diff --git a/app/backend/approaches/approach.py b/app/backend/approaches/approach.py index 04a74a8818..2b0119b342 100644 --- a/app/backend/approaches/approach.py +++ b/app/backend/approaches/approach.py @@ -214,6 +214,45 @@ async def search( ) -> list[Document]: search_text = query_text if use_text_search else "" search_vectors = vectors if use_vector_search else [] + # Specialized filename: pattern support. If the query explicitly requests a filename, + # bypass normal text/vector search and filter directly on the sourcepage field. + if query_text and query_text.startswith("filename:"): + raw_filename = query_text[len("filename:") :].strip() + safe_filename = raw_filename.replace("'", "''") + filename_filter = f"sourcefile eq '{safe_filename}'" + effective_filter = f"{filter} and {filename_filter}" if filter else filename_filter + results = await self.search_client.search( + search_text="", # empty since we rely solely on filter + filter=effective_filter, + top=top, + ) + documents: list[Document] = [] + async for page in results.by_page(): + async for document in page: + documents.append( + Document( + id=document.get("id"), + content=document.get("content"), + category=document.get("category"), + sourcepage=document.get("sourcepage"), + sourcefile=document.get("sourcefile"), + oids=document.get("oids"), + groups=document.get("groups"), + captions=cast(list[QueryCaptionResult], document.get("@search.captions")), + score=document.get("@search.score"), + reranker_score=document.get("@search.reranker_score"), + images=document.get("images"), + ) + ) + qualified_documents = [ + doc + for doc in documents + if ( + (doc.score or 0) >= (minimum_search_score or 0) + and (doc.reranker_score or 0) >= (minimum_reranker_score or 0) + ) + ] + return qualified_documents if use_semantic_ranker: results = await self.search_client.search( search_text=search_text, diff --git a/app/backend/approaches/chatreadretrieveread.py b/app/backend/approaches/chatreadretrieveread.py index bc51dc107a..52f4a827b8 100644 --- a/app/backend/approaches/chatreadretrieveread.py +++ b/app/backend/approaches/chatreadretrieveread.py @@ -3,6 +3,7 @@ from collections.abc import AsyncGenerator, Awaitable from typing import Any, Optional, Union, cast +import aiohttp from azure.search.documents.agent.aio import KnowledgeAgentRetrievalClient from azure.search.documents.aio import SearchClient from azure.search.documents.models import VectorQuery @@ -16,6 +17,7 @@ from approaches.approach import ( Approach, + DataPoints, ExtraInfo, ThoughtStep, ) @@ -89,23 +91,53 @@ def __init__( self.global_blob_manager = global_blob_manager self.user_blob_manager = user_blob_manager - def get_search_query(self, chat_completion: ChatCompletion, user_query: str): - response_message = chat_completion.choices[0].message + def get_tool_details(self, chat_completion: ChatCompletion, user_query: str) -> tuple[str, dict[str, Any]]: + """Determine selected tool and return normalized arguments. + + Always returns a tuple (tool_name, args) where tool_name is one of + "search_sources", "search_by_filename", or "get_weather". The args + dict ALWAYS includes a 'search_query' key that can be used directly + for downstream retrieval (for filename, the value is prefixed with + 'filename:', for weather it is the original or inferred user query). + This normalization keeps downstream logic simple: callers can just + use args['search_query'] without additional branching. + """ + response_message = chat_completion.choices[0].message if response_message.tool_calls: for tool in response_message.tool_calls: - if tool.type != "function": + if tool.type != "function": # Skip non-function tool calls continue function = tool.function + try: + parsed = json.loads(function.arguments) if function.arguments else {} + except Exception: + parsed = {} if function.name == "search_sources": - arg = json.loads(function.arguments) - search_query = arg.get("search_query", self.NO_RESPONSE) + search_query = parsed.get("search_query", self.NO_RESPONSE) if search_query != self.NO_RESPONSE: - return search_query - elif query_text := response_message.content: - if query_text.strip() != self.NO_RESPONSE: - return query_text - return user_query + return ( + "search_sources", + {"search_query": search_query}, + ) + elif function.name == "search_by_filename": + filename = parsed.get("filename", "") + if filename: + return ( + "search_by_filename", + {"filename": filename, "search_query": f"filename:{filename}"}, + ) + elif function.name == "get_weather": + # Preserve original parsed args, but add normalized search_query + return ( + "get_weather", + parsed | {"search_query": user_query}, # fallback to original user query + ) + else: + if (query_text := response_message.content) and query_text.strip() != self.NO_RESPONSE: + return ("search_sources", {"search_query": query_text}) + # Fallback: treat original user query as search_sources + return ("search_sources", {"search_query": user_query}) def extract_followup_questions(self, content: Optional[str]): if content is None: @@ -314,72 +346,89 @@ async def run_search_approach( ), ) - query_text = self.get_search_query(chat_completion, original_user_query) - - # STEP 2: Retrieve relevant documents from the search index with the GPT optimized query - - vectors: list[VectorQuery] = [] - if use_vector_search: - if search_text_embeddings: - vectors.append(await self.compute_text_embedding(query_text)) - if search_image_embeddings: - vectors.append(await self.compute_multimodal_embedding(query_text)) - - results = await self.search( - top, - query_text, - search_index_filter, - vectors, - use_text_search, - use_vector_search, - use_semantic_ranker, - use_semantic_captions, - minimum_search_score, - minimum_reranker_score, - use_query_rewriting, - ) + tool_name, tool_args = self.get_tool_details(chat_completion, original_user_query) + + # Conditional branching based off tool_name + data_points = DataPoints([], [], []) + thoughts = [] + if tool_name == "get_weather": + # Optional: fetch weather and add as synthetic result for grounding + weather_datapoint: Optional[str] = None + lat = tool_args.get("latitude") + lon = tool_args.get("longitude") + weather_datapoint = await self.fetch_weather_noaa(float(lat), float(lon)) + # Prepend weather info so it is considered high-salience; mark citation + data_points.text.insert(0, weather_datapoint) + data_points.citations.insert(0, "Current Weather") + thoughts.append(ThoughtStep("Fetch weather data", weather_datapoint, {"location": f"{lat}, {lon}"})) + elif tool_name == "search_by_filename" or tool_name == "search_sources": + query_text = tool_args.get("search_query") + + # STEP 2: Retrieve relevant documents from the search index with the GPT optimized query + + vectors: list[VectorQuery] = [] + if use_vector_search: + if search_text_embeddings: + vectors.append(await self.compute_text_embedding(query_text)) + if search_image_embeddings: + vectors.append(await self.compute_multimodal_embedding(query_text)) + + results = await self.search( + top, + query_text, + search_index_filter, + vectors, + use_text_search, + use_vector_search, + use_semantic_ranker, + use_semantic_captions, + minimum_search_score, + minimum_reranker_score, + use_query_rewriting, + ) - # STEP 3: Generate a contextual and content specific answer using the search results and chat history - data_points = await self.get_sources_content( - results, - use_semantic_captions, - include_text_sources=send_text_sources, - download_image_sources=send_image_sources, - user_oid=auth_claims.get("oid"), - ) - extra_info = ExtraInfo( - data_points, - thoughts=[ - self.format_thought_step_for_chatcompletion( - title="Prompt to generate search query", - messages=query_messages, - overrides=overrides, - model=self.chatgpt_model, - deployment=self.chatgpt_deployment, - usage=chat_completion.usage, - reasoning_effort=self.get_lowest_reasoning_effort(self.chatgpt_model), - ), - ThoughtStep( - "Search using generated search query", - query_text, - { - "use_semantic_captions": use_semantic_captions, - "use_semantic_ranker": use_semantic_ranker, - "use_query_rewriting": use_query_rewriting, - "top": top, - "filter": search_index_filter, - "use_vector_search": use_vector_search, - "use_text_search": use_text_search, - "search_text_embeddings": search_text_embeddings, - "search_image_embeddings": search_image_embeddings, - }, - ), - ThoughtStep( - "Search results", - [result.serialize_for_results() for result in results], - ), - ], - ) + # STEP 3: Generate a contextual and content specific answer using the search results and chat history + data_points = await self.get_sources_content( + results, + use_semantic_captions, + include_text_sources=send_text_sources, + download_image_sources=send_image_sources, + user_oid=auth_claims.get("oid"), + ) + thoughts.append( + [ + self.format_thought_step_for_chatcompletion( + title="Prompt to generate search query", + messages=query_messages, + overrides=overrides, + model=self.chatgpt_model, + deployment=self.chatgpt_deployment, + usage=chat_completion.usage, + reasoning_effort=self.get_lowest_reasoning_effort(self.chatgpt_model), + ), + ThoughtStep( + "Search using generated search query", + query_text, + { + "use_semantic_captions": use_semantic_captions, + "use_semantic_ranker": use_semantic_ranker, + "use_query_rewriting": use_query_rewriting, + "top": top, + "filter": search_index_filter, + "use_vector_search": use_vector_search, + "use_text_search": use_text_search, + "search_text_embeddings": search_text_embeddings, + "search_image_embeddings": search_image_embeddings, + }, + ), + ThoughtStep( + "Search results", + [result.serialize_for_results() for result in results], + ), + ] + ) + + extra_info = ExtraInfo(data_points, thoughts=thoughts) return extra_info async def run_agentic_retrieval_approach( @@ -438,3 +487,64 @@ async def run_agentic_retrieval_approach( ], ) return extra_info + + async def fetch_weather_noaa(self, latitude: float, longitude: float) -> str: + """Retrieve a concise current weather summary from NOAA (NWS) API. + + Approach adapted from sample in Azure Samples repository. We first call + the points endpoint to discover the forecast office grid, then fetch + the latest observation. We intentionally keep the text short so it + remains an efficient grounding data point. + + Parameters + ---------- + latitude: float + Latitude in decimal degrees. + longitude: float + Longitude in decimal degrees. + + Returns + ------- + str + A short textual weather summary including temperature, wind, and + description. Falls back gracefully if any field is missing. + """ + headers = {"User-Agent": "azure-search-openai-demo/1.0 (support@example.com)"} + async with aiohttp.ClientSession(headers=headers) as session: + # Points metadata + async with session.get( + f"https://api.weather.gov/points/{latitude:.4f},{longitude:.4f}", timeout=10 + ) as resp: + if resp.status != 200: + raise RuntimeError(f"NOAA points lookup failed: {resp.status}") + points = await resp.json() + observation_url = points.get("properties", {}).get("observationStations") + if not observation_url: + raise RuntimeError("Missing observationStations in NOAA points response") + # Fetch stations list (first station is fine for a lightweight summary) + async with session.get(observation_url, timeout=10) as resp: + if resp.status != 200: + raise RuntimeError(f"NOAA stations lookup failed: {resp.status}") + stations = await resp.json() + station_id = stations.get("features", [{}])[0].get("properties", {}).get("stationIdentifier") + if not station_id: + raise RuntimeError("No stationIdentifier found") + async with session.get( + f"https://api.weather.gov/stations/{station_id}/observations/latest", timeout=10 + ) as resp: + if resp.status != 200: + raise RuntimeError(f"NOAA latest observation failed: {resp.status}") + latest = await resp.json() + props = latest.get("properties", {}) + text = props.get("textDescription") or "Unknown conditions" + temp_c = props.get("temperature", {}).get("value") + wind_speed_mps = props.get("windSpeed", {}).get("value") + rel_humidity = props.get("relativeHumidity", {}).get("value") + parts = [text] + if temp_c is not None: + parts.append(f"Temp {temp_c:.1f}C") + if wind_speed_mps is not None: + parts.append(f"Wind {wind_speed_mps:.1f}m/s") + if rel_humidity is not None: + parts.append(f"RH {rel_humidity:.0f}%") + return " | ".join(parts) diff --git a/app/backend/approaches/prompts/chat_query_rewrite.prompty b/app/backend/approaches/prompts/chat_query_rewrite.prompty index 545b3f5b8c..12fe5307a9 100644 --- a/app/backend/approaches/prompts/chat_query_rewrite.prompty +++ b/app/backend/approaches/prompts/chat_query_rewrite.prompty @@ -23,17 +23,11 @@ Do not include any special characters like '+'. 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 just the number 0. -user: -How did crypto do last year? - -assistant: -Summarize Cryptocurrency Market Dynamics from last year - -user: -What are my health plans? +You have three callable tools available (they may be invoked via function calling): +1. search_sources: Use this to generate a general keyword style search query. +2. search_by_filename: Use this ONLY when the user clearly references a specific document by its exact filename (e.g., "PerksPlus.pdf"). Provide just the filename (without extra words). If the user asks to summarize or open a specific known file by name, prefer calling search_by_filename. If they mention concepts or partial names, fall back to generating a normal keyword query with search_sources. +3. get_weather: Use this ONLY when the user explicitly asks about current weather conditions for a location for which latitude and longitude are provided or can be clearly inferred (already numeric). Return weather only via the function call; do not include weather text directly as the search query. After calling get_weather you should still generate an appropriate search query (or 0) if needed for knowledge base lookup. -assistant: -Show available health plans {% for message in past_messages %} {{ message["role"] }}: @@ -41,4 +35,4 @@ Show available health plans {% endfor %} user: -Generate search query for: {{ user_query }} +{{ user_query }} diff --git a/app/backend/approaches/prompts/chat_query_rewrite_tools.json b/app/backend/approaches/prompts/chat_query_rewrite_tools.json index cf1743483c..850e39d7e0 100644 --- a/app/backend/approaches/prompts/chat_query_rewrite_tools.json +++ b/app/backend/approaches/prompts/chat_query_rewrite_tools.json @@ -1,17 +1,57 @@ -[{ - "type": "function", - "function": { - "name": "search_sources", - "description": "Retrieve sources from the Azure AI Search index", - "parameters": { - "type": "object", - "properties": { - "search_query": { - "type": "string", - "description": "Query string to retrieve documents from azure search eg: 'Health care plan'" - } - }, - "required": ["search_query"] +[ + { + "type": "function", + "function": { + "name": "search_sources", + "description": "Retrieve sources from the Azure AI Search index", + "parameters": { + "type": "object", + "properties": { + "search_query": { + "type": "string", + "description": "Query string to retrieve documents from azure search eg: 'Health care plan'" + } + }, + "required": ["search_query"] + } + } + }, + { + "type": "function", + "function": { + "name": "search_by_filename", + "description": "Retrieve a specific filename from the Azure AI Search index", + "parameters": { + "type": "object", + "properties": { + "filename": { + "type": "string", + "description": "The filename, like 'PerksPlus.pdf'" + } + }, + "required": ["filename"] + } + } + }, + { + "type": "function", + "function": { + "name": "get_weather", + "description": "Get current weather summary for given latitude and longitude using NOAA (National Weather Service) API", + "parameters": { + "type": "object", + "properties": { + "latitude": { + "type": "number", + "description": "Latitude in decimal degrees (e.g. 37.7749)" + }, + "longitude": { + "type": "number", + "description": "Longitude in decimal degrees (e.g. -122.4194)" + } + }, + "required": ["latitude", "longitude"] + } } } -}] +]