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