2026-04-23 20:42:21 +08:00
|
|
|
|
"""内存态的手术会话注册表。
|
|
|
|
|
|
|
|
|
|
|
|
该模块只管「活跃会话的共享内存状态」:候选耗材、推理投票、明细、语音待确认项、近期
|
|
|
|
|
|
语音 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
|
|
|
|
|
|
|
2026-04-24 15:33:22 +08:00
|
|
|
|
from app.baked import pipeline as bp
|
2026-04-23 20:42:21 +08:00
|
|
|
|
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 (
|
2026-04-24 14:27:56 +08:00
|
|
|
|
replace_pending_line_with_voice_resolution,
|
2026-04-23 20:42:21 +08:00
|
|
|
|
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]
|
2026-04-24 11:05:17 +08:00
|
|
|
|
#: 分类类名(归一化) -> 业务物品 id(YAML label_id 或类名)
|
2026-04-23 20:42:21 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
|
class RunningSurgery:
|
|
|
|
|
|
stop_event: asyncio.Event
|
|
|
|
|
|
state: SurgerySessionState
|
|
|
|
|
|
tasks: list[asyncio.Task[None]]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SurgerySessionRegistry:
|
|
|
|
|
|
"""活跃手术会话的内存索引;实现 ``PendingConfirmationStore`` 协议。
|
|
|
|
|
|
|
|
|
|
|
|
持有 ``_active`` 与 ``_manager_lock``;暴露只读查询与原子写入方法。
|
|
|
|
|
|
生命周期归 ``CameraSessionManager`` 负责,新增/停止会话都走本类。
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
2026-04-24 15:33:22 +08:00
|
|
|
|
def __init__(self) -> None:
|
2026-04-23 20:42:21 +08:00
|
|
|
|
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
|
2026-04-24 15:33:22 +08:00
|
|
|
|
max_r = int(bp.VOICE_CONFIRM_MAX_FAILED_PARSE_ROUNDS)
|
2026-04-23 20:42:21 +08:00
|
|
|
|
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,
|
2026-04-24 15:33:22 +08:00
|
|
|
|
doctor_id=bp.VIDEO_VOICE_CONFIRM_DOCTOR_ID,
|
2026-04-23 20:42:21 +08:00
|
|
|
|
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,
|
2026-04-24 15:33:22 +08:00
|
|
|
|
doctor_id=bp.VIDEO_VOICE_CONFIRM_DOCTOR_ID,
|
2026-04-23 20:42:21 +08:00
|
|
|
|
source="voice",
|
|
|
|
|
|
)
|
|
|
|
|
|
self._finalize_voice_confirmed_consumption_log(
|
|
|
|
|
|
state=st,
|
|
|
|
|
|
surgery_id=surgery_id,
|
2026-04-24 14:27:56 +08:00
|
|
|
|
confirmation_id=confirmation_id,
|
2026-04-23 20:42:21 +08:00
|
|
|
|
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,
|
2026-04-24 14:27:56 +08:00
|
|
|
|
confirmation_id: str,
|
2026-04-23 20:42:21 +08:00
|
|
|
|
chosen_label: str,
|
|
|
|
|
|
) -> None:
|
2026-04-24 14:27:56 +08:00
|
|
|
|
"""待确认流程在语音落锤后:将 TSV 中原 pending 行替换为最终真值。停录汇总与 HTTP 一致,由 details 经 ``build_consumption_summary`` 得到。"""
|
2026-04-23 20:42:21 +08:00
|
|
|
|
cl = (chosen_label or "").strip()
|
|
|
|
|
|
if not cl:
|
|
|
|
|
|
return
|
2026-04-24 14:27:56 +08:00
|
|
|
|
replace_pending_line_with_voice_resolution(
|
2026-04-23 20:42:21 +08:00
|
|
|
|
surgery_id=surgery_id,
|
2026-04-24 14:27:56 +08:00
|
|
|
|
confirmation_id=confirmation_id,
|
2026-04-23 20:42:21 +08:00
|
|
|
|
name_to_code=state.name_to_code,
|
|
|
|
|
|
chosen_label=cl,
|
2026-04-24 15:33:22 +08:00
|
|
|
|
doctor_id=bp.VIDEO_VOICE_CONFIRM_DOCTOR_ID,
|
2026-04-23 20:42:21 +08:00
|
|
|
|
wall_epoch=time.time(),
|
2026-04-24 15:33:22 +08:00
|
|
|
|
tsv_enabled=bp.CONSUMPTION_TSV_LOG_ENABLED,
|
2026-04-23 20:42:21 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def _append_confirmed_detail_locked(
|
|
|
|
|
|
self,
|
|
|
|
|
|
*,
|
|
|
|
|
|
state: SurgerySessionState,
|
|
|
|
|
|
item_id: str,
|
|
|
|
|
|
item_name: str,
|
|
|
|
|
|
doctor_id: str,
|
|
|
|
|
|
source: str,
|
2026-04-24 15:33:22 +08:00
|
|
|
|
cooldown_key: str | None = None,
|
|
|
|
|
|
detail_timestamp: datetime | None = None,
|
2026-04-23 20:42:21 +08:00
|
|
|
|
) -> None:
|
2026-04-24 15:33:22 +08:00
|
|
|
|
"""在已持有 ``state.lock`` 时追加一条消耗明细。
|
|
|
|
|
|
|
|
|
|
|
|
``cooldown_key``:非空时用于 `video_detail_cooldown_sec` 去重(例如撕段每段独立键,避免同 SKU 多段被吞)。
|
|
|
|
|
|
``detail_timestamp``:非空时写入该 UTC 时刻,否则为当前时间。
|
|
|
|
|
|
"""
|
|
|
|
|
|
dedupe = cooldown_key if cooldown_key is not None else item_id
|
2026-04-23 20:42:21 +08:00
|
|
|
|
now_m = time.monotonic()
|
2026-04-24 15:33:22 +08:00
|
|
|
|
cooldown = bp.VIDEO_DETAIL_COOLDOWN_SEC
|
|
|
|
|
|
prev = state.last_detail_monotonic.get(dedupe)
|
2026-04-23 20:42:21 +08:00
|
|
|
|
if prev is not None and (now_m - prev) < cooldown:
|
|
|
|
|
|
return
|
2026-04-24 15:33:22 +08:00
|
|
|
|
state.last_detail_monotonic[dedupe] = now_m
|
|
|
|
|
|
ts = detail_timestamp if detail_timestamp is not None else datetime.now(timezone.utc)
|
2026-04-23 20:42:21 +08:00
|
|
|
|
state.details.append(
|
|
|
|
|
|
SurgeryConsumptionStored(
|
|
|
|
|
|
item_id=item_id,
|
|
|
|
|
|
item_name=item_name,
|
|
|
|
|
|
qty=1,
|
|
|
|
|
|
doctor_id=doctor_id,
|
2026-04-24 15:33:22 +08:00
|
|
|
|
timestamp=ts,
|
2026-04-23 20:42:21 +08:00
|
|
|
|
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,
|
2026-04-24 15:33:22 +08:00
|
|
|
|
cooldown_key: str | None = None,
|
|
|
|
|
|
detail_timestamp: datetime | None = None,
|
2026-04-23 20:42:21 +08:00
|
|
|
|
) -> 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,
|
2026-04-24 15:33:22 +08:00
|
|
|
|
cooldown_key=cooldown_key,
|
|
|
|
|
|
detail_timestamp=detail_timestamp,
|
2026-04-23 20:42:21 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
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()
|
2026-04-24 15:33:22 +08:00
|
|
|
|
cooldown = bp.VIDEO_DETAIL_COOLDOWN_SEC
|
2026-04-23 20:42:21 +08:00
|
|
|
|
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
|