Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions app/backend/approaches/approach.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
260 changes: 185 additions & 75 deletions app/backend/approaches/chatreadretrieveread.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -16,6 +17,7 @@

from approaches.approach import (
Approach,
DataPoints,
ExtraInfo,
ThoughtStep,
)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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 ([email protected])"}
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)
16 changes: 5 additions & 11 deletions app/backend/approaches/prompts/chat_query_rewrite.prompty
Original file line number Diff line number Diff line change
Expand Up @@ -23,22 +23,16 @@ 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"] }}:
{{ message["content"] }}
{% endfor %}

user:
Generate search query for: {{ user_query }}
{{ user_query }}
Loading
Loading