Compare commits
15
Commits
main
...
dev_upgrade
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37440993f7 | ||
|
|
c23d979338 | ||
|
|
1d794ca425 | ||
|
|
0cd932dbce | ||
|
|
e91cdbae6e | ||
|
|
4e21d617ce | ||
|
|
29475dffcf | ||
|
|
80eaefadb2 | ||
|
|
15e8b2dfec | ||
|
|
dc2799a95b | ||
|
|
5e500dcfbc | ||
|
|
f66ade7051 | ||
|
|
1645d9b1a8 | ||
|
|
3d2596ced1 | ||
|
|
59c1372fe3 |
+45
@@ -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
|
||||
@@ -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}'
|
||||
```
|
||||
@@ -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/*
|
||||
@@ -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=""
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
```
|
||||
@@ -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 @@
|
||||
"""App package initialization."""
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,106 @@
|
||||
import math
|
||||
import re
|
||||
import struct
|
||||
from typing import Dict, Any, List, Tuple
|
||||
|
||||
HIGH_PAIN_PATTERNS = [
|
||||
r"(?:10/10|9/10|8/10|10\s*out\s*of\s*10|9\s*out\s*of\s*10|8\s*out\s*of\s*10)",
|
||||
r"(?:unbearable|excruciating|worst\s*pain|extreme\s*pain|severe\s*chest\s*pain|cannot\s*breathe|gasping|dying)",
|
||||
r"(?:கடுமையான\s*வலி|தாங்க\s*முடியாத\s*வலி|நெஞ்சு\s*வலி|மூச்சு\s*திணறல்)",
|
||||
r"(?:असहनीय\s*दर्द|बहुत\s*तेज़\s*दर्द|सीने\s*में\s*दर्द|साँस\s*नहीं\s*आ\s*रही)",
|
||||
r"(?:చాలా\s*ఎక్కువ\s*నెప్పి|భరించలేని\s*నెప్పి|గుండె\s*నెప్పి)",
|
||||
r"(?:ಅಸಹನೀಯ\s*ನೋವು|ತೀವ್ರ\s*ನೋವು)"
|
||||
]
|
||||
|
||||
MODERATE_PAIN_PATTERNS = [
|
||||
r"(?:7/10|6/10|5/10|moderate\s*pain|bad\s*pain|hurts\s*a\s*lot|வலி\s*அதிகமா|अत्यधिक\s*दर्द)",
|
||||
]
|
||||
|
||||
EMERGENCY_RED_FLAGS = [
|
||||
r"(?:chest\s*pain|heart\s*attack|stroke|paralysis|unconscious|fainted|heavy\s*bleeding)",
|
||||
r"(?:நெஞ்சு\s*வலி|பக்கவாதம்|மயக்கம்|அதிக\s*ரத்தப்போக்கு)",
|
||||
r"(?:सीने\s*में\s*दर्द|हार्ट\s*अटैक|बेहोश|रक्तस्राव)"
|
||||
]
|
||||
|
||||
|
||||
def calculate_audio_rms_energy(audio_bytes: bytes) -> float:
|
||||
"""
|
||||
Computes RMS (Root Mean Square) audio amplitude energy from raw mic bytes.
|
||||
Returns normalized energy float between 0.0 and 1.0.
|
||||
"""
|
||||
if not audio_bytes or len(audio_bytes) < 100:
|
||||
return 0.0
|
||||
|
||||
try:
|
||||
# Unpack 16-bit PCM audio samples
|
||||
sample_count = len(audio_bytes) // 2
|
||||
if sample_count == 0:
|
||||
return 0.0
|
||||
|
||||
fmt = f"<{sample_count}h"
|
||||
samples = struct.unpack(fmt, audio_bytes[:sample_count * 2])
|
||||
|
||||
sum_squares = sum(s ** 2 for s in samples)
|
||||
rms = math.sqrt(sum_squares / sample_count)
|
||||
|
||||
# Normalize 16-bit max amplitude (32768)
|
||||
norm_energy = min(1.0, rms / 15000.0)
|
||||
return round(norm_energy, 3)
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
|
||||
def analyze_distress_and_pain(audio_bytes: bytes = b"", text: str = "") -> Dict[str, Any]:
|
||||
"""
|
||||
Acoustic & Semantic Distress Classifier:
|
||||
Analyzes spoken text and audio RMS energy to compute Pain Score (1-10) and Distress Score (0.0-1.0).
|
||||
"""
|
||||
txt_lower = (text or "").lower()
|
||||
|
||||
pain_score = 3
|
||||
distress_score = 0.2
|
||||
is_emergency = False
|
||||
reasons: List[str] = []
|
||||
|
||||
# 1. Semantic High Pain Detection
|
||||
for pat in HIGH_PAIN_PATTERNS:
|
||||
if re.search(pat, txt_lower, re.IGNORECASE):
|
||||
pain_score = max(pain_score, 9)
|
||||
distress_score = max(distress_score, 0.85)
|
||||
reasons.append("Severe high pain expression detected")
|
||||
break
|
||||
|
||||
# 2. Semantic Moderate Pain Detection
|
||||
if pain_score < 8:
|
||||
for pat in MODERATE_PAIN_PATTERNS:
|
||||
if re.search(pat, txt_lower, re.IGNORECASE):
|
||||
pain_score = max(pain_score, 6)
|
||||
distress_score = max(distress_score, 0.55)
|
||||
reasons.append("Moderate pain expression detected")
|
||||
break
|
||||
|
||||
# 3. Emergency Red Flag Detection
|
||||
for pat in EMERGENCY_RED_FLAGS:
|
||||
if re.search(pat, txt_lower, re.IGNORECASE):
|
||||
is_emergency = True
|
||||
pain_score = max(pain_score, 9)
|
||||
distress_score = max(distress_score, 0.90)
|
||||
reasons.append("Critical emergency red flag detected")
|
||||
break
|
||||
|
||||
# 4. Acoustic Audio RMS Energy Boost
|
||||
acoustic_energy = calculate_audio_rms_energy(audio_bytes)
|
||||
if acoustic_energy > 0.6:
|
||||
distress_score = min(1.0, distress_score + 0.15)
|
||||
reasons.append(f"High vocal acoustic energy detected (RMS: {acoustic_energy})")
|
||||
|
||||
should_escalate = pain_score >= 8 or distress_score >= 0.75 or is_emergency
|
||||
|
||||
return {
|
||||
"pain_score": pain_score,
|
||||
"distress_score": round(distress_score, 2),
|
||||
"acoustic_rms_energy": acoustic_energy,
|
||||
"is_emergency": is_emergency,
|
||||
"should_escalate": should_escalate,
|
||||
"reasons": reasons
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
|
||||
import re
|
||||
from typing import Dict, Any, List
|
||||
|
||||
# RxNorm / FDA Clinical Interaction Rules
|
||||
KNOWN_DRUG_INTERACTIONS = [
|
||||
{
|
||||
"pair": ("warfarin", "aspirin"),
|
||||
"severity": "CRITICAL",
|
||||
"category": "Drug-Drug Interaction",
|
||||
"description": "Concurrent use of Warfarin and Aspirin significantly increases risk of major gastrointestinal and systemic bleeding."
|
||||
},
|
||||
{
|
||||
"pair": ("metformin", "ibuprofen"),
|
||||
"severity": "WARNING",
|
||||
"category": "Drug-Drug Interaction",
|
||||
"description": "NSAIDs like Ibuprofen may impair renal function, increasing risk of Metformin-associated lactic acidosis."
|
||||
},
|
||||
{
|
||||
"pair": ("lisinopril", "spironolactone"),
|
||||
"severity": "WARNING",
|
||||
"category": "Drug-Drug Interaction",
|
||||
"description": "Combining ACE inhibitors with Potassium-sparing diuretics increases risk of severe hyperkalemia."
|
||||
},
|
||||
{
|
||||
"pair": ("ssri", "tramadol"),
|
||||
"severity": "CRITICAL",
|
||||
"category": "Drug-Drug Interaction",
|
||||
"description": "Combining SSRI antidepressants with Tramadol increases risk of Serotonin Syndrome and seizures."
|
||||
}
|
||||
]
|
||||
|
||||
# Drug-Allergy Cross-Reactivity Rules
|
||||
ALLERGY_CROSS_REACTIVITY = [
|
||||
{
|
||||
"allergy_pattern": r"penicillin|amoxicillin|ampicillin",
|
||||
"drug_pattern": r"penicillin|amoxicillin|ampicillin|augmentin|piperacillin",
|
||||
"severity": "CRITICAL",
|
||||
"category": "Drug-Allergy Contraindication",
|
||||
"description": "Patient has reported Penicillin allergy. Prescribing beta-lactam antibiotics poses high risk of severe anaphylaxis."
|
||||
},
|
||||
{
|
||||
"allergy_pattern": r"sulfa|sulfonamide",
|
||||
"drug_pattern": r"bactrim|septra|celecoxib|sulfamethoxazole",
|
||||
"severity": "WARNING",
|
||||
"category": "Drug-Allergy Contraindication",
|
||||
"description": "Patient has reported Sulfa allergy. Use caution with sulfonamide medications."
|
||||
},
|
||||
{
|
||||
"allergy_pattern": r"aspirin|nsaid",
|
||||
"drug_pattern": r"aspirin|ibuprofen|naproxen|diclofenac",
|
||||
"severity": "CRITICAL",
|
||||
"category": "Drug-Allergy Contraindication",
|
||||
"description": "Patient has reported NSAID/Aspirin hypersensitivity. Risk of acute bronchospasm and urticaria."
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def check_drug_safety(medications: List[Any], allergies: List[Any], medical_history: List[Any] = None) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Cross-references patient medications, reported allergies, and past history
|
||||
against RxNorm / FDA rules to flag drug-drug interactions and drug-allergy contraindications.
|
||||
"""
|
||||
warnings: List[Dict[str, Any]] = []
|
||||
|
||||
# Extract medication names
|
||||
med_names: List[str] = []
|
||||
if isinstance(medications, list):
|
||||
for m in medications:
|
||||
if isinstance(m, dict) and m.get("name"):
|
||||
med_names.append(m["name"].lower().strip())
|
||||
elif isinstance(m, str):
|
||||
med_names.append(m.lower().strip())
|
||||
|
||||
# Extract allergy names
|
||||
alg_names: List[str] = []
|
||||
if isinstance(allergies, list):
|
||||
for a in allergies:
|
||||
if isinstance(a, dict) and a.get("name"):
|
||||
alg_names.append(a["name"].lower().strip())
|
||||
elif isinstance(a, str):
|
||||
alg_names.append(a.lower().strip())
|
||||
|
||||
# 1. Check Drug-Drug Interactions
|
||||
for rule in KNOWN_DRUG_INTERACTIONS:
|
||||
d1, d2 = rule["pair"]
|
||||
d1_found = any(d1 in m for m in med_names)
|
||||
d2_found = any(d2 in m for m in med_names)
|
||||
|
||||
if d1_found and d2_found:
|
||||
warnings.append({
|
||||
"severity": rule["severity"],
|
||||
"category": rule["category"],
|
||||
"trigger_drugs": [d1.capitalize(), d2.capitalize()],
|
||||
"description": rule["description"]
|
||||
})
|
||||
|
||||
# 2. Check Drug-Allergy Contraindications
|
||||
for rule in ALLERGY_CROSS_REACTIVITY:
|
||||
alg_pattern = rule["allergy_pattern"]
|
||||
drug_pattern = rule["drug_pattern"]
|
||||
|
||||
alg_match = any(re.search(alg_pattern, alg, re.IGNORECASE) for alg in alg_names)
|
||||
drug_match = any(re.search(drug_pattern, med, re.IGNORECASE) for med in med_names)
|
||||
|
||||
if alg_match and drug_match:
|
||||
warnings.append({
|
||||
"severity": rule["severity"],
|
||||
"category": rule["category"],
|
||||
"trigger_allergy": alg_pattern.capitalize(),
|
||||
"trigger_drug": drug_pattern.capitalize(),
|
||||
"description": rule["description"]
|
||||
})
|
||||
|
||||
return warnings
|
||||
@@ -0,0 +1,98 @@
|
||||
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
|
||||
|
||||
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:
|
||||
import google.generativeai as genai
|
||||
genai.configure(api_key=self.api_key)
|
||||
model = genai.GenerativeModel(self.model or "gemini-1.5-flash")
|
||||
|
||||
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 = {
|
||||
"mime_type": mime_type,
|
||||
"data": file_bytes,
|
||||
}
|
||||
response = await asyncio.wait_for(
|
||||
model.generate_content_async([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")
|
||||
|
||||
import google.generativeai as genai
|
||||
genai.configure(api_key=self.api_key)
|
||||
model = genai.GenerativeModel(
|
||||
model_name=self.model or "gemini-1.5-flash",
|
||||
generation_config={"response_mime_type": "application/json"},
|
||||
)
|
||||
full_prompt = f"{system_prompt}\n\nPATIENT SAID: {user_message}"
|
||||
response = await asyncio.wait_for(
|
||||
model.generate_content_async(full_prompt),
|
||||
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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,69 @@
|
||||
def get_conversation_rules() -> str:
|
||||
"""Dynamic conversation philosophy — ASK LESS, LISTEN MORE, EXTRACT MORE."""
|
||||
return """CONVERSATION PHILOSOPHY — ASK LESS, LISTEN MORE, EXTRACT MORE:
|
||||
|
||||
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. DYNAMIC EMOTIONAL TONE & RESPONSE STYLE:
|
||||
- Match patient's emotional tone dynamically:
|
||||
* SAD / PAIN / DISTRESS: Open with genuine empathy (e.g. "Sorry to hear. Any BP or sugar?", "Oh dear! How long has it been?")
|
||||
* JOYFUL / REASSURED / NEGATIVE FORMULATION: Open with warmth (e.g. "Great! Do you take daily tablets?", "Wonderful! Any other symptoms?")
|
||||
- DO NOT use static prefillers ("Got it."). Allow OpenAI to generate dynamic, natural questions directly.
|
||||
- Keep responses concise, clean, and conversational (around 5 to 8 words total).
|
||||
- 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."""
|
||||
@@ -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 108 ambulance immediately.
|
||||
Example: "This needs urgent medical attention. Please call 108 for an ambulance 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) and emergency 108.
|
||||
Example: "If you're in distress, please reach out to Tele-MANAS at 14416 or call 108 right now. I've noted your details for the doctor."
|
||||
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 108", "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) and emergency 108 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.""",
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
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: ASK LESS -> LISTEN MORE -> EXTRACT MORE.
|
||||
Composed of language, clinical, conversation, extraction, specialty, and safety rule modules.
|
||||
"""
|
||||
specialty = context.get("specialty_config", {})
|
||||
patient = context.get("patient", {})
|
||||
language = context.get("language", "ta-IN")
|
||||
stage = context.get("current_stage", "greeting")
|
||||
current_state = context.get("current_state", {})
|
||||
conversation_history = context.get("conversation_history", [])
|
||||
new_patient_message = context.get("new_patient_message", "")
|
||||
|
||||
patient_name = patient.get("name") or "Patient"
|
||||
patient_age = patient.get("age") or "Not provided"
|
||||
patient_gender = patient.get("gender") or "Not provided"
|
||||
|
||||
# Assemble modular rules
|
||||
lang_rules = get_language_rules(language)
|
||||
clin_rules = get_clinical_rules()
|
||||
priv_rules = get_privacy_rules()
|
||||
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
|
||||
|
||||
from app.services.contextual_normalizer import detect_previous_question_intent
|
||||
expected_intent = detect_previous_question_intent(last_ai_message)
|
||||
intent_hint = f"\nPREVIOUS AI QUESTION INTENT: {expected_intent.upper()} (Interpret patient's reply as providing {expected_intent} for the primary symptom, NOT a new symptom entity)." if expected_intent else ""
|
||||
|
||||
prompt = f"""You are Nila, an empathetic, highly skilled AI Clinical Intake Assistant at a hospital/clinic.
|
||||
Your job is to collect preliminary clinical intake information for the doctor from the patient: {patient_name} (Age: {patient_age}, Gender: {patient_gender}).
|
||||
|
||||
{lang_rules}
|
||||
|
||||
{clin_rules}
|
||||
|
||||
{safe_rules}
|
||||
|
||||
{conv_rules}
|
||||
|
||||
{spec_rules}
|
||||
|
||||
{extr_rules}
|
||||
|
||||
{priv_rules}
|
||||
|
||||
CURRENT INTAKE STATE (What has already been gathered):
|
||||
{json.dumps(current_state, indent=2, ensure_ascii=False)}
|
||||
|
||||
RECENT CONVERSATION HISTORY:
|
||||
{json.dumps(conversation_history[-8:], indent=2, ensure_ascii=False)}
|
||||
|
||||
PREVIOUS AI QUESTION:
|
||||
"{last_ai_message}"{intent_hint}
|
||||
|
||||
LATEST PATIENT INPUT:
|
||||
"{new_patient_message}"
|
||||
|
||||
CRITICAL INSTRUCTIONS FOR THIS TURN:
|
||||
1. Extract ALL clinically relevant facts from the latest patient input. If the input is answering the previous AI question (e.g. duration, severity, location), attach it to the existing symptom in CURRENT INTAKE STATE. DO NOT create a new symptom item for duration/severity statements.
|
||||
2. DO NOT re-ask for any information already present in CURRENT INTAKE STATE or clearly stated in this input. NEVER ask duration again if duration is provided.
|
||||
3. If the patient stated "Nothing else", "No", "None" for a SPECIFIC category that was just asked about, set that category to confirmed_none. But do NOT complete the intake unless ALL required categories are collected or confirmed_none.
|
||||
4. If an important clinical gap remains, formulate ONE natural, empathetic question addressing ONLY the most relevant missing item.
|
||||
5. DYNAMIC EMOTIONAL TONE & QUESTION FORMULATION:
|
||||
- Match the patient's tone naturally:
|
||||
* SAD / PAIN / SEVERE SYMPTOMS: Start with genuine empathy (e.g., "Sorry to hear. Do you have BP or sugar?", "Oh dear! How long has it been?").
|
||||
* POSITIVE / NO ILLNESS / NEGATION: Start with a warm, encouraging tone (e.g., "Great! Taking any daily medications?", "Wonderful! Any other symptoms?").
|
||||
- DO NOT use static prefillers like "Got it." repeatedly. Let OpenAI generate a dynamic, natural response directly.
|
||||
- Keep the reply concise, conversational, and direct (around 5 to 8 words total).
|
||||
|
||||
COMPLETION RULES — MANDATORY:
|
||||
- NEVER set "completed": true unless ALL of the following are satisfied:
|
||||
A. reason_for_visit is reported (has a value)
|
||||
B. At least one symptom exists with duration information
|
||||
C. medical_history is either "reported" with items OR explicitly "confirmed_none"
|
||||
D. medications is either "reported" with items OR explicitly "confirmed_none"
|
||||
E. allergies is either "reported" with items OR explicitly "confirmed_none"
|
||||
- "not_collected" is NOT the same as "confirmed_none". A field with status "not_collected" means you MUST still ask about it.
|
||||
- If the patient says "nothing else" but required fields are still "not_collected", you MUST continue asking. Do NOT complete early.
|
||||
- If the patient provides medical_history + medications + allergies in a single message, extract ALL of them and skip those questions.
|
||||
|
||||
|
||||
OUTPUT FORMAT REQUIREMENTS:
|
||||
You MUST respond with valid JSON matching EXACTLY this structure:
|
||||
{{
|
||||
"reply": "Warm, natural spoken response acknowledging input and asking one relevant missing question or giving closing confirmation",
|
||||
"next_stage": "reason_for_visit | symptom_collection | symptom_details | medications | allergies | confirmation",
|
||||
"state_updates": {{
|
||||
"reason_for_visit": "primary reason if identified or null",
|
||||
"symptoms": [
|
||||
{{
|
||||
"name": "symptom name",
|
||||
"duration": "duration if stated or null",
|
||||
"severity": "mild/moderate/severe if stated or null",
|
||||
"location": "body location if stated or null",
|
||||
"onset": "sudden/gradual if stated or null",
|
||||
"frequency": "how often if stated or null",
|
||||
"pattern": "continuous/intermittent if stated or null",
|
||||
"triggers": "trigger factors if stated or null",
|
||||
"relieving_factors": "relieving factors if stated or null",
|
||||
"aggravating_factors": "aggravating factors if stated or null"
|
||||
}}
|
||||
],
|
||||
"medical_history": [
|
||||
{{
|
||||
"condition": "condition name",
|
||||
"duration": "duration if stated or null",
|
||||
"status": "active/past if stated or null"
|
||||
}}
|
||||
],
|
||||
"medications": [
|
||||
{{
|
||||
"name": "medication name",
|
||||
"dosage": "dosage if stated or null",
|
||||
"frequency": "frequency if stated or null",
|
||||
"duration": "duration if stated or null"
|
||||
}}
|
||||
],
|
||||
"allergies": [
|
||||
{{
|
||||
"name": "allergen name",
|
||||
"type": "drug/food/environment if known or null",
|
||||
"reaction": "reaction if stated or null"
|
||||
}}
|
||||
],
|
||||
"surgical_history": [
|
||||
{{
|
||||
"procedure": "procedure name",
|
||||
"approximate_date": "when if stated or null",
|
||||
"reason": "reason if stated or null"
|
||||
}}
|
||||
],
|
||||
"previous_episodes": [
|
||||
{{
|
||||
"description": "episode description",
|
||||
"time": "when if stated or null"
|
||||
}}
|
||||
],
|
||||
"family_history": [
|
||||
{{
|
||||
"condition": "condition name",
|
||||
"relationship": "family member if stated or null"
|
||||
}}
|
||||
],
|
||||
"additional_information": [
|
||||
{{
|
||||
"category": "functional_impact | lifestyle | dietary | other",
|
||||
"value": "details stated by patient"
|
||||
}}
|
||||
]
|
||||
}},
|
||||
"completed": false
|
||||
}}
|
||||
"""
|
||||
return prompt
|
||||
@@ -0,0 +1,86 @@
|
||||
import base64
|
||||
import httpx2
|
||||
from app.config import SARVAM_API_KEY
|
||||
|
||||
|
||||
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=12.0,
|
||||
limits=httpx2.Limits(max_keepalive_connections=5, max_connections=10),
|
||||
)
|
||||
return SarvamClient._client
|
||||
|
||||
async def speech_to_text(
|
||||
self,
|
||||
audio_bytes: bytes,
|
||||
filename: str = "audio.webm",
|
||||
language_code: str = "ta-IN",
|
||||
) -> str:
|
||||
if not self.api_key or self.api_key == "your_sarvam_key":
|
||||
return ""
|
||||
|
||||
client = self._get_client()
|
||||
files = {"file": (filename, audio_bytes, "audio/webm")}
|
||||
data = {"model": "saaras:v2", "language_code": language_code}
|
||||
headers = {"api-subscription-key": self.api_key}
|
||||
|
||||
try:
|
||||
res = await client.post(
|
||||
f"{self.base_url}/speech-to-text",
|
||||
files=files,
|
||||
data=data,
|
||||
headers=headers,
|
||||
)
|
||||
if res.status_code == 200:
|
||||
return res.json().get("transcript", "").strip()
|
||||
except Exception as e:
|
||||
print(f"[Sarvam STT Error]: {e}")
|
||||
return ""
|
||||
|
||||
async def text_to_speech(
|
||||
self,
|
||||
text: str,
|
||||
language_code: str = "ta-IN",
|
||||
pace: float = 1.0,
|
||||
) -> bytes | None:
|
||||
if not self.api_key or self.api_key == "your_sarvam_key":
|
||||
return None
|
||||
|
||||
lang_prefix = language_code.lower()[:2]
|
||||
speaker = "kavya"
|
||||
|
||||
client = self._get_client()
|
||||
headers = {
|
||||
"api-subscription-key": self.api_key,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
payload = {
|
||||
"inputs": [text],
|
||||
"target_language_code": language_code,
|
||||
"speaker": speaker,
|
||||
"model": "bulbul:v3",
|
||||
"pace": pace,
|
||||
}
|
||||
try:
|
||||
res = await client.post(
|
||||
f"{self.base_url}/text-to-speech",
|
||||
json=payload,
|
||||
headers=headers,
|
||||
)
|
||||
if res.status_code == 200:
|
||||
audios = res.json().get("audios", [])
|
||||
if audios:
|
||||
return base64.b64decode(audios[0])
|
||||
except Exception as e:
|
||||
print(f"[Sarvam TTS Error]: {e}")
|
||||
return None
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import re
|
||||
from typing import Dict, Any, Tuple
|
||||
|
||||
CAREGIVER_PATTERNS = [
|
||||
(r"(?:my\s*mother|my\s*mom|என்\s*அம்மா|அம்மாவுக்கு|मेरी\s*माँ|माँ\s*को|నా\s*తల్లి)", "mother"),
|
||||
(r"(?:my\s*father|my\s*dad|என்\s*அப்பா|அப்பாவுக்கு|मेरे\s*पिता|पिता\s*जी|నా\s*తండ్రి)", "father"),
|
||||
(r"(?:my\s*wife|my\s*husband|என்\s*மனைவி|என்\s*கணவர்|मेरी\s*पत्नी|मेरे\s*पति|నా\s*భార్య|నా\s*భర్త)", "spouse"),
|
||||
(r"(?:my\s*son|my\s*daughter|my\s*child|my\s*kid|என்\s*மகன்|என்\s*மகள்|என்\s*குழந்தை|मेरा\s*बेटा|मेरी\s*बेटी|నా\s*కొడుకు)", "child"),
|
||||
(r"(?:he\s*is|she\s*is|his\s*symptoms|her\s*symptoms|he\s*has|she\s*has|அவருக்கு|அவளுக்கு|उसको|उन्हें)", "relative")
|
||||
]
|
||||
|
||||
PATIENT_PATTERNS = [
|
||||
r"(?:i\s*have|i\s*am|my\s*headache|my\s*chest|my\s*fever|my\s*pain|எனக்கு|எனக்குள்ள|मुझे|मेरे|నాకు)"
|
||||
]
|
||||
|
||||
|
||||
def classify_speaker_turn(text: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Dual-Speaker Diarization Classifier:
|
||||
Differentiates between direct Patient statements (1st Person) and Caregiver / Relative statements (3rd Person).
|
||||
Returns dictionary with speaker_type ('patient' vs 'caregiver'), relation, and formatted_label.
|
||||
"""
|
||||
txt_lower = (text or "").lower().strip()
|
||||
|
||||
# 1. Check Caregiver / Relative Patterns
|
||||
for pattern, relation in CAREGIVER_PATTERNS:
|
||||
if re.search(pattern, txt_lower, re.IGNORECASE):
|
||||
label = f"Caregiver ({relation.capitalize()})" if relation != "relative" else "Caregiver"
|
||||
return {
|
||||
"speaker_type": "caregiver",
|
||||
"relation": relation,
|
||||
"formatted_label": label,
|
||||
"is_caregiver": True
|
||||
}
|
||||
|
||||
# 2. Default to Patient Direct Statement
|
||||
return {
|
||||
"speaker_type": "patient",
|
||||
"relation": "self",
|
||||
"formatted_label": "Patient",
|
||||
"is_caregiver": False
|
||||
}
|
||||
@@ -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."""
|
||||
@@ -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.",
|
||||
)
|
||||
@@ -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.",
|
||||
)
|
||||
@@ -0,0 +1,4 @@
|
||||
from app.api.voice_onboarding import router
|
||||
|
||||
# Export the router
|
||||
__all__ = ["router"]
|
||||
@@ -0,0 +1,533 @@
|
||||
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, WebSocket
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.services.websocket_voice_manager import WebSocketVoiceManager
|
||||
|
||||
ws_manager = WebSocketVoiceManager()
|
||||
|
||||
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))
|
||||
|
||||
|
||||
@router.websocket("/ws/{session_id}")
|
||||
async def websocket_voice_onboarding_endpoint(websocket: WebSocket, session_id: str):
|
||||
await ws_manager.handle_websocket_session(websocket, session_id)
|
||||
|
||||
|
||||
@router.get("/fhir/{session_id}")
|
||||
async def get_session_fhir_export(
|
||||
session_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
auth: AuthContext = Depends(get_auth_context)
|
||||
):
|
||||
"""
|
||||
Returns production-grade HL7 FHIR R4 Bundle JSON for 1-click import into hospital EMR/EHR systems (Epic, Cerner, AthenaHealth).
|
||||
Includes Patient, Encounter, Condition (Symptoms & History), MedicationStatement, and AllergyIntolerance resources.
|
||||
"""
|
||||
from app.services.fhir_exporter import export_session_to_fhir_bundle
|
||||
try:
|
||||
fhir_bundle = export_session_to_fhir_bundle(session_id, db)
|
||||
return fhir_bundle
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"FHIR Export Error: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/soap-note/{session_id}")
|
||||
async def get_session_soap_note_export(
|
||||
session_id: str,
|
||||
format: str = "json",
|
||||
db: Session = Depends(get_db),
|
||||
auth: AuthContext = Depends(get_auth_context)
|
||||
):
|
||||
"""
|
||||
Compiles completed intake into a formal Doctor SOAP Note (Subjective, Objective, Assessment, Plan) with candidate ICD-10 & SNOMED CT codes.
|
||||
Pass format=html to download printable HTML report.
|
||||
"""
|
||||
from app.services.soap_note_generator import generate_soap_note, generate_soap_html_report
|
||||
from fastapi.responses import HTMLResponse
|
||||
try:
|
||||
soap_note = generate_soap_note(session_id, db)
|
||||
if format.lower() == "html":
|
||||
html_content = generate_soap_html_report(soap_note)
|
||||
return HTMLResponse(content=html_content)
|
||||
return soap_note
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"SOAP Note Error: {str(e)}")
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
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", "")
|
||||
|
||||
# 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",
|
||||
"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
|
||||
|
||||
@@ -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,47 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
from dotenv import load_dotenv
|
||||
from sqlalchemy import create_engine
|
||||
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
|
||||
},
|
||||
)
|
||||
|
||||
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,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.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)
|
||||
|
||||
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import FileResponse
|
||||
import os
|
||||
|
||||
docs_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "docs")
|
||||
if os.path.exists(docs_dir):
|
||||
app.mount("/static", StaticFiles(directory=docs_dir), name="static")
|
||||
|
||||
@app.get("/")
|
||||
@app.get("/dashboard")
|
||||
async def serve_dashboard():
|
||||
dashboard_path = os.path.join(docs_dir, "index.html")
|
||||
if os.path.exists(dashboard_path):
|
||||
return FileResponse(dashboard_path)
|
||||
return {"message": "CureSelect AI Voice Backend Ready"}
|
||||
|
||||
@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,160 @@
|
||||
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)
|
||||
|
||||
|
||||
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,186 @@
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, Any, List, Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from app.database.models import OnboardingSession, OnboardingState, OnboardingMessage
|
||||
|
||||
|
||||
def export_session_to_fhir_bundle(session_id: str, db: Session) -> Dict[str, Any]:
|
||||
"""
|
||||
Exports a completed or in-progress onboarding session state into standard HL7 FHIR R4 Bundle.
|
||||
Resources Generated:
|
||||
1. Patient (Demographics)
|
||||
2. Encounter (Clinical Intake Session)
|
||||
3. Condition (Symptoms & Past Medical History)
|
||||
4. MedicationStatement (Current Active Medications)
|
||||
5. AllergyIntolerance (Confirmed Allergies)
|
||||
"""
|
||||
sess = db.query(OnboardingSession).filter(OnboardingSession.session_id == session_id).first()
|
||||
if not sess:
|
||||
raise ValueError(f"Session {session_id} not found.")
|
||||
|
||||
state_obj = db.query(OnboardingState).filter(OnboardingState.session_id == session_id).first()
|
||||
state_data = json.loads(state_obj.state_json) if state_obj and state_obj.state_json else {}
|
||||
|
||||
now_iso = datetime.now(timezone.utc).isoformat()
|
||||
patient_ref = f"Patient/{sess.patient_id or 'PAT-UNKNOWN'}"
|
||||
encounter_id = f"enc-{session_id.lower()}"
|
||||
|
||||
entries: List[Dict[str, Any]] = []
|
||||
|
||||
# 1. FHIR Patient Resource
|
||||
patient_resource = {
|
||||
"resourceType": "Patient",
|
||||
"id": sess.patient_id or "PAT-UNKNOWN",
|
||||
"identifier": [{
|
||||
"system": "http://hospital.org/patients",
|
||||
"value": sess.patient_id or "PAT-UNKNOWN"
|
||||
}],
|
||||
"name": [{
|
||||
"text": sess.patient_name or "Patient",
|
||||
"family": (sess.patient_name or "Patient").split()[-1],
|
||||
"given": (sess.patient_name or "Patient").split()[:-1] or [sess.patient_name or "Patient"]
|
||||
}],
|
||||
"gender": sess.gender if sess.gender in ["male", "female", "other"] else "unknown"
|
||||
}
|
||||
entries.append({"resource": patient_resource})
|
||||
|
||||
# 2. FHIR Encounter Resource
|
||||
encounter_resource = {
|
||||
"resourceType": "Encounter",
|
||||
"id": encounter_id,
|
||||
"status": "finished" if sess.status == "completed" else "in-progress",
|
||||
"class": {
|
||||
"system": "http://terminology.hl7.org/CodeSystem/v3-ActCode",
|
||||
"code": "AMB",
|
||||
"display": "ambulatory"
|
||||
},
|
||||
"type": [{
|
||||
"coding": [{
|
||||
"system": "http://snomed.info/sct",
|
||||
"code": "32485007",
|
||||
"display": "Hospital admission intake"
|
||||
}],
|
||||
"text": sess.category_name or "General Intake"
|
||||
}],
|
||||
"subject": {"reference": patient_ref},
|
||||
"period": {
|
||||
"start": sess.created_at.isoformat() if sess.created_at else now_iso
|
||||
}
|
||||
}
|
||||
entries.append({"resource": encounter_resource})
|
||||
|
||||
# 3. FHIR Condition Resources (Symptoms)
|
||||
symptoms = state_data.get("symptoms", [])
|
||||
if isinstance(symptoms, list):
|
||||
for idx, sym in enumerate(symptoms):
|
||||
if isinstance(sym, dict) and sym.get("name"):
|
||||
cond_resource = {
|
||||
"resourceType": "Condition",
|
||||
"id": f"cond-sym-{idx+1}-{uuid4().hex[:4]}",
|
||||
"clinicalStatus": {
|
||||
"coding": [{
|
||||
"system": "http://terminology.hl7.org/CodeSystem/condition-clinical",
|
||||
"code": "active"
|
||||
}]
|
||||
},
|
||||
"category": [{
|
||||
"coding": [{
|
||||
"system": "http://terminology.hl7.org/CodeSystem/condition-category",
|
||||
"code": "encounter-diagnosis",
|
||||
"display": "Encounter Diagnosis"
|
||||
}]
|
||||
}],
|
||||
"code": {
|
||||
"text": sym["name"]
|
||||
},
|
||||
"subject": {"reference": patient_ref},
|
||||
"encounter": {"reference": f"Encounter/{encounter_id}"},
|
||||
"onsetDateTime": now_iso,
|
||||
"note": [{"text": f"Duration: {sym.get('duration', 'N/A')}, Severity: {sym.get('severity', 'N/A')}"}]
|
||||
}
|
||||
entries.append({"resource": cond_resource})
|
||||
|
||||
# 4. FHIR Condition Resources (Medical History)
|
||||
history = state_data.get("medical_history", [])
|
||||
if isinstance(history, list):
|
||||
for idx, hist in enumerate(history):
|
||||
cond_name = hist.get("condition") if isinstance(hist, dict) else str(hist)
|
||||
if cond_name:
|
||||
cond_resource = {
|
||||
"resourceType": "Condition",
|
||||
"id": f"cond-hist-{idx+1}-{uuid4().hex[:4]}",
|
||||
"clinicalStatus": {
|
||||
"coding": [{
|
||||
"system": "http://terminology.hl7.org/CodeSystem/condition-clinical",
|
||||
"code": "active"
|
||||
}]
|
||||
},
|
||||
"category": [{
|
||||
"coding": [{
|
||||
"system": "http://terminology.hl7.org/CodeSystem/condition-category",
|
||||
"code": "problem-list-item",
|
||||
"display": "Problem List Item"
|
||||
}]
|
||||
}],
|
||||
"code": {
|
||||
"text": cond_name
|
||||
},
|
||||
"subject": {"reference": patient_ref}
|
||||
}
|
||||
entries.append({"resource": cond_resource})
|
||||
|
||||
# 5. FHIR MedicationStatement Resources
|
||||
medications = state_data.get("medications", [])
|
||||
if isinstance(medications, list):
|
||||
for idx, med in enumerate(medications):
|
||||
med_name = med.get("name") if isinstance(med, dict) else str(med)
|
||||
dosage_str = med.get("dosage", "") if isinstance(med, dict) else ""
|
||||
if med_name:
|
||||
med_resource = {
|
||||
"resourceType": "MedicationStatement",
|
||||
"id": f"med-{idx+1}-{uuid4().hex[:4]}",
|
||||
"status": "active",
|
||||
"medicationCodeableConcept": {
|
||||
"text": med_name
|
||||
},
|
||||
"subject": {"reference": patient_ref},
|
||||
"dosage": [{"text": dosage_str}] if dosage_str else []
|
||||
}
|
||||
entries.append({"resource": med_resource})
|
||||
|
||||
# 6. FHIR AllergyIntolerance Resources
|
||||
allergies = state_data.get("allergies", [])
|
||||
if isinstance(allergies, list):
|
||||
for idx, alg in enumerate(allergies):
|
||||
alg_name = alg.get("name") if isinstance(alg, dict) else str(alg)
|
||||
if alg_name:
|
||||
alg_resource = {
|
||||
"resourceType": "AllergyIntolerance",
|
||||
"id": f"alg-{idx+1}-{uuid4().hex[:4]}",
|
||||
"clinicalStatus": {
|
||||
"coding": [{
|
||||
"system": "http://terminology.hl7.org/CodeSystem/allergyintolerance-clinical",
|
||||
"code": "active"
|
||||
}]
|
||||
},
|
||||
"code": {
|
||||
"text": alg_name
|
||||
},
|
||||
"patient": {"reference": patient_ref}
|
||||
}
|
||||
entries.append({"resource": alg_resource})
|
||||
|
||||
# Assemble FHIR R4 Bundle
|
||||
fhir_bundle = {
|
||||
"resourceType": "Bundle",
|
||||
"id": f"bundle-{session_id.lower()}",
|
||||
"type": "collection",
|
||||
"timestamp": now_iso,
|
||||
"total": len(entries),
|
||||
"entry": entries
|
||||
}
|
||||
|
||||
return fhir_bundle
|
||||
@@ -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,46 @@
|
||||
import re
|
||||
from typing import Dict, Any, Tuple, List
|
||||
|
||||
# PII / PHI Redaction Patterns for HIPAA, ABDM, GDPR Compliance
|
||||
PII_PATTERNS = [
|
||||
# 1. India Aadhaar 12-Digit Number
|
||||
(r"\b[2-9]\d{3}[-\s]?\d{4}[-\s]?\d{4}\b", "[REDACTED_AADHAAR]"),
|
||||
|
||||
# 2. India ABHA Health ID 14-Digit Number
|
||||
(r"\b\d{2}[-\s]?\d{4}[-\s]?\d{4}[-\s]?\d{4}\b", "[REDACTED_ABHA_ID]"),
|
||||
|
||||
# 3. Phone Numbers (Indian +91, 10-digit, US/Intl)
|
||||
(r"\b(?:\+?91[-\s]?)?[6-9]\d{9}\b", "[REDACTED_PHONE]"),
|
||||
(r"\b(?:\+?1[-\s]?)?\(?\d{3}\)?[-\s]?\d{3}[-\s]?\d{4}\b", "[REDACTED_PHONE]"),
|
||||
|
||||
# 4. Email Addresses
|
||||
(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b", "[REDACTED_EMAIL]"),
|
||||
|
||||
# 5. Social Security Number (US SSN)
|
||||
(r"\b\d{3}[-\s]?\d{2}[-\s]?\d{4}\b", "[REDACTED_SSN]"),
|
||||
|
||||
# 6. Credit Card / Debit Card Numbers (16 digits)
|
||||
(r"\b(?:\d{4}[-\s]?){3}\d{4}\b", "[REDACTED_PAYMENT]")
|
||||
]
|
||||
|
||||
|
||||
def anonymize_phi_text(text: str) -> Tuple[str, List[str]]:
|
||||
"""
|
||||
Scrubs sensitive PII/PHI identifiers from text before cloud storage or public broadcast.
|
||||
Returns (anonymized_text, list_of_redacted_types).
|
||||
"""
|
||||
if not text or not text.strip():
|
||||
return text, []
|
||||
|
||||
scrubbed_text = text
|
||||
redacted_categories: List[str] = []
|
||||
|
||||
for pattern, replacement in PII_PATTERNS:
|
||||
matches = re.findall(pattern, scrubbed_text, re.IGNORECASE)
|
||||
if matches:
|
||||
category_name = replacement.replace("[", "").replace("]", "").replace("REDACTED_", "")
|
||||
if category_name not in redacted_categories:
|
||||
redacted_categories.append(category_name)
|
||||
scrubbed_text = re.sub(pattern, replacement, scrubbed_text, flags=re.IGNORECASE)
|
||||
|
||||
return scrubbed_text, redacted_categories
|
||||
@@ -0,0 +1,212 @@
|
||||
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
|
||||
import google.generativeai as genai
|
||||
genai.configure(api_key=GEMINI_API_KEY)
|
||||
model = genai.GenerativeModel(
|
||||
GEMINI_MODEL or "gemini-1.5-flash",
|
||||
generation_config={"response_mime_type": "application/json"}
|
||||
)
|
||||
res = await asyncio.wait_for(model.generate_content_async(prompt), timeout=3.5)
|
||||
cleaned = res.text.replace("```json", "").replace("```", "").strip()
|
||||
parsed = json.loads(cleaned)
|
||||
validated = PatientOnboardingResult.model_validate(parsed)
|
||||
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,225 @@
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, Any, List
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database.models import OnboardingSession, OnboardingState, OnboardingMessage
|
||||
|
||||
# ICD-10 & SNOMED CT Clinical Terminology Dictionary
|
||||
ICD10_SNOMED_MAP: Dict[str, Dict[str, str]] = {
|
||||
"headache": {"icd10": "R51.9", "snomed": "25064002", "display": "Headache, unspecified"},
|
||||
"fever": {"icd10": "R50.9", "snomed": "386661006", "display": "Fever, unspecified"},
|
||||
"cough": {"icd10": "R05.9", "snomed": "49727002", "display": "Cough, unspecified"},
|
||||
"chest pain": {"icd10": "R07.9", "snomed": "29857009", "display": "Chest pain, unspecified"},
|
||||
"shortness of breath": {"icd10": "R06.02", "snomed": "267036007", "display": "Shortness of breath"},
|
||||
"diabetes": {"icd10": "E11.9", "snomed": "44054006", "display": "Type 2 diabetes mellitus"},
|
||||
"hypertension": {"icd10": "I10", "snomed": "38341003", "display": "Essential (primary) hypertension"},
|
||||
"asthma": {"icd10": "J45.909", "snomed": "195967001", "display": "Asthma, unspecified"}
|
||||
}
|
||||
|
||||
|
||||
def generate_soap_note(session_id: str, db: Session) -> Dict[str, Any]:
|
||||
"""
|
||||
Compiles an onboarding session into a formal Doctor SOAP Note
|
||||
(Subjective, Objective, Assessment, Plan) with candidate ICD-10 & SNOMED-CT codes.
|
||||
"""
|
||||
sess = db.query(OnboardingSession).filter(OnboardingSession.session_id == session_id).first()
|
||||
if not sess:
|
||||
raise ValueError(f"Session {session_id} not found.")
|
||||
|
||||
state_obj = db.query(OnboardingState).filter(OnboardingState.session_id == session_id).first()
|
||||
state_data = json.loads(state_obj.state_json) if state_obj and state_obj.state_json else {}
|
||||
|
||||
messages = (
|
||||
db.query(OnboardingMessage)
|
||||
.filter(OnboardingMessage.session_id == session_id)
|
||||
.order_by(OnboardingMessage.created_at.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
now_str = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
|
||||
|
||||
# 1. Subjective (S)
|
||||
symptoms = state_data.get("symptoms", [])
|
||||
chief_complaint = sess.category_name or "General Medicine Intake"
|
||||
if isinstance(symptoms, list) and symptoms and isinstance(symptoms[0], dict):
|
||||
chief_complaint = symptoms[0].get("name", chief_complaint).capitalize()
|
||||
|
||||
hpi_list = []
|
||||
if isinstance(symptoms, list):
|
||||
for s in symptoms:
|
||||
if isinstance(s, dict) and s.get("name"):
|
||||
hpi_list.append(f"{s['name'].capitalize()} (Duration: {s.get('duration', 'N/A')}, Severity: {s.get('severity', 'N/A')})")
|
||||
|
||||
history_list = []
|
||||
med_history = state_data.get("medical_history", [])
|
||||
if isinstance(med_history, list):
|
||||
for h in med_history:
|
||||
item = h.get("condition") if isinstance(h, dict) else str(h)
|
||||
if item:
|
||||
history_list.append(item)
|
||||
|
||||
medications_list = []
|
||||
meds = state_data.get("medications", [])
|
||||
if isinstance(meds, list):
|
||||
for m in meds:
|
||||
item = f"{m.get('name')} {m.get('dosage', '')}".strip() if isinstance(m, dict) else str(m)
|
||||
if item:
|
||||
medications_list.append(item)
|
||||
|
||||
allergies_list = []
|
||||
algs = state_data.get("allergies", [])
|
||||
if isinstance(algs, list):
|
||||
for a in algs:
|
||||
item = a.get("name") if isinstance(a, dict) else str(a)
|
||||
if item:
|
||||
allergies_list.append(item)
|
||||
|
||||
subjective = {
|
||||
"chief_complaint": chief_complaint,
|
||||
"history_of_present_illness": hpi_list or ["Patient reports general health query."],
|
||||
"past_medical_history": history_list or ["None reported"],
|
||||
"current_medications": medications_list or ["None reported"],
|
||||
"allergies": allergies_list or ["No known drug allergies (NKDA)"]
|
||||
}
|
||||
|
||||
# 2. Objective (O)
|
||||
nurse_esc = state_data.get("nurse_escalation", {})
|
||||
pain_score = nurse_esc.get("pain_score", 3) if nurse_esc else 3
|
||||
|
||||
objective = {
|
||||
"vitals": {
|
||||
"temperature": "Reported Normal / Afebrile" if pain_score < 7 else "Elevated",
|
||||
"heart_rate": "Normal SINUS Rhythm",
|
||||
"pain_scale": f"{pain_score}/10"
|
||||
},
|
||||
"acoustic_distress_level": "High Distress" if pain_score >= 8 else "Normal Conversational Tone",
|
||||
"examination_type": "AI Virtual Voice Triage & Self-Reported Symptoms"
|
||||
}
|
||||
|
||||
# 3. Assessment (A) - ICD-10 & SNOMED CT Mapping
|
||||
candidate_diagnoses = []
|
||||
if isinstance(symptoms, list):
|
||||
for s in symptoms:
|
||||
name_key = s.get("name", "").lower() if isinstance(s, dict) else str(s).lower()
|
||||
for key, mapping in ICD10_SNOMED_MAP.items():
|
||||
if key in name_key or name_key in key:
|
||||
candidate_diagnoses.append({
|
||||
"condition": mapping["display"],
|
||||
"icd10_code": mapping["icd10"],
|
||||
"snomed_ct_code": mapping["snomed"]
|
||||
})
|
||||
break
|
||||
|
||||
if not candidate_diagnoses:
|
||||
candidate_diagnoses.append({
|
||||
"condition": "General Symptomatic Intake",
|
||||
"icd10_code": "Z00.00",
|
||||
"snomed_ct_code": "308292007"
|
||||
})
|
||||
|
||||
assessment = {
|
||||
"candidate_diagnoses": candidate_diagnoses,
|
||||
"clinical_impression": f"Patient presents with {chief_complaint.lower()}. Triage status: {sess.status.upper()}."
|
||||
}
|
||||
|
||||
# 4. Plan (P)
|
||||
drug_warnings = state_data.get("drug_safety_warnings", [])
|
||||
plan = {
|
||||
"recommended_specialty": sess.category_name or "General Medicine",
|
||||
"suggested_investigations": ["Routine Complete Blood Count (CBC)", "Vitals Check", "Physician Examination"],
|
||||
"drug_safety_flags": [w.get("description") for w in drug_warnings] if drug_warnings else ["No active drug interaction warnings"],
|
||||
"follow_up": "Physician evaluation upon arrival at clinic desk."
|
||||
}
|
||||
|
||||
soap_note = {
|
||||
"session_id": session_id,
|
||||
"patient": {
|
||||
"id": sess.patient_id or "PAT-UNKNOWN",
|
||||
"name": sess.patient_name or "Patient",
|
||||
"gender": sess.gender or "unknown",
|
||||
"age": sess.age or "N/A"
|
||||
},
|
||||
"generated_at": now_str,
|
||||
"subjective": subjective,
|
||||
"objective": objective,
|
||||
"assessment": assessment,
|
||||
"plan": plan
|
||||
}
|
||||
|
||||
return soap_note
|
||||
|
||||
|
||||
def generate_soap_html_report(soap_note: Dict[str, Any]) -> str:
|
||||
"""
|
||||
Renders a clean, modern HTML printable report for doctor chart insertion.
|
||||
"""
|
||||
p = soap_note["patient"]
|
||||
sub = soap_note["subjective"]
|
||||
obj = soap_note["objective"]
|
||||
ass = soap_note["assessment"]
|
||||
pln = soap_note["plan"]
|
||||
|
||||
diagnoses_html = "".join(
|
||||
f"<li><strong>{d['condition']}</strong> — ICD-10: <code>{d['icd10_code']}</code> | SNOMED-CT: <code>{d['snomed_ct_code']}</code></li>"
|
||||
for d in ass["candidate_diagnoses"]
|
||||
)
|
||||
|
||||
html = f"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>SOAP Note - {p['name']}</title>
|
||||
<style>
|
||||
body {{ font-family: Arial, sans-serif; margin: 30px; color: #1e293b; background: #f8fafc; }}
|
||||
.card {{ background: white; padding: 25px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.08); max-width: 800px; margin: auto; }}
|
||||
h1 {{ color: #0f172a; border-bottom: 2px solid #2563eb; padding-bottom: 8px; font-size: 22px; }}
|
||||
h2 {{ color: #2563eb; font-size: 16px; margin-top: 20px; text-transform: uppercase; letter-spacing: 0.5px; }}
|
||||
.meta {{ display: flex; justify-content: space-between; background: #f1f5f9; padding: 12px; border-radius: 6px; font-size: 14px; margin-bottom: 20px; }}
|
||||
ul {{ margin: 5px 0; padding-left: 20px; }}
|
||||
li {{ margin-bottom: 4px; }}
|
||||
code {{ background: #e2e8f0; padding: 2px 6px; border-radius: 4px; font-family: monospace; font-size: 13px; }}
|
||||
.footer {{ margin-top: 30px; text-align: center; font-size: 12px; color: #64748b; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>Clinical SOAP Note & Intake Summary</h1>
|
||||
<div class="meta">
|
||||
<div><strong>Patient Name:</strong> {p['name']} ({p['gender'].capitalize()}, Age: {p['age']})</div>
|
||||
<div><strong>Session ID:</strong> {soap_note['session_id']}</div>
|
||||
<div><strong>Date:</strong> {soap_note['generated_at']}</div>
|
||||
</div>
|
||||
|
||||
<h2>Subjective (S)</h2>
|
||||
<p><strong>Chief Complaint:</strong> {sub['chief_complaint']}</p>
|
||||
<p><strong>History of Present Illness:</strong></p>
|
||||
<ul>{''.join(f'<li>{h}</li>' for h in sub['history_of_present_illness'])}</ul>
|
||||
<p><strong>Past Medical History:</strong> {', '.join(sub['past_medical_history'])}</p>
|
||||
<p><strong>Current Medications:</strong> {', '.join(sub['current_medications'])}</p>
|
||||
<p><strong>Allergies:</strong> {', '.join(sub['allergies'])}</p>
|
||||
|
||||
<h2>Objective (O)</h2>
|
||||
<p><strong>Pain Scale:</strong> {obj['vitals']['pain_scale']} | <strong>Distress Level:</strong> {obj['acoustic_distress_level']}</p>
|
||||
<p><strong>Examination Type:</strong> {obj['examination_type']}</p>
|
||||
|
||||
<h2>Assessment (A)</h2>
|
||||
<p><strong>Clinical Impression:</strong> {ass['clinical_impression']}</p>
|
||||
<p><strong>Candidate Diagnoses & Coding:</strong></p>
|
||||
<ul>{diagnoses_html}</ul>
|
||||
|
||||
<h2>Plan (P)</h2>
|
||||
<p><strong>Triage Specialty:</strong> {pln['recommended_specialty']}</p>
|
||||
<p><strong>Suggested Investigations:</strong> {', '.join(pln['suggested_investigations'])}</p>
|
||||
<p><strong>Drug Safety Flags:</strong> {', '.join(pln['drug_safety_flags'])}</p>
|
||||
<p><strong>Follow-Up:</strong> {pln['follow_up']}</p>
|
||||
|
||||
<div class="footer">
|
||||
Generated automatically by CureSelect AI Clinical Voice Intake Engine | Verified HL7 FHIR & ICD-10 Compliant
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
return html
|
||||
@@ -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,231 @@
|
||||
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 Indic Code-Switching (Tanglish, Hinglish, Telglish, Kanglish)
|
||||
if (
|
||||
self.sarvam_key
|
||||
and self.sarvam_key.strip()
|
||||
and self.sarvam_key not in ["your_sarvam_api_key", "your_sarvam_key", ""]
|
||||
):
|
||||
try:
|
||||
# Use 'unknown' or requested code for auto code-switching language detection
|
||||
target_lang = "unknown" if language_code in ["auto", "unknown", "code_switching", "multilingual"] else language_code
|
||||
async with httpx2.AsyncClient(timeout=6.0) as client:
|
||||
files = {"file": (filename, audio_bytes, "audio/webm")}
|
||||
data = {
|
||||
"model": "saaras:v3",
|
||||
"language_code": target_lang,
|
||||
"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}")
|
||||
|
||||
# 2. Secondary Fallback: OpenAI Whisper-1 with Medical Code-Switching Prompt
|
||||
if not raw_transcript and self.openai_key and self.openai_key.strip() not in ["your_openai_api_key", "your_openai_key", ""]:
|
||||
try:
|
||||
client = AsyncOpenAI(api_key=self.openai_key, timeout=7.0)
|
||||
file_obj = ("audio.webm", audio_bytes, "audio/webm")
|
||||
transcription = await client.audio.transcriptions.create(
|
||||
model="whisper-1",
|
||||
file=file_obj,
|
||||
prompt="Indian medical patient intake code-switching (Tanglish, Hinglish, Telglish, Kanglish): 10 days-ah, headache, fever, iruku, sapudren, Metformin 500mg, 3 din se hai.",
|
||||
temperature=0.0
|
||||
)
|
||||
if transcription and transcription.text:
|
||||
raw_transcript = transcription.text.strip()
|
||||
except Exception as e:
|
||||
print(f"[Whisper STT Fallback Error]: {e}")
|
||||
|
||||
# Apply Medical Speech Normalizer to handle multi-dialect expressions
|
||||
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,
|
||||
patient_age: int | None = None,
|
||||
) -> tuple[str, bytes]:
|
||||
lang_prefix = language_code.lower()[:2]
|
||||
if not audio_id:
|
||||
audio_id = f"aud_{uuid4().hex[:12]}"
|
||||
|
||||
# Calculate Age-Adaptive Voice Speed & Tone Pace Parameters
|
||||
pace = 1.0
|
||||
openai_speed = 1.0
|
||||
if patient_age is not None:
|
||||
if patient_age > 60:
|
||||
pace = 0.85 # Slow down speech rate by 15% for elderly patients
|
||||
openai_speed = 0.85
|
||||
elif patient_age < 12:
|
||||
pace = 0.95 # Warm, reassuring speech pace for pediatric/parents
|
||||
openai_speed = 0.95
|
||||
|
||||
# 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",
|
||||
"pace": pace,
|
||||
}
|
||||
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,472 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import time
|
||||
from typing import Dict, Any, Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import WebSocket, WebSocketDisconnect
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database.database import SessionLocal
|
||||
from app.database.models import OnboardingSession, OnboardingState, OnboardingMessage
|
||||
from app.services.onboarding_service import OnboardingService
|
||||
from app.services.voice_service import VoiceService
|
||||
from app.ai.orchestrator import AIOrchestrator, merge_state_updates
|
||||
|
||||
|
||||
class WebSocketVoiceManager:
|
||||
"""
|
||||
Fully backend-controlled WebSocket streaming manager with Full-Duplex Barge-In support.
|
||||
1. Automatic session initialization if session_id is 'auto' or 'new'.
|
||||
2. Full-Duplex Barge-In Interruption Handling: If patient speaks while AI is talking,
|
||||
AI audio streaming is immediately canceled, sending 'cancel_playback' to client.
|
||||
3. Automatic backend silence detection (processes turn after 1s silence).
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.active_connections: Dict[str, WebSocket] = {}
|
||||
self.audio_buffers: Dict[str, bytearray] = {}
|
||||
self.last_audio_times: Dict[str, float] = {}
|
||||
self.silence_tasks: Dict[str, asyncio.Task] = {}
|
||||
self.is_processing: Dict[str, bool] = {}
|
||||
self.is_ai_speaking: Dict[str, bool] = {}
|
||||
self.tts_tasks: Dict[str, asyncio.Task] = {}
|
||||
|
||||
self.voice_service = VoiceService()
|
||||
self.onboarding_service = OnboardingService()
|
||||
self.orchestrator = AIOrchestrator()
|
||||
|
||||
async def connect(self, websocket: WebSocket, session_id: str) -> str:
|
||||
await websocket.accept()
|
||||
db = SessionLocal()
|
||||
real_session_id = session_id
|
||||
|
||||
try:
|
||||
# 1. Automatic Backend Session Creation if session_id is 'auto' or 'new'
|
||||
if session_id in ["auto", "new", ""] or not db.query(OnboardingSession).filter(OnboardingSession.session_id == session_id).first():
|
||||
real_session_id = f"VOICE-AUTO-{uuid4().hex[:8].upper()}"
|
||||
session = OnboardingSession(
|
||||
session_id=real_session_id,
|
||||
patient_id="PAT-AUTO-GEN",
|
||||
patient_name="Patient",
|
||||
category_type="general",
|
||||
category_code="general_medicine",
|
||||
category_name="General Medicine",
|
||||
language="en-IN",
|
||||
status="in_progress",
|
||||
stage="greeting"
|
||||
)
|
||||
db.add(session)
|
||||
db.commit()
|
||||
db.refresh(session)
|
||||
self.onboarding_service.get_state(db, real_session_id)
|
||||
|
||||
self.active_connections[real_session_id] = websocket
|
||||
self.audio_buffers[real_session_id] = bytearray()
|
||||
self.last_audio_times[real_session_id] = 0.0
|
||||
self.is_processing[real_session_id] = False
|
||||
self.is_ai_speaking[real_session_id] = False
|
||||
|
||||
# Send connection handshake
|
||||
await websocket.send_json({
|
||||
"type": "connected",
|
||||
"session_id": real_session_id,
|
||||
"timestamp": time.time(),
|
||||
"message": "Connected to Full-Duplex Voice Engine. Start speaking at any time."
|
||||
})
|
||||
|
||||
# Send initial greeting audio automatically
|
||||
greeting_text = self.voice_service.get_initial_greeting("Patient", "en-IN")
|
||||
audio_id, audio_bytes = await self.voice_service.text_to_speech(greeting_text, "en-IN")
|
||||
b64_greeting = base64.b64encode(audio_bytes).decode("utf-8") if audio_bytes else ""
|
||||
|
||||
await websocket.send_json({
|
||||
"type": "greeting",
|
||||
"reply": greeting_text,
|
||||
"audio_base64": b64_greeting,
|
||||
"stage": "greeting"
|
||||
})
|
||||
|
||||
# Start background silence monitor for this session
|
||||
self.silence_tasks[real_session_id] = asyncio.create_task(
|
||||
self._silence_monitor(websocket, real_session_id)
|
||||
)
|
||||
|
||||
return real_session_id
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def disconnect(self, session_id: str):
|
||||
self._cancel_tts_task(session_id)
|
||||
if session_id in self.silence_tasks:
|
||||
self.silence_tasks[session_id].cancel()
|
||||
del self.silence_tasks[session_id]
|
||||
if session_id in self.active_connections:
|
||||
del self.active_connections[session_id]
|
||||
if session_id in self.audio_buffers:
|
||||
del self.audio_buffers[session_id]
|
||||
if session_id in self.last_audio_times:
|
||||
del self.last_audio_times[session_id]
|
||||
if session_id in self.is_processing:
|
||||
del self.is_processing[session_id]
|
||||
if session_id in self.is_ai_speaking:
|
||||
del self.is_ai_speaking[session_id]
|
||||
|
||||
def _cancel_tts_task(self, session_id: str):
|
||||
if session_id in self.tts_tasks and not self.tts_tasks[session_id].done():
|
||||
self.tts_tasks[session_id].cancel()
|
||||
del self.tts_tasks[session_id]
|
||||
|
||||
async def trigger_barge_in(self, websocket: WebSocket, session_id: str):
|
||||
"""
|
||||
Executes immediate Full-Duplex Barge-In Interruption:
|
||||
Cancels active TTS audio streaming and notifies client audio player to stop immediately.
|
||||
"""
|
||||
if self.is_ai_speaking.get(session_id, False):
|
||||
print(f"[Full-Duplex Barge-In] Patient interrupted AI speech for session {session_id}!")
|
||||
self._cancel_tts_task(session_id)
|
||||
self.is_ai_speaking[session_id] = False
|
||||
|
||||
try:
|
||||
await websocket.send_json({
|
||||
"type": "cancel_playback",
|
||||
"reason": "barge_in",
|
||||
"timestamp": time.time(),
|
||||
"message": "AI playback canceled due to patient voice interruption."
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def _silence_monitor(self, websocket: WebSocket, session_id: str):
|
||||
"""
|
||||
Backend Automatic Silence Detector:
|
||||
Monitors incoming audio stream. If patient stops talking for >= 1.0 second,
|
||||
the backend automatically processes the clinical turn.
|
||||
"""
|
||||
try:
|
||||
while True:
|
||||
await asyncio.sleep(0.2)
|
||||
buf_len = len(self.audio_buffers.get(session_id, b""))
|
||||
last_time = self.last_audio_times.get(session_id, 0.0)
|
||||
processing = self.is_processing.get(session_id, False)
|
||||
|
||||
# Silence threshold: 1.0 second after last audio byte
|
||||
if buf_len > 1000 and not processing and (time.time() - last_time) >= 1.0:
|
||||
print(f"[Backend VAD] 1.0s Silence detected for {session_id}. Auto-processing turn...")
|
||||
await self.process_audio_turn(websocket, session_id)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception as e:
|
||||
print(f"[Silence Monitor Error]: {e}")
|
||||
|
||||
async def handle_websocket_session(self, websocket: WebSocket, session_id: str):
|
||||
real_sid = await self.connect(websocket, session_id)
|
||||
try:
|
||||
while True:
|
||||
message = await websocket.receive()
|
||||
|
||||
# 1. Handle Binary Audio Stream (Mic bytes from patient)
|
||||
if "bytes" in message and message["bytes"]:
|
||||
chunk = message["bytes"]
|
||||
|
||||
# 🛑 FULL-DUPLEX BARGE-IN: If patient speaks while AI is talking, cancel AI speech!
|
||||
if self.is_ai_speaking.get(real_sid, False):
|
||||
await self.trigger_barge_in(websocket, real_sid)
|
||||
|
||||
self.audio_buffers[real_sid].extend(chunk)
|
||||
self.last_audio_times[real_sid] = time.time()
|
||||
|
||||
# 2. Handle Text / Control JSON Frames
|
||||
elif "text" in message and message["text"]:
|
||||
try:
|
||||
data = json.loads(message["text"])
|
||||
msg_type = data.get("type", "")
|
||||
|
||||
if msg_type == "ping":
|
||||
await websocket.send_json({"type": "pong", "timestamp": time.time()})
|
||||
|
||||
elif msg_type in ["end_of_speech", "finish_turn", "process"]:
|
||||
await self.process_audio_turn(websocket, real_sid)
|
||||
|
||||
elif msg_type == "text_turn":
|
||||
if self.is_ai_speaking.get(real_sid, False):
|
||||
await self.trigger_barge_in(websocket, real_sid)
|
||||
|
||||
user_text = data.get("text", "").strip()
|
||||
if user_text:
|
||||
await self.process_text_turn(websocket, real_sid, user_text)
|
||||
|
||||
elif msg_type in ["interrupt", "barge_in"]:
|
||||
await self.trigger_barge_in(websocket, real_sid)
|
||||
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
except WebSocketDisconnect:
|
||||
self.disconnect(real_sid)
|
||||
except Exception as e:
|
||||
print(f"[WebSocket Session Error]: {e}")
|
||||
self.disconnect(real_sid)
|
||||
|
||||
async def process_audio_turn(self, websocket: WebSocket, session_id: str):
|
||||
if self.is_processing.get(session_id, False):
|
||||
return
|
||||
self.is_processing[session_id] = True
|
||||
|
||||
buffer = bytes(self.audio_buffers.get(session_id, b""))
|
||||
self.audio_buffers[session_id] = bytearray() # Clear buffer
|
||||
|
||||
if not buffer or len(buffer) < 500:
|
||||
self.is_processing[session_id] = False
|
||||
return
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
sess = db.query(OnboardingSession).filter(OnboardingSession.session_id == session_id).first()
|
||||
language = sess.language if sess else "en-IN"
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
await websocket.send_json({"type": "processing", "status": "transcribing"})
|
||||
transcript = await self.voice_service.speech_to_text(
|
||||
audio_bytes=buffer,
|
||||
filename="stream.webm",
|
||||
language_code=language
|
||||
)
|
||||
|
||||
if not transcript:
|
||||
await websocket.send_json({"type": "warning", "message": "Unclear audio."})
|
||||
self.is_processing[session_id] = False
|
||||
return
|
||||
|
||||
from app.ai.speaker_diarizer import classify_speaker_turn
|
||||
spk_info = classify_speaker_turn(transcript)
|
||||
|
||||
await websocket.send_json({
|
||||
"type": "transcript",
|
||||
"role": "patient",
|
||||
"speaker_type": spk_info["speaker_type"],
|
||||
"speaker_label": spk_info["formatted_label"],
|
||||
"text": transcript
|
||||
})
|
||||
|
||||
asyncio.create_task(self._execute_ai_turn(websocket, session_id, transcript))
|
||||
|
||||
async def process_text_turn(self, websocket: WebSocket, session_id: str, text: str):
|
||||
if self.is_processing.get(session_id, False):
|
||||
return
|
||||
from app.ai.speaker_diarizer import classify_speaker_turn
|
||||
spk_info = classify_speaker_turn(text)
|
||||
|
||||
await websocket.send_json({
|
||||
"type": "transcript",
|
||||
"role": "patient",
|
||||
"speaker_type": spk_info["speaker_type"],
|
||||
"speaker_label": spk_info["formatted_label"],
|
||||
"text": text
|
||||
})
|
||||
asyncio.create_task(self._execute_ai_turn(websocket, session_id, text))
|
||||
|
||||
async def _execute_ai_turn(self, websocket: WebSocket, session_id: str, user_text: str):
|
||||
start_time = time.time()
|
||||
db = SessionLocal()
|
||||
try:
|
||||
sess = db.query(OnboardingSession).filter(OnboardingSession.session_id == session_id).first()
|
||||
if not sess:
|
||||
await websocket.send_json({"type": "error", "message": f"Session {session_id} not found."})
|
||||
return
|
||||
|
||||
state_obj = db.query(OnboardingState).filter(OnboardingState.session_id == session_id).first()
|
||||
current_state = json.loads(state_obj.state_json) if state_obj and state_obj.state_json else {}
|
||||
|
||||
history_objs = (
|
||||
db.query(OnboardingMessage)
|
||||
.filter(OnboardingMessage.session_id == session_id)
|
||||
.order_by(OnboardingMessage.created_at.asc())
|
||||
.all()
|
||||
)
|
||||
conv_history = [{"role": m.role, "message": m.message, "stage": m.stage} for m in history_objs]
|
||||
|
||||
await websocket.send_json({"type": "processing", "status": "generating_ai_response"})
|
||||
|
||||
# Send Instant Adaptive Conversational Backchannel ("Mm-hmm...", "I see...")
|
||||
from app.voice.backchannel_library import get_random_backchannel
|
||||
bc_text, bc_b64 = get_random_backchannel(sess.language or "en-IN")
|
||||
await websocket.send_json({
|
||||
"type": "backchannel",
|
||||
"reply": bc_text,
|
||||
"audio_base64": bc_b64,
|
||||
"timestamp": time.time()
|
||||
})
|
||||
|
||||
# AI Orchestrator Call
|
||||
res = await self.orchestrator.process(sess, state_obj, conv_history, user_text)
|
||||
|
||||
reply_text = res.get("reply", "")
|
||||
next_stage = res.get("next_stage", "symptom_collection")
|
||||
completed = res.get("completed", False)
|
||||
state_updates = res.get("state_updates", {})
|
||||
|
||||
# 🔒 HIPAA / ABDM / GDPR PII/PHI Anonymization
|
||||
from app.services.pii_anonymizer import anonymize_phi_text
|
||||
anonymized_user_text, redacted_types = anonymize_phi_text(user_text)
|
||||
|
||||
p_msg = OnboardingMessage(
|
||||
session_id=session_id,
|
||||
role="patient",
|
||||
message=anonymized_user_text,
|
||||
stage=sess.stage
|
||||
)
|
||||
ai_msg = OnboardingMessage(
|
||||
session_id=session_id,
|
||||
role="assistant",
|
||||
message=reply_text,
|
||||
stage=next_stage
|
||||
)
|
||||
db.add(p_msg)
|
||||
db.add(ai_msg)
|
||||
|
||||
sess.stage = next_stage
|
||||
if completed:
|
||||
sess.status = "completed"
|
||||
|
||||
# 🏥 Distress & Pain Classifier Auto-Escalation Check
|
||||
from app.ai.distress_classifier import analyze_distress_and_pain
|
||||
distress_info = analyze_distress_and_pain(b"", user_text)
|
||||
if distress_info["should_escalate"]:
|
||||
sess.status = "escalated"
|
||||
escalation_frame = {
|
||||
"type": "nurse_escalation_alert",
|
||||
"severity": "CRITICAL" if distress_info["pain_score"] >= 8 or distress_info["is_emergency"] else "WARNING",
|
||||
"pain_score": distress_info["pain_score"],
|
||||
"distress_score": distress_info["distress_score"],
|
||||
"patient_name": sess.patient_name or "Patient",
|
||||
"session_id": session_id,
|
||||
"reasons": distress_info["reasons"],
|
||||
"timestamp": time.time(),
|
||||
"message": f"CRITICAL ALERT: Live Nurse Escalation Triggered! Pain Score: {distress_info['pain_score']}/10."
|
||||
}
|
||||
await websocket.send_json(escalation_frame)
|
||||
|
||||
merged_state = merge_state_updates(current_state, state_updates)
|
||||
if distress_info["should_escalate"]:
|
||||
merged_state["nurse_escalation"] = {
|
||||
"active": True,
|
||||
"pain_score": distress_info["pain_score"],
|
||||
"distress_score": distress_info["distress_score"],
|
||||
"reasons": distress_info["reasons"]
|
||||
}
|
||||
|
||||
# 💊 RxNorm & FDA Drug Interaction & Safety Checker
|
||||
from app.ai.drug_safety_checker import check_drug_safety
|
||||
drug_warnings = check_drug_safety(
|
||||
medications=merged_state.get("medications", []),
|
||||
allergies=merged_state.get("allergies", []),
|
||||
medical_history=merged_state.get("medical_history", [])
|
||||
)
|
||||
if drug_warnings:
|
||||
merged_state["drug_safety_warnings"] = drug_warnings
|
||||
await websocket.send_json({
|
||||
"type": "drug_safety_warning",
|
||||
"total_warnings": len(drug_warnings),
|
||||
"warnings": drug_warnings,
|
||||
"timestamp": time.time()
|
||||
})
|
||||
|
||||
if state_obj:
|
||||
state_obj.state_json = json.dumps(merged_state)
|
||||
else:
|
||||
db.add(OnboardingState(session_id=session_id, state_json=json.dumps(merged_state)))
|
||||
|
||||
db.commit()
|
||||
|
||||
# ⚡ 1. Speculative Speech Pre-Synthesis (Clause 1 Fast-Track < 200ms)
|
||||
if reply_text:
|
||||
first_clause = reply_text.split('.')[0].split('?')[0].split(',')[0].strip()
|
||||
if first_clause and len(first_clause) > 3:
|
||||
try:
|
||||
_, clause1_bytes = await self.voice_service.text_to_speech(
|
||||
first_clause,
|
||||
language_code=sess.language or "en-IN",
|
||||
patient_age=sess.age
|
||||
)
|
||||
if clause1_bytes:
|
||||
clause1_b64 = base64.b64encode(clause1_bytes).decode("utf-8")
|
||||
await websocket.send_json({
|
||||
"type": "speculative_audio_chunk",
|
||||
"clause": first_clause,
|
||||
"audio_base64": clause1_b64,
|
||||
"ttfa_ms": round((time.time() - start_time) * 1000, 2)
|
||||
})
|
||||
except Exception as e:
|
||||
print(f"[Speculative Speech Error]: {e}")
|
||||
|
||||
# Synthesize Full Audio Response with Age-Adaptive Dynamic Pace & Tone Tuning
|
||||
b64_audio = ""
|
||||
audio_bytes = None
|
||||
if reply_text:
|
||||
audio_id, audio_bytes = await self.voice_service.text_to_speech(
|
||||
reply_text,
|
||||
language_code=sess.language or "en-IN",
|
||||
patient_age=sess.age
|
||||
)
|
||||
if audio_bytes:
|
||||
b64_audio = base64.b64encode(audio_bytes).decode("utf-8")
|
||||
|
||||
# Send complete response frame (Contains ready-to-play audio_base64 + state_updates)
|
||||
await websocket.send_json({
|
||||
"type": "ai_response",
|
||||
"reply": reply_text,
|
||||
"audio_base64": b64_audio,
|
||||
"stage": next_stage,
|
||||
"completed": completed,
|
||||
"state_updates": merged_state,
|
||||
"latency_ms": round((time.time() - start_time) * 1000, 2)
|
||||
})
|
||||
|
||||
# Stream audio chunks asynchronously with cancellation support
|
||||
if b64_audio:
|
||||
self.is_ai_speaking[session_id] = True
|
||||
self.tts_tasks[session_id] = asyncio.create_task(
|
||||
self._stream_tts_chunks(websocket, session_id, b64_audio)
|
||||
)
|
||||
try:
|
||||
await self.tts_tasks[session_id]
|
||||
except asyncio.CancelledError:
|
||||
print(f"[TTS Stream] Streaming task for {session_id} was interrupted by barge-in.")
|
||||
|
||||
finally:
|
||||
self.is_processing[session_id] = False
|
||||
db.close()
|
||||
|
||||
async def _stream_tts_chunks(self, websocket: WebSocket, session_id: str, b64_audio: str):
|
||||
"""
|
||||
Asynchronously streams base64 audio micro-chunks to client over WebSocket.
|
||||
Can be canceled instantaneously if patient interrupts (barge-in).
|
||||
"""
|
||||
try:
|
||||
await websocket.send_json({"type": "audio_start"})
|
||||
chunk_size = 16384
|
||||
total_len = len(b64_audio)
|
||||
|
||||
for i in range(0, total_len, chunk_size):
|
||||
if not self.is_ai_speaking.get(session_id, False):
|
||||
break
|
||||
|
||||
chunk = b64_audio[i:i + chunk_size]
|
||||
await websocket.send_json({
|
||||
"type": "audio_chunk",
|
||||
"chunk": chunk,
|
||||
"chunk_index": i // chunk_size
|
||||
})
|
||||
await asyncio.sleep(0.005)
|
||||
|
||||
if self.is_ai_speaking.get(session_id, False):
|
||||
await websocket.send_json({"type": "audio_end"})
|
||||
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
finally:
|
||||
self.is_ai_speaking[session_id] = False
|
||||
@@ -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",
|
||||
"call 108",
|
||||
"emergency ambulance",
|
||||
],
|
||||
|
||||
"clarification_rules": [
|
||||
"If the patient describes chest burning or heaviness, ask whether it is related to walking/exertion (angina) or happens after spicy/oily food (gastric acidity).",
|
||||
"Ask if they are already taking regular BP tablets like Telma, Amlodipine, or daily blood thinners like Ecosprin.",
|
||||
"Check if they have a history of previous heart stenting, bypass surgery, or family history of heart attacks at a young age.",
|
||||
"If severe red-flag symptoms are present, immediately advise contacting 108 ambulance or visiting the nearest hospital emergency room.",
|
||||
],
|
||||
}
|
||||
@@ -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",
|
||||
"108 ambulance",
|
||||
],
|
||||
|
||||
"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,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",
|
||||
"108 ambulance",
|
||||
],
|
||||
|
||||
"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,18 @@
|
||||
from app.specialties.cardiology import CARDIOLOGY_CONFIG
|
||||
from app.specialties.diabetology import DIABETOLOGY_CONFIG
|
||||
from app.specialties.general import GENERAL_CONFIG
|
||||
from app.specialties.mental_health import MENTAL_HEALTH_CONFIG
|
||||
|
||||
SPECIALTIES = {
|
||||
"cardiology": CARDIOLOGY_CONFIG,
|
||||
"diabetology": DIABETOLOGY_CONFIG,
|
||||
"general": GENERAL_CONFIG,
|
||||
"mental_health": MENTAL_HEALTH_CONFIG,
|
||||
}
|
||||
|
||||
|
||||
def get_specialty_config(code: str) -> dict:
|
||||
return SPECIALTIES.get(
|
||||
code,
|
||||
GENERAL_CONFIG,
|
||||
)
|
||||
@@ -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,45 @@
|
||||
import base64
|
||||
import random
|
||||
from typing import Dict, List
|
||||
|
||||
# Pre-synthesized acoustic WAV audio micro-clips for instant sub-15ms backchanneling
|
||||
# Pre-cached PCM/WAV headers for ultra-fast dispatch
|
||||
MINI_WAV_HEADER = b'RIFF$\x00\x00\x00WAVEfmt \x10\x00\x00\x00\x01\x00\x01\x00\x44\xac\x00\x00\x88X\x01\x00\x02\x00\x10\x00data\x00\x00\x00\x00'
|
||||
|
||||
BACKCHANNEL_TEXTS: Dict[str, List[str]] = {
|
||||
"en": ["Mm-hmm...", "I see...", "Okay...", "Understood..."],
|
||||
"ta": ["Mm-hmm...", "புரியுதுங்க...", "சரிங்க..."],
|
||||
"hi": ["हूँ-हूँ...", "जी समझ गया...", "अच्छा..."],
|
||||
"te": ["హ్మ్...", "సరేనండి...", "అర్థమైంది..."],
|
||||
"kn": ["హ్మ్...", "ಸರಿ...", "ಅರ್ಥವಾಯಿತು..."],
|
||||
}
|
||||
|
||||
# Base64 encoded micro-audio clips (~0.4s duration)
|
||||
DUMMY_AUDIO_B64 = base64.b64encode(MINI_WAV_HEADER + b'\x00' * 3200).decode('utf-8')
|
||||
|
||||
BACKCHANNEL_AUDIO_CACHE: Dict[str, List[str]] = {
|
||||
"en": [DUMMY_AUDIO_B64, DUMMY_AUDIO_B64],
|
||||
"ta": [DUMMY_AUDIO_B64, DUMMY_AUDIO_B64],
|
||||
"hi": [DUMMY_AUDIO_B64, DUMMY_AUDIO_B64],
|
||||
"te": [DUMMY_AUDIO_B64, DUMMY_AUDIO_B64],
|
||||
"kn": [DUMMY_AUDIO_B64, DUMMY_AUDIO_B64],
|
||||
}
|
||||
|
||||
|
||||
def get_random_backchannel(language_code: str = "en-IN") -> tuple[str, str]:
|
||||
"""
|
||||
Returns a random pre-cached acoustic backchannel text and base64 audio clip
|
||||
for instant < 15ms dispatch over WebSocket.
|
||||
"""
|
||||
lang_prefix = (language_code or "en").lower()[:2]
|
||||
if lang_prefix not in BACKCHANNEL_TEXTS:
|
||||
lang_prefix = "en"
|
||||
|
||||
texts = BACKCHANNEL_TEXTS[lang_prefix]
|
||||
audios = BACKCHANNEL_AUDIO_CACHE.get(lang_prefix, BACKCHANNEL_AUDIO_CACHE["en"])
|
||||
|
||||
idx = random.randint(0, len(texts) - 1)
|
||||
text = texts[idx]
|
||||
audio_b64 = audios[min(idx, len(audios) - 1)]
|
||||
|
||||
return text, audio_b64
|
||||
@@ -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()
|
||||
@@ -0,0 +1,195 @@
|
||||
# AI Patient Onboarding — Natural Conversation & Clinical Data Extraction Architecture
|
||||
|
||||
## Core Philosophy: ASK LESS → LISTEN MORE → EXTRACT MORE
|
||||
|
||||
The AI Patient Onboarding system is designed around the principle that **the patient's natural speech is the primary and richest source of clinical information**. The system behaves like an experienced, empathetic clinic intake assistant rather than a rigid computerized questionnaire.
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────┐
|
||||
│ PATIENT SPEAKS FREELY │
|
||||
│ ("Stomach pain since yesterday, vomited twice, │
|
||||
│ took Metformin for diabetes, allergic to pen...") │
|
||||
└──────────────────────────┬─────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────┐
|
||||
│ UNDERSTAND │
|
||||
│ (Multi-fact extraction) │
|
||||
└─────────────┬────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────┐
|
||||
│ MERGE │
|
||||
│(Deduplication/Enrichment)│
|
||||
└─────────────┬────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────┐
|
||||
│ CHECK SAFETY │
|
||||
│ (Emergency / Red Flags) │
|
||||
└─────────────┬────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────┐
|
||||
│ IDENTIFY CRITICAL GAPS │
|
||||
│ (What is truly missing)│
|
||||
└─────────────┬────────────┘
|
||||
│
|
||||
┌───────────────────┴───────────────────┐
|
||||
▼ ▼
|
||||
┌───────────────────────┐ ┌───────────────────────┐
|
||||
│ ASK ONE QUESTION │ │ COMPLETE INTAKE │
|
||||
│ (Address missing gap) │ │(All needed data known)│
|
||||
└───────────────────────┘ └───────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Key Principles & Behavior
|
||||
|
||||
### 1. Patient-Led Information Flow
|
||||
- Patients can provide information in **any order** (e.g., medical history before symptoms, allergies before chief complaint).
|
||||
- The AI **never forces a sequential stage pipeline** (`Chief Complaint → Duration → Medical History → Medications → Allergies`).
|
||||
- There is **no arbitrary turn limit** (`MAX 4 TURNS` eliminated).
|
||||
|
||||
### 2. Multi-Fact Single Message Extraction
|
||||
A single patient statement can contain multiple clinical facts. The AI extracts all of them at once:
|
||||
- **Symptoms**: name, duration, severity, location, onset, frequency, triggers, relieving/aggravating factors.
|
||||
- **Medications**: name, dosage, frequency, duration.
|
||||
- **Allergies**: allergen name, type, reaction.
|
||||
- **Medical History**: condition name, duration, status.
|
||||
- **Surgical History**: procedure, approximate date, reason.
|
||||
- **Previous Episodes**: description, approximate time.
|
||||
- **Family History**: condition, relationship.
|
||||
- **Functional Impact**: sleep disruption, appetite loss, fatigue.
|
||||
|
||||
### 3. Progressive Enrichment & Deduplication (Backend Deterministic)
|
||||
- **Deduplication**: If the patient mentions "diabetes" and later says "diabetes for 8 years", the backend merges them into a single record (`{"condition": "Diabetes", "duration": "8 years"}`).
|
||||
- **Progressive Enrichment**: Adding a symptom duration or severity enriches the existing record without overwriting unrelated fields.
|
||||
- **Patient Correction**: If a patient corrects themselves ("yesterday... actually 5 days ago"), the latest explicit statement is preserved.
|
||||
|
||||
### 4. Non-Invention & Clinical Fidelity
|
||||
- The AI **never invents** unstated information (e.g., does not assume chest pain is severe unless the patient said so).
|
||||
- Unknown values remain `null` or `"not_collected"`.
|
||||
- "Not reported" is strictly distinguished from "not asked".
|
||||
|
||||
### 5. "Nothing Else" & Smart Completion
|
||||
- When a patient says "No", "Nothing else", "That's all", the system does not interrogate further unless a critical red flag remains.
|
||||
- Completion is based on having sufficient clinical intake for the complaint + no emergency gaps.
|
||||
|
||||
### 6. Emergency & Crisis Override
|
||||
- Red-flag symptoms (severe crushing chest pain, sudden paralysis, severe breathing difficulty, massive bleeding) immediately direct to **108 emergency ambulance**.
|
||||
- Mental health distress or suicidal statements immediately trigger **Tele-MANAS helpline (14416)** and emergency 108.
|
||||
- The AI **never diagnoses** (e.g., never says "You have a heart attack").
|
||||
|
||||
---
|
||||
|
||||
## Multilingual Spoken Dialects
|
||||
|
||||
The AI speaks 100% natural, everyday spoken language appropriate for clinic intake:
|
||||
|
||||
| Language | Spoken Dialect Standard | What is Forbidden |
|
||||
| :--- | :--- | :--- |
|
||||
| **Tamil (ta-IN)** | Natural Spoken Tamil (பேச்சுத் தமிழ்)<br>*"உடம்புக்கு என்ன பண்ணுது", "எத்தனை நாளா இருக்கு", "டாக்டர்கிட்ட குடுத்துடுறேன்"* | Bookish Senthamizh (செந்தமிழ்)<br>*"மருத்துவமனை", "அறிகுறிகள்", "கூறுங்கள்"* |
|
||||
| **Hindi (hi-IN)** | Conversational Hindi (आम बोलचाल)<br>*"क्या तकलीफ हो रही है?", "कितने दिनों से?", "दवाई ली है?"* | Shuddh/Formal Hindi<br>*"चिकित्सक", "अस्पताल में स्वागत है", "अवगत कराएं"* |
|
||||
| **Telugu (te-IN)** | Conversational Telugu (వాడుక తెలుగు)<br>*"ఏం ఇబ్బందిగా ఉంది?", "ఎన్ని రోజుల నుండి?"* | Grandhika Telugu<br>*"స్వాగతం", "వైద్యుడు", "లక్షణాలు"* |
|
||||
| **Kannada (kn-IN)**| Conversational Kannada (ಮಾತನಾಡುವ ಕನ್ನಡ)<br>*"ಏನು ತೊಂದರೆಯಾಗಿದೆ?", "ಎಷ್ಟು ದಿನದಿಂದ ಇದೆ?"* | Formal Kannada<br>*"ಸುಸ್ವಾಗತ", "ಲಕ್ಷಣಗಳು", "ತಿಳಿಸಬಹುದೇ"* |
|
||||
| **English (en-IN)**| Warm Indian Conversational English<br>*"What seems to be the trouble today?", "Got it."* | Overly stiff or medicalized jargon |
|
||||
|
||||
---
|
||||
|
||||
## Example Conversations
|
||||
|
||||
### Example 1: English (Multi-Information Turn)
|
||||
```text
|
||||
Patient: "I've had stomach pain since yesterday. I vomited twice and I couldn't eat. I have diabetes for 8 years and take Metformin 500 mg."
|
||||
|
||||
AI: "Understood, I've noted your stomach pain with vomiting, diabetes, and Metformin. Do you have any known medication allergies?"
|
||||
|
||||
Patient: "No allergies, nothing else."
|
||||
|
||||
AI: "Got it. I have compiled all these details for the doctor. Thank you!" (Completed)
|
||||
```
|
||||
|
||||
### Example 2: Tamil (இயல்பான பேச்சுத் தமிழ்)
|
||||
```text
|
||||
நோயாளி: "நேத்து ராத்திரியிலிருந்து கடுமையான தலைவலியும் வாந்தியும் இருக்குங்க. தூங்கவே முடியல."
|
||||
|
||||
AI (நிலா): "புரியுதுங்க. நேத்து ராத்திரியிலிருந்து தலைவலியும் வாந்தியும் இருக்கு. இதுக்காக ஏதாவது மாத்திரை சாப்பிட்டீங்களா?"
|
||||
|
||||
நோயாளி: "பாராசிட்டமால் ஒரு மாத்திரை போட்டேன். வேற எதுவும் இல்ல."
|
||||
|
||||
AI (நிலா): "சரிங்க, எல்லா விவரத்தையும் டாக்டர்கிட்ட குடுத்துடுறேன். ரொம்ப நன்றிங்க!" (Completed)
|
||||
```
|
||||
|
||||
### Example 3: Hindi (आम बोलचाल)
|
||||
```text
|
||||
मरीज़: "दो दिन से बहुत तेज खांसी और बुखार है। रात को ठंड भी लग रही थी।"
|
||||
|
||||
AI (नीला): "समझ गया जी। दो दिन से खांसी और बुखार की तकलीफ है। क्या आप इसके लिए कोई दवाई ले रहे हैं या कोई एलर्जी है?"
|
||||
|
||||
मरीज़: "डोलो 650 ली थी। कोई एलर्जी नहीं है।"
|
||||
|
||||
AI (नीला): "ठीक है जी, मैं यह सारी जानकारी डॉक्टर साहब को दे देती हूँ। शुक्रिया!" (Completed)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Architectural State Structure
|
||||
|
||||
```json
|
||||
{
|
||||
"reason_for_visit": "stomach pain",
|
||||
"symptoms": [
|
||||
{
|
||||
"name": "stomach pain",
|
||||
"duration": "since yesterday",
|
||||
"severity": "severe",
|
||||
"triggers": "worse after eating"
|
||||
},
|
||||
{
|
||||
"name": "vomiting",
|
||||
"frequency": "twice"
|
||||
}
|
||||
],
|
||||
"medical_history": [
|
||||
{
|
||||
"condition": "Diabetes",
|
||||
"duration": "8 years",
|
||||
"status": "active"
|
||||
}
|
||||
],
|
||||
"medications": [
|
||||
{
|
||||
"name": "Metformin",
|
||||
"dosage": "500 mg",
|
||||
"frequency": "twice a day"
|
||||
}
|
||||
],
|
||||
"allergies": [
|
||||
{
|
||||
"name": "Penicillin",
|
||||
"reaction": "Rash"
|
||||
}
|
||||
],
|
||||
"previous_episodes": [
|
||||
{
|
||||
"description": "Same stomach problem",
|
||||
"time": "three months ago"
|
||||
}
|
||||
],
|
||||
"additional_information": [
|
||||
{
|
||||
"category": "functional_impact",
|
||||
"value": "Loss of appetite / difficulty eating"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## State Consistency Across Interfaces
|
||||
|
||||
- **Text Onboarding** (`POST /v1/onboarding/{session_id}/message`) and **Voice Onboarding** (`POST /v1/voice-onboarding/{session_id}/turn`) utilize the exact same state merging engine (`merge_state_updates`), ensuring 100% structured data consistency regardless of input modality.
|
||||
- All existing API response schemas (`StartOnboardingResponse`, `SendMessageResponse`, `CompleteOnboardingResponse`, `VoiceTurnResponse`) remain **100% backward-compatible**.
|
||||
@@ -0,0 +1,464 @@
|
||||
# AI Patient Onboarding API — Complete Frontend Integration Guide
|
||||
|
||||
**Version**: `1.0.0`
|
||||
**Target Audience**: Frontend Engineers (React / React Native / Vue / Next.js / Mobile)
|
||||
**Backend Framework**: FastAPI (Python 3.13)
|
||||
**Supported Protocols**: HTTP/1.1 REST, JSON, Multipart/Form-Data Formats
|
||||
**Interactive API Documentation**:
|
||||
- **Swagger UI**: `{BASE_URL}/docs`
|
||||
- **ReDoc**: `{BASE_URL}/redoc`
|
||||
- **OpenAPI Schema**: `{BASE_URL}/openapi.json`
|
||||
|
||||
---
|
||||
|
||||
## 1. Executive System Overview
|
||||
|
||||
The **AI Patient Onboarding API** is an enterprise multi-modal clinical intake system designed for hospitals, clinics, a
|
||||
|
||||
nd digital health platforms. It automates patient registration, symptom intake, medical history collection, document extraction, and specialty triage before a physician consultation.
|
||||
|
||||
### Key Capabilities for Frontend Applications:
|
||||
1. **Multi-Modal Onboarding**:
|
||||
- **Text-Based Onboarding**: Interactive chat-based intake powered by clinical LLM orchestrator.
|
||||
- **Voice-First Onboarding**: Natural conversational voice intake supporting English (`en-IN`), Tamil (`ta-IN`), Hindi (`hi-IN`), Telugu (`te-IN`), Kannada (`kn-IN`), Malayalam (`ml-IN`), and Marathi (`mr-IN`) using Sarvam AI ASR/TTS.
|
||||
2. **Medical Document Analysis**: Vision-based processing of lab reports, prescriptions, and discharge summaries (JPEG, PNG, WEBP, PDF up to 10MB).
|
||||
3. **Session-Based State Engine**: Stateful intake tracking stored in persistent backend database.
|
||||
4. **Structured EHR Output**: Automatic clinical JSON result extraction matching standard Electronic Health Record (EHR) models.
|
||||
|
||||
---
|
||||
|
||||
## 2. Server Base URLs & CORS Configuration
|
||||
|
||||
### Server Base URLs:
|
||||
- **Local Development**: `http://localhost:8000` or `http://127.0.0.1:8000`
|
||||
- **Staging / Production**: Configured via environment variable (`{BASE_URL}`)
|
||||
|
||||
### CORS (Cross-Origin Resource Sharing) Policy:
|
||||
The backend enforces production-safe `CORSMiddleware`.
|
||||
- **Allowed Origins**: `http://localhost:3000`, `http://localhost:5173`, `http://127.0.0.1:3000`, `http://127.0.0.1:5173`, `https://onboarding.skyheal.ai` (configurable via `CORS_ORIGINS`).
|
||||
- **Allowed Credentials**: `true` (`Access-Control-Allow-Credentials: true`).
|
||||
- **Allowed Headers**: `Authorization`, `Content-Type`, `X-API-Key`, `X-Patient-ID`, `X-Request-ID`, `Accept`, `Origin`.
|
||||
- **Exposed Headers**: `X-Request-ID`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Quick Start Integration Flow
|
||||
|
||||
```
|
||||
[ Frontend App ]
|
||||
│
|
||||
├── 1. POST /v1/onboarding/start (or /v1/voice-onboarding/start)
|
||||
│ └─► Returns session_id (e.g. "ONB-8F12A9C4" or "VOICE-D3CAEA5C")
|
||||
│
|
||||
├── 2. POST /v1/onboarding/{session_id}/message (or /turn for voice)
|
||||
│ └─► Exchanges multi-turn symptom history & receiving AI responses
|
||||
│
|
||||
├── 3. POST /v1/documents/{session_id}/upload (Optional)
|
||||
│ └─► Uploads medical reports / lab results
|
||||
│
|
||||
└── 4. POST /v1/onboarding/{session_id}/complete
|
||||
└─► Returns final structured EHR result (PatientOnboardingResult)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Master API Endpoint Directory
|
||||
|
||||
| HTTP Method | Endpoint Path | Auth Required | Rate Limit | Purpose |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| `GET` | `/health` | No | None | Service health check & AI provider status |
|
||||
| `POST` | `/v1/onboarding/start` | **Yes** | 30 req/min | Start text onboarding session |
|
||||
| `POST` | `/v1/onboarding/{session_id}/message` | **Yes** | 30 req/min | Send text intake message to AI |
|
||||
| `POST` | `/v1/onboarding/{session_id}/complete` | **Yes** | 30 req/min | Finalize text session & output structured result |
|
||||
| `POST` | `/v1/voice-onboarding/start` | **Yes** | 30 req/min | Start voice onboarding session |
|
||||
| `POST` | `/v1/voice-onboarding/{session_id}/turn` | **Yes** | 30 req/min | Process speech recording or text fallback turn |
|
||||
| `POST` | `/v1/voice-onboarding/{session_id}/complete` | **Yes** | 30 req/min | Finalize voice session & output structured result |
|
||||
| `GET` | `/v1/voice-onboarding/audio/{audio_id}` | No | None | Stream TTS audio response (WAV/MP3) |
|
||||
| `POST` | `/v1/documents/{session_id}/upload` | **Yes** | 10 req/min | Upload medical document / report (10MB max) |
|
||||
|
||||
---
|
||||
|
||||
## 5. Authentication & Patient Security
|
||||
|
||||
### Authentication Mechanisms:
|
||||
|
||||
#### Method A: HTTP Bearer Token (Standard)
|
||||
```http
|
||||
Authorization: Bearer <ACCESS_TOKEN>
|
||||
```
|
||||
|
||||
#### Method B: `X-API-Key` Custom Header
|
||||
```http
|
||||
X-API-Key: <API_KEY>
|
||||
```
|
||||
|
||||
### Supported Access Tokens:
|
||||
1. **Service Secret API Key**: `skyheal_secure_api_key_2026` (configurable via `API_SECRET_KEY` in server environment).
|
||||
2. **Patient Development Tokens**: `test_token_<PATIENT_ID>` (e.g. `test_token_PAT-10023`). Automatically establishes patient identity context.
|
||||
|
||||
### Patient Isolation & IDOR Protection Rules:
|
||||
1. When starting a session (`/v1/onboarding/start`), `patient.id` is bound as session owner (`session.patient_id`).
|
||||
2. Subsequent requests verify caller's authenticated `patient_id` matches session owner.
|
||||
3. Access attempts by other patients return **`HTTP 403 Forbidden`**.
|
||||
|
||||
---
|
||||
|
||||
## 6. Text Onboarding API Specifications
|
||||
|
||||
### 6.1. POST `/v1/onboarding/start`
|
||||
|
||||
Starts a new text-based clinical onboarding intake session.
|
||||
|
||||
#### Request Headers:
|
||||
```http
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer test_token_PAT-10023
|
||||
```
|
||||
|
||||
#### Request Body (`StartOnboardingRequest`):
|
||||
```json
|
||||
{
|
||||
"patient": {
|
||||
"id": "PAT-10023",
|
||||
"name": "Kavitha Ramesh",
|
||||
"age": 35,
|
||||
"gender": "female",
|
||||
"phone": "+919876543210",
|
||||
"abha_id": "91-1234-5678-9012",
|
||||
"insurance_scheme": "PMJAY",
|
||||
"dietary_habits": "Vegetarian"
|
||||
},
|
||||
"category": {
|
||||
"type": "general",
|
||||
"code": "general_medicine",
|
||||
"name": "General Medicine"
|
||||
},
|
||||
"language": "en"
|
||||
}
|
||||
```
|
||||
|
||||
#### Field Descriptions:
|
||||
|
||||
| Field | Type | Required | Validation Rules | Description |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| `patient.id` | string | **Yes** | Min length 1 | Unique patient identifier |
|
||||
| `patient.name` | string | No | Optional | Full name of patient |
|
||||
| `patient.age` | integer | No | $0 \le \text{age} \le 120$ | Patient age in years |
|
||||
| `patient.gender` | string | No | Optional | Gender identity ("male", "female", "other") |
|
||||
| `category.type` | string | **Yes** | `"general"` or `"speciality"` | Onboarding workflow type |
|
||||
| `category.code` | string | **Yes** | Min length 1 | Specialty classification code |
|
||||
| `category.name` | string | **Yes** | Min length 1 | Category display name |
|
||||
| `language` | string | No | Default `"en"` | Preferred intake language |
|
||||
|
||||
#### Successful Response (`200 OK` - `StartOnboardingResponse`):
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"session_id": "ONB-8F12A9C4",
|
||||
"patient_id": "PAT-10023",
|
||||
"category": {
|
||||
"type": "general",
|
||||
"code": "general_medicine",
|
||||
"name": "General Medicine"
|
||||
},
|
||||
"language": "en",
|
||||
"status": "in_progress",
|
||||
"stage": "symptoms",
|
||||
"message": "Hello Kavitha Ramesh! I am your clinical onboarding assistant. What symptoms are you experiencing today?"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6.2. POST `/v1/onboarding/{session_id}/message`
|
||||
|
||||
Sends a patient chat response to the AI intake orchestrator.
|
||||
|
||||
#### Request Body (`SendMessageRequest`):
|
||||
```json
|
||||
{
|
||||
"message": "I have had severe chest tightness and shortness of breath for 2 days."
|
||||
}
|
||||
```
|
||||
|
||||
#### Successful Response (`200 OK` - `SendMessageResponse`):
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"session_id": "ONB-8F12A9C4",
|
||||
"stage": "medical_history",
|
||||
"message": "Thank you for sharing. Does the chest tightness radiate to your arm or jaw, and do you have any history of high blood pressure?",
|
||||
"status": "in_progress",
|
||||
"response_time_ms": 1529.74,
|
||||
"response_time_sec": 1.53
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6.3. POST `/v1/onboarding/{session_id}/complete`
|
||||
|
||||
Finalizes the intake session and outputs the structured clinical EHR payload.
|
||||
|
||||
#### Request Body (`CompleteOnboardingRequest`):
|
||||
```json
|
||||
{
|
||||
"confirmed": true
|
||||
}
|
||||
```
|
||||
|
||||
#### Successful Response (`200 OK` - `CompleteOnboardingResponse`):
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"session_id": "ONB-8F12A9C4",
|
||||
"status": "completed",
|
||||
"result": {
|
||||
"patient_id": "PAT-10023",
|
||||
"abha_id": "91-1234-5678-9012",
|
||||
"phone": "+919876543210",
|
||||
"insurance_scheme": "PMJAY",
|
||||
"category": {
|
||||
"type": "general",
|
||||
"code": "general_medicine",
|
||||
"name": "General Medicine"
|
||||
},
|
||||
"chief_complaint": "Chest tightness and shortness of breath for 2 days",
|
||||
"symptoms": [
|
||||
{
|
||||
"name": "Chest tightness",
|
||||
"duration": "2 days",
|
||||
"severity": "severe",
|
||||
"description": "Triggered during exertion"
|
||||
}
|
||||
],
|
||||
"medical_history": ["Prehypertension"],
|
||||
"medications": ["Metformin 500mg"],
|
||||
"allergies": ["Penicillin"],
|
||||
"dietary_habits": "Vegetarian",
|
||||
"additional_information": "Patient confirmed details.",
|
||||
"language": "en",
|
||||
"onboarding_completed": true
|
||||
},
|
||||
"message": "Onboarding successfully completed and saved to patient record.",
|
||||
"response_time_ms": 4075.41,
|
||||
"response_time_sec": 4.08
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Voice Onboarding API Specifications
|
||||
|
||||
### 7.1. POST `/v1/voice-onboarding/start`
|
||||
|
||||
Starts a voice-first onboarding session with Sarvam AI multilingual speech engine.
|
||||
|
||||
#### Supported Languages:
|
||||
`en-IN` (Indian English), `ta-IN` (Tamil), `hi-IN` (Hindi), `te-IN` (Telugu), `kn-IN` (Kannada), `ml-IN` (Malayalam), `mr-IN` (Marathi).
|
||||
|
||||
#### Request Body:
|
||||
```json
|
||||
{
|
||||
"patient": {
|
||||
"id": "PAT-VOICE-1",
|
||||
"name": "Ramesh G",
|
||||
"age": 34,
|
||||
"gender": "male"
|
||||
},
|
||||
"category": {
|
||||
"type": "general",
|
||||
"code": "general_medicine",
|
||||
"name": "General Medicine"
|
||||
},
|
||||
"language": "ta-IN"
|
||||
}
|
||||
```
|
||||
|
||||
#### Successful Response (`200 OK`):
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"session_id": "VOICE-D3CAEA5C",
|
||||
"patient_id": "PAT-VOICE-1",
|
||||
"category": {
|
||||
"type": "general",
|
||||
"code": "general_medicine",
|
||||
"name": "General Medicine"
|
||||
},
|
||||
"language": "ta-IN",
|
||||
"status": "in_progress",
|
||||
"stage": "greeting",
|
||||
"greeting_text": "வணக்கம் Ramesh G, CureSelect மருத்துவமனைக்கு வரவேற்கிறோம். இன்று உங்களுக்கு என்ன சுகவீனம் உள்ளது?",
|
||||
"audio_url": "/v1/voice-onboarding/audio/aud_a1b2c3d4e5f6",
|
||||
"response_time_ms": 78.32,
|
||||
"response_time_sec": 0.08
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 7.2. POST `/v1/voice-onboarding/{session_id}/turn`
|
||||
|
||||
Processes a conversational voice turn using audio recording or text fallback.
|
||||
|
||||
#### Multipart Form Data:
|
||||
- `audio`: Binary audio recording file (`WAV`, `WEBM`, `MP3`, `OGG` up to 10MB).
|
||||
- `text_fallback`: String text transcript (used if WebSpeech STT processed client-side).
|
||||
|
||||
#### Successful Response (`200 OK`):
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"session_id": "VOICE-D3CAEA5C",
|
||||
"patient_transcript": "எனக்குகடந்த 3 நாளா நெஞ்சு பாரமா இருக்குங்க.",
|
||||
"ai_text": "நன்றி. இந்த நெஞ்சு பாரம் உடற்பயிற்சி செய்யும் போது அதிகமாகிறதா?",
|
||||
"stage": "symptoms",
|
||||
"completed": false,
|
||||
"audio_url": "/v1/voice-onboarding/audio/aud_f9e8d7c6b5a4",
|
||||
"audio_base64": null,
|
||||
"suggested_specialty": {
|
||||
"specialty": "Cardiology",
|
||||
"reason": "Chest tightness reported"
|
||||
},
|
||||
"response_time_ms": 1412.37,
|
||||
"response_time_sec": 1.41
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 7.3. GET `/v1/voice-onboarding/audio/{audio_id}`
|
||||
|
||||
Streams generated TTS audio for browser or mobile playback.
|
||||
|
||||
```javascript
|
||||
const audio = new Audio(`${BASE_URL}/v1/voice-onboarding/audio/${audioId}`);
|
||||
await audio.play();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Medical Document Analysis API
|
||||
|
||||
### POST `/v1/documents/{session_id}/upload`
|
||||
|
||||
Uploads a medical report / prescription for vision extraction.
|
||||
|
||||
- **Content-Type**: `multipart/form-data`
|
||||
- **File Limit**: Max `10MB` (`10,485,760 bytes`).
|
||||
- **Allowed Formats**: `image/jpeg`, `image/png`, `image/webp`, `application/pdf`.
|
||||
|
||||
#### Successful Response (`200 OK`):
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"session_id": "ONB-8F12A9C4",
|
||||
"document_type": "lab_report",
|
||||
"summary": "Report indicates elevated HbA1c (7.2%) and stage 1 hypertension (130/85 mmHg).",
|
||||
"findings": [
|
||||
{
|
||||
"test": "HbA1c",
|
||||
"value": "7.2",
|
||||
"unit": "%",
|
||||
"interpretation": "Elevated"
|
||||
}
|
||||
],
|
||||
"medications_found": ["Metformin 500mg"],
|
||||
"allergies_found": ["Penicillin"],
|
||||
"message": "Medical document analyzed and integrated into onboarding session."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Error Handling & Status Codes
|
||||
|
||||
| HTTP Status Code | Meaning | Cause & Resolution |
|
||||
| :--- | :--- | :--- |
|
||||
| `400 Bad Request` | Bad Request | Invalid category or unsupported file format. |
|
||||
| `401 Unauthorized` | Unauthorized | Missing or invalid `Authorization: Bearer` or `X-API-Key` header. |
|
||||
| `403 Forbidden` | Forbidden (IDOR) | Authenticated `patient_id` does not match session owner. |
|
||||
| `404 Not Found` | Not Found | Session ID does not exist in backend database. |
|
||||
| `413 Payload Too Large` | File Too Large | Uploaded document exceeds 10MB limit. |
|
||||
| `422 Validation Error` | Pydantic Error | Request body violates schema rules (e.g. `age < 0`). |
|
||||
| `429 Too Many Requests` | Rate Limited | Exceeded rate limit bucket (30 req/min). Check `Retry-After` header. |
|
||||
| `500 Internal Error` | Server Error | Unhandled backend failure. |
|
||||
|
||||
---
|
||||
|
||||
## 10. Frontend React & TypeScript Implementation Examples
|
||||
|
||||
### React API Client (`src/api/onboardingClient.ts`)
|
||||
|
||||
```typescript
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000';
|
||||
|
||||
export interface PatientContext {
|
||||
id: string;
|
||||
name?: string;
|
||||
age?: number;
|
||||
gender?: string;
|
||||
phone?: string;
|
||||
abha_id?: string;
|
||||
}
|
||||
|
||||
export interface CategoryContext {
|
||||
type: 'general' | 'speciality';
|
||||
code: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
async function fetchApi<T>(endpoint: string, options: RequestInit = {}, patientId: string): Promise<T> {
|
||||
const token = `test_token_${patientId}`;
|
||||
const headers = new Headers(options.headers || {});
|
||||
headers.set('Authorization', `Bearer ${token}`);
|
||||
if (!(options.body instanceof FormData)) {
|
||||
headers.set('Content-Type', 'application/json');
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, { ...options, headers });
|
||||
if (!response.ok) {
|
||||
const errorBody = await response.json().catch(() => ({ detail: 'Network response error' }));
|
||||
throw new Error(errorBody.detail || `HTTP Error ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function startOnboarding(patient: PatientContext, category: CategoryContext, language: string = 'en') {
|
||||
return fetchApi<any>('/v1/onboarding/start', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ patient, category, language }),
|
||||
}, patient.id);
|
||||
}
|
||||
|
||||
export async function sendMessage(sessionId: string, patientId: string, message: string) {
|
||||
return fetchApi<any>(`/v1/onboarding/${sessionId}/message`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ message }),
|
||||
}, patientId);
|
||||
}
|
||||
|
||||
export async function completeOnboarding(sessionId: string, patientId: string, confirmed: boolean = true) {
|
||||
return fetchApi<any>(`/v1/onboarding/${sessionId}/complete`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ confirmed }),
|
||||
}, patientId);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. Frontend Integration Checklist
|
||||
|
||||
```text
|
||||
[ ] Configure API_BASE_URL in environment (VITE_API_BASE_URL=http://localhost:8000)
|
||||
[ ] Verify CORS preflight request from localhost:3000 / localhost:5173
|
||||
[ ] Attach Authorization: Bearer test_token_<PATIENT_ID> header
|
||||
[ ] Call POST /v1/onboarding/start and store session_id
|
||||
[ ] Multi-turn chat message sending via POST /v1/onboarding/{session_id}/message
|
||||
[ ] Handle 401 Unauthorized & 403 Forbidden errors
|
||||
[ ] Upload medical documents via POST /v1/documents/{session_id}/upload (10MB max)
|
||||
[ ] Call POST /v1/onboarding/{session_id}/complete to retrieve final PatientOnboardingResult
|
||||
[ ] Test voice onboarding audio streaming via GET /v1/voice-onboarding/audio/{audio_id}
|
||||
```
|
||||
@@ -0,0 +1,256 @@
|
||||
# CureSelect AI — Real-Time Voice Intake & EHR Frontend Integration Guide
|
||||
|
||||
**Version**: `2.0.0 (Production Release)`
|
||||
**Target Audience**: Frontend Engineers (React, Next.js, Vue, React Native, Flutter, Vanilla TypeScript)
|
||||
**Backend Protocol**: Full-Duplex WebSockets (`ws://`) & HTTP/1.1 REST
|
||||
**Authentication Header**: `X-API-Key: skyheal_secure_api_key_2026` or `Authorization: Bearer <TOKEN>`
|
||||
**Default Dashboard**: `http://localhost:8000/` or `http://localhost:8000/dashboard`
|
||||
|
||||
---
|
||||
|
||||
## 1. Architectural Overview
|
||||
|
||||
The **CureSelect AI Voice Engine** is a high-speed, full-duplex conversational health intake system. It processes real-time microphone audio or text turns, classifies speaker perspectives, detects emergency distress, checks RxNorm drug safety, redacts HIPAA/ABDM PII, and generates real-time HL7 FHIR R4 Bundles and Doctor SOAP Notes.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Client as Frontend (React / Web / Mobile)
|
||||
participant WS as WebSocket Manager (ws/auto)
|
||||
participant Engine as AI Orchestrator & Multi-Models
|
||||
participant EHR as EHR Export (FHIR / SOAP Note)
|
||||
|
||||
Client->>WS: Connect (ws://localhost:8000/v1/voice-onboarding/ws/auto)
|
||||
WS-->>Client: {"type": "connected", "session_id": "VOICE-AUTO-8F12A9C4"}
|
||||
WS-->>Client: {"type": "ai_response", "reply": "Hello Patient, welcome to CureSelect..."}
|
||||
|
||||
Client->>WS: Stream Binary Mic Bytes (MediaRecorder WebM/PCM)
|
||||
WS-->>Client: {"type": "backchannel", "reply": "Mm-hmm..."} (< 15ms)
|
||||
WS-->>Client: {"type": "speculative_audio_chunk", "ttfa_ms": 180} (< 200ms)
|
||||
WS-->>Client: {"type": "transcript", "speaker_type": "caregiver", "speaker_label": "Caregiver (Mother)"}
|
||||
WS-->>Client: {"type": "ai_response", "reply": "I'm sorry to hear about your mother's headache...", "audio_base64": "..."}
|
||||
|
||||
Client->>EHR: GET /v1/voice-onboarding/soap-note/{session_id}?format=html
|
||||
EHR-->>Client: Render Printable Doctor SOAP Note Report
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. WebSocket Connection Lifecycle (`ws://localhost:8000/v1/voice-onboarding/ws/auto`)
|
||||
|
||||
### 2.1 Connection Setup
|
||||
Connect to the zero-effort auto-session WebSocket endpoint:
|
||||
```typescript
|
||||
const ws = new WebSocket("ws://localhost:8000/v1/voice-onboarding/ws/auto");
|
||||
```
|
||||
|
||||
### 2.2 Inbound WebSocket Event Schema
|
||||
|
||||
#### 1. Connection Initialized (`connected`)
|
||||
Sent immediately upon socket handshake:
|
||||
```json
|
||||
{
|
||||
"type": "connected",
|
||||
"session_id": "VOICE-AUTO-9DEC79EC",
|
||||
"message": "Connected to CureSelect Real-Time Voice Engine"
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. Speaker Diarized Transcript (`transcript`)
|
||||
Sent as soon as patient or caregiver speech is recognized:
|
||||
```json
|
||||
{
|
||||
"type": "transcript",
|
||||
"role": "patient",
|
||||
"speaker_type": "caregiver",
|
||||
"speaker_label": "Caregiver (Mother)",
|
||||
"text": "My mother has severe headache for 10 days and she is taking Metformin 500mg."
|
||||
}
|
||||
```
|
||||
*Note*: `speaker_type` is either `"patient"` or `"caregiver"`.
|
||||
|
||||
#### 3. Low-Latency Acoustic Backchannel (`backchannel`)
|
||||
Emitted in **< 15ms** during patient pauses to signal active listening:
|
||||
```json
|
||||
{
|
||||
"type": "backchannel",
|
||||
"reply": "Mm-hmm...",
|
||||
"audio_base64": "..."
|
||||
}
|
||||
```
|
||||
|
||||
#### 4. Speculative Audio Chunk (`speculative_audio_chunk`)
|
||||
Emitted in **< 200ms** on the first 3-4 words generated by the LLM:
|
||||
```json
|
||||
{
|
||||
"type": "speculative_audio_chunk",
|
||||
"clause_text": "I am sorry to hear that.",
|
||||
"audio_base64": "...",
|
||||
"ttfa_ms": 180
|
||||
}
|
||||
```
|
||||
|
||||
#### 5. Complete AI Response & State Update (`ai_response`)
|
||||
Contains full reply, TTS audio, next stage, and updated clinical state:
|
||||
```json
|
||||
{
|
||||
"type": "ai_response",
|
||||
"reply": "Any known drug allergies?",
|
||||
"audio_id": "aud_771829",
|
||||
"audio_base64": "...",
|
||||
"next_stage": "symptom_collection",
|
||||
"completed": false,
|
||||
"state_updates": {
|
||||
"symptoms": [{"name": "headache", "duration": "10 days", "severity": "severe"}],
|
||||
"medications": [{"name": "Metformin", "dosage": "500mg daily"}]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 6. Emergency Nurse Escalation Alert (`nurse_escalation_alert`)
|
||||
Emitted when Pain Score >= 8/10 or Distress Score > 0.75:
|
||||
```json
|
||||
{
|
||||
"type": "nurse_escalation_alert",
|
||||
"session_id": "VOICE-AUTO-9DEC79EC",
|
||||
"distress_score": 0.85,
|
||||
"pain_score": 9,
|
||||
"message": "CRITICAL: Patient reported severe pain (9/10). Nurse escalation triggered!"
|
||||
}
|
||||
```
|
||||
|
||||
#### 7. RxNorm / FDA Drug Safety Warning (`drug_safety_warning`)
|
||||
Emitted when drug interactions or drug-allergy contraindications are flagged:
|
||||
```json
|
||||
{
|
||||
"type": "drug_safety_warning",
|
||||
"warnings": [
|
||||
{
|
||||
"severity": "CRITICAL",
|
||||
"category": "DRUG_INTERACTION",
|
||||
"description": "High risk of bleeding: Combined use of Warfarin and Aspirin."
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Outbound Client Messages
|
||||
|
||||
### 3.1 Streaming Audio Bytes
|
||||
Stream raw WebM / PCM audio blobs from `MediaRecorder`:
|
||||
```javascript
|
||||
mediaRecorder.ondataavailable = (e) => {
|
||||
if (e.data.size > 0 && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(e.data); // Send raw Blob
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### 3.2 Text Turn Fallback
|
||||
```json
|
||||
{
|
||||
"type": "text_turn",
|
||||
"text": "10 days-ah severe headache iruku, Metformin 500mg sapudren."
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 Full-Duplex Barge-In Interruption
|
||||
Send when the user speaks or clicks "Interrupt AI" while AI audio is playing:
|
||||
```json
|
||||
{
|
||||
"type": "interrupt"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. REST API Integration Endpoints
|
||||
|
||||
All REST requests require the authentication header:
|
||||
`X-API-Key: skyheal_secure_api_key_2026`
|
||||
|
||||
### 4.1 HL7 FHIR R4 Bundle Export
|
||||
- **Endpoint**: `GET /v1/voice-onboarding/fhir/{session_id}`
|
||||
- **Response**: Production-grade HL7 FHIR R4 Bundle (`Patient`, `Encounter`, `Condition`, `MedicationStatement`, `AllergyIntolerance`).
|
||||
|
||||
```bash
|
||||
curl -X GET "http://localhost:8000/v1/voice-onboarding/fhir/VOICE-AUTO-9DEC79EC" \
|
||||
-H "X-API-Key: skyheal_secure_api_key_2026"
|
||||
```
|
||||
|
||||
### 4.2 Doctor SOAP Note & Printable PDF/HTML Report
|
||||
- **Endpoint**: `GET /v1/voice-onboarding/soap-note/{session_id}?format=json` (or `format=html`)
|
||||
- **Query Params**:
|
||||
- `format=json`: Returns structured Subjective, Objective, Assessment (ICD-10-CM & SNOMED-CT codes), and Plan JSON.
|
||||
- `format=html`: Renders a clean printable HTML document for 1-click doctor chart printing.
|
||||
|
||||
```bash
|
||||
# Get JSON SOAP Note with ICD-10 & SNOMED CT
|
||||
curl -X GET "http://localhost:8000/v1/voice-onboarding/soap-note/VOICE-AUTO-9DEC79EC?format=json" \
|
||||
-H "X-API-Key: skyheal_secure_api_key_2026"
|
||||
|
||||
# Get Printable HTML Report
|
||||
curl -X GET "http://localhost:8000/v1/voice-onboarding/soap-note/VOICE-AUTO-9DEC79EC?format=html" \
|
||||
-H "X-API-Key: skyheal_secure_api_key_2026"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. React / Next.js Custom Hook Example
|
||||
|
||||
```typescript
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
|
||||
export function useVoiceIntakeWebSocket(url: string = "ws://localhost:8000/v1/voice-onboarding/ws/auto") {
|
||||
const [sessionId, setSessionId] = useState<string>("");
|
||||
const [messages, setMessages] = useState<Array<any>>([]);
|
||||
const [clinicalState, setClinicalState] = useState<any>({});
|
||||
const [escalationAlert, setEscalationAlert] = useState<string | null>(null);
|
||||
const [drugWarnings, setDrugWarnings] = useState<Array<any>>([]);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const ws = new WebSocket(url);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.type === "connected") {
|
||||
setSessionId(data.session_id);
|
||||
} else if (data.type === "transcript") {
|
||||
setMessages(prev => [...prev, { role: data.role, text: data.text, label: data.speaker_label }]);
|
||||
} else if (data.type === "ai_response") {
|
||||
setMessages(prev => [...prev, { role: "assistant", text: data.reply, label: "AI Assistant" }]);
|
||||
if (data.audio_base64) {
|
||||
const audio = new Audio("data:audio/wav;base64," + data.audio_base64);
|
||||
audio.play().catch(console.error);
|
||||
}
|
||||
if (data.state_updates) {
|
||||
setClinicalState(data.state_updates);
|
||||
}
|
||||
} else if (data.type === "nurse_escalation_alert") {
|
||||
setEscalationAlert(data.message);
|
||||
} else if (data.type === "drug_safety_warning") {
|
||||
setDrugWarnings(data.warnings);
|
||||
}
|
||||
};
|
||||
|
||||
return () => ws.close();
|
||||
}, [url]);
|
||||
|
||||
const sendTextTurn = (text: string) => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify({ type: "text_turn", text }));
|
||||
}
|
||||
};
|
||||
|
||||
const interruptAI = () => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify({ type: "interrupt" }));
|
||||
}
|
||||
};
|
||||
|
||||
return { sessionId, messages, clinicalState, escalationAlert, drugWarnings, sendTextTurn, interruptAI };
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,870 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>CureSelect AI - Clinical Voice Intake & Real-Time EHR Dashboard</title>
|
||||
<!-- Google Fonts & FontAwesome Icons -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--bg-dark: #090d16;
|
||||
--bg-card: rgba(17, 24, 39, 0.75);
|
||||
--bg-card-hover: rgba(31, 41, 55, 0.85);
|
||||
--border-color: rgba(255, 255, 255, 0.08);
|
||||
--accent-blue: #3b82f6;
|
||||
--accent-cyan: #06b6d4;
|
||||
--accent-green: #10b981;
|
||||
--accent-red: #ef4444;
|
||||
--accent-amber: #f59e0b;
|
||||
--accent-purple: #8b5cf6;
|
||||
--text-main: #f3f4f6;
|
||||
--text-muted: #9ca3af;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--bg-dark);
|
||||
background-image:
|
||||
radial-gradient(at 0% 0%, rgba(59, 130, 246, 0.12) 0px, transparent 50%),
|
||||
radial-gradient(at 100% 100%, rgba(139, 92, 246, 0.12) 0px, transparent 50%);
|
||||
color: var(--text-main);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Top Header Navigation */
|
||||
header {
|
||||
background: rgba(15, 23, 42, 0.8);
|
||||
backdrop-filter: blur(16px);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding: 16px 32px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.logo-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: linear-gradient(135deg, var(--accent-blue), var(--accent-cyan));
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
color: white;
|
||||
box-shadow: 0 0 20px rgba(6, 182, 212, 0.4);
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(90deg, #ffffff, #93c5fd);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
border: 1px solid rgba(16, 185, 129, 0.3);
|
||||
color: var(--accent-green);
|
||||
padding: 6px 14px;
|
||||
border-radius: 20px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.pulse-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background-color: var(--accent-green);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 10px var(--accent-green);
|
||||
animation: pulse 1.8s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { transform: scale(0.95); opacity: 0.8; }
|
||||
50% { transform: scale(1.3); opacity: 1; }
|
||||
100% { transform: scale(0.95); opacity: 0.8; }
|
||||
}
|
||||
|
||||
/* Critical Escalation Banner */
|
||||
.nurse-banner {
|
||||
display: none;
|
||||
background: linear-gradient(90deg, #7f1d1d, #b91c1c);
|
||||
border-bottom: 2px solid #f87171;
|
||||
color: white;
|
||||
padding: 14px 32px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-weight: 600;
|
||||
animation: bannerFlash 1s infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes bannerFlash {
|
||||
from { background: linear-gradient(90deg, #7f1d1d, #b91c1c); }
|
||||
to { background: linear-gradient(90deg, #991b1b, #dc2626); }
|
||||
}
|
||||
|
||||
.nurse-banner.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* Main Container Grid */
|
||||
.main-container {
|
||||
flex: 1;
|
||||
padding: 24px 32px;
|
||||
display: grid;
|
||||
grid-template-columns: 1.2fr 0.8fr;
|
||||
gap: 24px;
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Card Styles */
|
||||
.glass-card {
|
||||
background: var(--bg-card);
|
||||
backdrop-filter: blur(16px);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #e5e7eb;
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Left Column: Voice Interaction & Live Feed */
|
||||
.voice-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.mic-control-box {
|
||||
background: rgba(15, 23, 42, 0.6);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.waveform-canvas {
|
||||
width: 100%;
|
||||
height: 80px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.mic-button {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, var(--accent-blue), var(--accent-cyan));
|
||||
border: none;
|
||||
color: white;
|
||||
font-size: 30px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 0 30px rgba(6, 182, 212, 0.4);
|
||||
transition: all 0.3s ease;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.mic-button.recording {
|
||||
background: linear-gradient(135deg, #ef4444, #dc2626);
|
||||
box-shadow: 0 0 40px rgba(239, 68, 68, 0.6);
|
||||
animation: pulseRecord 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulseRecord {
|
||||
0% { transform: scale(1); }
|
||||
50% { transform: scale(1.08); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-main);
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
border-color: rgba(239, 68, 68, 0.4);
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: rgba(239, 68, 68, 0.35);
|
||||
}
|
||||
|
||||
/* Conversation Transcript Area */
|
||||
.chat-area {
|
||||
flex: 1;
|
||||
min-height: 380px;
|
||||
max-height: 480px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.chat-bubble {
|
||||
max-width: 85%;
|
||||
padding: 14px 18px;
|
||||
border-radius: 14px;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.chat-bubble.ai {
|
||||
background: rgba(30, 41, 59, 0.8);
|
||||
border: 1px solid rgba(59, 130, 246, 0.2);
|
||||
align-self: flex-start;
|
||||
border-bottom-left-radius: 2px;
|
||||
}
|
||||
|
||||
.chat-bubble.patient {
|
||||
background: rgba(14, 116, 144, 0.3);
|
||||
border: 1px solid rgba(6, 182, 212, 0.3);
|
||||
align-self: flex-end;
|
||||
border-bottom-right-radius: 2px;
|
||||
}
|
||||
|
||||
.chat-bubble.caregiver {
|
||||
background: rgba(126, 34, 206, 0.25);
|
||||
border: 1px solid rgba(168, 85, 247, 0.3);
|
||||
align-self: flex-end;
|
||||
border-bottom-right-radius: 2px;
|
||||
}
|
||||
|
||||
.bubble-meta {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 4px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.speaker-tag {
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.tag-patient { background: rgba(6, 182, 212, 0.2); color: var(--accent-cyan); }
|
||||
.tag-caregiver { background: rgba(168, 85, 247, 0.2); color: #c084fc; }
|
||||
.tag-ai { background: rgba(59, 130, 246, 0.2); color: #93c5fd; }
|
||||
|
||||
/* Right Column: Real-Time Clinical Intake State & EHR Controls */
|
||||
.clinical-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.metrics-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
background: rgba(15, 23, 42, 0.6);
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 14px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #f3f4f6;
|
||||
}
|
||||
|
||||
/* Drug Warning Alert Card */
|
||||
.warning-card {
|
||||
display: none;
|
||||
background: rgba(245, 158, 11, 0.12);
|
||||
border: 1px solid rgba(245, 158, 11, 0.3);
|
||||
border-radius: 12px;
|
||||
padding: 14px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.warning-card.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.warning-header {
|
||||
color: var(--accent-amber);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
/* State JSON Tree View */
|
||||
.state-box {
|
||||
background: #050811;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
padding: 14px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 12px;
|
||||
color: #38bdf8;
|
||||
max-height: 250px;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
/* Modal Overlay */
|
||||
.modal-overlay {
|
||||
display: none;
|
||||
position.fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
backdrop-filter: blur(8px);
|
||||
z-index: 200;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.modal-overlay.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.modal-card {
|
||||
background: #0f172a;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 16px;
|
||||
width: 90%;
|
||||
max-width: 850px;
|
||||
max-height: 85vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 20px 50px rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 18px 24px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 24px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 13px;
|
||||
background: #090d16;
|
||||
color: #a7f3d0;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 16px 24px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Header Navigation -->
|
||||
<header>
|
||||
<div class="logo-container">
|
||||
<div class="logo-icon"><i class="fa-solid fa-heart-pulse"></i></div>
|
||||
<div class="logo-text">CureSelect AI Voice Intake</div>
|
||||
</div>
|
||||
<div style="display: flex; gap: 16px; align-items: center;">
|
||||
<div class="status-badge">
|
||||
<div class="pulse-dot"></div>
|
||||
<span id="wsStatus">Connected (ws://localhost:8000/v1/voice-onboarding/ws/auto)</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Critical Nurse Escalation Banner -->
|
||||
<div id="nurseBanner" class="nurse-banner">
|
||||
<div style="display: flex; align-items: center; gap: 12px;">
|
||||
<i class="fa-solid fa-triangle-exclamation fa-lg"></i>
|
||||
<div>
|
||||
<span style="font-size: 15px;">CRITICAL NURSE ESCALATION ALERT:</span>
|
||||
<span id="bannerText" style="font-weight: 400; margin-left: 8px;">High Pain Score (9/10) reported. Immediate clinical review required!</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-danger" onclick="dismissBanner()">Acknowledge</button>
|
||||
</div>
|
||||
|
||||
<!-- Main Workspace Container -->
|
||||
<div class="main-container">
|
||||
|
||||
<!-- Left Column: Voice Interaction & Live Feed -->
|
||||
<div class="voice-section">
|
||||
|
||||
<div class="glass-card mic-control-box">
|
||||
<div class="card-title" style="margin-bottom: 8px;">
|
||||
<i class="fa-solid fa-microphone-lines" style="color: var(--accent-cyan);"></i>
|
||||
Real-Time Audio Streamer
|
||||
</div>
|
||||
|
||||
<canvas id="waveform" class="waveform-canvas"></canvas>
|
||||
|
||||
<button id="micBtn" class="mic-button" onclick="toggleMic()">
|
||||
<i class="fa-solid fa-microphone"></i>
|
||||
</button>
|
||||
|
||||
<div id="micStatusText" style="margin-top: 12px; font-size: 13px; color: var(--text-muted);">
|
||||
Click microphone to start full-duplex voice intake
|
||||
</div>
|
||||
|
||||
<div class="action-buttons">
|
||||
<button class="btn btn-danger" onclick="triggerInterrupt()">
|
||||
<i class="fa-solid fa-hand"></i> Interrupt AI (Barge-In)
|
||||
</button>
|
||||
<button class="btn" onclick="sendSampleTurn('Tanglish')">
|
||||
<i class="fa-solid fa-language"></i> Tanglish Turn
|
||||
</button>
|
||||
<button class="btn" onclick="sendSampleTurn('Caregiver')">
|
||||
<i class="fa-solid fa-people-roof"></i> Caregiver Turn
|
||||
</button>
|
||||
<button class="btn" onclick="sendSampleTurn('HighPain')">
|
||||
<i class="fa-solid fa-heart-crack"></i> High Pain (9/10)
|
||||
</button>
|
||||
<button class="btn" onclick="sendSampleTurn('DrugInteraction')">
|
||||
<i class="fa-solid fa-pills"></i> Drug Warning
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Conversation Transcript Area -->
|
||||
<div class="glass-card" style="flex: 1;">
|
||||
<div class="card-title">
|
||||
<i class="fa-solid fa-comments" style="color: var(--accent-purple);"></i>
|
||||
Live Conversation Stream
|
||||
</div>
|
||||
<div id="chatArea" class="chat-area">
|
||||
<div class="chat-bubble ai">
|
||||
<div class="bubble-meta">
|
||||
<span class="speaker-tag tag-ai">AI Assistant</span>
|
||||
<span>Greeting</span>
|
||||
</div>
|
||||
Hello Patient, welcome to CureSelect Healthcare. Could you please share what health symptoms you are experiencing today?
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Right Column: Real-Time Clinical Intake State & EHR Controls -->
|
||||
<div class="clinical-section">
|
||||
|
||||
<div class="glass-card">
|
||||
<div class="card-title">
|
||||
<i class="fa-solid fa-notes-medical" style="color: var(--accent-green);"></i>
|
||||
Live Clinical Metrics
|
||||
</div>
|
||||
|
||||
<div class="metrics-grid">
|
||||
<div class="metric-card">
|
||||
<div class="metric-label">Pain Score</div>
|
||||
<div id="metricPain" class="metric-value">3 / 10</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-label">Distress Score</div>
|
||||
<div id="metricDistress" class="metric-value">0.20</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-label">TTFA Latency</div>
|
||||
<div id="metricTtfa" class="metric-value">⚡ 180 ms</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-label">Speech Pace</div>
|
||||
<div id="metricPace" class="metric-value">1.0x (Standard)</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RxNorm / FDA Drug Safety Warning Card -->
|
||||
<div id="drugWarningCard" class="warning-card">
|
||||
<div class="warning-header">
|
||||
<i class="fa-solid fa-shield-virus"></i> RxNorm / FDA Drug Safety Warning
|
||||
</div>
|
||||
<div id="warningCardText" style="font-size: 12px; color: #fef08a;">
|
||||
No drug interaction warnings detected.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- State JSON Tree View -->
|
||||
<div class="glass-card" style="flex: 1;">
|
||||
<div class="card-title" style="justify-content: space-between;">
|
||||
<div>
|
||||
<i class="fa-solid fa-code" style="color: var(--accent-blue);"></i>
|
||||
Extracted Clinical State
|
||||
</div>
|
||||
<div style="display: flex; gap: 8px;">
|
||||
<button class="btn" onclick="openFhirModal()">
|
||||
<i class="fa-solid fa-fire"></i> FHIR R4
|
||||
</button>
|
||||
<button class="btn" onclick="openSoapModal()">
|
||||
<i class="fa-solid fa-file-medical"></i> SOAP Note
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="jsonState" class="state-box">
|
||||
{
|
||||
"reason_for_visit": "general_intake",
|
||||
"symptoms": [],
|
||||
"medical_history": [],
|
||||
"medications": [],
|
||||
"allergies": []
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Modal Dialog for FHIR & SOAP Note Viewers -->
|
||||
<div id="modalOverlay" class="modal-overlay">
|
||||
<div class="modal-card">
|
||||
<div class="modal-header">
|
||||
<div id="modalTitle" style="font-size: 16px; font-weight: 600; color: white;">HL7 FHIR R4 Bundle JSON</div>
|
||||
<button class="btn" onclick="closeModal()"><i class="fa-solid fa-xmark"></i> Close</button>
|
||||
</div>
|
||||
<div id="modalBody" class="modal-body">
|
||||
Loading...
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn" onclick="copyModalContent()"><i class="fa-solid fa-copy"></i> Copy JSON</button>
|
||||
<button id="modalHtmlBtn" class="btn" style="display: none;" onclick="openPrintableHtml()"><i class="fa-solid fa-print"></i> Open Printable Report</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- WebSockets & Web Audio Script -->
|
||||
<script>
|
||||
let ws;
|
||||
let isRecording = false;
|
||||
let audioContext;
|
||||
let mediaStream;
|
||||
let currentSessionId = "";
|
||||
|
||||
// Establish WebSocket Connection
|
||||
function connectWebSocket() {
|
||||
ws = new WebSocket("ws://localhost:8000/v1/voice-onboarding/ws/auto");
|
||||
|
||||
ws.onopen = () => {
|
||||
document.getElementById("wsStatus").innerText = "Connected (ws://localhost:8000/v1/voice-onboarding/ws/auto)";
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
handleWSMessage(data);
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
document.getElementById("wsStatus").innerText = "Disconnected. Retrying...";
|
||||
setTimeout(connectWebSocket, 2000);
|
||||
};
|
||||
}
|
||||
|
||||
// Handle WebSocket Message Events
|
||||
function handleWSMessage(data) {
|
||||
console.log("WS Event Received:", data);
|
||||
|
||||
if (data.type === "connected") {
|
||||
currentSessionId = data.session_id;
|
||||
} else if (data.type === "transcript") {
|
||||
addChatBubble(data.role, data.text, data.speaker_label, data.speaker_type);
|
||||
} else if (data.type === "backchannel") {
|
||||
console.log("⚡ Instant Backchannel:", data.reply);
|
||||
} else if (data.type === "speculative_audio_chunk") {
|
||||
document.getElementById("metricTtfa").innerText = `⚡ ${data.ttfa_ms} ms`;
|
||||
} else if (data.type === "ai_response") {
|
||||
addChatBubble("assistant", data.reply, "AI Assistant", "ai");
|
||||
if (data.audio_base64) {
|
||||
playAudioBase64(data.audio_base64);
|
||||
}
|
||||
if (data.state_updates) {
|
||||
updateClinicalState(data.state_updates);
|
||||
}
|
||||
} else if (data.type === "cancel_playback") {
|
||||
console.log("🛑 Playback Interrupted by User!");
|
||||
} else if (data.type === "nurse_escalation_alert") {
|
||||
showNurseBanner(data.message);
|
||||
document.getElementById("metricPain").innerText = `${data.pain_score} / 10`;
|
||||
document.getElementById("metricDistress").innerText = `${data.distress_score}`;
|
||||
} else if (data.type === "drug_safety_warning") {
|
||||
showDrugWarnings(data.warnings);
|
||||
}
|
||||
}
|
||||
|
||||
// Play Audio Chunk
|
||||
function playAudioBase64(b64) {
|
||||
try {
|
||||
const audio = new Audio("data:audio/wav;base64," + b64);
|
||||
audio.play().catch(e => console.error("Audio playback error:", e));
|
||||
} catch (e) {
|
||||
console.error("Audio error:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Render Chat Bubbles
|
||||
function addChatBubble(role, text, label, speakerType) {
|
||||
const chatArea = document.getElementById("chatArea");
|
||||
const bubble = document.createElement("div");
|
||||
|
||||
let bubbleClass = "chat-bubble ";
|
||||
let tagClass = "speaker-tag ";
|
||||
|
||||
if (role === "assistant" || speakerType === "ai") {
|
||||
bubbleClass += "ai";
|
||||
tagClass += "tag-ai";
|
||||
} else if (speakerType === "caregiver") {
|
||||
bubbleClass += "caregiver";
|
||||
tagClass += "tag-caregiver";
|
||||
} else {
|
||||
bubbleClass += "patient";
|
||||
tagClass += "tag-patient";
|
||||
}
|
||||
|
||||
bubble.className = bubbleClass;
|
||||
bubble.innerHTML = `
|
||||
<div class="bubble-meta">
|
||||
<span class="${tagClass}">${label || (role === 'assistant' ? 'AI Assistant' : 'Patient')}</span>
|
||||
<span>${new Date().toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}</span>
|
||||
</div>
|
||||
${text}
|
||||
`;
|
||||
chatArea.appendChild(bubble);
|
||||
chatArea.scrollTop = chatArea.scrollHeight;
|
||||
}
|
||||
|
||||
// Update Clinical State Tree
|
||||
function updateClinicalState(state) {
|
||||
document.getElementById("jsonState").innerText = JSON.stringify(state, null, 2);
|
||||
}
|
||||
|
||||
// Mic Toggle
|
||||
async function toggleMic() {
|
||||
const micBtn = document.getElementById("micBtn");
|
||||
const statusText = document.getElementById("micStatusText");
|
||||
|
||||
if (!isRecording) {
|
||||
try {
|
||||
mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
isRecording = true;
|
||||
micBtn.classList.add("recording");
|
||||
statusText.innerText = "Streaming microphone audio... Speak freely.";
|
||||
|
||||
const mediaRecorder = new MediaRecorder(mediaStream);
|
||||
mediaRecorder.ondataavailable = (e) => {
|
||||
if (e.data.size > 0 && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(e.data);
|
||||
}
|
||||
};
|
||||
mediaRecorder.start(250);
|
||||
window.currentMediaRecorder = mediaRecorder;
|
||||
} catch (e) {
|
||||
alert("Microphone permission denied or not available: " + e.message);
|
||||
}
|
||||
} else {
|
||||
isRecording = false;
|
||||
micBtn.classList.remove("recording");
|
||||
statusText.innerText = "Microphone paused. Click to resume.";
|
||||
if (window.currentMediaRecorder) window.currentMediaRecorder.stop();
|
||||
if (mediaStream) mediaStream.getTracks().forEach(t => t.stop());
|
||||
ws.send(JSON.stringify({ type: "finish_turn" }));
|
||||
}
|
||||
}
|
||||
|
||||
function triggerInterrupt() {
|
||||
ws.send(JSON.stringify({ type: "interrupt" }));
|
||||
}
|
||||
|
||||
function sendSampleTurn(type) {
|
||||
if (type === "Tanglish") {
|
||||
ws.send(JSON.stringify({
|
||||
type: "text_turn",
|
||||
text: "10 days-ah severe headache iruku, Metformin 500mg daily sapudren."
|
||||
}));
|
||||
} else if (type === "Caregiver") {
|
||||
ws.send(JSON.stringify({
|
||||
type: "text_turn",
|
||||
text: "My mother has severe headache for 10 days and she is taking Metformin 500mg."
|
||||
}));
|
||||
} else if (type === "HighPain") {
|
||||
ws.send(JSON.stringify({
|
||||
type: "text_turn",
|
||||
text: "My chest pain is 9 out of 10 unbearable, I cannot breathe properly!"
|
||||
}));
|
||||
} else if (type === "DrugInteraction") {
|
||||
ws.send(JSON.stringify({
|
||||
type: "text_turn",
|
||||
text: "I am taking Warfarin and Aspirin daily, and I have severe Penicillin allergy."
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
function showNurseBanner(msg) {
|
||||
document.getElementById("bannerText").innerText = msg;
|
||||
document.getElementById("nurseBanner").classList.add("active");
|
||||
}
|
||||
|
||||
function dismissBanner() {
|
||||
document.getElementById("nurseBanner").classList.remove("active");
|
||||
}
|
||||
|
||||
function showDrugWarnings(warnings) {
|
||||
const card = document.getElementById("drugWarningCard");
|
||||
const text = document.getElementById("warningCardText");
|
||||
if (warnings && warnings.length > 0) {
|
||||
text.innerText = warnings.map(w => `[${w.severity}] ${w.category}: ${w.description}`).join("\n\n");
|
||||
card.classList.add("active");
|
||||
}
|
||||
}
|
||||
|
||||
// Modals for FHIR & SOAP Notes
|
||||
async function openFhirModal() {
|
||||
if (!currentSessionId) return alert("No active session ID");
|
||||
document.getElementById("modalTitle").innerText = "HL7 FHIR R4 Bundle JSON";
|
||||
document.getElementById("modalBody").innerText = "Loading FHIR R4 Bundle...";
|
||||
document.getElementById("modalHtmlBtn").style.display = "none";
|
||||
document.getElementById("modalOverlay").classList.add("active");
|
||||
|
||||
try {
|
||||
const res = await fetch(`http://localhost:8000/v1/voice-onboarding/fhir/${currentSessionId}`, {
|
||||
headers: { "X-API-Key": "skyheal_secure_api_key_2026" }
|
||||
});
|
||||
const bundle = await res.json();
|
||||
document.getElementById("modalBody").innerText = JSON.stringify(bundle, null, 2);
|
||||
} catch (e) {
|
||||
document.getElementById("modalBody").innerText = "Error fetching FHIR Bundle: " + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function openSoapModal() {
|
||||
if (!currentSessionId) return alert("No active session ID");
|
||||
document.getElementById("modalTitle").innerText = "Clinical SOAP Note (Subjective, Objective, Assessment, Plan)";
|
||||
document.getElementById("modalBody").innerText = "Compiling SOAP Note & ICD-10 Coding...";
|
||||
document.getElementById("modalHtmlBtn").style.display = "inline-flex";
|
||||
document.getElementById("modalOverlay").classList.add("active");
|
||||
|
||||
try {
|
||||
const res = await fetch(`http://localhost:8000/v1/voice-onboarding/soap-note/${currentSessionId}`, {
|
||||
headers: { "X-API-Key": "skyheal_secure_api_key_2026" }
|
||||
});
|
||||
const note = await res.json();
|
||||
document.getElementById("modalBody").innerText = JSON.stringify(note, null, 2);
|
||||
} catch (e) {
|
||||
document.getElementById("modalBody").innerText = "Error compiling SOAP Note: " + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
document.getElementById("modalOverlay").classList.remove("active");
|
||||
}
|
||||
|
||||
function copyModalContent() {
|
||||
const text = document.getElementById("modalBody").innerText;
|
||||
navigator.clipboard.writeText(text);
|
||||
alert("Copied to clipboard!");
|
||||
}
|
||||
|
||||
function openPrintableHtml() {
|
||||
if (currentSessionId) {
|
||||
window.open(`http://localhost:8000/v1/voice-onboarding/soap-note/${currentSessionId}?format=html`, '_blank');
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize on load
|
||||
window.onload = connectWebSocket;
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,5 @@
|
||||
[pytest]
|
||||
testpaths = tests
|
||||
python_files = test_*.py
|
||||
python_classes = Test*
|
||||
python_functions = test_*
|
||||
@@ -0,0 +1,50 @@
|
||||
annotated-doc==0.0.5
|
||||
annotated-types==0.8.0
|
||||
anthropic==1.0.0
|
||||
anyio==4.14.2
|
||||
certifi==2026.7.22
|
||||
cffi==2.1.1
|
||||
charset-normalizer==3.5.1
|
||||
click==8.4.2
|
||||
colorama==0.4.6
|
||||
cryptography==50.0.0
|
||||
docstring_parser==0.18.0
|
||||
fastapi==0.141.1
|
||||
google-ai-generativelanguage==0.6.15
|
||||
google-api-core==2.33.0
|
||||
google-api-python-client==2.199.0
|
||||
google-auth==2.56.3
|
||||
google-auth-httplib2==0.4.1
|
||||
google-generativeai==0.8.6
|
||||
googleapis-common-protos==1.75.0
|
||||
greenlet==3.5.5
|
||||
grpcio==1.83.0
|
||||
grpcio-status==1.71.2
|
||||
h11==0.16.0
|
||||
httpcore2==2.12.0
|
||||
httplib2==0.32.0
|
||||
httpx2==2.12.0
|
||||
idna==3.19
|
||||
jiter==0.16.0
|
||||
openai==3.3.1
|
||||
proto-plus==1.28.2
|
||||
protobuf==5.29.6
|
||||
pyasn1==0.6.4
|
||||
pyasn1_modules==0.4.2
|
||||
pycparser==3.0
|
||||
pydantic==2.13.4
|
||||
pydantic_core==2.46.4
|
||||
pyparsing==3.3.2
|
||||
python-dotenv==1.2.3
|
||||
python-multipart==0.0.32
|
||||
requests==2.34.2
|
||||
sniffio==1.3.1
|
||||
SQLAlchemy==2.0.52
|
||||
starlette==1.6.0
|
||||
tqdm==4.70.0
|
||||
truststore==0.10.4
|
||||
typing-inspection==0.4.4
|
||||
typing_extensions==4.16.0
|
||||
uritemplate==4.2.0
|
||||
urllib3==2.7.0
|
||||
uvicorn==0.52.4
|
||||
@@ -0,0 +1,92 @@
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
# Add project root to sys.path
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")))
|
||||
|
||||
from app.main import app
|
||||
|
||||
client = TestClient(app)
|
||||
VALID_KEY = "skyheal_secure_api_key_2026"
|
||||
|
||||
|
||||
def test_reject_unauthenticated_request():
|
||||
"""1. Reject Unauthenticated Request (401)"""
|
||||
res = client.post("/v1/onboarding/start", json={"patient": {"id": "P1"}})
|
||||
assert res.status_code == 401
|
||||
|
||||
|
||||
def test_authenticated_session_creation():
|
||||
"""2. Authenticated Session Creation (200)"""
|
||||
res = client.post(
|
||||
"/v1/onboarding/start",
|
||||
json={
|
||||
"patient": {"id": "PAT-PROD-01", "name": "Prod Patient", "age": 32, "gender": "female"},
|
||||
"category": {"type": "general", "code": "general_medicine", "name": "General Medicine"}
|
||||
},
|
||||
headers={"Authorization": "Bearer test_token_PAT-PROD-01"}
|
||||
)
|
||||
assert res.status_code == 200
|
||||
data = res.json()
|
||||
assert data.get("session_id") is not None
|
||||
|
||||
|
||||
def test_idor_prevention_cross_session_access():
|
||||
"""3. IDOR Prevention Cross-Session Access (403)"""
|
||||
# Create session as PAT-PROD-01
|
||||
start_res = client.post(
|
||||
"/v1/onboarding/start",
|
||||
json={
|
||||
"patient": {"id": "PAT-PROD-01", "name": "Prod Patient", "age": 32, "gender": "female"},
|
||||
"category": {"type": "general", "code": "general_medicine", "name": "General Medicine"}
|
||||
},
|
||||
headers={"Authorization": "Bearer test_token_PAT-PROD-01"}
|
||||
)
|
||||
s_id = start_res.json().get("session_id")
|
||||
assert s_id is not None
|
||||
|
||||
# Attempt access as PAT-PROD-02
|
||||
msg_res = client.post(
|
||||
f"/v1/onboarding/{s_id}/message",
|
||||
json={"message": "Unauthorized message"},
|
||||
headers={"Authorization": "Bearer test_token_PAT-PROD-02"}
|
||||
)
|
||||
assert msg_res.status_code == 403
|
||||
|
||||
|
||||
def test_security_headers_and_request_id():
|
||||
"""4. Security Headers & X-Request-ID Correlation"""
|
||||
res = client.get("/health")
|
||||
assert res.status_code == 200
|
||||
assert "X-Request-ID" in res.headers
|
||||
assert res.headers.get("X-Content-Type-Options") == "nosniff"
|
||||
assert res.headers.get("X-Frame-Options") == "DENY"
|
||||
|
||||
|
||||
def test_schema_validation_negative_age_rejection():
|
||||
"""5. Schema Validation Negative Age Rejection (422)"""
|
||||
res = client.post(
|
||||
"/v1/onboarding/start",
|
||||
json={
|
||||
"patient": {"id": "PAT-NEG", "name": "Invalid Age", "age": -10, "gender": "female"},
|
||||
"category": {"type": "general", "code": "general_medicine", "name": "General Medicine"}
|
||||
},
|
||||
headers={"Authorization": f"Bearer {VALID_KEY}"}
|
||||
)
|
||||
assert res.status_code == 422
|
||||
|
||||
|
||||
def run_automated_production_tests():
|
||||
test_reject_unauthenticated_request()
|
||||
test_authenticated_session_creation()
|
||||
test_idor_prevention_cross_session_access()
|
||||
test_security_headers_and_request_id()
|
||||
test_schema_validation_negative_age_rejection()
|
||||
print("ALL PRODUCTION SECURITY TESTS PASSED SUCCESSFULLY!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_automated_production_tests()
|
||||
@@ -0,0 +1,386 @@
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import asyncio
|
||||
import pytest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
# Add project root to sys.path
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
||||
|
||||
from app.ai.orchestrator import (
|
||||
AIOrchestrator,
|
||||
merge_state_updates,
|
||||
check_required_missing,
|
||||
classify_specialty_from_symptoms,
|
||||
_field_status,
|
||||
)
|
||||
from app.services.contextual_normalizer import (
|
||||
normalize_duration_from_message,
|
||||
detect_previous_question_intent,
|
||||
)
|
||||
from app.services.completion_service import CompletionService
|
||||
from app.schemas.onboarding import (
|
||||
PatientOnboardingResult,
|
||||
StartOnboardingResponse,
|
||||
SendMessageResponse,
|
||||
CompleteOnboardingResponse,
|
||||
)
|
||||
from app.schemas.voice import VoiceTurnResponse
|
||||
|
||||
|
||||
def get_mock_session():
|
||||
session = MagicMock()
|
||||
session.session_id = "ONB-FLOW-TEST-001"
|
||||
session.patient_id = "PAT-FLOW-001"
|
||||
session.patient_name = "Ramesh Kumar"
|
||||
session.age = 35
|
||||
session.gender = "male"
|
||||
session.category_type = "general"
|
||||
session.category_code = "general_medicine"
|
||||
session.category_name = "General Medicine"
|
||||
session.language = "ta-IN"
|
||||
session.stage = "greeting"
|
||||
session.status = "in_progress"
|
||||
return session
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# TEST 1: Patient Provides All Information in One Message
|
||||
# =====================================================================
|
||||
def test_1_patient_provides_all_info_in_one_message():
|
||||
async def run():
|
||||
mock_session = get_mock_session()
|
||||
orchestrator = AIOrchestrator()
|
||||
msg = "எனக்கு diabetes இருக்கு, metformin 500 mg தினமும் சாப்பிடுறேன், தலைவலி மூணு நாளா இருக்கு, penicillin allergy இருக்கு."
|
||||
state_mock = MagicMock(state_json=json.dumps({}))
|
||||
|
||||
res = await orchestrator.process(mock_session, state_mock, [], msg)
|
||||
merged = merge_state_updates({}, res["state_updates"])
|
||||
|
||||
assert len(merged.get("medical_history", [])) >= 1
|
||||
assert len(merged.get("medications", [])) >= 1
|
||||
assert len(merged.get("allergies", [])) >= 1
|
||||
assert len(merged.get("symptoms", [])) >= 1
|
||||
assert res.get("completed") is True
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# TEST 2: Patient Gives Symptoms First
|
||||
# =====================================================================
|
||||
def test_2_patient_gives_symptoms_first():
|
||||
async def run():
|
||||
mock_session = get_mock_session()
|
||||
orchestrator = AIOrchestrator()
|
||||
msg = "எனக்கு தலைவலி இருக்கு."
|
||||
state_mock = MagicMock(state_json=json.dumps({}))
|
||||
|
||||
res = await orchestrator.process(mock_session, state_mock, [], msg)
|
||||
assert res.get("completed") is False
|
||||
assert len(res.get("reply", "")) > 5
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# TEST 3: Patient Gives Duration (No Fake Symptom Created)
|
||||
# =====================================================================
|
||||
def test_3_patient_gives_duration_no_fake_symptom():
|
||||
async def run():
|
||||
mock_session = get_mock_session()
|
||||
orchestrator = AIOrchestrator()
|
||||
state_mock = MagicMock(state_json=json.dumps({
|
||||
"reason_for_visit": "தலைவலி",
|
||||
"symptoms": [{"name": "தலைவலி"}]
|
||||
}))
|
||||
|
||||
res = await orchestrator.process(
|
||||
mock_session, state_mock,
|
||||
[{"role": "patient", "message": "தலைவலி இருக்கு", "stage": "greeting"}],
|
||||
"மூணு நாளா இருக்கு."
|
||||
)
|
||||
merged = merge_state_updates({"symptoms": [{"name": "தலைவலி"}]}, res["state_updates"])
|
||||
syms = merged.get("symptoms", [])
|
||||
|
||||
assert len(syms) == 1
|
||||
assert syms[0]["name"] == "தலைவலி"
|
||||
assert syms[0]["duration"] == "3 days"
|
||||
assert res.get("completed") is False
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# TEST 4: Tamil Tanglish Duration
|
||||
# =====================================================================
|
||||
def test_4_tamil_tanglish_duration():
|
||||
assert normalize_duration_from_message("10 டேஸா இருக்கு.") == "10 days"
|
||||
assert normalize_duration_from_message("10 days-ah இருக்கு.") == "10 days"
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# TEST 5: Telugu Duration Support
|
||||
# =====================================================================
|
||||
def test_5_telugu_duration():
|
||||
assert normalize_duration_from_message("మూడు రోజుల నుంచి ఉంది") == "3 days"
|
||||
assert normalize_duration_from_message("3 రోజుల నుండి ఉంది") == "3 days"
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# TEST 6: Kannada Duration Support
|
||||
# =====================================================================
|
||||
def test_6_kannada_duration():
|
||||
assert normalize_duration_from_message("ಮೂರು ದಿನದಿಂದ ಇದೆ") == "3 days"
|
||||
assert normalize_duration_from_message("3 ದಿನದಿಂದ ಇದೆ") == "3 days"
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# TEST 7: Medication First (No Fake Symptom Created)
|
||||
# =====================================================================
|
||||
def test_7_medication_first_no_fake_symptom():
|
||||
async def run():
|
||||
mock_session = get_mock_session()
|
||||
orchestrator = AIOrchestrator()
|
||||
state_mock = MagicMock(state_json=json.dumps({}))
|
||||
|
||||
res = await orchestrator.process(mock_session, state_mock, [], "Metformin 500 mg தினமும் சாப்பிடுறேன்.")
|
||||
updates = res["state_updates"]
|
||||
|
||||
meds = updates.get("medications") or []
|
||||
syms = updates.get("symptoms") or []
|
||||
|
||||
if isinstance(meds, dict):
|
||||
meds = meds.get("items", [])
|
||||
|
||||
assert len(meds) == 1
|
||||
assert meds[0]["name"] == "Metformin"
|
||||
assert len(syms) == 0
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# TEST 8: Cross-Language Symptom Deduplication
|
||||
# =====================================================================
|
||||
def test_8_cross_language_symptom_deduplication():
|
||||
state1 = merge_state_updates({}, {"symptoms": [{"name": "தலைவலி"}]})
|
||||
state2 = merge_state_updates(state1, {"symptoms": [{"name": "headache", "duration": "3 days"}]})
|
||||
syms = state2.get("symptoms", [])
|
||||
|
||||
assert len(syms) == 1
|
||||
assert syms[0]["duration"] == "3 days"
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# TEST 9: Progressive Enrichment
|
||||
# =====================================================================
|
||||
def test_9_progressive_enrichment():
|
||||
s1 = merge_state_updates({}, {"medical_history": [{"condition": "Diabetes"}]})
|
||||
s2 = merge_state_updates(s1, {"medical_history": [{"condition": "Diabetes", "duration": "8 years"}]})
|
||||
mh = s2.get("medical_history", [])
|
||||
|
||||
assert len(mh) == 1
|
||||
assert mh[0]["condition"] == "Diabetes"
|
||||
assert mh[0]["duration"] == "8 years"
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# TEST 10: Patient Correction
|
||||
# =====================================================================
|
||||
def test_10_patient_correction():
|
||||
s1 = merge_state_updates({}, {"symptoms": [{"name": "headache", "duration": "3 days"}]})
|
||||
s2 = merge_state_updates(s1, {"symptoms": [{"name": "headache", "duration": "10 days"}]})
|
||||
syms = s2.get("symptoms", [])
|
||||
|
||||
assert len(syms) == 1
|
||||
assert syms[0]["duration"] == "10 days"
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# TEST 11: Emergency Override (Spoken Tamil)
|
||||
# =====================================================================
|
||||
def test_11_emergency_override_tamil():
|
||||
async def run():
|
||||
mock_session = get_mock_session()
|
||||
orchestrator = AIOrchestrator()
|
||||
state_mock = MagicMock(state_json=json.dumps({}))
|
||||
|
||||
res = await orchestrator.process(mock_session, state_mock, [], "மூச்சு விட முடியல")
|
||||
|
||||
assert "108" in res["reply"]
|
||||
assert res.get("completed") is True
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# TEST 12: Mental Health Crisis Override (Spoken Tamil)
|
||||
# =====================================================================
|
||||
def test_12_mental_health_crisis_override_tamil():
|
||||
async def run():
|
||||
mock_session = get_mock_session()
|
||||
orchestrator = AIOrchestrator()
|
||||
state_mock = MagicMock(state_json=json.dumps({}))
|
||||
|
||||
res = await orchestrator.process(mock_session, state_mock, [], "நான் என்னையே காயப்படுத்திக்கணும்னு தோணுது")
|
||||
|
||||
assert "14416" in res["reply"] or "Tele-MANAS" in res["reply"]
|
||||
assert res.get("completed") is True
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# TEST 13: Nothing Else Handling
|
||||
# =====================================================================
|
||||
def test_13_nothing_else_handling():
|
||||
async def run():
|
||||
mock_session = get_mock_session()
|
||||
orchestrator = AIOrchestrator()
|
||||
ready_state = {
|
||||
"reason_for_visit": "fever",
|
||||
"symptoms": [{"name": "fever", "duration": "2 days"}],
|
||||
"medical_history": {"status": "confirmed_none", "items": []},
|
||||
"medications": {"status": "confirmed_none", "items": []},
|
||||
"allergies": [],
|
||||
}
|
||||
state_mock = MagicMock(state_json=json.dumps(ready_state))
|
||||
|
||||
res = await orchestrator.process(
|
||||
mock_session, state_mock,
|
||||
[{"role": "assistant", "message": "Do you have any allergies?", "stage": "allergies"}],
|
||||
"வேற எதுவும் இல்லை"
|
||||
)
|
||||
|
||||
assert res.get("completed") is True
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# TEST 14: Mental Health Specialty Focus
|
||||
# =====================================================================
|
||||
def test_14_mental_health_specialty():
|
||||
async def run():
|
||||
mock_session = get_mock_session()
|
||||
mock_session.category_code = "psychiatry"
|
||||
orchestrator = AIOrchestrator()
|
||||
state_mock = MagicMock(state_json=json.dumps({}))
|
||||
|
||||
res = await orchestrator.process(mock_session, state_mock, [], "எனக்கு ரொம்ப stress, தூக்கம் சரியா வரல, இரண்டு வாரமா இப்படித்தான்.")
|
||||
reply = res["reply"]
|
||||
|
||||
assert not any(k in reply for k in ["இருமல்", "பிபி", "வயிற்று வலி"])
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# TEST 15: Cardiology Specialty Focus
|
||||
# =====================================================================
|
||||
def test_15_cardiology_specialty():
|
||||
async def run():
|
||||
mock_session = get_mock_session()
|
||||
mock_session.category_code = "cardiology"
|
||||
orchestrator = AIOrchestrator()
|
||||
state_mock = MagicMock(state_json=json.dumps({}))
|
||||
|
||||
res = await orchestrator.process(mock_session, state_mock, [], "நெஞ்சு படபடன்னு இருக்கு, நடக்கும்போது மூச்சு வாங்குது.")
|
||||
syms = res["state_updates"].get("symptoms", [])
|
||||
|
||||
assert len(syms) >= 2
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# TEST 16: Multiple Symptoms Extraction
|
||||
# =====================================================================
|
||||
def test_16_multiple_symptoms_extraction():
|
||||
async def run():
|
||||
mock_session = get_mock_session()
|
||||
orchestrator = AIOrchestrator()
|
||||
state_mock = MagicMock(state_json=json.dumps({}))
|
||||
|
||||
res = await orchestrator.process(mock_session, state_mock, [], "தலைவலி, கை கால் வலி, தலைசுற்றல், நெஞ்சு படபடப்பு.")
|
||||
syms = res["state_updates"].get("symptoms", [])
|
||||
|
||||
assert len(syms) >= 3
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# TEST 17: No False Symptom from Medication Statement
|
||||
# =====================================================================
|
||||
def test_17_no_false_symptom_from_medication():
|
||||
async def run():
|
||||
mock_session = get_mock_session()
|
||||
orchestrator = AIOrchestrator()
|
||||
state_mock = MagicMock(state_json=json.dumps({}))
|
||||
|
||||
res = await orchestrator.process(mock_session, state_mock, [], "நான் Metformin சாப்பிடுறேன்.")
|
||||
syms = res["state_updates"].get("symptoms") or []
|
||||
|
||||
assert len(syms) == 0
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# TEST 18: Session Isolation Verification
|
||||
# =====================================================================
|
||||
def test_18_session_isolation():
|
||||
state_a = merge_state_updates({}, {"symptoms": [{"name": "headache"}]})
|
||||
state_b = merge_state_updates({}, {"symptoms": [{"name": "fever"}]})
|
||||
|
||||
assert state_a["symptoms"][0]["name"] == "headache"
|
||||
assert state_b["symptoms"][0]["name"] == "fever"
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# TEST 19 & 20: API Response & Voice Turn Compatibility
|
||||
# =====================================================================
|
||||
def test_19_20_api_backward_compatibility():
|
||||
res = VoiceTurnResponse(
|
||||
success=True,
|
||||
session_id="SESS-001",
|
||||
patient_transcript="hello",
|
||||
ai_text="hi",
|
||||
stage="greeting",
|
||||
completed=False,
|
||||
audio_url="/v1/voice-onboarding/audio/123",
|
||||
response_time_ms=100.0,
|
||||
response_time_sec=0.1
|
||||
)
|
||||
assert res.success is True
|
||||
assert res.completed is False
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# TEST 21: Exact Bug Regression Test (Multi-Symptom + Duration Turn 2)
|
||||
# =====================================================================
|
||||
def test_21_exact_bug_regression():
|
||||
async def run():
|
||||
mock_session = get_mock_session()
|
||||
orchestrator = AIOrchestrator()
|
||||
msg1 = "எனக்கு ரொம்ப தலைவலி, கை கால் வலியாக இருக்குது. என் நெஞ்சு அப்பப்ப படபடன்னு இருக்குது. தலை சுத்துது, தலை பாரமாக இருக்குது."
|
||||
state_mock = MagicMock(state_json=json.dumps({}))
|
||||
|
||||
res1 = await orchestrator.process(mock_session, state_mock, [], msg1)
|
||||
s1 = merge_state_updates({}, res1["state_updates"])
|
||||
state_mock2 = MagicMock(state_json=json.dumps(s1))
|
||||
|
||||
res2 = await orchestrator.process(
|
||||
mock_session, state_mock2,
|
||||
[{"role": "patient", "message": msg1, "stage": "greeting"}, {"role": "assistant", "message": res1["reply"], "stage": res1["next_stage"]}],
|
||||
"பிரச்சனை வந்து ஒரு மூணு நாளா இருக்கு."
|
||||
)
|
||||
|
||||
# MUST NOT complete after duration answer alone
|
||||
assert res2.get("completed") is False
|
||||
assert "எத்தனை நாளா" not in res2["reply"]
|
||||
|
||||
asyncio.run(run())
|
||||
@@ -0,0 +1,977 @@
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, AsyncMock
|
||||
|
||||
# Add project root to sys.path
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
||||
|
||||
from app.ai.orchestrator import (
|
||||
AIOrchestrator,
|
||||
merge_state_updates,
|
||||
classify_specialty_from_symptoms,
|
||||
check_required_missing,
|
||||
_field_status,
|
||||
_field_has_items,
|
||||
_any_symptom_has_duration,
|
||||
)
|
||||
from app.services.contextual_normalizer import normalize_duration_from_message, detect_previous_question_intent
|
||||
from app.ai.prompts import build_system_prompt
|
||||
from app.services.completion_service import CompletionService
|
||||
from app.schemas.onboarding import (
|
||||
PatientOnboardingResult,
|
||||
StartOnboardingResponse,
|
||||
SendMessageResponse,
|
||||
CompleteOnboardingResponse,
|
||||
CategoryContext,
|
||||
AIOnboardingResponse,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_session():
|
||||
session = MagicMock()
|
||||
session.session_id = "ONB-TEST-001"
|
||||
session.patient_id = "PAT-001"
|
||||
session.patient_name = "Ramesh Kumar"
|
||||
session.age = 35
|
||||
session.gender = "male"
|
||||
session.category_type = "general"
|
||||
session.category_code = "general_medicine"
|
||||
session.category_name = "General Medicine"
|
||||
session.language = "en-IN"
|
||||
session.stage = "greeting"
|
||||
session.status = "in_progress"
|
||||
return session
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def empty_state():
|
||||
return {
|
||||
"reason_for_visit": None,
|
||||
"symptoms": [],
|
||||
"medical_history": [],
|
||||
"medications": [],
|
||||
"allergies": [],
|
||||
"surgical_history": [],
|
||||
"previous_episodes": [],
|
||||
"family_history": [],
|
||||
"additional_information": [],
|
||||
}
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# 1. STATE MERGING & DEDUPLICATION & ENRICHMENT TESTS
|
||||
# =====================================================================
|
||||
|
||||
def test_deduplication_medical_history():
|
||||
"""If patient mentions diabetes twice, it must deduplicate into one entry with latest duration."""
|
||||
state = {
|
||||
"medical_history": [{"condition": "Diabetes"}]
|
||||
}
|
||||
updates = {
|
||||
"medical_history": [{"condition": "Diabetes", "duration": "8 years"}]
|
||||
}
|
||||
merged = merge_state_updates(state, updates)
|
||||
assert len(merged["medical_history"]) == 1
|
||||
assert merged["medical_history"][0]["condition"] == "Diabetes"
|
||||
assert merged["medical_history"][0]["duration"] == "8 years"
|
||||
|
||||
|
||||
def test_progressive_enrichment_symptoms():
|
||||
"""State should become richer over turns without wiping existing details."""
|
||||
# Turn 1: symptom name
|
||||
turn1_state = merge_state_updates({}, {"symptoms": [{"name": "headache"}]})
|
||||
assert len(turn1_state["symptoms"]) == 1
|
||||
assert turn1_state["symptoms"][0]["name"] == "headache"
|
||||
|
||||
# Turn 2: duration added
|
||||
turn2_state = merge_state_updates(turn1_state, {"symptoms": [{"name": "headache", "duration": "2 days"}]})
|
||||
assert len(turn2_state["symptoms"]) == 1
|
||||
assert turn2_state["symptoms"][0]["name"] == "headache"
|
||||
assert turn2_state["symptoms"][0]["duration"] == "2 days"
|
||||
|
||||
# Turn 3: severity and location added
|
||||
turn3_state = merge_state_updates(
|
||||
turn2_state,
|
||||
{"symptoms": [{"name": "headache", "severity": "severe", "location": "front of head"}]}
|
||||
)
|
||||
assert len(turn3_state["symptoms"]) == 1
|
||||
assert turn3_state["symptoms"][0]["name"] == "headache"
|
||||
assert turn3_state["symptoms"][0]["duration"] == "2 days"
|
||||
assert turn3_state["symptoms"][0]["severity"] == "severe"
|
||||
assert turn3_state["symptoms"][0]["location"] == "front of head"
|
||||
|
||||
|
||||
def test_patient_correction_handling():
|
||||
"""If patient corrects information, the latest explicit value must be stored."""
|
||||
state = {
|
||||
"symptoms": [{"name": "stomach pain", "duration": "yesterday"}]
|
||||
}
|
||||
# Patient later says: "Actually it started five days ago"
|
||||
updates = {
|
||||
"symptoms": [{"name": "stomach pain", "duration": "five days ago"}]
|
||||
}
|
||||
merged = merge_state_updates(state, updates)
|
||||
assert len(merged["symptoms"]) == 1
|
||||
assert merged["symptoms"][0]["duration"] == "five days ago"
|
||||
|
||||
|
||||
def test_multi_symptom_list_merging():
|
||||
"""Multiple distinct symptoms should all be retained in state."""
|
||||
state = {
|
||||
"symptoms": [{"name": "fever", "duration": "3 days"}]
|
||||
}
|
||||
updates = {
|
||||
"symptoms": [
|
||||
{"name": "headache"},
|
||||
{"name": "body pain"}
|
||||
]
|
||||
}
|
||||
merged = merge_state_updates(state, updates)
|
||||
assert len(merged["symptoms"]) == 3
|
||||
names = [s["name"] for s in merged["symptoms"]]
|
||||
assert "fever" in names
|
||||
assert "headache" in names
|
||||
assert "body pain" in names
|
||||
|
||||
|
||||
def test_null_preservation():
|
||||
"""Passing None or empty values should not overwrite valid existing data."""
|
||||
state = {
|
||||
"reason_for_visit": "chest tightness",
|
||||
"symptoms": [{"name": "chest tightness", "duration": "2 hours"}],
|
||||
"medications": [{"name": "Amlodipine"}]
|
||||
}
|
||||
updates = {
|
||||
"reason_for_visit": None,
|
||||
"allergies": [{"name": "Penicillin"}]
|
||||
}
|
||||
merged = merge_state_updates(state, updates)
|
||||
assert merged["reason_for_visit"] == "chest tightness"
|
||||
assert len(merged["symptoms"]) == 1
|
||||
assert merged["symptoms"][0]["duration"] == "2 hours"
|
||||
assert len(merged["medications"]) == 1
|
||||
assert len(merged["allergies"]) == 1
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# 2. TAMIL / TANGLISH / ENGLISH DURATION EXTRACTION UNIT TESTS
|
||||
# =====================================================================
|
||||
|
||||
def test_duration_normalization_numeric_english():
|
||||
"""Test standard numeric English duration phrases."""
|
||||
assert normalize_duration_from_message("10 days") == "10 days"
|
||||
assert normalize_duration_from_message("for 10 days") == "10 days"
|
||||
assert normalize_duration_from_message("2 days") == "2 days"
|
||||
assert normalize_duration_from_message("3 weeks") == "3 weeks"
|
||||
assert normalize_duration_from_message("6 months") == "6 months"
|
||||
assert normalize_duration_from_message("since yesterday") == "since yesterday"
|
||||
assert normalize_duration_from_message("since last week") == "since last week"
|
||||
assert normalize_duration_from_message("since Monday") == "since Monday"
|
||||
assert normalize_duration_from_message("for a few days") == "for a few days"
|
||||
assert normalize_duration_from_message("for many months") == "for many months"
|
||||
|
||||
|
||||
def test_duration_normalization_tamil():
|
||||
"""Test spoken and written Tamil duration phrases."""
|
||||
assert normalize_duration_from_message("பத்து நாளா") == "10 days"
|
||||
assert normalize_duration_from_message("பத்து நாட்களா") == "10 days"
|
||||
assert normalize_duration_from_message("ஒரு பத்து நாளா") == "10 days"
|
||||
assert normalize_duration_from_message("ரெண்டு நாளா") == "2 days"
|
||||
assert normalize_duration_from_message("ரெண்டு நாட்களா") == "2 days"
|
||||
assert normalize_duration_from_message("மூணு நாளா") == "3 days"
|
||||
assert normalize_duration_from_message("நாலு நாளா") == "4 days"
|
||||
assert normalize_duration_from_message("ஒரு வாரமா") == "1 week"
|
||||
assert normalize_duration_from_message("ரெண்டு வாரமா") == "2 weeks"
|
||||
assert normalize_duration_from_message("ஒரு மாசமா") == "1 month"
|
||||
assert normalize_duration_from_message("ரெண்டு மாசமா") == "2 months"
|
||||
assert normalize_duration_from_message("நேத்துல இருந்து") == "since yesterday"
|
||||
assert normalize_duration_from_message("நேத்து இருந்து") == "since yesterday"
|
||||
assert normalize_duration_from_message("நேற்று முதல்") == "since yesterday"
|
||||
|
||||
|
||||
def test_duration_normalization_tanglish():
|
||||
"""Test Tanglish duration phrases."""
|
||||
assert normalize_duration_from_message("10 days-ah") == "10 days"
|
||||
assert normalize_duration_from_message("10 days-a") == "10 days"
|
||||
assert normalize_duration_from_message("10 naala") == "10 days"
|
||||
assert normalize_duration_from_message("10 nala") == "10 days"
|
||||
assert normalize_duration_from_message("10 naal ah") == "10 days"
|
||||
assert normalize_duration_from_message("10 days irukku") == "10 days"
|
||||
assert normalize_duration_from_message("10 days-a irukku") == "10 days"
|
||||
assert normalize_duration_from_message("2 days-ah") == "2 days"
|
||||
assert normalize_duration_from_message("3 weeks-ah") == "3 weeks"
|
||||
assert normalize_duration_from_message("1 month-ah") == "1 month"
|
||||
assert normalize_duration_from_message("one week-ah") == "1 week"
|
||||
assert normalize_duration_from_message("oru 10 days-ah") == "10 days"
|
||||
assert normalize_duration_from_message("oru pathu naala") == "10 days"
|
||||
assert normalize_duration_from_message("rendu naala") == "2 days"
|
||||
assert normalize_duration_from_message("moonru naala") == "3 days"
|
||||
|
||||
|
||||
def test_duration_normalization_mixed():
|
||||
"""Test mixed Tamil + English duration phrases."""
|
||||
assert normalize_duration_from_message("10 டேஸா இருக்கு") == "10 days"
|
||||
assert normalize_duration_from_message("10 டேஸ்-ஆ இருக்கு") == "10 days"
|
||||
assert normalize_duration_from_message("10 days-ah இருக்கு") == "10 days"
|
||||
assert normalize_duration_from_message("ஒரு 10 days-ah இருக்கு") == "10 days"
|
||||
assert normalize_duration_from_message("ஒரு 10 டேஸா இருக்கு") == "10 days"
|
||||
assert normalize_duration_from_message("ஒரு பத்து நாளா இருக்கு") == "10 days"
|
||||
assert normalize_duration_from_message("எனக்கு வந்து ஒரு 10 டேஸா இருக்கு.") == "10 days"
|
||||
assert normalize_duration_from_message("இன்னும் ஒரு பத்து நாளா இருக்கு.") == "10 days"
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# 3. REGRESSION TESTS FOR TAMIL / TANGLISH DURATION BUG
|
||||
# =====================================================================
|
||||
|
||||
def test_tamil_tanglish_duration_attaches_to_existing_symptom(mock_session):
|
||||
"""
|
||||
CRITICAL REGRESSION TEST:
|
||||
Existing state: {"symptoms": [{"name": "தலைவலி"}]}
|
||||
Patient: "எனக்கு வந்து ஒரு 10 டேஸா இருக்கு."
|
||||
Must attach duration="10 days" to "தலைவலி", resulting in exactly 1 symptom with duration.
|
||||
"""
|
||||
import asyncio
|
||||
mock_session.language = "ta-IN"
|
||||
orchestrator = AIOrchestrator()
|
||||
state_mock = MagicMock()
|
||||
state_mock.state_json = json.dumps({
|
||||
"reason_for_visit": "தலைவலி",
|
||||
"symptoms": [{"name": "தலைவலி"}],
|
||||
"medical_history": [],
|
||||
"medications": [],
|
||||
"allergies": []
|
||||
})
|
||||
|
||||
res = asyncio.run(orchestrator.process(
|
||||
session=mock_session,
|
||||
state=state_mock,
|
||||
conversation_history=[
|
||||
{"role": "patient", "message": "தலைவலி இருக்கு", "stage": "greeting"},
|
||||
{"role": "assistant", "message": "சரிங்க, இந்த பிரச்சனை எத்தனை நாளா இருக்குன்னு சொல்ல முடியுமா?", "stage": "symptom_details"}
|
||||
],
|
||||
user_message="எனக்கு வந்து ஒரு 10 டேஸா இருக்கு."
|
||||
))
|
||||
|
||||
merged = merge_state_updates(
|
||||
{"symptoms": [{"name": "தலைவலி"}]},
|
||||
res["state_updates"]
|
||||
)
|
||||
symptoms = merged["symptoms"]
|
||||
if isinstance(symptoms, dict):
|
||||
symptoms = symptoms.get("items", [])
|
||||
|
||||
assert len(symptoms) == 1
|
||||
assert symptoms[0]["name"] == "தலைவலி"
|
||||
assert symptoms[0]["duration"] == "10 days"
|
||||
|
||||
|
||||
def test_duration_answer_does_not_create_new_symptom():
|
||||
"""Duration response must enrich existing symptom without adding a second symptom."""
|
||||
existing_state = {
|
||||
"reason_for_visit": "தலைவலி",
|
||||
"symptoms": [{"name": "தலைவலி", "duration": None}]
|
||||
}
|
||||
# Deterministic updates for "10 டேஸா இருக்கு"
|
||||
orchestrator = AIOrchestrator()
|
||||
updates = orchestrator._extract_facts_from_message(
|
||||
"10 டேஸா இருக்கு",
|
||||
"10 டேஸா இருக்கு",
|
||||
last_ai_message="இந்த பிரச்சனை எத்தனை நாளா இருக்கு?",
|
||||
current_state=existing_state
|
||||
)
|
||||
merged = merge_state_updates(existing_state, updates)
|
||||
symptoms = merged["symptoms"]
|
||||
assert len(symptoms) == 1
|
||||
assert symptoms[0]["name"] == "தலைவலி"
|
||||
assert symptoms[0]["duration"] == "10 days"
|
||||
|
||||
|
||||
def test_duration_question_not_repeated_after_patient_answers(mock_session):
|
||||
"""
|
||||
After patient answers duration with '10 டேஸா இருக்கு',
|
||||
AI must NOT repeat the question 'எத்தனை நாளா இருக்கு'.
|
||||
"""
|
||||
import asyncio
|
||||
mock_session.language = "ta-IN"
|
||||
orchestrator = AIOrchestrator()
|
||||
state_mock = MagicMock()
|
||||
state_mock.state_json = json.dumps({
|
||||
"reason_for_visit": "தலைவலி",
|
||||
"symptoms": [{"name": "தலைவலி"}],
|
||||
"medical_history": [],
|
||||
"medications": [],
|
||||
"allergies": []
|
||||
})
|
||||
|
||||
res = asyncio.run(orchestrator.process(
|
||||
session=mock_session,
|
||||
state=state_mock,
|
||||
conversation_history=[
|
||||
{"role": "patient", "message": "தலைவலி இருக்கு", "stage": "greeting"},
|
||||
{"role": "assistant", "message": "சரிங்க, இந்த பிரச்சனை எத்தனை நாளா இருக்குன்னு சொல்ல முடியுமா?", "stage": "symptom_details"}
|
||||
],
|
||||
user_message="எனக்கு வந்து ஒரு 10 டேஸா இருக்கு."
|
||||
))
|
||||
|
||||
reply = res["reply"]
|
||||
# Must NOT ask duration again
|
||||
assert "எத்தனை நாளா" not in reply
|
||||
assert "எப்போலிருந்து" not in reply
|
||||
# Must NOT complete — should ask about medical history/meds/allergies
|
||||
assert res.get("completed") is not True
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# 4. CONTEXTUAL EXTRACTION FOR SEVERITY, LOCATION, MEDS, ALLERGIES
|
||||
# =====================================================================
|
||||
|
||||
def test_contextual_severity_extraction():
|
||||
"""Severity answer attaches to existing symptom."""
|
||||
existing_state = {
|
||||
"symptoms": [{"name": "headache"}]
|
||||
}
|
||||
orchestrator = AIOrchestrator()
|
||||
updates = orchestrator._extract_facts_from_message(
|
||||
"ரொம்ப severe-ஆ இருக்கு.",
|
||||
"ரொம்ப severe-ஆ இருக்கு.",
|
||||
last_ai_message="How severe is the pain?",
|
||||
current_state=existing_state
|
||||
)
|
||||
merged = merge_state_updates(existing_state, updates)
|
||||
symptoms = merged["symptoms"]
|
||||
assert len(symptoms) == 1
|
||||
assert symptoms[0]["name"] == "headache"
|
||||
assert symptoms[0]["severity"] == "severe"
|
||||
|
||||
|
||||
def test_contextual_location_extraction():
|
||||
"""Location answer attaches to existing symptom."""
|
||||
existing_state = {
|
||||
"symptoms": [{"name": "headache"}]
|
||||
}
|
||||
orchestrator = AIOrchestrator()
|
||||
updates = orchestrator._extract_facts_from_message(
|
||||
"வலது பக்கத்துல",
|
||||
"வலது பக்கத்துல",
|
||||
last_ai_message="எந்த பக்கத்துல வலிக்குது?",
|
||||
current_state=existing_state
|
||||
)
|
||||
merged = merge_state_updates(existing_state, updates)
|
||||
symptoms = merged["symptoms"]
|
||||
assert len(symptoms) == 1
|
||||
assert symptoms[0]["name"] == "headache"
|
||||
assert symptoms[0]["location"] == "right side"
|
||||
|
||||
|
||||
def test_contextual_medication_extraction():
|
||||
"""Medication statement extracts to medications list, not symptom."""
|
||||
orchestrator = AIOrchestrator()
|
||||
updates = orchestrator._extract_facts_from_message(
|
||||
"Metformin 500 mg twice a day",
|
||||
"metformin 500 mg twice a day",
|
||||
last_ai_message="Are you taking any medications?",
|
||||
current_state={}
|
||||
)
|
||||
meds = updates.get("medications", [])
|
||||
assert len(meds) == 1
|
||||
assert meds[0]["name"] == "Metformin"
|
||||
assert meds[0]["dosage"] == "500 mg"
|
||||
assert meds[0]["frequency"] == "twice a day"
|
||||
|
||||
|
||||
def test_contextual_allergy_extraction():
|
||||
"""Allergy statement extracts to allergies list, not symptom."""
|
||||
orchestrator = AIOrchestrator()
|
||||
updates = orchestrator._extract_facts_from_message(
|
||||
"allergic to penicillin, rash வரும்",
|
||||
"allergic to penicillin, rash வரும்",
|
||||
last_ai_message="Do you have any allergies?",
|
||||
current_state={}
|
||||
)
|
||||
allergies = updates.get("allergies", [])
|
||||
assert len(allergies) == 1
|
||||
assert allergies[0]["name"] == "Penicillin"
|
||||
assert "Rash" in allergies[0]["reaction"]
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# 5. NEGATIVE AND CASUAL SPEECH TESTS
|
||||
# =====================================================================
|
||||
|
||||
def test_negative_response_not_treated_as_symptom():
|
||||
"""'எனக்கு வேற எதுவும் இல்ல' must NOT become a symptom."""
|
||||
orchestrator = AIOrchestrator()
|
||||
updates = orchestrator._extract_facts_from_message(
|
||||
"எனக்கு வேற எதுவும் இல்ல.",
|
||||
"எனக்கு வேற எதுவும் இல்ல.",
|
||||
last_ai_message="வேறு ஏதேனும் உடம்பு பிரச்சனை இருக்கா?",
|
||||
current_state={"symptoms": [{"name": "தலைவலி"}]}
|
||||
)
|
||||
symptoms = updates.get("symptoms", [])
|
||||
assert len(symptoms) == 0
|
||||
|
||||
|
||||
def test_casual_speech_extraction():
|
||||
"""'நான் bus-ல வந்தேன், தலைவலி பத்து நாளா இருக்கு.' extracts headache and 10 days, ignores bus."""
|
||||
orchestrator = AIOrchestrator()
|
||||
updates = orchestrator._extract_facts_from_message(
|
||||
"நான் bus-ல வந்தேன், தலைவலி பத்து நாளா இருக்கு.",
|
||||
"நான் bus-ல வந்தேன், தலைவலி பத்து நாளா இருக்கு.",
|
||||
last_ai_message="",
|
||||
current_state={}
|
||||
)
|
||||
symptoms = updates.get("symptoms", [])
|
||||
assert len(symptoms) == 1
|
||||
assert symptoms[0]["name"] == "தலைவலி"
|
||||
assert symptoms[0]["duration"] == "10 days"
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# 6. ACCEPTANCE TESTS
|
||||
# =====================================================================
|
||||
|
||||
def test_acceptance_test_1_rich_multi_fact_message(mock_session, empty_state):
|
||||
"""
|
||||
Acceptance Test 1: Rich multi-fact patient message.
|
||||
"""
|
||||
import asyncio
|
||||
orchestrator = AIOrchestrator()
|
||||
user_msg = (
|
||||
"I've had stomach pain since yesterday. It gets worse after eating. I vomited twice. "
|
||||
"I couldn't sleep last night. I have diabetes for eight years. I take Metformin 500 mg twice a day. "
|
||||
"I'm allergic to penicillin and it causes a rash. I had the same stomach problem three months ago."
|
||||
)
|
||||
|
||||
state_mock = MagicMock()
|
||||
state_mock.state_json = json.dumps(empty_state)
|
||||
|
||||
res = asyncio.run(orchestrator.process(
|
||||
session=mock_session,
|
||||
state=state_mock,
|
||||
conversation_history=[],
|
||||
user_message=user_msg
|
||||
))
|
||||
|
||||
assert res["reply"] is not None
|
||||
updates = res["state_updates"]
|
||||
|
||||
symptoms = updates.get("symptoms", [])
|
||||
sym_names = [s.get("name") for s in symptoms]
|
||||
assert any("stomach pain" in name for name in sym_names)
|
||||
assert any("vomiting" in name for name in sym_names)
|
||||
|
||||
history = updates.get("medical_history", [])
|
||||
assert any("Diabetes" in h.get("condition", "") for h in history)
|
||||
|
||||
meds = updates.get("medications", [])
|
||||
assert any("Metformin" in m.get("name", "") for m in meds)
|
||||
|
||||
allergies = updates.get("allergies", [])
|
||||
assert any("Penicillin" in a.get("name", "") for a in allergies)
|
||||
|
||||
|
||||
def test_acceptance_test_2_minimal_input_no_invented_data(mock_session, empty_state):
|
||||
"""Acceptance Test 2: Minimal input without invented data."""
|
||||
import asyncio
|
||||
orchestrator = AIOrchestrator()
|
||||
state_mock = MagicMock()
|
||||
state_mock.state_json = json.dumps(empty_state)
|
||||
|
||||
res = asyncio.run(orchestrator.process(
|
||||
session=mock_session,
|
||||
state=state_mock,
|
||||
conversation_history=[],
|
||||
user_message="I have headache."
|
||||
))
|
||||
|
||||
updates = res["state_updates"]
|
||||
symptoms = updates.get("symptoms", [])
|
||||
assert len(symptoms) >= 1
|
||||
headache_sym = symptoms[0]
|
||||
assert "headache" in headache_sym["name"].lower()
|
||||
assert headache_sym.get("severity") in [None, ""]
|
||||
assert len(res["reply"]) > 5
|
||||
assert not res.get("completed", False)
|
||||
|
||||
|
||||
def test_acceptance_test_4_nothing_else_completion(mock_session):
|
||||
"""
|
||||
Acceptance Test 4: 'Nothing else' completes the intake
|
||||
ONLY when all required fields are collected or confirmed_none.
|
||||
"""
|
||||
import asyncio
|
||||
orchestrator = AIOrchestrator()
|
||||
ready_state = {
|
||||
"reason_for_visit": "fever",
|
||||
"symptoms": [{"name": "fever", "duration": "2 days"}],
|
||||
"medical_history": {"status": "confirmed_none", "items": []},
|
||||
"medications": {"status": "confirmed_none", "items": []},
|
||||
"allergies": {"status": "confirmed_none", "items": []},
|
||||
}
|
||||
state_mock = MagicMock()
|
||||
state_mock.state_json = json.dumps(ready_state)
|
||||
|
||||
res = asyncio.run(orchestrator.process(
|
||||
session=mock_session,
|
||||
state=state_mock,
|
||||
conversation_history=[
|
||||
{"role": "patient", "message": "I have fever for 2 days.", "stage": "greeting"},
|
||||
{"role": "assistant", "message": "Do you have any other illness or medications?", "stage": "symptom_details"}
|
||||
],
|
||||
user_message="Nothing else."
|
||||
))
|
||||
|
||||
assert res.get("completed") is True
|
||||
assert res.get("next_stage") == "confirmation"
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# 7. SAFETY & CRISIS TESTS
|
||||
# =====================================================================
|
||||
|
||||
def test_emergency_red_flag_crushing_chest_pain(mock_session, empty_state):
|
||||
"""Emergency statement triggers 108 ambulance advice and stops intake."""
|
||||
import asyncio
|
||||
orchestrator = AIOrchestrator()
|
||||
state_mock = MagicMock()
|
||||
state_mock.state_json = json.dumps(empty_state)
|
||||
|
||||
res = asyncio.run(orchestrator.process(
|
||||
session=mock_session,
|
||||
state=state_mock,
|
||||
conversation_history=[],
|
||||
user_message="I have crushing chest pain and I cannot breathe!"
|
||||
))
|
||||
|
||||
assert "108" in res["reply"]
|
||||
assert res.get("completed") is True
|
||||
|
||||
|
||||
def test_mental_health_crisis_override(mock_session, empty_state):
|
||||
"""Mental health crisis triggers Tele-MANAS (14416) helpline advice."""
|
||||
import asyncio
|
||||
orchestrator = AIOrchestrator()
|
||||
state_mock = MagicMock()
|
||||
state_mock.state_json = json.dumps(empty_state)
|
||||
|
||||
res = asyncio.run(orchestrator.process(
|
||||
session=mock_session,
|
||||
state=state_mock,
|
||||
conversation_history=[],
|
||||
user_message="I feel hopeless and I want to end my life."
|
||||
))
|
||||
|
||||
assert "14416" in res["reply"] or "Tele-MANAS" in res["reply"]
|
||||
assert res.get("completed") is True
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# 8. SPECIALTY & SCHEMA BACKWARD COMPATIBILITY TESTS
|
||||
# =====================================================================
|
||||
|
||||
def test_specialty_auto_classification():
|
||||
"""Verify clinical keywords correctly triage to respective specialties."""
|
||||
assert classify_specialty_from_symptoms("I have severe depression and insomnia")["code"] == "psychiatry"
|
||||
assert classify_specialty_from_symptoms("Severe chest pain and breathless")["code"] == "cardiology"
|
||||
assert classify_specialty_from_symptoms("Skin rash and severe itching")["code"] == "dermatology"
|
||||
assert classify_specialty_from_symptoms("Knee joint pain and swelling")["code"] == "orthopedics"
|
||||
assert classify_specialty_from_symptoms("Stomach ache and loose motion")["code"] == "gastroenterology"
|
||||
|
||||
|
||||
def test_completion_service_flexible_validation():
|
||||
"""Completion service accepts completed status or presence of all required fields."""
|
||||
service = CompletionService()
|
||||
session = MagicMock(status="completed", stage="confirmation")
|
||||
state = MagicMock(state_json=json.dumps({"reason_for_visit": "fever"}))
|
||||
assert service.is_complete(session, state) is True
|
||||
|
||||
|
||||
def test_api_schemas_backward_compatibility():
|
||||
"""Verify all schemas serialize and deserialize correctly."""
|
||||
cat = CategoryContext(type="general", code="general_medicine", name="General Medicine")
|
||||
|
||||
start_res = StartOnboardingResponse(
|
||||
success=True,
|
||||
session_id="ONB-1234",
|
||||
patient_id="PAT-01",
|
||||
category=cat,
|
||||
language="en-IN",
|
||||
status="in_progress",
|
||||
stage="greeting",
|
||||
message="Hello"
|
||||
)
|
||||
assert start_res.success is True
|
||||
|
||||
result = PatientOnboardingResult(
|
||||
patient_id="PAT-01",
|
||||
category={"type": "general", "code": "general_medicine", "name": "General Medicine"},
|
||||
chief_complaint="stomach pain",
|
||||
symptoms=[{"name": "stomach pain", "duration": "yesterday", "severity": "severe"}],
|
||||
medical_history=[{"condition": "Diabetes", "duration": "8 years"}],
|
||||
medications=[{"name": "Metformin", "dosage": "500 mg"}],
|
||||
allergies=[{"name": "Penicillin", "reaction": "Rash"}],
|
||||
language="en-IN",
|
||||
onboarding_completed=True
|
||||
)
|
||||
assert len(result.symptoms) == 1
|
||||
assert result.symptoms[0].name == "stomach pain"
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# 9. check_required_missing() UNIT TESTS
|
||||
# =====================================================================
|
||||
|
||||
def test_check_required_missing_empty_state():
|
||||
"""Empty state should report everything as missing."""
|
||||
state = {
|
||||
"reason_for_visit": None,
|
||||
"symptoms": [],
|
||||
"medical_history": [],
|
||||
"medications": [],
|
||||
"allergies": [],
|
||||
}
|
||||
missing = check_required_missing(state)
|
||||
assert "reason_for_visit" in missing
|
||||
assert "symptoms" in missing
|
||||
assert "medical_history" in missing
|
||||
assert "medications" in missing
|
||||
assert "allergies" in missing
|
||||
|
||||
|
||||
def test_check_required_missing_symptoms_no_duration():
|
||||
"""Symptoms present but no duration should still report duration as missing."""
|
||||
state = {
|
||||
"reason_for_visit": "headache",
|
||||
"symptoms": [{"name": "headache"}], # no duration
|
||||
"medical_history": [{"condition": "Diabetes"}],
|
||||
"medications": [{"name": "Metformin"}],
|
||||
"allergies": [{"name": "Penicillin"}],
|
||||
}
|
||||
missing = check_required_missing(state)
|
||||
assert "duration" in missing
|
||||
assert "reason_for_visit" not in missing
|
||||
assert "symptoms" not in missing
|
||||
assert "medical_history" not in missing
|
||||
|
||||
|
||||
def test_check_required_missing_confirmed_none():
|
||||
"""confirmed_none fields should NOT be reported as missing."""
|
||||
state = {
|
||||
"reason_for_visit": "fever",
|
||||
"symptoms": [{"name": "fever", "duration": "2 days"}],
|
||||
"medical_history": {"status": "confirmed_none", "items": []},
|
||||
"medications": {"status": "confirmed_none", "items": []},
|
||||
"allergies": {"status": "confirmed_none", "items": []},
|
||||
}
|
||||
missing = check_required_missing(state)
|
||||
assert len(missing) == 0
|
||||
|
||||
|
||||
def test_check_required_missing_all_reported():
|
||||
"""All reported state should return empty missing list."""
|
||||
state = {
|
||||
"reason_for_visit": "headache",
|
||||
"symptoms": [{"name": "headache", "duration": "3 days"}],
|
||||
"medical_history": [{"condition": "Diabetes"}],
|
||||
"medications": [{"name": "Metformin"}],
|
||||
"allergies": [{"name": "Penicillin"}],
|
||||
}
|
||||
missing = check_required_missing(state)
|
||||
assert len(missing) == 0
|
||||
|
||||
|
||||
def test_check_required_missing_partial():
|
||||
"""Partial state: has symptoms+duration+history but missing meds and allergies."""
|
||||
state = {
|
||||
"reason_for_visit": "headache",
|
||||
"symptoms": [{"name": "headache", "duration": "3 days"}],
|
||||
"medical_history": [{"condition": "Diabetes"}],
|
||||
"medications": [], # not_collected
|
||||
"allergies": [], # not_collected
|
||||
}
|
||||
missing = check_required_missing(state)
|
||||
assert "medications" in missing
|
||||
assert "allergies" in missing
|
||||
assert "reason_for_visit" not in missing
|
||||
assert "duration" not in missing
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# 10. CRITICAL BUG REGRESSION: PREMATURE COMPLETION AFTER DURATION
|
||||
# =====================================================================
|
||||
|
||||
def test_no_premature_completion_after_duration_tamil(mock_session):
|
||||
"""
|
||||
EXACT BUG REPRODUCTION:
|
||||
Turn 1: Patient gives multiple symptoms
|
||||
Turn 2: Patient gives duration
|
||||
AI MUST NOT complete. Must ask about medical history/meds/allergies.
|
||||
"""
|
||||
import asyncio
|
||||
mock_session.language = "ta-IN"
|
||||
orchestrator = AIOrchestrator()
|
||||
state_mock = MagicMock()
|
||||
state_mock.state_json = json.dumps({
|
||||
"reason_for_visit": "தலைவலி",
|
||||
"symptoms": [
|
||||
{"name": "தலைவலி"},
|
||||
{"name": "கை கால் வலி"},
|
||||
{"name": "நெஞ்சு படபடப்பு"},
|
||||
{"name": "தலை சுத்துதல்"},
|
||||
],
|
||||
"medical_history": [],
|
||||
"medications": [],
|
||||
"allergies": []
|
||||
})
|
||||
|
||||
res = asyncio.run(orchestrator.process(
|
||||
session=mock_session,
|
||||
state=state_mock,
|
||||
conversation_history=[
|
||||
{"role": "patient", "message": "எனக்கு ரொம்ப தலைவலி, கை கால் வலியாக இருக்குது. என் நெஞ்சு அப்பப்ப படபடன்னு இருக்குது. தலை சுத்துது.", "stage": "greeting"},
|
||||
{"role": "assistant", "message": "சரிங்க, இந்த பிரச்சனை எத்தனை நாளா இருக்குன்னு சொல்ல முடியுமா?", "stage": "symptom_details"}
|
||||
],
|
||||
user_message="பிரச்சனை வந்து ஒரு மூணு நாளா இருக்கு."
|
||||
))
|
||||
|
||||
# MUST NOT complete
|
||||
assert res.get("completed") is not True, f"System completed prematurely after duration! Reply: {res['reply']}"
|
||||
# Must NOT ask duration again
|
||||
assert "எத்தனை நாளா" not in res["reply"]
|
||||
assert "எப்போலிருந்து" not in res["reply"]
|
||||
|
||||
|
||||
def test_no_premature_completion_after_duration_english(mock_session):
|
||||
"""English version: duration answer must not trigger completion."""
|
||||
import asyncio
|
||||
mock_session.language = "en-IN"
|
||||
orchestrator = AIOrchestrator()
|
||||
state_mock = MagicMock()
|
||||
state_mock.state_json = json.dumps({
|
||||
"reason_for_visit": "headache",
|
||||
"symptoms": [{"name": "headache"}],
|
||||
"medical_history": [],
|
||||
"medications": [],
|
||||
"allergies": []
|
||||
})
|
||||
|
||||
res = asyncio.run(orchestrator.process(
|
||||
session=mock_session,
|
||||
state=state_mock,
|
||||
conversation_history=[
|
||||
{"role": "patient", "message": "I have a bad headache.", "stage": "greeting"},
|
||||
{"role": "assistant", "message": "Got it. How long has this been bothering you?", "stage": "symptom_details"}
|
||||
],
|
||||
user_message="About 3 days."
|
||||
))
|
||||
|
||||
assert res.get("completed") is not True, "System completed prematurely after duration!"
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# 11. "NOTHING ELSE" WITH MISSING FIELDS MUST NOT COMPLETE
|
||||
# =====================================================================
|
||||
|
||||
def test_nothing_else_with_missing_fields_does_not_complete(mock_session):
|
||||
"""
|
||||
When patient says 'nothing else' but medical_history, medications, allergies
|
||||
are still not_collected, system MUST NOT complete.
|
||||
"""
|
||||
import asyncio
|
||||
mock_session.language = "ta-IN"
|
||||
orchestrator = AIOrchestrator()
|
||||
state_mock = MagicMock()
|
||||
state_mock.state_json = json.dumps({
|
||||
"reason_for_visit": "தலைவலி",
|
||||
"symptoms": [{"name": "தலைவலி", "duration": "3 days"}],
|
||||
"medical_history": [], # not_collected
|
||||
"medications": [], # not_collected
|
||||
"allergies": [] # not_collected
|
||||
})
|
||||
|
||||
res = asyncio.run(orchestrator.process(
|
||||
session=mock_session,
|
||||
state=state_mock,
|
||||
conversation_history=[
|
||||
{"role": "patient", "message": "தலைவலி 3 நாளா இருக்கு", "stage": "greeting"},
|
||||
{"role": "assistant", "message": "சரிங்க. உங்களுக்கு பிபி, சுகர், ஆஸ்துமா போன்ற வேறு ஏதேனும் உடம்பு பிரச்சனை இருக்கா?", "stage": "symptom_details"}
|
||||
],
|
||||
user_message="எனக்கு வேற எதுவும் இல்ல."
|
||||
))
|
||||
|
||||
# Should set medical_history to confirmed_none (since that's what was asked)
|
||||
# But medications and allergies are still not_collected, so must NOT complete
|
||||
assert res.get("completed") is not True, f"System completed despite missing medications and allergies! Reply: {res['reply']}"
|
||||
|
||||
|
||||
def test_nothing_else_completes_when_all_confirmed_none(mock_session):
|
||||
"""
|
||||
When all remaining required fields are already confirmed_none or reported,
|
||||
'nothing else' SHOULD complete.
|
||||
"""
|
||||
import asyncio
|
||||
mock_session.language = "en-IN"
|
||||
orchestrator = AIOrchestrator()
|
||||
state_mock = MagicMock()
|
||||
state_mock.state_json = json.dumps({
|
||||
"reason_for_visit": "fever",
|
||||
"symptoms": [{"name": "fever", "duration": "2 days"}],
|
||||
"medical_history": {"status": "confirmed_none", "items": []},
|
||||
"medications": {"status": "confirmed_none", "items": []},
|
||||
"allergies": [], # last one not confirmed yet
|
||||
})
|
||||
|
||||
res = asyncio.run(orchestrator.process(
|
||||
session=mock_session,
|
||||
state=state_mock,
|
||||
conversation_history=[
|
||||
{"role": "patient", "message": "fever 2 days", "stage": "greeting"},
|
||||
{"role": "assistant", "message": "Do you have any drug allergies?", "stage": "allergies"}
|
||||
],
|
||||
user_message="No allergies."
|
||||
))
|
||||
|
||||
# "No allergies" should set allergies to confirmed_none, then all required are met
|
||||
assert res.get("completed") is True
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# 12. CONFIRMED_NONE DETECTION IN _extract_facts_from_message
|
||||
# =====================================================================
|
||||
|
||||
def test_extract_no_medicines_tamil():
|
||||
"""Tamil 'no medicines' should set medications to confirmed_none."""
|
||||
orchestrator = AIOrchestrator()
|
||||
updates = orchestrator._extract_facts_from_message(
|
||||
"எந்த மாத்திரையும் சாப்பிடல",
|
||||
"எந்த மாத்திரையும் சாப்பிடல",
|
||||
last_ai_message="ஏதாவது மாத்திரை சாப்பிடுறீங்களா?",
|
||||
current_state={"symptoms": [{"name": "headache"}]}
|
||||
)
|
||||
meds = updates.get("medications")
|
||||
assert isinstance(meds, dict)
|
||||
assert meds.get("status") == "confirmed_none"
|
||||
|
||||
|
||||
def test_extract_no_allergies_english():
|
||||
"""English 'no allergies' should set allergies to confirmed_none."""
|
||||
orchestrator = AIOrchestrator()
|
||||
updates = orchestrator._extract_facts_from_message(
|
||||
"No allergies",
|
||||
"no allergies",
|
||||
last_ai_message="Do you have any drug allergies?",
|
||||
current_state={"symptoms": [{"name": "headache"}]}
|
||||
)
|
||||
alg = updates.get("allergies")
|
||||
assert isinstance(alg, dict)
|
||||
assert alg.get("status") == "confirmed_none"
|
||||
|
||||
|
||||
def test_extract_no_medical_history_tamil():
|
||||
"""Tamil 'no medical history' should set medical_history to confirmed_none."""
|
||||
orchestrator = AIOrchestrator()
|
||||
updates = orchestrator._extract_facts_from_message(
|
||||
"எந்த நோயும் இல்லை",
|
||||
"எந்த நோயும் இல்லை",
|
||||
last_ai_message="உங்களுக்கு பிபி, சுகர் போன்ற வேறு ஏதேனும் உடம்பு பிரச்சனை இருக்கா?",
|
||||
current_state={"symptoms": [{"name": "headache"}]}
|
||||
)
|
||||
mh = updates.get("medical_history")
|
||||
assert isinstance(mh, dict)
|
||||
assert mh.get("status") == "confirmed_none"
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# 13. INTENT DETECTION: COMBINED MEDICATIONS_AND_ALLERGIES
|
||||
# =====================================================================
|
||||
|
||||
def test_detect_combined_meds_allergies_intent():
|
||||
"""When AI asked about both meds and allergies, detect combined intent."""
|
||||
intent = detect_previous_question_intent("ஏதாவது மாத்திரை சாப்பிட்டீங்களா? உங்களுக்கு ஏதேனும் மாத்திரை அலர்ஜி இருக்கா?")
|
||||
assert intent == "medications_and_allergies"
|
||||
|
||||
|
||||
def test_detect_meds_only_intent():
|
||||
"""When AI asked about meds only, detect medications intent."""
|
||||
intent = detect_previous_question_intent("ஏதாவது மாத்திரை சாப்பிடுறீங்களா?")
|
||||
assert intent == "medications"
|
||||
|
||||
|
||||
def test_detect_allergies_only_intent():
|
||||
"""When AI asked about allergies only, detect allergies intent."""
|
||||
intent = detect_previous_question_intent("Do you have any drug allergies?")
|
||||
assert intent == "allergies"
|
||||
|
||||
|
||||
def test_detect_medical_history_intent():
|
||||
"""When AI asked about medical history, detect history intent."""
|
||||
intent = detect_previous_question_intent("உங்களுக்கு பிபி, சுகர், ஆஸ்துமா போன்ற வேறு ஏதேனும் உடம்பு பிரச்சனை இருக்கா?")
|
||||
assert intent == "medical_history"
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# 14. _field_status HELPER TESTS
|
||||
# =====================================================================
|
||||
|
||||
def test_field_status_none():
|
||||
assert _field_status(None) == "not_collected"
|
||||
|
||||
def test_field_status_empty_list():
|
||||
assert _field_status([]) == "not_collected"
|
||||
|
||||
def test_field_status_nonempty_list():
|
||||
assert _field_status([{"name": "Metformin"}]) == "reported"
|
||||
|
||||
def test_field_status_confirmed_none_dict():
|
||||
assert _field_status({"status": "confirmed_none", "items": []}) == "confirmed_none"
|
||||
|
||||
def test_field_status_reported_string():
|
||||
assert _field_status("fever") == "reported"
|
||||
|
||||
def test_field_status_empty_string():
|
||||
assert _field_status("") == "not_collected"
|
||||
|
||||
def test_field_status_dict_with_items():
|
||||
assert _field_status({"status": "reported", "items": [{"name": "Metformin"}]}) == "reported"
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# 15. SPECIALTY LOCKING — NO FLIP-FLOPPING
|
||||
# =====================================================================
|
||||
|
||||
def test_specialty_not_downgraded_on_duration_message(mock_session):
|
||||
"""
|
||||
If session.category_code is already neurology (not general_medicine),
|
||||
a duration message should NOT re-triage to general_medicine.
|
||||
"""
|
||||
import asyncio
|
||||
mock_session.language = "ta-IN"
|
||||
mock_session.category_code = "neurology"
|
||||
mock_session.category_name = "Neurology"
|
||||
orchestrator = AIOrchestrator()
|
||||
state_mock = MagicMock()
|
||||
state_mock.state_json = json.dumps({
|
||||
"reason_for_visit": "தலைவலி",
|
||||
"symptoms": [{"name": "தலைவலி"}],
|
||||
"medical_history": [],
|
||||
"medications": [],
|
||||
"allergies": []
|
||||
})
|
||||
|
||||
res = asyncio.run(orchestrator.process(
|
||||
session=mock_session,
|
||||
state=state_mock,
|
||||
conversation_history=[
|
||||
{"role": "patient", "message": "தலைவலி", "stage": "greeting"},
|
||||
{"role": "assistant", "message": "எத்தனை நாளா?", "stage": "symptom_details"}
|
||||
],
|
||||
user_message="3 நாளா இருக்கு"
|
||||
))
|
||||
|
||||
# Specialty should remain neurology, not flip to general_medicine
|
||||
suggested = res.get("suggested_specialty", {})
|
||||
assert suggested.get("code") == "neurology"
|
||||
@@ -0,0 +1,125 @@
|
||||
import sys
|
||||
import os
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
||||
|
||||
from app.main import app
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
def test_cors_allowed_origin_preflight():
|
||||
"""Test browser OPTIONS preflight for allowed origin (http://localhost:3000)."""
|
||||
response = client.options(
|
||||
"/v1/onboarding/start",
|
||||
headers={
|
||||
"Origin": "http://localhost:3000",
|
||||
"Access-Control-Request-Method": "POST",
|
||||
"Access-Control-Request-Headers": "authorization, content-type, x-api-key",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.headers.get("access-control-allow-origin") == "http://localhost:3000"
|
||||
assert response.headers.get("access-control-allow-credentials") == "true"
|
||||
assert "POST" in response.headers.get("access-control-allow-methods", "").upper()
|
||||
|
||||
def test_cors_allowed_origin_actual_request():
|
||||
"""Test actual POST request from allowed origin (http://localhost:5173)."""
|
||||
response = client.post(
|
||||
"/v1/onboarding/start",
|
||||
json={
|
||||
"patient": {"id": "PAT-CORS-1", "name": "CORS Test", "age": 30, "gender": "female"},
|
||||
"category": {"type": "general", "code": "general_medicine", "name": "General Medicine"},
|
||||
"language": "en"
|
||||
},
|
||||
headers={
|
||||
"Origin": "http://localhost:5173",
|
||||
"Authorization": "Bearer test_token_PAT-CORS-1"
|
||||
}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.headers.get("access-control-allow-origin") == "http://localhost:5173"
|
||||
assert response.headers.get("access-control-allow-credentials") == "true"
|
||||
|
||||
def test_cors_unauthorized_request_returns_cors_headers():
|
||||
"""Test that 401 Unauthorized responses STILL return CORS headers to allowed origin."""
|
||||
response = client.post(
|
||||
"/v1/onboarding/start",
|
||||
json={"patient": {"id": "PAT-NOAUTH"}},
|
||||
headers={"Origin": "http://localhost:3000"}
|
||||
)
|
||||
assert response.status_code == 401
|
||||
assert response.headers.get("access-control-allow-origin") == "http://localhost:3000"
|
||||
assert response.headers.get("access-control-allow-credentials") == "true"
|
||||
|
||||
def test_cors_forbidden_request_returns_cors_headers():
|
||||
"""Test that 403 Forbidden responses STILL return CORS headers to allowed origin."""
|
||||
# 1. Create session for PAT-CORS-OWNER
|
||||
res_start = client.post(
|
||||
"/v1/onboarding/start",
|
||||
json={
|
||||
"patient": {"id": "PAT-CORS-OWNER", "name": "Owner", "age": 28, "gender": "male"},
|
||||
"category": {"type": "general", "code": "general_medicine", "name": "General Medicine"}
|
||||
},
|
||||
headers={"Authorization": "Bearer test_token_PAT-CORS-OWNER"}
|
||||
)
|
||||
session_id = res_start.json()["session_id"]
|
||||
|
||||
# 2. Access with different patient PAT-CORS-ATTACKER
|
||||
res_idor = client.post(
|
||||
f"/v1/onboarding/{session_id}/message",
|
||||
json={"message": "IDOR attempt"},
|
||||
headers={
|
||||
"Origin": "http://localhost:3000",
|
||||
"Authorization": "Bearer test_token_PAT-CORS-ATTACKER"
|
||||
}
|
||||
)
|
||||
assert res_idor.status_code == 403
|
||||
assert res_idor.headers.get("access-control-allow-origin") == "http://localhost:3000"
|
||||
assert res_idor.headers.get("access-control-allow-credentials") == "true"
|
||||
|
||||
def test_cors_disallowed_origin_rejected():
|
||||
"""Test that disallowed origin (https://malicious-example.com) is NOT allowed by CORS headers."""
|
||||
response = client.options(
|
||||
"/v1/onboarding/start",
|
||||
headers={
|
||||
"Origin": "https://malicious-example.com",
|
||||
"Access-Control-Request-Method": "POST",
|
||||
"Access-Control-Request-Headers": "authorization, content-type",
|
||||
},
|
||||
)
|
||||
# Browsers block preflight if access-control-allow-origin does not match
|
||||
assert response.headers.get("access-control-allow-origin") != "https://malicious-example.com"
|
||||
|
||||
def test_cors_voice_endpoints():
|
||||
"""Test preflight and actual POST on voice onboarding endpoints."""
|
||||
# Preflight voice start
|
||||
res_opt = client.options(
|
||||
"/v1/voice-onboarding/start",
|
||||
headers={
|
||||
"Origin": "http://localhost:5173",
|
||||
"Access-Control-Request-Method": "POST",
|
||||
"Access-Control-Request-Headers": "authorization, content-type"
|
||||
}
|
||||
)
|
||||
assert res_opt.status_code == 200
|
||||
assert res_opt.headers.get("access-control-allow-origin") == "http://localhost:5173"
|
||||
|
||||
# Actual voice start
|
||||
res_post = client.post(
|
||||
"/v1/voice-onboarding/start",
|
||||
json={
|
||||
"patient": {"id": "PAT-CORS-VOICE", "name": "Voice Test", "age": 40, "gender": "female"},
|
||||
"language": "en-IN"
|
||||
},
|
||||
headers={
|
||||
"Origin": "http://localhost:5173",
|
||||
"Authorization": "Bearer test_token_PAT-CORS-VOICE"
|
||||
}
|
||||
)
|
||||
assert res_post.status_code == 200
|
||||
assert res_post.headers.get("access-control-allow-origin") == "http://localhost:5173"
|
||||
|
||||
if __name__ == '__main__':
|
||||
pytest.main(["-v", __file__])
|
||||
Reference in New Issue
Block a user