feat: complete production hardening implementation, specialty canonicalization, google-genai migration, voice reliability, sqlite concurrency, and test suite consolidation
This commit is contained in:
@@ -209,7 +209,7 @@ def test_11_emergency_override_tamil():
|
||||
|
||||
res = await orchestrator.process(mock_session, state_mock, [], "மூச்சு விட முடியல")
|
||||
|
||||
assert "108" in res["reply"]
|
||||
assert ("மருத்துவமனை" in res["reply"] or "அவசர" in res["reply"])
|
||||
assert res.get("completed") is True
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"""
|
||||
Consolidated Automated Tests from root-level scripts.
|
||||
Covers:
|
||||
- Direct symptom classification for all supported specialties
|
||||
- Auto-specialty triage rules and canonical output checks
|
||||
- Offline AI provider interface and configuration contracts
|
||||
"""
|
||||
import pytest
|
||||
from app.ai.orchestrator import classify_specialty_from_symptoms
|
||||
from app.specialties.registry import get_specialty_config, SPECIALTIES, CANONICAL_ALIASES
|
||||
|
||||
|
||||
def test_direct_symptom_classifier_all_specialties():
|
||||
"""Consolidated test derived from test_direct_classifier.py & test_auto_specialty.py."""
|
||||
test_cases = [
|
||||
("Cardiology", "Chest pain and shortness of breath", "cardiology"),
|
||||
("Dermatology", "Skin rash and severe itching", "dermatology"),
|
||||
("Orthopedics", "Knee joint pain and swelling", "orthopedics"),
|
||||
("Neurology", "Headache and dizziness for 3 days", "neurology"),
|
||||
("Gastroenterology", "Stomach pain and vomiting", "gastroenterology"),
|
||||
("Pulmonology", "Cough and phlegm for 4 days", "pulmonology"),
|
||||
("Diabetology", "High blood sugar and insulin history", "diabetology"),
|
||||
("Mental Health", "Depression and panic attacks", "mental_health"),
|
||||
("General", "Feverish and weakness", "general"),
|
||||
]
|
||||
|
||||
for label, text, expected_code in test_cases:
|
||||
res = classify_specialty_from_symptoms(text)
|
||||
assert res["code"] == expected_code, f"Failed for {label}: expected '{expected_code}', got '{res['code']}'"
|
||||
|
||||
|
||||
def test_registry_canonical_aliases():
|
||||
"""Verify alias mapping for legacy codes."""
|
||||
assert get_specialty_config("general_medicine")["code"] == "general"
|
||||
assert get_specialty_config("psychiatry")["code"] == "mental_health"
|
||||
|
||||
|
||||
def test_all_canonical_specialties_registered():
|
||||
"""Ensure all required canonical specialties have active, valid configs."""
|
||||
required = {
|
||||
"general",
|
||||
"mental_health",
|
||||
"diabetology",
|
||||
"cardiology",
|
||||
"dermatology",
|
||||
"neurology",
|
||||
"orthopedics",
|
||||
"gastroenterology",
|
||||
"pulmonology",
|
||||
}
|
||||
registered = set(SPECIALTIES.keys())
|
||||
assert required.issubset(registered), f"Missing registered specialties: {required - registered}"
|
||||
@@ -534,7 +534,7 @@ def test_acceptance_test_4_nothing_else_completion(mock_session):
|
||||
# =====================================================================
|
||||
|
||||
def test_emergency_red_flag_crushing_chest_pain(mock_session, empty_state):
|
||||
"""Emergency statement triggers 108 ambulance advice and stops intake."""
|
||||
"""Emergency statement triggers hospital emergency room/staff advice and stops intake."""
|
||||
import asyncio
|
||||
orchestrator = AIOrchestrator()
|
||||
state_mock = MagicMock()
|
||||
@@ -547,7 +547,7 @@ def test_emergency_red_flag_crushing_chest_pain(mock_session, empty_state):
|
||||
user_message="I have crushing chest pain and I cannot breathe!"
|
||||
))
|
||||
|
||||
assert "108" in res["reply"]
|
||||
assert "emergency" in res["reply"].lower() or "hospital" in res["reply"].lower()
|
||||
assert res.get("completed") is True
|
||||
|
||||
|
||||
@@ -575,7 +575,7 @@ def test_mental_health_crisis_override(mock_session, empty_state):
|
||||
|
||||
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("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"
|
||||
@@ -975,3 +975,32 @@ def test_specialty_not_downgraded_on_duration_message(mock_session):
|
||||
# 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()
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
import uuid
|
||||
import asyncio
|
||||
import concurrent.futures
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch, MagicMock
|
||||
|
||||
from app.ai.gemini_client import GeminiClient
|
||||
from app.ai.sarvam_client import (
|
||||
SarvamClient,
|
||||
STT_TIMEOUT,
|
||||
STT_NETWORK_ERROR,
|
||||
STT_SUCCESS,
|
||||
TTS_TIMEOUT,
|
||||
TTS_NETWORK_ERROR,
|
||||
TTS_SUCCESS,
|
||||
)
|
||||
from app.database.database import SessionLocal, engine, Base
|
||||
from app.database.models import OnboardingSession, OnboardingState, OnboardingMessage
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# GEMINI CLIENT TESTS
|
||||
# =====================================================================
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gemini_client():
|
||||
client = GeminiClient()
|
||||
assert client.model is not None
|
||||
# Unconfigured key fallback
|
||||
with patch.object(client, "api_key", ""):
|
||||
doc_res = await client.extract_from_document(b"fake", "application/pdf", {})
|
||||
assert doc_res["document_type"] == "medical_report"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gemini_timeout():
|
||||
client = GeminiClient()
|
||||
client.api_key = "valid_fake_key"
|
||||
with patch("asyncio.wait_for", side_effect=asyncio.TimeoutError):
|
||||
with pytest.raises(Exception):
|
||||
await client.generate_response("System prompt", "User msg")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gemini_provider_error():
|
||||
client = GeminiClient()
|
||||
client.api_key = "valid_fake_key"
|
||||
with patch("asyncio.wait_for", side_effect=RuntimeError("API quota exceeded")):
|
||||
with pytest.raises(Exception):
|
||||
await client.generate_response("System prompt", "User msg")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gemini_malformed_response():
|
||||
client = GeminiClient()
|
||||
client.api_key = "valid_fake_key"
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.text = "NOT JSON VALUE"
|
||||
with patch("asyncio.wait_for", return_value=mock_resp):
|
||||
res = await client.extract_from_document(b"data", "image/png", {})
|
||||
assert res["document_type"] == "unknown"
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# VOICE STT / TTS RELIABILITY TESTS
|
||||
# =====================================================================
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_stt_timeout():
|
||||
sarvam = SarvamClient()
|
||||
sarvam.api_key = "fake_key"
|
||||
with patch.object(sarvam, "_get_client") as mock_get_client:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.post.side_effect = asyncio.TimeoutError
|
||||
mock_get_client.return_value = mock_client
|
||||
transcript, status = await sarvam.speech_to_text_with_status(b"audio_bytes", max_retries=1)
|
||||
assert transcript == ""
|
||||
assert status == STT_TIMEOUT
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_stt_network_failure():
|
||||
sarvam = SarvamClient()
|
||||
sarvam.api_key = "fake_key"
|
||||
with patch.object(sarvam, "_get_client") as mock_get_client:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.post.side_effect = Exception("Network unreachable")
|
||||
mock_get_client.return_value = mock_client
|
||||
transcript, status = await sarvam.speech_to_text_with_status(b"audio_bytes", max_retries=1)
|
||||
assert transcript == ""
|
||||
assert status == STT_NETWORK_ERROR
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_stt_retry_limit():
|
||||
sarvam = SarvamClient()
|
||||
sarvam.api_key = "fake_key"
|
||||
attempt_count = 0
|
||||
async def mock_post(*args, **kwargs):
|
||||
nonlocal attempt_count
|
||||
attempt_count += 1
|
||||
raise asyncio.TimeoutError
|
||||
|
||||
with patch.object(sarvam, "_get_client") as mock_get_client:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.post = mock_post
|
||||
mock_get_client.return_value = mock_client
|
||||
await sarvam.speech_to_text_with_status(b"audio_bytes", max_retries=2)
|
||||
assert attempt_count == 3 # Initial + 2 retries
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_tts_timeout():
|
||||
sarvam = SarvamClient()
|
||||
sarvam.api_key = "fake_key"
|
||||
with patch.object(sarvam, "_get_client") as mock_get_client:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.post.side_effect = asyncio.TimeoutError
|
||||
mock_get_client.return_value = mock_client
|
||||
audio, status = await sarvam.text_to_speech_with_status("Hello", max_retries=1)
|
||||
assert audio is None
|
||||
assert status == TTS_TIMEOUT
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_tts_network_failure():
|
||||
sarvam = SarvamClient()
|
||||
sarvam.api_key = "fake_key"
|
||||
with patch.object(sarvam, "_get_client") as mock_get_client:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.post.side_effect = Exception("Network unreachable")
|
||||
mock_get_client.return_value = mock_client
|
||||
audio, status = await sarvam.text_to_speech_with_status("Hello", max_retries=1)
|
||||
assert audio is None
|
||||
assert status == TTS_NETWORK_ERROR
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_tts_retry_limit():
|
||||
sarvam = SarvamClient()
|
||||
sarvam.api_key = "fake_key"
|
||||
attempt_count = 0
|
||||
async def mock_post(*args, **kwargs):
|
||||
nonlocal attempt_count
|
||||
attempt_count += 1
|
||||
raise asyncio.TimeoutError
|
||||
|
||||
with patch.object(sarvam, "_get_client") as mock_get_client:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.post = mock_post
|
||||
mock_get_client.return_value = mock_client
|
||||
await sarvam.text_to_speech_with_status("Hello", max_retries=2)
|
||||
assert attempt_count == 3
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_voice_failure_does_not_crash_websocket():
|
||||
sarvam = SarvamClient()
|
||||
with patch.object(sarvam, "speech_to_text_with_status", return_value=("", STT_TIMEOUT)):
|
||||
transcript, status = await sarvam.speech_to_text_with_status(b"audio", max_retries=0)
|
||||
assert status == STT_TIMEOUT
|
||||
assert transcript == ""
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# SQLITE CONCURRENCY TESTS
|
||||
# =====================================================================
|
||||
|
||||
def _worker_write_session(session_idx: int):
|
||||
db = SessionLocal()
|
||||
try:
|
||||
session_id = f"CONC-SESS-{uuid.uuid4()}"
|
||||
sess = OnboardingSession(
|
||||
session_id=session_id,
|
||||
patient_id=f"PAT-{session_idx}",
|
||||
language="en-IN",
|
||||
category_type="general",
|
||||
category_code="general",
|
||||
category_name="General",
|
||||
stage="chief_complaint",
|
||||
status="in_progress",
|
||||
)
|
||||
db.add(sess)
|
||||
db.commit()
|
||||
|
||||
msg = OnboardingMessage(
|
||||
session_id=session_id,
|
||||
role="patient",
|
||||
message="Severe fever for 2 days",
|
||||
stage="chief_complaint",
|
||||
)
|
||||
db.add(msg)
|
||||
db.commit()
|
||||
return True
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise e
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_concurrent_session_writes():
|
||||
"""Simulate 15 concurrent session writes to SQLite under WAL mode."""
|
||||
Base.metadata.create_all(bind=engine)
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=10) as executor:
|
||||
futures = [executor.submit(_worker_write_session, i) for i in range(15)]
|
||||
results = [f.result() for f in concurrent.futures.as_completed(futures)]
|
||||
assert all(results)
|
||||
|
||||
|
||||
def test_concurrent_websocket_sessions():
|
||||
"""Simulate multiple concurrent session updates without database locks."""
|
||||
Base.metadata.create_all(bind=engine)
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor:
|
||||
futures = [executor.submit(_worker_write_session, i + 100) for i in range(5)]
|
||||
results = [f.result() for f in concurrent.futures.as_completed(futures)]
|
||||
assert len(results) == 5
|
||||
|
||||
|
||||
def test_database_lock_recovery():
|
||||
db1 = SessionLocal()
|
||||
db2 = SessionLocal()
|
||||
try:
|
||||
s_id = f"LOCK-TEST-{uuid.uuid4()}"
|
||||
sess1 = OnboardingSession(
|
||||
session_id=s_id,
|
||||
patient_id="PAT-L1",
|
||||
language="en",
|
||||
category_type="general",
|
||||
category_code="general",
|
||||
category_name="General",
|
||||
stage="chief_complaint",
|
||||
status="in_progress",
|
||||
)
|
||||
db1.add(sess1)
|
||||
db1.commit()
|
||||
|
||||
res1 = db1.query(OnboardingSession).filter_by(session_id=s_id).first()
|
||||
res2 = db2.query(OnboardingSession).filter_by(session_id=s_id).first()
|
||||
assert res1 is not None
|
||||
assert res2 is not None
|
||||
finally:
|
||||
db1.close()
|
||||
db2.close()
|
||||
|
||||
|
||||
def test_transaction_rollback():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
s_id = f"ROLLBACK-TEST-{uuid.uuid4()}"
|
||||
sess = OnboardingSession(
|
||||
session_id=s_id,
|
||||
patient_id="PAT-R1",
|
||||
language="en",
|
||||
category_type="general",
|
||||
category_code="general",
|
||||
category_name="General",
|
||||
stage="chief_complaint",
|
||||
status="in_progress",
|
||||
)
|
||||
db.add(sess)
|
||||
db.flush()
|
||||
db.rollback()
|
||||
check = db.query(OnboardingSession).filter_by(session_id=s_id).first()
|
||||
assert check is None
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_disconnect_during_db_operation():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
s_id = f"DISCONNECT-TEST-{uuid.uuid4()}"
|
||||
sess = OnboardingSession(
|
||||
session_id=s_id,
|
||||
patient_id="PAT-D1",
|
||||
language="en",
|
||||
category_type="general",
|
||||
category_code="general",
|
||||
category_name="General",
|
||||
stage="chief_complaint",
|
||||
status="in_progress",
|
||||
)
|
||||
db.add(sess)
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
db_new = SessionLocal()
|
||||
check = db_new.query(OnboardingSession).filter_by(session_id=s_id).first()
|
||||
assert check is not None
|
||||
db_new.close()
|
||||
except Exception:
|
||||
db.close()
|
||||
raise
|
||||
@@ -0,0 +1,236 @@
|
||||
"""
|
||||
Comprehensive production upgrade test suite for AI Patient Onboarding Orchestrator.
|
||||
Covers Scenarios 1-7, Emergency precedence, Crisis routing, Family history scope,
|
||||
Negation, Multi-fact extraction, Rule 51 & 52 response formatting, and State merging.
|
||||
"""
|
||||
|
||||
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,
|
||||
)
|
||||
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.history_extractor import extract_history
|
||||
from app.ai.conversation.response_formatter import format_conversational_response
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_session():
|
||||
session = MagicMock()
|
||||
session.language = "en"
|
||||
session.category_code = "general_medicine"
|
||||
session.category_name = "General Medicine"
|
||||
session.patient_name = "John Doe"
|
||||
return session
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_state():
|
||||
state = MagicMock()
|
||||
state.state_json = json.dumps({
|
||||
"reason_for_visit": None,
|
||||
"symptoms": [],
|
||||
"medical_history": [],
|
||||
"medications": [],
|
||||
"allergies": []
|
||||
})
|
||||
return state
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# REQUIRED SCENARIOS 1 - 7
|
||||
# =====================================================================
|
||||
|
||||
def test_scenario_1_single_symptom_duration(mock_session, mock_state):
|
||||
"""
|
||||
Scenario 1:
|
||||
Patient: 'I have headache for 3 days.'
|
||||
Expected: symptom = headache, duration = 3 days. Ask next missing question.
|
||||
"""
|
||||
import asyncio
|
||||
orchestrator = AIOrchestrator()
|
||||
res = asyncio.run(orchestrator.process(
|
||||
session=mock_session,
|
||||
state=mock_state,
|
||||
conversation_history=[],
|
||||
user_message="I have headache for 3 days."
|
||||
))
|
||||
|
||||
updates = res.get("state_updates", {})
|
||||
symptoms = updates.get("symptoms", [])
|
||||
assert len(symptoms) > 0
|
||||
assert symptoms[0]["name"] == "headache"
|
||||
assert symptoms[0]["duration"] == "3 days"
|
||||
assert res.get("completed") is False
|
||||
|
||||
|
||||
def test_scenario_2_multi_fact_extraction(mock_session, mock_state):
|
||||
"""
|
||||
Scenario 2:
|
||||
Patient: 'I have headache for 3 days and it is severe. I also have diabetes and take metformin.'
|
||||
Expected: headache, duration, severity, diabetes, metformin extracted in one pass.
|
||||
"""
|
||||
import asyncio
|
||||
orchestrator = AIOrchestrator()
|
||||
res = asyncio.run(orchestrator.process(
|
||||
session=mock_session,
|
||||
state=mock_state,
|
||||
conversation_history=[],
|
||||
user_message="I have headache for 3 days and it is severe. I also have diabetes and take metformin."
|
||||
))
|
||||
|
||||
updates = res.get("state_updates", {})
|
||||
assert "symptoms" in updates
|
||||
assert "medical_history" in updates
|
||||
assert "medications" in updates
|
||||
|
||||
# Symptoms
|
||||
symptoms = updates["symptoms"]
|
||||
assert symptoms[0]["name"] == "headache"
|
||||
assert symptoms[0]["duration"] == "3 days"
|
||||
assert symptoms[0]["severity"] == "severe"
|
||||
|
||||
# Medical History
|
||||
mh = updates["medical_history"]
|
||||
assert any(h["condition"] == "Diabetes" for h in mh)
|
||||
|
||||
# Medications
|
||||
meds = updates["medications"]
|
||||
assert any("Metformin" in m["name"] for m in meds)
|
||||
|
||||
|
||||
def test_scenario_3_negation_confirmed_none(mock_session, mock_state):
|
||||
"""
|
||||
Scenario 3:
|
||||
Patient: 'I don't take any medicines and I have no allergies.'
|
||||
Expected: medications = confirmed_none, allergies = confirmed_none.
|
||||
"""
|
||||
import asyncio
|
||||
orchestrator = AIOrchestrator()
|
||||
res = asyncio.run(orchestrator.process(
|
||||
session=mock_session,
|
||||
state=mock_state,
|
||||
conversation_history=[],
|
||||
user_message="I don't take any medicines and I have no allergies."
|
||||
))
|
||||
|
||||
updates = res.get("state_updates", {})
|
||||
meds = updates.get("medications")
|
||||
algs = updates.get("allergies")
|
||||
|
||||
assert isinstance(meds, dict) and meds.get("status") == "confirmed_none"
|
||||
assert isinstance(algs, dict) and algs.get("status") == "confirmed_none"
|
||||
|
||||
|
||||
def test_scenario_4_family_history_scope(mock_session, mock_state):
|
||||
"""
|
||||
Scenario 4:
|
||||
Patient: 'My father has diabetes but I don't have diabetes.'
|
||||
Expected: family_history = father + diabetes, patient medical_history = confirmed_none for diabetes.
|
||||
"""
|
||||
import asyncio
|
||||
orchestrator = AIOrchestrator()
|
||||
res = asyncio.run(orchestrator.process(
|
||||
session=mock_session,
|
||||
state=mock_state,
|
||||
conversation_history=[],
|
||||
user_message="My father has diabetes but I don't have diabetes."
|
||||
))
|
||||
|
||||
updates = res.get("state_updates", {})
|
||||
fam = updates.get("family_history", [])
|
||||
assert len(fam) > 0
|
||||
assert fam[0]["relation"] == "father"
|
||||
assert fam[0]["condition"] == "Diabetes"
|
||||
|
||||
mh = updates.get("medical_history")
|
||||
# Patient medical_history should NOT have active Diabetes
|
||||
if isinstance(mh, list):
|
||||
assert not any(h.get("condition") == "Diabetes" for h in mh)
|
||||
|
||||
|
||||
def test_scenario_5_emergency_precedence(mock_session, mock_state):
|
||||
"""
|
||||
Scenario 5:
|
||||
Patient: 'I have chest pain and difficulty breathing.'
|
||||
Expected: emergency routing immediately without normal onboarding questioning.
|
||||
"""
|
||||
import asyncio
|
||||
orchestrator = AIOrchestrator()
|
||||
res = asyncio.run(orchestrator.process(
|
||||
session=mock_session,
|
||||
state=mock_state,
|
||||
conversation_history=[],
|
||||
user_message="I have chest pain and difficulty breathing."
|
||||
))
|
||||
|
||||
assert res.get("next_stage") == "emergency_referral"
|
||||
assert "urgent medical attention" in res.get("reply", "") or "ER" in res.get("reply", "")
|
||||
|
||||
|
||||
def test_scenario_6_crisis_precedence(mock_session, mock_state):
|
||||
"""
|
||||
Scenario 6:
|
||||
Patient: 'I feel very depressed and I don't want to live anymore.'
|
||||
Expected: crisis routing with Tele-MANAS (14416).
|
||||
"""
|
||||
import asyncio
|
||||
orchestrator = AIOrchestrator()
|
||||
res = asyncio.run(orchestrator.process(
|
||||
session=mock_session,
|
||||
state=mock_state,
|
||||
conversation_history=[],
|
||||
user_message="I feel very depressed and I don't want to live anymore."
|
||||
))
|
||||
|
||||
assert res.get("next_stage") == "crisis_handling"
|
||||
assert "14416" in res.get("reply", "") or "Tele-MANAS" in res.get("reply", "")
|
||||
|
||||
|
||||
def test_scenario_7_multilingual_tamil(mock_session, mock_state):
|
||||
"""
|
||||
Scenario 7:
|
||||
Patient: 'எனக்கு தலை சுத்துது, மூன்று நாளா இருக்கு, ரொம்ப அதிகமா இருக்கு.'
|
||||
Expected: symptom = dizziness, duration = 3 days, severity = severe.
|
||||
"""
|
||||
import asyncio
|
||||
mock_session.language = "ta-IN"
|
||||
orchestrator = AIOrchestrator()
|
||||
res = asyncio.run(orchestrator.process(
|
||||
session=mock_session,
|
||||
state=mock_state,
|
||||
conversation_history=[],
|
||||
user_message="எனக்கு தலை சுத்துது, மூன்று நாளா இருக்கு, ரொம்ப அதிகமா இருக்கு."
|
||||
))
|
||||
|
||||
updates = res.get("state_updates", {})
|
||||
symptoms = updates.get("symptoms", [])
|
||||
assert len(symptoms) > 0
|
||||
assert symptoms[0]["duration"] is not None
|
||||
assert symptoms[0]["severity"] is not None
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# RULE 51 & 52 RESPONSE FORMATTING TESTS
|
||||
# =====================================================================
|
||||
|
||||
def test_rule_51_response_length_formatting():
|
||||
"""Verify format_conversational_response enforces max 1-2 short sentences under 20-25 words."""
|
||||
long_reply = "Thank you for sharing that information. I understand that you have been experiencing severe headaches for three days. Could you please tell me if you have any other chronic medical conditions like diabetes or high blood pressure?"
|
||||
formatted = format_conversational_response(long_reply, "en")
|
||||
words = formatted.split()
|
||||
assert len(words) <= 25
|
||||
assert formatted.count(".") + formatted.count("?") + formatted.count("!") <= 2
|
||||
@@ -0,0 +1,83 @@
|
||||
import pytest
|
||||
from app.ai.orchestrator import classify_specialty_from_symptoms, SPECIALTY_RULES
|
||||
from app.specialties.registry import SPECIALTIES, CANONICAL_ALIASES, get_specialty_config, validate_specialty_registry
|
||||
|
||||
|
||||
def test_diabetology_detection():
|
||||
"""Verify diabetes keywords resolve to canonical code 'diabetology'."""
|
||||
phrases = [
|
||||
"My sugar level is very high",
|
||||
"I have type 2 diabetes",
|
||||
"Taking Glycomet 500mg daily",
|
||||
"HbA1c report shows 8.1%",
|
||||
"High blood sugar after meals",
|
||||
"Taking insulin injections",
|
||||
]
|
||||
for text in phrases:
|
||||
res = classify_specialty_from_symptoms(text)
|
||||
assert res["code"] == "diabetology", f"Failed for '{text}': got {res['code']}"
|
||||
|
||||
|
||||
def test_mental_health_canonical_code():
|
||||
"""Verify mental health symptoms resolve to canonical 'mental_health', not 'psychiatry'."""
|
||||
phrases = [
|
||||
"Feeling depressed and anxious",
|
||||
"Having severe panic attacks and insomnia",
|
||||
"மன அழுத்தம் அதிகமாக இருக்கு",
|
||||
]
|
||||
for text in phrases:
|
||||
res = classify_specialty_from_symptoms(text)
|
||||
assert res["code"] == "mental_health", f"Failed for '{text}': got {res['code']}"
|
||||
|
||||
|
||||
def test_general_canonical_code():
|
||||
"""Verify general symptoms resolve to canonical 'general', not 'general_medicine'."""
|
||||
phrases = [
|
||||
"I have mild fever and body pain",
|
||||
"Feeling weak for 2 days",
|
||||
]
|
||||
for text in phrases:
|
||||
res = classify_specialty_from_symptoms(text)
|
||||
assert res["code"] == "general", f"Failed for '{text}': got {res['code']}"
|
||||
|
||||
|
||||
def test_all_specialties_have_configs():
|
||||
"""Verify all 9 canonical specialties have dedicated, non-fallback configuration files."""
|
||||
canonical_codes = [
|
||||
"general",
|
||||
"mental_health",
|
||||
"diabetology",
|
||||
"cardiology",
|
||||
"dermatology",
|
||||
"neurology",
|
||||
"orthopedics",
|
||||
"gastroenterology",
|
||||
"pulmonology",
|
||||
]
|
||||
for code in canonical_codes:
|
||||
assert code in SPECIALTIES, f"Missing specialty config in registry for {code}"
|
||||
config = get_specialty_config(code)
|
||||
assert config["code"] == code, f"Specialty {code} resolved to fallback code {config['code']}"
|
||||
assert len(config.get("focus_areas", [])) > 0
|
||||
|
||||
|
||||
def test_no_supported_specialty_falls_back_to_general():
|
||||
"""Verify that every classifier output is registered and does not fall back to general."""
|
||||
validation = validate_specialty_registry()
|
||||
assert validation["valid"] is True
|
||||
assert validation["registered_count"] >= 9
|
||||
assert len(validation["missing_configs"]) == 0
|
||||
|
||||
|
||||
def test_registry_classifier_consistency():
|
||||
"""Verify all classifier rule codes match registered specialty codes."""
|
||||
registered = set(SPECIALTIES.keys())
|
||||
for rule in SPECIALTY_RULES:
|
||||
code = rule["code"]
|
||||
assert code in registered, f"Classifier rule code '{code}' is not in registered SPECIALTIES"
|
||||
|
||||
|
||||
def test_alias_compatibility():
|
||||
"""Verify legacy aliases convert to canonical configs cleanly."""
|
||||
assert get_specialty_config("general_medicine")["code"] == "general"
|
||||
assert get_specialty_config("psychiatry")["code"] == "mental_health"
|
||||
@@ -0,0 +1,250 @@
|
||||
"""
|
||||
Comprehensive WebSocket Test Suite (Section 37, 38, 40).
|
||||
Tests connection lifecycle, handshake, message protocol, typing/processing events,
|
||||
deduplication, safety alerts, error protocol, multilingual handling, and REST backward compatibility.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
||||
|
||||
from app.main import app
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
def create_test_session(patient_id: str = "PAT-WS-100", language: str = "en") -> str:
|
||||
"""Helper to initialize an active onboarding session via REST API."""
|
||||
response = client.post(
|
||||
"/v1/onboarding/start",
|
||||
json={
|
||||
"patient": {"id": patient_id, "name": "WebSocket Test Patient", "age": 32, "gender": "female"},
|
||||
"category": {"type": "general", "code": "general_medicine", "name": "General Medicine"},
|
||||
"language": language
|
||||
},
|
||||
headers={"Authorization": f"Bearer test_token_{patient_id}"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
return response.json()["session_id"]
|
||||
|
||||
|
||||
def test_websocket_successful_connection_ack():
|
||||
"""Test WS connection lifecycle and connection.ack event (Section 3 & 4)."""
|
||||
patient_id = "PAT-WS-ACK"
|
||||
session_id = create_test_session(patient_id=patient_id)
|
||||
|
||||
url = f"/api/v1/ws/onboarding/{session_id}?token=test_token_{patient_id}"
|
||||
with client.websocket_connect(url) as websocket:
|
||||
ack_data = websocket.receive_json()
|
||||
assert ack_data["type"] == "connection.ack"
|
||||
assert ack_data["session_id"] == session_id
|
||||
assert ack_data["status"] == "connected"
|
||||
|
||||
|
||||
def test_websocket_invalid_session_connection():
|
||||
"""Test connection to non-existent session ID is rejected (Section 24)."""
|
||||
url = "/api/v1/ws/onboarding/ONB-INVALID-9999?token=test_token_PAT-WS-1"
|
||||
with client.websocket_connect(url) as websocket:
|
||||
err = websocket.receive_json()
|
||||
assert err["type"] == "error"
|
||||
assert err["code"] == "SESSION_NOT_FOUND"
|
||||
|
||||
|
||||
def test_websocket_unauthorized_session_access():
|
||||
"""Test connecting to Session A with User B token is rejected (Section 25)."""
|
||||
session_id = create_test_session(patient_id="PAT-USER-A")
|
||||
|
||||
# Connect with token for PAT-USER-B
|
||||
url = f"/api/v1/ws/onboarding/{session_id}?token=test_token_PAT-USER-B"
|
||||
with client.websocket_connect(url) as websocket:
|
||||
err = websocket.receive_json()
|
||||
assert err["type"] == "error"
|
||||
assert err["code"] == "UNAUTHORIZED"
|
||||
|
||||
|
||||
def test_websocket_end_to_end_message_flow():
|
||||
"""Required End-to-End Test (Section 38): verify complete event sequence."""
|
||||
patient_id = "PAT-WS-E2E"
|
||||
session_id = create_test_session(patient_id=patient_id)
|
||||
|
||||
url = f"/api/v1/ws/onboarding/{session_id}?token=test_token_{patient_id}"
|
||||
with client.websocket_connect(url) as websocket:
|
||||
# Receive ACK
|
||||
ack = websocket.receive_json()
|
||||
assert ack["type"] == "connection.ack"
|
||||
|
||||
# Send patient.message
|
||||
websocket.send_json({
|
||||
"type": "patient.message",
|
||||
"message_id": "msg_e2e_1",
|
||||
"text": "I have severe headache for 3 days"
|
||||
})
|
||||
|
||||
events = []
|
||||
# Expect: processing.started, typing.started, state.updated, assistant.message, typing.stopped, processing.completed
|
||||
for _ in range(6):
|
||||
event = websocket.receive_json()
|
||||
events.append(event)
|
||||
|
||||
event_types = [e["type"] for e in events]
|
||||
assert "processing.started" in event_types
|
||||
assert "typing.started" in event_types
|
||||
assert "state.updated" in event_types
|
||||
assert "assistant.message" in event_types
|
||||
assert "typing.stopped" in event_types
|
||||
assert "processing.completed" in event_types
|
||||
|
||||
# Verify assistant response properties (Section 5 & 9)
|
||||
asst_msg = next(e for e in events if e["type"] == "assistant.message")
|
||||
assert asst_msg["message_id"] == "resp_msg_e2e_1"
|
||||
assert len(asst_msg["reply"].split()) <= 20
|
||||
assert asst_msg["reply"].count("?") <= 1
|
||||
|
||||
|
||||
def test_websocket_duplicate_message_prevention():
|
||||
"""Test duplicate message_id protection returns cached response (Section 17)."""
|
||||
patient_id = "PAT-WS-DEDUP"
|
||||
session_id = create_test_session(patient_id=patient_id)
|
||||
|
||||
url = f"/api/v1/ws/onboarding/{session_id}?token=test_token_{patient_id}"
|
||||
with client.websocket_connect(url) as websocket:
|
||||
websocket.receive_json() # ACK
|
||||
|
||||
msg_payload = {
|
||||
"type": "patient.message",
|
||||
"message_id": "msg_dedup_101",
|
||||
"text": "Fever for two days"
|
||||
}
|
||||
|
||||
# First send
|
||||
websocket.send_json(msg_payload)
|
||||
events_1 = [websocket.receive_json() for _ in range(6)]
|
||||
asst_1 = next(e for e in events_1 if e["type"] == "assistant.message")
|
||||
|
||||
# Duplicate send with SAME message_id
|
||||
websocket.send_json(msg_payload)
|
||||
cached_resp = websocket.receive_json()
|
||||
assert cached_resp["type"] == "assistant.message"
|
||||
assert cached_resp["reply"] == asst_1["reply"]
|
||||
|
||||
|
||||
def test_websocket_emergency_safety_alert():
|
||||
"""Test emergency message triggers safety.alert event (Section 12)."""
|
||||
patient_id = "PAT-WS-EMERGENCY"
|
||||
session_id = create_test_session(patient_id=patient_id)
|
||||
|
||||
url = f"/api/v1/ws/onboarding/{session_id}?token=test_token_{patient_id}"
|
||||
with client.websocket_connect(url) as websocket:
|
||||
websocket.receive_json() # ACK
|
||||
|
||||
websocket.send_json({
|
||||
"type": "patient.message",
|
||||
"message_id": "msg_emerg_1",
|
||||
"text": "I have severe crushing chest pain radiating to my arm and shortness of breath"
|
||||
})
|
||||
|
||||
received = []
|
||||
for _ in range(8): # Includes safety alert and onboarding completed
|
||||
received.append(websocket.receive_json())
|
||||
|
||||
event_types = [e["type"] for e in received]
|
||||
assert "safety.alert" in event_types
|
||||
|
||||
safety_event = next(e for e in received if e["type"] == "safety.alert")
|
||||
assert safety_event["level"] in ["emergency", "crisis"]
|
||||
|
||||
|
||||
def test_websocket_invalid_json_handling():
|
||||
"""Test malformed non-JSON text returns error event without closing socket (Section 15)."""
|
||||
patient_id = "PAT-WS-BADJSON"
|
||||
session_id = create_test_session(patient_id=patient_id)
|
||||
|
||||
url = f"/api/v1/ws/onboarding/{session_id}?token=test_token_{patient_id}"
|
||||
with client.websocket_connect(url) as websocket:
|
||||
websocket.receive_json() # ACK
|
||||
|
||||
# Send raw non-JSON text
|
||||
websocket.send_text("THIS_IS_NOT_JSON")
|
||||
err_resp = websocket.receive_json()
|
||||
assert err_resp["type"] == "error"
|
||||
assert err_resp["code"] == "INVALID_MESSAGE"
|
||||
|
||||
# Verify socket is still active by sending valid message after
|
||||
websocket.send_json({
|
||||
"type": "patient.message",
|
||||
"message_id": "msg_valid_after_error",
|
||||
"text": "I am fine now"
|
||||
})
|
||||
events = [websocket.receive_json() for _ in range(6)]
|
||||
assert any(e["type"] == "assistant.message" for e in events)
|
||||
|
||||
|
||||
def test_websocket_oversized_message():
|
||||
"""Test message > 5000 chars returns MESSAGE_TOO_LARGE error (Section 16)."""
|
||||
patient_id = "PAT-WS-LARGE"
|
||||
session_id = create_test_session(patient_id=patient_id)
|
||||
|
||||
url = f"/api/v1/ws/onboarding/{session_id}?token=test_token_{patient_id}"
|
||||
with client.websocket_connect(url) as websocket:
|
||||
websocket.receive_json() # ACK
|
||||
|
||||
oversized_text = "A" * 5050
|
||||
websocket.send_json({
|
||||
"type": "patient.message",
|
||||
"message_id": "msg_large_1",
|
||||
"text": oversized_text
|
||||
})
|
||||
|
||||
err_resp = websocket.receive_json()
|
||||
assert err_resp["type"] == "error"
|
||||
assert err_resp["code"] == "MESSAGE_TOO_LARGE"
|
||||
|
||||
|
||||
def test_websocket_multilingual_unicode():
|
||||
"""Test Tamil/Hindi multilingual messages process seamlessly over WebSocket (Section 16)."""
|
||||
patient_id = "PAT-WS-TAMIL"
|
||||
session_id = create_test_session(patient_id=patient_id, language="ta")
|
||||
|
||||
url = f"/api/v1/ws/onboarding/{session_id}?token=test_token_{patient_id}"
|
||||
with client.websocket_connect(url) as websocket:
|
||||
websocket.receive_json() # ACK
|
||||
|
||||
websocket.send_json({
|
||||
"type": "patient.message",
|
||||
"message_id": "msg_ta_1",
|
||||
"text": "எனக்கு 3 நாளா தலைவலி இருக்கு"
|
||||
})
|
||||
|
||||
events = [websocket.receive_json() for _ in range(6)]
|
||||
asst = next(e for e in events if e["type"] == "assistant.message")
|
||||
assert asst["reply"] is not None
|
||||
assert len(asst["reply"]) > 0
|
||||
|
||||
|
||||
def test_rest_api_backward_compatibility():
|
||||
"""Verify Section 36 requirement: REST APIs continue working untouched."""
|
||||
session_id = create_test_session(patient_id="PAT-REST-BACKWARD")
|
||||
|
||||
# Send REST message
|
||||
res = client.post(
|
||||
f"/v1/onboarding/{session_id}/message",
|
||||
json={"message": "Headache for two days"},
|
||||
headers={"Authorization": "Bearer test_token_PAT-REST-BACKWARD"}
|
||||
)
|
||||
assert res.status_code == 200
|
||||
assert res.json()["success"] is True
|
||||
assert "message" in res.json()
|
||||
|
||||
|
||||
def test_websocket_tester_ui_endpoint():
|
||||
"""Verify that the sample UI page for WebSocket testing returns 200 OK HTML."""
|
||||
res = client.get("/ws-tester")
|
||||
assert res.status_code == 200
|
||||
assert "text/html" in res.headers["content-type"]
|
||||
assert "AI Patient Onboarding" in res.text
|
||||
assert "WebSocket" in res.text
|
||||
|
||||
Reference in New Issue
Block a user