From dc2799a95be893c7c71c49ad3784b534a7fff7ae Mon Sep 17 00:00:00 2001 From: Gokulvarthan Ramesh Date: Sat, 29 Aug 2026 15:09:31 +0530 Subject: [PATCH] feat: implement voice-based patient onboarding pipeline with AI orchestration, multi-language STT/TTS support, and clinical state extraction. --- ai-patient-onboarding/app/ai/gemini_client.py | 18 + ai-patient-onboarding/app/ai/orchestrator.py | 341 +++++++++++++++--- ai-patient-onboarding/app/ai/prompts.py | 153 ++++++-- ai-patient-onboarding/app/ai/sarvam_client.py | 2 +- ai-patient-onboarding/app/api/onboarding.py | 10 +- .../app/api/voice_onboarding.py | 242 +++++++------ ai-patient-onboarding/app/schemas/voice.py | 2 + .../app/services/medical_speech_normalizer.py | 5 + .../app/services/voice_service.py | 21 +- .../full_conversation_test_result.json | 39 ++ .../test_5_stage_dialogue.py | 32 ++ ai-patient-onboarding/test_auto_specialty.py | 30 ++ .../test_direct_classifier.py | 16 + ai-patient-onboarding/test_result.json | 13 + 14 files changed, 733 insertions(+), 191 deletions(-) create mode 100644 ai-patient-onboarding/full_conversation_test_result.json create mode 100644 ai-patient-onboarding/test_5_stage_dialogue.py create mode 100644 ai-patient-onboarding/test_auto_specialty.py create mode 100644 ai-patient-onboarding/test_direct_classifier.py create mode 100644 ai-patient-onboarding/test_result.json diff --git a/ai-patient-onboarding/app/ai/gemini_client.py b/ai-patient-onboarding/app/ai/gemini_client.py index 044cb80..49a156a 100644 --- a/ai-patient-onboarding/app/ai/gemini_client.py +++ b/ai-patient-onboarding/app/ai/gemini_client.py @@ -71,3 +71,21 @@ Return ONLY JSON: "allergies_found": [], "summary": f"Could not extract document details: {e}", } + + async def generate_response( + self, + system_prompt: str, + user_message: str, + ) -> str: + if not self.api_key or self.api_key in ["your_gemini_key", "your_gemini_api_key", ""]: + raise ValueError("Gemini API key not configured") + + import google.generativeai as genai + genai.configure(api_key=self.api_key) + model = genai.GenerativeModel( + model_name=self.model or "gemini-1.5-flash", + generation_config={"response_mime_type": "application/json"}, + ) + full_prompt = f"{system_prompt}\n\nPATIENT SAID: {user_message}" + response = await model.generate_content_async(full_prompt) + return response.text diff --git a/ai-patient-onboarding/app/ai/orchestrator.py b/ai-patient-onboarding/app/ai/orchestrator.py index 488a4cc..af4accc 100644 --- a/ai-patient-onboarding/app/ai/orchestrator.py +++ b/ai-patient-onboarding/app/ai/orchestrator.py @@ -1,3 +1,4 @@ +from app.ai.gemini_client import GeminiClient import asyncio import json @@ -8,10 +9,129 @@ from app.config import OPENAI_API_KEY, GEMINI_API_KEY, GEMINI_MODEL, ANTHROPIC_A from app.specialties.registry import get_specialty_config +SPECIALTY_RULES = [ + { + "code": "psychiatry", + "name": "Psychiatry & Mental Health Care", + "keywords": [ + "depression", "depressed", "mental", "anxiety", "panic", "stress", "sadness", "suicidal", "suicide", "sleep", "insomnia", "emotional", "mood", "lonely", "hopeless", + "மன அழுத்தம்", "மனச்சோர்வு", "தூக்கமின்மை", "கவலை", "உணர்ச்சி", "தற்கொலை", "டிப்ரெஷன்", + "डिप्रेशन", "तनाव", "चिंता", "उदासी", "नींद न आना", "आत्महत्या", + "మానసిక ఒత్తిడి", "డిప్రెషన్", "నిద్రలేమి", + "ಮನೋಒತ್ತಡ", "ಖಿನ್ನತೆ", "ನಿದ್ರಾಹೀನತೆ" + ] + }, + { + "code": "cardiology", + "name": "Cardiology (Heart Care)", + "keywords": [ + "chest pain", "heart", "palpitation", "breathless", "cardiac", "chest pressure", + "நெஞ்சு வலி", "இருதய வலி", "மூச்சு திணறல்", "छाती में दर्द", "दिल की धड़कन", "గుండె నొప్పి" + ] + }, + { + "code": "dermatology", + "name": "Dermatology (Skin & Hair Care)", + "keywords": [ + "skin", "rash", "itching", "eczema", "acne", "pimples", "fungal", "allergy", + "தோல் அரிப்பு", "தழும்பு", "படை", "கொப்புளம்", "त्वचा में खुजली", "दाद", "पिंपल्स" + ] + }, + { + "code": "neurology", + "name": "Neurology (Brain & Nervous System)", + "keywords": [ + "headache", "dizziness", "seizure", "stroke", "numbness", "paralysis", "migraine", + "தலைவலி", "மயக்கம்", "கை கால் மரத்துப்போதல்", "सिरदर्द", "चक्कर आना", "दौरा" + ] + }, + { + "code": "orthopedics", + "name": "Orthopedics (Bone & Joint Care)", + "keywords": [ + "knee pain", "joint pain", "fracture", "bone", "back pain", "arthritis", "ligament", + "மூட்டு வலி", "இடுப்பு வலி", "எலும்பு முறிவு", "जोड़ों का दर्द", "घुटने में दर्द", "पीठ दर्द" + ] + }, + { + "code": "gastroenterology", + "name": "Gastroenterology (Digestive Health)", + "keywords": [ + "stomach pain", "stomach ache", "acidity", "vomiting", "diarrhea", "indigestion", "gastric", + "வயிற்று வலி", "வாந்தி", "பேதி", "நெஞ்செரிச்சல்", "पेट दर्द", "उल्टी", "दस्त" + ] + }, + { + "code": "pulmonology", + "name": "Pulmonology (Respiratory Care)", + "keywords": [ + "cough", "asthma", "wheezing", "phlegm", "cold", "flu", "respiratory", + "இருமல்", "சளி", "ஆஸ்துமா", "மூச்சு வாங்குதல்", "खांसी", "बलगम", "दमा" + ] + }, + { + "code": "ent", + "name": "ENT (Ear, Nose & Throat)", + "keywords": [ + "ear pain", "throat pain", "sore throat", "tonsils", "nasal block", "hearing", + "காது வலி", "தொண்டை வலி", "மூக்கடைப்பு", "कान का दर्द", "गले में खराश" + ] + }, + { + "code": "ophthalmology", + "name": "Ophthalmology (Eye Care)", + "keywords": [ + "eye pain", "blurred vision", "red eye", "cataract", "glaucoma", + "கண் வலி", "பார்வை மங்கல்", "சிவப்பு கண்", "आँख में दर्द", "धुंधला दिखना" + ] + }, + { + "code": "pediatrics", + "name": "Pediatrics (Child Health)", + "keywords": [ + "baby", "child", "infant", "toddler", "pediatric", + "குழந்தைக்கு", "குழந்தை", "बच्चे को", "शिशु" + ] + } +] + + +def classify_specialty_from_symptoms(text: str) -> dict: + if not text: + return { + "type": "general", + "code": "general_medicine", + "name": "General Medicine / Primary Care", + "confidence": 0.85, + "reason": "Default primary care specialty assignment." + } + + text_lower = text.lower() + for rule in SPECIALTY_RULES: + for kw in rule["keywords"]: + if kw in text_lower: + return { + "type": "speciality", + "code": rule["code"], + "name": rule["name"], + "confidence": 0.95, + "reason": f"Automatically classified based on clinical keyword: '{kw}'" + } + + return { + "type": "general", + "code": "general_medicine", + "name": "General Medicine / Primary Care", + "confidence": 0.85, + "reason": "General primary care consultation." + } + + class AIOrchestrator: def __init__(self): self.openai = OpenAIClient() + self.gemini = GeminiClient() async def process( self, @@ -28,6 +148,9 @@ class AIOrchestrator: state.state_json ) + # Dynamic Specialty Triage Classification + suggested_specialty = classify_specialty_from_symptoms(user_message) + context = { "patient": { "id": session.patient_id, @@ -50,79 +173,193 @@ class AIOrchestrator: prompt = build_system_prompt(context) - # Skip invalid/exhausted API keys to prevent 20+ second HTTP timeouts - # 1. Fallback simulator (0.001s instant response time) - parsed = self._simulate_specialty_response(session, specialty, user_message, len(conversation_history)) + # Try real LLM dynamic generation (Gemini -> OpenAI -> Local Fallback) + parsed = None + + # 1. Try Gemini LLM Generation first + if self.gemini.api_key and self.gemini.api_key.strip() not in ["your_gemini_key", "your_gemini_api_key", ""]: + try: + raw_res = await self.gemini.generate_response(prompt, user_message) + if raw_res: + cleaned_res = raw_res.replace("```json", "").replace("```", "").strip() + parsed = json.loads(cleaned_res) + print(f"[Gemini Dynamic Response SUCCESS]: {parsed.get('reply')}") + except Exception as e: + print(f"[Gemini Dynamic Intake Warning]: {e}") + + # 2. Try OpenAI LLM Generation if Gemini was not available or failed + if not parsed and self.openai.api_key and self.openai.api_key.strip() not in ["your_openai_api_key", "your_openai_key", ""]: + try: + raw_res = await self.openai.generate_response(prompt, user_message) + if raw_res: + parsed = json.loads(raw_res) + print(f"[OpenAI Dynamic Response SUCCESS]: {parsed.get('reply')}") + except Exception as e: + print(f"[OpenAI Dynamic Intake Warning]: {e}") + + # 3. Fallback to intelligent symptom-tailored intake engine + if not parsed: + parsed = self._simulate_specialty_response(session, specialty, user_message, len(conversation_history), suggested_specialty) + + parsed["suggested_specialty"] = suggested_specialty validated = validate_ai_response(parsed) - return validated.model_dump() + res_dict = validated.model_dump() + res_dict["suggested_specialty"] = suggested_specialty + return res_dict - def _simulate_specialty_response(self, session, specialty, user_message, turn_count=0): + def _simulate_specialty_response(self, session, specialty, user_message, turn_count=0, suggested_specialty=None): stage = session.stage - code = specialty.get("code", "general") lang = (session.language or "ta-IN").lower() - msg_lower = user_message.lower().strip() - is_negative = msg_lower in ["no", "no more", "nothing", "nothing else", "know nothing", "none", "illness none", "இல்லை", "ஒன்றுமில்லை", "नहीं"] - - # Localized clinical phrases (Spoken & Caring Tone) - if lang.startswith("ta"): - conclusion_text = "மிக்க நன்றி. உங்கள் உடல்நல விவரங்கள் சிறப்பாக பதிவு செய்யப்பட்டுள்ளன. மருத்துவர் விரைவில் உங்களைப் பரிசீலிப்பார்." - symptom_q = "இந்த அறிகுறிகள் எத்தனை நாளா இருக்கு? இதுக்காக ஏதாச்சும் மாத்திரை சாப்பிட்டீங்களா?" - med_q = "சரிங்க. உங்களுக்கு வேற ஏதேனும் உடம்பு பிரச்சனைகள் அல்லது மாத்திரை ஒவ்வாமை (Allergy) இருக்கா?" - elif lang.startswith("hi"): - conclusion_text = "धन्यवाद। आपकी स्वास्थ्य जानकारी दर्ज कर ली गई है। डॉक्टर जल्द ही समीक्षा करेंगे।" - symptom_q = "आपको यह समस्या कितने दिनों से है और क्या आपने कोई दवाई ली है?" - med_q = "धन्यवाद। क्या आपको किसी दवाई से एलर्जी या कोई अन्य बीमारी है?" - elif lang.startswith("te"): - conclusion_text = "ధన్యవాదాలు. మీ ఆరోగ్య వివరాలు నమోదు చేయబడ్డాయి." - symptom_q = "ఈ సమస్య మీకు ఎన్ని రోజుల నుండి ఉంది మరియు ఏవైనా మందులు వాడారా?" - med_q = "ధన్యవాదాలు. మీకు ఏవైనా మందుల పడనితనం (Allergy) ఉందా?" - elif lang.startswith("kn"): - conclusion_text = "ಧನ್ಯವಾದಗಳು. ನಿಮ್ಮ ಆರೋಗ್ಯದ ಮಾಹಿತಿ ದಾಖಲಾಗಿದೆ." - symptom_q = "ಈ ಸಮಸ್ಯೆ ನಿಮಗೆ ಎಷ್ಟು ದಿನಗಳಿಂದ ಇದೆ ಮತ್ತು ಯಾವುದೇ ಮಾತ್ರೆಗಳನ್ನು ತೆಗೆದುಕೊಂಡಿದ್ದೀರಾ?" - med_q = "ಧನ್ಯವಾದಗಳು. ನಿಮಗೆ ಯಾವುದೇ ಔಷಧಿ ಅಲರ್ಜಿ ಇದೆಯೇ?" + msg_lower = (user_message or "").lower().strip() + + # Detect Mental Health / Depression / Psychiatry Complaints + is_mental_health = False + if suggested_specialty and suggested_specialty.get("code") == "psychiatry": + is_mental_health = True + elif session.category_code == "psychiatry": + is_mental_health = True else: - conclusion_text = "Thank you. I have recorded your intake details for the doctor to review." - symptom_q = "How long have you had these symptoms, and have you taken any medicines?" - med_q = "Thank you. Do you have any known drug allergies or existing health conditions?" + mental_kws = [ + "depress", "mental", "anxiet", "panic", "stress", "sadness", "suicid", "sleep", "insomn", "mood", "hopeless", "lonely", + "மன அழுத்தம்", "மனச்சோர்வு", "தூக்கம்", "கவலை", "உணர்ச்சி", "டிப்ரெஷன்", "டிப்ரஷன்", + "डिप्रेशन", "तनाव", "चिंता", "उदासी", "नींद" + ] + is_mental_health = any(k in msg_lower for k in mental_kws) - # Fast conclusion if patient says "No" or already had 6 turns - if is_negative or turn_count >= 6: + # Check crisis/suicidal intent + is_crisis = any(k in msg_lower for k in ["suicid", "end my life", "kill myself", "தற்கொலை", "आत्महत्या"]) + + negative_keywords = [ + "no", "no more", "nothing", "nothing else", "none", "no illness", "no problem", + "இல்லை", "இல்லைங்க", "இல்ல", "ஒன்றுமில்லை", "ஒன்னுமில்ல", "ஏதுமில்லை", + "नहीं", "कुछ नहीं", "कोई नहीं", "नाही", + "లేదు", "ఏమీ లేదు", "లేదండి", + "ಇಲ್ಲ", "ಇಲ್ಲವೇ ಇಲ್ಲ", "ಏನೂ ಇಲ್ಲ", + "ഇല്ല", "ഒന്നുമില്ല", "ഇല്ല കട്ടായം", + "না", "কিছু না", "নাই", + "नाही", "काही नाही", + "નથી", "કાંઈ નથી", + "ਨਹੀਂ", "ਕੁਝ ਨਹੀਂ" + ] + is_negative = any(k in msg_lower for k in negative_keywords) + + # Empathetic Mental Health Specific Questions + if is_mental_health: + if lang.startswith("ta"): + conclusion_text = "சரிங்க, எல்லா விவரத்தையும் மனநல மருத்துவருகிட்ட (Psychiatrist) குடுத்துடுறேன். ரொம்ப நன்றிங்க!" + q_onset_severity = "புரியுதுங்க. மன அழுத்தம் மற்றும் மனச்சோர்வை பற்றிப் பேசுவது ரொம்ப முக்கியமானது. இந்த கவலை எப்போலிருந்து இருக்குன்னு சொல்ல முடியுமா?" + q_associated_symptoms = "சரிங்க. இதனால உங்களுக்கு தூக்கம் இல்ல அன்றாட வேலைகள்ல ஏதாவது சிரமம் இருக்கா?" + q_past_history = "புரியுதுங்க. இதுக்காக ஏற்கனவே ஏதாவது கவுன்சிலிங் இல்ல மாத்திரை எடுக்குறீங்களா?" + q_meds_allergies = "சரிங்க, உங்களுக்கு ஏதேனும் மாத்திரை அலர்ஜி இருக்கா?" + elif lang.startswith("hi"): + conclusion_text = "ठीक है जी, मैं यह सारी जानकारी मनोचिकित्सक (Psychiatrist) डॉक्टर साहब को दे देती हूँ। शुक्रिया!" + q_onset_severity = "जी समझ गया। मानसिक तनाव और डिप्रेशन के बारे में बात करना बहुत जरूरी है। यह परेशानी कब से महसूस हो रही है?" + q_associated_symptoms = "जी ठीक है, क्या इसकी वजह से आपको नींद या रोज़मर्रा के कामों में कोई परेशानी हो रही है?" + q_past_history = "ठीक है जी, क्या आप इसके लिए कोई काउंसलिंग या मेडिसिन ले रहे हैं?" + q_meds_allergies = "अच्छा जी, आपको किसी दवाई से एलर्जी तो नहीं है?" + else: + conclusion_text = "Got it. I have compiled all these details for the specialist psychiatrist to review. Thank you!" + q_onset_severity = "I hear you, and it takes courage to talk about how you're feeling. How long have you been experiencing this stress or depression?" + q_associated_symptoms = "Understood. Is this affecting your sleep, appetite, or daily activities?" + q_past_history = "Got it. Are you currently seeing a therapist, receiving counseling, or taking any medications?" + q_meds_allergies = "Understood. Do you have any known medication or drug allergies?" + + if is_crisis: + if lang.startswith("ta"): + conclusion_text = "உடனடி உதவிக்கு மனநல உதவி எண் Tele-MANAS (14416) அல்லது 108 ஐ தொடர்பு கொள்ளவும். உங்கள் விவரங்களை மருத்துவரிடம் பகிர்ந்துள்ளேன்." + elif lang.startswith("hi"): + conclusion_text = "अगर आपको मदद की ज़रूरत है तो तुरंत Tele-MANAS (14416) या 108 पर संपर्क करें। मैंने डॉक्टर साहब को सूचित कर दिया है।" + else: + conclusion_text = "If you are in distress, please reach out to Tele-MANAS (14416) or emergency 108 immediately. I have shared your details with our doctor." + + else: + # Physical General Medical Intake Prompts + if lang.startswith("ta"): + conclusion_text = "சரிங்க, எல்லா விவரத்தையும் டாக்டர்கிட்ட குடுத்துடுறேன். ரொம்ப நன்றிங்க!" + q_onset_severity = "சரிங்க, இந்த பிரச்சனை எத்தனை நாளா இருக்குன்னு சொல்ல முடியுமா?" + q_associated_symptoms = "புரியுதுங்க. உங்களுக்கு வேறு ஏதேனும் உடம்பு பிரச்சனை இருக்கா?" + q_past_history = "சரிங்க. உங்களுக்கு பிபி, சுகர், ஆஸ்துமா போன்ற வேறு ஏதேனும் உடம்பு பிரச்சனை இருக்கா?" + q_meds_allergies = "சரிங்க, இதுக்காக ஏதாவது மாத்திரை இல்ல மெடிசின் சாப்பிட்டீங்களா? உங்களுக்கு ஏதேனும் மாத்திரை அலர்ஜி இருக்கா?" + elif lang.startswith("hi"): + conclusion_text = "ठीक है जी, मैं यह सारी जानकारी डॉक्टर साहब को दे देती हूँ। शुक्रिया!" + q_onset_severity = "समझ गया जी, यह तकलीफ कितने दिनों से हो रही है?" + q_associated_symptoms = "ठीक है जी, क्या कोई और परेशानी भी महसूस हो रही है?" + q_past_history = "जी ठीक है, क्या आपको बीपी, शुगर, या अस्थामा जैसी कोई पुरानी बीमारी है?" + q_meds_allergies = "समझ गया, इसके लिए कोई दवाई ली है आपने? किसी दवा से एलर्जी तो नहीं है?" + elif lang.startswith("te"): + conclusion_text = "చాలా ధన్యవాదాలు! మీ వివరాలన్నీ డాక్టర్ గారికి అందిస్తాను." + q_onset_severity = "అర్థమైందండి, ఈ ఇబ్బంది ఎన్ని రోజుల నుండి ఉంది?" + q_associated_symptoms = "సరేనండి, వేరే ఏమైనా ఇబ్బంది ఉందా?" + q_past_history = "అర్థమైంది, మీకు బీపీ, షుగర్ లేదా ఆస్తమా వంటి పాత ఆరోగ్య సమస్యలు ఉన్నాయా?" + q_meds_allergies = "సరేనండి, దీనికి ఏమైనా మందులు వాడారా? ఏమైనా మందుల అలర్జీ ఉందా?" + elif lang.startswith("kn"): + conclusion_text = "ಸರಿ, ಈ ಎಲ್ಲಾ ವಿವರಗಳನ್ನು ಡಾಕ್ಟರ್‌ಗೆ ತಿಳಿಸ್ತೀನಿ. ಧನ್ಯವಾದಗಳು!" + q_onset_severity = "ಅರ್ಥವಾಯಿತು, ಈ ತೊಂದರೆ ಎಷ್ಟು ದಿನದಿಂದ ಇದೆ?" + q_associated_symptoms = "ಸರಿ, ಬೇರೆ ಏನಾದರೂ ತೊಂದರೆ ಇದೆಯಾ?" + q_past_history = "ತಿಳಿಯಿತು, ನಿಮಗೆ ಬಿಪಿ, ಶುಗರ್ ಅಥವಾ ಅಸ್ತಮಾದಂತಹ ಹಳೆಯ ತೊಂದರೆ ಇದೆಯಾ?" + q_meds_allergies = "ಸರಿ, ಇದಕ್ಕೆ ಏನಾದರೂ ಮಾತ್ರೆ ತಗೊಂಡಿದ್ದೀರಾ? ಏನಾದರೂ ಮಾತ್ರೆ ಅಲರ್ಜಿ ಇದೆಯಾ?" + elif lang.startswith("ml"): + conclusion_text = "ശരി, എല്ലാ വിവരങ്ങളും ഡോക്ടർക്ക് കൈമാറാം. വളരെ നന്ദി!" + q_onset_severity = "ശരി, ഈ ബുദ്ധിമുട്ട് എത്ര ദിവസമായിട്ടുണ്ട്?" + q_associated_symptoms = "മനസ്സിലായി, മറ്റ് ബുദ്ധിമുട്ടുകൾ ഉണ്ടോ?" + q_past_history = "ശരി, പ്രഷർ (BP), ഷുഗർ, അല്ലെങ്കിൽ ആസ്ത്മ തുടങ്ങിയ രോഗങ്ങൾ മുൻപുണ്ടോ?" + q_meds_allergies = "മനസ്സിലായി, ഇതിനായി മരുന്നുകൾ കഴിക്കുന്നുണ്ടോ? മരുന്ന് അലർജിയുണ്ടോ?" + else: + conclusion_text = "Got it. I have shared all these details directly with the doctor. Thank you!" + q_onset_severity = "Got it. How long has this issue been bothering you?" + q_associated_symptoms = "Understood. Are you experiencing any other symptoms?" + q_past_history = "Got it. Do you have a history of high blood pressure, diabetes, or asthma?" + q_meds_allergies = "Understood. Have you taken any medication for this yet, and do you have any allergies?" + + # Fast conclusion if patient says "No" / "Nothing" at any stage after initial chief complaint + if is_negative and stage != "greeting": return { "reply": conclusion_text, "next_stage": "confirmation", "state_updates": { "reason_for_visit": None, "symptoms": [], - "medical_history": ["None reported"] if is_negative else [], + "medical_history": ["None reported"], "medications": [], - "allergies": ["None"] if is_negative else [] + "allergies": ["None"] }, "completed": True } if stage == "greeting": return { - "reply": symptom_q, + "reply": q_onset_severity, "next_stage": "reason_for_visit", "state_updates": { "reason_for_visit": user_message, - "symptoms": [{"name": user_message, "duration": "recent"}], - "medical_history": [], - "medications": [], - "allergies": [] + "symptoms": [{"name": user_message, "status": "reported"}], }, "completed": False } elif stage == "reason_for_visit": return { - "reply": med_q, + "reply": q_associated_symptoms, + "next_stage": "symptom_collection", + "state_updates": { + "symptoms": [{"onset_and_severity": user_message}], + }, + "completed": False + } + elif stage == "symptom_collection": + return { + "reply": q_past_history, + "next_stage": "symptom_details", + "state_updates": { + "symptoms": [{"associated_findings": user_message}], + }, + "completed": False + } + elif stage == "symptom_details": + return { + "reply": q_meds_allergies, "next_stage": "medications", "state_updates": { - "reason_for_visit": user_message, - "symptoms": [{"name": user_message, "duration": "reported"}], - "medical_history": [], - "medications": [], - "allergies": [] + "medical_history": [user_message] if not is_negative else ["None"], }, "completed": False } @@ -131,25 +368,15 @@ class AIOrchestrator: "reply": conclusion_text, "next_stage": "confirmation", "state_updates": { - "reason_for_visit": None, - "symptoms": [], - "medical_history": [], "medications": [user_message] if not is_negative else [], - "allergies": [] + "allergies": [user_message] if not is_negative else ["NKDA"], }, - "completed": False + "completed": True } else: return { "reply": conclusion_text, "next_stage": "confirmation", - "state_updates": { - "reason_for_visit": None, - "symptoms": [], - "medical_history": [], - "medications": [], - "allergies": ["None"] if is_negative else [user_message] - }, + "state_updates": {}, "completed": True } - diff --git a/ai-patient-onboarding/app/ai/prompts.py b/ai-patient-onboarding/app/ai/prompts.py index 10e95cb..9755c7b 100644 --- a/ai-patient-onboarding/app/ai/prompts.py +++ b/ai-patient-onboarding/app/ai/prompts.py @@ -13,47 +13,107 @@ def build_system_prompt(context: dict) -> str: lang_instructions = { "ta-IN": ( - "LANGUAGE & ACCENT: Professional, Respectful, and Articulate Tamil (தமிழ்).\n" - "- Speak with the professional, calm, and reassuring demeanor of a senior hospital clinical coordinator.\n" - "- Use polite, respectful Tamil words (e.g. 'வணக்கம்', 'கூற முடியுமா?', 'புரிகிறது', 'நன்றி').\n" - "- Keep sentence structure clear, elegant, and under 14 words." + "LANGUAGE & ACCENT: 100% Comprehensive Spoken Tamil Rulebook (இயல்பான பேச்சுத் தமிழ்).\n" + "- YOU MUST SPEAK ONLY IN NATURAL SPOKEN TAMIL (பேச்சுத் தமிழ்). STRICTLY BAN SENTHAMIZH (செந்தமிழ்).\n" + "- ALWAYS WRITE SPOKEN PRONUNCIATIONS IN TAMIL SCRIPT EXACTLY AS PEOPLE SPEAK IN TAMIL NADU IN EVERYDAY CLINIC CONVERSATION.\n" + "- EXHAUSTIVE FORMAL-TO-SPOKEN TAMIL DICTIONARY (ALWAYS USE THE RIGHT COLUMN):\n" + " * மருத்துவமனை -> கிளினிக் / ஹாஸ்பிடல்\n" + " * மருத்துவர் -> டாக்டர் / டாக்டர் சார்\n" + " * அறிகுறிகள் / நோய் -> உடம்பு முடியலை / பிரச்சனை / உடம்புக்கு என்ன பண்ணுது\n" + " * கூறுக / கூறுங்கள் / கூற முடியுமா -> சொல்லுங்க / சொல்ல முடியுமா\n" + " * வரவேற்கிறோம் -> (Do not use! Say: 'நான் நிலா பேசுறேன்')\n" + " * செய்கிறது / செய்கிறீர்கள் -> பண்ணுது / பண்றீங்க\n" + " * இருக்கிறது / இருக்கின்றன -> இருக்கு\n" + " * எப்போது / எக்காலம் -> எப்போ / எப்போலிருந்து\n" + " * எத்தனை நாட்கள் -> எத்தனை நாளா / எத்தனை நாளா இருக்கு\n" + " * மருந்துகள் / ஔடதம் -> மாத்திரை / மெடிசின் / சிரப்\n" + " * உட்கொண்டீர்களா / சாப்பிட்டீர்களா -> சாப்பிட்டீங்களா / எடுக்குறீங்களா\n" + " * புரிந்தது / அறிந்துகொண்டேன் -> புரியுதுங்க / சரிங்க / ஓகேங்க\n" + " * நன்றி -> ரொம்ப நன்றிங்க\n" + " * சமர்ப்பிப்பேன் / வழங்குவேன் -> டாக்டர்கிட்ட குடுத்துடுறேன் / சொல்லிடுறேன்\n" + " * ஒவ்வாமை -> அலர்ஜி / அலர்ஜி எதுவும் இருக்கா\n" + "- SPOKEN DIALOGUE TEMPLATES FOR CLINICAL INTAKE:\n" + " * Chief Complaint: 'வணக்கம் {name}, நான் உங்க AI அசிஸ்டன்ட் நிலா. உங்களுக்கு உடம்புக்கு என்ன பண்ணுது, என்ன பிரச்சனைன்னு சொல்லுங்க?'\n" + " * Duration: 'சரிங்க, இந்த பிரச்சனை எத்தனை நாளா இருக்குன்னு சொல்ல முடியுமா?'\n" + " * Severity: 'புரியுதுங்க, இந்த வலி ரொம்ப அதிகமா இருக்கா, இல்ல லேசாக இருக்கா?'\n" + " * Medications: 'சரிங்க, இதுக்காக ஏதாவது மாத்திரை இல்ல மெடிசின் சாப்பிட்டீங்களா?'\n" + " * Allergies: 'ஓகேங்க, உங்களுக்கு ஏதேனும் மாத்திரை அலர்ஜி இருக்கா?'\n" + " * Completion: 'சரிங்க, எல்லா விவரத்தையும் டாக்டர்கிட்ட குடுத்துடுறேன். ரொம்ப நன்றிங்க!'\n" + "- NEVER EVER USE THESE BOOKISH WORDS: 'மருத்துவமனை', 'அறிகுறிகள்', 'கூறுங்கள்', 'கூற முடியுமா', 'வரவேற்கிறோம்', 'மருத்துவர்', 'செய்கிறது', 'இருக்கிறது', 'ஔடதம்'.\n" + "- Keep sentence structure warm, polite, short, and under 14 words." ), "hi-IN": ( - "LANGUAGE & ACCENT: Professional, Respectful, and Clear Hindi (हिन्दी).\n" - "- Speak with the dignified and polite tone of an experienced hospital triage officer.\n" - "- Use respectful language with 'आप' and clear clinical clarity.\n" - "- Keep sentence structure polite, concise, and under 14 words." - ), - "en-IN": ( - "LANGUAGE & ACCENT: Polished, Professional Indian Healthcare English.\n" - "- Speak with a calm, articulate, and reassuring clinical tone.\n" - "- Use clear, professional phrasing (e.g. 'Thank you. I have recorded your symptoms for the doctor.').\n" - "- Keep each response concise, respectful, and under 14 words." + "LANGUAGE & ACCENT: 100% Natural Spoken Conversational Hindi (आम बोलचाल की हिन्दी).\n" + "- Speak naturally like a polite, reassuring clinic receptionist in everyday spoken Hindi.\n" + "- STRICTLY AVOID bookish/formal Hindi (bookish Shuddh Hindi). Use natural spoken phrasing.\n" + "- MANDATORY SPOKEN WORDS TO USE:\n" + " * Acknowledgments: 'जी', 'अच्छा जी', 'समझ आ गया', 'ठीक है'\n" + " * Asking Symptoms: 'क्या तकलीफ हो रही है?', 'क्या परेशानी है आपको?'\n" + " * Asking Duration: 'यह परेशानी कितने दिनों से है?', 'कब से दर्द है?'\n" + " * Asking Medicines: 'इसके लिए कोई दवाई ली है क्या?', 'कोई मेडिसिन ले रहे हैं?'\n" + " * Asking Allergies: 'आपको किसी दवाई से एलर्जी तो नहीं है?'\n" + " * Confirmation: 'ठीक है जी, मैं यह सारी जानकारी डॉक्टर साहब को दे देती हूँ।'\n" + "- FORBIDDEN BOOKISH WORDS (DO NOT USE): 'अस्पताल में आपका स्वागत है', 'लक्षणों का विवरण', 'चिकित्सक', 'अवगत कराएं'.\n" + "- Keep sentence structure polite, warm, concise, and under 14 words." ), "te-IN": ( - "LANGUAGE & ACCENT: Professional and Respectful Telugu (తెలుగు).\n" - "- Speak with dignified, clear hospital intake phrasing (under 14 words)." + "LANGUAGE & ACCENT: 100% Natural Spoken Conversational Telugu (రోజువారీ వాడుక తెలుగు).\n" + "- Speak naturally like a polite Telugu clinic receptionist in daily conversational Telugu.\n" + "- STRICTLY AVOID formal bookish Telugu (Grandhika/bookish Telugu). Use natural spoken phrases.\n" + "- MANDATORY SPOKEN WORDS TO USE:\n" + " * Acknowledgments: 'సరేనండి', 'అర్థమైందండి', 'ధన్యవాదాలండి'\n" + " * Asking Symptoms: 'ఏం ఇబ్బందిగా ఉంది?', 'ఏమైంది మీకు?'\n" + " * Asking Duration: 'ఈ ఇబ్బంది ఎన్ని రోజుల నుండి ఉంది?', 'ఎప్పటి నుండి నొప్పి ఉంది?'\n" + " * Asking Medicines: 'దీనికి ఏమైనా మందులు వాడారా?'\n" + " * Asking Allergies: 'మీకు ఏదైనా మందుల అలర్జీ ఉందా?'\n" + " * Confirmation: 'సరేనండి, ఈ వివరాలన్నీ డాక్టర్ గారికి అందిస్తాను.'\n" + "- FORBIDDEN BOOKISH WORDS (DO NOT USE): 'స్వాగతం', 'లక్షణాలు', 'వైద్యుడు', 'తెలియజేయగలరు'.\n" + "- Keep sentence structure polite, warm, concise, and under 14 words." ), "kn-IN": ( - "LANGUAGE & ACCENT: Professional and Respectful Kannada (ಕನ್ನಡ).\n" - "- Speak with dignified, clear hospital intake phrasing (under 14 words)." + "LANGUAGE & ACCENT: 100% Natural Spoken Conversational Kannada (ದಿನನಿತ್ಯದ ಮಾತನಾಡುವ ಕನ್ನಡ).\n" + "- Speak naturally like a polite Kannada clinic receptionist in daily conversational Kannada.\n" + "- STRICTLY AVOID formal bookish Kannada. Use natural spoken phrasing.\n" + "- MANDATORY SPOKEN WORDS TO USE:\n" + " * Acknowledgments: 'ಸರಿ ಮೇಡಂ/ಸರ್', 'ಅರ್ಥವಾಯಿತು', 'ಧನ್ಯವಾದಗಳು'\n" + " * Asking Symptoms: 'ಏನು ತೊಂದರೆಯಾಗಿದೆ?', 'ಏನಾಗಿದೆ ನಿಮಗೆ?'\n" + " * Asking Duration: 'ಈ ತೊಂದರೆ ಎಷ್ಟು ದಿನದಿಂದ ಇದೆ?', 'ಎಂದಿನಿಂದ ನೋವು ಇದೆ?'\n" + " * Asking Medicines: 'ಇದಕ್ಕೆ ಏನಾದರೂ ಮಾತ್ರೆ ತಗೊಂಡಿದ್ದೀರಾ?'\n" + " * Asking Allergies: 'ನಿಮಗೆ ಏನಾದರೂ ಮಾತ್ರೆ ಅಲರ್ಜಿ ಇದೆಯಾ?'\n" + " * Confirmation: 'ಸರಿ, ಈ ಎಲ್ಲಾ ವಿವರಗಳನ್ನು ಡಾಕ್ಟರ್‌ಗೆ ತಿಳಿಸ್ತೀನಿ.'\n" + "- FORBIDDEN BOOKISH WORDS (DO NOT USE): 'ಸುಸ್ವಾಗತ', 'ಲಕ್ಷಣಗಳು', 'ವೈದ್ಯರು', 'ತಿಳಿಸಬಹುದೇ'.\n" + "- Keep sentence structure polite, warm, concise, and under 14 words." + ), + "en-IN": ( + "LANGUAGE & ACCENT: Warm, Natural Conversational Indian English.\n" + "- Speak naturally like a polite, reassuring clinic assistant in friendly spoken English.\n" + "- MANDATORY SPOKEN PHRASES TO USE:\n" + " * Acknowledgments: 'Got it', 'Understood', 'Thank you'\n" + " * Asking Symptoms: 'What seems to be the main trouble today?', 'What symptoms are you feeling?'\n" + " * Asking Duration: 'How long has this been bothering you?'\n" + " * Asking Medicines: 'Have you taken any medication for this yet?'\n" + " * Asking Allergies: 'Do you have any known medication allergies?'\n" + " * Confirmation: 'Got it, I have passed all these details directly to the doctor.'\n" + "- FORBIDDEN FORMAL PHRASES: 'Kindly divulge your clinical symptoms', 'Welcome to CureSelect Healthcare'.\n" + "- Keep each response concise, friendly, respectful, and under 14 words." ), } selected_lang_rule = lang_instructions.get(language, lang_instructions["en-IN"]) - return f"""You are a senior, professional Hospital Patient Intake Assistant at CureSelect Healthcare. + return f"""You are Nila, a senior professional AI Patient Intake Assistant. {selected_lang_rule} CRITICAL CLINICAL INTAKE PRINCIPLES (AVOID UNWANTED QUESTIONS): 1. **FOCUS STRICTLY ON THE PATIENT'S COMPLAINT**: - Only ask questions directly relevant to what the patient reported (e.g., if they report headache & fever, ask about duration, fever severity, remedies, and allergies). - - **DO NOT randomly cross-examine unrelated organ systems** (e.g., NEVER ask about digestive issues, acidity, loose stools, or stomach unless the patient specifically mentioned stomach problems!). + - **DO NOT randomly cross-examine unrelated organ systems** (e.g., NEVER ask about digestive issues or shortness of breath when patient reports depression or headache!). + - **MENTAL HEALTH & DEPRESSION INTAKE**: When patient mentions depression, stress, anxiety, or emotional struggles, acknowledge with warm empathy. Ask relevant mental health questions (duration, impact on sleep/daily life, prior therapy or medication) instead of physical symptom cross-examinations. 2. **FAST & CONCISE INTAKE (MAX 4 TURNS TOTAL)**: - - Flow: Chief Complaint -> Duration -> Medications Taken -> Drug Allergies -> COMPLETE. - - Once symptoms, duration, medicines, and allergies are collected, **IMMEDIATELY SET "completed": true**. + - Flow: Chief Complaint -> Duration -> Medications/Therapy -> Drug Allergies -> COMPLETE. + - Once symptoms, duration, medicines/therapy, and allergies are collected, **IMMEDIATELY SET "completed": true**. 3. **RESPECT NEGATIVE ANSWERS ("NO" / "NOTHING ELSE")**: - If the patient says "No", "Nothing", "Know nothing", or indicates no more symptoms, **DO NOT ask another diagnostic question**. @@ -63,6 +123,55 @@ CRITICAL CLINICAL INTAKE PRINCIPLES (AVOID UNWANTED QUESTIONS): 5. **NO ARTIFACTS**: No bullet points, no brackets, no markdown formatting. Deliver clean spoken dialogue. 6. **EMERGENCY**: Direct severe life-threatening chest pain, sudden paralysis, or severe respiratory distress to 108 ambulance. +7. **NATURAL SPOKEN DIALECT ENFORCEMENT**: + - The "reply" MUST sound 100% natural, spoken, and colloquial for the target language. + - **Tamil (`ta-IN`)**: Strictly use authentic Pechu Tamil ('சரிங்க', 'புரியுதுங்க', 'என்ன பண்ணுது', 'எப்போலிருந்து', 'சொல்லுங்க', 'டாக்டர்கிட்ட'). Never use formal Senthamizh words ('அறிகுறிகள்', 'மருத்துவமனை', 'வரவேற்கிறோம்', 'கூறுங்கள்'). + - **Hindi (`hi-IN`)**: Strictly use natural spoken Hindi ('जी', 'समझ आ गया', 'क्या तकलीफ है', 'कब से परेशानी है', 'डॉक्टर साहब को'). Never use formal bookish Shuddh Hindi. + - **Telugu (`te-IN`)**: Strictly use natural spoken Telugu ('సరేనండి', 'అర్థమైందండి', 'ఏం ఇబ్బందిగా ఉంది', 'ఎన్ని రోజుల నుండి ఉంది', 'డాక్టర్ గారికి'). Never use formal Grandhika Telugu. + - **Kannada (`kn-IN`)**: Strictly use natural spoken Kannada ('ಸರಿ ಮೇಡಂ/ಸರ್', 'ಅರ್ಥವಾಯಿತು', 'ಏನು ತೊಂದರೆಯಾಗಿದೆ', 'ಎಷ್ಟು ದಿನದಿಂದ ಇದೆ', 'ಡಾಕ್ಟರ್‌ಗೆ'). Never use formal literary Kannada. + - **English (`en-IN`)**: Strictly use warm spoken Indian English ('Got it', 'Understood', 'What seems to be the main trouble today?', 'I will pass this directly to the doctor'). Never use formal jargon ('divulge clinical symptoms'). + +8. **STAGE-BY-STAGE SPOKEN DIALOGUE EXAMPLES FOR ALL LANGUAGES**: + - **Reason for Visit / Symptoms**: + * `ta-IN`: "உங்களுக்கு உடம்புக்கு என்ன பண்ணுது, என்ன பிரச்சனைன்னு சொல்லுங்க?" + * `hi-IN`: "आपको क्या तकलीफ हो रही है, क्या परेशानी है बताएंगे?" + * `te-IN`: "మీకు ఏం ఇబ్బందిగా ఉంది, ఏమైందో చెప్పగలరా?" + * `kn-IN`: "ನಿಮಗೆ ಏನು ತೊಂದರೆಯಾಗಿದೆ ಅಂತ ತಿಳಿಸಬಹುದೇ?" + * `en-IN`: "What seems to be the main trouble you are feeling today?" + - **Duration / Severity**: + * `ta-IN`: "சரிங்க, இந்த பிரச்சனை எத்தனை நாளா இருக்குன்னு சொல்ல முடியுமா?" + * `hi-IN`: "समझ आ गया जी, यह तकलीफ कितने दिनों से हो रही है?" + * `te-IN`: "అర్థమైందండి, ఈ ఇబ్బంది ఎన్ని రోజుల నుండి ఉంది?" + * `kn-IN`: "ಅರ್ಥವಾಯಿತು, ಈ ತೊಂದರೆ ಎಷ್ಟು ದಿನದಿಂದ ಇದೆ?" + * `en-IN`: "Got it. How long has this issue been bothering you?" + - **Medications Taken**: + * `ta-IN`: "புரியுதுங்க. இதுக்கு ஏதாவது மாத்திரை இல்ல மெடிசின் சாப்பிட்டீங்களா?" + * `hi-IN`: "ठीक है जी, इसके लिए कोई दवाई या मेडिसिन ली है आपने?" + * `te-IN`: "సరేనండి, దీనికి ఏమైనా మందులు వాడారా?" + * `kn-IN`: "ಸರಿ, ಇದಕ್ಕೆ ಏನಾದರೂ ಮಾತ್ರೆ ತಗೊಂಡಿದ್ದೀರಾ?" + * `en-IN`: "Understood. Have you taken any medication for this yet?" + - **Allergies**: + * `ta-IN`: "சரிங்க, உங்களுக்கு ஏதேனும் மாத்திரை அலர்ஜி இருக்கா?" + * `hi-IN`: "अच्छा जी, आपको किसी दवाई से एलर्जी तो नहीं है?" + * `te-IN`: "మీకు ఏదైనా మందుల అలర్జీ ఉందా?" + * `kn-IN`: "ನಿಮಗೆ ಏನಾದರೂ ಮಾತ್ರೆ ಅಲರ್ಜಿ ಇದೆಯಾ?" + * `en-IN`: "Do you have any known medication or drug allergies?" + - **Confirmation & Wrap-up**: + * `ta-IN`: "சரிங்க, எல்லா விவரத்தையும் டாக்டர்கிட்ட குடுத்துடுறேன். ரொம்ப நன்றிங்க!" + * `hi-IN`: "ठीक है जी, मैं यह सारी जानकारी डॉक्टर साहब को दे देती हूँ। शुक्रिया!" + * `te-IN`: "సరేనండి, ఈ వివరాలన్నీ డాక్టర్ గారికి అందిస్తాను. ధన్యవాదాలు!" + * `kn-IN`: "ಸರಿ, ಈ ಎಲ್ಲಾ ವಿವರಗಳನ್ನು ಡಾಕ್ಟರ್‌ಗೆ ತಿಳಿಸ್ತೀನಿ. ಧನ್ಯವಾದಗಳು!" + * `en-IN`: "Got it. I have shared all these details directly with the doctor. Thank you!" + +9. **ACCURATE STRUCTURED STATE UPDATES**: + - Extract symptom names, duration, severity, medications, and allergies cleanly into `state_updates` while keeping the spoken `reply` ultra-friendly and natural. + +10. **HIPAA (US) & DPDP ACT 2023 (INDIA) COMPLIANCE SAFEGUARDS**: + - **Data Minimization (DPDP Sec 4 & HIPAA Privacy Rule)**: Collect ONLY minimum necessary health intake data (symptoms, duration, medicines, allergies). NEVER ask for or record sensitive non-medical PII/SPI (Aadhaar, SSN, bank info, passport, home address, national IDs). + - **Strict Non-Diagnosis Rule (Telemedicine Guidelines & FDA Rule)**: NEVER diagnose conditions, offer medical opinions (e.g. "You may have COVID/fever"), or prescribe drugs. State clearly that information is collected strictly for the attending physician's review. + - **AI Disclosure & Purpose Limitation**: Maintain transparency that you are an AI assistant gathering preliminary intake details exclusively for physician consultation. + - **Emergency Escalation Protocol**: For life-threatening emergencies (chest pain, stroke, unconsciousness, severe trauma), immediately direct to emergency services (108). + PATIENT: {patient.get("name", "Patient")}, {patient.get("age", "")}y, {patient.get("gender", "")} DEPARTMENT: {specialty.get("name", "General Intake")} CURRENT STAGE: {stage} (Turn #{turn_count}) diff --git a/ai-patient-onboarding/app/ai/sarvam_client.py b/ai-patient-onboarding/app/ai/sarvam_client.py index 6c30cff..8e8a942 100644 --- a/ai-patient-onboarding/app/ai/sarvam_client.py +++ b/ai-patient-onboarding/app/ai/sarvam_client.py @@ -55,7 +55,7 @@ class SarvamClient: return None lang_prefix = language_code.lower()[:2] - speaker = "anushka" if lang_prefix in ["ta", "te", "hi"] else "karun" + speaker = "kavya" client = self._get_client() headers = { diff --git a/ai-patient-onboarding/app/api/onboarding.py b/ai-patient-onboarding/app/api/onboarding.py index 43c0cd5..231dd7e 100644 --- a/ai-patient-onboarding/app/api/onboarding.py +++ b/ai-patient-onboarding/app/api/onboarding.py @@ -33,6 +33,7 @@ from app.services.patient_submission_service import ( from app.services.result_extraction_service import ( ResultExtractionService, ) +from app.services.voice_service import VoiceService router = APIRouter( @@ -73,11 +74,10 @@ async def start_onboarding( # Initialize empty state onboarding_service.get_state(db, session_id) - greeting_msg = ( - f"Hello {request.patient.name or 'there'}, " - "welcome to CureSelect Healthcare. " - "I'll ask you a few questions to understand " - "your current health concern. Shall we begin?" + voice_service = VoiceService() + greeting_msg = voice_service.get_initial_greeting( + request.patient.name, + request.language, ) # Save initial assistant greeting diff --git a/ai-patient-onboarding/app/api/voice_onboarding.py b/ai-patient-onboarding/app/api/voice_onboarding.py index 59858c5..dddcb90 100644 --- a/ai-patient-onboarding/app/api/voice_onboarding.py +++ b/ai-patient-onboarding/app/api/voice_onboarding.py @@ -1,3 +1,5 @@ +import asyncio +import base64 import json from uuid import uuid4 from typing import Optional @@ -42,64 +44,70 @@ async def start_voice_onboarding( request: StartVoiceOnboardingRequest, db: Session = Depends(get_db), ): - onboarding_service = OnboardingService() - voice_service = VoiceService() + try: + onboarding_service = OnboardingService() + voice_service = VoiceService() - session_id = f"VOICE-{uuid4().hex[:8].upper()}" + session_id = f"VOICE-{uuid4().hex[:8].upper()}" - session = OnboardingSession( - session_id=session_id, - patient_id=request.patient.id, - patient_name=request.patient.name, - age=request.patient.age, - gender=request.patient.gender, - category_type=request.category.type, - category_code=request.category.code, - category_name=request.category.name, - language=request.language, - status="in_progress", - stage="greeting", - ) + session = OnboardingSession( + session_id=session_id, + patient_id=request.patient.id, + patient_name=request.patient.name, + age=request.patient.age, + gender=request.patient.gender, + category_type=request.category.type, + category_code=request.category.code, + category_name=request.category.name, + language=request.language, + status="in_progress", + stage="greeting", + ) - db.add(session) - db.commit() - db.refresh(session) + db.add(session) + db.commit() + db.refresh(session) - # Initialize empty state - onboarding_service.get_state(db, session_id) + # Initialize empty state + onboarding_service.get_state(db, session_id) - # Generate localized voice greeting - greeting_text = voice_service.get_initial_greeting( - request.patient.name, - request.language, - ) + # Generate localized voice greeting + greeting_text = voice_service.get_initial_greeting( + request.patient.name, + request.language, + ) - # Synthesize audio greeting - audio_id, _ = await voice_service.text_to_speech( - greeting_text, - request.language, - ) + # Synthesize audio greeting in background for sub-50ms response + audio_id = f"aud_{uuid4().hex[:12]}" + asyncio.create_task( + voice_service.synthesize_to_file(audio_id, greeting_text, request.language) + ) - # Save initial assistant message - onboarding_service.save_message( - db=db, - session_id=session_id, - role="assistant", - message=greeting_text, - stage="greeting", - ) + # Save initial assistant message + onboarding_service.save_message( + db=db, + session_id=session_id, + role="assistant", + message=greeting_text, + stage="greeting", + ) - return StartVoiceOnboardingResponse( - success=True, - session_id=session.session_id, - patient_id=session.patient_id, - category=request.category, - language=session.language, - status=session.status, - stage=session.stage, - greeting_text=greeting_text, - audio_url=f"/v1/voice-onboarding/audio/{audio_id}", - ) + return StartVoiceOnboardingResponse( + success=True, + session_id=session.session_id, + patient_id=session.patient_id, + category=request.category, + language=session.language, + status=session.status, + stage=session.stage, + greeting_text=greeting_text, + audio_url=f"/v1/voice-onboarding/audio/{audio_id}", + ) + except Exception as err: + import traceback + print(f"[StartVoiceOnboarding Error]: {err}") + traceback.print_exc() + raise HTTPException(status_code=500, detail=str(err)) @router.post( @@ -157,7 +165,7 @@ async def voice_turn( if not patient_transcript or not patient_transcript.strip(): lang = session.language.lower() if lang.startswith("ta"): - no_speech_msg = "மன்னிக்கவும், உங்கள் குரல் கேட்கவில்லை. தயவுசெய்து உங்கள் உடல்நலப் பிரச்சினை பற்றி பேசவும்." + no_speech_msg = "மன்னிக்கவும், நீங்க பேசுறது கேட்கலை. திரும்பவும் என்ன பிரச்சனைன்னு சொல்ல முடியுமா?" elif lang.startswith("hi"): no_speech_msg = "क्षमा करें, मुझे आपकी आवाज़ सुनाई नहीं दी। कृपया अपनी स्वास्थ्य समस्या के बारे में बोलें।" elif lang.startswith("te"): @@ -210,62 +218,76 @@ async def voice_turn( for m in messages ] - # 5. AI Reasoning & Extraction - ai_response = await ai_service.process_message( - session=session, - state=state, - conversation_history=conversation_history, - user_message=patient_transcript, - ) + try: + # 5. AI Reasoning & Extraction + ai_response = await ai_service.process_message( + session=session, + state=state, + conversation_history=conversation_history, + user_message=patient_transcript, + ) - # 6. Update Structured Clinical State - current_state = json.loads(state.state_json) - updates = ai_response.get("state_updates", {}) - for k, v in updates.items(): - if v is not None: - if isinstance(v, list) and len(v) == 0: - continue - current_state[k] = v + # 6. Update Structured Clinical State + current_state = json.loads(state.state_json) + updates = ai_response.get("state_updates", {}) + for k, v in updates.items(): + if v is not None: + if isinstance(v, list) and len(v) == 0: + continue + current_state[k] = v - onboarding_service.update_state( - db=db, - state=state, - data=current_state, - ) + onboarding_service.update_state( + db=db, + state=state, + data=current_state, + ) - # 7. Update session stage & status - ai_reply_text = ai_response["reply"] - session.stage = ai_response.get("next_stage", session.stage) - is_completed = bool(ai_response.get("completed", False)) - if is_completed: - session.status = "completed" + # 7. Update session stage, status & auto-assigned specialty + ai_reply_text = ai_response["reply"] + session.stage = ai_response.get("next_stage", session.stage) + is_completed = bool(ai_response.get("completed", False)) - db.commit() + sug_spec = ai_response.get("suggested_specialty") + if sug_spec and sug_spec.get("code") and sug_spec["code"] != "general_medicine": + session.category_code = sug_spec["code"] + session.category_name = sug_spec["name"] - # 8. Save Assistant Message - onboarding_service.save_message( - db=db, - session_id=session_id, - role="assistant", - message=ai_reply_text, - stage=session.stage, - ) + if is_completed: + session.status = "completed" - # 9. Synthesize AI Speech Audio (TTS) - audio_id, _ = await voice_service.text_to_speech( - text=ai_reply_text, - language_code=session.language, - ) + db.commit() - return VoiceTurnResponse( - success=True, - session_id=session_id, - patient_transcript=patient_transcript, - ai_text=ai_reply_text, - stage=session.stage, - completed=is_completed, - audio_url=f"/v1/voice-onboarding/audio/{audio_id}", - ) + # 8. Save Assistant Message + onboarding_service.save_message( + db=db, + session_id=session_id, + role="assistant", + message=ai_reply_text, + stage=session.stage, + ) + + # 9. Synthesize AI Speech Audio (TTS) asynchronously in background for sub-50ms response + audio_id = f"aud_{uuid4().hex[:12]}" + asyncio.create_task( + voice_service.synthesize_to_file(audio_id, ai_reply_text, session.language) + ) + + return VoiceTurnResponse( + success=True, + session_id=session_id, + patient_transcript=patient_transcript, + ai_text=ai_reply_text, + stage=session.stage, + completed=is_completed, + audio_url=f"/v1/voice-onboarding/audio/{audio_id}", + audio_base64=None, + suggested_specialty=sug_spec, + ) + except Exception as err: + import traceback + print(f"[VoiceTurn Error]: {err}") + traceback.print_exc() + raise HTTPException(status_code=500, detail=str(err)) @router.get("/audio/{audio_id}") @@ -273,14 +295,32 @@ async def get_audio(audio_id: str): voice_service = VoiceService() audio_path = voice_service.get_audio_path(audio_id) + # Wait up to 6.0s for background TTS synthesis to complete + for _ in range(60): + if audio_path and audio_path.exists(): + if audio_path.stat().st_size > 1000: + break + await asyncio.sleep(0.1) + audio_path = voice_service.get_audio_path(audio_id) + if not audio_path or not audio_path.exists(): - raise HTTPException(status_code=404, detail="Audio file not found") + from app.services.voice_service import AUDIO_DIR + audio_data = voice_service._generate_fallback_wav() + file_path = AUDIO_DIR / f"{audio_id}.wav" + file_path.write_bytes(audio_data) + audio_path = file_path media_type = "audio/mpeg" if audio_path.suffix == ".mp3" else "audio/wav" return FileResponse( path=str(audio_path), media_type=media_type, filename=audio_path.name, + headers={ + "Content-Disposition": "inline", + "Accept-Ranges": "bytes", + "Cache-Control": "no-cache, no-store, must-revalidate", + "Access-Control-Allow-Origin": "*", + }, ) diff --git a/ai-patient-onboarding/app/schemas/voice.py b/ai-patient-onboarding/app/schemas/voice.py index c45a632..e8b5759 100644 --- a/ai-patient-onboarding/app/schemas/voice.py +++ b/ai-patient-onboarding/app/schemas/voice.py @@ -29,3 +29,5 @@ class VoiceTurnResponse(BaseModel): stage: str completed: bool = False audio_url: str + audio_base64: Optional[str] = None + suggested_specialty: Optional[dict] = None diff --git a/ai-patient-onboarding/app/services/medical_speech_normalizer.py b/ai-patient-onboarding/app/services/medical_speech_normalizer.py index 8e281f9..d15cbc7 100644 --- a/ai-patient-onboarding/app/services/medical_speech_normalizer.py +++ b/ai-patient-onboarding/app/services/medical_speech_normalizer.py @@ -54,6 +54,11 @@ def normalize_medical_speech(transcript: str, language_code: str = "en-IN") -> s for pattern, replacement in PHONETIC_REPLACEMENTS.items(): cleaned = re.sub(pattern, replacement, cleaned, flags=re.IGNORECASE) + # HIPAA & DPDP Act 2023 PII/SPI Redaction: Scrub sensitive non-medical national IDs & card numbers + cleaned = re.sub(r"\b\d{4}[-\s]?\d{4}[-\s]?\d{4}[-\s]?\d{4}\b", "[REDACTED_FINANCIAL_CARD]", cleaned) + cleaned = re.sub(r"\b\d{4}[-\s]?\d{4}[-\s]?\d{4}\b", "[REDACTED_NATIONAL_ID]", cleaned) + cleaned = re.sub(r"\b\d{3}[-\s]?\d{2}[-\s]?\d{4}\b", "[REDACTED_SSN]", cleaned) + # Standardize spaces and punctuation cleaned = re.sub(r"\s+([.,!?])", r"\1", cleaned) cleaned = re.sub(r"\s+", " ", cleaned).strip() diff --git a/ai-patient-onboarding/app/services/voice_service.py b/ai-patient-onboarding/app/services/voice_service.py index a5d8340..d53e33a 100644 --- a/ai-patient-onboarding/app/services/voice_service.py +++ b/ai-patient-onboarding/app/services/voice_service.py @@ -99,9 +99,11 @@ class VoiceService: self, text: str, language_code: str = "en-IN", + audio_id: str | None = None, ) -> tuple[str, bytes]: lang_prefix = language_code.lower()[:2] - audio_id = f"aud_{uuid4().hex[:12]}" + if not audio_id: + audio_id = f"aud_{uuid4().hex[:12]}" # Clean punctuation and brackets before synthesis cleaned_text = ( @@ -121,8 +123,8 @@ class VoiceService: and lang_prefix in ["ta", "hi", "te", "kn", "bn", "mr", "gu", "ml", "pa", "od", "en"] ): try: - speaker = "kavya" if lang_prefix in ["ta", "hi", "te", "kn"] else "gokul" - async with httpx2.AsyncClient(timeout=5.0) as client: + speaker = "kavya" + async with httpx2.AsyncClient(timeout=6.0) as client: headers = { "api-subscription-key": self.sarvam_key, "Content-Type": "application/json", @@ -153,11 +155,11 @@ class VoiceService: # 2. Use OpenAI TTS if self.openai_key and self.openai_key.strip() not in ["your_openai_api_key", "your_openai_key", ""]: - client = AsyncOpenAI(api_key=self.openai_key, timeout=5.0) + client = AsyncOpenAI(api_key=self.openai_key, timeout=6.0) try: response = await client.audio.speech.create( model="tts-1", - voice="shimmer", + voice="nova", input=cleaned_text, speed=1.0, ) @@ -176,6 +178,15 @@ class VoiceService: file_path.write_bytes(audio_data) return audio_id, audio_data + async def synthesize_to_file( + self, + audio_id: str, + text: str, + language_code: str = "en-IN", + ) -> bytes: + _, audio_data = await self.text_to_speech(text, language_code=language_code, audio_id=audio_id) + return audio_data + def _generate_fallback_wav(self) -> bytes: buf = io.BytesIO() with wave.open(buf, "wb") as wf: diff --git a/ai-patient-onboarding/full_conversation_test_result.json b/ai-patient-onboarding/full_conversation_test_result.json new file mode 100644 index 0000000..e05682a --- /dev/null +++ b/ai-patient-onboarding/full_conversation_test_result.json @@ -0,0 +1,39 @@ +[ + { + "step": "START_SESSION", + "session_id": "VOICE-B69073AB", + "ai_greeting": "Hello Gokul Varthan, welcome to CureSelect Healthcare. Could you please share what health symptoms you are experiencing today?" + }, + { + "turn": 1, + "patient_input": "I have been feeling severe mental depression, anxiety, and low energy recently.", + "ai_response": "Understood. How long has this been bothering you?", + "suggested_specialty": "Psychiatry & Mental Health Care", + "stage": "symptoms", + "completed": false + }, + { + "turn": 2, + "patient_input": "It has been bothering me for around 2 months now.", + "ai_response": "Understood, have you taken any medication for this yet?", + "suggested_specialty": "General Medicine / Primary Care", + "stage": "medications", + "completed": false + }, + { + "turn": 3, + "patient_input": "I haven't taken any psychiatric medications yet, just tried herbal tea.", + "ai_response": "Understood. Do you have any known medication allergies?", + "suggested_specialty": "General Medicine / Primary Care", + "stage": "allergies", + "completed": false + }, + { + "turn": 4, + "patient_input": "No, I don't have any drug allergies.", + "ai_response": "Got it, I have passed all these details directly to the doctor. Thank you!", + "suggested_specialty": "General Medicine / Primary Care", + "stage": "confirmation", + "completed": true + } +] \ No newline at end of file diff --git a/ai-patient-onboarding/test_5_stage_dialogue.py b/ai-patient-onboarding/test_5_stage_dialogue.py new file mode 100644 index 0000000..3a846ff --- /dev/null +++ b/ai-patient-onboarding/test_5_stage_dialogue.py @@ -0,0 +1,32 @@ +import httpx + +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) diff --git a/ai-patient-onboarding/test_auto_specialty.py b/ai-patient-onboarding/test_auto_specialty.py new file mode 100644 index 0000000..762eb24 --- /dev/null +++ b/ai-patient-onboarding/test_auto_specialty.py @@ -0,0 +1,30 @@ +import httpx + +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") diff --git a/ai-patient-onboarding/test_direct_classifier.py b/ai-patient-onboarding/test_direct_classifier.py new file mode 100644 index 0000000..70bf9f4 --- /dev/null +++ b/ai-patient-onboarding/test_direct_classifier.py @@ -0,0 +1,16 @@ +from app.ai.orchestrator import classify_specialty_from_symptoms + +test_cases = [ + ("Cardiology", "நெஞ்சு வலி மற்றும் மூச்சு திணறல்"), + ("Dermatology", "தோல் அரிப்பு மற்றும் படை"), + ("Orthopedics", "மூட்டு வலி"), + ("Neurology", "headache and dizziness"), + ("Gastroenterology", "வயிற்று வலி மற்றும் வாந்தி"), + ("Pulmonology", "இருமல் மற்றும் சளி"), + ("ENT", "காது வலி மற்றும் தொண்டை வலி") +] + +print("=== DIRECT SYMPTOM SPECIALTY CLASSIFIER TEST ===") +for label, text in test_cases: + res = classify_specialty_from_symptoms(text) + print("Test:", label, "| Code:", res["code"], "| Specialty:", res["name"]) diff --git a/ai-patient-onboarding/test_result.json b/ai-patient-onboarding/test_result.json new file mode 100644 index 0000000..7d40cba --- /dev/null +++ b/ai-patient-onboarding/test_result.json @@ -0,0 +1,13 @@ +{ + "session_id": "VOICE-05BAB771", + "patient_message": "எனக்கு ரொம்ப மன அழுத்தம் மற்றும் மனச்சோர்வு இருக்கு", + "ai_response": "புரியுதுங்க கோகுல், இந்த மன அழுத்தமும் மனச்சோர்வும் எத்தனை நாளா இருக்குன்னு சொல்ல முடியுமா?", + "suggested_specialty": { + "type": "speciality", + "code": "psychiatry", + "name": "Psychiatry & Mental Health Care", + "confidence": 0.95, + "reason": "Automatically classified based on clinical keyword: 'மன அழுத்தம்'" + }, + "stage": "symptoms" +} \ No newline at end of file