update the codebase poc ver1
233
workspace/sprint_1_2/CODEBASE/backend/api/analysis_api.py
Normal 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))
|
||||
82
workspace/sprint_1_2/CODEBASE/backend/api/auth_api.py
Normal 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))
|
||||
47
workspace/sprint_1_2/CODEBASE/backend/api/ingestion_api.py
Normal 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))
|
||||
@@ -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))
|
||||
91
workspace/sprint_1_2/CODEBASE/backend/api/patient_api.py
Normal 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))
|
||||
320
workspace/sprint_1_2/CODEBASE/backend/api/safety_api.py
Normal 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",
|
||||
},
|
||||
)
|
||||
214
workspace/sprint_1_2/CODEBASE/backend/api/session_api.py
Normal 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))
|
||||
47
workspace/sprint_1_2/CODEBASE/backend/api/settings_api.py
Normal 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))
|
||||
40
workspace/sprint_1_2/CODEBASE/backend/api/telemetry_api.py
Normal 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))
|
||||
93
workspace/sprint_1_2/CODEBASE/backend/cv_inference_server.py
Normal 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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -0,0 +1,13 @@
|
||||
"""CV inference orchestration (Sprint 1–2 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",
|
||||
]
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Shared CV pipeline helpers — Sprint 1–2 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),
|
||||
}
|
||||
@@ -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)),
|
||||
)
|
||||
@@ -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)},
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)),
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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
|
||||
89
workspace/sprint_1_2/CODEBASE/backend/main.py
Normal 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)
|
||||
118
workspace/sprint_1_2/CODEBASE/backend/routers/agent_tools.py
Normal 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))
|
||||
@@ -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",
|
||||
},
|
||||
)
|
||||
@@ -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))
|
||||
223
workspace/sprint_1_2/CODEBASE/backend/routers/cv_inference.py
Normal 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)
|
||||
@@ -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."
|
||||
)
|
||||
@@ -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")
|
||||
@@ -0,0 +1,336 @@
|
||||
"""Spec-compliant CV inference orchestration — Sprint 1–2 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,
|
||||
)
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
## Code‑base 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/ # Low‑level 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 module’s 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` | Modal‑based 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, circuit‑breaker, Socratic chat, drift check, RAG evidence, activations, annotations, escalation, telemetry | Calls to local LLM/RAG/BERT services, post‑processing 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, mark‑read, 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, well‑defined 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 module’s 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 AI‑navigability and testability.*
|
||||
@@ -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 AI‑navigability and automated test generation.*
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 111 KiB |
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 137 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 127 KiB |
|
After Width: | Height: | Size: 114 KiB |
|
After Width: | Height: | Size: 113 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 261 KiB |
@@ -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 (0–1 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"])
|
||||
@@ -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())
|
||||