github-actions commited on
Commit ·
b1198f0
1
Parent(s): fb01a6c
Auto deploy from GitHub
Browse files- .env.example +7 -1
- app.py +66 -12
- data/llama3.2-500.json +0 -0
- main.py +233 -43
- requirements.txt +0 -2
- src/agent_params.py +18 -0
- src/agents/agent_instances.py +8 -9
- src/agents/agents.py +305 -73
- src/agents/cdm_agents.py +144 -28
- src/agents/role_utils.py +23 -0
- src/core/evidence_models.py +57 -0
- src/core/graph.py +131 -20
- src/core/graph_cdm.py +25 -3
- src/core/model_manager.py +50 -6
- src/core/state.py +7 -0
- src/mcp/server.py +28 -18
- src/params.json +47 -0
- src/prompts/CaregiverLLM.txt +6 -0
- src/prompts/ClinicalSpecialist_Diagnosis.txt +13 -1
- src/prompts/ClinicalSpecialist_General Clinical Support.txt +14 -1
- src/prompts/ClinicalSpecialist_General.txt +13 -1
- src/prompts/ClinicalSpecialist_Monitoring.txt +15 -1
- src/prompts/ClinicalSpecialist_Treatment.txt +14 -1
- src/prompts/ResponseValidator.txt +5 -3
- src/prompts/RoleClassifier.txt +11 -2
- src/prompts/SafetyCheck.txt +5 -3
- src/tools/dietary_tools.py +16 -3
- src/tools/fhir_memory.py +71 -0
- src/utils/auth.py +45 -7
- src/utils/export_prompts.py +3 -2
- tests/test_agent_output_structuring.py +49 -0
- tests/test_agent_params.py +16 -0
- tests/test_chapter6.py +73 -0
- tests/test_dietary_singletons.py +23 -0
- tests/test_dietary_tools.py +44 -0
- tests/test_graph_routing.py +161 -0
- tests/test_role_classifier.py +15 -0
- tests/test_session_persistence.py +153 -0
.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(
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 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"]
|
| 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"]
|
| 126 |
final_msg = ""
|
|
|
|
|
|
|
| 127 |
if "messages" in final_state and final_state["messages"]:
|
| 128 |
-
|
| 129 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 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 |
-
|
| 183 |
-
|
| 184 |
-
|
| 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.
|
| 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
|
| 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 |
-
|
| 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.
|
| 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
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 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.
|
| 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.
|
| 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
|
| 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 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
metrics = {
|
| 106 |
"agent": "RoleClassifier",
|
| 107 |
"tokens": tokens,
|
| 108 |
-
"time":
|
| 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
|
| 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 |
-
|
| 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
|
| 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 |
-
|
| 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 |
-
|
| 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
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
for code, points in data_points.items():
|
| 50 |
if len(points) >= 2:
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
|
| 59 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
|
| 61 |
async def run(self, state: AgentState):
|
| 62 |
-
# Extract patient ID from
|
| 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,
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
#
|
| 147 |
-
if state.get("user_role")
|
| 148 |
start_time = time.time()
|
| 149 |
-
res = save_chat_as_fhir.invoke({
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
"
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 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
|
| 69 |
-
return
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 7 |
-
|
| 8 |
-
|
|
|
|
|
|
|
|
|
| 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
|
| 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.
|
| 7 |
-
|
| 8 |
-
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 =
|
| 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
|
|
|
|
|
|
|
| 39 |
"""
|
| 40 |
if not credentials:
|
| 41 |
-
|
| 42 |
-
|
|
|
|
|
|
|
|
|
|
| 43 |
try:
|
| 44 |
return get_current_user(credentials)
|
| 45 |
-
except
|
| 46 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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()
|