Nagendravarma commited on
Commit Β·
a74c657
1
Parent(s): 4f262ec
Implement Redis Semantic Caching, plan-tier scoping, cognitive query normalization, and Dev Console Redis visual flow integration
Browse files- Dockerfile +4 -2
- backend/main.py +226 -66
- backend/models.py +6 -2
- config.py +9 -0
- frontend/dev_console.html +0 -0
- orchestration/orchestrator.py +460 -160
- orchestration/semantic_cache.py +382 -0
- orchestration/tools.py +117 -21
- orchestration/tracing.py +15 -0
- requirements.txt +1 -0
- retrieval/graph_retriever.py +18 -2
- retrieval/retriever.py +40 -1
Dockerfile
CHANGED
|
@@ -11,13 +11,14 @@ ENV USE_NGINX=true
|
|
| 11 |
# Disable Mem0 telemetry
|
| 12 |
ENV MEM0_TELEMETRY=false
|
| 13 |
|
| 14 |
-
# Install system dependencies (Graphviz for diagrams, Nginx for reverse proxy)
|
| 15 |
RUN apt-get update && apt-get install -y \
|
| 16 |
graphviz \
|
| 17 |
libgraphviz-dev \
|
| 18 |
pkg-config \
|
| 19 |
build-essential \
|
| 20 |
nginx \
|
|
|
|
| 21 |
&& rm -rf /var/lib/apt/lists/*
|
| 22 |
|
| 23 |
# Set working directory
|
|
@@ -36,9 +37,10 @@ RUN mkdir -p logs storage && chmod -R 777 /app
|
|
| 36 |
# Expose the port Hugging Face expects (7860)
|
| 37 |
EXPOSE 7860
|
| 38 |
|
| 39 |
-
# Run uvicorn backend + nginx reverse proxy
|
| 40 |
# Streamlit frontend is launched as a sidecar by FastAPI startup event
|
| 41 |
CMD ["sh", "-c", "\
|
| 42 |
mkdir -p /tmp/nginx_client_body /tmp/nginx_proxy /tmp/nginx_fastcgi /tmp/nginx_uwsgi /tmp/nginx_scgi && \
|
|
|
|
| 43 |
python3 -m uvicorn backend.main:app --host 127.0.0.1 --port 8000 & \
|
| 44 |
nginx -c /app/nginx.conf -g 'daemon off;'"]
|
|
|
|
| 11 |
# Disable Mem0 telemetry
|
| 12 |
ENV MEM0_TELEMETRY=false
|
| 13 |
|
| 14 |
+
# Install system dependencies (Graphviz for diagrams, Nginx for reverse proxy, Redis for caching)
|
| 15 |
RUN apt-get update && apt-get install -y \
|
| 16 |
graphviz \
|
| 17 |
libgraphviz-dev \
|
| 18 |
pkg-config \
|
| 19 |
build-essential \
|
| 20 |
nginx \
|
| 21 |
+
redis-server \
|
| 22 |
&& rm -rf /var/lib/apt/lists/*
|
| 23 |
|
| 24 |
# Set working directory
|
|
|
|
| 37 |
# Expose the port Hugging Face expects (7860)
|
| 38 |
EXPOSE 7860
|
| 39 |
|
| 40 |
+
# Run uvicorn backend + nginx reverse proxy + redis server
|
| 41 |
# Streamlit frontend is launched as a sidecar by FastAPI startup event
|
| 42 |
CMD ["sh", "-c", "\
|
| 43 |
mkdir -p /tmp/nginx_client_body /tmp/nginx_proxy /tmp/nginx_fastcgi /tmp/nginx_uwsgi /tmp/nginx_scgi && \
|
| 44 |
+
redis-server --port 6379 --daemonize yes --protected-mode no --dir /app/storage & \
|
| 45 |
python3 -m uvicorn backend.main:app --host 127.0.0.1 --port 8000 & \
|
| 46 |
nginx -c /app/nginx.conf -g 'daemon off;'"]
|
backend/main.py
CHANGED
|
@@ -28,6 +28,7 @@ from backend.models import (
|
|
| 28 |
)
|
| 29 |
from orchestration.tools import preload_retrievers
|
| 30 |
from orchestration.memory import get_all_memories
|
|
|
|
| 31 |
|
| 32 |
|
| 33 |
from backend.session_manager import manager
|
|
@@ -157,11 +158,46 @@ async def chat(request: ChatRequest):
|
|
| 157 |
if request.plan_tier and request.plan_tier.lower() != "unknown":
|
| 158 |
final_query = f"I am on the {request.plan_tier} plan. {request.query}"
|
| 159 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
result = orch.ask_detailed(final_query)
|
| 161 |
duration = time.time() - start_time
|
| 162 |
|
| 163 |
logger.info(f"Query processed in {duration:.2f}s for session {request.session_id}")
|
| 164 |
|
|
|
|
|
|
|
|
|
|
| 165 |
return ChatResponse(
|
| 166 |
session_id=request.session_id,
|
| 167 |
query=request.query,
|
|
@@ -170,7 +206,11 @@ async def chat(request: ChatRequest):
|
|
| 170 |
steps_log=result["steps_log"],
|
| 171 |
memories_used=result.get("memories_used", []),
|
| 172 |
timestamp=datetime.now().isoformat(),
|
| 173 |
-
run_id=result.get("run_id"),
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
)
|
| 175 |
except Exception as e:
|
| 176 |
logger.error(f"Error processing chat: {str(e)}")
|
|
@@ -187,13 +227,74 @@ async def chat_stream(session_id: str, query: str, plan_tier: str = "Unknown"):
|
|
| 187 |
"""
|
| 188 |
import threading
|
| 189 |
orch = manager.get_orchestrator(session_id)
|
| 190 |
-
queue = asyncio.Queue()
|
| 191 |
-
loop = asyncio.get_running_loop()
|
| 192 |
|
| 193 |
final_query = query
|
| 194 |
if plan_tier and plan_tier.lower() != "unknown":
|
| 195 |
final_query = f"I am on the {plan_tier} plan. {query}"
|
| 196 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 197 |
def run_stream():
|
| 198 |
try:
|
| 199 |
for event in orch.stream_detailed(final_query):
|
|
@@ -202,7 +303,6 @@ async def chat_stream(session_id: str, query: str, plan_tier: str = "Unknown"):
|
|
| 202 |
except Exception as e:
|
| 203 |
loop.call_soon_threadsafe(queue.put_nowait, {"type": "error", "error": e})
|
| 204 |
|
| 205 |
-
# Start the LangGraph execution in a background thread so it doesn't block the main event loop
|
| 206 |
thread = threading.Thread(target=run_stream, daemon=True)
|
| 207 |
thread.start()
|
| 208 |
|
|
@@ -221,15 +321,14 @@ async def chat_stream(session_id: str, query: str, plan_tier: str = "Unknown"):
|
|
| 221 |
prev_steps: list[str] = []
|
| 222 |
final_answer = ""
|
| 223 |
last_intent = ""
|
|
|
|
| 224 |
|
| 225 |
# ββ Stream LangGraph events βββββββββββββββββββββββββββββββ
|
| 226 |
try:
|
| 227 |
while True:
|
| 228 |
try:
|
| 229 |
-
# Wait for 2.0s for the next event, if none, send keepalive
|
| 230 |
item = await asyncio.wait_for(queue.get(), timeout=2.0)
|
| 231 |
except asyncio.TimeoutError:
|
| 232 |
-
# SSE keepalive comment to keep the connection alive through reverse proxies
|
| 233 |
yield ": keepalive\n\n"
|
| 234 |
continue
|
| 235 |
|
|
@@ -241,31 +340,32 @@ async def chat_stream(session_id: str, query: str, plan_tier: str = "Unknown"):
|
|
| 241 |
event = item["data"]
|
| 242 |
node = event["node"]
|
| 243 |
state = event["state"]
|
|
|
|
| 244 |
current_steps = state.get("steps_log", [])
|
| 245 |
-
new_steps = current_steps[len(prev_steps):]
|
| 246 |
intent = state.get("intent", last_intent)
|
| 247 |
last_intent = intent
|
| 248 |
|
| 249 |
-
# Tell the frontend the LangGraph node just started
|
| 250 |
yield emit({"type": "node_start", "node": node, "intent": intent, "msg": f"Node '{node}' executingβ¦"})
|
| 251 |
await asyncio.sleep(0.15)
|
| 252 |
|
| 253 |
-
# Emit individual sub-steps (each line from steps_log) with a small delay
|
| 254 |
for step in new_steps:
|
| 255 |
yield emit({"type": "substep", "node": node, "intent": intent, "step": step, "all_steps": current_steps})
|
| 256 |
-
await asyncio.sleep(0.05)
|
| 257 |
|
| 258 |
-
# Signal node completion
|
| 259 |
final_answer = state.get("answer", "")
|
| 260 |
yield emit({
|
| 261 |
-
"type":
|
| 262 |
-
"node":
|
| 263 |
-
"intent":
|
| 264 |
-
"steps":
|
| 265 |
-
"answer":
|
|
|
|
|
|
|
|
|
|
|
|
|
| 266 |
})
|
| 267 |
|
| 268 |
-
# Persist completed state for trace/graph endpoints
|
| 269 |
if node == "synthesize":
|
| 270 |
if len(orch.chat_history) == 0 or orch.chat_history[-1] != ("ai", final_answer):
|
| 271 |
orch.chat_history.append(("human", query))
|
|
@@ -280,6 +380,24 @@ async def chat_stream(session_id: str, query: str, plan_tier: str = "Unknown"):
|
|
| 280 |
|
| 281 |
prev_steps = current_steps
|
| 282 |
await asyncio.sleep(0.1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 283 |
except Exception as e:
|
| 284 |
logger.error(f"Error in chat_stream: {str(e)}")
|
| 285 |
yield emit({"type": "error", "msg": f"Backend Error: {str(e)}"})
|
|
@@ -376,66 +494,108 @@ async def clear_memory(session_id: str):
|
|
| 376 |
def _steps_to_graphviz(query: str, intent: str, steps: list[str]) -> io.BytesIO:
|
| 377 |
"""Generate a PNG image of the workflow using Graphviz.
|
| 378 |
Nodes: FastAPI, Orchestrator, Intent, Retrieval, Synthesis, Answer.
|
| 379 |
-
|
| 380 |
"""
|
|
|
|
| 381 |
dot = Digraph(comment='Workflow')
|
| 382 |
dot.attr(rankdir='LR')
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 410 |
# Render to PNG in memory
|
| 411 |
png_bytes = dot.pipe(format='png')
|
| 412 |
return io.BytesIO(png_bytes)
|
| 413 |
|
| 414 |
def _steps_to_mermaid(query: str, intent: str, steps: list[str]) -> str:
|
| 415 |
"""Generate a simple mermaid diagram describing the workflow.
|
| 416 |
-
|
| 417 |
-
FastAPI --> Orchestrator --> Intent --> Retrieval --> Synthesis
|
| 418 |
-
and then each step from steps will be added as a subβnode.
|
| 419 |
"""
|
|
|
|
| 420 |
lines = ["graph LR"]
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
lines.append('
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 439 |
return "\n".join(lines)
|
| 440 |
|
| 441 |
@app.get("/session/{session_id}/diagram", response_model=WorkflowDiagramResponse)
|
|
|
|
| 28 |
)
|
| 29 |
from orchestration.tools import preload_retrievers
|
| 30 |
from orchestration.memory import get_all_memories
|
| 31 |
+
from orchestration.semantic_cache import cache_manager
|
| 32 |
|
| 33 |
|
| 34 |
from backend.session_manager import manager
|
|
|
|
| 158 |
if request.plan_tier and request.plan_tier.lower() != "unknown":
|
| 159 |
final_query = f"I am on the {request.plan_tier} plan. {request.query}"
|
| 160 |
|
| 161 |
+
# Check Semantic Cache
|
| 162 |
+
cached_result = cache_manager.check(final_query, plan_tier=request.plan_tier or "Unknown")
|
| 163 |
+
if cached_result:
|
| 164 |
+
if len(orch.chat_history) == 0 or orch.chat_history[-1] != ("ai", cached_result["answer"]):
|
| 165 |
+
orch.chat_history.append(("human", request.query))
|
| 166 |
+
orch.chat_history.append(("ai", cached_result["answer"]))
|
| 167 |
+
|
| 168 |
+
# Sync orchestrator trace for downstream requests (diagram, trace endpoints)
|
| 169 |
+
orch.last_detailed_result = cached_result.copy()
|
| 170 |
+
|
| 171 |
+
# Prepend a step identifying the cache hit
|
| 172 |
+
display_steps = [
|
| 173 |
+
"β‘ Semantic Cache HIT!",
|
| 174 |
+
f"Matched with cached query: '{cached_result.get('matched_query', '')}'",
|
| 175 |
+
f"Similarity Score: {cached_result.get('cache_similarity', 0)}%"
|
| 176 |
+
] + cached_result.get("steps_log", [])
|
| 177 |
+
|
| 178 |
+
return ChatResponse(
|
| 179 |
+
session_id=request.session_id,
|
| 180 |
+
query=request.query,
|
| 181 |
+
answer=cached_result["answer"],
|
| 182 |
+
intent=cached_result["intent"],
|
| 183 |
+
steps_log=display_steps,
|
| 184 |
+
memories_used=cached_result.get("memories_used", []),
|
| 185 |
+
timestamp=datetime.now().isoformat(),
|
| 186 |
+
run_id=cached_result.get("run_id"),
|
| 187 |
+
confidence=cached_result.get("confidence", "HIGH"),
|
| 188 |
+
confidence_reason=cached_result.get("confidence_reason", "") + " (Cached)",
|
| 189 |
+
blocked=cached_result.get("blocked", False),
|
| 190 |
+
sub_questions=cached_result.get("sub_questions", []),
|
| 191 |
+
)
|
| 192 |
+
|
| 193 |
result = orch.ask_detailed(final_query)
|
| 194 |
duration = time.time() - start_time
|
| 195 |
|
| 196 |
logger.info(f"Query processed in {duration:.2f}s for session {request.session_id}")
|
| 197 |
|
| 198 |
+
# Save to semantic cache
|
| 199 |
+
cache_manager.store(final_query, result, plan_tier=request.plan_tier or "Unknown")
|
| 200 |
+
|
| 201 |
return ChatResponse(
|
| 202 |
session_id=request.session_id,
|
| 203 |
query=request.query,
|
|
|
|
| 206 |
steps_log=result["steps_log"],
|
| 207 |
memories_used=result.get("memories_used", []),
|
| 208 |
timestamp=datetime.now().isoformat(),
|
| 209 |
+
run_id=result.get("run_id"),
|
| 210 |
+
confidence=result.get("confidence", ""),
|
| 211 |
+
confidence_reason=result.get("confidence_reason", ""),
|
| 212 |
+
blocked=result.get("blocked", False),
|
| 213 |
+
sub_questions=result.get("sub_questions", []),
|
| 214 |
)
|
| 215 |
except Exception as e:
|
| 216 |
logger.error(f"Error processing chat: {str(e)}")
|
|
|
|
| 227 |
"""
|
| 228 |
import threading
|
| 229 |
orch = manager.get_orchestrator(session_id)
|
|
|
|
|
|
|
| 230 |
|
| 231 |
final_query = query
|
| 232 |
if plan_tier and plan_tier.lower() != "unknown":
|
| 233 |
final_query = f"I am on the {plan_tier} plan. {query}"
|
| 234 |
|
| 235 |
+
# Check Semantic Cache
|
| 236 |
+
cached_result = cache_manager.check(final_query, plan_tier=plan_tier)
|
| 237 |
+
if cached_result:
|
| 238 |
+
# Sync orchestrator history & trace
|
| 239 |
+
if len(orch.chat_history) == 0 or orch.chat_history[-1] != ("ai", cached_result["answer"]):
|
| 240 |
+
orch.chat_history.append(("human", query))
|
| 241 |
+
orch.chat_history.append(("ai", cached_result["answer"]))
|
| 242 |
+
orch.last_detailed_result = cached_result.copy()
|
| 243 |
+
|
| 244 |
+
async def cache_hit_event_generator():
|
| 245 |
+
def emit(data: dict) -> str:
|
| 246 |
+
return f"data: {json.dumps(data)}\n\n"
|
| 247 |
+
|
| 248 |
+
# ββ Startup handshake events ββββββββββββββββββββββββββββββ
|
| 249 |
+
yield emit({"type": "node_start", "node": "user", "msg": query})
|
| 250 |
+
await asyncio.sleep(0.15)
|
| 251 |
+
yield emit({"type": "node_start", "node": "fastapi", "msg": "POST /chat/stream received"})
|
| 252 |
+
await asyncio.sleep(0.15)
|
| 253 |
+
yield emit({"type": "node_start", "node": "semantic_cache", "msg": "Checking semantic cacheβ¦"})
|
| 254 |
+
await asyncio.sleep(0.2)
|
| 255 |
+
|
| 256 |
+
sim_score = cached_result.get('cache_similarity', 0.0)
|
| 257 |
+
matched_q = cached_result.get('matched_query', '')
|
| 258 |
+
|
| 259 |
+
steps = [
|
| 260 |
+
"β‘ Semantic Cache HIT!",
|
| 261 |
+
f"Matched cached query: '{matched_q}'",
|
| 262 |
+
f"Similarity Score: {sim_score}%",
|
| 263 |
+
"Bypassing intent classifier, hybrid retrievers, and synthesis LLM.",
|
| 264 |
+
"Retrieving cached answer payload from Redis."
|
| 265 |
+
]
|
| 266 |
+
|
| 267 |
+
for step in steps:
|
| 268 |
+
yield emit({
|
| 269 |
+
"type": "substep",
|
| 270 |
+
"node": "semantic_cache",
|
| 271 |
+
"intent": cached_result.get("intent", "POLICY_QUESTION"),
|
| 272 |
+
"step": step,
|
| 273 |
+
"all_steps": steps
|
| 274 |
+
})
|
| 275 |
+
await asyncio.sleep(0.08)
|
| 276 |
+
|
| 277 |
+
# Signal cache node completion
|
| 278 |
+
yield emit({
|
| 279 |
+
"type": "node_done",
|
| 280 |
+
"node": "semantic_cache",
|
| 281 |
+
"intent": cached_result.get("intent", "POLICY_QUESTION"),
|
| 282 |
+
"steps": steps + cached_result.get("steps_log", []),
|
| 283 |
+
"answer": cached_result["answer"],
|
| 284 |
+
"confidence": cached_result.get("confidence", "HIGH"),
|
| 285 |
+
"confidence_reason": cached_result.get("confidence_reason", "") + " (Cached)",
|
| 286 |
+
"blocked": cached_result.get("blocked", False),
|
| 287 |
+
"sub_questions": cached_result.get("sub_questions", []),
|
| 288 |
+
})
|
| 289 |
+
await asyncio.sleep(0.1)
|
| 290 |
+
yield "data: [DONE]\n\n"
|
| 291 |
+
|
| 292 |
+
return StreamingResponse(cache_hit_event_generator(), media_type="text/event-stream")
|
| 293 |
+
|
| 294 |
+
# Cache Miss: run full LangGraph pipeline
|
| 295 |
+
queue = asyncio.Queue()
|
| 296 |
+
loop = asyncio.get_running_loop()
|
| 297 |
+
|
| 298 |
def run_stream():
|
| 299 |
try:
|
| 300 |
for event in orch.stream_detailed(final_query):
|
|
|
|
| 303 |
except Exception as e:
|
| 304 |
loop.call_soon_threadsafe(queue.put_nowait, {"type": "error", "error": e})
|
| 305 |
|
|
|
|
| 306 |
thread = threading.Thread(target=run_stream, daemon=True)
|
| 307 |
thread.start()
|
| 308 |
|
|
|
|
| 321 |
prev_steps: list[str] = []
|
| 322 |
final_answer = ""
|
| 323 |
last_intent = ""
|
| 324 |
+
last_state = None
|
| 325 |
|
| 326 |
# ββ Stream LangGraph events βββββββββββββββββββββββββββββββ
|
| 327 |
try:
|
| 328 |
while True:
|
| 329 |
try:
|
|
|
|
| 330 |
item = await asyncio.wait_for(queue.get(), timeout=2.0)
|
| 331 |
except asyncio.TimeoutError:
|
|
|
|
| 332 |
yield ": keepalive\n\n"
|
| 333 |
continue
|
| 334 |
|
|
|
|
| 340 |
event = item["data"]
|
| 341 |
node = event["node"]
|
| 342 |
state = event["state"]
|
| 343 |
+
last_state = state
|
| 344 |
current_steps = state.get("steps_log", [])
|
| 345 |
+
new_steps = current_steps[len(prev_steps):]
|
| 346 |
intent = state.get("intent", last_intent)
|
| 347 |
last_intent = intent
|
| 348 |
|
|
|
|
| 349 |
yield emit({"type": "node_start", "node": node, "intent": intent, "msg": f"Node '{node}' executingβ¦"})
|
| 350 |
await asyncio.sleep(0.15)
|
| 351 |
|
|
|
|
| 352 |
for step in new_steps:
|
| 353 |
yield emit({"type": "substep", "node": node, "intent": intent, "step": step, "all_steps": current_steps})
|
| 354 |
+
await asyncio.sleep(0.05)
|
| 355 |
|
|
|
|
| 356 |
final_answer = state.get("answer", "")
|
| 357 |
yield emit({
|
| 358 |
+
"type": "node_done",
|
| 359 |
+
"node": node,
|
| 360 |
+
"intent": intent,
|
| 361 |
+
"steps": current_steps,
|
| 362 |
+
"answer": final_answer,
|
| 363 |
+
"confidence": state.get("confidence", ""),
|
| 364 |
+
"confidence_reason": state.get("confidence_reason", ""),
|
| 365 |
+
"blocked": state.get("blocked", False),
|
| 366 |
+
"sub_questions": state.get("sub_questions", []),
|
| 367 |
})
|
| 368 |
|
|
|
|
| 369 |
if node == "synthesize":
|
| 370 |
if len(orch.chat_history) == 0 or orch.chat_history[-1] != ("ai", final_answer):
|
| 371 |
orch.chat_history.append(("human", query))
|
|
|
|
| 380 |
|
| 381 |
prev_steps = current_steps
|
| 382 |
await asyncio.sleep(0.1)
|
| 383 |
+
|
| 384 |
+
# Stream finished successfully, store result in cache
|
| 385 |
+
if last_state:
|
| 386 |
+
cache_payload = {
|
| 387 |
+
"query": query,
|
| 388 |
+
"answer": last_state.get("answer", ""),
|
| 389 |
+
"intent": last_state.get("intent", "POLICY_QUESTION"),
|
| 390 |
+
"steps_log": last_state.get("steps_log", []),
|
| 391 |
+
"retrieved_context": last_state.get("retrieved_context", ""),
|
| 392 |
+
"memories_used": last_state.get("memories_used", []),
|
| 393 |
+
"run_id": orch.last_detailed_result.get("run_id", "") if orch.last_detailed_result else "",
|
| 394 |
+
"blocked": last_state.get("blocked", False),
|
| 395 |
+
"confidence": last_state.get("confidence", ""),
|
| 396 |
+
"confidence_reason": last_state.get("confidence_reason", ""),
|
| 397 |
+
"sub_questions": last_state.get("sub_questions", []),
|
| 398 |
+
}
|
| 399 |
+
cache_manager.store(final_query, cache_payload, plan_tier=plan_tier)
|
| 400 |
+
|
| 401 |
except Exception as e:
|
| 402 |
logger.error(f"Error in chat_stream: {str(e)}")
|
| 403 |
yield emit({"type": "error", "msg": f"Backend Error: {str(e)}"})
|
|
|
|
| 494 |
def _steps_to_graphviz(query: str, intent: str, steps: list[str]) -> io.BytesIO:
|
| 495 |
"""Generate a PNG image of the workflow using Graphviz.
|
| 496 |
Nodes: FastAPI, Orchestrator, Intent, Retrieval, Synthesis, Answer.
|
| 497 |
+
If cached, bypasses all retrieval nodes and shows a direct link via Redis Cache.
|
| 498 |
"""
|
| 499 |
+
is_cached = any("Cache HIT" in s for s in steps)
|
| 500 |
dot = Digraph(comment='Workflow')
|
| 501 |
dot.attr(rankdir='LR')
|
| 502 |
+
|
| 503 |
+
if is_cached:
|
| 504 |
+
# Pruned cache hit nodes
|
| 505 |
+
dot.node('A', 'FastAPI Endpoint')
|
| 506 |
+
dot.node('R', 'Redis Semantic Cache')
|
| 507 |
+
dot.node('F', 'Answer')
|
| 508 |
+
# Edges
|
| 509 |
+
dot.edge('A', 'R')
|
| 510 |
+
dot.edge('R', 'F')
|
| 511 |
+
|
| 512 |
+
# Detailed steps subgraph
|
| 513 |
+
if steps:
|
| 514 |
+
with dot.subgraph(name='cluster_details') as c:
|
| 515 |
+
c.attr(label='Cache Hit Details')
|
| 516 |
+
prev = None
|
| 517 |
+
for i, s in enumerate(steps, start=1):
|
| 518 |
+
node_id = f'S{i}'
|
| 519 |
+
safe = s.replace('"', '\\"')
|
| 520 |
+
c.node(node_id, safe, shape='box')
|
| 521 |
+
if i == 1:
|
| 522 |
+
dot.edge('R', node_id)
|
| 523 |
+
else:
|
| 524 |
+
c.edge(prev, node_id)
|
| 525 |
+
prev = node_id
|
| 526 |
+
else:
|
| 527 |
+
# Core nodes
|
| 528 |
+
dot.node('A', 'FastAPI Endpoint')
|
| 529 |
+
dot.node('B', 'Orchestrator')
|
| 530 |
+
dot.node('C', 'Intent Classification')
|
| 531 |
+
dot.node('D', 'Retrieval Pipeline')
|
| 532 |
+
dot.node('E', 'Synthesis Agent')
|
| 533 |
+
dot.node('F', 'Answer')
|
| 534 |
+
# Edges
|
| 535 |
+
dot.edge('A', 'B')
|
| 536 |
+
dot.edge('B', 'C')
|
| 537 |
+
dot.edge('C', 'D')
|
| 538 |
+
dot.edge('D', 'E')
|
| 539 |
+
dot.edge('E', 'F')
|
| 540 |
+
|
| 541 |
+
# Detailed steps subgraph
|
| 542 |
+
if steps:
|
| 543 |
+
with dot.subgraph(name='cluster_details') as c:
|
| 544 |
+
c.attr(label='Retrieval Details')
|
| 545 |
+
prev = None
|
| 546 |
+
for i, s in enumerate(steps, start=1):
|
| 547 |
+
node_id = f'S{i}'
|
| 548 |
+
safe = s.replace('"', '\\"')
|
| 549 |
+
c.node(node_id, safe, shape='box')
|
| 550 |
+
if i == 1:
|
| 551 |
+
dot.edge('D', node_id)
|
| 552 |
+
else:
|
| 553 |
+
c.edge(prev, node_id)
|
| 554 |
+
prev = node_id
|
| 555 |
+
|
| 556 |
# Render to PNG in memory
|
| 557 |
png_bytes = dot.pipe(format='png')
|
| 558 |
return io.BytesIO(png_bytes)
|
| 559 |
|
| 560 |
def _steps_to_mermaid(query: str, intent: str, steps: list[str]) -> str:
|
| 561 |
"""Generate a simple mermaid diagram describing the workflow.
|
| 562 |
+
If cached, bypasses all retrieval nodes and shows a direct link via Redis Cache.
|
|
|
|
|
|
|
| 563 |
"""
|
| 564 |
+
is_cached = any("Cache HIT" in s for s in steps)
|
| 565 |
lines = ["graph LR"]
|
| 566 |
+
|
| 567 |
+
if is_cached:
|
| 568 |
+
lines.append(' A[FastAPI Endpoint] --> R[Redis Semantic Cache]')
|
| 569 |
+
lines.append(' R --> F[Answer]')
|
| 570 |
+
if steps:
|
| 571 |
+
lines.append(' subgraph Details [Cache Hit Details]')
|
| 572 |
+
for i, s in enumerate(steps, start=1):
|
| 573 |
+
safe = s.replace('"', '\\"')
|
| 574 |
+
node_id = f"S{i}"
|
| 575 |
+
lines.append(f' {node_id}["{safe}"]')
|
| 576 |
+
if i == 1:
|
| 577 |
+
lines.append(f' R --> {node_id}')
|
| 578 |
+
else:
|
| 579 |
+
lines.append(f' S{i-1} --> {node_id}')
|
| 580 |
+
lines.append(' end')
|
| 581 |
+
else:
|
| 582 |
+
lines.append(' A[FastAPI Endpoint] --> B[Orchestrator]')
|
| 583 |
+
lines.append(' B --> C[Intent Classification]')
|
| 584 |
+
lines.append(' C --> D[Retrieval Pipeline]')
|
| 585 |
+
lines.append(' D --> E[Synthesis Agent]')
|
| 586 |
+
lines.append(' E --> F[Answer]')
|
| 587 |
+
if steps:
|
| 588 |
+
lines.append(' subgraph Details [Retrieval Details]')
|
| 589 |
+
for i, s in enumerate(steps, start=1):
|
| 590 |
+
safe = s.replace('"', '\\"')
|
| 591 |
+
node_id = f"S{i}"
|
| 592 |
+
lines.append(f' {node_id}["{safe}"]')
|
| 593 |
+
if i == 1:
|
| 594 |
+
lines.append(f' D --> {node_id}')
|
| 595 |
+
else:
|
| 596 |
+
lines.append(f' S{i-1} --> {node_id}')
|
| 597 |
+
lines.append(' end')
|
| 598 |
+
|
| 599 |
return "\n".join(lines)
|
| 600 |
|
| 601 |
@app.get("/session/{session_id}/diagram", response_model=WorkflowDiagramResponse)
|
backend/models.py
CHANGED
|
@@ -31,11 +31,15 @@ class ChatResponse(BaseModel):
|
|
| 31 |
session_id: str
|
| 32 |
query: str
|
| 33 |
answer: str
|
| 34 |
-
intent: str
|
| 35 |
steps_log: List[str]
|
| 36 |
memories_used: List[str] = [] # Facts Mem0 extracted and stored from this turn
|
| 37 |
timestamp: str
|
| 38 |
-
run_id: Optional[str] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
|
| 40 |
|
| 41 |
class SessionHistoryResponse(BaseModel):
|
|
|
|
| 31 |
session_id: str
|
| 32 |
query: str
|
| 33 |
answer: str
|
| 34 |
+
intent: str # SIMPLE_LOOKUP | POLICY_QUESTION | MULTI_HOP | COMPARISON
|
| 35 |
steps_log: List[str]
|
| 36 |
memories_used: List[str] = [] # Facts Mem0 extracted and stored from this turn
|
| 37 |
timestamp: str
|
| 38 |
+
run_id: Optional[str] = None # LangSmith trace run ID (None if tracing disabled)
|
| 39 |
+
confidence: Optional[str] = None # HIGH | MEDIUM | LOW | BLOCKED (new confidence scorer)
|
| 40 |
+
confidence_reason: Optional[str] = None # Human-readable confidence explanation
|
| 41 |
+
blocked: bool = False # True if query_guard blocked the query
|
| 42 |
+
sub_questions: List[str] = [] # Sub-questions from query_decomposer (MULTI_HOP)
|
| 43 |
|
| 44 |
|
| 45 |
class SessionHistoryResponse(BaseModel):
|
config.py
CHANGED
|
@@ -111,3 +111,12 @@ GUIDELINES:
|
|
| 111 |
- Use 'policy_search' for general coverage rules, FAQs, and procedures.
|
| 112 |
- Use 'relational_search' for specific data like copays for a drug, provider lookups, or plan-specific relational details.
|
| 113 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
- Use 'policy_search' for general coverage rules, FAQs, and procedures.
|
| 112 |
- Use 'relational_search' for specific data like copays for a drug, provider lookups, or plan-specific relational details.
|
| 113 |
"""
|
| 114 |
+
|
| 115 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββ
|
| 116 |
+
# Redis Semantic Cache Settings
|
| 117 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββ
|
| 118 |
+
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379")
|
| 119 |
+
# Cosine similarity threshold for cache hits (1 - cosine_distance).
|
| 120 |
+
# 0.85 is a standard threshold for text-embedding-3-small semantic similarity.
|
| 121 |
+
SEMANTIC_CACHE_THRESHOLD = float(os.getenv("SEMANTIC_CACHE_THRESHOLD", "0.85"))
|
| 122 |
+
SEMANTIC_CACHE_COLLECTION = "semantic_cache"
|
frontend/dev_console.html
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
orchestration/orchestrator.py
CHANGED
|
@@ -1,49 +1,58 @@
|
|
| 1 |
"""
|
| 2 |
Health Insurance AI Copilot β LangGraph Sequential Chain Orchestrator.
|
| 3 |
|
| 4 |
-
Architecture (
|
| 5 |
|
| 6 |
START
|
| 7 |
β
|
| 8 |
βΌ
|
| 9 |
-
[1.
|
|
|
|
|
|
|
|
|
|
| 10 |
β
|
| 11 |
βΌ
|
| 12 |
-
[
|
| 13 |
Uses GPT-4o-mini to classify the query into one of four intents:
|
| 14 |
SIMPLE_LOOKUP | POLICY_QUESTION | MULTI_HOP | COMPARISON
|
| 15 |
β
|
| 16 |
βΌ
|
| 17 |
-
[
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
β
|
| 24 |
βΌ
|
| 25 |
-
[
|
|
|
|
|
|
|
|
|
|
| 26 |
Uses GPT-4o to generate a cited, safety-compliant final answer
|
| 27 |
β
|
| 28 |
βΌ
|
| 29 |
-
[
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
β
|
| 31 |
βΌ
|
| 32 |
END
|
| 33 |
"""
|
| 34 |
|
| 35 |
import os
|
|
|
|
| 36 |
import sys
|
| 37 |
-
from typing import TypedDict, List
|
| 38 |
import concurrent.futures
|
| 39 |
import contextvars
|
| 40 |
|
| 41 |
-
# Ensure project root is on sys.path so `config` and sibling packages resolve
|
| 42 |
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 43 |
|
| 44 |
-
# ββ Load .env FIRST β before any LangSmith/LangChain imports βββββββββββββββββ
|
| 45 |
-
# LANGCHAIN_TRACING_V2 must be in os.environ before langsmith is imported,
|
| 46 |
-
# otherwise the SDK and our _TRACING_ENABLED flag will always read False.
|
| 47 |
from dotenv import load_dotenv
|
| 48 |
load_dotenv()
|
| 49 |
|
|
@@ -52,12 +61,10 @@ from langchain_core.messages import HumanMessage, SystemMessage
|
|
| 52 |
from langgraph.graph import StateGraph, END
|
| 53 |
from rich.console import Console
|
| 54 |
|
| 55 |
-
# LangSmith observability β graceful no-ops if LANGCHAIN_TRACING_V2 is not set
|
| 56 |
try:
|
| 57 |
from langsmith import traceable as _langsmith_traceable
|
| 58 |
_LANGSMITH_AVAILABLE = True
|
| 59 |
except ImportError:
|
| 60 |
-
# If langsmith is not installed, create a passthrough decorator
|
| 61 |
def _langsmith_traceable(*args, **kwargs): # type: ignore[misc]
|
| 62 |
def _decorator(fn):
|
| 63 |
return fn
|
|
@@ -65,7 +72,6 @@ except ImportError:
|
|
| 65 |
_LANGSMITH_AVAILABLE = False
|
| 66 |
|
| 67 |
from orchestration.langsmith_tracing import tag_current_run, get_run_id
|
| 68 |
-
|
| 69 |
from config import (
|
| 70 |
LLM_MODEL,
|
| 71 |
CLASSIFIER_LLM_MODEL,
|
|
@@ -80,23 +86,29 @@ console = Console()
|
|
| 80 |
|
| 81 |
|
| 82 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 83 |
-
# 1. STATE
|
| 84 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 85 |
|
| 86 |
class AgentState(TypedDict):
|
| 87 |
-
"""
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
|
| 101 |
|
| 102 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
@@ -104,14 +116,13 @@ class AgentState(TypedDict):
|
|
| 104 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 105 |
|
| 106 |
def _classifier_llm() -> ChatOpenAI:
|
| 107 |
-
"""Fast, cheap model for intent classification."""
|
| 108 |
return ChatOpenAI(
|
| 109 |
model=CLASSIFIER_LLM_MODEL,
|
| 110 |
temperature=LLM_TEMPERATURE,
|
| 111 |
openai_api_key=os.getenv("OPENAI_API_KEY"),
|
| 112 |
)
|
| 113 |
|
| 114 |
-
|
| 115 |
def _synthesis_llm() -> ChatOpenAI:
|
| 116 |
"""High-accuracy model for final answer synthesis."""
|
| 117 |
return ChatOpenAI(
|
|
@@ -122,30 +133,31 @@ def _synthesis_llm() -> ChatOpenAI:
|
|
| 122 |
|
| 123 |
|
| 124 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 125 |
-
# 3. NODE
|
| 126 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 127 |
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
|
|
|
|
|
|
|
|
|
| 139 |
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
# 4. NODE 2 β classify_intent
|
| 146 |
-
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 147 |
|
| 148 |
-
_INTENT_SYSTEM = """You are a query intent classifier for a Health Insurance AI assistant.
|
| 149 |
Classify the user query into EXACTLY ONE of these four categories:
|
| 150 |
|
| 151 |
SIMPLE_LOOKUP β Quick single-fact lookups.
|
|
@@ -169,13 +181,6 @@ Classify the user query into EXACTLY ONE of these four categories:
|
|
| 169 |
|
| 170 |
Respond with ONLY the category name β no explanation, no punctuation, just the word."""
|
| 171 |
|
| 172 |
-
|
| 173 |
-
def classify_intent(state: AgentState) -> AgentState:
|
| 174 |
-
"""NODE 1: Intent Classification."""
|
| 175 |
-
llm = _classifier_llm()
|
| 176 |
-
query = state["query"]
|
| 177 |
-
log = list(state.get("steps_log", []))
|
| 178 |
-
|
| 179 |
response = llm.invoke([
|
| 180 |
SystemMessage(content=_INTENT_SYSTEM),
|
| 181 |
HumanMessage(content=query),
|
|
@@ -189,38 +194,38 @@ def classify_intent(state: AgentState) -> AgentState:
|
|
| 189 |
return {**state, "intent": intent, "steps_log": log}
|
| 190 |
|
| 191 |
|
| 192 |
-
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 193 |
-
# 4. NODE 2 β retrieve
|
| 194 |
-
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 195 |
-
|
| 196 |
def retrieve(state: AgentState) -> AgentState:
|
| 197 |
-
"""NODE
|
| 198 |
-
query
|
| 199 |
-
intent
|
| 200 |
-
|
|
|
|
|
|
|
| 201 |
parts: list[str] = []
|
| 202 |
|
| 203 |
-
# Initialize context-aware trace log for this node execution
|
| 204 |
internal_logs = []
|
| 205 |
token = trace_log.set(internal_logs)
|
| 206 |
|
| 207 |
def _run_tool(tool_func, kwargs):
|
| 208 |
return contextvars.copy_context().run(tool_func, kwargs)
|
| 209 |
|
| 210 |
-
|
|
|
|
|
|
|
|
|
|
| 211 |
if intent == "SIMPLE_LOOKUP":
|
| 212 |
log.append("π [SIMPLE_LOOKUP] Executing Graph & Hybrid retrieval concurrently")
|
| 213 |
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:
|
| 214 |
fut_g = executor.submit(_run_tool, relational_search.invoke, {"query": query})
|
| 215 |
fut_p = executor.submit(_run_tool, policy_search.invoke, {"query": query})
|
| 216 |
-
|
| 217 |
graph_ctx = fut_g.result()
|
| 218 |
if graph_ctx and "No structured" not in graph_ctx:
|
| 219 |
parts.append(f"[STRUCTURED GRAPH FACTS]\n{graph_ctx}")
|
| 220 |
log.append("πΈοΈ Graph entity lookup completed")
|
| 221 |
else:
|
| 222 |
log.append("πΈοΈ Graph entity lookup β no structured results")
|
| 223 |
-
|
| 224 |
policy_ctx = fut_p.result()
|
| 225 |
if policy_ctx and "No relevant" not in policy_ctx:
|
| 226 |
parts.append(f"[POLICY DOCUMENTS]\n{policy_ctx}")
|
|
@@ -228,35 +233,39 @@ def retrieve(state: AgentState) -> AgentState:
|
|
| 228 |
else:
|
| 229 |
log.append("π Hybrid policy retrieval β no relevant results")
|
| 230 |
|
| 231 |
-
# ββ POLICY_QUESTION ββββββββββββββββββββββββββββββββββββββββ
|
| 232 |
elif intent == "POLICY_QUESTION":
|
| 233 |
log.append("π [POLICY_QUESTION] Full hybrid document retrieval")
|
| 234 |
policy_ctx = policy_search.invoke({"query": query})
|
| 235 |
if policy_ctx and "No relevant" not in policy_ctx:
|
| 236 |
parts.append(f"[POLICY DOCUMENTS]\n{policy_ctx}")
|
| 237 |
|
| 238 |
-
# ββ MULTI_HOP ββββββββββββββββββββββββββββββββββββββββββββββ
|
| 239 |
elif intent == "MULTI_HOP":
|
| 240 |
-
|
|
|
|
|
|
|
|
|
|
| 241 |
with concurrent.futures.ThreadPoolExecutor(max_workers=3) as executor:
|
|
|
|
| 242 |
fut_g = executor.submit(_run_tool, relational_search.invoke, {"query": query})
|
| 243 |
fut_p = executor.submit(_run_tool, policy_search.invoke, {"query": query})
|
| 244 |
fut_a = executor.submit(_run_tool, prior_auth_search.invoke, {"query": query})
|
| 245 |
-
|
| 246 |
graph_ctx = fut_g.result()
|
| 247 |
if graph_ctx and "No structured" not in graph_ctx:
|
| 248 |
parts.append(f"[STRUCTURED GRAPH FACTS]\n{graph_ctx}")
|
| 249 |
log.append("πΈοΈ Graph entity lookup completed")
|
| 250 |
else:
|
| 251 |
log.append("πΈοΈ Graph entity lookup β no structured results")
|
| 252 |
-
|
| 253 |
policy_ctx = fut_p.result()
|
| 254 |
if policy_ctx and "No relevant" not in policy_ctx:
|
| 255 |
parts.append(f"[POLICY DOCUMENTS]\n{policy_ctx}")
|
| 256 |
log.append("π Hybrid policy retrieval completed")
|
| 257 |
else:
|
| 258 |
log.append("π Hybrid policy retrieval β no relevant results")
|
| 259 |
-
|
| 260 |
auth_ctx = fut_a.result()
|
| 261 |
if auth_ctx and "No prior authorization" not in auth_ctx:
|
| 262 |
parts.append(f"[PRIOR AUTHORIZATION RULES]\n{auth_ctx}")
|
|
@@ -264,9 +273,18 @@ def retrieve(state: AgentState) -> AgentState:
|
|
| 264 |
else:
|
| 265 |
log.append("π Prior authorization β no relevant rules found")
|
| 266 |
|
| 267 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 268 |
elif intent == "COMPARISON":
|
| 269 |
-
log.append(
|
| 270 |
with concurrent.futures.ThreadPoolExecutor(max_workers=3) as executor:
|
| 271 |
futures = {
|
| 272 |
tier: executor.submit(_run_tool, plan_comparison_search.invoke, {"query": query, "tier": tier})
|
|
@@ -280,25 +298,18 @@ def retrieve(state: AgentState) -> AgentState:
|
|
| 280 |
else:
|
| 281 |
log.append(f"β οΈ [{tier} Tier] No relevant context found")
|
| 282 |
|
| 283 |
-
separator
|
| 284 |
full_context = separator.join(parts) if parts else "No relevant context found."
|
| 285 |
-
|
| 286 |
-
# Capture any internal logs (like Multi-Query variants)
|
| 287 |
for l in internal_logs:
|
| 288 |
log.append(l)
|
| 289 |
-
|
| 290 |
log.append(f"β
Retrieved {len(parts)} context section(s)")
|
| 291 |
-
|
| 292 |
-
# Cleanup context
|
| 293 |
trace_log.reset(token)
|
| 294 |
|
| 295 |
return {**state, "retrieved_context": full_context, "steps_log": log}
|
| 296 |
|
| 297 |
|
| 298 |
-
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 299 |
-
# 6. NODE 4 β synthesize
|
| 300 |
-
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 301 |
-
|
| 302 |
_SYNTHESIS_TEMPLATE = """{system_prompt}
|
| 303 |
|
| 304 |
βββ PAST USER MEMORIES (from previous sessions) ββββββββββββββ
|
|
@@ -314,14 +325,14 @@ _SYNTHESIS_TEMPLATE = """{system_prompt}
|
|
| 314 |
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 315 |
|
| 316 |
Using ONLY the retrieved context above, answer the user's question.
|
| 317 |
-
IMPORTANT: You MUST also follow any user preferences
|
| 318 |
-
If the user asks for entities with multiple criteria
|
| 319 |
-
Always cite the source file and page number for every fact
|
| 320 |
If the context does not contain enough information, say so explicitly β do NOT guess."""
|
| 321 |
|
| 322 |
|
| 323 |
def synthesize(state: AgentState) -> AgentState:
|
| 324 |
-
"""NODE
|
| 325 |
llm = _synthesis_llm()
|
| 326 |
query = state["query"]
|
| 327 |
log = list(state.get("steps_log", []))
|
|
@@ -350,42 +361,110 @@ def synthesize(state: AgentState) -> AgentState:
|
|
| 350 |
|
| 351 |
|
| 352 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 353 |
-
#
|
| 354 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 355 |
|
| 356 |
class Orchestrator:
|
| 357 |
def __init__(self, user_id: str = "default", mem=None):
|
| 358 |
-
"""
|
| 359 |
-
Args:
|
| 360 |
-
user_id: The session ID β used for logging/tracing.
|
| 361 |
-
mem: A pre-created Mem0 Memory instance (in-memory, per-session).
|
| 362 |
-
Created by SessionManager alongside this Orchestrator.
|
| 363 |
-
Pass None to disable memory features.
|
| 364 |
-
"""
|
| 365 |
if not os.getenv("OPENAI_API_KEY"):
|
| 366 |
raise ValueError("OPENAI_API_KEY not found in environment.")
|
| 367 |
-
self._mem = mem
|
| 368 |
self.user_id: str = user_id
|
| 369 |
self.chat_history: List[tuple] = []
|
| 370 |
self.last_detailed_result: dict = {}
|
| 371 |
|
| 372 |
-
# Bind memory helpers to this session's Memory instance so nodes can call them
|
| 373 |
self._search_memories = lambda query: search_memories(self._mem, query)
|
| 374 |
self._add_memory = lambda q, a: add_memory(self._mem, q, a)
|
| 375 |
|
| 376 |
self.graph: any = self._build_graph()
|
| 377 |
|
| 378 |
-
# ββ Build graph with closures over self._mem ββββββββββββββββββββββββββββββ
|
| 379 |
-
|
| 380 |
def _build_graph(self):
|
| 381 |
-
"""Build the LangGraph
|
| 382 |
_search = self._search_memories
|
| 383 |
_add = self._add_memory
|
| 384 |
|
| 385 |
-
|
| 386 |
-
|
|
|
|
| 387 |
query = state["query"]
|
| 388 |
log = list(state.get("steps_log", []))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 389 |
memories = _search(query)
|
| 390 |
if memories:
|
| 391 |
count = memories.count("\n ") + 1
|
|
@@ -394,31 +473,264 @@ class Orchestrator:
|
|
| 394 |
log.append("π§ Mem0: no relevant facts recalled yet")
|
| 395 |
return {**state, "past_memories": memories, "steps_log": log}
|
| 396 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 397 |
def memory_add_node(state: AgentState) -> AgentState:
|
| 398 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 399 |
stored = _add(state["query"], state["answer"])
|
| 400 |
log = list(state.get("steps_log", []))
|
| 401 |
-
|
|
|
|
|
|
|
|
|
|
| 402 |
return {**state, "memories_used": stored, "steps_log": log}
|
| 403 |
|
|
|
|
| 404 |
builder = StateGraph(AgentState)
|
| 405 |
-
builder.add_node("memory_search", memory_search_node)
|
| 406 |
-
builder.add_node("classify_intent", classify_intent)
|
| 407 |
-
builder.add_node("retrieve", retrieve)
|
| 408 |
-
builder.add_node("synthesize", synthesize)
|
| 409 |
-
builder.add_node("memory_add", memory_add_node)
|
| 410 |
|
| 411 |
-
builder.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 412 |
builder.add_edge("memory_search", "classify_intent")
|
| 413 |
-
builder.add_edge("classify_intent", "
|
| 414 |
-
builder.add_edge("
|
| 415 |
-
builder.add_edge("
|
| 416 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 417 |
|
| 418 |
return builder.compile()
|
| 419 |
|
|
|
|
|
|
|
| 420 |
def _base_state(self, query: str) -> AgentState:
|
| 421 |
-
"""Build the initial AgentState for a new query."""
|
| 422 |
return {
|
| 423 |
"query": query,
|
| 424 |
"user_id": self.user_id,
|
|
@@ -429,8 +741,19 @@ class Orchestrator:
|
|
| 429 |
"answer": "",
|
| 430 |
"chat_history": self.chat_history.copy(),
|
| 431 |
"steps_log": [],
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 432 |
}
|
| 433 |
|
|
|
|
|
|
|
| 434 |
def ask(self, query: str, verbose: bool = False) -> str:
|
| 435 |
result = self.graph.invoke(self._base_state(query))
|
| 436 |
|
|
@@ -440,7 +763,6 @@ class Orchestrator:
|
|
| 440 |
console.print(f" [dim]{step}[/dim]")
|
| 441 |
|
| 442 |
answer = result["answer"]
|
| 443 |
-
|
| 444 |
self.chat_history.append(("human", query))
|
| 445 |
self.chat_history.append(("ai", answer))
|
| 446 |
if len(self.chat_history) > 10:
|
|
@@ -450,32 +772,19 @@ class Orchestrator:
|
|
| 450 |
|
| 451 |
@_langsmith_traceable(name="health-insurance-rag-query", run_type="chain") # type: ignore[misc]
|
| 452 |
def ask_detailed(self, query: str) -> dict:
|
| 453 |
-
"""
|
| 454 |
-
Like ask(), but returns the full result dict for the API layer.
|
| 455 |
-
Decorated with @traceable so every call creates a named top-level
|
| 456 |
-
LangSmith parent span that groups all 6 LangGraph node child spans.
|
| 457 |
-
|
| 458 |
-
Returns:
|
| 459 |
-
{
|
| 460 |
-
"answer": str,
|
| 461 |
-
"intent": str,
|
| 462 |
-
"steps_log": list[str],
|
| 463 |
-
"retrieved_context": str,
|
| 464 |
-
"memories_used": list[str],
|
| 465 |
-
"run_id": str | None, # LangSmith trace UUID
|
| 466 |
-
}
|
| 467 |
-
"""
|
| 468 |
result = self.graph.invoke(self._base_state(query))
|
| 469 |
answer = result["answer"]
|
| 470 |
|
| 471 |
-
# ββ Tag the LangSmith trace with structured metadata ββββββββββββββββββ
|
| 472 |
-
# Called after invoke so intent & language are already resolved.
|
| 473 |
tag_current_run(
|
| 474 |
session_id=self.user_id,
|
| 475 |
intent=result.get("intent", ""),
|
| 476 |
-
extra_metadata={
|
|
|
|
|
|
|
|
|
|
|
|
|
| 477 |
)
|
| 478 |
-
# Capture run_id while still inside the @traceable scope
|
| 479 |
run_id = get_run_id()
|
| 480 |
|
| 481 |
self.chat_history.append(("human", query))
|
|
@@ -490,28 +799,19 @@ class Orchestrator:
|
|
| 490 |
"steps_log": result.get("steps_log", []),
|
| 491 |
"retrieved_context": result.get("retrieved_context", ""),
|
| 492 |
"memories_used": result.get("memories_used", []),
|
| 493 |
-
"run_id": run_id,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 494 |
}
|
| 495 |
return self.last_detailed_result
|
| 496 |
|
| 497 |
def stream_detailed(self, query: str):
|
| 498 |
-
"""
|
| 499 |
-
Generator that yields intermediate AgentState updates as they happen.
|
| 500 |
-
Useful for "Live" Developer Console views.
|
| 501 |
-
"""
|
| 502 |
-
# Use LangGraph's streaming mode
|
| 503 |
for event in self.graph.stream(self._base_state(query)):
|
| 504 |
-
# event is a dict like {"node_name": state_update}
|
| 505 |
for node, state in event.items():
|
| 506 |
-
|
| 507 |
-
yield {
|
| 508 |
-
"node": node,
|
| 509 |
-
"state": state
|
| 510 |
-
}
|
| 511 |
-
|
| 512 |
-
# After completion, update internal history (last yield will have the full state)
|
| 513 |
-
# Note: In a production stream, you might want to handle this differently
|
| 514 |
-
# but for this POC, the last event from 'synthesize' has the answer.
|
| 515 |
|
| 516 |
|
| 517 |
if __name__ == "__main__":
|
|
|
|
| 1 |
"""
|
| 2 |
Health Insurance AI Copilot β LangGraph Sequential Chain Orchestrator.
|
| 3 |
|
| 4 |
+
Architecture (9 nodes in a directed StateGraph):
|
| 5 |
|
| 6 |
START
|
| 7 |
β
|
| 8 |
βΌ
|
| 9 |
+
[1. query_guard] β NEW: PII detection, off-topic guard, query sanitization
|
| 10 |
+
β (blocked β jump to memory_add with pre-set answer)
|
| 11 |
+
βΌ
|
| 12 |
+
[2. memory_search] β Retrieve relevant past facts from Mem0
|
| 13 |
β
|
| 14 |
βΌ
|
| 15 |
+
[3. classify_intent]
|
| 16 |
Uses GPT-4o-mini to classify the query into one of four intents:
|
| 17 |
SIMPLE_LOOKUP | POLICY_QUESTION | MULTI_HOP | COMPARISON
|
| 18 |
β
|
| 19 |
βΌ
|
| 20 |
+
[4. query_decomposer] β NEW: Splits MULTI_HOP into atomic sub-questions
|
| 21 |
+
β
|
| 22 |
+
βΌ
|
| 23 |
+
[5. retrieve]
|
| 24 |
+
Routes internally by intent to the right retrieval strategy.
|
| 25 |
+
If sub_questions exist (MULTI_HOP), retrieves per sub-question.
|
| 26 |
β
|
| 27 |
βΌ
|
| 28 |
+
[6. context_quality_check] β NEW: Validates context richness before synthesis
|
| 29 |
+
β (skip_synthesis β jump to confidence_scorer with fallback answer)
|
| 30 |
+
βΌ
|
| 31 |
+
[7. synthesize]
|
| 32 |
Uses GPT-4o to generate a cited, safety-compliant final answer
|
| 33 |
β
|
| 34 |
βΌ
|
| 35 |
+
[8. self_critique] β NEW: Verifies answer quality (Self-RAG style). Max 1 retry.
|
| 36 |
+
β (needs_retry β loop back to retrieve)
|
| 37 |
+
βΌ
|
| 38 |
+
[9. confidence_scorer] β NEW: Heuristic HIGH/MEDIUM/LOW confidence rating
|
| 39 |
+
β
|
| 40 |
+
βΌ
|
| 41 |
+
[10. memory_add] β Persist Q&A facts to Mem0 for future sessions
|
| 42 |
β
|
| 43 |
βΌ
|
| 44 |
END
|
| 45 |
"""
|
| 46 |
|
| 47 |
import os
|
| 48 |
+
import re
|
| 49 |
import sys
|
| 50 |
+
from typing import TypedDict, List, Optional
|
| 51 |
import concurrent.futures
|
| 52 |
import contextvars
|
| 53 |
|
|
|
|
| 54 |
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 55 |
|
|
|
|
|
|
|
|
|
|
| 56 |
from dotenv import load_dotenv
|
| 57 |
load_dotenv()
|
| 58 |
|
|
|
|
| 61 |
from langgraph.graph import StateGraph, END
|
| 62 |
from rich.console import Console
|
| 63 |
|
|
|
|
| 64 |
try:
|
| 65 |
from langsmith import traceable as _langsmith_traceable
|
| 66 |
_LANGSMITH_AVAILABLE = True
|
| 67 |
except ImportError:
|
|
|
|
| 68 |
def _langsmith_traceable(*args, **kwargs): # type: ignore[misc]
|
| 69 |
def _decorator(fn):
|
| 70 |
return fn
|
|
|
|
| 72 |
_LANGSMITH_AVAILABLE = False
|
| 73 |
|
| 74 |
from orchestration.langsmith_tracing import tag_current_run, get_run_id
|
|
|
|
| 75 |
from config import (
|
| 76 |
LLM_MODEL,
|
| 77 |
CLASSIFIER_LLM_MODEL,
|
|
|
|
| 86 |
|
| 87 |
|
| 88 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 89 |
+
# 1. STATE β the shared data bag that flows through all nodes
|
| 90 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 91 |
|
| 92 |
class AgentState(TypedDict):
|
| 93 |
+
"""What gets passed between every node in the graph."""
|
| 94 |
+
query: str # Original user question
|
| 95 |
+
user_id: str # Session identifier
|
| 96 |
+
intent: str # Classified intent
|
| 97 |
+
retrieved_context: str # All retrieved text
|
| 98 |
+
past_memories: str # Relevant session memories
|
| 99 |
+
memories_used: List[str] # Facts stored to Mem0
|
| 100 |
+
answer: str # Final answer
|
| 101 |
+
chat_history: List[tuple] # [(role, message), ...]
|
| 102 |
+
steps_log: List[str] # Human-readable trace
|
| 103 |
+
# ββ NEW fields ββββββββββββββββββββββββββββββββββββββββββββ
|
| 104 |
+
blocked: bool # query_guard: was query blocked?
|
| 105 |
+
block_reason: str # query_guard: reason for blocking
|
| 106 |
+
sub_questions: List[str] # query_decomposer: MULTI_HOP sub-questions
|
| 107 |
+
skip_synthesis: bool # context_quality_check: skip synthesis?
|
| 108 |
+
needs_retry: bool # self_critique: trigger retrieve retry?
|
| 109 |
+
retry_count: int # self_critique: how many retries done
|
| 110 |
+
confidence: str # confidence_scorer: HIGH / MEDIUM / LOW
|
| 111 |
+
confidence_reason: str # confidence_scorer: explanation
|
| 112 |
|
| 113 |
|
| 114 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 116 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 117 |
|
| 118 |
def _classifier_llm() -> ChatOpenAI:
|
| 119 |
+
"""Fast, cheap model for intent classification and auxiliary LLM calls."""
|
| 120 |
return ChatOpenAI(
|
| 121 |
model=CLASSIFIER_LLM_MODEL,
|
| 122 |
temperature=LLM_TEMPERATURE,
|
| 123 |
openai_api_key=os.getenv("OPENAI_API_KEY"),
|
| 124 |
)
|
| 125 |
|
|
|
|
| 126 |
def _synthesis_llm() -> ChatOpenAI:
|
| 127 |
"""High-accuracy model for final answer synthesis."""
|
| 128 |
return ChatOpenAI(
|
|
|
|
| 133 |
|
| 134 |
|
| 135 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 136 |
+
# 3. NODE FUNCTIONS (stateless β defined at module level)
|
| 137 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 138 |
|
| 139 |
+
# ββ PII patterns to detect and redact βββββββββββββββββββββββββ
|
| 140 |
+
_PII_PATTERNS = [
|
| 141 |
+
(r'\b\d{3}-\d{2}-\d{4}\b', 'SSN pattern'),
|
| 142 |
+
(r'\b(0[1-9]|1[0-2])[-/](0[1-9]|[12]\d|3[01])[-/](19|20)\d{2}\b', 'date-of-birth pattern'),
|
| 143 |
+
(r'\b[2-9]\d{3}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b', 'credit/debit card pattern'),
|
| 144 |
+
(r'\bMRN[\s:#]*\d{6,}\b', 'medical record number'),
|
| 145 |
+
]
|
| 146 |
+
|
| 147 |
+
# ββ Off-topic topic signal words βββββββββββββββββββββββββββββββ
|
| 148 |
+
_OFF_TOPIC_SIGNALS = [
|
| 149 |
+
'weather', 'forecast', 'cryptocurrency', 'bitcoin', 'stock price',
|
| 150 |
+
'sports score', 'movie review', 'recipe', 'video game', 'dating',
|
| 151 |
+
'political party', 'election result',
|
| 152 |
+
]
|
| 153 |
|
| 154 |
+
def classify_intent(state: AgentState) -> AgentState:
|
| 155 |
+
"""NODE 3: Intent Classification."""
|
| 156 |
+
llm = _classifier_llm()
|
| 157 |
+
query = state["query"]
|
| 158 |
+
log = list(state.get("steps_log", []))
|
|
|
|
|
|
|
| 159 |
|
| 160 |
+
_INTENT_SYSTEM = """You are a query intent classifier for a Health Insurance AI assistant.
|
| 161 |
Classify the user query into EXACTLY ONE of these four categories:
|
| 162 |
|
| 163 |
SIMPLE_LOOKUP β Quick single-fact lookups.
|
|
|
|
| 181 |
|
| 182 |
Respond with ONLY the category name β no explanation, no punctuation, just the word."""
|
| 183 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 184 |
response = llm.invoke([
|
| 185 |
SystemMessage(content=_INTENT_SYSTEM),
|
| 186 |
HumanMessage(content=query),
|
|
|
|
| 194 |
return {**state, "intent": intent, "steps_log": log}
|
| 195 |
|
| 196 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 197 |
def retrieve(state: AgentState) -> AgentState:
|
| 198 |
+
"""NODE 5: Smart Retrieval β routes by intent, uses sub-questions when available."""
|
| 199 |
+
query = state["query"]
|
| 200 |
+
intent = state["intent"]
|
| 201 |
+
sub_questions = state.get("sub_questions", [])
|
| 202 |
+
retry_count = state.get("retry_count", 0)
|
| 203 |
+
log = list(state.get("steps_log", []))
|
| 204 |
parts: list[str] = []
|
| 205 |
|
|
|
|
| 206 |
internal_logs = []
|
| 207 |
token = trace_log.set(internal_logs)
|
| 208 |
|
| 209 |
def _run_tool(tool_func, kwargs):
|
| 210 |
return contextvars.copy_context().run(tool_func, kwargs)
|
| 211 |
|
| 212 |
+
if retry_count > 0:
|
| 213 |
+
log.append(f"π Retry #{retry_count}: re-running retrieval with broader search")
|
| 214 |
+
|
| 215 |
+
# ββ SIMPLE_LOOKUP ββββββββββββββββββββββββββββββββββββββββββ
|
| 216 |
if intent == "SIMPLE_LOOKUP":
|
| 217 |
log.append("π [SIMPLE_LOOKUP] Executing Graph & Hybrid retrieval concurrently")
|
| 218 |
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:
|
| 219 |
fut_g = executor.submit(_run_tool, relational_search.invoke, {"query": query})
|
| 220 |
fut_p = executor.submit(_run_tool, policy_search.invoke, {"query": query})
|
| 221 |
+
|
| 222 |
graph_ctx = fut_g.result()
|
| 223 |
if graph_ctx and "No structured" not in graph_ctx:
|
| 224 |
parts.append(f"[STRUCTURED GRAPH FACTS]\n{graph_ctx}")
|
| 225 |
log.append("πΈοΈ Graph entity lookup completed")
|
| 226 |
else:
|
| 227 |
log.append("πΈοΈ Graph entity lookup β no structured results")
|
| 228 |
+
|
| 229 |
policy_ctx = fut_p.result()
|
| 230 |
if policy_ctx and "No relevant" not in policy_ctx:
|
| 231 |
parts.append(f"[POLICY DOCUMENTS]\n{policy_ctx}")
|
|
|
|
| 233 |
else:
|
| 234 |
log.append("π Hybrid policy retrieval β no relevant results")
|
| 235 |
|
| 236 |
+
# ββ POLICY_QUESTION ββββββββββββββββββββββββββββββββββββββββ
|
| 237 |
elif intent == "POLICY_QUESTION":
|
| 238 |
log.append("π [POLICY_QUESTION] Full hybrid document retrieval")
|
| 239 |
policy_ctx = policy_search.invoke({"query": query})
|
| 240 |
if policy_ctx and "No relevant" not in policy_ctx:
|
| 241 |
parts.append(f"[POLICY DOCUMENTS]\n{policy_ctx}")
|
| 242 |
|
| 243 |
+
# ββ MULTI_HOP ββββββββββββββββββββββββββββββββββββββββββββββ
|
| 244 |
elif intent == "MULTI_HOP":
|
| 245 |
+
# Use decomposed sub-questions for targeted retrieval per hop
|
| 246 |
+
queries_to_run = sub_questions if sub_questions else [query]
|
| 247 |
+
log.append(f"π [MULTI_HOP] Executing {len(queries_to_run)} targeted retrieval(s)")
|
| 248 |
+
|
| 249 |
with concurrent.futures.ThreadPoolExecutor(max_workers=3) as executor:
|
| 250 |
+
# Always run graph, policy, and prior-auth in parallel on main query
|
| 251 |
fut_g = executor.submit(_run_tool, relational_search.invoke, {"query": query})
|
| 252 |
fut_p = executor.submit(_run_tool, policy_search.invoke, {"query": query})
|
| 253 |
fut_a = executor.submit(_run_tool, prior_auth_search.invoke, {"query": query})
|
| 254 |
+
|
| 255 |
graph_ctx = fut_g.result()
|
| 256 |
if graph_ctx and "No structured" not in graph_ctx:
|
| 257 |
parts.append(f"[STRUCTURED GRAPH FACTS]\n{graph_ctx}")
|
| 258 |
log.append("πΈοΈ Graph entity lookup completed")
|
| 259 |
else:
|
| 260 |
log.append("πΈοΈ Graph entity lookup β no structured results")
|
| 261 |
+
|
| 262 |
policy_ctx = fut_p.result()
|
| 263 |
if policy_ctx and "No relevant" not in policy_ctx:
|
| 264 |
parts.append(f"[POLICY DOCUMENTS]\n{policy_ctx}")
|
| 265 |
log.append("π Hybrid policy retrieval completed")
|
| 266 |
else:
|
| 267 |
log.append("π Hybrid policy retrieval β no relevant results")
|
| 268 |
+
|
| 269 |
auth_ctx = fut_a.result()
|
| 270 |
if auth_ctx and "No prior authorization" not in auth_ctx:
|
| 271 |
parts.append(f"[PRIOR AUTHORIZATION RULES]\n{auth_ctx}")
|
|
|
|
| 273 |
else:
|
| 274 |
log.append("π Prior authorization β no relevant rules found")
|
| 275 |
|
| 276 |
+
# Additionally run per-sub-question retrieval for targeted coverage
|
| 277 |
+
if len(sub_questions) > 1:
|
| 278 |
+
log.append(f"βοΈ Running targeted retrieval for {len(sub_questions)} sub-question(s)")
|
| 279 |
+
for i, sq in enumerate(sub_questions):
|
| 280 |
+
sq_ctx = policy_search.invoke({"query": sq})
|
| 281 |
+
if sq_ctx and "No relevant" not in sq_ctx:
|
| 282 |
+
parts.append(f"[SUB-QUESTION {i+1}: {sq}]\n{sq_ctx}")
|
| 283 |
+
log.append(f" β³ Sub-Q {i+1} retrieved successfully")
|
| 284 |
+
|
| 285 |
+
# ββ COMPARISON βββββββββββββββββββββββββββββββββββββββββββββ
|
| 286 |
elif intent == "COMPARISON":
|
| 287 |
+
log.append("βοΈ [COMPARISON] Retrieving Bronze, Silver, and Gold plan contexts concurrently")
|
| 288 |
with concurrent.futures.ThreadPoolExecutor(max_workers=3) as executor:
|
| 289 |
futures = {
|
| 290 |
tier: executor.submit(_run_tool, plan_comparison_search.invoke, {"query": query, "tier": tier})
|
|
|
|
| 298 |
else:
|
| 299 |
log.append(f"β οΈ [{tier} Tier] No relevant context found")
|
| 300 |
|
| 301 |
+
separator = "\n\n" + "β" * 60 + "\n\n"
|
| 302 |
full_context = separator.join(parts) if parts else "No relevant context found."
|
| 303 |
+
|
|
|
|
| 304 |
for l in internal_logs:
|
| 305 |
log.append(l)
|
| 306 |
+
|
| 307 |
log.append(f"β
Retrieved {len(parts)} context section(s)")
|
|
|
|
|
|
|
| 308 |
trace_log.reset(token)
|
| 309 |
|
| 310 |
return {**state, "retrieved_context": full_context, "steps_log": log}
|
| 311 |
|
| 312 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 313 |
_SYNTHESIS_TEMPLATE = """{system_prompt}
|
| 314 |
|
| 315 |
βββ PAST USER MEMORIES (from previous sessions) ββββββββββββββ
|
|
|
|
| 325 |
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 326 |
|
| 327 |
Using ONLY the retrieved context above, answer the user's question.
|
| 328 |
+
IMPORTANT: You MUST also follow any user preferences found in the PAST USER MEMORIES section.
|
| 329 |
+
If the user asks for entities with multiple criteria, verify ALL criteria match in the context.
|
| 330 |
+
Always cite the source file and page number for every fact EXACTLY as it appears (e.g., "(Source: filename.pdf, Page: 2)").
|
| 331 |
If the context does not contain enough information, say so explicitly β do NOT guess."""
|
| 332 |
|
| 333 |
|
| 334 |
def synthesize(state: AgentState) -> AgentState:
|
| 335 |
+
"""NODE 7: Answer Synthesis."""
|
| 336 |
llm = _synthesis_llm()
|
| 337 |
query = state["query"]
|
| 338 |
log = list(state.get("steps_log", []))
|
|
|
|
| 361 |
|
| 362 |
|
| 363 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 364 |
+
# 4. ORCHESTRATOR CLASS β builds the LangGraph with all 9 nodes
|
| 365 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 366 |
|
| 367 |
class Orchestrator:
|
| 368 |
def __init__(self, user_id: str = "default", mem=None):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 369 |
if not os.getenv("OPENAI_API_KEY"):
|
| 370 |
raise ValueError("OPENAI_API_KEY not found in environment.")
|
| 371 |
+
self._mem = mem
|
| 372 |
self.user_id: str = user_id
|
| 373 |
self.chat_history: List[tuple] = []
|
| 374 |
self.last_detailed_result: dict = {}
|
| 375 |
|
|
|
|
| 376 |
self._search_memories = lambda query: search_memories(self._mem, query)
|
| 377 |
self._add_memory = lambda q, a: add_memory(self._mem, q, a)
|
| 378 |
|
| 379 |
self.graph: any = self._build_graph()
|
| 380 |
|
|
|
|
|
|
|
| 381 |
def _build_graph(self):
|
| 382 |
+
"""Build the LangGraph with 9 nodes, conditional edges, and retry loop."""
|
| 383 |
_search = self._search_memories
|
| 384 |
_add = self._add_memory
|
| 385 |
|
| 386 |
+
# ββ Node 1: Query Guard βββββββββββββββββββββββββββββββββ
|
| 387 |
+
def query_guard_node(state: AgentState) -> AgentState:
|
| 388 |
+
"""PII detection, off-topic guard, query sanitization."""
|
| 389 |
query = state["query"]
|
| 390 |
log = list(state.get("steps_log", []))
|
| 391 |
+
|
| 392 |
+
# 1a. PII detection β redact but don't block (still useful info to answer)
|
| 393 |
+
sanitized = query
|
| 394 |
+
pii_found = []
|
| 395 |
+
for pattern, label in _PII_PATTERNS:
|
| 396 |
+
if re.search(pattern, query, re.IGNORECASE):
|
| 397 |
+
sanitized = re.sub(pattern, '[REDACTED]', sanitized, flags=re.IGNORECASE)
|
| 398 |
+
pii_found.append(label)
|
| 399 |
+
|
| 400 |
+
if pii_found:
|
| 401 |
+
log.append(f"π‘οΈ Guard: PII detected ({', '.join(pii_found)}) β sensitive data redacted")
|
| 402 |
+
# Don't block, just sanitize
|
| 403 |
+
return {**state, "query": sanitized, "blocked": False, "block_reason": "",
|
| 404 |
+
"sub_questions": [], "skip_synthesis": False, "needs_retry": False,
|
| 405 |
+
"retry_count": 0, "confidence": "", "confidence_reason": "",
|
| 406 |
+
"steps_log": log}
|
| 407 |
+
|
| 408 |
+
# 1b. Off-topic detection
|
| 409 |
+
q_lower = query.lower()
|
| 410 |
+
for signal in _OFF_TOPIC_SIGNALS:
|
| 411 |
+
if signal in q_lower:
|
| 412 |
+
log.append(f"π‘οΈ Guard BLOCKED: off-topic signal '{signal}' detected")
|
| 413 |
+
return {
|
| 414 |
+
**state,
|
| 415 |
+
"blocked": True,
|
| 416 |
+
"block_reason": "OFF_TOPIC",
|
| 417 |
+
"answer": (
|
| 418 |
+
f"I'm a Health Insurance AI assistant and can only help with health "
|
| 419 |
+
f"insurance questions (coverage, claims, providers, plan comparisons, "
|
| 420 |
+
f"drug formularies, etc.). Your question appears to be about '{signal}' "
|
| 421 |
+
f"which is outside my scope. Please ask something related to your health plan."
|
| 422 |
+
),
|
| 423 |
+
"sub_questions": [],
|
| 424 |
+
"skip_synthesis": True,
|
| 425 |
+
"needs_retry": False,
|
| 426 |
+
"retry_count": 0,
|
| 427 |
+
"confidence": "BLOCKED",
|
| 428 |
+
"confidence_reason": "Off-topic query",
|
| 429 |
+
"steps_log": log,
|
| 430 |
+
}
|
| 431 |
+
|
| 432 |
+
# 1c. Empty / trivially short query
|
| 433 |
+
if len(query.strip()) < 4:
|
| 434 |
+
log.append("π‘οΈ Guard BLOCKED: query too short or empty")
|
| 435 |
+
return {
|
| 436 |
+
**state,
|
| 437 |
+
"blocked": True,
|
| 438 |
+
"block_reason": "INVALID_QUERY",
|
| 439 |
+
"answer": "Please provide a more detailed health insurance question.",
|
| 440 |
+
"sub_questions": [],
|
| 441 |
+
"skip_synthesis": True,
|
| 442 |
+
"needs_retry": False,
|
| 443 |
+
"retry_count": 0,
|
| 444 |
+
"confidence": "BLOCKED",
|
| 445 |
+
"confidence_reason": "Query too short",
|
| 446 |
+
"steps_log": log,
|
| 447 |
+
}
|
| 448 |
+
|
| 449 |
+
log.append("β
Guard: query validated β all checks passed")
|
| 450 |
+
return {
|
| 451 |
+
**state,
|
| 452 |
+
"blocked": False,
|
| 453 |
+
"block_reason": "",
|
| 454 |
+
"sub_questions": [],
|
| 455 |
+
"skip_synthesis": False,
|
| 456 |
+
"needs_retry": False,
|
| 457 |
+
"retry_count": state.get("retry_count", 0),
|
| 458 |
+
"confidence": "",
|
| 459 |
+
"confidence_reason": "",
|
| 460 |
+
"steps_log": log,
|
| 461 |
+
}
|
| 462 |
+
|
| 463 |
+
# ββ Node 2: Memory Search βββββββββββββββββββββββββββββββ
|
| 464 |
+
def memory_search_node(state: AgentState) -> AgentState:
|
| 465 |
+
"""Retrieve relevant facts from this session's Mem0."""
|
| 466 |
+
query = state["query"]
|
| 467 |
+
log = list(state.get("steps_log", []))
|
| 468 |
memories = _search(query)
|
| 469 |
if memories:
|
| 470 |
count = memories.count("\n ") + 1
|
|
|
|
| 473 |
log.append("π§ Mem0: no relevant facts recalled yet")
|
| 474 |
return {**state, "past_memories": memories, "steps_log": log}
|
| 475 |
|
| 476 |
+
# ββ Node 4: Query Decomposer ββββββββββββββββββββββββββββ
|
| 477 |
+
def query_decomposer_node(state: AgentState) -> AgentState:
|
| 478 |
+
"""Split MULTI_HOP queries into atomic sub-questions for targeted retrieval."""
|
| 479 |
+
intent = state.get("intent", "")
|
| 480 |
+
log = list(state.get("steps_log", []))
|
| 481 |
+
|
| 482 |
+
# Only decompose MULTI_HOP β pass-through for everything else
|
| 483 |
+
if intent != "MULTI_HOP":
|
| 484 |
+
log.append(f"βοΈ Decomposer: skipped (intent={intent}, no decomposition needed)")
|
| 485 |
+
return {**state, "sub_questions": [], "steps_log": log}
|
| 486 |
+
|
| 487 |
+
query = state["query"]
|
| 488 |
+
llm = _classifier_llm()
|
| 489 |
+
|
| 490 |
+
prompt = (
|
| 491 |
+
f"Break this health insurance question into 2-4 atomic sub-questions "
|
| 492 |
+
f"that can each be answered independently from separate document lookups.\n\n"
|
| 493 |
+
f"Question: {query}\n\n"
|
| 494 |
+
f"Rules:\n"
|
| 495 |
+
f"- Each sub-question must be self-contained (no pronouns referencing other sub-questions)\n"
|
| 496 |
+
f"- Focus on ONE entity or fact per sub-question\n"
|
| 497 |
+
f"- Return ONLY the sub-questions, one per line, no numbering or bullet points"
|
| 498 |
+
)
|
| 499 |
+
|
| 500 |
+
try:
|
| 501 |
+
response = llm.invoke(prompt)
|
| 502 |
+
sub_questions = [q.strip() for q in response.content.splitlines() if q.strip()]
|
| 503 |
+
# Fallback: if decomposition fails or returns nothing useful
|
| 504 |
+
if not sub_questions or len(sub_questions) == 1:
|
| 505 |
+
sub_questions = [query]
|
| 506 |
+
log.append(f"βοΈ Decomposer: could not split β using original query")
|
| 507 |
+
else:
|
| 508 |
+
log.append(f"βοΈ Decomposer: split into {len(sub_questions)} sub-question(s)")
|
| 509 |
+
for i, sq in enumerate(sub_questions):
|
| 510 |
+
log.append(f" β³ Sub-Q {i+1}: {sq}")
|
| 511 |
+
except Exception as e:
|
| 512 |
+
sub_questions = [query]
|
| 513 |
+
log.append(f"βοΈ Decomposer: error ({e}) β using original query")
|
| 514 |
+
|
| 515 |
+
return {**state, "sub_questions": sub_questions, "steps_log": log}
|
| 516 |
+
|
| 517 |
+
# ββ Node 6: Context Quality Check βββββββββββββββββββββββ
|
| 518 |
+
def context_quality_check_node(state: AgentState) -> AgentState:
|
| 519 |
+
"""Validate retrieved context richness before synthesis."""
|
| 520 |
+
log = list(state.get("steps_log", []))
|
| 521 |
+
ctx = state.get("retrieved_context", "")
|
| 522 |
+
|
| 523 |
+
# Count meaningful sections
|
| 524 |
+
empty_phrases = [
|
| 525 |
+
"No relevant context found.",
|
| 526 |
+
"No structured relational data found",
|
| 527 |
+
"No relevant policy information found",
|
| 528 |
+
]
|
| 529 |
+
is_empty = (
|
| 530 |
+
not ctx or
|
| 531 |
+
ctx.strip() in empty_phrases or
|
| 532 |
+
all(ep in ctx for ep in ["No relevant"]) or
|
| 533 |
+
len(ctx.strip()) < 80
|
| 534 |
+
)
|
| 535 |
+
|
| 536 |
+
if is_empty:
|
| 537 |
+
log.append("β οΈ Context Quality: insufficient context retrieved β synthesis skipped")
|
| 538 |
+
return {
|
| 539 |
+
**state,
|
| 540 |
+
"skip_synthesis": True,
|
| 541 |
+
"answer": (
|
| 542 |
+
"I don't have sufficient information in my knowledge base to answer this "
|
| 543 |
+
"question accurately. This could be because:\n"
|
| 544 |
+
"β’ The specific plan detail isn't in our documents\n"
|
| 545 |
+
"β’ The question may need more specific terms\n\n"
|
| 546 |
+
"Please contact your insurance provider directly or refer to your plan documents."
|
| 547 |
+
),
|
| 548 |
+
"confidence": "LOW",
|
| 549 |
+
"confidence_reason": "No relevant context retrieved",
|
| 550 |
+
"steps_log": log,
|
| 551 |
+
}
|
| 552 |
+
|
| 553 |
+
# Count context sections for logging
|
| 554 |
+
section_count = ctx.count("β" * 20) + 1
|
| 555 |
+
char_count = len(ctx)
|
| 556 |
+
log.append(
|
| 557 |
+
f"β
Context Quality: {section_count} section(s) | {char_count:,} chars β synthesis proceeding"
|
| 558 |
+
)
|
| 559 |
+
return {**state, "skip_synthesis": False, "steps_log": log}
|
| 560 |
+
|
| 561 |
+
# ββ Node 8: Self-Critique βββββββββββββββββββββββββββββββ
|
| 562 |
+
def self_critique_node(state: AgentState) -> AgentState:
|
| 563 |
+
"""Self-RAG style reflection: verify answer quality, trigger retry if poor."""
|
| 564 |
+
log = list(state.get("steps_log", []))
|
| 565 |
+
retry_count = state.get("retry_count", 0)
|
| 566 |
+
|
| 567 |
+
# Skip if already retried, blocked, or synthesis was skipped
|
| 568 |
+
if retry_count >= 1 or state.get("skip_synthesis"):
|
| 569 |
+
log.append("π Self-Critique: skipped (max retries reached or synthesis was skipped)")
|
| 570 |
+
return {**state, "needs_retry": False, "steps_log": log}
|
| 571 |
+
|
| 572 |
+
answer = state.get("answer", "")
|
| 573 |
+
query = state["query"]
|
| 574 |
+
|
| 575 |
+
if not answer:
|
| 576 |
+
log.append("π Self-Critique: no answer to evaluate")
|
| 577 |
+
return {**state, "needs_retry": False, "steps_log": log}
|
| 578 |
+
|
| 579 |
+
llm = _classifier_llm()
|
| 580 |
+
prompt = (
|
| 581 |
+
f"You are evaluating a health insurance AI assistant's answer quality.\n\n"
|
| 582 |
+
f"Question: {query}\n\n"
|
| 583 |
+
f"Answer (first 600 chars): {answer[:600]}\n\n"
|
| 584 |
+
f"Evaluate:\n"
|
| 585 |
+
f"1. Does the answer directly address the specific question?\n"
|
| 586 |
+
f"2. Does it contain concrete details (numbers, plan names, coverage specifics)?\n"
|
| 587 |
+
f"3. Does it cite sources?\n\n"
|
| 588 |
+
f"Respond with ONLY one word:\n"
|
| 589 |
+
f"- GOOD: answer is adequate, specific, and addresses the question\n"
|
| 590 |
+
f"- RETRY: answer is vague, generic, or says 'I don't have info' when a better retrieval might help"
|
| 591 |
+
)
|
| 592 |
+
|
| 593 |
+
try:
|
| 594 |
+
response = llm.invoke(prompt)
|
| 595 |
+
verdict = response.content.strip().upper()
|
| 596 |
+
|
| 597 |
+
if "RETRY" in verdict:
|
| 598 |
+
log.append(f"π Self-Critique: answer quality INSUFFICIENT β triggering retry #{retry_count + 1}")
|
| 599 |
+
return {**state, "needs_retry": True, "retry_count": retry_count + 1, "steps_log": log}
|
| 600 |
+
else:
|
| 601 |
+
log.append("β
Self-Critique: answer quality VERIFIED β proceeding")
|
| 602 |
+
return {**state, "needs_retry": False, "steps_log": log}
|
| 603 |
+
except Exception as e:
|
| 604 |
+
log.append(f"π Self-Critique: evaluation error ({e}) β skipping retry")
|
| 605 |
+
return {**state, "needs_retry": False, "steps_log": log}
|
| 606 |
+
|
| 607 |
+
# ββ Node 9: Confidence Scorer βββββββββββββββββββββββββββ
|
| 608 |
+
def confidence_scorer_node(state: AgentState) -> AgentState:
|
| 609 |
+
"""Heuristic HIGH/MEDIUM/LOW confidence rating based on context and answer quality."""
|
| 610 |
+
log = list(state.get("steps_log", []))
|
| 611 |
+
|
| 612 |
+
# Already set during guard or quality check
|
| 613 |
+
if state.get("confidence") in ("BLOCKED", "LOW"):
|
| 614 |
+
log.append(f"π Confidence: {state['confidence']} (pre-set by earlier node)")
|
| 615 |
+
return {**state, "steps_log": log}
|
| 616 |
+
|
| 617 |
+
answer = state.get("answer", "")
|
| 618 |
+
ctx = state.get("retrieved_context", "")
|
| 619 |
+
reasons = []
|
| 620 |
+
score = 0
|
| 621 |
+
|
| 622 |
+
# Citations in answer
|
| 623 |
+
citation_count = answer.count("(Source:")
|
| 624 |
+
if citation_count >= 3:
|
| 625 |
+
score += 3; reasons.append(f"{citation_count} citations")
|
| 626 |
+
elif citation_count >= 1:
|
| 627 |
+
score += 2; reasons.append(f"{citation_count} citation(s)")
|
| 628 |
+
else:
|
| 629 |
+
score -= 1; reasons.append("no citations found")
|
| 630 |
+
|
| 631 |
+
# Context richness
|
| 632 |
+
ctx_len = len(ctx)
|
| 633 |
+
if ctx_len > 2000:
|
| 634 |
+
score += 2; reasons.append("rich context")
|
| 635 |
+
elif ctx_len > 500:
|
| 636 |
+
score += 1; reasons.append("moderate context")
|
| 637 |
+
else:
|
| 638 |
+
reasons.append("sparse context")
|
| 639 |
+
|
| 640 |
+
# Hedging language in answer (uncertainty signals)
|
| 641 |
+
hedging_phrases = [
|
| 642 |
+
"i don't have", "cannot find", "not available in",
|
| 643 |
+
"no information", "unable to", "not in my knowledge"
|
| 644 |
+
]
|
| 645 |
+
if any(h in answer.lower() for h in hedging_phrases):
|
| 646 |
+
score -= 1; reasons.append("hedged answer")
|
| 647 |
+
|
| 648 |
+
# Retry needed (lower confidence if answer required retry)
|
| 649 |
+
if state.get("retry_count", 0) > 0:
|
| 650 |
+
score -= 1; reasons.append("required retry")
|
| 651 |
+
|
| 652 |
+
# Final rating
|
| 653 |
+
if score >= 4:
|
| 654 |
+
confidence = "HIGH"
|
| 655 |
+
elif score >= 2:
|
| 656 |
+
confidence = "MEDIUM"
|
| 657 |
+
else:
|
| 658 |
+
confidence = "LOW"
|
| 659 |
+
|
| 660 |
+
reason_str = " | ".join(reasons)
|
| 661 |
+
log.append(f"π Confidence: {confidence} (score={score} β {reason_str})")
|
| 662 |
+
|
| 663 |
+
return {**state, "confidence": confidence, "confidence_reason": reason_str, "steps_log": log}
|
| 664 |
+
|
| 665 |
+
# ββ Node 10: Memory Add βββββββββββββββββββββββββββββββββ
|
| 666 |
def memory_add_node(state: AgentState) -> AgentState:
|
| 667 |
+
"""Persist Q&A facts back to this session's Mem0."""
|
| 668 |
+
# Skip memory storage if query was blocked
|
| 669 |
+
if state.get("blocked"):
|
| 670 |
+
log = list(state.get("steps_log", []))
|
| 671 |
+
log.append("πΎ Mem0: skipped (query was blocked)")
|
| 672 |
+
return {**state, "memories_used": [], "steps_log": log}
|
| 673 |
+
|
| 674 |
stored = _add(state["query"], state["answer"])
|
| 675 |
log = list(state.get("steps_log", []))
|
| 676 |
+
if stored:
|
| 677 |
+
log.append(f"πΎ Mem0 stored {len(stored)} fact(s) from this turn")
|
| 678 |
+
else:
|
| 679 |
+
log.append("πΎ Mem0: no new facts extracted")
|
| 680 |
return {**state, "memories_used": stored, "steps_log": log}
|
| 681 |
|
| 682 |
+
# ββ Build the StateGraph ββββββββββββββββββββββββββββββββ
|
| 683 |
builder = StateGraph(AgentState)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 684 |
|
| 685 |
+
builder.add_node("query_guard", query_guard_node)
|
| 686 |
+
builder.add_node("memory_search", memory_search_node)
|
| 687 |
+
builder.add_node("classify_intent", classify_intent)
|
| 688 |
+
builder.add_node("query_decomposer", query_decomposer_node)
|
| 689 |
+
builder.add_node("retrieve", retrieve)
|
| 690 |
+
builder.add_node("context_quality_check", context_quality_check_node)
|
| 691 |
+
builder.add_node("synthesize", synthesize)
|
| 692 |
+
builder.add_node("self_critique", self_critique_node)
|
| 693 |
+
builder.add_node("confidence_scorer", confidence_scorer_node)
|
| 694 |
+
builder.add_node("memory_add", memory_add_node)
|
| 695 |
+
|
| 696 |
+
builder.set_entry_point("query_guard")
|
| 697 |
+
|
| 698 |
+
# Conditional: blocked queries skip straight to memory_add
|
| 699 |
+
builder.add_conditional_edges(
|
| 700 |
+
"query_guard",
|
| 701 |
+
lambda s: "blocked" if s.get("blocked") else "ok",
|
| 702 |
+
{"blocked": "memory_add", "ok": "memory_search"},
|
| 703 |
+
)
|
| 704 |
+
|
| 705 |
builder.add_edge("memory_search", "classify_intent")
|
| 706 |
+
builder.add_edge("classify_intent", "query_decomposer")
|
| 707 |
+
builder.add_edge("query_decomposer","retrieve")
|
| 708 |
+
builder.add_edge("retrieve", "context_quality_check")
|
| 709 |
+
|
| 710 |
+
# Conditional: no context β skip synthesis, go straight to confidence_scorer
|
| 711 |
+
builder.add_conditional_edges(
|
| 712 |
+
"context_quality_check",
|
| 713 |
+
lambda s: "skip" if s.get("skip_synthesis") else "ok",
|
| 714 |
+
{"skip": "confidence_scorer", "ok": "synthesize"},
|
| 715 |
+
)
|
| 716 |
+
|
| 717 |
+
builder.add_edge("synthesize", "self_critique")
|
| 718 |
+
|
| 719 |
+
# Conditional: poor answer β retry retrieve (max once)
|
| 720 |
+
builder.add_conditional_edges(
|
| 721 |
+
"self_critique",
|
| 722 |
+
lambda s: "retry" if s.get("needs_retry") else "ok",
|
| 723 |
+
{"retry": "retrieve", "ok": "confidence_scorer"},
|
| 724 |
+
)
|
| 725 |
+
|
| 726 |
+
builder.add_edge("confidence_scorer", "memory_add")
|
| 727 |
+
builder.add_edge("memory_add", END)
|
| 728 |
|
| 729 |
return builder.compile()
|
| 730 |
|
| 731 |
+
# ββ Base state builder βββββββββοΏ½οΏ½οΏ½ββββββββββββββββββββββββββββββββββββββββββ
|
| 732 |
+
|
| 733 |
def _base_state(self, query: str) -> AgentState:
|
|
|
|
| 734 |
return {
|
| 735 |
"query": query,
|
| 736 |
"user_id": self.user_id,
|
|
|
|
| 741 |
"answer": "",
|
| 742 |
"chat_history": self.chat_history.copy(),
|
| 743 |
"steps_log": [],
|
| 744 |
+
# New fields β always initialized
|
| 745 |
+
"blocked": False,
|
| 746 |
+
"block_reason": "",
|
| 747 |
+
"sub_questions": [],
|
| 748 |
+
"skip_synthesis": False,
|
| 749 |
+
"needs_retry": False,
|
| 750 |
+
"retry_count": 0,
|
| 751 |
+
"confidence": "",
|
| 752 |
+
"confidence_reason": "",
|
| 753 |
}
|
| 754 |
|
| 755 |
+
# ββ Public API ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 756 |
+
|
| 757 |
def ask(self, query: str, verbose: bool = False) -> str:
|
| 758 |
result = self.graph.invoke(self._base_state(query))
|
| 759 |
|
|
|
|
| 763 |
console.print(f" [dim]{step}[/dim]")
|
| 764 |
|
| 765 |
answer = result["answer"]
|
|
|
|
| 766 |
self.chat_history.append(("human", query))
|
| 767 |
self.chat_history.append(("ai", answer))
|
| 768 |
if len(self.chat_history) > 10:
|
|
|
|
| 772 |
|
| 773 |
@_langsmith_traceable(name="health-insurance-rag-query", run_type="chain") # type: ignore[misc]
|
| 774 |
def ask_detailed(self, query: str) -> dict:
|
| 775 |
+
"""Like ask(), but returns the full result dict for the API layer."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 776 |
result = self.graph.invoke(self._base_state(query))
|
| 777 |
answer = result["answer"]
|
| 778 |
|
|
|
|
|
|
|
| 779 |
tag_current_run(
|
| 780 |
session_id=self.user_id,
|
| 781 |
intent=result.get("intent", ""),
|
| 782 |
+
extra_metadata={
|
| 783 |
+
"query_length": len(query),
|
| 784 |
+
"blocked": result.get("blocked", False),
|
| 785 |
+
"confidence": result.get("confidence", ""),
|
| 786 |
+
},
|
| 787 |
)
|
|
|
|
| 788 |
run_id = get_run_id()
|
| 789 |
|
| 790 |
self.chat_history.append(("human", query))
|
|
|
|
| 799 |
"steps_log": result.get("steps_log", []),
|
| 800 |
"retrieved_context": result.get("retrieved_context", ""),
|
| 801 |
"memories_used": result.get("memories_used", []),
|
| 802 |
+
"run_id": run_id,
|
| 803 |
+
"blocked": result.get("blocked", False),
|
| 804 |
+
"confidence": result.get("confidence", ""),
|
| 805 |
+
"confidence_reason": result.get("confidence_reason", ""),
|
| 806 |
+
"sub_questions": result.get("sub_questions", []),
|
| 807 |
}
|
| 808 |
return self.last_detailed_result
|
| 809 |
|
| 810 |
def stream_detailed(self, query: str):
|
| 811 |
+
"""Generator that yields intermediate AgentState updates for the Developer Console."""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 812 |
for event in self.graph.stream(self._base_state(query)):
|
|
|
|
| 813 |
for node, state in event.items():
|
| 814 |
+
yield {"node": node, "state": state}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 815 |
|
| 816 |
|
| 817 |
if __name__ == "__main__":
|
orchestration/semantic_cache.py
ADDED
|
@@ -0,0 +1,382 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import json
|
| 3 |
+
import hashlib
|
| 4 |
+
import time
|
| 5 |
+
from typing import Optional, Dict, List, Tuple
|
| 6 |
+
from loguru import logger
|
| 7 |
+
import redis
|
| 8 |
+
|
| 9 |
+
from langchain_openai import OpenAIEmbeddings, ChatOpenAI
|
| 10 |
+
from langchain_core.messages import SystemMessage, HumanMessage
|
| 11 |
+
from langchain_chroma import Chroma
|
| 12 |
+
from langchain_core.documents import Document
|
| 13 |
+
|
| 14 |
+
from config import (
|
| 15 |
+
REDIS_URL,
|
| 16 |
+
SEMANTIC_CACHE_THRESHOLD,
|
| 17 |
+
SEMANTIC_CACHE_COLLECTION,
|
| 18 |
+
EMBEDDING_MODEL,
|
| 19 |
+
CHROMA_PERSIST_DIR,
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
class SemanticCache:
|
| 23 |
+
"""
|
| 24 |
+
Cognitive Semantic Cache for LLM responses.
|
| 25 |
+
|
| 26 |
+
1. Query Normalization:
|
| 27 |
+
- Uses a lightweight ChatOpenAI call (gpt-4o-mini) to translate conversational,
|
| 28 |
+
first-person user queries into standard, formal third-person policy search statements
|
| 29 |
+
before vector search. This bridges the semantic gap (e.g. "asthma inhalers" -> "chronic pre-existing conditions and maintenance medications").
|
| 30 |
+
|
| 31 |
+
2. Redis Mode:
|
| 32 |
+
- Persists query text, embedding vector of normalized query, and response JSON in Redis.
|
| 33 |
+
- Caches vectors and queries in Python memory on startup for sub-millisecond similarity search.
|
| 34 |
+
- Uses plain cosine similarity in Python.
|
| 35 |
+
- Does NOT require Redis Stack/RediSearch, making it 100% compatible with standard Redis (e.g. local redis-server on HF, Upstash, AWS).
|
| 36 |
+
|
| 37 |
+
3. Local Fallback Mode:
|
| 38 |
+
- Used if Redis is unavailable.
|
| 39 |
+
- Stores vectors and response JSON in a local ChromaDB collection (`semantic_cache`).
|
| 40 |
+
- Matches using ChromaDB's native vector similarity search on normalized query vectors.
|
| 41 |
+
"""
|
| 42 |
+
def __init__(self):
|
| 43 |
+
# Initialize Embeddings
|
| 44 |
+
logger.info(f"Initializing SemanticCache embeddings with model {EMBEDDING_MODEL}...")
|
| 45 |
+
self.embeddings = OpenAIEmbeddings(model=EMBEDDING_MODEL)
|
| 46 |
+
|
| 47 |
+
# Initialize query normalizer LLM
|
| 48 |
+
logger.info("Initializing SemanticCache normalizer LLM (gpt-4o-mini)...")
|
| 49 |
+
self.normalizer_llm = ChatOpenAI(model="gpt-4o-mini", temperature=0.0)
|
| 50 |
+
|
| 51 |
+
# Redis configuration
|
| 52 |
+
self.redis_url = REDIS_URL
|
| 53 |
+
self.redis_client: Optional[redis.Redis] = None
|
| 54 |
+
self.redis_available = False
|
| 55 |
+
|
| 56 |
+
# In-memory index of vectors for fast search in Redis Mode
|
| 57 |
+
# Format: {cache_id: {"query": query_text, "vector": list[float], "plan_tier": plan_tier}}
|
| 58 |
+
self._cache_memory: Dict[str, Dict] = {}
|
| 59 |
+
|
| 60 |
+
# Local Fallback Store (Chroma)
|
| 61 |
+
self.vector_store: Optional[Chroma] = None
|
| 62 |
+
|
| 63 |
+
# Try to connect to Redis
|
| 64 |
+
try:
|
| 65 |
+
logger.info(f"Connecting to Redis at {self.redis_url}...")
|
| 66 |
+
self.redis_client = redis.Redis.from_url(
|
| 67 |
+
self.redis_url,
|
| 68 |
+
socket_timeout=1.5,
|
| 69 |
+
socket_connect_timeout=1.5,
|
| 70 |
+
decode_responses=True # Decode hash values as string
|
| 71 |
+
)
|
| 72 |
+
self.redis_client.ping()
|
| 73 |
+
self.redis_available = True
|
| 74 |
+
logger.info("Successfully connected to Redis. Running in Redis Cache Mode.")
|
| 75 |
+
|
| 76 |
+
# Load existing cache items into memory
|
| 77 |
+
self._load_cache_into_memory()
|
| 78 |
+
|
| 79 |
+
except Exception as e:
|
| 80 |
+
logger.warning(f"Redis not available: {e}. Falling back to ChromaDB Local Cache Mode.")
|
| 81 |
+
self.redis_available = False
|
| 82 |
+
self._init_chroma_store()
|
| 83 |
+
|
| 84 |
+
def _init_chroma_store(self):
|
| 85 |
+
"""Initialize Chroma collection for fallback caching."""
|
| 86 |
+
try:
|
| 87 |
+
self.vector_store = Chroma(
|
| 88 |
+
persist_directory=CHROMA_PERSIST_DIR,
|
| 89 |
+
embedding_function=self.embeddings,
|
| 90 |
+
collection_name=SEMANTIC_CACHE_COLLECTION
|
| 91 |
+
)
|
| 92 |
+
logger.info(f"Initialized fallback ChromaDB cache collection: '{SEMANTIC_CACHE_COLLECTION}'")
|
| 93 |
+
except Exception as e:
|
| 94 |
+
logger.error(f"Failed to initialize fallback ChromaDB: {e}")
|
| 95 |
+
|
| 96 |
+
def _load_cache_into_memory(self):
|
| 97 |
+
"""Pre-load all cached vectors from Redis into Python memory for fast scanning."""
|
| 98 |
+
if not self.redis_client:
|
| 99 |
+
return
|
| 100 |
+
|
| 101 |
+
start_time = time.time()
|
| 102 |
+
try:
|
| 103 |
+
# Retrieve all cache IDs
|
| 104 |
+
cache_ids = self.redis_client.smembers("cache:ids")
|
| 105 |
+
if not cache_ids:
|
| 106 |
+
logger.info("Redis cache is empty. In-memory index initialized empty.")
|
| 107 |
+
return
|
| 108 |
+
|
| 109 |
+
logger.info(f"Pre-loading {len(cache_ids)} cached queries from Redis...")
|
| 110 |
+
|
| 111 |
+
# Fetch the query and vector hashes in pipelined execution
|
| 112 |
+
pipeline = self.redis_client.pipeline()
|
| 113 |
+
for cid in cache_ids:
|
| 114 |
+
pipeline.hmget(f"cache:data:{cid}", ["query", "vector", "plan_tier"])
|
| 115 |
+
|
| 116 |
+
results = pipeline.execute()
|
| 117 |
+
|
| 118 |
+
loaded_count = 0
|
| 119 |
+
for cid, (query, vector_str, plan_tier) in zip(cache_ids, results):
|
| 120 |
+
if query and vector_str:
|
| 121 |
+
try:
|
| 122 |
+
vector = json.loads(vector_str)
|
| 123 |
+
self._cache_memory[cid] = {
|
| 124 |
+
"query": query,
|
| 125 |
+
"vector": vector,
|
| 126 |
+
"plan_tier": plan_tier or "Unknown"
|
| 127 |
+
}
|
| 128 |
+
loaded_count += 1
|
| 129 |
+
except Exception as ve:
|
| 130 |
+
logger.error(f"Error parsing vector for key {cid}: {ve}")
|
| 131 |
+
|
| 132 |
+
logger.info(f"Loaded {loaded_count} cache keys into Python memory in {time.time() - start_time:.3f}s")
|
| 133 |
+
|
| 134 |
+
except Exception as e:
|
| 135 |
+
logger.error(f"Error pre-loading cache from Redis: {e}")
|
| 136 |
+
|
| 137 |
+
def _cosine_similarity(self, vec_a: List[float], vec_b: List[float]) -> float:
|
| 138 |
+
"""
|
| 139 |
+
Calculate cosine similarity between two vectors.
|
| 140 |
+
OpenAI text-embedding-3-small vectors are already normalized (L2 norm = 1.0).
|
| 141 |
+
Thus, cosine similarity is exactly the dot product.
|
| 142 |
+
"""
|
| 143 |
+
if len(vec_a) != len(vec_b):
|
| 144 |
+
return 0.0
|
| 145 |
+
return sum(a * b for a, b in zip(vec_a, vec_b))
|
| 146 |
+
|
| 147 |
+
def normalize_query(self, query: str) -> str:
|
| 148 |
+
"""
|
| 149 |
+
Normalize conversational, user-specific queries into formal, third-person health insurance search queries.
|
| 150 |
+
e.g., "asthma inhaler wait" -> "waiting periods for pre-existing conditions and maintenance medications"
|
| 151 |
+
"""
|
| 152 |
+
if not query or len(query.strip()) < 4:
|
| 153 |
+
return query
|
| 154 |
+
|
| 155 |
+
try:
|
| 156 |
+
system_prompt = (
|
| 157 |
+
"You are a health insurance query normalizer. Convert the user's conversational, first-person query "
|
| 158 |
+
"into a standard, formal, third-person search query about health insurance policies, deductibles, "
|
| 159 |
+
"coverage rules, or providers. Strip away personal details (like names, specific diagnosis dates, "
|
| 160 |
+
"pronouns, conversational filler). Keep plan names and general terms. Keep it concise.\n\n"
|
| 161 |
+
"Examples:\n"
|
| 162 |
+
"- 'What is the policy regarding waiting periods for chronic, pre-existing conditions before the plan starts paying for specialist visits and maintenance medications?' "
|
| 163 |
+
"-> 'Waiting periods for pre-existing conditions, specialist visits, and maintenance medications'\n"
|
| 164 |
+
"- 'I was diagnosed with asthma last year and just signed up for this insurance. Do I have to wait a certain number of months before you guys will cover my inhalers and pulmonologist appointments, or am I good to go right now?' "
|
| 165 |
+
"-> 'Waiting periods for pre-existing conditions, specialist visits, and maintenance medications'\n"
|
| 166 |
+
"- 'Who are the skin doctors in Aurora?' -> 'In-network dermatologists in Aurora'\n"
|
| 167 |
+
"- 'How much do I pay for Metformin on Silver?' -> 'Copay for Metformin on Silver plan'"
|
| 168 |
+
)
|
| 169 |
+
|
| 170 |
+
response = self.normalizer_llm.invoke([
|
| 171 |
+
SystemMessage(content=system_prompt),
|
| 172 |
+
HumanMessage(content=query)
|
| 173 |
+
])
|
| 174 |
+
|
| 175 |
+
normalized = response.content.strip()
|
| 176 |
+
logger.info(f"Normalized query: '{query[:50]}...' -> '{normalized}'")
|
| 177 |
+
return normalized
|
| 178 |
+
except Exception as e:
|
| 179 |
+
logger.warning(f"Failed to normalize query: {e}. Using original query.")
|
| 180 |
+
return query
|
| 181 |
+
|
| 182 |
+
def check(self, query: str, plan_tier: str = "Unknown") -> Optional[dict]:
|
| 183 |
+
"""
|
| 184 |
+
Check the semantic cache for a match.
|
| 185 |
+
|
| 186 |
+
Returns:
|
| 187 |
+
dict containing the response data and hit metadata if found, else None.
|
| 188 |
+
"""
|
| 189 |
+
if not query or len(query.strip()) < 4:
|
| 190 |
+
return None
|
| 191 |
+
|
| 192 |
+
start_time = time.time()
|
| 193 |
+
try:
|
| 194 |
+
# 1. Normalize query to standard terminology
|
| 195 |
+
norm_query = self.normalize_query(query)
|
| 196 |
+
|
| 197 |
+
# 2. Embed the normalized query
|
| 198 |
+
query_vector = self.embeddings.embed_query(norm_query)
|
| 199 |
+
|
| 200 |
+
if self.redis_available and self.redis_client:
|
| 201 |
+
# Redis Mode: Scan memory cache for similarity
|
| 202 |
+
if not self._cache_memory:
|
| 203 |
+
return None
|
| 204 |
+
|
| 205 |
+
best_id = None
|
| 206 |
+
best_score = -1.0
|
| 207 |
+
|
| 208 |
+
# Compute similarities in Python memory, filtering by plan_tier
|
| 209 |
+
for cid, item in self._cache_memory.items():
|
| 210 |
+
# Filter by plan_tier case-insensitively
|
| 211 |
+
if item.get("plan_tier", "Unknown").lower() != plan_tier.lower():
|
| 212 |
+
continue
|
| 213 |
+
|
| 214 |
+
sim = self._cosine_similarity(query_vector, item["vector"])
|
| 215 |
+
if sim > best_score:
|
| 216 |
+
best_score = sim
|
| 217 |
+
best_id = cid
|
| 218 |
+
|
| 219 |
+
# Check threshold
|
| 220 |
+
if best_id and best_score >= SEMANTIC_CACHE_THRESHOLD:
|
| 221 |
+
# Cache Hit! Retrieve full response from Redis
|
| 222 |
+
cached_json = self.redis_client.hget(f"cache:data:{best_id}", "response")
|
| 223 |
+
if cached_json:
|
| 224 |
+
response = json.loads(cached_json)
|
| 225 |
+
# Inject cache metadata
|
| 226 |
+
response["cached"] = True
|
| 227 |
+
response["cache_similarity"] = round(best_score * 100, 1)
|
| 228 |
+
response["matched_query"] = self._cache_memory[best_id]["query"]
|
| 229 |
+
|
| 230 |
+
logger.info(f"β‘ Redis Semantic Cache HIT (Plan: {plan_tier}, Similarity: {response['cache_similarity']}%) in {time.time() - start_time:.3f}s")
|
| 231 |
+
return response
|
| 232 |
+
|
| 233 |
+
else:
|
| 234 |
+
# Fallback ChromaDB Mode
|
| 235 |
+
if not self.vector_store:
|
| 236 |
+
self._init_chroma_store()
|
| 237 |
+
if not self.vector_store:
|
| 238 |
+
return None
|
| 239 |
+
|
| 240 |
+
# Search ChromaDB with plan_tier filter using the normalized query
|
| 241 |
+
results = self.vector_store.similarity_search_with_score(
|
| 242 |
+
norm_query,
|
| 243 |
+
k=1,
|
| 244 |
+
filter={"plan_tier": plan_tier}
|
| 245 |
+
)
|
| 246 |
+
if results:
|
| 247 |
+
doc, distance = results[0]
|
| 248 |
+
# Convert distance to similarity score
|
| 249 |
+
similarity = 1.0 - (distance / 2.0)
|
| 250 |
+
|
| 251 |
+
if similarity >= SEMANTIC_CACHE_THRESHOLD:
|
| 252 |
+
response_json = doc.metadata.get("response_json")
|
| 253 |
+
if response_json:
|
| 254 |
+
response = json.loads(response_json)
|
| 255 |
+
response["cached"] = True
|
| 256 |
+
response["cache_similarity"] = round(similarity * 100, 1)
|
| 257 |
+
response["matched_query"] = doc.metadata.get("original_query", doc.page_content)
|
| 258 |
+
|
| 259 |
+
logger.info(f"β‘ Local ChromaDB Cache HIT (Plan: {plan_tier}, Similarity: {response['cache_similarity']}%) in {time.time() - start_time:.3f}s")
|
| 260 |
+
return response
|
| 261 |
+
|
| 262 |
+
except Exception as e:
|
| 263 |
+
logger.error(f"Error checking semantic cache: {e}")
|
| 264 |
+
|
| 265 |
+
return None
|
| 266 |
+
|
| 267 |
+
def store(self, query: str, response: dict, plan_tier: str = "Unknown") -> None:
|
| 268 |
+
"""
|
| 269 |
+
Store query response in the semantic cache.
|
| 270 |
+
"""
|
| 271 |
+
# Skip caching error responses or empty results
|
| 272 |
+
if not query or not response or "error" in response:
|
| 273 |
+
return
|
| 274 |
+
|
| 275 |
+
try:
|
| 276 |
+
# 1. Normalize query to standard terminology
|
| 277 |
+
norm_query = self.normalize_query(query)
|
| 278 |
+
|
| 279 |
+
# Generate deterministic cache ID based on normalized query
|
| 280 |
+
cache_id = hashlib.sha256(norm_query.encode("utf-8")).hexdigest()
|
| 281 |
+
query_vector = self.embeddings.embed_query(norm_query)
|
| 282 |
+
|
| 283 |
+
# Remove any existing cache flags from the saved dictionary
|
| 284 |
+
clean_response = response.copy()
|
| 285 |
+
clean_response.pop("cached", None)
|
| 286 |
+
clean_response.pop("cache_similarity", None)
|
| 287 |
+
clean_response.pop("matched_query", None)
|
| 288 |
+
|
| 289 |
+
response_json = json.dumps(clean_response)
|
| 290 |
+
|
| 291 |
+
if self.redis_available and self.redis_client:
|
| 292 |
+
# Store in Redis
|
| 293 |
+
pipeline = self.redis_client.pipeline()
|
| 294 |
+
# Hash contents
|
| 295 |
+
pipeline.hset(f"cache:data:{cache_id}", mapping={
|
| 296 |
+
"query": query, # original user query for UI
|
| 297 |
+
"vector": json.dumps(query_vector),
|
| 298 |
+
"response": response_json,
|
| 299 |
+
"plan_tier": plan_tier,
|
| 300 |
+
"timestamp": time.time()
|
| 301 |
+
})
|
| 302 |
+
# Add to ID set
|
| 303 |
+
pipeline.sadd("cache:ids", cache_id)
|
| 304 |
+
# Set TTL on the hash (e.g. 7 days = 604800 seconds)
|
| 305 |
+
pipeline.expire(f"cache:data:{cache_id}", 604800)
|
| 306 |
+
pipeline.execute()
|
| 307 |
+
|
| 308 |
+
# Update Python memory index (storing normalized vector)
|
| 309 |
+
self._cache_memory[cache_id] = {
|
| 310 |
+
"query": query,
|
| 311 |
+
"vector": query_vector,
|
| 312 |
+
"plan_tier": plan_tier
|
| 313 |
+
}
|
| 314 |
+
logger.info(f"πΎ Query cached in Redis under ID cache:data:{cache_id[:8]}... (Plan: {plan_tier})")
|
| 315 |
+
|
| 316 |
+
else:
|
| 317 |
+
# Store in fallback ChromaDB
|
| 318 |
+
if not self.vector_store:
|
| 319 |
+
self._init_chroma_store()
|
| 320 |
+
if not self.vector_store:
|
| 321 |
+
return
|
| 322 |
+
|
| 323 |
+
# To prevent bloating, check if this query ID is already in Chroma
|
| 324 |
+
existing = self.vector_store.get(ids=[cache_id])
|
| 325 |
+
if existing and existing["ids"]:
|
| 326 |
+
# Update metadata
|
| 327 |
+
self.vector_store.update_document(
|
| 328 |
+
document_id=cache_id,
|
| 329 |
+
document=Document(
|
| 330 |
+
page_content=norm_query,
|
| 331 |
+
metadata={
|
| 332 |
+
"cache_id": cache_id,
|
| 333 |
+
"original_query": query,
|
| 334 |
+
"response_json": response_json,
|
| 335 |
+
"plan_tier": plan_tier,
|
| 336 |
+
"timestamp": time.time()
|
| 337 |
+
}
|
| 338 |
+
)
|
| 339 |
+
)
|
| 340 |
+
logger.info(f"πΎ Updated query cache in ChromaDB under ID {cache_id[:8]}... (Plan: {plan_tier})")
|
| 341 |
+
else:
|
| 342 |
+
# Add new
|
| 343 |
+
self.vector_store.add_texts(
|
| 344 |
+
texts=[norm_query],
|
| 345 |
+
metadatas=[{
|
| 346 |
+
"cache_id": cache_id,
|
| 347 |
+
"original_query": query,
|
| 348 |
+
"response_json": response_json,
|
| 349 |
+
"plan_tier": plan_tier,
|
| 350 |
+
"timestamp": time.time()
|
| 351 |
+
}],
|
| 352 |
+
ids=[cache_id]
|
| 353 |
+
)
|
| 354 |
+
logger.info(f"πΎ Created query cache in ChromaDB under ID {cache_id[:8]}... (Plan: {plan_tier})")
|
| 355 |
+
|
| 356 |
+
except Exception as e:
|
| 357 |
+
logger.error(f"Error storing query in semantic cache: {e}")
|
| 358 |
+
|
| 359 |
+
def clear(self) -> None:
|
| 360 |
+
"""Clear all entries in the cache."""
|
| 361 |
+
try:
|
| 362 |
+
if self.redis_available and self.redis_client:
|
| 363 |
+
cache_ids = self.redis_client.smembers("cache:ids")
|
| 364 |
+
if cache_ids:
|
| 365 |
+
pipeline = self.redis_client.pipeline()
|
| 366 |
+
for cid in cache_ids:
|
| 367 |
+
pipeline.delete(f"cache:data:{cid}")
|
| 368 |
+
pipeline.delete("cache:ids")
|
| 369 |
+
pipeline.execute()
|
| 370 |
+
self._cache_memory.clear()
|
| 371 |
+
logger.info("Cleared Redis semantic cache.")
|
| 372 |
+
else:
|
| 373 |
+
if self.vector_store:
|
| 374 |
+
# Recreate collection to wipe it
|
| 375 |
+
self.vector_store.delete_collection()
|
| 376 |
+
self._init_chroma_store()
|
| 377 |
+
logger.info("Cleared local ChromaDB semantic cache.")
|
| 378 |
+
except Exception as e:
|
| 379 |
+
logger.error(f"Error clearing cache: {e}")
|
| 380 |
+
|
| 381 |
+
# Global Cache Manager Singleton
|
| 382 |
+
cache_manager = SemanticCache()
|
orchestration/tools.py
CHANGED
|
@@ -7,14 +7,16 @@ Defines 4 retrieval tools the LangGraph orchestrator can invoke:
|
|
| 7 |
- plan_comparison_search : Hybrid retrieval scoped to a specific plan tier
|
| 8 |
- prior_auth_search : Hybrid retrieval filtered to prior-authorization docs
|
| 9 |
|
| 10 |
-
|
| 11 |
-
|
|
|
|
|
|
|
| 12 |
"""
|
| 13 |
|
| 14 |
import sys
|
| 15 |
import os
|
|
|
|
| 16 |
|
| 17 |
-
# Ensure project root is on sys.path so `config` and sibling packages resolve
|
| 18 |
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 19 |
|
| 20 |
from langchain_core.tools import tool
|
|
@@ -23,7 +25,7 @@ from langchain_core.documents import Document
|
|
| 23 |
from retrieval.retriever import get_hybrid_retriever
|
| 24 |
from retrieval.graph_retriever import GraphRetriever
|
| 25 |
|
| 26 |
-
# ββ Lazy singletons
|
| 27 |
_hybrid_retriever = None
|
| 28 |
_graph_retriever = None
|
| 29 |
|
|
@@ -52,6 +54,62 @@ def _format_docs(docs: list[Document], max_per_tool: int = 5) -> str:
|
|
| 52 |
return "\n\n---\n\n".join(formatted) if formatted else ""
|
| 53 |
|
| 54 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
# ββ Tool 1: General policy search βββββββββββββββββββββββββββββββββββββββββββββ
|
| 56 |
@tool
|
| 57 |
def policy_search(query: str) -> str:
|
|
@@ -60,14 +118,32 @@ def policy_search(query: str) -> str:
|
|
| 60 |
Use this for questions about coverage rules, claim procedures, benefit summaries,
|
| 61 |
and general insurance terms.
|
| 62 |
Uses the full hybrid pipeline: BM25 + Vector + MultiQuery + CrossEncoder Reranker + Graph.
|
|
|
|
| 63 |
"""
|
| 64 |
hybrid, _ = _get_retrievers()
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
result = _format_docs(docs)
|
| 72 |
return result if result else "No relevant policy information found."
|
| 73 |
|
|
@@ -95,30 +171,34 @@ def plan_comparison_search(query: str, tier: str) -> str:
|
|
| 95 |
Use this when the user wants to compare plans or asks about a specific plan tier.
|
| 96 |
Runs the full hybrid retrieval pipeline with a tier-augmented query, then
|
| 97 |
prioritises documents whose metadata or content mentions that tier.
|
|
|
|
| 98 |
"""
|
| 99 |
hybrid, _ = _get_retrievers()
|
| 100 |
|
| 101 |
-
#
|
| 102 |
tier_query = f"{tier} plan {query}"
|
| 103 |
docs = hybrid.invoke(tier_query)
|
| 104 |
|
| 105 |
-
# Guarantee tier-specific documents
|
| 106 |
try:
|
| 107 |
from retrieval.retriever import _load_vectorstore
|
| 108 |
vs = _load_vectorstore()
|
| 109 |
-
|
|
|
|
|
|
|
| 110 |
docs = tier_specific_docs + docs
|
| 111 |
-
except Exception
|
| 112 |
pass
|
| 113 |
|
|
|
|
| 114 |
tier_docs = [
|
| 115 |
d for d in docs
|
| 116 |
if d.metadata.get("plan_tier", "").lower() in [tier.lower(), "all"]
|
| 117 |
or tier.lower() in d.page_content.lower()
|
| 118 |
-
or d.metadata.get("doc_type")
|
| 119 |
]
|
| 120 |
if not tier_docs:
|
| 121 |
-
tier_docs = docs
|
| 122 |
|
| 123 |
result = _format_docs(tier_docs, max_per_tool=5)
|
| 124 |
return result if result else f"No {tier} plan information found for this query."
|
|
@@ -133,25 +213,41 @@ def prior_auth_search(query: str) -> str:
|
|
| 133 |
requirements for drugs or procedures, or PA criteria.
|
| 134 |
Runs the full hybrid pipeline with a PA-focused query and filters to
|
| 135 |
documents tagged as prior_authorization type or containing PA keywords.
|
|
|
|
| 136 |
"""
|
| 137 |
hybrid, _ = _get_retrievers()
|
| 138 |
|
| 139 |
-
auth_query = f"prior authorization {query}"
|
| 140 |
docs = hybrid.invoke(auth_query)
|
| 141 |
|
| 142 |
-
|
| 143 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
|
| 145 |
auth_docs = [
|
| 146 |
d for d in docs
|
| 147 |
-
if d.metadata.get("doc_type")
|
| 148 |
or any(kw in d.page_content.lower() for kw in pa_keywords)
|
| 149 |
]
|
| 150 |
|
| 151 |
if not auth_docs:
|
| 152 |
return "No prior authorization information found for this query."
|
| 153 |
|
| 154 |
-
result = _format_docs(auth_docs, max_per_tool=
|
| 155 |
return result if result else "No prior authorization information found for this query."
|
| 156 |
|
| 157 |
|
|
|
|
| 7 |
- plan_comparison_search : Hybrid retrieval scoped to a specific plan tier
|
| 8 |
- prior_auth_search : Hybrid retrieval filtered to prior-authorization docs
|
| 9 |
|
| 10 |
+
ENHANCEMENTS:
|
| 11 |
+
- Doc-type routing: pre-routes queries to the most relevant document collection
|
| 12 |
+
- Metadata pre-filtering: scopes vector searches to relevant doc types
|
| 13 |
+
- Per-tool query augmentation for higher precision
|
| 14 |
"""
|
| 15 |
|
| 16 |
import sys
|
| 17 |
import os
|
| 18 |
+
from typing import Optional
|
| 19 |
|
|
|
|
| 20 |
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 21 |
|
| 22 |
from langchain_core.tools import tool
|
|
|
|
| 25 |
from retrieval.retriever import get_hybrid_retriever
|
| 26 |
from retrieval.graph_retriever import GraphRetriever
|
| 27 |
|
| 28 |
+
# ββ Lazy singletons ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 29 |
_hybrid_retriever = None
|
| 30 |
_graph_retriever = None
|
| 31 |
|
|
|
|
| 54 |
return "\n\n---\n\n".join(formatted) if formatted else ""
|
| 55 |
|
| 56 |
|
| 57 |
+
# ββ Doc-Type Router ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 58 |
+
# Maps keyword signals β document collection types for pre-filtering
|
| 59 |
+
_DOC_TYPE_KEYWORDS: dict[str, list[str]] = {
|
| 60 |
+
"drug_formulary": [
|
| 61 |
+
"drug", "formulary", "medication", "prescription", "generic", "brand",
|
| 62 |
+
"metformin", "lisinopril", "atorvastatin", "tier 1", "tier 2", "tier 3",
|
| 63 |
+
"covered drug", "formulary list", "pharmacy benefit",
|
| 64 |
+
],
|
| 65 |
+
"claim_submission_guidelines": [
|
| 66 |
+
"claim", "submit a claim", "reimburs", "billing", "appeal", "dispute",
|
| 67 |
+
"out-of-pocket claim", "claim form", "eoob", "explanation of benefits",
|
| 68 |
+
],
|
| 69 |
+
"preventive_care_schedule": [
|
| 70 |
+
"preventive", "annual exam", "wellness visit", "vaccine", "immunization",
|
| 71 |
+
"mammogram", "colonoscopy", "screening", "checkup", "physical exam",
|
| 72 |
+
],
|
| 73 |
+
"prior_authorization": [
|
| 74 |
+
"prior auth", "prior authorization", "step therapy", "pre-approval",
|
| 75 |
+
"preauthorization", "pa required", "approval required", "step-therapy",
|
| 76 |
+
],
|
| 77 |
+
"provider_directory": [
|
| 78 |
+
"provider", "doctor", "specialist", "in-network", "out-of-network",
|
| 79 |
+
"hospital", "physician", "clinic", "dermatologist", "cardiologist",
|
| 80 |
+
"network provider", "find a doctor",
|
| 81 |
+
],
|
| 82 |
+
"evidence_of_coverage": [
|
| 83 |
+
"coverage", "covered service", "benefit", "exclusion", "limitation",
|
| 84 |
+
"deductible", "copay", "coinsurance", "out-of-pocket maximum",
|
| 85 |
+
"emergency", "urgent care", "what is covered",
|
| 86 |
+
],
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
def _infer_doc_type(query: str) -> Optional[str]:
|
| 90 |
+
"""Infer the most relevant document type from the query text."""
|
| 91 |
+
q_lower = query.lower()
|
| 92 |
+
best_doc_type = None
|
| 93 |
+
best_match_count = 0
|
| 94 |
+
|
| 95 |
+
for doc_type, keywords in _DOC_TYPE_KEYWORDS.items():
|
| 96 |
+
match_count = sum(1 for kw in keywords if kw in q_lower)
|
| 97 |
+
if match_count > best_match_count:
|
| 98 |
+
best_match_count = match_count
|
| 99 |
+
best_doc_type = doc_type
|
| 100 |
+
|
| 101 |
+
# Only return a type if we have at least one strong signal
|
| 102 |
+
return best_doc_type if best_match_count >= 1 else None
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def _get_doc_type_filtered_docs(vs, query: str, doc_type: str, k: int = 3) -> list[Document]:
|
| 106 |
+
"""Fetch docs pre-filtered by doc_type metadata from ChromaDB."""
|
| 107 |
+
try:
|
| 108 |
+
return vs.similarity_search(query, k=k, filter={"doc_type": doc_type})
|
| 109 |
+
except Exception:
|
| 110 |
+
return []
|
| 111 |
+
|
| 112 |
+
|
| 113 |
# ββ Tool 1: General policy search βββββββββββββββββββββββββββββββββββββββββββββ
|
| 114 |
@tool
|
| 115 |
def policy_search(query: str) -> str:
|
|
|
|
| 118 |
Use this for questions about coverage rules, claim procedures, benefit summaries,
|
| 119 |
and general insurance terms.
|
| 120 |
Uses the full hybrid pipeline: BM25 + Vector + MultiQuery + CrossEncoder Reranker + Graph.
|
| 121 |
+
Enhanced with doc-type pre-filtering for higher precision.
|
| 122 |
"""
|
| 123 |
hybrid, _ = _get_retrievers()
|
| 124 |
+
|
| 125 |
+
# Augment query for claim/eligibility-related searches
|
| 126 |
+
search_query = query
|
| 127 |
+
if any(kw in query.lower() for kw in ["claim", "diagnosis", "eligib"]):
|
| 128 |
+
search_query = f"claim submission eligibility diagnosis {query}"
|
| 129 |
+
|
| 130 |
+
docs = hybrid.invoke(search_query)
|
| 131 |
+
|
| 132 |
+
# Boost with doc-type pre-filtered results if a strong signal is found
|
| 133 |
+
inferred_type = _infer_doc_type(query)
|
| 134 |
+
if inferred_type:
|
| 135 |
+
try:
|
| 136 |
+
from retrieval.retriever import _load_vectorstore
|
| 137 |
+
vs = _load_vectorstore()
|
| 138 |
+
type_docs = _get_doc_type_filtered_docs(vs, query, inferred_type, k=3)
|
| 139 |
+
if type_docs:
|
| 140 |
+
# Prepend targeted docs β they're likely more relevant
|
| 141 |
+
seen = {d.page_content[:50] for d in docs}
|
| 142 |
+
new_type_docs = [d for d in type_docs if d.page_content[:50] not in seen]
|
| 143 |
+
docs = new_type_docs + docs
|
| 144 |
+
except Exception:
|
| 145 |
+
pass # Graceful fallback to standard retrieval
|
| 146 |
+
|
| 147 |
result = _format_docs(docs)
|
| 148 |
return result if result else "No relevant policy information found."
|
| 149 |
|
|
|
|
| 171 |
Use this when the user wants to compare plans or asks about a specific plan tier.
|
| 172 |
Runs the full hybrid retrieval pipeline with a tier-augmented query, then
|
| 173 |
prioritises documents whose metadata or content mentions that tier.
|
| 174 |
+
Enhanced: uses ChromaDB metadata pre-filter for plan_tier before hybrid search.
|
| 175 |
"""
|
| 176 |
hybrid, _ = _get_retrievers()
|
| 177 |
|
| 178 |
+
# Tier-augmented query forces relevant docs up the ranking
|
| 179 |
tier_query = f"{tier} plan {query}"
|
| 180 |
docs = hybrid.invoke(tier_query)
|
| 181 |
|
| 182 |
+
# Guarantee tier-specific documents via metadata pre-filter (high precision boost)
|
| 183 |
try:
|
| 184 |
from retrieval.retriever import _load_vectorstore
|
| 185 |
vs = _load_vectorstore()
|
| 186 |
+
|
| 187 |
+
# Pre-filter by both plan_tier AND inferred doc_type for maximum precision
|
| 188 |
+
tier_specific_docs = vs.similarity_search(query, k=4, filter={"plan_tier": tier.capitalize()})
|
| 189 |
docs = tier_specific_docs + docs
|
| 190 |
+
except Exception:
|
| 191 |
pass
|
| 192 |
|
| 193 |
+
# Filter to tier-relevant docs
|
| 194 |
tier_docs = [
|
| 195 |
d for d in docs
|
| 196 |
if d.metadata.get("plan_tier", "").lower() in [tier.lower(), "all"]
|
| 197 |
or tier.lower() in d.page_content.lower()
|
| 198 |
+
or d.metadata.get("doc_type") in ("sbc", f"summary_of_benefits_{tier.lower()}")
|
| 199 |
]
|
| 200 |
if not tier_docs:
|
| 201 |
+
tier_docs = docs # Fallback: use all results
|
| 202 |
|
| 203 |
result = _format_docs(tier_docs, max_per_tool=5)
|
| 204 |
return result if result else f"No {tier} plan information found for this query."
|
|
|
|
| 213 |
requirements for drugs or procedures, or PA criteria.
|
| 214 |
Runs the full hybrid pipeline with a PA-focused query and filters to
|
| 215 |
documents tagged as prior_authorization type or containing PA keywords.
|
| 216 |
+
Enhanced: uses ChromaDB metadata pre-filter for prior_authorization docs.
|
| 217 |
"""
|
| 218 |
hybrid, _ = _get_retrievers()
|
| 219 |
|
| 220 |
+
auth_query = f"prior authorization requirements {query}"
|
| 221 |
docs = hybrid.invoke(auth_query)
|
| 222 |
|
| 223 |
+
# Boost with metadata-filtered PA docs
|
| 224 |
+
try:
|
| 225 |
+
from retrieval.retriever import _load_vectorstore
|
| 226 |
+
vs = _load_vectorstore()
|
| 227 |
+
pa_docs = _get_doc_type_filtered_docs(vs, query, "prior_authorization", k=3)
|
| 228 |
+
if pa_docs:
|
| 229 |
+
seen = {d.page_content[:50] for d in docs}
|
| 230 |
+
new_pa = [d for d in pa_docs if d.page_content[:50] not in seen]
|
| 231 |
+
docs = new_pa + docs
|
| 232 |
+
except Exception:
|
| 233 |
+
pass
|
| 234 |
+
|
| 235 |
+
pa_keywords = {
|
| 236 |
+
"prior auth", "authorization required", "step therapy",
|
| 237 |
+
"pre-approval", "pre-authorization", "pa required", "preauth",
|
| 238 |
+
"prior authorization", "requires authorization",
|
| 239 |
+
}
|
| 240 |
|
| 241 |
auth_docs = [
|
| 242 |
d for d in docs
|
| 243 |
+
if d.metadata.get("doc_type") in ("prior_authorization",)
|
| 244 |
or any(kw in d.page_content.lower() for kw in pa_keywords)
|
| 245 |
]
|
| 246 |
|
| 247 |
if not auth_docs:
|
| 248 |
return "No prior authorization information found for this query."
|
| 249 |
|
| 250 |
+
result = _format_docs(auth_docs, max_per_tool=4)
|
| 251 |
return result if result else "No prior authorization information found for this query."
|
| 252 |
|
| 253 |
|
orchestration/tracing.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
| 1 |
import contextvars
|
|
|
|
| 2 |
|
| 3 |
# ContextVar to store a list of trace logs for the current request thread
|
| 4 |
trace_log = contextvars.ContextVar("trace_log", default=None)
|
|
@@ -8,3 +9,17 @@ def log_event(msg: str):
|
|
| 8 |
log = trace_log.get()
|
| 9 |
if log is not None:
|
| 10 |
log.append(msg)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import contextvars
|
| 2 |
+
import concurrent.futures
|
| 3 |
|
| 4 |
# ContextVar to store a list of trace logs for the current request thread
|
| 5 |
trace_log = contextvars.ContextVar("trace_log", default=None)
|
|
|
|
| 9 |
log = trace_log.get()
|
| 10 |
if log is not None:
|
| 11 |
log.append(msg)
|
| 12 |
+
else:
|
| 13 |
+
print(f"TRACE_LOG IS NONE FOR MSG: {msg}", flush=True)
|
| 14 |
+
|
| 15 |
+
# Global Monkey Patch: Ensure all ThreadPoolExecutor threads inherit contextvars
|
| 16 |
+
_original_submit = concurrent.futures.ThreadPoolExecutor.submit
|
| 17 |
+
|
| 18 |
+
def _patched_submit(self, fn, *args, **kwargs):
|
| 19 |
+
ctx = contextvars.copy_context()
|
| 20 |
+
def _wrapper(*wargs, **wkwargs):
|
| 21 |
+
return ctx.run(fn, *wargs, **wkwargs)
|
| 22 |
+
return _original_submit(self, _wrapper, *args, **kwargs)
|
| 23 |
+
|
| 24 |
+
concurrent.futures.ThreadPoolExecutor.submit = _patched_submit
|
| 25 |
+
|
requirements.txt
CHANGED
|
@@ -41,6 +41,7 @@ fastapi>=0.115.0
|
|
| 41 |
uvicorn[standard]>=0.30.0
|
| 42 |
pydantic>=2.0.0
|
| 43 |
loguru>=0.7.0
|
|
|
|
| 44 |
|
| 45 |
# Web Frontend (Streamlit)
|
| 46 |
streamlit>=1.40.0
|
|
|
|
| 41 |
uvicorn[standard]>=0.30.0
|
| 42 |
pydantic>=2.0.0
|
| 43 |
loguru>=0.7.0
|
| 44 |
+
redis>=5.0.0
|
| 45 |
|
| 46 |
# Web Frontend (Streamlit)
|
| 47 |
streamlit>=1.40.0
|
retrieval/graph_retriever.py
CHANGED
|
@@ -17,6 +17,7 @@ from langchain_core.documents import Document
|
|
| 17 |
from rich.console import Console
|
| 18 |
|
| 19 |
from config import GRAPH_DATA_PATH
|
|
|
|
| 20 |
|
| 21 |
# ββ Medical synonym expansion map ββββββββββββββββββββββββββββ
|
| 22 |
# Maps colloquial / lay terms β canonical specialty names in the graph
|
|
@@ -114,6 +115,7 @@ class GraphRetriever:
|
|
| 114 |
return ""
|
| 115 |
data = self.G.nodes[node]
|
| 116 |
node_type = data.get("type", "Unknown")
|
|
|
|
| 117 |
|
| 118 |
context = f"### GRAPH ENTITY: {node} ({node_type})\n"
|
| 119 |
props = [f"{k}: {v}" for k, v in data.items() if k != "type"]
|
|
@@ -130,6 +132,9 @@ class GraphRetriever:
|
|
| 130 |
target_type = target_data.get("type", "")
|
| 131 |
target_name = target_data.get("name", "")
|
| 132 |
|
|
|
|
|
|
|
|
|
|
| 133 |
display_target = f"{target_name} ({target})" if target_name else str(target)
|
| 134 |
context += f" - [{rel}] -> {display_target} ({target_type})"
|
| 135 |
|
|
@@ -165,6 +170,9 @@ class GraphRetriever:
|
|
| 165 |
source_type = source_data.get("type", "")
|
| 166 |
source_name = source_data.get("name", "")
|
| 167 |
|
|
|
|
|
|
|
|
|
|
| 168 |
display_source = f"{source_name} (NPI: {source})" if source_name and source_type == "Provider" else str(source)
|
| 169 |
context += f" - {display_source} ({source_type}) -> [{rel}] -> [THIS ENTITY]"
|
| 170 |
|
|
@@ -249,7 +257,10 @@ class GraphRetriever:
|
|
| 249 |
context += f"Note: No in-network {spec_label} providers found in {city}. "
|
| 250 |
context += f"Showing the closest in-network options in {state}:\n\n"
|
| 251 |
for npi, d in same_state[:8]:
|
| 252 |
-
|
|
|
|
|
|
|
|
|
|
| 253 |
f"{d.get('city','')}, {d.get('state','')} (NPI: {npi})\n")
|
| 254 |
return context
|
| 255 |
|
|
@@ -269,7 +280,10 @@ class GraphRetriever:
|
|
| 269 |
for st, providers in list(by_state.items())[:5]:
|
| 270 |
context += f"**{st}:**\n"
|
| 271 |
for npi, d in providers[:4]:
|
| 272 |
-
|
|
|
|
|
|
|
|
|
|
| 273 |
f"{d.get('city','')}, {st} (NPI: {npi})\n")
|
| 274 |
return context
|
| 275 |
|
|
@@ -279,8 +293,10 @@ class GraphRetriever:
|
|
| 279 |
"""Perform graph retrieval and return LangChain Documents."""
|
| 280 |
entities = self._extract_entities(query)
|
| 281 |
if not entities:
|
|
|
|
| 282 |
return []
|
| 283 |
|
|
|
|
| 284 |
docs = []
|
| 285 |
|
| 286 |
# 1. Add intersection context if multiple entities found
|
|
|
|
| 17 |
from rich.console import Console
|
| 18 |
|
| 19 |
from config import GRAPH_DATA_PATH
|
| 20 |
+
from orchestration.tracing import log_event
|
| 21 |
|
| 22 |
# ββ Medical synonym expansion map ββββββββββββββββββββββββββββ
|
| 23 |
# Maps colloquial / lay terms β canonical specialty names in the graph
|
|
|
|
| 115 |
return ""
|
| 116 |
data = self.G.nodes[node]
|
| 117 |
node_type = data.get("type", "Unknown")
|
| 118 |
+
node_display = data.get("name", str(node))
|
| 119 |
|
| 120 |
context = f"### GRAPH ENTITY: {node} ({node_type})\n"
|
| 121 |
props = [f"{k}: {v}" for k, v in data.items() if k != "type"]
|
|
|
|
| 132 |
target_type = target_data.get("type", "")
|
| 133 |
target_name = target_data.get("name", "")
|
| 134 |
|
| 135 |
+
target_display = target_name if target_name else str(target)
|
| 136 |
+
log_event(f"[GraphDB-Edge] {node_display}|||{rel}|||{target_display}")
|
| 137 |
+
|
| 138 |
display_target = f"{target_name} ({target})" if target_name else str(target)
|
| 139 |
context += f" - [{rel}] -> {display_target} ({target_type})"
|
| 140 |
|
|
|
|
| 170 |
source_type = source_data.get("type", "")
|
| 171 |
source_name = source_data.get("name", "")
|
| 172 |
|
| 173 |
+
source_display = source_name if source_name else str(source)
|
| 174 |
+
log_event(f"[GraphDB-Edge] {source_display}|||{rel}|||{node_display}")
|
| 175 |
+
|
| 176 |
display_source = f"{source_name} (NPI: {source})" if source_name and source_type == "Provider" else str(source)
|
| 177 |
context += f" - {display_source} ({source_type}) -> [{rel}] -> [THIS ENTITY]"
|
| 178 |
|
|
|
|
| 257 |
context += f"Note: No in-network {spec_label} providers found in {city}. "
|
| 258 |
context += f"Showing the closest in-network options in {state}:\n\n"
|
| 259 |
for npi, d in same_state[:8]:
|
| 260 |
+
provider_name = d.get('name', str(npi))
|
| 261 |
+
log_event(f"[GraphDB-Edge] {provider_name}|||located in|||{d.get('state','')}")
|
| 262 |
+
log_event(f"[GraphDB-Edge] {provider_name}|||specializes in|||{d.get('specialty','')}")
|
| 263 |
+
context += (f"- {provider_name} | {d.get('specialty','')} | "
|
| 264 |
f"{d.get('city','')}, {d.get('state','')} (NPI: {npi})\n")
|
| 265 |
return context
|
| 266 |
|
|
|
|
| 280 |
for st, providers in list(by_state.items())[:5]:
|
| 281 |
context += f"**{st}:**\n"
|
| 282 |
for npi, d in providers[:4]:
|
| 283 |
+
provider_name = d.get('name', str(npi))
|
| 284 |
+
log_event(f"[GraphDB-Edge] {provider_name}|||located in|||{st}")
|
| 285 |
+
log_event(f"[GraphDB-Edge] {provider_name}|||specializes in|||{d.get('specialty','')}")
|
| 286 |
+
context += (f" - {provider_name} | {d.get('specialty','')} | "
|
| 287 |
f"{d.get('city','')}, {st} (NPI: {npi})\n")
|
| 288 |
return context
|
| 289 |
|
|
|
|
| 293 |
"""Perform graph retrieval and return LangChain Documents."""
|
| 294 |
entities = self._extract_entities(query)
|
| 295 |
if not entities:
|
| 296 |
+
log_event("[GraphDB] No entities matched.")
|
| 297 |
return []
|
| 298 |
|
| 299 |
+
log_event(f"[GraphDB] Retrieved entities: {', '.join(entities)}")
|
| 300 |
docs = []
|
| 301 |
|
| 302 |
# 1. Add intersection context if multiple entities found
|
retrieval/retriever.py
CHANGED
|
@@ -180,6 +180,43 @@ def get_hybrid_retriever(
|
|
| 180 |
|
| 181 |
console.print("\nβοΈ Building retrieval pipeline...", style="bold cyan")
|
| 182 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
# ββ Stage 1: Vector Retriever βββββββββββββββββββββββββββββββ
|
| 184 |
console.print(" π¦ Loading ChromaDB vector store...")
|
| 185 |
vectorstore = _load_vectorstore()
|
|
@@ -187,17 +224,19 @@ def get_hybrid_retriever(
|
|
| 187 |
search_type="similarity",
|
| 188 |
search_kwargs={"k": k},
|
| 189 |
)
|
|
|
|
| 190 |
console.print(f" β
Vector retriever ready (k={k})")
|
| 191 |
|
| 192 |
# ββ Stage 2: BM25 Retriever βββββββββββββββββββββββββββββββββ
|
| 193 |
console.print(" π Building BM25 index from stored documents...")
|
| 194 |
all_docs = _get_all_documents(vectorstore)
|
| 195 |
bm25_retriever = BM25Retriever.from_documents(all_docs, k=k)
|
|
|
|
| 196 |
console.print(f" β
BM25 retriever ready (k={k}, {len(all_docs)} documents indexed)")
|
| 197 |
|
| 198 |
# ββ Stage 3: Ensemble Retriever βββββββββββββββββββββββββββββ
|
| 199 |
ensemble_retriever = EnsembleRetriever(
|
| 200 |
-
retrievers=[
|
| 201 |
weights=weights,
|
| 202 |
)
|
| 203 |
console.print(f" β
Ensemble retriever ready (weights: BM25={weights[0]}, Vector={weights[1]})")
|
|
|
|
| 180 |
|
| 181 |
console.print("\nβοΈ Building retrieval pipeline...", style="bold cyan")
|
| 182 |
|
| 183 |
+
class LoggingRetriever(Runnable):
|
| 184 |
+
def __init__(self, base_retriever, name):
|
| 185 |
+
self.base_retriever = base_retriever
|
| 186 |
+
self.name = name
|
| 187 |
+
|
| 188 |
+
def invoke(self, query: str, *args, **kwargs) -> list[Document]:
|
| 189 |
+
# Custom logic to extract scores for visualization
|
| 190 |
+
if self.name == "VectorDB":
|
| 191 |
+
# base_retriever is VectorStoreRetriever
|
| 192 |
+
docs_and_scores = self.base_retriever.vectorstore.similarity_search_with_score(query, **self.base_retriever.search_kwargs)
|
| 193 |
+
docs = []
|
| 194 |
+
for d, score in docs_and_scores:
|
| 195 |
+
d.metadata["score"] = score
|
| 196 |
+
docs.append(d)
|
| 197 |
+
elif self.name == "BM25":
|
| 198 |
+
# base_retriever is BM25Retriever
|
| 199 |
+
docs = self.base_retriever.invoke(query, *args, **kwargs)
|
| 200 |
+
try:
|
| 201 |
+
q_tokens = self.base_retriever.preprocess_func(query)
|
| 202 |
+
scores = self.base_retriever.vectorizer.get_scores(q_tokens)
|
| 203 |
+
# Assign the corresponding score based on the original document index
|
| 204 |
+
for d in docs:
|
| 205 |
+
idx = self.base_retriever.docs.index(d)
|
| 206 |
+
d.metadata["score"] = scores[idx]
|
| 207 |
+
except Exception:
|
| 208 |
+
pass
|
| 209 |
+
else:
|
| 210 |
+
docs = self.base_retriever.invoke(query, *args, **kwargs)
|
| 211 |
+
|
| 212 |
+
for i, d in enumerate(docs[:4]):
|
| 213 |
+
source = d.metadata.get("source_file", "unknown")
|
| 214 |
+
page = d.metadata.get("page", "?")
|
| 215 |
+
score = d.metadata.get("score", 0.0)
|
| 216 |
+
# Format: [Name] Retrieved: source | Score: 1.23 | Content: ...
|
| 217 |
+
log_event(f"[{self.name}] Retrieved: {source} (Page: {page})|{score:.4f}|{d.page_content[:40]}...")
|
| 218 |
+
return docs
|
| 219 |
+
|
| 220 |
# ββ Stage 1: Vector Retriever βββββββββββββββββββββββββββββββ
|
| 221 |
console.print(" π¦ Loading ChromaDB vector store...")
|
| 222 |
vectorstore = _load_vectorstore()
|
|
|
|
| 224 |
search_type="similarity",
|
| 225 |
search_kwargs={"k": k},
|
| 226 |
)
|
| 227 |
+
logging_vector_retriever = LoggingRetriever(vector_retriever, "VectorDB")
|
| 228 |
console.print(f" β
Vector retriever ready (k={k})")
|
| 229 |
|
| 230 |
# ββ Stage 2: BM25 Retriever βββββββββββββββββββββββββββββββββ
|
| 231 |
console.print(" π Building BM25 index from stored documents...")
|
| 232 |
all_docs = _get_all_documents(vectorstore)
|
| 233 |
bm25_retriever = BM25Retriever.from_documents(all_docs, k=k)
|
| 234 |
+
logging_bm25_retriever = LoggingRetriever(bm25_retriever, "BM25")
|
| 235 |
console.print(f" β
BM25 retriever ready (k={k}, {len(all_docs)} documents indexed)")
|
| 236 |
|
| 237 |
# ββ Stage 3: Ensemble Retriever βββββββββββββββββββββββββββββ
|
| 238 |
ensemble_retriever = EnsembleRetriever(
|
| 239 |
+
retrievers=[logging_bm25_retriever, logging_vector_retriever],
|
| 240 |
weights=weights,
|
| 241 |
)
|
| 242 |
console.print(f" β
Ensemble retriever ready (weights: BM25={weights[0]}, Vector={weights[1]})")
|