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

@@ -6,6 +6,156 @@ from typing import Any, Literal
from pydantic import Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class _SettingsGroup:
"""按主题分组的 Settings 视图;属性访问代理回主 Settings 实例。
主 Settings 保留所有原始平坦字段作为事实来源;此类仅提供 ``settings.video.xxx``
之类的分组读写入口,减少跨文件的耦合面,同时保持向后兼容。
"""
_FIELDS: tuple[str, ...] = ()
def __init__(self, root: "Settings") -> None:
object.__setattr__(self, "_root", root)
def __getattr__(self, name: str) -> Any:
if name not in self._FIELDS:
raise AttributeError(
f"{type(self).__name__} has no field '{name}'; "
f"available: {self._FIELDS}"
)
return getattr(self._root, name)
def __setattr__(self, name: str, value: Any) -> None:
if name in self._FIELDS:
setattr(self._root, name, value)
else:
object.__setattr__(self, name, value)
class _VideoGroup(_SettingsGroup):
_FIELDS = (
"video_default_backend",
"video_camera_backend_overrides_json",
"video_rtsp_url_template",
"video_rtsp_urls_json",
"video_rtsp_urls_json_file",
"video_open_timeout_sec",
"video_read_failure_reconnect_threshold",
"video_reconnect_backoff_seconds",
"video_inference_interval_sec",
"video_inference_confidence_threshold",
"video_auto_confirm_confidence",
"video_voice_confirm_min_confidence",
"video_voice_confirm_doctor_id",
"video_detail_cooldown_sec",
"video_jpeg_quality",
"video_result_doctor_id",
"video_log_inference_results",
"consumable_classifier_weights",
"consumable_classifier_imgsz",
"consumable_classifier_device",
"consumable_classifier_topk",
"consumable_min_cls_confidence",
"consumable_catalog_xlsx_path",
"consumable_vision_window_sec",
"hand_detection_weights",
"hand_detection_imgsz",
"hand_detection_conf",
"hand_detection_pad_ratio",
"hand_detection_min_crop_px",
"hand_detection_device",
"surgery_recording_max_attempts",
"surgery_recording_retry_delay_seconds",
"archive_persist_retry_interval_seconds",
"archive_persist_max_retries",
"archive_persist_backoff_cap_seconds",
"archive_persist_durable_fallback_dir",
"archive_persist_durable_fallback_enabled",
"consumption_tsv_log_enabled",
"consumption_tsv_log_path",
"consumption_log_markdown_terminal",
"consumption_log_timezone",
)
class _VoiceGroup(_SettingsGroup):
_FIELDS = (
"voice_confirmation_enabled",
"voice_upload_max_bytes",
"voice_confirm_max_failed_parse_rounds",
"voice_file_log_enabled",
"voice_file_log_path",
)
class _HikvisionGroup(_SettingsGroup):
_FIELDS = (
"hikvision_lib_dir",
"hikvision_sdk_enabled",
"hikvision_device_ip",
"hikvision_device_port",
"hikvision_user",
"hikvision_password",
"hikvision_channel",
"hikvision_preview_rtsp_template",
"hikvision_camera_rtsp_urls_json",
"hikvision_sdk_fallback_to_rtsp",
)
class _MinioGroup(_SettingsGroup):
_FIELDS = (
"minio_endpoint",
"minio_access_key",
"minio_secret_key",
"minio_bucket",
"minio_secure",
"minio_region",
)
class _BaiduGroup(_SettingsGroup):
_FIELDS = (
"baidu_speech_app_id",
"baidu_speech_api_key",
"baidu_speech_secret_key",
"baidu_speech_connection_timeout_ms",
"baidu_speech_socket_timeout_ms",
"baidu_speech_asr_dev_pid",
)
class _DemoGroup(_SettingsGroup):
_FIELDS = (
"demo_cors_enabled",
"demo_cors_origins",
"demo_orchestrator_enabled",
"demo_orchestrator_rtsp_port",
"demo_orchestrator_rtsp_json_host",
)
class _DatabaseGroup(_SettingsGroup):
_FIELDS = (
"database_url",
"postgres_user",
"postgres_password",
"postgres_db",
"postgres_host",
"postgres_port",
"auto_create_schema",
)
class _ServerGroup(_SettingsGroup):
_FIELDS = (
"server_host",
"server_port",
"server_reload",
)
_PACKAGE_DIR = Path(__file__).resolve().parent
# 仓库根目录(含 .env。用绝对路径读 .env避免从子目录/IDE 启动时 cwd 不同导致联调项未生效。
_REPO_ROOT = _PACKAGE_DIR.parent
@@ -31,6 +181,17 @@ class Settings(BaseSettings):
postgres_db: str = "operation_room"
postgres_host: str = "localhost"
postgres_port: int = 35432
#: 为 true 时lifespan 启动会调用 Base.metadata.create_all 确保表存在(开发/测试用)。
#: 生产请置 false并通过 ``alembic upgrade head`` 进行版本化迁移。
auto_create_schema: bool = True
# --- Uvicorn / API server ---
#: `uvicorn.run` 绑定的地址;默认监听所有接口(开发/容器联调常用)。
server_host: str = "0.0.0.0"
#: HTTP 端口;生产请按部署策略显式设置。
server_port: int = Field(default=38080, ge=1, le=65535)
#: 是否启用 `--reload`(仅本地开发;生产必须 false
server_reload: bool = False
consumable_classifier_weights: str | None = None
consumable_classifier_imgsz: int = 224
#: Explicit Ultralytics device (e.g. cpu, mps, cuda:0). Empty -> macOS prefers MPS; Linux prefers CUDA if available.
@@ -85,14 +246,20 @@ class Settings(BaseSettings):
voice_confirmation_enabled: bool = True
#: 语音确认记帐时的 doctor_id。
video_voice_confirm_doctor_id: str = "voice"
#: (已弃用)服务端本机录音秒数;当前闭环由客户端采集语音,此项仅保留兼容旧配置
voice_record_seconds: float = Field(default=5.0, ge=1.0, le=30.0)
#: (已弃用)服务端 ffmpeg 音频输入;当前闭环不依赖服务端录音。
voice_ffmpeg_input: str = ""
#: 手术结束后归档写库失败时,后台重试落库的间隔(秒)。
#: 手术结束后归档写库失败时,后台重试落库的间隔(秒),用作指数退避的基数
archive_persist_retry_interval_seconds: float = Field(
default=30.0, ge=5.0, le=3600.0
)
#: 单条归档允许的最大连续重试次数。达到上限后保持 durable fallback直到进程重启或手动介入。
archive_persist_max_retries: int = Field(default=12, ge=1, le=10000)
#: 指数退避上限(秒),防止间隔被放大到不切实际的值。
archive_persist_backoff_cap_seconds: float = Field(
default=900.0, ge=5.0, le=86400.0
)
#: 归档 durable fallback 的磁盘目录;启动/重试时会扫描其中 `*.json` 尝试恢复。
archive_persist_durable_fallback_dir: str = "logs/pending_archive"
#: 为 true 时,首次写库失败后立即把归档写到 durable fallback 目录,避免进程重启丢数据。
archive_persist_durable_fallback_enabled: bool = True
#: 同一物品重复记一条消耗的最短间隔(秒)。
video_detail_cooldown_sec: float = Field(default=15.0, ge=0.0, le=3600.0)
#: 送模型 JPEG 质量。
@@ -280,5 +447,37 @@ class Settings(BaseSettings):
"""仓库内示例映射路径(供文档与联调引用)。"""
return _default_camera_rtsp_urls_sample_path()
@property
def video(self) -> _VideoGroup:
return _VideoGroup(self)
@property
def voice(self) -> _VoiceGroup:
return _VoiceGroup(self)
@property
def hikvision(self) -> _HikvisionGroup:
return _HikvisionGroup(self)
@property
def minio(self) -> _MinioGroup:
return _MinioGroup(self)
@property
def baidu(self) -> _BaiduGroup:
return _BaiduGroup(self)
@property
def demo(self) -> _DemoGroup:
return _DemoGroup(self)
@property
def database(self) -> _DatabaseGroup:
return _DatabaseGroup(self)
@property
def server(self) -> _ServerGroup:
return _ServerGroup(self)
settings = Settings()