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:
201
app/api.py
201
app/api.py
@@ -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` 一致的 MP3(Base64),客户端可直接解码播放。"
|
||||
"无待确认项时返回 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],
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user