From 2e5377d3ea50e665309085008f7c9ec8db5c8131 Mon Sep 17 00:00:00 2001 From: Filipe Ximenes Date: Thu, 5 Sep 2024 18:28:39 -0300 Subject: [PATCH 01/13] adds as_graph method to the ai assistant class to port --- django_ai_assistant/helpers/assistants.py | 65 +++++++++++++++++++++++ poetry.lock | 42 +++++++++++++-- pyproject.toml | 1 + 3 files changed, 104 insertions(+), 4 deletions(-) diff --git a/django_ai_assistant/helpers/assistants.py b/django_ai_assistant/helpers/assistants.py index 57910c7..54dcdd0 100644 --- a/django_ai_assistant/helpers/assistants.py +++ b/django_ai_assistant/helpers/assistants.py @@ -37,6 +37,7 @@ from langchain_core.runnables.history import RunnableWithMessageHistory from langchain_core.tools import BaseTool from langchain_openai import ChatOpenAI +from langgraph.graph import MessagesState from django_ai_assistant.decorators import with_cast_id from django_ai_assistant.exceptions import ( @@ -437,6 +438,70 @@ def get_history_aware_retriever(self) -> Runnable[dict, RetrieverOutput]: prompt | llm | StrOutputParser() | retriever, ) + @with_cast_id + def as_graph(self, thread_id: Any | None = None) -> Runnable[dict, dict]: + from langchain_core.messages import AIMessage + from langgraph.graph import END, StateGraph + from langgraph.prebuilt import ToolNode + + class AgentState(MessagesState): + response: str + + llm = self.get_llm() + tools = self.get_tools() + if tools: + llm_with_tools = llm.bind_tools(tools) + else: + llm_with_tools = llm + + def agent(state: AgentState): + prompt_template = ChatPromptTemplate.from_messages( + [ + ("system", self.instructions), + MessagesPlaceholder(variable_name="history"), + ] + ) + message_history = self.get_message_history(thread_id) + prompt = prompt_template.format( + history=message_history.messages + state["messages"], + ) + + response = llm_with_tools.invoke(prompt) + + return {"messages": [response]} + + def tool_selector(state: AgentState): + messages = state["messages"] + last_message = messages[-1] + + if isinstance(last_message, AIMessage) and len(last_message.tool_calls) > 0: + return "call_tool" + + return "continue" + + def record_response(state: AgentState): + return {"response": state["messages"][-1].content} + + workflow = StateGraph(AgentState) + + workflow.add_node("agent", agent) + workflow.add_node("tools", ToolNode(tools)) + workflow.add_node("respond", record_response) + + workflow.set_entry_point("agent") + workflow.add_edge("tools", "agent") + workflow.add_conditional_edges( + "agent", + tool_selector, + { + "call_tool": "tools", + "continue": "respond", + }, + ) + workflow.add_edge("respond", END) + + return workflow.compile() + @with_cast_id def as_chain(self, thread_id: Any | None) -> Runnable[dict, dict]: """Create the Langchain chain for the assistant.\n diff --git a/poetry.lock b/poetry.lock index 6abd5cf..b02684b 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1440,13 +1440,13 @@ tenacity = ">=8.1.0,<8.4.0 || >8.4.0,<9.0.0" [[package]] name = "langchain-core" -version = "0.2.11" +version = "0.2.38" description = "Building applications with LLMs through composability" optional = false python-versions = "<4.0,>=3.8.1" files = [ - {file = "langchain_core-0.2.11-py3-none-any.whl", hash = "sha256:c7ca4dc4d88e3c69fd7916c95a7027c2b1a11c2db5a51141c3ceb8afac212208"}, - {file = "langchain_core-0.2.11.tar.gz", hash = "sha256:7a4661b50604eeb20c3373fbfd8a4f1b74482a6ab4e0f9df11e96821ead8ef0c"}, + {file = "langchain_core-0.2.38-py3-none-any.whl", hash = "sha256:8a5729bc7e68b4af089af20eff44fe4e7ca21d0e0c87ec21cef7621981fd1a4a"}, + {file = "langchain_core-0.2.38.tar.gz", hash = "sha256:eb69dbedd344f2ee1f15bcea6c71a05884b867588fadc42d04632e727c1238f3"}, ] [package.dependencies] @@ -1459,6 +1459,7 @@ pydantic = [ ] PyYAML = ">=5.3" tenacity = ">=8.1.0,<8.4.0 || >8.4.0,<9.0.0" +typing-extensions = ">=4.7" [[package]] name = "langchain-openai" @@ -1490,6 +1491,35 @@ files = [ [package.dependencies] langchain-core = ">=0.2.10,<0.3.0" +[[package]] +name = "langgraph" +version = "0.2.16" +description = "Building stateful, multi-actor applications with LLMs" +optional = false +python-versions = "<4.0,>=3.9.0" +files = [ + {file = "langgraph-0.2.16-py3-none-any.whl", hash = "sha256:5c8d5d119b98c1c071f37f4d41f98a8a06c1b5c36345b7be8e0f65c7fbfa0297"}, + {file = "langgraph-0.2.16.tar.gz", hash = "sha256:435e2bff165d526236294eac03b5e848653ed4d9ba2373e36432e3ca3e952ebe"}, +] + +[package.dependencies] +langchain-core = ">=0.2.27,<0.3" +langgraph-checkpoint = ">=1.0.2,<2.0.0" + +[[package]] +name = "langgraph-checkpoint" +version = "1.0.8" +description = "Library with base interfaces for LangGraph checkpoint savers." +optional = false +python-versions = "<4.0.0,>=3.9.0" +files = [ + {file = "langgraph_checkpoint-1.0.8-py3-none-any.whl", hash = "sha256:6eb2fa615e9a53d5a4181d0a189ecc76b87cd1d5613e17121d2779755d2e566c"}, + {file = "langgraph_checkpoint-1.0.8.tar.gz", hash = "sha256:a528009d4ccebfd24da550fc8ccdd4de0a3c1077f30e2fcd32bddca4c9237e7f"}, +] + +[package.dependencies] +langchain-core = ">=0.2.22,<0.3" + [[package]] name = "langsmith" version = "0.1.83" @@ -2279,6 +2309,8 @@ files = [ {file = "orjson-3.10.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:960db0e31c4e52fa0fc3ecbaea5b2d3b58f379e32a95ae6b0ebeaa25b93dfd34"}, {file = "orjson-3.10.6-cp312-none-win32.whl", hash = "sha256:a6ea7afb5b30b2317e0bee03c8d34c8181bc5a36f2afd4d0952f378972c4efd5"}, {file = "orjson-3.10.6-cp312-none-win_amd64.whl", hash = "sha256:874ce88264b7e655dde4aeaacdc8fd772a7962faadfb41abe63e2a4861abc3dc"}, + {file = "orjson-3.10.6-cp313-none-win32.whl", hash = "sha256:efdf2c5cde290ae6b83095f03119bdc00303d7a03b42b16c54517baa3c4ca3d0"}, + {file = "orjson-3.10.6-cp313-none-win_amd64.whl", hash = "sha256:8e190fe7888e2e4392f52cafb9626113ba135ef53aacc65cd13109eb9746c43e"}, {file = "orjson-3.10.6-cp38-cp38-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:66680eae4c4e7fc193d91cfc1353ad6d01b4801ae9b5314f17e11ba55e934183"}, {file = "orjson-3.10.6-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:caff75b425db5ef8e8f23af93c80f072f97b4fb3afd4af44482905c9f588da28"}, {file = "orjson-3.10.6-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3722fddb821b6036fd2a3c814f6bd9b57a89dc6337b9924ecd614ebce3271394"}, @@ -2834,6 +2866,7 @@ files = [ {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, + {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"}, {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, @@ -3599,6 +3632,7 @@ description = "Automatically mock your HTTP interactions to simplify and speed u optional = false python-versions = ">=3.8" files = [ + {file = "vcrpy-6.0.1-py2.py3-none-any.whl", hash = "sha256:621c3fb2d6bd8aa9f87532c688e4575bcbbde0c0afeb5ebdb7e14cac409edfdd"}, {file = "vcrpy-6.0.1.tar.gz", hash = "sha256:9e023fee7f892baa0bbda2f7da7c8ac51165c1c6e38ff8688683a12a4bde9278"}, ] @@ -3914,4 +3948,4 @@ test = ["big-O", "importlib-resources", "jaraco.functools", "jaraco.itertools", [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "1d151debf18974c6c411c502806e880b985ec4b050ece8dfef87c93cc7ec0bd2" +content-hash = "12be93152db28673d01f8e97da0e1c19c9194ad20fb60ba8035baab5b26fefb5" diff --git a/pyproject.toml b/pyproject.toml index 47cbaba..6089f65 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,6 +49,7 @@ pydantic = "^2.7.1" django-ninja = "^1.1.0" langchain = "^0.2.1" langchain-openai = "^0.1.8" +langgraph = "^0.2.16" [tool.poetry.group.dev.dependencies] coverage = "^7.2.7" From e35652855f116fa5cfb984618b537aff6b33079a Mon Sep 17 00:00:00 2001 From: Filipe Ximenes Date: Fri, 6 Sep 2024 18:53:53 -0300 Subject: [PATCH 02/13] adds rag capability to as_graph flow --- django_ai_assistant/helpers/assistants.py | 55 ++++++++++++++++------- 1 file changed, 38 insertions(+), 17 deletions(-) diff --git a/django_ai_assistant/helpers/assistants.py b/django_ai_assistant/helpers/assistants.py index 54dcdd0..76c1696 100644 --- a/django_ai_assistant/helpers/assistants.py +++ b/django_ai_assistant/helpers/assistants.py @@ -17,6 +17,7 @@ InMemoryChatMessageHistory, ) from langchain_core.language_models import BaseChatModel +from langchain_core.messages import AIMessage from langchain_core.output_parsers import StrOutputParser from langchain_core.prompts import ( ChatPromptTemplate, @@ -37,7 +38,8 @@ from langchain_core.runnables.history import RunnableWithMessageHistory from langchain_core.tools import BaseTool from langchain_openai import ChatOpenAI -from langgraph.graph import MessagesState +from langgraph.graph import END, MessagesState, StateGraph +from langgraph.prebuilt import ToolNode from django_ai_assistant.decorators import with_cast_id from django_ai_assistant.exceptions import ( @@ -440,19 +442,30 @@ def get_history_aware_retriever(self) -> Runnable[dict, RetrieverOutput]: @with_cast_id def as_graph(self, thread_id: Any | None = None) -> Runnable[dict, dict]: - from langchain_core.messages import AIMessage - from langgraph.graph import END, StateGraph - from langgraph.prebuilt import ToolNode - class AgentState(MessagesState): - response: str + input: str # noqa: A003 + context: str + output: str llm = self.get_llm() tools = self.get_tools() - if tools: - llm_with_tools = llm.bind_tools(tools) - else: - llm_with_tools = llm + llm_with_tools = llm.bind_tools(tools) if tools else llm + + def retriever(state: AgentState): + if not self.has_rag: + return + + retriever = self.get_history_aware_retriever() + docs = retriever.invoke({"input": state["input"], "history": state["messages"][:-1]}) + + document_separator = self.get_document_separator() + document_prompt = self.get_document_prompt() + + formatted_docs = document_separator.join( + format_document(doc, document_prompt) for doc in docs + ) + + return {"messages": [state["input"]], "context": formatted_docs} def agent(state: AgentState): prompt_template = ChatPromptTemplate.from_messages( @@ -461,11 +474,17 @@ def agent(state: AgentState): MessagesPlaceholder(variable_name="history"), ] ) + message_history = self.get_message_history(thread_id) - prompt = prompt_template.format( - history=message_history.messages + state["messages"], - ) + prompt_variables: dict[str, Any] = { + "history": message_history.messages + state["messages"] + } + + if self.has_rag: + context_placeholder = self.get_context_placeholder() + prompt_variables[context_placeholder] = state.get("context", "") + prompt = prompt_template.format(**prompt_variables) response = llm_with_tools.invoke(prompt) return {"messages": [response]} @@ -474,22 +493,23 @@ def tool_selector(state: AgentState): messages = state["messages"] last_message = messages[-1] - if isinstance(last_message, AIMessage) and len(last_message.tool_calls) > 0: + if isinstance(last_message, AIMessage) and last_message.tool_calls: return "call_tool" return "continue" def record_response(state: AgentState): - return {"response": state["messages"][-1].content} + return {"output": state["messages"][-1].content} workflow = StateGraph(AgentState) + workflow.add_node("retriever", retriever) workflow.add_node("agent", agent) workflow.add_node("tools", ToolNode(tools)) workflow.add_node("respond", record_response) - workflow.set_entry_point("agent") - workflow.add_edge("tools", "agent") + workflow.set_entry_point("retriever") + workflow.add_edge("retriever", "agent") workflow.add_conditional_edges( "agent", tool_selector, @@ -498,6 +518,7 @@ def record_response(state: AgentState): "continue": "respond", }, ) + workflow.add_edge("tools", "agent") workflow.add_edge("respond", END) return workflow.compile() From 3aee3063d257d903a558b857089c82d939c4cd57 Mon Sep 17 00:00:00 2001 From: Filipe Ximenes Date: Mon, 9 Sep 2024 18:28:20 -0300 Subject: [PATCH 03/13] adds support for RAG to the assistant as_graph implementation --- django_ai_assistant/helpers/assistants.py | 46 +++++++++++++------ .../langchain/chat_message_histories.py | 14 +++++- example/tour_guide/ai_assistants.py | 27 +++++------ 3 files changed, 55 insertions(+), 32 deletions(-) diff --git a/django_ai_assistant/helpers/assistants.py b/django_ai_assistant/helpers/assistants.py index 76c1696..40a7a25 100644 --- a/django_ai_assistant/helpers/assistants.py +++ b/django_ai_assistant/helpers/assistants.py @@ -1,7 +1,7 @@ import abc import inspect import re -from typing import Any, ClassVar, Sequence, cast +from typing import Annotated, Any, ClassVar, Sequence, TypedDict, cast from langchain.agents import AgentExecutor from langchain.agents.format_scratchpad.tools import ( @@ -17,7 +17,7 @@ InMemoryChatMessageHistory, ) from langchain_core.language_models import BaseChatModel -from langchain_core.messages import AIMessage +from langchain_core.messages import AIMessage, AnyMessage, BaseMessage, HumanMessage from langchain_core.output_parsers import StrOutputParser from langchain_core.prompts import ( ChatPromptTemplate, @@ -38,7 +38,8 @@ from langchain_core.runnables.history import RunnableWithMessageHistory from langchain_core.tools import BaseTool from langchain_openai import ChatOpenAI -from langgraph.graph import END, MessagesState, StateGraph +from langgraph.graph import END, StateGraph +from langgraph.graph.message import add_messages from langgraph.prebuilt import ToolNode from django_ai_assistant.decorators import with_cast_id @@ -442,7 +443,19 @@ def get_history_aware_retriever(self) -> Runnable[dict, RetrieverOutput]: @with_cast_id def as_graph(self, thread_id: Any | None = None) -> Runnable[dict, dict]: - class AgentState(MessagesState): + message_history = self.get_message_history(thread_id) + + def custom_add_messages(left: list[BaseMessage], right: list[BaseMessage]): + if thread_id is None: + return add_messages(left, right) + + message_history.add_messages(right) + messages = message_history.messages + + return messages + + class AgentState(TypedDict): + messages: Annotated[list[AnyMessage], custom_add_messages] input: str # noqa: A003 context: str output: str @@ -451,12 +464,15 @@ class AgentState(MessagesState): tools = self.get_tools() llm_with_tools = llm.bind_tools(tools) if tools else llm + def setup(state: AgentState): + return {"messages": [HumanMessage(content=state["input"])]} + def retriever(state: AgentState): if not self.has_rag: return retriever = self.get_history_aware_retriever() - docs = retriever.invoke({"input": state["input"], "history": state["messages"][:-1]}) + docs = retriever.invoke({"input": state["input"], "history": state["messages"]}) document_separator = self.get_document_separator() document_prompt = self.get_document_prompt() @@ -465,33 +481,30 @@ def retriever(state: AgentState): format_document(doc, document_prompt) for doc in docs ) - return {"messages": [state["input"]], "context": formatted_docs} + return {"context": formatted_docs} def agent(state: AgentState): prompt_template = ChatPromptTemplate.from_messages( [ - ("system", self.instructions), + ("system", self.get_instructions()), MessagesPlaceholder(variable_name="history"), ] ) - message_history = self.get_message_history(thread_id) - prompt_variables: dict[str, Any] = { - "history": message_history.messages + state["messages"] - } + prompt_variables: dict[str, Any] = {"history": state["messages"]} if self.has_rag: context_placeholder = self.get_context_placeholder() prompt_variables[context_placeholder] = state.get("context", "") prompt = prompt_template.format(**prompt_variables) + response = llm_with_tools.invoke(prompt) return {"messages": [response]} def tool_selector(state: AgentState): - messages = state["messages"] - last_message = messages[-1] + last_message = state["messages"][-1] if isinstance(last_message, AIMessage) and last_message.tool_calls: return "call_tool" @@ -503,12 +516,14 @@ def record_response(state: AgentState): workflow = StateGraph(AgentState) + workflow.add_node("setup", setup) workflow.add_node("retriever", retriever) workflow.add_node("agent", agent) workflow.add_node("tools", ToolNode(tools)) workflow.add_node("respond", record_response) - workflow.set_entry_point("retriever") + workflow.set_entry_point("setup") + workflow.add_edge("setup", "retriever") workflow.add_edge("retriever", "agent") workflow.add_conditional_edges( "agent", @@ -625,7 +640,8 @@ def invoke(self, *args: Any, thread_id: Any | None, **kwargs: Any) -> dict: dict: The output of the assistant chain, structured like `{"output": "assistant response", "history": ...}`. """ - chain = self.as_chain(thread_id) + # chain = self.as_chain(thread_id) + chain = self.as_graph(thread_id) return chain.invoke(*args, **kwargs) @with_cast_id diff --git a/django_ai_assistant/langchain/chat_message_histories.py b/django_ai_assistant/langchain/chat_message_histories.py index 42b6aa9..21ae717 100644 --- a/django_ai_assistant/langchain/chat_message_histories.py +++ b/django_ai_assistant/langchain/chat_message_histories.py @@ -12,7 +12,11 @@ from django.db import transaction from langchain_core.chat_history import BaseChatMessageHistory -from langchain_core.messages import BaseMessage, message_to_dict, messages_from_dict +from langchain_core.messages import ( + BaseMessage, + message_to_dict, + messages_from_dict, +) from django_ai_assistant.decorators import with_cast_id from django_ai_assistant.models import Message @@ -73,8 +77,14 @@ def add_messages(self, messages: Sequence[BaseMessage]) -> None: messages: A list of BaseMessage objects to store. """ with transaction.atomic(): + existing_messages = self.messages + created_messages = Message.objects.bulk_create( - [Message(thread_id=self._thread_id, message=dict()) for message in messages] + [ + Message(thread_id=self._thread_id, message=dict()) + for message in messages + if message not in existing_messages + ] ) # Update langchain message IDs with Django message IDs diff --git a/example/tour_guide/ai_assistants.py b/example/tour_guide/ai_assistants.py index 51297ed..0678526 100644 --- a/example/tour_guide/ai_assistants.py +++ b/example/tour_guide/ai_assistants.py @@ -34,14 +34,9 @@ class TourGuideAIAssistant(AIAssistant): name = "Tour Guide Assistant" instructions = ( "You are a tour guide assistant that offers information about nearby attractions. " - "The application will capture the user coordinates, and should provide a list of nearby attractions. " - "Use the available tools to suggest nearby attractions to the user. " - "You don't need to include all the found items, only include attractions that are relevant for a tourist. " - "Select the top 10 best attractions for a tourist, if there are less then 10 relevant items only return these. " - "Order items by the most relevant to the least relevant. " - "If there are no relevant attractions nearby, just keep the list empty. " - "Your response will be integrated with a frontend web application therefore it's critical that " - "it only contains a valid JSON. DON'T include '```json' in your response. " + "The application will pass the user coordinates, and should use available tools to find attractions nearby. " + "Only call the find_nearby_attractions tool once. " + "Your response should only contain valid JSON data. DON'T include '```json' in your response. " "The JSON should be formatted according to the following structure: \n" f"\n\n{_tour_guide_example_json()}\n\n\n" "In the 'attraction_name' field provide the name of the attraction in english. " @@ -49,7 +44,7 @@ class TourGuideAIAssistant(AIAssistant): "curiosities and interesting facts. " "Only include a value for the 'attraction_url' field if you find a real value in the provided data otherwise keep it empty. " ) - model = "gpt-4o" + model = "gpt-4o-2024-08-06" def get_instructions(self): # Warning: this will use the server's timezone @@ -60,11 +55,13 @@ def get_instructions(self): return f"Today is: {current_date_str}. {self.instructions}" @method_tool - def get_nearby_attractions_from_api(self, latitude: float, longitude: float) -> dict: + def find_nearby_attractions(self, latitude: float, longitude: float) -> str: """Find nearby attractions based on user's current location.""" - return fetch_points_of_interest( - latitude=latitude, - longitude=longitude, - tags=["tourism", "leisure", "place", "building"], - radius=500, + return json.dumps( + fetch_points_of_interest( + latitude=latitude, + longitude=longitude, + tags=["tourism", "leisure", "place", "building"], + radius=500, + ) ) From ad4320f6d24ff6a593d7fa002f7b3dc66fcc2354 Mon Sep 17 00:00:00 2001 From: Filipe Ximenes Date: Tue, 10 Sep 2024 12:03:46 -0300 Subject: [PATCH 04/13] fixes DjangoChatMessageHistory add_message --- django_ai_assistant/helpers/assistants.py | 2 +- .../langchain/chat_message_histories.py | 10 ++++------ 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/django_ai_assistant/helpers/assistants.py b/django_ai_assistant/helpers/assistants.py index 40a7a25..8c31ba2 100644 --- a/django_ai_assistant/helpers/assistants.py +++ b/django_ai_assistant/helpers/assistants.py @@ -465,7 +465,7 @@ class AgentState(TypedDict): llm_with_tools = llm.bind_tools(tools) if tools else llm def setup(state: AgentState): - return {"messages": [HumanMessage(content=state["input"])]} + return {"messages": [*message_history.messages, HumanMessage(content=state["input"])]} def retriever(state: AgentState): if not self.has_rag: diff --git a/django_ai_assistant/langchain/chat_message_histories.py b/django_ai_assistant/langchain/chat_message_histories.py index 21ae717..25459ed 100644 --- a/django_ai_assistant/langchain/chat_message_histories.py +++ b/django_ai_assistant/langchain/chat_message_histories.py @@ -79,17 +79,15 @@ def add_messages(self, messages: Sequence[BaseMessage]) -> None: with transaction.atomic(): existing_messages = self.messages + messages_to_create = [m for m in messages if m not in existing_messages] + created_messages = Message.objects.bulk_create( - [ - Message(thread_id=self._thread_id, message=dict()) - for message in messages - if message not in existing_messages - ] + [Message(thread_id=self._thread_id, message=dict()) for _ in messages_to_create] ) # Update langchain message IDs with Django message IDs for idx, created_message in enumerate(created_messages): - message_with_id = messages[idx] + message_with_id = messages_to_create[idx] message_with_id.id = str(created_message.id) created_message.message = message_to_dict(message_with_id) From 79d6d13edb5d19c976295505b5724aa8094817cb Mon Sep 17 00:00:00 2001 From: Filipe Ximenes Date: Tue, 10 Sep 2024 15:29:23 -0300 Subject: [PATCH 05/13] only stores messages that are from humam or from the AI as long as it's not a tool call --- django_ai_assistant/helpers/assistants.py | 20 ++++++++++++------- .../langchain/chat_message_histories.py | 8 ++++++-- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/django_ai_assistant/helpers/assistants.py b/django_ai_assistant/helpers/assistants.py index 8c31ba2..20967c9 100644 --- a/django_ai_assistant/helpers/assistants.py +++ b/django_ai_assistant/helpers/assistants.py @@ -17,7 +17,7 @@ InMemoryChatMessageHistory, ) from langchain_core.language_models import BaseChatModel -from langchain_core.messages import AIMessage, AnyMessage, BaseMessage, HumanMessage +from langchain_core.messages import AIMessage, AnyMessage, BaseMessage, ChatMessage, HumanMessage from langchain_core.output_parsers import StrOutputParser from langchain_core.prompts import ( ChatPromptTemplate, @@ -446,13 +446,19 @@ def as_graph(self, thread_id: Any | None = None) -> Runnable[dict, dict]: message_history = self.get_message_history(thread_id) def custom_add_messages(left: list[BaseMessage], right: list[BaseMessage]): - if thread_id is None: - return add_messages(left, right) - - message_history.add_messages(right) - messages = message_history.messages + result = add_messages(left, right) + + if thread_id: + # We only want to store human and ai messages that are not tool calls + messages_to_store = [ + m + for m in result + if isinstance(m, HumanMessage | ChatMessage) + or (isinstance(m, AIMessage) and not m.tool_calls) + ] + message_history.add_messages(messages_to_store) - return messages + return result class AgentState(TypedDict): messages: Annotated[list[AnyMessage], custom_add_messages] diff --git a/django_ai_assistant/langchain/chat_message_histories.py b/django_ai_assistant/langchain/chat_message_histories.py index 25459ed..04d51ce 100644 --- a/django_ai_assistant/langchain/chat_message_histories.py +++ b/django_ai_assistant/langchain/chat_message_histories.py @@ -99,15 +99,19 @@ async def aadd_messages(self, messages: Sequence[BaseMessage]) -> None: Args: messages: A list of BaseMessage objects to store. """ + existing_messages = self.messages + + messages_to_create = [m for m in messages if m not in existing_messages] + # NOTE: This method does not use transactions because it do not yet work in async mode. # Source: https://docs.djangoproject.com/en/5.0/topics/async/#queries-the-orm created_messages = await Message.objects.abulk_create( - [Message(thread_id=self._thread_id, message=dict()) for message in messages] + [Message(thread_id=self._thread_id, message=dict()) for _ in messages_to_create] ) # Update langchain message IDs with Django message IDs for idx, created_message in enumerate(created_messages): - message_with_id = messages[idx] + message_with_id = messages_to_create[idx] message_with_id.id = str(created_message.id) created_message.message = message_to_dict(message_with_id) From 6de74c2ef6ba5b10e0e971f02f92a6bc674f264b Mon Sep 17 00:00:00 2001 From: Filipe Ximenes Date: Tue, 10 Sep 2024 16:13:06 -0300 Subject: [PATCH 06/13] improves tour_guide assistant query --- example/tour_guide/ai_assistants.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/example/tour_guide/ai_assistants.py b/example/tour_guide/ai_assistants.py index 0678526..e44840f 100644 --- a/example/tour_guide/ai_assistants.py +++ b/example/tour_guide/ai_assistants.py @@ -34,7 +34,7 @@ class TourGuideAIAssistant(AIAssistant): name = "Tour Guide Assistant" instructions = ( "You are a tour guide assistant that offers information about nearby attractions. " - "The application will pass the user coordinates, and should use available tools to find attractions nearby. " + "You will receive the user coordinates and should use available tools to find nearby attractions. " "Only call the find_nearby_attractions tool once. " "Your response should only contain valid JSON data. DON'T include '```json' in your response. " "The JSON should be formatted according to the following structure: \n" @@ -56,7 +56,12 @@ def get_instructions(self): @method_tool def find_nearby_attractions(self, latitude: float, longitude: float) -> str: - """Find nearby attractions based on user's current location.""" + """ + Find nearby attractions based on user's current location. + Returns a JSON with the list of all types of points of interest, + which may or may not include attractions. + Calls to this tool are idempotent. + """ return json.dumps( fetch_points_of_interest( latitude=latitude, From 59c8950de22877edd20cc39643342eca13a60dba Mon Sep 17 00:00:00 2001 From: Filipe Ximenes Date: Tue, 10 Sep 2024 19:13:45 -0300 Subject: [PATCH 07/13] reorganizes graph so the retriver node is self contained --- django_ai_assistant/helpers/assistants.py | 45 ++++++++++++----------- 1 file changed, 23 insertions(+), 22 deletions(-) diff --git a/django_ai_assistant/helpers/assistants.py b/django_ai_assistant/helpers/assistants.py index 20967c9..a11faef 100644 --- a/django_ai_assistant/helpers/assistants.py +++ b/django_ai_assistant/helpers/assistants.py @@ -12,12 +12,20 @@ DEFAULT_DOCUMENT_PROMPT, DEFAULT_DOCUMENT_SEPARATOR, ) +from langchain.tools import StructuredTool from langchain_core.chat_history import ( BaseChatMessageHistory, InMemoryChatMessageHistory, ) from langchain_core.language_models import BaseChatModel -from langchain_core.messages import AIMessage, AnyMessage, BaseMessage, ChatMessage, HumanMessage +from langchain_core.messages import ( + AIMessage, + AnyMessage, + BaseMessage, + ChatMessage, + HumanMessage, + SystemMessage, +) from langchain_core.output_parsers import StrOutputParser from langchain_core.prompts import ( ChatPromptTemplate, @@ -46,7 +54,6 @@ from django_ai_assistant.exceptions import ( AIAssistantMisconfiguredError, ) -from django_ai_assistant.langchain.tools import Tool from django_ai_assistant.langchain.tools import tool as tool_decorator @@ -471,7 +478,7 @@ class AgentState(TypedDict): llm_with_tools = llm.bind_tools(tools) if tools else llm def setup(state: AgentState): - return {"messages": [*message_history.messages, HumanMessage(content=state["input"])]} + return {"messages": [SystemMessage(content=self.get_instructions())]} def retriever(state: AgentState): if not self.has_rag: @@ -487,25 +494,17 @@ def retriever(state: AgentState): format_document(doc, document_prompt) for doc in docs ) - return {"context": formatted_docs} - - def agent(state: AgentState): - prompt_template = ChatPromptTemplate.from_messages( - [ - ("system", self.get_instructions()), - MessagesPlaceholder(variable_name="history"), - ] - ) - - prompt_variables: dict[str, Any] = {"history": state["messages"]} - - if self.has_rag: - context_placeholder = self.get_context_placeholder() - prompt_variables[context_placeholder] = state.get("context", "") + return { + "messages": SystemMessage( + content=f"---START OF CONTEXT---\n{formatted_docs}---END OF CONTEXT---\n" + ) + } - prompt = prompt_template.format(**prompt_variables) + def history(state: AgentState): + return {"messages": [*message_history.messages, HumanMessage(content=state["input"])]} - response = llm_with_tools.invoke(prompt) + def agent(state: AgentState): + response = llm_with_tools.invoke(state["messages"]) return {"messages": [response]} @@ -524,13 +523,15 @@ def record_response(state: AgentState): workflow.add_node("setup", setup) workflow.add_node("retriever", retriever) + workflow.add_node("history", history) workflow.add_node("agent", agent) workflow.add_node("tools", ToolNode(tools)) workflow.add_node("respond", record_response) workflow.set_entry_point("setup") workflow.add_edge("setup", "retriever") - workflow.add_edge("retriever", "agent") + workflow.add_edge("retriever", "history") + workflow.add_edge("history", "agent") workflow.add_conditional_edges( "agent", tool_selector, @@ -685,7 +686,7 @@ def as_tool(self, description: str) -> BaseTool: Returns: BaseTool: A tool that runs the assistant. The tool name is this assistant's id. """ - return Tool.from_function( + return StructuredTool.from_function( func=self._run_as_tool, name=self.id, description=description, From 34056e9c74a2668ed73db03ed78aefc9e13e4d5a Mon Sep 17 00:00:00 2001 From: Filipe Ximenes Date: Wed, 11 Sep 2024 11:03:27 -0300 Subject: [PATCH 08/13] fixes tests to work with as_graph --- .../langchain/chat_message_histories.py | 4 +- .../test_create_thread_message.yaml | 125 +++++++++ .../test_delete_thread_message.yaml | 125 +++++++++ .../test_AIAssistant_invoke.yaml | 254 ++++++++++++++++++ .../test_AIAssistant_with_rag_invoke.yaml | 248 +++++++++++++++++ .../test_use_cases/test_create_message.yaml | 126 +++++++++ tests/test_helpers/test_assistants.py | 89 +++--- tests/test_helpers/test_use_cases.py | 10 +- tests/test_views.py | 2 +- 9 files changed, 931 insertions(+), 52 deletions(-) diff --git a/django_ai_assistant/langchain/chat_message_histories.py b/django_ai_assistant/langchain/chat_message_histories.py index 04d51ce..b0c1e3d 100644 --- a/django_ai_assistant/langchain/chat_message_histories.py +++ b/django_ai_assistant/langchain/chat_message_histories.py @@ -77,7 +77,7 @@ def add_messages(self, messages: Sequence[BaseMessage]) -> None: messages: A list of BaseMessage objects to store. """ with transaction.atomic(): - existing_messages = self.messages + existing_messages = self.get_messages() messages_to_create = [m for m in messages if m not in existing_messages] @@ -99,7 +99,7 @@ async def aadd_messages(self, messages: Sequence[BaseMessage]) -> None: Args: messages: A list of BaseMessage objects to store. """ - existing_messages = self.messages + existing_messages = await self.aget_messages() messages_to_create = [m for m in messages if m not in existing_messages] diff --git a/tests/cassettes/test_views/test_create_thread_message.yaml b/tests/cassettes/test_views/test_create_thread_message.yaml index 89f3d9f..3e0320f 100644 --- a/tests/cassettes/test_views/test_create_thread_message.yaml +++ b/tests/cassettes/test_views/test_create_thread_message.yaml @@ -193,4 +193,129 @@ interactions: status: code: 200 message: OK +- request: + body: '{"messages": [{"content": "You are a temperature bot. Today is 2024-06-09.", + "role": "system"}, {"content": "Hello, what is the temperature in SF right now?", + "role": "user"}], "model": "gpt-4o", "n": 1, "stream": false, "temperature": + 1.0, "tools": [{"type": "function", "function": {"name": "fetch_current_temperature", + "description": "Fetch the current temperature data for a location", "parameters": + {"type": "object", "properties": {"location": {"type": "string"}}, "required": + ["location"]}}}, {"type": "function", "function": {"name": "fetch_forecast_temperature", + "description": "Fetch the forecast temperature data for a location", "parameters": + {"type": "object", "properties": {"location": {"type": "string"}, "dt_str": + {"description": "Date in the format ''YYYY-MM-DD''", "type": "string"}}, "required": + ["location", "dt_str"]}}}]}' + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate + authorization: + - DUMMY + connection: + - keep-alive + content-length: + - '841' + content-type: + - application/json + host: + - api.openai.com + user-agent: + - OpenAI/Python + method: POST + uri: https://api.openai.com/v1/chat/completions + response: + body: + string: !!binary | + H4sIAAAAAAAAAwAAAP//bFJNj5swFLzzK6x3DhWQwO5yS3eXrdRLtZWqqE2FXpwHofVXbVOVRvnv + FR9J2KgcLGuGmXke+xgwBs0ecgb8gJ5LI8J19iH6+LXT6lVEu/i56Db1+91v3CR32UbAolfo3Q/i + /qx6x7U0gnyj1UhzS+ipd43vkixK77OHbCCk3pPoZbXx4UqHSZSswigN4+UkPOiGk4OcfQsYY+w4 + rP2Iak9/IGfR4oxIcg5rgvzyE2NgtegRQOca51F5WFxJrpUn1U+tWiFmhNdalByFuAaP33G2v/aE + QpTpy6sqvsju+Qn1g335Zf4ma1N8KmZ5o3VnhoGqVvFLPzP+guc3YYyBQjlqyfNDyVtrSfnSkzRk + 0beWbswYA7R1K0n5/iBw3ILQHHv7LeRb+IyKFRYVbxzXC/a43sIJ3jicgv/tv8+qslS1DsXU4YSf + LpcidG2s3rmbjqFqVOMOpSV0w1nnlQfntCEH2je3CsZqaXzp9U9SvW0cr0ZXuD66GXs/kV57FDN8 + mQTTnOA650mWVaNqssY2w4uAypRJmiUrpCWmEJyCfwAAAP//AwDHlhryGgMAAA== + headers: + Connection: + - keep-alive + Content-Encoding: + - gzip + Content-Type: + - application/json + Date: Sun, 09 Jun 2024 23:39:08 GMT + Server: DUMMY + Transfer-Encoding: + - chunked + X-Content-Type-Options: + - nosniff + access-control-expose-headers: + - X-Request-ID + status: + code: 200 + message: OK +- request: + body: '{"messages": [{"content": "You are a temperature bot. Today is 2024-06-09.", + "role": "system"}, {"content": "Hello, what is the temperature in SF right now?", + "role": "user"}, {"content": null, "role": "assistant", "tool_calls": [{"type": + "function", "id": "call_5GRnFVmyEDao9rGqpz2ApFPF", "function": {"name": "fetch_current_temperature", + "arguments": "{\"location\": \"San Francisco, CA\"}"}}]}, {"content": "32 degrees + Celsius", "role": "tool", "tool_call_id": "call_5GRnFVmyEDao9rGqpz2ApFPF"}], + "model": "gpt-4o", "n": 1, "stream": false, "temperature": 1.0, "tools": [{"type": + "function", "function": {"name": "fetch_current_temperature", "description": + "Fetch the current temperature data for a location", "parameters": {"type": + "object", "properties": {"location": {"type": "string"}}, "required": ["location"]}}}, + {"type": "function", "function": {"name": "fetch_forecast_temperature", "description": + "Fetch the forecast temperature data for a location", "parameters": {"type": + "object", "properties": {"location": {"type": "string"}, "dt_str": {"description": + "Date in the format ''YYYY-MM-DD''", "type": "string"}}, "required": ["location", + "dt_str"]}}}]}' + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate + authorization: + - DUMMY + connection: + - keep-alive + content-length: + - '1163' + content-type: + - application/json + cookie: + - DUMMY + host: + - api.openai.com + user-agent: + - OpenAI/Python + method: POST + uri: https://api.openai.com/v1/chat/completions + response: + body: + string: !!binary | + H4sIAAAAAAAAAwAAAP//VJDNbsIwEITveYqVzwkKIQHKjaK2VOJQFW5VhYzZELeJ17I3EgjxTn2G + PlmVEH568WE+z2h2jgGA0FsxAaEKyaqyZTQdzuPF5mWVL5ZyeiiyVZzu5evj0+GN3ucibBy0+ULF + F1dPUWVLZE3mjJVDydik9kfJMM7Gw4dRCyraYtnYdpajlKIkTtIozqL+oDMWpBV6MYGPAADg2L5N + RbPFvZhAHF6UCr2XOxST6ycA4ahsFCG9156lYRHeoCLDaNrWqwJB1c6hYWCsLDrJtUPQBpbSwLOT + RmmvKITZFLSHQfL7M+vdhznMay+bW0xdlp1+urYraWcdbXzHr3qujfbF2qH0ZJomnsmKlp4CgM92 + hfrfYcI6qiyvmb7RNIH9ND3nidvu97SDTCzLOz0bB11D4Q+esVrn2uzQWafPo+R2nWTDJJU4kJkI + TsEfAAAA//8DAFmF1AcdAgAA + headers: + Connection: + - keep-alive + Content-Encoding: + - gzip + Content-Type: + - application/json + Date: Sun, 09 Jun 2024 23:39:08 GMT + Server: DUMMY + Transfer-Encoding: + - chunked + X-Content-Type-Options: + - nosniff + access-control-expose-headers: + - X-Request-ID + status: + code: 200 + message: OK version: 1 diff --git a/tests/cassettes/test_views/test_delete_thread_message.yaml b/tests/cassettes/test_views/test_delete_thread_message.yaml index d5d75d4..cd8a59a 100644 --- a/tests/cassettes/test_views/test_delete_thread_message.yaml +++ b/tests/cassettes/test_views/test_delete_thread_message.yaml @@ -193,4 +193,129 @@ interactions: status: code: 200 message: OK +- request: + body: '{"messages": [{"content": "You are a temperature bot. Today is 2024-06-09.", + "role": "system"}, {"content": "Hello, what is the temperature in SF right now?", + "role": "user"}], "model": "gpt-4o", "n": 1, "stream": false, "temperature": + 1.0, "tools": [{"type": "function", "function": {"name": "fetch_current_temperature", + "description": "Fetch the current temperature data for a location", "parameters": + {"type": "object", "properties": {"location": {"type": "string"}}, "required": + ["location"]}}}, {"type": "function", "function": {"name": "fetch_forecast_temperature", + "description": "Fetch the forecast temperature data for a location", "parameters": + {"type": "object", "properties": {"location": {"type": "string"}, "dt_str": + {"description": "Date in the format ''YYYY-MM-DD''", "type": "string"}}, "required": + ["location", "dt_str"]}}}]}' + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate + authorization: + - DUMMY + connection: + - keep-alive + content-length: + - '841' + content-type: + - application/json + host: + - api.openai.com + user-agent: + - OpenAI/Python + method: POST + uri: https://api.openai.com/v1/chat/completions + response: + body: + string: !!binary | + H4sIAAAAAAAAA2xSXWvbMBR9968Q9zkejuOkmd+SQbsW+jFGS8cyjKpc29r0hXQNDSH/vfgjiRvm + ByHO8Tnn6kj7iDGQW8gZiJqT0E7Fq8X35J6qm3XlQnr7+LKWr4/16/PtnUpLDZNWYd/+oqCj6ouw + 2ikkaU1PC4+csHWdXqWLZL5cfF12hLZbVK2schRnNk6TNIuTeTydDcLaSoEBcvY7Yoyxfbe2I5ot + vkPOkskR0RgCrxDy00+MgbeqRYCHIANxQzA5k8IaQtNObRqlRgRZqwrBlToH999+tD/3xJUqrnf2 + nSfu4cfTw2q9usnWv8L2rvL3o7zeeue6gcrGiFM/I/6E5xdhjIHhutciiboQjfdoqCDUDj2nxuOF + GWPAfdVoNNQeBPYbUFbw1n4D+QZ+csOuPTdCBmEn7NtqAwf45HCI/rf/M6rKY9kEroYOB/xwuhRl + K+ftW7joGEppZKgLjzx0Zx1XHh3TuhxoPt0qOG+1o4LsPzSt7XSa9a5wfnQjdjmQZImrET5Lo2FO + CLtAqItSmgq987J7EVC6Ip0v0ozjjM8hOkQfAAAA//8DADocc08aAwAA + headers: + Connection: + - keep-alive + Content-Encoding: + - gzip + Content-Type: + - application/json + Date: Sun, 09 Jun 2024 23:39:08 GMT + Server: DUMMY + Transfer-Encoding: + - chunked + X-Content-Type-Options: + - nosniff + access-control-expose-headers: + - X-Request-ID + status: + code: 200 + message: OK +- request: + body: '{"messages": [{"content": "You are a temperature bot. Today is 2024-06-09.", + "role": "system"}, {"content": "Hello, what is the temperature in SF right now?", + "role": "user"}, {"content": null, "role": "assistant", "tool_calls": [{"type": + "function", "id": "call_Fyoxa0pNQPNABAG4BYsdJgrM", "function": {"name": "fetch_current_temperature", + "arguments": "{\"location\": \"San Francisco, CA\"}"}}]}, {"content": "32 degrees + Celsius", "role": "tool", "tool_call_id": "call_Fyoxa0pNQPNABAG4BYsdJgrM"}], + "model": "gpt-4o", "n": 1, "stream": false, "temperature": 1.0, "tools": [{"type": + "function", "function": {"name": "fetch_current_temperature", "description": + "Fetch the current temperature data for a location", "parameters": {"type": + "object", "properties": {"location": {"type": "string"}}, "required": ["location"]}}}, + {"type": "function", "function": {"name": "fetch_forecast_temperature", "description": + "Fetch the forecast temperature data for a location", "parameters": {"type": + "object", "properties": {"location": {"type": "string"}, "dt_str": {"description": + "Date in the format ''YYYY-MM-DD''", "type": "string"}}, "required": ["location", + "dt_str"]}}}]}' + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate + authorization: + - DUMMY + connection: + - keep-alive + content-length: + - '1163' + content-type: + - application/json + cookie: + - DUMMY + host: + - api.openai.com + user-agent: + - OpenAI/Python + method: POST + uri: https://api.openai.com/v1/chat/completions + response: + body: + string: !!binary | + H4sIAAAAAAAAA1SRS2vDMBCE7/4Vi85xcBw7r1swlFJoe2hoS0sJirJ21MiS0K6hJeS/FzvOoxcd + 5tsZZleHCEDorViAUDvJqvYmXk7uk8fVtHr2WnERij1tZu8Pb0+vH7hvxKB1uM03Kj67hsrV3iBr + Z09YBZSMbepomk6SfDaZzzpQuy2a1lZ5jjMXp0maxUkej8a9cee0QhIL+IwAAA7d21a0W/wRC0gG + Z6VGIlmhWFyGAERwplWEJNLE0rIYXKFyltF2rVc7BNWEgJaBsfYYJDcBQVt4kRbugrRKk3IDKJag + CcYpbLEKiAQFGtINDW+TA5YNyXYx2xjT68dLVeMqH9yGen7RS2017dYBJTnb1iJ2XnT0GAF8dSdp + /m0pfHC15zW7Pdo2cJRlpzxx/YQbmveQHUtzq8+jvqGgX2Ks16W2FQYf9OlCpV+n+STNJI5lLqJj + 9AcAAP//AwAgzy7tKgIAAA== + headers: + Connection: + - keep-alive + Content-Encoding: + - gzip + Content-Type: + - application/json + Date: Sun, 09 Jun 2024 23:39:08 GMT + Server: DUMMY + Transfer-Encoding: + - chunked + X-Content-Type-Options: + - nosniff + access-control-expose-headers: + - X-Request-ID + status: + code: 200 + message: OK version: 1 diff --git a/tests/test_helpers/cassettes/test_assistants/test_AIAssistant_invoke.yaml b/tests/test_helpers/cassettes/test_assistants/test_AIAssistant_invoke.yaml index bb1a867..bd1ede9 100644 --- a/tests/test_helpers/cassettes/test_assistants/test_AIAssistant_invoke.yaml +++ b/tests/test_helpers/cassettes/test_assistants/test_AIAssistant_invoke.yaml @@ -440,4 +440,258 @@ interactions: status: code: 200 message: OK +- request: + body: '{"messages": [{"content": "You are a temperature bot. Today is 2024-06-09.", + "role": "system"}, {"content": "What is the temperature today in Recife?", "role": + "user"}], "model": "gpt-4o", "n": 1, "stream": false, "temperature": 1.0, "tools": + [{"type": "function", "function": {"name": "fetch_current_temperature", "description": + "Fetch the current temperature data for a location", "parameters": {"type": + "object", "properties": {"location": {"type": "string"}}, "required": ["location"]}}}, + {"type": "function", "function": {"name": "fetch_forecast_temperature", "description": + "Fetch the forecast temperature data for a location", "parameters": {"type": + "object", "properties": {"location": {"type": "string"}, "dt_str": {"description": + "Date in the format ''YYYY-MM-DD''", "type": "string"}}, "required": ["location", + "dt_str"]}}}]}' + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate + authorization: + - DUMMY + connection: + - keep-alive + content-length: + - '834' + content-type: + - application/json + host: + - api.openai.com + user-agent: + - OpenAI/Python + method: POST + uri: https://api.openai.com/v1/chat/completions + response: + body: + string: !!binary | + H4sIAAAAAAAAA2xSXW/TMBR9z6+w7nODkqxNu7wNOrFqgBAMIZWiyHVvPlbHtuwbxKj63ycnaZtV + 5MGyzsk55/rYh4AxqHeQMRAVJ9EYGd6lD9Hd+tOXav2I9+ZeL9d/vj+vFtMPBsV7mHiF3j6joJPq + ndCNkUi1Vj0tLHJC7xrPkzSaLdJF2hGN3qH0stJQONVhEiXTMJqF8c0grHQt0EHGfgWMMXboVj+i + 2uFfyFg0OSENOsdLhOz8E2NgtfQIcOdqR1wRTC6k0IpQ+alVK+WIIK1lLriUl+D+O4z2l564lPl+ + P58vb79Gqx9L8bTe3f7btz8/fl5tR3m99YvpBipaJc79jPgznl2FMQaKN70WSVS5aK1FRTlhY9By + ai1emTEG3JZtg4r8QeCwAakF9/YbyDbwDUVd4AaO8EZ2DP63/z3qx2LROi6H4gb8eL4JqUtj9dZd + FQtFrWpX5Ra56w447jk4pXU50L65SjBWN4Zy0ntU3jaO494VLi9txKYDSZq4HOHJPBjmBPfiCJu8 + qFWJ1ti6ewZQmDyZpcmU4w2fQXAMXgEAAP//AwAwzXr/DwMAAA== + headers: + Connection: + - keep-alive + Content-Encoding: + - gzip + Content-Type: + - application/json + Date: Sun, 09 Jun 2024 23:39:08 GMT + Server: DUMMY + Transfer-Encoding: + - chunked + X-Content-Type-Options: + - nosniff + access-control-expose-headers: + - X-Request-ID + status: + code: 200 + message: OK +- request: + body: '{"messages": [{"content": "You are a temperature bot. Today is 2024-06-09.", + "role": "system"}, {"content": "What is the temperature today in Recife?", "role": + "user"}, {"content": null, "role": "assistant", "tool_calls": [{"type": "function", + "id": "call_kk77D9P0IUDcTZd9zkuWGMIb", "function": {"name": "fetch_current_temperature", + "arguments": "{\"location\": \"Recife\"}"}}]}, {"content": "32 degrees Celsius", + "role": "tool", "tool_call_id": "call_kk77D9P0IUDcTZd9zkuWGMIb"}], "model": + "gpt-4o", "n": 1, "stream": false, "temperature": 1.0, "tools": [{"type": "function", + "function": {"name": "fetch_current_temperature", "description": "Fetch the + current temperature data for a location", "parameters": {"type": "object", "properties": + {"location": {"type": "string"}}, "required": ["location"]}}}, {"type": "function", + "function": {"name": "fetch_forecast_temperature", "description": "Fetch the + forecast temperature data for a location", "parameters": {"type": "object", + "properties": {"location": {"type": "string"}, "dt_str": {"description": "Date + in the format ''YYYY-MM-DD''", "type": "string"}}, "required": ["location", + "dt_str"]}}}]}' + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate + authorization: + - DUMMY + connection: + - keep-alive + content-length: + - '1145' + content-type: + - application/json + cookie: + - DUMMY + host: + - api.openai.com + user-agent: + - OpenAI/Python + method: POST + uri: https://api.openai.com/v1/chat/completions + response: + body: + string: !!binary | + H4sIAAAAAAAAAwAAAP//VJDNTsMwEITveYqVz02Vpk0KvRV6AAEXVAESQpXrbBKDY1vejURV9d1R + 0vSHiw/zeUazs48AhC7EAoSqJavGm3iZPyR31Rof6d1oJlPVL+btY/m8elqtgxh1Drf9RsUn11i5 + xhtk7ewRq4CSsUudzNM8yW7ym3kPGleg6WyV53jm4jRJZ3GSxZPpYKydVkhiAZ8RAMC+f7uKtsBf + sYBkdFIaJJIVisX5E4AIznSKkESaWFoWowtUzjLavvW6RlBtCGgZGBuPQXIbELSFV1S6RGBXyB1o + gmkKBVYBkeAeDemWxtehAcuWZHeTbY0Z9MO5pXGVD25LAz/rpbaa6k1ASc52jYidFz09RABf/Rrt + vwOFD67xvGH3g7YLnExvj3nisv81HSA7luZKz9JoaChoR4zNptS2wuCDPo5T+k2a5elM4lRmIjpE + fwAAAP//AwDhh+s+JQIAAA== + headers: + Connection: + - keep-alive + Content-Encoding: + - gzip + Content-Type: + - application/json + Date: Sun, 09 Jun 2024 23:39:08 GMT + Server: DUMMY + Transfer-Encoding: + - chunked + X-Content-Type-Options: + - nosniff + access-control-expose-headers: + - X-Request-ID + status: + code: 200 + message: OK +- request: + body: '{"messages": [{"content": "You are a temperature bot. Today is 2024-06-09.", + "role": "system"}, {"content": "What is the temperature today in Recife?", "role": + "user"}, {"content": "The current temperature in Recife today is 32 degrees + Celsius.", "role": "assistant"}, {"content": "What about tomorrow?", "role": + "user"}], "model": "gpt-4o", "n": 1, "stream": false, "temperature": 1.0, "tools": + [{"type": "function", "function": {"name": "fetch_current_temperature", "description": + "Fetch the current temperature data for a location", "parameters": {"type": + "object", "properties": {"location": {"type": "string"}}, "required": ["location"]}}}, + {"type": "function", "function": {"name": "fetch_forecast_temperature", "description": + "Fetch the forecast temperature data for a location", "parameters": {"type": + "object", "properties": {"location": {"type": "string"}, "dt_str": {"description": + "Date in the format ''YYYY-MM-DD''", "type": "string"}}, "required": ["location", + "dt_str"]}}}]}' + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate + authorization: + - DUMMY + connection: + - keep-alive + content-length: + - '987' + content-type: + - application/json + host: + - api.openai.com + user-agent: + - OpenAI/Python + method: POST + uri: https://api.openai.com/v1/chat/completions + response: + body: + string: !!binary | + H4sIAAAAAAAAAwAAAP//bFLLbtswELzrK4g9W4UkW4qhW1O0yKFB0wBB01aFwFAriS5FsuQKrWH4 + 3ws9bCtGeCCIGc7scpaHgDGQFeQMRMtJdFaF77O76MOzT+7aB70RT38e73eYVJZi/Hb/EVaDwrzs + UNBJ9U6YziokafREC4eccHCNb5IsSrfZdjsSnalQDbLGUrgxYRIlmzBKw3g9C1sjBXrI2c+AMcYO + 4z60qCv8BzmLViekQ+95g5CfLzEGzqgBAe699MQ1wepCCqMJ9dC17pVaEGSMKgVX6lJ4WofF+ZIT + V6rsflSf43qffM9Maqvd36fbr18ePj3LRb3Jem/Hhupei3M+C/6M51fFGAPNu0mLJNqyNg4F91QS + dhYdp97hlRtjwF3Td6hpeAkcClBG8MG/gLyARxSyxgJWBVRUenIjOs0gC+OogCO8MjwGb51/LaJz + WPeeqznTGT+eh6RMY5158VeZQy219G3pkPvx7csRBKdqYx3oX00ZrDOdpZLMb9SDbbxOJ1e4fMIL + m9zMJBniaqHKkmDuE/zeE3ZlLXWDzjo5/hCobZmkWbLhuOYpBMfgPwAAAP//AwD589dpKgMAAA== + headers: + Connection: + - keep-alive + Content-Encoding: + - gzip + Content-Type: + - application/json + Date: Sun, 09 Jun 2024 23:39:08 GMT + Server: DUMMY + Transfer-Encoding: + - chunked + X-Content-Type-Options: + - nosniff + access-control-expose-headers: + - X-Request-ID + status: + code: 200 + message: OK +- request: + body: '{"messages": [{"content": "You are a temperature bot. Today is 2024-06-09.", + "role": "system"}, {"content": "What is the temperature today in Recife?", "role": + "user"}, {"content": "The current temperature in Recife today is 32 degrees + Celsius.", "role": "assistant"}, {"content": "What about tomorrow?", "role": + "user"}, {"content": null, "role": "assistant", "tool_calls": [{"type": "function", + "id": "call_mZdL1fy2Y6o5pdjwUBQOPFXi", "function": {"name": "fetch_forecast_temperature", + "arguments": "{\"location\": \"Recife\", \"dt_str\": \"2024-06-10\"}"}}]}, {"content": + "35 degrees Celsius", "role": "tool", "tool_call_id": "call_mZdL1fy2Y6o5pdjwUBQOPFXi"}], + "model": "gpt-4o", "n": 1, "stream": false, "temperature": 1.0, "tools": [{"type": + "function", "function": {"name": "fetch_current_temperature", "description": + "Fetch the current temperature data for a location", "parameters": {"type": + "object", "properties": {"location": {"type": "string"}}, "required": ["location"]}}}, + {"type": "function", "function": {"name": "fetch_forecast_temperature", "description": + "Fetch the forecast temperature data for a location", "parameters": {"type": + "object", "properties": {"location": {"type": "string"}, "dt_str": {"description": + "Date in the format ''YYYY-MM-DD''", "type": "string"}}, "required": ["location", + "dt_str"]}}}]}' + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate + authorization: + - DUMMY + connection: + - keep-alive + content-length: + - '1327' + content-type: + - application/json + cookie: + - DUMMY + host: + - api.openai.com + user-agent: + - OpenAI/Python + method: POST + uri: https://api.openai.com/v1/chat/completions + response: + body: + string: !!binary | + H4sIAAAAAAAAA1SRS0/DMBCE7/kVK58bFNKmtL1FRQguCCE4IIQq19mkBsdreTc8hPrfUdInFx/m + 21nNjn8TAGUrtQBlNlpMG1xaTm+za/P8YGf2Hpd3y7J4+dRYdjcPpe/UqHfQ+h2NHFwXhtrgUCz5 + HTYRtWC/9fIqn2bFbDqbD6ClCl1va4KkE0rzLJ+kWZFejvfGDVmDrBbwmgAA/A5vH9FX+K0WkI0O + SovMukG1OA4BqEiuV5RmtizaixqdoCEv6IfUTxuEmiIazYIVCLYBo5YuDjIItRQjfYH18IjG1giW + YVxAhU1EZFiiY9vxxfn6iHXHur/Od87t9e0xr6MmRFrznh/12nrLm1VEzeT7bCwU1EC3CcDb0Ev3 + 71QVIrVBVkIf6HloudjtU6efOKMHKCTanenzLNknVPzDgu2qtr7BGKLd1VSHVV5M84nGsS5Usk3+ + AAAA//8DADVkeHYvAgAA + headers: + Connection: + - keep-alive + Content-Encoding: + - gzip + Content-Type: + - application/json + Date: Sun, 09 Jun 2024 23:39:08 GMT + Server: DUMMY + Transfer-Encoding: + - chunked + X-Content-Type-Options: + - nosniff + access-control-expose-headers: + - X-Request-ID + status: + code: 200 + message: OK version: 1 diff --git a/tests/test_helpers/cassettes/test_assistants/test_AIAssistant_with_rag_invoke.yaml b/tests/test_helpers/cassettes/test_assistants/test_AIAssistant_with_rag_invoke.yaml index cd6a712..1bffb2f 100644 --- a/tests/test_helpers/cassettes/test_assistants/test_AIAssistant_with_rag_invoke.yaml +++ b/tests/test_helpers/cassettes/test_assistants/test_AIAssistant_with_rag_invoke.yaml @@ -776,4 +776,252 @@ interactions: status: code: 200 message: OK +- request: + body: '{"messages": [{"content": "Given a chat history and the latest user question + which might reference context in the chat history, formulate a standalone question + which can be understood without the chat history. Do NOT answer the question, + just reformulate it if needed and otherwise return it as is.", "role": "system"}, + {"content": "You are a tour guide assistant offers information about nearby + attractions. The user is at a location and wants to know what to learn about + nearby attractions. Use the following pieces of context to suggest nearby attractions + to the user. If there are no interesting attractions nearby, tell the user there''s + nothing to see where they''re at. Use three sentences maximum and keep your + suggestions concise.\n\n---START OF CONTEXT---\n{context}---END OF CONTEXT---\n", + "role": "system"}, {"content": "I''m at Central Park W & 79st, New York, NY + 10024, United States.", "role": "user"}], "model": "gpt-4o", "n": 1, "stream": + false, "temperature": 1.0}' + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate + authorization: + - DUMMY + connection: + - keep-alive + content-length: + - '979' + content-type: + - application/json + host: + - api.openai.com + user-agent: + - OpenAI/Python + method: POST + uri: https://api.openai.com/v1/chat/completions + response: + body: + string: !!binary | + H4sIAAAAAAAAAwAAAP//VJFPS8NAEMXv+RTDnhtJ0z/aXkSrWEGLoFCKSJgm02btZneZnaJF+t0l + aWzrZQ/vt2/2vdmfCEDpQo1B5SVKXnkT3wynyT2zJLcvyWy7mBb3u7cn//D8eDc1C9WpHW75Sbn8 + uS5yV3lDop094JwJheqp3ct0mAyuhqOkAZUryNS2tZe47+I0SftxMoi7vdZYOp1TUGN4jwAAfpqz + jmgL+lZjaMY0SkUh4JrU+HgJQLEztaIwBB0ErajOCebOCtkm9bxEAWQCKQksIS93gCKMeV0iAApM + yAqjgRfkDcwpCKAt4HIkJbwKEwloCzP6goXjDUy07K7PH2NabQPWXe3WmFbfH9Mbt/bslqHlR32l + rQ5lxoTB2TppEOdVQ/cRwEezpe2/4sqzq7xk4jZkQ7Pz3mGeOv3LGR21UJygOdfTqE2owi4IVdlK + 2zWxZ31Y2spn6WCY9pF6OFDRPvoFAAD//wMA3v8QVz0CAAA= + headers: + Connection: + - keep-alive + Content-Encoding: + - gzip + Content-Type: + - application/json + Date: Sun, 09 Jun 2024 23:39:08 GMT + Server: DUMMY + Transfer-Encoding: + - chunked + X-Content-Type-Options: + - nosniff + access-control-expose-headers: + - X-Request-ID + status: + code: 200 + message: OK +- request: + body: '{"messages": [{"content": "You are a tour guide assistant offers information + about nearby attractions. The user is at a location and wants to know what to + learn about nearby attractions. Use the following pieces of context to suggest + nearby attractions to the user. If there are no interesting attractions nearby, + tell the user there''s nothing to see where they''re at. Use three sentences + maximum and keep your suggestions concise.\n\n---START OF CONTEXT---\n{context}---END + OF CONTEXT---\n", "role": "system"}, {"content": "---START OF CONTEXT---\nCentral + Park\n\nAmerican Museum of Natural History---END OF CONTEXT---\n", "role": "system"}, + {"content": "I''m at Central Park W & 79st, New York, NY 10024, United States.", + "role": "user"}], "model": "gpt-4o", "n": 1, "stream": false, "temperature": + 1.0}' + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate + authorization: + - DUMMY + connection: + - keep-alive + content-length: + - '804' + content-type: + - application/json + host: + - api.openai.com + user-agent: + - OpenAI/Python + method: POST + uri: https://api.openai.com/v1/chat/completions + response: + body: + string: !!binary | + H4sIAAAAAAAAAwAAAP//VFLLbtswELz7Kxa8+GIbfrfxLUiDJkBbtIcegqIw1tRKYkxxVe7KshH4 + 3wvKqp1eeJjZGQ53+DYAMC4zGzC2RLVV7cf366fpY/1lt2g/83763OY/FocqOxQ/P5V/HswoKXj3 + Slb/qSaWq9qTOg4X2kZCpeQ6+zBfT1cf13fTjqg4I59kRa3jJY/n0/lyPF2NZ4teWLKzJGYDvwYA + AG/dmSKGjI5mA51Nh1QkggWZzXUIwET2CTEo4kQxqBndSMtBKXSpX7gBiwEOTpyClgT3FUWXoK+N + UFMB5/ANtYno4cmJcjyNoC2dLcEJeLbpfRBdUSoEOioowwMFTfPfMe4n8KxDAQQpOSq06PeAIQPO + c4oCOYp1AdWFAuhYup1TAQ6A0LqMIGIoKEUIfQSxjoIlUK6dlQk8hlc+wY617MLXGPdDuU7vCBs9 + dfcltupeNBSgrLGYWkIPLYeMokzeLyhS3gimfkLjfY+frxv3XNSRd9LzVzx3wUm5jYTCIW1XlGvT + secBwO+u2ea/skwduap1q7ynkAxny8XFz9z+0o1d9bUbZUX/TnW3GPQJjZxEqdrmLhQU6+guRef1 + dr5az5dIC1yZwXnwFwAA//8DADpH7jTxAgAA + headers: + Connection: + - keep-alive + Content-Encoding: + - gzip + Content-Type: + - application/json + Date: Sun, 09 Jun 2024 23:39:08 GMT + Server: DUMMY + Transfer-Encoding: + - chunked + X-Content-Type-Options: + - nosniff + access-control-expose-headers: + - X-Request-ID + status: + code: 200 + message: OK +- request: + body: '{"messages": [{"content": "Given a chat history and the latest user question + which might reference context in the chat history, formulate a standalone question + which can be understood without the chat history. Do NOT answer the question, + just reformulate it if needed and otherwise return it as is.", "role": "system"}, + {"content": "You are a tour guide assistant offers information about nearby + attractions. The user is at a location and wants to know what to learn about + nearby attractions. Use the following pieces of context to suggest nearby attractions + to the user. If there are no interesting attractions nearby, tell the user there''s + nothing to see where they''re at. Use three sentences maximum and keep your + suggestions concise.\n\n---START OF CONTEXT---\n{context}---END OF CONTEXT---\n", + "role": "system"}, {"content": "11 W 53rd St, New York, NY 10019, United States.", + "role": "user"}], "model": "gpt-4o", "n": 1, "stream": false, "temperature": + 1.0}' + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate + authorization: + - DUMMY + connection: + - keep-alive + content-length: + - '963' + content-type: + - application/json + host: + - api.openai.com + user-agent: + - OpenAI/Python + method: POST + uri: https://api.openai.com/v1/chat/completions + response: + body: + string: !!binary | + H4sIAAAAAAAAAwAAAP//VJHNbtswEITveooFz1IhyZYT+1IEMJD24h6aIj9FYayllc1a4hLcFVoj + 8LsXVBQ7vfAw3w45O3xNAIxtzApMfUCte99ld4sv+X319O20Htbt7fHpZr5+ePj60gyN25BJo4N3 + v6nWd9enmnvfkVp2b7gOhErx1uKmXOTV7WJZjqDnhrpo23vN5pyVeTnP8iorZpPxwLYmMSv4mQAA + vI5njOga+mtWkKfvSk8iuCezugwBmMBdVAyKWFF0atIrrNkpuTH14wEVMBAI9wSOMOxOgKoB67iF + ACoUBTxCNQsNfNcUNvQHnjkcU9g8Q5HnxTKFH84qRYxK8vnjU4HaQTBu6oaum/TzJXvHex94JxO/ + 6K11Vg7bQCjsYk5R9mak5wTg19jR8N/axgfuvW6Vj+RkbHzqyFx/5UrLaoLKit0H17JKpoRGTqLU + b1vr9hR8sG+VtX5bVotyjjTDyiTn5B8AAAD//wMAtwpy5zsCAAA= + headers: + Connection: + - keep-alive + Content-Encoding: + - gzip + Content-Type: + - application/json + Date: Sun, 09 Jun 2024 23:39:08 GMT + Server: DUMMY + Transfer-Encoding: + - chunked + X-Content-Type-Options: + - nosniff + access-control-expose-headers: + - X-Request-ID + status: + code: 200 + message: OK +- request: + body: '{"messages": [{"content": "You are a tour guide assistant offers information + about nearby attractions. The user is at a location and wants to know what to + learn about nearby attractions. Use the following pieces of context to suggest + nearby attractions to the user. If there are no interesting attractions nearby, + tell the user there''s nothing to see where they''re at. Use three sentences + maximum and keep your suggestions concise.\n\n---START OF CONTEXT---\n{context}---END + OF CONTEXT---\n", "role": "system"}, {"content": "---START OF CONTEXT---\nCentral + Park\n\nAmerican Museum of Natural History---END OF CONTEXT---\n", "role": "system"}, + {"content": "I''m at Central Park W & 79st, New York, NY 10024, United States.", + "role": "user"}, {"content": "You can visit the American Museum of Natural History, + which is located right next to Central Park. It''s a short walk and offers fascinating + exhibits on a wide range of natural science topics. Enjoy both the park''s natural + beauty and the museum''s educational wonders.", "role": "assistant"}, {"content": + "11 W 53rd St, New York, NY 10019, United States.", "role": "user"}], "model": + "gpt-4o", "n": 1, "stream": false, "temperature": 1.0}' + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate + authorization: + - DUMMY + connection: + - keep-alive + content-length: + - '1189' + content-type: + - application/json + host: + - api.openai.com + user-agent: + - OpenAI/Python + method: POST + uri: https://api.openai.com/v1/chat/completions + response: + body: + string: !!binary | + H4sIAAAAAAAAAwAAAP//VFJNb9swDL37VxC6dAPiIHE+1uaWw7AWQ4ABBbYVwxAoMm2rkURPpPOB + ov+9kJMm3UWH9/geyUe9ZADKlmoByjRajG9dvpzfj+5v97vxb12yfdjJ4XEbnw7lv/L75FENkoI2 + z2jkXTU05FuHYimcaBNRCybX8ZdiPprdzu8mPeGpRJdkdSv5lPJiVEzz0SwfT87ChqxBVgv4kwEA + vPRvGjGUeFALGA3eEY/Muka1uBQBqEguIUozWxYdRA2upKEgGPqpn6i7iQjR1o3A5gjSIKw6xs4D + VbCiEmOAZRT4tKLV8vMQHuSGQcOeoivziIH2AUvwJ0mFWrpoQw06AB4EA9sdgiHn0KRUkmnf3bcU + dTyCDiX4UxcdZQhfwzMdAQ+to97HGgrWpHZbhiqSh0p76jhVWxYGZ7cIP3WAb1Q3A/hhjWamQW/8 + S8eG3PDj6hGrjnVKPnTOnfHXS5aO6jbShs/8Ba9ssNysI2qmkHJjoVb17GsG8Le/WfffGVQbybey + FtpiSIbF+O7kp66/5MpOp2dSSLT7oJpPsvOEio8s6NeVDTXGNtrTCat2XczmxVTjRM9U9pq9AQAA + //8DAMCLIMDLAgAA + headers: + Connection: + - keep-alive + Content-Encoding: + - gzip + Content-Type: + - application/json + Date: Sun, 09 Jun 2024 23:39:08 GMT + Server: DUMMY + Transfer-Encoding: + - chunked + X-Content-Type-Options: + - nosniff + access-control-expose-headers: + - X-Request-ID + status: + code: 200 + message: OK version: 1 diff --git a/tests/test_helpers/cassettes/test_use_cases/test_create_message.yaml b/tests/test_helpers/cassettes/test_use_cases/test_create_message.yaml index 2c7eff4..86d5a43 100644 --- a/tests/test_helpers/cassettes/test_use_cases/test_create_message.yaml +++ b/tests/test_helpers/cassettes/test_use_cases/test_create_message.yaml @@ -183,4 +183,130 @@ interactions: status: code: 200 message: OK +- request: + body: '{"messages": [{"content": "You are a temperature bot. Today is 2024-06-09.", + "role": "system"}, {"content": "Hello, will I have to use my umbrella in Lisbon + tomorrow?", "role": "user"}], "model": "gpt-4o", "n": 1, "stream": false, "temperature": + 1.0, "tools": [{"type": "function", "function": {"name": "fetch_current_temperature", + "description": "Fetch the current temperature data for a location", "parameters": + {"type": "object", "properties": {"location": {"type": "string"}}, "required": + ["location"]}}}, {"type": "function", "function": {"name": "fetch_forecast_temperature", + "description": "Fetch the forecast temperature data for a location", "parameters": + {"type": "object", "properties": {"location": {"type": "string"}, "dt_str": + {"description": "Date in the format ''YYYY-MM-DD''", "type": "string"}}, "required": + ["location", "dt_str"]}}}]}' + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate + authorization: + - DUMMY + connection: + - keep-alive + content-length: + - '851' + content-type: + - application/json + host: + - api.openai.com + user-agent: + - OpenAI/Python + method: POST + uri: https://api.openai.com/v1/chat/completions + response: + body: + string: !!binary | + H4sIAAAAAAAAA2xSy47TQBC8+ytGfY6R7djexLcFDrCLWBASCAiyxpP2YzMPa6YtiKL8O/IjsTda + H0atKld1T/WcPMag2UPGQNSchGqlf59+CD5Gn5/+fnswP+zmp3vkYfFrs7Xuvd7CqleY4hkFXVRv + hFGtRGqMHmlhkRP2ruFdlAbJJt3GA6HMHmUvq1ryY+NHQRT7QeKH60lYm0agg4z99hhj7DSc/Yh6 + j/8gY8Hqgih0jlcI2fUnxsAa2SPAnWsccU2wmklhNKHup9adlAuCjJG54FLOjcfvtKjnnLiU+VFV + 3ePhKdHfpXlXfxGH54e3h69Fueg3Wh/bYaCy0+Kaz4K/4tlNM8ZAczVqkUSdl8ai4I5yQtWi5dRZ + vHFjDLitOoWa+pvAaQfSCN777yDbwafGFX252sGeckd2QMcdpH4Y7OAMLwzP3mv1n0V0FsvOcTll + OuHn65KkqVprCneTOZSNblydW+RuuPtyBd6l29AHuhdbhtYa1VJO5oC6tw3DdHSF+RHObHQ3kWSI + y4UqXnvTnOCOjlDlZaMrtK1thhcCZZtHSRrFHNc8Ae/s/QcAAP//AwB9Z52QKgMAAA== + headers: + Connection: + - keep-alive + Content-Encoding: + - gzip + Content-Type: + - application/json + Date: Sun, 09 Jun 2024 23:39:08 GMT + Server: DUMMY + Transfer-Encoding: + - chunked + X-Content-Type-Options: + - nosniff + access-control-expose-headers: + - X-Request-ID + status: + code: 200 + message: OK +- request: + body: '{"messages": [{"content": "You are a temperature bot. Today is 2024-06-09.", + "role": "system"}, {"content": "Hello, will I have to use my umbrella in Lisbon + tomorrow?", "role": "user"}, {"content": null, "role": "assistant", "tool_calls": + [{"type": "function", "id": "call_ymguKkO5nVloChPckjJBkQbf", "function": {"name": + "fetch_forecast_temperature", "arguments": "{\"location\": \"Lisbon\", \"dt_str\": + \"2024-06-10\"}"}}]}, {"content": "35 degrees Celsius", "role": "tool", "tool_call_id": + "call_ymguKkO5nVloChPckjJBkQbf"}], "model": "gpt-4o", "n": 1, "stream": false, + "temperature": 1.0, "tools": [{"type": "function", "function": {"name": "fetch_current_temperature", + "description": "Fetch the current temperature data for a location", "parameters": + {"type": "object", "properties": {"location": {"type": "string"}}, "required": + ["location"]}}}, {"type": "function", "function": {"name": "fetch_forecast_temperature", + "description": "Fetch the forecast temperature data for a location", "parameters": + {"type": "object", "properties": {"location": {"type": "string"}, "dt_str": + {"description": "Date in the format ''YYYY-MM-DD''", "type": "string"}}, "required": + ["location", "dt_str"]}}}]}' + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate + authorization: + - DUMMY + connection: + - keep-alive + content-length: + - '1191' + content-type: + - application/json + cookie: + - DUMMY + host: + - api.openai.com + user-agent: + - OpenAI/Python + method: POST + uri: https://api.openai.com/v1/chat/completions + response: + body: + string: !!binary | + H4sIAAAAAAAAAwAAAP//VFHRitswEHz3Vyx66YsTHCd2c3krpTSUtIVyhUIpQbbXtnqyVmjXpOmR + fy9yfMn1RYiZndFo9jkBUKZRO1B1r6UevF28K/fZp/G7/dJ/Y3c4/9jvP/RfP37228P275NKo4Kq + 31jLi2pZ0+AtiiF3peuAWjC6rt7mZVZsy4diIgZq0EZZ52WxoUWe5ZtFVixW61nYk6mR1Q5+JgAA + z9MZI7oG/6gdZOkLMiCz7lDtbkMAKpCNiNLMhkU7UemdrMkJuin1Y4/QUsBas8QLHAxX5EBooBDo + BIahJ4GTkR40CA4eg5YxIFAL6wIa7AIiw3u0bEZOQbsGjEBDyO6NAI9dhywQtHFLeOwxYHwvhTON + 4ANVurJnOFGcdYgNCMHIGOkA41AFtFYvX6cP2I6sY3lutHbGL7c6LHXRlmf+hrfGGe6PATWTi19n + Ia8m9pIA/JpqH/9rUvlAg5ej0BO6aLgqyqufui/6zq43Mykk2r5SPWTJnFDxmQWHY2tch8EHc91C + 6495UeYbjWtdqOSS/AMAAP//AwCgU4uTjgIAAA== + headers: + Connection: + - keep-alive + Content-Encoding: + - gzip + Content-Type: + - application/json + Date: Sun, 09 Jun 2024 23:39:08 GMT + Server: DUMMY + Transfer-Encoding: + - chunked + X-Content-Type-Options: + - nosniff + access-control-expose-headers: + - X-Request-ID + status: + code: 200 + message: OK version: 1 diff --git a/tests/test_helpers/test_assistants.py b/tests/test_helpers/test_assistants.py index b52f034..e288ef2 100644 --- a/tests/test_helpers/test_assistants.py +++ b/tests/test_helpers/test_assistants.py @@ -92,24 +92,24 @@ def test_AIAssistant_invoke(): messages = thread.messages.order_by("created_at").values_list("message", flat=True) messages_ids = thread.messages.order_by("created_at").values_list("id", flat=True) - assert response_0 == { - "history": [], - "input": "What is the temperature today in Recife?", - "output": "The current temperature in Recife today is 32 degrees Celsius.", - } - assert response_1 == { - "history": [ - HumanMessage(content="What is the temperature today in Recife?", id=messages_ids[0]), - AIMessage( - content="The current temperature in Recife today is 32 degrees Celsius.", - id=messages_ids[1], - ), - ], - "input": "What about tomorrow?", - "output": "The forecasted temperature in Recife for tomorrow, June 10, 2024, is " - "expected to be 35 degrees Celsius.", - } - assert list(messages) == messages_to_dict( + assert response_0["input"] == "What is the temperature today in Recife?" + assert response_0["output"] == "The current temperature in Recife today is 32 degrees Celsius." + assert response_1["input"] == "What about tomorrow?" + assert ( + response_1["output"] + == "The forecasted temperature for tomorrow in Recife is 35 degrees Celsius." + ) + + question_message = response_1["messages"][1] + assert question_message.content == "What is the temperature today in Recife?" + assert question_message.id == str(messages_ids[0]) + response_message = response_1["messages"][2] + assert ( + response_message.content == "The current temperature in Recife today is 32 degrees Celsius." + ) + assert response_message.id == str(messages_ids[1]) + + expected_messages = messages_to_dict( [ HumanMessage(content="What is the temperature today in Recife?", id=messages_ids[0]), AIMessage( @@ -118,13 +118,17 @@ def test_AIAssistant_invoke(): ), HumanMessage(content="What about tomorrow?", id=messages_ids[2]), AIMessage( - content="The forecasted temperature in Recife for tomorrow, June 10, 2024, is " - "expected to be 35 degrees Celsius.", + content="The forecasted temperature for tomorrow in Recife is 35 degrees Celsius.", id=messages_ids[3], ), ] ) + assert [m["data"]["content"] for m in list(messages)] == [ + m["data"]["content"] for m in expected_messages + ] + assert [m["data"]["id"] for m in list(messages)] == [m["data"]["id"] for m in expected_messages] + def test_AIAssistant_run_handles_optional_thread_id_param(): assistant = AIAssistant.get_cls("temperature_assistant")() @@ -170,30 +174,22 @@ def test_AIAssistant_with_rag_invoke(): messages = thread.messages.order_by("created_at").values_list("message", flat=True) messages_ids = thread.messages.order_by("created_at").values_list("id", flat=True) - assert response_0 == { - "history": [], - "input": "I'm at Central Park W & 79st, New York, NY 10024, United States.", - "output": "You're right by the American Museum of Natural History, one of the " - "largest museums in the world, featuring fascinating exhibits on " - "dinosaurs, human origins, and outer space. Additionally, you're at the " - "edge of Central Park, a sprawling urban oasis with scenic walking trails, " - "lakes, and the iconic Central Park Zoo. Enjoy the blend of natural beauty " - "and cultural richness!", - } - assert response_1 == { - "history": [ - HumanMessage(content=response_0["input"], id=messages_ids[0]), - AIMessage(content=response_0["output"], id=messages_ids[1]), - ], - "input": "11 W 53rd St, New York, NY 10019, United States.", - "output": "You're at the location of the Museum of Modern Art (MoMA), home to an " - "extensive collection of modern and contemporary art, including works by " - "Van Gogh, Picasso, and Warhol. Nearby, you can also visit Rockefeller " - "Center, known for its impressive architecture and the Top of the Rock " - "observation deck. These attractions offer a blend of artistic and urban " - "experiences.", - } - assert list(messages) == messages_to_dict( + assert response_0["input"] == "I'm at Central Park W & 79st, New York, NY 10024, United States." + assert response_0["output"] == ( + "You can visit the American Museum of Natural History, which is located " + "right next to Central Park. It's a short walk and offers fascinating exhibits " + "on a wide range of natural science topics. Enjoy both the park's natural beauty " + "and the museum's educational wonders." + ) + assert response_1["input"] == "11 W 53rd St, New York, NY 10019, United States." + assert response_1["output"] == ( + "You're right by the Museum of Modern Art (MoMA). It's a world-renowned " + "museum featuring an extensive collection of contemporary and modern art. " + "Enjoy exploring iconic works from famous artists like Van Gogh, " + "Picasso, and Warhol." + ) + + expected_messages = messages_to_dict( [ HumanMessage(content=response_0["input"], id=messages_ids[0]), AIMessage(content=response_0["output"], id=messages_ids[1]), @@ -202,6 +198,11 @@ def test_AIAssistant_with_rag_invoke(): ] ) + assert [m["data"]["content"] for m in list(messages)] == [ + m["data"]["content"] for m in expected_messages + ] + assert [m["data"]["id"] for m in list(messages)] == [m["data"]["id"] for m in expected_messages] + @pytest.mark.django_db(transaction=True) def test_AIAssistant_tool_order_same_as_declaration(): diff --git a/tests/test_helpers/test_use_cases.py b/tests/test_helpers/test_use_cases.py index 131d19a..08f9a78 100644 --- a/tests/test_helpers/test_use_cases.py +++ b/tests/test_helpers/test_use_cases.py @@ -151,11 +151,11 @@ def test_create_message(): "Hello, will I have to use my umbrella in Lisbon tomorrow?", ) - assert response == { - "input": "Hello, will I have to use my umbrella in Lisbon tomorrow?", - "history": [], - "output": "The forecast for Lisbon tomorrow is 35°C, which is quite warm and unlikely to involve rain. You probably won't need an umbrella.", - } + assert response["input"] == "Hello, will I have to use my umbrella in Lisbon tomorrow?" + assert response["output"] == ( + "The forecast for Lisbon tomorrow is hot with a temperature of 35 degrees Celsius, " + "and it doesn't suggest rain. Therefore, you probably won't need to use your umbrella." + ) @pytest.mark.django_db(transaction=True) diff --git a/tests/test_views.py b/tests/test_views.py index a621859..2c3e8f4 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -301,7 +301,7 @@ def test_create_thread_message(authenticated_client): ) assert ( ai_message.message["data"]["content"] - == "The current temperature in San Francisco, CA is 32 degrees Celsius." + == "The current temperature in San Francisco, CA is 32°C." ) From 8acb2e79a580f09ed56c35b7aa22fa2e9a035c6f Mon Sep 17 00:00:00 2001 From: Filipe Ximenes Date: Wed, 11 Sep 2024 11:26:16 -0300 Subject: [PATCH 09/13] adds docs to as_graph --- django_ai_assistant/helpers/assistants.py | 24 +++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/django_ai_assistant/helpers/assistants.py b/django_ai_assistant/helpers/assistants.py index a11faef..134e5f0 100644 --- a/django_ai_assistant/helpers/assistants.py +++ b/django_ai_assistant/helpers/assistants.py @@ -450,13 +450,28 @@ def get_history_aware_retriever(self) -> Runnable[dict, RetrieverOutput]: @with_cast_id def as_graph(self, thread_id: Any | None = None) -> Runnable[dict, dict]: + """Create the Langchain graph for the assistant.\n + This graph is an agent that supports chat history, tool calling, and RAG (if `has_rag=True`).\n + `as_graph` uses many other methods to create the graph for the assistant. + Prefer to override the other methods to customize the graph for the assistant. + Only override this method if you need to customize the graph at a lower level. + + Args: + thread_id (Any | None): The thread ID for the chat message history. + If `None`, an in-memory chat message history is used. + + Returns: + the compiled graph + """ + llm = self.get_llm() + tools = self.get_tools() + llm_with_tools = llm.bind_tools(tools) if tools else llm message_history = self.get_message_history(thread_id) def custom_add_messages(left: list[BaseMessage], right: list[BaseMessage]): result = add_messages(left, right) if thread_id: - # We only want to store human and ai messages that are not tool calls messages_to_store = [ m for m in result @@ -473,10 +488,6 @@ class AgentState(TypedDict): context: str output: str - llm = self.get_llm() - tools = self.get_tools() - llm_with_tools = llm.bind_tools(tools) if tools else llm - def setup(state: AgentState): return {"messages": [SystemMessage(content=self.get_instructions())]} @@ -501,7 +512,8 @@ def retriever(state: AgentState): } def history(state: AgentState): - return {"messages": [*message_history.messages, HumanMessage(content=state["input"])]} + history = message_history.messages if thread_id else [] + return {"messages": [*history, HumanMessage(content=state["input"])]} def agent(state: AgentState): response = llm_with_tools.invoke(state["messages"]) From 5c441e5c995425eff0c06248185e9e98f2e2bb86 Mon Sep 17 00:00:00 2001 From: Filipe Ximenes Date: Wed, 11 Sep 2024 12:10:17 -0300 Subject: [PATCH 10/13] allows toggling between the old as_chain to the new as_graph methods. Tests run with as_graph. --- django_ai_assistant/conf.py | 1 + django_ai_assistant/helpers/assistants.py | 7 +++++-- tests/settings.py | 1 + 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/django_ai_assistant/conf.py b/django_ai_assistant/conf.py index 7ebe069..7df70e4 100644 --- a/django_ai_assistant/conf.py +++ b/django_ai_assistant/conf.py @@ -10,6 +10,7 @@ DEFAULTS = { "INIT_API_FN": "django_ai_assistant.api.views.init_api", + "USE_LANGGRAPH": False, "CAN_CREATE_THREAD_FN": "django_ai_assistant.permissions.allow_all", "CAN_VIEW_THREAD_FN": "django_ai_assistant.permissions.owns_thread", "CAN_UPDATE_THREAD_FN": "django_ai_assistant.permissions.owns_thread", diff --git a/django_ai_assistant/helpers/assistants.py b/django_ai_assistant/helpers/assistants.py index 134e5f0..ec973ba 100644 --- a/django_ai_assistant/helpers/assistants.py +++ b/django_ai_assistant/helpers/assistants.py @@ -50,6 +50,7 @@ from langgraph.graph.message import add_messages from langgraph.prebuilt import ToolNode +from django_ai_assistant.conf import app_settings from django_ai_assistant.decorators import with_cast_id from django_ai_assistant.exceptions import ( AIAssistantMisconfiguredError, @@ -659,8 +660,10 @@ def invoke(self, *args: Any, thread_id: Any | None, **kwargs: Any) -> dict: dict: The output of the assistant chain, structured like `{"output": "assistant response", "history": ...}`. """ - # chain = self.as_chain(thread_id) - chain = self.as_graph(thread_id) + if app_settings.USE_LANGGRAPH: + chain = self.as_graph(thread_id) + else: + chain = self.as_chain(thread_id) return chain.invoke(*args, **kwargs) @with_cast_id diff --git a/tests/settings.py b/tests/settings.py index 47aa14c..58101a4 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -107,6 +107,7 @@ # django-ai-assistant # NOTE: set a OPENAI_API_KEY on .env.tests file at root when updating the VCRs. +AI_ASSISTANT_USE_LANGGRAPH = True AI_ASSISTANT_INIT_API_FN = "django_ai_assistant.api.views.init_api" AI_ASSISTANT_CAN_CREATE_THREAD_FN = "django_ai_assistant.permissions.allow_all" AI_ASSISTANT_CAN_VIEW_THREAD_FN = "django_ai_assistant.permissions.owns_thread" From 2ed100fdf0651c433c0d9d530cf3702308f46f56 Mon Sep 17 00:00:00 2001 From: Filipe Ximenes Date: Wed, 11 Sep 2024 15:40:35 -0300 Subject: [PATCH 11/13] sets example app to use langgraph --- example/example/settings.py | 1 + 1 file changed, 1 insertion(+) diff --git a/example/example/settings.py b/example/example/settings.py index dadb501..4727afd 100644 --- a/example/example/settings.py +++ b/example/example/settings.py @@ -159,6 +159,7 @@ # django-ai-assistant +AI_ASSISTANT_USE_LANGGRAPH = True AI_ASSISTANT_INIT_API_FN = "django_ai_assistant.api.views.init_api" AI_ASSISTANT_CAN_CREATE_THREAD_FN = "django_ai_assistant.permissions.allow_all" AI_ASSISTANT_CAN_VIEW_THREAD_FN = "django_ai_assistant.permissions.owns_thread" From e54dcd592e07c602aadde03e78b18a4fd4c9e005 Mon Sep 17 00:00:00 2001 From: Filipe Ximenes Date: Wed, 11 Sep 2024 15:46:25 -0300 Subject: [PATCH 12/13] uses messages ids to check for the existing messages in DjangoChatMessageHistory --- django_ai_assistant/langchain/chat_message_histories.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/django_ai_assistant/langchain/chat_message_histories.py b/django_ai_assistant/langchain/chat_message_histories.py index b0c1e3d..09c28f7 100644 --- a/django_ai_assistant/langchain/chat_message_histories.py +++ b/django_ai_assistant/langchain/chat_message_histories.py @@ -77,9 +77,9 @@ def add_messages(self, messages: Sequence[BaseMessage]) -> None: messages: A list of BaseMessage objects to store. """ with transaction.atomic(): - existing_messages = self.get_messages() + existing_message_ids = [m.id for m in self.get_messages()] - messages_to_create = [m for m in messages if m not in existing_messages] + messages_to_create = [m for m in messages if m.id not in existing_message_ids] created_messages = Message.objects.bulk_create( [Message(thread_id=self._thread_id, message=dict()) for _ in messages_to_create] @@ -99,9 +99,9 @@ async def aadd_messages(self, messages: Sequence[BaseMessage]) -> None: Args: messages: A list of BaseMessage objects to store. """ - existing_messages = await self.aget_messages() + existing_message_ids = [m.id for m in await self.aget_messages()] - messages_to_create = [m for m in messages if m not in existing_messages] + messages_to_create = [m for m in messages if m.id not in existing_message_ids] # NOTE: This method does not use transactions because it do not yet work in async mode. # Source: https://docs.djangoproject.com/en/5.0/topics/async/#queries-the-orm From 93d5f74fcd1648c167e50ee26f710a7030ebd750 Mon Sep 17 00:00:00 2001 From: Filipe Ximenes Date: Wed, 11 Sep 2024 16:07:47 -0300 Subject: [PATCH 13/13] optimizes DjangoChatMessageHistory add_messages by returning only the message ids --- django_ai_assistant/langchain/chat_message_histories.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/django_ai_assistant/langchain/chat_message_histories.py b/django_ai_assistant/langchain/chat_message_histories.py index 09c28f7..8291cdf 100644 --- a/django_ai_assistant/langchain/chat_message_histories.py +++ b/django_ai_assistant/langchain/chat_message_histories.py @@ -77,7 +77,9 @@ def add_messages(self, messages: Sequence[BaseMessage]) -> None: messages: A list of BaseMessage objects to store. """ with transaction.atomic(): - existing_message_ids = [m.id for m in self.get_messages()] + existing_message_ids = [ + str(i) for i in self._get_messages_qs().values_list("id", flat=True) + ] messages_to_create = [m for m in messages if m.id not in existing_message_ids] @@ -99,7 +101,9 @@ async def aadd_messages(self, messages: Sequence[BaseMessage]) -> None: Args: messages: A list of BaseMessage objects to store. """ - existing_message_ids = [m.id for m in await self.aget_messages()] + existing_message_ids = [ + str(i) async for i in self._get_messages_qs().values_list("id", flat=True) + ] messages_to_create = [m for m in messages if m.id not in existing_message_ids]