Merge pull request #3 from DTJ-Tran/poc1

Poc1-Proof of Concept verison 1
This commit is contained in:
David Tran
2026-07-07 15:56:36 +07:00
committed by GitHub
453 changed files with 84097 additions and 66425 deletions

View File

@@ -0,0 +1,233 @@
import asyncio
import logging
from contextlib import asynccontextmanager
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
from fastapi.security import OAuth2PasswordBearer
from fastapi.responses import StreamingResponse
from datetime import datetime
from data.spec.schemas import (
AnalysisJobSubmit, JobStatus, PipelineStep, StepEvent,
ModelCatalog, ModelRegistrationResult, HealthStatus,
AnalysisJobSyncSubmit, JobResult, ErrorResponse,
)
from backend.implementation.analysis_jobs import service as analysis_service
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1", tags=["analysis"])
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
_event_queues: dict[str, asyncio.Queue] = {}
_queue_lock = asyncio.Lock()
@asynccontextmanager
async def _get_queue(job_id: str):
async with _queue_lock:
if job_id not in _event_queues:
_event_queues[job_id] = asyncio.Queue()
yield _event_queues[job_id]
def _get_queue_sync(job_id: str) -> asyncio.Queue:
if job_id not in _event_queues:
_event_queues[job_id] = asyncio.Queue()
return _event_queues[job_id]
async def _verify_jwt_token(token: str = Depends(oauth2_scheme)) -> str:
try:
from backend.api.auth_api import verify_jwt_token as _verify
return await _verify(token)
except HTTPException:
raise
except Exception:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token",
headers={"WWW-Authenticate": "Bearer"},
)
def _sse_format(event: StepEvent) -> str:
lines = [f"event: {event.event_type}"]
payload = event.model_dump(mode="json")
lines.append(f"data: {payload}")
lines.append("")
lines.append("")
return "\n".join(lines)
@router.post(
"/analysis-jobs",
response_model=dict,
status_code=status.HTTP_201_CREATED,
responses={401: {"model": ErrorResponse}, 422: {"model": ErrorResponse}},
)
async def submit_analysis_job(
payload: AnalysisJobSubmit,
user_id: str = Depends(_verify_jwt_token),
):
try:
job_id = await analysis_service.submit_job(
session_id=payload.session_id,
params=payload.params or {},
model_versions=payload.model_versions,
)
return {"job_id": job_id}
except NotImplementedError as exc:
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
@router.get(
"/analysis-jobs/{job_id}",
response_model=JobStatus,
responses={401: {"model": ErrorResponse}, 404: {"model": ErrorResponse}},
)
async def get_job_status(job_id: str, user_id: str = Depends(_verify_jwt_token)):
try:
return await analysis_service.job_status(job_id)
except NotImplementedError as exc:
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
except LookupError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
@router.get(
"/analysis-jobs/{job_id}/steps",
response_model=list[PipelineStep],
responses={401: {"model": ErrorResponse}, 404: {"model": ErrorResponse}},
)
async def get_job_steps(job_id: str, user_id: str = Depends(_verify_jwt_token)):
try:
return await analysis_service.job_steps(job_id)
except NotImplementedError as exc:
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
except LookupError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
@router.post(
"/analysis",
response_model=JobResult,
responses={401: {"model": ErrorResponse}, 422: {"model": ErrorResponse}},
)
async def submit_sync_analysis(
payload: AnalysisJobSyncSubmit,
user_id: str = Depends(_verify_jwt_token),
):
try:
return await analysis_service.submit_sync(
session_id=payload.session_id,
params=payload.params or {},
model_versions=payload.model_versions,
)
except NotImplementedError as exc:
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
@router.get(
"/analysis-jobs/{job_id}/stream",
responses={
401: {"model": ErrorResponse},
404: {"model": ErrorResponse},
},
)
async def stream_job_events(job_id: str, user_id: str = Depends(_verify_jwt_token)):
queue = _get_queue_sync(job_id)
async def event_generator():
try:
while True:
event = await queue.get()
yield _sse_format(event)
if event.event_type == "completed" or event.event_type == "failed":
break
except asyncio.CancelledError:
logger.info(f"SSE stream cancelled for job_id={job_id}")
finally:
async with _queue_lock:
_event_queues.pop(job_id, None)
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
"Connection": "keep-alive",
},
)
@router.post(
"/internal/analysis-jobs/{job_id}/events",
status_code=status.HTTP_202_ACCEPTED,
include_in_schema=False,
)
async def internal_push_event(job_id: str, event: dict):
queue = _get_queue_sync(job_id)
try:
step_event = StepEvent(
step_id=event.get("step_id", ""),
job_id=job_id,
event_type=event.get("event_type", "progress"),
task_type=event.get("task_type", ""),
status=event.get("status", "running"),
data=event.get("data"),
timestamp=datetime.now(),
)
await queue.put(step_event)
except Exception as exc:
logger.error(f"Failed to push event for job {job_id}: {exc}")
return {"queued": True}
@router.get(
"/health",
response_model=HealthStatus,
include_in_schema=False,
)
async def health_check():
try:
return await analysis_service.health()
except NotImplementedError:
return HealthStatus(
status="ok",
version="0.1.0",
dependencies={},
uptime_seconds=0.0,
)
@router.get(
"/model-registry",
response_model=ModelCatalog,
responses={401: {"model": ErrorResponse}},
)
async def list_models(user_id: str = Depends(_verify_jwt_token)):
try:
return await analysis_service.list_registered_models()
except NotImplementedError as exc:
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
@router.post(
"/models/register",
response_model=ModelRegistrationResult,
status_code=status.HTTP_201_CREATED,
responses={401: {"model": ErrorResponse}},
)
async def register_model(
model_id: str,
file: UploadFile | None = None,
user_id: str = Depends(_verify_jwt_token),
):
try:
return await analysis_service.register_model(model_id, file)
except NotImplementedError as exc:
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))

View File

@@ -0,0 +1,82 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from data.spec.schemas import LoginRequest, Token, UserProfile, UserUpdateRequest, RefreshRequest, ErrorResponse
from backend.implementation.auth import service as auth_service
router = APIRouter(prefix="/api/v1", tags=["auth"])
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
async def verify_jwt_token(token: str = Depends(oauth2_scheme)) -> str:
try:
profile = await auth_service.me(token)
return profile.user_id
except Exception:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token",
headers={"WWW-Authenticate": "Bearer"},
)
@router.post(
"/auth/login",
response_model=Token,
responses={401: {"model": ErrorResponse}},
)
async def login(payload: LoginRequest):
try:
return await auth_service.login(payload.username, payload.password)
except NotImplementedError as exc:
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc))
@router.post(
"/auth/logout",
status_code=status.HTTP_204_NO_CONTENT,
)
async def logout(token: str = Depends(oauth2_scheme)):
try:
await auth_service.logout(token)
except NotImplementedError as exc:
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
@router.post(
"/auth/refresh",
response_model=Token,
responses={401: {"model": ErrorResponse}},
)
async def refresh(payload: RefreshRequest):
try:
return await auth_service.refresh(payload.refresh_token)
except NotImplementedError as exc:
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc))
@router.get(
"/users/me",
response_model=UserProfile,
responses={401: {"model": ErrorResponse}},
)
async def get_me(user_id: str = Depends(verify_jwt_token)):
try:
return await auth_service.me(user_id)
except NotImplementedError as exc:
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
@router.patch(
"/users/me",
response_model=UserProfile,
responses={401: {"model": ErrorResponse}},
)
async def update_me(payload: UserUpdateRequest, user_id: str = Depends(verify_jwt_token)):
try:
return await auth_service.update_me(user_id, payload.model_dump(exclude_none=True))
except NotImplementedError as exc:
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))

View File

@@ -0,0 +1,47 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from data.spec.schemas import IngestionRecord, RecordDetail, ErrorResponse
from backend.implementation.ingestion_history import service as ingestion_service
router = APIRouter(prefix="/api/v1", tags=["ingestion-history"])
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
async def _verify_jwt_token(token: str = Depends(oauth2_scheme)) -> str:
try:
from backend.api.auth_api import verify_jwt_token as _verify
return await _verify(token)
except HTTPException:
raise
except Exception:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token",
headers={"WWW-Authenticate": "Bearer"},
)
@router.get(
"/ingestion-history",
response_model=list[IngestionRecord],
responses={401: {"model": ErrorResponse}},
)
async def list_ingestion_records(user_id: str = Depends(_verify_jwt_token)):
try:
return await ingestion_service.list_records(user_id)
except NotImplementedError as exc:
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
@router.get(
"/ingestion-history/{record_id}",
response_model=RecordDetail,
responses={401: {"model": ErrorResponse}, 404: {"model": ErrorResponse}},
)
async def get_ingestion_record(record_id: str, user_id: str = Depends(_verify_jwt_token)):
try:
return await ingestion_service.get_record(record_id)
except NotImplementedError as exc:
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
except LookupError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))

View File

@@ -0,0 +1,59 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from data.spec.schemas import NotificationItem, NotificationPreferences, ErrorResponse
from backend.implementation.notification import service as notification_service
router = APIRouter(prefix="/api/v1", tags=["notification"])
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
async def _verify_jwt_token(token: str = Depends(oauth2_scheme)) -> str:
try:
from backend.api.auth_api import verify_jwt_token as _verify
return await _verify(token)
except HTTPException:
raise
except Exception:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token",
headers={"WWW-Authenticate": "Bearer"},
)
@router.get(
"/notifications",
response_model=list[NotificationItem],
responses={401: {"model": ErrorResponse}},
)
async def list_notifications(user_id: str = Depends(_verify_jwt_token), filters: dict | None = None):
try:
return await notification_service.list_notifications(user_id, filters)
except NotImplementedError as exc:
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
@router.patch(
"/notifications/{notification_id}/read",
status_code=status.HTTP_204_NO_CONTENT,
responses={401: {"model": ErrorResponse}, 404: {"model": ErrorResponse}},
)
async def mark_read(notification_id: str, user_id: str = Depends(_verify_jwt_token)):
try:
await notification_service.mark_read(notification_id)
except NotImplementedError as exc:
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
except LookupError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
@router.post(
"/notifications/preferences",
status_code=status.HTTP_204_NO_CONTENT,
responses={401: {"model": ErrorResponse}},
)
async def set_preferences(payload: NotificationPreferences, user_id: str = Depends(_verify_jwt_token)):
try:
await notification_service.set_preferences(user_id, payload.model_dump(exclude_none=True))
except NotImplementedError as exc:
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))

View File

@@ -0,0 +1,91 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from data.spec.schemas import Patient, PatientCreate, PatientListResponse, ErrorResponse
from backend.implementation.patient import service as patient_service
router = APIRouter(prefix="/api/v1", tags=["patient"])
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
async def _verify_jwt_token(token: str = Depends(oauth2_scheme)) -> str:
try:
from backend.api.auth_api import verify_jwt_token as _verify
return await _verify(token)
except HTTPException:
raise
except Exception:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token",
headers={"WWW-Authenticate": "Bearer"},
)
@router.get(
"/patients",
response_model=PatientListResponse,
responses={401: {"model": ErrorResponse}},
)
async def list_patients(user_id: str = Depends(_verify_jwt_token)):
try:
items = await patient_service.list_patients(user_id)
return PatientListResponse(items=items, total=len(items))
except NotImplementedError as exc:
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
@router.post(
"/patients",
response_model=Patient,
status_code=status.HTTP_201_CREATED,
responses={401: {"model": ErrorResponse}, 422: {"model": ErrorResponse}},
)
async def create_patient(payload: PatientCreate, user_id: str = Depends(_verify_jwt_token)):
try:
return await patient_service.create_patient(payload.model_dump(exclude_none=True))
except NotImplementedError as exc:
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
@router.get(
"/patients/{patient_id}",
response_model=Patient,
responses={401: {"model": ErrorResponse}, 404: {"model": ErrorResponse}},
)
async def get_patient(patient_id: str, user_id: str = Depends(_verify_jwt_token)):
try:
return await patient_service.get_patient(patient_id)
except NotImplementedError as exc:
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
except LookupError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
@router.get(
"/patients/{patient_id}/sessions",
response_model=list[dict],
responses={401: {"model": ErrorResponse}, 404: {"model": ErrorResponse}},
)
async def list_patient_sessions(patient_id: str, user_id: str = Depends(_verify_jwt_token)):
try:
return await patient_service.list_sessions(patient_id)
except NotImplementedError as exc:
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
except LookupError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
@router.get(
"/patients/{patient_id}/history",
response_model=list[dict],
responses={401: {"model": ErrorResponse}, 404: {"model": ErrorResponse}},
)
async def patient_ingestion_history(patient_id: str, user_id: str = Depends(_verify_jwt_token)):
try:
return await patient_service.ingestion_history(patient_id)
except NotImplementedError as exc:
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
except LookupError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))

View File

@@ -0,0 +1,320 @@
import asyncio
from typing import Any
import httpx
import logging
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
from fastapi.security import OAuth2PasswordBearer
from fastapi.responses import StreamingResponse
from PILOT_PROJECT.workspace.sprint_1_2.CODEBASE.data.spec.schemas.safety_schemas import ChatResponse
from data.spec.schemas import (
HeatmapResult, RationaleResult, ChatEvent, DriftCheckResult,
EvidenceList, ActivationMeta, AnnotationArtifact, EscalationTicket,
GuardrailResult, ErrorResponse, CorrectionSubmit, CorrectionRecord,
)
from backend.implementation.safety import service as safety_service
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1", tags=["safety"])
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
async def _verify_jwt_token(token: str = Depends(oauth2_scheme)) -> str:
try:
from backend.api.auth_api import verify_jwt_token as _verify
return await _verify(token)
except HTTPException:
raise
except Exception:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token",
headers={"WWW-Authenticate": "Bearer"},
)
def _sse_chat_format(event: ChatEvent) -> str:
lines = [f"event: {event.event_type}"]
payload = event.model_dump(mode="json")
lines.append(f"data: {payload}")
lines.append("")
lines.append("")
return "\n".join(lines)
@router.post(
"/sessions/{session_id}/explanations/gradcam",
response_model=HeatmapResult,
responses={401: {"model": ErrorResponse}, 404: {"model": ErrorResponse}},
)
async def gradcam(session_id: str, user_id: str = Depends(_verify_jwt_token)):
try:
return await safety_service.gradcam(session_id)
except NotImplementedError as exc:
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
except LookupError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
@router.post(
"/sessions/{session_id}/explanations/rationale",
response_model=RationaleResult,
responses={401: {"model": ErrorResponse}, 404: {"model": ErrorResponse}},
)
async def rationale(
session_id: str,
redaction_hash: str | None = None,
user_id: str = Depends(_verify_jwt_token)
):
try:
return await safety_service.rationale(session_id, redaction_hash)
except HTTPException:
raise
except NotImplementedError as exc:
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
except LookupError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
@router.post(
"/sessions/{session_id}/safety/circuit-breaker",
status_code=status.HTTP_204_NO_CONTENT,
responses={401: {"model": ErrorResponse}, 404: {"model": ErrorResponse}},
)
async def circuit_breaker(session_id: str, payload: dict, user_id: str = Depends(_verify_jwt_token)):
try:
await safety_service.circuit_break(session_id, payload.get("flag", False))
except NotImplementedError as exc:
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
except LookupError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
@router.post(
"/sessions/{session_id}/chat/socratic",
response_model=ChatResponse,
responses={401: {"model": ErrorResponse}, 404: {"model: ErrorResponse}},
)
async def socratic_chat(
session_id: str,
payload: dict,
redaction_hash: str | None = None,
user_id: str = Depends(_verify_jwt_token)
):
try:
return await safety_service.socratic_chat(
session_id,
payload.get("prompt", ""),
redaction_hash=redaction_hash
)
except HTTPException:
raise
except NotImplementedError as exc:
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
except LookupError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
@router.post(
"/sessions/{session_id}/drift/check",
response_model=DriftCheckResult,
responses={401: {"model": ErrorResponse}, 404: {"model": ErrorResponse}},
)
async def drift_check(session_id: str, user_id: str = Depends(_verify_jwt_token)):
try:
return await safety_service.drift_check(session_id)
except NotImplementedError as exc:
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
except LookupError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
@router.post(
"/sessions/{session_id}/rag/evidence",
response_model=EvidenceList,
responses={401: {"model": ErrorResponse}, 404: {"model": ErrorResponse}},
)
async def rag_evidence(session_id: str, user_id: str = Depends(_verify_jwt_token)):
try:
return await safety_service.rag_evidence(session_id)
except NotImplementedError as exc:
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
except LookupError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
@router.post(
"/sessions/{session_id}/activations",
response_model=ActivationMeta,
responses={401: {"model": ErrorResponse}, 404: {"model": ErrorResponse}},
)
async def activations(session_id: str, params: dict, user_id: str = Depends(_verify_jwt_token)):
try:
return await safety_service.activations(session_id, params)
except NotImplementedError as exc:
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
except LookupError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
@router.post(
"/sessions/{session_id}/annotations/artifacts",
response_model=AnnotationArtifact,
status_code=status.HTTP_201_CREATED,
responses={401: {"model": ErrorResponse}, 404: {"model": ErrorResponse}},
)
async def upload_artifact(
session_id: str,
file: UploadFile = File(...),
user_id: str = Depends(_verify_jwt_token),
):
try:
return await safety_service.upload_artifact(session_id, file)
except NotImplementedError as exc:
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
except LookupError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
@router.post(
"/sessions/{session_id}/ground-truth",
status_code=status.HTTP_204_NO_CONTENT,
responses={401: {"model": ErrorResponse}, 404: {"model": ErrorResponse}},
)
async def ground_truth(session_id: str, payload: dict, user_id: str = Depends(_verify_jwt_token)):
try:
await safety_service.ground_truth(session_id, payload.get("label", {}))
except NotImplementedError as exc:
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
except LookupError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
@router.post(
"/sessions/{session_id}/escalation",
response_model=EscalationTicket,
responses={401: {"model": ErrorResponse}, 404: {"model": ErrorResponse}},
)
async def escalate(session_id: str, payload: dict, user_id: str = Depends(_verify_jwt_token)):
try:
return await safety_service.escalate(session_id, payload.get("reason", ""))
except NotImplementedError as exc:
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
except LookupError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
@router.post(
"/sessions/{session_id}/annotations/morphology",
status_code=status.HTTP_204_NO_CONTENT,
responses={401: {"model": ErrorResponse}, 404: {"model": ErrorResponse}},
)
async def morphology(session_id: str, payload: dict, user_id: str = Depends(_verify_jwt_token)):
try:
await safety_service.morphology(session_id, payload.get("annotation", {}))
except NotImplementedError as exc:
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
except LookupError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
@router.post(
"/safety/guardrail-check",
response_model=GuardrailResult,
responses={401: {"model": ErrorResponse}, 422: {"model": ErrorResponse}},
)
async def guardrail_check(payload: dict, user_id: str = Depends(_verify_jwt_token)):
try:
session_id = payload.get("session_id", "")
return await safety_service.guardrail_check(
session_id=session_id,
prompt=payload.get("prompt", ""),
score=float(payload.get("score", 0.0)),
)
except NotImplementedError as exc:
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
except (ValueError, TypeError) as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
@router.post(
"/sessions/{session_id}/feedback",
response_model=CorrectionRecord,
status_code=status.HTTP_201_CREATED,
responses={
401: {"model": ErrorResponse},
404: {"model": ErrorResponse},
422: {"model": ErrorResponse},
},
)
async def submit_correction(
session_id: str,
payload: CorrectionSubmit,
user_id: str = Depends(_verify_jwt_token),
):
try:
return await safety_service.submit_correction(session_id, payload.dict())
except NotImplementedError as exc:
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
except LookupError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
@router.get(
"/sessions/{session_id}/chat/stream",
responses={401: {"model": ErrorResponse}, 404: {"model": ErrorResponse}},
)
async def chat_stream(
session_id: str,
prompt: str,
redaction_hash: str | None = None,
user_id: str = Depends(_verify_jwt_token),
):
async def generate():
try:
async for chunk in safety_service.chat_stream(session_id, prompt, redaction_hash):
event = ChatEvent(
session_id=session_id,
event_type="chunk",
content=chunk,
is_final=False,
)
yield _sse_chat_format(event)
final_event = ChatEvent(
session_id=session_id,
event_type="completed",
content="",
is_final=True,
)
yield _sse_chat_format(final_event)
except HTTPException as exc:
error_event = ChatEvent(
session_id=session_id,
event_type="error",
content=exc.detail,
is_final=True,
)
yield _sse_chat_format(error_event)
except NotImplementedError:
fallback_event = ChatEvent(
session_id=session_id,
event_type="error",
content="Chat streaming service not yet implemented",
is_final=True,
)
yield _sse_chat_format(fallback_event)
except asyncio.CancelledError:
logger.info(f"Chat stream cancelled for session_id={session_id}")
return StreamingResponse(
generate(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
"Connection": "keep-alive",
},
)

View File

@@ -0,0 +1,214 @@
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
from fastapi.security import OAuth2PasswordBearer
from data.spec.schemas import (
Session, SessionDetail, SessionCreate, SessionPatchReview,
FrameMetadata, PersistResult, ExportResult, ScrubResult,
ReportCreate, ReportSignRequest, ReportSyncEMRRequest, SyncResult,
ErrorResponse,
)
from backend.implementation.session import service as session_service
router = APIRouter(prefix="/api/v1", tags=["session"])
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
async def verify_jwt_token(token: str = Depends(oauth2_scheme)) -> str:
try:
from backend.api.auth_api import verify_jwt_token as _verify
return await _verify(token)
except HTTPException:
raise
except Exception:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token",
headers={"WWW-Authenticate": "Bearer"},
)
@router.post(
"/sessions",
response_model=Session,
status_code=status.HTTP_201_CREATED,
responses={401: {"model": ErrorResponse}, 422: {"model": ErrorResponse}},
)
async def create_session(payload: SessionCreate, user_id: str = Depends(verify_jwt_token)):
try:
return await session_service.create_session(
user_id=user_id,
patient_id=payload.patient_id,
case_id=getattr(payload, "case_id", None),
)
except NotImplementedError as exc:
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
@router.get(
"/sessions/{session_id}",
response_model=SessionDetail,
responses={401: {"model": ErrorResponse}, 404: {"model": ErrorResponse}},
)
async def get_session(session_id: str, user_id: str = Depends(verify_jwt_token)):
try:
return await session_service.get_session(session_id)
except NotImplementedError as exc:
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
except LookupError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
@router.post(
"/sessions/{session_id}/frames",
response_model=FrameMetadata,
status_code=status.HTTP_201_CREATED,
responses={401: {"model": ErrorResponse}, 404: {"model": ErrorResponse}},
)
async def add_frame(
session_id: str,
file: UploadFile = File(...),
frame_number: int | None = None,
user_id: str = Depends(verify_jwt_token),
):
try:
return await session_service.add_frame(session_id, file, frame_number)
except NotImplementedError as exc:
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
except LookupError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
@router.patch(
"/sessions/{session_id}/review",
response_model=Session,
responses={401: {"model": ErrorResponse}, 404: {"model": ErrorResponse}},
)
async def patch_review(
session_id: str,
payload: SessionPatchReview,
user_id: str = Depends(verify_jwt_token),
):
try:
return await session_service.patch_review(session_id, payload.model_dump(exclude_none=True))
except NotImplementedError as exc:
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
except LookupError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
@router.post(
"/reports",
response_model=dict,
status_code=status.HTTP_201_CREATED,
responses={401: {"model": ErrorResponse}, 404: {"model": ErrorResponse}},
)
async def create_report(payload: ReportCreate, user_id: str = Depends(verify_jwt_token)):
try:
result = await session_service.persist(payload.session_id, payload.payload)
return {"report_id": result.session_id, "status": result.status, "updated_at": result.updated_at.isoformat()}
except NotImplementedError as exc:
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
except LookupError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
@router.post(
"/reports/{report_id}/sign",
response_model=dict,
responses={401: {"model": ErrorResponse}, 404: {"model": ErrorResponse}},
)
async def sign_report(
report_id: str,
payload: ReportSignRequest,
user_id: str = Depends(verify_jwt_token),
):
try:
result = await session_service.persist(report_id, {"signed": True, "signature": payload.signature})
return {"report_id": report_id, "signed": True, "updated_at": result.updated_at.isoformat()}
except NotImplementedError as exc:
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
except LookupError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
@router.post(
"/reports/{report_id}/emr-sync",
response_model=SyncResult,
responses={401: {"model": ErrorResponse}, 404: {"model": ErrorResponse}},
)
async def sync_emr(
report_id: str,
payload: ReportSyncEMRRequest,
user_id: str = Depends(verify_jwt_token),
):
from datetime import datetime
try:
result = await session_service.persist(report_id, {"emr_sync": True})
return SyncResult(
report_id=report_id,
emr_status="pending",
emr_reference=None,
synced_at=datetime.utcnow(),
)
except NotImplementedError as exc:
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
except LookupError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
@router.post(
"/sessions/{session_id}/persist",
response_model=PersistResult,
responses={401: {"model": ErrorResponse}, 404: {"model": ErrorResponse}},
)
async def persist_session(
session_id: str,
review: dict,
user_id: str = Depends(verify_jwt_token),
):
try:
return await session_service.persist(session_id, review)
except NotImplementedError as exc:
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
except LookupError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
@router.post(
"/sessions/{session_id}/export-pdf",
response_model=ExportResult,
responses={401: {"model": ErrorResponse}, 404: {"model": ErrorResponse}},
)
async def export_pdf(
session_id: str,
params: dict,
user_id: str = Depends(verify_jwt_token),
):
try:
return await session_service.export_pdf(session_id, params)
except NotImplementedError as exc:
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
except LookupError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
@router.post(
"/sessions/{session_id}/scrub-validate",
response_model=ScrubResult,
responses={401: {"model": ErrorResponse}, 404: {"model": ErrorResponse}},
)
async def scrub_validate(
session_id: str,
metadata: dict,
user_id: str = Depends(verify_jwt_token),
):
try:
return await session_service.scrub_validate(session_id, metadata)
except NotImplementedError as exc:
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
except LookupError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))

View File

@@ -0,0 +1,47 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from data.spec.schemas import UserSettings, SettingsUpdate, ErrorResponse
from backend.implementation.settings import service as settings_service
router = APIRouter(prefix="/api/v1", tags=["settings"])
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
async def _verify_jwt_token(token: str = Depends(oauth2_scheme)) -> str:
try:
from backend.api.auth_api import verify_jwt_token as _verify
return await _verify(token)
except HTTPException:
raise
except Exception:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token",
headers={"WWW-Authenticate": "Bearer"},
)
@router.get(
"/settings",
response_model=UserSettings,
responses={401: {"model": ErrorResponse}},
)
async def get_settings(user_id: str = Depends(_verify_jwt_token)):
try:
return await settings_service.get_settings(user_id)
except NotImplementedError as exc:
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
@router.patch(
"/settings",
response_model=UserSettings,
responses={401: {"model": ErrorResponse}, 422: {"model": ErrorResponse}},
)
async def update_settings(payload: SettingsUpdate, user_id: str = Depends(_verify_jwt_token)):
try:
return await settings_service.update_settings(user_id, payload.model_dump(exclude_none=True))
except NotImplementedError as exc:
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))

View File

@@ -0,0 +1,40 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from data.spec.schemas import AnomalyReport, AnomalyRecord, ErrorResponse
from backend.implementation.telemetry import service as telemetry_service
router = APIRouter(prefix="/api/v1", tags=["telemetry"])
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
async def verify_jwt_token(token: str = Depends(oauth2_scheme)) -> str:
try:
from backend.api.auth_api import verify_jwt_token as _verify
return await _verify(token)
except HTTPException:
raise
except Exception:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token",
headers={"WWW-Authenticate": "Bearer"},
)
@router.post(
"/analysis-jobs/{job_id}/anomalies",
response_model=AnomalyRecord,
status_code=status.HTTP_201_CREATED,
responses={401: {"model": ErrorResponse}, 404: {"model": ErrorResponse}},
)
async def report_anomaly(
job_id: str,
payload: AnomalyReport,
user_id: str = Depends(verify_jwt_token),
):
try:
return await telemetry_service.report_anomaly(job_id, payload.data)
except NotImplementedError as exc:
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
except LookupError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))

View File

@@ -0,0 +1,93 @@
"""
Standalone FastAPI server for CV inference (Modal Triton).
Run from CODEBASE root:
PYTHONPATH=. python -m backend.cv_inference_server
Or the backward-compatible launcher:
PYTHONPATH=. python backend/tests/test_fast_api_proxy.py
Default: http://127.0.0.1:8001 — point the frontend Vite proxy here (see .env.development).
Env:
TRITON_ENDPOINT Modal Triton KServe v2 HTTP URL
CV_INFERENCE_HOST bind host (default 127.0.0.1)
CV_INFERENCE_PORT bind port (default 8001)
ANGLE_MODEL / INFLAMMATION_MODEL / SEGMENT_MODEL optional overrides
CV_PIPELINE_VERSION cache invalidation fingerprint (default poc-v2-spec-cv)
"""
from __future__ import annotations
import logging
import os
from contextlib import asynccontextmanager
# Must run before backend imports — config reads TRITON_ENDPOINT at import time.
DEFAULT_TRITON_ENDPOINT = "https://dtj-tran--triton-s3-service-unified-triton-server.modal.run"
os.environ.setdefault("TRITON_ENDPOINT", DEFAULT_TRITON_ENDPOINT)
import uvicorn
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from backend.routers import cv_inference
logger = logging.getLogger(__name__)
DEFAULT_TRITON_ENDPOINT = "https://dtj-tran--triton-s3-service-unified-triton-server.modal.run"
@asynccontextmanager
async def lifespan(app: FastAPI):
logger.info("Starting CV inference service on Triton: %s", os.getenv("TRITON_ENDPOINT"))
from backend.services.triton_warmup import warmup_triton_models
try:
await warmup_triton_models()
except Exception as exc:
logger.warning("Triton warmup failed — first clinical request may be slow: %s", exc)
yield
logger.info("Shutting down CV inference service")
def create_app() -> FastAPI:
app = FastAPI(
title="VKIST CV Inference Service",
version="0.2.0",
description=(
"Spec-compliant musculoskeletal ultrasound CV pipeline "
"(CLAHE → angle → inflammation → conditional segmentation)."
),
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=os.getenv(
"CORS_ORIGINS",
"http://localhost:3000,http://localhost:5173,http://localhost:4173,http://127.0.0.1:5173",
).split(","),
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(cv_inference.router)
return app
app = create_app()
def main() -> None:
logging.basicConfig(level=logging.INFO)
host = os.getenv("CV_INFERENCE_HOST", os.getenv("SEGMENT_TEST_HOST", "127.0.0.1"))
port = int(os.getenv("CV_INFERENCE_PORT", os.getenv("SEGMENT_TEST_PORT", "8001")))
logger.info("CV inference service listening on %s:%s", host, port)
uvicorn.run(app, host=host, port=port, log_level="info")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,48 @@
import logging
from typing import NamedTuple, Optional
from dataclasses import dataclass
logger = logging.getLogger(__name__)
@dataclass
class DriftResult:
score: float
is_drifted: bool
threshold: float
@dataclass
class GuardrailResult:
verdict: str # "PASS" | "MITIGATE"
reason: Optional[str] = None
class BERTAdapter:
"""
Adapter for BERT-based safety checks (drift, referee, guardrails).
Current implementation provides stubs.
"""
def __init__(self):
self.drift_threshold = 0.7
def drift_check(self, text: str) -> DriftResult:
"""
Checks if the input text drifts from the clinical domain.
"""
# Stub: Always return no drift
return DriftResult(score=0.9, is_drifted=False, threshold=self.drift_threshold)
def referee_check(self, text: str, retrieved_chunks: list) -> bool:
"""
RAG-Referee: Validates if LLM response is grounded in provided chunks.
"""
# Stub: Always return grounded
return True
def guardrail_check(self, text: str) -> GuardrailResult:
"""
Token/Chunk level guardrail check for hallucinations or scope violations.
"""
# Stub: Always return PASS
return GuardrailResult(verdict="PASS")
def get_bert_adapter() -> BERTAdapter:
return BERTAdapter()

View File

@@ -0,0 +1,71 @@
import logging
from typing import Any, AsyncGenerator, List, Optional
from langchain_google_vertexai import VertexAI
from langchain.callbacks.base import BaseCallbackHandler
from langchain.schema import LLMResult
from backend.implementation import config
logger = logging.getLogger(__name__)
class AuditCallbackHandler(BaseCallbackHandler):
"""
Langchain callback to enforce audit logging before LLM calls per NFR-16a.
"""
def __init__(self, session_id: str, metadata: Optional[dict] = None):
self.session_id = session_id
self.metadata = metadata or {}
def on_llm_start(self, serialized: Any, prompts: List[str], **kwargs) -> None:
# MANDATORY: Write egress_consent + egress_redact_manifest to immutable audit log
# In a real implementation, this would call a database service to commit to Postgres.
logger.info(f"[AUDIT] Pre-egress audit commit for session {self.session_id}. "
f"Prompts: {prompts}. Metadata: {self.metadata}")
def on_llm_end(self, response: LLMResult, **kwargs) -> None:
# Log actual egress event after completion
logger.info(f"[AUDIT] LLM egress completed for session {self.session_id}")
def on_llm_error(self, error: Exception, **kwargs) -> None:
logger.error(f"[AUDIT] LLM error for session {self.session_id}: {str(error)}")
class VertexAILangchainAdapter:
def __init__(self):
self.llm = VertexAI(
model_name=config.VERTEX_AI_MODEL,
project_id=config.VERTEX_AI_PROJECT,
location=config.VERTEX_AI_LOCATION,
max_output_tokens=256,
temperature=0.2,
top_p=0.8,
top_k=40,
)
async def generate(self, prompt: str, session_id: str, metadata: Optional[dict] = None) -> str:
import asyncio
loop = asyncio.get_event_loop()
callback_handler = AuditCallbackHandler(session_id, metadata)
def _sync_generate():
result = self.llm.generate(
prompts=[prompt],
callbacks=[callback_handler]
)
return result.generations[0][0].text
return await loop.run_in_executor(None, _sync_generate)
async def stream_generate(self, prompt: str, session_id: str, metadata: Optional[dict] = None) -> AsyncGenerator[str, None]:
import asyncio
loop = asyncio.get_event_loop()
callback_handler = AuditCallbackHandler(session_id, metadata)
def _sync_stream():
return self.llm.stream(prompt, callbacks=[callback_handler])
stream = await loop.run_in_executor(None, _sync_stream)
for chunk in stream:
yield chunk
def get_llm_adapter() -> VertexAILangchainAdapter:
return VertexAILangchainAdapter()

View File

@@ -0,0 +1,40 @@
import redis
import logging
from backend.implementation import config
logger = logging.getLogger(__name__)
class RedisClient:
"""
Singleton Redis client for managing session state and consult_mode.
"""
_instance = None
def __new__(cls):
if cls._instance is None:
cls._instance = super(RedisClient, cls).__new__(cls)
try:
cls._instance.client = redis.Redis(
host=config.REDIS_HOST,
port=config.REDIS_PORT,
db=config.REDIS_DB,
decode_responses=True
)
logger.info("Connected to Redis at %s:%s", config.REDIS_HOST, config.REDIS_PORT)
except Exception as e:
logger.error("Failed to connect to Redis: %s", e)
cls._instance.client = None
return cls._instance
def get(self, key: str):
return self.client.get(key) if self.client else None
def set(self, key: str, value: str, ex: int = None):
if self.client:
self.client.set(key, value, ex=ex)
def exists(self, key: str) -> bool:
return bool(self.client.exists(key)) if self.client else False
def get_redis_client() -> RedisClient:
return RedisClient()

View File

@@ -0,0 +1,121 @@
import asyncio
import json
from typing import Any
import numpy as np
import requests
class TritonAdapter:
def __init__(self, endpoint_url: str, timeout: float = 60.0):
self.endpoint_url = endpoint_url.rstrip("/")
self.timeout = timeout
async def close(self):
pass
async def infer(
self, model_name: str, inputs: dict, outputs: list[str] | None = None
) -> dict:
return await asyncio.to_thread(
self._infer_sync, model_name, inputs, outputs
)
def _infer_sync(
self, model_name: str, inputs: dict, outputs: list[str] | None = None
) -> dict:
metadata_inputs = []
binary_chunks = []
for name, spec in inputs.items():
data = spec["data"]
shape = spec.get("shape", [])
datatype = spec.get("datatype", "FP32")
arr = np.asarray(data, dtype=np.float32)
binary = arr.tobytes()
metadata_inputs.append(
{
"name": name,
"shape": shape,
"datatype": datatype,
"parameters": {"binary_data_size": len(binary)},
}
)
binary_chunks.append(binary)
metadata_outputs = [{"name": o} for o in (outputs or [])]
metadata = {
"inputs": metadata_inputs,
"outputs": metadata_outputs,
}
metadata_bytes = json.dumps(metadata).encode("utf-8")
body = metadata_bytes + b"".join(binary_chunks)
headers = {
"Inference-Header-Content-Length": str(len(metadata_bytes)),
"Content-Type": "application/octet-stream",
}
url = f"{self.endpoint_url}/v2/models/{model_name}/infer"
response = requests.post(url, data=body, headers=headers, timeout=self.timeout)
response.raise_for_status()
return self._parse_binary_response(response.headers, response.content)
@staticmethod
def _parse_binary_response(headers: dict, body: bytes) -> dict:
header_len = int(headers.get("Inference-Header-Content-Length", "0"))
metadata = json.loads(body[:header_len].decode("utf-8"))
result = {}
offset = 0
for output in metadata.get("outputs", []):
name = output["name"]
shape = output.get("shape", [])
params = output.get("parameters", {})
binary_size = params.get("binary_data_size", 0)
if binary_size > 0:
chunk = body[header_len + offset : header_len + offset + binary_size]
arr = np.frombuffer(chunk, dtype=np.float32).reshape(shape)
result[name] = arr.tolist()
offset += binary_size
return result
async def model_ready(self, model_name: str) -> bool:
return await asyncio.to_thread(self._model_ready_sync, model_name)
def _model_ready_sync(self, model_name: str) -> bool:
url = f"{self.endpoint_url}/v2/models/{model_name}"
response = requests.get(url, timeout=self.timeout)
if response.status_code == 404:
return False
response.raise_for_status()
data = response.json()
return data.get("ready", False)
async def list_models(self) -> list[dict]:
return await asyncio.to_thread(self._list_models_sync)
# def _list_models_sync(self) -> list[dict]:
# url = f"{self.endpoint_url}/v2/models"
# response = requests.get(url, timeout=self.timeout)
# response.raise_for_status()
# data = response.json()
# return data.get("models", [])
def _list_models_sync(self) -> list[dict]:
# 1. Change the endpoint to Triton's repository index path
url = f"{self.endpoint_url}/v2/repository/index"
# 2. Change requests.get to requests.post with an empty json payload {}
response = requests.post(url, json={}, timeout=self.timeout)
response.raise_for_status()
data = response.json()
# KServe v2 returns a list directly: [{"name": "model_a", "version": "1", "state": "READY"}]
return data

View File

@@ -0,0 +1,323 @@
import asyncio
import io
import base64
import uuid
import logging
import numpy as np
from datetime import datetime
from typing import Any
from data.spec.schemas import (
AnalysisJobSubmit, JobStatus, JobResult, PipelineStep,
StepEvent, ModelCatalog, ModelRegistrationResult,
HealthStatus,
)
from PIL import Image
from backend.implementation.preprocessing.clahe import apply_clahe
from backend.implementation.preprocessing.tensor_prep import (
prepare_angle_tensor,
prepare_inflammation_tensor,
prepare_segmentation_tensor,
)
from backend.implementation.postprocessing.measurement import calculate_thickness
from backend.implementation.postprocessing.severity import calculate_severity
from backend.implementation.postprocessing.overlay import create_overlay
from backend.implementation.postprocessing.calibration import (
calibration_config_from_params,
interpret_angle_logits,
interpret_inflammation_logits,
)
from backend.implementation.config import (
get_model_name,
get_segmentation_model,
get_angle_type,
TRITON_ENDPOINT,
)
from backend.implementation.adapters.triton_adapter import TritonAdapter
logger = logging.getLogger(__name__)
_job_registry: dict[str, dict] = {}
_job_lock = asyncio.Lock()
def _interpret_angle_result(result: dict, params: dict | None = None) -> dict:
logits = result.get("logits", [])
if not logits:
raise ValueError("Empty angle logits")
config = calibration_config_from_params(params)
return interpret_angle_logits(logits, config)
def _interpret_inflammation_result(result: dict, params: dict | None = None) -> dict:
logits = result.get("logits", [])
if not logits:
raise ValueError("Empty inflammation logits")
config = calibration_config_from_params(params)
return interpret_inflammation_logits(logits, config)
def _process_segmentation_result(result: dict, angle_class: str) -> tuple:
logits = result.get("logits", [])
if not logits:
raise ValueError("Empty segmentation logits")
logits_arr = np.array(logits)
if logits_arr.ndim < 3:
raise ValueError("Unexpected segmentation output shape")
preds = logits_arr.argmax(axis=1)[0]
angle_type = get_angle_type(angle_class)
if angle_type == "sup":
class_map = {
0: "background", 1: "effusion", 2: "fat", 3: "fat-pat",
4: "femur", 5: "synovium", 6: "tendon",
}
else:
class_map = {
0: "background", 1: "fat", 2: "tendon", 3: "muscle",
4: "femur", 5: "artery", 6: "baker's cyst",
}
masks = {}
for class_id, class_name in class_map.items():
masks[class_name] = (preds == class_id).astype(np.uint8)
return preds, masks
async def _get_triton_adapter() -> TritonAdapter:
return TritonAdapter(endpoint_url=TRITON_ENDPOINT)
def _encode_image_to_base64(image_pil: Image.Image) -> str:
buffered = io.BytesIO()
image_pil.save(buffered, format="PNG")
return base64.b64encode(buffered.getvalue()).decode()
async def _run_pipeline(image_pil: Image.Image, session_id: str, params: dict, model_versions: dict | None = None) -> dict:
enhanced_pil = apply_clahe(image_pil)
angle_tensor = prepare_angle_tensor(image_pil)
inflammation_tensor = prepare_inflammation_tensor(image_pil)
segmentation_tensor = prepare_segmentation_tensor(image_pil)
triton = await _get_triton_adapter()
angle_model = get_model_name("angle", model_versions)
angle_result = await triton.infer(
model_name=angle_model,
inputs={"input": {"data": angle_tensor.tolist(), "shape": list(angle_tensor.shape), "datatype": "FP32"}},
)
angle_interpreted = _interpret_angle_result(angle_result, params)
result = {
"angle": {
"class": angle_interpreted["class"],
"confidence": angle_interpreted["confidence"],
"calibration": angle_interpreted["calibration"],
},
"models_used": {"angle": angle_model},
}
if angle_interpreted["class"] in ("post-trans", "sup-up-long"):
inflam_model = get_model_name("inflammation", model_versions)
inflammation_result = await triton.infer(
model_name=inflam_model,
inputs={"input": {"data": inflammation_tensor.tolist(), "shape": list(inflammation_tensor.shape), "datatype": "FP32"}},
)
inflammation_interpreted = _interpret_inflammation_result(inflammation_result, params)
result["inflammation"] = {
"detected": inflammation_interpreted["detected"],
"confidence": inflammation_interpreted["confidence"],
"calibration": inflammation_interpreted["calibration"],
}
result["models_used"]["inflammation"] = inflam_model
if inflammation_interpreted["detected"]:
seg_model_name = get_segmentation_model(angle_interpreted["class"], model_versions)
seg_result = await triton.infer(
model_name=seg_model_name,
inputs={"input": {"data": segmentation_tensor.tolist(), "shape": list(segmentation_tensor.shape), "datatype": "FP32"}},
)
preds, masks = _process_segmentation_result(seg_result, angle_interpreted["class"])
angle_type = get_angle_type(angle_interpreted["class"])
measurement = calculate_thickness(masks, image_pil.size)
severity = calculate_severity(masks, image_pil.size)
segmented_overlay = create_overlay(image_pil, masks, measurement, angle_type)
result.update({
"measurement": measurement,
"severity": severity,
"segmentation": {
"performed": True,
"classes_detected": [k for k, v in masks.items() if np.sum(v) > 0],
"angle_type": angle_type,
},
"images": {
"enhanced": _encode_image_to_base64(enhanced_pil),
"segmented": _encode_image_to_base64(segmented_overlay),
},
})
result["models_used"]["segmentation"] = seg_model_name
else:
from backend.implementation.pipeline.cv_spec_pipeline import (
build_segmentation_skipped,
build_severity_zero,
)
result["segmentation"] = build_segmentation_skipped("no_inflammation")
result["severity"] = build_severity_zero("no_inflammation")
result["images"] = {"enhanced": _encode_image_to_base64(enhanced_pil)}
else:
from backend.implementation.pipeline.cv_spec_pipeline import (
build_segmentation_skipped,
build_severity_zero,
)
result["segmentation"] = build_segmentation_skipped("angle_only")
result["severity"] = build_severity_zero("angle_only")
result["images"] = {"enhanced": _encode_image_to_base64(enhanced_pil)}
return result
async def submit_sync(session_id: str, params: dict, model_versions: dict | None = None) -> JobResult:
if "local_image_path" in params:
image_pil = Image.open(params["local_image_path"]).convert("RGB")
elif "local_image_bytes" in params:
image_pil = Image.open(io.BytesIO(params["local_image_bytes"])).convert("RGB")
else:
from backend.implementation.session import service as session_service
frame_metadata = await session_service.get_frame(session_id, params.get("frame_id"))
raise NotImplementedError("S3 frame retrieval not yet integrated; use local_image_path for testing")
pipeline_result = await _run_pipeline(image_pil, session_id, params, model_versions)
job_id = str(uuid.uuid4())
return JobResult(
job_id=job_id,
session_id=session_id,
status="completed",
result=pipeline_result,
duration_ms=0,
)
async def submit_job(session_id: str, params: dict, model_versions: dict | None = None) -> str:
job_id = str(uuid.uuid4())
async with _job_lock:
_job_registry[job_id] = {
"session_id": session_id,
"params": params,
"model_versions": model_versions,
"status": "queued",
"result": None,
"steps": [],
"created_at": datetime.now(),
}
async def _background():
try:
await push_step_event(job_id, {
"step_id": str(uuid.uuid4()),
"job_id": job_id,
"event_type": "progress",
"task_type": "analysis",
"status": "running",
})
async with _job_lock:
_job_registry[job_id]["status"] = "running"
result = await submit_sync(session_id, params, model_versions)
async with _job_lock:
_job_registry[job_id].update({
"status": "completed",
"result": result.model_dump(),
})
await push_step_event(job_id, {
"step_id": str(uuid.uuid4()),
"job_id": job_id,
"event_type": "completed",
"task_type": "analysis",
"status": "completed",
"data": {"job_result": result.model_dump()},
})
except Exception as exc:
logger.exception(f"Job {job_id} failed")
async with _job_lock:
_job_registry[job_id].update({
"status": "failed",
"result": {"error": str(exc)},
})
await push_step_event(job_id, {
"step_id": str(uuid.uuid4()),
"job_id": job_id,
"event_type": "failed",
"task_type": "analysis",
"status": "failed",
"data": {"error": str(exc)},
})
asyncio.create_task(_background())
return job_id
async def job_status(job_id: str) -> JobStatus:
async with _job_lock:
job = _job_registry.get(job_id)
if not job:
raise LookupError(f"Job {job_id} not found")
return JobStatus(
job_id=job_id,
session_id=job["session_id"],
status=job["status"],
result=job.get("result"),
steps=job.get("steps", []),
created_at=job["created_at"],
updated_at=datetime.now(),
)
async def job_steps(job_id: str) -> list[PipelineStep]:
async with _job_lock:
job = _job_registry.get(job_id)
if not job:
raise LookupError(f"Job {job_id} not found")
return job.get("steps", [])
async def list_registered_models() -> ModelCatalog:
return ModelCatalog(models=[], total=0)
async def register_model(model_id: str, file: Any) -> ModelRegistrationResult:
raise NotImplementedError("Model registration not yet implemented")
async def health() -> HealthStatus:
try:
triton = await _get_triton_adapter()
models = await triton.list_models()
ready = any(m.get("state") == "READY" for m in models)
status = "ok" if ready else "degraded"
return HealthStatus(
status=status,
version="0.1.0",
dependencies={"triton": str(ready)},
uptime_seconds=0.0,
)
except Exception as exc:
logger.warning(f"Health check failed: {exc}")
return HealthStatus(status="error", version="0.1.0", dependencies={"triton": "False"}, uptime_seconds=0.0)
async def push_step_event(job_id: str, event: dict) -> None:
from backend.api.analysis_api import _event_queues, _queue_lock
async with _queue_lock:
if job_id not in _event_queues:
_event_queues[job_id] = asyncio.Queue()
step_event = StepEvent(
step_id=event.get("step_id", ""),
job_id=job_id,
event_type=event.get("event_type", "progress"),
task_type=event.get("task_type", ""),
status=event.get("status", "running"),
data=event.get("data"),
timestamp=datetime.now(),
)
await _event_queues[job_id].put(step_event)

View File

@@ -0,0 +1,21 @@
from data.spec.schemas import LoginRequest, Token, UserProfile, UserUpdateRequest
async def login(username: str, password: str) -> Token:
raise NotImplementedError("Auth service not yet implemented")
async def logout(token: str) -> None:
raise NotImplementedError("Auth service not yet implemented")
async def refresh(refresh_token: str) -> Token:
raise NotImplementedError("Auth service not yet implemented")
async def me(token: str) -> UserProfile:
raise NotImplementedError("Auth service not yet implemented")
async def update_me(token: str, updates: dict) -> UserProfile:
raise NotImplementedError("Auth service not yet implemented")

View File

@@ -0,0 +1,75 @@
import os
from pathlib import Path
from typing import Dict
SECRETS_DIR = Path(__file__).resolve().parent.parent.parent.parent.parent.parent / "secrets"
def _load_secret(name: str, filename: str) -> str:
file_path = SECRETS_DIR / filename
env_file = os.getenv(f"{name}_FILE")
if env_file:
resolved = Path(env_file)
if resolved.exists():
with open(resolved, "r", encoding="utf-8") as f:
return f.read().strip()
if file_path.exists():
with open(file_path, "r", encoding="utf-8") as f:
return f.read().strip()
raise RuntimeError(
f"Required secret {name} not found at {file_path} or via {name}_FILE env var"
)
# Endpoints (environment-provided, no hardcoded fallback for production)
MODAL_MEDGEMMA_ENDPOINT = os.getenv("MODAL_MEDGEMMA_ENDPOINT")
VERTEX_AI_GEMINI_ENDPOINT = os.getenv("VERTEX_AI_GEMINI_ENDPOINT")
# Secrets (must be present in PILOT_PROJECT/secrets or env)
GCP_ACCESS_TOKEN = _load_secret("GCP_ACCESS_TOKEN", "gcp_access_token.txt")
MEDGEMMA_API_KEY = _load_secret("MEDGEMMA_API_KEY", "modal_api_key.txt")
PROJECT_ID = os.getenv("VERTEX_AI_PROJECT", "vkist-project")
LOCATION = os.getenv("VERTEX_AI_LOCATION", "asia-southeast1")
TRITON_ENDPOINT = os.getenv("TRITON_ENDPOINT", "http://localhost:8000")
TEMP_DIR = os.getenv("TEMP_DIR", "/tmp/analysis_jobs")
# LLM Configuration
VERTEX_AI_PROJECT = os.getenv("VERTEX_AI_PROJECT", "vkist-project")
VERTEX_AI_LOCATION = os.getenv("VERTEX_AI_LOCATION", "asia-southeast1")
VERTEX_AI_MODEL = os.getenv("VERTEX_AI_MODEL", "medgemma")
# Redis Configuration
REDIS_HOST = os.getenv("REDIS_HOST", "localhost")
REDIS_PORT = int(os.getenv("REDIS_PORT", "6379"))
REDIS_DB = int(os.getenv("REDIS_DB", "0"))
DEFAULT_MODEL_VERSIONS = {
"angle": "angle_classify_convnext_tiny",
"inflammation": "inflammation_model_efficientnet_b0_ultrasound_2_cls",
"segmentation_sup": "segmentation_model_unet_resnet101",
"segmentation_post": "segmentation_model_post_deeplabv3_resnet101",
}
CLAHE_CLIP_LIMIT = float(os.getenv("CLAHE_CLIP_LIMIT", "2.0"))
CLAHE_TILE_SIZE = tuple(int(x) for x in os.getenv("CLAHE_TILE_SIZE", "8,8").split(","))
def get_model_name(task: str, model_versions: Dict[str, str] | None = None) -> str:
if model_versions and task in model_versions:
return model_versions[task]
return DEFAULT_MODEL_VERSIONS.get(task, task)
def get_angle_type(angle_class: str) -> str:
if angle_class in ("sup-trans-flex", "sup-up-long"):
return "sup"
if angle_class == "post-trans":
return "post"
return "other"
def get_segmentation_model(angle_class: str, model_versions: Dict[str, str] | None = None) -> str:
angle_type = get_angle_type(angle_class)
task = "segmentation_sup" if angle_type == "sup" else "segmentation_post"
return get_model_name(task, model_versions)

View File

@@ -0,0 +1,10 @@
from data.spec.schemas import IngestionRecord, RecordDetail
from typing import Any
async def list_records(user_id: str) -> list[IngestionRecord]:
raise NotImplementedError("Ingestion history service not yet implemented")
async def get_record(record_id: str) -> RecordDetail:
raise NotImplementedError("Ingestion history service not yet implemented")

View File

@@ -0,0 +1,13 @@
from data.spec.schemas import NotificationItem, NotificationPreferences
async def list_notifications(user_id: str, filters: dict | None = None) -> list[NotificationItem]:
raise NotImplementedError("Notification service not yet implemented")
async def mark_read(notification_id: str) -> None:
raise NotImplementedError("Notification service not yet implemented")
async def set_preferences(user_id: str, prefs: dict) -> None:
raise NotImplementedError("Notification service not yet implemented")

View File

@@ -0,0 +1,21 @@
from data.spec.schemas import Patient, PatientCreate, PatientListResponse
async def list_patients(user_id: str) -> list[Patient]:
raise NotImplementedError("Patient service not yet implemented")
async def create_patient(data: dict) -> Patient:
raise NotImplementedError("Patient service not yet implemented")
async def get_patient(patient_id: str) -> Patient:
raise NotImplementedError("Patient service not yet implemented")
async def list_sessions(patient_id: str) -> list[dict]:
raise NotImplementedError("Patient service not yet implemented")
async def ingestion_history(patient_id: str) -> list[dict]:
raise NotImplementedError("Patient service not yet implemented")

View File

@@ -0,0 +1,13 @@
"""CV inference orchestration (Sprint 12 spec)."""
from backend.implementation.pipeline.cv_spec_pipeline import (
BRANCH_ANGLE_CLASSES,
build_segmentation_skipped,
build_severity_zero,
)
__all__ = [
"BRANCH_ANGLE_CLASSES",
"build_segmentation_skipped",
"build_severity_zero",
]

View File

@@ -0,0 +1,35 @@
"""Shared CV pipeline helpers — Sprint 12 architecture spec §7."""
from __future__ import annotations
# Angles that may run inflammation → conditional segmentation.
BRANCH_ANGLE_CLASSES = frozenset({"post-trans", "sup-up-long"})
def build_severity_zero(reason: str) -> dict:
descriptions = {
"angle_only": "Góc quét không yêu cầu phân đoạn viêm",
"no_inflammation": "Không phát hiện viêm — bỏ qua phân đoạn",
}
return {
"level": 0,
"severity": "Rất nhẹ",
"color": "#28a745",
"description": descriptions.get(reason, "Không phân đoạn"),
"effusion": {"pixels": 0, "ratio": 0.0, "thickness": 0},
"synovium": {"pixels": 0, "ratio": 0.0},
"combined_score": 0.0,
"reason": reason,
}
def build_segmentation_skipped(reason: str) -> dict:
notes = {
"angle_only": "Chỉ phân loại góc — med-lat / sup-trans-flex",
"no_inflammation": "Không phát hiện viêm — bỏ qua phân đoạn",
}
return {
"performed": False,
"reason": reason,
"note": notes.get(reason, reason),
}

View File

@@ -0,0 +1,230 @@
"""Temperature-scaled softmax, entropy guardrails, and risk-first prediction payloads."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
import numpy as np
ANGLE_CLASSES = ["med-lat", "post-trans", "sup-trans-flex", "sup-up-long"]
INFLAMMATION_CLASSES = ["no_inflammation", "inflammation"]
CALIBRATION_TIERS = frozenset({"aggressive", "standard", "conservative"})
# Legacy API aliases
CALIBRATION_MODES = CALIBRATION_TIERS | frozenset({"screening", "diagnostic"})
TIER_RECOMMENDED_T = {
"aggressive": 0.7,
"standard": 1.4,
"conservative": 2.2,
# legacy → tier
"screening": 2.2,
"diagnostic": 1.4,
}
TIER_BOUNDARY_AGGRESSIVE_MAX = (0.7 + 1.4) / 2
TIER_BOUNDARY_STANDARD_MAX = (1.4 + 2.2) / 2
@dataclass
class CalibrationConfig:
"""User-adjustable calibration context (maps to UI mode / clinical prior)."""
temperature: float = 1.4
mode: str = "standard"
clinical_suspicion: float = 0.0
alpha_margin: float = 0.05
ood_entropy_threshold: float = 0.88
def __post_init__(self) -> None:
self.clinical_suspicion = float(np.clip(self.clinical_suspicion, 0.0, 1.0))
if self.mode not in CALIBRATION_TIERS:
if self.mode in ("screening",):
self.mode = "conservative"
elif self.mode in ("diagnostic",):
self.mode = "standard"
else:
self.mode = "standard"
if self.temperature <= 0:
self.temperature = 1.0
def logits_to_array(logits: Any) -> np.ndarray:
arr = np.asarray(logits, dtype=np.float32).reshape(-1)
if arr.size == 0:
raise ValueError("Empty logits")
return arr
def resolve_tier_from_temperature(temperature: float) -> str:
if temperature <= TIER_BOUNDARY_AGGRESSIVE_MAX:
return "aggressive"
if temperature <= TIER_BOUNDARY_STANDARD_MAX:
return "standard"
return "conservative"
def effective_temperature(config: CalibrationConfig) -> float:
if config.temperature > 0:
return max(0.25, float(config.temperature))
return TIER_RECOMMENDED_T.get(config.mode, 1.4)
def temperature_scaled_softmax(logits: np.ndarray, temperature: float) -> np.ndarray:
scaled = logits / max(temperature, 1e-6)
shifted = scaled - np.max(scaled)
exp = np.exp(shifted)
return exp / np.sum(exp)
def shannon_entropy(probs: np.ndarray) -> float:
safe = np.clip(probs, 1e-12, 1.0)
return float(-np.sum(safe * np.log(safe)))
def normalized_entropy(probs: np.ndarray) -> float:
if probs.size <= 1:
return 0.0
return shannon_entropy(probs) / float(np.log(probs.size))
def ambiguous_class_set(probs: np.ndarray, class_names: list[str], alpha_margin: float) -> list[str]:
idx_sorted = np.argsort(probs)[::-1]
max_prob = float(probs[idx_sorted[0]])
return [class_names[i] for i in idx_sorted if float(probs[i]) >= max_prob - alpha_margin]
def estimate_misclassification_rate(max_prob: float) -> float:
"""Placeholder empirical mapping until validation-set calibration bins are wired."""
if max_prob >= 0.95:
return 0.05
if max_prob >= 0.90:
return 0.08
if max_prob >= 0.85:
return 0.12
if max_prob >= 0.75:
return 0.18
if max_prob >= 0.65:
return 0.25
return 0.35
def _risk_framing_vi(
predicted_class: str,
class_names: list[str],
probs: np.ndarray,
decision_state: str,
error_rate: float,
ambiguous_set: list[str],
norm_entropy: float,
) -> str:
if decision_state == "ood_warning":
return (
"Mô hình chưa được huấn luyện với loại ảnh tương tự, nên kết quả AI có thể không đáng tin. "
"Hãy kiểm tra chất lượng ảnh và đối chiếu lâm sàng trước khi dựa vào nhãn tự động."
)
if decision_state == "ambiguous":
alt = ", ".join(c for c in ambiguous_set if c != predicted_class)
return (
f"Dự đoán chính: {predicted_class}. Tập mơ hồ (α): {', '.join(ambiguous_set)}"
+ (f" — các lựa chọn khả dĩ gồm {alt}." if alt else ".")
+ " Cần đối chiếu lâm sàng trước khi khóa kết quả."
)
return (
f"Dự đoán: {predicted_class}. "
f"Trong các ca có phân bố thống kê tương tự, tỷ lệ phân loại sai ước tính ~{error_rate * 100:.0f}% "
f"(entropy chuẩn hóa {norm_entropy:.2f})."
)
def decision_state_from(probs: np.ndarray, norm_entropy: float, config: CalibrationConfig) -> str:
if norm_entropy >= config.ood_entropy_threshold:
return "ood_warning"
ambiguous = ambiguous_class_set(probs, [str(i) for i in range(probs.size)], config.alpha_margin)
if len(ambiguous) > 1:
return "ambiguous"
return "confident"
def interpret_classification_logits(
logits: Any,
class_names: list[str],
config: CalibrationConfig | None = None,
) -> dict[str, Any]:
if len(class_names) == 0:
raise ValueError("class_names must not be empty")
cfg = config or CalibrationConfig()
arr = logits_to_array(logits)
if arr.size != len(class_names):
raise ValueError(f"Expected {len(class_names)} logits, got {arr.size}")
temperature = effective_temperature(cfg)
tier = resolve_tier_from_temperature(temperature)
probs = temperature_scaled_softmax(arr, temperature)
pred_idx = int(np.argmax(probs))
predicted_class = class_names[pred_idx]
max_prob = float(probs[pred_idx])
entropy = shannon_entropy(probs)
norm_entropy = normalized_entropy(probs)
ambiguous = ambiguous_class_set(probs, class_names, cfg.alpha_margin)
state = decision_state_from(probs, norm_entropy, cfg)
error_rate = estimate_misclassification_rate(max_prob)
risk_vi = _risk_framing_vi(
predicted_class,
class_names,
probs,
state,
error_rate,
ambiguous,
norm_entropy,
)
class_probabilities = {
name: round(float(probs[i]) * 100, 2) for i, name in enumerate(class_names)
}
return {
"class": predicted_class,
"confidence": round(max_prob * 100, 2),
"calibration": {
"raw_logits": [round(float(x), 6) for x in arr.tolist()],
"temperature": round(temperature, 4),
"base_temperature": cfg.temperature,
"mode": tier,
"clinical_suspicion": round(cfg.clinical_suspicion, 3),
"alpha_margin": cfg.alpha_margin,
"class_probabilities": class_probabilities,
"entropy": round(entropy, 4),
"normalized_entropy": round(norm_entropy, 4),
"ambiguous_set": ambiguous,
"decision_state": state,
"predicted_error_rate": round(error_rate, 4),
"risk_framing_vi": risk_vi,
},
}
def interpret_angle_logits(logits: Any, config: CalibrationConfig | None = None) -> dict[str, Any]:
return interpret_classification_logits(logits, ANGLE_CLASSES, config)
def interpret_inflammation_logits(logits: Any, config: CalibrationConfig | None = None) -> dict[str, Any]:
payload = interpret_classification_logits(logits, INFLAMMATION_CLASSES, config)
detected = payload["class"] == "inflammation"
payload["detected"] = detected
return payload
def calibration_config_from_params(params: dict[str, Any] | None) -> CalibrationConfig:
if not params:
return CalibrationConfig()
calibration = params.get("calibration") or {}
return CalibrationConfig(
temperature=float(calibration.get("temperature", 1.0)),
mode=str(calibration.get("mode", "standard")),
clinical_suspicion=float(calibration.get("clinical_suspicion", 0.0)),
alpha_margin=float(calibration.get("alpha_margin", 0.05)),
ood_entropy_threshold=float(calibration.get("ood_entropy_threshold", 0.88)),
)

View File

@@ -0,0 +1,123 @@
__all__ = ["calculate_thickness", "get_mask_bounding_box", "find_max_continuous_segment"]
import numpy as np
import cv2
SEGMENT_CLASSES_SUPRAPAT = {
0: "background", 1: "effusion", 2: "fat", 3: "fat-pat",
4: "femur", 5: "synovium", 6: "tendon"
}
SEGMENT_CLASSES_POST = {
0: "background", 1: "fat", 2: "tendon", 3: "muscle",
4: "femur", 5: "artery", 6: "baker's cyst"
}
PIXEL_TO_MM = 45.0 / 655.0
def get_mask_bounding_box(mask, dist_percent: float = 0.01):
if mask is None or np.sum(mask) == 0:
return None
mask_uint8 = mask.astype(np.uint8)
if np.max(mask_uint8) == 1:
mask_uint8 *= 255
img_width = mask_uint8.shape[1]
dist_threshold = img_width * dist_percent
kernel = np.ones((5, 5), np.uint8)
clean_mask = cv2.morphologyEx(mask_uint8, cv2.MORPH_OPEN, kernel)
contours, _ = cv2.findContours(clean_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
if not contours:
return None
contour_info = sorted(
[{"cnt": cnt, "area": cv2.contourArea(cnt)} for cnt in contours],
key=lambda x: x["area"], reverse=True,
)
main_block = contour_info[0]
max_area = main_block["area"]
if max_area < 50:
return None
main_mask = np.zeros_like(mask_uint8)
cv2.drawContours(main_mask, [main_block["cnt"]], -1, 255, -1)
dist_map = cv2.distanceTransform(255 - main_mask, cv2.DIST_L2, 3)
significant_contours = [main_block["cnt"]]
area_threshold = max_area / 4.0
for i in range(1, len(contour_info)):
other = contour_info[i]
other_mask = np.zeros_like(mask_uint8)
cv2.drawContours(other_mask, [other["cnt"]], -1, 255, -1)
min_dist = np.min(dist_map[other_mask > 0])
if other["area"] >= area_threshold or min_dist <= dist_threshold:
significant_contours.append(other["cnt"])
all_points = np.concatenate(significant_contours)
x, y, w, h = cv2.boundingRect(all_points)
return x, y, w, h
def find_max_continuous_segment(col_array):
padded = np.concatenate(([0], col_array, [0]))
diffs = np.diff(padded)
starts = np.where(diffs == 1)[0]
ends = np.where(diffs == -1)[0]
if len(starts) == 0:
return 0, -1, -1
lengths = ends - starts
max_idx = int(np.argmax(lengths))
max_len = int(lengths[max_idx])
return max_len, int(starts[max_idx]), int(ends[max_idx])
def calculate_thickness(masks: dict, image_size, measure_ids=None):
if measure_ids is None:
measure_ids = [1, 5]
width, height = image_size
mask_all_labels = np.zeros((height, width), dtype=np.uint8)
mask_measure = np.zeros((height, width), dtype=np.uint8)
has_any_label = False
if "fat-pat" in masks:
class_map = SEGMENT_CLASSES_SUPRAPAT
else:
class_map = SEGMENT_CLASSES_POST
for class_id, class_name in class_map.items():
if class_name not in masks or class_name == "background":
continue
mask = masks[class_name]
if np.sum(mask) > 0:
has_any_label = True
mask_all_labels = np.logical_or(mask_all_labels, mask).astype(np.uint8)
if class_id in measure_ids:
mask_measure = np.logical_or(mask_measure, mask).astype(np.uint8)
if not has_any_label or np.sum(mask_measure) == 0:
return None
bbox_all = get_mask_bounding_box(mask_all_labels)
if bbox_all is None:
return None
x_all, y_all, w_all, h_all = bbox_all
roi_start = x_all + (w_all // 3)
roi_end = x_all + (2 * w_all // 3)
roi_strip = mask_measure[:, roi_start:roi_end]
global_max_len_px = 0
best_x_rel = 0
best_y_start = 0
best_y_end = 0
for x in range(roi_strip.shape[1]):
col = roi_strip[:, x]
if not np.any(col):
continue
length, y_s, y_e = find_max_continuous_segment(col)
if length > global_max_len_px:
global_max_len_px = length
best_x_rel = x
best_y_start = y_s
best_y_end = y_e
if global_max_len_px == 0:
return None
thickness_mm = global_max_len_px * PIXEL_TO_MM
real_x = roi_start + best_x_rel
return {
"thickness_px": int(global_max_len_px),
"thickness_mm": float(round(thickness_mm, 2)),
"x": int(real_x),
"y_start": int(best_y_start),
"y_end": int(best_y_end),
"roi_start": int(roi_start),
"roi_end": int(roi_end),
"bbox": {"x": int(x_all), "y": int(y_all), "w": int(w_all), "h": int(h_all)},
}

View File

@@ -0,0 +1,89 @@
__all__ = ["create_overlay"]
from PIL import Image, ImageDraw
import numpy as np
import cv2
COLOR_MAP_SUP = {
"background": [0, 0, 0],
"effusion": [255, 0, 0],
"fat": [255, 255, 0],
"fat-pat": [0, 255, 255],
"femur": [0, 255, 0],
"synovium": [255, 0, 255],
"tendon": [0, 0, 255],
}
COLOR_MAP_POST = {
"background": [0, 0, 0],
"baker's cyst": [255, 0, 0],
"fat": [255, 255, 0],
"muscle": [0, 255, 255],
"femur": [0, 255, 0],
"artery": [255, 0, 255],
"synovium": [255, 0, 255],
"tendon": [0, 0, 255],
}
def create_overlay(image_pil: Image.Image, masks: dict, measurement, angle_type: str = "sup") -> Image.Image:
if masks is None:
return image_pil
color_map = COLOR_MAP_SUP if angle_type == "sup" else COLOR_MAP_POST
img_array = np.array(image_pil)
overlay = img_array.copy()
for class_name, mask in masks.items():
if class_name in color_map and np.sum(mask) > 0:
color = color_map[class_name]
for i in range(3):
overlay[:, :, i] = np.where(
mask > 0,
(overlay[:, :, i] * 0.6 + color[i] * 0.4).astype(np.uint8),
overlay[:, :, i],
)
overlay_pil = Image.fromarray(overlay)
draw = ImageDraw.Draw(overlay_pil)
for class_name in ["effusion", "synovium"]:
mask = masks.get(class_name)
if mask is not None and np.sum(mask) > 0:
mask_uint8 = (mask * 255).astype(np.uint8)
contours, _ = cv2.findContours(mask_uint8, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
for contour in contours:
points = contour.reshape(-1, 2).tolist()
if len(points) > 2:
points = [(int(p[0]), int(p[1])) for p in points]
draw.line(points + [points[0]], fill=(255, 255, 255), width=3)
if measurement and angle_type == "sup":
x = measurement["x"]
y_start = measurement["y_start"]
y_end = measurement["y_end"]
thickness_mm = measurement["thickness_mm"]
roi_start = measurement["roi_start"]
roi_end = measurement["roi_end"]
bbox = measurement["bbox"]
draw.rectangle(
[bbox["x"], bbox["y"], bbox["x"] + bbox["w"], bbox["y"] + bbox["h"]],
outline=(0, 255, 0), width=3,
)
h = image_pil.size[1]
draw.line([(roi_start, 0), (roi_start, h)], fill=(0, 255, 255), width=2)
draw.line([(roi_end, 0), (roi_end, h)], fill=(0, 255, 255), width=2)
draw.line([(x, y_start), (x, y_end)], fill=(255, 0, 0), width=4)
radius = 4
draw.ellipse([x - radius, y_start - radius, x + radius, y_start + radius],
fill=(0, 255, 0), outline=(255, 255, 255), width=2)
draw.ellipse([x - radius, y_end - radius, x + radius, y_end + radius],
fill=(0, 255, 0), outline=(255, 255, 255), width=2)
text = f"{thickness_mm:.2f} mm"
try:
from PIL import ImageFont
font = ImageFont.load_default()
bbox_text = draw.textbbox((0, 0), text, font=font)
text_w = bbox_text[2] - bbox_text[0]
text_h = bbox_text[3] - bbox_text[1]
except Exception:
text_w, text_h = 100, 20
text_x = x + 8
text_y = y_start - text_h - 8
draw.rectangle([text_x - 2, text_y - 2, text_x + text_w + 2, text_y + text_h + 2], fill=(0, 0, 0))
draw.text((text_x, text_y), text, fill=(255, 0, 0))
return overlay_pil

View File

@@ -0,0 +1,64 @@
__all__ = ["calculate_severity"]
import numpy as np
SEVERITY_LEVELS = [
(15, 3, "Nặng", "#dc3545", "Dịch khớp dày, màng hoạt dịch tăng sinh rõ"),
(8, 2, "Trung bình", "#fd7e14", "Dịch khớp trung bình, màng hoạt dịch tăng sinh vừa"),
(3, 1, "Nhẹ", "#ffc107", "Dịch khớp mỏng, màng hoạt dịch tăng sinh nhẹ"),
(0, 0, "Rất nhẹ", "#28a745", "Lượng dịch và màng hoạt dịch trong giới hạn bình thường"),
]
def calculate_severity(masks: dict, image_size) -> dict | None:
if not masks:
return None
width, height = image_size
total_pixels = width * height
effusion_mask = masks.get("effusion", np.zeros((height, width), dtype=np.uint8))
effusion_pixels = int(np.sum(effusion_mask))
effusion_ratio = (effusion_pixels / total_pixels) * 100
effusion_thickness = 0
if effusion_pixels > 0:
rows_with_effusion = np.any(effusion_mask > 0, axis=1)
if np.any(rows_with_effusion):
effusion_thickness = int(np.sum(rows_with_effusion))
synovium_mask = masks.get("synovium", np.zeros((height, width), dtype=np.uint8))
synovium_pixels = int(np.sum(synovium_mask))
synovium_ratio = (synovium_pixels / total_pixels) * 100
effusion_score = min(effusion_thickness / height * 100, 100)
synovium_score = synovium_ratio
combined_score = effusion_score * 0.6 + synovium_score * 0.4
for threshold, level, severity, color, description in SEVERITY_LEVELS:
if combined_score > threshold:
return {
"level": int(level),
"severity": severity,
"color": color,
"description": description,
"effusion": {
"pixels": effusion_pixels,
"ratio": float(round(effusion_ratio, 2)),
"thickness": effusion_thickness,
},
"synovium": {
"pixels": synovium_pixels,
"ratio": float(round(synovium_ratio, 2)),
},
"combined_score": float(round(combined_score, 2)),
}
return {
"level": 0,
"severity": "Rất nhẹ",
"color": "#28a745",
"description": "Lượng dịch và màng hoạt dịch trong giới hạn bình thường",
"effusion": {
"pixels": effusion_pixels,
"ratio": float(round(effusion_ratio, 2)),
"thickness": effusion_thickness,
},
"synovium": {
"pixels": synovium_pixels,
"ratio": float(round(synovium_ratio, 2)),
},
"combined_score": float(round(combined_score, 2)),
}

View File

@@ -0,0 +1,13 @@
__all__ = ["apply_clahe"]
import cv2
import numpy as np
from PIL import Image
def apply_clahe(image_pil: Image.Image, clip_limit: float = 2.0, tile_grid_size: tuple[int, int] = (8, 8)) -> Image.Image:
img_array = np.array(image_pil)
gray = cv2.cvtColor(img_array, cv2.COLOR_RGB2GRAY)
clahe = cv2.createCLAHE(clipLimit=clip_limit, tileGridSize=tile_grid_size)
enhanced_gray = clahe.apply(gray)
enhanced_rgb = cv2.cvtColor(enhanced_gray, cv2.COLOR_GRAY2RGB)
return Image.fromarray(enhanced_rgb)

View File

@@ -0,0 +1,35 @@
__all__ = ["prepare_angle_tensor", "prepare_inflammation_tensor", "prepare_segmentation_tensor"]
import numpy as np
from PIL import Image
from .transforms import Resize, Normalize
ANGLE_TRANSFORM = Resize((224, 224))
ANGLE_NORMALIZE = Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])
INFLAMMATION_TRANSFORM = Resize((224, 224))
INFLAMMATION_NORMALIZE = Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])
SEGMENTATION_TRANSFORM = Resize((512, 512))
def _to_nchw(arr_hwc: np.ndarray) -> np.ndarray:
arr = arr_hwc.transpose(2, 0, 1)
return np.expand_dims(arr, axis=0)
def prepare_angle_tensor(image_pil: Image.Image) -> np.ndarray:
img = ANGLE_TRANSFORM(image_pil)
arr = ANGLE_NORMALIZE(img)
return _to_nchw(arr)
def prepare_inflammation_tensor(image_pil: Image.Image) -> np.ndarray:
img = INFLAMMATION_TRANSFORM(image_pil)
arr = INFLAMMATION_NORMALIZE(img)
return _to_nchw(arr)
def prepare_segmentation_tensor(image_pil: Image.Image) -> np.ndarray:
img = SEGMENTATION_TRANSFORM(image_pil)
arr = np.asarray(img).astype(np.float32) / 255.0
return _to_nchw(arr)

View File

@@ -0,0 +1,22 @@
__all__ = ["Resize", "Normalize"]
from PIL import Image
import numpy as np
class Resize:
def __init__(self, size: tuple[int, int]):
self.size = size
def __call__(self, image: Image.Image) -> Image.Image:
return image.resize(self.size, Image.Resampling.BILINEAR)
class Normalize:
def __init__(self, mean: list[float], std: list[float]):
self.mean = np.array(mean, dtype=np.float32)
self.std = np.array(std, dtype=np.float32)
def __call__(self, image_pil: Image.Image) -> np.ndarray:
arr = np.asarray(image_pil).astype(np.float32) / 255.0
arr = (arr - self.mean) / self.std
return arr

View File

@@ -0,0 +1,148 @@
from typing import Any, AsyncGenerator
import logging
from fastapi import HTTPException, status
from data.spec.schemas import (
HeatmapResult, RationaleResult, ChatResponse, DriftCheckResult,
EvidenceList, ActivationMeta, AnnotationArtifact, EscalationTicket,
GuardrailResult, CorrectionRecord,
)
from backend.implementation.adapters.llm_adapter import get_llm_adapter
from backend.implementation.adapters.bert_adapter import get_bert_adapter
from backend.implementation.adapters.redis_adapter import get_redis_client
logger = logging.getLogger(__name__)
llm_adapter = get_llm_adapter()
bert_adapter = get_bert_adapter()
redis_client = get_redis_client()
async def _verify_pre_egress(session_id: str, redaction_hash: str | None = None):
"""
Enforce NFR-16a Pre-Egress Checklist.
"""
# 1. Consent Verification
consent_key = f"consent:{session_id}"
if not redis_client.exists(consent_key):
logger.error(f"Consent missing for session {session_id}")
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User consent for cloud LLM egress is required."
)
# 2. Redaction Verification (if hash provided)
if redaction_hash:
# In real impl: Run Presidio on the prompt and compare hashes
# For now, we assume a simple check or stub
if redaction_hash == "FAIL_HASH":
logger.error(f"Redaction hash mismatch for session {session_id}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Redaction verification failed. PHI may be present."
)
# Note: Audit Log commit is handled via the LLM adapter's AuditCallbackHandler
# to ensure it happens exactly before the call.
async def gradcam(session_id: str) -> HeatmapResult:
raise NotImplementedError("Safety service not yet implemented")
async def rationale(session_id: str, redaction_hash: str | None = None) -> RationaleResult:
# Pre-egress check
await _verify_pre_egress(session_id, redaction_hash)
# 1. Fetch session context (simplified for stub)
context = {"grade": "moderate", "joint_site": "wrist"}
# 2. Construct prompt
prompt = f"Based on MOH guidelines, explain the synovitis grade {context['grade']} for {context['joint_site']}..."
# 3. Call LLM adapter
text = await llm_adapter.generate(prompt, session_id)
redis_client.set(f"consult_mode:{session_id}", "tier_3")
return RationaleResult(text=text)
async def circuit_break(session_id: str, flag: bool) -> None:
if flag:
logger.warning(f"Circuit breaker triggered for session {session_id}")
return None
async def socratic_chat(session_id: str, prompt: str, redaction_hash: str | None = None) -> ChatResponse:
# Pre-egress check
await _verify_pre_egress(session_id, redaction_hash)
# 1. Retrieve conversation history (stub)
history = []
# 2. Construct prompt
full_prompt = f"History: {history}\nUser: {prompt}\nAssistant: "
# 3. Call LLM adapter
response_text = await llm_adapter.generate(full_prompt, session_id)
# 4. BERT Referee check (stub)
is_grounded = bert_adapter.referee_check(response_text, [])
if not is_grounded:
response_text = "I'm sorry, I couldn't verify this answer against the guidelines."
# Post-egress: update consult mode
redis_client.set(f"consult_mode:{session_id}", "tier_3")
return ChatResponse(response=response_text)
async def drift_check(session_id: str) -> DriftCheckResult:
res = bert_adapter.drift_check("mock clinical text")
return DriftCheckResult(score=res.score, is_drifted=res.is_drifted)
async def rag_evidence(session_id: str) -> EvidenceList:
raise NotImplementedError("Safety service not yet implemented")
async def activations(session_id: str, params: dict) -> ActivationMeta:
raise NotImplementedError("Safety service not yet implemented")
async def upload_artifact(session_id: str, file: Any) -> AnnotationArtifact:
raise NotImplementedError("Safety service not yet implemented")
async def ground_truth(session_id: str, label: dict) -> None:
raise NotImplementedError("Safety service not yet implemented")
async def escalate(session_id: str, reason: str) -> EscalationTicket:
raise NotImplementedError("Safety service not yet implemented")
async def morphology(session_id: str, annotation: dict) -> None:
raise NotImplementedError("Safety service not yet implemented")
async def guardrail_check(session_id: str, prompt: str, score: float) -> GuardrailResult:
res = bert_adapter.guardrail_check(prompt)
return GuardrailResult(verdict=res.verdict, reason=res.reason)
async def submit_correction(session_id: str, correction: dict) -> CorrectionRecord:
raise NotImplementedError("Safety correction service not yet implemented")
async def chat_stream(session_id: str, prompt: str, redaction_hash: str | None = None) -> AsyncGenerator[str, None]:
# Pre-egress check
await _verify_pre_egress(session_id, redaction_hash)
async for chunk in llm_adapter.stream_generate(prompt, session_id):
res = bert_adapter.guardrail_check(chunk)
if res.verdict == "MITIGATE":
yield "[Content Filtered]"
return
yield chunk
# Post-egress
redis_client.set(f"consult_mode:{session_id}", "tier_3")

View File

@@ -0,0 +1,34 @@
from datetime import datetime
from data.spec.schemas import (
Session, SessionCreate, SessionDetail, SessionPatchReview,
FrameMetadata, PersistResult, ExportResult, ScrubResult,
)
from typing import Any
async def create_session(user_id: str, patient_id: str, case_id: str | None = None) -> Session:
raise NotImplementedError("Session service not yet implemented")
async def get_session(session_id: str) -> SessionDetail:
raise NotImplementedError("Session service not yet implemented")
async def add_frame(session_id: str, file: Any, frame_number: int | None = None) -> FrameMetadata:
raise NotImplementedError("Session service not yet implemented")
async def patch_review(session_id: str, review: dict) -> Session:
raise NotImplementedError("Session service not yet implemented")
async def persist(session_id: str, review: dict) -> PersistResult:
raise NotImplementedError("Session service not yet implemented")
async def export_pdf(session_id: str, params: dict) -> ExportResult:
raise NotImplementedError("Session service not yet implemented")
async def scrub_validate(session_id: str, metadata: dict) -> ScrubResult:
raise NotImplementedError("Session service not yet implemented")

View File

@@ -0,0 +1,9 @@
from data.spec.schemas import UserSettings, SettingsUpdate
async def get_settings(user_id: str) -> UserSettings:
raise NotImplementedError("Settings service not yet implemented")
async def update_settings(user_id: str, updates: dict) -> UserSettings:
raise NotImplementedError("Settings service not yet implemented")

View File

@@ -0,0 +1,6 @@
from data.spec.schemas import AnomalyRecord
from typing import Any
async def report_anomaly(session_id: str, data: dict) -> AnomalyRecord:
raise NotImplementedError("Telemetry service not yet implemented")

View File

@@ -0,0 +1,27 @@
"""Triton batching helpers — aligned with config.pbtxt max_batch_size: 8."""
from __future__ import annotations
import os
from collections.abc import Iterator, Sequence
from typing import TypeVar
T = TypeVar("T")
TRITON_MAX_BATCH_SIZE = int(os.getenv("TRITON_MAX_BATCH_SIZE", "8"))
def chunk_sequence(items: Sequence[T], batch_size: int | None = None) -> Iterator[list[T]]:
"""Split a sequence into chunks of at most ``batch_size`` (default: TRITON_MAX_BATCH_SIZE)."""
size = batch_size if batch_size is not None else TRITON_MAX_BATCH_SIZE
if size < 1:
raise ValueError(f"batch_size must be >= 1, got {size}")
for start in range(0, len(items), size):
yield list(items[start : start + size])
def batch_count(item_count: int, batch_size: int | None = None) -> int:
"""Number of Triton infer calls needed (e.g. 10 images -> 2 batches when size=8)."""
if item_count <= 0:
return 0
size = batch_size if batch_size is not None else TRITON_MAX_BATCH_SIZE
return (item_count + size - 1) // size

View File

@@ -0,0 +1,89 @@
import logging
import os
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.exceptions import RequestValidationError
from starlette.exceptions import HTTPException as StarletteHTTPException
from backend.api import (
auth_api,
patient_api,
session_api,
analysis_api,
safety_api,
notification_api,
settings_api,
ingestion_api,
telemetry_api,
)
from backend.routers import cloud_orchestrate, cloud_consult, agent_tools
logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI):
logger.info("Starting medical imaging AI platform API")
yield
logger.info("Shutting down medical imaging AI platform API")
app = FastAPI(
title="Medical Imaging & AI Safety Platform",
description="Clinical diagnostic imaging platform with AI safety analysis",
version="0.1.0",
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=os.getenv(
"CORS_ORIGINS",
"http://localhost:3000,http://localhost:5173,http://localhost:5174,http://localhost:4173",
).split(","),
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request, exc):
from fastapi.responses import JSONResponse
from data.spec.schemas import ErrorResponse
return JSONResponse(
status_code=422,
content=ErrorResponse(detail=str(exc), code="VALIDATION_ERROR").model_dump(),
)
@app.exception_handler(StarletteHTTPException)
async def http_exception_handler(request, exc):
from fastapi.responses import JSONResponse
from data.spec.schemas import ErrorResponse
content = ErrorResponse(detail=exc.detail, code="HTTP_ERROR").model_dump()
return JSONResponse(status_code=exc.status_code, content=content)
@app.exception_handler(NotImplementedError)
async def not_implemented_handler(request, exc):
from fastapi.responses import JSONResponse
from data.spec.schemas import ErrorResponse
content = ErrorResponse(detail=str(exc), code="NOT_IMPLEMENTED").model_dump()
return JSONResponse(status_code=501, content=content)
app.include_router(cloud_orchestrate.router)
app.include_router(cloud_consult.router)
app.include_router(agent_tools.router)
app.include_router(auth_api.router)
app.include_router(patient_api.router)
app.include_router(session_api.router)
app.include_router(analysis_api.router)
app.include_router(safety_api.router)
app.include_router(notification_api.router)
app.include_router(settings_api.router)
app.include_router(ingestion_api.router)
app.include_router(telemetry_api.router)

View File

@@ -0,0 +1,118 @@
import logging
from typing import Any
import httpx
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from pydantic import BaseModel, Field
from backend.services import agent_tools_service
from backend.services import embed_service
from data.spec.schemas import ErrorResponse
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1", tags=["agent-tools"])
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login", auto_error=False)
class ExaSearchRequest(BaseModel):
query: str = Field(..., max_length=512)
type: str = "auto"
numResults: int = Field(default=10, ge=1, le=10)
includeDomains: list[str] | None = None
excludeDomains: list[str] | None = None
session_id: str
class SupabaseQueryRequest(BaseModel):
rpc: str
args: dict[str, Any] = Field(default_factory=dict)
session_id: str
class EmbedRequest(BaseModel):
text: str = Field(..., max_length=8192)
task: str = "retrieval-query"
title: str | None = None
async def _verify_jwt_token_optional(token: str | None = Depends(oauth2_scheme)) -> str | None:
if not token:
return None
try:
from backend.api.auth_api import verify_jwt_token as _verify
return await _verify(token)
except HTTPException:
raise
except Exception:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token",
headers={"WWW-Authenticate": "Bearer"},
)
@router.post(
"/agent/tools/exa/search",
responses={
401: {"model": ErrorResponse},
422: {"model": ErrorResponse},
502: {"model": ErrorResponse},
},
)
async def exa_search(
body: ExaSearchRequest,
user_id: str | None = Depends(_verify_jwt_token_optional),
):
try:
return await agent_tools_service.exa_search(body.model_dump())
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
except RuntimeError as exc:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc))
except httpx.HTTPError as exc:
logger.exception("Exa upstream error")
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc))
@router.post(
"/embed",
responses={
401: {"model": ErrorResponse},
422: {"model": ErrorResponse},
},
)
async def embed(
body: EmbedRequest,
user_id: str | None = Depends(_verify_jwt_token_optional),
):
task = body.task if body.task in {"retrieval-query", "retrieval-document"} else "retrieval-query"
try:
return await embed_service.embed_text(body.text, task=task, title=body.title)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
@router.post(
"/agent/tools/supabase/query",
responses={
401: {"model": ErrorResponse},
422: {"model": ErrorResponse},
501: {"model": ErrorResponse},
502: {"model": ErrorResponse},
},
)
async def supabase_query(
body: SupabaseQueryRequest,
user_id: str | None = Depends(_verify_jwt_token_optional),
):
try:
return await agent_tools_service.supabase_query(body.model_dump())
except NotImplementedError as exc:
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
except RuntimeError as exc:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc))

View File

@@ -0,0 +1,81 @@
import logging
from fastapi import APIRouter, Depends, HTTPException, status, Body
from fastapi.responses import StreamingResponse
from fastapi.security import OAuth2PasswordBearer
from pydantic import BaseModel
from data.spec.schemas import ErrorResponse
from backend.services.cloud_llm_gateway import route_medgemma_request
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1", tags=["cloud-consult"])
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
class ConsultStreamRequest(BaseModel):
session_id: str
prompt: str
task_type: str = "clinical_deep_reasoning"
async def _verify_jwt_token(token: str = Depends(oauth2_scheme)) -> str:
try:
from backend.api.auth_api import verify_jwt_token as _verify
return await _verify(token)
except HTTPException:
raise
except Exception:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token",
headers={"WWW-Authenticate": "Bearer"},
)
@router.post(
"/cloud-consult",
responses={401: {"model": ErrorResponse}, 403: {"model": ErrorResponse}, 502: {"model": ErrorResponse}},
)
async def cloud_consult(
payload: dict,
user_id: str = Depends(_verify_jwt_token),
):
try:
return await route_medgemma_request(payload, user_id)
except NotImplementedError as exc:
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
except PermissionError as exc:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc))
@router.post(
"/cloud-consult/stream",
responses={401: {"model": ErrorResponse}, 403: {"model": ErrorResponse}},
)
async def cloud_consult_stream(
body: ConsultStreamRequest,
user_id: str = Depends(_verify_jwt_token),
):
async def generate():
async for chunk in route_medgemma_request(
{
"session_id": body.session_id,
"prompt": body.prompt,
"task_type": body.task_type,
"stream": True,
},
user_id,
):
yield chunk
return StreamingResponse(
generate(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
"Connection": "keep-alive",
},
)

View File

@@ -0,0 +1,44 @@
import logging
import httpx
from fastapi import APIRouter, Depends, HTTPException, status, Body
from fastapi.responses import StreamingResponse
from fastapi.security import OAuth2PasswordBearer
from data.spec.schemas import ErrorResponse
from backend.services.cloud_llm_gateway import route_gemini_request
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1", tags=["cloud-orchestrate"])
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
async def _verify_jwt_token(token: str = Depends(oauth2_scheme)) -> str:
try:
from backend.api.auth_api import verify_jwt_token as _verify
return await _verify(token)
except HTTPException:
raise
except Exception:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token",
headers={"WWW-Authenticate": "Bearer"},
)
@router.post(
"/cloud-orchestrate",
responses={401: {"model": ErrorResponse}, 403: {"model": ErrorResponse}, 502: {"model": ErrorResponse}},
)
async def cloud_orchestrate(
payload: dict,
user_id: str = Depends(_verify_jwt_token),
):
try:
return await route_gemini_request(payload, user_id)
except NotImplementedError as exc:
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
except PermissionError as exc:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc))

View File

@@ -0,0 +1,223 @@
"""HTTP routes for spec-compliant CV inference (CLAHE → angle → inflammation → seg)."""
from __future__ import annotations
import io
import json
import logging
import os
import requests
from fastapi import APIRouter, File, Form, HTTPException, UploadFile
from fastapi.responses import JSONResponse
from PIL import Image
from backend.implementation.adapters.triton_adapter import TritonAdapter
from backend.implementation.config import get_model_name, get_segmentation_model
from backend.implementation.postprocessing.calibration import CalibrationConfig, calibration_config_from_params
from backend.implementation.triton_batch import TRITON_MAX_BATCH_SIZE
from backend.services import cv_result_cache
from backend.services import triton_runtime_service as triton_runtime
from backend.services.cv_inference_service import CvBatchResult, CvInferenceOptions, run_batch, run_single
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/test", tags=["cv-inference"])
LEGACY_DEPRECATION_DETAIL = (
"This endpoint is deprecated. Use POST /api/test/analyze or POST /api/test/analyze/batch "
"for the spec-compliant CV pipeline."
)
def _is_image_upload(content_type: str | None, filename: str | None) -> bool:
if content_type and content_type.startswith("image/"):
return True
if content_type in (None, "", "application/octet-stream", "binary/octet-stream"):
name = (filename or "").lower()
return name.endswith((".png", ".jpg", ".jpeg", ".webp", ".bmp", ".gif"))
return False
def _parse_calibration_form(calibration_json: str | None) -> CalibrationConfig:
if not calibration_json:
return CalibrationConfig()
try:
data = json.loads(calibration_json)
except json.JSONDecodeError:
return CalibrationConfig()
if not isinstance(data, dict):
return CalibrationConfig()
return calibration_config_from_params({"calibration": data})
def _default_model_versions() -> dict[str, str] | None:
versions: dict[str, str] = {}
if angle := os.getenv("ANGLE_MODEL"):
versions["angle"] = angle
elif os.getenv("CV_USE_CONFIG_ANGLE_MODEL", "").lower() not in {"1", "true", "yes"}:
# Match legacy test proxy default for PoC clinical accuracy
versions["angle"] = "angle_classify_resnet50"
if inflam := os.getenv("INFLAMMATION_MODEL"):
versions["inflammation"] = inflam
if seg := os.getenv("SEGMENT_MODEL"):
versions["segmentation_sup"] = seg
versions["segmentation_post"] = seg
return versions or None
def _build_options(
calibration: CalibrationConfig,
*,
use_cache: bool = True,
) -> CvInferenceOptions:
return CvInferenceOptions(
calibration=calibration,
model_versions=_default_model_versions(),
use_cache=use_cache,
)
async def _load_upload_image(upload: UploadFile) -> Image.Image:
if not _is_image_upload(upload.content_type, upload.filename):
raise HTTPException(status_code=400, detail=f"Expected images, got {upload.filename}")
try:
raw = await upload.read()
return Image.open(io.BytesIO(raw)).convert("RGB")
except Exception as exc:
raise HTTPException(status_code=400, detail=f"Invalid image {upload.filename}: {exc}") from exc
def _triton_http_error_detail(exc: requests.HTTPError, operation: str) -> str:
status = exc.response.status_code if exc.response is not None else 503
detail = (
f"{operation} failed ({status}). "
"Modal server may be cold-starting — retry in a few seconds."
)
if exc.response is not None and exc.response.text:
detail = f"{detail} Server: {exc.response.text[:300]}"
return detail
@router.get("/health")
async def cv_inference_health():
angle_model = get_model_name("angle", _default_model_versions())
inflam_model = get_model_name("inflammation", _default_model_versions())
seg_model = get_segmentation_model("sup-up-long", _default_model_versions())
triton_endpoint = triton_runtime.get_triton_endpoint()
try:
adapter = TritonAdapter(endpoint_url=triton_endpoint, timeout=triton_runtime.TRITON_INFER_TIMEOUT)
angle_ready = await adapter.model_ready(angle_model)
inflam_ready = await adapter.model_ready(inflam_model)
seg_ready = await adapter.model_ready(seg_model)
status = "ok" if angle_ready and inflam_ready and seg_ready else "degraded"
cache = cv_result_cache.cache_stats()
return {
"status": status,
"service": "cv-inference",
"triton": triton_endpoint,
"angle_model": angle_model,
"angle_ready": angle_ready,
"inflammation_model": inflam_model,
"inflammation_ready": inflam_ready,
"segmentation_model": seg_model,
"segmentation_ready": seg_ready,
"triton_max_batch_size": TRITON_MAX_BATCH_SIZE,
"triton_infer_timeout": triton_runtime.TRITON_INFER_TIMEOUT,
"triton_infer_retries": triton_runtime.TRITON_INFER_RETRIES,
"triton_use_batch_infer": triton_runtime.TRITON_USE_BATCH_INFER,
**cache,
}
except Exception as exc:
logger.exception("CV inference health check failed")
return JSONResponse(
status_code=503,
content={"status": "error", "service": "cv-inference", "detail": str(exc), "triton": triton_endpoint},
)
@router.post("/analyze")
async def analyze_upload(
image: UploadFile = File(...),
calibration: str | None = Form(default=None),
):
"""Spec-compliant CV pipeline: CLAHE → angle → inflammation → conditional segmentation."""
image_pil = await _load_upload_image(image)
options = _build_options(_parse_calibration_form(calibration), use_cache=False)
try:
result = await run_single(image_pil, frame_id=None, options=options)
return JSONResponse(result)
except requests.HTTPError as exc:
logger.exception("Analyze pipeline failed (Triton HTTP)")
raise HTTPException(status_code=503, detail=_triton_http_error_detail(exc, "Triton analyze pipeline")) from exc
except (requests.ConnectionError, requests.Timeout) as exc:
logger.exception("Analyze pipeline failed (Triton network)")
raise HTTPException(
status_code=503,
detail=f"Triton unreachable: {exc}. Check TRITON_ENDPOINT and Modal deployment.",
) from exc
except Exception as exc:
logger.exception("Analyze pipeline failed")
raise HTTPException(status_code=500, detail=str(exc)) from exc
@router.post("/analyze/batch")
async def analyze_batch_upload(
images: list[UploadFile] = File(...),
frame_ids: str = Form(...),
calibration: str | None = Form(default=None),
):
"""Spec-compliant CV batch — one full pipeline per frame (angle-first, gated segmentation)."""
if not images:
raise HTTPException(status_code=400, detail="At least one image is required")
try:
id_list = json.loads(frame_ids)
except json.JSONDecodeError as exc:
raise HTTPException(status_code=400, detail="frame_ids must be a JSON array of strings") from exc
if not isinstance(id_list, list) or not all(isinstance(x, str) for x in id_list):
raise HTTPException(status_code=400, detail="frame_ids must be a JSON array of strings")
if len(id_list) != len(images):
raise HTTPException(status_code=400, detail="frame_ids length must match images count")
image_pils: list[Image.Image] = []
for upload in images:
image_pils.append(await _load_upload_image(upload))
options = _build_options(_parse_calibration_form(calibration))
try:
batch: CvBatchResult = await run_batch(image_pils, id_list, options=options)
cache = cv_result_cache.cache_stats()
return JSONResponse({
"success": True,
"image_count": len(batch.results),
"pipeline": "spec-cv-v1",
"triton_infer_calls": batch.triton_infer_calls,
"triton_infer_mode": batch.triton_infer_modes,
"pipeline_version": cache["pipeline_version"],
"results": batch.results,
})
except requests.HTTPError as exc:
logger.exception("Analyze batch pipeline failed (Triton HTTP)")
raise HTTPException(status_code=503, detail=_triton_http_error_detail(exc, "Triton analyze batch")) from exc
except (requests.ConnectionError, requests.Timeout) as exc:
logger.exception("Analyze batch pipeline failed (Triton network)")
raise HTTPException(
status_code=503,
detail=f"Triton unreachable: {exc}. Check TRITON_ENDPOINT and Modal deployment.",
) from exc
except Exception as exc:
logger.exception("Analyze batch pipeline failed")
raise HTTPException(status_code=500, detail=str(exc)) from exc
@router.post("/segment")
@router.post("/segment/batch")
@router.post("/angle")
@router.post("/angle/batch")
@router.post("/inflammation")
@router.post("/inflammation/batch")
async def legacy_cv_endpoints_deprecated():
raise HTTPException(status_code=410, detail=LEGACY_DEPRECATION_DETAIL)

View File

@@ -0,0 +1,184 @@
"""Agent tool BFF services — Exa search and Supabase knowledge queries."""
from __future__ import annotations
import hashlib
import json
import logging
import os
from typing import Any
import httpx
logger = logging.getLogger(__name__)
EXA_SEARCH_URL = "https://api.exa.ai/search"
EXA_API_KEY = os.getenv("EXA_API_KEY", "").strip()
SUPABASE_URL = os.getenv("SUPABASE_URL", "").rstrip("/")
SUPABASE_SERVICE_ROLE_KEY = os.getenv("SUPABASE_SERVICE_ROLE_KEY", "").strip()
ALLOWED_EXA_TYPES = frozenset(
{"auto", "fast", "instant", "deep-lite", "deep", "deep-reasoning"}
)
ALLOWED_SUPABASE_RPC = frozenset({"match_semantic_chunks", "get_corpus_citation"})
def _audit(event: str, session_id: str, payload: dict[str, Any]) -> None:
query_hash = payload.get("query_hash")
logger.info(
"[AUDIT] event=%s session=%s query_hash=%s payload_keys=%s",
event,
session_id,
query_hash,
list(payload.keys()),
)
def _query_hash(text: str) -> str:
return hashlib.sha256(text.encode("utf-8")).hexdigest()[:16]
async def exa_search(payload: dict[str, Any]) -> dict[str, Any]:
session_id = str(payload.get("session_id", ""))
query = str(payload.get("query", "")).strip()
if not query:
raise ValueError("query is required")
if len(query) > 512:
raise ValueError("query exceeds 512 characters")
search_type = str(payload.get("type", "auto"))
if search_type not in ALLOWED_EXA_TYPES:
raise ValueError(f"unsupported Exa type: {search_type}")
num_results = int(payload.get("numResults", 10))
num_results = max(1, min(num_results, 10))
if not EXA_API_KEY:
raise RuntimeError("EXA_API_KEY is not configured on the backend")
body: dict[str, Any] = {
"query": query,
"type": search_type,
"numResults": num_results,
"contents": {"highlights": True},
}
include_domains = payload.get("includeDomains")
exclude_domains = payload.get("excludeDomains")
if include_domains:
body["includeDomains"] = include_domains
if exclude_domains:
body["excludeDomains"] = exclude_domains
_audit(
"exa_search",
session_id,
{"query_hash": _query_hash(query), "type": search_type, "numResults": num_results},
)
headers = {"x-api-key": EXA_API_KEY, "Content-Type": "application/json"}
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(EXA_SEARCH_URL, headers=headers, json=body)
response.raise_for_status()
data = response.json()
hits = []
for index, row in enumerate(data.get("results", [])):
hits.append(
{
"id": row.get("id") or f"exa-{index}",
"url": row.get("url") or "",
"title": row.get("title") or row.get("url") or "Untitled",
"highlights": row.get("highlights") or [],
"publishedDate": row.get("publishedDate"),
"score": row.get("score"),
}
)
return {"hits": hits, "requestId": data.get("requestId")}
async def supabase_query(payload: dict[str, Any]) -> dict[str, Any]:
session_id = str(payload.get("session_id", ""))
rpc = str(payload.get("rpc", ""))
if rpc not in ALLOWED_SUPABASE_RPC:
raise ValueError(f"rpc not allowlisted: {rpc}")
if not SUPABASE_URL or not SUPABASE_SERVICE_ROLE_KEY:
raise RuntimeError("SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY must be configured")
args = payload.get("args") or {}
if rpc == "match_semantic_chunks":
query_text = str(args.get("query_text", "")).strip()
if not query_text:
raise ValueError("args.query_text is required for match_semantic_chunks")
_audit(
"supabase_query",
session_id,
{"query_hash": _query_hash(query_text), "rpc": rpc},
)
embedding = await _embed_query_text(query_text)
rpc_body = {
"query_embedding": embedding,
"match_count": int(args.get("match_count", 5)),
"filter_book_ids": args.get("filter_book_ids"),
"filter_edition_ids": args.get("filter_edition_ids"),
}
else:
_audit("supabase_query", session_id, {"rpc": rpc})
rpc_body = args
url = f"{SUPABASE_URL}/rest/v1/rpc/{rpc}"
headers = {
"apikey": SUPABASE_SERVICE_ROLE_KEY,
"Authorization": f"Bearer {SUPABASE_SERVICE_ROLE_KEY}",
"Content-Type": "application/json",
"Accept-Profile": "knowledge",
"Content-Profile": "knowledge",
}
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(url, headers=headers, json=rpc_body)
if response.status_code >= 400:
detail = response.text
try:
detail = json.dumps(response.json())
except Exception:
pass
raise RuntimeError(f"Supabase RPC failed ({response.status_code}): {detail}")
rows = response.json()
normalized_rows = []
for row in rows if isinstance(rows, list) else []:
normalized_rows.append(
{
"chunk_id": str(row.get("chunk_id", "")),
"content": row.get("content") or "",
"book_id": row.get("book_id") or "",
"parent_title": row.get("parent_title"),
"page_start": row.get("page_start"),
"page_end": row.get("page_end"),
"similarity": row.get("similarity"),
}
)
return {"rpc": rpc, "rows": normalized_rows}
async def _embed_query_text(query_text: str) -> list[float]:
"""Compute 768-d EmbeddingGemma vector for Supabase RPC.
PoC: returns NotImplemented until Triton/on-prem embedder is wired.
Set EMBED_QUERY_MOCK=1 to return a zero vector for integration testing only.
"""
if os.getenv("EMBED_QUERY_MOCK") == "1":
logger.warning("Using EMBED_QUERY_MOCK zero vector — not for production search quality")
return [0.0] * 768
raise NotImplementedError(
"Server-side query embedding is not wired yet. "
"Configure Triton EmbeddingGemma or set EMBED_QUERY_MOCK=1 for integration tests."
)

View File

@@ -0,0 +1,203 @@
import logging
import httpx
import json
from typing import AsyncGenerator
from datetime import datetime
from backend.implementation.adapters.redis_adapter import get_redis_client
from backend.implementation.adapters.llm_adapter import get_llm_adapter, AuditCallbackHandler
from backend.implementation.config import (
MODAL_MEDGEMMA_ENDPOINT,
VERTEX_AI_GEMINI_ENDPOINT,
GCP_ACCESS_TOKEN,
PROJECT_ID,
LOCATION,
)
logger = logging.getLogger(__name__)
redis_client = get_redis_client()
llm_adapter = get_llm_adapter()
def _set_consult_mode(session_id: str, mode: str):
redis_client.set(f"consult_mode:{session_id}", mode, ex=7200)
async def _verify_consent(session_id: str) -> bool:
consent_key = f"consent:{session_id}"
return bool(await asyncio.to_thread(redis_client.exists, consent_key))
async def _verify_session_ownership(session_id: str, user_id: str) -> bool:
owner_key = f"session_owner:{session_id}"
owner_id = await asyncio.to_thread(redis_client.get, owner_key)
if not owner_id:
return False
return owner_id == user_id
def _audit_event(session_id: str, event_type: str, payload: dict):
key = f"audit:{session_id}:{event_type}"
redis_client.set(key, json.dumps(payload), ex=86400)
logger.info(
"[AUDIT] event=%s session=%s payload=%s",
event_type,
session_id,
payload,
)
async def route_gemini_request(payload: dict, user_id: str) -> dict:
session_id = payload.get("session_id", "")
task_type = payload.get("task_type", "orchestration")
prompt = payload.get("prompt", "")
redaction_hash = payload.get("redaction_hash")
if not await _verify_consent(session_id):
raise PermissionError("User consent for cloud LLM egress is required.")
if not await _verify_session_ownership(session_id, user_id):
raise PermissionError("You do not own this session.")
_audit_event(session_id, "egress_consent_gemini", {
"user_id": user_id,
"task_type": task_type,
"redaction_hash": redaction_hash,
"ts": datetime.utcnow().isoformat(),
})
headers = {
"Authorization": f"Bearer {GCP_ACCESS_TOKEN}",
"Content-Type": "application/json",
}
vertex_payload = {
"contents": [{"parts": [{"text": prompt}]}],
"generationConfig": {
"temperature": 0.2,
"topP": 0.8,
"topK": 40,
"maxOutputTokens": 1024,
},
}
async with httpx.AsyncClient(timeout=22.0) as client:
response = await client.post(
VERTEX_AI_GEMINI_ENDPOINT,
headers=headers,
json=vertex_payload,
)
response.raise_for_status()
result = response.json()
_audit_event(session_id, "egress_response_gemini", {
"task_type": task_type,
"status": "success",
"ts": datetime.utcnow().isoformat(),
})
_set_consult_mode(session_id, "tier_2")
return {
"text": result.get("candidates", [{}])[0].get("content", {}).get("parts", [{}])[0].get("text", ""),
"tier": "gemini",
"task_type": task_type,
}
async def route_medgemma_request(payload: dict, user_id: str) -> dict | AsyncGenerator[str, None]:
session_id = payload.get("session_id", "")
task_type = payload.get("task_type", "clinical_deep_reasoning")
prompt = payload.get("prompt", "")
stream = payload.get("stream", False)
redaction_hash = payload.get("redaction_hash")
if not await _verify_consent(session_id):
raise PermissionError("User consent for cloud LLM egress is required.")
if not await _verify_session_ownership(session_id, user_id):
raise PermissionError("You do not own this session.")
_audit_event(session_id, "egress_consent_medgemma", {
"user_id": user_id,
"task_type": task_type,
"redaction_hash": redaction_hash,
"ts": datetime.utcnow().isoformat(),
})
modal_payload = {
"model": payload.get("model", "medgemma:4b"),
"prompt": prompt,
"stream": stream,
"options": {
"temperature": 0.1,
"top_p": 0.8,
"top_k": 40,
"num_predict": 2048,
},
}
headers = {
"Content-Type": "application/json",
}
if stream:
return _stream_medgemma(session_id, modal_payload, headers, task_type)
async with httpx.AsyncClient(timeout=22.0) as client:
response = await client.post(
f"{MODAL_MEDGEMMA_ENDPOINT}/api/generate",
headers=headers,
json=modal_payload,
)
response.raise_for_status()
result = response.json()
_audit_event(session_id, "egress_response_medgemma", {
"task_type": task_type,
"status": "success",
"ts": datetime.utcnow().isoformat(),
})
_set_consult_mode(session_id, "tier_3")
return {
"text": result.get("response", ""),
"tier": "medgemma",
"task_type": task_type,
}
async def _stream_medgemma(
session_id: str,
modal_payload: dict,
headers: dict,
task_type: str,
) -> AsyncGenerator[str, None]:
async with httpx.AsyncClient(timeout=22.0) as client:
async with client.stream(
"POST",
f"{MODAL_MEDGEMMA_ENDPOINT}/api/generate",
headers=headers,
json=modal_payload,
) as response:
response.raise_for_status()
async for line in response.aiter_lines():
if not line.startswith("data:"):
continue
data_str = line[len("data:"):].strip()
if not data_str:
continue
try:
data = json.loads(data_str)
chunk = data.get("response", "")
if chunk:
yield chunk
except json.JSONDecodeError:
continue
_audit_event(session_id, "egress_response_medgemma_stream", {
"task_type": task_type,
"status": "success",
"ts": datetime.utcnow().isoformat(),
})
_set_consult_mode(session_id, "tier_3")

View File

@@ -0,0 +1,336 @@
"""Spec-compliant CV inference orchestration — Sprint 12 architecture §7."""
from __future__ import annotations
import asyncio
import base64
import io
import logging
from dataclasses import dataclass
from typing import Any
import cv2
import numpy as np
from PIL import Image
from backend.implementation.config import get_angle_type, get_model_name, get_segmentation_model
from backend.implementation.pipeline.cv_spec_pipeline import (
BRANCH_ANGLE_CLASSES,
build_segmentation_skipped,
build_severity_zero,
)
from backend.implementation.postprocessing.calibration import (
CalibrationConfig,
calibration_config_from_params,
interpret_angle_logits,
interpret_inflammation_logits,
)
from backend.implementation.postprocessing.measurement import calculate_thickness
from backend.implementation.postprocessing.overlay import COLOR_MAP_POST, COLOR_MAP_SUP, create_overlay
from backend.implementation.postprocessing.severity import calculate_severity
from backend.implementation.preprocessing.clahe import apply_clahe
from backend.services import cv_result_cache
from backend.services import triton_runtime_service as triton_runtime
logger = logging.getLogger(__name__)
SEGMENT_CLASSES_SUP = {
0: "background",
1: "effusion",
2: "fat",
3: "fat-pat",
4: "femur",
5: "synovium",
6: "tendon",
}
SEGMENT_CLASSES_POST = {
0: "background",
1: "fat",
2: "tendon",
3: "muscle",
4: "femur",
5: "artery",
6: "baker's cyst",
}
_triton_pipeline_lock = asyncio.Lock()
@dataclass
class CvInferenceOptions:
calibration: CalibrationConfig | None = None
model_versions: dict[str, str] | None = None
use_cache: bool = True
@dataclass
class CvBatchResult:
results: list[dict[str, Any]]
triton_infer_calls: int
triton_infer_modes: list[str]
def _encode_image_to_data_url(image_pil: Image.Image) -> str:
buffered = io.BytesIO()
image_pil.save(buffered, format="PNG")
encoded = base64.b64encode(buffered.getvalue()).decode()
return f"data:image/png;base64,{encoded}"
def _build_inflammation_payload(logits_row: np.ndarray, config: CalibrationConfig) -> dict:
interpreted = interpret_inflammation_logits(logits_row, config)
return {
"detected": interpreted["detected"],
"confidence": interpreted["confidence"],
"calibration": interpreted["calibration"],
}
def _logits_to_masks(
logits: np.ndarray,
angle_class: str,
image_size: tuple[int, int],
batch_idx: int = 0,
) -> dict[str, np.ndarray]:
logits_arr = np.asarray(logits, dtype=np.float32)
while logits_arr.ndim < 4:
logits_arr = np.expand_dims(logits_arr, 0)
if logits_arr.ndim != 4:
raise ValueError(f"Unexpected segmentation logits shape: {logits_arr.shape}")
if batch_idx >= logits_arr.shape[0]:
raise IndexError(f"batch_idx {batch_idx} out of range for shape {logits_arr.shape}")
preds_lowres = logits_arr.argmax(axis=1)[batch_idx]
width, height = image_size
preds = cv2.resize(
preds_lowres.astype(np.uint8),
(width, height),
interpolation=cv2.INTER_NEAREST,
)
angle_type = get_angle_type(angle_class)
class_map = SEGMENT_CLASSES_SUP if angle_type == "sup" else SEGMENT_CLASSES_POST
masks: dict[str, np.ndarray] = {}
for class_id, class_name in class_map.items():
masks[class_name] = (preds == class_id).astype(np.uint8)
return masks
def _build_color_legend(classes_detected: list[str], angle_type: str) -> dict[str, list[int]]:
color_map = COLOR_MAP_SUP if angle_type == "sup" else COLOR_MAP_POST
legend: dict[str, list[int]] = {}
for class_name in classes_detected:
if class_name in color_map:
legend[class_name] = color_map[class_name]
return legend
def _build_segmentation_result(
image_pil: Image.Image,
logits: np.ndarray,
angle_class: str,
seg_model: str,
*,
frame_id: str | None,
inflammation: dict,
angle_payload: dict,
enhanced_data_url: str,
inflam_model: str,
angle_model: str,
) -> dict:
angle_type = get_angle_type(angle_class)
masks = _logits_to_masks(logits, angle_class, image_pil.size)
measurement = calculate_thickness(masks, image_pil.size)
severity = calculate_severity(masks, image_pil.size)
overlay = create_overlay(image_pil, masks, measurement, angle_type)
classes_detected = [name for name, mask in masks.items() if np.sum(mask) > 0 and name != "background"]
color_legend = _build_color_legend(classes_detected, angle_type)
result: dict[str, Any] = {
"success": True,
"angle": angle_payload,
"inflammation": inflammation,
"measurement": measurement,
"severity": severity,
"segmentation": {
"performed": True,
"angle_type": angle_type,
"classes_detected": classes_detected,
"color_legend": color_legend,
},
"images": {
"enhanced": enhanced_data_url,
"segmented": _encode_image_to_data_url(overlay),
},
"models_used": {
"angle": angle_model,
"inflammation": inflam_model,
"segmentation": seg_model,
},
}
if frame_id is not None:
result["frame_id"] = frame_id
return result
async def _run_spec_cv_pipeline_single(
image_pil: Image.Image,
*,
frame_id: str | None,
options: CvInferenceOptions,
) -> tuple[dict[str, Any], str, int]:
"""
Per-image spec path:
CLAHE → angle → (post-trans|sup-up-long only) inflammation → conditional segmentation.
"""
config = options.calibration or CalibrationConfig()
model_versions = options.model_versions
angle_model = get_model_name("angle", model_versions)
inflam_model = get_model_name("inflammation", model_versions)
triton_calls = 0
modes: list[str] = []
enhanced_pil = apply_clahe(image_pil)
enhanced_data_url = _encode_image_to_data_url(enhanced_pil)
angle_logits, angle_mode, angle_calls = await triton_runtime.infer_angle_logits_single(
image_pil, angle_model
)
modes.append(angle_mode)
triton_calls += angle_calls
angle_interpreted = interpret_angle_logits(angle_logits, config)
angle_payload = {
"class": angle_interpreted["class"],
"confidence": angle_interpreted["confidence"],
"calibration": angle_interpreted["calibration"],
}
result: dict[str, Any] = {
"success": True,
"angle": angle_payload,
"models_used": {"angle": angle_model},
"images": {"enhanced": enhanced_data_url},
}
if frame_id is not None:
result["frame_id"] = frame_id
angle_class = angle_interpreted["class"]
if angle_class not in BRANCH_ANGLE_CLASSES:
result["segmentation"] = build_segmentation_skipped("angle_only")
result["severity"] = build_severity_zero("angle_only")
return result, "+".join(modes), triton_calls
inflam_logits, inflam_mode, inflam_calls = await triton_runtime.infer_inflammation_logits_single(
image_pil, inflam_model
)
modes.append(inflam_mode)
triton_calls += inflam_calls
inflammation = _build_inflammation_payload(inflam_logits, config)
result["inflammation"] = inflammation
result["models_used"]["inflammation"] = inflam_model
if not inflammation.get("detected"):
result["segmentation"] = build_segmentation_skipped("no_inflammation")
result["severity"] = build_severity_zero("no_inflammation")
return result, "+".join(modes), triton_calls
seg_model = get_segmentation_model(angle_class, model_versions)
seg_logits, seg_mode, seg_calls = await triton_runtime.infer_segmentation_logits_single(
image_pil, seg_model
)
modes.append(seg_mode)
triton_calls += seg_calls
seg_result = _build_segmentation_result(
image_pil,
seg_logits,
angle_class,
seg_model,
frame_id=frame_id,
inflammation=inflammation,
angle_payload=angle_payload,
enhanced_data_url=enhanced_data_url,
inflam_model=inflam_model,
angle_model=angle_model,
)
return seg_result, "+".join(modes), triton_calls
async def run_single(
image: Image.Image,
*,
frame_id: str | None = None,
options: CvInferenceOptions | None = None,
) -> dict[str, Any]:
opts = options or CvInferenceOptions()
result, _, _ = await _run_spec_cv_pipeline_single(image, frame_id=frame_id, options=opts)
return result
async def _run_batch_uncached(
images: list[Image.Image],
frame_ids: list[str],
options: CvInferenceOptions,
) -> CvBatchResult:
if not images:
return CvBatchResult(results=[], triton_infer_calls=0, triton_infer_modes=[])
if len(frame_ids) != len(images):
raise ValueError("frame_ids length must match images length")
async with _triton_pipeline_lock:
results: list[dict[str, Any]] = []
infer_modes: list[str] = []
triton_call_count = 0
for image_pil, fid in zip(images, frame_ids, strict=True):
item, mode, calls = await _run_spec_cv_pipeline_single(
image_pil,
frame_id=fid,
options=options,
)
results.append(item)
infer_modes.append(mode)
triton_call_count += calls
return CvBatchResult(
results=results,
triton_infer_calls=triton_call_count,
triton_infer_modes=infer_modes,
)
async def run_batch(
images: list[Image.Image],
frame_ids: list[str],
options: CvInferenceOptions | None = None,
) -> CvBatchResult:
opts = options or CvInferenceOptions()
if not opts.use_cache or not images:
return await _run_batch_uncached(images, frame_ids, opts)
image_hashes = []
for image in images:
buf = io.BytesIO()
image.save(buf, format="PNG")
image_hashes.append(cv_result_cache.hash_image_bytes(buf.getvalue()))
cache_key = cv_result_cache.analyze_batch_cache_key(frame_ids, image_hashes)
async def compute():
return await _run_batch_uncached(images, frame_ids, opts)
return await cv_result_cache.with_result_cache(cache_key, compute, enabled=opts.use_cache)
def options_from_params(params: dict[str, Any] | None) -> CvInferenceOptions:
params = params or {}
calibration = calibration_config_from_params(params)
model_versions = params.get("model_versions")
use_cache = params.get("use_cache", True)
return CvInferenceOptions(
calibration=calibration,
model_versions=model_versions,
use_cache=use_cache,
)

View File

@@ -0,0 +1,72 @@
"""In-memory CV inference result cache with in-flight request coalescing."""
from __future__ import annotations
import asyncio
import hashlib
import logging
import os
import time
from typing import Any, Awaitable, Callable, TypeVar
logger = logging.getLogger(__name__)
CV_PIPELINE_VERSION = os.getenv("CV_PIPELINE_VERSION", "poc-v2-spec-cv-seg-norm")
CV_RESULT_CACHE_TTL_S = float(os.getenv("CV_RESULT_CACHE_TTL_S", "3600"))
CV_CACHE_ENABLED = os.getenv("CV_CACHE_ENABLED", "true").lower() in {"1", "true", "yes"}
T = TypeVar("T")
_result_cache: dict[str, tuple[float, Any]] = {}
_inflight: dict[str, asyncio.Future] = {}
def hash_image_bytes(raw: bytes) -> str:
return hashlib.sha256(raw).hexdigest()
def analyze_batch_cache_key(frame_ids: list[str], image_hashes: list[str]) -> str:
pairs = sorted(zip(frame_ids, image_hashes, strict=True), key=lambda item: item[0])
payload = "|".join(f"{frame_id}:{digest}" for frame_id, digest in pairs)
return f"analyze|{CV_PIPELINE_VERSION}|{payload}"
async def with_result_cache(cache_key: str, compute: Callable[[], Awaitable[T]], *, enabled: bool = True) -> T:
if not enabled or not CV_CACHE_ENABLED:
return await compute()
now = time.monotonic()
cached = _result_cache.get(cache_key)
if cached and cached[0] > now:
logger.info("CV cache HIT: %s", cache_key[:96])
return cached[1]
inflight = _inflight.get(cache_key)
if inflight is not None:
logger.info("CV in-flight coalesce: %s", cache_key[:96])
return await inflight
loop = asyncio.get_running_loop()
fut: asyncio.Future = loop.create_future()
_inflight[cache_key] = fut
try:
result = await compute()
_result_cache[cache_key] = (time.monotonic() + CV_RESULT_CACHE_TTL_S, result)
fut.set_result(result)
logger.info("CV cache STORE: %s", cache_key[:96])
return result
except Exception as exc:
if not fut.done():
fut.set_exception(exc)
raise
finally:
_inflight.pop(cache_key, None)
def cache_stats() -> dict[str, int | bool | float | str]:
return {
"cache_enabled": CV_CACHE_ENABLED,
"pipeline_version": CV_PIPELINE_VERSION,
"cache_ttl_s": CV_RESULT_CACHE_TTL_S,
"cache_entries": len(_result_cache),
"inflight_batches": len(_inflight),
}

View File

@@ -0,0 +1,88 @@
"""EmbeddingGemma-compatible embed endpoint for episodic memory and RAG queries."""
from __future__ import annotations
import hashlib
import logging
import math
import os
from typing import Literal
logger = logging.getLogger(__name__)
EMBEDDING_DIMENSIONS = 768
def _format_embed_input(text: str, task: str, title: str | None = None) -> str:
if task == "retrieval-document":
title_value = title.strip() if title and title.strip() else "none"
return f"title: {title_value} | text: {text}"
return f"task: search result | query: {text}"
def deterministic_embed(text: str, dimensions: int = EMBEDDING_DIMENSIONS) -> list[float]:
"""PoC fallback — matches gemma4_e2b deterministicEmbed (SHA-256 + char histogram)."""
vec = [0.0] * dimensions
normalized = text.lower().strip()
if not normalized:
return vec
for index, char in enumerate(normalized):
code = ord(char)
bucket = (code * (index + 17)) % dimensions
vec[bucket] += 1.0
digest = hashlib.sha256(normalized.encode("utf-8")).digest()
for i in range(dimensions):
vec[i] += digest[i % len(digest)] / 255.0
norm = math.sqrt(sum(value * value for value in vec))
if norm == 0:
return vec
return [value / norm for value in vec]
def _try_gemma_embedder(formatted: str) -> list[float] | None:
"""Optional real EmbeddingGemma via knowledge ingestion pipeline."""
if os.getenv("EMBED_QUERY_MOCK") == "1":
return None
try:
from knowledge.implementation.ingestion.embedding import GemmaEmbedder, EmbedTask
embedder = GemmaEmbedder()
task = (
EmbedTask.RETRIEVAL_DOCUMENT
if formatted.startswith("title:")
else EmbedTask.RETRIEVAL_QUERY
)
raw = formatted
title = None
if task == EmbedTask.RETRIEVAL_DOCUMENT and "| text: " in formatted:
prefix, body = formatted.split("| text: ", 1)
title = prefix.replace("title:", "").strip()
raw = body
vector = embedder.embed(raw, task, title=title if title and title != "none" else None)
return vector.tolist()
except Exception as exc:
logger.debug("GemmaEmbedder unavailable: %s", exc)
return None
async def embed_text(
text: str,
task: Literal["retrieval-query", "retrieval-document"] = "retrieval-query",
title: str | None = None,
) -> dict[str, object]:
formatted = _format_embed_input(text, task, title)
vector = _try_gemma_embedder(formatted)
if vector is not None:
return {"vector": vector, "model": "embeddinggemma-300m", "source": "gemma"}
if os.getenv("EMBED_QUERY_MOCK") == "1":
logger.warning("Using deterministic embed fallback (EMBED_QUERY_MOCK or no embedder)")
return {
"vector": deterministic_embed(formatted),
"model": "embeddinggemma-300m-deterministic",
"source": "deterministic",
}

View File

@@ -0,0 +1,384 @@
"""Triton inference runtime — lock, retry, batching with batched→sequential fallback."""
from __future__ import annotations
import asyncio
import logging
import os
import time
from typing import Literal
import numpy as np
import requests
from PIL import Image
from backend.implementation.adapters.triton_adapter import TritonAdapter
from backend.implementation.preprocessing.tensor_prep import (
prepare_angle_tensor,
prepare_inflammation_tensor,
prepare_segmentation_tensor,
)
from backend.implementation.triton_batch import TRITON_MAX_BATCH_SIZE, chunk_sequence
logger = logging.getLogger(__name__)
INPUT_NAME = "input_image"
OUTPUT_NAME = "logits"
TRITON_INFER_TIMEOUT = float(os.getenv("TRITON_INFER_TIMEOUT", "120"))
TRITON_INFER_RETRIES = int(os.getenv("TRITON_INFER_RETRIES", "5"))
TRITON_RETRY_BASE_S = float(os.getenv("TRITON_RETRY_BASE_S", "4"))
TRITON_USE_BATCH_INFER = os.getenv("TRITON_USE_BATCH_INFER", "true").lower()
RETRYABLE_STATUS = {429, 502, 503, 504}
_triton_infer_lock = asyncio.Lock()
_adapter: TritonAdapter | None = None
_adapter_endpoint: str | None = None
def get_triton_endpoint() -> str:
return os.getenv("TRITON_ENDPOINT", "http://localhost:8000").rstrip("/")
def _get_adapter() -> TritonAdapter:
global _adapter, _adapter_endpoint
endpoint = get_triton_endpoint()
if _adapter is None or _adapter_endpoint != endpoint:
_adapter = TritonAdapter(endpoint_url=endpoint, timeout=TRITON_INFER_TIMEOUT)
_adapter_endpoint = endpoint
return _adapter
def _retry_backoff_seconds(attempt: int) -> float:
return TRITON_RETRY_BASE_S * (2 ** (attempt - 1))
def _should_try_batched_infer(image_count: int) -> bool:
if image_count <= 1:
return True
if TRITON_USE_BATCH_INFER in {"1", "true", "yes"}:
return True
if TRITON_USE_BATCH_INFER in {"0", "false", "no"}:
return False
return True
def _logits_array_from_adapter(result: dict) -> np.ndarray:
logits = result.get(OUTPUT_NAME, [])
if not logits:
raise ValueError(f"Empty {OUTPUT_NAME} in Triton response")
return np.asarray(logits, dtype=np.float32)
def _infer_sync_with_retry(
model_name: str,
batch_tensor: np.ndarray,
*,
operation: str,
max_retries: int | None = None,
) -> np.ndarray:
adapter = _get_adapter()
attempts = max_retries if max_retries is not None else TRITON_INFER_RETRIES
last_error: Exception | None = None
inputs = {
INPUT_NAME: {
"data": batch_tensor,
"shape": list(batch_tensor.shape),
"datatype": "FP32",
}
}
for attempt in range(1, attempts + 1):
try:
result = adapter._infer_sync(model_name, inputs, outputs=[OUTPUT_NAME])
if attempt > 1:
logger.info("%s succeeded on attempt %s/%s", operation, attempt, attempts)
return _logits_array_from_adapter(result)
except requests.HTTPError as exc:
status = exc.response.status_code if exc.response is not None else None
if status not in RETRYABLE_STATUS:
raise
last_error = exc
except (requests.ConnectionError, requests.Timeout) as exc:
last_error = exc
if attempt >= attempts:
logger.error("%s failed on final attempt %s/%s: %s", operation, attempt, attempts, last_error)
break
wait_s = _retry_backoff_seconds(attempt)
logger.warning(
"%s attempt %s/%s failed (%s); exponential retry in %.1fs",
operation,
attempt,
attempts,
last_error,
wait_s,
)
time.sleep(wait_s)
assert last_error is not None
raise last_error
def _stack_angle_tensors(images: list[Image.Image]) -> np.ndarray:
if not images:
raise ValueError("images must not be empty")
if len(images) > TRITON_MAX_BATCH_SIZE:
raise ValueError(f"At most {TRITON_MAX_BATCH_SIZE} images per Triton batch, got {len(images)}")
tensors = [prepare_angle_tensor(img) for img in images]
return np.concatenate(tensors, axis=0).astype(np.float32)
def _stack_inflammation_tensors(images: list[Image.Image]) -> np.ndarray:
if not images:
raise ValueError("images must not be empty")
if len(images) > TRITON_MAX_BATCH_SIZE:
raise ValueError(f"At most {TRITON_MAX_BATCH_SIZE} images per Triton batch, got {len(images)}")
tensors = [prepare_inflammation_tensor(img) for img in images]
return np.concatenate(tensors, axis=0).astype(np.float32)
def _stack_segmentation_tensors(images: list[Image.Image]) -> np.ndarray:
if not images:
raise ValueError("images must not be empty")
if len(images) > TRITON_MAX_BATCH_SIZE:
raise ValueError(f"At most {TRITON_MAX_BATCH_SIZE} images per Triton batch, got {len(images)}")
tensors = [prepare_segmentation_tensor(img) for img in images]
return np.concatenate(tensors, axis=0).astype(np.float32)
def _normalize_batched_angle_logits(logits: np.ndarray, expected_count: int) -> np.ndarray:
if logits.ndim == 1:
logits = np.expand_dims(logits, axis=0)
if logits.ndim != 2:
raise ValueError(f"Unexpected batched angle logits shape: {logits.shape}")
if logits.shape[0] != expected_count:
raise ValueError(
f"Triton returned batch {logits.shape[0]} but expected {expected_count} angle rows",
)
return logits
def _normalize_batched_segmentation_logits(logits: np.ndarray, expected_count: int) -> np.ndarray:
if logits.ndim == 3:
logits = np.expand_dims(logits, axis=0)
if logits.ndim != 4:
raise ValueError(f"Unexpected batched segmentation logits shape: {logits.shape}")
if logits.shape[0] != expected_count:
raise ValueError(
f"Triton returned batch {logits.shape[0]} but expected {expected_count} images",
)
return logits
def _infer_angle_logits_batch(
images: list[Image.Image],
model_name: str,
*,
max_retries: int | None = None,
) -> np.ndarray:
batch_tensor = _stack_angle_tensors(images)
logits = _infer_sync_with_retry(
model_name,
batch_tensor,
operation=f"Triton angle batch×{len(images)} ({model_name})",
max_retries=max_retries,
)
return _normalize_batched_angle_logits(logits, len(images))
def _infer_angle_logits_sequential(images: list[Image.Image], model_name: str) -> np.ndarray:
rows: list[np.ndarray] = []
for index, image in enumerate(images, start=1):
logits = _infer_angle_logits_batch([image], model_name)
row = logits[0] if logits.ndim == 2 else logits
rows.append(row)
logger.info("Triton sequential angle infer %s/%s complete for %s", index, len(images), model_name)
return np.stack(rows, axis=0)
def _infer_angle_logits_chunk(images: list[Image.Image], model_name: str) -> tuple[np.ndarray, Literal["batched", "sequential"]]:
if not _should_try_batched_infer(len(images)):
return _infer_angle_logits_sequential(images, model_name), "sequential"
try:
return _infer_angle_logits_batch(images, model_name, max_retries=1), "batched"
except Exception as exc:
logger.warning(
"Batched angle infer×%s failed (%s); falling back to sequential single-image calls",
len(images),
exc,
)
return _infer_angle_logits_sequential(images, model_name), "sequential"
def _infer_inflammation_logits_batch(
images: list[Image.Image],
model_name: str,
*,
max_retries: int | None = None,
) -> np.ndarray:
batch_tensor = _stack_inflammation_tensors(images)
logits = _infer_sync_with_retry(
model_name,
batch_tensor,
operation=f"Triton inflammation batch×{len(images)} ({model_name})",
max_retries=max_retries,
)
return _normalize_batched_angle_logits(logits, len(images))
def _infer_inflammation_logits_sequential(images: list[Image.Image], model_name: str) -> np.ndarray:
rows: list[np.ndarray] = []
for index, image in enumerate(images, start=1):
logits = _infer_inflammation_logits_batch([image], model_name)
row = logits[0] if logits.ndim == 2 else logits
rows.append(row)
logger.info(
"Triton sequential inflammation infer %s/%s complete for %s",
index,
len(images),
model_name,
)
return np.stack(rows, axis=0)
def _infer_inflammation_logits_chunk(
images: list[Image.Image],
model_name: str,
) -> tuple[np.ndarray, Literal["batched", "sequential"]]:
if not _should_try_batched_infer(len(images)):
return _infer_inflammation_logits_sequential(images, model_name), "sequential"
try:
return _infer_inflammation_logits_batch(images, model_name, max_retries=1), "batched"
except Exception as exc:
logger.warning(
"Batched inflammation infer×%s failed (%s); falling back to sequential",
len(images),
exc,
)
return _infer_inflammation_logits_sequential(images, model_name), "sequential"
def _infer_segmentation_logits_batch(
images: list[Image.Image],
model_name: str,
*,
max_retries: int | None = None,
) -> np.ndarray:
batch_tensor = _stack_segmentation_tensors(images)
logits = _infer_sync_with_retry(
model_name,
batch_tensor,
operation=f"Triton segmentation batch×{len(images)} ({model_name})",
max_retries=max_retries,
)
return _normalize_batched_segmentation_logits(logits, len(images))
def _infer_segmentation_logits_sequential(images: list[Image.Image], model_name: str) -> np.ndarray:
rows: list[np.ndarray] = []
for index, image in enumerate(images, start=1):
logits = _infer_segmentation_logits_batch([image], model_name)
row = logits[0] if logits.ndim == 4 else logits
rows.append(row)
logger.info(
"Triton sequential segmentation infer %s/%s complete for %s",
index,
len(images),
model_name,
)
return np.stack(rows, axis=0)
def _infer_segmentation_logits_chunk(
images: list[Image.Image],
model_name: str,
) -> tuple[np.ndarray, Literal["batched", "sequential"]]:
if not _should_try_batched_infer(len(images)):
return _infer_segmentation_logits_sequential(images, model_name), "sequential"
try:
return _infer_segmentation_logits_batch(images, model_name, max_retries=1), "batched"
except Exception as exc:
logger.warning(
"Batched segmentation infer×%s failed (%s); falling back to sequential",
len(images),
exc,
)
return _infer_segmentation_logits_sequential(images, model_name), "sequential"
async def infer_angle_logits(
images: list[Image.Image],
model_name: str,
) -> tuple[np.ndarray, Literal["batched", "sequential"], int]:
"""Run angle classification under the global Triton lock. Returns (logits, mode, call_count)."""
async with _triton_infer_lock:
all_logits: list[np.ndarray] = []
modes: list[str] = []
call_count = 0
for chunk in chunk_sequence(images):
logits, mode = await asyncio.to_thread(_infer_angle_logits_chunk, chunk, model_name)
all_logits.append(logits)
modes.append(mode)
call_count += 1 if mode == "batched" else len(chunk)
combined = np.concatenate(all_logits, axis=0) if len(all_logits) > 1 else all_logits[0]
infer_mode: Literal["batched", "sequential"] = (
"sequential" if any(m == "sequential" for m in modes) else "batched"
)
return combined, infer_mode, call_count
async def infer_inflammation_logits(
images: list[Image.Image],
model_name: str,
) -> tuple[np.ndarray, Literal["batched", "sequential"], int]:
async with _triton_infer_lock:
all_logits: list[np.ndarray] = []
modes: list[str] = []
call_count = 0
for chunk in chunk_sequence(images):
logits, mode = await asyncio.to_thread(_infer_inflammation_logits_chunk, chunk, model_name)
all_logits.append(logits)
modes.append(mode)
call_count += 1 if mode == "batched" else len(chunk)
combined = np.concatenate(all_logits, axis=0) if len(all_logits) > 1 else all_logits[0]
infer_mode: Literal["batched", "sequential"] = (
"sequential" if any(m == "sequential" for m in modes) else "batched"
)
return combined, infer_mode, call_count
async def infer_segmentation_logits(
images: list[Image.Image],
model_name: str,
) -> tuple[np.ndarray, Literal["batched", "sequential"], int]:
async with _triton_infer_lock:
all_logits: list[np.ndarray] = []
modes: list[str] = []
call_count = 0
for chunk in chunk_sequence(images):
logits, mode = await asyncio.to_thread(_infer_segmentation_logits_chunk, chunk, model_name)
all_logits.append(logits)
modes.append(mode)
call_count += 1 if mode == "batched" else len(chunk)
combined = np.concatenate(all_logits, axis=0) if len(all_logits) > 1 else all_logits[0]
infer_mode: Literal["batched", "sequential"] = (
"sequential" if any(m == "sequential" for m in modes) else "batched"
)
return combined, infer_mode, call_count
async def infer_angle_logits_single(image: Image.Image, model_name: str) -> tuple[np.ndarray, str, int]:
logits, mode, calls = await infer_angle_logits([image], model_name)
return logits[0], f"angle:{mode}", calls
async def infer_inflammation_logits_single(image: Image.Image, model_name: str) -> tuple[np.ndarray, str, int]:
logits, mode, calls = await infer_inflammation_logits([image], model_name)
return logits[0], f"inflam:{mode}", calls
async def infer_segmentation_logits_single(image: Image.Image, model_name: str) -> tuple[np.ndarray, str, int]:
logits, mode, calls = await infer_segmentation_logits([image], model_name)
row = logits[0] if logits.ndim == 4 else logits
return row, f"seg:{mode}", calls

View File

@@ -0,0 +1,48 @@
"""Warm up Modal Triton on CV service startup — reduces cold-start 502s and first-frame latency."""
from __future__ import annotations
import logging
import os
from PIL import Image
from backend.implementation.config import get_model_name, get_segmentation_model
from backend.services import triton_runtime_service as triton_runtime
logger = logging.getLogger(__name__)
def _warmup_model_versions() -> dict[str, str]:
versions: dict[str, str] = {}
if angle := os.getenv("ANGLE_MODEL"):
versions["angle"] = angle
else:
versions["angle"] = "angle_classify_resnet50"
if inflam := os.getenv("INFLAMMATION_MODEL"):
versions["inflammation"] = inflam
return versions
async def warmup_triton_models() -> None:
if os.getenv("CV_SKIP_TRITON_WARMUP", "").lower() in {"1", "true", "yes"}:
logger.info("Triton warmup skipped (CV_SKIP_TRITON_WARMUP)")
return
model_versions = _warmup_model_versions()
angle_model = get_model_name("angle", model_versions)
inflam_model = get_model_name("inflammation", model_versions)
seg_model = get_segmentation_model("sup-up-long", model_versions)
img224 = Image.new("RGB", (224, 224), color=(128, 128, 128))
img512 = Image.new("RGB", (512, 512), color=(128, 128, 128))
logger.info(
"Warming up Triton (angle=%s, inflammation=%s, segmentation=%s)…",
angle_model,
inflam_model,
seg_model,
)
await triton_runtime.infer_angle_logits_single(img224, angle_model)
await triton_runtime.infer_inflammation_logits_single(img224, inflam_model)
await triton_runtime.infer_segmentation_logits_single(img512, seg_model)
logger.info("Triton warmup complete")

View File

@@ -0,0 +1,77 @@
# Agent Tools BFF Contract
Base path: `/api/v1/agent/tools`
## POST /exa/search
Proxies Exa `/search` with server-held `EXA_API_KEY`.
**Request**
```json
{
"query": "synovitis grading power doppler",
"type": "auto",
"numResults": 10,
"includeDomains": ["pubmed.ncbi.nlm.nih.gov"],
"session_id": "sess-001"
}
```
**Response**
```json
{
"hits": [
{
"id": "…",
"url": "https://…",
"title": "…",
"highlights": ["…"],
"publishedDate": "…",
"score": 0.92
}
],
"requestId": "…"
}
```
## POST /supabase/query
Allowlisted RPC only. Embedding computed server-side.
**Request**
```json
{
"rpc": "match_semantic_chunks",
"args": {
"query_text": "synovitis grade 2 knee ultrasound",
"match_count": 5,
"filter_book_ids": ["mor", "oho"]
},
"session_id": "sess-001"
}
```
**Response**
```json
{
"rpc": "match_semantic_chunks",
"rows": [
{
"chunk_id": "uuid",
"content": "…",
"book_id": "mor",
"parent_title": "…",
"similarity": 0.88
}
]
}
```
## References
- Exa: https://docs.exa.ai/reference/search-api-guide-for-coding-agents
- Supabase schema: [`knowledge/spec/pg_semantic_vector_db/supabase_schema.md`](../../knowledge/spec/pg_semantic_vector_db/supabase_schema.md)

View File

@@ -1,47 +1,122 @@
# Backend Specification
## Purpose
Orchestrates API routers, role checks, Socratic circuit-breaker state evaluations, and coordinates ML inference, telemetry collection, and data persistence.
## Codebase Tree View (implementation)
## Owner
Core Backend Team
```
backend/
├─ api/ # FastAPI routers (expose HTTP endpoints)
│ ├─ auth_api.py
│ ├─ patient_api.py
│ ├─ session_api.py
│ ├─ analysis_api.py
│ ├─ safety_api.py
│ ├─ notification_api.py
│ ├─ settings_api.py
│ ├─ ingestion_api.py
│ ├─ telemetry_api.py
├─ routers/ # Cloud LLM API routers
│ ├─ cloud_orchestrate.py # POST /api/cloud-orchestrate → Gemini proxy
│ └─ cloud_consult.py # POST /api/cloud-consult + GET /stream → MedGemma proxy
├─ services/ # Cloud LLM Gateway business logic
│ └─ cloud_llm_gateway.py # Routes Gemini/MedGemma based on task_type + consult_mode
├─ implementation/ # Deep modules (seams) each provides a small interface
│ ├─ auth/ # Auth Module
│ │ ├─ __init__.py
│ │ └─ service.py # login, logout, refresh, me, update_me
│ ├─ patient/ # Patient Module
│ │ ├─ __init__.py
│ │ └─ service.py
│ ├─ session/ # Session & Frame handling
│ │ ├─ __init__.py
│ │ ├─ service.py
│ │ └─ frame_storage.py # S3 adapter
│ ├─ analysis_jobs/ # Async analysis orchestration
│ │ ├─ __init__.py
│ │ ├─ service.py
│ │ └─ triton_client.py # gRPC wrapper
│ ├─ safety/ # Internal safety stack
│ │ ├─ __init__.py
│ │ ├─ gradcam.py
│ │ ├─ rationale.py
│ │ ├─ circuit_breaker.py
│ │ ├─ socratic_chat.py
│ │ ├─ drift_check.py
│ │ ├─ rag_evidence.py
│ │ ├─ activations.py
│ │ └─ annotations.py
│ ├─ notification/ # Notification Module
│ │ ├─ __init__.py
│ │ └─ service.py
│ ├─ settings/ # User settings module
│ │ ├─ __init__.py
│ │ └─ service.py
│ ├─ ingestion_history/ # Ingestion history module
│ │ ├─ __init__.py
│ │ └─ service.py
│ ├─ telemetry/ # Telemetry & anomaly reporting
│ │ ├─ __init__.py
│ │ └─ service.py
│ └─ adapters/ # Lowlevel adapters used by modules
│ ├─ s3_adapter.py # Generic S3 wrapper
│ ├─ triton_adapter.py # Adapter toward the Triton serving module hosted on Modal (KServe v2 HTTP / binary inference)
│ ├─ llm_adapter.py
│ └─ bert_adapter.py
└─ main.py # FastAPI app entry point, wires routers to modules
```
## Boundary
FastAPI server, API routers, authentication middleware, circuit breaker engine, report generator, RAG coordinator, RAG-Referee (BERT), ledger logger, and connections to Postgres + pgvector, S3 (MinIO), Redis, Triton, ladybugDB.
## Internal Design
- Built with FastAPI (Python) and Uvicorn for async HTTP server.
- Authentication middleware validates JWT tokens and enforces RBAC (roles: RO_RADIOLOGIST, RO_THERAPIST).
- Socratic circuit-breaker engine monitors interaction telemetry (hover duration, decision time, override magnitude) and triggers safety dialogs.
- Clinical Report Engine uses ReportLab to generate bilingual PDF reports per Circular 46/2018/TT-BYT.
- RAG Coordinator orchestrates Retrieval-Augmented Generation: dense vector lookup in pgvector (PostgreSQL HNSW), graph traversal in ladybugDB, mandatory pre-generation retrieval, prompt enrichment, LLM generation on browser WebLLM (GemmaE2B) or cloud Vertex AI (MedGemma via NFR-16a), and hallucination guarding via BERT RAG-Referee.
- NLP Scrubber (Microsoft Presidio): re-verifies client edge redaction, refines residual PII, and returns error if unresolvable.
- Ledger Logger appends immutable, cryptographically chained audit logs to Postgres via triggers preventing UPDATE/DELETE.
- Connections: Postgres + pgvector (via SQLAlchemy), S3 (via boto3), Redis (via redis-py), Triton (via gRPC — CV + EmbeddingGemma only), ladybugDB (via in-process C++ bindings).
- Model weights loaded at startup from internal registry; cached in memory.
- API endpoints layered: public clinical (sessions, analysis, reports, feedback) and internal/local safety (explanations, safety, drift, RAG, activations, annotations, ground-truth, escalation, morphology, telemetry).
## Interface Contract
See `bento/backend/spec/interface-contract.md`.
## Overview
The backend is a **FastAPI** application that orchestrates several **deep modules**. Each module presents a **seam** (the modules public interface) that callers the FastAPI router use. Below is the module map, its **interface**, **implementation**, and the external **services** it depends on.
## Consumers
- frontend
| Module | Interface (public API) | Implementation | External Services (dependencies) |
|--------|------------------------|----------------|-----------------------------------|
| **Auth Module** | `login`, `logout`, `refresh`, `me`, `update_me` | JWT handling, password hashing, session store | PostgreSQL (`users` table), Redis (optional token blacklist) |
| **Patient Module** | CRUD for patients, list sessions, ingestion history | ORM models, business rules | PostgreSQL (`patients`, `sessions`, `ingestion_history`) |
| **Session Module** | Create session, add frames, retrieve, patch review | Transactional management, validation | PostgreSQL (`sessions`, `frames`), S3 adapter (frame storage) |
| **Frame Storage Adapter** | `store_frame`, `generate_presigned_url` | Modalbased S3 client wrapper | AWS S3 (object store) |
| **Analysis Jobs Module** | `submit_job`, `job_status`, `job_steps` | Async job scheduler, Triton inference HTTP client, result aggregator | Triton inference server (KServe v2 HTTP, Modal serverless endpoint), PostgreSQL (`analysis_jobs`), S3 (artifact storage) |
| **Safety Module** | GradCAM, rationale, circuitbreaker, Socratic chat, drift check, RAG evidence, activations, annotations, escalation, telemetry | Calls to local LLM/RAG/BERT services, postprocessing utilities | Local LLM container, BERT drift detector, RAG knowledge base, PostgreSQL (`safety_events`), S3 (heatmaps, masks) |
| **Cloud LLM Gateway Module** | `route_gemini_request`, `route_medgemma_request` | task_type matcher, NFR-16a consent/redaction/audit enforcement, consult_mode state extension, cost guarding | GCP Vertex AI (Gemini), Modal (MedGemma), Redis (consult_mode, consent, rate-limit), PostgreSQL (audit) |
| **Agent Tools Module** | `exa_search`, `supabase_query` | Exa web search proxy, Supabase allowlisted RPC, PHI-safe audit logging | Exa API, Supabase (`knowledge` schema), EmbeddingGemma (embedder TBD) |
| **Notification Module** | List, markread, set preferences | Simple DB queries, push service stub | PostgreSQL (`notifications`), optional WebSocket push service |
| **Settings Module** | Get/patch user settings | DB reads/writes | PostgreSQL (`user_settings`) |
| **Ingestion History Module** | List uploads, get details | Query on `ingestion_history` table | PostgreSQL, S3 (original DICOM/frame) |
| **Telemetry Module** | Anomaly reporting | Write to telemetry tables, async queue | PostgreSQL (`telemetry`), optional analytics pipeline |
## Breaking-change Policy
See `bento/backend/spec/interface-contract.md`.
## Design Principles Applied
- **Depth**: Each module hides complex orchestration (e.g., Triton gRPC, S3 multipart upload) behind a small, welldefined interface.
- **Seams**: Interfaces live in `backend/api/<module>_api.py`; adapters implement them in `backend/services/<module>_service.py`.
- **Deletion Test**: Removing any module concentrates its complexity inside callers, confirming the modules value.
- **Locality**: All error handling, logging, and retry logic resides inside the module implementation, giving callers a clean contract.
- **Leverage**: Callers (FastAPI routes) only need to know request/response shapes; the module provides the full workflow.
## References
- NFR-7 (Real-Time UI Screen Refresh ≤200ms)
- NFR-10 (Generative Safety Guardrails)
- NFR-11 (Frontline Usability & Training)
- UC-48376 (Load Patient Scan Session)
- UC-47988 (Review Suggested Synovitis Grade)
- UC-25776 (Generate GradCAM & CoT Explanation Panel)
- UC-02423 (Log High-Trust Concur Block)
- UC_Q2_* (All Quadrant 2 safety workflows)
- UC_Q3_* (All Quadrant 3 subservience workflows)
- UC_Q4_* (All Quadrant 4 double-blind workflows)
- SOFTWARE_SYSTEM_DESIGN_FR_25.md (Sections 1.2, 2.1-2.6)
- SOLUTION_ARCHITECTURE_SPEC.md (Sections 2.1-2.6)
- DATA_ENGINEERING_SPEC.md (Sections 4-12 for domain objects)
- CI_CD_DEPLOYMENT_PIPELINE.md (Section 9.2 for docker-compose)
## Module Dependency Graph (Mermaid)
```mermaid
graph TD;
Auth --> DB[PostgreSQL];
Patient --> DB;
Session --> DB;
Session --> S3[AWS S3];
AnalysisJobs --> Triton[KServe v2 HTTP Triton = Modal Serverless];
AnalysisJobs --> DB;
AnalysisJobs --> S3;
Safety --> LLM[Local LLM];
Safety --> BERT[Drift Detector];
Safety --> RAG[Local RAG];
Safety --> DB;
Safety --> S3;
CloudLLM --> Gemini[GCP Vertex AI Gemini];
CloudLLM --> MedGemma[Modal MedGemma];
CloudLLM --> Redis;
CloudLLM --> DB;
CloudLLM --> CostGuard[MedGemma Usage Counter];
Notification --> DB;
Settings --> DB;
IngestionHistory --> DB;
IngestionHistory --> S3;
Telemetry --> DB;
```
---
*Generated for AInavigability and testability.*

View File

@@ -1,46 +1,98 @@
# Backend Interface Contract
# Interface Contract Catalog
## Purpose
Orchestrates API routers, role checks, Socratic circuit-breaker state evaluations, and coordinates ML inference, telemetry collection, and data persistence.
This document enumerates every public **API contract** (HTTP endpoint) defined in `API_CONTRACT_DRAFT.md` and maps it to the **seam** (module) that fulfills it, together with the **external services** that the module interacts with.
## Owner
Core Backend Team
## 0. Health & Model Registry (Infrastructure / Analysis Jobs Module)
| Method | Path | Interface Function | Dependencies |
|--------|------|-------------------|--------------|
| GET | /api/v1/health | `system.health() -> HealthStatus` | All backend dependencies |
| GET | /api/v1/model-registry | `analysis.list_registered_models() -> ModelCatalog` | Triton (Modal serverless), S3 (model artifacts) |
| POST | /api/v1/models/register | `analysis.register_model(model_id: str, file: Binary) -> RegistrationResult` | S3 (model storage), Modal (serverless provisioning) |
## Provides
- api endpoints (session management, frame upload, analysis jobs, reporting, feedback, safety endpoints)
- model inference orchestration (dispatches to Triton, aggregates results)
- telemetry collection (edge-based behavioral summaries, audit logs)
- data persistence coordination (writes to Postgres, S3, Redis)
## 1. Authentication Endpoints (Auth Module)
| Method | Path | Interface Function | Dependencies |
|--------|------|-------------------|--------------|
| POST | /api/v1/auth/login | `auth.login(username: str, password: str) -> JWT` | PostgreSQL (users), bcrypt, optional Redis blacklist |
| POST | /api/v1/auth/logout | `auth.logout(token: str) -> None` | Redis (token revocation) |
| POST | /api/v1/auth/refresh | `auth.refresh(refresh_token: str) -> JWT` | PostgreSQL, Redis |
| GET | /api/v1/users/me | `auth.me(token: str) -> UserProfile` | PostgreSQL |
| PATCH | /api/v1/users/me | `auth.update_me(token: str, updates: dict) -> UserProfile` | PostgreSQL |
## Consumes
- data:storage-spec (Postgres DB, S3 object store, Redis cache)
- ml:inference-spec (Triton server for angle, inflammation, segmentation, severity)
- knowledge:guideline-spec (Qdrant vector DB, ladybugDB graph DB for grounded explanations)
## 2. Patient Management (Patient Module)
| Method | Path | Interface Function | Dependencies |
|--------|------|-------------------|--------------|
| GET | /api/v1/patients | `patient.list(user_id: str) -> List[Patient]` | PostgreSQL |
| POST | /api/v1/patients | `patient.create(data: dict) -> Patient` | PostgreSQL |
| GET | /api/v1/patients/{patient_id} | `patient.get(id: str) -> Patient` | PostgreSQL |
| GET | /api/v1/patients/{patient_id}/sessions | `patient.list_sessions(id: str) -> List[Session]` | PostgreSQL |
| GET | /api/v1/patients/{patient_id}/history | `patient.ingestion_history(id: str) -> List[IngestionRecord]` | PostgreSQL, S3 |
## Consumers
- frontend
## 3. Notification Endpoints (Notification Module)
| Method | Path | Interface Function | Dependencies |
|--------|------|-------------------|--------------|
| GET | /api/v1/notifications | `notification.list(user_id: str, filters: dict) -> List[Notification]` | PostgreSQL |
| PATCH | /api/v1/notifications/{notification_id}/read | `notification.mark_read(id: str) -> None` | PostgreSQL |
| POST | /api/v1/notifications/preferences | `notification.set_preferences(user_id: str, prefs: dict) -> None` | PostgreSQL |
## Not Directly Consumable
- data internals (Postgres tables, S3 object layout, Redis keys)
- ml internals (Triton model details, GPU kernels)
- knowledge internals (Qdrant vectors, ladybugDB graph)
## 4. Settings & Preferences (Settings Module)
| Method | Path | Interface Function | Dependencies |
|--------|------|-------------------|--------------|
| GET | /api/v1/settings | `settings.get(user_id: str) -> Settings` | PostgreSQL |
| PATCH | /api/v1/settings | `settings.update(user_id: str, updates: dict) -> Settings` | PostgreSQL |
## Breaking-change Policy
- API versioning via path (e.g., /api/v1/).
- Backward compatibility maintained for one minor version.
- Deprecation notices issued in release notes.
- Model interface changes (input/output tensors) require version bump.
## 5. Ingestion History (Ingestion History Module)
| Method | Path | Interface Function | Dependencies |
|--------|------|-------------------|--------------|
| GET | /api/v1/ingestion-history | `ingestion.list(user_id: str) -> List[Record]` | PostgreSQL, S3 |
| GET | /api/v1/ingestion-history/{record_id} | `ingestion.get(id: str) -> RecordDetail` | PostgreSQL, S3 |
## References
- NFR-7 (Real-Time UI Screen Refresh ≤200ms)
- NFR-10 (Generative Safety Guardrails)
- NFR-11 (Frontline Usability & Training)
- UC-48376 (Load Patient Scan Session)
- UC-47988 (Review Suggested Synovitis Grade)
- UC-25776 (Generate GradCAM & CoT Explanation Panel)
- UC-02423 (Log High-Trust Concur Block)
- UC_Q2_* (All Quadrant 2 safety workflows)
- UC_Q3_* (All Quadrant 3 subservience workflows)
- UC_Q4_* (All Quadrant 4 double-blind workflows)
- SOFTWARE_SYSTEM_DESIGN_FR_25.md (Sections 1.2, 2.1-2.6)
- SOLUTION_ARCHITECTURE_SPEC.md (Sections 2.1-2.6)
## 6. Clinical Workflow Endpoints (Session & Analysis Modules)
| Method | Path | Interface Function | Module | Dependencies |
|--------|------|-------------------|--------|--------------|
| POST | /api/v1/sessions | `session.create(user_id: str, patient_id: str) -> Session` | Session Module | PostgreSQL |
| GET | /api/v1/sessions/{session_id} | `session.get(id: str) -> SessionDetail` | Session Module | PostgreSQL |
| POST | /api/v1/sessions/{session_id}/frames | `session.add_frame(id: str, file: UploadFile) -> FrameMeta` | Session Module (via Frame Storage Adapter) | S3, PostgreSQL |
| PATCH | /api/v1/sessions/{session_id}/review | `session.patch_review(id: str, review: dict) -> Session` | Session Module | PostgreSQL |
| POST | /api/v1/analysis-jobs | `analysis.submit(session_id: str, params: dict) -> JobID` | Analysis Jobs Module | Triton, PostgreSQL, S3 |
| GET | /api/v1/analysis-jobs/{job_id} | `analysis.status(job_id: str) -> JobStatus` | Analysis Jobs Module | PostgreSQL |
| GET | /api/v1/analysis-jobs/{job_id}/steps | `analysis.steps(job_id: str) -> List[Step]` | Analysis Jobs Module | PostgreSQL |
| POST | /api/v1/reports | `report.create(session_id: str, payload: dict) -> ReportID` | Session Module | PostgreSQL, S3 |
| POST | /api/v1/reports/{report_id}/sign | `report.sign(id: str, signature: dict) -> Report` | Session Module | PostgreSQL |
| POST | /api/v1/reports/{report_id}/emr-sync | `report.sync_emr(id: str) -> SyncResult` | Session Module | External EMR connector (REST) |
| POST | /api/v1/sessions/{session_id}/feedback | `safety.submit_correction(session_id: str, correction: dict) -> None` | Safety Module | PostgreSQL, async analytics pipeline |
| POST | /api/v1/analysis | `analysis.submit_sync(session_id: str, params: dict) -> JobResult` | Analysis Jobs Module | Triton, PostgreSQL, S3 |
| POST | /api/v1/sessions/{session_id}/persist | `session.persist(session_id: str, review: dict) -> PersistResult` | Session Module | PostgreSQL, S3 |
| POST | /api/v1/sessions/{session_id}/export-pdf | `session.export_pdf(session_id: str, params: dict) -> ExportResult` | Session Module | S3 |
| POST | /api/v1/sessions/{session_id}/scrub-validate | `session.scrub_validate(session_id: str, metadata: dict) -> ScrubResult` | Session Module | - |
| GET | /api/v1/analysis-jobs/{job_id}/stream | `analysis.stream(job_id: str) -> SSE[StepEvent]` | Analysis Jobs Module | - |
## 8. Cloud LLM Orchestration (Cloud LLM Gateway)
| Method | Path | Interface Function | Dependencies |
|--------|------|-------------------|--------------|
| POST | /api/v1/cloud-orchestrate | `cloud_llm_gateway.route_gemini_request(payload, user_id) -> dict` | Vertex AI (Gemini), Redis (consult_mode, consent), audit log |
| POST | /api/v1/cloud-consult | `cloud_llm_gateway.route_medgemma_request(payload, user_id) -> dict` | Modal MedGemma, Redis (consult_mode, consent), audit log |
| GET | /api/v1/cloud-consult/stream | `cloud_llm_gateway.route_medgemma_request(payload, user_id) -> SSE[chunk]` | Modal MedGemma streaming, Redis, audit log |
## 9. Internal/Local Safety Endpoints (Safety Module)
| Method | Path | Interface Function | Dependencies |
|--------|------|-------------------|--------------|
| POST | /api/v1/sessions/{session_id}/explanations/gradcam | `safety.gradcam(session_id: str) -> HeatmapURL` | Triton (model output), S3 (store heatmap) |
| POST | /api/v1/sessions/{session_id}/explanations/rationale | `safety.rationale(session_id: str) -> Text` | Local LLM service |
| POST | /api/v1/sessions/{session_id}/safety/circuit-breaker | `safety.circuit_break(session_id: str, flag: bool) -> None` | PostgreSQL |
| POST | /api/v1/sessions/{session_id}/chat/socratic | `safety.socratic_chat(session_id: str, prompt: str) -> ChatResponse` | Local LLM, PostgreSQL |
| POST | /api/v1/sessions/{session_id}/drift/check | `safety.drift_check(session_id: str) -> DriftResult` | BERT (Drift Detector) |
| POST | /api/v1/sessions/{session_id}/rag/evidence | `safety.rag_evidence(session_id: str) -> EvidenceList` | Local RAG |
| POST | /api/v1/sessions/{session_id}/activations | `safety.activations(session_id: str, params: dict) -> ActivationMeta` | Triton, S3 |
| POST | /api/v1/sessions/{session_id}/annotations/artifacts | `safety.upload_artifact(session_id: str, file: UploadFile) -> ArtifactMeta` | S3 |
| POST | /api/v1/sessions/{session_id}/ground-truth | `safety.ground_truth(session_id: str, label: dict) -> None` | PostgreSQL |
| POST | /api/v1/sessions/{session_id}/escalation | `safety.escalate(session_id: str, reason: str) -> EscalationTicket` | PostgreSQL, external ticketing stub |
| POST | /api/v1/sessions/{session_id}/annotations/morphology | `safety.morphology(session_id: str, annotation: dict) -> None` | PostgreSQL |
| POST | /api/v1/sessions/{session_id}/telemetry/anomalies | `telemetry.anomaly(session_id: str, data: dict) -> None` | PostgreSQL, async analytics pipeline |
| POST | /api/v1/safety/guardrail-check | `safety.guardrail_check(session_id: str, prompt: str, score: float) -> GuardrailResult` | Safety Module | - |
| GET | /api/v1/sessions/{session_id}/chat/stream | `safety.chat_stream(session_id: str) -> SSE[ChatEvent]` | Safety Module | - |
---
**Notation**: each row describes the **seam** (module) that implements the endpoint. Callers only need to know the request/response signature; the module encapsulates all orchestration, giving high **leverage** and good **testability**.
*Generated to aid AInavigability and automated test generation.*

View File

@@ -0,0 +1,277 @@
#!/usr/bin/env python3
"""
Stratified sampling of test_images into per-patient scan profiles.
Each patient receives the same number of frames: (images_per_stratum × 5 strata).
Strata = folder names under backend/tests/test_images/:
- sup-up-long_positive
- sup-up-long_negative
- post_trans_positive
- post_trans_negative
- other_angle
Sampling is with replacement within each stratum (per patient).
Outputs:
- backend/tests/test_images/profiles/manifest.json
- frontend/implementation/public/assets/patient-profiles/{patient_id}/...
- frontend/implementation/src/data/patientScanProfiles.generated.ts
Run from CODEBASE root:
python backend/tests/sample_patient_profiles.py
python backend/tests/sample_patient_profiles.py --per-stratum 2 --seed 42
"""
from __future__ import annotations
import argparse
import json
import random
import shutil
from dataclasses import dataclass
from pathlib import Path
CODEBASE_ROOT = Path(__file__).resolve().parents[2]
TEST_IMAGES_ROOT = CODEBASE_ROOT / "backend/tests/test_images"
PROFILES_MANIFEST = TEST_IMAGES_ROOT / "profiles" / "manifest.json"
PUBLIC_PROFILES_ROOT = CODEBASE_ROOT / "frontend/implementation/public/assets/patient-profiles"
GENERATED_TS = CODEBASE_ROOT / "frontend/implementation/src/data/patientScanProfiles.generated.ts"
IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".webp", ".bmp"}
STRATA: list[str] = [
"sup-up-long_positive",
"sup-up-long_negative",
"post_trans_positive",
"post_trans_negative",
"other_angle",
]
STRATUM_SLUG: dict[str, str] = {
"sup-up-long_positive": "sup-long-pos",
"sup-up-long_negative": "sup-long-neg",
"post_trans_positive": "post-trans-pos",
"post_trans_negative": "post-trans-neg",
"other_angle": "other",
}
STRATUM_LABEL_VI: dict[str, str] = {
"sup-up-long_positive": "Sup dọc — viêm (+)",
"sup-up-long_negative": "Sup dọc — không viêm ()",
"post_trans_positive": "Sau ngang — viêm (+)",
"post_trans_negative": "Sau ngang — không viêm ()",
"other_angle": "Góc khác (med-lat / sup-trans-flex)",
}
EXPECTED_ANGLE_BY_STRATUM: dict[str, str | None] = {
"sup-up-long_positive": "sup-up-long",
"sup-up-long_negative": "sup-up-long",
"post_trans_positive": "post-trans",
"post_trans_negative": "post-trans",
"other_angle": None,
}
PATIENTS: list[dict[str, str]] = [
{"id": "p-001", "name": "Nguyễn Văn An", "mrn": "BN-2024-1847"},
{"id": "p-002", "name": "Trần Thị Bích", "mrn": "BN-2024-1923"},
{"id": "p-003", "name": "Lê Hoàng Minh", "mrn": "BN-2024-2011"},
{"id": "p-004", "name": "Phạm Thu Hà", "mrn": "BN-2024-2088"},
]
@dataclass(frozen=True)
class PoolImage:
path: Path
stratum: str
def _list_images(folder: Path) -> list[Path]:
if not folder.is_dir():
return []
files = [
p
for p in sorted(folder.iterdir())
if p.is_file() and p.suffix.lower() in IMAGE_SUFFIXES
]
return files
def _infer_other_angle_class(filename: str) -> str:
lower = filename.lower()
if "trans" in lower and "flex" in lower:
return "sup-trans-flex"
if "med" in lower and "lat" in lower:
return "med-lat"
return "med-lat"
def _build_pools() -> dict[str, list[PoolImage]]:
pools: dict[str, list[PoolImage]] = {}
for stratum in STRATA:
folder = TEST_IMAGES_ROOT / stratum
images = _list_images(folder)
if not images:
raise FileNotFoundError(f"No images in stratum folder: {folder}")
pools[stratum] = [PoolImage(path=p, stratum=stratum) for p in images]
return pools
def _sample_profile(
patient: dict[str, str],
pools: dict[str, list[PoolImage]],
*,
per_stratum: int,
rng: random.Random,
) -> list[dict]:
frames: list[dict] = []
for stratum in STRATA:
pool = pools[stratum]
slug = STRATUM_SLUG[stratum]
for index in range(per_stratum):
chosen = rng.choice(pool)
source_name = chosen.path.name
expected = EXPECTED_ANGLE_BY_STRATUM[stratum]
if expected is None:
expected = _infer_other_angle_class(source_name)
frame_id = f"{patient['id']}-{slug}-{index}"
ext = chosen.path.suffix.lower()
asset_name = f"{slug}-{index}{ext}"
rel_asset = f"/assets/patient-profiles/{patient['id']}/{asset_name}"
frames.append(
{
"id": frame_id,
"patient_id": patient["id"],
"stratum": stratum,
"stratum_index": index,
"label": f"{STRATUM_LABEL_VI[stratum]} · #{index + 1}",
"expected_angle_class": expected,
"source_path": str(chosen.path.relative_to(CODEBASE_ROOT)),
"source_filename": source_name,
"asset_path": rel_asset,
"asset_filename": asset_name,
}
)
return frames
def _materialize_assets(patient_id: str, frames: list[dict]) -> None:
out_dir = PUBLIC_PROFILES_ROOT / patient_id
if out_dir.exists():
shutil.rmtree(out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
for frame in frames:
src = CODEBASE_ROOT / frame["source_path"]
dst = out_dir / frame["asset_filename"]
shutil.copy2(src, dst)
def _write_manifest(payload: dict) -> None:
PROFILES_MANIFEST.parent.mkdir(parents=True, exist_ok=True)
PROFILES_MANIFEST.write_text(
json.dumps(payload, indent=2, ensure_ascii=False) + "\n",
encoding="utf-8",
)
def _ts_string(value: str) -> str:
return json.dumps(value, ensure_ascii=False)
def _write_generated_ts(payload: dict) -> None:
lines = [
"/** Auto-generated by backend/tests/sample_patient_profiles.py — do not edit. */",
"import type { ScanFrame } from './scanFrames';",
"",
"export interface PatientScanProfileFrame extends ScanFrame {",
" stratum: string;",
" expectedAngleClass?: string;",
" sourcePath: string;",
"}",
"",
"export const PATIENT_SCAN_PROFILES: Record<string, PatientScanProfileFrame[]> = {",
]
for patient in payload["patients"]:
pid = patient["id"]
lines.append(f" {_ts_string(pid)}: [")
for frame in patient["frames"]:
lines.append(
" {"
f" id: {_ts_string(frame['id'])},"
f" src: {_ts_string(frame['asset_path'])},"
f" label: {_ts_string(frame['label'])},"
f" stratum: {_ts_string(frame['stratum'])},"
f" expectedAngleClass: {_ts_string(frame['expected_angle_class'])},"
f" sourcePath: {_ts_string(frame['source_path'])},"
" },"
)
lines.append(" ],")
lines.extend(
[
"};",
"",
"export function getScanFramesForPatient(patientId: string): PatientScanProfileFrame[] {",
" return PATIENT_SCAN_PROFILES[patientId] ?? PATIENT_SCAN_PROFILES['p-001'] ?? [];",
"}",
"",
f"export const FRAMES_PER_PATIENT = {payload['frames_per_patient']};",
f"export const IMAGES_PER_STRATUM = {payload['images_per_stratum']};",
"",
]
)
GENERATED_TS.write_text("\n".join(lines), encoding="utf-8")
def main() -> None:
parser = argparse.ArgumentParser(description="Sample stratified ultrasound frames per patient profile.")
parser.add_argument(
"--per-stratum",
type=int,
default=1,
help="Images sampled per stratum folder per patient (default: 1 → 5 frames/patient).",
)
parser.add_argument("--seed", type=int, default=2026, help="RNG seed for reproducible sampling.")
args = parser.parse_args()
if args.per_stratum < 1:
raise SystemExit("--per-stratum must be >= 1")
rng = random.Random(args.seed)
pools = _build_pools()
print("Stratum pool sizes:")
for stratum in STRATA:
print(f" {stratum}: {len(pools[stratum])} images")
profiles: list[dict] = []
for patient in PATIENTS:
frames = _sample_profile(patient, pools, per_stratum=args.per_stratum, rng=rng)
_materialize_assets(patient["id"], frames)
profiles.append({**patient, "frames": frames})
print(f"Patient {patient['id']}: {len(frames)} frames")
frames_per_patient = args.per_stratum * len(STRATA)
payload = {
"seed": args.seed,
"images_per_stratum": args.per_stratum,
"frames_per_patient": frames_per_patient,
"strata": STRATA,
"patients": profiles,
}
_write_manifest(payload)
_write_generated_ts(payload)
print(f"\nWrote manifest: {PROFILES_MANIFEST.relative_to(CODEBASE_ROOT)}")
print(f"Wrote assets: {PUBLIC_PROFILES_ROOT.relative_to(CODEBASE_ROOT)}/")
print(f"Wrote TS: {GENERATED_TS.relative_to(CODEBASE_ROOT)}")
print(f"Total: {len(PATIENTS)} patients × {frames_per_patient} frames = {len(PATIENTS) * frames_per_patient} images")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,71 @@
"""
Minimal BFF for agent-tool smoke tests (no GCP secrets, no Redis).
Mounts only:
POST /api/v1/embed
POST /api/v1/agent/tools/exa/search
POST /api/v1/agent/tools/supabase/query
Run from CODEBASE root:
# loads PILOT_PROJECT/secrets/aws_secret/.env if present
PYTHONPATH=. python backend/tests/smoke_agent_tools_server.py
Then:
cd ml/tests/agent_tools && npm run smoke:bff
"""
from __future__ import annotations
import logging
import os
import sys
from pathlib import Path
import uvicorn
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
CODEBASE_ROOT = Path(__file__).resolve().parents[2]
if str(CODEBASE_ROOT) not in sys.path:
sys.path.insert(0, str(CODEBASE_ROOT))
SECRETS_ENV = CODEBASE_ROOT.parents[2] / "secrets" / "aws_secret" / ".env"
def _load_dotenv_file(path: Path) -> None:
if not path.exists():
return
for line in path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
key = key.strip()
value = value.strip().strip('"').strip("'")
os.environ.setdefault(key, value)
_load_dotenv_file(SECRETS_ENV)
os.environ.setdefault("EMBED_QUERY_MOCK", "1")
from backend.routers import agent_tools # noqa: E402
logger = logging.getLogger(__name__)
app = FastAPI(title="Agent Tools Smoke BFF", version="0.1.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(agent_tools.router)
if __name__ == "__main__":
host = os.getenv("SMOKE_BFF_HOST", "127.0.0.1")
port = int(os.getenv("SMOKE_BFF_PORT", "8000"))
logging.basicConfig(level=logging.INFO)
logger.info("Agent tools smoke BFF on http://%s:%s", host, port)
logger.info("Secrets env: %s (%s)", SECRETS_ENV, "found" if SECRETS_ENV.exists() else "missing")
uvicorn.run(app, host=host, port=port, log_level="info")

View File

@@ -0,0 +1,77 @@
"""HTTP layer tests for the CV inference FastAPI router."""
from __future__ import annotations
import io
import json
import sys
from pathlib import Path
from unittest.mock import AsyncMock, patch
import pytest
from fastapi.testclient import TestClient
from PIL import Image
CODEBASE_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(CODEBASE_ROOT))
from backend.cv_inference_server import create_app
@pytest.fixture
def client() -> TestClient:
return TestClient(create_app())
def _png_bytes() -> bytes:
buf = io.BytesIO()
Image.new("RGB", (32, 24), color=(100, 120, 140)).save(buf, format="PNG")
return buf.getvalue()
def test_health_route(client: TestClient):
with patch(
"backend.routers.cv_inference.TritonAdapter.model_ready",
new=AsyncMock(return_value=True),
):
response = client.get("/api/test/health")
assert response.status_code == 200
body = response.json()
assert body["service"] == "cv-inference"
assert body["status"] == "ok"
def test_analyze_batch_route(client: TestClient):
mock_result = type(
"Batch",
(),
{
"results": [{"success": True, "frame_id": "f1", "angle": {"class": "med-lat"}}],
"triton_infer_calls": 1,
"triton_infer_modes": ["angle:batched"],
},
)()
with patch(
"backend.routers.cv_inference.run_batch",
new=AsyncMock(return_value=mock_result),
):
response = client.post(
"/api/test/analyze/batch",
data={"frame_ids": json.dumps(["f1"])},
files=[("images", ("f1.png", _png_bytes(), "image/png"))],
)
assert response.status_code == 200
body = response.json()
assert body["success"] is True
assert body["image_count"] == 1
assert body["results"][0]["frame_id"] == "f1"
def test_legacy_segment_route_returns_410(client: TestClient):
response = client.post(
"/api/test/segment/batch",
data={"frame_ids": json.dumps(["f1"]), "angle_type": "sup"},
files=[("images", ("f1.png", _png_bytes(), "image/png"))],
)
assert response.status_code == 410

View File

@@ -0,0 +1,255 @@
"""Tests for backend.services.cv_inference_service — structure, gating, cache keys."""
from __future__ import annotations
import asyncio
import json
import os
import sys
from pathlib import Path
from unittest.mock import AsyncMock, patch
import numpy as np
import pytest
from PIL import Image
CODEBASE_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(CODEBASE_ROOT))
MANIFEST_PATH = Path(__file__).resolve().parent / "test_images" / "profiles" / "manifest.json"
REQUIRED_RESULT_KEYS = {
"success",
"angle",
"segmentation",
"severity",
"images",
"models_used",
}
def _load_manifest_frames() -> list[dict]:
if not MANIFEST_PATH.exists():
return []
data = json.loads(MANIFEST_PATH.read_text(encoding="utf-8"))
frames: list[dict] = []
for patient in data.get("patients", []):
frames.extend(patient.get("frames", []))
return frames
def _frame_image_path(frame: dict) -> Path:
return CODEBASE_ROOT / frame["source_path"]
def _make_angle_logits(class_index: int, num_classes: int = 4) -> np.ndarray:
row = np.full(num_classes, -2.0, dtype=np.float32)
row[class_index] = 5.0
return row
ANGLE_CLASS_INDEX = {
"med-lat": 0,
"post-trans": 1,
"sup-trans-flex": 2,
"sup-up-long": 3,
}
@pytest.fixture
def sample_rgb_image() -> Image.Image:
return Image.new("RGB", (128, 96), color=(80, 120, 160))
def test_analyze_batch_cache_key_stable_order():
from backend.services.cv_result_cache import analyze_batch_cache_key
key_a = analyze_batch_cache_key(
["frame-b", "frame-a"],
["hash-b", "hash-a"],
)
key_b = analyze_batch_cache_key(
["frame-a", "frame-b"],
["hash-a", "hash-b"],
)
assert key_a == key_b
assert key_a.startswith("analyze|")
def test_cv_result_cache_coalesces_inflight():
from backend.services import cv_result_cache
calls = 0
async def slow_compute():
nonlocal calls
calls += 1
await asyncio.sleep(0.05)
return {"ok": True}
async def run():
return await asyncio.gather(
cv_result_cache.with_result_cache("test-key-coalesce", slow_compute),
cv_result_cache.with_result_cache("test-key-coalesce", slow_compute),
)
results = asyncio.run(run())
assert results == [{"ok": True}, {"ok": True}]
assert calls == 1
@pytest.mark.parametrize(
"angle_class,inflammation_detected,expect_seg_performed",
[
("med-lat", False, False),
("sup-trans-flex", False, False),
("post-trans", False, False),
("post-trans", True, True),
("sup-up-long", False, False),
("sup-up-long", True, True),
],
)
def test_run_single_gating_logic(
sample_rgb_image: Image.Image,
angle_class: str,
inflammation_detected: bool,
expect_seg_performed: bool,
):
from backend.services.cv_inference_service import CvInferenceOptions, run_single
angle_idx = ANGLE_CLASS_INDEX[angle_class]
inflam_logits = _make_angle_logits(1 if inflammation_detected else 0, num_classes=2)
seg_logits = np.zeros((1, 7, 64, 64), dtype=np.float32)
async def mock_angle_single(image, model_name):
return _make_angle_logits(angle_idx), "angle:batched", 1
async def mock_inflam_single(image, model_name):
return inflam_logits, "inflam:batched", 1
async def mock_seg_single(image, model_name):
return seg_logits[0], "seg:batched", 1
with (
patch(
"backend.services.cv_inference_service.triton_runtime.infer_angle_logits_single",
new=AsyncMock(side_effect=mock_angle_single),
),
patch(
"backend.services.cv_inference_service.triton_runtime.infer_inflammation_logits_single",
new=AsyncMock(side_effect=mock_inflam_single),
),
patch(
"backend.services.cv_inference_service.triton_runtime.infer_segmentation_logits_single",
new=AsyncMock(side_effect=mock_seg_single),
),
):
result = asyncio.run(
run_single(
sample_rgb_image,
frame_id="test-frame",
options=CvInferenceOptions(use_cache=False),
)
)
assert result["success"] is True
assert REQUIRED_RESULT_KEYS.issubset(result.keys())
assert result["angle"]["class"] == angle_class
assert result["segmentation"]["performed"] is expect_seg_performed
if expect_seg_performed:
assert "segmented" in result["images"]
assert "inflammation" in result["models_used"]
assert "segmentation" in result["models_used"]
elif angle_class in {"post-trans", "sup-up-long"}:
assert result["inflammation"]["detected"] is inflammation_detected
assert result["severity"]["level"] == 0
def test_run_batch_result_shape(sample_rgb_image: Image.Image):
from backend.services.cv_inference_service import CvInferenceOptions, run_batch
async def mock_angle_single(image, model_name):
return _make_angle_logits(ANGLE_CLASS_INDEX["med-lat"]), "angle:batched", 1
with patch(
"backend.services.cv_inference_service.triton_runtime.infer_angle_logits_single",
new=AsyncMock(side_effect=mock_angle_single),
):
batch = asyncio.run(
run_batch(
[sample_rgb_image, sample_rgb_image],
["f1", "f2"],
options=CvInferenceOptions(use_cache=False),
)
)
assert len(batch.results) == 2
assert batch.triton_infer_calls == 2
assert len(batch.triton_infer_modes) == 2
for item in batch.results:
assert REQUIRED_RESULT_KEYS.issubset(item.keys())
assert item["success"] is True
@pytest.mark.skipif(
not os.getenv("RUN_CV_INTEGRATION"),
reason="Set RUN_CV_INTEGRATION=1 to call live Modal Triton",
)
def test_run_single_live_other_angle_frame():
frames = _load_manifest_frames()
other_frames = [f for f in frames if f.get("stratum") == "other_angle"]
if not other_frames:
pytest.skip("No other_angle frames in manifest")
frame = other_frames[0]
image_path = _frame_image_path(frame)
if not image_path.exists():
pytest.skip(f"Test image missing: {image_path}")
from backend.services.cv_inference_service import CvInferenceOptions, run_single
image = Image.open(image_path).convert("RGB")
result = asyncio.run(
run_single(
image,
frame_id=frame["id"],
options=CvInferenceOptions(use_cache=False),
)
)
assert result["success"] is True
assert REQUIRED_RESULT_KEYS.issubset(result.keys())
assert result["segmentation"]["performed"] is False
assert result["severity"]["level"] == 0
assert "enhanced" in result["images"]
@pytest.mark.skipif(
not os.getenv("RUN_CV_INTEGRATION"),
reason="Set RUN_CV_INTEGRATION=1 to call live Modal Triton",
)
def test_run_batch_live_from_manifest():
frames = _load_manifest_frames()
if not frames:
pytest.skip("Manifest not found or empty")
selected = frames[:2]
images: list[Image.Image] = []
frame_ids: list[str] = []
for frame in selected:
path = _frame_image_path(frame)
if not path.exists():
pytest.skip(f"Test image missing: {path}")
images.append(Image.open(path).convert("RGB"))
frame_ids.append(frame["id"])
from backend.services.cv_inference_service import CvInferenceOptions, run_batch
batch = asyncio.run(
run_batch(images, frame_ids, options=CvInferenceOptions(use_cache=False))
)
assert len(batch.results) == len(images)
assert batch.triton_infer_calls >= len(images)
for item in batch.results:
assert item["success"] is True
assert REQUIRED_RESULT_KEYS.issubset(item.keys())

View File

@@ -0,0 +1,21 @@
"""
Backward-compatible launcher for the CV inference FastAPI service.
Prefer:
PYTHONPATH=. python -m backend.cv_inference_server
This module remains so existing docs/scripts that invoke
`backend/tests/test_fast_api_proxy.py` keep working.
"""
import os
os.environ.setdefault(
"TRITON_ENDPOINT",
"https://dtj-tran--triton-s3-service-unified-triton-server.modal.run",
)
from backend.cv_inference_server import main
if __name__ == "__main__":
main()

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

View File

@@ -0,0 +1,282 @@
{
"seed": 2026,
"images_per_stratum": 1,
"frames_per_patient": 5,
"strata": [
"sup-up-long_positive",
"sup-up-long_negative",
"post_trans_positive",
"post_trans_negative",
"other_angle"
],
"patients": [
{
"id": "p-001",
"name": "Nguyễn Văn An",
"mrn": "BN-2024-1847",
"frames": [
{
"id": "p-001-sup-long-pos-0",
"patient_id": "p-001",
"stratum": "sup-up-long_positive",
"stratum_index": 0,
"label": "Sup dọc — viêm (+) · #1",
"expected_angle_class": "sup-up-long",
"source_path": "backend/tests/test_images/sup-up-long_positive/58e7a7ef-de3e-11ee-97e2-0a580a5f5b60_11.png",
"source_filename": "58e7a7ef-de3e-11ee-97e2-0a580a5f5b60_11.png",
"asset_path": "/assets/patient-profiles/p-001/sup-long-pos-0.png",
"asset_filename": "sup-long-pos-0.png"
},
{
"id": "p-001-sup-long-neg-0",
"patient_id": "p-001",
"stratum": "sup-up-long_negative",
"stratum_index": 0,
"label": "Sup dọc — không viêm () · #1",
"expected_angle_class": "sup-up-long",
"source_path": "backend/tests/test_images/sup-up-long_negative/58e7a90f-de3e-11ee-97e2-0a580a5f5b60_11.png",
"source_filename": "58e7a90f-de3e-11ee-97e2-0a580a5f5b60_11.png",
"asset_path": "/assets/patient-profiles/p-001/sup-long-neg-0.png",
"asset_filename": "sup-long-neg-0.png"
},
{
"id": "p-001-post-trans-pos-0",
"patient_id": "p-001",
"stratum": "post_trans_positive",
"stratum_index": 0,
"label": "Sau ngang — viêm (+) · #1",
"expected_angle_class": "post-trans",
"source_path": "backend/tests/test_images/post_trans_positive/72bb41ed-f020-11ed-b527-0a580a5f736a_17.png",
"source_filename": "72bb41ed-f020-11ed-b527-0a580a5f736a_17.png",
"asset_path": "/assets/patient-profiles/p-001/post-trans-pos-0.png",
"asset_filename": "post-trans-pos-0.png"
},
{
"id": "p-001-post-trans-neg-0",
"patient_id": "p-001",
"stratum": "post_trans_negative",
"stratum_index": 0,
"label": "Sau ngang — không viêm () · #1",
"expected_angle_class": "post-trans",
"source_path": "backend/tests/test_images/post_trans_negative/72bb1476-f020-11ed-b527-0a580a5f736a_17.png",
"source_filename": "72bb1476-f020-11ed-b527-0a580a5f736a_17.png",
"asset_path": "/assets/patient-profiles/p-001/post-trans-neg-0.png",
"asset_filename": "post-trans-neg-0.png"
},
{
"id": "p-001-other-0",
"patient_id": "p-001",
"stratum": "other_angle",
"stratum_index": 0,
"label": "Góc khác (med-lat / sup-trans-flex) · #1",
"expected_angle_class": "sup-trans-flex",
"source_path": "backend/tests/test_images/other_angle/trans_flex.png",
"source_filename": "trans_flex.png",
"asset_path": "/assets/patient-profiles/p-001/other-0.png",
"asset_filename": "other-0.png"
}
]
},
{
"id": "p-002",
"name": "Trần Thị Bích",
"mrn": "BN-2024-1923",
"frames": [
{
"id": "p-002-sup-long-pos-0",
"patient_id": "p-002",
"stratum": "sup-up-long_positive",
"stratum_index": 0,
"label": "Sup dọc — viêm (+) · #1",
"expected_angle_class": "sup-up-long",
"source_path": "backend/tests/test_images/sup-up-long_positive/72bb1ac0-f020-11ed-b527-0a580a5f736a_11.png",
"source_filename": "72bb1ac0-f020-11ed-b527-0a580a5f736a_11.png",
"asset_path": "/assets/patient-profiles/p-002/sup-long-pos-0.png",
"asset_filename": "sup-long-pos-0.png"
},
{
"id": "p-002-sup-long-neg-0",
"patient_id": "p-002",
"stratum": "sup-up-long_negative",
"stratum_index": 0,
"label": "Sup dọc — không viêm () · #1",
"expected_angle_class": "sup-up-long",
"source_path": "backend/tests/test_images/sup-up-long_negative/72bb0a8a-f020-11ed-b527-0a580a5f736a_11.png",
"source_filename": "72bb0a8a-f020-11ed-b527-0a580a5f736a_11.png",
"asset_path": "/assets/patient-profiles/p-002/sup-long-neg-0.png",
"asset_filename": "sup-long-neg-0.png"
},
{
"id": "p-002-post-trans-pos-0",
"patient_id": "p-002",
"stratum": "post_trans_positive",
"stratum_index": 0,
"label": "Sau ngang — viêm (+) · #1",
"expected_angle_class": "post-trans",
"source_path": "backend/tests/test_images/post_trans_positive/72bb4c47-f020-11ed-b527-0a580a5f736a_17.png",
"source_filename": "72bb4c47-f020-11ed-b527-0a580a5f736a_17.png",
"asset_path": "/assets/patient-profiles/p-002/post-trans-pos-0.png",
"asset_filename": "post-trans-pos-0.png"
},
{
"id": "p-002-post-trans-neg-0",
"patient_id": "p-002",
"stratum": "post_trans_negative",
"stratum_index": 0,
"label": "Sau ngang — không viêm () · #1",
"expected_angle_class": "post-trans",
"source_path": "backend/tests/test_images/post_trans_negative/72bb322d-f020-11ed-b527-0a580a5f736a_17.png",
"source_filename": "72bb322d-f020-11ed-b527-0a580a5f736a_17.png",
"asset_path": "/assets/patient-profiles/p-002/post-trans-neg-0.png",
"asset_filename": "post-trans-neg-0.png"
},
{
"id": "p-002-other-0",
"patient_id": "p-002",
"stratum": "other_angle",
"stratum_index": 0,
"label": "Góc khác (med-lat / sup-trans-flex) · #1",
"expected_angle_class": "sup-trans-flex",
"source_path": "backend/tests/test_images/other_angle/trans_flex.png",
"source_filename": "trans_flex.png",
"asset_path": "/assets/patient-profiles/p-002/other-0.png",
"asset_filename": "other-0.png"
}
]
},
{
"id": "p-003",
"name": "Lê Hoàng Minh",
"mrn": "BN-2024-2011",
"frames": [
{
"id": "p-003-sup-long-pos-0",
"patient_id": "p-003",
"stratum": "sup-up-long_positive",
"stratum_index": 0,
"label": "Sup dọc — viêm (+) · #1",
"expected_angle_class": "sup-up-long",
"source_path": "backend/tests/test_images/sup-up-long_positive/72bb1aaf-f020-11ed-b527-0a580a5f736a_11.png",
"source_filename": "72bb1aaf-f020-11ed-b527-0a580a5f736a_11.png",
"asset_path": "/assets/patient-profiles/p-003/sup-long-pos-0.png",
"asset_filename": "sup-long-pos-0.png"
},
{
"id": "p-003-sup-long-neg-0",
"patient_id": "p-003",
"stratum": "sup-up-long_negative",
"stratum_index": 0,
"label": "Sup dọc — không viêm () · #1",
"expected_angle_class": "sup-up-long",
"source_path": "backend/tests/test_images/sup-up-long_negative/53a31572-4380-11ee-9e9a-0a580a5f5f0e_11.png",
"source_filename": "53a31572-4380-11ee-9e9a-0a580a5f5f0e_11.png",
"asset_path": "/assets/patient-profiles/p-003/sup-long-neg-0.png",
"asset_filename": "sup-long-neg-0.png"
},
{
"id": "p-003-post-trans-pos-0",
"patient_id": "p-003",
"stratum": "post_trans_positive",
"stratum_index": 0,
"label": "Sau ngang — viêm (+) · #1",
"expected_angle_class": "post-trans",
"source_path": "backend/tests/test_images/post_trans_positive/72bb41ed-f020-11ed-b527-0a580a5f736a_17.png",
"source_filename": "72bb41ed-f020-11ed-b527-0a580a5f736a_17.png",
"asset_path": "/assets/patient-profiles/p-003/post-trans-pos-0.png",
"asset_filename": "post-trans-pos-0.png"
},
{
"id": "p-003-post-trans-neg-0",
"patient_id": "p-003",
"stratum": "post_trans_negative",
"stratum_index": 0,
"label": "Sau ngang — không viêm () · #1",
"expected_angle_class": "post-trans",
"source_path": "backend/tests/test_images/post_trans_negative/72bb1476-f020-11ed-b527-0a580a5f736a_17.png",
"source_filename": "72bb1476-f020-11ed-b527-0a580a5f736a_17.png",
"asset_path": "/assets/patient-profiles/p-003/post-trans-neg-0.png",
"asset_filename": "post-trans-neg-0.png"
},
{
"id": "p-003-other-0",
"patient_id": "p-003",
"stratum": "other_angle",
"stratum_index": 0,
"label": "Góc khác (med-lat / sup-trans-flex) · #1",
"expected_angle_class": "med-lat",
"source_path": "backend/tests/test_images/other_angle/med-lat_1.png",
"source_filename": "med-lat_1.png",
"asset_path": "/assets/patient-profiles/p-003/other-0.png",
"asset_filename": "other-0.png"
}
]
},
{
"id": "p-004",
"name": "Phạm Thu Hà",
"mrn": "BN-2024-2088",
"frames": [
{
"id": "p-004-sup-long-pos-0",
"patient_id": "p-004",
"stratum": "sup-up-long_positive",
"stratum_index": 0,
"label": "Sup dọc — viêm (+) · #1",
"expected_angle_class": "sup-up-long",
"source_path": "backend/tests/test_images/sup-up-long_positive/72bb0b3a-f020-11ed-b527-0a580a5f736a_21.png",
"source_filename": "72bb0b3a-f020-11ed-b527-0a580a5f736a_21.png",
"asset_path": "/assets/patient-profiles/p-004/sup-long-pos-0.png",
"asset_filename": "sup-long-pos-0.png"
},
{
"id": "p-004-sup-long-neg-0",
"patient_id": "p-004",
"stratum": "sup-up-long_negative",
"stratum_index": 0,
"label": "Sup dọc — không viêm () · #1",
"expected_angle_class": "sup-up-long",
"source_path": "backend/tests/test_images/sup-up-long_negative/53a31572-4380-11ee-9e9a-0a580a5f5f0e_11.png",
"source_filename": "53a31572-4380-11ee-9e9a-0a580a5f5f0e_11.png",
"asset_path": "/assets/patient-profiles/p-004/sup-long-neg-0.png",
"asset_filename": "sup-long-neg-0.png"
},
{
"id": "p-004-post-trans-pos-0",
"patient_id": "p-004",
"stratum": "post_trans_positive",
"stratum_index": 0,
"label": "Sau ngang — viêm (+) · #1",
"expected_angle_class": "post-trans",
"source_path": "backend/tests/test_images/post_trans_positive/72bb4c47-f020-11ed-b527-0a580a5f736a_17.png",
"source_filename": "72bb4c47-f020-11ed-b527-0a580a5f736a_17.png",
"asset_path": "/assets/patient-profiles/p-004/post-trans-pos-0.png",
"asset_filename": "post-trans-pos-0.png"
},
{
"id": "p-004-post-trans-neg-0",
"patient_id": "p-004",
"stratum": "post_trans_negative",
"stratum_index": 0,
"label": "Sau ngang — không viêm () · #1",
"expected_angle_class": "post-trans",
"source_path": "backend/tests/test_images/post_trans_negative/72bb1476-f020-11ed-b527-0a580a5f736a_17.png",
"source_filename": "72bb1476-f020-11ed-b527-0a580a5f736a_17.png",
"asset_path": "/assets/patient-profiles/p-004/post-trans-neg-0.png",
"asset_filename": "post-trans-neg-0.png"
},
{
"id": "p-004-other-0",
"patient_id": "p-004",
"stratum": "other_angle",
"stratum_index": 0,
"label": "Góc khác (med-lat / sup-trans-flex) · #1",
"expected_angle_class": "sup-trans-flex",
"source_path": "backend/tests/test_images/other_angle/trans_flex.png",
"source_filename": "trans_flex.png",
"asset_path": "/assets/patient-profiles/p-004/other-0.png",
"asset_filename": "other-0.png"
}
]
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

View File

@@ -0,0 +1,297 @@
import os
import sys
import pytest
import numpy as np
from PIL import Image
# Add the project root to sys.path
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.path.insert(0, PROJECT_ROOT)
# Test config module
def test_config():
from backend.implementation.config import get_model_name, get_angle_type, get_segmentation_model
# Test default model names
assert get_model_name("angle", None) == "angle_classify_convnext_tiny"
assert get_model_name("inflammation", None) == "inflammation_model_efficientnet_b0_ultrasound_2_cls"
assert get_model_name("segmentation_sup", None) == "segmentation_model_unet_resnet101"
assert get_model_name("segmentation_post", None) == "segmentation_model_post_deeplabv3_resnet101"
# Test model_versions override
custom = {"angle": "custom_angle_model"}
assert get_model_name("angle", custom) == "custom_angle_model"
assert get_model_name("inflammation", None) == "inflammation_model_efficientnet_b0_ultrasound_2_cls" # unchanged
# Test angle type
assert get_angle_type("med-lat") == "other"
assert get_angle_type("post-trans") == "post"
assert get_angle_type("sup-trans-flex") == "sup"
assert get_angle_type("sup-up-long") == "sup"
# Test segmentation model selection for angles that actually get segmentation
# Only post-trans and sup-up-long trigger inflammation->segmentation
assert get_segmentation_model("post-trans", None) == "segmentation_model_post_deeplabv3_resnet101" # post
assert get_segmentation_model("sup-up-long", None) == "segmentation_model_unet_resnet101" # sup
assert get_segmentation_model("sup-trans-flex", None) == "segmentation_model_unet_resnet101" # sup
# For other angles, the function still works but result isn't used in practice
assert get_segmentation_model("med-lat", None) == "segmentation_model_post_deeplabv3_resnet101" # defaults to post
# Test transforms module
def test_transforms():
from backend.implementation.preprocessing.transforms import Resize, Normalize
import numpy as np
from PIL import Image
# Create test image
img = Image.new('RGB', (100, 50), color='red')
# Test resize
resizer = Resize((50, 25))
resized = resizer(img)
assert resized.size == (50, 25)
# Test normalize
normalizer = Normalize(mean=[0.5, 0.5, 0.5], std=[0.2, 0.2, 0.2])
arr = np.array(img).astype(np.float32) / 255.0
normalized = normalizer(img)
expected = (arr - 0.5) / 0.2
np.testing.assert_allclose(normalized, expected)
# Test tensor prep
def test_tensor_prep():
from backend.implementation.preprocessing.tensor_prep import (
prepare_angle_tensor, prepare_inflammation_tensor, prepare_segmentation_tensor
)
from PIL import Image
import numpy as np
# Create test image
img = Image.new('RGB', (64, 64), color=(100, 150, 200))
# Test angle tensor
angle_tensor = prepare_angle_tensor(img)
assert angle_tensor.shape == (1, 3, 224, 224)
assert angle_tensor.dtype == np.float32
# Test inflammation tensor
inflam_tensor = prepare_inflammation_tensor(img)
assert inflam_tensor.shape == (1, 3, 224, 224)
assert inflam_tensor.dtype == np.float32
# Test segmentation tensor (01 normalized — matches infra preprocess_512 / Triton training)
seg_tensor = prepare_segmentation_tensor(img)
assert seg_tensor.shape == (1, 3, 512, 512)
assert seg_tensor.dtype == np.float32
assert seg_tensor.max() <= 1.0
assert seg_tensor.min() >= 0.0
# Test measurement module
def test_measurement():
from backend.implementation.postprocessing.measurement import (
calculate_thickness, get_mask_bounding_box, find_max_continuous_segment
)
import numpy as np
# Test find_max_continuous_segment
arr = np.array([0, 0, 1, 1, 1, 0, 1, 1, 0, 0])
length, start, end = find_max_continuous_segment(arr)
assert length == 3
assert start == 2
assert end == 5 # end is exclusive (like Python slicing)
# Test get_mask_bounding_box with simple square
mask = np.zeros((14, 14), dtype=np.uint8)
# 10x10 square (leaving 2-pixel border) to ensure it survives morphology operations with area >= 50
mask[2:12, 2:12] = 1 # 10x10 square at (2,2) to (11,11)
bbox = get_mask_bounding_box(mask)
assert bbox is not None
# Should be (2, 2, 10, 10) - x, y, width, height
x, y, w, h = bbox
assert x == 2 and y == 2 and w == 10 and h == 10
# Test calculate_thickness with horizontal bar
masks = {
'fat': np.zeros((14, 14), dtype=np.uint8),
'tendon': np.zeros((14, 14), dtype=np.uint8)
}
# Make a 6-pixel wide horizontal bar at row 6-11 in FAT (class 1)
masks['fat'][6:12, :] = 1
thickness = calculate_thickness(masks, (14, 14), measure_ids=[1]) # fat is class 1 in POST
assert thickness is not None
# Should detect approximately 6 pixels width (allowing for some variation)
assert thickness['thickness_px'] >= 4
# Note: thickness_mm calculation uses the pixel count directly
assert thickness['thickness_mm'] == round(6 * 45.0 / 655.0, 2)
# Test severity module
def test_severity():
from backend.implementation.postprocessing.severity import calculate_severity
import numpy as np
# Test empty masks
result = calculate_severity({}, (100, 100))
assert result is None
# Test low severity
masks = {
'effusion': np.zeros((100, 100), dtype=np.uint8),
'synovium': np.zeros((100, 100), dtype=np.uint8)
}
# Very small effusion: 5 pixels in a column (thickness=5)
masks['effusion'][40:45, 50] = 1
# Very small synovium: 5x5 square = 25 pixels
masks['synovium'][40:45, 40:45] = 1
result = calculate_severity(masks, (100, 100))
# Debug: print values
# print(f"effusion_pixels: {np.sum(masks['effusion'])}")
# print(f"synovium_pixels: {np.sum(masks['synovium'])}")
assert result is not None
# With minimal effusion and synovium, should be low severity (level 1)
assert result['level'] == 1 # Should be mild
# Test high severity
masks['effusion'][:, :] = 1 # Full effusion
masks['synovium'][:, :] = 1 # Full synovium
result = calculate_severity(masks, (100, 100))
assert result['level'] == 3 # Severe
assert result['severity'] == "Nặng"
# Test overlay module
def test_overlay():
from backend.implementation.postprocessing.overlay import create_overlay
from PIL import Image, ImageDraw
import numpy as np
# Create test image
img = Image.new('RGB', (100, 100), color='white')
draw = ImageDraw.Draw(img)
draw.rectangle([20, 20, 80, 80], fill='gray') # Add a gray square
# Create simple masks
masks = {
'background': np.zeros((100, 100), dtype=np.uint8),
'effusion': np.zeros((100, 100), dtype=np.uint8),
'fat': np.zeros((100, 100), dtype=np.uint8)
}
# Make a red blob in the center
masks['effusion'][40:60, 40:60] = 1
# Test without measurement
overlay = create_overlay(img, masks, None, angle_type='sup')
assert overlay.size == img.size
assert overlay.mode == 'RGB'
# Test with measurement
measurement = {
'x': 50,
'y_start': 30,
'y_end': 70,
'thickness_mm': 2.5,
'roi_start': 20,
'roi_end': 80,
'bbox': {'x': 10, 'y': 10, 'w': 80, 'h': 80}
}
overlay_with_meas = create_overlay(img, masks, measurement, angle_type='sup')
assert overlay_with_meas.size == img.size
# Test CLAHE module (requires cv2)
def test_clahe():
pytest.importorskip("cv2")
from backend.implementation.preprocessing.clahe import apply_clahe
from PIL import Image
import numpy as np
# Create test image with low contrast
img = Image.new('RGB', (50, 50), color=(128, 128, 128))
# Add some variation
pixels = []
for y in range(50):
for x in range(50):
v = 128 + int(20 * np.sin(x/10.0) * np.cos(y/10.0))
pixels.append((v, v, v))
img.putdata(pixels)
# Apply CLAHE
enhanced = apply_clahe(img)
assert enhanced.size == img.size
assert enhanced.mode == 'RGB'
# Enhanced image should have different pixel values (not identical)
orig_arr = np.array(img)
enh_arr = np.array(enhanced)
# Not exactly equal due to CLAHE processing
assert not np.array_equal(orig_arr, enh_arr)
# Test calibration module
def test_calibration():
from backend.implementation.postprocessing.calibration import (
CalibrationConfig,
interpret_angle_logits,
interpret_inflammation_logits,
normalized_entropy,
temperature_scaled_softmax,
)
import numpy as np
logits = np.array([3.0, 1.0, 0.5, 0.2], dtype=np.float32)
result = interpret_angle_logits(logits)
assert result["class"] == "med-lat"
assert "calibration" in result
cal = result["calibration"]
assert len(cal["raw_logits"]) == 4
assert len(cal["class_probabilities"]) == 4
assert cal["class_probabilities"]["med-lat"] > cal["class_probabilities"]["post-trans"]
assert 0 <= cal["normalized_entropy"] <= 1
assert cal["decision_state"] in ("confident", "ambiguous", "ood_warning")
flat = interpret_angle_logits(np.array([0.1, 0.1, 0.1, 0.1]))
assert flat["calibration"]["normalized_entropy"] > 0.9
assert flat["calibration"]["decision_state"] == "ood_warning"
screening = interpret_angle_logits(
logits,
CalibrationConfig(temperature=2.2),
)
aggressive = interpret_angle_logits(
logits,
CalibrationConfig(temperature=0.7),
)
aggressive_probs = aggressive["calibration"]["class_probabilities"]
screening_probs = screening["calibration"]["class_probabilities"]
assert aggressive_probs["med-lat"] > screening_probs["med-lat"]
inflam = interpret_inflammation_logits(np.array([-1.0, 2.0]))
assert inflam["detected"] is True
assert inflam["calibration"]["class_probabilities"]["inflammation"] > 50
probs = temperature_scaled_softmax(logits, 1.0)
assert abs(float(np.sum(probs)) - 1.0) < 1e-5
assert normalized_entropy(probs) < normalized_entropy(np.full(4, 0.25))
def test_triton_batch_chunking():
from backend.implementation.triton_batch import (
TRITON_MAX_BATCH_SIZE,
batch_count,
chunk_sequence,
)
assert TRITON_MAX_BATCH_SIZE == 8
assert batch_count(0) == 0
assert batch_count(4) == 1
assert batch_count(8) == 1
assert batch_count(10) == 2
assert batch_count(11) == 2
assert batch_count(16) == 2
assert batch_count(17) == 3
chunks = list(chunk_sequence(list(range(10))))
assert len(chunks) == 2
assert chunks[0] == list(range(8))
assert chunks[1] == [8, 9]
if __name__ == "__main__":
pytest.main([__file__, "-v"])

View File

@@ -0,0 +1,87 @@
import asyncio
from backend.implementation.adapters.triton_adapter import TritonAdapter
from infra.tests.test_1_model import preprocess_224, preprocess_512, load_image
from pathlib import Path
inference_server = "https://dtj-tran--triton-s3-service-unified-triton-server.modal.run"
endpoint_url = f"{inference_server}"
adapter = TritonAdapter(endpoint_url=endpoint_url)
test_img_path = "/Users/davestran/Downloads/vkist_internship/PILOT_PROJECT/workspace/sprint_1_2/CODEBASE/infra/tests/test_images/other_angle/med_lat_2.png"
async def main():
img = load_image(Path(test_img_path))
preprocessed_img_224 = preprocess_224(img)
# Test 4: list_models
models = await adapter.list_models()
assert isinstance(models, list)
print(f"[OK] list_models count={len(models)}")
# Test 3: model_ready
ready = await adapter.model_ready("angle_classify_convnext_tiny")
print(f"[OK] model_ready={ready}")
# Test 3: model_ready
ready = await adapter.model_ready("msk_vision_pipeline_ensemble")
print(f"[OK] model_ready={ready}")
# Test 1: single model, infer without output filter
result = await adapter.infer(
model_name="angle_classify_convnext_tiny",
inputs={
"input_image": {
"data": preprocessed_img_224.tolist(),
"shape": list(preprocessed_img_224.shape),
"datatype": "FP32",
}
},
)
assert isinstance(result, dict), f"Expected dict, got {type(result)}"
assert "logits" in result, f"Expected 'logits' key, got keys: {list(result.keys())}"
assert isinstance(result["logits"], list), "logits should be a list"
print(f"[OK] single model: logits={result['logits']}")
preprocessed_img_512 = preprocess_512(img)
# Test 2: ensemble with all outputs (Triton ensemble requires all outputs to avoid deadlock)
result2 = await adapter.infer(
model_name="msk_vision_pipeline_ensemble",
inputs={
"input_224": {
"data": preprocessed_img_224.tolist(),
"shape": list(preprocessed_img_224.shape),
"datatype": "FP32",
},
"input_512": {
"data": preprocessed_img_512.tolist(),
"shape": list(preprocessed_img_512.shape),
"datatype": "FP32",
},
},
outputs=[
"angle_classify_convnext_tiny_logits",
"angle_classify_resnet50_logits",
"angle_classify_swin_v2_s_logits",
"angle_classify_densenet_logits",
"angle_classify_efficientnet_logits",
"inflammation_model_efficientnet_b0_ultrasound_2_cls_logits",
"segmentation_model_unet_resnet101_logits",
"segmentation_model_unet3plus_att_logits",
"segmentation_model_post_deeplabv3_resnet101_logits",
"segmentation_model_post_deeplabv3_logits",
"segmentation_model_post_efficientfeedback_logits",
],
)
assert "angle_classify_convnext_tiny_logits" in result2
assert "segmentation_model_unet_resnet101_logits" in result2
print(f"[OK] ensemble: {list(result2.keys())}")
for elements in result2:
print(elements, ":", result2[elements].shape)
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1,115 @@
# Dependencies, Orchestration, and Integration — Sprint 1_2
## Data Engineering Alignment
### Storage Strategy
- **Structured metadata**: PostgreSQL (aligned with backend modules)
- **Artifacts** (DICOM, images, masks, overlays, models): S3-compatible bucket (MinIO)
- **Naming convention**: UUIDs only — no PHI in filenames, keys, or URLs
- **Access**: Presigned URLs for temporary access
### Canonical JSON Schemas
All serialized domain objects must validate against canonical schemas defined in `data/schemas/`. Key schemas:
- `session.schema.json`
- `frame.schema.json`
- `prediction.schema.json`
- `measurement.schema.json`
- `audit.schema.json`
### Model Output Normalization
All model adapters must normalize outputs to canonical labels.
**Segmentation classes:**
- `background`
- `effusion`
- `fat`
- `fat-pat`
- `femur`
- `synovium`
- `tendon`
**Angle classes:**
- `med-lat`
- `post-trans`
- `sup-trans-flex`
- `sup-up-long`
**Severity grades:**
- 0: Rất nhẹ
- 1: Nhẹ
- 2: Trung bình
- 3: Nặng
---
## Orchestrators and Use Cases
Orchestrators coordinate the workflow by sequencing agents and enforcing state machines.
### Key Use Cases
1. **Upload and Ingest**
- Input: multipart DICOM or image upload
- Steps: `DICOMIngestAgent` / `ImageUploadIngestAgent``FrameStorageAdapter`
- Output: `DiagnosticSession`, `ScanFrame`, `ImageAsset`
2. **Run Analysis Pipeline**
- Input: `DiagnosticSession`
- Steps: `VisionPipelineAgent``InferenceRunner``MeasurementAgent``SeverityScorerAgent`
- Output: `AnalysisJob` with completed results
3. **Review and Finalize**
- Input: Clinician review data
- Steps: `LedgerWriterAgent``ReviewDecision`
- Output: Updated session state
---
## Integration with Backend Architecture
This OOP design maps to the backend specification modules:
| OOP Layer | Backend Module |
|-----------|---------------|
| Orchestrators & APIs | `api/` routers (session_api, analysis_api, etc.) |
| Agents/Services | `implementation/` services |
| Adapters | `implementation/` adapters |
| Domain Objects | ORM models (PostgreSQL) + S3 references |
| Orchestration | `implementation/analysis_jobs/service.py` (async jobs) |
---
## Validation and Testing
### Structural Validation
- All candidate objects must map to either a PostgreSQL table or an S3 artifact reference.
- No object may contain PHI fields that bypass scrubbing.
### Behavioral Validation
- Adapter interfaces must support both mock and real implementations.
- Agents must be stateless and idempotent where possible.
### End-to-End Flow
```
image/DICOM upload → secure local ingest → frame extraction → preprocessing → model inference → structured metrics/mask → API result → browser mask preview
```
---
## ObjectObject and ObjectService Relationship Summary
- `ClinicianUser` owns `DiagnosticSession` and authors `ReviewDecision`
- `PatientCase` groups many `DiagnosticSession` records
- `DiagnosticSession` contains `ScanFrame`s, spawns `AnalysisJob`s, and tracks `Calibration` and `ReviewDecision` records
- `AnalysisJob` consists of `PipelineStep`s and produces prediction, mask, measurement, and grade objects
- `ScanFrame` becomes a `PreprocessedImage` via `FramePreprocessor`
- `ImageAsset` stores the raw binary artifact for a `ScanFrame`
- `ArtifactReference` can point to any `ScanFrame` or mask/overlay S3 object
- `LedgerWriterAgent` writes `AuditLedgerEntry` for all state changes
## AgentAdapter Dependencies
- `DICOMIngestAgent` and `ImageUploadIngestAgent``FrameStorageAdapter`
- `ArtifactStoreAgent``FrameStorageAdapter` and `ArtifactStorageAdapter`
- `InferenceRunner``InferenceAdapter` (PyTorch, Triton, or Mock)
- `ModelRegistryAgent``ArtifactStorageAdapter`

View File

@@ -0,0 +1,393 @@
# Object Specifications — Sprint 1_2
## Overview
Domain objects represent **persistable clinical and analysis facts**. They are pure data structures with minimal behavior, focused on encapsulating business rules and state. They are persisted via PostgreSQL (structured metadata) and S3-compatible storage (artifacts).
## OOP Boundary
```
Domain objects = persistable clinical/analysis facts.
Agents/services = runtime workers that transform facts.
Orchestrators = coordinate use cases and enforce workflow state.
Adapters = hide PyTorch, filesystem, image, and API details.
```
## Layer Architecture
```
┌─────────────────────────────────────────────────────────────┐
│ API Layer (FastAPI) │
├─────────────────────────────────────────────────────────────┤
│ Orchestrators (Use Cases) │
├─────────────────────────────────────────────────────────────┤
│ Agents & Services (Workers) │
├─────────────────────────────────────────────────────────────┤
│ Domain Objects │
├─────────────────────────────────────────────────────────────┤
│ Adapters │
└─────────────────────────────────────────────────────────────┘
```
---
## Domain Objects
### ClinicianUser
Represents an authenticated medical professional.
**Fields:**
- `user_id`: UUID / primary key
- `username`: str
- `hashed_password`: str
- `name`: str
- `role`: str (e.g., "radiologist", "support")
- `credentials`: dict | None
- `specialization`: str
- `created_at`: datetime
- `last_login`: datetime | None
**Relationships:**
- owns many `DiagnosticSession`s
- author of many `ReviewDecision`s
**Responsibilities:**
- Authentication (via `AuthModule`)
- Session ownership
- Review and sign decisions
---
### 2. PatientCase
Represents a patient's overall medical case.
**Fields:**
- `case_id`: UUID
- `patient_identifier`: str (hashed / pseudonymized)
- `demographic_info`: dict
- `medical_history_summary`: dict
- `created_by`: `ClinicianUser`
- `created_at`: datetime
**Relationships:**
- has many `DiagnosticSession`s
**Responsibilities:**
- Case registration and tracking
- Session grouping
---
### 3. DiagnosticSession
Represents a single ultrasound examination session.
**Fields:**
- `session_id`: UUID
- `case_id`: ForeignKey → PatientCase
- `clinician_id`: ForeignKey → ClinicianUser
- `status`: str (e.g., "created", "uploaded", "in_progress", "completed", "reviewed")
- `created_at`: datetime
- `updated_at`: datetime
**Relationships:**
- belongs to `PatientCase`
- belongs to `ClinicianUser`
- has many `ScanFrame`s
- has many `AnalysisJob`s
- has many `ReviewDecision`s
**Responsibilities:**
- Session lifecycle management
- Frame and job grouping
- Review state enforcement
---
### 4. ScanFrame
Represents a single ultrasound image frame extracted from DICOM or standard image upload.
**Fields:**
- `frame_id`: UUID
- `session_id`: ForeignKey → DiagnosticSession
- `storage_reference`: str (S3 key)
- `original_format`: str (e.g., "dicom", "png", "jpeg")
- `frame_number`: int | None
- `metadata`: dict (DICOM tags, image dimensions, etc.)
- `checksum`: str (SHA-256)
- `created_at`: datetime
**Relationships:**
- belongs to `DiagnosticSession`
- has one `ImageAsset` (the raw artifact)
- has one `PreprocessedImage`
**Responsibilities:**
- Frame metadata capture
- PHI-safe storage reference management
---
### 5. ImageAsset
Represents the raw storage artifact for a frame.
**Fields:**
- `asset_id`: UUID
- `frame_id`: ForeignKey → ScanFrame
- `storage_key`: str (S3 / MinIO key, UUID-based, no PHI)
- `content_type`: str
- `size_bytes`: int
- `checksum`: str (SHA-256)
- `uploaded_at`: datetime
**Responsibilities:**
- Binary artifact storage reference
- Integrity verification
---
### 6. Calibration
Device-specific calibration parameters for a session.
**Fields:**
- `calibration_id`: UUID
- `session_id`: ForeignKey → DiagnosticSession
- `pixel_to_mm_ratio`: float
- `parameters`: dict
- `recorded_at`: datetime
**Responsibilities:**
- Measurement calibration
- ROI metric scaling
---
### 7. AnalysisJob
Request for AI/ML analysis on session frame(s).
**Fields:**
- `job_id`: UUID
- `session_id`: ForeignKey → DiagnosticSession
- `parameters`: dict (e.g., selected models, flags)
- `model_versions`: dict (task → model_id + version)
- `status`: str (e.g., "pending", "running", "completed", "failed")
- `result`: dict | None
- `created_at`: datetime
- `updated_at`: datetime
**Relationships:**
- belongs to `DiagnosticSession`
- has many `PipelineStep`s
- produces angle, inflammation, segmentation, measurement, and grade results
**Responsibilities:**
- Async job orchestration
- Result aggregation
---
### 8. PipelineStep
Single step in the analysis pipeline.
**Fields:**
- `step_id`: UUID
- `job_id`: ForeignKey → AnalysisJob
- `task_type`: str (e.g., "angle_classification", "inflammation_detection", "segmentation_sup", "segmentation_post", "measurement", "severity_scoring")
- `status`: str
- `output`: dict | None
- `duration_ms`: int | None
- `started_at`: datetime | None
- `completed_at`: datetime | None
**Responsibilities:**
- Step-level progress tracking
- Error isolation
---
### 9. ModelRegistryEntry
Metadata record for a registered ML model.
**Fields:**
- `model_id`: str
- `name`: str
- `task_type`: str
- `version`: str
- `description`: str
- `framework`: str (e.g., "pytorch", "onnx")
- `labels`: list[str]
- `registered_at`: datetime
- `is_active`: bool
**Responsibilities:**
- Model discovery and selection
- Version tracking
---
### 10. ModelArtifact
Actual stored ML model artifact.
**Fields:**
- `artifact_id`: UUID
- `model_id`: ForeignKey → ModelRegistryEntry
- `storage_key`: str (S3 key, UUID-based)
- `format`: str (e.g., ".pth", ".onnx")
- `size_bytes`: int
- `checksum`: str
- `uploaded_at`: datetime
**Responsibilities:**
- Secure storage of model weights
- Integrity verification
---
### 11. PreprocessedImage
Frame after preprocessing transformations.
**Fields:**
- `preprocessed_id`: UUID
- `frame_id`: ForeignKey → ScanFrame
- `preprocessing_steps`: list[str]
- `storage_reference`: str (S3 key, or inline base64 for small artifacts)
- `width`: int
- `height`: int
- `created_at`: datetime
**Responsibilities:**
- Intermediate processing artifact management
---
### 12. AnglePrediction
Output of the angle classification model.
**Fields:**
- `prediction_id`: UUID
- `job_id`: ForeignKey → AnalysisJob
- `step_id`: ForeignKey → PipelineStep
- `angle_class`: str (e.g., "med-lat", "post-trans", "sup-trans-flex", "sup-up-long")
- `confidence`: float
- `metadata`: dict
**Responsibilities:**
- Classification result encapsulation
---
### 13. InflammationPrediction
Output of the inflammation detection model.
**Fields:**
- `prediction_id`: UUID
- `job_id`: ForeignKey → AnalysisJob
- `step_id`: ForeignKey → PipelineStep
- `detected`: bool
- `confidence`: float
**Responsibilities:**
- Binary detection result encapsulation
---
### 14. SegmentationMask
Output of the segmentation model.
**Fields:**
- `mask_id`: UUID
- `job_id`: ForeignKey → AnalysisJob
- `step_id`: ForeignKey → PipelineStep
- `storage_reference`: str (S3 key)
- `overlay_reference`: str (S3 key)
- `color_legend`: dict (class → color)
- `metadata`: dict
**Responsibilities:**
- Segmentation result storage and retrieval
---
### 15. Measurement
Quantitative measurement derived from a segmentation mask.
**Fields:**
- `measurement_id`: UUID
- `job_id`: ForeignKey → AnalysisJob
- `step_id`: ForeignKey → PipelineStep
- `thickness_mm`: float | None
- `pixel_to_mm_ratio`: float
- `roi_specification`: dict (e.g., bounding box, region)
- `created_at`: datetime
**Responsibilities:**
- Measurement calculation and storage
---
### 16. SynovitisGrade
Final severity grade (03) for synovitis.
**Fields:**
- `grade_id`: UUID
- `job_id`: ForeignKey → AnalysisJob
- `step_id`: ForeignKey → PipelineStep
- `level`: int (03)
- `label`: str
- `combined_score`: float | None
- `confidence`: float | None
**Responsibilities:**
- Severity scoring encapsulation
---
### 17. ReviewDecision
Clinician's approval, correction, or rejection of AI results.
**Fields:**
- `decision_id`: UUID
- `session_id`: ForeignKey → DiagnosticSession
- `job_id`: ForeignKey → AnalysisJob
- `reviewer_id`: ForeignKey → ClinicianUser
- `decision_type`: str ("approve", "correct", "reject")
- `justification`: str | None
- `created_at`: datetime
**Responsibilities:**
- HITL decision capture
- Review audit trail
---
### 18. ArtifactReference
Polymorphic reference to any stored artifact.
**Fields:**
- `reference_id`: UUID
- `artifact_type`: str
- `associated_entity_id`: UUID
- `storage_key`: str (S3 key)
- `content_type`: str
- `created_at`: datetime
**Responsibilities:**
- Unified artifact reference management
---
### 19. AuditLedgerEntry
Immutable audit trail entry for any significant event.
**Fields:**
- `entry_id`: UUID
- `entity_type`: str
- `entity_id`: UUID
- `action`: str
- `user_id`: UUID | None
- `checksum`: str (SHA-256 of the event payload)
- `metadata`: dict
- `timestamp`: datetime
**Responsibilities:**
- Immutable audit trail
- Compliance (Decree 13 / Circular 46)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,136 @@
# Services, Agents, and Adapters — Sprint 1_2
Agents and services are the **runtime workers** that transform domain objects. Each agent has a single, focused responsibility and collaborates via well-defined interfaces.
## Ingestion Agents
### DICOMIngestAgent
- **Responsibility:** Parse and validate DICOM files, extract metadata and renderable frames.
- **Input:** `UploadFile` (DICOM bytes)
- **Output:** `ScanFrame`, `ImageAsset`
- **Collaborators:** `FrameStorageAdapter` (S3)
### ImageUploadIngestAgent
- **Responsibility:** Handle standard image uploads (JPEG, PNG, etc.).
- **Input:** `UploadFile` (image bytes)
- **Output:** `ScanFrame`, `ImageAsset`
- **Collaborators:** `FrameStorageAdapter`
## Preprocessing and Validation Agents
### FramePreprocessor
- **Responsibility:** Apply preprocessing transformations (CLAHE, resizing, normalization).
- **Input:** `ScanFrame`
- **Output:** `PreprocessedImage`
- **Collaborators:** Image libraries via adapter
### AngleValidatorAgent
- **Responsibility:** Validate angle classification results against clinical rules.
- **Input:** `AnglePrediction`
- **Output:** `AnglePrediction` (possibly adjusted confidence)
- **Collaborators:** Clinical rule engine
### ROICropperAgent
- **Responsibility:** Extract regions of interest for specialized models.
- **Input:** `PreprocessedImage`
- **Output:** Cropped image segments
- **Collaborators:** Frame storage, preprocessing
## Core Analysis Agents
### VisionPipelineAgent
- **Responsibility:** Orchestrate the end-to-end vision inference pipeline for a session.
- **Input:** `DiagnosticSession`, list of `ScanFrame`s
- **Output:** `AnalysisJob` with completed `PipelineStep`s and results
- **Collaborators:** `InferenceRunner`, `MeasurementAgent`, `SeverityScorerAgent`, `ModelRegistryAgent`
### InferenceRunner
- **Responsibility:** Execute ML model inference via adapters (PyTorch, Triton, or Mock).
- **Input:** `ModelReference` (id + version), `ProcessedImage` data
- **Output:** Raw prediction payloads
- **Collaborators:** `PyTorchAdapter`, `TritonAdapter`, `MockAdapter`
### MeasurementAgent
- **Responsibility:** Calculate quantitative measurements from `SegmentationMask` using `Calibration`.
- **Input:** `SegmentationMask`, `Calibration`
- **Output:** `Measurement`
- **Collaborators:** Calibration service, segmentation model geometry
### SeverityScorerAgent
- **Responsibility:** Compute synovitis grade (03) from effusion and synovium measurements and inflammation prediction.
- **Input:** `Measurement`, `InflammationPrediction`
- **Output:** `SynovitisGrade`
- **Collaborators:** Clinical scoring rules
## Management Agents
### ModelRegistryAgent
- **Responsibility:** Manage model registration, versioning, and availability checks.
- **Input:** `ModelRegistryEntry` data, `ModelArtifact` binaries
- **Output:** `ModelRegistryEntry`, `ModelArtifact`
- **Collaborators:** `ArtifactStoreAgent`, database persistence
### ArtifactStoreAgent
- **Responsibility:** Store and retrieve large artifacts via S3-compatible storage.
- **Input:** Binary data, storage key
- **Output:** Storage confirmation, presigned URLs or S3 references
- **Collaborators:** `FrameStorageAdapter`, S3 / MinIO
### LedgerWriterAgent
- **Responsibility:** Write immutable `AuditLedgerEntry` records for state changes.
- **Input:** Audit event payloads
- **Output:** `AuditLedgerEntry`
- **Collaborators:** PostgreSQL persistence
---
## Adapter Interfaces
Adapters encapsulate external system details and provide a uniform internal interface.
### Storage Adapters
```python
class FrameStorageAdapter(ABC):
@abstractmethod
def store_frame(self, frame_id: UUID, data: bytes, content_type: str) -> str:
"""Returns S3 storage key"""
pass
@abstractmethod
def generate_presigned_url(self, storage_key: str, expires_in: int) -> str:
pass
@abstractmethod
def delete_frame(self, storage_key: str) -> None:
pass
```
```python
class ArtifactStorageAdapter(ABC):
@abstractmethod
def store_artifact(self, artifact_id: UUID, data: bytes, content_type: str) -> str:
pass
@abstractmethod
def retrieve_artifact(self, storage_key: str) -> bytes:
pass
```
### ML Inference Adapters
```python
class InferenceAdapter(ABC):
@abstractmethod
def load_model(self, model_reference: str) -> None:
pass
@abstractmethod
def infer(self, input_data: ProcessedImage) -> dict:
"""Returns standardized prediction dict"""
pass
@abstractmethod
def unload_model(self, model_reference: str) -> None:
pass
```

View File

@@ -0,0 +1,501 @@
# Visualization — Sprint 1_2 Class and Architecture Diagrams
## Layer Architecture
```
┌─────────────────────────────────────────────────────────────┐
│ API Layer (FastAPI) │
├─────────────────────────────────────────────────────────────┤
│ Orchestrators (Use Cases) │
├─────────────────────────────────────────────────────────────┤
│ Agents & Services (Workers) │
├─────────────────────────────────────────────────────────────┤
│ Domain Objects │
├─────────────────────────────────────────────────────────────┤
│ Adapters │
└─────────────────────────────────────────────────────────────┘
```
## Full Class Diagram
```plantuml
@startuml Sprint 1_2 OOP Class Diagram
skinparam classAttributeAlignment left
skinparam classFontSize 11
skinparam backgroundColor #FEFEFF
skinparam handwritten false
package "Domain Objects" {
class ClinicianUser {
-user_id: UUID
-username: str
-hashed_password: str
-name: str
-role: str
-credentials: dict | None
-specialization: str
-created_at: datetime
-last_login: datetime | None
--
+authenticate(password: str): bool
+owns_sessions(): List[DiagnosticSession]
+creates_review(decision: ReviewDecision): ReviewDecision
}
class PatientCase {
-case_id: UUID
-patient_identifier: str
-demographic_info: dict
-medical_history_summary: dict
-created_at: datetime
--
+add_session(session: DiagnosticSession): void
+list_sessions(): List[DiagnosticSession]
}
class DiagnosticSession {
-session_id: UUID
-case_id: UUID
-clinician_id: UUID
-status: str
-created_at: datetime
-updated_at: datetime
--
+add_frame(frame: ScanFrame): void
+add_job(job: AnalysisJob): void
+add_review(decision: ReviewDecision): void
+can_upload(): bool
+can_analyze(): bool
+can_review(): bool
}
class ScanFrame {
-frame_id: UUID
-session_id: UUID
-storage_reference: str
-original_format: str
-frame_number: int | None
-metadata: dict
-checksum: str
-created_at: datetime
--
+get_image_data(): bytes
+get_metadata(): dict
+has_preprocessed(): bool
+calculate_checksum(): str
}
class ImageAsset {
-asset_id: UUID
-frame_id: UUID
-storage_key: str
-content_type: str
-size_bytes: int
-checksum: str
-uploaded_at: datetime
--
+get_storage_key(): str
+verify_checksum(): bool
}
class Calibration {
-calibration_id: UUID
-session_id: UUID
-pixel_to_mm_ratio: float
-parameters: dict
-recorded_at: datetime
--
+scale_pixels_to_mm(pixels: float): float
+get_parameters(): dict
}
class AnalysisJob {
-job_id: UUID
-session_id: UUID
-parameters: dict
-model_versions: dict
-status: str
-result: dict | None
-created_at: datetime
-updated_at: datetime
--
+add_step(step: PipelineStep): void
+set_status(status: str): void
+set_result(result: dict): void
+is_running(): bool
+is_completed(): bool
+is_failed(): bool
+get_steps(): List[PipelineStep]
}
class PipelineStep {
-step_id: UUID
-job_id: UUID
-task_type: str
-status: str
-output: dict | None
-duration_ms: int | None
-started_at: datetime | None
-completed_at: datetime | None
--
+start(model: ModelReference): void
+complete(output: dict): void
+fail(error: str): void
+get_duration(): int | None
}
class ModelRegistryEntry {
-model_id: str
-name: str
-task_type: str
-version: str
-description: str
-framework: str
-labels: list[str]
-registered_at: datetime
-is_active: bool
--
+get_labels(): list[str]
+is_compatible_with(task: str): bool
+activate(): void
+deactivate(): void
}
class ModelArtifact {
-artifact_id: UUID
-model_id: str
-storage_key: str
-format: str
-size_bytes: int
-checksum: str
-uploaded_at: datetime
--
+get_model_file(): bytes
+verify_checksum(): bool
+get_format(): str
}
class PreprocessedImage {
-preprocessed_id: UUID
-frame_id: UUID
-preprocessing_steps: list[str]
-storage_reference: str
-width: int
-height: int
-created_at: datetime
--
+get_image_data(): bytes
+get_dimensions(): tuple[int, int]
+applied_steps(): list[str]
}
class AnglePrediction {
-prediction_id: UUID
-job_id: UUID
-step_id: UUID
-angle_class: str
-confidence: float
-metadata: dict
--
+get_class(): str
+get_confidence(): float
+is_confident(threshold: float): bool
}
class InflammationPrediction {
-prediction_id: UUID
-job_id: UUID
-step_id: UUID
-detected: bool
-confidence: float
--
+is_detected(): bool
+get_confidence(): float
}
class SegmentationMask {
-mask_id: UUID
-job_id: UUID
-step_id: UUID
-storage_reference: str
-overlay_reference: str
-color_legend: dict
-metadata: dict
--
+get_mask_data(): bytes
+get_overlay_reference(): str
+get_color_legend(): dict
+get_classes(): list[str]
}
class Measurement {
-measurement_id: UUID
-job_id: UUID
-step_id: UUID
-thickness_mm: float | None
-pixel_to_mm_ratio: float
-roi_specification: dict
-created_at: datetime
--
+get_thickness_mm(): float | None
+get_pixel_to_mm_ratio(): float
+calculate_area(pixels: int): float
+get_roi_specification(): dict
}
class SynovitisGrade {
-grade_id: UUID
-job_id: UUID
-step_id: UUID
-level: int
-label: str
-combined_score: float | None
-confidence: float | None
--
+get_level(): int
+get_label(): str
+get_combined_score(): float | None
+get_confidence(): float | None
+is_severe(): bool
}
class ReviewDecision {
-decision_id: UUID
-session_id: UUID
-job_id: UUID
-reviewer_id: UUID
-decision_type: str
-justification: str | None
-created_at: datetime
--
+is_approved(): bool
+is_corrected(): bool
+is_rejected(): bool
+get_justification(): str | None
}
class ArtifactReference {
-reference_id: UUID
-artifact_type: str
-associated_entity_id: UUID
-storage_key: str
-content_type: str
-created_at: datetime
--
+get_storage_key(): str
+get_content_type(): str
+get_entity_id(): UUID
}
class AuditLedgerEntry {
-entry_id: UUID
-entity_type: str
-entity_id: UUID
-action: str
-user_id: UUID | None
-checksum: str
-metadata: dict
-timestamp: datetime
--
+get_action(): str
+get_entity(): str
+verify_checksum(payload: dict): bool
+to_immutable(): AuditLedgerEntry
}
}
package "Agents / Services" {
class DICOMIngestAgent {
+ingest(source: UploadFile): ScanFrame
+validate_dicom(data: bytes): bool
+extract_metadata(data: bytes): dict
+extract_frames(data: bytes): List[bytes]
}
class ImageUploadIngestAgent {
+ingest(source: UploadFile): ScanFrame
+validate_image(data: bytes): bool
+extract_metadata(data: bytes): dict
}
class FramePreprocessor {
+preprocess(frame: ScanFrame): PreprocessedImage
+apply_clahe(image: bytes): bytes
+normalize(image: bytes): bytes
+resize(image: bytes, size: tuple[int, int]): bytes
}
class AngleValidatorAgent {
+validate(prediction: AnglePrediction): AnglePrediction
+adjust_confidence(prediction: AnglePrediction, adjustment: float): AnglePrediction
+check_clinical_rules(angle_class: str): bool
}
class ROICropperAgent {
+crop_for_inflammation(image: PreprocessedImage): PreprocessedImage
+crop_for_segmentation(image: PreprocessedImage, angle: str): PreprocessedImage
+extract_bounding_box(image: bytes): dict
}
class VisionPipelineAgent {
+run_pipeline(session: DiagnosticSession, frames: List[ScanFrame]): AnalysisJob
+coordinate_models(frames: List[ScanFrame], models: dict): dict
+should_apply_inflammation(angle: str): bool
+should_apply_segmentation(angle: str): bool
}
class InferenceRunner {
+infer(model: ModelReference, image: bytes): dict
+load_model(model_id: str, version: str): void
+unload_model(model_id: str): void
+get_model_status(model_id: str): str
}
class MeasurementAgent {
+measure(mask: SegmentationMask, calibration: Calibration): Measurement
+calculate_thickness(mask: bytes, ratio: float): float
+calculate_roi(mask: bytes): dict
+validate_measurement(measurement: Measurement): bool
}
class SeverityScorerAgent {
+score(measurement: Measurement, inflammation: InflammationPrediction): SynovitisGrade
+calculate_combined_score(thickness: float, detected: bool): float
+get_grade_label(score: float): str
+validate_grade(grade: SynovitisGrade): bool
}
class ModelRegistryAgent {
+register_model(entry: ModelRegistryEntry, artifact: ModelArtifact): ModelRegistryEntry
+get_model(task: str, version: str = "latest"): ModelReference
+list_models(): List[ModelRegistryEntry]
+activate_model(model_id: str): void
+deactivate_model(model_id: str): void
+verify_artifact(model_id: str, checksum: str): bool
}
class ArtifactStoreAgent {
+store_artifact(artifact_id: UUID, data: bytes, content_type: str): str
+retrieve_artifact(storage_key: str): bytes
+delete_artifact(storage_key: str): void
+generate_presigned_url(storage_key: str, expires_in: int = 3600): str
+verify_integrity(storage_key: str, checksum: str): bool
}
class LedgerWriterAgent {
+write(event_type: str, entity_type: str, entity_id: UUID, payload: dict, user_id: UUID | None = None): AuditLedgerEntry
+verify_integrity(entry: AuditLedgerEntry): bool
+query_by_entity(entity_type: str, entity_id: UUID): List[AuditLedgerEntry]
}
}
package "Adapters" {
class FrameStorageAdapter {
{abstract} +store_frame(frame_id: UUID, data: bytes, content_type: str) -> str
{abstract} +generate_presigned_url(storage_key: str, expires_in: int) -> str
{abstract} +delete_frame(storage_key: str) -> None
}
class ArtifactStorageAdapter {
{abstract} +store_artifact(artifact_id: UUID, data: bytes, content_type: str) -> str
{abstract} +retrieve_artifact(storage_key: str) -> bytes
}
class InferenceAdapter {
{abstract} +load_model(model_reference: str) -> None
{abstract} +infer(input_data: bytes) -> dict
{abstract} +unload_model(model_reference: str) -> None
}
class PyTorchAdapter {
+load_model(model_reference: str) -> None
+infer(input_data: bytes) -> dict
+unload_model(model_reference: str) -> None
}
class TritonAdapter {
+load_model(model_reference: str) -> None
+infer(input_data: bytes) -> dict
+unload_model(model_reference: str) -> None
}
class MockAdapter {
+load_model(model_reference: str) -> None
+infer(input_data: bytes) -> dict
+unload_model(model_reference: str) -> None
}
}
' Relationships: Domain Objects
PatientCase "1" --> "*" DiagnosticSession : has
DiagnosticSession "1" --> "*" ScanFrame : contains
DiagnosticSession "1" --> "*" AnalysisJob : initiated
DiagnosticSession "1" --> "*" ReviewDecision : reviewed_by
DiagnosticSession "1" --> "*" Calibration : has
DiagnosticSession "*" --> "1" ClinicianUser : conducted_by
ScanFrame "1" --> "1" ImageAsset : stored_as
ScanFrame "1" --> "1" PreprocessedImage : becomes
AnalysisJob "1" --> "*" PipelineStep : consists_of
AnalysisJob "1" --> "*" AnglePrediction : produces
AnalysisJob "1" --> "*" InflammationPrediction : produces
AnalysisJob "1" --> "*" SegmentationMask : produces
AnalysisJob "1" --> "*" Measurement : produces
AnalysisJob "1" --> "*" SynovitisGrade : produces
ModelRegistryEntry "1" --> "*" ModelArtifact : has
PipelineStep "*" --> "1" ModelRegistryEntry : uses
ArtifactReference "1" --> "1" ScanFrame : references
' Relationships: Agents depend on Adapters
DICOMIngestAgent --> FrameStorageAdapter : uses
ImageUploadIngestAgent --> FrameStorageAdapter : uses
ArtifactStoreAgent --> FrameStorageAdapter : uses
ArtifactStoreAgent --> ArtifactStorageAdapter : uses
InferenceRunner --> InferenceAdapter : uses
ModelRegistryAgent --> ArtifactStorageAdapter : uses
' Relationships: Agents operate on Domain Objects
DICOMIngestAgent --> ScanFrame : creates
DICOMIngestAgent --> ImageAsset : creates
ImageUploadIngestAgent --> ScanFrame : creates
ImageUploadIngestAgent --> ImageAsset : creates
FramePreprocessor --> ScanFrame : reads
FramePreprocessor --> PreprocessedImage : creates
AngleValidatorAgent --> AnglePrediction : validates
ROICropperAgent --> PreprocessedImage : modifies
VisionPipelineAgent --> AnalysisJob : orchestrates
InferenceRunner --> AnalysisJob : populates
MeasurementAgent --> SegmentationMask : reads
MeasurementAgent --> Calibration : uses
MeasurementAgent --> Measurement : creates
SeverityScorerAgent --> InflammationPrediction : reads
SeverityScorerAgent --> Measurement : reads
SeverityScorerAgent --> SynovitisGrade : creates
ModelRegistryAgent --> ModelRegistryEntry : manages
ModelRegistryAgent --> ModelArtifact : manages
LedgerWriterAgent --> AuditLedgerEntry : creates
' Relationships: Adapters
PyTorchAdapter ..|> InferenceAdapter
TritonAdapter ..|> InferenceAdapter
MockAdapter ..|> InferenceAdapter
@enduml
```
The diagram above shows:
- **19 Domain Objects** with their attributes, methods, and relationships
- **12 Agents/Services** with their interface methods and collaborators
- **3 Adapter hierarchies** (Storage and Inference) showing abstraction relationships
- **Dependency arrows** showing what objects depend on what adapters or other objects
Key relationships:
- `PatientCase` 1→* `DiagnosticSession` (case has many sessions)
- `DiagnosticSession` 1→* `ScanFrame` (session has many frames)
- `AnalysisJob` 1→* `PipelineStep` (job has many steps)
- All prediction/measurement objects belong to exactly one `AnalysisJob`
- Agents depend on adapters (e.g., `DICOMIngestAgent` uses `FrameStorageAdapter`)
Legend:
- `-->` : Association (uses or references)
- `..|>` : Realization (implements interface)
- Package groupings: Domain Objects, Agents/Services, Adapters

View File

@@ -0,0 +1,89 @@
from .auth_schemas import Token, TokenPayload, LoginRequest, UserProfile, UserUpdateRequest, RefreshRequest
from .patient_schemas import Patient, PatientCreate, PatientListResponse, DemographicInfo
from .session_schemas import (
Session, SessionCreate, SessionDetail, SessionPatchReview,
FrameMetadata, PersistResult, ExportResult, ScrubResult,
)
from .analysis_schemas import (
AnalysisJobSubmit, AnalysisJobSyncSubmit, JobStatus, PipelineStep,
StepEvent, JobResult, ModelRegistryEntry, ModelCatalog,
ModelRegistrationResult,
)
from .telemetry_schemas import CorrectionSubmit, CorrectionRecord, AnomalyReport, AnomalyRecord
from .report_schemas import ReportCreate, ReportSignRequest, ReportSyncEMRRequest, SyncResult
from .safety_schemas import (
GradCAMRequest, HeatmapResult, RationaleRequest, RationaleResult,
CircuitBreakerRequest, ChatStreamRequest, ChatEvent, ChatResponse,
DriftCheckResult, RAGEvidenceRequest, EvidenceList, ActivationMeta,
AnnotationArtifact, GroundTruthLabel, EscalationRequest, EscalationTicket,
MorphologyAnnotation, GuardrailCheckRequest, GuardrailResult,
)
from .notification_schemas import NotificationItem, NotificationPreferences
from .settings_schemas import UserSettings, SettingsUpdate
from .ingestion_schemas import IngestionRecord, RecordDetail
from .common_schemas import HealthStatus, ErrorResponse
__all__ = [
"Token",
"TokenPayload",
"LoginRequest",
"UserProfile",
"UserUpdateRequest",
"RefreshRequest",
"Patient",
"PatientCreate",
"PatientListResponse",
"DemographicInfo",
"Session",
"SessionCreate",
"SessionDetail",
"SessionPatchReview",
"FrameMetadata",
"PersistResult",
"ExportResult",
"ScrubResult",
"AnalysisJobSubmit",
"AnalysisJobSyncSubmit",
"JobStatus",
"PipelineStep",
"StepEvent",
"JobResult",
"ModelRegistryEntry",
"ModelCatalog",
"ModelRegistrationResult",
"CorrectionSubmit",
"CorrectionRecord",
"AnomalyReport",
"AnomalyRecord",
"ReportCreate",
"ReportSignRequest",
"ReportSyncEMRRequest",
"SyncResult",
"GradCAMRequest",
"HeatmapResult",
"RationaleRequest",
"RationaleResult",
"CircuitBreakerRequest",
"ChatStreamRequest",
"ChatEvent",
"ChatResponse",
"DriftCheckResult",
"RAGEvidenceRequest",
"EvidenceList",
"ActivationMeta",
"AnnotationArtifact",
"GroundTruthLabel",
"EscalationRequest",
"EscalationTicket",
"MorphologyAnnotation",
"GuardrailCheckRequest",
"GuardrailResult",
"NotificationItem",
"NotificationPreferences",
"UserSettings",
"SettingsUpdate",
"IngestionRecord",
"RecordDetail",
"HealthStatus",
"ErrorResponse",
]

View File

@@ -0,0 +1,78 @@
from pydantic import BaseModel, Field
from datetime import datetime
from typing import Any
class AnalysisJobSubmit(BaseModel):
session_id: str
params: dict[str, Any] | None = None
model_versions: dict[str, str] | None = None
class AnalysisJobSyncSubmit(BaseModel):
session_id: str
params: dict[str, Any] | None = None
model_versions: dict[str, str] | None = None
class PipelineStep(BaseModel):
step_id: str
job_id: str
task_type: str
status: str
output: dict | None = None
duration_ms: int | None = None
started_at: datetime | None = None
completed_at: datetime | None = None
class JobStatus(BaseModel):
job_id: str
session_id: str
status: str
result: dict | None = None
steps: list[PipelineStep] | None = None
created_at: datetime
updated_at: datetime
class StepEvent(BaseModel):
step_id: str
job_id: str
event_type: str
task_type: str
status: str
data: dict | None = None
timestamp: datetime
class JobResult(BaseModel):
job_id: str
session_id: str
status: str
result: dict | None = None
duration_ms: int | None = None
class ModelRegistryEntry(BaseModel):
model_id: str
name: str
task_type: str
version: str
description: str
framework: str
labels: list[str]
registered_at: datetime
is_active: bool
class ModelCatalog(BaseModel):
models: list[ModelRegistryEntry]
total: int
class ModelRegistrationResult(BaseModel):
model_id: str
status: str
s3_key: str
registered_at: datetime

View File

@@ -0,0 +1,37 @@
from pydantic import BaseModel, EmailStr, Field
class Token(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
class TokenPayload(BaseModel):
sub: str
exp: int
role: str = "clinician"
class LoginRequest(BaseModel):
username: str = Field(..., min_length=3, max_length=50)
password: str = Field(..., min_length=6)
class UserProfile(BaseModel):
user_id: str
username: str
name: str
role: str
credentials: dict | None = None
specialization: str | None = None
class UserUpdateRequest(BaseModel):
name: str | None = None
specialization: str | None = None
credentials: dict | None = None
class RefreshRequest(BaseModel):
refresh_token: str

View File

@@ -0,0 +1,14 @@
from pydantic import BaseModel
from typing import Any
class HealthStatus(BaseModel):
status: str
version: str
dependencies: dict[str, str]
uptime_seconds: float
class ErrorResponse(BaseModel):
detail: str
code: str | None = None

View File

@@ -0,0 +1,22 @@
from pydantic import BaseModel
from datetime import datetime
from typing import Any
class IngestionRecord(BaseModel):
record_id: str
user_id: str
patient_id: str
session_id: str | None = None
filename: str
file_type: str
size_bytes: int
status: str
created_at: datetime
metadata: dict | None = None
class RecordDetail(IngestionRecord):
s3_key: str
checksum: str
frame_count: int | None = None

View File

@@ -0,0 +1,22 @@
from pydantic import BaseModel, Field
from datetime import datetime
from typing import Any
class NotificationPreferences(BaseModel):
user_id: str
email_enabled: bool = True
push_enabled: bool = True
in_app_enabled: bool = True
categories: dict[str, bool] | None = None
class NotificationItem(BaseModel):
notification_id: str
user_id: str
title: str
message: str
category: str
is_read: bool = False
created_at: datetime
metadata: dict | None = None

Some files were not shown because too many files have changed in this diff Show More