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 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 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 汇总一致。", )