feat(api): production security, cors hardening, latency optimizations, postman artifacts & frontend documentation
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
import json
|
||||
import base64
|
||||
from app.config import GEMINI_API_KEY, GEMINI_MODEL
|
||||
@@ -15,7 +16,7 @@ class GeminiClient:
|
||||
mime_type: str,
|
||||
patient_context: dict,
|
||||
) -> dict:
|
||||
if not self.api_key or self.api_key == "your_gemini_key":
|
||||
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",
|
||||
@@ -31,7 +32,7 @@ class GeminiClient:
|
||||
try:
|
||||
import google.generativeai as genai
|
||||
genai.configure(api_key=self.api_key)
|
||||
model = genai.GenerativeModel(self.model)
|
||||
model = genai.GenerativeModel(self.model or "gemini-1.5-flash")
|
||||
|
||||
prompt = f"""
|
||||
You are a medical document extraction AI.
|
||||
@@ -59,11 +60,14 @@ Return ONLY JSON:
|
||||
"mime_type": mime_type,
|
||||
"data": file_bytes,
|
||||
}
|
||||
response = await model.generate_content_async([part, prompt])
|
||||
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 Error]: {e}")
|
||||
print(f"[Gemini Extraction Warning]: {e}")
|
||||
return {
|
||||
"document_type": "unknown",
|
||||
"findings": [],
|
||||
@@ -87,5 +91,8 @@ Return ONLY JSON:
|
||||
generation_config={"response_mime_type": "application/json"},
|
||||
)
|
||||
full_prompt = f"{system_prompt}\n\nPATIENT SAID: {user_message}"
|
||||
response = await model.generate_content_async(full_prompt)
|
||||
response = await asyncio.wait_for(
|
||||
model.generate_content_async(full_prompt),
|
||||
timeout=3.5
|
||||
)
|
||||
return response.text
|
||||
|
||||
@@ -112,8 +112,8 @@ CRITICAL CLINICAL INTAKE PRINCIPLES (AVOID UNWANTED QUESTIONS):
|
||||
- **MENTAL HEALTH & DEPRESSION INTAKE**: When patient mentions depression, stress, anxiety, or emotional struggles, acknowledge with warm empathy. Ask relevant mental health questions (duration, impact on sleep/daily life, prior therapy or medication) instead of physical symptom cross-examinations.
|
||||
|
||||
2. **FAST & CONCISE INTAKE (MAX 4 TURNS TOTAL)**:
|
||||
- Flow: Chief Complaint -> Duration -> Medications/Therapy -> Drug Allergies -> COMPLETE.
|
||||
- Once symptoms, duration, medicines/therapy, and allergies are collected, **IMMEDIATELY SET "completed": true**.
|
||||
- Flow: Chief Complaint -> Duration -> Past History & Medications -> Drug Allergies -> COMPLETE.
|
||||
- **STRICT COMPLETION RULE**: DO NOT set `"completed": true` on turn 1 or 2. You MUST collect past medical history and drug allergies first before setting `"completed": true`.
|
||||
|
||||
3. **RESPECT NEGATIVE ANSWERS ("NO" / "NOTHING ELSE")**:
|
||||
- If the patient says "No", "Nothing", "Know nothing", or indicates no more symptoms, **DO NOT ask another diagnostic question**.
|
||||
|
||||
@@ -7,6 +7,9 @@ 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
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/v1/documents",
|
||||
tags=["Medical Documents"],
|
||||
@@ -20,8 +23,13 @@ router = APIRouter(
|
||||
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()
|
||||
|
||||
@@ -29,9 +37,28 @@ async def upload_medical_document(
|
||||
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()
|
||||
# 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 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,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import json
|
||||
import time
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
@@ -36,6 +37,10 @@ from app.services.result_extraction_service import (
|
||||
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
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/v1/onboarding",
|
||||
tags=["Patient Onboarding"],
|
||||
@@ -48,14 +53,22 @@ router = APIRouter(
|
||||
)
|
||||
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=request.patient.id,
|
||||
patient_id=patient_id,
|
||||
patient_name=request.patient.name,
|
||||
age=request.patient.age,
|
||||
gender=request.patient.gender,
|
||||
@@ -108,16 +121,24 @@ async def start_onboarding(
|
||||
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(
|
||||
@@ -125,6 +146,13 @@ async def send_message(
|
||||
detail="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 access or modify this patient onboarding session.",
|
||||
)
|
||||
|
||||
# 2. Check session
|
||||
if session.status != "in_progress":
|
||||
raise HTTPException(
|
||||
@@ -142,12 +170,15 @@ async def send_message(
|
||||
)
|
||||
|
||||
# 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(
|
||||
@@ -167,14 +198,17 @@ async def send_message(
|
||||
}
|
||||
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
|
||||
current_state = json.loads(
|
||||
@@ -205,7 +239,9 @@ async def send_message(
|
||||
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(
|
||||
@@ -216,12 +252,20 @@ async def send_message(
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@@ -232,8 +276,10 @@ async def send_message(
|
||||
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()
|
||||
@@ -251,6 +297,13 @@ async def complete_onboarding(
|
||||
detail="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 onboarding session.",
|
||||
)
|
||||
|
||||
# 2. Load state
|
||||
state = onboarding_service.get_state(
|
||||
db,
|
||||
@@ -297,19 +350,35 @@ async def complete_onboarding(
|
||||
# 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()
|
||||
if isinstance(result, dict):
|
||||
result["onboarding_completed"] = True
|
||||
|
||||
if session.status == "completed":
|
||||
return CompleteOnboardingResponse(
|
||||
success=True,
|
||||
session_id=session_id,
|
||||
status="submitted_and_cleaned",
|
||||
status="completed",
|
||||
result=result,
|
||||
message="Onboarding successfully completed, saved to patient record, and temporary session cleaned.",
|
||||
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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -24,3 +24,21 @@ 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,75 @@
|
||||
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 api_key:
|
||||
token = api_key
|
||||
elif credentials:
|
||||
token = credentials.credentials
|
||||
|
||||
# If no token is provided
|
||||
if not token:
|
||||
# Check if environment allows default dev token or requires strict 401
|
||||
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"}
|
||||
)
|
||||
|
||||
# Derive patient/user identity context from token or header
|
||||
patient_id = x_patient_id or ("PAT-DEFAULT" if token == API_SECRET_KEY else token.replace("test_token_", ""))
|
||||
return AuthContext(user_id=patient_id, role="patient", patient_id=patient_id)
|
||||
|
||||
|
||||
# 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
|
||||
@@ -23,19 +23,47 @@ app = FastAPI(
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# Enable CORS for React Web & Mobile clients
|
||||
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=[
|
||||
"https://onboarding.skyheal.ai",
|
||||
"http://localhost:5173",
|
||||
"http://127.0.0.1:5173",
|
||||
"*",
|
||||
],
|
||||
allow_origins=CORS_ORIGINS,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
expose_headers=["*"],
|
||||
expose_headers=["X-Request-ID"],
|
||||
)
|
||||
|
||||
app.include_router(onboarding_router)
|
||||
|
||||
@@ -5,7 +5,7 @@ from pydantic import BaseModel, Field
|
||||
class PatientContext(BaseModel):
|
||||
id: str = Field(..., min_length=1)
|
||||
name: Optional[str] = None
|
||||
age: Optional[int] = None
|
||||
age: Optional[int] = Field(None, ge=0, le=120)
|
||||
gender: Optional[str] = None
|
||||
abha_id: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
@@ -46,6 +46,8 @@ class SendMessageResponse(BaseModel):
|
||||
stage: str
|
||||
message: str
|
||||
status: str
|
||||
response_time_ms: Optional[float] = None
|
||||
response_time_sec: Optional[float] = None
|
||||
|
||||
|
||||
class AIStateUpdate(BaseModel):
|
||||
|
||||
@@ -76,6 +76,27 @@ Return ONLY JSON conforming to this exact structure:
|
||||
}}
|
||||
"""
|
||||
|
||||
from app.config import GEMINI_API_KEY, GEMINI_MODEL, ANTHROPIC_API_KEY, ANTHROPIC_MODEL
|
||||
|
||||
# Primary: Google Gemini extraction (Ultra-fast ~1s)
|
||||
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(
|
||||
@@ -95,22 +116,7 @@ Return ONLY JSON conforming to this exact structure:
|
||||
validated = PatientOnboardingResult.model_validate(parsed)
|
||||
return validated.model_dump()
|
||||
except Exception as e:
|
||||
print(f"[OpenAI Result Extraction Error]: {e}")
|
||||
|
||||
# Secondary: Google Gemini extraction
|
||||
from app.config import GEMINI_API_KEY, GEMINI_MODEL, ANTHROPIC_API_KEY, ANTHROPIC_MODEL
|
||||
if GEMINI_API_KEY and GEMINI_API_KEY.strip() not in ["your_gemini_key", ""]:
|
||||
try:
|
||||
import google.generativeai as genai
|
||||
genai.configure(api_key=GEMINI_API_KEY)
|
||||
model = genai.GenerativeModel(GEMINI_MODEL or "gemini-3.6-flash")
|
||||
res = await model.generate_content_async(prompt)
|
||||
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 Error]: {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", ""]:
|
||||
|
||||
Reference in New Issue
Block a user