feat: align surgery API with schemas and extend client tooling

- Refactor app API and schemas; adjust surgery pipeline, repository, and session manager.

- Improve consumption TSV logging and consumable vision integration; trim voice resolution.

- Add Baidu Face 1:N search script, .env.example entries, and client API integration doc.

- Update demo client, staging checklist, surgery interface doc, and related tests; add sample face image.

Made-with: Cursor
This commit is contained in:
Kevin
2026-04-23 16:09:20 +08:00
parent 0c05463617
commit 69980d8073
20 changed files with 994 additions and 610 deletions

View File

@@ -2,8 +2,7 @@ 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 import APIRouter, Depends, File, HTTPException, Path, UploadFile, status
from fastapi.responses import JSONResponse
from loguru import logger
from sqlalchemy.exc import SQLAlchemyError
@@ -18,12 +17,8 @@ from app.schemas import (
SurgeryEndRequest,
SurgeryPendingConfirmationResolveResponse,
SurgeryPendingConfirmationResponse,
SurgeryPendingResolveTextRequest,
SurgeryResultResponse,
SurgeryStartRequest,
SurgeryVoiceAuditItem,
SurgeryVoiceAuditsListResponse,
SurgeryVoiceStatusResponse,
build_consumption_summary,
)
from app.services.surgery_pipeline import SurgeryPipeline
@@ -251,7 +246,11 @@ async def end_surgery(
"根据手术 6 位号查询该台手术的耗材消耗明细(多行)及按物品汇总。"
"手术进行中返回当前内存已记账结果;结束后返回数据库持久化结果。"
"若手术从未开始或尚无可查的最终归档,返回 503。"
"使用 GET只读、幂等。"
"使用 GET只读、幂等。\n\n"
"响应体 `details` 与 `summary` 的字段定义见模式 SurgeryConsumptionDetail / SurgeryConsumptionSummary"
"若服务端启用耗材 TSV 文本日志,文件明细列为 tab 分隔的 "
"item_id、item_name、qty、doctor_id、timestamp文末另有仅三列的汇总块 item_id、item_name、qty"
"与 HTTP JSON 字段一致。"
),
)
async def get_surgery_result(
@@ -296,13 +295,22 @@ async def get_surgery_result(
"description": "当前无待确认项或手术未在进行。",
"model": SurgeryClientErrorResponse,
},
status.HTTP_422_UNPROCESSABLE_CONTENT: {
"description": "提示文本为空等导致无法合成播报。",
"model": SurgeryClientErrorResponse,
},
status.HTTP_503_SERVICE_UNAVAILABLE: {
"description": "百度语音未配置或 TTS 调用失败。",
"model": SurgeryClientErrorResponse,
},
},
tags=["client"],
summary="拉取待确认耗材",
summary="拉取待确认耗材(含 TTS 音频)",
description=(
"返回当前 FIFO 队首的一条低置信度识别"
"客户端应播报 prompt_text 并由医生确认后调用 resolve 接口"
"无待确认项时返回 404。"
"返回当前 FIFO 队首的一条低置信度识别"
"响应内 `prompt_audio_mp3_base64` 为与 `prompt_text` 一致的 MP3Base64客户端可直接解码播放"
"无待确认项时返回 404;合成失败或未配置语音服务时返回 422/503见错误码"
"医生确认后请使用 `POST .../resolve` 上传 WAV。"
),
)
async def get_pending_consumable_confirmation(
@@ -317,7 +325,10 @@ async def get_pending_consumable_confirmation(
],
pipeline: Annotated[SurgeryPipeline, Depends(get_surgery_pipeline)],
) -> SurgeryPendingConfirmationResponse:
payload = pipeline.get_pending_confirmation_for_client(surgery_id)
try:
payload = await pipeline.get_pending_confirmation_for_client(surgery_id)
except SurgeryPipelineError as exc:
_raise_confirmation_http(exc, surgery_id)
if payload is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
@@ -344,7 +355,7 @@ async def get_pending_consumable_confirmation(
description=(
"multipart/form-data 上传单个 WAV 文件(字段名 `audio`)。"
"服务端将音频存入 MinIO、调用百度 ASR 识别、解析候选项并完成确认。"
"记一条 source=voice 的消耗;若语音表示否认全部候选则不记消耗。"
"解析并确认后记一条消耗明细;若语音表示否认全部候选则不记消耗。"
),
)
async def resolve_pending_consumable_confirmation(
@@ -407,167 +418,3 @@ async def resolve_pending_consumable_confirmation(
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],
)