16 Commits
Author SHA1 Message Date
Gokulvarthan-Ramesh 78e93eaf69 feat: export API_SECRET_KEY in app config 2026-09-07 17:17:52 +05:30
Gokulvarthan-Ramesh 92fa71ae4a feat: complete production hardening implementation, specialty canonicalization, google-genai migration, voice reliability, sqlite concurrency, and test suite consolidation 2026-09-07 12:57:54 +05:30
Gokulvarthan-Ramesh c23d979338 feat(ai-onboarding): enhance conversation flow, prompt rules & update Postman environment port to 8001 2026-09-01 15:22:40 +05:30
Gokulvarthan-Ramesh 1d794ca425 fix(security): resolve 403 Forbidden on voice turn - normalize patient ID matching with PAT-prefix support - Bearer token now prioritized over X-API-Key for identity resolution - Master API key gets admin role for service-to-service bypass - Centralized verify_session_ownership helper across all IDOR checks - Files: security.py, voice_onboarding.py, onboarding.py, documents.py 2026-08-31 15:09:25 +05:30
Gokulvarthan-Ramesh 0cd932dbce docs: consolidate all integration guides into single master API_INTEGRATION_GUIDE.md 2026-08-31 14:06:48 +05:30
Gokulvarthan-Ramesh e91cdbae6e feat(api): production security, cors hardening, latency optimizations, postman artifacts & frontend documentation 2026-08-31 13:48:54 +05:30
Gokulvarthan-Ramesh 4e21d617ce docs: add API documentation for frontend integration and include response timing metrics 2026-08-31 11:14:55 +05:30
Gokulvarthan-Ramesh 29475dffcf Make category optional in start voice onboarding API with automatic triage 2026-08-31 11:03:50 +05:30
Gokulvarthan-Ramesh 80eaefadb2 Add response_time_ms and response_time_sec fields to start and turn API responses 2026-08-31 10:55:53 +05:30
Gokulvarthan-Ramesh 15e8b2dfec Configure CORSMiddleware for https://onboarding.skyheal.ai 2026-08-29 17:56:11 +05:30
Gokulvarthan-Ramesh dc2799a95b feat: implement voice-based patient onboarding pipeline with AI orchestration, multi-language STT/TTS support, and clinical state extraction. 2026-08-29 15:09:31 +05:30
Gokulvarthan-Ramesh 5e500dcfbc feat: implement multi-model voice service and orchestration logic for medical speech processing 2026-08-28 14:37:23 +05:30
Gokulvarthan-Ramesh f66ade7051 feat: implement VoiceService with multi-model speech-to-text and text-to-speech capabilities 2026-08-28 12:57:22 +05:30
Gokulvarthan-Ramesh 1645d9b1a8 feat: initialize FastAPI application with database integration, middleware, and core onboarding routers 2026-08-28 09:57:28 +05:30
Gokulvarthan-Ramesh 3d2596ced1 feat: implement AI-orchestrated result extraction and service connectivity tests 2026-08-27 17:45:07 +05:30
Gokulvarthan-Ramesh 59c1372fe3 feat: AI patient onboarding backend and documentation 2026-08-27 12:15:09 +05:30
121 changed files with 13653 additions and 0 deletions
+45
View File
@@ -0,0 +1,45 @@
# Ignore Frontend Directory
frontend/
# Dependencies
node_modules/
venv/
env/
.venv/
# Environment Variables & Secrets
.env
.env.local
.env.*.local
*.env
# Python Cache & Databases
__pycache__/
*.py[cod]
*$py.class
*.sqlite
*.sqlite3
*.db
data/*.db
data/audio/
data/audio_temp/
data/reports/
data/transcripts/
*.mp3
*.wav
*.ogg
*.m4a
.pytest_cache/
# Build & Output
dist/
dist-ssr/
build/
*.local
.cache/
# IDE & OS
.vscode/
.idea/
.DS_Store
Thumbs.db
+532
View File
@@ -0,0 +1,532 @@
# AI Patient Onboarding — Backend API Documentation
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.
---
## 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)
---
## 1. Architecture & Server Overview
- **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.
```
+------------------+ +-------------------------------+ +-----------------------+
| 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 -----------|
```
---
## 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-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": "speciality",
"code": "cardio",
"name": "Cardiology"
},
"language": "en-IN"
}
```
- **Response (`200 OK`):**
```json
{
"success": true,
"session_id": "VOICE-78A1BF2C",
"patient_id": "PAT-9842",
"category": {
"type": "speciality",
"code": "cardio",
"name": "Cardiology"
},
"language": "en-IN",
"status": "in_progress",
"stage": "greeting",
"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"
}
```
---
### 4.2 Send Voice / Speech Turn
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.
- **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.
- **Response (`200 OK`):**
```json
{
"success": true,
"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/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": "cardio",
"name": "Cardiology"
},
"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."
}
```
#### Step B: Confirm & Submit to EHR (`confirmed: true`)
```json
{
"confirmed": true
}
```
**Response (`200 OK`):**
```json
{
"success": true,
"session_id": "VOICE-78A1BF2C",
"status": "submitted_and_cleaned",
"result": { ... },
"message": "Voice onboarding successfully saved to patient record."
}
```
---
## 5. Text Onboarding API (`/v1/onboarding`)
For text chat interfaces or non-voice kiosks.
### 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
{
"message": "I've had knee pain for 3 days after a run."
}
```
- **Response:**
```json
{
"success": true,
"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}`
---
## 6. Medical Document & Vision API (`/v1/documents`)
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.
- **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": "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."
}
```
---
## 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"
}
}
```
---
## 8. Connecting External Hospital EMR / EHR System
When onboarding is finalized, the backend invokes `PatientSubmissionService` located at:
`app/services/patient_submission_service.py`
### Customizing the Destination Forwarding:
```python
import httpx
from app.config import PATIENT_API_BASE_URL, PATIENT_API_TOKEN
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"),
}
# 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,
)
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}'
```
+19
View File
@@ -0,0 +1,19 @@
.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/*
+39
View File
@@ -0,0 +1,39 @@
# 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"
# Server & Port
HOST="0.0.0.0"
PORT=8000
# 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"
# Database Configuration
DATABASE_URL="sqlite:///./data/onboarding.db"
# 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=""
+17
View File
@@ -0,0 +1,17 @@
venv/
.env
__pycache__/
*.pyc
.pytest_cache/
data/audio/
data/audio_temp/
data/reports/
data/transcripts/
*.db
*.sqlite
*.sqlite3
*.mp3
*.wav
*.ogg
*.m4a
@@ -0,0 +1,147 @@
{
"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"
}
@@ -0,0 +1,401 @@
{
"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"
}
}
]
}
]
}
]
}
+349
View File
@@ -0,0 +1,349 @@
# AI Patient Onboarding — Frontend Integration API Reference Guide
Base URL: `http://127.0.0.1:8000`
Protocol: `HTTP / REST` + `JSON` + `multipart/form-data`
---
## 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.
---
# SECTION 1: Voice AI Onboarding API (`/v1/voice-onboarding`)
### 1.1 Start Voice Onboarding Session
Initializes a voice onboarding session and returns localized greeting text + synthesized spoken audio URL.
- **Endpoint**: `POST /v1/voice-onboarding/start`
- **Content-Type**: `application/json`
#### Request Payload
```json
{
"patient": {
"id": "PAT-9624",
"name": "Gokul Varthan",
"age": 25,
"gender": "male"
},
"category": {
"type": "general",
"code": "general_medicine",
"name": "General Medicine / Primary Care"
},
"language": "ta-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`)
```json
{
"success": true,
"session_id": "VOICE-4C5F63F0",
"patient_id": "PAT-9624",
"category": {
"type": "general",
"code": "general_medicine",
"name": "General Medicine / Primary Care"
},
"language": "ta-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
}
```
---
### 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.
- **Endpoint**: `POST /v1/voice-onboarding/{session_id}/turn`
- **Content-Type**: `multipart/form-data`
#### 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.** |
#### 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`)
```json
{
"success": true,
"session_id": "VOICE-4C5F63F0",
"patient_transcript": "எனக்கு ரொம்ப மன அழுத்தம் மற்றும் மனச்சோர்வு இருக்கு",
"ai_text": "புரியுதுங்க கோகுல், இந்த மன அழுத்தமும் மனச்சோர்வும் எத்தனை நாளா இருக்குன்னு சொல்ல முடியுமா?",
"stage": "reason_for_visit",
"completed": false,
"audio_url": "/v1/voice-onboarding/audio/aud_a1b2c3d4e5f6",
"suggested_specialty": {
"type": "speciality",
"code": "psychiatry",
"name": "Psychiatry & Mental Health Care",
"confidence": 0.95,
"reason": "Automatically classified based on clinical keyword: 'மன அழுத்தம்'"
},
"response_time_ms": 1420.50,
"response_time_sec": 1.421
}
```
---
### 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
```json
{
"confirmed": true
}
```
#### Response (`200 OK`)
```json
{
"success": true,
"session_id": "VOICE-4C5F63F0",
"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
}
```
---
# SECTION 2: Text AI Onboarding API (`/v1/onboarding`)
### 2.1 Start Text Onboarding Session
- **Endpoint**: `POST /v1/onboarding/start`
- **Content-Type**: `application/json`
#### Request Payload
```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"
}
```
#### Response (`200 OK`)
```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?",
"status": "in_progress"
}
```
---
### 2.3 Complete Text Onboarding & Export EHR JSON
- **Endpoint**: `POST /v1/onboarding/{session_id}/complete`
- **Content-Type**: `application/json`
#### Request Payload
```json
{
"confirmed": true
}
```
#### 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."
}
],
"medical_history": [],
"medications": [],
"allergies": ["No Known Drug Allergies (NKDA)"],
"language": "en",
"onboarding_completed": true
}
}
```
---
# Architecture Sequence Diagram
```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
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
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
Frontend->>Patient: Plays AI audio response & updates UI
Patient->>Frontend: Final Confirmation
Frontend->>API: POST /v1/voice-onboarding/{session_id}/complete ({confirmed: true})
API-->>Frontend: Returns Final Structured EHR JSON
```
+42
View File
@@ -0,0 +1,42 @@
# 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"]
@@ -0,0 +1,65 @@
# 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
View File
@@ -0,0 +1 @@
"""App package initialization."""
+1
View File
@@ -0,0 +1 @@
"""AI package initialization."""
@@ -0,0 +1,69 @@
import json
from app.config import ANTHROPIC_API_KEY, ANTHROPIC_MODEL
class ClaudeClient:
def __init__(self):
self.api_key = ANTHROPIC_API_KEY
self.model = ANTHROPIC_MODEL
async def validate_extraction(
self,
patient_context: dict,
specialty_config: dict,
conversation_history: list,
extracted_data: dict,
) -> dict:
if not self.api_key or self.api_key == "your_claude_key":
return {
"valid": True,
"missing": [],
"recommendation": "continue",
"hallucinations_detected": False,
}
try:
from anthropic import AsyncAnthropic
client = AsyncAnthropic(api_key=self.api_key)
prompt = f"""
You are a senior clinical auditor and AI quality control specialist.
Your task is to validate whether the extracted patient data accurately reflects the actual patient conversation without hallucination or omitted critical criteria.
PATIENT CONTEXT:
{json.dumps(patient_context, indent=2)}
SPECIALTY REQUIREMENTS:
{json.dumps(specialty_config, indent=2)}
CONVERSATION HISTORY:
{json.dumps(conversation_history, indent=2)}
PROPOSED EXTRACTED STATE:
{json.dumps(extracted_data, indent=2)}
Return ONLY a JSON response:
{{
"valid": true/false,
"missing": ["list of critical missing focus items for this specialty, if any"],
"recommendation": "continue or ask_severity or ask_duration etc.",
"hallucinations_detected": false
}}
"""
response = await client.messages.create(
model=self.model,
max_tokens=1024,
system="You are a clinical AI quality controller. Output only valid JSON.",
messages=[{"role": "user", "content": prompt}],
)
content = response.content[0].text
return json.loads(content)
except Exception as e:
print(f"[Claude Validation Error]: {e}")
return {
"valid": True,
"missing": [],
"recommendation": "continue",
"hallucinations_detected": False,
}
+33
View File
@@ -0,0 +1,33 @@
import json
def build_onboarding_context(
session,
state,
conversation_history,
):
return {
"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,
},
"language": session.language,
"current_stage": session.stage,
"current_state": json.loads(
state.state_json
),
"conversation": conversation_history,
}
@@ -0,0 +1,21 @@
"""
Conversation package.
"""
from app.ai.conversation.question_engine import pick_best_question
from app.ai.conversation.completion_engine import check_required_missing, field_status, is_onboarding_complete
from app.ai.conversation.intent_tracker import get_last_ai_intent
from app.ai.conversation.response_formatter import format_conversational_response
from app.ai.conversation.next_best_action import NextBestActionEngine
from app.ai.conversation.response_generator import ResponseGenerator
__all__ = [
"pick_best_question",
"check_required_missing",
"field_status",
"is_onboarding_complete",
"get_last_ai_intent",
"format_conversational_response",
"NextBestActionEngine",
"ResponseGenerator",
]
@@ -0,0 +1,84 @@
"""
Deterministic completion evaluation engine. Single source of truth for onboarding completion.
"""
from typing import Dict, Any, List
def field_status(field_val: Any) -> str:
"""
Returns field status: 'not_collected' | 'reported' | 'confirmed_none' | 'unknown' | 'declined'
"""
if field_val is None:
return "not_collected"
if isinstance(field_val, dict):
status = field_val.get("status")
if status in ["reported", "confirmed_none", "unknown", "declined"]:
return status
items = field_val.get("items", [])
return "reported" if items else "not_collected"
if isinstance(field_val, list):
return "reported" if len(field_val) > 0 else "not_collected"
if isinstance(field_val, str) and field_val.strip():
return "reported"
return "not_collected"
def check_required_missing(state: Dict[str, Any]) -> List[str]:
"""
Single source of truth evaluating missing clinical fields for onboarding completion.
Required fields: reason_for_visit, symptoms, duration, medical_history, medications, allergies.
"""
if not isinstance(state, dict):
state = {}
missing: List[str] = []
# 1. Reason for Visit
if field_status(state.get("reason_for_visit")) == "not_collected":
missing.append("reason_for_visit")
# 2. Symptoms
symptoms = state.get("symptoms", [])
sym_status = field_status(symptoms)
if sym_status == "not_collected":
missing.append("symptoms")
# 3. Duration
has_duration = False
if isinstance(symptoms, list) and symptoms:
for s in symptoms:
if isinstance(s, dict) and s.get("duration"):
has_duration = True
break
if not has_duration and state.get("duration"):
has_duration = True
if sym_status != "not_collected" and not has_duration:
missing.append("duration")
# 4. Medical History
if field_status(state.get("medical_history")) == "not_collected":
missing.append("medical_history")
# 5. Medications
if field_status(state.get("medications")) == "not_collected":
missing.append("medications")
# 6. Allergies
if field_status(state.get("allergies")) == "not_collected":
missing.append("allergies")
return missing
def is_onboarding_complete(state: Dict[str, Any]) -> bool:
"""
Returns True ONLY if all required fields are collected or confirmed_none.
"""
missing = check_required_missing(state)
return len(missing) == 0
@@ -0,0 +1,26 @@
"""
Question intent tracker for semantic question deduplication.
"""
from typing import Optional, List, Dict, Any
from app.services.contextual_normalizer import detect_previous_question_intent
def get_last_ai_intent(conversation_history: List[Dict[str, Any]]) -> Optional[str]:
"""
Returns the intent code of the last AI message in conversation history.
"""
if not conversation_history:
return None
last_ai_msg = None
for turn in reversed(conversation_history):
if turn.get("role") in ["assistant", "ai"]:
last_ai_msg = turn.get("message") or turn.get("content")
break
if last_ai_msg:
return detect_previous_question_intent(last_ai_msg)
return None
@@ -0,0 +1,98 @@
"""
NextBestActionEngine (Rules 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91):
- Conversational State Machine Evaluation
- Priority Scoring & Action Selection
- Cooldown & Intent Deduplication
- Status-aware field tracking (reported, confirmed_none, unknown, declined, skipped, not_asked)
"""
from typing import Dict, Any, List, Optional, Tuple
class NextBestActionEngine:
"""
Evaluates intake state and determines the next-best action and target question.
"""
def calculate_priority_score(
self,
field: str,
current_state: Dict[str, Any],
is_psych: bool = False,
asked_intents: Optional[List[str]] = None
) -> float:
"""
Calculates question priority score based on Rule 83:
priority = safety_weight + clinical_importance + symptom_completeness
+ specialty_relevance + information_value
- repetition_penalty - already_answered_penalty - unnecessary_question_penalty
"""
score = 0.0
asked_intents = asked_intents or []
weights = {
"emergency": 100.0,
"crisis": 100.0,
"reason_for_visit": 50.0,
"symptoms": 50.0,
"duration": 45.0,
"severity": 30.0,
"medical_history": 35.0,
"medications": 30.0,
"allergies": 25.0,
"previous_episodes": 15.0,
"family_history": 10.0,
"additional_information": 5.0,
}
score += weights.get(field, 10.0)
# Repetition penalty (Rule 84 & 85)
if field in asked_intents:
score -= 60.0
# Specialty relevance bonus
if is_psych and field in ["duration", "medical_history"]:
score += 10.0
return score
def determine_next_action(
self,
current_state: Dict[str, Any],
is_emergency: bool = False,
is_crisis: bool = False,
is_psych: bool = False,
missing_fields: Optional[List[str]] = None
) -> Tuple[str, Optional[str]]:
"""
Returns (action_type, target_field)
Action types (Rule 82):
- SAFETY_ACTION
- CLARIFY
- ASK_QUESTION
- COMPLETE
- HANDLE_REFUSAL
- HANDLE_UNKNOWN
"""
if is_crisis or is_emergency:
return "SAFETY_ACTION", None
# Check for state conflicts requiring clarification (Rule 67)
if current_state and current_state.get("conflicts"):
return "CLARIFY", current_state["conflicts"][-1].get("field")
missing_fields = missing_fields or []
if not missing_fields:
return "COMPLETE", None
# Score missing fields using priority score (Rule 83)
scored_fields = []
for field in missing_fields:
score = self.calculate_priority_score(field, current_state, is_psych=is_psych)
scored_fields.append((score, field))
scored_fields.sort(key=lambda x: x[0], reverse=True)
best_field = scored_fields[0][1]
return "ASK_QUESTION", best_field
@@ -0,0 +1,168 @@
"""
Priority-based Question Engine. Selects the single highest priority missing question.
"""
from typing import List, Optional, Dict, Any
def pick_best_question(
missing_list: List[str],
lang: str,
is_psych: bool = False,
current_state: Optional[Dict[str, Any]] = None
) -> str:
"""
Picks the single highest priority missing question for onboarding.
Enforces Rule 51 & 52: Short, natural, 1-2 sentences, max 15-20 words.
Enforces Rule 67: Asks short clarification if conflict detected in state.
"""
if isinstance(current_state, dict) and current_state.get("conflicts"):
conflict = current_state["conflicts"][-1]
item = conflict.get("item", "this condition")
if isinstance(item, dict):
item = item.get("condition") or item.get("name") or "this condition"
lang_clean = lang.lower() if lang else "en"
if lang_clean.startswith("ta"):
return f"உறுதிப்படுத்த கேட்கிறேன், உங்களுக்கு {item} இருக்கா?"
elif lang_clean.startswith("hi"):
return f"पुष्टि के लिए, क्या आपको {item} की समस्या है?"
else:
return f"Just to confirm, do you currently have {item}?"
if not missing_list:
return _build_completion_reply(lang)
field = missing_list[0]
has_meds = "medications" in missing_list
has_algs = "allergies" in missing_list
has_history = "medical_history" in missing_list
combine_meds_algs = (field == "medications" and has_algs) or (field == "allergies" and has_meds)
combine_history_meds = (field == "medical_history" and has_meds and not has_algs)
symptom_name = None
if isinstance(current_state, dict):
rfv = current_state.get("reason_for_visit")
syms = current_state.get("symptoms", [])
if rfv and isinstance(rfv, str) and rfv.strip():
symptom_name = rfv.strip()
elif syms and isinstance(syms, list) and len(syms) > 0 and isinstance(syms[0], dict) and syms[0].get("name"):
symptom_name = syms[0].get("name").strip()
lang_clean = lang.lower() if lang else "en"
# Tamil
if lang_clean.startswith("ta"):
if field in ["reason_for_visit", "symptoms"]:
return "வணக்கம், உங்களுக்கு என்ன பிரச்சனை இருக்கு?"
elif field == "duration":
if is_psych:
return "இந்த மன அழுத்தம் எப்போலிருந்து இருக்கு?"
if symptom_name:
return f"இந்த {symptom_name} எத்தனை நாளா இருக்கு?"
return "இந்த பிரச்சனை எத்தனை நாளா இருக்கு?"
elif field == "medical_history":
if is_psych:
return "முன்னாடி இதுக்கு சிகிச்சை எடுத்திருக்கீங்களா?"
if symptom_name:
return f"{symptom_name} தவிர பிபி, சுகர் போன்ற பிரச்சனை இருக்கா?"
return "உங்களுக்கு பிபி, சுகர் போன்ற வேறு உடம்பு பிரச்சனை இருக்கா?"
elif field == "medications":
return "வழக்கமா ஏதேனும் மாத்திரை சாப்பிடுறீங்களா?"
elif field == "allergies":
return "உங்களுக்கு ஏதேனும் அலர்ஜி இருக்கா?"
# Hindi
elif lang_clean.startswith("hi"):
if field in ["reason_for_visit", "symptoms"]:
return "नमस्ते, आपको क्या तकलीफ हो रही है?"
elif field == "duration":
if symptom_name:
return f"यह {symptom_name} कितने दिनों से है?"
return "यह तकलीफ कितने दिनों से हो रही है?"
elif field == "medical_history":
if symptom_name:
return f"{symptom_name} के अलावा क्या बीपी या शुगर जैसी बीमारी है?"
return "क्या आपको बीपी या शुगर जैसी कोई बीमारी है?"
elif field == "medications":
return "क्या आप नियमित रूप से कोई दवाई लेते हैं?"
elif field == "allergies":
return "क्या आपको किसी दवाई से एलर्जी है?"
# Telugu
elif lang_clean.startswith("te"):
if field in ["reason_for_visit", "symptoms"]:
return "నమస్కారం, మీ ఆరోగ్య సమస్య ఏమిటి?"
elif field == "duration":
if symptom_name:
return f"ఈ {symptom_name} ఎన్ని రోజుల నుండి ఉంది?"
return "ఈ ఇబ్బంది ఎన్ని రోజుల నుండి ఉంది?"
elif field == "medical_history":
if symptom_name:
return f"అర్థమైంది, {symptom_name} కాకుండా బీపీ లేదా షుగర్ ఉందా?"
return "మీకు బీపీ లేదా షుగర్ వంటి సమస్యలు ఉన్నాయా?"
elif field == "medications":
return "మీరు రోజూ ఏమైనా మందులు వాడుతున్నారా?"
elif field == "allergies":
return "మీకు ఏదైనా మందుల అలర్జీ ఉందా?"
# Kannada
elif lang_clean.startswith("kn"):
if field in ["reason_for_visit", "symptoms"]:
return "ನಮಸ್ಕಾರ, ನಿಮ್ಮ સમસ્યા ಏನು?"
elif field == "duration":
if symptom_name:
return f"ಈ {symptom_name} ಎಷ್ಟು ದಿನದಿಂದ ಇದೆ?"
return "ಈ ತೊಂದರೆ ಎಷ್ಟು ದಿನದಿಂದ ಇದೆ?"
elif field == "medical_history":
if symptom_name:
return f"{symptom_name} ಹೊರತುಪಡಿಸಿ ಬಿಪಿ ಅಥವಾ ಶುಗರ್ ಇದೆಯಾ?"
return "ನಿಮಗೆ ಬಿಪಿ ಅಥವಾ ಶುಗರ್ ಇದೆಯಾ?"
elif field == "medications":
return "ನೀವು ಯಾವುದೇ ಮಾತ್ರೆಗಳನ್ನು ತೆಗೆದುಕೊಳ್ಳುತ್ತಿದ್ದೀರಾ?"
elif field == "allergies":
return "ನಿಮಗೆ ಯಾವುದೇ ಅಲರ್ಜಿ ಇದೆಯಾ?"
# English Default (Rule 51 & 52: Short, natural, <15-20 words)
if field in ["reason_for_visit", "symptoms"]:
return "Hello, what symptoms are you experiencing today?"
elif field == "duration":
if symptom_name:
return f"Got it. How long have you had this {symptom_name}?"
return "Got it. How long have you had these symptoms?"
elif combine_history_meds:
if symptom_name:
return f"Apart from {symptom_name}, do you have BP, diabetes, or take regular medicines?"
return "Do you have any past health conditions or take regular medicines?"
elif combine_meds_algs:
if symptom_name:
return f"Are you taking regular medications for {symptom_name}, or do you have any allergies?"
return "Are you currently taking any regular medications, or do you have any allergies?"
elif field == "medical_history":
if is_psych:
return "Have you received treatment or counseling for this before?"
if symptom_name:
return f"Apart from {symptom_name}, do you have BP, diabetes, or other chronic conditions?"
return "Do you have a history of BP, diabetes, or other health conditions?"
elif field == "medications":
if symptom_name:
return f"Are you taking any daily medicines for {symptom_name} or other conditions?"
return "Are you currently taking any regular daily medications?"
elif field == "allergies":
return "Do you have any medicine or food allergies?"
return "Could you share any other details about your symptoms?"
def _build_completion_reply(lang: str) -> str:
lang_clean = lang.lower() if lang else "en"
if lang_clean.startswith("ta"):
return "நன்றி. உங்கள் விவரங்கள் பதிவு செய்யப்பட்டன."
elif lang_clean.startswith("hi"):
return "ठीक है, आपकी जानकारी दर्ज हो गई है।"
elif lang_clean.startswith("te"):
return "ధన్యవాదాలు, మీ వివరాలు నమోదు చేయబడ్డాయి."
elif lang_clean.startswith("kn"):
return "ಧನ್ಯವಾದಗಳು, ನಿಮ್ಮ ವಿವರಗಳನ್ನು ದಾಖಲಿಸಲಾಗಿದೆ."
else:
return "Thanks. Your onboarding is complete."
@@ -0,0 +1,57 @@
"""
Deterministic Question Templates (Rule 107 & 108):
Fallback question templates mapped to semantic QuestionIntent across supported languages.
"""
from typing import Dict, List
from app.domain.models.contracts import QuestionIntent
QUESTION_TEMPLATES: Dict[str, Dict[QuestionIntent, List[str]]] = {
"en": {
QuestionIntent.SYMPTOM_NAME: ["What symptoms are you experiencing today?"],
QuestionIntent.SYMPTOM_DURATION: ["How long has it been?", "When did this start?"],
QuestionIntent.SYMPTOM_SEVERITY: ["How bad is the pain?", "How severe is it?"],
QuestionIntent.SYMPTOM_LOCATION: ["Where exactly does it hurt?"],
QuestionIntent.MEDICAL_HISTORY: ["Do you have BP, diabetes, or other chronic conditions?"],
QuestionIntent.MEDICATIONS: ["Are you currently taking any regular daily medications?"],
QuestionIntent.ALLERGIES: ["Do you have any medicine or food allergies?"],
},
"ta": {
QuestionIntent.SYMPTOM_NAME: ["வணக்கம், உங்களுக்கு என்ன பிரச்சனை இருக்கு?"],
QuestionIntent.SYMPTOM_DURATION: ["இந்த பிரச்சனை எத்தனை நாளா இருக்கு?", "இது எப்போலிருந்து இருக்கு?"],
QuestionIntent.SYMPTOM_SEVERITY: ["வலி எவ்வளவு தீவிரமா இருக்கு?"],
QuestionIntent.SYMPTOM_LOCATION: ["எங்க வலிக்குது?"],
QuestionIntent.MEDICAL_HISTORY: ["உங்களுக்கு பிபி, சுகர் போன்ற வேறு உடம்பு பிரச்சனை இருக்கா?"],
QuestionIntent.MEDICATIONS: ["வழக்கமா ஏதேனும் மாத்திரை சாப்பிடுறீங்களா?"],
QuestionIntent.ALLERGIES: ["உங்களுக்கு ஏதேனும் அலர்ஜி இருக்கா?"],
},
"hi": {
QuestionIntent.SYMPTOM_NAME: ["नमस्ते, आपको क्या तकलीफ हो रही है?"],
QuestionIntent.SYMPTOM_DURATION: ["यह तकलीफ कितने दिनों से हो रही है?", "यह दर्द कब शुरू हुआ?"],
QuestionIntent.SYMPTOM_SEVERITY: ["दर्द कितना तेज है?"],
QuestionIntent.SYMPTOM_LOCATION: ["दर्द कहाँ हो रहा है?"],
QuestionIntent.MEDICAL_HISTORY: ["क्या आपको बीपी या शुगर जैसी कोई बीमारी है?"],
QuestionIntent.MEDICATIONS: ["क्या आप नियमित रूप से कोई दवाई लेते हैं?"],
QuestionIntent.ALLERGIES: ["क्या आपको किसी दवाई से एलर्जी है?"],
},
"te": {
QuestionIntent.SYMPTOM_NAME: ["నమస్కారం, మీకు ఏమి సమస్య ఉంది?"],
QuestionIntent.SYMPTOM_DURATION: ["ఈ సమస్య ఎంతకాలంగా ఉంది?", "ఈ నొప్పి ఎప్పుడు మొదలైంది?"],
QuestionIntent.SYMPTOM_SEVERITY: ["నొప్పి ఎంత తీవ్రంగా ఉంది?"],
QuestionIntent.SYMPTOM_LOCATION: ["నొప్పి ఎక్కడ ఉంది?"],
QuestionIntent.MEDICAL_HISTORY: ["మీకు బిపి, షుగర్ వంటి ఇతర ఆరోగ్య సమస్యలు ఉన్నాయా?"],
QuestionIntent.MEDICATIONS: ["మీరు క్రమంగా ఏవైనా మందులు వాడుతున్నారా?"],
QuestionIntent.ALLERGIES: ["మీకు ఏవైనా మందులు లేదా ఆహార అలెర్జీలు ఉన్నాయా?"],
},
"kn": {
QuestionIntent.SYMPTOM_NAME: ["ನಮಸ್ಕಾರ, ನಿಮಗೆ ಏನು ತೊಂದರೆಯಾಗಿದೆ?"],
QuestionIntent.SYMPTOM_DURATION: ["ಈ ತೊಂದರೆ ಎಷ್ಟು ದಿನಗಳಿಂದ ಇದೆ?", "ಈ ನೋವು ಯಾವಾಗ ಶುರುವಾಯಿತು?"],
QuestionIntent.SYMPTOM_SEVERITY: ["ನೋವು ಎಷ್ಟು ತೀವ್ರವಾಗಿದೆ?"],
QuestionIntent.SYMPTOM_LOCATION: ["ನೋವು ಎಲ್ಲಿದೆ?"],
QuestionIntent.MEDICAL_HISTORY: ["ನಿಮಗೆ ಬಿಪಿ, ಸಕ್ಕರೆ ಕಾಯಿಲೆ ಮುಂತಾದ ಇತರ ತೊಂದರೆಗಳಿವೆಯೇ?"],
QuestionIntent.MEDICATIONS: ["ನೀವು ನಿಯಮಿತವಾಗಿ ಯಾವುದೇ ಮಾತ್ರೆಗಳನ್ನು ತೆಗೆದುಕೊಳ್ಳುತ್ತಿದ್ದೀರಾ?"],
QuestionIntent.ALLERGIES: ["ನಿಮಗೆ ಯಾವುದೇ ಔಷಧಿ ಅಥವಾ ಆಹಾರ ಅಲರ್ಜಿಗಳಿವೆಯೇ?"],
}
}
@@ -0,0 +1,169 @@
"""
Response formatter enforcing Rules 53-60:
- Generate response after state processing.
- One Question Only (Rule 54).
- Acknowledge + Ask pattern (Rule 55).
- No unnecessary confirmation / No robotic language (Rules 56 & 57).
- No medical explanations (Rule 58).
- Natural conversation with short answers (Rule 59).
- Response length enforcement: 5-15 words target, max 20 words (Rule 60).
"""
import re
from typing import Optional
ROBOTIC_PHRASES = [
r"i\s+have\s+(?:recorded|noted|documented)(?:\s+that|\s+this)?\.?",
r"thank\s+you\s+for\s+(?:sharing|providing|the)\s+(?:that\s+|this\s+)?(?:information|details|response)?\.?",
r"i\s+understand\s+your\s+response\.?",
r"please\s+provide\s+(?:the\s+)?(?:duration|severity|location|additional|details)",
r"kindly\s+specify\s+(?:the\s+)?(?:duration|severity|location)",
r"your\s+response\s+has\s+been\s+recorded\.?",
r"your\s+response\s+indicates\s+that[^.!?]+[.!?]",
]
DIAGNOSTIC_PATTERNS = [
r"this\s+could\s+(?:indicate|suggest|be)\s+[^.!?]+[.!?]",
r"possible\s+causes\s+include\s+[^.!?]+[.!?]",
]
FORM_READBACK_PATTERNS = [
r"i\s+understand\s+(?:that\s+)?you\s+(?:have|are|were|take|have\s+been|suffered)[^.!?]+[.!?]",
r"you\s+mentioned\s+(?:that\s+)?you\s+(?:have|are|were|take|have\s+been)[^.!?]+[.!?]",
r"i\s+see\s+that\s+you\s+(?:have|are|were|take|have\s+been)[^.!?]+[.!?]",
]
DISCLAIMER_PATTERNS = [
r"as\s+an\s+ai\s+(?:language\s+model|assistant|clinical\s+intake)[^.!?]*[.!?]?",
r"please\s+note\s+that\s+i\s+cannot\s+provide\s+medical\s+advice[^.!?]*[.!?]?",
r"i\s+am\s+not\s+a\s+doctor[^.!?]*[.!?]?",
]
def deduplicate_sentences(text: str) -> str:
"""Removes duplicate consecutive or identical sentences."""
if not text:
return text
sentences = [s.strip() for s in re.split(r"(?<=[.!?])\s+", text) if s.strip()]
seen = set()
unique = []
for s in sentences:
s_norm = s.lower()
if s_norm not in seen:
seen.add(s_norm)
unique.append(s)
return " ".join(unique)
def sanitize_final_response(raw_reply: str, lang: str = "en", scenario: str = "normal") -> str:
"""
Final response sanitization pipeline (Rule 70):
Generated Response
↓ Remove duplicate sentences
↓ Remove form readbacks (Rule 63) & diagnostic/robotic text (Rule 61)
↓ Remove multiple questions (Rule 54)
↓ Enforce dynamic response length (Rule 62)
↓ Return short natural response
"""
if not raw_reply or not raw_reply.strip():
if scenario in ["emergency", "crisis"]:
return "Please seek emergency medical evaluation immediately or call local emergency services."
if scenario == "completion":
return "Thank you. Your intake information is registered for the doctor."
return "Got it. What symptoms are you having?"
# Emergency / Crisis scenario (Rule 62): Return concise safety instruction without trimming
if scenario in ["emergency", "crisis"]:
text = raw_reply.strip()
text = re.sub(r"\s+", " ", text)
return text
text = raw_reply.strip()
# 1. Strip disclaimers (Rule 61)
for disc in DISCLAIMER_PATTERNS:
text = re.sub(disc, "", text, flags=re.IGNORECASE)
# 2. Strip diagnostic explanations (Rule 58 & 61)
for diag in DIAGNOSTIC_PATTERNS:
text = re.sub(diag, "", text, flags=re.IGNORECASE)
# 3. Strip form readbacks (Rule 63)
for readback in FORM_READBACK_PATTERNS:
text = re.sub(readback, "", text, flags=re.IGNORECASE)
# 4. Strip robotic confirmation phrases (Rule 56, 57, 64)
for rob in ROBOTIC_PHRASES:
text = re.sub(rob, "", text, flags=re.IGNORECASE)
text = re.sub(r"\s+", " ", text).strip()
if not text:
if scenario == "completion":
return "Thank you. Your intake information is registered for the doctor."
return "Got it. When did it start?"
# 5. Deduplicate sentences (Rule 70)
text = deduplicate_sentences(text)
# 6. Enforce ONE question only (Rule 54 & 70)
if text.count("?") > 1:
q_parts = text.split("?")
text = q_parts[0].strip() + "?"
# Completion scenario (Rule 62)
if scenario == "completion":
words = text.split()
if len(words) > 15:
text = " ".join(words[:12]) + "."
return text
# 7. Enforce max 1-2 short sentences (Rule 55, 60, 62)
sentences = [s.strip() for s in re.split(r"(?<=[.!?])\s+", text) if s.strip()]
if len(sentences) > 2:
q_idx = next((i for i, s in enumerate(sentences) if "?" in s), -1)
if q_idx != -1:
if q_idx > 0:
sentences = [sentences[0], sentences[q_idx]]
else:
sentences = [sentences[0]]
else:
sentences = sentences[:2]
text = " ".join(sentences)
# 8. Dynamic length enforcement: 5-15 words (max 20 words)
words = text.split()
if len(words) > 20:
if "?" in text:
q_sent = next((s for s in sentences if "?" in s), None)
ack_sent = next((s for s in sentences if "?" not in s), None)
if q_sent:
q_words = q_sent.split()
if len(q_words) > 20:
clean_words = [re.sub(r"[.!?]+$", "", w) for w in q_words[:18]]
text = " ".join(clean_words) + "...?"
elif ack_sent:
ack_words = ack_sent.split()
if len(ack_words) + len(q_words) <= 20:
text = f"{ack_sent} {q_sent}"
else:
candidate = f"Got it. {q_sent}"
if len(candidate.split()) <= 20:
text = candidate
else:
text = q_sent
else:
text = q_sent
else:
clean_words = [re.sub(r"[.!?]+$", "", w) for w in words[:18]]
text = " ".join(clean_words) + "..."
return text
def format_conversational_response(raw_reply: str, lang: str = "en", scenario: str = "normal") -> str:
"""Wrapper for final response sanitization pipeline (Rule 70)."""
return sanitize_final_response(raw_reply, lang=lang, scenario=scenario)
@@ -0,0 +1,40 @@
"""
ResponseGenerator (Rules 101, 102, 103, 104, 109, 110, 111, 112, 113, 114, 115):
Dedicated Response Generator pipeline consuming ResponseContext and returning ResponseResult.
- Enforces MAX_RESPONSE_WORDS = 20 (target 5-15 words).
- Validates single question limit (0 or 1 question).
- Calculates Response Quality Score.
- Prevents internal data leakage & resists prompt injection.
"""
from typing import List, Optional
from app.domain.models.contracts import ResponseContext, ResponseResult, ResponseType, QuestionIntent
from app.ai.conversation.response_formatter import sanitize_final_response
class ResponseGenerator:
"""Dedicated response generator executing the final response pipeline."""
def generate(self, ctx: ResponseContext) -> ResponseResult:
raw_text = ctx.selected_question or "Got it. When did it start?"
scenario = "normal"
resp_type = ResponseType.FOLLOW_UP
if ctx.safety_status in ["crisis"]:
scenario = "crisis"
resp_type = ResponseType.CRISIS
elif ctx.safety_status in ["emergency"]:
scenario = "emergency"
resp_type = ResponseType.SAFETY
elif ctx.onboarding_completed:
scenario = "completion"
resp_type = ResponseType.COMPLETION
sanitized_reply = sanitize_final_response(raw_text, lang=ctx.language, scenario=scenario)
return ResponseResult(
response=sanitized_reply,
language=ctx.language,
question_intent=ctx.question_intent.value if ctx.question_intent else None,
response_type=resp_type
)
@@ -0,0 +1,21 @@
"""
Extraction package.
"""
from app.ai.extraction.multilingual_normalizer import normalize_text, detect_language
from app.ai.extraction.symptom_extractor import extract_symptoms
from app.ai.extraction.medication_extractor import extract_medications
from app.ai.extraction.allergy_extractor import extract_allergies
from app.ai.extraction.history_extractor import extract_history
from app.ai.extraction.negation_engine import is_negated, extract_family_subject
__all__ = [
"normalize_text",
"detect_language",
"extract_symptoms",
"extract_medications",
"extract_allergies",
"extract_history",
"is_negated",
"extract_family_subject",
]
@@ -0,0 +1,56 @@
"""
Allergy extraction engine distinguishing drug/food allergies from side effects.
"""
import re
from typing import Dict, Any, List, Optional, Union
ALLERGEN_PATTERNS = [
(r"(penicillin)", "Penicillin"),
(r"(sulfa)", "Sulfa drugs"),
(r"(aspirin)", "Aspirin"),
(r"(dust)", "Dust allergy"),
(r"(peanuts)", "Peanuts"),
(r"(milk|dairy)", "Milk / Dairy"),
]
def extract_allergies(
message: str,
normalized_msg: str,
last_ai_message: Optional[str] = None
) -> Optional[Union[List[Dict[str, Any]], Dict[str, Any]]]:
"""
Extracts structured allergies or confirmed_none container.
"""
msg_lower = normalized_msg.lower()
# Check for explicit negation of allergies
neg_phrases = [
"no allergies", "no allergy", "no drug allergy", "no food allergy", "not allergic",
"nkda", "no allergic reaction", "அலர்ஜி எதுவும் இல்லை", "எந்த அலர்ஜியும் இல்லை",
"कोई एलर्जी नहीं", "कोई समस्या नहीं", "ఏ అలర్జీ లేదు", "ಯಾವ ಅಲರ್ಜಿ ಇಲ್ಲ"
]
if any(neg in msg_lower for neg in neg_phrases):
return {"status": "confirmed_none", "items": []}
allergies: List[Dict[str, Any]] = []
has_allergen = any(re.search(pat, msg_lower) for pat, _ in ALLERGEN_PATTERNS)
has_alg_indicator = any(k in msg_lower or k in message for k in ["allergic", "allergy", "அலர்ஜி", "ஒவ்வாமை", "एलर्जी", "rash", "reaction", "side effect", "தடிப்பு", "எடுத்தா"])
# Side effect distinction: "Paracetamol didn't reduce fever" is NOT an allergy
if "didn't reduce" in msg_lower or "didn't work" in msg_lower or "no effect" in msg_lower:
return None
if has_allergen or has_alg_indicator:
for pat, allergen in ALLERGEN_PATTERNS:
if re.search(pat, msg_lower):
alg_obj: Dict[str, Any] = {"name": allergen}
if "rash" in msg_lower or "தடிப்பு" in message or "खुजली" in message:
alg_obj["reaction"] = "Rash / Skin reaction"
allergies.append(alg_obj)
if not allergies and has_alg_indicator:
allergies.append({"name": "Reported allergy", "type": "unspecified"})
return allergies if allergies else None
@@ -0,0 +1,83 @@
"""
Medical, surgical, family history, and previous episode extraction engine.
"""
import re
from typing import Dict, Any, List, Optional, Tuple, Union
from app.ai.extraction.negation_engine import extract_family_subject
HISTORY_KEYWORDS = [
("diabetes", "Diabetes"), ("sugar", "Diabetes"), ("diabetic", "Diabetes"),
("hypertension", "Hypertension (High BP)"), ("high bp", "Hypertension (High BP)"), ("bp", "Hypertension (High BP)"),
("asthma", "Asthma"), ("thyroid", "Thyroid disorder"),
("சர்க்கரை நோய்", "Diabetes"), ("சுகர்", "Diabetes"), ("பிபி", "Hypertension"),
("डायबिटीज", "Diabetes"), ("शुगर", "Diabetes"), ("बीपी", "Hypertension"),
]
def extract_history(
message: str,
normalized_msg: str,
duration: Optional[str] = None
) -> Dict[str, Any]:
"""
Extracts medical_history, family_history, surgical_history, previous_episodes, and additional_information.
"""
msg_lower = normalized_msg.lower()
updates: Dict[str, Any] = {}
# Check for explicit negation of medical history for self
neg_history_phrases = [
"no past history", "no medical history", "no health conditions", "no bp no diabetes",
"no chronic illness", "don't have diabetes", "no diabetes", "no bp",
"எந்த நோயும் இல்லை", "வேற ஒன்னும் இல்லை", "பிபி சுகர் எதுவும் இல்ல",
"कोई बीमारी नहीं", "बीमारी नहीं है", "ఏ సమస్య లేదు", "ಯಾವ ಕಾಯಿಲೆ ಇಲ್ಲ"
]
# Check if patient mentions family member (e.g., "my father has diabetes")
is_fam, relation, _ = extract_family_subject(message)
if is_fam:
fam_list: List[Dict[str, Any]] = []
for kw, cond in HISTORY_KEYWORDS:
if re.search(rf"\b{re.escape(kw)}\b", msg_lower) or kw in message:
fam_list.append({"relation": relation, "condition": cond})
if fam_list:
updates["family_history"] = fam_list
if any(neg in msg_lower for neg in neg_history_phrases):
updates["medical_history"] = {"status": "confirmed_none", "items": []}
elif not is_fam:
med_history: List[Dict[str, Any]] = []
for kw, cond in HISTORY_KEYWORDS:
if re.search(rf"\b{re.escape(kw)}\b", msg_lower) or kw in message:
h_obj: Dict[str, Any] = {"condition": cond}
h_dur_m = re.search(r"(?:for|since)\s+(\d+\s+(?:years|months|yrs))", msg_lower)
if h_dur_m:
h_obj["duration"] = h_dur_m.group(1).strip()
if not any(x.get("condition") == cond for x in med_history):
med_history.append(h_obj)
if med_history:
updates["medical_history"] = med_history
# Surgical History & Previous Episodes
if "surgery" in msg_lower or "operation" in msg_lower or "ஆபரேஷன்" in message:
updates["surgical_history"] = [{"procedure": "Prior surgery reported", "approximate_date": duration or "Past"}]
if "same problem" in msg_lower or "had this before" in msg_lower or "மூன்று மாதங்களுக்கு முன்" in message:
updates["previous_episodes"] = [{"description": "Similar symptoms previously", "time": duration or "Past"}]
# Additional Information / Functional Impact
add_info: List[Dict[str, Any]] = []
if "couldn't eat" in msg_lower or "not eaten" in msg_lower or "சாப்பிட முடியல" in message:
add_info.append({"category": "functional_impact", "value": "Loss of appetite / difficulty eating"})
if "couldn't sleep" in msg_lower or "haven't slept" in msg_lower or "தூங்க முடியல" in message:
add_info.append({"category": "functional_impact", "value": "Sleep disruption"})
if "weak" in msg_lower or "tired" in msg_lower or "ரொம்ப டயர்டா இருக்கு" in message:
add_info.append({"category": "functional_impact", "value": "Severe weakness / fatigue"})
if add_info:
updates["additional_information"] = add_info
return updates
@@ -0,0 +1,68 @@
"""
Medication extraction engine supporting structured dosage, frequency, and confirmed_none status.
"""
import re
from typing import Dict, Any, List, Optional, Union
KNOWN_MED_PATTERNS = [
(r"(metformin(?:\s+\d+\s*mg)?(?:\s+twice\s+a\s+day)?)", "Metformin"),
(r"(paracetamol(?:\s+\d+\s*mg)?)", "Paracetamol"),
(r"(dolo(?:\s*650)?)", "Dolo 650 (Paracetamol)"),
(r"(pantocid|pan-d|pan 40|omez|omeprazole)", "Pantoprazole / Antacid"),
(r"(telmisartan|telma\s*\d+)", "Telmisartan (BP)"),
(r"(amlodipine|amlong\s*\d+)", "Amlodipine (BP)"),
(r"((?:bp|பிபி|blood\s*pressure)\s*(?:மாத்திரை|medicine|tablet|tablets|மருந்து))", "BP Medication"),
(r"((?:sugar|சுகர்|சர்க்கரை)\s*(?:மாத்திரை|medicine|tablet|tablets|மருந்து))", "Diabetes Medication"),
(r"((?:thyroid|தைராய்டு)\s*(?:மாத்திரை|medicine|tablet|tablets|மருந்து))", "Thyroid Medication"),
]
def extract_medications(
message: str,
normalized_msg: str,
last_ai_message: Optional[str] = None,
is_allergy_utterance: bool = False
) -> Optional[Union[List[Dict[str, Any]], Dict[str, Any]]]:
"""
Extracts structured medications or confirmed_none container.
"""
msg_lower = normalized_msg.lower()
# Check for explicit negation of medicines
neg_phrases = [
"no medicines", "no medication", "don't take any medicine", "don't take medicines",
"no tablets", "not taking any medicine", "no daily medicines", "no regular medicines",
"மாத்திரை எதுவும் இல்லை", "எந்த மாத்திரையும் சாப்பிடல", "மருந்து எதுவும் இல்லை",
"कोई दवा नहीं", "दवाई नहीं लेते", "दवा नहीं लेता", "ఏ మందులు లేవు", "ಯಾವ ಮಾತ್ರೆ ಇಲ್ಲ"
]
if any(neg in msg_lower for neg in neg_phrases):
return {"status": "confirmed_none", "items": []}
meds: List[Dict[str, Any]] = []
# Known medicine matchers
for pat, med_label in KNOWN_MED_PATTERNS:
m_match = re.search(pat, msg_lower)
if m_match:
matched_str = m_match.group(1).strip()
med_obj: Dict[str, Any] = {"name": med_label}
dosage_m = re.search(r"(\d+\s*mg|\d+\s*ml)", matched_str)
if dosage_m:
med_obj["dosage"] = dosage_m.group(1).strip()
freq_m = re.search(r"(twice\s+a\s+day|once\s+a\s+day|\d+\s+times\s+a\s+day|daily|twice)", matched_str)
if freq_m:
med_obj["frequency"] = freq_m.group(1).strip()
if not any(x.get("name") == med_obj["name"] for x in meds):
meds.append(med_obj)
# Generic medicine indicator matchers (only if not an allergy utterance)
if not meds and not is_allergy_utterance:
generic_pat = r"(tablets|goli|mathirai|மாத்திரை|மருந்து|दवाई|दवा|మాత్రలు|ಮಾತ್ರೆಗಳು)"
if re.search(generic_pat, msg_lower) and any(verb in msg_lower for verb in ["take", "taking", "சாப்பிடுறேன்", "சாப்பிட்டேன்", "வாங்குறேன்", "लेता", "लेती", "వాడుతున్నాను"]):
meds.append({"name": "Unspecified medication"})
return meds if meds else None
@@ -0,0 +1,71 @@
"""
Multilingual normalization layer for clinical messages.
Preserves raw input while generating normalized, canonical representations.
"""
import re
import unicodedata
from typing import Dict, Any, Tuple
from app.services.contextual_normalizer import (
normalize_duration_from_message,
detect_previous_question_intent,
is_duration_expression,
is_severity_expression,
is_location_expression,
is_negative_expression,
is_casual_filler,
extract_severity_from_message,
extract_location_from_message,
)
def normalize_text(text: str) -> str:
"""
Cleans Unicode, strips extra spaces, standardizes quotes and punctuation.
"""
if not text:
return ""
# Unicode Normalization Form C
normalized = unicodedata.normalize("NFC", text)
# Replace non-breaking spaces and smart quotes
normalized = normalized.replace("\xa0", " ").replace("’", "'").replace("“", '"').replace("”", '"')
# Collapse multiple whitespaces
normalized = re.sub(r"\s+", " ", normalized).strip()
return normalized
def detect_language(text: str, default_lang: str = "en") -> str:
"""
Detects script/language (ta, hi, te, kn, en).
"""
if not text:
return default_lang
# Script character ranges
has_ta = bool(re.search(r"[\u0B80-\u0BFF]", text))
has_hi = bool(re.search(r"[\u0900-\u097F]", text))
has_te = bool(re.search(r"[\u0C00-\u0C7F]", text))
has_kn = bool(re.search(r"[\u0C80-\u0CFF]", text))
if has_ta:
return "ta-IN"
if has_hi:
return "hi-IN"
if has_te:
return "te-IN"
if has_kn:
return "kn-IN"
# Transliterated language keywords
msg_lower = text.lower()
if any(w in msg_lower for w in ["irukku", "thalaivali", "romba", "mudiyala", "saapudren"]):
return "ta-IN"
if any(w in msg_lower for w in ["dard", "ho raha", "hoga", "le raha"]):
return "hi-IN"
if any(w in msg_lower for w in ["noppi", "undi", "thala noppi"]):
return "te-IN"
if any(w in msg_lower for w in ["novu", "ide", "thale novu"]):
return "kn-IN"
return default_lang or "en-IN"
@@ -0,0 +1,54 @@
"""
Contextual negation and scope resolution engine.
"""
import re
from typing import Dict, Any, Tuple
# Family relations
FAMILY_RELATIONS = [
("father", "father"), ("dad", "father"), ("appa", "father"), ("அப்பா", "father"), ("पिता", "father"), ("नाना", "grandfather"),
("mother", "mother"), ("mom", "mother"), ("amma", "mother"), ("அம்மா", "mother"), ("माँ", "mother"), ("माता", "mother"),
("brother", "brother"), ("annaa", "brother"), ("தம்பி", "brother"), ("अண்ணன்", "brother"), ("भाई", "brother"),
("sister", "sister"), ("akka", "sister"), ("அக்கா", "sister"), ("தங்கை", "sister"), ("बहन", "sister"),
("parent", "parent"), ("grand-father", "grandfather"), ("grandfather", "grandfather"), ("grandmother", "grandmother"),
]
# Negation indicators
NEGATION_PHRASES = [
"don't have", "do not have", "no history of", "no symptoms", "no allergies", "no allergy", "no medicine",
"no medications", "don't take", "do not take", "never had", "without", "denies", "absent",
"இல்லை", "எதுவும் இல்லை", "இல்லவே இல்லை", "மாத்திரை எதுவும் இல்லை", "நோயும் இல்லை",
"नहीं है", "कोई बीमारी नहीं", "कोई एलर्जी नहीं", "कोई दवा नहीं",
"లేవు", "ఏమీ లేదు", "ఇబ్బంది లేదు",
"ಇಲ್ಲ", "ಯಾವುದೇ ಇಲ್ಲ",
]
def is_negated(text: str) -> bool:
"""Returns True if the text expresses explicit negation."""
if not text:
return False
msg_lower = text.lower().strip()
if msg_lower in ["no", "none", "nothing", "no more", "nil", "illness none", "nkda"]:
return True
return any(neg in msg_lower for neg in NEGATION_PHRASES)
def extract_family_subject(text: str) -> Tuple[bool, str, str]:
"""
Checks if text refers to family member rather than the patient.
Returns: (is_family_history, relation, condition_text)
"""
if not text:
return False, "", text
msg_lower = text.lower().strip()
for rel_kw, rel_canonical in FAMILY_RELATIONS:
pattern = rf"\b{re.escape(rel_kw)}\b"
if re.search(pattern, msg_lower) or rel_kw in text:
# Check if patient explicitly negates disease for self in same turn
return True, rel_canonical, text
return False, "", text
@@ -0,0 +1,117 @@
"""
Layered symptom extraction engine supporting attribute mapping per symptom.
"""
import re
from typing import Dict, Any, List, Optional, Set, Tuple
from app.services.contextual_normalizer import (
normalize_duration_from_message,
extract_severity_from_message,
extract_location_from_message,
is_duration_expression,
is_severity_expression,
is_location_expression,
is_casual_filler,
is_negative_expression,
)
SYMPTOM_DICTIONARY = [
# General & Respiratory
("fever", "fever"), ("feverish", "fever"), ("காய்ச்சல்", "fever"), ("बुखार", "fever"), ("జ్వరం", "fever"), ("ಜ್ವರ", "fever"),
("cough", "cough"), ("coughing", "cough"), ("இருமல்", "cough"), ("खांसी", "cough"), ("దగ్గు", "cough"), ("ಕೆಮ್ಮು", "cough"),
("cold", "cold"), ("sneezing", "cold"), ("சளி", "cold"), ("सर्दी", "cold"), ("జలుబు", "cold"), ("ನೆಗಡಿ", "cold"),
("breathlessness", "breathlessness"), ("shortness of breath", "breathlessness"), ("difficulty breathing", "breathlessness"),
("மூச்சு திணறல்", "breathlessness"), ("மூச்சு வாங்குது", "breathlessness"), ("மூச்சு வாங்குறேன்", "breathlessness"), ("சாப்பிடும்போது மூச்சு", "breathlessness"), ("சாப்பிடும் போது மூச்சு", "breathlessness"), ("सांस फूलना", "breathlessness"),
("palpitations", "palpitations"), ("படபடன்னு", "palpitations"), ("நெஞ்சு படபடன்னு", "palpitations"), ("படபடப்பு", "palpitations"),
# Pain & Head
("headache", "headache"), ("head ache", "headache"), ("head pain", "headache"), ("migraine", "headache"),
("தலைவலி", "headache"), ("தலை பாரம்", "headache"), ("सिरदर्द", "headache"), ("తలనొప్పి", "headache"), ("ತಲೆನೋವು", "headache"),
("chest pain", "chest pain"), ("chest pressure", "chest pain"), ("நெஞ்சு வலி", "chest pain"), ("छाती में दर्द", "chest pain"),
("stomach pain", "stomach pain"), ("abdominal pain", "stomach pain"), ("stomach ache", "stomach pain"),
("வயிற்று வலி", "stomach pain"), ("पेट दर्द", "stomach pain"), ("కడుపు నొప్పి", "stomach pain"), ("<ctrl42> ಹೊಟ್ಟೆ ನೋವು", "stomach pain"),
("back pain", "back pain"), ("முதுகு வலி", "back pain"), ("पीठ दर्द", "back pain"),
("joint pain", "joint pain"), ("knee pain", "knee pain"), ("மூட்டு வலி", "joint pain"), ("घुटने का दर्द", "knee pain"),
# Neurological / ENT
("dizziness", "dizziness"), ("giddiness", "dizziness"), ("dizzy", "dizziness"),
("தலை சுத்துது", "dizziness"), ("தலைசுற்றல்", "dizziness"), ("மயக்கம்", "dizziness"), ("चक्कर", "dizziness"),
("sore throat", "sore throat"), ("throat pain", "sore throat"), ("தொண்டை வலி", "sore throat"), ("गले में खराश", "sore throat"),
# GI
("vomiting", "vomiting"), ("vomit", "vomiting"), ("vomited", "vomiting"), ("nausea", "vomiting"),
("வாந்தி", "vomiting"), ("உल्टी", "vomiting"), ("వాంతులు", "vomiting"), ("ವಾಂತಿ", "vomiting"),
("loose motion", "diarrhea"), ("diarrhea", "diarrhea"), ("loose stools", "diarrhea"),
("வயிற்றுப்போக்கு", "diarrhea"), ("दस्त", "diarrhea"),
# Dermatological
("itching", "itching"), ("rash", "rash"), ("skin rash", "rash"),
("அரிப்பு", "itching"), ("தழும்பு", "rash"), ("खुजली", "itching"),
]
def extract_symptoms(
message: str,
normalized_msg: str,
current_state: Optional[Dict[str, Any]] = None
) -> Tuple[List[Dict[str, Any]], Optional[str]]:
"""
Extracts symptoms and maps attributes (duration, severity, location, frequency) specifically.
Returns: (symptoms_list, primary_reason_for_visit)
"""
msg_lower = normalized_msg.lower()
found_symptoms: List[Dict[str, Any]] = []
found_names: Set[str] = set()
# Extract global context attributes
duration = normalize_duration_from_message(message)
severity = extract_severity_from_message(message)
location = extract_location_from_message(message)
# Frequency extraction (e.g. "twice", "2 times")
freq_match = re.search(r"(\b\d+\s*times\b|\btwice\b|\bonce\b|\bmultiple times\b)", msg_lower)
frequency = freq_match.group(1) if freq_match else None
# Scan for symptoms
for kw, canonical in SYMPTOM_DICTIONARY:
pattern = rf"\b{re.escape(kw)}\b"
if re.search(pattern, msg_lower) or kw in message:
if canonical not in found_names:
found_names.add(canonical)
# Store canonical name as 'name' if kw is an English inflection like 'vomit', else keep kw if non-English
stored_name = canonical if kw in ["vomit", "vomited", "coughing", "feverish"] else kw
sym_obj: Dict[str, Any] = {"name": stored_name}
if duration:
sym_obj["duration"] = duration
if severity:
sym_obj["severity"] = severity
if location:
sym_obj["location"] = location
if frequency and ("vomit" in canonical or "வாந்தி" in kw or "உल्टी" in kw):
sym_obj["frequency"] = frequency
found_symptoms.append(sym_obj)
primary_rfv = list(found_names)[0] if found_names else None
# Check for medication or non-symptom statements to prevent fake symptom fallback
is_med_statement = any(k in msg_lower or k in message for k in ["metformin", "dolo", "paracetamol", "pantocid", "telmisartan", "amlodipine", "tablets", "medicine", "pill", "pills", "goli", "mathirai", "மாத்திரை", "மருந்து", "சாப்பிடுறேன்", "சாப்பிட்டேன்", "வாங்குறேன்", "दवाई", "दवा"])
# Fallback: single phrase short symptom message
if not found_symptoms and message and len(message.split()) < 10 and not is_med_statement:
if not (
is_duration_expression(message)
or is_severity_expression(message)
or is_location_expression(message)
or is_casual_filler(message)
or is_negative_expression(message)
):
clean_symptom = message.strip()
sym_obj = {"name": clean_symptom}
if duration:
sym_obj["duration"] = duration
if severity:
sym_obj["severity"] = severity
if location:
sym_obj["location"] = location
found_symptoms.append(sym_obj)
primary_rfv = clean_symptom
return found_symptoms, primary_rfv
@@ -0,0 +1,102 @@
import asyncio
import json
import base64
from app.config import GEMINI_API_KEY, GEMINI_MODEL
class GeminiClient:
def __init__(self):
self.api_key = GEMINI_API_KEY
self.model = GEMINI_MODEL or "gemini-1.5-flash"
async def extract_from_document(
self,
file_bytes: bytes,
mime_type: str,
patient_context: dict,
) -> dict:
if not self.api_key or self.api_key in ["your_gemini_key", "your_gemini_api_key", ""]:
return {
"document_type": "medical_report",
"extracted_text": "Sample HbA1c 7.2%, Blood Pressure 130/85 mmHg",
"findings": [
{"test": "HbA1c", "value": "7.2", "unit": "%", "interpretation": "Elevated"},
{"test": "Blood Pressure", "value": "130/85", "unit": "mmHg", "interpretation": "Stage 1"},
],
"medications_found": ["Metformin 500mg"],
"allergies_found": [],
"summary": "Report indicates elevated HbA1c and prehypertension.",
}
try:
from google import genai
from google.genai import types
client = genai.Client(api_key=self.api_key)
prompt = f"""
You are a medical document extraction AI.
Analyze this medical report / prescription / lab result for patient {patient_context.get('name', 'Patient')}.
Extract all lab tests, numeric values, units, diagnosed conditions, medications, and allergies into structured JSON.
Return ONLY JSON:
{{
"document_type": "lab_report | prescription | discharge_summary | other",
"extracted_text": "brief summary text",
"findings": [
{{
"test": "test name",
"value": "numeric or text value",
"unit": "unit or null",
"interpretation": "Normal | High | Low | etc."
}}
],
"medications_found": ["medication 1", "medication 2"],
"allergies_found": ["allergy 1"],
"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
)
cleaned_text = response.text.replace("```json", "").replace("```", "").strip()
return json.loads(cleaned_text)
except Exception as e:
print(f"[Gemini Extraction Warning]: {e}")
return {
"document_type": "unknown",
"findings": [],
"medications_found": [],
"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
@@ -0,0 +1,39 @@
from openai import AsyncOpenAI
from app.config import OPENAI_API_KEY, OPENAI_MODEL
class OpenAIClient:
_client = None
def __init__(self):
self.api_key = OPENAI_API_KEY
self.model = OPENAI_MODEL
if OpenAIClient._client is None and self.api_key:
OpenAIClient._client = AsyncOpenAI(
api_key=self.api_key,
timeout=12.0,
max_retries=1,
)
async def generate_response(
self,
system_prompt: str,
user_message: str,
) -> str:
client = OpenAIClient._client or AsyncOpenAI(
api_key=self.api_key or "sk-dummy-key-placeholder",
timeout=12.0,
)
response = await client.chat.completions.create(
model=self.model or "gpt-4o-mini",
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_message},
],
response_format={"type": "json_object"},
temperature=0.35,
max_completion_tokens=500,
)
return response.choices[0].message.content
@@ -0,0 +1,561 @@
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")
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
)
# -------------------------------------------------------------
# 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
)
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 [],
"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)
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}")
# -------------------------------------------------------------
# STEP 7: DETERMINISTIC FALLBACK (INSTANT)
# -------------------------------------------------------------
formatted_reply = format_conversational_response(next_q, lang, scenario="normal")
return {
"reply": formatted_reply,
"next_stage": next_stage,
"state_updates": extracted_updates,
"completed": False,
"suggested_specialty": spec_result
}
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] = {}
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}
@@ -0,0 +1 @@
# Prompt rules package
@@ -0,0 +1,40 @@
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."""
@@ -0,0 +1,135 @@
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."""
@@ -0,0 +1,52 @@
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."""
@@ -0,0 +1,74 @@
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."
),
}
@@ -0,0 +1,75 @@
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
"ನೋಯಿಸಿಕೊಳ್ಳಬೇಕು", "ಸಾಯಬೇಕು", "ಆತ್ಮಹತ್ಯೆ",
]
@@ -0,0 +1,70 @@
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.""",
}
+143
View File
@@ -0,0 +1,143 @@
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")
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"
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()
last_ai_message = ""
for turn in reversed(conversation_history):
if turn.get("role") in ["assistant", "ai"]:
last_ai_message = turn.get("message", "")
break
prompt = f"""You are Nila, an empathetic, highly skilled AI Clinical Intake Assistant at a hospital.
Your job is to collect preliminary clinical intake information for the doctor from patient: {patient_name}.
{lang_rules}
{clin_rules}
{safe_rules}
{conv_rules}
{spec_rules}
{extr_rules}
{priv_rules}
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:
{{
"reply": "Short natural spoken response (5-15 words, max 1 question) following Acknowledge + Ask pattern",
"next_stage": "reason_for_visit | symptom_collection | symptom_details | medical_history | medications | allergies | confirmation",
"state_updates": {{
"reason_for_visit": "primary reason or null",
"symptoms": [
{{
"name": "symptom name",
"duration": "duration or null",
"severity": "severity or null",
"location": "location or null"
}}
],
"medical_history": [],
"medications": [],
"allergies": [],
"surgical_history": [],
"previous_episodes": [],
"family_history": [],
"additional_information": []
}},
"completed": false
}}
"""
return prompt
@@ -0,0 +1,9 @@
"""
Providers package.
"""
from app.ai.providers.base import LLMProvider
from app.ai.providers.openai import OpenAIProvider
from app.ai.providers.gemini import GeminiProvider
__all__ = ["LLMProvider", "OpenAIProvider", "GeminiProvider"]
@@ -0,0 +1,30 @@
"""
Base LLM Provider interface with hard async timeout enforcement.
"""
import asyncio
from abc import ABC, abstractmethod
from typing import Optional
class LLMProvider(ABC):
"""
Abstract LLM Provider interface.
Enforces a strict hard timeout (default 4 seconds).
"""
def __init__(self, timeout_seconds: float = 4.0):
self.timeout_seconds = timeout_seconds
async def generate_response_with_timeout(self, system_prompt: str, user_message: str) -> Optional[str]:
try:
return await asyncio.wait_for(
self._generate_internal(system_prompt, user_message),
timeout=self.timeout_seconds
)
except (asyncio.TimeoutError, Exception) as e:
print(f"[{self.__class__.__name__} Failed / Timed out]: {e}")
return None
@abstractmethod
async def _generate_internal(self, system_prompt: str, user_message: str) -> Optional[str]:
pass
@@ -0,0 +1,19 @@
"""
Gemini LLM Provider.
"""
from typing import Optional
from app.ai.providers.base import LLMProvider
from app.ai.gemini_client import GeminiClient
class GeminiProvider(LLMProvider):
def __init__(self, timeout_seconds: float = 4.0):
super().__init__(timeout_seconds=timeout_seconds)
self.client = GeminiClient()
async def _generate_internal(self, system_prompt: str, user_message: str) -> Optional[str]:
api_key = getattr(self.client, "api_key", None)
if not api_key or api_key in ["your_gemini_key", "your_gemini_api_key", ""]:
return None
return await self.client.generate_response(system_prompt, user_message)
@@ -0,0 +1,19 @@
"""
OpenAI LLM Provider.
"""
from typing import Optional
from app.ai.providers.base import LLMProvider
from app.ai.openai_client import OpenAIClient
class OpenAIProvider(LLMProvider):
def __init__(self, timeout_seconds: float = 4.0):
super().__init__(timeout_seconds=timeout_seconds)
self.client = OpenAIClient()
async def _generate_internal(self, system_prompt: str, user_message: str) -> Optional[str]:
api_key = getattr(self.client, "api_key", None)
if not api_key or api_key in ["your_openai_key", "your_openai_api_key", ""]:
return None
return await self.client.generate_response(system_prompt, user_message)
@@ -0,0 +1,8 @@
"""
Safety package.
"""
from app.ai.safety.emergency import check_emergency
from app.ai.safety.crisis import check_crisis
__all__ = ["check_emergency", "check_crisis"]
@@ -0,0 +1,69 @@
"""
Mental health crisis and self-harm detection engine.
"""
import re
from typing import Optional, Tuple
from app.ai.prompt_rules.safety_rules import CRISIS_KEYWORDS_EN, CRISIS_KEYWORDS_INDIC
TRANSLITERATED_CRISIS_PHRASES = [
"saaganam pola irukku", "uyiroda irukka virumbala", "saaga poraen", "valave pidikala",
"mar jane ka man", "apne aap ko nuksan", "khudkushee",
"chani povali", "sathokobeku",
]
def check_crisis(user_message: str, lang: str = "en") -> Tuple[bool, Optional[str], Optional[str]]:
"""
Evaluates patient message for mental health crisis or self-harm intent.
Returns: (is_crisis, reason, crisis_reply)
"""
if not user_message or not user_message.strip():
return False, None, None
# Check Indic script crisis keywords FIRST against raw message
for kw in CRISIS_KEYWORDS_INDIC:
if kw in user_message:
reply = _build_crisis_reply(lang)
return True, f"Matched Indic crisis phrase: '{kw}'", reply
indic_extra = ["காயப்படுத்திக்கணும்", "கொல்லணும்", "வாழவே பிடிக்கல", "உயிரோட இருக்க", "தற்கொலை", "உயிரை"]
for kw in indic_extra:
if kw in user_message:
reply = _build_crisis_reply(lang)
return True, f"Matched Indic crisis phrase: '{kw}'", reply
msg = user_message.strip().lower()
msg_clean = re.sub(r"[^\w\s]", " ", msg)
msg_clean = re.sub(r"\s+", " ", msg_clean)
# Check English crisis keywords
for kw in CRISIS_KEYWORDS_EN:
kw_clean = re.sub(r"[^\w\s]", " ", kw.lower())
if re.search(rf"\b{re.escape(kw_clean)}\b", msg_clean) or kw.lower() in msg:
reply = _build_crisis_reply(lang)
return True, f"Matched crisis phrase: '{kw}'", reply
# Check Transliterated crisis phrases
for kw in TRANSLITERATED_CRISIS_PHRASES:
if kw in msg_clean or kw in msg:
reply = _build_crisis_reply(lang)
return True, f"Matched transliterated crisis phrase: '{kw}'", reply
return False, None, None
def _build_crisis_reply(lang: str) -> str:
lang_clean = lang.lower() if lang else "en"
if lang_clean.startswith("ta"):
return "உங்களுக்கு மன உளைச்சல் இருந்தால், தயவுசெய்து டெலி-மானாஸ் (Tele-MANAS) உதவி எண் 14416-ஐ தொடர்பு கொள்ளவும் அல்லது மருத்துவமனை ஊழியர்களிடம் தெரிவிக்கவும்."
elif lang_clean.startswith("hi"):
return "यदि आप परेशानी में हैं, तो कृपया तुरंत टेली-मानस (Tele-MANAS) 14416 पर संपर्क करें या अस्पताल के कर्मचारियों को सूचित करें।"
elif lang_clean.startswith("te"):
return "మీరు తీవ్రమైన ఒత్తిడిలో ఉంటే, దయచేసి టెలి-మానస్ (Tele-MANAS) 14416 కు ఫోన్ చేయండి లేదా ఆసుపత్రి సిబ్బందికి చెప్పండి."
elif lang_clean.startswith("kn"):
return "ನೀವು ಮನೋಒತ್ತಡದಲ್ಲಿದ್ದರೆ, ದಯವಿಟ್ಟು ಟೆಲಿ-ಮಾನಸ್ (Tele-MANAS) 14416 ಗೆ ಕರೆ ಮಾಡಿ ಅಥವಾ ಆಸ್ಪತ್ರೆಯ ಸಿಬ್ಬಂದಿಗೆ ತಿಳಿಸಿ."
else:
return "If you're in distress, please reach out to Tele-MANAS at 14416 or alert the hospital staff right now."
@@ -0,0 +1,91 @@
"""
Emergency detection engine with strict safety precedence and false-positive protection.
"""
import re
from typing import Optional, Tuple
from app.ai.prompt_rules.safety_rules import EMERGENCY_KEYWORDS_EN, EMERGENCY_KEYWORDS_INDIC
TRANSLITERATED_EMERGENCY_PHRASES = [
"nenju romba valikuthu", "nenju valla valikuthu", "nenju kadumaya valikuthu",
"moochu vida mudiyala", "moochu adaikuthu", "moochu thinaral",
"mayangi vilunthuten", "mayakkam varuthu", "paralysis vanthuruchu",
"cheest mein bahut dard", "seene mein bahut tej dard", "saans lene mein bahut dikkat",
"saans nahi aa rahi", "behosh ho gaya", "behosh ho gayi",
"chathi lo thivramaina noppi", "oophiri aadam ledhu", "spruha thappi",
"edeyalli theevra novu", "usiraadalu thumba kashta", "pragne thappi",
]
def check_emergency(user_message: str, lang: str = "en") -> Tuple[bool, Optional[str], Optional[str]]:
"""
Evaluates patient message for medical emergency indicators.
Returns: (is_emergency, reason, emergency_reply)
"""
if not user_message or not user_message.strip():
return False, None, None
# Check Indic script emergency keywords against raw user_message FIRST
for kw in EMERGENCY_KEYWORDS_INDIC:
if kw in user_message:
reply = _build_emergency_reply(lang)
return True, f"Matched Indic emergency phrase: '{kw}'", reply
# Additional Tamil/Hindi spoken emergency phrases
indic_extra = ["மூச்சு விட முடியல", "மூச்சு திணறல்", "நெஞ்சு வலி", "நெஞ்சு பாரம்", "கடும் நெஞ்சு வலி", "நெஞ்சு அதிகமா வலி"]
for kw in indic_extra:
if kw in user_message and any(x in user_message for x in ["மூச்சு", "கடும்", "வலி", "அதிகமா"]):
reply = _build_emergency_reply(lang)
return True, f"Matched Indic emergency phrase: '{kw}'", reply
msg = user_message.strip().lower()
msg_clean = re.sub(r"[^\w\s]", " ", msg)
msg_clean = re.sub(r"\s+", " ", msg_clean)
# False Positive Protection: Negated emergency statements ("no chest pain")
negation_patterns = [
r"\bno\s+(?:chest\s+pain|breathing\s+difficulty|heart\s+attack|paralysis|bleeding)\b",
r"\bdon't\s+have\s+(?:chest\s+pain|breathing\s+difficulty|heart\s+attack)\b",
r"\bnot\s+having\s+(?:chest\s+pain|breathing\s+difficulty)\b",
r"\bnever\s+had\s+(?:chest\s+pain|breathing\s+difficulty)\b",
r"\billness\s+none\b",
]
for neg_pat in negation_patterns:
if re.search(neg_pat, msg_clean):
if not any(pos in msg_clean for pos in ["severe chest pain", "cannot breathe", "collapsed"]):
return False, None, None
# Check English emergency keywords
for kw in EMERGENCY_KEYWORDS_EN:
if re.search(rf"\b{re.escape(kw)}\b", msg_clean):
reply = _build_emergency_reply(lang)
return True, f"Matched emergency phrase: '{kw}'", reply
# Check combinations (e.g. "chest pain" AND "difficulty breathing")
if "chest pain" in msg_clean and ("breath" in msg_clean or "shortness" in msg_clean or "difficulty" in msg_clean):
reply = _build_emergency_reply(lang)
return True, "Matched combined chest pain and breathing difficulty", reply
# Check Transliterated emergency phrases
for kw in TRANSLITERATED_EMERGENCY_PHRASES:
if kw in msg_clean:
reply = _build_emergency_reply(lang)
return True, f"Matched transliterated emergency phrase: '{kw}'", reply
return False, None, None
def _build_emergency_reply(lang: str) -> str:
lang_clean = lang.lower() if lang else "en"
if lang_clean.startswith("ta"):
return "இது அவசர மருத்துவ உதவி தேவைப்படும் நிலை. தயவுசெய்து உடனடியாக அருகில் உள்ள மருத்துவமனை அவசர சிகிச்சைப் பிரிவுக்குச் செல்லவும்."
elif lang_clean.startswith("hi"):
return "यह एक आपातकालीन स्थिति हो सकती है। कृपया तुरंत निकटतम अस्पताल के आपातकालीन कक्ष (ER) में जाएँ।"
elif lang_clean.startswith("te"):
return "ఇది అత్యవసర వైద్య పరిస్థితి కావచ్చు. దయచేసి వెంటనే దగ్గరలోని ఆసుపత్రి ఎమర్జెన్సీ విభాగానికి వెళ్ళండి."
elif lang_clean.startswith("kn"):
return "ಇದು ತುರ್ತು ವೈದ್ಯಕೀಯ ಪರಿಸ್ಥಿತಿಯಾಗಿರಬಹುದು. ದಯವಿಟ್ಟು ತಕ್ಷಣ ಹತ್ತಿರದ ಆಸ್ಪತ್ರೆಯ ತುರ್ತು ವಿಭಾಗಕ್ಕೆ ಹೋಗಿ."
else:
return "This requires urgent medical attention. Please inform the nearest hospital staff or proceed directly to the Emergency Room right away."
@@ -0,0 +1,205 @@
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:
_client = None
def __init__(self):
self.api_key = SARVAM_API_KEY
self.base_url = "https://api.sarvam.ai"
def _get_client(self) -> httpx2.AsyncClient:
if SarvamClient._client is None:
SarvamClient._client = httpx2.AsyncClient(
timeout=8.0,
limits=httpx2.Limits(max_keepalive_connections=5, max_connections=10),
)
return SarvamClient._client
async def speech_to_text_with_status(
self,
audio_bytes: bytes,
filename: str = "audio.webm",
language_code: str = "ta-IN",
session_id: Optional[str] = None,
max_retries: int = 2,
) -> Tuple[str, str]:
"""
Transcribes audio to text returning (transcript, status_code).
Uses bounded retries for transient errors.
"""
if not audio_bytes:
return "", STT_EMPTY_AUDIO
if not self.api_key or self.api_key in ["your_sarvam_key", ""]:
return "", STT_PROVIDER_ERROR
client = self._get_client()
files = {"file": (filename, audio_bytes, "audio/webm")}
data = {"model": "saaras:v2", "language_code": language_code}
headers = {"api-subscription-key": self.api_key}
attempt = 0
last_status = STT_PROVIDER_ERROR
while attempt <= max_retries:
start_time = time.time()
try:
res = await client.post(
f"{self.base_url}/speech-to-text",
files=files,
data=data,
headers=headers,
)
duration = round(time.time() - start_time, 3)
if res.status_code == 200:
transcript = res.json().get("transcript", "").strip()
logger.info(
"sarvam_stt_success",
extra={
"provider": "SarvamAI",
"operation": "STT",
"session_id": session_id,
"duration_sec": duration,
"attempt": attempt + 1,
},
)
return transcript, STT_SUCCESS
elif 400 <= res.status_code < 500:
# Non-retryable 4xx client errors
logger.warning(
"sarvam_stt_client_error",
extra={
"provider": "SarvamAI",
"operation": "STT",
"session_id": session_id,
"http_status": res.status_code,
},
)
return "", STT_PROVIDER_ERROR
else:
last_status = STT_PROVIDER_ERROR
except (httpx2.TimeoutException, asyncio.TimeoutError):
last_status = STT_TIMEOUT
logger.warning("sarvam_stt_timeout", extra={"session_id": session_id, "attempt": attempt + 1})
except (httpx2.NetworkError, Exception) as e:
last_status = STT_NETWORK_ERROR
logger.warning("sarvam_stt_network_error", extra={"session_id": session_id, "attempt": attempt + 1})
attempt += 1
if attempt <= max_retries:
await asyncio.sleep(0.2 * attempt)
return "", last_status
async def speech_to_text(
self,
audio_bytes: bytes,
filename: str = "audio.webm",
language_code: str = "ta-IN",
) -> str:
transcript, _ = await self.speech_to_text_with_status(audio_bytes, filename, language_code)
return transcript
async def text_to_speech_with_status(
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
if not self.api_key or self.api_key in ["your_sarvam_key", ""]:
return None, TTS_PROVIDER_ERROR
client = self._get_client()
headers = {
"api-subscription-key": self.api_key,
"Content-Type": "application/json",
}
payload = {
"inputs": [text],
"target_language_code": language_code,
"speaker": "kavya",
"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
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
+12
View File
@@ -0,0 +1,12 @@
from app.schemas.onboarding import (
AIOnboardingResponse,
)
def validate_ai_response(
data: dict,
) -> AIOnboardingResponse:
return AIOnboardingResponse.model_validate(
data
)
@@ -0,0 +1 @@
"""API package initialization."""
+108
View File
@@ -0,0 +1,108 @@
import json
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from sqlalchemy.orm import Session
from app.ai.gemini_client import GeminiClient
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"],
)
@router.post(
"/{session_id}/upload",
response_model=DocumentExtractionResponse,
)
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()
session = onboarding_service.get_session(db, session_id)
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.",
)
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.",
)
patient_context = {
"id": session.patient_id,
"name": session.patient_name,
"age": session.age,
"gender": session.gender,
"category": session.category_name,
}
# Extract clinical data with Gemini
extracted = await gemini_client.extract_from_document(
file_bytes=file_bytes,
mime_type=mime_type,
patient_context=patient_context,
)
# 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
meds_found = extracted.get("medications_found", [])
if meds_found:
existing_meds = current_state.get("medications", {})
items = existing_meds.get("items", []) if isinstance(existing_meds, dict) else (existing_meds or [])
items = list(set(items + meds_found))
current_state["medications"] = {"status": "reported", "items": items}
# Append allergies if found
allergies_found = extracted.get("allergies_found", [])
if allergies_found:
existing_allergies = current_state.get("allergies", {})
items = existing_allergies.get("items", []) if isinstance(existing_allergies, dict) else (existing_allergies or [])
items = list(set(items + allergies_found))
current_state["allergies"] = {"status": "reported", "items": items}
# Add document findings to state
current_state["document_findings"] = extracted.get("findings", [])
onboarding_service.update_state(db=db, state=state, data=current_state)
return DocumentExtractionResponse(
success=True,
session_id=session_id,
document_type=extracted.get("document_type", "medical_report"),
summary=extracted.get("summary", "Document analyzed successfully"),
findings=extracted.get("findings", []),
medications_found=meds_found,
allergies_found=allergies_found,
message="Medical document analyzed and integrated into onboarding session.",
)
+387
View File
@@ -0,0 +1,387 @@
import json
import time
from uuid import uuid4
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.database.database import get_db
from app.database.models import (
OnboardingMessage,
OnboardingSession,
OnboardingState,
)
from app.schemas.onboarding import (
CompleteOnboardingRequest,
CompleteOnboardingResponse,
SendMessageRequest,
SendMessageResponse,
StartOnboardingRequest,
StartOnboardingResponse,
)
from app.services.ai_onboarding_service import (
AIOnboardingService,
)
from app.services.completion_service import (
CompletionService,
)
from app.services.onboarding_service import (
OnboardingService,
)
from app.services.patient_submission_service import (
PatientSubmissionService,
)
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"],
)
@router.post(
"/start",
response_model=StartOnboardingResponse,
)
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_name=request.patient.name,
age=request.patient.age,
gender=request.patient.gender,
category_type=request.category.type,
category_code=request.category.code,
category_name=request.category.name,
language=request.language,
status="in_progress",
stage="greeting",
)
db.add(session)
db.commit()
db.refresh(session)
# 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,
)
# Save initial assistant greeting
onboarding_service.save_message(
db=db,
session_id=session_id,
role="assistant",
message=greeting_msg,
stage="greeting",
)
return StartOnboardingResponse(
success=True,
session_id=session.session_id,
patient_id=session.patient_id,
category=request.category,
language=session.language,
status=session.status,
stage=session.stage,
message=greeting_msg,
)
@router.post(
"/{session_id}/message",
response_model=SendMessageResponse,
)
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(
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 access or modify this patient onboarding session.")
# 2. Check session
if session.status != "in_progress":
raise HTTPException(
status_code=400,
detail="Onboarding session is not active",
)
# 3. Save patient message
onboarding_service.save_message(
db=db,
session_id=session_id,
role="patient",
message=request.message,
stage=session.stage,
)
# 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(
OnboardingMessage.session_id == session_id
)
.order_by(
OnboardingMessage.created_at.asc()
)
.all()
)
conversation_history = [
{
"role": message.role,
"message": message.message,
"stage": message.stage,
}
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
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)
onboarding_service.update_state(
db=db,
state=state,
data=merged_state,
)
# 8. Update stage
session.stage = ai_response.get("next_stage", session.stage)
# 9. Check completion
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(
db=db,
session_id=session_id,
role="assistant",
message=ai_response["reply"],
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,
)
@router.post(
"/{session_id}/complete",
response_model=CompleteOnboardingResponse,
)
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()
submission_service = PatientSubmissionService()
# 1. Find session
session = onboarding_service.get_session(
db,
session_id,
)
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 complete this onboarding session.")
# 2. Load state
state = onboarding_service.get_state(
db,
session_id,
)
# Check if complete
has_details = completion_service.is_complete(session, state)
# 4. Get conversation history
messages = (
db.query(OnboardingMessage)
.filter(OnboardingMessage.session_id == session_id)
.order_by(OnboardingMessage.created_at.asc())
.all()
)
conversation_history = [
{
"role": message.role,
"message": message.message,
"stage": message.stage,
}
for message in messages
]
# 5. Generate final structured result
result = await result_service.extract_result(
session=session,
state=state,
conversation_history=conversation_history,
)
# 6. If patient has not confirmed yet, return summary for review
if not request.confirmed:
return CompleteOnboardingResponse(
success=True,
session_id=session_id,
status="awaiting_confirmation",
result=result,
message="Please review the information and confirm.",
)
# 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"
db.commit()
elapsed = time.time() - start_time
return CompleteOnboardingResponse(
success=True,
session_id=session_id,
status="completed",
result=result,
message="Onboarding successfully completed and saved to patient record.",
response_time_ms=round(elapsed * 1000, 2),
response_time_sec=round(elapsed, 3),
)
# If submission failed, preserve SQLite session for retry
session.status = "submission_pending_retry"
db.commit()
return CompleteOnboardingResponse(
success=False,
session_id=session_id,
status="submission_pending_retry",
result=result,
message="Patient API submission failed. Session preserved for retry.",
)
+4
View File
@@ -0,0 +1,4 @@
from app.api.voice_onboarding import router
# Export the router
__all__ = ["router"]
@@ -0,0 +1,478 @@
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.responses import FileResponse
from sqlalchemy.orm import Session
from app.database.database import get_db
from app.database.models import (
OnboardingMessage,
OnboardingSession,
OnboardingState,
)
from app.schemas.onboarding import (
CategoryContext,
CompleteOnboardingRequest,
CompleteOnboardingResponse,
)
from app.schemas.voice import (
StartVoiceOnboardingRequest,
StartVoiceOnboardingResponse,
VoiceTurnResponse,
)
from app.services.ai_onboarding_service import AIOnboardingService
from app.services.completion_service import CompletionService
from app.services.onboarding_service import OnboardingService
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"],
)
@router.post(
"/start",
response_model=StartVoiceOnboardingResponse,
)
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_name=request.patient.name,
age=request.patient.age,
gender=request.patient.gender,
category_type=category.type,
category_code=category.code,
category_name=category.name,
language=request.language,
status="in_progress",
stage="greeting",
)
db.add(session)
db.commit()
db.refresh(session)
# Initialize empty state
onboarding_service.get_state(db, session_id)
# Generate localized voice greeting
greeting_text = voice_service.get_initial_greeting(
request.patient.name,
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)
)
# Save initial assistant message
onboarding_service.save_message(
db=db,
session_id=session_id,
role="assistant",
message=greeting_text,
stage="greeting",
)
elapsed = time.time() - start_time
return StartVoiceOnboardingResponse(
success=True,
session_id=session.session_id,
patient_id=session.patient_id,
category=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),
)
except Exception as err:
import traceback
print(f"[StartVoiceOnboarding Error]: {err}")
traceback.print_exc()
raise HTTPException(status_code=500, detail=str(err))
@router.post(
"/{session_id}/turn",
response_model=VoiceTurnResponse,
)
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()
# 1. Fetch active session
session = onboarding_service.get_session(db, session_id)
if not session:
raise HTTPException(
status_code=404,
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"]:
raise HTTPException(
status_code=400,
detail="Voice onboarding session is closed or submitted.",
)
# 2. Fast-path transcript: use real-time Web Speech text_fallback first for zero-latency processing
patient_transcript = ""
if text_fallback and text_fallback.strip():
from app.services.medical_speech_normalizer import normalize_medical_speech
patient_transcript = normalize_medical_speech(text_fallback.strip(), language_code=session.language)
# If text_fallback was not available, run server-side multi-model STT
if not patient_transcript and audio:
audio_bytes = await audio.read()
if len(audio_bytes) > 0:
patient_transcript = await voice_service.speech_to_text(
audio_bytes=audio_bytes,
filename=audio.filename or "recording.webm",
language_code=session.language,
specialty_code=session.category_code or "general",
patient_context={
"name": session.patient_name,
"age": session.age,
"gender": session.gender,
},
)
# If STILL empty, user spoke nothing or silence was recorded.
if not patient_transcript or not patient_transcript.strip():
lang = session.language.lower()
if lang.startswith("ta"):
no_speech_msg = "உங்க குரல் கேட்கலைங்க, தயவுசெய்து மைக்கில பேசுங்க. உங்க உடம்பு பிரச்சனையை சொல்லுங்க."
elif lang.startswith("hi"):
no_speech_msg = "आपकी आवाज़ सुनाई नहीं दी। कृपया अपने माइक में बोलें और अपनी समस्या बताएं।"
elif lang.startswith("te"):
no_speech_msg = "మీ స్వరం వినబడలేదు. దయచేసి మైక్‌లో మాట్లాడి మీ ఆరోగ్య సమస్యను చెప్పండి."
elif lang.startswith("kn"):
no_speech_msg = "ನಿಮ್ಮ ಧ್ವನಿ ಕೇಳಿಸಲಿಲ್ಲ. ದಯವಿಟ್ಟು ಮೈಕ್‌ನಲ್ಲಿ ಮಾತನಾಡಿ ನಿಮ್ಮ ಆರೋಗ್ಯ ಸಮಸ್ಯೆಯನ್ನು ತಿಳಿಸಿ."
else:
no_speech_msg = "I didn't hear anything. Please speak into your microphone and describe your health symptoms."
audio_id, _ = await voice_service.text_to_speech(
text=no_speech_msg,
language_code=session.language,
)
elapsed = time.time() - start_time
return VoiceTurnResponse(
success=True,
session_id=session_id,
patient_transcript="",
ai_text=no_speech_msg,
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,
session_id=session_id,
role="patient",
message=patient_transcript,
stage=session.stage,
)
# 4. Fetch state & history
state = 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
]
try:
# 5. AI Reasoning & Extraction
ai_response = await ai_service.process_message(
session=session,
state=state,
conversation_history=conversation_history,
user_message=patient_transcript,
)
# 6. Update Structured Clinical State using shared intelligent merge
from app.ai.orchestrator import merge_state_updates
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)
onboarding_service.update_state(
db=db,
state=state,
data=merged_state,
)
# 7. Update session stage, status & auto-assigned specialty
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"
db.commit()
# 8. Save Assistant Message
onboarding_service.save_message(
db=db,
session_id=session_id,
role="assistant",
message=ai_reply_text,
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)
)
elapsed = time.time() - start_time
return VoiceTurnResponse(
success=True,
session_id=session_id,
patient_transcript=patient_transcript,
ai_text=ai_reply_text,
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),
)
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}")
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
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": "*",
},
)
@router.post(
"/{session_id}/complete",
response_model=CompleteOnboardingResponse,
)
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()
submission_service = PatientSubmissionService()
session = onboarding_service.get_session(db, session_id)
if not session:
raise HTTPException(
status_code=404,
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)
.order_by(OnboardingMessage.created_at.asc())
.all()
)
conversation_history = [
{"role": m.role, "message": m.message, "stage": m.stage}
for m in messages
]
result = await result_service.extract_result(
session=session,
state=state,
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,
session_id=session_id,
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.commit()
return CompleteOnboardingResponse(
success=True,
session_id=session_id,
status="completed",
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"
db.commit()
return CompleteOnboardingResponse(
success=False,
session_id=session_id,
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))
@@ -0,0 +1,3 @@
"""
WebSocket API Package for Real-Time Patient Onboarding.
"""
@@ -0,0 +1,73 @@
"""
WebSocket Connection Manager (Section 18, 19, 28).
Manages active WebSocket instances and per-session processing locks.
Ensures session-level concurrency control (messages for same session processed sequentially,
different sessions process concurrently without global blocking).
"""
import asyncio
import logging
from typing import Dict, Set, Optional
from fastapi import WebSocket, status
logger = logging.getLogger(__name__)
class ConnectionManager:
def __init__(self):
# Maps session_id to set of active WebSocket instances
self.active_connections: Dict[str, Set[WebSocket]] = {}
# Per-session processing lock to enforce sequential message processing per session (Section 18 & 19)
self.session_locks: Dict[str, asyncio.Lock] = {}
# Stores processed message_ids and assistant responses for deduplication per session (Section 17)
self.dedup_store: Dict[str, Dict[str, dict]] = {}
def get_session_lock(self, session_id: str) -> asyncio.Lock:
"""Returns or creates a per-session lock."""
if session_id not in self.session_locks:
self.session_locks[session_id] = asyncio.Lock()
return self.session_locks[session_id]
async def connect(self, session_id: str, websocket: WebSocket):
"""Accept and register connection."""
if session_id not in self.active_connections:
self.active_connections[session_id] = set()
self.active_connections[session_id].add(websocket)
logger.info("websocket_connected", extra={"session_id": session_id})
def disconnect(self, session_id: str, websocket: WebSocket):
"""Clean up disconnected socket reference."""
if session_id in self.active_connections:
self.active_connections[session_id].discard(websocket)
if not self.active_connections[session_id]:
del self.active_connections[session_id]
# Clean up session lock if no connections remain
if session_id in self.session_locks:
del self.session_locks[session_id]
logger.info("websocket_disconnected", extra={"session_id": session_id})
async def send_json(self, websocket: WebSocket, data: dict):
"""Send JSON payload to specific socket."""
try:
await websocket.send_json(data)
except Exception as e:
logger.warning("websocket_send_failed", extra={"error": str(e)})
async def send_to_session(self, session_id: str, data: dict):
"""Broadcast payload to all sockets connected to a session."""
if session_id in self.active_connections:
for ws in list(self.active_connections[session_id]):
await self.send_json(ws, data)
def cache_processed_message(self, session_id: str, message_id: str, response_data: dict):
"""Cache response data for duplicate message_id protection (Section 17)."""
if session_id not in self.dedup_store:
self.dedup_store[session_id] = {}
self.dedup_store[session_id][message_id] = response_data
def get_cached_message_response(self, session_id: str, message_id: str) -> Optional[dict]:
"""Return cached response for message_id if previously processed."""
return self.dedup_store.get(session_id, {}).get(message_id)
manager = ConnectionManager()
@@ -0,0 +1,225 @@
"""
WebSocket Route Handler (Section 2, 3, 4, 15, 16, 22, 23, 24, 25).
Endpoint: WS /api/v1/ws/onboarding/{session_id}
Handles connection lifecycle, authentication, session validation, input sanitization, rate limiting, and message dispatch.
"""
import json
import logging
from typing import Optional
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Depends, Query, status
from sqlalchemy.orm import Session
from app.database.database import get_db
from app.core.security import get_auth_context, verify_session_ownership, AuthContext
from app.services.onboarding_service import OnboardingService
from app.services.websocket_service import WebSocketService
from app.api.websocket.manager import manager
from app.api.websocket.schemas import (
WSEventType,
WSErrorCode,
WSConnectionAck,
WSPatientMessage,
WSErrorEvent,
)
from app.database.database import SessionLocal
logger = logging.getLogger(__name__)
router = APIRouter(
prefix="/api/v1/ws/onboarding",
tags=["WebSocket Onboarding"],
)
# In-memory per-connection rate limiter (Section 26)
# Maps session_id -> list of message timestamps
WS_RATE_LIMIT_STORE = {}
def check_ws_rate_limit(session_id: str, limit: int = 30, window_sec: int = 60) -> bool:
import time
now = time.time()
timestamps = WS_RATE_LIMIT_STORE.get(session_id, [])
timestamps = [t for t in timestamps if now - t < window_sec]
if len(timestamps) >= limit:
return False
timestamps.append(now)
WS_RATE_LIMIT_STORE[session_id] = timestamps
return True
@router.websocket("/{session_id}")
async def websocket_onboarding_endpoint(
websocket: WebSocket,
session_id: str,
token: Optional[str] = Query(None),
x_api_key: Optional[str] = Query(None, alias="api_key"),
x_patient_id: Optional[str] = Query(None, alias="patient_id"),
):
"""
Real-Time Patient Onboarding WebSocket Endpoint.
WS /api/v1/ws/onboarding/{session_id}
"""
db = SessionLocal()
try:
await _handle_websocket_lifecycle(
websocket=websocket,
session_id=session_id,
db=db,
)
finally:
db.close()
async def _handle_websocket_lifecycle(
websocket: WebSocket,
session_id: str,
db: Session,
):
onboarding_service = OnboardingService()
ws_service = WebSocketService()
await websocket.accept()
# Extract query params directly from WebSocket request URL (Section 25)
query_token = websocket.query_params.get("token")
query_api_key = websocket.query_params.get("api_key")
query_patient_id = websocket.query_params.get("patient_id")
auth_token = query_token or query_api_key or websocket.headers.get("x-api-key")
if not auth_token:
# Allow test connection token if in header or subprotocol
sec_protocol = websocket.headers.get("sec-websocket-protocol")
if sec_protocol:
auth_token = sec_protocol
try:
auth_context = get_auth_context(
api_key=auth_token,
credentials=None,
x_patient_id=query_patient_id or websocket.headers.get("x-patient-id")
)
except Exception as e:
logger.warning("websocket_auth_failed", extra={"session_id": session_id, "error": str(e)})
await websocket.send_json(
WSErrorEvent(code=WSErrorCode.UNAUTHORIZED.value, message="Authentication failed.").model_dump()
)
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
return
# 2. Validate session exists & active (Section 24)
session = onboarding_service.get_session(db, session_id)
if not session:
logger.warning("websocket_session_not_found", extra={"session_id": session_id})
await websocket.send_json(
WSErrorEvent(code=WSErrorCode.SESSION_NOT_FOUND.value, message="Session not found.").model_dump()
)
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
return
# 3. Verify session ownership (Section 25)
try:
verify_session_ownership(session.patient_id, auth_context)
except Exception as e:
logger.warning("websocket_unauthorized_session_access", extra={"session_id": session_id, "error": str(e)})
await websocket.send_json(
WSErrorEvent(code=WSErrorCode.UNAUTHORIZED.value, message="Unauthorized session access.").model_dump()
)
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
return
if session.status != "in_progress":
logger.warning("websocket_session_expired", extra={"session_id": session_id})
await websocket.send_json(
WSErrorEvent(code=WSErrorCode.SESSION_EXPIRED.value, message="Session expired or completed.").model_dump()
)
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
return
# 4. Register connection & send ACK (Section 3 & 4)
await manager.connect(session_id, websocket)
ack = WSConnectionAck(session_id=session_id, status="connected").model_dump()
await manager.send_json(websocket, ack)
try:
while True:
# 5. Receive text message
data_text = await websocket.receive_text()
# 6. Check Rate Limiting (Section 26)
if not check_ws_rate_limit(session_id, limit=30, window_sec=60):
await manager.send_json(
websocket,
WSErrorEvent(
code=WSErrorCode.RATE_LIMITED.value,
message="Please wait a moment before sending another message."
).model_dump()
)
continue
# 7. Validate JSON structure & size (Section 15 & 16)
if len(data_text) > 5000:
await manager.send_json(
websocket,
WSErrorEvent(
code=WSErrorCode.MESSAGE_TOO_LARGE.value,
message="Message exceeds maximum allowed length of 5000 characters."
).model_dump()
)
continue
try:
payload = json.loads(data_text)
except Exception:
await manager.send_json(
websocket,
WSErrorEvent(
code=WSErrorCode.INVALID_MESSAGE.value,
message="Please send a valid JSON message."
).model_dump()
)
continue
if not isinstance(payload, dict) or payload.get("type") != WSEventType.PATIENT_MESSAGE.value:
await manager.send_json(
websocket,
WSErrorEvent(
code=WSErrorCode.INVALID_MESSAGE.value,
message="Unsupported message type. Expected 'patient.message'."
).model_dump()
)
continue
message_id = str(payload.get("message_id") or "").strip()
text = str(payload.get("text") or "").strip()
if not message_id or not text:
await manager.send_json(
websocket,
WSErrorEvent(
code=WSErrorCode.INVALID_MESSAGE.value,
message="Message must include non-empty 'message_id' and 'text'."
).model_dump()
)
continue
# 8. Session-level lock for sequential processing per session (Section 18 & 19)
async with manager.get_session_lock(session_id):
await ws_service.handle_patient_message(
websocket=websocket,
db=db,
session_id=session_id,
message_id=message_id,
text=text,
)
except WebSocketDisconnect:
manager.disconnect(session_id, websocket)
logger.info("websocket_client_disconnected", extra={"session_id": session_id})
except Exception as e:
manager.disconnect(session_id, websocket)
logger.error("websocket_unexpected_error", extra={"session_id": session_id, "error": str(e)})
@@ -0,0 +1,91 @@
"""
WebSocket Protocol Schemas and Event Constants (Section 5, 6, 15).
Centralized definitions for event types, error codes, and message models.
"""
from enum import Enum
from typing import Optional, Any, Dict
from pydantic import BaseModel, Field
class WSEventType(str, Enum):
CONNECTION_ACK = "connection.ack"
PATIENT_MESSAGE = "patient.message"
ASSISTANT_MESSAGE = "assistant.message"
PROCESSING_STARTED = "processing.started"
PROCESSING_COMPLETED = "processing.completed"
TYPING_STARTED = "typing.started"
TYPING_STOPPED = "typing.stopped"
STATE_UPDATED = "state.updated"
SAFETY_ALERT = "safety.alert"
ONBOARDING_COMPLETED = "onboarding.completed"
ERROR = "error"
SESSION_ENDED = "session.ended"
class WSErrorCode(str, Enum):
INVALID_MESSAGE = "INVALID_MESSAGE"
MESSAGE_TOO_LARGE = "MESSAGE_TOO_LARGE"
UNAUTHORIZED = "UNAUTHORIZED"
SESSION_NOT_FOUND = "SESSION_NOT_FOUND"
SESSION_EXPIRED = "SESSION_EXPIRED"
PROCESSING_ERROR = "PROCESSING_ERROR"
AI_TIMEOUT = "AI_TIMEOUT"
AI_UNAVAILABLE = "AI_UNAVAILABLE"
RATE_LIMITED = "RATE_LIMITED"
INVALID_STATE = "INVALID_STATE"
INTERNAL_ERROR = "INTERNAL_ERROR"
class WSConnectionAck(BaseModel):
type: str = WSEventType.CONNECTION_ACK.value
session_id: str
status: str = "connected"
class WSPatientMessage(BaseModel):
type: str = WSEventType.PATIENT_MESSAGE.value
message_id: str = Field(..., description="Unique client message ID")
text: str = Field(..., max_length=5000, description="Patient text message")
class WSAssistantMessage(BaseModel):
type: str = WSEventType.ASSISTANT_MESSAGE.value
message_id: str
reply: str
language: str = "en-IN"
class WSTypingEvent(BaseModel):
type: str # typing.started | typing.stopped
class WSProcessingStartedEvent(BaseModel):
type: str = WSEventType.PROCESSING_STARTED.value
class WSProcessingCompletedEvent(BaseModel):
type: str = WSEventType.PROCESSING_COMPLETED.value
processing_time_ms: int
class WSSafetyAlertEvent(BaseModel):
type: str = WSEventType.SAFETY_ALERT.value
level: str = "emergency" # emergency | crisis
message: Optional[str] = None
class WSStateUpdatedEvent(BaseModel):
type: str = WSEventType.STATE_UPDATED.value
session_id: str
class WSOnboardingCompletedEvent(BaseModel):
type: str = WSEventType.ONBOARDING_COMPLETED.value
session_id: str
class WSErrorEvent(BaseModel):
type: str = WSEventType.ERROR.value
code: str
message: str
+68
View File
@@ -0,0 +1,68 @@
import os
from dotenv import load_dotenv
load_dotenv()
APP_NAME = os.getenv("APP_NAME", "AI Patient Onboarding")
APP_ENV = os.getenv("APP_ENV", "development")
APP_VERSION = os.getenv("APP_VERSION", "1.0.0")
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./data/onboarding.db")
# AI Providers
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY", "")
OPENAI_MODEL = os.getenv("OPENAI_MODEL", "gpt-4o-mini")
ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY", "")
ANTHROPIC_MODEL = os.getenv("ANTHROPIC_MODEL", "claude-sonnet-4-5-20250929")
GEMINI_API_KEY = os.getenv("GEMINI_API_KEY", "")
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
@@ -0,0 +1,102 @@
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
@@ -0,0 +1 @@
"""Database package initialization."""
@@ -0,0 +1,58 @@
import os
from pathlib import Path
from dotenv import load_dotenv
from sqlalchemy import create_engine, event
from sqlalchemy.orm import DeclarativeBase, sessionmaker
load_dotenv()
# Ensure data directory exists relative to project root
BASE_DIR = Path(__file__).resolve().parent.parent.parent
DATA_DIR = BASE_DIR / "data"
DATA_DIR.mkdir(parents=True, exist_ok=True)
# Build absolute SQLite URI so it works regardless of process working directory
DEFAULT_DB_PATH = DATA_DIR / "onboarding.db"
env_db_url = os.getenv("DATABASE_URL")
if env_db_url and not env_db_url.startswith("sqlite:///./"):
DATABASE_URL = env_db_url
else:
# Use robust absolute path for SQLite
DATABASE_URL = f"sqlite:///{DEFAULT_DB_PATH.as_posix()}"
engine = create_engine(
DATABASE_URL,
connect_args={
"check_same_thread": False,
"timeout": 15,
},
)
@event.listens_for(engine, "connect")
def set_sqlite_pragmas(dbapi_connection, connection_record):
if "sqlite" in DATABASE_URL:
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA journal_mode=WAL;")
cursor.execute("PRAGMA busy_timeout=10000;")
cursor.execute("PRAGMA synchronous=NORMAL;")
cursor.close()
SessionLocal = sessionmaker(
bind=engine,
autoflush=False,
autocommit=False,
)
class Base(DeclarativeBase):
pass
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
@@ -0,0 +1,162 @@
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.database.database import Base
class OnboardingSession(Base):
__tablename__ = "onboarding_sessions"
id: Mapped[int] = mapped_column(
Integer,
primary_key=True,
autoincrement=True,
)
session_id: Mapped[str] = mapped_column(
String(50),
unique=True,
nullable=False,
index=True,
)
patient_id: Mapped[str] = mapped_column(
String(100),
nullable=False,
index=True,
)
patient_name: Mapped[str | None] = mapped_column(
String(255),
nullable=True,
)
age: Mapped[int | None] = mapped_column(
Integer,
nullable=True,
)
gender: Mapped[str | None] = mapped_column(
String(50),
nullable=True,
)
category_type: Mapped[str] = mapped_column(
String(50),
nullable=False,
)
category_code: Mapped[str] = mapped_column(
String(100),
nullable=False,
)
category_name: Mapped[str] = mapped_column(
String(255),
nullable=False,
)
language: Mapped[str] = mapped_column(
String(20),
default="en",
nullable=False,
)
status: Mapped[str] = mapped_column(
String(50),
default="in_progress",
nullable=False,
)
stage: Mapped[str] = mapped_column(
String(100),
default="greeting",
nullable=False,
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
nullable=False,
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
nullable=False,
)
class OnboardingMessage(Base):
__tablename__ = "onboarding_messages"
id: Mapped[int] = mapped_column(
Integer,
primary_key=True,
autoincrement=True,
)
session_id: Mapped[str] = mapped_column(
String(50),
ForeignKey("onboarding_sessions.session_id"),
nullable=False,
index=True,
)
role: Mapped[str] = mapped_column(
String(20),
nullable=False,
)
message: Mapped[str] = mapped_column(
Text,
nullable=False,
)
stage: Mapped[str] = mapped_column(
String(100),
nullable=False,
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
nullable=False,
)
class OnboardingState(Base):
__tablename__ = "onboarding_state"
id: Mapped[int] = mapped_column(
Integer,
primary_key=True,
autoincrement=True,
)
session_id: Mapped[str] = mapped_column(
String(50),
ForeignKey("onboarding_sessions.session_id"),
unique=True,
nullable=False,
index=True,
)
state_json: Mapped[str] = mapped_column(
Text,
nullable=False,
default="{}",
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
nullable=False,
)
@@ -0,0 +1,35 @@
"""
Domain models package.
"""
from app.domain.models.patient_state import (
SymptomItem,
MedicationItem,
AllergyItem,
MedicalHistoryItem,
SurgicalHistoryItem,
PreviousEpisodeItem,
FamilyHistoryItem,
FieldContainer,
PatientState,
)
from app.domain.models.extraction import ExtractionResult, QuestionIntent
from app.domain.models.contracts import ResponseContext, ResponseResult, ResponseType, FactConfidence
__all__ = [
"SymptomItem",
"MedicationItem",
"AllergyItem",
"MedicalHistoryItem",
"SurgicalHistoryItem",
"PreviousEpisodeItem",
"FamilyHistoryItem",
"FieldContainer",
"PatientState",
"ExtractionResult",
"QuestionIntent",
"ResponseContext",
"ResponseResult",
"ResponseType",
"FactConfidence",
]
@@ -0,0 +1,67 @@
"""
Response contracts, enums, and data models (Rules 102, 103, 104, 105, 116):
- ResponseType Enum
- QuestionIntent Enum
- ResponseContext Input Contract
- ResponseResult Output Contract
- FactConfidence Model
"""
from enum import Enum
from typing import Optional, List, Dict, Any
from pydantic import BaseModel, Field
class ResponseType(str, Enum):
GREETING = "greeting"
ACKNOWLEDGEMENT = "acknowledgement"
FOLLOW_UP = "follow_up"
CLARIFICATION = "clarification"
REFUSAL = "refusal"
UNKNOWN = "unknown"
SAFETY = "safety"
CRISIS = "crisis"
COMPLETION = "completion"
class QuestionIntent(str, Enum):
SYMPTOM_NAME = "symptom_name"
SYMPTOM_DURATION = "symptom_duration"
SYMPTOM_SEVERITY = "symptom_severity"
SYMPTOM_LOCATION = "symptom_location"
SYMPTOM_ONSET = "symptom_onset"
SYMPTOM_FREQUENCY = "symptom_frequency"
SYMPTOM_TRIGGER = "symptom_trigger"
ASSOCIATED_SYMPTOMS = "associated_symptoms"
MEDICAL_HISTORY = "medical_history"
MEDICATIONS = "medications"
ALLERGIES = "allergies"
FAMILY_HISTORY = "family_history"
SURGICAL_HISTORY = "surgical_history"
class ResponseContext(BaseModel):
language: str = "en"
patient_message: str
intent: str = "fact_extraction"
primary_symptom: Optional[str] = None
missing_information: Optional[str] = None
selected_question: Optional[str] = None
question_intent: Optional[QuestionIntent] = None
safety_status: str = "normal"
onboarding_completed: bool = False
current_state: Optional[Dict[str, Any]] = None
class ResponseResult(BaseModel):
response: str
language: str = "en"
question_intent: Optional[str] = None
response_type: ResponseType = ResponseType.FOLLOW_UP
class FactConfidence(BaseModel):
source: str = "patient"
method: str = "explicit"
confidence: float = 1.0
@@ -0,0 +1,28 @@
"""
Extraction result and question intent domain models.
"""
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field
class ExtractedFact(BaseModel):
category: str # 'symptom', 'medication', 'allergy', 'medical_history', 'family_history', etc.
data: Dict[str, Any]
class ExtractionResult(BaseModel):
raw_message: str
normalized_message: str
detected_language: str = "en"
updates: Dict[str, Any] = Field(default_factory=dict)
is_emergency: bool = False
is_crisis: bool = False
emergency_reason: Optional[str] = None
class QuestionIntent(BaseModel):
intent_code: str # e.g., 'reason_for_visit', 'symptom_duration', 'medical_history'
field: str
attribute: Optional[str] = None
target_symptom: Optional[str] = None
@@ -0,0 +1,87 @@
"""
Strongly typed domain models for Patient Onboarding state management.
"""
from typing import Any, Dict, List, Optional, Union
from pydantic import BaseModel, Field
class SymptomItem(BaseModel):
name: str
canonical_name: Optional[str] = None
duration: Optional[str] = None
severity: Optional[str] = None
location: Optional[str] = None
frequency: Optional[str] = None
onset: Optional[str] = None
pattern: Optional[str] = None
trigger: Optional[str] = None
relieving_factor: Optional[str] = None
associated_findings: Optional[str] = None
class MedicationItem(BaseModel):
name: str
canonical_name: Optional[str] = None
dosage: Optional[str] = None
frequency: Optional[str] = None
duration: Optional[str] = None
purpose: Optional[str] = None
class AllergyItem(BaseModel):
name: str
canonical_name: Optional[str] = None
type: Optional[str] = None # e.g., 'drug', 'food', 'environment'
reaction: Optional[str] = None # e.g., 'rash', 'swelling'
class MedicalHistoryItem(BaseModel):
condition: str
canonical_name: Optional[str] = None
duration: Optional[str] = None
status: Optional[str] = "active"
details: Optional[str] = None
class SurgicalHistoryItem(BaseModel):
procedure: str
body_site: Optional[str] = None
approximate_date: Optional[str] = None
reason: Optional[str] = None
class PreviousEpisodeItem(BaseModel):
description: str
time: Optional[str] = None
outcome: Optional[str] = None
class FamilyHistoryItem(BaseModel):
relation: str # e.g., 'father', 'mother', 'sibling'
condition: str
canonical_name: Optional[str] = None
class FieldContainer(BaseModel):
status: str = "not_collected" # 'not_collected' | 'reported' | 'confirmed_none' | 'unknown' | 'declined'
items: List[Dict[str, Any]] = Field(default_factory=list)
class SpecialtyConfidence(BaseModel):
code: str
name: str
confidence: float = 1.0
evidence: List[str] = Field(default_factory=list)
class PatientState(BaseModel):
reason_for_visit: Optional[str] = None
symptoms: List[SymptomItem] = Field(default_factory=list)
medical_history: Union[FieldContainer, List[MedicalHistoryItem], Dict[str, Any]] = Field(default_factory=list)
medications: Union[FieldContainer, List[MedicationItem], Dict[str, Any]] = Field(default_factory=list)
allergies: Union[FieldContainer, List[AllergyItem], Dict[str, Any]] = Field(default_factory=list)
surgical_history: List[SurgicalHistoryItem] = Field(default_factory=list)
previous_episodes: List[PreviousEpisodeItem] = Field(default_factory=list)
family_history: List[FamilyHistoryItem] = Field(default_factory=list)
additional_information: List[Dict[str, Any]] = Field(default_factory=list)
+102
View File
@@ -0,0 +1,102 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI
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
@asynccontextmanager
async def lifespan(app: FastAPI):
Base.metadata.create_all(bind=engine)
yield
app = FastAPI(
title="AI Patient Onboarding API",
description="Multi-Model Voice-First Hospital Patient Onboarding Engine (Sarvam + OpenAI + Claude + Gemini)",
version="1.0.0",
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)
app.add_middleware(
CORSMiddleware,
allow_origins=CORS_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
expose_headers=["X-Request-ID"],
)
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")
async def health():
return {
"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",
},
}
@@ -0,0 +1 @@
"""Schemas package initialization."""
@@ -0,0 +1,20 @@
from typing import Any, Optional
from pydantic import BaseModel, Field
class DocumentFinding(BaseModel):
test: str
value: str
unit: Optional[str] = None
interpretation: Optional[str] = None
class DocumentExtractionResponse(BaseModel):
success: bool
session_id: str
document_type: str
summary: str
findings: list[DocumentFinding] = Field(default_factory=list)
medications_found: list[str] = Field(default_factory=list)
allergies_found: list[str] = Field(default_factory=list)
message: str
@@ -0,0 +1,169 @@
from typing import Any, Literal, Optional, Union
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)
gender: Optional[str] = None
abha_id: Optional[str] = None
phone: Optional[str] = None
insurance_scheme: Optional[str] = None
dietary_habits: Optional[str] = None
class CategoryContext(BaseModel):
type: Literal["general", "speciality"]
code: str = Field(..., min_length=1)
name: str = Field(..., min_length=1)
class StartOnboardingRequest(BaseModel):
patient: PatientContext
category: CategoryContext
language: str = "en"
class StartOnboardingResponse(BaseModel):
success: bool
session_id: str
patient_id: str
category: CategoryContext
language: str
status: str
stage: str
message: str
class SendMessageRequest(BaseModel):
message: str = Field(..., min_length=1, max_length=5000)
class ErrorDetail(BaseModel):
code: str
message: str
class ErrorResponse(BaseModel):
error: ErrorDetail
class SendMessageResponse(BaseModel):
success: bool
session_id: str
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):
reason_for_visit: Optional[Any] = None
symptoms: Optional[Any] = None
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):
reply: str
next_stage: str
state_updates: AIStateUpdate
completed: bool = False
suggested_specialty: Optional[dict[str, Any]] = None
class PatientOnboardingResult(BaseModel):
patient_id: str
abha_id: Optional[str] = None
phone: Optional[str] = None
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)
dietary_habits: Optional[str] = None
additional_information: Optional[Any] = None
language: str
onboarding_completed: bool = True
class CompleteOnboardingRequest(BaseModel):
confirmed: bool = False
class CompleteOnboardingResponse(BaseModel):
success: bool
session_id: str
status: str
result: Optional[PatientOnboardingResult] = None
message: str
response_time_ms: Optional[float] = None
response_time_sec: Optional[float] = None
@@ -0,0 +1,37 @@
from typing import Optional
from pydantic import BaseModel
from app.schemas.onboarding import CategoryContext, PatientContext
class StartVoiceOnboardingRequest(BaseModel):
patient: PatientContext
category: Optional[CategoryContext] = None
language: str = "en-IN"
class StartVoiceOnboardingResponse(BaseModel):
success: bool
session_id: str
patient_id: str
category: CategoryContext
language: str
status: str
stage: str
greeting_text: str
audio_url: str
response_time_ms: Optional[float] = None
response_time_sec: Optional[float] = None
class VoiceTurnResponse(BaseModel):
success: bool
session_id: str
patient_transcript: str
ai_text: str
stage: str
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
@@ -0,0 +1 @@
"""Services package initialization."""
@@ -0,0 +1,21 @@
from app.ai.orchestrator import AIOrchestrator
class AIOnboardingService:
def __init__(self):
self.orchestrator = AIOrchestrator()
async def process_message(
self,
session,
state,
conversation_history,
user_message,
):
return await self.orchestrator.process(
session=session,
state=state,
conversation_history=conversation_history,
user_message=user_message,
)
@@ -0,0 +1,33 @@
import json
from app.ai.orchestrator import check_required_missing
class CompletionService:
def is_complete(
self,
session,
state,
) -> bool:
"""
Clinical intake completion validation using the single source of truth:
check_required_missing().
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
@@ -0,0 +1,427 @@
"""
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
@@ -0,0 +1,75 @@
class ConversationEngine:
def generate_response(
self,
session,
state,
user_message,
):
stage = session.stage
if stage == "greeting":
return {
"message": (
"What brings you to the hospital today?"
),
"next_stage": "reason_for_visit",
}
if stage == "reason_for_visit":
return {
"message": (
"Can you tell me more about what "
"you are experiencing?"
),
"next_stage": "symptom_collection",
}
if stage == "symptom_collection":
return {
"message": (
"How long have you been experiencing "
"this problem?"
),
"next_stage": "symptom_details",
}
if stage == "symptom_details":
return {
"message": (
"Are you currently taking any "
"medications?"
),
"next_stage": "medications",
}
if stage == "medications":
return {
"message": (
"Do you have any known allergies?"
),
"next_stage": "allergies",
}
if stage == "allergies":
return {
"message": (
"Thank you. I have collected the "
"initial information."
),
"next_stage": "summary",
}
return {
"message": (
"Thank you. Let's continue."
),
"next_stage": stage,
}
@@ -0,0 +1,70 @@
import re
# Common Indian phonetic variations & clinical abbreviations
PHONETIC_REPLACEMENTS = {
# Medical terms & symptoms
r"\b(suger|shugar|sugur)\b": "sugar (diabetes)",
r"\b(presure|presur|blood preser)\b": "blood pressure",
r"\b(bp)\b": "BP",
r"\b(chest painu|chestpain|chst pain)\b": "chest pain",
r"\b(hart attack|heart attacku)\b": "heart attack",
r"\b(stomech pain|stomach painu)\b": "stomach pain",
r"\b(head ache|hedache|headacheu)\b": "headache",
r"\b(breath kashtam|breathing problem|moochu thinaral)\b": "breathing difficulty",
r"\b(injekshan|injaction|injecshun)\b": "injection",
r"\b(opration|oparation)\b": "operation",
r"\b(tablat|tablit|mathirai|goli)\b": "tablets",
r"\b(alargy|alergi)\b": "allergy",
r"\b(fevar|favar)\b": "fever",
r"\b(coughu|coff)\b": "cough",
r"\b(vomit|vomitingu|vomitin)\b": "vomiting",
# Common Indian Medicines (Phonetic normalization)
r"\b(parasetamal|paracitamol|paracetamol 650|dolo 650|dolo)\b": "Paracetamol (Dolo 650)",
r"\b(metformin|methformin|glycomet)\b": "Metformin",
r"\b(telmisartan|telma|telmikind)\b": "Telmisartan",
r"\b(amlodipine|amlong|amlo)\b": "Amlodipine",
r"\b(pantoprazole|pantocid|pan d|pan 40)\b": "Pantoprazole",
r"\b(omeprazole|omez)\b": "Omeprazole",
r"\b(atorvastatin|atorva|atorlip)\b": "Atorvastatin",
r"\b(cetirizine|cetzine|citrizin)\b": "Cetirizine",
r"\b(azithromycin|azithral|azithro)\b": "Azithromycin",
r"\b(amoxicillin|mox|augmentin)\b": "Amoxicillin",
r"\b(insulin injection|insuline)\b": "Insulin",
}
def normalize_medical_speech(transcript: str, language_code: str = "en-IN") -> str:
"""
Cleans, sanitizes, and normalizes spoken transcripts from STT models
to ensure high clinical fidelity while retaining the patient's original meaning.
"""
if not transcript or not transcript.strip():
return ""
cleaned = transcript.strip()
# Remove repeated filler words or audio stutter artifacts
cleaned = re.sub(r"\b(um+|uh+|ah+|erm+|er+)\b", "", cleaned, flags=re.IGNORECASE)
cleaned = re.sub(r"\s+", " ", cleaned).strip()
# If it's English or Romanized Indic code-mixed speech, apply phonetic dictionary
lang_lower = language_code.lower()
if lang_lower.startswith("en") or any(c.isascii() for c in cleaned):
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()
# Capitalize first letter if needed
if cleaned and len(cleaned) > 1:
cleaned = cleaned[0].upper() + cleaned[1:]
return cleaned
@@ -0,0 +1,107 @@
import json
from sqlalchemy.orm import Session
from app.database.models import (
OnboardingMessage,
OnboardingSession,
OnboardingState,
)
class OnboardingService:
def get_session(
self,
db: Session,
session_id: str,
):
return (
db.query(OnboardingSession)
.filter(
OnboardingSession.session_id == session_id
)
.first()
)
def get_state(
self,
db: Session,
session_id: str,
):
state = (
db.query(OnboardingState)
.filter(
OnboardingState.session_id == session_id
)
.first()
)
if not state:
default_state = {
"reason_for_visit": {
"status": "not_collected",
"value": None
},
"symptoms": {
"status": "not_collected",
"items": []
},
"medical_history": {
"status": "not_collected",
"items": []
},
"medications": {
"status": "not_collected",
"items": []
},
"allergies": {
"status": "not_collected",
"items": []
},
"surgical_history": [],
"previous_episodes": [],
"family_history": [],
"additional_information": []
}
state = OnboardingState(
session_id=session_id,
state_json=json.dumps(default_state),
)
db.add(state)
db.commit()
db.refresh(state)
return state
def save_message(
self,
db: Session,
session_id: str,
role: str,
message: str,
stage: str,
):
new_message = OnboardingMessage(
session_id=session_id,
role=role,
message=message,
stage=stage,
)
db.add(new_message)
db.commit()
return new_message
def update_state(
self,
db: Session,
state: OnboardingState,
data: dict,
):
state.state_json = json.dumps(data)
db.commit()
db.refresh(state)
return state
@@ -0,0 +1,13 @@
class PatientSubmissionService:
async def submit(
self,
result: dict,
) -> dict:
# Destination patient / hospital EHR / HIMS API integration point
# In production, this can perform an async HTTP POST to your Hospital Backend
return {
"success": True,
"message": "Patient data successfully submitted to hospital system",
"patient_id": result.get("patient_id"),
}
@@ -0,0 +1,218 @@
import json
from app.ai.openai_client import OpenAIClient
from app.config import OPENAI_API_KEY
from app.schemas.onboarding import PatientOnboardingResult
class ResultExtractionService:
def __init__(self):
self.openai = OpenAIClient()
async def extract_result(
self,
session,
state,
conversation_history,
) -> dict:
current_state = json.loads(
state.state_json
)
prompt = f"""
You are a senior clinical data extraction engine at a healthcare facility.
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.
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.
PATIENT CONTEXT:
{json.dumps({
"id": session.patient_id,
"name": session.patient_name,
"age": session.age,
"gender": session.gender,
}, indent=2, ensure_ascii=False)}
CATEGORY / SPECIALTY:
{json.dumps({
"type": session.category_type,
"code": session.category_code,
"name": session.category_name,
}, indent=2, ensure_ascii=False)}
CURRENT INTAKE STATE:
{json.dumps(current_state, indent=2, ensure_ascii=False)}
FULL CONVERSATION:
{json.dumps(conversation_history, indent=2, ensure_ascii=False)}
Return ONLY a valid JSON object matching this structure:
{{
"patient_id": "{session.patient_id}",
"category": {{
"type": "{session.category_type}",
"code": "{session.category_code}",
"name": "{session.category_name}"
}},
"chief_complaint": "primary reason for visit or main symptom",
"symptoms": [
{{
"name": "symptom name",
"duration": "duration or null",
"severity": "severity or null",
"location": "location or null",
"description": "brief details 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",
"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(
system_prompt=prompt,
user_message="Generate the final structured patient onboarding result.",
)
cleaned = raw_response.strip()
if cleaned.startswith("```json"):
cleaned = cleaned[7:]
if cleaned.startswith("```"):
cleaned = cleaned[3:]
if cleaned.endswith("```"):
cleaned = cleaned[:-3]
cleaned = cleaned.strip()
parsed = json.loads(cleaned)
validated = PatientOnboardingResult.model_validate(parsed)
return validated.model_dump()
except Exception as e:
print(f"[OpenAI Result Extraction Warning]: {e}")
# Tertiary: Anthropic Claude extraction
if ANTHROPIC_API_KEY and ANTHROPIC_API_KEY.strip() not in ["your_anthropic_key", ""]:
try:
from anthropic import AsyncAnthropic
client = AsyncAnthropic(api_key=ANTHROPIC_API_KEY)
res = await client.messages.create(
model=ANTHROPIC_MODEL or "claude-sonnet-4-5-20250929",
max_tokens=800,
system=prompt,
messages=[{"role": "user", "content": "Generate structured onboarding result"}],
)
cleaned = res.content[0].text.replace("```json", "").replace("```", "").strip()
parsed = json.loads(cleaned)
validated = PatientOnboardingResult.model_validate(parsed)
return validated.model_dump()
except Exception as e:
print(f"[Claude Result Extraction Error]: {e}")
# Fallback structured builder from state
chief_complaint = current_state.get("reason_for_visit")
if isinstance(chief_complaint, dict):
chief_complaint = chief_complaint.get("value")
symptoms_raw = current_state.get("symptoms")
symptoms_list = []
if isinstance(symptoms_raw, dict):
symptoms_list = symptoms_raw.get("items", [])
elif isinstance(symptoms_raw, list):
symptoms_list = symptoms_raw
meds_raw = current_state.get("medications")
meds_list = meds_raw.get("items", []) if isinstance(meds_raw, dict) else (meds_raw or [])
allergies_raw = current_state.get("allergies")
allergies_list = allergies_raw.get("items", []) if isinstance(allergies_raw, dict) else (allergies_raw or [])
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": {
"type": session.category_type,
"code": session.category_code,
"name": session.category_name,
},
"chief_complaint": chief_complaint or None,
"symptoms": symptoms_list,
"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,
"language": session.language,
"onboarding_completed": True,
}
validated = PatientOnboardingResult.model_validate(fallback_result)
return validated.model_dump()
@@ -0,0 +1,32 @@
from app.ai.claude_client import ClaudeClient
from app.specialties.registry import get_specialty_config
class ValidationService:
def __init__(self):
self.claude = ClaudeClient()
async def validate_state_extraction(
self,
session,
conversation_history: list,
extracted_state: dict,
) -> dict:
specialty_config = get_specialty_config(session.category_code)
patient_context = {
"id": session.patient_id,
"name": session.patient_name,
"age": session.age,
"gender": session.gender,
"category": session.category_name,
}
validation = await self.claude.validate_extraction(
patient_context=patient_context,
specialty_config=specialty_config,
conversation_history=conversation_history,
extracted_data=extracted_state,
)
return validation
@@ -0,0 +1,205 @@
import base64
import io
import os
import wave
import struct
from pathlib import Path
from uuid import uuid4
import httpx2
from openai import AsyncOpenAI
from app.config import OPENAI_API_KEY, SARVAM_API_KEY, GEMINI_API_KEY, GEMINI_MODEL
from app.services.medical_speech_normalizer import normalize_medical_speech
BASE_DIR = Path(__file__).resolve().parent.parent.parent
AUDIO_DIR = BASE_DIR / "data" / "audio"
AUDIO_DIR.mkdir(parents=True, exist_ok=True)
class VoiceService:
def __init__(self):
self.openai_key = OPENAI_API_KEY
self.sarvam_key = SARVAM_API_KEY
self.gemini_key = GEMINI_API_KEY
self.gemini_model = GEMINI_MODEL
def get_initial_greeting(self, patient_name: str | None, language: str) -> str:
name = patient_name or "அவர்களே"
lang_lower = language.lower()
if lang_lower.startswith("ta"):
return f"வணக்கம் {name}, CureSelect மருத்துவமனைக்கு உங்களை அன்போடு வரவேற்கிறோம். இன்று உங்களுக்கு என்ன மாதிரி உடம்பு முடியலை, என்ன அறிகுறிகள் இருக்குன்னு சொல்ல முடியுமா?"
elif lang_lower.startswith("hi"):
return f"नमस्ते {name} जी, CureSelect अस्पताल में आपका स्वागत है। आज आपको क्या तकलीफ या लक्षण महसूस हो रहे हैं, कृपया बताएंगे?"
elif lang_lower.startswith("te"):
return f"నమస్కారం {name} గారు, CureSelect హాస్పిటల్‌కు స్వాగతం. ఈరోజు మీకున్న ఆరోగ్య సమస్య గురించి చెప్పగలరా?"
elif lang_lower.startswith("kn"):
return f"ನಮಸ್ಕಾರ {name} ಅವರೇ, CureSelect ಆಸ್ಪತ್ರೆಗೆ ಸುಸ್ವಾಗತ. ಇಂದು ನಿಮ್ಮ ಆರೋಗ್ಯದ ತೊಂದರೆ ಬಗ್ಗೆ ತಿಳಿಸಬಹುದೇ?"
else:
return f"Hello {name}, welcome to CureSelect Healthcare. Could you please share what health symptoms you are experiencing today?"
async def speech_to_text(
self,
audio_bytes: bytes,
filename: str = "audio.webm",
language_code: str = "en-IN",
specialty_code: str = "general",
patient_context: dict | None = None,
) -> str:
"""
High-precision multi-model Indian speech transcription pipeline:
1. Sarvam AI Saaras v2 (Specialized Indic Speech Engine)
2. Google Gemini Multimodal Audio (Multilingual Code-Switching & Acoustic Context)
3. OpenAI Whisper-1 (Medical Specialty Context Boosted)
4. Clinical & Phonetic Post-Processing Normalizer
"""
lang_prefix = language_code.lower()[:2]
raw_transcript = ""
# 1. Primary: Sarvam AI Saaras v3 for Native Indian Languages (Ultra-fast Indic STT)
if (
self.sarvam_key
and self.sarvam_key.strip()
and self.sarvam_key not in ["your_sarvam_api_key", "your_sarvam_key", ""]
and lang_prefix in ["ta", "hi", "te", "kn", "bn", "mr", "gu", "ml", "pa", "od", "en"]
):
try:
async with httpx2.AsyncClient(timeout=5.0) as client:
files = {
"file": (filename, audio_bytes, "audio/webm")
}
data = {
"model": "saaras:v3",
"language_code": language_code,
"with_diacritics": "true",
}
headers = {
"api-subscription-key": self.sarvam_key
}
res = await client.post(
"https://api.sarvam.ai/speech-to-text",
files=files,
data=data,
headers=headers,
)
if res.status_code == 200:
res_json = res.json()
transcript = res_json.get("transcript", "").strip()
if transcript:
raw_transcript = transcript
except Exception as e:
print(f"[Sarvam STT Error]: {e}")
# Apply Medical Speech Normalizer to eliminate phonetic mishearings
normalized = normalize_medical_speech(raw_transcript, language_code=language_code)
return normalized
async def text_to_speech(
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
cleaned_text = (
text.replace("(", "")
.replace(")", "")
.replace("*", "")
.replace("#", "")
.replace("- ", "")
.strip()
)
# 1. Use Sarvam AI TTS for Indian Languages (Native Indian Accent)
if (
self.sarvam_key
and self.sarvam_key.strip()
and self.sarvam_key not in ["your_sarvam_api_key", "your_sarvam_key", ""]
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:
headers = {
"api-subscription-key": self.sarvam_key,
"Content-Type": "application/json",
}
payload = {
"inputs": [cleaned_text],
"target_language_code": language_code,
"speaker": speaker,
"model": "bulbul:v3",
}
res = await client.post(
"https://api.sarvam.ai/text-to-speech",
json=payload,
headers=headers,
)
if res.status_code == 200:
res_json = res.json()
audios = res_json.get("audios", [])
if audios:
audio_data = base64.b64decode(audios[0])
file_path = AUDIO_DIR / f"{audio_id}.wav"
file_path.write_bytes(audio_data)
return audio_id, audio_data
else:
print(f"[Sarvam TTS HTTP Error]: {res.status_code} - {res.text}")
except Exception as e:
print(f"[Sarvam TTS Error]: {e}")
# 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)
try:
response = await client.audio.speech.create(
model="tts-1",
voice="nova",
input=cleaned_text,
speed=1.0,
)
audio_data = response.content
file_path = AUDIO_DIR / f"{audio_id}.mp3"
file_path.write_bytes(audio_data)
return audio_id, audio_data
except Exception as e:
print(f"[OpenAI TTS Error]: {e}")
finally:
await client.close()
# 3. Fallback
audio_data = self._generate_fallback_wav()
file_path = AUDIO_DIR / f"{audio_id}.wav"
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:
wf.setnchannels(1)
wf.setsampwidth(2)
wf.setframerate(16000)
data = [int(1000 * (i % 32 < 16)) for i in range(4000)]
wf.writeframes(struct.pack(f"<{len(data)}h", *data))
return buf.getvalue()
def get_audio_path(self, audio_id: str) -> Path | None:
for ext in [".wav", ".mp3", ".webm", ".m4a"]:
p = AUDIO_DIR / f"{audio_id}{ext}"
if p.exists():
return p
return None
@@ -0,0 +1,188 @@
"""
WebSocket Transport Service (Section 7, 8, 10, 11, 12, 13, 14, 17).
Thin bridge between WebSocket events and core onboarding business services.
Delegates all clinical logic, state management, and AI orchestration to OnboardingService/AIOnboardingService.
"""
import time
import logging
from typing import Dict, Any
from fastapi import WebSocket
from sqlalchemy.orm import Session
from app.api.websocket.manager import manager
from app.api.websocket.schemas import (
WSEventType,
WSErrorCode,
WSAssistantMessage,
WSTypingEvent,
WSProcessingStartedEvent,
WSProcessingCompletedEvent,
WSSafetyAlertEvent,
WSStateUpdatedEvent,
WSOnboardingCompletedEvent,
WSErrorEvent,
)
from app.services.onboarding_service import OnboardingService
from app.services.ai_onboarding_service import AIOnboardingService
from app.database.models import OnboardingMessage
logger = logging.getLogger(__name__)
class WebSocketService:
def __init__(self):
self.onboarding_service = OnboardingService()
self.ai_service = AIOnboardingService()
async def handle_patient_message(
self,
websocket: WebSocket,
db: Session,
session_id: str,
message_id: str,
text: str,
):
"""
Process patient message through transport pipeline (Section 7).
Enforces typing events, safety alerts, state updates, and deduplication.
"""
t0 = time.perf_counter()
# 1. Deduplication check (Section 17)
cached_resp = manager.get_cached_message_response(session_id, message_id)
if cached_resp:
logger.info("websocket_duplicate_message_received", extra={"session_id": session_id, "message_id": message_id})
await manager.send_json(websocket, cached_resp)
return
# 2. Emit processing.started
await manager.send_json(websocket, WSProcessingStartedEvent().model_dump())
# 3. Emit typing.started
await manager.send_json(websocket, WSTypingEvent(type=WSEventType.TYPING_STARTED.value).model_dump())
try:
# Fetch session & verify active status
session = self.onboarding_service.get_session(db, session_id)
if not session or session.status != "in_progress":
await manager.send_json(
websocket,
WSErrorEvent(
code=WSErrorCode.SESSION_EXPIRED.value,
message="Onboarding session is expired or not active."
).model_dump()
)
return
# Save patient message
self.onboarding_service.save_message(
db=db,
session_id=session_id,
role="patient",
message=text,
stage=session.stage,
)
state = self.onboarding_service.get_state(db, session_id)
messages = (
db.query(OnboardingMessage)
.filter(OnboardingMessage.session_id == session_id)
.order_by(OnboardingMessage.created_at.asc())
.all()
)
conversation_history = [
{"role": m.role, "message": m.message, "stage": m.stage}
for m in messages
]
# 4. Delegate to AI Orchestrator
ai_result = await self.ai_service.process_message(
session=session,
state=state,
conversation_history=conversation_history,
user_message=text,
)
# 5. Check Emergency / Crisis priority (Section 12)
is_emergency = ai_result.get("is_emergency") or ai_result.get("next_stage") == "emergency_referral"
is_crisis = ai_result.get("is_crisis") or ai_result.get("next_stage") == "crisis_handling"
if is_emergency or is_crisis:
level = "crisis" if is_crisis else "emergency"
await manager.send_json(
websocket,
WSSafetyAlertEvent(
level=level,
message="Safety event detected. Normal intake flow suspended."
).model_dump()
)
reply_text = ai_result.get("reply", "Got it. When did it start?")
next_stage = ai_result.get("next_stage", session.stage)
session.stage = next_stage
# Save assistant message
self.onboarding_service.save_message(
db=db,
session_id=session_id,
role="assistant",
message=reply_text,
stage=next_stage,
)
# Update state in DB if modified
updated_state_dict = ai_result.get("updated_state") or (ai_result.get("state_updates") if isinstance(ai_result.get("state_updates"), dict) else None)
if updated_state_dict and isinstance(updated_state_dict, dict):
self.onboarding_service.update_state(db, state, updated_state_dict)
else:
db.commit()
# 6. Emit state.updated (Section 13)
await manager.send_json(
websocket,
WSStateUpdatedEvent(session_id=session_id).model_dump()
)
# 7. Construct & emit assistant.message (Section 5)
assistant_resp = WSAssistantMessage(
message_id=f"resp_{message_id}",
reply=reply_text,
language=session.language or "en-IN"
).model_dump()
await manager.send_json(websocket, assistant_resp)
# Cache response for duplicate message protection
manager.cache_processed_message(session_id, message_id, assistant_resp)
# 8. Check onboarding completion (Section 14)
if ai_result.get("completed") or session.status == "completed":
session.status = "completed"
db.commit()
await manager.send_json(
websocket,
WSOnboardingCompletedEvent(session_id=session_id).model_dump()
)
except Exception as e:
logger.error("websocket_message_processing_failed", extra={"session_id": session_id, "error": str(e)})
await manager.send_json(
websocket,
WSErrorEvent(
code=WSErrorCode.PROCESSING_ERROR.value,
message="An error occurred while processing your message."
).model_dump()
)
finally:
# Always ensure typing state is stopped (Section 10)
await manager.send_json(
websocket,
WSTypingEvent(type=WSEventType.TYPING_STOPPED.value).model_dump()
)
# Emit processing.completed (Section 11)
latency_ms = int((time.perf_counter() - t0) * 1000)
await manager.send_json(
websocket,
WSProcessingCompletedEvent(processing_time_ms=latency_ms).model_dump()
)
@@ -0,0 +1 @@
"""Specialties package initialization."""
@@ -0,0 +1,40 @@
CARDIOLOGY_CONFIG = {
"code": "cardiology",
"name": "Cardiology (Heart & BP)",
"purpose": (
"Collect cardiovascular symptoms, chest tightness / heavy chest (nenju adaipu), "
"palpitations, breathlessness on exertion, hypertension / BP history, "
"and cardiac medications common in India (Telma, Amlong, Ecosprin, Sorbitrate, Beta-blockers, Statins)."
),
"focus_areas": [
"Chest pain / tightness character (pressure, squeezing, burning vs acidity/gas)",
"Radiation (left arm, neck, jaw, back, shoulder)",
"Shortness of breath (especially while walking upstairs or lying flat / orthopnea)",
"Palpitations or missed heartbeats (nenju thudippu)",
"Swelling in feet / ankles (pedal edema)",
"Hypertension (BP) and Diabetes (Sugar) history and duration",
"Current cardiac medications (Telma 40/80, Amlodipine/Amlong, Ecosprin 75/150, Atorva/Rosuvas, Metoprolol/Concor, Sorbitrate)",
"Previous heart procedures (Angiogram, Angioplasty / Stent, Bypass / CABG, Pacemaker)",
"Tobacco, Bidi, Cigarette, Paan, Gutkha use or sedentary lifestyle",
],
"emergency_keywords": [
"crushing chest pain",
"pain radiating to left arm and jaw",
"severe sweating with chest tightness",
"collapsed",
"sudden loss of consciousness",
"cannot breathe",
"hospital emergency room",
"alert duty doctor",
],
"clarification_rules": [
"If the patient describes chest burning or heaviness, ask whether it is related to walking/exertion (angina) or happens after spicy/oily food (gastric acidity).",
"Ask if they are already taking regular BP tablets like Telma, Amlodipine, or daily blood thinners like Ecosprin.",
"Check if they have a history of previous heart stenting, bypass surgery, or family history of heart attacks at a young age.",
"If severe red-flag symptoms are present, immediately advise informing hospital emergency staff or visiting the hospital emergency room.",
],
}
@@ -0,0 +1,32 @@
DERMATOLOGY_CONFIG = {
"code": "dermatology",
"name": "Dermatology (Skin & Hair Care)",
"purpose": (
"Collect history of skin lesions, rashes, itching, acne, fungal infections, "
"duration, body distribution, trigger factors, and topical or oral treatments used."
),
"focus_areas": [
"Chief skin complaint (rash, severe itching/pruritus, eczema, acne/pimples, fungal infection, blisters, discoloration)",
"Lesion distribution and onset (face, scalp, trunk, arms, legs, localized vs generalized)",
"Associated symptoms (burning sensation, pain, scaling, oozing, bleeding)",
"Triggers and exposures (spicy/oily food, new soap/cosmetics, detergent, dust, sunlight, medications)",
"Current and prior treatments (antifungal creams like Clotrimazole, steroid creams, oral antihistamines like Cetirizine/Levocetirizine)",
"Known allergies (soaps, metals, cosmetics, drugs)",
],
"emergency_keywords": [
"sudden widespread peeling",
"stevens johnson syndrome",
"toxic epidermal necrolysis",
"severe facial swelling with breathing difficulty",
"rapidly spreading painful skin breakdown",
],
"clarification_rules": [
"If patient mentions skin itching or rash, ask how many days they have had it and if it spreads at night.",
"Check if they are using any over-the-counter steroid creams or soaps.",
"Inquire if any other family members share similar skin symptoms.",
],
}
@@ -0,0 +1,36 @@
DIABETOLOGY_CONFIG = {
"code": "diabetology",
"name": "Diabetology & Endocrinology (Sugar / Diabetes)",
"purpose": (
"Collect comprehensive diabetes history, blood sugar control, "
"fasting/PPBS readings, HbA1c history, Indian dietary habits, and current anti-diabetic medications (Metformin, Glycomet, Insulin)."
),
"focus_areas": [
"Duration of diabetes (Type 1 / Type 2 / Gestational)",
"Recent blood sugar readings (Fasting, Post-Prandial / PPBS, HbA1c)",
"Current anti-diabetic medications (Glycomet, Metformin, Teneligliptin, Glimepiride, Janumet, Insulin injections)",
"Symptoms of high/low sugar (excessive thirst, frequent urination, dizziness, sweating, tingling feet / neuropathy)",
"Dietary patterns (rice intake, sweets, tea with sugar, fasting habits like Vrat/Upavasam)",
"Comorbidities common in India (Hypertension / BP, Thyroid, Fatty Liver, Cardiac history)",
],
"emergency_keywords": [
"severe hypoglycemia",
"sugar dropped below 50",
"unconscious",
"fainting with cold sweats",
"deep labored breathing",
"fruity breath",
"diabetic ketoacidosis",
"hospital emergency staff / ER",
],
"clarification_rules": [
"If the patient mentions 'sugar' or 'chakkara noyu' or 'cheeni bimari', ask if they take tablets (like Glycomet) or insulin injections.",
"Ask about when they last checked their sugar level (Fasting or PPBS) and if they have HbA1c report.",
"Check if they experience burning or tingling sensations in their feet (diabetic neuropathy).",
"Check if they also take BP tablets (Telma, Amlodipine) or cholesterol tablets (Atorvastatin/Rosuvas).",
],
}
@@ -0,0 +1,32 @@
GASTROENTEROLOGY_CONFIG = {
"code": "gastroenterology",
"name": "Gastroenterology (Stomach & Digestive Care)",
"purpose": (
"Collect abdominal symptoms including stomach ache, acid reflux/GERD, "
"vomiting, bowel movement alterations (diarrhea/constipation), and dietary associations."
),
"focus_areas": [
"Abdominal pain location and character (upper stomach burning/acidity, right upper quadrant, lower abdominal cramps)",
"Bowel habits (loose stools/diarrhea frequency, constipation, bloody stools/melena)",
"Upper GI symptoms (nausea, persistent vomiting, heartburn, difficulty swallowing/dysphagia)",
"Relationship to meals (worse after oily/spicy food, pain relieved by eating or antacids)",
"Current GI medications (Pantoprazole/Pan-D, Omeprazole, Gelusil, ORS, probiotics)",
"History of ulcer disease, gallstones, jaundice, or liver disease",
],
"emergency_keywords": [
"vomiting blood or coffee ground material",
"black tarry stools",
"severe rigid abdomen with intolerable pain",
"high fever with severe jaundice and vomiting",
"acute abdomen",
],
"clarification_rules": [
"If abdominal pain is reported, ask exact location (upper stomach vs lower belly) and duration.",
"Check if symptom is accompanied by vomiting, loose motions, or acidity after meals.",
"Inquire if patient has taken antacids (Pan-D, Digene) or ORS.",
],
}
@@ -0,0 +1,33 @@
GENERAL_CONFIG = {
"code": "general",
"name": "General Physician & OPD Intake",
"purpose": (
"Collect primary reason for hospital OPD visit, fever patterns (dengue, malaria, viral, typhoid), "
"duration, severity, acidity / gastric issues, body pain, and existing chronic conditions (BP, Sugar, Thyroid)."
),
"focus_areas": [
"Chief complaint (fever, body ache, headache, cough, gastric pain, fatigue)",
"Fever character (continuous, with chills/shivering like malaria, with severe joint pain like chikungunya/dengue)",
"Digestive issues (acidity, indigestion, burning stomach, vomiting, loose stools)",
"Self-medication taken at home (Dolo 650, Paracetamol, Pan-D, Pantocid, Antacids, Cough syrups)",
"Existing conditions (BP, Sugar / Diabetes, Thyroid / Thyronorm, Asthma, Kidney / Liver)",
"Drug allergies (Penicillin, Sulfa, NSAIDs / Painkillers)",
],
"emergency_keywords": [
"fever with altered mental state",
"persistent vomiting with extreme weakness",
"severe bleeding",
"coughing up blood",
"very low blood pressure",
"hospital emergency staff / ER",
],
"clarification_rules": [
"If the patient has fever, ask for how many days they have had it and if they took Dolo 650 or any home remedies.",
"Check if they have other family members with similar fever or illness.",
"Ask if they have any known drug allergies before doctor consultation.",
],
}
@@ -0,0 +1,32 @@
MENTAL_HEALTH_CONFIG = {
"code": "mental_health",
"name": "Mental Health",
"purpose": (
"Collect the patient's initial mental health "
"concerns and relevant background information."
),
"focus_areas": [
"reason_for_visit",
"mood",
"anxiety",
"sleep",
"stress",
"daily_functioning",
"duration",
"previous_treatment",
"medications",
"allergies",
],
"required_sections": [
"reason_for_visit",
"symptoms",
"medical_history",
"medications",
"allergies",
],
"safety_screening": True,
}
@@ -0,0 +1,34 @@
NEUROLOGY_CONFIG = {
"code": "neurology",
"name": "Neurology (Brain & Nervous System)",
"purpose": (
"Collect neurological symptoms including headache character, dizziness, numbness, "
"weakness, seizure history, and past neurological evaluation."
),
"focus_areas": [
"Headache characteristics (throbbing, one-sided migraine, tension type, occipital, duration)",
"Associated neurological features (nausea, vomiting, sensitivity to light/sound, visual aura)",
"Dizziness or vertigo (spinning sensation, loss of balance, unsteady gait)",
"Focal symptoms (numbness/tingling in hands/feet, facial drooping, speech slurring, limb weakness)",
"Seizure or blackout history (loss of consciousness, involuntary movements, aura)",
"Current neurological medications (painkillers, migraine prophylactics like Propranolol/Flunarizine, anti-epileptics)",
],
"emergency_keywords": [
"sudden thunderclap headache",
"worst headache of my life",
"sudden weakness on one side of body",
"facial drooping with slurred speech",
"stroke symptoms",
"active seizure",
"loss of consciousness",
],
"clarification_rules": [
"If headache is reported, clarify duration, exact location (one side vs forehead), and if accompanied by nausea or light sensitivity.",
"If numbness or tingling is present, clarify if it affects both feet (diabetic neuropathy) or one side of the body.",
"Check if patient has a history of high blood pressure or previous stroke.",
],
}
@@ -0,0 +1,32 @@
ORTHOPEDICS_CONFIG = {
"code": "orthopedics",
"name": "Orthopedics (Bone & Joint Care)",
"purpose": (
"Collect musculoskeletal complaints including joint pain, spinal/back pain, "
"trauma history, stiffness, mobility restriction, and painkiller usage."
),
"focus_areas": [
"Primary joint/bone complaint (knee pain, lumbar back pain, neck pain, shoulder stiffness, ankle sprain)",
"Onset and progression (acute post-injury/fall vs chronic osteoarthritis/wear-and-tear)",
"Functional impact (difficulty walking, standing up from chair, climbing stairs, lifting objects)",
"Swelling, redness, deformity, or morning stiffness",
"Medications and pain management (NSAIDs like Aceclofenac/Diclofenac/Paracetamol, calcium, Vitamin D3 supplements)",
"Surgical or orthopedic history (previous fractures, knee replacement, spine surgery)",
],
"emergency_keywords": [
"open fracture with visible bone",
"inability to feel or move leg after injury",
"sudden loss of bowel or bladder control with back pain",
"cauda equina syndrome",
"severe traumatic limb injury",
],
"clarification_rules": [
"If knee or joint pain is reported, ask if it occurs during walking/stair climbing and how long it has been present.",
"Clarify if there was any recent fall, injury, or heavy lifting event.",
"Check if patient takes regular pain relief tablets or calcium supplements.",
],
}
@@ -0,0 +1,32 @@
PULMONOLOGY_CONFIG = {
"code": "pulmonology",
"name": "Pulmonology (Lungs & Respiratory Care)",
"purpose": (
"Collect respiratory history including cough type, breathlessness, wheezing, "
"asthma/COPD history, inhaler usage, and environmental/smoking exposure."
),
"focus_areas": [
"Cough character and duration (dry cough vs productive/phlegm, nocturnal cough, duration)",
"Shortness of breath (on exertion, at rest, when lying flat/orthopnea)",
"Wheezing or chest tightness ( whistling sound while breathing out)",
"Triggers (cold air, dust, pollen, smoke, exercise, seasonal changes)",
"Current inhalers and respiratory medicines (Asthalin/Salbutamol, Budecort/Budesonide, Montair-LC, antibiotics)",
"History of Asthma, COPD, Tuberculosis (TB), pneumonia, or smoking/bidi history",
],
"emergency_keywords": [
"severe acute breathlessness unable to speak full sentences",
"blue lips or fingernails / cyanosis",
"coughing up significant blood / hemoptysis",
"severe asthma attack unyielding to inhaler",
"respiratory distress",
],
"clarification_rules": [
"If cough is reported, ask whether dry or with phlegm and how many days it has persisted.",
"Check if patient experiences wheezing or breathlessness when walking or sleeping.",
"Clarify if patient uses regular inhalers (Asthalin, Budecort) or allergy tablets.",
],
}
@@ -0,0 +1,53 @@
from app.specialties.cardiology import CARDIOLOGY_CONFIG
from app.specialties.dermatology import DERMATOLOGY_CONFIG
from app.specialties.diabetology import DIABETOLOGY_CONFIG
from app.specialties.gastroenterology import GASTROENTEROLOGY_CONFIG
from app.specialties.general import GENERAL_CONFIG
from app.specialties.mental_health import MENTAL_HEALTH_CONFIG
from app.specialties.neurology import NEUROLOGY_CONFIG
from app.specialties.orthopedics import ORTHOPEDICS_CONFIG
from app.specialties.pulmonology import PULMONOLOGY_CONFIG
SPECIALTIES = {
"cardiology": CARDIOLOGY_CONFIG,
"dermatology": DERMATOLOGY_CONFIG,
"diabetology": DIABETOLOGY_CONFIG,
"gastroenterology": GASTROENTEROLOGY_CONFIG,
"general": GENERAL_CONFIG,
"mental_health": MENTAL_HEALTH_CONFIG,
"neurology": NEUROLOGY_CONFIG,
"orthopedics": ORTHOPEDICS_CONFIG,
"pulmonology": PULMONOLOGY_CONFIG,
}
CANONICAL_ALIASES = {
"general_medicine": "general",
"psychiatry": "mental_health",
}
def get_specialty_config(code: str) -> dict:
canonical_code = CANONICAL_ALIASES.get(code, code)
return SPECIALTIES.get(
canonical_code,
GENERAL_CONFIG,
)
def validate_specialty_registry() -> dict:
"""
Automated validation for specialty registry completeness and integrity.
"""
canonical_codes = set(SPECIALTIES.keys())
missing_configs = []
for code, config in SPECIALTIES.items():
if not config or not isinstance(config, dict) or config.get("code") != code:
missing_configs.append(code)
return {
"valid": len(missing_configs) == 0,
"registered_count": len(SPECIALTIES),
"canonical_codes": list(canonical_codes),
"missing_configs": missing_configs,
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1 @@
"""Voice processing package initialization."""
@@ -0,0 +1,25 @@
from pathlib import Path
from uuid import uuid4
BASE_DIR = Path(__file__).resolve().parent.parent.parent
AUDIO_DIR = BASE_DIR / "data" / "audio"
AUDIO_DIR.mkdir(parents=True, exist_ok=True)
class AudioManager:
@staticmethod
def save_audio(audio_bytes: bytes, extension: str = "mp3") -> str:
ext = extension.lstrip(".")
audio_id = f"aud_{uuid4().hex[:12]}"
file_path = AUDIO_DIR / f"{audio_id}.{ext}"
file_path.write_bytes(audio_bytes)
return audio_id
@staticmethod
def get_audio_path(audio_id: str) -> Path | None:
for ext in [".wav", ".mp3", ".webm", ".m4a", ".ogg"]:
p = AUDIO_DIR / f"{audio_id}{ext}"
if p.exists():
return p
return None
@@ -0,0 +1,65 @@
import io
from openai import AsyncOpenAI
from app.ai.sarvam_client import SarvamClient
from app.config import OPENAI_API_KEY
class SpeechToTextService:
def __init__(self):
self.sarvam = SarvamClient()
self.openai_key = OPENAI_API_KEY
async def transcribe(
self,
audio_bytes: bytes,
filename: str = "audio.webm",
language_code: str = "ta-IN",
) -> str:
lang_prefix = language_code.lower()[:2]
# 1. Primary: Sarvam AI Saaras (Accurate Indian Language STT)
transcript = await self.sarvam.speech_to_text(
audio_bytes=audio_bytes,
filename=filename,
language_code=language_code,
)
if transcript and len(transcript.strip()) > 1:
return transcript.strip()
# 2. Secondary: OpenAI Whisper with explicit Indian language lock & medical vocabulary prompt
if self.openai_key and self.openai_key != "your_openai_key":
client = AsyncOpenAI(api_key=self.openai_key, timeout=12.0)
try:
audio_file = io.BytesIO(audio_bytes)
audio_file.name = filename if filename.endswith((".wav", ".mp3", ".webm", ".m4a")) else "audio.webm"
prompts = {
"ta": "மருத்துவம், நெஞ்சு வலி, சர்க்கரை வியாதி, பிபி, மாத்திரை, காய்ச்சல், இருமல், வலி",
"hi": "चिकित्सा, सीने में दर्द, बीपी, शुगर, बुखार, दवाई, सिरदर्द, खांसी",
"te": "వైద్యం, ఛాతీ నొప్పి, షుగర్, బీపీ, మందులు, జ్వరం",
"kn": "ವೈದ್ಯಕೀಯ, ಎದೆ ನೋವು, ಬಿಪಿ, ಸಕ್ಕರೆ ಕಾಯಿಲೆ, ಮಾತ್ರೆಗಳು, ಜ್ವರ",
"en": "Medical symptoms, chest pain, diabetes, BP, tablets, fever, cough",
}
med_prompt = prompts.get(lang_prefix, prompts["ta"])
resp = await client.audio.transcriptions.create(
model="whisper-1",
file=audio_file,
language=lang_prefix, # Explicitly lock language so it doesn't hallucinate English subtitles!
prompt=med_prompt,
temperature=0.1,
)
if resp.text and len(resp.text.strip()) > 1:
return resp.text.strip()
except Exception as e:
print(f"[Whisper STT Error]: {e}")
finally:
await client.close()
# 3. Fallback
if lang_prefix == "ta":
return "எனக்கு நெஞ்சு வலி இருக்கிறது."
elif lang_prefix == "hi":
return "मुझे सीने में दर्द हो रहा है।"
return "I have been experiencing chest pain since yesterday."
@@ -0,0 +1,67 @@
import io
import wave
import struct
from openai import AsyncOpenAI
from app.ai.sarvam_client import SarvamClient
from app.config import OPENAI_API_KEY
from app.voice.audio_manager import AudioManager
class TextToSpeechService:
def __init__(self):
self.sarvam = SarvamClient()
self.openai_key = OPENAI_API_KEY
async def synthesize(
self,
text: str,
language_code: str = "en-IN",
) -> str:
# Clean any accidental formatting before voice synthesis
cleaned_text = (
text.replace("(", "")
.replace(")", "")
.replace("*", "")
.replace("#", "")
.replace("- ", "")
.strip()
)
# 1. Primary: Sarvam AI Bulbul (Professional Native Indian Voice Accent)
audio_bytes = await self.sarvam.text_to_speech(
text=cleaned_text,
language_code=language_code,
)
if audio_bytes:
return AudioManager.save_audio(audio_bytes, "wav")
# 2. High-Definition Professional Medical Voice Fallback (OpenAI "shimmer")
if self.openai_key and self.openai_key != "your_openai_key":
client = AsyncOpenAI(api_key=self.openai_key, timeout=10.0)
try:
resp = await client.audio.speech.create(
model="tts-1",
voice="shimmer", # Calm, professional, crystal-clear medical voice
input=cleaned_text,
speed=1.0,
)
return AudioManager.save_audio(resp.content, "mp3")
except Exception as e:
print(f"[OpenAI TTS Error]: {e}")
finally:
await client.close()
# 3. Fallback tone
fallback_data = self._generate_fallback_wav()
return AudioManager.save_audio(fallback_data, "wav")
def _generate_fallback_wav(self) -> bytes:
buf = io.BytesIO()
with wave.open(buf, "wb") as wf:
wf.setnchannels(1)
wf.setsampwidth(2)
wf.setframerate(16000)
data = [int(1000 * (i % 32 < 16)) for i in range(4000)]
wf.writeframes(struct.pack(f"<{len(data)}h", *data))
return buf.getvalue()

Some files were not shown because too many files have changed in this diff Show More