From 7b99a18c20da9b4f7add18bea5f3bead13ffe4b1 Mon Sep 17 00:00:00 2001 From: Quentin BEY Date: Tue, 8 Jul 2025 22:55:36 +0200 Subject: [PATCH] =?UTF-8?q?fixup!=20fixup!=20=F0=9F=8E=89(conversations)?= =?UTF-8?q?=20bootstrap=20backend=20&=20frontend?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- compose.yml | 18 ++++--- src/backend/chat/clients/openai.py | 47 +++++++++++++------ src/backend/chat/tools/__init__.py | 15 ++++++ .../fake_current_weather.py} | 5 +- src/backend/chat/tools/web_search_tavily.py | 38 +++++++++++++++ src/backend/conversations/settings.py | 12 +++++ 6 files changed, 109 insertions(+), 26 deletions(-) create mode 100644 src/backend/chat/tools/__init__.py rename src/backend/chat/{tools.py => tools/fake_current_weather.py} (96%) create mode 100644 src/backend/chat/tools/web_search_tavily.py diff --git a/compose.yml b/compose.yml index 1b5b8b6..a0ed397 100644 --- a/compose.yml +++ b/compose.yml @@ -134,7 +134,7 @@ services: - ".:/app" kc_postgresql: - image: postgres:14.3 + image: postgres:17.5 healthcheck: test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"] interval: 1s @@ -146,7 +146,7 @@ services: - env.d/development/kc_postgresql keycloak: - image: quay.io/keycloak/keycloak:20.0.1 + image: quay.io/keycloak/keycloak:26.3 volumes: - ./docker/auth/realm.json:/opt/keycloak/data/import/realm.json command: @@ -154,20 +154,20 @@ services: - --features=preview - --import-realm - --proxy=edge - - --hostname-url=http://localhost:8083 - - --hostname-admin-url=http://localhost:8083/ + - --hostname=http://localhost:8083 - --hostname-strict=false - - --hostname-strict-https=false - --health-enabled=true - --metrics-enabled=true + - --http-enabled=true healthcheck: - test: ["CMD", "curl", "--head", "-fsS", "http://localhost:8080/health/ready"] + test: ['CMD-SHELL', '[ -f /tmp/HealthCheck.java ] || echo "public class HealthCheck { public static void main(String[] args) throws java.lang.Throwable { java.net.URI uri = java.net.URI.create(args[0]); System.exit(java.net.HttpURLConnection.HTTP_OK == ((java.net.HttpURLConnection)uri.toURL().openConnection()).getResponseCode() ? 0 : 1); } }" > /tmp/HealthCheck.java && java /tmp/HealthCheck.java http://localhost:9000/health/live'] + start_period: 10s interval: 1s timeout: 2s retries: 300 environment: - KEYCLOAK_ADMIN: admin - KEYCLOAK_ADMIN_PASSWORD: admin + KC_BOOTSTRAP_ADMIN_USERNAME: admin + KC_BOOTSTRAP_ADMIN_PASSWORD: admin KC_DB: postgres KC_DB_URL_HOST: kc_postgresql KC_DB_URL_DATABASE: keycloak @@ -175,8 +175,6 @@ services: KC_DB_USERNAME: conversations KC_DB_SCHEMA: public PROXY_ADDRESS_FORWARDING: 'true' - ports: - - "8080:8080" depends_on: kc_postgresql: condition: service_healthy diff --git a/src/backend/chat/clients/openai.py b/src/backend/chat/clients/openai.py index 620219a..9667be6 100644 --- a/src/backend/chat/clients/openai.py +++ b/src/backend/chat/clients/openai.py @@ -30,7 +30,6 @@ from chat.ai_sdk_types import ( UIMessage, ) from chat.mcp_servers import get_mcp_servers -from chat.tools import agent_get_current_weather logger = logging.getLogger(__name__) @@ -171,28 +170,30 @@ class AIAgentService: async def stream_text_async(self, messages: List[UIMessage]): """Async generator for streaming agent events.""" openai_messages = self._convert_to_openai_messages(messages) - logger.info("[stream_data_async] Received messages: %s", openai_messages) + logger.debug("[stream_data_async] Received messages: %s", openai_messages) - mcp_servers = get_mcp_servers() - - async with mcp_servers[0] as mcp_server: + async with AsyncExitStack() as stack: + initialized_mcp_servers = [ + await stack.enter_async_context(mcp_server) for mcp_server in get_mcp_servers() + ] agent = Agent( name=settings.AI_AGENT_NAME, instructions=settings.settings.AI_AGENT_INSTRUCTIONS, model=self.model, - tools=[agent_get_current_weather], - mcp_servers=[mcp_server], + #tools=[agent_get_current_weather], + mcp_servers=initialized_mcp_servers, ) + result = Runner.run_streamed( agent, input=openai_messages, ) async for event in result.stream_events(): - logger.info("[stream_text_async] Received event: %s", event) + logger.debug("[stream_text_async] Received event: %s", event) if event.type == "raw_response_event": data = event.data - logger.info("[stream_text_async] - data: %s", data) + logger.debug("[stream_text_async] - data: %s", data) if data.type == "response.output_text.delta": yield data.delta @@ -215,19 +216,38 @@ class AIAgentService: finish_reason = "stop" openai_messages = self._convert_to_openai_messages(messages) - logger.info("[stream_data_async] Received messages: %s", openai_messages) + logger.debug("[stream_data_async] Received messages: %s", openai_messages) async with AsyncExitStack() as stack: initialized_mcp_servers = [ await stack.enter_async_context(mcp_server) for mcp_server in get_mcp_servers() ] + # websearch_agent = Agent( + # name="web search", + # instructions=( + # "You are a web search agent. " + # "Your task is to search the web for up-to-date information." + # " You will be called by the main agent to perform web searches." + # " You will receive a query and return the search results." + # " The results should be in the format of a list of dictionaries, " + # "each containing 'link', 'title', and 'snippet' keys." + # " If you cannot find any results, return an empty list." + # " Do not include any other information in your response." + # " Do not include any additional text or explanations." + # " You must always annotate the response mentioning the url, title, and snippet." + # ), + # handoff_description="You are a web search agent. Your task is to search the web for up-to-date information.", + # model=self.model, + # tools=[agent_web_search_tavily], + # ) + agent = Agent( name=settings.AI_AGENT_NAME, instructions=settings.AI_AGENT_INSTRUCTIONS, model=self.model, - tools=[agent_get_current_weather], mcp_servers=initialized_mcp_servers, + # handoffs=[websearch_agent], ) result = Runner.run_streamed( agent, @@ -236,11 +256,10 @@ class AIAgentService: try: async for event in result.stream_events(): - # logger.info("[stream_data_async] Received event: %s", event) + logger.debug("[stream_data_async] Received event: %s", event) if event.type == "raw_response_event": data = event.data - # logger.info("[stream_data_async] - data: %s", data) if data.type == "response.output_text.delta": yield f"0:{json.dumps(data.delta)}\n" @@ -267,7 +286,7 @@ class AIAgentService: } yield f"a:{json.dumps(_tool_call_result)}\n" elif event.type == "agent_updated_stream_event": - logger.info( + logger.debug( "[stream_data_async] Agent switched to: %s", event.new_agent.name ) diff --git a/src/backend/chat/tools/__init__.py b/src/backend/chat/tools/__init__.py new file mode 100644 index 0000000..55d7ea5 --- /dev/null +++ b/src/backend/chat/tools/__init__.py @@ -0,0 +1,15 @@ +"""Tools for the chat agent.""" +from agents import FunctionTool + +from .fake_current_weather import agent_get_current_weather +from .web_search_tavily import agent_web_search_tavily + + +def get_tool_by_name(name: str) -> FunctionTool: + """Get a tool by its name.""" + tool_dict = { + "get_current_weather": agent_get_current_weather, + "tavily_web_search": agent_web_search_tavily, + } + + return tool_dict[name] # will raise on purpose if name is not found diff --git a/src/backend/chat/tools.py b/src/backend/chat/tools/fake_current_weather.py similarity index 96% rename from src/backend/chat/tools.py rename to src/backend/chat/tools/fake_current_weather.py index 16468c7..6237254 100644 --- a/src/backend/chat/tools.py +++ b/src/backend/chat/tools/fake_current_weather.py @@ -1,9 +1,9 @@ -"""Tools for the chat agent.""" - +"""Fake weather tool for the chat agent.""" from agents import function_tool from openai.types.chat import ChatCompletionToolParam from openai.types.shared_params import FunctionDefinition + current_weather = ChatCompletionToolParam( type="function", function=FunctionDefinition( @@ -37,3 +37,4 @@ def get_current_weather(location: str, unit: str): def agent_get_current_weather(location: str, unit: str) -> dict: """Get the current weather in a given location.""" return get_current_weather(location, unit) + diff --git a/src/backend/chat/tools/web_search_tavily.py b/src/backend/chat/tools/web_search_tavily.py new file mode 100644 index 0000000..db13c4e --- /dev/null +++ b/src/backend/chat/tools/web_search_tavily.py @@ -0,0 +1,38 @@ +"""Web search tool using Tavily for the chat agent.""" +import requests +from agents import function_tool + +from django.conf import settings + +@function_tool(name_override="tavily_web_search") +def agent_web_search_tavily(query: str) -> list[dict]: + """ + Search the web for up-to-date information + + Args: + query (str): The query to search for. + + Returns: + list[dict]: A list of search results, each represented as a dictionary. + """ + url = "https://api.tavily.com/search" + data = { + "query": query, + "api_key": settings.TAVILY_API_KEY, + "max_results": settings.TAVILY_MAX_RESULTS, + } + response = requests.post(url, json=data) + response.raise_for_status() + + json_response = response.json() + + raw_search_results = json_response.get("results", []) + + return [ + dict( + link=result["url"], + title=result.get("title", ""), + snippet=result.get("content"), + ) + for result in raw_search_results + ] diff --git a/src/backend/conversations/settings.py b/src/backend/conversations/settings.py index 3679ed9..b571405 100755 --- a/src/backend/conversations/settings.py +++ b/src/backend/conversations/settings.py @@ -477,6 +477,18 @@ class Base(Configuration): environ_prefix=None, ) + # Web search + TAVILY_API_KEY = values.Value( + None, # Tavily API key is not set by default + environ_name="TAVILY_API_KEY", + environ_prefix=None, + ) + TAVILY_MAX_RESULTS = values.PositiveIntegerValue( + default=5, + environ_name="TAVILY_MAX_RESULTS", + environ_prefix=None, + ) + # Logging # We want to make it easy to log to console but by default we log production # to Sentry and don't want to log to console.