feat: 手术视频消耗、待确认与持久化改造

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

Made-with: Cursor
This commit is contained in:
Kevin
2026-04-23 20:42:21 +08:00
parent 69980d8073
commit 3d7bd70355
55 changed files with 4544 additions and 2050 deletions

View File

@@ -2,120 +2,61 @@ 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 loguru import logger
from app.config import Settings
from sqlalchemy.ext.asyncio import async_sessionmaker
from app.database import AsyncSessionLocal
from app.domain.consumption import SurgeryConsumptionStored
from app.repositories.surgery_results import SurgeryResultRepository
from app.schemas import SurgeryConsumptionDetail, SurgeryConsumptionStored
from app.services.consumable_vision_algorithm import (
ClsTop3,
ConsumableVisionAlgorithmService,
PredictionCandidate,
PredictionResult,
_norm_product_name,
cls_top3_to_prediction_result,
window_bucket_to_best_snap,
)
from app.services.video.archive_persister import ArchivePersister
from app.services.video.backend_resolver import BackendResolver
from app.services.video.classification_handler import VisionClassificationHandler
from app.services.video.hikvision_runtime import HikvisionInitRefCount, HikvisionRuntime
from app.services.video.rtsp_capture import RtspCapture
from app.services.video.inference_aggregator import WindowInferenceAggregator
from app.services.video.session_registry import (
CameraStreamInferState,
PendingConsumableConfirmation,
RunningSurgery,
SurgerySessionRegistry,
SurgerySessionState,
)
from app.services.video.stream_worker import CameraStreamWorker, redact_rtsp_url
from app.services.video.types import VideoBackendKind
from app.services.consumption_tsv_log import (
append_consumption_log_summary,
append_consumption_window,
init_consumption_log_file,
print_consumption_summary_markdown,
)
from app.services.voice_file_log import init_voice_log_file
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]]
@dataclass
class ArchivedSurgery:
details: list[SurgeryConsumptionStored]
def _rank_topk_for_candidates(
topk: list[PredictionCandidate],
ordered_candidates: list[str],
*,
limit: int = 5,
) -> list[PredictionCandidate]:
if not topk:
return []
stripped_order = [c.strip() for c in ordered_candidates if c.strip()]
if not stripped_order:
return topk[:limit]
order_index = {name: i for i, name in enumerate(stripped_order)}
picked = [c for c in topk if c.label.strip() in order_index]
picked.sort(key=lambda c: order_index[c.label.strip()])
return picked[:limit]
__all__ = [
"CameraSessionManager",
"CameraStreamInferState",
"PendingConsumableConfirmation",
"RunningSurgery",
"SurgerySessionState",
]
class CameraSessionManager:
"""Per-surgery camera streams, RTSP + optional Hikvision SDK login, inference, client-side human confirm."""
"""Per-surgery camera orchestration.
本类负责:
1. 开始/停止手术:创建 `SurgerySessionState`、拉起相机 worker、停录时收尾。
2. 把「语音确认所需的内存态」委托给 ``SurgerySessionRegistry``(实现 `PendingConfirmationStore`)。
3. 把「结果写库 + 失败重试 + durable fallback」委托给 ``ArchivePersister``。
对外接口保持不变,上游(``SurgeryPipeline`` / ``VoiceConfirmationService``)无需感知拆分。
"""
def __init__(
self,
@@ -124,131 +65,92 @@ class CameraSessionManager:
vision_algorithm: ConsumableVisionAlgorithmService,
hikvision_runtime: HikvisionRuntime | None,
result_repository: SurgeryResultRepository | None = None,
session_factory: async_sessionmaker | None = None,
registry: SurgerySessionRegistry | None = None,
archive_persister: ArchivePersister | None = None,
) -> None:
self._s = settings
self._vision = vision_algorithm
self._hik = hikvision_runtime
self._repo = result_repository
self._session_factory: async_sessionmaker = session_factory or AsyncSessionLocal
self._resolver = BackendResolver(settings, hikvision_runtime=hikvision_runtime)
self._active: dict[str, RunningSurgery] = {}
self._archive: dict[str, ArchivedSurgery] = {}
self._manager_lock = asyncio.Lock()
self._retry_task: asyncio.Task[None] | None = None
self._retry_stop = asyncio.Event()
async def start_archive_retry_loop(self) -> None:
if self._retry_task is not None and not self._retry_task.done():
return
self._retry_stop.clear()
self._retry_task = asyncio.create_task(
self._archive_persist_retry_loop(),
name="archive_persist_retry",
self._registry = registry or SurgerySessionRegistry(settings=settings)
self._archive = archive_persister or ArchivePersister(
settings=settings,
repository=result_repository,
session_factory=self._session_factory,
)
self._aggregator = WindowInferenceAggregator(settings=settings)
self._classifier_handler = VisionClassificationHandler(
settings=settings,
registry=self._registry,
)
# ------------------------------------------------------------------
# 生命周期
# ------------------------------------------------------------------
async def start_archive_retry_loop(self) -> None:
await self._archive.recover_from_durable_fallback()
await self._archive.start_retry_loop()
async def shutdown(self) -> None:
self._retry_stop.set()
if self._retry_task is not None:
self._retry_task.cancel()
try:
await self._retry_task
except asyncio.CancelledError:
pass
except Exception as exc:
logger.debug("retry task shutdown: {}", exc)
self._retry_task = None
async with self._manager_lock:
ids = list(self._active.keys())
await self._archive.shutdown()
ids = self._registry.active_ids()
for sid in ids:
try:
await self.stop_surgery(sid, require_active=False)
except Exception as exc:
logger.warning("shutdown stop_surgery {}: {}", sid, exc)
async def _archive_persist_retry_loop(self) -> None:
while not self._retry_stop.is_set():
try:
await asyncio.wait_for(
self._retry_stop.wait(),
timeout=self._s.archive_persist_retry_interval_seconds,
)
break
except TimeoutError:
pass
ids = list(self._archive.keys())
for sid in ids:
if self._retry_stop.is_set():
break
await self._try_persist_archive(sid)
async def _try_persist_archive(self, surgery_id: str) -> bool:
if self._repo is None:
return False
async with self._manager_lock:
arch = self._archive.get(surgery_id)
if arch is None:
return True
try:
async with AsyncSessionLocal() as session:
async with session.begin():
await self._repo.save_final_result(
session,
surgery_id=surgery_id,
details=list(arch.details),
)
except Exception as exc:
logger.warning(
"Archive persist retry failed surgery_id={}: {}",
surgery_id,
exc,
)
return False
async with self._manager_lock:
self._archive.pop(surgery_id, None)
logger.info("Archive persisted after retry surgery_id={}", surgery_id)
return True
# ------------------------------------------------------------------
# Surgery start / stop
# ------------------------------------------------------------------
async def start_surgery(
self,
surgery_id: str,
camera_ids: list[str],
candidate_consumables: list[str],
) -> None:
stale_archive: ArchivedSurgery | None = None
async with self._manager_lock:
if surgery_id in self._active:
raise SurgeryPipelineError(
"RECORDING_CANNOT_START",
"该手术已在录制中,请勿重复开始。",
)
if surgery_id in self._archive:
logger.warning(
"surgery_id={} 仍有未落库归档,尝试写入数据库后再开始新会话",
surgery_id,
)
stale_archive = self._archive.pop(surgery_id)
if stale_archive is not None:
if self._repo is None:
if self._registry.has_active(surgery_id):
raise SurgeryPipelineError(
"RECORDING_CANNOT_START",
"该手术已在录制中,请勿重复开始。",
)
stale = await self._archive.take_archived_details(surgery_id)
if stale is not None:
logger.warning(
"surgery_id={} 仍有未落库归档,尝试写入数据库后再开始新会话",
surgery_id,
)
if self._archive.repository is None:
logger.error(
"surgery_id={} 有内存归档但未配置数据库仓库,无法持久化;"
"开始新会话将丢弃该归档(仅开发/无库模式)",
surgery_id,
)
else:
ok = await self._persist_archived_details(
surgery_id, list(stale_archive.details)
)
ok = await self._archive.persist_or_archive(surgery_id, stale)
if not ok:
async with self._manager_lock:
self._archive[surgery_id] = stale_archive
raise SurgeryPipelineError(
"RECORDING_CANNOT_START",
"该手术号存在尚未写入数据库的历史结果,请修复数据库或等待自动重试成功后再开始。",
)
name_to_code = self._vision.build_name_mapping(candidate_consumables)
resolved = self._vision.effective_candidate_consumables(candidate_consumables)
if not resolved:
raise SurgeryPipelineError(
"RECORDING_CANNOT_START",
"耗材候选为空:请在请求中传入 candidate_consumables或配置耗材目录 Excel / 分类模型。",
)
if not any(str(x).strip() for x in candidate_consumables):
logger.info(
"surgery {}: candidate_consumables 未提供,使用默认全量 {}",
surgery_id,
len(resolved),
)
name_to_code = self._vision.build_name_mapping(resolved)
state = SurgerySessionState(
candidate_consumables=list(candidate_consumables),
candidate_consumables=list(resolved),
name_to_code=name_to_code,
)
stop_event = asyncio.Event()
@@ -273,8 +175,7 @@ class CameraSessionManager:
run = RunningSurgery(stop_event=stop_event, state=state, tasks=tasks)
init_consumption_log_file(surgery_id)
init_voice_log_file(surgery_id, self._s)
async with self._manager_lock:
self._active[surgery_id] = run
await self._registry.register(surgery_id, run)
try:
await asyncio.wait_for(
@@ -297,33 +198,8 @@ class CameraSessionManager:
await self.stop_surgery(surgery_id, require_active=True)
raise
async def _persist_archived_details(
self,
surgery_id: str,
details: list[SurgeryConsumptionStored],
) -> bool:
if self._repo is None:
return True
try:
async with AsyncSessionLocal() as session:
async with session.begin():
await self._repo.save_final_result(
session,
surgery_id=surgery_id,
details=details,
)
except Exception as exc:
logger.exception(
"Persist archived surgery {} failed (will keep archive): {}",
surgery_id,
exc,
)
return False
return True
async def stop_surgery(self, surgery_id: str, *, require_active: bool = True) -> None:
async with self._manager_lock:
run = self._active.pop(surgery_id, None)
run = await self._registry.unregister(surgery_id)
if run is None:
if require_active:
raise SurgeryPipelineError(
@@ -343,45 +219,20 @@ class CameraSessionManager:
print_consumption_summary_markdown(totals)
details = list(run.state.details)
await self._archive.persist_or_archive(surgery_id, details)
persisted = False
if self._repo is not None:
try:
async with AsyncSessionLocal() as session:
async with session.begin():
await self._repo.save_final_result(
session,
surgery_id=surgery_id,
details=details,
)
persisted = True
except Exception as exc:
logger.exception("Persist surgery {} failed: {}", surgery_id, exc)
# ------------------------------------------------------------------
# PendingConfirmationStore 协议委托
# ------------------------------------------------------------------
def live_consumption_if_active(
self, surgery_id: str
) -> list[SurgeryConsumptionStored] | None:
return self._registry.live_consumption_if_active(surgery_id)
async with self._manager_lock:
if not persisted:
self._archive[surgery_id] = ArchivedSurgery(details=details)
logger.error(
"Surgery {} final result kept in memory archive only; "
"background retry will attempt persist",
surgery_id,
)
def live_consumption_if_active(self, surgery_id: str) -> list[SurgeryConsumptionDetail] | None:
if surgery_id not in self._active:
return None
if not self._active[surgery_id].state.ready.is_set():
return None
rows = list(self._active[surgery_id].state.details)
if not rows:
return None
return [r.as_response() for r in rows]
def archived_consumption_fallback(self, surgery_id: str) -> list[SurgeryConsumptionDetail] | None:
arch = self._archive.get(surgery_id)
if arch is None:
return None
return [r.as_response() for r in arch.details]
def archived_consumption_fallback(
self, surgery_id: str
) -> list[SurgeryConsumptionStored] | None:
return self._archive.archived_details(surgery_id)
def record_voice_trace(
self,
@@ -390,57 +241,27 @@ class CameraSessionManager:
asr_text: str | None,
error: str | None,
) -> None:
if surgery_id not in self._active:
return
st = self._active[surgery_id].state
st.last_asr_text = asr_text
st.last_voice_error = error
self._registry.record_voice_trace(surgery_id, asr_text=asr_text, error=error)
def get_pending_confirmation_by_id(
self,
surgery_id: str,
confirmation_id: str,
) -> PendingConsumableConfirmation | None:
if surgery_id not in self._active:
return None
p = self._active[surgery_id].state.pending_by_id.get(confirmation_id)
if p is None or p.status != "pending":
return None
return p
return self._registry.get_pending_confirmation_by_id(surgery_id, confirmation_id)
def get_surgery_candidate_consumables(self, surgery_id: str) -> list[str]:
"""本台手术开始手术时传入的耗材候选清单(语音可任选其中一项,不限于模型 topk"""
if surgery_id not in self._active:
return []
return list(self._active[surgery_id].state.candidate_consumables)
return self._registry.get_surgery_candidate_consumables(surgery_id)
async def record_voice_parse_failure(
self, surgery_id: str, confirmation_id: str
) -> tuple[int, int]:
"""解析失败时累加计数,返回 (当前失败次数, 距上限还剩几次「重试机会」)。"""
if surgery_id not in self._active:
return 0, 0
st = self._active[surgery_id].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
return await self._registry.record_voice_parse_failure(surgery_id, confirmation_id)
def next_pending_confirmation(
self, surgery_id: str
) -> PendingConsumableConfirmation | None:
if surgery_id not in self._active:
return None
st = self._active[surgery_id].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
return self._registry.next_pending_confirmation(surgery_id)
async def resolve_pending_confirmation(
self,
@@ -450,107 +271,16 @@ class CameraSessionManager:
chosen_label: str | None,
rejected: bool,
) -> None:
if surgery_id not in self._active:
raise SurgeryPipelineError(
"CONFIRMATION_NOT_ACTIVE",
"该手术当前不在进行中,无法提交确认。",
)
st = self._active[surgery_id].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"
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"
norm = _norm_product_name(label)
item_id = st.name_to_code.get(norm, label)
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",
)
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 _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,
)
await self._registry.resolve_pending_confirmation(
surgery_id,
confirmation_id,
chosen_label=chosen_label,
rejected=rejected,
)
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,
)
# ------------------------------------------------------------------
# Camera worker拉流 + 推理节流 + 时间窗分桶 + 分类结果处理)
# ------------------------------------------------------------------
async def _camera_worker(
self,
*,
@@ -561,70 +291,23 @@ class CameraSessionManager:
state: SurgerySessionState,
) -> None:
kind = self._resolver.backend_for_camera(camera_id)
cap: RtspCapture | None = None
hik_user_id: int | None = None
hik_init_retained = False
url: str | None = None
consecutive_failures = 0
first_ready = True
try:
url, hik_user_id, hik_init_retained = await self._resolve_rtsp_url(
camera_id=camera_id,
kind=kind,
)
assert url is not None
last_infer = 0.0
while not stop_event.is_set():
if cap is None:
try:
cap = RtspCapture(url, open_timeout_sec=self._s.video_open_timeout_sec)
await asyncio.to_thread(cap.open)
consecutive_failures = 0
if first_ready:
stream_ready.set()
first_ready = False
logger.info(
"RTSP stream opened camera={} surgery={}",
camera_id,
surgery_id,
)
except Exception as exc:
logger.warning(
"RTSP open failed camera={} surgery={}: {}",
camera_id,
surgery_id,
exc,
)
if cap is not None:
await asyncio.to_thread(cap.release)
cap = None
await asyncio.sleep(self._s.video_reconnect_backoff_seconds)
continue
ok, frame = await asyncio.to_thread(cap.read)
if not ok or frame is None:
consecutive_failures += 1
if consecutive_failures >= self._s.video_read_failure_reconnect_threshold:
logger.warning(
"RTSP reconnect camera={} surgery={} after {} read failures",
camera_id,
surgery_id,
consecutive_failures,
)
await asyncio.to_thread(cap.release)
cap = None
consecutive_failures = 0
await asyncio.sleep(self._s.video_reconnect_backoff_seconds)
else:
await asyncio.sleep(0.05)
continue
consecutive_failures = 0
async def _frame_handler(frame: object) -> None:
nonlocal last_infer
now = time.monotonic()
if now - last_infer < self._s.video_inference_interval_sec:
await asyncio.sleep(0.01)
continue
return
last_infer = now
try:
snap = await asyncio.to_thread(
@@ -639,10 +322,10 @@ class CameraSessionManager:
surgery_id,
exc,
)
continue
return
if snap is None:
continue
return
if self._s.video_log_inference_results:
logger.info(
@@ -657,59 +340,35 @@ class CameraSessionManager:
snap.t3_conf,
)
wsec = self._s.consumable_vision_window_sec
pending_preds: list[PredictionResult] = []
async with state.lock:
cis = state.camera_infer.setdefault(
camera_id, CameraStreamInferState()
)
if cis.stream_t0 is None:
cis.stream_t0 = time.monotonic()
cis.stream_wall_start = time.time()
t_rel = time.monotonic() - cis.stream_t0
cis.votes.append((t_rel, snap.t1_name, snap))
current_b = int(t_rel // wsec)
while cis.next_bucket < current_b:
b = cis.next_bucket
cis.next_bucket += 1
lo, hi = b * wsec, (b + 1) * wsec
bucket_pts = [
(p, sn) for (t, p, sn) in cis.votes if lo <= t < hi
]
cis.votes = [
(t, p, sn)
for (t, p, sn) in cis.votes
if not (lo <= t < hi)
]
if not bucket_pts:
continue
best = window_bucket_to_best_snap(bucket_pts)
if best is not None and cis.stream_wall_start is not None:
if self._s.consumption_tsv_log_enabled or self._s.consumption_log_markdown_terminal:
wall_lo = cis.stream_wall_start + lo
wall_hi = cis.stream_wall_start + hi
append_consumption_window(
surgery_id=surgery_id,
name_to_code=state.name_to_code,
best=best,
doctor_id=self._s.video_result_doctor_id,
camera_id=camera_id,
wall_start_epoch=wall_lo,
wall_end_epoch=wall_hi,
running_totals=state.consumption_log_totals,
)
pending_preds.append(
cls_top3_to_prediction_result(best)
)
for cls_res in pending_preds:
await self._handle_classification_result(
ready_windows = self._aggregator.ingest_snapshot_and_collect_ready(
surgery_id=surgery_id,
camera_id=camera_id,
snap=snap,
state=state,
cls_res=cls_res,
)
for win in ready_windows:
await self._classifier_handler.handle(
state=state,
cls_res=win.prediction,
ready=win,
surgery_id=surgery_id,
camera_id=camera_id,
)
worker = CameraStreamWorker(
settings=self._s,
surgery_id=surgery_id,
camera_id=camera_id,
url=url,
)
await worker.run(
stream_ready=stream_ready,
stop_event=stop_event,
frame_handler=_frame_handler,
)
finally:
if cap is not None:
await asyncio.to_thread(cap.release)
if hik_user_id is not None and self._hik is not None:
await asyncio.to_thread(self._hik.logout, hik_user_id)
if hik_init_retained and self._hik is not None:
@@ -721,96 +380,11 @@ class CameraSessionManager:
state: SurgerySessionState,
cls_res: PredictionResult,
) -> None:
conf = cls_res.confidence
label = (cls_res.label or "").strip()
item_id = state.name_to_code.get(label, label)
voice_floor = self._s.video_voice_confirm_min_confidence
if conf < voice_floor:
return
"""Deprecated test-shim沿用旧签名转发给 ``VisionClassificationHandler``。
cand_order = [c.strip() for c in state.candidate_consumables if c.strip()]
if not cand_order:
return
cand_set = set(cand_order)
ranked = _rank_topk_for_candidates(cls_res.topk, cand_order)
auto_th = self._s.video_auto_confirm_confidence
def in_allowed(name: str) -> bool:
return name in cand_set
if conf >= auto_th and in_allowed(label):
await self._append_confirmed_detail(
state=state,
item_id=item_id or label or "unknown",
item_name=label or "unknown",
doctor_id=self._s.video_result_doctor_id,
source="vision",
)
return
if conf >= auto_th and not in_allowed(label):
if ranked and self._s.voice_confirmation_enabled:
await self._maybe_enqueue_pending_confirmation(
state, ranked, top_key=label, top_confidence=conf
)
return
if not self._s.voice_confirmation_enabled:
return
if ranked:
await self._maybe_enqueue_pending_confirmation(
state, ranked, top_key=label, top_confidence=conf
)
elif in_allowed(label):
await self._maybe_enqueue_pending_confirmation(
state,
[PredictionCandidate(label=label, confidence=conf)],
top_key=label,
top_confidence=conf,
)
async def _maybe_enqueue_pending_confirmation(
self,
state: SurgerySessionState,
ranked: list[PredictionCandidate],
*,
top_key: str,
top_confidence: float,
) -> None:
opts = [(c.label.strip(), float(c.confidence)) for c in ranked if c.label.strip()]
if not opts:
return
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
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]
logger.info(
"Enqueued pending consumable confirmation id={} top_key={}",
confirm_id,
top_key,
)
保留此方法是因为单元测试直接调用了它。新代码应使用 ``self._classifier_handler.handle``。
"""
await self._classifier_handler.handle(state=state, cls_res=cls_res)
async def _resolve_rtsp_url(
self,