feat(ai-onboarding): enhance conversation flow, prompt rules & update Postman environment port to 8001
This commit is contained in:
@@ -0,0 +1,977 @@
|
||||
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 108 ambulance 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 "108" in res["reply"]
|
||||
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"] == "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"
|
||||
Reference in New Issue
Block a user