2 Commits
83 changed files with 5733 additions and 3593 deletions
@@ -0,0 +1,65 @@
# Postman Collection Import & Usage Guide
**API System**: `AI Patient Onboarding API v1.0.0`
**Environment**: Local / Staging / Production
**Collection File**: [`AI-Patient-Onboarding.postman_collection.json`](file:///c:/Users/Lenovo/New%20folder%20%282%29/ai-patient-onboarding/AI-Patient-Onboarding.postman_collection.json)
**Environment File**: [`AI-Patient-Onboarding-Local.postman_environment.json`](file:///c:/Users/Lenovo/New%20folder%20%282%29/ai-patient-onboarding/AI-Patient-Onboarding-Local.postman_environment.json)
---
## 1. Quick Start Import Instructions
1. Open **Postman Desktop Application**.
2. Click **Import** (top-left button).
3. Select both files:
- `AI-Patient-Onboarding.postman_collection.json`
- `AI-Patient-Onboarding-Local.postman_environment.json`
4. Select environment **"AI-Patient-Onboarding-Local"** from the environment dropdown in the top-right corner.
---
## 2. Collection Hierarchy
```
AI Patient Onboarding API
├── 00 - Health
│ └── Get Service Health Status (GET /health)
├── 01 - Authentication
│ └── Test Bearer Auth Token Validation
├── 02 - Patient Onboarding (Text)
│ ├── Start Text Onboarding (POST /v1/onboarding/start) -> auto-saves {{session_id}}
│ ├── Send Patient Message (POST /v1/onboarding/{{session_id}}/message)
│ └── Complete Text Onboarding (POST /v1/onboarding/{{session_id}}/complete)
├── 03 - Voice Onboarding
│ ├── Start Voice Onboarding (POST /v1/voice-onboarding/start) -> auto-saves {{voice_session_id}} & {{audio_id}}
│ ├── Voice Turn - Text Fallback (POST /v1/voice-onboarding/{{voice_session_id}}/turn)
│ ├── Complete Voice Onboarding (POST /v1/voice-onboarding/{{voice_session_id}}/complete)
│ └── Get Synthesized Audio File (GET /v1/voice-onboarding/audio/{{audio_id}})
├── 04 - Documents
│ └── Upload Patient Document (POST /v1/documents/{{session_id}}/upload)
├── 05 - Validation Tests
│ └── Reject Negative Age Input (age = -50 -> 422)
├── 06 - Authorization / IDOR Security Tests
│ └── Reject Cross-Patient IDOR Access (Patient B -> Patient A Session -> 403)
└── 07 - Security & Auth Tests
└── Reject Unauthenticated Request (Missing Token -> 401)
```
---
## 3. Dynamic Variable Chaining
The collection automatically passes state between requests using Postman Environment variables:
- Running `Start Text Onboarding` extracts `session_id` from response JSON and sets `{{session_id}}`.
- Subsequent requests (`Send Patient Message`, `Complete Text Onboarding`, `Upload Patient Document`) dynamically consume `{{session_id}}`.
- Running `Start Voice Onboarding` extracts `session_id` and `audio_id` into `{{voice_session_id}}` and `{{audio_id}}`.
---
## 4. Running via Postman Collection Runner / Newman CLI
If Newman CLI is installed locally:
```bash
newman run AI-Patient-Onboarding.postman_collection.json -e AI-Patient-Onboarding-Local.postman_environment.json
```
@@ -0,0 +1,21 @@
"""
Conversation package.
"""
from app.ai.conversation.question_engine import pick_best_question
from app.ai.conversation.completion_engine import check_required_missing, field_status, is_onboarding_complete
from app.ai.conversation.intent_tracker import get_last_ai_intent
from app.ai.conversation.response_formatter import format_conversational_response
from app.ai.conversation.next_best_action import NextBestActionEngine
from app.ai.conversation.response_generator import ResponseGenerator
__all__ = [
"pick_best_question",
"check_required_missing",
"field_status",
"is_onboarding_complete",
"get_last_ai_intent",
"format_conversational_response",
"NextBestActionEngine",
"ResponseGenerator",
]
@@ -0,0 +1,84 @@
"""
Deterministic completion evaluation engine. Single source of truth for onboarding completion.
"""
from typing import Dict, Any, List
def field_status(field_val: Any) -> str:
"""
Returns field status: 'not_collected' | 'reported' | 'confirmed_none' | 'unknown' | 'declined'
"""
if field_val is None:
return "not_collected"
if isinstance(field_val, dict):
status = field_val.get("status")
if status in ["reported", "confirmed_none", "unknown", "declined"]:
return status
items = field_val.get("items", [])
return "reported" if items else "not_collected"
if isinstance(field_val, list):
return "reported" if len(field_val) > 0 else "not_collected"
if isinstance(field_val, str) and field_val.strip():
return "reported"
return "not_collected"
def check_required_missing(state: Dict[str, Any]) -> List[str]:
"""
Single source of truth evaluating missing clinical fields for onboarding completion.
Required fields: reason_for_visit, symptoms, duration, medical_history, medications, allergies.
"""
if not isinstance(state, dict):
state = {}
missing: List[str] = []
# 1. Reason for Visit
if field_status(state.get("reason_for_visit")) == "not_collected":
missing.append("reason_for_visit")
# 2. Symptoms
symptoms = state.get("symptoms", [])
sym_status = field_status(symptoms)
if sym_status == "not_collected":
missing.append("symptoms")
# 3. Duration
has_duration = False
if isinstance(symptoms, list) and symptoms:
for s in symptoms:
if isinstance(s, dict) and s.get("duration"):
has_duration = True
break
if not has_duration and state.get("duration"):
has_duration = True
if sym_status != "not_collected" and not has_duration:
missing.append("duration")
# 4. Medical History
if field_status(state.get("medical_history")) == "not_collected":
missing.append("medical_history")
# 5. Medications
if field_status(state.get("medications")) == "not_collected":
missing.append("medications")
# 6. Allergies
if field_status(state.get("allergies")) == "not_collected":
missing.append("allergies")
return missing
def is_onboarding_complete(state: Dict[str, Any]) -> bool:
"""
Returns True ONLY if all required fields are collected or confirmed_none.
"""
missing = check_required_missing(state)
return len(missing) == 0
@@ -0,0 +1,26 @@
"""
Question intent tracker for semantic question deduplication.
"""
from typing import Optional, List, Dict, Any
from app.services.contextual_normalizer import detect_previous_question_intent
def get_last_ai_intent(conversation_history: List[Dict[str, Any]]) -> Optional[str]:
"""
Returns the intent code of the last AI message in conversation history.
"""
if not conversation_history:
return None
last_ai_msg = None
for turn in reversed(conversation_history):
if turn.get("role") in ["assistant", "ai"]:
last_ai_msg = turn.get("message") or turn.get("content")
break
if last_ai_msg:
return detect_previous_question_intent(last_ai_msg)
return None
@@ -0,0 +1,98 @@
"""
NextBestActionEngine (Rules 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91):
- Conversational State Machine Evaluation
- Priority Scoring & Action Selection
- Cooldown & Intent Deduplication
- Status-aware field tracking (reported, confirmed_none, unknown, declined, skipped, not_asked)
"""
from typing import Dict, Any, List, Optional, Tuple
class NextBestActionEngine:
"""
Evaluates intake state and determines the next-best action and target question.
"""
def calculate_priority_score(
self,
field: str,
current_state: Dict[str, Any],
is_psych: bool = False,
asked_intents: Optional[List[str]] = None
) -> float:
"""
Calculates question priority score based on Rule 83:
priority = safety_weight + clinical_importance + symptom_completeness
+ specialty_relevance + information_value
- repetition_penalty - already_answered_penalty - unnecessary_question_penalty
"""
score = 0.0
asked_intents = asked_intents or []
weights = {
"emergency": 100.0,
"crisis": 100.0,
"reason_for_visit": 50.0,
"symptoms": 50.0,
"duration": 45.0,
"severity": 30.0,
"medical_history": 35.0,
"medications": 30.0,
"allergies": 25.0,
"previous_episodes": 15.0,
"family_history": 10.0,
"additional_information": 5.0,
}
score += weights.get(field, 10.0)
# Repetition penalty (Rule 84 & 85)
if field in asked_intents:
score -= 60.0
# Specialty relevance bonus
if is_psych and field in ["duration", "medical_history"]:
score += 10.0
return score
def determine_next_action(
self,
current_state: Dict[str, Any],
is_emergency: bool = False,
is_crisis: bool = False,
is_psych: bool = False,
missing_fields: Optional[List[str]] = None
) -> Tuple[str, Optional[str]]:
"""
Returns (action_type, target_field)
Action types (Rule 82):
- SAFETY_ACTION
- CLARIFY
- ASK_QUESTION
- COMPLETE
- HANDLE_REFUSAL
- HANDLE_UNKNOWN
"""
if is_crisis or is_emergency:
return "SAFETY_ACTION", None
# Check for state conflicts requiring clarification (Rule 67)
if current_state and current_state.get("conflicts"):
return "CLARIFY", current_state["conflicts"][-1].get("field")
missing_fields = missing_fields or []
if not missing_fields:
return "COMPLETE", None
# Score missing fields using priority score (Rule 83)
scored_fields = []
for field in missing_fields:
score = self.calculate_priority_score(field, current_state, is_psych=is_psych)
scored_fields.append((score, field))
scored_fields.sort(key=lambda x: x[0], reverse=True)
best_field = scored_fields[0][1]
return "ASK_QUESTION", best_field
@@ -0,0 +1,168 @@
"""
Priority-based Question Engine. Selects the single highest priority missing question.
"""
from typing import List, Optional, Dict, Any
def pick_best_question(
missing_list: List[str],
lang: str,
is_psych: bool = False,
current_state: Optional[Dict[str, Any]] = None
) -> str:
"""
Picks the single highest priority missing question for onboarding.
Enforces Rule 51 & 52: Short, natural, 1-2 sentences, max 15-20 words.
Enforces Rule 67: Asks short clarification if conflict detected in state.
"""
if isinstance(current_state, dict) and current_state.get("conflicts"):
conflict = current_state["conflicts"][-1]
item = conflict.get("item", "this condition")
if isinstance(item, dict):
item = item.get("condition") or item.get("name") or "this condition"
lang_clean = lang.lower() if lang else "en"
if lang_clean.startswith("ta"):
return f"உறுதிப்படுத்த கேட்கிறேன், உங்களுக்கு {item} இருக்கா?"
elif lang_clean.startswith("hi"):
return f"पुष्टि के लिए, क्या आपको {item} की समस्या है?"
else:
return f"Just to confirm, do you currently have {item}?"
if not missing_list:
return _build_completion_reply(lang)
field = missing_list[0]
has_meds = "medications" in missing_list
has_algs = "allergies" in missing_list
has_history = "medical_history" in missing_list
combine_meds_algs = (field == "medications" and has_algs) or (field == "allergies" and has_meds)
combine_history_meds = (field == "medical_history" and has_meds and not has_algs)
symptom_name = None
if isinstance(current_state, dict):
rfv = current_state.get("reason_for_visit")
syms = current_state.get("symptoms", [])
if rfv and isinstance(rfv, str) and rfv.strip():
symptom_name = rfv.strip()
elif syms and isinstance(syms, list) and len(syms) > 0 and isinstance(syms[0], dict) and syms[0].get("name"):
symptom_name = syms[0].get("name").strip()
lang_clean = lang.lower() if lang else "en"
# Tamil
if lang_clean.startswith("ta"):
if field in ["reason_for_visit", "symptoms"]:
return "வணக்கம், உங்களுக்கு என்ன பிரச்சனை இருக்கு?"
elif field == "duration":
if is_psych:
return "இந்த மன அழுத்தம் எப்போலிருந்து இருக்கு?"
if symptom_name:
return f"இந்த {symptom_name} எத்தனை நாளா இருக்கு?"
return "இந்த பிரச்சனை எத்தனை நாளா இருக்கு?"
elif field == "medical_history":
if is_psych:
return "முன்னாடி இதுக்கு சிகிச்சை எடுத்திருக்கீங்களா?"
if symptom_name:
return f"{symptom_name} தவிர பிபி, சுகர் போன்ற பிரச்சனை இருக்கா?"
return "உங்களுக்கு பிபி, சுகர் போன்ற வேறு உடம்பு பிரச்சனை இருக்கா?"
elif field == "medications":
return "வழக்கமா ஏதேனும் மாத்திரை சாப்பிடுறீங்களா?"
elif field == "allergies":
return "உங்களுக்கு ஏதேனும் அலர்ஜி இருக்கா?"
# Hindi
elif lang_clean.startswith("hi"):
if field in ["reason_for_visit", "symptoms"]:
return "नमस्ते, आपको क्या तकलीफ हो रही है?"
elif field == "duration":
if symptom_name:
return f"यह {symptom_name} कितने दिनों से है?"
return "यह तकलीफ कितने दिनों से हो रही है?"
elif field == "medical_history":
if symptom_name:
return f"{symptom_name} के अलावा क्या बीपी या शुगर जैसी बीमारी है?"
return "क्या आपको बीपी या शुगर जैसी कोई बीमारी है?"
elif field == "medications":
return "क्या आप नियमित रूप से कोई दवाई लेते हैं?"
elif field == "allergies":
return "क्या आपको किसी दवाई से एलर्जी है?"
# Telugu
elif lang_clean.startswith("te"):
if field in ["reason_for_visit", "symptoms"]:
return "నమస్కారం, మీ ఆరోగ్య సమస్య ఏమిటి?"
elif field == "duration":
if symptom_name:
return f"ఈ {symptom_name} ఎన్ని రోజుల నుండి ఉంది?"
return "ఈ ఇబ్బంది ఎన్ని రోజుల నుండి ఉంది?"
elif field == "medical_history":
if symptom_name:
return f"అర్థమైంది, {symptom_name} కాకుండా బీపీ లేదా షుగర్ ఉందా?"
return "మీకు బీపీ లేదా షుగర్ వంటి సమస్యలు ఉన్నాయా?"
elif field == "medications":
return "మీరు రోజూ ఏమైనా మందులు వాడుతున్నారా?"
elif field == "allergies":
return "మీకు ఏదైనా మందుల అలర్జీ ఉందా?"
# Kannada
elif lang_clean.startswith("kn"):
if field in ["reason_for_visit", "symptoms"]:
return "ನಮಸ್ಕಾರ, ನಿಮ್ಮ સમસ્યા ಏನು?"
elif field == "duration":
if symptom_name:
return f"ಈ {symptom_name} ಎಷ್ಟು ದಿನದಿಂದ ಇದೆ?"
return "ಈ ತೊಂದರೆ ಎಷ್ಟು ದಿನದಿಂದ ಇದೆ?"
elif field == "medical_history":
if symptom_name:
return f"{symptom_name} ಹೊರತುಪಡಿಸಿ ಬಿಪಿ ಅಥವಾ ಶುಗರ್ ಇದೆಯಾ?"
return "ನಿಮಗೆ ಬಿಪಿ ಅಥವಾ ಶುಗರ್ ಇದೆಯಾ?"
elif field == "medications":
return "ನೀವು ಯಾವುದೇ ಮಾತ್ರೆಗಳನ್ನು ತೆಗೆದುಕೊಳ್ಳುತ್ತಿದ್ದೀರಾ?"
elif field == "allergies":
return "ನಿಮಗೆ ಯಾವುದೇ ಅಲರ್ಜಿ ಇದೆಯಾ?"
# English Default (Rule 51 & 52: Short, natural, <15-20 words)
if field in ["reason_for_visit", "symptoms"]:
return "Hello, what symptoms are you experiencing today?"
elif field == "duration":
if symptom_name:
return f"Got it. How long have you had this {symptom_name}?"
return "Got it. How long have you had these symptoms?"
elif combine_history_meds:
if symptom_name:
return f"Apart from {symptom_name}, do you have BP, diabetes, or take regular medicines?"
return "Do you have any past health conditions or take regular medicines?"
elif combine_meds_algs:
if symptom_name:
return f"Are you taking regular medications for {symptom_name}, or do you have any allergies?"
return "Are you currently taking any regular medications, or do you have any allergies?"
elif field == "medical_history":
if is_psych:
return "Have you received treatment or counseling for this before?"
if symptom_name:
return f"Apart from {symptom_name}, do you have BP, diabetes, or other chronic conditions?"
return "Do you have a history of BP, diabetes, or other health conditions?"
elif field == "medications":
if symptom_name:
return f"Are you taking any daily medicines for {symptom_name} or other conditions?"
return "Are you currently taking any regular daily medications?"
elif field == "allergies":
return "Do you have any medicine or food allergies?"
return "Could you share any other details about your symptoms?"
def _build_completion_reply(lang: str) -> str:
lang_clean = lang.lower() if lang else "en"
if lang_clean.startswith("ta"):
return "நன்றி. உங்கள் விவரங்கள் பதிவு செய்யப்பட்டன."
elif lang_clean.startswith("hi"):
return "ठीक है, आपकी जानकारी दर्ज हो गई है।"
elif lang_clean.startswith("te"):
return "ధన్యవాదాలు, మీ వివరాలు నమోదు చేయబడ్డాయి."
elif lang_clean.startswith("kn"):
return "ಧನ್ಯವಾದಗಳು, ನಿಮ್ಮ ವಿವರಗಳನ್ನು ದಾಖಲಿಸಲಾಗಿದೆ."
else:
return "Thanks. Your onboarding is complete."
@@ -0,0 +1,57 @@
"""
Deterministic Question Templates (Rule 107 & 108):
Fallback question templates mapped to semantic QuestionIntent across supported languages.
"""
from typing import Dict, List
from app.domain.models.contracts import QuestionIntent
QUESTION_TEMPLATES: Dict[str, Dict[QuestionIntent, List[str]]] = {
"en": {
QuestionIntent.SYMPTOM_NAME: ["What symptoms are you experiencing today?"],
QuestionIntent.SYMPTOM_DURATION: ["How long has it been?", "When did this start?"],
QuestionIntent.SYMPTOM_SEVERITY: ["How bad is the pain?", "How severe is it?"],
QuestionIntent.SYMPTOM_LOCATION: ["Where exactly does it hurt?"],
QuestionIntent.MEDICAL_HISTORY: ["Do you have BP, diabetes, or other chronic conditions?"],
QuestionIntent.MEDICATIONS: ["Are you currently taking any regular daily medications?"],
QuestionIntent.ALLERGIES: ["Do you have any medicine or food allergies?"],
},
"ta": {
QuestionIntent.SYMPTOM_NAME: ["வணக்கம், உங்களுக்கு என்ன பிரச்சனை இருக்கு?"],
QuestionIntent.SYMPTOM_DURATION: ["இந்த பிரச்சனை எத்தனை நாளா இருக்கு?", "இது எப்போலிருந்து இருக்கு?"],
QuestionIntent.SYMPTOM_SEVERITY: ["வலி எவ்வளவு தீவிரமா இருக்கு?"],
QuestionIntent.SYMPTOM_LOCATION: ["எங்க வலிக்குது?"],
QuestionIntent.MEDICAL_HISTORY: ["உங்களுக்கு பிபி, சுகர் போன்ற வேறு உடம்பு பிரச்சனை இருக்கா?"],
QuestionIntent.MEDICATIONS: ["வழக்கமா ஏதேனும் மாத்திரை சாப்பிடுறீங்களா?"],
QuestionIntent.ALLERGIES: ["உங்களுக்கு ஏதேனும் அலர்ஜி இருக்கா?"],
},
"hi": {
QuestionIntent.SYMPTOM_NAME: ["नमस्ते, आपको क्या तकलीफ हो रही है?"],
QuestionIntent.SYMPTOM_DURATION: ["यह तकलीफ कितने दिनों से हो रही है?", "यह दर्द कब शुरू हुआ?"],
QuestionIntent.SYMPTOM_SEVERITY: ["दर्द कितना तेज है?"],
QuestionIntent.SYMPTOM_LOCATION: ["दर्द कहाँ हो रहा है?"],
QuestionIntent.MEDICAL_HISTORY: ["क्या आपको बीपी या शुगर जैसी कोई बीमारी है?"],
QuestionIntent.MEDICATIONS: ["क्या आप नियमित रूप से कोई दवाई लेते हैं?"],
QuestionIntent.ALLERGIES: ["क्या आपको किसी दवाई से एलर्जी है?"],
},
"te": {
QuestionIntent.SYMPTOM_NAME: ["నమస్కారం, మీకు ఏమి సమస్య ఉంది?"],
QuestionIntent.SYMPTOM_DURATION: ["ఈ సమస్య ఎంతకాలంగా ఉంది?", "ఈ నొప్పి ఎప్పుడు మొదలైంది?"],
QuestionIntent.SYMPTOM_SEVERITY: ["నొప్పి ఎంత తీవ్రంగా ఉంది?"],
QuestionIntent.SYMPTOM_LOCATION: ["నొప్పి ఎక్కడ ఉంది?"],
QuestionIntent.MEDICAL_HISTORY: ["మీకు బిపి, షుగర్ వంటి ఇతర ఆరోగ్య సమస్యలు ఉన్నాయా?"],
QuestionIntent.MEDICATIONS: ["మీరు క్రమంగా ఏవైనా మందులు వాడుతున్నారా?"],
QuestionIntent.ALLERGIES: ["మీకు ఏవైనా మందులు లేదా ఆహార అలెర్జీలు ఉన్నాయా?"],
},
"kn": {
QuestionIntent.SYMPTOM_NAME: ["ನಮಸ್ಕಾರ, ನಿಮಗೆ ಏನು ತೊಂದರೆಯಾಗಿದೆ?"],
QuestionIntent.SYMPTOM_DURATION: ["ಈ ತೊಂದರೆ ಎಷ್ಟು ದಿನಗಳಿಂದ ಇದೆ?", "ಈ ನೋವು ಯಾವಾಗ ಶುರುವಾಯಿತು?"],
QuestionIntent.SYMPTOM_SEVERITY: ["ನೋವು ಎಷ್ಟು ತೀವ್ರವಾಗಿದೆ?"],
QuestionIntent.SYMPTOM_LOCATION: ["ನೋವು ಎಲ್ಲಿದೆ?"],
QuestionIntent.MEDICAL_HISTORY: ["ನಿಮಗೆ ಬಿಪಿ, ಸಕ್ಕರೆ ಕಾಯಿಲೆ ಮುಂತಾದ ಇತರ ತೊಂದರೆಗಳಿವೆಯೇ?"],
QuestionIntent.MEDICATIONS: ["ನೀವು ನಿಯಮಿತವಾಗಿ ಯಾವುದೇ ಮಾತ್ರೆಗಳನ್ನು ತೆಗೆದುಕೊಳ್ಳುತ್ತಿದ್ದೀರಾ?"],
QuestionIntent.ALLERGIES: ["ನಿಮಗೆ ಯಾವುದೇ ಔಷಧಿ ಅಥವಾ ಆಹಾರ ಅಲರ್ಜಿಗಳಿವೆಯೇ?"],
}
}
@@ -0,0 +1,169 @@
"""
Response formatter enforcing Rules 53-60:
- Generate response after state processing.
- One Question Only (Rule 54).
- Acknowledge + Ask pattern (Rule 55).
- No unnecessary confirmation / No robotic language (Rules 56 & 57).
- No medical explanations (Rule 58).
- Natural conversation with short answers (Rule 59).
- Response length enforcement: 5-15 words target, max 20 words (Rule 60).
"""
import re
from typing import Optional
ROBOTIC_PHRASES = [
r"i\s+have\s+(?:recorded|noted|documented)(?:\s+that|\s+this)?\.?",
r"thank\s+you\s+for\s+(?:sharing|providing|the)\s+(?:that\s+|this\s+)?(?:information|details|response)?\.?",
r"i\s+understand\s+your\s+response\.?",
r"please\s+provide\s+(?:the\s+)?(?:duration|severity|location|additional|details)",
r"kindly\s+specify\s+(?:the\s+)?(?:duration|severity|location)",
r"your\s+response\s+has\s+been\s+recorded\.?",
r"your\s+response\s+indicates\s+that[^.!?]+[.!?]",
]
DIAGNOSTIC_PATTERNS = [
r"this\s+could\s+(?:indicate|suggest|be)\s+[^.!?]+[.!?]",
r"possible\s+causes\s+include\s+[^.!?]+[.!?]",
]
FORM_READBACK_PATTERNS = [
r"i\s+understand\s+(?:that\s+)?you\s+(?:have|are|were|take|have\s+been|suffered)[^.!?]+[.!?]",
r"you\s+mentioned\s+(?:that\s+)?you\s+(?:have|are|were|take|have\s+been)[^.!?]+[.!?]",
r"i\s+see\s+that\s+you\s+(?:have|are|were|take|have\s+been)[^.!?]+[.!?]",
]
DISCLAIMER_PATTERNS = [
r"as\s+an\s+ai\s+(?:language\s+model|assistant|clinical\s+intake)[^.!?]*[.!?]?",
r"please\s+note\s+that\s+i\s+cannot\s+provide\s+medical\s+advice[^.!?]*[.!?]?",
r"i\s+am\s+not\s+a\s+doctor[^.!?]*[.!?]?",
]
def deduplicate_sentences(text: str) -> str:
"""Removes duplicate consecutive or identical sentences."""
if not text:
return text
sentences = [s.strip() for s in re.split(r"(?<=[.!?])\s+", text) if s.strip()]
seen = set()
unique = []
for s in sentences:
s_norm = s.lower()
if s_norm not in seen:
seen.add(s_norm)
unique.append(s)
return " ".join(unique)
def sanitize_final_response(raw_reply: str, lang: str = "en", scenario: str = "normal") -> str:
"""
Final response sanitization pipeline (Rule 70):
Generated Response
↓ Remove duplicate sentences
↓ Remove form readbacks (Rule 63) & diagnostic/robotic text (Rule 61)
↓ Remove multiple questions (Rule 54)
↓ Enforce dynamic response length (Rule 62)
↓ Return short natural response
"""
if not raw_reply or not raw_reply.strip():
if scenario in ["emergency", "crisis"]:
return "Please seek emergency medical evaluation immediately or call local emergency services."
if scenario == "completion":
return "Thank you. Your intake information is registered for the doctor."
return "Got it. What symptoms are you having?"
# Emergency / Crisis scenario (Rule 62): Return concise safety instruction without trimming
if scenario in ["emergency", "crisis"]:
text = raw_reply.strip()
text = re.sub(r"\s+", " ", text)
return text
text = raw_reply.strip()
# 1. Strip disclaimers (Rule 61)
for disc in DISCLAIMER_PATTERNS:
text = re.sub(disc, "", text, flags=re.IGNORECASE)
# 2. Strip diagnostic explanations (Rule 58 & 61)
for diag in DIAGNOSTIC_PATTERNS:
text = re.sub(diag, "", text, flags=re.IGNORECASE)
# 3. Strip form readbacks (Rule 63)
for readback in FORM_READBACK_PATTERNS:
text = re.sub(readback, "", text, flags=re.IGNORECASE)
# 4. Strip robotic confirmation phrases (Rule 56, 57, 64)
for rob in ROBOTIC_PHRASES:
text = re.sub(rob, "", text, flags=re.IGNORECASE)
text = re.sub(r"\s+", " ", text).strip()
if not text:
if scenario == "completion":
return "Thank you. Your intake information is registered for the doctor."
return "Got it. When did it start?"
# 5. Deduplicate sentences (Rule 70)
text = deduplicate_sentences(text)
# 6. Enforce ONE question only (Rule 54 & 70)
if text.count("?") > 1:
q_parts = text.split("?")
text = q_parts[0].strip() + "?"
# Completion scenario (Rule 62)
if scenario == "completion":
words = text.split()
if len(words) > 15:
text = " ".join(words[:12]) + "."
return text
# 7. Enforce max 1-2 short sentences (Rule 55, 60, 62)
sentences = [s.strip() for s in re.split(r"(?<=[.!?])\s+", text) if s.strip()]
if len(sentences) > 2:
q_idx = next((i for i, s in enumerate(sentences) if "?" in s), -1)
if q_idx != -1:
if q_idx > 0:
sentences = [sentences[0], sentences[q_idx]]
else:
sentences = [sentences[0]]
else:
sentences = sentences[:2]
text = " ".join(sentences)
# 8. Dynamic length enforcement: 5-15 words (max 20 words)
words = text.split()
if len(words) > 20:
if "?" in text:
q_sent = next((s for s in sentences if "?" in s), None)
ack_sent = next((s for s in sentences if "?" not in s), None)
if q_sent:
q_words = q_sent.split()
if len(q_words) > 20:
clean_words = [re.sub(r"[.!?]+$", "", w) for w in q_words[:18]]
text = " ".join(clean_words) + "...?"
elif ack_sent:
ack_words = ack_sent.split()
if len(ack_words) + len(q_words) <= 20:
text = f"{ack_sent} {q_sent}"
else:
candidate = f"Got it. {q_sent}"
if len(candidate.split()) <= 20:
text = candidate
else:
text = q_sent
else:
text = q_sent
else:
clean_words = [re.sub(r"[.!?]+$", "", w) for w in words[:18]]
text = " ".join(clean_words) + "..."
return text
def format_conversational_response(raw_reply: str, lang: str = "en", scenario: str = "normal") -> str:
"""Wrapper for final response sanitization pipeline (Rule 70)."""
return sanitize_final_response(raw_reply, lang=lang, scenario=scenario)
@@ -0,0 +1,40 @@
"""
ResponseGenerator (Rules 101, 102, 103, 104, 109, 110, 111, 112, 113, 114, 115):
Dedicated Response Generator pipeline consuming ResponseContext and returning ResponseResult.
- Enforces MAX_RESPONSE_WORDS = 20 (target 5-15 words).
- Validates single question limit (0 or 1 question).
- Calculates Response Quality Score.
- Prevents internal data leakage & resists prompt injection.
"""
from typing import List, Optional
from app.domain.models.contracts import ResponseContext, ResponseResult, ResponseType, QuestionIntent
from app.ai.conversation.response_formatter import sanitize_final_response
class ResponseGenerator:
"""Dedicated response generator executing the final response pipeline."""
def generate(self, ctx: ResponseContext) -> ResponseResult:
raw_text = ctx.selected_question or "Got it. When did it start?"
scenario = "normal"
resp_type = ResponseType.FOLLOW_UP
if ctx.safety_status in ["crisis"]:
scenario = "crisis"
resp_type = ResponseType.CRISIS
elif ctx.safety_status in ["emergency"]:
scenario = "emergency"
resp_type = ResponseType.SAFETY
elif ctx.onboarding_completed:
scenario = "completion"
resp_type = ResponseType.COMPLETION
sanitized_reply = sanitize_final_response(raw_text, lang=ctx.language, scenario=scenario)
return ResponseResult(
response=sanitized_reply,
language=ctx.language,
question_intent=ctx.question_intent.value if ctx.question_intent else None,
response_type=resp_type
)
@@ -1,106 +0,0 @@
import math
import re
import struct
from typing import Dict, Any, List, Tuple
HIGH_PAIN_PATTERNS = [
r"(?:10/10|9/10|8/10|10\s*out\s*of\s*10|9\s*out\s*of\s*10|8\s*out\s*of\s*10)",
r"(?:unbearable|excruciating|worst\s*pain|extreme\s*pain|severe\s*chest\s*pain|cannot\s*breathe|gasping|dying)",
r"(?:கடுமையான\s*வலி|தாங்க\s*முடியாத\s*வலி|நெஞ்சு\s*வலி|மூச்சு\s*திணறல்)",
r"(?:असहनीय\s*दर्द|बहुत\s*तेज़\s*दर्द|सीने\s*में\s*दर्द|साँस\s*नहीं\s*आ\s*रही)",
r"(?:చాలా\s*ఎక్కువ\s*నెప్పి|భరించలేని\s*నెప్పి|గుండె\s*నెప్పి)",
r"(?:ಅಸಹನೀಯ\s*ನೋವು|ತೀವ್ರ\s*ನೋವು)"
]
MODERATE_PAIN_PATTERNS = [
r"(?:7/10|6/10|5/10|moderate\s*pain|bad\s*pain|hurts\s*a\s*lot|வலி\s*அதிகமா|अत्यधिक\s*दर्द)",
]
EMERGENCY_RED_FLAGS = [
r"(?:chest\s*pain|heart\s*attack|stroke|paralysis|unconscious|fainted|heavy\s*bleeding)",
r"(?:நெஞ்சு\s*வலி|பக்கவாதம்|மயக்கம்|அதிக\s*ரத்தப்போக்கு)",
r"(?:सीने\s*में\s*दर्द|हार्ट\s*अटैक|बेहोश|रक्तस्राव)"
]
def calculate_audio_rms_energy(audio_bytes: bytes) -> float:
"""
Computes RMS (Root Mean Square) audio amplitude energy from raw mic bytes.
Returns normalized energy float between 0.0 and 1.0.
"""
if not audio_bytes or len(audio_bytes) < 100:
return 0.0
try:
# Unpack 16-bit PCM audio samples
sample_count = len(audio_bytes) // 2
if sample_count == 0:
return 0.0
fmt = f"<{sample_count}h"
samples = struct.unpack(fmt, audio_bytes[:sample_count * 2])
sum_squares = sum(s ** 2 for s in samples)
rms = math.sqrt(sum_squares / sample_count)
# Normalize 16-bit max amplitude (32768)
norm_energy = min(1.0, rms / 15000.0)
return round(norm_energy, 3)
except Exception:
return 0.0
def analyze_distress_and_pain(audio_bytes: bytes = b"", text: str = "") -> Dict[str, Any]:
"""
Acoustic & Semantic Distress Classifier:
Analyzes spoken text and audio RMS energy to compute Pain Score (1-10) and Distress Score (0.0-1.0).
"""
txt_lower = (text or "").lower()
pain_score = 3
distress_score = 0.2
is_emergency = False
reasons: List[str] = []
# 1. Semantic High Pain Detection
for pat in HIGH_PAIN_PATTERNS:
if re.search(pat, txt_lower, re.IGNORECASE):
pain_score = max(pain_score, 9)
distress_score = max(distress_score, 0.85)
reasons.append("Severe high pain expression detected")
break
# 2. Semantic Moderate Pain Detection
if pain_score < 8:
for pat in MODERATE_PAIN_PATTERNS:
if re.search(pat, txt_lower, re.IGNORECASE):
pain_score = max(pain_score, 6)
distress_score = max(distress_score, 0.55)
reasons.append("Moderate pain expression detected")
break
# 3. Emergency Red Flag Detection
for pat in EMERGENCY_RED_FLAGS:
if re.search(pat, txt_lower, re.IGNORECASE):
is_emergency = True
pain_score = max(pain_score, 9)
distress_score = max(distress_score, 0.90)
reasons.append("Critical emergency red flag detected")
break
# 4. Acoustic Audio RMS Energy Boost
acoustic_energy = calculate_audio_rms_energy(audio_bytes)
if acoustic_energy > 0.6:
distress_score = min(1.0, distress_score + 0.15)
reasons.append(f"High vocal acoustic energy detected (RMS: {acoustic_energy})")
should_escalate = pain_score >= 8 or distress_score >= 0.75 or is_emergency
return {
"pain_score": pain_score,
"distress_score": round(distress_score, 2),
"acoustic_rms_energy": acoustic_energy,
"is_emergency": is_emergency,
"should_escalate": should_escalate,
"reasons": reasons
}
@@ -1,115 +0,0 @@
import re
from typing import Dict, Any, List
# RxNorm / FDA Clinical Interaction Rules
KNOWN_DRUG_INTERACTIONS = [
{
"pair": ("warfarin", "aspirin"),
"severity": "CRITICAL",
"category": "Drug-Drug Interaction",
"description": "Concurrent use of Warfarin and Aspirin significantly increases risk of major gastrointestinal and systemic bleeding."
},
{
"pair": ("metformin", "ibuprofen"),
"severity": "WARNING",
"category": "Drug-Drug Interaction",
"description": "NSAIDs like Ibuprofen may impair renal function, increasing risk of Metformin-associated lactic acidosis."
},
{
"pair": ("lisinopril", "spironolactone"),
"severity": "WARNING",
"category": "Drug-Drug Interaction",
"description": "Combining ACE inhibitors with Potassium-sparing diuretics increases risk of severe hyperkalemia."
},
{
"pair": ("ssri", "tramadol"),
"severity": "CRITICAL",
"category": "Drug-Drug Interaction",
"description": "Combining SSRI antidepressants with Tramadol increases risk of Serotonin Syndrome and seizures."
}
]
# Drug-Allergy Cross-Reactivity Rules
ALLERGY_CROSS_REACTIVITY = [
{
"allergy_pattern": r"penicillin|amoxicillin|ampicillin",
"drug_pattern": r"penicillin|amoxicillin|ampicillin|augmentin|piperacillin",
"severity": "CRITICAL",
"category": "Drug-Allergy Contraindication",
"description": "Patient has reported Penicillin allergy. Prescribing beta-lactam antibiotics poses high risk of severe anaphylaxis."
},
{
"allergy_pattern": r"sulfa|sulfonamide",
"drug_pattern": r"bactrim|septra|celecoxib|sulfamethoxazole",
"severity": "WARNING",
"category": "Drug-Allergy Contraindication",
"description": "Patient has reported Sulfa allergy. Use caution with sulfonamide medications."
},
{
"allergy_pattern": r"aspirin|nsaid",
"drug_pattern": r"aspirin|ibuprofen|naproxen|diclofenac",
"severity": "CRITICAL",
"category": "Drug-Allergy Contraindication",
"description": "Patient has reported NSAID/Aspirin hypersensitivity. Risk of acute bronchospasm and urticaria."
}
]
def check_drug_safety(medications: List[Any], allergies: List[Any], medical_history: List[Any] = None) -> List[Dict[str, Any]]:
"""
Cross-references patient medications, reported allergies, and past history
against RxNorm / FDA rules to flag drug-drug interactions and drug-allergy contraindications.
"""
warnings: List[Dict[str, Any]] = []
# Extract medication names
med_names: List[str] = []
if isinstance(medications, list):
for m in medications:
if isinstance(m, dict) and m.get("name"):
med_names.append(m["name"].lower().strip())
elif isinstance(m, str):
med_names.append(m.lower().strip())
# Extract allergy names
alg_names: List[str] = []
if isinstance(allergies, list):
for a in allergies:
if isinstance(a, dict) and a.get("name"):
alg_names.append(a["name"].lower().strip())
elif isinstance(a, str):
alg_names.append(a.lower().strip())
# 1. Check Drug-Drug Interactions
for rule in KNOWN_DRUG_INTERACTIONS:
d1, d2 = rule["pair"]
d1_found = any(d1 in m for m in med_names)
d2_found = any(d2 in m for m in med_names)
if d1_found and d2_found:
warnings.append({
"severity": rule["severity"],
"category": rule["category"],
"trigger_drugs": [d1.capitalize(), d2.capitalize()],
"description": rule["description"]
})
# 2. Check Drug-Allergy Contraindications
for rule in ALLERGY_CROSS_REACTIVITY:
alg_pattern = rule["allergy_pattern"]
drug_pattern = rule["drug_pattern"]
alg_match = any(re.search(alg_pattern, alg, re.IGNORECASE) for alg in alg_names)
drug_match = any(re.search(drug_pattern, med, re.IGNORECASE) for med in med_names)
if alg_match and drug_match:
warnings.append({
"severity": rule["severity"],
"category": rule["category"],
"trigger_allergy": alg_pattern.capitalize(),
"trigger_drug": drug_pattern.capitalize(),
"description": rule["description"]
})
return warnings
@@ -0,0 +1,21 @@
"""
Extraction package.
"""
from app.ai.extraction.multilingual_normalizer import normalize_text, detect_language
from app.ai.extraction.symptom_extractor import extract_symptoms
from app.ai.extraction.medication_extractor import extract_medications
from app.ai.extraction.allergy_extractor import extract_allergies
from app.ai.extraction.history_extractor import extract_history
from app.ai.extraction.negation_engine import is_negated, extract_family_subject
__all__ = [
"normalize_text",
"detect_language",
"extract_symptoms",
"extract_medications",
"extract_allergies",
"extract_history",
"is_negated",
"extract_family_subject",
]
@@ -0,0 +1,56 @@
"""
Allergy extraction engine distinguishing drug/food allergies from side effects.
"""
import re
from typing import Dict, Any, List, Optional, Union
ALLERGEN_PATTERNS = [
(r"(penicillin)", "Penicillin"),
(r"(sulfa)", "Sulfa drugs"),
(r"(aspirin)", "Aspirin"),
(r"(dust)", "Dust allergy"),
(r"(peanuts)", "Peanuts"),
(r"(milk|dairy)", "Milk / Dairy"),
]
def extract_allergies(
message: str,
normalized_msg: str,
last_ai_message: Optional[str] = None
) -> Optional[Union[List[Dict[str, Any]], Dict[str, Any]]]:
"""
Extracts structured allergies or confirmed_none container.
"""
msg_lower = normalized_msg.lower()
# Check for explicit negation of allergies
neg_phrases = [
"no allergies", "no allergy", "no drug allergy", "no food allergy", "not allergic",
"nkda", "no allergic reaction", "அலர்ஜி எதுவும் இல்லை", "எந்த அலர்ஜியும் இல்லை",
"कोई एलर्जी नहीं", "कोई समस्या नहीं", "ఏ అలర్జీ లేదు", "ಯಾವ ಅಲರ್ಜಿ ಇಲ್ಲ"
]
if any(neg in msg_lower for neg in neg_phrases):
return {"status": "confirmed_none", "items": []}
allergies: List[Dict[str, Any]] = []
has_allergen = any(re.search(pat, msg_lower) for pat, _ in ALLERGEN_PATTERNS)
has_alg_indicator = any(k in msg_lower or k in message for k in ["allergic", "allergy", "அலர்ஜி", "ஒவ்வாமை", "एलर्जी", "rash", "reaction", "side effect", "தடிப்பு", "எடுத்தா"])
# Side effect distinction: "Paracetamol didn't reduce fever" is NOT an allergy
if "didn't reduce" in msg_lower or "didn't work" in msg_lower or "no effect" in msg_lower:
return None
if has_allergen or has_alg_indicator:
for pat, allergen in ALLERGEN_PATTERNS:
if re.search(pat, msg_lower):
alg_obj: Dict[str, Any] = {"name": allergen}
if "rash" in msg_lower or "தடிப்பு" in message or "खुजली" in message:
alg_obj["reaction"] = "Rash / Skin reaction"
allergies.append(alg_obj)
if not allergies and has_alg_indicator:
allergies.append({"name": "Reported allergy", "type": "unspecified"})
return allergies if allergies else None
@@ -0,0 +1,83 @@
"""
Medical, surgical, family history, and previous episode extraction engine.
"""
import re
from typing import Dict, Any, List, Optional, Tuple, Union
from app.ai.extraction.negation_engine import extract_family_subject
HISTORY_KEYWORDS = [
("diabetes", "Diabetes"), ("sugar", "Diabetes"), ("diabetic", "Diabetes"),
("hypertension", "Hypertension (High BP)"), ("high bp", "Hypertension (High BP)"), ("bp", "Hypertension (High BP)"),
("asthma", "Asthma"), ("thyroid", "Thyroid disorder"),
("சர்க்கரை நோய்", "Diabetes"), ("சுகர்", "Diabetes"), ("பிபி", "Hypertension"),
("डायबिटीज", "Diabetes"), ("शुगर", "Diabetes"), ("बीपी", "Hypertension"),
]
def extract_history(
message: str,
normalized_msg: str,
duration: Optional[str] = None
) -> Dict[str, Any]:
"""
Extracts medical_history, family_history, surgical_history, previous_episodes, and additional_information.
"""
msg_lower = normalized_msg.lower()
updates: Dict[str, Any] = {}
# Check for explicit negation of medical history for self
neg_history_phrases = [
"no past history", "no medical history", "no health conditions", "no bp no diabetes",
"no chronic illness", "don't have diabetes", "no diabetes", "no bp",
"எந்த நோயும் இல்லை", "வேற ஒன்னும் இல்லை", "பிபி சுகர் எதுவும் இல்ல",
"कोई बीमारी नहीं", "बीमारी नहीं है", "ఏ సమస్య లేదు", "ಯಾವ ಕಾಯಿಲೆ ಇಲ್ಲ"
]
# Check if patient mentions family member (e.g., "my father has diabetes")
is_fam, relation, _ = extract_family_subject(message)
if is_fam:
fam_list: List[Dict[str, Any]] = []
for kw, cond in HISTORY_KEYWORDS:
if re.search(rf"\b{re.escape(kw)}\b", msg_lower) or kw in message:
fam_list.append({"relation": relation, "condition": cond})
if fam_list:
updates["family_history"] = fam_list
if any(neg in msg_lower for neg in neg_history_phrases):
updates["medical_history"] = {"status": "confirmed_none", "items": []}
elif not is_fam:
med_history: List[Dict[str, Any]] = []
for kw, cond in HISTORY_KEYWORDS:
if re.search(rf"\b{re.escape(kw)}\b", msg_lower) or kw in message:
h_obj: Dict[str, Any] = {"condition": cond}
h_dur_m = re.search(r"(?:for|since)\s+(\d+\s+(?:years|months|yrs))", msg_lower)
if h_dur_m:
h_obj["duration"] = h_dur_m.group(1).strip()
if not any(x.get("condition") == cond for x in med_history):
med_history.append(h_obj)
if med_history:
updates["medical_history"] = med_history
# Surgical History & Previous Episodes
if "surgery" in msg_lower or "operation" in msg_lower or "ஆபரேஷன்" in message:
updates["surgical_history"] = [{"procedure": "Prior surgery reported", "approximate_date": duration or "Past"}]
if "same problem" in msg_lower or "had this before" in msg_lower or "மூன்று மாதங்களுக்கு முன்" in message:
updates["previous_episodes"] = [{"description": "Similar symptoms previously", "time": duration or "Past"}]
# Additional Information / Functional Impact
add_info: List[Dict[str, Any]] = []
if "couldn't eat" in msg_lower or "not eaten" in msg_lower or "சாப்பிட முடியல" in message:
add_info.append({"category": "functional_impact", "value": "Loss of appetite / difficulty eating"})
if "couldn't sleep" in msg_lower or "haven't slept" in msg_lower or "தூங்க முடியல" in message:
add_info.append({"category": "functional_impact", "value": "Sleep disruption"})
if "weak" in msg_lower or "tired" in msg_lower or "ரொம்ப டயர்டா இருக்கு" in message:
add_info.append({"category": "functional_impact", "value": "Severe weakness / fatigue"})
if add_info:
updates["additional_information"] = add_info
return updates
@@ -0,0 +1,68 @@
"""
Medication extraction engine supporting structured dosage, frequency, and confirmed_none status.
"""
import re
from typing import Dict, Any, List, Optional, Union
KNOWN_MED_PATTERNS = [
(r"(metformin(?:\s+\d+\s*mg)?(?:\s+twice\s+a\s+day)?)", "Metformin"),
(r"(paracetamol(?:\s+\d+\s*mg)?)", "Paracetamol"),
(r"(dolo(?:\s*650)?)", "Dolo 650 (Paracetamol)"),
(r"(pantocid|pan-d|pan 40|omez|omeprazole)", "Pantoprazole / Antacid"),
(r"(telmisartan|telma\s*\d+)", "Telmisartan (BP)"),
(r"(amlodipine|amlong\s*\d+)", "Amlodipine (BP)"),
(r"((?:bp|பிபி|blood\s*pressure)\s*(?:மாத்திரை|medicine|tablet|tablets|மருந்து))", "BP Medication"),
(r"((?:sugar|சுகர்|சர்க்கரை)\s*(?:மாத்திரை|medicine|tablet|tablets|மருந்து))", "Diabetes Medication"),
(r"((?:thyroid|தைராய்டு)\s*(?:மாத்திரை|medicine|tablet|tablets|மருந்து))", "Thyroid Medication"),
]
def extract_medications(
message: str,
normalized_msg: str,
last_ai_message: Optional[str] = None,
is_allergy_utterance: bool = False
) -> Optional[Union[List[Dict[str, Any]], Dict[str, Any]]]:
"""
Extracts structured medications or confirmed_none container.
"""
msg_lower = normalized_msg.lower()
# Check for explicit negation of medicines
neg_phrases = [
"no medicines", "no medication", "don't take any medicine", "don't take medicines",
"no tablets", "not taking any medicine", "no daily medicines", "no regular medicines",
"மாத்திரை எதுவும் இல்லை", "எந்த மாத்திரையும் சாப்பிடல", "மருந்து எதுவும் இல்லை",
"कोई दवा नहीं", "दवाई नहीं लेते", "दवा नहीं लेता", "ఏ మందులు లేవు", "ಯಾವ ಮಾತ್ರೆ ಇಲ್ಲ"
]
if any(neg in msg_lower for neg in neg_phrases):
return {"status": "confirmed_none", "items": []}
meds: List[Dict[str, Any]] = []
# Known medicine matchers
for pat, med_label in KNOWN_MED_PATTERNS:
m_match = re.search(pat, msg_lower)
if m_match:
matched_str = m_match.group(1).strip()
med_obj: Dict[str, Any] = {"name": med_label}
dosage_m = re.search(r"(\d+\s*mg|\d+\s*ml)", matched_str)
if dosage_m:
med_obj["dosage"] = dosage_m.group(1).strip()
freq_m = re.search(r"(twice\s+a\s+day|once\s+a\s+day|\d+\s+times\s+a\s+day|daily|twice)", matched_str)
if freq_m:
med_obj["frequency"] = freq_m.group(1).strip()
if not any(x.get("name") == med_obj["name"] for x in meds):
meds.append(med_obj)
# Generic medicine indicator matchers (only if not an allergy utterance)
if not meds and not is_allergy_utterance:
generic_pat = r"(tablets|goli|mathirai|மாத்திரை|மருந்து|दवाई|दवा|మాత్రలు|ಮಾತ್ರೆಗಳು)"
if re.search(generic_pat, msg_lower) and any(verb in msg_lower for verb in ["take", "taking", "சாப்பிடுறேன்", "சாப்பிட்டேன்", "வாங்குறேன்", "लेता", "लेती", "వాడుతున్నాను"]):
meds.append({"name": "Unspecified medication"})
return meds if meds else None
@@ -0,0 +1,71 @@
"""
Multilingual normalization layer for clinical messages.
Preserves raw input while generating normalized, canonical representations.
"""
import re
import unicodedata
from typing import Dict, Any, Tuple
from app.services.contextual_normalizer import (
normalize_duration_from_message,
detect_previous_question_intent,
is_duration_expression,
is_severity_expression,
is_location_expression,
is_negative_expression,
is_casual_filler,
extract_severity_from_message,
extract_location_from_message,
)
def normalize_text(text: str) -> str:
"""
Cleans Unicode, strips extra spaces, standardizes quotes and punctuation.
"""
if not text:
return ""
# Unicode Normalization Form C
normalized = unicodedata.normalize("NFC", text)
# Replace non-breaking spaces and smart quotes
normalized = normalized.replace("\xa0", " ").replace("’", "'").replace("“", '"').replace("”", '"')
# Collapse multiple whitespaces
normalized = re.sub(r"\s+", " ", normalized).strip()
return normalized
def detect_language(text: str, default_lang: str = "en") -> str:
"""
Detects script/language (ta, hi, te, kn, en).
"""
if not text:
return default_lang
# Script character ranges
has_ta = bool(re.search(r"[\u0B80-\u0BFF]", text))
has_hi = bool(re.search(r"[\u0900-\u097F]", text))
has_te = bool(re.search(r"[\u0C00-\u0C7F]", text))
has_kn = bool(re.search(r"[\u0C80-\u0CFF]", text))
if has_ta:
return "ta-IN"
if has_hi:
return "hi-IN"
if has_te:
return "te-IN"
if has_kn:
return "kn-IN"
# Transliterated language keywords
msg_lower = text.lower()
if any(w in msg_lower for w in ["irukku", "thalaivali", "romba", "mudiyala", "saapudren"]):
return "ta-IN"
if any(w in msg_lower for w in ["dard", "ho raha", "hoga", "le raha"]):
return "hi-IN"
if any(w in msg_lower for w in ["noppi", "undi", "thala noppi"]):
return "te-IN"
if any(w in msg_lower for w in ["novu", "ide", "thale novu"]):
return "kn-IN"
return default_lang or "en-IN"
@@ -0,0 +1,54 @@
"""
Contextual negation and scope resolution engine.
"""
import re
from typing import Dict, Any, Tuple
# Family relations
FAMILY_RELATIONS = [
("father", "father"), ("dad", "father"), ("appa", "father"), ("அப்பா", "father"), ("पिता", "father"), ("नाना", "grandfather"),
("mother", "mother"), ("mom", "mother"), ("amma", "mother"), ("அம்மா", "mother"), ("माँ", "mother"), ("माता", "mother"),
("brother", "brother"), ("annaa", "brother"), ("தம்பி", "brother"), ("अண்ணன்", "brother"), ("भाई", "brother"),
("sister", "sister"), ("akka", "sister"), ("அக்கா", "sister"), ("தங்கை", "sister"), ("बहन", "sister"),
("parent", "parent"), ("grand-father", "grandfather"), ("grandfather", "grandfather"), ("grandmother", "grandmother"),
]
# Negation indicators
NEGATION_PHRASES = [
"don't have", "do not have", "no history of", "no symptoms", "no allergies", "no allergy", "no medicine",
"no medications", "don't take", "do not take", "never had", "without", "denies", "absent",
"இல்லை", "எதுவும் இல்லை", "இல்லவே இல்லை", "மாத்திரை எதுவும் இல்லை", "நோயும் இல்லை",
"नहीं है", "कोई बीमारी नहीं", "कोई एलर्जी नहीं", "कोई दवा नहीं",
"లేవు", "ఏమీ లేదు", "ఇబ్బంది లేదు",
"ಇಲ್ಲ", "ಯಾವುದೇ ಇಲ್ಲ",
]
def is_negated(text: str) -> bool:
"""Returns True if the text expresses explicit negation."""
if not text:
return False
msg_lower = text.lower().strip()
if msg_lower in ["no", "none", "nothing", "no more", "nil", "illness none", "nkda"]:
return True
return any(neg in msg_lower for neg in NEGATION_PHRASES)
def extract_family_subject(text: str) -> Tuple[bool, str, str]:
"""
Checks if text refers to family member rather than the patient.
Returns: (is_family_history, relation, condition_text)
"""
if not text:
return False, "", text
msg_lower = text.lower().strip()
for rel_kw, rel_canonical in FAMILY_RELATIONS:
pattern = rf"\b{re.escape(rel_kw)}\b"
if re.search(pattern, msg_lower) or rel_kw in text:
# Check if patient explicitly negates disease for self in same turn
return True, rel_canonical, text
return False, "", text
@@ -0,0 +1,117 @@
"""
Layered symptom extraction engine supporting attribute mapping per symptom.
"""
import re
from typing import Dict, Any, List, Optional, Set, Tuple
from app.services.contextual_normalizer import (
normalize_duration_from_message,
extract_severity_from_message,
extract_location_from_message,
is_duration_expression,
is_severity_expression,
is_location_expression,
is_casual_filler,
is_negative_expression,
)
SYMPTOM_DICTIONARY = [
# General & Respiratory
("fever", "fever"), ("feverish", "fever"), ("காய்ச்சல்", "fever"), ("बुखार", "fever"), ("జ్వరం", "fever"), ("ಜ್ವರ", "fever"),
("cough", "cough"), ("coughing", "cough"), ("இருமல்", "cough"), ("खांसी", "cough"), ("దగ్గు", "cough"), ("ಕೆಮ್ಮು", "cough"),
("cold", "cold"), ("sneezing", "cold"), ("சளி", "cold"), ("सर्दी", "cold"), ("జలుబు", "cold"), ("ನೆಗಡಿ", "cold"),
("breathlessness", "breathlessness"), ("shortness of breath", "breathlessness"), ("difficulty breathing", "breathlessness"),
("மூச்சு திணறல்", "breathlessness"), ("மூச்சு வாங்குது", "breathlessness"), ("மூச்சு வாங்குறேன்", "breathlessness"), ("சாப்பிடும்போது மூச்சு", "breathlessness"), ("சாப்பிடும் போது மூச்சு", "breathlessness"), ("सांस फूलना", "breathlessness"),
("palpitations", "palpitations"), ("படபடன்னு", "palpitations"), ("நெஞ்சு படபடன்னு", "palpitations"), ("படபடப்பு", "palpitations"),
# Pain & Head
("headache", "headache"), ("head ache", "headache"), ("head pain", "headache"), ("migraine", "headache"),
("தலைவலி", "headache"), ("தலை பாரம்", "headache"), ("सिरदर्द", "headache"), ("తలనొప్పి", "headache"), ("ತಲೆನೋವು", "headache"),
("chest pain", "chest pain"), ("chest pressure", "chest pain"), ("நெஞ்சு வலி", "chest pain"), ("छाती में दर्द", "chest pain"),
("stomach pain", "stomach pain"), ("abdominal pain", "stomach pain"), ("stomach ache", "stomach pain"),
("வயிற்று வலி", "stomach pain"), ("पेट दर्द", "stomach pain"), ("కడుపు నొప్పి", "stomach pain"), ("<ctrl42> ಹೊಟ್ಟೆ ನೋವು", "stomach pain"),
("back pain", "back pain"), ("முதுகு வலி", "back pain"), ("पीठ दर्द", "back pain"),
("joint pain", "joint pain"), ("knee pain", "knee pain"), ("மூட்டு வலி", "joint pain"), ("घुटने का दर्द", "knee pain"),
# Neurological / ENT
("dizziness", "dizziness"), ("giddiness", "dizziness"), ("dizzy", "dizziness"),
("தலை சுத்துது", "dizziness"), ("தலைசுற்றல்", "dizziness"), ("மயக்கம்", "dizziness"), ("चक्कर", "dizziness"),
("sore throat", "sore throat"), ("throat pain", "sore throat"), ("தொண்டை வலி", "sore throat"), ("गले में खराश", "sore throat"),
# GI
("vomiting", "vomiting"), ("vomit", "vomiting"), ("vomited", "vomiting"), ("nausea", "vomiting"),
("வாந்தி", "vomiting"), ("உल्टी", "vomiting"), ("వాంతులు", "vomiting"), ("ವಾಂತಿ", "vomiting"),
("loose motion", "diarrhea"), ("diarrhea", "diarrhea"), ("loose stools", "diarrhea"),
("வயிற்றுப்போக்கு", "diarrhea"), ("दस्त", "diarrhea"),
# Dermatological
("itching", "itching"), ("rash", "rash"), ("skin rash", "rash"),
("அரிப்பு", "itching"), ("தழும்பு", "rash"), ("खुजली", "itching"),
]
def extract_symptoms(
message: str,
normalized_msg: str,
current_state: Optional[Dict[str, Any]] = None
) -> Tuple[List[Dict[str, Any]], Optional[str]]:
"""
Extracts symptoms and maps attributes (duration, severity, location, frequency) specifically.
Returns: (symptoms_list, primary_reason_for_visit)
"""
msg_lower = normalized_msg.lower()
found_symptoms: List[Dict[str, Any]] = []
found_names: Set[str] = set()
# Extract global context attributes
duration = normalize_duration_from_message(message)
severity = extract_severity_from_message(message)
location = extract_location_from_message(message)
# Frequency extraction (e.g. "twice", "2 times")
freq_match = re.search(r"(\b\d+\s*times\b|\btwice\b|\bonce\b|\bmultiple times\b)", msg_lower)
frequency = freq_match.group(1) if freq_match else None
# Scan for symptoms
for kw, canonical in SYMPTOM_DICTIONARY:
pattern = rf"\b{re.escape(kw)}\b"
if re.search(pattern, msg_lower) or kw in message:
if canonical not in found_names:
found_names.add(canonical)
# Store canonical name as 'name' if kw is an English inflection like 'vomit', else keep kw if non-English
stored_name = canonical if kw in ["vomit", "vomited", "coughing", "feverish"] else kw
sym_obj: Dict[str, Any] = {"name": stored_name}
if duration:
sym_obj["duration"] = duration
if severity:
sym_obj["severity"] = severity
if location:
sym_obj["location"] = location
if frequency and ("vomit" in canonical or "வாந்தி" in kw or "உल्टी" in kw):
sym_obj["frequency"] = frequency
found_symptoms.append(sym_obj)
primary_rfv = list(found_names)[0] if found_names else None
# Check for medication or non-symptom statements to prevent fake symptom fallback
is_med_statement = any(k in msg_lower or k in message for k in ["metformin", "dolo", "paracetamol", "pantocid", "telmisartan", "amlodipine", "tablets", "medicine", "pill", "pills", "goli", "mathirai", "மாத்திரை", "மருந்து", "சாப்பிடுறேன்", "சாப்பிட்டேன்", "வாங்குறேன்", "दवाई", "दवा"])
# Fallback: single phrase short symptom message
if not found_symptoms and message and len(message.split()) < 10 and not is_med_statement:
if not (
is_duration_expression(message)
or is_severity_expression(message)
or is_location_expression(message)
or is_casual_filler(message)
or is_negative_expression(message)
):
clean_symptom = message.strip()
sym_obj = {"name": clean_symptom}
if duration:
sym_obj["duration"] = duration
if severity:
sym_obj["severity"] = severity
if location:
sym_obj["location"] = location
found_symptoms.append(sym_obj)
primary_rfv = clean_symptom
return found_symptoms, primary_rfv
+20 -16
View File
@@ -8,7 +8,7 @@ class GeminiClient:
def __init__(self):
self.api_key = GEMINI_API_KEY
self.model = GEMINI_MODEL
self.model = GEMINI_MODEL or "gemini-1.5-flash"
async def extract_from_document(
self,
@@ -30,9 +30,10 @@ class GeminiClient:
}
try:
import google.generativeai as genai
genai.configure(api_key=self.api_key)
model = genai.GenerativeModel(self.model or "gemini-1.5-flash")
from google import genai
from google.genai import types
client = genai.Client(api_key=self.api_key)
prompt = f"""
You are a medical document extraction AI.
@@ -56,12 +57,12 @@ Return ONLY JSON:
"summary": "short clinical summary of the document"
}}
"""
part = {
"mime_type": mime_type,
"data": file_bytes,
}
part = types.Part.from_bytes(data=file_bytes, mime_type=mime_type)
response = await asyncio.wait_for(
model.generate_content_async([part, prompt]),
client.aio.models.generate_content(
model=self.model,
contents=[part, prompt]
),
timeout=4.0
)
cleaned_text = response.text.replace("```json", "").replace("```", "").strip()
@@ -84,15 +85,18 @@ Return ONLY JSON:
if not self.api_key or self.api_key in ["your_gemini_key", "your_gemini_api_key", ""]:
raise ValueError("Gemini API key not configured")
import google.generativeai as genai
genai.configure(api_key=self.api_key)
model = genai.GenerativeModel(
model_name=self.model or "gemini-1.5-flash",
generation_config={"response_mime_type": "application/json"},
)
from google import genai
from google.genai import types
client = genai.Client(api_key=self.api_key)
full_prompt = f"{system_prompt}\n\nPATIENT SAID: {user_message}"
config = types.GenerateContentConfig(response_mime_type="application/json")
response = await asyncio.wait_for(
model.generate_content_async(full_prompt),
client.aio.models.generate_content(
model=self.model,
contents=full_prompt,
config=config,
),
timeout=3.5
)
return response.text
File diff suppressed because it is too large Load Diff
@@ -2,6 +2,68 @@ def get_conversation_rules() -> str:
"""Dynamic conversation philosophy — ASK LESS, LISTEN MORE, EXTRACT MORE."""
return """CONVERSATION PHILOSOPHY — ASK LESS, LISTEN MORE, EXTRACT MORE:
51. SHORT SPOKEN RESPONSE RULE:
- The AI must speak short, natural sentences.
- Do NOT give long explanations.
- Prefer: "Got it. How long have you had this pain?"
Instead of: "Thank you for providing that information. I understand that you are currently experiencing pain, and I would like to know how long you have been experiencing this symptom."
- Rules:
* Maximum 1–2 short sentences per response.
* Ask only one question at a time.
* Acknowledge briefly.
* Do not repeat information already provided.
* Do not explain internal processing.
* Do not read JSON/state to the patient.
* Avoid medical jargon.
* Sound like a real human healthcare agent.
* Keep emergency/crisis messages clear and direct.
* Generate the response in the patient's detected language.
- Multilingual Examples:
* English: "Got it. When did the pain start?"
* Tamil: "சரி. இந்த வலி எப்போ ஆரம்பிச்சது?"
* Hindi: "ठीक है। यह दर्द कब शुरू हुआ?"
* Telugu: "సరే. ఈ నొప్పి ఎప్పుడు మొదలైంది?"
* Kannada: "ಸರಿ. ಈ ನೋವು ಯಾವಾಗ ಶುರುವಾಯಿತು?"
- Important: Short response ≠ incomplete extraction. The backend must extract all facts from the patient's message, while the AI speaks only the single most important next question.
52. RESPONSE LENGTH ENFORCEMENT:
- The response generator must strictly control spoken response length.
- AI SPEECH RULES:
1. Maximum 20 words per response.
2. Maximum 2 short sentences.
3. Ask only 1 question.
4. Never ask multiple questions together.
5. Acknowledge briefly.
6. Never repeat known information.
7. Never explain internal processing.
8. Never read structured data aloud.
9. Use natural spoken language.
10. Prefer simple words.
11. If one short sentence is enough, use one sentence.
12. Safety responses may exceed 20 words only when necessary.
- Bad: "Thank you for telling me that you have been experiencing headaches for three days with moderate severity. Could you please tell me whether the headache is constant or comes and goes?"
- Good: "Got it. Is the headache constant or does it come and go?"
- Bad: "I understand that you mentioned diabetes, metformin, and a penicillin allergy. I have recorded all this information."
- Good: "Got it. When was your headache last this severe?"
- Core rule: Extract everything. Speak only what is necessary. Ask only the next best question.
53. RESPONSE GENERATION PRIORITY:
- RESPONSE PRIORITY:
1. Handle emergency/crisis first.
2. Acknowledge the patient's latest information briefly.
3. Extract all useful facts silently.
4. Identify the most important missing information.
5. Ask only ONE question.
6. Keep the response under 20 words whenever possible.
7. Never ask for information already provided.
8. Never ask optional questions when important information is missing.
9. If all required information is available, finish naturally.
- Example:
* Patient: "I have chest pain for two days and it gets worse when walking."
* AI: "I understand. Are you having trouble breathing right now?"
* Not: "Thank you for providing detailed information about your chest pain. I have recorded the duration and trigger. I would now like to ask you another important question…"
- Core principle: Backend thinks deeply. AI speaks simply.
1. EVERY PATIENT MESSAGE IS INFORMATION-RICH:
- Treat every patient message as a potentially rich clinical statement.
- Extract ALL clinically relevant information from EVERY message, not just the answer to the current question.
@@ -19,7 +81,6 @@ def get_conversation_rules() -> str:
- If the patient already mentioned penicillin allergy, do NOT ask "Do you have any allergies?"
- If duration is present, progress immediately to medical history or medication/allergy questions.
4. ACKNOWLEDGE BEFORE ASKING:
- When the patient provides multiple details, briefly acknowledge: "Got it, you have headache and fever for two days with vomiting."
- Then ask only the MOST important missing question.
@@ -46,12 +107,10 @@ def get_conversation_rules() -> str:
9. ADAPTIVE TURN STRATEGY (internal process each turn):
PATIENT MESSAGE → UNDERSTAND → EXTRACT ALL RELEVANT INFO → MERGE INTO STATE → CHECK EMERGENCY → CHECK WHAT IS KNOWN → IDENTIFY IMPORTANT GAPS → ASK ONE BEST QUESTION or COMPLETE
10. DYNAMIC EMOTIONAL TONE & RESPONSE STYLE:
- Match patient's emotional tone dynamically:
* SAD / PAIN / DISTRESS: Open with genuine empathy (e.g. "Sorry to hear. Any BP or sugar?", "Oh dear! How long has it been?")
* JOYFUL / REASSURED / NEGATIVE FORMULATION: Open with warmth (e.g. "Great! Do you take daily tablets?", "Wonderful! Any other symptoms?")
- DO NOT use static prefillers ("Got it."). Allow OpenAI to generate dynamic, natural questions directly.
- Keep responses concise, clean, and conversational (around 5 to 8 words total).
10. RESPONSE STYLE:
- Produce ONE concise, natural spoken response (1-2 short sentences max).
- Priority: Safety > Correctness > Patient Understanding > Natural Conversation > Conciseness.
- Do NOT sacrifice acknowledgement or safety merely to stay under a word limit.
- No markdown, no bullet points, no brackets. Clean spoken dialogue only.
11. COMPLETION CHECK (MANDATORY):
@@ -66,4 +125,11 @@ def get_conversation_rules() -> str:
12. PATIENT-LED EXTRACTION:
- If the patient volunteers multiple categories in one message, extract ALL of them.
- Example: "diabetes இருக்கு, metformin 500 எடுத்துக்கிறேன், penicillin அலர்ஜி இருக்கு" → Extract medical_history, medications, AND allergies simultaneously.
- Skip questions for categories already covered. Ask only for what is genuinely missing."""
- Skip questions for categories already covered. Ask only for what is genuinely missing.
13. DYNAMIC PATIENT-TAILORED QUESTIONS REQUIREMENT:
- ALWAYS reference the patient's specific reported symptoms or concerns in your follow-up questions.
- Example: If patient reports "headache", ask "How long have you had this headache?" (NOT generic "How long have you had this problem?").
- Example: If patient reports "fever", ask "Are you taking any medicine for your fever?" (NOT generic "Are you taking any medications?").
- NEVER use robotic generic templates like "this issue" or "this condition" when the specific symptom name is known."""
@@ -12,8 +12,8 @@ def get_safety_rules() -> str:
- Severe trauma / major injury
- Seizure / convulsion in progress
EMERGENCY RESPONSE: Direct to 108 ambulance immediately.
Example: "This needs urgent medical attention. Please call 108 for an ambulance right away."
EMERGENCY RESPONSE: Direct to hospital emergency staff or ER immediately.
Example: "This needs urgent medical attention. Please inform the nearest hospital staff or proceed directly to the Emergency Room right away."
Do NOT diagnose. Do NOT say "You are having a heart attack."
2. MENTAL HEALTH CRISIS:
@@ -22,8 +22,8 @@ def get_safety_rules() -> str:
- Self-harm intent
- Immediate danger to self or others
CRISIS RESPONSE: Provide Tele-MANAS helpline (14416) and emergency 108.
Example: "If you're in distress, please reach out to Tele-MANAS at 14416 or call 108 right now. I've noted your details for the doctor."
CRISIS RESPONSE: Provide Tele-MANAS helpline (14416) or direct to hospital staff immediately.
Example: "If you're in distress, please reach out to Tele-MANAS at 14416 or alert the hospital staff right now. I've noted your details for the doctor."
Do NOT continue normal intake before acknowledging the crisis.
Do NOT diagnose or offer therapy.
@@ -43,7 +43,7 @@ EMERGENCY_KEYWORDS_EN = [
"can't breathe", "cannot breathe", "severe breathing difficulty", "unable to breathe",
"sudden paralysis", "sudden weakness", "collapsed", "passed out",
"loss of consciousness", "unconscious", "severe bleeding",
"call 108", "call ambulance",
"call nurse", "call doctor", "emergency room", "call ambulance",
]
CRISIS_KEYWORDS_EN = [
@@ -31,7 +31,7 @@ _SPECIALTY_OVERRIDES = {
- Acknowledge the patient's courage in sharing emotional/mental struggles.
- Focus on: duration, impact on daily life, sleep, mood, appetite, previous treatment/therapy, medications, immediate safety concerns.
- Do NOT randomly ask about: blood pressure, cough, stomach pain, digestive issues UNLESS the patient mentions them.
- CRISIS DETECTION: If patient mentions suicidal thoughts, self-harm, or immediate danger, immediately provide Tele-MANAS helpline (14416) and emergency 108 before continuing intake.
- CRISIS DETECTION: If patient mentions suicidal thoughts, self-harm, or immediate danger, immediately provide Tele-MANAS helpline (14416) or direct to hospital emergency staff before continuing intake.
- Use empathetic, warm language. Mental health intake requires extra sensitivity.""",
"cardiology": """
+74 -102
View File
@@ -10,22 +10,23 @@ from app.ai.prompt_rules.safety_rules import get_safety_rules
def build_system_prompt(context: dict) -> str:
"""
Build a dynamic, modular system prompt for natural patient intake.
Adheres to: ASK LESS -> LISTEN MORE -> EXTRACT MORE.
Composed of language, clinical, conversation, extraction, specialty, and safety rule modules.
Adheres to Master Prompt Rules 53-60:
- Extract a lot internally. Speak very little externally.
- One question only.
- Acknowledge + Ask pattern.
- No robotic confirmations or medical explanations.
- Target length: 5-15 words (max 20 words).
"""
specialty = context.get("specialty_config", {})
patient = context.get("patient", {})
language = context.get("language", "ta-IN")
stage = context.get("current_stage", "greeting")
current_state = context.get("current_state", {})
conversation_history = context.get("conversation_history", [])
new_patient_message = context.get("new_patient_message", "")
target_question = context.get("target_question", "")
patient_name = patient.get("name") or "Patient"
patient_age = patient.get("age") or "Not provided"
patient_gender = patient.get("gender") or "Not provided"
# Assemble modular rules
lang_rules = get_language_rules(language)
clin_rules = get_clinical_rules()
priv_rules = get_privacy_rules()
@@ -33,18 +34,15 @@ def build_system_prompt(context: dict) -> str:
extr_rules = get_extraction_rules()
spec_rules = get_specialty_rules(specialty)
safe_rules = get_safety_rules()
last_ai_message = ""
for turn in reversed(conversation_history):
if turn.get("role") in ["assistant", "ai"]:
last_ai_message = turn.get("message", "")
break
from app.services.contextual_normalizer import detect_previous_question_intent
expected_intent = detect_previous_question_intent(last_ai_message)
intent_hint = f"\nPREVIOUS AI QUESTION INTENT: {expected_intent.upper()} (Interpret patient's reply as providing {expected_intent} for the primary symptom, NOT a new symptom entity)." if expected_intent else ""
prompt = f"""You are Nila, an empathetic, highly skilled AI Clinical Intake Assistant at a hospital/clinic.
Your job is to collect preliminary clinical intake information for the doctor from the patient: {patient_name} (Age: {patient_age}, Gender: {patient_gender}).
prompt = f"""You are Nila, an empathetic, highly skilled AI Clinical Intake Assistant at a hospital.
Your job is to collect preliminary clinical intake information for the doctor from patient: {patient_name}.
{lang_rules}
@@ -60,110 +58,84 @@ Your job is to collect preliminary clinical intake information for the doctor fr
{priv_rules}
CURRENT INTAKE STATE (What has already been gathered):
RESPONSE GENERATION RULES (RULES 51 - 200):
1. SHORT SPOKEN RESPONSE RULE (Rule 51):
- Speak short, natural sentences (max 1-2 short sentences). No long explanations.
- Prefer "Got it. How long have you had this pain?" over long robotic explanations.
- Ask only ONE question at a time. Acknowledge briefly. Do not repeat provided info or explain internal processing.
- Avoid medical jargon. Sound like a real human healthcare agent.
- Generate response in patient's detected language (en: "Got it. When did the pain start?", ta: "சரி. இந்த வலி எப்போ ஆரம்பிச்சது?", hi: "ठीक है। यह दर्द कब शुरू हुआ?", te: "సరే. ఈ నొప్పి ఎప్పుడు మొదలైంది?", kn: "ಸರಿ. ಈ ನೋವು ಯಾವಾಗ ಶುರುವಾಯಿತು?").
- Short response ≠ incomplete extraction (backend extracts everything internally, AI speaks only single next question).
2. RESPONSE LENGTH ENFORCEMENT (Rule 52):
- Maximum 20 words per response. Maximum 2 short sentences (use 1 sentence if enough).
- Ask only 1 question. Never ask multiple questions together. Acknowledge briefly.
- Never repeat known info, explain internal processing, or read structured data aloud.
- Use natural spoken language and simple words. Core rule: Extract everything. Speak only what is necessary.
3. RESPONSE GENERATION PRIORITY (Rule 53):
- Priority sequence: (1) Emergency/crisis first (2) Acknowledge briefly (3) Extract facts silently (4) Identify missing info (5) Ask ONE question (6) Keep under 20 words (7) Never ask provided info (8) Prioritize critical gaps over optional (9) Finish naturally when complete.
- Core principle: Backend thinks deeply. AI speaks simply.
4. RESPONSE TIMING & CONTEXT: Generate response after state processing. Receive only necessary context.
3. ONE QUESTION ONLY (Rule 54): Ask ONLY ONE question in your response. Never ask multiple independent questions.
4. ACKNOWLEDGE + ASK PATTERN (Rules 55 & 63): Brief acknowledgement + ONE question. NEVER read the form back or repeat full patient info.
5. NO ROBOTIC LANGUAGE OR CONFIRMATIONS (Rules 56, 57, 64, 79): Avoid 'I have recorded that', 'Please provide...', 'Your response indicates that...'. Interpret short answers ('yeah', '3 days', 'aama', 'illa', 'no', 'haan') naturally without asking for complete sentences.
6. NO MEDICAL EXPLANATIONS & NO HALLUCINATION (Rules 58 & 75): No diagnosis, no medical advice, no underlying condition lectures. NEVER assume unestablished facts ('Since you have diabetes...') in spoken reply.
7. INTENT-BASED MICRO RESPONSES & DYNAMIC LENGTH (Rules 60, 61, 62, 70, 71, 72, 73, 74, 168):
- Adapt to turn intent (greeting, fact_extraction, clarification, completion, emergency, crisis).
- Filter out greetings, repeated acknowledgements, form readbacks, medical jargon, disclaimers.
- Normal turn: 1 short sentence, 5-15 words (max 20 words). Voice turn: 5-12 words.
- Completion turn: Short natural closing (5-12 words, e.g. 'Thanks. Your onboarding is complete.').
- Emergency / Crisis: Immediate concise safety instructions (EMERGENCY > NORMAL CONVERSATION).
8. LANGUAGE CONSISTENCY & CODE-SWITCHING (Rules 76, 166, 167): Respond in patient's active language/transliteration (e.g. Tanglish/Hinglish). Support mixed-language code-switching naturally.
9. PATIENT-LED PRIORITY & STATE INTEGRITY (Rules 66, 67, 68, 69, 77, 78, 80, 121-140):
- Extract patient profile (age, gender, name) and validate values (Rule 121 & 123).
- Separate family history / representative info from patient history ('My father has diabetes' -> family_history, NOT patient.medical_history) (Rule 122 & 129).
- Extract medications with dose & frequency, updating on corrections without duplicating (Rule 124 & 125).
- Extract surgical history, previous episodes, treatment history, test reports (Rules 128, 130, 131, 132).
- Maintain symptom relationship graphs (duration, severity, location mapped per symptom) (Rule 133).
- Temporal, severity, & frequency normalization without fabricating numeric scores or exact dates (Rules 134, 135, 136).
- Negation scope & pronoun resolution ('it' -> active symptom name) (Rules 137 & 138).
- Correction phrase detection ('actually', 'I meant') overriding previous values (Rule 139).
- Final data integrity rule: Never complete because conversation is long; complete ONLY when state is complete (Rule 140).
10. PRODUCTION SECURITY, PRIVACY & GOVERNANCE (Rules 171-200):
- RBAC & tenant isolation (Rules 171-176). Minimum data exposure & log redaction (Rules 177-179).
- Environment separation, explicit CORS, request correlation ID (Rules 180-186).
- Audit event logging (SESSION_CREATED, SAFETY_DETECTED, LLM_REQUESTED, COMPLETED) (Rules 187 & 188).
- Decoupled architecture: Router -> Service -> AI Orchestrator (SafetyEngine, Normalization, ExtractionEngine, StateManager, QuestionEngine, ResponseGenerator) -> Repository (Rules 189-200).
FINAL BACKEND DESIGN PRINCIPLE (Rule 200):
COMPLEX BACKEND → SIMPLE CONVERSATION → SHORT AI SPEECH.
CURRENT INTAKE STATE:
{json.dumps(current_state, indent=2, ensure_ascii=False)}
RECENT CONVERSATION HISTORY:
{json.dumps(conversation_history[-8:], indent=2, ensure_ascii=False)}
PREVIOUS AI QUESTION:
"{last_ai_message}"{intent_hint}
LATEST PATIENT INPUT:
LATEST PATIENT MESSAGE:
"{new_patient_message}"
CRITICAL INSTRUCTIONS FOR THIS TURN:
1. Extract ALL clinically relevant facts from the latest patient input. If the input is answering the previous AI question (e.g. duration, severity, location), attach it to the existing symptom in CURRENT INTAKE STATE. DO NOT create a new symptom item for duration/severity statements.
2. DO NOT re-ask for any information already present in CURRENT INTAKE STATE or clearly stated in this input. NEVER ask duration again if duration is provided.
3. If the patient stated "Nothing else", "No", "None" for a SPECIFIC category that was just asked about, set that category to confirmed_none. But do NOT complete the intake unless ALL required categories are collected or confirmed_none.
4. If an important clinical gap remains, formulate ONE natural, empathetic question addressing ONLY the most relevant missing item.
5. DYNAMIC EMOTIONAL TONE & QUESTION FORMULATION:
- Match the patient's tone naturally:
* SAD / PAIN / SEVERE SYMPTOMS: Start with genuine empathy (e.g., "Sorry to hear. Do you have BP or sugar?", "Oh dear! How long has it been?").
* POSITIVE / NO ILLNESS / NEGATION: Start with a warm, encouraging tone (e.g., "Great! Taking any daily medications?", "Wonderful! Any other symptoms?").
- DO NOT use static prefillers like "Got it." repeatedly. Let OpenAI generate a dynamic, natural response directly.
- Keep the reply concise, conversational, and direct (around 5 to 8 words total).
COMPLETION RULES — MANDATORY:
- NEVER set "completed": true unless ALL of the following are satisfied:
A. reason_for_visit is reported (has a value)
B. At least one symptom exists with duration information
C. medical_history is either "reported" with items OR explicitly "confirmed_none"
D. medications is either "reported" with items OR explicitly "confirmed_none"
E. allergies is either "reported" with items OR explicitly "confirmed_none"
- "not_collected" is NOT the same as "confirmed_none". A field with status "not_collected" means you MUST still ask about it.
- If the patient says "nothing else" but required fields are still "not_collected", you MUST continue asking. Do NOT complete early.
- If the patient provides medical_history + medications + allergies in a single message, extract ALL of them and skip those questions.
TARGET MISSING QUESTION TO ASK:
"{target_question}"
OUTPUT FORMAT REQUIREMENTS:
You MUST respond with valid JSON matching EXACTLY this structure:
{{
"reply": "Warm, natural spoken response acknowledging input and asking one relevant missing question or giving closing confirmation",
"next_stage": "reason_for_visit | symptom_collection | symptom_details | medications | allergies | confirmation",
"reply": "Short natural spoken response (5-15 words, max 1 question) following Acknowledge + Ask pattern",
"next_stage": "reason_for_visit | symptom_collection | symptom_details | medical_history | medications | allergies | confirmation",
"state_updates": {{
"reason_for_visit": "primary reason if identified or null",
"reason_for_visit": "primary reason or null",
"symptoms": [
{{
"name": "symptom name",
"duration": "duration if stated or null",
"severity": "mild/moderate/severe if stated or null",
"location": "body location if stated or null",
"onset": "sudden/gradual if stated or null",
"frequency": "how often if stated or null",
"pattern": "continuous/intermittent if stated or null",
"triggers": "trigger factors if stated or null",
"relieving_factors": "relieving factors if stated or null",
"aggravating_factors": "aggravating factors if stated or null"
"duration": "duration or null",
"severity": "severity or null",
"location": "location or null"
}}
],
"medical_history": [
{{
"condition": "condition name",
"duration": "duration if stated or null",
"status": "active/past if stated or null"
}}
],
"medications": [
{{
"name": "medication name",
"dosage": "dosage if stated or null",
"frequency": "frequency if stated or null",
"duration": "duration if stated or null"
}}
],
"allergies": [
{{
"name": "allergen name",
"type": "drug/food/environment if known or null",
"reaction": "reaction if stated or null"
}}
],
"surgical_history": [
{{
"procedure": "procedure name",
"approximate_date": "when if stated or null",
"reason": "reason if stated or null"
}}
],
"previous_episodes": [
{{
"description": "episode description",
"time": "when if stated or null"
}}
],
"family_history": [
{{
"condition": "condition name",
"relationship": "family member if stated or null"
}}
],
"additional_information": [
{{
"category": "functional_impact | lifestyle | dietary | other",
"value": "details stated by patient"
}}
]
"medical_history": [],
"medications": [],
"allergies": [],
"surgical_history": [],
"previous_episodes": [],
"family_history": [],
"additional_information": []
}},
"completed": false
}}
@@ -0,0 +1,9 @@
"""
Providers package.
"""
from app.ai.providers.base import LLMProvider
from app.ai.providers.openai import OpenAIProvider
from app.ai.providers.gemini import GeminiProvider
__all__ = ["LLMProvider", "OpenAIProvider", "GeminiProvider"]
@@ -0,0 +1,30 @@
"""
Base LLM Provider interface with hard async timeout enforcement.
"""
import asyncio
from abc import ABC, abstractmethod
from typing import Optional
class LLMProvider(ABC):
"""
Abstract LLM Provider interface.
Enforces a strict hard timeout (default 4 seconds).
"""
def __init__(self, timeout_seconds: float = 4.0):
self.timeout_seconds = timeout_seconds
async def generate_response_with_timeout(self, system_prompt: str, user_message: str) -> Optional[str]:
try:
return await asyncio.wait_for(
self._generate_internal(system_prompt, user_message),
timeout=self.timeout_seconds
)
except (asyncio.TimeoutError, Exception) as e:
print(f"[{self.__class__.__name__} Failed / Timed out]: {e}")
return None
@abstractmethod
async def _generate_internal(self, system_prompt: str, user_message: str) -> Optional[str]:
pass
@@ -0,0 +1,19 @@
"""
Gemini LLM Provider.
"""
from typing import Optional
from app.ai.providers.base import LLMProvider
from app.ai.gemini_client import GeminiClient
class GeminiProvider(LLMProvider):
def __init__(self, timeout_seconds: float = 4.0):
super().__init__(timeout_seconds=timeout_seconds)
self.client = GeminiClient()
async def _generate_internal(self, system_prompt: str, user_message: str) -> Optional[str]:
api_key = getattr(self.client, "api_key", None)
if not api_key or api_key in ["your_gemini_key", "your_gemini_api_key", ""]:
return None
return await self.client.generate_response(system_prompt, user_message)
@@ -0,0 +1,19 @@
"""
OpenAI LLM Provider.
"""
from typing import Optional
from app.ai.providers.base import LLMProvider
from app.ai.openai_client import OpenAIClient
class OpenAIProvider(LLMProvider):
def __init__(self, timeout_seconds: float = 4.0):
super().__init__(timeout_seconds=timeout_seconds)
self.client = OpenAIClient()
async def _generate_internal(self, system_prompt: str, user_message: str) -> Optional[str]:
api_key = getattr(self.client, "api_key", None)
if not api_key or api_key in ["your_openai_key", "your_openai_api_key", ""]:
return None
return await self.client.generate_response(system_prompt, user_message)
@@ -0,0 +1,8 @@
"""
Safety package.
"""
from app.ai.safety.emergency import check_emergency
from app.ai.safety.crisis import check_crisis
__all__ = ["check_emergency", "check_crisis"]
@@ -0,0 +1,69 @@
"""
Mental health crisis and self-harm detection engine.
"""
import re
from typing import Optional, Tuple
from app.ai.prompt_rules.safety_rules import CRISIS_KEYWORDS_EN, CRISIS_KEYWORDS_INDIC
TRANSLITERATED_CRISIS_PHRASES = [
"saaganam pola irukku", "uyiroda irukka virumbala", "saaga poraen", "valave pidikala",
"mar jane ka man", "apne aap ko nuksan", "khudkushee",
"chani povali", "sathokobeku",
]
def check_crisis(user_message: str, lang: str = "en") -> Tuple[bool, Optional[str], Optional[str]]:
"""
Evaluates patient message for mental health crisis or self-harm intent.
Returns: (is_crisis, reason, crisis_reply)
"""
if not user_message or not user_message.strip():
return False, None, None
# Check Indic script crisis keywords FIRST against raw message
for kw in CRISIS_KEYWORDS_INDIC:
if kw in user_message:
reply = _build_crisis_reply(lang)
return True, f"Matched Indic crisis phrase: '{kw}'", reply
indic_extra = ["காயப்படுத்திக்கணும்", "கொல்லணும்", "வாழவே பிடிக்கல", "உயிரோட இருக்க", "தற்கொலை", "உயிரை"]
for kw in indic_extra:
if kw in user_message:
reply = _build_crisis_reply(lang)
return True, f"Matched Indic crisis phrase: '{kw}'", reply
msg = user_message.strip().lower()
msg_clean = re.sub(r"[^\w\s]", " ", msg)
msg_clean = re.sub(r"\s+", " ", msg_clean)
# Check English crisis keywords
for kw in CRISIS_KEYWORDS_EN:
kw_clean = re.sub(r"[^\w\s]", " ", kw.lower())
if re.search(rf"\b{re.escape(kw_clean)}\b", msg_clean) or kw.lower() in msg:
reply = _build_crisis_reply(lang)
return True, f"Matched crisis phrase: '{kw}'", reply
# Check Transliterated crisis phrases
for kw in TRANSLITERATED_CRISIS_PHRASES:
if kw in msg_clean or kw in msg:
reply = _build_crisis_reply(lang)
return True, f"Matched transliterated crisis phrase: '{kw}'", reply
return False, None, None
def _build_crisis_reply(lang: str) -> str:
lang_clean = lang.lower() if lang else "en"
if lang_clean.startswith("ta"):
return "உங்களுக்கு மன உளைச்சல் இருந்தால், தயவுசெய்து டெலி-மானாஸ் (Tele-MANAS) உதவி எண் 14416-ஐ தொடர்பு கொள்ளவும் அல்லது மருத்துவமனை ஊழியர்களிடம் தெரிவிக்கவும்."
elif lang_clean.startswith("hi"):
return "यदि आप परेशानी में हैं, तो कृपया तुरंत टेली-मानस (Tele-MANAS) 14416 पर संपर्क करें या अस्पताल के कर्मचारियों को सूचित करें।"
elif lang_clean.startswith("te"):
return "మీరు తీవ్రమైన ఒత్తిడిలో ఉంటే, దయచేసి టెలి-మానస్ (Tele-MANAS) 14416 కు ఫోన్ చేయండి లేదా ఆసుపత్రి సిబ్బందికి చెప్పండి."
elif lang_clean.startswith("kn"):
return "ನೀವು ಮನೋಒತ್ತಡದಲ್ಲಿದ್ದರೆ, ದಯವಿಟ್ಟು ಟೆಲಿ-ಮಾನಸ್ (Tele-MANAS) 14416 ಗೆ ಕರೆ ಮಾಡಿ ಅಥವಾ ಆಸ್ಪತ್ರೆಯ ಸಿಬ್ಬಂದಿಗೆ ತಿಳಿಸಿ."
else:
return "If you're in distress, please reach out to Tele-MANAS at 14416 or alert the hospital staff right now."
@@ -0,0 +1,91 @@
"""
Emergency detection engine with strict safety precedence and false-positive protection.
"""
import re
from typing import Optional, Tuple
from app.ai.prompt_rules.safety_rules import EMERGENCY_KEYWORDS_EN, EMERGENCY_KEYWORDS_INDIC
TRANSLITERATED_EMERGENCY_PHRASES = [
"nenju romba valikuthu", "nenju valla valikuthu", "nenju kadumaya valikuthu",
"moochu vida mudiyala", "moochu adaikuthu", "moochu thinaral",
"mayangi vilunthuten", "mayakkam varuthu", "paralysis vanthuruchu",
"cheest mein bahut dard", "seene mein bahut tej dard", "saans lene mein bahut dikkat",
"saans nahi aa rahi", "behosh ho gaya", "behosh ho gayi",
"chathi lo thivramaina noppi", "oophiri aadam ledhu", "spruha thappi",
"edeyalli theevra novu", "usiraadalu thumba kashta", "pragne thappi",
]
def check_emergency(user_message: str, lang: str = "en") -> Tuple[bool, Optional[str], Optional[str]]:
"""
Evaluates patient message for medical emergency indicators.
Returns: (is_emergency, reason, emergency_reply)
"""
if not user_message or not user_message.strip():
return False, None, None
# Check Indic script emergency keywords against raw user_message FIRST
for kw in EMERGENCY_KEYWORDS_INDIC:
if kw in user_message:
reply = _build_emergency_reply(lang)
return True, f"Matched Indic emergency phrase: '{kw}'", reply
# Additional Tamil/Hindi spoken emergency phrases
indic_extra = ["மூச்சு விட முடியல", "மூச்சு திணறல்", "நெஞ்சு வலி", "நெஞ்சு பாரம்", "கடும் நெஞ்சு வலி", "நெஞ்சு அதிகமா வலி"]
for kw in indic_extra:
if kw in user_message and any(x in user_message for x in ["மூச்சு", "கடும்", "வலி", "அதிகமா"]):
reply = _build_emergency_reply(lang)
return True, f"Matched Indic emergency phrase: '{kw}'", reply
msg = user_message.strip().lower()
msg_clean = re.sub(r"[^\w\s]", " ", msg)
msg_clean = re.sub(r"\s+", " ", msg_clean)
# False Positive Protection: Negated emergency statements ("no chest pain")
negation_patterns = [
r"\bno\s+(?:chest\s+pain|breathing\s+difficulty|heart\s+attack|paralysis|bleeding)\b",
r"\bdon't\s+have\s+(?:chest\s+pain|breathing\s+difficulty|heart\s+attack)\b",
r"\bnot\s+having\s+(?:chest\s+pain|breathing\s+difficulty)\b",
r"\bnever\s+had\s+(?:chest\s+pain|breathing\s+difficulty)\b",
r"\billness\s+none\b",
]
for neg_pat in negation_patterns:
if re.search(neg_pat, msg_clean):
if not any(pos in msg_clean for pos in ["severe chest pain", "cannot breathe", "collapsed"]):
return False, None, None
# Check English emergency keywords
for kw in EMERGENCY_KEYWORDS_EN:
if re.search(rf"\b{re.escape(kw)}\b", msg_clean):
reply = _build_emergency_reply(lang)
return True, f"Matched emergency phrase: '{kw}'", reply
# Check combinations (e.g. "chest pain" AND "difficulty breathing")
if "chest pain" in msg_clean and ("breath" in msg_clean or "shortness" in msg_clean or "difficulty" in msg_clean):
reply = _build_emergency_reply(lang)
return True, "Matched combined chest pain and breathing difficulty", reply
# Check Transliterated emergency phrases
for kw in TRANSLITERATED_EMERGENCY_PHRASES:
if kw in msg_clean:
reply = _build_emergency_reply(lang)
return True, f"Matched transliterated emergency phrase: '{kw}'", reply
return False, None, None
def _build_emergency_reply(lang: str) -> str:
lang_clean = lang.lower() if lang else "en"
if lang_clean.startswith("ta"):
return "இது அவசர மருத்துவ உதவி தேவைப்படும் நிலை. தயவுசெய்து உடனடியாக அருகில் உள்ள மருத்துவமனை அவசர சிகிச்சைப் பிரிவுக்குச் செல்லவும்."
elif lang_clean.startswith("hi"):
return "यह एक आपातकालीन स्थिति हो सकती है। कृपया तुरंत निकटतम अस्पताल के आपातकालीन कक्ष (ER) में जाएँ।"
elif lang_clean.startswith("te"):
return "ఇది అత్యవసర వైద్య పరిస్థితి కావచ్చు. దయచేసి వెంటనే దగ్గరలోని ఆసుపత్రి ఎమర్జెన్సీ విభాగానికి వెళ్ళండి."
elif lang_clean.startswith("kn"):
return "ಇದು ತುರ್ತು ವೈದ್ಯಕೀಯ ಪರಿಸ್ಥಿತಿಯಾಗಿರಬಹುದು. ದಯವಿಟ್ಟು ತಕ್ಷಣ ಹತ್ತಿರದ ಆಸ್ಪತ್ರೆಯ ತುರ್ತು ವಿಭಾಗಕ್ಕೆ ಹೋಗಿ."
else:
return "This requires urgent medical attention. Please inform the nearest hospital staff or proceed directly to the Emergency Room right away."
+143 -24
View File
@@ -1,7 +1,25 @@
import base64
import time
import asyncio
import logging
import httpx2
from typing import Tuple, Optional
from app.config import SARVAM_API_KEY
logger = logging.getLogger("sarvam_client")
# Status Constants
STT_SUCCESS = "STT_SUCCESS"
STT_EMPTY_AUDIO = "STT_EMPTY_AUDIO"
STT_TIMEOUT = "STT_TIMEOUT"
STT_PROVIDER_ERROR = "STT_PROVIDER_ERROR"
STT_NETWORK_ERROR = "STT_NETWORK_ERROR"
TTS_SUCCESS = "TTS_SUCCESS"
TTS_TIMEOUT = "TTS_TIMEOUT"
TTS_PROVIDER_ERROR = "TTS_PROVIDER_ERROR"
TTS_NETWORK_ERROR = "TTS_NETWORK_ERROR"
class SarvamClient:
@@ -14,25 +32,39 @@ class SarvamClient:
def _get_client(self) -> httpx2.AsyncClient:
if SarvamClient._client is None:
SarvamClient._client = httpx2.AsyncClient(
timeout=12.0,
timeout=8.0,
limits=httpx2.Limits(max_keepalive_connections=5, max_connections=10),
)
return SarvamClient._client
async def speech_to_text(
async def speech_to_text_with_status(
self,
audio_bytes: bytes,
filename: str = "audio.webm",
language_code: str = "ta-IN",
) -> str:
if not self.api_key or self.api_key == "your_sarvam_key":
return ""
session_id: Optional[str] = None,
max_retries: int = 2,
) -> Tuple[str, str]:
"""
Transcribes audio to text returning (transcript, status_code).
Uses bounded retries for transient errors.
"""
if not audio_bytes:
return "", STT_EMPTY_AUDIO
if not self.api_key or self.api_key in ["your_sarvam_key", ""]:
return "", STT_PROVIDER_ERROR
client = self._get_client()
files = {"file": (filename, audio_bytes, "audio/webm")}
data = {"model": "saaras:v2", "language_code": language_code}
headers = {"api-subscription-key": self.api_key}
attempt = 0
last_status = STT_PROVIDER_ERROR
while attempt <= max_retries:
start_time = time.time()
try:
res = await client.post(
f"{self.base_url}/speech-to-text",
@@ -40,23 +72,74 @@ class SarvamClient:
data=data,
headers=headers,
)
if res.status_code == 200:
return res.json().get("transcript", "").strip()
except Exception as e:
print(f"[Sarvam STT Error]: {e}")
return ""
duration = round(time.time() - start_time, 3)
async def text_to_speech(
if res.status_code == 200:
transcript = res.json().get("transcript", "").strip()
logger.info(
"sarvam_stt_success",
extra={
"provider": "SarvamAI",
"operation": "STT",
"session_id": session_id,
"duration_sec": duration,
"attempt": attempt + 1,
},
)
return transcript, STT_SUCCESS
elif 400 <= res.status_code < 500:
# Non-retryable 4xx client errors
logger.warning(
"sarvam_stt_client_error",
extra={
"provider": "SarvamAI",
"operation": "STT",
"session_id": session_id,
"http_status": res.status_code,
},
)
return "", STT_PROVIDER_ERROR
else:
last_status = STT_PROVIDER_ERROR
except (httpx2.TimeoutException, asyncio.TimeoutError):
last_status = STT_TIMEOUT
logger.warning("sarvam_stt_timeout", extra={"session_id": session_id, "attempt": attempt + 1})
except (httpx2.NetworkError, Exception) as e:
last_status = STT_NETWORK_ERROR
logger.warning("sarvam_stt_network_error", extra={"session_id": session_id, "attempt": attempt + 1})
attempt += 1
if attempt <= max_retries:
await asyncio.sleep(0.2 * attempt)
return "", last_status
async def speech_to_text(
self,
audio_bytes: bytes,
filename: str = "audio.webm",
language_code: str = "ta-IN",
) -> str:
transcript, _ = await self.speech_to_text_with_status(audio_bytes, filename, language_code)
return transcript
async def text_to_speech_with_status(
self,
text: str,
language_code: str = "ta-IN",
pace: float = 1.0,
) -> bytes | None:
if not self.api_key or self.api_key == "your_sarvam_key":
return None
session_id: Optional[str] = None,
max_retries: int = 2,
) -> Tuple[Optional[bytes], str]:
"""
Synthesizes text to speech returning (audio_bytes, status_code).
Uses bounded retries for transient errors.
"""
if not text or not text.strip():
return None, TTS_PROVIDER_ERROR
lang_prefix = language_code.lower()[:2]
speaker = "kavya"
if not self.api_key or self.api_key in ["your_sarvam_key", ""]:
return None, TTS_PROVIDER_ERROR
client = self._get_client()
headers = {
@@ -66,21 +149,57 @@ class SarvamClient:
payload = {
"inputs": [text],
"target_language_code": language_code,
"speaker": speaker,
"model": "bulbul:v3",
"pace": pace,
"speaker": "kavya",
"model": "bulbul:v2",
}
attempt = 0
last_status = TTS_PROVIDER_ERROR
while attempt <= max_retries:
start_time = time.time()
try:
res = await client.post(
f"{self.base_url}/text-to-speech",
json=payload,
headers=headers,
)
duration = round(time.time() - start_time, 3)
if res.status_code == 200:
audios = res.json().get("audios", [])
if audios:
return base64.b64decode(audios[0])
except Exception as e:
print(f"[Sarvam TTS Error]: {e}")
return None
logger.info(
"sarvam_tts_success",
extra={
"provider": "SarvamAI",
"operation": "TTS",
"session_id": session_id,
"duration_sec": duration,
"attempt": attempt + 1,
},
)
return base64.b64decode(audios[0]), TTS_SUCCESS
elif 400 <= res.status_code < 500:
return None, TTS_PROVIDER_ERROR
else:
last_status = TTS_PROVIDER_ERROR
except (httpx2.TimeoutException, asyncio.TimeoutError):
last_status = TTS_TIMEOUT
except (httpx2.NetworkError, Exception):
last_status = TTS_NETWORK_ERROR
attempt += 1
if attempt <= max_retries:
await asyncio.sleep(0.2 * attempt)
return None, last_status
async def text_to_speech(
self,
text: str,
language_code: str = "ta-IN",
) -> Optional[bytes]:
audio, _ = await self.text_to_speech_with_status(text, language_code)
return audio
@@ -1,42 +0,0 @@
import re
from typing import Dict, Any, Tuple
CAREGIVER_PATTERNS = [
(r"(?:my\s*mother|my\s*mom|என்\s*அம்மா|அம்மாவுக்கு|मेरी\s*माँ|माँ\s*को|నా\s*తల్లి)", "mother"),
(r"(?:my\s*father|my\s*dad|என்\s*அப்பா|அப்பாவுக்கு|मेरे\s*पिता|पिता\s*जी|నా\s*తండ్రి)", "father"),
(r"(?:my\s*wife|my\s*husband|என்\s*மனைவி|என்\s*கணவர்|मेरी\s*पत्नी|मेरे\s*पति|నా\s*భార్య|నా\s*భర్త)", "spouse"),
(r"(?:my\s*son|my\s*daughter|my\s*child|my\s*kid|என்\s*மகன்|என்\s*மகள்|என்\s*குழந்தை|मेरा\s*बेटा|मेरी\s*बेटी|నా\s*కొడుకు)", "child"),
(r"(?:he\s*is|she\s*is|his\s*symptoms|her\s*symptoms|he\s*has|she\s*has|அவருக்கு|அவளுக்கு|उसको|उन्हें)", "relative")
]
PATIENT_PATTERNS = [
r"(?:i\s*have|i\s*am|my\s*headache|my\s*chest|my\s*fever|my\s*pain|எனக்கு|எனக்குள்ள|मुझे|मेरे|నాకు)"
]
def classify_speaker_turn(text: str) -> Dict[str, Any]:
"""
Dual-Speaker Diarization Classifier:
Differentiates between direct Patient statements (1st Person) and Caregiver / Relative statements (3rd Person).
Returns dictionary with speaker_type ('patient' vs 'caregiver'), relation, and formatted_label.
"""
txt_lower = (text or "").lower().strip()
# 1. Check Caregiver / Relative Patterns
for pattern, relation in CAREGIVER_PATTERNS:
if re.search(pattern, txt_lower, re.IGNORECASE):
label = f"Caregiver ({relation.capitalize()})" if relation != "relative" else "Caregiver"
return {
"speaker_type": "caregiver",
"relation": relation,
"formatted_label": label,
"is_caregiver": True
}
# 2. Default to Patient Direct Statement
return {
"speaker_type": "patient",
"relation": "self",
"formatted_label": "Patient",
"is_caregiver": False
}
@@ -5,14 +5,10 @@ import time
from uuid import uuid4
from typing import Optional
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile, WebSocket
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
from app.services.websocket_voice_manager import WebSocketVoiceManager
ws_manager = WebSocketVoiceManager()
from app.database.database import get_db
from app.database.models import (
OnboardingMessage,
@@ -480,54 +476,3 @@ async def complete_voice_onboarding(
print(f"[CompleteVoiceOnboarding Error]: {err}")
traceback.print_exc()
raise HTTPException(status_code=500, detail=str(err))
@router.websocket("/ws/{session_id}")
async def websocket_voice_onboarding_endpoint(websocket: WebSocket, session_id: str):
await ws_manager.handle_websocket_session(websocket, session_id)
@router.get("/fhir/{session_id}")
async def get_session_fhir_export(
session_id: str,
db: Session = Depends(get_db),
auth: AuthContext = Depends(get_auth_context)
):
"""
Returns production-grade HL7 FHIR R4 Bundle JSON for 1-click import into hospital EMR/EHR systems (Epic, Cerner, AthenaHealth).
Includes Patient, Encounter, Condition (Symptoms & History), MedicationStatement, and AllergyIntolerance resources.
"""
from app.services.fhir_exporter import export_session_to_fhir_bundle
try:
fhir_bundle = export_session_to_fhir_bundle(session_id, db)
return fhir_bundle
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=f"FHIR Export Error: {str(e)}")
@router.get("/soap-note/{session_id}")
async def get_session_soap_note_export(
session_id: str,
format: str = "json",
db: Session = Depends(get_db),
auth: AuthContext = Depends(get_auth_context)
):
"""
Compiles completed intake into a formal Doctor SOAP Note (Subjective, Objective, Assessment, Plan) with candidate ICD-10 & SNOMED CT codes.
Pass format=html to download printable HTML report.
"""
from app.services.soap_note_generator import generate_soap_note, generate_soap_html_report
from fastapi.responses import HTMLResponse
try:
soap_note = generate_soap_note(session_id, db)
if format.lower() == "html":
html_content = generate_soap_html_report(soap_note)
return HTMLResponse(content=html_content)
return soap_note
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=f"SOAP Note Error: {str(e)}")
@@ -0,0 +1,3 @@
"""
WebSocket API Package for Real-Time Patient Onboarding.
"""
@@ -0,0 +1,73 @@
"""
WebSocket Connection Manager (Section 18, 19, 28).
Manages active WebSocket instances and per-session processing locks.
Ensures session-level concurrency control (messages for same session processed sequentially,
different sessions process concurrently without global blocking).
"""
import asyncio
import logging
from typing import Dict, Set, Optional
from fastapi import WebSocket, status
logger = logging.getLogger(__name__)
class ConnectionManager:
def __init__(self):
# Maps session_id to set of active WebSocket instances
self.active_connections: Dict[str, Set[WebSocket]] = {}
# Per-session processing lock to enforce sequential message processing per session (Section 18 & 19)
self.session_locks: Dict[str, asyncio.Lock] = {}
# Stores processed message_ids and assistant responses for deduplication per session (Section 17)
self.dedup_store: Dict[str, Dict[str, dict]] = {}
def get_session_lock(self, session_id: str) -> asyncio.Lock:
"""Returns or creates a per-session lock."""
if session_id not in self.session_locks:
self.session_locks[session_id] = asyncio.Lock()
return self.session_locks[session_id]
async def connect(self, session_id: str, websocket: WebSocket):
"""Accept and register connection."""
if session_id not in self.active_connections:
self.active_connections[session_id] = set()
self.active_connections[session_id].add(websocket)
logger.info("websocket_connected", extra={"session_id": session_id})
def disconnect(self, session_id: str, websocket: WebSocket):
"""Clean up disconnected socket reference."""
if session_id in self.active_connections:
self.active_connections[session_id].discard(websocket)
if not self.active_connections[session_id]:
del self.active_connections[session_id]
# Clean up session lock if no connections remain
if session_id in self.session_locks:
del self.session_locks[session_id]
logger.info("websocket_disconnected", extra={"session_id": session_id})
async def send_json(self, websocket: WebSocket, data: dict):
"""Send JSON payload to specific socket."""
try:
await websocket.send_json(data)
except Exception as e:
logger.warning("websocket_send_failed", extra={"error": str(e)})
async def send_to_session(self, session_id: str, data: dict):
"""Broadcast payload to all sockets connected to a session."""
if session_id in self.active_connections:
for ws in list(self.active_connections[session_id]):
await self.send_json(ws, data)
def cache_processed_message(self, session_id: str, message_id: str, response_data: dict):
"""Cache response data for duplicate message_id protection (Section 17)."""
if session_id not in self.dedup_store:
self.dedup_store[session_id] = {}
self.dedup_store[session_id][message_id] = response_data
def get_cached_message_response(self, session_id: str, message_id: str) -> Optional[dict]:
"""Return cached response for message_id if previously processed."""
return self.dedup_store.get(session_id, {}).get(message_id)
manager = ConnectionManager()
@@ -0,0 +1,225 @@
"""
WebSocket Route Handler (Section 2, 3, 4, 15, 16, 22, 23, 24, 25).
Endpoint: WS /api/v1/ws/onboarding/{session_id}
Handles connection lifecycle, authentication, session validation, input sanitization, rate limiting, and message dispatch.
"""
import json
import logging
from typing import Optional
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Depends, Query, status
from sqlalchemy.orm import Session
from app.database.database import get_db
from app.core.security import get_auth_context, verify_session_ownership, AuthContext
from app.services.onboarding_service import OnboardingService
from app.services.websocket_service import WebSocketService
from app.api.websocket.manager import manager
from app.api.websocket.schemas import (
WSEventType,
WSErrorCode,
WSConnectionAck,
WSPatientMessage,
WSErrorEvent,
)
from app.database.database import SessionLocal
logger = logging.getLogger(__name__)
router = APIRouter(
prefix="/api/v1/ws/onboarding",
tags=["WebSocket Onboarding"],
)
# In-memory per-connection rate limiter (Section 26)
# Maps session_id -> list of message timestamps
WS_RATE_LIMIT_STORE = {}
def check_ws_rate_limit(session_id: str, limit: int = 30, window_sec: int = 60) -> bool:
import time
now = time.time()
timestamps = WS_RATE_LIMIT_STORE.get(session_id, [])
timestamps = [t for t in timestamps if now - t < window_sec]
if len(timestamps) >= limit:
return False
timestamps.append(now)
WS_RATE_LIMIT_STORE[session_id] = timestamps
return True
@router.websocket("/{session_id}")
async def websocket_onboarding_endpoint(
websocket: WebSocket,
session_id: str,
token: Optional[str] = Query(None),
x_api_key: Optional[str] = Query(None, alias="api_key"),
x_patient_id: Optional[str] = Query(None, alias="patient_id"),
):
"""
Real-Time Patient Onboarding WebSocket Endpoint.
WS /api/v1/ws/onboarding/{session_id}
"""
db = SessionLocal()
try:
await _handle_websocket_lifecycle(
websocket=websocket,
session_id=session_id,
db=db,
)
finally:
db.close()
async def _handle_websocket_lifecycle(
websocket: WebSocket,
session_id: str,
db: Session,
):
onboarding_service = OnboardingService()
ws_service = WebSocketService()
await websocket.accept()
# Extract query params directly from WebSocket request URL (Section 25)
query_token = websocket.query_params.get("token")
query_api_key = websocket.query_params.get("api_key")
query_patient_id = websocket.query_params.get("patient_id")
auth_token = query_token or query_api_key or websocket.headers.get("x-api-key")
if not auth_token:
# Allow test connection token if in header or subprotocol
sec_protocol = websocket.headers.get("sec-websocket-protocol")
if sec_protocol:
auth_token = sec_protocol
try:
auth_context = get_auth_context(
api_key=auth_token,
credentials=None,
x_patient_id=query_patient_id or websocket.headers.get("x-patient-id")
)
except Exception as e:
logger.warning("websocket_auth_failed", extra={"session_id": session_id, "error": str(e)})
await websocket.send_json(
WSErrorEvent(code=WSErrorCode.UNAUTHORIZED.value, message="Authentication failed.").model_dump()
)
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
return
# 2. Validate session exists & active (Section 24)
session = onboarding_service.get_session(db, session_id)
if not session:
logger.warning("websocket_session_not_found", extra={"session_id": session_id})
await websocket.send_json(
WSErrorEvent(code=WSErrorCode.SESSION_NOT_FOUND.value, message="Session not found.").model_dump()
)
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
return
# 3. Verify session ownership (Section 25)
try:
verify_session_ownership(session.patient_id, auth_context)
except Exception as e:
logger.warning("websocket_unauthorized_session_access", extra={"session_id": session_id, "error": str(e)})
await websocket.send_json(
WSErrorEvent(code=WSErrorCode.UNAUTHORIZED.value, message="Unauthorized session access.").model_dump()
)
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
return
if session.status != "in_progress":
logger.warning("websocket_session_expired", extra={"session_id": session_id})
await websocket.send_json(
WSErrorEvent(code=WSErrorCode.SESSION_EXPIRED.value, message="Session expired or completed.").model_dump()
)
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
return
# 4. Register connection & send ACK (Section 3 & 4)
await manager.connect(session_id, websocket)
ack = WSConnectionAck(session_id=session_id, status="connected").model_dump()
await manager.send_json(websocket, ack)
try:
while True:
# 5. Receive text message
data_text = await websocket.receive_text()
# 6. Check Rate Limiting (Section 26)
if not check_ws_rate_limit(session_id, limit=30, window_sec=60):
await manager.send_json(
websocket,
WSErrorEvent(
code=WSErrorCode.RATE_LIMITED.value,
message="Please wait a moment before sending another message."
).model_dump()
)
continue
# 7. Validate JSON structure & size (Section 15 & 16)
if len(data_text) > 5000:
await manager.send_json(
websocket,
WSErrorEvent(
code=WSErrorCode.MESSAGE_TOO_LARGE.value,
message="Message exceeds maximum allowed length of 5000 characters."
).model_dump()
)
continue
try:
payload = json.loads(data_text)
except Exception:
await manager.send_json(
websocket,
WSErrorEvent(
code=WSErrorCode.INVALID_MESSAGE.value,
message="Please send a valid JSON message."
).model_dump()
)
continue
if not isinstance(payload, dict) or payload.get("type") != WSEventType.PATIENT_MESSAGE.value:
await manager.send_json(
websocket,
WSErrorEvent(
code=WSErrorCode.INVALID_MESSAGE.value,
message="Unsupported message type. Expected 'patient.message'."
).model_dump()
)
continue
message_id = str(payload.get("message_id") or "").strip()
text = str(payload.get("text") or "").strip()
if not message_id or not text:
await manager.send_json(
websocket,
WSErrorEvent(
code=WSErrorCode.INVALID_MESSAGE.value,
message="Message must include non-empty 'message_id' and 'text'."
).model_dump()
)
continue
# 8. Session-level lock for sequential processing per session (Section 18 & 19)
async with manager.get_session_lock(session_id):
await ws_service.handle_patient_message(
websocket=websocket,
db=db,
session_id=session_id,
message_id=message_id,
text=text,
)
except WebSocketDisconnect:
manager.disconnect(session_id, websocket)
logger.info("websocket_client_disconnected", extra={"session_id": session_id})
except Exception as e:
manager.disconnect(session_id, websocket)
logger.error("websocket_unexpected_error", extra={"session_id": session_id, "error": str(e)})
@@ -0,0 +1,91 @@
"""
WebSocket Protocol Schemas and Event Constants (Section 5, 6, 15).
Centralized definitions for event types, error codes, and message models.
"""
from enum import Enum
from typing import Optional, Any, Dict
from pydantic import BaseModel, Field
class WSEventType(str, Enum):
CONNECTION_ACK = "connection.ack"
PATIENT_MESSAGE = "patient.message"
ASSISTANT_MESSAGE = "assistant.message"
PROCESSING_STARTED = "processing.started"
PROCESSING_COMPLETED = "processing.completed"
TYPING_STARTED = "typing.started"
TYPING_STOPPED = "typing.stopped"
STATE_UPDATED = "state.updated"
SAFETY_ALERT = "safety.alert"
ONBOARDING_COMPLETED = "onboarding.completed"
ERROR = "error"
SESSION_ENDED = "session.ended"
class WSErrorCode(str, Enum):
INVALID_MESSAGE = "INVALID_MESSAGE"
MESSAGE_TOO_LARGE = "MESSAGE_TOO_LARGE"
UNAUTHORIZED = "UNAUTHORIZED"
SESSION_NOT_FOUND = "SESSION_NOT_FOUND"
SESSION_EXPIRED = "SESSION_EXPIRED"
PROCESSING_ERROR = "PROCESSING_ERROR"
AI_TIMEOUT = "AI_TIMEOUT"
AI_UNAVAILABLE = "AI_UNAVAILABLE"
RATE_LIMITED = "RATE_LIMITED"
INVALID_STATE = "INVALID_STATE"
INTERNAL_ERROR = "INTERNAL_ERROR"
class WSConnectionAck(BaseModel):
type: str = WSEventType.CONNECTION_ACK.value
session_id: str
status: str = "connected"
class WSPatientMessage(BaseModel):
type: str = WSEventType.PATIENT_MESSAGE.value
message_id: str = Field(..., description="Unique client message ID")
text: str = Field(..., max_length=5000, description="Patient text message")
class WSAssistantMessage(BaseModel):
type: str = WSEventType.ASSISTANT_MESSAGE.value
message_id: str
reply: str
language: str = "en-IN"
class WSTypingEvent(BaseModel):
type: str # typing.started | typing.stopped
class WSProcessingStartedEvent(BaseModel):
type: str = WSEventType.PROCESSING_STARTED.value
class WSProcessingCompletedEvent(BaseModel):
type: str = WSEventType.PROCESSING_COMPLETED.value
processing_time_ms: int
class WSSafetyAlertEvent(BaseModel):
type: str = WSEventType.SAFETY_ALERT.value
level: str = "emergency" # emergency | crisis
message: Optional[str] = None
class WSStateUpdatedEvent(BaseModel):
type: str = WSEventType.STATE_UPDATED.value
session_id: str
class WSOnboardingCompletedEvent(BaseModel):
type: str = WSEventType.ONBOARDING_COMPLETED.value
session_id: str
class WSErrorEvent(BaseModel):
type: str = WSEventType.ERROR.value
code: str
message: str
+24
View File
@@ -21,6 +21,10 @@ GEMINI_MODEL = os.getenv("GEMINI_MODEL", "gemini-3.6-flash")
SARVAM_API_KEY = os.getenv("SARVAM_API_KEY", "")
# Security Keys
API_SECRET_KEY = os.getenv("API_SECRET_KEY", "skyheal_secure_api_key_2026")
# Destination Hospital EHR / Patient API
PATIENT_API_BASE_URL = os.getenv("PATIENT_API_BASE_URL", "")
PATIENT_API_TOKEN = os.getenv("PATIENT_API_TOKEN", "")
@@ -33,6 +37,13 @@ DEFAULT_CORS_ORIGINS = [
"http://127.0.0.1:5173",
"http://localhost:8000",
"http://127.0.0.1:8000",
"http://localhost:5500",
"http://127.0.0.1:5500",
"http://localhost:5501",
"http://127.0.0.1:5501",
"http://localhost:8080",
"http://127.0.0.1:8080",
"null",
"https://onboarding.skyheal.ai",
]
@@ -42,3 +53,16 @@ if cors_raw:
else:
CORS_ORIGINS = DEFAULT_CORS_ORIGINS
class Settings:
ai_timeout_seconds: float = float(os.getenv("AI_TIMEOUT_SECONDS", "3.5"))
session_timeout_minutes: int = int(os.getenv("SESSION_TIMEOUT_MINUTES", "30"))
max_message_length: int = int(os.getenv("MAX_MESSAGE_LENGTH", "5000"))
max_response_words: int = int(os.getenv("MAX_RESPONSE_WORDS", "20"))
llm_retry_count: int = int(os.getenv("LLM_RETRY_COUNT", "2"))
supported_languages: list[str] = ["en", "ta", "hi", "te", "kn"]
settings = Settings()
+13 -2
View File
@@ -1,7 +1,7 @@
import os
from pathlib import Path
from dotenv import load_dotenv
from sqlalchemy import create_engine
from sqlalchemy import create_engine, event
from sqlalchemy.orm import DeclarativeBase, sessionmaker
load_dotenv()
@@ -24,10 +24,21 @@ else:
engine = create_engine(
DATABASE_URL,
connect_args={
"check_same_thread": False
"check_same_thread": False,
"timeout": 15,
},
)
@event.listens_for(engine, "connect")
def set_sqlite_pragmas(dbapi_connection, connection_record):
if "sqlite" in DATABASE_URL:
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA journal_mode=WAL;")
cursor.execute("PRAGMA busy_timeout=10000;")
cursor.execute("PRAGMA synchronous=NORMAL;")
cursor.close()
SessionLocal = sessionmaker(
bind=engine,
autoflush=False,
@@ -0,0 +1,35 @@
"""
Domain models package.
"""
from app.domain.models.patient_state import (
SymptomItem,
MedicationItem,
AllergyItem,
MedicalHistoryItem,
SurgicalHistoryItem,
PreviousEpisodeItem,
FamilyHistoryItem,
FieldContainer,
PatientState,
)
from app.domain.models.extraction import ExtractionResult, QuestionIntent
from app.domain.models.contracts import ResponseContext, ResponseResult, ResponseType, FactConfidence
__all__ = [
"SymptomItem",
"MedicationItem",
"AllergyItem",
"MedicalHistoryItem",
"SurgicalHistoryItem",
"PreviousEpisodeItem",
"FamilyHistoryItem",
"FieldContainer",
"PatientState",
"ExtractionResult",
"QuestionIntent",
"ResponseContext",
"ResponseResult",
"ResponseType",
"FactConfidence",
]
@@ -0,0 +1,67 @@
"""
Response contracts, enums, and data models (Rules 102, 103, 104, 105, 116):
- ResponseType Enum
- QuestionIntent Enum
- ResponseContext Input Contract
- ResponseResult Output Contract
- FactConfidence Model
"""
from enum import Enum
from typing import Optional, List, Dict, Any
from pydantic import BaseModel, Field
class ResponseType(str, Enum):
GREETING = "greeting"
ACKNOWLEDGEMENT = "acknowledgement"
FOLLOW_UP = "follow_up"
CLARIFICATION = "clarification"
REFUSAL = "refusal"
UNKNOWN = "unknown"
SAFETY = "safety"
CRISIS = "crisis"
COMPLETION = "completion"
class QuestionIntent(str, Enum):
SYMPTOM_NAME = "symptom_name"
SYMPTOM_DURATION = "symptom_duration"
SYMPTOM_SEVERITY = "symptom_severity"
SYMPTOM_LOCATION = "symptom_location"
SYMPTOM_ONSET = "symptom_onset"
SYMPTOM_FREQUENCY = "symptom_frequency"
SYMPTOM_TRIGGER = "symptom_trigger"
ASSOCIATED_SYMPTOMS = "associated_symptoms"
MEDICAL_HISTORY = "medical_history"
MEDICATIONS = "medications"
ALLERGIES = "allergies"
FAMILY_HISTORY = "family_history"
SURGICAL_HISTORY = "surgical_history"
class ResponseContext(BaseModel):
language: str = "en"
patient_message: str
intent: str = "fact_extraction"
primary_symptom: Optional[str] = None
missing_information: Optional[str] = None
selected_question: Optional[str] = None
question_intent: Optional[QuestionIntent] = None
safety_status: str = "normal"
onboarding_completed: bool = False
current_state: Optional[Dict[str, Any]] = None
class ResponseResult(BaseModel):
response: str
language: str = "en"
question_intent: Optional[str] = None
response_type: ResponseType = ResponseType.FOLLOW_UP
class FactConfidence(BaseModel):
source: str = "patient"
method: str = "explicit"
confidence: float = 1.0
@@ -0,0 +1,28 @@
"""
Extraction result and question intent domain models.
"""
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field
class ExtractedFact(BaseModel):
category: str # 'symptom', 'medication', 'allergy', 'medical_history', 'family_history', etc.
data: Dict[str, Any]
class ExtractionResult(BaseModel):
raw_message: str
normalized_message: str
detected_language: str = "en"
updates: Dict[str, Any] = Field(default_factory=dict)
is_emergency: bool = False
is_crisis: bool = False
emergency_reason: Optional[str] = None
class QuestionIntent(BaseModel):
intent_code: str # e.g., 'reason_for_visit', 'symptom_duration', 'medical_history'
field: str
attribute: Optional[str] = None
target_symptom: Optional[str] = None
@@ -0,0 +1,87 @@
"""
Strongly typed domain models for Patient Onboarding state management.
"""
from typing import Any, Dict, List, Optional, Union
from pydantic import BaseModel, Field
class SymptomItem(BaseModel):
name: str
canonical_name: Optional[str] = None
duration: Optional[str] = None
severity: Optional[str] = None
location: Optional[str] = None
frequency: Optional[str] = None
onset: Optional[str] = None
pattern: Optional[str] = None
trigger: Optional[str] = None
relieving_factor: Optional[str] = None
associated_findings: Optional[str] = None
class MedicationItem(BaseModel):
name: str
canonical_name: Optional[str] = None
dosage: Optional[str] = None
frequency: Optional[str] = None
duration: Optional[str] = None
purpose: Optional[str] = None
class AllergyItem(BaseModel):
name: str
canonical_name: Optional[str] = None
type: Optional[str] = None # e.g., 'drug', 'food', 'environment'
reaction: Optional[str] = None # e.g., 'rash', 'swelling'
class MedicalHistoryItem(BaseModel):
condition: str
canonical_name: Optional[str] = None
duration: Optional[str] = None
status: Optional[str] = "active"
details: Optional[str] = None
class SurgicalHistoryItem(BaseModel):
procedure: str
body_site: Optional[str] = None
approximate_date: Optional[str] = None
reason: Optional[str] = None
class PreviousEpisodeItem(BaseModel):
description: str
time: Optional[str] = None
outcome: Optional[str] = None
class FamilyHistoryItem(BaseModel):
relation: str # e.g., 'father', 'mother', 'sibling'
condition: str
canonical_name: Optional[str] = None
class FieldContainer(BaseModel):
status: str = "not_collected" # 'not_collected' | 'reported' | 'confirmed_none' | 'unknown' | 'declined'
items: List[Dict[str, Any]] = Field(default_factory=list)
class SpecialtyConfidence(BaseModel):
code: str
name: str
confidence: float = 1.0
evidence: List[str] = Field(default_factory=list)
class PatientState(BaseModel):
reason_for_visit: Optional[str] = None
symptoms: List[SymptomItem] = Field(default_factory=list)
medical_history: Union[FieldContainer, List[MedicalHistoryItem], Dict[str, Any]] = Field(default_factory=list)
medications: Union[FieldContainer, List[MedicationItem], Dict[str, Any]] = Field(default_factory=list)
allergies: Union[FieldContainer, List[AllergyItem], Dict[str, Any]] = Field(default_factory=list)
surgical_history: List[SurgicalHistoryItem] = Field(default_factory=list)
previous_episodes: List[PreviousEpisodeItem] = Field(default_factory=list)
family_history: List[FamilyHistoryItem] = Field(default_factory=list)
additional_information: List[Dict[str, Any]] = Field(default_factory=list)
+13 -13
View File
@@ -6,6 +6,7 @@ from fastapi.middleware.cors import CORSMiddleware
from app.api.documents import router as documents_router
from app.api.onboarding import router as onboarding_router
from app.api.voice_onboarding import router as voice_router
from app.api.websocket.routes import router as websocket_router
from app.database import models
from app.database.database import Base, engine
@@ -69,23 +70,21 @@ app.add_middleware(
app.include_router(onboarding_router)
app.include_router(voice_router)
app.include_router(documents_router)
app.include_router(websocket_router)
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
import os
from fastapi.responses import HTMLResponse
docs_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "docs")
if os.path.exists(docs_dir):
app.mount("/static", StaticFiles(directory=docs_dir), name="static")
@app.get("/ws-tester", response_class=HTMLResponse, tags=["WebSocket Onboarding UI"])
@app.get("/ws-ui", response_class=HTMLResponse, include_in_schema=False)
async def get_ws_tester_page():
"""Serves sample UI for testing WebSocket patient onboarding."""
static_file_path = os.path.join(os.path.dirname(__file__), "static", "ws_tester.html")
if os.path.exists(static_file_path):
with open(static_file_path, "r", encoding="utf-8") as f:
return HTMLResponse(content=f.read())
return HTMLResponse("<h2>ws_tester.html not found</h2>", status_code=404)
@app.get("/")
@app.get("/dashboard")
async def serve_dashboard():
dashboard_path = os.path.join(docs_dir, "index.html")
if os.path.exists(dashboard_path):
return FileResponse(dashboard_path)
return {"message": "CureSelect AI Voice Backend Ready"}
@app.get("/health")
async def health():
@@ -100,3 +99,4 @@ async def health():
"vision_documents": "Gemini 1.5 Flash",
},
}
@@ -37,7 +37,16 @@ class StartOnboardingResponse(BaseModel):
class SendMessageRequest(BaseModel):
message: str = Field(..., min_length=1)
message: str = Field(..., min_length=1, max_length=5000)
class ErrorDetail(BaseModel):
code: str
message: str
class ErrorResponse(BaseModel):
error: ErrorDetail
class SendMessageResponse(BaseModel):
@@ -1,186 +0,0 @@
import json
from datetime import datetime, timezone
from typing import Dict, Any, List, Optional
from uuid import uuid4
from sqlalchemy.orm import Session
from app.database.models import OnboardingSession, OnboardingState, OnboardingMessage
def export_session_to_fhir_bundle(session_id: str, db: Session) -> Dict[str, Any]:
"""
Exports a completed or in-progress onboarding session state into standard HL7 FHIR R4 Bundle.
Resources Generated:
1. Patient (Demographics)
2. Encounter (Clinical Intake Session)
3. Condition (Symptoms & Past Medical History)
4. MedicationStatement (Current Active Medications)
5. AllergyIntolerance (Confirmed Allergies)
"""
sess = db.query(OnboardingSession).filter(OnboardingSession.session_id == session_id).first()
if not sess:
raise ValueError(f"Session {session_id} not found.")
state_obj = db.query(OnboardingState).filter(OnboardingState.session_id == session_id).first()
state_data = json.loads(state_obj.state_json) if state_obj and state_obj.state_json else {}
now_iso = datetime.now(timezone.utc).isoformat()
patient_ref = f"Patient/{sess.patient_id or 'PAT-UNKNOWN'}"
encounter_id = f"enc-{session_id.lower()}"
entries: List[Dict[str, Any]] = []
# 1. FHIR Patient Resource
patient_resource = {
"resourceType": "Patient",
"id": sess.patient_id or "PAT-UNKNOWN",
"identifier": [{
"system": "http://hospital.org/patients",
"value": sess.patient_id or "PAT-UNKNOWN"
}],
"name": [{
"text": sess.patient_name or "Patient",
"family": (sess.patient_name or "Patient").split()[-1],
"given": (sess.patient_name or "Patient").split()[:-1] or [sess.patient_name or "Patient"]
}],
"gender": sess.gender if sess.gender in ["male", "female", "other"] else "unknown"
}
entries.append({"resource": patient_resource})
# 2. FHIR Encounter Resource
encounter_resource = {
"resourceType": "Encounter",
"id": encounter_id,
"status": "finished" if sess.status == "completed" else "in-progress",
"class": {
"system": "http://terminology.hl7.org/CodeSystem/v3-ActCode",
"code": "AMB",
"display": "ambulatory"
},
"type": [{
"coding": [{
"system": "http://snomed.info/sct",
"code": "32485007",
"display": "Hospital admission intake"
}],
"text": sess.category_name or "General Intake"
}],
"subject": {"reference": patient_ref},
"period": {
"start": sess.created_at.isoformat() if sess.created_at else now_iso
}
}
entries.append({"resource": encounter_resource})
# 3. FHIR Condition Resources (Symptoms)
symptoms = state_data.get("symptoms", [])
if isinstance(symptoms, list):
for idx, sym in enumerate(symptoms):
if isinstance(sym, dict) and sym.get("name"):
cond_resource = {
"resourceType": "Condition",
"id": f"cond-sym-{idx+1}-{uuid4().hex[:4]}",
"clinicalStatus": {
"coding": [{
"system": "http://terminology.hl7.org/CodeSystem/condition-clinical",
"code": "active"
}]
},
"category": [{
"coding": [{
"system": "http://terminology.hl7.org/CodeSystem/condition-category",
"code": "encounter-diagnosis",
"display": "Encounter Diagnosis"
}]
}],
"code": {
"text": sym["name"]
},
"subject": {"reference": patient_ref},
"encounter": {"reference": f"Encounter/{encounter_id}"},
"onsetDateTime": now_iso,
"note": [{"text": f"Duration: {sym.get('duration', 'N/A')}, Severity: {sym.get('severity', 'N/A')}"}]
}
entries.append({"resource": cond_resource})
# 4. FHIR Condition Resources (Medical History)
history = state_data.get("medical_history", [])
if isinstance(history, list):
for idx, hist in enumerate(history):
cond_name = hist.get("condition") if isinstance(hist, dict) else str(hist)
if cond_name:
cond_resource = {
"resourceType": "Condition",
"id": f"cond-hist-{idx+1}-{uuid4().hex[:4]}",
"clinicalStatus": {
"coding": [{
"system": "http://terminology.hl7.org/CodeSystem/condition-clinical",
"code": "active"
}]
},
"category": [{
"coding": [{
"system": "http://terminology.hl7.org/CodeSystem/condition-category",
"code": "problem-list-item",
"display": "Problem List Item"
}]
}],
"code": {
"text": cond_name
},
"subject": {"reference": patient_ref}
}
entries.append({"resource": cond_resource})
# 5. FHIR MedicationStatement Resources
medications = state_data.get("medications", [])
if isinstance(medications, list):
for idx, med in enumerate(medications):
med_name = med.get("name") if isinstance(med, dict) else str(med)
dosage_str = med.get("dosage", "") if isinstance(med, dict) else ""
if med_name:
med_resource = {
"resourceType": "MedicationStatement",
"id": f"med-{idx+1}-{uuid4().hex[:4]}",
"status": "active",
"medicationCodeableConcept": {
"text": med_name
},
"subject": {"reference": patient_ref},
"dosage": [{"text": dosage_str}] if dosage_str else []
}
entries.append({"resource": med_resource})
# 6. FHIR AllergyIntolerance Resources
allergies = state_data.get("allergies", [])
if isinstance(allergies, list):
for idx, alg in enumerate(allergies):
alg_name = alg.get("name") if isinstance(alg, dict) else str(alg)
if alg_name:
alg_resource = {
"resourceType": "AllergyIntolerance",
"id": f"alg-{idx+1}-{uuid4().hex[:4]}",
"clinicalStatus": {
"coding": [{
"system": "http://terminology.hl7.org/CodeSystem/allergyintolerance-clinical",
"code": "active"
}]
},
"code": {
"text": alg_name
},
"patient": {"reference": patient_ref}
}
entries.append({"resource": alg_resource})
# Assemble FHIR R4 Bundle
fhir_bundle = {
"resourceType": "Bundle",
"id": f"bundle-{session_id.lower()}",
"type": "collection",
"timestamp": now_iso,
"total": len(entries),
"entry": entries
}
return fhir_bundle
@@ -1,46 +0,0 @@
import re
from typing import Dict, Any, Tuple, List
# PII / PHI Redaction Patterns for HIPAA, ABDM, GDPR Compliance
PII_PATTERNS = [
# 1. India Aadhaar 12-Digit Number
(r"\b[2-9]\d{3}[-\s]?\d{4}[-\s]?\d{4}\b", "[REDACTED_AADHAAR]"),
# 2. India ABHA Health ID 14-Digit Number
(r"\b\d{2}[-\s]?\d{4}[-\s]?\d{4}[-\s]?\d{4}\b", "[REDACTED_ABHA_ID]"),
# 3. Phone Numbers (Indian +91, 10-digit, US/Intl)
(r"\b(?:\+?91[-\s]?)?[6-9]\d{9}\b", "[REDACTED_PHONE]"),
(r"\b(?:\+?1[-\s]?)?\(?\d{3}\)?[-\s]?\d{3}[-\s]?\d{4}\b", "[REDACTED_PHONE]"),
# 4. Email Addresses
(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b", "[REDACTED_EMAIL]"),
# 5. Social Security Number (US SSN)
(r"\b\d{3}[-\s]?\d{2}[-\s]?\d{4}\b", "[REDACTED_SSN]"),
# 6. Credit Card / Debit Card Numbers (16 digits)
(r"\b(?:\d{4}[-\s]?){3}\d{4}\b", "[REDACTED_PAYMENT]")
]
def anonymize_phi_text(text: str) -> Tuple[str, List[str]]:
"""
Scrubs sensitive PII/PHI identifiers from text before cloud storage or public broadcast.
Returns (anonymized_text, list_of_redacted_types).
"""
if not text or not text.strip():
return text, []
scrubbed_text = text
redacted_categories: List[str] = []
for pattern, replacement in PII_PATTERNS:
matches = re.findall(pattern, scrubbed_text, re.IGNORECASE)
if matches:
category_name = replacement.replace("[", "").replace("]", "").replace("REDACTED_", "")
if category_name not in redacted_categories:
redacted_categories.append(category_name)
scrubbed_text = re.sub(pattern, replacement, scrubbed_text, flags=re.IGNORECASE)
return scrubbed_text, redacted_categories
@@ -109,13 +109,19 @@ Return ONLY a valid JSON object matching this structure:
if GEMINI_API_KEY and GEMINI_API_KEY.strip() not in ["your_gemini_key", "your_gemini_api_key", ""]:
try:
import asyncio
import google.generativeai as genai
genai.configure(api_key=GEMINI_API_KEY)
model = genai.GenerativeModel(
GEMINI_MODEL or "gemini-1.5-flash",
generation_config={"response_mime_type": "application/json"}
from google import genai
from google.genai import types
client = genai.Client(api_key=GEMINI_API_KEY)
config = types.GenerateContentConfig(response_mime_type="application/json")
res = await asyncio.wait_for(
client.aio.models.generate_content(
model=GEMINI_MODEL or "gemini-1.5-flash",
contents=prompt,
config=config,
),
timeout=3.5
)
res = await asyncio.wait_for(model.generate_content_async(prompt), timeout=3.5)
cleaned = res.text.replace("```json", "").replace("```", "").strip()
parsed = json.loads(cleaned)
validated = PatientOnboardingResult.model_validate(parsed)
@@ -1,225 +0,0 @@
import json
from datetime import datetime, timezone
from typing import Dict, Any, List
from sqlalchemy.orm import Session
from app.database.models import OnboardingSession, OnboardingState, OnboardingMessage
# ICD-10 & SNOMED CT Clinical Terminology Dictionary
ICD10_SNOMED_MAP: Dict[str, Dict[str, str]] = {
"headache": {"icd10": "R51.9", "snomed": "25064002", "display": "Headache, unspecified"},
"fever": {"icd10": "R50.9", "snomed": "386661006", "display": "Fever, unspecified"},
"cough": {"icd10": "R05.9", "snomed": "49727002", "display": "Cough, unspecified"},
"chest pain": {"icd10": "R07.9", "snomed": "29857009", "display": "Chest pain, unspecified"},
"shortness of breath": {"icd10": "R06.02", "snomed": "267036007", "display": "Shortness of breath"},
"diabetes": {"icd10": "E11.9", "snomed": "44054006", "display": "Type 2 diabetes mellitus"},
"hypertension": {"icd10": "I10", "snomed": "38341003", "display": "Essential (primary) hypertension"},
"asthma": {"icd10": "J45.909", "snomed": "195967001", "display": "Asthma, unspecified"}
}
def generate_soap_note(session_id: str, db: Session) -> Dict[str, Any]:
"""
Compiles an onboarding session into a formal Doctor SOAP Note
(Subjective, Objective, Assessment, Plan) with candidate ICD-10 & SNOMED-CT codes.
"""
sess = db.query(OnboardingSession).filter(OnboardingSession.session_id == session_id).first()
if not sess:
raise ValueError(f"Session {session_id} not found.")
state_obj = db.query(OnboardingState).filter(OnboardingState.session_id == session_id).first()
state_data = json.loads(state_obj.state_json) if state_obj and state_obj.state_json else {}
messages = (
db.query(OnboardingMessage)
.filter(OnboardingMessage.session_id == session_id)
.order_by(OnboardingMessage.created_at.asc())
.all()
)
now_str = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
# 1. Subjective (S)
symptoms = state_data.get("symptoms", [])
chief_complaint = sess.category_name or "General Medicine Intake"
if isinstance(symptoms, list) and symptoms and isinstance(symptoms[0], dict):
chief_complaint = symptoms[0].get("name", chief_complaint).capitalize()
hpi_list = []
if isinstance(symptoms, list):
for s in symptoms:
if isinstance(s, dict) and s.get("name"):
hpi_list.append(f"{s['name'].capitalize()} (Duration: {s.get('duration', 'N/A')}, Severity: {s.get('severity', 'N/A')})")
history_list = []
med_history = state_data.get("medical_history", [])
if isinstance(med_history, list):
for h in med_history:
item = h.get("condition") if isinstance(h, dict) else str(h)
if item:
history_list.append(item)
medications_list = []
meds = state_data.get("medications", [])
if isinstance(meds, list):
for m in meds:
item = f"{m.get('name')} {m.get('dosage', '')}".strip() if isinstance(m, dict) else str(m)
if item:
medications_list.append(item)
allergies_list = []
algs = state_data.get("allergies", [])
if isinstance(algs, list):
for a in algs:
item = a.get("name") if isinstance(a, dict) else str(a)
if item:
allergies_list.append(item)
subjective = {
"chief_complaint": chief_complaint,
"history_of_present_illness": hpi_list or ["Patient reports general health query."],
"past_medical_history": history_list or ["None reported"],
"current_medications": medications_list or ["None reported"],
"allergies": allergies_list or ["No known drug allergies (NKDA)"]
}
# 2. Objective (O)
nurse_esc = state_data.get("nurse_escalation", {})
pain_score = nurse_esc.get("pain_score", 3) if nurse_esc else 3
objective = {
"vitals": {
"temperature": "Reported Normal / Afebrile" if pain_score < 7 else "Elevated",
"heart_rate": "Normal SINUS Rhythm",
"pain_scale": f"{pain_score}/10"
},
"acoustic_distress_level": "High Distress" if pain_score >= 8 else "Normal Conversational Tone",
"examination_type": "AI Virtual Voice Triage & Self-Reported Symptoms"
}
# 3. Assessment (A) - ICD-10 & SNOMED CT Mapping
candidate_diagnoses = []
if isinstance(symptoms, list):
for s in symptoms:
name_key = s.get("name", "").lower() if isinstance(s, dict) else str(s).lower()
for key, mapping in ICD10_SNOMED_MAP.items():
if key in name_key or name_key in key:
candidate_diagnoses.append({
"condition": mapping["display"],
"icd10_code": mapping["icd10"],
"snomed_ct_code": mapping["snomed"]
})
break
if not candidate_diagnoses:
candidate_diagnoses.append({
"condition": "General Symptomatic Intake",
"icd10_code": "Z00.00",
"snomed_ct_code": "308292007"
})
assessment = {
"candidate_diagnoses": candidate_diagnoses,
"clinical_impression": f"Patient presents with {chief_complaint.lower()}. Triage status: {sess.status.upper()}."
}
# 4. Plan (P)
drug_warnings = state_data.get("drug_safety_warnings", [])
plan = {
"recommended_specialty": sess.category_name or "General Medicine",
"suggested_investigations": ["Routine Complete Blood Count (CBC)", "Vitals Check", "Physician Examination"],
"drug_safety_flags": [w.get("description") for w in drug_warnings] if drug_warnings else ["No active drug interaction warnings"],
"follow_up": "Physician evaluation upon arrival at clinic desk."
}
soap_note = {
"session_id": session_id,
"patient": {
"id": sess.patient_id or "PAT-UNKNOWN",
"name": sess.patient_name or "Patient",
"gender": sess.gender or "unknown",
"age": sess.age or "N/A"
},
"generated_at": now_str,
"subjective": subjective,
"objective": objective,
"assessment": assessment,
"plan": plan
}
return soap_note
def generate_soap_html_report(soap_note: Dict[str, Any]) -> str:
"""
Renders a clean, modern HTML printable report for doctor chart insertion.
"""
p = soap_note["patient"]
sub = soap_note["subjective"]
obj = soap_note["objective"]
ass = soap_note["assessment"]
pln = soap_note["plan"]
diagnoses_html = "".join(
f"<li><strong>{d['condition']}</strong> — ICD-10: <code>{d['icd10_code']}</code> | SNOMED-CT: <code>{d['snomed_ct_code']}</code></li>"
for d in ass["candidate_diagnoses"]
)
html = f"""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>SOAP Note - {p['name']}</title>
<style>
body {{ font-family: Arial, sans-serif; margin: 30px; color: #1e293b; background: #f8fafc; }}
.card {{ background: white; padding: 25px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.08); max-width: 800px; margin: auto; }}
h1 {{ color: #0f172a; border-bottom: 2px solid #2563eb; padding-bottom: 8px; font-size: 22px; }}
h2 {{ color: #2563eb; font-size: 16px; margin-top: 20px; text-transform: uppercase; letter-spacing: 0.5px; }}
.meta {{ display: flex; justify-content: space-between; background: #f1f5f9; padding: 12px; border-radius: 6px; font-size: 14px; margin-bottom: 20px; }}
ul {{ margin: 5px 0; padding-left: 20px; }}
li {{ margin-bottom: 4px; }}
code {{ background: #e2e8f0; padding: 2px 6px; border-radius: 4px; font-family: monospace; font-size: 13px; }}
.footer {{ margin-top: 30px; text-align: center; font-size: 12px; color: #64748b; }}
</style>
</head>
<body>
<div class="card">
<h1>Clinical SOAP Note & Intake Summary</h1>
<div class="meta">
<div><strong>Patient Name:</strong> {p['name']} ({p['gender'].capitalize()}, Age: {p['age']})</div>
<div><strong>Session ID:</strong> {soap_note['session_id']}</div>
<div><strong>Date:</strong> {soap_note['generated_at']}</div>
</div>
<h2>Subjective (S)</h2>
<p><strong>Chief Complaint:</strong> {sub['chief_complaint']}</p>
<p><strong>History of Present Illness:</strong></p>
<ul>{''.join(f'<li>{h}</li>' for h in sub['history_of_present_illness'])}</ul>
<p><strong>Past Medical History:</strong> {', '.join(sub['past_medical_history'])}</p>
<p><strong>Current Medications:</strong> {', '.join(sub['current_medications'])}</p>
<p><strong>Allergies:</strong> {', '.join(sub['allergies'])}</p>
<h2>Objective (O)</h2>
<p><strong>Pain Scale:</strong> {obj['vitals']['pain_scale']} | <strong>Distress Level:</strong> {obj['acoustic_distress_level']}</p>
<p><strong>Examination Type:</strong> {obj['examination_type']}</p>
<h2>Assessment (A)</h2>
<p><strong>Clinical Impression:</strong> {ass['clinical_impression']}</p>
<p><strong>Candidate Diagnoses & Coding:</strong></p>
<ul>{diagnoses_html}</ul>
<h2>Plan (P)</h2>
<p><strong>Triage Specialty:</strong> {pln['recommended_specialty']}</p>
<p><strong>Suggested Investigations:</strong> {', '.join(pln['suggested_investigations'])}</p>
<p><strong>Drug Safety Flags:</strong> {', '.join(pln['drug_safety_flags'])}</p>
<p><strong>Follow-Up:</strong> {pln['follow_up']}</p>
<div class="footer">
Generated automatically by CureSelect AI Clinical Voice Intake Engine | Verified HL7 FHIR & ICD-10 Compliant
</div>
</div>
</body>
</html>
"""
return html
@@ -57,23 +57,26 @@ class VoiceService:
lang_prefix = language_code.lower()[:2]
raw_transcript = ""
# 1. Primary: Sarvam AI Saaras v3 for Indic Code-Switching (Tanglish, Hinglish, Telglish, Kanglish)
# 1. Primary: Sarvam AI Saaras v3 for Native Indian Languages (Ultra-fast Indic STT)
if (
self.sarvam_key
and self.sarvam_key.strip()
and self.sarvam_key not in ["your_sarvam_api_key", "your_sarvam_key", ""]
and lang_prefix in ["ta", "hi", "te", "kn", "bn", "mr", "gu", "ml", "pa", "od", "en"]
):
try:
# Use 'unknown' or requested code for auto code-switching language detection
target_lang = "unknown" if language_code in ["auto", "unknown", "code_switching", "multilingual"] else language_code
async with httpx2.AsyncClient(timeout=6.0) as client:
files = {"file": (filename, audio_bytes, "audio/webm")}
async with httpx2.AsyncClient(timeout=5.0) as client:
files = {
"file": (filename, audio_bytes, "audio/webm")
}
data = {
"model": "saaras:v3",
"language_code": target_lang,
"language_code": language_code,
"with_diacritics": "true",
}
headers = {"api-subscription-key": self.sarvam_key}
headers = {
"api-subscription-key": self.sarvam_key
}
res = await client.post(
"https://api.sarvam.ai/speech-to-text",
files=files,
@@ -88,23 +91,7 @@ class VoiceService:
except Exception as e:
print(f"[Sarvam STT Error]: {e}")
# 2. Secondary Fallback: OpenAI Whisper-1 with Medical Code-Switching Prompt
if not raw_transcript and self.openai_key and self.openai_key.strip() not in ["your_openai_api_key", "your_openai_key", ""]:
try:
client = AsyncOpenAI(api_key=self.openai_key, timeout=7.0)
file_obj = ("audio.webm", audio_bytes, "audio/webm")
transcription = await client.audio.transcriptions.create(
model="whisper-1",
file=file_obj,
prompt="Indian medical patient intake code-switching (Tanglish, Hinglish, Telglish, Kanglish): 10 days-ah, headache, fever, iruku, sapudren, Metformin 500mg, 3 din se hai.",
temperature=0.0
)
if transcription and transcription.text:
raw_transcript = transcription.text.strip()
except Exception as e:
print(f"[Whisper STT Fallback Error]: {e}")
# Apply Medical Speech Normalizer to handle multi-dialect expressions
# Apply Medical Speech Normalizer to eliminate phonetic mishearings
normalized = normalize_medical_speech(raw_transcript, language_code=language_code)
return normalized
@@ -113,23 +100,11 @@ class VoiceService:
text: str,
language_code: str = "en-IN",
audio_id: str | None = None,
patient_age: int | None = None,
) -> tuple[str, bytes]:
lang_prefix = language_code.lower()[:2]
if not audio_id:
audio_id = f"aud_{uuid4().hex[:12]}"
# Calculate Age-Adaptive Voice Speed & Tone Pace Parameters
pace = 1.0
openai_speed = 1.0
if patient_age is not None:
if patient_age > 60:
pace = 0.85 # Slow down speech rate by 15% for elderly patients
openai_speed = 0.85
elif patient_age < 12:
pace = 0.95 # Warm, reassuring speech pace for pediatric/parents
openai_speed = 0.95
# Clean punctuation and brackets before synthesis
cleaned_text = (
text.replace("(", "")
@@ -159,7 +134,6 @@ class VoiceService:
"target_language_code": language_code,
"speaker": speaker,
"model": "bulbul:v3",
"pace": pace,
}
res = await client.post(
"https://api.sarvam.ai/text-to-speech",
@@ -0,0 +1,188 @@
"""
WebSocket Transport Service (Section 7, 8, 10, 11, 12, 13, 14, 17).
Thin bridge between WebSocket events and core onboarding business services.
Delegates all clinical logic, state management, and AI orchestration to OnboardingService/AIOnboardingService.
"""
import time
import logging
from typing import Dict, Any
from fastapi import WebSocket
from sqlalchemy.orm import Session
from app.api.websocket.manager import manager
from app.api.websocket.schemas import (
WSEventType,
WSErrorCode,
WSAssistantMessage,
WSTypingEvent,
WSProcessingStartedEvent,
WSProcessingCompletedEvent,
WSSafetyAlertEvent,
WSStateUpdatedEvent,
WSOnboardingCompletedEvent,
WSErrorEvent,
)
from app.services.onboarding_service import OnboardingService
from app.services.ai_onboarding_service import AIOnboardingService
from app.database.models import OnboardingMessage
logger = logging.getLogger(__name__)
class WebSocketService:
def __init__(self):
self.onboarding_service = OnboardingService()
self.ai_service = AIOnboardingService()
async def handle_patient_message(
self,
websocket: WebSocket,
db: Session,
session_id: str,
message_id: str,
text: str,
):
"""
Process patient message through transport pipeline (Section 7).
Enforces typing events, safety alerts, state updates, and deduplication.
"""
t0 = time.perf_counter()
# 1. Deduplication check (Section 17)
cached_resp = manager.get_cached_message_response(session_id, message_id)
if cached_resp:
logger.info("websocket_duplicate_message_received", extra={"session_id": session_id, "message_id": message_id})
await manager.send_json(websocket, cached_resp)
return
# 2. Emit processing.started
await manager.send_json(websocket, WSProcessingStartedEvent().model_dump())
# 3. Emit typing.started
await manager.send_json(websocket, WSTypingEvent(type=WSEventType.TYPING_STARTED.value).model_dump())
try:
# Fetch session & verify active status
session = self.onboarding_service.get_session(db, session_id)
if not session or session.status != "in_progress":
await manager.send_json(
websocket,
WSErrorEvent(
code=WSErrorCode.SESSION_EXPIRED.value,
message="Onboarding session is expired or not active."
).model_dump()
)
return
# Save patient message
self.onboarding_service.save_message(
db=db,
session_id=session_id,
role="patient",
message=text,
stage=session.stage,
)
state = self.onboarding_service.get_state(db, session_id)
messages = (
db.query(OnboardingMessage)
.filter(OnboardingMessage.session_id == session_id)
.order_by(OnboardingMessage.created_at.asc())
.all()
)
conversation_history = [
{"role": m.role, "message": m.message, "stage": m.stage}
for m in messages
]
# 4. Delegate to AI Orchestrator
ai_result = await self.ai_service.process_message(
session=session,
state=state,
conversation_history=conversation_history,
user_message=text,
)
# 5. Check Emergency / Crisis priority (Section 12)
is_emergency = ai_result.get("is_emergency") or ai_result.get("next_stage") == "emergency_referral"
is_crisis = ai_result.get("is_crisis") or ai_result.get("next_stage") == "crisis_handling"
if is_emergency or is_crisis:
level = "crisis" if is_crisis else "emergency"
await manager.send_json(
websocket,
WSSafetyAlertEvent(
level=level,
message="Safety event detected. Normal intake flow suspended."
).model_dump()
)
reply_text = ai_result.get("reply", "Got it. When did it start?")
next_stage = ai_result.get("next_stage", session.stage)
session.stage = next_stage
# Save assistant message
self.onboarding_service.save_message(
db=db,
session_id=session_id,
role="assistant",
message=reply_text,
stage=next_stage,
)
# Update state in DB if modified
updated_state_dict = ai_result.get("updated_state") or (ai_result.get("state_updates") if isinstance(ai_result.get("state_updates"), dict) else None)
if updated_state_dict and isinstance(updated_state_dict, dict):
self.onboarding_service.update_state(db, state, updated_state_dict)
else:
db.commit()
# 6. Emit state.updated (Section 13)
await manager.send_json(
websocket,
WSStateUpdatedEvent(session_id=session_id).model_dump()
)
# 7. Construct & emit assistant.message (Section 5)
assistant_resp = WSAssistantMessage(
message_id=f"resp_{message_id}",
reply=reply_text,
language=session.language or "en-IN"
).model_dump()
await manager.send_json(websocket, assistant_resp)
# Cache response for duplicate message protection
manager.cache_processed_message(session_id, message_id, assistant_resp)
# 8. Check onboarding completion (Section 14)
if ai_result.get("completed") or session.status == "completed":
session.status = "completed"
db.commit()
await manager.send_json(
websocket,
WSOnboardingCompletedEvent(session_id=session_id).model_dump()
)
except Exception as e:
logger.error("websocket_message_processing_failed", extra={"session_id": session_id, "error": str(e)})
await manager.send_json(
websocket,
WSErrorEvent(
code=WSErrorCode.PROCESSING_ERROR.value,
message="An error occurred while processing your message."
).model_dump()
)
finally:
# Always ensure typing state is stopped (Section 10)
await manager.send_json(
websocket,
WSTypingEvent(type=WSEventType.TYPING_STOPPED.value).model_dump()
)
# Emit processing.completed (Section 11)
latency_ms = int((time.perf_counter() - t0) * 1000)
await manager.send_json(
websocket,
WSProcessingCompletedEvent(processing_time_ms=latency_ms).model_dump()
)
@@ -1,472 +0,0 @@
import asyncio
import base64
import json
import time
from typing import Dict, Any, Optional
from uuid import uuid4
from fastapi import WebSocket, WebSocketDisconnect
from sqlalchemy.orm import Session
from app.database.database import SessionLocal
from app.database.models import OnboardingSession, OnboardingState, OnboardingMessage
from app.services.onboarding_service import OnboardingService
from app.services.voice_service import VoiceService
from app.ai.orchestrator import AIOrchestrator, merge_state_updates
class WebSocketVoiceManager:
"""
Fully backend-controlled WebSocket streaming manager with Full-Duplex Barge-In support.
1. Automatic session initialization if session_id is 'auto' or 'new'.
2. Full-Duplex Barge-In Interruption Handling: If patient speaks while AI is talking,
AI audio streaming is immediately canceled, sending 'cancel_playback' to client.
3. Automatic backend silence detection (processes turn after 1s silence).
"""
def __init__(self):
self.active_connections: Dict[str, WebSocket] = {}
self.audio_buffers: Dict[str, bytearray] = {}
self.last_audio_times: Dict[str, float] = {}
self.silence_tasks: Dict[str, asyncio.Task] = {}
self.is_processing: Dict[str, bool] = {}
self.is_ai_speaking: Dict[str, bool] = {}
self.tts_tasks: Dict[str, asyncio.Task] = {}
self.voice_service = VoiceService()
self.onboarding_service = OnboardingService()
self.orchestrator = AIOrchestrator()
async def connect(self, websocket: WebSocket, session_id: str) -> str:
await websocket.accept()
db = SessionLocal()
real_session_id = session_id
try:
# 1. Automatic Backend Session Creation if session_id is 'auto' or 'new'
if session_id in ["auto", "new", ""] or not db.query(OnboardingSession).filter(OnboardingSession.session_id == session_id).first():
real_session_id = f"VOICE-AUTO-{uuid4().hex[:8].upper()}"
session = OnboardingSession(
session_id=real_session_id,
patient_id="PAT-AUTO-GEN",
patient_name="Patient",
category_type="general",
category_code="general_medicine",
category_name="General Medicine",
language="en-IN",
status="in_progress",
stage="greeting"
)
db.add(session)
db.commit()
db.refresh(session)
self.onboarding_service.get_state(db, real_session_id)
self.active_connections[real_session_id] = websocket
self.audio_buffers[real_session_id] = bytearray()
self.last_audio_times[real_session_id] = 0.0
self.is_processing[real_session_id] = False
self.is_ai_speaking[real_session_id] = False
# Send connection handshake
await websocket.send_json({
"type": "connected",
"session_id": real_session_id,
"timestamp": time.time(),
"message": "Connected to Full-Duplex Voice Engine. Start speaking at any time."
})
# Send initial greeting audio automatically
greeting_text = self.voice_service.get_initial_greeting("Patient", "en-IN")
audio_id, audio_bytes = await self.voice_service.text_to_speech(greeting_text, "en-IN")
b64_greeting = base64.b64encode(audio_bytes).decode("utf-8") if audio_bytes else ""
await websocket.send_json({
"type": "greeting",
"reply": greeting_text,
"audio_base64": b64_greeting,
"stage": "greeting"
})
# Start background silence monitor for this session
self.silence_tasks[real_session_id] = asyncio.create_task(
self._silence_monitor(websocket, real_session_id)
)
return real_session_id
finally:
db.close()
def disconnect(self, session_id: str):
self._cancel_tts_task(session_id)
if session_id in self.silence_tasks:
self.silence_tasks[session_id].cancel()
del self.silence_tasks[session_id]
if session_id in self.active_connections:
del self.active_connections[session_id]
if session_id in self.audio_buffers:
del self.audio_buffers[session_id]
if session_id in self.last_audio_times:
del self.last_audio_times[session_id]
if session_id in self.is_processing:
del self.is_processing[session_id]
if session_id in self.is_ai_speaking:
del self.is_ai_speaking[session_id]
def _cancel_tts_task(self, session_id: str):
if session_id in self.tts_tasks and not self.tts_tasks[session_id].done():
self.tts_tasks[session_id].cancel()
del self.tts_tasks[session_id]
async def trigger_barge_in(self, websocket: WebSocket, session_id: str):
"""
Executes immediate Full-Duplex Barge-In Interruption:
Cancels active TTS audio streaming and notifies client audio player to stop immediately.
"""
if self.is_ai_speaking.get(session_id, False):
print(f"[Full-Duplex Barge-In] Patient interrupted AI speech for session {session_id}!")
self._cancel_tts_task(session_id)
self.is_ai_speaking[session_id] = False
try:
await websocket.send_json({
"type": "cancel_playback",
"reason": "barge_in",
"timestamp": time.time(),
"message": "AI playback canceled due to patient voice interruption."
})
except Exception:
pass
async def _silence_monitor(self, websocket: WebSocket, session_id: str):
"""
Backend Automatic Silence Detector:
Monitors incoming audio stream. If patient stops talking for >= 1.0 second,
the backend automatically processes the clinical turn.
"""
try:
while True:
await asyncio.sleep(0.2)
buf_len = len(self.audio_buffers.get(session_id, b""))
last_time = self.last_audio_times.get(session_id, 0.0)
processing = self.is_processing.get(session_id, False)
# Silence threshold: 1.0 second after last audio byte
if buf_len > 1000 and not processing and (time.time() - last_time) >= 1.0:
print(f"[Backend VAD] 1.0s Silence detected for {session_id}. Auto-processing turn...")
await self.process_audio_turn(websocket, session_id)
except asyncio.CancelledError:
pass
except Exception as e:
print(f"[Silence Monitor Error]: {e}")
async def handle_websocket_session(self, websocket: WebSocket, session_id: str):
real_sid = await self.connect(websocket, session_id)
try:
while True:
message = await websocket.receive()
# 1. Handle Binary Audio Stream (Mic bytes from patient)
if "bytes" in message and message["bytes"]:
chunk = message["bytes"]
# 🛑 FULL-DUPLEX BARGE-IN: If patient speaks while AI is talking, cancel AI speech!
if self.is_ai_speaking.get(real_sid, False):
await self.trigger_barge_in(websocket, real_sid)
self.audio_buffers[real_sid].extend(chunk)
self.last_audio_times[real_sid] = time.time()
# 2. Handle Text / Control JSON Frames
elif "text" in message and message["text"]:
try:
data = json.loads(message["text"])
msg_type = data.get("type", "")
if msg_type == "ping":
await websocket.send_json({"type": "pong", "timestamp": time.time()})
elif msg_type in ["end_of_speech", "finish_turn", "process"]:
await self.process_audio_turn(websocket, real_sid)
elif msg_type == "text_turn":
if self.is_ai_speaking.get(real_sid, False):
await self.trigger_barge_in(websocket, real_sid)
user_text = data.get("text", "").strip()
if user_text:
await self.process_text_turn(websocket, real_sid, user_text)
elif msg_type in ["interrupt", "barge_in"]:
await self.trigger_barge_in(websocket, real_sid)
except json.JSONDecodeError:
pass
except WebSocketDisconnect:
self.disconnect(real_sid)
except Exception as e:
print(f"[WebSocket Session Error]: {e}")
self.disconnect(real_sid)
async def process_audio_turn(self, websocket: WebSocket, session_id: str):
if self.is_processing.get(session_id, False):
return
self.is_processing[session_id] = True
buffer = bytes(self.audio_buffers.get(session_id, b""))
self.audio_buffers[session_id] = bytearray() # Clear buffer
if not buffer or len(buffer) < 500:
self.is_processing[session_id] = False
return
db = SessionLocal()
try:
sess = db.query(OnboardingSession).filter(OnboardingSession.session_id == session_id).first()
language = sess.language if sess else "en-IN"
finally:
db.close()
await websocket.send_json({"type": "processing", "status": "transcribing"})
transcript = await self.voice_service.speech_to_text(
audio_bytes=buffer,
filename="stream.webm",
language_code=language
)
if not transcript:
await websocket.send_json({"type": "warning", "message": "Unclear audio."})
self.is_processing[session_id] = False
return
from app.ai.speaker_diarizer import classify_speaker_turn
spk_info = classify_speaker_turn(transcript)
await websocket.send_json({
"type": "transcript",
"role": "patient",
"speaker_type": spk_info["speaker_type"],
"speaker_label": spk_info["formatted_label"],
"text": transcript
})
asyncio.create_task(self._execute_ai_turn(websocket, session_id, transcript))
async def process_text_turn(self, websocket: WebSocket, session_id: str, text: str):
if self.is_processing.get(session_id, False):
return
from app.ai.speaker_diarizer import classify_speaker_turn
spk_info = classify_speaker_turn(text)
await websocket.send_json({
"type": "transcript",
"role": "patient",
"speaker_type": spk_info["speaker_type"],
"speaker_label": spk_info["formatted_label"],
"text": text
})
asyncio.create_task(self._execute_ai_turn(websocket, session_id, text))
async def _execute_ai_turn(self, websocket: WebSocket, session_id: str, user_text: str):
start_time = time.time()
db = SessionLocal()
try:
sess = db.query(OnboardingSession).filter(OnboardingSession.session_id == session_id).first()
if not sess:
await websocket.send_json({"type": "error", "message": f"Session {session_id} not found."})
return
state_obj = db.query(OnboardingState).filter(OnboardingState.session_id == session_id).first()
current_state = json.loads(state_obj.state_json) if state_obj and state_obj.state_json else {}
history_objs = (
db.query(OnboardingMessage)
.filter(OnboardingMessage.session_id == session_id)
.order_by(OnboardingMessage.created_at.asc())
.all()
)
conv_history = [{"role": m.role, "message": m.message, "stage": m.stage} for m in history_objs]
await websocket.send_json({"type": "processing", "status": "generating_ai_response"})
# Send Instant Adaptive Conversational Backchannel ("Mm-hmm...", "I see...")
from app.voice.backchannel_library import get_random_backchannel
bc_text, bc_b64 = get_random_backchannel(sess.language or "en-IN")
await websocket.send_json({
"type": "backchannel",
"reply": bc_text,
"audio_base64": bc_b64,
"timestamp": time.time()
})
# AI Orchestrator Call
res = await self.orchestrator.process(sess, state_obj, conv_history, user_text)
reply_text = res.get("reply", "")
next_stage = res.get("next_stage", "symptom_collection")
completed = res.get("completed", False)
state_updates = res.get("state_updates", {})
# 🔒 HIPAA / ABDM / GDPR PII/PHI Anonymization
from app.services.pii_anonymizer import anonymize_phi_text
anonymized_user_text, redacted_types = anonymize_phi_text(user_text)
p_msg = OnboardingMessage(
session_id=session_id,
role="patient",
message=anonymized_user_text,
stage=sess.stage
)
ai_msg = OnboardingMessage(
session_id=session_id,
role="assistant",
message=reply_text,
stage=next_stage
)
db.add(p_msg)
db.add(ai_msg)
sess.stage = next_stage
if completed:
sess.status = "completed"
# 🏥 Distress & Pain Classifier Auto-Escalation Check
from app.ai.distress_classifier import analyze_distress_and_pain
distress_info = analyze_distress_and_pain(b"", user_text)
if distress_info["should_escalate"]:
sess.status = "escalated"
escalation_frame = {
"type": "nurse_escalation_alert",
"severity": "CRITICAL" if distress_info["pain_score"] >= 8 or distress_info["is_emergency"] else "WARNING",
"pain_score": distress_info["pain_score"],
"distress_score": distress_info["distress_score"],
"patient_name": sess.patient_name or "Patient",
"session_id": session_id,
"reasons": distress_info["reasons"],
"timestamp": time.time(),
"message": f"CRITICAL ALERT: Live Nurse Escalation Triggered! Pain Score: {distress_info['pain_score']}/10."
}
await websocket.send_json(escalation_frame)
merged_state = merge_state_updates(current_state, state_updates)
if distress_info["should_escalate"]:
merged_state["nurse_escalation"] = {
"active": True,
"pain_score": distress_info["pain_score"],
"distress_score": distress_info["distress_score"],
"reasons": distress_info["reasons"]
}
# 💊 RxNorm & FDA Drug Interaction & Safety Checker
from app.ai.drug_safety_checker import check_drug_safety
drug_warnings = check_drug_safety(
medications=merged_state.get("medications", []),
allergies=merged_state.get("allergies", []),
medical_history=merged_state.get("medical_history", [])
)
if drug_warnings:
merged_state["drug_safety_warnings"] = drug_warnings
await websocket.send_json({
"type": "drug_safety_warning",
"total_warnings": len(drug_warnings),
"warnings": drug_warnings,
"timestamp": time.time()
})
if state_obj:
state_obj.state_json = json.dumps(merged_state)
else:
db.add(OnboardingState(session_id=session_id, state_json=json.dumps(merged_state)))
db.commit()
# ⚡ 1. Speculative Speech Pre-Synthesis (Clause 1 Fast-Track < 200ms)
if reply_text:
first_clause = reply_text.split('.')[0].split('?')[0].split(',')[0].strip()
if first_clause and len(first_clause) > 3:
try:
_, clause1_bytes = await self.voice_service.text_to_speech(
first_clause,
language_code=sess.language or "en-IN",
patient_age=sess.age
)
if clause1_bytes:
clause1_b64 = base64.b64encode(clause1_bytes).decode("utf-8")
await websocket.send_json({
"type": "speculative_audio_chunk",
"clause": first_clause,
"audio_base64": clause1_b64,
"ttfa_ms": round((time.time() - start_time) * 1000, 2)
})
except Exception as e:
print(f"[Speculative Speech Error]: {e}")
# Synthesize Full Audio Response with Age-Adaptive Dynamic Pace & Tone Tuning
b64_audio = ""
audio_bytes = None
if reply_text:
audio_id, audio_bytes = await self.voice_service.text_to_speech(
reply_text,
language_code=sess.language or "en-IN",
patient_age=sess.age
)
if audio_bytes:
b64_audio = base64.b64encode(audio_bytes).decode("utf-8")
# Send complete response frame (Contains ready-to-play audio_base64 + state_updates)
await websocket.send_json({
"type": "ai_response",
"reply": reply_text,
"audio_base64": b64_audio,
"stage": next_stage,
"completed": completed,
"state_updates": merged_state,
"latency_ms": round((time.time() - start_time) * 1000, 2)
})
# Stream audio chunks asynchronously with cancellation support
if b64_audio:
self.is_ai_speaking[session_id] = True
self.tts_tasks[session_id] = asyncio.create_task(
self._stream_tts_chunks(websocket, session_id, b64_audio)
)
try:
await self.tts_tasks[session_id]
except asyncio.CancelledError:
print(f"[TTS Stream] Streaming task for {session_id} was interrupted by barge-in.")
finally:
self.is_processing[session_id] = False
db.close()
async def _stream_tts_chunks(self, websocket: WebSocket, session_id: str, b64_audio: str):
"""
Asynchronously streams base64 audio micro-chunks to client over WebSocket.
Can be canceled instantaneously if patient interrupts (barge-in).
"""
try:
await websocket.send_json({"type": "audio_start"})
chunk_size = 16384
total_len = len(b64_audio)
for i in range(0, total_len, chunk_size):
if not self.is_ai_speaking.get(session_id, False):
break
chunk = b64_audio[i:i + chunk_size]
await websocket.send_json({
"type": "audio_chunk",
"chunk": chunk,
"chunk_index": i // chunk_size
})
await asyncio.sleep(0.005)
if self.is_ai_speaking.get(session_id, False):
await websocket.send_json({"type": "audio_end"})
except asyncio.CancelledError:
pass
finally:
self.is_ai_speaking[session_id] = False
@@ -27,14 +27,14 @@ CARDIOLOGY_CONFIG = {
"collapsed",
"sudden loss of consciousness",
"cannot breathe",
"call 108",
"emergency ambulance",
"hospital emergency room",
"alert duty doctor",
],
"clarification_rules": [
"If the patient describes chest burning or heaviness, ask whether it is related to walking/exertion (angina) or happens after spicy/oily food (gastric acidity).",
"Ask if they are already taking regular BP tablets like Telma, Amlodipine, or daily blood thinners like Ecosprin.",
"Check if they have a history of previous heart stenting, bypass surgery, or family history of heart attacks at a young age.",
"If severe red-flag symptoms are present, immediately advise contacting 108 ambulance or visiting the nearest hospital emergency room.",
"If severe red-flag symptoms are present, immediately advise informing hospital emergency staff or visiting the hospital emergency room.",
],
}
@@ -0,0 +1,32 @@
DERMATOLOGY_CONFIG = {
"code": "dermatology",
"name": "Dermatology (Skin & Hair Care)",
"purpose": (
"Collect history of skin lesions, rashes, itching, acne, fungal infections, "
"duration, body distribution, trigger factors, and topical or oral treatments used."
),
"focus_areas": [
"Chief skin complaint (rash, severe itching/pruritus, eczema, acne/pimples, fungal infection, blisters, discoloration)",
"Lesion distribution and onset (face, scalp, trunk, arms, legs, localized vs generalized)",
"Associated symptoms (burning sensation, pain, scaling, oozing, bleeding)",
"Triggers and exposures (spicy/oily food, new soap/cosmetics, detergent, dust, sunlight, medications)",
"Current and prior treatments (antifungal creams like Clotrimazole, steroid creams, oral antihistamines like Cetirizine/Levocetirizine)",
"Known allergies (soaps, metals, cosmetics, drugs)",
],
"emergency_keywords": [
"sudden widespread peeling",
"stevens johnson syndrome",
"toxic epidermal necrolysis",
"severe facial swelling with breathing difficulty",
"rapidly spreading painful skin breakdown",
],
"clarification_rules": [
"If patient mentions skin itching or rash, ask how many days they have had it and if it spreads at night.",
"Check if they are using any over-the-counter steroid creams or soaps.",
"Inquire if any other family members share similar skin symptoms.",
],
}
@@ -24,7 +24,7 @@ DIABETOLOGY_CONFIG = {
"deep labored breathing",
"fruity breath",
"diabetic ketoacidosis",
"108 ambulance",
"hospital emergency staff / ER",
],
"clarification_rules": [
@@ -0,0 +1,32 @@
GASTROENTEROLOGY_CONFIG = {
"code": "gastroenterology",
"name": "Gastroenterology (Stomach & Digestive Care)",
"purpose": (
"Collect abdominal symptoms including stomach ache, acid reflux/GERD, "
"vomiting, bowel movement alterations (diarrhea/constipation), and dietary associations."
),
"focus_areas": [
"Abdominal pain location and character (upper stomach burning/acidity, right upper quadrant, lower abdominal cramps)",
"Bowel habits (loose stools/diarrhea frequency, constipation, bloody stools/melena)",
"Upper GI symptoms (nausea, persistent vomiting, heartburn, difficulty swallowing/dysphagia)",
"Relationship to meals (worse after oily/spicy food, pain relieved by eating or antacids)",
"Current GI medications (Pantoprazole/Pan-D, Omeprazole, Gelusil, ORS, probiotics)",
"History of ulcer disease, gallstones, jaundice, or liver disease",
],
"emergency_keywords": [
"vomiting blood or coffee ground material",
"black tarry stools",
"severe rigid abdomen with intolerable pain",
"high fever with severe jaundice and vomiting",
"acute abdomen",
],
"clarification_rules": [
"If abdominal pain is reported, ask exact location (upper stomach vs lower belly) and duration.",
"Check if symptom is accompanied by vomiting, loose motions, or acidity after meals.",
"Inquire if patient has taken antacids (Pan-D, Digene) or ORS.",
],
}
@@ -22,7 +22,7 @@ GENERAL_CONFIG = {
"severe bleeding",
"coughing up blood",
"very low blood pressure",
"108 ambulance",
"hospital emergency staff / ER",
],
"clarification_rules": [
@@ -0,0 +1,34 @@
NEUROLOGY_CONFIG = {
"code": "neurology",
"name": "Neurology (Brain & Nervous System)",
"purpose": (
"Collect neurological symptoms including headache character, dizziness, numbness, "
"weakness, seizure history, and past neurological evaluation."
),
"focus_areas": [
"Headache characteristics (throbbing, one-sided migraine, tension type, occipital, duration)",
"Associated neurological features (nausea, vomiting, sensitivity to light/sound, visual aura)",
"Dizziness or vertigo (spinning sensation, loss of balance, unsteady gait)",
"Focal symptoms (numbness/tingling in hands/feet, facial drooping, speech slurring, limb weakness)",
"Seizure or blackout history (loss of consciousness, involuntary movements, aura)",
"Current neurological medications (painkillers, migraine prophylactics like Propranolol/Flunarizine, anti-epileptics)",
],
"emergency_keywords": [
"sudden thunderclap headache",
"worst headache of my life",
"sudden weakness on one side of body",
"facial drooping with slurred speech",
"stroke symptoms",
"active seizure",
"loss of consciousness",
],
"clarification_rules": [
"If headache is reported, clarify duration, exact location (one side vs forehead), and if accompanied by nausea or light sensitivity.",
"If numbness or tingling is present, clarify if it affects both feet (diabetic neuropathy) or one side of the body.",
"Check if patient has a history of high blood pressure or previous stroke.",
],
}
@@ -0,0 +1,32 @@
ORTHOPEDICS_CONFIG = {
"code": "orthopedics",
"name": "Orthopedics (Bone & Joint Care)",
"purpose": (
"Collect musculoskeletal complaints including joint pain, spinal/back pain, "
"trauma history, stiffness, mobility restriction, and painkiller usage."
),
"focus_areas": [
"Primary joint/bone complaint (knee pain, lumbar back pain, neck pain, shoulder stiffness, ankle sprain)",
"Onset and progression (acute post-injury/fall vs chronic osteoarthritis/wear-and-tear)",
"Functional impact (difficulty walking, standing up from chair, climbing stairs, lifting objects)",
"Swelling, redness, deformity, or morning stiffness",
"Medications and pain management (NSAIDs like Aceclofenac/Diclofenac/Paracetamol, calcium, Vitamin D3 supplements)",
"Surgical or orthopedic history (previous fractures, knee replacement, spine surgery)",
],
"emergency_keywords": [
"open fracture with visible bone",
"inability to feel or move leg after injury",
"sudden loss of bowel or bladder control with back pain",
"cauda equina syndrome",
"severe traumatic limb injury",
],
"clarification_rules": [
"If knee or joint pain is reported, ask if it occurs during walking/stair climbing and how long it has been present.",
"Clarify if there was any recent fall, injury, or heavy lifting event.",
"Check if patient takes regular pain relief tablets or calcium supplements.",
],
}
@@ -0,0 +1,32 @@
PULMONOLOGY_CONFIG = {
"code": "pulmonology",
"name": "Pulmonology (Lungs & Respiratory Care)",
"purpose": (
"Collect respiratory history including cough type, breathlessness, wheezing, "
"asthma/COPD history, inhaler usage, and environmental/smoking exposure."
),
"focus_areas": [
"Cough character and duration (dry cough vs productive/phlegm, nocturnal cough, duration)",
"Shortness of breath (on exertion, at rest, when lying flat/orthopnea)",
"Wheezing or chest tightness ( whistling sound while breathing out)",
"Triggers (cold air, dust, pollen, smoke, exercise, seasonal changes)",
"Current inhalers and respiratory medicines (Asthalin/Salbutamol, Budecort/Budesonide, Montair-LC, antibiotics)",
"History of Asthma, COPD, Tuberculosis (TB), pneumonia, or smoking/bidi history",
],
"emergency_keywords": [
"severe acute breathlessness unable to speak full sentences",
"blue lips or fingernails / cyanosis",
"coughing up significant blood / hemoptysis",
"severe asthma attack unyielding to inhaler",
"respiratory distress",
],
"clarification_rules": [
"If cough is reported, ask whether dry or with phlegm and how many days it has persisted.",
"Check if patient experiences wheezing or breathlessness when walking or sleeping.",
"Clarify if patient uses regular inhalers (Asthalin, Budecort) or allergy tablets.",
],
}
@@ -1,18 +1,53 @@
from app.specialties.cardiology import CARDIOLOGY_CONFIG
from app.specialties.dermatology import DERMATOLOGY_CONFIG
from app.specialties.diabetology import DIABETOLOGY_CONFIG
from app.specialties.gastroenterology import GASTROENTEROLOGY_CONFIG
from app.specialties.general import GENERAL_CONFIG
from app.specialties.mental_health import MENTAL_HEALTH_CONFIG
from app.specialties.neurology import NEUROLOGY_CONFIG
from app.specialties.orthopedics import ORTHOPEDICS_CONFIG
from app.specialties.pulmonology import PULMONOLOGY_CONFIG
SPECIALTIES = {
"cardiology": CARDIOLOGY_CONFIG,
"dermatology": DERMATOLOGY_CONFIG,
"diabetology": DIABETOLOGY_CONFIG,
"gastroenterology": GASTROENTEROLOGY_CONFIG,
"general": GENERAL_CONFIG,
"mental_health": MENTAL_HEALTH_CONFIG,
"neurology": NEUROLOGY_CONFIG,
"orthopedics": ORTHOPEDICS_CONFIG,
"pulmonology": PULMONOLOGY_CONFIG,
}
CANONICAL_ALIASES = {
"general_medicine": "general",
"psychiatry": "mental_health",
}
def get_specialty_config(code: str) -> dict:
canonical_code = CANONICAL_ALIASES.get(code, code)
return SPECIALTIES.get(
code,
canonical_code,
GENERAL_CONFIG,
)
def validate_specialty_registry() -> dict:
"""
Automated validation for specialty registry completeness and integrity.
"""
canonical_codes = set(SPECIALTIES.keys())
missing_configs = []
for code, config in SPECIALTIES.items():
if not config or not isinstance(config, dict) or config.get("code") != code:
missing_configs.append(code)
return {
"valid": len(missing_configs) == 0,
"registered_count": len(SPECIALTIES),
"canonical_codes": list(canonical_codes),
"missing_configs": missing_configs,
}
File diff suppressed because it is too large Load Diff
@@ -1,45 +0,0 @@
import base64
import random
from typing import Dict, List
# Pre-synthesized acoustic WAV audio micro-clips for instant sub-15ms backchanneling
# Pre-cached PCM/WAV headers for ultra-fast dispatch
MINI_WAV_HEADER = b'RIFF$\x00\x00\x00WAVEfmt \x10\x00\x00\x00\x01\x00\x01\x00\x44\xac\x00\x00\x88X\x01\x00\x02\x00\x10\x00data\x00\x00\x00\x00'
BACKCHANNEL_TEXTS: Dict[str, List[str]] = {
"en": ["Mm-hmm...", "I see...", "Okay...", "Understood..."],
"ta": ["Mm-hmm...", "புரியுதுங்க...", "சரிங்க..."],
"hi": ["हूँ-हूँ...", "जी समझ गया...", "अच्छा..."],
"te": ["హ్మ్...", "సరేనండి...", "అర్థమైంది..."],
"kn": ["హ్మ్...", "ಸರಿ...", "ಅರ್ಥವಾಯಿತು..."],
}
# Base64 encoded micro-audio clips (~0.4s duration)
DUMMY_AUDIO_B64 = base64.b64encode(MINI_WAV_HEADER + b'\x00' * 3200).decode('utf-8')
BACKCHANNEL_AUDIO_CACHE: Dict[str, List[str]] = {
"en": [DUMMY_AUDIO_B64, DUMMY_AUDIO_B64],
"ta": [DUMMY_AUDIO_B64, DUMMY_AUDIO_B64],
"hi": [DUMMY_AUDIO_B64, DUMMY_AUDIO_B64],
"te": [DUMMY_AUDIO_B64, DUMMY_AUDIO_B64],
"kn": [DUMMY_AUDIO_B64, DUMMY_AUDIO_B64],
}
def get_random_backchannel(language_code: str = "en-IN") -> tuple[str, str]:
"""
Returns a random pre-cached acoustic backchannel text and base64 audio clip
for instant < 15ms dispatch over WebSocket.
"""
lang_prefix = (language_code or "en").lower()[:2]
if lang_prefix not in BACKCHANNEL_TEXTS:
lang_prefix = "en"
texts = BACKCHANNEL_TEXTS[lang_prefix]
audios = BACKCHANNEL_AUDIO_CACHE.get(lang_prefix, BACKCHANNEL_AUDIO_CACHE["en"])
idx = random.randint(0, len(texts) - 1)
text = texts[idx]
audio_b64 = audios[min(idx, len(audios) - 1)]
return text, audio_b64
@@ -0,0 +1,95 @@
import asyncio
import os
import sys
from dotenv import load_dotenv
if sys.stdout.encoding != 'utf-8':
try:
sys.stdout.reconfigure(encoding='utf-8')
except Exception:
pass
load_dotenv()
async def diagnose_anthropic():
api_key = os.getenv("ANTHROPIC_API_KEY")
print("\n--- Testing Anthropic Models ---")
candidate_models = [
"claude-3-5-sonnet-latest",
"claude-3-5-haiku-latest",
"claude-3-haiku-20240307",
"claude-3-sonnet-20240229",
"claude-3-opus-20240229",
]
from anthropic import AsyncAnthropic
client = AsyncAnthropic(api_key=api_key)
for model in candidate_models:
try:
res = await client.messages.create(
model=model,
max_tokens=20,
messages=[{"role": "user", "content": "Hi"}],
)
print(f"[OK] Anthropic Model '{model}' WORKS! Response: {res.content[0].text.strip()}")
return model
except Exception as e:
print(f"[X] '{model}' failed: {e}")
return None
async def diagnose_gemini():
api_key = os.getenv("GEMINI_API_KEY")
print("\n--- Testing Gemini Models ---")
from google import genai
genai.configure(api_key=api_key)
try:
models = [m.name for m in genai.list_models() if 'generateContent' in m.supported_generation_methods]
print(f"Available Gemini models: {models[:10]}")
for m_name in models[:5]:
try:
m = genai.GenerativeModel(m_name)
res = await m.generate_content_async("Hi")
print(f"[OK] Gemini Model '{m_name}' WORKS! Response: {res.text.strip()}")
return m_name
except Exception as e:
print(f"[X] Gemini Model '{m_name}' failed: {e}")
except Exception as e:
print(f"[FAIL] Listing Gemini models failed: {e}")
return None
async def diagnose_sarvam():
api_key = os.getenv("SARVAM_API_KEY")
print("\n--- Testing Sarvam Speakers ---")
import httpx2
client = httpx2.AsyncClient(timeout=15.0)
headers = {
"api-subscription-key": api_key,
"Content-Type": "application/json",
}
payload = {
"inputs": ["வணக்கம்"],
"target_language_code": "ta-IN",
"speaker": "kavya",
"model": "bulbul:v1",
}
try:
res = await client.post(
"https://api.sarvam.ai/text-to-speech",
json=payload,
headers=headers,
)
if res.status_code == 200:
print("[OK] Sarvam TTS with speaker 'kavya' WORKS!")
return "kavya"
else:
print(f"[FAIL] Sarvam HTTP {res.status_code}: {res.text}")
except Exception as e:
print(f"[FAIL] Sarvam error: {e}")
return None
async def main():
await diagnose_anthropic()
await diagnose_gemini()
await diagnose_sarvam()
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,37 @@
import httpx
def main():
client = httpx.Client(timeout=60.0)
start_res = client.post('http://127.0.0.1:8000/v1/voice-onboarding/start', json={
'patient': {'id': 'PAT-INTAKE-5', 'first_name': 'Arun', 'last_name': 'Kumar'},
'category': {'type': 'general', 'code': 'general_medicine', 'name': 'General Medicine'},
'language': 'en-IN'
}).json()
sid = start_res['session_id']
print("=== DEBUGGING 5-STAGE DIALOGUE ===")
print("Session ID:", sid)
dialogue = [
"Chest pain and shortness of breath",
"2 days, pain severity is 8 out of 10",
"Yes, sweating and dizziness",
"High blood pressure and Diabetes",
"Taking Amlodipine, no drug allergies"
]
for i, turn_input in enumerate(dialogue, 1):
res = client.post(f"http://127.0.0.1:8000/v1/voice-onboarding/{sid}/turn", data={"text_fallback": turn_input})
print(f"Turn {i} Status Code: {res.status_code}")
if res.status_code != 200:
print("FULL ERROR BODY:", res.text)
break
data = res.json()
print(f"Turn {i} Input: '{turn_input}'")
print(f" --> Next Stage: {data['stage']} | Completed: {data['completed']}")
print("-" * 70)
if __name__ == '__main__':
main()
@@ -0,0 +1,135 @@
import asyncio
import os
import sys
from dotenv import load_dotenv
if sys.stdout.encoding != 'utf-8':
try:
sys.stdout.reconfigure(encoding='utf-8')
except Exception:
pass
load_dotenv()
async def test_openai():
api_key = os.getenv("OPENAI_API_KEY")
model = os.getenv("OPENAI_MODEL", "gpt-4o-mini")
print(f"\n--- [1/4] Testing OpenAI ({model}) ---")
if not api_key:
print("[FAIL] OPENAI_API_KEY is not set.")
return False, "Key missing"
try:
from openai import AsyncOpenAI
client = AsyncOpenAI(api_key=api_key)
response = await client.chat.completions.create(
model=model,
messages=[{"role": "user", "content": "Respond with JSON: {\"status\": \"ok\"}"}],
response_format={"type": "json_object"},
max_completion_tokens=50,
)
content = response.choices[0].message.content
print(f"[OK] OpenAI Success: {content.strip()}")
return True, "Connected & Working"
except Exception as e:
err_msg = str(e)
print(f"[FAIL] OpenAI Error: {err_msg}")
return False, err_msg
async def test_anthropic():
api_key = os.getenv("ANTHROPIC_API_KEY")
model = os.getenv("ANTHROPIC_MODEL", "claude-sonnet-4-5-20250929")
print(f"\n--- [2/4] Testing Anthropic Claude ({model}) ---")
if not api_key:
print("[FAIL] ANTHROPIC_API_KEY is not set.")
return False, "Key missing"
try:
from anthropic import AsyncAnthropic
client = AsyncAnthropic(api_key=api_key)
response = await client.messages.create(
model=model,
max_tokens=50,
messages=[{"role": "user", "content": "Reply with 'ok'"}],
)
content = response.content[0].text
print(f"[OK] Claude Success: {content.strip()}")
return True, "Connected & Working"
except Exception as e:
err_msg = str(e)
print(f"[FAIL] Claude Error: {err_msg}")
return False, err_msg
async def test_gemini():
api_key = os.getenv("GEMINI_API_KEY")
model_name = os.getenv("GEMINI_MODEL", "gemini-3.6-flash")
print(f"\n--- [3/4] Testing Google Gemini ({model_name}) ---")
if not api_key:
print("[FAIL] GEMINI_API_KEY is not set.")
return False, "Key missing"
try:
from google import genai
genai.configure(api_key=api_key)
model = genai.GenerativeModel(model_name)
response = await model.generate_content_async("Say 'ok'")
print(f"[OK] Gemini Success: {response.text.strip()}")
return True, "Connected & Working"
except Exception as e:
err_msg = str(e)
print(f"[FAIL] Gemini Error: {err_msg}")
return False, err_msg
async def test_sarvam():
api_key = os.getenv("SARVAM_API_KEY")
print(f"\n--- [4/4] Testing Sarvam AI (TTS bulbul:v2) ---")
if not api_key:
print("[FAIL] SARVAM_API_KEY is not set.")
return False, "Key missing"
try:
import httpx2
client = httpx2.AsyncClient(timeout=15.0)
headers = {
"api-subscription-key": api_key,
"Content-Type": "application/json",
}
payload = {
"inputs": ["வணக்கம்"],
"target_language_code": "ta-IN",
"speaker": "anushka",
"model": "bulbul:v2",
}
res = await client.post(
"https://api.sarvam.ai/text-to-speech",
json=payload,
headers=headers,
)
if res.status_code == 200:
audios = res.json().get("audios", [])
print(f"[OK] Sarvam AI Success: Generated {len(audios)} audio stream(s)")
return True, "Connected & Working"
else:
err_msg = f"HTTP {res.status_code}: {res.text}"
print(f"[FAIL] Sarvam AI Error: {err_msg}")
return False, err_msg
except Exception as e:
err_msg = str(e)
print(f"[FAIL] Sarvam AI Error: {err_msg}")
return False, err_msg
async def main():
print("========================================")
print("AI Patient Onboarding - Key Verification")
print("========================================")
results = {}
results["OpenAI"] = await test_openai()
results["Anthropic Claude"] = await test_anthropic()
results["Google Gemini"] = await test_gemini()
results["Sarvam AI"] = await test_sarvam()
print("\n========================================")
print("SUMMARY")
print("========================================")
for provider, (passed, msg) in results.items():
tag = "[PASS]" if passed else "[FAIL]"
print(f"{tag} {provider}: {msg}")
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,35 @@
import httpx
def main():
client = httpx.Client(timeout=30.0)
test_cases = [
("Cardiology Test", "எனக்கு நெஞ்சு வலி மற்றும் மூச்சு திணறல் இருக்கு"),
("Dermatology Test", "தோல் அரிப்பு மற்றும் படை உள்ளது"),
("Orthopedics Test", "மூட்டு வலி நடக்க முடியல"),
("Neurology Test", "headache and dizziness for 3 days"),
("Gastroenterology Test", "வயிற்று வலி மற்றும் வாந்தி"),
("Pulmonology Test", "இருமல் மற்றும் சளி அதிகமாக உள்ளது"),
("ENT Test", "காது வலி மற்றும் தொண்டை வலி")
]
print("=== AUTOMATIC SPECIALTY TRIAGE ENGINE TEST ===")
for label, symptom in test_cases:
start_res = client.post("http://127.0.0.1:8000/v1/voice-onboarding/start", json={
"patient": {"id": "PAT-AUTO", "first_name": "Arun", "last_name": "Kumar"},
"category": {"type": "general", "code": "general_medicine", "name": "General Medicine"},
"language": "ta-IN"
})
sid = start_res.json()["session_id"]
turn_res = client.post(f"http://127.0.0.1:8000/v1/voice-onboarding/{sid}/turn", data={"text_fallback": symptom})
spec = turn_res.json().get("suggested_specialty", {})
print(f"Label: {label}")
print(f" Patient Symptom: '{symptom}'")
print(f" --> Auto-Assigned Specialty: {spec.get('name')} (Code: {spec.get('code')})")
print(f" --> Reasoning: {spec.get('reason')}\n")
if __name__ == '__main__':
main()
@@ -0,0 +1,16 @@
from app.ai.orchestrator import classify_specialty_from_symptoms
test_cases = [
("Cardiology", "நெஞ்சு வலி மற்றும் மூச்சு திணறல்"),
("Dermatology", "தோல் அரிப்பு மற்றும் படை"),
("Orthopedics", "மூட்டு வலி"),
("Neurology", "headache and dizziness"),
("Gastroenterology", "வயிற்று வலி மற்றும் வாந்தி"),
("Pulmonology", "இருமல் மற்றும் சளி"),
("ENT", "காது வலி மற்றும் தொண்டை வலி")
]
print("=== DIRECT SYMPTOM SPECIALTY CLASSIFIER TEST ===")
for label, text in test_cases:
res = classify_specialty_from_symptoms(text)
print("Test:", label, "| Code:", res["code"], "| Specialty:", res["name"])
@@ -0,0 +1,60 @@
import asyncio
import os
import sys
from dotenv import load_dotenv
if sys.stdout.encoding != 'utf-8':
try:
sys.stdout.reconfigure(encoding='utf-8')
except Exception:
pass
load_dotenv()
async def test_sarvam_anushka():
api_key = os.getenv("SARVAM_API_KEY")
import httpx2
client = httpx2.AsyncClient(timeout=15.0)
headers = {
"api-subscription-key": api_key,
"Content-Type": "application/json",
}
payload = {
"inputs": ["வணக்கம், உங்கள் உடல்நலம் எப்படி உள்ளது?"],
"target_language_code": "ta-IN",
"speaker": "anushka",
"model": "bulbul:v2",
}
res = await client.post(
"https://api.sarvam.ai/text-to-speech",
json=payload,
headers=headers,
)
if res.status_code == 200:
audios = res.json().get("audios", [])
print(f"[OK] Sarvam TTS bulbul:v2 (anushka) SUCCESS! Audio segments: {len(audios)}")
return True
else:
print(f"[FAIL] Sarvam error {res.status_code}: {res.text}")
return False
async def list_anthropic_models():
api_key = os.getenv("ANTHROPIC_API_KEY")
from anthropic import AsyncAnthropic
client = AsyncAnthropic(api_key=api_key)
try:
models = await client.models.list()
print("Available Anthropic models on this account:")
for m in models.data:
print(f" - {m.id} ({m.display_name})")
except Exception as e:
print(f"Anthropic list models error: {e}")
async def main():
print("--- 1. Testing Sarvam ---")
await test_sarvam_anushka()
print("\n--- 2. Listing Anthropic Models ---")
await list_anthropic_models()
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,87 @@
import asyncio
import os
import sys
from dotenv import load_dotenv
if sys.stdout.encoding != 'utf-8':
try:
sys.stdout.reconfigure(encoding='utf-8')
except Exception:
pass
load_dotenv()
async def test_sarvam_v2():
api_key = os.getenv("SARVAM_API_KEY")
import httpx2
client = httpx2.AsyncClient(timeout=15.0)
headers = {
"api-subscription-key": api_key,
"Content-Type": "application/json",
}
payload = {
"inputs": ["வணக்கம், உங்கள் உடல்நலம் எப்படி உள்ளது?"],
"target_language_code": "ta-IN",
"speaker": "kavya",
"model": "bulbul:v2",
}
res = await client.post(
"https://api.sarvam.ai/text-to-speech",
json=payload,
headers=headers,
)
if res.status_code == 200:
audios = res.json().get("audios", [])
print(f"[OK] Sarvam TTS bulbul:v2 WORKS! Generated {len(audios)} audio clips.")
return True
else:
print(f"[FAIL] Sarvam error {res.status_code}: {res.text}")
return False
async def test_gemini_models():
api_key = os.getenv("GEMINI_API_KEY")
from google import genai
genai.configure(api_key=api_key)
for model_name in ["gemini-3.6-flash", "gemini-flash-latest", "gemini-2.5-flash-lite", "gemma-4-26b-a4b-it"]:
try:
m = genai.GenerativeModel(model_name)
res = await m.generate_content_async("Hello")
print(f"[OK] Gemini '{model_name}' WORKS! Response: {res.text.strip()}")
return model_name
except Exception as e:
print(f"[X] Gemini '{model_name}' failed: {e}")
return None
async def test_anthropic_models():
api_key = os.getenv("ANTHROPIC_API_KEY")
from anthropic import AsyncAnthropic
client = AsyncAnthropic(api_key=api_key)
for model_name in [
"claude-3-7-sonnet-20250219",
"claude-3-5-sonnet-20240620",
"claude-3-sonnet-20240229",
"claude-2.1",
"claude-instant-1.2"
]:
try:
res = await client.messages.create(
model=model_name,
max_tokens=20,
messages=[{"role": "user", "content": "Hi"}],
)
print(f"[OK] Anthropic '{model_name}' WORKS! Response: {res.content[0].text.strip()}")
return model_name
except Exception as e:
print(f"[X] Anthropic '{model_name}' failed: {e}")
return None
async def main():
print("--- Testing Sarvam v2 ---")
await test_sarvam_v2()
print("\n--- Testing Gemini Models ---")
await test_gemini_models()
print("\n--- Testing Anthropic Models ---")
await test_anthropic_models()
if __name__ == "__main__":
asyncio.run(main())
@@ -78,8 +78,8 @@ A single patient statement can contain multiple clinical facts. The AI extracts
- Completion is based on having sufficient clinical intake for the complaint + no emergency gaps.
### 6. Emergency & Crisis Override
- Red-flag symptoms (severe crushing chest pain, sudden paralysis, severe breathing difficulty, massive bleeding) immediately direct to **108 emergency ambulance**.
- Mental health distress or suicidal statements immediately trigger **Tele-MANAS helpline (14416)** and emergency 108.
- Red-flag symptoms (severe crushing chest pain, sudden paralysis, severe breathing difficulty, massive bleeding) immediately direct to **hospital emergency staff or Emergency Room (ER)**.
- Mental health distress or suicidal statements immediately trigger **Tele-MANAS helpline (14416)** or direct alert to hospital staff.
- The AI **never diagnoses** (e.g., never says "You have a heart attack").
---
@@ -1,256 +0,0 @@
# CureSelect AI — Real-Time Voice Intake & EHR Frontend Integration Guide
**Version**: `2.0.0 (Production Release)`
**Target Audience**: Frontend Engineers (React, Next.js, Vue, React Native, Flutter, Vanilla TypeScript)
**Backend Protocol**: Full-Duplex WebSockets (`ws://`) & HTTP/1.1 REST
**Authentication Header**: `X-API-Key: skyheal_secure_api_key_2026` or `Authorization: Bearer <TOKEN>`
**Default Dashboard**: `http://localhost:8000/` or `http://localhost:8000/dashboard`
---
## 1. Architectural Overview
The **CureSelect AI Voice Engine** is a high-speed, full-duplex conversational health intake system. It processes real-time microphone audio or text turns, classifies speaker perspectives, detects emergency distress, checks RxNorm drug safety, redacts HIPAA/ABDM PII, and generates real-time HL7 FHIR R4 Bundles and Doctor SOAP Notes.
```mermaid
sequenceDiagram
participant Client as Frontend (React / Web / Mobile)
participant WS as WebSocket Manager (ws/auto)
participant Engine as AI Orchestrator & Multi-Models
participant EHR as EHR Export (FHIR / SOAP Note)
Client->>WS: Connect (ws://localhost:8000/v1/voice-onboarding/ws/auto)
WS-->>Client: {"type": "connected", "session_id": "VOICE-AUTO-8F12A9C4"}
WS-->>Client: {"type": "ai_response", "reply": "Hello Patient, welcome to CureSelect..."}
Client->>WS: Stream Binary Mic Bytes (MediaRecorder WebM/PCM)
WS-->>Client: {"type": "backchannel", "reply": "Mm-hmm..."} (< 15ms)
WS-->>Client: {"type": "speculative_audio_chunk", "ttfa_ms": 180} (< 200ms)
WS-->>Client: {"type": "transcript", "speaker_type": "caregiver", "speaker_label": "Caregiver (Mother)"}
WS-->>Client: {"type": "ai_response", "reply": "I'm sorry to hear about your mother's headache...", "audio_base64": "..."}
Client->>EHR: GET /v1/voice-onboarding/soap-note/{session_id}?format=html
EHR-->>Client: Render Printable Doctor SOAP Note Report
```
---
## 2. WebSocket Connection Lifecycle (`ws://localhost:8000/v1/voice-onboarding/ws/auto`)
### 2.1 Connection Setup
Connect to the zero-effort auto-session WebSocket endpoint:
```typescript
const ws = new WebSocket("ws://localhost:8000/v1/voice-onboarding/ws/auto");
```
### 2.2 Inbound WebSocket Event Schema
#### 1. Connection Initialized (`connected`)
Sent immediately upon socket handshake:
```json
{
"type": "connected",
"session_id": "VOICE-AUTO-9DEC79EC",
"message": "Connected to CureSelect Real-Time Voice Engine"
}
```
#### 2. Speaker Diarized Transcript (`transcript`)
Sent as soon as patient or caregiver speech is recognized:
```json
{
"type": "transcript",
"role": "patient",
"speaker_type": "caregiver",
"speaker_label": "Caregiver (Mother)",
"text": "My mother has severe headache for 10 days and she is taking Metformin 500mg."
}
```
*Note*: `speaker_type` is either `"patient"` or `"caregiver"`.
#### 3. Low-Latency Acoustic Backchannel (`backchannel`)
Emitted in **< 15ms** during patient pauses to signal active listening:
```json
{
"type": "backchannel",
"reply": "Mm-hmm...",
"audio_base64": "..."
}
```
#### 4. Speculative Audio Chunk (`speculative_audio_chunk`)
Emitted in **< 200ms** on the first 3-4 words generated by the LLM:
```json
{
"type": "speculative_audio_chunk",
"clause_text": "I am sorry to hear that.",
"audio_base64": "...",
"ttfa_ms": 180
}
```
#### 5. Complete AI Response & State Update (`ai_response`)
Contains full reply, TTS audio, next stage, and updated clinical state:
```json
{
"type": "ai_response",
"reply": "Any known drug allergies?",
"audio_id": "aud_771829",
"audio_base64": "...",
"next_stage": "symptom_collection",
"completed": false,
"state_updates": {
"symptoms": [{"name": "headache", "duration": "10 days", "severity": "severe"}],
"medications": [{"name": "Metformin", "dosage": "500mg daily"}]
}
}
```
#### 6. Emergency Nurse Escalation Alert (`nurse_escalation_alert`)
Emitted when Pain Score >= 8/10 or Distress Score > 0.75:
```json
{
"type": "nurse_escalation_alert",
"session_id": "VOICE-AUTO-9DEC79EC",
"distress_score": 0.85,
"pain_score": 9,
"message": "CRITICAL: Patient reported severe pain (9/10). Nurse escalation triggered!"
}
```
#### 7. RxNorm / FDA Drug Safety Warning (`drug_safety_warning`)
Emitted when drug interactions or drug-allergy contraindications are flagged:
```json
{
"type": "drug_safety_warning",
"warnings": [
{
"severity": "CRITICAL",
"category": "DRUG_INTERACTION",
"description": "High risk of bleeding: Combined use of Warfarin and Aspirin."
}
]
}
```
---
## 3. Outbound Client Messages
### 3.1 Streaming Audio Bytes
Stream raw WebM / PCM audio blobs from `MediaRecorder`:
```javascript
mediaRecorder.ondataavailable = (e) => {
if (e.data.size > 0 && ws.readyState === WebSocket.OPEN) {
ws.send(e.data); // Send raw Blob
}
};
```
### 3.2 Text Turn Fallback
```json
{
"type": "text_turn",
"text": "10 days-ah severe headache iruku, Metformin 500mg sapudren."
}
```
### 3.3 Full-Duplex Barge-In Interruption
Send when the user speaks or clicks "Interrupt AI" while AI audio is playing:
```json
{
"type": "interrupt"
}
```
---
## 4. REST API Integration Endpoints
All REST requests require the authentication header:
`X-API-Key: skyheal_secure_api_key_2026`
### 4.1 HL7 FHIR R4 Bundle Export
- **Endpoint**: `GET /v1/voice-onboarding/fhir/{session_id}`
- **Response**: Production-grade HL7 FHIR R4 Bundle (`Patient`, `Encounter`, `Condition`, `MedicationStatement`, `AllergyIntolerance`).
```bash
curl -X GET "http://localhost:8000/v1/voice-onboarding/fhir/VOICE-AUTO-9DEC79EC" \
-H "X-API-Key: skyheal_secure_api_key_2026"
```
### 4.2 Doctor SOAP Note & Printable PDF/HTML Report
- **Endpoint**: `GET /v1/voice-onboarding/soap-note/{session_id}?format=json` (or `format=html`)
- **Query Params**:
- `format=json`: Returns structured Subjective, Objective, Assessment (ICD-10-CM & SNOMED-CT codes), and Plan JSON.
- `format=html`: Renders a clean printable HTML document for 1-click doctor chart printing.
```bash
# Get JSON SOAP Note with ICD-10 & SNOMED CT
curl -X GET "http://localhost:8000/v1/voice-onboarding/soap-note/VOICE-AUTO-9DEC79EC?format=json" \
-H "X-API-Key: skyheal_secure_api_key_2026"
# Get Printable HTML Report
curl -X GET "http://localhost:8000/v1/voice-onboarding/soap-note/VOICE-AUTO-9DEC79EC?format=html" \
-H "X-API-Key: skyheal_secure_api_key_2026"
```
---
## 5. React / Next.js Custom Hook Example
```typescript
import { useState, useEffect, useRef } from "react";
export function useVoiceIntakeWebSocket(url: string = "ws://localhost:8000/v1/voice-onboarding/ws/auto") {
const [sessionId, setSessionId] = useState<string>("");
const [messages, setMessages] = useState<Array<any>>([]);
const [clinicalState, setClinicalState] = useState<any>({});
const [escalationAlert, setEscalationAlert] = useState<string | null>(null);
const [drugWarnings, setDrugWarnings] = useState<Array<any>>([]);
const wsRef = useRef<WebSocket | null>(null);
useEffect(() => {
const ws = new WebSocket(url);
wsRef.current = ws;
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === "connected") {
setSessionId(data.session_id);
} else if (data.type === "transcript") {
setMessages(prev => [...prev, { role: data.role, text: data.text, label: data.speaker_label }]);
} else if (data.type === "ai_response") {
setMessages(prev => [...prev, { role: "assistant", text: data.reply, label: "AI Assistant" }]);
if (data.audio_base64) {
const audio = new Audio("data:audio/wav;base64," + data.audio_base64);
audio.play().catch(console.error);
}
if (data.state_updates) {
setClinicalState(data.state_updates);
}
} else if (data.type === "nurse_escalation_alert") {
setEscalationAlert(data.message);
} else if (data.type === "drug_safety_warning") {
setDrugWarnings(data.warnings);
}
};
return () => ws.close();
}, [url]);
const sendTextTurn = (text: string) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: "text_turn", text }));
}
};
const interruptAI = () => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: "interrupt" }));
}
};
return { sessionId, messages, clinicalState, escalationAlert, drugWarnings, sendTextTurn, interruptAI };
}
```
-870
View File
@@ -1,870 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CureSelect AI - Clinical Voice Intake & Real-Time EHR Dashboard</title>
<!-- Google Fonts & FontAwesome Icons -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
:root {
--bg-dark: #090d16;
--bg-card: rgba(17, 24, 39, 0.75);
--bg-card-hover: rgba(31, 41, 55, 0.85);
--border-color: rgba(255, 255, 255, 0.08);
--accent-blue: #3b82f6;
--accent-cyan: #06b6d4;
--accent-green: #10b981;
--accent-red: #ef4444;
--accent-amber: #f59e0b;
--accent-purple: #8b5cf6;
--text-main: #f3f4f6;
--text-muted: #9ca3af;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
font-family: 'Inter', sans-serif;
}
body {
background-color: var(--bg-dark);
background-image:
radial-gradient(at 0% 0%, rgba(59, 130, 246, 0.12) 0px, transparent 50%),
radial-gradient(at 100% 100%, rgba(139, 92, 246, 0.12) 0px, transparent 50%);
color: var(--text-main);
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* Top Header Navigation */
header {
background: rgba(15, 23, 42, 0.8);
backdrop-filter: blur(16px);
border-bottom: 1px solid var(--border-color);
padding: 16px 32px;
display: flex;
justify-content: space-between;
align-items: center;
position: sticky;
top: 0;
z-index: 100;
}
.logo-container {
display: flex;
align-items: center;
gap: 12px;
}
.logo-icon {
width: 40px;
height: 40px;
background: linear-gradient(135deg, var(--accent-blue), var(--accent-cyan));
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
color: white;
box-shadow: 0 0 20px rgba(6, 182, 212, 0.4);
}
.logo-text {
font-size: 20px;
font-weight: 700;
background: linear-gradient(90deg, #ffffff, #93c5fd);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.status-badge {
display: flex;
align-items: center;
gap: 8px;
background: rgba(16, 185, 129, 0.1);
border: 1px solid rgba(16, 185, 129, 0.3);
color: var(--accent-green);
padding: 6px 14px;
border-radius: 20px;
font-size: 13px;
font-weight: 500;
}
.pulse-dot {
width: 8px;
height: 8px;
background-color: var(--accent-green);
border-radius: 50%;
box-shadow: 0 0 10px var(--accent-green);
animation: pulse 1.8s infinite;
}
@keyframes pulse {
0% { transform: scale(0.95); opacity: 0.8; }
50% { transform: scale(1.3); opacity: 1; }
100% { transform: scale(0.95); opacity: 0.8; }
}
/* Critical Escalation Banner */
.nurse-banner {
display: none;
background: linear-gradient(90deg, #7f1d1d, #b91c1c);
border-bottom: 2px solid #f87171;
color: white;
padding: 14px 32px;
align-items: center;
justify-content: space-between;
font-weight: 600;
animation: bannerFlash 1s infinite alternate;
}
@keyframes bannerFlash {
from { background: linear-gradient(90deg, #7f1d1d, #b91c1c); }
to { background: linear-gradient(90deg, #991b1b, #dc2626); }
}
.nurse-banner.active {
display: flex;
}
/* Main Container Grid */
.main-container {
flex: 1;
padding: 24px 32px;
display: grid;
grid-template-columns: 1.2fr 0.8fr;
gap: 24px;
max-width: 1600px;
margin: 0 auto;
width: 100%;
}
/* Card Styles */
.glass-card {
background: var(--bg-card);
backdrop-filter: blur(16px);
border: 1px solid var(--border-color);
border-radius: 16px;
padding: 24px;
display: flex;
flex-direction: column;
box-shadow: 0 10px 30px rgba(0,0,0,0.3);
}
.card-title {
font-size: 16px;
font-weight: 600;
color: #e5e7eb;
margin-bottom: 16px;
display: flex;
align-items: center;
gap: 10px;
}
/* Left Column: Voice Interaction & Live Feed */
.voice-section {
display: flex;
flex-direction: column;
gap: 20px;
}
.mic-control-box {
background: rgba(15, 23, 42, 0.6);
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 16px;
padding: 24px;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
position: relative;
overflow: hidden;
}
.waveform-canvas {
width: 100%;
height: 80px;
margin: 10px 0;
}
.mic-button {
width: 80px;
height: 80px;
border-radius: 50%;
background: linear-gradient(135deg, var(--accent-blue), var(--accent-cyan));
border: none;
color: white;
font-size: 30px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 0 30px rgba(6, 182, 212, 0.4);
transition: all 0.3s ease;
outline: none;
}
.mic-button.recording {
background: linear-gradient(135deg, #ef4444, #dc2626);
box-shadow: 0 0 40px rgba(239, 68, 68, 0.6);
animation: pulseRecord 1.5s infinite;
}
@keyframes pulseRecord {
0% { transform: scale(1); }
50% { transform: scale(1.08); }
100% { transform: scale(1); }
}
.action-buttons {
display: flex;
gap: 12px;
margin-top: 16px;
}
.btn {
background: rgba(255, 255, 255, 0.08);
border: 1px solid var(--border-color);
color: var(--text-main);
padding: 8px 16px;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
gap: 8px;
}
.btn:hover {
background: rgba(255, 255, 255, 0.15);
}
.btn-danger {
background: rgba(239, 68, 68, 0.2);
border-color: rgba(239, 68, 68, 0.4);
color: #fca5a5;
}
.btn-danger:hover {
background: rgba(239, 68, 68, 0.35);
}
/* Conversation Transcript Area */
.chat-area {
flex: 1;
min-height: 380px;
max-height: 480px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 14px;
padding-right: 8px;
}
.chat-bubble {
max-width: 85%;
padding: 14px 18px;
border-radius: 14px;
font-size: 14px;
line-height: 1.5;
position: relative;
}
.chat-bubble.ai {
background: rgba(30, 41, 59, 0.8);
border: 1px solid rgba(59, 130, 246, 0.2);
align-self: flex-start;
border-bottom-left-radius: 2px;
}
.chat-bubble.patient {
background: rgba(14, 116, 144, 0.3);
border: 1px solid rgba(6, 182, 212, 0.3);
align-self: flex-end;
border-bottom-right-radius: 2px;
}
.chat-bubble.caregiver {
background: rgba(126, 34, 206, 0.25);
border: 1px solid rgba(168, 85, 247, 0.3);
align-self: flex-end;
border-bottom-right-radius: 2px;
}
.bubble-meta {
font-size: 11px;
color: var(--text-muted);
margin-bottom: 4px;
display: flex;
justify-content: space-between;
align-items: center;
}
.speaker-tag {
padding: 2px 8px;
border-radius: 10px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
}
.tag-patient { background: rgba(6, 182, 212, 0.2); color: var(--accent-cyan); }
.tag-caregiver { background: rgba(168, 85, 247, 0.2); color: #c084fc; }
.tag-ai { background: rgba(59, 130, 246, 0.2); color: #93c5fd; }
/* Right Column: Real-Time Clinical Intake State & EHR Controls */
.clinical-section {
display: flex;
flex-direction: column;
gap: 20px;
}
.metrics-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
.metric-card {
background: rgba(15, 23, 42, 0.6);
border: 1px solid var(--border-color);
padding: 14px;
border-radius: 10px;
}
.metric-label {
font-size: 12px;
color: var(--text-muted);
margin-bottom: 4px;
}
.metric-value {
font-size: 18px;
font-weight: 700;
color: #f3f4f6;
}
/* Drug Warning Alert Card */
.warning-card {
display: none;
background: rgba(245, 158, 11, 0.12);
border: 1px solid rgba(245, 158, 11, 0.3);
border-radius: 12px;
padding: 14px;
margin-top: 10px;
}
.warning-card.active {
display: block;
}
.warning-header {
color: var(--accent-amber);
font-size: 13px;
font-weight: 600;
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
/* State JSON Tree View */
.state-box {
background: #050811;
border: 1px solid var(--border-color);
border-radius: 10px;
padding: 14px;
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
color: #38bdf8;
max-height: 250px;
overflow-y: auto;
white-space: pre-wrap;
}
/* Modal Overlay */
.modal-overlay {
display: none;
position.fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.8);
backdrop-filter: blur(8px);
z-index: 200;
align-items: center;
justify-content: center;
}
.modal-overlay.active {
display: flex;
}
.modal-card {
background: #0f172a;
border: 1px solid var(--border-color);
border-radius: 16px;
width: 90%;
max-width: 850px;
max-height: 85vh;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 20px 50px rgba(0,0,0,0.5);
}
.modal-header {
padding: 18px 24px;
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-body {
padding: 24px;
overflow-y: auto;
flex: 1;
font-family: 'JetBrains Mono', monospace;
font-size: 13px;
background: #090d16;
color: #a7f3d0;
}
.modal-footer {
padding: 16px 24px;
border-top: 1px solid var(--border-color);
display: flex;
justify-content: flex-end;
gap: 12px;
}
</style>
</head>
<body>
<!-- Header Navigation -->
<header>
<div class="logo-container">
<div class="logo-icon"><i class="fa-solid fa-heart-pulse"></i></div>
<div class="logo-text">CureSelect AI Voice Intake</div>
</div>
<div style="display: flex; gap: 16px; align-items: center;">
<div class="status-badge">
<div class="pulse-dot"></div>
<span id="wsStatus">Connected (ws://localhost:8000/v1/voice-onboarding/ws/auto)</span>
</div>
</div>
</header>
<!-- Critical Nurse Escalation Banner -->
<div id="nurseBanner" class="nurse-banner">
<div style="display: flex; align-items: center; gap: 12px;">
<i class="fa-solid fa-triangle-exclamation fa-lg"></i>
<div>
<span style="font-size: 15px;">CRITICAL NURSE ESCALATION ALERT:</span>
<span id="bannerText" style="font-weight: 400; margin-left: 8px;">High Pain Score (9/10) reported. Immediate clinical review required!</span>
</div>
</div>
<button class="btn btn-danger" onclick="dismissBanner()">Acknowledge</button>
</div>
<!-- Main Workspace Container -->
<div class="main-container">
<!-- Left Column: Voice Interaction & Live Feed -->
<div class="voice-section">
<div class="glass-card mic-control-box">
<div class="card-title" style="margin-bottom: 8px;">
<i class="fa-solid fa-microphone-lines" style="color: var(--accent-cyan);"></i>
Real-Time Audio Streamer
</div>
<canvas id="waveform" class="waveform-canvas"></canvas>
<button id="micBtn" class="mic-button" onclick="toggleMic()">
<i class="fa-solid fa-microphone"></i>
</button>
<div id="micStatusText" style="margin-top: 12px; font-size: 13px; color: var(--text-muted);">
Click microphone to start full-duplex voice intake
</div>
<div class="action-buttons">
<button class="btn btn-danger" onclick="triggerInterrupt()">
<i class="fa-solid fa-hand"></i> Interrupt AI (Barge-In)
</button>
<button class="btn" onclick="sendSampleTurn('Tanglish')">
<i class="fa-solid fa-language"></i> Tanglish Turn
</button>
<button class="btn" onclick="sendSampleTurn('Caregiver')">
<i class="fa-solid fa-people-roof"></i> Caregiver Turn
</button>
<button class="btn" onclick="sendSampleTurn('HighPain')">
<i class="fa-solid fa-heart-crack"></i> High Pain (9/10)
</button>
<button class="btn" onclick="sendSampleTurn('DrugInteraction')">
<i class="fa-solid fa-pills"></i> Drug Warning
</button>
</div>
</div>
<!-- Conversation Transcript Area -->
<div class="glass-card" style="flex: 1;">
<div class="card-title">
<i class="fa-solid fa-comments" style="color: var(--accent-purple);"></i>
Live Conversation Stream
</div>
<div id="chatArea" class="chat-area">
<div class="chat-bubble ai">
<div class="bubble-meta">
<span class="speaker-tag tag-ai">AI Assistant</span>
<span>Greeting</span>
</div>
Hello Patient, welcome to CureSelect Healthcare. Could you please share what health symptoms you are experiencing today?
</div>
</div>
</div>
</div>
<!-- Right Column: Real-Time Clinical Intake State & EHR Controls -->
<div class="clinical-section">
<div class="glass-card">
<div class="card-title">
<i class="fa-solid fa-notes-medical" style="color: var(--accent-green);"></i>
Live Clinical Metrics
</div>
<div class="metrics-grid">
<div class="metric-card">
<div class="metric-label">Pain Score</div>
<div id="metricPain" class="metric-value">3 / 10</div>
</div>
<div class="metric-card">
<div class="metric-label">Distress Score</div>
<div id="metricDistress" class="metric-value">0.20</div>
</div>
<div class="metric-card">
<div class="metric-label">TTFA Latency</div>
<div id="metricTtfa" class="metric-value">⚡ 180 ms</div>
</div>
<div class="metric-card">
<div class="metric-label">Speech Pace</div>
<div id="metricPace" class="metric-value">1.0x (Standard)</div>
</div>
</div>
<!-- RxNorm / FDA Drug Safety Warning Card -->
<div id="drugWarningCard" class="warning-card">
<div class="warning-header">
<i class="fa-solid fa-shield-virus"></i> RxNorm / FDA Drug Safety Warning
</div>
<div id="warningCardText" style="font-size: 12px; color: #fef08a;">
No drug interaction warnings detected.
</div>
</div>
</div>
<!-- State JSON Tree View -->
<div class="glass-card" style="flex: 1;">
<div class="card-title" style="justify-content: space-between;">
<div>
<i class="fa-solid fa-code" style="color: var(--accent-blue);"></i>
Extracted Clinical State
</div>
<div style="display: flex; gap: 8px;">
<button class="btn" onclick="openFhirModal()">
<i class="fa-solid fa-fire"></i> FHIR R4
</button>
<button class="btn" onclick="openSoapModal()">
<i class="fa-solid fa-file-medical"></i> SOAP Note
</button>
</div>
</div>
<div id="jsonState" class="state-box">
{
"reason_for_visit": "general_intake",
"symptoms": [],
"medical_history": [],
"medications": [],
"allergies": []
}
</div>
</div>
</div>
</div>
<!-- Modal Dialog for FHIR & SOAP Note Viewers -->
<div id="modalOverlay" class="modal-overlay">
<div class="modal-card">
<div class="modal-header">
<div id="modalTitle" style="font-size: 16px; font-weight: 600; color: white;">HL7 FHIR R4 Bundle JSON</div>
<button class="btn" onclick="closeModal()"><i class="fa-solid fa-xmark"></i> Close</button>
</div>
<div id="modalBody" class="modal-body">
Loading...
</div>
<div class="modal-footer">
<button class="btn" onclick="copyModalContent()"><i class="fa-solid fa-copy"></i> Copy JSON</button>
<button id="modalHtmlBtn" class="btn" style="display: none;" onclick="openPrintableHtml()"><i class="fa-solid fa-print"></i> Open Printable Report</button>
</div>
</div>
</div>
<!-- WebSockets & Web Audio Script -->
<script>
let ws;
let isRecording = false;
let audioContext;
let mediaStream;
let currentSessionId = "";
// Establish WebSocket Connection
function connectWebSocket() {
ws = new WebSocket("ws://localhost:8000/v1/voice-onboarding/ws/auto");
ws.onopen = () => {
document.getElementById("wsStatus").innerText = "Connected (ws://localhost:8000/v1/voice-onboarding/ws/auto)";
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
handleWSMessage(data);
};
ws.onclose = () => {
document.getElementById("wsStatus").innerText = "Disconnected. Retrying...";
setTimeout(connectWebSocket, 2000);
};
}
// Handle WebSocket Message Events
function handleWSMessage(data) {
console.log("WS Event Received:", data);
if (data.type === "connected") {
currentSessionId = data.session_id;
} else if (data.type === "transcript") {
addChatBubble(data.role, data.text, data.speaker_label, data.speaker_type);
} else if (data.type === "backchannel") {
console.log("⚡ Instant Backchannel:", data.reply);
} else if (data.type === "speculative_audio_chunk") {
document.getElementById("metricTtfa").innerText = `⚡ ${data.ttfa_ms} ms`;
} else if (data.type === "ai_response") {
addChatBubble("assistant", data.reply, "AI Assistant", "ai");
if (data.audio_base64) {
playAudioBase64(data.audio_base64);
}
if (data.state_updates) {
updateClinicalState(data.state_updates);
}
} else if (data.type === "cancel_playback") {
console.log("🛑 Playback Interrupted by User!");
} else if (data.type === "nurse_escalation_alert") {
showNurseBanner(data.message);
document.getElementById("metricPain").innerText = `${data.pain_score} / 10`;
document.getElementById("metricDistress").innerText = `${data.distress_score}`;
} else if (data.type === "drug_safety_warning") {
showDrugWarnings(data.warnings);
}
}
// Play Audio Chunk
function playAudioBase64(b64) {
try {
const audio = new Audio("data:audio/wav;base64," + b64);
audio.play().catch(e => console.error("Audio playback error:", e));
} catch (e) {
console.error("Audio error:", e);
}
}
// Render Chat Bubbles
function addChatBubble(role, text, label, speakerType) {
const chatArea = document.getElementById("chatArea");
const bubble = document.createElement("div");
let bubbleClass = "chat-bubble ";
let tagClass = "speaker-tag ";
if (role === "assistant" || speakerType === "ai") {
bubbleClass += "ai";
tagClass += "tag-ai";
} else if (speakerType === "caregiver") {
bubbleClass += "caregiver";
tagClass += "tag-caregiver";
} else {
bubbleClass += "patient";
tagClass += "tag-patient";
}
bubble.className = bubbleClass;
bubble.innerHTML = `
<div class="bubble-meta">
<span class="${tagClass}">${label || (role === 'assistant' ? 'AI Assistant' : 'Patient')}</span>
<span>${new Date().toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}</span>
</div>
${text}
`;
chatArea.appendChild(bubble);
chatArea.scrollTop = chatArea.scrollHeight;
}
// Update Clinical State Tree
function updateClinicalState(state) {
document.getElementById("jsonState").innerText = JSON.stringify(state, null, 2);
}
// Mic Toggle
async function toggleMic() {
const micBtn = document.getElementById("micBtn");
const statusText = document.getElementById("micStatusText");
if (!isRecording) {
try {
mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true });
isRecording = true;
micBtn.classList.add("recording");
statusText.innerText = "Streaming microphone audio... Speak freely.";
const mediaRecorder = new MediaRecorder(mediaStream);
mediaRecorder.ondataavailable = (e) => {
if (e.data.size > 0 && ws.readyState === WebSocket.OPEN) {
ws.send(e.data);
}
};
mediaRecorder.start(250);
window.currentMediaRecorder = mediaRecorder;
} catch (e) {
alert("Microphone permission denied or not available: " + e.message);
}
} else {
isRecording = false;
micBtn.classList.remove("recording");
statusText.innerText = "Microphone paused. Click to resume.";
if (window.currentMediaRecorder) window.currentMediaRecorder.stop();
if (mediaStream) mediaStream.getTracks().forEach(t => t.stop());
ws.send(JSON.stringify({ type: "finish_turn" }));
}
}
function triggerInterrupt() {
ws.send(JSON.stringify({ type: "interrupt" }));
}
function sendSampleTurn(type) {
if (type === "Tanglish") {
ws.send(JSON.stringify({
type: "text_turn",
text: "10 days-ah severe headache iruku, Metformin 500mg daily sapudren."
}));
} else if (type === "Caregiver") {
ws.send(JSON.stringify({
type: "text_turn",
text: "My mother has severe headache for 10 days and she is taking Metformin 500mg."
}));
} else if (type === "HighPain") {
ws.send(JSON.stringify({
type: "text_turn",
text: "My chest pain is 9 out of 10 unbearable, I cannot breathe properly!"
}));
} else if (type === "DrugInteraction") {
ws.send(JSON.stringify({
type: "text_turn",
text: "I am taking Warfarin and Aspirin daily, and I have severe Penicillin allergy."
}));
}
}
function showNurseBanner(msg) {
document.getElementById("bannerText").innerText = msg;
document.getElementById("nurseBanner").classList.add("active");
}
function dismissBanner() {
document.getElementById("nurseBanner").classList.remove("active");
}
function showDrugWarnings(warnings) {
const card = document.getElementById("drugWarningCard");
const text = document.getElementById("warningCardText");
if (warnings && warnings.length > 0) {
text.innerText = warnings.map(w => `[${w.severity}] ${w.category}: ${w.description}`).join("\n\n");
card.classList.add("active");
}
}
// Modals for FHIR & SOAP Notes
async function openFhirModal() {
if (!currentSessionId) return alert("No active session ID");
document.getElementById("modalTitle").innerText = "HL7 FHIR R4 Bundle JSON";
document.getElementById("modalBody").innerText = "Loading FHIR R4 Bundle...";
document.getElementById("modalHtmlBtn").style.display = "none";
document.getElementById("modalOverlay").classList.add("active");
try {
const res = await fetch(`http://localhost:8000/v1/voice-onboarding/fhir/${currentSessionId}`, {
headers: { "X-API-Key": "skyheal_secure_api_key_2026" }
});
const bundle = await res.json();
document.getElementById("modalBody").innerText = JSON.stringify(bundle, null, 2);
} catch (e) {
document.getElementById("modalBody").innerText = "Error fetching FHIR Bundle: " + e.message;
}
}
async function openSoapModal() {
if (!currentSessionId) return alert("No active session ID");
document.getElementById("modalTitle").innerText = "Clinical SOAP Note (Subjective, Objective, Assessment, Plan)";
document.getElementById("modalBody").innerText = "Compiling SOAP Note & ICD-10 Coding...";
document.getElementById("modalHtmlBtn").style.display = "inline-flex";
document.getElementById("modalOverlay").classList.add("active");
try {
const res = await fetch(`http://localhost:8000/v1/voice-onboarding/soap-note/${currentSessionId}`, {
headers: { "X-API-Key": "skyheal_secure_api_key_2026" }
});
const note = await res.json();
document.getElementById("modalBody").innerText = JSON.stringify(note, null, 2);
} catch (e) {
document.getElementById("modalBody").innerText = "Error compiling SOAP Note: " + e.message;
}
}
function closeModal() {
document.getElementById("modalOverlay").classList.remove("active");
}
function copyModalContent() {
const text = document.getElementById("modalBody").innerText;
navigator.clipboard.writeText(text);
alert("Copied to clipboard!");
}
function openPrintableHtml() {
if (currentSessionId) {
window.open(`http://localhost:8000/v1/voice-onboarding/soap-note/${currentSessionId}?format=html`, '_blank');
}
}
// Initialize on load
window.onload = connectWebSocket;
</script>
</body>
</html>
@@ -0,0 +1,22 @@
# Production Security & Compliance Checklist
## 1. Authentication & Authorization Controls
- [x] **Bearer Token & API Key Authentication**: Verified (`app/core/security.py`).
- [x] **IDOR Session Ownership Validation**: Verified (`session.patient_id == auth.patient_id`).
- [x] **Unauthenticated Access Prevention**: Unauthenticated requests return `401 Unauthorized`.
- [x] **Unauthorized Session Modification Prevention**: Cross-patient requests return `403 Forbidden`.
## 2. Input Validation & Schema Boundaries
- [x] **Numeric Age Validation Bounds**: Enforced `ge=0, le=120` in `PatientContext` schema (`422` returned on invalid inputs).
- [x] **String & XSS Sanitization**: HTML tags stripped/escaped from initial greetings.
- [x] **File Upload Restrictions**: 10MB size limit and strict MIME validation (`JPEG`, `PNG`, `WEBP`, `PDF`).
## 3. Network & Transport Security
- [x] **CORS Configuration**: Wildcard origin (`*`) removed; explicit domain origins configured.
- [x] **HTTP Security Headers**: `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy: strict-origin-when-cross-origin` attached.
- [x] **Rate Limiting**: IP-based rate limiting enforced on API endpoints (`429 Too Many Requests` returned with `Retry-After`).
## 4. Operational & Container Security
- [x] **Non-Root Container**: Dockerfile configured with dedicated `appuser` (UID 999).
- [x] **Request ID Tracking**: Attached `X-Request-ID` correlation header to all HTTP requests.
- [x] **Error Masking**: Stack traces hidden behind generic `500 Internal Server Error` responses.
+1 -1
View File
@@ -15,7 +15,7 @@ google-api-core==2.33.0
google-api-python-client==2.199.0
google-auth==2.56.3
google-auth-httplib2==0.4.1
google-generativeai==0.8.6
google-genai
googleapis-common-protos==1.75.0
greenlet==3.5.5
grpcio==1.83.0
@@ -209,7 +209,7 @@ def test_11_emergency_override_tamil():
res = await orchestrator.process(mock_session, state_mock, [], "மூச்சு விட முடியல")
assert "108" in res["reply"]
assert ("மருத்துவமனை" in res["reply"] or "அவசர" in res["reply"])
assert res.get("completed") is True
asyncio.run(run())
@@ -0,0 +1,52 @@
"""
Consolidated Automated Tests from root-level scripts.
Covers:
- Direct symptom classification for all supported specialties
- Auto-specialty triage rules and canonical output checks
- Offline AI provider interface and configuration contracts
"""
import pytest
from app.ai.orchestrator import classify_specialty_from_symptoms
from app.specialties.registry import get_specialty_config, SPECIALTIES, CANONICAL_ALIASES
def test_direct_symptom_classifier_all_specialties():
"""Consolidated test derived from test_direct_classifier.py & test_auto_specialty.py."""
test_cases = [
("Cardiology", "Chest pain and shortness of breath", "cardiology"),
("Dermatology", "Skin rash and severe itching", "dermatology"),
("Orthopedics", "Knee joint pain and swelling", "orthopedics"),
("Neurology", "Headache and dizziness for 3 days", "neurology"),
("Gastroenterology", "Stomach pain and vomiting", "gastroenterology"),
("Pulmonology", "Cough and phlegm for 4 days", "pulmonology"),
("Diabetology", "High blood sugar and insulin history", "diabetology"),
("Mental Health", "Depression and panic attacks", "mental_health"),
("General", "Feverish and weakness", "general"),
]
for label, text, expected_code in test_cases:
res = classify_specialty_from_symptoms(text)
assert res["code"] == expected_code, f"Failed for {label}: expected '{expected_code}', got '{res['code']}'"
def test_registry_canonical_aliases():
"""Verify alias mapping for legacy codes."""
assert get_specialty_config("general_medicine")["code"] == "general"
assert get_specialty_config("psychiatry")["code"] == "mental_health"
def test_all_canonical_specialties_registered():
"""Ensure all required canonical specialties have active, valid configs."""
required = {
"general",
"mental_health",
"diabetology",
"cardiology",
"dermatology",
"neurology",
"orthopedics",
"gastroenterology",
"pulmonology",
}
registered = set(SPECIALTIES.keys())
assert required.issubset(registered), f"Missing registered specialties: {required - registered}"
@@ -534,7 +534,7 @@ def test_acceptance_test_4_nothing_else_completion(mock_session):
# =====================================================================
def test_emergency_red_flag_crushing_chest_pain(mock_session, empty_state):
"""Emergency statement triggers 108 ambulance advice and stops intake."""
"""Emergency statement triggers hospital emergency room/staff advice and stops intake."""
import asyncio
orchestrator = AIOrchestrator()
state_mock = MagicMock()
@@ -547,7 +547,7 @@ def test_emergency_red_flag_crushing_chest_pain(mock_session, empty_state):
user_message="I have crushing chest pain and I cannot breathe!"
))
assert "108" in res["reply"]
assert "emergency" in res["reply"].lower() or "hospital" in res["reply"].lower()
assert res.get("completed") is True
@@ -575,7 +575,7 @@ def test_mental_health_crisis_override(mock_session, empty_state):
def test_specialty_auto_classification():
"""Verify clinical keywords correctly triage to respective specialties."""
assert classify_specialty_from_symptoms("I have severe depression and insomnia")["code"] == "psychiatry"
assert classify_specialty_from_symptoms("I have severe depression and insomnia")["code"] in ("mental_health", "psychiatry")
assert classify_specialty_from_symptoms("Severe chest pain and breathless")["code"] == "cardiology"
assert classify_specialty_from_symptoms("Skin rash and severe itching")["code"] == "dermatology"
assert classify_specialty_from_symptoms("Knee joint pain and swelling")["code"] == "orthopedics"
@@ -975,3 +975,32 @@ def test_specialty_not_downgraded_on_duration_message(mock_session):
# Specialty should remain neurology, not flip to general_medicine
suggested = res.get("suggested_specialty", {})
assert suggested.get("code") == "neurology"
def test_dynamic_symptom_tailored_questions():
"""Verify that follow-up questions dynamically reference the reported symptom name."""
from app.ai.orchestrator import _pick_best_question
# Tamil with headache reported
state_ta = {"reason_for_visit": "தலைவலி", "symptoms": [{"name": "தலைவலி"}]}
q_ta = _pick_best_question(["duration"], "ta-IN", False, current_state=state_ta)
assert "தலைவலி" in q_ta
# English with stomach pain reported
state_en = {"reason_for_visit": "stomach pain", "symptoms": [{"name": "stomach pain"}]}
q_en = _pick_best_question(["duration"], "en-US", False, current_state=state_en)
assert "stomach pain" in q_en
def test_combined_meds_and_allergies_question_pattern():
"""Verify that when medications and allergies are missing, questions are combined naturally."""
from app.ai.orchestrator import _pick_best_question
state_en = {"reason_for_visit": "fever", "symptoms": [{"name": "fever", "duration": "2 days"}], "medical_history": ["Hypertension"]}
q_comb = _pick_best_question(["medications", "allergies"], "en-US", False, current_state=state_en)
# Should ask about both medications AND allergies in one fluid question
assert "medication" in q_comb.lower() or "regula" in q_comb.lower()
assert "allerg" in q_comb.lower()
@@ -0,0 +1,294 @@
import uuid
import asyncio
import concurrent.futures
import pytest
from unittest.mock import AsyncMock, patch, MagicMock
from app.ai.gemini_client import GeminiClient
from app.ai.sarvam_client import (
SarvamClient,
STT_TIMEOUT,
STT_NETWORK_ERROR,
STT_SUCCESS,
TTS_TIMEOUT,
TTS_NETWORK_ERROR,
TTS_SUCCESS,
)
from app.database.database import SessionLocal, engine, Base
from app.database.models import OnboardingSession, OnboardingState, OnboardingMessage
# =====================================================================
# GEMINI CLIENT TESTS
# =====================================================================
@pytest.mark.anyio
async def test_gemini_client():
client = GeminiClient()
assert client.model is not None
# Unconfigured key fallback
with patch.object(client, "api_key", ""):
doc_res = await client.extract_from_document(b"fake", "application/pdf", {})
assert doc_res["document_type"] == "medical_report"
@pytest.mark.anyio
async def test_gemini_timeout():
client = GeminiClient()
client.api_key = "valid_fake_key"
with patch("asyncio.wait_for", side_effect=asyncio.TimeoutError):
with pytest.raises(Exception):
await client.generate_response("System prompt", "User msg")
@pytest.mark.anyio
async def test_gemini_provider_error():
client = GeminiClient()
client.api_key = "valid_fake_key"
with patch("asyncio.wait_for", side_effect=RuntimeError("API quota exceeded")):
with pytest.raises(Exception):
await client.generate_response("System prompt", "User msg")
@pytest.mark.anyio
async def test_gemini_malformed_response():
client = GeminiClient()
client.api_key = "valid_fake_key"
mock_resp = MagicMock()
mock_resp.text = "NOT JSON VALUE"
with patch("asyncio.wait_for", return_value=mock_resp):
res = await client.extract_from_document(b"data", "image/png", {})
assert res["document_type"] == "unknown"
# =====================================================================
# VOICE STT / TTS RELIABILITY TESTS
# =====================================================================
@pytest.mark.anyio
async def test_stt_timeout():
sarvam = SarvamClient()
sarvam.api_key = "fake_key"
with patch.object(sarvam, "_get_client") as mock_get_client:
mock_client = AsyncMock()
mock_client.post.side_effect = asyncio.TimeoutError
mock_get_client.return_value = mock_client
transcript, status = await sarvam.speech_to_text_with_status(b"audio_bytes", max_retries=1)
assert transcript == ""
assert status == STT_TIMEOUT
@pytest.mark.anyio
async def test_stt_network_failure():
sarvam = SarvamClient()
sarvam.api_key = "fake_key"
with patch.object(sarvam, "_get_client") as mock_get_client:
mock_client = AsyncMock()
mock_client.post.side_effect = Exception("Network unreachable")
mock_get_client.return_value = mock_client
transcript, status = await sarvam.speech_to_text_with_status(b"audio_bytes", max_retries=1)
assert transcript == ""
assert status == STT_NETWORK_ERROR
@pytest.mark.anyio
async def test_stt_retry_limit():
sarvam = SarvamClient()
sarvam.api_key = "fake_key"
attempt_count = 0
async def mock_post(*args, **kwargs):
nonlocal attempt_count
attempt_count += 1
raise asyncio.TimeoutError
with patch.object(sarvam, "_get_client") as mock_get_client:
mock_client = AsyncMock()
mock_client.post = mock_post
mock_get_client.return_value = mock_client
await sarvam.speech_to_text_with_status(b"audio_bytes", max_retries=2)
assert attempt_count == 3 # Initial + 2 retries
@pytest.mark.anyio
async def test_tts_timeout():
sarvam = SarvamClient()
sarvam.api_key = "fake_key"
with patch.object(sarvam, "_get_client") as mock_get_client:
mock_client = AsyncMock()
mock_client.post.side_effect = asyncio.TimeoutError
mock_get_client.return_value = mock_client
audio, status = await sarvam.text_to_speech_with_status("Hello", max_retries=1)
assert audio is None
assert status == TTS_TIMEOUT
@pytest.mark.anyio
async def test_tts_network_failure():
sarvam = SarvamClient()
sarvam.api_key = "fake_key"
with patch.object(sarvam, "_get_client") as mock_get_client:
mock_client = AsyncMock()
mock_client.post.side_effect = Exception("Network unreachable")
mock_get_client.return_value = mock_client
audio, status = await sarvam.text_to_speech_with_status("Hello", max_retries=1)
assert audio is None
assert status == TTS_NETWORK_ERROR
@pytest.mark.anyio
async def test_tts_retry_limit():
sarvam = SarvamClient()
sarvam.api_key = "fake_key"
attempt_count = 0
async def mock_post(*args, **kwargs):
nonlocal attempt_count
attempt_count += 1
raise asyncio.TimeoutError
with patch.object(sarvam, "_get_client") as mock_get_client:
mock_client = AsyncMock()
mock_client.post = mock_post
mock_get_client.return_value = mock_client
await sarvam.text_to_speech_with_status("Hello", max_retries=2)
assert attempt_count == 3
@pytest.mark.anyio
async def test_voice_failure_does_not_crash_websocket():
sarvam = SarvamClient()
with patch.object(sarvam, "speech_to_text_with_status", return_value=("", STT_TIMEOUT)):
transcript, status = await sarvam.speech_to_text_with_status(b"audio", max_retries=0)
assert status == STT_TIMEOUT
assert transcript == ""
# =====================================================================
# SQLITE CONCURRENCY TESTS
# =====================================================================
def _worker_write_session(session_idx: int):
db = SessionLocal()
try:
session_id = f"CONC-SESS-{uuid.uuid4()}"
sess = OnboardingSession(
session_id=session_id,
patient_id=f"PAT-{session_idx}",
language="en-IN",
category_type="general",
category_code="general",
category_name="General",
stage="chief_complaint",
status="in_progress",
)
db.add(sess)
db.commit()
msg = OnboardingMessage(
session_id=session_id,
role="patient",
message="Severe fever for 2 days",
stage="chief_complaint",
)
db.add(msg)
db.commit()
return True
except Exception as e:
db.rollback()
raise e
finally:
db.close()
def test_concurrent_session_writes():
"""Simulate 15 concurrent session writes to SQLite under WAL mode."""
Base.metadata.create_all(bind=engine)
with concurrent.futures.ThreadPoolExecutor(max_workers=10) as executor:
futures = [executor.submit(_worker_write_session, i) for i in range(15)]
results = [f.result() for f in concurrent.futures.as_completed(futures)]
assert all(results)
def test_concurrent_websocket_sessions():
"""Simulate multiple concurrent session updates without database locks."""
Base.metadata.create_all(bind=engine)
with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor:
futures = [executor.submit(_worker_write_session, i + 100) for i in range(5)]
results = [f.result() for f in concurrent.futures.as_completed(futures)]
assert len(results) == 5
def test_database_lock_recovery():
db1 = SessionLocal()
db2 = SessionLocal()
try:
s_id = f"LOCK-TEST-{uuid.uuid4()}"
sess1 = OnboardingSession(
session_id=s_id,
patient_id="PAT-L1",
language="en",
category_type="general",
category_code="general",
category_name="General",
stage="chief_complaint",
status="in_progress",
)
db1.add(sess1)
db1.commit()
res1 = db1.query(OnboardingSession).filter_by(session_id=s_id).first()
res2 = db2.query(OnboardingSession).filter_by(session_id=s_id).first()
assert res1 is not None
assert res2 is not None
finally:
db1.close()
db2.close()
def test_transaction_rollback():
db = SessionLocal()
try:
s_id = f"ROLLBACK-TEST-{uuid.uuid4()}"
sess = OnboardingSession(
session_id=s_id,
patient_id="PAT-R1",
language="en",
category_type="general",
category_code="general",
category_name="General",
stage="chief_complaint",
status="in_progress",
)
db.add(sess)
db.flush()
db.rollback()
check = db.query(OnboardingSession).filter_by(session_id=s_id).first()
assert check is None
finally:
db.close()
def test_disconnect_during_db_operation():
db = SessionLocal()
try:
s_id = f"DISCONNECT-TEST-{uuid.uuid4()}"
sess = OnboardingSession(
session_id=s_id,
patient_id="PAT-D1",
language="en",
category_type="general",
category_code="general",
category_name="General",
stage="chief_complaint",
status="in_progress",
)
db.add(sess)
db.commit()
db.close()
db_new = SessionLocal()
check = db_new.query(OnboardingSession).filter_by(session_id=s_id).first()
assert check is not None
db_new.close()
except Exception:
db.close()
raise
@@ -0,0 +1,236 @@
"""
Comprehensive production upgrade test suite for AI Patient Onboarding Orchestrator.
Covers Scenarios 1-7, Emergency precedence, Crisis routing, Family history scope,
Negation, Multi-fact extraction, Rule 51 & 52 response formatting, and State merging.
"""
import os
import sys
import json
import pytest
from unittest.mock import MagicMock, AsyncMock
# Add project root to sys.path
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
from app.ai.orchestrator import (
AIOrchestrator,
merge_state_updates,
classify_specialty_from_symptoms,
check_required_missing,
_field_status,
)
from app.ai.safety.emergency import check_emergency
from app.ai.safety.crisis import check_crisis
from app.ai.extraction.multilingual_normalizer import normalize_text, detect_language
from app.ai.extraction.history_extractor import extract_history
from app.ai.conversation.response_formatter import format_conversational_response
@pytest.fixture
def mock_session():
session = MagicMock()
session.language = "en"
session.category_code = "general_medicine"
session.category_name = "General Medicine"
session.patient_name = "John Doe"
return session
@pytest.fixture
def mock_state():
state = MagicMock()
state.state_json = json.dumps({
"reason_for_visit": None,
"symptoms": [],
"medical_history": [],
"medications": [],
"allergies": []
})
return state
# =====================================================================
# REQUIRED SCENARIOS 1 - 7
# =====================================================================
def test_scenario_1_single_symptom_duration(mock_session, mock_state):
"""
Scenario 1:
Patient: 'I have headache for 3 days.'
Expected: symptom = headache, duration = 3 days. Ask next missing question.
"""
import asyncio
orchestrator = AIOrchestrator()
res = asyncio.run(orchestrator.process(
session=mock_session,
state=mock_state,
conversation_history=[],
user_message="I have headache for 3 days."
))
updates = res.get("state_updates", {})
symptoms = updates.get("symptoms", [])
assert len(symptoms) > 0
assert symptoms[0]["name"] == "headache"
assert symptoms[0]["duration"] == "3 days"
assert res.get("completed") is False
def test_scenario_2_multi_fact_extraction(mock_session, mock_state):
"""
Scenario 2:
Patient: 'I have headache for 3 days and it is severe. I also have diabetes and take metformin.'
Expected: headache, duration, severity, diabetes, metformin extracted in one pass.
"""
import asyncio
orchestrator = AIOrchestrator()
res = asyncio.run(orchestrator.process(
session=mock_session,
state=mock_state,
conversation_history=[],
user_message="I have headache for 3 days and it is severe. I also have diabetes and take metformin."
))
updates = res.get("state_updates", {})
assert "symptoms" in updates
assert "medical_history" in updates
assert "medications" in updates
# Symptoms
symptoms = updates["symptoms"]
assert symptoms[0]["name"] == "headache"
assert symptoms[0]["duration"] == "3 days"
assert symptoms[0]["severity"] == "severe"
# Medical History
mh = updates["medical_history"]
assert any(h["condition"] == "Diabetes" for h in mh)
# Medications
meds = updates["medications"]
assert any("Metformin" in m["name"] for m in meds)
def test_scenario_3_negation_confirmed_none(mock_session, mock_state):
"""
Scenario 3:
Patient: 'I don't take any medicines and I have no allergies.'
Expected: medications = confirmed_none, allergies = confirmed_none.
"""
import asyncio
orchestrator = AIOrchestrator()
res = asyncio.run(orchestrator.process(
session=mock_session,
state=mock_state,
conversation_history=[],
user_message="I don't take any medicines and I have no allergies."
))
updates = res.get("state_updates", {})
meds = updates.get("medications")
algs = updates.get("allergies")
assert isinstance(meds, dict) and meds.get("status") == "confirmed_none"
assert isinstance(algs, dict) and algs.get("status") == "confirmed_none"
def test_scenario_4_family_history_scope(mock_session, mock_state):
"""
Scenario 4:
Patient: 'My father has diabetes but I don't have diabetes.'
Expected: family_history = father + diabetes, patient medical_history = confirmed_none for diabetes.
"""
import asyncio
orchestrator = AIOrchestrator()
res = asyncio.run(orchestrator.process(
session=mock_session,
state=mock_state,
conversation_history=[],
user_message="My father has diabetes but I don't have diabetes."
))
updates = res.get("state_updates", {})
fam = updates.get("family_history", [])
assert len(fam) > 0
assert fam[0]["relation"] == "father"
assert fam[0]["condition"] == "Diabetes"
mh = updates.get("medical_history")
# Patient medical_history should NOT have active Diabetes
if isinstance(mh, list):
assert not any(h.get("condition") == "Diabetes" for h in mh)
def test_scenario_5_emergency_precedence(mock_session, mock_state):
"""
Scenario 5:
Patient: 'I have chest pain and difficulty breathing.'
Expected: emergency routing immediately without normal onboarding questioning.
"""
import asyncio
orchestrator = AIOrchestrator()
res = asyncio.run(orchestrator.process(
session=mock_session,
state=mock_state,
conversation_history=[],
user_message="I have chest pain and difficulty breathing."
))
assert res.get("next_stage") == "emergency_referral"
assert "urgent medical attention" in res.get("reply", "") or "ER" in res.get("reply", "")
def test_scenario_6_crisis_precedence(mock_session, mock_state):
"""
Scenario 6:
Patient: 'I feel very depressed and I don't want to live anymore.'
Expected: crisis routing with Tele-MANAS (14416).
"""
import asyncio
orchestrator = AIOrchestrator()
res = asyncio.run(orchestrator.process(
session=mock_session,
state=mock_state,
conversation_history=[],
user_message="I feel very depressed and I don't want to live anymore."
))
assert res.get("next_stage") == "crisis_handling"
assert "14416" in res.get("reply", "") or "Tele-MANAS" in res.get("reply", "")
def test_scenario_7_multilingual_tamil(mock_session, mock_state):
"""
Scenario 7:
Patient: 'எனக்கு தலை சுத்துது, மூன்று நாளா இருக்கு, ரொம்ப அதிகமா இருக்கு.'
Expected: symptom = dizziness, duration = 3 days, severity = severe.
"""
import asyncio
mock_session.language = "ta-IN"
orchestrator = AIOrchestrator()
res = asyncio.run(orchestrator.process(
session=mock_session,
state=mock_state,
conversation_history=[],
user_message="எனக்கு தலை சுத்துது, மூன்று நாளா இருக்கு, ரொம்ப அதிகமா இருக்கு."
))
updates = res.get("state_updates", {})
symptoms = updates.get("symptoms", [])
assert len(symptoms) > 0
assert symptoms[0]["duration"] is not None
assert symptoms[0]["severity"] is not None
# =====================================================================
# RULE 51 & 52 RESPONSE FORMATTING TESTS
# =====================================================================
def test_rule_51_response_length_formatting():
"""Verify format_conversational_response enforces max 1-2 short sentences under 20-25 words."""
long_reply = "Thank you for sharing that information. I understand that you have been experiencing severe headaches for three days. Could you please tell me if you have any other chronic medical conditions like diabetes or high blood pressure?"
formatted = format_conversational_response(long_reply, "en")
words = formatted.split()
assert len(words) <= 25
assert formatted.count(".") + formatted.count("?") + formatted.count("!") <= 2
@@ -0,0 +1,83 @@
import pytest
from app.ai.orchestrator import classify_specialty_from_symptoms, SPECIALTY_RULES
from app.specialties.registry import SPECIALTIES, CANONICAL_ALIASES, get_specialty_config, validate_specialty_registry
def test_diabetology_detection():
"""Verify diabetes keywords resolve to canonical code 'diabetology'."""
phrases = [
"My sugar level is very high",
"I have type 2 diabetes",
"Taking Glycomet 500mg daily",
"HbA1c report shows 8.1%",
"High blood sugar after meals",
"Taking insulin injections",
]
for text in phrases:
res = classify_specialty_from_symptoms(text)
assert res["code"] == "diabetology", f"Failed for '{text}': got {res['code']}"
def test_mental_health_canonical_code():
"""Verify mental health symptoms resolve to canonical 'mental_health', not 'psychiatry'."""
phrases = [
"Feeling depressed and anxious",
"Having severe panic attacks and insomnia",
"மன அழுத்தம் அதிகமாக இருக்கு",
]
for text in phrases:
res = classify_specialty_from_symptoms(text)
assert res["code"] == "mental_health", f"Failed for '{text}': got {res['code']}"
def test_general_canonical_code():
"""Verify general symptoms resolve to canonical 'general', not 'general_medicine'."""
phrases = [
"I have mild fever and body pain",
"Feeling weak for 2 days",
]
for text in phrases:
res = classify_specialty_from_symptoms(text)
assert res["code"] == "general", f"Failed for '{text}': got {res['code']}"
def test_all_specialties_have_configs():
"""Verify all 9 canonical specialties have dedicated, non-fallback configuration files."""
canonical_codes = [
"general",
"mental_health",
"diabetology",
"cardiology",
"dermatology",
"neurology",
"orthopedics",
"gastroenterology",
"pulmonology",
]
for code in canonical_codes:
assert code in SPECIALTIES, f"Missing specialty config in registry for {code}"
config = get_specialty_config(code)
assert config["code"] == code, f"Specialty {code} resolved to fallback code {config['code']}"
assert len(config.get("focus_areas", [])) > 0
def test_no_supported_specialty_falls_back_to_general():
"""Verify that every classifier output is registered and does not fall back to general."""
validation = validate_specialty_registry()
assert validation["valid"] is True
assert validation["registered_count"] >= 9
assert len(validation["missing_configs"]) == 0
def test_registry_classifier_consistency():
"""Verify all classifier rule codes match registered specialty codes."""
registered = set(SPECIALTIES.keys())
for rule in SPECIALTY_RULES:
code = rule["code"]
assert code in registered, f"Classifier rule code '{code}' is not in registered SPECIALTIES"
def test_alias_compatibility():
"""Verify legacy aliases convert to canonical configs cleanly."""
assert get_specialty_config("general_medicine")["code"] == "general"
assert get_specialty_config("psychiatry")["code"] == "mental_health"
@@ -0,0 +1,250 @@
"""
Comprehensive WebSocket Test Suite (Section 37, 38, 40).
Tests connection lifecycle, handshake, message protocol, typing/processing events,
deduplication, safety alerts, error protocol, multilingual handling, and REST backward compatibility.
"""
import sys
import os
import json
import pytest
from fastapi.testclient import TestClient
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
from app.main import app
client = TestClient(app)
def create_test_session(patient_id: str = "PAT-WS-100", language: str = "en") -> str:
"""Helper to initialize an active onboarding session via REST API."""
response = client.post(
"/v1/onboarding/start",
json={
"patient": {"id": patient_id, "name": "WebSocket Test Patient", "age": 32, "gender": "female"},
"category": {"type": "general", "code": "general_medicine", "name": "General Medicine"},
"language": language
},
headers={"Authorization": f"Bearer test_token_{patient_id}"}
)
assert response.status_code == 200
return response.json()["session_id"]
def test_websocket_successful_connection_ack():
"""Test WS connection lifecycle and connection.ack event (Section 3 & 4)."""
patient_id = "PAT-WS-ACK"
session_id = create_test_session(patient_id=patient_id)
url = f"/api/v1/ws/onboarding/{session_id}?token=test_token_{patient_id}"
with client.websocket_connect(url) as websocket:
ack_data = websocket.receive_json()
assert ack_data["type"] == "connection.ack"
assert ack_data["session_id"] == session_id
assert ack_data["status"] == "connected"
def test_websocket_invalid_session_connection():
"""Test connection to non-existent session ID is rejected (Section 24)."""
url = "/api/v1/ws/onboarding/ONB-INVALID-9999?token=test_token_PAT-WS-1"
with client.websocket_connect(url) as websocket:
err = websocket.receive_json()
assert err["type"] == "error"
assert err["code"] == "SESSION_NOT_FOUND"
def test_websocket_unauthorized_session_access():
"""Test connecting to Session A with User B token is rejected (Section 25)."""
session_id = create_test_session(patient_id="PAT-USER-A")
# Connect with token for PAT-USER-B
url = f"/api/v1/ws/onboarding/{session_id}?token=test_token_PAT-USER-B"
with client.websocket_connect(url) as websocket:
err = websocket.receive_json()
assert err["type"] == "error"
assert err["code"] == "UNAUTHORIZED"
def test_websocket_end_to_end_message_flow():
"""Required End-to-End Test (Section 38): verify complete event sequence."""
patient_id = "PAT-WS-E2E"
session_id = create_test_session(patient_id=patient_id)
url = f"/api/v1/ws/onboarding/{session_id}?token=test_token_{patient_id}"
with client.websocket_connect(url) as websocket:
# Receive ACK
ack = websocket.receive_json()
assert ack["type"] == "connection.ack"
# Send patient.message
websocket.send_json({
"type": "patient.message",
"message_id": "msg_e2e_1",
"text": "I have severe headache for 3 days"
})
events = []
# Expect: processing.started, typing.started, state.updated, assistant.message, typing.stopped, processing.completed
for _ in range(6):
event = websocket.receive_json()
events.append(event)
event_types = [e["type"] for e in events]
assert "processing.started" in event_types
assert "typing.started" in event_types
assert "state.updated" in event_types
assert "assistant.message" in event_types
assert "typing.stopped" in event_types
assert "processing.completed" in event_types
# Verify assistant response properties (Section 5 & 9)
asst_msg = next(e for e in events if e["type"] == "assistant.message")
assert asst_msg["message_id"] == "resp_msg_e2e_1"
assert len(asst_msg["reply"].split()) <= 20
assert asst_msg["reply"].count("?") <= 1
def test_websocket_duplicate_message_prevention():
"""Test duplicate message_id protection returns cached response (Section 17)."""
patient_id = "PAT-WS-DEDUP"
session_id = create_test_session(patient_id=patient_id)
url = f"/api/v1/ws/onboarding/{session_id}?token=test_token_{patient_id}"
with client.websocket_connect(url) as websocket:
websocket.receive_json() # ACK
msg_payload = {
"type": "patient.message",
"message_id": "msg_dedup_101",
"text": "Fever for two days"
}
# First send
websocket.send_json(msg_payload)
events_1 = [websocket.receive_json() for _ in range(6)]
asst_1 = next(e for e in events_1 if e["type"] == "assistant.message")
# Duplicate send with SAME message_id
websocket.send_json(msg_payload)
cached_resp = websocket.receive_json()
assert cached_resp["type"] == "assistant.message"
assert cached_resp["reply"] == asst_1["reply"]
def test_websocket_emergency_safety_alert():
"""Test emergency message triggers safety.alert event (Section 12)."""
patient_id = "PAT-WS-EMERGENCY"
session_id = create_test_session(patient_id=patient_id)
url = f"/api/v1/ws/onboarding/{session_id}?token=test_token_{patient_id}"
with client.websocket_connect(url) as websocket:
websocket.receive_json() # ACK
websocket.send_json({
"type": "patient.message",
"message_id": "msg_emerg_1",
"text": "I have severe crushing chest pain radiating to my arm and shortness of breath"
})
received = []
for _ in range(8): # Includes safety alert and onboarding completed
received.append(websocket.receive_json())
event_types = [e["type"] for e in received]
assert "safety.alert" in event_types
safety_event = next(e for e in received if e["type"] == "safety.alert")
assert safety_event["level"] in ["emergency", "crisis"]
def test_websocket_invalid_json_handling():
"""Test malformed non-JSON text returns error event without closing socket (Section 15)."""
patient_id = "PAT-WS-BADJSON"
session_id = create_test_session(patient_id=patient_id)
url = f"/api/v1/ws/onboarding/{session_id}?token=test_token_{patient_id}"
with client.websocket_connect(url) as websocket:
websocket.receive_json() # ACK
# Send raw non-JSON text
websocket.send_text("THIS_IS_NOT_JSON")
err_resp = websocket.receive_json()
assert err_resp["type"] == "error"
assert err_resp["code"] == "INVALID_MESSAGE"
# Verify socket is still active by sending valid message after
websocket.send_json({
"type": "patient.message",
"message_id": "msg_valid_after_error",
"text": "I am fine now"
})
events = [websocket.receive_json() for _ in range(6)]
assert any(e["type"] == "assistant.message" for e in events)
def test_websocket_oversized_message():
"""Test message > 5000 chars returns MESSAGE_TOO_LARGE error (Section 16)."""
patient_id = "PAT-WS-LARGE"
session_id = create_test_session(patient_id=patient_id)
url = f"/api/v1/ws/onboarding/{session_id}?token=test_token_{patient_id}"
with client.websocket_connect(url) as websocket:
websocket.receive_json() # ACK
oversized_text = "A" * 5050
websocket.send_json({
"type": "patient.message",
"message_id": "msg_large_1",
"text": oversized_text
})
err_resp = websocket.receive_json()
assert err_resp["type"] == "error"
assert err_resp["code"] == "MESSAGE_TOO_LARGE"
def test_websocket_multilingual_unicode():
"""Test Tamil/Hindi multilingual messages process seamlessly over WebSocket (Section 16)."""
patient_id = "PAT-WS-TAMIL"
session_id = create_test_session(patient_id=patient_id, language="ta")
url = f"/api/v1/ws/onboarding/{session_id}?token=test_token_{patient_id}"
with client.websocket_connect(url) as websocket:
websocket.receive_json() # ACK
websocket.send_json({
"type": "patient.message",
"message_id": "msg_ta_1",
"text": "எனக்கு 3 நாளா தலைவலி இருக்கு"
})
events = [websocket.receive_json() for _ in range(6)]
asst = next(e for e in events if e["type"] == "assistant.message")
assert asst["reply"] is not None
assert len(asst["reply"]) > 0
def test_rest_api_backward_compatibility():
"""Verify Section 36 requirement: REST APIs continue working untouched."""
session_id = create_test_session(patient_id="PAT-REST-BACKWARD")
# Send REST message
res = client.post(
f"/v1/onboarding/{session_id}/message",
json={"message": "Headache for two days"},
headers={"Authorization": "Bearer test_token_PAT-REST-BACKWARD"}
)
assert res.status_code == 200
assert res.json()["success"] is True
assert "message" in res.json()
def test_websocket_tester_ui_endpoint():
"""Verify that the sample UI page for WebSocket testing returns 200 OK HTML."""
res = client.get("/ws-tester")
assert res.status_code == 200
assert "text/html" in res.headers["content-type"]
assert "AI Patient Onboarding" in res.text
assert "WebSocket" in res.text