From 02c5f128fe337970f77bcc33e7b9bd850b9041ef Mon Sep 17 00:00:00 2001 From: Pamela Fox Date: Mon, 5 May 2025 19:56:21 +0000 Subject: [PATCH 01/10] Initial port to pydantic-ai --- src/backend/fastapi_app/prompts/query.txt | 8 +- src/backend/fastapi_app/rag_advanced.py | 193 ++++++++++--------- src/backend/fastapi_app/routes/api_routes.py | 12 +- src/backend/pyproject.toml | 1 + 4 files changed, 111 insertions(+), 103 deletions(-) diff --git a/src/backend/fastapi_app/prompts/query.txt b/src/backend/fastapi_app/prompts/query.txt index 6bbb0a23..0de14213 100644 --- a/src/backend/fastapi_app/prompts/query.txt +++ b/src/backend/fastapi_app/prompts/query.txt @@ -1,6 +1,2 @@ -Below is a history of the conversation so far, and a new question asked by the user that needs to be answered by searching database rows. -You have access to an Azure PostgreSQL database with an items table that has columns for title, description, brand, price, and type. -Generate a search query based on the conversation and the new question. -If the question is not in English, translate the question to English before generating the search query. -If you cannot generate a search query, return the original user question. -DO NOT return anything besides the query. +Your job is to find search results based off the user's question and past messages. +Once you get the search results, you're done. diff --git a/src/backend/fastapi_app/rag_advanced.py b/src/backend/fastapi_app/rag_advanced.py index fe75ea5f..7afc52ce 100644 --- a/src/backend/fastapi_app/rag_advanced.py +++ b/src/backend/fastapi_app/rag_advanced.py @@ -1,9 +1,14 @@ +import os from collections.abc import AsyncGenerator -from typing import Any, Final, Optional, Union +from typing import Optional, TypedDict, Union from openai import AsyncAzureOpenAI, AsyncOpenAI, AsyncStream -from openai.types.chat import ChatCompletion, ChatCompletionChunk, ChatCompletionMessageParam -from openai_messages_token_helper import build_messages, get_token_limit +from openai.types.chat import ChatCompletionChunk, ChatCompletionMessageParam +from openai_messages_token_helper import get_token_limit +from pydantic_ai import Agent, RunContext +from pydantic_ai.models.openai import OpenAIModel +from pydantic_ai.providers.openai import OpenAIProvider +from pydantic_ai.settings import ModelSettings from fastapi_app.api_models import ( AIChatRoles, @@ -15,9 +20,35 @@ ) from fastapi_app.postgres_models import Item from fastapi_app.postgres_searcher import PostgresSearcher -from fastapi_app.query_rewriter import build_search_function, extract_search_arguments from fastapi_app.rag_base import ChatParams, RAGChatBase +# Experiment #1: Annotated did not work! +# Experiment #2: Function-level docstring, Inline docstrings next to attributes +# Function -level docstring leads to XML like this: Search ... +# Experiment #3: Move the docstrings below the attributes in triple-quoted strings - SUCCESS!!! + + +class PriceFilter(TypedDict): + column: str = "price" + """The column to filter on (always 'price' for this filter)""" + + comparison_operator: str + """The operator for price comparison ('>', '<', '>=', '<=', '=')""" + + value: float + """ The price value to compare against (e.g., 30.00) """ + + +class BrandFilter(TypedDict): + column: str = "brand" + """The column to filter on (always 'brand' for this filter)""" + + comparison_operator: str + """The operator for brand comparison ('=' or '!=')""" + + value: str + """The brand name to compare against (e.g., 'AirStrider')""" + class AdvancedRAGChat(RAGChatBase): def __init__( @@ -34,82 +65,64 @@ def __init__( self.chat_deployment = chat_deployment self.chat_token_limit = get_token_limit(chat_model, default_to_minimum=True) - async def generate_search_query( + async def search_database( self, - original_user_query: str, - past_messages: list[ChatCompletionMessageParam], - query_response_token_limit: int, - seed: Optional[int] = None, - ) -> tuple[list[ChatCompletionMessageParam], Union[Any, str, None], list]: - """Generate an optimized keyword search query based on the chat history and the last question""" - - tools = build_search_function() - tool_choice: Final = "auto" - - query_messages: list[ChatCompletionMessageParam] = build_messages( - model=self.chat_model, - system_prompt=self.query_prompt_template, - few_shots=self.query_fewshots, - new_user_content=original_user_query, - past_messages=past_messages, - max_tokens=self.chat_token_limit - query_response_token_limit, - tools=tools, - tool_choice=tool_choice, - fallback_to_default=True, - ) - - chat_completion: ChatCompletion = await self.openai_chat_client.chat.completions.create( - messages=query_messages, - # Azure OpenAI takes the deployment name as the model name - model=self.chat_deployment if self.chat_deployment else self.chat_model, - temperature=0.0, # Minimize creativity for search query generation - max_tokens=query_response_token_limit, # Setting too low risks malformed JSON, too high risks performance - n=1, - tools=tools, - tool_choice=tool_choice, - seed=seed, - ) - - query_text, filters = extract_search_arguments(original_user_query, chat_completion) - - return query_messages, query_text, filters - - async def prepare_context( - self, chat_params: ChatParams - ) -> tuple[list[ChatCompletionMessageParam], list[Item], list[ThoughtStep]]: - query_messages, query_text, filters = await self.generate_search_query( - original_user_query=chat_params.original_user_query, - past_messages=chat_params.past_messages, - query_response_token_limit=500, - seed=chat_params.seed, - ) - - # Retrieve relevant rows from the database with the GPT optimized query + ctx: RunContext[ChatParams], + search_query: str, + price_filter: Optional[PriceFilter] = None, + brand_filter: Optional[BrandFilter] = None, + ) -> list[str]: + """ + Search PostgreSQL database for relevant products based on user query + + Args: + search_query: Query string to use for full text search, e.g. 'red shoes' + price_filter: Filter search results based on price of the product + brand_filter: Filter search results based on brand of the product + + Returns: + List of formatted items that match the search query and filters + """ + print(search_query, price_filter, brand_filter) + # Only send non-None filters + filters = [] + if price_filter: + filters.append(price_filter) + if brand_filter: + filters.append(brand_filter) results = await self.searcher.search_and_embed( - query_text, - top=chat_params.top, - enable_vector_search=chat_params.enable_vector_search, - enable_text_search=chat_params.enable_text_search, + search_query, + top=ctx.deps.top, + enable_vector_search=ctx.deps.enable_vector_search, + enable_text_search=ctx.deps.enable_text_search, filters=filters, ) + return [f"[{(item.id)}]:{item.to_str_for_rag()}\n\n" for item in results] - sources_content = [f"[{(item.id)}]:{item.to_str_for_rag()}\n\n" for item in results] - content = "\n".join(sources_content) - - # Generate a contextual and content specific answer using the search results and chat history - contextual_messages: list[ChatCompletionMessageParam] = build_messages( - model=self.chat_model, - system_prompt=chat_params.prompt_template, - new_user_content=chat_params.original_user_query + "\n\nSources:\n" + content, - past_messages=chat_params.past_messages, - max_tokens=self.chat_token_limit - chat_params.response_token_limit, - fallback_to_default=True, + async def prepare_context(self, chat_params: ChatParams) -> tuple[str, list[Item], list[ThoughtStep]]: + model = OpenAIModel( + os.environ["AZURE_OPENAI_CHAT_DEPLOYMENT"], provider=OpenAIProvider(openai_client=self.openai_chat_client) + ) + agent = Agent( + model, + model_settings=ModelSettings(temperature=0.0, max_tokens=500, seed=chat_params.seed), + system_prompt=self.query_prompt_template, + tools=[self.search_database], + output_type=list[str], + ) + # TODO: Provide few-shot examples + results = await agent.run( + f"Find search results for user query: {chat_params.original_user_query}", + # message_history=chat_params.past_messages, # TODO + deps=chat_params, ) + if not isinstance(results, list): + raise ValueError("Search results should be a list of strings") thoughts = [ ThoughtStep( title="Prompt to generate search arguments", - description=query_messages, + description=chat_params.past_messages, # TODO: update this props=( {"model": self.chat_model, "deployment": self.chat_deployment} if self.chat_deployment @@ -118,50 +131,52 @@ async def prepare_context( ), ThoughtStep( title="Search using generated search arguments", - description=query_text, + description=chat_params.original_user_query, # TODO: props={ "top": chat_params.top, "vector_search": chat_params.enable_vector_search, "text_search": chat_params.enable_text_search, - "filters": filters, + "filters": [], # TODO }, ), ThoughtStep( title="Search results", - description=[result.to_dict() for result in results], + description="", # TODO ), ] - return contextual_messages, results, thoughts + return results, thoughts async def answer( self, chat_params: ChatParams, - contextual_messages: list[ChatCompletionMessageParam], - results: list[Item], + results: list[str], earlier_thoughts: list[ThoughtStep], ) -> RetrievalResponse: - chat_completion_response: ChatCompletion = await self.openai_chat_client.chat.completions.create( - # Azure OpenAI takes the deployment name as the model name - model=self.chat_deployment if self.chat_deployment else self.chat_model, - messages=contextual_messages, - temperature=chat_params.temperature, - max_tokens=chat_params.response_token_limit, - n=1, - stream=False, - seed=chat_params.seed, + agent = Agent( + OpenAIModel( + os.environ["AZURE_OPENAI_CHAT_DEPLOYMENT"], + provider=OpenAIProvider(openai_client=self.openai_chat_client), + ), + system_prompt=self.answer_prompt_template, + model_settings=ModelSettings( + temperature=chat_params.temperature, max_tokens=chat_params.response_token_limit, seed=chat_params.seed + ), + ) + + response = await agent.run( + user_prompt=chat_params.original_user_query + "Sources:\n" + "\n".join(results), + message_history=chat_params.past_messages, ) return RetrievalResponse( - message=Message( - content=str(chat_completion_response.choices[0].message.content), role=AIChatRoles.ASSISTANT - ), + message=Message(content=str(response.output), role=AIChatRoles.ASSISTANT), context=RAGContext( data_points={item.id: item.to_dict() for item in results}, thoughts=earlier_thoughts + [ ThoughtStep( title="Prompt to generate answer", - description=contextual_messages, + description="", # TODO: update props=( {"model": self.chat_model, "deployment": self.chat_deployment} if self.chat_deployment diff --git a/src/backend/fastapi_app/routes/api_routes.py b/src/backend/fastapi_app/routes/api_routes.py index 54e7e3b1..21e3a808 100644 --- a/src/backend/fastapi_app/routes/api_routes.py +++ b/src/backend/fastapi_app/routes/api_routes.py @@ -136,10 +136,8 @@ async def chat_handler( chat_params = rag_flow.get_params(chat_request.messages, chat_request.context.overrides) - contextual_messages, results, thoughts = await rag_flow.prepare_context(chat_params) - response = await rag_flow.answer( - chat_params=chat_params, contextual_messages=contextual_messages, results=results, earlier_thoughts=thoughts - ) + results, thoughts = await rag_flow.prepare_context(chat_params) + response = await rag_flow.answer(chat_params=chat_params, results=results, earlier_thoughts=thoughts) return response except Exception as e: if isinstance(e, APIError) and e.code == "content_filter": @@ -187,10 +185,8 @@ async def chat_stream_handler( # Intentionally do this before we stream down a response, to avoid using database connections during stream # See https://github.com/tiangolo/fastapi/discussions/11321 try: - contextual_messages, results, thoughts = await rag_flow.prepare_context(chat_params) - result = rag_flow.answer_stream( - chat_params=chat_params, contextual_messages=contextual_messages, results=results, earlier_thoughts=thoughts - ) + results, thoughts = await rag_flow.prepare_context(chat_params) + result = rag_flow.answer_stream(chat_params=chat_params, results=results, earlier_thoughts=thoughts) return StreamingResponse(content=format_as_ndjson(result), media_type="application/x-ndjson") except Exception as e: if isinstance(e, APIError) and e.code == "content_filter": diff --git a/src/backend/pyproject.toml b/src/backend/pyproject.toml index cdadc177..0e694634 100644 --- a/src/backend/pyproject.toml +++ b/src/backend/pyproject.toml @@ -19,6 +19,7 @@ dependencies = [ "opentelemetry-instrumentation-sqlalchemy", "opentelemetry-instrumentation-aiohttp-client", "opentelemetry-instrumentation-openai", + "pydantic-ai" ] [build-system] From b1b8746611fbc476b6fa20cc21e16023ae7ecfa5 Mon Sep 17 00:00:00 2001 From: Pamela Fox Date: Mon, 5 May 2025 22:35:57 +0000 Subject: [PATCH 02/10] More Pydantic-AI usage --- src/backend/fastapi_app/__init__.py | 8 ++++- src/backend/fastapi_app/api_models.py | 3 ++ src/backend/fastapi_app/rag_advanced.py | 31 ++++++++++++-------- src/backend/fastapi_app/routes/api_routes.py | 4 +-- 4 files changed, 31 insertions(+), 15 deletions(-) diff --git a/src/backend/fastapi_app/__init__.py b/src/backend/fastapi_app/__init__.py index 5510a2f0..07686e5d 100644 --- a/src/backend/fastapi_app/__init__.py +++ b/src/backend/fastapi_app/__init__.py @@ -34,7 +34,13 @@ class State(TypedDict): @asynccontextmanager async def lifespan(app: fastapi.FastAPI) -> AsyncIterator[State]: context = await common_parameters() - azure_credential = await get_azure_credential() + azure_credential = None + if ( + os.getenv("OPENAI_CHAT_HOST") == "azure" + or os.getenv("OPENAI_EMBED_HOST") == "azure" + or os.getenv("POSTGRES_HOST").endswith(".database.azure.com") + ): + azure_credential = await get_azure_credential() engine = await create_postgres_engine_from_env(azure_credential) sessionmaker = await create_async_sessionmaker(engine) chat_client = await create_openai_chat_client(azure_credential) diff --git a/src/backend/fastapi_app/api_models.py b/src/backend/fastapi_app/api_models.py index 446967ad..4b38af73 100644 --- a/src/backend/fastapi_app/api_models.py +++ b/src/backend/fastapi_app/api_models.py @@ -77,6 +77,9 @@ class ItemPublic(BaseModel): description: str price: float + def to_str_for_rag(self): + return f"Name:{self.name} Description:{self.description} Price:{self.price} Brand:{self.brand} Type:{self.type}" + class ItemWithDistance(ItemPublic): distance: float diff --git a/src/backend/fastapi_app/rag_advanced.py b/src/backend/fastapi_app/rag_advanced.py index 7afc52ce..81aa95c9 100644 --- a/src/backend/fastapi_app/rag_advanced.py +++ b/src/backend/fastapi_app/rag_advanced.py @@ -12,6 +12,7 @@ from fastapi_app.api_models import ( AIChatRoles, + ItemPublic, Message, RAGContext, RetrievalResponse, @@ -50,6 +51,14 @@ class BrandFilter(TypedDict): """The brand name to compare against (e.g., 'AirStrider')""" +class SearchResults(TypedDict): + items: list[ItemPublic] + """List of items that match the search query and filters""" + + filters: list[Union[PriceFilter, BrandFilter]] + """List of filters applied to the search results""" + + class AdvancedRAGChat(RAGChatBase): def __init__( self, @@ -71,7 +80,7 @@ async def search_database( search_query: str, price_filter: Optional[PriceFilter] = None, brand_filter: Optional[BrandFilter] = None, - ) -> list[str]: + ) -> SearchResults: """ Search PostgreSQL database for relevant products based on user query @@ -83,7 +92,6 @@ async def search_database( Returns: List of formatted items that match the search query and filters """ - print(search_query, price_filter, brand_filter) # Only send non-None filters filters = [] if price_filter: @@ -97,9 +105,9 @@ async def search_database( enable_text_search=ctx.deps.enable_text_search, filters=filters, ) - return [f"[{(item.id)}]:{item.to_str_for_rag()}\n\n" for item in results] + return SearchResults(items=[ItemPublic.model_validate(item.to_dict()) for item in results], filters=filters) - async def prepare_context(self, chat_params: ChatParams) -> tuple[str, list[Item], list[ThoughtStep]]: + async def prepare_context(self, chat_params: ChatParams) -> tuple[list[ItemPublic], list[ThoughtStep]]: model = OpenAIModel( os.environ["AZURE_OPENAI_CHAT_DEPLOYMENT"], provider=OpenAIProvider(openai_client=self.openai_chat_client) ) @@ -108,7 +116,7 @@ async def prepare_context(self, chat_params: ChatParams) -> tuple[str, list[Item model_settings=ModelSettings(temperature=0.0, max_tokens=500, seed=chat_params.seed), system_prompt=self.query_prompt_template, tools=[self.search_database], - output_type=list[str], + output_type=SearchResults, ) # TODO: Provide few-shot examples results = await agent.run( @@ -116,9 +124,7 @@ async def prepare_context(self, chat_params: ChatParams) -> tuple[str, list[Item # message_history=chat_params.past_messages, # TODO deps=chat_params, ) - if not isinstance(results, list): - raise ValueError("Search results should be a list of strings") - + items = results.output.items thoughts = [ ThoughtStep( title="Prompt to generate search arguments", @@ -144,12 +150,12 @@ async def prepare_context(self, chat_params: ChatParams) -> tuple[str, list[Item description="", # TODO ), ] - return results, thoughts + return items, thoughts async def answer( self, chat_params: ChatParams, - results: list[str], + items: list[ItemPublic], earlier_thoughts: list[ThoughtStep], ) -> RetrievalResponse: agent = Agent( @@ -163,15 +169,16 @@ async def answer( ), ) + item_references = [item.to_str_for_rag() for item in items] response = await agent.run( - user_prompt=chat_params.original_user_query + "Sources:\n" + "\n".join(results), + user_prompt=chat_params.original_user_query + "Sources:\n" + "\n".join(item_references), message_history=chat_params.past_messages, ) return RetrievalResponse( message=Message(content=str(response.output), role=AIChatRoles.ASSISTANT), context=RAGContext( - data_points={item.id: item.to_dict() for item in results}, + data_points={}, # TODO thoughts=earlier_thoughts + [ ThoughtStep( diff --git a/src/backend/fastapi_app/routes/api_routes.py b/src/backend/fastapi_app/routes/api_routes.py index b57acda9..2130a3ac 100644 --- a/src/backend/fastapi_app/routes/api_routes.py +++ b/src/backend/fastapi_app/routes/api_routes.py @@ -136,8 +136,8 @@ async def chat_handler( chat_params = rag_flow.get_params(chat_request.messages, chat_request.context.overrides) - results, thoughts = await rag_flow.prepare_context(chat_params) - response = await rag_flow.answer(chat_params=chat_params, results=results, earlier_thoughts=thoughts) + items, thoughts = await rag_flow.prepare_context(chat_params) + response = await rag_flow.answer(chat_params=chat_params, items=items, earlier_thoughts=thoughts) return response except Exception as e: if isinstance(e, APIError) and e.code == "content_filter": From 202fa4b8e6458581bbaa66e433ffdd0ce647c7ca Mon Sep 17 00:00:00 2001 From: Pamela Fox Date: Tue, 6 May 2025 22:17:37 +0000 Subject: [PATCH 03/10] More Pydantic AI changes --- src/backend/fastapi_app/api_models.py | 42 +++++++++++------------ src/backend/fastapi_app/openai_clients.py | 2 +- src/backend/fastapi_app/rag_advanced.py | 28 +++++++++------ 3 files changed, 39 insertions(+), 33 deletions(-) diff --git a/src/backend/fastapi_app/api_models.py b/src/backend/fastapi_app/api_models.py index 4b38af73..b7290f0b 100644 --- a/src/backend/fastapi_app/api_models.py +++ b/src/backend/fastapi_app/api_models.py @@ -41,6 +41,26 @@ class ChatRequest(BaseModel): sessionState: Optional[Any] = None +class ItemPublic(BaseModel): + id: int + type: str + brand: str + name: str + description: str + price: float + + def to_str_for_rag(self): + return f"Name:{self.name} Description:{self.description} Price:{self.price} Brand:{self.brand} Type:{self.type}" + + +class ItemWithDistance(ItemPublic): + distance: float + + def __init__(self, **data): + super().__init__(**data) + self.distance = round(self.distance, 2) + + class ThoughtStep(BaseModel): title: str description: Any @@ -48,7 +68,7 @@ class ThoughtStep(BaseModel): class RAGContext(BaseModel): - data_points: dict[int, dict[str, Any]] + data_points: dict[int, ItemPublic] thoughts: list[ThoughtStep] followup_questions: Optional[list[str]] = None @@ -69,26 +89,6 @@ class RetrievalResponseDelta(BaseModel): sessionState: Optional[Any] = None -class ItemPublic(BaseModel): - id: int - type: str - brand: str - name: str - description: str - price: float - - def to_str_for_rag(self): - return f"Name:{self.name} Description:{self.description} Price:{self.price} Brand:{self.brand} Type:{self.type}" - - -class ItemWithDistance(ItemPublic): - distance: float - - def __init__(self, **data): - super().__init__(**data) - self.distance = round(self.distance, 2) - - class ChatParams(ChatRequestOverrides): prompt_template: str response_token_limit: int = 1024 diff --git a/src/backend/fastapi_app/openai_clients.py b/src/backend/fastapi_app/openai_clients.py index e83e0c41..57dcbc96 100644 --- a/src/backend/fastapi_app/openai_clients.py +++ b/src/backend/fastapi_app/openai_clients.py @@ -14,7 +14,7 @@ async def create_openai_chat_client( openai_chat_client: Union[openai.AsyncAzureOpenAI, openai.AsyncOpenAI] OPENAI_CHAT_HOST = os.getenv("OPENAI_CHAT_HOST") if OPENAI_CHAT_HOST == "azure": - api_version = os.environ["AZURE_OPENAI_VERSION"] or "2024-03-01-preview" + api_version = os.environ["AZURE_OPENAI_VERSION"] or "2024-10-21" azure_endpoint = os.environ["AZURE_OPENAI_ENDPOINT"] azure_deployment = os.environ["AZURE_OPENAI_CHAT_DEPLOYMENT"] if api_key := os.getenv("AZURE_OPENAI_KEY"): diff --git a/src/backend/fastapi_app/rag_advanced.py b/src/backend/fastapi_app/rag_advanced.py index 81aa95c9..636d9fa1 100644 --- a/src/backend/fastapi_app/rag_advanced.py +++ b/src/backend/fastapi_app/rag_advanced.py @@ -52,6 +52,9 @@ class BrandFilter(TypedDict): class SearchResults(TypedDict): + query: str + """The original search query""" + items: list[ItemPublic] """List of items that match the search query and filters""" @@ -105,7 +108,9 @@ async def search_database( enable_text_search=ctx.deps.enable_text_search, filters=filters, ) - return SearchResults(items=[ItemPublic.model_validate(item.to_dict()) for item in results], filters=filters) + return SearchResults( + query=search_query, items=[ItemPublic.model_validate(item.to_dict()) for item in results], filters=filters + ) async def prepare_context(self, chat_params: ChatParams) -> tuple[list[ItemPublic], list[ThoughtStep]]: model = OpenAIModel( @@ -119,35 +124,36 @@ async def prepare_context(self, chat_params: ChatParams) -> tuple[list[ItemPubli output_type=SearchResults, ) # TODO: Provide few-shot examples + user_query = f"Find search results for user query: {chat_params.original_user_query}" results = await agent.run( - f"Find search results for user query: {chat_params.original_user_query}", - # message_history=chat_params.past_messages, # TODO + user_query, + message_history=chat_params.past_messages, deps=chat_params, ) - items = results.output.items + items = results.output["items"] thoughts = [ ThoughtStep( title="Prompt to generate search arguments", - description=chat_params.past_messages, # TODO: update this + description=results.all_messages(), props=( {"model": self.chat_model, "deployment": self.chat_deployment} if self.chat_deployment - else {"model": self.chat_model} + else {"model": self.chat_model} # TODO ), ), ThoughtStep( title="Search using generated search arguments", - description=chat_params.original_user_query, # TODO: + description=results.output["query"], props={ "top": chat_params.top, "vector_search": chat_params.enable_vector_search, "text_search": chat_params.enable_text_search, - "filters": [], # TODO + "filters": results.output["filters"], }, ), ThoughtStep( title="Search results", - description="", # TODO + description=items, ), ] return items, thoughts @@ -178,12 +184,12 @@ async def answer( return RetrievalResponse( message=Message(content=str(response.output), role=AIChatRoles.ASSISTANT), context=RAGContext( - data_points={}, # TODO + data_points={item.id: item for item in items}, thoughts=earlier_thoughts + [ ThoughtStep( title="Prompt to generate answer", - description="", # TODO: update + description=response.all_messages(), props=( {"model": self.chat_model, "deployment": self.chat_deployment} if self.chat_deployment From 020434e3ab396522fbf4c964b81339e1d8feac22 Mon Sep 17 00:00:00 2001 From: Pamela Fox Date: Wed, 7 May 2025 19:22:08 +0000 Subject: [PATCH 04/10] Port over fewshots to Pydantic format --- .../fastapi_app/prompts/query_fewshots.json | 106 ++++++++++++------ src/backend/fastapi_app/rag_advanced.py | 11 +- src/backend/fastapi_app/rag_base.py | 3 +- src/frontend/src/components/Answer/Answer.tsx | 1 - 4 files changed, 81 insertions(+), 40 deletions(-) diff --git a/src/backend/fastapi_app/prompts/query_fewshots.json b/src/backend/fastapi_app/prompts/query_fewshots.json index d5a026f2..d5ab7f2b 100644 --- a/src/backend/fastapi_app/prompts/query_fewshots.json +++ b/src/backend/fastapi_app/prompts/query_fewshots.json @@ -1,34 +1,76 @@ [ - {"role": "user", "content": "good options for climbing gear that can be used outside?"}, - {"role": "assistant", "tool_calls": [ - { - "id": "call_abc123", - "type": "function", - "function": { - "arguments": "{\"search_query\":\"climbing gear outside\"}", - "name": "search_database" - } - } - ]}, - { - "role": "tool", - "tool_call_id": "call_abc123", - "content": "Search results for climbing gear that can be used outside: ..." - }, - {"role": "user", "content": "are there any shoes less than $50?"}, - {"role": "assistant", "tool_calls": [ - { - "id": "call_abc456", - "type": "function", - "function": { - "arguments": "{\"search_query\":\"shoes\",\"price_filter\":{\"comparison_operator\":\"<\",\"value\":50}}", - "name": "search_database" - } - } - ]}, - { - "role": "tool", - "tool_call_id": "call_abc456", - "content": "Search results for shoes cheaper than 50: ..." - } + { + "parts": [ + { + "content": "good options for climbing gear that can be used outside?", + "timestamp": "2025-05-07T19:02:46.977501Z", + "part_kind": "user-prompt" + } + ], + "instructions": null, + "kind": "request" + }, + { + "parts": [ + { + "tool_name": "search_database", + "args": "{\"search_query\":\"climbing gear outside\"}", + "tool_call_id": "call_4HeBCmo2uioV6CyoePEGyZPc", + "part_kind": "tool-call" + } + ], + "model_name": "gpt-4o-mini-2024-07-18", + "timestamp": "2025-05-07T19:02:47Z", + "kind": "response" + }, + { + "parts": [ + { + "tool_name": "search_database", + "content": "Search results for climbing gear that can be used outside: ...", + "tool_call_id": "call_4HeBCmo2uioV6CyoePEGyZPc", + "timestamp": "2025-05-07T19:02:48.242408Z", + "part_kind": "tool-return" + } + ], + "instructions": null, + "kind": "request" + }, + { + "parts": [ + { + "content": "are there any shoes less than $50?", + "timestamp": "2025-05-07T19:02:46.977501Z", + "part_kind": "user-prompt" + } + ], + "instructions": null, + "kind": "request" + }, + { + "parts": [ + { + "tool_name": "search_database", + "args": "{\"search_query\":\"shoes\",\"price_filter\":{\"comparison_operator\":\"<\",\"value\":50}}", + "tool_call_id": "call_4HeBCmo2uioV6CyoePEGyZPc", + "part_kind": "tool-call" + } + ], + "model_name": "gpt-4o-mini-2024-07-18", + "timestamp": "2025-05-07T19:02:47Z", + "kind": "response" + }, + { + "parts": [ + { + "tool_name": "search_database", + "content": "Search results for shoes cheaper than 50: ...", + "tool_call_id": "call_4HeBCmo2uioV6CyoePEGyZPc", + "timestamp": "2025-05-07T19:02:48.242408Z", + "part_kind": "tool-return" + } + ], + "instructions": null, + "kind": "request" + } ] diff --git a/src/backend/fastapi_app/rag_advanced.py b/src/backend/fastapi_app/rag_advanced.py index 636d9fa1..b178563a 100644 --- a/src/backend/fastapi_app/rag_advanced.py +++ b/src/backend/fastapi_app/rag_advanced.py @@ -6,6 +6,7 @@ from openai.types.chat import ChatCompletionChunk, ChatCompletionMessageParam from openai_messages_token_helper import get_token_limit from pydantic_ai import Agent, RunContext +from pydantic_ai.messages import ModelMessagesTypeAdapter from pydantic_ai.models.openai import OpenAIModel from pydantic_ai.providers.openai import OpenAIProvider from pydantic_ai.settings import ModelSettings @@ -119,15 +120,15 @@ async def prepare_context(self, chat_params: ChatParams) -> tuple[list[ItemPubli agent = Agent( model, model_settings=ModelSettings(temperature=0.0, max_tokens=500, seed=chat_params.seed), - system_prompt=self.query_prompt_template, + instructions=self.query_prompt_template, tools=[self.search_database], output_type=SearchResults, ) - # TODO: Provide few-shot examples + few_shots = ModelMessagesTypeAdapter.validate_json(self.query_fewshots) user_query = f"Find search results for user query: {chat_params.original_user_query}" results = await agent.run( user_query, - message_history=chat_params.past_messages, + message_history=few_shots + chat_params.past_messages, deps=chat_params, ) items = results.output["items"] @@ -175,9 +176,9 @@ async def answer( ), ) - item_references = [item.to_str_for_rag() for item in items] + sources_content = [f"[{(item.id)}]:{item.to_str_for_rag()}\n\n" for item in items] response = await agent.run( - user_prompt=chat_params.original_user_query + "Sources:\n" + "\n".join(item_references), + user_prompt=chat_params.original_user_query + "Sources:\n" + "\n".join(sources_content), message_history=chat_params.past_messages, ) diff --git a/src/backend/fastapi_app/rag_base.py b/src/backend/fastapi_app/rag_base.py index 34fba44a..7a5c496b 100644 --- a/src/backend/fastapi_app/rag_base.py +++ b/src/backend/fastapi_app/rag_base.py @@ -1,4 +1,3 @@ -import json import pathlib from abc import ABC, abstractmethod from collections.abc import AsyncGenerator @@ -18,7 +17,7 @@ class RAGChatBase(ABC): current_dir = pathlib.Path(__file__).parent query_prompt_template = open(current_dir / "prompts/query.txt").read() - query_fewshots = json.loads(open(current_dir / "prompts/query_fewshots.json").read()) + query_fewshots = open(current_dir / "prompts/query_fewshots.json").read() answer_prompt_template = open(current_dir / "prompts/answer.txt").read() def get_params(self, messages: list[ChatCompletionMessageParam], overrides: ChatRequestOverrides) -> ChatParams: diff --git a/src/frontend/src/components/Answer/Answer.tsx b/src/frontend/src/components/Answer/Answer.tsx index a542064c..9726c7b6 100644 --- a/src/frontend/src/components/Answer/Answer.tsx +++ b/src/frontend/src/components/Answer/Answer.tsx @@ -34,7 +34,6 @@ export const Answer = ({ const parsedAnswer = useMemo(() => parseAnswerToHtml(messageContent, isStreaming, onCitationClicked), [answer]); const sanitizedAnswerHtml = DOMPurify.sanitize(parsedAnswer.answerHtml); - return ( From 076f3674441c4a63dceb9527b0a27d8347682542 Mon Sep 17 00:00:00 2001 From: Pamela Fox Date: Wed, 7 May 2025 19:22:57 +0000 Subject: [PATCH 05/10] Port over fewshots to Pydantic format --- src/backend/fastapi_app/rag_advanced.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/backend/fastapi_app/rag_advanced.py b/src/backend/fastapi_app/rag_advanced.py index b178563a..326d03e2 100644 --- a/src/backend/fastapi_app/rag_advanced.py +++ b/src/backend/fastapi_app/rag_advanced.py @@ -120,7 +120,7 @@ async def prepare_context(self, chat_params: ChatParams) -> tuple[list[ItemPubli agent = Agent( model, model_settings=ModelSettings(temperature=0.0, max_tokens=500, seed=chat_params.seed), - instructions=self.query_prompt_template, + system_prompt=self.query_prompt_template, tools=[self.search_database], output_type=SearchResults, ) From 62502b139117f63c486f5b22fa454e4a94b2ca83 Mon Sep 17 00:00:00 2001 From: Pamela Fox Date: Wed, 7 May 2025 21:08:12 +0000 Subject: [PATCH 06/10] Finish refactoring of rag flows --- src/backend/fastapi_app/rag_advanced.py | 161 ++++++++---------- src/backend/fastapi_app/rag_base.py | 15 +- src/backend/fastapi_app/rag_simple.py | 167 ++++++++----------- src/backend/fastapi_app/routes/api_routes.py | 30 ++-- 4 files changed, 164 insertions(+), 209 deletions(-) diff --git a/src/backend/fastapi_app/rag_advanced.py b/src/backend/fastapi_app/rag_advanced.py index 326d03e2..248a4d22 100644 --- a/src/backend/fastapi_app/rag_advanced.py +++ b/src/backend/fastapi_app/rag_advanced.py @@ -1,10 +1,8 @@ -import os from collections.abc import AsyncGenerator from typing import Optional, TypedDict, Union -from openai import AsyncAzureOpenAI, AsyncOpenAI, AsyncStream -from openai.types.chat import ChatCompletionChunk, ChatCompletionMessageParam -from openai_messages_token_helper import get_token_limit +from openai import AsyncAzureOpenAI, AsyncOpenAI +from openai.types.chat import ChatCompletionMessageParam from pydantic_ai import Agent, RunContext from pydantic_ai.messages import ModelMessagesTypeAdapter from pydantic_ai.models.openai import OpenAIModel @@ -13,6 +11,7 @@ from fastapi_app.api_models import ( AIChatRoles, + ChatRequestOverrides, ItemPublic, Message, RAGContext, @@ -20,15 +19,9 @@ RetrievalResponseDelta, ThoughtStep, ) -from fastapi_app.postgres_models import Item from fastapi_app.postgres_searcher import PostgresSearcher from fastapi_app.rag_base import ChatParams, RAGChatBase -# Experiment #1: Annotated did not work! -# Experiment #2: Function-level docstring, Inline docstrings next to attributes -# Function -level docstring leads to XML like this: Search ... -# Experiment #3: Move the docstrings below the attributes in triple-quoted strings - SUCCESS!!! - class PriceFilter(TypedDict): column: str = "price" @@ -64,19 +57,44 @@ class SearchResults(TypedDict): class AdvancedRAGChat(RAGChatBase): + query_prompt_template = open(RAGChatBase.prompts_dir / "query.txt").read() + query_fewshots = open(RAGChatBase.prompts_dir / "query_fewshots.json").read() + def __init__( self, *, + messages: list[ChatCompletionMessageParam], + overrides: ChatRequestOverrides, searcher: PostgresSearcher, openai_chat_client: Union[AsyncOpenAI, AsyncAzureOpenAI], chat_model: str, chat_deployment: Optional[str], # Not needed for non-Azure OpenAI ): self.searcher = searcher - self.openai_chat_client = openai_chat_client - self.chat_model = chat_model - self.chat_deployment = chat_deployment - self.chat_token_limit = get_token_limit(chat_model, default_to_minimum=True) + self.chat_params = self.get_chat_params(messages, overrides) + self.model_for_thoughts = ( + {"model": chat_model, "deployment": chat_deployment} if chat_deployment else {"model": chat_model} + ) + pydantic_chat_model = OpenAIModel( + chat_model if chat_deployment is None else chat_deployment, + provider=OpenAIProvider(openai_client=openai_chat_client), + ) + self.search_agent = Agent( + pydantic_chat_model, + model_settings=ModelSettings(temperature=0.0, max_tokens=500, seed=self.chat_params.seed), + system_prompt=self.query_prompt_template, + tools=[self.search_database], + output_type=SearchResults, + ) + self.answer_agent = Agent( + pydantic_chat_model, + system_prompt=self.answer_prompt_template, + model_settings=ModelSettings( + temperature=self.chat_params.temperature, + max_tokens=self.chat_params.response_token_limit, + seed=self.chat_params.seed, + ), + ) async def search_database( self, @@ -113,42 +131,28 @@ async def search_database( query=search_query, items=[ItemPublic.model_validate(item.to_dict()) for item in results], filters=filters ) - async def prepare_context(self, chat_params: ChatParams) -> tuple[list[ItemPublic], list[ThoughtStep]]: - model = OpenAIModel( - os.environ["AZURE_OPENAI_CHAT_DEPLOYMENT"], provider=OpenAIProvider(openai_client=self.openai_chat_client) - ) - agent = Agent( - model, - model_settings=ModelSettings(temperature=0.0, max_tokens=500, seed=chat_params.seed), - system_prompt=self.query_prompt_template, - tools=[self.search_database], - output_type=SearchResults, - ) + async def prepare_context(self) -> tuple[list[ItemPublic], list[ThoughtStep]]: few_shots = ModelMessagesTypeAdapter.validate_json(self.query_fewshots) - user_query = f"Find search results for user query: {chat_params.original_user_query}" - results = await agent.run( + user_query = f"Find search results for user query: {self.chat_params.original_user_query}" + results = await self.search_agent.run( user_query, - message_history=few_shots + chat_params.past_messages, - deps=chat_params, + message_history=few_shots + self.chat_params.past_messages, + deps=self.chat_params, ) items = results.output["items"] thoughts = [ ThoughtStep( title="Prompt to generate search arguments", description=results.all_messages(), - props=( - {"model": self.chat_model, "deployment": self.chat_deployment} - if self.chat_deployment - else {"model": self.chat_model} # TODO - ), + props=self.model_for_thoughts, ), ThoughtStep( title="Search using generated search arguments", description=results.output["query"], props={ - "top": chat_params.top, - "vector_search": chat_params.enable_vector_search, - "text_search": chat_params.enable_text_search, + "top": self.chat_params.top, + "vector_search": self.chat_params.enable_vector_search, + "text_search": self.chat_params.enable_text_search, "filters": results.output["filters"], }, ), @@ -161,25 +165,12 @@ async def prepare_context(self, chat_params: ChatParams) -> tuple[list[ItemPubli async def answer( self, - chat_params: ChatParams, items: list[ItemPublic], earlier_thoughts: list[ThoughtStep], ) -> RetrievalResponse: - agent = Agent( - OpenAIModel( - os.environ["AZURE_OPENAI_CHAT_DEPLOYMENT"], - provider=OpenAIProvider(openai_client=self.openai_chat_client), - ), - system_prompt=self.answer_prompt_template, - model_settings=ModelSettings( - temperature=chat_params.temperature, max_tokens=chat_params.response_token_limit, seed=chat_params.seed - ), - ) - - sources_content = [f"[{(item.id)}]:{item.to_str_for_rag()}\n\n" for item in items] - response = await agent.run( - user_prompt=chat_params.original_user_query + "Sources:\n" + "\n".join(sources_content), - message_history=chat_params.past_messages, + response = await self.answer_agent.run( + user_prompt=self.prepare_rag_request(self.chat_params.original_user_query, items), + message_history=self.chat_params.past_messages, ) return RetrievalResponse( @@ -191,11 +182,7 @@ async def answer( ThoughtStep( title="Prompt to generate answer", description=response.all_messages(), - props=( - {"model": self.chat_model, "deployment": self.chat_deployment} - if self.chat_deployment - else {"model": self.chat_model} - ), + props=self.model_for_thoughts, ), ], ), @@ -203,45 +190,27 @@ async def answer( async def answer_stream( self, - chat_params: ChatParams, - contextual_messages: list[ChatCompletionMessageParam], - results: list[Item], + items: list[ItemPublic], earlier_thoughts: list[ThoughtStep], ) -> AsyncGenerator[RetrievalResponseDelta, None]: - chat_completion_async_stream: AsyncStream[ - ChatCompletionChunk - ] = await self.openai_chat_client.chat.completions.create( - # Azure OpenAI takes the deployment name as the model name - model=self.chat_deployment if self.chat_deployment else self.chat_model, - messages=contextual_messages, - temperature=chat_params.temperature, - max_tokens=chat_params.response_token_limit, - n=1, - stream=True, - ) - - yield RetrievalResponseDelta( - context=RAGContext( - data_points={item.id: item.to_dict() for item in results}, - thoughts=earlier_thoughts - + [ - ThoughtStep( - title="Prompt to generate answer", - description=contextual_messages, - props=( - {"model": self.chat_model, "deployment": self.chat_deployment} - if self.chat_deployment - else {"model": self.chat_model} + async with self.answer_agent.run_stream( + self.prepare_rag_request(self.chat_params.original_user_query, items), + message_history=self.chat_params.past_messages, + ) as agent_stream_runner: + yield RetrievalResponseDelta( + context=RAGContext( + data_points={item.id: item for item in items}, + thoughts=earlier_thoughts + + [ + ThoughtStep( + title="Prompt to generate answer", + description=agent_stream_runner.all_messages(), + props=self.model_for_thoughts, ), - ), - ], - ), - ) + ], + ), + ) - async for response_chunk in chat_completion_async_stream: - # first response has empty choices and last response has empty content - if response_chunk.choices and response_chunk.choices[0].delta.content: - yield RetrievalResponseDelta( - delta=Message(content=str(response_chunk.choices[0].delta.content), role=AIChatRoles.ASSISTANT) - ) - return + async for message in agent_stream_runner.stream_text(delta=True, debounce_by=None): + yield RetrievalResponseDelta(delta=Message(content=str(message), role=AIChatRoles.ASSISTANT)) + return diff --git a/src/backend/fastapi_app/rag_base.py b/src/backend/fastapi_app/rag_base.py index 7a5c496b..c1c28ccd 100644 --- a/src/backend/fastapi_app/rag_base.py +++ b/src/backend/fastapi_app/rag_base.py @@ -7,6 +7,7 @@ from fastapi_app.api_models import ( ChatParams, ChatRequestOverrides, + ItemPublic, RetrievalResponse, RetrievalResponseDelta, ThoughtStep, @@ -15,12 +16,12 @@ class RAGChatBase(ABC): - current_dir = pathlib.Path(__file__).parent - query_prompt_template = open(current_dir / "prompts/query.txt").read() - query_fewshots = open(current_dir / "prompts/query_fewshots.json").read() - answer_prompt_template = open(current_dir / "prompts/answer.txt").read() + prompts_dir = pathlib.Path(__file__).parent / "prompts/" + answer_prompt_template = open(prompts_dir / "answer.txt").read() - def get_params(self, messages: list[ChatCompletionMessageParam], overrides: ChatRequestOverrides) -> ChatParams: + def get_chat_params( + self, messages: list[ChatCompletionMessageParam], overrides: ChatRequestOverrides + ) -> ChatParams: response_token_limit = 1024 prompt_template = overrides.prompt_template or self.answer_prompt_template @@ -52,6 +53,10 @@ async def prepare_context( ) -> tuple[list[ChatCompletionMessageParam], list[Item], list[ThoughtStep]]: raise NotImplementedError + def prepare_rag_request(self, user_query, items: list[ItemPublic]) -> str: + sources_str = "\n".join([f"[{item.id}]:{item.to_str_for_rag()}" for item in items]) + return f"{user_query}Sources:\n{sources_str}" + @abstractmethod async def answer( self, diff --git a/src/backend/fastapi_app/rag_simple.py b/src/backend/fastapi_app/rag_simple.py index 79350ab7..ad748320 100644 --- a/src/backend/fastapi_app/rag_simple.py +++ b/src/backend/fastapi_app/rag_simple.py @@ -1,115 +1,104 @@ from collections.abc import AsyncGenerator from typing import Optional, Union -from openai import AsyncAzureOpenAI, AsyncOpenAI, AsyncStream -from openai.types.chat import ChatCompletion, ChatCompletionChunk, ChatCompletionMessageParam -from openai_messages_token_helper import build_messages, get_token_limit +from openai import AsyncAzureOpenAI, AsyncOpenAI +from openai.types.chat import ChatCompletionMessageParam +from pydantic_ai import Agent +from pydantic_ai.models.openai import OpenAIModel +from pydantic_ai.providers.openai import OpenAIProvider +from pydantic_ai.settings import ModelSettings from fastapi_app.api_models import ( AIChatRoles, + ChatRequestOverrides, + ItemPublic, Message, RAGContext, RetrievalResponse, RetrievalResponseDelta, ThoughtStep, ) -from fastapi_app.postgres_models import Item from fastapi_app.postgres_searcher import PostgresSearcher -from fastapi_app.rag_base import ChatParams, RAGChatBase +from fastapi_app.rag_base import RAGChatBase class SimpleRAGChat(RAGChatBase): def __init__( self, *, + messages: list[ChatCompletionMessageParam], + overrides: ChatRequestOverrides, searcher: PostgresSearcher, openai_chat_client: Union[AsyncOpenAI, AsyncAzureOpenAI], chat_model: str, chat_deployment: Optional[str], # Not needed for non-Azure OpenAI ): self.searcher = searcher - self.openai_chat_client = openai_chat_client - self.chat_model = chat_model - self.chat_deployment = chat_deployment - self.chat_token_limit = get_token_limit(chat_model, default_to_minimum=True) + self.chat_params = self.get_chat_params(messages, overrides) + self.model_for_thoughts = ( + {"model": chat_model, "deployment": chat_deployment} if chat_deployment else {"model": chat_model} + ) + pydantic_chat_model = OpenAIModel( + chat_model if chat_deployment is None else chat_deployment, + provider=OpenAIProvider(openai_client=openai_chat_client), + ) + self.answer_agent = Agent( + pydantic_chat_model, + system_prompt=self.answer_prompt_template, + model_settings=ModelSettings( + temperature=self.chat_params.temperature, + max_tokens=self.chat_params.response_token_limit, + seed=self.chat_params.seed, + ), + ) - async def prepare_context( - self, chat_params: ChatParams - ) -> tuple[list[ChatCompletionMessageParam], list[Item], list[ThoughtStep]]: + async def prepare_context(self) -> tuple[list[ItemPublic], list[ThoughtStep]]: """Retrieve relevant rows from the database and build a context for the chat model.""" - # Retrieve relevant rows from the database results = await self.searcher.search_and_embed( - chat_params.original_user_query, - top=chat_params.top, - enable_vector_search=chat_params.enable_vector_search, - enable_text_search=chat_params.enable_text_search, - ) - - sources_content = [f"[{(item.id)}]:{item.to_str_for_rag()}\n\n" for item in results] - content = "\n".join(sources_content) - - # Generate a contextual and content specific answer using the search results and chat history - contextual_messages: list[ChatCompletionMessageParam] = build_messages( - model=self.chat_model, - system_prompt=chat_params.prompt_template, - new_user_content=chat_params.original_user_query + "\n\nSources:\n" + content, - past_messages=chat_params.past_messages, - max_tokens=self.chat_token_limit - chat_params.response_token_limit, - fallback_to_default=True, + self.chat_params.original_user_query, + top=self.chat_params.top, + enable_vector_search=self.chat_params.enable_vector_search, + enable_text_search=self.chat_params.enable_text_search, ) + items = [ItemPublic.model_validate(item.to_dict()) for item in results] thoughts = [ ThoughtStep( title="Search query for database", - description=chat_params.original_user_query, + description=self.chat_params.original_user_query, props={ - "top": chat_params.top, - "vector_search": chat_params.enable_vector_search, - "text_search": chat_params.enable_text_search, + "top": self.chat_params.top, + "vector_search": self.chat_params.enable_vector_search, + "text_search": self.chat_params.enable_text_search, }, ), ThoughtStep( title="Search results", - description=[result.to_dict() for result in results], + description=items, ), ] - return contextual_messages, results, thoughts + return items, thoughts async def answer( self, - chat_params: ChatParams, - contextual_messages: list[ChatCompletionMessageParam], - results: list[Item], + items: list[ItemPublic], earlier_thoughts: list[ThoughtStep], ) -> RetrievalResponse: - chat_completion_response: ChatCompletion = await self.openai_chat_client.chat.completions.create( - # Azure OpenAI takes the deployment name as the model name - model=self.chat_deployment if self.chat_deployment else self.chat_model, - messages=contextual_messages, - temperature=chat_params.temperature, - max_tokens=chat_params.response_token_limit, - n=1, - stream=False, - seed=chat_params.seed, + response = await self.answer_agent.run( + user_prompt=self.prepare_rag_request(self.chat_params.original_user_query, items), + message_history=self.chat_params.past_messages, ) - return RetrievalResponse( - message=Message( - content=str(chat_completion_response.choices[0].message.content), role=AIChatRoles.ASSISTANT - ), + message=Message(content=str(response.output), role=AIChatRoles.ASSISTANT), context=RAGContext( - data_points={item.id: item.to_dict() for item in results}, + data_points={item.id: item for item in items}, thoughts=earlier_thoughts + [ ThoughtStep( title="Prompt to generate answer", - description=contextual_messages, - props=( - {"model": self.chat_model, "deployment": self.chat_deployment} - if self.chat_deployment - else {"model": self.chat_model} - ), + description=response.all_messages(), + props=self.model_for_thoughts, ), ], ), @@ -117,45 +106,27 @@ async def answer( async def answer_stream( self, - chat_params: ChatParams, - contextual_messages: list[ChatCompletionMessageParam], - results: list[Item], + items: list[ItemPublic], earlier_thoughts: list[ThoughtStep], ) -> AsyncGenerator[RetrievalResponseDelta, None]: - chat_completion_async_stream: AsyncStream[ - ChatCompletionChunk - ] = await self.openai_chat_client.chat.completions.create( - # Azure OpenAI takes the deployment name as the model name - model=self.chat_deployment if self.chat_deployment else self.chat_model, - messages=contextual_messages, - temperature=chat_params.temperature, - max_tokens=chat_params.response_token_limit, - n=1, - stream=True, - seed=chat_params.seed, - ) - - yield RetrievalResponseDelta( - context=RAGContext( - data_points={item.id: item.to_dict() for item in results}, - thoughts=earlier_thoughts - + [ - ThoughtStep( - title="Prompt to generate answer", - description=contextual_messages, - props=( - {"model": self.chat_model, "deployment": self.chat_deployment} - if self.chat_deployment - else {"model": self.chat_model} + async with self.answer_agent.run_stream( + self.prepare_rag_request(self.chat_params.original_user_query, items), + message_history=self.chat_params.past_messages, + ) as agent_stream_runner: + yield RetrievalResponseDelta( + context=RAGContext( + data_points={item.id: item for item in items}, + thoughts=earlier_thoughts + + [ + ThoughtStep( + title="Prompt to generate answer", + description=agent_stream_runner.all_messages(), + props=self.model_for_thoughts, ), - ), - ], - ), - ) - async for response_chunk in chat_completion_async_stream: - # first response has empty choices and last response has empty content - if response_chunk.choices and response_chunk.choices[0].delta.content: - yield RetrievalResponseDelta( - delta=Message(content=str(response_chunk.choices[0].delta.content), role=AIChatRoles.ASSISTANT) - ) - return + ], + ), + ) + + async for message in agent_stream_runner.stream_text(delta=True, debounce_by=None): + yield RetrievalResponseDelta(delta=Message(content=str(message), role=AIChatRoles.ASSISTANT)) + return diff --git a/src/backend/fastapi_app/routes/api_routes.py b/src/backend/fastapi_app/routes/api_routes.py index 2130a3ac..f566886c 100644 --- a/src/backend/fastapi_app/routes/api_routes.py +++ b/src/backend/fastapi_app/routes/api_routes.py @@ -121,6 +121,8 @@ async def chat_handler( rag_flow: Union[SimpleRAGChat, AdvancedRAGChat] if chat_request.context.overrides.use_advanced_flow: rag_flow = AdvancedRAGChat( + messages=chat_request.messages, + overrides=chat_request.context.overrides, searcher=searcher, openai_chat_client=openai_chat.client, chat_model=context.openai_chat_model, @@ -128,16 +130,16 @@ async def chat_handler( ) else: rag_flow = SimpleRAGChat( + messages=chat_request.messages, + overrides=chat_request.context.overrides, searcher=searcher, openai_chat_client=openai_chat.client, chat_model=context.openai_chat_model, chat_deployment=context.openai_chat_deployment, ) - chat_params = rag_flow.get_params(chat_request.messages, chat_request.context.overrides) - - items, thoughts = await rag_flow.prepare_context(chat_params) - response = await rag_flow.answer(chat_params=chat_params, items=items, earlier_thoughts=thoughts) + items, thoughts = await rag_flow.prepare_context() + response = await rag_flow.answer(items=items, earlier_thoughts=thoughts) return response except Exception as e: if isinstance(e, APIError) and e.code == "content_filter": @@ -167,6 +169,8 @@ async def chat_stream_handler( rag_flow: Union[SimpleRAGChat, AdvancedRAGChat] if chat_request.context.overrides.use_advanced_flow: rag_flow = AdvancedRAGChat( + messages=chat_request.messages, + overrides=chat_request.context.overrides, searcher=searcher, openai_chat_client=openai_chat.client, chat_model=context.openai_chat_model, @@ -174,19 +178,19 @@ async def chat_stream_handler( ) else: rag_flow = SimpleRAGChat( + messages=chat_request.messages, + overrides=chat_request.context.overrides, searcher=searcher, openai_chat_client=openai_chat.client, chat_model=context.openai_chat_model, chat_deployment=context.openai_chat_deployment, ) - chat_params = rag_flow.get_params(chat_request.messages, chat_request.context.overrides) - - # Intentionally do this before we stream down a response, to avoid using database connections during stream - # See https://github.com/tiangolo/fastapi/discussions/11321 try: - results, thoughts = await rag_flow.prepare_context(chat_params) - result = rag_flow.answer_stream(chat_params=chat_params, results=results, earlier_thoughts=thoughts) + # Intentionally do search we stream down the answer, to avoid using database connections during stream + # See https://github.com/tiangolo/fastapi/discussions/11321 + items, thoughts = await rag_flow.prepare_context() + result = rag_flow.answer_stream(items, thoughts) return StreamingResponse(content=format_as_ndjson(result), media_type="application/x-ndjson") except Exception as e: if isinstance(e, APIError) and e.code == "content_filter": @@ -194,3 +198,9 @@ async def chat_stream_handler( content=json.dumps(ERROR_FILTER) + "\n", media_type="application/x-ndjson", ) + else: + logging.exception("Exception while generating response: %s", e) + return StreamingResponse( + content=json.dumps({"error": str(e)}, ensure_ascii=False) + "\n", + media_type="application/x-ndjson", + ) From 9871f973024ef69dfe8dd90adb3d15148d0428b2 Mon Sep 17 00:00:00 2001 From: Pamela Fox Date: Wed, 7 May 2025 22:31:58 +0000 Subject: [PATCH 07/10] Test updates --- src/backend/fastapi_app/__init__.py | 15 +- src/backend/fastapi_app/routes/api_routes.py | 3 + tests/conftest.py | 63 +++++- .../advanced_chat_flow_response.json | 214 +++++++++++++----- ...ced_chat_streaming_flow_response.jsonlines | 2 +- .../simple_chat_flow_response.json | 28 ++- ...ple_chat_streaming_flow_response.jsonlines | 2 +- 7 files changed, 257 insertions(+), 70 deletions(-) diff --git a/src/backend/fastapi_app/__init__.py b/src/backend/fastapi_app/__init__.py index 07686e5d..ddbd72c6 100644 --- a/src/backend/fastapi_app/__init__.py +++ b/src/backend/fastapi_app/__init__.py @@ -58,10 +58,17 @@ def create_app(testing: bool = False): else: if not testing: load_dotenv(override=True) - logging.basicConfig(level=logging.INFO) - # Turn off particularly noisy INFO level logs from Azure Core SDK: - logging.getLogger("azure.core.pipeline.policies.http_logging_policy").setLevel(logging.WARNING) - logging.getLogger("azure.identity").setLevel(logging.WARNING) + logging.basicConfig(level=logging.DEBUG) + + # Enable detailed HTTP traffic logging + logging.getLogger("httpx").setLevel(logging.DEBUG) + logging.getLogger("azure.core.pipeline.policies.http_logging_policy").setLevel(logging.DEBUG) + logging.getLogger("azure.identity").setLevel(logging.DEBUG) + logging.getLogger("urllib3").setLevel(logging.DEBUG) + + # Configure httpx logging to show full bodies + os.environ["HTTPX_LOG_LEVEL"] = "DEBUG" + os.environ["HTTPCORE_LOG_LEVEL"] = "DEBUG" if os.getenv("APPLICATIONINSIGHTS_CONNECTION_STRING"): logger.info("Configuring Azure Monitor") diff --git a/src/backend/fastapi_app/routes/api_routes.py b/src/backend/fastapi_app/routes/api_routes.py index f566886c..04a83c86 100644 --- a/src/backend/fastapi_app/routes/api_routes.py +++ b/src/backend/fastapi_app/routes/api_routes.py @@ -1,3 +1,4 @@ +import http.client import json import logging from collections.abc import AsyncGenerator @@ -26,6 +27,8 @@ router = fastapi.APIRouter() +http.client.HTTPConnection.debuglevel = 1 + ERROR_FILTER = {"error": "Your message contains content that was flagged by the content filter."} diff --git a/tests/conftest.py b/tests/conftest.py index 5bbff0f6..6a54cc0e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -13,6 +13,7 @@ ChatCompletionMessage, Choice, ) +from openai.types.chat.chat_completion_message_tool_call import ChatCompletionMessageToolCall, Function from openai.types.create_embedding_response import Usage from sqlalchemy.ext.asyncio import async_sessionmaker @@ -232,6 +233,12 @@ def __init__(self, answer: str): } ) + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + return None + def __aiter__(self): return self @@ -244,9 +251,59 @@ async def __anext__(self): async def mock_acreate(*args, **kwargs): messages = kwargs["messages"] last_question = messages[-1]["content"] - if last_question == "Generate search query for: What is the capital of France?": - answer = "capital of France" - elif last_question == "Generate search query for: Are interest rates high?": + last_role = messages[-1]["role"] + if last_role == "tool": + return ChatCompletion( + object="chat.completion", + choices=[ + Choice( + message=ChatCompletionMessage( + role="assistant", + tool_calls=[ + ChatCompletionMessageToolCall( + id="call_abc123final", + type="function", + function=Function( + name="final_result", + arguments='{"query":"capital of France", "items":[], "filters":[]}', + ), + ) + ], + ), + finish_reason="stop", + index=0, + ) + ], + id="test-123final", + created=0, + model="test-model", + ) + if last_question == "Find search results for user query: What is the capital of France?": + return ChatCompletion( + object="chat.completion", + choices=[ + Choice( + message=ChatCompletionMessage( + role="assistant", + tool_calls=[ + ChatCompletionMessageToolCall( + id="call_abc123", + type="function", + function=Function( + name="search_database", arguments='{"search_query":"climbing gear outside"}' + ), + ) + ], + ), + finish_reason="stop", + index=0, + ) + ], + id="test-123", + created=0, + model="test-model", + ) + elif last_question == "Find search results for user query: Are interest rates high?": answer = "interest rates" elif isinstance(last_question, list) and last_question[2].get("image_url"): answer = "From the provided sources, the impact of interest rates and GDP growth on " diff --git a/tests/snapshots/test_api_routes/test_advanced_chat_flow/advanced_chat_flow_response.json b/tests/snapshots/test_api_routes/test_advanced_chat_flow/advanced_chat_flow_response.json index d9f9762d..13ae6741 100644 --- a/tests/snapshots/test_api_routes/test_advanced_chat_flow/advanced_chat_flow_response.json +++ b/tests/snapshots/test_api_routes/test_advanced_chat_flow/advanced_chat_flow_response.json @@ -4,71 +4,160 @@ "role": "assistant" }, "context": { - "data_points": { - "1": { - "id": 1, - "type": "Footwear", - "brand": "Daybird", - "name": "Wanderer Black Hiking Boots", - "description": "Daybird's Wanderer Hiking Boots in sleek black are perfect for all your outdoor adventures. These boots are made with a waterproof leather upper and a durable rubber sole for superior traction. With their cushioned insole and padded collar, these boots will keep you comfortable all day long.", - "price": 109.99 - } - }, + "data_points": {}, "thoughts": [ { "title": "Prompt to generate search arguments", "description": [ { - "role": "system", - "content": "Below is a history of the conversation so far, and a new question asked by the user that needs to be answered by searching database rows.\nYou have access to an Azure PostgreSQL database with an items table that has columns for title, description, brand, price, and type.\nGenerate a search query based on the conversation and the new question.\nIf the question is not in English, translate the question to English before generating the search query.\nIf you cannot generate a search query, return the original user question.\nDO NOT return anything besides the query.\n" + "parts": [ + { + "content": "good options for climbing gear that can be used outside?", + "timestamp": "2025-05-07T19:02:46.977501Z", + "part_kind": "user-prompt" + } + ], + "instructions": null, + "kind": "request" }, { - "role": "user", - "content": "good options for climbing gear that can be used outside?" + "parts": [ + { + "tool_name": "search_database", + "args": "{\"search_query\":\"climbing gear outside\"}", + "tool_call_id": "call_4HeBCmo2uioV6CyoePEGyZPc", + "part_kind": "tool-call" + } + ], + "model_name": "gpt-4o-mini-2024-07-18", + "timestamp": "2025-05-07T19:02:47Z", + "kind": "response" + }, + { + "parts": [ + { + "tool_name": "search_database", + "content": "Search results for climbing gear that can be used outside: ...", + "tool_call_id": "call_4HeBCmo2uioV6CyoePEGyZPc", + "timestamp": "2025-05-07T19:02:48.242408Z", + "part_kind": "tool-return" + } + ], + "instructions": null, + "kind": "request" + }, + { + "parts": [ + { + "content": "are there any shoes less than $50?", + "timestamp": "2025-05-07T19:02:46.977501Z", + "part_kind": "user-prompt" + } + ], + "instructions": null, + "kind": "request" + }, + { + "parts": [ + { + "tool_name": "search_database", + "args": "{\"search_query\":\"shoes\",\"price_filter\":{\"comparison_operator\":\"<\",\"value\":50}}", + "tool_call_id": "call_4HeBCmo2uioV6CyoePEGyZPc", + "part_kind": "tool-call" + } + ], + "model_name": "gpt-4o-mini-2024-07-18", + "timestamp": "2025-05-07T19:02:47Z", + "kind": "response" }, { - "role": "assistant", - "tool_calls": [ + "parts": [ { - "id": "call_abc123", - "type": "function", - "function": { - "arguments": "{\"search_query\":\"climbing gear outside\"}", - "name": "search_database" - } + "tool_name": "search_database", + "content": "Search results for shoes cheaper than 50: ...", + "tool_call_id": "call_4HeBCmo2uioV6CyoePEGyZPc", + "timestamp": "2025-05-07T19:02:48.242408Z", + "part_kind": "tool-return" } - ] + ], + "instructions": null, + "kind": "request" }, { - "role": "tool", - "tool_call_id": "call_abc123", - "content": "Search results for climbing gear that can be used outside: ..." + "parts": [ + { + "content": "Find search results for user query: What is the capital of France?", + "timestamp": "2025-05-07T22:31:16.169127Z", + "part_kind": "user-prompt" + } + ], + "instructions": null, + "kind": "request" }, { - "role": "user", - "content": "are there any shoes less than $50?" + "parts": [ + { + "tool_name": "search_database", + "args": "{\"search_query\":\"climbing gear outside\"}", + "tool_call_id": "call_abc123", + "part_kind": "tool-call" + } + ], + "model_name": "test-model", + "timestamp": "1970-01-01T00:00:00Z", + "kind": "response" }, { - "role": "assistant", - "tool_calls": [ + "parts": [ { - "id": "call_abc456", - "type": "function", - "function": { - "arguments": "{\"search_query\":\"shoes\",\"price_filter\":{\"comparison_operator\":\"<\",\"value\":50}}", - "name": "search_database" - } + "tool_name": "search_database", + "content": { + "query": "climbing gear outside", + "items": [ + { + "id": 1, + "type": "Footwear", + "brand": "Daybird", + "name": "Wanderer Black Hiking Boots", + "description": "Daybird's Wanderer Hiking Boots in sleek black are perfect for all your outdoor adventures. These boots are made with a waterproof leather upper and a durable rubber sole for superior traction. With their cushioned insole and padded collar, these boots will keep you comfortable all day long.", + "price": 109.99 + } + ], + "filters": [] + }, + "tool_call_id": "call_abc123", + "timestamp": "2025-05-07T22:31:16.187780Z", + "part_kind": "tool-return" } - ] + ], + "instructions": null, + "kind": "request" }, { - "role": "tool", - "tool_call_id": "call_abc456", - "content": "Search results for shoes cheaper than 50: ..." + "parts": [ + { + "tool_name": "final_result", + "args": "{\"query\":\"capital of France\", \"items\":[], \"filters\":[]}", + "tool_call_id": "call_abc123final", + "part_kind": "tool-call" + } + ], + "model_name": "test-model", + "timestamp": "1970-01-01T00:00:00Z", + "kind": "response" }, { - "role": "user", - "content": "What is the capital of France?" + "parts": [ + { + "tool_name": "final_result", + "content": "Final result processed.", + "tool_call_id": "call_abc123final", + "timestamp": "2025-05-07T22:31:16.188322Z", + "part_kind": "tool-return" + } + ], + "instructions": null, + "kind": "request" } ], "props": { @@ -78,7 +167,7 @@ }, { "title": "Search using generated search arguments", - "description": "The capital of France is Paris. [Benefit_Options-2.pdf].", + "description": "capital of France", "props": { "top": 1, "vector_search": true, @@ -88,28 +177,39 @@ }, { "title": "Search results", - "description": [ - { - "id": 1, - "type": "Footwear", - "brand": "Daybird", - "name": "Wanderer Black Hiking Boots", - "description": "Daybird's Wanderer Hiking Boots in sleek black are perfect for all your outdoor adventures. These boots are made with a waterproof leather upper and a durable rubber sole for superior traction. With their cushioned insole and padded collar, these boots will keep you comfortable all day long.", - "price": 109.99 - } - ], + "description": [], "props": {} }, { "title": "Prompt to generate answer", "description": [ { - "role": "system", - "content": "Assistant helps customers with questions about products.\nRespond as if you are a salesperson helping a customer in a store. Do NOT respond with tables.\nAnswer ONLY with the product details listed in the products.\nIf there isn't enough information below, say you don't know.\nDo not generate answers that don't use the sources below.\nEach product has an ID in brackets followed by colon and the product details.\nAlways include the product ID for each product you use in the response.\nUse square brackets to reference the source, for example [52].\nDon't combine citations, list each product separately, for example [27][51]." + "parts": [ + { + "content": "Assistant helps customers with questions about products.\nRespond as if you are a salesperson helping a customer in a store. Do NOT respond with tables.\nAnswer ONLY with the product details listed in the products.\nIf there isn't enough information below, say you don't know.\nDo not generate answers that don't use the sources below.\nEach product has an ID in brackets followed by colon and the product details.\nAlways include the product ID for each product you use in the response.\nUse square brackets to reference the source, for example [52].\nDon't combine citations, list each product separately, for example [27][51].", + "timestamp": "2025-05-07T22:31:16.188611Z", + "dynamic_ref": null, + "part_kind": "system-prompt" + }, + { + "content": "What is the capital of France?Sources:\n", + "timestamp": "2025-05-07T22:31:16.188613Z", + "part_kind": "user-prompt" + } + ], + "instructions": null, + "kind": "request" }, { - "role": "user", - "content": "What is the capital of France?\n\nSources:\n[1]:Name:Wanderer Black Hiking Boots Description:Daybird's Wanderer Hiking Boots in sleek black are perfect for all your outdoor adventures. These boots are made with a waterproof leather upper and a durable rubber sole for superior traction. With their cushioned insole and padded collar, these boots will keep you comfortable all day long. Price:109.99 Brand:Daybird Type:Footwear\n\n" + "parts": [ + { + "content": "The capital of France is Paris. [Benefit_Options-2.pdf].", + "part_kind": "text" + } + ], + "model_name": "test-model", + "timestamp": "1970-01-01T00:00:00Z", + "kind": "response" } ], "props": { diff --git a/tests/snapshots/test_api_routes/test_advanced_chat_streaming_flow/advanced_chat_streaming_flow_response.jsonlines b/tests/snapshots/test_api_routes/test_advanced_chat_streaming_flow/advanced_chat_streaming_flow_response.jsonlines index 9f5aaa63..a96fa99f 100644 --- a/tests/snapshots/test_api_routes/test_advanced_chat_streaming_flow/advanced_chat_streaming_flow_response.jsonlines +++ b/tests/snapshots/test_api_routes/test_advanced_chat_streaming_flow/advanced_chat_streaming_flow_response.jsonlines @@ -1,2 +1,2 @@ -{"delta":null,"context":{"data_points":{"1":{"id":1,"type":"Footwear","brand":"Daybird","name":"Wanderer Black Hiking Boots","description":"Daybird's Wanderer Hiking Boots in sleek black are perfect for all your outdoor adventures. These boots are made with a waterproof leather upper and a durable rubber sole for superior traction. With their cushioned insole and padded collar, these boots will keep you comfortable all day long.","price":109.99}},"thoughts":[{"title":"Prompt to generate search arguments","description":[{"role":"system","content":"Below is a history of the conversation so far, and a new question asked by the user that needs to be answered by searching database rows.\nYou have access to an Azure PostgreSQL database with an items table that has columns for title, description, brand, price, and type.\nGenerate a search query based on the conversation and the new question.\nIf the question is not in English, translate the question to English before generating the search query.\nIf you cannot generate a search query, return the original user question.\nDO NOT return anything besides the query.\n"},{"role":"user","content":"good options for climbing gear that can be used outside?"},{"role":"assistant","tool_calls":[{"id":"call_abc123","type":"function","function":{"arguments":"{\"search_query\":\"climbing gear outside\"}","name":"search_database"}}]},{"role":"tool","tool_call_id":"call_abc123","content":"Search results for climbing gear that can be used outside: ..."},{"role":"user","content":"are there any shoes less than $50?"},{"role":"assistant","tool_calls":[{"id":"call_abc456","type":"function","function":{"arguments":"{\"search_query\":\"shoes\",\"price_filter\":{\"comparison_operator\":\"<\",\"value\":50}}","name":"search_database"}}]},{"role":"tool","tool_call_id":"call_abc456","content":"Search results for shoes cheaper than 50: ..."},{"role":"user","content":"What is the capital of France?"}],"props":{"model":"gpt-4o-mini","deployment":"gpt-4o-mini"}},{"title":"Search using generated search arguments","description":"The capital of France is Paris. [Benefit_Options-2.pdf].","props":{"top":1,"vector_search":true,"text_search":true,"filters":[]}},{"title":"Search results","description":[{"id":1,"type":"Footwear","brand":"Daybird","name":"Wanderer Black Hiking Boots","description":"Daybird's Wanderer Hiking Boots in sleek black are perfect for all your outdoor adventures. These boots are made with a waterproof leather upper and a durable rubber sole for superior traction. With their cushioned insole and padded collar, these boots will keep you comfortable all day long.","price":109.99}],"props":{}},{"title":"Prompt to generate answer","description":[{"role":"system","content":"Assistant helps customers with questions about products.\nRespond as if you are a salesperson helping a customer in a store. Do NOT respond with tables.\nAnswer ONLY with the product details listed in the products.\nIf there isn't enough information below, say you don't know.\nDo not generate answers that don't use the sources below.\nEach product has an ID in brackets followed by colon and the product details.\nAlways include the product ID for each product you use in the response.\nUse square brackets to reference the source, for example [52].\nDon't combine citations, list each product separately, for example [27][51]."},{"role":"user","content":"What is the capital of France?\n\nSources:\n[1]:Name:Wanderer Black Hiking Boots Description:Daybird's Wanderer Hiking Boots in sleek black are perfect for all your outdoor adventures. These boots are made with a waterproof leather upper and a durable rubber sole for superior traction. With their cushioned insole and padded collar, these boots will keep you comfortable all day long. Price:109.99 Brand:Daybird Type:Footwear\n\n"}],"props":{"model":"gpt-4o-mini","deployment":"gpt-4o-mini"}}],"followup_questions":null},"sessionState":null} +{"delta":null,"context":{"data_points":{},"thoughts":[{"title":"Prompt to generate search arguments","description":[{"parts":[{"content":"good options for climbing gear that can be used outside?","timestamp":"2025-05-07T19:02:46.977501Z","part_kind":"user-prompt"}],"instructions":null,"kind":"request"},{"parts":[{"tool_name":"search_database","args":"{\"search_query\":\"climbing gear outside\"}","tool_call_id":"call_4HeBCmo2uioV6CyoePEGyZPc","part_kind":"tool-call"}],"model_name":"gpt-4o-mini-2024-07-18","timestamp":"2025-05-07T19:02:47Z","kind":"response"},{"parts":[{"tool_name":"search_database","content":"Search results for climbing gear that can be used outside: ...","tool_call_id":"call_4HeBCmo2uioV6CyoePEGyZPc","timestamp":"2025-05-07T19:02:48.242408Z","part_kind":"tool-return"}],"instructions":null,"kind":"request"},{"parts":[{"content":"are there any shoes less than $50?","timestamp":"2025-05-07T19:02:46.977501Z","part_kind":"user-prompt"}],"instructions":null,"kind":"request"},{"parts":[{"tool_name":"search_database","args":"{\"search_query\":\"shoes\",\"price_filter\":{\"comparison_operator\":\"<\",\"value\":50}}","tool_call_id":"call_4HeBCmo2uioV6CyoePEGyZPc","part_kind":"tool-call"}],"model_name":"gpt-4o-mini-2024-07-18","timestamp":"2025-05-07T19:02:47Z","kind":"response"},{"parts":[{"tool_name":"search_database","content":"Search results for shoes cheaper than 50: ...","tool_call_id":"call_4HeBCmo2uioV6CyoePEGyZPc","timestamp":"2025-05-07T19:02:48.242408Z","part_kind":"tool-return"}],"instructions":null,"kind":"request"},{"parts":[{"content":"Find search results for user query: What is the capital of France?","timestamp":"2025-05-07T22:31:16.246413Z","part_kind":"user-prompt"}],"instructions":null,"kind":"request"},{"parts":[{"tool_name":"search_database","args":"{\"search_query\":\"climbing gear outside\"}","tool_call_id":"call_abc123","part_kind":"tool-call"}],"model_name":"test-model","timestamp":"1970-01-01T00:00:00Z","kind":"response"},{"parts":[{"tool_name":"search_database","content":{"query":"climbing gear outside","items":[{"id":1,"type":"Footwear","brand":"Daybird","name":"Wanderer Black Hiking Boots","description":"Daybird's Wanderer Hiking Boots in sleek black are perfect for all your outdoor adventures. These boots are made with a waterproof leather upper and a durable rubber sole for superior traction. With their cushioned insole and padded collar, these boots will keep you comfortable all day long.","price":109.99}],"filters":[]},"tool_call_id":"call_abc123","timestamp":"2025-05-07T22:31:16.268574Z","part_kind":"tool-return"}],"instructions":null,"kind":"request"},{"parts":[{"tool_name":"final_result","args":"{\"query\":\"capital of France\", \"items\":[], \"filters\":[]}","tool_call_id":"call_abc123final","part_kind":"tool-call"}],"model_name":"test-model","timestamp":"1970-01-01T00:00:00Z","kind":"response"},{"parts":[{"tool_name":"final_result","content":"Final result processed.","tool_call_id":"call_abc123final","timestamp":"2025-05-07T22:31:16.269069Z","part_kind":"tool-return"}],"instructions":null,"kind":"request"}],"props":{"model":"gpt-4o-mini","deployment":"gpt-4o-mini"}},{"title":"Search using generated search arguments","description":"capital of France","props":{"top":1,"vector_search":true,"text_search":true,"filters":[]}},{"title":"Search results","description":[],"props":{}},{"title":"Prompt to generate answer","description":[{"parts":[{"content":"Assistant helps customers with questions about products.\nRespond as if you are a salesperson helping a customer in a store. Do NOT respond with tables.\nAnswer ONLY with the product details listed in the products.\nIf there isn't enough information below, say you don't know.\nDo not generate answers that don't use the sources below.\nEach product has an ID in brackets followed by colon and the product details.\nAlways include the product ID for each product you use in the response.\nUse square brackets to reference the source, for example [52].\nDon't combine citations, list each product separately, for example [27][51].","timestamp":"2025-05-07T22:31:16.269763Z","dynamic_ref":null,"part_kind":"system-prompt"},{"content":"What is the capital of France?Sources:\n","timestamp":"2025-05-07T22:31:16.269765Z","part_kind":"user-prompt"}],"instructions":null,"kind":"request"}],"props":{"model":"gpt-4o-mini","deployment":"gpt-4o-mini"}}],"followup_questions":null},"sessionState":null} {"delta":{"content":"The capital of France is Paris. [Benefit_Options-2.pdf].","role":"assistant"},"context":null,"sessionState":null} diff --git a/tests/snapshots/test_api_routes/test_simple_chat_flow/simple_chat_flow_response.json b/tests/snapshots/test_api_routes/test_simple_chat_flow/simple_chat_flow_response.json index ca9bc1bb..4a3a5623 100644 --- a/tests/snapshots/test_api_routes/test_simple_chat_flow/simple_chat_flow_response.json +++ b/tests/snapshots/test_api_routes/test_simple_chat_flow/simple_chat_flow_response.json @@ -42,12 +42,32 @@ "title": "Prompt to generate answer", "description": [ { - "role": "system", - "content": "Assistant helps customers with questions about products.\nRespond as if you are a salesperson helping a customer in a store. Do NOT respond with tables.\nAnswer ONLY with the product details listed in the products.\nIf there isn't enough information below, say you don't know.\nDo not generate answers that don't use the sources below.\nEach product has an ID in brackets followed by colon and the product details.\nAlways include the product ID for each product you use in the response.\nUse square brackets to reference the source, for example [52].\nDon't combine citations, list each product separately, for example [27][51]." + "parts": [ + { + "content": "Assistant helps customers with questions about products.\nRespond as if you are a salesperson helping a customer in a store. Do NOT respond with tables.\nAnswer ONLY with the product details listed in the products.\nIf there isn't enough information below, say you don't know.\nDo not generate answers that don't use the sources below.\nEach product has an ID in brackets followed by colon and the product details.\nAlways include the product ID for each product you use in the response.\nUse square brackets to reference the source, for example [52].\nDon't combine citations, list each product separately, for example [27][51].", + "timestamp": "2025-05-07T22:31:15.947141Z", + "dynamic_ref": null, + "part_kind": "system-prompt" + }, + { + "content": "What is the capital of France?Sources:\n[1]:Name:Wanderer Black Hiking Boots Description:Daybird's Wanderer Hiking Boots in sleek black are perfect for all your outdoor adventures. These boots are made with a waterproof leather upper and a durable rubber sole for superior traction. With their cushioned insole and padded collar, these boots will keep you comfortable all day long. Price:109.99 Brand:Daybird Type:Footwear", + "timestamp": "2025-05-07T22:31:15.947145Z", + "part_kind": "user-prompt" + } + ], + "instructions": null, + "kind": "request" }, { - "role": "user", - "content": "What is the capital of France?\n\nSources:\n[1]:Name:Wanderer Black Hiking Boots Description:Daybird's Wanderer Hiking Boots in sleek black are perfect for all your outdoor adventures. These boots are made with a waterproof leather upper and a durable rubber sole for superior traction. With their cushioned insole and padded collar, these boots will keep you comfortable all day long. Price:109.99 Brand:Daybird Type:Footwear\n\n" + "parts": [ + { + "content": "The capital of France is Paris. [Benefit_Options-2.pdf].", + "part_kind": "text" + } + ], + "model_name": "test-model", + "timestamp": "1970-01-01T00:00:00Z", + "kind": "response" } ], "props": { diff --git a/tests/snapshots/test_api_routes/test_simple_chat_streaming_flow/simple_chat_streaming_flow_response.jsonlines b/tests/snapshots/test_api_routes/test_simple_chat_streaming_flow/simple_chat_streaming_flow_response.jsonlines index e79e5461..6f3f95e6 100644 --- a/tests/snapshots/test_api_routes/test_simple_chat_streaming_flow/simple_chat_streaming_flow_response.jsonlines +++ b/tests/snapshots/test_api_routes/test_simple_chat_streaming_flow/simple_chat_streaming_flow_response.jsonlines @@ -1,2 +1,2 @@ -{"delta":null,"context":{"data_points":{"1":{"id":1,"type":"Footwear","brand":"Daybird","name":"Wanderer Black Hiking Boots","description":"Daybird's Wanderer Hiking Boots in sleek black are perfect for all your outdoor adventures. These boots are made with a waterproof leather upper and a durable rubber sole for superior traction. With their cushioned insole and padded collar, these boots will keep you comfortable all day long.","price":109.99}},"thoughts":[{"title":"Search query for database","description":"What is the capital of France?","props":{"top":1,"vector_search":true,"text_search":true}},{"title":"Search results","description":[{"id":1,"type":"Footwear","brand":"Daybird","name":"Wanderer Black Hiking Boots","description":"Daybird's Wanderer Hiking Boots in sleek black are perfect for all your outdoor adventures. These boots are made with a waterproof leather upper and a durable rubber sole for superior traction. With their cushioned insole and padded collar, these boots will keep you comfortable all day long.","price":109.99}],"props":{}},{"title":"Prompt to generate answer","description":[{"role":"system","content":"Assistant helps customers with questions about products.\nRespond as if you are a salesperson helping a customer in a store. Do NOT respond with tables.\nAnswer ONLY with the product details listed in the products.\nIf there isn't enough information below, say you don't know.\nDo not generate answers that don't use the sources below.\nEach product has an ID in brackets followed by colon and the product details.\nAlways include the product ID for each product you use in the response.\nUse square brackets to reference the source, for example [52].\nDon't combine citations, list each product separately, for example [27][51]."},{"role":"user","content":"What is the capital of France?\n\nSources:\n[1]:Name:Wanderer Black Hiking Boots Description:Daybird's Wanderer Hiking Boots in sleek black are perfect for all your outdoor adventures. These boots are made with a waterproof leather upper and a durable rubber sole for superior traction. With their cushioned insole and padded collar, these boots will keep you comfortable all day long. Price:109.99 Brand:Daybird Type:Footwear\n\n"}],"props":{"model":"gpt-4o-mini","deployment":"gpt-4o-mini"}}],"followup_questions":null},"sessionState":null} +{"delta":null,"context":{"data_points":{"1":{"id":1,"type":"Footwear","brand":"Daybird","name":"Wanderer Black Hiking Boots","description":"Daybird's Wanderer Hiking Boots in sleek black are perfect for all your outdoor adventures. These boots are made with a waterproof leather upper and a durable rubber sole for superior traction. With their cushioned insole and padded collar, these boots will keep you comfortable all day long.","price":109.99}},"thoughts":[{"title":"Search query for database","description":"What is the capital of France?","props":{"top":1,"vector_search":true,"text_search":true}},{"title":"Search results","description":[{"id":1,"type":"Footwear","brand":"Daybird","name":"Wanderer Black Hiking Boots","description":"Daybird's Wanderer Hiking Boots in sleek black are perfect for all your outdoor adventures. These boots are made with a waterproof leather upper and a durable rubber sole for superior traction. With their cushioned insole and padded collar, these boots will keep you comfortable all day long.","price":109.99}],"props":{}},{"title":"Prompt to generate answer","description":[{"parts":[{"content":"Assistant helps customers with questions about products.\nRespond as if you are a salesperson helping a customer in a store. Do NOT respond with tables.\nAnswer ONLY with the product details listed in the products.\nIf there isn't enough information below, say you don't know.\nDo not generate answers that don't use the sources below.\nEach product has an ID in brackets followed by colon and the product details.\nAlways include the product ID for each product you use in the response.\nUse square brackets to reference the source, for example [52].\nDon't combine citations, list each product separately, for example [27][51].","timestamp":"2025-05-07T22:31:16.060955Z","dynamic_ref":null,"part_kind":"system-prompt"},{"content":"What is the capital of France?Sources:\n[1]:Name:Wanderer Black Hiking Boots Description:Daybird's Wanderer Hiking Boots in sleek black are perfect for all your outdoor adventures. These boots are made with a waterproof leather upper and a durable rubber sole for superior traction. With their cushioned insole and padded collar, these boots will keep you comfortable all day long. Price:109.99 Brand:Daybird Type:Footwear","timestamp":"2025-05-07T22:31:16.060964Z","part_kind":"user-prompt"}],"instructions":null,"kind":"request"}],"props":{"model":"gpt-4o-mini","deployment":"gpt-4o-mini"}}],"followup_questions":null},"sessionState":null} {"delta":{"content":"The capital of France is Paris. [Benefit_Options-2.pdf].","role":"assistant"},"context":null,"sessionState":null} From 33b52955bf645b3faf5d055ef28beeef17119a75 Mon Sep 17 00:00:00 2001 From: Pamela Fox Date: Wed, 7 May 2025 22:53:41 +0000 Subject: [PATCH 08/10] Tests better --- tests/conftest.py | 5 ++- .../advanced_chat_flow_response.json | 36 ++++++++++++++----- ...ced_chat_streaming_flow_response.jsonlines | 2 +- .../simple_chat_flow_response.json | 4 +-- ...ple_chat_streaming_flow_response.jsonlines | 2 +- 5 files changed, 35 insertions(+), 14 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 6a54cc0e..5fe67053 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,4 @@ +import json import os from pathlib import Path from unittest import mock @@ -253,6 +254,8 @@ async def mock_acreate(*args, **kwargs): last_question = messages[-1]["content"] last_role = messages[-1]["role"] if last_role == "tool": + items = json.loads(last_question)["items"] + arguments = {"query": "capital of France", "items": items, "filters": []} return ChatCompletion( object="chat.completion", choices=[ @@ -265,7 +268,7 @@ async def mock_acreate(*args, **kwargs): type="function", function=Function( name="final_result", - arguments='{"query":"capital of France", "items":[], "filters":[]}', + arguments=json.dumps(arguments), ), ) ], diff --git a/tests/snapshots/test_api_routes/test_advanced_chat_flow/advanced_chat_flow_response.json b/tests/snapshots/test_api_routes/test_advanced_chat_flow/advanced_chat_flow_response.json index 13ae6741..342d9f70 100644 --- a/tests/snapshots/test_api_routes/test_advanced_chat_flow/advanced_chat_flow_response.json +++ b/tests/snapshots/test_api_routes/test_advanced_chat_flow/advanced_chat_flow_response.json @@ -4,7 +4,16 @@ "role": "assistant" }, "context": { - "data_points": {}, + "data_points": { + "1": { + "id": 1, + "type": "Footwear", + "brand": "Daybird", + "name": "Wanderer Black Hiking Boots", + "description": "Daybird's Wanderer Hiking Boots in sleek black are perfect for all your outdoor adventures. These boots are made with a waterproof leather upper and a durable rubber sole for superior traction. With their cushioned insole and padded collar, these boots will keep you comfortable all day long.", + "price": 109.99 + } + }, "thoughts": [ { "title": "Prompt to generate search arguments", @@ -87,7 +96,7 @@ "parts": [ { "content": "Find search results for user query: What is the capital of France?", - "timestamp": "2025-05-07T22:31:16.169127Z", + "timestamp": "2025-05-07T22:53:02.948859Z", "part_kind": "user-prompt" } ], @@ -126,7 +135,7 @@ "filters": [] }, "tool_call_id": "call_abc123", - "timestamp": "2025-05-07T22:31:16.187780Z", + "timestamp": "2025-05-07T22:53:02.963649Z", "part_kind": "tool-return" } ], @@ -137,7 +146,7 @@ "parts": [ { "tool_name": "final_result", - "args": "{\"query\":\"capital of France\", \"items\":[], \"filters\":[]}", + "args": "{\"query\": \"capital of France\", \"items\": [{\"id\": 1, \"type\": \"Footwear\", \"brand\": \"Daybird\", \"name\": \"Wanderer Black Hiking Boots\", \"description\": \"Daybird's Wanderer Hiking Boots in sleek black are perfect for all your outdoor adventures. These boots are made with a waterproof leather upper and a durable rubber sole for superior traction. With their cushioned insole and padded collar, these boots will keep you comfortable all day long.\", \"price\": 109.99}], \"filters\": []}", "tool_call_id": "call_abc123final", "part_kind": "tool-call" } @@ -152,7 +161,7 @@ "tool_name": "final_result", "content": "Final result processed.", "tool_call_id": "call_abc123final", - "timestamp": "2025-05-07T22:31:16.188322Z", + "timestamp": "2025-05-07T22:53:02.964200Z", "part_kind": "tool-return" } ], @@ -177,7 +186,16 @@ }, { "title": "Search results", - "description": [], + "description": [ + { + "id": 1, + "type": "Footwear", + "brand": "Daybird", + "name": "Wanderer Black Hiking Boots", + "description": "Daybird's Wanderer Hiking Boots in sleek black are perfect for all your outdoor adventures. These boots are made with a waterproof leather upper and a durable rubber sole for superior traction. With their cushioned insole and padded collar, these boots will keep you comfortable all day long.", + "price": 109.99 + } + ], "props": {} }, { @@ -187,13 +205,13 @@ "parts": [ { "content": "Assistant helps customers with questions about products.\nRespond as if you are a salesperson helping a customer in a store. Do NOT respond with tables.\nAnswer ONLY with the product details listed in the products.\nIf there isn't enough information below, say you don't know.\nDo not generate answers that don't use the sources below.\nEach product has an ID in brackets followed by colon and the product details.\nAlways include the product ID for each product you use in the response.\nUse square brackets to reference the source, for example [52].\nDon't combine citations, list each product separately, for example [27][51].", - "timestamp": "2025-05-07T22:31:16.188611Z", + "timestamp": "2025-05-07T22:53:02.964504Z", "dynamic_ref": null, "part_kind": "system-prompt" }, { - "content": "What is the capital of France?Sources:\n", - "timestamp": "2025-05-07T22:31:16.188613Z", + "content": "What is the capital of France?Sources:\n[1]:Name:Wanderer Black Hiking Boots Description:Daybird's Wanderer Hiking Boots in sleek black are perfect for all your outdoor adventures. These boots are made with a waterproof leather upper and a durable rubber sole for superior traction. With their cushioned insole and padded collar, these boots will keep you comfortable all day long. Price:109.99 Brand:Daybird Type:Footwear", + "timestamp": "2025-05-07T22:53:02.964505Z", "part_kind": "user-prompt" } ], diff --git a/tests/snapshots/test_api_routes/test_advanced_chat_streaming_flow/advanced_chat_streaming_flow_response.jsonlines b/tests/snapshots/test_api_routes/test_advanced_chat_streaming_flow/advanced_chat_streaming_flow_response.jsonlines index a96fa99f..84ce766c 100644 --- a/tests/snapshots/test_api_routes/test_advanced_chat_streaming_flow/advanced_chat_streaming_flow_response.jsonlines +++ b/tests/snapshots/test_api_routes/test_advanced_chat_streaming_flow/advanced_chat_streaming_flow_response.jsonlines @@ -1,2 +1,2 @@ -{"delta":null,"context":{"data_points":{},"thoughts":[{"title":"Prompt to generate search arguments","description":[{"parts":[{"content":"good options for climbing gear that can be used outside?","timestamp":"2025-05-07T19:02:46.977501Z","part_kind":"user-prompt"}],"instructions":null,"kind":"request"},{"parts":[{"tool_name":"search_database","args":"{\"search_query\":\"climbing gear outside\"}","tool_call_id":"call_4HeBCmo2uioV6CyoePEGyZPc","part_kind":"tool-call"}],"model_name":"gpt-4o-mini-2024-07-18","timestamp":"2025-05-07T19:02:47Z","kind":"response"},{"parts":[{"tool_name":"search_database","content":"Search results for climbing gear that can be used outside: ...","tool_call_id":"call_4HeBCmo2uioV6CyoePEGyZPc","timestamp":"2025-05-07T19:02:48.242408Z","part_kind":"tool-return"}],"instructions":null,"kind":"request"},{"parts":[{"content":"are there any shoes less than $50?","timestamp":"2025-05-07T19:02:46.977501Z","part_kind":"user-prompt"}],"instructions":null,"kind":"request"},{"parts":[{"tool_name":"search_database","args":"{\"search_query\":\"shoes\",\"price_filter\":{\"comparison_operator\":\"<\",\"value\":50}}","tool_call_id":"call_4HeBCmo2uioV6CyoePEGyZPc","part_kind":"tool-call"}],"model_name":"gpt-4o-mini-2024-07-18","timestamp":"2025-05-07T19:02:47Z","kind":"response"},{"parts":[{"tool_name":"search_database","content":"Search results for shoes cheaper than 50: ...","tool_call_id":"call_4HeBCmo2uioV6CyoePEGyZPc","timestamp":"2025-05-07T19:02:48.242408Z","part_kind":"tool-return"}],"instructions":null,"kind":"request"},{"parts":[{"content":"Find search results for user query: What is the capital of France?","timestamp":"2025-05-07T22:31:16.246413Z","part_kind":"user-prompt"}],"instructions":null,"kind":"request"},{"parts":[{"tool_name":"search_database","args":"{\"search_query\":\"climbing gear outside\"}","tool_call_id":"call_abc123","part_kind":"tool-call"}],"model_name":"test-model","timestamp":"1970-01-01T00:00:00Z","kind":"response"},{"parts":[{"tool_name":"search_database","content":{"query":"climbing gear outside","items":[{"id":1,"type":"Footwear","brand":"Daybird","name":"Wanderer Black Hiking Boots","description":"Daybird's Wanderer Hiking Boots in sleek black are perfect for all your outdoor adventures. These boots are made with a waterproof leather upper and a durable rubber sole for superior traction. With their cushioned insole and padded collar, these boots will keep you comfortable all day long.","price":109.99}],"filters":[]},"tool_call_id":"call_abc123","timestamp":"2025-05-07T22:31:16.268574Z","part_kind":"tool-return"}],"instructions":null,"kind":"request"},{"parts":[{"tool_name":"final_result","args":"{\"query\":\"capital of France\", \"items\":[], \"filters\":[]}","tool_call_id":"call_abc123final","part_kind":"tool-call"}],"model_name":"test-model","timestamp":"1970-01-01T00:00:00Z","kind":"response"},{"parts":[{"tool_name":"final_result","content":"Final result processed.","tool_call_id":"call_abc123final","timestamp":"2025-05-07T22:31:16.269069Z","part_kind":"tool-return"}],"instructions":null,"kind":"request"}],"props":{"model":"gpt-4o-mini","deployment":"gpt-4o-mini"}},{"title":"Search using generated search arguments","description":"capital of France","props":{"top":1,"vector_search":true,"text_search":true,"filters":[]}},{"title":"Search results","description":[],"props":{}},{"title":"Prompt to generate answer","description":[{"parts":[{"content":"Assistant helps customers with questions about products.\nRespond as if you are a salesperson helping a customer in a store. Do NOT respond with tables.\nAnswer ONLY with the product details listed in the products.\nIf there isn't enough information below, say you don't know.\nDo not generate answers that don't use the sources below.\nEach product has an ID in brackets followed by colon and the product details.\nAlways include the product ID for each product you use in the response.\nUse square brackets to reference the source, for example [52].\nDon't combine citations, list each product separately, for example [27][51].","timestamp":"2025-05-07T22:31:16.269763Z","dynamic_ref":null,"part_kind":"system-prompt"},{"content":"What is the capital of France?Sources:\n","timestamp":"2025-05-07T22:31:16.269765Z","part_kind":"user-prompt"}],"instructions":null,"kind":"request"}],"props":{"model":"gpt-4o-mini","deployment":"gpt-4o-mini"}}],"followup_questions":null},"sessionState":null} +{"delta":null,"context":{"data_points":{"1":{"id":1,"type":"Footwear","brand":"Daybird","name":"Wanderer Black Hiking Boots","description":"Daybird's Wanderer Hiking Boots in sleek black are perfect for all your outdoor adventures. These boots are made with a waterproof leather upper and a durable rubber sole for superior traction. With their cushioned insole and padded collar, these boots will keep you comfortable all day long.","price":109.99}},"thoughts":[{"title":"Prompt to generate search arguments","description":[{"parts":[{"content":"good options for climbing gear that can be used outside?","timestamp":"2025-05-07T19:02:46.977501Z","part_kind":"user-prompt"}],"instructions":null,"kind":"request"},{"parts":[{"tool_name":"search_database","args":"{\"search_query\":\"climbing gear outside\"}","tool_call_id":"call_4HeBCmo2uioV6CyoePEGyZPc","part_kind":"tool-call"}],"model_name":"gpt-4o-mini-2024-07-18","timestamp":"2025-05-07T19:02:47Z","kind":"response"},{"parts":[{"tool_name":"search_database","content":"Search results for climbing gear that can be used outside: ...","tool_call_id":"call_4HeBCmo2uioV6CyoePEGyZPc","timestamp":"2025-05-07T19:02:48.242408Z","part_kind":"tool-return"}],"instructions":null,"kind":"request"},{"parts":[{"content":"are there any shoes less than $50?","timestamp":"2025-05-07T19:02:46.977501Z","part_kind":"user-prompt"}],"instructions":null,"kind":"request"},{"parts":[{"tool_name":"search_database","args":"{\"search_query\":\"shoes\",\"price_filter\":{\"comparison_operator\":\"<\",\"value\":50}}","tool_call_id":"call_4HeBCmo2uioV6CyoePEGyZPc","part_kind":"tool-call"}],"model_name":"gpt-4o-mini-2024-07-18","timestamp":"2025-05-07T19:02:47Z","kind":"response"},{"parts":[{"tool_name":"search_database","content":"Search results for shoes cheaper than 50: ...","tool_call_id":"call_4HeBCmo2uioV6CyoePEGyZPc","timestamp":"2025-05-07T19:02:48.242408Z","part_kind":"tool-return"}],"instructions":null,"kind":"request"},{"parts":[{"content":"Find search results for user query: What is the capital of France?","timestamp":"2025-05-07T22:53:03.018949Z","part_kind":"user-prompt"}],"instructions":null,"kind":"request"},{"parts":[{"tool_name":"search_database","args":"{\"search_query\":\"climbing gear outside\"}","tool_call_id":"call_abc123","part_kind":"tool-call"}],"model_name":"test-model","timestamp":"1970-01-01T00:00:00Z","kind":"response"},{"parts":[{"tool_name":"search_database","content":{"query":"climbing gear outside","items":[{"id":1,"type":"Footwear","brand":"Daybird","name":"Wanderer Black Hiking Boots","description":"Daybird's Wanderer Hiking Boots in sleek black are perfect for all your outdoor adventures. These boots are made with a waterproof leather upper and a durable rubber sole for superior traction. With their cushioned insole and padded collar, these boots will keep you comfortable all day long.","price":109.99}],"filters":[]},"tool_call_id":"call_abc123","timestamp":"2025-05-07T22:53:03.034667Z","part_kind":"tool-return"}],"instructions":null,"kind":"request"},{"parts":[{"tool_name":"final_result","args":"{\"query\": \"capital of France\", \"items\": [{\"id\": 1, \"type\": \"Footwear\", \"brand\": \"Daybird\", \"name\": \"Wanderer Black Hiking Boots\", \"description\": \"Daybird's Wanderer Hiking Boots in sleek black are perfect for all your outdoor adventures. These boots are made with a waterproof leather upper and a durable rubber sole for superior traction. With their cushioned insole and padded collar, these boots will keep you comfortable all day long.\", \"price\": 109.99}], \"filters\": []}","tool_call_id":"call_abc123final","part_kind":"tool-call"}],"model_name":"test-model","timestamp":"1970-01-01T00:00:00Z","kind":"response"},{"parts":[{"tool_name":"final_result","content":"Final result processed.","tool_call_id":"call_abc123final","timestamp":"2025-05-07T22:53:03.035160Z","part_kind":"tool-return"}],"instructions":null,"kind":"request"}],"props":{"model":"gpt-4o-mini","deployment":"gpt-4o-mini"}},{"title":"Search using generated search arguments","description":"capital of France","props":{"top":1,"vector_search":true,"text_search":true,"filters":[]}},{"title":"Search results","description":[{"id":1,"type":"Footwear","brand":"Daybird","name":"Wanderer Black Hiking Boots","description":"Daybird's Wanderer Hiking Boots in sleek black are perfect for all your outdoor adventures. These boots are made with a waterproof leather upper and a durable rubber sole for superior traction. With their cushioned insole and padded collar, these boots will keep you comfortable all day long.","price":109.99}],"props":{}},{"title":"Prompt to generate answer","description":[{"parts":[{"content":"Assistant helps customers with questions about products.\nRespond as if you are a salesperson helping a customer in a store. Do NOT respond with tables.\nAnswer ONLY with the product details listed in the products.\nIf there isn't enough information below, say you don't know.\nDo not generate answers that don't use the sources below.\nEach product has an ID in brackets followed by colon and the product details.\nAlways include the product ID for each product you use in the response.\nUse square brackets to reference the source, for example [52].\nDon't combine citations, list each product separately, for example [27][51].","timestamp":"2025-05-07T22:53:03.035786Z","dynamic_ref":null,"part_kind":"system-prompt"},{"content":"What is the capital of France?Sources:\n[1]:Name:Wanderer Black Hiking Boots Description:Daybird's Wanderer Hiking Boots in sleek black are perfect for all your outdoor adventures. These boots are made with a waterproof leather upper and a durable rubber sole for superior traction. With their cushioned insole and padded collar, these boots will keep you comfortable all day long. Price:109.99 Brand:Daybird Type:Footwear","timestamp":"2025-05-07T22:53:03.035788Z","part_kind":"user-prompt"}],"instructions":null,"kind":"request"}],"props":{"model":"gpt-4o-mini","deployment":"gpt-4o-mini"}}],"followup_questions":null},"sessionState":null} {"delta":{"content":"The capital of France is Paris. [Benefit_Options-2.pdf].","role":"assistant"},"context":null,"sessionState":null} diff --git a/tests/snapshots/test_api_routes/test_simple_chat_flow/simple_chat_flow_response.json b/tests/snapshots/test_api_routes/test_simple_chat_flow/simple_chat_flow_response.json index 4a3a5623..e0b818c8 100644 --- a/tests/snapshots/test_api_routes/test_simple_chat_flow/simple_chat_flow_response.json +++ b/tests/snapshots/test_api_routes/test_simple_chat_flow/simple_chat_flow_response.json @@ -45,13 +45,13 @@ "parts": [ { "content": "Assistant helps customers with questions about products.\nRespond as if you are a salesperson helping a customer in a store. Do NOT respond with tables.\nAnswer ONLY with the product details listed in the products.\nIf there isn't enough information below, say you don't know.\nDo not generate answers that don't use the sources below.\nEach product has an ID in brackets followed by colon and the product details.\nAlways include the product ID for each product you use in the response.\nUse square brackets to reference the source, for example [52].\nDon't combine citations, list each product separately, for example [27][51].", - "timestamp": "2025-05-07T22:31:15.947141Z", + "timestamp": "2025-05-07T22:53:02.797757Z", "dynamic_ref": null, "part_kind": "system-prompt" }, { "content": "What is the capital of France?Sources:\n[1]:Name:Wanderer Black Hiking Boots Description:Daybird's Wanderer Hiking Boots in sleek black are perfect for all your outdoor adventures. These boots are made with a waterproof leather upper and a durable rubber sole for superior traction. With their cushioned insole and padded collar, these boots will keep you comfortable all day long. Price:109.99 Brand:Daybird Type:Footwear", - "timestamp": "2025-05-07T22:31:15.947145Z", + "timestamp": "2025-05-07T22:53:02.797762Z", "part_kind": "user-prompt" } ], diff --git a/tests/snapshots/test_api_routes/test_simple_chat_streaming_flow/simple_chat_streaming_flow_response.jsonlines b/tests/snapshots/test_api_routes/test_simple_chat_streaming_flow/simple_chat_streaming_flow_response.jsonlines index 6f3f95e6..9e4487bb 100644 --- a/tests/snapshots/test_api_routes/test_simple_chat_streaming_flow/simple_chat_streaming_flow_response.jsonlines +++ b/tests/snapshots/test_api_routes/test_simple_chat_streaming_flow/simple_chat_streaming_flow_response.jsonlines @@ -1,2 +1,2 @@ -{"delta":null,"context":{"data_points":{"1":{"id":1,"type":"Footwear","brand":"Daybird","name":"Wanderer Black Hiking Boots","description":"Daybird's Wanderer Hiking Boots in sleek black are perfect for all your outdoor adventures. These boots are made with a waterproof leather upper and a durable rubber sole for superior traction. With their cushioned insole and padded collar, these boots will keep you comfortable all day long.","price":109.99}},"thoughts":[{"title":"Search query for database","description":"What is the capital of France?","props":{"top":1,"vector_search":true,"text_search":true}},{"title":"Search results","description":[{"id":1,"type":"Footwear","brand":"Daybird","name":"Wanderer Black Hiking Boots","description":"Daybird's Wanderer Hiking Boots in sleek black are perfect for all your outdoor adventures. These boots are made with a waterproof leather upper and a durable rubber sole for superior traction. With their cushioned insole and padded collar, these boots will keep you comfortable all day long.","price":109.99}],"props":{}},{"title":"Prompt to generate answer","description":[{"parts":[{"content":"Assistant helps customers with questions about products.\nRespond as if you are a salesperson helping a customer in a store. Do NOT respond with tables.\nAnswer ONLY with the product details listed in the products.\nIf there isn't enough information below, say you don't know.\nDo not generate answers that don't use the sources below.\nEach product has an ID in brackets followed by colon and the product details.\nAlways include the product ID for each product you use in the response.\nUse square brackets to reference the source, for example [52].\nDon't combine citations, list each product separately, for example [27][51].","timestamp":"2025-05-07T22:31:16.060955Z","dynamic_ref":null,"part_kind":"system-prompt"},{"content":"What is the capital of France?Sources:\n[1]:Name:Wanderer Black Hiking Boots Description:Daybird's Wanderer Hiking Boots in sleek black are perfect for all your outdoor adventures. These boots are made with a waterproof leather upper and a durable rubber sole for superior traction. With their cushioned insole and padded collar, these boots will keep you comfortable all day long. Price:109.99 Brand:Daybird Type:Footwear","timestamp":"2025-05-07T22:31:16.060964Z","part_kind":"user-prompt"}],"instructions":null,"kind":"request"}],"props":{"model":"gpt-4o-mini","deployment":"gpt-4o-mini"}}],"followup_questions":null},"sessionState":null} +{"delta":null,"context":{"data_points":{"1":{"id":1,"type":"Footwear","brand":"Daybird","name":"Wanderer Black Hiking Boots","description":"Daybird's Wanderer Hiking Boots in sleek black are perfect for all your outdoor adventures. These boots are made with a waterproof leather upper and a durable rubber sole for superior traction. With their cushioned insole and padded collar, these boots will keep you comfortable all day long.","price":109.99}},"thoughts":[{"title":"Search query for database","description":"What is the capital of France?","props":{"top":1,"vector_search":true,"text_search":true}},{"title":"Search results","description":[{"id":1,"type":"Footwear","brand":"Daybird","name":"Wanderer Black Hiking Boots","description":"Daybird's Wanderer Hiking Boots in sleek black are perfect for all your outdoor adventures. These boots are made with a waterproof leather upper and a durable rubber sole for superior traction. With their cushioned insole and padded collar, these boots will keep you comfortable all day long.","price":109.99}],"props":{}},{"title":"Prompt to generate answer","description":[{"parts":[{"content":"Assistant helps customers with questions about products.\nRespond as if you are a salesperson helping a customer in a store. Do NOT respond with tables.\nAnswer ONLY with the product details listed in the products.\nIf there isn't enough information below, say you don't know.\nDo not generate answers that don't use the sources below.\nEach product has an ID in brackets followed by colon and the product details.\nAlways include the product ID for each product you use in the response.\nUse square brackets to reference the source, for example [52].\nDon't combine citations, list each product separately, for example [27][51].","timestamp":"2025-05-07T22:53:02.878495Z","dynamic_ref":null,"part_kind":"system-prompt"},{"content":"What is the capital of France?Sources:\n[1]:Name:Wanderer Black Hiking Boots Description:Daybird's Wanderer Hiking Boots in sleek black are perfect for all your outdoor adventures. These boots are made with a waterproof leather upper and a durable rubber sole for superior traction. With their cushioned insole and padded collar, these boots will keep you comfortable all day long. Price:109.99 Brand:Daybird Type:Footwear","timestamp":"2025-05-07T22:53:02.878497Z","part_kind":"user-prompt"}],"instructions":null,"kind":"request"}],"props":{"model":"gpt-4o-mini","deployment":"gpt-4o-mini"}}],"followup_questions":null},"sessionState":null} {"delta":{"content":"The capital of France is Paris. [Benefit_Options-2.pdf].","role":"assistant"},"context":null,"sessionState":null} From 292517b89bcf849298c87841d6202af470580304 Mon Sep 17 00:00:00 2001 From: Pamela Fox Date: Thu, 8 May 2025 05:07:22 +0000 Subject: [PATCH 09/10] Refactor, please mypy --- .vscode/settings.json | 3 +- requirements-dev.txt | 1 + src/backend/fastapi_app/__init__.py | 2 +- src/backend/fastapi_app/api_models.py | 36 ++++++- src/backend/fastapi_app/openai_clients.py | 12 ++- src/backend/fastapi_app/postgres_searcher.py | 16 +-- src/backend/fastapi_app/rag_advanced.py | 59 ++++------- src/backend/fastapi_app/rag_base.py | 29 +++--- src/backend/fastapi_app/rag_simple.py | 2 +- src/backend/pyproject.toml | 2 +- src/backend/requirements.txt | 44 +++++++-- tests/conftest.py | 8 ++ .../advanced_chat_flow_response.json | 10 +- ...ced_chat_streaming_flow_response.jsonlines | 2 +- .../simple_chat_flow_response.json | 4 +- ...le_chat_flow_message_history_response.json | 98 +++++++++++++++++++ ...ple_chat_streaming_flow_response.jsonlines | 2 +- tests/test_api_routes.py | 23 +++++ tests/test_postgres_searcher.py | 23 ++++- 19 files changed, 283 insertions(+), 93 deletions(-) create mode 100644 tests/snapshots/test_api_routes/test_simple_chat_flow_message_history/simple_chat_flow_message_history_response.json diff --git a/.vscode/settings.json b/.vscode/settings.json index c9eb00cc..0528c26b 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -36,5 +36,6 @@ "htmlcov": true, ".mypy_cache": true, ".coverage": true - } + }, + "python.REPL.enableREPLSmartSend": false } diff --git a/requirements-dev.txt b/requirements-dev.txt index e73ac0c7..632cfe91 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -14,3 +14,4 @@ pytest-snapshot locust psycopg2 dotenv-azd +freezegun diff --git a/src/backend/fastapi_app/__init__.py b/src/backend/fastapi_app/__init__.py index ddbd72c6..5b4b5943 100644 --- a/src/backend/fastapi_app/__init__.py +++ b/src/backend/fastapi_app/__init__.py @@ -38,7 +38,7 @@ async def lifespan(app: fastapi.FastAPI) -> AsyncIterator[State]: if ( os.getenv("OPENAI_CHAT_HOST") == "azure" or os.getenv("OPENAI_EMBED_HOST") == "azure" - or os.getenv("POSTGRES_HOST").endswith(".database.azure.com") + or os.getenv("POSTGRES_HOST", "").endswith(".database.azure.com") ): azure_credential = await get_azure_credential() engine = await create_postgres_engine_from_env(azure_credential) diff --git a/src/backend/fastapi_app/api_models.py b/src/backend/fastapi_app/api_models.py index b7290f0b..46574c4e 100644 --- a/src/backend/fastapi_app/api_models.py +++ b/src/backend/fastapi_app/api_models.py @@ -1,8 +1,9 @@ from enum import Enum -from typing import Any, Optional +from typing import Any, Optional, Union from openai.types.chat import ChatCompletionMessageParam -from pydantic import BaseModel +from pydantic import BaseModel, Field +from pydantic_ai.messages import ModelRequest, ModelResponse class AIChatRoles(str, Enum): @@ -95,4 +96,33 @@ class ChatParams(ChatRequestOverrides): enable_text_search: bool enable_vector_search: bool original_user_query: str - past_messages: list[ChatCompletionMessageParam] + past_messages: list[Union[ModelRequest, ModelResponse]] + + +class Filter(BaseModel): + column: str + comparison_operator: str + value: Any + + +class PriceFilter(Filter): + column: str = Field(default="price", description="The column to filter on (always 'price' for this filter)") + comparison_operator: str = Field(description="The operator for price comparison ('>', '<', '>=', '<=', '=')") + value: float = Field(description="The price value to compare against (e.g., 30.00)") + + +class BrandFilter(Filter): + column: str = Field(default="brand", description="The column to filter on (always 'brand' for this filter)") + comparison_operator: str = Field(description="The operator for brand comparison ('=' or '!=')") + value: str = Field(description="The brand name to compare against (e.g., 'AirStrider')") + + +class SearchResults(BaseModel): + query: str + """The original search query""" + + items: list[ItemPublic] + """List of items that match the search query and filters""" + + filters: list[Filter] + """List of filters applied to the search results""" diff --git a/src/backend/fastapi_app/openai_clients.py b/src/backend/fastapi_app/openai_clients.py index 57dcbc96..af76229d 100644 --- a/src/backend/fastapi_app/openai_clients.py +++ b/src/backend/fastapi_app/openai_clients.py @@ -9,7 +9,7 @@ async def create_openai_chat_client( - azure_credential: Union[azure.identity.AzureDeveloperCliCredential, azure.identity.ManagedIdentityCredential], + azure_credential: Union[azure.identity.AzureDeveloperCliCredential, azure.identity.ManagedIdentityCredential, None], ) -> Union[openai.AsyncAzureOpenAI, openai.AsyncOpenAI]: openai_chat_client: Union[openai.AsyncAzureOpenAI, openai.AsyncOpenAI] OPENAI_CHAT_HOST = os.getenv("OPENAI_CHAT_HOST") @@ -29,7 +29,7 @@ async def create_openai_chat_client( azure_deployment=azure_deployment, api_key=api_key, ) - else: + elif azure_credential: logger.info( "Setting up Azure OpenAI client for chat completions using Azure Identity, endpoint %s, deployment %s", azure_endpoint, @@ -44,6 +44,8 @@ async def create_openai_chat_client( azure_deployment=azure_deployment, azure_ad_token_provider=token_provider, ) + else: + raise ValueError("Azure OpenAI client requires either an API key or Azure Identity credential.") elif OPENAI_CHAT_HOST == "ollama": logger.info("Setting up OpenAI client for chat completions using Ollama") openai_chat_client = openai.AsyncOpenAI( @@ -67,7 +69,7 @@ async def create_openai_chat_client( async def create_openai_embed_client( - azure_credential: Union[azure.identity.AzureDeveloperCliCredential, azure.identity.ManagedIdentityCredential], + azure_credential: Union[azure.identity.AzureDeveloperCliCredential, azure.identity.ManagedIdentityCredential, None], ) -> Union[openai.AsyncAzureOpenAI, openai.AsyncOpenAI]: openai_embed_client: Union[openai.AsyncAzureOpenAI, openai.AsyncOpenAI] OPENAI_EMBED_HOST = os.getenv("OPENAI_EMBED_HOST") @@ -87,7 +89,7 @@ async def create_openai_embed_client( azure_deployment=azure_deployment, api_key=api_key, ) - else: + elif azure_credential: logger.info( "Setting up Azure OpenAI client for embeddings using Azure Identity, endpoint %s, deployment %s", azure_endpoint, @@ -102,6 +104,8 @@ async def create_openai_embed_client( azure_deployment=azure_deployment, azure_ad_token_provider=token_provider, ) + else: + raise ValueError("Azure OpenAI client requires either an API key or Azure Identity credential.") elif OPENAI_EMBED_HOST == "ollama": logger.info("Setting up OpenAI client for embeddings using Ollama") openai_embed_client = openai.AsyncOpenAI( diff --git a/src/backend/fastapi_app/postgres_searcher.py b/src/backend/fastapi_app/postgres_searcher.py index cf753632..aa84eaf8 100644 --- a/src/backend/fastapi_app/postgres_searcher.py +++ b/src/backend/fastapi_app/postgres_searcher.py @@ -5,6 +5,7 @@ from sqlalchemy import Float, Integer, column, select, text from sqlalchemy.ext.asyncio import AsyncSession +from fastapi_app.api_models import Filter from fastapi_app.embeddings import compute_text_embedding from fastapi_app.postgres_models import Item @@ -26,21 +27,24 @@ def __init__( self.embed_dimensions = embed_dimensions self.embedding_column = embedding_column - def build_filter_clause(self, filters) -> tuple[str, str]: + def build_filter_clause(self, filters: Optional[list[Filter]]) -> tuple[str, str]: if filters is None: return "", "" filter_clauses = [] for filter in filters: - if isinstance(filter["value"], str): - filter["value"] = f"'{filter['value']}'" - filter_clauses.append(f"{filter['column']} {filter['comparison_operator']} {filter['value']}") + filter_value = f"'{filter.value}'" if isinstance(filter.value, str) else filter.value + filter_clauses.append(f"{filter.column} {filter.comparison_operator} {filter_value}") filter_clause = " AND ".join(filter_clauses) if len(filter_clause) > 0: return f"WHERE {filter_clause}", f"AND {filter_clause}" return "", "" async def search( - self, query_text: Optional[str], query_vector: list[float], top: int = 5, filters: Optional[list[dict]] = None + self, + query_text: Optional[str], + query_vector: list[float], + top: int = 5, + filters: Optional[list[Filter]] = None, ): filter_clause_where, filter_clause_and = self.build_filter_clause(filters) table_name = Item.__tablename__ @@ -106,7 +110,7 @@ async def search_and_embed( top: int = 5, enable_vector_search: bool = False, enable_text_search: bool = False, - filters: Optional[list[dict]] = None, + filters: Optional[list[Filter]] = None, ) -> list[Item]: """ Search rows by query text. Optionally converts the query text to a vector if enable_vector_search is True. diff --git a/src/backend/fastapi_app/rag_advanced.py b/src/backend/fastapi_app/rag_advanced.py index 248a4d22..582684c5 100644 --- a/src/backend/fastapi_app/rag_advanced.py +++ b/src/backend/fastapi_app/rag_advanced.py @@ -1,5 +1,5 @@ from collections.abc import AsyncGenerator -from typing import Optional, TypedDict, Union +from typing import Optional, Union from openai import AsyncAzureOpenAI, AsyncOpenAI from openai.types.chat import ChatCompletionMessageParam @@ -11,51 +11,22 @@ from fastapi_app.api_models import ( AIChatRoles, + BrandFilter, ChatRequestOverrides, + Filter, ItemPublic, Message, + PriceFilter, RAGContext, RetrievalResponse, RetrievalResponseDelta, + SearchResults, ThoughtStep, ) from fastapi_app.postgres_searcher import PostgresSearcher from fastapi_app.rag_base import ChatParams, RAGChatBase -class PriceFilter(TypedDict): - column: str = "price" - """The column to filter on (always 'price' for this filter)""" - - comparison_operator: str - """The operator for price comparison ('>', '<', '>=', '<=', '=')""" - - value: float - """ The price value to compare against (e.g., 30.00) """ - - -class BrandFilter(TypedDict): - column: str = "brand" - """The column to filter on (always 'brand' for this filter)""" - - comparison_operator: str - """The operator for brand comparison ('=' or '!=')""" - - value: str - """The brand name to compare against (e.g., 'AirStrider')""" - - -class SearchResults(TypedDict): - query: str - """The original search query""" - - items: list[ItemPublic] - """List of items that match the search query and filters""" - - filters: list[Union[PriceFilter, BrandFilter]] - """List of filters applied to the search results""" - - class AdvancedRAGChat(RAGChatBase): query_prompt_template = open(RAGChatBase.prompts_dir / "query.txt").read() query_fewshots = open(RAGChatBase.prompts_dir / "query_fewshots.json").read() @@ -79,9 +50,13 @@ def __init__( chat_model if chat_deployment is None else chat_deployment, provider=OpenAIProvider(openai_client=openai_chat_client), ) - self.search_agent = Agent( + self.search_agent = Agent[ChatParams, SearchResults]( pydantic_chat_model, - model_settings=ModelSettings(temperature=0.0, max_tokens=500, seed=self.chat_params.seed), + model_settings=ModelSettings( + temperature=0.0, + max_tokens=500, + **({"seed": self.chat_params.seed} if self.chat_params.seed is not None else {}), + ), system_prompt=self.query_prompt_template, tools=[self.search_database], output_type=SearchResults, @@ -92,7 +67,7 @@ def __init__( model_settings=ModelSettings( temperature=self.chat_params.temperature, max_tokens=self.chat_params.response_token_limit, - seed=self.chat_params.seed, + **({"seed": self.chat_params.seed} if self.chat_params.seed is not None else {}), ), ) @@ -115,7 +90,7 @@ async def search_database( List of formatted items that match the search query and filters """ # Only send non-None filters - filters = [] + filters: list[Filter] = [] if price_filter: filters.append(price_filter) if brand_filter: @@ -134,12 +109,12 @@ async def search_database( async def prepare_context(self) -> tuple[list[ItemPublic], list[ThoughtStep]]: few_shots = ModelMessagesTypeAdapter.validate_json(self.query_fewshots) user_query = f"Find search results for user query: {self.chat_params.original_user_query}" - results = await self.search_agent.run( + results = await self.search_agent.run( # type: ignore[call-overload] user_query, message_history=few_shots + self.chat_params.past_messages, deps=self.chat_params, ) - items = results.output["items"] + items = results.output.items thoughts = [ ThoughtStep( title="Prompt to generate search arguments", @@ -148,12 +123,12 @@ async def prepare_context(self) -> tuple[list[ItemPublic], list[ThoughtStep]]: ), ThoughtStep( title="Search using generated search arguments", - description=results.output["query"], + description=results.output.query, props={ "top": self.chat_params.top, "vector_search": self.chat_params.enable_vector_search, "text_search": self.chat_params.enable_text_search, - "filters": results.output["filters"], + "filters": results.output.filters, }, ), ThoughtStep( diff --git a/src/backend/fastapi_app/rag_base.py b/src/backend/fastapi_app/rag_base.py index c1c28ccd..62bdc800 100644 --- a/src/backend/fastapi_app/rag_base.py +++ b/src/backend/fastapi_app/rag_base.py @@ -1,8 +1,10 @@ import pathlib from abc import ABC, abstractmethod from collections.abc import AsyncGenerator +from typing import Union from openai.types.chat import ChatCompletionMessageParam +from pydantic_ai.messages import ModelRequest, ModelResponse, TextPart, UserPromptPart from fastapi_app.api_models import ( ChatParams, @@ -12,7 +14,6 @@ RetrievalResponseDelta, ThoughtStep, ) -from fastapi_app.postgres_models import Item class RAGChatBase(ABC): @@ -31,7 +32,19 @@ def get_chat_params( original_user_query = messages[-1]["content"] if not isinstance(original_user_query, str): raise ValueError("The most recent message content must be a string.") - past_messages = messages[:-1] + + # Convert to PydanticAI format: + past_messages: list[Union[ModelRequest, ModelResponse]] = [] + for message in messages[:-1]: + content = message["content"] + if not isinstance(content, str): + raise ValueError("All messages must have string content.") + if message["role"] == "user": + past_messages.append(ModelRequest(parts=[UserPromptPart(content=content)])) + elif message["role"] == "assistant": + past_messages.append(ModelResponse(parts=[TextPart(content=content)])) + else: + raise ValueError(f"Cannot convert message: {message}") return ChatParams( top=overrides.top, @@ -48,9 +61,7 @@ def get_chat_params( ) @abstractmethod - async def prepare_context( - self, chat_params: ChatParams - ) -> tuple[list[ChatCompletionMessageParam], list[Item], list[ThoughtStep]]: + async def prepare_context(self) -> tuple[list[ItemPublic], list[ThoughtStep]]: raise NotImplementedError def prepare_rag_request(self, user_query, items: list[ItemPublic]) -> str: @@ -60,9 +71,7 @@ def prepare_rag_request(self, user_query, items: list[ItemPublic]) -> str: @abstractmethod async def answer( self, - chat_params: ChatParams, - contextual_messages: list[ChatCompletionMessageParam], - results: list[Item], + items: list[ItemPublic], earlier_thoughts: list[ThoughtStep], ) -> RetrievalResponse: raise NotImplementedError @@ -70,9 +79,7 @@ async def answer( @abstractmethod async def answer_stream( self, - chat_params: ChatParams, - contextual_messages: list[ChatCompletionMessageParam], - results: list[Item], + items: list[ItemPublic], earlier_thoughts: list[ThoughtStep], ) -> AsyncGenerator[RetrievalResponseDelta, None]: raise NotImplementedError diff --git a/src/backend/fastapi_app/rag_simple.py b/src/backend/fastapi_app/rag_simple.py index ad748320..2d41bb9d 100644 --- a/src/backend/fastapi_app/rag_simple.py +++ b/src/backend/fastapi_app/rag_simple.py @@ -48,7 +48,7 @@ def __init__( model_settings=ModelSettings( temperature=self.chat_params.temperature, max_tokens=self.chat_params.response_token_limit, - seed=self.chat_params.seed, + **({"seed": self.chat_params.seed} if self.chat_params.seed is not None else {}), ), ) diff --git a/src/backend/pyproject.toml b/src/backend/pyproject.toml index 0e694634..7f5ac750 100644 --- a/src/backend/pyproject.toml +++ b/src/backend/pyproject.toml @@ -19,7 +19,7 @@ dependencies = [ "opentelemetry-instrumentation-sqlalchemy", "opentelemetry-instrumentation-aiohttp-client", "opentelemetry-instrumentation-openai", - "pydantic-ai" + "pydantic-ai-slim[openai]" ] [build-system] diff --git a/src/backend/requirements.txt b/src/backend/requirements.txt index 6972e8a3..dbeab125 100644 --- a/src/backend/requirements.txt +++ b/src/backend/requirements.txt @@ -2,7 +2,7 @@ # uv pip compile pyproject.toml -o requirements.txt --python-version 3.9 aiohappyeyeballs==2.4.4 # via aiohttp -aiohttp==3.11.14 +aiohttp==3.11.18 # via fastapi-app (pyproject.toml) aiosignal==1.3.1 # via aiohttp @@ -50,6 +50,8 @@ charset-normalizer==3.4.0 # via requests click==8.1.7 # via uvicorn +colorama==0.4.6 + # via griffe cryptography==44.0.1 # via # azure-identity @@ -63,8 +65,12 @@ distro==1.9.0 # via openai environs==11.2.1 # via fastapi-app (pyproject.toml) +eval-type-backport==0.2.2 + # via pydantic-ai-slim exceptiongroup==1.2.2 - # via anyio + # via + # anyio + # pydantic-ai-slim fastapi==0.115.8 # via fastapi-app (pyproject.toml) fixedint==0.1.6 @@ -75,6 +81,8 @@ frozenlist==1.5.0 # aiosignal greenlet==3.1.1 # via sqlalchemy +griffe==1.7.3 + # via pydantic-ai-slim h11==0.14.0 # via # httpcore @@ -82,7 +90,10 @@ h11==0.14.0 httpcore==1.0.7 # via httpx httpx==0.28.0 - # via openai + # via + # openai + # pydantic-ai-slim + # pydantic-graph idna==3.10 # via # anyio @@ -90,13 +101,13 @@ idna==3.10 # requests # yarl importlib-metadata==8.4.0 - # via - # opentelemetry-api - # opentelemetry-instrumentation-flask + # via opentelemetry-api isodate==0.7.2 # via msrest jiter==0.8.0 # via openai +logfire-api==3.14.1 + # via pydantic-graph marshmallow==3.23.1 # via environs msal==1.31.1 @@ -115,10 +126,11 @@ numpy==2.0.2 # via pgvector oauthlib==3.2.2 # via requests-oauthlib -openai==1.55.3 +openai==1.77.0 # via # fastapi-app (pyproject.toml) # openai-messages-token-helper + # pydantic-ai-slim openai-messages-token-helper==0.1.11 # via fastapi-app (pyproject.toml) opentelemetry-api==1.30.0 @@ -141,6 +153,7 @@ opentelemetry-api==1.30.0 # opentelemetry-instrumentation-wsgi # opentelemetry-sdk # opentelemetry-semantic-conventions + # pydantic-ai-slim opentelemetry-instrumentation==0.51b0 # via # opentelemetry-instrumentation-aiohttp-client @@ -193,6 +206,7 @@ opentelemetry-sdk==1.30.0 # opentelemetry-resource-detector-azure opentelemetry-semantic-conventions==0.51b0 # via + # opentelemetry-instrumentation # opentelemetry-instrumentation-aiohttp-client # opentelemetry-instrumentation-asgi # opentelemetry-instrumentation-dbapi @@ -222,6 +236,7 @@ opentelemetry-util-http==0.51b0 packaging==24.2 # via # marshmallow + # opentelemetry-instrumentation # opentelemetry-instrumentation-flask # opentelemetry-instrumentation-sqlalchemy pgvector==0.3.6 @@ -242,8 +257,14 @@ pydantic==2.10.2 # via # fastapi # openai + # pydantic-ai-slim + # pydantic-graph +pydantic-ai-slim==0.1.10 + # via fastapi-app (pyproject.toml) pydantic-core==2.27.1 # via pydantic +pydantic-graph==0.1.10 + # via pydantic-ai-slim pyjwt==2.10.1 # via msal python-dotenv==1.0.1 @@ -261,8 +282,6 @@ requests==2.32.3 # tiktoken requests-oauthlib==2.0.0 # via msrest -setuptools==75.6.0 - # via opentelemetry-instrumentation six==1.16.0 # via azure-core sniffio==1.3.1 @@ -294,8 +313,13 @@ typing-extensions==4.12.2 # pydantic-core # sqlalchemy # starlette + # typing-inspection # uvicorn -urllib3==2.2.3 +typing-inspection==0.4.0 + # via + # pydantic-ai-slim + # pydantic-graph +urllib3==1.26.20 # via requests uvicorn==0.32.1 # via fastapi-app (pyproject.toml) diff --git a/tests/conftest.py b/tests/conftest.py index 5fe67053..f3800dd3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,6 +8,7 @@ import pytest import pytest_asyncio from fastapi.testclient import TestClient +from freezegun import freeze_time from openai.types import CreateEmbeddingResponse, Embedding from openai.types.chat import ChatCompletion, ChatCompletionChunk from openai.types.chat.chat_completion import ( @@ -335,6 +336,13 @@ async def mock_acreate(*args, **kwargs): yield +@pytest.fixture(autouse=True) +def frozen_time(): + """Freeze time for all tests to ensure consistent timestamps""" + with freeze_time("2024-01-01 12:00:00"): + yield + + @pytest.fixture(scope="function") def mock_azure_credential(mock_session_env): """Mock the Azure credential for testing.""" diff --git a/tests/snapshots/test_api_routes/test_advanced_chat_flow/advanced_chat_flow_response.json b/tests/snapshots/test_api_routes/test_advanced_chat_flow/advanced_chat_flow_response.json index 342d9f70..240a638a 100644 --- a/tests/snapshots/test_api_routes/test_advanced_chat_flow/advanced_chat_flow_response.json +++ b/tests/snapshots/test_api_routes/test_advanced_chat_flow/advanced_chat_flow_response.json @@ -96,7 +96,7 @@ "parts": [ { "content": "Find search results for user query: What is the capital of France?", - "timestamp": "2025-05-07T22:53:02.948859Z", + "timestamp": "2024-01-01T12:00:00Z", "part_kind": "user-prompt" } ], @@ -135,7 +135,7 @@ "filters": [] }, "tool_call_id": "call_abc123", - "timestamp": "2025-05-07T22:53:02.963649Z", + "timestamp": "2024-01-01T12:00:00Z", "part_kind": "tool-return" } ], @@ -161,7 +161,7 @@ "tool_name": "final_result", "content": "Final result processed.", "tool_call_id": "call_abc123final", - "timestamp": "2025-05-07T22:53:02.964200Z", + "timestamp": "2024-01-01T12:00:00Z", "part_kind": "tool-return" } ], @@ -205,13 +205,13 @@ "parts": [ { "content": "Assistant helps customers with questions about products.\nRespond as if you are a salesperson helping a customer in a store. Do NOT respond with tables.\nAnswer ONLY with the product details listed in the products.\nIf there isn't enough information below, say you don't know.\nDo not generate answers that don't use the sources below.\nEach product has an ID in brackets followed by colon and the product details.\nAlways include the product ID for each product you use in the response.\nUse square brackets to reference the source, for example [52].\nDon't combine citations, list each product separately, for example [27][51].", - "timestamp": "2025-05-07T22:53:02.964504Z", + "timestamp": "2024-01-01T12:00:00Z", "dynamic_ref": null, "part_kind": "system-prompt" }, { "content": "What is the capital of France?Sources:\n[1]:Name:Wanderer Black Hiking Boots Description:Daybird's Wanderer Hiking Boots in sleek black are perfect for all your outdoor adventures. These boots are made with a waterproof leather upper and a durable rubber sole for superior traction. With their cushioned insole and padded collar, these boots will keep you comfortable all day long. Price:109.99 Brand:Daybird Type:Footwear", - "timestamp": "2025-05-07T22:53:02.964505Z", + "timestamp": "2024-01-01T12:00:00Z", "part_kind": "user-prompt" } ], diff --git a/tests/snapshots/test_api_routes/test_advanced_chat_streaming_flow/advanced_chat_streaming_flow_response.jsonlines b/tests/snapshots/test_api_routes/test_advanced_chat_streaming_flow/advanced_chat_streaming_flow_response.jsonlines index 84ce766c..e241106d 100644 --- a/tests/snapshots/test_api_routes/test_advanced_chat_streaming_flow/advanced_chat_streaming_flow_response.jsonlines +++ b/tests/snapshots/test_api_routes/test_advanced_chat_streaming_flow/advanced_chat_streaming_flow_response.jsonlines @@ -1,2 +1,2 @@ -{"delta":null,"context":{"data_points":{"1":{"id":1,"type":"Footwear","brand":"Daybird","name":"Wanderer Black Hiking Boots","description":"Daybird's Wanderer Hiking Boots in sleek black are perfect for all your outdoor adventures. These boots are made with a waterproof leather upper and a durable rubber sole for superior traction. With their cushioned insole and padded collar, these boots will keep you comfortable all day long.","price":109.99}},"thoughts":[{"title":"Prompt to generate search arguments","description":[{"parts":[{"content":"good options for climbing gear that can be used outside?","timestamp":"2025-05-07T19:02:46.977501Z","part_kind":"user-prompt"}],"instructions":null,"kind":"request"},{"parts":[{"tool_name":"search_database","args":"{\"search_query\":\"climbing gear outside\"}","tool_call_id":"call_4HeBCmo2uioV6CyoePEGyZPc","part_kind":"tool-call"}],"model_name":"gpt-4o-mini-2024-07-18","timestamp":"2025-05-07T19:02:47Z","kind":"response"},{"parts":[{"tool_name":"search_database","content":"Search results for climbing gear that can be used outside: ...","tool_call_id":"call_4HeBCmo2uioV6CyoePEGyZPc","timestamp":"2025-05-07T19:02:48.242408Z","part_kind":"tool-return"}],"instructions":null,"kind":"request"},{"parts":[{"content":"are there any shoes less than $50?","timestamp":"2025-05-07T19:02:46.977501Z","part_kind":"user-prompt"}],"instructions":null,"kind":"request"},{"parts":[{"tool_name":"search_database","args":"{\"search_query\":\"shoes\",\"price_filter\":{\"comparison_operator\":\"<\",\"value\":50}}","tool_call_id":"call_4HeBCmo2uioV6CyoePEGyZPc","part_kind":"tool-call"}],"model_name":"gpt-4o-mini-2024-07-18","timestamp":"2025-05-07T19:02:47Z","kind":"response"},{"parts":[{"tool_name":"search_database","content":"Search results for shoes cheaper than 50: ...","tool_call_id":"call_4HeBCmo2uioV6CyoePEGyZPc","timestamp":"2025-05-07T19:02:48.242408Z","part_kind":"tool-return"}],"instructions":null,"kind":"request"},{"parts":[{"content":"Find search results for user query: What is the capital of France?","timestamp":"2025-05-07T22:53:03.018949Z","part_kind":"user-prompt"}],"instructions":null,"kind":"request"},{"parts":[{"tool_name":"search_database","args":"{\"search_query\":\"climbing gear outside\"}","tool_call_id":"call_abc123","part_kind":"tool-call"}],"model_name":"test-model","timestamp":"1970-01-01T00:00:00Z","kind":"response"},{"parts":[{"tool_name":"search_database","content":{"query":"climbing gear outside","items":[{"id":1,"type":"Footwear","brand":"Daybird","name":"Wanderer Black Hiking Boots","description":"Daybird's Wanderer Hiking Boots in sleek black are perfect for all your outdoor adventures. These boots are made with a waterproof leather upper and a durable rubber sole for superior traction. With their cushioned insole and padded collar, these boots will keep you comfortable all day long.","price":109.99}],"filters":[]},"tool_call_id":"call_abc123","timestamp":"2025-05-07T22:53:03.034667Z","part_kind":"tool-return"}],"instructions":null,"kind":"request"},{"parts":[{"tool_name":"final_result","args":"{\"query\": \"capital of France\", \"items\": [{\"id\": 1, \"type\": \"Footwear\", \"brand\": \"Daybird\", \"name\": \"Wanderer Black Hiking Boots\", \"description\": \"Daybird's Wanderer Hiking Boots in sleek black are perfect for all your outdoor adventures. These boots are made with a waterproof leather upper and a durable rubber sole for superior traction. With their cushioned insole and padded collar, these boots will keep you comfortable all day long.\", \"price\": 109.99}], \"filters\": []}","tool_call_id":"call_abc123final","part_kind":"tool-call"}],"model_name":"test-model","timestamp":"1970-01-01T00:00:00Z","kind":"response"},{"parts":[{"tool_name":"final_result","content":"Final result processed.","tool_call_id":"call_abc123final","timestamp":"2025-05-07T22:53:03.035160Z","part_kind":"tool-return"}],"instructions":null,"kind":"request"}],"props":{"model":"gpt-4o-mini","deployment":"gpt-4o-mini"}},{"title":"Search using generated search arguments","description":"capital of France","props":{"top":1,"vector_search":true,"text_search":true,"filters":[]}},{"title":"Search results","description":[{"id":1,"type":"Footwear","brand":"Daybird","name":"Wanderer Black Hiking Boots","description":"Daybird's Wanderer Hiking Boots in sleek black are perfect for all your outdoor adventures. These boots are made with a waterproof leather upper and a durable rubber sole for superior traction. With their cushioned insole and padded collar, these boots will keep you comfortable all day long.","price":109.99}],"props":{}},{"title":"Prompt to generate answer","description":[{"parts":[{"content":"Assistant helps customers with questions about products.\nRespond as if you are a salesperson helping a customer in a store. Do NOT respond with tables.\nAnswer ONLY with the product details listed in the products.\nIf there isn't enough information below, say you don't know.\nDo not generate answers that don't use the sources below.\nEach product has an ID in brackets followed by colon and the product details.\nAlways include the product ID for each product you use in the response.\nUse square brackets to reference the source, for example [52].\nDon't combine citations, list each product separately, for example [27][51].","timestamp":"2025-05-07T22:53:03.035786Z","dynamic_ref":null,"part_kind":"system-prompt"},{"content":"What is the capital of France?Sources:\n[1]:Name:Wanderer Black Hiking Boots Description:Daybird's Wanderer Hiking Boots in sleek black are perfect for all your outdoor adventures. These boots are made with a waterproof leather upper and a durable rubber sole for superior traction. With their cushioned insole and padded collar, these boots will keep you comfortable all day long. Price:109.99 Brand:Daybird Type:Footwear","timestamp":"2025-05-07T22:53:03.035788Z","part_kind":"user-prompt"}],"instructions":null,"kind":"request"}],"props":{"model":"gpt-4o-mini","deployment":"gpt-4o-mini"}}],"followup_questions":null},"sessionState":null} +{"delta":null,"context":{"data_points":{"1":{"id":1,"type":"Footwear","brand":"Daybird","name":"Wanderer Black Hiking Boots","description":"Daybird's Wanderer Hiking Boots in sleek black are perfect for all your outdoor adventures. These boots are made with a waterproof leather upper and a durable rubber sole for superior traction. With their cushioned insole and padded collar, these boots will keep you comfortable all day long.","price":109.99}},"thoughts":[{"title":"Prompt to generate search arguments","description":[{"parts":[{"content":"good options for climbing gear that can be used outside?","timestamp":"2025-05-07T19:02:46.977501Z","part_kind":"user-prompt"}],"instructions":null,"kind":"request"},{"parts":[{"tool_name":"search_database","args":"{\"search_query\":\"climbing gear outside\"}","tool_call_id":"call_4HeBCmo2uioV6CyoePEGyZPc","part_kind":"tool-call"}],"model_name":"gpt-4o-mini-2024-07-18","timestamp":"2025-05-07T19:02:47Z","kind":"response"},{"parts":[{"tool_name":"search_database","content":"Search results for climbing gear that can be used outside: ...","tool_call_id":"call_4HeBCmo2uioV6CyoePEGyZPc","timestamp":"2025-05-07T19:02:48.242408Z","part_kind":"tool-return"}],"instructions":null,"kind":"request"},{"parts":[{"content":"are there any shoes less than $50?","timestamp":"2025-05-07T19:02:46.977501Z","part_kind":"user-prompt"}],"instructions":null,"kind":"request"},{"parts":[{"tool_name":"search_database","args":"{\"search_query\":\"shoes\",\"price_filter\":{\"comparison_operator\":\"<\",\"value\":50}}","tool_call_id":"call_4HeBCmo2uioV6CyoePEGyZPc","part_kind":"tool-call"}],"model_name":"gpt-4o-mini-2024-07-18","timestamp":"2025-05-07T19:02:47Z","kind":"response"},{"parts":[{"tool_name":"search_database","content":"Search results for shoes cheaper than 50: ...","tool_call_id":"call_4HeBCmo2uioV6CyoePEGyZPc","timestamp":"2025-05-07T19:02:48.242408Z","part_kind":"tool-return"}],"instructions":null,"kind":"request"},{"parts":[{"content":"Find search results for user query: What is the capital of France?","timestamp":"2024-01-01T12:00:00Z","part_kind":"user-prompt"}],"instructions":null,"kind":"request"},{"parts":[{"tool_name":"search_database","args":"{\"search_query\":\"climbing gear outside\"}","tool_call_id":"call_abc123","part_kind":"tool-call"}],"model_name":"test-model","timestamp":"1970-01-01T00:00:00Z","kind":"response"},{"parts":[{"tool_name":"search_database","content":{"query":"climbing gear outside","items":[{"id":1,"type":"Footwear","brand":"Daybird","name":"Wanderer Black Hiking Boots","description":"Daybird's Wanderer Hiking Boots in sleek black are perfect for all your outdoor adventures. These boots are made with a waterproof leather upper and a durable rubber sole for superior traction. With their cushioned insole and padded collar, these boots will keep you comfortable all day long.","price":109.99}],"filters":[]},"tool_call_id":"call_abc123","timestamp":"2024-01-01T12:00:00Z","part_kind":"tool-return"}],"instructions":null,"kind":"request"},{"parts":[{"tool_name":"final_result","args":"{\"query\": \"capital of France\", \"items\": [{\"id\": 1, \"type\": \"Footwear\", \"brand\": \"Daybird\", \"name\": \"Wanderer Black Hiking Boots\", \"description\": \"Daybird's Wanderer Hiking Boots in sleek black are perfect for all your outdoor adventures. These boots are made with a waterproof leather upper and a durable rubber sole for superior traction. With their cushioned insole and padded collar, these boots will keep you comfortable all day long.\", \"price\": 109.99}], \"filters\": []}","tool_call_id":"call_abc123final","part_kind":"tool-call"}],"model_name":"test-model","timestamp":"1970-01-01T00:00:00Z","kind":"response"},{"parts":[{"tool_name":"final_result","content":"Final result processed.","tool_call_id":"call_abc123final","timestamp":"2024-01-01T12:00:00Z","part_kind":"tool-return"}],"instructions":null,"kind":"request"}],"props":{"model":"gpt-4o-mini","deployment":"gpt-4o-mini"}},{"title":"Search using generated search arguments","description":"capital of France","props":{"top":1,"vector_search":true,"text_search":true,"filters":[]}},{"title":"Search results","description":[{"id":1,"type":"Footwear","brand":"Daybird","name":"Wanderer Black Hiking Boots","description":"Daybird's Wanderer Hiking Boots in sleek black are perfect for all your outdoor adventures. These boots are made with a waterproof leather upper and a durable rubber sole for superior traction. With their cushioned insole and padded collar, these boots will keep you comfortable all day long.","price":109.99}],"props":{}},{"title":"Prompt to generate answer","description":[{"parts":[{"content":"Assistant helps customers with questions about products.\nRespond as if you are a salesperson helping a customer in a store. Do NOT respond with tables.\nAnswer ONLY with the product details listed in the products.\nIf there isn't enough information below, say you don't know.\nDo not generate answers that don't use the sources below.\nEach product has an ID in brackets followed by colon and the product details.\nAlways include the product ID for each product you use in the response.\nUse square brackets to reference the source, for example [52].\nDon't combine citations, list each product separately, for example [27][51].","timestamp":"2024-01-01T12:00:00Z","dynamic_ref":null,"part_kind":"system-prompt"},{"content":"What is the capital of France?Sources:\n[1]:Name:Wanderer Black Hiking Boots Description:Daybird's Wanderer Hiking Boots in sleek black are perfect for all your outdoor adventures. These boots are made with a waterproof leather upper and a durable rubber sole for superior traction. With their cushioned insole and padded collar, these boots will keep you comfortable all day long. Price:109.99 Brand:Daybird Type:Footwear","timestamp":"2024-01-01T12:00:00Z","part_kind":"user-prompt"}],"instructions":null,"kind":"request"}],"props":{"model":"gpt-4o-mini","deployment":"gpt-4o-mini"}}],"followup_questions":null},"sessionState":null} {"delta":{"content":"The capital of France is Paris. [Benefit_Options-2.pdf].","role":"assistant"},"context":null,"sessionState":null} diff --git a/tests/snapshots/test_api_routes/test_simple_chat_flow/simple_chat_flow_response.json b/tests/snapshots/test_api_routes/test_simple_chat_flow/simple_chat_flow_response.json index e0b818c8..337a67d3 100644 --- a/tests/snapshots/test_api_routes/test_simple_chat_flow/simple_chat_flow_response.json +++ b/tests/snapshots/test_api_routes/test_simple_chat_flow/simple_chat_flow_response.json @@ -45,13 +45,13 @@ "parts": [ { "content": "Assistant helps customers with questions about products.\nRespond as if you are a salesperson helping a customer in a store. Do NOT respond with tables.\nAnswer ONLY with the product details listed in the products.\nIf there isn't enough information below, say you don't know.\nDo not generate answers that don't use the sources below.\nEach product has an ID in brackets followed by colon and the product details.\nAlways include the product ID for each product you use in the response.\nUse square brackets to reference the source, for example [52].\nDon't combine citations, list each product separately, for example [27][51].", - "timestamp": "2025-05-07T22:53:02.797757Z", + "timestamp": "2024-01-01T12:00:00Z", "dynamic_ref": null, "part_kind": "system-prompt" }, { "content": "What is the capital of France?Sources:\n[1]:Name:Wanderer Black Hiking Boots Description:Daybird's Wanderer Hiking Boots in sleek black are perfect for all your outdoor adventures. These boots are made with a waterproof leather upper and a durable rubber sole for superior traction. With their cushioned insole and padded collar, these boots will keep you comfortable all day long. Price:109.99 Brand:Daybird Type:Footwear", - "timestamp": "2025-05-07T22:53:02.797762Z", + "timestamp": "2024-01-01T12:00:00Z", "part_kind": "user-prompt" } ], diff --git a/tests/snapshots/test_api_routes/test_simple_chat_flow_message_history/simple_chat_flow_message_history_response.json b/tests/snapshots/test_api_routes/test_simple_chat_flow_message_history/simple_chat_flow_message_history_response.json new file mode 100644 index 00000000..6bc9d4ec --- /dev/null +++ b/tests/snapshots/test_api_routes/test_simple_chat_flow_message_history/simple_chat_flow_message_history_response.json @@ -0,0 +1,98 @@ +{ + "message": { + "content": "The capital of France is Paris. [Benefit_Options-2.pdf].", + "role": "assistant" + }, + "context": { + "data_points": { + "1": { + "id": 1, + "type": "Footwear", + "brand": "Daybird", + "name": "Wanderer Black Hiking Boots", + "description": "Daybird's Wanderer Hiking Boots in sleek black are perfect for all your outdoor adventures. These boots are made with a waterproof leather upper and a durable rubber sole for superior traction. With their cushioned insole and padded collar, these boots will keep you comfortable all day long.", + "price": 109.99 + } + }, + "thoughts": [ + { + "title": "Search query for database", + "description": "What is the capital of France?", + "props": { + "top": 1, + "vector_search": true, + "text_search": true + } + }, + { + "title": "Search results", + "description": [ + { + "id": 1, + "type": "Footwear", + "brand": "Daybird", + "name": "Wanderer Black Hiking Boots", + "description": "Daybird's Wanderer Hiking Boots in sleek black are perfect for all your outdoor adventures. These boots are made with a waterproof leather upper and a durable rubber sole for superior traction. With their cushioned insole and padded collar, these boots will keep you comfortable all day long.", + "price": 109.99 + } + ], + "props": {} + }, + { + "title": "Prompt to generate answer", + "description": [ + { + "parts": [ + { + "content": "What is the capital of France?", + "timestamp": "2024-01-01T12:00:00Z", + "part_kind": "user-prompt" + } + ], + "instructions": null, + "kind": "request" + }, + { + "parts": [ + { + "content": "The capital of France is Paris.", + "part_kind": "text" + } + ], + "model_name": null, + "timestamp": "2024-01-01T12:00:00Z", + "kind": "response" + }, + { + "parts": [ + { + "content": "What is the capital of France?Sources:\n[1]:Name:Wanderer Black Hiking Boots Description:Daybird's Wanderer Hiking Boots in sleek black are perfect for all your outdoor adventures. These boots are made with a waterproof leather upper and a durable rubber sole for superior traction. With their cushioned insole and padded collar, these boots will keep you comfortable all day long. Price:109.99 Brand:Daybird Type:Footwear", + "timestamp": "2024-01-01T12:00:00Z", + "part_kind": "user-prompt" + } + ], + "instructions": null, + "kind": "request" + }, + { + "parts": [ + { + "content": "The capital of France is Paris. [Benefit_Options-2.pdf].", + "part_kind": "text" + } + ], + "model_name": "test-model", + "timestamp": "1970-01-01T00:00:00Z", + "kind": "response" + } + ], + "props": { + "model": "gpt-4o-mini", + "deployment": "gpt-4o-mini" + } + } + ], + "followup_questions": null + }, + "sessionState": null +} \ No newline at end of file diff --git a/tests/snapshots/test_api_routes/test_simple_chat_streaming_flow/simple_chat_streaming_flow_response.jsonlines b/tests/snapshots/test_api_routes/test_simple_chat_streaming_flow/simple_chat_streaming_flow_response.jsonlines index 9e4487bb..28bfd00f 100644 --- a/tests/snapshots/test_api_routes/test_simple_chat_streaming_flow/simple_chat_streaming_flow_response.jsonlines +++ b/tests/snapshots/test_api_routes/test_simple_chat_streaming_flow/simple_chat_streaming_flow_response.jsonlines @@ -1,2 +1,2 @@ -{"delta":null,"context":{"data_points":{"1":{"id":1,"type":"Footwear","brand":"Daybird","name":"Wanderer Black Hiking Boots","description":"Daybird's Wanderer Hiking Boots in sleek black are perfect for all your outdoor adventures. These boots are made with a waterproof leather upper and a durable rubber sole for superior traction. With their cushioned insole and padded collar, these boots will keep you comfortable all day long.","price":109.99}},"thoughts":[{"title":"Search query for database","description":"What is the capital of France?","props":{"top":1,"vector_search":true,"text_search":true}},{"title":"Search results","description":[{"id":1,"type":"Footwear","brand":"Daybird","name":"Wanderer Black Hiking Boots","description":"Daybird's Wanderer Hiking Boots in sleek black are perfect for all your outdoor adventures. These boots are made with a waterproof leather upper and a durable rubber sole for superior traction. With their cushioned insole and padded collar, these boots will keep you comfortable all day long.","price":109.99}],"props":{}},{"title":"Prompt to generate answer","description":[{"parts":[{"content":"Assistant helps customers with questions about products.\nRespond as if you are a salesperson helping a customer in a store. Do NOT respond with tables.\nAnswer ONLY with the product details listed in the products.\nIf there isn't enough information below, say you don't know.\nDo not generate answers that don't use the sources below.\nEach product has an ID in brackets followed by colon and the product details.\nAlways include the product ID for each product you use in the response.\nUse square brackets to reference the source, for example [52].\nDon't combine citations, list each product separately, for example [27][51].","timestamp":"2025-05-07T22:53:02.878495Z","dynamic_ref":null,"part_kind":"system-prompt"},{"content":"What is the capital of France?Sources:\n[1]:Name:Wanderer Black Hiking Boots Description:Daybird's Wanderer Hiking Boots in sleek black are perfect for all your outdoor adventures. These boots are made with a waterproof leather upper and a durable rubber sole for superior traction. With their cushioned insole and padded collar, these boots will keep you comfortable all day long. Price:109.99 Brand:Daybird Type:Footwear","timestamp":"2025-05-07T22:53:02.878497Z","part_kind":"user-prompt"}],"instructions":null,"kind":"request"}],"props":{"model":"gpt-4o-mini","deployment":"gpt-4o-mini"}}],"followup_questions":null},"sessionState":null} +{"delta":null,"context":{"data_points":{"1":{"id":1,"type":"Footwear","brand":"Daybird","name":"Wanderer Black Hiking Boots","description":"Daybird's Wanderer Hiking Boots in sleek black are perfect for all your outdoor adventures. These boots are made with a waterproof leather upper and a durable rubber sole for superior traction. With their cushioned insole and padded collar, these boots will keep you comfortable all day long.","price":109.99}},"thoughts":[{"title":"Search query for database","description":"What is the capital of France?","props":{"top":1,"vector_search":true,"text_search":true}},{"title":"Search results","description":[{"id":1,"type":"Footwear","brand":"Daybird","name":"Wanderer Black Hiking Boots","description":"Daybird's Wanderer Hiking Boots in sleek black are perfect for all your outdoor adventures. These boots are made with a waterproof leather upper and a durable rubber sole for superior traction. With their cushioned insole and padded collar, these boots will keep you comfortable all day long.","price":109.99}],"props":{}},{"title":"Prompt to generate answer","description":[{"parts":[{"content":"Assistant helps customers with questions about products.\nRespond as if you are a salesperson helping a customer in a store. Do NOT respond with tables.\nAnswer ONLY with the product details listed in the products.\nIf there isn't enough information below, say you don't know.\nDo not generate answers that don't use the sources below.\nEach product has an ID in brackets followed by colon and the product details.\nAlways include the product ID for each product you use in the response.\nUse square brackets to reference the source, for example [52].\nDon't combine citations, list each product separately, for example [27][51].","timestamp":"2024-01-01T12:00:00Z","dynamic_ref":null,"part_kind":"system-prompt"},{"content":"What is the capital of France?Sources:\n[1]:Name:Wanderer Black Hiking Boots Description:Daybird's Wanderer Hiking Boots in sleek black are perfect for all your outdoor adventures. These boots are made with a waterproof leather upper and a durable rubber sole for superior traction. With their cushioned insole and padded collar, these boots will keep you comfortable all day long. Price:109.99 Brand:Daybird Type:Footwear","timestamp":"2024-01-01T12:00:00Z","part_kind":"user-prompt"}],"instructions":null,"kind":"request"}],"props":{"model":"gpt-4o-mini","deployment":"gpt-4o-mini"}}],"followup_questions":null},"sessionState":null} {"delta":{"content":"The capital of France is Paris. [Benefit_Options-2.pdf].","role":"assistant"},"context":null,"sessionState":null} diff --git a/tests/test_api_routes.py b/tests/test_api_routes.py index c36a1617..55da4d6f 100644 --- a/tests/test_api_routes.py +++ b/tests/test_api_routes.py @@ -125,6 +125,29 @@ async def test_simple_chat_flow(test_client, snapshot): snapshot.assert_match(json.dumps(response_data, indent=4), "simple_chat_flow_response.json") +@pytest.mark.asyncio +async def test_simple_chat_flow_message_history(test_client, snapshot): + """test the simple chat flow route with hybrid retrieval mode""" + response = test_client.post( + "/chat", + json={ + "context": { + "overrides": {"top": 1, "use_advanced_flow": False, "retrieval_mode": "hybrid", "temperature": 0.3} + }, + "messages": [ + {"content": "What is the capital of France?", "role": "user"}, + {"content": "The capital of France is Paris.", "role": "assistant"}, + {"content": "What is the capital of France?", "role": "user"}, + ], + }, + ) + response_data = response.json() + + assert response.status_code == 200 + assert response.headers["Content-Type"] == "application/json" + snapshot.assert_match(json.dumps(response_data, indent=4), "simple_chat_flow_message_history_response.json") + + @pytest.mark.asyncio async def test_simple_chat_streaming_flow(test_client, snapshot): """test the simple chat streaming flow route with hybrid retrieval mode""" diff --git a/tests/test_postgres_searcher.py b/tests/test_postgres_searcher.py index ee2992e0..4860e49e 100644 --- a/tests/test_postgres_searcher.py +++ b/tests/test_postgres_searcher.py @@ -1,6 +1,6 @@ import pytest -from fastapi_app.api_models import ItemPublic +from fastapi_app.api_models import BrandFilter, ItemPublic, PriceFilter from tests.data import test_data @@ -10,9 +10,24 @@ def test_postgres_build_filter_clause_without_filters(postgres_searcher): def test_postgres_build_filter_clause_with_filters(postgres_searcher): - assert postgres_searcher.build_filter_clause([{"column": "id", "comparison_operator": "=", "value": 1}]) == ( - "WHERE id = 1", - "AND id = 1", + assert postgres_searcher.build_filter_clause( + [ + BrandFilter(comparison_operator="=", value="AirStrider"), + ] + ) == ( + "WHERE brand = 'AirStrider'", + "AND brand = 'AirStrider'", + ) + + +def test_postgres_build_filter_clause_with_filters_numeric(postgres_searcher): + assert postgres_searcher.build_filter_clause( + [ + PriceFilter(comparison_operator="<", value=30), + ] + ) == ( + "WHERE price < 30.0", + "AND price < 30.0", ) From 7e744d24e72807b2da63fa1ede3b7ce6838308c7 Mon Sep 17 00:00:00 2001 From: Pamela Fox Date: Thu, 8 May 2025 05:31:06 +0000 Subject: [PATCH 10/10] Revert some changes --- .vscode/settings.json | 3 +-- src/backend/fastapi_app/__init__.py | 14 ++++---------- src/backend/fastapi_app/prompts/query.txt | 3 +++ src/backend/fastapi_app/rag_advanced.py | 4 ++-- src/backend/fastapi_app/routes/api_routes.py | 3 --- src/frontend/src/components/Answer/Answer.tsx | 1 + tests/test_postgres_searcher.py | 10 +++++----- 7 files changed, 16 insertions(+), 22 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 0528c26b..c9eb00cc 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -36,6 +36,5 @@ "htmlcov": true, ".mypy_cache": true, ".coverage": true - }, - "python.REPL.enableREPLSmartSend": false + } } diff --git a/src/backend/fastapi_app/__init__.py b/src/backend/fastapi_app/__init__.py index 5b4b5943..b760fdb2 100644 --- a/src/backend/fastapi_app/__init__.py +++ b/src/backend/fastapi_app/__init__.py @@ -58,17 +58,11 @@ def create_app(testing: bool = False): else: if not testing: load_dotenv(override=True) - logging.basicConfig(level=logging.DEBUG) - - # Enable detailed HTTP traffic logging - logging.getLogger("httpx").setLevel(logging.DEBUG) - logging.getLogger("azure.core.pipeline.policies.http_logging_policy").setLevel(logging.DEBUG) - logging.getLogger("azure.identity").setLevel(logging.DEBUG) - logging.getLogger("urllib3").setLevel(logging.DEBUG) + logging.basicConfig(level=logging.INFO) - # Configure httpx logging to show full bodies - os.environ["HTTPX_LOG_LEVEL"] = "DEBUG" - os.environ["HTTPCORE_LOG_LEVEL"] = "DEBUG" + # Turn off particularly noisy INFO level logs from Azure Core SDK: + logging.getLogger("azure.core.pipeline.policies.http_logging_policy").setLevel(logging.WARNING) + logging.getLogger("azure.identity").setLevel(logging.WARNING) if os.getenv("APPLICATIONINSIGHTS_CONNECTION_STRING"): logger.info("Configuring Azure Monitor") diff --git a/src/backend/fastapi_app/prompts/query.txt b/src/backend/fastapi_app/prompts/query.txt index 0de14213..54464bcb 100644 --- a/src/backend/fastapi_app/prompts/query.txt +++ b/src/backend/fastapi_app/prompts/query.txt @@ -1,2 +1,5 @@ Your job is to find search results based off the user's question and past messages. +You have access to only these tools: +1. **search_database**: This tool allows you to search a table for items based on a query. + You can pass in a search query and optional filters. Once you get the search results, you're done. diff --git a/src/backend/fastapi_app/rag_advanced.py b/src/backend/fastapi_app/rag_advanced.py index 582684c5..3541d8c7 100644 --- a/src/backend/fastapi_app/rag_advanced.py +++ b/src/backend/fastapi_app/rag_advanced.py @@ -82,7 +82,7 @@ async def search_database( Search PostgreSQL database for relevant products based on user query Args: - search_query: Query string to use for full text search, e.g. 'red shoes' + search_query: English query string to use for full text search, e.g. 'red shoes'. price_filter: Filter search results based on price of the product brand_filter: Filter search results based on brand of the product @@ -109,7 +109,7 @@ async def search_database( async def prepare_context(self) -> tuple[list[ItemPublic], list[ThoughtStep]]: few_shots = ModelMessagesTypeAdapter.validate_json(self.query_fewshots) user_query = f"Find search results for user query: {self.chat_params.original_user_query}" - results = await self.search_agent.run( # type: ignore[call-overload] + results = await self.search_agent.run( user_query, message_history=few_shots + self.chat_params.past_messages, deps=self.chat_params, diff --git a/src/backend/fastapi_app/routes/api_routes.py b/src/backend/fastapi_app/routes/api_routes.py index 04a83c86..f566886c 100644 --- a/src/backend/fastapi_app/routes/api_routes.py +++ b/src/backend/fastapi_app/routes/api_routes.py @@ -1,4 +1,3 @@ -import http.client import json import logging from collections.abc import AsyncGenerator @@ -27,8 +26,6 @@ router = fastapi.APIRouter() -http.client.HTTPConnection.debuglevel = 1 - ERROR_FILTER = {"error": "Your message contains content that was flagged by the content filter."} diff --git a/src/frontend/src/components/Answer/Answer.tsx b/src/frontend/src/components/Answer/Answer.tsx index 9726c7b6..a542064c 100644 --- a/src/frontend/src/components/Answer/Answer.tsx +++ b/src/frontend/src/components/Answer/Answer.tsx @@ -34,6 +34,7 @@ export const Answer = ({ const parsedAnswer = useMemo(() => parseAnswerToHtml(messageContent, isStreaming, onCitationClicked), [answer]); const sanitizedAnswerHtml = DOMPurify.sanitize(parsedAnswer.answerHtml); + return ( diff --git a/tests/test_postgres_searcher.py b/tests/test_postgres_searcher.py index 4860e49e..fff7fdfd 100644 --- a/tests/test_postgres_searcher.py +++ b/tests/test_postgres_searcher.py @@ -1,6 +1,6 @@ import pytest -from fastapi_app.api_models import BrandFilter, ItemPublic, PriceFilter +from fastapi_app.api_models import Filter, ItemPublic from tests.data import test_data @@ -12,7 +12,7 @@ def test_postgres_build_filter_clause_without_filters(postgres_searcher): def test_postgres_build_filter_clause_with_filters(postgres_searcher): assert postgres_searcher.build_filter_clause( [ - BrandFilter(comparison_operator="=", value="AirStrider"), + Filter(column="brand", comparison_operator="=", value="AirStrider"), ] ) == ( "WHERE brand = 'AirStrider'", @@ -23,11 +23,11 @@ def test_postgres_build_filter_clause_with_filters(postgres_searcher): def test_postgres_build_filter_clause_with_filters_numeric(postgres_searcher): assert postgres_searcher.build_filter_clause( [ - PriceFilter(comparison_operator="<", value=30), + Filter(column="price", comparison_operator="<", value=30), ] ) == ( - "WHERE price < 30.0", - "AND price < 30.0", + "WHERE price < 30", + "AND price < 30", )