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 5510a2f0..b760fdb2 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) @@ -53,6 +59,7 @@ def create_app(testing: bool = False): 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) diff --git a/src/backend/fastapi_app/api_models.py b/src/backend/fastapi_app/api_models.py index 446967ad..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): @@ -41,6 +42,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 +69,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,27 +90,39 @@ class RetrievalResponseDelta(BaseModel): sessionState: Optional[Any] = None -class ItemPublic(BaseModel): - id: int - type: str - brand: str - name: str - description: str - price: float - - -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 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 e83e0c41..af76229d 100644 --- a/src/backend/fastapi_app/openai_clients.py +++ b/src/backend/fastapi_app/openai_clients.py @@ -9,12 +9,12 @@ 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") 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"): @@ -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/prompts/query.txt b/src/backend/fastapi_app/prompts/query.txt index 6bbb0a23..54464bcb 100644 --- a/src/backend/fastapi_app/prompts/query.txt +++ b/src/backend/fastapi_app/prompts/query.txt @@ -1,6 +1,5 @@ -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. +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/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 fe75ea5f..3541d8c7 100644 --- a/src/backend/fastapi_app/rag_advanced.py +++ b/src/backend/fastapi_app/rag_advanced.py @@ -1,172 +1,163 @@ from collections.abc import AsyncGenerator -from typing import Any, Final, Optional, Union +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, 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 from fastapi_app.api_models import ( AIChatRoles, + BrandFilter, + ChatRequestOverrides, + Filter, + ItemPublic, Message, + PriceFilter, RAGContext, RetrievalResponse, RetrievalResponseDelta, + SearchResults, ThoughtStep, ) -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 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) - - async def generate_search_query( - 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, + 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} ) - - 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, + pydantic_chat_model = OpenAIModel( + chat_model if chat_deployment is None else chat_deployment, + provider=OpenAIProvider(openai_client=openai_chat_client), ) - - 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, + self.search_agent = Agent[ChatParams, SearchResults]( + pydantic_chat_model, + 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, + ) + 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} if self.chat_params.seed is not None else {}), + ), ) - # Retrieve relevant rows from the database with the GPT optimized query + async def search_database( + self, + ctx: RunContext[ChatParams], + search_query: str, + price_filter: Optional[PriceFilter] = None, + brand_filter: Optional[BrandFilter] = None, + ) -> SearchResults: + """ + Search PostgreSQL database for relevant products based on user query + + Args: + 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 + + Returns: + List of formatted items that match the search query and filters + """ + # Only send non-None filters + filters: list[Filter] = [] + 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, ) - - 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, + return SearchResults( + query=search_query, items=[ItemPublic.model_validate(item.to_dict()) for item in results], filters=filters ) + 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( + user_query, + 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=query_messages, - props=( - {"model": self.chat_model, "deployment": self.chat_deployment} - if self.chat_deployment - else {"model": self.chat_model} - ), + description=results.all_messages(), + props=self.model_for_thoughts, ), ThoughtStep( title="Search using generated search arguments", - description=query_text, + description=results.output.query, props={ - "top": chat_params.top, - "vector_search": chat_params.enable_vector_search, - "text_search": chat_params.enable_text_search, - "filters": filters, + "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, }, ), 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, ), ], ), @@ -174,45 +165,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 34fba44a..62bdc800 100644 --- a/src/backend/fastapi_app/rag_base.py +++ b/src/backend/fastapi_app/rag_base.py @@ -1,27 +1,28 @@ -import json 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, ChatRequestOverrides, + ItemPublic, RetrievalResponse, RetrievalResponseDelta, ThoughtStep, ) -from fastapi_app.postgres_models import Item 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()) - 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 @@ -31,7 +32,19 @@ def get_params(self, messages: list[ChatCompletionMessageParam], overrides: Chat 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,17 +61,17 @@ def get_params(self, messages: list[ChatCompletionMessageParam], overrides: Chat ) @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: + 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, - chat_params: ChatParams, - contextual_messages: list[ChatCompletionMessageParam], - results: list[Item], + items: list[ItemPublic], earlier_thoughts: list[ThoughtStep], ) -> RetrievalResponse: raise NotImplementedError @@ -66,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 79350ab7..2d41bb9d 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} if self.chat_params.seed is not None else {}), + ), + ) - 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 d7486730..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,18 +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) - - 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 - ) + 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": @@ -169,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, @@ -176,21 +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: - 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 - ) + # 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": @@ -198,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", + ) diff --git a/src/backend/pyproject.toml b/src/backend/pyproject.toml index cdadc177..7f5ac750 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-slim[openai]" ] [build-system] diff --git a/src/backend/requirements.txt b/src/backend/requirements.txt index 1549201b..bc349b03 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.3 # via # azure-identity @@ -63,8 +65,12 @@ distro==1.9.0 # via openai environs==14.1.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,7 +313,12 @@ typing-extensions==4.12.2 # pydantic-core # sqlalchemy # starlette + # typing-inspection # uvicorn +typing-inspection==0.4.0 + # via + # pydantic-ai-slim + # pydantic-graph urllib3==2.4.0 # via requests uvicorn==0.32.1 diff --git a/tests/conftest.py b/tests/conftest.py index 5bbff0f6..f3800dd3 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 @@ -7,12 +8,14 @@ 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 ( 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 +235,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 +253,61 @@ 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": + items = json.loads(last_question)["items"] + arguments = {"query": "capital of France", "items": items, "filters": []} + 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=json.dumps(arguments), + ), + ) + ], + ), + 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 " @@ -275,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 d9f9762d..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 @@ -19,56 +19,154 @@ "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" }, { - "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 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" }, { - "role": "tool", - "tool_call_id": "call_abc123", - "content": "Search results for climbing gear that can be used outside: ..." + "parts": [ + { + "content": "are there any shoes less than $50?", + "timestamp": "2025-05-07T19:02:46.977501Z", + "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\":\"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_abc456", - "type": "function", - "function": { - "arguments": "{\"search_query\":\"shoes\",\"price_filter\":{\"comparison_operator\":\"<\",\"value\":50}}", - "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_abc456", - "content": "Search results for shoes cheaper than 50: ..." + "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" }, { - "role": "user", - "content": "What is the capital of France?" + "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": { @@ -78,7 +176,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, @@ -104,12 +202,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": "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" }, { - "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..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":[{"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":{"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 ca9bc1bb..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 @@ -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": "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" }, { - "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_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 e79e5461..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":[{"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":"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..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 ItemPublic +from fastapi_app.api_models import Filter, ItemPublic 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( + [ + Filter(column="brand", 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( + [ + Filter(column="price", comparison_operator="<", value=30), + ] + ) == ( + "WHERE price < 30", + "AND price < 30", )