"""内存态的手术会话注册表。 该模块只管「活跃会话的共享内存状态」:候选耗材、推理投票、明细、语音待确认项、近期 语音 trace。不知道 RTSP、数据库或持久化细节,便于 `VoiceConfirmationService` 等组件通过 `PendingConfirmationStore` 协议依赖。 """ from __future__ import annotations import asyncio import time import uuid from dataclasses import dataclass, field from datetime import datetime, timezone from typing import Literal from app.config import Settings from app.domain.consumption import SurgeryConsumptionStored from app.services.consumable_vision_algorithm import ( ClsTop3, PredictionCandidate, _norm_product_name, ) from app.services.consumption_tsv_log import ( append_consumption_voice_resolution_line, resolve_consumption_ids, resolve_consumption_item_id, ) from app.services.voice_confirm import build_prompt_text from app.surgery_errors import SurgeryPipelineError @dataclass class PendingConsumableConfirmation: """待客户端确认的一条低置信度识别(不阻塞后续帧推理)。""" id: str status: Literal["pending", "confirmed", "rejected"] options: list[tuple[str, float]] prompt_text: str created_at: datetime model_top1_label: str model_top1_confidence: float #: 本轮待确认在解析失败时累计次数(首败 + 重试),供 API 计算 retry_remaining。 voice_parse_failures: int = 0 @dataclass class CameraStreamInferState: """单路视频上的时间窗投票(与离线算法一致)。""" votes: list[tuple[float, str, ClsTop3]] = field(default_factory=list) stream_t0: float | None = None #: 与 ``stream_t0`` 同一次初始化时的 ``time.time()``,与 monotonic 流逝秒相加得到墙钟时间戳 stream_wall_start: float | None = None next_bucket: int = 0 @dataclass class SurgerySessionState: candidate_consumables: list[str] #: 分类类名(归一化) -> 业务物品 id(Excel 产品编码或名称)。 name_to_code: dict[str, str] = field(default_factory=dict) camera_infer: dict[str, CameraStreamInferState] = field(default_factory=dict) 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) #: 仅含 status=pending 的确认任务 id,FIFO。 pending_fifo: list[str] = field(default_factory=list) pending_by_id: dict[str, PendingConsumableConfirmation] = field(default_factory=dict) last_pending_prompt_snippet: str | None = None #: 最近一次语音确认 ASR 文本(成功识别时写入)。 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 class RunningSurgery: stop_event: asyncio.Event state: SurgerySessionState tasks: list[asyncio.Task[None]] class SurgerySessionRegistry: """活跃手术会话的内存索引;实现 ``PendingConfirmationStore`` 协议。 持有 ``_active`` 与 ``_manager_lock``;暴露只读查询与原子写入方法。 生命周期归 ``CameraSessionManager`` 负责,新增/停止会话都走本类。 """ def __init__(self, *, settings: Settings) -> None: self._s = settings self._active: dict[str, RunningSurgery] = {} self._manager_lock = asyncio.Lock() @property def manager_lock(self) -> asyncio.Lock: return self._manager_lock def has_active(self, surgery_id: str) -> bool: return surgery_id in self._active def get_running(self, surgery_id: str) -> RunningSurgery | None: return self._active.get(surgery_id) def active_ids(self) -> list[str]: return list(self._active.keys()) async def register(self, surgery_id: str, running: RunningSurgery) -> None: async with self._manager_lock: self._active[surgery_id] = running async def unregister(self, surgery_id: str) -> RunningSurgery | None: async with self._manager_lock: return self._active.pop(surgery_id, None) def live_consumption_if_active( self, surgery_id: str ) -> list[SurgeryConsumptionStored] | None: run = self._active.get(surgery_id) if run is None: return None if not run.state.ready.is_set(): return None rows = list(run.state.details) if not rows: return None return rows def record_voice_trace( self, surgery_id: str, *, asr_text: str | None, error: str | None, ) -> None: run = self._active.get(surgery_id) if run is None: return st = run.state st.last_asr_text = asr_text st.last_voice_error = error def get_pending_confirmation_by_id( self, surgery_id: str, confirmation_id: str, ) -> PendingConsumableConfirmation | None: run = self._active.get(surgery_id) if run is None: return None p = run.state.pending_by_id.get(confirmation_id) if p is None or p.status != "pending": return None return p def get_surgery_candidate_consumables(self, surgery_id: str) -> list[str]: run = self._active.get(surgery_id) if run is None: return [] return list(run.state.candidate_consumables) async def record_voice_parse_failure( self, surgery_id: str, confirmation_id: str ) -> tuple[int, int]: run = self._active.get(surgery_id) if run is None: return 0, 0 st = run.state max_r = int(self._s.voice_confirm_max_failed_parse_rounds) async with st.lock: p = st.pending_by_id.get(confirmation_id) if p is None or p.status != "pending": return 0, 0 p.voice_parse_failures += 1 remaining = max(0, max_r - p.voice_parse_failures) return p.voice_parse_failures, remaining def next_pending_confirmation( self, surgery_id: str ) -> PendingConsumableConfirmation | None: run = self._active.get(surgery_id) if run is None: return None st = run.state for cid in st.pending_fifo: p = st.pending_by_id.get(cid) if p is not None and p.status == "pending": return p return None async def resolve_pending_confirmation( self, surgery_id: str, confirmation_id: str, *, chosen_label: str | None, rejected: bool, ) -> None: run = self._active.get(surgery_id) if run is None: raise SurgeryPipelineError( "CONFIRMATION_NOT_ACTIVE", "该手术当前不在进行中,无法提交确认。", ) st = run.state async with st.lock: pending = st.pending_by_id.get(confirmation_id) if pending is None: raise SurgeryPipelineError( "CONFIRMATION_NOT_FOUND", "未找到该待确认项或已处理。", ) if pending.status != "pending": raise SurgeryPipelineError( "CONFIRMATION_ALREADY_RESOLVED", "该待确认项已处理。", ) if rejected and chosen_label: raise SurgeryPipelineError( "CONFIRMATION_INVALID", "拒绝确认时不应同时提供 chosen_label。", ) if not rejected and not chosen_label: raise SurgeryPipelineError( "CONFIRMATION_INVALID", "请提供 chosen_label 或设置 rejected=true。", ) allowed_pending = {lbl.strip() for lbl, _ in pending.options if lbl.strip()} allowed_surgery = {c.strip() for c in st.candidate_consumables if c.strip()} if rejected: pending.status = "rejected" st.details = [ d for d in st.details if d.pending_confirmation_id != confirmation_id and d.item_id != f"pending:{confirmation_id}" ] else: label = chosen_label.strip() if chosen_label else "" if label not in allowed_pending and label not in allowed_surgery: raise SurgeryPipelineError( "CONFIRMATION_INVALID", f"所选耗材不在本台手术候选清单或本次追问选项中:{chosen_label!r}", ) pending.status = "confirmed" item_id = resolve_consumption_item_id(label, "", st.name_to_code) resolved = SurgeryConsumptionStored( item_id=item_id, item_name=label, qty=1, doctor_id=self._s.video_voice_confirm_doctor_id, timestamp=datetime.now(timezone.utc), source="voice", pending_confirmation_id=None, ) replaced = False for i, d in enumerate(st.details): if d.pending_confirmation_id == confirmation_id: st.details[i] = resolved replaced = True break if not replaced: for i, d in enumerate(st.details): if d.item_id == f"pending:{confirmation_id}": st.details[i] = resolved replaced = True break if not replaced: self._append_confirmed_detail_locked( state=st, item_id=item_id, item_name=label, doctor_id=self._s.video_voice_confirm_doctor_id, source="voice", ) self._finalize_voice_confirmed_consumption_log( state=st, surgery_id=surgery_id, chosen_label=label, ) try: idx = st.pending_fifo.index(confirmation_id) st.pending_fifo.pop(idx) except ValueError: pass st.pending_by_id.pop(confirmation_id, None) def _finalize_voice_confirmed_consumption_log( self, *, state: SurgerySessionState, surgery_id: str, chosen_label: str, ) -> None: """待确认流程在语音落锤后:汇总 +1 最终耗材,并追加 TSV 正式行。""" cl = (chosen_label or "").strip() if not cl: return _, key_chosen = resolve_consumption_ids(cl, "", state.name_to_code) tot = state.consumption_log_totals if key_chosen not in tot: tot[key_chosen] = (cl, 0) nm, q = tot[key_chosen] tot[key_chosen] = (nm, q + 1) append_consumption_voice_resolution_line( surgery_id=surgery_id, name_to_code=state.name_to_code, chosen_label=cl, doctor_id=self._s.video_voice_confirm_doctor_id, wall_epoch=time.time(), tsv_enabled=self._s.consumption_tsv_log_enabled, ) def _append_confirmed_detail_locked( self, *, state: SurgerySessionState, item_id: str, item_name: str, doctor_id: str, source: str, ) -> None: """在已持有 ``state.lock`` 时追加一条消耗明细。""" now_m = time.monotonic() cooldown = self._s.video_detail_cooldown_sec prev = state.last_detail_monotonic.get(item_id) if prev is not None and (now_m - prev) < cooldown: return state.last_detail_monotonic[item_id] = now_m state.details.append( SurgeryConsumptionStored( item_id=item_id, item_name=item_name, qty=1, doctor_id=doctor_id, timestamp=datetime.now(timezone.utc), source=source, pending_confirmation_id=None, ) ) def _append_pending_detail_locked( self, *, state: SurgerySessionState, confirmation_id: str, doctor_id: str, ) -> None: pid = f"pending:{confirmation_id}" state.details.append( SurgeryConsumptionStored( item_id=pid, item_name="待确认", qty=1, doctor_id=doctor_id, timestamp=datetime.now(timezone.utc), source="pending_confirmation", pending_confirmation_id=confirmation_id, ) ) async def append_pending_consumption_detail( self, *, state: SurgerySessionState, confirmation_id: str, doctor_id: str, ) -> None: async with state.lock: self._append_pending_detail_locked( state=state, confirmation_id=confirmation_id, doctor_id=doctor_id, ) async def append_confirmed_detail( self, *, state: SurgerySessionState, item_id: str, item_name: str, doctor_id: str, source: str, ) -> None: async with state.lock: self._append_confirmed_detail_locked( state=state, item_id=item_id, item_name=item_name, doctor_id=doctor_id, source=source, ) async def enqueue_pending_confirmation( self, state: SurgerySessionState, ranked: list[PredictionCandidate], *, top_key: str, top_confidence: float, ) -> str | None: """向 pending FIFO 追加一条待人工确认项;返回分配的 confirmation_id;冷却期内则返回 None。""" opts = [(c.label.strip(), float(c.confidence)) for c in ranked if c.label.strip()] if not opts: return None now_m = time.monotonic() cooldown = self._s.video_detail_cooldown_sec dedupe_key = f"pending_confirm:{top_key}:{opts[0][0]}" async with state.lock: prev = state.last_detail_monotonic.get(dedupe_key) if prev is not None and (now_m - prev) < cooldown: return None state.last_detail_monotonic[dedupe_key] = now_m confirm_id = str(uuid.uuid4()) prompt = build_prompt_text(opts) pending = PendingConsumableConfirmation( id=confirm_id, status="pending", options=list(opts), prompt_text=prompt, created_at=datetime.now(timezone.utc), model_top1_label=top_key, model_top1_confidence=top_confidence, ) state.pending_by_id[confirm_id] = pending state.pending_fifo.append(confirm_id) state.last_pending_prompt_snippet = prompt[:200] return confirm_id