Claude commited on
Commit
6c763c1
·
unverified ·
1 Parent(s): 13b7790

Add multi-agent hospital ecosystem with patient, nurse, and senior doctor

Browse files

Backend:
- 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 ADDED
@@ -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
backend/app/core/agents/base_agent.py ADDED
@@ -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 = []
backend/app/core/agents/nurse_agent.py ADDED
@@ -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
+ }
backend/app/core/agents/orchestrator.py ADDED
@@ -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()
backend/app/core/agents/patient_agent.py ADDED
@@ -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
+ }
backend/app/core/agents/senior_agent.py ADDED
@@ -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
+ }
backend/app/main.py CHANGED
@@ -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("/")
frontend/src/components/avatars/NurseAvatar.tsx ADDED
@@ -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
+ };
frontend/src/components/avatars/PatientAvatar.tsx ADDED
@@ -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
+ };
frontend/src/components/avatars/SeniorDoctorAvatar.tsx ADDED
@@ -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
+ };
frontend/src/components/avatars/index.ts ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ export { PatientAvatar } from './PatientAvatar';
2
+ export { NurseAvatar } from './NurseAvatar';
3
+ export { SeniorDoctorAvatar } from './SeniorDoctorAvatar';
frontend/src/components/case/AgentMessage.tsx ADDED
@@ -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
+ };
frontend/src/hooks/useApi.ts CHANGED
@@ -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
+ }
frontend/src/pages/CaseInterface.tsx CHANGED
@@ -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 type { Message } from '../types';
5
- import { fetchCase, generateCase, submitDiagnosis, sendTutorMessage, type GeneratedCase, type DiagnosisResult } from '../hooks/useApi';
 
 
 
 
 
 
 
 
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
- const [messages, setMessages] = useState<Message[]>([
27
- {
28
- id: '1',
29
- role: 'ai',
30
- content: "I see you've started a new case. Take a look at the patient presentation and vital signs. What's your initial assessment? What differential diagnoses come to mind?",
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
- }, [messages]);
72
 
73
  const revealStage = (index: number) => {
74
  setRevealedStages((prev) => new Set(prev).add(index));
75
  };
76
 
77
  const sendMessage = async () => {
78
- if (!inputValue.trim() || !caseData) return;
79
 
80
- const studentMsg: Message = {
81
  id: Date.now().toString(),
82
- role: 'student',
 
83
  content: inputValue,
84
  timestamp: new Date(),
85
  };
86
- setMessages((prev) => [...prev, studentMsg]);
 
87
  setInputValue('');
88
  setSendingMessage(true);
89
 
90
  try {
91
- const res = await sendTutorMessage(caseData.id, inputValue);
92
- const aiResponse: Message = {
93
- id: (Date.now() + 1).toString(),
94
- role: 'ai',
95
- content: res.response,
 
 
 
96
  timestamp: new Date(),
97
- };
98
- setMessages((prev) => [...prev, aiResponse]);
99
  } catch {
100
- const aiResponse: Message = {
101
- id: (Date.now() + 1).toString(),
102
- role: 'ai',
103
- content: "Good thinking. Can you explain your reasoning further? What evidence supports your hypothesis?",
104
- timestamp: new Date(),
105
- };
106
- setMessages((prev) => [...prev, aiResponse]);
 
 
 
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
- const aiMsg: Message = {
121
- id: Date.now().toString(),
122
- role: 'ai',
123
- content: result.feedback,
124
- timestamp: new Date(),
125
- };
126
- setMessages((prev) => [...prev, aiMsg]);
 
 
 
127
  } catch {
128
- const aiMsg: Message = {
129
- id: Date.now().toString(),
130
- role: 'ai',
131
- content: `You diagnosed: "${studentDiagnosis}". Review the case details and learning points for feedback.`,
132
- timestamp: new Date(),
133
- };
134
- setMessages((prev) => [...prev, aiMsg]);
 
 
 
135
  }
136
  };
137
 
@@ -345,10 +399,11 @@ export const CaseInterface: React.FC = () => {
345
  )}
346
  </div>
347
 
348
- {/* AI Tutor Sidebar (1/3) */}
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">AI Tutor</h3>
360
- <p className="text-xs text-text-tertiary">Socratic reasoning coach</p>
361
  </div>
362
  </div>
363
 
364
- <div className="flex-1 overflow-y-auto space-y-3 mb-4">
365
- {messages.map((msg) => (
366
- <div
367
- key={msg.id}
368
- className={`p-3 rounded-xl text-sm leading-relaxed ${
369
- msg.role === 'ai'
370
- ? 'bg-forest-green/5 border border-forest-green/10 text-text-primary'
371
- : 'bg-warm-gray-50 border border-warm-gray-100 text-text-primary ml-4'
 
 
 
 
 
 
 
 
 
 
372
  }`}
373
  >
374
- {msg.content}
375
- </div>
 
 
 
 
 
 
 
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="Type your reasoning..."
 
 
 
 
 
 
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()}