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

1288 lines
59 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 家庭多模态智能监控系统 — 软件设计说明书
**版本**: 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<br>家庭局域网: 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<br>公网: 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: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_<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_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"]` | 首期典型场景 |
| 加 OpenAIv1.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/analyzeEdge 接收任务)
请求体:
```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 UnavailableOllama 不可用
### 6.2 POST /api/core/callback/eventNAS 接收回调)
成功请求体:
```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/\<path:filename\>Video-Server 静态服务)
- 带鉴权:`?token=xxx`token 从 NAS 端配置读取
- 无 token 或 token 错误返回 403
- 文件不存在返回 404
### 6.4 POST /api/chat/askNAS 接收用户问答)
请求体:
```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`
- **超时**240s5-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。
输入参数:
- 多模型视觉分析日志(每个模型独立输出,平等对待,交叉验证):
- <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 校验
三层容错策略,从最稳到最不稳依次尝试:
```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
- 全部模型不健康 → 返回 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 抽象基类
```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+FlaskGunicorn1 workermysql-connector-pythonPyYAML
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 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自建仅内网访问
**克隆方式**
```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 后**:单独 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 | 🤖 | 回调返回 200`monitor_events` 插 1 条聚合记录;`frame_details` 数组逐条插入 `event_details`(条数无上限);未命名 abstract_label 自动 upsert 到 `family_members` |
| 2.3 状态机流转 | 🤖 | 失败自动重试,超 3 次置为 FAILED`failure_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+。
---
文档结束