Files
operating-room-monitor-server/app/api.py

420 lines
15 KiB
Python
Raw Normal View History

import asyncio
from collections.abc import Awaitable, Callable
from typing import Annotated
from fastapi import APIRouter, Depends, File, HTTPException, Path, UploadFile, status
from fastapi.responses import JSONResponse
from loguru import logger
from sqlalchemy.exc import SQLAlchemyError
from app.config import settings
from app.database import check_database
from app.dependencies import get_surgery_pipeline
from app.schemas import (
HealthResponse,
SurgeryApiResponse,
SurgeryClientErrorResponse,
SurgeryEndRequest,
SurgeryPendingConfirmationResolveResponse,
SurgeryPendingConfirmationResponse,
SurgeryResultResponse,
SurgeryStartRequest,
SurgeryVoiceStatusResponse,
build_consumption_summary,
)
from app.services.surgery_pipeline import SurgeryPipeline
from app.surgery_errors import SurgeryPipelineError
router = APIRouter()
def _raise_surgery_pipeline_http(exc: SurgeryPipelineError, surgery_id: str) -> None:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail={
"code": exc.code,
"message": exc.message,
"surgery_id": surgery_id,
},
) from exc
def _raise_confirmation_http(exc: SurgeryPipelineError, surgery_id: str) -> None:
status_map = {
"CONFIRMATION_NOT_FOUND": status.HTTP_404_NOT_FOUND,
"CONFIRMATION_NOT_ACTIVE": status.HTTP_404_NOT_FOUND,
"CONFIRMATION_ALREADY_RESOLVED": status.HTTP_409_CONFLICT,
"CONFIRMATION_INVALID": status.HTTP_422_UNPROCESSABLE_CONTENT,
"VOICE_ASR_FAILED": status.HTTP_422_UNPROCESSABLE_CONTENT,
"VOICE_PARSE_FAILED": status.HTTP_422_UNPROCESSABLE_CONTENT,
"VOICE_AUDIO_INVALID": status.HTTP_422_UNPROCESSABLE_CONTENT,
"MINIO_NOT_CONFIGURED": status.HTTP_503_SERVICE_UNAVAILABLE,
"MINIO_UPLOAD_FAILED": status.HTTP_503_SERVICE_UNAVAILABLE,
"BAIDU_NOT_CONFIGURED": status.HTTP_503_SERVICE_UNAVAILABLE,
}
st = status_map.get(exc.code, status.HTTP_500_INTERNAL_SERVER_ERROR)
raise HTTPException(
status_code=st,
detail={
"code": exc.code,
"message": exc.message,
"surgery_id": surgery_id,
},
) from exc
async def _call_recording_with_retries(
factory: Callable[[], Awaitable[None]],
*,
max_attempts: int,
delay_seconds: float,
log_prefix: str,
) -> None:
"""录制相关操作失败时按配置重试全部失败后抛出最后一次错误message 会附带重试说明)。"""
last_exc: SurgeryPipelineError | None = None
for attempt in range(1, max_attempts + 1):
try:
await factory()
return
except SurgeryPipelineError as exc:
last_exc = exc
if attempt < max_attempts:
logger.warning(
"{} attempt {}/{} failed ({}), retrying in {}s",
log_prefix,
attempt,
max_attempts,
exc.code,
delay_seconds,
)
await asyncio.sleep(delay_seconds)
if last_exc is None:
return
raise SurgeryPipelineError(
last_exc.code,
f"{last_exc.message}(已重试 {max_attempts} 次仍失败)",
) from last_exc
@router.get("/health", response_model=HealthResponse, tags=["health"])
async def health() -> HealthResponse | JSONResponse:
logger.debug("Health check")
try:
await check_database()
except SQLAlchemyError as exc:
logger.warning("Health check: database unavailable: {}", exc)
return JSONResponse(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
content={"status": "degraded", "database": "unavailable"},
)
return HealthResponse(status="ok", database="connected")
@router.post(
"/client/surgeries/start",
response_model=SurgeryApiResponse,
responses={
status.HTTP_503_SERVICE_UNAVAILABLE: {
"description": (
"未能在确认摄像头已开始录制后完成请求;"
"录制子系统未就绪、开录未确认或发生故障。"
),
"model": SurgeryClientErrorResponse,
},
},
tags=["client"],
summary="开始手术",
description=(
"开始一台手术:服务端启动关联摄像头录制。"
"仅在确认开录完成后返回 HTTP 200否则按配置重试仍失败则返回 503。"
),
)
async def start_surgery(
payload: SurgeryStartRequest,
pipeline: Annotated[SurgeryPipeline, Depends(get_surgery_pipeline)],
) -> SurgeryApiResponse:
logger.info(
"Start surgery: surgery_id={}, cameras={}, candidates={}",
payload.surgery_id,
payload.camera_ids,
payload.candidate_consumables,
)
try:
async def _start() -> None:
await pipeline.start_recording(
payload.surgery_id,
payload.camera_ids,
payload.candidate_consumables,
)
await _call_recording_with_retries(
_start,
max_attempts=settings.surgery_recording_max_attempts,
delay_seconds=settings.surgery_recording_retry_delay_seconds,
log_prefix=f"Start surgery {payload.surgery_id}",
)
except SurgeryPipelineError as exc:
_raise_surgery_pipeline_http(exc, payload.surgery_id)
return SurgeryApiResponse(
surgery_id=payload.surgery_id,
status="accepted",
message="摄像头录制已开始,手术已启动。",
)
@router.post(
"/client/surgeries/end",
response_model=SurgeryApiResponse,
responses={
status.HTTP_503_SERVICE_UNAVAILABLE: {
"description": (
"未能在确认摄像头已全部停止录制后完成请求;"
"停录未确认、录制子系统未就绪或发生故障。"
),
"model": SurgeryClientErrorResponse,
},
},
tags=["client"],
summary="结束手术",
description=(
"结束一台手术:服务端停止关联摄像头录制。"
"仅在确认停录完成后返回 HTTP 200否则按配置重试仍失败则返回 503。"
),
)
async def end_surgery(
payload: SurgeryEndRequest,
pipeline: Annotated[SurgeryPipeline, Depends(get_surgery_pipeline)],
) -> SurgeryApiResponse:
logger.info("End surgery: surgery_id={}", payload.surgery_id)
try:
async def _stop() -> None:
await pipeline.stop_recording(payload.surgery_id)
await _call_recording_with_retries(
_stop,
max_attempts=settings.surgery_recording_max_attempts,
delay_seconds=settings.surgery_recording_retry_delay_seconds,
log_prefix=f"End surgery {payload.surgery_id}",
)
except SurgeryPipelineError as exc:
_raise_surgery_pipeline_http(exc, payload.surgery_id)
return SurgeryApiResponse(
surgery_id=payload.surgery_id,
status="accepted",
message="摄像头录制已停止,手术已结束。",
)
@router.get(
"/client/surgeries/{surgery_id}/result",
response_model=SurgeryResultResponse,
responses={
status.HTTP_503_SERVICE_UNAVAILABLE: {
"description": (
"结果尚不可查询:未同时满足「已开录」且「算法已产生可返回的实时计算结果」。"
),
"model": SurgeryClientErrorResponse,
},
},
tags=["client"],
summary="查询手术结果",
description=(
"根据手术 6 位号查询该台手术的耗材消耗明细(多行)及按物品汇总。"
"手术进行中返回当前内存已记账结果;结束后返回数据库持久化结果。"
"若手术从未开始或尚无可查的最终归档,返回 503。"
"使用 GET只读、幂等。"
),
)
async def get_surgery_result(
surgery_id: Annotated[
str,
Path(
min_length=6,
max_length=6,
pattern=r"^\d{6}$",
description="手术 6 位号,仅允许 6 位数字。",
),
],
pipeline: Annotated[SurgeryPipeline, Depends(get_surgery_pipeline)],
) -> SurgeryResultResponse:
logger.info("Query surgery result: surgery_id={}", surgery_id)
details = await pipeline.get_consumption_details_for_client(surgery_id)
if details is None:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail={
"code": "RESULT_NOT_READY",
"message": (
"当前无该手术的可查询结果:手术未开始、未成功开录或尚未产生可返回的数据。"
),
"surgery_id": surgery_id,
},
)
return SurgeryResultResponse(
surgery_id=surgery_id,
status="completed",
message="查询成功。",
details=details,
summary=build_consumption_summary(details),
)
@router.get(
"/client/surgeries/{surgery_id}/pending-confirmation",
response_model=SurgeryPendingConfirmationResponse,
responses={
status.HTTP_404_NOT_FOUND: {
"description": "当前无待确认项或手术未在进行。",
"model": SurgeryClientErrorResponse,
},
},
tags=["client"],
summary="拉取待确认耗材",
description=(
"返回当前 FIFO 队首的一条低置信度识别。"
"客户端应播报 prompt_text 并由医生确认后调用 resolve 接口。"
"无待确认项时返回 404。"
),
)
async def get_pending_consumable_confirmation(
surgery_id: Annotated[
str,
Path(
min_length=6,
max_length=6,
pattern=r"^\d{6}$",
description="手术 6 位号,仅允许 6 位数字。",
),
],
pipeline: Annotated[SurgeryPipeline, Depends(get_surgery_pipeline)],
) -> SurgeryPendingConfirmationResponse:
payload = pipeline.get_pending_confirmation_for_client(surgery_id)
if payload is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={
"code": "NO_PENDING_CONFIRMATION",
"message": "当前无待确认的耗材识别,或手术未在进行。",
"surgery_id": surgery_id,
},
)
return payload
@router.post(
"/client/surgeries/{surgery_id}/pending-confirmation/{confirmation_id}/resolve",
response_model=SurgeryPendingConfirmationResolveResponse,
responses={
status.HTTP_404_NOT_FOUND: {"model": SurgeryClientErrorResponse},
status.HTTP_409_CONFLICT: {"model": SurgeryClientErrorResponse},
status.HTTP_422_UNPROCESSABLE_CONTENT: {"model": SurgeryClientErrorResponse},
status.HTTP_503_SERVICE_UNAVAILABLE: {"model": SurgeryClientErrorResponse},
},
tags=["client"],
summary="提交耗材确认结果(上传医生语音 WAV",
description=(
"multipart/form-data 上传单个 WAV 文件(字段名 `audio`)。"
"服务端将音频存入 MinIO、调用百度 ASR 识别、解析候选项并完成确认。"
"记一条 source=voice 的消耗;若语音表示否认全部候选则不记消耗。"
),
)
async def resolve_pending_consumable_confirmation(
surgery_id: Annotated[
str,
Path(
min_length=6,
max_length=6,
pattern=r"^\d{6}$",
description="手术 6 位号,仅允许 6 位数字。",
),
],
confirmation_id: Annotated[str, Path(min_length=1, max_length=128)],
audio: Annotated[
UploadFile,
File(
...,
description="医生语音 WAV 文件(建议 16kHz 单声道 PCM其他格式将尝试 ffmpeg 转码)",
),
],
pipeline: Annotated[SurgeryPipeline, Depends(get_surgery_pipeline)],
) -> SurgeryPendingConfirmationResolveResponse:
raw = await audio.read()
if not raw:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail={
"code": "VOICE_AUDIO_INVALID",
"message": "音频文件为空。",
"surgery_id": surgery_id,
},
)
filename = (audio.filename or "voice.wav").strip()
if not filename.lower().endswith(".wav"):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail={
"code": "VOICE_AUDIO_INVALID",
"message": "仅支持上传 .wav 文件。",
"surgery_id": surgery_id,
},
)
try:
result = await pipeline.resolve_pending_confirmation_from_audio(
surgery_id=surgery_id,
confirmation_id=confirmation_id,
wav_bytes=raw,
filename=filename,
content_type=audio.content_type,
)
except SurgeryPipelineError as exc:
_raise_confirmation_http(exc, surgery_id)
return SurgeryPendingConfirmationResolveResponse(
surgery_id=surgery_id,
confirmation_id=confirmation_id,
status="accepted",
message=result.message,
resolved_label=result.resolved_label,
rejected=result.rejected,
asr_text=result.asr_text,
audio_object_key=result.audio_object_key,
)
@router.get(
"/internal/surgeries/{surgery_id}/voice-status",
response_model=SurgeryVoiceStatusResponse,
tags=["internal"],
summary="人工确认队列状态(联调)",
description="查询指定进行中手术的待确认队列长度与最近话术摘要。手术未在进行返回 404。",
)
async def get_surgery_voice_status(
surgery_id: Annotated[
str,
Path(
min_length=6,
max_length=6,
pattern=r"^\d{6}$",
description="手术 6 位号,仅允许 6 位数字。",
),
],
pipeline: Annotated[SurgeryPipeline, Depends(get_surgery_pipeline)],
) -> SurgeryVoiceStatusResponse:
payload = pipeline.voice_status(surgery_id)
if payload is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={
"code": "SURGERY_NOT_ACTIVE",
"message": "该手术当前不在进行中,无实时语音状态。",
"surgery_id": surgery_id,
},
)
return SurgeryVoiceStatusResponse(
surgery_id=surgery_id,
voice_enabled=bool(payload["voice_enabled"]),
pending_queue_approx=int(payload["pending_queue_approx"]),
last_prompt_snippet=payload.get("last_prompt_snippet"),
last_asr_text=payload.get("last_asr_text"),
last_error=payload.get("last_error"),
)