Nagendravarma commited on
Commit
fbd8eda
Β·
1 Parent(s): a74c657

Optimize dev console, add benchmark presets, filter knowledge graph, and fix specialist copay retrieval

Browse files
backend/main.py CHANGED
@@ -233,7 +233,8 @@ async def chat_stream(session_id: str, query: str, plan_tier: str = "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"]):
@@ -250,6 +251,20 @@ async def chat_stream(session_id: str, query: str, plan_tier: str = "Unknown"):
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
 
@@ -312,11 +327,36 @@ async def chat_stream(session_id: str, query: str, plan_tier: str = "Unknown"):
312
 
313
  # ── Startup handshake events ──────────────────────────────
314
  yield emit({"type": "node_start", "node": "user", "msg": query})
315
- await asyncio.sleep(0.25)
316
  yield emit({"type": "node_start", "node": "fastapi", "msg": "POST /chat/stream received"})
317
- await asyncio.sleep(0.25)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
318
  yield emit({"type": "node_start", "node": "orchestrator", "msg": "Building LangGraph state…"})
319
- await asyncio.sleep(0.35)
320
 
321
  prev_steps: list[str] = []
322
  final_answer = ""
@@ -396,7 +436,7 @@ async def chat_stream(session_id: str, query: str, plan_tier: str = "Unknown"):
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)}")
 
233
  final_query = f"I am on the {plan_tier} plan. {query}"
234
 
235
  # Check Semantic Cache
236
+ norm_query = cache_manager.normalize_query(final_query)
237
+ cached_result = cache_manager.check(final_query, plan_tier=plan_tier, normalized_query=norm_query)
238
  if cached_result:
239
  # Sync orchestrator history & trace
240
  if len(orch.chat_history) == 0 or orch.chat_history[-1] != ("ai", cached_result["answer"]):
 
251
  await asyncio.sleep(0.15)
252
  yield emit({"type": "node_start", "node": "fastapi", "msg": "POST /chat/stream received"})
253
  await asyncio.sleep(0.15)
254
+
255
+ # Query Analyzer
256
+ yield emit({"type": "node_start", "node": "query_analyzer", "msg": "Analyzing search intent & phrasing..."})
257
+ await asyncio.sleep(0.2)
258
+ yield emit({
259
+ "type": "substep",
260
+ "node": "query_analyzer",
261
+ "step": f"Normalized query: '{final_query[:40]}...' -> '{norm_query}'"
262
+ })
263
+ await asyncio.sleep(0.2)
264
+ yield emit({"type": "node_done", "node": "query_analyzer", "normalized_query": norm_query})
265
+ await asyncio.sleep(0.15)
266
+
267
+ # Redis Cache Check
268
  yield emit({"type": "node_start", "node": "semantic_cache", "msg": "Checking semantic cache…"})
269
  await asyncio.sleep(0.2)
270
 
 
327
 
328
  # ── Startup handshake events ──────────────────────────────
329
  yield emit({"type": "node_start", "node": "user", "msg": query})
330
+ await asyncio.sleep(0.15)
331
  yield emit({"type": "node_start", "node": "fastapi", "msg": "POST /chat/stream received"})
332
+ await asyncio.sleep(0.15)
333
+
334
+ # Query Analyzer
335
+ yield emit({"type": "node_start", "node": "query_analyzer", "msg": "Analyzing search intent & phrasing..."})
336
+ await asyncio.sleep(0.2)
337
+ yield emit({
338
+ "type": "substep",
339
+ "node": "query_analyzer",
340
+ "step": f"Normalized query: '{final_query[:40]}...' -> '{norm_query}'"
341
+ })
342
+ await asyncio.sleep(0.2)
343
+ yield emit({"type": "node_done", "node": "query_analyzer", "normalized_query": norm_query})
344
+ await asyncio.sleep(0.15)
345
+
346
+ # Redis Cache Check
347
+ yield emit({"type": "node_start", "node": "semantic_cache", "msg": "Checking semantic cache…"})
348
+ await asyncio.sleep(0.2)
349
+ yield emit({
350
+ "type": "substep",
351
+ "node": "semantic_cache",
352
+ "step": "Cache MISS. No matching normalized query found."
353
+ })
354
+ await asyncio.sleep(0.15)
355
+ yield emit({"type": "node_done", "node": "semantic_cache", "msg": "Proceeding to full RAG pipeline"})
356
+ await asyncio.sleep(0.15)
357
+
358
  yield emit({"type": "node_start", "node": "orchestrator", "msg": "Building LangGraph state…"})
359
+ await asyncio.sleep(0.2)
360
 
361
  prev_steps: list[str] = []
362
  final_answer = ""
 
436
  "confidence_reason": last_state.get("confidence_reason", ""),
437
  "sub_questions": last_state.get("sub_questions", []),
438
  }
439
+ cache_manager.store(final_query, cache_payload, plan_tier=plan_tier, normalized_query=norm_query)
440
 
441
  except Exception as e:
442
  logger.error(f"Error in chat_stream: {str(e)}")
frontend/dev_console.html CHANGED
@@ -157,6 +157,11 @@ body {
157
  }
158
  .preset-item:hover { background: rgba(99,102,241,0.1); border-color: rgba(99,102,241,0.3); color: #a5b4fc; }
159
  .preset-icon { flex-shrink: 0; margin-top: 1px; }
 
 
 
 
 
160
 
161
  .run-btn {
162
  width: 100%; margin-top: 10px; padding: 11px; border: none; border-radius: 9px;
@@ -351,6 +356,8 @@ svg { width:100%; height:100%; overflow:visible; }
351
  .n.active rect { fill:rgba(124,58,237,0.25); stroke:#a78bfa; stroke-width:2; filter:drop-shadow(0 0 10px rgba(139,92,246,0.5)); }
352
  .n.done rect { fill:rgba(16,185,129,0.13); stroke:#10b981; stroke-width:1.8; }
353
  .n.skip rect { fill:#050811; stroke:rgba(255,255,255,0.03); opacity:0.3; }
 
 
354
  .n .icon { font-size:14px; }
355
  .n .title { font-family:'Plus Jakarta Sans',sans-serif; font-size:10px; font-weight:700; fill:#e2e8f0; }
356
  .n .sub { font-family:'Plus Jakarta Sans',sans-serif; font-size:8px; fill:#64748b; font-weight:500; }
@@ -613,24 +620,27 @@ svg { width:100%; height:100%; overflow:visible; }
613
  πŸ“Œ Load demo query <span id="preset-arrow">β–Ύ</span>
614
  </button>
615
  <div class="preset-list" id="preset-list">
616
- <div class="preset-item" onclick="loadPreset(0)"><span class="preset-icon">βš–οΈ</span>Compare Bronze and Gold plan copays for Metformin</div>
617
- <div class="preset-item" onclick="loadPreset(1)"><span class="preset-icon">πŸ”—</span>Is Metformin covered and do I need prior authorization for it?</div>
618
- <div class="preset-item" onclick="loadPreset(2)"><span class="preset-icon">πŸ‘¨β€βš•οΈ</span>Which cardiologists are in-network in Chicago?</div>
619
- <div class="preset-item" onclick="loadPreset(3)"><span class="preset-icon">πŸ“‹</span>What are the prior authorization requirements for MRI scans?</div>
620
- <div class="preset-item" onclick="loadPreset(4)"><span class="preset-icon">πŸ₯</span>What preventive care is covered at no cost on the Silver plan?</div>
621
- <div class="preset-item" onclick="loadPreset(5)"><span class="preset-icon">πŸ’Š</span>What is the out-of-pocket maximum on the Gold plan and which diabetes drugs are covered?</div>
 
 
 
 
 
622
  </div>
623
  </div>
624
 
625
  <button class="run-btn" id="runbtn" onclick="run()">πŸš€ Execute Live Trace</button>
626
  </div>
627
 
628
- <!-- Intent + Confidence row -->
629
  <div class="meta-row" id="meta-row">
630
  <span class="intent-label">Intent</span>
631
  <div class="intent-pill" id="intent-pill">β€”</div>
632
- <span class="conf-label" style="margin-left:8px">Confidence</span>
633
- <div class="conf-badge" id="conf-badge">β€”</div>
634
  </div>
635
 
636
  <!-- Memory inspector -->
@@ -651,8 +661,6 @@ svg { width:100%; height:100%; overflow:visible; }
651
  <label>⚑ System Event Stream</label>
652
  <div class="log-controls">
653
  <input class="log-search" id="log-search" placeholder="filter…" oninput="filterLogs()">
654
- <button class="filter-btn ALL active" id="fb-ALL" onclick="setFilter('ALL')">ALL</button>
655
- <button class="filter-btn CLASSIFY" id="fb-CLASSIFY" onclick="setFilter('CLASSIFY')">CLS</button>
656
  <button class="filter-btn RETRIEVE" id="fb-RETRIEVE" onclick="setFilter('RETRIEVE')">RET</button>
657
  <button class="filter-btn SYNTH" id="fb-SYNTH" onclick="setFilter('SYNTH')">SYN</button>
658
  <button class="filter-btn ERROR" id="fb-ERROR" onclick="setFilter('ERROR')">ERR</button>
@@ -734,12 +742,14 @@ document.getElementById('pass-input').addEventListener('keydown', e => { if (e.k
734
 
735
  // ── PRESET QUERIES ───────────────────────────────────────────────────────────
736
  const PRESETS = [
737
- 'Compare Bronze and Gold plan copays for Metformin',
738
- 'Is Metformin covered and do I need prior authorization for it?',
739
- 'Which cardiologists are in-network in Chicago?',
740
- 'What are the prior authorization requirements for MRI scans?',
741
- 'What preventive care is covered at no cost on the Silver plan?',
742
- 'What is the out-of-pocket maximum on the Gold plan and which diabetes drugs are covered?',
 
 
743
  ];
744
  function loadPreset(i) {
745
  document.getElementById('qinput').value = PRESETS[i];
@@ -818,7 +828,8 @@ let activeFilter = 'ALL';
818
  function setFilter(f) {
819
  activeFilter = f;
820
  document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
821
- document.getElementById('fb-' + (f === 'SYNTH' ? 'SYNTH' : f)).classList.add('active');
 
822
  filterLogs();
823
  }
824
  function filterLogs() {
@@ -848,6 +859,9 @@ function copyAnswer() {
848
 
849
  // ── DOWNLOAD TRACE ────────────────────────────────────────────────────────────
850
  let traceStore = [];
 
 
 
851
  function downloadTrace() {
852
  if (!traceStore.length) { alert('Run a query first to generate a trace.'); return; }
853
  const blob = new Blob([JSON.stringify(traceStore, null, 2)], { type: 'application/json' });
@@ -895,7 +909,8 @@ const NODES = [
895
  {id:'confidence', icon:'πŸ“Š', title:'Confidence Scorer', sub:'HIGH/MED/LOW rating', x:920, y:670}, // NEW
896
  {id:'mem_add', icon:'πŸ’Ύ', title:'Memory Commit', sub:'Mem0 fact extractor', x:1100, y:670},
897
  {id:'answer', icon:'πŸ’¬', title:'Response Output', sub:'Client socket stream', x:1300, y:670},
898
- {id:'redis', icon:'πŸ”΄', title:'Redis Cache', sub:'Semantic Vector Cache', x:290, y:150},
 
899
  ];
900
 
901
  const INTENT_PILLS = [
@@ -906,7 +921,7 @@ const INTENT_PILLS = [
906
  ];
907
 
908
  const EDGES = [
909
- ['user','fastapi'],['fastapi','orchestrator'],['orchestrator','query_guard'],
910
  ['query_guard','mem_search'],['mem_search','intent_node'],
911
  ['orchestrator','langsmith'],
912
  ['intent_node','query_decomposer'],['query_decomposer','retrieve_agent'],
@@ -921,7 +936,7 @@ const EDGES = [
921
  ['merger','synthesis'],['synthesis','langsmith'],
922
  ['synthesis','self_critique'],
923
  ['self_critique','confidence'],['confidence','mem_add'],['mem_add','answer'],
924
- ['fastapi','redis'],['redis','answer'],
925
  ];
926
 
927
  // Self-critique retry edge (back to retrieve β€” special styling)
@@ -971,6 +986,7 @@ const STEP_MAP = [
971
  ];
972
 
973
  const LG_START = {
 
974
  semantic_cache: ['redis'],
975
  query_guard: ['query_guard'],
976
  memory_search: ['mem_search'],
@@ -1080,6 +1096,7 @@ const NODE_DESC = {
1080
  langsmith: 'LangSmith observability: records full traces with latency, token usage, and intermediate states.',
1081
  mem_add: 'Mem0 fact extractor: persists key Q&A facts to session memory for future context personalization.',
1082
  answer: 'Final answer streamed back to the client via Server-Sent Events (SSE).',
 
1083
  redis: 'Cognitive Semantic Vector Cache: checks incoming queries against previously answered queries using GPT-4o-mini normalization and vector embeddings to bypass the orchestrator and LLM on cache hits.',
1084
  };
1085
 
@@ -1177,14 +1194,21 @@ function clearAll() {
1177
  document.getElementById('answer-card').style.display = 'none';
1178
  document.getElementById('answer-text').innerHTML = '';
1179
  document.getElementById('meta-row').classList.remove('visible');
1180
- document.getElementById('conf-badge').className = 'conf-badge';
1181
- document.getElementById('conf-badge').textContent = 'β€”';
1182
- document.getElementById('intent-pill').textContent = 'β€”';
 
 
 
 
 
 
1183
  Object.keys(nodeTimers).forEach(k => delete nodeTimers[k]);
1184
  Object.keys(nodeLatencies).forEach(k => delete nodeLatencies[k]);
1185
  multiQueryVariants = [];
1186
  nodeData = {};
1187
  traceStore = [];
 
1188
  }
1189
 
1190
  function addLog(icon, tag, tagClass, text) {
@@ -1209,6 +1233,7 @@ function activateFromStep(step, lgNode) {
1209
  for (const m of STEP_MAP) {
1210
  if (m.pat.test(step)) {
1211
  m.nodes.forEach(id => {
 
1212
  setNode(id, 'active');
1213
  const fromNode = lgNode === 'retrieve' ? 'retrieve_agent' : lgNode;
1214
  activateEdge(fromNode, id);
@@ -1263,8 +1288,9 @@ async function run() {
1263
  let elapsedTimer = setInterval(() => updateElapsed(performance.now()-startMs), 500);
1264
 
1265
  let activeNodeIds = [];
1266
- let currentIntent = '';
1267
- let currentConfidence = '';
 
1268
 
1269
  try {
1270
  const url = `${API}/chat/stream?session_id=${encodeURIComponent(SID)}&query=${encodeURIComponent(q)}`;
@@ -1290,6 +1316,11 @@ async function run() {
1290
  const totalMs = Math.round(performance.now() - startMs);
1291
  updateElapsed(totalMs);
1292
  setStatus(`βœ… Pipeline completed in ${(totalMs/1000).toFixed(2)}s`, 'done');
 
 
 
 
 
1293
  break;
1294
  }
1295
  try {
@@ -1311,6 +1342,9 @@ async function run() {
1311
 
1312
  function handleEvent(ev) {
1313
  const { type, node, intent, step, msg, answer, confidence, confidence_reason, blocked, sub_questions } = ev;
 
 
 
1314
 
1315
  if (intent && intent !== currentIntent) { currentIntent = intent; setIntent(intent); document.getElementById('meta-row').classList.add('visible'); }
1316
  if (confidence) setConfidence(confidence, confidence_reason);
@@ -1323,8 +1357,14 @@ async function run() {
1323
  activeNodeIds.forEach(id => setNode(id, 'done'));
1324
 
1325
  if (node === 'user') { setNode('user','active'); activeNodeIds=['user']; activateEdge('user','fastapi'); }
1326
- else if (node === 'fastapi') { setNode('fastapi','active'); activeNodeIds=['fastapi']; activateEdge('fastapi','orchestrator'); }
1327
- else if (node === 'orchestrator') { setNode('orchestrator','active'); activeNodeIds=['orchestrator']; activateEdge('orchestrator','query_guard'); }
 
 
 
 
 
 
1328
  else {
1329
  diagramNodes.forEach(id => { setNode(id,'active'); });
1330
  activeNodeIds = diagramNodes;
@@ -1427,6 +1467,28 @@ async function run() {
1427
  doneEdge('self_critique','confidence');
1428
  }
1429
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1430
  if (node === 'memory_add') {
1431
  doneEdge('synthesis','self_critique'); doneEdge('self_critique','confidence');
1432
  doneEdge('confidence','mem_add'); doneEdge('mem_add','answer');
@@ -1516,10 +1578,10 @@ function collectNodeData(step, node, ev) {
1516
  if (ev && ev.run_id && !nodeData.langsmith) {
1517
  nodeData.langsmith = { run_id: ev.run_id };
1518
  }
1519
- // Retrieved chunks for Vector and BM25
1520
- const chunkMatch = step.match(/\[(VectorDB|BM25)\] Retrieved: (.+?)(?:\|([-\.\d]+)\|(.+))?$/i);
1521
  if (chunkMatch) {
1522
- const nodeKey = chunkMatch[1].toUpperCase() === 'BM25' ? 'bm25' : 'vectordb';
1523
  nodeData[nodeKey] = nodeData[nodeKey] || { retrieved_chunks: [] };
1524
  if (chunkMatch[3] !== undefined && chunkMatch[4] !== undefined) {
1525
  nodeData[nodeKey].retrieved_chunks.push({
@@ -1567,6 +1629,10 @@ function collectNodeData(step, node, ev) {
1567
  nodeData.redis.similarity = m ? parseFloat(m[1]) : 0;
1568
  nodeData.redis.hit = true;
1569
  }
 
 
 
 
1570
  }
1571
 
1572
  // ── NODE DETAIL MODAL ──────────────────────────────────────────────
@@ -1599,14 +1665,31 @@ function openNodeModal(nodeId, icon, title) {
1599
 
1600
  else if (nodeId === 'query_decomposer') {
1601
  const sqs = data.sub_questions || [];
1602
- body += `<div class="nm-section"><div class="nm-section-title">βœ‚οΈ Decomposed Sub-Questions</div>`;
1603
- body += sqs.length
1604
- ? sqs.map((q, i) => `<div class="nm-card subq"><strong>Sub-Q ${i+1}:</strong> ${escHtml(q)}</div>`).join('')
1605
- : noData;
1606
- body += '</div>';
1607
- if (sqs.length) {
1608
- body += `<div class="nm-stat-row"><div class="nm-stat">Sub-questions generated: <span>${sqs.length}</span></div></div>`;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1609
  }
 
1610
  }
1611
 
1612
  else if (nodeId === 'query_guard') {
@@ -1799,27 +1882,187 @@ function openNodeModal(nodeId, icon, title) {
1799
  body += `</div>`;
1800
  }
1801
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1802
  else if (nodeId === 'graphdb') {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1803
  body += `<div class="nm-section"><div class="nm-section-title">πŸ•ΈοΈ Knowledge Graph Lookups</div>`;
1804
  if (data.entities && data.entities.length) {
1805
- body += `<div class="nm-stat-row"><div class="nm-stat">Entities resolved: <span>${data.entities.length}</span></div></div>`;
1806
 
1807
- if (data.edges && data.edges.length > 0) {
1808
  body += `<div class="nm-section-title" style="margin-top:10px; display:flex; justify-content:space-between; align-items:center;">
1809
- <span>πŸ“ˆ Local Graph Explorer</span>
1810
  <button onclick="toggleGraphFullscreen()" style="background:none;border:none;color:#38bdf8;cursor:pointer;font-size:0.8rem;padding:0;">β›Ά Fullscreen</button>
1811
  </div>`;
1812
  body += `<svg id="nm-graph-viz" width="100%" height="250" style="background:#1e293b; border-radius:8px; margin-top:5px; border:1px solid #334155; transition: height 0.3s ease;"></svg>`;
1813
  }
1814
 
1815
- body += `<div class="nm-section-title" style="margin-top:10px">πŸ“Œ Fetched Nodes</div>`;
1816
- body += data.entities.map(e => `<div class="nm-card info" style="font-size: 0.8rem;">${escHtml(e)}</div>`).join('');
1817
  } else if (data.entities && data.entities.length === 0) {
1818
  body += `<div class="nm-card info">No matching entities found in the knowledge graph.</div>`;
1819
  } else {
1820
  body += noData;
1821
  }
1822
  body += `</div>`;
 
 
 
1823
  }
1824
 
1825
  else {
@@ -1832,7 +2075,7 @@ function openNodeModal(nodeId, icon, title) {
1832
  document.getElementById('nm-body').innerHTML = body;
1833
  document.getElementById('node-modal-backdrop').classList.add('open');
1834
 
1835
- if (nodeId === 'graphdb' && data.edges && data.edges.length > 0) {
1836
  setTimeout(() => {
1837
  const container = document.getElementById('nm-graph-viz');
1838
  if (!container) return;
@@ -1842,12 +2085,12 @@ function openNodeModal(nodeId, icon, title) {
1842
  svg.selectAll('*').remove();
1843
 
1844
  const nodesMap = {};
1845
- data.edges.forEach(e => {
1846
  nodesMap[e.source] = { id: e.source };
1847
  nodesMap[e.target] = { id: e.target };
1848
  });
1849
  const nodes = Object.values(nodesMap);
1850
- const links = data.edges.map(e => ({ source: e.source, target: e.target, label: e.relation }));
1851
 
1852
  const simulation = d3.forceSimulation(nodes)
1853
  .force("link", d3.forceLink(links).id(d => d.id).distance(120))
@@ -1932,7 +2175,7 @@ function openNodeModal(nodeId, icon, title) {
1932
  }, 100);
1933
  }
1934
 
1935
- if ((nodeId === 'vectordb' || nodeId === 'bm25') && data.retrieved_chunks && data.retrieved_chunks.length > 0) {
1936
  setTimeout(() => {
1937
  renderScoreGraph(data.retrieved_chunks);
1938
  }, 100);
 
157
  }
158
  .preset-item:hover { background: rgba(99,102,241,0.1); border-color: rgba(99,102,241,0.3); color: #a5b4fc; }
159
  .preset-icon { flex-shrink: 0; margin-top: 1px; }
160
+ .preset-header {
161
+ font-size: 0.68rem; font-weight: 700; text-transform: uppercase; color: #64748b;
162
+ margin-top: 10px; margin-bottom: 2px; padding-left: 4px; letter-spacing: 0.05em;
163
+ }
164
+ .preset-header:first-child { margin-top: 2px; }
165
 
166
  .run-btn {
167
  width: 100%; margin-top: 10px; padding: 11px; border: none; border-radius: 9px;
 
356
  .n.active rect { fill:rgba(124,58,237,0.25); stroke:#a78bfa; stroke-width:2; filter:drop-shadow(0 0 10px rgba(139,92,246,0.5)); }
357
  .n.done rect { fill:rgba(16,185,129,0.13); stroke:#10b981; stroke-width:1.8; }
358
  .n.skip rect { fill:#050811; stroke:rgba(255,255,255,0.03); opacity:0.3; }
359
+ .n.hit rect { fill:rgba(16,185,129,0.22) !important; stroke:#10b981 !important; stroke-width:2.5px !important; filter:drop-shadow(0 0 10px rgba(16,185,129,0.6)) !important; }
360
+ .n.miss rect { fill:rgba(249,115,22,0.18) !important; stroke:#f97316 !important; stroke-width:2.2px !important; filter:drop-shadow(0 0 8px rgba(249,115,22,0.4)) !important; }
361
  .n .icon { font-size:14px; }
362
  .n .title { font-family:'Plus Jakarta Sans',sans-serif; font-size:10px; font-weight:700; fill:#e2e8f0; }
363
  .n .sub { font-family:'Plus Jakarta Sans',sans-serif; font-size:8px; fill:#64748b; font-weight:500; }
 
620
  πŸ“Œ Load demo query <span id="preset-arrow">β–Ύ</span>
621
  </button>
622
  <div class="preset-list" id="preset-list">
623
+ <div class="preset-header">πŸ” RAG Benchmark Queries</div>
624
+ <div class="preset-item" onclick="loadPreset(0)"><span class="preset-icon">🟒</span>Easy: What is a deductible?</div>
625
+ <div class="preset-item" onclick="loadPreset(1)"><span class="preset-icon">🟑</span>Medium: What is the copay for Metformin on the Silver plan?</div>
626
+ <div class="preset-item" onclick="loadPreset(2)"><span class="preset-icon">🟠</span>Hard: Compare the specialist copays and out-of-pocket maximums between the Silver and Gold plans.</div>
627
+ <div class="preset-item" onclick="loadPreset(3)"><span class="preset-icon">πŸ”΄</span>Very Hard: Compare the overall deductibles, specialist copays, and drug formulary tier copays between the Bronze, Silver, and Gold plans.</div>
628
+
629
+ <div class="preset-header">⚑ Redis Semantic Cache Testing (Run Base, then Variation)</div>
630
+ <div class="preset-item" onclick="loadPreset(4)"><span class="preset-icon">πŸ’Š</span>Simple Base: Is Metformin covered on the Bronze plan?</div>
631
+ <div class="preset-item" onclick="loadPreset(5)"><span class="preset-icon">πŸŒ€</span>Simple Variation: Does the Bronze plan cover Metformin?</div>
632
+ <div class="preset-item" onclick="loadPreset(6)"><span class="preset-icon">πŸ“‹</span>Complex Base: What is the policy regarding waiting periods for chronic, pre-existing conditions before the plan starts paying for specialist visits and maintenance medications?</div>
633
+ <div class="preset-item" onclick="loadPreset(7)"><span class="preset-icon">πŸŒ€</span>Complex Variation: 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?</div>
634
  </div>
635
  </div>
636
 
637
  <button class="run-btn" id="runbtn" onclick="run()">πŸš€ Execute Live Trace</button>
638
  </div>
639
 
640
+ <!-- Intent row -->
641
  <div class="meta-row" id="meta-row">
642
  <span class="intent-label">Intent</span>
643
  <div class="intent-pill" id="intent-pill">β€”</div>
 
 
644
  </div>
645
 
646
  <!-- Memory inspector -->
 
661
  <label>⚑ System Event Stream</label>
662
  <div class="log-controls">
663
  <input class="log-search" id="log-search" placeholder="filter…" oninput="filterLogs()">
 
 
664
  <button class="filter-btn RETRIEVE" id="fb-RETRIEVE" onclick="setFilter('RETRIEVE')">RET</button>
665
  <button class="filter-btn SYNTH" id="fb-SYNTH" onclick="setFilter('SYNTH')">SYN</button>
666
  <button class="filter-btn ERROR" id="fb-ERROR" onclick="setFilter('ERROR')">ERR</button>
 
742
 
743
  // ── PRESET QUERIES ───────────────────────────────────────────────────────────
744
  const PRESETS = [
745
+ 'What is a deductible?',
746
+ 'What is the copay for Metformin on the Silver plan?',
747
+ 'Compare the specialist copays and out-of-pocket maximums between the Silver and Gold plans.',
748
+ 'Compare the overall deductibles, specialist copays, and drug formulary tier copays between the Bronze, Silver, and Gold plans.',
749
+ 'Is Metformin covered on the Bronze plan?',
750
+ 'Does the Bronze plan cover Metformin?',
751
+ 'What is the policy regarding waiting periods for chronic, pre-existing conditions before the plan starts paying for specialist visits and maintenance medications?',
752
+ '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?',
753
  ];
754
  function loadPreset(i) {
755
  document.getElementById('qinput').value = PRESETS[i];
 
828
  function setFilter(f) {
829
  activeFilter = f;
830
  document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
831
+ const btn = document.getElementById('fb-' + (f === 'SYNTH' ? 'SYNTH' : f));
832
+ if (btn) btn.classList.add('active');
833
  filterLogs();
834
  }
835
  function filterLogs() {
 
859
 
860
  // ── DOWNLOAD TRACE ────────────────────────────────────────────────────────────
861
  let traceStore = [];
862
+ let currentIntent = '';
863
+ let currentConfidence = '';
864
+ let finalResponseText = '';
865
  function downloadTrace() {
866
  if (!traceStore.length) { alert('Run a query first to generate a trace.'); return; }
867
  const blob = new Blob([JSON.stringify(traceStore, null, 2)], { type: 'application/json' });
 
909
  {id:'confidence', icon:'πŸ“Š', title:'Confidence Scorer', sub:'HIGH/MED/LOW rating', x:920, y:670}, // NEW
910
  {id:'mem_add', icon:'πŸ’Ύ', title:'Memory Commit', sub:'Mem0 fact extractor', x:1100, y:670},
911
  {id:'answer', icon:'πŸ’¬', title:'Response Output', sub:'Client socket stream', x:1300, y:670},
912
+ {id:'query_analyzer', icon:'πŸ”', title:'Query Analyzer', sub:'phrasing normalizer', x:200, y:150},
913
+ {id:'redis', icon:'πŸ”΄', title:'Redis Cache', sub:'Semantic Vector Cache', x:380, y:150},
914
  ];
915
 
916
  const INTENT_PILLS = [
 
921
  ];
922
 
923
  const EDGES = [
924
+ ['user','fastapi'],['redis','orchestrator'],['orchestrator','query_guard'],
925
  ['query_guard','mem_search'],['mem_search','intent_node'],
926
  ['orchestrator','langsmith'],
927
  ['intent_node','query_decomposer'],['query_decomposer','retrieve_agent'],
 
936
  ['merger','synthesis'],['synthesis','langsmith'],
937
  ['synthesis','self_critique'],
938
  ['self_critique','confidence'],['confidence','mem_add'],['mem_add','answer'],
939
+ ['fastapi','query_analyzer'],['query_analyzer','redis'],['redis','answer'],
940
  ];
941
 
942
  // Self-critique retry edge (back to retrieve β€” special styling)
 
986
  ];
987
 
988
  const LG_START = {
989
+ query_analyzer: ['query_analyzer'],
990
  semantic_cache: ['redis'],
991
  query_guard: ['query_guard'],
992
  memory_search: ['mem_search'],
 
1096
  langsmith: 'LangSmith observability: records full traces with latency, token usage, and intermediate states.',
1097
  mem_add: 'Mem0 fact extractor: persists key Q&A facts to session memory for future context personalization.',
1098
  answer: 'Final answer streamed back to the client via Server-Sent Events (SSE).',
1099
+ query_analyzer: 'Translates conversational user queries into standardized third-person insurance policy search terms using GPT-4o-mini.',
1100
  redis: 'Cognitive Semantic Vector Cache: checks incoming queries against previously answered queries using GPT-4o-mini normalization and vector embeddings to bypass the orchestrator and LLM on cache hits.',
1101
  };
1102
 
 
1194
  document.getElementById('answer-card').style.display = 'none';
1195
  document.getElementById('answer-text').innerHTML = '';
1196
  document.getElementById('meta-row').classList.remove('visible');
1197
+ const confBadge = document.getElementById('conf-badge');
1198
+ if (confBadge) {
1199
+ confBadge.className = 'conf-badge';
1200
+ confBadge.textContent = 'β€”';
1201
+ }
1202
+ const intentPill = document.getElementById('intent-pill');
1203
+ if (intentPill) {
1204
+ intentPill.textContent = 'β€”';
1205
+ }
1206
  Object.keys(nodeTimers).forEach(k => delete nodeTimers[k]);
1207
  Object.keys(nodeLatencies).forEach(k => delete nodeLatencies[k]);
1208
  multiQueryVariants = [];
1209
  nodeData = {};
1210
  traceStore = [];
1211
+ setFilter('ALL');
1212
  }
1213
 
1214
  function addLog(icon, tag, tagClass, text) {
 
1233
  for (const m of STEP_MAP) {
1234
  if (m.pat.test(step)) {
1235
  m.nodes.forEach(id => {
1236
+ if (id === 'graphdb' && currentIntent === 'POLICY_QUESTION') return;
1237
  setNode(id, 'active');
1238
  const fromNode = lgNode === 'retrieve' ? 'retrieve_agent' : lgNode;
1239
  activateEdge(fromNode, id);
 
1288
  let elapsedTimer = setInterval(() => updateElapsed(performance.now()-startMs), 500);
1289
 
1290
  let activeNodeIds = [];
1291
+ currentIntent = '';
1292
+ currentConfidence = '';
1293
+ finalResponseText = '';
1294
 
1295
  try {
1296
  const url = `${API}/chat/stream?session_id=${encodeURIComponent(SID)}&query=${encodeURIComponent(q)}`;
 
1316
  const totalMs = Math.round(performance.now() - startMs);
1317
  updateElapsed(totalMs);
1318
  setStatus(`βœ… Pipeline completed in ${(totalMs/1000).toFixed(2)}s`, 'done');
1319
+ const ansEl = document.getElementById('answer-text');
1320
+ if (finalResponseText && (!ansEl.innerHTML || ansEl.innerHTML.trim() === '')) {
1321
+ document.getElementById('answer-card').style.display = 'block';
1322
+ typewriterMarkdown(finalResponseText, ansEl);
1323
+ }
1324
  break;
1325
  }
1326
  try {
 
1342
 
1343
  function handleEvent(ev) {
1344
  const { type, node, intent, step, msg, answer, confidence, confidence_reason, blocked, sub_questions } = ev;
1345
+ if (answer) {
1346
+ finalResponseText = answer;
1347
+ }
1348
 
1349
  if (intent && intent !== currentIntent) { currentIntent = intent; setIntent(intent); document.getElementById('meta-row').classList.add('visible'); }
1350
  if (confidence) setConfidence(confidence, confidence_reason);
 
1357
  activeNodeIds.forEach(id => setNode(id, 'done'));
1358
 
1359
  if (node === 'user') { setNode('user','active'); activeNodeIds=['user']; activateEdge('user','fastapi'); }
1360
+ else if (node === 'fastapi') { setNode('fastapi','active'); activeNodeIds=['fastapi']; activateEdge('fastapi','query_analyzer'); }
1361
+ else if (node === 'query_analyzer'){ setNode('query_analyzer','active'); activeNodeIds=['query_analyzer']; activateEdge('fastapi','query_analyzer'); }
1362
+ else if (node === 'semantic_cache') {
1363
+ setNode('redis','active');
1364
+ activeNodeIds=['redis'];
1365
+ activateEdge('query_analyzer','redis');
1366
+ }
1367
+ else if (node === 'orchestrator') { setNode('orchestrator','active'); activeNodeIds=['orchestrator']; activateEdge('redis','orchestrator'); }
1368
  else {
1369
  diagramNodes.forEach(id => { setNode(id,'active'); });
1370
  activeNodeIds = diagramNodes;
 
1467
  doneEdge('self_critique','confidence');
1468
  }
1469
 
1470
+ if (node === 'query_analyzer') {
1471
+ doneEdge('fastapi', 'query_analyzer');
1472
+ setNode('query_analyzer', 'done');
1473
+ nodeData.query_analyzer = nodeData.query_analyzer || {};
1474
+ nodeData.query_analyzer.normalized_query = ev.normalized_query || '';
1475
+ }
1476
+
1477
+ if (node === 'semantic_cache') {
1478
+ doneEdge('query_analyzer', 'redis');
1479
+ if (answer) {
1480
+ setNode('redis', 'hit');
1481
+ doneEdge('redis', 'answer');
1482
+ setNode('answer', 'active');
1483
+ const ansEl = document.getElementById('answer-text');
1484
+ document.getElementById('answer-card').style.display = 'block';
1485
+ typewriterMarkdown(answer, ansEl);
1486
+ } else {
1487
+ setNode('redis', 'miss');
1488
+ doneEdge('redis', 'orchestrator');
1489
+ }
1490
+ }
1491
+
1492
  if (node === 'memory_add') {
1493
  doneEdge('synthesis','self_critique'); doneEdge('self_critique','confidence');
1494
  doneEdge('confidence','mem_add'); doneEdge('mem_add','answer');
 
1578
  if (ev && ev.run_id && !nodeData.langsmith) {
1579
  nodeData.langsmith = { run_id: ev.run_id };
1580
  }
1581
+ // Retrieved chunks for Vector, BM25, Ensemble, and Reranker
1582
+ const chunkMatch = step.match(/\[(VectorDB|BM25|Ensemble|Reranker)\] Retrieved: (.+?)(?:\|([-\.\d]+)\|(.+))?$/i);
1583
  if (chunkMatch) {
1584
+ const nodeKey = chunkMatch[1].toLowerCase();
1585
  nodeData[nodeKey] = nodeData[nodeKey] || { retrieved_chunks: [] };
1586
  if (chunkMatch[3] !== undefined && chunkMatch[4] !== undefined) {
1587
  nodeData[nodeKey].retrieved_chunks.push({
 
1629
  nodeData.redis.similarity = m ? parseFloat(m[1]) : 0;
1630
  nodeData.redis.hit = true;
1631
  }
1632
+ if (/Cache MISS/i.test(step)) {
1633
+ nodeData.redis = nodeData.redis || {};
1634
+ nodeData.redis.hit = false;
1635
+ }
1636
  }
1637
 
1638
  // ── NODE DETAIL MODAL ──────────────────────────────────────────────
 
1665
 
1666
  else if (nodeId === 'query_decomposer') {
1667
  const sqs = data.sub_questions || [];
1668
+ const orig = data.original_query || document.getElementById('qinput').value || '';
1669
+ const currentIntent = document.getElementById('intent-pill').textContent.trim();
1670
+
1671
+ body += `<div class="nm-section"><div class="nm-section-title">βœ‚οΈ Decomposing Complex Intent</div>`;
1672
+
1673
+ if (currentIntent === 'MULTI_HOP' && sqs.length) {
1674
+ body += `
1675
+ <div class="nm-card info" style="margin-bottom:12px; border-left-color:#a78bfa">
1676
+ <strong>Decomposer Logic:</strong> Since the query was classified as <strong>${currentIntent}</strong>, it is split into independent sub-queries to retrieve targeted facts from different documents (e.g. drug formularies, provider databases) which are later synthesized.
1677
+ </div>
1678
+ <div class="nm-card subq" style="background:rgba(255,255,255,0.03); border-left-color:rgba(167,139,250,0.5); margin-bottom:16px;">
1679
+ <strong>Original Input Query:</strong> <em>"${escHtml(orig)}"</em>
1680
+ </div>
1681
+ <div class="nm-section-title" style="font-size:0.85rem; margin-top:8px;">Generated Atomic Sub-Questions:</div>
1682
+ `;
1683
+ body += sqs.map((q, i) => `<div class="nm-card subq" style="border-left-color:#a78bfa; margin-bottom:6px;"><strong>Sub-Q ${i+1}:</strong> ${escHtml(q)}</div>`).join('');
1684
+ body += `<div class="nm-stat-row" style="margin-top:12px;"><div class="nm-stat">Sub-questions generated: <span>${sqs.length}</span></div></div>`;
1685
+ } else {
1686
+ body += `
1687
+ <div class="nm-card warning" style="border-left-color:#fbbf24">
1688
+ <strong>Decomposer Skipped:</strong> The query intent was classified as <strong>${currentIntent || 'non-MULTI_HOP'}</strong>. Only queries with <strong>MULTI_HOP</strong> intent are decomposed. Other intents retrieve results in a single hop.
1689
+ </div>
1690
+ `;
1691
  }
1692
+ body += '</div>';
1693
  }
1694
 
1695
  else if (nodeId === 'query_guard') {
 
1882
  body += `</div>`;
1883
  }
1884
 
1885
+ else if (nodeId === 'ensemble') {
1886
+ body += `<div class="nm-section"><div class="nm-section-title">βš–οΈ Reciprocal Rank Fusion (RRF) Formula</div>`;
1887
+ body += `<div class="nm-card info" style="font-family:monospace; font-size: 0.8rem; overflow-x: auto; white-space: nowrap;">
1888
+ RRF_Score(d ∈ D) = βˆ‘_{m ∈ M} w_m / (r_m(d) + k)
1889
+ </div>`;
1890
+ body += `<div class="nm-card info" style="font-size:0.75rem; line-height:1.4;">
1891
+ Melds Vector search similarity results with BM25 keyword rankings. w_m represents retriever weights, and k is the constant rank smoothing parameter (default c=60).
1892
+ </div>`;
1893
+ if (data.retrieved_chunks && data.retrieved_chunks.length) {
1894
+ body += `<div class="nm-section-title" style="margin-top:10px; display:flex; justify-content:space-between; align-items:center;">
1895
+ <span>πŸ“Š RRF Merged Scores</span>
1896
+ <button onclick="toggleScoreFullscreen()" style="background:none;border:none;color:#38bdf8;cursor:pointer;font-size:0.8rem;padding:0;">β›Ά Fullscreen</button>
1897
+ </div>`;
1898
+ body += `<svg id="nm-score-viz" width="100%" height="250" style="background:#1e293b; border-radius:8px; margin-top:5px; border:1px solid #334155; transition: height 0.3s ease;"></svg>`;
1899
+ body += `<div class="nm-section-title" style="margin-top:10px">πŸ“„ Merged Documents (RRF Order)</div>`;
1900
+ body += data.retrieved_chunks.map(c => {
1901
+ const text = typeof c === 'string' ? c : `[Score/RRF: ${c.score.toFixed(4)}] ${c.content}`;
1902
+ return `<div class="nm-card info" style="font-size: 0.8rem;">${escHtml(text)}</div>`;
1903
+ }).join('');
1904
+ } else {
1905
+ body += noData;
1906
+ }
1907
+ body += `</div>`;
1908
+ }
1909
+
1910
+ else if (nodeId === 'reranker') {
1911
+ body += `<div class="nm-section"><div class="nm-section-title">βš–οΈ Cross-Encoder Relevance Scoring</div>`;
1912
+ body += `<div class="nm-card info" style="font-size:0.75rem; line-height:1.4;">
1913
+ Uses the BGE Cross-Encoder model to score the raw relevance of each retrieved chunk against the query. Chunks scoring below the minimum relevance threshold are filtered out.
1914
+ </div>`;
1915
+ if (data.retrieved_chunks && data.retrieved_chunks.length) {
1916
+ body += `<div class="nm-section-title" style="margin-top:10px; display:flex; justify-content:space-between; align-items:center;">
1917
+ <span>πŸ“Š Reranker Relevance Scores</span>
1918
+ <button onclick="toggleScoreFullscreen()" style="background:none;border:none;color:#38bdf8;cursor:pointer;font-size:0.8rem;padding:0;">β›Ά Fullscreen</button>
1919
+ </div>`;
1920
+ body += `<svg id="nm-score-viz" width="100%" height="250" style="background:#1e293b; border-radius:8px; margin-top:5px; border:1px solid #334155; transition: height 0.3s ease;"></svg>`;
1921
+ body += `<div class="nm-section-title" style="margin-top:10px">πŸ“„ Top Reranked Chunks</div>`;
1922
+ body += data.retrieved_chunks.map(c => {
1923
+ const text = typeof c === 'string' ? c : `[Score: ${c.score.toFixed(4)}] ${c.content}`;
1924
+ return `<div class="nm-card info" style="font-size: 0.8rem;">${escHtml(text)}</div>`;
1925
+ }).join('');
1926
+ } else {
1927
+ body += noData;
1928
+ }
1929
+ body += `</div>`;
1930
+ }
1931
+
1932
+ else if (nodeId === 'query_analyzer') {
1933
+ body += `<div class="nm-section"><div class="nm-section-title">πŸ” Query Analyzer & Normalizer</div>`;
1934
+ body += `<div class="nm-card info" style="font-size:0.75rem; line-height:1.4;">
1935
+ Translates conversational, user-specific phrasing (first-person details, questions) into standard, formal third-person search terminology using GPT-4o-mini. This bridges the vocabulary gap and enables accurate semantic cache matching.
1936
+ </div>`;
1937
+ if (data.normalized_query) {
1938
+ body += `<div class="nm-section-title" style="margin-top:10px;">πŸ“Œ Normalization Output</div>`;
1939
+ body += `<div class="nm-card good" style="font-size:0.85rem; font-weight:700;">
1940
+ "${escHtml(data.normalized_query)}"
1941
+ </div>`;
1942
+ } else {
1943
+ body += noData;
1944
+ }
1945
+ body += `</div>`;
1946
+ }
1947
+
1948
+ else if (nodeId === 'redis') {
1949
+ body += `<div class="nm-section"><div class="nm-section-title">πŸ”΄ Redis Semantic Cache Details</div>`;
1950
+ body += `<div class="nm-card info" style="font-size:0.75rem; line-height:1.4;">
1951
+ Checks the Redis vector cache (or fallback local cache) for semantically equivalent queries. Bypasses the LangGraph orchestrator and LLM generation completely on cache hits.
1952
+ </div>`;
1953
+ if (data.matched_query) {
1954
+ body += `<div class="nm-card good" style="font-size: 0.8rem; border-left: 3px solid #10b981;">
1955
+ <strong>⚑ Cache HIT!</strong><br>
1956
+ <strong>Matched Cached Query:</strong> "${escHtml(data.matched_query)}"<br>
1957
+ <strong>Similarity Score:</strong> ${data.similarity}%
1958
+ </div>`;
1959
+ } else if (data.hit === false) {
1960
+ body += `<div class="nm-card bad" style="font-size: 0.8rem; border-left: 3px solid #ef4444;">
1961
+ <strong>❌ Cache MISS</strong><br>
1962
+ No semantically matching query was found in the cache. Proceeding to standard RAG pipeline.
1963
+ </div>`;
1964
+ } else {
1965
+ body += noData;
1966
+ }
1967
+ body += `</div>`;
1968
+ }
1969
+
1970
  else if (nodeId === 'graphdb') {
1971
+ // Edge Filtering Logic for Neater Visualisation
1972
+ let filteredEdges = [];
1973
+ let filteredEntities = data.entities || [];
1974
+
1975
+ if (data.edges && data.edges.length > 0) {
1976
+ const HUBS = new Set(['bronze', 'silver', 'gold', 'all']);
1977
+ const queryTokens = (document.getElementById('qinput').value || '').toLowerCase();
1978
+
1979
+ const directlyRelevant = new Set();
1980
+ data.edges.forEach(e => {
1981
+ [e.source, e.target].forEach(nodeName => {
1982
+ const nLower = nodeName.toLowerCase();
1983
+ if (queryTokens.includes(nLower) && nLower.length > 2) {
1984
+ directlyRelevant.add(nodeName);
1985
+ }
1986
+ if (nLower === 'cardiology' && (queryTokens.includes('cardiologist') || queryTokens.includes('cardio'))) {
1987
+ directlyRelevant.add(nodeName);
1988
+ }
1989
+ if (nLower === 'dermatology' && (queryTokens.includes('dermatologist') || queryTokens.includes('derm'))) {
1990
+ directlyRelevant.add(nodeName);
1991
+ }
1992
+ if (nLower === 'pediatrics' && (queryTokens.includes('pediatrician') || queryTokens.includes('ped'))) {
1993
+ directlyRelevant.add(nodeName);
1994
+ }
1995
+ if (nLower === 'chicago, il' && queryTokens.includes('chicago')) {
1996
+ directlyRelevant.add(nodeName);
1997
+ }
1998
+ });
1999
+ });
2000
+
2001
+ const secondaryRelevant = new Set();
2002
+ data.edges.forEach(e => {
2003
+ const sHub = HUBS.has(e.source.toLowerCase());
2004
+ const tHub = HUBS.has(e.target.toLowerCase());
2005
+ if (directlyRelevant.has(e.source) && !sHub) secondaryRelevant.add(e.target);
2006
+ if (directlyRelevant.has(e.target) && !tHub) secondaryRelevant.add(e.source);
2007
+ });
2008
+
2009
+ filteredEdges = data.edges.filter(e => {
2010
+ const sLower = e.source.toLowerCase();
2011
+ const tLower = e.target.toLowerCase();
2012
+ const sDirect = directlyRelevant.has(e.source);
2013
+ const tDirect = directlyRelevant.has(e.target);
2014
+ const sSec = secondaryRelevant.has(e.source);
2015
+ const tSec = secondaryRelevant.has(e.target);
2016
+
2017
+ if ((sDirect || sSec) && (tDirect || tSec)) {
2018
+ if (HUBS.has(sLower) && !tDirect && !tSec) return false;
2019
+ if (HUBS.has(tLower) && !sDirect && !sSec) return false;
2020
+ return true;
2021
+ }
2022
+ return false;
2023
+ });
2024
+
2025
+ if (filteredEdges.length === 0) {
2026
+ filteredEdges = data.edges.slice(0, 15);
2027
+ }
2028
+
2029
+ const visibleNodes = new Set();
2030
+ filteredEdges.forEach(e => {
2031
+ visibleNodes.add(e.source);
2032
+ visibleNodes.add(e.target);
2033
+ });
2034
+
2035
+ if (data.entities) {
2036
+ filteredEntities = data.entities.filter(ent => visibleNodes.has(ent) || directlyRelevant.has(ent));
2037
+ if (filteredEntities.length === 0) {
2038
+ filteredEntities = data.entities.slice(0, 10);
2039
+ }
2040
+ }
2041
+ }
2042
+
2043
  body += `<div class="nm-section"><div class="nm-section-title">πŸ•ΈοΈ Knowledge Graph Lookups</div>`;
2044
  if (data.entities && data.entities.length) {
2045
+ body += `<div class="nm-stat-row"><div class="nm-stat">Entities resolved: <span>${data.entities.length}</span> (showing relevant: ${filteredEntities.length})</div></div>`;
2046
 
2047
+ if (filteredEdges.length > 0) {
2048
  body += `<div class="nm-section-title" style="margin-top:10px; display:flex; justify-content:space-between; align-items:center;">
2049
+ <span>πŸ“ˆ Local Graph Explorer (Relevant Nodes)</span>
2050
  <button onclick="toggleGraphFullscreen()" style="background:none;border:none;color:#38bdf8;cursor:pointer;font-size:0.8rem;padding:0;">β›Ά Fullscreen</button>
2051
  </div>`;
2052
  body += `<svg id="nm-graph-viz" width="100%" height="250" style="background:#1e293b; border-radius:8px; margin-top:5px; border:1px solid #334155; transition: height 0.3s ease;"></svg>`;
2053
  }
2054
 
2055
+ body += `<div class="nm-section-title" style="margin-top:10px">πŸ“Œ Fetched Entities</div>`;
2056
+ body += filteredEntities.map(e => `<div class="nm-card info" style="font-size: 0.8rem;">${escHtml(e)}</div>`).join('');
2057
  } else if (data.entities && data.entities.length === 0) {
2058
  body += `<div class="nm-card info">No matching entities found in the knowledge graph.</div>`;
2059
  } else {
2060
  body += noData;
2061
  }
2062
  body += `</div>`;
2063
+
2064
+ // Save filtered edges globally so we can retrieve them in the D3 rendering phase below
2065
+ window.lastFilteredEdges = filteredEdges;
2066
  }
2067
 
2068
  else {
 
2075
  document.getElementById('nm-body').innerHTML = body;
2076
  document.getElementById('node-modal-backdrop').classList.add('open');
2077
 
2078
+ if (nodeId === 'graphdb' && window.lastFilteredEdges && window.lastFilteredEdges.length > 0) {
2079
  setTimeout(() => {
2080
  const container = document.getElementById('nm-graph-viz');
2081
  if (!container) return;
 
2085
  svg.selectAll('*').remove();
2086
 
2087
  const nodesMap = {};
2088
+ window.lastFilteredEdges.forEach(e => {
2089
  nodesMap[e.source] = { id: e.source };
2090
  nodesMap[e.target] = { id: e.target };
2091
  });
2092
  const nodes = Object.values(nodesMap);
2093
+ const links = window.lastFilteredEdges.map(e => ({ source: e.source, target: e.target, label: e.relation }));
2094
 
2095
  const simulation = d3.forceSimulation(nodes)
2096
  .force("link", d3.forceLink(links).id(d => d.id).distance(120))
 
2175
  }, 100);
2176
  }
2177
 
2178
+ if ((nodeId === 'vectordb' || nodeId === 'bm25' || nodeId === 'ensemble' || nodeId === 'reranker') && data.retrieved_chunks && data.retrieved_chunks.length > 0) {
2179
  setTimeout(() => {
2180
  renderScoreGraph(data.retrieved_chunks);
2181
  }, 100);
ingestion/graph_ingest.py CHANGED
@@ -83,7 +83,30 @@ def build_knowledge_graph():
83
  state = str(row['state']).strip()
84
 
85
  # Provider Node
86
- G.add_node(npi, type="Provider", name=name, specialty=specialty, city=city, state=state)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
87
 
88
  # Specialty Node
89
  G.add_node(specialty, type="Specialty")
 
83
  state = str(row['state']).strip()
84
 
85
  # Provider Node
86
+ accepting = str(row['accepting_new_patients']).strip()
87
+ telehealth = str(row['telehealth_available']).strip()
88
+ languages = str(row['languages_spoken']).strip()
89
+ group = str(row['group_practice']).strip()
90
+ phone = str(row['phone']).strip()
91
+ address = str(row['street_address']).strip()
92
+ zip_code = str(row['zip']).strip()
93
+
94
+ G.add_node(
95
+ npi,
96
+ type="Provider",
97
+ name=name,
98
+ specialty=specialty,
99
+ city=city,
100
+ state=state,
101
+ accepting_new_patients=accepting,
102
+ telehealth_available=telehealth,
103
+ languages_spoken=languages,
104
+ group_practice=group,
105
+ phone=phone,
106
+ street_address=address,
107
+ zip=zip_code,
108
+ hospital_affiliation=hospital
109
+ )
110
 
111
  # Specialty Node
112
  G.add_node(specialty, type="Specialty")
ingestion/ingest.py CHANGED
@@ -250,7 +250,16 @@ def load_and_chunk_documents() -> list[Document]:
250
 
251
  dl_meta = chunk.metadata.get("dl_meta", {})
252
  if dl_meta:
253
- chunk.metadata["page_no"] = dl_meta.get("page_no")
 
 
 
 
 
 
 
 
 
254
  chunk.metadata["heading"] = (
255
  dl_meta.get("headings", [""])[0]
256
  if dl_meta.get("headings") else ""
 
250
 
251
  dl_meta = chunk.metadata.get("dl_meta", {})
252
  if dl_meta:
253
+ page_no = None
254
+ if "doc_items" in dl_meta and dl_meta["doc_items"]:
255
+ prov = dl_meta["doc_items"][0].get("prov", [])
256
+ if prov:
257
+ page_no = prov[0].get("page_no")
258
+
259
+ if page_no is not None:
260
+ chunk.metadata["page"] = page_no - 1 # 0-indexed for internal consistency
261
+ chunk.metadata["page_no"] = page_no
262
+
263
  chunk.metadata["heading"] = (
264
  dl_meta.get("headings", [""])[0]
265
  if dl_meta.get("headings") else ""
orchestration/semantic_cache.py CHANGED
@@ -179,7 +179,7 @@ class SemanticCache:
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
 
@@ -192,7 +192,7 @@ class SemanticCache:
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)
@@ -264,7 +264,7 @@ class SemanticCache:
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
  """
@@ -274,7 +274,7 @@ class SemanticCache:
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()
 
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", normalized_query: Optional[str] = None) -> Optional[dict]:
183
  """
184
  Check the semantic cache for a match.
185
 
 
192
  start_time = time.time()
193
  try:
194
  # 1. Normalize query to standard terminology
195
+ norm_query = normalized_query or self.normalize_query(query)
196
 
197
  # 2. Embed the normalized query
198
  query_vector = self.embeddings.embed_query(norm_query)
 
264
 
265
  return None
266
 
267
+ def store(self, query: str, response: dict, plan_tier: str = "Unknown", normalized_query: Optional[str] = None) -> None:
268
  """
269
  Store query response in the semantic cache.
270
  """
 
274
 
275
  try:
276
  # 1. Normalize query to standard terminology
277
+ norm_query = normalized_query or 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()
orchestration/tools.py CHANGED
@@ -44,13 +44,29 @@ def _format_docs(docs: list[Document], max_per_tool: int = 5) -> str:
44
  formatted = []
45
  for d in docs[:max_per_tool]:
46
  source = d.metadata.get("source_file", "Unknown")
47
- page = d.metadata.get("page", "")
48
- row = d.metadata.get("row_range", "")
49
- cite = f"(Source: {source}"
50
- if page: cite += f", Page: {int(page)+1}"
 
 
 
 
 
 
 
 
 
 
 
51
  if row: cite += f", Rows: {row}"
52
  cite += ")"
53
- formatted.append(f"{cite}\n{d.page_content}")
 
 
 
 
 
54
  return "\n\n---\n\n".join(formatted) if formatted else ""
55
 
56
 
@@ -185,7 +201,7 @@ def plan_comparison_search(query: str, tier: str) -> str:
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
@@ -200,7 +216,7 @@ def plan_comparison_search(query: str, tier: str) -> str:
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."
205
 
206
 
 
44
  formatted = []
45
  for d in docs[:max_per_tool]:
46
  source = d.metadata.get("source_file", "Unknown")
47
+ # Support both 'page' (0-indexed) and 'page_no' (1-indexed) metadata fields
48
+ page = d.metadata.get("page")
49
+ page_no = d.metadata.get("page_no")
50
+ row = d.metadata.get("row_range", "")
51
+ cite = f"(Source: {source}"
52
+ if page is not None and str(page).strip() != "":
53
+ try:
54
+ # If page is stored, it's 0-indexed, display as 1-indexed
55
+ cite += f", Page: {int(page)+1}"
56
+ except ValueError:
57
+ cite += f", Page: {page}"
58
+ elif page_no is not None and str(page_no).strip() != "":
59
+ # page_no is 1-indexed directly from Docling
60
+ cite += f", Page: {page_no}"
61
+
62
  if row: cite += f", Rows: {row}"
63
  cite += ")"
64
+
65
+ # Prepend contextual display header (plan tier, document type, source)
66
+ header = d.metadata.get("display_header", "")
67
+ content = f"{header}\n{d.page_content}" if header else d.page_content
68
+ formatted.append(f"{cite}\n{content}")
69
+
70
  return "\n\n---\n\n".join(formatted) if formatted else ""
71
 
72
 
 
201
  vs = _load_vectorstore()
202
 
203
  # Pre-filter by both plan_tier AND inferred doc_type for maximum precision
204
+ tier_specific_docs = vs.similarity_search(query, k=8, filter={"plan_tier": tier.capitalize()})
205
  docs = tier_specific_docs + docs
206
  except Exception:
207
  pass
 
216
  if not tier_docs:
217
  tier_docs = docs # Fallback: use all results
218
 
219
+ result = _format_docs(tier_docs, max_per_tool=8)
220
  return result if result else f"No {tier} plan information found for this query."
221
 
222
 
retrieval/graph_retriever.py CHANGED
@@ -23,26 +23,26 @@ from orchestration.tracing import log_event
23
  # Maps colloquial / lay terms β†’ canonical specialty names in the graph
24
  SYNONYM_MAP = {
25
  "brain doctor": "Neurology", "brain doctors": "Neurology",
26
- "brain specialist": "Neurology", "neurologist": "Neurology", "neuro": "Neurology",
27
  "heart doctor": "Cardiology", "heart doctors": "Cardiology",
28
- "heart specialist": "Cardiology", "cardiologist": "Cardiology",
29
  "eye doctor": "Ophthalmology", "eye doctors": "Ophthalmology",
30
- "eye specialist": "Ophthalmology", "optometrist": "Ophthalmology",
31
  "skin doctor": "Dermatology", "skin doctors": "Dermatology",
32
- "dermatologist": "Dermatology",
33
- "bone doctor": "Orthopedics", "joint specialist": "Orthopedics",
34
- "orthopedist": "Orthopedics",
35
- "gut doctor": "Gastroenterology", "stomach doctor": "Gastroenterology",
36
- "gastro": "Gastroenterology",
37
- "kidney doctor": "Nephrology", "kidney specialist": "Nephrology",
38
- "lung doctor": "Pulmonology", "lung specialist": "Pulmonology",
39
- "kids doctor": "Pediatrics", "child doctor": "Pediatrics",
40
- "pediatrician": "Pediatrics",
41
- "diabetes doctor": "Endocrinology", "endocrinologist": "Endocrinology",
42
- "women doctor": "OB/GYN", "gynecologist": "OB/GYN", "obgyn": "OB/GYN",
43
- "blood doctor": "Hematology",
44
- "cancer doctor": "Oncology", "oncologist": "Oncology",
45
- "psychiatrist": "Psychiatry", "mental health": "Psychiatry",
46
  }
47
 
48
  console = Console()
@@ -238,6 +238,16 @@ class GraphRetriever:
238
  if self.G.nodes.get(n, {}).get("type") == "Specialty"
239
  ]
240
 
 
 
 
 
 
 
 
 
 
 
241
  def _find_providers(state_filter=None):
242
  return [
243
  (n, d) for n, d in self.G.nodes(data=True)
 
23
  # Maps colloquial / lay terms β†’ canonical specialty names in the graph
24
  SYNONYM_MAP = {
25
  "brain doctor": "Neurology", "brain doctors": "Neurology",
26
+ "brain specialist": "Neurology", "neurologist": "Neurology", "neurologists": "Neurology", "neuro": "Neurology",
27
  "heart doctor": "Cardiology", "heart doctors": "Cardiology",
28
+ "heart specialist": "Cardiology", "cardiologist": "Cardiology", "cardiologists": "Cardiology",
29
  "eye doctor": "Ophthalmology", "eye doctors": "Ophthalmology",
30
+ "eye specialist": "Ophthalmology", "optometrist": "Ophthalmology", "optometrists": "Ophthalmology", "ophthalmologist": "Ophthalmology", "ophthalmologists": "Ophthalmology",
31
  "skin doctor": "Dermatology", "skin doctors": "Dermatology",
32
+ "dermatologist": "Dermatology", "dermatologists": "Dermatology",
33
+ "bone doctor": "Orthopedics", "joint specialist": "Orthopedics", "orthopedist": "Orthopedics", "orthopedists": "Orthopedics",
34
+ "gut doctor": "Gastroenterology", "stomach doctor": "Gastroenterology", "gastroenterologist": "Gastroenterology", "gastroenterologists": "Gastroenterology", "gastro": "Gastroenterology",
35
+ "kidney doctor": "Nephrology", "kidney specialist": "Nephrology", "nephrologist": "Nephrology", "nephrologists": "Nephrology",
36
+ "lung doctor": "Pulmonology", "lung specialist": "Pulmonology", "pulmonologist": "Pulmonology", "pulmonologists": "Pulmonology",
37
+ "kids doctor": "Pediatrics", "child doctor": "Pediatrics", "pediatrician": "Pediatrics", "pediatricians": "Pediatrics",
38
+ "diabetes doctor": "Endocrinology", "endocrinologist": "Endocrinology", "endocrinologists": "Endocrinology",
39
+ "women doctor": "OB/GYN", "gynecologist": "OB/GYN", "gynecologists": "OB/GYN", "obgyn": "OB/GYN",
40
+ "blood doctor": "Hematology", "hematologist": "Hematology", "hematologists": "Hematology",
41
+ "cancer doctor": "Oncology", "oncologist": "Oncology", "oncologists": "Oncology",
42
+ "psychiatrist": "Psychiatry", "psychiatrists": "Psychiatry", "mental health": "Psychiatry",
43
+ "urologist": "Urology", "urologists": "Urology",
44
+ "rheumatologist": "Rheumatology", "rheumatologists": "Rheumatology",
45
+ "plastic surgeon": "Plastic Surgery", "plastic surgeons": "Plastic Surgery",
46
  }
47
 
48
  console = Console()
 
238
  if self.G.nodes.get(n, {}).get("type") == "Specialty"
239
  ]
240
 
241
+ # Check if we have any providers in the requested city with matching specialty
242
+ in_city_providers = [
243
+ (n, d) for n, d in self.G.nodes(data=True)
244
+ if d.get("type") == "Provider"
245
+ and (not target_specs or d.get("specialty", "") in target_specs)
246
+ and d.get("city", "").lower() == city.lower()
247
+ ]
248
+ if in_city_providers:
249
+ return ""
250
+
251
  def _find_providers(state_filter=None):
252
  return [
253
  (n, d) for n, d in self.G.nodes(data=True)
retrieval/retriever.py CHANGED
@@ -235,7 +235,37 @@ def get_hybrid_retriever(
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
  )
@@ -281,7 +311,16 @@ def get_hybrid_retriever(
281
  def compress_documents(self, documents, query, callbacks=None):
282
  if not documents:
283
  return []
284
- scores = self.model.score([(query, d.page_content) for d in documents])
 
 
 
 
 
 
 
 
 
285
  for d, s in zip(documents, scores):
286
  d.metadata["relevance_score"] = float(s)
287
 
@@ -291,6 +330,11 @@ def get_hybrid_retriever(
291
  if d.metadata.get("doc_type") == "knowledge_graph"
292
  or d.metadata.get("relevance_score", 0) >= MIN_RELEVANCE_SCORE
293
  ]
 
 
 
 
 
294
  return filtered[:self.top_n]
295
 
296
  compressor = ScoredReranker(model=cross_encoder, top_n=top_n)
 
235
  console.print(f" βœ… BM25 retriever ready (k={k}, {len(all_docs)} documents indexed)")
236
 
237
  # ── Stage 3: Ensemble Retriever ─────────────────────────────
238
+ class LoggingEnsembleRetriever(EnsembleRetriever):
239
+ def weighted_reciprocal_rank(self, doc_lists: list[list[Document]]) -> list[Document]:
240
+ from collections import defaultdict
241
+ rrf_score = defaultdict(float)
242
+ for doc_list, weight in zip(doc_lists, self.weights):
243
+ for rank, doc in enumerate(doc_list, start=1):
244
+ key = (
245
+ doc.page_content
246
+ if self.id_key is None
247
+ else doc.metadata.get(self.id_key)
248
+ )
249
+ rrf_score[key] += weight / (rank + self.c)
250
+
251
+ merged_docs = super().weighted_reciprocal_rank(doc_lists)
252
+ for doc in merged_docs:
253
+ key = (
254
+ doc.page_content
255
+ if self.id_key is None
256
+ else doc.metadata.get(self.id_key)
257
+ )
258
+ doc.metadata["score"] = rrf_score.get(key, 0.0)
259
+
260
+ for i, d in enumerate(merged_docs[:4]):
261
+ source = d.metadata.get("source_file", "unknown")
262
+ page = d.metadata.get("page", "?")
263
+ score = d.metadata.get("score", 0.0)
264
+ log_event(f"[Ensemble] Retrieved: {source} (Page: {page})|{score:.4f}|{d.page_content[:40]}...")
265
+
266
+ return merged_docs
267
+
268
+ ensemble_retriever = LoggingEnsembleRetriever(
269
  retrievers=[logging_bm25_retriever, logging_vector_retriever],
270
  weights=weights,
271
  )
 
311
  def compress_documents(self, documents, query, callbacks=None):
312
  if not documents:
313
  return []
314
+
315
+ # Prepend display_header (which contains plan tier, source file, etc.)
316
+ # so that the Cross-Encoder is aware of metadata context during scoring.
317
+ texts_to_score = []
318
+ for d in documents:
319
+ header = d.metadata.get("display_header", "")
320
+ content = f"{header}\n{d.page_content}" if header else d.page_content
321
+ texts_to_score.append((query, content))
322
+
323
+ scores = self.model.score(texts_to_score)
324
  for d, s in zip(documents, scores):
325
  d.metadata["relevance_score"] = float(s)
326
 
 
330
  if d.metadata.get("doc_type") == "knowledge_graph"
331
  or d.metadata.get("relevance_score", 0) >= MIN_RELEVANCE_SCORE
332
  ]
333
+ for i, d in enumerate(filtered[:4]):
334
+ source = d.metadata.get("source_file", "unknown")
335
+ page = d.metadata.get("page", "?")
336
+ score = d.metadata.get("relevance_score", 0.0)
337
+ log_event(f"[Reranker] Retrieved: {source} (Page: {page})|{score:.4f}|{d.page_content[:40]}...")
338
  return filtered[:self.top_n]
339
 
340
  compressor = ScoredReranker(model=cross_encoder, top_n=top_n)
storage/chroma_db/chroma.sqlite3 CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:e65b5c4be6a0ca6865909e91e19c0b41a5bb732e913c3f8ce791ff046c04bffc
3
  size 29683712
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:7ef4ea9956fe593a75772e296a52b386783773ed497ba317718629e198503e44
3
  size 29683712
storage/knowledge_graph.graphml CHANGED
The diff for this file is too large to render. See raw diff