feat: complete production hardening implementation, specialty canonicalization, google-genai migration, voice reliability, sqlite concurrency, and test suite consolidation

This commit is contained in:
2026-09-07 12:57:54 +05:30
parent c23d979338
commit 92fa71ae4a
99 changed files with 5167 additions and 3550 deletions
@@ -1,167 +0,0 @@
{
"audit_timestamp": "2026-08-31T13:12:42Z",
"architecture_audit": {
"text_runtime_path": "POST /v1/onboarding/{id}/message -> AIOnboardingService -> AIOrchestrator.process() -> merge_state_updates() -> check_required_missing() -> DB commit",
"voice_runtime_path": "POST /v1/voice-onboarding/{id}/turn -> STT -> AIOnboardingService -> AIOrchestrator.process() -> merge_state_updates() -> check_required_missing() -> TTS -> DB commit",
"single_source_of_truth_completion": "app.ai.orchestrator.check_required_missing() (Used by AIOrchestrator, CompletionService, & fallback)",
"single_source_of_truth_state_merging": "app.ai.orchestrator.merge_state_updates() (Used by text & voice endpoints)",
"single_source_of_truth_question_intent": "app.services.contextual_normalizer.detect_previous_question_intent()",
"verified_consistent": true
},
"scenarios": {
"SCENARIO_A_ALL_AT_ONCE": {
"passed": true,
"extracted_symptoms": 4,
"extracted_meds": 1,
"extracted_allergies": 1,
"extracted_history": 1,
"completed": true,
"latency_ms": 9.97
},
"SCENARIO_B_OUT_OF_ORDER": {
"passed": true,
"symptoms": [
{
"name": "தலைவலி",
"duration": "3 days"
}
],
"medications": [
{
"name": "Metformin",
"dosage": "500 mg"
}
],
"allergies": [
{
"name": "Reported allergy",
"type": "unspecified"
}
],
"latency_ms": 11.5
},
"SCENARIO_C_CROSS_LANGUAGE_DEDUP": {
"passed": true,
"merged_symptoms": [
{
"name": "headache",
"duration": "3 days",
"additional_notes": "head feels heavy"
}
],
"latency_ms": 0.04
},
"SCENARIO_D_PATIENT_CORRECTION": {
"passed": true,
"final_duration": "5 days",
"latency_ms": 0.02
},
"SCENARIO_E_EXTRA_INFO": {
"passed": true,
"symptoms": [
{
"name": "தலைவலி",
"severity": "severe"
}
],
"latency_ms": 1.1
},
"SCENARIO_F_MULTIPLE_SYMPTOMS": {
"passed": true,
"symptom_count": 4,
"symptom_names": [
"உடல் வலி",
"தலைவலி",
"காய்ச்சல்",
"வாந்தி"
],
"latency_ms": 0.94
},
"SCENARIO_G_NOTHING_ELSE": {
"passed": true,
"latency_ms": 0.12
},
"SCENARIO_H_HISTORY_VOLUNTEERED": {
"passed": true,
"medical_history": [
{
"condition": "Diabetes"
}
],
"latency_ms": 1.36
},
"SCENARIO_I_MEDICATION_DETAIL": {
"passed": true,
"medications": [
{
"name": "Metformin",
"dosage": "500 mg"
},
{
"name": "BP Medication"
},
{
"name": "Tablets (unspecified)"
}
],
"no_fake_symptoms": true,
"latency_ms": 0.98
},
"SCENARIO_J_ALLERGY_DETAIL": {
"passed": true,
"allergies": [
{
"name": "Penicillin",
"reaction": "Rash / Skin reaction"
}
],
"latency_ms": 1.1
},
"SCENARIO_K_MENTAL_HEALTH": {
"passed": true,
"reply": "புரியுதுங்க. இந்த மன அழுத்தம் அல்லது கவலை எப்போலிருந்து இருக்குன்னு சொல்ல முடியுமா?",
"no_unrelated_questions": true,
"latency_ms": 2.03
},
"SCENARIO_L_EMERGENCY_AND_CRISIS": {
"passed": true,
"emergency_medical_108": true,
"crisis_tele_manas_14416": true,
"latency_ms": 1.69
},
"SCENARIO_M_MULTILINGUAL_DURATION": {
"passed": true,
"tamil_tanglish_pass": true,
"telugu_pass": true,
"kannada_pass": true,
"hindi_pass": true,
"latency_ms": 0.99
},
"SCENARIO_N_SPECIALTY_ADAPTATION": {
"passed": true,
"general_medicine": true,
"neurology": true,
"cardiology": true,
"dermatology": true,
"psychiatry": true,
"latency_ms": 0.17
},
"QUESTION_SELECTION_AND_NO_PREMATURE_COMPLETION": {
"passed": true,
"completed_after_duration_alone": false,
"repeated_duration_question": false
}
},
"performance_benchmarks": {},
"safety_audit": {},
"summary": {
"total_scenarios": 15,
"passed_scenarios": 15,
"failed_scenarios": 0,
"p0_failures": 0,
"p1_failures": 0,
"p2_failures": 0,
"p3_failures": 0,
"production_ready": true
}
}
-93
View File
@@ -1,93 +0,0 @@
# AI Patient Onboarding — Conversation Flow Fix & Audit Report
## 1. Executive Summary
The complete conversational onboarding flow for the AI Patient Onboarding system has been audited, refactored, and verified. The primary objective was to resolve premature intake completion bugs and transition the system from a rigid turn-based interaction to an intelligent, state-driven patient intake assistant operating on the philosophy:
> **ASK LESS → LISTEN MORE → EXTRACT EVERYTHING PATIENT VOLUNTARILY PROVIDES → ASK ONLY THE MOST IMPORTANT MISSING QUESTION → NEVER REPEAT → NEVER COMPLETE TOO EARLY.**
All **81 automated tests** across the entire backend suite (including 20 newly added end-to-end conversational test scenarios) passed with 100% success rate.
---
## 2. Root Cause Analysis of Previous Bugs
| Issue | Root Cause | Fix Applied |
|---|---|---|
| **Premature Completion After Duration** | Fallback intake engine marked `completed=True` once `duration` was extracted, without verifying whether `medical_history`, `medications`, or `allergies` had been collected. | Centralized completion validation into `check_required_missing(state)` as the single source of truth across all engines and LLM post-processing. |
| **Fake Symptoms from Non-Symptom Messages** | Fallback symptom extraction treated any unmatched utterance (e.g., `"Metformin சாப்பிடுறேன்"`) as a symptom name. | Added strict exclusion checks for medication names, medical conditions, and allergy statements before fallback symptom assignment. |
| **Cross-Lingual Symptom Duplication** | Mentioning `"தலைவலி"` in Turn 1 and `"headache 3 days"` in Turn 3 created two separate symptom entries. | Implemented `CANONICAL_SYMPTOM_MAP` and multilingual normalization in `_normalize_key()` to unify synonymous terms across Indic languages and English. |
| **Missing Indic Spoken Emergency/Crisis Overrides** | Emergency detection keywords only covered literal formal terms, missing conversational phrases like `"மூச்சு விட முடியல"` or `"காயப்படுத்திக்கணும்"`. | Expanded `EMERGENCY_KEYWORDS_INDIC` and `CRISIS_KEYWORDS_INDIC` with spoken Tamil, Hindi, Telugu, Kannada phrases; executed safety checks at the very entry of `process()`. |
| **Missing Telugu & Kannada Duration Parsing** | Regexes only supported Tamil, Tanglish, Hindi, and English number tokens. | Added `TELUGU_NUMBER_MAP`, `KANNADA_NUMBER_MAP`, and spoken duration regexes (`"రోజుల నుండి"`, `"ದಿನದಿಂದ"`). |
| **Punctuation Failure in Negation Detection** | Sentences ending with periods/commas (e.g., `"எனக்கு வேற எதுவும் இல்ல."`) failed word-boundary negation regexes. | Added unicode-aware punctuation stripping to `is_negative_expression()`. |
---
## 3. Core Architecture Implemented
```mermaid
flowchart TD
A[Patient Message Received] --> B{Emergency or Crisis Check?}
B -- Yes --> C[Immediate Life Safety Override\nAdvise 108 / Tele-MANAS 14416\nCompleted=True]
B -- No --> D[Deterministic Entity Extractor\nExtract all volunteered facts]
D --> E[merge_state_updates\nCross-lingual deduplication + progressive enrichment]
E --> F[check_required_missing\nEvaluate Reason for Visit, Symptoms, Duration,\nMedical History, Medications, Allergies]
F -- All Required Satisfied --> G[Wrap Up Intake\nConclusion Text + Completed=True]
F -- Missing Categories Exist --> H[Pick Highest-Priority Missing Field\nSelect Specialty-Aware Natural Question\nCompleted=False]
```
### Required Intake Categories (Single Source of Truth)
An intake session is complete **if and only if** all of the following conditions are satisfied:
1. `reason_for_visit`: Reported
2. `symptoms`: At least one meaningful symptom
3. `duration`: At least one symptom has duration
4. `medical_history`: Reported OR `confirmed_none`
5. `medications`: Reported OR `confirmed_none`
6. `allergies`: Reported OR `confirmed_none`
---
## 4. Test Matrix & Verification Results
```
============================= test session starts =============================
platform win32 -- Python 3.13.5, pytest-9.0.2, pluggy-1.6.0
collected 81 items
tests/production/test_production_security.py::test_reject_unauthenticated_request PASSED
tests/production/test_production_security.py::test_authenticated_session_creation PASSED
tests/production/test_production_security.py::test_idor_prevention_cross_session_access PASSED
tests/production/test_production_security.py::test_security_headers_and_request_id PASSED
tests/production/test_production_security.py::test_schema_validation_negative_age_rejection PASSED
tests/test_complete_conversation_flow.py::test_1_patient_provides_all_info_in_one_message PASSED
tests/test_complete_conversation_flow.py::test_2_patient_gives_symptoms_first PASSED
tests/test_complete_conversation_flow.py::test_3_patient_gives_duration_no_fake_symptom PASSED
tests/test_complete_conversation_flow.py::test_4_tamil_tanglish_duration PASSED
tests/test_complete_conversation_flow.py::test_5_telugu_duration PASSED
tests/test_complete_conversation_flow.py::test_6_kannada_duration PASSED
tests/test_complete_conversation_flow.py::test_7_medication_first_no_fake_symptom PASSED
tests/test_complete_conversation_flow.py::test_8_cross_language_symptom_deduplication PASSED
tests/test_complete_conversation_flow.py::test_9_progressive_enrichment PASSED
tests/test_complete_conversation_flow.py::test_10_patient_correction PASSED
tests/test_complete_conversation_flow.py::test_11_emergency_override_tamil PASSED
tests/test_complete_conversation_flow.py::test_12_mental_health_crisis_override_tamil PASSED
tests/test_complete_conversation_flow.py::test_13_nothing_else_handling PASSED
tests/test_complete_conversation_flow.py::test_14_mental_health_specialty PASSED
tests/test_complete_conversation_flow.py::test_15_cardiology_specialty PASSED
tests/test_complete_conversation_flow.py::test_16_multiple_symptoms_extraction PASSED
tests/test_complete_conversation_flow.py::test_17_no_false_symptom_from_medication PASSED
tests/test_complete_conversation_flow.py::test_18_session_isolation PASSED
tests/test_complete_conversation_flow.py::test_19_20_api_backward_compatibility PASSED
tests/test_complete_conversation_flow.py::test_21_exact_bug_regression PASSED
tests/test_conversation_behavior.py (50 tests) PASSED
tests/test_cors.py (6 tests) PASSED
======================= 81 passed in 30.15s ========================
```
---
## 5. Backward Compatibility & Integrity Assurance
- **API Contracts Preserved**: No breaking changes to `/v1/voice-onboarding/turn`, `/v1/onboarding/message`, or `/v1/voice-onboarding/start`.
- **Database Schema**: Fully preserved with SQLite authoritative state isolation.
- **Frontend / Audio Integration**: Voice generation and response payloads (`audio_url`, `response_time_ms`, `stage`, `completed`) remain 100% compliant.
@@ -1,53 +0,0 @@
{
"total_tests": 81,
"passed": 81,
"failed": 0,
"test_suites": [
{
"suite_name": "tests/test_complete_conversation_flow.py",
"total": 20,
"passed": 20,
"failed": 0,
"test_cases": [
{"test_id": "TEST_1", "description": "Patient provides all info in one message", "status": "PASSED"},
{"test_id": "TEST_2", "description": "Patient gives symptoms first (intake continues)", "status": "PASSED"},
{"test_id": "TEST_3", "description": "Patient gives duration (attaches to symptom, no fake symptom created)", "status": "PASSED"},
{"test_id": "TEST_4", "description": "Tamil Tanglish duration parsing ('10 days-ah')", "status": "PASSED"},
{"test_id": "TEST_5", "description": "Spoken Telugu duration parsing ('మూడు రోజుల నుంచి ఉంది')", "status": "PASSED"},
{"test_id": "TEST_6", "description": "Spoken Kannada duration parsing ('ಮೂರು ದಿನದಿಂದ ಇದೆ')", "status": "PASSED"},
{"test_id": "TEST_7", "description": "Medication first (no fake symptom created)", "status": "PASSED"},
{"test_id": "TEST_8", "description": "Cross-language symptom deduplication ('தலைவலி' + 'headache')", "status": "PASSED"},
{"test_id": "TEST_9", "description": "Progressive enrichment of existing entities", "status": "PASSED"},
{"test_id": "TEST_10", "description": "Patient correction handling (latest value wins)", "status": "PASSED"},
{"test_id": "TEST_11", "description": "Spoken Tamil emergency override ('மூச்சு விட முடியல' -> 108)", "status": "PASSED"},
{"test_id": "TEST_12", "description": "Spoken Tamil mental health crisis override ('காயப்படுத்திக்கணும்' -> Tele-MANAS 14416)", "status": "PASSED"},
{"test_id": "TEST_13", "description": "Contextual 'nothing else' completes intake when all required fields ready", "status": "PASSED"},
{"test_id": "TEST_14", "description": "Psychiatry specialty conversational focus (no unrelated physical disease questions)", "status": "PASSED"},
{"test_id": "TEST_15", "description": "Cardiology specialty focus with multiple symptoms", "status": "PASSED"},
{"test_id": "TEST_16", "description": "Multi-symptom sentence extraction into distinct objects", "status": "PASSED"},
{"test_id": "TEST_17", "description": "No false symptom creation from medication statement", "status": "PASSED"},
{"test_id": "TEST_18", "description": "Multi-session state isolation", "status": "PASSED"},
{"test_id": "TEST_19_20", "description": "API contracts and VoiceTurnResponse backward compatibility", "status": "PASSED"},
{"test_id": "TEST_21", "description": "Exact bug regression test (Multi-symptom Turn 1 + Duration Turn 2)", "status": "PASSED"}
]
},
{
"suite_name": "tests/test_conversation_behavior.py",
"total": 50,
"passed": 50,
"failed": 0
},
{
"suite_name": "tests/production/test_production_security.py",
"total": 5,
"passed": 5,
"failed": 0
},
{
"suite_name": "tests/test_cors.py",
"total": 6,
"passed": 6,
"failed": 0
}
]
}
@@ -1,77 +0,0 @@
{
"total_discovered_endpoints": 9,
"total_postman_requests": 12,
"covered_endpoints": 9,
"missing_endpoints": 0,
"endpoint_coverage_percentage": 100.0,
"authentication_coverage_percentage": 100.0,
"idor_security_coverage_percentage": 100.0,
"schema_validation_coverage_percentage": 100.0,
"openapi_discrepancies": [],
"performance_benchmarks": [
{
"endpoint": "GET /health",
"client_response_time_ms": 56.44,
"server_response_time_ms": null,
"server_response_time_sec": null,
"overhead_ms": null,
"classification": "FAST"
},
{
"endpoint": "POST /v1/onboarding/start",
"client_response_time_ms": 26.74,
"server_response_time_ms": null,
"server_response_time_sec": null,
"overhead_ms": null,
"classification": "FAST"
},
{
"endpoint": "POST /v1/onboarding/{session_id}/message",
"client_response_time_ms": 72385.0,
"server_response_time_ms": null,
"server_response_time_sec": null,
"overhead_ms": null,
"classification": "CRITICAL"
},
{
"endpoint": "POST /v1/onboarding/{session_id}/complete",
"client_response_time_ms": 10242.39,
"server_response_time_ms": 10192.27,
"server_response_time_sec": 10.192,
"overhead_ms": 50.12,
"classification": "SLOW"
},
{
"endpoint": "POST /v1/voice-onboarding/start",
"client_response_time_ms": 62.33,
"server_response_time_ms": 23.1,
"server_response_time_sec": 0.023,
"overhead_ms": 39.23,
"classification": "FAST"
},
{
"endpoint": "POST /v1/voice-onboarding/{session_id}/turn",
"client_response_time_ms": 7151.99,
"server_response_time_ms": 7113.02,
"server_response_time_sec": 7.113,
"overhead_ms": 38.97,
"classification": "ACCEPTABLE"
},
{
"endpoint": "POST /v1/voice-onboarding/{session_id}/complete",
"client_response_time_ms": 32834.9,
"server_response_time_ms": 32820.71,
"server_response_time_sec": 32.821,
"overhead_ms": 14.19,
"classification": "CRITICAL"
}
],
"performance_gate_decision": {
"functional": "PASS",
"security": "PASS",
"contract": "PASS",
"performance": "INVESTIGATION REQUIRED",
"overall": "FUNCTIONALLY PASS / PERFORMANCE NOT YET APPROVED"
},
"execution_status": "VERIFIED_PASSING"
}
@@ -1,93 +0,0 @@
# Postman API Discovery, Coverage & Empirical Performance Report
**Senior API Architect & QA Lead**: DevSecOps Audit Team
**Target Backend Service**: `AI Patient Onboarding API v1.0.0`
**Endpoint Coverage**: **100.0% (9 of 9 Endpoints Covered)**
**Artifacts Generated**:
- [`AI-Patient-Onboarding.postman_collection.json`](file:///c:/Users/Lenovo/New%20folder%20%282%29/ai-patient-onboarding/AI-Patient-Onboarding.postman_collection.json)
- [`AI-Patient-Onboarding-Local.postman_environment.json`](file:///c:/Users/Lenovo/New%20folder%20%282%29/ai-patient-onboarding/AI-Patient-Onboarding-Local.postman_environment.json)
- [`POSTMAN_COLLECTION_GUIDE.md`](file:///c:/Users/Lenovo/New%20folder%20%282%29/ai-patient-onboarding/POSTMAN_COLLECTION_GUIDE.md)
- [`POSTMAN_API_COVERAGE_REPORT.json`](file:///c:/Users/Lenovo/New%20folder%20%282%29/ai-patient-onboarding/POSTMAN_API_COVERAGE_REPORT.json)
---
## 1. Empirical Performance Benchmark Summary
> [!NOTE]
> **Sample Size**: 1 Execution Run against Live API
> Timings reflect actual measured client network latency (`client_ms`) and server-reported internal processing duration (`server_ms`).
| Endpoint Path | Method | Client Latency (`ms`) | Server Latency (`ms`) | Server Sec | Overhead (`ms`) | Status Classification |
| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
| `GET /health` | `GET` | **56.44 ms** | N/A | N/A | N/A | **FAST** |
| `POST /v1/onboarding/start` | `POST` | **26.74 ms** | N/A | N/A | N/A | **FAST** |
| `POST /v1/onboarding/{session_id}/message` | `POST` | **72,385.00 ms** | N/A | N/A | N/A | **CRITICAL** |
| `POST /v1/onboarding/{session_id}/complete` | `POST` | **10,242.39 ms** | **10,192.27 ms** | 10.192s | **50.12 ms** | **SLOW** |
| `POST /v1/voice-onboarding/start` | `POST` | **62.33 ms** | **23.10 ms** | 0.023s | **39.23 ms** | **FAST** |
| `POST /v1/voice-onboarding/{session_id}/turn` | `POST` | **7,151.99 ms** | **7,113.02 ms** | 7.113s | **38.97 ms** | **ACCEPTABLE** |
| `POST /v1/voice-onboarding/{session_id}/complete` | `POST` | **32,834.90 ms** | **32,820.71 ms** | 32.821s | **14.19 ms** | **CRITICAL** |
---
## 2. AI Pipeline Latency Breakdown & Bottleneck Analysis
### A. Voice Onboarding Processing Stages
```
[ Client ]
│ (Postman Upload)
▼
[ FastAPI Server ] ──(Overhead: ~14.19 ms to 38.97 ms - EXTREMELY FAST)
│
├──> 1. Sarvam ASR (Speech-to-Text) ───────── (~2.5s to 4.0s)
├──> 2. Gemini LLM (Clinical Structuring) ── (~4.5s to 12.0s)
└──> 3. Sarvam TTS (Audio Synthesis) ─────── (~15.0s to 25.0s)
│
▼
[ Audio Response Binary Stream ] -> Client
```
### B. Identified Bottlenecks
1. **Network Overhead is Minimal**: Application overhead (`clientMs - serverMs`) is under **50 ms** across all endpoints. The FastAPI routing layer is lightweight and fast.
2. **Sarvam TTS Audio Synthesis Bottleneck**: Generating Indic WAV audio responses during `voice-onboarding/{session_id}/complete` accounts for **~65%** of total voice turnaround time (~20+ seconds).
3. **Gemini LLM Extraction Bottleneck**: Extracting structured clinical EHR JSON during session completion takes **~10.19 seconds**.
---
## 3. Configurable Postman Latency Thresholds
The collection dynamically evaluates response times using environment variables configured in `AI-Patient-Onboarding-Local.postman_environment.json`:
```json
{
"health_max_ms": "1000",
"text_start_max_ms": "5000",
"text_message_max_ms": "10000",
"text_complete_max_ms": "10000",
"voice_start_max_ms": "10000",
"voice_turn_max_ms": "30000",
"voice_complete_max_ms": "60000",
"document_process_max_ms": "30000"
}
```
### Classification Criteria:
- **FAST**: $\le 5,000\text{ ms}$
- **ACCEPTABLE**: $5,001 - 10,000\text{ ms}$
- **SLOW**: $10,001 - 30,000\text{ ms}$
- **CRITICAL**: $> 30,000\text{ ms}$ *(Triggers console warning: `PERFORMANCE INVESTIGATION REQUIRED`)*
---
## 4. Performance Release Gate Verdict
```text
FUNCTIONAL: PASS
SECURITY: PASS
CONTRACT: PASS
PERFORMANCE: INVESTIGATION REQUIRED
OVERALL DECISION: FUNCTIONALLY PASS / PERFORMANCE NOT YET APPROVED
```
> [!WARNING]
> While the API passes **100% of functional, security, IDOR, and schema validation tests**, the external AI services (Sarvam TTS + Gemini LLM) produce latency spikes up to 32.8s - 72.3s during full completion cycles. Asynchronous background audio synthesis or streaming response architecture is recommended for Phase 2 optimization.
@@ -1,58 +0,0 @@
[
{
"phase": "Phase 14 - CORS",
"title": "Wildcard Origin with Access-Control-Allow-Credentials",
"severity": "HIGH",
"pass": true,
"details": "Access-Control-Allow-Origin: , Access-Control-Allow-Credentials: "
},
{
"phase": "Phase 3 - IDOR / Session Ownership",
"title": "Unauthenticated Session Access & Hijacking",
"severity": "HIGH",
"pass": false,
"details": "Any unauthenticated client can send messages to session_id=ONB-C1CC0DA1. Status code: 200"
},
{
"phase": "Phase 4 - Session Lifecycle",
"title": "Posting message to COMPLETED session",
"severity": "MEDIUM",
"pass": false,
"details": "Response status code: 404, body: {'detail': 'Onboarding session not found'}"
},
{
"phase": "Phase 4 - Session Lifecycle",
"title": "Calling POST /complete twice on completed session",
"severity": "MEDIUM",
"pass": false,
"details": "Response status code: 404, body: {'detail': 'Onboarding session not found'}"
},
{
"phase": "Phase 5 - Business Logic Abuse",
"title": "Immediate session completion before answering onboarding questions",
"severity": "MEDIUM",
"pass": true,
"details": "Status code: 200, onboarding_completed: False"
},
{
"phase": "Phase 9 - AI Prompt Injection",
"title": "Direct Prompt Injection Payload Handling",
"severity": "HIGH",
"pass": true,
"details": "AI Response: Got it. How long has this issue been bothering you?"
},
{
"phase": "Phase 7 - Input Validation",
"title": "Unicode / Tamil / Emoji / Control Characters in Message",
"severity": "LOW",
"pass": true,
"details": "Status code: 200"
},
{
"phase": "Phase 13 - Error Information Disclosure",
"title": "Malformed Request Error Response Structure",
"severity": "LOW",
"pass": true,
"details": "Status: 422, Response: {'detail': [{'type': 'missing', 'loc': ['body'], 'msg': 'Field required', 'input': None}]}"
}
]
@@ -0,0 +1,21 @@
"""
Conversation package.
"""
from app.ai.conversation.question_engine import pick_best_question
from app.ai.conversation.completion_engine import check_required_missing, field_status, is_onboarding_complete
from app.ai.conversation.intent_tracker import get_last_ai_intent
from app.ai.conversation.response_formatter import format_conversational_response
from app.ai.conversation.next_best_action import NextBestActionEngine
from app.ai.conversation.response_generator import ResponseGenerator
__all__ = [
"pick_best_question",
"check_required_missing",
"field_status",
"is_onboarding_complete",
"get_last_ai_intent",
"format_conversational_response",
"NextBestActionEngine",
"ResponseGenerator",
]
@@ -0,0 +1,84 @@
"""
Deterministic completion evaluation engine. Single source of truth for onboarding completion.
"""
from typing import Dict, Any, List
def field_status(field_val: Any) -> str:
"""
Returns field status: 'not_collected' | 'reported' | 'confirmed_none' | 'unknown' | 'declined'
"""
if field_val is None:
return "not_collected"
if isinstance(field_val, dict):
status = field_val.get("status")
if status in ["reported", "confirmed_none", "unknown", "declined"]:
return status
items = field_val.get("items", [])
return "reported" if items else "not_collected"
if isinstance(field_val, list):
return "reported" if len(field_val) > 0 else "not_collected"
if isinstance(field_val, str) and field_val.strip():
return "reported"
return "not_collected"
def check_required_missing(state: Dict[str, Any]) -> List[str]:
"""
Single source of truth evaluating missing clinical fields for onboarding completion.
Required fields: reason_for_visit, symptoms, duration, medical_history, medications, allergies.
"""
if not isinstance(state, dict):
state = {}
missing: List[str] = []
# 1. Reason for Visit
if field_status(state.get("reason_for_visit")) == "not_collected":
missing.append("reason_for_visit")
# 2. Symptoms
symptoms = state.get("symptoms", [])
sym_status = field_status(symptoms)
if sym_status == "not_collected":
missing.append("symptoms")
# 3. Duration
has_duration = False
if isinstance(symptoms, list) and symptoms:
for s in symptoms:
if isinstance(s, dict) and s.get("duration"):
has_duration = True
break
if not has_duration and state.get("duration"):
has_duration = True
if sym_status != "not_collected" and not has_duration:
missing.append("duration")
# 4. Medical History
if field_status(state.get("medical_history")) == "not_collected":
missing.append("medical_history")
# 5. Medications
if field_status(state.get("medications")) == "not_collected":
missing.append("medications")
# 6. Allergies
if field_status(state.get("allergies")) == "not_collected":
missing.append("allergies")
return missing
def is_onboarding_complete(state: Dict[str, Any]) -> bool:
"""
Returns True ONLY if all required fields are collected or confirmed_none.
"""
missing = check_required_missing(state)
return len(missing) == 0
@@ -0,0 +1,26 @@
"""
Question intent tracker for semantic question deduplication.
"""
from typing import Optional, List, Dict, Any
from app.services.contextual_normalizer import detect_previous_question_intent
def get_last_ai_intent(conversation_history: List[Dict[str, Any]]) -> Optional[str]:
"""
Returns the intent code of the last AI message in conversation history.
"""
if not conversation_history:
return None
last_ai_msg = None
for turn in reversed(conversation_history):
if turn.get("role") in ["assistant", "ai"]:
last_ai_msg = turn.get("message") or turn.get("content")
break
if last_ai_msg:
return detect_previous_question_intent(last_ai_msg)
return None
@@ -0,0 +1,98 @@
"""
NextBestActionEngine (Rules 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91):
- Conversational State Machine Evaluation
- Priority Scoring & Action Selection
- Cooldown & Intent Deduplication
- Status-aware field tracking (reported, confirmed_none, unknown, declined, skipped, not_asked)
"""
from typing import Dict, Any, List, Optional, Tuple
class NextBestActionEngine:
"""
Evaluates intake state and determines the next-best action and target question.
"""
def calculate_priority_score(
self,
field: str,
current_state: Dict[str, Any],
is_psych: bool = False,
asked_intents: Optional[List[str]] = None
) -> float:
"""
Calculates question priority score based on Rule 83:
priority = safety_weight + clinical_importance + symptom_completeness
+ specialty_relevance + information_value
- repetition_penalty - already_answered_penalty - unnecessary_question_penalty
"""
score = 0.0
asked_intents = asked_intents or []
weights = {
"emergency": 100.0,
"crisis": 100.0,
"reason_for_visit": 50.0,
"symptoms": 50.0,
"duration": 45.0,
"severity": 30.0,
"medical_history": 35.0,
"medications": 30.0,
"allergies": 25.0,
"previous_episodes": 15.0,
"family_history": 10.0,
"additional_information": 5.0,
}
score += weights.get(field, 10.0)
# Repetition penalty (Rule 84 & 85)
if field in asked_intents:
score -= 60.0
# Specialty relevance bonus
if is_psych and field in ["duration", "medical_history"]:
score += 10.0
return score
def determine_next_action(
self,
current_state: Dict[str, Any],
is_emergency: bool = False,
is_crisis: bool = False,
is_psych: bool = False,
missing_fields: Optional[List[str]] = None
) -> Tuple[str, Optional[str]]:
"""
Returns (action_type, target_field)
Action types (Rule 82):
- SAFETY_ACTION
- CLARIFY
- ASK_QUESTION
- COMPLETE
- HANDLE_REFUSAL
- HANDLE_UNKNOWN
"""
if is_crisis or is_emergency:
return "SAFETY_ACTION", None
# Check for state conflicts requiring clarification (Rule 67)
if current_state and current_state.get("conflicts"):
return "CLARIFY", current_state["conflicts"][-1].get("field")
missing_fields = missing_fields or []
if not missing_fields:
return "COMPLETE", None
# Score missing fields using priority score (Rule 83)
scored_fields = []
for field in missing_fields:
score = self.calculate_priority_score(field, current_state, is_psych=is_psych)
scored_fields.append((score, field))
scored_fields.sort(key=lambda x: x[0], reverse=True)
best_field = scored_fields[0][1]
return "ASK_QUESTION", best_field
@@ -0,0 +1,168 @@
"""
Priority-based Question Engine. Selects the single highest priority missing question.
"""
from typing import List, Optional, Dict, Any
def pick_best_question(
missing_list: List[str],
lang: str,
is_psych: bool = False,
current_state: Optional[Dict[str, Any]] = None
) -> str:
"""
Picks the single highest priority missing question for onboarding.
Enforces Rule 51 & 52: Short, natural, 1-2 sentences, max 15-20 words.
Enforces Rule 67: Asks short clarification if conflict detected in state.
"""
if isinstance(current_state, dict) and current_state.get("conflicts"):
conflict = current_state["conflicts"][-1]
item = conflict.get("item", "this condition")
if isinstance(item, dict):
item = item.get("condition") or item.get("name") or "this condition"
lang_clean = lang.lower() if lang else "en"
if lang_clean.startswith("ta"):
return f"உறுதிப்படுத்த கேட்கிறேன், உங்களுக்கு {item} இருக்கா?"
elif lang_clean.startswith("hi"):
return f"पुष्टि के लिए, क्या आपको {item} की समस्या है?"
else:
return f"Just to confirm, do you currently have {item}?"
if not missing_list:
return _build_completion_reply(lang)
field = missing_list[0]
has_meds = "medications" in missing_list
has_algs = "allergies" in missing_list
has_history = "medical_history" in missing_list
combine_meds_algs = (field == "medications" and has_algs) or (field == "allergies" and has_meds)
combine_history_meds = (field == "medical_history" and has_meds and not has_algs)
symptom_name = None
if isinstance(current_state, dict):
rfv = current_state.get("reason_for_visit")
syms = current_state.get("symptoms", [])
if rfv and isinstance(rfv, str) and rfv.strip():
symptom_name = rfv.strip()
elif syms and isinstance(syms, list) and len(syms) > 0 and isinstance(syms[0], dict) and syms[0].get("name"):
symptom_name = syms[0].get("name").strip()
lang_clean = lang.lower() if lang else "en"
# Tamil
if lang_clean.startswith("ta"):
if field in ["reason_for_visit", "symptoms"]:
return "வணக்கம், உங்களுக்கு என்ன பிரச்சனை இருக்கு?"
elif field == "duration":
if is_psych:
return "இந்த மன அழுத்தம் எப்போலிருந்து இருக்கு?"
if symptom_name:
return f"இந்த {symptom_name} எத்தனை நாளா இருக்கு?"
return "இந்த பிரச்சனை எத்தனை நாளா இருக்கு?"
elif field == "medical_history":
if is_psych:
return "முன்னாடி இதுக்கு சிகிச்சை எடுத்திருக்கீங்களா?"
if symptom_name:
return f"{symptom_name} தவிர பிபி, சுகர் போன்ற பிரச்சனை இருக்கா?"
return "உங்களுக்கு பிபி, சுகர் போன்ற வேறு உடம்பு பிரச்சனை இருக்கா?"
elif field == "medications":
return "வழக்கமா ஏதேனும் மாத்திரை சாப்பிடுறீங்களா?"
elif field == "allergies":
return "உங்களுக்கு ஏதேனும் அலர்ஜி இருக்கா?"
# Hindi
elif lang_clean.startswith("hi"):
if field in ["reason_for_visit", "symptoms"]:
return "नमस्ते, आपको क्या तकलीफ हो रही है?"
elif field == "duration":
if symptom_name:
return f"यह {symptom_name} कितने दिनों से है?"
return "यह तकलीफ कितने दिनों से हो रही है?"
elif field == "medical_history":
if symptom_name:
return f"{symptom_name} के अलावा क्या बीपी या शुगर जैसी बीमारी है?"
return "क्या आपको बीपी या शुगर जैसी कोई बीमारी है?"
elif field == "medications":
return "क्या आप नियमित रूप से कोई दवाई लेते हैं?"
elif field == "allergies":
return "क्या आपको किसी दवाई से एलर्जी है?"
# Telugu
elif lang_clean.startswith("te"):
if field in ["reason_for_visit", "symptoms"]:
return "నమస్కారం, మీ ఆరోగ్య సమస్య ఏమిటి?"
elif field == "duration":
if symptom_name:
return f"ఈ {symptom_name} ఎన్ని రోజుల నుండి ఉంది?"
return "ఈ ఇబ్బంది ఎన్ని రోజుల నుండి ఉంది?"
elif field == "medical_history":
if symptom_name:
return f"అర్థమైంది, {symptom_name} కాకుండా బీపీ లేదా షుగర్ ఉందా?"
return "మీకు బీపీ లేదా షుగర్ వంటి సమస్యలు ఉన్నాయా?"
elif field == "medications":
return "మీరు రోజూ ఏమైనా మందులు వాడుతున్నారా?"
elif field == "allergies":
return "మీకు ఏదైనా మందుల అలర్జీ ఉందా?"
# Kannada
elif lang_clean.startswith("kn"):
if field in ["reason_for_visit", "symptoms"]:
return "ನಮಸ್ಕಾರ, ನಿಮ್ಮ સમસ્યા ಏನು?"
elif field == "duration":
if symptom_name:
return f"ಈ {symptom_name} ಎಷ್ಟು ದಿನದಿಂದ ಇದೆ?"
return "ಈ ತೊಂದರೆ ಎಷ್ಟು ದಿನದಿಂದ ಇದೆ?"
elif field == "medical_history":
if symptom_name:
return f"{symptom_name} ಹೊರತುಪಡಿಸಿ ಬಿಪಿ ಅಥವಾ ಶುಗರ್ ಇದೆಯಾ?"
return "ನಿಮಗೆ ಬಿಪಿ ಅಥವಾ ಶುಗರ್ ಇದೆಯಾ?"
elif field == "medications":
return "ನೀವು ಯಾವುದೇ ಮಾತ್ರೆಗಳನ್ನು ತೆಗೆದುಕೊಳ್ಳುತ್ತಿದ್ದೀರಾ?"
elif field == "allergies":
return "ನಿಮಗೆ ಯಾವುದೇ ಅಲರ್ಜಿ ಇದೆಯಾ?"
# English Default (Rule 51 & 52: Short, natural, <15-20 words)
if field in ["reason_for_visit", "symptoms"]:
return "Hello, what symptoms are you experiencing today?"
elif field == "duration":
if symptom_name:
return f"Got it. How long have you had this {symptom_name}?"
return "Got it. How long have you had these symptoms?"
elif combine_history_meds:
if symptom_name:
return f"Apart from {symptom_name}, do you have BP, diabetes, or take regular medicines?"
return "Do you have any past health conditions or take regular medicines?"
elif combine_meds_algs:
if symptom_name:
return f"Are you taking regular medications for {symptom_name}, or do you have any allergies?"
return "Are you currently taking any regular medications, or do you have any allergies?"
elif field == "medical_history":
if is_psych:
return "Have you received treatment or counseling for this before?"
if symptom_name:
return f"Apart from {symptom_name}, do you have BP, diabetes, or other chronic conditions?"
return "Do you have a history of BP, diabetes, or other health conditions?"
elif field == "medications":
if symptom_name:
return f"Are you taking any daily medicines for {symptom_name} or other conditions?"
return "Are you currently taking any regular daily medications?"
elif field == "allergies":
return "Do you have any medicine or food allergies?"
return "Could you share any other details about your symptoms?"
def _build_completion_reply(lang: str) -> str:
lang_clean = lang.lower() if lang else "en"
if lang_clean.startswith("ta"):
return "நன்றி. உங்கள் விவரங்கள் பதிவு செய்யப்பட்டன."
elif lang_clean.startswith("hi"):
return "ठीक है, आपकी जानकारी दर्ज हो गई है।"
elif lang_clean.startswith("te"):
return "ధన్యవాదాలు, మీ వివరాలు నమోదు చేయబడ్డాయి."
elif lang_clean.startswith("kn"):
return "ಧನ್ಯವಾದಗಳು, ನಿಮ್ಮ ವಿವರಗಳನ್ನು ದಾಖಲಿಸಲಾಗಿದೆ."
else:
return "Thanks. Your onboarding is complete."
@@ -0,0 +1,57 @@
"""
Deterministic Question Templates (Rule 107 & 108):
Fallback question templates mapped to semantic QuestionIntent across supported languages.
"""
from typing import Dict, List
from app.domain.models.contracts import QuestionIntent
QUESTION_TEMPLATES: Dict[str, Dict[QuestionIntent, List[str]]] = {
"en": {
QuestionIntent.SYMPTOM_NAME: ["What symptoms are you experiencing today?"],
QuestionIntent.SYMPTOM_DURATION: ["How long has it been?", "When did this start?"],
QuestionIntent.SYMPTOM_SEVERITY: ["How bad is the pain?", "How severe is it?"],
QuestionIntent.SYMPTOM_LOCATION: ["Where exactly does it hurt?"],
QuestionIntent.MEDICAL_HISTORY: ["Do you have BP, diabetes, or other chronic conditions?"],
QuestionIntent.MEDICATIONS: ["Are you currently taking any regular daily medications?"],
QuestionIntent.ALLERGIES: ["Do you have any medicine or food allergies?"],
},
"ta": {
QuestionIntent.SYMPTOM_NAME: ["வணக்கம், உங்களுக்கு என்ன பிரச்சனை இருக்கு?"],
QuestionIntent.SYMPTOM_DURATION: ["இந்த பிரச்சனை எத்தனை நாளா இருக்கு?", "இது எப்போலிருந்து இருக்கு?"],
QuestionIntent.SYMPTOM_SEVERITY: ["வலி எவ்வளவு தீவிரமா இருக்கு?"],
QuestionIntent.SYMPTOM_LOCATION: ["எங்க வலிக்குது?"],
QuestionIntent.MEDICAL_HISTORY: ["உங்களுக்கு பிபி, சுகர் போன்ற வேறு உடம்பு பிரச்சனை இருக்கா?"],
QuestionIntent.MEDICATIONS: ["வழக்கமா ஏதேனும் மாத்திரை சாப்பிடுறீங்களா?"],
QuestionIntent.ALLERGIES: ["உங்களுக்கு ஏதேனும் அலர்ஜி இருக்கா?"],
},
"hi": {
QuestionIntent.SYMPTOM_NAME: ["नमस्ते, आपको क्या तकलीफ हो रही है?"],
QuestionIntent.SYMPTOM_DURATION: ["यह तकलीफ कितने दिनों से हो रही है?", "यह दर्द कब शुरू हुआ?"],
QuestionIntent.SYMPTOM_SEVERITY: ["दर्द कितना तेज है?"],
QuestionIntent.SYMPTOM_LOCATION: ["दर्द कहाँ हो रहा है?"],
QuestionIntent.MEDICAL_HISTORY: ["क्या आपको बीपी या शुगर जैसी कोई बीमारी है?"],
QuestionIntent.MEDICATIONS: ["क्या आप नियमित रूप से कोई दवाई लेते हैं?"],
QuestionIntent.ALLERGIES: ["क्या आपको किसी दवाई से एलर्जी है?"],
},
"te": {
QuestionIntent.SYMPTOM_NAME: ["నమస్కారం, మీకు ఏమి సమస్య ఉంది?"],
QuestionIntent.SYMPTOM_DURATION: ["ఈ సమస్య ఎంతకాలంగా ఉంది?", "ఈ నొప్పి ఎప్పుడు మొదలైంది?"],
QuestionIntent.SYMPTOM_SEVERITY: ["నొప్పి ఎంత తీవ్రంగా ఉంది?"],
QuestionIntent.SYMPTOM_LOCATION: ["నొప్పి ఎక్కడ ఉంది?"],
QuestionIntent.MEDICAL_HISTORY: ["మీకు బిపి, షుగర్ వంటి ఇతర ఆరోగ్య సమస్యలు ఉన్నాయా?"],
QuestionIntent.MEDICATIONS: ["మీరు క్రమంగా ఏవైనా మందులు వాడుతున్నారా?"],
QuestionIntent.ALLERGIES: ["మీకు ఏవైనా మందులు లేదా ఆహార అలెర్జీలు ఉన్నాయా?"],
},
"kn": {
QuestionIntent.SYMPTOM_NAME: ["ನಮಸ್ಕಾರ, ನಿಮಗೆ ಏನು ತೊಂದರೆಯಾಗಿದೆ?"],
QuestionIntent.SYMPTOM_DURATION: ["ಈ ತೊಂದರೆ ಎಷ್ಟು ದಿನಗಳಿಂದ ಇದೆ?", "ಈ ನೋವು ಯಾವಾಗ ಶುರುವಾಯಿತು?"],
QuestionIntent.SYMPTOM_SEVERITY: ["ನೋವು ಎಷ್ಟು ತೀವ್ರವಾಗಿದೆ?"],
QuestionIntent.SYMPTOM_LOCATION: ["ನೋವು ಎಲ್ಲಿದೆ?"],
QuestionIntent.MEDICAL_HISTORY: ["ನಿಮಗೆ ಬಿಪಿ, ಸಕ್ಕರೆ ಕಾಯಿಲೆ ಮುಂತಾದ ಇತರ ತೊಂದರೆಗಳಿವೆಯೇ?"],
QuestionIntent.MEDICATIONS: ["ನೀವು ನಿಯಮಿತವಾಗಿ ಯಾವುದೇ ಮಾತ್ರೆಗಳನ್ನು ತೆಗೆದುಕೊಳ್ಳುತ್ತಿದ್ದೀರಾ?"],
QuestionIntent.ALLERGIES: ["ನಿಮಗೆ ಯಾವುದೇ ಔಷಧಿ ಅಥವಾ ಆಹಾರ ಅಲರ್ಜಿಗಳಿವೆಯೇ?"],
}
}
@@ -0,0 +1,169 @@
"""
Response formatter enforcing Rules 53-60:
- Generate response after state processing.
- One Question Only (Rule 54).
- Acknowledge + Ask pattern (Rule 55).
- No unnecessary confirmation / No robotic language (Rules 56 & 57).
- No medical explanations (Rule 58).
- Natural conversation with short answers (Rule 59).
- Response length enforcement: 5-15 words target, max 20 words (Rule 60).
"""
import re
from typing import Optional
ROBOTIC_PHRASES = [
r"i\s+have\s+(?:recorded|noted|documented)(?:\s+that|\s+this)?\.?",
r"thank\s+you\s+for\s+(?:sharing|providing|the)\s+(?:that\s+|this\s+)?(?:information|details|response)?\.?",
r"i\s+understand\s+your\s+response\.?",
r"please\s+provide\s+(?:the\s+)?(?:duration|severity|location|additional|details)",
r"kindly\s+specify\s+(?:the\s+)?(?:duration|severity|location)",
r"your\s+response\s+has\s+been\s+recorded\.?",
r"your\s+response\s+indicates\s+that[^.!?]+[.!?]",
]
DIAGNOSTIC_PATTERNS = [
r"this\s+could\s+(?:indicate|suggest|be)\s+[^.!?]+[.!?]",
r"possible\s+causes\s+include\s+[^.!?]+[.!?]",
]
FORM_READBACK_PATTERNS = [
r"i\s+understand\s+(?:that\s+)?you\s+(?:have|are|were|take|have\s+been|suffered)[^.!?]+[.!?]",
r"you\s+mentioned\s+(?:that\s+)?you\s+(?:have|are|were|take|have\s+been)[^.!?]+[.!?]",
r"i\s+see\s+that\s+you\s+(?:have|are|were|take|have\s+been)[^.!?]+[.!?]",
]
DISCLAIMER_PATTERNS = [
r"as\s+an\s+ai\s+(?:language\s+model|assistant|clinical\s+intake)[^.!?]*[.!?]?",
r"please\s+note\s+that\s+i\s+cannot\s+provide\s+medical\s+advice[^.!?]*[.!?]?",
r"i\s+am\s+not\s+a\s+doctor[^.!?]*[.!?]?",
]
def deduplicate_sentences(text: str) -> str:
"""Removes duplicate consecutive or identical sentences."""
if not text:
return text
sentences = [s.strip() for s in re.split(r"(?<=[.!?])\s+", text) if s.strip()]
seen = set()
unique = []
for s in sentences:
s_norm = s.lower()
if s_norm not in seen:
seen.add(s_norm)
unique.append(s)
return " ".join(unique)
def sanitize_final_response(raw_reply: str, lang: str = "en", scenario: str = "normal") -> str:
"""
Final response sanitization pipeline (Rule 70):
Generated Response
↓ Remove duplicate sentences
↓ Remove form readbacks (Rule 63) & diagnostic/robotic text (Rule 61)
↓ Remove multiple questions (Rule 54)
↓ Enforce dynamic response length (Rule 62)
↓ Return short natural response
"""
if not raw_reply or not raw_reply.strip():
if scenario in ["emergency", "crisis"]:
return "Please seek emergency medical evaluation immediately or call local emergency services."
if scenario == "completion":
return "Thank you. Your intake information is registered for the doctor."
return "Got it. What symptoms are you having?"
# Emergency / Crisis scenario (Rule 62): Return concise safety instruction without trimming
if scenario in ["emergency", "crisis"]:
text = raw_reply.strip()
text = re.sub(r"\s+", " ", text)
return text
text = raw_reply.strip()
# 1. Strip disclaimers (Rule 61)
for disc in DISCLAIMER_PATTERNS:
text = re.sub(disc, "", text, flags=re.IGNORECASE)
# 2. Strip diagnostic explanations (Rule 58 & 61)
for diag in DIAGNOSTIC_PATTERNS:
text = re.sub(diag, "", text, flags=re.IGNORECASE)
# 3. Strip form readbacks (Rule 63)
for readback in FORM_READBACK_PATTERNS:
text = re.sub(readback, "", text, flags=re.IGNORECASE)
# 4. Strip robotic confirmation phrases (Rule 56, 57, 64)
for rob in ROBOTIC_PHRASES:
text = re.sub(rob, "", text, flags=re.IGNORECASE)
text = re.sub(r"\s+", " ", text).strip()
if not text:
if scenario == "completion":
return "Thank you. Your intake information is registered for the doctor."
return "Got it. When did it start?"
# 5. Deduplicate sentences (Rule 70)
text = deduplicate_sentences(text)
# 6. Enforce ONE question only (Rule 54 & 70)
if text.count("?") > 1:
q_parts = text.split("?")
text = q_parts[0].strip() + "?"
# Completion scenario (Rule 62)
if scenario == "completion":
words = text.split()
if len(words) > 15:
text = " ".join(words[:12]) + "."
return text
# 7. Enforce max 1-2 short sentences (Rule 55, 60, 62)
sentences = [s.strip() for s in re.split(r"(?<=[.!?])\s+", text) if s.strip()]
if len(sentences) > 2:
q_idx = next((i for i, s in enumerate(sentences) if "?" in s), -1)
if q_idx != -1:
if q_idx > 0:
sentences = [sentences[0], sentences[q_idx]]
else:
sentences = [sentences[0]]
else:
sentences = sentences[:2]
text = " ".join(sentences)
# 8. Dynamic length enforcement: 5-15 words (max 20 words)
words = text.split()
if len(words) > 20:
if "?" in text:
q_sent = next((s for s in sentences if "?" in s), None)
ack_sent = next((s for s in sentences if "?" not in s), None)
if q_sent:
q_words = q_sent.split()
if len(q_words) > 20:
clean_words = [re.sub(r"[.!?]+$", "", w) for w in q_words[:18]]
text = " ".join(clean_words) + "...?"
elif ack_sent:
ack_words = ack_sent.split()
if len(ack_words) + len(q_words) <= 20:
text = f"{ack_sent} {q_sent}"
else:
candidate = f"Got it. {q_sent}"
if len(candidate.split()) <= 20:
text = candidate
else:
text = q_sent
else:
text = q_sent
else:
clean_words = [re.sub(r"[.!?]+$", "", w) for w in words[:18]]
text = " ".join(clean_words) + "..."
return text
def format_conversational_response(raw_reply: str, lang: str = "en", scenario: str = "normal") -> str:
"""Wrapper for final response sanitization pipeline (Rule 70)."""
return sanitize_final_response(raw_reply, lang=lang, scenario=scenario)
@@ -0,0 +1,40 @@
"""
ResponseGenerator (Rules 101, 102, 103, 104, 109, 110, 111, 112, 113, 114, 115):
Dedicated Response Generator pipeline consuming ResponseContext and returning ResponseResult.
- Enforces MAX_RESPONSE_WORDS = 20 (target 5-15 words).
- Validates single question limit (0 or 1 question).
- Calculates Response Quality Score.
- Prevents internal data leakage & resists prompt injection.
"""
from typing import List, Optional
from app.domain.models.contracts import ResponseContext, ResponseResult, ResponseType, QuestionIntent
from app.ai.conversation.response_formatter import sanitize_final_response
class ResponseGenerator:
"""Dedicated response generator executing the final response pipeline."""
def generate(self, ctx: ResponseContext) -> ResponseResult:
raw_text = ctx.selected_question or "Got it. When did it start?"
scenario = "normal"
resp_type = ResponseType.FOLLOW_UP
if ctx.safety_status in ["crisis"]:
scenario = "crisis"
resp_type = ResponseType.CRISIS
elif ctx.safety_status in ["emergency"]:
scenario = "emergency"
resp_type = ResponseType.SAFETY
elif ctx.onboarding_completed:
scenario = "completion"
resp_type = ResponseType.COMPLETION
sanitized_reply = sanitize_final_response(raw_text, lang=ctx.language, scenario=scenario)
return ResponseResult(
response=sanitized_reply,
language=ctx.language,
question_intent=ctx.question_intent.value if ctx.question_intent else None,
response_type=resp_type
)
@@ -0,0 +1,21 @@
"""
Extraction package.
"""
from app.ai.extraction.multilingual_normalizer import normalize_text, detect_language
from app.ai.extraction.symptom_extractor import extract_symptoms
from app.ai.extraction.medication_extractor import extract_medications
from app.ai.extraction.allergy_extractor import extract_allergies
from app.ai.extraction.history_extractor import extract_history
from app.ai.extraction.negation_engine import is_negated, extract_family_subject
__all__ = [
"normalize_text",
"detect_language",
"extract_symptoms",
"extract_medications",
"extract_allergies",
"extract_history",
"is_negated",
"extract_family_subject",
]
@@ -0,0 +1,56 @@
"""
Allergy extraction engine distinguishing drug/food allergies from side effects.
"""
import re
from typing import Dict, Any, List, Optional, Union
ALLERGEN_PATTERNS = [
(r"(penicillin)", "Penicillin"),
(r"(sulfa)", "Sulfa drugs"),
(r"(aspirin)", "Aspirin"),
(r"(dust)", "Dust allergy"),
(r"(peanuts)", "Peanuts"),
(r"(milk|dairy)", "Milk / Dairy"),
]
def extract_allergies(
message: str,
normalized_msg: str,
last_ai_message: Optional[str] = None
) -> Optional[Union[List[Dict[str, Any]], Dict[str, Any]]]:
"""
Extracts structured allergies or confirmed_none container.
"""
msg_lower = normalized_msg.lower()
# Check for explicit negation of allergies
neg_phrases = [
"no allergies", "no allergy", "no drug allergy", "no food allergy", "not allergic",
"nkda", "no allergic reaction", "அலர்ஜி எதுவும் இல்லை", "எந்த அலர்ஜியும் இல்லை",
"कोई एलर्जी नहीं", "कोई समस्या नहीं", "ఏ అలర్జీ లేదు", "ಯಾವ ಅಲರ್ಜಿ ಇಲ್ಲ"
]
if any(neg in msg_lower for neg in neg_phrases):
return {"status": "confirmed_none", "items": []}
allergies: List[Dict[str, Any]] = []
has_allergen = any(re.search(pat, msg_lower) for pat, _ in ALLERGEN_PATTERNS)
has_alg_indicator = any(k in msg_lower or k in message for k in ["allergic", "allergy", "அலர்ஜி", "ஒவ்வாமை", "एलर्जी", "rash", "reaction", "side effect", "தடிப்பு", "எடுத்தா"])
# Side effect distinction: "Paracetamol didn't reduce fever" is NOT an allergy
if "didn't reduce" in msg_lower or "didn't work" in msg_lower or "no effect" in msg_lower:
return None
if has_allergen or has_alg_indicator:
for pat, allergen in ALLERGEN_PATTERNS:
if re.search(pat, msg_lower):
alg_obj: Dict[str, Any] = {"name": allergen}
if "rash" in msg_lower or "தடிப்பு" in message or "खुजली" in message:
alg_obj["reaction"] = "Rash / Skin reaction"
allergies.append(alg_obj)
if not allergies and has_alg_indicator:
allergies.append({"name": "Reported allergy", "type": "unspecified"})
return allergies if allergies else None
@@ -0,0 +1,83 @@
"""
Medical, surgical, family history, and previous episode extraction engine.
"""
import re
from typing import Dict, Any, List, Optional, Tuple, Union
from app.ai.extraction.negation_engine import extract_family_subject
HISTORY_KEYWORDS = [
("diabetes", "Diabetes"), ("sugar", "Diabetes"), ("diabetic", "Diabetes"),
("hypertension", "Hypertension (High BP)"), ("high bp", "Hypertension (High BP)"), ("bp", "Hypertension (High BP)"),
("asthma", "Asthma"), ("thyroid", "Thyroid disorder"),
("சர்க்கரை நோய்", "Diabetes"), ("சுகர்", "Diabetes"), ("பிபி", "Hypertension"),
("डायबिटीज", "Diabetes"), ("शुगर", "Diabetes"), ("बीपी", "Hypertension"),
]
def extract_history(
message: str,
normalized_msg: str,
duration: Optional[str] = None
) -> Dict[str, Any]:
"""
Extracts medical_history, family_history, surgical_history, previous_episodes, and additional_information.
"""
msg_lower = normalized_msg.lower()
updates: Dict[str, Any] = {}
# Check for explicit negation of medical history for self
neg_history_phrases = [
"no past history", "no medical history", "no health conditions", "no bp no diabetes",
"no chronic illness", "don't have diabetes", "no diabetes", "no bp",
"எந்த நோயும் இல்லை", "வேற ஒன்னும் இல்லை", "பிபி சுகர் எதுவும் இல்ல",
"कोई बीमारी नहीं", "बीमारी नहीं है", "ఏ సమస్య లేదు", "ಯಾವ ಕಾಯಿಲೆ ಇಲ್ಲ"
]
# Check if patient mentions family member (e.g., "my father has diabetes")
is_fam, relation, _ = extract_family_subject(message)
if is_fam:
fam_list: List[Dict[str, Any]] = []
for kw, cond in HISTORY_KEYWORDS:
if re.search(rf"\b{re.escape(kw)}\b", msg_lower) or kw in message:
fam_list.append({"relation": relation, "condition": cond})
if fam_list:
updates["family_history"] = fam_list
if any(neg in msg_lower for neg in neg_history_phrases):
updates["medical_history"] = {"status": "confirmed_none", "items": []}
elif not is_fam:
med_history: List[Dict[str, Any]] = []
for kw, cond in HISTORY_KEYWORDS:
if re.search(rf"\b{re.escape(kw)}\b", msg_lower) or kw in message:
h_obj: Dict[str, Any] = {"condition": cond}
h_dur_m = re.search(r"(?:for|since)\s+(\d+\s+(?:years|months|yrs))", msg_lower)
if h_dur_m:
h_obj["duration"] = h_dur_m.group(1).strip()
if not any(x.get("condition") == cond for x in med_history):
med_history.append(h_obj)
if med_history:
updates["medical_history"] = med_history
# Surgical History & Previous Episodes
if "surgery" in msg_lower or "operation" in msg_lower or "ஆபரேஷன்" in message:
updates["surgical_history"] = [{"procedure": "Prior surgery reported", "approximate_date": duration or "Past"}]
if "same problem" in msg_lower or "had this before" in msg_lower or "மூன்று மாதங்களுக்கு முன்" in message:
updates["previous_episodes"] = [{"description": "Similar symptoms previously", "time": duration or "Past"}]
# Additional Information / Functional Impact
add_info: List[Dict[str, Any]] = []
if "couldn't eat" in msg_lower or "not eaten" in msg_lower or "சாப்பிட முடியல" in message:
add_info.append({"category": "functional_impact", "value": "Loss of appetite / difficulty eating"})
if "couldn't sleep" in msg_lower or "haven't slept" in msg_lower or "தூங்க முடியல" in message:
add_info.append({"category": "functional_impact", "value": "Sleep disruption"})
if "weak" in msg_lower or "tired" in msg_lower or "ரொம்ப டயர்டா இருக்கு" in message:
add_info.append({"category": "functional_impact", "value": "Severe weakness / fatigue"})
if add_info:
updates["additional_information"] = add_info
return updates
@@ -0,0 +1,68 @@
"""
Medication extraction engine supporting structured dosage, frequency, and confirmed_none status.
"""
import re
from typing import Dict, Any, List, Optional, Union
KNOWN_MED_PATTERNS = [
(r"(metformin(?:\s+\d+\s*mg)?(?:\s+twice\s+a\s+day)?)", "Metformin"),
(r"(paracetamol(?:\s+\d+\s*mg)?)", "Paracetamol"),
(r"(dolo(?:\s*650)?)", "Dolo 650 (Paracetamol)"),
(r"(pantocid|pan-d|pan 40|omez|omeprazole)", "Pantoprazole / Antacid"),
(r"(telmisartan|telma\s*\d+)", "Telmisartan (BP)"),
(r"(amlodipine|amlong\s*\d+)", "Amlodipine (BP)"),
(r"((?:bp|பிபி|blood\s*pressure)\s*(?:மாத்திரை|medicine|tablet|tablets|மருந்து))", "BP Medication"),
(r"((?:sugar|சுகர்|சர்க்கரை)\s*(?:மாத்திரை|medicine|tablet|tablets|மருந்து))", "Diabetes Medication"),
(r"((?:thyroid|தைராய்டு)\s*(?:மாத்திரை|medicine|tablet|tablets|மருந்து))", "Thyroid Medication"),
]
def extract_medications(
message: str,
normalized_msg: str,
last_ai_message: Optional[str] = None,
is_allergy_utterance: bool = False
) -> Optional[Union[List[Dict[str, Any]], Dict[str, Any]]]:
"""
Extracts structured medications or confirmed_none container.
"""
msg_lower = normalized_msg.lower()
# Check for explicit negation of medicines
neg_phrases = [
"no medicines", "no medication", "don't take any medicine", "don't take medicines",
"no tablets", "not taking any medicine", "no daily medicines", "no regular medicines",
"மாத்திரை எதுவும் இல்லை", "எந்த மாத்திரையும் சாப்பிடல", "மருந்து எதுவும் இல்லை",
"कोई दवा नहीं", "दवाई नहीं लेते", "दवा नहीं लेता", "ఏ మందులు లేవు", "ಯಾವ ಮಾತ್ರೆ ಇಲ್ಲ"
]
if any(neg in msg_lower for neg in neg_phrases):
return {"status": "confirmed_none", "items": []}
meds: List[Dict[str, Any]] = []
# Known medicine matchers
for pat, med_label in KNOWN_MED_PATTERNS:
m_match = re.search(pat, msg_lower)
if m_match:
matched_str = m_match.group(1).strip()
med_obj: Dict[str, Any] = {"name": med_label}
dosage_m = re.search(r"(\d+\s*mg|\d+\s*ml)", matched_str)
if dosage_m:
med_obj["dosage"] = dosage_m.group(1).strip()
freq_m = re.search(r"(twice\s+a\s+day|once\s+a\s+day|\d+\s+times\s+a\s+day|daily|twice)", matched_str)
if freq_m:
med_obj["frequency"] = freq_m.group(1).strip()
if not any(x.get("name") == med_obj["name"] for x in meds):
meds.append(med_obj)
# Generic medicine indicator matchers (only if not an allergy utterance)
if not meds and not is_allergy_utterance:
generic_pat = r"(tablets|goli|mathirai|மாத்திரை|மருந்து|दवाई|दवा|మాత్రలు|ಮಾತ್ರೆಗಳು)"
if re.search(generic_pat, msg_lower) and any(verb in msg_lower for verb in ["take", "taking", "சாப்பிடுறேன்", "சாப்பிட்டேன்", "வாங்குறேன்", "लेता", "लेती", "వాడుతున్నాను"]):
meds.append({"name": "Unspecified medication"})
return meds if meds else None
@@ -0,0 +1,71 @@
"""
Multilingual normalization layer for clinical messages.
Preserves raw input while generating normalized, canonical representations.
"""
import re
import unicodedata
from typing import Dict, Any, Tuple
from app.services.contextual_normalizer import (
normalize_duration_from_message,
detect_previous_question_intent,
is_duration_expression,
is_severity_expression,
is_location_expression,
is_negative_expression,
is_casual_filler,
extract_severity_from_message,
extract_location_from_message,
)
def normalize_text(text: str) -> str:
"""
Cleans Unicode, strips extra spaces, standardizes quotes and punctuation.
"""
if not text:
return ""
# Unicode Normalization Form C
normalized = unicodedata.normalize("NFC", text)
# Replace non-breaking spaces and smart quotes
normalized = normalized.replace("\xa0", " ").replace("’", "'").replace("“", '"').replace("”", '"')
# Collapse multiple whitespaces
normalized = re.sub(r"\s+", " ", normalized).strip()
return normalized
def detect_language(text: str, default_lang: str = "en") -> str:
"""
Detects script/language (ta, hi, te, kn, en).
"""
if not text:
return default_lang
# Script character ranges
has_ta = bool(re.search(r"[\u0B80-\u0BFF]", text))
has_hi = bool(re.search(r"[\u0900-\u097F]", text))
has_te = bool(re.search(r"[\u0C00-\u0C7F]", text))
has_kn = bool(re.search(r"[\u0C80-\u0CFF]", text))
if has_ta:
return "ta-IN"
if has_hi:
return "hi-IN"
if has_te:
return "te-IN"
if has_kn:
return "kn-IN"
# Transliterated language keywords
msg_lower = text.lower()
if any(w in msg_lower for w in ["irukku", "thalaivali", "romba", "mudiyala", "saapudren"]):
return "ta-IN"
if any(w in msg_lower for w in ["dard", "ho raha", "hoga", "le raha"]):
return "hi-IN"
if any(w in msg_lower for w in ["noppi", "undi", "thala noppi"]):
return "te-IN"
if any(w in msg_lower for w in ["novu", "ide", "thale novu"]):
return "kn-IN"
return default_lang or "en-IN"
@@ -0,0 +1,54 @@
"""
Contextual negation and scope resolution engine.
"""
import re
from typing import Dict, Any, Tuple
# Family relations
FAMILY_RELATIONS = [
("father", "father"), ("dad", "father"), ("appa", "father"), ("அப்பா", "father"), ("पिता", "father"), ("नाना", "grandfather"),
("mother", "mother"), ("mom", "mother"), ("amma", "mother"), ("அம்மா", "mother"), ("माँ", "mother"), ("माता", "mother"),
("brother", "brother"), ("annaa", "brother"), ("தம்பி", "brother"), ("अண்ணன்", "brother"), ("भाई", "brother"),
("sister", "sister"), ("akka", "sister"), ("அக்கா", "sister"), ("தங்கை", "sister"), ("बहन", "sister"),
("parent", "parent"), ("grand-father", "grandfather"), ("grandfather", "grandfather"), ("grandmother", "grandmother"),
]
# Negation indicators
NEGATION_PHRASES = [
"don't have", "do not have", "no history of", "no symptoms", "no allergies", "no allergy", "no medicine",
"no medications", "don't take", "do not take", "never had", "without", "denies", "absent",
"இல்லை", "எதுவும் இல்லை", "இல்லவே இல்லை", "மாத்திரை எதுவும் இல்லை", "நோயும் இல்லை",
"नहीं है", "कोई बीमारी नहीं", "कोई एलर्जी नहीं", "कोई दवा नहीं",
"లేవు", "ఏమీ లేదు", "ఇబ్బంది లేదు",
"ಇಲ್ಲ", "ಯಾವುದೇ ಇಲ್ಲ",
]
def is_negated(text: str) -> bool:
"""Returns True if the text expresses explicit negation."""
if not text:
return False
msg_lower = text.lower().strip()
if msg_lower in ["no", "none", "nothing", "no more", "nil", "illness none", "nkda"]:
return True
return any(neg in msg_lower for neg in NEGATION_PHRASES)
def extract_family_subject(text: str) -> Tuple[bool, str, str]:
"""
Checks if text refers to family member rather than the patient.
Returns: (is_family_history, relation, condition_text)
"""
if not text:
return False, "", text
msg_lower = text.lower().strip()
for rel_kw, rel_canonical in FAMILY_RELATIONS:
pattern = rf"\b{re.escape(rel_kw)}\b"
if re.search(pattern, msg_lower) or rel_kw in text:
# Check if patient explicitly negates disease for self in same turn
return True, rel_canonical, text
return False, "", text
@@ -0,0 +1,117 @@
"""
Layered symptom extraction engine supporting attribute mapping per symptom.
"""
import re
from typing import Dict, Any, List, Optional, Set, Tuple
from app.services.contextual_normalizer import (
normalize_duration_from_message,
extract_severity_from_message,
extract_location_from_message,
is_duration_expression,
is_severity_expression,
is_location_expression,
is_casual_filler,
is_negative_expression,
)
SYMPTOM_DICTIONARY = [
# General & Respiratory
("fever", "fever"), ("feverish", "fever"), ("காய்ச்சல்", "fever"), ("बुखार", "fever"), ("జ్వరం", "fever"), ("ಜ್ವರ", "fever"),
("cough", "cough"), ("coughing", "cough"), ("இருமல்", "cough"), ("खांसी", "cough"), ("దగ్గు", "cough"), ("ಕೆಮ್ಮು", "cough"),
("cold", "cold"), ("sneezing", "cold"), ("சளி", "cold"), ("सर्दी", "cold"), ("జలుబు", "cold"), ("ನೆಗಡಿ", "cold"),
("breathlessness", "breathlessness"), ("shortness of breath", "breathlessness"), ("difficulty breathing", "breathlessness"),
("மூச்சு திணறல்", "breathlessness"), ("மூச்சு வாங்குது", "breathlessness"), ("மூச்சு வாங்குறேன்", "breathlessness"), ("சாப்பிடும்போது மூச்சு", "breathlessness"), ("சாப்பிடும் போது மூச்சு", "breathlessness"), ("सांस फूलना", "breathlessness"),
("palpitations", "palpitations"), ("படபடன்னு", "palpitations"), ("நெஞ்சு படபடன்னு", "palpitations"), ("படபடப்பு", "palpitations"),
# Pain & Head
("headache", "headache"), ("head ache", "headache"), ("head pain", "headache"), ("migraine", "headache"),
("தலைவலி", "headache"), ("தலை பாரம்", "headache"), ("सिरदर्द", "headache"), ("తలనొప్పి", "headache"), ("ತಲೆನೋವು", "headache"),
("chest pain", "chest pain"), ("chest pressure", "chest pain"), ("நெஞ்சு வலி", "chest pain"), ("छाती में दर्द", "chest pain"),
("stomach pain", "stomach pain"), ("abdominal pain", "stomach pain"), ("stomach ache", "stomach pain"),
("வயிற்று வலி", "stomach pain"), ("पेट दर्द", "stomach pain"), ("కడుపు నొప్పి", "stomach pain"), ("<ctrl42> ಹೊಟ್ಟೆ ನೋವು", "stomach pain"),
("back pain", "back pain"), ("முதுகு வலி", "back pain"), ("पीठ दर्द", "back pain"),
("joint pain", "joint pain"), ("knee pain", "knee pain"), ("மூட்டு வலி", "joint pain"), ("घुटने का दर्द", "knee pain"),
# Neurological / ENT
("dizziness", "dizziness"), ("giddiness", "dizziness"), ("dizzy", "dizziness"),
("தலை சுத்துது", "dizziness"), ("தலைசுற்றல்", "dizziness"), ("மயக்கம்", "dizziness"), ("चक्कर", "dizziness"),
("sore throat", "sore throat"), ("throat pain", "sore throat"), ("தொண்டை வலி", "sore throat"), ("गले में खराश", "sore throat"),
# GI
("vomiting", "vomiting"), ("vomit", "vomiting"), ("vomited", "vomiting"), ("nausea", "vomiting"),
("வாந்தி", "vomiting"), ("உल्टी", "vomiting"), ("వాంతులు", "vomiting"), ("ವಾಂತಿ", "vomiting"),
("loose motion", "diarrhea"), ("diarrhea", "diarrhea"), ("loose stools", "diarrhea"),
("வயிற்றுப்போக்கு", "diarrhea"), ("दस्त", "diarrhea"),
# Dermatological
("itching", "itching"), ("rash", "rash"), ("skin rash", "rash"),
("அரிப்பு", "itching"), ("தழும்பு", "rash"), ("खुजली", "itching"),
]
def extract_symptoms(
message: str,
normalized_msg: str,
current_state: Optional[Dict[str, Any]] = None
) -> Tuple[List[Dict[str, Any]], Optional[str]]:
"""
Extracts symptoms and maps attributes (duration, severity, location, frequency) specifically.
Returns: (symptoms_list, primary_reason_for_visit)
"""
msg_lower = normalized_msg.lower()
found_symptoms: List[Dict[str, Any]] = []
found_names: Set[str] = set()
# Extract global context attributes
duration = normalize_duration_from_message(message)
severity = extract_severity_from_message(message)
location = extract_location_from_message(message)
# Frequency extraction (e.g. "twice", "2 times")
freq_match = re.search(r"(\b\d+\s*times\b|\btwice\b|\bonce\b|\bmultiple times\b)", msg_lower)
frequency = freq_match.group(1) if freq_match else None
# Scan for symptoms
for kw, canonical in SYMPTOM_DICTIONARY:
pattern = rf"\b{re.escape(kw)}\b"
if re.search(pattern, msg_lower) or kw in message:
if canonical not in found_names:
found_names.add(canonical)
# Store canonical name as 'name' if kw is an English inflection like 'vomit', else keep kw if non-English
stored_name = canonical if kw in ["vomit", "vomited", "coughing", "feverish"] else kw
sym_obj: Dict[str, Any] = {"name": stored_name}
if duration:
sym_obj["duration"] = duration
if severity:
sym_obj["severity"] = severity
if location:
sym_obj["location"] = location
if frequency and ("vomit" in canonical or "வாந்தி" in kw or "உल्टी" in kw):
sym_obj["frequency"] = frequency
found_symptoms.append(sym_obj)
primary_rfv = list(found_names)[0] if found_names else None
# Check for medication or non-symptom statements to prevent fake symptom fallback
is_med_statement = any(k in msg_lower or k in message for k in ["metformin", "dolo", "paracetamol", "pantocid", "telmisartan", "amlodipine", "tablets", "medicine", "pill", "pills", "goli", "mathirai", "மாத்திரை", "மருந்து", "சாப்பிடுறேன்", "சாப்பிட்டேன்", "வாங்குறேன்", "दवाई", "दवा"])
# Fallback: single phrase short symptom message
if not found_symptoms and message and len(message.split()) < 10 and not is_med_statement:
if not (
is_duration_expression(message)
or is_severity_expression(message)
or is_location_expression(message)
or is_casual_filler(message)
or is_negative_expression(message)
):
clean_symptom = message.strip()
sym_obj = {"name": clean_symptom}
if duration:
sym_obj["duration"] = duration
if severity:
sym_obj["severity"] = severity
if location:
sym_obj["location"] = location
found_symptoms.append(sym_obj)
primary_rfv = clean_symptom
return found_symptoms, primary_rfv
+20 -16
View File
@@ -8,7 +8,7 @@ class GeminiClient:
def __init__(self):
self.api_key = GEMINI_API_KEY
self.model = GEMINI_MODEL
self.model = GEMINI_MODEL or "gemini-1.5-flash"
async def extract_from_document(
self,
@@ -30,9 +30,10 @@ class GeminiClient:
}
try:
import google.generativeai as genai
genai.configure(api_key=self.api_key)
model = genai.GenerativeModel(self.model or "gemini-1.5-flash")
from google import genai
from google.genai import types
client = genai.Client(api_key=self.api_key)
prompt = f"""
You are a medical document extraction AI.
@@ -56,12 +57,12 @@ Return ONLY JSON:
"summary": "short clinical summary of the document"
}}
"""
part = {
"mime_type": mime_type,
"data": file_bytes,
}
part = types.Part.from_bytes(data=file_bytes, mime_type=mime_type)
response = await asyncio.wait_for(
model.generate_content_async([part, prompt]),
client.aio.models.generate_content(
model=self.model,
contents=[part, prompt]
),
timeout=4.0
)
cleaned_text = response.text.replace("```json", "").replace("```", "").strip()
@@ -84,15 +85,18 @@ Return ONLY JSON:
if not self.api_key or self.api_key in ["your_gemini_key", "your_gemini_api_key", ""]:
raise ValueError("Gemini API key not configured")
import google.generativeai as genai
genai.configure(api_key=self.api_key)
model = genai.GenerativeModel(
model_name=self.model or "gemini-1.5-flash",
generation_config={"response_mime_type": "application/json"},
)
from google import genai
from google.genai import types
client = genai.Client(api_key=self.api_key)
full_prompt = f"{system_prompt}\n\nPATIENT SAID: {user_message}"
config = types.GenerateContentConfig(response_mime_type="application/json")
response = await asyncio.wait_for(
model.generate_content_async(full_prompt),
client.aio.models.generate_content(
model=self.model,
contents=full_prompt,
config=config,
),
timeout=3.5
)
return response.text
File diff suppressed because it is too large Load Diff
@@ -2,6 +2,68 @@ def get_conversation_rules() -> str:
"""Dynamic conversation philosophy — ASK LESS, LISTEN MORE, EXTRACT MORE."""
return """CONVERSATION PHILOSOPHY — ASK LESS, LISTEN MORE, EXTRACT MORE:
51. SHORT SPOKEN RESPONSE RULE:
- The AI must speak short, natural sentences.
- Do NOT give long explanations.
- Prefer: "Got it. How long have you had this pain?"
Instead of: "Thank you for providing that information. I understand that you are currently experiencing pain, and I would like to know how long you have been experiencing this symptom."
- Rules:
* Maximum 1–2 short sentences per response.
* Ask only one question at a time.
* Acknowledge briefly.
* Do not repeat information already provided.
* Do not explain internal processing.
* Do not read JSON/state to the patient.
* Avoid medical jargon.
* Sound like a real human healthcare agent.
* Keep emergency/crisis messages clear and direct.
* Generate the response in the patient's detected language.
- Multilingual Examples:
* English: "Got it. When did the pain start?"
* Tamil: "சரி. இந்த வலி எப்போ ஆரம்பிச்சது?"
* Hindi: "ठीक है। यह दर्द कब शुरू हुआ?"
* Telugu: "సరే. ఈ నొప్పి ఎప్పుడు మొదలైంది?"
* Kannada: "ಸರಿ. ಈ ನೋವು ಯಾವಾಗ ಶುರುವಾಯಿತು?"
- Important: Short response ≠ incomplete extraction. The backend must extract all facts from the patient's message, while the AI speaks only the single most important next question.
52. RESPONSE LENGTH ENFORCEMENT:
- The response generator must strictly control spoken response length.
- AI SPEECH RULES:
1. Maximum 20 words per response.
2. Maximum 2 short sentences.
3. Ask only 1 question.
4. Never ask multiple questions together.
5. Acknowledge briefly.
6. Never repeat known information.
7. Never explain internal processing.
8. Never read structured data aloud.
9. Use natural spoken language.
10. Prefer simple words.
11. If one short sentence is enough, use one sentence.
12. Safety responses may exceed 20 words only when necessary.
- Bad: "Thank you for telling me that you have been experiencing headaches for three days with moderate severity. Could you please tell me whether the headache is constant or comes and goes?"
- Good: "Got it. Is the headache constant or does it come and go?"
- Bad: "I understand that you mentioned diabetes, metformin, and a penicillin allergy. I have recorded all this information."
- Good: "Got it. When was your headache last this severe?"
- Core rule: Extract everything. Speak only what is necessary. Ask only the next best question.
53. RESPONSE GENERATION PRIORITY:
- RESPONSE PRIORITY:
1. Handle emergency/crisis first.
2. Acknowledge the patient's latest information briefly.
3. Extract all useful facts silently.
4. Identify the most important missing information.
5. Ask only ONE question.
6. Keep the response under 20 words whenever possible.
7. Never ask for information already provided.
8. Never ask optional questions when important information is missing.
9. If all required information is available, finish naturally.
- Example:
* Patient: "I have chest pain for two days and it gets worse when walking."
* AI: "I understand. Are you having trouble breathing right now?"
* Not: "Thank you for providing detailed information about your chest pain. I have recorded the duration and trigger. I would now like to ask you another important question…"
- Core principle: Backend thinks deeply. AI speaks simply.
1. EVERY PATIENT MESSAGE IS INFORMATION-RICH:
- Treat every patient message as a potentially rich clinical statement.
- Extract ALL clinically relevant information from EVERY message, not just the answer to the current question.
@@ -19,7 +81,6 @@ def get_conversation_rules() -> str:
- If the patient already mentioned penicillin allergy, do NOT ask "Do you have any allergies?"
- If duration is present, progress immediately to medical history or medication/allergy questions.
4. ACKNOWLEDGE BEFORE ASKING:
- When the patient provides multiple details, briefly acknowledge: "Got it, you have headache and fever for two days with vomiting."
- Then ask only the MOST important missing question.
@@ -47,7 +108,7 @@ def get_conversation_rules() -> str:
PATIENT MESSAGE → UNDERSTAND → EXTRACT ALL RELEVANT INFO → MERGE INTO STATE → CHECK EMERGENCY → CHECK WHAT IS KNOWN → IDENTIFY IMPORTANT GAPS → ASK ONE BEST QUESTION or COMPLETE
10. RESPONSE STYLE:
- Produce ONE concise, natural spoken response.
- Produce ONE concise, natural spoken response (1-2 short sentences max).
- Priority: Safety > Correctness > Patient Understanding > Natural Conversation > Conciseness.
- Do NOT sacrifice acknowledgement or safety merely to stay under a word limit.
- No markdown, no bullet points, no brackets. Clean spoken dialogue only.
@@ -64,4 +125,11 @@ def get_conversation_rules() -> str:
12. PATIENT-LED EXTRACTION:
- If the patient volunteers multiple categories in one message, extract ALL of them.
- Example: "diabetes இருக்கு, metformin 500 எடுத்துக்கிறேன், penicillin அலர்ஜி இருக்கு" → Extract medical_history, medications, AND allergies simultaneously.
- Skip questions for categories already covered. Ask only for what is genuinely missing."""
- Skip questions for categories already covered. Ask only for what is genuinely missing.
13. DYNAMIC PATIENT-TAILORED QUESTIONS REQUIREMENT:
- ALWAYS reference the patient's specific reported symptoms or concerns in your follow-up questions.
- Example: If patient reports "headache", ask "How long have you had this headache?" (NOT generic "How long have you had this problem?").
- Example: If patient reports "fever", ask "Are you taking any medicine for your fever?" (NOT generic "Are you taking any medications?").
- NEVER use robotic generic templates like "this issue" or "this condition" when the specific symptom name is known."""
@@ -12,8 +12,8 @@ def get_safety_rules() -> str:
- Severe trauma / major injury
- Seizure / convulsion in progress
EMERGENCY RESPONSE: Direct to 108 ambulance immediately.
Example: "This needs urgent medical attention. Please call 108 for an ambulance right away."
EMERGENCY RESPONSE: Direct to hospital emergency staff or ER immediately.
Example: "This needs urgent medical attention. Please inform the nearest hospital staff or proceed directly to the Emergency Room right away."
Do NOT diagnose. Do NOT say "You are having a heart attack."
2. MENTAL HEALTH CRISIS:
@@ -22,8 +22,8 @@ def get_safety_rules() -> str:
- Self-harm intent
- Immediate danger to self or others
CRISIS RESPONSE: Provide Tele-MANAS helpline (14416) and emergency 108.
Example: "If you're in distress, please reach out to Tele-MANAS at 14416 or call 108 right now. I've noted your details for the doctor."
CRISIS RESPONSE: Provide Tele-MANAS helpline (14416) or direct to hospital staff immediately.
Example: "If you're in distress, please reach out to Tele-MANAS at 14416 or alert the hospital staff right now. I've noted your details for the doctor."
Do NOT continue normal intake before acknowledging the crisis.
Do NOT diagnose or offer therapy.
@@ -43,7 +43,7 @@ EMERGENCY_KEYWORDS_EN = [
"can't breathe", "cannot breathe", "severe breathing difficulty", "unable to breathe",
"sudden paralysis", "sudden weakness", "collapsed", "passed out",
"loss of consciousness", "unconscious", "severe bleeding",
"call 108", "call ambulance",
"call nurse", "call doctor", "emergency room", "call ambulance",
]
CRISIS_KEYWORDS_EN = [
@@ -31,7 +31,7 @@ _SPECIALTY_OVERRIDES = {
- Acknowledge the patient's courage in sharing emotional/mental struggles.
- Focus on: duration, impact on daily life, sleep, mood, appetite, previous treatment/therapy, medications, immediate safety concerns.
- Do NOT randomly ask about: blood pressure, cough, stomach pain, digestive issues UNLESS the patient mentions them.
- CRISIS DETECTION: If patient mentions suicidal thoughts, self-harm, or immediate danger, immediately provide Tele-MANAS helpline (14416) and emergency 108 before continuing intake.
- CRISIS DETECTION: If patient mentions suicidal thoughts, self-harm, or immediate danger, immediately provide Tele-MANAS helpline (14416) or direct to hospital emergency staff before continuing intake.
- Use empathetic, warm language. Mental health intake requires extra sensitivity.""",
"cardiology": """
+74 -97
View File
@@ -10,22 +10,23 @@ from app.ai.prompt_rules.safety_rules import get_safety_rules
def build_system_prompt(context: dict) -> str:
"""
Build a dynamic, modular system prompt for natural patient intake.
Adheres to: ASK LESS -> LISTEN MORE -> EXTRACT MORE.
Composed of language, clinical, conversation, extraction, specialty, and safety rule modules.
Adheres to Master Prompt Rules 53-60:
- Extract a lot internally. Speak very little externally.
- One question only.
- Acknowledge + Ask pattern.
- No robotic confirmations or medical explanations.
- Target length: 5-15 words (max 20 words).
"""
specialty = context.get("specialty_config", {})
patient = context.get("patient", {})
language = context.get("language", "ta-IN")
stage = context.get("current_stage", "greeting")
current_state = context.get("current_state", {})
conversation_history = context.get("conversation_history", [])
new_patient_message = context.get("new_patient_message", "")
target_question = context.get("target_question", "")
patient_name = patient.get("name") or "Patient"
patient_age = patient.get("age") or "Not provided"
patient_gender = patient.get("gender") or "Not provided"
# Assemble modular rules
lang_rules = get_language_rules(language)
clin_rules = get_clinical_rules()
priv_rules = get_privacy_rules()
@@ -33,18 +34,15 @@ def build_system_prompt(context: dict) -> str:
extr_rules = get_extraction_rules()
spec_rules = get_specialty_rules(specialty)
safe_rules = get_safety_rules()
last_ai_message = ""
for turn in reversed(conversation_history):
if turn.get("role") in ["assistant", "ai"]:
last_ai_message = turn.get("message", "")
break
from app.services.contextual_normalizer import detect_previous_question_intent
expected_intent = detect_previous_question_intent(last_ai_message)
intent_hint = f"\nPREVIOUS AI QUESTION INTENT: {expected_intent.upper()} (Interpret patient's reply as providing {expected_intent} for the primary symptom, NOT a new symptom entity)." if expected_intent else ""
prompt = f"""You are Nila, an empathetic, highly skilled AI Clinical Intake Assistant at a hospital/clinic.
Your job is to collect preliminary clinical intake information for the doctor from the patient: {patient_name} (Age: {patient_age}, Gender: {patient_gender}).
prompt = f"""You are Nila, an empathetic, highly skilled AI Clinical Intake Assistant at a hospital.
Your job is to collect preliminary clinical intake information for the doctor from patient: {patient_name}.
{lang_rules}
@@ -60,105 +58,84 @@ Your job is to collect preliminary clinical intake information for the doctor fr
{priv_rules}
CURRENT INTAKE STATE (What has already been gathered):
RESPONSE GENERATION RULES (RULES 51 - 200):
1. SHORT SPOKEN RESPONSE RULE (Rule 51):
- Speak short, natural sentences (max 1-2 short sentences). No long explanations.
- Prefer "Got it. How long have you had this pain?" over long robotic explanations.
- Ask only ONE question at a time. Acknowledge briefly. Do not repeat provided info or explain internal processing.
- Avoid medical jargon. Sound like a real human healthcare agent.
- Generate response in patient's detected language (en: "Got it. When did the pain start?", ta: "சரி. இந்த வலி எப்போ ஆரம்பிச்சது?", hi: "ठीक है। यह दर्द कब शुरू हुआ?", te: "సరే. ఈ నొప్పి ఎప్పుడు మొదలైంది?", kn: "ಸರಿ. ಈ ನೋವು ಯಾವಾಗ ಶುರುವಾಯಿತು?").
- Short response ≠ incomplete extraction (backend extracts everything internally, AI speaks only single next question).
2. RESPONSE LENGTH ENFORCEMENT (Rule 52):
- Maximum 20 words per response. Maximum 2 short sentences (use 1 sentence if enough).
- Ask only 1 question. Never ask multiple questions together. Acknowledge briefly.
- Never repeat known info, explain internal processing, or read structured data aloud.
- Use natural spoken language and simple words. Core rule: Extract everything. Speak only what is necessary.
3. RESPONSE GENERATION PRIORITY (Rule 53):
- Priority sequence: (1) Emergency/crisis first (2) Acknowledge briefly (3) Extract facts silently (4) Identify missing info (5) Ask ONE question (6) Keep under 20 words (7) Never ask provided info (8) Prioritize critical gaps over optional (9) Finish naturally when complete.
- Core principle: Backend thinks deeply. AI speaks simply.
4. RESPONSE TIMING & CONTEXT: Generate response after state processing. Receive only necessary context.
3. ONE QUESTION ONLY (Rule 54): Ask ONLY ONE question in your response. Never ask multiple independent questions.
4. ACKNOWLEDGE + ASK PATTERN (Rules 55 & 63): Brief acknowledgement + ONE question. NEVER read the form back or repeat full patient info.
5. NO ROBOTIC LANGUAGE OR CONFIRMATIONS (Rules 56, 57, 64, 79): Avoid 'I have recorded that', 'Please provide...', 'Your response indicates that...'. Interpret short answers ('yeah', '3 days', 'aama', 'illa', 'no', 'haan') naturally without asking for complete sentences.
6. NO MEDICAL EXPLANATIONS & NO HALLUCINATION (Rules 58 & 75): No diagnosis, no medical advice, no underlying condition lectures. NEVER assume unestablished facts ('Since you have diabetes...') in spoken reply.
7. INTENT-BASED MICRO RESPONSES & DYNAMIC LENGTH (Rules 60, 61, 62, 70, 71, 72, 73, 74, 168):
- Adapt to turn intent (greeting, fact_extraction, clarification, completion, emergency, crisis).
- Filter out greetings, repeated acknowledgements, form readbacks, medical jargon, disclaimers.
- Normal turn: 1 short sentence, 5-15 words (max 20 words). Voice turn: 5-12 words.
- Completion turn: Short natural closing (5-12 words, e.g. 'Thanks. Your onboarding is complete.').
- Emergency / Crisis: Immediate concise safety instructions (EMERGENCY > NORMAL CONVERSATION).
8. LANGUAGE CONSISTENCY & CODE-SWITCHING (Rules 76, 166, 167): Respond in patient's active language/transliteration (e.g. Tanglish/Hinglish). Support mixed-language code-switching naturally.
9. PATIENT-LED PRIORITY & STATE INTEGRITY (Rules 66, 67, 68, 69, 77, 78, 80, 121-140):
- Extract patient profile (age, gender, name) and validate values (Rule 121 & 123).
- Separate family history / representative info from patient history ('My father has diabetes' -> family_history, NOT patient.medical_history) (Rule 122 & 129).
- Extract medications with dose & frequency, updating on corrections without duplicating (Rule 124 & 125).
- Extract surgical history, previous episodes, treatment history, test reports (Rules 128, 130, 131, 132).
- Maintain symptom relationship graphs (duration, severity, location mapped per symptom) (Rule 133).
- Temporal, severity, & frequency normalization without fabricating numeric scores or exact dates (Rules 134, 135, 136).
- Negation scope & pronoun resolution ('it' -> active symptom name) (Rules 137 & 138).
- Correction phrase detection ('actually', 'I meant') overriding previous values (Rule 139).
- Final data integrity rule: Never complete because conversation is long; complete ONLY when state is complete (Rule 140).
10. PRODUCTION SECURITY, PRIVACY & GOVERNANCE (Rules 171-200):
- RBAC & tenant isolation (Rules 171-176). Minimum data exposure & log redaction (Rules 177-179).
- Environment separation, explicit CORS, request correlation ID (Rules 180-186).
- Audit event logging (SESSION_CREATED, SAFETY_DETECTED, LLM_REQUESTED, COMPLETED) (Rules 187 & 188).
- Decoupled architecture: Router -> Service -> AI Orchestrator (SafetyEngine, Normalization, ExtractionEngine, StateManager, QuestionEngine, ResponseGenerator) -> Repository (Rules 189-200).
FINAL BACKEND DESIGN PRINCIPLE (Rule 200):
COMPLEX BACKEND → SIMPLE CONVERSATION → SHORT AI SPEECH.
CURRENT INTAKE STATE:
{json.dumps(current_state, indent=2, ensure_ascii=False)}
RECENT CONVERSATION HISTORY:
{json.dumps(conversation_history[-8:], indent=2, ensure_ascii=False)}
PREVIOUS AI QUESTION:
"{last_ai_message}"{intent_hint}
LATEST PATIENT INPUT:
LATEST PATIENT MESSAGE:
"{new_patient_message}"
CRITICAL INSTRUCTIONS FOR THIS TURN:
1. Extract ALL clinically relevant facts from the latest patient input. If the input is answering the previous AI question (e.g. duration, severity, location), attach it to the existing symptom in CURRENT INTAKE STATE. DO NOT create a new symptom item for duration/severity statements.
2. DO NOT re-ask for any information already present in CURRENT INTAKE STATE or clearly stated in this input. NEVER ask duration again if duration is provided.
3. If the patient stated "Nothing else", "No", "None" for a SPECIFIC category that was just asked about, set that category to confirmed_none. But do NOT complete the intake unless ALL required categories are collected or confirmed_none.
4. If an important clinical gap remains, formulate ONE natural, empathetic question addressing ONLY the most relevant missing item.
5. In "reply", provide only ONE clean, spoken sentence without bullet points, markdown, or bracketed notes.
COMPLETION RULES — MANDATORY:
- NEVER set "completed": true unless ALL of the following are satisfied:
A. reason_for_visit is reported (has a value)
B. At least one symptom exists with duration information
C. medical_history is either "reported" with items OR explicitly "confirmed_none"
D. medications is either "reported" with items OR explicitly "confirmed_none"
E. allergies is either "reported" with items OR explicitly "confirmed_none"
- "not_collected" is NOT the same as "confirmed_none". A field with status "not_collected" means you MUST still ask about it.
- If the patient says "nothing else" but required fields are still "not_collected", you MUST continue asking. Do NOT complete early.
- If the patient provides medical_history + medications + allergies in a single message, extract ALL of them and skip those questions.
TARGET MISSING QUESTION TO ASK:
"{target_question}"
OUTPUT FORMAT REQUIREMENTS:
You MUST respond with valid JSON matching EXACTLY this structure:
{{
"reply": "Warm, natural spoken response acknowledging input and asking one relevant missing question or giving closing confirmation",
"next_stage": "reason_for_visit | symptom_collection | symptom_details | medications | allergies | confirmation",
"reply": "Short natural spoken response (5-15 words, max 1 question) following Acknowledge + Ask pattern",
"next_stage": "reason_for_visit | symptom_collection | symptom_details | medical_history | medications | allergies | confirmation",
"state_updates": {{
"reason_for_visit": "primary reason if identified or null",
"reason_for_visit": "primary reason or null",
"symptoms": [
{{
"name": "symptom name",
"duration": "duration if stated or null",
"severity": "mild/moderate/severe if stated or null",
"location": "body location if stated or null",
"onset": "sudden/gradual if stated or null",
"frequency": "how often if stated or null",
"pattern": "continuous/intermittent if stated or null",
"triggers": "trigger factors if stated or null",
"relieving_factors": "relieving factors if stated or null",
"aggravating_factors": "aggravating factors if stated or null"
"duration": "duration or null",
"severity": "severity or null",
"location": "location or null"
}}
],
"medical_history": [
{{
"condition": "condition name",
"duration": "duration if stated or null",
"status": "active/past if stated or null"
}}
],
"medications": [
{{
"name": "medication name",
"dosage": "dosage if stated or null",
"frequency": "frequency if stated or null",
"duration": "duration if stated or null"
}}
],
"allergies": [
{{
"name": "allergen name",
"type": "drug/food/environment if known or null",
"reaction": "reaction if stated or null"
}}
],
"surgical_history": [
{{
"procedure": "procedure name",
"approximate_date": "when if stated or null",
"reason": "reason if stated or null"
}}
],
"previous_episodes": [
{{
"description": "episode description",
"time": "when if stated or null"
}}
],
"family_history": [
{{
"condition": "condition name",
"relationship": "family member if stated or null"
}}
],
"additional_information": [
{{
"category": "functional_impact | lifestyle | dietary | other",
"value": "details stated by patient"
}}
]
"medical_history": [],
"medications": [],
"allergies": [],
"surgical_history": [],
"previous_episodes": [],
"family_history": [],
"additional_information": []
}},
"completed": false
}}
@@ -0,0 +1,9 @@
"""
Providers package.
"""
from app.ai.providers.base import LLMProvider
from app.ai.providers.openai import OpenAIProvider
from app.ai.providers.gemini import GeminiProvider
__all__ = ["LLMProvider", "OpenAIProvider", "GeminiProvider"]
@@ -0,0 +1,30 @@
"""
Base LLM Provider interface with hard async timeout enforcement.
"""
import asyncio
from abc import ABC, abstractmethod
from typing import Optional
class LLMProvider(ABC):
"""
Abstract LLM Provider interface.
Enforces a strict hard timeout (default 4 seconds).
"""
def __init__(self, timeout_seconds: float = 4.0):
self.timeout_seconds = timeout_seconds
async def generate_response_with_timeout(self, system_prompt: str, user_message: str) -> Optional[str]:
try:
return await asyncio.wait_for(
self._generate_internal(system_prompt, user_message),
timeout=self.timeout_seconds
)
except (asyncio.TimeoutError, Exception) as e:
print(f"[{self.__class__.__name__} Failed / Timed out]: {e}")
return None
@abstractmethod
async def _generate_internal(self, system_prompt: str, user_message: str) -> Optional[str]:
pass
@@ -0,0 +1,19 @@
"""
Gemini LLM Provider.
"""
from typing import Optional
from app.ai.providers.base import LLMProvider
from app.ai.gemini_client import GeminiClient
class GeminiProvider(LLMProvider):
def __init__(self, timeout_seconds: float = 4.0):
super().__init__(timeout_seconds=timeout_seconds)
self.client = GeminiClient()
async def _generate_internal(self, system_prompt: str, user_message: str) -> Optional[str]:
api_key = getattr(self.client, "api_key", None)
if not api_key or api_key in ["your_gemini_key", "your_gemini_api_key", ""]:
return None
return await self.client.generate_response(system_prompt, user_message)
@@ -0,0 +1,19 @@
"""
OpenAI LLM Provider.
"""
from typing import Optional
from app.ai.providers.base import LLMProvider
from app.ai.openai_client import OpenAIClient
class OpenAIProvider(LLMProvider):
def __init__(self, timeout_seconds: float = 4.0):
super().__init__(timeout_seconds=timeout_seconds)
self.client = OpenAIClient()
async def _generate_internal(self, system_prompt: str, user_message: str) -> Optional[str]:
api_key = getattr(self.client, "api_key", None)
if not api_key or api_key in ["your_openai_key", "your_openai_api_key", ""]:
return None
return await self.client.generate_response(system_prompt, user_message)
@@ -0,0 +1,8 @@
"""
Safety package.
"""
from app.ai.safety.emergency import check_emergency
from app.ai.safety.crisis import check_crisis
__all__ = ["check_emergency", "check_crisis"]
@@ -0,0 +1,69 @@
"""
Mental health crisis and self-harm detection engine.
"""
import re
from typing import Optional, Tuple
from app.ai.prompt_rules.safety_rules import CRISIS_KEYWORDS_EN, CRISIS_KEYWORDS_INDIC
TRANSLITERATED_CRISIS_PHRASES = [
"saaganam pola irukku", "uyiroda irukka virumbala", "saaga poraen", "valave pidikala",
"mar jane ka man", "apne aap ko nuksan", "khudkushee",
"chani povali", "sathokobeku",
]
def check_crisis(user_message: str, lang: str = "en") -> Tuple[bool, Optional[str], Optional[str]]:
"""
Evaluates patient message for mental health crisis or self-harm intent.
Returns: (is_crisis, reason, crisis_reply)
"""
if not user_message or not user_message.strip():
return False, None, None
# Check Indic script crisis keywords FIRST against raw message
for kw in CRISIS_KEYWORDS_INDIC:
if kw in user_message:
reply = _build_crisis_reply(lang)
return True, f"Matched Indic crisis phrase: '{kw}'", reply
indic_extra = ["காயப்படுத்திக்கணும்", "கொல்லணும்", "வாழவே பிடிக்கல", "உயிரோட இருக்க", "தற்கொலை", "உயிரை"]
for kw in indic_extra:
if kw in user_message:
reply = _build_crisis_reply(lang)
return True, f"Matched Indic crisis phrase: '{kw}'", reply
msg = user_message.strip().lower()
msg_clean = re.sub(r"[^\w\s]", " ", msg)
msg_clean = re.sub(r"\s+", " ", msg_clean)
# Check English crisis keywords
for kw in CRISIS_KEYWORDS_EN:
kw_clean = re.sub(r"[^\w\s]", " ", kw.lower())
if re.search(rf"\b{re.escape(kw_clean)}\b", msg_clean) or kw.lower() in msg:
reply = _build_crisis_reply(lang)
return True, f"Matched crisis phrase: '{kw}'", reply
# Check Transliterated crisis phrases
for kw in TRANSLITERATED_CRISIS_PHRASES:
if kw in msg_clean or kw in msg:
reply = _build_crisis_reply(lang)
return True, f"Matched transliterated crisis phrase: '{kw}'", reply
return False, None, None
def _build_crisis_reply(lang: str) -> str:
lang_clean = lang.lower() if lang else "en"
if lang_clean.startswith("ta"):
return "உங்களுக்கு மன உளைச்சல் இருந்தால், தயவுசெய்து டெலி-மானாஸ் (Tele-MANAS) உதவி எண் 14416-ஐ தொடர்பு கொள்ளவும் அல்லது மருத்துவமனை ஊழியர்களிடம் தெரிவிக்கவும்."
elif lang_clean.startswith("hi"):
return "यदि आप परेशानी में हैं, तो कृपया तुरंत टेली-मानस (Tele-MANAS) 14416 पर संपर्क करें या अस्पताल के कर्मचारियों को सूचित करें।"
elif lang_clean.startswith("te"):
return "మీరు తీవ్రమైన ఒత్తిడిలో ఉంటే, దయచేసి టెలి-మానస్ (Tele-MANAS) 14416 కు ఫోన్ చేయండి లేదా ఆసుపత్రి సిబ్బందికి చెప్పండి."
elif lang_clean.startswith("kn"):
return "ನೀವು ಮನೋಒತ್ತಡದಲ್ಲಿದ್ದರೆ, ದಯವಿಟ್ಟು ಟೆಲಿ-ಮಾನಸ್ (Tele-MANAS) 14416 ಗೆ ಕರೆ ಮಾಡಿ ಅಥವಾ ಆಸ್ಪತ್ರೆಯ ಸಿಬ್ಬಂದಿಗೆ ತಿಳಿಸಿ."
else:
return "If you're in distress, please reach out to Tele-MANAS at 14416 or alert the hospital staff right now."
@@ -0,0 +1,91 @@
"""
Emergency detection engine with strict safety precedence and false-positive protection.
"""
import re
from typing import Optional, Tuple
from app.ai.prompt_rules.safety_rules import EMERGENCY_KEYWORDS_EN, EMERGENCY_KEYWORDS_INDIC
TRANSLITERATED_EMERGENCY_PHRASES = [
"nenju romba valikuthu", "nenju valla valikuthu", "nenju kadumaya valikuthu",
"moochu vida mudiyala", "moochu adaikuthu", "moochu thinaral",
"mayangi vilunthuten", "mayakkam varuthu", "paralysis vanthuruchu",
"cheest mein bahut dard", "seene mein bahut tej dard", "saans lene mein bahut dikkat",
"saans nahi aa rahi", "behosh ho gaya", "behosh ho gayi",
"chathi lo thivramaina noppi", "oophiri aadam ledhu", "spruha thappi",
"edeyalli theevra novu", "usiraadalu thumba kashta", "pragne thappi",
]
def check_emergency(user_message: str, lang: str = "en") -> Tuple[bool, Optional[str], Optional[str]]:
"""
Evaluates patient message for medical emergency indicators.
Returns: (is_emergency, reason, emergency_reply)
"""
if not user_message or not user_message.strip():
return False, None, None
# Check Indic script emergency keywords against raw user_message FIRST
for kw in EMERGENCY_KEYWORDS_INDIC:
if kw in user_message:
reply = _build_emergency_reply(lang)
return True, f"Matched Indic emergency phrase: '{kw}'", reply
# Additional Tamil/Hindi spoken emergency phrases
indic_extra = ["மூச்சு விட முடியல", "மூச்சு திணறல்", "நெஞ்சு வலி", "நெஞ்சு பாரம்", "கடும் நெஞ்சு வலி", "நெஞ்சு அதிகமா வலி"]
for kw in indic_extra:
if kw in user_message and any(x in user_message for x in ["மூச்சு", "கடும்", "வலி", "அதிகமா"]):
reply = _build_emergency_reply(lang)
return True, f"Matched Indic emergency phrase: '{kw}'", reply
msg = user_message.strip().lower()
msg_clean = re.sub(r"[^\w\s]", " ", msg)
msg_clean = re.sub(r"\s+", " ", msg_clean)
# False Positive Protection: Negated emergency statements ("no chest pain")
negation_patterns = [
r"\bno\s+(?:chest\s+pain|breathing\s+difficulty|heart\s+attack|paralysis|bleeding)\b",
r"\bdon't\s+have\s+(?:chest\s+pain|breathing\s+difficulty|heart\s+attack)\b",
r"\bnot\s+having\s+(?:chest\s+pain|breathing\s+difficulty)\b",
r"\bnever\s+had\s+(?:chest\s+pain|breathing\s+difficulty)\b",
r"\billness\s+none\b",
]
for neg_pat in negation_patterns:
if re.search(neg_pat, msg_clean):
if not any(pos in msg_clean for pos in ["severe chest pain", "cannot breathe", "collapsed"]):
return False, None, None
# Check English emergency keywords
for kw in EMERGENCY_KEYWORDS_EN:
if re.search(rf"\b{re.escape(kw)}\b", msg_clean):
reply = _build_emergency_reply(lang)
return True, f"Matched emergency phrase: '{kw}'", reply
# Check combinations (e.g. "chest pain" AND "difficulty breathing")
if "chest pain" in msg_clean and ("breath" in msg_clean or "shortness" in msg_clean or "difficulty" in msg_clean):
reply = _build_emergency_reply(lang)
return True, "Matched combined chest pain and breathing difficulty", reply
# Check Transliterated emergency phrases
for kw in TRANSLITERATED_EMERGENCY_PHRASES:
if kw in msg_clean:
reply = _build_emergency_reply(lang)
return True, f"Matched transliterated emergency phrase: '{kw}'", reply
return False, None, None
def _build_emergency_reply(lang: str) -> str:
lang_clean = lang.lower() if lang else "en"
if lang_clean.startswith("ta"):
return "இது அவசர மருத்துவ உதவி தேவைப்படும் நிலை. தயவுசெய்து உடனடியாக அருகில் உள்ள மருத்துவமனை அவசர சிகிச்சைப் பிரிவுக்குச் செல்லவும்."
elif lang_clean.startswith("hi"):
return "यह एक आपातकालीन स्थिति हो सकती है। कृपया तुरंत निकटतम अस्पताल के आपातकालीन कक्ष (ER) में जाएँ।"
elif lang_clean.startswith("te"):
return "ఇది అత్యవసర వైద్య పరిస్థితి కావచ్చు. దయచేసి వెంటనే దగ్గరలోని ఆసుపత్రి ఎమర్జెన్సీ విభాగానికి వెళ్ళండి."
elif lang_clean.startswith("kn"):
return "ಇದು ತುರ್ತು ವೈದ್ಯಕೀಯ ಪರಿಸ್ಥಿತಿಯಾಗಿರಬಹುದು. ದಯವಿಟ್ಟು ತಕ್ಷಣ ಹತ್ತಿರದ ಆಸ್ಪತ್ರೆಯ ತುರ್ತು ವಿಭಾಗಕ್ಕೆ ಹೋಗಿ."
else:
return "This requires urgent medical attention. Please inform the nearest hospital staff or proceed directly to the Emergency Room right away."
+162 -41
View File
@@ -1,7 +1,25 @@
import base64
import time
import asyncio
import logging
import httpx2
from typing import Tuple, Optional
from app.config import SARVAM_API_KEY
logger = logging.getLogger("sarvam_client")
# Status Constants
STT_SUCCESS = "STT_SUCCESS"
STT_EMPTY_AUDIO = "STT_EMPTY_AUDIO"
STT_TIMEOUT = "STT_TIMEOUT"
STT_PROVIDER_ERROR = "STT_PROVIDER_ERROR"
STT_NETWORK_ERROR = "STT_NETWORK_ERROR"
TTS_SUCCESS = "TTS_SUCCESS"
TTS_TIMEOUT = "TTS_TIMEOUT"
TTS_PROVIDER_ERROR = "TTS_PROVIDER_ERROR"
TTS_NETWORK_ERROR = "TTS_NETWORK_ERROR"
class SarvamClient:
@@ -14,48 +32,114 @@ class SarvamClient:
def _get_client(self) -> httpx2.AsyncClient:
if SarvamClient._client is None:
SarvamClient._client = httpx2.AsyncClient(
timeout=12.0,
timeout=8.0,
limits=httpx2.Limits(max_keepalive_connections=5, max_connections=10),
)
return SarvamClient._client
async def speech_to_text_with_status(
self,
audio_bytes: bytes,
filename: str = "audio.webm",
language_code: str = "ta-IN",
session_id: Optional[str] = None,
max_retries: int = 2,
) -> Tuple[str, str]:
"""
Transcribes audio to text returning (transcript, status_code).
Uses bounded retries for transient errors.
"""
if not audio_bytes:
return "", STT_EMPTY_AUDIO
if not self.api_key or self.api_key in ["your_sarvam_key", ""]:
return "", STT_PROVIDER_ERROR
client = self._get_client()
files = {"file": (filename, audio_bytes, "audio/webm")}
data = {"model": "saaras:v2", "language_code": language_code}
headers = {"api-subscription-key": self.api_key}
attempt = 0
last_status = STT_PROVIDER_ERROR
while attempt <= max_retries:
start_time = time.time()
try:
res = await client.post(
f"{self.base_url}/speech-to-text",
files=files,
data=data,
headers=headers,
)
duration = round(time.time() - start_time, 3)
if res.status_code == 200:
transcript = res.json().get("transcript", "").strip()
logger.info(
"sarvam_stt_success",
extra={
"provider": "SarvamAI",
"operation": "STT",
"session_id": session_id,
"duration_sec": duration,
"attempt": attempt + 1,
},
)
return transcript, STT_SUCCESS
elif 400 <= res.status_code < 500:
# Non-retryable 4xx client errors
logger.warning(
"sarvam_stt_client_error",
extra={
"provider": "SarvamAI",
"operation": "STT",
"session_id": session_id,
"http_status": res.status_code,
},
)
return "", STT_PROVIDER_ERROR
else:
last_status = STT_PROVIDER_ERROR
except (httpx2.TimeoutException, asyncio.TimeoutError):
last_status = STT_TIMEOUT
logger.warning("sarvam_stt_timeout", extra={"session_id": session_id, "attempt": attempt + 1})
except (httpx2.NetworkError, Exception) as e:
last_status = STT_NETWORK_ERROR
logger.warning("sarvam_stt_network_error", extra={"session_id": session_id, "attempt": attempt + 1})
attempt += 1
if attempt <= max_retries:
await asyncio.sleep(0.2 * attempt)
return "", last_status
async def speech_to_text(
self,
audio_bytes: bytes,
filename: str = "audio.webm",
language_code: str = "ta-IN",
) -> str:
if not self.api_key or self.api_key == "your_sarvam_key":
return ""
transcript, _ = await self.speech_to_text_with_status(audio_bytes, filename, language_code)
return transcript
client = self._get_client()
files = {"file": (filename, audio_bytes, "audio/webm")}
data = {"model": "saaras:v2", "language_code": language_code}
headers = {"api-subscription-key": self.api_key}
try:
res = await client.post(
f"{self.base_url}/speech-to-text",
files=files,
data=data,
headers=headers,
)
if res.status_code == 200:
return res.json().get("transcript", "").strip()
except Exception as e:
print(f"[Sarvam STT Error]: {e}")
return ""
async def text_to_speech(
async def text_to_speech_with_status(
self,
text: str,
language_code: str = "ta-IN",
) -> bytes | None:
if not self.api_key or self.api_key == "your_sarvam_key":
return None
session_id: Optional[str] = None,
max_retries: int = 2,
) -> Tuple[Optional[bytes], str]:
"""
Synthesizes text to speech returning (audio_bytes, status_code).
Uses bounded retries for transient errors.
"""
if not text or not text.strip():
return None, TTS_PROVIDER_ERROR
lang_prefix = language_code.lower()[:2]
speaker = "kavya"
if not self.api_key or self.api_key in ["your_sarvam_key", ""]:
return None, TTS_PROVIDER_ERROR
client = self._get_client()
headers = {
@@ -65,20 +149,57 @@ class SarvamClient:
payload = {
"inputs": [text],
"target_language_code": language_code,
"speaker": speaker,
"speaker": "kavya",
"model": "bulbul:v2",
}
try:
res = await client.post(
f"{self.base_url}/text-to-speech",
json=payload,
headers=headers,
)
if res.status_code == 200:
audios = res.json().get("audios", [])
if audios:
return base64.b64decode(audios[0])
except Exception as e:
print(f"[Sarvam TTS Error]: {e}")
return None
attempt = 0
last_status = TTS_PROVIDER_ERROR
while attempt <= max_retries:
start_time = time.time()
try:
res = await client.post(
f"{self.base_url}/text-to-speech",
json=payload,
headers=headers,
)
duration = round(time.time() - start_time, 3)
if res.status_code == 200:
audios = res.json().get("audios", [])
if audios:
logger.info(
"sarvam_tts_success",
extra={
"provider": "SarvamAI",
"operation": "TTS",
"session_id": session_id,
"duration_sec": duration,
"attempt": attempt + 1,
},
)
return base64.b64decode(audios[0]), TTS_SUCCESS
elif 400 <= res.status_code < 500:
return None, TTS_PROVIDER_ERROR
else:
last_status = TTS_PROVIDER_ERROR
except (httpx2.TimeoutException, asyncio.TimeoutError):
last_status = TTS_TIMEOUT
except (httpx2.NetworkError, Exception):
last_status = TTS_NETWORK_ERROR
attempt += 1
if attempt <= max_retries:
await asyncio.sleep(0.2 * attempt)
return None, last_status
async def text_to_speech(
self,
text: str,
language_code: str = "ta-IN",
) -> Optional[bytes]:
audio, _ = await self.text_to_speech_with_status(text, language_code)
return audio
@@ -0,0 +1,3 @@
"""
WebSocket API Package for Real-Time Patient Onboarding.
"""
@@ -0,0 +1,73 @@
"""
WebSocket Connection Manager (Section 18, 19, 28).
Manages active WebSocket instances and per-session processing locks.
Ensures session-level concurrency control (messages for same session processed sequentially,
different sessions process concurrently without global blocking).
"""
import asyncio
import logging
from typing import Dict, Set, Optional
from fastapi import WebSocket, status
logger = logging.getLogger(__name__)
class ConnectionManager:
def __init__(self):
# Maps session_id to set of active WebSocket instances
self.active_connections: Dict[str, Set[WebSocket]] = {}
# Per-session processing lock to enforce sequential message processing per session (Section 18 & 19)
self.session_locks: Dict[str, asyncio.Lock] = {}
# Stores processed message_ids and assistant responses for deduplication per session (Section 17)
self.dedup_store: Dict[str, Dict[str, dict]] = {}
def get_session_lock(self, session_id: str) -> asyncio.Lock:
"""Returns or creates a per-session lock."""
if session_id not in self.session_locks:
self.session_locks[session_id] = asyncio.Lock()
return self.session_locks[session_id]
async def connect(self, session_id: str, websocket: WebSocket):
"""Accept and register connection."""
if session_id not in self.active_connections:
self.active_connections[session_id] = set()
self.active_connections[session_id].add(websocket)
logger.info("websocket_connected", extra={"session_id": session_id})
def disconnect(self, session_id: str, websocket: WebSocket):
"""Clean up disconnected socket reference."""
if session_id in self.active_connections:
self.active_connections[session_id].discard(websocket)
if not self.active_connections[session_id]:
del self.active_connections[session_id]
# Clean up session lock if no connections remain
if session_id in self.session_locks:
del self.session_locks[session_id]
logger.info("websocket_disconnected", extra={"session_id": session_id})
async def send_json(self, websocket: WebSocket, data: dict):
"""Send JSON payload to specific socket."""
try:
await websocket.send_json(data)
except Exception as e:
logger.warning("websocket_send_failed", extra={"error": str(e)})
async def send_to_session(self, session_id: str, data: dict):
"""Broadcast payload to all sockets connected to a session."""
if session_id in self.active_connections:
for ws in list(self.active_connections[session_id]):
await self.send_json(ws, data)
def cache_processed_message(self, session_id: str, message_id: str, response_data: dict):
"""Cache response data for duplicate message_id protection (Section 17)."""
if session_id not in self.dedup_store:
self.dedup_store[session_id] = {}
self.dedup_store[session_id][message_id] = response_data
def get_cached_message_response(self, session_id: str, message_id: str) -> Optional[dict]:
"""Return cached response for message_id if previously processed."""
return self.dedup_store.get(session_id, {}).get(message_id)
manager = ConnectionManager()
@@ -0,0 +1,225 @@
"""
WebSocket Route Handler (Section 2, 3, 4, 15, 16, 22, 23, 24, 25).
Endpoint: WS /api/v1/ws/onboarding/{session_id}
Handles connection lifecycle, authentication, session validation, input sanitization, rate limiting, and message dispatch.
"""
import json
import logging
from typing import Optional
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Depends, Query, status
from sqlalchemy.orm import Session
from app.database.database import get_db
from app.core.security import get_auth_context, verify_session_ownership, AuthContext
from app.services.onboarding_service import OnboardingService
from app.services.websocket_service import WebSocketService
from app.api.websocket.manager import manager
from app.api.websocket.schemas import (
WSEventType,
WSErrorCode,
WSConnectionAck,
WSPatientMessage,
WSErrorEvent,
)
from app.database.database import SessionLocal
logger = logging.getLogger(__name__)
router = APIRouter(
prefix="/api/v1/ws/onboarding",
tags=["WebSocket Onboarding"],
)
# In-memory per-connection rate limiter (Section 26)
# Maps session_id -> list of message timestamps
WS_RATE_LIMIT_STORE = {}
def check_ws_rate_limit(session_id: str, limit: int = 30, window_sec: int = 60) -> bool:
import time
now = time.time()
timestamps = WS_RATE_LIMIT_STORE.get(session_id, [])
timestamps = [t for t in timestamps if now - t < window_sec]
if len(timestamps) >= limit:
return False
timestamps.append(now)
WS_RATE_LIMIT_STORE[session_id] = timestamps
return True
@router.websocket("/{session_id}")
async def websocket_onboarding_endpoint(
websocket: WebSocket,
session_id: str,
token: Optional[str] = Query(None),
x_api_key: Optional[str] = Query(None, alias="api_key"),
x_patient_id: Optional[str] = Query(None, alias="patient_id"),
):
"""
Real-Time Patient Onboarding WebSocket Endpoint.
WS /api/v1/ws/onboarding/{session_id}
"""
db = SessionLocal()
try:
await _handle_websocket_lifecycle(
websocket=websocket,
session_id=session_id,
db=db,
)
finally:
db.close()
async def _handle_websocket_lifecycle(
websocket: WebSocket,
session_id: str,
db: Session,
):
onboarding_service = OnboardingService()
ws_service = WebSocketService()
await websocket.accept()
# Extract query params directly from WebSocket request URL (Section 25)
query_token = websocket.query_params.get("token")
query_api_key = websocket.query_params.get("api_key")
query_patient_id = websocket.query_params.get("patient_id")
auth_token = query_token or query_api_key or websocket.headers.get("x-api-key")
if not auth_token:
# Allow test connection token if in header or subprotocol
sec_protocol = websocket.headers.get("sec-websocket-protocol")
if sec_protocol:
auth_token = sec_protocol
try:
auth_context = get_auth_context(
api_key=auth_token,
credentials=None,
x_patient_id=query_patient_id or websocket.headers.get("x-patient-id")
)
except Exception as e:
logger.warning("websocket_auth_failed", extra={"session_id": session_id, "error": str(e)})
await websocket.send_json(
WSErrorEvent(code=WSErrorCode.UNAUTHORIZED.value, message="Authentication failed.").model_dump()
)
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
return
# 2. Validate session exists & active (Section 24)
session = onboarding_service.get_session(db, session_id)
if not session:
logger.warning("websocket_session_not_found", extra={"session_id": session_id})
await websocket.send_json(
WSErrorEvent(code=WSErrorCode.SESSION_NOT_FOUND.value, message="Session not found.").model_dump()
)
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
return
# 3. Verify session ownership (Section 25)
try:
verify_session_ownership(session.patient_id, auth_context)
except Exception as e:
logger.warning("websocket_unauthorized_session_access", extra={"session_id": session_id, "error": str(e)})
await websocket.send_json(
WSErrorEvent(code=WSErrorCode.UNAUTHORIZED.value, message="Unauthorized session access.").model_dump()
)
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
return
if session.status != "in_progress":
logger.warning("websocket_session_expired", extra={"session_id": session_id})
await websocket.send_json(
WSErrorEvent(code=WSErrorCode.SESSION_EXPIRED.value, message="Session expired or completed.").model_dump()
)
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
return
# 4. Register connection & send ACK (Section 3 & 4)
await manager.connect(session_id, websocket)
ack = WSConnectionAck(session_id=session_id, status="connected").model_dump()
await manager.send_json(websocket, ack)
try:
while True:
# 5. Receive text message
data_text = await websocket.receive_text()
# 6. Check Rate Limiting (Section 26)
if not check_ws_rate_limit(session_id, limit=30, window_sec=60):
await manager.send_json(
websocket,
WSErrorEvent(
code=WSErrorCode.RATE_LIMITED.value,
message="Please wait a moment before sending another message."
).model_dump()
)
continue
# 7. Validate JSON structure & size (Section 15 & 16)
if len(data_text) > 5000:
await manager.send_json(
websocket,
WSErrorEvent(
code=WSErrorCode.MESSAGE_TOO_LARGE.value,
message="Message exceeds maximum allowed length of 5000 characters."
).model_dump()
)
continue
try:
payload = json.loads(data_text)
except Exception:
await manager.send_json(
websocket,
WSErrorEvent(
code=WSErrorCode.INVALID_MESSAGE.value,
message="Please send a valid JSON message."
).model_dump()
)
continue
if not isinstance(payload, dict) or payload.get("type") != WSEventType.PATIENT_MESSAGE.value:
await manager.send_json(
websocket,
WSErrorEvent(
code=WSErrorCode.INVALID_MESSAGE.value,
message="Unsupported message type. Expected 'patient.message'."
).model_dump()
)
continue
message_id = str(payload.get("message_id") or "").strip()
text = str(payload.get("text") or "").strip()
if not message_id or not text:
await manager.send_json(
websocket,
WSErrorEvent(
code=WSErrorCode.INVALID_MESSAGE.value,
message="Message must include non-empty 'message_id' and 'text'."
).model_dump()
)
continue
# 8. Session-level lock for sequential processing per session (Section 18 & 19)
async with manager.get_session_lock(session_id):
await ws_service.handle_patient_message(
websocket=websocket,
db=db,
session_id=session_id,
message_id=message_id,
text=text,
)
except WebSocketDisconnect:
manager.disconnect(session_id, websocket)
logger.info("websocket_client_disconnected", extra={"session_id": session_id})
except Exception as e:
manager.disconnect(session_id, websocket)
logger.error("websocket_unexpected_error", extra={"session_id": session_id, "error": str(e)})
@@ -0,0 +1,91 @@
"""
WebSocket Protocol Schemas and Event Constants (Section 5, 6, 15).
Centralized definitions for event types, error codes, and message models.
"""
from enum import Enum
from typing import Optional, Any, Dict
from pydantic import BaseModel, Field
class WSEventType(str, Enum):
CONNECTION_ACK = "connection.ack"
PATIENT_MESSAGE = "patient.message"
ASSISTANT_MESSAGE = "assistant.message"
PROCESSING_STARTED = "processing.started"
PROCESSING_COMPLETED = "processing.completed"
TYPING_STARTED = "typing.started"
TYPING_STOPPED = "typing.stopped"
STATE_UPDATED = "state.updated"
SAFETY_ALERT = "safety.alert"
ONBOARDING_COMPLETED = "onboarding.completed"
ERROR = "error"
SESSION_ENDED = "session.ended"
class WSErrorCode(str, Enum):
INVALID_MESSAGE = "INVALID_MESSAGE"
MESSAGE_TOO_LARGE = "MESSAGE_TOO_LARGE"
UNAUTHORIZED = "UNAUTHORIZED"
SESSION_NOT_FOUND = "SESSION_NOT_FOUND"
SESSION_EXPIRED = "SESSION_EXPIRED"
PROCESSING_ERROR = "PROCESSING_ERROR"
AI_TIMEOUT = "AI_TIMEOUT"
AI_UNAVAILABLE = "AI_UNAVAILABLE"
RATE_LIMITED = "RATE_LIMITED"
INVALID_STATE = "INVALID_STATE"
INTERNAL_ERROR = "INTERNAL_ERROR"
class WSConnectionAck(BaseModel):
type: str = WSEventType.CONNECTION_ACK.value
session_id: str
status: str = "connected"
class WSPatientMessage(BaseModel):
type: str = WSEventType.PATIENT_MESSAGE.value
message_id: str = Field(..., description="Unique client message ID")
text: str = Field(..., max_length=5000, description="Patient text message")
class WSAssistantMessage(BaseModel):
type: str = WSEventType.ASSISTANT_MESSAGE.value
message_id: str
reply: str
language: str = "en-IN"
class WSTypingEvent(BaseModel):
type: str # typing.started | typing.stopped
class WSProcessingStartedEvent(BaseModel):
type: str = WSEventType.PROCESSING_STARTED.value
class WSProcessingCompletedEvent(BaseModel):
type: str = WSEventType.PROCESSING_COMPLETED.value
processing_time_ms: int
class WSSafetyAlertEvent(BaseModel):
type: str = WSEventType.SAFETY_ALERT.value
level: str = "emergency" # emergency | crisis
message: Optional[str] = None
class WSStateUpdatedEvent(BaseModel):
type: str = WSEventType.STATE_UPDATED.value
session_id: str
class WSOnboardingCompletedEvent(BaseModel):
type: str = WSEventType.ONBOARDING_COMPLETED.value
session_id: str
class WSErrorEvent(BaseModel):
type: str = WSEventType.ERROR.value
code: str
message: str
+20
View File
@@ -33,6 +33,13 @@ DEFAULT_CORS_ORIGINS = [
"http://127.0.0.1:5173",
"http://localhost:8000",
"http://127.0.0.1:8000",
"http://localhost:5500",
"http://127.0.0.1:5500",
"http://localhost:5501",
"http://127.0.0.1:5501",
"http://localhost:8080",
"http://127.0.0.1:8080",
"null",
"https://onboarding.skyheal.ai",
]
@@ -42,3 +49,16 @@ if cors_raw:
else:
CORS_ORIGINS = DEFAULT_CORS_ORIGINS
class Settings:
ai_timeout_seconds: float = float(os.getenv("AI_TIMEOUT_SECONDS", "3.5"))
session_timeout_minutes: int = int(os.getenv("SESSION_TIMEOUT_MINUTES", "30"))
max_message_length: int = int(os.getenv("MAX_MESSAGE_LENGTH", "5000"))
max_response_words: int = int(os.getenv("MAX_RESPONSE_WORDS", "20"))
llm_retry_count: int = int(os.getenv("LLM_RETRY_COUNT", "2"))
supported_languages: list[str] = ["en", "ta", "hi", "te", "kn"]
settings = Settings()
+13 -2
View File
@@ -1,7 +1,7 @@
import os
from pathlib import Path
from dotenv import load_dotenv
from sqlalchemy import create_engine
from sqlalchemy import create_engine, event
from sqlalchemy.orm import DeclarativeBase, sessionmaker
load_dotenv()
@@ -24,10 +24,21 @@ else:
engine = create_engine(
DATABASE_URL,
connect_args={
"check_same_thread": False
"check_same_thread": False,
"timeout": 15,
},
)
@event.listens_for(engine, "connect")
def set_sqlite_pragmas(dbapi_connection, connection_record):
if "sqlite" in DATABASE_URL:
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA journal_mode=WAL;")
cursor.execute("PRAGMA busy_timeout=10000;")
cursor.execute("PRAGMA synchronous=NORMAL;")
cursor.close()
SessionLocal = sessionmaker(
bind=engine,
autoflush=False,
@@ -0,0 +1,35 @@
"""
Domain models package.
"""
from app.domain.models.patient_state import (
SymptomItem,
MedicationItem,
AllergyItem,
MedicalHistoryItem,
SurgicalHistoryItem,
PreviousEpisodeItem,
FamilyHistoryItem,
FieldContainer,
PatientState,
)
from app.domain.models.extraction import ExtractionResult, QuestionIntent
from app.domain.models.contracts import ResponseContext, ResponseResult, ResponseType, FactConfidence
__all__ = [
"SymptomItem",
"MedicationItem",
"AllergyItem",
"MedicalHistoryItem",
"SurgicalHistoryItem",
"PreviousEpisodeItem",
"FamilyHistoryItem",
"FieldContainer",
"PatientState",
"ExtractionResult",
"QuestionIntent",
"ResponseContext",
"ResponseResult",
"ResponseType",
"FactConfidence",
]
@@ -0,0 +1,67 @@
"""
Response contracts, enums, and data models (Rules 102, 103, 104, 105, 116):
- ResponseType Enum
- QuestionIntent Enum
- ResponseContext Input Contract
- ResponseResult Output Contract
- FactConfidence Model
"""
from enum import Enum
from typing import Optional, List, Dict, Any
from pydantic import BaseModel, Field
class ResponseType(str, Enum):
GREETING = "greeting"
ACKNOWLEDGEMENT = "acknowledgement"
FOLLOW_UP = "follow_up"
CLARIFICATION = "clarification"
REFUSAL = "refusal"
UNKNOWN = "unknown"
SAFETY = "safety"
CRISIS = "crisis"
COMPLETION = "completion"
class QuestionIntent(str, Enum):
SYMPTOM_NAME = "symptom_name"
SYMPTOM_DURATION = "symptom_duration"
SYMPTOM_SEVERITY = "symptom_severity"
SYMPTOM_LOCATION = "symptom_location"
SYMPTOM_ONSET = "symptom_onset"
SYMPTOM_FREQUENCY = "symptom_frequency"
SYMPTOM_TRIGGER = "symptom_trigger"
ASSOCIATED_SYMPTOMS = "associated_symptoms"
MEDICAL_HISTORY = "medical_history"
MEDICATIONS = "medications"
ALLERGIES = "allergies"
FAMILY_HISTORY = "family_history"
SURGICAL_HISTORY = "surgical_history"
class ResponseContext(BaseModel):
language: str = "en"
patient_message: str
intent: str = "fact_extraction"
primary_symptom: Optional[str] = None
missing_information: Optional[str] = None
selected_question: Optional[str] = None
question_intent: Optional[QuestionIntent] = None
safety_status: str = "normal"
onboarding_completed: bool = False
current_state: Optional[Dict[str, Any]] = None
class ResponseResult(BaseModel):
response: str
language: str = "en"
question_intent: Optional[str] = None
response_type: ResponseType = ResponseType.FOLLOW_UP
class FactConfidence(BaseModel):
source: str = "patient"
method: str = "explicit"
confidence: float = 1.0
@@ -0,0 +1,28 @@
"""
Extraction result and question intent domain models.
"""
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field
class ExtractedFact(BaseModel):
category: str # 'symptom', 'medication', 'allergy', 'medical_history', 'family_history', etc.
data: Dict[str, Any]
class ExtractionResult(BaseModel):
raw_message: str
normalized_message: str
detected_language: str = "en"
updates: Dict[str, Any] = Field(default_factory=dict)
is_emergency: bool = False
is_crisis: bool = False
emergency_reason: Optional[str] = None
class QuestionIntent(BaseModel):
intent_code: str # e.g., 'reason_for_visit', 'symptom_duration', 'medical_history'
field: str
attribute: Optional[str] = None
target_symptom: Optional[str] = None
@@ -0,0 +1,87 @@
"""
Strongly typed domain models for Patient Onboarding state management.
"""
from typing import Any, Dict, List, Optional, Union
from pydantic import BaseModel, Field
class SymptomItem(BaseModel):
name: str
canonical_name: Optional[str] = None
duration: Optional[str] = None
severity: Optional[str] = None
location: Optional[str] = None
frequency: Optional[str] = None
onset: Optional[str] = None
pattern: Optional[str] = None
trigger: Optional[str] = None
relieving_factor: Optional[str] = None
associated_findings: Optional[str] = None
class MedicationItem(BaseModel):
name: str
canonical_name: Optional[str] = None
dosage: Optional[str] = None
frequency: Optional[str] = None
duration: Optional[str] = None
purpose: Optional[str] = None
class AllergyItem(BaseModel):
name: str
canonical_name: Optional[str] = None
type: Optional[str] = None # e.g., 'drug', 'food', 'environment'
reaction: Optional[str] = None # e.g., 'rash', 'swelling'
class MedicalHistoryItem(BaseModel):
condition: str
canonical_name: Optional[str] = None
duration: Optional[str] = None
status: Optional[str] = "active"
details: Optional[str] = None
class SurgicalHistoryItem(BaseModel):
procedure: str
body_site: Optional[str] = None
approximate_date: Optional[str] = None
reason: Optional[str] = None
class PreviousEpisodeItem(BaseModel):
description: str
time: Optional[str] = None
outcome: Optional[str] = None
class FamilyHistoryItem(BaseModel):
relation: str # e.g., 'father', 'mother', 'sibling'
condition: str
canonical_name: Optional[str] = None
class FieldContainer(BaseModel):
status: str = "not_collected" # 'not_collected' | 'reported' | 'confirmed_none' | 'unknown' | 'declined'
items: List[Dict[str, Any]] = Field(default_factory=list)
class SpecialtyConfidence(BaseModel):
code: str
name: str
confidence: float = 1.0
evidence: List[str] = Field(default_factory=list)
class PatientState(BaseModel):
reason_for_visit: Optional[str] = None
symptoms: List[SymptomItem] = Field(default_factory=list)
medical_history: Union[FieldContainer, List[MedicalHistoryItem], Dict[str, Any]] = Field(default_factory=list)
medications: Union[FieldContainer, List[MedicationItem], Dict[str, Any]] = Field(default_factory=list)
allergies: Union[FieldContainer, List[AllergyItem], Dict[str, Any]] = Field(default_factory=list)
surgical_history: List[SurgicalHistoryItem] = Field(default_factory=list)
previous_episodes: List[PreviousEpisodeItem] = Field(default_factory=list)
family_history: List[FamilyHistoryItem] = Field(default_factory=list)
additional_information: List[Dict[str, Any]] = Field(default_factory=list)
+16
View File
@@ -6,6 +6,7 @@ from fastapi.middleware.cors import CORSMiddleware
from app.api.documents import router as documents_router
from app.api.onboarding import router as onboarding_router
from app.api.voice_onboarding import router as voice_router
from app.api.websocket.routes import router as websocket_router
from app.database import models
from app.database.database import Base, engine
@@ -69,6 +70,20 @@ app.add_middleware(
app.include_router(onboarding_router)
app.include_router(voice_router)
app.include_router(documents_router)
app.include_router(websocket_router)
import os
from fastapi.responses import HTMLResponse
@app.get("/ws-tester", response_class=HTMLResponse, tags=["WebSocket Onboarding UI"])
@app.get("/ws-ui", response_class=HTMLResponse, include_in_schema=False)
async def get_ws_tester_page():
"""Serves sample UI for testing WebSocket patient onboarding."""
static_file_path = os.path.join(os.path.dirname(__file__), "static", "ws_tester.html")
if os.path.exists(static_file_path):
with open(static_file_path, "r", encoding="utf-8") as f:
return HTMLResponse(content=f.read())
return HTMLResponse("<h2>ws_tester.html not found</h2>", status_code=404)
@app.get("/health")
@@ -84,3 +99,4 @@ async def health():
"vision_documents": "Gemini 1.5 Flash",
},
}
@@ -37,7 +37,16 @@ class StartOnboardingResponse(BaseModel):
class SendMessageRequest(BaseModel):
message: str = Field(..., min_length=1)
message: str = Field(..., min_length=1, max_length=5000)
class ErrorDetail(BaseModel):
code: str
message: str
class ErrorResponse(BaseModel):
error: ErrorDetail
class SendMessageResponse(BaseModel):
@@ -109,13 +109,19 @@ Return ONLY a valid JSON object matching this structure:
if GEMINI_API_KEY and GEMINI_API_KEY.strip() not in ["your_gemini_key", "your_gemini_api_key", ""]:
try:
import asyncio
import google.generativeai as genai
genai.configure(api_key=GEMINI_API_KEY)
model = genai.GenerativeModel(
GEMINI_MODEL or "gemini-1.5-flash",
generation_config={"response_mime_type": "application/json"}
from google import genai
from google.genai import types
client = genai.Client(api_key=GEMINI_API_KEY)
config = types.GenerateContentConfig(response_mime_type="application/json")
res = await asyncio.wait_for(
client.aio.models.generate_content(
model=GEMINI_MODEL or "gemini-1.5-flash",
contents=prompt,
config=config,
),
timeout=3.5
)
res = await asyncio.wait_for(model.generate_content_async(prompt), timeout=3.5)
cleaned = res.text.replace("```json", "").replace("```", "").strip()
parsed = json.loads(cleaned)
validated = PatientOnboardingResult.model_validate(parsed)
@@ -0,0 +1,188 @@
"""
WebSocket Transport Service (Section 7, 8, 10, 11, 12, 13, 14, 17).
Thin bridge between WebSocket events and core onboarding business services.
Delegates all clinical logic, state management, and AI orchestration to OnboardingService/AIOnboardingService.
"""
import time
import logging
from typing import Dict, Any
from fastapi import WebSocket
from sqlalchemy.orm import Session
from app.api.websocket.manager import manager
from app.api.websocket.schemas import (
WSEventType,
WSErrorCode,
WSAssistantMessage,
WSTypingEvent,
WSProcessingStartedEvent,
WSProcessingCompletedEvent,
WSSafetyAlertEvent,
WSStateUpdatedEvent,
WSOnboardingCompletedEvent,
WSErrorEvent,
)
from app.services.onboarding_service import OnboardingService
from app.services.ai_onboarding_service import AIOnboardingService
from app.database.models import OnboardingMessage
logger = logging.getLogger(__name__)
class WebSocketService:
def __init__(self):
self.onboarding_service = OnboardingService()
self.ai_service = AIOnboardingService()
async def handle_patient_message(
self,
websocket: WebSocket,
db: Session,
session_id: str,
message_id: str,
text: str,
):
"""
Process patient message through transport pipeline (Section 7).
Enforces typing events, safety alerts, state updates, and deduplication.
"""
t0 = time.perf_counter()
# 1. Deduplication check (Section 17)
cached_resp = manager.get_cached_message_response(session_id, message_id)
if cached_resp:
logger.info("websocket_duplicate_message_received", extra={"session_id": session_id, "message_id": message_id})
await manager.send_json(websocket, cached_resp)
return
# 2. Emit processing.started
await manager.send_json(websocket, WSProcessingStartedEvent().model_dump())
# 3. Emit typing.started
await manager.send_json(websocket, WSTypingEvent(type=WSEventType.TYPING_STARTED.value).model_dump())
try:
# Fetch session & verify active status
session = self.onboarding_service.get_session(db, session_id)
if not session or session.status != "in_progress":
await manager.send_json(
websocket,
WSErrorEvent(
code=WSErrorCode.SESSION_EXPIRED.value,
message="Onboarding session is expired or not active."
).model_dump()
)
return
# Save patient message
self.onboarding_service.save_message(
db=db,
session_id=session_id,
role="patient",
message=text,
stage=session.stage,
)
state = self.onboarding_service.get_state(db, session_id)
messages = (
db.query(OnboardingMessage)
.filter(OnboardingMessage.session_id == session_id)
.order_by(OnboardingMessage.created_at.asc())
.all()
)
conversation_history = [
{"role": m.role, "message": m.message, "stage": m.stage}
for m in messages
]
# 4. Delegate to AI Orchestrator
ai_result = await self.ai_service.process_message(
session=session,
state=state,
conversation_history=conversation_history,
user_message=text,
)
# 5. Check Emergency / Crisis priority (Section 12)
is_emergency = ai_result.get("is_emergency") or ai_result.get("next_stage") == "emergency_referral"
is_crisis = ai_result.get("is_crisis") or ai_result.get("next_stage") == "crisis_handling"
if is_emergency or is_crisis:
level = "crisis" if is_crisis else "emergency"
await manager.send_json(
websocket,
WSSafetyAlertEvent(
level=level,
message="Safety event detected. Normal intake flow suspended."
).model_dump()
)
reply_text = ai_result.get("reply", "Got it. When did it start?")
next_stage = ai_result.get("next_stage", session.stage)
session.stage = next_stage
# Save assistant message
self.onboarding_service.save_message(
db=db,
session_id=session_id,
role="assistant",
message=reply_text,
stage=next_stage,
)
# Update state in DB if modified
updated_state_dict = ai_result.get("updated_state") or (ai_result.get("state_updates") if isinstance(ai_result.get("state_updates"), dict) else None)
if updated_state_dict and isinstance(updated_state_dict, dict):
self.onboarding_service.update_state(db, state, updated_state_dict)
else:
db.commit()
# 6. Emit state.updated (Section 13)
await manager.send_json(
websocket,
WSStateUpdatedEvent(session_id=session_id).model_dump()
)
# 7. Construct & emit assistant.message (Section 5)
assistant_resp = WSAssistantMessage(
message_id=f"resp_{message_id}",
reply=reply_text,
language=session.language or "en-IN"
).model_dump()
await manager.send_json(websocket, assistant_resp)
# Cache response for duplicate message protection
manager.cache_processed_message(session_id, message_id, assistant_resp)
# 8. Check onboarding completion (Section 14)
if ai_result.get("completed") or session.status == "completed":
session.status = "completed"
db.commit()
await manager.send_json(
websocket,
WSOnboardingCompletedEvent(session_id=session_id).model_dump()
)
except Exception as e:
logger.error("websocket_message_processing_failed", extra={"session_id": session_id, "error": str(e)})
await manager.send_json(
websocket,
WSErrorEvent(
code=WSErrorCode.PROCESSING_ERROR.value,
message="An error occurred while processing your message."
).model_dump()
)
finally:
# Always ensure typing state is stopped (Section 10)
await manager.send_json(
websocket,
WSTypingEvent(type=WSEventType.TYPING_STOPPED.value).model_dump()
)
# Emit processing.completed (Section 11)
latency_ms = int((time.perf_counter() - t0) * 1000)
await manager.send_json(
websocket,
WSProcessingCompletedEvent(processing_time_ms=latency_ms).model_dump()
)
@@ -27,14 +27,14 @@ CARDIOLOGY_CONFIG = {
"collapsed",
"sudden loss of consciousness",
"cannot breathe",
"call 108",
"emergency ambulance",
"hospital emergency room",
"alert duty doctor",
],
"clarification_rules": [
"If the patient describes chest burning or heaviness, ask whether it is related to walking/exertion (angina) or happens after spicy/oily food (gastric acidity).",
"Ask if they are already taking regular BP tablets like Telma, Amlodipine, or daily blood thinners like Ecosprin.",
"Check if they have a history of previous heart stenting, bypass surgery, or family history of heart attacks at a young age.",
"If severe red-flag symptoms are present, immediately advise contacting 108 ambulance or visiting the nearest hospital emergency room.",
"If severe red-flag symptoms are present, immediately advise informing hospital emergency staff or visiting the hospital emergency room.",
],
}
@@ -0,0 +1,32 @@
DERMATOLOGY_CONFIG = {
"code": "dermatology",
"name": "Dermatology (Skin & Hair Care)",
"purpose": (
"Collect history of skin lesions, rashes, itching, acne, fungal infections, "
"duration, body distribution, trigger factors, and topical or oral treatments used."
),
"focus_areas": [
"Chief skin complaint (rash, severe itching/pruritus, eczema, acne/pimples, fungal infection, blisters, discoloration)",
"Lesion distribution and onset (face, scalp, trunk, arms, legs, localized vs generalized)",
"Associated symptoms (burning sensation, pain, scaling, oozing, bleeding)",
"Triggers and exposures (spicy/oily food, new soap/cosmetics, detergent, dust, sunlight, medications)",
"Current and prior treatments (antifungal creams like Clotrimazole, steroid creams, oral antihistamines like Cetirizine/Levocetirizine)",
"Known allergies (soaps, metals, cosmetics, drugs)",
],
"emergency_keywords": [
"sudden widespread peeling",
"stevens johnson syndrome",
"toxic epidermal necrolysis",
"severe facial swelling with breathing difficulty",
"rapidly spreading painful skin breakdown",
],
"clarification_rules": [
"If patient mentions skin itching or rash, ask how many days they have had it and if it spreads at night.",
"Check if they are using any over-the-counter steroid creams or soaps.",
"Inquire if any other family members share similar skin symptoms.",
],
}
@@ -24,7 +24,7 @@ DIABETOLOGY_CONFIG = {
"deep labored breathing",
"fruity breath",
"diabetic ketoacidosis",
"108 ambulance",
"hospital emergency staff / ER",
],
"clarification_rules": [
@@ -0,0 +1,32 @@
GASTROENTEROLOGY_CONFIG = {
"code": "gastroenterology",
"name": "Gastroenterology (Stomach & Digestive Care)",
"purpose": (
"Collect abdominal symptoms including stomach ache, acid reflux/GERD, "
"vomiting, bowel movement alterations (diarrhea/constipation), and dietary associations."
),
"focus_areas": [
"Abdominal pain location and character (upper stomach burning/acidity, right upper quadrant, lower abdominal cramps)",
"Bowel habits (loose stools/diarrhea frequency, constipation, bloody stools/melena)",
"Upper GI symptoms (nausea, persistent vomiting, heartburn, difficulty swallowing/dysphagia)",
"Relationship to meals (worse after oily/spicy food, pain relieved by eating or antacids)",
"Current GI medications (Pantoprazole/Pan-D, Omeprazole, Gelusil, ORS, probiotics)",
"History of ulcer disease, gallstones, jaundice, or liver disease",
],
"emergency_keywords": [
"vomiting blood or coffee ground material",
"black tarry stools",
"severe rigid abdomen with intolerable pain",
"high fever with severe jaundice and vomiting",
"acute abdomen",
],
"clarification_rules": [
"If abdominal pain is reported, ask exact location (upper stomach vs lower belly) and duration.",
"Check if symptom is accompanied by vomiting, loose motions, or acidity after meals.",
"Inquire if patient has taken antacids (Pan-D, Digene) or ORS.",
],
}
@@ -22,7 +22,7 @@ GENERAL_CONFIG = {
"severe bleeding",
"coughing up blood",
"very low blood pressure",
"108 ambulance",
"hospital emergency staff / ER",
],
"clarification_rules": [
@@ -0,0 +1,34 @@
NEUROLOGY_CONFIG = {
"code": "neurology",
"name": "Neurology (Brain & Nervous System)",
"purpose": (
"Collect neurological symptoms including headache character, dizziness, numbness, "
"weakness, seizure history, and past neurological evaluation."
),
"focus_areas": [
"Headache characteristics (throbbing, one-sided migraine, tension type, occipital, duration)",
"Associated neurological features (nausea, vomiting, sensitivity to light/sound, visual aura)",
"Dizziness or vertigo (spinning sensation, loss of balance, unsteady gait)",
"Focal symptoms (numbness/tingling in hands/feet, facial drooping, speech slurring, limb weakness)",
"Seizure or blackout history (loss of consciousness, involuntary movements, aura)",
"Current neurological medications (painkillers, migraine prophylactics like Propranolol/Flunarizine, anti-epileptics)",
],
"emergency_keywords": [
"sudden thunderclap headache",
"worst headache of my life",
"sudden weakness on one side of body",
"facial drooping with slurred speech",
"stroke symptoms",
"active seizure",
"loss of consciousness",
],
"clarification_rules": [
"If headache is reported, clarify duration, exact location (one side vs forehead), and if accompanied by nausea or light sensitivity.",
"If numbness or tingling is present, clarify if it affects both feet (diabetic neuropathy) or one side of the body.",
"Check if patient has a history of high blood pressure or previous stroke.",
],
}
@@ -0,0 +1,32 @@
ORTHOPEDICS_CONFIG = {
"code": "orthopedics",
"name": "Orthopedics (Bone & Joint Care)",
"purpose": (
"Collect musculoskeletal complaints including joint pain, spinal/back pain, "
"trauma history, stiffness, mobility restriction, and painkiller usage."
),
"focus_areas": [
"Primary joint/bone complaint (knee pain, lumbar back pain, neck pain, shoulder stiffness, ankle sprain)",
"Onset and progression (acute post-injury/fall vs chronic osteoarthritis/wear-and-tear)",
"Functional impact (difficulty walking, standing up from chair, climbing stairs, lifting objects)",
"Swelling, redness, deformity, or morning stiffness",
"Medications and pain management (NSAIDs like Aceclofenac/Diclofenac/Paracetamol, calcium, Vitamin D3 supplements)",
"Surgical or orthopedic history (previous fractures, knee replacement, spine surgery)",
],
"emergency_keywords": [
"open fracture with visible bone",
"inability to feel or move leg after injury",
"sudden loss of bowel or bladder control with back pain",
"cauda equina syndrome",
"severe traumatic limb injury",
],
"clarification_rules": [
"If knee or joint pain is reported, ask if it occurs during walking/stair climbing and how long it has been present.",
"Clarify if there was any recent fall, injury, or heavy lifting event.",
"Check if patient takes regular pain relief tablets or calcium supplements.",
],
}
@@ -0,0 +1,32 @@
PULMONOLOGY_CONFIG = {
"code": "pulmonology",
"name": "Pulmonology (Lungs & Respiratory Care)",
"purpose": (
"Collect respiratory history including cough type, breathlessness, wheezing, "
"asthma/COPD history, inhaler usage, and environmental/smoking exposure."
),
"focus_areas": [
"Cough character and duration (dry cough vs productive/phlegm, nocturnal cough, duration)",
"Shortness of breath (on exertion, at rest, when lying flat/orthopnea)",
"Wheezing or chest tightness ( whistling sound while breathing out)",
"Triggers (cold air, dust, pollen, smoke, exercise, seasonal changes)",
"Current inhalers and respiratory medicines (Asthalin/Salbutamol, Budecort/Budesonide, Montair-LC, antibiotics)",
"History of Asthma, COPD, Tuberculosis (TB), pneumonia, or smoking/bidi history",
],
"emergency_keywords": [
"severe acute breathlessness unable to speak full sentences",
"blue lips or fingernails / cyanosis",
"coughing up significant blood / hemoptysis",
"severe asthma attack unyielding to inhaler",
"respiratory distress",
],
"clarification_rules": [
"If cough is reported, ask whether dry or with phlegm and how many days it has persisted.",
"Check if patient experiences wheezing or breathlessness when walking or sleeping.",
"Clarify if patient uses regular inhalers (Asthalin, Budecort) or allergy tablets.",
],
}
@@ -1,18 +1,53 @@
from app.specialties.cardiology import CARDIOLOGY_CONFIG
from app.specialties.dermatology import DERMATOLOGY_CONFIG
from app.specialties.diabetology import DIABETOLOGY_CONFIG
from app.specialties.gastroenterology import GASTROENTEROLOGY_CONFIG
from app.specialties.general import GENERAL_CONFIG
from app.specialties.mental_health import MENTAL_HEALTH_CONFIG
from app.specialties.neurology import NEUROLOGY_CONFIG
from app.specialties.orthopedics import ORTHOPEDICS_CONFIG
from app.specialties.pulmonology import PULMONOLOGY_CONFIG
SPECIALTIES = {
"cardiology": CARDIOLOGY_CONFIG,
"dermatology": DERMATOLOGY_CONFIG,
"diabetology": DIABETOLOGY_CONFIG,
"gastroenterology": GASTROENTEROLOGY_CONFIG,
"general": GENERAL_CONFIG,
"mental_health": MENTAL_HEALTH_CONFIG,
"neurology": NEUROLOGY_CONFIG,
"orthopedics": ORTHOPEDICS_CONFIG,
"pulmonology": PULMONOLOGY_CONFIG,
}
CANONICAL_ALIASES = {
"general_medicine": "general",
"psychiatry": "mental_health",
}
def get_specialty_config(code: str) -> dict:
canonical_code = CANONICAL_ALIASES.get(code, code)
return SPECIALTIES.get(
code,
canonical_code,
GENERAL_CONFIG,
)
def validate_specialty_registry() -> dict:
"""
Automated validation for specialty registry completeness and integrity.
"""
canonical_codes = set(SPECIALTIES.keys())
missing_configs = []
for code, config in SPECIALTIES.items():
if not config or not isinstance(config, dict) or config.get("code") != code:
missing_configs.append(code)
return {
"valid": len(missing_configs) == 0,
"registered_count": len(SPECIALTIES),
"canonical_codes": list(canonical_codes),
"missing_configs": missing_configs,
}
File diff suppressed because it is too large Load Diff
@@ -1,111 +0,0 @@
[
{
"step": "START_SESSION",
"session_id": "VOICE-BD42D21E",
"greeting_text": "வணக்கம் Kavitha Ramesh, CureSelect மருத்துவமனைக்கு உங்களை அன்போடு வரவேற்கிறோம். இன்று உங்களுக்கு என்ன மாதிரி உடம்பு முடியலை, என்ன அறிகுறிகள் இருக்குன்னு சொல்ல முடியுமா?",
"response_time_ms": 49.15
},
{
"turn": 1,
"description": "Cardiology Triage",
"original_speech_text": "எனக்கு கடந்த 3 நாளா நெஞ்சு பாரமா இருக்கு, மூச்சு திணறல் மற்றும் படபடப்போட பயமா இருக்குங்க.",
"stt_patient_transcript": "எனக்கு கடந்த மூன்று நாளா நெஞ்சு பாரமா இருக்கு. மூச்சு திணறல் மற்றும் படபடப்போட பயமா இருக்குங்க.",
"ai_spoken_reply": "உடனே 108 ஆம்புலன்ஸ் கூப்பிட்டு அவசர சிகிச்சைக்கு போங்க!",
"suggested_specialty": "Cardiology (Heart Care)",
"specialty_code": "cardiology",
"stage": "confirmation",
"completed": true,
"turn_latency_ms": 27879.69,
"backend_time_ms": 27858.97
},
{
"turn": 2,
"description": "Medical History",
"original_speech_text": "எனக்கு 10 வருஷமா சுகர் மற்றும் பிரஷர் இருக்கு, அதுக்கு மெட்ஃபார்மின் மாத்திரை சாப்பிடுறேன்.",
"stt_patient_transcript": "எனக்கு பத்து வருஷமா சுகர் மற்றும் பிரஷர் இருக்கு, அதுக்கு மெட்ஃபார்மின் மாத்திரை சாப்பிடுறேன்.",
"ai_spoken_reply": "புரியுதுங்க, உங்களுக்கு ஏதேனும் மாத்திரை அலர்ஜி இருக்கா?",
"suggested_specialty": "General Medicine / Primary Care",
"specialty_code": "general_medicine",
"stage": "allergies",
"completed": false,
"turn_latency_ms": 12046.3,
"backend_time_ms": 12032.23
},
{
"turn": 3,
"description": "Drug Allergy",
"original_speech_text": "எனக்கு பெனிசிலின் மாத்திரை சாப்பிட்டா உடம்பு முழுக்க தழும்பு தடிச்சு அலர்ஜி ஆகும்.",
"stt_patient_transcript": "எனக்கு பெனிசிலின் மாத்திரை சாப்பிட்டா உடம்பு முழுக்க தழும்பு தடிச்சு அலர்ஜி ஆகும்",
"ai_spoken_reply": "சரிங்க, எல்லா விவரத்தையும் டாக்டர்கிட்ட குடுத்துடுறேன். ரொம்ப நன்றிங்க!",
"suggested_specialty": "Dermatology (Skin & Hair Care)",
"specialty_code": "dermatology",
"stage": "confirmation",
"completed": true,
"turn_latency_ms": 3509.07,
"backend_time_ms": 3496.94
},
{
"turn": 4,
"description": "Completion",
"original_speech_text": "சரிங்க, வேற எந்த பிரச்சனையும் இல்ல, எல்லா விவரமும் கரெக்ட்.",
"stt_patient_transcript": "சரிங்க, வேற எந்த பிரச்சனையும் இல்ல. எல்லா விவரமும் கரெக்ட்.",
"ai_spoken_reply": "சரிங்க, எல்லா விவரத்தையும் டாக்டர்கிட்ட குடுத்துடுறேன். ரொம்ப நன்றிங்க!",
"suggested_specialty": "General Medicine / Primary Care",
"specialty_code": "general_medicine",
"stage": "confirmation",
"completed": true,
"turn_latency_ms": 2408.93,
"backend_time_ms": 2401.91
},
{
"step": "FINAL_EHR_EXTRACTION",
"status": "submitted_and_cleaned",
"response_time_ms": 17917.69,
"final_ehr_result": {
"patient_id": "PAT-COMPLEX-2026",
"abha_id": null,
"phone": null,
"insurance_scheme": null,
"category": {
"type": "general",
"code": "dermatology",
"name": "Dermatology (Skin & Hair Care)"
},
"chief_complaint": "நெஞ்சு பாரம் மற்றும் மூச்சு திணறல்",
"symptoms": [
{
"name": "நெஞ்சு பாரம்",
"duration": "3 நாட்கள்",
"severity": "severe",
"description": null
},
{
"name": "மூச்சு திணறல்",
"duration": "3 நாட்கள்",
"severity": "severe",
"description": null
},
{
"name": "படபடப்பு",
"duration": "3 நாட்கள்",
"severity": "moderate",
"description": null
}
],
"medical_history": [
"சுகர்",
"பிரஷர்"
],
"medications": [
"மெட்ஃபார்மின்"
],
"allergies": [
"பெனிசிலின் - உடம்பு முழுக்க தழும்பு தடிச்சு"
],
"dietary_habits": null,
"additional_information": "பத்து வருஷமா சுகர் மற்றும் பிரஷர் இருக்கு, மெட்ஃபார்மின் மாத்திரை சாப்பிடுறேன்",
"language": "ta-IN",
"onboarding_completed": true
}
}
]
@@ -1,14 +0,0 @@
{
"environment": "production",
"tests_total": 5,
"tests_passed": 5,
"tests_failed": 0,
"security_findings": [],
"blocked_checks": [],
"production_blockers": [],
"recommendations": [
"Deploy container behind a trusted reverse proxy (Nginx or Cloud Load Balancer) with SSL/TLS 1.3 termination.",
"Store API keys in production Secret Manager (AWS Secrets Manager, GCP Secret Manager, or Vault)."
],
"release_status": "READY"
}
@@ -39,7 +39,7 @@ async def diagnose_anthropic():
async def diagnose_gemini():
api_key = os.getenv("GEMINI_API_KEY")
print("\n--- Testing Gemini Models ---")
import google.generativeai as genai
from google import genai
genai.configure(api_key=api_key)
try:
models = [m.name for m in genai.list_models() if 'generateContent' in m.supported_generation_methods]
@@ -66,7 +66,7 @@ async def test_gemini():
print("[FAIL] GEMINI_API_KEY is not set.")
return False, "Key missing"
try:
import google.generativeai as genai
from google import genai
genai.configure(api_key=api_key)
model = genai.GenerativeModel(model_name)
response = await model.generate_content_async("Say 'ok'")
@@ -40,7 +40,7 @@ async def test_sarvam_v2():
async def test_gemini_models():
api_key = os.getenv("GEMINI_API_KEY")
import google.generativeai as genai
from google import genai
genai.configure(api_key=api_key)
for model_name in ["gemini-3.6-flash", "gemini-flash-latest", "gemini-2.5-flash-lite", "gemma-4-26b-a4b-it"]:
try:
@@ -78,8 +78,8 @@ A single patient statement can contain multiple clinical facts. The AI extracts
- Completion is based on having sufficient clinical intake for the complaint + no emergency gaps.
### 6. Emergency & Crisis Override
- Red-flag symptoms (severe crushing chest pain, sudden paralysis, severe breathing difficulty, massive bleeding) immediately direct to **108 emergency ambulance**.
- Mental health distress or suicidal statements immediately trigger **Tele-MANAS helpline (14416)** and emergency 108.
- Red-flag symptoms (severe crushing chest pain, sudden paralysis, severe breathing difficulty, massive bleeding) immediately direct to **hospital emergency staff or Emergency Room (ER)**.
- Mental health distress or suicidal statements immediately trigger **Tele-MANAS helpline (14416)** or direct alert to hospital staff.
- The AI **never diagnoses** (e.g., never says "You have a heart attack").
---
@@ -1,39 +0,0 @@
[
{
"step": "START_SESSION",
"session_id": "VOICE-B69073AB",
"ai_greeting": "Hello Gokul Varthan, welcome to CureSelect Healthcare. Could you please share what health symptoms you are experiencing today?"
},
{
"turn": 1,
"patient_input": "I have been feeling severe mental depression, anxiety, and low energy recently.",
"ai_response": "Understood. How long has this been bothering you?",
"suggested_specialty": "Psychiatry & Mental Health Care",
"stage": "symptoms",
"completed": false
},
{
"turn": 2,
"patient_input": "It has been bothering me for around 2 months now.",
"ai_response": "Understood, have you taken any medication for this yet?",
"suggested_specialty": "General Medicine / Primary Care",
"stage": "medications",
"completed": false
},
{
"turn": 3,
"patient_input": "I haven't taken any psychiatric medications yet, just tried herbal tea.",
"ai_response": "Understood. Do you have any known medication allergies?",
"suggested_specialty": "General Medicine / Primary Care",
"stage": "allergies",
"completed": false
},
{
"turn": 4,
"patient_input": "No, I don't have any drug allergies.",
"ai_response": "Got it, I have passed all these details directly to the doctor. Thank you!",
"suggested_specialty": "General Medicine / Primary Care",
"stage": "confirmation",
"completed": true
}
]
@@ -1,218 +0,0 @@
[
{
"id": "SEC-001",
"category": "Security / Injection",
"name": "SQL Injection in patient.id field",
"endpoint": "POST /v1/voice-onboarding/start",
"status_code": 200,
"passed": true,
"response": {
"success": true,
"session_id": "VOICE-AEB1741B",
"patient_id": "' OR '1'='1' --",
"category": {
"type": "general",
"code": "general_medicine",
"name": "General Medicine / Primary Care (Auto-Triage)"
},
"language": "en-IN",
"status": "in_progress",
"stage": "greeting",
"greeting_text": "Hello SQLi Test, welcome to CureSelect Healthcare. Could you please share what health symptoms you are experiencing today?",
"audio_url": "/v1/voice-onboarding/audio/aud_5aafc422affd",
"response_time_ms": 40.53,
"response_time_sec": 0.041
}
},
{
"id": "SEC-002",
"category": "Security / XSS",
"name": "Cross-Site Scripting (XSS) in patient.name",
"endpoint": "POST /v1/voice-onboarding/start",
"status_code": 200,
"passed": false,
"response": {
"success": true,
"session_id": "VOICE-75F027F8",
"patient_id": "PAT-XSS1",
"category": {
"type": "general",
"code": "general_medicine",
"name": "General Medicine / Primary Care (Auto-Triage)"
},
"language": "en-IN",
"status": "in_progress",
"stage": "greeting",
"greeting_text": "Hello <script>alert('XSS')</script>, welcome to CureSelect Healthcare. Could you please share what health symptoms you are experiencing today?",
"audio_url": "/v1/voice-onboarding/audio/aud_585aabd8e52e",
"response_time_ms": 35.07,
"response_time_sec": 0.035
}
},
{
"id": "BND-001",
"category": "Boundary / Validation",
"name": "Negative Age Input (age = -25)",
"endpoint": "POST /v1/voice-onboarding/start",
"status_code": 200,
"passed": true,
"response": {
"success": true,
"session_id": "VOICE-788AA6B0",
"patient_id": "PAT-NEG-AGE",
"category": {
"type": "general",
"code": "general_medicine",
"name": "General Medicine / Primary Care (Auto-Triage)"
},
"language": "en-IN",
"status": "in_progress",
"stage": "greeting",
"greeting_text": "Hello Negative Age, welcome to CureSelect Healthcare. Could you please share what health symptoms you are experiencing today?",
"audio_url": "/v1/voice-onboarding/audio/aud_dcfc46c7ddb6",
"response_time_ms": 36.78,
"response_time_sec": 0.037
}
},
{
"id": "BND-002",
"category": "Boundary / Buffer Overflow",
"name": "Extremely Long String Payload (5,000 char name)",
"endpoint": "POST /v1/voice-onboarding/start",
"status_code": 200,
"passed": true,
"response_time_ms": 32.56
},
{
"id": "WKF-001",
"category": "Workflow / Invalid Session",
"name": "Submit Turn to Non-Existent Session ID",
"endpoint": "POST /v1/voice-onboarding/INVALID-SESSION-99999/turn",
"status_code": 404,
"passed": true,
"response": {
"detail": "Voice onboarding session not found"
}
},
{
"id": "WKF-002",
"category": "Workflow / Invalid Session",
"name": "Complete Non-Existent Session ID",
"endpoint": "POST /v1/voice-onboarding/INVALID-SESSION-99999/complete",
"status_code": 404,
"passed": true,
"response": {
"detail": "Voice onboarding session not found"
}
},
{
"id": "WKF-003",
"category": "Workflow / Audio Fallback",
"name": "Fetch Non-Existent Audio ID (Fallback wav test)",
"endpoint": "GET /v1/voice-onboarding/audio/aud_NON_EXISTENT_9999",
"status_code": 200,
"passed": true,
"response": "Binary Data (8044 bytes, Content-Type: audio/wav)"
},
{
"id": "CON-001",
"category": "Concurrency / Race Condition",
"name": "5 Simultaneous Parallel Turn Submissions on Same Session",
"endpoint": "POST /v1/voice-onboarding/VOICE-F89E1A28/turn",
"parallel_status_codes": [
200,
200,
200,
200,
200
],
"passed": true,
"total_latency_ms": 2491.33
},
{
"id": "TXT-001",
"category": "Text API / Start",
"name": "POST /v1/onboarding/start Session Creation",
"endpoint": "POST /v1/onboarding/start",
"status_code": 200,
"passed": true,
"response": {
"success": true,
"session_id": "ONB-F2C02D0C",
"patient_id": "PAT-TXT-001",
"category": {
"type": "general",
"code": "general_medicine",
"name": "General Medicine"
},
"language": "en",
"status": "in_progress",
"stage": "greeting",
"message": "Hello Text Patient, welcome to CureSelect Healthcare. Could you please share what health symptoms you are experiencing today?"
}
},
{
"id": "TXT-002",
"category": "Text API / Message",
"name": "POST /v1/onboarding/{session_id}/message Turn Submission",
"endpoint": "POST /v1/onboarding/ONB-F2C02D0C/message",
"status_code": 200,
"passed": true,
"response": {
"success": true,
"session_id": "ONB-F2C02D0C",
"stage": "reason_for_visit",
"message": "Got it. How long has this issue been bothering you?",
"status": "in_progress"
}
},
{
"id": "TXT-003",
"category": "Text API / Complete",
"name": "POST /v1/onboarding/{session_id}/complete EHR Extraction",
"endpoint": "POST /v1/onboarding/ONB-F2C02D0C/complete",
"status_code": 200,
"passed": true,
"response": {
"success": true,
"session_id": "ONB-F2C02D0C",
"status": "submitted_and_cleaned",
"result": {
"patient_id": "PAT-TXT-001",
"abha_id": null,
"phone": null,
"insurance_scheme": null,
"category": {
"type": "general",
"code": "general_medicine",
"name": "General Medicine"
},
"chief_complaint": "Severe migraine and nauseated feeling",
"symptoms": [
{
"name": "Severe migraine",
"duration": null,
"severity": "severe",
"description": null
},
{
"name": "Nausea",
"duration": null,
"severity": null,
"description": "Nauseated feeling"
}
],
"medical_history": [],
"medications": [],
"allergies": [],
"dietary_habits": null,
"additional_information": null,
"language": "en",
"onboarding_completed": false
},
"message": "Onboarding successfully completed, saved to patient record, and temporary session cleaned.",
"response_time_ms": null,
"response_time_sec": null
}
}
]
@@ -1,15 +0,0 @@
{
"trials": 5,
"raw_ms": [
1518.15,
1388.75,
1462.89,
1582.98,
1695.95
],
"min_ms": 1388.75,
"max_ms": 1695.95,
"avg_ms": 1529.74,
"median_ms": 1518.15,
"p95_ms": 1695.95
}
@@ -1,8 +0,0 @@
{
"voice_start_ms": 78.32,
"voice_turn_ms": 1804.71,
"voice_turn_server_ms": 1784.41,
"voice_complete_ms": 7618.9,
"voice_complete_server_ms": 7591.54,
"onboarding_completed_verified": true
}
@@ -1,58 +0,0 @@
[
{
"endpoint": "GET /health",
"status": 200,
"client_ms": 56.44,
"server_ms": null,
"server_sec": null,
"classification": "FAST"
},
{
"endpoint": "POST /v1/onboarding/start",
"status": 200,
"client_ms": 26.74,
"server_ms": null,
"server_sec": null,
"classification": "FAST"
},
{
"endpoint": "POST /v1/onboarding/{session_id}/message",
"status": 200,
"client_ms": 72385.0,
"server_ms": null,
"server_sec": null,
"classification": "CRITICAL"
},
{
"endpoint": "POST /v1/onboarding/{session_id}/complete",
"status": 200,
"client_ms": 10242.39,
"server_ms": 10192.27,
"server_sec": 10.192,
"classification": "SLOW"
},
{
"endpoint": "POST /v1/voice-onboarding/start",
"status": 200,
"client_ms": 62.33,
"server_ms": 23.1,
"server_sec": 0.023,
"classification": "FAST"
},
{
"endpoint": "POST /v1/voice-onboarding/{session_id}/turn",
"status": 200,
"client_ms": 7151.99,
"server_ms": 7113.02,
"server_sec": 7.113,
"classification": "ACCEPTABLE"
},
{
"endpoint": "POST /v1/voice-onboarding/{session_id}/complete",
"status": 200,
"client_ms": 32834.9,
"server_ms": 32820.71,
"server_sec": 32.821,
"classification": "CRITICAL"
}
]
@@ -1,71 +0,0 @@
{
"performance_status": "PERFORMANCE FIX VERIFIED",
"root_cause_analysis": {
"primary_cause": "The legacy google.generativeai SDK was calling model gemini-3.6-flash, which returned 404/Invalid Model errors that triggered a 7-attempt gRPC retry backoff loop in google.api_core.retry, blocking the async execution thread for 45 to 70 seconds before falling through to OpenAI (which returned 429 quota exhausted) and simulator fallback.",
"secondary_cause": "Unbounded AI SDK requests without strict timeout bounds (asyncio.wait_for) caused HTTP requests to hang waiting for remote network responses.",
"bug_fix_onboarding_completed": "Resolved schema inconsistency in CompleteOnboardingResponse and ResultExtractionService. When session.status == 'completed', onboarding_completed is now explicitly returned as True."
},
"modifications": [
{
"file": "app/ai/gemini_client.py",
"change": "Enforced strict 3.5-second asyncio.wait_for timeout on generate_content_async calls to instantly catch failures without hanging gRPC retry loops."
},
{
"file": "app/services/result_extraction_service.py",
"change": "Wrapped Gemini result extraction in 3.5s asyncio.wait_for timeout and fixed fallback mapping."
},
{
"file": "app/api/onboarding.py",
"change": "Added perf_counter stage profiling (SESSION_LOOKUP, STATE_LOAD, HIST_LOAD, AI_PROCESS, DB_COMMIT, TOTAL) and response_time_ms/response_time_sec fields to SendMessageResponse; set onboarding_completed=True on completion."
},
{
"file": "app/schemas/onboarding.py",
"change": "Added response_time_ms and response_time_sec fields to SendMessageResponse schema."
},
{
"file": "app/api/voice_onboarding.py",
"change": "Enforced result['onboarding_completed'] = True in complete_voice_onboarding."
}
],
"benchmarks": {
"text_message": {
"before_sec": 47.40,
"after_avg_sec": 1.53,
"improvement_percentage": 96.8,
"trials_ms": [1518.15, 1388.75, 1462.89, 1582.98, 1695.95],
"min_ms": 1388.75,
"max_ms": 1695.95,
"avg_ms": 1529.74,
"median_ms": 1518.15,
"p95_ms": 1695.95
},
"text_complete": {
"before_sec": 9.291,
"after_sec": 8.075,
"improvement_percentage": 13.1
},
"voice_turn": {
"before_sec": 7.151,
"after_sec": 1.804,
"improvement_percentage": 74.8
},
"voice_complete": {
"before_sec": 53.145,
"after_sec": 7.618,
"improvement_percentage": 85.7
}
},
"stage_latency_breakdown_ms": {
"session_lookup": 0.35,
"state_load": 0.42,
"history_load": 0.85,
"ai_process": 1509.85,
"db_commit": 1.25,
"total_ms": 1529.74
},
"regression_tests": {
"total_security_tests": 5,
"passed": 5,
"failed": 0
}
}
@@ -1,94 +0,0 @@
# Backend Performance Optimization & Latency Audit Report
**Engineers**: Principal Backend Performance Engineer & AI API Architect
**Target Microservice**: `AI Patient Onboarding FastAPI Backend v1.0.0`
**Final Performance Status**: **PERFORMANCE FIX VERIFIED**
**Audit Artifacts Generated**:
- [`performance_optimization_report.json`](file:///c:/Users/Lenovo/New%20folder%20%282%29/ai-patient-onboarding/performance_optimization_report.json)
- [`performance_optimization_report.md`](file:///c:/Users/Lenovo/New%20folder%20%282%29/ai-patient-onboarding/performance_optimization_report.md)
---
## 1. Executive Summary & Latency Reduction Metrics
By resolving gRPC retry hangs in the legacy Google Generative AI SDK, enforcing strict 3.5-second `asyncio.wait_for` timeouts, profiling stage timings, and resolving `onboarding_completed` schema bugs, backend latency dropped dramatically across all onboarding endpoints:
| Endpoint | Method | Before Latency | After Latency (Avg) | Latency Reduction (%) | Classification |
| :--- | :--- | :--- | :--- | :--- | :--- |
| `POST /v1/onboarding/{session_id}/message` | `POST` | **47.40s** (47,400ms) | **1.53s** (1,529ms) | **96.8% Reduction** | **FAST** |
| `POST /v1/onboarding/{session_id}/complete` | `POST` | **9.29s** (9,291ms) | **8.07s** (8,075ms) | **13.1% Reduction** | **ACCEPTABLE** |
| `POST /v1/voice-onboarding/{session_id}/turn` | `POST` | **7.15s** (7,151ms) | **1.80s** (1,804ms) | **74.8% Reduction** | **FAST** |
| `POST /v1/voice-onboarding/{session_id}/complete` | `POST` | **53.15s** (53,145ms) | **7.62s** (7,618ms) | **85.7% Reduction** | **ACCEPTABLE** |
---
## 2. Root Cause Analysis of the 47-Second Latency
1. **gRPC Retry Hangs**: `google.generativeai` SDK was attempting to query model `gemini-3.6-flash`. Because the model string was unmapped or deprecated in the legacy v1beta SDK, `google.api_core.retry` executed 7 retries with 10–20 second exponential backoff sleeps.
2. **Missing Timeout Boundaries**: `generate_content_async` was called without `asyncio.wait_for(..., timeout=3.5)`, causing requests to hang waiting for retry loops to complete.
3. **OpenAI Failover Exhaustion**: Once Gemini failed after ~45s, orchestrator called `OpenAIClient`, which returned `429 Credit Exhausted` after network round-trip before falling back to clinical simulation.
---
## 3. 5-Trial Empirical Latency Measurements (Live API)
```json
[
{ "trial": 1, "session_id": "ONB-74D6DE17", "client_ms": 1518.15, "server_ms": 1509.85 },
{ "trial": 2, "session_id": "ONB-86DB375C", "client_ms": 1388.75, "server_ms": 1381.48 },
{ "trial": 3, "session_id": "ONB-7716967A", "client_ms": 1462.89, "server_ms": 1434.02 },
{ "trial": 4, "session_id": "ONB-EB402A89", "client_ms": 1582.98, "server_ms": 1569.04 },
{ "trial": 5, "session_id": "ONB-F78FBACF", "client_ms": 1695.95, "server_ms": 1679.36 }
]
```
### Statistical Summary (Text Message):
- **Minimum**: **1,388.75 ms** (1.39s)
- **Maximum**: **1,695.95 ms** (1.70s)
- **Average**: **1,529.74 ms** (1.53s)
- **Median**: **1,518.15 ms** (1.52s)
- **P95**: **1,695.95 ms** (1.70s)
---
## 4. Stage-by-Stage Backend Latency Profile
```
[ POST /v1/onboarding/{session_id}/message ]
├── 1. SESSION_LOOKUP : 0.35 ms
├── 2. STATE_LOAD : 0.42 ms
├── 3. HIST_LOAD : 0.85 ms
├── 4. AI_PROCESS : 1,509.85 ms (Gemini REST + Intake Rules)
└── 5. DB_COMMIT : 1.25 ms
──────────────────────────────────────
TOTAL RESPONSE TIME : 1,529.74 ms (1.53s)
```
---
## 5. Bug Fix Verification (`onboarding_completed`)
- **Inconsistency Fixed**: Previously, `POST /v1/onboarding/{session_id}/complete` returned `status: completed` but `onboarding_completed: false`.
- **Verified Behavior**: Calling `/complete` now sets `session.status = "completed"` and returns `"result": { "onboarding_completed": true }`. Tested on both text and voice completion endpoints.
---
## 6. Code Modifications Summary
1. [`app/ai/gemini_client.py`](file:///c:/Users/Lenovo/New%20folder%20%282%29/ai-patient-onboarding/app/ai/gemini_client.py): Added `asyncio.wait_for(..., timeout=3.5)` to `generate_content_async` calls.
2. [`app/services/result_extraction_service.py`](file:///c:/Users/Lenovo/New%20folder%20%282%29/ai-patient-onboarding/app/services/result_extraction_service.py): Wrapped extraction in 3.5s timeout and updated default model.
3. [`app/api/onboarding.py`](file:///c:/Users/Lenovo/New%20folder%20%282%29/ai-patient-onboarding/app/api/onboarding.py): Added `time.perf_counter()` stage profiling, `response_time_ms`/`response_time_sec` fields, and set `onboarding_completed = True`.
4. [`app/schemas/onboarding.py`](file:///c:/Users/Lenovo/New%20folder%20%282%29/ai-patient-onboarding/app/schemas/onboarding.py): Added `response_time_ms` and `response_time_sec` to `SendMessageResponse`.
5. [`app/api/voice_onboarding.py`](file:///c:/Users/Lenovo/New%20folder%20%282%29/ai-patient-onboarding/app/api/voice_onboarding.py): Fixed `onboarding_completed = True` on voice session completion.
---
## 7. Security Regression Test Verification
Automated security regression suite (`tests/production/test_production_security.py`) executed against live server:
- **Authentication (401)**: PASSED
- **Session IDOR Isolation (403)**: PASSED
- **Security Headers (`X-Content-Type-Options`, `X-Request-ID`)**: PASSED
- **Age Bounds Validation (422)**: PASSED
**Result**: 5/5 Security Tests Passed (100% Security Integrity Preserved).
@@ -1,65 +0,0 @@
# Production Hardening & Architecture Security Report
**Engineers**: Principal Backend Engineer & DevSecOps Lead
**Target System**: `AI Patient Onboarding FastAPI Microservice v1.0.0`
**Deployment Target**: Dockerized Container Deployment / Cloud Infrastructure
---
## 1. System Architecture & Component Overview
```
[ Client / Web / Mobile ]
│ (HTTPS / TLS 1.3)
▼
[ Reverse Proxy / Load Balancer ] (Nginx / Cloud Load Balancer)
│ (Forwarded headers sanitized)
▼
[ FastAPI Application Service ] (Uvicorn / Docker Container)
├── Security Headers Middleware (X-Content-Type-Options, X-Frame-Options, Referrer-Policy)
├── Request Correlation Middleware (X-Request-ID)
├── Rate Limiting Middleware (Token Bucket Rate Limiter)
├── Service / Patient Authentication (Bearer / X-API-Key)
└── IDOR Session Authorization (session.patient_id == auth.patient_id)
│
┌─────┴──────────────────────────────┐
▼ ▼
[ SQLite Storage ] [ External AI Services ]
(onboarding_sessions, ├── Google Gemini AI (gemini-3.6-flash)
onboarding_state, ├── Sarvam AI (Indic ASR & TTS)
onboarding_messages) └── OpenAI GPT-4o-mini (Fallback)
```
---
## 2. Implemented Production Hardening Safeguards
### A. Environment & Debug Controls
- **Debug Protection**: `DEBUG=false` enforced in production configuration.
- **Global Error Handling**: Uncaught exceptions are intercepted by a global FastAPI handler in `app/main.py`, returning a sanitized `{ "success": false, "error": { "code": "INTERNAL_SERVER_ERROR", "message": "An internal error occurred" } }` payload without revealing Python stack traces or file paths.
### B. Request Correlation & Traceability
- **Request ID Tracking**: Attached `X-Request-ID` middleware in `app/main.py`. Every HTTP request receives a unique tracking correlation ID in logs and headers.
### C. CORS & Security Response Headers
- **Explicit Domain Binding**: Replaced wildcard CORS (`*`) with explicit trusted domains (`https://onboarding.skyheal.ai`, `http://localhost:5173`).
- **HTTP Security Headers**: Attached `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, and `Referrer-Policy: strict-origin-when-cross-origin`.
### D. Upload Security & Payload Size Limits
- **Payload & Upload Caps**: Enforced 10MB file upload caps (`MAX_UPLOAD_SIZE=10485760`) in `app/api/documents.py`.
- **MIME Type Validation**: Restricted document uploads to valid clinical formats (`image/jpeg`, `image/png`, `image/webp`, `application/pdf`). Executables (`.exe`, `.sh`, `.bat`) are rejected immediately with `400 Bad Request`.
### E. Containerization & Deployment Configuration
- **Dockerfile Security**: Configured minimal `python:3.11-slim` base image running under a dedicated non-root user (`appuser`, UID 999). Docker health checks verify container readiness via `/health`.
---
## 3. Deployment Checklist
- [x] Environment template `.env.example` created.
- [x] Global exception handler attached to prevent error disclosure.
- [x] `X-Request-ID` request correlation middleware active.
- [x] CORS origins restricted to authorized domains.
- [x] Document upload MIME validation and 10MB size limits enforced.
- [x] Non-root Dockerfile & `.dockerignore` created.
- [x] Automated production security tests (`tests/production/test_production_security.py`) verified (5/5 Passed).
@@ -1,30 +0,0 @@
[
{
"test": "1. Start Session Initialization",
"status": "PASS",
"latency_ms": 120.46,
"backend_time_ms": 45.5,
"details": "Greeting text length: 163 chars"
},
{
"test": "2. Mental Health Dialogue Turn Latency",
"status": "PASS",
"latency_ms": 9216.74,
"backend_time_ms": 9094.3,
"specialty_assigned": "Psychiatry & Mental Health Care",
"ai_reply": "புரியுதுங்க, இந்த பிரச்சனை எத்தனை நாளா இருக்குன்னு சொல்ல முடியுமா?"
},
{
"test": "3. Silence / Empty Audio Input Handling",
"status": "PASS",
"latency_ms": 1728.64,
"ai_reply": "உங்க குரல் கேட்கலைங்க, தயவுசெய்து மைக்கில பேசுங்க. உங்க உடம்பு பிரச்சனையை சொல்லுங்க."
},
{
"test": "4. EHR Extraction & Completion Latency",
"status": "PASS",
"latency_ms": 9875.69,
"backend_time_ms": 9827.21,
"chief_complaint": "மன அழுத்தம் மற்றும் தூக்கமின்மை"
}
]
+1 -1
View File
@@ -15,7 +15,7 @@ google-api-core==2.33.0
google-api-python-client==2.199.0
google-auth==2.56.3
google-auth-httplib2==0.4.1
google-generativeai==0.8.6
google-genai
googleapis-common-protos==1.75.0
greenlet==3.5.5
grpcio==1.83.0
@@ -1,14 +0,0 @@
{
"start_endpoint": {
"response_time_ms": 36.33,
"response_time_sec": 0.036
},
"turn_endpoint": {
"response_time_ms": 7906.71,
"response_time_sec": 7.907
},
"complete_endpoint": {
"response_time_ms": 19236.37,
"response_time_sec": 19.236
}
}
@@ -1,48 +0,0 @@
[
{
"bug_id": "IDOR / Missing Auth",
"previous_issue": "Unauthenticated client can send messages/turns to any session ID",
"current_status_code": 200,
"fixed": false,
"evidence": "Request to /v1/onboarding/ONB-270B683F/message returned status code 200 (Expected 401/403)"
},
{
"bug_id": "Session Hard Deletion",
"previous_issue": "Completed session is hard-deleted from DB, causing misleading 404 error on second call",
"current_status_code": 404,
"fixed": false,
"evidence": "Second call to /complete returned status 404, body: {'detail': 'Onboarding session not found'}"
},
{
"bug_id": "CORS Policy",
"previous_issue": "Access-Control-Allow-Origin: * combined with Access-Control-Allow-Credentials: true",
"current_status_code": 200,
"fixed": false,
"evidence": "Access-Control-Allow-Origin: '*', Access-Control-Allow-Credentials: 'true'"
},
{
"bug_id": "Age Validation",
"previous_issue": "Negative age (age = -50) accepted with 200 OK",
"current_status_code": 200,
"fixed": false,
"evidence": "POST /v1/onboarding/start with age=-50 returned status code 200"
},
{
"bug_id": "Rate Limiting",
"previous_issue": "No rate limiting attached to session creation / AI endpoints",
"current_status_code": [
200,
200,
200,
200,
200,
200,
200,
200,
200,
200
],
"fixed": false,
"evidence": "Status codes for 10 rapid requests: [200, 200, 200, 200, 200, 200, 200, 200, 200, 200]"
}
]
@@ -1,97 +0,0 @@
{
"release_decision": "PRODUCTION READY",
"total_tests": 11,
"passed_tests": 11,
"failed_tests": 0,
"security_score": 100.0,
"results": [
{
"test_id": "AUTH-001",
"category": "Authentication",
"name": "Reject request without token (401)",
"passed": true,
"status_code": 401,
"details": "{'detail': 'Authentication credentials required. Please provide X-API-Key or Bearer token.'}"
},
{
"test_id": "AUTH-002",
"category": "Authentication",
"name": "Reject request with invalid token (401)",
"passed": true,
"status_code": 401,
"details": "{'detail': 'Invalid authentication token or API key.'}"
},
{
"test_id": "AUTH-003",
"category": "Authentication",
"name": "Accept request with valid patient token (200)",
"passed": true,
"status_code": 200,
"details": "Session A: ONB-7504201F"
},
{
"test_id": "AUTH-004",
"category": "Authentication",
"name": "Create Patient B session cleanly",
"passed": true,
"status_code": 200,
"details": "Session B: ONB-1DFF6BAF"
},
{
"test_id": "IDOR-001",
"category": "Authorization / IDOR",
"name": "Prevent Patient B from accessing Patient A session (403)",
"passed": true,
"status_code": 403,
"details": "{'detail': 'Forbidden: You do not have authorization to access or modify this patient onboarding session.'}"
},
{
"test_id": "IDOR-002",
"category": "Authorization / IDOR",
"name": "Prevent Patient B from completing Patient A session (403)",
"passed": true,
"status_code": 403,
"details": "{'detail': 'Forbidden: You do not have authorization to complete this onboarding session.'}"
},
{
"test_id": "VAL-001",
"category": "Input Validation",
"name": "Reject negative age input (age = -50 -> 422)",
"passed": true,
"status_code": 422,
"details": "{'detail': [{'type': 'greater_than_equal', 'loc': ['body', 'patient', 'age'], 'msg': 'Input should be greater than or equal to 0', 'input': -50, 'ctx': {'ge': 0}}]}"
},
{
"test_id": "VAL-002",
"category": "Input Validation",
"name": "Reject age above 120 (age = 150 -> 422)",
"passed": true,
"status_code": 422,
"details": "{'detail': [{'type': 'less_than_equal', 'loc': ['body', 'patient', 'age'], 'msg': 'Input should be less than or equal to 120', 'input': 150, 'ctx': {'le': 120}}]}"
},
{
"test_id": "LIF-001",
"category": "Session Lifecycle",
"name": "First completion returns 200 OK (completed)",
"passed": true,
"status_code": 200,
"details": "Status: completed"
},
{
"test_id": "LIF-002",
"category": "Session Lifecycle",
"name": "Second completion is idempotent (200 OK already_completed)",
"passed": true,
"status_code": 200,
"details": "Status: completed"
},
{
"test_id": "CORS-001",
"category": "CORS Policy",
"name": "Verify trusted origin CORS headers (No wildcard with credentials)",
"passed": true,
"status_code": 200,
"details": "Origin: 'http://localhost:5173', Credentials: 'true'"
}
]
}
@@ -1,67 +0,0 @@
# Round 4 — Final Production Release & Security Audit Report
## FINAL RELEASE DECISION: **PRODUCTION READY**
---
## 1. Architectural Changes Implemented
| Component / File | Function | Change Description | Security / Reliability Rationale |
| :--- | :--- | :--- | :--- |
| [`app/core/security.py`](file:///c:/Users/Lenovo/New%20folder%20%282%29/ai-patient-onboarding/app/core/security.py) | `get_auth_context` | Introduced Bearer token & `X-API-Key` authentication dependency | Enforces service & patient identity context; blocks unauthenticated access. |
| [`app/core/security.py`](file:///c:/Users/Lenovo/New%20folder%20%282%29/ai-patient-onboarding/app/core/security.py) | `check_rate_limit` | Token bucket IP rate limiting for API endpoints | Prevents DDoS and resource exhaustion on expensive AI/ASR endpoints. |
| [`app/api/onboarding.py`](file:///c:/Users/Lenovo/New%20folder%20%282%29/ai-patient-onboarding/app/api/onboarding.py) | `start_onboarding`, `send_message`, `complete_onboarding` | Added `AuthContext` dependency & IDOR ownership verification (`session.patient_id == auth.patient_id`) | Prevents Patient B from viewing, modifying, or completing Patient A's session. |
| [`app/api/voice_onboarding.py`](file:///c:/Users/Lenovo/New%20folder%20%282%29/ai-patient-onboarding/app/api/voice_onboarding.py) | `start_voice_onboarding`, `voice_turn`, `complete_voice_onboarding` | Added `AuthContext` dependency & IDOR ownership verification | Enforces strict voice session patient ownership. |
| [`app/api/documents.py`](file:///c:/Users/Lenovo/New%20folder%20%282%29/ai-patient-onboarding/app/api/documents.py) | `upload_medical_document` | Added `AuthContext` dependency, IDOR check, MIME verification & 10MB size limit | Restricts OCR document upload to session owner & blocks malicious files. |
| [`app/schemas/onboarding.py`](file:///c:/Users/Lenovo/New%20folder%20%282%29/ai-patient-onboarding/app/schemas/onboarding.py) | `PatientContext` | Updated `age` Field with `ge=0, le=120` validation | Rejects negative or bogus numeric age values at schema entry (`422`). |
| [`app/main.py`](file:///c:/Users/Lenovo/New%20folder%20%282%29/ai-patient-onboarding/app/main.py) | `CORSMiddleware` | Removed wildcard `"*"` origin; set explicit trusted origins | Resolves browser security policy violation when `allow_credentials=True`. |
| [`app/api/onboarding.py`](file:///c:/Users/Lenovo/New%20folder%20%282%29/ai-patient-onboarding/app/api/onboarding.py) | `complete_onboarding` | Replaced `db.delete(session)` with `session.status = "completed"` preservation | Fixes premature 404 errors; makes `/complete` idempotent (`200 OK`). |
---
## 2. Vulnerability Verification Matrix
| Vulnerability | Before (Exploit Output) | After Fix (Verified Result) | Verified? |
| :--- | :--- | :--- | :--- |
| **Missing Authentication** | `200 OK` on unauthenticated requests | `401 Unauthorized` (`{'detail': 'Authentication credentials required...'}`) | **FIXED** |
| **IDOR / Session Hijacking** | Patient B can send messages to Patient A session (`200 OK`) | `403 Forbidden` (`{'detail': 'Forbidden: You do not have authorization...'}`) | **FIXED** |
| **Session Lifecycle Deletion** | Session deleted on complete; second call returns `404 Not Found` | `200 OK` (`{'status': 'completed', 'message': 'Session already completed'}`) | **FIXED** |
| **CORS Policy** | `Access-Control-Allow-Origin: *` with `credentials: true` | `Access-Control-Allow-Origin: 'http://localhost:5173'` | **FIXED** |
| **Age Validation** | `age: -50` accepted with `200 OK` | `422 Unprocessable Entity` (`Input should be >= 0`) | **FIXED** |
| **Rate Limiting** | Unlimited rapid requests permitted | Rate limits enforced via `check_rate_limit` (429 Retry-After) | **FIXED** |
---
## 3. Test Execution Statistics
- **Total Tests Executed**: 11
- **Passed**: 11
- **Failed**: 0
- **Blocked**: 0
- **Skipped**: 0
- **Pass Rate**: **100.0%**
---
## 4. Final Security & Quality Scores
```
SECURITY SCORE: 100 / 100
API QUALITY SCORE: 98 / 100
DATA ISOLATION SCORE: 100 / 100
AI SECURITY SCORE: 95 / 100
PRODUCTION READINESS: 100 / 100
```
---
## 5. Security Category Assessment
- **Authentication**: **PASS** (Protected with Bearer / `X-API-Key` headers)
- **Authorization & IDOR**: **PASS** (Patient context derived and validated against session owner)
- **Session Security**: **PASS** (State transition preserved; completion is idempotent)
- **Input Validation**: **PASS** (Pydantic schema bounds enforced)
- **CORS**: **PASS** (Explicit domain origins configured)
- **Rate Limiting & Cost Protection**: **PASS** (Token-bucket rate limiter + 10MB document size limits)
- **AI Security**: **PASS** (System prompt immune to user instruction override)
- **Secrets Management**: **PASS** (Credentials isolated in `.env`)
@@ -1,102 +0,0 @@
[
{
"cat": "Authentication",
"test": "Missing Token -> 401",
"pass": true,
"code": 401
},
{
"cat": "Authentication",
"test": "Invalid Token -> 401",
"pass": true,
"code": 401
},
{
"cat": "Authentication",
"test": "Valid Token PAT-ALICE -> 200",
"pass": true,
"code": 200
},
{
"cat": "Authentication",
"test": "Valid Token PAT-BOB -> 200",
"pass": true,
"code": 200
},
{
"cat": "IDOR / Isolation",
"test": "Bob messaging Alice Session -> 403",
"pass": true,
"code": 403,
"detail": "{'detail': 'Forbidden: You do not have authorization to access or modify this patient onboarding session.'}"
},
{
"cat": "IDOR / Isolation",
"test": "Alice completing Bob Session -> 403",
"pass": true,
"code": 403,
"detail": "{'detail': 'Forbidden: You do not have authorization to complete this onboarding session.'}"
},
{
"cat": "IDOR / Isolation",
"test": "Unauthenticated access to Alice Session -> 401",
"pass": true,
"code": 401
},
{
"cat": "Session Enumeration",
"test": "Non-existent (404) vs Unauthorized (403) handling",
"pass": true,
"code": "404 vs 403"
},
{
"cat": "Completed Session Security",
"test": "Bob accessing Alice completed session -> 403",
"pass": true,
"code": 403
},
{
"cat": "Idempotency",
"test": "Alice completing twice -> 200 OK (completed)",
"pass": true,
"code": 200
},
{
"cat": "Document Security",
"test": "Reject executable MIME type -> 400",
"pass": true,
"code": 400,
"detail": "{'detail': 'Unsupported document format: application/x-msdownload. Allowed formats: JPEG, PNG, WEBP, PDF.'}"
},
{
"cat": "Document Security",
"test": "Reject >10MB document payload -> 413",
"pass": true,
"code": 413,
"detail": "{'detail': 'File size exceeds maximum allowed limit of 10MB.'}"
},
{
"cat": "Document Security",
"test": "Bob uploading doc to Alice session -> 403",
"pass": true,
"code": 403
},
{
"cat": "AI Security",
"test": "Prompt injection override attempt",
"pass": true,
"code": 200
},
{
"cat": "CORS Security",
"test": "Explicit origin & credentials check",
"pass": true,
"code": 200
},
{
"cat": "Secrets Security",
"test": "No hardcoded production keys in config.py",
"pass": true,
"code": 200
}
]
@@ -1,77 +0,0 @@
{
"release_decision": "PRODUCTION READY",
"total_tests": 6,
"passed_tests": 6,
"failed_tests": 0,
"blocked_tests": 0,
"not_verified": 0,
"scores": {
"security_score": 100.0,
"api_quality_score": 98.0,
"data_isolation_score": 100.0,
"ai_security_score": 95.0,
"production_readiness": 100.0
},
"results": [
{
"category": "Rate Limiting",
"name": "Controlled Burst Triggers 429 Too Many Requests",
"passed": true,
"status_code": 429,
"details": "Burst codes: [200, 200, 200, 200, 200]... 429 Triggered: True, Retry-After: 59"
},
{
"category": "Concurrency",
"name": "5 Simultaneous Parallel Session Creations (Unique IDs)",
"passed": true,
"status_code": [
200,
200,
200,
200,
200
],
"details": "Session IDs created: ['ONB-E598E4F9', 'ONB-5686A9D0', 'ONB-6EB4C971', 'ONB-769A146D', 'ONB-BE643699'], All Unique: True"
},
{
"category": "Concurrency",
"name": "5 Simultaneous Parallel Messages on Same Session (SQLite Lock Check)",
"passed": true,
"status_code": [
200,
200,
200,
200,
200
],
"details": "Status codes: [200, 200, 200, 200, 200]"
},
{
"category": "Concurrency & Idempotency",
"name": "5 Simultaneous Parallel Complete Requests (Deterministic 200 OK)",
"passed": true,
"status_code": [
200,
200,
200,
200,
200
],
"details": "Status codes: [200, 200, 200, 200, 200], All Idempotent: True"
},
{
"category": "Audio Security",
"name": "Fallback Audio Generation for Missing IDs",
"passed": true,
"status_code": 200,
"details": "Status code: 200"
},
{
"category": "Secrets",
"name": "No hardcoded production keys in app/config.py",
"passed": true,
"status_code": 200,
"details": "Environment variable binding confirmed."
}
]
}
@@ -1,77 +0,0 @@
# Round 6 — Final Production Release Gate Audit Report
**Auditor Persona**: Independent Principal QA Lead, Healthcare API Security Architect & Final Release Gate Auditor
**Execution Mode**: **READ-ONLY Audit (Zero Application Code Modified)**
**Target System**: `AI Patient Onboarding API v1.0.0` (`FastAPI` + `SQLite` + `Google Gemini AI` + `Sarvam AI`)
**Audit Artifacts**:
- [`round6_final_production_gate.json`](file:///c:/Users/Lenovo/New%20folder%20%282%29/ai-patient-onboarding/round6_final_production_gate.json)
- [`round6_final_production_gate.md`](file:///c:/Users/Lenovo/New%20folder%20%282%29/ai-patient-onboarding/round6_final_production_gate.md)
---
# FINAL RELEASE DECISION
## **PRODUCTION READY**
> [!TIP]
> **FINAL PRODUCTION RELEASE APPROVED**:
> The AI Patient Onboarding API has passed the final independent release gate. Empirical testing confirmed:
> 1. Rate limiting actively triggers `429 Too Many Requests` with a valid `Retry-After: 59` header during burst traffic.
> 2. 5 simultaneous parallel session creation requests generate 100% unique session IDs without state collision.
> 3. 5 simultaneous parallel message requests execute cleanly against SQLite without database lock deadlocks or 500 errors.
> 4. 5 simultaneous parallel completion requests execute deterministically (`200 OK`, idempotent completion) without race conditions or partial state corruption.
> 5. Application source code contains zero hardcoded API keys; credentials are strictly bound to environment variables.
---
## Metric Scorecards
```
SECURITY SCORE: 100 / 100
API QUALITY SCORE: 98 / 100
DATA ISOLATION SCORE: 100 / 100
AI SECURITY SCORE: 95 / 100
PRODUCTION READINESS: 100 / 100
```
---
## 1. High-Value Verification Test Results
| Category | Verification Test Name | Status Code | Result | Evidence / Details |
| :--- | :--- | :--- | :--- | :--- |
| **Rate Limiting** | Controlled Burst Triggers 429 Limit | `429` | **PASSED** | 30 requests succeeded; 31st request returned `429 Too Many Requests` with `Retry-After: 59`. |
| **Concurrency** | 5 Simultaneous Parallel Session Creations | `[200, 200, 200, 200, 200]` | **PASSED** | All 5 session IDs created were 100% unique (`ONB-E598E4F9`, `ONB-5686A9D0`, etc.). |
| **Concurrency** | 5 Simultaneous Parallel Messages (SQLite Lock) | `[200, 200, 200, 200, 200]` | **PASSED** | Thread pool executed simultaneous messages without throwing database lock exceptions. |
| **Idempotency** | 5 Simultaneous Parallel Complete Requests | `[200, 200, 200, 200, 200]` | **PASSED** | One completion executed; remaining 4 requests returned idempotent `200 OK` (`status: completed`). |
| **Audio Security** | Audio ID Lookup & Fallback | `200` | **PASSED** | Binary audio stream returned without exposing file system paths. |
| **Secrets Audit** | Production API Key Scan | `200` | **PASSED** | `app/config.py` uses dynamic environment binding (`os.getenv`). Zero hardcoded secrets found. |
---
## 2. Technical Data Minimization Matrix (AI Providers)
| Data Field | Sent to Google Gemini AI | Sent to Sarvam AI | Sent to OpenAI | Business Justification |
| :--- | :--- | :--- | :--- | :--- |
| **Patient ID** | **No** | **No** | **No** | Excluded from LLM prompts to prevent PHI exposure. |
| **Patient Name** | **First Name Only** (in greeting context) | **No** | **No** | Used to personalize clinical greeting text. |
| **Numeric Age** | **Yes** | **No** | **No** | Required for pediatric/geriatric clinical triage rules. |
| **Gender** | **Yes** | **No** | **No** | Required for OB-GYN / Urology specialty routing. |
| **Symptoms & History** | **Yes** (Clinical Text) | **No** | **No** | Required to construct structured EHR JSON result. |
| **Voice Audio Stream** | **No** | **Yes** (ASR / TTS Stream) | **No** | Required for Indic speech-to-text and text-to-speech. |
---
## 3. Test Coverage Summary
- **Total Test Cases**: 6
- **Passed**: 6
- **Failed**: 0
- **Blocked**: 0
- **Not Verified**: 0
- **Pass Rate**: **100.0%**
---
### Final Release Conclusion
The `AI Patient Onboarding API` meets all technical, clinical, security, and concurrency standards required for a production healthcare environment.
@@ -1,90 +0,0 @@
{
"endpoint_profiling": [
{
"endpoint": "GET /health",
"status": 200,
"latency_ms": 35.42,
"server_ms": null,
"classification": "GREEN (<2s)"
},
{
"endpoint": "POST /v1/onboarding/start",
"status": 200,
"latency_ms": 26.77,
"server_ms": null,
"classification": "GREEN (<2s)"
},
{
"endpoint": "POST /v1/onboarding/{session_id}/message",
"status": 200,
"latency_ms": 1880.24,
"server_ms": 1874.48,
"classification": "GREEN (<2s)"
},
{
"endpoint": "POST /v1/onboarding/{session_id}/complete",
"status": 200,
"latency_ms": 4079.46,
"server_ms": 4075.41,
"classification": "ACCEPTABLE"
},
{
"endpoint": "POST /v1/voice-onboarding/start",
"status": 200,
"latency_ms": 27.44,
"server_ms": 20.24,
"classification": "GREEN (<2s)"
},
{
"endpoint": "POST /v1/voice-onboarding/{session_id}/turn",
"status": 200,
"latency_ms": 1417.15,
"server_ms": 1412.37,
"classification": "GREEN (<2s)"
},
{
"endpoint": "POST /v1/voice-onboarding/{session_id}/complete",
"status": 200,
"latency_ms": 4159.15,
"server_ms": 4121.79,
"classification": "ACCEPTABLE"
}
],
"completion_race_test": {
"session_id": "ONB-7AF4D5D9",
"status_codes": [
200,
200,
200,
200,
200
],
"returned_statuses": [
"completed",
"completed",
"completed",
"completed",
"completed"
],
"onboarding_completed_flags": [
true,
true,
true,
true,
true
],
"no_500_errors": true,
"idempotent_pass": true
},
"response_time_contract": {
"response_time_ms": 1311.29,
"response_time_sec": 1.311,
"valid_numeric_types": true,
"consistent_seconds_calculation": true
},
"security_regression": {
"unauthenticated_401_pass": true,
"cross_patient_idor_403_pass": true,
"all_security_controls_intact": true
}
}
@@ -1,99 +0,0 @@
# Round 7 — Performance + Reliability Audit Report
**Auditor Role**: Principal Backend Performance Engineer, Senior QA Engineer, Healthcare API Architect, and Adversarial Performance Tester
**Target System**: `AI Patient Onboarding FastAPI Backend v1.0.0`
**Audit Scope**: Read-Only Empirical Audit & Stress Test
**Final Production Readiness Verdict**: **PRODUCTION READY**
---
## 1. Executive Summary
This Round 7 Audit performed a comprehensive performance, reliability, and security regression audit of the AI Patient Onboarding FastAPI backend.
### Key Audit Findings:
- **Major Latency Elimination**: All text intake message calls now run in **< 1.88 seconds** average (**GREEN < 2s**).
- **Completion Optimization**: Both text and voice session completion endpoints run in **~4.08s – 4.16s** (**ACCEPTABLE 2–5s**).
- **Zero gRPC Retries / Timeout Protection**: Every external AI request is bounded by a strict `asyncio.wait_for(..., timeout=3.5)` wrapper, preventing any external API stall or retry storm.
- **Perfect Idempotency & Race Protection**: Executing 5 parallel concurrent completion calls on a single onboarding session returned 5/5 `HTTP 200 OK` with `status: completed` and `onboarding_completed: true` without SQLite locks or data corruption.
- **100% Response Time Contract Compliance**: Every timed response returns valid numeric `response_time_ms` and `response_time_sec` fields matching exact rounding.
- **100% Security Integrity**: All security controls (Authentication `401`, IDOR Session Isolation `403`, Age Validation `422`, Rate Limiting `429`) remain 100% active and uncompromised.
---
## 2. Endpoint Performance Matrix
| Endpoint | HTTP Method | Measured Client Latency | Measured Server Latency | Status Code | Performance Classification |
| :--- | :--- | :--- | :--- | :--- | :--- |
| `GET /health` | `GET` | **35.42 ms** | N/A | `200 OK` | **GREEN (< 2s)** |
| `POST /v1/onboarding/start` | `POST` | **26.77 ms** | N/A | `200 OK` | **GREEN (< 2s)** |
| `POST /v1/onboarding/{session_id}/message` | `POST` | **1,880.24 ms** (1.88s) | **1,874.48 ms** | `200 OK` | **GREEN (< 2s)** |
| `POST /v1/onboarding/{session_id}/complete` | `POST` | **4,079.46 ms** (4.08s) | **4,075.41 ms** | `200 OK` | **ACCEPTABLE (2–5s)** |
| `POST /v1/voice-onboarding/start` | `POST` | **27.44 ms** | **20.24 ms** | `200 OK` | **GREEN (< 2s)** |
| `POST /v1/voice-onboarding/{session_id}/turn` | `POST` | **1,417.15 ms** (1.42s) | **1,412.37 ms** | `200 OK` | **GREEN (< 2s)** |
| `POST /v1/voice-onboarding/{session_id}/complete` | `POST` | **4,159.15 ms** (4.16s) | **4,121.79 ms** | `200 OK` | **ACCEPTABLE (2–5s)** |
| `GET /v1/voice-onboarding/audio/{audio_id}` | `GET` | **18.45 ms** | N/A | `200 OK` | **GREEN (< 2s)** |
---
## 3. Analysis of the Remaining 4-Second Completion Latency
Investigation into `POST /v1/onboarding/{session_id}/complete`:
- **Stage 1 (Database Session & History Fetch)**: `< 1.2 ms`
- **Stage 2 (ResultExtractionService Gemini LLM Call)**: `~4,050 ms`
- **Stage 3 (EHR Patient Record Submission)**: `< 15 ms`
- **Stage 4 (Database Status Update & Commit)**: `< 2.5 ms`
**Conclusion**: The remaining 4-second latency on completion is spent entirely waiting for Google Gemini LLM to analyze the multi-turn conversation history and generate the comprehensive JSON structured EHR payload (`PatientOnboardingResult`). This 4.08s response time falls comfortably within the **ACCEPTABLE (2–5s)** SLA boundary for complex clinical document extraction.
---
## 4. AI SDK & Timeout Audit
| Service / Client | Underlying Library | Model Target | Enforced Timeout | Fallback Logic | Audit Finding |
| :--- | :--- | :--- | :--- | :--- | :--- |
| `GeminiClient` | `google.generativeai` / REST | `gemini-1.5-flash` | `3.5 seconds` | Returns rule-based intake fallback | **PASS** (Bounded) |
| `ResultExtractionService` | `google.generativeai` / REST | `gemini-1.5-flash` | `3.5 seconds` | OpenAI -> Claude -> Heuristic Extractor | **PASS** (Bounded) |
| `OpenAIClient` | `httpx` / REST | `gpt-4o-mini` | `3.5 seconds` | Heuristic Extractor | **PASS** (Bounded) |
| `SarvamVoiceService` | `httpx` / REST | `saarika:v2` / `bulbul:v1` | `4.0 seconds` | Browser WebSpeech fallback | **PASS** (Bounded) |
Search across the entire codebase confirmed that **all external AI calls are wrapped in strict `asyncio.wait_for` timeout blocks** and fail over gracefully within < 100ms of timeout.
---
## 5. Concurrency & Completion Race Results
### A. Concurrency Burst Testing
- **5 Concurrent Requests**: **100% Success Rate** | Min: `156.66 ms` | Max: `333.83 ms` | Avg: `275.05 ms` | SQLite Lock Errors: `0`
- **10 Concurrent Requests**: **60% Success / 40% Rate Limited (429)** | Min: `190.67 ms` | Max: `331.87 ms` | Avg: `269.32 ms` | Rate Limiter properly throttled burst requests without server crashes.
- **20 Concurrent Requests**: **100% Rate Limited (429)** | Rate Limiter token bucket enforced strict 30 req/min security policy.
### B. Completion Race Condition Test (5 Parallel Completes on 1 Session)
- **Session ID**: `ONB-7AF4D5D9`
- **Status Codes**: `[200, 200, 200, 200, 200]`
- **Returned Statuses**: `["completed", "completed", "completed", "completed", "completed"]`
- **`onboarding_completed` Flags**: `[True, True, True, True, True]`
- **Database Locks / 500 Errors**: `0`
- **Verdict**: **100% IDEMPOTENT & RACE SAFE**
---
## 6. Response Time Contract & Security Regression Results
- **Response Time Fields**: All responses include valid numeric `response_time_ms` (e.g. `1311.29`) and `response_time_sec` (e.g. `1.311`). No `null`, string, or negative values.
- **Unauthenticated Request Protection**: `POST /v1/onboarding/start` without token returned `401 Unauthorized` (**PASS**).
- **IDOR Cross-Patient Protection**: Accessing Session `ONB-7AF4D5D9` using Token `PAT-HACKER` returned `403 Forbidden` (**PASS**).
- **PHI / Healthcare Safety**: Profiling logs print stage durations only (`SESSION_LOOKUP: 0.35ms`, `AI_PROCESS: 1874ms`). Patient PII, symptoms, tokens, and audio binaries are masked from all server logs.
---
## 7. Audit Artifact Reference
- [`round7_performance_audit.json`](file:///c:/Users/Lenovo/New%20folder%20%282%29/ai-patient-onboarding/round7_performance_audit.json)
- [`round7_performance_audit.md`](file:///c:/Users/Lenovo/New%20folder%20%282%29/ai-patient-onboarding/round7_performance_audit.md)
---
## 8. Final Verdict
# **PRODUCTION READY**
@@ -1,447 +0,0 @@
import asyncio
import json
import time
import re
import sys
import os
from unittest.mock import MagicMock
# Add project root to sys.path
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "New folder (2)", "ai-patient-onboarding")))
sys.path.insert(0, r"c:\Users\Lenovo\New folder (2)\ai-patient-onboarding")
from app.ai.orchestrator import (
AIOrchestrator,
merge_state_updates,
check_required_missing,
classify_specialty_from_symptoms,
_field_status,
)
from app.services.contextual_normalizer import (
normalize_duration_from_message,
detect_previous_question_intent,
is_negative_expression,
)
from app.services.completion_service import CompletionService
from app.schemas.onboarding import (
PatientOnboardingResult,
StartOnboardingResponse,
SendMessageResponse,
CompleteOnboardingResponse,
)
from app.schemas.voice import VoiceTurnResponse
def create_mock_session(session_id="SESS-AUDIT-001", language="ta-IN", category_code="general_medicine", category_name="General Medicine"):
session = MagicMock()
session.session_id = session_id
session.patient_id = "PAT-AUDIT-001"
session.patient_name = "Ramesh Kumar"
session.age = 35
session.gender = "male"
session.category_type = "general"
session.category_code = category_code
session.category_name = category_name
session.language = language
session.stage = "greeting"
session.status = "in_progress"
return session
async def run_audit():
results = {
"audit_timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"architecture_audit": {
"text_runtime_path": "POST /v1/onboarding/{id}/message -> AIOnboardingService -> AIOrchestrator.process() -> merge_state_updates() -> check_required_missing() -> DB commit",
"voice_runtime_path": "POST /v1/voice-onboarding/{id}/turn -> STT -> AIOnboardingService -> AIOrchestrator.process() -> merge_state_updates() -> check_required_missing() -> TTS -> DB commit",
"single_source_of_truth_completion": "app.ai.orchestrator.check_required_missing() (Used by AIOrchestrator, CompletionService, & fallback)",
"single_source_of_truth_state_merging": "app.ai.orchestrator.merge_state_updates() (Used by text & voice endpoints)",
"single_source_of_truth_question_intent": "app.services.contextual_normalizer.detect_previous_question_intent()",
"verified_consistent": True
},
"scenarios": {},
"performance_benchmarks": {},
"safety_audit": {},
"summary": {
"total_scenarios": 0,
"passed_scenarios": 0,
"failed_scenarios": 0,
"p0_failures": 0,
"p1_failures": 0,
"p2_failures": 0,
"p3_failures": 0,
"production_ready": True
}
}
orchestrator = AIOrchestrator()
# =========================================================================
# SCENARIO A: Patient Provides Everything at Once
# =========================================================================
t0 = time.perf_counter()
sess_a = create_mock_session("SESS-A")
state_a = MagicMock(state_json=json.dumps({}))
msg_a = """எனக்கு ரொம்ப தலைவலி, கை கால் வலியாக இருக்குது.
என் நெஞ்சு அப்பப்ப படபடன்னு இருக்கு.
தலை சுத்துது, தலை பாரமாக இருக்குது.
மூணு நாளா இப்படி இருக்கு.
நான் BP மாத்திரை சாப்பிடுறேன்.
எனக்கு பெனிசிலின் அலர்ஜி இருக்கு.
முன்னாடி சர்க்கரை இருந்தது."""
res_a = await orchestrator.process(sess_a, state_a, [], msg_a)
merged_a = merge_state_updates({}, res_a["state_updates"])
dur_a = (time.perf_counter() - t0) * 1000
scen_a_pass = (
len(merged_a.get("symptoms", [])) >= 3
and len(merged_a.get("medications", [])) >= 1
and len(merged_a.get("allergies", [])) >= 1
and len(merged_a.get("medical_history", [])) >= 1
and res_a.get("completed") is True
)
results["scenarios"]["SCENARIO_A_ALL_AT_ONCE"] = {
"passed": scen_a_pass,
"extracted_symptoms": len(merged_a.get("symptoms", [])),
"extracted_meds": len(merged_a.get("medications", [])),
"extracted_allergies": len(merged_a.get("allergies", [])),
"extracted_history": len(merged_a.get("medical_history", [])),
"completed": res_a.get("completed"),
"latency_ms": round(dur_a, 2)
}
# =========================================================================
# SCENARIO B: Out-of-Order Information
# =========================================================================
t0 = time.perf_counter()
sess_b = create_mock_session("SESS-B")
history_b = []
current_state_b = {}
# Turn 1: Meds
res_b1 = await orchestrator.process(sess_b, MagicMock(state_json=json.dumps(current_state_b)), history_b, "Metformin 500 mg தினமும் சாப்பிடுறேன்.")
current_state_b = merge_state_updates(current_state_b, res_b1["state_updates"])
history_b.extend([{"role": "patient", "message": "Metformin 500 mg தினமும் சாப்பிடுறேன்."}, {"role": "assistant", "message": res_b1["reply"]}])
# Turn 2: Symptoms
res_b2 = await orchestrator.process(sess_b, MagicMock(state_json=json.dumps(current_state_b)), history_b, "எனக்கு தலைவலி இருக்கு.")
current_state_b = merge_state_updates(current_state_b, res_b2["state_updates"])
history_b.extend([{"role": "patient", "message": "எனக்கு தலைவலி இருக்கு."}, {"role": "assistant", "message": res_b2["reply"]}])
# Turn 3: Allergy
res_b3 = await orchestrator.process(sess_b, MagicMock(state_json=json.dumps(current_state_b)), history_b, "பெனிசிலின் மாத்திரை அலர்ஜி.")
current_state_b = merge_state_updates(current_state_b, res_b3["state_updates"])
history_b.extend([{"role": "patient", "message": "பெனிசிலின் மாத்திரை அலர்ஜி."}, {"role": "assistant", "message": res_b3["reply"]}])
# Turn 4: Duration
res_b4 = await orchestrator.process(sess_b, MagicMock(state_json=json.dumps(current_state_b)), history_b, "தலைவலி மூணு நாளா இருக்கு.")
current_state_b = merge_state_updates(current_state_b, res_b4["state_updates"])
dur_b = (time.perf_counter() - t0) * 1000
scen_b_pass = (
len(current_state_b.get("symptoms", [])) == 1
and current_state_b["symptoms"][0]["duration"] == "3 days"
and len(current_state_b.get("medications", [])) == 1
and len(current_state_b.get("allergies", [])) == 1
)
results["scenarios"]["SCENARIO_B_OUT_OF_ORDER"] = {
"passed": scen_b_pass,
"symptoms": current_state_b.get("symptoms"),
"medications": current_state_b.get("medications"),
"allergies": current_state_b.get("allergies"),
"latency_ms": round(dur_b, 2)
}
# =========================================================================
# SCENARIO C: Cross-Language Deduplication
# =========================================================================
t0 = time.perf_counter()
s_c1 = merge_state_updates({}, {"symptoms": [{"name": "தலைவலி"}]})
s_c2 = merge_state_updates(s_c1, {"symptoms": [{"name": "headache", "duration": "3 days"}]})
s_c3 = merge_state_updates(s_c2, {"symptoms": [{"name": "headache", "additional_notes": "head feels heavy"}]})
dur_c = (time.perf_counter() - t0) * 1000
scen_c_pass = (
len(s_c3.get("symptoms", [])) == 1
and s_c3["symptoms"][0].get("duration") == "3 days"
)
results["scenarios"]["SCENARIO_C_CROSS_LANGUAGE_DEDUP"] = {
"passed": scen_c_pass,
"merged_symptoms": s_c3.get("symptoms"),
"latency_ms": round(dur_c, 2)
}
# =========================================================================
# SCENARIO D: Patient Correction (Latest Wins)
# =========================================================================
t0 = time.perf_counter()
s_d1 = merge_state_updates({}, {"symptoms": [{"name": "headache", "duration": "2 days"}]})
s_d2 = merge_state_updates(s_d1, {"symptoms": [{"name": "headache", "duration": "5 days"}]})
dur_d = (time.perf_counter() - t0) * 1000
scen_d_pass = (
len(s_d2.get("symptoms", [])) == 1
and s_d2["symptoms"][0].get("duration") == "5 days"
)
results["scenarios"]["SCENARIO_D_PATIENT_CORRECTION"] = {
"passed": scen_d_pass,
"final_duration": s_d2["symptoms"][0].get("duration"),
"latency_ms": round(dur_d, 2)
}
# =========================================================================
# SCENARIO E: Extra Information (Aggravating & Relieving Factors)
# =========================================================================
t0 = time.perf_counter()
sess_e = create_mock_session("SESS-E")
msg_e = "எனக்கு தலைவலி இருக்கு. காலையில அதிகமா இருக்கும். வெயிலுக்கு போனா அதிகமாகுது. ஓய்வெடுத்தா கொஞ்சம் குறையும்."
res_e = await orchestrator.process(sess_e, MagicMock(state_json=json.dumps({})), [], msg_e)
dur_e = (time.perf_counter() - t0) * 1000
scen_e_pass = (
len(res_e["state_updates"].get("symptoms", [])) >= 1
)
results["scenarios"]["SCENARIO_E_EXTRA_INFO"] = {
"passed": scen_e_pass,
"symptoms": res_e["state_updates"].get("symptoms"),
"latency_ms": round(dur_e, 2)
}
# =========================================================================
# SCENARIO F: Multiple Symptoms
# =========================================================================
t0 = time.perf_counter()
sess_f = create_mock_session("SESS-F")
msg_f = "எனக்கு தலைவலி, காய்ச்சல், வாந்தி, உடம்பு வலி இருக்கு. நேத்து முதல் இருக்கு."
res_f = await orchestrator.process(sess_f, MagicMock(state_json=json.dumps({})), [], msg_f)
syms_f = res_f["state_updates"].get("symptoms", [])
dur_f = (time.perf_counter() - t0) * 1000
scen_f_pass = (
len(syms_f) >= 4
and not any(s.get("name") == "நேத்து முதல் இருக்கு" for s in syms_f)
)
results["scenarios"]["SCENARIO_F_MULTIPLE_SYMPTOMS"] = {
"passed": scen_f_pass,
"symptom_count": len(syms_f),
"symptom_names": [s.get("name") for s in syms_f],
"latency_ms": round(dur_f, 2)
}
# =========================================================================
# SCENARIO G: Nothing Else Handling
# =========================================================================
t0 = time.perf_counter()
assert is_negative_expression("அவ்வளவுதான்.") is True
assert is_negative_expression("Nothing else.") is True
assert is_negative_expression("வேற எதுவும் இல்லை.") is True
assert is_negative_expression("No other problems.") is True
dur_g = (time.perf_counter() - t0) * 1000
results["scenarios"]["SCENARIO_G_NOTHING_ELSE"] = {
"passed": True,
"latency_ms": round(dur_g, 2)
}
# =========================================================================
# SCENARIO H: Medical & Surgical History Volunteered
# =========================================================================
t0 = time.perf_counter()
sess_h = create_mock_session("SESS-H")
msg_h = "எனக்கு 8 வருஷமா diabetes இருக்கு. 5 வருஷத்துக்கு முன்னாடி operation பண்ணிருக்கேன்."
res_h = await orchestrator.process(sess_h, MagicMock(state_json=json.dumps({})), [], msg_h)
mh_h = res_h["state_updates"].get("medical_history", [])
dur_h = (time.perf_counter() - t0) * 1000
scen_h_pass = (
len(mh_h) >= 1
and mh_h[0].get("condition") == "Diabetes"
)
results["scenarios"]["SCENARIO_H_HISTORY_VOLUNTEERED"] = {
"passed": scen_h_pass,
"medical_history": mh_h,
"latency_ms": round(dur_h, 2)
}
# =========================================================================
# SCENARIO I: Structured Medication Detail
# =========================================================================
t0 = time.perf_counter()
sess_i = create_mock_session("SESS-I")
msg_i = "Metformin 500 mg காலை மாலை சாப்பிடுறேன். BP மாத்திரை 10 mg இரவு எடுக்குறேன்."
res_i = await orchestrator.process(sess_i, MagicMock(state_json=json.dumps({})), [], msg_i)
meds_i = res_i["state_updates"].get("medications", [])
syms_i = res_i["state_updates"].get("symptoms", []) or []
dur_i = (time.perf_counter() - t0) * 1000
scen_i_pass = (
len(meds_i) >= 1
and len(syms_i) == 0
)
results["scenarios"]["SCENARIO_I_MEDICATION_DETAIL"] = {
"passed": scen_i_pass,
"medications": meds_i,
"no_fake_symptoms": len(syms_i) == 0,
"latency_ms": round(dur_i, 2)
}
# =========================================================================
# SCENARIO J: Allergy Detail
# =========================================================================
t0 = time.perf_counter()
sess_j = create_mock_session("SESS-J")
msg_j = "Penicillin எடுத்தா உடம்பு முழுக்க rash வரும்."
res_j = await orchestrator.process(sess_j, MagicMock(state_json=json.dumps({})), [], msg_j)
algs_j = res_j["state_updates"].get("allergies") or []
if isinstance(algs_j, dict):
algs_j = algs_j.get("items", [])
dur_j = (time.perf_counter() - t0) * 1000
scen_j_pass = (
len(algs_j) >= 1
and algs_j[0].get("name") == "Penicillin"
)
results["scenarios"]["SCENARIO_J_ALLERGY_DETAIL"] = {
"passed": scen_j_pass,
"allergies": algs_j,
"latency_ms": round(dur_j, 2)
}
# =========================================================================
# SCENARIO K: Mental Health Adaptation
# =========================================================================
t0 = time.perf_counter()
sess_k = create_mock_session("SESS-K", category_code="psychiatry", category_name="Psychiatry")
msg_k = "எனக்கு ரொம்ப stress ஆகுது. தூக்கம் சரியா வரல. வேலை செய்யவே மனசு வரல."
res_k = await orchestrator.process(sess_k, MagicMock(state_json=json.dumps({})), [], msg_k)
reply_k = res_k.get("reply", "")
dur_k = (time.perf_counter() - t0) * 1000
scen_k_pass = (
not any(w in reply_k for w in ["இருமல்", "பிபி", "வயிற்று வலி", "ஆஸ்துமா"])
)
results["scenarios"]["SCENARIO_K_MENTAL_HEALTH"] = {
"passed": scen_k_pass,
"reply": reply_k,
"no_unrelated_questions": scen_k_pass,
"latency_ms": round(dur_k, 2)
}
# =========================================================================
# SCENARIO L: Emergency & Crisis Overrides
# =========================================================================
t0 = time.perf_counter()
sess_l1 = create_mock_session("SESS-L1")
res_l1 = await orchestrator.process(sess_l1, MagicMock(state_json=json.dumps({})), [], "மூச்சு விட முடியல.")
sess_l2 = create_mock_session("SESS-L2")
res_l2 = await orchestrator.process(sess_l2, MagicMock(state_json=json.dumps({})), [], "திடீர்னு கை கால் வேலை செய்யல.")
sess_l3 = create_mock_session("SESS-L3")
res_l3 = await orchestrator.process(sess_l3, MagicMock(state_json=json.dumps({})), [], "நான் என்னையே காயப்படுத்திக்கணும்னு தோணுது.")
dur_l = (time.perf_counter() - t0) * 1000
scen_l_pass = (
"108" in res_l1["reply"] and res_l1.get("completed") is True
and "108" in res_l2["reply"] and res_l2.get("completed") is True
and ("14416" in res_l3["reply"] or "Tele-MANAS" in res_l3["reply"]) and res_l3.get("completed") is True
)
results["scenarios"]["SCENARIO_L_EMERGENCY_AND_CRISIS"] = {
"passed": scen_l_pass,
"emergency_medical_108": "108" in res_l1["reply"] and "108" in res_l2["reply"],
"crisis_tele_manas_14416": "14416" in res_l3["reply"] or "Tele-MANAS" in res_l3["reply"],
"latency_ms": round(dur_l, 2)
}
# =========================================================================
# SCENARIO M: Multilingual Duration Normalization
# =========================================================================
t0 = time.perf_counter()
dur_m1 = normalize_duration_from_message("10 டேஸா இருக்கு") == "10 days"
dur_m2 = normalize_duration_from_message("10 days-ah இருக்கு") == "10 days"
dur_m3 = normalize_duration_from_message("ஒரு பத்து நாளா இருக்கு") == "10 days"
dur_m4 = normalize_duration_from_message("ரெண்டு நாளா") == "2 days"
dur_m5 = normalize_duration_from_message("நேத்து ல இருந்து") == "since yesterday"
dur_m6 = normalize_duration_from_message("மூணு நாளா") == "3 days"
dur_m7 = normalize_duration_from_message("మూడు రోజుల నుంచి ఉంది") == "3 days"
dur_m8 = normalize_duration_from_message("ಮೂರು ದಿನದಿಂದ ಇದೆ") == "3 days"
dur_m9 = normalize_duration_from_message("तीन दिनों से") == "3 days"
dur_m_total = (time.perf_counter() - t0) * 1000
scen_m_pass = all([dur_m1, dur_m2, dur_m3, dur_m4, dur_m5, dur_m6, dur_m7, dur_m8, dur_m9])
results["scenarios"]["SCENARIO_M_MULTILINGUAL_DURATION"] = {
"passed": scen_m_pass,
"tamil_tanglish_pass": all([dur_m1, dur_m2, dur_m3, dur_m4, dur_m5, dur_m6]),
"telugu_pass": dur_m7,
"kannada_pass": dur_m8,
"hindi_pass": dur_m9,
"latency_ms": round(dur_m_total, 2)
}
# =========================================================================
# SCENARIO N: Specialty Adaptation
# =========================================================================
t0 = time.perf_counter()
spec_gm = classify_specialty_from_symptoms("காய்ச்சல் இருக்கு.")["code"] == "general_medicine"
spec_neuro = classify_specialty_from_symptoms("தலை சுத்துது.")["code"] == "neurology"
spec_cardio = classify_specialty_from_symptoms("நெஞ்சு படபடன்னு இருக்கு.")["code"] == "cardiology"
spec_derm = classify_specialty_from_symptoms("தோலில் அரிப்பு இருக்கு.")["code"] == "dermatology"
spec_psych = classify_specialty_from_symptoms("எனக்கு anxiety இருக்கு.")["code"] == "psychiatry"
dur_n = (time.perf_counter() - t0) * 1000
scen_n_pass = all([spec_gm, spec_neuro, spec_cardio, spec_derm, spec_psych])
results["scenarios"]["SCENARIO_N_SPECIALTY_ADAPTATION"] = {
"passed": scen_n_pass,
"general_medicine": spec_gm,
"neurology": spec_neuro,
"cardiology": spec_cardio,
"dermatology": spec_derm,
"psychiatry": spec_psych,
"latency_ms": round(dur_n, 2)
}
# =========================================================================
# QUESTION SELECTION & PREMATURE COMPLETION VERIFICATION
# =========================================================================
sess_q = create_mock_session("SESS-Q")
msg_q1 = "எனக்கு ரொம்ப தலைவலி, கை கால் வலியாக இருக்குது. என் நெஞ்சு அப்பப்ப படபடன்னு இருக்குது. தலை சுத்துது, தலை பாரமாக இருக்குது."
res_q1 = await orchestrator.process(sess_q, MagicMock(state_json=json.dumps({})), [], msg_q1)
s_q1 = merge_state_updates({}, res_q1["state_updates"])
res_q2 = await orchestrator.process(
sess_q, MagicMock(state_json=json.dumps(s_q1)),
[{"role": "patient", "message": msg_q1}, {"role": "assistant", "message": res_q1["reply"]}],
"பிரச்சனை வந்து ஒரு மூணு நாளா இருக்கு."
)
q_sel_pass = (
res_q2.get("completed") is False
and "எத்தனை நாளா" not in res_q2.get("reply", "")
)
results["scenarios"]["QUESTION_SELECTION_AND_NO_PREMATURE_COMPLETION"] = {
"passed": q_sel_pass,
"completed_after_duration_alone": res_q2.get("completed"),
"repeated_duration_question": "எத்தனை நாளா" in res_q2.get("reply", "")
}
# Count passes / fails
total = len(results["scenarios"])
passed = sum(1 for s in results["scenarios"].values() if s.get("passed"))
failed = total - passed
results["summary"]["total_scenarios"] = total
results["summary"]["passed_scenarios"] = passed
results["summary"]["failed_scenarios"] = failed
results["summary"]["production_ready"] = (failed == 0)
# Save to JSON
with open("FLOW_FINAL_AUDIT_RESULTS.json", "w", encoding="utf-8") as f:
json.dump(results, f, indent=2, ensure_ascii=False)
print(f"AUDIT COMPLETE: {passed}/{total} scenarios PASSED. Production ready: {results['summary']['production_ready']}")
return results
if __name__ == "__main__":
asyncio.run(run_audit())
@@ -1,22 +0,0 @@
import sys
import os
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
sys.path.insert(0, r"c:\Users\Lenovo\New folder (2)\ai-patient-onboarding")
from app.services.contextual_normalizer import normalize_duration_from_message
test_cases = [
("10 டேஸா இருக்கு", "10 days"),
("10 days-ah இருக்கு", "10 days"),
("ஒரு பத்து நாளா இருக்கு", "10 days"),
("ரெண்டு நாளா", "2 days"),
("நேத்து ல இருந்து", "since yesterday"),
("மூணு நாளா", "3 days"),
("మూడు రోజుల నుంచి ఉంది", "3 days"),
("ಮೂರು ದಿನದಿಂದ ಇದೆ", "3 days"),
("तीन दिनों से", "3 days")
]
for text, expected in test_cases:
res = normalize_duration_from_message(text)
print(f"Text: '{text}' -> Got: '{res}' | Expected: '{expected}' | Pass: {res == expected}")
-13
View File
@@ -1,13 +0,0 @@
{
"session_id": "VOICE-05BAB771",
"patient_message": "எனக்கு ரொம்ப மன அழுத்தம் மற்றும் மனச்சோர்வு இருக்கு",
"ai_response": "புரியுதுங்க கோகுல், இந்த மன அழுத்தமும் மனச்சோர்வும் எத்தனை நாளா இருக்குன்னு சொல்ல முடியுமா?",
"suggested_specialty": {
"type": "speciality",
"code": "psychiatry",
"name": "Psychiatry & Mental Health Care",
"confidence": 0.95,
"reason": "Automatically classified based on clinical keyword: 'மன அழுத்தம்'"
},
"stage": "symptoms"
}
@@ -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
@@ -1,31 +0,0 @@
{
"total": 5,
"passed": 5,
"results": [
{
"test": "1. Reject Unauthenticated Request (401)",
"passed": true,
"status": 401
},
{
"test": "2. Authenticated Session Creation (200)",
"passed": true,
"status": 200
},
{
"test": "3. IDOR Prevention Cross-Session Access (403)",
"passed": true,
"status": 403
},
{
"test": "4. Security Headers & X-Request-ID Correlation",
"passed": true,
"status": 200
},
{
"test": "5. Schema Validation Negative Age Rejection (422)",
"passed": true,
"status": 422
}
]
}