import asyncio from collections.abc import Awaitable, Callable from typing import Annotated from fastapi import APIRouter, Depends, File, HTTPException, Path, Query, UploadFile, status from fastapi.responses import Response 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, SurgeryPendingResolveTextRequest, SurgeryResultResponse, SurgeryStartRequest, SurgeryVoiceAuditItem, SurgeryVoiceAuditsListResponse, SurgeryVoiceStatusResponse, build_consumption_summary, ) from app.services.surgery_pipeline import SurgeryPipeline from app.surgery_errors import SurgeryPipelineError router = APIRouter() def _pipeline_error_detail(exc: SurgeryPipelineError, surgery_id: str) -> dict: d: dict = { "code": exc.code, "message": exc.message, "surgery_id": surgery_id, } if exc.extra: d.update(exc.extra) return d def _raise_surgery_pipeline_http(exc: SurgeryPipelineError, surgery_id: str) -> None: raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=_pipeline_error_detail(exc, 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_TEXT_EMPTY": status.HTTP_422_UNPROCESSABLE_CONTENT, "TTS_TEXT_EMPTY": status.HTTP_422_UNPROCESSABLE_CONTENT, "TTS_ERROR": status.HTTP_503_SERVICE_UNAVAILABLE, "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=_pipeline_error_detail(exc, 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.get( "/internal/demo/orchestrator-status", tags=["demo"], summary="一键联调接口是否可用", description="供 demo 页探测:是否启用 orchestrator、RTSP 文件配置等;此路由始终存在,不依赖 DEMO_ORCHESTRATOR_ENABLED。", ) async def demo_orchestrator_status() -> dict: f = (settings.video_rtsp_urls_json_file or "").strip() return { "orchestrator_enabled": bool(settings.demo_orchestrator_enabled), "orchestrate_method": "POST", "orchestrate_path": "/internal/demo/orchestrate-and-start", "video_rtsp_urls_json_file_set": bool(f), "video_rtsp_urls_json_file": f or None, "orchestrator_rtsp_port": settings.demo_orchestrator_rtsp_port, "orchestrator_rtsp_json_host": settings.demo_orchestrator_rtsp_json_host, } @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.post( "/client/surgeries/{surgery_id}/pending-confirmation/{confirmation_id}/resolve-text", response_model=SurgeryPendingConfirmationResolveResponse, responses={ status.HTTP_404_NOT_FOUND: {"model": SurgeryClientErrorResponse}, status.HTTP_409_CONFLICT: {"model": SurgeryClientErrorResponse}, status.HTTP_422_UNPROCESSABLE_CONTENT: {"model": SurgeryClientErrorResponse}, }, tags=["client"], summary="提交客户端语音识别文本以确认耗材", description=( "由浏览器 Web Speech 等本机 STT 得到的文本,不做 MinIO/百度 ASR;" "候选项解析与上传 WAV 接口一致。" ), ) async def resolve_pending_consumable_confirmation_text( 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)], body: SurgeryPendingResolveTextRequest, pipeline: Annotated[SurgeryPipeline, Depends(get_surgery_pipeline)], ) -> SurgeryPendingConfirmationResolveResponse: try: result = await pipeline.resolve_pending_confirmation_from_client_text( surgery_id=surgery_id, confirmation_id=confirmation_id, recognized_text=body.recognized_text, ) 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( "/client/surgeries/{surgery_id}/pending-confirmation/{confirmation_id}/prompt-audio", responses={ status.HTTP_404_NOT_FOUND: {"model": SurgeryClientErrorResponse}, status.HTTP_422_UNPROCESSABLE_CONTENT: {"model": SurgeryClientErrorResponse}, status.HTTP_503_SERVICE_UNAVAILABLE: {"model": SurgeryClientErrorResponse}, }, tags=["client"], summary="待确认话术的 TTS 音频(MP3)", description="使用百度在线合成,与 prompt_text 一致;供浏览器 MediaElement 直放。未配置百度语音时返回 503。", response_class=Response, ) async def get_pending_prompt_audio_mpeg( 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)], pipeline: Annotated[SurgeryPipeline, Depends(get_surgery_pipeline)], ) -> Response: try: data = await pipeline.get_pending_prompt_audio_mp3( surgery_id=surgery_id, confirmation_id=confirmation_id, ) except SurgeryPipelineError as exc: _raise_confirmation_http(exc, surgery_id) return Response( content=data, media_type="audio/mpeg", headers={"Cache-Control": "no-store"}, ) @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"), ) @router.get( "/internal/surgeries/{surgery_id}/voice-audits", response_model=SurgeryVoiceAuditsListResponse, tags=["internal"], summary="语音确认审计记录(按手术号分页)", description=( "查询持久化表 `voice_confirmation_audits`:ASR 文本、解析结果、" "候选项快照、MinIO 对象键、失败原因等。用于追溯、对账与报表;" "不区分手术是否仍进行中,只要库里有记录即返回。" ), ) async def get_surgery_voice_audits( 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)], limit: Annotated[int, Query(ge=1, le=200, description="每页条数。")] = 50, offset: Annotated[int, Query(ge=0, description="跳过前若干条,供分页。")] = 0, ) -> SurgeryVoiceAuditsListResponse: rows, total = await pipeline.list_voice_audits( surgery_id, limit=limit, offset=offset ) return SurgeryVoiceAuditsListResponse( surgery_id=surgery_id, total=total, limit=limit, offset=offset, items=[SurgeryVoiceAuditItem.model_validate(r) for r in rows], )