Compare commits
1
Commits
dev
..
dev_upgrade
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37440993f7 |
@@ -1,65 +0,0 @@
|
||||
# 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
|
||||
```
|
||||
@@ -1,21 +0,0 @@
|
||||
"""
|
||||
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",
|
||||
]
|
||||
@@ -1,84 +0,0 @@
|
||||
"""
|
||||
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
|
||||
@@ -1,26 +0,0 @@
|
||||
"""
|
||||
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
|
||||
@@ -1,98 +0,0 @@
|
||||
"""
|
||||
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
|
||||
@@ -1,168 +0,0 @@
|
||||
"""
|
||||
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."
|
||||
@@ -1,57 +0,0 @@
|
||||
"""
|
||||
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: ["ನಿಮಗೆ ಯಾವುದೇ ಔಷಧಿ ಅಥವಾ ಆಹಾರ ಅಲರ್ಜಿಗಳಿವೆಯೇ?"],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
"""
|
||||
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)
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
"""
|
||||
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
|
||||
)
|
||||
@@ -0,0 +1,106 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
|
||||
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
|
||||
@@ -1,21 +0,0 @@
|
||||
"""
|
||||
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",
|
||||
]
|
||||
@@ -1,56 +0,0 @@
|
||||
"""
|
||||
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
|
||||
@@ -1,83 +0,0 @@
|
||||
"""
|
||||
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
|
||||
@@ -1,68 +0,0 @@
|
||||
"""
|
||||
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
|
||||
@@ -1,71 +0,0 @@
|
||||
"""
|
||||
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"
|
||||
@@ -1,54 +0,0 @@
|
||||
"""
|
||||
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
|
||||
@@ -1,117 +0,0 @@
|
||||
"""
|
||||
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
|
||||
@@ -8,7 +8,7 @@ class GeminiClient:
|
||||
|
||||
def __init__(self):
|
||||
self.api_key = GEMINI_API_KEY
|
||||
self.model = GEMINI_MODEL or "gemini-1.5-flash"
|
||||
self.model = GEMINI_MODEL
|
||||
|
||||
async def extract_from_document(
|
||||
self,
|
||||
@@ -30,10 +30,9 @@ class GeminiClient:
|
||||
}
|
||||
|
||||
try:
|
||||
from google import genai
|
||||
from google.genai import types
|
||||
|
||||
client = genai.Client(api_key=self.api_key)
|
||||
import google.generativeai as genai
|
||||
genai.configure(api_key=self.api_key)
|
||||
model = genai.GenerativeModel(self.model or "gemini-1.5-flash")
|
||||
|
||||
prompt = f"""
|
||||
You are a medical document extraction AI.
|
||||
@@ -57,12 +56,12 @@ Return ONLY JSON:
|
||||
"summary": "short clinical summary of the document"
|
||||
}}
|
||||
"""
|
||||
part = types.Part.from_bytes(data=file_bytes, mime_type=mime_type)
|
||||
part = {
|
||||
"mime_type": mime_type,
|
||||
"data": file_bytes,
|
||||
}
|
||||
response = await asyncio.wait_for(
|
||||
client.aio.models.generate_content(
|
||||
model=self.model,
|
||||
contents=[part, prompt]
|
||||
),
|
||||
model.generate_content_async([part, prompt]),
|
||||
timeout=4.0
|
||||
)
|
||||
cleaned_text = response.text.replace("```json", "").replace("```", "").strip()
|
||||
@@ -85,18 +84,15 @@ 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")
|
||||
|
||||
from google import genai
|
||||
from google.genai import types
|
||||
|
||||
client = genai.Client(api_key=self.api_key)
|
||||
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"},
|
||||
)
|
||||
full_prompt = f"{system_prompt}\n\nPATIENT SAID: {user_message}"
|
||||
config = types.GenerateContentConfig(response_mime_type="application/json")
|
||||
response = await asyncio.wait_for(
|
||||
client.aio.models.generate_content(
|
||||
model=self.model,
|
||||
contents=full_prompt,
|
||||
config=config,
|
||||
),
|
||||
model.generate_content_async(full_prompt),
|
||||
timeout=3.5
|
||||
)
|
||||
return response.text
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,68 +2,6 @@ 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.
|
||||
@@ -81,6 +19,7 @@ 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.
|
||||
@@ -107,10 +46,12 @@ 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. 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.
|
||||
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).
|
||||
- No markdown, no bullet points, no brackets. Clean spoken dialogue only.
|
||||
|
||||
11. COMPLETION CHECK (MANDATORY):
|
||||
@@ -125,11 +66,4 @@ 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.
|
||||
|
||||
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."""
|
||||
|
||||
- Skip questions for categories already covered. Ask only for what is genuinely missing."""
|
||||
|
||||
@@ -12,8 +12,8 @@ def get_safety_rules() -> str:
|
||||
- Severe trauma / major injury
|
||||
- Seizure / convulsion in progress
|
||||
|
||||
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."
|
||||
EMERGENCY RESPONSE: Direct to 108 ambulance immediately.
|
||||
Example: "This needs urgent medical attention. Please call 108 for an ambulance 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) 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."
|
||||
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."
|
||||
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 nurse", "call doctor", "emergency room", "call ambulance",
|
||||
"call 108", "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) or direct to hospital emergency staff before continuing intake.
|
||||
- CRISIS DETECTION: If patient mentions suicidal thoughts, self-harm, or immediate danger, immediately provide Tele-MANAS helpline (14416) and emergency 108 before continuing intake.
|
||||
- Use empathetic, warm language. Mental health intake requires extra sensitivity.""",
|
||||
|
||||
"cardiology": """
|
||||
|
||||
@@ -10,23 +10,22 @@ 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 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).
|
||||
Adheres to: ASK LESS -> LISTEN MORE -> EXTRACT MORE.
|
||||
Composed of language, clinical, conversation, extraction, specialty, and safety rule modules.
|
||||
"""
|
||||
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()
|
||||
@@ -34,15 +33,18 @@ 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
|
||||
|
||||
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}.
|
||||
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}).
|
||||
|
||||
{lang_rules}
|
||||
|
||||
@@ -58,84 +60,110 @@ Your job is to collect preliminary clinical intake information for the doctor fr
|
||||
|
||||
{priv_rules}
|
||||
|
||||
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:
|
||||
CURRENT INTAKE STATE (What has already been gathered):
|
||||
{json.dumps(current_state, indent=2, ensure_ascii=False)}
|
||||
|
||||
LATEST PATIENT MESSAGE:
|
||||
RECENT CONVERSATION HISTORY:
|
||||
{json.dumps(conversation_history[-8:], indent=2, ensure_ascii=False)}
|
||||
|
||||
PREVIOUS AI QUESTION:
|
||||
"{last_ai_message}"{intent_hint}
|
||||
|
||||
LATEST PATIENT INPUT:
|
||||
"{new_patient_message}"
|
||||
|
||||
TARGET MISSING QUESTION TO ASK:
|
||||
"{target_question}"
|
||||
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.
|
||||
|
||||
|
||||
OUTPUT FORMAT REQUIREMENTS:
|
||||
You MUST respond with valid JSON matching EXACTLY this structure:
|
||||
{{
|
||||
"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",
|
||||
"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",
|
||||
"state_updates": {{
|
||||
"reason_for_visit": "primary reason or null",
|
||||
"reason_for_visit": "primary reason if identified or null",
|
||||
"symptoms": [
|
||||
{{
|
||||
"name": "symptom name",
|
||||
"duration": "duration or null",
|
||||
"severity": "severity or null",
|
||||
"location": "location or null"
|
||||
"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"
|
||||
}}
|
||||
],
|
||||
"medical_history": [],
|
||||
"medications": [],
|
||||
"allergies": [],
|
||||
"surgical_history": [],
|
||||
"previous_episodes": [],
|
||||
"family_history": [],
|
||||
"additional_information": []
|
||||
"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"
|
||||
}}
|
||||
]
|
||||
}},
|
||||
"completed": false
|
||||
}}
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
"""
|
||||
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"]
|
||||
@@ -1,30 +0,0 @@
|
||||
"""
|
||||
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
|
||||
@@ -1,19 +0,0 @@
|
||||
"""
|
||||
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)
|
||||
@@ -1,19 +0,0 @@
|
||||
"""
|
||||
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)
|
||||
@@ -1,8 +0,0 @@
|
||||
"""
|
||||
Safety package.
|
||||
"""
|
||||
|
||||
from app.ai.safety.emergency import check_emergency
|
||||
from app.ai.safety.crisis import check_crisis
|
||||
|
||||
__all__ = ["check_emergency", "check_crisis"]
|
||||
@@ -1,69 +0,0 @@
|
||||
"""
|
||||
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."
|
||||
@@ -1,91 +0,0 @@
|
||||
"""
|
||||
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."
|
||||
@@ -1,25 +1,7 @@
|
||||
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:
|
||||
|
||||
@@ -32,114 +14,49 @@ class SarvamClient:
|
||||
def _get_client(self) -> httpx2.AsyncClient:
|
||||
if SarvamClient._client is None:
|
||||
SarvamClient._client = httpx2.AsyncClient(
|
||||
timeout=8.0,
|
||||
timeout=12.0,
|
||||
limits=httpx2.Limits(max_keepalive_connections=5, max_connections=10),
|
||||
)
|
||||
return SarvamClient._client
|
||||
|
||||
async def speech_to_text_with_status(
|
||||
self,
|
||||
audio_bytes: bytes,
|
||||
filename: str = "audio.webm",
|
||||
language_code: str = "ta-IN",
|
||||
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",
|
||||
files=files,
|
||||
data=data,
|
||||
headers=headers,
|
||||
)
|
||||
duration = round(time.time() - start_time, 3)
|
||||
|
||||
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
|
||||
if not self.api_key or self.api_key == "your_sarvam_key":
|
||||
return ""
|
||||
|
||||
async def text_to_speech_with_status(
|
||||
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}
|
||||
|
||||
try:
|
||||
res = await client.post(
|
||||
f"{self.base_url}/speech-to-text",
|
||||
files=files,
|
||||
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 ""
|
||||
|
||||
async def text_to_speech(
|
||||
self,
|
||||
text: str,
|
||||
language_code: str = "ta-IN",
|
||||
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
|
||||
pace: float = 1.0,
|
||||
) -> bytes | None:
|
||||
if not self.api_key or self.api_key == "your_sarvam_key":
|
||||
return None
|
||||
|
||||
if not self.api_key or self.api_key in ["your_sarvam_key", ""]:
|
||||
return None, TTS_PROVIDER_ERROR
|
||||
lang_prefix = language_code.lower()[:2]
|
||||
speaker = "kavya"
|
||||
|
||||
client = self._get_client()
|
||||
headers = {
|
||||
@@ -149,57 +66,21 @@ class SarvamClient:
|
||||
payload = {
|
||||
"inputs": [text],
|
||||
"target_language_code": language_code,
|
||||
"speaker": "kavya",
|
||||
"model": "bulbul:v2",
|
||||
"speaker": speaker,
|
||||
"model": "bulbul:v3",
|
||||
"pace": pace,
|
||||
}
|
||||
|
||||
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:
|
||||
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
|
||||
return base64.b64decode(audios[0])
|
||||
except Exception as e:
|
||||
print(f"[Sarvam TTS Error]: {e}")
|
||||
return None
|
||||
|
||||
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
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
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,10 +5,14 @@ import time
|
||||
from uuid import uuid4
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile, WebSocket
|
||||
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,
|
||||
@@ -476,3 +480,54 @@ 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)}")
|
||||
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
"""
|
||||
WebSocket API Package for Real-Time Patient Onboarding.
|
||||
"""
|
||||
@@ -1,73 +0,0 @@
|
||||
"""
|
||||
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()
|
||||
@@ -1,225 +0,0 @@
|
||||
"""
|
||||
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)})
|
||||
@@ -1,91 +0,0 @@
|
||||
"""
|
||||
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
|
||||
@@ -21,10 +21,6 @@ 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", "")
|
||||
@@ -37,13 +33,6 @@ 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",
|
||||
]
|
||||
|
||||
@@ -53,16 +42,3 @@ 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()
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
from dotenv import load_dotenv
|
||||
from sqlalchemy import create_engine, event
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import DeclarativeBase, sessionmaker
|
||||
|
||||
load_dotenv()
|
||||
@@ -24,21 +24,10 @@ else:
|
||||
engine = create_engine(
|
||||
DATABASE_URL,
|
||||
connect_args={
|
||||
"check_same_thread": False,
|
||||
"timeout": 15,
|
||||
"check_same_thread": False
|
||||
},
|
||||
)
|
||||
|
||||
@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,
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
"""
|
||||
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",
|
||||
]
|
||||
@@ -1,67 +0,0 @@
|
||||
"""
|
||||
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
|
||||
@@ -1,28 +0,0 @@
|
||||
"""
|
||||
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
|
||||
@@ -1,87 +0,0 @@
|
||||
"""
|
||||
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)
|
||||
@@ -6,7 +6,6 @@ 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
|
||||
|
||||
@@ -70,21 +69,23 @@ 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
|
||||
|
||||
@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)
|
||||
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("/")
|
||||
@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():
|
||||
@@ -99,4 +100,3 @@ async def health():
|
||||
"vision_documents": "Gemini 1.5 Flash",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -37,16 +37,7 @@ class StartOnboardingResponse(BaseModel):
|
||||
|
||||
|
||||
class SendMessageRequest(BaseModel):
|
||||
message: str = Field(..., min_length=1, max_length=5000)
|
||||
|
||||
|
||||
class ErrorDetail(BaseModel):
|
||||
code: str
|
||||
message: str
|
||||
|
||||
|
||||
class ErrorResponse(BaseModel):
|
||||
error: ErrorDetail
|
||||
message: str = Field(..., min_length=1)
|
||||
|
||||
|
||||
class SendMessageResponse(BaseModel):
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
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
|
||||
@@ -0,0 +1,46 @@
|
||||
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,19 +109,13 @@ 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
|
||||
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
|
||||
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"}
|
||||
)
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
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,26 +57,23 @@ class VoiceService:
|
||||
lang_prefix = language_code.lower()[:2]
|
||||
raw_transcript = ""
|
||||
|
||||
# 1. Primary: Sarvam AI Saaras v3 for Native Indian Languages (Ultra-fast Indic STT)
|
||||
# 1. Primary: Sarvam AI Saaras v3 for Indic Code-Switching (Tanglish, Hinglish, Telglish, Kanglish)
|
||||
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:
|
||||
async with httpx2.AsyncClient(timeout=5.0) as client:
|
||||
files = {
|
||||
"file": (filename, audio_bytes, "audio/webm")
|
||||
}
|
||||
# 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")}
|
||||
data = {
|
||||
"model": "saaras:v3",
|
||||
"language_code": language_code,
|
||||
"language_code": target_lang,
|
||||
"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,
|
||||
@@ -91,7 +88,23 @@ class VoiceService:
|
||||
except Exception as e:
|
||||
print(f"[Sarvam STT Error]: {e}")
|
||||
|
||||
# Apply Medical Speech Normalizer to eliminate phonetic mishearings
|
||||
# 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
|
||||
normalized = normalize_medical_speech(raw_transcript, language_code=language_code)
|
||||
return normalized
|
||||
|
||||
@@ -100,11 +113,23 @@ 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("(", "")
|
||||
@@ -134,6 +159,7 @@ 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",
|
||||
|
||||
@@ -1,188 +0,0 @@
|
||||
"""
|
||||
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()
|
||||
)
|
||||
@@ -0,0 +1,472 @@
|
||||
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",
|
||||
"hospital emergency room",
|
||||
"alert duty doctor",
|
||||
"call 108",
|
||||
"emergency ambulance",
|
||||
],
|
||||
|
||||
"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 informing hospital emergency staff or visiting the hospital emergency room.",
|
||||
"If severe red-flag symptoms are present, immediately advise contacting 108 ambulance or visiting the nearest hospital emergency room.",
|
||||
],
|
||||
}
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
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",
|
||||
"hospital emergency staff / ER",
|
||||
"108 ambulance",
|
||||
],
|
||||
|
||||
"clarification_rules": [
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
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",
|
||||
"hospital emergency staff / ER",
|
||||
"108 ambulance",
|
||||
],
|
||||
|
||||
"clarification_rules": [
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
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.",
|
||||
],
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
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.",
|
||||
],
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
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,53 +1,18 @@
|
||||
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(
|
||||
canonical_code,
|
||||
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
@@ -0,0 +1,45 @@
|
||||
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
|
||||
@@ -1,95 +0,0 @@
|
||||
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())
|
||||
@@ -1,37 +0,0 @@
|
||||
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()
|
||||
@@ -1,135 +0,0 @@
|
||||
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())
|
||||
@@ -1,35 +0,0 @@
|
||||
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()
|
||||
@@ -1,16 +0,0 @@
|
||||
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"])
|
||||
@@ -1,60 +0,0 @@
|
||||
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())
|
||||
@@ -1,87 +0,0 @@
|
||||
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 **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.
|
||||
- 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.
|
||||
- The AI **never diagnoses** (e.g., never says "You have a heart attack").
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
# 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 };
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,870 @@
|
||||
<!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>
|
||||
@@ -1,22 +0,0 @@
|
||||
# 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.
|
||||
@@ -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-genai
|
||||
google-generativeai==0.8.6
|
||||
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 ("மருத்துவமனை" in res["reply"] or "அவசர" in res["reply"])
|
||||
assert "108" in res["reply"]
|
||||
assert res.get("completed") is True
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
"""
|
||||
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 hospital emergency room/staff advice and stops intake."""
|
||||
"""Emergency statement triggers 108 ambulance 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 "emergency" in res["reply"].lower() or "hospital" in res["reply"].lower()
|
||||
assert "108" in res["reply"]
|
||||
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"] in ("mental_health", "psychiatry")
|
||||
assert classify_specialty_from_symptoms("I have severe depression and insomnia")["code"] == "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,32 +975,3 @@ 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()
|
||||
|
||||
|
||||
|
||||
@@ -1,294 +0,0 @@
|
||||
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
|
||||
@@ -1,236 +0,0 @@
|
||||
"""
|
||||
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
|
||||
@@ -1,83 +0,0 @@
|
||||
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"
|
||||
@@ -1,250 +0,0 @@
|
||||
"""
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user