Files
aionboarding/ai-patient-onboarding/app/ai/orchestrator.py
T

562 lines
27 KiB
Python

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}