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=( "本次手术可能使用到的耗材子集(可选)。" "非空时仅对该清单内名称做自动记账与待确认追问。" "缺省或空数组时,使用 consumable_classifier_labels.yaml 中全部类名;" "无有效 yaml 则使用分类模型全部类名。" ), ) 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 VoiceTerminalAssignmentResponse(BaseModel): """语音桌面终端当前被指派的手术(HTTP 轮询兜底,与 WebSocket 推送一致)。""" voice_terminal_id: str active_surgery_id: str | None = None 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): """单条消耗明细(HTTP 与 OpenAPI;按事件发生,可能多行)。 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=( "业务物品标识:来自 consumable_classifier_labels.yaml 的 label_id;" "类名经归一化后匹配,未在 yaml 中配置 label_id 时与模型类名相同。" ), ) item_name: str = Field(description="物品名称(分类或确认后的展示名)。") qty: int = Field( ge=0, description="本条记录对应的消耗数量;当前一次识别或一次人工确认仅追加一条明细,因此固定为 1。", ) doctor_id: str = Field(description="医生 ID。") timestamp: datetime = Field(description="记录时间(ISO 8601,date-time)。") class SurgeryConsumptionSummary(BaseModel): """按物品汇总:该手术下该物品消耗数量合计(item_id、item_name、total_quantity)。""" 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="该物品在本台手术中的消耗数量合计。") 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.qty) 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 PendingConfirmationOption(BaseModel): label: str confidence: float class SurgeryPendingConfirmationResponse(BaseModel): """当前待医生确认的一条低置信度识别。""" surgery_id: str confirmation_id: str prompt_text: str = Field(description="可直接用于展示或无障碍朗读的话术(与 MP3 内容一致)。") prompt_audio_mp3_base64: str = Field( description=( "与 prompt_text 一致的百度在线语音合成(MP3)的标准 Base64 字符串(无换行);" "成功响应时非空,客户端以 audio/mpeg 解码播放。" "未配置百度语音或合成失败时本接口以 HTTP 4xx/503 返回,见错误码,不返回半套数据。" ), ) options: list[PendingConfirmationOption] model_top1_label: str = Field(description="模型原始 Top1 标签(可能不在候选清单内)。") model_top1_confidence: float created_at: datetime 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": "纱布", "qty": 1, "doctor_id": "D1001", "timestamp": "2026-04-21T10:30:00+08:00", }, { "item_id": "HC001", "item_name": "纱布", "qty": 1, "doctor_id": "D1002", "timestamp": "2026-04-21T11:05:00+08:00", }, { "item_id": "HC002", "item_name": "缝线", "qty": 1, "doctor_id": "D1001", "timestamp": "2026-04-21T10:45:00+08:00", }, ], "summary": [ {"item_id": "HC001", "item_name": "纱布", "total_quantity": 2}, {"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=( "消耗明细(多行)。每行字段顺序:item_id、item_name、qty、doctor_id、timestamp;" "同一 item_id 可多次出现。" ), ) summary: list[SurgeryConsumptionSummary] = Field( default_factory=list, description=( "按 item_id 汇总的合计表(仅 item_id、item_name、total_quantity)," "应与 details 按 item_id 汇总 qty 一致。" ), )