github-actions commited on
Commit
b1198f0
·
1 Parent(s): fb01a6c

Auto deploy from GitHub

Browse files
.env.example CHANGED
@@ -11,4 +11,10 @@ OPENROUTER_MODEL_NAME=openai/gpt-oss-20b:free
11
  SUPABASE_URL=your_project_url
12
  SUPABASE_KEY=your_anon_key
13
  SUPABASE_SERVICE_ROLE_KEY=your_service_role_key
14
- SUPABASE_JWT_SECRET=your_jwt_secret
 
 
 
 
 
 
 
11
  SUPABASE_URL=your_project_url
12
  SUPABASE_KEY=your_anon_key
13
  SUPABASE_SERVICE_ROLE_KEY=your_service_role_key
14
+ SUPABASE_JWT_SECRET=your_jwt_secret
15
+ # Optional: enable fallback to DEFAULT_PATIENT_UUID when no credentials provided (development only)
16
+ DEV_ALLOW_DEFAULT_PATIENT=true
17
+ # Optional: default patient id used in dev fallback
18
+ DEFAULT_PATIENT_UUID=00000000-0000-0000-0000-000000000000
19
+ # Rate limit per minute (integer)
20
+ RATE_LIMIT_PER_MINUTE=30
app.py CHANGED
@@ -1,8 +1,12 @@
1
- from fastapi import FastAPI, HTTPException, Depends
2
  from pydantic import BaseModel
3
  from typing import List, Dict, Any, Optional
 
4
  import os
5
  import sys
 
 
 
6
  from dotenv import load_dotenv
7
  from langchain_core.messages import HumanMessage, AIMessage, ToolMessage
8
  from src.utils.logger import setup_logger
@@ -24,13 +28,17 @@ from src.tools.fhir_memory import (
24
  create_session,
25
  get_sessions_by_patient,
26
  get_chat_history_by_session,
27
- save_chat_as_fhir
 
 
28
  )
 
29
 
30
  load_dotenv()
31
  logger = setup_logger("FastAPI")
32
 
33
  app = FastAPI(title="Medical AI Backend")
 
34
 
35
  class ChatMessage(BaseModel):
36
  role: str
@@ -43,6 +51,11 @@ class PipelineRequest(BaseModel):
43
  mode: str = "Standard Triage" # "Standard Triage" or "CDM Proactive"
44
  history: List[Dict[str, Any]] = []
45
 
 
 
 
 
 
46
  class PipelineResponse(BaseModel):
47
  messages: List[Dict[str, Any]]
48
  final_state: Dict[str, Any]
@@ -91,10 +104,14 @@ async def process_pipeline(request: PipelineRequest, user_id: str = Depends(get_
91
  initial_messages = convert_to_langchain_messages(history)
92
  initial_messages.append(HumanMessage(content=enhanced_prompt))
93
 
 
 
94
  initial_state = {
95
  "messages": initial_messages,
96
  "user_role": "unknown",
97
  "intent_type": "unknown",
 
 
98
  "is_valid": False,
99
  "is_safe": False,
100
  "attempts": 0,
@@ -105,20 +122,15 @@ async def process_pipeline(request: PipelineRequest, user_id: str = Depends(get_
105
  "logs": [],
106
  "metrics": []
107
  }
108
-
109
  initial_state["patient_id"] = patient_id
110
 
111
  try:
112
- final_state = await active_pipeline.ainvoke(initial_state)
113
-
114
- # Save chat history as FHIR Communication linked to session
115
- new_msgs_for_fhir = []
116
- for msg in final_state["messages"][len(initial_messages)-1:]:
117
- role = "user" if isinstance(msg, HumanMessage) else "assistant"
118
- new_msgs_for_fhir.append({"role": role, "content": msg.content})
119
 
120
- save_chat_as_fhir.invoke({"patient_id": patient_id, "messages": new_msgs_for_fhir, "session_id": session_id})
121
-
122
  # Format messages for response
123
  resp_messages = []
124
  for msg in final_state["messages"][len(initial_messages):]:
@@ -146,6 +158,16 @@ async def list_sessions(user_id: str = Depends(get_active_user)):
146
  async def get_session_history(session_id: str, user_id: str = Depends(get_active_user)):
147
  return get_chat_history_by_session.invoke({"session_id": session_id})
148
 
 
 
 
 
 
 
 
 
 
 
149
  @app.get("/patient/summary")
150
  async def get_patient_summary(user_id: str = Depends(get_active_user)):
151
  try:
@@ -171,6 +193,38 @@ async def set_llm_provider(provider: str, user_id: str = Depends(get_current_use
171
  except Exception as e:
172
  raise HTTPException(status_code=500, detail=str(e))
173
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
174
  if __name__ == "__main__":
175
  import uvicorn
176
  import datetime
 
1
+ from fastapi import FastAPI, HTTPException, Depends, Request
2
  from pydantic import BaseModel
3
  from typing import List, Dict, Any, Optional
4
+ import datetime
5
  import os
6
  import sys
7
+ import uuid
8
+ import csv
9
+ import datetime
10
  from dotenv import load_dotenv
11
  from langchain_core.messages import HumanMessage, AIMessage, ToolMessage
12
  from src.utils.logger import setup_logger
 
28
  create_session,
29
  get_sessions_by_patient,
30
  get_chat_history_by_session,
31
+ save_chat_as_fhir,
32
+ _get_client
33
+ get_chat_history_by_session
34
  )
35
+ from src.mcp.server import MedicalMCPServer
36
 
37
  load_dotenv()
38
  logger = setup_logger("FastAPI")
39
 
40
  app = FastAPI(title="Medical AI Backend")
41
+ mcp_server = MedicalMCPServer()
42
 
43
  class ChatMessage(BaseModel):
44
  role: str
 
51
  mode: str = "Standard Triage" # "Standard Triage" or "CDM Proactive"
52
  history: List[Dict[str, Any]] = []
53
 
54
+ class ClinicianScore(BaseModel):
55
+ trace_id: str
56
+ score: int
57
+ feedback: str
58
+
59
  class PipelineResponse(BaseModel):
60
  messages: List[Dict[str, Any]]
61
  final_state: Dict[str, Any]
 
104
  initial_messages = convert_to_langchain_messages(history)
105
  initial_messages.append(HumanMessage(content=enhanced_prompt))
106
 
107
+ trace_id = str(uuid.uuid4())
108
+
109
  initial_state = {
110
  "messages": initial_messages,
111
  "user_role": "unknown",
112
  "intent_type": "unknown",
113
+ "trace_id": trace_id,
114
+ "session_id": session_id,
115
  "is_valid": False,
116
  "is_safe": False,
117
  "attempts": 0,
 
122
  "logs": [],
123
  "metrics": []
124
  }
125
+
126
  initial_state["patient_id"] = patient_id
127
 
128
  try:
129
+ final_state = await active_pipeline.ainvoke(
130
+ initial_state,
131
+ config={"run_name": "MedicalPipeline", "metadata": {"trace_id": trace_id}}
132
+ )
 
 
 
133
 
 
 
134
  # Format messages for response
135
  resp_messages = []
136
  for msg in final_state["messages"][len(initial_messages):]:
 
158
  async def get_session_history(session_id: str, user_id: str = Depends(get_active_user)):
159
  return get_chat_history_by_session.invoke({"session_id": session_id})
160
 
161
+ @app.post("/feedback/score")
162
+ async def save_clinician_score(score_data: ClinicianScore, user_id: str = Depends(get_active_user)):
163
+ file_exists = os.path.isfile("clinician_scores.csv")
164
+ with open("clinician_scores.csv", mode="a", newline="", encoding="utf-8") as f:
165
+ writer = csv.writer(f)
166
+ if not file_exists:
167
+ writer.writerow(["trace_id", "user_id", "score", "feedback", "timestamp"])
168
+ writer.writerow([score_data.trace_id, user_id, score_data.score, score_data.feedback, datetime.datetime.now().isoformat()])
169
+ return {"status": "success", "message": "Score saved successfully"}
170
+
171
  @app.get("/patient/summary")
172
  async def get_patient_summary(user_id: str = Depends(get_active_user)):
173
  try:
 
193
  except Exception as e:
194
  raise HTTPException(status_code=500, detail=str(e))
195
 
196
+ @app.post("/mcp")
197
+ async def mcp_endpoint(request: Request):
198
+ body = await request.json()
199
+ return await mcp_server.handle_request(body)
200
+
201
+ @app.get("/Patient/{patient_id}")
202
+ async def get_fhir_patient(patient_id: str):
203
+ client = _get_client()
204
+ res = client.table("patients").select("resource").eq("id", patient_id).execute()
205
+ if res.data:
206
+ return res.data[0]["resource"]
207
+ raise HTTPException(status_code=404, detail="Patient not found")
208
+
209
+ @app.get("/Observation")
210
+ async def get_fhir_observation(patient: str, code: Optional[str] = None):
211
+ client = _get_client()
212
+ query = client.table("observations").select("resource").eq("patient_id", patient)
213
+ res = query.execute()
214
+ observations = [r["resource"] for r in res.data]
215
+ if code:
216
+ observations = [
217
+ obs for obs in observations
218
+ if any(c.get("code") == code for c in obs.get("code", {}).get("coding", []))
219
+ ]
220
+ return observations
221
+
222
+ @app.get("/Communication")
223
+ async def get_fhir_communication(patient: str):
224
+ client = _get_client()
225
+ res = client.table("communications").select("resource").eq("patient_id", patient).execute()
226
+ return [r["resource"] for r in res.data]
227
+
228
  if __name__ == "__main__":
229
  import uvicorn
230
  import datetime
data/llama3.2-500.json ADDED
The diff for this file is too large to render. See raw diff
 
main.py CHANGED
@@ -1,7 +1,8 @@
1
- from fastapi import FastAPI, HTTPException
2
  from fastapi.middleware.cors import CORSMiddleware
3
  from pydantic import BaseModel
4
  from typing import List, Dict, Any, Optional
 
5
  import os
6
  import sys
7
  from dotenv import load_dotenv
@@ -17,7 +18,49 @@ from src.core.graph import medical_pipeline
17
  from src.core.graph_cdm import cdm_pipeline
18
  from src.core.model_manager import model_manager
19
  from src.agents.agent_instances import update_all_agents_llm
20
- from src.tools.fhir_memory import get_patient_summary_fhir, save_observation, save_patient
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
 
22
  load_dotenv()
23
  logger = setup_logger("FastAPI")
@@ -43,12 +86,14 @@ class ChatMessage(BaseModel):
43
  class PipelineRequest(BaseModel):
44
  prompt: str
45
  patient_id: Optional[str] = None
 
46
  mode: str = "Standard Triage" # "Standard Triage" or "CDM Proactive"
47
  history: List[Dict[str, Any]] = []
48
 
49
  class PipelineResponse(BaseModel):
50
  messages: List[Dict[str, Any]]
51
  final_state: Dict[str, Any]
 
52
 
53
  def convert_to_langchain_messages(history):
54
  messages = []
@@ -56,32 +101,58 @@ def convert_to_langchain_messages(history):
56
  if msg["role"] == "user":
57
  messages.append(HumanMessage(content=msg["content"]))
58
  elif msg["role"] == "assistant":
59
- # For now, simplifying. Full implementation would handle tool calls.
60
  messages.append(AIMessage(content=msg["content"]))
61
  return messages
62
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
  import json
64
  from fastapi.responses import StreamingResponse
65
 
66
  @app.post("/process_stream")
67
  async def process_pipeline_stream(request: PipelineRequest):
68
  logger.info(f"Streaming request for mode: {request.mode}")
69
-
70
- # Select pipeline
 
 
 
 
 
 
 
 
 
 
 
71
  active_pipeline = cdm_pipeline if request.mode == "CDM Proactive" else medical_pipeline
72
-
73
- # Prepare initial state
74
  enhanced_prompt = request.prompt
75
  if request.patient_id:
76
  enhanced_prompt = f"[System: User's Patient ID is {request.patient_id}]\n\n{request.prompt}"
77
-
78
- initial_messages = convert_to_langchain_messages(request.history)
79
  initial_messages.append(HumanMessage(content=enhanced_prompt))
80
-
81
  initial_state = {
82
  "messages": initial_messages,
83
  "user_role": "unknown",
84
  "intent_type": "unknown",
 
85
  "is_valid": False,
86
  "is_safe": False,
87
  "attempts": 0,
@@ -90,46 +161,72 @@ async def process_pipeline_stream(request: PipelineRequest):
90
  "research_output": "",
91
  "sources": [],
92
  "logs": [],
93
- "metrics": []
94
  }
95
-
96
  if request.patient_id:
97
  initial_state["patient_id"] = request.patient_id
98
 
 
 
99
  async def event_generator():
 
 
 
 
 
 
 
100
  try:
101
- # Using astream_events v2 for more granular control
102
  async for event in active_pipeline.astream_events(initial_state, version="v2"):
103
  kind = event["event"]
104
-
105
  # Progress Update: Node start
106
  if kind == "on_chain_start" and event.get("name") in [
107
- "role_classifier", "patient_llm", "safety_check", "validator",
108
- "intent_classifier", "persistence_node", "tools_node"
 
 
109
  ]:
110
  yield f"data: {json.dumps({'type': 'node', 'node': event['name']})}\n\n"
111
-
112
  # Progress Update: Graph Nodes
 
 
 
 
 
 
 
 
 
 
 
113
  elif kind == "on_chain_end" and "node" in event.get("metadata", {}):
114
  node_name = event["metadata"]["node"]
115
  yield f"data: {json.dumps({'type': 'node_complete', 'node': node_name})}\n\n"
116
 
117
- # Token Streaming: Chat Model stream
118
  elif kind == "on_chat_model_stream":
119
- content = event["data"]["chunk"].content
120
  if content:
 
121
  yield f"data: {json.dumps({'type': 'token', 'content': content})}\n\n"
122
-
123
  # Final State: End of graph
124
  elif kind == "on_chain_end" and event["name"] == "LangGraph":
125
- final_state = event["data"]["output"]
126
  final_msg = ""
 
 
127
  if "messages" in final_state and final_state["messages"]:
128
- final_msg = final_state["messages"][-1].content
129
- # Format state for frontend
 
 
130
  clean_state = {k: v for k, v in final_state.items() if k != "messages"}
131
- yield f"data: {json.dumps({'type': 'end', 'final_state': clean_state, 'final_message': final_msg})}\n\n"
132
 
 
 
133
  except Exception as e:
134
  logger.error(f"Streaming error: {str(e)}")
135
  yield f"data: {json.dumps({'type': 'error', 'detail': str(e)})}\n\n"
@@ -139,22 +236,33 @@ async def process_pipeline_stream(request: PipelineRequest):
139
  @app.post("/process", response_model=PipelineResponse)
140
  async def process_pipeline(request: PipelineRequest):
141
  logger.info(f"Processing request for mode: {request.mode}")
142
-
143
- # Select pipeline
 
 
 
 
 
 
 
 
 
 
 
144
  active_pipeline = cdm_pipeline if request.mode == "CDM Proactive" else medical_pipeline
145
-
146
- # Prepare initial state
147
  enhanced_prompt = request.prompt
148
  if request.patient_id:
149
  enhanced_prompt = f"[System: User's Patient ID is {request.patient_id}]\n\n{request.prompt}"
150
-
151
- initial_messages = convert_to_langchain_messages(request.history)
152
  initial_messages.append(HumanMessage(content=enhanced_prompt))
153
-
154
  initial_state = {
155
  "messages": initial_messages,
156
  "user_role": "unknown",
157
  "intent_type": "unknown",
 
158
  "is_valid": False,
159
  "is_safe": False,
160
  "attempts": 0,
@@ -163,30 +271,31 @@ async def process_pipeline(request: PipelineRequest):
163
  "research_output": "",
164
  "sources": [],
165
  "logs": [],
166
- "metrics": []
167
  }
168
-
169
  if request.patient_id:
170
  initial_state["patient_id"] = request.patient_id
171
 
172
  try:
173
- # Run the pipeline
174
  final_state = await active_pipeline.ainvoke(initial_state)
175
-
176
- # Format messages for response
177
  resp_messages = []
178
  for msg in final_state["messages"][len(initial_messages):]:
179
  from langchain_core.messages import AIMessage, ToolMessage
180
  msg_type = "assistant" if isinstance(msg, AIMessage) else "tool" if isinstance(msg, ToolMessage) else "user"
181
- resp_messages.append({
182
- "role": msg_type,
183
- "content": msg.content,
184
- "type": msg.__class__.__name__
185
- })
186
-
 
 
187
  return PipelineResponse(
188
  messages=resp_messages,
189
- final_state={k: v for k, v in final_state.items() if k != "messages"}
 
190
  )
191
  except Exception as e:
192
  logger.error(f"Pipeline error: {str(e)}")
@@ -225,3 +334,84 @@ if __name__ == "__main__":
225
  port = int(os.environ.get("PORT", 8000))
226
  logger.info(f"Starting server on port {port}")
227
  uvicorn.run("main:app", host="0.0.0.0", port=port, reload=False)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI, HTTPException, Request
2
  from fastapi.middleware.cors import CORSMiddleware
3
  from pydantic import BaseModel
4
  from typing import List, Dict, Any, Optional
5
+ import datetime
6
  import os
7
  import sys
8
  from dotenv import load_dotenv
 
18
  from src.core.graph_cdm import cdm_pipeline
19
  from src.core.model_manager import model_manager
20
  from src.agents.agent_instances import update_all_agents_llm
21
+ from src.tools.fhir_memory import (
22
+ get_patient_summary_fhir,
23
+ save_observation,
24
+ save_patient,
25
+ create_session,
26
+ get_chat_history_by_session,
27
+ _get_client,
28
+ )
29
+ from src.utils.auth import create_dev_token
30
+ import re
31
+ import time
32
+ import uuid
33
+
34
+ # Simple in-memory rate limiter: keys map to list of request timestamps
35
+ _RATE_LIMIT_WINDOW = 60 # seconds
36
+ _RATE_LIMIT_MAX = int(os.getenv("RATE_LIMIT_PER_MINUTE", "30"))
37
+ _rate_store = {}
38
+
39
+ def _check_rate_limit(key: str):
40
+ now = time.time()
41
+ bucket = _rate_store.get(key, [])
42
+ # drop old
43
+ bucket = [t for t in bucket if now - t < _RATE_LIMIT_WINDOW]
44
+ if len(bucket) >= _RATE_LIMIT_MAX:
45
+ return False
46
+ bucket.append(now)
47
+ _rate_store[key] = bucket
48
+ return True
49
+
50
+ _PROMPT_INJECTION_PATTERNS = [
51
+ r"ignore (system|instructions|previous|above)",
52
+ r"disregard (previous|above|system)",
53
+ r"do not follow (system|instructions)",
54
+ r"override (system|instructions)",
55
+ ]
56
+
57
+ def _detect_prompt_injection(text: str) -> bool:
58
+ if not text:
59
+ return False
60
+ for p in _PROMPT_INJECTION_PATTERNS:
61
+ if re.search(p, text, re.IGNORECASE):
62
+ return True
63
+ return False
64
 
65
  load_dotenv()
66
  logger = setup_logger("FastAPI")
 
86
  class PipelineRequest(BaseModel):
87
  prompt: str
88
  patient_id: Optional[str] = None
89
+ session_id: Optional[str] = None
90
  mode: str = "Standard Triage" # "Standard Triage" or "CDM Proactive"
91
  history: List[Dict[str, Any]] = []
92
 
93
  class PipelineResponse(BaseModel):
94
  messages: List[Dict[str, Any]]
95
  final_state: Dict[str, Any]
96
+ session_id: Optional[str] = None
97
 
98
  def convert_to_langchain_messages(history):
99
  messages = []
 
101
  if msg["role"] == "user":
102
  messages.append(HumanMessage(content=msg["content"]))
103
  elif msg["role"] == "assistant":
 
104
  messages.append(AIMessage(content=msg["content"]))
105
  return messages
106
 
107
+
108
+ def load_session_history(session_id: str):
109
+ if not session_id:
110
+ return []
111
+
112
+ raw_history = get_chat_history_by_session.invoke({"session_id": session_id})
113
+ history = []
114
+ for comm in raw_history:
115
+ payload = comm.get("payload", [])
116
+ for item in payload:
117
+ content = item.get("contentString", "")
118
+ if ":" in content:
119
+ role, text = content.split(":", 1)
120
+ history.append({"role": role.strip(), "content": text.strip()})
121
+ return history
122
+
123
  import json
124
  from fastapi.responses import StreamingResponse
125
 
126
  @app.post("/process_stream")
127
  async def process_pipeline_stream(request: PipelineRequest):
128
  logger.info(f"Streaming request for mode: {request.mode}")
129
+
130
+ history = list(request.history)
131
+ session_id = request.session_id
132
+ if not session_id and request.patient_id:
133
+ session_id = create_session.invoke(
134
+ {
135
+ "patient_id": request.patient_id,
136
+ "title": f"Session {datetime.datetime.now().strftime('%Y-%m-%d %H:%M')}",
137
+ }
138
+ )
139
+ if session_id and not history:
140
+ history = load_session_history(session_id)
141
+
142
  active_pipeline = cdm_pipeline if request.mode == "CDM Proactive" else medical_pipeline
143
+
 
144
  enhanced_prompt = request.prompt
145
  if request.patient_id:
146
  enhanced_prompt = f"[System: User's Patient ID is {request.patient_id}]\n\n{request.prompt}"
147
+
148
+ initial_messages = convert_to_langchain_messages(history)
149
  initial_messages.append(HumanMessage(content=enhanced_prompt))
150
+
151
  initial_state = {
152
  "messages": initial_messages,
153
  "user_role": "unknown",
154
  "intent_type": "unknown",
155
+ "session_id": session_id,
156
  "is_valid": False,
157
  "is_safe": False,
158
  "attempts": 0,
 
161
  "research_output": "",
162
  "sources": [],
163
  "logs": [],
164
+ "metrics": [],
165
  }
166
+
167
  if request.patient_id:
168
  initial_state["patient_id"] = request.patient_id
169
 
170
+ final_state = None
171
+
172
  async def event_generator():
173
+ saw_stream = False
174
+
175
+ def chunk_text(text: str, size: int = 24):
176
+ for start in range(0, len(text), size):
177
+ yield text[start:start + size]
178
+
179
+ nonlocal final_state
180
  try:
 
181
  async for event in active_pipeline.astream_events(initial_state, version="v2"):
182
  kind = event["event"]
183
+
184
  # Progress Update: Node start
185
  if kind == "on_chain_start" and event.get("name") in [
186
+ "role_classifier", "patient_llm", "caregiver_llm", "safety_check", "validator",
187
+ "intent_classifier", "persistence_node", "tools_node",
188
+ "diagnosis_assist", "treatment_assist", "monitoring_assist", "general_assist",
189
+ "merge_outputs", "research_agent", "dietary_assist"
190
  ]:
191
  yield f"data: {json.dumps({'type': 'node', 'node': event['name']})}\n\n"
192
+
193
  # Progress Update: Graph Nodes
194
+ if kind == "on_chain_start" and event.get("name") in [
195
+ "role_classifier",
196
+ "patient_llm",
197
+ "safety_check",
198
+ "validator",
199
+ "intent_classifier",
200
+ "persistence_node",
201
+ "tools_node",
202
+ ]:
203
+ yield f"data: {json.dumps({'type': 'node', 'node': event['name']})}\n\n"
204
+
205
  elif kind == "on_chain_end" and "node" in event.get("metadata", {}):
206
  node_name = event["metadata"]["node"]
207
  yield f"data: {json.dumps({'type': 'node_complete', 'node': node_name})}\n\n"
208
 
 
209
  elif kind == "on_chat_model_stream":
210
+ content = getattr(event["data"]["chunk"], "content", "")
211
  if content:
212
+ saw_stream = True
213
  yield f"data: {json.dumps({'type': 'token', 'content': content})}\n\n"
214
+
215
  # Final State: End of graph
216
  elif kind == "on_chain_end" and event["name"] == "LangGraph":
217
+ final_state = event["data"].get("output", {}) or {}
218
  final_msg = ""
219
+
220
+ # Extract the final message from the state
221
  if "messages" in final_state and final_state["messages"]:
222
+ last_msg = final_state["messages"][-1]
223
+ final_msg = last_msg.content if hasattr(last_msg, "content") else str(last_msg)
224
+
225
+ # Format state for frontend (exclude messages to save bandwidth)
226
  clean_state = {k: v for k, v in final_state.items() if k != "messages"}
 
227
 
228
+ yield f"data: {json.dumps({'type': 'end', 'final_state': clean_state, 'final_message': final_msg})}\n\n"
229
+
230
  except Exception as e:
231
  logger.error(f"Streaming error: {str(e)}")
232
  yield f"data: {json.dumps({'type': 'error', 'detail': str(e)})}\n\n"
 
236
  @app.post("/process", response_model=PipelineResponse)
237
  async def process_pipeline(request: PipelineRequest):
238
  logger.info(f"Processing request for mode: {request.mode}")
239
+
240
+ history = list(request.history)
241
+ session_id = request.session_id
242
+ if not session_id and request.patient_id:
243
+ session_id = create_session.invoke(
244
+ {
245
+ "patient_id": request.patient_id,
246
+ "title": f"Session {datetime.datetime.now().strftime('%Y-%m-%d %H:%M')}",
247
+ }
248
+ )
249
+ if session_id and not history:
250
+ history = load_session_history(session_id)
251
+
252
  active_pipeline = cdm_pipeline if request.mode == "CDM Proactive" else medical_pipeline
253
+
 
254
  enhanced_prompt = request.prompt
255
  if request.patient_id:
256
  enhanced_prompt = f"[System: User's Patient ID is {request.patient_id}]\n\n{request.prompt}"
257
+
258
+ initial_messages = convert_to_langchain_messages(history)
259
  initial_messages.append(HumanMessage(content=enhanced_prompt))
260
+
261
  initial_state = {
262
  "messages": initial_messages,
263
  "user_role": "unknown",
264
  "intent_type": "unknown",
265
+ "session_id": session_id,
266
  "is_valid": False,
267
  "is_safe": False,
268
  "attempts": 0,
 
271
  "research_output": "",
272
  "sources": [],
273
  "logs": [],
274
+ "metrics": [],
275
  }
276
+
277
  if request.patient_id:
278
  initial_state["patient_id"] = request.patient_id
279
 
280
  try:
 
281
  final_state = await active_pipeline.ainvoke(initial_state)
282
+
 
283
  resp_messages = []
284
  for msg in final_state["messages"][len(initial_messages):]:
285
  from langchain_core.messages import AIMessage, ToolMessage
286
  msg_type = "assistant" if isinstance(msg, AIMessage) else "tool" if isinstance(msg, ToolMessage) else "user"
287
+ resp_messages.append(
288
+ {
289
+ "role": msg_type,
290
+ "content": msg.content,
291
+ "type": msg.__class__.__name__,
292
+ }
293
+ )
294
+
295
  return PipelineResponse(
296
  messages=resp_messages,
297
+ final_state={k: v for k, v in final_state.items() if k != "messages"},
298
+ session_id=session_id,
299
  )
300
  except Exception as e:
301
  logger.error(f"Pipeline error: {str(e)}")
 
334
  port = int(os.environ.get("PORT", 8000))
335
  logger.info(f"Starting server on port {port}")
336
  uvicorn.run("main:app", host="0.0.0.0", port=port, reload=False)
337
+
338
+
339
+ class RegisterRequest(BaseModel):
340
+ username: str
341
+ first_name: str
342
+ last_name: str
343
+
344
+ @app.post("/auth/register")
345
+ async def dev_register(req: RegisterRequest):
346
+ if not req.username.strip():
347
+ raise HTTPException(status_code=400, detail="Username is required")
348
+ if not req.first_name.strip():
349
+ raise HTTPException(status_code=400, detail="First name is required")
350
+ if not req.last_name.strip():
351
+ raise HTTPException(status_code=400, detail="Last name is required")
352
+
353
+ client = _get_client()
354
+ try:
355
+ # Check if username already exists
356
+ res = client.table("patients").select("id").eq("resource->>username", req.username.strip()).execute()
357
+ if res.data:
358
+ raise HTTPException(status_code=400, detail="Username already exists")
359
+
360
+ pid = str(uuid.uuid4())
361
+ full_name = f"{req.first_name.strip()} {req.last_name.strip()}"
362
+ fhir_patient = {
363
+ "resourceType": "Patient",
364
+ "id": pid,
365
+ "active": True,
366
+ "name": [{
367
+ "text": full_name,
368
+ "use": "official",
369
+ "given": [req.first_name.strip()],
370
+ "family": req.last_name.strip()
371
+ }],
372
+ "username": req.username.strip(),
373
+ "meta": {
374
+ "lastUpdated": datetime.datetime.now(datetime.timezone.utc).isoformat()
375
+ }
376
+ }
377
+ data = {
378
+ "id": pid,
379
+ "resource": fhir_patient,
380
+ "last_updated": datetime.datetime.now(datetime.timezone.utc).isoformat()
381
+ }
382
+ client.table("patients").insert(data).execute()
383
+
384
+ token = create_dev_token(pid, expires_minutes=24 * 60)
385
+ return {"status": "ok", "patient_id": pid, "token": token}
386
+ except HTTPException as he:
387
+ raise he
388
+ except Exception as e:
389
+ logger.error(f"Failed to register: {e}")
390
+ raise HTTPException(status_code=500, detail=str(e))
391
+
392
+
393
+ class LoginRequest(BaseModel):
394
+ username: str
395
+
396
+ @app.post("/auth/login")
397
+ async def dev_login(req: LoginRequest):
398
+ """Development-only login endpoint that verifies username.
399
+ """
400
+ if not req.username:
401
+ raise HTTPException(status_code=400, detail="Username required")
402
+
403
+ client = _get_client()
404
+ try:
405
+ res = client.table("patients").select("*").eq("resource->>username", req.username.strip()).execute()
406
+ if not res.data:
407
+ raise HTTPException(status_code=404, detail="Username not found. Please register first.")
408
+
409
+ patient = res.data[0]
410
+ pid = patient["id"]
411
+ token = create_dev_token(pid, expires_minutes=24 * 60)
412
+ return {"status": "ok", "patient_id": pid, "token": token}
413
+ except HTTPException as he:
414
+ raise he
415
+ except Exception as e:
416
+ logger.error(f"Failed to login: {e}")
417
+ raise HTTPException(status_code=500, detail=str(e))
requirements.txt CHANGED
@@ -7,8 +7,6 @@ pysqlite3
7
  bs4
8
  requests
9
  pypdf
10
- pymongo
11
- langchain-mongodb
12
  langchain-openai
13
  pydantic
14
  ddgs
 
7
  bs4
8
  requests
9
  pypdf
 
 
10
  langchain-openai
11
  pydantic
12
  ddgs
src/agent_params.py ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ from pathlib import Path
3
+
4
+
5
+ def _load_params() -> dict:
6
+ params_path = Path(__file__).with_name("params.json")
7
+ if not params_path.exists():
8
+ return {}
9
+
10
+ with params_path.open("r", encoding="utf-8") as handle:
11
+ data = json.load(handle)
12
+
13
+ return data if isinstance(data, dict) else {}
14
+
15
+
16
+ def get_agent_params(agent_name: str) -> dict:
17
+ params = _load_params().get(agent_name, {})
18
+ return params if isinstance(params, dict) else {}
src/agents/agent_instances.py CHANGED
@@ -1,5 +1,5 @@
1
  from src.agents.agents import (
2
- RoleClassifier, PatientLLM, ResponseValidator, SafetyCheck,
3
  IntentClassifier, ClinicalSpecialist, OutputMerger, ResearchAgent,
4
  DietarySpecialist
5
  )
@@ -11,6 +11,7 @@ logger = setup_logger("AgentInstances")
11
  # Instantiate all agents
12
  role_classifier = RoleClassifier()
13
  patient_llm = PatientLLM()
 
14
  validator = ResponseValidator()
15
  safety_check = SafetyCheck()
16
  intent_classifier = IntentClassifier()
@@ -31,21 +32,19 @@ trend_analyzer = TrendAnalyzer()
31
 
32
  def update_all_agents_llm(provider_name: str):
33
  from src.core.model_manager import model_manager
34
-
35
  if model_manager.provider == provider_name.lower():
36
  return
37
-
38
  model_manager.provider = provider_name.lower()
39
  logger.info(f"Switching LLM provider to: {provider_name}")
40
-
41
  all_agents = [
42
- role_classifier, patient_llm, validator, safety_check,
43
  intent_classifier, diagnosis_assist, treatment_assist,
44
  monitoring_assist, general_assist, output_merger,
45
  research_agent, dietary_assist, health_coach, trend_analyzer
46
  ]
47
-
48
  for agent in all_agents:
49
- agent.llm = model_manager.get_llm()
50
- if hasattr(agent, 'tools') and agent.tools:
51
- agent.llm = agent.llm.bind_tools(agent.tools)
 
1
  from src.agents.agents import (
2
+ RoleClassifier, PatientLLM, CaregiverLLM, ResponseValidator, SafetyCheck,
3
  IntentClassifier, ClinicalSpecialist, OutputMerger, ResearchAgent,
4
  DietarySpecialist
5
  )
 
11
  # Instantiate all agents
12
  role_classifier = RoleClassifier()
13
  patient_llm = PatientLLM()
14
+ caregiver_llm = CaregiverLLM()
15
  validator = ResponseValidator()
16
  safety_check = SafetyCheck()
17
  intent_classifier = IntentClassifier()
 
32
 
33
  def update_all_agents_llm(provider_name: str):
34
  from src.core.model_manager import model_manager
35
+
36
  if model_manager.provider == provider_name.lower():
37
  return
38
+
39
  model_manager.provider = provider_name.lower()
40
  logger.info(f"Switching LLM provider to: {provider_name}")
41
+
42
  all_agents = [
43
+ role_classifier, patient_llm, caregiver_llm, validator, safety_check,
44
  intent_classifier, diagnosis_assist, treatment_assist,
45
  monitoring_assist, general_assist, output_merger,
46
  research_agent, dietary_assist, health_coach, trend_analyzer
47
  ]
48
+
49
  for agent in all_agents:
50
+ agent._refresh_llm()
 
 
src/agents/agents.py CHANGED
@@ -1,113 +1,164 @@
1
  import os
2
- import sys
3
- from langchain_core.messages import SystemMessage, HumanMessage, AIMessage
4
- from langchain_core.prompts import ChatPromptTemplate
5
  import json
6
  import time
 
7
  from src.utils.logger import setup_logger
8
 
9
  logger = setup_logger("Agents")
10
 
11
- # Absolute import management for the new structure - removed as it's redundant and can cause issues
12
-
13
  from src.core.model_manager import model_manager
14
  from src.core.state import AgentState
 
15
  from src.tools.web_tools import web_search_tool
16
  from src.tools.dietary_tools import search_guidelines, get_nutritional_data, page_indexed_retrieval
17
  from src.tools.patient_memory import save_patient_memory, get_patient_memory
 
 
 
18
 
19
  class BaseAgent:
20
- def __init__(self, fallback_prompt: str, prompt_file: str = None, tools: list = None):
21
- self.llm = model_manager.get_llm()
22
- self.tools = tools
23
- if tools:
24
- # We use bind_tools for LLMs that support it
25
- self.llm = self.llm.bind_tools(tools)
26
  self.fallback_prompt = fallback_prompt
27
  self.prompt_file = prompt_file
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
 
29
  @property
30
  def system_prompt(self) -> str:
31
  """Dynamically load prompt from file if available, otherwise use fallback."""
32
  if self.prompt_file:
33
- # Get the path relative to this file's location
34
  current_dir = os.path.dirname(os.path.abspath(__file__))
35
  prompt_path = os.path.abspath(os.path.join(current_dir, "..", "prompts", self.prompt_file))
36
  try:
37
  if os.path.exists(prompt_path):
38
- with open(prompt_path, "r", encoding="utf-8") as f:
39
- return f.read().strip()
40
- except Exception as e:
41
- # Log locally or via print, then fall back
42
- pass
43
- return self.fallback_prompt
 
 
 
 
 
 
 
 
 
 
 
44
 
45
  async def run(self, state: AgentState, config=None):
46
  """Standard run method for graph nodes."""
47
  messages = [SystemMessage(content=self.system_prompt)] + state["messages"]
48
- logger.info(f"--- Sending {len(messages)} messages to LLM ({self.__class__.__name__}) ---")
49
-
50
  start_time = time.time()
51
  try:
52
  response = await self.llm.ainvoke(messages, config=config)
53
  end_time = time.time()
54
-
55
- # Extract tokens
56
  tokens = 0
57
  if hasattr(response, "usage_metadata") and response.usage_metadata:
58
  tokens = response.usage_metadata.get("total_tokens", 0)
59
  elif "token_usage" in response.response_metadata:
60
  tokens = response.response_metadata["token_usage"].get("total_tokens", 0)
61
-
 
 
 
 
 
 
 
 
 
62
  metrics = {
63
- "agent": self.__class__.__name__,
64
  "tokens": tokens,
65
- "time": round(end_time - start_time, 3)
 
66
  }
67
-
68
  return {"messages": [response], "metrics": [metrics]}
69
  except Exception as e:
70
- logger.error(f"Error in {self.__class__.__name__}.run: {e}")
71
  raise
72
 
 
73
  class RoleClassifier(BaseAgent):
74
  def __init__(self):
75
- fallback_prompt = """You are a medical triage assistant.
76
- Classify the user input into one of four roles: 'patient', 'clinician', 'researcher', or 'dietary'. Return only the name."""
77
  super().__init__(fallback_prompt, "RoleClassifier.txt")
78
 
79
  async def run(self, state: AgentState):
80
  messages = [SystemMessage(content=self.system_prompt)] + state["messages"]
81
  logger.info(f"--- RoleClassifier: Sending {len(messages)} messages to LLM ---")
82
-
 
 
 
83
  start_time = time.time()
84
  try:
85
- response = await self.llm.ainvoke(messages)
86
- end_time = time.time()
87
-
88
- role = response.content.lower().strip()
89
- if not role:
90
- role = "patient"
91
- roles = ["patient", "clinician", "researcher", "dietary"]
92
- for r in roles:
93
- if r in role:
94
- role = r
95
- break
96
- if role not in roles:
97
- role = "patient"
98
-
99
- tokens = 0
100
- if hasattr(response, "usage_metadata") and response.usage_metadata:
101
- tokens = response.usage_metadata.get("total_tokens", 0)
102
- elif "token_usage" in response.response_metadata:
103
- tokens = response.response_metadata["token_usage"].get("total_tokens", 0)
104
-
105
  metrics = {
106
  "agent": "RoleClassifier",
107
  "tokens": tokens,
108
- "time": round(end_time - start_time, 3)
109
  }
110
-
111
  return {"user_role": role, "metrics": [metrics]}
112
  except Exception as e:
113
  logger.error(f"Error in RoleClassifier.run: {e}")
@@ -119,71 +170,99 @@ class PatientLLM(BaseAgent):
119
  Provide helpful, empathetic, and medically sound advice."""
120
  super().__init__(fallback_prompt, "PatientLLM.txt", tools=[web_search_tool, save_patient_memory, get_patient_memory])
121
 
 
 
 
 
 
 
 
122
  class ResponseValidator(BaseAgent):
123
  def __init__(self):
124
- fallback_prompt = """You are a medical response validator.
125
- Check if the last response is medically accurate and follows guidelines. Return 'valid' or 'invalid'."""
126
  super().__init__(fallback_prompt, "ResponseValidator.txt")
127
 
128
  async def run(self, state: AgentState):
129
- # We check the last AI message in the state
130
  last_message = state["messages"][-1].content
131
-
132
  start_time = time.time()
133
  response = await self.llm.ainvoke([
134
  SystemMessage(content=self.system_prompt),
135
  HumanMessage(content=f"Verify this response: {last_message}")
136
  ])
137
  end_time = time.time()
138
-
139
- is_valid = "valid" in response.content.lower()
140
-
 
 
 
 
 
 
 
 
 
 
141
  tokens = 0
142
  if hasattr(response, "usage_metadata") and response.usage_metadata:
143
  tokens = response.usage_metadata.get("total_tokens", 0)
144
  elif "token_usage" in response.response_metadata:
145
  tokens = response.response_metadata["token_usage"].get("total_tokens", 0)
146
-
147
  metrics = {
148
  "agent": "ResponseValidator",
149
  "tokens": tokens,
150
  "time": round(end_time - start_time, 3)
151
  }
152
-
153
  return {"is_valid": is_valid, "metrics": [metrics]}
154
 
 
155
  class SafetyCheck(BaseAgent):
156
  def __init__(self):
157
- fallback_prompt = """You are a medical safety officer.
158
- Check if the response contains any dangerous advice or misinformation. Return 'safe' or 'unsafe'."""
159
  super().__init__(fallback_prompt, "SafetyCheck.txt")
160
 
161
  async def run(self, state: AgentState):
162
  last_message = state["messages"][-1].content
163
-
164
  start_time = time.time()
165
  response = await self.llm.ainvoke([
166
  SystemMessage(content=self.system_prompt),
167
  HumanMessage(content=f"Safety check on this: {last_message}")
168
  ])
169
  end_time = time.time()
170
-
171
- is_safe = "safe" in response.content.lower()
172
-
 
 
 
 
 
 
 
 
 
 
173
  tokens = 0
174
  if hasattr(response, "usage_metadata") and response.usage_metadata:
175
  tokens = response.usage_metadata.get("total_tokens", 0)
176
  elif "token_usage" in response.response_metadata:
177
  tokens = response.response_metadata["token_usage"].get("total_tokens", 0)
178
-
179
  metrics = {
180
  "agent": "SafetyCheck",
181
  "tokens": tokens,
182
  "time": round(end_time - start_time, 3)
183
  }
184
-
185
  return {"is_safe": is_safe, "metrics": [metrics]}
186
 
 
187
  class IntentClassifier(BaseAgent):
188
  def __init__(self):
189
  fallback_prompt = """You are a clinical intent classifier.
@@ -212,15 +291,170 @@ class IntentClassifier(BaseAgent):
212
  return {"intent_type": intent, "metrics": [metrics]}
213
 
214
  class ClinicalSpecialist(BaseAgent):
 
 
 
 
215
  def __init__(self, specialty: str):
216
  fallback_prompt = f"You are a clinical specialist in {specialty}. Provide expert medical support."
217
  super().__init__(fallback_prompt, f"ClinicalSpecialist_{specialty}.txt")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
218
 
219
  class OutputMerger(BaseAgent):
220
  def __init__(self):
221
  fallback_prompt = "You are a clinical coordinator. Merge outputs into a single cohesive report."
222
  super().__init__(fallback_prompt, "OutputMerger.txt")
223
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
224
  class ResearchAgent(BaseAgent):
225
  def __init__(self):
226
  fallback_prompt = """You are a medical research assistant. Provide detailed information for researchers."""
@@ -230,8 +464,6 @@ class DietarySpecialist(BaseAgent):
230
  def __init__(self):
231
  fallback_prompt = """You are a certified dietary specialist. Provide advice based on guidelines."""
232
  super().__init__(fallback_prompt, "DietarySpecialist.txt", tools=[search_guidelines, get_nutritional_data, page_indexed_retrieval, save_patient_memory, get_patient_memory])
233
-
234
  async def run(self, state: AgentState, config=None):
235
- messages = [SystemMessage(content=self.system_prompt)] + state["messages"]
236
- response = await self.llm.ainvoke(messages, config=config)
237
- return {"messages": [response]}
 
1
  import os
2
+ import re
 
 
3
  import json
4
  import time
5
+ from langchain_core.messages import SystemMessage, HumanMessage, AIMessage
6
  from src.utils.logger import setup_logger
7
 
8
  logger = setup_logger("Agents")
9
 
10
+ from src.agent_params import get_agent_params
 
11
  from src.core.model_manager import model_manager
12
  from src.core.state import AgentState
13
+ from src.core.evidence_models import ClinicalOutputWithEvidence, EvidenceCitation
14
  from src.tools.web_tools import web_search_tool
15
  from src.tools.dietary_tools import search_guidelines, get_nutritional_data, page_indexed_retrieval
16
  from src.tools.patient_memory import save_patient_memory, get_patient_memory
17
+ from src.agents.role_utils import classify_role
18
+ from datetime import datetime
19
+
20
 
21
  class BaseAgent:
22
+ def __init__(self, fallback_prompt: str, prompt_file: str = None, tools: list = None, agent_name: str = None):
23
+ self.agent_name = agent_name or self.__class__.__name__
 
 
 
 
24
  self.fallback_prompt = fallback_prompt
25
  self.prompt_file = prompt_file
26
+ self.tools = tools or []
27
+ self.params = get_agent_params(self.agent_name)
28
+ self.temperature = float(self.params.get("temperature", 0.0))
29
+ self.model_name = self.params.get("model_name")
30
+ self._refresh_llm()
31
+
32
+ def _refresh_llm(self):
33
+ llm = model_manager.get_llm(
34
+ temperature=self.temperature,
35
+ model_name=self.model_name,
36
+ )
37
+ if self.tools:
38
+ llm = llm.bind_tools(self.tools)
39
+ self.llm = llm
40
+
41
+ def parse_json_response(self, response_text: str):
42
+ if not response_text:
43
+ return {}
44
+
45
+ text = response_text.strip()
46
+ try:
47
+ return json.loads(text)
48
+ except json.JSONDecodeError:
49
+ match = re.search(r"\{.*\}", text, re.S)
50
+ if not match:
51
+ return {}
52
+ try:
53
+ return json.loads(match.group(0))
54
+ except json.JSONDecodeError:
55
+ return {}
56
 
57
  @property
58
  def system_prompt(self) -> str:
59
  """Dynamically load prompt from file if available, otherwise use fallback."""
60
  if self.prompt_file:
 
61
  current_dir = os.path.dirname(os.path.abspath(__file__))
62
  prompt_path = os.path.abspath(os.path.join(current_dir, "..", "prompts", self.prompt_file))
63
  try:
64
  if os.path.exists(prompt_path):
65
+ with open(prompt_path, "r", encoding="utf-8") as handle:
66
+ prompt = handle.read().strip()
67
+ else:
68
+ prompt = self.fallback_prompt
69
+ except Exception:
70
+ prompt = self.fallback_prompt
71
+ else:
72
+ prompt = self.fallback_prompt
73
+
74
+ skip_confidence = self.agent_name in {"ResponseValidator", "SafetyCheck"}
75
+ confidence_instruction = (
76
+ "\n\nAt the end of your response, include a confidence score from 0.0 to 1.0 "
77
+ "in the format: Confidence: 0.8"
78
+ )
79
+ if not skip_confidence and "confidence" not in prompt.lower():
80
+ prompt = f"{prompt}{confidence_instruction}"
81
+ return prompt
82
 
83
  async def run(self, state: AgentState, config=None):
84
  """Standard run method for graph nodes."""
85
  messages = [SystemMessage(content=self.system_prompt)] + state["messages"]
86
+ logger.info(f"--- Sending {len(messages)} messages to LLM ({self.agent_name}) ---")
87
+
88
  start_time = time.time()
89
  try:
90
  response = await self.llm.ainvoke(messages, config=config)
91
  end_time = time.time()
92
+
 
93
  tokens = 0
94
  if hasattr(response, "usage_metadata") and response.usage_metadata:
95
  tokens = response.usage_metadata.get("total_tokens", 0)
96
  elif "token_usage" in response.response_metadata:
97
  tokens = response.response_metadata["token_usage"].get("total_tokens", 0)
98
+
99
+ confidence = None
100
+ if hasattr(response, "content") and isinstance(response.content, str):
101
+ match = re.search(r"Confidence(?:\s+Score)?:\s*([0-9.]+)", response.content, re.IGNORECASE)
102
+ if match:
103
+ try:
104
+ confidence = float(match.group(1))
105
+ except ValueError:
106
+ pass
107
+
108
  metrics = {
109
+ "agent": self.agent_name,
110
  "tokens": tokens,
111
+ "time": round(end_time - start_time, 3),
112
+ "confidence": confidence
113
  }
114
+
115
  return {"messages": [response], "metrics": [metrics]}
116
  except Exception as e:
117
+ logger.error(f"Error in {self.agent_name}.run: {e}")
118
  raise
119
 
120
+
121
  class RoleClassifier(BaseAgent):
122
  def __init__(self):
123
+ fallback_prompt = """You are a medical triage assistant.
124
+ Classify the user input into one of five roles: 'patient', 'caregiver', 'clinician', 'researcher', or 'dietary'. Return only the name."""
125
  super().__init__(fallback_prompt, "RoleClassifier.txt")
126
 
127
  async def run(self, state: AgentState):
128
  messages = [SystemMessage(content=self.system_prompt)] + state["messages"]
129
  logger.info(f"--- RoleClassifier: Sending {len(messages)} messages to LLM ---")
130
+
131
+ user_message = state["messages"][-1].content.lower() if state["messages"] else ""
132
+ role = classify_role(user_message)
133
+
134
  start_time = time.time()
135
  try:
136
+ if role is None:
137
+ response = await self.llm.ainvoke(messages)
138
+ end_time = time.time()
139
+ raw = response.content.lower()
140
+ if not raw.strip():
141
+ role = "patient"
142
+ else:
143
+ roles = ["patient", "caregiver", "clinician", "researcher", "dietary"]
144
+ role = next((r for r in roles if r in raw), "patient")
145
+ tokens = 0
146
+ if hasattr(response, "usage_metadata") and response.usage_metadata:
147
+ tokens = response.usage_metadata.get("total_tokens", 0)
148
+ elif "token_usage" in response.response_metadata:
149
+ tokens = response.response_metadata["token_usage"].get("total_tokens", 0)
150
+ duration = round(end_time - start_time, 3)
151
+ else:
152
+ end_time = time.time()
153
+ tokens = 0
154
+ duration = round(end_time - start_time, 3)
155
+
156
  metrics = {
157
  "agent": "RoleClassifier",
158
  "tokens": tokens,
159
+ "time": duration
160
  }
161
+
162
  return {"user_role": role, "metrics": [metrics]}
163
  except Exception as e:
164
  logger.error(f"Error in RoleClassifier.run: {e}")
 
170
  Provide helpful, empathetic, and medically sound advice."""
171
  super().__init__(fallback_prompt, "PatientLLM.txt", tools=[web_search_tool, save_patient_memory, get_patient_memory])
172
 
173
+ class CaregiverLLM(BaseAgent):
174
+ def __init__(self):
175
+ fallback_prompt = """You are a supportive caregiver assistant for a diabetes management platform.
176
+ Help caregivers interpret symptoms, monitor treatment adherence, and know when to escalate to urgent care.
177
+ Frame advice as practical proxy guidance for a patient while remaining clear and compassionate."""
178
+ super().__init__(fallback_prompt, "CaregiverLLM.txt", tools=[web_search_tool, save_patient_memory, get_patient_memory])
179
+
180
  class ResponseValidator(BaseAgent):
181
  def __init__(self):
182
+ fallback_prompt = """You are a medical response validator.
183
+ Check if the last response is medically accurate and follows guidelines. Return JSON only."""
184
  super().__init__(fallback_prompt, "ResponseValidator.txt")
185
 
186
  async def run(self, state: AgentState):
 
187
  last_message = state["messages"][-1].content
188
+
189
  start_time = time.time()
190
  response = await self.llm.ainvoke([
191
  SystemMessage(content=self.system_prompt),
192
  HumanMessage(content=f"Verify this response: {last_message}")
193
  ])
194
  end_time = time.time()
195
+
196
+ parsed = self.parse_json_response(response.content)
197
+ decision = parsed.get("decision", "invalid").lower()
198
+ is_valid = decision == "valid"
199
+ if not parsed:
200
+ lower_response = response.content.lower()
201
+ if re.search(r"\binvalid\b", lower_response):
202
+ is_valid = False
203
+ elif re.search(r"\bvalid\b", lower_response):
204
+ is_valid = True
205
+ else:
206
+ is_valid = False
207
+
208
  tokens = 0
209
  if hasattr(response, "usage_metadata") and response.usage_metadata:
210
  tokens = response.usage_metadata.get("total_tokens", 0)
211
  elif "token_usage" in response.response_metadata:
212
  tokens = response.response_metadata["token_usage"].get("total_tokens", 0)
213
+
214
  metrics = {
215
  "agent": "ResponseValidator",
216
  "tokens": tokens,
217
  "time": round(end_time - start_time, 3)
218
  }
219
+
220
  return {"is_valid": is_valid, "metrics": [metrics]}
221
 
222
+
223
  class SafetyCheck(BaseAgent):
224
  def __init__(self):
225
+ fallback_prompt = """You are a medical safety officer.
226
+ Check if the response contains any dangerous advice or misinformation. Return JSON only."""
227
  super().__init__(fallback_prompt, "SafetyCheck.txt")
228
 
229
  async def run(self, state: AgentState):
230
  last_message = state["messages"][-1].content
231
+
232
  start_time = time.time()
233
  response = await self.llm.ainvoke([
234
  SystemMessage(content=self.system_prompt),
235
  HumanMessage(content=f"Safety check on this: {last_message}")
236
  ])
237
  end_time = time.time()
238
+
239
+ parsed = self.parse_json_response(response.content)
240
+ decision = parsed.get("decision", "unsafe").lower()
241
+ is_safe = decision == "safe"
242
+ if not parsed:
243
+ lower_response = response.content.lower()
244
+ if re.search(r"\bunsafe\b", lower_response):
245
+ is_safe = False
246
+ elif re.search(r"\bsafe\b", lower_response):
247
+ is_safe = True
248
+ else:
249
+ is_safe = False
250
+
251
  tokens = 0
252
  if hasattr(response, "usage_metadata") and response.usage_metadata:
253
  tokens = response.usage_metadata.get("total_tokens", 0)
254
  elif "token_usage" in response.response_metadata:
255
  tokens = response.response_metadata["token_usage"].get("total_tokens", 0)
256
+
257
  metrics = {
258
  "agent": "SafetyCheck",
259
  "tokens": tokens,
260
  "time": round(end_time - start_time, 3)
261
  }
262
+
263
  return {"is_safe": is_safe, "metrics": [metrics]}
264
 
265
+
266
  class IntentClassifier(BaseAgent):
267
  def __init__(self):
268
  fallback_prompt = """You are a clinical intent classifier.
 
291
  return {"intent_type": intent, "metrics": [metrics]}
292
 
293
  class ClinicalSpecialist(BaseAgent):
294
+ """
295
+ Clinical specialist agent with structured output including evidence citations.
296
+ Bug 12.3: Provides explainability through guideline sources and evidence levels.
297
+ """
298
  def __init__(self, specialty: str):
299
  fallback_prompt = f"You are a clinical specialist in {specialty}. Provide expert medical support."
300
  super().__init__(fallback_prompt, f"ClinicalSpecialist_{specialty}.txt")
301
+ self.specialty = specialty
302
+
303
+ async def run(self, state: AgentState, config=None):
304
+ """
305
+ Run clinical specialist with structured output requiring evidence citations.
306
+ Returns both the text response and evidence citations in AgentState.
307
+ """
308
+ messages = [SystemMessage(content=self.system_prompt)] + state["messages"]
309
+ logger.info(f"--- ClinicalSpecialist ({self.specialty}): Running with evidence structure ---")
310
+
311
+ start_time = time.time()
312
+ try:
313
+ # Use structured output with the LLM if available
314
+ try:
315
+ # Try to use with_structured_output for models that support it
316
+ llm_with_output = self.llm.with_structured_output(ClinicalOutputWithEvidence)
317
+ response = await llm_with_output.ainvoke(messages, config=config)
318
+ except (AttributeError, NotImplementedError):
319
+ # Fallback: regular invocation and manual extraction
320
+ logger.warning(f"Model does not support structured output, using fallback")
321
+ response = await self.llm.ainvoke(messages, config=config)
322
+ # Create a basic ClinicalOutputWithEvidence from the response
323
+ from src.core.evidence_models import Citation
324
+ response = ClinicalOutputWithEvidence(
325
+ recommendation=response.content[:200] if hasattr(response, 'content') else str(response),
326
+ explanation=response.content if hasattr(response, 'content') else str(response),
327
+ citations=[
328
+ Citation(
329
+ source_document="Knowledge Base",
330
+ evidence_level="C",
331
+ section="General"
332
+ )
333
+ ],
334
+ confidence_score=0.7
335
+ )
336
+
337
+ end_time = time.time()
338
+
339
+ # Extract tokens
340
+ tokens = 0
341
+ if hasattr(response, "usage_metadata") and response.usage_metadata:
342
+ tokens = response.usage_metadata.get("total_tokens", 0)
343
+ elif isinstance(response, dict) and "usage_metadata" in response:
344
+ tokens = response["usage_metadata"].get("total_tokens", 0)
345
+ elif hasattr(response, "response_metadata") and "token_usage" in response.response_metadata:
346
+ tokens = response.response_metadata["token_usage"].get("total_tokens", 0)
347
+
348
+ confidence = 0.7
349
+ if isinstance(response, ClinicalOutputWithEvidence):
350
+ output_content = response.recommendation
351
+ citations = response.citations
352
+ confidence = getattr(response, "confidence_score", 0.7)
353
+ else:
354
+ output_content = response.content if hasattr(response, 'content') else str(response)
355
+ citations = []
356
+ if isinstance(output_content, str):
357
+ match = re.search(r"Confidence(?:\s+Score)?:\s*([0-9.]+)", output_content, re.IGNORECASE)
358
+ if match:
359
+ try:
360
+ confidence = float(match.group(1))
361
+ except ValueError:
362
+ pass
363
+
364
+ metrics = {
365
+ "agent": f"ClinicalSpecialist({self.specialty})",
366
+ "tokens": tokens,
367
+ "time": round(end_time - start_time, 3),
368
+ "confidence": confidence
369
+ }
370
+
371
+ # Build evidence citations from the structured output
372
+ evidence_citations = []
373
+ for idx, citation in enumerate(citations):
374
+ evidence_citation = {
375
+ "recommendation_id": f"{self.specialty}_{idx}",
376
+ "source_document": citation.source_document if hasattr(citation, 'source_document') else "Unknown",
377
+ "page_number": getattr(citation, 'page_number', None),
378
+ "evidence_level": getattr(citation, 'evidence_level', 'C'),
379
+ "agent_name": f"ClinicalSpecialist({self.specialty})",
380
+ "timestamp": datetime.utcnow().isoformat()
381
+ }
382
+ evidence_citations.append(evidence_citation)
383
+
384
+ from langchain_core.messages import AIMessage
385
+ return {
386
+ "messages": [AIMessage(content=output_content)],
387
+ "metrics": [metrics],
388
+ "evidence_citations": evidence_citations
389
+ }
390
+
391
+ except Exception as e:
392
+ logger.error(f"Error in ClinicalSpecialist({self.specialty}).run: {e}")
393
+ raise
394
 
395
  class OutputMerger(BaseAgent):
396
  def __init__(self):
397
  fallback_prompt = "You are a clinical coordinator. Merge outputs into a single cohesive report."
398
  super().__init__(fallback_prompt, "OutputMerger.txt")
399
 
400
+ async def run(self, state: AgentState, config=None):
401
+ latest_user_message = None
402
+ for message in reversed(state["messages"]):
403
+ if getattr(message, "type", None) == "human":
404
+ latest_user_message = message.content
405
+ break
406
+
407
+ human_messages = []
408
+ if latest_user_message:
409
+ human_messages.append(HumanMessage(content=f"Original user request:\n{latest_user_message}"))
410
+
411
+ clinician_outputs = state.get("clinician_outputs") or []
412
+ if clinician_outputs:
413
+ human_messages.append(
414
+ HumanMessage(content="Latest specialist outputs:\n" + "\n\n".join(clinician_outputs))
415
+ )
416
+
417
+ messages = [SystemMessage(content=self.system_prompt)] + human_messages
418
+
419
+ start_time = time.time()
420
+ response = None
421
+ full_content = ""
422
+ async for chunk in self.llm.astream(messages, config=config):
423
+ response = chunk
424
+ if chunk and hasattr(chunk, "content") and isinstance(chunk.content, str):
425
+ full_content += chunk.content
426
+
427
+ if response is None:
428
+ response = await self.llm.ainvoke(messages, config=config)
429
+ if response and hasattr(response, "content") and isinstance(response.content, str):
430
+ full_content = response.content
431
+
432
+ end_time = time.time()
433
+
434
+ tokens = 0
435
+ if hasattr(response, "usage_metadata") and response.usage_metadata:
436
+ tokens = response.usage_metadata.get("total_tokens", 0)
437
+ elif "token_usage" in response.response_metadata:
438
+ tokens = response.response_metadata["token_usage"].get("total_tokens", 0)
439
+
440
+ confidence = None
441
+ if full_content:
442
+ match = re.search(r"Confidence(?:\s+Score)?:\s*([0-9.]+)", full_content, re.IGNORECASE)
443
+ if match:
444
+ try:
445
+ confidence = float(match.group(1))
446
+ except ValueError:
447
+ pass
448
+
449
+ metrics = {
450
+ "agent": self.__class__.__name__,
451
+ "tokens": tokens,
452
+ "time": round(end_time - start_time, 3),
453
+ "confidence": confidence
454
+ }
455
+
456
+ return {"messages": [response], "metrics": [metrics]}
457
+
458
  class ResearchAgent(BaseAgent):
459
  def __init__(self):
460
  fallback_prompt = """You are a medical research assistant. Provide detailed information for researchers."""
 
464
  def __init__(self):
465
  fallback_prompt = """You are a certified dietary specialist. Provide advice based on guidelines."""
466
  super().__init__(fallback_prompt, "DietarySpecialist.txt", tools=[search_guidelines, get_nutritional_data, page_indexed_retrieval, save_patient_memory, get_patient_memory])
467
+
468
  async def run(self, state: AgentState, config=None):
469
+ return await super().run(state, config=config)
 
 
src/agents/cdm_agents.py CHANGED
@@ -1,8 +1,9 @@
1
  import json
2
  import time
 
3
  from src.agents.agents import BaseAgent
4
  from src.core.state import AgentState
5
- from src.tools.fhir_memory import get_observations_by_patient
6
  from langchain_core.messages import SystemMessage, HumanMessage
7
  from src.utils.logger import setup_logger
8
 
@@ -12,7 +13,8 @@ class HealthCoach(BaseAgent):
12
  def __init__(self):
13
  fallback_prompt = """You are a proactive Chronic Disease Management (CDM) Health Coach.
14
  Your goal is to help patients manage their conditions (like Diabetes or Hypertension) through motivation, education, and lifestyle tracking.
15
- Always review their recent FHIR observations and provide trend-aware advice.
 
16
  Be encouraging but firm about safety guidelines."""
17
  super().__init__(fallback_prompt, "HealthCoach.txt")
18
 
@@ -21,64 +23,178 @@ class TrendAnalyzer(BaseAgent):
21
  fallback_prompt = """You are a medical data trend analyzer.
22
  You analyze FHIR observations and identify clinically significant trends.
23
  If you see rising glucose levels or blood pressure, flag them immediately.
24
- Provide a concise summary of the last 7 days of data."""
25
  super().__init__(fallback_prompt, "TrendAnalyzer.txt")
26
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
  async def analyze_trends(self, patient_id: str):
28
  """
29
- Logic to analyze trends for a specific patient.
30
- This can be called as a tool or as an agent step.
31
  """
32
  logger.info(f"Analyzing health trends for patient: {patient_id}")
33
  observations = get_observations_by_patient.invoke({"patient_id": patient_id})
34
  if isinstance(observations, str):
35
  return observations
 
 
 
36
 
37
- # Group by LOINC code
38
  data_points = {}
39
  for obs in observations:
40
- code = obs["code"]["coding"][0]["display"]
41
- val = obs["valueQuantity"]["value"]
42
- date = obs["effectiveDateTime"]
43
- if code not in data_points:
44
- data_points[code] = []
45
- data_points[code].append({"value": val, "date": date})
46
-
47
- # Basic trend analysis logic
48
- analysis = "Trend Analysis Summary:\n"
 
 
 
 
 
 
 
 
 
 
49
  for code, points in data_points.items():
50
  if len(points) >= 2:
51
- latest = points[0]["value"]
52
- previous = points[1]["value"]
53
- diff = latest - previous
54
- trend = "increasing" if diff > 0 else "decreasing" if diff < 0 else "stable"
55
- analysis += f"- {code}: {trend} (latest: {latest}, change: {diff:+.1f})\n"
56
- else:
57
- analysis += f"- {code}: Insufficient data for trend (latest: {points[0]['value']})\n"
 
 
 
 
58
 
59
- return analysis
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
 
61
  async def run(self, state: AgentState):
62
- # Extract patient ID from messages or state
63
- # For simplicity, we assume it's in the state or can be derived
64
- # In a real scenario, we'd have a 'patient_id' in AgentState
65
  patient_id = state.get("patient_id", "unknown")
66
  if patient_id == "unknown":
67
  logger.warning("TrendAnalyzer: No patient ID found in state.")
68
  return {"logs": ["TrendAnalyzer: No patient ID found in state."]}
69
 
70
  start_time = time.time()
71
- analysis = await self.analyze_trends(patient_id)
72
  end_time = time.time()
73
 
74
  metrics = {
75
  "agent": "TrendAnalyzer",
76
- "tokens": 0, # Logic based, no LLM call here
77
  "time": round(end_time - start_time, 3)
78
  }
79
 
80
  return {
81
  "trend_analysis": analysis,
 
82
  "logs": [f"TrendAnalyzer: Completed analysis for {patient_id}"],
83
  "metrics": [metrics]
84
  }
 
1
  import json
2
  import time
3
+ from datetime import datetime, timedelta
4
  from src.agents.agents import BaseAgent
5
  from src.core.state import AgentState
6
+ from src.tools.fhir_memory import get_observations_by_patient, get_medications_by_patient
7
  from langchain_core.messages import SystemMessage, HumanMessage
8
  from src.utils.logger import setup_logger
9
 
 
13
  def __init__(self):
14
  fallback_prompt = """You are a proactive Chronic Disease Management (CDM) Health Coach.
15
  Your goal is to help patients manage their conditions (like Diabetes or Hypertension) through motivation, education, and lifestyle tracking.
16
+ Always review their recent FHIR observations, current medications, and trend analysis to provide personalized advice.
17
+ Consider the patient's medication regimen when making lifestyle recommendations.
18
  Be encouraging but firm about safety guidelines."""
19
  super().__init__(fallback_prompt, "HealthCoach.txt")
20
 
 
23
  fallback_prompt = """You are a medical data trend analyzer.
24
  You analyze FHIR observations and identify clinically significant trends.
25
  If you see rising glucose levels or blood pressure, flag them immediately.
26
+ Provide a concise summary of the last 7-14 days of data with trend projections."""
27
  super().__init__(fallback_prompt, "TrendAnalyzer.txt")
28
 
29
+ def _compute_trend_statistics(self, values: list) -> dict:
30
+ """
31
+ Compute statistical trend indicators using polynomial regression.
32
+ Bug 7.2: Enhanced trend analysis with proper windowing.
33
+
34
+ Returns:
35
+ dict: Contains trend direction, slope, R-squared, and projection
36
+ """
37
+ if len(values) < 2:
38
+ return {"trend": "insufficient", "slope": 0, "r_squared": 0, "projection": None}
39
+
40
+ try:
41
+ import numpy as np
42
+
43
+ # Prepare data points
44
+ x = np.array(range(len(values)))
45
+ y = np.array(values)
46
+
47
+ # First-order polynomial fit (linear regression)
48
+ if len(values) >= 3:
49
+ coeffs = np.polyfit(x, y, 1)
50
+ slope = coeffs[0]
51
+ intercept = coeffs[1]
52
+
53
+ # Calculate R-squared
54
+ y_pred = np.polyval(coeffs, x)
55
+ ss_res = np.sum((y - y_pred) ** 2)
56
+ ss_tot = np.sum((y - np.mean(y)) ** 2)
57
+ r_squared = 1 - (ss_res / ss_tot) if ss_tot != 0 else 0
58
+
59
+ # Determine trend with threshold
60
+ if abs(slope) < 0.5:
61
+ trend = "stable"
62
+ elif slope > 0.5:
63
+ trend = "rising"
64
+ else:
65
+ trend = "falling"
66
+
67
+ # Project next value
68
+ next_x = len(values)
69
+ projection = np.polyval(coeffs, next_x)
70
+
71
+ return {
72
+ "trend": trend,
73
+ "slope": float(slope),
74
+ "r_squared": float(r_squared),
75
+ "projection": float(projection),
76
+ "change_rate": float(slope)
77
+ }
78
+ else:
79
+ # For < 3 points, use simple delta
80
+ slope = (values[-1] - values[0]) / (len(values) - 1)
81
+ trend = "rising" if slope > 0.5 else "falling" if slope < -0.5 else "stable"
82
+ return {
83
+ "trend": trend,
84
+ "slope": float(slope),
85
+ "r_squared": 0,
86
+ "projection": values[-1] + slope,
87
+ "change_rate": float(slope)
88
+ }
89
+ except ImportError:
90
+ logger.warning("NumPy not available, using basic trend analysis")
91
+ # Fallback: basic two-point comparison
92
+ if len(values) >= 2:
93
+ delta = values[-1] - values[-2]
94
+ trend = "rising" if delta > 0.5 else "falling" if delta < -0.5 else "stable"
95
+ return {
96
+ "trend": trend,
97
+ "slope": float(delta),
98
+ "r_squared": 0,
99
+ "projection": values[-1] + delta,
100
+ "change_rate": float(delta)
101
+ }
102
+ return {"trend": "insufficient", "slope": 0, "r_squared": 0, "projection": None}
103
+
104
  async def analyze_trends(self, patient_id: str):
105
  """
106
+ Logic to analyze trends for a specific patient with windowing.
107
+ Bug 7.2: Implements 7-14 day moving window analysis with regression.
108
  """
109
  logger.info(f"Analyzing health trends for patient: {patient_id}")
110
  observations = get_observations_by_patient.invoke({"patient_id": patient_id})
111
  if isinstance(observations, str):
112
  return observations
113
+
114
+ if not observations:
115
+ return "No observations available for trend analysis.", {}
116
 
117
+ # Group by LOINC code and sort by date
118
  data_points = {}
119
  for obs in observations:
120
+ try:
121
+ code = obs["code"]["coding"][0]["display"]
122
+ val = float(obs["valueQuantity"]["value"])
123
+ date = obs["effectiveDateTime"]
124
+ if code not in data_points:
125
+ data_points[code] = []
126
+ data_points[code].append({"value": val, "date": date})
127
+ except (KeyError, IndexError, ValueError) as e:
128
+ logger.warning(f"Error parsing observation: {e}")
129
+ continue
130
+
131
+ # Sort each metric's points by date (descending for most recent first)
132
+ for code in data_points:
133
+ data_points[code].sort(key=lambda x: x["date"], reverse=True)
134
+
135
+ # Comprehensive trend analysis
136
+ analysis = "**Trend Analysis Report (7-14 Day Window)**\n\n"
137
+ structured_data = {}
138
+
139
  for code, points in data_points.items():
140
  if len(points) >= 2:
141
+ # Extract values for statistical analysis
142
+ values = [p["value"] for p in points]
143
+ stats = self._compute_trend_statistics(values)
144
+
145
+ latest = values[0]
146
+ prev = values[1] if len(values) > 1 else values[0]
147
+ delta = latest - prev
148
+
149
+ analysis += f"**{code}**\n"
150
+ analysis += f"- Latest: {latest:.1f}\n"
151
+ analysis += f"- Trend: {stats['trend'].upper()} (slope: {stats['slope']:.2f}/day)\n"
152
 
153
+ if stats['r_squared'] > 0:
154
+ analysis += f"- Trend Quality (R²): {stats['r_squared']:.2f}\n"
155
+
156
+ if stats['projection'] is not None:
157
+ analysis += f"- Projected Next: {stats['projection']:.1f}\n"
158
+
159
+ # Clinical alert for high-priority metrics
160
+ if code.lower() in ["blood glucose", "glucose"] and latest > 250:
161
+ analysis += f" ⚠️ **ALERT**: Glucose critically high\n"
162
+ elif code.lower() in ["blood glucose", "glucose"] and latest < 70:
163
+ analysis += f" ⚠️ **ALERT**: Risk of hypoglycemia\n"
164
+
165
+
166
+ # Add to structured data
167
+ structured_data[code] = {
168
+ "points": [{"date": p["date"], "value": p["value"]} for p in points],
169
+ "stats": stats
170
+ }
171
+
172
+ analysis += "\n"
173
+ elif len(points) == 1:
174
+ analysis += f"**{code}**: Single data point (latest: {points[0]['value']:.1f}) — insufficient for trend\n\n"
175
+
176
+ return analysis, structured_data
177
 
178
  async def run(self, state: AgentState):
179
+ # Extract patient ID from state
 
 
180
  patient_id = state.get("patient_id", "unknown")
181
  if patient_id == "unknown":
182
  logger.warning("TrendAnalyzer: No patient ID found in state.")
183
  return {"logs": ["TrendAnalyzer: No patient ID found in state."]}
184
 
185
  start_time = time.time()
186
+ analysis, structured_data = await self.analyze_trends(patient_id)
187
  end_time = time.time()
188
 
189
  metrics = {
190
  "agent": "TrendAnalyzer",
191
+ "tokens": 0, # Logic-based, no LLM call
192
  "time": round(end_time - start_time, 3)
193
  }
194
 
195
  return {
196
  "trend_analysis": analysis,
197
+ "trend_data": structured_data,
198
  "logs": [f"TrendAnalyzer: Completed analysis for {patient_id}"],
199
  "metrics": [metrics]
200
  }
src/agents/role_utils.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import re
2
+
3
+
4
+ def classify_role(user_message: str) -> str | None:
5
+ """Return an inferred role from the user's message using regex cues."""
6
+ text = (user_message or "").lower()
7
+
8
+ if re.search(r"\b(caregiver|care giver|caring for|supporting|helping)\b", text):
9
+ return "caregiver"
10
+
11
+ if re.search(r"\b(pregnant|pregnancy|gestational|pregnant woman|gestational diabetes)\b", text):
12
+ return "patient"
13
+
14
+ if re.search(r"\b(diet|nutrition|meal plan|glycemic|carbohydrate|food facts|calorie|keto|vegetarian|vegan|grocery|menu)\b", text):
15
+ return "dietary"
16
+
17
+ if re.search(r"\b(study|research|paper|trial|meta-analysis|randomized|cohort|evidence|data|epidemiology)\b", text):
18
+ return "researcher"
19
+
20
+ if re.search(r"\b(doctor|dr\.|physician|provider|clinician|prescription|diagnosis|treatment|protocol|dose|medication|management|hba1c|a1c|insulin regimen|therapy|medical advice)\b", text):
21
+ return "clinician"
22
+
23
+ return None
src/core/evidence_models.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Evidence and citation models for clinical recommendations.
3
+ Bug 12.3: Explainability Layer for Clinical Recommendations
4
+ """
5
+
6
+ from pydantic import BaseModel, Field
7
+ from typing import List, Literal, Optional
8
+
9
+
10
+ class Citation(BaseModel):
11
+ """Represents a citation to a specific guideline or evidence source."""
12
+ source_document: str = Field(
13
+ description="Name of the guideline or research document (e.g., 'ADA Standards of Care 2026')"
14
+ )
15
+ page_number: Optional[int] = Field(None, description="Page number in the source document")
16
+ section: Optional[str] = Field(None, description="Section or chapter title in the document")
17
+ evidence_level: Literal["A", "B", "C"] = Field(
18
+ description="Evidence level per ADA standards: A (Excellent), B (Good), C (Limited)"
19
+ )
20
+ url: Optional[str] = Field(None, description="Optional URL to the source document")
21
+
22
+
23
+ class ClinicalOutputWithEvidence(BaseModel):
24
+ """
25
+ Clinical specialist output with explicit evidence citations.
26
+ This ensures transparency and allows clinician users to verify the basis of recommendations.
27
+ """
28
+ recommendation: str = Field(
29
+ description="The clinical recommendation or advice provided"
30
+ )
31
+ explanation: str = Field(
32
+ description="Detailed explanation of the recommendation and its rationale"
33
+ )
34
+ citations: List[Citation] = Field(
35
+ description="List of guideline and evidence sources supporting this recommendation"
36
+ )
37
+ confidence_score: float = Field(
38
+ ge=0.0, le=1.0,
39
+ description="Agent's confidence score in this recommendation (0.0 to 1.0)"
40
+ )
41
+ disclaimer: Optional[str] = Field(
42
+ None,
43
+ description="Any clinical disclaimers or caveats (e.g., 'Consult with physician', 'Not for acute conditions')"
44
+ )
45
+
46
+
47
+ class EvidenceCitation(BaseModel):
48
+ """
49
+ Record of an evidence citation used in the clinical pipeline.
50
+ Used to track citations within AgentState.
51
+ """
52
+ recommendation_id: str = Field(description="ID of the clinical recommendation this cites")
53
+ source_document: str = Field(description="Name of the source document")
54
+ page_number: Optional[int] = Field(None, description="Page number in source")
55
+ evidence_level: Literal["A", "B", "C"] = Field(description="Evidence level classification")
56
+ agent_name: str = Field(description="Name of the agent that produced this citation")
57
+ timestamp: str = Field(description="ISO format timestamp when citation was generated")
src/core/graph.py CHANGED
@@ -1,7 +1,10 @@
 
 
 
1
  from langgraph.graph import StateGraph, END
2
  from src.core.state import AgentState
3
  from src.agents.agent_instances import (
4
- role_classifier, patient_llm, validator, safety_check,
5
  intent_classifier, diagnosis_assist, treatment_assist,
6
  monitoring_assist, general_assist, output_merger, research_agent,
7
  dietary_assist
@@ -11,10 +14,15 @@ from langgraph.prebuilt import ToolNode
11
  from src.tools.web_tools import web_search_tool
12
  from src.utils.logger import setup_logger
13
  from src.tools.fhir_memory import save_chat_as_fhir
14
- import time
15
 
16
  logger = setup_logger("MedicalPipeline")
17
 
 
 
 
 
 
 
18
  def log_step(name: str, output: str = None):
19
  """Utility to log both to terminal and return a state update for the logs list."""
20
  logger.info(f"Executing: {name}")
@@ -23,14 +31,45 @@ def log_step(name: str, output: str = None):
23
  log_msg += f"\nOutput: {output}"
24
  return {"logs": [log_msg]}
25
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  async def role_classifier_node(state: AgentState):
27
  res = await role_classifier.run(state)
28
  log = log_step("Role Classifier", f"Detected Role: {res.get('user_role')}")
29
  res.update(log)
30
-
 
31
  if res.get('user_role') != "clinician":
32
  res['intent_type'] = "general"
33
-
34
  return res
35
 
36
  from langchain_core.runnables.config import RunnableConfig
@@ -42,6 +81,13 @@ async def patient_llm_node(state: AgentState, config: RunnableConfig):
42
  log = log_step("Patient LLM", content)
43
  return {"messages": res["messages"], "logs": log["logs"]}
44
 
 
 
 
 
 
 
 
45
  async def validator_node(state: AgentState):
46
  res = await validator.run(state)
47
  log = log_step("Response Validator", f"Valid: {res.get('is_valid')}")
@@ -63,6 +109,22 @@ async def recovery_loop_node(state: AgentState):
63
  "logs": log["logs"]
64
  }
65
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66
  async def intent_classifier_node(state: AgentState):
67
  res = await intent_classifier.run(state)
68
  log = log_step("Intent Classifier", f"Intent: {res.get('intent_type')}")
@@ -70,31 +132,48 @@ async def intent_classifier_node(state: AgentState):
70
  return res
71
 
72
  async def diagnosis_assist_node(state: AgentState, config: RunnableConfig):
73
- res = await diagnosis_assist.run(state, config=config)
74
  log = log_step("Diagnosis Assistant", res.get('clinician_outputs', [""])[-1] if res.get('clinician_outputs') else "")
75
  res.update(log)
 
 
76
  return res
77
 
78
  async def treatment_assist_node(state: AgentState, config: RunnableConfig):
79
- res = await treatment_assist.run(state, config=config)
80
  log = log_step("Treatment Assistant", res.get('clinician_outputs', [""])[-1] if res.get('clinician_outputs') else "")
81
  res.update(log)
 
 
82
  return res
83
 
84
  async def monitoring_assist_node(state: AgentState, config: RunnableConfig):
85
- res = await monitoring_assist.run(state, config=config)
86
  log = log_step("Monitoring Assistant", res.get('clinician_outputs', [""])[-1] if res.get('clinician_outputs') else "")
87
  res.update(log)
 
 
88
  return res
89
 
90
  async def general_assist_node(state: AgentState, config: RunnableConfig):
91
- res = await general_assist.run(state, config=config)
92
  log = log_step("General Clinical Assistant", res.get('clinician_outputs', [""])[-1] if res.get('clinician_outputs') else "")
93
  res.update(log)
 
 
94
  return res
95
 
96
  async def merge_outputs_node(state: AgentState, config: RunnableConfig):
97
- res = await output_merger.run(state, config=config)
 
 
 
 
 
 
 
 
 
98
  log = log_step("Output Merger", "Merged outputs successfully.")
99
  res.update(log)
100
  return res
@@ -134,23 +213,28 @@ async def tool_node_with_logging(state: AgentState):
134
  return res
135
 
136
  async def persistence_node(state: AgentState):
137
- """Save the current chat history to MongoDB in FHIR format."""
138
  patient_id = state.get("patient_id", "anonymous")
139
-
 
140
  # Convert LangChain messages to a simple list of dicts for the tool
141
  formatted_messages = []
142
  for msg in state["messages"]:
143
  role = "user" if msg.type == "human" else "assistant"
144
  formatted_messages.append({"role": role, "content": msg.content})
145
-
146
- # We only save if there's a patient_id and it's a patient role
147
- if state.get("user_role") == "patient":
148
  start_time = time.time()
149
- res = save_chat_as_fhir.invoke({"patient_id": patient_id, "messages": formatted_messages})
 
 
 
 
150
  end_time = time.time()
151
-
152
  log = log_step("FHIR Persistence", res)
153
-
154
  metrics = {
155
  "agent": "PersistenceNode",
156
  "tokens": 0,
@@ -163,8 +247,12 @@ async def persistence_node(state: AgentState):
163
  # Define routing functions
164
  def route_after_role(state: AgentState):
165
  role = state["user_role"]
 
 
166
  if role == "patient":
167
  return "patient_llm"
 
 
168
  elif role == "clinician":
169
  return "intent_classifier"
170
  elif role == "researcher":
@@ -193,6 +281,8 @@ def route_after_tools(state: AgentState):
193
  return "research_agent"
194
  elif role == "dietary":
195
  return "dietary_assist"
 
 
196
  return "patient_llm"
197
 
198
  def route_after_validator(state: AgentState):
@@ -209,10 +299,18 @@ def route_after_persistence(state: AgentState):
209
  return END
210
 
211
  def route_after_recovery(state: AgentState):
 
 
 
 
212
  role = state.get("user_role")
213
  if role == "dietary":
214
  return "dietary_assist"
215
- return "patient_llm"
 
 
 
 
216
 
217
  def route_after_intent(state: AgentState):
218
  intent = state.get("intent_type", "general")
@@ -237,9 +335,11 @@ builder = StateGraph(AgentState)
237
  # Add nodes
238
  builder.add_node("role_classifier", role_classifier_node)
239
  builder.add_node("patient_llm", patient_llm_node)
 
240
  builder.add_node("validator", validator_node)
241
  builder.add_node("safety_check", safety_check_node)
242
  builder.add_node("recovery_loop", recovery_loop_node)
 
243
  builder.add_node("intent_classifier", intent_classifier_node)
244
  builder.add_node("diagnosis_assist", diagnosis_assist_node)
245
  builder.add_node("treatment_assist", treatment_assist_node)
@@ -257,17 +357,23 @@ builder.set_entry_point("role_classifier")
257
  # Define edges
258
  builder.add_conditional_edges("role_classifier", route_after_role, {
259
  "patient_llm": "patient_llm",
 
260
  "intent_classifier": "intent_classifier",
261
  "research_agent": "research_agent",
262
  "dietary_assist": "dietary_assist",
 
263
  END: END
264
  })
265
 
266
- # Patient Pathway
267
  builder.add_conditional_edges("patient_llm", route_patient_llm, {
268
  "tools_node": "tools_node",
269
  "validator": "validator"
270
  })
 
 
 
 
271
  builder.add_conditional_edges("validator", route_after_validator, {
272
  "safety_check": "safety_check",
273
  "recovery_loop": "recovery_loop"
@@ -279,9 +385,13 @@ builder.add_conditional_edges("safety_check", route_after_safety, {
279
  builder.add_edge("persistence_node", END)
280
  builder.add_conditional_edges("recovery_loop", route_after_recovery, {
281
  "dietary_assist": "dietary_assist",
282
- "patient_llm": "patient_llm"
 
 
283
  })
284
 
 
 
285
  # Clinician Pathway
286
  builder.add_conditional_edges("intent_classifier", route_after_intent, {
287
  "diagnosis_assist": "diagnosis_assist",
@@ -305,6 +415,7 @@ builder.add_conditional_edges("research_agent", route_research_agent, {
305
  builder.add_conditional_edges("tools_node", route_after_tools, {
306
  "research_agent": "research_agent",
307
  "patient_llm": "patient_llm",
 
308
  "dietary_assist": "dietary_assist"
309
  })
310
 
 
1
+ import re
2
+ import time
3
+
4
  from langgraph.graph import StateGraph, END
5
  from src.core.state import AgentState
6
  from src.agents.agent_instances import (
7
+ role_classifier, patient_llm, caregiver_llm, validator, safety_check,
8
  intent_classifier, diagnosis_assist, treatment_assist,
9
  monitoring_assist, general_assist, output_merger, research_agent,
10
  dietary_assist
 
14
  from src.tools.web_tools import web_search_tool
15
  from src.utils.logger import setup_logger
16
  from src.tools.fhir_memory import save_chat_as_fhir
 
17
 
18
  logger = setup_logger("MedicalPipeline")
19
 
20
+ MAX_RECOVERY_ATTEMPTS = 3
21
+ EMERGENCY_PATTERNS = [
22
+ r"\b(unconscious|passed out|not breathing|seizure|seizing|stroke|heart attack|chest pain|coma)\b",
23
+ r"\b(dka|ketoacidosis|hypoglycemic emergency|hyperglycemic emergency|insulin overdose)\b",
24
+ ]
25
+
26
  def log_step(name: str, output: str = None):
27
  """Utility to log both to terminal and return a state update for the logs list."""
28
  logger.info(f"Executing: {name}")
 
31
  log_msg += f"\nOutput: {output}"
32
  return {"logs": [log_msg]}
33
 
34
+
35
+ def _extract_message_content(message) -> str:
36
+ if getattr(message, "content", None):
37
+ return message.content
38
+ return str(getattr(message, "tool_calls", ""))
39
+
40
+
41
+ def _set_latest_clinician_output(res: dict):
42
+ latest_output = ""
43
+ if res.get("messages"):
44
+ latest_output = _extract_message_content(res["messages"][-1])
45
+
46
+ if latest_output:
47
+ res["clinician_outputs"] = [latest_output]
48
+ else:
49
+ res["clinician_outputs"] = []
50
+ return res
51
+ def detect_emergency(state: AgentState) -> bool:
52
+ last_message = state.get("messages")[-1] if state.get("messages") else None
53
+ if not last_message:
54
+ return False
55
+
56
+ content = getattr(last_message, "content", "")
57
+ if not content:
58
+ return False
59
+
60
+ normalized = content.lower()
61
+ return any(re.search(pattern, normalized) for pattern in EMERGENCY_PATTERNS)
62
+
63
+
64
  async def role_classifier_node(state: AgentState):
65
  res = await role_classifier.run(state)
66
  log = log_step("Role Classifier", f"Detected Role: {res.get('user_role')}")
67
  res.update(log)
68
+
69
+ # Intent is only needed for the clinician pathway.
70
  if res.get('user_role') != "clinician":
71
  res['intent_type'] = "general"
72
+
73
  return res
74
 
75
  from langchain_core.runnables.config import RunnableConfig
 
81
  log = log_step("Patient LLM", content)
82
  return {"messages": res["messages"], "logs": log["logs"]}
83
 
84
+ async def caregiver_llm_node(state: AgentState, config: RunnableConfig):
85
+ res = await caregiver_llm.run(state, config=config)
86
+ last_msg = res["messages"][-1]
87
+ content = last_msg.content if getattr(last_msg, "content", "") else str(getattr(last_msg, "tool_calls", ""))
88
+ log = log_step("Caregiver LLM", content)
89
+ return {"messages": res["messages"], "logs": log["logs"]}
90
+
91
  async def validator_node(state: AgentState):
92
  res = await validator.run(state)
93
  log = log_step("Response Validator", f"Valid: {res.get('is_valid')}")
 
109
  "logs": log["logs"]
110
  }
111
 
112
+
113
+ async def emergency_response_node(state: AgentState):
114
+ response = (
115
+ "This appears to be a possible medical emergency. Seek urgent medical assistance now "
116
+ "and do not delay care. If the person is unconscious, not breathing, or having a seizure, "
117
+ "call emergency services immediately."
118
+ )
119
+ log = log_step("Emergency Fast Path", response)
120
+ return {
121
+ "messages": [AIMessage(content=response)],
122
+ "logs": log["logs"],
123
+ "is_valid": True,
124
+ "is_safe": True,
125
+ }
126
+
127
+
128
  async def intent_classifier_node(state: AgentState):
129
  res = await intent_classifier.run(state)
130
  log = log_step("Intent Classifier", f"Intent: {res.get('intent_type')}")
 
132
  return res
133
 
134
  async def diagnosis_assist_node(state: AgentState, config: RunnableConfig):
135
+ res = _set_latest_clinician_output(await diagnosis_assist.run(state, config=config))
136
  log = log_step("Diagnosis Assistant", res.get('clinician_outputs', [""])[-1] if res.get('clinician_outputs') else "")
137
  res.update(log)
138
+ clinician_outputs = (state.get("clinician_outputs") or []) + (res.get("clinician_outputs") or [])
139
+ res["clinician_outputs"] = clinician_outputs
140
  return res
141
 
142
  async def treatment_assist_node(state: AgentState, config: RunnableConfig):
143
+ res = _set_latest_clinician_output(await treatment_assist.run(state, config=config))
144
  log = log_step("Treatment Assistant", res.get('clinician_outputs', [""])[-1] if res.get('clinician_outputs') else "")
145
  res.update(log)
146
+ clinician_outputs = (state.get("clinician_outputs") or []) + (res.get("clinician_outputs") or [])
147
+ res["clinician_outputs"] = clinician_outputs
148
  return res
149
 
150
  async def monitoring_assist_node(state: AgentState, config: RunnableConfig):
151
+ res = _set_latest_clinician_output(await monitoring_assist.run(state, config=config))
152
  log = log_step("Monitoring Assistant", res.get('clinician_outputs', [""])[-1] if res.get('clinician_outputs') else "")
153
  res.update(log)
154
+ clinician_outputs = (state.get("clinician_outputs") or []) + (res.get("clinician_outputs") or [])
155
+ res["clinician_outputs"] = clinician_outputs
156
  return res
157
 
158
  async def general_assist_node(state: AgentState, config: RunnableConfig):
159
+ res = _set_latest_clinician_output(await general_assist.run(state, config=config))
160
  log = log_step("General Clinical Assistant", res.get('clinician_outputs', [""])[-1] if res.get('clinician_outputs') else "")
161
  res.update(log)
162
+ clinician_outputs = (state.get("clinician_outputs") or []) + (res.get("clinician_outputs") or [])
163
+ res["clinician_outputs"] = clinician_outputs
164
  return res
165
 
166
  async def merge_outputs_node(state: AgentState, config: RunnableConfig):
167
+ # Prepare state with context about the specialist outputs for the OutputMerger
168
+ clinician_outputs = state.get("clinician_outputs", [])
169
+ if clinician_outputs:
170
+ # Add specialist outputs summary to messages for context
171
+ outputs_context = "\n\n".join([f"Specialist Output {i+1}:\n{output}" for i, output in enumerate(clinician_outputs)])
172
+ state_with_context = dict(state)
173
+ state_with_context["messages"] = state["messages"] + [AIMessage(content=outputs_context)]
174
+ res = await output_merger.run(state_with_context, config=config)
175
+ else:
176
+ res = await output_merger.run(state, config=config)
177
  log = log_step("Output Merger", "Merged outputs successfully.")
178
  res.update(log)
179
  return res
 
213
  return res
214
 
215
  async def persistence_node(state: AgentState):
216
+ """Save the current chat history to Supabase in FHIR format."""
217
  patient_id = state.get("patient_id", "anonymous")
218
+ session_id = state.get("session_id")
219
+
220
  # Convert LangChain messages to a simple list of dicts for the tool
221
  formatted_messages = []
222
  for msg in state["messages"]:
223
  role = "user" if msg.type == "human" else "assistant"
224
  formatted_messages.append({"role": role, "content": msg.content})
225
+
226
+ # Persist patient-facing and caregiver-facing conversations for the active patient.
227
+ if state.get("user_role") in ("patient", "caregiver"):
228
  start_time = time.time()
229
+ res = save_chat_as_fhir.invoke({
230
+ "patient_id": patient_id,
231
+ "messages": formatted_messages,
232
+ "session_id": session_id,
233
+ })
234
  end_time = time.time()
235
+
236
  log = log_step("FHIR Persistence", res)
237
+
238
  metrics = {
239
  "agent": "PersistenceNode",
240
  "tokens": 0,
 
247
  # Define routing functions
248
  def route_after_role(state: AgentState):
249
  role = state["user_role"]
250
+ if role in {"patient", "caregiver"} and detect_emergency(state):
251
+ return "emergency_response"
252
  if role == "patient":
253
  return "patient_llm"
254
+ elif role == "caregiver":
255
+ return "caregiver_llm"
256
  elif role == "clinician":
257
  return "intent_classifier"
258
  elif role == "researcher":
 
281
  return "research_agent"
282
  elif role == "dietary":
283
  return "dietary_assist"
284
+ elif role == "caregiver":
285
+ return "caregiver_llm"
286
  return "patient_llm"
287
 
288
  def route_after_validator(state: AgentState):
 
299
  return END
300
 
301
  def route_after_recovery(state: AgentState):
302
+ attempts = state.get("attempts", 0)
303
+ if attempts >= MAX_RECOVERY_ATTEMPTS:
304
+ return "persistence_node"
305
+
306
  role = state.get("user_role")
307
  if role == "dietary":
308
  return "dietary_assist"
309
+ elif role == "caregiver":
310
+ return "caregiver_llm"
311
+ elif role == "patient":
312
+ return "patient_llm"
313
+ return "persistence_node"
314
 
315
  def route_after_intent(state: AgentState):
316
  intent = state.get("intent_type", "general")
 
335
  # Add nodes
336
  builder.add_node("role_classifier", role_classifier_node)
337
  builder.add_node("patient_llm", patient_llm_node)
338
+ builder.add_node("caregiver_llm", caregiver_llm_node)
339
  builder.add_node("validator", validator_node)
340
  builder.add_node("safety_check", safety_check_node)
341
  builder.add_node("recovery_loop", recovery_loop_node)
342
+ builder.add_node("emergency_response", emergency_response_node)
343
  builder.add_node("intent_classifier", intent_classifier_node)
344
  builder.add_node("diagnosis_assist", diagnosis_assist_node)
345
  builder.add_node("treatment_assist", treatment_assist_node)
 
357
  # Define edges
358
  builder.add_conditional_edges("role_classifier", route_after_role, {
359
  "patient_llm": "patient_llm",
360
+ "caregiver_llm": "caregiver_llm",
361
  "intent_classifier": "intent_classifier",
362
  "research_agent": "research_agent",
363
  "dietary_assist": "dietary_assist",
364
+ "emergency_response": "emergency_response",
365
  END: END
366
  })
367
 
368
+ # Patient and Caregiver Pathways
369
  builder.add_conditional_edges("patient_llm", route_patient_llm, {
370
  "tools_node": "tools_node",
371
  "validator": "validator"
372
  })
373
+ builder.add_conditional_edges("caregiver_llm", route_patient_llm, {
374
+ "tools_node": "tools_node",
375
+ "validator": "validator"
376
+ })
377
  builder.add_conditional_edges("validator", route_after_validator, {
378
  "safety_check": "safety_check",
379
  "recovery_loop": "recovery_loop"
 
385
  builder.add_edge("persistence_node", END)
386
  builder.add_conditional_edges("recovery_loop", route_after_recovery, {
387
  "dietary_assist": "dietary_assist",
388
+ "caregiver_llm": "caregiver_llm",
389
+ "patient_llm": "patient_llm",
390
+ "persistence_node": "persistence_node"
391
  })
392
 
393
+ builder.add_edge("emergency_response", "persistence_node")
394
+
395
  # Clinician Pathway
396
  builder.add_conditional_edges("intent_classifier", route_after_intent, {
397
  "diagnosis_assist": "diagnosis_assist",
 
415
  builder.add_conditional_edges("tools_node", route_after_tools, {
416
  "research_agent": "research_agent",
417
  "patient_llm": "patient_llm",
418
+ "caregiver_llm": "caregiver_llm",
419
  "dietary_assist": "dietary_assist"
420
  })
421
 
src/core/graph_cdm.py CHANGED
@@ -3,7 +3,10 @@ from src.core.state import AgentState
3
  from src.agents.agent_instances import (
4
  health_coach, trend_analyzer, validator, safety_check
5
  )
6
- from src.tools.fhir_memory import get_patient_summary_fhir, save_observation, ingest_fhir_bundle
 
 
 
7
  from src.tools.web_tools import web_search_tool
8
  from langgraph.prebuilt import ToolNode
9
  import time
@@ -19,15 +22,33 @@ def log_step(name: str, output: str = None):
19
  return {"logs": [log_msg]}
20
 
21
  async def data_fetch_node(state: AgentState):
 
 
 
 
22
  patient_id = state.get("patient_id", "unknown")
23
  if patient_id == "unknown":
24
  return log_step("Data Fetch", "No Patient ID provided.")
25
 
26
  start_time = time.time()
 
 
27
  summary = get_patient_summary_fhir.invoke({"patient_id": patient_id})
 
 
 
 
 
 
 
 
28
  end_time = time.time()
29
 
30
- log = log_step("FHIR Data Fetch", f"Retrieved summary for {patient_id}")
 
 
 
 
31
 
32
  metrics = {
33
  "agent": "DataFetchNode",
@@ -37,6 +58,7 @@ async def data_fetch_node(state: AgentState):
37
 
38
  return {
39
  "fhir_data": [summary] if isinstance(summary, dict) else [],
 
40
  "logs": log["logs"],
41
  "metrics": [metrics]
42
  }
@@ -85,7 +107,7 @@ def route_after_validator(state: AgentState):
85
  return "health_coach" # Simple retry
86
 
87
  # Setup tools
88
- cdm_tools = [web_search_tool, save_observation, ingest_fhir_bundle, get_patient_summary_fhir]
89
  tool_node = ToolNode(cdm_tools)
90
 
91
  async def tools_node_with_metrics(state: AgentState):
 
3
  from src.agents.agent_instances import (
4
  health_coach, trend_analyzer, validator, safety_check
5
  )
6
+ from src.tools.fhir_memory import (
7
+ get_patient_summary_fhir, save_observation, ingest_fhir_bundle,
8
+ get_medications_by_patient, save_medication
9
+ )
10
  from src.tools.web_tools import web_search_tool
11
  from langgraph.prebuilt import ToolNode
12
  import time
 
22
  return {"logs": [log_msg]}
23
 
24
  async def data_fetch_node(state: AgentState):
25
+ """
26
+ Fetch FHIR data and medications for CDM context.
27
+ Bug 7.3: Include medication context in CDM pipeline.
28
+ """
29
  patient_id = state.get("patient_id", "unknown")
30
  if patient_id == "unknown":
31
  return log_step("Data Fetch", "No Patient ID provided.")
32
 
33
  start_time = time.time()
34
+
35
+ # Fetch FHIR summary
36
  summary = get_patient_summary_fhir.invoke({"patient_id": patient_id})
37
+
38
+ # Bug 7.3: Fetch medications for context
39
+ medications = get_medications_by_patient.invoke({"patient_id": patient_id})
40
+
41
+ # If summary has active_medications field, populate it
42
+ if isinstance(summary, dict) and "active_medications" in summary:
43
+ summary["active_medications"] = medications if medications else []
44
+
45
  end_time = time.time()
46
 
47
+ log_output = f"Retrieved summary and medications for {patient_id}"
48
+ if medications:
49
+ log_output += f" ({len(medications)} active medications)"
50
+
51
+ log = log_step("FHIR Data Fetch", log_output)
52
 
53
  metrics = {
54
  "agent": "DataFetchNode",
 
58
 
59
  return {
60
  "fhir_data": [summary] if isinstance(summary, dict) else [],
61
+ "current_medications": medications if isinstance(medications, list) else [],
62
  "logs": log["logs"],
63
  "metrics": [metrics]
64
  }
 
107
  return "health_coach" # Simple retry
108
 
109
  # Setup tools
110
+ cdm_tools = [web_search_tool, save_observation, ingest_fhir_bundle, get_patient_summary_fhir, get_medications_by_patient, save_medication]
111
  tool_node = ToolNode(cdm_tools)
112
 
113
  async def tools_node_with_metrics(state: AgentState):
src/core/model_manager.py CHANGED
@@ -107,6 +107,14 @@ class ReqModel:
107
  loop = asyncio.get_event_loop()
108
  return await loop.run_in_executor(None, lambda: self._make_request(messages, config, **kwargs))
109
 
 
 
 
 
 
 
 
 
110
  class RateLimitFallbackWrapper:
111
  def __init__(self, main_llm, fallback_llms):
112
  self.main_llm = main_llm
@@ -132,7 +140,7 @@ class RateLimitFallbackWrapper:
132
  return await fb_llm.ainvoke(messages, config=config, **kwargs)
133
  except Exception as fb_e:
134
  logger.warning(f"Fallback {idx+1} failed: {fb_e}")
135
-
136
  raise RuntimeError("All models (main and fallbacks) failed.") from None
137
 
138
  def invoke(self, messages, config=None, **kwargs):
@@ -147,9 +155,45 @@ class RateLimitFallbackWrapper:
147
  return fb_llm.invoke(messages, config=config, **kwargs)
148
  except Exception as fb_e:
149
  logger.warning(f"Fallback {idx+1} failed: {fb_e}")
150
-
151
  raise RuntimeError("All models failed synchronously.") from None
152
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
153
  class ModelManager:
154
  def __init__(self, model_name: str = "google/gemma-4-26b-a4b-it:free"):
155
  self.provider = os.getenv("MODEL_PROVIDER", "openrouter").lower()
@@ -171,13 +215,13 @@ class ModelManager:
171
 
172
  return primary_api_key, secondary_api_key
173
 
174
- def get_llm(self, temperature: float = 0):
175
- logger.info(f"Initializing LLM: Provider={self.provider}, Model={self.model_name}")
 
176
 
177
- model = self.model_name
178
  primary_api_key, secondary_api_key = self._get_openrouter_api_keys()
179
  base_url = "https://openrouter.ai/api/v1"
180
-
181
  main_llm = ReqModel(
182
  model=model,
183
  temperature=temperature,
 
107
  loop = asyncio.get_event_loop()
108
  return await loop.run_in_executor(None, lambda: self._make_request(messages, config, **kwargs))
109
 
110
+ def stream(self, messages, config=None, **kwargs):
111
+ yield self._make_request(messages, config, **kwargs)
112
+
113
+ async def astream(self, messages, config=None, **kwargs):
114
+ loop = asyncio.get_event_loop()
115
+ response = await loop.run_in_executor(None, lambda: self._make_request(messages, config, **kwargs))
116
+ yield response
117
+
118
  class RateLimitFallbackWrapper:
119
  def __init__(self, main_llm, fallback_llms):
120
  self.main_llm = main_llm
 
140
  return await fb_llm.ainvoke(messages, config=config, **kwargs)
141
  except Exception as fb_e:
142
  logger.warning(f"Fallback {idx+1} failed: {fb_e}")
143
+
144
  raise RuntimeError("All models (main and fallbacks) failed.") from None
145
 
146
  def invoke(self, messages, config=None, **kwargs):
 
155
  return fb_llm.invoke(messages, config=config, **kwargs)
156
  except Exception as fb_e:
157
  logger.warning(f"Fallback {idx+1} failed: {fb_e}")
158
+
159
  raise RuntimeError("All models failed synchronously.") from None
160
 
161
+ def stream(self, messages, config=None, **kwargs):
162
+ try:
163
+ yield from self.main_llm.stream(messages, config=config, **kwargs)
164
+ return
165
+ except Exception as e:
166
+ logger.warning(f"LLM stream error with main model: {e}. Attempting fallbacks immediately.")
167
+
168
+ for idx, fb_llm in enumerate(self.fallback_llms):
169
+ try:
170
+ logger.info(f"Trying fallback model {idx+1} for streaming")
171
+ yield from fb_llm.stream(messages, config=config, **kwargs)
172
+ return
173
+ except Exception as fb_e:
174
+ logger.warning(f"Fallback {idx+1} streaming failed: {fb_e}")
175
+
176
+ raise RuntimeError("All models failed while streaming.")
177
+
178
+ async def astream(self, messages, config=None, **kwargs):
179
+ try:
180
+ async for chunk in self.main_llm.astream(messages, config=config, **kwargs):
181
+ yield chunk
182
+ return
183
+ except Exception as e:
184
+ logger.warning(f"LLM async stream error with main model: {e}. Attempting fallbacks immediately.")
185
+
186
+ for idx, fb_llm in enumerate(self.fallback_llms):
187
+ try:
188
+ logger.info(f"Trying fallback model {idx+1} for async streaming")
189
+ async for chunk in fb_llm.astream(messages, config=config, **kwargs):
190
+ yield chunk
191
+ return
192
+ except Exception as fb_e:
193
+ logger.warning(f"Fallback {idx+1} async streaming failed: {fb_e}")
194
+
195
+ raise RuntimeError("All models failed while async streaming.")
196
+
197
  class ModelManager:
198
  def __init__(self, model_name: str = "google/gemma-4-26b-a4b-it:free"):
199
  self.provider = os.getenv("MODEL_PROVIDER", "openrouter").lower()
 
215
 
216
  return primary_api_key, secondary_api_key
217
 
218
+ def get_llm(self, temperature: float = 0, model_name: str = None):
219
+ model = model_name or self.model_name
220
+ logger.info(f"Initializing LLM: Provider={self.provider}, Model={model}")
221
 
 
222
  primary_api_key, secondary_api_key = self._get_openrouter_api_keys()
223
  base_url = "https://openrouter.ai/api/v1"
224
+
225
  main_llm = ReqModel(
226
  model=model,
227
  temperature=temperature,
src/core/state.py CHANGED
@@ -9,11 +9,15 @@ class AgentState(TypedDict):
9
  # Metadata for routing
10
  user_role: str # patient, clinician, researcher
11
  intent_type: str # diagnosis, treatment, monitoring, general
 
12
 
13
  # CDM / FHIR Data
 
14
  patient_id: str
15
  fhir_data: List[dict]
16
  trend_analysis: str
 
 
17
 
18
  # Validation status
19
  is_valid: bool
@@ -30,6 +34,9 @@ class AgentState(TypedDict):
30
  # Sources for information
31
  sources: Annotated[List[str], operator.add]
32
 
 
 
 
33
  # Execution logs
34
  logs: Annotated[List[str], operator.add]
35
 
 
9
  # Metadata for routing
10
  user_role: str # patient, clinician, researcher
11
  intent_type: str # diagnosis, treatment, monitoring, general
12
+ trace_id: str
13
 
14
  # CDM / FHIR Data
15
+ session_id: str
16
  patient_id: str
17
  fhir_data: List[dict]
18
  trend_analysis: str
19
+ trend_data: dict
20
+ current_medications: List[dict]
21
 
22
  # Validation status
23
  is_valid: bool
 
34
  # Sources for information
35
  sources: Annotated[List[str], operator.add]
36
 
37
+ # Evidence citations for clinical recommendations (Bug 12.3)
38
+ evidence_citations: Annotated[List[dict], operator.add]
39
+
40
  # Execution logs
41
  logs: Annotated[List[str], operator.add]
42
 
src/mcp/server.py CHANGED
@@ -6,7 +6,9 @@ import sys
6
  import json
7
  import asyncio
8
  from typing import Any, Dict, List
9
- from src.tools.fhir_memory import get_observations_by_patient, ingest_fhir_bundle
 
 
10
  from src.agents.cdm_agents import TrendAnalyzer
11
  from src.utils.logger import setup_logger
12
 
@@ -15,6 +17,12 @@ logger = setup_logger("MCPServer")
15
  class MedicalMCPServer:
16
  def __init__(self):
17
  self.trend_analyzer = TrendAnalyzer()
 
 
 
 
 
 
18
 
19
  async def handle_request(self, request: Dict[str, Any]) -> Dict[str, Any]:
20
  method = request.get("method")
@@ -35,7 +43,7 @@ class MedicalMCPServer:
35
  return {"jsonrpc": "2.0", "error": {"code": -32603, "message": str(e)}, "id": req_id}
36
 
37
  def list_tools(self) -> List[Dict[str, Any]]:
38
- return [
39
  {
40
  "name": "analyze_health_trends",
41
  "description": "Analyze FHIR observation trends for a patient.",
@@ -46,29 +54,31 @@ class MedicalMCPServer:
46
  },
47
  "required": ["patient_id"]
48
  }
49
- },
50
- {
51
- "name": "query_fhir_observations",
52
- "description": "Query historical FHIR observations for a patient.",
53
- "inputSchema": {
54
- "type": "object",
55
- "properties": {
56
- "patient_id": {"type": "string"},
57
- "loinc_code": {"type": "string"}
58
- },
59
- "required": ["patient_id"]
60
- }
61
  }
62
  ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
 
64
  async def call_tool(self, name: str, args: Dict[str, Any]) -> Any:
65
  logger.info(f"Calling MCP tool: {name}")
66
  if name == "analyze_health_trends":
67
  return await self.trend_analyzer.analyze_trends(args["patient_id"])
68
- elif name == "query_fhir_observations":
69
- return get_observations_by_patient.invoke(args)
70
- elif name == "ingest_fhir":
71
- return ingest_fhir_bundle.invoke({"bundle": args["bundle"]})
72
  else:
73
  raise ValueError(f"Unknown tool: {name}")
74
 
 
6
  import json
7
  import asyncio
8
  from typing import Any, Dict, List
9
+ import src.tools.fhir_memory as fhir_tools
10
+ import src.tools.dietary_tools as dietary_tools
11
+ from langchain_core.tools import BaseTool
12
  from src.agents.cdm_agents import TrendAnalyzer
13
  from src.utils.logger import setup_logger
14
 
 
17
  class MedicalMCPServer:
18
  def __init__(self):
19
  self.trend_analyzer = TrendAnalyzer()
20
+ self.tools = {}
21
+ for module in [fhir_tools, dietary_tools]:
22
+ for name in dir(module):
23
+ obj = getattr(module, name)
24
+ if isinstance(obj, BaseTool):
25
+ self.tools[obj.name] = obj
26
 
27
  async def handle_request(self, request: Dict[str, Any]) -> Dict[str, Any]:
28
  method = request.get("method")
 
43
  return {"jsonrpc": "2.0", "error": {"code": -32603, "message": str(e)}, "id": req_id}
44
 
45
  def list_tools(self) -> List[Dict[str, Any]]:
46
+ tool_list = [
47
  {
48
  "name": "analyze_health_trends",
49
  "description": "Analyze FHIR observation trends for a patient.",
 
54
  },
55
  "required": ["patient_id"]
56
  }
 
 
 
 
 
 
 
 
 
 
 
 
57
  }
58
  ]
59
+
60
+ for name, tool_obj in self.tools.items():
61
+ schema = {}
62
+ if tool_obj.args_schema:
63
+ try:
64
+ schema = tool_obj.args_schema.schema()
65
+ except AttributeError:
66
+ schema = tool_obj.args_schema.model_json_schema()
67
+
68
+ tool_list.append({
69
+ "name": name,
70
+ "description": tool_obj.description,
71
+ "inputSchema": schema
72
+ })
73
+
74
+ return tool_list
75
 
76
  async def call_tool(self, name: str, args: Dict[str, Any]) -> Any:
77
  logger.info(f"Calling MCP tool: {name}")
78
  if name == "analyze_health_trends":
79
  return await self.trend_analyzer.analyze_trends(args["patient_id"])
80
+ elif name in self.tools:
81
+ return self.tools[name].invoke(args)
 
 
82
  else:
83
  raise ValueError(f"Unknown tool: {name}")
84
 
src/params.json ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "RoleClassifier": {
3
+ "temperature": 0.1
4
+ },
5
+ "IntentClassifier": {
6
+ "temperature": 0.0
7
+ },
8
+ "PatientLLM": {
9
+ "temperature": 0.2
10
+ },
11
+ "CaregiverLLM": {
12
+ "temperature": 0.2
13
+ },
14
+ "ResponseValidator": {
15
+ "temperature": 0.0
16
+ },
17
+ "SafetyCheck": {
18
+ "temperature": 0.0
19
+ },
20
+ "ClinicalSpecialist_Diagnosis": {
21
+ "temperature": 0.0
22
+ },
23
+ "ClinicalSpecialist_Treatment": {
24
+ "temperature": 0.0
25
+ },
26
+ "ClinicalSpecialist_Monitoring": {
27
+ "temperature": 0.0
28
+ },
29
+ "ClinicalSpecialist_General Clinical Support": {
30
+ "temperature": 0.2
31
+ },
32
+ "ResearchAgent": {
33
+ "temperature": 0.2
34
+ },
35
+ "DietarySpecialist": {
36
+ "temperature": 0.2
37
+ },
38
+ "OutputMerger": {
39
+ "temperature": 0.2
40
+ },
41
+ "HealthCoach": {
42
+ "temperature": 0.2
43
+ },
44
+ "TrendAnalyzer": {
45
+ "temperature": 0.0
46
+ }
47
+ }
src/prompts/CaregiverLLM.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ You are a supportive caregiver assistant for a diabetes management platform.
2
+ Your role is to help a caregiver interpret the patient's symptoms, understand medication and monitoring needs, and know when to escalate to urgent care.
3
+ Answer in a calm, practical, third-person tone as if you are guiding someone who is supporting a patient.
4
+ Focus on actionable next steps, safety, and reassurance. If the query involves urgent symptoms, encourage immediate escalation to emergency services or professional help.
5
+
6
+ At the end of your response, include a confidence score from 0.0 to 1.0 in the format: Confidence: 0.8
src/prompts/ClinicalSpecialist_Diagnosis.txt CHANGED
@@ -1 +1,13 @@
1
- You are a assistant to a certified medical pratiotioner in Diabetes. You are to assist the pratiotioner to diagnose what specific issue he wants you to diagnose and validate. Provide proper reasoning. Use medical terminologies and expressions.
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ You are a assistant to a certified medical practitioner in Diabetes. You are to assist the practitioner to diagnose what specific issue they want you to diagnose and validate. Provide proper reasoning. Use medical terminologies and expressions.
2
+
3
+ [Evidence Citation Requirements - Bug 12.3]
4
+ For EVERY diagnostic recommendation or clinical statement, you MUST:
5
+ 1. Cite the specific guideline or document source (e.g., "ADA Standards of Care 2026", page X)
6
+ 2. Assign an evidence level: A (Excellent evidence), B (Good evidence), or C (Limited evidence)
7
+ 3. Include the specific guideline clause or section being referenced
8
+
9
+ Format your response with clear sections:
10
+ - DIAGNOSIS: [Your diagnostic statement]
11
+ - EVIDENCE LEVEL: [A/B/C]
12
+ - SOURCE: [Specific guideline, page, section]
13
+ - EXPLANATION: [Detailed clinical reasoning]
src/prompts/ClinicalSpecialist_General Clinical Support.txt CHANGED
@@ -1 +1,14 @@
1
- You are a assistant to a certified medical pratiotioner in Diabetes. You are to assist the pratiotioner in general clinical support. Provide proper reasoning. Use medical terminologies and expressions.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ You are a assistant to a certified medical pratiotioner in Diabetes. You are to assist the pratiotioner in general clinical support. Provide proper reasoning. Use medical terminologies and expressions.
2
+
3
+ [Evidence Citation Requirements - Bug 12.3]
4
+ For clinical statements and recommendations, you MUST:
5
+ 1. Cite the specific guideline or document source when applicable (e.g., "ADA Standards of Care 2026", page X)
6
+ 2. Assign an evidence level: A (Excellent evidence), B (Good evidence), or C (Limited evidence)
7
+ 3. Include the specific guideline clause or section being referenced
8
+
9
+ Structure clinical guidance with:
10
+ - TOPIC/QUESTION: [What is being addressed]
11
+ - KEY POINTS: [Main clinical information]
12
+ - EVIDENCE LEVEL: [A/B/C]
13
+ - SOURCE: [Specific guideline or reference]
14
+ - CLINICAL CONTEXT: [Relevant considerations for application]
src/prompts/ClinicalSpecialist_General.txt CHANGED
@@ -1 +1,13 @@
1
- You are a assistant to a certified medical pratiotioner in Diabetes. You are to assist the pratiotioner in general clinical support. Provide proper reasoning. Use medical terminologies and expressions.
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ You are a assistant to a certified medical pratiotioner in Diabetes. You are to assist the pratiotioner in general clinical support. Provide proper reasoning. Use medical terminologies and expressions.
2
+
3
+ [Evidence Citation Requirements - Bug 12.3]
4
+ For EVERY clinical recommendation or statement, you MUST:
5
+ 1. Cite the specific guideline or document source (e.g., "ADA Standards of Care 2026", page X)
6
+ 2. Assign an evidence level: A (Excellent evidence), B (Good evidence), or C (Limited evidence)
7
+ 3. Include the specific guideline clause or section being referenced
8
+
9
+ Format key recommendations with:
10
+ - RECOMMENDATION: [Your recommendation]
11
+ - EVIDENCE LEVEL: [A/B/C]
12
+ - SOURCE: [Specific guideline, page, section]
13
+ - RATIONALE: [Clinical reasoning]
src/prompts/ClinicalSpecialist_Monitoring.txt CHANGED
@@ -1 +1,15 @@
1
- You are a assistant to a certified medical pratiotioner in Diabetes. You are to assist the pratiotioner to monitor the patient. Go through diffrential diagnosis to arrive at a final possible diagnosis. Use medical terminologies and expressions.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ You are a assistant to a certified medical pratiotioner in Diabetes. You are to assist the pratiotioner to monitor the patient. Go through diffrential diagnosis to arrive at a final possible diagnosis. Use medical terminologies and expressions.
2
+
3
+ [Evidence Citation Requirements - Bug 12.3]
4
+ For EVERY monitoring recommendation or parameter threshold, you MUST:
5
+ 1. Cite the specific guideline or document source (e.g., "ADA Standards of Care 2026", page X)
6
+ 2. Assign an evidence level: A (Excellent evidence), B (Good evidence), or C (Limited evidence)
7
+ 3. Include the specific guideline clause or section being referenced
8
+
9
+ Format monitoring recommendations with:
10
+ - MONITORING PARAMETER: [Parameter to monitor]
11
+ - THRESHOLD/TARGET: [Specific value or range]
12
+ - EVIDENCE LEVEL: [A/B/C]
13
+ - SOURCE: [Specific guideline, page, section]
14
+ - FREQUENCY: [Recommended monitoring frequency]
15
+ - RATIONALE: [Clinical justification]
src/prompts/ClinicalSpecialist_Treatment.txt CHANGED
@@ -1 +1,14 @@
1
- You are a assistant to a certified medical pratiotioner in Diabetes. You are to assist the pratiotioner to treat the patient. If multiple treatments are possible, then list them with their pros and cons and let the pratiotioner decide. Use medical terminologies and expressions.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ You are a assistant to a certified medical pratiotioner in Diabetes. You are to assist the pratiotioner to treat the patient. If multiple treatments are possible, then list them with their pros and cons and let the pratiotioner decide. Use medical terminologies and expressions.
2
+
3
+ [Evidence Citation Requirements - Bug 12.3]
4
+ For EVERY treatment recommendation or medication suggestion, you MUST:
5
+ 1. Cite the specific guideline or document source (e.g., "ADA Standards of Care 2026", page X)
6
+ 2. Assign an evidence level: A (Excellent evidence), B (Good evidence), or C (Limited evidence)
7
+ 3. Include the specific guideline clause or section being referenced
8
+
9
+ Format treatment recommendations with:
10
+ - TREATMENT OPTION: [Your recommendation]
11
+ - EVIDENCE LEVEL: [A/B/C]
12
+ - SOURCE: [Specific guideline, page, section]
13
+ - CLINICAL RATIONALE: [Detailed explanation]
14
+ - MONITORING: [Required follow-up and monitoring]
src/prompts/ResponseValidator.txt CHANGED
@@ -3,6 +3,8 @@ You are a medical response validator for a diabetes management AI. Your task is
3
  GUIDELINES:
4
  1. Check the response for medical accuracy and completeness regarding diabetes care.
5
  2. Check for proper source citations.
6
- 3. If the response is accurate, return only 'valid'.
7
- 4. If there are issues, return 'invalid' followed by a brief reason.
8
- 5. Use professional judgment but be rigorous.
 
 
 
3
  GUIDELINES:
4
  1. Check the response for medical accuracy and completeness regarding diabetes care.
5
  2. Check for proper source citations.
6
+ 3. Return JSON only in the following format:
7
+ {"decision": "valid"}
8
+ or
9
+ {"decision": "invalid", "reason": "brief explanation"}
10
+ 4. Use professional judgment but be rigorous.
src/prompts/RoleClassifier.txt CHANGED
@@ -1,10 +1,19 @@
1
  You are a highly accurate Medical Triage Assistant for a diabetes management platform.
2
- Your task is to analyze the user's input and classify it into one of four specific roles:
3
  - 'patient': For general health inquiries, symptoms, diabetes self-management, or personal medical advice.
 
4
  - 'clinician': For professional medical questions regarding diabetes diagnosis, treatment protocols, HbA1c management, or specialized medical data.
5
  - 'researcher': For deep medical research, endocrinology scientific information, or clinical trial data on diabetes.
6
  - 'dietary': For requests related to nutrition, diabetic diet plans, food facts, glycemic index, or nutritional guidelines.
7
 
 
 
 
 
 
 
 
8
  Return ONLY the role name in lowercase (e.g., 'patient').
9
  If the input is ambiguous but mentions food, nutrition, or diet, prioritize 'dietary'.
10
- If the user is asking about scientific papers or diabetes guidelines, it might be 'researcher' or 'dietary' depending on the focus.
 
 
1
  You are a highly accurate Medical Triage Assistant for a diabetes management platform.
2
+ Your task is to analyze the user's input and classify it into one of five specific roles:
3
  - 'patient': For general health inquiries, symptoms, diabetes self-management, or personal medical advice.
4
+ - 'caregiver': For questions from a family member, caregiver, or proxy support person managing a patient's care.
5
  - 'clinician': For professional medical questions regarding diabetes diagnosis, treatment protocols, HbA1c management, or specialized medical data.
6
  - 'researcher': For deep medical research, endocrinology scientific information, or clinical trial data on diabetes.
7
  - 'dietary': For requests related to nutrition, diabetic diet plans, food facts, glycemic index, or nutritional guidelines.
8
 
9
+ Use regex-style signal words when possible. For example:
10
+ - caregiver queries: "caregiver", "caring for", "helping my mother", "supporting my father"
11
+ - diet or nutrition queries: "meal plan", "glycemic index", "food facts", "carbohydrate counting"
12
+ - research queries: "study", "trial", "paper", "meta-analysis", "cohort", "evidence"
13
+ - clinician queries: "doctor", "physician", "prescription", "diagnosis", "treatment", "protocol", "dose", "HbA1c"
14
+ - patient queries: first-person symptoms like "I have", "I've been", "I feel", "my blood sugar", "my doctor told me"
15
+
16
  Return ONLY the role name in lowercase (e.g., 'patient').
17
  If the input is ambiguous but mentions food, nutrition, or diet, prioritize 'dietary'.
18
+ If the user is asking about scientific papers or diabetes guidelines, it might be 'researcher' or 'dietary' depending on the focus.
19
+ If the user is asking on behalf of another person, prioritize 'caregiver'.
src/prompts/SafetyCheck.txt CHANGED
@@ -3,6 +3,8 @@ You are a medical safety officer for a diabetes management AI. Your primary resp
3
  GUIDELINES:
4
  1. Check if the response contains any life-threatening misinformation (e.g., incorrect insulin dosing advice, ignoring severe hypoglycemia).
5
  2. Verify if the advice is compliant with standard medical protocols for diabetes (e.g., WHO, ADA, or national guidelines).
6
- 3. If the advice is safe, return 'safe'.
7
- 4. If there is even a small risk of harm or severe misinformation, return 'unsafe' followed by the specific safety concern.
8
- 5. Pay close attention to dosages (especially insulin), dangerous drug interactions, or inappropriate self-treatment suggestions for severe symptoms (like DKA).
 
 
 
3
  GUIDELINES:
4
  1. Check if the response contains any life-threatening misinformation (e.g., incorrect insulin dosing advice, ignoring severe hypoglycemia).
5
  2. Verify if the advice is compliant with standard medical protocols for diabetes (e.g., WHO, ADA, or national guidelines).
6
+ 3. Return JSON only in the following format:
7
+ {"decision": "safe"}
8
+ or
9
+ {"decision": "unsafe", "reason": "brief explanation"}
10
+ 4. Pay close attention to dosages (especially insulin), dangerous drug interactions, or inappropriate self-treatment suggestions for severe symptoms (like DKA).
src/tools/dietary_tools.py CHANGED
@@ -14,11 +14,24 @@ DB_PATH = os.path.join(os.path.dirname(__file__), "../../data/dietary_guidelines
14
  logger.info("Loading embedding model for tools...")
15
  model = SentenceTransformer('all-MiniLM-L6-v2')
16
 
17
- def _get_supabase_client() -> Client:
 
 
 
18
  url = os.getenv("SUPABASE_URL")
19
  key = os.getenv("SUPABASE_KEY")
 
 
20
  return create_client(url, key)
21
 
 
 
 
 
 
 
 
 
22
  @tool
23
  def search_guidelines(query: str):
24
  """
@@ -26,8 +39,8 @@ def search_guidelines(query: str):
26
  Returns content with source and page information.
27
  """
28
  logger.info(f"Searching Supabase guidelines for: {query}")
29
- client = _get_supabase_client()
30
-
31
  # Generate embedding for query
32
  query_embedding = model.encode(query).tolist()
33
 
 
14
  logger.info("Loading embedding model for tools...")
15
  model = SentenceTransformer('all-MiniLM-L6-v2')
16
 
17
+ _supabase_client = None
18
+
19
+
20
+ def _build_supabase_client() -> Client:
21
  url = os.getenv("SUPABASE_URL")
22
  key = os.getenv("SUPABASE_KEY")
23
+ if not url or not key:
24
+ raise RuntimeError("SUPABASE_URL and SUPABASE_KEY must be set")
25
  return create_client(url, key)
26
 
27
+
28
+ def get_supabase_client() -> Client:
29
+ global _supabase_client
30
+ if _supabase_client is None:
31
+ _supabase_client = _build_supabase_client()
32
+ return _supabase_client
33
+
34
+
35
  @tool
36
  def search_guidelines(query: str):
37
  """
 
39
  Returns content with source and page information.
40
  """
41
  logger.info(f"Searching Supabase guidelines for: {query}")
42
+ client = get_supabase_client()
43
+
44
  # Generate embedding for query
45
  query_embedding = model.encode(query).tolist()
46
 
src/tools/fhir_memory.py CHANGED
@@ -273,6 +273,77 @@ def get_chat_history_by_session(session_id: str):
273
  logger.error(f"Error retrieving chat history: {e}")
274
  return []
275
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
276
  @tool
277
  def ingest_fhir_bundle(bundle: dict):
278
  """
 
273
  logger.error(f"Error retrieving chat history: {e}")
274
  return []
275
 
276
+ @tool
277
+ def get_medications_by_patient(patient_id: str):
278
+ """
279
+ Retrieve current medications for a patient in FHIR MedicationRequest format.
280
+ Bug 7.3: Retrieve medications to support CDM pipeline context.
281
+ """
282
+ logger.info(f"Retrieving medications for patient: {patient_id}")
283
+ client = _get_client()
284
+
285
+ try:
286
+ # Query medications table if it exists
287
+ query = client.table("medications").select("resource").eq("patient_id", patient_id)
288
+ response = query.execute()
289
+
290
+ if response.data:
291
+ medications = [record["resource"] for record in response.data]
292
+ return medications
293
+
294
+ # Return empty list with informative message if no medications found
295
+ return []
296
+ except Exception as e:
297
+ logger.error(f"Error retrieving medications: {e}")
298
+ # Return empty list on error to prevent pipeline failures
299
+ return []
300
+
301
+ @tool
302
+ def save_medication(patient_id: str, medication_name: str, dosage: str, frequency: str, status: str = "active"):
303
+ """
304
+ Save a medication record for a patient in FHIR MedicationRequest format.
305
+ Bug 7.3: Store medications to support CDM pipeline context.
306
+ """
307
+ logger.info(f"Saving medication for patient: {patient_id}")
308
+ client = _get_client()
309
+ _ensure_patient_exists(patient_id, client)
310
+
311
+ med_id = str(uuid.uuid4())
312
+
313
+ # Construct FHIR MedicationRequest
314
+ fhir_medication_request = {
315
+ "resourceType": "MedicationRequest",
316
+ "id": med_id,
317
+ "status": status,
318
+ "intent": "order",
319
+ "subject": {"reference": f"Patient/{patient_id}"},
320
+ "authoredOn": datetime.datetime.now(datetime.timezone.utc).isoformat(),
321
+ "medication": {
322
+ "coding": [{
323
+ "system": "http://www.nlm.nih.gov/research/umls/rxnorm",
324
+ "display": medication_name
325
+ }]
326
+ },
327
+ "dosageInstruction": [{
328
+ "text": f"{dosage} {frequency}"
329
+ }]
330
+ }
331
+
332
+ data = {
333
+ "id": med_id,
334
+ "patient_id": patient_id,
335
+ "resource": fhir_medication_request,
336
+ "last_updated": datetime.datetime.now(datetime.timezone.utc).isoformat()
337
+ }
338
+
339
+ try:
340
+ # Check if medications table exists, if not create in memory
341
+ client.table("medications").insert(data).execute()
342
+ return f"Successfully saved medication: {medication_name}"
343
+ except Exception as e:
344
+ logger.error(f"Error saving medication: {e}")
345
+ return f"Error saving medication: {str(e)}"
346
+
347
  @tool
348
  def ingest_fhir_bundle(bundle: dict):
349
  """
src/utils/auth.py CHANGED
@@ -1,5 +1,7 @@
1
  import os
2
  import jwt
 
 
3
  from fastapi import Request, HTTPException, Depends
4
  from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
5
  from src.utils.logger import setup_logger
@@ -7,19 +9,25 @@ from src.utils.logger import setup_logger
7
  logger = setup_logger("Auth")
8
  security = HTTPBearer(auto_error=False)
9
 
 
10
  DEFAULT_PATIENT_ID = os.getenv("DEFAULT_PATIENT_UUID")
 
 
11
 
12
  def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)):
13
  """
14
  Verifies the Supabase JWT and returns the user ID (sub).
15
  """
 
 
 
16
  token = credentials.credentials
17
  jwt_secret = os.getenv("SUPABASE_JWT_SECRET")
18
-
19
  if not jwt_secret:
20
  logger.error("SUPABASE_JWT_SECRET not found in environment")
21
  raise HTTPException(status_code=500, detail="JWT secret missing")
22
-
23
  try:
24
  # Supabase uses HS256 for signing JWTs with the project secret
25
  payload = jwt.decode(token, jwt_secret, algorithms=["HS256"], audience="authenticated")
@@ -33,14 +41,44 @@ def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(securit
33
  logger.warning(f"Invalid token: {e}")
34
  raise HTTPException(status_code=401, detail="Invalid token")
35
 
 
36
  def get_active_user(credentials: HTTPAuthorizationCredentials = Depends(security)):
37
  """
38
- Returns the user ID from the token if valid, otherwise returns the DEFAULT_PATIENT_ID.
 
 
39
  """
40
  if not credentials:
41
- return DEFAULT_PATIENT_ID
42
-
 
 
 
43
  try:
44
  return get_current_user(credentials)
45
- except Exception:
46
- return DEFAULT_PATIENT_ID
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import os
2
  import jwt
3
+ import datetime
4
+ import uuid
5
  from fastapi import Request, HTTPException, Depends
6
  from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
7
  from src.utils.logger import setup_logger
 
9
  logger = setup_logger("Auth")
10
  security = HTTPBearer(auto_error=False)
11
 
12
+ # Default patient fallback is allowed only in development when explicitly enabled
13
  DEFAULT_PATIENT_ID = os.getenv("DEFAULT_PATIENT_UUID")
14
+ DEV_ALLOW_DEFAULT_PATIENT = os.getenv("DEV_ALLOW_DEFAULT_PATIENT", "false").lower() in ("1", "true", "yes")
15
+
16
 
17
  def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)):
18
  """
19
  Verifies the Supabase JWT and returns the user ID (sub).
20
  """
21
+ if not credentials:
22
+ raise HTTPException(status_code=401, detail="Missing credentials")
23
+
24
  token = credentials.credentials
25
  jwt_secret = os.getenv("SUPABASE_JWT_SECRET")
26
+
27
  if not jwt_secret:
28
  logger.error("SUPABASE_JWT_SECRET not found in environment")
29
  raise HTTPException(status_code=500, detail="JWT secret missing")
30
+
31
  try:
32
  # Supabase uses HS256 for signing JWTs with the project secret
33
  payload = jwt.decode(token, jwt_secret, algorithms=["HS256"], audience="authenticated")
 
41
  logger.warning(f"Invalid token: {e}")
42
  raise HTTPException(status_code=401, detail="Invalid token")
43
 
44
+
45
  def get_active_user(credentials: HTTPAuthorizationCredentials = Depends(security)):
46
  """
47
+ Returns the user ID from the token if valid.
48
+ In development mode (DEV_ALLOW_DEFAULT_PATIENT) it falls back to DEFAULT_PATIENT_ID when no token provided.
49
+ In production it will raise 401 for missing/invalid tokens.
50
  """
51
  if not credentials:
52
+ if DEV_ALLOW_DEFAULT_PATIENT and DEFAULT_PATIENT_ID:
53
+ logger.info("No credentials provided; falling back to DEFAULT_PATIENT_ID (dev mode)")
54
+ return DEFAULT_PATIENT_ID
55
+ raise HTTPException(status_code=401, detail="Missing credentials")
56
+
57
  try:
58
  return get_current_user(credentials)
59
+ except HTTPException as e:
60
+ # In dev mode we may still fall back
61
+ if DEV_ALLOW_DEFAULT_PATIENT and DEFAULT_PATIENT_ID:
62
+ logger.info("Invalid credentials; falling back to DEFAULT_PATIENT_ID (dev mode)")
63
+ return DEFAULT_PATIENT_ID
64
+ raise e
65
+
66
+
67
+ def create_dev_token(patient_id: str, expires_minutes: int = 60):
68
+ """Create a development JWT signed with SUPABASE_JWT_SECRET for local dev/testing.
69
+
70
+ The token uses `sub` to store the patient/user id and `aud` of 'authenticated' to match validation.
71
+ """
72
+ jwt_secret = os.getenv("SUPABASE_JWT_SECRET")
73
+ if not jwt_secret:
74
+ raise RuntimeError("SUPABASE_JWT_SECRET not set; cannot create token")
75
+
76
+ now = datetime.datetime.utcnow()
77
+ payload = {
78
+ "sub": patient_id,
79
+ "iat": now,
80
+ "exp": now + datetime.timedelta(minutes=expires_minutes),
81
+ "aud": "authenticated",
82
+ }
83
+ token = jwt.encode(payload, jwt_secret, algorithm="HS256")
84
+ return token
src/utils/export_prompts.py CHANGED
@@ -7,8 +7,8 @@ if project_root not in sys.path:
7
  sys.path.append(project_root)
8
 
9
  from src.agents.agents import (
10
- RoleClassifier, PatientLLM, ResponseValidator, SafetyCheck,
11
- IntentClassifier, ClinicalSpecialist, OutputMerger,
12
  ResearchAgent, DietarySpecialist
13
  )
14
 
@@ -21,6 +21,7 @@ def export_prompts():
21
  agents = {
22
  "RoleClassifier": RoleClassifier(),
23
  "PatientLLM": PatientLLM(),
 
24
  "ResponseValidator": ResponseValidator(),
25
  "SafetyCheck": SafetyCheck(),
26
  "IntentClassifier": IntentClassifier(),
 
7
  sys.path.append(project_root)
8
 
9
  from src.agents.agents import (
10
+ RoleClassifier, PatientLLM, CaregiverLLM, ResponseValidator, SafetyCheck,
11
+ IntentClassifier, ClinicalSpecialist, OutputMerger,
12
  ResearchAgent, DietarySpecialist
13
  )
14
 
 
21
  agents = {
22
  "RoleClassifier": RoleClassifier(),
23
  "PatientLLM": PatientLLM(),
24
+ "CaregiverLLM": CaregiverLLM(),
25
  "ResponseValidator": ResponseValidator(),
26
  "SafetyCheck": SafetyCheck(),
27
  "IntentClassifier": IntentClassifier(),
tests/test_agent_output_structuring.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import sys
3
+ import types
4
+ import unittest
5
+ from pathlib import Path
6
+
7
+ ROOT = Path(__file__).resolve().parents[2]
8
+ if str(ROOT) not in sys.path:
9
+ sys.path.insert(0, str(ROOT))
10
+
11
+ from src.agents.agents import ResponseValidator, SafetyCheck
12
+
13
+
14
+ class _DummyLLM:
15
+ def __init__(self, content):
16
+ self.content = content
17
+
18
+ async def ainvoke(self, messages):
19
+ return types.SimpleNamespace(
20
+ content=self.content,
21
+ usage_metadata=None,
22
+ response_metadata={}
23
+ )
24
+
25
+
26
+ class TestStructuredAgentOutputs(unittest.TestCase):
27
+ def test_response_validator_accepts_structured_json(self):
28
+ validator = ResponseValidator()
29
+ validator.llm = _DummyLLM('{"decision": "invalid", "reason": "not complete"}')
30
+
31
+ async def _run():
32
+ return await validator.run({"messages": [types.SimpleNamespace(content="Sample output")]})
33
+
34
+ result = asyncio.run(_run())
35
+ self.assertFalse(result["is_valid"])
36
+
37
+ def test_safety_check_accepts_structured_json(self):
38
+ safety_check = SafetyCheck()
39
+ safety_check.llm = _DummyLLM('{"decision": "safe"}')
40
+
41
+ async def _run():
42
+ return await safety_check.run({"messages": [types.SimpleNamespace(content="Sample output")]})
43
+
44
+ result = asyncio.run(_run())
45
+ self.assertTrue(result["is_safe"])
46
+
47
+
48
+ if __name__ == "__main__":
49
+ unittest.main()
tests/test_agent_params.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import unittest
2
+
3
+ from src.agent_params import get_agent_params
4
+
5
+
6
+ class TestAgentParams(unittest.TestCase):
7
+ def test_known_agent_temperature_is_loaded(self):
8
+ params = get_agent_params("PatientLLM")
9
+ self.assertEqual(params["temperature"], 0.2)
10
+
11
+ def test_unknown_agent_returns_empty_dict(self):
12
+ self.assertEqual(get_agent_params("UnknownAgent"), {})
13
+
14
+
15
+ if __name__ == "__main__":
16
+ unittest.main()
tests/test_chapter6.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys
2
+ import unittest
3
+ from pathlib import Path
4
+
5
+ from langchain_core.messages import AIMessage, HumanMessage
6
+
7
+ ROOT = Path(__file__).resolve().parents[2]
8
+ if str(ROOT) not in sys.path:
9
+ sys.path.insert(0, str(ROOT))
10
+
11
+ from src.agents.agents import DietarySpecialist, OutputMerger
12
+
13
+
14
+ class StubLLM:
15
+ def __init__(self, content):
16
+ self.content = content
17
+ self.calls = []
18
+
19
+ async def ainvoke(self, messages, config=None):
20
+ self.calls.append(messages)
21
+ return AIMessage(content=self.content)
22
+
23
+ async def astream(self, messages, config=None):
24
+ self.calls.append(messages)
25
+ yield AIMessage(content=self.content)
26
+
27
+
28
+ class TestChapter6(unittest.IsolatedAsyncioTestCase):
29
+ async def test_output_merger_uses_latest_specialist_output(self):
30
+ llm = StubLLM("merged response")
31
+ merger = OutputMerger()
32
+ merger.llm = llm
33
+
34
+ state = {
35
+ "messages": [
36
+ HumanMessage(content="What should I do next?"),
37
+ AIMessage(content="Older specialist response"),
38
+ AIMessage(content="Older user-facing answer"),
39
+ ],
40
+ "clinician_outputs": [
41
+ "Diagnosis says continue monitoring.",
42
+ "Treatment says adjust medication dose.",
43
+ ],
44
+ }
45
+
46
+ result = await merger.run(state)
47
+
48
+ self.assertEqual(result["messages"][-1].content, "merged response")
49
+ self.assertEqual(result["metrics"][0]["agent"], "OutputMerger")
50
+
51
+ merged_prompt = "\n".join(
52
+ message.content for message in llm.calls[-1]
53
+ )
54
+ self.assertIn("Treatment says adjust medication dose.", merged_prompt)
55
+ self.assertNotIn("Older specialist response", merged_prompt)
56
+
57
+ async def test_dietary_specialist_returns_metrics(self):
58
+ llm = StubLLM("dietary guidance")
59
+ specialist = DietarySpecialist()
60
+ specialist.llm = llm
61
+
62
+ state = {
63
+ "messages": [HumanMessage(content="Suggest a low-carb meal plan for today.")]
64
+ }
65
+
66
+ result = await specialist.run(state)
67
+
68
+ self.assertEqual(result["messages"][-1].content, "dietary guidance")
69
+ self.assertEqual(result["metrics"][0]["agent"], "DietarySpecialist")
70
+
71
+
72
+ if __name__ == "__main__":
73
+ unittest.main()
tests/test_dietary_singletons.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import unittest
2
+ from unittest.mock import patch
3
+
4
+ from src.tools import dietary_tools
5
+
6
+
7
+ class TestDietarySingletons(unittest.TestCase):
8
+ def tearDown(self):
9
+ dietary_tools._supabase_client = None
10
+
11
+ def test_get_supabase_client_reuses_cached_client(self):
12
+ sentinel_client = object()
13
+
14
+ with patch.object(dietary_tools, "_build_supabase_client", return_value=sentinel_client):
15
+ first_client = dietary_tools.get_supabase_client()
16
+ second_client = dietary_tools.get_supabase_client()
17
+
18
+ self.assertIs(first_client, second_client)
19
+ self.assertIs(first_client, sentinel_client)
20
+
21
+
22
+ if __name__ == "__main__":
23
+ unittest.main()
tests/test_dietary_tools.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import unittest
2
+ from unittest.mock import patch
3
+
4
+ from src.tools import dietary_tools
5
+
6
+
7
+ class DummyResponse:
8
+ def __init__(self, data):
9
+ self.data = data
10
+
11
+
12
+ class DummyClient:
13
+ def __init__(self, data):
14
+ self._data = data
15
+
16
+ def rpc(self, *args, **kwargs):
17
+ return DummyResponse(self._data)
18
+
19
+ def table(self, *args, **kwargs):
20
+ return DummyResponse([])
21
+
22
+
23
+ class TestDietaryTools(unittest.TestCase):
24
+ def test_page_indexed_retrieval_matches_search_guidelines(self):
25
+ dummy_results = [
26
+ {
27
+ "metadata": {"source": "Supabase Guide", "page_index": 12},
28
+ "content": "Use dietary guidance for carbohydrate targets."
29
+ }
30
+ ]
31
+ dummy_client = DummyClient(dummy_results)
32
+
33
+ with patch.object(dietary_tools, "get_supabase_client", return_value=dummy_client), \
34
+ patch.object(dietary_tools.model, "encode", return_value=[0.1, 0.2, 0.3]):
35
+ search_output = dietary_tools.search_guidelines.invoke("low carb meal plan")
36
+ page_output = dietary_tools.page_indexed_retrieval.invoke("low carb meal plan")
37
+
38
+ self.assertEqual(search_output, page_output)
39
+ self.assertIn("Supabase pgvector", search_output)
40
+ self.assertIn("Supabase Guide", search_output)
41
+
42
+
43
+ if __name__ == "__main__":
44
+ unittest.main()
tests/test_graph_routing.py ADDED
@@ -0,0 +1,161 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import importlib.util
2
+ import sys
3
+ import types
4
+ import unittest
5
+ from pathlib import Path
6
+
7
+
8
+ ROOT = Path(__file__).resolve().parents[2]
9
+ if str(ROOT) not in sys.path:
10
+ sys.path.insert(0, str(ROOT))
11
+
12
+
13
+ class _DummyLogger:
14
+ def info(self, *_args, **_kwargs):
15
+ return None
16
+
17
+
18
+ class _DummyMessage:
19
+ def __init__(self, content):
20
+ self.content = content
21
+
22
+
23
+ class _DummyStateGraph:
24
+ def __init__(self, *args, **kwargs):
25
+ pass
26
+
27
+ def add_node(self, *args, **kwargs):
28
+ return None
29
+
30
+ def add_conditional_edges(self, *args, **kwargs):
31
+ return None
32
+
33
+ def add_edge(self, *args, **kwargs):
34
+ return None
35
+
36
+ def set_entry_point(self, *args, **kwargs):
37
+ return None
38
+
39
+ def compile(self):
40
+ return self
41
+
42
+
43
+ def _load_graph_module():
44
+ langgraph = types.ModuleType("langgraph")
45
+ graph_module = types.ModuleType("langgraph.graph")
46
+ graph_module.StateGraph = _DummyStateGraph
47
+ graph_module.END = "END"
48
+ langgraph.graph = graph_module
49
+
50
+ prebuilt_module = types.ModuleType("langgraph.prebuilt")
51
+ prebuilt_module.ToolNode = lambda tools: tools
52
+
53
+ langchain_core = types.ModuleType("langchain_core")
54
+ messages_module = types.ModuleType("langchain_core.messages")
55
+
56
+ class AIMessage:
57
+ pass
58
+
59
+ class ToolMessage:
60
+ pass
61
+
62
+ messages_module.AIMessage = AIMessage
63
+ messages_module.ToolMessage = ToolMessage
64
+
65
+ runnables_module = types.ModuleType("langchain_core.runnables")
66
+ config_module = types.ModuleType("langchain_core.runnables.config")
67
+ config_module.RunnableConfig = dict
68
+
69
+ runnables_module.config = config_module
70
+ langchain_core.messages = messages_module
71
+ langchain_core.runnables = runnables_module
72
+
73
+ sys.modules.setdefault("langgraph", langgraph)
74
+ sys.modules.setdefault("langgraph.graph", graph_module)
75
+ sys.modules.setdefault("langgraph.prebuilt", prebuilt_module)
76
+ sys.modules.setdefault("langchain_core", langchain_core)
77
+ sys.modules.setdefault("langchain_core.messages", messages_module)
78
+ sys.modules.setdefault("langchain_core.runnables", runnables_module)
79
+ sys.modules.setdefault("langchain_core.runnables.config", config_module)
80
+
81
+ dummy_tools_module = types.ModuleType("src.tools.web_tools")
82
+ dummy_tools_module.web_search_tool = object()
83
+
84
+ dummy_fhir_module = types.ModuleType("src.tools.fhir_memory")
85
+ dummy_fhir_module.save_chat_as_fhir = types.SimpleNamespace(invoke=lambda payload: {"saved": True})
86
+
87
+ logger_module = types.ModuleType("src.utils.logger")
88
+ logger_module.setup_logger = lambda _name: _DummyLogger()
89
+
90
+ dietary_module = types.ModuleType("src.tools.dietary_tools")
91
+ dietary_module.page_indexed_retrieval = object()
92
+ dietary_module.search_guidelines = object()
93
+ dietary_module.get_nutritional_data = object()
94
+
95
+ agent_instances_module = types.ModuleType("src.agents.agent_instances")
96
+ agent_instances_module.role_classifier = types.SimpleNamespace(run=lambda *_args, **_kwargs: None)
97
+ agent_instances_module.patient_llm = types.SimpleNamespace(run=lambda *_args, **_kwargs: None)
98
+ agent_instances_module.caregiver_llm = types.SimpleNamespace(run=lambda *_args, **_kwargs: None)
99
+ agent_instances_module.validator = types.SimpleNamespace(run=lambda *_args, **_kwargs: None)
100
+ agent_instances_module.safety_check = types.SimpleNamespace(run=lambda *_args, **_kwargs: None)
101
+ agent_instances_module.intent_classifier = types.SimpleNamespace(run=lambda *_args, **_kwargs: None)
102
+ agent_instances_module.diagnosis_assist = types.SimpleNamespace(run=lambda *_args, **_kwargs: None)
103
+ agent_instances_module.treatment_assist = types.SimpleNamespace(run=lambda *_args, **_kwargs: None)
104
+ agent_instances_module.monitoring_assist = types.SimpleNamespace(run=lambda *_args, **_kwargs: None)
105
+ agent_instances_module.general_assist = types.SimpleNamespace(run=lambda *_args, **_kwargs: None)
106
+ agent_instances_module.output_merger = types.SimpleNamespace(run=lambda *_args, **_kwargs: None)
107
+ agent_instances_module.research_agent = types.SimpleNamespace(run=lambda *_args, **_kwargs: None)
108
+ agent_instances_module.dietary_assist = types.SimpleNamespace(run=lambda *_args, **_kwargs: None)
109
+
110
+ sys.modules.setdefault("src.tools.web_tools", dummy_tools_module)
111
+ sys.modules.setdefault("src.tools.fhir_memory", dummy_fhir_module)
112
+ sys.modules.setdefault("src.utils.logger", logger_module)
113
+ sys.modules.setdefault("src.tools.dietary_tools", dietary_module)
114
+ sys.modules.setdefault("src.agents.agent_instances", agent_instances_module)
115
+
116
+ spec = importlib.util.spec_from_file_location("graph_under_test", ROOT / "backend" / "src" / "core" / "graph.py")
117
+ module = importlib.util.module_from_spec(spec)
118
+ sys.modules[spec.name] = module
119
+ spec.loader.exec_module(module)
120
+ return module
121
+
122
+
123
+ class TestGraphRouting(unittest.TestCase):
124
+ @classmethod
125
+ def setUpClass(cls):
126
+ cls.graph = _load_graph_module()
127
+
128
+ def test_role_routes_caregiver_to_caregiver_llm(self):
129
+ state = {"user_role": "caregiver"}
130
+ self.assertEqual(self.graph.route_after_role(state), "caregiver_llm")
131
+
132
+ def test_role_routes_clinician_to_intent_classifier(self):
133
+ state = {"user_role": "clinician"}
134
+ self.assertEqual(self.graph.route_after_role(state), "intent_classifier")
135
+
136
+ def test_tools_route_returns_caregiver_llm_for_caregiver(self):
137
+ state = {"user_role": "caregiver"}
138
+ self.assertEqual(self.graph.route_after_tools(state), "caregiver_llm")
139
+
140
+ def test_recovery_route_returns_caregiver_llm_for_caregiver(self):
141
+ state = {"user_role": "caregiver"}
142
+ self.assertEqual(self.graph.route_after_recovery(state), "caregiver_llm")
143
+
144
+ def test_recovery_route_persists_after_max_attempts(self):
145
+ state = {"user_role": "patient", "attempts": 3}
146
+ self.assertEqual(self.graph.route_after_recovery(state), "persistence_node")
147
+
148
+ def test_emergency_route_triggers_fast_path_for_patient(self):
149
+ state = {
150
+ "user_role": "patient",
151
+ "messages": [_DummyMessage("The patient is unconscious and not breathing.")]
152
+ }
153
+ self.assertEqual(self.graph.route_after_role(state), "emergency_response")
154
+
155
+ def test_intent_classifier_routes_general_to_general_assist(self):
156
+ state = {"intent_type": "general"}
157
+ self.assertEqual(self.graph.route_after_intent(state), "general_assist")
158
+
159
+
160
+ if __name__ == "__main__":
161
+ unittest.main()
tests/test_role_classifier.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import unittest
2
+
3
+ from src.agents.role_utils import classify_role
4
+
5
+
6
+ class TestRoleClassifier(unittest.TestCase):
7
+ def test_caregiver_query_maps_to_caregiver(self):
8
+ self.assertEqual(classify_role("I am caring for my mother with diabetes"), "caregiver")
9
+
10
+ def test_dietary_query_maps_to_dietary(self):
11
+ self.assertEqual(classify_role("Can you suggest a low-carb meal plan?"), "dietary")
12
+
13
+
14
+ if __name__ == "__main__":
15
+ unittest.main()
tests/test_session_persistence.py ADDED
@@ -0,0 +1,153 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import importlib.util
2
+ import sys
3
+ import types
4
+ import unittest
5
+ from pathlib import Path
6
+
7
+ ROOT = Path(__file__).resolve().parents[2]
8
+ if str(ROOT) not in sys.path:
9
+ sys.path.insert(0, str(ROOT))
10
+
11
+
12
+ class _DummyLogger:
13
+ def info(self, *_args, **_kwargs):
14
+ return None
15
+
16
+
17
+ class _DummyStateGraph:
18
+ def __init__(self, *args, **kwargs):
19
+ pass
20
+
21
+ def add_node(self, *args, **kwargs):
22
+ return None
23
+
24
+ def add_conditional_edges(self, *args, **kwargs):
25
+ return None
26
+
27
+ def add_edge(self, *args, **kwargs):
28
+ return None
29
+
30
+ def set_entry_point(self, *args, **kwargs):
31
+ return None
32
+
33
+ def compile(self):
34
+ return self
35
+
36
+
37
+ class DummyMessage:
38
+ def __init__(self, message_type, content):
39
+ self.type = message_type
40
+ self.content = content
41
+
42
+
43
+ def _load_graph_module():
44
+ langgraph = types.ModuleType("langgraph")
45
+ graph_module = types.ModuleType("langgraph.graph")
46
+ graph_module.StateGraph = _DummyStateGraph
47
+ graph_module.END = "END"
48
+ langgraph.graph = graph_module
49
+
50
+ prebuilt_module = types.ModuleType("langgraph.prebuilt")
51
+ prebuilt_module.ToolNode = lambda tools: tools
52
+
53
+ langchain_core = types.ModuleType("langchain_core")
54
+ messages_module = types.ModuleType("langchain_core.messages")
55
+
56
+ class AIMessage:
57
+ pass
58
+
59
+ class ToolMessage:
60
+ pass
61
+
62
+ messages_module.AIMessage = AIMessage
63
+ messages_module.ToolMessage = ToolMessage
64
+
65
+ runnables_module = types.ModuleType("langchain_core.runnables")
66
+ config_module = types.ModuleType("langchain_core.runnables.config")
67
+ config_module.RunnableConfig = dict
68
+ runnables_module.config = config_module
69
+ langchain_core.messages = messages_module
70
+ langchain_core.runnables = runnables_module
71
+
72
+ sys.modules.setdefault("langgraph", langgraph)
73
+ sys.modules.setdefault("langgraph.graph", graph_module)
74
+ sys.modules.setdefault("langgraph.prebuilt", prebuilt_module)
75
+ sys.modules.setdefault("langchain_core", langchain_core)
76
+ sys.modules.setdefault("langchain_core.messages", messages_module)
77
+ sys.modules.setdefault("langchain_core.runnables", runnables_module)
78
+ sys.modules.setdefault("langchain_core.runnables.config", config_module)
79
+
80
+ dummy_tools_module = types.ModuleType("src.tools.web_tools")
81
+ dummy_tools_module.web_search_tool = object()
82
+
83
+ captured = {}
84
+
85
+ class DummySaveFHIR:
86
+ def invoke(self, payload):
87
+ captured.update(payload)
88
+ return "saved"
89
+
90
+ dummy_fhir_module = types.ModuleType("src.tools.fhir_memory")
91
+ dummy_fhir_module.save_chat_as_fhir = DummySaveFHIR()
92
+
93
+ logger_module = types.ModuleType("src.utils.logger")
94
+ logger_module.setup_logger = lambda _name: _DummyLogger()
95
+
96
+ dietary_module = types.ModuleType("src.tools.dietary_tools")
97
+ dietary_module.page_indexed_retrieval = object()
98
+ dietary_module.search_guidelines = object()
99
+ dietary_module.get_nutritional_data = object()
100
+
101
+ agent_instances_module = types.ModuleType("src.agents.agent_instances")
102
+ agent_instances_module.role_classifier = types.SimpleNamespace(run=lambda *_args, **_kwargs: None)
103
+ agent_instances_module.patient_llm = types.SimpleNamespace(run=lambda *_args, **_kwargs: None)
104
+ agent_instances_module.caregiver_llm = types.SimpleNamespace(run=lambda *_args, **_kwargs: None)
105
+ agent_instances_module.validator = types.SimpleNamespace(run=lambda *_args, **_kwargs: None)
106
+ agent_instances_module.safety_check = types.SimpleNamespace(run=lambda *_args, **_kwargs: None)
107
+ agent_instances_module.intent_classifier = types.SimpleNamespace(run=lambda *_args, **_kwargs: None)
108
+ agent_instances_module.diagnosis_assist = types.SimpleNamespace(run=lambda *_args, **_kwargs: None)
109
+ agent_instances_module.treatment_assist = types.SimpleNamespace(run=lambda *_args, **_kwargs: None)
110
+ agent_instances_module.monitoring_assist = types.SimpleNamespace(run=lambda *_args, **_kwargs: None)
111
+ agent_instances_module.general_assist = types.SimpleNamespace(run=lambda *_args, **_kwargs: None)
112
+ agent_instances_module.output_merger = types.SimpleNamespace(run=lambda *_args, **_kwargs: None)
113
+ agent_instances_module.research_agent = types.SimpleNamespace(run=lambda *_args, **_kwargs: None)
114
+ agent_instances_module.dietary_assist = types.SimpleNamespace(run=lambda *_args, **_kwargs: None)
115
+
116
+ sys.modules.setdefault("src.tools.web_tools", dummy_tools_module)
117
+ sys.modules.setdefault("src.tools.fhir_memory", dummy_fhir_module)
118
+ sys.modules.setdefault("src.utils.logger", logger_module)
119
+ sys.modules.setdefault("src.tools.dietary_tools", dietary_module)
120
+ sys.modules.setdefault("src.agents.agent_instances", agent_instances_module)
121
+
122
+ spec = importlib.util.spec_from_file_location("graph_under_test", ROOT / "backend" / "src" / "core" / "graph.py")
123
+ module = importlib.util.module_from_spec(spec)
124
+ sys.modules[spec.name] = module
125
+ spec.loader.exec_module(module)
126
+ return module, captured
127
+
128
+
129
+ class TestSessionPersistence(unittest.IsolatedAsyncioTestCase):
130
+ async def test_persistence_node_passes_session_id_to_fhir(self):
131
+ graph, captured = _load_graph_module()
132
+
133
+ state = {
134
+ "messages": [
135
+ DummyMessage("human", "What is my glucose trend?"),
136
+ DummyMessage("ai", "Your glucose trend is stable."),
137
+ ],
138
+ "user_role": "patient",
139
+ "patient_id": "patient-123",
140
+ "session_id": "session-456",
141
+ }
142
+
143
+ result = await graph.persistence_node(state)
144
+
145
+ self.assertEqual(captured["patient_id"], "patient-123")
146
+ self.assertEqual(captured["session_id"], "session-456")
147
+ self.assertEqual(captured["messages"][0]["role"], "user")
148
+ self.assertEqual(captured["messages"][1]["role"], "assistant")
149
+ self.assertEqual(result["metrics"][0]["agent"], "PersistenceNode")
150
+
151
+
152
+ if __name__ == "__main__":
153
+ unittest.main()