Files
operating-room-monitor-server/app/services/video/session_registry.py
Kevin 3d7bd70355 feat: 手术视频消耗、待确认与持久化改造
- 新增 Alembic 初始迁移、领域明细模型及归档持久化与重试链路\n- 拆分视频会话注册表、分类处理、推理时间窗聚合与流处理\n- 消耗日志:TSV/Markdown 含 top2/top3;item_id 优先产品编码;待确认记「待确认」行,语音确认后落正式行并更新汇总\n- 待确认时内存/DB 明细为占位行,确认后替换;拒绝时移除占位\n- 分类 probs 先 detach/cpu 再转 NumPy,修复 MPS/CUDA 上推理被静默跳过\n- 补充集成测试、归档与设备张量等单测

Made-with: Cursor
2026-04-23 20:42:21 +08:00

435 lines
15 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.
"""内存态的手术会话注册表。
该模块只管「活跃会话的共享内存状态」:候选耗材、推理投票、明细、语音待确认项、近期
语音 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]
#: 分类类名(归一化) -> 业务物品 idExcel 产品编码或名称)。
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 的确认任务 idFIFO。
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