feat(api): production security, cors hardening, latency optimizations, postman artifacts & frontend documentation
This commit is contained in:
@@ -5,7 +5,7 @@ import time
|
||||
from uuid import uuid4
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -32,6 +32,8 @@ 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
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/v1/voice-onboarding",
|
||||
tags=["Voice Onboarding"],
|
||||
@@ -44,8 +46,12 @@ router = APIRouter(
|
||||
)
|
||||
async def start_voice_onboarding(
|
||||
request: StartVoiceOnboardingRequest,
|
||||
req_obj: Request = None,
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
if req_obj and req_obj.client:
|
||||
check_rate_limit(req_obj.client.host, limit=15, window_sec=60)
|
||||
start_time = time.time()
|
||||
try:
|
||||
onboarding_service = OnboardingService()
|
||||
@@ -60,9 +66,11 @@ async def start_voice_onboarding(
|
||||
name="General Medicine / Primary Care (Auto-Triage)",
|
||||
)
|
||||
|
||||
patient_id = request.patient.id or auth.patient_id
|
||||
|
||||
session = OnboardingSession(
|
||||
session_id=session_id,
|
||||
patient_id=request.patient.id,
|
||||
patient_id=patient_id,
|
||||
patient_name=request.patient.name,
|
||||
age=request.patient.age,
|
||||
gender=request.patient.gender,
|
||||
@@ -131,8 +139,12 @@ 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()
|
||||
@@ -146,10 +158,21 @@ async def voice_turn(
|
||||
detail="Voice onboarding session not found",
|
||||
)
|
||||
|
||||
if session.status != "in_progress":
|
||||
# 1b. IDOR Session Ownership Check
|
||||
if session.patient_id != auth.patient_id and auth.role != "admin":
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
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 not active",
|
||||
detail="Voice onboarding session is closed or submitted.",
|
||||
)
|
||||
|
||||
# 2. Fast-path transcript: use real-time Web Speech text_fallback first for zero-latency processing
|
||||
@@ -244,6 +267,11 @@ async def voice_turn(
|
||||
# 6. Update Structured Clinical State
|
||||
current_state = json.loads(state.state_json)
|
||||
updates = ai_response.get("state_updates", {})
|
||||
if hasattr(updates, "model_dump"):
|
||||
updates = updates.model_dump()
|
||||
elif not isinstance(updates, dict):
|
||||
updates = {}
|
||||
|
||||
for k, v in updates.items():
|
||||
if v is not None:
|
||||
if isinstance(v, list) and len(v) == 0:
|
||||
@@ -348,6 +376,7 @@ async def get_audio(audio_id: str):
|
||||
async def complete_voice_onboarding(
|
||||
session_id: str,
|
||||
request: CompleteOnboardingRequest,
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
start_time = time.time()
|
||||
@@ -363,6 +392,13 @@ async def complete_voice_onboarding(
|
||||
detail="Voice onboarding session not found",
|
||||
)
|
||||
|
||||
# 1b. Verify Session Ownership (IDOR Prevention)
|
||||
if session.patient_id != auth.patient_id and auth.role != "admin":
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
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
|
||||
@@ -389,6 +425,20 @@ async def complete_voice_onboarding(
|
||||
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,
|
||||
@@ -403,15 +453,14 @@ async def complete_voice_onboarding(
|
||||
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)
|
||||
session.status = "completed"
|
||||
session.stage = "confirmation"
|
||||
db.commit()
|
||||
|
||||
return CompleteOnboardingResponse(
|
||||
success=True,
|
||||
session_id=session_id,
|
||||
status="submitted_and_cleaned",
|
||||
status="completed",
|
||||
result=result,
|
||||
message="Voice onboarding successfully saved to patient record.",
|
||||
response_time_ms=resp_ms,
|
||||
|
||||
Reference in New Issue
Block a user