import json from pathlib import Path from urllib.parse import quote_plus 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 _DEFAULT_ENV_FILE = _REPO_ROOT / ".env" def _default_consumable_classifier_weights() -> str: """耗材识别与分类(YOLO-cls):`app/resources/consumable_classifier.pt`。""" return str(_PACKAGE_DIR / "resources" / "consumable_classifier.pt") def _default_camera_rtsp_urls_sample_path() -> str: """示例映射路径(可复制为自有 `camera_rtsp_urls.json` 后在环境变量中引用)。""" return str(_PACKAGE_DIR / "resources" / "camera_rtsp_urls.sample.json") class Settings(BaseSettings): """Application configuration loaded from environment / .env.""" database_url: str | None = None postgres_user: str = "postgres" postgres_password: str = "postgres" 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. consumable_classifier_device: str = "" consumable_classifier_topk: int = 5 #: 耗材分类 top1 最低置信度(手部 ROI 或全帧送入分类器后的门槛)。 consumable_min_cls_confidence: float = Field(default=0.5, ge=0.0, le=1.0) #: 可选:`视频中的商品信息表.xlsx`(含「商品名称」「产品编码」);空则物品 id 用名称本身。 consumable_catalog_xlsx_path: str = "" #: 与离线脚本一致的时间窗(秒);窗内多次推理取众数后再走自动记账 / 语音追问逻辑。 consumable_vision_window_sec: float = Field(default=15.0, ge=0.5, le=600.0) #: 手部检测 YOLO 权重;空或文件不存在时退化为「全帧送分类器」(兼容仅有关分类权重的环境)。 hand_detection_weights: str = "" hand_detection_imgsz: int = Field(default=640, ge=32, le=4096) hand_detection_conf: float = Field(default=0.25, ge=0.0, le=1.0) hand_detection_pad_ratio: float = Field(default=0.30, ge=0.0, le=2.0) hand_detection_min_crop_px: int = Field(default=64, ge=8, le=4096) hand_detection_device: str = "" #: 开始/结束手术时调用录制流水线的最大尝试次数(含首次)。 surgery_recording_max_attempts: int = Field(default=3, ge=1, le=20) #: 两次尝试之间的等待秒数。 surgery_recording_retry_delay_seconds: float = Field(default=1.0, ge=0.0, le=60.0) # --- 视频:RTSP / 海康 SDK 双后端 --- #: 默认后端:`rtsp` | `hikvision_sdk` | `auto`(auto:SDK 动态库可用且 HIKVISION_SDK_ENABLED 时优先 SDK)。 video_default_backend: Literal["rtsp", "hikvision_sdk", "auto"] = "rtsp" #: 按摄像头覆盖后端,JSON 对象,例如 `{"or-cam-01":"rtsp","or-cam-02":"hikvision_sdk"}`。 video_camera_backend_overrides_json: str = "" #: 单 URL 模板,例如 `rtsp://user:pass@192.168.1.64:554/Streaming/Channels/101`(可用 `{camera_id}`)。 video_rtsp_url_template: str = "" #: 每路 RTSP 完整 URL,JSON 对象;与 `video_rtsp_urls_json_file` 合并时,**本字段覆盖同键**。 video_rtsp_urls_json: str = "" #: 从文件加载 camera_id -> rtsp_url(UTF-8 JSON 对象)。示例见 app/resources/camera_rtsp_urls.sample.json。 video_rtsp_urls_json_file: str = "" #: 打开 RTSP 并读到首帧的超时(秒)。 video_open_timeout_sec: float = Field(default=15.0, ge=1.0, le=120.0) #: 连续读帧失败达到该次数后释放连接并尝试重连。 video_read_failure_reconnect_threshold: int = Field(default=15, ge=1, le=500) #: 重连前等待秒数(亦用于 open 失败后的退避)。 video_reconnect_backoff_seconds: float = Field(default=1.0, ge=0.1, le=60.0) #: 推理抽帧间隔(秒)。 video_inference_interval_sec: float = Field(default=2.0, ge=0.2, le=60.0) #: 分类置信度阈值(兼容旧逻辑):低于 `video_voice_confirm_min_confidence` 的帧不参与自动确认或语音追问。 video_inference_confidence_threshold: float = Field( default=0.35, ge=0.0, le=1.0 ) #: 达到或超过该置信度且 Top1 在候选内时,自动记一条 vision 消耗;低于该值时走待确认(不低于 video_voice_confirm_min 且可展示候选项时)。默认 0.9:不足 0.9 的需人工确认。 video_auto_confirm_confidence: float = Field(default=0.9, ge=0.0, le=1.0) #: 低于本值的帧不进入自动/待确认逻辑(与 `video_auto_confirm_confidence` 下沿之间的区间可入队待确认)。 video_voice_confirm_min_confidence: float = Field(default=0.35, ge=0.0, le=1.0) #: 是否启用低置信度时的人工确认(客户端拉取待确认项并回传结果;不依赖服务端麦克风/扬声器)。 voice_confirmation_enabled: bool = True #: 语音确认记帐时的 doctor_id。 video_voice_confirm_doctor_id: str = "voice" #: 手术结束后归档写库失败时,后台重试落库的间隔(秒),用作指数退避的基数。 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 质量。 video_jpeg_quality: int = Field(default=85, ge=40, le=100) #: 写入消耗明细时的 doctor_id(无外部医生 ID 来源时的占位)。 video_result_doctor_id: str = "vision" #: 为 true 时,每次单帧分类得到 top1 等结果会打一条 INFO 日志(联调用;高流量时建议关)。 video_log_inference_results: bool = False #: 为 true 时,将时间窗级识别写入文本日志(`start_surgery` 时按手术截断/初始化;每行 tab:item_id、item_name、qty、doctor_id、timestamp;停录后追加汇总块 item_id、item_name、qty)。 consumption_tsv_log_enabled: bool = True #: 路径模板,须含 `{surgery_id}`(每例手术独立文件)。不含占位时自动在扩展名前追加 `_`。 consumption_tsv_log_path: str = "logs/consumption_{surgery_id}.txt" #: 为 true 时,同一时间窗结果在终端以 Markdown 表格打印(Top1~3 分列 id / 名称 / 置信度)。 consumption_log_markdown_terminal: bool = True #: 消耗日志「时间戳」列的时区,IANA 名如 `Asia/Shanghai`;空串则使用「当前系统时区」。 consumption_log_timezone: str = "" #: 为 true 时,语音确认(WAV/文本)的 ASR/解析结果写 TSV 文件,并在终端打 `VoiceConfirm` 行;`start_surgery` 时与消耗日志同寿命截断初始化。 voice_file_log_enabled: bool = True #: 路径模板,须含 `{surgery_id}`,与 `consumption_tsv_log_path` 规则相同。 voice_file_log_path: str = "logs/voice_{surgery_id}.txt" #: 海康 SDK `.so` 所在目录(容器内可挂载 `/opt/hikvision/lib`)。 hikvision_lib_dir: str = "/opt/hikvision/lib" #: 为 true 时 `auto` 模式才会优先走 SDK;亦为 SDK 登录的前提之一。 hikvision_sdk_enabled: bool = False hikvision_device_ip: str = "" hikvision_device_port: int = Field(default=8000, ge=1, le=65535) hikvision_user: str = "" hikvision_password: str = "" #: 预览 URL 模板中的通道号等(如 101 主码流常写作 channel 拼接)。 hikvision_channel: int = Field(default=1, ge=1, le=512) #: SDK 登录成功后用于拉流的 RTSP 模板;占位符如 `{ip} {user} {password} {channel} {camera_id}`。 hikvision_preview_rtsp_template: str = "" #: 与 VIDEO_RTSP_URLS_JSON 类似,按 camera_id 指定 SDK 路径下的预览 RTSP。 hikvision_camera_rtsp_urls_json: str = "" #: SDK 登录失败时是否仍尝试用通用 RTSP 映射拉流(仅当能解析到 RTSP URL 时)。 hikvision_sdk_fallback_to_rtsp: bool = True #: 百度语音(`baidu-aip` AipSpeech:短语音识别 + 在线合成)。在控制台创建应用后填写。 baidu_speech_app_id: str = "" baidu_speech_api_key: str = "" baidu_speech_secret_key: str = "" #: 建立连接超时(毫秒)。未设置则使用 SDK 默认。 baidu_speech_connection_timeout_ms: int | None = None #: 传输数据超时(毫秒)。未设置则使用 SDK 默认。 baidu_speech_socket_timeout_ms: int | None = None #: 百度短语音识别 `dev_pid`,**始终**用于 ASR(调用方传入的 options 不会覆盖)。1537=普通话通用(与百度控制台一致;勿用 1737 英语、1837 粤语等)。 baidu_speech_asr_dev_pid: int = Field(default=1537, ge=1000, le=99999) # --- MinIO:语音确认原始 WAV 追溯存储 --- #: 为空则视为未配置 MinIO,语音确认接口将返回业务错误(联调需配置)。 minio_endpoint: str = "" minio_access_key: str = "" minio_secret_key: str = "" minio_bucket: str = "operation-room-voice" #: 是否使用 HTTPS(MinIO 常见为 false,走 9000 明文或 TLS)。 minio_secure: bool = False #: 可选区域(部分 S3 兼容实现需要)。 minio_region: str = "" #: 上传医生语音 WAV 的最大字节数(默认 10MB)。 voice_upload_max_bytes: int = Field(default=10 * 1024 * 1024, ge=64, le=50 * 1024 * 1024) #: 同一条待确认在 ASR/文本解析为选项或耗材名失败时,计数的最大失败轮数。默认 2 表示首败后再允许 1 次「显式重试」语义(API 的 retry_remaining 首轮为 1、再败为 0);不阻止后续继续上传直至成功或否认。 voice_confirm_max_failed_parse_rounds: int = Field(default=2, ge=1, le=20) # --- Demo 客户端跨源(仅用于 scripts/demo_client 联调;生产置 false) --- #: 为 true 时挂载 CORSMiddleware,便于浏览器 demo 从另一个端口访问本服务。 demo_cors_enabled: bool = True #: 逗号分隔的允许来源;`*` 表示允许全部来源(demo/联调用,生产应显式指定)。 demo_cors_origins: str = "*" # --- 一键联调:上传视频 → 起假 RTSP → 写 VIDEO_RTSP_URLS_JSON_FILE → 开始手术(仅开发;生产必须 false) --- #: 为 true 时注册 `POST /internal/demo/orchestrate-and-start`。 demo_orchestrator_enabled: bool = False #: 假 RTSP(MediaMTX)在宿主机上映射的端口(与 scripts/demo_client 默认一致)。 demo_orchestrator_rtsp_port: int = Field(default=18554, ge=1, le=65535) #: 手配假流时:写入 JSON 可把 `rtsp://127.0.0.1` 换成此主机,便于**别一进程**(如仅容器内的监控)访问宿主机推流。 #: `POST /internal/demo/orchestrate-and-start` 在本进程起流+拉流,始终写 `127.0.0.1`,**不读**此字段。 demo_orchestrator_rtsp_json_host: str = "host.docker.internal" def parsed_demo_cors_origins(self) -> list[str]: raw = (self.demo_cors_origins or "").strip() if not raw: return [] if raw == "*": return ["*"] return [item.strip() for item in raw.split(",") if item.strip()] @field_validator("consumable_classifier_weights", mode="before") @classmethod def consumable_classifier_weights_default(cls, value: object) -> str: if value is None or value == "": return _default_consumable_classifier_weights() return str(value) model_config = SettingsConfigDict( env_file=(str(_DEFAULT_ENV_FILE),), env_file_encoding="utf-8", extra="ignore", ) @property def sqlalchemy_database_url(self) -> str: component_values = ( self.postgres_user, self.postgres_password, self.postgres_db, self.postgres_host, self.postgres_port, ) default_component_values = ( "postgres", "postgres", "operation_room", "localhost", 35432, ) if component_values != default_component_values or not self.database_url: user = quote_plus(self.postgres_user) password = quote_plus(self.postgres_password) database = quote_plus(self.postgres_db) return ( "postgresql+asyncpg://" f"{user}:{password}@{self.postgres_host}:{self.postgres_port}/{database}" ) return self.database_url @property def baidu_speech_configured(self) -> bool: return bool( self.baidu_speech_app_id.strip() and self.baidu_speech_api_key.strip() and self.baidu_speech_secret_key.strip() ) @property def minio_configured(self) -> bool: return bool( self.minio_endpoint.strip() and self.minio_access_key.strip() and self.minio_secret_key.strip() and self.minio_bucket.strip() ) @staticmethod def _parse_rtsp_urls_object(raw: str) -> dict[str, str]: raw = (raw or "").strip() if not raw: return {} try: data: Any = json.loads(raw) except json.JSONDecodeError as exc: raise ValueError(f"Invalid VIDEO_RTSP_URLS_JSON: {exc}") from exc if not isinstance(data, dict): raise ValueError("VIDEO_RTSP_URLS_JSON must be a JSON object") return {str(k): str(v) for k, v in data.items()} def video_rtsp_url_map(self) -> dict[str, str]: """合并文件与内联 JSON;内联键覆盖文件。""" merged: dict[str, str] = {} path_raw = (self.video_rtsp_urls_json_file or "").strip() if path_raw: path = Path(path_raw).expanduser() if not path.is_file(): raise ValueError( f"VIDEO_RTSP_URLS_JSON_FILE is set but file not found: {path}" ) try: file_obj: Any = json.loads(path.read_text(encoding="utf-8")) except json.JSONDecodeError as exc: raise ValueError( f"Invalid JSON in VIDEO_RTSP_URLS_JSON_FILE {path}: {exc}" ) from exc if not isinstance(file_obj, dict): raise ValueError( f"VIDEO_RTSP_URLS_JSON_FILE must contain a JSON object: {path}" ) merged = {str(k): str(v) for k, v in file_obj.items()} merged.update(self._parse_rtsp_urls_object(self.video_rtsp_urls_json)) return merged @property def camera_rtsp_urls_sample_path(self) -> str: """仓库内示例映射路径(供文档与联调引用)。""" 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()