Files
operating-room-monitor-server/app/schemas.py
Kevin 0c05463617 feat: 语音确认、联调与运维增强
- 语音:序数解析(第一个/第二个等)、解析失败计数与 API detail.retry_remaining;
  百度 ASR 固定 dev_pid 为普通话;SurgeryPipelineError 支持 extra 并入 HTTP detail。
- Demo:demo 路由与假 RTSP、客户端 index 与 README;BackendResolver 与配置调整。
- 可观测:消耗 TSV 日志、语音文件日志、终端 Markdown 辅助;相关测试与依赖更新。
- 注意:.env 仍被 gitignore,本地密钥不会进入本提交。

Made-with: Cursor
2026-04-23 14:24:20 +08:00

285 lines
9.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
class HealthResponse(BaseModel):
status: str
database: str
class SurgeryStartRequest(BaseModel):
model_config = ConfigDict(
json_schema_extra={
"example": {
"surgery_id": "123456",
"camera_ids": ["or-cam-01", "or-cam-02"],
"candidate_consumables": ["纱布", "缝线", "止血钳"],
}
}
)
surgery_id: str = Field(
min_length=6,
max_length=6,
pattern=r"^\d{6}$",
description="手术6位号只允许6位数字。",
)
camera_ids: list[str] = Field(
min_length=1,
description="本次手术需要接入的摄像头 ID 列表。",
)
candidate_consumables: list[str] = Field(
default_factory=list,
description=(
"本次手术可能使用到的耗材清单。"
"服务端仅对该清单内的耗材做自动记账与待确认追问;"
"若为空则不会写入任何消耗(仅拉流推理)。"
),
)
class SurgeryEndRequest(BaseModel):
model_config = ConfigDict(
json_schema_extra={"example": {"surgery_id": "123456"}}
)
surgery_id: str = Field(
min_length=6,
max_length=6,
pattern=r"^\d{6}$",
description="手术6位号只允许6位数字。",
)
class SurgeryApiResponse(BaseModel):
surgery_id: str = Field(description="手术6位号。")
status: str = Field(description="接口处理状态。")
message: str = Field(description="返回说明。")
class SurgeryClientErrorDetail(BaseModel):
"""与 `HTTPException(detail={...})` 对应;最终 JSON 为 `{"detail": {...}}`。"""
code: str = Field(description="业务错误码,如 RECORDING_CANNOT_START、RECORDING_NOT_STOPPED、RESULT_NOT_READY。")
message: str = Field(description="人类可读说明。")
surgery_id: str = Field(description="手术 6 位号。")
class SurgeryClientErrorResponse(BaseModel):
"""FastAPI/Starlette 对 HTTPException 序列化后的常见外形(`detail` 为对象时)。"""
detail: SurgeryClientErrorDetail
class SurgeryConsumptionDetail(BaseModel):
"""单条消耗明细(按事件发生,可能多行)。"""
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 语音确认。",
)
class SurgeryConsumptionSummary(BaseModel):
"""按物品汇总:该手术下该物品消耗数量合计。"""
item_id: str = Field(description="物品 ID。")
item_name: str = Field(description="物品名称。")
total_quantity: int = Field(ge=0, description="该物品在本台手术中的消耗数量合计。")
def build_consumption_summary(
details: list[SurgeryConsumptionDetail],
) -> list[SurgeryConsumptionSummary]:
"""按 item_id 汇总 total_quantity名称取该物品首条出现时的 item_name。"""
totals: dict[str, tuple[str, int]] = {}
for row in details:
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)
return [
SurgeryConsumptionSummary(
item_id=iid,
item_name=name,
total_quantity=qty,
)
for iid, (name, qty) in sorted(totals.items(), key=lambda x: x[0])
]
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
class SurgeryPendingConfirmationResponse(BaseModel):
"""当前待医生确认的一条低置信度识别。"""
surgery_id: str
confirmation_id: str
prompt_text: str = Field(description="可直接用于 TTS 播报的话术。")
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
status: str = Field(description="accepted")
message: str
resolved_label: str | None = Field(
default=None,
description="解析并确认后的耗材名称;否认全部候选时为 null。",
)
rejected: bool = Field(
default=False,
description="是否为否认全部候选(不记消耗)。",
)
asr_text: str | None = Field(
default=None,
description="服务端语音识别得到的文本。",
)
audio_object_key: str | None = Field(
default=None,
description="MinIO 中原始 WAV 的对象键,用于追溯。",
)
class SurgeryResultResponse(BaseModel):
model_config = ConfigDict(
json_schema_extra={
"example": {
"surgery_id": "123456",
"status": "completed",
"message": "结果查询成功。",
"details": [
{
"item_id": "HC001",
"item_name": "纱布",
"quantity": 2,
"doctor_id": "D1001",
"timestamp": "2026-04-21T10:30:00+08:00",
},
{
"item_id": "HC001",
"item_name": "纱布",
"quantity": 1,
"doctor_id": "D1002",
"timestamp": "2026-04-21T11:05:00+08:00",
},
{
"item_id": "HC002",
"item_name": "缝线",
"quantity": 1,
"doctor_id": "D1001",
"timestamp": "2026-04-21T10:45:00+08:00",
},
],
"summary": [
{"item_id": "HC001", "item_name": "纱布", "total_quantity": 3},
{"item_id": "HC002", "item_name": "缝线", "total_quantity": 1},
],
}
}
)
surgery_id: str = Field(description="手术6位号。")
status: str = Field(description="结果状态,例如 pending / completed / failed。")
message: str = Field(description="返回说明。")
details: list[SurgeryConsumptionDetail] = Field(
default_factory=list,
description="消耗明细行:每条含物品、数量、医生与时间;同一物品可多次出现。",
)
summary: list[SurgeryConsumptionSummary] = Field(
default_factory=list,
description="按物品汇总的消耗合计,应与 details 按 item_id 汇总一致。",
)