# 家庭多模态智能监控系统 — 软件设计说明书 **版本**: 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+。 --- 文档结束