karthikmulugu08 commited on
Commit
6b5b399
·
verified ·
1 Parent(s): 8976277

Upload agent.py

Browse files
Files changed (1) hide show
  1. agent.py +385 -0
agent.py ADDED
@@ -0,0 +1,385 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ ClinIQ — Multi-step LangGraph agent.
3
+
4
+ Graph: classify_query → [decompose] → retrieve → generate → reflect ──→ END
5
+ ↑__________| (retry if not grounded, max 2x)
6
+
7
+ Query types:
8
+ simple — single-hop fact lookup ("What is the diagnosis?")
9
+ structured — extract as JSON list ("List all medications")
10
+ complex — multi-hop reasoning ("Do any meds interact with the allergy?")
11
+ comparison — across multiple docs ("How did medications change between visits?")
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import os
18
+ import re
19
+ import time
20
+ from typing import Any, Dict, Iterator, List, Literal, Optional, TypedDict
21
+
22
+ import httpx
23
+ from langgraph.graph import END, StateGraph
24
+
25
+ from retriever import Chunk, HybridRetriever
26
+
27
+ MODAL_ENDPOINT = os.getenv("MODAL_ENDPOINT", "")
28
+ MAX_CONTEXT_CHARS = 4000
29
+ MAX_RETRIES = 2
30
+
31
+ SYSTEM_PROMPT = """You are ClinIQ, a clinical document assistant for small medical clinics.
32
+ Answer ONLY from the provided document excerpts. Cite the source document for every claim.
33
+ If information is not in the excerpts, say clearly: "Not found in the provided documents."
34
+ Be concise and medically precise. Never guess or hallucinate clinical information."""
35
+
36
+ STRUCTURED_SCHEMAS = {
37
+ "medications": ("List every medication with dose and frequency as JSON array: "
38
+ '[{"name":"...","dose":"...","frequency":"...","route":"..."}]'),
39
+ "allergies": ('List every allergy with reaction as JSON array: '
40
+ '[{"substance":"...","reaction":"...","severity":"..."}]'),
41
+ "diagnoses": ('List all diagnoses/conditions as JSON array: '
42
+ '[{"diagnosis":"...","type":"primary|secondary"}]'),
43
+ "followup": ('List all follow-up appointments/instructions as JSON array: '
44
+ '[{"action":"...","date":"...","provider":"..."}]'),
45
+ "vitals": ('Extract all vital signs as JSON object: '
46
+ '{"bp":"...","hr":"...","rr":"...","temp":"...","spo2":"...","weight":"..."}'),
47
+ }
48
+
49
+
50
+ # ── State ──────────────────────────────────────────────────────────────────────
51
+
52
+ class AgentState(TypedDict):
53
+ question: str
54
+ query_type: str # simple | structured | complex | comparison
55
+ struct_key: Optional[str] # which STRUCTURED_SCHEMAS key if structured
56
+ sub_queries: List[str] # decomposed queries for complex/comparison
57
+ chunks: List[Chunk]
58
+ context: str
59
+ answer: str
60
+ structured_data: Optional[Any] # parsed JSON for structured answers
61
+ reflection_ok: bool
62
+ reflection_note: str
63
+ retry_count: int
64
+ trace: List[Dict[str, Any]]
65
+
66
+
67
+ # ── Helpers ────────────────────────────────────────────────────────────────────
68
+
69
+ def _call_model(prompt: str, max_tokens: int = 600, json_mode: bool = False) -> str:
70
+ """Call Modal llama.cpp endpoint (or local fallback)."""
71
+ if MODAL_ENDPOINT:
72
+ resp = httpx.post(
73
+ MODAL_ENDPOINT,
74
+ json={"prompt": prompt, "max_tokens": max_tokens, "json_mode": json_mode},
75
+ timeout=120,
76
+ )
77
+ resp.raise_for_status()
78
+ return resp.json()["text"].strip()
79
+ return _local_fallback(prompt, max_tokens)
80
+
81
+
82
+ def _local_fallback(prompt: str, max_tokens: int) -> str:
83
+ import torch
84
+ from transformers import AutoModelForCausalLM, AutoTokenizer, pipeline as hf_pipeline
85
+
86
+ MODEL_ID = os.getenv("MODEL_ID", "Qwen/Qwen2.5-3B-Instruct")
87
+ if not hasattr(_local_fallback, "_pipe"):
88
+ tok = AutoTokenizer.from_pretrained(MODEL_ID)
89
+ model = AutoModelForCausalLM.from_pretrained(MODEL_ID, torch_dtype=torch.float32)
90
+ _local_fallback._pipe = hf_pipeline("text-generation", model=model, tokenizer=tok)
91
+ out = _local_fallback._pipe(prompt, max_new_tokens=max_tokens, do_sample=False,
92
+ temperature=None, top_p=None)
93
+ raw = out[0]["generated_text"]
94
+ return raw[len(prompt):].strip()
95
+
96
+
97
+ def _build_prompt(system: str, user: str) -> str:
98
+ return (f"<|im_start|>system\n{system}<|im_end|>\n"
99
+ f"<|im_start|>user\n{user}<|im_end|>\n"
100
+ f"<|im_start|>assistant\n")
101
+
102
+
103
+ def _detect_struct_key(question: str) -> Optional[str]:
104
+ q = question.lower()
105
+ if any(w in q for w in ["medication", "medicine", "drug", "prescribed", "taking", "rx"]):
106
+ return "medications"
107
+ if any(w in q for w in ["allerg"]):
108
+ return "allergies"
109
+ if any(w in q for w in ["diagnos", "condition", "problem list"]):
110
+ return "diagnoses"
111
+ if any(w in q for w in ["follow", "appointment", "next visit", "schedule"]):
112
+ return "followup"
113
+ if any(w in q for w in ["vital", "blood pressure", "heart rate", "bp ", "hr ", "temp"]):
114
+ return "vitals"
115
+ return None
116
+
117
+
118
+ # ── Nodes ──────────────────────────────────────────────────────────────────────
119
+
120
+ def node_classify(state: AgentState) -> AgentState:
121
+ t0 = time.time()
122
+ q = state["question"].lower()
123
+
124
+ # Rule-based classification (fast, no LLM call needed)
125
+ is_list_question = any(w in q for w in ["list", "all ", "what are", "enumerate", "summarize"])
126
+ struct_key = _detect_struct_key(state["question"])
127
+ is_comparison = any(w in q for w in ["changed", "different", "compare", "between", "versus", "vs", "previous"])
128
+ is_complex = any(w in q for w in ["interact", "relate", "given", "safe", "why", "how does", "affect"])
129
+
130
+ if struct_key and is_list_question:
131
+ qtype = "structured"
132
+ elif is_comparison:
133
+ qtype = "comparison"
134
+ elif is_complex:
135
+ qtype = "complex"
136
+ else:
137
+ qtype = "simple"
138
+
139
+ state["query_type"] = qtype
140
+ state["struct_key"] = struct_key if qtype == "structured" else None
141
+ state["trace"].append({
142
+ "step": "classify",
143
+ "icon": "🔍",
144
+ "detail": f"Query type: **{qtype}**" + (f" (extracting: {struct_key})" if struct_key else ""),
145
+ "ms": int((time.time() - t0) * 1000),
146
+ })
147
+ return state
148
+
149
+
150
+ def node_decompose(state: AgentState) -> AgentState:
151
+ """For complex/comparison queries, break into focused sub-queries."""
152
+ t0 = time.time()
153
+ if state["query_type"] in ("simple", "structured"):
154
+ state["sub_queries"] = [state["question"]]
155
+ return state
156
+
157
+ prompt = _build_prompt(
158
+ "You are a query decomposition assistant. Break the user's question into 2-3 focused sub-queries "
159
+ "that can each be answered independently from a medical document. Output ONLY a JSON array of strings.",
160
+ f"Question: {state['question']}\nOutput format: [\"sub-query 1\", \"sub-query 2\"]"
161
+ )
162
+ try:
163
+ raw = _call_model(prompt, max_tokens=200)
164
+ match = re.search(r'\[.*?\]', raw, re.DOTALL)
165
+ sub_queries = json.loads(match.group()) if match else [state["question"]]
166
+ except Exception:
167
+ sub_queries = [state["question"]]
168
+
169
+ state["sub_queries"] = sub_queries[:3]
170
+ state["trace"].append({
171
+ "step": "decompose",
172
+ "icon": "✂️",
173
+ "detail": f"Split into **{len(sub_queries)}** sub-queries",
174
+ "sub_queries": sub_queries,
175
+ "ms": int((time.time() - t0) * 1000),
176
+ })
177
+ return state
178
+
179
+
180
+ def node_retrieve(state: AgentState, retriever: HybridRetriever) -> AgentState:
181
+ t0 = time.time()
182
+ seen_ids: set = set()
183
+ all_chunks: List[Chunk] = []
184
+
185
+ for sq in state["sub_queries"]:
186
+ for c in retriever.retrieve(sq, top_k=4):
187
+ uid = (c.source, c.index)
188
+ if uid not in seen_ids:
189
+ seen_ids.add(uid)
190
+ all_chunks.append(c)
191
+
192
+ # Limit total chunks
193
+ all_chunks = all_chunks[:8]
194
+ state["chunks"] = all_chunks
195
+
196
+ sources = list(dict.fromkeys(c.source for c in all_chunks))
197
+ state["trace"].append({
198
+ "step": "retrieve",
199
+ "icon": "📄",
200
+ "detail": f"Retrieved **{len(all_chunks)}** chunks from {len(sources)} document(s)",
201
+ "sources": sources,
202
+ "ms": int((time.time() - t0) * 1000),
203
+ })
204
+ return state
205
+
206
+
207
+ def node_build_context(state: AgentState) -> AgentState:
208
+ parts: List[str] = []
209
+ total = 0
210
+ for c in state["chunks"]:
211
+ snippet = f"[Document: {c.source}]\n{c.text}"
212
+ if total + len(snippet) > MAX_CONTEXT_CHARS:
213
+ break
214
+ parts.append(snippet)
215
+ total += len(snippet)
216
+ state["context"] = "\n\n---\n\n".join(parts)
217
+ return state
218
+
219
+
220
+ def node_generate(state: AgentState) -> AgentState:
221
+ t0 = time.time()
222
+ qtype = state["query_type"]
223
+
224
+ if qtype == "structured" and state["struct_key"]:
225
+ schema_hint = STRUCTURED_SCHEMAS[state["struct_key"]]
226
+ user_msg = (
227
+ f"Document excerpts:\n{state['context']}\n\n"
228
+ f"Task: {schema_hint}\n"
229
+ f"Question: {state['question']}\n"
230
+ f"Output ONLY valid JSON, no explanation."
231
+ )
232
+ raw = _call_model(_build_prompt(SYSTEM_PROMPT, user_msg), max_tokens=800, json_mode=True)
233
+ # Try to parse JSON
234
+ try:
235
+ match = re.search(r'(\[.*?\]|\{.*?\})', raw, re.DOTALL)
236
+ parsed = json.loads(match.group()) if match else None
237
+ except Exception:
238
+ parsed = None
239
+ state["structured_data"] = parsed
240
+ state["answer"] = raw if not parsed else json.dumps(parsed, indent=2)
241
+ else:
242
+ user_msg = (
243
+ f"Document excerpts:\n{state['context']}\n\n"
244
+ f"Question: {state['question']}"
245
+ )
246
+ state["answer"] = _call_model(_build_prompt(SYSTEM_PROMPT, user_msg), max_tokens=600)
247
+ state["structured_data"] = None
248
+
249
+ state["trace"].append({
250
+ "step": "generate",
251
+ "icon": "🧠",
252
+ "detail": f"Generated answer via Qwen2.5-3B-Instruct ({'structured JSON' if qtype == 'structured' else 'free text'})",
253
+ "ms": int((time.time() - t0) * 1000),
254
+ })
255
+ return state
256
+
257
+
258
+ def node_reflect(state: AgentState) -> AgentState:
259
+ """Check if the answer is grounded in the context. Flag if hallucinated."""
260
+ t0 = time.time()
261
+
262
+ # Skip reflection for structured data (it's JSON, harder to verify this way)
263
+ if state["query_type"] == "structured" and state["structured_data"]:
264
+ state["reflection_ok"] = True
265
+ state["reflection_note"] = "Structured extraction — skipped."
266
+ return state
267
+
268
+ # Quick heuristic: if answer says "not found" it's honest, skip
269
+ answer_lower = state["answer"].lower()
270
+ if "not found" in answer_lower or "not mentioned" in answer_lower or "not in" in answer_lower:
271
+ state["reflection_ok"] = True
272
+ state["reflection_note"] = "Model self-reported missing information — answer is honest."
273
+ state["trace"].append({
274
+ "step": "reflect",
275
+ "icon": "✅",
276
+ "detail": "Answer honest (model flagged missing info)",
277
+ "ms": int((time.time() - t0) * 1000),
278
+ })
279
+ return state
280
+
281
+ prompt = _build_prompt(
282
+ "You are a clinical fact-checker. Given an answer and source excerpts, "
283
+ "decide if the answer is fully supported by the excerpts. "
284
+ "Reply with JSON: {\"grounded\": true|false, \"note\": \"reason\"}",
285
+ f"Answer:\n{state['answer'][:800]}\n\nSource excerpts:\n{state['context'][:1500]}"
286
+ )
287
+ try:
288
+ raw = _call_model(prompt, max_tokens=150)
289
+ match = re.search(r'\{.*?\}', raw, re.DOTALL)
290
+ data = json.loads(match.group()) if match else {"grounded": True, "note": "parse error"}
291
+ ok = bool(data.get("grounded", True))
292
+ note = data.get("note", "")
293
+ except Exception:
294
+ ok, note = True, "Reflection parse error — defaulting to accept."
295
+
296
+ state["reflection_ok"] = ok
297
+ state["reflection_note"] = note
298
+ state["trace"].append({
299
+ "step": "reflect",
300
+ "icon": "✅" if ok else "⚠️",
301
+ "detail": f"Grounded: **{'yes' if ok else 'no — retrying'}** — {note}",
302
+ "ms": int((time.time() - t0) * 1000),
303
+ })
304
+ return state
305
+
306
+
307
+ def _should_retry(state: AgentState) -> str:
308
+ if not state["reflection_ok"] and state["retry_count"] < MAX_RETRIES:
309
+ state["retry_count"] += 1
310
+ # Broaden query on retry
311
+ state["sub_queries"] = [state["question"] + " (provide only information explicitly stated)"]
312
+ return "retrieve"
313
+ return END
314
+
315
+
316
+ # ── Graph ──────────────────────────────────────────────────────────────────────
317
+
318
+ def build_graph(retriever: HybridRetriever):
319
+ g = StateGraph(AgentState)
320
+ g.add_node("classify", node_classify)
321
+ g.add_node("decompose", node_decompose)
322
+ g.add_node("retrieve", lambda s: node_retrieve(s, retriever))
323
+ g.add_node("build_context", node_build_context)
324
+ g.add_node("generate", node_generate)
325
+ g.add_node("reflect", node_reflect)
326
+
327
+ g.set_entry_point("classify")
328
+ g.add_edge("classify", "decompose")
329
+ g.add_edge("decompose", "retrieve")
330
+ g.add_edge("retrieve", "build_context")
331
+ g.add_edge("build_context", "generate")
332
+ g.add_edge("generate", "reflect")
333
+ g.add_conditional_edges("reflect", _should_retry, {"retrieve": "retrieve", END: END})
334
+ return g.compile()
335
+
336
+
337
+ def run_query(graph, question: str) -> AgentState:
338
+ return graph.invoke({
339
+ "question": question,
340
+ "query_type": "simple",
341
+ "struct_key": None,
342
+ "sub_queries": [question],
343
+ "chunks": [],
344
+ "context": "",
345
+ "answer": "",
346
+ "structured_data": None,
347
+ "reflection_ok": True,
348
+ "reflection_note": "",
349
+ "retry_count": 0,
350
+ "trace": [],
351
+ })
352
+
353
+
354
+ def stream_query(graph, question: str) -> Iterator[Dict[str, Any]]:
355
+ """
356
+ Yields trace dicts as each node completes, then a final 'done' event.
357
+ Uses LangGraph streaming.
358
+ """
359
+ init: AgentState = {
360
+ "question": question,
361
+ "query_type": "simple",
362
+ "struct_key": None,
363
+ "sub_queries": [question],
364
+ "chunks": [],
365
+ "context": "",
366
+ "answer": "",
367
+ "structured_data": None,
368
+ "reflection_ok": True,
369
+ "reflection_note": "",
370
+ "retry_count": 0,
371
+ "trace": [],
372
+ }
373
+ for event in graph.stream(init, stream_mode="updates"):
374
+ for node_name, state_update in event.items():
375
+ new_trace = state_update.get("trace", [])
376
+ if new_trace:
377
+ yield {"type": "trace_step", "step": new_trace[-1], "node": node_name}
378
+ if "answer" in state_update and state_update["answer"]:
379
+ yield {
380
+ "type": "answer",
381
+ "answer": state_update["answer"],
382
+ "structured_data": state_update.get("structured_data"),
383
+ "chunks": [{"source": c.source, "excerpt": c.text[:350]}
384
+ for c in state_update.get("chunks", [])[:4]],
385
+ }