- 用 OR_SITE_CONFIG_JSON_FILE 统一术间配置(video_rtsp_urls + voice_or_room_bindings) - VoiceTerminalHub:assignment、WS 推送与 HTTP 查询;开录/停录后 notify - 一键联调 orchestrate-and-start 与 /client/surgeries/start 共用指派逻辑,修复 demo 路径不发 WS - 语音桌面端:SIGINT 退出、shutdown 清理、仅 WS 指派、固定 pending 轮询间隔、界面仅保留录音时长 - 新增/调整契约与绑定测试,文档与示例配置同步 Made-with: Cursor
454 lines
16 KiB
Python
454 lines
16 KiB
Python
"""内存态的手术会话注册表。
|
||
|
||
该模块只管「活跃会话的共享内存状态」:候选耗材、推理投票、明细、语音待确认项、近期
|
||
语音 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.baked import pipeline as bp
|
||
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 (
|
||
replace_pending_line_with_voice_resolution,
|
||
resolve_consumption_item_id,
|
||
)
|
||
from app.services.voice_confirm import build_prompt_text
|
||
from app.surgery_errors import SurgeryPipelineError
|
||
|
||
|
||
def format_elapsed_mmss_since(surgery_started_wall: float | None, *, at_epoch: float) -> str:
|
||
"""从 ``start_surgery`` 记录的开录时刻到 ``at_epoch`` 的流逝时间(分+秒),供终端 loguru 使用。"""
|
||
if surgery_started_wall is None:
|
||
return "—"
|
||
sec = max(0.0, at_epoch - surgery_started_wall)
|
||
total = int(sec)
|
||
m, s = divmod(total, 60)
|
||
return f"{m}分{s:02d}秒"
|
||
|
||
|
||
@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(YAML label_id 或类名)
|
||
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
|
||
#: ``start_surgery`` 创建会话时的 ``time.time()``,用于日志中「相对开录的流逝时间」。
|
||
surgery_started_wall: float | None = None
|
||
#: 术间绑定配置解析出的语音桌面终端 ID;停录时用于推送 end。
|
||
voice_terminal_id: str | None = None
|
||
|
||
|
||
@dataclass
|
||
class RunningSurgery:
|
||
stop_event: asyncio.Event
|
||
state: SurgerySessionState
|
||
tasks: list[asyncio.Task[None]]
|
||
|
||
|
||
class SurgerySessionRegistry:
|
||
"""活跃手术会话的内存索引;实现 ``PendingConfirmationStore`` 协议。
|
||
|
||
持有 ``_active`` 与 ``_manager_lock``;暴露只读查询与原子写入方法。
|
||
生命周期归 ``CameraSessionManager`` 负责,新增/停止会话都走本类。
|
||
"""
|
||
|
||
def __init__(self) -> None:
|
||
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(bp.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=bp.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=bp.VIDEO_VOICE_CONFIRM_DOCTOR_ID,
|
||
source="voice",
|
||
)
|
||
self._finalize_voice_confirmed_consumption_log(
|
||
state=st,
|
||
surgery_id=surgery_id,
|
||
confirmation_id=confirmation_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,
|
||
confirmation_id: str,
|
||
chosen_label: str,
|
||
) -> None:
|
||
"""待确认流程在语音落锤后:将 TSV 中原 pending 行替换为最终真值。停录汇总与 HTTP 一致,由 details 经 ``build_consumption_summary`` 得到。"""
|
||
cl = (chosen_label or "").strip()
|
||
if not cl:
|
||
return
|
||
replace_pending_line_with_voice_resolution(
|
||
surgery_id=surgery_id,
|
||
confirmation_id=confirmation_id,
|
||
name_to_code=state.name_to_code,
|
||
chosen_label=cl,
|
||
doctor_id=bp.VIDEO_VOICE_CONFIRM_DOCTOR_ID,
|
||
wall_epoch=time.time(),
|
||
tsv_enabled=bp.CONSUMPTION_TSV_LOG_ENABLED,
|
||
)
|
||
|
||
def _append_confirmed_detail_locked(
|
||
self,
|
||
*,
|
||
state: SurgerySessionState,
|
||
item_id: str,
|
||
item_name: str,
|
||
doctor_id: str,
|
||
source: str,
|
||
cooldown_key: str | None = None,
|
||
detail_timestamp: datetime | None = None,
|
||
) -> None:
|
||
"""在已持有 ``state.lock`` 时追加一条消耗明细。
|
||
|
||
``cooldown_key``:非空时用于 `video_detail_cooldown_sec` 去重(例如撕段每段独立键,避免同 SKU 多段被吞)。
|
||
``detail_timestamp``:非空时写入该 UTC 时刻,否则为当前时间。
|
||
"""
|
||
dedupe = cooldown_key if cooldown_key is not None else item_id
|
||
now_m = time.monotonic()
|
||
cooldown = bp.VIDEO_DETAIL_COOLDOWN_SEC
|
||
prev = state.last_detail_monotonic.get(dedupe)
|
||
if prev is not None and (now_m - prev) < cooldown:
|
||
return
|
||
state.last_detail_monotonic[dedupe] = now_m
|
||
ts = detail_timestamp if detail_timestamp is not None else datetime.now(timezone.utc)
|
||
state.details.append(
|
||
SurgeryConsumptionStored(
|
||
item_id=item_id,
|
||
item_name=item_name,
|
||
qty=1,
|
||
doctor_id=doctor_id,
|
||
timestamp=ts,
|
||
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,
|
||
cooldown_key: str | None = None,
|
||
detail_timestamp: datetime | None = None,
|
||
) -> 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,
|
||
cooldown_key=cooldown_key,
|
||
detail_timestamp=detail_timestamp,
|
||
)
|
||
|
||
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 = bp.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
|