Files
aionboarding/ai-patient-onboarding/app/api/voice_onboarding.py
T

534 lines
19 KiB
Python

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)}")