import json import re import os from typing import Dict, Any, List, Optional, Union 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, ) from app.ai.prompts import build_system_prompt # Import domain and sub-modules from app.ai.safety.emergency import check_emergency from app.ai.safety.crisis import check_crisis from app.ai.extraction.multilingual_normalizer import normalize_text, detect_language from app.ai.extraction.symptom_extractor import extract_symptoms from app.ai.extraction.medication_extractor import extract_medications from app.ai.extraction.allergy_extractor import extract_allergies from app.ai.extraction.history_extractor import extract_history from app.ai.extraction.negation_engine import is_negated, extract_family_subject from app.ai.conversation.completion_engine import check_required_missing, field_status as _field_status, is_onboarding_complete from app.ai.conversation.question_engine import pick_best_question as _pick_best_question from app.ai.conversation.intent_tracker import get_last_ai_intent from app.ai.conversation.response_formatter import format_conversational_response from app.ai.conversation.next_best_action import NextBestActionEngine from app.ai.conversation.response_generator import ResponseGenerator from app.domain.models.contracts import ResponseContext from app.ai.providers.openai import OpenAIProvider from app.ai.providers.gemini import GeminiProvider SPECIALTY_RULES = [ { "code": "mental_health", "name": "Mental Health & Psychiatry", "keywords": [ "depression", "depressed", "mental", "anxiety", "panic", "stress", "sadness", "suicidal", "suicide", "sleep", "insomnia", "emotional", "mood", "lonely", "hopeless", "மன அழுத்தம்", "மனச்சோர்வு", "தூக்கமின்மை", "கவலை", "உணர்ச்சி", "தற்கொலை", "டிப்ரெஷன்", "டிப்ரஷன்", "डिप्रेशन", "तनाव", "चिंता", "उदासी", "नींद न आना", "आत्महत्या", "మానసిక ఒత్తిడి", "డిప్రెషన్", "నిద్రలేమి", "ಮನೋಒತ್ತಡ", "ಖಿನ್ನತೆ", "ನಿದ್ರಾಹೀನತೆ" ] }, { "code": "diabetology", "name": "Diabetology & Endocrinology (Sugar / Diabetes)", "keywords": [ "sugar level", "diabetes", "diabetic", "hba1c", "glycomet", "blood sugar", "high glucose", "low glucose", "insulin", "chakkara noyu", "cheeni bimari", "fasting sugar", "ppbs", "metformin", "teneligliptin", "glimepiride", "janumet", "சர்க்கரை", "நீரிழிவு", "சர்க்கரை நோய்", "शुगर", "मधुमेह", "చక్కెర", "సక్కರೆ" ] }, { "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", "name": "General Physician & OPD Intake", "keywords": [ "fever", "weakness", "body pain", "fatigue", "tired", "chills", "weight loss", "bp", "காய்ச்சல்", "உடல் வலி", "உடம்பு வலி", "சோர்வு", "குளிர்", "बुखार", "कमजोरी", "बदन दर्द" ] } ] def classify_specialty_from_symptoms(text: str) -> Dict[str, str]: if not text: return {"code": "general", "name": "General Physician & OPD Intake"} msg_lower = text.lower() for spec in SPECIALTY_RULES: if spec["code"] in ("general", "general_medicine"): continue for kw in spec["keywords"]: pattern = rf"\b{re.escape(kw.lower())}\b" if re.search(pattern, msg_lower) or kw.lower() in msg_lower: code = spec["code"] if code == "psychiatry": code = "mental_health" elif code == "general_medicine": code = "general" return {"code": code, "name": spec["name"]} return {"code": "general", "name": "General Physician & OPD Intake"} def _normalize_key(name: str) -> str: if not name: return "" n = name.lower().strip() synonyms = { "headache": "headache", "head pain": "headache", "migraine": "headache", "தலைவலி": "headache", "தலை பாரம்": "headache", "सिरदर्द": "headache", "తలనొప్పి": "headache", "ತಲೆನೋವು": "headache", "fever": "fever", "feverish": "fever", "காய்ச்சல்": "fever", "बुखार": "fever", "జ్వరం": "fever", "ಜ್ವರ": "fever", "cough": "cough", "coughing": "cough", "இருமல்": "cough", "खांसी": "cough", "దగ్గు": "cough", "ಕೆಮ್ಮು": "cough", "chest pain": "chest pain", "நெஞ்சு வலி": "chest pain", "छाती में दर्द": "chest pain", "stomach pain": "stomach pain", "வயிற்று வலி": "stomach pain", "पेट दर्द": "stomach pain", "vomiting": "vomiting", "vomit": "vomiting", "vomited": "vomiting", "வாந்தி": "vomiting", "உल्टी": "vomiting", "dizziness": "dizziness", "giddiness": "dizziness", "தலை சுத்துது": "dizziness", "தலைசுற்றல்": "dizziness", "மயக்கம்": "dizziness", "चक्कर": "dizziness", "diabetes": "diabetes", "sugar": "diabetes", "சர்க்கரை": "diabetes", "hypertension": "hypertension", "bp": "hypertension", "high bp": "hypertension", "பிபி": "hypertension", } return synonyms.get(n, n) def merge_state_updates(existing_state: Dict[str, Any], new_updates: Dict[str, Any]) -> Dict[str, Any]: if not existing_state: existing_state = {} if not new_updates: return existing_state merged = dict(existing_state) for key, val in new_updates.items(): if val is None: continue if key == "reason_for_visit": if not merged.get("reason_for_visit") or (isinstance(val, str) and val.strip()): merged["reason_for_visit"] = val continue if key in ["medical_history", "medications", "allergies"]: if isinstance(val, dict) and val.get("status") == "confirmed_none": existing_val = merged.get(key) if isinstance(existing_val, list) and len(existing_val) > 0: conflicts = merged.get("conflicts", []) conflicts.append({"field": key, "type": "conflict_none_vs_reported", "item": existing_val[0]}) merged["conflicts"] = conflicts if not existing_val or _field_status(existing_val) == "not_collected": merged[key] = val continue if isinstance(val, dict) and val.get("status") == "reported": merged[key] = val continue if key == "symptoms": existing_syms = merged.get("symptoms", []) if not isinstance(existing_syms, list): existing_syms = [] new_syms = val if isinstance(val, list) else [val] merged_syms_map: Dict[str, Dict[str, Any]] = {} for s in existing_syms: if isinstance(s, dict) and s.get("name"): norm_k = _normalize_key(s["name"]) merged_syms_map[norm_k] = dict(s) for s in new_syms: if isinstance(s, dict) and s.get("name"): norm_k = _normalize_key(s["name"]) if norm_k in merged_syms_map: existing_obj = merged_syms_map[norm_k] for attr_k, attr_v in s.items(): if attr_v: existing_obj[attr_k] = attr_v else: merged_syms_map[norm_k] = dict(s) elif isinstance(s, dict): if merged_syms_map: first_k = list(merged_syms_map.keys())[0] for attr_k, attr_v in s.items(): if attr_v: merged_syms_map[first_k][attr_k] = attr_v merged["symptoms"] = list(merged_syms_map.values()) if merged_syms_map else existing_syms continue if key in ["medical_history", "medications", "allergies"]: existing_field = merged.get(key) if isinstance(existing_field, dict) and existing_field.get("status") == "confirmed_none": new_items = val if isinstance(val, list) else [val] if new_items: conflicts = merged.get("conflicts", []) conflicts.append({"field": key, "type": "conflict_reported_vs_none", "item": new_items[0]}) merged["conflicts"] = conflicts merged[key] = new_items continue existing_list = merged.get(key, []) if isinstance(existing_field, dict) and "items" in existing_field: existing_list = existing_field.get("items", []) if not isinstance(existing_list, list): existing_list = [existing_list] if existing_list else [] new_items = val if isinstance(val, list) else [val] # Merge items by condition/name key to prevent duplicates and support enrichment item_map: Dict[str, Dict[str, Any]] = {} for item in existing_list: if isinstance(item, dict): k = item.get("condition") or item.get("name") if k: item_map[k] = dict(item) for item in new_items: if isinstance(item, dict): k = item.get("condition") or item.get("name") if k: if k in item_map: for ik, iv in item.items(): if iv: item_map[k][ik] = iv else: item_map[k] = dict(item) if item_map: merged[key] = list(item_map.values()) else: merged[key] = existing_list + [v for v in new_items if v not in existing_list] continue if key in ["surgical_history", "previous_episodes", "family_history", "additional_information"]: 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_has_items(val: Any) -> bool: return _field_status(val) in ["reported", "confirmed_none"] def _any_symptom_has_duration(symptoms: Any) -> bool: if isinstance(symptoms, list): return any(isinstance(s, dict) and bool(s.get("duration")) for s in symptoms) return False 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") class AIOrchestrator: def __init__(self): self.openai_provider = OpenAIProvider(timeout_seconds=4.0) self.gemini_provider = GeminiProvider(timeout_seconds=4.0) self.openai = OpenAIClient() self.gemini = GeminiClient() self.nba_engine = NextBestActionEngine() self.response_generator = ResponseGenerator() async def process( self, session: Any, state: Any, conversation_history: List[Dict[str, Any]], user_message: str ) -> Dict[str, Any]: lang = getattr(session, "language", "en") or "en" current_state = json.loads(state.state_json) if hasattr(state, "state_json") and state.state_json else {} # ------------------------------------------------------------- # STEP 1: CRISIS DETECTION (PRECEDENCE OVER ALL ONBOARDING) # ------------------------------------------------------------- is_crisis, crisis_reason, crisis_reply = check_crisis(user_message, lang) if is_crisis: return { "reply": format_conversational_response(crisis_reply, lang, scenario="crisis"), "next_stage": "crisis_handling", "state_updates": {}, "completed": True, "is_crisis": True, "suggested_specialty": None } # ------------------------------------------------------------- # STEP 2: EMERGENCY DETECTION (PRECEDENCE OVER ALL ONBOARDING) # ------------------------------------------------------------- is_emergency, emergency_reason, emergency_reply = check_emergency(user_message, lang) if is_emergency: return { "reply": format_conversational_response(emergency_reply, lang, scenario="emergency"), "next_stage": "emergency_referral", "state_updates": {}, "completed": True, "is_emergency": True, "suggested_specialty": None } # ------------------------------------------------------------- # STEP 3: MULTILINGUAL NORMALIZATION & FACT EXTRACTION # ------------------------------------------------------------- normalized_msg = normalize_text(user_message) last_ai_intent = get_last_ai_intent(conversation_history) last_ai_message = conversation_history[-1].get("message") if conversation_history else None extracted_updates = self._extract_facts_from_message( user_message=user_message, normalized_msg=normalized_msg, last_ai_message=last_ai_message, current_state=current_state ) # ------------------------------------------------------------- # STEP 4: STATE MERGE & SPECIALTY SELECTION # ------------------------------------------------------------- merged_preview = merge_state_updates(current_state, extracted_updates) # Specialty classification (preserving existing non-general specialty) existing_code = getattr(session, "category_code", "general_medicine") existing_name = getattr(session, "category_name", "General Medicine") is_duration_msg = is_duration_expression(user_message) or bool(normalize_duration_from_message(user_message)) if existing_code and existing_code != "general_medicine" and is_duration_msg: spec_result = {"code": existing_code, "name": existing_name} else: raw_spec = classify_specialty_from_symptoms(user_message) if existing_code and existing_code != "general_medicine" and raw_spec["code"] == "general_medicine": spec_result = {"code": existing_code, "name": existing_name} else: spec_result = raw_spec is_psych = (spec_result.get("code") == "psychiatry") # ------------------------------------------------------------- # STEP 5: DETERMINISTIC COMPLETION & MISSING FIELD EVALUATION # ------------------------------------------------------------- required_missing = check_required_missing(merged_preview) action_type, target_field = self.nba_engine.determine_next_action( current_state=merged_preview, is_emergency=False, is_crisis=False, is_psych=is_psych, missing_fields=required_missing ) completed = (action_type == "COMPLETE" or len(required_missing) == 0) if completed: conclusion_reply = _pick_best_question([], lang, is_psych=is_psych, current_state=merged_preview) formatted_reply = format_conversational_response(conclusion_reply, lang, scenario="completion") return { "reply": formatted_reply, "next_stage": "confirmation", "state_updates": extracted_updates, "completed": True, "suggested_specialty": spec_result } # Select single next question next_q = _pick_best_question(required_missing, lang, is_psych=is_psych, current_state=merged_preview) next_stage = _stage_for_missing(target_field or required_missing[0]) # ------------------------------------------------------------- # STEP 6: AI RESPONSE GENERATION WITH HARD 4s TIMEOUT & FALLBACK # ------------------------------------------------------------- context = { "specialty_name": spec_result.get("name", "General Medicine"), "patient": {"name": getattr(session, "patient_name", "Patient"), "age": 30, "gender": "Unknown"}, "language": lang, "current_state": merged_preview, "conversation_history": conversation_history or [], "new_patient_message": user_message, "target_question": next_q, } system_prompt = build_system_prompt(context) raw_text = await self.openai_provider.generate_response_with_timeout(system_prompt, user_message) if not raw_text: raw_text = await self.gemini_provider.generate_response_with_timeout(system_prompt, user_message) if raw_text: try: cleaned = raw_text.strip().replace("```json", "").replace("```", "").strip() parsed = json.loads(cleaned) validated = AIOnboardingResponse(**parsed) llm_dict = validated.model_dump() llm_dict["state_updates"] = merge_state_updates(extracted_updates, llm_dict.get("state_updates") or {}) llm_dict["reply"] = format_conversational_response(llm_dict["reply"], lang, scenario="normal") llm_dict["completed"] = False llm_dict["next_stage"] = next_stage llm_dict["suggested_specialty"] = spec_result return llm_dict except Exception as e: print(f"[AIOrchestrator LLM Validation Error]: {e}") # ------------------------------------------------------------- # STEP 7: DETERMINISTIC FALLBACK (INSTANT) # ------------------------------------------------------------- formatted_reply = format_conversational_response(next_q, lang, scenario="normal") return { "reply": formatted_reply, "next_stage": next_stage, "state_updates": extracted_updates, "completed": False, "suggested_specialty": spec_result } def _extract_facts_from_message( self, user_message: str, normalized_msg: str, last_ai_message: Optional[str] = None, current_state: Optional[Dict[str, Any]] = None ) -> Dict[str, Any]: msg_lower = normalized_msg.lower().strip() updates: Dict[str, Any] = {} duration = normalize_duration_from_message(user_message) severity = extract_severity_from_message(user_message) location = extract_location_from_message(user_message) last_intent = detect_previous_question_intent(last_ai_message) if last_ai_message else None # 1. Symptoms & Reason for Visit found_symptoms, primary_rfv = extract_symptoms(user_message, normalized_msg, current_state) if found_symptoms: updates["symptoms"] = found_symptoms if primary_rfv: updates["reason_for_visit"] = primary_rfv elif (duration or severity or location) and current_state and current_state.get("symptoms"): # Duration, Severity, or Location standalone follow-up turn existing_syms = current_state.get("symptoms", []) if isinstance(existing_syms, list) and len(existing_syms) > 0 and isinstance(existing_syms[0], dict): updated_syms = [dict(s) for s in existing_syms] if duration: updated_syms[0]["duration"] = duration if severity: updated_syms[0]["severity"] = severity if location: updated_syms[0]["location"] = location updates["symptoms"] = updated_syms # 2. Medical History history_updates = extract_history(user_message, normalized_msg, duration) updates.update(history_updates) # 3. Medications is_alg_uttr = "allergy" in msg_lower or "அலர்ஜி" in user_message or "एलर्जी" in user_message meds_res = extract_medications(user_message, normalized_msg, last_ai_message, is_allergy_utterance=is_alg_uttr) if meds_res is not None: updates["medications"] = meds_res # 4. Allergies alg_res = extract_allergies(user_message, normalized_msg, last_ai_message) if alg_res is not None: updates["allergies"] = alg_res # Contextual intent follow-up mapping if last_intent == "medical_history" and is_negative_expression(user_message): updates["medical_history"] = {"status": "confirmed_none", "items": []} if last_intent == "medications" and is_negative_expression(user_message): updates["medications"] = {"status": "confirmed_none", "items": []} if last_intent == "allergies" and is_negative_expression(user_message): updates["allergies"] = {"status": "confirmed_none", "items": []} if last_intent == "medications_and_allergies" and is_negative_expression(user_message): updates["medications"] = {"status": "confirmed_none", "items": []} updates["allergies"] = {"status": "confirmed_none", "items": []} return updates def _simulate_specialty_response( self, session: Any, specialty: str, user_message: str, history_length: int ) -> Dict[str, Any]: lang = getattr(session, "language", "en") or "en" is_psych = (specialty == "psychiatry") spec_info = {"code": specialty, "name": specialty.replace("_", " ").title()} is_crisis, _, crisis_reply = check_crisis(user_message, lang) if is_crisis: return {"reply": crisis_reply, "next_stage": "crisis_handling", "state_updates": {}, "completed": True, "suggested_specialty": spec_info} is_emergency, _, emergency_reply = check_emergency(user_message, lang) if is_emergency: return {"reply": emergency_reply, "next_stage": "emergency_referral", "state_updates": {}, "completed": True, "suggested_specialty": spec_info} updates = self._extract_facts_from_message(user_message, normalize_text(user_message)) missing = check_required_missing(updates) completed = (len(missing) == 0) if completed: reply = _pick_best_question([], lang, is_psych=is_psych) return {"reply": format_conversational_response(reply, lang), "next_stage": "confirmation", "state_updates": updates, "completed": True, "suggested_specialty": spec_info} reply = _pick_best_question(missing, lang, is_psych=is_psych) stage = _stage_for_missing(missing[0]) return {"reply": format_conversational_response(reply, lang), "next_stage": stage, "state_updates": updates, "completed": False, "suggested_specialty": spec_info}