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],
)

View File

@@ -101,7 +101,7 @@ class Settings(BaseSettings):
video_result_doctor_id: str = "vision"
#: 为 true 时,每次单帧分类得到 top1 等结果会打一条 INFO 日志(联调用;高流量时建议关)。
video_log_inference_results: bool = False
#: 为 true 时,将时间窗级识别写入文本日志(`start_surgery` 时按手术截断/初始化窗内结果追加Top2/3 仅名称;数量恒 1)。
#: 为 true 时,将时间窗级识别写入文本日志(`start_surgery` 时按手术截断/初始化;每行 tabitem_id、item_name、qty、doctor_id、timestamp停录后追加汇总块 item_id、item_name、qty)。
consumption_tsv_log_enabled: bool = True
#: 路径模板,须含 `{surgery_id}`(每例手术独立文件)。不含占位时自动在扩展名前追加 `_<surgery_id>`。
consumption_tsv_log_path: str = "logs/consumption_{surgery_id}.txt"

View File

@@ -6,7 +6,7 @@ from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import SurgeryFinalResult, SurgeryResultDetailRow
from app.schemas import SurgeryConsumptionDetail
from app.schemas import SurgeryConsumptionDetail, SurgeryConsumptionStored
class SurgeryResultRepository:
@@ -17,7 +17,7 @@ class SurgeryResultRepository:
session: AsyncSession,
*,
surgery_id: str,
details: list[SurgeryConsumptionDetail],
details: list[SurgeryConsumptionStored],
completed_at: datetime | None = None,
) -> None:
when = completed_at or datetime.now(timezone.utc)
@@ -37,7 +37,7 @@ class SurgeryResultRepository:
surgery_id=surgery_id,
item_id=d.item_id,
item_name=d.item_name,
quantity=d.quantity,
quantity=d.qty,
doctor_id=d.doctor_id,
recorded_at=d.timestamp,
source=d.source,
@@ -64,10 +64,9 @@ class SurgeryResultRepository:
SurgeryConsumptionDetail(
item_id=r.item_id,
item_name=r.item_name,
quantity=r.quantity,
qty=r.quantity,
doctor_id=r.doctor_id,
timestamp=r.recorded_at,
source=r.source,
)
for r in rows
]

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
@@ -75,24 +76,72 @@ class SurgeryClientErrorResponse(BaseModel):
class SurgeryConsumptionDetail(BaseModel):
"""单条消耗明细(按事件发生,可能多行)。"""
"""单条消耗明细(HTTP 与 OpenAPI按事件发生,可能多行)。
item_id: str = Field(description="物品 ID。")
item_name: str = Field(description="物品名称。")
quantity: int = Field(ge=0, description="本条记录对应的消耗数量。")
doctor_id: str = Field(description="医生 ID。")
timestamp: datetime = Field(description="记录时间ISO 8601")
source: str = Field(
default="vision",
description="记录来源vision 自动识别voice 语音确认。",
JSON 字段顺序item_id → item_name → qty → doctor_id → timestamp。
(可选落盘耗材 TSV 明细列与此一致item_id、item_name、qty、doctor_id、timestamp。
"""
model_config = ConfigDict(
json_schema_extra={
"example": {
"item_id": "HC001",
"item_name": "纱布",
"qty": 1,
"doctor_id": "D1001",
"timestamp": "2026-04-21T10:30:00+08:00",
}
}
)
item_id: str = Field(
description=(
"业务物品标识:优先为耗材目录中的产品编码;"
"目录键经名称归一化后与分类类名匹配,未命中目录时与模型输出类名一致。"
),
)
item_name: str = Field(description="物品名称(分类或确认后的展示名)。")
qty: int = Field(ge=0, description="本条记录对应的消耗数量。")
doctor_id: str = Field(description="医生 ID。")
timestamp: datetime = Field(description="记录时间ISO 8601date-time")
@dataclass
class SurgeryConsumptionStored:
"""内存 / 数据库持久化用的明细行(含 source仅服务端内部使用不随 HTTP 返回)。"""
item_id: str
item_name: str
qty: int
doctor_id: str
timestamp: datetime
source: str = "vision"
def as_response(self) -> SurgeryConsumptionDetail:
return SurgeryConsumptionDetail(
item_id=self.item_id,
item_name=self.item_name,
qty=self.qty,
doctor_id=self.doctor_id,
timestamp=self.timestamp,
)
class SurgeryConsumptionSummary(BaseModel):
"""按物品汇总:该手术下该物品消耗数量合计。"""
"""按物品汇总:该手术下该物品消耗数量合计item_id、item_name、total_quantity"""
item_id: str = Field(description="物品 ID。")
item_name: str = Field(description="物品名称。")
model_config = ConfigDict(
json_schema_extra={
"example": {
"item_id": "HC001",
"item_name": "纱布",
"total_quantity": 3,
}
}
)
item_id: str = Field(description="物品 ID与明细中 item_id 一致。")
item_name: str = Field(description="物品名称,取该物品首条明细中的名称。")
total_quantity: int = Field(ge=0, description="该物品在本台手术中的消耗数量合计。")
@@ -105,7 +154,7 @@ def build_consumption_summary(
if row.item_id not in totals:
totals[row.item_id] = (row.item_name, 0)
name, acc = totals[row.item_id]
totals[row.item_id] = (name, acc + row.quantity)
totals[row.item_id] = (name, acc + row.qty)
return [
SurgeryConsumptionSummary(
item_id=iid,
@@ -116,73 +165,6 @@ def build_consumption_summary(
]
class SurgeryVoiceStatusResponse(BaseModel):
"""手术进行中人工确认(客户端播报)联调状态。"""
surgery_id: str = Field(description="手术 6 位号。")
voice_enabled: bool = Field(
description="是否启用了低置信度人工确认(客户端拉取待确认项)。",
)
pending_queue_approx: int = Field(
ge=0,
description="待医生确认的追问任务数量FIFO 队列长度)。",
)
last_prompt_snippet: str | None = Field(
default=None,
description="最近一次生成的待确认话术摘要。",
)
last_asr_text: str | None = Field(
default=None,
description="最近一次语音确认接口产生的 ASR 文本。",
)
last_error: str | None = Field(
default=None,
description="最近一次语音确认错误说明(如 ASR/解析失败)。",
)
class SurgeryVoiceAuditItem(BaseModel):
"""单条 `voice_confirmation_audits` 行(追溯对账用)。"""
id: int
confirmation_id: str
status: str = Field(
description=(
"recognized / rejected / parse_failed / asr_failed / invalid_audio / "
"upload_failed / client_stt_empty / client_stt_parse_failed 等"
),
)
audio_object_key: str | None = None
audio_content_type: str | None = None
audio_size_bytes: int | None = None
audio_sha256: str | None = None
asr_text: str | None = None
resolved_label: str | None = None
options_snapshot_json: str | None = Field(
default=None,
description="当次候选项与置信度 JSON 快照。",
)
error_message: str | None = None
created_at: datetime = Field(
description="记录写入时间UTC",
)
model_config = ConfigDict(from_attributes=True)
class SurgeryVoiceAuditsListResponse(BaseModel):
"""按手术号分页的语音确认审计列表。"""
surgery_id: str
total: int = Field(ge=0, description="该手术在表中的总条数(不受本页 limit 截断)。")
limit: int = Field(ge=1, le=200)
offset: int = Field(ge=0)
items: list[SurgeryVoiceAuditItem] = Field(
default_factory=list,
description="按 `created_at` 降序。",
)
class PendingConfirmationOption(BaseModel):
label: str
confidence: float
@@ -193,23 +175,19 @@ class SurgeryPendingConfirmationResponse(BaseModel):
surgery_id: str
confirmation_id: str
prompt_text: str = Field(description="可直接用于 TTS 播报的话术")
prompt_text: str = Field(description="可直接用于展示或无障碍朗读的话术(与 MP3 内容一致)")
prompt_audio_mp3_base64: str = Field(
description=(
"与 prompt_text 对应的百度 TTS 音频MP3的标准 Base64 字符串(无换行);"
"客户端解码为二进制后以 audio/mpeg 播放。"
),
)
options: list[PendingConfirmationOption]
model_top1_label: str = Field(description="模型原始 Top1 标签(可能不在候选清单内)。")
model_top1_confidence: float
created_at: datetime
class SurgeryPendingResolveTextRequest(BaseModel):
"""由浏览器 Web Speech 等客户端本地识别后提交的文本,语义与经百度 ASR 得到的文本相同。"""
recognized_text: str = Field(
min_length=1,
max_length=2000,
description="识别文本;服务端用与语音接口相同的规则解析候选项。",
)
class SurgeryPendingConfirmationResolveResponse(BaseModel):
surgery_id: str
confirmation_id: str
@@ -244,21 +222,21 @@ class SurgeryResultResponse(BaseModel):
{
"item_id": "HC001",
"item_name": "纱布",
"quantity": 2,
"qty": 2,
"doctor_id": "D1001",
"timestamp": "2026-04-21T10:30:00+08:00",
},
{
"item_id": "HC001",
"item_name": "纱布",
"quantity": 1,
"qty": 1,
"doctor_id": "D1002",
"timestamp": "2026-04-21T11:05:00+08:00",
},
{
"item_id": "HC002",
"item_name": "缝线",
"quantity": 1,
"qty": 1,
"doctor_id": "D1001",
"timestamp": "2026-04-21T10:45:00+08:00",
},
@@ -276,9 +254,15 @@ class SurgeryResultResponse(BaseModel):
message: str = Field(description="返回说明。")
details: list[SurgeryConsumptionDetail] = Field(
default_factory=list,
description="消耗明细行:每条含物品、数量、医生与时间;同一物品可多次出现。",
description=(
"消耗明细多行。每行字段顺序item_id、item_name、qty、doctor_id、timestamp"
"同一 item_id 可多次出现。"
),
)
summary: list[SurgeryConsumptionSummary] = Field(
default_factory=list,
description="按物品汇总的消耗合计,应与 details 按 item_id 汇总一致。",
description=(
"按 item_id 汇总的合计表(仅 item_id、item_name、total_quantity"
"应与 details 按 item_id 汇总 qty 一致。"
),
)

View File

@@ -219,6 +219,13 @@ def cls_top3_from_result(
n3 = str(cls.names.get(int(t5i[2]), "")).strip()
c3 = _ci(2)
def _pid(label: str) -> str:
lb = (label or "").strip()
if not lb:
return ""
norm = _norm_product_name(lb)
return (name_to_code.get(norm) or name_to_code.get(lb) or "").strip()
return ClsTop3(
t1_name=n1,
t1_conf=c1,
@@ -226,9 +233,9 @@ def cls_top3_from_result(
t2_conf=c2,
t3_name=n3,
t3_conf=c3,
t1_pid=name_to_code.get(n1, ""),
t2_pid=name_to_code.get(n2, ""),
t3_pid=name_to_code.get(n3, ""),
t1_pid=_pid(n1),
t2_pid=_pid(n2),
t3_pid=_pid(n3),
)

View File

@@ -1,4 +1,6 @@
"""每例手术一个文本文件(制表符列):`start_surgery` 时截断并写表头,每次时间窗识别**追加**一行。终端 Markdown 时间戳为可读形式;落盘行内仍为 ISO 便于程序解析。
"""每例手术一个文本文件(制表符列):`start_surgery` 时截断并写表头,每次时间窗识别**追加**一行(仅 item_id, item_name, qty, doctor_id, timestamp。终端 Markdown 时间戳为可读形式;落盘时间戳为 ISO 区间便于程序解析。
手术结束时再追加一节汇总行item_id, item_name, qty无其它列
时间戳:在拉流起点记录 `time.time()`,与 `time.monotonic()` 时间窗对齐。直播 RTSP 经 OpenCV 一般无可靠绝对时码,以本机接收时刻为准。
"""
@@ -14,11 +16,12 @@ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from loguru import logger
from app.config import settings
from app.services.consumable_vision_algorithm import ClsTop3
from app.services.consumable_vision_algorithm import ClsTop3, _norm_product_name
from app.terminal_markdown import print_markdown_stderr
# 制表符分隔;时间范围用 U+2013 连接;Top2/3 仅名称;本窗消耗数量恒为 1
HEADER = "物品id\t物品名称\tTop2物品名称\tTop3物品名称\t消耗数量\t医生id\t时间戳\n"
# 制表符分隔;时间范围用 U+2013 连接;本窗消耗数量恒为 1
HEADER = "item_id\titem_name\tqty\tdoctor_id\ttimestamp\n"
SUMMARY_HEADER = "item_id\titem_name\tqty\n"
_RANGE_SEP = "\u2013" # en dash与样例 `00:00:00.00000:00:45.000` 一致
_lock = threading.Lock()
@@ -83,13 +86,20 @@ def _encode_cell(value: str) -> str:
return s
def _item_id_for_row(name: str, pid: str, name_to_code: dict[str, str]) -> str:
p = (pid or "").strip()
def resolve_consumption_item_id(
t1_name: str,
t1_pid: str,
name_to_code: dict[str, str],
) -> str:
"""业务物品 id`name_to_code` 的键为归一化名称,须与分类输出一同参与查找。"""
n = (t1_name or "").strip()
norm = _norm_product_name(n)
code = (name_to_code.get(norm) or name_to_code.get(n) or "").strip()
if code:
return code
p = (t1_pid or "").strip()
if p:
return p
n = (name or "").strip()
if n in name_to_code:
return (name_to_code.get(n) or n).strip()
return n
@@ -102,17 +112,12 @@ def build_tsv_line(
wall_start_epoch: float,
wall_end_epoch: float,
) -> str:
id1 = _item_id_for_row(best.t1_name, best.t1_pid, name_to_code)
# 与历史样例Top1 为「名称 置信度」四位小数
name1 = f"{(best.t1_name or '').strip()} {best.t1_conf:.4f}".strip()
n2 = (best.t2_name or "").strip()
n3 = (best.t3_name or "").strip()
id1 = resolve_consumption_item_id(best.t1_name, best.t1_pid, name_to_code)
name1 = (best.t1_name or "").strip()
ts = format_consumption_timestamp(camera_id, wall_start_epoch, wall_end_epoch)
row = [
_encode_cell(id1),
_encode_cell(name1),
_encode_cell(n2),
_encode_cell(n3),
"1",
_encode_cell(doctor_id),
_encode_cell(ts),
@@ -179,25 +184,17 @@ def build_consumption_markdown(
wall_start_epoch: float,
wall_end_epoch: float,
) -> str:
"""终端用:Top1 含 id/名称/置信度Top2/3 仅名称;消耗数量恒为 1。"""
id1 = _item_id_for_row(best.t1_name, best.t1_pid, name_to_code)
"""终端用:与落盘列一致;本窗 qty 恒为 1。"""
id1 = resolve_consumption_item_id(best.t1_name, best.t1_pid, name_to_code)
n1 = (best.t1_name or "").strip()
has2 = bool((best.t2_name or "").strip())
has3 = bool((best.t3_name or "").strip())
n2 = (best.t2_name or "").strip() if has2 else ""
n3 = (best.t3_name or "").strip() if has3 else ""
dash = ""
ts = format_consumption_timestamp_readable(camera_id, wall_start_epoch, wall_end_epoch)
return "\n".join(
[
"| Top1 物品id | Top1 物品名称 | Top1 置信度 | Top2 物品名称 | Top3 物品名称 | 消耗数量 | 医生id | 时间戳 |",
"| :--- | :--- | ---: | :--- | :--- | ---: | :--- | :--- |",
"| {} | {} | {:.4f} | {} | {} | 1 | {} | {} |".format(
"| item_id | item_name | qty | doctor_id | timestamp |",
"| :--- | :--- | ---: | :--- | :--- |",
"| {} | {} | 1 | {} | {} |".format(
_md_cell(id1),
_md_cell(n1),
best.t1_conf,
_md_cell(n2) if has2 else dash,
_md_cell(n3) if has3 else dash,
_md_cell(doctor_id),
_md_cell(ts),
),
@@ -206,6 +203,47 @@ def build_consumption_markdown(
)
def append_consumption_log_summary(
surgery_id: str,
totals: dict[str, tuple[str, int]],
) -> None:
"""在明细行之后追加汇总块(表头 + 每物品一行)。"""
if not settings.consumption_tsv_log_enabled or not totals:
return
path = resolved_consumption_log_path(surgery_id)
if not path.is_file():
return
body = "".join(
["\n", SUMMARY_HEADER]
+ [
"\t".join([_encode_cell(iid), _encode_cell(name), str(qty)]) + "\n"
for iid, (name, qty) in sorted(totals.items(), key=lambda x: x[0])
]
)
with _lock:
with path.open("a", encoding="utf-8") as f:
f.write(body)
def print_consumption_summary_markdown(
totals: dict[str, tuple[str, int]],
) -> None:
if not settings.consumption_log_markdown_terminal or not totals:
return
lines = [
"## 消耗汇总",
"",
"| item_id | item_name | qty |",
"| :--- | :--- | ---: |",
]
for iid, (name, qty) in sorted(totals.items(), key=lambda x: x[0]):
lines.append(
"| {} | {} | {} |".format(_md_cell(iid), _md_cell(name), qty)
)
lines.append("")
print_markdown_stderr("\n".join(lines))
def append_consumption_window(
*,
surgery_id: str,
@@ -215,9 +253,17 @@ def append_consumption_window(
camera_id: str,
wall_start_epoch: float,
wall_end_epoch: float,
running_totals: dict[str, tuple[str, int]] | None = None,
) -> None:
if not settings.consumption_tsv_log_enabled and not settings.consumption_log_markdown_terminal:
return
iid = resolve_consumption_item_id(best.t1_name, best.t1_pid, name_to_code)
iname = (best.t1_name or "").strip()
if running_totals is not None:
if iid not in running_totals:
running_totals[iid] = (iname, 0)
prev_name, q = running_totals[iid]
running_totals[iid] = (prev_name, q + 1)
if settings.consumption_tsv_log_enabled:
line = build_tsv_line(
name_to_code=name_to_code,

View File

@@ -2,6 +2,8 @@
from __future__ import annotations
import base64
from app.database import AsyncSessionLocal
from app.repositories.surgery_results import SurgeryResultRepository
from app.schemas import (
@@ -78,31 +80,22 @@ class SurgeryPipeline:
return persisted
return self._sessions.archived_consumption_fallback(surgery_id)
def voice_status(self, surgery_id: str) -> dict[str, object] | None:
return self._sessions.voice_status(surgery_id)
async def list_voice_audits(
self,
surgery_id: str,
*,
limit: int = 50,
offset: int = 0,
):
"""持久化表 `voice_confirmation_audits` 分页,用于追溯/对账/报表。"""
return await self._voice.list_voice_audits_for_surgery(
surgery_id, limit=limit, offset=offset
)
def get_pending_confirmation_for_client(
async def get_pending_confirmation_for_client(
self, surgery_id: str
) -> SurgeryPendingConfirmationResponse | None:
pending = self._sessions.next_pending_confirmation(surgery_id)
if pending is None:
return None
mp3 = await run_in_threadpool(
self._voice.synthesize_prompt_to_mp3,
pending.prompt_text,
)
b64 = base64.b64encode(mp3).decode("ascii")
return SurgeryPendingConfirmationResponse(
surgery_id=surgery_id,
confirmation_id=pending.id,
prompt_text=pending.prompt_text,
prompt_audio_mp3_base64=b64,
options=[
PendingConfirmationOption(label=a, confidence=b)
for a, b in pending.options
@@ -129,34 +122,3 @@ class SurgeryPipeline:
content_type=content_type,
)
async def resolve_pending_confirmation_from_client_text(
self,
surgery_id: str,
confirmation_id: str,
recognized_text: str,
) -> VoiceResolveResult:
"""浏览器等客户端本机识别后的文本,解析规则与 WAV 路径一致(无需 MinIO/百度)。"""
return await self._voice.resolve_from_recognized_text(
surgery_id=surgery_id,
confirmation_id=confirmation_id,
recognized_text=recognized_text,
)
async def get_pending_prompt_audio_mp3(
self,
surgery_id: str,
confirmation_id: str,
) -> bytes:
"""待确认 `prompt_text` 的百度 TTS MP3供模拟客户端用 Audio 直放。"""
pending = self._sessions.get_pending_confirmation_by_id(
surgery_id, confirmation_id
)
if pending is None or pending.status != "pending":
raise SurgeryPipelineError(
"CONFIRMATION_NOT_FOUND",
"未找到该待确认项或已处理。",
)
return await run_in_threadpool(
self._voice.synthesize_prompt_to_mp3,
pending.prompt_text,
)

View File

@@ -12,7 +12,7 @@ from loguru import logger
from app.config import Settings
from app.database import AsyncSessionLocal
from app.repositories.surgery_results import SurgeryResultRepository
from app.schemas import SurgeryConsumptionDetail
from app.schemas import SurgeryConsumptionDetail, SurgeryConsumptionStored
from app.services.consumable_vision_algorithm import (
ClsTop3,
ConsumableVisionAlgorithmService,
@@ -26,7 +26,12 @@ from app.services.video.backend_resolver import BackendResolver
from app.services.video.hikvision_runtime import HikvisionInitRefCount, HikvisionRuntime
from app.services.video.rtsp_capture import RtspCapture
from app.services.video.types import VideoBackendKind
from app.services.consumption_tsv_log import append_consumption_window, init_consumption_log_file
from app.services.consumption_tsv_log import (
append_consumption_log_summary,
append_consumption_window,
init_consumption_log_file,
print_consumption_summary_markdown,
)
from app.services.voice_file_log import init_voice_log_file
from app.services.voice_confirm import build_prompt_text
from app.surgery_errors import SurgeryPipelineError
@@ -64,7 +69,7 @@ class SurgerySessionState:
#: 分类类名(归一化) -> 业务物品 idExcel 产品编码或名称)。
name_to_code: dict[str, str] = field(default_factory=dict)
camera_infer: dict[str, CameraStreamInferState] = field(default_factory=dict)
details: list[SurgeryConsumptionDetail] = field(default_factory=list)
details: list[SurgeryConsumptionStored] = field(default_factory=list)
lock: asyncio.Lock = field(default_factory=asyncio.Lock)
ready: asyncio.Event = field(default_factory=asyncio.Event)
last_detail_monotonic: dict[str, float] = field(default_factory=dict)
@@ -76,6 +81,8 @@ class SurgerySessionState:
last_asr_text: str | None = None
#: 最近一次语音确认错误说明ASR/解析失败等)。
last_voice_error: str | None = None
#: 视觉时间窗落盘用量累计供停录时写汇总item_id -> 首次名称, 次数)。
consumption_log_totals: dict[str, tuple[str, int]] = field(default_factory=dict)
@dataclass
@@ -87,7 +94,7 @@ class RunningSurgery:
@dataclass
class ArchivedSurgery:
details: list[SurgeryConsumptionDetail]
details: list[SurgeryConsumptionStored]
def _rank_topk_for_candidates(
@@ -293,7 +300,7 @@ class CameraSessionManager:
async def _persist_archived_details(
self,
surgery_id: str,
details: list[SurgeryConsumptionDetail],
details: list[SurgeryConsumptionStored],
) -> bool:
if self._repo is None:
return True
@@ -331,6 +338,10 @@ class CameraSessionManager:
if isinstance(res, BaseException):
logger.warning("surgery task finished with error: {}", res)
totals = dict(run.state.consumption_log_totals)
append_consumption_log_summary(surgery_id, totals)
print_consumption_summary_markdown(totals)
details = list(run.state.details)
persisted = False
@@ -364,26 +375,13 @@ class CameraSessionManager:
rows = list(self._active[surgery_id].state.details)
if not rows:
return None
return rows
return [r.as_response() for r in rows]
def archived_consumption_fallback(self, surgery_id: str) -> list[SurgeryConsumptionDetail] | None:
arch = self._archive.get(surgery_id)
if arch is None:
return None
return list(arch.details)
def voice_status(self, surgery_id: str) -> dict[str, object] | None:
if surgery_id not in self._active:
return None
st = self._active[surgery_id].state
return {
"surgery_id": surgery_id,
"voice_enabled": bool(self._s.voice_confirmation_enabled),
"pending_queue_approx": len(st.pending_fifo),
"last_prompt_snippet": st.last_pending_prompt_snippet,
"last_asr_text": st.last_asr_text,
"last_error": st.last_voice_error,
}
return [r.as_response() for r in arch.details]
def record_voice_trace(
self,
@@ -525,10 +523,10 @@ class CameraSessionManager:
return
state.last_detail_monotonic[item_id] = now_m
state.details.append(
SurgeryConsumptionDetail(
SurgeryConsumptionStored(
item_id=item_id,
item_name=item_name,
quantity=1,
qty=1,
doctor_id=doctor_id,
timestamp=datetime.now(timezone.utc),
source=source,
@@ -698,6 +696,7 @@ class CameraSessionManager:
camera_id=camera_id,
wall_start_epoch=wall_lo,
wall_end_epoch=wall_hi,
running_totals=state.consumption_log_totals,
)
pending_preds.append(
cls_top3_to_prediction_result(best)

View File

@@ -11,7 +11,6 @@ from loguru import logger
from app.config import Settings
from app.services.voice_file_log import emit_voice_event
from app.database import AsyncSessionLocal
from app.db.models import VoiceConfirmationAudit
from app.repositories.voice_audits import VoiceAuditRepository
from app.services.audio_wav import WavDecodeError, wav_bytes_to_pcm16k_mono_s16le
from app.services.baidu_speech import BaiduSpeechNotConfiguredError, BaiduSpeechService
@@ -660,22 +659,6 @@ class VoiceConfirmationService:
message="已确认并记一条消耗。",
)
async def list_voice_audits_for_surgery(
self,
surgery_id: str,
*,
limit: int = 50,
offset: int = 0,
) -> tuple[list[VoiceConfirmationAudit], int]:
"""从 `voice_confirmation_audits` 表分页读取,供内部查询与报表。"""
async with AsyncSessionLocal() as session:
return await self._audits.list_by_surgery(
session,
surgery_id,
limit=limit,
offset=offset,
)
async def _persist_audit(
self,
*,