Spaces:
Runtime error
Add multi-agent hospital ecosystem with patient, nurse, and senior doctor
Browse filesBackend:
- BaseAgent with Claude extended thinking support
- PatientAgent with Hindi/English (Hinglish) speech and distress levels
- NurseAgent with clinical observations, vitals reporting, and urgency alerts
- SeniorDoctorAgent with Socratic teaching methodology and progressive hints
- AgentOrchestrator coordinating all agents with session management
- New /api/agents routes (initialize, action, vitals)
Frontend:
- Animated SVG avatars (PatientAvatar, NurseAvatar, SeniorDoctorAvatar)
with state-based animations (distress, alerts, thinking bubbles)
- AgentMessage component with per-agent styling and avatar rendering
- Agent target selector (Patient/Nurse/Dr. Sharma) in chat sidebar
- Multi-agent chat replaces single AI tutor in CaseInterface
Existing functionality preserved: landing page, case browser, stage reveal,
diagnosis submission, results display, dashboard, and analytics.
https://claude.ai/code/session_01EtTBqEZVEmdWihzhSden2o
- backend/app/api/agents.py +65 -0
- backend/app/core/agents/base_agent.py +101 -0
- backend/app/core/agents/nurse_agent.py +179 -0
- backend/app/core/agents/orchestrator.py +195 -0
- backend/app/core/agents/patient_agent.py +148 -0
- backend/app/core/agents/senior_agent.py +156 -0
- backend/app/main.py +2 -1
- frontend/src/components/avatars/NurseAvatar.tsx +83 -0
- frontend/src/components/avatars/PatientAvatar.tsx +87 -0
- frontend/src/components/avatars/SeniorDoctorAvatar.tsx +78 -0
- frontend/src/components/avatars/index.ts +3 -0
- frontend/src/components/case/AgentMessage.tsx +97 -0
- frontend/src/hooks/useApi.ts +47 -0
- frontend/src/pages/CaseInterface.tsx +139 -60
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""API routes for the multi-agent hospital ecosystem."""
|
| 2 |
+
|
| 3 |
+
from fastapi import APIRouter, HTTPException
|
| 4 |
+
from pydantic import BaseModel
|
| 5 |
+
from typing import Optional
|
| 6 |
+
|
| 7 |
+
from app.core.agents.orchestrator import orchestrator
|
| 8 |
+
from app.core.rag.generator import CaseGenerator
|
| 9 |
+
|
| 10 |
+
router = APIRouter()
|
| 11 |
+
case_generator = CaseGenerator()
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class InitializeRequest(BaseModel):
|
| 15 |
+
case_id: str
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class AgentActionRequest(BaseModel):
|
| 19 |
+
session_id: str
|
| 20 |
+
action_type: str
|
| 21 |
+
student_input: Optional[str] = None
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
@router.post("/initialize")
|
| 25 |
+
async def initialize_agents(request: InitializeRequest):
|
| 26 |
+
"""Initialize multi-agent session for a case.
|
| 27 |
+
|
| 28 |
+
Returns initial messages from patient, nurse, and senior doctor.
|
| 29 |
+
"""
|
| 30 |
+
case = case_generator.get_case(request.case_id)
|
| 31 |
+
if not case:
|
| 32 |
+
raise HTTPException(status_code=404, detail="Case not found")
|
| 33 |
+
|
| 34 |
+
result = orchestrator.initialize_session(case)
|
| 35 |
+
return result
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
@router.post("/action")
|
| 39 |
+
async def agent_action(request: AgentActionRequest):
|
| 40 |
+
"""Process a student action through the multi-agent system.
|
| 41 |
+
|
| 42 |
+
action_type options:
|
| 43 |
+
- talk_to_patient: Talk to the patient
|
| 44 |
+
- ask_nurse: Ask the nurse
|
| 45 |
+
- consult_senior: Consult the senior doctor
|
| 46 |
+
- examine_patient: Perform examination
|
| 47 |
+
- order_investigation: Order tests
|
| 48 |
+
"""
|
| 49 |
+
result = orchestrator.process_action(
|
| 50 |
+
session_id=request.session_id,
|
| 51 |
+
action_type=request.action_type,
|
| 52 |
+
student_input=request.student_input,
|
| 53 |
+
)
|
| 54 |
+
if "error" in result:
|
| 55 |
+
raise HTTPException(status_code=404, detail=result["error"])
|
| 56 |
+
return result
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
@router.get("/vitals/{session_id}")
|
| 60 |
+
async def get_vitals(session_id: str):
|
| 61 |
+
"""Get current vital signs and urgency status for a session."""
|
| 62 |
+
vitals = orchestrator.get_session_vitals(session_id)
|
| 63 |
+
if not vitals:
|
| 64 |
+
raise HTTPException(status_code=404, detail="Session not found")
|
| 65 |
+
return vitals
|
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Base agent class for the multi-agent hospital ecosystem."""
|
| 2 |
+
|
| 3 |
+
import logging
|
| 4 |
+
import os
|
| 5 |
+
from abc import ABC, abstractmethod
|
| 6 |
+
from typing import Optional
|
| 7 |
+
|
| 8 |
+
import anthropic
|
| 9 |
+
|
| 10 |
+
logger = logging.getLogger(__name__)
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class BaseAgent(ABC):
|
| 14 |
+
"""Abstract base class for all hospital agents."""
|
| 15 |
+
|
| 16 |
+
agent_type: str = "base"
|
| 17 |
+
display_name: str = "Agent"
|
| 18 |
+
|
| 19 |
+
def __init__(self):
|
| 20 |
+
self.conversation_history: list[dict] = []
|
| 21 |
+
self.api_key = os.environ.get("ANTHROPIC_API_KEY")
|
| 22 |
+
self.client: Optional[anthropic.Anthropic] = None
|
| 23 |
+
if self.api_key and self.api_key != "sk-ant-your-key-here":
|
| 24 |
+
try:
|
| 25 |
+
self.client = anthropic.Anthropic(api_key=self.api_key)
|
| 26 |
+
except Exception as e:
|
| 27 |
+
logger.warning(f"{self.display_name} client init failed: {e}")
|
| 28 |
+
|
| 29 |
+
@abstractmethod
|
| 30 |
+
def get_system_prompt(self, case_context: dict) -> str:
|
| 31 |
+
"""Return the system prompt for this agent given case context."""
|
| 32 |
+
|
| 33 |
+
@abstractmethod
|
| 34 |
+
def get_fallback_response(self, message: str, case_context: dict) -> str:
|
| 35 |
+
"""Return a fallback response when the API is unavailable."""
|
| 36 |
+
|
| 37 |
+
def respond(self, message: str, case_context: dict) -> dict:
|
| 38 |
+
"""Generate a response from this agent.
|
| 39 |
+
|
| 40 |
+
Returns dict with: agent_type, display_name, content, metadata
|
| 41 |
+
"""
|
| 42 |
+
self.conversation_history.append({"role": "user", "content": message})
|
| 43 |
+
|
| 44 |
+
content = ""
|
| 45 |
+
thinking = ""
|
| 46 |
+
|
| 47 |
+
if self.client:
|
| 48 |
+
result = self._respond_with_claude(message, case_context)
|
| 49 |
+
if result:
|
| 50 |
+
content, thinking = result
|
| 51 |
+
|
| 52 |
+
if not content:
|
| 53 |
+
content = self.get_fallback_response(message, case_context)
|
| 54 |
+
|
| 55 |
+
self.conversation_history.append({"role": "assistant", "content": content})
|
| 56 |
+
|
| 57 |
+
response = {
|
| 58 |
+
"agent_type": self.agent_type,
|
| 59 |
+
"display_name": self.display_name,
|
| 60 |
+
"content": content,
|
| 61 |
+
}
|
| 62 |
+
if thinking:
|
| 63 |
+
response["thinking"] = thinking
|
| 64 |
+
return response
|
| 65 |
+
|
| 66 |
+
def _respond_with_claude(
|
| 67 |
+
self, message: str, case_context: dict
|
| 68 |
+
) -> Optional[tuple[str, str]]:
|
| 69 |
+
"""Call Claude with extended thinking. Returns (content, thinking) or None."""
|
| 70 |
+
system = self.get_system_prompt(case_context)
|
| 71 |
+
messages = self.conversation_history.copy()
|
| 72 |
+
|
| 73 |
+
try:
|
| 74 |
+
response = self.client.messages.create(
|
| 75 |
+
model="claude-opus-4-6",
|
| 76 |
+
max_tokens=16000,
|
| 77 |
+
temperature=1, # required for extended thinking
|
| 78 |
+
thinking={
|
| 79 |
+
"type": "enabled",
|
| 80 |
+
"budget_tokens": 10000,
|
| 81 |
+
},
|
| 82 |
+
system=system,
|
| 83 |
+
messages=messages,
|
| 84 |
+
)
|
| 85 |
+
|
| 86 |
+
content = ""
|
| 87 |
+
thinking = ""
|
| 88 |
+
for block in response.content:
|
| 89 |
+
if block.type == "thinking":
|
| 90 |
+
thinking = block.thinking
|
| 91 |
+
elif block.type == "text":
|
| 92 |
+
content = block.text.strip()
|
| 93 |
+
|
| 94 |
+
return (content, thinking) if content else None
|
| 95 |
+
except Exception as e:
|
| 96 |
+
logger.error(f"{self.display_name} Claude API error: {e}")
|
| 97 |
+
return None
|
| 98 |
+
|
| 99 |
+
def reset(self):
|
| 100 |
+
"""Reset conversation history for a new case."""
|
| 101 |
+
self.conversation_history = []
|
|
@@ -0,0 +1,179 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Nurse agent — provides clinical observations, vitals, and urgency alerts."""
|
| 2 |
+
|
| 3 |
+
from app.core.agents.base_agent import BaseAgent
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
NURSE_SYSTEM_PROMPT = """You are an experienced ward nurse in an Indian government hospital assisting a medical student with a patient case.
|
| 7 |
+
|
| 8 |
+
CRITICAL RULES:
|
| 9 |
+
1. You are professional, efficient, and supportive of the student.
|
| 10 |
+
2. You provide clinical observations, vital sign readings, and nursing assessments.
|
| 11 |
+
3. You alert the student about urgent/critical findings using clear urgency levels.
|
| 12 |
+
4. You do NOT diagnose — you report observations and let the doctor decide.
|
| 13 |
+
5. You use proper medical terminology (you're a trained nurse).
|
| 14 |
+
6. You may gently prompt the student if they're missing something obvious.
|
| 15 |
+
7. Keep responses concise and clinical — 2-4 sentences.
|
| 16 |
+
8. You speak in English with occasional Hindi/medical terms naturally used in Indian hospitals.
|
| 17 |
+
|
| 18 |
+
CASE DETAILS:
|
| 19 |
+
- Patient: {age}y {gender} from {location}
|
| 20 |
+
- Chief complaint: {chief_complaint}
|
| 21 |
+
- Vitals: BP {bp}, HR {hr}, RR {rr}, Temp {temp}°C, SpO2 {spo2}%
|
| 22 |
+
- Physical exam findings: {physical_exam}
|
| 23 |
+
- Lab findings: {labs}
|
| 24 |
+
|
| 25 |
+
URGENCY PROTOCOL:
|
| 26 |
+
- routine: Normal observations. "Doctor, patient is stable. Vitals are within normal range."
|
| 27 |
+
- attention: Something needs noting. "Doctor, I'd like to draw your attention to..."
|
| 28 |
+
- urgent: Abnormal finding needs action. "Doctor, the patient's SpO2 is dropping. Should we start O2?"
|
| 29 |
+
- critical: Immediate intervention needed. "Doctor! Patient's vitals are deteriorating — we need to act NOW!"
|
| 30 |
+
|
| 31 |
+
Assess urgency based on the vitals and case severity. Respond ONLY as the nurse."""
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class NurseAgent(BaseAgent):
|
| 35 |
+
"""Nurse agent that provides clinical observations and alerts."""
|
| 36 |
+
|
| 37 |
+
agent_type = "nurse"
|
| 38 |
+
display_name = "Nurse Priya"
|
| 39 |
+
|
| 40 |
+
def __init__(self):
|
| 41 |
+
super().__init__()
|
| 42 |
+
self.urgency_level = "routine"
|
| 43 |
+
self.case_info: dict = {}
|
| 44 |
+
|
| 45 |
+
def configure(self, case_data: dict):
|
| 46 |
+
"""Configure nurse with case-specific data."""
|
| 47 |
+
vitals = case_data.get("vital_signs", {})
|
| 48 |
+
self.case_info = {
|
| 49 |
+
"age": case_data.get("patient", {}).get("age", 45),
|
| 50 |
+
"gender": case_data.get("patient", {}).get("gender", "Male"),
|
| 51 |
+
"location": case_data.get("patient", {}).get("location", "Delhi"),
|
| 52 |
+
"chief_complaint": case_data.get("chief_complaint", ""),
|
| 53 |
+
"bp": vitals.get("bp", "120/80"),
|
| 54 |
+
"hr": vitals.get("hr", 80),
|
| 55 |
+
"rr": vitals.get("rr", 16),
|
| 56 |
+
"temp": vitals.get("temp", 37.0),
|
| 57 |
+
"spo2": vitals.get("spo2", 98),
|
| 58 |
+
"physical_exam": "",
|
| 59 |
+
"labs": "",
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
# Extract exam and lab info from stages
|
| 63 |
+
for stage in case_data.get("stages", []):
|
| 64 |
+
if stage.get("stage") == "physical_exam":
|
| 65 |
+
self.case_info["physical_exam"] = stage.get("info", "")
|
| 66 |
+
elif stage.get("stage") == "labs":
|
| 67 |
+
self.case_info["labs"] = stage.get("info", "")
|
| 68 |
+
|
| 69 |
+
self._set_urgency_level(vitals, case_data.get("difficulty", "intermediate"))
|
| 70 |
+
|
| 71 |
+
def _set_urgency_level(self, vitals: dict, difficulty: str):
|
| 72 |
+
"""Determine urgency from vitals."""
|
| 73 |
+
hr = vitals.get("hr", 80)
|
| 74 |
+
spo2 = vitals.get("spo2", 98)
|
| 75 |
+
rr = vitals.get("rr", 16)
|
| 76 |
+
temp = vitals.get("temp", 37.0)
|
| 77 |
+
|
| 78 |
+
if spo2 < 88 or hr > 140 or rr > 35 or temp > 40:
|
| 79 |
+
self.urgency_level = "critical"
|
| 80 |
+
elif spo2 < 92 or hr > 120 or rr > 28 or temp > 39:
|
| 81 |
+
self.urgency_level = "urgent"
|
| 82 |
+
elif spo2 < 95 or hr > 100 or rr > 22 or temp > 38:
|
| 83 |
+
self.urgency_level = "attention"
|
| 84 |
+
else:
|
| 85 |
+
self.urgency_level = "routine"
|
| 86 |
+
|
| 87 |
+
if difficulty == "advanced" and self.urgency_level == "routine":
|
| 88 |
+
self.urgency_level = "attention"
|
| 89 |
+
|
| 90 |
+
def get_system_prompt(self, case_context: dict) -> str:
|
| 91 |
+
info = {**self.case_info, **case_context}
|
| 92 |
+
return NURSE_SYSTEM_PROMPT.format(
|
| 93 |
+
age=info.get("age", 45),
|
| 94 |
+
gender=info.get("gender", "Male"),
|
| 95 |
+
location=info.get("location", "Delhi"),
|
| 96 |
+
chief_complaint=info.get("chief_complaint", "unknown"),
|
| 97 |
+
bp=info.get("bp", "120/80"),
|
| 98 |
+
hr=info.get("hr", 80),
|
| 99 |
+
rr=info.get("rr", 16),
|
| 100 |
+
temp=info.get("temp", 37.0),
|
| 101 |
+
spo2=info.get("spo2", 98),
|
| 102 |
+
physical_exam=info.get("physical_exam", "Not yet examined"),
|
| 103 |
+
labs=info.get("labs", "Pending"),
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
def get_fallback_response(self, message: str, case_context: dict) -> str:
|
| 107 |
+
msg = message.lower()
|
| 108 |
+
vitals = self.case_info
|
| 109 |
+
|
| 110 |
+
if self.urgency_level == "critical":
|
| 111 |
+
return (
|
| 112 |
+
f"Doctor! Patient's vitals are concerning — "
|
| 113 |
+
f"HR {vitals['hr']}, SpO2 {vitals['spo2']}%, RR {vitals['rr']}. "
|
| 114 |
+
f"We need to act quickly. What do you want me to start?"
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
if any(w in msg for w in ["vitals", "vital signs", "bp", "pulse"]):
|
| 118 |
+
return (
|
| 119 |
+
f"Doctor, latest vitals: BP {vitals['bp']}, HR {vitals['hr']} bpm, "
|
| 120 |
+
f"RR {vitals['rr']}/min, Temp {vitals['temp']}°C, SpO2 {vitals['spo2']}%. "
|
| 121 |
+
f"{'I notice the SpO2 is on the lower side.' if vitals['spo2'] < 95 else 'Vitals are noted.'}"
|
| 122 |
+
)
|
| 123 |
+
|
| 124 |
+
if any(w in msg for w in ["oxygen", "o2", "spo2"]):
|
| 125 |
+
return (
|
| 126 |
+
f"SpO2 is currently {vitals['spo2']}%. "
|
| 127 |
+
f"{'Shall I start supplemental O2 via nasal cannula?' if vitals['spo2'] < 94 else 'Saturation is being maintained.'}"
|
| 128 |
+
)
|
| 129 |
+
|
| 130 |
+
if any(w in msg for w in ["iv", "line", "access", "cannula"]):
|
| 131 |
+
return "Doctor, shall I get an IV line set up? I have 18G and 20G cannulas ready. Which access do you want?"
|
| 132 |
+
|
| 133 |
+
if any(w in msg for w in ["monitor", "ecg", "cardiac"]):
|
| 134 |
+
return "I'll put the patient on continuous cardiac monitoring right away. ECG machine is on its way."
|
| 135 |
+
|
| 136 |
+
if any(w in msg for w in ["lab", "blood", "test", "investigation"]):
|
| 137 |
+
return "Doctor, I can send the samples to lab right away. What tests do you want me to order — CBC, RFT, LFT, or anything specific?"
|
| 138 |
+
|
| 139 |
+
if self.urgency_level == "urgent":
|
| 140 |
+
return (
|
| 141 |
+
f"Doctor, just to update you — the patient's HR is {vitals['hr']} and SpO2 {vitals['spo2']}%. "
|
| 142 |
+
f"I'd recommend we keep a close watch. Want me to prepare any emergency medications?"
|
| 143 |
+
)
|
| 144 |
+
|
| 145 |
+
return "Yes doctor, I'm here. What do you need me to do for the patient?"
|
| 146 |
+
|
| 147 |
+
def get_initial_report(self) -> dict:
|
| 148 |
+
"""Generate nurse's initial patient handoff report."""
|
| 149 |
+
vitals = self.case_info
|
| 150 |
+
alerts = []
|
| 151 |
+
|
| 152 |
+
if vitals["spo2"] < 94:
|
| 153 |
+
alerts.append(f"SpO2 is low at {vitals['spo2']}%")
|
| 154 |
+
if vitals["hr"] > 110:
|
| 155 |
+
alerts.append(f"tachycardic at {vitals['hr']} bpm")
|
| 156 |
+
if vitals["rr"] > 24:
|
| 157 |
+
alerts.append(f"tachypneic with RR {vitals['rr']}")
|
| 158 |
+
if vitals["temp"] > 38.5:
|
| 159 |
+
alerts.append(f"febrile at {vitals['temp']}°C")
|
| 160 |
+
|
| 161 |
+
base = (
|
| 162 |
+
f"Doctor, we have a {vitals['age']}-year-old {vitals['gender'].lower()} patient "
|
| 163 |
+
f"presenting with {vitals['chief_complaint'].lower()}. "
|
| 164 |
+
f"Vitals — BP {vitals['bp']}, HR {vitals['hr']}, RR {vitals['rr']}, "
|
| 165 |
+
f"Temp {vitals['temp']}°C, SpO2 {vitals['spo2']}%."
|
| 166 |
+
)
|
| 167 |
+
|
| 168 |
+
if alerts:
|
| 169 |
+
base += f" Please note: patient is {', '.join(alerts)}."
|
| 170 |
+
|
| 171 |
+
if self.urgency_level in ("urgent", "critical"):
|
| 172 |
+
base += " I'd recommend we prioritize this case."
|
| 173 |
+
|
| 174 |
+
return {
|
| 175 |
+
"agent_type": self.agent_type,
|
| 176 |
+
"display_name": self.display_name,
|
| 177 |
+
"content": base,
|
| 178 |
+
"urgency_level": self.urgency_level,
|
| 179 |
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Agent orchestrator — coordinates patient, nurse, and senior doctor agents."""
|
| 2 |
+
|
| 3 |
+
import logging
|
| 4 |
+
import uuid
|
| 5 |
+
from typing import Optional
|
| 6 |
+
|
| 7 |
+
from app.core.agents.patient_agent import PatientAgent
|
| 8 |
+
from app.core.agents.nurse_agent import NurseAgent
|
| 9 |
+
from app.core.agents.senior_agent import SeniorDoctorAgent
|
| 10 |
+
|
| 11 |
+
logger = logging.getLogger(__name__)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class AgentSession:
|
| 15 |
+
"""Holds the agent instances and state for a single case session."""
|
| 16 |
+
|
| 17 |
+
def __init__(self, session_id: str, case_data: dict):
|
| 18 |
+
self.session_id = session_id
|
| 19 |
+
self.case_data = case_data
|
| 20 |
+
|
| 21 |
+
# Initialize agents
|
| 22 |
+
self.patient = PatientAgent()
|
| 23 |
+
self.nurse = NurseAgent()
|
| 24 |
+
self.senior = SeniorDoctorAgent()
|
| 25 |
+
|
| 26 |
+
# Configure with case data
|
| 27 |
+
self.patient.configure(case_data)
|
| 28 |
+
self.nurse.configure(case_data)
|
| 29 |
+
self.senior.configure(case_data)
|
| 30 |
+
|
| 31 |
+
# Track conversation state
|
| 32 |
+
self.message_history: list[dict] = []
|
| 33 |
+
self.stages_revealed: set[int] = set()
|
| 34 |
+
self.diagnosis_submitted = False
|
| 35 |
+
|
| 36 |
+
def get_vitals(self) -> dict:
|
| 37 |
+
"""Return current vital signs with nurse's urgency assessment."""
|
| 38 |
+
vitals = self.case_data.get("vital_signs", {})
|
| 39 |
+
return {
|
| 40 |
+
"vitals": vitals,
|
| 41 |
+
"urgency_level": self.nurse.urgency_level,
|
| 42 |
+
"patient_distress": self.patient.distress_level,
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
class AgentOrchestrator:
|
| 47 |
+
"""Coordinates all hospital agents for multi-agent case interactions."""
|
| 48 |
+
|
| 49 |
+
def __init__(self):
|
| 50 |
+
self.sessions: dict[str, AgentSession] = {}
|
| 51 |
+
|
| 52 |
+
def initialize_session(self, case_data: dict) -> dict:
|
| 53 |
+
"""Create a new agent session for a case.
|
| 54 |
+
|
| 55 |
+
Returns initial messages from all agents (patient greeting, nurse report,
|
| 56 |
+
senior guidance).
|
| 57 |
+
"""
|
| 58 |
+
session_id = str(uuid.uuid4())[:8]
|
| 59 |
+
session = AgentSession(session_id, case_data)
|
| 60 |
+
self.sessions[session_id] = session
|
| 61 |
+
|
| 62 |
+
# Gather initial messages from all agents
|
| 63 |
+
initial_messages = []
|
| 64 |
+
|
| 65 |
+
# 1. Nurse gives initial report
|
| 66 |
+
nurse_report = session.nurse.get_initial_report()
|
| 67 |
+
initial_messages.append(nurse_report)
|
| 68 |
+
|
| 69 |
+
# 2. Patient greets
|
| 70 |
+
patient_greeting = session.patient.get_initial_greeting()
|
| 71 |
+
initial_messages.append(patient_greeting)
|
| 72 |
+
|
| 73 |
+
# 3. Senior doctor gives initial guidance
|
| 74 |
+
senior_guidance = session.senior.get_initial_guidance()
|
| 75 |
+
initial_messages.append(senior_guidance)
|
| 76 |
+
|
| 77 |
+
# Store in history
|
| 78 |
+
session.message_history.extend(initial_messages)
|
| 79 |
+
|
| 80 |
+
return {
|
| 81 |
+
"session_id": session_id,
|
| 82 |
+
"messages": initial_messages,
|
| 83 |
+
"vitals": session.get_vitals(),
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
def process_action(
|
| 87 |
+
self,
|
| 88 |
+
session_id: str,
|
| 89 |
+
action_type: str,
|
| 90 |
+
student_input: Optional[str] = None,
|
| 91 |
+
) -> dict:
|
| 92 |
+
"""Process a student action and get multi-agent responses.
|
| 93 |
+
|
| 94 |
+
action_type can be:
|
| 95 |
+
- 'talk_to_patient': Student talks to the patient
|
| 96 |
+
- 'ask_nurse': Student asks the nurse something
|
| 97 |
+
- 'consult_senior': Student discusses with senior doctor
|
| 98 |
+
- 'examine_patient': Student performs examination (triggers nurse + patient)
|
| 99 |
+
- 'order_investigation': Student orders tests (triggers nurse response)
|
| 100 |
+
"""
|
| 101 |
+
session = self.sessions.get(session_id)
|
| 102 |
+
if not session:
|
| 103 |
+
return {"error": "Session not found", "messages": []}
|
| 104 |
+
|
| 105 |
+
case_context = {
|
| 106 |
+
"chief_complaint": session.case_data.get("chief_complaint", ""),
|
| 107 |
+
"specialty": session.case_data.get("specialty", ""),
|
| 108 |
+
"difficulty": session.case_data.get("difficulty", ""),
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
messages = []
|
| 112 |
+
|
| 113 |
+
if action_type == "talk_to_patient":
|
| 114 |
+
# Student talks to patient, patient responds
|
| 115 |
+
patient_response = session.patient.respond(
|
| 116 |
+
student_input or "Tell me about your problem",
|
| 117 |
+
case_context,
|
| 118 |
+
)
|
| 119 |
+
messages.append(patient_response)
|
| 120 |
+
|
| 121 |
+
elif action_type == "ask_nurse":
|
| 122 |
+
# Student asks nurse
|
| 123 |
+
nurse_response = session.nurse.respond(
|
| 124 |
+
student_input or "What are the current vitals?",
|
| 125 |
+
case_context,
|
| 126 |
+
)
|
| 127 |
+
messages.append(nurse_response)
|
| 128 |
+
|
| 129 |
+
elif action_type == "consult_senior":
|
| 130 |
+
# Student discusses with senior doctor
|
| 131 |
+
senior_response = session.senior.respond(
|
| 132 |
+
student_input or "What do you think about this case?",
|
| 133 |
+
case_context,
|
| 134 |
+
)
|
| 135 |
+
messages.append(senior_response)
|
| 136 |
+
|
| 137 |
+
elif action_type == "examine_patient":
|
| 138 |
+
# Physical examination — patient reacts, nurse assists
|
| 139 |
+
patient_response = session.patient.respond(
|
| 140 |
+
"The doctor is examining you now. How do you feel?",
|
| 141 |
+
case_context,
|
| 142 |
+
)
|
| 143 |
+
messages.append(patient_response)
|
| 144 |
+
|
| 145 |
+
nurse_response = session.nurse.respond(
|
| 146 |
+
f"Student is performing physical examination. Key findings to report: {student_input or 'general exam'}",
|
| 147 |
+
case_context,
|
| 148 |
+
)
|
| 149 |
+
messages.append(nurse_response)
|
| 150 |
+
|
| 151 |
+
elif action_type == "order_investigation":
|
| 152 |
+
# Order tests — nurse acknowledges and reports
|
| 153 |
+
nurse_response = session.nurse.respond(
|
| 154 |
+
f"Student has ordered: {student_input or 'routine investigations'}. Report the findings.",
|
| 155 |
+
case_context,
|
| 156 |
+
)
|
| 157 |
+
messages.append(nurse_response)
|
| 158 |
+
|
| 159 |
+
else:
|
| 160 |
+
# Default: route to senior doctor
|
| 161 |
+
senior_response = session.senior.respond(
|
| 162 |
+
student_input or "I need guidance",
|
| 163 |
+
case_context,
|
| 164 |
+
)
|
| 165 |
+
messages.append(senior_response)
|
| 166 |
+
|
| 167 |
+
# Store in history
|
| 168 |
+
if student_input:
|
| 169 |
+
session.message_history.append({
|
| 170 |
+
"agent_type": "student",
|
| 171 |
+
"display_name": "You",
|
| 172 |
+
"content": student_input,
|
| 173 |
+
})
|
| 174 |
+
session.message_history.extend(messages)
|
| 175 |
+
|
| 176 |
+
return {
|
| 177 |
+
"session_id": session_id,
|
| 178 |
+
"messages": messages,
|
| 179 |
+
"vitals": session.get_vitals(),
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
def get_session_vitals(self, session_id: str) -> Optional[dict]:
|
| 183 |
+
"""Get current vitals for a session."""
|
| 184 |
+
session = self.sessions.get(session_id)
|
| 185 |
+
if not session:
|
| 186 |
+
return None
|
| 187 |
+
return session.get_vitals()
|
| 188 |
+
|
| 189 |
+
def get_session(self, session_id: str) -> Optional[AgentSession]:
|
| 190 |
+
"""Get an agent session by ID."""
|
| 191 |
+
return self.sessions.get(session_id)
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
# Singleton orchestrator shared across the app
|
| 195 |
+
orchestrator = AgentOrchestrator()
|
|
@@ -0,0 +1,148 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Patient agent — speaks in Hindi/English mix with realistic distress levels."""
|
| 2 |
+
|
| 3 |
+
from app.core.agents.base_agent import BaseAgent
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
PATIENT_SYSTEM_PROMPT = """You are a patient in an Indian hospital. You are being examined by a medical student.
|
| 7 |
+
|
| 8 |
+
CRITICAL RULES:
|
| 9 |
+
1. You speak in Hindi-English mix (Hinglish) naturally — like a real Indian patient would.
|
| 10 |
+
Examples: "Doctor sahab, mujhe bahut zyada dard ho raha hai chest mein", "Haan doctor, breathing mein problem hai"
|
| 11 |
+
2. You do NOT know medical terminology. Describe symptoms in simple, lay terms.
|
| 12 |
+
3. You have a specific distress level based on your condition severity.
|
| 13 |
+
4. You may be anxious, scared, in pain, or confused — act accordingly.
|
| 14 |
+
5. You can only share information you would realistically know (symptoms, history, lifestyle).
|
| 15 |
+
6. You do NOT know your own diagnosis. You are the patient, not the doctor.
|
| 16 |
+
7. If asked about something you don't know (like lab values), say "Yeh toh doctor aapko pata hoga"
|
| 17 |
+
8. Keep responses realistic — 1-3 sentences typically, more if telling your history.
|
| 18 |
+
|
| 19 |
+
PATIENT DETAILS:
|
| 20 |
+
- Age: {age}, Gender: {gender}, Location: {location}
|
| 21 |
+
- Chief complaint: {chief_complaint}
|
| 22 |
+
- Presentation: {presentation}
|
| 23 |
+
- History: {history}
|
| 24 |
+
- Distress level: {distress_level} (low=calm, moderate=worried, high=distressed/crying, critical=severe pain/panic)
|
| 25 |
+
|
| 26 |
+
DISTRESS BEHAVIOR:
|
| 27 |
+
- low: Calm, answers questions clearly. "Haan doctor, yeh problem 2 hafte se hai."
|
| 28 |
+
- moderate: Worried but cooperative. "Doctor, mujhe dar lag raha hai... kuch serious toh nahi?"
|
| 29 |
+
- high: In visible distress, may cry or groan. "Aaahhh... bahut dard ho raha hai doctor... please kuch karo!"
|
| 30 |
+
- critical: Severe pain/panic, short responses. "Doctor... saans... nahi aa rahi... please..."
|
| 31 |
+
|
| 32 |
+
Respond ONLY as the patient. Stay in character completely."""
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class PatientAgent(BaseAgent):
|
| 36 |
+
"""Patient agent that speaks in Hinglish with realistic distress."""
|
| 37 |
+
|
| 38 |
+
agent_type = "patient"
|
| 39 |
+
display_name = "Patient"
|
| 40 |
+
|
| 41 |
+
def __init__(self):
|
| 42 |
+
super().__init__()
|
| 43 |
+
self.distress_level = "moderate"
|
| 44 |
+
self.patient_info: dict = {}
|
| 45 |
+
|
| 46 |
+
def configure(self, case_data: dict):
|
| 47 |
+
"""Configure patient with case-specific data."""
|
| 48 |
+
self.patient_info = {
|
| 49 |
+
"age": case_data.get("patient", {}).get("age", 45),
|
| 50 |
+
"gender": case_data.get("patient", {}).get("gender", "Male"),
|
| 51 |
+
"location": case_data.get("patient", {}).get("location", "Delhi"),
|
| 52 |
+
"chief_complaint": case_data.get("chief_complaint", ""),
|
| 53 |
+
"presentation": case_data.get("initial_presentation", ""),
|
| 54 |
+
"history": "",
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
# Extract history from stages
|
| 58 |
+
for stage in case_data.get("stages", []):
|
| 59 |
+
if stage.get("stage") == "history":
|
| 60 |
+
self.patient_info["history"] = stage.get("info", "")
|
| 61 |
+
break
|
| 62 |
+
|
| 63 |
+
# Set distress based on vital signs and difficulty
|
| 64 |
+
self._set_distress_level(case_data)
|
| 65 |
+
|
| 66 |
+
def _set_distress_level(self, case_data: dict):
|
| 67 |
+
"""Determine distress level from vitals and difficulty."""
|
| 68 |
+
difficulty = case_data.get("difficulty", "intermediate")
|
| 69 |
+
vitals = case_data.get("vital_signs", {})
|
| 70 |
+
|
| 71 |
+
hr = vitals.get("hr", 80)
|
| 72 |
+
spo2 = vitals.get("spo2", 98)
|
| 73 |
+
rr = vitals.get("rr", 16)
|
| 74 |
+
|
| 75 |
+
if difficulty == "advanced" or spo2 < 90 or hr > 130 or rr > 30:
|
| 76 |
+
self.distress_level = "critical"
|
| 77 |
+
elif difficulty == "intermediate" or spo2 < 94 or hr > 110 or rr > 24:
|
| 78 |
+
self.distress_level = "high"
|
| 79 |
+
elif hr > 100 or rr > 20:
|
| 80 |
+
self.distress_level = "moderate"
|
| 81 |
+
else:
|
| 82 |
+
self.distress_level = "low"
|
| 83 |
+
|
| 84 |
+
def get_system_prompt(self, case_context: dict) -> str:
|
| 85 |
+
info = {**self.patient_info, **case_context}
|
| 86 |
+
info["distress_level"] = self.distress_level
|
| 87 |
+
return PATIENT_SYSTEM_PROMPT.format(
|
| 88 |
+
age=info.get("age", 45),
|
| 89 |
+
gender=info.get("gender", "Male"),
|
| 90 |
+
location=info.get("location", "Delhi"),
|
| 91 |
+
chief_complaint=info.get("chief_complaint", "unknown"),
|
| 92 |
+
presentation=info.get("presentation", ""),
|
| 93 |
+
history=info.get("history", ""),
|
| 94 |
+
distress_level=self.distress_level,
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
def get_fallback_response(self, message: str, case_context: dict) -> str:
|
| 98 |
+
msg = message.lower()
|
| 99 |
+
|
| 100 |
+
if self.distress_level == "critical":
|
| 101 |
+
if any(w in msg for w in ["pain", "dard", "hurt"]):
|
| 102 |
+
return "Doctor... bahut... zyada dard... please kuch karo... saans nahi aa rahi..."
|
| 103 |
+
return "Doctor... please... jaldi..."
|
| 104 |
+
|
| 105 |
+
if self.distress_level == "high":
|
| 106 |
+
if any(w in msg for w in ["how long", "kab se", "when"]):
|
| 107 |
+
return "Doctor sahab, yeh 2-3 din se bahut zyada ho gaya hai... pehle thoda thoda hota tha, ab toh sehen nahi hota!"
|
| 108 |
+
if any(w in msg for w in ["pain", "dard", "hurt"]):
|
| 109 |
+
return "Haan doctor, bahut dard hai... yahan pe... aaahhh... please dawai de do!"
|
| 110 |
+
return "Doctor, mujhe bahut takleef ho rahi hai... kuch serious toh nahi na?"
|
| 111 |
+
|
| 112 |
+
if self.distress_level == "moderate":
|
| 113 |
+
if any(w in msg for w in ["history", "pehle", "before", "past"]):
|
| 114 |
+
return "Doctor, pehle aisa kabhi nahi hua tha. Bas 1-2 baar thoda sa hua tha lekin itna nahi tha."
|
| 115 |
+
if any(w in msg for w in ["medicine", "dawai", "medication"]):
|
| 116 |
+
return "Haan doctor, mein BP ki dawai leta hoon... naam yaad nahi aa raha... chhoti wali goli hai."
|
| 117 |
+
if any(w in msg for w in ["family", "gharwale", "parents"]):
|
| 118 |
+
return "Ji doctor, mere father ko bhi sugar tha... aur unko heart ka bhi problem tha."
|
| 119 |
+
return "Ji doctor, bataiye kya karna hai? Mujhe thoda dar lag raha hai."
|
| 120 |
+
|
| 121 |
+
# low distress
|
| 122 |
+
if any(w in msg for w in ["how", "kaise"]):
|
| 123 |
+
return "Doctor sahab, yeh problem thode dinon se hai. Pehle chalta tha lekin ab zyada ho gaya."
|
| 124 |
+
if any(w in msg for w in ["smoke", "drink", "sharab", "cigarette"]):
|
| 125 |
+
return "Nahi doctor, mein na pita hoon na cigarette peeta hoon. Bas kabhi kabhi chai peeta hoon."
|
| 126 |
+
return f"Ji doctor, main {self.patient_info.get('chief_complaint', 'problem').lower()} ki wajah se aaya hoon. Aap bataiye kya karna chahiye?"
|
| 127 |
+
|
| 128 |
+
def get_initial_greeting(self) -> dict:
|
| 129 |
+
"""Generate the patient's initial complaint on arrival."""
|
| 130 |
+
cc = self.patient_info.get("chief_complaint", "problem")
|
| 131 |
+
age = self.patient_info.get("age", 45)
|
| 132 |
+
gender = self.patient_info.get("gender", "Male")
|
| 133 |
+
honorific = "beti" if gender == "Female" and age < 30 else "bhai" if gender == "Male" and age < 40 else "uncle" if gender == "Male" else "aunty"
|
| 134 |
+
|
| 135 |
+
greetings = {
|
| 136 |
+
"critical": f"Doctor sahab... please... {cc.lower()}... bahut... zyada hai... saans nahi aa rahi...",
|
| 137 |
+
"high": f"Doctor sahab, namaste... mujhe bahut zyada problem ho rahi hai... {cc.lower()}... please jaldi check karo!",
|
| 138 |
+
"moderate": f"Namaste doctor sahab. Mein aapke paas aaya hoon kyunki mujhe {cc.lower()} ki problem hai. 2-3 din se ho raha hai, ab zyada ho gaya.",
|
| 139 |
+
"low": f"Namaste doctor sahab. Mujhe {cc.lower()} ki thodi si problem hai, isliye aaya hoon. Dekhiye na please.",
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
content = greetings.get(self.distress_level, greetings["moderate"])
|
| 143 |
+
return {
|
| 144 |
+
"agent_type": self.agent_type,
|
| 145 |
+
"display_name": self.display_name,
|
| 146 |
+
"content": content,
|
| 147 |
+
"distress_level": self.distress_level,
|
| 148 |
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Senior doctor agent — Socratic teaching mentor who guides without giving answers."""
|
| 2 |
+
|
| 3 |
+
from app.core.agents.base_agent import BaseAgent
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
SENIOR_SYSTEM_PROMPT = """You are a senior consultant doctor (professor) in an Indian teaching hospital. You are supervising a final-year MBBS student who is handling a case.
|
| 7 |
+
|
| 8 |
+
CRITICAL RULES:
|
| 9 |
+
1. Use the SOCRATIC METHOD — ask probing questions, never give the answer directly.
|
| 10 |
+
2. Guide the student's clinical reasoning through structured questioning.
|
| 11 |
+
3. You are supportive but academically rigorous.
|
| 12 |
+
4. You reference Indian clinical guidelines (ICMR, API, NHM) when relevant.
|
| 13 |
+
5. You know the correct diagnosis but must NOT reveal it unless the student has already diagnosed correctly.
|
| 14 |
+
6. If the student is on the wrong track, gently redirect with questions.
|
| 15 |
+
7. If the student is stuck, provide progressive hints (never the answer).
|
| 16 |
+
8. Occasionally reference exam relevance: "This is a common NEET-PG question pattern."
|
| 17 |
+
9. Keep responses concise — 2-4 sentences with 1-2 Socratic questions.
|
| 18 |
+
10. You speak in professional English with occasional Hindi phrases natural in Indian hospitals.
|
| 19 |
+
|
| 20 |
+
CASE DETAILS:
|
| 21 |
+
- Patient: {age}y {gender}, {chief_complaint}
|
| 22 |
+
- Specialty: {specialty}
|
| 23 |
+
- Difficulty: {difficulty}
|
| 24 |
+
- Correct diagnosis: {diagnosis}
|
| 25 |
+
- Key differentials: {differentials}
|
| 26 |
+
- Critical learning points: {learning_points}
|
| 27 |
+
|
| 28 |
+
TEACHING APPROACH:
|
| 29 |
+
1. If student hasn't started: "Let's approach this systematically. What are your initial impressions?"
|
| 30 |
+
2. If student has a hypothesis: Challenge it. "Good thinking, but what else could present similarly?"
|
| 31 |
+
3. If student is stuck: Hint progressively. "Think about the vital signs... what pattern do you see?"
|
| 32 |
+
4. If student is close: Encourage. "You're on the right track. Now, what investigation would confirm?"
|
| 33 |
+
5. If student is wrong: Redirect gently. "That's a reasonable thought, but consider — would that explain ALL the findings?"
|
| 34 |
+
6. After diagnosis: Teach the deeper lesson. "Excellent. Now for the exam — what's the pathophysiology here?"
|
| 35 |
+
|
| 36 |
+
Respond ONLY as the senior doctor. Be a great teacher."""
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
class SeniorDoctorAgent(BaseAgent):
|
| 40 |
+
"""Senior doctor agent that teaches using Socratic method."""
|
| 41 |
+
|
| 42 |
+
agent_type = "senior_doctor"
|
| 43 |
+
display_name = "Dr. Sharma"
|
| 44 |
+
|
| 45 |
+
def __init__(self):
|
| 46 |
+
super().__init__()
|
| 47 |
+
self.case_info: dict = {}
|
| 48 |
+
self.hints_given = 0
|
| 49 |
+
self.student_on_track = False
|
| 50 |
+
|
| 51 |
+
def configure(self, case_data: dict):
|
| 52 |
+
"""Configure senior doctor with full case knowledge."""
|
| 53 |
+
self.case_info = {
|
| 54 |
+
"age": case_data.get("patient", {}).get("age", 45),
|
| 55 |
+
"gender": case_data.get("patient", {}).get("gender", "Male"),
|
| 56 |
+
"chief_complaint": case_data.get("chief_complaint", ""),
|
| 57 |
+
"specialty": case_data.get("specialty", ""),
|
| 58 |
+
"difficulty": case_data.get("difficulty", "intermediate"),
|
| 59 |
+
"diagnosis": case_data.get("diagnosis", ""),
|
| 60 |
+
"differentials": ", ".join(case_data.get("differentials", [])[:5]),
|
| 61 |
+
"learning_points": "; ".join(case_data.get("learning_points", [])[:3]),
|
| 62 |
+
}
|
| 63 |
+
self.hints_given = 0
|
| 64 |
+
self.student_on_track = False
|
| 65 |
+
|
| 66 |
+
def get_system_prompt(self, case_context: dict) -> str:
|
| 67 |
+
info = {**self.case_info, **case_context}
|
| 68 |
+
return SENIOR_SYSTEM_PROMPT.format(
|
| 69 |
+
age=info.get("age", 45),
|
| 70 |
+
gender=info.get("gender", "Male"),
|
| 71 |
+
chief_complaint=info.get("chief_complaint", "unknown"),
|
| 72 |
+
specialty=info.get("specialty", "general"),
|
| 73 |
+
difficulty=info.get("difficulty", "intermediate"),
|
| 74 |
+
diagnosis=info.get("diagnosis", "unknown"),
|
| 75 |
+
differentials=info.get("differentials", ""),
|
| 76 |
+
learning_points=info.get("learning_points", ""),
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
def get_fallback_response(self, message: str, case_context: dict) -> str:
|
| 80 |
+
msg = message.lower()
|
| 81 |
+
self.hints_given += 1
|
| 82 |
+
|
| 83 |
+
# Check if student mentions the correct diagnosis
|
| 84 |
+
diagnosis = self.case_info.get("diagnosis", "").lower()
|
| 85 |
+
if diagnosis and any(
|
| 86 |
+
word in msg for word in diagnosis.split() if len(word) > 3
|
| 87 |
+
):
|
| 88 |
+
self.student_on_track = True
|
| 89 |
+
return (
|
| 90 |
+
"Excellent clinical reasoning! You've identified the key diagnosis. "
|
| 91 |
+
"Now tell me — what is the pathophysiological mechanism here? "
|
| 92 |
+
"And what would be your first-line management according to current guidelines?"
|
| 93 |
+
)
|
| 94 |
+
|
| 95 |
+
if self.hints_given <= 1:
|
| 96 |
+
return (
|
| 97 |
+
"Let's think about this systematically. "
|
| 98 |
+
f"You have a {self.case_info.get('age', 45)}-year-old presenting with "
|
| 99 |
+
f"{self.case_info.get('chief_complaint', 'these symptoms')}. "
|
| 100 |
+
"What are the most dangerous diagnoses you need to rule out first? "
|
| 101 |
+
"Start with your differential — what's at the top of your list?"
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
if self.hints_given == 2:
|
| 105 |
+
return (
|
| 106 |
+
"Good effort. Now look at the vital signs carefully — do you see a pattern? "
|
| 107 |
+
f"This is a {self.case_info.get('specialty', 'clinical')} case. "
|
| 108 |
+
"What investigation would help you narrow down your differential? "
|
| 109 |
+
"Remember — systematic approach is key for NEET-PG as well."
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
if self.hints_given == 3:
|
| 113 |
+
specialty = self.case_info.get("specialty", "")
|
| 114 |
+
return (
|
| 115 |
+
f"Let me give you a hint — think about the classic {specialty} presentations. "
|
| 116 |
+
f"The key differentials here would include: {self.case_info.get('differentials', 'several possibilities')}. "
|
| 117 |
+
"Which of these fits best with ALL the findings — history, examination, and investigations?"
|
| 118 |
+
)
|
| 119 |
+
|
| 120 |
+
# Progressive hints after 3
|
| 121 |
+
return (
|
| 122 |
+
"You're working hard on this, which is good. Let me narrow it down — "
|
| 123 |
+
"focus on the ONE finding that is most specific. "
|
| 124 |
+
"What single investigation or sign points you toward the diagnosis? "
|
| 125 |
+
"Think about what makes this case different from the usual presentation."
|
| 126 |
+
)
|
| 127 |
+
|
| 128 |
+
def get_initial_guidance(self) -> dict:
|
| 129 |
+
"""Generate senior doctor's initial teaching prompt."""
|
| 130 |
+
difficulty = self.case_info.get("difficulty", "intermediate")
|
| 131 |
+
specialty = self.case_info.get("specialty", "clinical")
|
| 132 |
+
|
| 133 |
+
if difficulty == "advanced":
|
| 134 |
+
content = (
|
| 135 |
+
f"Interesting {specialty} case we have here. "
|
| 136 |
+
"This one will test your clinical reasoning — the presentation may not be straightforward. "
|
| 137 |
+
"Start by taking a thorough history from the patient. What would you ask first, and why?"
|
| 138 |
+
)
|
| 139 |
+
elif difficulty == "beginner":
|
| 140 |
+
content = (
|
| 141 |
+
f"Good, let's work through this {specialty} case together. "
|
| 142 |
+
"Start from the basics — look at the patient's presentation and vital signs. "
|
| 143 |
+
"What's your initial assessment? Don't worry about getting it perfect, just think aloud."
|
| 144 |
+
)
|
| 145 |
+
else:
|
| 146 |
+
content = (
|
| 147 |
+
f"Alright, we have a {specialty} case. "
|
| 148 |
+
"I want you to approach this like you would in your exam — systematically. "
|
| 149 |
+
"Start with the patient's presenting complaint and vitals. What catches your attention?"
|
| 150 |
+
)
|
| 151 |
+
|
| 152 |
+
return {
|
| 153 |
+
"agent_type": self.agent_type,
|
| 154 |
+
"display_name": self.display_name,
|
| 155 |
+
"content": content,
|
| 156 |
+
}
|
|
@@ -9,7 +9,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
|
| 9 |
# Load .env from backend/ directory
|
| 10 |
load_dotenv(Path(__file__).resolve().parent.parent / ".env")
|
| 11 |
|
| 12 |
-
from app.api import cases, student, analytics
|
| 13 |
|
| 14 |
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(name)s] %(levelname)s: %(message)s")
|
| 15 |
logger = logging.getLogger(__name__)
|
|
@@ -52,6 +52,7 @@ app.add_middleware(
|
|
| 52 |
app.include_router(cases.router, prefix="/api/cases", tags=["cases"])
|
| 53 |
app.include_router(student.router, prefix="/api/student", tags=["student"])
|
| 54 |
app.include_router(analytics.router, prefix="/api/analytics", tags=["analytics"])
|
|
|
|
| 55 |
|
| 56 |
|
| 57 |
@app.get("/")
|
|
|
|
| 9 |
# Load .env from backend/ directory
|
| 10 |
load_dotenv(Path(__file__).resolve().parent.parent / ".env")
|
| 11 |
|
| 12 |
+
from app.api import cases, student, analytics, agents
|
| 13 |
|
| 14 |
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(name)s] %(levelname)s: %(message)s")
|
| 15 |
logger = logging.getLogger(__name__)
|
|
|
|
| 52 |
app.include_router(cases.router, prefix="/api/cases", tags=["cases"])
|
| 53 |
app.include_router(student.router, prefix="/api/student", tags=["student"])
|
| 54 |
app.include_router(analytics.router, prefix="/api/analytics", tags=["analytics"])
|
| 55 |
+
app.include_router(agents.router, prefix="/api/agents", tags=["agents"])
|
| 56 |
|
| 57 |
|
| 58 |
@app.get("/")
|
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
|
| 3 |
+
interface NurseAvatarProps {
|
| 4 |
+
size?: number;
|
| 5 |
+
urgencyLevel?: 'routine' | 'attention' | 'urgent' | 'critical';
|
| 6 |
+
className?: string;
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
export const NurseAvatar: React.FC<NurseAvatarProps> = ({
|
| 10 |
+
size = 40,
|
| 11 |
+
urgencyLevel = 'routine',
|
| 12 |
+
className = '',
|
| 13 |
+
}) => {
|
| 14 |
+
const isAlert = urgencyLevel === 'urgent' || urgencyLevel === 'critical';
|
| 15 |
+
|
| 16 |
+
return (
|
| 17 |
+
<svg
|
| 18 |
+
width={size}
|
| 19 |
+
height={size}
|
| 20 |
+
viewBox="0 0 32 32"
|
| 21 |
+
fill="none"
|
| 22 |
+
xmlns="http://www.w3.org/2000/svg"
|
| 23 |
+
className={className}
|
| 24 |
+
>
|
| 25 |
+
{/* Head */}
|
| 26 |
+
<circle cx="16" cy="14" r="10" fill="#f0d9b5" stroke="#8B7355" strokeWidth="0.5" />
|
| 27 |
+
|
| 28 |
+
{/* Hair - tied back */}
|
| 29 |
+
<path
|
| 30 |
+
d="M 7 13 Q 6 5 16 4 Q 26 5 25 13"
|
| 31 |
+
fill="#1a0a00"
|
| 32 |
+
stroke="#0d0500"
|
| 33 |
+
strokeWidth="0.3"
|
| 34 |
+
/>
|
| 35 |
+
{/* Hair bun */}
|
| 36 |
+
<circle cx="22" cy="7" r="3" fill="#1a0a00" />
|
| 37 |
+
|
| 38 |
+
{/* Nurse cap */}
|
| 39 |
+
<rect x="10" y="3" width="12" height="4" rx="1" fill="white" stroke="#4a90d9" strokeWidth="0.5" />
|
| 40 |
+
<line x1="14" y1="3" x2="14" y2="7" stroke="#e74c3c" strokeWidth="0.4" />
|
| 41 |
+
<line x1="18" y1="3" x2="18" y2="7" stroke="#e74c3c" strokeWidth="0.4" />
|
| 42 |
+
<line x1="12" y1="5" x2="20" y2="5" stroke="#e74c3c" strokeWidth="0.4" />
|
| 43 |
+
|
| 44 |
+
{/* Eyes */}
|
| 45 |
+
<circle cx="12" cy="13" r="1.1" fill="#2C1810" />
|
| 46 |
+
<circle cx="20" cy="13" r="1.1" fill="#2C1810" />
|
| 47 |
+
|
| 48 |
+
{/* Friendly smile */}
|
| 49 |
+
<path d="M 13 19 Q 16 21 19 19" stroke="#8B4513" strokeWidth="0.6" fill="none" strokeLinecap="round" />
|
| 50 |
+
|
| 51 |
+
{/* Scrubs collar */}
|
| 52 |
+
<path d="M 9 24 L 12 22 L 16 24 L 20 22 L 23 24" stroke="#4a90d9" strokeWidth="1.2" fill="none" />
|
| 53 |
+
|
| 54 |
+
{/* Stethoscope */}
|
| 55 |
+
<path
|
| 56 |
+
d="M 14 24 Q 12 27 14 29 Q 16 30 18 29 Q 20 27 18 24"
|
| 57 |
+
stroke="#555"
|
| 58 |
+
strokeWidth="0.7"
|
| 59 |
+
fill="none"
|
| 60 |
+
/>
|
| 61 |
+
<circle cx="16" cy="30" r="1.2" fill="#666" stroke="#444" strokeWidth="0.3" />
|
| 62 |
+
|
| 63 |
+
{/* Alert indicator for urgent/critical */}
|
| 64 |
+
{isAlert && (
|
| 65 |
+
<g>
|
| 66 |
+
<circle cx="27" cy="5" r="4" fill={urgencyLevel === 'critical' ? '#e74c3c' : '#f39c12'}>
|
| 67 |
+
<animate attributeName="r" values="3.5;4.5;3.5" dur="1s" repeatCount="indefinite" />
|
| 68 |
+
</circle>
|
| 69 |
+
<text
|
| 70 |
+
x="27"
|
| 71 |
+
y="7"
|
| 72 |
+
textAnchor="middle"
|
| 73 |
+
fill="white"
|
| 74 |
+
fontSize="5"
|
| 75 |
+
fontWeight="bold"
|
| 76 |
+
>
|
| 77 |
+
!
|
| 78 |
+
</text>
|
| 79 |
+
</g>
|
| 80 |
+
)}
|
| 81 |
+
</svg>
|
| 82 |
+
);
|
| 83 |
+
};
|
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
|
| 3 |
+
interface PatientAvatarProps {
|
| 4 |
+
size?: number;
|
| 5 |
+
distressLevel?: 'low' | 'moderate' | 'high' | 'critical';
|
| 6 |
+
className?: string;
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
export const PatientAvatar: React.FC<PatientAvatarProps> = ({
|
| 10 |
+
size = 40,
|
| 11 |
+
distressLevel = 'moderate',
|
| 12 |
+
className = '',
|
| 13 |
+
}) => {
|
| 14 |
+
const faceColor = distressLevel === 'critical' ? '#e8d5d0' : distressLevel === 'high' ? '#edd9c5' : '#f0d9b5';
|
| 15 |
+
const mouthPath =
|
| 16 |
+
distressLevel === 'critical'
|
| 17 |
+
? 'M 14 22 Q 16 20 18 22' // grimace
|
| 18 |
+
: distressLevel === 'high'
|
| 19 |
+
? 'M 14 21 Q 16 19 18 21' // frown
|
| 20 |
+
: distressLevel === 'moderate'
|
| 21 |
+
? 'M 14 20 L 18 20' // flat
|
| 22 |
+
: 'M 14 20 Q 16 22 18 20'; // slight smile
|
| 23 |
+
|
| 24 |
+
const eyeY = distressLevel === 'critical' ? '14' : '13';
|
| 25 |
+
const browOffset = distressLevel === 'high' || distressLevel === 'critical' ? -1 : 0;
|
| 26 |
+
|
| 27 |
+
return (
|
| 28 |
+
<svg
|
| 29 |
+
width={size}
|
| 30 |
+
height={size}
|
| 31 |
+
viewBox="0 0 32 32"
|
| 32 |
+
fill="none"
|
| 33 |
+
xmlns="http://www.w3.org/2000/svg"
|
| 34 |
+
className={className}
|
| 35 |
+
>
|
| 36 |
+
{/* Head */}
|
| 37 |
+
<circle cx="16" cy="14" r="10" fill={faceColor} stroke="#8B7355" strokeWidth="0.5" />
|
| 38 |
+
|
| 39 |
+
{/* Hair */}
|
| 40 |
+
<path
|
| 41 |
+
d="M 6 14 Q 6 5 16 4 Q 26 5 26 14"
|
| 42 |
+
fill="#2C1810"
|
| 43 |
+
stroke="#1a0f09"
|
| 44 |
+
strokeWidth="0.3"
|
| 45 |
+
/>
|
| 46 |
+
|
| 47 |
+
{/* Eyes */}
|
| 48 |
+
<circle cx="12" cy={eyeY} r="1.2" fill="#2C1810" />
|
| 49 |
+
<circle cx="20" cy={eyeY} r="1.2" fill="#2C1810" />
|
| 50 |
+
|
| 51 |
+
{/* Eyebrows */}
|
| 52 |
+
<line x1="10" y1={10 + browOffset} x2="14" y2={9.5 + browOffset} stroke="#2C1810" strokeWidth="0.6" strokeLinecap="round" />
|
| 53 |
+
<line x1="18" y1={9.5 + browOffset} x2="22" y2={10 + browOffset} stroke="#2C1810" strokeWidth="0.6" strokeLinecap="round" />
|
| 54 |
+
|
| 55 |
+
{/* Mouth */}
|
| 56 |
+
<path d={mouthPath} stroke="#8B4513" strokeWidth="0.7" fill="none" strokeLinecap="round" />
|
| 57 |
+
|
| 58 |
+
{/* Hospital gown collar */}
|
| 59 |
+
<path d="M 10 24 Q 16 26 22 24" stroke="#6B9BD2" strokeWidth="1.5" fill="none" />
|
| 60 |
+
|
| 61 |
+
{/* Distress indicators */}
|
| 62 |
+
{(distressLevel === 'high' || distressLevel === 'critical') && (
|
| 63 |
+
<>
|
| 64 |
+
{/* Sweat drops */}
|
| 65 |
+
<ellipse cx="7" cy="12" rx="0.6" ry="1" fill="#87CEEB" opacity="0.7">
|
| 66 |
+
<animate attributeName="cy" values="12;14;12" dur="1.5s" repeatCount="indefinite" />
|
| 67 |
+
</ellipse>
|
| 68 |
+
<ellipse cx="25" cy="11" rx="0.5" ry="0.8" fill="#87CEEB" opacity="0.6">
|
| 69 |
+
<animate attributeName="cy" values="11;13;11" dur="1.8s" repeatCount="indefinite" />
|
| 70 |
+
</ellipse>
|
| 71 |
+
</>
|
| 72 |
+
)}
|
| 73 |
+
|
| 74 |
+
{distressLevel === 'critical' && (
|
| 75 |
+
<>
|
| 76 |
+
{/* Pain lines */}
|
| 77 |
+
<line x1="3" y1="8" x2="5" y2="9" stroke="#cc0000" strokeWidth="0.5" opacity="0.6">
|
| 78 |
+
<animate attributeName="opacity" values="0.6;0.2;0.6" dur="0.8s" repeatCount="indefinite" />
|
| 79 |
+
</line>
|
| 80 |
+
<line x1="27" y1="8" x2="29" y2="9" stroke="#cc0000" strokeWidth="0.5" opacity="0.6">
|
| 81 |
+
<animate attributeName="opacity" values="0.6;0.2;0.6" dur="0.8s" repeatCount="indefinite" />
|
| 82 |
+
</line>
|
| 83 |
+
</>
|
| 84 |
+
)}
|
| 85 |
+
</svg>
|
| 86 |
+
);
|
| 87 |
+
};
|
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
|
| 3 |
+
interface SeniorDoctorAvatarProps {
|
| 4 |
+
size?: number;
|
| 5 |
+
isThinking?: boolean;
|
| 6 |
+
className?: string;
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
export const SeniorDoctorAvatar: React.FC<SeniorDoctorAvatarProps> = ({
|
| 10 |
+
size = 40,
|
| 11 |
+
isThinking = false,
|
| 12 |
+
className = '',
|
| 13 |
+
}) => {
|
| 14 |
+
return (
|
| 15 |
+
<svg
|
| 16 |
+
width={size}
|
| 17 |
+
height={size}
|
| 18 |
+
viewBox="0 0 32 32"
|
| 19 |
+
fill="none"
|
| 20 |
+
xmlns="http://www.w3.org/2000/svg"
|
| 21 |
+
className={className}
|
| 22 |
+
>
|
| 23 |
+
{/* Head */}
|
| 24 |
+
<circle cx="16" cy="14" r="10" fill="#e8c99b" stroke="#8B7355" strokeWidth="0.5" />
|
| 25 |
+
|
| 26 |
+
{/* Hair - grey/distinguished */}
|
| 27 |
+
<path
|
| 28 |
+
d="M 7 14 Q 6 4 16 3 Q 26 4 25 14"
|
| 29 |
+
fill="#888"
|
| 30 |
+
stroke="#666"
|
| 31 |
+
strokeWidth="0.3"
|
| 32 |
+
/>
|
| 33 |
+
{/* Slightly receding hairline */}
|
| 34 |
+
<path d="M 9 8 Q 12 6 16 5.5 Q 20 6 23 8" fill="#e8c99b" />
|
| 35 |
+
|
| 36 |
+
{/* Glasses */}
|
| 37 |
+
<rect x="9" y="11" width="5.5" height="4" rx="1.5" stroke="#333" strokeWidth="0.6" fill="none" />
|
| 38 |
+
<rect x="17.5" y="11" width="5.5" height="4" rx="1.5" stroke="#333" strokeWidth="0.6" fill="none" />
|
| 39 |
+
<line x1="14.5" y1="13" x2="17.5" y2="13" stroke="#333" strokeWidth="0.5" />
|
| 40 |
+
<line x1="9" y1="12.5" x2="7" y2="12" stroke="#333" strokeWidth="0.4" />
|
| 41 |
+
<line x1="23" y1="12.5" x2="25" y2="12" stroke="#333" strokeWidth="0.4" />
|
| 42 |
+
|
| 43 |
+
{/* Eyes behind glasses */}
|
| 44 |
+
<circle cx="12" cy="13" r="0.9" fill="#2C1810" />
|
| 45 |
+
<circle cx="20" cy="13" r="0.9" fill="#2C1810" />
|
| 46 |
+
|
| 47 |
+
{/* Slight mustache */}
|
| 48 |
+
<path d="M 13 17.5 Q 16 18.5 19 17.5" stroke="#777" strokeWidth="0.5" fill="none" />
|
| 49 |
+
|
| 50 |
+
{/* Confident smile */}
|
| 51 |
+
<path d="M 13 19.5 Q 16 21 19 19.5" stroke="#8B4513" strokeWidth="0.5" fill="none" strokeLinecap="round" />
|
| 52 |
+
|
| 53 |
+
{/* White coat collar */}
|
| 54 |
+
<path d="M 8 24 L 12 21 L 16 23 L 20 21 L 24 24" stroke="#fff" strokeWidth="1.5" fill="white" />
|
| 55 |
+
<line x1="16" y1="23" x2="16" y2="28" stroke="#ddd" strokeWidth="0.5" />
|
| 56 |
+
|
| 57 |
+
{/* Name badge */}
|
| 58 |
+
<rect x="18" y="24" width="5" height="3" rx="0.5" fill="#4a90d9" stroke="#3a7bc8" strokeWidth="0.3" />
|
| 59 |
+
<text x="20.5" y="26.2" textAnchor="middle" fill="white" fontSize="2">DR</text>
|
| 60 |
+
|
| 61 |
+
{/* Thinking bubble when active */}
|
| 62 |
+
{isThinking && (
|
| 63 |
+
<g>
|
| 64 |
+
<circle cx="5" cy="5" r="1" fill="#ddd" opacity="0.6">
|
| 65 |
+
<animate attributeName="opacity" values="0.3;0.8;0.3" dur="1.5s" repeatCount="indefinite" />
|
| 66 |
+
</circle>
|
| 67 |
+
<circle cx="3" cy="3" r="1.8" fill="#ddd" opacity="0.5">
|
| 68 |
+
<animate attributeName="opacity" values="0.2;0.7;0.2" dur="1.5s" repeatCount="indefinite" begin="0.3s" />
|
| 69 |
+
</circle>
|
| 70 |
+
<ellipse cx="0" cy="0" rx="3" ry="2" fill="#eee" stroke="#ccc" strokeWidth="0.3" opacity="0.7">
|
| 71 |
+
<animate attributeName="opacity" values="0.4;0.9;0.4" dur="1.5s" repeatCount="indefinite" begin="0.6s" />
|
| 72 |
+
</ellipse>
|
| 73 |
+
<text x="0" y="1" textAnchor="middle" fill="#666" fontSize="2.5" opacity="0.8">?</text>
|
| 74 |
+
</g>
|
| 75 |
+
)}
|
| 76 |
+
</svg>
|
| 77 |
+
);
|
| 78 |
+
};
|
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export { PatientAvatar } from './PatientAvatar';
|
| 2 |
+
export { NurseAvatar } from './NurseAvatar';
|
| 3 |
+
export { SeniorDoctorAvatar } from './SeniorDoctorAvatar';
|
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import { PatientAvatar, NurseAvatar, SeniorDoctorAvatar } from '../avatars';
|
| 3 |
+
|
| 4 |
+
export interface AgentMessageData {
|
| 5 |
+
id: string;
|
| 6 |
+
agent_type: 'patient' | 'nurse' | 'senior_doctor' | 'student';
|
| 7 |
+
display_name: string;
|
| 8 |
+
content: string;
|
| 9 |
+
distress_level?: string;
|
| 10 |
+
urgency_level?: string;
|
| 11 |
+
timestamp: Date;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
interface AgentMessageProps {
|
| 15 |
+
message: AgentMessageData;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
const agentStyles: Record<string, { bg: string; border: string; nameColor: string }> = {
|
| 19 |
+
patient: {
|
| 20 |
+
bg: 'bg-amber-50',
|
| 21 |
+
border: 'border-amber-200',
|
| 22 |
+
nameColor: 'text-amber-700',
|
| 23 |
+
},
|
| 24 |
+
nurse: {
|
| 25 |
+
bg: 'bg-blue-50',
|
| 26 |
+
border: 'border-blue-200',
|
| 27 |
+
nameColor: 'text-blue-700',
|
| 28 |
+
},
|
| 29 |
+
senior_doctor: {
|
| 30 |
+
bg: 'bg-emerald-50',
|
| 31 |
+
border: 'border-emerald-200',
|
| 32 |
+
nameColor: 'text-emerald-700',
|
| 33 |
+
},
|
| 34 |
+
student: {
|
| 35 |
+
bg: 'bg-warm-gray-50',
|
| 36 |
+
border: 'border-warm-gray-200',
|
| 37 |
+
nameColor: 'text-text-primary',
|
| 38 |
+
},
|
| 39 |
+
};
|
| 40 |
+
|
| 41 |
+
const AgentAvatarIcon: React.FC<{ agentType: string; distressLevel?: string; urgencyLevel?: string }> = ({
|
| 42 |
+
agentType,
|
| 43 |
+
distressLevel,
|
| 44 |
+
urgencyLevel,
|
| 45 |
+
}) => {
|
| 46 |
+
switch (agentType) {
|
| 47 |
+
case 'patient':
|
| 48 |
+
return (
|
| 49 |
+
<PatientAvatar
|
| 50 |
+
size={28}
|
| 51 |
+
distressLevel={(distressLevel as 'low' | 'moderate' | 'high' | 'critical') || 'moderate'}
|
| 52 |
+
/>
|
| 53 |
+
);
|
| 54 |
+
case 'nurse':
|
| 55 |
+
return (
|
| 56 |
+
<NurseAvatar
|
| 57 |
+
size={28}
|
| 58 |
+
urgencyLevel={(urgencyLevel as 'routine' | 'attention' | 'urgent' | 'critical') || 'routine'}
|
| 59 |
+
/>
|
| 60 |
+
);
|
| 61 |
+
case 'senior_doctor':
|
| 62 |
+
return <SeniorDoctorAvatar size={28} />;
|
| 63 |
+
default:
|
| 64 |
+
return (
|
| 65 |
+
<div className="w-7 h-7 rounded-full bg-warm-gray-200 flex items-center justify-center text-xs font-bold text-text-primary">
|
| 66 |
+
You
|
| 67 |
+
</div>
|
| 68 |
+
);
|
| 69 |
+
}
|
| 70 |
+
};
|
| 71 |
+
|
| 72 |
+
export const AgentMessage: React.FC<AgentMessageProps> = ({ message }) => {
|
| 73 |
+
const isStudent = message.agent_type === 'student';
|
| 74 |
+
const style = agentStyles[message.agent_type] || agentStyles.student;
|
| 75 |
+
|
| 76 |
+
return (
|
| 77 |
+
<div className={`flex gap-2 ${isStudent ? 'flex-row-reverse' : 'flex-row'}`}>
|
| 78 |
+
<div className="flex-shrink-0 mt-1">
|
| 79 |
+
<AgentAvatarIcon
|
| 80 |
+
agentType={message.agent_type}
|
| 81 |
+
distressLevel={message.distress_level}
|
| 82 |
+
urgencyLevel={message.urgency_level}
|
| 83 |
+
/>
|
| 84 |
+
</div>
|
| 85 |
+
<div
|
| 86 |
+
className={`p-3 rounded-xl text-sm leading-relaxed border max-w-[85%] ${style.bg} ${style.border}`}
|
| 87 |
+
>
|
| 88 |
+
{!isStudent && (
|
| 89 |
+
<div className={`text-xs font-semibold mb-1 ${style.nameColor}`}>
|
| 90 |
+
{message.display_name}
|
| 91 |
+
</div>
|
| 92 |
+
)}
|
| 93 |
+
<div className="text-text-primary">{message.content}</div>
|
| 94 |
+
</div>
|
| 95 |
+
</div>
|
| 96 |
+
);
|
| 97 |
+
};
|
|
@@ -153,3 +153,50 @@ export function fetchPerformance(): Promise<PerformanceData> {
|
|
| 153 |
export function fetchRecommendations(): Promise<RecommendationItem[]> {
|
| 154 |
return request('/analytics/recommendations');
|
| 155 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
export function fetchRecommendations(): Promise<RecommendationItem[]> {
|
| 154 |
return request('/analytics/recommendations');
|
| 155 |
}
|
| 156 |
+
|
| 157 |
+
// --- Multi-Agent System ---
|
| 158 |
+
|
| 159 |
+
export interface AgentMessageDTO {
|
| 160 |
+
agent_type: 'patient' | 'nurse' | 'senior_doctor' | 'student';
|
| 161 |
+
display_name: string;
|
| 162 |
+
content: string;
|
| 163 |
+
distress_level?: string;
|
| 164 |
+
urgency_level?: string;
|
| 165 |
+
thinking?: string;
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
export interface AgentSessionResponse {
|
| 169 |
+
session_id: string;
|
| 170 |
+
messages: AgentMessageDTO[];
|
| 171 |
+
vitals: {
|
| 172 |
+
vitals: { bp: string; hr: number; rr: number; temp: number; spo2: number };
|
| 173 |
+
urgency_level: string;
|
| 174 |
+
patient_distress: string;
|
| 175 |
+
};
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
export function initializeAgents(caseId: string): Promise<AgentSessionResponse> {
|
| 179 |
+
return request('/agents/initialize', {
|
| 180 |
+
method: 'POST',
|
| 181 |
+
body: JSON.stringify({ case_id: caseId }),
|
| 182 |
+
});
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
export function sendAgentAction(
|
| 186 |
+
sessionId: string,
|
| 187 |
+
actionType: string,
|
| 188 |
+
studentInput?: string,
|
| 189 |
+
): Promise<AgentSessionResponse> {
|
| 190 |
+
return request('/agents/action', {
|
| 191 |
+
method: 'POST',
|
| 192 |
+
body: JSON.stringify({
|
| 193 |
+
session_id: sessionId,
|
| 194 |
+
action_type: actionType,
|
| 195 |
+
student_input: studentInput,
|
| 196 |
+
}),
|
| 197 |
+
});
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
export function fetchAgentVitals(sessionId: string): Promise<AgentSessionResponse['vitals']> {
|
| 201 |
+
return request(`/agents/vitals/${sessionId}`);
|
| 202 |
+
}
|
|
@@ -1,8 +1,16 @@
|
|
| 1 |
import React, { useState, useRef, useEffect } from 'react';
|
| 2 |
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
| 3 |
import { Button, Card, Badge, Input } from '../components/ui';
|
| 4 |
-
import
|
| 5 |
-
import {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
|
| 7 |
const stageLabels: Record<string, string> = {
|
| 8 |
history: 'History',
|
|
@@ -16,6 +24,8 @@ const stageIcons: Record<string, string> = {
|
|
| 16 |
labs: '🔬',
|
| 17 |
};
|
| 18 |
|
|
|
|
|
|
|
| 19 |
export const CaseInterface: React.FC = () => {
|
| 20 |
const navigate = useNavigate();
|
| 21 |
const { id } = useParams<{ id: string }>();
|
|
@@ -23,20 +33,20 @@ export const CaseInterface: React.FC = () => {
|
|
| 23 |
const [caseData, setCaseData] = useState<GeneratedCase | null>(null);
|
| 24 |
const [loadingCase, setLoadingCase] = useState(true);
|
| 25 |
const [revealedStages, setRevealedStages] = useState<Set<number>>(new Set());
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
timestamp: new Date(),
|
| 32 |
-
},
|
| 33 |
-
]);
|
| 34 |
const [inputValue, setInputValue] = useState('');
|
| 35 |
const [sendingMessage, setSendingMessage] = useState(false);
|
|
|
|
|
|
|
| 36 |
const [showDiagnosis, setShowDiagnosis] = useState(false);
|
| 37 |
const [studentDiagnosis, setStudentDiagnosis] = useState('');
|
| 38 |
const [showDiagnosisInput, setShowDiagnosisInput] = useState(false);
|
| 39 |
const [diagnosisResult, setDiagnosisResult] = useState<DiagnosisResult | null>(null);
|
|
|
|
| 40 |
const chatEndRef = useRef<HTMLDivElement>(null);
|
| 41 |
|
| 42 |
// Load existing case by ID, or generate new one
|
|
@@ -44,11 +54,9 @@ export const CaseInterface: React.FC = () => {
|
|
| 44 |
setLoadingCase(true);
|
| 45 |
|
| 46 |
if (id && id !== 'new') {
|
| 47 |
-
// Try to load an existing case from the backend
|
| 48 |
fetchCase(id)
|
| 49 |
.then((data) => setCaseData(data))
|
| 50 |
.catch(() => {
|
| 51 |
-
// Case not found (expired from memory) — generate a fresh one
|
| 52 |
const specialty = searchParams.get('specialty') || 'cardiology';
|
| 53 |
const difficulty = searchParams.get('difficulty') || 'intermediate';
|
| 54 |
return generateCase(specialty, difficulty).then((data) => setCaseData(data));
|
|
@@ -56,7 +64,6 @@ export const CaseInterface: React.FC = () => {
|
|
| 56 |
.catch(() => setCaseData(null))
|
| 57 |
.finally(() => setLoadingCase(false));
|
| 58 |
} else {
|
| 59 |
-
// Explicitly generating a new case
|
| 60 |
const specialty = searchParams.get('specialty') || 'cardiology';
|
| 61 |
const difficulty = searchParams.get('difficulty') || 'intermediate';
|
| 62 |
generateCase(specialty, difficulty)
|
|
@@ -66,44 +73,85 @@ export const CaseInterface: React.FC = () => {
|
|
| 66 |
}
|
| 67 |
}, [id, searchParams]);
|
| 68 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
useEffect(() => {
|
| 70 |
chatEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
| 71 |
-
}, [
|
| 72 |
|
| 73 |
const revealStage = (index: number) => {
|
| 74 |
setRevealedStages((prev) => new Set(prev).add(index));
|
| 75 |
};
|
| 76 |
|
| 77 |
const sendMessage = async () => {
|
| 78 |
-
if (!inputValue.trim() || !
|
| 79 |
|
| 80 |
-
const studentMsg:
|
| 81 |
id: Date.now().toString(),
|
| 82 |
-
|
|
|
|
| 83 |
content: inputValue,
|
| 84 |
timestamp: new Date(),
|
| 85 |
};
|
| 86 |
-
|
|
|
|
| 87 |
setInputValue('');
|
| 88 |
setSendingMessage(true);
|
| 89 |
|
| 90 |
try {
|
| 91 |
-
const res = await
|
| 92 |
-
const
|
| 93 |
-
id:
|
| 94 |
-
|
| 95 |
-
|
|
|
|
|
|
|
|
|
|
| 96 |
timestamp: new Date(),
|
| 97 |
-
};
|
| 98 |
-
|
| 99 |
} catch {
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
|
|
|
|
|
|
|
|
|
| 107 |
} finally {
|
| 108 |
setSendingMessage(false);
|
| 109 |
}
|
|
@@ -117,21 +165,27 @@ export const CaseInterface: React.FC = () => {
|
|
| 117 |
const result = await submitDiagnosis(caseData.id, studentDiagnosis, '');
|
| 118 |
setDiagnosisResult(result);
|
| 119 |
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
|
|
|
|
|
|
|
|
|
| 127 |
} catch {
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
|
|
|
|
|
|
|
|
|
| 135 |
}
|
| 136 |
};
|
| 137 |
|
|
@@ -345,10 +399,11 @@ export const CaseInterface: React.FC = () => {
|
|
| 345 |
)}
|
| 346 |
</div>
|
| 347 |
|
| 348 |
-
{/*
|
| 349 |
<div className="lg:col-span-1">
|
| 350 |
<div className="sticky top-24">
|
| 351 |
<Card padding="md" className="h-[calc(100vh-8rem)] flex flex-col">
|
|
|
|
| 352 |
<div className="flex items-center gap-3 mb-4 pb-4 border-b border-warm-gray-100">
|
| 353 |
<div className="w-10 h-10 bg-forest-green/10 rounded-xl flex items-center justify-center">
|
| 354 |
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#2D5C3F" strokeWidth="2">
|
|
@@ -356,23 +411,40 @@ export const CaseInterface: React.FC = () => {
|
|
| 356 |
</svg>
|
| 357 |
</div>
|
| 358 |
<div>
|
| 359 |
-
<h3 className="text-base font-semibold text-text-primary">
|
| 360 |
-
<p className="text-xs text-text-tertiary">
|
| 361 |
</div>
|
| 362 |
</div>
|
| 363 |
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 372 |
}`}
|
| 373 |
>
|
| 374 |
-
{
|
| 375 |
-
</
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 376 |
))}
|
| 377 |
{sendingMessage && (
|
| 378 |
<div className="p-3 rounded-xl text-sm bg-forest-green/5 border border-forest-green/10 text-text-tertiary animate-pulse">
|
|
@@ -382,11 +454,18 @@ export const CaseInterface: React.FC = () => {
|
|
| 382 |
<div ref={chatEndRef} />
|
| 383 |
</div>
|
| 384 |
|
|
|
|
| 385 |
<div className="mt-auto">
|
| 386 |
<div className="flex gap-2">
|
| 387 |
<input
|
| 388 |
type="text"
|
| 389 |
-
placeholder=
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 390 |
value={inputValue}
|
| 391 |
onChange={(e) => setInputValue(e.target.value)}
|
| 392 |
onKeyPress={(e) => e.key === 'Enter' && sendMessage()}
|
|
|
|
| 1 |
import React, { useState, useRef, useEffect } from 'react';
|
| 2 |
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
| 3 |
import { Button, Card, Badge, Input } from '../components/ui';
|
| 4 |
+
import { AgentMessage, type AgentMessageData } from '../components/case/AgentMessage';
|
| 5 |
+
import {
|
| 6 |
+
fetchCase,
|
| 7 |
+
generateCase,
|
| 8 |
+
submitDiagnosis,
|
| 9 |
+
initializeAgents,
|
| 10 |
+
sendAgentAction,
|
| 11 |
+
type GeneratedCase,
|
| 12 |
+
type DiagnosisResult,
|
| 13 |
+
} from '../hooks/useApi';
|
| 14 |
|
| 15 |
const stageLabels: Record<string, string> = {
|
| 16 |
history: 'History',
|
|
|
|
| 24 |
labs: '🔬',
|
| 25 |
};
|
| 26 |
|
| 27 |
+
type AgentTarget = 'talk_to_patient' | 'ask_nurse' | 'consult_senior';
|
| 28 |
+
|
| 29 |
export const CaseInterface: React.FC = () => {
|
| 30 |
const navigate = useNavigate();
|
| 31 |
const { id } = useParams<{ id: string }>();
|
|
|
|
| 33 |
const [caseData, setCaseData] = useState<GeneratedCase | null>(null);
|
| 34 |
const [loadingCase, setLoadingCase] = useState(true);
|
| 35 |
const [revealedStages, setRevealedStages] = useState<Set<number>>(new Set());
|
| 36 |
+
|
| 37 |
+
// Multi-agent state
|
| 38 |
+
const [agentSessionId, setAgentSessionId] = useState<string | null>(null);
|
| 39 |
+
const [agentMessages, setAgentMessages] = useState<AgentMessageData[]>([]);
|
| 40 |
+
const [activeTarget, setActiveTarget] = useState<AgentTarget>('talk_to_patient');
|
|
|
|
|
|
|
|
|
|
| 41 |
const [inputValue, setInputValue] = useState('');
|
| 42 |
const [sendingMessage, setSendingMessage] = useState(false);
|
| 43 |
+
|
| 44 |
+
// Diagnosis state
|
| 45 |
const [showDiagnosis, setShowDiagnosis] = useState(false);
|
| 46 |
const [studentDiagnosis, setStudentDiagnosis] = useState('');
|
| 47 |
const [showDiagnosisInput, setShowDiagnosisInput] = useState(false);
|
| 48 |
const [diagnosisResult, setDiagnosisResult] = useState<DiagnosisResult | null>(null);
|
| 49 |
+
|
| 50 |
const chatEndRef = useRef<HTMLDivElement>(null);
|
| 51 |
|
| 52 |
// Load existing case by ID, or generate new one
|
|
|
|
| 54 |
setLoadingCase(true);
|
| 55 |
|
| 56 |
if (id && id !== 'new') {
|
|
|
|
| 57 |
fetchCase(id)
|
| 58 |
.then((data) => setCaseData(data))
|
| 59 |
.catch(() => {
|
|
|
|
| 60 |
const specialty = searchParams.get('specialty') || 'cardiology';
|
| 61 |
const difficulty = searchParams.get('difficulty') || 'intermediate';
|
| 62 |
return generateCase(specialty, difficulty).then((data) => setCaseData(data));
|
|
|
|
| 64 |
.catch(() => setCaseData(null))
|
| 65 |
.finally(() => setLoadingCase(false));
|
| 66 |
} else {
|
|
|
|
| 67 |
const specialty = searchParams.get('specialty') || 'cardiology';
|
| 68 |
const difficulty = searchParams.get('difficulty') || 'intermediate';
|
| 69 |
generateCase(specialty, difficulty)
|
|
|
|
| 73 |
}
|
| 74 |
}, [id, searchParams]);
|
| 75 |
|
| 76 |
+
// Initialize multi-agent session once case loads
|
| 77 |
+
useEffect(() => {
|
| 78 |
+
if (!caseData) return;
|
| 79 |
+
|
| 80 |
+
initializeAgents(caseData.id)
|
| 81 |
+
.then((res) => {
|
| 82 |
+
setAgentSessionId(res.session_id);
|
| 83 |
+
const initialMsgs: AgentMessageData[] = res.messages.map((m, i) => ({
|
| 84 |
+
id: `init-${i}`,
|
| 85 |
+
agent_type: m.agent_type,
|
| 86 |
+
display_name: m.display_name,
|
| 87 |
+
content: m.content,
|
| 88 |
+
distress_level: m.distress_level,
|
| 89 |
+
urgency_level: m.urgency_level,
|
| 90 |
+
timestamp: new Date(),
|
| 91 |
+
}));
|
| 92 |
+
setAgentMessages(initialMsgs);
|
| 93 |
+
})
|
| 94 |
+
.catch(() => {
|
| 95 |
+
// Fallback: show a default welcome message
|
| 96 |
+
setAgentMessages([
|
| 97 |
+
{
|
| 98 |
+
id: 'fallback-1',
|
| 99 |
+
agent_type: 'senior_doctor',
|
| 100 |
+
display_name: 'Dr. Sharma',
|
| 101 |
+
content:
|
| 102 |
+
"Let's work through this case together. Start by examining the patient's presentation and vitals. What catches your attention?",
|
| 103 |
+
timestamp: new Date(),
|
| 104 |
+
},
|
| 105 |
+
]);
|
| 106 |
+
});
|
| 107 |
+
}, [caseData]);
|
| 108 |
+
|
| 109 |
useEffect(() => {
|
| 110 |
chatEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
| 111 |
+
}, [agentMessages]);
|
| 112 |
|
| 113 |
const revealStage = (index: number) => {
|
| 114 |
setRevealedStages((prev) => new Set(prev).add(index));
|
| 115 |
};
|
| 116 |
|
| 117 |
const sendMessage = async () => {
|
| 118 |
+
if (!inputValue.trim() || !agentSessionId) return;
|
| 119 |
|
| 120 |
+
const studentMsg: AgentMessageData = {
|
| 121 |
id: Date.now().toString(),
|
| 122 |
+
agent_type: 'student',
|
| 123 |
+
display_name: 'You',
|
| 124 |
content: inputValue,
|
| 125 |
timestamp: new Date(),
|
| 126 |
};
|
| 127 |
+
setAgentMessages((prev) => [...prev, studentMsg]);
|
| 128 |
+
const currentInput = inputValue;
|
| 129 |
setInputValue('');
|
| 130 |
setSendingMessage(true);
|
| 131 |
|
| 132 |
try {
|
| 133 |
+
const res = await sendAgentAction(agentSessionId, activeTarget, currentInput);
|
| 134 |
+
const newMsgs: AgentMessageData[] = res.messages.map((m, i) => ({
|
| 135 |
+
id: `${Date.now()}-${i}`,
|
| 136 |
+
agent_type: m.agent_type,
|
| 137 |
+
display_name: m.display_name,
|
| 138 |
+
content: m.content,
|
| 139 |
+
distress_level: m.distress_level,
|
| 140 |
+
urgency_level: m.urgency_level,
|
| 141 |
timestamp: new Date(),
|
| 142 |
+
}));
|
| 143 |
+
setAgentMessages((prev) => [...prev, ...newMsgs]);
|
| 144 |
} catch {
|
| 145 |
+
setAgentMessages((prev) => [
|
| 146 |
+
...prev,
|
| 147 |
+
{
|
| 148 |
+
id: (Date.now() + 1).toString(),
|
| 149 |
+
agent_type: 'senior_doctor',
|
| 150 |
+
display_name: 'Dr. Sharma',
|
| 151 |
+
content: 'Good thinking. Can you explain your reasoning further? What evidence supports your hypothesis?',
|
| 152 |
+
timestamp: new Date(),
|
| 153 |
+
},
|
| 154 |
+
]);
|
| 155 |
} finally {
|
| 156 |
setSendingMessage(false);
|
| 157 |
}
|
|
|
|
| 165 |
const result = await submitDiagnosis(caseData.id, studentDiagnosis, '');
|
| 166 |
setDiagnosisResult(result);
|
| 167 |
|
| 168 |
+
setAgentMessages((prev) => [
|
| 169 |
+
...prev,
|
| 170 |
+
{
|
| 171 |
+
id: Date.now().toString(),
|
| 172 |
+
agent_type: 'senior_doctor',
|
| 173 |
+
display_name: 'Dr. Sharma',
|
| 174 |
+
content: result.feedback,
|
| 175 |
+
timestamp: new Date(),
|
| 176 |
+
},
|
| 177 |
+
]);
|
| 178 |
} catch {
|
| 179 |
+
setAgentMessages((prev) => [
|
| 180 |
+
...prev,
|
| 181 |
+
{
|
| 182 |
+
id: Date.now().toString(),
|
| 183 |
+
agent_type: 'senior_doctor',
|
| 184 |
+
display_name: 'Dr. Sharma',
|
| 185 |
+
content: `You diagnosed: "${studentDiagnosis}". Review the case details and learning points for feedback.`,
|
| 186 |
+
timestamp: new Date(),
|
| 187 |
+
},
|
| 188 |
+
]);
|
| 189 |
}
|
| 190 |
};
|
| 191 |
|
|
|
|
| 399 |
)}
|
| 400 |
</div>
|
| 401 |
|
| 402 |
+
{/* Multi-Agent Hospital Chat Sidebar (1/3) */}
|
| 403 |
<div className="lg:col-span-1">
|
| 404 |
<div className="sticky top-24">
|
| 405 |
<Card padding="md" className="h-[calc(100vh-8rem)] flex flex-col">
|
| 406 |
+
{/* Header */}
|
| 407 |
<div className="flex items-center gap-3 mb-4 pb-4 border-b border-warm-gray-100">
|
| 408 |
<div className="w-10 h-10 bg-forest-green/10 rounded-xl flex items-center justify-center">
|
| 409 |
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#2D5C3F" strokeWidth="2">
|
|
|
|
| 411 |
</svg>
|
| 412 |
</div>
|
| 413 |
<div>
|
| 414 |
+
<h3 className="text-base font-semibold text-text-primary">Hospital Ward</h3>
|
| 415 |
+
<p className="text-xs text-text-tertiary">Patient, Nurse, Senior Doctor</p>
|
| 416 |
</div>
|
| 417 |
</div>
|
| 418 |
|
| 419 |
+
{/* Agent target selector */}
|
| 420 |
+
<div className="flex gap-1 mb-3 p-1 bg-warm-gray-50 rounded-lg">
|
| 421 |
+
{([
|
| 422 |
+
{ key: 'talk_to_patient' as AgentTarget, label: 'Patient', color: 'amber' },
|
| 423 |
+
{ key: 'ask_nurse' as AgentTarget, label: 'Nurse', color: 'blue' },
|
| 424 |
+
{ key: 'consult_senior' as AgentTarget, label: 'Dr. Sharma', color: 'emerald' },
|
| 425 |
+
]).map((target) => (
|
| 426 |
+
<button
|
| 427 |
+
key={target.key}
|
| 428 |
+
onClick={() => setActiveTarget(target.key)}
|
| 429 |
+
className={`flex-1 text-xs font-medium py-1.5 px-2 rounded-md transition-colors ${
|
| 430 |
+
activeTarget === target.key
|
| 431 |
+
? target.color === 'amber'
|
| 432 |
+
? 'bg-amber-100 text-amber-800'
|
| 433 |
+
: target.color === 'blue'
|
| 434 |
+
? 'bg-blue-100 text-blue-800'
|
| 435 |
+
: 'bg-emerald-100 text-emerald-800'
|
| 436 |
+
: 'text-text-tertiary hover:text-text-secondary'
|
| 437 |
}`}
|
| 438 |
>
|
| 439 |
+
{target.label}
|
| 440 |
+
</button>
|
| 441 |
+
))}
|
| 442 |
+
</div>
|
| 443 |
+
|
| 444 |
+
{/* Messages */}
|
| 445 |
+
<div className="flex-1 overflow-y-auto space-y-3 mb-4">
|
| 446 |
+
{agentMessages.map((msg) => (
|
| 447 |
+
<AgentMessage key={msg.id} message={msg} />
|
| 448 |
))}
|
| 449 |
{sendingMessage && (
|
| 450 |
<div className="p-3 rounded-xl text-sm bg-forest-green/5 border border-forest-green/10 text-text-tertiary animate-pulse">
|
|
|
|
| 454 |
<div ref={chatEndRef} />
|
| 455 |
</div>
|
| 456 |
|
| 457 |
+
{/* Input */}
|
| 458 |
<div className="mt-auto">
|
| 459 |
<div className="flex gap-2">
|
| 460 |
<input
|
| 461 |
type="text"
|
| 462 |
+
placeholder={
|
| 463 |
+
activeTarget === 'talk_to_patient'
|
| 464 |
+
? 'Talk to the patient...'
|
| 465 |
+
: activeTarget === 'ask_nurse'
|
| 466 |
+
? 'Ask Nurse Priya...'
|
| 467 |
+
: 'Discuss with Dr. Sharma...'
|
| 468 |
+
}
|
| 469 |
value={inputValue}
|
| 470 |
onChange={(e) => setInputValue(e.target.value)}
|
| 471 |
onKeyPress={(e) => e.key === 'Enter' && sendMessage()}
|