feat: AI patient onboarding backend and documentation

This commit is contained in:
2026-08-27 12:15:09 +05:30
parent b9440772d1
commit 59c1372fe3
49 changed files with 4012 additions and 0 deletions
@@ -0,0 +1 @@
"""API package initialization."""
@@ -0,0 +1,84 @@
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
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(...),
db: Session = Depends(get_db),
):
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")
state = onboarding_service.get_state(db, session_id)
file_bytes = await file.read()
mime_type = file.content_type or "application/pdf"
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
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.",
)
+325
View File
@@ -0,0 +1,325 @@
import json
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,
)
router = APIRouter(
prefix="/v1/onboarding",
tags=["Patient Onboarding"],
)
@router.post(
"/start",
response_model=StartOnboardingResponse,
)
async def start_onboarding(
request: StartOnboardingRequest,
db: Session = Depends(get_db),
):
onboarding_service = OnboardingService()
session_id = f"ONB-{uuid4().hex[:8].upper()}"
session = OnboardingSession(
session_id=session_id,
patient_id=request.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)
greeting_msg = (
f"Hello {request.patient.name or 'there'}, "
"welcome to CureSelect Healthcare. "
"I'll ask you a few questions to understand "
"your current health concern. Shall we begin?"
)
# Save initial assistant greeting
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,
db: Session = Depends(get_db),
):
onboarding_service = OnboardingService()
ai_service = AIOnboardingService()
# 1. Get session
session = onboarding_service.get_session(
db,
session_id,
)
if not session:
raise HTTPException(
status_code=404,
detail="Onboarding session not found",
)
# 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
state = onboarding_service.get_state(
db,
session_id,
)
# 5. 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
]
# 6. Send to AI
ai_response = await ai_service.process_message(
session=session,
state=state,
conversation_history=conversation_history,
user_message=request.message,
)
# 7. Update state
current_state = json.loads(
state.state_json
)
updates = ai_response.get(
"state_updates",
{},
)
for key, value in updates.items():
if value is not None:
if isinstance(value, list) and len(value) == 0:
continue
current_state[key] = value
onboarding_service.update_state(
db=db,
state=state,
data=current_state,
)
# 8. Update stage
session.stage = ai_response.get("next_stage", session.stage)
# 9. Check completion
if ai_response.get("completed"):
session.status = "completed"
db.commit()
# 10. Save AI message
onboarding_service.save_message(
db=db,
session_id=session_id,
role="assistant",
message=ai_response["reply"],
stage=session.stage,
)
return SendMessageResponse(
success=True,
session_id=session_id,
stage=session.stage,
message=ai_response["reply"],
status=session.status,
)
@router.post(
"/{session_id}/complete",
response_model=CompleteOnboardingResponse,
)
async def complete_onboarding(
session_id: str,
request: CompleteOnboardingRequest,
db: Session = Depends(get_db),
):
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",
)
# 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 submission.get("success"):
# 8. Successful submission -> clean up temporary SQLite session
db.query(OnboardingMessage).filter(OnboardingMessage.session_id == session_id).delete()
db.query(OnboardingState).filter(OnboardingState.session_id == session_id).delete()
db.delete(session)
db.commit()
return CompleteOnboardingResponse(
success=True,
session_id=session_id,
status="submitted_and_cleaned",
result=result,
message="Onboarding successfully completed, saved to patient record, and temporary session cleaned.",
)
# If submission failed, preserve SQLite session for retry
session.status = "submission_pending_retry"
db.commit()
return CompleteOnboardingResponse(
success=False,
session_id=session_id,
status="submission_pending_retry",
result=result,
message="Patient API submission failed. Session preserved for retry.",
)
+4
View File
@@ -0,0 +1,4 @@
from app.api.voice_onboarding import router
# Export the router
__all__ = ["router"]
@@ -0,0 +1,343 @@
import json
from uuid import uuid4
from typing import Optional
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
from app.database.database import get_db
from app.database.models import (
OnboardingMessage,
OnboardingSession,
OnboardingState,
)
from app.schemas.onboarding import (
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
router = APIRouter(
prefix="/v1/voice-onboarding",
tags=["Voice Onboarding"],
)
@router.post(
"/start",
response_model=StartVoiceOnboardingResponse,
)
async def start_voice_onboarding(
request: StartVoiceOnboardingRequest,
db: Session = Depends(get_db),
):
onboarding_service = OnboardingService()
voice_service = VoiceService()
session_id = f"VOICE-{uuid4().hex[:8].upper()}"
session = OnboardingSession(
session_id=session_id,
patient_id=request.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)
# Generate localized voice greeting
greeting_text = voice_service.get_initial_greeting(
request.patient.name,
request.language,
)
# Synthesize audio greeting
audio_id, _ = await voice_service.text_to_speech(
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",
)
return StartVoiceOnboardingResponse(
success=True,
session_id=session.session_id,
patient_id=session.patient_id,
category=request.category,
language=session.language,
status=session.status,
stage=session.stage,
greeting_text=greeting_text,
audio_url=f"/v1/voice-onboarding/audio/{audio_id}",
)
@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),
db: Session = Depends(get_db),
):
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",
)
if session.status != "in_progress":
raise HTTPException(
status_code=400,
detail="Voice onboarding session is not active",
)
# 2. Transcribe speech if audio provided with multi-model Indic recognition
patient_transcript = ""
if 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 backend STT is empty, use frontend real-time transcript fallback
if (not patient_transcript or not patient_transcript.strip()) and 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 not patient_transcript or not patient_transcript.strip():
if session.language.startswith("ta"):
patient_transcript = "எனக்கு உடல்நலக்குறைவு உள்ளது."
elif session.language.startswith("hi"):
patient_transcript = "मुझे स्वास्थ्य संबंधी समस्या है।"
else:
patient_transcript = "I am experiencing symptoms."
# 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
]
# 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
current_state = json.loads(state.state_json)
updates = ai_response.get("state_updates", {})
for k, v in updates.items():
if v is not None:
if isinstance(v, list) and len(v) == 0:
continue
current_state[k] = v
onboarding_service.update_state(
db=db,
state=state,
data=current_state,
)
# 7. Update session stage & status
ai_reply_text = ai_response["reply"]
session.stage = ai_response.get("next_stage", session.stage)
is_completed = bool(ai_response.get("completed", False))
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)
audio_id, _ = await voice_service.text_to_speech(
text=ai_reply_text,
language_code=session.language,
)
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}",
)
@router.get("/audio/{audio_id}")
async def get_audio(audio_id: str):
voice_service = VoiceService()
audio_path = voice_service.get_audio_path(audio_id)
if not audio_path or not audio_path.exists():
raise HTTPException(status_code=404, detail="Audio file not found")
media_type = "audio/mpeg" if audio_path.suffix == ".mp3" else "audio/wav"
return FileResponse(
path=str(audio_path),
media_type=media_type,
filename=audio_path.name,
)
@router.post(
"/{session_id}/complete",
response_model=CompleteOnboardingResponse,
)
async def complete_voice_onboarding(
session_id: str,
request: CompleteOnboardingRequest,
db: Session = Depends(get_db),
):
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",
)
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,
)
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.",
)
submission = await submission_service.submit(result)
if submission.get("success"):
db.query(OnboardingMessage).filter(OnboardingMessage.session_id == session_id).delete()
db.query(OnboardingState).filter(OnboardingState.session_id == session_id).delete()
db.delete(session)
db.commit()
return CompleteOnboardingResponse(
success=True,
session_id=session_id,
status="submitted_and_cleaned",
result=result,
message="Voice onboarding successfully saved to patient record.",
)
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.",
)