feat: 配置写死与 baked 模块,Alembic 建表,百度仅 BAIDU_*

- 新增 app/baked/algorithm|pipeline,非部署参数不再走 env;Settings 保留 DB/HTTP/RTSP/海康/百度/MinIO/Demo
- 移除 init_db_schema 与 reload 配置;main 仅 check_database;start*.sh 在 uvicorn 前执行 alembic upgrade head
- 依赖 psycopg[binary] 供 Alembic 同步 URL;alembic/env 注释与预发清单更新
- 撕段门控消费管线、各视频/语音/归档调用改为 baked
- 百度环境变量仅 BAIDU_APP_ID、BAIDU_API_KEY、BAIDU_SECRET_KEY 与 BAIDU_* 超时/ASR;人脸脚本与 baidu_speech 文案同步
- 全量单测与 .env.example 更新;.gitignore 忽略 refs/(本地权重/视频不入库)

Made-with: Cursor
This commit is contained in:
Kevin
2026-04-24 15:33:22 +08:00
parent b651364877
commit 8a4bad99d3
47 changed files with 1333 additions and 648 deletions

View File

@@ -3,16 +3,14 @@ from pathlib import Path
from urllib.parse import quote_plus
from typing import Any, Literal
from pydantic import Field, field_validator
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
from app.baked import algorithm as baked_algorithm
class _SettingsGroup:
"""按主题分组的 Settings 视图;属性访问代理回主 Settings 实例。
主 Settings 保留所有原始平坦字段作为事实来源;此类仅提供 ``settings.video.xxx``
之类的分组读写入口,减少跨文件的耦合面,同时保持向后兼容。
"""
"""按主题分组的 Settings 视图;属性访问代理回主 Settings 实例。"""
_FIELDS: tuple[str, ...] = ()
@@ -35,59 +33,19 @@ class _SettingsGroup:
class _VideoGroup(_SettingsGroup):
"""仅含 RTSP 解析与按路后端;抽帧/推理/耗材等见 app.baked.pipeline / algorithm。"""
_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_classifier_labels_yaml_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",
)
_FIELDS = ()
class _HikvisionGroup(_SettingsGroup):
@@ -145,7 +103,6 @@ class _DatabaseGroup(_SettingsGroup):
"postgres_db",
"postgres_host",
"postgres_port",
"auto_create_schema",
)
@@ -153,32 +110,17 @@ 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")
def _default_consumable_classifier_labels_yaml() -> str:
"""与分类训练类名、业务 `label_id` 对照的 YAML见 `app/resources/consumable_classifier_labels.yaml`。"""
return str(_PACKAGE_DIR / "resources" / "consumable_classifier_labels.yaml")
class Settings(BaseSettings):
"""Application configuration loaded from environment / .env."""
"""Application configuration loaded from environment / .env.
算法与管线默认可调项见 ``app.baked.algorithm`` / ``app.baked.pipeline``。
"""
database_url: str | None = None
postgres_user: str = "postgres"
@@ -186,162 +128,51 @@ 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.
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)
#: 分类类名 + 业务 `label_id` 对照(与训练 `names` 一致);`build_name_mapping` 将识别出的类名匹配至此得到业务 id。空则使用下方默认包内文件。
consumable_classifier_labels_yaml_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`autoSDK 动态库可用且 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 完整 URLJSON 对象;与 `video_rtsp_urls_json_file` 合并时,**本字段覆盖同键**。
video_rtsp_urls_json: str = ""
#: 从文件加载 camera_id -> rtsp_urlUTF-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` 时按手术截断/初始化;每行 tabitem_id、item_name、qty、doctor_id、timestamp停录后按内存明细与查结果 API 同口径追加汇总块 item_id、item_name、qty
consumption_tsv_log_enabled: bool = True
#: 路径模板,须含 `{surgery_id}`(每例手术独立文件)。不含占位时自动在扩展名前追加 `_<surgery_id>`。
consumption_tsv_log_path: str = "logs/consumption_{surgery_id}.txt"
#: 为 true 时,同一时间窗结果在终端以 Markdown 表格打印Top13 分列 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语音确认接口将返回业务错误联调需配置
baidu_speech_app_id: str = Field(default="", validation_alias="BAIDU_APP_ID")
baidu_speech_api_key: str = Field(default="", validation_alias="BAIDU_API_KEY")
baidu_speech_secret_key: str = Field(default="", validation_alias="BAIDU_SECRET_KEY")
baidu_speech_connection_timeout_ms: int | None = Field(
default=None, validation_alias="BAIDU_CONNECTION_TIMEOUT_MS"
)
baidu_speech_socket_timeout_ms: int | None = Field(
default=None, validation_alias="BAIDU_SOCKET_TIMEOUT_MS"
)
baidu_speech_asr_dev_pid: int = Field(
default=1537, ge=1000, le=99999, validation_alias="BAIDU_ASR_DEV_PID"
)
minio_endpoint: str = ""
minio_access_key: str = ""
minio_secret_key: str = ""
minio_bucket: str = "operation-room-voice"
#: 是否使用 HTTPSMinIO 常见为 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
#: 假 RTSPMediaMTX在宿主机上映射的端口与 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]:
@@ -352,24 +183,12 @@ class Settings(BaseSettings):
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)
@field_validator("consumable_classifier_labels_yaml_path", mode="before")
@classmethod
def consumable_classifier_labels_yaml_path_default(cls, value: object) -> str:
if value is None or str(value).strip() == "":
return _default_consumable_classifier_labels_yaml()
return str(value).strip()
model_config = SettingsConfigDict(
env_file=(str(_DEFAULT_ENV_FILE),),
env_file_encoding="utf-8",
extra="ignore",
env_ignore_empty=True,
populate_by_name=True,
)
@property
@@ -431,7 +250,6 @@ class Settings(BaseSettings):
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:
@@ -456,8 +274,7 @@ class Settings(BaseSettings):
@property
def camera_rtsp_urls_sample_path(self) -> str:
"""仓库内示例映射路径(供文档与联调引用)。"""
return _default_camera_rtsp_urls_sample_path()
return baked_algorithm.default_camera_rtsp_urls_sample_path()
@property
def video(self) -> _VideoGroup: