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:
@@ -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]
|
||||
#: 分类类名(归一化) -> 业务物品 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]]
|
||||
|
||||
|
||||
@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,
|
||||
|
||||
Reference in New Issue
Block a user