import json import re import os from typing import Dict, Any, List, Optional from pydantic import ValidationError from app.config import OPENAI_API_KEY, GEMINI_API_KEY, GEMINI_MODEL from app.ai.openai_client import OpenAIClient from app.ai.gemini_client import GeminiClient from app.schemas.onboarding import AIOnboardingResponse 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, ) from app.ai.prompt_rules.safety_rules import ( EMERGENCY_KEYWORDS_EN, EMERGENCY_KEYWORDS_INDIC, CRISIS_KEYWORDS_EN, CRISIS_KEYWORDS_INDIC, ) 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", "joint pain", "bone", "fracture", "back pain", "shoulder", "arthritis", "sprain", "swelling", "மூட்டு வலி", "இடுப்பு வலி", "முதுகு வலி", "எலும்பு முறிவு", "घुटने का दर्द", "जोड़ों का दर्द", "हड्डी" ] }, { "code": "gastroenterology", "name": "Gastroenterology (Stomach & Digestive Care)", "keywords": [ "stomach pain", "stomach ache", "acidity", "vomiting", "diarrhea", "indigestion", "gastric", "loose motion", "constipation", "வயிற்று வலி", "வாந்தி", "அஜீரணம்", "வயிற்றுப்போக்கு", "गैस", "पेट दर्द", "उल्टी", "दस्त" ] }, { "code": "pulmonology", "name": "Pulmonology (Lungs & Respiratory)", "keywords": [ "cough", "asthma", "wheezing", "phlegm", "shortness of breath", "cold", "flu", "இருமல்", "சளி", "ஆஸ்துமா", "இளைப்பு", "खांसी", "दमा", "कफ", "सांस फूलना" ] }, { "code": "general_medicine", "name": "General Medicine", "keywords": [ "fever", "weakness", "body pain", "fatigue", "tired", "chills", "weight loss", "diabetes", "bp", "sugar", "காய்ச்சல்", "உடல் வலி", "உடம்பு வலி", "சோர்வு", "குளிர்", "बुखार", "कमजोरी", "बदन दर्द" ] } ] def classify_specialty_from_symptoms(text: str) -> Dict[str, str]: if not text: return {"code": "general_medicine", "name": "General Medicine"} t_lower = text.lower() for rule in SPECIALTY_RULES: for kw in rule["keywords"]: if kw.lower() in t_lower: return {"code": rule["code"], "name": rule["name"]} return {"code": "general_medicine", "name": "General Medicine"} def validate_ai_response(data: dict) -> AIOnboardingResponse: try: return AIOnboardingResponse.model_validate(data) except ValidationError: updates = data.get("state_updates", {}) if not isinstance(updates, dict): updates = {} cleaned = { "reply": str(data.get("reply", "சரிங்க, மேலும் விவரங்களை சொல்லுங்க.")), "next_stage": str(data.get("next_stage", "symptom_details")), "state_updates": updates, "completed": bool(data.get("completed", False)), "suggested_specialty": data.get("suggested_specialty"), } return AIOnboardingResponse.model_validate(cleaned) CANONICAL_SYMPTOM_MAP = { "தலைவலி": "headache", "தலை வலி": "headache", "தலை பாரம்": "headache", "सिरदर्द": "headache", "காய்ச்சல்": "fever", "சுரம்": "fever", "बुखार": "fever", "வயிற்று வலி": "stomach pain", "வாந்தி": "vomiting", "उल्टी": "vomiting", "இருமல்": "cough", "खांसी": "cough", "சளி": "cold", "நெஞ்சு வலி": "chest pain", "छाती में दर्द": "chest pain", "மூச்சு திணறல்": "breathlessness", "மூச்சு வாங்குது": "breathlessness", "கை கால் வலி": "body pain", "உடல் வலி": "body pain", "உடம்பு வலி": "body pain", "बदन दर्द": "body pain", "தலைச்சுற்றல்": "dizziness", "தலை சுத்துது": "dizziness", "चक्कर": "dizziness", "நெஞ்சு படபடப்பு": "palpitations", "படபடப்பு": "palpitations", "படபடன்னு": "palpitations", "தோல் அரிப்பு": "skin rash", "அரிப்பு": "skin rash", "தூக்கமின்மை": "insomnia", "மன அழுத்தம்": "stress", } def _normalize_key(name: str) -> str: if not name: return "" cleaned = name.strip().lower() for k, canonical in CANONICAL_SYMPTOM_MAP.items(): if k in cleaned or cleaned in k: return canonical return cleaned def merge_state_updates(existing_state: Dict[str, Any], new_updates: Dict[str, Any]) -> Dict[str, Any]: merged = dict(existing_state) if not isinstance(new_updates, dict): return merged # 1. reason_for_visit rfv = new_updates.get("reason_for_visit") if rfv is not None and rfv != "": merged["reason_for_visit"] = rfv # 2. symptoms new_symptoms = new_updates.get("symptoms") if new_symptoms: if isinstance(new_symptoms, dict): new_symptoms = new_symptoms.get("items", []) if isinstance(new_symptoms, list): existing_symptoms = merged.get("symptoms", []) if isinstance(existing_symptoms, dict): existing_symptoms = existing_symptoms.get("items", []) if not isinstance(existing_symptoms, list): existing_symptoms = [] sym_map = {} for item in existing_symptoms: if isinstance(item, dict): key = _normalize_key(item.get("name", "")) if key: sym_map[key] = dict(item) else: sym_map[f"_unnamed_{len(sym_map)}"] = dict(item) for new_item in new_symptoms: if isinstance(new_item, dict): new_name = new_item.get("name") if new_name: key = _normalize_key(new_name) if key in sym_map: for attr, val in new_item.items(): if val is not None and val != "": sym_map[key][attr] = val else: sym_map[key] = dict(new_item) else: # Contextual attribute for the primary symptom primary_keys = list(sym_map.keys()) if primary_keys: target_key = primary_keys[0] for attr, val in new_item.items(): if val is not None and val != "": sym_map[target_key][attr] = val else: sym_map[f"_unnamed_{len(sym_map)}"] = dict(new_item) merged["symptoms"] = list(sym_map.values()) # 3. medical_history new_hist = new_updates.get("medical_history") if new_hist: if isinstance(new_hist, dict): if new_hist.get("status") == "confirmed_none": existing_curr = merged.get("medical_history") existing_items = existing_curr.get("items", []) if isinstance(existing_curr, dict) else (existing_curr or []) if not existing_items: merged["medical_history"] = {"status": "confirmed_none", "items": []} else: new_hist = new_hist.get("items", []) if isinstance(new_hist, list): existing_hist = merged.get("medical_history", []) if isinstance(existing_hist, dict): existing_hist = existing_hist.get("items", []) if not isinstance(existing_hist, list): existing_hist = [] h_map = {} for item in existing_hist: if isinstance(item, dict): c = item.get("condition", "").lower().strip() if c: h_map[c] = dict(item) elif isinstance(item, str) and item.strip(): h_map[item.lower().strip()] = {"condition": item.strip()} for item in new_hist: if isinstance(item, dict): c = item.get("condition", "").lower().strip() if c in h_map: for attr, val in item.items(): if val is not None and val != "": h_map[c][attr] = val elif c: h_map[c] = dict(item) elif isinstance(item, str) and item.strip(): c = item.lower().strip() if c not in h_map: h_map[c] = {"condition": item.strip()} merged["medical_history"] = list(h_map.values()) # 4. medications new_meds = new_updates.get("medications") if new_meds: if isinstance(new_meds, dict): if new_meds.get("status") == "confirmed_none": existing_curr = merged.get("medications") existing_items = existing_curr.get("items", []) if isinstance(existing_curr, dict) else (existing_curr or []) if not existing_items: merged["medications"] = {"status": "confirmed_none", "items": []} else: new_meds = new_meds.get("items", []) if isinstance(new_meds, list): existing_meds = merged.get("medications", []) if isinstance(existing_meds, dict): existing_meds = existing_meds.get("items", []) if not isinstance(existing_meds, list): existing_meds = [] m_map = {} for item in existing_meds: if isinstance(item, dict): n = item.get("name", "").lower().strip() if n: m_map[n] = dict(item) elif isinstance(item, str) and item.strip(): m_map[item.lower().strip()] = {"name": item.strip()} for item in new_meds: if isinstance(item, dict): n = item.get("name", "").lower().strip() if n in m_map: for attr, val in item.items(): if val is not None and val != "": m_map[n][attr] = val elif n: m_map[n] = dict(item) elif isinstance(item, str) and item.strip(): n = item.lower().strip() if n not in m_map: m_map[n] = {"name": item.strip()} merged["medications"] = list(m_map.values()) # 5. allergies new_algs = new_updates.get("allergies") if new_algs: if isinstance(new_algs, dict): if new_algs.get("status") == "confirmed_none": existing_curr = merged.get("allergies") existing_items = existing_curr.get("items", []) if isinstance(existing_curr, dict) else (existing_curr or []) if not existing_items: merged["allergies"] = {"status": "confirmed_none", "items": []} else: new_algs = new_algs.get("items", []) if isinstance(new_algs, list): existing_algs = merged.get("allergies", []) if isinstance(existing_algs, dict): existing_algs = existing_algs.get("items", []) if not isinstance(existing_algs, list): existing_algs = [] a_map = {} for item in existing_algs: if isinstance(item, dict): n = item.get("name", "").lower().strip() if n: a_map[n] = dict(item) elif isinstance(item, str) and item.strip(): a_map[item.lower().strip()] = {"name": item.strip()} for item in new_algs: if isinstance(item, dict): n = item.get("name", "").lower().strip() if n in a_map: for attr, val in item.items(): if val is not None and val != "": a_map[n][attr] = val elif n: a_map[n] = dict(item) elif isinstance(item, str) and item.strip(): n = item.lower().strip() if n not in a_map: a_map[n] = {"name": item.strip()} merged["allergies"] = list(a_map.values()) # 6. surgical_history, previous_episodes, family_history, additional_information for key in ["surgical_history", "previous_episodes", "family_history", "additional_information"]: val = new_updates.get(key) if val: existing_list = merged.get(key, []) if not isinstance(existing_list, list): existing_list = [existing_list] if existing_list else [] if isinstance(val, list): merged[key] = existing_list + [v for v in val if v not in existing_list] else: if val not in existing_list: merged[key] = existing_list + [val] return merged def _field_status(field_val) -> str: if field_val is None: return "not_collected" if isinstance(field_val, dict): status = field_val.get("status") if status in ["reported", "confirmed_none"]: 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 _field_has_items(field_val) -> bool: """Backward-compatible helper: returns True if the field has at least one item.""" return _field_status(field_val) == "reported" def _any_symptom_has_duration(symptoms) -> bool: """Backward-compatible helper: returns True if any symptom has a duration.""" if isinstance(symptoms, dict): symptoms = symptoms.get("items", []) if not isinstance(symptoms, list): return False return any( bool(s.get("duration") or s.get("onset") or s.get("onset_and_severity")) for s in symptoms if isinstance(s, dict) ) def check_required_missing(state: Dict[str, Any]) -> List[str]: missing = [] # 1. Reason for visit / Chief complaint rfv = state.get("reason_for_visit") if _field_status(rfv) == "not_collected": missing.append("reason_for_visit") # 2. Symptoms syms = state.get("symptoms") syms_list = syms.get("items", []) if isinstance(syms, dict) else (syms if isinstance(syms, list) else []) if not syms_list: missing.append("symptoms") else: # Check duration on at least the primary symptom has_duration = any( bool(s.get("duration") or s.get("onset") or s.get("onset_and_severity")) for s in syms_list if isinstance(s, dict) ) if not has_duration: missing.append("duration") # 3. Medical history mh = state.get("medical_history") if _field_status(mh) == "not_collected": missing.append("medical_history") # 4. Medications meds = state.get("medications") if _field_status(meds) == "not_collected": missing.append("medications") # 5. Allergies algs = state.get("allergies") if _field_status(algs) == "not_collected": missing.append("allergies") return missing def _stage_for_missing(missing_field: str) -> str: mapping = { "reason_for_visit": "reason_for_visit", "symptoms": "symptom_collection", "duration": "symptom_details", "medical_history": "medical_history", "medications": "medications", "allergies": "allergies", } return mapping.get(missing_field, "symptom_details") def _pick_best_question(missing_list: List[str], lang: str, is_psych: bool = False) -> str: if not missing_list: if lang.startswith("ta"): return "அனைத்து விபரங்களும் பதிவு செய்யப்பட்டது!" elif lang.startswith("hi"): return "सभी विवरण दर्ज कर लिए।" elif lang.startswith("te"): return "అన్ని వివరాలు నమోదు చేయబడ్డాయి. ధన్యవాదాలు!" elif lang.startswith("kn"): return "ಎಲ್ಲಾ ವಿವರಗಳನ್ನು ದಾಖಲಿಸಲಾಗಿದೆ. ಧನ್ಯವಾದಗಳು!" else: return "All details recorded. Thank you!" field = missing_list[0] # Tamil (Max 5 words) if lang.startswith("ta"): if field == "reason_for_visit" or field == "symptoms": return "உங்களுக்கு என்ன உடம்பு பிரச்சனை?" elif field == "duration": return "எத்தனை நாளாக இந்த பிரச்சனை?" elif field == "medical_history": return "வேறு ஏதேனும் நோய் இருக்கிறதா?" elif field == "medications": return "தினமும் ஏதேனும் மாத்திரை சாப்பிடுகிறீர்களா?" elif field == "allergies": return "ஏதேனும் மருந்து அலர்ஜி இருக்கா?" # Hindi (Max 5 words) elif lang.startswith("hi"): if field == "reason_for_visit" or field == "symptoms": return "नमस्ते, आपको क्या स्वास्थ्य समस्या हो रही है?" elif field == "duration": return "समझ गया जी, यह तकलीफ कितने दिनों से हो रही है?" elif field == "medical_history": return "जी ठीक है, क्या आपको बीपी, शुगर या अस्थामा जैसी कोई पुरानी बीमारी है?" elif field == "medications": return "समझ गया, क्या आप नियमित रूप से कोई दवाई लेते हैं?" elif field == "allergies": return "अच्छा जी, आपको किसी दवाई या खाने से एलर्जी तो नहीं है?" # Telugu elif lang.startswith("te"): if field == "reason_for_visit" or field == "symptoms": return "నమస్కారం, మీ ఆరోగ్య సమస్య ఏమిటో చెప్పగలరా?" elif field == "duration": return "అర్థమైందండి, ఈ ఇబ్బంది ఎన్ని రోజుల నుండి ఉంది?" elif field == "medical_history": return "అర్థమైంది, మీకు బీపీ, షుగర్ లేదా ఆస్తమా వంటి పాత ఆరోగ్య సమస్యలు ఉన్నాయా?" elif field == "medications": return "సరేనండి, మీరు క్రమం తప్పకుండా ఏమైనా మందులు వాడుతున్నారా?" elif field == "allergies": return "మీకు ఏదైనా మందుల అలర్జీ ఉందా?" # Kannada elif lang.startswith("kn"): if field == "reason_for_visit" or field == "symptoms": return "ನಮಸ್ಕಾರ, ನಿಮ್ಮ ಆರೋಗ್ಯ ಸಮಸ್ಯೆ ಏನು ಎಂದು ತಿಳಿಸುವಿರಾ?" elif field == "duration": return "ಅರ್ಥವಾಯಿತು, ಈ ತೊಂದರೆ ಎಷ್ಟು ದಿನದಿಂದ ಇದೆ?" elif field == "medical_history": return "ತಿಳಿಯಿತು, ನಿಮಗೆ ಬಿಪಿ, ಶುಗರ್ ಅಥವಾ ಅಸ್ತಮಾದಂತಹ ಹಳೆಯ ತೊಂದರೆ ಇದೆಯಾ?" elif field == "medications": return "ಸರಿ, ನೀವು ನಿಯಮಿತವಾಗಿ ಯಾವುದೇ ಮಾತ್ರೆಗಳನ್ನು ತೆಗೆದುಕೊಳ್ಳುತ್ತಿದ್ದೀರಾ?" elif field == "allergies": return "ನಿಮಗೆ ಯಾವುದೇ ಮಾತ್ರೆ ಅಥವಾ ಔಷಧಿಯ ಅಲರ್ಜಿ ಇದೆಯಾ?" # English Default if field == "reason_for_visit" or field == "symptoms": return "Hello, could you please describe what symptoms or health concerns you are experiencing?" elif field == "duration": return "Got it. How long have you had these symptoms?" elif field == "medical_history": if is_psych: return "Understood. Have you previously received counseling or treatment for similar concerns?" return "Got it. Do you have a history of diabetes, high blood pressure, asthma, or other chronic conditions?" elif field == "medications": return "Understood. Are you currently taking any prescription medications or daily tablets?" elif field == "allergies": return "Understood. Do you have any known medication or food allergies?" return "Could you provide any additional details about your symptoms?" class AIOrchestrator: def __init__(self): self.openai = OpenAIClient() self.gemini = GeminiClient() async def process( self, session, state, conversation_history, user_message: str, ) -> Dict[str, Any]: specialty = { "code": getattr(session, "category_code", "general_medicine") or "general_medicine", "name": getattr(session, "category_name", "General Medicine") or "General Medicine", } # ── EMERGENCY / CRISIS OVERRIDE (Executed first before anything else) ── msg_lower = (user_message or "").lower() msg_raw = user_message or "" lang = (session.language or "ta-IN").lower() is_crisis = ( any(k in msg_lower for k in CRISIS_KEYWORDS_EN) or any(k in msg_raw for k in CRISIS_KEYWORDS_INDIC) ) is_emergency = ( any(k in msg_lower for k in EMERGENCY_KEYWORDS_EN) or any(k in msg_raw for k in EMERGENCY_KEYWORDS_INDIC) ) if is_crisis: if lang.startswith("ta"): reply = "உங்கள் மன நிலை எங்களுக்கு புரிகிறது. உடனடியாக உதவி பெற Tele-MANAS (14416) அல்லது 108 என்ற இலவச எண்ணை அழைக்கவும்." elif lang.startswith("hi"): reply = "आपकी परेशानी हम समझ सकते हैं। कृपया तुरंत मदद के लिए Tele-MANAS (14416) या 108 पर कॉल करें।" elif lang.startswith("te"): reply = "దయచేసి ఆందోళన చెందకండి. తక్షణ సహాయం కోసం Tele-MANAS (14416) లేదా 108 కి కాల్ చేయండి." elif lang.startswith("kn"): reply = "ದಯವಿಟ್ಟು ಧೈರ್ಯವಾಗಿರಿ. ತುರ್ತು ನೆರವಿಗಾಗಿ Tele-MANAS (14416) ಅಥವಾ 108 ಗೆ ಕರೆ ಮಾಡಿ." else: reply = "We care about your safety. Please immediately reach out to the 24/7 Tele-MANAS mental health helpline at 14416 or call 108." return { "reply": reply, "next_stage": "confirmation", "state_updates": {"additional_information": [{"category": "crisis_alert", "value": user_message}]}, "completed": True, "suggested_specialty": {"code": "psychiatry", "name": "Psychiatry & Mental Health Care"}, } if is_emergency: if lang.startswith("ta"): reply = "இதற்கு உடனடியாக தீவிர மருத்துவ சிகிச்சை தேவைப்படலாம். தயவுசெய்து உடனே 108 அவசர ஆம்புலன்ஸை அழைக்கவும் அல்லது அவசர சிகிச்சை பிரிவுக்கு செல்லவும்." elif lang.startswith("hi"): reply = "इसके लिए तुरंत आपातकालीन चिकित्सा की आवश्यकता हो सकती है। कृपया तुरंत 108 एम्बुलेंस बुलाएं या नजदीकी अस्पताल जाएं।" elif lang.startswith("te"): reply = "దీనికి తక్షణ వైద్య సహాయం అవసరం కావచ్చు. దయచేసి వెంటనే 108 కి కాల్ చేయండి." elif lang.startswith("kn"): reply = "ಇದಕ್ಕೆ ತಕ್ಷಣದ ಚಿಕಿತ್ಸೆ ಅಗತ್ಯವಿದೆ. ದಯವಿಟ್ಟು ಕೂಡಲೇ 108 ಆಂಬುಲೆನ್ಸ್‌ಗೆ ಕರೆ ಮಾಡಿ." else: reply = "This may require immediate medical attention. Please call 108 for an ambulance or visit the nearest emergency room immediately." return { "reply": reply, "next_stage": "confirmation", "state_updates": {"additional_information": [{"category": "emergency_alert", "value": user_message}]}, "completed": True, "suggested_specialty": {"code": "cardiology", "name": "Cardiology (Heart Care)"}, } current_state = json.loads(state.state_json) if hasattr(state, "state_json") and state.state_json else {} # Auto-detect / classify specialty from user message suggested_specialty = classify_specialty_from_symptoms(user_message) # Only upgrade specialty, never downgrade from a specific specialty to general_medicine if suggested_specialty["code"] == "general_medicine" and specialty["code"] != "general_medicine": suggested_specialty = specialty elif suggested_specialty["code"] != "general_medicine" and specialty["code"] == "general_medicine": specialty = suggested_specialty last_ai_message = None for m in reversed(conversation_history or []): if m.get("role") in ["assistant", "ai"]: last_ai_message = m.get("message") break # ── STRATEGY: Try Gemini LLM for natural contextual response, fall back to rule-based ── gemini_result = None try: gemini_result = await self._try_gemini_response( session, specialty, user_message, current_state, conversation_history, suggested_specialty ) except Exception as e: print(f"[AIOrchestrator] Gemini LLM call failed, using rule-based fallback: {e}") if gemini_result: # ── HYBRID: Use Gemini's reply text, but validate state with deterministic extraction ── deterministic = self._simulate_specialty_response( session, specialty, user_message, current_state, len(conversation_history or []), suggested_specialty, last_ai_message=last_ai_message, ) # Use Gemini's natural reply, but trust deterministic state_updates and completed flag gemini_reply = gemini_result.get("reply", "") gemini_state = gemini_result.get("state_updates", {}) det_state = deterministic.get("state_updates", {}) # Merge: deterministic extraction is the safety net, Gemini can add extras final_state = dict(det_state) for key in ["additional_information", "surgical_history", "previous_episodes", "family_history"]: gemini_val = gemini_state.get(key) if gemini_val and not final_state.get(key): final_state[key] = gemini_val # For core fields, prefer Gemini if it extracted something deterministic missed for key in ["symptoms", "medical_history", "medications", "allergies"]: g_val = gemini_state.get(key) d_val = final_state.get(key) if g_val and not d_val: final_state[key] = g_val parsed = { "reply": gemini_reply, "next_stage": deterministic.get("next_stage", "symptom_details"), "state_updates": final_state, "completed": deterministic["completed"], # Trust deterministic completion } else: # ── FALLBACK: Pure rule-based response ── parsed = self._simulate_specialty_response( session, specialty, user_message, current_state, len(conversation_history or []), suggested_specialty, last_ai_message=last_ai_message, ) parsed["suggested_specialty"] = suggested_specialty validated = validate_ai_response(parsed) res_dict = validated.model_dump() res_dict["suggested_specialty"] = suggested_specialty return res_dict async def _try_gemini_response( self, session, specialty: dict, user_message: str, current_state: dict, conversation_history: list, suggested_specialty: dict, ) -> Optional[Dict[str, Any]]: """ Try to get a natural, contextual response from Gemini LLM. Returns parsed JSON dict on success, None on failure. """ from app.ai.prompts import build_system_prompt patient = { "name": getattr(session, "first_name", "Patient"), "age": getattr(session, "age", ""), "gender": getattr(session, "gender", ""), } context = { "specialty_config": specialty, "patient": patient, "language": session.language or "ta-IN", "current_stage": getattr(session, "stage", "greeting"), "current_state": current_state, "conversation_history": conversation_history or [], "new_patient_message": user_message, } system_prompt = build_system_prompt(context) raw_text = None # Priority 1: OpenAI (if user paid for key) if self.openai.api_key and self.openai.api_key.strip() not in ["your_openai_key", "your_openai_api_key", ""]: try: raw_text = await self.openai.generate_response(system_prompt, user_message) except Exception as e: print(f"[AIOrchestrator] OpenAI LLM call failed: {e}") # Priority 2: Gemini fallback if not raw_text and self.gemini.api_key and self.gemini.api_key.strip() not in ["your_gemini_key", "your_gemini_api_key", ""]: try: raw_text = await self.gemini.generate_response(system_prompt, user_message) except Exception as e: print(f"[AIOrchestrator] Gemini LLM call failed: {e}") if not raw_text: return None # Parse JSON from response cleaned = raw_text.strip() if cleaned.startswith("```"): cleaned = cleaned.split("\n", 1)[-1] if cleaned.endswith("```"): cleaned = cleaned[:-3] cleaned = cleaned.strip() parsed = json.loads(cleaned) # Validate it has required keys if "reply" not in parsed or not isinstance(parsed.get("reply"), str): return None # Clean reply string generated directly by OpenAI parsed["reply"] = parsed["reply"].strip() if "state_updates" not in parsed or not isinstance(parsed.get("state_updates"), dict): parsed["state_updates"] = {} if "completed" not in parsed: parsed["completed"] = False return parsed def _simulate_specialty_response( self, session, specialty: dict, msg: str, current_state: dict, turn_count: int, suggested_specialty: dict = None, last_ai_message: str = None, ) -> dict: lang = (session.language or "ta-IN").lower() msg_lower = msg.lower() # ── STEP 1: Emergency / Crisis detection ── is_crisis = any(k in msg_lower for k in CRISIS_KEYWORDS_EN) or any(k in msg for k in CRISIS_KEYWORDS_EN) or any(k in msg for k in CRISIS_KEYWORDS_INDIC) is_emergency = any(k in msg_lower for k in EMERGENCY_KEYWORDS_EN) or any(k in msg for k in EMERGENCY_KEYWORDS_INDIC) if is_crisis: if lang.startswith("ta"): reply = "உங்கள் மன நிலை எங்களுக்கு புரிகிறது. உடனடியாக உதவி பெற Tele-MANAS (14416) அல்லது 108 என்ற இலவச எண்ணை அழைக்கவும்." elif lang.startswith("hi"): reply = "आपकी परेशानी हम समझ सकते हैं। कृपया तुरंत मदद के लिए Tele-MANAS (14416) या 108 पर कॉल करें।" elif lang.startswith("te"): reply = "దయచేసి ఆందోళన చెందకండి. తక్షణ సహాయం కోసం Tele-MANAS (14416) లేదా 108 కి కాల్ చేయండి." elif lang.startswith("kn"): reply = "ದಯವಿಟ್ಟು ಧೈರ್ಯವಾಗಿರಿ. ತುರ್ತು ನೆರವಿಗಾಗಿ Tele-MANAS (14416) ಅಥವಾ 108 ಗೆ ಕರೆ ಮಾಡಿ." else: reply = "We care about your safety. Please immediately reach out to the 24/7 Tele-MANAS mental health helpline at 14416 or call 108." return { "reply": reply, "next_stage": "confirmation", "state_updates": {"additional_information": [{"category": "crisis_alert", "value": msg}]}, "completed": True, } if is_emergency: if lang.startswith("ta"): reply = "இதற்கு உடனடியாக தீவிர மருத்துவ சிகிச்சை தேவைப்படலாம். தயவுசெய்து உடனே 108 அவசர ஆம்புலன்ஸை அழைக்கவும் அல்லது அவசர சிகிச்சை பிரிவுக்கு செல்லவும்." elif lang.startswith("hi"): reply = "इसके लिए तुरंत आपातकालीन चिकित्सा की आवश्यकता हो सकती है। कृपया तुरंत 108 एम्बुलेंस बुलाएं या नजदीकी अस्पताल जाएं।" elif lang.startswith("te"): reply = "దీనికి తక్షణ వైద్య సహాయం అవసరం కావచ్చు. దయచేసి వెంటనే 108 కి కాల్ చేయండి." elif lang.startswith("kn"): reply = "ಇದಕ್ಕೆ ತಕ್ಷಣದ ಚಿಕಿತ್ಸೆ ಅಗತ್ಯವಿದೆ. ದಯವಿಟ್ಟು ಕೂಡಲೇ 108 ಆಂಬುಲೆನ್ಸ್‌ಗೆ ಕರೆ ಮಾಡಿ." else: reply = "This may require immediate medical attention. Please call 108 for an ambulance or visit the nearest emergency room immediately." return { "reply": reply, "next_stage": "confirmation", "state_updates": {"additional_information": [{"category": "emergency_alert", "value": msg}]}, "completed": True, } # ── STEP 2: Build closing text ── if lang.startswith("ta"): conclusion_text = "சரிங்க, எல்லா விவரத்தையும் டாக்டர்கிட்ட குடுத்துடுறேன். ரொம்ப நன்றிங்க!" elif lang.startswith("hi"): conclusion_text = "ठीक है जी, मैं यह सारी जानकारी डॉक्टर साहब को दे देती हूँ। शुक्रिया!" elif lang.startswith("te"): conclusion_text = "చాలా ధన్యవాదాలు! మీ వివరాలన్నీ డాక్టర్ గారికి అందిస్తాను." elif lang.startswith("kn"): conclusion_text = "ಸರಿ, ಈ ಎಲ್ಲಾ ವಿವರಗಳನ್ನು ಡಾಕ್ಟರ್‌ಗೆ ತಿಳಿಸ್ತೀನಿ. ಧನ್ಯವಾದಗಳು!" else: conclusion_text = "Got it. I have compiled all these details for the doctor. Thank you!" # ── STEP 3: Extract facts from patient message ── extracted_updates = self._extract_facts_from_message( msg, msg_lower, last_ai_message=last_ai_message, current_state=current_state, ) # ── STEP 4: Handle "nothing else" / negation with contextual confirmed_none ── is_negative = is_negative_expression(msg) if is_negative: expected_intent = detect_previous_question_intent(last_ai_message) if expected_intent in ["medical_history", "history"]: if "medical_history" not in extracted_updates or not extracted_updates.get("medical_history"): extracted_updates["medical_history"] = {"status": "confirmed_none", "items": []} elif expected_intent in ["medications", "medicine"]: if "medications" not in extracted_updates or not extracted_updates.get("medications"): extracted_updates["medications"] = {"status": "confirmed_none", "items": []} elif expected_intent in ["allergies", "allergy"]: if "allergies" not in extracted_updates or not extracted_updates.get("allergies"): extracted_updates["allergies"] = {"status": "confirmed_none", "items": []} elif expected_intent in ["medications_and_allergies"]: if "medications" not in extracted_updates or not extracted_updates.get("medications"): extracted_updates["medications"] = {"status": "confirmed_none", "items": []} if "allergies" not in extracted_updates or not extracted_updates.get("allergies"): extracted_updates["allergies"] = {"status": "confirmed_none", "items": []} # ── STEP 5: Merge and check what is still missing ── merged_preview = merge_state_updates(current_state, extracted_updates) required_missing = check_required_missing(merged_preview) is_psych = (specialty.get("code") == "psychiatry") or (suggested_specialty and suggested_specialty.get("code") == "psychiatry") # ── STEP 6: If negative and all required is collected → complete ── if is_negative and not required_missing: return { "reply": conclusion_text, "next_stage": "confirmation", "state_updates": extracted_updates, "completed": True, } # ── STEP 7: If all required is collected (even without negation) → complete ── if not required_missing: return { "reply": conclusion_text, "next_stage": "confirmation", "state_updates": extracted_updates, "completed": True, } # ── STEP 8: Ask ONE best question for the highest priority missing field ── q = _pick_best_question(required_missing, lang, is_psych) next_stg = _stage_for_missing(required_missing[0]) return { "reply": q, "next_stage": next_stg, "state_updates": extracted_updates, "completed": False, } def _extract_facts_from_message( self, msg: str, msg_lower: str, last_ai_message: str = None, current_state: dict = None, ) -> dict: updates = {} # Detect confirmed_none expressions for specific categories confirmed_none_meds_patterns = [ "எந்த மாத்திரையும்", "மாத்திரை சாப்பிடல", "மாத்திரையும் சாப்பிடல", "no medicine", "no medication", "not taking any", "no tablets", "no pills", "कोई दवाई नहीं", "दवाई नहीं", ] confirmed_none_allergy_patterns = [ "எந்த அலர்ஜியும் இல்லை", "அலர்ஜி இல்லை", "அலர்ஜி இல்ல", "no allergy", "no allergies", "not allergic", "nkda", "कोई एलर्जी नहीं", "एलर्जी नहीं", ] confirmed_none_history_patterns = [ "எந்த நோயும் இல்லை", "நோய் இல்லை", "நோயும் இல்ல", "உடம்பு பிரச்சனை இல்ல", "பிரச்சனை இல்ல", "no medical history", "no chronic", "no diseases", "no conditions", "कोई बीमारी नहीं", "बीमारी नहीं", ] curr_meds_status = _field_status(current_state.get("medications")) if isinstance(current_state, dict) else "not_collected" curr_alg_status = _field_status(current_state.get("allergies")) if isinstance(current_state, dict) else "not_collected" curr_mh_status = _field_status(current_state.get("medical_history")) if isinstance(current_state, dict) else "not_collected" if curr_meds_status != "reported": for pat in confirmed_none_meds_patterns: if pat in msg_lower or pat in msg: updates["medications"] = {"status": "confirmed_none", "items": []} break if curr_alg_status != "reported": for pat in confirmed_none_allergy_patterns: if pat in msg_lower or pat in msg: updates["allergies"] = {"status": "confirmed_none", "items": []} break if curr_mh_status != "reported": for pat in confirmed_none_history_patterns: if pat in msg_lower or pat in msg: updates["medical_history"] = {"status": "confirmed_none", "items": []} break # If negative expression or casual filler without clinical content, return confirmed_none updates only if is_negative_expression(msg) or (is_casual_filler(msg) and not any(k in msg_lower for k in ["வலி", "pain", "fever", "காய்ச்சல்", "cough", "இருமல்"])): return updates expected_intent = detect_previous_question_intent(last_ai_message) # 1. Symptoms & Attributes Extraction symptoms = [] symptom_keywords = [ ("stomach pain", "stomach pain"), ("stomach ache", "stomach pain"), ("gastric", "gastric pain"), ("vomiting", "vomiting"), ("vomited", "vomiting"), ("nausea", "nausea"), ("headache", "headache"), ("head ache", "headache"), ("fever", "fever"), ("temperature", "fever"), ("chills", "chills"), ("cough", "cough"), ("cold", "cold"), ("sore throat", "sore throat"), ("chest pain", "chest pain"), ("chest tightness", "chest tightness"), ("breathless", "breathlessness"), ("breathing difficulty", "breathlessness"), ("dizziness", "dizziness"), ("giddiness", "dizziness"), ("knee pain", "knee pain"), ("joint pain", "joint pain"), ("back pain", "back pain"), ("swelling", "swelling"), ("rash", "skin rash"), ("itching", "itching"), ("depression", "depression"), ("anxiety", "anxiety"), ("stress", "stress"), ("insomnia", "insomnia"), ("cannot sleep", "sleep difficulty"), ("couldn't sleep", "sleep difficulty"), # Tamil ("வயிற்று வலி", "வயிற்று வலி"), ("வாந்தி", "வாந்தி"), ("தலைவலி", "தலைவலி"), ("காய்ச்சல்", "காய்ச்சல்"), ("இருமல்", "இருமல்"), ("சளி", "சளி"), ("நெஞ்சு வலி", "நெஞ்சு வலி"), ("மூச்சு திணறல்", "மூச்சு திணறல்"), ("மூச்சு வாங்குது", "மூச்சு திணறல்"), ("மூச்சு வாங்குதல்", "மூச்சு திணறல்"), ("மூட்டு வலி", "மூட்டு வலி"), ("தோல் அரிப்பு", "தோல் அரிப்பு"), ("மன அழுத்தம்", "மன அழுத்தம்"), ("கை கால் வலி", "உடல் வலி"), ("உடல் வலி", "உடல் வலி"), ("உடம்பு வலி", "உடல் வலி"), ("தலை சுத்துது", "தலைச்சுற்றல்"), ("தலைசுற்றல்", "தலைச்சுற்றல்"), ("படபடப்பு", "நெஞ்சு படபடப்பு"), ("படபடன்னு", "நெஞ்சு படபடப்பு"), ("தூக்கம் சரியா வரல", "தூக்கமின்மை"), ("தூக்கமின்மை", "தூக்கமின்மை"), # Hindi ("पेट दर्द", "पेट दर्द"), ("उल्टी", "उल्टी"), ("सिरदर्द", "सिरदर्द"), ("बुखार", "बुखार"), ("खांसी", "खांसी"), ("छाती में दर्द", "छाती में दर्द"), ("सांस लेने में तकलीफ", "सांस लेने में तकलीफ"), ] found_sym_names = set() for kw, sym_name in symptom_keywords: if kw in msg_lower or kw in msg: if sym_name not in found_sym_names: found_sym_names.add(sym_name) # Detect normalized duration duration = normalize_duration_from_message(msg) # Detect severity & location severity = extract_severity_from_message(msg) location = extract_location_from_message(msg) # Detect frequency (e.g. "twice", "3 times", "irandu murai") freq = None freq_m = re.search(r"(twice|once|\d+\s*times|ரெண்டு வாட்டி|இரண்டு முறை|दो बार|तीन बार)", msg_lower) if freq_m: freq = freq_m.group(1).strip() # Build structured symptoms if found_sym_names: for sname in found_sym_names: s_obj = {"name": sname} if duration: s_obj["duration"] = duration if severity: s_obj["severity"] = severity if location: s_obj["location"] = location if freq and ("vomit" in sname or "வாந்தி" in sname or "உल्टी" in sname): s_obj["frequency"] = freq symptoms.append(s_obj) updates["symptoms"] = symptoms updates["reason_for_visit"] = list(found_sym_names)[0] elif duration or severity or location: attr_obj = {} if duration: attr_obj["duration"] = duration if severity: attr_obj["severity"] = severity if location: attr_obj["location"] = location updates["symptoms"] = [attr_obj] elif ( msg.strip() and len(msg.split()) < 12 and not is_duration_expression(msg) and not is_severity_expression(msg) and not is_location_expression(msg) and not is_casual_filler(msg) and not is_negative_expression(msg) and not any(k in msg_lower or k in msg for k in [ "metformin", "dolo", "paracetamol", "pantocid", "telmisartan", "amlodipine", "tablets", "medicine", "pill", "pills", "goli", "mathirai", "மாத்திரை", "மருந்து", "சாப்பிடுறேன்", "சாப்பிட்டேன்", "வாங்குறேன்", "दवाई", "दवा", "diabetes", "bp", "hypertension", "asthma", "thyroid", "சர்க்கரை", "பிபி", "ஆஸ்துமா", "நோயும்", "வியாதி", "बीमारी", "शुगर", "allergy", "allergies", "allergic", "அலர்ஜி", "ஒவ்வாமை", "एलर्जी" ]) ): updates["reason_for_visit"] = msg.strip() updates["symptoms"] = [{"name": msg.strip()}] # 2. Medical History Extraction (e.g. "diabetes for 8 years", "BP", "hypertension", "asthma") med_history = [] 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"), ] for kw, cond in history_keywords: if re.search(rf"\b{re.escape(kw)}\b", msg_lower) or kw in msg: h_obj = {"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 # 3. Medications Extraction (e.g. "Metformin 500 mg twice a day", "Paracetamol", "Dolo 650") is_allergy_utterance = any(k in msg_lower or k in msg for k in ["allergy", "allergies", "allergic", "அலர்ஜி", "ஒவ்வாமை", "एलर्जी", "rash", "reaction"]) if updates.get("medications", {}).get("status") != "confirmed_none": meds = [] 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"), ] if not is_allergy_utterance: med_patterns.append((r"(tablets|goli|mathirai|மாத்திரை)", "Tablets (unspecified)")) for pat, med_label in med_patterns: m_match = re.search(pat, msg_lower) if m_match: matched_str = m_match.group(1).strip() med_obj = {"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) if meds: updates["medications"] = meds # 4. Allergies Extraction (e.g. "allergic to penicillin", "causes rash", "penicillin எடுத்தா rash") allergies = [] alg_patterns = [ (r"(penicillin)", "Penicillin"), (r"(sulfa)", "Sulfa drugs"), (r"(aspirin)", "Aspirin"), (r"(dust)", "Dust allergy"), (r"(peanuts)", "Peanuts"), ] has_allergen = any(re.search(pat, msg_lower) for pat, _ in alg_patterns) has_alg_indicator = any(k in msg_lower or k in msg for k in ["allergic", "allergy", "அலர்ஜி", "ஒவ்வாமை", "एलर्जी", "rash", "reaction", "side effect", "தடிப்பு", "எடுத்தா"]) if has_allergen or has_alg_indicator: for pat, allergen in alg_patterns: if re.search(pat, msg_lower): alg_obj = {"name": allergen} if "rash" in msg_lower or "தடிப்பு" in msg or "खुजली" in msg: alg_obj["reaction"] = "Rash / Skin reaction" allergies.append(alg_obj) if not allergies and has_alg_indicator: allergies.append({"name": "Reported allergy", "type": "unspecified"}) if allergies: updates["allergies"] = allergies # 5. Surgical History & Previous Episodes if "surgery" in msg_lower or "operation" in msg_lower or "ஆபரேஷன்" in msg: 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 msg: updates["previous_episodes"] = [{"description": "Similar symptoms previously", "time": duration or "Past"}] # 6. Additional Information / Functional Impact add_info = [] if "couldn't eat" in msg_lower or "not eaten" in msg_lower or "சாப்பிட முடியல" in msg: 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 msg: add_info.append({"category": "functional_impact", "value": "Sleep disruption"}) if "weak" in msg_lower or "tired" in msg_lower or "ரொம்ப டயர்டா இருக்கு" in msg: add_info.append({"category": "functional_impact", "value": "Severe weakness / fatigue"}) if add_info: updates["additional_information"] = add_info return updates