diff --git a/README.md b/README.md index d4f7999..fbb0825 100644 --- a/README.md +++ b/README.md @@ -1,90 +1,421 @@ -# Sentinel Home AI - 家庭多模态智能监控系统 +# Sentinel Home AI — 家庭多模态智能监控系统 -多模型并行 + 交互式命名 + AI 对话的家庭监控系统。 +> 多模型并行 + 交互式命名 + AI 对话的家庭监控系统。 +> 本文档为项目需求文档与 README 的整合版,按当前代码实际状态(v1.0 E2E 已打通)编写。 +> 最后更新:2026-08-20 -## 架构概览 +--- -| 节点 | 角色 | 服务 | +## 1. 项目简介与设计目标 + +系统持续分析家庭监控摄像头(Synology Surveillance Station)录制的视频,抽取关键帧后调用视觉语言模型(VLM)分析画面中的人物、动作、衣着,落库为结构化事件;用户通过 Web UI 浏览事件、给"人物A/B/C"命名(批量回溯历史记录)、以及用自然语言向 AI 询问"汤圆今天干嘛了"。 + +### 1.1 首期范围(已基本完成) + +- **FAM-Core**(NAS 端单进程):Task-Scheduler / Dispatcher / Event-Receiver / Chat-Handler / Member-Manager / Video-Server 六个子模块 +- **FAM-Edge**(Oracle 端单进程):接收视频上传 → FFmpeg 快速抽帧 → OpenCV 关键帧筛选 → VLM 视觉分析 → VLM 文本融合 → 结果同步返回 全链路 +- **FAM-UI**(NAS 端):Streamlit 直读 DB,事件列表 + 成员命名页 + AI 对话页 + 对话历史 +- **数据库六张表**:process_tasks / monitor_events / event_details / chat_history / family_members / daily_summaries(预留) +- **任务状态机**:PENDING → PROCESSING → SUCCESS/FAILED,含退避重试与僵尸任务回收 +- **AI 对话**:查 event_details 拼上下文 → 经 FAM-Edge 代理调 Oracle Ollama 纯文本 → 返回回答并写 chat_history +- **交互式成员命名**:VLM 按特征提取"人物A/B/C"落库,用户命名后批量回溯更新历史,后续分析直接用真名 + +### 1.2 不在首期范围(推迟 v1.1+) + +- Nginx 静态服务(Video-Server 用 Flask `send_from_directory` 替代,且推送模式下已不再必需) +- 资源保护策略 B(CPU/内存过载返回 503) +- 心跳监控(用整体超时 + 僵尸任务回收兜底) +- API 业务接口鉴权(`/api/edge/*` 和 `/api/core/*` 不加鉴权) +- Cron 兜底清理(`finally` 清理足够) +- daily_summaries 每日摘要(表已建,逻辑未实现) + +### 1.3 已知遗留问题(v1.1 优先解决) + +| 问题 | 现状 | 影响 | |------|------|------| -| Synology NAS | FAM-Core + FAM-UI + 数据库 | Flask (8000), Streamlit (8501), MariaDB (3306) | -| Oracle Cloud | FAM-Edge | Flask (5000), Ollama (11434), FFmpeg | -| 家庭网络 | 用户入口 | 浏览器访问 FAM-UI | +| **llava-phi3 多图单请求失效** | `analyze_frames` 把全部关键帧放一次请求,输出长度仅 3~4(垃圾内容),融合阶段只能靠 known_members 上下文幻觉编造 | 事件内容质量差 | +| **吞吐不足** | 30s 视频全流程约 19 分钟(关键帧筛选 10min + VLM 5.5min + 融合 3min);30min/360MB 视频实测 929s | 生产视频每 30 分钟新增一个,处理速度勉强跟上但无余量 | +| **Tailscale 端口不通** | NAS tailscaled 以 userspace 模式运行(无 TUN 网卡),Oracle 无法反向访问 NAS;当前 NAS→Oracle 走公网 IP | 推送模式已规避反向访问,但流量走公网 | +| **历史视频积压** | 正式目录 `/volume1/surveillance/Generic_ONVIF-001` 有约 285 个历史视频(~100GB),288 个历史任务已标记 FAILED 避免全量上传 | 切回生产目录前需确认回补策略 | -## 目录结构 +**规划方向**:接入 Gemini(`gemini-flash-latest`,API Key 已验证可用,单帧 2~3s 且真正理解多图)作为主视觉分析模型,替换/补充 Ollama;同步把 `analyze_frames` 改为逐帧请求。 + +--- + +## 2. 系统架构 + +### 2.1 物理节点与部署 + +| 节点 | 角色 | 硬件 | IP | 服务与端口 | +|------|------|------|-----|-----------| +| Synology NAS | FAM-Core + FAM-UI + 数据库 | DS220+ (Geminilake), DSM 7 | 家庭局域网 192.168.50.64 / Tailscale 100.70.234.39 | FAM-Core :8000, FAM-UI :8501, MariaDB :3306, Surveillance Station | +| Oracle Cloud | FAM-Edge | Ampere A1 2C12G ARM64(无 GPU), Ubuntu 20.04 | 公网 129.146.203.203 / Tailscale 100.74.137.126 | FAM-Edge :5000, Ollama :11434(仅本地) | +| 家庭网络 | 用户入口 | 普通终端 | 192.168.50.0/24 | 浏览器访问 `http://192.168.50.64:8501` | + +**网络要点(推送模式)**: +- 服务间通信只有一条:**NAS → Oracle 公网 IP:5000**(HTTP 上传视频)。Edge 不需要反向访问 NAS(无 webhook、无视频拉取) +- Oracle 端 Ollama 端口 11434 不对外暴露,聊天请求经 FAM-Edge `/api/edge/chat` 代理转发 +- Tailscale 两节点已安装在线,但 NAS tailscaled 为 userspace 模式且防火墙端口不通,暂走公网 IP + +### 2.2 部署拓扑与数据流(推送模式) ``` -sentinel-home-ai/ -├── fam-core/ # NAS 端 - 任务调度/事件接收/对话/成员管理/视频服务 -├── fam-edge/ # Oracle 端 - 视频预处理/AI编排/模型适配器 -├── fam-ui/ # NAS 端 - Streamlit 前端 -├── scripts/ # 数据库初始化/部署脚本 -├── tests/ # 单元测试与集成测试 -└── docs/ # 设计文档 +┌─────────────────────────────── NAS (192.168.50.64) ──────────────────────────────┐ +│ │ +│ Surveillance Station ──► /volume1/surveillance/Generic_ONVIF-001/ │ +│ (YYYYMMDDAM / YYYYMMDDPM 两级目录,~30min/350MB) │ +│ │ │ +│ ▼ │ +│ FAM-Core (Flask :8000, gunicorn) │ +│ ├─ Task-Scheduler: 60s 轮询视频目录,稳定文件建 PENDING 任务 │ +│ ├─ Dispatcher: 30s 轮询,multipart 上传视频 ──────────┐ │ +│ │ (push_timeout=1800s,同步等待响应) │ │ +│ ├─ 僵尸回收: PROCESSING 超 push_timeout+120s 重置 PENDING │ +│ ├─ Event-Receiver: /api/core/callback/event(兼容保留) │ +│ ├─ Chat-Handler ── /api/edge/chat 代理 ──────────────┐ │ +│ ├─ Member-Manager / Video-Server(/media, token) │ │ +│ ▼ │ │ +│ MariaDB (sentinel_home_ai, 6 张表) │ │ +│ │ │ +│ FAM-UI (Streamlit :8501) 直读 DB │ │ +└─────────────────────────────────────────────────────────┼─────────────────────────┘ + │ HTTP (公网) + ▼ +┌──────────────────────── Oracle Cloud (129.146.203.203) ──────────────────────────┐ +│ FAM-Edge (Flask :5000, gunicorn --timeout 1800, 单 worker) │ +│ ├─ POST /api/edge/video/push(multipart 视频,同步分析,结果随响应返回) │ +│ ├─ AI-Orchestrator: 健康检查 → 抽帧 → 选帧 → 压缩 → VLM 视觉 → 文本融合 │ +│ ├─ POST /api/edge/chat(代理转发本地 Ollama) │ +│ ▼ │ +│ Ollama :11434 (llava-phi3, OLLAMA_KEEP_ALIVE=-1 常驻) + FFmpeg + OpenCV │ +└───────────────────────────────────────────────────────────────────────────────────┘ ``` -## 技术栈 +### 2.3 主链路时序(推送模式) -- **后端**: Python 3.10+, Flask, Gunicorn -- **数据库**: MariaDB (MySQL 兼容) -- **AI 模型**: Ollama (llava-phi3), Gemini 1.5 Flash -- **视频处理**: FFmpeg, OpenCV -- **前端**: Streamlit +1. Scheduler 扫描到新视频(修改时间 > 60s 且大小稳定)→ 写 `process_tasks`(PENDING) +2. Dispatcher 领取 PENDING 任务 → 状态置 PROCESSING → 读本地视频文件,multipart POST 到 Edge `/api/edge/video/push`,payload 含 task_id / camera_name / event_start_time(文件 mtime)/ known_members_context +3. Edge 同步执行: + - a. 保存上传视频到临时目录(超时 60s) + - b. FFmpeg 快速 seek(`-ss -frames:v 1`)粗抽候选帧,帧数随视频时长自适应 + - c. OpenCV MSE 帧差分析筛选关键帧 → 压缩(长边 ≤ 1024px,JPEG 质量 80) + - d. 健康的模型适配器并行视觉分析(Ollama 视觉超时 600s;Gemini 规划中) + - e. Ollama 纯文本融合(超时 300s)→ JSON 结构化结果 + - f. `event_end_time` = event_start_time + 视频时长(Edge 推算) + - g. `finally` 清理临时文件 +4. Edge 把结果 JSON 直接作为 HTTP 响应返回(无 webhook) +5. Dispatcher 收到响应 → 调用 `apply_success_event()` 写 `monitor_events`(1 条聚合)+ `event_details`(每关键帧 1 条)+ upsert 未命名成员 → 任务置 SUCCESS;失败则退避重试(`min(60×(retry+1)×2, 600)`s,超 3 次 FAILED) -## 快速开始 +**容错设计**: +- Dispatcher 僵尸回收:PROCESSING 状态超过 `push_timeout + 120s` 自动重置 PENDING(应对进程重启/Edge 重启导致 in-flight 请求丢失) +- fam-core 日志双写:stdout + `fam-core/logs/fam-core.log`(daemon 模式下 stdout 不可见) +- 所有日志带 `task_id` 作为 trace_id,各阶段耗时打 INFO -1. 数据库初始化: `python scripts/init_db.py` -2. 启动 FAM-Core: `cd fam-core && gunicorn -w 1 -b 0.0.0.0:8000 src.app:app` -3. 启动 FAM-Edge: `cd fam-edge && gunicorn -w 1 -b 0.0.0.0:5000 src.app:app` -4. 启动 FAM-UI: `cd fam-ui && streamlit run src/app.py` +--- -## 服务器访问信息 +## 3. 模块设计 -### Synology NAS (FAM-Core + FAM-UI + MariaDB) +### 3.1 FAM-Core(NAS 端) + +| 模块 | 文件 | 职责 | +|------|------|------| +| Task-Scheduler | `scheduler/scheduler.py` | 60s 轮询视频目录(`os.walk` 递归,支持 AM/PM 子目录),`video_path` 去重,稳定文件建 PENDING 任务 | +| Dispatcher | `dispatcher/dispatcher.py` | 30s 轮询 PENDING;multipart 上传视频至 Edge push 端点;收响应后经 `apply_success_event` 落库;僵尸 PROCESSING 回收;退避重试 | +| Event-Receiver | `event_receiver/event_receiver.py` | `/api/core/callback/event`(webhook 兼容保留);核心逻辑抽为 `apply_success_event(data)` 供 Dispatcher 推送模式复用;未命名 abstract_label 自动 upsert `family_members` | +| Chat-Handler | `chat_handler/chat_handler.py` | `/api/chat/ask` 查 event_details 拼上下文 → 经 Edge 代理调 Ollama → 写 chat_history;明细 > 50 条按小时聚合 | +| Member-Manager | `member_manager/member_manager.py` | `/api/member/unnamed` / `/api/member/name` / `/api/member/list`;命名后批量回溯 UPDATE event_details(MariaDB 不支持 `$[*]` JSON 路径,Python 层逐行更新) | +| Video-Server | `video_server/video_server.py` | `/media/?token=xxx` 静态视频服务(推送模式下主链路不再使用,保留备用) | +| 公共层 | `db_layer.py` / `config_loader.py` / `logger.py` | PyMySQL 连接(unix_socket);datetime 空串归一化 NULL + NOT NULL 列兜底;文件日志 | + +### 3.2 FAM-Edge(Oracle 端) + +| 模块 | 文件 | 职责 | +|------|------|------| +| API-Gateway | `api_gateway/api_gateway.py` | `POST /api/edge/video/push`(multipart 上传 + 同步分析 + 结果返回);`POST /api/edge/video/analyze`(旧拉取模式,兼容保留);`POST /api/edge/chat`(Ollama 代理);`GET /health`;单并发控制(处理中返回 429) | +| Video-Preprocessor | `video_preprocessor/preprocessor.py` | `save_upload` 保存上传视频;FFmpeg 快速 seek 粗抽候选帧(帧数自适应);OpenCV MSE 帧差筛选关键帧(首末帧必选);压缩;`compute_timestamps` 用 start+偏移算绝对时间戳;`video_duration` 供 event_end_time 推算 | +| AI-Orchestrator | `ai_orchestrator/orchestrator.py` | 模型健康检查 → 健康适配器并行视觉分析 → 文本融合 → JSON 解析三层容错 + schema 校验;`process_push_task` 为推送模式入口(不触发 webhook) | +| Model-Adapters | `model_adapters/` | `BaseModelAdapter` 抽象基类(health_check / analyze_frames / get_timeout / 熔断器);OllamaAdapter 已实现(requests 直调 REST,`num_predict` 可配);GeminiAdapter 配置位预留 | +| Storage-Cleaner | `storage_cleaner/` | `finally` 删除临时视频与帧图片 | + +### 3.3 FAM-UI(NAS 端) + +Streamlit 应用(`fam-ui/src/app.py`),侧边栏切换页面: + +| 页面 | 功能 | +|------|------| +| 📊 事件列表 | 按日期筛选 + 分页(20 条/页)展示 monitor_events | +| 👤 成员命名 | 列出未命名人物 + 特征描述,输入真名后调 `/api/member/name` 批量回溯 | +| 💬 AI 对话 | 输入框 + 调 `/api/chat/ask`;按 queried_person 预设快捷提问 | +| 📜 对话历史 | chat_history 倒序展示 | +| 📈 统计图表 | compute_provider 占比(bar_chart) | + +--- + +## 4. 数据库设计 + +库名 `sentinel_home_ai`,MariaDB 10.11.11,utf8mb4。完整 DDL 见 `scripts/ddl.sql`。 + +### 4.1 表清单 + +| 表 | 用途 | 关键字段 | +|----|------|---------| +| `process_tasks` | 视频处理任务 | task_id, video_path, video_url, status(PENDING/PROCESSING/SUCCESS/FAILED), retry_count, max_retries, next_retry_at, error_message, failure_stage(ENUM) | +| `monitor_events` | 事件聚合(每任务 1 条) | event_id, task_id, event_start_time, event_end_time, camera_name, global_summary, entities_json(JSON), compute_provider(JSON 数组) | +| `event_details` | 每关键帧一条明细 | detail_id, event_id, task_id, frame_index, frame_timestamp, person, action, clothing, is_attention_event, source_providers(JSON) | +| `family_members` | 交互式命名 | member_id, abstract_label(如"人物A"), real_name(NULL=未命名), feature_description, first_seen_at, named_at, named_by | +| `chat_history` | AI 问答记录 | chat_id, user_question, ai_answer, context_summary, queried_date, queried_person | +| `daily_summaries` | 每日摘要(预留) | target_date, summary_text | + +### 4.2 表关系与命名回溯 + +``` +process_tasks (1) ─── (N) monitor_events (1) ─── (N) event_details +family_members 独立表: + - event_details.person 存 abstract_label(未命名)或 real_name(命名后) + - 命名后: UPDATE event_details SET person = real_name WHERE person = abstract_label + - monitor_events.entities_json 由 Python 层解析逐行更新(MariaDB 不支持 $[*] 路径) +chat_history 独立表 +``` + +### 4.3 compute_provider / source_providers + +- `monitor_events.compute_provider`:JSON 数组,记录本次任务实际成功调用的模型,如 `["ollama"]` 或 `["ollama","gemini"]` +- `event_details.source_providers`:该条明细被哪些模型识别到(可能少于 compute_provider) +- 多模型交叉验证:多模型一致 → 可信度高;仅单一模型描述 → source_providers 仅含该模型;冲突 → 多数派为准 + +### 4.4 兼容性注意 + +- MariaDB 10.11 严格模式:**空字符串不能插 DATETIME 列**(1292 错误)。`db_layer._dt_or_none` 将空串归一化 NULL;`event_end_time` NOT NULL 列按 end→start→NOW 兜底;`frame_timestamp` 空值兜底 NOW +- MariaDB 不支持 MySQL 的 `$[*]` JSON 通配路径与 `->` 操作符,JSON 字段在 Python 层处理 + +--- + +## 5. API 规范(实际实现) + +### 5.1 FAM-Edge(Oracle :5000) + +**POST /api/edge/video/push**(主链路,推送模式) + +- 请求:`multipart/form-data`,字段 `video`(文件) / `task_id` / `camera_name` / `event_start_time` / `known_members_context` +- 处理:同步执行完整分析流水线(可能耗时数分钟,gunicorn timeout 1800) +- 响应(200): + +```json +{ + "task_id": 289, + "status": "success", + "event_start_time": "2026-08-20 01:06:44", + "event_end_time": "2026-08-20 01:07:13", + "camera_name": "客厅", + "global_summary": "...", + "entities_json": [{"person": "汤圆", "action": "...", "clothing": "..."}], + "frame_details": [ + {"frame_index": 1, "frame_timestamp": "...", "person": "...", "action": "...", + "clothing": "...", "is_attention_event": false, "source_providers": ["ollama"]} + ], + "compute_provider": ["ollama"] +} +``` + +- 失败:`{"task_id": ..., "status": "failed", "failure_stage": "vlm_visual", "error_message": "..."}` +- 429:已有任务处理中(单并发);503:全部模型不健康 + +**POST /api/edge/video/analyze** — 旧拉取模式(Edge 拉 video_url + webhook 回调),兼容保留,主链路不再使用 +**POST /api/edge/chat** — Ollama 聊天代理(FAM-Core Chat-Handler 调用) +**GET /health** — 服务与模型健康状态(任务处理中可能无响应,单 worker 忙) + +### 5.2 FAM-Core(NAS :8000) + +| 端点 | 方法 | 说明 | +|------|------|------| +| `/health` | GET | 服务健康 | +| `/api/status` | GET | scheduler/dispatcher 运行状态 | +| `/api/core/callback/event` | POST | Edge 回调(webhook 兼容保留);推送模式下由 Dispatcher 内部调用 `apply_success_event` | +| `/api/chat/ask` | POST | 用户问答:`{"question","queried_person","queried_date"}` → `{"answer","context_summary","chat_id"}` | +| `/api/chat/history` | GET | 对话历史(`?date=` 或 `?person=&limit=`) | +| `/api/member/unnamed` | GET | 未命名人物列表(含特征描述、出现次数) | +| `/api/member/name` | POST | 命名:`{"abstract_label","real_name","named_by"}` → 批量回溯 event_details/entities_json,返回更新条数 | +| `/api/member/list` | GET | 全部成员 | +| `/media/?token=xxx` | GET | 视频静态服务(token 鉴权,推送模式下备用) | + +### 5.3 融合输出 JSON Schema + +三层容错解析:直接 `json.loads` → 提取 markdown fence ` ```json ... ``` ` → 贪婪匹配最大 `{...}`;再过 `validate_schema`(必填字段检查 + 脏数据清洗,`action` 由 AI 自由生成无枚举过滤,`is_attention_event` 由 AI 自行判断,`source_providers` 必须非空数组)。三层全失败 → 任务 FAILED 走重试。 + +--- + +## 6. 关键技术 + +### 6.1 视频预处理(自适应关键帧) + +- **粗抽候选帧**:FFmpeg 快速 seek(逐帧 `ffmpeg -ss -frames:v 1`),替代 fps 滤镜全解码(30min 视频从 180s+ 降到 33s,6-8x 提速) +- **帧数自适应**:候选帧 `clamp(时长分钟×2, 30, 120)`;关键帧上限 `clamp(时长/150s, 8, 30)`(30min→12 帧,60min→24 帧,封顶 30) +- **帧差筛选**:OpenCV MSE,首末帧必选,MSE > 阈值 500 的保留,不足 min_key_frames=5 补足 +- **压缩**:长边 > 1024px 才缩放,JPEG 质量 80 +- **异常兜底**:ffprobe 失败退化为 60s 间隔抽帧;帧差异常退化为等距 5 帧 + +### 6.2 Ollama 调用(ARM CPU 实测调优) + +| 参数 | 值 | 依据 | +|------|-----|------| +| `OLLAMA_KEEP_ALIVE=-1` | 模型常驻内存 | 消除 55s 冷启动(常驻约 4.3GB,12GB 内存够用) | +| `num_predict=60` | 限制生成 token | ARM 约 5 tok/s,500 会单帧跑数分钟触发超时 | +| 视觉/模型 timeout | 600s | 实测 1024px 帧视觉编码 ~36s + 生成 ~12s/60token | +| 融合 timeout | 300s | — | +| gunicorn(Edge) | `--timeout 1800` | 同步分析模式,默认 30s 会杀 worker | +| push_timeout(NAS) | 1800s | 覆盖最坏情况(30min 视频实测 929s) | + +**已知问题**:llava-phi3 多图单请求基本失效(N 张图一次调用输出长度仅 3~4)。待改为逐帧请求,或由 Gemini 承担主视觉分析。 + +### 6.3 模型适配器架构 + +- 抽象基类 `BaseModelAdapter`:`health_check()` / `analyze_frames()` / `get_timeout()` / 熔断器实例 +- 模型清单由 `config.yaml` 的 `models` 数组动态决定,新增模型 = 实现适配器 + 配置加一项,主流程不动 +- 每个云端模型独立熔断器(连续 5 次失败 → OPEN 15 分钟 → HALF_OPEN 探测);本地 Ollama 不启用熔断 +- 健康探测:Ollama `GET /api/tags`;Gemini `GET /v1/models?key=...`;全部不健康返回 503 + +### 6.4 任务可靠性 + +- 状态机 PENDING → PROCESSING → SUCCESS/FAILED,退避重试 `min(60×(retry+1)×2, 600)`s,max_retries=3 +- **僵尸任务回收**:PROCESSING 超过 `push_timeout + 120s` 自动重置 PENDING(应对 fam-core 重启 / Edge 重启 / in-flight 请求丢失) +- `failure_stage` ENUM:download / extract / vlm_visual / vlm_fusion / callback + +--- + +## 7. 性能基准(实测) + +### 7.1 单图推理(llava-phi3, Oracle ARM 2C12G) + +| 场景 | 总耗时 | +|------|--------| +| 冷启动(首次加载 2.9GB 模型) | 109s(已由 keep-alive 消除) | +| 预热 + num_predict=30 | **4.3s** | +| 1024px 真实帧 + num_predict=60 | ~48.6s(视觉编码 36s 固定成本 + ~5 tok/s) | + +### 7.2 真实视频全流程(30min / 360MB / 1080p H.264) + +| 步骤 | 耗时 | +|------|------| +| 视频上传 NAS→Oracle(公网,5.1 MB/s) | 70s | +| FFmpeg 快速 seek 抽 60 候选帧 | 33s | +| OpenCV 关键帧筛选(→12 帧) | 5s | +| 压缩 | 0.6s | +| Ollama 视觉分析(12 帧) | 820s(avg 68s/帧,0.9 tok/s) | +| **总计** | **929s(15.5min)** | + +### 7.3 E2E 验证(2026-08-20) + +task_id=289(30s 测试片段)全链路打通:推送 5.7MB → Edge 分析 1129s → 同步返回 → `monitor_events` event_id=1 + `event_details` 2 条落库,`event_end_time` 推算正确(start+29s)。 + +--- + +## 8. 部署说明 + +### 8.1 部署位置与启动命令 + +| 组件 | 节点 | 路径 | 启动 | +|------|------|------|------| +| FAM-Core | NAS | `/volume1/web/sentinel-home-ai/fam-core/` | `./venv/bin/gunicorn --chdir <路径> -w 1 -b 0.0.0.0:8000 --timeout 120 --daemon --pid /tmp/fam-core-gunicorn.pid src.fam_core.app:app` | +| FAM-UI | NAS | `/volume1/web/sentinel-home-ai/fam-ui/` | `./venv/bin/streamlit run src/app.py`(headless, :8501) | +| FAM-Edge | Oracle | `/opt/fam-edge/` | `venv/bin/gunicorn -w 1 -b 0.0.0.0:5000 --timeout 1800 src.fam_edge.app:app`(日志 `/tmp/fam-edge.log`) | +| Ollama | Oracle | systemd 托管 | 环境变量 `OLLAMA_KEEP_ALIVE=-1` | + +### 8.2 依赖 + +- **FAM-Core/UI(NAS, Python 3.10 venv)**:Flask, Gunicorn, **PyMySQL**(45KB 纯 Python 替代 19MB mysql-connector), PyYAML, requests;FAM-UI 另需 Streamlit + pandas +- **FAM-Edge(Oracle, Python 3.8+ venv)**:Flask, Gunicorn, requests, PyYAML, opencv-python, numpy;Gemini 用 requests 直调 REST(不依赖 google-generativeai SDK) +- **系统级**:FFmpeg(两端)、Ollama + llava-phi3(Oracle)、MariaDB 10.11(NAS) + +### 8.3 配置文件要点 + +**fam-core/config/config.yaml**(NAS): + +```yaml +scheduler: + video_dir: "/volume1/web/sentinel-home-ai/e2e-test" # 当前指向 E2E 测试目录 + # 正式目录: /volume1/surveillance/Generic_ONVIF-001(YYYYMMDDAM/PM 两级子目录, + # os.walk 递归支持;切回前需处理 285 个历史视频积压,避免全量上传 ~100GB) +dispatcher: + edge_url: "http://129.146.203.203:5000/api/edge/video/push" + push_timeout: 1800 +chat_handler: + ollama_url: "http://129.146.203.203:5000/api/edge/chat" # 经 Edge 代理 +``` + +**fam-edge/config/config.yaml**(Oracle):关键节选见 6.2;`models` 数组含 ollama(enabled: true, num_predict: 60)与 gemini(enabled: false, 待接入)。 + +### 8.4 运维注意事项 + +- NAS 部署目录**不是 git 仓库**(文件拷贝部署),同步代码用 stdin 管道:`ssh ... "cat > 远端路径" < 本地文件` +- NAS scp 子系统被禁用,同样用 stdin 管道传文件 +- 远端 kill gunicorn 时 pkill/pgrep 会匹配 SSH 自身命令行导致断连,用 `pgrep -f 'gunicorn -w [1]'` 字符类技巧或 PID 文件 +- fam-core 启动模块路径是 `src.fam_core.app:app`(不是 `fam_core.app:app`) +- Edge 单 worker 处理任务期间 `/health` 可能不响应,属正常 + +--- + +## 9. 快速开始 + +```bash +# 1. 初始化数据库(NAS) +python scripts/init_db.py + +# 2. 启动 FAM-Core(NAS) +cd fam-core && gunicorn -w 1 -b 0.0.0.0:8000 --timeout 120 src.fam_core.app:app + +# 3. 启动 FAM-Edge(Oracle) +cd fam-edge && gunicorn -w 1 -b 0.0.0.0:5000 --timeout 1800 src.fam_edge.app:app + +# 4. 启动 FAM-UI(NAS) +cd fam-ui && streamlit run src/app.py + +# 或使用脚本 +./scripts/start_core.sh && ./scripts/start_edge.sh && ./scripts/start_ui.sh +``` + +--- + +## 10. 服务器访问信息 + +### 10.1 Synology NAS(FAM-Core + FAM-UI + MariaDB) | 项目 | 值 | |------|-----| | IP | 192.168.50.64 | -| SSH 端口 | 2222 | -| SSH 用户 | ericwyuan | -| SSH 密码 | iLoveJava5 | +| SSH 端口 | 2222(scp 禁用,用 stdin 管道传文件) | +| SSH 用户 / 密码 | ericwyuan / iLoveJava5 | | 系统 | Synology DS220+ (Geminilake), DSM 7 | -| Tailscale IP | 100.70.234.39 | +| Tailscale IP | 100.70.234.39(userspace 模式,端口不通待修) | | 登录命令 | `ssh -p 2222 ericwyuan@192.168.50.64` | -### MariaDB 数据库 (NAS) +### 10.2 MariaDB(NAS) | 项目 | 值 | |------|-----| | 版本 | MariaDB 10.11.11 | -| 端口 | 3306 | | Socket | /run/mysqld/mysqld10.sock | | Root 密码 | iLoveJava5! | | 数据库名 | sentinel_home_ai | -| 连接命令 | `/usr/local/mariadb10/bin/mysql -S /run/mysqld/mysqld10.sock -u root -p` | -| mysql 路径 | /usr/local/mariadb10/bin/mysql | +| 连接 | `/usr/local/mariadb10/bin/mysql -S /run/mysqld/mysqld10.sock -u root -p`(非交互 PATH 下用 fam-core venv 的 PyMySQL + unix_socket 查询) | -### Oracle Cloud (FAM-Edge + Ollama + FFmpeg) +### 10.3 Oracle Cloud(FAM-Edge + Ollama + FFmpeg) | 项目 | 值 | |------|-----| | 公网 IP | 129.146.203.203 | -| SSH 端口 | 22 | -| SSH 用户 | ubuntu | -| 认证方式 | RSA 私钥 | -| 私钥文件 | `~/.ssh/oracle_sentinel` | -| 系统 | aarch64 (ARM Ampere A1 2C12G), Ubuntu 20.04 LTS | -| Tailscale | 已安装,待认证 | +| SSH 用户 | ubuntu(密钥 `~/.ssh/oracle_sentinel`) | +| 系统 | aarch64 (Ampere A1 2C12G), Ubuntu 20.04 LTS | +| Tailscale | 100.74.137.126(已安装在线,与 NAS 端口不通) | | 登录命令 | `ssh -i ~/.ssh/oracle_sentinel ubuntu@129.146.203.203` | -### Gitea 代码仓库 +### 10.4 Gitea 代码仓库 | 项目 | 值 | |------|-----| | URL | http://192.168.50.64:3000/ericwyuan/sentinel-home-ai | -| 账号 | ericwyuan | -| 密码 | iLoveJava5 | +| 账号 / 密码 | ericwyuan / iLoveJava5 | -### Gemini API Key (Google AI Studio) +### 10.5 Gemini API(Google AI Studio) | 项目 | 值 | |------|-----| @@ -93,26 +424,64 @@ sentinel-home-ai/ | 端点 | https://generativelanguage.googleapis.com/v1beta/models/gemini-flash-latest:generateContent | | 验证状态 | 2026-08-20 测试可用 | -调用示例: - ```bash curl "https://generativelanguage.googleapis.com/v1beta/models/gemini-flash-latest:generateContent" \ -H 'Content-Type: application/json' \ -H 'X-goog-api-key: AQ.Ab8RN6I0l8hC7hLnNHRY6qOXdch5CTWczDNlS4c1XrneGHipUQ' \ -X POST \ - -d '{ - "contents": [ - { - "parts": [ - { - "text": "Explain how AI works in a few words" - } - ] - } - ] - }' + -d '{"contents":[{"parts":[{"text":"Explain how AI works in a few words"}]}]}' ``` -## 代码仓库 +--- -- Gitea: `http://192.168.50.64:3000/ericwyuan/sentinel-home-ai` +## 11. 提交规范(AI Agent 必读) + +**工作流程(强制)**: + +1. **开工前**:仓库根目录 `git pull --rebase` +2. **每完成一项验收子任务**:立即 `git add` + `git commit` + `git push`,一任务一 commit,不批量合并 +3. **遇到阻塞**:先 commit 可工作部分,message 加 `[WIP]` 前缀 +4. **修 Bug**:单独 commit,格式 `fix(模块): 问题简述` + +**commit message 格式**: + +- 任务:`[阶段X.Y子任务号] 子任务名称 - 完成内容简述` +- Bug:`fix(模块): 问题简述` +- 性能/文档:`perf(...)` / `docs(...)` + +**禁止**:不 commit 直接继续;一次 commit 多个子任务;`git push --force`;跳过 hooks(`--no-verify`)。 + +**自检清单**:任务对应哪一行验收标准?是否已 commit + push?message 是否合规?开工前是否 pull --rebase? + +--- + +## 12. 当前进度与 v1.1 计划 + +### 已完成(截至 2026-08-20) + +- 全部模块代码 + DDL + 部署脚本;NAS/Oracle 双端部署运行 +- 模型基准测试(llava-phi3 预热 4.3s PASS);Ollama 常驻内存 +- 架构改为推送模式(NAS 上传整段视频 → Edge 同步分析 → 结果随响应返回) +- 关键帧自适应帧数 + FFmpeg 快速 seek(6-8x 提速) +- FAM-Core API 全端点测试通过;端到端聊天链路(Core→Edge→Ollama)验证 +- FAM-UI 部署(Streamlit 1.61.1) +- 真实视频性能基准(30min/360MB → 929s) +- **E2E 全链路打通**:task 289 → SUCCESS,monitor_events/event_details 落库正确 +- 可靠性加固:僵尸任务回收、文件日志、datetime 空值兜底、超时按实测调整 + +详细进度见 `PROGRESS.md`。 + +### v1.1 待办 + +| # | 任务 | 优先级 | +|---|------|--------| +| 1 | 接入 Gemini(gemini-flash-latest)作为视觉分析模型(API Key 已验证) | 高 | +| 2 | analyze_frames 改逐帧请求(修复 llava-phi3 多图失效) | 高 | +| 3 | 切回生产视频目录(/volume1/surveillance/Generic_ONVIF-001),确认历史视频回补策略 | 高 | +| 4 | 单元测试(JSON parser / circuit breaker / schema 校验) | 中 | +| 5 | Tailscale 修复(NAS userspace 模式升级,流量不走公网) | 低 | +| 6 | daily_summaries 每日摘要 | 低 | + +--- + +文档结束 diff --git a/项目需求文档.md b/项目需求文档.md deleted file mode 100644 index 44523a9..0000000 --- a/项目需求文档.md +++ /dev/null @@ -1,1287 +0,0 @@ - -# 家庭多模态智能监控系统 — 软件设计说明书 - -**版本**: 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+。 - ---- - -文档结束 -