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 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"), # LangSmith trace UUID (None if tracing off)
 
 
 
 
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):] # only new lines
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) # minimal pause for smooth UI without blocking stream
257
 
258
- # Signal node completion
259
  final_answer = state.get("answer", "")
260
  yield emit({
261
- "type": "node_done",
262
- "node": node,
263
- "intent": intent,
264
- "steps": current_steps,
265
- "answer": final_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
- Detailed steps are added as chained nodes attached to Retrieval.
380
  """
 
381
  dot = Digraph(comment='Workflow')
382
  dot.attr(rankdir='LR')
383
- # Core nodes
384
- dot.node('A', 'FastAPI Endpoint')
385
- dot.node('B', 'Orchestrator')
386
- dot.node('C', 'Intent Classification')
387
- dot.node('D', 'Retrieval Pipeline')
388
- dot.node('E', 'Synthesis Agent')
389
- dot.node('F', 'Answer')
390
- # Edges
391
- dot.edge('A', 'B')
392
- dot.edge('B', 'C')
393
- dot.edge('C', 'D')
394
- dot.edge('D', 'E')
395
- dot.edge('E', 'F')
396
- # Detailed steps subgraph
397
- if steps:
398
- with dot.subgraph(name='cluster_details') as c:
399
- c.attr(label='Retrieval Details')
400
- prev = None
401
- for i, s in enumerate(steps, start=1):
402
- node_id = f'S{i}'
403
- safe = s.replace('"', '\\"')
404
- c.node(node_id, safe, shape='box')
405
- if i == 1:
406
- dot.edge('D', node_id)
407
- else:
408
- c.edge(prev, node_id)
409
- prev = node_id
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- The diagram will have the form:
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
- lines.append(' A[FastAPI Endpoint] --> B[Orchestrator]')
422
- lines.append(' B --> C[Intent Classification]')
423
- lines.append(' C --> D[Retrieval Pipeline]')
424
- lines.append(' D --> E[Synthesis Agent]')
425
- lines.append(' E --> F[Answer]')
426
- # Add detailed steps as a vertical sub‑graph attached to D
427
- if steps:
428
- lines.append(' subgraph Details [Retrieval Details]')
429
- for i, s in enumerate(steps, start=1):
430
- # sanitize characters that break mermaid syntax
431
- safe = s.replace('"', '\\"')
432
- node_id = f"S{i}"
433
- lines.append(f' {node_id}["{safe}"]')
434
- if i == 1:
435
- lines.append(f' D --> {node_id}')
436
- else:
437
- lines.append(f' S{i-1} --> {node_id}')
438
- lines.append(' end')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 # 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
 
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 nodes in a directed StateGraph):
5
 
6
  START
7
  β”‚
8
  β–Ό
9
- [1. memory_search] ← NEW: Retrieve relevant past facts from Mem0
 
 
 
10
  β”‚
11
  β–Ό
12
- [2. classify_intent]
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
- [3. retrieve]
18
- Routes internally by intent to the right retrieval strategy:
19
- β”Œβ”€ SIMPLE_LOOKUP β†’ Graph entity lookup β†’ Hybrid retrieval
20
- β”œβ”€ POLICY_QUESTION β†’ Hybrid retrieval only
21
- β”œβ”€ MULTI_HOP β†’ Graph β†’ Policy β†’ Prior-Auth (3 sequential steps)
22
- └─ COMPARISON β†’ Hybrid retrieval per plan tier (Bronze / Silver / Gold)
23
  β”‚
24
  β–Ό
25
- [4. synthesize]
 
 
 
26
  Uses GPT-4o to generate a cited, safety-compliant final answer
27
  β”‚
28
  β–Ό
29
- [5. memory_add] ← NEW: Persist Q&A facts to Mem0 for future sessions
 
 
 
 
 
 
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 β€” the shared data bag that flows through all nodes
84
  # ══════════════════════════════════════════════════════════════
85
 
86
  class AgentState(TypedDict):
87
- """
88
- What gets passed between every node in the graph.
89
- Each node receives this dict and returns an updated copy.
90
- """
91
- query: str # Original user question
92
- user_id: str # Session identifier (for logging)
93
- intent: str # Classified intent (set by classify_intent)
94
- retrieved_context: str # All retrieved text (set by retrieve)
95
- past_memories: str # Relevant session memories (set by memory_search)
96
- memories_used: List[str] # Facts stored to Mem0 (set by memory_add)
97
- answer: str # Final answer (set by synthesize)
98
- chat_history: List[tuple] # [(role, message), ...] β€” last 5 turns
99
- steps_log: List[str] # Human-readable trace of what happened
 
 
 
 
 
 
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 1 β€” memory_search (NEW)
126
  # ══════════════════════════════════════════════════════════════
127
 
128
- def memory_search(state: AgentState) -> AgentState:
129
- """NODE 1: Retrieve relevant past memories from Mem0 before classification."""
130
- query = state["query"]
131
- user_id = state.get("user_id", "default")
132
- log = list(state.get("steps_log", []))
133
-
134
- memories = search_memories(user_id=user_id, query=query)
135
- if memories:
136
- log.append(f"🧠 Mem0 recalled {memories.count(chr(10) + ' ')+1} memory fact(s) for user")
137
- else:
138
- log.append("🧠 Mem0: no relevant past memories found")
 
 
 
139
 
140
- return {**state, "past_memories": memories, "steps_log": log}
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 2: Smart Retrieval."""
198
- query = state["query"]
199
- intent = state["intent"]
200
- log = list(state.get("steps_log", []))
 
 
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
- # ── SIMPLE_LOOKUP ────────────────────────────────────────────
 
 
 
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
- log.append("πŸ”— [MULTI_HOP] Executing Graph, Policy, and Prior-Auth retrievals concurrently")
 
 
 
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
- # ── COMPARISON ───────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
268
  elif intent == "COMPARISON":
269
- log.append(f"βš–οΈ [COMPARISON] Retrieving Bronze, Silver, and Gold plan contexts concurrently")
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 = "\n\n" + "─" * 60 + "\n\n"
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 (e.g., formatting, language, or specific plan details) found in the PAST USER MEMORIES section.
318
- If the user asks for entities with multiple criteria (e.g., "Specialist X in City Y"), you MUST verify that the same entity satisfies ALL criteria in the context. Do NOT assume an entity has a property just because it is listed in the context alongside another entity.
319
- Always cite the source file and page number for every fact you state EXACTLY as it appears in the context (e.g., "(Source: filename.pdf, Page: 2)"). Failure to include the page number is a strict violation.
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 3: Answer Synthesis."""
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
- # 7. PUBLIC ORCHESTRATOR CLASS
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 # Mem0 in-memory instance for this session
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 StateGraph with memory nodes bound to this session."""
382
  _search = self._search_memories
383
  _add = self._add_memory
384
 
385
- def memory_search_node(state: AgentState) -> AgentState:
386
- """NODE 1: Retrieve relevant facts from this session's Mem0."""
 
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
- """NODE 5: Persist Q&A facts back to this session's Mem0."""
 
 
 
 
 
 
399
  stored = _add(state["query"], state["answer"])
400
  log = list(state.get("steps_log", []))
401
- log.append(f"πŸ’Ύ Mem0 stored {len(stored)} fact(s) from this turn" if stored else "πŸ’Ύ Mem0: no new facts extracted")
 
 
 
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.set_entry_point("memory_search")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
412
  builder.add_edge("memory_search", "classify_intent")
413
- builder.add_edge("classify_intent", "retrieve")
414
- builder.add_edge("retrieve", "synthesize")
415
- builder.add_edge("synthesize", "memory_add")
416
- builder.add_edge("memory_add", END)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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={"query_length": len(query)},
 
 
 
 
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, # None when tracing is disabled
 
 
 
 
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
- # We yield the current state and which node just finished
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
- The underlying retrieval pipeline (BM25 + Vector + MultiQuery + CrossEncoder + Graph)
11
- is UNCHANGED β€” these tools are thin wrappers that control WHAT to query and HOW to filter.
 
 
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 (built once, reused across all tool calls) ──
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
- if "claim" in query.lower() or "diagnosis" in query.lower() or "eligib" in query.lower():
67
- docs = hybrid.invoke(f"claim submission eligibility diagnosis {query}")
68
- else:
69
- docs = hybrid.invoke(query)
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
- # Augment the query with the specific tier to force relevant documents up the ranking
102
  tier_query = f"{tier} plan {query}"
103
  docs = hybrid.invoke(tier_query)
104
 
105
- # Guarantee tier-specific documents (like the SBC deductibles table) are included
106
  try:
107
  from retrieval.retriever import _load_vectorstore
108
  vs = _load_vectorstore()
109
- tier_specific_docs = vs.similarity_search(query, k=3, filter={"plan_tier": tier.capitalize()})
 
 
110
  docs = tier_specific_docs + docs
111
- except Exception as e:
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") == "sbc"
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
- pa_keywords = {"prior auth", "authorization required", "step therapy",
143
- "pre-approval", "pre-authorization", "pa required", "preauth"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
144
 
145
  auth_docs = [
146
  d for d in docs
147
- if d.metadata.get("doc_type") == "prior_authorization"
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=3)
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
- context += (f"- {d.get('name','Unknown')} | {d.get('specialty','')} | "
 
 
 
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
- context += (f" - {d.get('name','Unknown')} | {d.get('specialty','')} | "
 
 
 
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=[bm25_retriever, vector_retriever],
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]})")