# Sentinel Home AI — 家庭多模态智能监控系统 > 多模型容灾降级 + 交互式命名 + AI 对话的家庭监控系统。 > 本文档为项目需求文档与 README 的整合版,按当前代码实际状态(v1.0 E2E 已打通)编写。 > 最后更新:2026-08-25(Oracle 迁至新服务器 129.146.26.249;前端由 NAS 迁至云服务器 Caddy :80) --- ## 1. 项目简介与设计目标 系统持续分析家庭监控摄像头(Synology Surveillance Station)录制的视频,抽取关键帧后调用视觉语言模型(VLM)分析画面中的人物、动作、衣着,落库为结构化事件;用户通过 Web UI 浏览事件、给"人物A/B/C"命名(批量回溯历史记录)、以及用自然语言向 AI 询问"汤圆今天干嘛了"。 ### 1.1 首期范围(已基本完成) > **新架构 v3(2026-08-22 重构:运动事件驱动)**:NAS 不再处理视频,仅作管理后台 + 运动事件推送;视频分析全部上云(Oracle),且**不再分析整段视频**——整段素材按 SS 运动事件**分割成运动片段**后只分析片段。 - **FAM-Core**(NAS 端单进程):**Oracle-Sync**(每 30 分钟拉增量镜像)+ **MotionNotifier**(轮询 SS 运动事件推送 Oracle)+ **Chat-Handler** + **Member-Manager**,CPU 占用极低 - **FAM-Edge**(Oracle 端单进程):rclone 实时同步 Google 硬盘视频(**整段素材**)→ 按 `ss_motion_events` 运动事件(start_time/duration)**ffmpeg 分割运动片段** → **只把运动片段送云端 VLM**(Gemini 用 Files API / NVIDIA 整视频 `video_url`)→ 结构化 JSON 落本地 SQLite → 对外提供 `/api/oracle/sync` 增量拉取接口(**本地模型不参与视频分析**) - **FAM-UI**(云服务器端,由 Caddy :80 托管):Vue3 SPA 读本地同步镜像(sync_videos / sync_events / sync_people),事件时间轴 + 人物管理 + AI 对话 + 对话历史 + 统计;浏览器经云服务器访问,/api 经 frp 隧道反代回 NAS FAM-Core - **数据库**:Oracle 侧 SQLite(videos/events/people/ss_motion_events/sync_cursor);NAS 侧 MariaDB 镜像(sync_videos / sync_events / sync_people / sync_cursor)+ chat_history - **数据流向**:Google 硬盘 ──rclone──► 甲骨文整段素材 ──按运动事件分割片段──► 片段云端分析 ──► Oracle SQLite ──每 30 分钟 NAS 拉取──► NAS MariaDB 镜像 ──► FAM-UI;运动事件由 NAS 轮询 SS 推送 Oracle(NAS → Oracle 单向) - **AI 对话**:查 sync_events 拼上下文 → FAM-Edge 转发到独立 **ai-gateway** 服务(OpenAI 兼容协议,NVIDIA → Gemini → 本地 Ollama 降级链在 ai-gateway 内部完成,FAM-Edge 不再自己维护模型链)→ 返回并写 chat_history - **人物命名**:用户命名/合并某 label → 回推 Oracle `/api/oracle/people/correct`(manual 优先)→ 下一周期同步回 NAS;Oracle 独立 person_service 汇总全量人物 → LLM 合并为规范名 → 回灌视频提示 ### 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 优先解决) | 问题 | 现状 | 影响 | |------|------|------| | **llava-phi3 多图单请求失效(已替换)** | 原本地模型 llava-phi3 `analyze_frames` 多图单请求输出长度仅 3~4;**已替换为 qwen2.5:7b**(纯文本模型,专职问答兜底) | 历史遗留描述,新方案下视觉走云端,本地不参与视觉分析,该问题随之消除 | | **吞吐不足** | 历史本地模型全流程约 19 分钟(关键帧筛选 10min + VLM 5.5min + 融合 3min);30min/360MB 视频实测 929s | 已切换到云端 VLM 直出结构化 JSON(无本地融合步骤),吞吐瓶颈转移至云端调用延迟,整体明显改善 | | **Tailscale 端口不通** | NAS tailscaled 以 userspace 模式运行(无 TUN 网卡),Oracle 无法反向访问 NAS;当前 NAS→Oracle 走公网 IP | 推送模式已规避反向访问,但流量走公网 | | **历史视频积压(已处理)** | 正式目录 `/volume1/surveillance/Generic_ONVIF-001` 原有约 285 个历史视频(~100GB),已切回生产目录并 forward-only 处理:历史任务占位 FAILED,scheduler dedup 自动跳过,仅处理新增视频 | 已解决;生产目录已生效,新视频正常入链 | **规划方向(新框架)**:**云端大模型负责视觉识别与结构化输出、本地大模型仅做智能问答兜底**。按任务类型分工: | 任务 | 执行方 | 模型 | 说明 | |------|--------|------|------| | **视觉分析 + 结构化输出**(看图识人/动作/衣着 → 直出 JSON) | 云端 | Gemini → NVIDIA NIM(fallback 降级) | 云端 VLM 直接产出 `global_summary` / `entities_json` / `frame_details`,Edge 仅做**格式化校验**后直存 NAS,**本地模型不介入** | | **结果格式化**(云端 JSON → 入库 schema) | Edge 进程 | 无模型调用 | `format_cloud_result`:字段归一化、补 `source_providers`/`compute_provider`、推导 `entities`、缺失 `global_summary` 时事实拼接;纯数据转换,非 LLM 二次汇总 | | **AI 对话**("汤圆今天干嘛了") | 独立 **ai-gateway** 服务(OpenAI 兼容协议) | NVIDIA → Gemini → 本地 Ollama | FAM-Edge 不参与问答模型调用,只转发;降级顺序、key 轮换、熔断全部由 ai-gateway 自己管理 | - **Google Gemini**(`gemini-flash-latest`,API Key 已验证,支持多图单请求;**问答场景改用 ai-gateway 里独立的非 flash 文字模型链**,不复用视觉分析这个 flash 实例) - **NVIDIA NIM**(视觉分析用 `nvidia/nemotron-3-nano-omni-30b-a3b-reasoning` 整视频输入;**问答场景改用 ai-gateway 里独立的文字模型链**,不复用视觉分析实例) - **本地 Ollama**(qwen2.5:7b)**已从 FAM-Edge 移除,2026-08-23 起归属独立的 ai-gateway 服务**:仅参与智能问答,且仅在 ai-gateway 内部 NVIDIA/Gemini 都失败时作兜底;视觉分析链路完全不涉及本地模型 Orchestrator 视觉阶段按 `fallback` 模式顺序降级:Gemini → NVIDIA NIM;云端模型直出结构化 JSON 后由 `format_cloud_result` 格式化,均在 FAM-Edge 内完成。问答阶段完全在 **ai-gateway**(独立服务,见 §3.4)内部按 `nvidia → gemini → ollama` 顺序降级,FAM-Edge 只是转发客户端。 --- ## 2. 系统架构 ### 2.1 物理节点与部署 | 节点 | 角色 | 硬件 | IP | 服务与端口 | |------|------|------|-----|-----------| | Synology NAS | FAM-Core + 数据库(采集 + 聚合 + 镜像层) | DS220+ (Geminilake), DSM 7 | 家庭局域网 192.168.50.64 | FAM-Core :8000(API 聚合 + 轮询 SS + OracleSync), MariaDB :3306, Surveillance Station :5000;FAM-UI 已迁至云服务器(见下) | | Oracle Cloud(云服务器) | FAM-Edge + AI-Gateway + Caddy(前端) | Ampere A1 4C23G ARM64(无 GPU), Ubuntu 20.04 | 公网 129.146.26.249 / Tailscale(已安装未启用,备用) | FAM-Edge :5000(systemd 守护,视频分析 + 转发问答), **AI-Gateway :5100(systemd 守护,对外监听,问答模型降级链)**, Caddy :80(托管 FAM-UI SPA + 反代 /api/* → NAS FAM-Core), Ollama :11434(仅本地,被 AI-Gateway 调用) | | 家庭网络 | 用户入口 | 普通终端 | 公网 / 家庭网络 | 浏览器访问 `http://129.146.26.249/`(Caddy 托管前端,/api 经 frp 隧道回源 NAS FAM-Core :8000) | **网络要点(推送模式)**: - 服务间通信只有两条出站:**NAS → Oracle 公网 IP:5000**(① `GET /api/oracle/sync` 拉增量 + `POST /api/oracle/people/correct` 命名回推 ② `POST /api/ss/motion` 推送运动侦测事件)。Edge 不需要反向访问 NAS - **运动事件获取(NAS 轮询驱动)**:FAM-Core MotionNotifier 每 60s 轮询本机 Surveillance Station 事件列表 API(`SYNO.SurveillanceStation.EventCenter.Event`),增量推送 Oracle。**2026-08-25 起 `/api/ss/webhook` 接收端点已彻底移除**(`motion_bp.py` 不再注册该路由),轮询是唯一路径,不再保留 Webhook 可选补充。 - Oracle 端 Ollama 端口 11434 不对外暴露;聊天请求 NAS → FAM-Edge `/api/edge/chat/ask`(保持不变的对外契约)→ **FAM-Edge 转发到同机的 AI-Gateway :5100**(`/v1/chat/completions`,OpenAI 兼容协议,Bearer token 鉴权)→ NVIDIA/Gemini/Ollama 降级链。AI-Gateway 自己对外监听 0.0.0.0:5100(Bearer token 鉴权 fail-closed),供其他项目直接接入,不止服务本系统 - Tailscale 两节点已安装在线,但 NAS tailscaled 为 userspace 模式且防火墙端口不通,暂走公网 IP ### 2.2 部署拓扑与数据流(新架构 v2:Oracle 分析 + NAS 镜像) ``` ┌──────────── Google 硬盘 ────────────┐ │ oraclenas@...gserviceaccount.com │ │ (Cloud Sync 落盘目录) │ └──────────────┬───────────────────────┘ │ rclone 定时同步(systemd timer) ▼ ┌──────────────────────── Oracle Cloud / 云服务器 (129.146.26.249) ────────────────────────┐ │ FAM-Edge (Flask :5000) │ │ ├─ Video-Queue: 30s 轮询 /opt/fam-edge/gdrive_videos 登记整段素材 │ │ ├─ Video-Processor: 素材按 ss_motion_events 分割运动片段 → 只分析片段 │ │ │ (ffmpeg -c:v copy -c:a aac) → Gemini → 失败 NVIDIA → 再失败 FAILED │ │ ├─ Person-Service: 汇总人物 → LLM 合并规范名 → 回灌视频提示 │ │ ├─ QA-Proxy (qa.py): /api/edge/chat/ask(/stream) 原样转发到 AI-Gateway │ │ ├─ OracleDB (SQLite): videos / events / people / ss_motion_events │ │ └─ API: /api/oracle/sync (增量拉取) · /api/ss/motion (运动事件) · │ │ /api/oracle/people/correct (命名校正) · /api/edge/chat/ask (问答代理) │ │ │ HTTP (本机回环 + 公网均可达) │ │ ▼ │ │ AI-Gateway (Flask :5100,独立项目/服务/git 仓库,OpenAI 兼容协议) │ │ ├─ /v1/chat/completions:NVIDIA → Gemini(多 Key 轮换)→ 本地 Ollama 降级链 │ │ ├─ Bearer token 鉴权(fail-closed),对外 0.0.0.0:5100,非本系统专属 │ │ └─ 独立 .env(/opt/ai-gateway/.env,NVIDIA/Gemini key 与 FAM-Edge 各自一份) │ │ Caddy :80:托管 FAM-UI SPA + 反代 /api/* → NAS FAM-Core(frp 隧道 :8000) │ └───────────────────────────────┬───────────────────────────────────────────────┘ │ HTTP GET /api/oracle/sync?since=&token= (每 30 分钟) ▼ ┌─────────────────────────────── NAS (192.168.50.64) ────────────────────────────┐ │ FAM-Core (Flask :8000) │ │ ├─ Oracle-Sync: 唯一后台线程,拉增量写 MariaDB 镜像 + 维护 sync_cursor │ │ ├─ Chat-Handler: /api/chat/ask(查 sync_events 拼上下文 → 走 Oracle 编排) │ │ ├─ Member-Manager: /api/member/name|merge(回推 Oracle + 即时拉回) │ │ ▼ │ │ MariaDB (sentinel_home_ai): sync_videos / sync_events / sync_people / │ │ sync_cursor / chat_history │ │ (FAM-UI 已迁至云服务器 Caddy :80,见上) │ └─────────────────────────────────────────────────────────────────────────────────┘ ``` #### 2.2.1 运动监测链路(v3,NAS 轮询驱动) 摄像头动作事件由 **NAS 端 MotionNotifier 轮询 Surveillance Station 事件列表**获取(简单稳定,事件不遗漏;2026-08-25 起 Webhook 接收端点已彻底移除,轮询是唯一路径): Surveillance Station (NAS 本机) │ SYNO.SurveillanceStation.EventCenter.Event method=List │ camera_ids=2, event_types=10, start_time/end_time(下划线风格) ▼ NAS FAM-Core MotionNotifier(motion_notifier.py,每 60s) │ ├─ 增量游标(MariaDB sync_cursor.motion_last_event_id,DB 续用/重启补推) │ ├─ 推送失败批次不前进游标(下轮重试,不丢事件) │ └─ 定期空 events 心跳(证明推送链路存活) │ HTTP POST /api/ss/motion?token=ORACLE_SYNC_TOKEN(真实 event_id/start_time/duration) ▼ Oracle FAM-Edge :5000 /api/ss/motion (api_gateway.py) │ → OracleDB.ss_motion_events(event_id UNIQUE,自动去重;start_time/duration 为 Unix epoch) ▼ Oracle video_processor:整段素材按运动事件【分割运动片段】→ 只分析片段(见 2.3) > 设计要点:甲骨文**不反向访问** NAS;只分割**已结束**事件(SS 事件 duration 在动作进行中为 0、结束才回填真实时长)。 > 失败-open:心跳超过 `max_heartbeat_age_sec`(900s)未更新时预过滤/分割 fail-open,不误判"无运动"。 **网络要点(新架构)**: - NAS → Oracle 出站共两条:① `GET /api/oracle/sync`(拉取增量)+ `POST /api/oracle/people/correct`(命名回推);② `POST /api/ss/motion`(运动侦测事件推送)。均走 Oracle 公网 IP:5000,token 鉴权 - Oracle Ollama :11434 不对外暴露,问答经 FAM-Edge `/api/edge/chat/ask` → 同机 AI-Gateway `:5100` 两跳代理 - AI-Gateway `:5100` 本身对公网直接开放(Bearer token 鉴权),跟 FAM-Edge `:5000` 是两个独立监听端口,非本系统的其他项目可以跳过 FAM-Edge 直接接入 - Tailscale 两节点在线但 NAS 无法反向访问 Oracle,故全部走 NAS 主动出站拉取模式 ### 2.3 主链路时序(新架构 v3:运动事件驱动) 1. Google 硬盘新视频(整段素材)→ rclone 定时同步到 Oracle `/opt/fam-edge/gdrive_videos` 2. Video-Queue 轮询发现新文件 → 登记到 Oracle `videos`(pending) 3. Video-Processor 处理素材:按文件名解析开始时间 → 查窗口内 `ss_motion_events` **已结束**运动事件 → ffmpeg 分割运动片段(`-c:v copy -c:a aac` 保留音频)→ 片段登记 `videos`(pending,`motion_event_id` 关联)并入队;素材标记"已分割 N 段"(仍有未结束事件则保持 pending 下轮再分割) 4. Video-Processor 处理运动片段:**只分析片段**(不分析整段)→ Gemini(或 NVIDIA 兜底)直出 `{global_summary, events[], people_mentioned[]}` → 写 Oracle `videos` + `events` + `people` 5. Person-Service 每 30 分钟汇总全量人物 → LLM 合并为规范名 → 更新 `people.canonical_name` → 生成 `known_members_context` 回灌后续片段提示 6. NAS Oracle-Sync 每 30 分钟 `GET /api/oracle/sync?since=` → upsert 到本地 `sync_*` 镜像表 → 推进 `sync_cursor` 7. FAM-UI 读本地镜像展示(时间轴/人物/统计字段契约不变);用户命名 → `POST /api/oracle/people/correct` 回推 Oracle,下一周期同步生效 **运动监测支流(NAS 轮询驱动)**: - MotionNotifier 每 60s 轮询 SS `EventCenter.Event.List`(camera_ids=2, event_types=10)→ 增量(游标)推送 `POST /api/ss/motion` 落库 `ss_motion_events`(真实 event_id/start_time/duration) - 整段素材处理时按 `ss_motion_events` 分割运动片段(只分割已结束事件;duration=0 的进行中事件等结束后的下轮);片段分析结果的时间点(`events.ts`)为绝对时间,前端时间轴与帧图(`/api/proxy/frame`,绝对 ts − event_start_time 偏移取帧)天然兼容 **容错设计**: - Oracle 单视频串行(`max_concurrent=1`)避免多视频抢占云端配额 - 运动片段分析失败(两云端均不可用)标记 `failed`,下一周期 cursor 仍包含它会被重试 - 素材分割幂等:按 `motion_event_id` 去重,片段文件已存在则跳过分割 - NAS 同步失败仅记日志,下一周期(30 分钟)自动重试,不阻塞 UI - fam-core 日志双写:stdout + `fam-core/logs/fam-core.log` --- ## 3. 模块设计 ### 3.1 FAM-Core(NAS 端) > NAS 不再处理视频,仅作管理后台。常驻后台线程:Oracle-Sync(增量镜像)+ MotionNotifier(轮询 SS 运动事件)。 | 模块 | 文件 | 职责 | |------|------|------| | Oracle-Sync | `oracle_sync/oracle_sync.py` | 后台线程:每 30 分钟 `GET /api/oracle/sync?since=&token=` 拉增量 → upsert 到 `sync_videos`/`sync_events`/`sync_people` → 推进 `sync_cursor`;`push_name_correct()` 回推命名校正;`trigger_now()` 立即同步 | | Chat-Handler | `chat_handler/chat_handler.py` | `/api/chat/ask` 查 `sync_events` 拼上下文 → 经 Oracle `/api/edge/chat/ask` 问答(FAM-Edge 转发到独立 AI-Gateway 服务,接口契约不变)→ 写 chat_history | | Member-Manager | `member_manager/member_manager.py` | `/api/member/unnamed` / `/api/member/list` / `/api/member/name` / `/api/member/merge`;命名/合并回推 Oracle 并即时拉回本地镜像 | | MotionNotifier | `motion_notifier/motion_notifier.py` | **轮询主路径**(`poll_enabled=true`):每 `poll_interval_sec`(60s) 查 SS `EventCenter.Event.List`(camera_ids/event_types=10)→ 增量推送 Oracle `/api/ss/motion`;游标存 MariaDB(重启续用/补推停机期间事件,失败批次不前进);心跳线程定期空 POST 证明链路存活;启动时拉取 SS 摄像头「名→id」映射并配置兜底 | | Motion-BP | `motion_bp.py` | **2026-08-25 起不再提供 `/api/ss/webhook` 接收端点**(轮询是唯一路径);`GET /api/ss/status` 查询 MotionNotifier 状态 | | UI-API | `ui_api.py` | `/api/ui/*` 只读接口(videos/stats/people/people-clips/attention-events/named-members/model-stats/service-status),供 Vue 前端渲染 | | 公共层 | `db_layer.py` / `config_loader.py` / `logger.py` | PyMySQL 连接(unix_socket);同步镜像 CRUD;`get_sync_people_clips()` 人物→运动片段查询;文件日志 | > 已删除:Task-Scheduler / Dispatcher / Poller / Event-Receiver / Video-Server(视频上传、切片、抽帧、关键帧落盘等职责全部迁移至 Oracle 端,NAS CPU 占用大幅降低)。 ### 3.2 FAM-Edge(Oracle 端) > 整段素材按运动事件分割运动片段,**只分析运动片段**(不再整段送云端)。 | 模块 | 文件 | 职责 | |------|------|------| | API-Gateway | `api_gateway/api_gateway.py` | `GET /api/oracle/sync`(增量拉取,since+token 校验);`POST /api/oracle/people/correct`(命名校正);`POST /api/edge/chat/ask(/stream)`(问答,转发到 AI-Gateway,见 §3.4);`POST /api/ss/motion`(运动事件接收,token 校验,落库 `ss_motion_events` + 刷新心跳);`GET /api/oracle/frame|avatar`(帧/头像);`GET /health` | | Video-Queue | `video_queue.py` | 生产-消费队列:生产者 30s 轮询 rclone 同步落地目录登记整段素材入队(含重启恢复);消费者(`max_concurrent` 个线程)取队列调 Video-Processor;素材分割出的运动片段自动入队;模型超时 = 原配置 ×`timeout_multiplier`;失败重试上限 `max_retries` | | Video-Processor | `video_processor.py` | **素材→分割/片段→分析**双分支:素材按 `ss_motion_events` 已结束运动事件 ffmpeg 分割运动片段(`-c:v copy -c:a aac` 保留音频,`motion_event_id` 幂等),片段只送云端 VLM 分析;按 `vision_order` 调适配器;首个成功即落库 Oracle `videos`+`events`+`people`;全失败标 `failed` | | Person-Service | `person_service.py` | 汇总全量人物 → LLM 合并为规范名 → `set_canonical`;生成 `known_members_context` 回灌片段提示;manual 命名优先不被覆盖 | | Disk-Guard | `disk_guard.py` | **2026-08-28 新增**:5 分钟检查一次磁盘剩余空间,低于 `min_free_gb`(默认 10GB)就清理最旧的、已完成分割阶段的整段素材,删到 `target_free_gb`(默认 15GB)水位为止;只碰原始素材(`gdrive_videos`),绝不碰运动片段(`motion_clips`,事件时间轴/人物头像依赖它)或还在处理中的素材 | | OracleDB | `oracle_db.py` | SQLite:videos(含 `motion_event_id`/`camera_id` 列)/ events / people / sync_cursor / ss_motion_events;`get_sync_delta(since)` 增量导出;`record_motion_events` / `get_motion_events_in_range`(已结束运动事件窗口查询,分割用)/ `has_motion_in_range_local` / 心跳 | | Model-Adapters | `model_adapters/` | `BaseModelAdapter.analyze_video(video_path, known_members_context, event_start_time)`;Gemini(Files API)/ NVIDIA(整视频 `video_url`)——**只有视觉分析用,2026-08-23 起不再含 Ollama/文字模型**,问答模型完全移交 AI-Gateway | | QA-Proxy | `qa.py` | **2026-08-23 重写为 HTTP 转发客户端**(原来自己遍历适配器 `chat()` 做 NVIDIA→Gemini→Ollama 三级降级的逻辑已整个搬到 AI-Gateway):调 AI-Gateway `/v1/chat/completions`,把 OpenAI 兼容响应翻译回原有 `run_qa`/`run_qa_stream` 契约,`api_gateway.py` 和 FAM-Core 调用方零改动 | ### 3.3 FAM-UI(NAS 端) Vue3 + Vite + Tailwind SPA(`fam-ui/src/views/*.vue`,构建产物 `fam-ui/dist/` 由 FAM-Core `static_app.py` 托管,Vue Router history 模式),页面均读本地同步镜像: | 页面 | 功能 | |------|------| | 🕒 事件时间轴 | 运动片段会话列表(`motion_*.mp4`,只显示有内容的会话)+ 选中会话的事件时间线(时间点 + 描述 + 人物/关注徽章 + 事件帧图);支持 `?video=` 定位跳转;详情卡片支持**删除会话**(原生 `confirm()` 二次确认,2026-08-24 新增) | | 👤 人物管理 | 按规范名/标签聚合,命名/合并(回推 Oracle);**人物卡含「运动片段」区块**(该人物出现过的运动片段:缩略图/时间/摘要/事件数,点击跳时间轴) | | 💬 AI 对话 | 输入框 + 调 `/api/chat/ask`;按 queried_person 预设快捷提问 | | 📝 对话历史 | chat_history 倒序展示 | | 📈 统计图表 | 模型来源占比 / 关注事件 / 同步状态 | ### 3.4 AI-Gateway(Oracle 端,独立项目/服务,2026-08-23 新增) > 独立的 git 仓库/部署单元(`ai-gateway/`,Gitea 见 §10.4),跟 FAM-Edge/FAM-Core 不是同一个代码库。原本嵌在 FAM-Edge 里的问答模型降级链(跟视频分析业务无关,是通用能力)整个抽出来,做成 OpenAI 兼容协议的独立服务——除了 FAM-Edge 自己(改为转发调用),任何支持自定义 `base_url` 的 OpenAI SDK/工具都能直接接入,不限于本系统。 | 模块 | 文件 | 职责 | |------|------|------| | App | `app.py` | `POST /v1/chat/completions`(核心端点,OpenAI 兼容请求/响应结构,支持 `stream: true` 流式与非流式);`GET /v1/models`(占位实现);`GET /health`(免鉴权) | | Auth | `auth.py` | Bearer token 鉴权(`Authorization: Bearer `),**fail-closed**:未配置 token 时全部需鉴权接口直接拒绝(503),不会退回任何默认值 | | Orchestrator | `orchestrator.py` | `ChatOrchestrator`:按 `config.yaml` 里 `models` 数组顺序依次尝试适配器 `chat()`/`chat_stream()`,一个 provider 完全没有输出才换下一个;已开始吐字后中途失败直接结束,不悄悄换源接着写 | | Adapters | `adapters/` | `NvidiaAdapter`(多模型链)/ `GeminiAdapter`(多 Key 随机轮换,`_messages_to_gemini()` 转换为原生 `contents`/`systemInstruction`)/ `OllamaAdapter`(`/api/chat` 原生多轮,含 `warm_up()` 启动预热)——**纯文本,均为 chat-only,不含视觉分析** | | Config | `config_loader.py` | 部署时用 `FAM_ENV_FILE` 指向共享的 `.env` 复用密钥(当前实际部署未启用这个复用,AI-Gateway 用自己独立的 `/opt/ai-gateway/.env`,见 §8.3 说明) | **降级链**(`config.yaml` 里 `models` 数组顺序):NVIDIA(多模型链自动降级)→ Gemini(多 Key 随机轮换)→ 本地 Ollama(兜底,`OLLAMA_KEEP_ALIVE=-1` + 启动预热避免冷启动)。 --- ## 4. 数据库设计 库名 `sentinel_home_ai`,MariaDB 10.11.11,utf8mb4。完整 DDL 见 `scripts/ddl.sql`。 ### 4.1 表清单 > 新架构 v2:Oracle 侧用 SQLite(`videos`/`events`/`people`/`sync_cursor`),NAS 侧 MariaDB 仅保留 **同步镜像表 + 问答历史**。`process_tasks`/`monitor_events`/`event_details`/`family_members` 等旧表已不再写入(保留历史数据,未删除)。 **Oracle(SQLite,`oracle_db.py`)** | 表 | 用途 | 关键字段 | |----|------|---------| | `videos` | 视频会话(整段素材 + 运动片段均在此表) | id, filename(UNIQUE), camera_name, status, summary_json, events_json, people_json, compute_provider, event_start_time, duration_sec, **motion_event_id**(运动片段关联的 SS 事件 id), **camera_id**, updated_at | | `events` | 视频内时间点事件 | id, video_id, ts, description, person_list_json, person_appearances_json, is_attention_event | | `people` | 规范人物(Oracle 维护) | id, label(UNIQUE), canonical_name, appearances, source(llm/manual), features_json, display_uid | | `ss_motion_events` | NAS 推送的运动事件(分割素材的依据) | id, event_id(UNIQUE), camera_id, event_type(10=运动), start_time(epoch), duration, thumbnail_url, received_at | | `sync_cursor` | 同步游标 + 心跳 | key, value(sync_cursor=上次 server_time;motion_heartbeat_at=推送链路心跳) | **NAS(MariaDB,同步镜像,`scripts/ddl.sql`)** | 表 | 用途 | 关键字段 | |----|------|---------| | `sync_videos` | 视频会话镜像(对齐 Oracle videos,含运动片段) | id, filename, camera_name, status, summary_json, events_json, people_json, compute_provider, processed_at | | `sync_events` | 事件镜像(对齐 Oracle events) | id, video_id, ts, description, person_list_json, is_attention_event | | `sync_people` | 人物镜像(对齐 Oracle people) | id, label, canonical_name, appearances, source, features_json, display_uid | | `sync_cursor` | 同步游标 + 运动游标 | key='last_since';key='motion_last_event_id'(MotionNotifier 增量游标) | | `chat_history` | AI 问答记录 | chat_id, user_question, ai_answer, context_summary, queried_date, queried_person | ### 4.2 表关系 ``` Oracle: videos (1) ─── (N) events people 独立(label/canonical_name) NAS 镜像: sync_videos (1) ─── (N) sync_events sync_people 独立 chat_history 独立表(问答上下文摘要留存) ``` 命名回溯:用户命名某 `label` → `POST /api/oracle/people/correct` 设 `canonical_name`(manual 优先)→ 下一周期同步回 NAS `sync_people`;Oracle `person_service` 用规范名回灌视频提示,后续事件 `person_list_json` 直接带真名。 ### 4.3 compute_provider - `sync_videos.compute_provider`:字符串,记录该视频实际成功调用的视觉模型(`gemini` / `nvidia`);本地 Ollama 不参与视频分析,不会出现在该字段 - 问答链路(AI-Gateway 内部 NVIDIA→Gemini→Ollama 降级链)的 provider 体现在 `/api/edge/chat/ask` 响应的 `provider` 字段(由 AI-Gateway 原样透传回来) ### 4.4 兼容性注意 - MariaDB 10.11 严格模式:**空字符串不能插 DATETIME 列**(1292 错误)。同步表时间字段统一用 `VARCHAR(32)` 文本存储 Oracle 的 ISO 字符串,规避类型转换问题 - MariaDB 不支持 MySQL 的 `$[*]` JSON 通配路径,人物统计按 `person_list_json LIKE '%name%'` 字符串匹配在 Python 层完成 --- ## 5. API 规范(实际实现) ### 5.1 FAM-Edge(Oracle :5000) **GET /api/oracle/sync**(NAS 每 30 分钟拉增量,新架构主接口) - 请求:`?since=&token=`(`since` 为空拉全量) - 响应(200): ```json { "videos": [ {"id": 1, "filename": "2026-08-21_081500.mp4", "camera_name": "客厅", "status": "done", "summary_json": "...", "events_json": "[...]", "people_json": "[...]", "compute_provider": "gemini", "event_start_time": "2026-08-21 08:15:00", "updated_at": "2026-08-21 08:40:12"} ], "events": [ {"id": 10, "video_id": 1, "ts": "00:01:23", "description": "汤圆在客厅玩耍", "person_list_json": "[\"汤圆\"]", "is_attention_event": 0, "updated_at": "2026-08-21 08:40:12"} ], "people": [ {"id": 1, "label": "人物A", "canonical_name": "汤圆", "source": "manual", "appearances": 12, "updated_at": "2026-08-21 08:41:00"} ], "server_time": "2026-08-21 08:41:30" } ``` - 401:token 校验失败 **POST /api/oracle/people/correct** — 命名校正回推:`{"label":"人物A","canonical_name":"汤圆","token":...}`(manual 优先,不被 LLM 覆盖)→ `{"status":"ok"}` **POST /api/oracle/video/delete**(2026-08-24 新增)— 删除视频会话(NAS 转发):`{"video_id":123,"token":...}` → 删 `events`+`videos` 行 + 磁盘上的运动片段文件 → `{"status":"ok","video_id":123}`;**不清理**对应的 `ss_motion_events` 源事件(那是硬件推送的原始事件记录,跟切出来的片段生命周期独立;素材一旦处理完就不会被生产者重新捡起,删片段不会触发重新分割);video_id 不存在返回 404 **POST /api/edge/chat/ask(/stream)** — 智能问答(FAM-Core Chat-Handler 调用):请求 `{"prompt","max_tokens"}` → 响应 `{"answer","provider"}`(流式版为 SSE,事件 `provider_trying`/`chunk`/`done`/`all_failed`);**2026-08-23 起 FAM-Edge 自己不跑模型,转发到同机 AI-Gateway `/v1/chat/completions`**,内部按 NVIDIA → Gemini → 本地 Ollama 顺序降级(对 FAM-Core 不可见,接口契约不变) **POST /api/ss/motion** — 运动事件接收(NAS MotionNotifier 推送):`{"token", "events":[{event_id, camera_id, event_type, start_time, duration, thumbnail_url}]}` → 幂等落库 `ss_motion_events` + 刷新心跳;`events: []` 空数组即心跳 **GET /api/oracle/frame** — 事件帧图:`?video_id=&ts=&w=` → jpeg(`ts` 为绝对时间,`frame_service` 按 `ts − event_start_time` 偏移从视频文件取帧) **GET /api/oracle/avatar** — 人物头像:`?label=&w=` → jpeg(从该人物候选事件 `person_appearances` bbox 裁剪) **GET /health** — 服务状态(含已处理视频数) ### 5.2 FAM-Core(NAS :8000) | 端点 | 方法 | 说明 | |------|------|------| | `/health` | GET | 服务健康(**免登录**) | | `/api/login` | POST | 登录:`{"username","password"}` → 校验 `FAM_AUTH_USER`/`FAM_AUTH_PASS`(NAS `.env` 配置,无硬编码默认值,未配置则拒绝所有登录)→ 种 HttpOnly cookie `fam_session`(2 小时) | | `/api/logout` | POST | 退出登录(清 cookie) | | `/api/auth/check` | GET | 登录态检查:`{"authed": true\|false}`(**免登录**) | | `/api/status` | GET | Oracle-Sync + MotionNotifier 状态(sync running/cursor;motion poll_enabled/pushed_total/heartbeat) | | `/api/chat/ask` | POST | 用户问答:`{"question","queried_person","queried_date"}` → `{"answer","context_summary","chat_id"}`(上下文来自 sync_events) | | `/api/chat/history` | GET | 对话历史(`?date=` 或 `?person=&limit=`) | | `/api/member/unnamed` | GET | 未命名人物列表(label / 出现次数 / 首见时间) | | `/api/member/list` | GET | 全部人物(label + canonical_name + 是否命名) | | `/api/member/name` | POST | 命名:`{"label","canonical_name"}` → 回推 Oracle 并即时拉回本地镜像 | | `/api/member/merge` | POST | 合并:`{"source","target"}` → 将 source 并入 target 身份(统一 canonical_name) | | `/api/ss/status` | GET | 运动监测状态:`poll_enabled` / `camera_loaded` / `camera_map` / `pushed_total` / `last_error` / 心跳 | | `/api/ui/videos` | GET | 运动片段会话列表(`?date=&page=`;**只返回 `motion_` 前缀或有事件的会话**,0 段素材不展示) | | `/api/ui/videos/` | GET | 会话详情:`{video, events[]}`(事件含 person_list_json / person_appearances_json / is_attention_event) | | `/api/ui/videos/` | DELETE | **(2026-08-24 新增)**删除会话:先回推 Oracle 物理删除(含磁盘文件),成功后清本地镜像(`sync_events`+`sync_videos`,增量同步感知不到删除,必须显式清理);Oracle 回推失败返回 502,不改本地状态 | | `/api/ui/stats` | GET | 统计卡:videos / events / people / attention(`?date=` 过滤,口径与列表一致) | | `/api/ui/people` | GET | 人物列表(按 canonical_name 聚合:display / labels / appearances / first_seen / features_json / display_uid) | | `/api/ui/people/clips` | GET | 人物运动片段:`?label=&limit=` → 该人物出现过的运动片段(video_id / event_start_time / duration / summary / first_ts 缩略图定位 / clip_events) | | `/api/ui/attention-events` | GET | 需关注事件(日期 + 涉及人物,已去重清洗) | | `/api/ui/named-members` | GET | 已命名成员真名列表(AI 对话快捷选择) | | `/api/ui/model-stats` | GET | 云端模型调用统计(按模型聚合 + 最近明细) | | `/api/ui/service-status` | GET | NAS 同步状态 + Oracle 实时活动(代理,token 不下发浏览器) | | `/api/proxy/frame` | GET | 事件帧图代理:`?video_id=&ts=&w=` → Oracle `/api/oracle/frame`(浏览器不直连 Oracle) | | `/api/proxy/avatar` | GET | 人物头像代理:`?label=&w=` → Oracle `/api/oracle/avatar` | > 已删除:`/api/core/callback/event`、`/media/`(视频处理职责已迁移至 Oracle)。 ### 5.3 云端结构化输出 JSON Schema 运动片段直传云端 VLM,模型直接产出结构化 JSON(`analyze_video` 返回): ```json { "global_summary": "客厅监控摘要……", "events": [ {"timestamp": "00:01:23", "description": "汤圆在客厅玩耍", "people": ["汤圆"], "is_attention_event": false} ], "people_mentioned": ["汤圆"] } ``` - Gemini 用 Files API 上传运动片段后 `generateContent`;NVIDIA 用整视频 `video_url` + `num_frames=128`(模型内部自行采样帧);均不依赖 OpenCV 人脸 - `person_service` 汇总全量 `people_mentioned` → LLM 合并为规范名 → 生成 `known_members_context` 回灌后续片段提示,使模型用真名指代 - `action` / 描述由 AI 自由生成无枚举过滤,`is_attention_event` 由 AI 自行判断 --- ## 6. 关键技术 ### 6.1 视频预处理(历史记录:关键帧抽帧方案,v3 已废弃) > ⚠️ 本小节为旧架构(v1/v2 本地抽帧分析)记录。**v3 运动事件驱动架构不再抽帧**: > 整段素材按运动事件 ffmpeg 分割运动片段(`-c:v copy`),片段直传云端 VLM 分析。 > 保留此处仅作历史参考。 - **粗抽候选帧**: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(仅智能问答兜底,归属 AI-Gateway) 本地 Ollama(qwen2.5:7b,纯文本模型)**不参与视觉分析、不参与云端结果融合**。**2026-08-23 起 Ollama 相关代码(`OllamaAdapter`、启动预热逻辑)已从 FAM-Edge 整个移除,归属独立的 AI-Gateway 服务**——它只在 AI-Gateway 内部 NVIDIA 与 Gemini 都失败时才被启用作为兜底,进程仍然跑在 Oracle 同一台机器上,只是调用方从 FAM-Edge 换成了 AI-Gateway。视觉分析与结构化输出全部由云端模型承担,跟本地模型无关。以下参数作为问答任务的调优依据保留。 | 参数 | 值 | 依据 | |------|-----|------| | `OLLAMA_KEEP_ALIVE=-1` | 模型常驻内存 | 消除 55s 冷启动(常驻约 4.3GB,12GB 内存够用) | | `num_predict=512` | 限制生成 token | ARM 约 5 tok/s,过长生成会拖慢问答响应 | | AI-Gateway 启动预热 | 后台线程 `warm_up()` | `OLLAMA_KEEP_ALIVE=-1` 只保证加载后不换出,不负责主动预加载;NVIDIA/Gemini 一直成功时 Ollama 永远不会被自然触发,加了启动时预热避免真正兜底时才发现要等 1-2 分钟冷启动 | | gunicorn(Edge) | `--timeout 1800` | 同步分析模式,默认 30s 会杀 worker | | push_timeout(NAS) | 1800s | 覆盖最坏情况(30min 视频实测 929s) | **历史问题**:原 llava-phi3 多图单请求基本失效(N 张图一次调用输出长度仅 3~4)。已替换为 qwen2.5:7b(纯文本模型,专职问答兜底,不涉及视觉多图问题;视频分析已全部由云端 VLM 承担)。 ### 6.3 模型适配器架构 **抽象基类 `BaseModelAdapter`**:定义统一接口,每个模型实现自己的适配器。 | 方法 | 职责 | |------|------| | `__init__(provider_name, config)` | 读取 api_key / base_url / model_name / timeout / 熔断器配置 | | `health_check() -> bool` | 轻量请求验证连通性与 Key 有效性 | | `analyze_frames(frame_paths, frame_timestamps, known_members_context) -> Optional[str]` | 接收多张关键帧路径 + 时间戳 + 已知成员特征,返回 VLM 文本描述(失败返回 None) | | `get_timeout() -> int` | 返回该模型单次调用超时阈值 | | 熔断器实例 | 每个云端模型独立熔断器 | **模型清单由 `config.yaml` 的 `models` 数组动态决定**,新增模型 = 实现适配器 + 配置加一项,主流程不动。 **已实现 / 规划的适配器**(按 `role` 区分职责): | provider | 适配器类 | role | SDK / 协议 | 状态 | |----------|---------|------|------------|------| | `gemini` | `GeminiAdapter` | **vision** | requests 直调 REST `:generateContent` | 已实现 | | `nvidia` | `NvidiaVisionAdapter` | **vision** | **openai SDK**(NIM 兼容 OpenAI API 规范) | 已实现 | > `OllamaAdapter` 已于 2026-08-23 从 FAM-Edge 移除(连同 `role='text'` 的问答适配器整体搬到独立的 AI-Gateway 服务,见 §3.4)。FAM-Edge 现在的 `model_adapters/` 只剩 `vision` 角色,`role` 字段本身仍保留(`get_role()` 仍被 `video_processor.py` 用于筛选视觉适配器),只是不会再出现 `text` 取值。 **role 语义**: - `vision`:参与视觉分析阶段,按 fallback 顺序降级,直出结构化 JSON - `text`:**已不在 FAM-Edge 出现**——原来"仅参与智能问答、Gemini/NVIDIA 都失败时兜底"的语义现在完全由独立的 AI-Gateway 服务内部实现(见 §3.4) **NvidiaVisionAdapter 关键实现**(`fam_edge/adapters/nvidia_adapter.py`): - 基于 `openai` Python SDK,`base_url=https://integrate.api.nvidia.com/v1`,`api_key` 从 `${NVIDIA_API_KEY}` 展开 - `health_check`:调 `client.models.list()` 轻量验证 Key - `analyze_frames`:构建 OpenAI 标准 `content` 列表(1 个 text item + N 个 `image_url` item,图片以 `data:image/jpeg;base64,...` 格式内联),调 `chat.completions.create`,`temperature=0.2`、`max_tokens=1024` - prompt 中文模板:要求按时间顺序分析人物数量/衣着/微动作/互动,重点标注异常高危行为 **Orchestrator 调度模式**(`config.yaml` → `orchestrator.mode`): | 模式 | 行为 | |------|------| | `fallback`(默认) | 顺序降级:按 models 数组顺序依次尝试,首个健康且返回非 None 的模型结果即采用;失败则降级到下一个;全部失败返回 503 | | `ensemble` | 并行交叉验证:所有 enabled 模型并行调用,结果一致 → 可信度高;多数派为准;仅单一模型描述 → `source_providers` 仅含该模型 | **视觉分析降级链路(fallback 模式,仅云端 role=vision 模型)**: ``` Gemini (gemini-flash-latest, role=vision) —— 多图单请求直出结构化 JSON │ 失败 / 熔断 OPEN / 超时 ▼ NVIDIA NIM (llama-3.2-11b-vision-instruct, role=vision) —— 逐帧结构化聚合 │ 失败 / 熔断 OPEN / 超时 ▼ 任务 FAILED(走重试,绝不回退本地 Ollama —— 本地仅负责问答兜底) ``` **格式化阶段(无模型调用,仅数据转换)**: ``` 云端成功 provider 直出的结构化 JSON(frame_details / 可选 global_summary / entities_json) ▼ format_cloud_result:字段归一化 → 补 source_providers=[provider] / compute_provider=[provider] → 缺失 entities_json 由 frame_details 推导 → 缺失 global_summary 则事实拼接 → schema 校验 ▼ 合法入库结构,随响应返回 NAS 直接落库(本地模型不介入) ``` **智能问答降级链路(chat 场景,2026-08-23 起完全在独立的 AI-Gateway 服务内部,FAM-Edge 只转发)**: ``` FAM-Edge /api/edge/chat/ask(/stream) │ HTTP 转发(qa.py,本机回环) ▼ AI-Gateway /v1/chat/completions │ ▼ NVIDIA(问答专用文字模型链,跟视觉分析的 omni 模型完全独立) │ 完全没有输出 / 熔断 OPEN / 超时 ▼ Gemini(问答专用非 flash 文字模型链,多 Key 随机轮换,跟视觉分析的 flash 模型完全独立) │ 完全没有输出 / 熔断 OPEN / 超时 ▼ 本地 Ollama (qwen2.5:7b) —— 仅当前两者都失败才启用 │ 失败 ▼ 返回"所有模型均不可用"(HTTP 503) ``` > 已经开始吐字之后中途失败:不换下一个 provider 接着写(避免答案风格前后不连贯),直接结束这次生成——这个语义在 AI-Gateway 的 `orchestrator.py` 里实现,FAM-Edge 的 `qa.py` 只是原样转发这个行为,不重复实现。 **熔断器策略**(按 provider 独立): | 服务 | provider | role | threshold | cooldown | enabled | |------|----------|------|-----------|----------|---------| | FAM-Edge | Gemini | vision | 3 次连续失败 | 600s | true | | FAM-Edge | NVIDIA NIM | vision | 3 次连续失败 | 600s | true | | AI-Gateway | NVIDIA(问答) | — | 见 ai-gateway 配置 | — | true | | AI-Gateway | Gemini(问答) | — | 见 ai-gateway 配置 | — | true | | AI-Gateway | Ollama | — | — | — | false(本地,不熔断) | **健康探测**:FAM-Edge 侧 Gemini `GET /v1/models?key=...`;NVIDIA `client.models.list()`,视觉模型全部不健康返回 503。AI-Gateway 侧 Ollama `GET /api/tags`;Gemini/NVIDIA 同上,各自独立。 **多模型标签兼容**:`compute_provider` 与 `event_details.source_providers` 的 JSON 数组值新增 `"nvidia"` 标签(与 `"ollama"` / `"gemini"` 并列);`validate_schema` 校验非空数组。 **执行可观测性**:Orchestrator 在每次调用记录各模型的 `provider / 耗时 / success` 三元组到日志,并落库到 `monitor_events.compute_provider` 数组。 ### 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 — 已替换为 qwen2.5:7b) | 场景 | 总耗时 | |------|--------| | 冷启动(首次加载 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/` | `bash start_core.sh`(gunicorn -w 1 :8000,source .env 注入 DSM_*/ORACLE_SYNC_TOKEN/FAM_AUTH_*) | | FAM-UI | NAS | `/volume1/web/sentinel-home-ai/fam-ui/dist/` | Vue3 构建产物,由 FAM-Core `static_app.py` 托管(无需独立进程);本地改代码后 `npm run build` 并 tar 部署 dist | | FAM-Edge | Oracle | `/opt/fam-edge/` | **systemd `fam-edge.service` 守护**(Restart=always);部署代码后 `sudo systemctl restart fam-edge`(勿手动 setsid,会端口冲突) | | **AI-Gateway** | Oracle | `/opt/ai-gateway/` | **systemd `ai-gateway.service` 守护**(Restart=always),独立 venv(Python 3.8);gunicorn 绑定 `0.0.0.0:5100`(对外直接开放,非仅本机);部署代码后 `sudo systemctl restart ai-gateway` | | Ollama | Oracle | systemd 托管 | 环境变量 `OLLAMA_KEEP_ALIVE=-1`;**被 AI-Gateway 调用,不再被 FAM-Edge 调用** | > **外网访问(frp 内网穿透)**:NAS 跑 `frpc`(`/etc/frp/frpc.toml`,S99frpc.sh 守护),映射到云服务器 `129.146.26.249`(frps :7000): > - `3000` → NAS Gitea、`8500` → NAS WordPress(8088)、**`8000` → NAS FAM-Core(本系统)** > - 前端入口 `http://129.146.26.249/`(Caddy 托管 FAM-UI,/api 经 frp 隧道反代回 NAS FAM-Core :8000),**需登录**(见 §5.2 `/api/login`) > > **登录校验(2026-08-22 新增,2026-08-23 改为 fail-closed)**:FAM-Core 全站拦截(`auth.py`)——页面未登录 302 `/login`(内置深色登录页,SPA 零改动),`/api/*` 未登录 401;凭据 `FAM_AUTH_USER`/`FAM_AUTH_PASS`(NAS `.env` 配置,**无硬编码默认值**——这两个变量跟 NAS SSH 密码是同一个值,公网入口不能有"没配置就退回已知密码"的兜底,`.env` 没配好这两项时直接拒绝所有登录);白名单免登录:`/login` `/api/login` `/api/logout` `/api/auth/check` `/health` `/assets/*`(`/api/ss/webhook` 已随端点一起移除,2026-08-25)。登录态为进程内 token + HttpOnly cookie(**2 小时**),过期或重启 fam-core 需重新登录。 > Oracle 部署方式:本地 git 提交 push Gitea → tar 管道到 `/opt/fam-edge`(`--strip-components=1` 解临时目录再 cp,避免动 data/venv/gdrive_videos)。**AI-Gateway 是独立 git 仓库**(http://192.168.50.64:3000/ericwyuan/ai-gateway,见 §10.4),同样 tar 管道部署到 `/opt/ai-gateway`,互不影响。 ### 8.2 依赖 - **FAM-Core(NAS, Python 3.10 venv)**:Flask, Gunicorn, **PyMySQL**(45KB 纯 Python 替代 19MB mysql-connector), PyYAML, requests - **FAM-UI(NAS, Node)**:Vue3 + Vite + Tailwind(`fam-ui/`,构建产物 dist 不入库);**不再依赖 Streamlit** - **FAM-Edge(Oracle, Python venv)**:Flask, Gunicorn, requests, PyYAML, opencv-python, numpy, **openai**(NVIDIA NIM 兼容 OpenAI API 规范,仅视觉分析用);Gemini 用 requests 直调 REST;**问答不再直接调模型,只用 requests 转发到 AI-Gateway** - **AI-Gateway(Oracle, Python 3.8 venv,独立部署单元)**:Flask, Gunicorn, requests, PyYAML, **openai**(NVIDIA 问答模型用) - **系统级**:FFmpeg(两端;Oracle 端用于运动片段分割 + 帧图/头像)、Ollama + qwen2.5:7b(Oracle,**被 AI-Gateway 调用**)、MariaDB 10.11(NAS)、rclone(Oracle,同步 Google Drive 素材) ### 8.3 配置文件要点 **fam-core/config/config.yaml**(NAS —— 同步 + 问答 + 运动监测): ```yaml server: port: 8000 database: # MariaDB(unix_socket 优先) unix_socket: "/run/mysqld/mysqld10.sock" oracle_sync: # 增量镜像线程 base_url: "http://129.146.26.249:5000" token: "${ORACLE_SYNC_TOKEN}" # 与 Oracle 端 sync_api.token 一致 interval_sec: 1800 # 每 30 分钟拉一次增量 chat_handler: qa_url: "http://129.146.26.249:5000/api/edge/chat/ask" timeout: 120 motion_notifier: # 运动监测(轮询主路径) enabled: true poll_enabled: true dsm_host: "192.168.50.64" # Surveillance Station dsm_port: 5000 dsm_account: "${DSM_ACCOUNT}" dsm_password: "${DSM_PASSWORD}" camera_ids: [2] camera_name_to_id: {"Generic_ONVIF-001": 2} oracle_base_url: "http://129.146.26.249:5000" oracle_token: "${ORACLE_SYNC_TOKEN}" poll_interval_sec: 60 poll_window_hours: 2 batch_size: 100 heartbeat_interval_sec: 300 # 心跳(证明推送链路存活,< Oracle 侧 900s 阈值) ``` **fam-edge/config/config.yaml**(Oracle —— 素材分割 + 片段分析 + 同步 + 人物服务;**2026-08-23 起不再含任何问答模型配置**): ```yaml server: port: 5000 gdrive_sync: # rclone 同步落地目录(整段素材) enabled: true local_dir: "/opt/fam-edge/gdrive_videos" watch_interval_sec: 30 camera_name: "客厅" parse_start_from_filename: true oracle_db: path: "/opt/fam-edge/data/oracle.db" sync_api: token: "${ORACLE_SYNC_TOKEN}" person_service: schedule_interval_sec: 1800 # 每 30 分钟重新汇总人物 model: "gemini" video_processing: max_concurrent: 1 # 单视频串行,避免抢占云端配额 timeout: 900 vision_order: ["gemini", "nvidia"] motion_segment: # 运动片段分割(运动事件驱动架构) clips_dir: "/opt/fam-edge/motion_clips" keep_audio: true # 保留音频(pcm_alaw -> aac 64k 转码) min_duration_sec: 1 unfinished_grace_sec: 10 # start+duration 距当前 ≤10s 视为已结束 ai_gateway: # 问答转发客户端配置(2026-08-23 新增,取代原来的问答专用 models 条目) base_url: "http://127.0.0.1:5100" token: "${AI_GATEWAY_TOKEN}" timeout: 60 models: # 只剩视觉分析用的两个 provider,role 全是 vision - provider: "gemini" role: "vision" model_name: "gemini-flash-latest" fallback_models: ["gemini-flash-lite-latest"] api_key: "${GEMINI_API_KEY}" extra_api_keys: ["${GEMINI_API_KEY_2}", "${GEMINI_API_KEY_3}", "${GEMINI_API_KEY_4}"] timeout: 600 - provider: "nvidia" role: "vision" model_name: "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning" # 整视频 video_url 输入(内部采样帧) base_url: "https://integrate.api.nvidia.com/v1" api_key: "${NVIDIA_API_KEY}" timeout: 600 ``` **ai-gateway/config/config.yaml**(Oracle —— 独立服务,问答模型降级链,`server.port: 5100`): ```yaml server: port: 5100 models: # 顺序即降级优先级,chat-only,不含视觉 - provider: "nvidia" enabled: true model_name: "nvidia/nemotron-3-ultra-550b-a55b" # 问答专用文字模型链,跟视觉分析的 omni 模型不同实例 fallback_models: ["nvidia/nemotron-3-super-120b-a12b", "openai/gpt-oss-120b"] api_key: "${NVIDIA_API_KEY}" timeout: 600 - provider: "gemini" enabled: true model_name: "gemini-pro-latest" # 问答专用非 flash 模型链,跟视觉分析的 flash 模型不同实例 fallback_models: ["gemini-2.5-pro"] api_key: "${GEMINI_API_KEY}" extra_api_keys: ["${GEMINI_API_KEY_2}", "${GEMINI_API_KEY_3}", "${GEMINI_API_KEY_4}"] timeout: 60 - provider: "ollama" enabled: true model_name: "qwen2.5:7b" base_url: "http://localhost:11434" timeout: 120 ``` > 部署时 `config_loader.py` 支持 `FAM_ENV_FILE` 环境变量指向另一个 `.env` 复用密钥(设计初衷是复用 FAM-Edge 已配置的 `NVIDIA_API_KEY`/`GEMINI_API_KEY*`,避免同一份密钥维护两份);**当前实际部署选择了各自独立**:`/opt/ai-gateway/.env` 自己存了一份 `NVIDIA_API_KEY`/`GEMINI_API_KEY*`/`AI_GATEWAY_TOKEN`,跟 `/opt/fam-edge/.env` 没有关联,以后轮换 key 需要两处都改。 **环境变量**(Oracle 节点,写入 `~/.bashrc` 或 systemd 环境变量文件): ```bash export GEMINI_API_KEY="AQ.Ab8RN6I0l8hC7hLnNHRY6qOXdch5CTWczDNlS4c1XrneGHipUQ" export NVIDIA_API_KEY="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" ``` **NVIDIA NIM 单图连通性验证**: ```bash pip3 install openai python3 -c " import os from openai import OpenAI client = OpenAI( base_url='https://integrate.api.nvidia.com/v1', api_key=os.environ['NVIDIA_API_KEY'] ) response = client.chat.completions.create( model='meta/llama-3.2-11b-vision-instruct', messages=[{'role': 'user', 'content': 'Hello, are you ready?'}], max_tokens=30 ) print('NVIDIA NIM 连接成功:', response.choices[0].message.content) " ``` ### 8.4 运维注意事项 - NAS 部署目录**不是 git 仓库**(文件拷贝部署),同步代码用 stdin 管道:`tar czf - <子目录> | ssh ... 'tar xzf - -C <目标>'` - NAS scp 子系统被禁用,同样用 stdin 管道传文件 - **Oracle fam-edge 由 systemd `fam-edge.service` 守护(Restart=always)**:部署代码后必须 `sudo systemctl restart fam-edge`;手动 `setsid` 启动会和守护打架(端口 `Connection in use`) - NAS 远端 kill gunicorn 用 `ps aux | grep "[f]am-core/venv/bin/gunicorn"` 字符类技巧(pkill/pgrep 会匹配 SSH 自身命令行导致断连) - fam-core 启动模块路径是 `src.fam_core.app:app`(不是 `fam_core.app:app`);`start_core.sh` 会 source 仓库根 `.env` 注入 `DSM_*/ORACLE_SYNC_TOKEN` - Edge 单 worker 处理任务期间 `/health` 可能不响应,属正常 - **运动数据清理**:切换架构/重新提取时清 Oracle `videos/events/people` + `motion_clips/`(保留 `ss_motion_events` 与素材)与 NAS `sync_*` 镜像,重启两端自动重新分割分析 - **前端构建产物 `fam-ui/dist` 不入库**(.gitignore),改前端后需 `npm run build` 并单独 tar 部署 dist --- ## 9. 快速开始 ```bash # 1. 初始化数据库(NAS) python scripts/init_db.py # 2. 启动 FAM-Core(NAS,含运动监测轮询 + Oracle-Sync) cd /volume1/web/sentinel-home-ai && bash start_core.sh # 3. 启动 FAM-Edge(Oracle,systemd 守护) sudo systemctl restart fam-edge # 4. 前端(Vue3,构建后由云服务器 Caddy :80 托管,/api 经 frp 隧道反代回 NAS FAM-Core,无需在 NAS 独立进程) cd fam-ui && npm run build # 产物 fam-ui/dist/ # 或使用脚本 ./scripts/start_core.sh && ./scripts/start_edge.sh ``` --- ## 10. 服务器访问信息 ### 10.1 Synology NAS(FAM-Core + FAM-UI + MariaDB) | 项目 | 值 | |------|-----| | IP | 192.168.50.64 | | SSH 端口 | 2222(scp 禁用,用 stdin 管道传文件) | | SSH 用户 / 密码 | ericwyuan / iLoveJava5 | | 系统 | Synology DS220+ (Geminilake), DSM 7 | | Tailscale IP | 100.70.234.39(userspace 模式,端口不通待修) | | 登录命令 | `ssh -p 2222 ericwyuan@192.168.50.64` | ### 10.2 MariaDB(NAS) | 项目 | 值 | |------|-----| | 版本 | MariaDB 10.11.11 | | Socket | /run/mysqld/mysqld10.sock | | Root 密码 | iLoveJava5! | | 数据库名 | sentinel_home_ai | | 连接 | `/usr/local/mariadb10/bin/mysql -S /run/mysqld/mysqld10.sock -u root -p`(非交互 PATH 下用 fam-core venv 的 PyMySQL + unix_socket 查询) | ### 10.3 Oracle Cloud(FAM-Edge + Ollama + FFmpeg) | 项目 | 值 | |------|-----| | 公网 IP | 129.146.26.249 | | SSH 用户 | ubuntu(密钥 `~/.ssh/oracle_new`) | | 系统 | aarch64 (Ampere A1 4C23G), Ubuntu 20.04 LTS | | Tailscale | 已安装未启用(备用;新机公网直连为主) | | 登录命令 | `ssh -i ~/.ssh/oracle_new ubuntu@129.146.26.249` | ### 10.4 Gitea 代码仓库 | 仓库 | URL | 说明 | |------|-----|------| | sentinel-home-ai(本仓库) | http://192.168.50.64:3000/ericwyuan/sentinel-home-ai | monorepo:fam-core + fam-edge + fam-ui | | ai-gateway(2026-08-23 新增) | http://192.168.50.64:3000/ericwyuan/ai-gateway | 独立仓库/独立部署单元,问答网关服务 | 账号 / 密码:ericwyuan / iLoveJava5(两个仓库共用) ### 10.5 Gemini API(Google AI Studio) | 项目 | 值 | |------|-----| | API Key | AQ.Ab8RN6I0l8hC7hLnNHRY6qOXdch5CTWczDNlS4c1XrneGHipUQ | | 模型 | `gemini-flash-latest`(生产配置,v1beta 下 `gemini-1.5-flash` 会 404,故用别名) | | 端点 | 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"}]}]}' ``` ### 10.6 NVIDIA NIM 托管 API | 项目 | 值 | |------|-----| | API Key | `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` | | Base URL | `https://integrate.api.nvidia.com/v1` | | 默认模型 | `meta/llama-3.2-11b-vision-instruct`(可替换 `qwen/qwen2-vl-72b-instruct`) | | SDK | `openai` Python SDK(NIM 兼容 OpenAI API 规范,直接复用) | | 验证状态 | 已验证(2026-08-20 部署验证:`/api/edge/chat/ask` 实测 provider=nvidia,Gemini 超时后 NVIDIA 兜底成功;单图连通性验证脚本见 8.3) | **环境变量**: ```bash export NVIDIA_API_KEY="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" ``` --- ## 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-28) - **Oracle 迁移故障排查 + DiskGuard 磁盘守护**(2026-08-28):8/25 迁移新机器时漏装了 FFmpeg,导致运动片段分割和帧图抽取全部失效(`ffprobe: command not found`);同时 `gdrive_videos` 持续下载新素材没有配套清理,磁盘被写满到 100%,触发 rclone 的安全机制(IO 错误时拒绝执行删除),形成"越满越删不掉"的死循环,表现为"只下载不删除"。已装回 FFmpeg、手动清理了 148 个远端已不存在的孤儿文件(释放 41G)恢复同步;新增 `DiskGuard` 后台服务作为永久兜底,剩余空间 <10GB 自动清理最旧的已完成素材。8/25 之前(旧机器时代)的历史事件帧图因源文件未随迁移保留、旧机器已销毁,无法找回;8/25-28 期间的 190 个素材/736 个运动事件按用户决定不重新处理 - **事件时间轴支持删除视频会话**(2026-08-24):三端联动,沿用"NAS 转发写请求到 Oracle"的既有模式(新增 `POST /api/oracle/video/delete` + `DELETE /api/ui/videos/`);删 events+videos 行 + 磁盘文件,不清理 `ss_motion_events` 源事件;`db_layer.delete_sync_video()` 是项目里第一个"NAS 直接写自己镜像表"的函数(增量同步机制感知不到删除,不能靠 `trigger_now()` 拉增量清理) - **问答链路抽离为独立 ai-gateway 服务**(2026-08-23):FAM-Edge 原本自己维护的问答模型降级链(NVIDIA→Gemini→Ollama,含 key 轮换/熔断)整个搬到独立仓库/独立部署单元 `ai-gateway`(OpenAI 兼容协议 `/v1/chat/completions`,Bearer token 鉴权,对外 `:5100`);FAM-Edge `qa.py` 重写为转发客户端,`/api/edge/chat/ask(/stream)` 对 FAM-Core 的契约不变;`OllamaAdapter` 从 FAM-Edge 删除 - **运动事件驱动**(v3,2026-08-22):不再分析整段视频。NAS MotionNotifier 轮询 SS 事件列表(60s,游标续用/失败重试/心跳)推送 `ss_motion_events`;Oracle 整段素材按运动事件 ffmpeg 分割运动片段(`-c:v copy -c:a aac`,只分割已结束事件,`motion_event_id` 幂等),**只分析运动片段**;前端契约不变 - **数据迁移**(2026-08-22):整段提取的旧数据(Oracle videos/events/people + NAS 镜像)已清空并按运动视频重新提取;历史素材(NAS 轮询启动前)无运动事件,时间轴已过滤其空会话 - **人物管理重设计**(2026-08-22):人物卡新增「运动片段」区块(缩略图/时间/摘要/事件数,点击跳时间轴定位);新增 `GET /api/ui/people/clips`;Timeline 支持 `?video=` 定位 - 前端从 Streamlit 迁移为 **Vue3 + Vite + Tailwind SPA**;2026-08-25 起前端托管由 NAS 迁至云服务器 Caddy :80,/api 经 frp 隧道反代回 NAS FAM-Core - 运动事件链路(NAS→Oracle 单向推送)+ 心跳 fail-open;Gemini 多 Key 轮换 + 模型调用统计 - 双端部署:NAS `start_core.sh`(source .env);Oracle **systemd `fam-edge.service` 守护** 详细进度见 `PROGRESS.md`。 ### v1.1 待办 | # | 任务 | 优先级 | |---|------|--------| | 1 | 单元测试(JSON parser / circuit breaker / schema 校验) | 中 | | 2 | Tailscale 修复(NAS userspace 模式升级,流量不走公网) | 低 | | 3 | daily_summaries 每日摘要 | 低 | | 4 | 运动片段音频策略验证(当前保留 aac 转码) | 低 | --- 文档结束