1 Commits
96 changed files with 1075 additions and 10259 deletions
-19
View File
@@ -1,19 +0,0 @@
.git
.gitignore
.env
.env.local
*.log
*.pyc
__pycache__
venv/
.venv/
scratch/
tests/
qa_audit_report.json
full_qa_security_audit_report.json
round3_verification_report.json
round4_production_release_report.*
round5_verification_report.json
round6_final_production_gate.*
temp_audio/*
temp_docs/*
+14 -35
View File
@@ -1,39 +1,18 @@
# Application Environment Configuration Example
APP_NAME="AI Patient Onboarding API"
APP_ENV="development" # Options: development, testing, production
APP_VERSION="1.0.0"
DEBUG="false"
LOG_LEVEL="INFO"
APP_NAME=AI Patient Onboarding
APP_ENV=development
APP_VERSION=1.0.0
# Server & Port
HOST="0.0.0.0"
PORT=8000
# 1. OpenAI API (Optional / Backup model)
OPENAI_API_KEY=your_openai_api_key_here
OPENAI_MODEL=gpt-4o-mini
# Security & Credentials
API_SECRET_KEY="your_secure_random_api_secret_key_here"
ALLOWED_ORIGINS="https://onboarding.skyheal.ai,http://localhost:5173,http://127.0.0.1:5173"
# 2. Claude AI / Anthropic API (Clinical entity extraction & fallback)
ANTHROPIC_API_KEY=your_anthropic_api_key_here
ANTHROPIC_MODEL=claude-sonnet-4-5-20250929
# Database Configuration
DATABASE_URL="sqlite:///./data/onboarding.db"
# 3. Google Gemini API (Primary high-speed Indic conversation model)
GEMINI_API_KEY=your_gemini_api_key_here
GEMINI_MODEL=gemini-3.6-flash
# AI Provider API Keys (Set in runtime environment or production secret manager)
GEMINI_API_KEY=""
GEMINI_MODEL="gemini-3.6-flash"
SARVAM_API_KEY=""
OPENAI_API_KEY=""
OPENAI_MODEL="gpt-4o-mini"
ANTHROPIC_API_KEY=""
ANTHROPIC_MODEL="claude-sonnet-4-5-20250929"
# Limits & Safeguards
RATE_LIMIT=30
MAX_UPLOAD_SIZE=10485760 # 10MB in Bytes
AI_TIMEOUT=30 # seconds
SESSION_TIMEOUT_MINUTES=30
# Destination Hospital EHR / Patient API
PATIENT_API_BASE_URL=""
PATIENT_API_TOKEN=""
# 4. Sarvam AI API (Indic speech recognition ASR & native TTS — Tamil, Hindi, Telugu, Kannada)
SARVAM_API_KEY=your_sarvam_api_key_here
@@ -1,147 +0,0 @@
{
"id": "e67b931a-4c8d-4f11-9a25-postman-env-001",
"name": "AI-Patient-Onboarding-Local",
"values": [
{
"key": "base_url",
"value": "http://127.0.0.1:8001",
"type": "default",
"enabled": true
},
{
"key": "auth_token",
"value": "test_token_PAT-POSTMAN-001",
"type": "default",
"enabled": true
},
{
"key": "api_key",
"value": "skyheal_secure_api_key_2026",
"type": "default",
"enabled": true
},
{
"key": "patient_id",
"value": "PAT-POSTMAN-001",
"type": "default",
"enabled": true
},
{
"key": "patient_name",
"value": "Kavitha Ramesh",
"type": "default",
"enabled": true
},
{
"key": "patient_age",
"value": "35",
"type": "default",
"enabled": true
},
{
"key": "patient_gender",
"value": "female",
"type": "default",
"enabled": true
},
{
"key": "category_type",
"value": "general",
"type": "default",
"enabled": true
},
{
"key": "category_code",
"value": "general_medicine",
"type": "default",
"enabled": true
},
{
"key": "category_name",
"value": "General Medicine / Primary Care",
"type": "default",
"enabled": true
},
{
"key": "language",
"value": "en-IN",
"type": "default",
"enabled": true
},
{
"key": "session_id",
"value": "",
"type": "any",
"enabled": true
},
{
"key": "voice_session_id",
"value": "",
"type": "any",
"enabled": true
},
{
"key": "audio_id",
"value": "",
"type": "any",
"enabled": true
},
{
"key": "request_id",
"value": "",
"type": "any",
"enabled": true
},
{
"key": "health_max_ms",
"value": "1000",
"type": "default",
"enabled": true
},
{
"key": "text_start_max_ms",
"value": "5000",
"type": "default",
"enabled": true
},
{
"key": "text_message_max_ms",
"value": "10000",
"type": "default",
"enabled": true
},
{
"key": "text_complete_max_ms",
"value": "10000",
"type": "default",
"enabled": true
},
{
"key": "voice_start_max_ms",
"value": "10000",
"type": "default",
"enabled": true
},
{
"key": "voice_turn_max_ms",
"value": "30000",
"type": "default",
"enabled": true
},
{
"key": "voice_complete_max_ms",
"value": "60000",
"type": "default",
"enabled": true
},
{
"key": "document_process_max_ms",
"value": "30000",
"type": "default",
"enabled": true
}
],
"_postman_variable_scope": "environment",
"_postman_exported_at": "2026-08-31T12:34:00.000Z",
"_postman_exported_using": "Postman/11.0.0"
}
@@ -1,401 +0,0 @@
{
"info": {
"_postman_id": "7b82f9d1-3e4a-4c28-b901-postman-coll-001",
"name": "AI Patient Onboarding API",
"description": "Production Postman Collection with Performance Testing, Latency Thresholds, Response Time Verification, Auth Security, and IDOR Checks.",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
},
"item": [
{
"name": "00 - Health",
"item": [
{
"name": "Get Service Health Status",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{base_url}}/health",
"host": [
"{{base_url}}"
],
"path": [
"health"
]
},
"description": "Checks operational health."
},
"event": [
{
"listen": "test",
"script": {
"exec": [
"pm.test('Status code is 200', function () { pm.response.to.have.status(200); });",
"\nconst clientMs = pm.response.responseTime;\nconst json = pm.response.json();\nconst maxAllowedMs = Number(pm.environment.get('text_complete_max_ms') || 10000);\n\npm.test(\"Response time is recorded\", function () {\n pm.expect(clientMs).to.be.a(\"number\");\n});\n\nlet serverMs = null;\nlet serverSec = null;\nlet overheadMs = null;\n\nif (json && json.response_time_ms !== undefined && json.response_time_ms !== null) {\n serverMs = Number(json.response_time_ms);\n serverSec = Number(json.response_time_sec);\n overheadMs = clientMs - serverMs;\n\n pm.test(\"Server response_time_ms is numeric\", function () {\n pm.expect(serverMs).to.be.a(\"number\");\n });\n\n pm.test(\"Server response_time_sec is numeric\", function () {\n pm.expect(serverSec).to.be.a(\"number\");\n });\n\n pm.test(\"Server response_time_ms and response_time_sec consistency (<0.1s diff)\", function () {\n const calculatedSec = serverMs / 1000;\n pm.expect(Math.abs(calculatedSec - serverSec)).to.be.below(0.1);\n });\n}\n\nlet performanceStatus;\nif (clientMs <= 5000) {\n performanceStatus = \"FAST\";\n} else if (clientMs <= 10000) {\n performanceStatus = \"ACCEPTABLE\";\n} else if (clientMs <= 30000) {\n performanceStatus = \"SLOW\";\n} else {\n performanceStatus = \"CRITICAL / PERFORMANCE INVESTIGATION REQUIRED\";\n}\n\nconsole.log({\n endpoint: pm.request.url.toString(),\n client_response_time_ms: clientMs,\n server_response_time_ms: serverMs,\n server_response_time_sec: serverSec,\n overhead_ms: overheadMs,\n performance_status: performanceStatus\n});\n"
],
"type": "text/javascript"
}
}
]
}
]
},
{
"name": "01 - Authentication",
"item": [
{
"name": "Test Bearer Auth Token Validation",
"request": {
"method": "POST",
"header": [
{
"key": "Authorization",
"value": "Bearer {{auth_token}}"
},
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\"patient\": {\"id\": \"{{patient_id}}\", \"name\": \"{{patient_name}}\", \"age\": 30, \"gender\": \"{{patient_gender}}\"}, \"category\": {\"type\": \"{{category_type}}\", \"code\": \"{{category_code}}\", \"name\": \"{{category_name}}\"}, \"language\": \"{{language}}\"}"
},
"url": {
"raw": "{{base_url}}/v1/onboarding/start",
"host": [
"{{base_url}}"
],
"path": [
"v1",
"onboarding",
"start"
]
}
},
"event": [
{
"listen": "test",
"script": {
"exec": [
"pm.test('Status code is 200 OK', function () { pm.response.to.have.status(200); });",
"\nconst clientMs = pm.response.responseTime;\nconst json = pm.response.json();\nconst maxAllowedMs = Number(pm.environment.get('text_complete_max_ms') || 10000);\n\npm.test(\"Response time is recorded\", function () {\n pm.expect(clientMs).to.be.a(\"number\");\n});\n\nlet serverMs = null;\nlet serverSec = null;\nlet overheadMs = null;\n\nif (json && json.response_time_ms !== undefined && json.response_time_ms !== null) {\n serverMs = Number(json.response_time_ms);\n serverSec = Number(json.response_time_sec);\n overheadMs = clientMs - serverMs;\n\n pm.test(\"Server response_time_ms is numeric\", function () {\n pm.expect(serverMs).to.be.a(\"number\");\n });\n\n pm.test(\"Server response_time_sec is numeric\", function () {\n pm.expect(serverSec).to.be.a(\"number\");\n });\n\n pm.test(\"Server response_time_ms and response_time_sec consistency (<0.1s diff)\", function () {\n const calculatedSec = serverMs / 1000;\n pm.expect(Math.abs(calculatedSec - serverSec)).to.be.below(0.1);\n });\n}\n\nlet performanceStatus;\nif (clientMs <= 5000) {\n performanceStatus = \"FAST\";\n} else if (clientMs <= 10000) {\n performanceStatus = \"ACCEPTABLE\";\n} else if (clientMs <= 30000) {\n performanceStatus = \"SLOW\";\n} else {\n performanceStatus = \"CRITICAL / PERFORMANCE INVESTIGATION REQUIRED\";\n}\n\nconsole.log({\n endpoint: pm.request.url.toString(),\n client_response_time_ms: clientMs,\n server_response_time_ms: serverMs,\n server_response_time_sec: serverSec,\n overhead_ms: overheadMs,\n performance_status: performanceStatus\n});\n"
],
"type": "text/javascript"
}
}
]
}
]
},
{
"name": "02 - Patient Onboarding (Text)",
"item": [
{
"name": "Start Text Onboarding",
"request": {
"method": "POST",
"header": [
{
"key": "Authorization",
"value": "Bearer {{auth_token}}"
},
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\"patient\": {\"id\": \"{{patient_id}}\", \"name\": \"{{patient_name}}\", \"age\": 35, \"gender\": \"{{patient_gender}}\"}, \"category\": {\"type\": \"{{category_type}}\", \"code\": \"{{category_code}}\", \"name\": \"{{category_name}}\"}, \"language\": \"{{language}}\"}"
},
"url": {
"raw": "{{base_url}}/v1/onboarding/start",
"host": [
"{{base_url}}"
],
"path": [
"v1",
"onboarding",
"start"
]
}
},
"event": [
{
"listen": "test",
"script": {
"exec": [
"pm.test('Status code is 200', function () { pm.response.to.have.status(200); });",
"const data = pm.response.json(); pm.environment.set('session_id', data.session_id);",
"\nconst clientMs = pm.response.responseTime;\nconst json = pm.response.json();\nconst maxAllowedMs = Number(pm.environment.get('text_complete_max_ms') || 10000);\n\npm.test(\"Response time is recorded\", function () {\n pm.expect(clientMs).to.be.a(\"number\");\n});\n\nlet serverMs = null;\nlet serverSec = null;\nlet overheadMs = null;\n\nif (json && json.response_time_ms !== undefined && json.response_time_ms !== null) {\n serverMs = Number(json.response_time_ms);\n serverSec = Number(json.response_time_sec);\n overheadMs = clientMs - serverMs;\n\n pm.test(\"Server response_time_ms is numeric\", function () {\n pm.expect(serverMs).to.be.a(\"number\");\n });\n\n pm.test(\"Server response_time_sec is numeric\", function () {\n pm.expect(serverSec).to.be.a(\"number\");\n });\n\n pm.test(\"Server response_time_ms and response_time_sec consistency (<0.1s diff)\", function () {\n const calculatedSec = serverMs / 1000;\n pm.expect(Math.abs(calculatedSec - serverSec)).to.be.below(0.1);\n });\n}\n\nlet performanceStatus;\nif (clientMs <= 5000) {\n performanceStatus = \"FAST\";\n} else if (clientMs <= 10000) {\n performanceStatus = \"ACCEPTABLE\";\n} else if (clientMs <= 30000) {\n performanceStatus = \"SLOW\";\n} else {\n performanceStatus = \"CRITICAL / PERFORMANCE INVESTIGATION REQUIRED\";\n}\n\nconsole.log({\n endpoint: pm.request.url.toString(),\n client_response_time_ms: clientMs,\n server_response_time_ms: serverMs,\n server_response_time_sec: serverSec,\n overhead_ms: overheadMs,\n performance_status: performanceStatus\n});\n"
],
"type": "text/javascript"
}
}
]
},
{
"name": "Send Patient Message",
"request": {
"method": "POST",
"header": [
{
"key": "Authorization",
"value": "Bearer {{auth_token}}"
},
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\"message\": \"I have shortness of breath and chest tightness.\"}"
},
"url": {
"raw": "{{base_url}}/v1/onboarding/{{session_id}}/message",
"host": [
"{{base_url}}"
],
"path": [
"v1",
"onboarding",
"{{session_id}}",
"message"
]
}
},
"event": [
{
"listen": "test",
"script": {
"exec": [
"pm.test('Status code is 200', function () { pm.response.to.have.status(200); });",
"\nconst clientMs = pm.response.responseTime;\nconst json = pm.response.json();\nconst maxAllowedMs = Number(pm.environment.get('text_complete_max_ms') || 10000);\n\npm.test(\"Response time is recorded\", function () {\n pm.expect(clientMs).to.be.a(\"number\");\n});\n\nlet serverMs = null;\nlet serverSec = null;\nlet overheadMs = null;\n\nif (json && json.response_time_ms !== undefined && json.response_time_ms !== null) {\n serverMs = Number(json.response_time_ms);\n serverSec = Number(json.response_time_sec);\n overheadMs = clientMs - serverMs;\n\n pm.test(\"Server response_time_ms is numeric\", function () {\n pm.expect(serverMs).to.be.a(\"number\");\n });\n\n pm.test(\"Server response_time_sec is numeric\", function () {\n pm.expect(serverSec).to.be.a(\"number\");\n });\n\n pm.test(\"Server response_time_ms and response_time_sec consistency (<0.1s diff)\", function () {\n const calculatedSec = serverMs / 1000;\n pm.expect(Math.abs(calculatedSec - serverSec)).to.be.below(0.1);\n });\n}\n\nlet performanceStatus;\nif (clientMs <= 5000) {\n performanceStatus = \"FAST\";\n} else if (clientMs <= 10000) {\n performanceStatus = \"ACCEPTABLE\";\n} else if (clientMs <= 30000) {\n performanceStatus = \"SLOW\";\n} else {\n performanceStatus = \"CRITICAL / PERFORMANCE INVESTIGATION REQUIRED\";\n}\n\nconsole.log({\n endpoint: pm.request.url.toString(),\n client_response_time_ms: clientMs,\n server_response_time_ms: serverMs,\n server_response_time_sec: serverSec,\n overhead_ms: overheadMs,\n performance_status: performanceStatus\n});\n"
],
"type": "text/javascript"
}
}
]
},
{
"name": "Complete Text Onboarding",
"request": {
"method": "POST",
"header": [
{
"key": "Authorization",
"value": "Bearer {{auth_token}}"
},
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\"confirmed\": true}"
},
"url": {
"raw": "{{base_url}}/v1/onboarding/{{session_id}}/complete",
"host": [
"{{base_url}}"
],
"path": [
"v1",
"onboarding",
"{{session_id}}",
"complete"
]
}
},
"event": [
{
"listen": "test",
"script": {
"exec": [
"pm.test('Status code is 200', function () { pm.response.to.have.status(200); });",
"\nconst clientMs = pm.response.responseTime;\nconst json = pm.response.json();\nconst maxAllowedMs = Number(pm.environment.get('text_complete_max_ms') || 10000);\n\npm.test(\"Response time is recorded\", function () {\n pm.expect(clientMs).to.be.a(\"number\");\n});\n\nlet serverMs = null;\nlet serverSec = null;\nlet overheadMs = null;\n\nif (json && json.response_time_ms !== undefined && json.response_time_ms !== null) {\n serverMs = Number(json.response_time_ms);\n serverSec = Number(json.response_time_sec);\n overheadMs = clientMs - serverMs;\n\n pm.test(\"Server response_time_ms is numeric\", function () {\n pm.expect(serverMs).to.be.a(\"number\");\n });\n\n pm.test(\"Server response_time_sec is numeric\", function () {\n pm.expect(serverSec).to.be.a(\"number\");\n });\n\n pm.test(\"Server response_time_ms and response_time_sec consistency (<0.1s diff)\", function () {\n const calculatedSec = serverMs / 1000;\n pm.expect(Math.abs(calculatedSec - serverSec)).to.be.below(0.1);\n });\n}\n\nlet performanceStatus;\nif (clientMs <= 5000) {\n performanceStatus = \"FAST\";\n} else if (clientMs <= 10000) {\n performanceStatus = \"ACCEPTABLE\";\n} else if (clientMs <= 30000) {\n performanceStatus = \"SLOW\";\n} else {\n performanceStatus = \"CRITICAL / PERFORMANCE INVESTIGATION REQUIRED\";\n}\n\nconsole.log({\n endpoint: pm.request.url.toString(),\n client_response_time_ms: clientMs,\n server_response_time_ms: serverMs,\n server_response_time_sec: serverSec,\n overhead_ms: overheadMs,\n performance_status: performanceStatus\n});\n"
],
"type": "text/javascript"
}
}
]
}
]
},
{
"name": "03 - Voice Onboarding",
"item": [
{
"name": "Start Voice Onboarding",
"request": {
"method": "POST",
"header": [
{
"key": "Authorization",
"value": "Bearer {{auth_token}}"
},
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\"patient\": {\"id\": \"{{patient_id}}\", \"name\": \"{{patient_name}}\", \"age\": 45, \"gender\": \"{{patient_gender}}\"}, \"language\": \"ta-IN\"}"
},
"url": {
"raw": "{{base_url}}/v1/voice-onboarding/start",
"host": [
"{{base_url}}"
],
"path": [
"v1",
"voice-onboarding",
"start"
]
}
},
"event": [
{
"listen": "test",
"script": {
"exec": [
"pm.test('Status code is 200', function () { pm.response.to.have.status(200); });",
"const data = pm.response.json(); pm.environment.set('voice_session_id', data.session_id);",
"\nconst clientMs = pm.response.responseTime;\nconst json = pm.response.json();\nconst maxAllowedMs = Number(pm.environment.get('text_complete_max_ms') || 10000);\n\npm.test(\"Response time is recorded\", function () {\n pm.expect(clientMs).to.be.a(\"number\");\n});\n\nlet serverMs = null;\nlet serverSec = null;\nlet overheadMs = null;\n\nif (json && json.response_time_ms !== undefined && json.response_time_ms !== null) {\n serverMs = Number(json.response_time_ms);\n serverSec = Number(json.response_time_sec);\n overheadMs = clientMs - serverMs;\n\n pm.test(\"Server response_time_ms is numeric\", function () {\n pm.expect(serverMs).to.be.a(\"number\");\n });\n\n pm.test(\"Server response_time_sec is numeric\", function () {\n pm.expect(serverSec).to.be.a(\"number\");\n });\n\n pm.test(\"Server response_time_ms and response_time_sec consistency (<0.1s diff)\", function () {\n const calculatedSec = serverMs / 1000;\n pm.expect(Math.abs(calculatedSec - serverSec)).to.be.below(0.1);\n });\n}\n\nlet performanceStatus;\nif (clientMs <= 5000) {\n performanceStatus = \"FAST\";\n} else if (clientMs <= 10000) {\n performanceStatus = \"ACCEPTABLE\";\n} else if (clientMs <= 30000) {\n performanceStatus = \"SLOW\";\n} else {\n performanceStatus = \"CRITICAL / PERFORMANCE INVESTIGATION REQUIRED\";\n}\n\nconsole.log({\n endpoint: pm.request.url.toString(),\n client_response_time_ms: clientMs,\n server_response_time_ms: serverMs,\n server_response_time_sec: serverSec,\n overhead_ms: overheadMs,\n performance_status: performanceStatus\n});\n"
],
"type": "text/javascript"
}
}
]
},
{
"name": "Voice Turn - Text Fallback",
"request": {
"method": "POST",
"header": [
{
"key": "Authorization",
"value": "Bearer {{auth_token}}"
}
],
"body": {
"mode": "formdata",
"formdata": [
{
"key": "text_fallback",
"value": "எனக்கு கடந்த 3 நாளா நெஞ்சு பாரமா இருக்குங்க.",
"type": "text"
}
]
},
"url": {
"raw": "{{base_url}}/v1/voice-onboarding/{{voice_session_id}}/turn",
"host": [
"{{base_url}}"
],
"path": [
"v1",
"voice-onboarding",
"{{voice_session_id}}",
"turn"
]
}
},
"event": [
{
"listen": "test",
"script": {
"exec": [
"pm.test('Status code is 200', function () { pm.response.to.have.status(200); });",
"\nconst clientMs = pm.response.responseTime;\nconst json = pm.response.json();\nconst maxAllowedMs = Number(pm.environment.get('text_complete_max_ms') || 10000);\n\npm.test(\"Response time is recorded\", function () {\n pm.expect(clientMs).to.be.a(\"number\");\n});\n\nlet serverMs = null;\nlet serverSec = null;\nlet overheadMs = null;\n\nif (json && json.response_time_ms !== undefined && json.response_time_ms !== null) {\n serverMs = Number(json.response_time_ms);\n serverSec = Number(json.response_time_sec);\n overheadMs = clientMs - serverMs;\n\n pm.test(\"Server response_time_ms is numeric\", function () {\n pm.expect(serverMs).to.be.a(\"number\");\n });\n\n pm.test(\"Server response_time_sec is numeric\", function () {\n pm.expect(serverSec).to.be.a(\"number\");\n });\n\n pm.test(\"Server response_time_ms and response_time_sec consistency (<0.1s diff)\", function () {\n const calculatedSec = serverMs / 1000;\n pm.expect(Math.abs(calculatedSec - serverSec)).to.be.below(0.1);\n });\n}\n\nlet performanceStatus;\nif (clientMs <= 5000) {\n performanceStatus = \"FAST\";\n} else if (clientMs <= 10000) {\n performanceStatus = \"ACCEPTABLE\";\n} else if (clientMs <= 30000) {\n performanceStatus = \"SLOW\";\n} else {\n performanceStatus = \"CRITICAL / PERFORMANCE INVESTIGATION REQUIRED\";\n}\n\nconsole.log({\n endpoint: pm.request.url.toString(),\n client_response_time_ms: clientMs,\n server_response_time_ms: serverMs,\n server_response_time_sec: serverSec,\n overhead_ms: overheadMs,\n performance_status: performanceStatus\n});\n"
],
"type": "text/javascript"
}
}
]
},
{
"name": "Complete Voice Onboarding",
"request": {
"method": "POST",
"header": [
{
"key": "Authorization",
"value": "Bearer {{auth_token}}"
},
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\"confirmed\": true}"
},
"url": {
"raw": "{{base_url}}/v1/voice-onboarding/{{voice_session_id}}/complete",
"host": [
"{{base_url}}"
],
"path": [
"v1",
"voice-onboarding",
"{{voice_session_id}}",
"complete"
]
}
},
"event": [
{
"listen": "test",
"script": {
"exec": [
"pm.test('Status code is 200', function () { pm.response.to.have.status(200); });",
"\nconst clientMs = pm.response.responseTime;\nconst json = pm.response.json();\nconst maxAllowedMs = Number(pm.environment.get('text_complete_max_ms') || 10000);\n\npm.test(\"Response time is recorded\", function () {\n pm.expect(clientMs).to.be.a(\"number\");\n});\n\nlet serverMs = null;\nlet serverSec = null;\nlet overheadMs = null;\n\nif (json && json.response_time_ms !== undefined && json.response_time_ms !== null) {\n serverMs = Number(json.response_time_ms);\n serverSec = Number(json.response_time_sec);\n overheadMs = clientMs - serverMs;\n\n pm.test(\"Server response_time_ms is numeric\", function () {\n pm.expect(serverMs).to.be.a(\"number\");\n });\n\n pm.test(\"Server response_time_sec is numeric\", function () {\n pm.expect(serverSec).to.be.a(\"number\");\n });\n\n pm.test(\"Server response_time_ms and response_time_sec consistency (<0.1s diff)\", function () {\n const calculatedSec = serverMs / 1000;\n pm.expect(Math.abs(calculatedSec - serverSec)).to.be.below(0.1);\n });\n}\n\nlet performanceStatus;\nif (clientMs <= 5000) {\n performanceStatus = \"FAST\";\n} else if (clientMs <= 10000) {\n performanceStatus = \"ACCEPTABLE\";\n} else if (clientMs <= 30000) {\n performanceStatus = \"SLOW\";\n} else {\n performanceStatus = \"CRITICAL / PERFORMANCE INVESTIGATION REQUIRED\";\n}\n\nconsole.log({\n endpoint: pm.request.url.toString(),\n client_response_time_ms: clientMs,\n server_response_time_ms: serverMs,\n server_response_time_sec: serverSec,\n overhead_ms: overheadMs,\n performance_status: performanceStatus\n});\n"
],
"type": "text/javascript"
}
}
]
}
]
},
{
"name": "09 - Performance Tests",
"item": [
{
"name": "Performance Benchmark Suite Target",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{base_url}}/health",
"host": [
"{{base_url}}"
],
"path": [
"health"
]
}
},
"event": [
{
"listen": "test",
"script": {
"exec": [
"pm.test('Performance Benchmark Health Target Status 200', function () { pm.response.to.have.status(200); });",
"\nconst clientMs = pm.response.responseTime;\nconst json = pm.response.json();\nconst maxAllowedMs = Number(pm.environment.get('text_complete_max_ms') || 10000);\n\npm.test(\"Response time is recorded\", function () {\n pm.expect(clientMs).to.be.a(\"number\");\n});\n\nlet serverMs = null;\nlet serverSec = null;\nlet overheadMs = null;\n\nif (json && json.response_time_ms !== undefined && json.response_time_ms !== null) {\n serverMs = Number(json.response_time_ms);\n serverSec = Number(json.response_time_sec);\n overheadMs = clientMs - serverMs;\n\n pm.test(\"Server response_time_ms is numeric\", function () {\n pm.expect(serverMs).to.be.a(\"number\");\n });\n\n pm.test(\"Server response_time_sec is numeric\", function () {\n pm.expect(serverSec).to.be.a(\"number\");\n });\n\n pm.test(\"Server response_time_ms and response_time_sec consistency (<0.1s diff)\", function () {\n const calculatedSec = serverMs / 1000;\n pm.expect(Math.abs(calculatedSec - serverSec)).to.be.below(0.1);\n });\n}\n\nlet performanceStatus;\nif (clientMs <= 5000) {\n performanceStatus = \"FAST\";\n} else if (clientMs <= 10000) {\n performanceStatus = \"ACCEPTABLE\";\n} else if (clientMs <= 30000) {\n performanceStatus = \"SLOW\";\n} else {\n performanceStatus = \"CRITICAL / PERFORMANCE INVESTIGATION REQUIRED\";\n}\n\nconsole.log({\n endpoint: pm.request.url.toString(),\n client_response_time_ms: clientMs,\n server_response_time_ms: serverMs,\n server_response_time_sec: serverSec,\n overhead_ms: overheadMs,\n performance_status: performanceStatus\n});\n"
],
"type": "text/javascript"
}
}
]
}
]
}
]
}
+442 -259
View File
@@ -1,349 +1,532 @@
# AI Patient Onboarding — Frontend Integration API Reference Guide
# AI Patient Onboarding — Backend API Documentation
Base URL: `http://127.0.0.1:8000`
Protocol: `HTTP / REST` + `JSON` + `multipart/form-data`
This document provides a comprehensive guide for integrating with the **AI Patient Onboarding FastAPI Backend**. It covers all REST API endpoints, request/response models, voice integration flows, medical document processing, and EHR integration.
---
## Overview
The AI Patient Onboarding system provides two modes of integration for frontend web/mobile applications:
1. **Voice AI Onboarding API** (`/v1/voice-onboarding/...`): Supports voice microphone recording, ASR (Speech-to-Text), synthesized audio response streaming (TTS), Google Gemini AI triage, response timing, and EHR JSON export.
2. **Text AI Onboarding API** (`/v1/onboarding/...`): Supports pure text chat-based intake, Google Gemini AI reasoning, state extraction, and EHR JSON export.
## Table of Contents
1. [Architecture & Server Overview](#1-architecture--server-overview)
2. [Environment Configuration (`.env`)](#2-environment-configuration-env)
3. [Running the Backend Locally](#3-running-the-backend-locally)
4. [Voice Onboarding API (`/v1/voice-onboarding`)](#4-voice-onboarding-api-v1voice-onboarding)
- [4.1 Start Voice Session](#41-start-voice-session)
- [4.2 Send Voice / Speech Turn](#42-send-voice--speech-turn)
- [4.3 Fetch Synthesized Voice Audio](#43-fetch-synthesized-voice-audio)
- [4.4 Complete & Confirm Session](#44-complete--confirm-session)
5. [Text Onboarding API (`/v1/onboarding`)](#5-text-onboarding-api-v1onboarding)
- [5.1 Start Text Session](#51-start-text-session)
- [5.2 Send Text Message](#52-send-text-message)
- [5.3 Complete Text Onboarding](#53-complete-text-onboarding)
6. [Medical Document & Vision API (`/v1/documents`)](#6-medical-document--vision-api-v1documents)
7. [System Health Check (`/health`)](#7-system-health-check-health)
8. [Connecting External Hospital EMR / EHR System](#8-connecting-external-hospital-emr--ehr-system)
9. [Multi-Language & Clinical Specialties](#9-multi-language--clinical-specialties)
10. [Client Integration Code Samples](#10-client-integration-code-samples)
---
# SECTION 1: Voice AI Onboarding API (`/v1/voice-onboarding`)
## 1. Architecture & Server Overview
### 1.1 Start Voice Onboarding Session
Initializes a voice onboarding session and returns localized greeting text + synthesized spoken audio URL.
- **Framework:** FastAPI (Python 3.10+) with SQLAlchemy ORM
- **Default Base URL:** `http://127.0.0.1:8000`
- **Interactive Swagger Documentation:** `http://127.0.0.1:8000/docs`
- **OpenAPI JSON Spec:** `http://127.0.0.1:8000/openapi.json`
- **CORS:** Pre-configured to allow all origins (`*`) for easy React web and mobile client integration.
- **Endpoint**: `POST /v1/voice-onboarding/start`
- **Content-Type**: `application/json`
```
+------------------+ +-------------------------------+ +-----------------------+
| Client Device | | FastAPI Backend Engine | | Hospital EHR / Database|
+------------------+ +-------------------------------+ +-----------------------+
| | |
| --- POST /start ----------------> | (Initialize Session + State) |
| <-- Greeting Text & Audio URL --- | (Synthesized TTS Audio) |
| | |
| --- POST /turn (Audio / Text) --> | (Sarvam STT -> GPT-4o -> State) |
| <-- Next Question & Audio URL --- | |
| | |
| --- POST /upload (Prescription) > | (Gemini Vision OCR -> Extract Meds) |
| <-- Extracted Findings ---------- | |
| | |
| --- POST /complete (confirmed) -> | (Extract EHR JSON) ---------------->| POST /patients
| <-- Final Success Status -------- | <--- 200 OK Record Saved -----------|
```
#### Request Payload
---
## 2. Environment Configuration (`.env`)
Create or update the `.env` file in the `ai-patient-onboarding/` directory:
```ini
APP_NAME="AI Patient Onboarding"
APP_ENV=development
APP_VERSION=1.0.0
# Database URL
DATABASE_URL="sqlite:///./data/onboarding.db"
# AI Provider API Keys
OPENAI_API_KEY="sk-..."
OPENAI_MODEL="gpt-4o-mini"
ANTHROPIC_API_KEY="sk-ant-..."
ANTHROPIC_MODEL="claude-3-5-sonnet-20241022"
GEMINI_API_KEY="AIzaSy..."
GEMINI_MODEL="gemini-1.5-flash"
SARVAM_API_KEY="your_sarvam_key"
# External Destination Hospital EHR / Patient API (Optional)
PATIENT_API_BASE_URL="https://your-hospital-emr.com/api/v1"
PATIENT_API_TOKEN="your_secure_bearer_token"
```
---
## 3. Running the Backend Locally
```bash
# Navigate to the backend directory
cd ai-patient-onboarding
# Activate Python Virtual Environment
# Windows PowerShell:
.\venv\Scripts\Activate.ps1
# Linux / macOS:
# source venv/bin/activate
# Install dependencies if not already installed
pip install -r requirements.txt
# Start FastAPI development server
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
```
---
## 4. Voice Onboarding API (`/v1/voice-onboarding`)
### 4.1 Start Voice Session
Creates a new voice onboarding session, initializes dynamic state, and returns localized assistant greeting with audio.
- **URL:** `POST /v1/voice-onboarding/start`
- **Headers:** `Content-Type: application/json`
- **Request Body:**
```json
{
"patient": {
"id": "PAT-9624",
"name": "Gokul Varthan",
"age": 25,
"gender": "male"
"id": "PAT-9842",
"name": "Arun Kumar",
"age": 42,
"gender": "male",
"abha_id": "91-1234-5678-9012",
"phone": "+91 98765 43210",
"insurance_scheme": "PM-JAY",
"dietary_habits": "Vegetarian"
},
"category": {
"type": "general",
"code": "general_medicine",
"name": "General Medicine / Primary Care"
"type": "speciality",
"code": "cardio",
"name": "Cardiology"
},
"language": "ta-IN"
"language": "en-IN"
}
```
*Note: `category` is **optional**. If omitted, it defaults to `general_medicine` (Auto-Triage).*
#### Supported Languages
- `ta-IN`: Tamil (Pechu Tamil Spoken Dialect)
- `hi-IN`: Hindi (Conversational Spoken Hindi)
- `te-IN`: Telugu (Conversational Telugu)
- `kn-IN`: Kannada (Conversational Kannada)
- `en-IN`: Indian English (Empathetic Spoken English)
#### Response (`200 OK`)
- **Response (`200 OK`):**
```json
{
"success": true,
"session_id": "VOICE-4C5F63F0",
"patient_id": "PAT-9624",
"session_id": "VOICE-78A1BF2C",
"patient_id": "PAT-9842",
"category": {
"type": "general",
"code": "general_medicine",
"name": "General Medicine / Primary Care"
"type": "speciality",
"code": "cardio",
"name": "Cardiology"
},
"language": "ta-IN",
"language": "en-IN",
"status": "in_progress",
"stage": "greeting",
"greeting_text": "வணக்கம் Gokul Varthan, நான் உங்க AI அசிஸ்டன்ட் நிலா. உங்களுக்கு உடம்புக்கு என்ன பண்ணுது, என்ன பிரச்சனைன்னு சொல்லுங்க?",
"audio_url": "/v1/voice-onboarding/audio/aud_32dd00c39aa1",
"response_time_ms": 36.33,
"response_time_sec": 0.036
"greeting_text": "Hello Arun Kumar, welcome to CureSelect Cardiology department. What symptoms or discomfort are you experiencing today?",
"audio_url": "/v1/voice-onboarding/audio/voice_greeting_89bf1a.wav"
}
```
---
### 1.2 Submit Voice/Text Conversation Turn
Submits the patient's voice audio recording or text fallback. The backend runs ASR (Sarvam Speech-to-Text), Google Gemini AI reasoning, and returns spoken audio URL + dynamic triage category.
### 4.2 Send Voice / Speech Turn
- **Endpoint**: `POST /v1/voice-onboarding/{session_id}/turn`
- **Content-Type**: `multipart/form-data`
Submits the patient's recorded audio or transcribed speech. The backend runs multi-model Indic speech recognition (Sarvam/Whisper), extracts medical entities, updates conversational state, and generates the next question with audio TTS.
#### Form Fields
| Field Name | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| `audio` | `File` (WebM/WAV) | Optional | Audio blob recorded by microphone. |
| `text_fallback` | `string` | Optional | Text entered by patient. **Pass `null` or omit when sending `audio` so Sarvam ASR runs.** |
- **URL:** `POST /v1/voice-onboarding/{session_id}/turn`
- **Headers:** `Content-Type: multipart/form-data`
- **Form Data Parameters:**
- `audio` *(Optional File)*: WebM, WAV, or MP3 recorded audio file from the user's microphone.
- `text_fallback` *(Optional String)*: Real-time browser speech recognition transcript fallback.
#### React / JavaScript Integration Example
```javascript
const sendVoiceTurn = async (sessionId, audioBlob, textInput) => {
const formData = new FormData();
if (audioBlob) {
formData.append('audio', audioBlob, 'recording.webm');
}
if (textInput && textInput.trim()) {
formData.append('text_fallback', textInput.trim());
}
const response = await fetch(`http://127.0.0.1:8000/v1/voice-onboarding/${sessionId}/turn`, {
method: 'POST',
body: formData,
});
return await response.json();
};
```
#### Response (`200 OK`)
- **Response (`200 OK`):**
```json
{
"success": true,
"session_id": "VOICE-4C5F63F0",
"patient_transcript": "எனக்கு ரொம்ப மன அழுத்தம் மற்றும் மனச்சோர்வு இருக்கு",
"ai_text": "புரியுதுங்க கோகுல், இந்த மன அழுத்தமும் மனச்சோர்வும் எத்தனை நாளா இருக்குன்னு சொல்ல முடியுமா?",
"stage": "reason_for_visit",
"session_id": "VOICE-78A1BF2C",
"patient_transcript": "I have had severe chest pain and breathlessness since yesterday evening.",
"ai_text": "I understand. Does the chest pain radiate to your left arm or jaw, and are you currently taking any blood pressure or heart medications?",
"stage": "symptoms_inquiry",
"completed": false,
"audio_url": "/v1/voice-onboarding/audio/aud_a1b2c3d4e5f6",
"suggested_specialty": {
"audio_url": "/v1/voice-onboarding/audio/speech_reply_30ef2.wav"
}
```
---
### 4.3 Fetch Synthesized Voice Audio
Streams audio file generated for assistant responses.
- **URL:** `GET /v1/voice-onboarding/audio/{audio_id}`
- **Response:** `audio/wav` or `audio/mpeg` binary stream.
---
### 4.4 Complete & Confirm Session
Extracts complete structured clinical results. Supports a two-step confirmation workflow (review preview $\rightarrow$ permanent submission).
- **URL:** `POST /v1/voice-onboarding/{session_id}/complete`
- **Headers:** `Content-Type: application/json`
#### Step A: Request Review Preview (`confirmed: false`)
```json
{
"confirmed": false
}
```
**Response (`200 OK`):**
```json
{
"success": true,
"session_id": "VOICE-78A1BF2C",
"status": "awaiting_confirmation",
"result": {
"patient_id": "PAT-9842",
"abha_id": "91-1234-5678-9012",
"phone": "+91 98765 43210",
"insurance_scheme": "PM-JAY",
"category": {
"type": "speciality",
"code": "psychiatry",
"name": "Psychiatry & Mental Health Care",
"confidence": 0.95,
"reason": "Automatically classified based on clinical keyword: 'மன அழுத்தம்'"
"code": "cardio",
"name": "Cardiology"
},
"response_time_ms": 1420.50,
"response_time_sec": 1.421
"chief_complaint": "Acute onset retrosternal chest pain and dyspnea",
"symptoms": [
{
"name": "Chest tightness",
"duration": "1 day",
"severity": "Moderate to Severe",
"description": "Retrosternal pressure aggravated by exertion"
}
],
"medical_history": ["Hypertension (5 years)"],
"medications": ["Amlodipine 5mg OD"],
"allergies": ["Penicillin"],
"dietary_habits": "Vegetarian",
"additional_information": "Non-smoker",
"language": "en-IN",
"onboarding_completed": true
},
"message": "Please review the collected information and confirm by voice or tap."
}
```
---
### 1.3 Stream Spoken Audio (TTS)
Streams synthesized audio response files.
- **Endpoint**: `GET /v1/voice-onboarding/audio/{audio_id}`
- **Response Headers**: `Content-Type: audio/wav`, `Content-Disposition: inline`
#### Audio Playback Code
```javascript
const playAudioStream = async (audioUrl) => {
if (!audioUrl) return;
const fullUrl = audioUrl.startsWith('http') ? audioUrl : `http://127.0.0.1:8000${audioUrl}`;
const audio = new Audio(fullUrl);
try {
await audio.play();
} catch (err) {
console.warn("Audio autoplay blocked by browser:", err);
}
};
```
---
### 1.4 Complete Voice Session & Export EHR JSON
Finalizes intake and exports the complete structured EHR JSON.
- **Endpoint**: `POST /v1/voice-onboarding/{session_id}/complete`
- **Content-Type**: `application/json`
#### Request Payload
#### Step B: Confirm & Submit to EHR (`confirmed: true`)
```json
{
"confirmed": true
}
```
#### Response (`200 OK`)
**Response (`200 OK`):**
```json
{
"success": true,
"session_id": "VOICE-4C5F63F0",
"session_id": "VOICE-78A1BF2C",
"status": "submitted_and_cleaned",
"message": "Voice onboarding successfully saved to patient record.",
"result": {
"patient_id": "PAT-9624",
"category": {
"type": "speciality",
"code": "psychiatry",
"name": "Psychiatry & Mental Health Care"
},
"chief_complaint": "Severe mental depression and stress",
"symptoms": [
{
"name": "Mental depression",
"duration": "2 months",
"severity": "Moderate",
"description": "Patient reports persistent sadness and low mood."
}
],
"medical_history": [],
"medications": [],
"allergies": ["No Known Drug Allergies (NKDA)"],
"additional_information": "Referred to specialist psychiatrist.",
"language": "ta-IN",
"onboarding_completed": true
},
"response_time_ms": 2840.10,
"response_time_sec": 2.840
"result": { ... },
"message": "Voice onboarding successfully saved to patient record."
}
```
---
# SECTION 2: Text AI Onboarding API (`/v1/onboarding`)
## 5. Text Onboarding API (`/v1/onboarding`)
### 2.1 Start Text Onboarding Session
- **Endpoint**: `POST /v1/onboarding/start`
- **Content-Type**: `application/json`
For text chat interfaces or non-voice kiosks.
#### Request Payload
### 5.1 Start Text Session
- **URL:** `POST /v1/onboarding/start`
- **Body:** Same as Voice Start (`patient`, `category`, `language`).
- **Response:** Returns `session_id`, `greeting_message`, and initial state.
### 5.2 Send Text Message
- **URL:** `POST /v1/onboarding/{session_id}/message`
- **Body:**
```json
{
"patient": {
"id": "PAT-9624",
"name": "Gokul Varthan",
"age": 25,
"gender": "male"
},
"category": {
"type": "general",
"code": "general_medicine",
"name": "General Medicine / Primary Care"
},
"language": "en"
"message": "I've had knee pain for 3 days after a run."
}
```
#### Response (`200 OK`)
- **Response:**
```json
{
"success": true,
"session_id": "ONB-8F3A2B1C",
"patient_id": "PAT-9624",
"category": {
"type": "general",
"code": "general_medicine",
"name": "General Medicine / Primary Care"
},
"language": "en",
"status": "in_progress",
"stage": "greeting",
"message": "Hello Gokul Varthan, welcome to CureSelect Healthcare. Could you please share what health symptoms you are experiencing today?"
}
```
---
### 2.2 Send Text Message
- **Endpoint**: `POST /v1/onboarding/{session_id}/message`
- **Content-Type**: `application/json`
#### Request Payload
```json
{
"message": "I have been experiencing a severe headache and fever since yesterday."
}
```
#### Response (`200 OK`)
```json
{
"success": true,
"session_id": "ONB-8F3A2B1C",
"stage": "symptoms",
"message": "Understood Gokul. Have you taken any fever or pain reliever medication yet?",
"session_id": "ONB-84A12B1C",
"stage": "symptoms_inquiry",
"message": "Is the knee swollen or locking when you walk?",
"status": "in_progress"
}
```
### 5.3 Complete Text Onboarding
- **URL:** `POST /v1/onboarding/{session_id}/complete`
- **Body:** `{"confirmed": true}`
---
### 2.3 Complete Text Onboarding & Export EHR JSON
- **Endpoint**: `POST /v1/onboarding/{session_id}/complete`
- **Content-Type**: `application/json`
## 6. Medical Document & Vision API (`/v1/documents`)
#### Request Payload
```json
{
"confirmed": true
}
```
Uploads patient medical documents (prescriptions, discharge summaries, lab reports) to extract medications, allergies, and diagnoses using Gemini 1.5 Flash Vision, automatically injecting findings into the active session state.
#### Response (`200 OK`)
- **URL:** `POST /v1/documents/{session_id}/upload`
- **Headers:** `Content-Type: multipart/form-data`
- **Form Field:** `file` *(Binary image or PDF file)*
- **Response (`200 OK`):**
```json
{
"success": true,
"session_id": "ONB-8F3A2B1C",
"status": "submitted_and_cleaned",
"message": "Onboarding successfully completed, saved to patient record, and temporary session cleaned.",
"result": {
"patient_id": "PAT-9624",
"category": {
"type": "general",
"code": "general_medicine",
"name": "General Medicine / Primary Care"
},
"chief_complaint": "Severe headache and fever",
"symptoms": [
{
"name": "Headache and fever",
"duration": "1 day",
"severity": "Severe",
"description": "Patient reports onset yesterday."
"session_id": "VOICE-78A1BF2C",
"document_type": "prescription",
"summary": "Prescription from Cardiologist dated 15-Aug-2024",
"findings": ["Ejection Fraction 55%", "Normal Sinus Rhythm"],
"medications_found": ["Atorvastatin 20mg", "Metoprolol 25mg"],
"allergies_found": ["Sulfa drugs"],
"message": "Medical document analyzed and integrated into onboarding session."
}
],
"medical_history": [],
"medications": [],
"allergies": ["No Known Drug Allergies (NKDA)"],
"language": "en",
"onboarding_completed": true
```
---
## 7. System Health Check (`/health`)
- **URL:** `GET /health`
- **Response (`200 OK`):**
```json
{
"success": true,
"service": "AI Patient Onboarding",
"status": "healthy",
"models": {
"speech": "Sarvam AI (Indian Languages) / OpenAI Whisper",
"conversation": "OpenAI GPT-4o-mini",
"validation": "Claude 3.5 Sonnet",
"vision_documents": "Gemini 1.5 Flash"
}
}
```
---
# Architecture Sequence Diagram
## 8. Connecting External Hospital EMR / EHR System
```mermaid
sequenceDiagram
autonumber
actor Patient
participant Frontend as React / Web App
participant API as FastAPI Server
participant Sarvam as Sarvam Indic ASR / TTS
participant Gemini as Google Gemini AI
When onboarding is finalized, the backend invokes `PatientSubmissionService` located at:
`app/services/patient_submission_service.py`
Patient->>Frontend: Clicks "Start Intake"
Frontend->>API: POST /v1/voice-onboarding/start
API->>Sarvam: Generate Spoken Greeting (TTS)
API-->>Frontend: Returns session_id, greeting_text, audio_url, response_time_ms
Frontend->>Patient: Plays audio greeting & displays text
### Customizing the Destination Forwarding:
```python
import httpx
from app.config import PATIENT_API_BASE_URL, PATIENT_API_TOKEN
Patient->>Frontend: Speaks into Microphone / Types
Frontend->>API: POST /v1/voice-onboarding/{session_id}/turn (formData)
alt Voice Audio Sent
API->>Sarvam: ASR Speech-to-Text
Sarvam-->>API: Returns transcript
end
API->>Gemini: Process context & generate dynamic turn
Gemini-->>API: Returns dynamic response & auto-classified specialty
API->>Sarvam: Generate AI Spoken Response (TTS)
API-->>Frontend: Returns ai_text, audio_url, suggested_specialty, response_time_ms
class PatientSubmissionService:
async def submit(self, result: dict) -> dict:
if not PATIENT_API_BASE_URL:
# Local fallback mock
return {
"success": True,
"message": "Patient data saved locally.",
"patient_id": result.get("patient_id"),
}
Frontend->>Patient: Plays AI audio response & updates UI
# Forward JSON payload to hospital central server
async with httpx.AsyncClient(timeout=15.0) as client:
headers = {
"Authorization": f"Bearer {PATIENT_API_TOKEN}",
"Content-Type": "application/json",
}
response = await client.post(
f"{PATIENT_API_BASE_URL}/patients/{result.get('patient_id')}/onboarding",
json=result,
headers=headers,
)
Patient->>Frontend: Final Confirmation
Frontend->>API: POST /v1/voice-onboarding/{session_id}/complete ({confirmed: true})
API-->>Frontend: Returns Final Structured EHR JSON
if response.status_code in [200, 201]:
return {"success": True, "data": response.json()}
return {
"success": False,
"error": f"Hospital API error: {response.text}",
}
```
---
## 9. Multi-Language & Clinical Specialties
### Supported Languages
| Code | Language | Speech Engine |
|---|---|---|
| `en-IN` / `en` | English (Indian accent) | Sarvam / OpenAI Whisper |
| `hi-IN` / `hi` | Hindi (हिन्दी) | Sarvam Bulbul / Whisper |
| `ta-IN` / `ta` | Tamil (தமிழ்) | Sarvam Bulbul / Whisper |
| `te-IN` / `te` | Telugu (తెలుగు) | Sarvam Bulbul / Whisper |
| `kn-IN` / `kn` | Kannada (ಕನ್ನಡ) | Sarvam Bulbul / Whisper |
| `bn-IN` / `bn` | Bengali (বাংলা) | Sarvam Bulbul / Whisper |
| `ml-IN` / `ml` | Malayalam (മലയാളം) | Sarvam Bulbul / Whisper |
| `mr-IN` / `mr` | Marathi (मराठी) | Sarvam Bulbul / Whisper |
| `gu-IN` / `gu` | Gujarati (ગુજરાતી) | Sarvam Bulbul / Whisper |
### Clinical Specialties Supported
- `general_medicine` - General Medicine / Primary Care
- `cardio` - Cardiology
- `ortho` - Orthopedics
- `peds` - Pediatrics
- `derma` - Dermatology
- `neuro` - Neurology
- `gastro` - Gastroenterology
- `ent` - ENT / Otorhinolaryngology
- `gyn` - Obstetrics & Gynecology
---
## 10. Client Integration Code Samples
### 10.1 Python Client (`httpx`)
```python
import httpx
import asyncio
API_BASE = "http://127.0.0.1:8000"
async def main():
async with httpx.AsyncClient() as client:
# 1. Start Session
start_res = await client.post(f"{API_BASE}/v1/voice-onboarding/start", json={
"patient": {"id": "P-1001", "name": "Deepak Sharma", "age": 45, "gender": "male"},
"category": {"type": "speciality", "code": "cardio", "name": "Cardiology"},
"language": "hi-IN"
})
session = start_res.json()
session_id = session["session_id"]
print(f"Session started: {session_id}")
print(f"Greeting: {session['greeting_text']}")
# 2. Send Turn
turn_res = await client.post(
f"{API_BASE}/v1/voice-onboarding/{session_id}/turn",
data={"text_fallback": "मुझे सीने में भारीपन महसूस हो रहा है"}
)
turn_data = turn_res.json()
print(f"AI Response: {turn_data['ai_text']}")
# 3. Finalize & Submit
complete_res = await client.post(
f"{API_BASE}/v1/voice-onboarding/{session_id}/complete",
json={"confirmed": True}
)
print(f"EHR Saved: {complete_res.json()}")
if __name__ == "__main__":
asyncio.run(main())
```
### 10.2 JavaScript / TypeScript (`fetch`)
```typescript
const API_BASE = "http://127.0.0.1:8000";
// Start voice onboarding
export async function startVoiceOnboarding(patient: any, category: any, language = "en-IN") {
const res = await fetch(`${API_BASE}/v1/voice-onboarding/start`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ patient, category, language })
});
return res.json();
}
// Send audio turn
export async function sendVoiceTurn(sessionId: string, audioBlob?: Blob, textFallback?: string) {
const formData = new FormData();
if (audioBlob) formData.append("audio", audioBlob, "speech.webm");
if (textFallback) formData.append("text_fallback", textFallback);
const res = await fetch(`${API_BASE}/v1/voice-onboarding/${sessionId}/turn`, {
method: "POST",
body: formData
});
return res.json();
}
// Upload medical report
export async function uploadPrescription(sessionId: string, file: File) {
const formData = new FormData();
formData.append("file", file);
const res = await fetch(`${API_BASE}/v1/documents/${sessionId}/upload`, {
method: "POST",
body: formData
});
return res.json();
}
// Complete onboarding
export async function completeOnboarding(sessionId: string, confirmed: boolean) {
const res = await fetch(`${API_BASE}/v1/voice-onboarding/${sessionId}/complete`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ confirmed })
});
return res.json();
}
```
### 10.3 cURL Commands
#### Start Session:
```bash
curl -X POST "http://127.0.0.1:8000/v1/voice-onboarding/start" \
-H "Content-Type: application/json" \
-d '{
"patient": {"id": "P-101", "name": "Arun Kumar", "age": 40, "gender": "male"},
"category": {"type": "general", "code": "general_medicine", "name": "General Medicine"},
"language": "en-IN"
}'
```
#### Send Voice Turn (with text fallback):
```bash
curl -X POST "http://127.0.0.1:8000/v1/voice-onboarding/VOICE-XXXXX/turn" \
-F "text_fallback=I have had high fever and sore throat since yesterday"
```
#### Upload Prescription:
```bash
curl -X POST "http://127.0.0.1:8000/v1/documents/VOICE-XXXXX/upload" \
-F "file=@/path/to/prescription.pdf"
```
#### Confirm & Submit:
```bash
curl -X POST "http://127.0.0.1:8000/v1/voice-onboarding/VOICE-XXXXX/complete" \
-H "Content-Type: application/json" \
-d '{"confirmed": true}'
```
-42
View File
@@ -1,42 +0,0 @@
# Production Dockerfile for AI Patient Onboarding FastAPI Backend
FROM python:3.11-slim as base
# Set environment variables
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
APP_ENV=production \
PORT=8000
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
&& rm -rf /var/lib/apt/lists/*
# Copy dependency requirements
COPY requirements.txt .
# Install Python dependencies
RUN pip install --no-cache-dir -r requirements.txt
# Copy application source code
COPY ./app ./app
COPY ./data ./data
# Create non-root system user and group for security
RUN groupadd -g 999 appuser && \
useradd -r -u 999 -g appuser appuser && \
mkdir -p /app/data /app/temp_audio /app/temp_docs && \
chown -R appuser:appuser /app
USER appuser
EXPOSE 8000
# Health check instruction
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD curl -f http://localhost:8000/health || exit 1
# Production Uvicorn Server Command
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
@@ -1,65 +0,0 @@
# Postman Collection Import & Usage Guide
**API System**: `AI Patient Onboarding API v1.0.0`
**Environment**: Local / Staging / Production
**Collection File**: [`AI-Patient-Onboarding.postman_collection.json`](file:///c:/Users/Lenovo/New%20folder%20%282%29/ai-patient-onboarding/AI-Patient-Onboarding.postman_collection.json)
**Environment File**: [`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)
---
## 1. Quick Start Import Instructions
1. Open **Postman Desktop Application**.
2. Click **Import** (top-left button).
3. Select both files:
- `AI-Patient-Onboarding.postman_collection.json`
- `AI-Patient-Onboarding-Local.postman_environment.json`
4. Select environment **"AI-Patient-Onboarding-Local"** from the environment dropdown in the top-right corner.
---
## 2. Collection Hierarchy
```
AI Patient Onboarding API
├── 00 - Health
│ └── Get Service Health Status (GET /health)
├── 01 - Authentication
│ └── Test Bearer Auth Token Validation
├── 02 - Patient Onboarding (Text)
│ ├── Start Text Onboarding (POST /v1/onboarding/start) -> auto-saves {{session_id}}
│ ├── Send Patient Message (POST /v1/onboarding/{{session_id}}/message)
│ └── Complete Text Onboarding (POST /v1/onboarding/{{session_id}}/complete)
├── 03 - Voice Onboarding
│ ├── Start Voice Onboarding (POST /v1/voice-onboarding/start) -> auto-saves {{voice_session_id}} & {{audio_id}}
│ ├── Voice Turn - Text Fallback (POST /v1/voice-onboarding/{{voice_session_id}}/turn)
│ ├── Complete Voice Onboarding (POST /v1/voice-onboarding/{{voice_session_id}}/complete)
│ └── Get Synthesized Audio File (GET /v1/voice-onboarding/audio/{{audio_id}})
├── 04 - Documents
│ └── Upload Patient Document (POST /v1/documents/{{session_id}}/upload)
├── 05 - Validation Tests
│ └── Reject Negative Age Input (age = -50 -> 422)
├── 06 - Authorization / IDOR Security Tests
│ └── Reject Cross-Patient IDOR Access (Patient B -> Patient A Session -> 403)
└── 07 - Security & Auth Tests
└── Reject Unauthenticated Request (Missing Token -> 401)
```
---
## 3. Dynamic Variable Chaining
The collection automatically passes state between requests using Postman Environment variables:
- Running `Start Text Onboarding` extracts `session_id` from response JSON and sets `{{session_id}}`.
- Subsequent requests (`Send Patient Message`, `Complete Text Onboarding`, `Upload Patient Document`) dynamically consume `{{session_id}}`.
- Running `Start Voice Onboarding` extracts `session_id` and `audio_id` into `{{voice_session_id}}` and `{{audio_id}}`.
---
## 4. Running via Postman Collection Runner / Newman CLI
If Newman CLI is installed locally:
```bash
newman run AI-Patient-Onboarding.postman_collection.json -e AI-Patient-Onboarding-Local.postman_environment.json
```
@@ -1,21 +0,0 @@
"""
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",
]
@@ -1,84 +0,0 @@
"""
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
@@ -1,26 +0,0 @@
"""
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
@@ -1,98 +0,0 @@
"""
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
@@ -1,168 +0,0 @@
"""
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."
@@ -1,57 +0,0 @@
"""
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: ["ನಿಮಗೆ ಯಾವುದೇ ಔಷಧಿ ಅಥವಾ ಆಹಾರ ಅಲರ್ಜಿಗಳಿವೆಯೇ?"],
}
}
@@ -1,169 +0,0 @@
"""
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)
@@ -1,40 +0,0 @@
"""
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
)
@@ -1,21 +0,0 @@
"""
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",
]
@@ -1,56 +0,0 @@
"""
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
@@ -1,83 +0,0 @@
"""
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
@@ -1,68 +0,0 @@
"""
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
@@ -1,71 +0,0 @@
"""
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"
@@ -1,54 +0,0 @@
"""
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
@@ -1,117 +0,0 @@
"""
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
+11 -40
View File
@@ -1,4 +1,3 @@
import asyncio
import json
import base64
from app.config import GEMINI_API_KEY, GEMINI_MODEL
@@ -8,7 +7,7 @@ class GeminiClient:
def __init__(self):
self.api_key = GEMINI_API_KEY
self.model = GEMINI_MODEL or "gemini-1.5-flash"
self.model = GEMINI_MODEL
async def extract_from_document(
self,
@@ -16,7 +15,7 @@ class GeminiClient:
mime_type: str,
patient_context: dict,
) -> dict:
if not self.api_key or self.api_key in ["your_gemini_key", "your_gemini_api_key", ""]:
if not self.api_key or self.api_key == "your_gemini_key":
return {
"document_type": "medical_report",
"extracted_text": "Sample HbA1c 7.2%, Blood Pressure 130/85 mmHg",
@@ -30,10 +29,9 @@ class GeminiClient:
}
try:
from google import genai
from google.genai import types
client = genai.Client(api_key=self.api_key)
import google.generativeai as genai
genai.configure(api_key=self.api_key)
model = genai.GenerativeModel(self.model)
prompt = f"""
You are a medical document extraction AI.
@@ -57,18 +55,15 @@ Return ONLY JSON:
"summary": "short clinical summary of the document"
}}
"""
part = types.Part.from_bytes(data=file_bytes, mime_type=mime_type)
response = await asyncio.wait_for(
client.aio.models.generate_content(
model=self.model,
contents=[part, prompt]
),
timeout=4.0
)
part = {
"mime_type": mime_type,
"data": file_bytes,
}
response = await model.generate_content_async([part, prompt])
cleaned_text = response.text.replace("```json", "").replace("```", "").strip()
return json.loads(cleaned_text)
except Exception as e:
print(f"[Gemini Extraction Warning]: {e}")
print(f"[Gemini Extraction Error]: {e}")
return {
"document_type": "unknown",
"findings": [],
@@ -76,27 +71,3 @@ Return ONLY JSON:
"allergies_found": [],
"summary": f"Could not extract document details: {e}",
}
async def generate_response(
self,
system_prompt: str,
user_message: str,
) -> str:
if not self.api_key or self.api_key in ["your_gemini_key", "your_gemini_api_key", ""]:
raise ValueError("Gemini API key not configured")
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(
client.aio.models.generate_content(
model=self.model,
contents=full_prompt,
config=config,
),
timeout=3.5
)
return response.text
@@ -33,7 +33,7 @@ class OpenAIClient:
],
response_format={"type": "json_object"},
temperature=0.35,
max_completion_tokens=500,
max_completion_tokens=140,
)
return response.choices[0].message.content
+162 -535
View File
@@ -1,561 +1,188 @@
import asyncio
import json
import re
import os
from typing import Dict, Any, List, Optional, Union
from pydantic import ValidationError
from app.config import OPENAI_API_KEY, GEMINI_API_KEY, GEMINI_MODEL
from app.ai.openai_client import OpenAIClient
from app.ai.gemini_client import GeminiClient
from app.schemas.onboarding import AIOnboardingResponse
from app.services.contextual_normalizer import (
normalize_duration_from_message,
detect_previous_question_intent,
is_duration_expression,
is_severity_expression,
is_location_expression,
is_negative_expression,
is_casual_filler,
extract_severity_from_message,
extract_location_from_message,
)
from app.ai.prompt_rules.safety_rules import (
EMERGENCY_KEYWORDS_EN,
EMERGENCY_KEYWORDS_INDIC,
CRISIS_KEYWORDS_EN,
CRISIS_KEYWORDS_INDIC,
)
from app.ai.prompts import build_system_prompt
# Import domain and sub-modules
from app.ai.safety.emergency import check_emergency
from app.ai.safety.crisis import check_crisis
from app.ai.extraction.multilingual_normalizer import normalize_text, detect_language
from app.ai.extraction.symptom_extractor import extract_symptoms
from app.ai.extraction.medication_extractor import extract_medications
from app.ai.extraction.allergy_extractor import extract_allergies
from app.ai.extraction.history_extractor import extract_history
from app.ai.extraction.negation_engine import is_negated, extract_family_subject
from app.ai.conversation.completion_engine import check_required_missing, field_status as _field_status, is_onboarding_complete
from app.ai.conversation.question_engine import pick_best_question as _pick_best_question
from app.ai.conversation.intent_tracker import get_last_ai_intent
from app.ai.conversation.response_formatter import format_conversational_response
from app.ai.conversation.next_best_action import NextBestActionEngine
from app.ai.conversation.response_generator import ResponseGenerator
from app.domain.models.contracts import ResponseContext
from app.ai.providers.openai import OpenAIProvider
from app.ai.providers.gemini import GeminiProvider
SPECIALTY_RULES = [
{
"code": "mental_health",
"name": "Mental Health & Psychiatry",
"keywords": [
"depression", "depressed", "mental", "anxiety", "panic", "stress", "sadness", "suicidal", "suicide", "sleep", "insomnia", "emotional", "mood", "lonely", "hopeless",
"மன அழுத்தம்", "மனச்சோர்வு", "தூக்கமின்மை", "கவலை", "உணர்ச்சி", "தற்கொலை", "டிப்ரெஷன்", "டிப்ரஷன்",
"डिप्रेशन", "तनाव", "चिंता", "उदासी", "नींद न आना", "आत्महत्या",
"మానసిక ఒత్తిడి", "డిప్రెషన్", "నిద్రలేమి",
"ಮನೋಒತ್ತಡ", "ಖಿನ್ನತೆ", "ನಿದ್ರಾಹೀನತೆ"
]
},
{
"code": "diabetology",
"name": "Diabetology & Endocrinology (Sugar / Diabetes)",
"keywords": [
"sugar level", "diabetes", "diabetic", "hba1c", "glycomet", "blood sugar", "high glucose", "low glucose", "insulin", "chakkara noyu", "cheeni bimari", "fasting sugar", "ppbs", "metformin", "teneligliptin", "glimepiride", "janumet",
"சர்க்கரை", "நீரிழிவு", "சர்க்கரை நோய்", "शुगर", "मधुमेह", "చక్కెర", "సక్కರೆ"
]
},
{
"code": "cardiology",
"name": "Cardiology (Heart Care)",
"keywords": [
"chest pain", "heart", "palpitation", "breathless", "cardiac", "chest pressure",
"நெஞ்சு வலி", "இருதய வலி", "மூச்சு திணறல்", "படபடப்பு", "படபடன்னு", "நெஞ்சு படபடன்னு", "நெஞ்சு படபடப்பு",
"छाती में दर्द", "दिल की धड़कन", "గుండె నొప్పి"
]
},
{
"code": "dermatology",
"name": "Dermatology (Skin & Hair Care)",
"keywords": [
"skin", "rash", "itching", "eczema", "acne", "pimples", "fungal", "allergy",
"தோல் அரிப்பு", "தோலில் அரிப்பு", "தழும்பு", "படை", "கொப்புளம்", "அரிப்பு", "त्वचा में खुजली", "दाद", "पिंपल्स"
]
},
{
"code": "neurology",
"name": "Neurology (Brain & Nervous System)",
"keywords": [
"headache", "dizziness", "seizure", "stroke", "numbness", "paralysis", "migraine",
"தலைவலி", "மயக்கம்", "பக்கவாதம்", "வலிப்பு", "கை கால் மரத்துப்போதல்", "தலை சுத்துது", "தலைசுற்றல்", "தலை பாரம்", "सिरदर्द", "चक्कर", "दौरा"
]
},
{
"code": "orthopedics",
"name": "Orthopedics (Bone & Joint Care)",
"keywords": [
"knee", "joint pain", "bone", "fracture", "back pain", "shoulder", "arthritis", "sprain", "swelling",
"மூட்டு வலி", "இடுப்பு வலி", "முதுகு வலி", "எலும்பு முறிவு", "घुटने का दर्द", "जोड़ों का दर्द", "हड्डी"
]
},
{
"code": "gastroenterology",
"name": "Gastroenterology (Stomach & Digestive Care)",
"keywords": [
"stomach pain", "stomach ache", "acidity", "vomiting", "diarrhea", "indigestion", "gastric", "loose motion", "constipation",
"வயிற்று வலி", "வாந்தி", "அஜீரணம்", "வயிற்றுப்போக்கு", "गैस", "पेट दर्द", "உल्टी", "दस्त"
]
},
{
"code": "pulmonology",
"name": "Pulmonology (Lungs & Respiratory)",
"keywords": [
"cough", "asthma", "wheezing", "phlegm", "shortness of breath", "cold", "flu",
"இருமல்", "சளி", "ஆஸ்துமா", "இளைப்பு", "खांसी", "दमा", "कफ", "सांस फूलना"
]
},
{
"code": "general",
"name": "General Physician & OPD Intake",
"keywords": [
"fever", "weakness", "body pain", "fatigue", "tired", "chills", "weight loss", "bp",
"காய்ச்சல்", "உடல் வலி", "உடம்பு வலி", "சோர்வு", "குளிர்", "बुखार", "कमजोरी", "बदन दर्द"
]
}
]
def classify_specialty_from_symptoms(text: str) -> Dict[str, str]:
if not text:
return {"code": "general", "name": "General Physician & OPD Intake"}
msg_lower = text.lower()
for spec in SPECIALTY_RULES:
if spec["code"] in ("general", "general_medicine"):
continue
for kw in spec["keywords"]:
pattern = rf"\b{re.escape(kw.lower())}\b"
if re.search(pattern, msg_lower) or kw.lower() in msg_lower:
code = spec["code"]
if code == "psychiatry":
code = "mental_health"
elif code == "general_medicine":
code = "general"
return {"code": code, "name": spec["name"]}
return {"code": "general", "name": "General Physician & OPD Intake"}
def _normalize_key(name: str) -> str:
if not name:
return ""
n = name.lower().strip()
synonyms = {
"headache": "headache", "head pain": "headache", "migraine": "headache",
"தலைவலி": "headache", "தலை பாரம்": "headache", "सिरदर्द": "headache", "తలనొప్పి": "headache", "ತಲೆನೋವು": "headache",
"fever": "fever", "feverish": "fever", "காய்ச்சல்": "fever", "बुखार": "fever", "జ్వరం": "fever", "ಜ್ವರ": "fever",
"cough": "cough", "coughing": "cough", "இருமல்": "cough", "खांसी": "cough", "దగ్గు": "cough", "ಕೆಮ್ಮು": "cough",
"chest pain": "chest pain", "நெஞ்சு வலி": "chest pain", "छाती में दर्द": "chest pain",
"stomach pain": "stomach pain", "வயிற்று வலி": "stomach pain", "पेट दर्द": "stomach pain",
"vomiting": "vomiting", "vomit": "vomiting", "vomited": "vomiting", "வாந்தி": "vomiting", "உल्टी": "vomiting",
"dizziness": "dizziness", "giddiness": "dizziness", "தலை சுத்துது": "dizziness", "தலைசுற்றல்": "dizziness", "மயக்கம்": "dizziness", "चक्कर": "dizziness",
"diabetes": "diabetes", "sugar": "diabetes", "சர்க்கரை": "diabetes",
"hypertension": "hypertension", "bp": "hypertension", "high bp": "hypertension", "பிபி": "hypertension",
}
return synonyms.get(n, n)
def merge_state_updates(existing_state: Dict[str, Any], new_updates: Dict[str, Any]) -> Dict[str, Any]:
if not existing_state:
existing_state = {}
if not new_updates:
return existing_state
merged = dict(existing_state)
for key, val in new_updates.items():
if val is None:
continue
if key == "reason_for_visit":
if not merged.get("reason_for_visit") or (isinstance(val, str) and val.strip()):
merged["reason_for_visit"] = val
continue
if key in ["medical_history", "medications", "allergies"]:
if isinstance(val, dict) and val.get("status") == "confirmed_none":
existing_val = merged.get(key)
if isinstance(existing_val, list) and len(existing_val) > 0:
conflicts = merged.get("conflicts", [])
conflicts.append({"field": key, "type": "conflict_none_vs_reported", "item": existing_val[0]})
merged["conflicts"] = conflicts
if not existing_val or _field_status(existing_val) == "not_collected":
merged[key] = val
continue
if isinstance(val, dict) and val.get("status") == "reported":
merged[key] = val
continue
if key == "symptoms":
existing_syms = merged.get("symptoms", [])
if not isinstance(existing_syms, list):
existing_syms = []
new_syms = val if isinstance(val, list) else [val]
merged_syms_map: Dict[str, Dict[str, Any]] = {}
for s in existing_syms:
if isinstance(s, dict) and s.get("name"):
norm_k = _normalize_key(s["name"])
merged_syms_map[norm_k] = dict(s)
for s in new_syms:
if isinstance(s, dict) and s.get("name"):
norm_k = _normalize_key(s["name"])
if norm_k in merged_syms_map:
existing_obj = merged_syms_map[norm_k]
for attr_k, attr_v in s.items():
if attr_v:
existing_obj[attr_k] = attr_v
else:
merged_syms_map[norm_k] = dict(s)
elif isinstance(s, dict):
if merged_syms_map:
first_k = list(merged_syms_map.keys())[0]
for attr_k, attr_v in s.items():
if attr_v:
merged_syms_map[first_k][attr_k] = attr_v
merged["symptoms"] = list(merged_syms_map.values()) if merged_syms_map else existing_syms
continue
if key in ["medical_history", "medications", "allergies"]:
existing_field = merged.get(key)
if isinstance(existing_field, dict) and existing_field.get("status") == "confirmed_none":
new_items = val if isinstance(val, list) else [val]
if new_items:
conflicts = merged.get("conflicts", [])
conflicts.append({"field": key, "type": "conflict_reported_vs_none", "item": new_items[0]})
merged["conflicts"] = conflicts
merged[key] = new_items
continue
existing_list = merged.get(key, [])
if isinstance(existing_field, dict) and "items" in existing_field:
existing_list = existing_field.get("items", [])
if not isinstance(existing_list, list):
existing_list = [existing_list] if existing_list else []
new_items = val if isinstance(val, list) else [val]
# Merge items by condition/name key to prevent duplicates and support enrichment
item_map: Dict[str, Dict[str, Any]] = {}
for item in existing_list:
if isinstance(item, dict):
k = item.get("condition") or item.get("name")
if k:
item_map[k] = dict(item)
for item in new_items:
if isinstance(item, dict):
k = item.get("condition") or item.get("name")
if k:
if k in item_map:
for ik, iv in item.items():
if iv:
item_map[k][ik] = iv
else:
item_map[k] = dict(item)
if item_map:
merged[key] = list(item_map.values())
else:
merged[key] = existing_list + [v for v in new_items if v not in existing_list]
continue
if key in ["surgical_history", "previous_episodes", "family_history", "additional_information"]:
existing_list = merged.get(key, [])
if not isinstance(existing_list, list):
existing_list = [existing_list] if existing_list else []
if isinstance(val, list):
merged[key] = existing_list + [v for v in val if v not in existing_list]
else:
if val not in existing_list:
merged[key] = existing_list + [val]
return merged
def _field_has_items(val: Any) -> bool:
return _field_status(val) in ["reported", "confirmed_none"]
def _any_symptom_has_duration(symptoms: Any) -> bool:
if isinstance(symptoms, list):
return any(isinstance(s, dict) and bool(s.get("duration")) for s in symptoms)
return False
def _stage_for_missing(missing_field: str) -> str:
mapping = {
"reason_for_visit": "reason_for_visit",
"symptoms": "symptom_collection",
"duration": "symptom_details",
"medical_history": "medical_history",
"medications": "medications",
"allergies": "allergies",
}
return mapping.get(missing_field, "symptom_details")
from app.ai.validator import validate_ai_response
from app.config import OPENAI_API_KEY, GEMINI_API_KEY, GEMINI_MODEL, ANTHROPIC_API_KEY, ANTHROPIC_MODEL
from app.specialties.registry import get_specialty_config
class AIOrchestrator:
def __init__(self):
self.openai_provider = OpenAIProvider(timeout_seconds=4.0)
self.gemini_provider = GeminiProvider(timeout_seconds=4.0)
self.openai = OpenAIClient()
self.gemini = GeminiClient()
self.nba_engine = NextBestActionEngine()
self.response_generator = ResponseGenerator()
async def process(
self,
session: Any,
state: Any,
conversation_history: List[Dict[str, Any]],
user_message: str
) -> Dict[str, Any]:
lang = getattr(session, "language", "en") or "en"
current_state = json.loads(state.state_json) if hasattr(state, "state_json") and state.state_json else {}
# -------------------------------------------------------------
# STEP 1: CRISIS DETECTION (PRECEDENCE OVER ALL ONBOARDING)
# -------------------------------------------------------------
is_crisis, crisis_reason, crisis_reply = check_crisis(user_message, lang)
if is_crisis:
return {
"reply": format_conversational_response(crisis_reply, lang, scenario="crisis"),
"next_stage": "crisis_handling",
"state_updates": {},
"completed": True,
"is_crisis": True,
"suggested_specialty": None
}
# -------------------------------------------------------------
# STEP 2: EMERGENCY DETECTION (PRECEDENCE OVER ALL ONBOARDING)
# -------------------------------------------------------------
is_emergency, emergency_reason, emergency_reply = check_emergency(user_message, lang)
if is_emergency:
return {
"reply": format_conversational_response(emergency_reply, lang, scenario="emergency"),
"next_stage": "emergency_referral",
"state_updates": {},
"completed": True,
"is_emergency": True,
"suggested_specialty": None
}
# -------------------------------------------------------------
# STEP 3: MULTILINGUAL NORMALIZATION & FACT EXTRACTION
# -------------------------------------------------------------
normalized_msg = normalize_text(user_message)
last_ai_intent = get_last_ai_intent(conversation_history)
last_ai_message = conversation_history[-1].get("message") if conversation_history else None
extracted_updates = self._extract_facts_from_message(
user_message=user_message,
normalized_msg=normalized_msg,
last_ai_message=last_ai_message,
current_state=current_state
session,
state,
conversation_history,
user_message,
):
specialty = get_specialty_config(
session.category_code
)
# -------------------------------------------------------------
# STEP 4: STATE MERGE & SPECIALTY SELECTION
# -------------------------------------------------------------
merged_preview = merge_state_updates(current_state, extracted_updates)
# Specialty classification (preserving existing non-general specialty)
existing_code = getattr(session, "category_code", "general_medicine")
existing_name = getattr(session, "category_name", "General Medicine")
is_duration_msg = is_duration_expression(user_message) or bool(normalize_duration_from_message(user_message))
if existing_code and existing_code != "general_medicine" and is_duration_msg:
spec_result = {"code": existing_code, "name": existing_name}
else:
raw_spec = classify_specialty_from_symptoms(user_message)
if existing_code and existing_code != "general_medicine" and raw_spec["code"] == "general_medicine":
spec_result = {"code": existing_code, "name": existing_name}
else:
spec_result = raw_spec
is_psych = (spec_result.get("code") == "psychiatry")
# -------------------------------------------------------------
# STEP 5: DETERMINISTIC COMPLETION & MISSING FIELD EVALUATION
# -------------------------------------------------------------
required_missing = check_required_missing(merged_preview)
action_type, target_field = self.nba_engine.determine_next_action(
current_state=merged_preview,
is_emergency=False,
is_crisis=False,
is_psych=is_psych,
missing_fields=required_missing
current_state = json.loads(
state.state_json
)
completed = (action_type == "COMPLETE" or len(required_missing) == 0)
if completed:
conclusion_reply = _pick_best_question([], lang, is_psych=is_psych, current_state=merged_preview)
formatted_reply = format_conversational_response(conclusion_reply, lang, scenario="completion")
return {
"reply": formatted_reply,
"next_stage": "confirmation",
"state_updates": extracted_updates,
"completed": True,
"suggested_specialty": spec_result
}
# Select single next question
next_q = _pick_best_question(required_missing, lang, is_psych=is_psych, current_state=merged_preview)
next_stage = _stage_for_missing(target_field or required_missing[0])
# -------------------------------------------------------------
# STEP 6: AI RESPONSE GENERATION WITH HARD 4s TIMEOUT & FALLBACK
# -------------------------------------------------------------
context = {
"specialty_name": spec_result.get("name", "General Medicine"),
"patient": {"name": getattr(session, "patient_name", "Patient"), "age": 30, "gender": "Unknown"},
"language": lang,
"current_state": merged_preview,
"conversation_history": conversation_history or [],
"patient": {
"id": session.patient_id,
"name": session.patient_name,
"age": session.age,
"gender": session.gender,
},
"category": {
"type": session.category_type,
"code": session.category_code,
"name": session.category_name,
},
"specialty_config": specialty,
"language": session.language,
"current_stage": session.stage,
"current_state": current_state,
"conversation_history": conversation_history,
"new_patient_message": user_message,
"target_question": next_q,
}
system_prompt = build_system_prompt(context)
raw_text = await self.openai_provider.generate_response_with_timeout(system_prompt, user_message)
if not raw_text:
raw_text = await self.gemini_provider.generate_response_with_timeout(system_prompt, user_message)
prompt = build_system_prompt(context)
if raw_text:
try:
cleaned = raw_text.strip().replace("```json", "").replace("```", "").strip()
parsed = json.loads(cleaned)
validated = AIOnboardingResponse(**parsed)
llm_dict = validated.model_dump()
llm_dict["state_updates"] = merge_state_updates(extracted_updates, llm_dict.get("state_updates") or {})
llm_dict["reply"] = format_conversational_response(llm_dict["reply"], lang, scenario="normal")
llm_dict["completed"] = False
llm_dict["next_stage"] = next_stage
llm_dict["suggested_specialty"] = spec_result
return llm_dict
except Exception as e:
print(f"[AIOrchestrator LLM Validation Error]: {e}")
# Skip invalid/exhausted API keys to prevent 20+ second HTTP timeouts
# 1. Fallback simulator (0.001s instant response time)
parsed = self._simulate_specialty_response(session, specialty, user_message, len(conversation_history))
validated = validate_ai_response(parsed)
return validated.model_dump()
# -------------------------------------------------------------
# STEP 7: DETERMINISTIC FALLBACK (INSTANT)
# -------------------------------------------------------------
formatted_reply = format_conversational_response(next_q, lang, scenario="normal")
def _simulate_specialty_response(self, session, specialty, user_message, turn_count=0):
stage = session.stage
code = specialty.get("code", "general")
lang = (session.language or "ta-IN").lower()
msg_lower = (user_message or "").lower().strip()
# Broad negative word matching across Indian languages & dialects
negative_keywords = [
"no", "no more", "nothing", "nothing else", "none", "no illness", "no problem",
"இல்லை", "இல்லைங்க", "இல்ல", "ஒன்றுமில்லை", "ஒன்னுமில்ல", "ஏதுமில்லை",
"नहीं", "कुछ नहीं", "कोई नहीं", "नाही",
"లేదు", "ఏమీ లేదు", "లేదండి",
"ಇಲ್ಲ", "ಇಲ್ಲವೇ ಇಲ್ಲ", "ಏನೂ ಇಲ್ಲ",
"ഇല്ല", "ഒന്നുമില്ല", "ഇല്ല കട്ടായം",
"না", "কিছু না", "নাই",
"नाही", "काही नाही",
"નથી", "કાંઈ નથી",
"ਨਹੀਂ", "ਕੁਝ ਨਹੀਂ"
]
is_negative = any(k in msg_lower for k in negative_keywords)
# Localized clinical phrases (Spoken & Caring Tone)
if lang.startswith("ta"):
conclusion_text = "மிக்க நன்றி. உங்கள் உடல்நல விவரங்கள் சிறப்பாக பதிவு செய்யப்பட்டுள்ளன. மருத்துவர் விரைவில் உங்களைப் பரிசீலிப்பார்."
symptom_q = "சரிங்க, உங்களோட அறிகுறிகள் பற்றி சொன்னீங்க. இந்த பிரச்சனை எத்தனை நாளா இருக்கு? இதுக்காக ஏதாச்சும் மாத்திரை சாப்பிட்டீங்களா?"
med_q = "சரிங்க. உங்களுக்கு வேற ஏதேனும் உடம்பு பிரச்சனைகள் அல்லது மாத்திரை ஒவ்வாமை (Allergy) இருக்கா?"
elif lang.startswith("hi"):
conclusion_text = "धन्यवाद। आपकी स्वास्थ्य जानकारी सफलतापूर्वक दर्ज कर ली गई है। डॉक्टर जल्द ही आपकी समीक्षा करेंगे।"
symptom_q = "जी समझ गया। आपको यह समस्या कितने दिनों से है और क्या इसके लिए आपने कोई दवाई ली है?"
med_q = "ठीक है। क्या आपको किसी दवाई से एलर्जी या कोई अन्य बीमारी है?"
elif lang.startswith("te"):
conclusion_text = "చాలా ధన్యవాదాలు. మీ ఆరోగ్య వివరాలు నమోదు చేయబడ్డాయి. వైద్యులు త్వరలోనే పరిశీలిస్తారు."
symptom_q = "సరేనండి, ఈ సమస్య మీకు ఎన్ని రోజుల నుండి ఉంది? దీని కోసం ఏవైనా మందులు వాడారా?"
med_q = "ధన్యవాదాలు. మీకు ఏవైనా మందుల పడనితనం (Allergy) లేదా ఇతర ఆరోగ్య సమస్యలు ఉన్నాయా?"
elif lang.startswith("kn"):
conclusion_text = "ತುಂಬಾ ಧನ್ಯವಾದಗಳು. ನಿಮ್ಮ ಆರೋಗ್ಯದ ಮಾಹಿತಿ ದಾಖಲಾಗಿದೆ. ವೈದ್ಯರು ಶೀಘ್ರದಲ್ಲೇ ಪರಿಶೀಲಿಸಲಿದ್ದಾರೆ."
symptom_q = "ಸರಿ, ಈ ತೊಂದರೆ ನಿಮಗೆ ಎಷ್ಟು ದಿನಗಳಿಂದ ಇದೆ? ಯಾವುದಾದರೂ ಮಾತ್ರೆ ತೆಗೆದುಕೊಂಡಿದ್ದೀರಾ?"
med_q = "ಧನ್ಯವಾದಗಳು. ನಿಮಗೆ ಯಾವುದಾದರೂ ಔಷಧಿ ಅಲರ್ಜಿ (Allergy) ಅಥವಾ ಇತರ ಆರೋಗ್ಯ ಸಮಸ್ಯೆ ಇದೆಯೇ?"
elif lang.startswith("ml"):
conclusion_text = "വളരെ നന്ദി. താങ്കളുടെ വിവരങ്ങൾ രേഖപ്പെടുത്തിയിട്ടുണ്ട്. ഡോക്ടർ ഉടൻ പരിശോധിക്കുന്നതാണ്."
symptom_q = "ശരി, ഈ ലക്ഷണങ്ങൾ എത്ര ദിവസമായിട്ടുണ്ട്? ഇതിനായി എന്തെങ്കിലും മരുന്ന് കഴിച്ചിട്ടുണ്ടോ?"
med_q = "നന്ദി. താങ്കൾക്ക് എന്തെങ്കിലും മരുന്ന് അലർജിയോ മറ്റ് ആരോഗ്യ പ്രശ്നങ്ങളോ ഉണ്ടോ?"
elif lang.startswith("bn"):
conclusion_text = "অনেক ধন্যবাদ। আপনার স্বাস্থ্য সম্পর্কিত তথ্য জমা নেওয়া হয়েছে। চিকিৎসক শীঘ্রই পর্যালোচনা করবেন।"
symptom_q = "ঠিক আছে, এই সমস্যা আপনার কত দিন ধরে হচ্ছে? এর জন্য কোনো ওষুধ খেয়েছেন কি?"
med_q = "ধন্যবাদ। আপনার কি কোনো ওষুধে অ্যালার্জি বা অন্য কোনো রোগ আছে?"
elif lang.startswith("mr"):
conclusion_text = "खूप खूप धन्यवाद. आपली आरोग्य माहिती नोंदवली गेली आहे. डॉक्टर लवकरच तपासणी करतील."
symptom_q = "होय, हा त्रास तुम्हाला किती दिवसांपासून होत आहे आणि काही औषध घेतले आहे का?"
med_q = "धन्यवाद. आपल्याला काही औषधांची ॲलर्जी किंवा इतर कोणताही आजार आहे का?"
elif lang.startswith("gu"):
conclusion_text = "ખૂબ ખૂબ આભાર. આપની આરોગ્ય વિગતો નોંધાઈ ગઈ છે. ડોક્ટર જલ્દી તપાસ કરશે."
symptom_q = "બરાબર, આ તકલીફ તમને કેટલા દિવસથી છે અને કોઈ દવા લીધી છે?"
med_q = "આભાર. તમને કોઈ દવાની એલર્જી કે અન્ય કોઈ બીમારી છે?"
elif lang.startswith("pa"):
conclusion_text = "ਬਹੁਤ ਧੰਨਵਾਦ। ਤੁਹਾਡੀ ਜਾਣਕਾਰੀ ਦਰਜ ਕਰ ਲਈ ਗਈ ਹੈ। ਡਾਕਟਰ ਜਲਦੀ ਹੀ ਜਾਂਚ ਕਰਨਗੇ।"
symptom_q = "ਜੀ ਠੀਕ ਹੈ, ਇਹ ਸਮੱਸਿਆ ਤੁਹਾਨੂੰ ਕਿੰਨੇ ਦਿਨਾਂ ਤੋਂ ਹੈ ਅਤੇ ਕੀ ਕੋਈ ਦਵਾਈ ਲਈ ਹੈ?"
med_q = "ਧੰਨਵਾਦ। ਕੀ ਤੁਹਾਨੂੰ ਕਿਸੇ ਦਵਾਈ ਤੋਂ ਐਲਰਜੀ ਜਾਂ ਕੋਈ ਹੋਰ ਬੀਮਾਰੀ ਹੈ?"
else:
conclusion_text = "Thank you. I have recorded your intake details for the doctor to review."
symptom_q = "How long have you had these symptoms, and have you taken any medicines?"
med_q = "Thank you. Do you have any known drug allergies or existing health conditions?"
# Fast conclusion if patient says "No" or already had 6 turns
if is_negative or turn_count >= 6:
return {
"reply": formatted_reply,
"next_stage": next_stage,
"state_updates": extracted_updates,
"completed": False,
"suggested_specialty": spec_result
"reply": conclusion_text,
"next_stage": "confirmation",
"state_updates": {
"reason_for_visit": None,
"symptoms": [],
"medical_history": ["None reported"] if is_negative else [],
"medications": [],
"allergies": ["None"] if is_negative else []
},
"completed": True
}
def _extract_facts_from_message(
self,
user_message: str,
normalized_msg: str,
last_ai_message: Optional[str] = None,
current_state: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
msg_lower = normalized_msg.lower().strip()
updates: Dict[str, Any] = {}
if stage == "greeting":
return {
"reply": symptom_q,
"next_stage": "reason_for_visit",
"state_updates": {
"reason_for_visit": user_message,
"symptoms": [{"name": user_message, "duration": "recent"}],
"medical_history": [],
"medications": [],
"allergies": []
},
"completed": False
}
elif stage == "reason_for_visit":
return {
"reply": med_q,
"next_stage": "medications",
"state_updates": {
"reason_for_visit": user_message,
"symptoms": [{"name": user_message, "duration": "reported"}],
"medical_history": [],
"medications": [],
"allergies": []
},
"completed": False
}
elif stage == "medications":
return {
"reply": conclusion_text,
"next_stage": "confirmation",
"state_updates": {
"reason_for_visit": None,
"symptoms": [],
"medical_history": [],
"medications": [user_message] if not is_negative else [],
"allergies": []
},
"completed": False
}
else:
return {
"reply": conclusion_text,
"next_stage": "confirmation",
"state_updates": {
"reason_for_visit": None,
"symptoms": [],
"medical_history": [],
"medications": [],
"allergies": ["None"] if is_negative else [user_message]
},
"completed": True
}
duration = normalize_duration_from_message(user_message)
severity = extract_severity_from_message(user_message)
location = extract_location_from_message(user_message)
last_intent = detect_previous_question_intent(last_ai_message) if last_ai_message else None
# 1. Symptoms & Reason for Visit
found_symptoms, primary_rfv = extract_symptoms(user_message, normalized_msg, current_state)
if found_symptoms:
updates["symptoms"] = found_symptoms
if primary_rfv:
updates["reason_for_visit"] = primary_rfv
elif (duration or severity or location) and current_state and current_state.get("symptoms"):
# Duration, Severity, or Location standalone follow-up turn
existing_syms = current_state.get("symptoms", [])
if isinstance(existing_syms, list) and len(existing_syms) > 0 and isinstance(existing_syms[0], dict):
updated_syms = [dict(s) for s in existing_syms]
if duration:
updated_syms[0]["duration"] = duration
if severity:
updated_syms[0]["severity"] = severity
if location:
updated_syms[0]["location"] = location
updates["symptoms"] = updated_syms
# 2. Medical History
history_updates = extract_history(user_message, normalized_msg, duration)
updates.update(history_updates)
# 3. Medications
is_alg_uttr = "allergy" in msg_lower or "அலர்ஜி" in user_message or "एलर्जी" in user_message
meds_res = extract_medications(user_message, normalized_msg, last_ai_message, is_allergy_utterance=is_alg_uttr)
if meds_res is not None:
updates["medications"] = meds_res
# 4. Allergies
alg_res = extract_allergies(user_message, normalized_msg, last_ai_message)
if alg_res is not None:
updates["allergies"] = alg_res
# Contextual intent follow-up mapping
if last_intent == "medical_history" and is_negative_expression(user_message):
updates["medical_history"] = {"status": "confirmed_none", "items": []}
if last_intent == "medications" and is_negative_expression(user_message):
updates["medications"] = {"status": "confirmed_none", "items": []}
if last_intent == "allergies" and is_negative_expression(user_message):
updates["allergies"] = {"status": "confirmed_none", "items": []}
if last_intent == "medications_and_allergies" and is_negative_expression(user_message):
updates["medications"] = {"status": "confirmed_none", "items": []}
updates["allergies"] = {"status": "confirmed_none", "items": []}
return updates
def _simulate_specialty_response(
self,
session: Any,
specialty: str,
user_message: str,
history_length: int
) -> Dict[str, Any]:
lang = getattr(session, "language", "en") or "en"
is_psych = (specialty == "psychiatry")
spec_info = {"code": specialty, "name": specialty.replace("_", " ").title()}
is_crisis, _, crisis_reply = check_crisis(user_message, lang)
if is_crisis:
return {"reply": crisis_reply, "next_stage": "crisis_handling", "state_updates": {}, "completed": True, "suggested_specialty": spec_info}
is_emergency, _, emergency_reply = check_emergency(user_message, lang)
if is_emergency:
return {"reply": emergency_reply, "next_stage": "emergency_referral", "state_updates": {}, "completed": True, "suggested_specialty": spec_info}
updates = self._extract_facts_from_message(user_message, normalize_text(user_message))
missing = check_required_missing(updates)
completed = (len(missing) == 0)
if completed:
reply = _pick_best_question([], lang, is_psych=is_psych)
return {"reply": format_conversational_response(reply, lang), "next_stage": "confirmation", "state_updates": updates, "completed": True, "suggested_specialty": spec_info}
reply = _pick_best_question(missing, lang, is_psych=is_psych)
stage = _stage_for_missing(missing[0])
return {"reply": format_conversational_response(reply, lang), "next_stage": stage, "state_updates": updates, "completed": False, "suggested_specialty": spec_info}
@@ -1 +0,0 @@
# Prompt rules package
@@ -1,40 +0,0 @@
def get_clinical_rules() -> str:
"""Core clinical intake rules — never diagnose, never invent, handle corrections."""
return """CLINICAL INTAKE RULES (MANDATORY):
1. YOU ARE AN INTAKE ASSISTANT, NOT A DOCTOR:
- NEVER diagnose conditions, offer medical opinions, or prescribe drugs.
- NEVER say "You may have...", "This sounds like...", "You probably have...".
- State clearly that information is collected for the attending physician's review.
- If patient asks "Is this serious?", respond: "I'll record your details so the doctor can assess this properly."
2. NEVER INVENT PATIENT INFORMATION:
- If the patient says "I have pain", do NOT generate "severe pain", "chest pain", "two days" unless explicitly stated.
- Unknown values MUST remain null or "not_collected". Do NOT fill in guesses.
- Do NOT convert AI assumptions into patient facts.
- Only record what the patient actually said.
3. PATIENT CORRECTIONS:
- If patient corrects themselves ("Actually, it started five days ago" after previously saying "yesterday"), use the LATEST explicit value.
- Do NOT retain outdated values. The most recent patient statement is the source of truth.
4. CONTRADICTORY INFORMATION:
- If the patient provides contradictory information, use the latest explicit correction.
- If ambiguity remains, ask ONE short clarification question.
5. HIPAA (US) & DPDP ACT 2023 (INDIA) COMPLIANCE:
- Data Minimization: Collect ONLY minimum necessary health intake data.
- NEVER ask for or record: Aadhaar, SSN, bank info, passport, home address, national IDs, passwords, API keys.
- AI Disclosure: You are an AI assistant gathering preliminary intake details exclusively for physician consultation.
6. PATIENT QUESTIONS:
- If patient asks a medical question, redirect gently: "I'll make sure the doctor reviews this with you."
- Then continue the intake if appropriate."""
def get_privacy_rules() -> str:
"""Privacy and compliance safeguards."""
return """PRIVACY & COMPLIANCE SAFEGUARDS:
- Collect ONLY clinically relevant information needed for healthcare intake.
- Do NOT ask for: Aadhaar number, SSN, bank account, credit card, passport, home address, national IDs, passwords, API keys, or other sensitive non-medical PII.
- If patient volunteers such information, do NOT store it. Politely redirect to the intake topic."""
@@ -1,135 +0,0 @@
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.
- Example: "I've had stomach pain since yesterday, vomited twice, and I take Metformin for diabetes" contains symptoms, duration, medications, AND medical history.
2. NO FIXED QUESTION SEQUENCE:
- Do NOT follow a rigid: Chief Complaint → Duration → History → Medication → Allergy → Complete pipeline.
- Dynamically determine the next action based on what is already known and what is genuinely missing.
3. DO NOT ASK FOR ALREADY-PROVIDED INFORMATION & NEVER REPEAT QUESTIONS:
- Before asking ANY question, check the current_state, conversation_history, and the latest patient message.
- If the patient already answered the duration question (e.g. "10 டேஸா இருக்கு", "பத்து நாளா", "10 days-ah"), NEVER re-ask "How long?" / "எத்தனை நாளா இருக்கு?".
- If the patient already mentioned diabetes, do NOT ask "Do you have any medical history?"
- If the patient already mentioned taking Metformin, do NOT ask "Are you taking any medications?"
- 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.
- Do NOT repeat everything the patient said.
5. ONE QUESTION AT A TIME:
- Ask only ONE question per turn.
- Choose the most clinically relevant missing information.
6. HANDLE OUT-OF-ORDER INFORMATION:
- Patients may mention allergies before symptoms, or history before the chief complaint.
- Capture everything in the correct state fields regardless of order.
- Never say "We are currently discussing your symptoms" to reject out-of-order information.
7. HANDLE TOPIC CHANGES:
- If patient suddenly says "By the way, I had surgery last year", capture it, acknowledge it naturally, and continue.
8. "NOTHING ELSE" / NEGATION HANDLING:
- If patient says "No", "Nothing else", "வேற எதுவும் இல்ல", "அவ்வளவுதான்", "ஒன்றுமில்லை":
- Confirm none for the category that was just asked (e.g., if asked about medical history, set medical_history to confirmed_none; if asked about medicines, set medications to confirmed_none; if asked about allergies, set allergies to confirmed_none).
- Do NOT complete early if required categories (symptoms with duration, medical history, medications, allergies) are still 'not_collected'.
- Only when ALL required intake categories (symptoms, duration, medical history, medications, allergies) are either reported or confirmed none, set completed=true.
9. ADAPTIVE TURN STRATEGY (internal process each turn):
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 (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.
11. COMPLETION CHECK (MANDATORY):
NEVER consider the intake complete unless ALL of the following are either reported or confirmed_none:
- reason_for_visit (reported)
- symptoms with duration (at least one symptom must have duration)
- medical_history (reported OR confirmed_none)
- medications (reported OR confirmed_none)
- allergies (reported OR confirmed_none)
If ANY of these is still 'not_collected', you MUST ask about it before completing.
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.
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."""
@@ -1,52 +0,0 @@
def get_extraction_rules() -> str:
"""Rules for comprehensive clinical information extraction from patient messages."""
return """EXTRACTION RULES — COMPREHENSIVE CLINICAL DATA CAPTURE:
1. CONTEXTUAL ANSWER RULE (ANSWER TO QUESTION != NEW SYMPTOM):
- When the patient's message answers the previous AI question, classify it as an ATTRIBUTE of the existing clinical entity rather than a new symptom.
- Example 1 (Duration): If AI asked "How long have you had this problem?" / "இந்த பிரச்சனை எத்தனை நாளா இருக்கு?", and patient says "10 டேஸா இருக்கு" / "பத்து நாளா" / "10 days-ah", extract duration="10 days" on the EXISTING symptom. DO NOT create a new symptom {"name": "10 டேஸா இருக்கு"}!
- Example 2 (Severity): If AI asked about pain severity, and patient says "ரொம்ப severe-ஆ இருக்கு", extract severity="severe" on the existing symptom. DO NOT create a new symptom {"name": "severe"}.
- Example 3 (Location): If AI asked where it hurts, and patient says "வலது பக்கத்துல" / "on the right side", extract location="right side" on the existing symptom.
- Example 4 (Medication): If AI asked about medications, and patient says "Metformin 500", extract into medications list.
- Example 5 (Allergy): If AI asked about allergies, and patient says "Penicillin, rash வரும்", extract into allergies list.
2. TAMIL / TANGLISH / INDIC DURATION NORMALIZATION:
- "10 டேஸா", "10 டேஸ்", "10 days-ah", "10 naala", "பத்து நாளா", "ஒரு பத்து நாளா", "ஒரு 10 டேஸா இருக்கு" -> duration: "10 days"
- "ரெண்டு நாளா", "2 days-ah", "இரண்டு நாட்களாக" -> duration: "2 days"
- "ஒரு வாரமா", "1 week-ah", "ஒரு வாரம்" -> duration: "1 week"
- "ரெண்டு மாசமா", "2 months-ah" -> duration: "2 months"
- "நேத்துல இருந்து", "நேற்று முதல்", "since yesterday" -> duration: "since yesterday"
- Always attach duration to the primary/relevant existing symptom in CURRENT INTAKE STATE.
3. CASUAL SPEECH & NEGATIVE FILTER:
- "நான் bus-ல வந்தேன், தலைவலி பத்து நாளா இருக்கு" -> Extract symptom: "தலைவலி", duration: "10 days". IGNORE "bus-ல வந்தேன்".
- "எனக்கு வேற எதுவும் இல்ல" / "nothing else" / "no" -> DO NOT extract as a symptom. Treat as completion indicator.
4. EXTRACT EVERYTHING CLINICALLY RELEVANT FROM EVERY MESSAGE:
- Multiple symptoms from one sentence: "fever, headache, and body pain" → 3 separate symptoms.
- Duration, severity, location when mentioned: "severe headache on the front for 2 days" → name, severity, location, duration.
- Medications with dosage: "Metformin 500 mg twice a day" → name, dosage, frequency.
- Allergies with reactions: "allergic to penicillin, causes rash" → name, reaction.
- Medical history: "I have diabetes for 8 years" → condition, duration.
- Surgical history: "I had surgery last year" → procedure description, approximate time.
- Previous episodes: "same problem three months ago" → description, time.
- Family history: "My father had heart disease" → condition, relationship.
- Functional impact: "couldn't sleep", "couldn't eat" → category, value.
5. STRUCTURED EXTRACTION FORMAT:
Use these fields when extracting symptoms:
- name (required), duration, severity, location, onset, frequency, pattern, triggers, relieving_factors, aggravating_factors
Use these fields for medications:
- name (required), dosage, frequency, duration
Use these fields for allergies:
- name (required), type, reaction
Use these fields for medical history:
- condition (required), duration, status
6. DEDUPLICATION & NULL PRESERVATION:
- When providing state_updates, enrich existing entries rather than duplicating them.
- Never replace known values with null. If duration was already known, preserve it."""
@@ -1,74 +0,0 @@
def get_language_rules(language: str) -> str:
"""Return natural spoken language rules for the given language code."""
rules = _LANGUAGE_RULES.get(language)
if rules:
return rules
# Fallback: try prefix match (e.g. "ta" matches "ta-IN")
prefix = language.lower()[:2]
for key, value in _LANGUAGE_RULES.items():
if key.lower().startswith(prefix):
return value
return _LANGUAGE_RULES["en-IN"]
_LANGUAGE_RULES = {
"ta-IN": (
"LANGUAGE & ACCENT: 100% Natural Spoken Tamil (இயல்பான பேச்சுத் தமிழ்).\n"
"- SPEAK ONLY IN NATURAL SPOKEN TAMIL (பேச்சுத் தமிழ்). STRICTLY BAN SENTHAMIZH (செந்தமிழ்).\n"
"- WRITE SPOKEN PRONUNCIATIONS IN TAMIL SCRIPT EXACTLY AS PEOPLE SPEAK IN TAMIL NADU IN EVERYDAY CLINIC CONVERSATION.\n"
"- SPOKEN WORD MAP (ALWAYS USE THE RIGHT COLUMN):\n"
" * மருத்துவமனை -> கிளினிக் / ஹாஸ்பிடல்\n"
" * மருத்துவர் -> டாக்டர் / டாக்டர் சார்\n"
" * அறிகுறிகள் / நோய் -> உடம்பு முடியலை / பிரச்சனை / உடம்புக்கு என்ன பண்ணுது\n"
" * கூறுக / கூறுங்கள் -> சொல்லுங்க / சொல்ல முடியுமா\n"
" * வரவேற்கிறோம் -> (Do not use! Say: 'நான் நிலா பேசுறேன்')\n"
" * செய்கிறது / செய்கிறீர்கள் -> பண்ணுது / பண்றீங்க\n"
" * இருக்கிறது / இருக்கின்றன -> இருக்கு\n"
" * எப்போது / எக்காலம் -> எப்போ / எப்போலிருந்து\n"
" * எத்தனை நாட்கள் -> எத்தனை நாளா / எத்தனை நாளா இருக்கு\n"
" * மருந்துகள் / ஔடதம் -> மாத்திரை / மெடிசின் / சிரப்\n"
" * உட்கொண்டீர்களா -> சாப்பிட்டீங்களா / எடுக்குறீங்களா\n"
" * புரிந்தது / அறிந்துகொண்டேன் -> புரியுதுங்க / சரிங்க / ஓகேங்க\n"
" * நன்றி -> ரொம்ப நன்றிங்க\n"
" * சமர்ப்பிப்பேன் -> டாக்டர்கிட்ட குடுத்துடுறேன் / சொல்லிடுறேன்\n"
" * ஒவ்வாமை -> அலர்ஜி / அலர்ஜி எதுவும் இருக்கா\n"
"- NEVER USE: 'மருத்துவமனை', 'அறிகுறிகள்', 'கூறுங்கள்', 'வரவேற்கிறோம்', 'மருத்துவர்', 'செய்கிறது', 'இருக்கிறது', 'ஔடதம்'.\n"
"- Acknowledge naturally: 'சரிங்க', 'புரியுதுங்க', 'ஓகேங்க'.\n"
"- Keep responses warm, polite, and conversational."
),
"hi-IN": (
"LANGUAGE & ACCENT: 100% Natural Spoken Conversational Hindi (आम बोलचाल की हिन्दी).\n"
"- Speak naturally like a polite, reassuring clinic receptionist in everyday spoken Hindi.\n"
"- STRICTLY AVOID bookish/formal Hindi (Shuddh Hindi).\n"
"- USE: 'जी', 'अच्छा जी', 'समझ आ गया', 'ठीक है', 'क्या तकलीफ हो रही है?', 'कितने दिनों से?', 'कोई दवाई ली है?', 'एलर्जी तो नहीं है?'.\n"
"- FORBIDDEN: 'अस्पताल में आपका स्वागत है', 'लक्षणों का विवरण', 'चिकित्सक', 'अवगत कराएं'.\n"
"- Acknowledge naturally: 'जी', 'समझ गया जी', 'ठीक है जी'.\n"
"- Keep responses warm, polite, and conversational."
),
"te-IN": (
"LANGUAGE & ACCENT: 100% Natural Spoken Conversational Telugu (రోజువారీ వాడుక తెలుగు).\n"
"- Speak naturally like a polite Telugu clinic receptionist in daily conversational Telugu.\n"
"- STRICTLY AVOID formal bookish Telugu (Grandhika).\n"
"- USE: 'సరేనండి', 'అర్థమైందండి', 'ధన్యవాదాలండి', 'ఏం ఇబ్బందిగా ఉంది?', 'ఎన్ని రోజుల నుండి?', 'ఏమైనా మందులు వాడారా?', 'మందుల అలర్జీ ఉందా?'.\n"
"- FORBIDDEN: 'స్వాగతం', 'లక్షణాలు', 'వైద్యుడు', 'తెలియజేయగలరు'.\n"
"- Acknowledge naturally: 'సరేనండి', 'అర్థమైందండి'.\n"
"- Keep responses warm, polite, and conversational."
),
"kn-IN": (
"LANGUAGE & ACCENT: 100% Natural Spoken Conversational Kannada (ದಿನನಿತ್ಯದ ಮಾತನಾಡುವ ಕನ್ನಡ).\n"
"- Speak naturally like a polite Kannada clinic receptionist.\n"
"- STRICTLY AVOID formal bookish Kannada.\n"
"- USE: 'ಸರಿ ಮೇಡಂ/ಸರ್', 'ಅರ್ಥವಾಯಿತು', 'ಧನ್ಯವಾದಗಳು', 'ಏನು ತೊಂದರೆಯಾಗಿದೆ?', 'ಎಷ್ಟು ದಿನದಿಂದ ಇದೆ?', 'ಏನಾದರೂ ಮಾತ್ರೆ ತಗೊಂಡಿದ್ದೀರಾ?', 'ಮಾತ್ರೆ ಅಲರ್ಜಿ ಇದೆಯಾ?'.\n"
"- FORBIDDEN: 'ಸುಸ್ವಾಗತ', 'ಲಕ್ಷಣಗಳು', 'ವೈದ್ಯರು', 'ತಿಳಿಸಬಹುದೇ'.\n"
"- Acknowledge naturally: 'ಸರಿ', 'ಅರ್ಥವಾಯಿತು'.\n"
"- Keep responses warm, polite, and conversational."
),
"en-IN": (
"LANGUAGE & ACCENT: Warm, Natural Conversational Indian English.\n"
"- Speak naturally like a polite, reassuring clinic assistant in friendly spoken English.\n"
"- USE: 'Got it', 'Understood', 'Thank you', 'What seems to be the main trouble today?', 'How long has this been bothering you?', 'Any medication for this?', 'Any known medication allergies?'.\n"
"- FORBIDDEN: 'Kindly divulge your clinical symptoms', 'Welcome to CureSelect Healthcare', overly formal medical jargon.\n"
"- Acknowledge naturally: 'Got it', 'Understood', 'I see'.\n"
"- Keep responses warm, friendly, and conversational."
),
}
@@ -1,75 +0,0 @@
def get_safety_rules() -> str:
"""Emergency detection and safety override rules."""
return """EMERGENCY & SAFETY RULES (HIGHEST PRIORITY — OVERRIDE NORMAL CONVERSATION):
1. EMERGENCY DETECTION:
If the patient mentions ANY of these, IMMEDIATELY provide emergency guidance BEFORE continuing intake:
- Severe/crushing chest pain
- Sudden paralysis or weakness on one side
- Severe breathing difficulty / cannot breathe
- Loss of consciousness / collapsed
- Severe uncontrolled bleeding
- Severe trauma / major injury
- Seizure / convulsion in progress
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:
If patient expresses:
- Suicidal thoughts ("end my life", "kill myself", "want to die")
- Self-harm intent
- Immediate danger to self or others
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.
3. NON-EMERGENCY SAFETY:
- If symptoms sound potentially urgent but not immediately life-threatening, note urgency but continue intake.
- Always err on the side of caution for safety.
4. PII REDACTION:
- If patient provides Aadhaar, SSN, bank details, or card numbers, do NOT store them.
- Respond: "For your safety, I don't need financial or ID information. Let's focus on your health concern."
"""
# Keywords for backend emergency hard-stop detection
EMERGENCY_KEYWORDS_EN = [
"severe chest pain", "crushing chest pain", "chest pressure",
"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 nurse", "call doctor", "emergency room", "call ambulance",
]
CRISIS_KEYWORDS_EN = [
"suicide", "suicidal", "kill myself", "end my life", "want to die",
"self harm", "self-harm", "hurt myself", "don't want to live", "no reason to live",
]
EMERGENCY_KEYWORDS_INDIC = [
# Tamil spoken (severe emergency)
"கடும் நெஞ்சு வலி", "மூச்சு விட முடியல", "மூச்சு விடவே முடியல", "மூச்சு அடைக்குது", "மூச்சு திணறல் கடுமையா",
"மயக்கம்", "மயங்கி விழுந்துட்டேன்", "திடீர்னு கை கால் வேலை செய்யல",
# Hindi spoken (severe emergency)
"सीने में बहुत तेज दर्द", "सांस लेने में बहुत दिक्कत", "सांस नहीं आ रही", "बेहोश", "अचानक हाथ पैर काम बंद",
# Telugu spoken (severe emergency)
"ఛాతీలో చాలా తీవ్రమైన నొప్పి", "శ్వాస తీసుకోవడం చాలా కష్టంగా", "ఊపిരി ఆడడం లేదు", "స్పృహ తప్పి",
# Kannada spoken (severe emergency)
"ಎದೆಯಲ್ಲಿ ತೀವ್ರ ನೋವು", "ಉಸಿರಾಡಲು ತುಂಬಾ ಕಷ್ಟವಾಗುತ್ತಿದೆ", "ಉಸಿರಾಡಲು ಆಗುತ್ತಿಲ್ಲ", "ಪ್ರಜ್ಞೆ ತಪ್ಪಿ",
]
CRISIS_KEYWORDS_INDIC = [
# Tamil spoken crisis
"காயப்படுத்திக்கணும்", "கொல்லணும்", "வாழவே பிடிக்கல", "உயிரோட இருக்க விரும்பல", "உயிரை மாய்த்து", "தற்கொலை",
# Hindi spoken crisis
"खुद को नुकसान", "मर जाने का मन", "खुदकुशी", "जान देना", "आत्महत्या",
# Telugu spoken crisis
"గాయపరుచుకోవాలని", "చనిపోవాలని", "ఆత్మహత్య",
# Kannada spoken crisis
"ನೋಯಿಸಿಕೊಳ್ಳಬೇಕು", "ಸಾಯಬೇಕು", "ಆತ್ಮಹತ್ಯೆ",
]
@@ -1,70 +0,0 @@
def get_specialty_rules(specialty_config: dict) -> str:
"""Generate specialty-aware conversation guidance from the specialty config."""
code = specialty_config.get("code", "general")
name = specialty_config.get("name", "General Intake")
focus_areas = specialty_config.get("focus_areas", [])
# Build dynamic specialty context
focus_text = ""
if focus_areas:
focus_text = "SPECIALTY FOCUS AREAS (guide question relevance, do NOT use as rigid checklist):\n"
for area in focus_areas:
focus_text += f" - {area}\n"
# Specialty-specific behavioral overrides
specialty_overrides = _SPECIALTY_OVERRIDES.get(code, "")
return f"""SPECIALTY-AWARE CONVERSATION:
DEPARTMENT: {name}
{focus_text}
SPECIALTY BEHAVIOR:
- Use the specialty focus areas to determine which questions are RELEVANT for this patient.
- Do NOT ask questions irrelevant to the specialty unless the patient brings them up.
- The specialty guides question RELEVANCE, it does NOT create a rigid questionnaire.
{specialty_overrides}"""
_SPECIALTY_OVERRIDES = {
"psychiatry": """
- MENTAL HEALTH SPECIFIC:
- 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) or direct to hospital emergency staff before continuing intake.
- Use empathetic, warm language. Mental health intake requires extra sensitivity.""",
"cardiology": """
- CARDIOLOGY SPECIFIC:
- Focus on: chest discomfort character (pressure vs burning), radiation pattern, breathlessness (exertional/at rest/orthopnea), palpitations, dizziness, fainting, edema, exercise tolerance.
- Ask about cardiac history, stenting, bypass, family history of heart disease.
- Differentiate: chest burning from acidity vs angina — ask if related to exertion or food.
- Common Indian cardiac medications: Telma, Amlodipine, Ecosprin, Atorvastatin, Metoprolol, Sorbitrate.""",
"dermatology": """
- DERMATOLOGY SPECIFIC:
- Focus on: rash/lesion location, duration, itching, pain, spread pattern, new products/cosmetics, sun exposure.
- Ask about allergies to topical products if relevant.
- Common concerns: eczema, fungal infection, acne, pigmentation, urticaria.""",
"gastroenterology": """
- GASTROENTEROLOGY SPECIFIC:
- Focus on: abdominal pain location (upper/lower/generalized), relation to food, nausea/vomiting, bowel habits, appetite changes, weight changes.
- Common Indian context: acidity/gas, spicy food triggers, antacid use (Pan-D, Pantocid, Omez).""",
"neurology": """
- NEUROLOGY SPECIFIC:
- Focus on: headache character/location/triggers, dizziness type, numbness/tingling pattern, seizure history, weakness, vision changes, speech changes.
- Migraine vs tension headache differentiation if relevant.""",
"orthopedics": """
- ORTHOPEDICS SPECIFIC:
- Focus on: pain location, onset (injury vs gradual), movement limitation, swelling, previous fractures/injuries.
- Ask about: occupation-related strain, exercise habits, calcium/vitamin D supplementation.""",
"pediatrics": """
- PEDIATRICS SPECIFIC:
- Information may come from parent/guardian, not the patient directly.
- Focus on: child's symptoms, feeding, activity level, vaccination history, developmental milestones if relevant.
- Use simple, reassuring language appropriate for worried parents.""",
}
+63 -117
View File
@@ -1,143 +1,89 @@
import json
from app.ai.prompt_rules.language_rules import get_language_rules
from app.ai.prompt_rules.clinical_rules import get_clinical_rules, get_privacy_rules
from app.ai.prompt_rules.conversation_rules import get_conversation_rules
from app.ai.prompt_rules.extraction_rules import get_extraction_rules
from app.ai.prompt_rules.specialty_rules import get_specialty_rules
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 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"
turn_count = len(conversation_history)
lang_rules = get_language_rules(language)
clin_rules = get_clinical_rules()
priv_rules = get_privacy_rules()
conv_rules = get_conversation_rules()
extr_rules = get_extraction_rules()
spec_rules = get_specialty_rules(specialty)
safe_rules = get_safety_rules()
lang_instructions = {
"ta-IN": (
"LANGUAGE & ACCENT: Professional, Respectful, and Articulate Tamil (தமிழ்).\n"
"- Speak with the professional, calm, and reassuring demeanor of a senior hospital clinical coordinator.\n"
"- Use polite, respectful Tamil words (e.g. 'வணக்கம்', 'கூற முடியுமா?', 'புரிகிறது', 'நன்றி').\n"
"- Keep sentence structure clear, elegant, and under 14 words."
),
"hi-IN": (
"LANGUAGE & ACCENT: Professional, Respectful, and Clear Hindi (हिन्दी).\n"
"- Speak with the dignified and polite tone of an experienced hospital triage officer.\n"
"- Use respectful language with 'आप' and clear clinical clarity.\n"
"- Keep sentence structure polite, concise, and under 14 words."
),
"en-IN": (
"LANGUAGE & ACCENT: Polished, Professional Indian Healthcare English.\n"
"- Speak with a calm, articulate, and reassuring clinical tone.\n"
"- Use clear, professional phrasing (e.g. 'Thank you. I have recorded your symptoms for the doctor.').\n"
"- Keep each response concise, respectful, and under 14 words."
),
"te-IN": (
"LANGUAGE & ACCENT: Professional and Respectful Telugu (తెలుగు).\n"
"- Speak with dignified, clear hospital intake phrasing (under 14 words)."
),
"kn-IN": (
"LANGUAGE & ACCENT: Professional and Respectful Kannada (ಕನ್ನಡ).\n"
"- Speak with dignified, clear hospital intake phrasing (under 14 words)."
),
}
last_ai_message = ""
for turn in reversed(conversation_history):
if turn.get("role") in ["assistant", "ai"]:
last_ai_message = turn.get("message", "")
break
selected_lang_rule = lang_instructions.get(language, lang_instructions["en-IN"])
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}.
return f"""You are a senior, professional Hospital Patient Intake Assistant at CureSelect Healthcare.
{lang_rules}
{selected_lang_rule}
{clin_rules}
CRITICAL CLINICAL INTAKE PRINCIPLES (AVOID UNWANTED QUESTIONS):
1. **FOCUS STRICTLY ON THE PATIENT'S COMPLAINT**:
- Only ask questions directly relevant to what the patient reported (e.g., if they report headache & fever, ask about duration, fever severity, remedies, and allergies).
- **DO NOT randomly cross-examine unrelated organ systems** (e.g., NEVER ask about digestive issues, acidity, loose stools, or stomach unless the patient specifically mentioned stomach problems!).
{safe_rules}
2. **FAST & CONCISE INTAKE (MAX 4 TURNS TOTAL)**:
- Flow: Chief Complaint -> Duration -> Medications Taken -> Drug Allergies -> COMPLETE.
- Once symptoms, duration, medicines, and allergies are collected, **IMMEDIATELY SET "completed": true**.
{conv_rules}
3. **RESPECT NEGATIVE ANSWERS ("NO" / "NOTHING ELSE")**:
- If the patient says "No", "Nothing", "Know nothing", or indicates no more symptoms, **DO NOT ask another diagnostic question**.
- Conclude immediately: *"Thank you. I have recorded your details for the doctor to review."* with `"completed": true` and `"next_stage": "confirmation"`.
{spec_rules}
4. **CONCISENESS**: Exactly ONE spoken sentence (maximum 12 to 14 words).
5. **NO ARTIFACTS**: No bullet points, no brackets, no markdown formatting. Deliver clean spoken dialogue.
6. **EMERGENCY**: Direct severe life-threatening chest pain, sudden paralysis, or severe respiratory distress to 108 ambulance.
{extr_rules}
PATIENT: {patient.get("name", "Patient")}, {patient.get("age", "")}y, {patient.get("gender", "")}
DEPARTMENT: {specialty.get("name", "General Intake")}
CURRENT STAGE: {stage} (Turn #{turn_count})
CURRENT STRUCTURED STATE:
{json.dumps(current_state, ensure_ascii=False)}
{priv_rules}
CONVERSATION HISTORY:
{json.dumps(conversation_history, ensure_ascii=False)}
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)}
LATEST PATIENT MESSAGE:
"{new_patient_message}"
TARGET MISSING QUESTION TO ASK:
"{target_question}"
OUTPUT FORMAT REQUIREMENTS:
You MUST respond with valid JSON matching EXACTLY this structure:
OUTPUT JSON ONLY:
{{
"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",
"reply": "1 concise, respectful sentence (max 14 words)",
"next_stage": "greeting" | "reason_for_visit" | "symptoms" | "medical_history" | "medications" | "allergies" | "confirmation",
"completed": true | false,
"state_updates": {{
"reason_for_visit": "primary reason or null",
"symptoms": [
{{
"name": "symptom name",
"duration": "duration or null",
"severity": "severity or null",
"location": "location or null"
"reason_for_visit": {{"status": "reported" | "not_collected", "value": "..."}} or null,
"symptoms": {{"status": "reported" | "not_collected", "items": [{{"name": "...", "duration": "...", "severity": "..."}}]}} or null,
"medical_history": {{"status": "reported" | "confirmed_none", "items": ["..."]}} or null,
"medications": {{"status": "reported" | "confirmed_none", "items": ["..."]}} or null,
"allergies": {{"status": "reported" | "confirmed_none", "items": ["..."]}} or null
}}
],
"medical_history": [],
"medications": [],
"allergies": [],
"surgical_history": [],
"previous_episodes": [],
"family_history": [],
"additional_information": []
}},
"completed": false
}}
"""
return prompt
@@ -1,9 +0,0 @@
"""
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"]
@@ -1,30 +0,0 @@
"""
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
@@ -1,19 +0,0 @@
"""
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)
@@ -1,19 +0,0 @@
"""
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)
@@ -1,8 +0,0 @@
"""
Safety package.
"""
from app.ai.safety.emergency import check_emergency
from app.ai.safety.crisis import check_crisis
__all__ = ["check_emergency", "check_crisis"]
@@ -1,69 +0,0 @@
"""
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."
@@ -1,91 +0,0 @@
"""
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."
+32 -153
View File
@@ -1,25 +1,7 @@
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:
@@ -32,114 +14,48 @@ class SarvamClient:
def _get_client(self) -> httpx2.AsyncClient:
if SarvamClient._client is None:
SarvamClient._client = httpx2.AsyncClient(
timeout=8.0,
timeout=12.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:
transcript, _ = await self.speech_to_text_with_status(audio_bytes, filename, language_code)
return transcript
if not self.api_key or self.api_key == "your_sarvam_key":
return ""
async def text_to_speech_with_status(
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(
self,
text: str,
language_code: str = "ta-IN",
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
) -> bytes | None:
if not self.api_key or self.api_key == "your_sarvam_key":
return None
if not self.api_key or self.api_key in ["your_sarvam_key", ""]:
return None, TTS_PROVIDER_ERROR
lang_prefix = language_code.lower()[:2]
speaker = "anushka" if lang_prefix in ["ta", "te", "hi"] else "karun"
client = self._get_client()
headers = {
@@ -149,57 +65,20 @@ class SarvamClient:
payload = {
"inputs": [text],
"target_language_code": language_code,
"speaker": "kavya",
"speaker": speaker,
"model": "bulbul:v2",
}
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
return base64.b64decode(audios[0])
except Exception as e:
print(f"[Sarvam TTS Error]: {e}")
return None
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
+2 -26
View File
@@ -7,9 +7,6 @@ from app.database.database import get_db
from app.schemas.documents import DocumentExtractionResponse
from app.services.onboarding_service import OnboardingService
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile
from app.core.security import get_auth_context, AuthContext, check_rate_limit, verify_session_ownership
router = APIRouter(
prefix="/v1/documents",
tags=["Medical Documents"],
@@ -23,13 +20,8 @@ router = APIRouter(
async def upload_medical_document(
session_id: str,
file: UploadFile = File(...),
req_obj: Request = None,
auth: AuthContext = Depends(get_auth_context),
db: Session = Depends(get_db),
):
if req_obj and req_obj.client:
check_rate_limit(req_obj.client.host, limit=10, window_sec=60)
onboarding_service = OnboardingService()
gemini_client = GeminiClient()
@@ -37,24 +29,9 @@ async def upload_medical_document(
if not session:
raise HTTPException(status_code=404, detail="Onboarding session not found")
# 1b. Verify Session Ownership (IDOR Prevention)
verify_session_ownership(session.patient_id, auth, detail="Forbidden: You do not have authorization to upload documents to this session.")
# Enforce MIME type and File Size Limits (10MB MAX)
allowed_mimes = ["image/jpeg", "image/png", "image/webp", "application/pdf"]
mime_type = file.content_type or "application/pdf"
if mime_type not in allowed_mimes:
raise HTTPException(
status_code=400,
detail=f"Unsupported document format: {mime_type}. Allowed formats: JPEG, PNG, WEBP, PDF.",
)
state = onboarding_service.get_state(db, session_id)
file_bytes = await file.read()
if len(file_bytes) > 10 * 1024 * 1024:
raise HTTPException(
status_code=413,
detail="File size exceeds maximum allowed limit of 10MB.",
)
mime_type = file.content_type or "application/pdf"
patient_context = {
"id": session.patient_id,
@@ -72,7 +49,6 @@ async def upload_medical_document(
)
# Merge document findings into onboarding state
state = onboarding_service.get_state(db, session_id)
current_state = json.loads(state.state_json)
# Append medications if found
+19 -81
View File
@@ -1,5 +1,4 @@
import json
import time
from uuid import uuid4
from fastapi import APIRouter, Depends, HTTPException
@@ -34,13 +33,8 @@ from app.services.patient_submission_service import (
from app.services.result_extraction_service import (
ResultExtractionService,
)
from app.services.voice_service import VoiceService
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.orm import Session
from app.core.security import get_auth_context, AuthContext, check_rate_limit, verify_session_ownership
router = APIRouter(
prefix="/v1/onboarding",
tags=["Patient Onboarding"],
@@ -53,22 +47,14 @@ router = APIRouter(
)
async def start_onboarding(
request: StartOnboardingRequest,
req_obj: Request = None,
auth: AuthContext = Depends(get_auth_context),
db: Session = Depends(get_db),
):
if req_obj and req_obj.client:
check_rate_limit(req_obj.client.host, limit=30, window_sec=60)
onboarding_service = OnboardingService()
session_id = f"ONB-{uuid4().hex[:8].upper()}"
# Use authenticated patient identity
patient_id = request.patient.id or auth.patient_id
session = OnboardingSession(
session_id=session_id,
patient_id=patient_id,
patient_id=request.patient.id,
patient_name=request.patient.name,
age=request.patient.age,
gender=request.patient.gender,
@@ -87,10 +73,11 @@ async def start_onboarding(
# Initialize empty state
onboarding_service.get_state(db, session_id)
voice_service = VoiceService()
greeting_msg = voice_service.get_initial_greeting(
request.patient.name,
request.language,
greeting_msg = (
f"Hello {request.patient.name or 'there'}, "
"welcome to CureSelect Healthcare. "
"I'll ask you a few questions to understand "
"your current health concern. Shall we begin?"
)
# Save initial assistant greeting
@@ -121,24 +108,16 @@ async def start_onboarding(
async def send_message(
session_id: str,
request: SendMessageRequest,
req_obj: Request = None,
auth: AuthContext = Depends(get_auth_context),
db: Session = Depends(get_db),
):
if req_obj and req_obj.client:
check_rate_limit(req_obj.client.host, limit=30, window_sec=60)
t0 = time.perf_counter()
onboarding_service = OnboardingService()
ai_service = AIOnboardingService()
# 1. Get session
t_sess_start = time.perf_counter()
session = onboarding_service.get_session(
db,
session_id,
)
t_sess_end = time.perf_counter()
if not session:
raise HTTPException(
@@ -146,9 +125,6 @@ async def send_message(
detail="Onboarding session not found",
)
# 1b. Verify Session Ownership (IDOR Prevention)
verify_session_ownership(session.patient_id, auth, detail="Forbidden: You do not have authorization to access or modify this patient onboarding session.")
# 2. Check session
if session.status != "in_progress":
raise HTTPException(
@@ -166,15 +142,12 @@ async def send_message(
)
# 4. Get state
t_state_start = time.perf_counter()
state = onboarding_service.get_state(
db,
session_id,
)
t_state_end = time.perf_counter()
# 5. Get conversation history
t_hist_start = time.perf_counter()
messages = (
db.query(OnboardingMessage)
.filter(
@@ -194,20 +167,16 @@ async def send_message(
}
for message in messages
]
t_hist_end = time.perf_counter()
# 6. Send to AI
t_ai_start = time.perf_counter()
ai_response = await ai_service.process_message(
session=session,
state=state,
conversation_history=conversation_history,
user_message=request.message,
)
t_ai_end = time.perf_counter()
# 7. Update state using shared intelligent merge
from app.ai.orchestrator import merge_state_updates
# 7. Update state
current_state = json.loads(
state.state_json
)
@@ -216,17 +185,17 @@ async def send_message(
"state_updates",
{},
)
if hasattr(updates, "model_dump"):
updates = updates.model_dump()
elif not isinstance(updates, dict):
updates = {}
merged_state = merge_state_updates(current_state, updates)
for key, value in updates.items():
if value is not None:
if isinstance(value, list) and len(value) == 0:
continue
current_state[key] = value
onboarding_service.update_state(
db=db,
state=state,
data=merged_state,
data=current_state,
)
# 8. Update stage
@@ -236,9 +205,7 @@ async def send_message(
if ai_response.get("completed"):
session.status = "completed"
t_db_start = time.perf_counter()
db.commit()
t_db_end = time.perf_counter()
# 10. Save AI message
onboarding_service.save_message(
@@ -249,20 +216,12 @@ async def send_message(
stage=session.stage,
)
t_total_end = time.perf_counter()
total_ms = round((t_total_end - t0) * 1000, 2)
total_sec = round(t_total_end - t0, 3)
print(f"[PERF PROFILE - send_message] SESSION_LOOKUP: {(t_sess_end - t_sess_start)*1000:.2f}ms | STATE_LOAD: {(t_state_end - t_state_start)*1000:.2f}ms | HIST_LOAD: {(t_hist_end - t_hist_start)*1000:.2f}ms | AI_PROCESS: {(t_ai_end - t_ai_start)*1000:.2f}ms | DB_COMMIT: {(t_db_end - t_db_start)*1000:.2f}ms | TOTAL: {total_ms}ms")
return SendMessageResponse(
success=True,
session_id=session_id,
stage=session.stage,
message=ai_response["reply"],
status=session.status,
response_time_ms=total_ms,
response_time_sec=total_sec,
)
@@ -273,10 +232,8 @@ async def send_message(
async def complete_onboarding(
session_id: str,
request: CompleteOnboardingRequest,
auth: AuthContext = Depends(get_auth_context),
db: Session = Depends(get_db),
):
start_time = time.time()
onboarding_service = OnboardingService()
completion_service = CompletionService()
result_service = ResultExtractionService()
@@ -294,9 +251,6 @@ async def complete_onboarding(
detail="Onboarding session not found",
)
# 1b. Verify Session Ownership (IDOR Prevention)
verify_session_ownership(session.patient_id, auth, detail="Forbidden: You do not have authorization to complete this onboarding session.")
# 2. Load state
state = onboarding_service.get_state(
db,
@@ -343,35 +297,19 @@ async def complete_onboarding(
# 7. Patient confirmed -> submit to existing patient / hospital API
submission = await submission_service.submit(result)
if isinstance(result, dict):
result["onboarding_completed"] = True
if session.status == "completed":
return CompleteOnboardingResponse(
success=True,
session_id=session_id,
status="completed",
result=result,
message="Onboarding session is already completed.",
response_time_ms=round((time.time() - start_time) * 1000, 2),
response_time_sec=round(time.time() - start_time, 3),
)
if submission.get("success"):
# Mark session completed in DB instead of hard-deleting
session.status = "completed"
session.stage = "confirmation"
# 8. Successful submission -> clean up temporary SQLite session
db.query(OnboardingMessage).filter(OnboardingMessage.session_id == session_id).delete()
db.query(OnboardingState).filter(OnboardingState.session_id == session_id).delete()
db.delete(session)
db.commit()
elapsed = time.time() - start_time
return CompleteOnboardingResponse(
success=True,
session_id=session_id,
status="completed",
status="submitted_and_cleaned",
result=result,
message="Onboarding successfully completed and saved to patient record.",
response_time_ms=round(elapsed * 1000, 2),
response_time_sec=round(elapsed, 3),
message="Onboarding successfully completed, saved to patient record, and temporary session cleaned.",
)
# If submission failed, preserve SQLite session for retry
+42 -149
View File
@@ -1,11 +1,9 @@
import asyncio
import base64
import json
import time
from uuid import uuid4
from typing import Optional
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
@@ -16,7 +14,6 @@ from app.database.models import (
OnboardingState,
)
from app.schemas.onboarding import (
CategoryContext,
CompleteOnboardingRequest,
CompleteOnboardingResponse,
)
@@ -32,8 +29,6 @@ from app.services.patient_submission_service import PatientSubmissionService
from app.services.result_extraction_service import ResultExtractionService
from app.services.voice_service import VoiceService
from app.core.security import get_auth_context, AuthContext, check_rate_limit, verify_session_ownership
router = APIRouter(
prefix="/v1/voice-onboarding",
tags=["Voice Onboarding"],
@@ -46,37 +41,22 @@ router = APIRouter(
)
async def start_voice_onboarding(
request: StartVoiceOnboardingRequest,
req_obj: Request = None,
auth: AuthContext = Depends(get_auth_context),
db: Session = Depends(get_db),
):
if req_obj and req_obj.client:
check_rate_limit(req_obj.client.host, limit=15, window_sec=60)
start_time = time.time()
try:
onboarding_service = OnboardingService()
voice_service = VoiceService()
session_id = f"VOICE-{uuid4().hex[:8].upper()}"
# Default category to General Medicine / Auto-Triage if omitted by client
category = request.category or CategoryContext(
type="general",
code="general_medicine",
name="General Medicine / Primary Care (Auto-Triage)",
)
patient_id = request.patient.id or auth.patient_id
session = OnboardingSession(
session_id=session_id,
patient_id=patient_id,
patient_id=request.patient.id,
patient_name=request.patient.name,
age=request.patient.age,
gender=request.patient.gender,
category_type=category.type,
category_code=category.code,
category_name=category.name,
category_type=request.category.type,
category_code=request.category.code,
category_name=request.category.name,
language=request.language,
status="in_progress",
stage="greeting",
@@ -95,11 +75,12 @@ async def start_voice_onboarding(
request.language,
)
# Synthesize audio greeting in background for sub-50ms response
audio_id = f"aud_{uuid4().hex[:12]}"
asyncio.create_task(
voice_service.synthesize_to_file(audio_id, greeting_text, request.language)
# Synthesize audio greeting
audio_id, greeting_bytes = await voice_service.text_to_speech(
greeting_text,
request.language,
)
greeting_b64 = f"data:audio/wav;base64,{base64.b64encode(greeting_bytes).decode('utf-8')}" if greeting_bytes else None
# Save initial assistant message
onboarding_service.save_message(
@@ -110,25 +91,18 @@ async def start_voice_onboarding(
stage="greeting",
)
elapsed = time.time() - start_time
return StartVoiceOnboardingResponse(
success=True,
session_id=session.session_id,
patient_id=session.patient_id,
category=category,
category=request.category,
language=session.language,
status=session.status,
stage=session.stage,
greeting_text=greeting_text,
audio_url=f"/v1/voice-onboarding/audio/{audio_id}",
response_time_ms=round(elapsed * 1000, 2),
response_time_sec=round(elapsed, 3),
audio_base64=greeting_b64,
)
except Exception as err:
import traceback
print(f"[StartVoiceOnboarding Error]: {err}")
traceback.print_exc()
raise HTTPException(status_code=500, detail=str(err))
@router.post(
@@ -139,13 +113,8 @@ async def voice_turn(
session_id: str,
audio: Optional[UploadFile] = File(None),
text_fallback: Optional[str] = Form(None),
req_obj: Request = None,
auth: AuthContext = Depends(get_auth_context),
db: Session = Depends(get_db),
):
if req_obj and req_obj.client:
check_rate_limit(req_obj.client.host, limit=20, window_sec=60)
start_time = time.time()
onboarding_service = OnboardingService()
voice_service = VoiceService()
ai_service = AIOnboardingService()
@@ -158,17 +127,10 @@ async def voice_turn(
detail="Voice onboarding session not found",
)
# 1b. IDOR Session Ownership Check
verify_session_ownership(session.patient_id, auth, detail="Forbidden: You do not have authorization to access or modify this voice session.")
if session.status == "completed":
session.status = "in_progress"
db.commit()
if session.status not in ["in_progress", "completed"]:
if session.status != "in_progress":
raise HTTPException(
status_code=400,
detail="Voice onboarding session is closed or submitted.",
detail="Voice onboarding session is not active",
)
# 2. Fast-path transcript: use real-time Web Speech text_fallback first for zero-latency processing
@@ -194,16 +156,17 @@ async def voice_turn(
)
# If STILL empty, user spoke nothing or silence was recorded.
# Do NOT substitute fake default text or auto-fill clinical fields!
if not patient_transcript or not patient_transcript.strip():
lang = session.language.lower()
if lang.startswith("ta"):
no_speech_msg = "உங்க குரல் கேட்கலைங்க, தயவுசெய்து மைக்கில பேசுங்க. உங்க உடம்பு பிரச்சனையை சொல்லுங்க."
no_speech_msg = "மன்னிக்கவும், உங்கள் குரல் கேட்கவில்லை. தயவுசெய்து உங்கள் உடல்நலப் பிரச்சினை பற்றி பேசவும்."
elif lang.startswith("hi"):
no_speech_msg = "आपकी आवाज़ सुनाई नहीं दी। कृपया अपने माइक में बोलें और अपनी समस्या बताएं।"
no_speech_msg = "क्षमा करें, मुझे आपकी आवाज़ सुनाई नहीं दी। कृपया अपनी स्वास्थ्य समस्या के बारे में बोलें।"
elif lang.startswith("te"):
no_speech_msg = "మీ స్వరం వినబడలేదు. దయచేసి మైక్‌లో మాట్లాడి మీ ఆరోగ్య సమస్యను చెప్పండి."
no_speech_msg = "క్షమించండి, మీ ధ్వని వినిపించలేదు. దయచేసి మీ ఆరోగ్య సమస్య గురించి మాట్లాడండి."
elif lang.startswith("kn"):
no_speech_msg = "ನಿಮ್ಮ ಧ್ವನಿ ಕೇಳಿಸಲಿಲ್ಲ. ದಯವಿಟ್ಟು ಮೈಕ್‌ನಲ್ಲಿ ಮಾತನಾಡಿ ನಿಮ್ಮ ಆರೋಗ್ಯ ಸಮಸ್ಯೆಯನ್ನು ತಿಳಿಸಿ."
no_speech_msg = "ಕ್ಷಮಿಸಿ, ನಿಮ್ಮ ಧ್ವನಿ ಕೇಳಿಸಲಿಲ್ಲ. ದಯವಿಟ್ಟು ನಿಮ್ಮ ಆರೋಗ್ಯದ ತೊಂದರೆ ಬಗ್ಗೆ ಮಾತನಾಡಿ."
else:
no_speech_msg = "I didn't hear anything. Please speak into your microphone and describe your health symptoms."
@@ -212,7 +175,6 @@ async def voice_turn(
language_code=session.language,
)
elapsed = time.time() - start_time
return VoiceTurnResponse(
success=True,
session_id=session_id,
@@ -221,10 +183,10 @@ async def voice_turn(
stage=session.stage,
completed=False,
audio_url=f"/v1/voice-onboarding/audio/{audio_id}",
response_time_ms=round(elapsed * 1000, 2),
response_time_sec=round(elapsed, 3),
)
# 3. Save patient message
onboarding_service.save_message(
db=db,
@@ -251,7 +213,6 @@ async def voice_turn(
for m in messages
]
try:
# 5. AI Reasoning & Extraction
ai_response = await ai_service.process_message(
session=session,
@@ -260,33 +221,25 @@ async def voice_turn(
user_message=patient_transcript,
)
# 6. Update Structured Clinical State using shared intelligent merge
from app.ai.orchestrator import merge_state_updates
# 6. Update Structured Clinical State
current_state = json.loads(state.state_json)
updates = ai_response.get("state_updates", {})
if hasattr(updates, "model_dump"):
updates = updates.model_dump()
elif not isinstance(updates, dict):
updates = {}
merged_state = merge_state_updates(current_state, updates)
for k, v in updates.items():
if v is not None:
if isinstance(v, list) and len(v) == 0:
continue
current_state[k] = v
onboarding_service.update_state(
db=db,
state=state,
data=merged_state,
data=current_state,
)
# 7. Update session stage, status & auto-assigned specialty
# 7. Update session stage & status
ai_reply_text = ai_response["reply"]
session.stage = ai_response.get("next_stage", session.stage)
is_completed = bool(ai_response.get("completed", False))
sug_spec = ai_response.get("suggested_specialty")
if sug_spec and sug_spec.get("code") and sug_spec["code"] != "general_medicine":
session.category_code = sug_spec["code"]
session.category_name = sug_spec["name"]
if is_completed:
session.status = "completed"
@@ -301,13 +254,14 @@ async def voice_turn(
stage=session.stage,
)
# 9. Synthesize AI Speech Audio (TTS) asynchronously in background for sub-50ms response
audio_id = f"aud_{uuid4().hex[:12]}"
asyncio.create_task(
voice_service.synthesize_to_file(audio_id, ai_reply_text, session.language)
# 9. Synthesize AI Speech Audio (TTS)
audio_id, audio_bytes = await voice_service.text_to_speech(
text=ai_reply_text,
language_code=session.language,
)
elapsed = time.time() - start_time
audio_b64 = f"data:audio/wav;base64,{base64.b64encode(audio_bytes).decode('utf-8')}" if audio_bytes else None
return VoiceTurnResponse(
success=True,
session_id=session_id,
@@ -316,16 +270,8 @@ async def voice_turn(
stage=session.stage,
completed=is_completed,
audio_url=f"/v1/voice-onboarding/audio/{audio_id}",
audio_base64=None,
suggested_specialty=sug_spec,
response_time_ms=round(elapsed * 1000, 2),
response_time_sec=round(elapsed, 3),
audio_base64=audio_b64,
)
except Exception as err:
import traceback
print(f"[VoiceTurn Error]: {err}")
traceback.print_exc()
raise HTTPException(status_code=500, detail=str(err))
@router.get("/audio/{audio_id}")
@@ -333,32 +279,14 @@ async def get_audio(audio_id: str):
voice_service = VoiceService()
audio_path = voice_service.get_audio_path(audio_id)
# Wait up to 6.0s for background TTS synthesis to complete
for _ in range(60):
if audio_path and audio_path.exists():
if audio_path.stat().st_size > 1000:
break
await asyncio.sleep(0.1)
audio_path = voice_service.get_audio_path(audio_id)
if not audio_path or not audio_path.exists():
from app.services.voice_service import AUDIO_DIR
audio_data = voice_service._generate_fallback_wav()
file_path = AUDIO_DIR / f"{audio_id}.wav"
file_path.write_bytes(audio_data)
audio_path = file_path
raise HTTPException(status_code=404, detail="Audio file not found")
media_type = "audio/mpeg" if audio_path.suffix == ".mp3" else "audio/wav"
return FileResponse(
path=str(audio_path),
media_type=media_type,
filename=audio_path.name,
headers={
"Content-Disposition": "inline",
"Accept-Ranges": "bytes",
"Cache-Control": "no-cache, no-store, must-revalidate",
"Access-Control-Allow-Origin": "*",
},
)
@@ -369,11 +297,8 @@ async def get_audio(audio_id: str):
async def complete_voice_onboarding(
session_id: str,
request: CompleteOnboardingRequest,
auth: AuthContext = Depends(get_auth_context),
db: Session = Depends(get_db),
):
start_time = time.time()
try:
onboarding_service = OnboardingService()
completion_service = CompletionService()
result_service = ResultExtractionService()
@@ -386,14 +311,12 @@ async def complete_voice_onboarding(
detail="Voice onboarding session not found",
)
# 1b. Verify Session Ownership (IDOR Prevention)
verify_session_ownership(session.patient_id, auth, detail="Forbidden: You do not have authorization to complete this voice session.")
state = onboarding_service.get_state(db, session_id)
# Check if there is any conversation to summarize
has_details = completion_service.is_complete(session, state)
messages = (
db.query(OnboardingMessage)
.filter(OnboardingMessage.session_id == session_id)
@@ -411,24 +334,6 @@ async def complete_voice_onboarding(
conversation_history=conversation_history,
)
elapsed = time.time() - start_time
resp_ms = round(elapsed * 1000, 2)
resp_sec = round(elapsed, 3)
if isinstance(result, dict):
result["onboarding_completed"] = True
if session.status == "completed":
return CompleteOnboardingResponse(
success=True,
session_id=session_id,
status="completed",
result=result,
message="Voice onboarding session is already completed.",
response_time_ms=resp_ms,
response_time_sec=resp_sec,
)
if not request.confirmed:
return CompleteOnboardingResponse(
success=True,
@@ -436,25 +341,22 @@ async def complete_voice_onboarding(
status="awaiting_confirmation",
result=result,
message="Please review the collected information and confirm by voice or tap.",
response_time_ms=resp_ms,
response_time_sec=resp_sec,
)
submission = await submission_service.submit(result)
if submission.get("success"):
session.status = "completed"
session.stage = "confirmation"
db.query(OnboardingMessage).filter(OnboardingMessage.session_id == session_id).delete()
db.query(OnboardingState).filter(OnboardingState.session_id == session_id).delete()
db.delete(session)
db.commit()
return CompleteOnboardingResponse(
success=True,
session_id=session_id,
status="completed",
status="submitted_and_cleaned",
result=result,
message="Voice onboarding successfully saved to patient record.",
response_time_ms=resp_ms,
response_time_sec=resp_sec,
)
session.status = "submission_pending_retry"
@@ -466,13 +368,4 @@ async def complete_voice_onboarding(
status="submission_pending_retry",
result=result,
message="Patient API submission failed. Session preserved for retry.",
response_time_ms=resp_ms,
response_time_sec=resp_sec,
)
except HTTPException:
raise
except Exception as err:
import traceback
print(f"[CompleteVoiceOnboarding Error]: {err}")
traceback.print_exc()
raise HTTPException(status_code=500, detail=str(err))
@@ -1,3 +0,0 @@
"""
WebSocket API Package for Real-Time Patient Onboarding.
"""
@@ -1,73 +0,0 @@
"""
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()
@@ -1,225 +0,0 @@
"""
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)})
@@ -1,91 +0,0 @@
"""
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
-42
View File
@@ -21,48 +21,6 @@ GEMINI_MODEL = os.getenv("GEMINI_MODEL", "gemini-3.6-flash")
SARVAM_API_KEY = os.getenv("SARVAM_API_KEY", "")
# Security Keys
API_SECRET_KEY = os.getenv("API_SECRET_KEY", "skyheal_secure_api_key_2026")
# Destination Hospital EHR / Patient API
PATIENT_API_BASE_URL = os.getenv("PATIENT_API_BASE_URL", "")
PATIENT_API_TOKEN = os.getenv("PATIENT_API_TOKEN", "")
# CORS Allowed Origins Configuration
DEFAULT_CORS_ORIGINS = [
"http://localhost:3000",
"http://localhost:5173",
"http://127.0.0.1:3000",
"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",
]
cors_raw = os.getenv("CORS_ORIGINS", "")
if cors_raw:
CORS_ORIGINS = [origin.strip() for origin in cors_raw.split(",") if origin.strip()]
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()
-102
View File
@@ -1,102 +0,0 @@
import os
import time
from typing import Optional, Dict
from fastapi import Header, HTTPException, Security, Depends
from fastapi.security import APIKeyHeader, HTTPBearer, HTTPAuthorizationCredentials
from app.config import APP_ENV
# Configurable API Secret Key for Service-to-Service and Client Auth
API_KEY_NAME = "X-API-Key"
API_SECRET_KEY = os.getenv("API_SECRET_KEY", "skyheal_secure_api_key_2026")
api_key_header = APIKeyHeader(name=API_KEY_NAME, auto_error=False)
security_bearer = HTTPBearer(auto_error=False)
class AuthContext:
def __init__(self, user_id: str, role: str = "patient", patient_id: Optional[str] = None):
self.user_id = user_id
self.role = role
self.patient_id = patient_id or user_id
def get_auth_context(
api_key: Optional[str] = Security(api_key_header),
credentials: Optional[HTTPAuthorizationCredentials] = Security(security_bearer),
x_patient_id: Optional[str] = Header(None, alias="X-Patient-ID")
) -> AuthContext:
"""
Service Authentication & Authorization Dependency.
Validates API key header (X-API-Key) or Bearer token.
In development / testing mode, allows test tokens while enforcing identity isolation.
"""
token = None
if credentials and credentials.credentials:
token = credentials.credentials
elif api_key:
token = api_key
# If no token is provided
if not token:
raise HTTPException(
status_code=401,
detail="Authentication credentials required. Please provide X-API-Key or Bearer token.",
headers={"WWW-Authenticate": "Bearer"}
)
# Validate secret token or bearer token
if token != API_SECRET_KEY and not token.startswith("test_token_"):
raise HTTPException(
status_code=401,
detail="Invalid authentication token or API key.",
headers={"WWW-Authenticate": "Bearer"}
)
# Master API Secret Key has admin/service privileges
if token == API_SECRET_KEY:
patient_id = x_patient_id or "PAT-DEFAULT"
return AuthContext(user_id=patient_id, role="admin", patient_id=patient_id)
else:
patient_id = x_patient_id or token.replace("test_token_", "")
return AuthContext(user_id=patient_id, role="patient", patient_id=patient_id)
def verify_session_ownership(session_patient_id: Optional[str], auth: AuthContext, detail: str = "Forbidden: You do not have authorization to access or modify this onboarding session."):
"""
Verifies session ownership by comparing normalized patient IDs.
Master API key callers (role='admin') are always authorized.
"""
if auth.role == "admin":
return
s_id = (session_patient_id or "").strip()
a_id = (auth.patient_id or "").strip()
if s_id == a_id:
return
# Normalize "PAT-" prefixes for matching (e.g. "PAT-417" vs "417")
s_norm = s_id[4:] if s_id.startswith("PAT-") else s_id
a_norm = a_id[4:] if a_id.startswith("PAT-") else a_id
if s_norm == a_norm:
return
raise HTTPException(status_code=403, detail=detail)
# Simple In-Memory Token Bucket Rate Limiter
RATE_LIMIT_STORE: Dict[str, list] = {}
def check_rate_limit(client_ip: str, limit: int = 30, window_sec: int = 60):
now = time.time()
timestamps = RATE_LIMIT_STORE.get(client_ip, [])
# Filter timestamps within window
timestamps = [t for t in timestamps if now - t < window_sec]
if len(timestamps) >= limit:
retry_after = int(window_sec - (now - timestamps[0]))
raise HTTPException(
status_code=429,
detail=f"Rate limit exceeded ({limit} requests per {window_sec}s). Please try again later.",
headers={"Retry-After": str(max(1, retry_after))}
)
timestamps.append(now)
RATE_LIMIT_STORE[client_ip] = timestamps
+2 -13
View File
@@ -1,7 +1,7 @@
import os
from pathlib import Path
from dotenv import load_dotenv
from sqlalchemy import create_engine, event
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, sessionmaker
load_dotenv()
@@ -24,21 +24,10 @@ else:
engine = create_engine(
DATABASE_URL,
connect_args={
"check_same_thread": False,
"timeout": 15,
"check_same_thread": False
},
)
@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,
@@ -1,35 +0,0 @@
"""
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",
]
@@ -1,67 +0,0 @@
"""
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
@@ -1,28 +0,0 @@
"""
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
@@ -1,87 +0,0 @@
"""
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 -53
View File
@@ -6,7 +6,6 @@ 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
@@ -24,66 +23,31 @@ app = FastAPI(
lifespan=lifespan,
)
from uuid import uuid4
from fastapi import Request
from fastapi.responses import JSONResponse
from app.config import CORS_ORIGINS
# Global Exception Handler (Hides Stack Traces in Production)
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
import traceback
print(f"[Unhandled Server Error] Path: {request.url.path} Error: {exc}")
traceback.print_exc()
return JSONResponse(
status_code=500,
content={
"success": False,
"error": {
"code": "INTERNAL_SERVER_ERROR",
"message": "An unexpected internal server error occurred. Please contact system administration.",
}
}
)
# Security Headers & Request Correlation Middleware
@app.middleware("http")
async def security_and_request_id_middleware(request: Request, call_next):
request_id = request.headers.get("X-Request-ID") or f"req_{uuid4().hex[:12]}"
response = await call_next(request)
response.headers["X-Request-ID"] = request_id
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
return response
# Enable Production-Safe CORS for React Web & Mobile clients (Outermost Layer)
# Enable CORS for React Web & Mobile clients
app.add_middleware(
CORSMiddleware,
allow_origins=CORS_ORIGINS,
allow_credentials=True,
allow_origins=["*"],
allow_credentials=False,
allow_methods=["*"],
allow_headers=["*"],
expose_headers=["X-Request-ID"],
)
@app.options("/{full_path:path}")
async def options_preflight(full_path: str):
from fastapi import Response, status
return Response(
status_code=status.HTTP_204_NO_CONTENT,
headers={
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "*",
},
)
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")
@@ -99,4 +63,3 @@ async def health():
"vision_documents": "Gemini 1.5 Flash",
},
}
+16 -85
View File
@@ -1,11 +1,11 @@
from typing import Any, Literal, Optional, Union
from typing import Any, Literal, Optional
from pydantic import BaseModel, Field
class PatientContext(BaseModel):
id: str = Field(..., min_length=1)
name: Optional[str] = None
age: Optional[int] = Field(None, ge=0, le=120)
age: Optional[int] = None
gender: Optional[str] = None
abha_id: Optional[str] = None
phone: Optional[str] = None
@@ -37,16 +37,7 @@ class StartOnboardingResponse(BaseModel):
class SendMessageRequest(BaseModel):
message: str = Field(..., min_length=1, max_length=5000)
class ErrorDetail(BaseModel):
code: str
message: str
class ErrorResponse(BaseModel):
error: ErrorDetail
message: str = Field(..., min_length=1)
class SendMessageResponse(BaseModel):
@@ -55,64 +46,6 @@ class SendMessageResponse(BaseModel):
stage: str
message: str
status: str
response_time_ms: Optional[float] = None
response_time_sec: Optional[float] = None
class Symptom(BaseModel):
name: str
duration: Optional[str] = None
severity: Optional[str] = None
description: Optional[str] = None
location: Optional[str] = None
onset: Optional[str] = None
frequency: Optional[str] = None
pattern: Optional[str] = None
triggers: Optional[str] = None
relieving_factors: Optional[str] = None
aggravating_factors: Optional[str] = None
class MedicationDetail(BaseModel):
name: str
dosage: Optional[str] = None
frequency: Optional[str] = None
duration: Optional[str] = None
reason: Optional[str] = None
class AllergyDetail(BaseModel):
name: str
type: Optional[str] = None
reaction: Optional[str] = None
class MedicalHistoryDetail(BaseModel):
condition: str
duration: Optional[str] = None
status: Optional[str] = None
class SurgicalHistoryDetail(BaseModel):
procedure: str
approximate_date: Optional[str] = None
reason: Optional[str] = None
class PreviousEpisodeDetail(BaseModel):
description: str
time: Optional[str] = None
outcome: Optional[str] = None
class FamilyHistoryDetail(BaseModel):
condition: str
relationship: Optional[str] = None
class AdditionalInfoDetail(BaseModel):
category: str
value: str
class AIStateUpdate(BaseModel):
@@ -121,10 +54,6 @@ class AIStateUpdate(BaseModel):
medical_history: Optional[Any] = None
medications: Optional[Any] = None
allergies: Optional[Any] = None
surgical_history: Optional[Any] = None
previous_episodes: Optional[Any] = None
family_history: Optional[Any] = None
additional_information: Optional[Any] = None
class AIOnboardingResponse(BaseModel):
@@ -132,7 +61,13 @@ class AIOnboardingResponse(BaseModel):
next_stage: str
state_updates: AIStateUpdate
completed: bool = False
suggested_specialty: Optional[dict[str, Any]] = None
class Symptom(BaseModel):
name: str
duration: Optional[str] = None
severity: Optional[str] = None
description: Optional[str] = None
class PatientOnboardingResult(BaseModel):
@@ -142,19 +77,17 @@ class PatientOnboardingResult(BaseModel):
insurance_scheme: Optional[str] = None
category: dict[str, Any]
chief_complaint: Optional[str] = None
symptoms: list[Union[Symptom, dict, str]] = Field(default_factory=list)
medical_history: list[Union[str, dict, MedicalHistoryDetail]] = Field(default_factory=list)
medications: list[Union[str, dict, MedicationDetail]] = Field(default_factory=list)
allergies: list[Union[str, dict, AllergyDetail]] = Field(default_factory=list)
surgical_history: Optional[list[Union[str, dict, SurgicalHistoryDetail]]] = Field(default_factory=list)
previous_episodes: Optional[list[Union[str, dict, PreviousEpisodeDetail]]] = Field(default_factory=list)
family_history: Optional[list[Union[str, dict, FamilyHistoryDetail]]] = Field(default_factory=list)
symptoms: list[Symptom] = Field(default_factory=list)
medical_history: list[str] = Field(default_factory=list)
medications: list[str] = Field(default_factory=list)
allergies: list[str] = Field(default_factory=list)
dietary_habits: Optional[str] = None
additional_information: Optional[Any] = None
additional_information: Optional[str] = None
language: str
onboarding_completed: bool = True
class CompleteOnboardingRequest(BaseModel):
confirmed: bool = False
@@ -165,5 +98,3 @@ class CompleteOnboardingResponse(BaseModel):
status: str
result: Optional[PatientOnboardingResult] = None
message: str
response_time_ms: Optional[float] = None
response_time_sec: Optional[float] = None
+2 -6
View File
@@ -5,7 +5,7 @@ from app.schemas.onboarding import CategoryContext, PatientContext
class StartVoiceOnboardingRequest(BaseModel):
patient: PatientContext
category: Optional[CategoryContext] = None
category: CategoryContext
language: str = "en-IN"
@@ -19,8 +19,7 @@ class StartVoiceOnboardingResponse(BaseModel):
stage: str
greeting_text: str
audio_url: str
response_time_ms: Optional[float] = None
response_time_sec: Optional[float] = None
audio_base64: Optional[str] = None
class VoiceTurnResponse(BaseModel):
@@ -32,6 +31,3 @@ class VoiceTurnResponse(BaseModel):
completed: bool = False
audio_url: str
audio_base64: Optional[str] = None
suggested_specialty: Optional[dict] = None
response_time_ms: Optional[float] = None
response_time_sec: Optional[float] = None
@@ -1,5 +1,4 @@
import json
from app.ai.orchestrator import check_required_missing
class CompletionService:
@@ -9,25 +8,45 @@ class CompletionService:
session,
state,
) -> bool:
"""
Clinical intake completion validation using the single source of truth:
check_required_missing().
data = json.loads(
state.state_json
)
reason = data.get("reason_for_visit")
symptoms = data.get("symptoms")
history = data.get("medical_history")
medications = data.get("medications")
allergies = data.get("allergies")
# Check structured status dictionary format
if isinstance(reason, dict):
if reason.get("status") == "not_collected" or not reason.get("value"):
return False
elif not reason:
return False
if isinstance(symptoms, dict):
if symptoms.get("status") == "not_collected" or len(symptoms.get("items", [])) == 0:
return False
elif not symptoms:
return False
if isinstance(history, dict):
if history.get("status") == "not_collected":
return False
elif history is None:
return False
if isinstance(medications, dict):
if medications.get("status") == "not_collected":
return False
elif medications is None:
return False
if isinstance(allergies, dict):
if allergies.get("status") == "not_collected":
return False
elif allergies is None:
return False
Returns True only when:
- Session status is already 'completed' or stage is 'confirmation', OR
- All required categories (reason_for_visit, symptoms+duration,
medical_history, medications, allergies) are either reported or confirmed_none.
"""
if getattr(session, "status", None) == "completed" or getattr(session, "stage", None) == "confirmation":
return True
if not state or not getattr(state, "state_json", None):
return False
try:
data = json.loads(state.state_json)
except Exception:
return False
missing = check_required_missing(data)
return len(missing) == 0
@@ -1,427 +0,0 @@
"""
Contextual Normalizer for Clinical Intake.
Provides:
- Tamil, Tanglish, Hindi, and English number & duration normalization.
- Contextual intent recognition from previous AI dialogue turns.
- Non-symptom expression filters (duration, severity, location, negative phrases, filler).
- Deduplication and deterministic clinical attribute extraction.
"""
import re
from typing import Optional, Tuple, Dict, Any, List
# 1. Spoken Number Mappings across Tamil, Tanglish, Hindi, English
TAMIL_NUMBER_MAP: Dict[str, int] = {
"ஒரு": 1, "ஒண்ணு": 1, "ஒன்று": 1, "ஒன்னு": 1,
"ரெண்டு": 2, "இரண்டு": 2,
"மூணு": 3, "மூன்று": 3,
"நாலு": 4, "நான்கு": 4,
"அஞ்சு": 5, "ஐந்து": 5,
"ஆறு": 6,
"ஏழு": 7,
"எட்டு": 8,
"ஒன்பது": 9,
"பத்து": 10,
"பதினொன்னு": 11, "பதினொன்று": 11,
"பன்னிரண்டு": 12, "பன்னிரெண்டு": 12,
"பதின்மூன்று": 13,
"பதினாலு": 14, "பதினான்கு": 14,
"பதினைந்து": 15, "பதினாஞ்சு": 15,
"பதினாறு": 16,
"பதினேழு": 17,
"பதினெட்டு": 18,
"பத்தொன்பது": 19,
"இருபது": 20, "இருபத்தஞ்சு": 25,
"முப்பது": 30,
"நாற்பது": 40,
"ஐம்பது": 50,
}
TANGLISH_NUMBER_MAP: Dict[str, int] = {
"oru": 1, "onnu": 1, "ondru": 1,
"rendu": 2, "irandu": 2,
"moonu": 3, "moonru": 3,
"naalu": 4, "nangu": 4,
"anju": 5, "ainthu": 5,
"aaru": 6,
"yezhu": 7, "ezhu": 7, "elu": 7,
"ettu": 8,
"onbathu": 9, "ombadhu": 9,
"pathu": 10, "padhu": 10,
"pathinonnu": 11,
"pannirendu": 12,
"pathinaanchu": 15, "pathinainthu": 15,
"irubathu": 20, "irupathu": 20,
"muppathu": 30,
}
HINDI_NUMBER_MAP: Dict[str, int] = {
"एक": 1, "दो": 2, "तीन": 3, "चार": 4, "पांच": 5, "पाँच": 5,
"छह": 6, "सात": 7, "आठ": 8, "नौ": 9, "दस": 10,
"ग्यारह": 11, "बारह": 12, "पंद्रह": 15, "बीस": 20, "तीस": 30, "चालीस": 40, "पचास": 50,
}
TELUGU_NUMBER_MAP: Dict[str, int] = {
"ఒకటి": 1, "ఒక": 1, "రెండు": 2, "మూడు": 3, "నాలుగు": 4, "ఐదు": 5,
"ఆరు": 6, "ఏడు": 7, "ఎనిమిది": 8, "తొమ్మిది": 9, "పది": 10,
}
KANNADA_NUMBER_MAP: Dict[str, int] = {
"ಒಂದು": 1, "ಒಂದ": 1, "ಎರಡು": 2, "ಮೂರು": 3, "ನಾಲ್ಕು": 4, "ಐದು": 5,
"ಆರು": 6, "ಏಳು": 7, "ಎಂಟು": 8, "ಒಂಬತ್ತು": 9, "ಹತ್ತು": 10,
}
ENGLISH_NUMBER_MAP: Dict[str, int] = {
"a": 1, "an": 1, "one": 1, "two": 2, "three": 3, "four": 4,
"five": 5, "six": 6, "seven": 7, "eight": 8, "nine": 9, "ten": 10,
"eleven": 11, "twelve": 12, "thirteen": 13, "fourteen": 14, "fifteen": 15,
"sixteen": 16, "seventeen": 17, "eighteen": 18, "nineteen": 19,
"twenty": 20, "twenty-five": 25, "twenty five": 25, "thirty": 30,
"forty": 40, "fifty": 50,
}
def normalize_duration_from_message(message: str) -> Optional[str]:
"""
Detects and canonicalizes duration phrases across English, Tamil, Tanglish, and Mixed forms.
Returns canonical strings such as: '10 days', '2 days', '1 week', '2 months', 'since yesterday',
'since last week', 'since Monday', 'for a few days', 'for many months'.
Returns None if no duration is recognized.
"""
if not message or not message.strip():
return None
msg = message.strip()
msg_lower = msg.lower()
# 1. Check relative date / anchor phrases
rel_patterns = [
(r"(?:நேற்றிலிருந்து|நேத்து\s*ல\s*இருந்து|நேத்துல\s*இருந்து|நேத்து\s*இருந்து|நேற்று\s*முதல்|since\s*yesterday|कल\s*से|निन्नೆಯಿಂದ|నిన్నటి\s*నుండి)", "since yesterday"),
(r"(?:இன்னைக்கு\s*காலையிலிருந்து|காலையிலிருந்து|today\s*morning|since\s*morning|आज\s*सुबह\s*से)", "since morning"),
(r"(?:since\s*last\s*night|நேத்து\s*ராத்திரி\s*இருந்து|நேத்து\s*நைட்டிலிருந்து)", "since last night"),
(r"(?:since\s*last\s*week|போன\s*வாரத்திலிருந்து|पिछले\s*हफ़्ते\s*से)", "since last week"),
(r"(?:since\s*(?:monday|tuesday|wednesday|thursday|friday|saturday|sunday))", lambda m: m.group(0).strip()),
(r"(?:for\s*a\s*few\s*days|few\s*days|சில\s*நாட்களா|கொஞ்ச\s*நாளா|कुछ\s*दिनों\s*से)", "for a few days"),
(r"(?:for\s*many\s*months|ரொம்ப\s*மாசமா|பல\s*மாதங்களா|कई\s*महीनों\s*से)", "for many months"),
]
for pat, canonical in rel_patterns:
m = re.search(pat, msg, re.IGNORECASE) or re.search(pat, msg_lower, re.IGNORECASE)
if m:
if callable(canonical):
return canonical(m)
return canonical
# 2. Number word or digit + Unit detection
all_tamil_nums = "|".join(sorted(TAMIL_NUMBER_MAP.keys(), key=len, reverse=True))
all_tanglish_nums = "|".join(sorted(TANGLISH_NUMBER_MAP.keys(), key=len, reverse=True))
all_hindi_nums = "|".join(sorted(HINDI_NUMBER_MAP.keys(), key=len, reverse=True))
all_telugu_nums = "|".join(sorted(TELUGU_NUMBER_MAP.keys(), key=len, reverse=True))
all_kannada_nums = "|".join(sorted(KANNADA_NUMBER_MAP.keys(), key=len, reverse=True))
all_eng_nums = "|".join(sorted(ENGLISH_NUMBER_MAP.keys(), key=len, reverse=True))
num_regex_part = rf"(\d+|{all_tamil_nums}|{all_tanglish_nums}|{all_hindi_nums}|{all_telugu_nums}|{all_kannada_nums}|{all_eng_nums})"
unit_regex_part = (
r"(?:days|day|டேஸா|டேஸ்|டே|நாட்களா|நாட்களாக|நாளா|நாளாக|நாளு|நாட்கள்|நாள்|"
r"weeks|week|வாரமா|வாரமாக|வாரம்|வாரங்கள்|"
r"months|month|மாசம்|மாசமா|மாதமா|மாதம்|மாதங்களாக|"
r"years|year|வருஷம்|வருசமா|வருடம்|வருடங்கள்|"
r"hours|hour|hrs|மணி\s*நேரம்|மணி|"
r"naala|naal\s*ah|naal|nala|vaarama|vaaram|maasama|maasam|varusham|varushama|"
r"दिनों|दिन|हफ़्ते|हफ्ता|महीने|साल|घंटे|"
r"రోజుల\s*నుండి|రోజుల\s*నుంచి|రోజులుగా|రోజుల|రోజులు|వారం|వారాలు|"
r"ದಿನದಿಂದ|ದಿನಗಳಿಂದ|ದಿನಗಳು|ದಿನ|ವಾರದಿಂದ|ವಾರಗಳು)"
)
# Prefix qualifiers: "ஒரு 10 டேஸா", "about 10 days", "for 10 days", "இன்னும் ஒரு பத்து நாளா"
full_pattern = (
rf"(?:(?:எனக்கு\s*வந்து\s*)?(?:இன்னும்\s*)?(?:ஒரு|oru|about|around|for\s+the\s+last|for|approx|approximately)\s+)?"
rf"{num_regex_part}\s*[-]?\s*{unit_regex_part}"
rf"(?:[-]?\s*(?:ah|a|ஆ|இருக்கு|ஆச்சு|ஆகியது|ஆகப்\s*போகுது|உంది|ಇದೆ|now))?"
)
match = re.search(full_pattern, msg, re.IGNORECASE) or re.search(full_pattern, msg_lower, re.IGNORECASE)
if match:
raw_num_str = match.group(1).lower().strip()
matched_text = match.group(0).lower()
# Parse number
val = None
if raw_num_str.isdigit():
val = int(raw_num_str)
elif raw_num_str in TAMIL_NUMBER_MAP:
val = TAMIL_NUMBER_MAP[raw_num_str]
elif raw_num_str in TANGLISH_NUMBER_MAP:
val = TANGLISH_NUMBER_MAP[raw_num_str]
elif raw_num_str in HINDI_NUMBER_MAP:
val = HINDI_NUMBER_MAP[raw_num_str]
elif raw_num_str in TELUGU_NUMBER_MAP:
val = TELUGU_NUMBER_MAP[raw_num_str]
elif raw_num_str in KANNADA_NUMBER_MAP:
val = KANNADA_NUMBER_MAP[raw_num_str]
elif raw_num_str in ENGLISH_NUMBER_MAP:
val = ENGLISH_NUMBER_MAP[raw_num_str]
# Determine unit
unit = "days"
if any(w in matched_text for w in ["week", "வாரம்", "வார", "vaaram", "vaarama", "हफ़्ते", "हफ्ता", "వారం", "ವಾರ"]):
unit = "week" if val == 1 else "weeks"
elif any(w in matched_text for w in ["month", "மாச", "மாத", "maasam", "maasama", "महीने", "నెలా", "ತಿಂಗಳು"]):
unit = "month" if val == 1 else "months"
elif any(w in matched_text for w in ["year", "வருஷ", "வருட", "varusham", "varushama", "साल", "సంవత్సరం", "ವರ್ಷ"]):
unit = "year" if val == 1 else "years"
elif any(w in matched_text for w in ["hour", "மணி", "hrs", "घंटे", "గంటలు", "ಗಂಟೆ"]):
unit = "hour" if val == 1 else "hours"
else:
unit = "day" if val == 1 else "days"
if val is not None:
return f"{val} {unit}"
# 3. Fallback loose check for standalone Tanglish/Tamil tokens (e.g. "10 days-ah", "10 naala", "2 days-ah")
loose_match = re.search(r"(\d+)\s*[-]?\s*(?:days?|naal[a]?|nal[a]?|weeks?|months?|years?|டேஸா?|டேஸ்|நாளா?|நாட்களா?)(?:[-]?\s*(?:ah|a|இருக்கு))?", msg_lower)
if loose_match:
val = int(loose_match.group(1))
unit = "day" if val == 1 else "days"
if "week" in loose_match.group(0):
unit = "week" if val == 1 else "weeks"
elif "month" in loose_match.group(0):
unit = "month" if val == 1 else "months"
elif "year" in loose_match.group(0):
unit = "year" if val == 1 else "years"
return f"{val} {unit}"
return None
def detect_previous_question_intent(last_ai_message: str) -> Optional[str]:
"""
Identifies what clinical category the previous AI message asked about.
Returns one of: 'duration', 'severity', 'location', 'onset', 'frequency',
'triggers', 'relieving_factors', 'aggravating_factors', 'medications',
'allergies', 'medical_history', 'surgical_history', 'family_history',
'previous_episodes', or None.
"""
if not last_ai_message or not last_ai_message.strip():
return None
msg = last_ai_message.lower()
# Duration indicators
if any(k in msg for k in [
"எத்தனை நாளா", "எப்போலிருந்து", "எத்தனை நாள்", "எப்போ இருந்து", "கால அளவு",
"how long", "since when", "how many days", "how many weeks", "duration",
"कितने दिनों से", "कब से", "कितने दिन",
"ఎన్ని రోజుల", "ఎప్పటి నుండి", "ఎన్ని రోజులు",
"ಎಷ್ಟು ದಿನದಿಂದ", "ಯಾವಾಗಿನಿಂದ", "ಎಷ್ಟು ದಿನ",
]):
return "duration"
# Severity indicators
if any(k in msg for k in [
"எவ்வளவு தீவிரமா", "ரொம்ப வலியா", "தீவிரம்", "தாங்க முடியாத", "எப்படி இருக்கு வலி",
"how severe", "how bad", "mild or severe", "severity", "scale of 1",
"कितना तेज", "तीव्रता", "ఎంత తీవ్రంగా", "ಎಷ್ಟು ತೀವ್ರ",
]):
return "severity"
# Location indicators
if any(k in msg for k in [
"எந்த இடத்துல", "எந்த பக்கத்துல", "எங்கே வலிக்குது", "எங்கு", "எந்த பகுதி",
"where does it hurt", "which side", "location of the pain", "where is",
"कहाँ दर्द", "किस जगह", "ఎక్కడ నొప్పి", "ಯಾವ ಜಾಗದಲ್ಲಿ",
]):
return "location"
# Onset indicators
if any(k in msg for k in [
"திடீர்னு", "படிப்படியா", "எப்படி ஆரம்பிச்சது", "onset", "sudden", "gradual", "how did it start",
"अचानक", "धीरे-धीरे",
]):
return "onset"
# Frequency indicators
if any(k in msg for k in [
"எத்தனை வாட்டி", "எத்தனை முறை", "அடிக்கடி", "frequency", "how often", "how many times",
"कितनी बार", "ఎన్ని సార్లు", "ಎಷ್ಟು ಬಾರಿ",
]):
return "frequency"
# Triggers / Aggravating / Relieving indicators
if any(k in msg for k in [
"எப்போ அதிகமாகுது", "எப்போ குறையுது", "சாப்பிட்ட பிறகு", "trigger", "worse", "better", "relieve", "aggravate",
]):
return "triggers"
# Combined Medications + Allergies indicators (asked together in one question)
has_med_kw = any(k in msg for k in [
"மாத்திரை", "மருந்து", "மெடிசின்", "medication", "medicine", "tablets", "taking anything", "taken any",
"दवाई", "दवा", "మందులు", "ಔಷಧ",
])
has_alg_kw = any(k in msg for k in [
"அலர்ஜி", "ஒவ்வாமை", "allergy", "allergies", "allergic",
"एलर्जी", "అలర్జీ", "ಅಲರ್ಜಿ",
])
if has_med_kw and has_alg_kw:
return "medications_and_allergies"
# Medications indicators
if has_med_kw or any(k in msg for k in [
"டேப்லெட்", "எடுத்துக்குறீங்களா", "சாப்பிட்டீங்களா", "சாப்பிடுறீங்களா",
"prescribed", "மாத்திரை சாப்பிடுறீங்களா",
"మాత్ర", "ಮಾತ್ರೆ",
]):
return "medications"
# Allergies indicators
if has_alg_kw or any(k in msg for k in [
"ஏதேனும் அலர்ஜி", "மாத்திரை அலர்ஜி",
"reaction to any medicine",
]):
return "allergies"
# Medical History indicators
if any(k in msg for k in [
"பிபி", "சுகர்", "ஆஸ்துமா", "முந்தைய", "உடம்பு பிரச்சனை", "வேறு ஏதேனும்",
"medical history", "chronic condition", "past illness", "diabetes", "hypertension",
"पुरानी बीमारी", "बीपी", "शुगर", "పాత ఆరోగ్య సమస్యలు", "హಳೆಯ ತೊಂದರೆ",
]):
return "medical_history"
# Surgical History indicators
if any(k in msg for k in [
"ஆபரேஷன்", "அறுவை சிகிச்சை", "surgery", "operation", "surgical history", "ऑपरेशन",
]):
return "surgical_history"
# Family History indicators
if any(k in msg for k in [
"குடும்பத்துல", "குடும்ப பின்னணி", "family history", "family members", "parivarik",
]):
return "family_history"
# Previous Episodes indicators
if any(k in msg for k in [
"இதுக்கு முன்னாடி", "முன்னாடி வந்திருக்கா", "previous episode", "had this before", "same problem before",
]):
return "previous_episodes"
return None
def is_duration_expression(text: str) -> bool:
"""Check if the text is primarily a duration statement rather than a symptom name."""
if not text:
return False
norm = normalize_duration_from_message(text)
if norm is not None:
clean = re.sub(r"[^\w\s\u0B80-\u0BFF]", "", text.lower()).strip()
words = clean.split()
if len(words) <= 8:
return True
filler_words = {"எனக்கு", "வந்து", "ஒரு", "இன்னும்", "இருக்கு", "ஆச்சு", "ஆகுது", "ஆ", "for", "me", "it", "is", "about", "around", "since", "from", "now"}
non_filler = [w for w in words if w not in filler_words]
if len(non_filler) <= 3:
return True
return False
def is_severity_expression(text: str) -> bool:
"""Check if the text is primarily a severity descriptor."""
if not text:
return False
t_lower = text.lower().strip()
patterns = [
"mild", "moderate", "severe", "very severe", "unbearable", "high",
"ரொம்ப severe", "ரொம்ப அதிகம்", "மிதமான", "தாங்க முடியாத", "அதிகமா இருக்கு", "ரொம்ப வலி",
"बहुत तेज", "हल्का", "कम", "చాలా తీవ్రంగా", "తక్కువగా", "ತುಂಬಾ ಜಾಸ್ತಿ",
]
return any(p in t_lower for p in patterns) and len(text.split()) <= 6
def is_location_expression(text: str) -> bool:
"""Check if the text is primarily a body location descriptor."""
if not text:
return False
t_lower = text.lower().strip()
patterns = [
"right side", "left side", "front", "back", "lower abdomen", "upper abdomen", "both sides",
"வலது பக்கம்", "இடது பக்கம்", "வலது பக்கத்துல", "இடது பக்கத்துல", "முன் பக்கம்", "பின் பக்கம்", "அடிவயிறு",
"दाएं तरफ", "बाएं तरफ", "పీట", "కుడి వైపు", "ఎడమ వైపు", "ಬಲಗಡೆ", "ಎಡಗಡೆ",
]
return any(p in t_lower for p in patterns) and len(text.split()) <= 6
def is_negative_expression(text: str) -> bool:
"""Check if text is an explicit negation / nothing else."""
if not text:
return False
t_clean = re.sub(r"[^\w\s\u0B80-\u0BFF\u0900-\u097F\u0C00-\u0C7F\u0C80-\u0CFF]", "", text.lower()).strip()
words = t_clean.split()
if not words:
return False
negative_keywords = [
"no", "no more", "nothing", "nothing else", "none", "no illness", "no problem", "that's all", "thats all",
"இல்லை", "இல்லைங்க", "இல்ல", "ஒன்றுமில்லை", "ஒன்னுமில்ல", "ஏதுமில்லை", "அவ்வளவுதான்", "வேற எதுவும் இல்ல",
"எனக்கு வேற எதுவும் இல்ல", "வேறு எதுவும் இல்லை", "வேறு எதுவும் இல்ல", "வேற எதுவும் இல்லை",
"नहीं", "कुछ नहीं", "कोई नहीं", "बस यही है", "और कुछ नहीं",
"లేదు", "ఏమీ లేదు", "లేదండి", "ఇంతేనండి", "ఇంకేమీ లేదు",
"ಇಲ್ಲ", "ಇಲ್ಲವೇ ಇಲ್ಲ", "ಏನೂ ಇಲ್ಲ", "ಅಷ್ಟೇ",
"ഇല്ല", "ഒന്നുമില്ല",
]
for k in negative_keywords:
k_clean = re.sub(r"[^\w\s\u0B80-\u0BFF\u0900-\u097F\u0C00-\u0C7F\u0C80-\u0CFF]", "", k.lower()).strip()
if k_clean == t_clean:
return True
if len(words) <= 6:
pattern = rf"(?:^|\s){re.escape(k_clean)}(?:\s|$)"
if re.search(pattern, t_clean):
return True
return False
def is_casual_filler(text: str) -> bool:
"""Identify casual filler statements that should not be extracted as clinical entities."""
if not text or not str(text).strip():
return False
t_lower = str(text).lower().strip()
filler_patterns = [
r"^(?:சரி\s*சார்\s*சரி\s*சார்|சரி\s*சார்|சரிங்க|ஓகே\s*சார்|ஓகேங்க|ok\s*sir|ok\s*mam|okay|yes|yeah|sure)$",
r"^(?:நான்\s*bus-?ல\s*வந்தேன்|auto-?ல\s*வந்தேன்|நடந்து\s*வந்தேன்)$",
r"^(?:hello|hi|vanakkam|namaste)$",
]
return any(re.search(p, t_lower) for p in filler_patterns)
def extract_severity_from_message(msg: str) -> Optional[str]:
"""Extract normalized severity string."""
m_lower = msg.lower()
if any(k in m_lower for k in ["very severe", "unbearable", "ரொம்ப அதிகம்", "தாங்க முடியாத", "बहुत तेज"]):
return "severe"
if any(k in m_lower for k in ["severe", "ரொம்ப severe", "அதிகமா", "तेज"]):
return "severe"
if any(k in m_lower for k in ["moderate", "medium", "மிதமான", "मध्यम"]):
return "moderate"
if any(k in m_lower for k in ["mild", "light", "லேசா", "हल्का"]):
return "mild"
return None
def extract_location_from_message(msg: str) -> Optional[str]:
"""Extract normalized location string."""
m_lower = msg.lower()
if any(k in m_lower for k in ["right side", "வலது பக்கம்", "வலது பக்கத்துல", "दाएं"]):
return "right side"
if any(k in m_lower for k in ["left side", "இடது பக்கம்", "இடது பக்கத்துல", "बाएं"]):
return "left side"
if any(k in m_lower for k in ["front", "முன் பக்கம்"]):
return "front"
if any(k in m_lower for k in ["back", "பின் பக்கம்"]):
return "back"
if any(k in m_lower for k in ["lower abdomen", "அடிவயிறு"]):
return "lower abdomen"
return None
@@ -54,11 +54,6 @@ def normalize_medical_speech(transcript: str, language_code: str = "en-IN") -> s
for pattern, replacement in PHONETIC_REPLACEMENTS.items():
cleaned = re.sub(pattern, replacement, cleaned, flags=re.IGNORECASE)
# HIPAA & DPDP Act 2023 PII/SPI Redaction: Scrub sensitive non-medical national IDs & card numbers
cleaned = re.sub(r"\b\d{4}[-\s]?\d{4}[-\s]?\d{4}[-\s]?\d{4}\b", "[REDACTED_FINANCIAL_CARD]", cleaned)
cleaned = re.sub(r"\b\d{4}[-\s]?\d{4}[-\s]?\d{4}\b", "[REDACTED_NATIONAL_ID]", cleaned)
cleaned = re.sub(r"\b\d{3}[-\s]?\d{2}[-\s]?\d{4}\b", "[REDACTED_SSN]", cleaned)
# Standardize spaces and punctuation
cleaned = re.sub(r"\s+([.,!?])", r"\1", cleaned)
cleaned = re.sub(r"\s+", " ", cleaned).strip()
@@ -57,11 +57,7 @@ class OnboardingService:
"allergies": {
"status": "not_collected",
"items": []
},
"surgical_history": [],
"previous_episodes": [],
"family_history": [],
"additional_information": []
}
}
state = OnboardingState(
@@ -20,40 +20,37 @@ class ResultExtractionService:
)
prompt = f"""
You are a senior clinical data extraction engine at a healthcare facility.
You are a hospital patient onboarding data extraction assistant.
Your task is to convert the collected patient onboarding conversation and structured state
into a clean, comprehensive, structured patient intake record for the attending doctor.
Your task is to convert the completed onboarding information
into structured patient intake data.
CLINICAL RULES:
1. You are NOT a doctor. Do NOT diagnose or add medical interpretations.
2. Capture ALL patient-reported symptoms, onset/duration, severity, location, frequency.
3. Capture ALL reported medical history, medications (with dosage/frequency), allergies (with reaction), surgical history, and previous episodes.
4. Capture any functional impact (sleep disruption, appetite changes, weakness) in additional_information.
5. Do NOT invent information. If something was not provided, leave it null or an empty list.
You are NOT a doctor.
Do not diagnose the patient.
Do not add information that was not provided.
PATIENT CONTEXT:
PATIENT:
{json.dumps({
"id": session.patient_id,
"name": session.patient_name,
"age": session.age,
"gender": session.gender,
}, indent=2, ensure_ascii=False)}
}, indent=2)}
CATEGORY / SPECIALTY:
CATEGORY:
{json.dumps({
"type": session.category_type,
"code": session.category_code,
"name": session.category_name,
}, indent=2, ensure_ascii=False)}
}, indent=2)}
CURRENT INTAKE STATE:
{json.dumps(current_state, indent=2, ensure_ascii=False)}
CURRENT STATE:
{json.dumps(current_state, indent=2)}
FULL CONVERSATION:
{json.dumps(conversation_history, indent=2, ensure_ascii=False)}
CONVERSATION:
{json.dumps(conversation_history, indent=2)}
Return ONLY a valid JSON object matching this structure:
Return ONLY JSON conforming to this exact structure:
{{
"patient_id": "{session.patient_id}",
"category": {{
@@ -61,75 +58,24 @@ Return ONLY a valid JSON object matching this structure:
"code": "{session.category_code}",
"name": "{session.category_name}"
}},
"chief_complaint": "primary reason for visit or main symptom",
"chief_complaint": "string or null",
"symptoms": [
{{
"name": "symptom name",
"duration": "duration or null",
"severity": "severity or null",
"location": "location or null",
"description": "brief details or null"
"name": "string",
"duration": "string or null",
"severity": "string or null",
"description": "string or null"
}}
],
"medical_history": [
{{
"condition": "condition name",
"duration": "duration or null",
"status": "active/past or null"
}}
],
"medications": [
{{
"name": "medication name",
"dosage": "dosage or null",
"frequency": "frequency or null",
"duration": "duration or null"
}}
],
"allergies": [
{{
"name": "allergen name",
"type": "drug/food/etc or null",
"reaction": "reaction or null"
}}
],
"surgical_history": [],
"previous_episodes": [],
"family_history": [],
"dietary_habits": null,
"additional_information": "any functional impact, previous episodes, or additional notes",
"medical_history": [],
"medications": [],
"allergies": [],
"additional_information": "string or null",
"language": "{session.language}",
"onboarding_completed": true
}}
"""
from app.config import GEMINI_API_KEY, GEMINI_MODEL, ANTHROPIC_API_KEY, ANTHROPIC_MODEL
# Primary: Google Gemini extraction
if GEMINI_API_KEY and GEMINI_API_KEY.strip() not in ["your_gemini_key", "your_gemini_api_key", ""]:
try:
import asyncio
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
)
cleaned = res.text.replace("```json", "").replace("```", "").strip()
parsed = json.loads(cleaned)
validated = PatientOnboardingResult.model_validate(parsed)
return validated.model_dump()
except Exception as e:
print(f"[Gemini Result Extraction Warning]: {e}")
# Secondary: OpenAI extraction
if OPENAI_API_KEY and OPENAI_API_KEY.strip() not in ["your_openai_api_key", "your_openai_key", ""]:
try:
raw_response = await self.openai.generate_response(
@@ -149,7 +95,22 @@ Return ONLY a valid JSON object matching this structure:
validated = PatientOnboardingResult.model_validate(parsed)
return validated.model_dump()
except Exception as e:
print(f"[OpenAI Result Extraction Warning]: {e}")
print(f"[OpenAI Result Extraction Error]: {e}")
# Secondary: Google Gemini extraction
from app.config import GEMINI_API_KEY, GEMINI_MODEL, ANTHROPIC_API_KEY, ANTHROPIC_MODEL
if GEMINI_API_KEY and GEMINI_API_KEY.strip() not in ["your_gemini_key", ""]:
try:
import google.generativeai as genai
genai.configure(api_key=GEMINI_API_KEY)
model = genai.GenerativeModel(GEMINI_MODEL or "gemini-3.6-flash")
res = await model.generate_content_async(prompt)
cleaned = res.text.replace("```json", "").replace("```", "").strip()
parsed = json.loads(cleaned)
validated = PatientOnboardingResult.model_validate(parsed)
return validated.model_dump()
except Exception as e:
print(f"[Gemini Result Extraction Error]: {e}")
# Tertiary: Anthropic Claude extraction
if ANTHROPIC_API_KEY and ANTHROPIC_API_KEY.strip() not in ["your_anthropic_key", ""]:
@@ -169,7 +130,7 @@ Return ONLY a valid JSON object matching this structure:
except Exception as e:
print(f"[Claude Result Extraction Error]: {e}")
# Fallback structured builder from state
# Fallback simulator from state (strictly without fake dummy strings)
chief_complaint = current_state.get("reason_for_visit")
if isinstance(chief_complaint, dict):
chief_complaint = chief_complaint.get("value")
@@ -190,11 +151,6 @@ Return ONLY a valid JSON object matching this structure:
history_raw = current_state.get("medical_history")
history_list = history_raw.get("items", []) if isinstance(history_raw, dict) else (history_raw or [])
surgical_history = current_state.get("surgical_history", [])
previous_episodes = current_state.get("previous_episodes", [])
family_history = current_state.get("family_history", [])
add_info = current_state.get("additional_information")
fallback_result = {
"patient_id": session.patient_id,
"category": {
@@ -207,10 +163,7 @@ Return ONLY a valid JSON object matching this structure:
"medical_history": history_list,
"medications": meds_list,
"allergies": allergies_list,
"surgical_history": surgical_history if isinstance(surgical_history, list) else [],
"previous_episodes": previous_episodes if isinstance(previous_episodes, list) else [],
"family_history": family_history if isinstance(family_history, list) else [],
"additional_information": add_info,
"additional_information": None,
"language": session.language,
"onboarding_completed": True,
}
@@ -3,6 +3,7 @@ import io
import os
import wave
import struct
import hashlib
from pathlib import Path
from uuid import uuid4
import httpx2
@@ -13,6 +14,8 @@ from app.services.medical_speech_normalizer import normalize_medical_speech
BASE_DIR = Path(__file__).resolve().parent.parent.parent
AUDIO_DIR = BASE_DIR / "data" / "audio"
CACHE_DIR = AUDIO_DIR / "cache"
CACHE_DIR.mkdir(parents=True, exist_ok=True)
AUDIO_DIR.mkdir(parents=True, exist_ok=True)
@@ -33,9 +36,19 @@ class VoiceService:
elif lang_lower.startswith("hi"):
return f"नमस्ते {name} जी, CureSelect अस्पताल में आपका स्वागत है। आज आपको क्या तकलीफ या लक्षण महसूस हो रहे हैं, कृपया बताएंगे?"
elif lang_lower.startswith("te"):
return f"నమస్కారం {name} గారు, CureSelect హాస్పిటల్‌కు స్వాగతం. ఈరోజు మీకున్న ఆరోగ్య సమస్య గురించి చెప్పగలరా?"
return f"నమస్కారం {name} గారు, CureSelect హాస్పిటల్‌కు స్వాగతం. ఈరోజు మీకున్న ఆరోగ్య సమస్యలు లేదా లక్షణాల గురించి చెప్పగలరా?"
elif lang_lower.startswith("kn"):
return f"ನಮಸ್ಕಾರ {name} ಅವರೇ, CureSelect ಆಸ್ಪತ್ರೆಗೆ ಸುಸ್ವಾಗತ. ಇಂದು ನಿಮ್ಮ ಆರೋಗ್ಯದ ತೊಂದರೆ ಬಗ್ಗೆ ತಿಳಿಸಬಹುದೇ?"
return f"ನಮಸ್ಕಾರ {name} ಅವರೇ, CureSelect ಆಸ್ಪತ್ರೆಗೆ ಸುಸ್ವಾಗತ. ಇಂದು ನಿಮ್ಮ ಆರೋಗ್ಯದಲ್ಲಿ ಏನು ತೊಂದರೆ ಇದೆ ಎಂದು ತಿಳಿಸಬಹುದೇ?"
elif lang_lower.startswith("ml"):
return f"നമസ്കാരം {name}, CureSelect ആശുപത്രിയിലേക്ക് സ്വാഗതം. ഇന്ന് താങ്കൾക്ക് എന്തെല്ലാം ആരോഗ്യ ബുദ്ധിമുട്ടുകളാണ് ഉള്ളത് എന്ന് പറയാമോ?"
elif lang_lower.startswith("bn"):
return f"নমস্কার {name}, CureSelect হাসপাতালে আপনাকে স্বাগতম। আজ আপনার কি ধরনের শারীরিক অসুবিধা হচ্ছে বলবেন কি?"
elif lang_lower.startswith("mr"):
return f"नमस्कार {name} जी, CureSelect रुग्णालयात आपले स्वागत आहे। आज आपल्याला काय त्रास होत आहे, ते सांगू शकाल का?"
elif lang_lower.startswith("gu"):
return f"નમસ્તે {name} જી, CureSelect હોસ્પિટલમાં આપનું સ્વાગત છે। આજે આપને શું તકલીફ કે લક્ષણો છે, જરા જણાવશો?"
elif lang_lower.startswith("pa"):
return f"ਸਤਿ ਸ਼੍ਰੀ ਅਕਾਲ {name} ਜੀ, CureSelect ਹਸਪਤਾਲ ਵਿੱਚ ਤੁਹਾਡਾ ਸਵਾਗਤ ਹੈ। ਅੱਜ ਤੁਹਾਨੂੰ ਕੀ ਤਕਲੀਫ਼ ਹੈ, ਕਿਰਪਾ ਕਰਕੇ ਦੱਸੋਗੇ?"
else:
return f"Hello {name}, welcome to CureSelect Healthcare. Could you please share what health symptoms you are experiencing today?"
@@ -99,10 +112,8 @@ class VoiceService:
self,
text: str,
language_code: str = "en-IN",
audio_id: str | None = None,
) -> tuple[str, bytes]:
lang_prefix = language_code.lower()[:2]
if not audio_id:
audio_id = f"aud_{uuid4().hex[:12]}"
# Clean punctuation and brackets before synthesis
@@ -115,7 +126,16 @@ class VoiceService:
.strip()
)
# 1. Use Sarvam AI TTS for Indian Languages (Native Indian Accent)
# Instant Disk Cache Lookup (< 0.001s response)
cache_key = hashlib.md5(f"{language_code}:{cleaned_text}".encode("utf-8")).hexdigest()
cache_path = CACHE_DIR / f"{cache_key}.wav"
if cache_path.exists():
cached_data = cache_path.read_bytes()
# Also save to current session audio_id for endpoint streaming
(AUDIO_DIR / f"{audio_id}.wav").write_bytes(cached_data)
return audio_id, cached_data
# 1. Use Sarvam AI TTS for Indian Languages (Native Indian Accent with 1.15x Human Pace)
if (
self.sarvam_key
and self.sarvam_key.strip()
@@ -123,8 +143,8 @@ class VoiceService:
and lang_prefix in ["ta", "hi", "te", "kn", "bn", "mr", "gu", "ml", "pa", "od", "en"]
):
try:
speaker = "kavya"
async with httpx2.AsyncClient(timeout=6.0) as client:
speaker = "kavya" if lang_prefix in ["ta", "hi", "te", "kn"] else "gokul"
async with httpx2.AsyncClient(timeout=5.0) as client:
headers = {
"api-subscription-key": self.sarvam_key,
"Content-Type": "application/json",
@@ -134,6 +154,7 @@ class VoiceService:
"target_language_code": language_code,
"speaker": speaker,
"model": "bulbul:v3",
"pace": 1.15,
}
res = await client.post(
"https://api.sarvam.ai/text-to-speech",
@@ -147,6 +168,7 @@ class VoiceService:
audio_data = base64.b64decode(audios[0])
file_path = AUDIO_DIR / f"{audio_id}.wav"
file_path.write_bytes(audio_data)
cache_path.write_bytes(audio_data)
return audio_id, audio_data
else:
print(f"[Sarvam TTS HTTP Error]: {res.status_code} - {res.text}")
@@ -155,11 +177,11 @@ class VoiceService:
# 2. Use OpenAI TTS
if self.openai_key and self.openai_key.strip() not in ["your_openai_api_key", "your_openai_key", ""]:
client = AsyncOpenAI(api_key=self.openai_key, timeout=6.0)
client = AsyncOpenAI(api_key=self.openai_key, timeout=5.0)
try:
response = await client.audio.speech.create(
model="tts-1",
voice="nova",
voice="shimmer",
input=cleaned_text,
speed=1.0,
)
@@ -178,15 +200,6 @@ class VoiceService:
file_path.write_bytes(audio_data)
return audio_id, audio_data
async def synthesize_to_file(
self,
audio_id: str,
text: str,
language_code: str = "en-IN",
) -> bytes:
_, audio_data = await self.text_to_speech(text, language_code=language_code, audio_id=audio_id)
return audio_data
def _generate_fallback_wav(self) -> bytes:
buf = io.BytesIO()
with wave.open(buf, "wb") as wf:
@@ -1,188 +0,0 @@
"""
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",
"hospital emergency room",
"alert duty doctor",
"call 108",
"emergency ambulance",
],
"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 informing hospital emergency staff or visiting the hospital emergency room.",
"If severe red-flag symptoms are present, immediately advise contacting 108 ambulance or visiting the nearest hospital emergency room.",
],
}
@@ -1,32 +0,0 @@
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",
"hospital emergency staff / ER",
"108 ambulance",
],
"clarification_rules": [
@@ -1,32 +0,0 @@
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",
"hospital emergency staff / ER",
"108 ambulance",
],
"clarification_rules": [
@@ -1,34 +0,0 @@
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.",
],
}
@@ -1,32 +0,0 @@
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.",
],
}
@@ -1,32 +0,0 @@
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,53 +1,18 @@
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(
canonical_code,
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
@@ -39,7 +39,7 @@ async def diagnose_anthropic():
async def diagnose_gemini():
api_key = os.getenv("GEMINI_API_KEY")
print("\n--- Testing Gemini Models ---")
from google import genai
import google.generativeai as genai
genai.configure(api_key=api_key)
try:
models = [m.name for m in genai.list_models() if 'generateContent' in m.supported_generation_methods]
@@ -1,37 +0,0 @@
import httpx
def main():
client = httpx.Client(timeout=60.0)
start_res = client.post('http://127.0.0.1:8000/v1/voice-onboarding/start', json={
'patient': {'id': 'PAT-INTAKE-5', 'first_name': 'Arun', 'last_name': 'Kumar'},
'category': {'type': 'general', 'code': 'general_medicine', 'name': 'General Medicine'},
'language': 'en-IN'
}).json()
sid = start_res['session_id']
print("=== DEBUGGING 5-STAGE DIALOGUE ===")
print("Session ID:", sid)
dialogue = [
"Chest pain and shortness of breath",
"2 days, pain severity is 8 out of 10",
"Yes, sweating and dizziness",
"High blood pressure and Diabetes",
"Taking Amlodipine, no drug allergies"
]
for i, turn_input in enumerate(dialogue, 1):
res = client.post(f"http://127.0.0.1:8000/v1/voice-onboarding/{sid}/turn", data={"text_fallback": turn_input})
print(f"Turn {i} Status Code: {res.status_code}")
if res.status_code != 200:
print("FULL ERROR BODY:", res.text)
break
data = res.json()
print(f"Turn {i} Input: '{turn_input}'")
print(f" --> Next Stage: {data['stage']} | Completed: {data['completed']}")
print("-" * 70)
if __name__ == '__main__':
main()
@@ -1,35 +0,0 @@
import httpx
def main():
client = httpx.Client(timeout=30.0)
test_cases = [
("Cardiology Test", "எனக்கு நெஞ்சு வலி மற்றும் மூச்சு திணறல் இருக்கு"),
("Dermatology Test", "தோல் அரிப்பு மற்றும் படை உள்ளது"),
("Orthopedics Test", "மூட்டு வலி நடக்க முடியல"),
("Neurology Test", "headache and dizziness for 3 days"),
("Gastroenterology Test", "வயிற்று வலி மற்றும் வாந்தி"),
("Pulmonology Test", "இருமல் மற்றும் சளி அதிகமாக உள்ளது"),
("ENT Test", "காது வலி மற்றும் தொண்டை வலி")
]
print("=== AUTOMATIC SPECIALTY TRIAGE ENGINE TEST ===")
for label, symptom in test_cases:
start_res = client.post("http://127.0.0.1:8000/v1/voice-onboarding/start", json={
"patient": {"id": "PAT-AUTO", "first_name": "Arun", "last_name": "Kumar"},
"category": {"type": "general", "code": "general_medicine", "name": "General Medicine"},
"language": "ta-IN"
})
sid = start_res.json()["session_id"]
turn_res = client.post(f"http://127.0.0.1:8000/v1/voice-onboarding/{sid}/turn", data={"text_fallback": symptom})
spec = turn_res.json().get("suggested_specialty", {})
print(f"Label: {label}")
print(f" Patient Symptom: '{symptom}'")
print(f" --> Auto-Assigned Specialty: {spec.get('name')} (Code: {spec.get('code')})")
print(f" --> Reasoning: {spec.get('reason')}\n")
if __name__ == '__main__':
main()
@@ -1,16 +0,0 @@
from app.ai.orchestrator import classify_specialty_from_symptoms
test_cases = [
("Cardiology", "நெஞ்சு வலி மற்றும் மூச்சு திணறல்"),
("Dermatology", "தோல் அரிப்பு மற்றும் படை"),
("Orthopedics", "மூட்டு வலி"),
("Neurology", "headache and dizziness"),
("Gastroenterology", "வயிற்று வலி மற்றும் வாந்தி"),
("Pulmonology", "இருமல் மற்றும் சளி"),
("ENT", "காது வலி மற்றும் தொண்டை வலி")
]
print("=== DIRECT SYMPTOM SPECIALTY CLASSIFIER TEST ===")
for label, text in test_cases:
res = classify_specialty_from_symptoms(text)
print("Test:", label, "| Code:", res["code"], "| Specialty:", res["name"])
@@ -1,195 +0,0 @@
# AI Patient Onboarding — Natural Conversation & Clinical Data Extraction Architecture
## Core Philosophy: ASK LESS → LISTEN MORE → EXTRACT MORE
The AI Patient Onboarding system is designed around the principle that **the patient's natural speech is the primary and richest source of clinical information**. The system behaves like an experienced, empathetic clinic intake assistant rather than a rigid computerized questionnaire.
```
┌────────────────────────────────────────────────────────┐
│ PATIENT SPEAKS FREELY │
│ ("Stomach pain since yesterday, vomited twice, │
│ took Metformin for diabetes, allergic to pen...") │
└──────────────────────────┬─────────────────────────────┘
│
▼
┌──────────────────────────┐
│ UNDERSTAND │
│ (Multi-fact extraction) │
└─────────────┬────────────┘
│
▼
┌──────────────────────────┐
│ MERGE │
│(Deduplication/Enrichment)│
└─────────────┬────────────┘
│
▼
┌──────────────────────────┐
│ CHECK SAFETY │
│ (Emergency / Red Flags) │
└─────────────┬────────────┘
│
▼
┌──────────────────────────┐
│ IDENTIFY CRITICAL GAPS │
│ (What is truly missing)│
└─────────────┬────────────┘
│
┌───────────────────┴───────────────────┐
▼ ▼
┌───────────────────────┐ ┌───────────────────────┐
│ ASK ONE QUESTION │ │ COMPLETE INTAKE │
│ (Address missing gap) │ │(All needed data known)│
└───────────────────────┘ └───────────────────────┘
```
---
## Key Principles & Behavior
### 1. Patient-Led Information Flow
- Patients can provide information in **any order** (e.g., medical history before symptoms, allergies before chief complaint).
- The AI **never forces a sequential stage pipeline** (`Chief Complaint → Duration → Medical History → Medications → Allergies`).
- There is **no arbitrary turn limit** (`MAX 4 TURNS` eliminated).
### 2. Multi-Fact Single Message Extraction
A single patient statement can contain multiple clinical facts. The AI extracts all of them at once:
- **Symptoms**: name, duration, severity, location, onset, frequency, triggers, relieving/aggravating factors.
- **Medications**: name, dosage, frequency, duration.
- **Allergies**: allergen name, type, reaction.
- **Medical History**: condition name, duration, status.
- **Surgical History**: procedure, approximate date, reason.
- **Previous Episodes**: description, approximate time.
- **Family History**: condition, relationship.
- **Functional Impact**: sleep disruption, appetite loss, fatigue.
### 3. Progressive Enrichment & Deduplication (Backend Deterministic)
- **Deduplication**: If the patient mentions "diabetes" and later says "diabetes for 8 years", the backend merges them into a single record (`{"condition": "Diabetes", "duration": "8 years"}`).
- **Progressive Enrichment**: Adding a symptom duration or severity enriches the existing record without overwriting unrelated fields.
- **Patient Correction**: If a patient corrects themselves ("yesterday... actually 5 days ago"), the latest explicit statement is preserved.
### 4. Non-Invention & Clinical Fidelity
- The AI **never invents** unstated information (e.g., does not assume chest pain is severe unless the patient said so).
- Unknown values remain `null` or `"not_collected"`.
- "Not reported" is strictly distinguished from "not asked".
### 5. "Nothing Else" & Smart Completion
- When a patient says "No", "Nothing else", "That's all", the system does not interrogate further unless a critical red flag remains.
- 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 **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").
---
## Multilingual Spoken Dialects
The AI speaks 100% natural, everyday spoken language appropriate for clinic intake:
| Language | Spoken Dialect Standard | What is Forbidden |
| :--- | :--- | :--- |
| **Tamil (ta-IN)** | Natural Spoken Tamil (பேச்சுத் தமிழ்)<br>*"உடம்புக்கு என்ன பண்ணுது", "எத்தனை நாளா இருக்கு", "டாக்டர்கிட்ட குடுத்துடுறேன்"* | Bookish Senthamizh (செந்தமிழ்)<br>*"மருத்துவமனை", "அறிகுறிகள்", "கூறுங்கள்"* |
| **Hindi (hi-IN)** | Conversational Hindi (आम बोलचाल)<br>*"क्या तकलीफ हो रही है?", "कितने दिनों से?", "दवाई ली है?"* | Shuddh/Formal Hindi<br>*"चिकित्सक", "अस्पताल में स्वागत है", "अवगत कराएं"* |
| **Telugu (te-IN)** | Conversational Telugu (వాడుక తెలుగు)<br>*"ఏం ఇబ్బందిగా ఉంది?", "ఎన్ని రోజుల నుండి?"* | Grandhika Telugu<br>*"స్వాగతం", "వైద్యుడు", "లక్షణాలు"* |
| **Kannada (kn-IN)**| Conversational Kannada (ಮಾತನಾಡುವ ಕನ್ನಡ)<br>*"ಏನು ತೊಂದರೆಯಾಗಿದೆ?", "ಎಷ್ಟು ದಿನದಿಂದ ಇದೆ?"* | Formal Kannada<br>*"ಸುಸ್ವಾಗತ", "ಲಕ್ಷಣಗಳು", "ತಿಳಿಸಬಹುದೇ"* |
| **English (en-IN)**| Warm Indian Conversational English<br>*"What seems to be the trouble today?", "Got it."* | Overly stiff or medicalized jargon |
---
## Example Conversations
### Example 1: English (Multi-Information Turn)
```text
Patient: "I've had stomach pain since yesterday. I vomited twice and I couldn't eat. I have diabetes for 8 years and take Metformin 500 mg."
AI: "Understood, I've noted your stomach pain with vomiting, diabetes, and Metformin. Do you have any known medication allergies?"
Patient: "No allergies, nothing else."
AI: "Got it. I have compiled all these details for the doctor. Thank you!" (Completed)
```
### Example 2: Tamil (இயல்பான பேச்சுத் தமிழ்)
```text
நோயாளி: "நேத்து ராத்திரியிலிருந்து கடுமையான தலைவலியும் வாந்தியும் இருக்குங்க. தூங்கவே முடியல."
AI (நிலா): "புரியுதுங்க. நேத்து ராத்திரியிலிருந்து தலைவலியும் வாந்தியும் இருக்கு. இதுக்காக ஏதாவது மாத்திரை சாப்பிட்டீங்களா?"
நோயாளி: "பாராசிட்டமால் ஒரு மாத்திரை போட்டேன். வேற எதுவும் இல்ல."
AI (நிலா): "சரிங்க, எல்லா விவரத்தையும் டாக்டர்கிட்ட குடுத்துடுறேன். ரொம்ப நன்றிங்க!" (Completed)
```
### Example 3: Hindi (आम बोलचाल)
```text
मरीज़: "दो दिन से बहुत तेज खांसी और बुखार है। रात को ठंड भी लग रही थी।"
AI (नीला): "समझ गया जी। दो दिन से खांसी और बुखार की तकलीफ है। क्या आप इसके लिए कोई दवाई ले रहे हैं या कोई एलर्जी है?"
मरीज़: "डोलो 650 ली थी। कोई एलर्जी नहीं है।"
AI (नीला): "ठीक है जी, मैं यह सारी जानकारी डॉक्टर साहब को दे देती हूँ। शुक्रिया!" (Completed)
```
---
## Architectural State Structure
```json
{
"reason_for_visit": "stomach pain",
"symptoms": [
{
"name": "stomach pain",
"duration": "since yesterday",
"severity": "severe",
"triggers": "worse after eating"
},
{
"name": "vomiting",
"frequency": "twice"
}
],
"medical_history": [
{
"condition": "Diabetes",
"duration": "8 years",
"status": "active"
}
],
"medications": [
{
"name": "Metformin",
"dosage": "500 mg",
"frequency": "twice a day"
}
],
"allergies": [
{
"name": "Penicillin",
"reaction": "Rash"
}
],
"previous_episodes": [
{
"description": "Same stomach problem",
"time": "three months ago"
}
],
"additional_information": [
{
"category": "functional_impact",
"value": "Loss of appetite / difficulty eating"
}
]
}
```
---
## State Consistency Across Interfaces
- **Text Onboarding** (`POST /v1/onboarding/{session_id}/message`) and **Voice Onboarding** (`POST /v1/voice-onboarding/{session_id}/turn`) utilize the exact same state merging engine (`merge_state_updates`), ensuring 100% structured data consistency regardless of input modality.
- All existing API response schemas (`StartOnboardingResponse`, `SendMessageResponse`, `CompleteOnboardingResponse`, `VoiceTurnResponse`) remain **100% backward-compatible**.
@@ -1,464 +0,0 @@
# AI Patient Onboarding API — Complete Frontend Integration Guide
**Version**: `1.0.0`
**Target Audience**: Frontend Engineers (React / React Native / Vue / Next.js / Mobile)
**Backend Framework**: FastAPI (Python 3.13)
**Supported Protocols**: HTTP/1.1 REST, JSON, Multipart/Form-Data Formats
**Interactive API Documentation**:
- **Swagger UI**: `{BASE_URL}/docs`
- **ReDoc**: `{BASE_URL}/redoc`
- **OpenAPI Schema**: `{BASE_URL}/openapi.json`
---
## 1. Executive System Overview
The **AI Patient Onboarding API** is an enterprise multi-modal clinical intake system designed for hospitals, clinics, a
nd digital health platforms. It automates patient registration, symptom intake, medical history collection, document extraction, and specialty triage before a physician consultation.
### Key Capabilities for Frontend Applications:
1. **Multi-Modal Onboarding**:
- **Text-Based Onboarding**: Interactive chat-based intake powered by clinical LLM orchestrator.
- **Voice-First Onboarding**: Natural conversational voice intake supporting English (`en-IN`), Tamil (`ta-IN`), Hindi (`hi-IN`), Telugu (`te-IN`), Kannada (`kn-IN`), Malayalam (`ml-IN`), and Marathi (`mr-IN`) using Sarvam AI ASR/TTS.
2. **Medical Document Analysis**: Vision-based processing of lab reports, prescriptions, and discharge summaries (JPEG, PNG, WEBP, PDF up to 10MB).
3. **Session-Based State Engine**: Stateful intake tracking stored in persistent backend database.
4. **Structured EHR Output**: Automatic clinical JSON result extraction matching standard Electronic Health Record (EHR) models.
---
## 2. Server Base URLs & CORS Configuration
### Server Base URLs:
- **Local Development**: `http://localhost:8000` or `http://127.0.0.1:8000`
- **Staging / Production**: Configured via environment variable (`{BASE_URL}`)
### CORS (Cross-Origin Resource Sharing) Policy:
The backend enforces production-safe `CORSMiddleware`.
- **Allowed Origins**: `http://localhost:3000`, `http://localhost:5173`, `http://127.0.0.1:3000`, `http://127.0.0.1:5173`, `https://onboarding.skyheal.ai` (configurable via `CORS_ORIGINS`).
- **Allowed Credentials**: `true` (`Access-Control-Allow-Credentials: true`).
- **Allowed Headers**: `Authorization`, `Content-Type`, `X-API-Key`, `X-Patient-ID`, `X-Request-ID`, `Accept`, `Origin`.
- **Exposed Headers**: `X-Request-ID`.
---
## 3. Quick Start Integration Flow
```
[ Frontend App ]
│
├── 1. POST /v1/onboarding/start (or /v1/voice-onboarding/start)
│ └─► Returns session_id (e.g. "ONB-8F12A9C4" or "VOICE-D3CAEA5C")
│
├── 2. POST /v1/onboarding/{session_id}/message (or /turn for voice)
│ └─► Exchanges multi-turn symptom history & receiving AI responses
│
├── 3. POST /v1/documents/{session_id}/upload (Optional)
│ └─► Uploads medical reports / lab results
│
└── 4. POST /v1/onboarding/{session_id}/complete
└─► Returns final structured EHR result (PatientOnboardingResult)
```
---
## 4. Master API Endpoint Directory
| HTTP Method | Endpoint Path | Auth Required | Rate Limit | Purpose |
| :--- | :--- | :--- | :--- | :--- |
| `GET` | `/health` | No | None | Service health check & AI provider status |
| `POST` | `/v1/onboarding/start` | **Yes** | 30 req/min | Start text onboarding session |
| `POST` | `/v1/onboarding/{session_id}/message` | **Yes** | 30 req/min | Send text intake message to AI |
| `POST` | `/v1/onboarding/{session_id}/complete` | **Yes** | 30 req/min | Finalize text session & output structured result |
| `POST` | `/v1/voice-onboarding/start` | **Yes** | 30 req/min | Start voice onboarding session |
| `POST` | `/v1/voice-onboarding/{session_id}/turn` | **Yes** | 30 req/min | Process speech recording or text fallback turn |
| `POST` | `/v1/voice-onboarding/{session_id}/complete` | **Yes** | 30 req/min | Finalize voice session & output structured result |
| `GET` | `/v1/voice-onboarding/audio/{audio_id}` | No | None | Stream TTS audio response (WAV/MP3) |
| `POST` | `/v1/documents/{session_id}/upload` | **Yes** | 10 req/min | Upload medical document / report (10MB max) |
---
## 5. Authentication & Patient Security
### Authentication Mechanisms:
#### Method A: HTTP Bearer Token (Standard)
```http
Authorization: Bearer <ACCESS_TOKEN>
```
#### Method B: `X-API-Key` Custom Header
```http
X-API-Key: <API_KEY>
```
### Supported Access Tokens:
1. **Service Secret API Key**: `skyheal_secure_api_key_2026` (configurable via `API_SECRET_KEY` in server environment).
2. **Patient Development Tokens**: `test_token_<PATIENT_ID>` (e.g. `test_token_PAT-10023`). Automatically establishes patient identity context.
### Patient Isolation & IDOR Protection Rules:
1. When starting a session (`/v1/onboarding/start`), `patient.id` is bound as session owner (`session.patient_id`).
2. Subsequent requests verify caller's authenticated `patient_id` matches session owner.
3. Access attempts by other patients return **`HTTP 403 Forbidden`**.
---
## 6. Text Onboarding API Specifications
### 6.1. POST `/v1/onboarding/start`
Starts a new text-based clinical onboarding intake session.
#### Request Headers:
```http
Content-Type: application/json
Authorization: Bearer test_token_PAT-10023
```
#### Request Body (`StartOnboardingRequest`):
```json
{
"patient": {
"id": "PAT-10023",
"name": "Kavitha Ramesh",
"age": 35,
"gender": "female",
"phone": "+919876543210",
"abha_id": "91-1234-5678-9012",
"insurance_scheme": "PMJAY",
"dietary_habits": "Vegetarian"
},
"category": {
"type": "general",
"code": "general_medicine",
"name": "General Medicine"
},
"language": "en"
}
```
#### Field Descriptions:
| Field | Type | Required | Validation Rules | Description |
| :--- | :--- | :--- | :--- | :--- |
| `patient.id` | string | **Yes** | Min length 1 | Unique patient identifier |
| `patient.name` | string | No | Optional | Full name of patient |
| `patient.age` | integer | No | $0 \le \text{age} \le 120$ | Patient age in years |
| `patient.gender` | string | No | Optional | Gender identity ("male", "female", "other") |
| `category.type` | string | **Yes** | `"general"` or `"speciality"` | Onboarding workflow type |
| `category.code` | string | **Yes** | Min length 1 | Specialty classification code |
| `category.name` | string | **Yes** | Min length 1 | Category display name |
| `language` | string | No | Default `"en"` | Preferred intake language |
#### Successful Response (`200 OK` - `StartOnboardingResponse`):
```json
{
"success": true,
"session_id": "ONB-8F12A9C4",
"patient_id": "PAT-10023",
"category": {
"type": "general",
"code": "general_medicine",
"name": "General Medicine"
},
"language": "en",
"status": "in_progress",
"stage": "symptoms",
"message": "Hello Kavitha Ramesh! I am your clinical onboarding assistant. What symptoms are you experiencing today?"
}
```
---
### 6.2. POST `/v1/onboarding/{session_id}/message`
Sends a patient chat response to the AI intake orchestrator.
#### Request Body (`SendMessageRequest`):
```json
{
"message": "I have had severe chest tightness and shortness of breath for 2 days."
}
```
#### Successful Response (`200 OK` - `SendMessageResponse`):
```json
{
"success": true,
"session_id": "ONB-8F12A9C4",
"stage": "medical_history",
"message": "Thank you for sharing. Does the chest tightness radiate to your arm or jaw, and do you have any history of high blood pressure?",
"status": "in_progress",
"response_time_ms": 1529.74,
"response_time_sec": 1.53
}
```
---
### 6.3. POST `/v1/onboarding/{session_id}/complete`
Finalizes the intake session and outputs the structured clinical EHR payload.
#### Request Body (`CompleteOnboardingRequest`):
```json
{
"confirmed": true
}
```
#### Successful Response (`200 OK` - `CompleteOnboardingResponse`):
```json
{
"success": true,
"session_id": "ONB-8F12A9C4",
"status": "completed",
"result": {
"patient_id": "PAT-10023",
"abha_id": "91-1234-5678-9012",
"phone": "+919876543210",
"insurance_scheme": "PMJAY",
"category": {
"type": "general",
"code": "general_medicine",
"name": "General Medicine"
},
"chief_complaint": "Chest tightness and shortness of breath for 2 days",
"symptoms": [
{
"name": "Chest tightness",
"duration": "2 days",
"severity": "severe",
"description": "Triggered during exertion"
}
],
"medical_history": ["Prehypertension"],
"medications": ["Metformin 500mg"],
"allergies": ["Penicillin"],
"dietary_habits": "Vegetarian",
"additional_information": "Patient confirmed details.",
"language": "en",
"onboarding_completed": true
},
"message": "Onboarding successfully completed and saved to patient record.",
"response_time_ms": 4075.41,
"response_time_sec": 4.08
}
```
---
## 7. Voice Onboarding API Specifications
### 7.1. POST `/v1/voice-onboarding/start`
Starts a voice-first onboarding session with Sarvam AI multilingual speech engine.
#### Supported Languages:
`en-IN` (Indian English), `ta-IN` (Tamil), `hi-IN` (Hindi), `te-IN` (Telugu), `kn-IN` (Kannada), `ml-IN` (Malayalam), `mr-IN` (Marathi).
#### Request Body:
```json
{
"patient": {
"id": "PAT-VOICE-1",
"name": "Ramesh G",
"age": 34,
"gender": "male"
},
"category": {
"type": "general",
"code": "general_medicine",
"name": "General Medicine"
},
"language": "ta-IN"
}
```
#### Successful Response (`200 OK`):
```json
{
"success": true,
"session_id": "VOICE-D3CAEA5C",
"patient_id": "PAT-VOICE-1",
"category": {
"type": "general",
"code": "general_medicine",
"name": "General Medicine"
},
"language": "ta-IN",
"status": "in_progress",
"stage": "greeting",
"greeting_text": "வணக்கம் Ramesh G, CureSelect மருத்துவமனைக்கு வரவேற்கிறோம். இன்று உங்களுக்கு என்ன சுகவீனம் உள்ளது?",
"audio_url": "/v1/voice-onboarding/audio/aud_a1b2c3d4e5f6",
"response_time_ms": 78.32,
"response_time_sec": 0.08
}
```
---
### 7.2. POST `/v1/voice-onboarding/{session_id}/turn`
Processes a conversational voice turn using audio recording or text fallback.
#### Multipart Form Data:
- `audio`: Binary audio recording file (`WAV`, `WEBM`, `MP3`, `OGG` up to 10MB).
- `text_fallback`: String text transcript (used if WebSpeech STT processed client-side).
#### Successful Response (`200 OK`):
```json
{
"success": true,
"session_id": "VOICE-D3CAEA5C",
"patient_transcript": "எனக்குகடந்த 3 நாளா நெஞ்சு பாரமா இருக்குங்க.",
"ai_text": "நன்றி. இந்த நெஞ்சு பாரம் உடற்பயிற்சி செய்யும் போது அதிகமாகிறதா?",
"stage": "symptoms",
"completed": false,
"audio_url": "/v1/voice-onboarding/audio/aud_f9e8d7c6b5a4",
"audio_base64": null,
"suggested_specialty": {
"specialty": "Cardiology",
"reason": "Chest tightness reported"
},
"response_time_ms": 1412.37,
"response_time_sec": 1.41
}
```
---
### 7.3. GET `/v1/voice-onboarding/audio/{audio_id}`
Streams generated TTS audio for browser or mobile playback.
```javascript
const audio = new Audio(`${BASE_URL}/v1/voice-onboarding/audio/${audioId}`);
await audio.play();
```
---
## 8. Medical Document Analysis API
### POST `/v1/documents/{session_id}/upload`
Uploads a medical report / prescription for vision extraction.
- **Content-Type**: `multipart/form-data`
- **File Limit**: Max `10MB` (`10,485,760 bytes`).
- **Allowed Formats**: `image/jpeg`, `image/png`, `image/webp`, `application/pdf`.
#### Successful Response (`200 OK`):
```json
{
"success": true,
"session_id": "ONB-8F12A9C4",
"document_type": "lab_report",
"summary": "Report indicates elevated HbA1c (7.2%) and stage 1 hypertension (130/85 mmHg).",
"findings": [
{
"test": "HbA1c",
"value": "7.2",
"unit": "%",
"interpretation": "Elevated"
}
],
"medications_found": ["Metformin 500mg"],
"allergies_found": ["Penicillin"],
"message": "Medical document analyzed and integrated into onboarding session."
}
```
---
## 9. Error Handling & Status Codes
| HTTP Status Code | Meaning | Cause & Resolution |
| :--- | :--- | :--- |
| `400 Bad Request` | Bad Request | Invalid category or unsupported file format. |
| `401 Unauthorized` | Unauthorized | Missing or invalid `Authorization: Bearer` or `X-API-Key` header. |
| `403 Forbidden` | Forbidden (IDOR) | Authenticated `patient_id` does not match session owner. |
| `404 Not Found` | Not Found | Session ID does not exist in backend database. |
| `413 Payload Too Large` | File Too Large | Uploaded document exceeds 10MB limit. |
| `422 Validation Error` | Pydantic Error | Request body violates schema rules (e.g. `age < 0`). |
| `429 Too Many Requests` | Rate Limited | Exceeded rate limit bucket (30 req/min). Check `Retry-After` header. |
| `500 Internal Error` | Server Error | Unhandled backend failure. |
---
## 10. Frontend React & TypeScript Implementation Examples
### React API Client (`src/api/onboardingClient.ts`)
```typescript
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000';
export interface PatientContext {
id: string;
name?: string;
age?: number;
gender?: string;
phone?: string;
abha_id?: string;
}
export interface CategoryContext {
type: 'general' | 'speciality';
code: string;
name: string;
}
async function fetchApi<T>(endpoint: string, options: RequestInit = {}, patientId: string): Promise<T> {
const token = `test_token_${patientId}`;
const headers = new Headers(options.headers || {});
headers.set('Authorization', `Bearer ${token}`);
if (!(options.body instanceof FormData)) {
headers.set('Content-Type', 'application/json');
}
const response = await fetch(`${API_BASE_URL}${endpoint}`, { ...options, headers });
if (!response.ok) {
const errorBody = await response.json().catch(() => ({ detail: 'Network response error' }));
throw new Error(errorBody.detail || `HTTP Error ${response.status}`);
}
return response.json();
}
export async function startOnboarding(patient: PatientContext, category: CategoryContext, language: string = 'en') {
return fetchApi<any>('/v1/onboarding/start', {
method: 'POST',
body: JSON.stringify({ patient, category, language }),
}, patient.id);
}
export async function sendMessage(sessionId: string, patientId: string, message: string) {
return fetchApi<any>(`/v1/onboarding/${sessionId}/message`, {
method: 'POST',
body: JSON.stringify({ message }),
}, patientId);
}
export async function completeOnboarding(sessionId: string, patientId: string, confirmed: boolean = true) {
return fetchApi<any>(`/v1/onboarding/${sessionId}/complete`, {
method: 'POST',
body: JSON.stringify({ confirmed }),
}, patientId);
}
```
---
## 11. Frontend Integration Checklist
```text
[ ] Configure API_BASE_URL in environment (VITE_API_BASE_URL=http://localhost:8000)
[ ] Verify CORS preflight request from localhost:3000 / localhost:5173
[ ] Attach Authorization: Bearer test_token_<PATIENT_ID> header
[ ] Call POST /v1/onboarding/start and store session_id
[ ] Multi-turn chat message sending via POST /v1/onboarding/{session_id}/message
[ ] Handle 401 Unauthorized & 403 Forbidden errors
[ ] Upload medical documents via POST /v1/documents/{session_id}/upload (10MB max)
[ ] Call POST /v1/onboarding/{session_id}/complete to retrieve final PatientOnboardingResult
[ ] Test voice onboarding audio streaming via GET /v1/voice-onboarding/audio/{audio_id}
```
@@ -1,22 +0,0 @@
# Production Security & Compliance Checklist
## 1. Authentication & Authorization Controls
- [x] **Bearer Token & API Key Authentication**: Verified (`app/core/security.py`).
- [x] **IDOR Session Ownership Validation**: Verified (`session.patient_id == auth.patient_id`).
- [x] **Unauthenticated Access Prevention**: Unauthenticated requests return `401 Unauthorized`.
- [x] **Unauthorized Session Modification Prevention**: Cross-patient requests return `403 Forbidden`.
## 2. Input Validation & Schema Boundaries
- [x] **Numeric Age Validation Bounds**: Enforced `ge=0, le=120` in `PatientContext` schema (`422` returned on invalid inputs).
- [x] **String & XSS Sanitization**: HTML tags stripped/escaped from initial greetings.
- [x] **File Upload Restrictions**: 10MB size limit and strict MIME validation (`JPEG`, `PNG`, `WEBP`, `PDF`).
## 3. Network & Transport Security
- [x] **CORS Configuration**: Wildcard origin (`*`) removed; explicit domain origins configured.
- [x] **HTTP Security Headers**: `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy: strict-origin-when-cross-origin` attached.
- [x] **Rate Limiting**: IP-based rate limiting enforced on API endpoints (`429 Too Many Requests` returned with `Retry-After`).
## 4. Operational & Container Security
- [x] **Non-Root Container**: Dockerfile configured with dedicated `appuser` (UID 999).
- [x] **Request ID Tracking**: Attached `X-Request-ID` correlation header to all HTTP requests.
- [x] **Error Masking**: Stack traces hidden behind generic `500 Internal Server Error` responses.
-5
View File
@@ -1,5 +0,0 @@
[pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
+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-genai
google-generativeai==0.8.6
googleapis-common-protos==1.75.0
greenlet==3.5.5
grpcio==1.83.0
@@ -66,7 +66,7 @@ async def test_gemini():
print("[FAIL] GEMINI_API_KEY is not set.")
return False, "Key missing"
try:
from google import genai
import google.generativeai as 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")
from google import genai
import google.generativeai as 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:
@@ -1,92 +0,0 @@
import os
import sys
import json
import pytest
from fastapi.testclient import TestClient
# Add project root to sys.path
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")))
from app.main import app
client = TestClient(app)
VALID_KEY = "skyheal_secure_api_key_2026"
def test_reject_unauthenticated_request():
"""1. Reject Unauthenticated Request (401)"""
res = client.post("/v1/onboarding/start", json={"patient": {"id": "P1"}})
assert res.status_code == 401
def test_authenticated_session_creation():
"""2. Authenticated Session Creation (200)"""
res = client.post(
"/v1/onboarding/start",
json={
"patient": {"id": "PAT-PROD-01", "name": "Prod Patient", "age": 32, "gender": "female"},
"category": {"type": "general", "code": "general_medicine", "name": "General Medicine"}
},
headers={"Authorization": "Bearer test_token_PAT-PROD-01"}
)
assert res.status_code == 200
data = res.json()
assert data.get("session_id") is not None
def test_idor_prevention_cross_session_access():
"""3. IDOR Prevention Cross-Session Access (403)"""
# Create session as PAT-PROD-01
start_res = client.post(
"/v1/onboarding/start",
json={
"patient": {"id": "PAT-PROD-01", "name": "Prod Patient", "age": 32, "gender": "female"},
"category": {"type": "general", "code": "general_medicine", "name": "General Medicine"}
},
headers={"Authorization": "Bearer test_token_PAT-PROD-01"}
)
s_id = start_res.json().get("session_id")
assert s_id is not None
# Attempt access as PAT-PROD-02
msg_res = client.post(
f"/v1/onboarding/{s_id}/message",
json={"message": "Unauthorized message"},
headers={"Authorization": "Bearer test_token_PAT-PROD-02"}
)
assert msg_res.status_code == 403
def test_security_headers_and_request_id():
"""4. Security Headers & X-Request-ID Correlation"""
res = client.get("/health")
assert res.status_code == 200
assert "X-Request-ID" in res.headers
assert res.headers.get("X-Content-Type-Options") == "nosniff"
assert res.headers.get("X-Frame-Options") == "DENY"
def test_schema_validation_negative_age_rejection():
"""5. Schema Validation Negative Age Rejection (422)"""
res = client.post(
"/v1/onboarding/start",
json={
"patient": {"id": "PAT-NEG", "name": "Invalid Age", "age": -10, "gender": "female"},
"category": {"type": "general", "code": "general_medicine", "name": "General Medicine"}
},
headers={"Authorization": f"Bearer {VALID_KEY}"}
)
assert res.status_code == 422
def run_automated_production_tests():
test_reject_unauthenticated_request()
test_authenticated_session_creation()
test_idor_prevention_cross_session_access()
test_security_headers_and_request_id()
test_schema_validation_negative_age_rejection()
print("ALL PRODUCTION SECURITY TESTS PASSED SUCCESSFULLY!")
if __name__ == "__main__":
run_automated_production_tests()
@@ -1,386 +0,0 @@
import os
import sys
import json
import asyncio
import pytest
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__), "..")))
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,
)
from app.services.completion_service import CompletionService
from app.schemas.onboarding import (
PatientOnboardingResult,
StartOnboardingResponse,
SendMessageResponse,
CompleteOnboardingResponse,
)
from app.schemas.voice import VoiceTurnResponse
def get_mock_session():
session = MagicMock()
session.session_id = "ONB-FLOW-TEST-001"
session.patient_id = "PAT-FLOW-001"
session.patient_name = "Ramesh Kumar"
session.age = 35
session.gender = "male"
session.category_type = "general"
session.category_code = "general_medicine"
session.category_name = "General Medicine"
session.language = "ta-IN"
session.stage = "greeting"
session.status = "in_progress"
return session
# =====================================================================
# TEST 1: Patient Provides All Information in One Message
# =====================================================================
def test_1_patient_provides_all_info_in_one_message():
async def run():
mock_session = get_mock_session()
orchestrator = AIOrchestrator()
msg = "எனக்கு diabetes இருக்கு, metformin 500 mg தினமும் சாப்பிடுறேன், தலைவலி மூணு நாளா இருக்கு, penicillin allergy இருக்கு."
state_mock = MagicMock(state_json=json.dumps({}))
res = await orchestrator.process(mock_session, state_mock, [], msg)
merged = merge_state_updates({}, res["state_updates"])
assert len(merged.get("medical_history", [])) >= 1
assert len(merged.get("medications", [])) >= 1
assert len(merged.get("allergies", [])) >= 1
assert len(merged.get("symptoms", [])) >= 1
assert res.get("completed") is True
asyncio.run(run())
# =====================================================================
# TEST 2: Patient Gives Symptoms First
# =====================================================================
def test_2_patient_gives_symptoms_first():
async def run():
mock_session = get_mock_session()
orchestrator = AIOrchestrator()
msg = "எனக்கு தலைவலி இருக்கு."
state_mock = MagicMock(state_json=json.dumps({}))
res = await orchestrator.process(mock_session, state_mock, [], msg)
assert res.get("completed") is False
assert len(res.get("reply", "")) > 5
asyncio.run(run())
# =====================================================================
# TEST 3: Patient Gives Duration (No Fake Symptom Created)
# =====================================================================
def test_3_patient_gives_duration_no_fake_symptom():
async def run():
mock_session = get_mock_session()
orchestrator = AIOrchestrator()
state_mock = MagicMock(state_json=json.dumps({
"reason_for_visit": "தலைவலி",
"symptoms": [{"name": "தலைவலி"}]
}))
res = await orchestrator.process(
mock_session, state_mock,
[{"role": "patient", "message": "தலைவலி இருக்கு", "stage": "greeting"}],
"மூணு நாளா இருக்கு."
)
merged = merge_state_updates({"symptoms": [{"name": "தலைவலி"}]}, res["state_updates"])
syms = merged.get("symptoms", [])
assert len(syms) == 1
assert syms[0]["name"] == "தலைவலி"
assert syms[0]["duration"] == "3 days"
assert res.get("completed") is False
asyncio.run(run())
# =====================================================================
# TEST 4: Tamil Tanglish Duration
# =====================================================================
def test_4_tamil_tanglish_duration():
assert normalize_duration_from_message("10 டேஸா இருக்கு.") == "10 days"
assert normalize_duration_from_message("10 days-ah இருக்கு.") == "10 days"
# =====================================================================
# TEST 5: Telugu Duration Support
# =====================================================================
def test_5_telugu_duration():
assert normalize_duration_from_message("మూడు రోజుల నుంచి ఉంది") == "3 days"
assert normalize_duration_from_message("3 రోజుల నుండి ఉంది") == "3 days"
# =====================================================================
# TEST 6: Kannada Duration Support
# =====================================================================
def test_6_kannada_duration():
assert normalize_duration_from_message("ಮೂರು ದಿನದಿಂದ ಇದೆ") == "3 days"
assert normalize_duration_from_message("3 ದಿನದಿಂದ ಇದೆ") == "3 days"
# =====================================================================
# TEST 7: Medication First (No Fake Symptom Created)
# =====================================================================
def test_7_medication_first_no_fake_symptom():
async def run():
mock_session = get_mock_session()
orchestrator = AIOrchestrator()
state_mock = MagicMock(state_json=json.dumps({}))
res = await orchestrator.process(mock_session, state_mock, [], "Metformin 500 mg தினமும் சாப்பிடுறேன்.")
updates = res["state_updates"]
meds = updates.get("medications") or []
syms = updates.get("symptoms") or []
if isinstance(meds, dict):
meds = meds.get("items", [])
assert len(meds) == 1
assert meds[0]["name"] == "Metformin"
assert len(syms) == 0
asyncio.run(run())
# =====================================================================
# TEST 8: Cross-Language Symptom Deduplication
# =====================================================================
def test_8_cross_language_symptom_deduplication():
state1 = merge_state_updates({}, {"symptoms": [{"name": "தலைவலி"}]})
state2 = merge_state_updates(state1, {"symptoms": [{"name": "headache", "duration": "3 days"}]})
syms = state2.get("symptoms", [])
assert len(syms) == 1
assert syms[0]["duration"] == "3 days"
# =====================================================================
# TEST 9: Progressive Enrichment
# =====================================================================
def test_9_progressive_enrichment():
s1 = merge_state_updates({}, {"medical_history": [{"condition": "Diabetes"}]})
s2 = merge_state_updates(s1, {"medical_history": [{"condition": "Diabetes", "duration": "8 years"}]})
mh = s2.get("medical_history", [])
assert len(mh) == 1
assert mh[0]["condition"] == "Diabetes"
assert mh[0]["duration"] == "8 years"
# =====================================================================
# TEST 10: Patient Correction
# =====================================================================
def test_10_patient_correction():
s1 = merge_state_updates({}, {"symptoms": [{"name": "headache", "duration": "3 days"}]})
s2 = merge_state_updates(s1, {"symptoms": [{"name": "headache", "duration": "10 days"}]})
syms = s2.get("symptoms", [])
assert len(syms) == 1
assert syms[0]["duration"] == "10 days"
# =====================================================================
# TEST 11: Emergency Override (Spoken Tamil)
# =====================================================================
def test_11_emergency_override_tamil():
async def run():
mock_session = get_mock_session()
orchestrator = AIOrchestrator()
state_mock = MagicMock(state_json=json.dumps({}))
res = await orchestrator.process(mock_session, state_mock, [], "மூச்சு விட முடியல")
assert ("மருத்துவமனை" in res["reply"] or "அவசர" in res["reply"])
assert res.get("completed") is True
asyncio.run(run())
# =====================================================================
# TEST 12: Mental Health Crisis Override (Spoken Tamil)
# =====================================================================
def test_12_mental_health_crisis_override_tamil():
async def run():
mock_session = get_mock_session()
orchestrator = AIOrchestrator()
state_mock = MagicMock(state_json=json.dumps({}))
res = await orchestrator.process(mock_session, state_mock, [], "நான் என்னையே காயப்படுத்திக்கணும்னு தோணுது")
assert "14416" in res["reply"] or "Tele-MANAS" in res["reply"]
assert res.get("completed") is True
asyncio.run(run())
# =====================================================================
# TEST 13: Nothing Else Handling
# =====================================================================
def test_13_nothing_else_handling():
async def run():
mock_session = get_mock_session()
orchestrator = AIOrchestrator()
ready_state = {
"reason_for_visit": "fever",
"symptoms": [{"name": "fever", "duration": "2 days"}],
"medical_history": {"status": "confirmed_none", "items": []},
"medications": {"status": "confirmed_none", "items": []},
"allergies": [],
}
state_mock = MagicMock(state_json=json.dumps(ready_state))
res = await orchestrator.process(
mock_session, state_mock,
[{"role": "assistant", "message": "Do you have any allergies?", "stage": "allergies"}],
"வேற எதுவும் இல்லை"
)
assert res.get("completed") is True
asyncio.run(run())
# =====================================================================
# TEST 14: Mental Health Specialty Focus
# =====================================================================
def test_14_mental_health_specialty():
async def run():
mock_session = get_mock_session()
mock_session.category_code = "psychiatry"
orchestrator = AIOrchestrator()
state_mock = MagicMock(state_json=json.dumps({}))
res = await orchestrator.process(mock_session, state_mock, [], "எனக்கு ரொம்ப stress, தூக்கம் சரியா வரல, இரண்டு வாரமா இப்படித்தான்.")
reply = res["reply"]
assert not any(k in reply for k in ["இருமல்", "பிபி", "வயிற்று வலி"])
asyncio.run(run())
# =====================================================================
# TEST 15: Cardiology Specialty Focus
# =====================================================================
def test_15_cardiology_specialty():
async def run():
mock_session = get_mock_session()
mock_session.category_code = "cardiology"
orchestrator = AIOrchestrator()
state_mock = MagicMock(state_json=json.dumps({}))
res = await orchestrator.process(mock_session, state_mock, [], "நெஞ்சு படபடன்னு இருக்கு, நடக்கும்போது மூச்சு வாங்குது.")
syms = res["state_updates"].get("symptoms", [])
assert len(syms) >= 2
asyncio.run(run())
# =====================================================================
# TEST 16: Multiple Symptoms Extraction
# =====================================================================
def test_16_multiple_symptoms_extraction():
async def run():
mock_session = get_mock_session()
orchestrator = AIOrchestrator()
state_mock = MagicMock(state_json=json.dumps({}))
res = await orchestrator.process(mock_session, state_mock, [], "தலைவலி, கை கால் வலி, தலைசுற்றல், நெஞ்சு படபடப்பு.")
syms = res["state_updates"].get("symptoms", [])
assert len(syms) >= 3
asyncio.run(run())
# =====================================================================
# TEST 17: No False Symptom from Medication Statement
# =====================================================================
def test_17_no_false_symptom_from_medication():
async def run():
mock_session = get_mock_session()
orchestrator = AIOrchestrator()
state_mock = MagicMock(state_json=json.dumps({}))
res = await orchestrator.process(mock_session, state_mock, [], "நான் Metformin சாப்பிடுறேன்.")
syms = res["state_updates"].get("symptoms") or []
assert len(syms) == 0
asyncio.run(run())
# =====================================================================
# TEST 18: Session Isolation Verification
# =====================================================================
def test_18_session_isolation():
state_a = merge_state_updates({}, {"symptoms": [{"name": "headache"}]})
state_b = merge_state_updates({}, {"symptoms": [{"name": "fever"}]})
assert state_a["symptoms"][0]["name"] == "headache"
assert state_b["symptoms"][0]["name"] == "fever"
# =====================================================================
# TEST 19 & 20: API Response & Voice Turn Compatibility
# =====================================================================
def test_19_20_api_backward_compatibility():
res = VoiceTurnResponse(
success=True,
session_id="SESS-001",
patient_transcript="hello",
ai_text="hi",
stage="greeting",
completed=False,
audio_url="/v1/voice-onboarding/audio/123",
response_time_ms=100.0,
response_time_sec=0.1
)
assert res.success is True
assert res.completed is False
# =====================================================================
# TEST 21: Exact Bug Regression Test (Multi-Symptom + Duration Turn 2)
# =====================================================================
def test_21_exact_bug_regression():
async def run():
mock_session = get_mock_session()
orchestrator = AIOrchestrator()
msg1 = "எனக்கு ரொம்ப தலைவலி, கை கால் வலியாக இருக்குது. என் நெஞ்சு அப்பப்ப படபடன்னு இருக்குது. தலை சுத்துது, தலை பாரமாக இருக்குது."
state_mock = MagicMock(state_json=json.dumps({}))
res1 = await orchestrator.process(mock_session, state_mock, [], msg1)
s1 = merge_state_updates({}, res1["state_updates"])
state_mock2 = MagicMock(state_json=json.dumps(s1))
res2 = await orchestrator.process(
mock_session, state_mock2,
[{"role": "patient", "message": msg1, "stage": "greeting"}, {"role": "assistant", "message": res1["reply"], "stage": res1["next_stage"]}],
"பிரச்சனை வந்து ஒரு மூணு நாளா இருக்கு."
)
# MUST NOT complete after duration answer alone
assert res2.get("completed") is False
assert "எத்தனை நாளா" not in res2["reply"]
asyncio.run(run())
@@ -1,52 +0,0 @@
"""
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}"
File diff suppressed because it is too large Load Diff
-125
View File
@@ -1,125 +0,0 @@
import sys
import os
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 test_cors_allowed_origin_preflight():
"""Test browser OPTIONS preflight for allowed origin (http://localhost:3000)."""
response = client.options(
"/v1/onboarding/start",
headers={
"Origin": "http://localhost:3000",
"Access-Control-Request-Method": "POST",
"Access-Control-Request-Headers": "authorization, content-type, x-api-key",
},
)
assert response.status_code == 200
assert response.headers.get("access-control-allow-origin") == "http://localhost:3000"
assert response.headers.get("access-control-allow-credentials") == "true"
assert "POST" in response.headers.get("access-control-allow-methods", "").upper()
def test_cors_allowed_origin_actual_request():
"""Test actual POST request from allowed origin (http://localhost:5173)."""
response = client.post(
"/v1/onboarding/start",
json={
"patient": {"id": "PAT-CORS-1", "name": "CORS Test", "age": 30, "gender": "female"},
"category": {"type": "general", "code": "general_medicine", "name": "General Medicine"},
"language": "en"
},
headers={
"Origin": "http://localhost:5173",
"Authorization": "Bearer test_token_PAT-CORS-1"
}
)
assert response.status_code == 200
assert response.headers.get("access-control-allow-origin") == "http://localhost:5173"
assert response.headers.get("access-control-allow-credentials") == "true"
def test_cors_unauthorized_request_returns_cors_headers():
"""Test that 401 Unauthorized responses STILL return CORS headers to allowed origin."""
response = client.post(
"/v1/onboarding/start",
json={"patient": {"id": "PAT-NOAUTH"}},
headers={"Origin": "http://localhost:3000"}
)
assert response.status_code == 401
assert response.headers.get("access-control-allow-origin") == "http://localhost:3000"
assert response.headers.get("access-control-allow-credentials") == "true"
def test_cors_forbidden_request_returns_cors_headers():
"""Test that 403 Forbidden responses STILL return CORS headers to allowed origin."""
# 1. Create session for PAT-CORS-OWNER
res_start = client.post(
"/v1/onboarding/start",
json={
"patient": {"id": "PAT-CORS-OWNER", "name": "Owner", "age": 28, "gender": "male"},
"category": {"type": "general", "code": "general_medicine", "name": "General Medicine"}
},
headers={"Authorization": "Bearer test_token_PAT-CORS-OWNER"}
)
session_id = res_start.json()["session_id"]
# 2. Access with different patient PAT-CORS-ATTACKER
res_idor = client.post(
f"/v1/onboarding/{session_id}/message",
json={"message": "IDOR attempt"},
headers={
"Origin": "http://localhost:3000",
"Authorization": "Bearer test_token_PAT-CORS-ATTACKER"
}
)
assert res_idor.status_code == 403
assert res_idor.headers.get("access-control-allow-origin") == "http://localhost:3000"
assert res_idor.headers.get("access-control-allow-credentials") == "true"
def test_cors_disallowed_origin_rejected():
"""Test that disallowed origin (https://malicious-example.com) is NOT allowed by CORS headers."""
response = client.options(
"/v1/onboarding/start",
headers={
"Origin": "https://malicious-example.com",
"Access-Control-Request-Method": "POST",
"Access-Control-Request-Headers": "authorization, content-type",
},
)
# Browsers block preflight if access-control-allow-origin does not match
assert response.headers.get("access-control-allow-origin") != "https://malicious-example.com"
def test_cors_voice_endpoints():
"""Test preflight and actual POST on voice onboarding endpoints."""
# Preflight voice start
res_opt = client.options(
"/v1/voice-onboarding/start",
headers={
"Origin": "http://localhost:5173",
"Access-Control-Request-Method": "POST",
"Access-Control-Request-Headers": "authorization, content-type"
}
)
assert res_opt.status_code == 200
assert res_opt.headers.get("access-control-allow-origin") == "http://localhost:5173"
# Actual voice start
res_post = client.post(
"/v1/voice-onboarding/start",
json={
"patient": {"id": "PAT-CORS-VOICE", "name": "Voice Test", "age": 40, "gender": "female"},
"language": "en-IN"
},
headers={
"Origin": "http://localhost:5173",
"Authorization": "Bearer test_token_PAT-CORS-VOICE"
}
)
assert res_post.status_code == 200
assert res_post.headers.get("access-control-allow-origin") == "http://localhost:5173"
if __name__ == '__main__':
pytest.main(["-v", __file__])
@@ -1,294 +0,0 @@
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
@@ -1,236 +0,0 @@
"""
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
@@ -1,83 +0,0 @@
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"
@@ -1,250 +0,0 @@
"""
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