From c1bfac59df418d222de9ae2571325238449ff043 Mon Sep 17 00:00:00 2001 From: ericwyuan Date: Wed, 19 Aug 2026 22:50:30 +0800 Subject: [PATCH] =?UTF-8?q?fix(requirements):=20=E7=A7=BB=E9=99=A4=20googl?= =?UTF-8?q?e-generativeai/openai=20=E7=A1=AC=E4=BE=9D=E8=B5=96=20-=20?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E7=94=A8=20requests=20=E7=9B=B4=E6=8E=A5?= =?UTF-8?q?=E8=B0=83=20REST=20API=EF=BC=8C=E5=85=BC=E5=AE=B9=20Python=203.?= =?UTF-8?q?8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- fam-edge/requirements.txt | 16 +- 项目需求文档.md | 1287 +++++++++++++++++++++++++++++++++++++ 2 files changed, 1296 insertions(+), 7 deletions(-) create mode 100644 项目需求文档.md diff --git a/fam-edge/requirements.txt b/fam-edge/requirements.txt index ed946c7..41a82f7 100644 --- a/fam-edge/requirements.txt +++ b/fam-edge/requirements.txt @@ -1,8 +1,10 @@ -flask>=3.0.0 -gunicorn>=21.2.0 -requests>=2.31.0 +flask>=2.0.0 +gunicorn>=20.0.0 +requests>=2.28.0 PyYAML>=6.0 -opencv-python>=4.8.0 -numpy>=1.24.0 -google-generativeai>=0.5.0 -openai>=1.10.0 +opencv-python-headless>=4.5.0 +numpy>=1.21.0 +# google-generativeai 和 openai 为可选依赖(代码用 requests 直接调 REST API) +# 如需 SDK 方式调用,取消注释并在 Python 3.9+ 环境安装: +# google-generativeai>=0.5.0 +# openai>=1.10.0 diff --git a/项目需求文档.md b/项目需求文档.md new file mode 100644 index 0000000..44523a9 --- /dev/null +++ b/项目需求文档.md @@ -0,0 +1,1287 @@ + +# 家庭多模态智能监控系统 — 软件设计说明书 + +**版本**: v1.0 +**日期**: 2026-08-19 +**目标**: 多模型并行 + 交互式命名 + AI 对话的家庭监控系统 + +--- + +## 1. 设计目标与范围 + +### 1.1 首期范围(In Scope) +- FAM-Core 单进程,承载 Task-Scheduler / Dispatcher / Event-Receiver / Chat-Handler / Member-Manager / Video-Server 六个子模块 +- FAM-Edge 单进程,承载视频下载 → FFmpeg 抽帧 → 本地 VLM 视觉分析 + Gemini 云端增强 → 本地 VLM 文本融合 → 回调 全链路 +- FAM-UI 用 Streamlit 直读 DB,展示事件列表 + compute_provider 占比 + AI 对话页 + 对话历史 + 成员命名页 +- 数据库六张表:process_tasks / monitor_events / daily_summaries(预留) / event_details / chat_history / family_members +- 任务状态机:PENDING → PROCESSING → SUCCESS/FAILED,含重试 +- AI 对话:用户问"汤圆今天干嘛了",FAM-Core 查 event_details 拼上下文 → 调 Oracle Ollama 纯文本 → 返回回答并写 chat_history +- 交互式成员命名:VLM 自动按特征提取"人物A/B/C"落库,用户在 FAM-UI 命名为"汤圆/妈妈"后,批量回溯更新历史记录,后续视频分析直接使用真实名字 + +### 1.2 不在首期范围(Out of Scope,推迟 v1.1+) +- Nginx 静态服务(用 Flask `send_from_directory` 替代) +- 资源保护策略 B(CPU/内存过载返回 503,单并发下不必要) +- 心跳监控(Heartbeat-Monitor,单任务串行下用整体超时兜底) +- 成员特征历史快照(members_snapshot_json 字段) +- API 业务接口鉴权(`/api/edge/*` 和 `/api/core/*` 不加鉴权;仅 Video-Server 的 `/media/*` 加 token,因为视频文件是敏感数据) +- Cron 兜底清理(finally 清理足够,Cron 推后) + +### 1.3 仍保留的"非可选"埋点 +即使首期也要做的,否则后期回头补很痛: +1. `task_id` 作为 trace_id,NAS 与 Oracle 两端日志必须带 +2. 各阶段耗时落日志(download / extract / vlm_visual / vlm_fusion / callback) +3. `failure_stage` 字段落库(原 DDL 已有,保留) +4. Video-Server 路由带 `?token=xxx` 简单鉴权 + +--- + +## 2. 系统架构 + +### 2.1 物理节点与部署 + +三台物理/虚拟节点,通过 Tailscale 虚拟局域网互联(100.x.x.x/24 网段)。 + +| 节点 | 角色 | 硬件 | IP 地址 | 部署服务 | 监听端口 | +|------|------|------|---------|---------|---------| +| Synology NAS | FAM-Core + FAM-UI + 数据库 | NAS 自带资源 | Tailscale: 100.x.x.10
家庭局域网: 192.168.50.64 | FAM-Core (Flask)、FAM-UI (Streamlit)、MariaDB、Surveillance Station | 8000 (FAM-Core)、8501 (Streamlit)、3306 (MariaDB) | +| Oracle Cloud | FAM-Edge | 2C12G, ARM Ampere A1, 无 GPU | Tailscale: 100.x.x.20
公网: 129.146.203.203 | FAM-Edge (Flask)、Ollama、FFmpeg | 5000 (FAM-Edge)、11434 (Ollama) | +| 家庭网络 | 管理员/用户入口 | 普通终端 | 192.168.50.0/24 内 | 浏览器访问 FAM-UI | — | + +**IP 访问约束**: +- Oracle ↔ NAS 的服务间通信一律走 Tailscale(100.x.x.10 / 100.x.x.20),不走公网,不走家庭局域网 +- Oracle FAM-Edge 监听 `0.0.0.0:5000`,但安全组仅放行 Tailscale 入站;公网 129.146.203.203:5000 由安全组拦截 +- NAS FAM-Core 监听 `0.0.0.0:8000`,Video-Server 路由靠 `?token=xxx` 鉴权;Tailscale 入站自由,家庭局域网 192.168.50.64:8000 也可达 +- 家庭网络用户访问 FAM-UI 走 `http://192.168.50.64:8501`(局域网直连,不经 Tailscale) + +### 2.2 部署拓扑图 + +``` +┌──────────────────────────────────────────────────────────────────────────┐ +│ Tailscale 虚拟局域网 (100.x.x.x/24) │ +│ │ +│ ┌──────────────────────────────────┐ ┌──────────────────────────┐ │ +│ │ Synology NAS (FAM-Core) │ │ Oracle Cloud (FAM-Edge)│ │ +│ │ 硬件: NAS 自带 │ │ 硬件: 2C12G ARM A1 │ │ +│ │ Tailscale: 100.x.x.10 │ │ Tailscale: 100.x.x.20 │ │ +│ │ 家庭局域网: 192.168.50.64 │ │ 公网: 129.146.203.203 │ │ +│ │ (公网入站: 无, 仅出站) │ │ (公网入站: 仅 Tailscale)│ │ +│ │ │ │ │ │ +│ │ ┌────────────────────────────┐ │ │ ┌────────────────────┐ │ │ +│ │ │ Surveillance Station │ │ │ │ FAM-Edge (Flask) │ │ │ +│ │ │ - 录制视频 │ │ │ │ 端口: 5000 │ │ │ +│ │ │ - 输出 mp4 至挂载目录 │ │ │ │ - Video-Preprocess │ │ │ +│ │ └───────────┬────────────────┘ │ │ │ - AI-Orchestrator │ │ │ +│ │ │ │ │ │ - Storage-Cleaner │ │ │ +│ │ ▼ │ │ └─────────┬──────────┘ │ │ +│ │ ┌────────────────────────────┐ │ │ │ │ │ +│ │ │ NAS 挂载目录 │ │ │ ▼ │ │ +│ │ │ /volume1/surveillance/ │◄─┼───────┼── HTTP GET /media/*.mp4 │ │ +│ │ └───────────┬────────────────┘ │ │ (Tailscale, ?token) │ │ +│ │ │ │ │ │ │ +│ │ ▼ │ │ ┌────────────────────┐ │ │ +│ │ ┌────────────────────────────┐ │ │ │ Ollama (systemd) │ │ │ +│ │ │ FAM-Core (Flask) │ │ │ │ 端口: 11434 │ │ │ +│ │ │ 端口: 8000 (Tailscale) │ │ │ │ 模型: llava-phi3 │ │ │ +│ │ │ - Task-Scheduler │──┼───────┼─► HTTP POST 下发任务 │ │ +│ │ │ - Dispatcher │◄─┼───────┼── HTTP POST 回调 │ │ +│ │ │ - Event-Receiver │ │ │ │ │ +│ │ │ - Video-Server │ │ │ ┌────────────────────┐ │ │ +│ │ └───────────┬────────────────┘ │ │ │ FFmpeg + OpenCV │ │ │ +│ │ │ │ │ │ (系统级二进制) │ │ │ +│ │ ▼ │ │ └────────────────────┘ │ │ +│ │ ┌────────────────────────────┐ │ │ ▲ │ │ +│ │ │ MariaDB │ │ │ │ │ │ +│ │ │ 端口: 3306 (本地) │◄─┼───────┼── HTTP 回调 (POST) │ │ +│ │ │ - process_tasks │ │ 写入 │ │ task_id + 结果 JSON │ │ +│ │ │ - monitor_events │ │ │ └────────────────────┘ │ │ +│ │ │ - daily_summaries │ │ └──────────────────────────┘ │ +│ │ └───────────┬────────────────┘ │ │ +│ │ │ │ │ +│ │ ▼ │ ┌──────────────┐ │ +│ │ ┌────────────────────────────┐ │ │ 家庭网络用户 │ │ +│ │ │ FAM-UI (Streamlit) │◄─┼───────────│ 浏览器 │ │ +│ │ │ 端口: 8501 (局域网) │ │ 192.168. │ 192.168.50.x │ │ +│ │ │ - Event-List │ │ 50.64:8501└──────────────┘ │ +│ │ │ - Filter-Bar │ │ │ +│ │ │ - Stats-Chart │ │ │ +│ │ └────────────────────────────┘ │ │ +│ └──────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────────────────┘ +``` + +### 2.3 网络流与数据流 + +| # | 流向 | 协议 | 内容 | 触发方 | +|---|------|------|------|--------| +| 1 | SS → NAS 挂载目录 | 文件系统 | mp4 视频落盘 | Surveillance Station 定时录制 | +| 2 | NAS → NAS | 进程内 | Task-Scheduler 扫描目录,写 process_tasks | FAM-Core 内部 | +| 3 | NAS → Oracle | HTTP POST | Dispatcher 下发任务 (task_id + video_url + webhook) | FAM-Core | +| 4 | Oracle → NAS | HTTP GET | FAM-Edge 拉取 mp4 视频 | FAM-Edge | +| 5 | Oracle → Oracle | HTTP | FAM-Edge 调用 Ollama (视觉分析 + 文本融合) | FAM-Edge | +| 6 | Oracle → NAS | HTTP POST | FAM-Edge 回调 Event-Receiver,写 monitor_events | FAM-Edge | +| 7 | 浏览器 → NAS | HTTP | 访问 Streamlit UI | 家庭网络用户 | + +### 2.4 端口与目录清单 + +**NAS 端**: +| 服务 | 端口 | 数据目录 | 配置文件 | +|------|------|---------|---------| +| FAM-Core | 8000 | — | `config/config.yaml`(成员特征改由 `family_members` 表管理) | +| MariaDB | 3306 | `/volume1/@database/mysql/` | MariaDB 默认配置 | +| FAM-UI | 8501 | — | `config/config.yaml` (复用) | +| Surveillance Station | (SS 默认) | `/volume1/surveillance/` | SS 控制台 | +| Video-Server 路由 | 8000 (复用) | `/volume1/surveillance/` | — | + +**Oracle 端**: +| 服务 | 端口 | 工作目录 | 说明 | +|------|------|---------|------| +| FAM-Edge | 5000 | `/opt/fam-edge/` | 应用代码 | +| Ollama | 11434 | `~/.ollama/` | 模型存储 | +| FFmpeg | — | `/tmp/fam_media/task_*/` | 临时帧图片,任务结束清理 | + +### 2.5 与原文档的差异 + +| 章节 | 原设计 | 首期 | 理由 | +|------|-------|-----|------| +| 1.1 架构图 | Nginx 标"可选" | Flask `send_from_directory` 替代 | 首期并发 1,无需 Nginx | +| 1.2 技术选型 | Gemini 1.5 Flash(可选增强) | 首期纳入,与本地 VLM 并行调用 | 云端增强 + 交叉验证 | +| 2.1 FAM-Core | 未列 Video-Server | 加为子模块 | 主链路必需 | +| 2.1 FAM-Core | Heartbeat-Monitor | 首期不做 | 单任务串行,整体超时兜底 | +| 2.2 FAM-Edge | Local-LLM-Runtime 子模块 | 首期不单独管理 Ollama | 靠 systemd 托管 + `/api/tags` 探测,挂了 503 | +| 2.2 FAM-Edge | Storage-Cleaner 子模块 + Cron 兜底 | 仅 `finally` 清理 | 首期任务量小,Cron 推后 | +| 2.3 FAM-UI | 成员管理页(缺失) | 首期纳入,新增 Member-Naming 页 | 交互式命名流程,用户给"人物A/B"赋真名 | +| 2.4 熔断器 | CircuitBreaker | 首期纳入 | Gemini 连续失败跳过调用,冷却后探测恢复 | +| 2.5 资源保护 | 策略 B(503) | 首期不做 | 单并发下无意义 | +| DB | family_members 表(缺失) | 首期新增 | 支持交互式命名,VLM 输出 abstract_label,用户命名后批量回溯更新 | +| API payload | `known_members_context` 字符串 | 改为注入已命名+未命名成员清单 | NAS 端从 `family_members` 表读取后拼接 | + +--- + +## 3. 模块设计 + +### 3.1 FAM-Core(NAS 端,单进程) + +| 模块 | 职责 | 首期简化 | +|------|------|---------| +| Task-Scheduler | 60s 轮询视频目录,创建 PENDING 任务 | `is_complete` 判定简化为"修改时间 > 60s 且文件大小稳定" | +| Dispatcher | 30s 轮询 PENDING 任务,下发至 Edge | 退避重试保留,但所有 stage 一视同仁;payload 注入已命名成员清单 | +| Event-Receiver | Flask 路由,接收 Edge 回调,写库 | 单 worker,不做幂等锁;接收 `frame_details` 数组,逐条插入 `event_details`;对未命名的 abstract_label 自动 upsert 到 `family_members` | +| Chat-Handler | Flask 路由,接收用户问答 | 查 `event_details` 拼上下文 → 调 Oracle Ollama 纯文本 → 写 `chat_history` | +| Member-Manager | Flask 路由,成员命名管理 | 列出未命名人物;接收命名请求;批量 UPDATE `event_details` 回溯历史 | +| Video-Server | Flask `send_from_directory`,提供 mp4 静态下载 | 路由带 `?token=xxx` 鉴权 | + +### 3.2 FAM-Edge(Oracle 端,单进程) + +| 模块 | 职责 | 首期简化 | +|------|------|---------| +| API-Gateway | 接收任务,并发控制 | 同时只允许 1 个任务在处理;新任务到达时若当前有任务处理中,返回 429 | +| Video-Preprocessor | 下载视频 + FFmpeg 粗抽帧 + OpenCV 关键帧筛选 + 压缩 | 下载超时 60s;粗抽 30 张候选帧;筛 5-8 张关键帧;长边 ≤ 1024px,JPEG 质量 80 | +| AI-Orchestrator | 调用 Ollama 视觉分析 + Gemini 视觉分析 + Ollama 文本融合 | Gemini 异步并行调用,超时 8s 不阻塞;熔断器保护;失败降级 local_only | +| Storage-Cleaner | `finally` 删除下载的视频和帧图片 | 不做 Cron 兜底 | + +### 3.3 FAM-UI(NAS 端) + +| 模块 | 职责 | 首期简化 | +|------|------|---------| +| Event-List | 分页展示事件 | 按时间倒序,每页 20 条 | +| Filter-Bar | 按日期筛选 | 仅日期,不做事件类型筛选 | +| Stats-Chart | compute_provider 占比 | 用 Streamlit 内置 bar_chart | +| Chat-Page | AI 对话页签 | 输入框 + 调用 `POST /api/chat/ask` + 展示回答;按 `queried_person` 预设快捷提问按钮 | +| Chat-History | 对话历史列表 | 按 `chat_history.created_at` 倒序,每页 20 条 | +| Member-Naming | 成员命名页 | 列出 `family_members` 中 `real_name IS NULL` 的未命名人物,展示特征描述,输入框命名后调 `POST /api/member/name`;命名后展示已命名成员列表 | + +--- + +## 4. 数据流(首期主链路) + +### 4.1 时序 + +1. NAS 发现新视频 → 写入 `process_tasks` (PENDING) +2. Dispatcher 下发任务至 Oracle (`POST /api/edge/video/analyze`) +3. Oracle 返回 202 → NAS 更新状态为 PROCESSING +4. Oracle 内部: + - a. 下载视频(超时 60s) + - b. FFmpeg 粗抽 30 张候选帧 + OpenCV 关键帧筛选(5-8 张)+ 压缩 + - c. 并行启动:本地 VLM 多模态视觉分析(5-8 张关键帧,超时 240s)+ Gemini 云端视觉分析(超时 8s,可选) + - d. 等待 Gemini 结果(最多 8s,超时或熔断则 `gemini_output=None`,不阻塞) + - e. 本地 VLM 纯文本融合(VLM_OUTPUT + GEMINI_OUTPUT,超时 120s) + - f. 回调 NAS(超时 30s,失败重试 3 次) + - g. 清理临时文件 +5. NAS 收到回调 → 写入 `monitor_events`(含 `compute_provider: hybrid` 或 `local_only`),更新任务 SUCCESS +6. UI 展示 + +### 4.2 与原文档的差异 +- 删除心跳发送步骤 +- 整体超时仍为 600s,由 Edge 端内部定时器控制 +- 保留并行 Gemini 调用(首期纳入),熔断器保护,失败降级 local_only + +--- + +## 5. 数据库设计 + +### 5.1 DDL(与原文档一致) + +```sql +-- 任务表 +CREATE TABLE process_tasks ( + task_id INT AUTO_INCREMENT PRIMARY KEY, + video_path VARCHAR(500) NOT NULL, + video_url VARCHAR(500) NOT NULL, + status ENUM('PENDING','PROCESSING','SUCCESS','FAILED') DEFAULT 'PENDING', + retry_count INT DEFAULT 0, + max_retries INT DEFAULT 3, + next_retry_at DATETIME NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + error_message TEXT NULL, + failure_stage ENUM('download','extract','vlm_visual','vlm_fusion','callback') NULL, + heartbeat_at DATETIME NULL, + INDEX idx_status (status), + INDEX idx_created (created_at) +) ENGINE=InnoDB; + +-- 事件表 +CREATE TABLE monitor_events ( + event_id INT AUTO_INCREMENT PRIMARY KEY, + task_id INT NOT NULL, + event_start_time DATETIME NOT NULL, + event_end_time DATETIME NOT NULL, + camera_name VARCHAR(50), + global_summary TEXT, + entities_json JSON NOT NULL, + compute_provider JSON NOT NULL, -- 模型来源数组,如 ["ollama","gemini"] + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + INDEX idx_event_start (event_start_time), + INDEX idx_compute_provider (compute_provider), + FOREIGN KEY (task_id) REFERENCES process_tasks(task_id) ON DELETE CASCADE +) ENGINE=InnoDB; + +-- 每日摘要表(预留) +CREATE TABLE daily_summaries ( + id INT AUTO_INCREMENT PRIMARY KEY, + target_date DATE NOT NULL UNIQUE, + summary_text TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +) ENGINE=InnoDB; + +-- 事件明细表(每关键帧一条,由 VLM 输出 frame_details 拆分落库) +CREATE TABLE event_details ( + detail_id INT AUTO_INCREMENT PRIMARY KEY, + event_id INT NOT NULL, -- 关联 monitor_events.event_id + task_id INT NOT NULL, -- 冗余,便于查询 + frame_index INT NOT NULL, -- 关键帧序号(1-8) + frame_timestamp DATETIME NOT NULL, -- 该帧对应的绝对时间点 + camera_name VARCHAR(50), -- 摄像头位置(如"客厅") + person VARCHAR(50) NOT NULL, -- 成员名或"未知访客" + action VARCHAR(200) NOT NULL, -- 动作描述(AI 自由生成,无枚举) + clothing VARCHAR(100), -- 衣着 + is_attention_event BOOLEAN DEFAULT FALSE, -- 由 AI 自行判断是否为关注事件,无固定清单 + source_providers JSON NOT NULL, -- 识别到该明细的模型来源数组,如 ["ollama","gemini"] + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + INDEX idx_event_id (event_id), + INDEX idx_frame_timestamp (frame_timestamp), + INDEX idx_person (person), + INDEX idx_queried_date (frame_timestamp), + FOREIGN KEY (event_id) REFERENCES monitor_events(event_id) ON DELETE CASCADE +) ENGINE=InnoDB; + +-- 对话记录表(用户与 AI 问答的完整记录) +CREATE TABLE chat_history ( + chat_id INT AUTO_INCREMENT PRIMARY KEY, + user_question TEXT NOT NULL, -- 用户问题 + ai_answer TEXT NOT NULL, -- AI 回答 + context_summary TEXT, -- 问答时使用的上下文摘要(可选) + queried_date DATE, -- 查询的日期范围 + queried_person VARCHAR(50), -- 查询的人物 + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + INDEX idx_created (created_at), + INDEX idx_queried_date (queried_date) +) ENGINE=InnoDB; + +-- 家庭成员表(交互式命名) +CREATE TABLE family_members ( + member_id INT AUTO_INCREMENT PRIMARY KEY, + abstract_label VARCHAR(20) NOT NULL UNIQUE, -- VLM 首次提取的抽象标识,如 "人物A" + real_name VARCHAR(50), -- 用户命名的真实名字,如 "汤圆";NULL 表示未命名 + feature_description TEXT, -- VLM 提取的特征描述,如 "短发、黄色T恤、男性" + first_seen_at DATETIME, -- 首次被提取的时间 + named_at DATETIME NULL, -- 用户命名的时间 + named_by VARCHAR(50), -- 命名人 + is_active BOOLEAN DEFAULT TRUE, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_real_name (real_name), + INDEX idx_abstract_label (abstract_label) +) ENGINE=InnoDB; +``` + +### 5.2 表关系 + +``` +process_tasks (1) ─── (N) monitor_events (1) ─── (N) event_details + │ + └─ event_id 外键 + +family_members 独立表 + - event_details.person 存 family_members.abstract_label(未命名时)或 real_name(命名后) + - 命名后批量 UPDATE event_details SET person = real_name WHERE person = abstract_label + +chat_history 独立表,不与上述表强关联 +``` + +### 5.3 compute_provider 取值(多模型场景) + +`monitor_events.compute_provider` 记录本次任务调用了哪些模型,JSON 数组字符串: + +| 场景 | 取值示例 | 说明 | +|------|---------|------| +| 仅本地 Ollama 成功 | `["ollama"]` | Gemini 超时/失败/熔断 | +| Ollama + Gemini 均成功 | `["ollama","gemini"]` | 首期典型场景 | +| 加 OpenAI(v1.1) | `["ollama","gemini","openai"]` | 扩展场景 | +| 全部失败 | 任务 FAILED | 不写入 monitor_events | + +**字段类型调整**:`compute_provider` 由原 ENUM 改为 JSON 数组,便于动态扩展。原 ENUM('hybrid','local_only') 已不适用多模型场景。 + +`event_details.source_providers` 与 `monitor_events.compute_provider` 的关系: +- `compute_provider` 记录本次任务级别调用了哪些模型(数组去重) +- `source_providers` 记录该条 frame_detail 被哪些模型识别到(可能少于 compute_provider,例如某帧 Gemini 没识别到人物但 Ollama 识别了) + +--- + +## 6. API 规范 + +## 6. API 规范 + +### 6.1 POST /api/edge/video/analyze(Edge 接收任务) + +请求体: +```json +{ + "task_id": 1001, + "video_url": "http://100.x.x.10:8000/media/video_171500.mp4?token=xxx", + "webhook_url": "http://100.x.x.10:8000/api/core/callback/event", + "known_members_context": "成员A:特征描述; 成员B:特征描述" +} +``` + +响应: +- 202 Accepted:任务已入队 +- 429 Too Many Requests:队列已满(>1 个等待) +- 503 Service Unavailable:Ollama 不可用 + +### 6.2 POST /api/core/callback/event(NAS 接收回调) + +成功请求体: +```json +{ + "task_id": 1001, + "status": "success", + "event_start_time": "2026-08-19T14:00:00Z", + "event_end_time": "2026-08-19T14:30:00Z", + "camera_name": "客厅", + "global_summary": "客厅有人员走动,汤圆在玩积木,期间跌倒一次。", + "entities_json": [ + {"person": "汤圆", "action": "在地毯上玩积木", "clothing": "黄色T恤"} + ], + "frame_details": [ + { + "frame_index": 1, + "frame_timestamp": "2026-08-19T14:03:00Z", + "person": "汤圆", + "action": "在地毯上玩积木", + "clothing": "黄色T恤", + "is_attention_event": false + }, + { + "frame_index": 5, + "frame_timestamp": "2026-08-19T14:25:00Z", + "person": "汤圆", + "action": "跌倒", + "clothing": "黄色T恤", + "is_attention_event": true + } + ], + "compute_provider": "local_only", + "error_message": null +} +``` + +**Event-Receiver 处理逻辑**: +1. 插入 `monitor_events` 1 条(聚合记录,含 global_summary / entities_json / camera_name) +2. 遍历 `frame_details` 数组,逐条插入 `event_details`(条数无上限,AI 输出多少插多少;`action` 由 AI 自由生成,无枚举过滤) +3. 更新 `process_tasks` 状态为 SUCCESS + +失败请求体: +```json +{ + "task_id": 1001, + "status": "failed", + "failure_stage": "vlm_visual", + "error_message": "VLM inference timeout" +} +``` + +响应:200 OK + +### 6.3 GET /media/\(Video-Server 静态服务) + +- 带鉴权:`?token=xxx`,token 从 NAS 端配置读取 +- 无 token 或 token 错误返回 403 +- 文件不存在返回 404 + +### 6.4 POST /api/chat/ask(NAS 接收用户问答) + +请求体: +```json +{ + "question": "汤圆今天干嘛了?", + "queried_person": "汤圆", + "queried_date": "2026-08-19" +} +``` + +响应(200 OK): +```json +{ + "answer": "根据今日监控数据,汤圆主要活动如下:\n14:03 在客厅地毯上玩积木\n14:25 在客厅跌倒一次(关注事件)\n15:10 在客厅看绘本\n...", + "context_summary": "查询 event_details 12 条,时间范围 14:03-18:30", + "chat_id": 5001 +} +``` + +**Chat-Handler 处理逻辑**: +1. 根据 `queried_person` 和 `queried_date` 查询 `event_details` + ```sql + SELECT frame_timestamp, camera_name, person, action, clothing, + is_attention_event + FROM event_details + WHERE person = :queried_person + AND DATE(frame_timestamp) = :queried_date + ORDER BY frame_timestamp ASC + ``` +2. 拼接上下文(每条明细一行:`[14:03 客厅] 汤圆 在地毯上玩积木 (黄色T恤)`) +3. 若明细条数 > 50,按小时聚合成 24 行摘要再喂给 VLM(避免超 llava-phi3 上下文 2000 字) +4. POST Oracle Ollama 纯文本模式,调问答 Prompt +5. 插入 `chat_history`(user_question + ai_answer + context_summary + queried_date + queried_person) +6. 返回回答 + +**问答 Prompt(中文)**: + +``` +你是家庭监控助手。根据以下今日监控数据,回答用户问题。 + +今日数据(按时间顺序,每条一行): +[14:03 客厅] 汤圆 在地毯上玩积木 (黄色T恤) +[14:25 客厅] 汤圆 跌倒 (黄色T恤) [关注事件] +[15:10 客厅] 汤圆 看绘本 (黄色T恤) +... + +已知家庭成员: 汤圆(特征描述) + +用户问题: {question} + +要求: +- 只基于上述数据回答,不要编造 +- 按时间顺序总结 +- 若有关注事件(跌倒、哭闹等),重点提示 +- 若当天没有该人员的数据,明确说"今天没有观察到{person}" +- 用自然语言回答,不要输出 JSON +``` + +### 6.5 GET /api/chat/history(获取对话历史,可选) + +查询参数:`?date=2026-08-19` 或 `?person=汤圆&limit=20` + +响应:按 `chat_history.created_at` 倒序返回对话列表。 + +### 6.6 GET /api/member/unnamed(列出未命名人物) + +响应(200 OK): +```json +{ + "unnamed_members": [ + { + "abstract_label": "人物A", + "feature_description": "短发、黄色T恤、男性", + "first_seen_at": "2026-08-19T14:03:00Z", + "event_count": 15 + }, + { + "abstract_label": "人物B", + "feature_description": "长发、红色裙子、女性", + "first_seen_at": "2026-08-19T15:10:00Z", + "event_count": 8 + } + ] +} +``` + +### 6.7 POST /api/member/name(命名人物) + +请求体: +```json +{ + "abstract_label": "人物A", + "real_name": "汤圆", + "named_by": "管理员" +} +``` + +**Member-Manager 处理逻辑**: +1. 校验 `abstract_label` 存在于 `family_members` 且 `real_name IS NULL` +2. UPDATE `family_members` SET `real_name=:real_name, named_at=NOW(), named_by=:named_by` WHERE `abstract_label=:abstract_label` +3. 批量回溯更新历史记录: + ```sql + UPDATE event_details SET person = :real_name + WHERE person = :abstract_label; + + UPDATE monitor_events + SET entities_json = JSON_REPLACE(entities_json, '$[*].person', :real_name) + WHERE entities_json LIKE :abstract_label_pattern; + ``` +4. 响应(200 OK): + ```json + { + "abstract_label": "人物A", + "real_name": "汤圆", + "updated_event_details_count": 15, + "updated_monitor_events_count": 8 + } + ``` + +### 6.8 GET /api/member/list(列出所有成员) + +查询参数:`?include_named=true&include_unnamed=true` + +响应:返回 `family_members` 全表,按 `first_seen_at` 升序。 + +--- + +## 7. 关键技术 + +### 7.1 视频预处理(关键帧筛选) + +不再使用等距抽 30 帧的方案,改为关键帧筛选,将 VLM 输入图片数量从 30 降到 5-8 张。 + +**两步流程**: + +**步骤 1:等距粗抽帧** +- `ffprobe` 获取视频时长(秒) +- 计算 `interval = duration / 30` +- `ffmpeg -vf fps=1/{interval}` 抽出 30 张候选帧 + +**步骤 2:帧差分析筛选关键帧** +- 用 OpenCV 对 30 张候选帧两两计算 MSE(均方误差)或 SSIM(结构相似性) +- 筛选规则(按优先级): + 1. **首帧必选**(保留时间起点) + 2. **末帧必选**(保留时间终点) + 3. **差异最大的 N 帧**:遍历 30 张,保留与上一关键帧 MSE > 阈值(默认 500)的帧 + 4. 若筛选结果 < 5 帧,从剩余候选中按时间均匀补足到 5 帧 + 5. 若筛选结果 > 8 帧,按差异值降序取前 8 帧 +- 最终输出 5-8 张关键帧 + +**步骤 3:压缩** +- OpenCV 缩放(长边 > 1024 才缩)+ JPEG 质量 80 保存 + +**MSE 阈值参数**: +- 默认 `MSE_THRESHOLD = 500`(对应像素差异约 22,可调) +- 配置项放 `config.yaml`,便于现场调优 + +**异常兜底**: +- ffprobe 失败 → 退化为按 60s 间隔抽帧 +- 帧差分析异常 → 退化为等距抽 5 帧 +- OpenCV 压缩失败 → 跳过该帧,记录 WARN + +### 7.2 本地 VLM 两阶段调用 + +#### 7.2.1 视觉分析阶段(多模态输入,并行调用) + +本地 VLM 和 Gemini 云端并行调用,两个结果都送入文本融合阶段做交叉验证。 + +**本地 VLM 调用**: +- **模型**:`llava-phi3`(Ollama) +- **输入**:5-8 张关键帧(Base64 编码)+ 中文 Prompt +- **调用方式**:一次性输入所有关键帧(数量已降到 5-8,上下文压力可控) +- **参数**:`temperature=0.2, top_p=0.8` +- **超时**:240s(5-8 张 × 单张 ≤ 8s ≈ 40-64s,有充足余量) +- **输出**:VLM_OUTPUT,自然语言描述,按帧顺序逐段输出 + +**Gemini 云端调用**(详见 7.6): +- **模型**:`gemini-1.5-flash`(Google AI Studio API) +- **输入**:同批 5-8 张关键帧(inline_data Base64)+ 英文 Prompt +- **调用方式**:`ThreadPoolExecutor` 异步并行,与本地 VLM 同时启动 +- **参数**:`temperature=0.2, top_p=0.8` +- **超时**:8s(`future.result(timeout=8)`,超时或异常返回 None,不阻塞主链路) +- **熔断保护**:连续 5 次失败 → OPEN 状态 15 分钟内跳过调用(详见 7.6.4) +- **输出**:GEMINI_OUTPUT,自然语言描述;失败/超时/熔断时为 None + +**视觉分析 Prompt(中文,含时间戳注入 + 交互式命名)**: + +Oracle 抽帧时记录每帧在视频中的时间偏移(秒),加上视频开始时间(event_start_time),得到绝对时间。注入 Prompt 时按帧序号列出。 + +NAS 端 Dispatcher 下发任务时,从 `family_members` 表读取已命名成员清单,注入 Prompt。未命名成员(`real_name IS NULL`)的特征描述也注入,提示 VLM 用其 `abstract_label`(如"人物A")标识。 + +``` +你是家庭监控视频分析助手。请按时间顺序描述下列 {N} 张图片中可见的内容,只描述客观画面,不要猜测或推测。 + +每张图片对应的时间戳如下: +[图片1] 时间: {frame_timestamp_1} # 例: 2026-08-19 14:03:00 +[图片2] 时间: {frame_timestamp_2} +... +[图片N] 时间: {frame_timestamp_N} + +每张图片需报告: +1. 人物:数量、衣着(颜色+类型)、可见动作 +2. 物品:玩具、奶瓶、家具等显眼物品 +3. 互动:人与人、人与物品之间的互动 + +已知家庭成员清单(按特征匹配,匹配成功用 real_name,未匹配用"人物X"标识): +{known_members_context} +# 注入示例: +# 汤圆: 短发、黄色T恤、男性(real_name=汤圆) +# 人物A: 长发、红色裙子、女性(abstract_label=人物A,未命名) +# 若画面人物与上述特征匹配,使用对应的名字或 abstract_label; +# 若画面人物与上述都不匹配,按出现顺序赋予新标识"人物B"、"人物C"... + +输出格式(纯文本,每张图片一段,保留时间戳标记): +[图片1] 时间: {frame_timestamp_1} +内容: ... + +[图片2] 时间: {frame_timestamp_2} +内容: ... +... + +要求简洁、客观。不要输出 JSON,不要输出 markdown。 +``` + +#### 7.2.2 文本融合阶段(纯文本输入,多模型平等交叉验证) + +- **模型**:`llava-phi3`(同一模型,纯文本模式) +- **输入**:所有启用模型的视觉分析输出(键值对,provider → 输出文本)+ 已知成员清单 +- **参数**:`temperature=0.0, format="json"` +- **超时**:120s +- **输出**:JSON 结构(global_summary / entities_json / frame_details / compute_provider) + +**文本融合 System Prompt(中文,多模型平等交叉验证)**: + +``` +你是一个无情的数据提取器。不要输出任何思考过程,只输出合法 JSON。 + +输入参数: +- 多模型视觉分析日志(每个模型独立输出,平等对待,交叉验证): + - 输出: # 例: ollama 输出 + - 输出: # 例: gemini 输出 + - ...(数量可变,由实际调用的模型决定) +- 已知成员清单: + +执行规则: +1. 多个模型的输出平等对待,交叉验证: + - 多个模型一致描述的内容 → 可信度高,必须纳入 frame_details,source_providers 列出所有一致的模型 + - 仅单一模型描述的内容 → 纳入 frame_details,source_providers 仅含该模型 + - 多个模型冲突时(如人物动作描述不一致)→ 以多数模型一致为准,source_providers 列出多数派模型 +2. 画面人物按特征匹配已知成员清单: + - 匹配到已命名成员(real_name 非空)→ person 字段填 real_name(如"汤圆") + - 匹配到未命名成员(real_name 为空)→ person 字段填 abstract_label(如"人物A") + - 都不匹配 → 按出现顺序赋予新标识"人物B"、"人物C"... +3. 提取每张关键帧对应的时间点、人物、动作、衣着,输出到 frame_details 数组。 +4. frame_details 每条必须包含 source_providers 数组,列出识别到该条内容的模型来源。 +5. compute_provider 字段填入本次实际成功调用的所有模型标识数组(去重)。 +6. 仅输出合法 JSON,不输出任何思考过程、markdown 标记或注释。 + +输出 JSON 结构: +{ + "global_summary": "字符串,整个时段的整体摘要,简体中文", + "entities_json": [ + { + "person": "字符串,已命名成员的 real_name,或未命名成员的 abstract_label(人物A/B/C...)", + "action": "字符串,简体中文动作描述", + "clothing": "字符串,简体中文衣着描述,可为空字符串" + } + ], + "frame_details": [ + { + "frame_index": "数字,关键帧序号(1-8)", + "frame_timestamp": "字符串,ISO 8601 格式时间戳,与视觉日志中的时间戳一致", + "person": "字符串,已命名成员的 real_name,或未命名成员的 abstract_label", + "action": "字符串,简体中文动作描述,由 AI 自由生成,无枚举限制", + "clothing": "字符串,简体中文衣着描述,可为空字符串", + "is_attention_event": "布尔值,由 AI 自行判断该动作是否属于需要关注的异常行为(如跌倒、哭闹、玩危险物品等),无需匹配固定清单", + "source_providers": "数组,识别到该条内容的模型标识,如 [\"ollama\",\"gemini\"]" + } + ], + "compute_provider": "数组,本次实际成功调用的所有模型标识,如 [\"ollama\",\"gemini\"]" +} +``` + +**Event-Receiver 对未命名 abstract_label 的处理**: +- 收到回调后,对 frame_details 中每个 person 字段值 +- 若该值形如 `人物X`(正则 `^人物[A-Z]$`)且不在 `family_members` 表中: + - INSERT INTO family_members (abstract_label, feature_description, first_seen_at) VALUES (...) + - feature_description 从该帧的 clothing + action 推断拼接 +- 若已存在,跳过(不更新 feature_description,保留首次提取的描述) + +**frame_details 字段说明**: +- 数组长度等于关键帧数量(5-8 条) +- `frame_timestamp` 必须与视觉分析阶段 Prompt 注入的时间戳一致 +- `frame_index` 从 1 开始,与图片序号对应 +- `action` 由 AI 自由生成,无枚举过滤 +- `is_attention_event` 由 AI 自行判断,不预设事件清单 +- `source_providers` 必填,数组,列出识别到该条内容的模型标识 +- 条数无上限,AI 输出多少条就插多少条到 `event_details` + +#### 7.2.3 JSON 解析容错与 Schema 校验 + +三层容错策略,从最稳到最不稳依次尝试: + +```python +def parse_vlm_json(raw: str) -> dict: + # 第 1 层:直接 json.loads + try: + return validate_schema(json.loads(raw.strip())) + except json.JSONDecodeError: + pass + + # 第 2 层:提取 markdown fence 内容(llava 经常会加 ```json ... ```) + fence_match = re.search(r'```(?:json)?\s*(\{.*?\})\s*```', raw, re.DOTALL) + if fence_match: + try: + return validate_schema(json.loads(fence_match.group(1))) + except json.JSONDecodeError: + pass + + # 第 3 层:贪婪匹配最大的 {...} + brace_match = re.search(r'\{.*\}', raw, re.DOTALL) + if brace_match: + try: + return validate_schema(json.loads(brace_match.group(0))) + except json.JSONDecodeError: + pass + + raise VLMOutputInvalidError(f"无法从 VLM 输出中解析 JSON: {raw[:200]}") + + +def validate_schema(data: dict) -> dict: + """Schema 校验 + 脏数据清洗""" + required = ["global_summary", "entities_json", "frame_details", "compute_provider"] + for k in required: + if k not in data: + raise VLMOutputInvalidError(f"缺失字段: {k}") + + # entities_json 结构校验 + if not isinstance(data["entities_json"], list): + raise VLMOutputInvalidError("entities_json 必须为数组") + + cleaned_entities = [] + for ent in data["entities_json"]: + if not isinstance(ent, dict): + continue + if "person" not in ent or "action" not in ent: + raise VLMOutputInvalidError("entity 缺少 person 或 action 字段") + cleaned_entities.append({ + "person": str(ent["person"]), + "action": str(ent["action"]), + "clothing": str(ent.get("clothing", "")) + }) + data["entities_json"] = cleaned_entities + + # frame_details 结构校验(无枚举过滤,AI 自由生成) + if "frame_details" not in data: + data["frame_details"] = [] + if not isinstance(data["frame_details"], list): + raise VLMOutputInvalidError("frame_details 必须为数组") + + cleaned_frames = [] + for frame in data["frame_details"]: + if not isinstance(frame, dict): + continue + # 必填字段:frame_index, frame_timestamp, person, action, source_providers + for k in ["frame_index", "frame_timestamp", "person", "action", "source_providers"]: + if k not in frame: + raise VLMOutputInvalidError(f"frame_details 缺少字段: {k}") + # source_providers 必须是非空数组 + sp = frame["source_providers"] + if not isinstance(sp, list) or len(sp) == 0: + raise VLMOutputInvalidError("frame_details.source_providers 必须为非空数组") + cleaned_frames.append({ + "frame_index": int(frame["frame_index"]), + "frame_timestamp": str(frame["frame_timestamp"]), + "person": str(frame["person"]), + "action": str(frame["action"]), # 由 AI 自由生成,无枚举过滤 + "clothing": str(frame.get("clothing", "")), + "is_attention_event": bool(frame.get("is_attention_event", False)), + "source_providers": [str(p) for p in sp] # 模型标识数组,如 ["ollama","gemini"] + }) + data["frame_details"] = cleaned_frames + + # compute_provider 校验为数组(多模型场景) + if not isinstance(data["compute_provider"], list): + raise VLMOutputInvalidError("compute_provider 必须为数组") + if len(data["compute_provider"]) == 0: + raise VLMOutputInvalidError("compute_provider 不能为空数组") + data["compute_provider"] = [str(p) for p in data["compute_provider"]] + + return data +``` + +**容错层级说明**: +- 第 1 层:最理想情况,模型直接输出合法 JSON +- 第 2 层:模型加了 markdown fence 包裹 +- 第 3 层:模型输出有前后缀文字,但 JSON 片段完整 +- 三层全部失败 → 抛 `VLMOutputInvalidError`,任务进入 FAILED 状态,由 Dispatcher 退避重试 + +**`validate_schema` 的脏数据清洗**: +- entities_json 中非 dict 元素被过滤 +- frame_details 中非 dict 元素被过滤 +- frame_details 的 `action` 字段由 AI 自由生成,**不做枚举过滤** +- frame_details 的 `is_attention_event` 由 AI 自行判断,不预设事件清单 +- frame_details 的 `source_providers` 必须为非空数组 +- compute_provider 必须为非空数组,由融合阶段根据实际成功调用的模型动态填入 + +### 7.3 模型健康探测 + +接收任务前,AI-Orchestrator 遍历 `config.yaml` 中所有 `enabled: true` 的模型,逐个调用其适配器的 `health_check()` 方法: + +| 适配器 | 健康检查方法 | 判定条件 | +|--------|------------|---------| +| OllamaAdapter | `GET /api/tags` | HTTP 200 且响应含配置的模型名(如 `llava-phi3`) | +| GeminiAdapter | `GET https://generativelanguage.googleapis.com/v1/models?key=API_KEY` | HTTP 200 且响应含 `gemini-1.5-flash` | +| OpenAIAdapter (v1.1) | `GET /v1/models` 带鉴权头 | HTTP 200 | +| NvidiaAdapter (v1.1) | `GET /v1/models` 带鉴权头 | HTTP 200 | + +- 至少一个模型健康 → 接收任务,进入 202 +- 全部模型不健康 → 返回 503,NAS 端 Dispatcher 退避重试 +- 任务执行时,单个模型不健康跳过该模型,其他模型照常调用(动态降级) +- Ollama 由 systemd 托管,FAM-Edge 不管理进程 + +### 7.4 任务超时与重试 +- 整体超时 600s(Edge 端内部定时器) +- 失败后由 NAS 端 Dispatcher 退避重试:`min(60 * (retry_count + 1) * 2, 600)` 秒 +- 超过 max_retries=3 置为 FAILED + +### 7.5 日志与可观测性(首期最小集) +- 所有日志带 `task_id` 作为 trace_id +- 各阶段打 INFO 日志,格式:`[task_id={id}] {stage} done in {ms}ms` +- 模型调用日志:`[task_id={id}] model {provider} done in {ms}ms, success={bool}` +- 失败打 ERROR 日志,带 failure_stage 和 error_message +- 不做指标聚合和告警,v1.1 再说 + +### 7.6 模型适配器架构(可扩展多模型) + +#### 7.6.1 抽象基类 + +```python +from abc import ABC, abstractmethod +from typing import List, Optional + +class BaseModelAdapter(ABC): + """所有模型适配器的抽象基类。新增模型只需继承此类并实现 4 个方法。""" + + def __init__(self, provider_name: str, config: dict): + self.provider_name = provider_name # 如 "ollama", "gemini", "openai" + self.config = config # 从 config.yaml 读到的该模型配置 + + @abstractmethod + def health_check(self) -> bool: + """健康检查,返回 True/False""" + pass + + @abstractmethod + def analyze_frames(self, frame_paths: List[str], + frame_timestamps: List[str], + known_members_context: str) -> Optional[str]: + """视觉分析:输入帧图片路径 + 时间戳 + 成员清单,输出自然语言描述。 + 失败/超时返回 None。""" + pass + + @abstractmethod + def get_timeout(self) -> int: + """该模型的调用超时秒数""" + pass + + @abstractmethod + def get_circuit_breaker(self): + """返回该模型专属的熔断器实例""" + pass +``` + +#### 7.6.2 首期实现的适配器 + +**OllamaAdapter**(本地模型): +- `provider_name = "ollama"` +- 健康检查:`GET http://localhost:11434/api/tags` +- `analyze_frames`:调用 `ollama.generate(model="llava-phi3", prompt=..., images=[base64...], options={"temperature":0.2,"top_p":0.8})` +- 超时:240s +- 熔断器:不启用(本地模型,挂了靠健康检查拦截) + +**GeminiAdapter**(云端): +- `provider_name = "gemini"` +- 健康检查:`GET https://generativelanguage.googleapis.com/v1/models?key=API_KEY` +- `analyze_frames`:调用 `google-generativeai` SDK,`GenerativeModel("gemini-1.5-flash").generate_content(prompt, [PIL.Image, ...])` +- 超时:8s +- 熔断器:启用,连续 5 次失败 → OPEN 15 分钟,HALF_OPEN 允许一次探测 + +#### 7.6.3 v1.1 扩展适配器(接口位预留) + +- **OpenAIAdapter**:`provider_name="openai"`,调 `openai.ChatCompletion` 或 `gpt-4o` 视觉接口 +- **NvidiaAdapter**:`provider_name="nvidia"`,调 NVIDIA NIM API +- 新增模型只需:继承 `BaseModelAdapter` + 在 `config.yaml` 的 `models` 数组加一项 + 在适配器工厂注册 + +#### 7.6.4 熔断器(CircuitBreaker,每个云端模型独立实例) + +```python +from collections import deque +import time + +class CircuitBreaker: + def __init__(self, threshold: int = 5, cooldown: int = 900): + self.failures = deque(maxlen=threshold) + self.state = 'CLOSED' # CLOSED / OPEN / HALF_OPEN + self.cooldown = cooldown + self.last_failure = None + + def record_failure(self): + self.failures.append(time.time()) + if len(self.failures) >= self.failures.maxlen: + self.state = 'OPEN' + self.last_failure = time.time() + + def record_success(self): + self.failures.clear() + self.state = 'CLOSED' + + def is_open(self): + if self.state == 'OPEN' and time.time() - self.last_failure > self.cooldown: + self.state = 'HALF_OPEN' + return self.state == 'OPEN' +``` + +- 阈值:连续 5 次失败触发 OPEN +- 冷却:15 分钟后转 HALF_OPEN,允许一次探测 +- 探测成功 → CLOSED;探测失败 → 重新 OPEN 计时 +- OPEN 状态直接跳过该模型调用,`analyze_frames` 返回 None + +### 7.7 AI-Orchestrator 调用流程(多模型并行) + +```python +def process_task_worker(task_data): + # 1. 加载所有启用的模型适配器 + adapters = [build_adapter(cfg) for cfg in config["models"] if cfg["enabled"]] + + # 2. 健康检查 + healthy_adapters = [a for a in adapters if a.health_check()] + if not healthy_adapters: + return 503 # NAS 退避重试 + + # 3. 抽帧 + frame_paths = extract_frames(video_path, task_id) + frame_timestamps = compute_timestamps(video_path, task_data["event_start_time"]) + + # 4. 并行调用所有健康模型(ThreadPoolExecutor) + from concurrent.futures import ThreadPoolExecutor, as_completed + model_outputs = {} + with ThreadPoolExecutor(max_workers=len(healthy_adapters)) as pool: + futures = { + pool.submit(a.analyze_frames, frame_paths, frame_timestamps, known_members): a.provider_name + for a in healthy_adapters + if not a.get_circuit_breaker().is_open() # 熔断的跳过 + } + for fut in as_completed(futures, timeout=240): + provider = futures[fut] + try: + output = fut.result(timeout=a.get_timeout()) + if output: + model_outputs[provider] = output + a.get_circuit_breaker().record_success() + else: + a.get_circuit_breaker().record_failure() + except Exception: + a.get_circuit_breaker().record_failure() + + # 5. 至少一个模型成功才继续 + if not model_outputs: + raise TaskFailedError('vlm_visual', 'All models failed') + + # 6. 文本融合(多模型输出平等交叉验证) + fusion_result = run_vlm_fusion(model_outputs, task_data["known_members_context"]) + # fusion_result.compute_provider = list(model_outputs.keys()) + # fusion_result.frame_details[*].source_providers 由 VLM 在融合时填入 + + # 7. 回调 + send_callback(task_data["webhook_url"], task_id, fusion_result) +``` + +**关键设计**: +- 模型清单由 `config.yaml` 的 `models` 数组动态决定,AI-Orchestrator 不硬编码任何模型 +- 新增模型只改 config.yaml + 加适配器类,主流程不动 +- 熔断器每模型独立,互不影响 +- `model_outputs` 是 dict,键是 provider_name,值是输出文本,送入融合 Prompt + +--- + +## 8. 部署说明 + +### 8.0 代码仓库(部署前必读) + +- 仓库地址:`http://192.168.50.64:3000/ericwyuan/sentinel-home-ai` +- 仓库类型:Gitea(自建,仅内网访问) +- 克隆:`git clone http://192.168.50.64:3000/ericwyuan/sentinel-home-ai.git` +- 详细的 AI Agent 提交规范见 10.1.1 节 + +### 8.1 NAS 端 +1. Python 3.10+,Flask,Gunicorn(1 worker),mysql-connector-python,PyYAML +2. 配置 MariaDB,执行 DDL(含 `family_members` 表) +3. 配置 `config/config.yaml`(Oracle 地址、token、NAS 端口等;成员不再走 YAML,由 `family_members` 表管理) +4. `gunicorn -w 1 -b 0.0.0.0:8000 app:app` 启动 + +### 8.2 Oracle 端 +1. FFmpeg、OpenCV、Ollama +2. `ollama pull llava-phi3` +3. 安装 Python 依赖:`pip install google-generativeai openai`(openai 为 v1.1 预留) +4. 配置 `config/config.yaml`(含 models 数组,见 8.4) +5. 配置 Gemini API Key:在 `config/config.yaml` 的 `models` 中填入 `api_key`,或通过环境变量 `GEMINI_API_KEY` 注入 +6. `gunicorn -w 1 -b 0.0.0.0:5000 app:app` 启动(或 Flask 内置线程池) + +### 8.3 FAM-UI +1. Streamlit +2. 连接 MariaDB +3. `streamlit run app.py` + +### 8.4 config.yaml 结构(多模型可配置) + +Oracle 端 `config/config.yaml` 示例: + +```yaml +# NAS 端回调地址 +nas: + webhook_url: "http://100.x.x.10:8000/api/core/callback/event" + media_base_url: "http://100.x.x.10:8000/media" + media_token: "xxx" + +# Oracle 端服务 +server: + host: "0.0.0.0" + port: 5000 + max_concurrent_tasks: 1 + +# 关键帧筛选参数 +video: + candidate_frames: 30 # 粗抽候选帧数 + min_key_frames: 5 # 最少关键帧 + max_key_frames: 8 # 最多关键帧 + mse_threshold: 500 # 帧差阈值 + jpeg_quality: 80 + max_long_edge: 1024 + +# 超时(秒) +timeout: + download: 60 + vlm_visual: 240 # 单模型视觉分析超时 + vlm_fusion: 120 + callback: 30 + overall: 600 + +# 模型清单(可扩展,新增模型只需在此数组加一项 + 实现适配器) +models: + - provider: "ollama" + enabled: true + model_name: "llava-phi3" + base_url: "http://localhost:11434" + timeout: 240 + circuit_breaker: + enabled: false # 本地模型不启用熔断 + threshold: 5 + cooldown: 900 + + - provider: "gemini" + enabled: true + model_name: "gemini-1.5-flash" + api_key: "${GEMINI_API_KEY}" # 从环境变量读取,避免硬编码 + timeout: 8 + circuit_breaker: + enabled: true + threshold: 5 + cooldown: 900 + + # v1.1 扩展示例(取消注释并填入 API Key 即启用) + # - provider: "openai" + # enabled: false + # model_name: "gpt-4o" + # api_key: "${OPENAI_API_KEY}" + # timeout: 30 + # circuit_breaker: + # enabled: true + # threshold: 5 + # cooldown: 900 + + # - provider: "nvidia" + # enabled: false + # model_name: "nvidia/llama-3.1-nemotron-70b-instruct" + # api_key: "${NVIDIA_API_KEY}" + # base_url: "https://integrate.api.nvidia.com/v1" + # timeout: 30 + # circuit_breaker: + # enabled: true + # threshold: 5 + # cooldown: 900 +``` + +**配置说明**: +- `models` 是数组,每个元素是一个模型的完整配置 +- `provider` 字段决定使用哪个适配器(OllamaAdapter / GeminiAdapter / ...) +- `enabled: false` 的模型被跳过,不调用 +- `api_key` 支持 `${ENV_VAR}` 语法从环境变量读取,避免硬编码到配置文件 +- 新增模型只需:实现适配器类 + 在此数组加一项,主流程不动 + +### 8.3 FAM-UI +1. Streamlit +2. 连接 MariaDB +3. `streamlit run app.py` + +--- + +## 9. 前置风险(必须在阶段一验证) + +### 9.1 llava-phi3 在 Oracle 2C12G 无 GPU 上的可行性 + +Oracle 免费 2C12G 实例(Ampere A1,ARM)无 GPU,走 CPU 推理。llava-phi3(基于 Phi-2,约 2.7B 参数 + CLIP 视觉编码器)即使 4-bit 量化也需约 2-2.5GB 内存。单张 1024px 图像推理可能数十秒级。 + +**验证方法**: +1. `ollama pull llava-phi3` +2. 喂单张 1024px JPEG,问"描述画面",记录耗时 +3. 喂 5 张、10 张、30 张,记录耗时和是否报错 + +**判定标准**(关键帧筛选后 VLM 输入为 5-8 张,与视觉分析 240s 超时对齐): +- 单张 ≤ 8 秒 → 可行,5-8 张预计 40-64s,远低于 240s 超时 +- 单张 ≤ 30 秒 → 可接受,5-8 张预计 150-240s,刚好卡超时边界 +- 单张 > 30 秒 → 不可行,5-8 张 > 240s,需减帧或换更小模型 +- 多张图触上下文截断或报错 → 需调整关键帧数量上限(默认 8 张往下调) + +**若不可行的备选方向**: +1. 换更小模型(如 Moondream 或 Qwen-VL 0.5B 级别) +2. 减少抽帧数量(30 → 10 → 5) +3. 加 swap(不推荐,性能更差) +4. 改用 Gemini 作为主链路(与首期"先跑通本地链路"目标冲突,需重新讨论) + +--- + +## 10. 验收标准(首期版) + +### 10.1 执行主体说明 + +- 🤖 **AI Agent 自动执行**:编码、脚本配置、单元/集成测试、Bug 修复迭代,可连续执行,不受人类工作时间限制 +- 👤 **需人工介入**:账号开通、密钥提供、真实录像、硬件操作、最终效果确认 + +### 10.1.1 代码仓库与提交规范(AI Agent 必读) + +**仓库地址**: +- `http://192.168.50.64:3000/ericwyuan/sentinel-home-ai` +- 仓库类型:Gitea(自建,仅内网访问) + +**克隆方式**: +```bash +git clone http://192.168.50.64:3000/ericwyuan/sentinel-home-ai.git +``` + +**AI Agent 工作流程(强制)**: + +1. **开工前**:在仓库根目录执行 `git pull --rebase`,确保本地与远端同步 +2. **每完成一项验收标准子任务后**(即 10.2 中表格的每一行): + - 🤖 必须执行 `git add` + `git commit` + `git push` + - commit message 格式:`[阶段X.Y子任务号] 子任务名称 - 完成内容简述` + - 例:`[2.1] Task-Scheduler - 实现 60s 轮询 + 持久化 known_files` + - 例:`[3.3] 文本融合调用 - 三层 JSON 容错 + validate_schema` + - 一项任务一个 commit,不批量合并多任务到单 commit +3. **遇到阻塞时**(如外网不可达、API 失败): + - 先 commit 当前可工作的部分,commit message 标注 `[WIP]` 前缀 + - 例:`[WIP][3.2] 视觉分析调用 - Prompt 注入时间戳,待真实视频测试` + - 不要等所有问题解决再提交 +4. **修复 Bug 后**:单独 commit,message 格式 `fix(模块): 问题简述` + - 例:`fix(Event-Receiver): frame_details 逐条插入时 abstract_label 未 upsert` +5. **禁止**: + - ❌ 不 commit 直接继续下一项任务 + - ❌ 一次 commit 包含多个子任务 + - ❌ 使用 `git push --force`(除非明确授权) + - ❌ 跳过 hooks(`--no-verify`) + +**commit message 语言**:中文为主,技术术语可保留英文 + +**示例(一天的工作流)**: +``` +git pull --rebase +# 完成 2.1 Task-Scheduler +git add src/fam_core/scheduler.py +git commit -m "[2.1] Task-Scheduler - 60s 轮询 + known_files 持久化到 DB" +git push + +# 完成 2.2 Event-Receiver +git add src/fam_core/event_receiver.py src/fam_core/db_layer.py +git commit -m "[2.2] Event-Receiver - frame_details 逐条插入 + abstract_label upsert" +git push + +# 发现 Bug 修复 +git add src/fam_core/event_receiver.py +git commit -m "fix(Event-Receiver): abstract_label 正则匹配漏了小写字母" +git push +``` + +**AI Agent 自检清单**(每完成一项任务前自问): +- [ ] 这项任务对应 10.2 表格的哪一行?验收标准是否达到? +- [ ] 是否已 commit + push? +- [ ] commit message 是否符合格式? +- [ ] 是否在开始下一项前 `git pull --rebase`? + +### 10.2 详细任务分解与验收标准 + +#### 阶段一:环境准备与基础设施 + +| 子任务 | 执行主体 | 验收标准 | +|-------|---------|---------| +| 1.1 Tailscale 组网 | 👤+🤖 | 两端可 ping 通,curl 可访问端口 | +| 1.2 MariaDB 部署 | 🤖 | 建表成功(含 `family_members` / `event_details` / `chat_history` 六张表),字段类型符合 Schema | +| 1.3 Oracle 基础环境 | 👤+🤖 | FFmpeg、Ollama 正常,`llava-phi3` 模型加载成功 | +| 1.4 **模型可行性压测** | 🤖 | **单张 1024px 图推理 ≤ 8 秒(关键帧 5-8 张,与视觉分析 240s 超时对齐,首期阻塞项)** | + +#### 阶段二:FAM-Core 基础链路(NAS 端) + +| 子任务 | 执行主体 | 验收标准 | +|-------|---------|---------| +| 2.1 Task-Scheduler | 🤖 | 新视频 60s 内被扫描,无重复插入;重启后 `known_files` 不丢失(持久化到 DB 或文件) | +| 2.2 Event-Receiver | 🤖 | 回调返回 200;`monitor_events` 插 1 条聚合记录;`frame_details` 数组逐条插入 `event_details`(条数无上限);未命名 abstract_label 自动 upsert 到 `family_members` | +| 2.3 状态机流转 | 🤖 | 失败自动重试,超 3 次置为 FAILED;`failure_stage` 字段正确记录失败阶段 | +| 2.4 任务下发客户端 | 🤖 | 收到 202 后状态切换为 PROCESSING;payload 注入 `family_members` 表的已命名+未命名成员清单 | +| 2.5 Member-Manager | 🤖 | `GET /api/member/unnamed` 返回未命名人物列表;`POST /api/member/name` 命名后批量 UPDATE `event_details` 回溯历史,返回更新条数 | + +#### 阶段三:FAM-Edge 本地推理链路(Oracle 端) + +| 子任务 | 执行主体 | 验收标准 | +|-------|---------|---------| +| 3.1 Video-Preprocessor | 🤖 | 等距粗抽 30 张候选帧;OpenCV MSE 帧差分析筛 5-8 张关键帧(首末帧必选);长边 ≤ 1024px,JPEG 质量 80 | +| 3.2 视觉分析调用 | 🤖 | 10 次测试无空输出、无崩溃;Prompt 注入每帧时间戳(Oracle 计算 = 视频开始时间 + 帧偏移);输出按帧顺序逐段 | +| 3.3 文本融合调用 | 🤖 | 20 次测试 JSON 解析成功率 ≥ 90%(三层容错:直接解析 → markdown fence → 贪婪 brace 匹配);`validate_schema` 通过;`action` 由 AI 自由生成,无枚举过滤;`is_attention_event` 由 AI 自行判断 | +| 3.4 成员标识提取 | 🤖 | 已命名成员用 `real_name`;未命名成员用 `abstract_label`(人物A/B/C...);新人物按出现顺序赋予新标识;特征描述落 `family_members.feature_description` | +| 3.5 AI-Orchestrator 串联 | 🤖 | 端到端全自动完成(下载 → 抽帧 → VLM 视觉 → VLM 融合 → 回调 → 清理);各阶段耗时打 INFO 日志(带 task_id) | + +#### 阶段四:AI 对话与成员命名(NAS 端) + +| 子任务 | 执行主体 | 验收标准 | +|-------|---------|---------| +| 4.1 Chat-Handler | 🤖 | `POST /api/chat/ask` 接收问题;查 `event_details` 拼上下文;调 Oracle Ollama 纯文本返回自然语言回答;写 `chat_history`;明细 > 50 条时按小时聚合 | +| 4.2 成员命名流程 | 👤+🤖 | FAM-UI 展示未命名人物列表 + 特征描述;用户输入真名 → 批量回溯更新 `event_details`;命名后后续视频分析直接用 real_name | +| 4.3 对话准确率验证 | 👤+🤖 | 问"汤圆今天干嘛了",回答包含主要活动时段 + 关注事件提示;当天无数据时明确说"今天没有观察到汤圆" | + +#### 阶段五:FAM-UI + 联调 + 验收 + +| 子任务 | 执行主体 | 验收标准 | +|-------|---------|---------| +| 5.1 Streamlit 基础页面 | 🤖 | 事件列表分页加载、按日期筛选可用 | +| 5.2 成员命名页 | 🤖 | 展示未命名人物 + 命名输入框;命名后刷新已命名列表 | +| 5.3 AI 对话页 | 🤖 | 输入框 + 调 `/api/chat/ask` + 展示回答;按 `queried_person` 预设快捷提问按钮 | +| 5.4 compute_provider 可视化 | 🤖 | 图表与数据库统计一致(首期阶段全为 `local_only`) | +| 5.5 真实素材端到端联调 | 👤+🤖 | 至少 3 个真实切片跑通,VLM 输出 frame_details 落库正确,AI 对话能回答当日活动,结果经人工确认 | + +阶段四(原 v1.0 的 Gemini 增强)和阶段五的长稳测试(24-48h)推到 v1.1+。 + +--- + +文档结束 +