Files
sentinel-home-ai/项目需求文档.md

59 KiB
Raw Blame History

家庭多模态智能监控系统 — 软件设计说明书

版本: 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 替代)
  • 资源保护策略 BCPU/内存过载返回 503单并发下不必要
  • 心跳监控Heartbeat-Monitor单任务串行下用整体超时兜底
  • 成员特征历史快照members_snapshot_json 字段)
  • API 业务接口鉴权(/api/edge/*/api/core/* 不加鉴权;仅 Video-Server 的 /media/* 加 token因为视频文件是敏感数据
  • Cron 兜底清理finally 清理足够Cron 推后)

1.3 仍保留的"非可选"埋点

即使首期也要做的,否则后期回头补很痛:

  1. task_id 作为 trace_idNAS 与 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 的服务间通信一律走 Tailscale100.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:8000Video-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_<id>*/ 临时帧图片,任务结束清理

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 资源保护 策略 B503 首期不做 单并发下无意义
DB family_members 表(缺失) 首期新增 支持交互式命名VLM 输出 abstract_label用户命名后批量回溯更新
API payload known_members_context 字符串 改为注入已命名+未命名成员清单 NAS 端从 family_members 表读取后拼接

3. 模块设计

3.1 FAM-CoreNAS 端,单进程)

模块 职责 首期简化
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-EdgeOracle 端,单进程)

模块 职责 首期简化
API-Gateway 接收任务,并发控制 同时只允许 1 个任务在处理;新任务到达时若当前有任务处理中,返回 429
Video-Preprocessor 下载视频 + FFmpeg 粗抽帧 + OpenCV 关键帧筛选 + 压缩 下载超时 60s粗抽 30 张候选帧;筛 5-8 张关键帧;长边 ≤ 1024pxJPEG 质量 80
AI-Orchestrator 调用 Ollama 视觉分析 + Gemini 视觉分析 + Ollama 文本融合 Gemini 异步并行调用,超时 8s 不阻塞;熔断器保护;失败降级 local_only
Storage-Cleaner finally 删除下载的视频和帧图片 不做 Cron 兜底

3.3 FAM-UINAS 端)

模块 职责 首期简化
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_membersreal_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: hybridlocal_only),更新任务 SUCCESS
  6. UI 展示

4.2 与原文档的差异

  • 删除心跳发送步骤
  • 整体超时仍为 600s由 Edge 端内部定时器控制
  • 保留并行 Gemini 调用(首期纳入),熔断器保护,失败降级 local_only

5. 数据库设计

5.1 DDL与原文档一致

-- 任务表
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"] 首期典型场景
加 OpenAIv1.1 ["ollama","gemini","openai"] 扩展场景
全部失败 任务 FAILED 不写入 monitor_events

字段类型调整compute_provider 由原 ENUM 改为 JSON 数组,便于动态扩展。原 ENUM('hybrid','local_only') 已不适用多模型场景。

event_details.source_providersmonitor_events.compute_provider 的关系:

  • compute_provider 记录本次任务级别调用了哪些模型(数组去重)
  • source_providers 记录该条 frame_detail 被哪些模型识别到(可能少于 compute_provider例如某帧 Gemini 没识别到人物但 Ollama 识别了)

6. API 规范

6. API 规范

6.1 POST /api/edge/video/analyzeEdge 接收任务)

请求体:

{
  "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 UnavailableOllama 不可用

6.2 POST /api/core/callback/eventNAS 接收回调)

成功请求体:

{
  "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

失败请求体:

{
  "task_id": 1001,
  "status": "failed",
  "failure_stage": "vlm_visual",
  "error_message": "VLM inference timeout"
}

响应200 OK

6.3 GET /media/<path:filename>Video-Server 静态服务)

  • 带鉴权:?token=xxxtoken 从 NAS 端配置读取
  • 无 token 或 token 错误返回 403
  • 文件不存在返回 404

6.4 POST /api/chat/askNAS 接收用户问答)

请求体:

{
  "question": "汤圆今天干嘛了?",
  "queried_person": "汤圆",
  "queried_date": "2026-08-19"
}

响应200 OK

{
  "answer": "根据今日监控数据,汤圆主要活动如下:\n14:03 在客厅地毯上玩积木\n14:25 在客厅跌倒一次(关注事件)\n15:10 在客厅看绘本\n...",
  "context_summary": "查询 event_details 12 条,时间范围 14:03-18:30",
  "chat_id": 5001
}

Chat-Handler 处理逻辑

  1. 根据 queried_personqueried_date 查询 event_details
    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_historyuser_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

{
  "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命名人物

请求体:

{
  "abstract_label": "人物A",
  "real_name": "汤圆",
  "named_by": "管理员"
}

Member-Manager 处理逻辑

  1. 校验 abstract_label 存在于 family_membersreal_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. 批量回溯更新历史记录:
    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
    {
      "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-phi3Ollama
  • 输入5-8 张关键帧Base64 编码)+ 中文 Prompt
  • 调用方式:一次性输入所有关键帧(数量已降到 5-8上下文压力可控
  • 参数temperature=0.2, top_p=0.8
  • 超时240s5-8 张 × 单张 ≤ 8s ≈ 40-64s有充足余量
  • 输出VLM_OUTPUT自然语言描述按帧顺序逐段输出

Gemini 云端调用(详见 7.6

  • 模型gemini-1.5-flashGoogle AI Studio API
  • 输入:同批 5-8 张关键帧inline_data Base64+ 英文 Prompt
  • 调用方式ThreadPoolExecutor 异步并行,与本地 VLM 同时启动
  • 参数temperature=0.2, top_p=0.8
  • 超时8sfuture.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。

输入参数:
- 多模型视觉分析日志(每个模型独立输出,平等对待,交叉验证):
  - <PROVIDER_1> 输出: <OUTPUT_1>  # 例: ollama 输出
  - <PROVIDER_2> 输出: <OUTPUT_2>  # 例: gemini 输出
  - ...(数量可变,由实际调用的模型决定)
- 已知成员清单: <KNOWN_MEMBERS>

执行规则:
1. 多个模型的输出平等对待,交叉验证:
   - 多个模型一致描述的内容 → 可信度高,必须纳入 frame_detailssource_providers 列出所有一致的模型
   - 仅单一模型描述的内容 → 纳入 frame_detailssource_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 校验

三层容错策略,从最稳到最不稳依次尝试:

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
  • 全部模型不健康 → 返回 503NAS 端 Dispatcher 退避重试
  • 任务执行时,单个模型不健康跳过该模型,其他模型照常调用(动态降级)
  • Ollama 由 systemd 托管FAM-Edge 不管理进程

7.4 任务超时与重试

  • 整体超时 600sEdge 端内部定时器)
  • 失败后由 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 抽象基类

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 SDKGenerativeModel("gemini-1.5-flash").generate_content(prompt, [PIL.Image, ...])
  • 超时8s
  • 熔断器:启用,连续 5 次失败 → OPEN 15 分钟HALF_OPEN 允许一次探测

7.6.3 v1.1 扩展适配器(接口位预留)

  • OpenAIAdapterprovider_name="openai",调 openai.ChatCompletiongpt-4o 视觉接口
  • NvidiaAdapterprovider_name="nvidia",调 NVIDIA NIM API
  • 新增模型只需:继承 BaseModelAdapter + 在 config.yamlmodels 数组加一项 + 在适配器工厂注册

7.6.4 熔断器CircuitBreaker每个云端模型独立实例

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 调用流程(多模型并行)

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.yamlmodels 数组动态决定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+FlaskGunicorn1 workermysql-connector-pythonPyYAML
  2. 配置 MariaDB执行 DDLfamily_members 表)
  3. 配置 config/config.yamlOracle 地址、token、NAS 端口等;成员不再走 YAMLfamily_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 openaiopenai 为 v1.1 预留)
  4. 配置 config/config.yaml(含 models 数组,见 8.4
  5. 配置 Gemini API Keyconfig/config.yamlmodels 中填入 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 示例:

# 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 A1ARM无 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自建仅内网访问

克隆方式

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 后:单独 commitmessage 格式 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 🤖 回调返回 200monitor_events 插 1 条聚合记录;frame_details 数组逐条插入 event_details(条数无上限);未命名 abstract_label 自动 upsert 到 family_members
2.3 状态机流转 🤖 失败自动重试,超 3 次置为 FAILEDfailure_stage 字段正确记录失败阶段
2.4 任务下发客户端 🤖 收到 202 后状态切换为 PROCESSINGpayload 注入 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 张关键帧(首末帧必选);长边 ≤ 1024pxJPEG 质量 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+。


文档结束