import os import sys import json import pytest from unittest.mock import MagicMock, AsyncMock # Add project root to sys.path sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) from app.ai.orchestrator import ( AIOrchestrator, merge_state_updates, classify_specialty_from_symptoms, check_required_missing, _field_status, _field_has_items, _any_symptom_has_duration, ) from app.services.contextual_normalizer import normalize_duration_from_message, detect_previous_question_intent from app.ai.prompts import build_system_prompt from app.services.completion_service import CompletionService from app.schemas.onboarding import ( PatientOnboardingResult, StartOnboardingResponse, SendMessageResponse, CompleteOnboardingResponse, CategoryContext, AIOnboardingResponse, ) @pytest.fixture def mock_session(): session = MagicMock() session.session_id = "ONB-TEST-001" session.patient_id = "PAT-001" session.patient_name = "Ramesh Kumar" session.age = 35 session.gender = "male" session.category_type = "general" session.category_code = "general_medicine" session.category_name = "General Medicine" session.language = "en-IN" session.stage = "greeting" session.status = "in_progress" return session @pytest.fixture def empty_state(): return { "reason_for_visit": None, "symptoms": [], "medical_history": [], "medications": [], "allergies": [], "surgical_history": [], "previous_episodes": [], "family_history": [], "additional_information": [], } # ===================================================================== # 1. STATE MERGING & DEDUPLICATION & ENRICHMENT TESTS # ===================================================================== def test_deduplication_medical_history(): """If patient mentions diabetes twice, it must deduplicate into one entry with latest duration.""" state = { "medical_history": [{"condition": "Diabetes"}] } updates = { "medical_history": [{"condition": "Diabetes", "duration": "8 years"}] } merged = merge_state_updates(state, updates) assert len(merged["medical_history"]) == 1 assert merged["medical_history"][0]["condition"] == "Diabetes" assert merged["medical_history"][0]["duration"] == "8 years" def test_progressive_enrichment_symptoms(): """State should become richer over turns without wiping existing details.""" # Turn 1: symptom name turn1_state = merge_state_updates({}, {"symptoms": [{"name": "headache"}]}) assert len(turn1_state["symptoms"]) == 1 assert turn1_state["symptoms"][0]["name"] == "headache" # Turn 2: duration added turn2_state = merge_state_updates(turn1_state, {"symptoms": [{"name": "headache", "duration": "2 days"}]}) assert len(turn2_state["symptoms"]) == 1 assert turn2_state["symptoms"][0]["name"] == "headache" assert turn2_state["symptoms"][0]["duration"] == "2 days" # Turn 3: severity and location added turn3_state = merge_state_updates( turn2_state, {"symptoms": [{"name": "headache", "severity": "severe", "location": "front of head"}]} ) assert len(turn3_state["symptoms"]) == 1 assert turn3_state["symptoms"][0]["name"] == "headache" assert turn3_state["symptoms"][0]["duration"] == "2 days" assert turn3_state["symptoms"][0]["severity"] == "severe" assert turn3_state["symptoms"][0]["location"] == "front of head" def test_patient_correction_handling(): """If patient corrects information, the latest explicit value must be stored.""" state = { "symptoms": [{"name": "stomach pain", "duration": "yesterday"}] } # Patient later says: "Actually it started five days ago" updates = { "symptoms": [{"name": "stomach pain", "duration": "five days ago"}] } merged = merge_state_updates(state, updates) assert len(merged["symptoms"]) == 1 assert merged["symptoms"][0]["duration"] == "five days ago" def test_multi_symptom_list_merging(): """Multiple distinct symptoms should all be retained in state.""" state = { "symptoms": [{"name": "fever", "duration": "3 days"}] } updates = { "symptoms": [ {"name": "headache"}, {"name": "body pain"} ] } merged = merge_state_updates(state, updates) assert len(merged["symptoms"]) == 3 names = [s["name"] for s in merged["symptoms"]] assert "fever" in names assert "headache" in names assert "body pain" in names def test_null_preservation(): """Passing None or empty values should not overwrite valid existing data.""" state = { "reason_for_visit": "chest tightness", "symptoms": [{"name": "chest tightness", "duration": "2 hours"}], "medications": [{"name": "Amlodipine"}] } updates = { "reason_for_visit": None, "allergies": [{"name": "Penicillin"}] } merged = merge_state_updates(state, updates) assert merged["reason_for_visit"] == "chest tightness" assert len(merged["symptoms"]) == 1 assert merged["symptoms"][0]["duration"] == "2 hours" assert len(merged["medications"]) == 1 assert len(merged["allergies"]) == 1 # ===================================================================== # 2. TAMIL / TANGLISH / ENGLISH DURATION EXTRACTION UNIT TESTS # ===================================================================== def test_duration_normalization_numeric_english(): """Test standard numeric English duration phrases.""" assert normalize_duration_from_message("10 days") == "10 days" assert normalize_duration_from_message("for 10 days") == "10 days" assert normalize_duration_from_message("2 days") == "2 days" assert normalize_duration_from_message("3 weeks") == "3 weeks" assert normalize_duration_from_message("6 months") == "6 months" assert normalize_duration_from_message("since yesterday") == "since yesterday" assert normalize_duration_from_message("since last week") == "since last week" assert normalize_duration_from_message("since Monday") == "since Monday" assert normalize_duration_from_message("for a few days") == "for a few days" assert normalize_duration_from_message("for many months") == "for many months" def test_duration_normalization_tamil(): """Test spoken and written Tamil duration phrases.""" assert normalize_duration_from_message("பத்து நாளா") == "10 days" assert normalize_duration_from_message("பத்து நாட்களா") == "10 days" assert normalize_duration_from_message("ஒரு பத்து நாளா") == "10 days" assert normalize_duration_from_message("ரெண்டு நாளா") == "2 days" assert normalize_duration_from_message("ரெண்டு நாட்களா") == "2 days" assert normalize_duration_from_message("மூணு நாளா") == "3 days" assert normalize_duration_from_message("நாலு நாளா") == "4 days" assert normalize_duration_from_message("ஒரு வாரமா") == "1 week" assert normalize_duration_from_message("ரெண்டு வாரமா") == "2 weeks" assert normalize_duration_from_message("ஒரு மாசமா") == "1 month" assert normalize_duration_from_message("ரெண்டு மாசமா") == "2 months" assert normalize_duration_from_message("நேத்துல இருந்து") == "since yesterday" assert normalize_duration_from_message("நேத்து இருந்து") == "since yesterday" assert normalize_duration_from_message("நேற்று முதல்") == "since yesterday" def test_duration_normalization_tanglish(): """Test Tanglish duration phrases.""" assert normalize_duration_from_message("10 days-ah") == "10 days" assert normalize_duration_from_message("10 days-a") == "10 days" assert normalize_duration_from_message("10 naala") == "10 days" assert normalize_duration_from_message("10 nala") == "10 days" assert normalize_duration_from_message("10 naal ah") == "10 days" assert normalize_duration_from_message("10 days irukku") == "10 days" assert normalize_duration_from_message("10 days-a irukku") == "10 days" assert normalize_duration_from_message("2 days-ah") == "2 days" assert normalize_duration_from_message("3 weeks-ah") == "3 weeks" assert normalize_duration_from_message("1 month-ah") == "1 month" assert normalize_duration_from_message("one week-ah") == "1 week" assert normalize_duration_from_message("oru 10 days-ah") == "10 days" assert normalize_duration_from_message("oru pathu naala") == "10 days" assert normalize_duration_from_message("rendu naala") == "2 days" assert normalize_duration_from_message("moonru naala") == "3 days" def test_duration_normalization_mixed(): """Test mixed Tamil + English duration phrases.""" assert normalize_duration_from_message("10 டேஸா இருக்கு") == "10 days" assert normalize_duration_from_message("10 டேஸ்-ஆ இருக்கு") == "10 days" assert normalize_duration_from_message("10 days-ah இருக்கு") == "10 days" assert normalize_duration_from_message("ஒரு 10 days-ah இருக்கு") == "10 days" assert normalize_duration_from_message("ஒரு 10 டேஸா இருக்கு") == "10 days" assert normalize_duration_from_message("ஒரு பத்து நாளா இருக்கு") == "10 days" assert normalize_duration_from_message("எனக்கு வந்து ஒரு 10 டேஸா இருக்கு.") == "10 days" assert normalize_duration_from_message("இன்னும் ஒரு பத்து நாளா இருக்கு.") == "10 days" # ===================================================================== # 3. REGRESSION TESTS FOR TAMIL / TANGLISH DURATION BUG # ===================================================================== def test_tamil_tanglish_duration_attaches_to_existing_symptom(mock_session): """ CRITICAL REGRESSION TEST: Existing state: {"symptoms": [{"name": "தலைவலி"}]} Patient: "எனக்கு வந்து ஒரு 10 டேஸா இருக்கு." Must attach duration="10 days" to "தலைவலி", resulting in exactly 1 symptom with duration. """ import asyncio mock_session.language = "ta-IN" orchestrator = AIOrchestrator() state_mock = MagicMock() state_mock.state_json = json.dumps({ "reason_for_visit": "தலைவலி", "symptoms": [{"name": "தலைவலி"}], "medical_history": [], "medications": [], "allergies": [] }) res = asyncio.run(orchestrator.process( session=mock_session, state=state_mock, conversation_history=[ {"role": "patient", "message": "தலைவலி இருக்கு", "stage": "greeting"}, {"role": "assistant", "message": "சரிங்க, இந்த பிரச்சனை எத்தனை நாளா இருக்குன்னு சொல்ல முடியுமா?", "stage": "symptom_details"} ], user_message="எனக்கு வந்து ஒரு 10 டேஸா இருக்கு." )) merged = merge_state_updates( {"symptoms": [{"name": "தலைவலி"}]}, res["state_updates"] ) symptoms = merged["symptoms"] if isinstance(symptoms, dict): symptoms = symptoms.get("items", []) assert len(symptoms) == 1 assert symptoms[0]["name"] == "தலைவலி" assert symptoms[0]["duration"] == "10 days" def test_duration_answer_does_not_create_new_symptom(): """Duration response must enrich existing symptom without adding a second symptom.""" existing_state = { "reason_for_visit": "தலைவலி", "symptoms": [{"name": "தலைவலி", "duration": None}] } # Deterministic updates for "10 டேஸா இருக்கு" orchestrator = AIOrchestrator() updates = orchestrator._extract_facts_from_message( "10 டேஸா இருக்கு", "10 டேஸா இருக்கு", last_ai_message="இந்த பிரச்சனை எத்தனை நாளா இருக்கு?", current_state=existing_state ) merged = merge_state_updates(existing_state, updates) symptoms = merged["symptoms"] assert len(symptoms) == 1 assert symptoms[0]["name"] == "தலைவலி" assert symptoms[0]["duration"] == "10 days" def test_duration_question_not_repeated_after_patient_answers(mock_session): """ After patient answers duration with '10 டேஸா இருக்கு', AI must NOT repeat the question 'எத்தனை நாளா இருக்கு'. """ import asyncio mock_session.language = "ta-IN" orchestrator = AIOrchestrator() state_mock = MagicMock() state_mock.state_json = json.dumps({ "reason_for_visit": "தலைவலி", "symptoms": [{"name": "தலைவலி"}], "medical_history": [], "medications": [], "allergies": [] }) res = asyncio.run(orchestrator.process( session=mock_session, state=state_mock, conversation_history=[ {"role": "patient", "message": "தலைவலி இருக்கு", "stage": "greeting"}, {"role": "assistant", "message": "சரிங்க, இந்த பிரச்சனை எத்தனை நாளா இருக்குன்னு சொல்ல முடியுமா?", "stage": "symptom_details"} ], user_message="எனக்கு வந்து ஒரு 10 டேஸா இருக்கு." )) reply = res["reply"] # Must NOT ask duration again assert "எத்தனை நாளா" not in reply assert "எப்போலிருந்து" not in reply # Must NOT complete — should ask about medical history/meds/allergies assert res.get("completed") is not True # ===================================================================== # 4. CONTEXTUAL EXTRACTION FOR SEVERITY, LOCATION, MEDS, ALLERGIES # ===================================================================== def test_contextual_severity_extraction(): """Severity answer attaches to existing symptom.""" existing_state = { "symptoms": [{"name": "headache"}] } orchestrator = AIOrchestrator() updates = orchestrator._extract_facts_from_message( "ரொம்ப severe-ஆ இருக்கு.", "ரொம்ப severe-ஆ இருக்கு.", last_ai_message="How severe is the pain?", current_state=existing_state ) merged = merge_state_updates(existing_state, updates) symptoms = merged["symptoms"] assert len(symptoms) == 1 assert symptoms[0]["name"] == "headache" assert symptoms[0]["severity"] == "severe" def test_contextual_location_extraction(): """Location answer attaches to existing symptom.""" existing_state = { "symptoms": [{"name": "headache"}] } orchestrator = AIOrchestrator() updates = orchestrator._extract_facts_from_message( "வலது பக்கத்துல", "வலது பக்கத்துல", last_ai_message="எந்த பக்கத்துல வலிக்குது?", current_state=existing_state ) merged = merge_state_updates(existing_state, updates) symptoms = merged["symptoms"] assert len(symptoms) == 1 assert symptoms[0]["name"] == "headache" assert symptoms[0]["location"] == "right side" def test_contextual_medication_extraction(): """Medication statement extracts to medications list, not symptom.""" orchestrator = AIOrchestrator() updates = orchestrator._extract_facts_from_message( "Metformin 500 mg twice a day", "metformin 500 mg twice a day", last_ai_message="Are you taking any medications?", current_state={} ) meds = updates.get("medications", []) assert len(meds) == 1 assert meds[0]["name"] == "Metformin" assert meds[0]["dosage"] == "500 mg" assert meds[0]["frequency"] == "twice a day" def test_contextual_allergy_extraction(): """Allergy statement extracts to allergies list, not symptom.""" orchestrator = AIOrchestrator() updates = orchestrator._extract_facts_from_message( "allergic to penicillin, rash வரும்", "allergic to penicillin, rash வரும்", last_ai_message="Do you have any allergies?", current_state={} ) allergies = updates.get("allergies", []) assert len(allergies) == 1 assert allergies[0]["name"] == "Penicillin" assert "Rash" in allergies[0]["reaction"] # ===================================================================== # 5. NEGATIVE AND CASUAL SPEECH TESTS # ===================================================================== def test_negative_response_not_treated_as_symptom(): """'எனக்கு வேற எதுவும் இல்ல' must NOT become a symptom.""" orchestrator = AIOrchestrator() updates = orchestrator._extract_facts_from_message( "எனக்கு வேற எதுவும் இல்ல.", "எனக்கு வேற எதுவும் இல்ல.", last_ai_message="வேறு ஏதேனும் உடம்பு பிரச்சனை இருக்கா?", current_state={"symptoms": [{"name": "தலைவலி"}]} ) symptoms = updates.get("symptoms", []) assert len(symptoms) == 0 def test_casual_speech_extraction(): """'நான் bus-ல வந்தேன், தலைவலி பத்து நாளா இருக்கு.' extracts headache and 10 days, ignores bus.""" orchestrator = AIOrchestrator() updates = orchestrator._extract_facts_from_message( "நான் bus-ல வந்தேன், தலைவலி பத்து நாளா இருக்கு.", "நான் bus-ல வந்தேன், தலைவலி பத்து நாளா இருக்கு.", last_ai_message="", current_state={} ) symptoms = updates.get("symptoms", []) assert len(symptoms) == 1 assert symptoms[0]["name"] == "தலைவலி" assert symptoms[0]["duration"] == "10 days" # ===================================================================== # 6. ACCEPTANCE TESTS # ===================================================================== def test_acceptance_test_1_rich_multi_fact_message(mock_session, empty_state): """ Acceptance Test 1: Rich multi-fact patient message. """ import asyncio orchestrator = AIOrchestrator() user_msg = ( "I've had stomach pain since yesterday. It gets worse after eating. I vomited twice. " "I couldn't sleep last night. I have diabetes for eight years. I take Metformin 500 mg twice a day. " "I'm allergic to penicillin and it causes a rash. I had the same stomach problem three months ago." ) state_mock = MagicMock() state_mock.state_json = json.dumps(empty_state) res = asyncio.run(orchestrator.process( session=mock_session, state=state_mock, conversation_history=[], user_message=user_msg )) assert res["reply"] is not None updates = res["state_updates"] symptoms = updates.get("symptoms", []) sym_names = [s.get("name") for s in symptoms] assert any("stomach pain" in name for name in sym_names) assert any("vomiting" in name for name in sym_names) history = updates.get("medical_history", []) assert any("Diabetes" in h.get("condition", "") for h in history) meds = updates.get("medications", []) assert any("Metformin" in m.get("name", "") for m in meds) allergies = updates.get("allergies", []) assert any("Penicillin" in a.get("name", "") for a in allergies) def test_acceptance_test_2_minimal_input_no_invented_data(mock_session, empty_state): """Acceptance Test 2: Minimal input without invented data.""" import asyncio orchestrator = AIOrchestrator() state_mock = MagicMock() state_mock.state_json = json.dumps(empty_state) res = asyncio.run(orchestrator.process( session=mock_session, state=state_mock, conversation_history=[], user_message="I have headache." )) updates = res["state_updates"] symptoms = updates.get("symptoms", []) assert len(symptoms) >= 1 headache_sym = symptoms[0] assert "headache" in headache_sym["name"].lower() assert headache_sym.get("severity") in [None, ""] assert len(res["reply"]) > 5 assert not res.get("completed", False) def test_acceptance_test_4_nothing_else_completion(mock_session): """ Acceptance Test 4: 'Nothing else' completes the intake ONLY when all required fields are collected or confirmed_none. """ import asyncio orchestrator = AIOrchestrator() ready_state = { "reason_for_visit": "fever", "symptoms": [{"name": "fever", "duration": "2 days"}], "medical_history": {"status": "confirmed_none", "items": []}, "medications": {"status": "confirmed_none", "items": []}, "allergies": {"status": "confirmed_none", "items": []}, } state_mock = MagicMock() state_mock.state_json = json.dumps(ready_state) res = asyncio.run(orchestrator.process( session=mock_session, state=state_mock, conversation_history=[ {"role": "patient", "message": "I have fever for 2 days.", "stage": "greeting"}, {"role": "assistant", "message": "Do you have any other illness or medications?", "stage": "symptom_details"} ], user_message="Nothing else." )) assert res.get("completed") is True assert res.get("next_stage") == "confirmation" # ===================================================================== # 7. SAFETY & CRISIS TESTS # ===================================================================== def test_emergency_red_flag_crushing_chest_pain(mock_session, empty_state): """Emergency statement triggers hospital emergency room/staff advice and stops intake.""" import asyncio orchestrator = AIOrchestrator() state_mock = MagicMock() state_mock.state_json = json.dumps(empty_state) res = asyncio.run(orchestrator.process( session=mock_session, state=state_mock, conversation_history=[], user_message="I have crushing chest pain and I cannot breathe!" )) assert "emergency" in res["reply"].lower() or "hospital" in res["reply"].lower() assert res.get("completed") is True def test_mental_health_crisis_override(mock_session, empty_state): """Mental health crisis triggers Tele-MANAS (14416) helpline advice.""" import asyncio orchestrator = AIOrchestrator() state_mock = MagicMock() state_mock.state_json = json.dumps(empty_state) res = asyncio.run(orchestrator.process( session=mock_session, state=state_mock, conversation_history=[], user_message="I feel hopeless and I want to end my life." )) assert "14416" in res["reply"] or "Tele-MANAS" in res["reply"] assert res.get("completed") is True # ===================================================================== # 8. SPECIALTY & SCHEMA BACKWARD COMPATIBILITY TESTS # ===================================================================== def test_specialty_auto_classification(): """Verify clinical keywords correctly triage to respective specialties.""" assert classify_specialty_from_symptoms("I have severe depression and insomnia")["code"] in ("mental_health", "psychiatry") assert classify_specialty_from_symptoms("Severe chest pain and breathless")["code"] == "cardiology" assert classify_specialty_from_symptoms("Skin rash and severe itching")["code"] == "dermatology" assert classify_specialty_from_symptoms("Knee joint pain and swelling")["code"] == "orthopedics" assert classify_specialty_from_symptoms("Stomach ache and loose motion")["code"] == "gastroenterology" def test_completion_service_flexible_validation(): """Completion service accepts completed status or presence of all required fields.""" service = CompletionService() session = MagicMock(status="completed", stage="confirmation") state = MagicMock(state_json=json.dumps({"reason_for_visit": "fever"})) assert service.is_complete(session, state) is True def test_api_schemas_backward_compatibility(): """Verify all schemas serialize and deserialize correctly.""" cat = CategoryContext(type="general", code="general_medicine", name="General Medicine") start_res = StartOnboardingResponse( success=True, session_id="ONB-1234", patient_id="PAT-01", category=cat, language="en-IN", status="in_progress", stage="greeting", message="Hello" ) assert start_res.success is True result = PatientOnboardingResult( patient_id="PAT-01", category={"type": "general", "code": "general_medicine", "name": "General Medicine"}, chief_complaint="stomach pain", symptoms=[{"name": "stomach pain", "duration": "yesterday", "severity": "severe"}], medical_history=[{"condition": "Diabetes", "duration": "8 years"}], medications=[{"name": "Metformin", "dosage": "500 mg"}], allergies=[{"name": "Penicillin", "reaction": "Rash"}], language="en-IN", onboarding_completed=True ) assert len(result.symptoms) == 1 assert result.symptoms[0].name == "stomach pain" # ===================================================================== # 9. check_required_missing() UNIT TESTS # ===================================================================== def test_check_required_missing_empty_state(): """Empty state should report everything as missing.""" state = { "reason_for_visit": None, "symptoms": [], "medical_history": [], "medications": [], "allergies": [], } missing = check_required_missing(state) assert "reason_for_visit" in missing assert "symptoms" in missing assert "medical_history" in missing assert "medications" in missing assert "allergies" in missing def test_check_required_missing_symptoms_no_duration(): """Symptoms present but no duration should still report duration as missing.""" state = { "reason_for_visit": "headache", "symptoms": [{"name": "headache"}], # no duration "medical_history": [{"condition": "Diabetes"}], "medications": [{"name": "Metformin"}], "allergies": [{"name": "Penicillin"}], } missing = check_required_missing(state) assert "duration" in missing assert "reason_for_visit" not in missing assert "symptoms" not in missing assert "medical_history" not in missing def test_check_required_missing_confirmed_none(): """confirmed_none fields should NOT be reported as missing.""" state = { "reason_for_visit": "fever", "symptoms": [{"name": "fever", "duration": "2 days"}], "medical_history": {"status": "confirmed_none", "items": []}, "medications": {"status": "confirmed_none", "items": []}, "allergies": {"status": "confirmed_none", "items": []}, } missing = check_required_missing(state) assert len(missing) == 0 def test_check_required_missing_all_reported(): """All reported state should return empty missing list.""" state = { "reason_for_visit": "headache", "symptoms": [{"name": "headache", "duration": "3 days"}], "medical_history": [{"condition": "Diabetes"}], "medications": [{"name": "Metformin"}], "allergies": [{"name": "Penicillin"}], } missing = check_required_missing(state) assert len(missing) == 0 def test_check_required_missing_partial(): """Partial state: has symptoms+duration+history but missing meds and allergies.""" state = { "reason_for_visit": "headache", "symptoms": [{"name": "headache", "duration": "3 days"}], "medical_history": [{"condition": "Diabetes"}], "medications": [], # not_collected "allergies": [], # not_collected } missing = check_required_missing(state) assert "medications" in missing assert "allergies" in missing assert "reason_for_visit" not in missing assert "duration" not in missing # ===================================================================== # 10. CRITICAL BUG REGRESSION: PREMATURE COMPLETION AFTER DURATION # ===================================================================== def test_no_premature_completion_after_duration_tamil(mock_session): """ EXACT BUG REPRODUCTION: Turn 1: Patient gives multiple symptoms Turn 2: Patient gives duration AI MUST NOT complete. Must ask about medical history/meds/allergies. """ import asyncio mock_session.language = "ta-IN" orchestrator = AIOrchestrator() state_mock = MagicMock() state_mock.state_json = json.dumps({ "reason_for_visit": "தலைவலி", "symptoms": [ {"name": "தலைவலி"}, {"name": "கை கால் வலி"}, {"name": "நெஞ்சு படபடப்பு"}, {"name": "தலை சுத்துதல்"}, ], "medical_history": [], "medications": [], "allergies": [] }) res = asyncio.run(orchestrator.process( session=mock_session, state=state_mock, conversation_history=[ {"role": "patient", "message": "எனக்கு ரொம்ப தலைவலி, கை கால் வலியாக இருக்குது. என் நெஞ்சு அப்பப்ப படபடன்னு இருக்குது. தலை சுத்துது.", "stage": "greeting"}, {"role": "assistant", "message": "சரிங்க, இந்த பிரச்சனை எத்தனை நாளா இருக்குன்னு சொல்ல முடியுமா?", "stage": "symptom_details"} ], user_message="பிரச்சனை வந்து ஒரு மூணு நாளா இருக்கு." )) # MUST NOT complete assert res.get("completed") is not True, f"System completed prematurely after duration! Reply: {res['reply']}" # Must NOT ask duration again assert "எத்தனை நாளா" not in res["reply"] assert "எப்போலிருந்து" not in res["reply"] def test_no_premature_completion_after_duration_english(mock_session): """English version: duration answer must not trigger completion.""" import asyncio mock_session.language = "en-IN" orchestrator = AIOrchestrator() state_mock = MagicMock() state_mock.state_json = json.dumps({ "reason_for_visit": "headache", "symptoms": [{"name": "headache"}], "medical_history": [], "medications": [], "allergies": [] }) res = asyncio.run(orchestrator.process( session=mock_session, state=state_mock, conversation_history=[ {"role": "patient", "message": "I have a bad headache.", "stage": "greeting"}, {"role": "assistant", "message": "Got it. How long has this been bothering you?", "stage": "symptom_details"} ], user_message="About 3 days." )) assert res.get("completed") is not True, "System completed prematurely after duration!" # ===================================================================== # 11. "NOTHING ELSE" WITH MISSING FIELDS MUST NOT COMPLETE # ===================================================================== def test_nothing_else_with_missing_fields_does_not_complete(mock_session): """ When patient says 'nothing else' but medical_history, medications, allergies are still not_collected, system MUST NOT complete. """ import asyncio mock_session.language = "ta-IN" orchestrator = AIOrchestrator() state_mock = MagicMock() state_mock.state_json = json.dumps({ "reason_for_visit": "தலைவலி", "symptoms": [{"name": "தலைவலி", "duration": "3 days"}], "medical_history": [], # not_collected "medications": [], # not_collected "allergies": [] # not_collected }) res = asyncio.run(orchestrator.process( session=mock_session, state=state_mock, conversation_history=[ {"role": "patient", "message": "தலைவலி 3 நாளா இருக்கு", "stage": "greeting"}, {"role": "assistant", "message": "சரிங்க. உங்களுக்கு பிபி, சுகர், ஆஸ்துமா போன்ற வேறு ஏதேனும் உடம்பு பிரச்சனை இருக்கா?", "stage": "symptom_details"} ], user_message="எனக்கு வேற எதுவும் இல்ல." )) # Should set medical_history to confirmed_none (since that's what was asked) # But medications and allergies are still not_collected, so must NOT complete assert res.get("completed") is not True, f"System completed despite missing medications and allergies! Reply: {res['reply']}" def test_nothing_else_completes_when_all_confirmed_none(mock_session): """ When all remaining required fields are already confirmed_none or reported, 'nothing else' SHOULD complete. """ import asyncio mock_session.language = "en-IN" orchestrator = AIOrchestrator() state_mock = MagicMock() state_mock.state_json = json.dumps({ "reason_for_visit": "fever", "symptoms": [{"name": "fever", "duration": "2 days"}], "medical_history": {"status": "confirmed_none", "items": []}, "medications": {"status": "confirmed_none", "items": []}, "allergies": [], # last one not confirmed yet }) res = asyncio.run(orchestrator.process( session=mock_session, state=state_mock, conversation_history=[ {"role": "patient", "message": "fever 2 days", "stage": "greeting"}, {"role": "assistant", "message": "Do you have any drug allergies?", "stage": "allergies"} ], user_message="No allergies." )) # "No allergies" should set allergies to confirmed_none, then all required are met assert res.get("completed") is True # ===================================================================== # 12. CONFIRMED_NONE DETECTION IN _extract_facts_from_message # ===================================================================== def test_extract_no_medicines_tamil(): """Tamil 'no medicines' should set medications to confirmed_none.""" orchestrator = AIOrchestrator() updates = orchestrator._extract_facts_from_message( "எந்த மாத்திரையும் சாப்பிடல", "எந்த மாத்திரையும் சாப்பிடல", last_ai_message="ஏதாவது மாத்திரை சாப்பிடுறீங்களா?", current_state={"symptoms": [{"name": "headache"}]} ) meds = updates.get("medications") assert isinstance(meds, dict) assert meds.get("status") == "confirmed_none" def test_extract_no_allergies_english(): """English 'no allergies' should set allergies to confirmed_none.""" orchestrator = AIOrchestrator() updates = orchestrator._extract_facts_from_message( "No allergies", "no allergies", last_ai_message="Do you have any drug allergies?", current_state={"symptoms": [{"name": "headache"}]} ) alg = updates.get("allergies") assert isinstance(alg, dict) assert alg.get("status") == "confirmed_none" def test_extract_no_medical_history_tamil(): """Tamil 'no medical history' should set medical_history to confirmed_none.""" orchestrator = AIOrchestrator() updates = orchestrator._extract_facts_from_message( "எந்த நோயும் இல்லை", "எந்த நோயும் இல்லை", last_ai_message="உங்களுக்கு பிபி, சுகர் போன்ற வேறு ஏதேனும் உடம்பு பிரச்சனை இருக்கா?", current_state={"symptoms": [{"name": "headache"}]} ) mh = updates.get("medical_history") assert isinstance(mh, dict) assert mh.get("status") == "confirmed_none" # ===================================================================== # 13. INTENT DETECTION: COMBINED MEDICATIONS_AND_ALLERGIES # ===================================================================== def test_detect_combined_meds_allergies_intent(): """When AI asked about both meds and allergies, detect combined intent.""" intent = detect_previous_question_intent("ஏதாவது மாத்திரை சாப்பிட்டீங்களா? உங்களுக்கு ஏதேனும் மாத்திரை அலர்ஜி இருக்கா?") assert intent == "medications_and_allergies" def test_detect_meds_only_intent(): """When AI asked about meds only, detect medications intent.""" intent = detect_previous_question_intent("ஏதாவது மாத்திரை சாப்பிடுறீங்களா?") assert intent == "medications" def test_detect_allergies_only_intent(): """When AI asked about allergies only, detect allergies intent.""" intent = detect_previous_question_intent("Do you have any drug allergies?") assert intent == "allergies" def test_detect_medical_history_intent(): """When AI asked about medical history, detect history intent.""" intent = detect_previous_question_intent("உங்களுக்கு பிபி, சுகர், ஆஸ்துமா போன்ற வேறு ஏதேனும் உடம்பு பிரச்சனை இருக்கா?") assert intent == "medical_history" # ===================================================================== # 14. _field_status HELPER TESTS # ===================================================================== def test_field_status_none(): assert _field_status(None) == "not_collected" def test_field_status_empty_list(): assert _field_status([]) == "not_collected" def test_field_status_nonempty_list(): assert _field_status([{"name": "Metformin"}]) == "reported" def test_field_status_confirmed_none_dict(): assert _field_status({"status": "confirmed_none", "items": []}) == "confirmed_none" def test_field_status_reported_string(): assert _field_status("fever") == "reported" def test_field_status_empty_string(): assert _field_status("") == "not_collected" def test_field_status_dict_with_items(): assert _field_status({"status": "reported", "items": [{"name": "Metformin"}]}) == "reported" # ===================================================================== # 15. SPECIALTY LOCKING — NO FLIP-FLOPPING # ===================================================================== def test_specialty_not_downgraded_on_duration_message(mock_session): """ If session.category_code is already neurology (not general_medicine), a duration message should NOT re-triage to general_medicine. """ import asyncio mock_session.language = "ta-IN" mock_session.category_code = "neurology" mock_session.category_name = "Neurology" orchestrator = AIOrchestrator() state_mock = MagicMock() state_mock.state_json = json.dumps({ "reason_for_visit": "தலைவலி", "symptoms": [{"name": "தலைவலி"}], "medical_history": [], "medications": [], "allergies": [] }) res = asyncio.run(orchestrator.process( session=mock_session, state=state_mock, conversation_history=[ {"role": "patient", "message": "தலைவலி", "stage": "greeting"}, {"role": "assistant", "message": "எத்தனை நாளா?", "stage": "symptom_details"} ], user_message="3 நாளா இருக்கு" )) # Specialty should remain neurology, not flip to general_medicine suggested = res.get("suggested_specialty", {}) assert suggested.get("code") == "neurology" def test_dynamic_symptom_tailored_questions(): """Verify that follow-up questions dynamically reference the reported symptom name.""" from app.ai.orchestrator import _pick_best_question # Tamil with headache reported state_ta = {"reason_for_visit": "தலைவலி", "symptoms": [{"name": "தலைவலி"}]} q_ta = _pick_best_question(["duration"], "ta-IN", False, current_state=state_ta) assert "தலைவலி" in q_ta # English with stomach pain reported state_en = {"reason_for_visit": "stomach pain", "symptoms": [{"name": "stomach pain"}]} q_en = _pick_best_question(["duration"], "en-US", False, current_state=state_en) assert "stomach pain" in q_en def test_combined_meds_and_allergies_question_pattern(): """Verify that when medications and allergies are missing, questions are combined naturally.""" from app.ai.orchestrator import _pick_best_question state_en = {"reason_for_visit": "fever", "symptoms": [{"name": "fever", "duration": "2 days"}], "medical_history": ["Hypertension"]} q_comb = _pick_best_question(["medications", "allergies"], "en-US", False, current_state=state_en) # Should ask about both medications AND allergies in one fluid question assert "medication" in q_comb.lower() or "regula" in q_comb.lower() assert "allerg" in q_comb.lower()