Compare commits
52 Commits
0d8e62607c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
deaebf22b4 | ||
|
|
3b5f7db51d | ||
|
|
a494d60361 | ||
|
|
da60365214 | ||
|
|
1964e976f4 | ||
|
|
dc284f4bf5 | ||
|
|
0238030128 | ||
|
|
2ad0472578 | ||
|
|
db4f46140b | ||
|
|
9c4db682d5 | ||
|
|
feb096ff3d | ||
|
|
5e1230d9b0 | ||
|
|
8120d2ae6a | ||
|
|
988c63a8f9 | ||
|
|
05c7a8b70a | ||
|
|
c9354ed1a1 | ||
|
|
ef56ae5662 | ||
|
|
3ec79de911 | ||
|
|
09708bd9a4 | ||
|
|
cf3d145c57 | ||
|
|
7948f55b02 | ||
|
|
3cb08c9749 | ||
|
|
28397e9e6a | ||
|
|
2e370ab668 | ||
|
|
5caeb299a4 | ||
|
|
9dff19e6ac | ||
|
|
5f7b27bf04 | ||
|
|
63b28663f1 | ||
|
|
bdf561a415 | ||
|
|
9bba7b7e14 | ||
|
|
22bd4d4ff9 | ||
|
|
2c5bf950c5 | ||
|
|
aca1a674b1 | ||
|
|
9c51c65e62 | ||
|
|
1dd9df34c2 | ||
|
|
177c2a83b7 | ||
|
|
cbcc5d0325 | ||
|
|
01a8ac2a3f | ||
|
|
354ff18101 | ||
|
|
96644599d3 | ||
|
|
6a29e88d86 | ||
|
|
4cf4fc4bc2 | ||
|
|
ffcc292cce | ||
|
|
8d6cfad6c0 | ||
|
|
a1523b46c8 | ||
|
|
2d9d96de56 | ||
|
|
7adc32486a | ||
|
|
b2b537dde5 | ||
|
|
e52d8e9103 | ||
|
|
ca6d425b19 | ||
|
|
01bbb39750 | ||
|
|
38dea6ba2e |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -42,3 +42,6 @@ logs/
|
||||
# Env
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# 其他独立项目(各有独立仓库,勿提交进本仓库)
|
||||
ai-gateway/
|
||||
|
||||
31
.workbuddy/memory/2026-08-20.md
Normal file
31
.workbuddy/memory/2026-08-20.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# 2026-08-20 工作记录
|
||||
|
||||
## NAS 瘦身 + 甲骨文同步(架构重构收尾,任务 #49/#50/#57/#59)
|
||||
|
||||
完成 NAS 端从「视频处理节点」到「纯管理后台」的改造:
|
||||
|
||||
### fam-core(NAS)改动
|
||||
- 删除:scheduler / dispatcher / poller / event_receiver / video_server 五个模块 + tools/ 下 backfill 脚本(不再切片/抽帧/上传/提供视频下载)
|
||||
- 新增 `oracle_sync.py`:唯一后台线程,每 30 分钟 `GET /api/oracle/sync?since=<cursor>&token=` 拉增量 → upsert 到本地 MariaDB `sync_*` 镜像表 → 推进 `sync_cursor`;`push_name_correct` 回推命名校正
|
||||
- `db_layer.py` 重写:移除 process_tasks/event_details/monitor_events/family_members 旧逻辑,新增 sync_videos/sync_events/sync_people/sync_cursor 的 upsert/查询、`get_sync_stats`、`query_sync_events_for_person_date`、`get_sync_known_members_context`
|
||||
- `member_manager.py`:命名/合并改回推 Oracle `/api/oracle/people/correct` + 即时 trigger_now 拉回
|
||||
- `chat_handler.py`:上下文改查 sync_events(按 person_list_json + 视频日期过滤),仍走 Oracle `/api/edge/chat/ask`
|
||||
- `app.py`:仅启动 OracleSync 线程,移除看门狗
|
||||
- config:删除 scheduler/dispatcher/poller/video_server/storage,新增 `oracle_sync` 段(token 用 `${ORACLE_SYNC_TOKEN}` 环境变量)
|
||||
|
||||
### fam-ui(NAS)改动
|
||||
- 改写读 sync_videos/sync_events/sync_people;事件时间轴改为「视频会话列表 + 事件时间线」(无帧图);人物管理移除照片逻辑(仅标签/规范名/命名/合并);统计改自 sync 表;侧边栏任务队列状态改为同步状态
|
||||
|
||||
### DDL / 文档
|
||||
- `scripts/ddl.sql` 新增 sync_videos / sync_events / sync_people / sync_cursor 四张镜像表(时间字段用 VARCHAR 规避 MariaDB 严格模式)
|
||||
- README §1.1/§2.2/§3.1/§3.2/§3.3/§4/§5/§8.3 全面更新到新数据流(Google 硬盘→rclone→甲骨文→同步→NAS 镜像)
|
||||
|
||||
### 提交
|
||||
- `[阶段2]` fam-edge 重构(整视频分析+同步接口+人物服务)已 commit+push
|
||||
- `[阶段3]` fam-core 瘦身+Oracle-Sync+UI 改读 已 commit+push
|
||||
|
||||
### 待办(#51 部署验证,未做)
|
||||
- Oracle:rclone 配 Google 服务账号 JSON(orcLenas@gen-lang-client-0523399799.iam.gserviceaccount.com)→ systemd timer 定时同步到 /opt/fam-edge/gdrive_videos;设 ORACLE_SYNC_TOKEN 环境变量;重启 fam-edge
|
||||
- NAS:MariaDB 跑新 DDL 建 sync_* 表;设 ORACLE_SYNC_TOKEN;重启 fam-core + fam-ui;卸载 NAS venv 无用包(opencv/numpy 等)
|
||||
- 端到端验证:Google 硬盘新视频 → 甲骨文处理 → NAS 30 分钟同步可见
|
||||
- 服务账号私钥未入库,部署时需单独落到 Oracle 服务器本地
|
||||
180
.workbuddy/memory/2026-08-21.md
Normal file
180
.workbuddy/memory/2026-08-21.md
Normal file
@@ -0,0 +1,180 @@
|
||||
# 2026-08-21 工作记录
|
||||
|
||||
## #51 部署验证 - rclone 同步 Google 硬盘 + 全链路跑通
|
||||
|
||||
### Oracle(fam-edge)部署完成
|
||||
- rclone v1.75.0 装到 /usr/local/bin(aarch64,zip 解压)
|
||||
- 服务账号 JSON 存 /opt/fam-edge/gdrive-sa.json(chmod 600,用户提供)
|
||||
- rclone remote `gdrive:`(drive scope + service_account_file)
|
||||
- 同步脚本 /opt/fam-edge/rclone_sync.sh + systemd timer(每 5 分钟):
|
||||
`rclone sync --drive-shared-with-me gdrive:SS/Generic_ONVIF-001 /opt/fam-edge/gdrive_videos`
|
||||
- 首次全量同步 20 个视频 7.52GB(~2 分钟完成)
|
||||
- 共享目录是 `SS`(群晖监控站),摄像头 `Generic_ONVIF-001` 按 YYYYMMDDAM/PM 分目录
|
||||
|
||||
### 踩坑与修复(3 个 commit)
|
||||
1. `watch_processor._scan_files` 改 os.walk 递归(原 listdir 只看根目录,视频在子目录)
|
||||
2. `.env` 的 GEMINI/NVIDIA key 是旧无效值 → 用 env.sh 的真实 key 修复(config_loader 只读 .env)
|
||||
3. **Gemini 上传**:必须用 `/upload/v1beta/files` 端点(/v1beta/files 是元数据端点)+ resumable 协议,370MB 整视频 ~55s 上传成功
|
||||
4. **NVIDIA**:base64 塞 payload 超 25MB 上限 → 改 Assets API(POST 拿 assetId+uploadUrl,PUT 上传);PUT 必须**全小写 content-type 且值=POST contentType(video/mp4)**,否则 S3 预签名 SignatureDoesNotMatch
|
||||
5. `parse_vlm_json` schema 从旧架构(entities_json/frame_details)改为新架构(global_summary/events/people_mentioned),兼容旧结构转换
|
||||
|
||||
### 端到端验证通过
|
||||
- 21 视频全部登记,video 1/2 已 done(provider=gemini,凌晨视频无事件=分析正确)
|
||||
- NAS 重启 fam-core 触发立即同步:sync_videos=21,cursor 推进正确
|
||||
- 剩余 19 个视频串行处理中(Gemini 免费配额 429 会降级 flash-lite,之后可考虑 NVIDIA)
|
||||
|
||||
### 待观察
|
||||
- Gemini 免费 key 配额限制(~20 req/day),后续 19 个视频可能大量 429
|
||||
- rclone timer 每 5 分钟增量同步验证
|
||||
- NAS 30 分钟自动同步已生效
|
||||
|
||||
## 生产-消费队列 + 模型超时×2(新需求,commit ce27e4a)
|
||||
- 新增 `fam-edge/src/fam_edge/video_queue.py`:VideoQueue 生产-消费队列
|
||||
- 生产者线程:30s 轮询 rclone 落地目录,新文件登记 pending 并入队;启动时补入队 DB 未处理完的(重启恢复,实测 14 个)
|
||||
- 消费者线程:max_concurrent=1,从队列取 video_id → VideoProcessor.process_video(timeout_multiplier=2)
|
||||
- 防重复入队:_queued set;重试上限:max_retries=2(oracle_db.videos 加 retry_count 列,mark_video_failed 递增)
|
||||
- `video_processor.process_video` 加 timeout_multiplier:遍历 adapter 时临时 adapter.timeout = 原配置 ×倍数,finally 恢复(gemini 600→1200s 生效日志确认)
|
||||
- config.yaml video_processing 加 timeout_multiplier: 2 / max_retries: 2
|
||||
- 删除旧 watch_processor.py,app.py 改启动 VideoQueue
|
||||
- oracle_db:retry_count 列(建表+ALTER 兼容旧库)、get_video_by_id、PRAGMA busy_timeout=10000
|
||||
- 部署验证:启动恢复入队 14 个、video_id=8 gemini 600→1200s、生产者登记 id=22 新视频、done 持续增长
|
||||
|
||||
## 模型调用统计界面 + lite 超时实测×4(commit b87b38b + 3e94c2a)
|
||||
- Oracle 端:oracle_db 新增 model_calls 表(provider/model/video_id/started_at/duration_sec/success/error/created_at);BaseModelAdapter 加 model_call_hook + _emit_model_call,gemini(_generate_video 每模型每attempt)/nvidia(analyze_video) 每次请求记录;get_sync_delta 下发 model_calls(created_at >= since + NAS 幂等 upsert 防漏)
|
||||
- gemini 支持模型级 model_timeouts(最终值不参与 ×2):实测 lite 370MB 视频耗时 22.6s(生产 27~34s),lite 限制 = 22.6×4 ≈ 90s;flash 仍 1200s(600×2)。日志确认:flash 本轮 1200s / lite 本轮 90s
|
||||
- NAS 端:sync_model_calls 镜像表(ddl 8.1);db_layer upsert_sync_model_calls/get_sync_model_calls/get_sync_model_calls_stats;oracle_sync 拉 model_calls(last_count 变 4 元组)
|
||||
- fam-ui 新增 "🤖 模型统计" 页:按模型聚合(成功/失败/成功率/平均耗时/最后调用)+ 最近 100 条调用明细(请求时间/模型/耗时/状态/失败原因/视频)
|
||||
- 验证:Oracle model_calls 正常记录(flash 429_quota 失败、lite 成功 27~34s);NAS 同步 4 条,fam-ui 200
|
||||
|
||||
## 整体排查 + lite 超时 8 分钟(commit 83eefc1)
|
||||
- **NVIDIA 400 排查结论**:`nvidia/nemotron-nano-12b-v2-vl` 对整视频分析**稳定复现 400/500 服务端内部错误**("not enough values to unpack (expected 2, got 1)"),所有视频、有无 num_frames 均复现 → NVIDIA 兜底在当前模型/端点**不可用**(asset 上传 OK,chat.completions 必失败)。建议从 vision_order 移除或换模型
|
||||
- gemini-flash-lite 超时 90s → **480s(8 分钟)**,config model_timeouts 已改并部署(日志确认 480s)
|
||||
- 处理进度:done 33 / failed 1(video24 待重试)/ pending 12
|
||||
|
||||
## NVIDIA 多模型降级链 + 实测结论(commit b7b5fe6)
|
||||
- nvidia_adapter 改造:model_chain(asset 上传一次,逐个模型 video_url 引用尝试)+ switch_interval_sec 切换间隔(默认 5s)+ model_timeouts 每模型独立超时 + compute_provider 带模型名(nvidia:{model})
|
||||
- **实测全部 NVIDIA 云端视频候选不可用**:omni 500(asset_id 引用失败)、12b 400、llama-3.2-11b-vision 400(不支持视频)、cosmos-reason2/phi-3-vision/gemma-3/kosmos-2/fuyu-8b/paligemma 404;base64 超 25MB;public URL 方案被用户否决(不暴露视频)
|
||||
- config:model_name=omni + fallback 12b/llama-11b,链机制保留,未来可用模型出现只需改配置
|
||||
- 手动验证:模型链 [1/3]→[2/3]→[3/3] 逐个尝试+5s 间隔+失败原因记录,全部失败返回 None
|
||||
- ⚠️ 安全提醒:Oracle 曾短暂起 http.server:8000 暴露视频目录做 POC,已按用户要求关闭
|
||||
|
||||
## 前端修复:SQL 1054 + 视频缩略图(commit 4148507)
|
||||
- **SQL 1054 修复**:fam-ui 事件查询 `SELECT ... camera_name FROM sync_events`(该列在 sync_videos)→ 改 JOIN sync_videos 取 v.camera_name
|
||||
- **视频缩略图**:Oracle 装 opencv-python-headless(5.0);video_processor 处理成功后抽首帧(宽≤640, JPEG q65)存 /opt/fam-edge/thumbs/{video_id}.jpg;api_gateway 新增 GET /api/oracle/video/{id}/thumb(token 校验,无 token 401);已 done 46 视频 backfill 46/46
|
||||
- **fam-ui**:config 加 oracle_url(129.146.203.203:5000)+oracle_token(${ORACLE_SYNC_TOKEN});事件时间轴视频会话头显示缩略图(img onerror 优雅降级);start_ui.sh 补 source 项目 .env 注入 token
|
||||
- 验证:NAS→Oracle thumb HTTP 200/40KB;fam-ui 进程 token env 就绪;UI 8501 正常
|
||||
- 人物管理无照片:架构局限(LLM 仅输出人物名,无图像锚点),如需人物照片需从视频定位+裁剪,待后续
|
||||
|
||||
## 每个事件对应时间点画面截图(commit 05f727a)
|
||||
- oracle_db.mark_video_processed 返回 event_ids(与 events 一一对应)
|
||||
- video_processor._generate_event_thumbs:事件 ts - 视频 event_start_time = 偏移秒 → cv2 跳帧(CAP_PROP_POS_MSEC)截图,存 /opt/fam-edge/thumbs/ev_{event_id}.jpg(宽≤640, q65;解析失败/负偏移取首帧)
|
||||
- api_gateway 新增 GET /api/oracle/event/{event_id}/thumb(token 校验)
|
||||
- fam-ui 事件查询加 e.id;render_event_list 每条事件显示对应截图(onerror 隐藏降级)
|
||||
- backfill:已 done 视频 102 个事件截图全部生成;验证 NAS→Oracle HTTP 200/51KB、无 token 401
|
||||
|
||||
## 事件截图时间对不上修复(commit 5233296 + 3fcd00d)
|
||||
- **根因**:模型输出"绝对北京时间"靠自身推算,1 小时视频内误差可达分钟级 → 截图按不准的时间定位帧必然图文不符
|
||||
- **修复**:① prompt 改为要求输出"视频内相对时间 HH:MM:SS"(模型对相对位置判断准);② 后端 _parse_event_ts 解析相对时间 → 绝对时间 = start + offset 精确落库(兼容旧绝对格式);③ 截图直接用 offset 跳帧
|
||||
- **连带 bug**:_parse_event_start_from_filename 只认带分隔符日期(2026-08-21),监控文件名是纯数字 20260820-140416 → event_start_time 空 → 新增纯数字格式解析
|
||||
- 启发式:相对时间 >6h 视为模型误输出绝对时间,不强行定位
|
||||
- 验证:重置 video 46 重分析 → event_start=14:04:16 ✓,事件 ts 14:05:37/14:06:21/14:13:02,截图 offset 36/48/81/125/526s 精确 ✓
|
||||
- 旧视频(除重分析的)仍用旧时间戳截图;如需全部修正需批量重分析(成本高,用户确认后再做)
|
||||
|
||||
## 全部旧视频重分析 + 人物管理图片(commit 92ef9fe)
|
||||
- **全量重分析**:清空 events(104) + 旧事件截图(108) + 重置全部 45+1 个视频 pending → 队列后台串行重跑(新相对时间逻辑,预计 1.5-2.5h,Gemini flash 429 → lite 兜底)
|
||||
- **人物管理图片**:fam-ui 每个人物身份取其一 label 出现事件的截图作头像(查 sync_events.person_list_json LIKE → 显示 Oracle event thumb)
|
||||
- 修正:video 46 起初被排除重跑但 events 被清 → 一并重置统一重跑
|
||||
|
||||
## 队列健壮性三项(commit 6afdda5,用户需求清单)
|
||||
1. **失败重试间隔 30s→1h**:videos 表加 last_fail_at(mark_video_failed 记录);video_queue._retry_allowed 对 failed 要求 retry_count<max_retries 且距上次失败 ≥ retry_interval_sec(3600) 才重新入队(配额类瞬时故障等恢复,避免重复打爆)
|
||||
2. **max_retries 2→10**:默认值与 config 均改 10(瞬时故障更多机会)
|
||||
3. **入队前文件校验**:validate_video()(OpenCV:大小>0/可打开/可读帧/元数据 fps-frames-duration-分辨率);新文件先过 mtime 稳定窗口(stable_window_sec=60 防 rclone 半成品)再校验,失败登记 status='invalid' 不入队(可追溯,_retry_allowed 排除);process_video 处理前二次确认(失败标 failed:invalid_file:xxx)
|
||||
- 验证:正常视频 ok+meta(30min/2880x1620/14.28fps)、损坏 cannot_open、空文件 file_empty;部署后日志"重试上限 10"
|
||||
- 重分析 42 个 pending 继续后台跑
|
||||
|
||||
## 代码审查 18 项处理(commit 034dca9 + 4e85a98)
|
||||
- **已解决(新增)**:#18 密钥明文直配 config.yaml(token/gemini/nvidia,弃 .env 依赖,验证 token_ok/key 长度正常);#5 appearances 改 distinct 视频数覆盖校准(set_person_appearances,防 reconcile 累加膨胀);#9 oracle_sync _pull_lock 防 trigger_now 与后台并发双拉;#11 LLM 合并命名解析 target 已有 canonical(统一显示名);#12 熔断器状态转换加锁;#10 问答人名匹配改 JSON_CONTAINS 精确匹配(MariaDB 语法验证 OK);#4 OracleDB _write_lock 复合写串行化;#6 每消费者独立 VideoProcessor(消 adapter.timeout 共享竞争);#15 done 视频文件被覆盖 mtime>processed_at 自动重置重分析
|
||||
- **累计已解决**:#1/#2/#3/#4/#5/#6/#9/#10/#11/#12/#15/#18;部分缓解 #7/#14/#17
|
||||
- **未解决(说明)**:#8 删除传播(tombstone 大工程,暂缓);#13 Gemini 文件/NVIDIA asset 残留清理(暂缓);#16 gunicorn 线程数(运维配置,暂缓)
|
||||
- 重分析进度:done 13 / pending 33(约剩 1h)
|
||||
|
||||
## Prompt 集中化重构(commit a72b286,用户提供详细设计文档)
|
||||
- 新增 fam-edge/src/fam_edge/ai_orchestrator/prompts.py:build_video_prompt(含 3 秒密度抽取/7 维度描述/people_mentioned 一致性强制/输出硬约束/边界情况/camera_name 注入)、build_chat_prompt、build_person_merge_prompt(唯一性+保守不合并);__init__ 导出
|
||||
- gemini/nvidia adapter._build_video_prompt 改调共享函数(消除两份发散),注入 camera_name
|
||||
- person_service._llm_merge 改调 build_person_merge_prompt
|
||||
- fam-core chat_handler 新增 prompts.py(跨模块独立维护,风格一致),删除内联 CHAT_SYSTEM_PROMPT,chat_ask 改调 build_chat_prompt
|
||||
- 部署验证:prompt 生成正常(3 秒密度/camera/唯一性均含);fam-edge active;fam-core health OK
|
||||
- 注意:3 秒密度可能让有人时段 events 数百条,Gemini maxOutputTokens=4096 可能截断——若出现 JSON 解析失败需提高 max_tokens
|
||||
|
||||
## 最新代码调试 + 人物标识清洗(commit 0d73c23)
|
||||
- 调试结果:服务全正常(fam-edge active、NAS core/ui 200、rclone timer active);重分析 done 26/pending 20;新 prompt 正常——有人视频 5-16 事件、无人时段(晚 19:31/凌晨 5 点)events=0 合理、无 JSON 解析失败
|
||||
- 发现并修复:模型输出 people 含 known_members 上下文格式串"人物A(别名/标识:人物B)"污染人物表 → prompts.py 明确"people 只填标识本身不带括号注释" + video_processor._clean_person 后端清洗(events.people 与 people_mentioned 均清洗)+ 清理存量脏 label(人物A(别名/标识:人物B)→ 人物A)
|
||||
- people 表现状:A/B/C/D 四标签,LLM 合并 B/C/D → canonical 人物A
|
||||
|
||||
## 前端日期标签与视频文件对不上(commit da76319)
|
||||
|
||||
**用户反馈**:前端显示"2026-08-21 · 画面静止/无人员活动",和下面的视频文件对不上。
|
||||
|
||||
**根因链**(排查发现):
|
||||
1. 前端事件时间轴/统计按 `processed_at`(**分析处理时间**)做日期分组——全量重分析都在 8/21 完成,导致 8/15~8/20 录制的视频全部堆在"2026-08-21"标签下,与文件名(录制时间)错位
|
||||
2. Oracle videos 表 8/21 11:29~12:59 被整表重建(created 全在此区间),重分析串行进行
|
||||
3. 20 个视频 event_start_time 为空:video 30-45 在 13:00~13:25 处理时服务器跑的是旧文件名解析代码(无纯数字正则 YYYYMMDD-HHMMSS),且被后续部署重启中断(pending);28/29 从未处理
|
||||
4. NAS 镜像状态与 Oracle 错位(35-45 NAS 显示 done、Oracle 实为 pending)——Oracle 重建后 NAS 增量未对齐
|
||||
|
||||
**修复**:
|
||||
1. **日期维度统一改视频实际录制时间**:fam-ui(事件时间轴统计/列表/关注事件统计)与 db_layer(get_sync_videos/get_sync_stats/query_sync_events_for_person_date)全部改用 `COALESCE(NULLIF(event_start_time,''), processed_at)`(录制时间优先,回退处理时间)
|
||||
2. **Oracle backfill**:对 12 个缺失 start 的视频按文件名解析回填(30/33-42/45)→ Oracle 46 个视频全部有 event_start_time
|
||||
3. **NAS 强制全量重同步**:删 sync_cursor last_since + 重启 fam-core → since='' 全量拉取 → EMPTY_START 20→0、状态与 Oracle 对齐(done 28/failed 4/pending 14)
|
||||
|
||||
**验证**:done 视频按录制日期分布 8/15:10、8/16:8、8/17:2、8/18:2、8/19:2、8/20:1、8/21:3;8/15/8/20 筛选正确;fam-ui 200 无报错。
|
||||
|
||||
**遗留**:Oracle 端 pending 14 个在队列继续串行处理(video 28 处理中);failed 4 个(24/25/26 等,Gemini 429)等 1h 重试间隔自动重试。
|
||||
|
||||
## Google 硬盘删除联动 + DB 摘要保留(用户需求确认,无代码改动)
|
||||
- **用户需求**:①谷歌删了甲骨文也删(文件层);②Oracle/NAS 数据库生成的摘要不能删(DB 层)
|
||||
- **验证结论**:
|
||||
- 文件层:rclone sync 本就是镜像语义(远程删→本地删),实测放临时文件→sync→Deleted:1 确认生效
|
||||
- DB 层:video_queue 生产者只扫描"目录存在的文件",文件消失不影响 Oracle videos/events/people 记录;NAS 镜像照常同步,前端摘要保留;截图接口 404 由前端 onerror 降级
|
||||
- **加固**:rclone_sync.sh 加 `--max-delete 200`(防 Google API 临时故障级联误删本地,单次最多删 200 个),脚本验证 exit=0、75 文件不受影响
|
||||
- 注意:当前是**单向镜像**(Google 为源→本地),不建议反向传播(本地删→谷歌删会误删原始监控),如需 rclone bisync 真双向需用户确认风险
|
||||
|
||||
## 实时服务状态界面 + 7 天活动记录(commit 0d0a7f6 + fec9a3e)
|
||||
- **Oracle**:service_activity 表(service/action/detail/ts,写入时清 7 天前);record_activity/get_recent_activities/get_queue_status;VideoQueue 打点(register/reanalyze/process_start/process_done/process_fail)+_current 当前处理跟踪+status();PersonService 打点(merge_done/merge_skip);api_gateway 新增 GET /api/oracle/activity(token 鉴权,返回 queue/db.by_status/rclone/person/model_calls/最近50条活动)
|
||||
- **rclone_sync.sh**:同步结果写 activity 表(service=rclone, transferred/deleted/exit);TRANS 提取正则修过一次(输出带 B 单位)
|
||||
- **fam-ui**:新增"🖥 服务状态"页(导航第7项):状态卡(队列运行/排队/完成/待处理/失败 + 当前处理视频 + rclone/人物/NAS同步/模型 4 张服务卡)+ 最近活动时间流(服务徽章),直连 Oracle /api/oracle/activity(复用 oracle_url+token)
|
||||
- **踩坑**:video_queue.status() 用 Dict 注解未导入 → worker 启动 NameError → 补 typing.Dict
|
||||
- 验证:接口数据正常(queue running/queued 19/当前 video 56;rclone sync_done/person merge_done 打点生效;401 鉴权;NAS→Oracle HTTP 200);fam-ui 200
|
||||
|
||||
## 模型调用时间差 8 小时修复(commit 02fc80b)
|
||||
- **根因**:gemini/nvidia adapter 的 model_calls.started_at 用 `datetime.now()`(Oracle 服务器 UTC),而 created_at 用 _now_iso()(北京时间)→ 前端模型统计/服务状态页时间差 8h
|
||||
- **修复**:两个 adapter started_at 改 `datetime.now(timezone(timedelta(hours=8)))`;Oracle 历史 215 条 +8h;NAS 镜像 sync_model_calls 210 条 DATE_ADD +8h
|
||||
- 验证:最新记录 started 16:05:02 与 created 对齐(北京时间)✓
|
||||
|
||||
## 人物管理无照片修复(commit 27bfd6a)
|
||||
- **根因**:NAS sync_events 残留重分析前旧事件(223 条 vs Oracle 115 条,id 1-108 旧事件未删除——#8 删除传播未做的副作用)。人物页头像查 `ORDER BY e.id LIMIT 1` 取到旧事件 id=1 → Oracle ev_1.jpg 不存在(重分析后事件从 109 起)→ 404 → onerror 隐藏 → 无照片
|
||||
- **修复**:① NAS 清空 sync_events + 重置 cursor 全量重拉 → 精确 115 条镜像;② api_gateway event_thumb 兜底:ev 缺失时查所属 video 返回 thumbs/{video_id}.jpg(视频首帧);③ 清理 Oracle people 脏 label(人物A(别名/标识:人物B)→人物A)
|
||||
- 验证:NAS→Oracle ev109 HTTP 200/51KB ✓;人物页查询到的都是新事件 id → 截图存在
|
||||
|
||||
## 人物头像改为"出现事件画面"(commit 2ba3478,用户否决视频首帧方案)
|
||||
- **用户意见**:不能用视频首帧当头像(首帧可能无人/非本人)——该人物在众多视频中多次出现,肯定能在他出现的事件里找到画面
|
||||
- **新方案**:Oracle 新增 GET /api/oracle/person/avatar?label=X(token 鉴权):events.person_list_json 按 `%"label"%` JSON 数组精确匹配,按 id DESC 遍历返回第一个 ev_{id}.jpg 存在的截图(人物出现事件中最新的有截图画面);撤销 event_thumb 的视频首帧兜底(改回 404)
|
||||
- **fam-ui 人物页**:头像改调 avatar 接口(先 requests 探测 200 再渲染 img,label 用 urllib.parse.quote 编码)
|
||||
- 验证:人物A/B avatar 均 200(39-41KB 真实事件画面)、无 token 401、NAS→Oracle 200、UI 200
|
||||
|
||||
## Oracle 磁盘扩容(用户控制台扩盘 + 重启生效)
|
||||
- **背景**:rclone 持续同步视频导致 45G 盘写满(剩 46M,gdrive_videos 31G/75 个视频)。用户说已扩容但服务器 lsblk 一直 46.6G(在线未生效)
|
||||
- **处理**:growpart/resize2fs 均 NOCHANGE(块设备没变);临时清理 /tmp 残留+apt+journal 释放 ~900M;禁用 rclone timer 防写满;按用户要求重启服务器
|
||||
- **重启后扩容生效**:sda 46.6G→**150G**,Ubuntu cloud-init 开机自动扩展分区+文件系统 → df 146G 可用 90G(39%)
|
||||
- **恢复**:enable --now rclone-sync.timer;同步恢复(视频 75→121 个持续下载中);fam-edge active
|
||||
- 经验:Oracle 在线扩容偶尔不立即生效,重启可触发(Ubuntu 自动 growfs);扩容后无需手动 growpart
|
||||
- 提醒:视频持续增长(121 个已占 56G),150G 约可再装 240 个视频,长期需考虑清理策略(如只保留分析完的摘要+删本地视频,需改 rclone 策略避免重新拉回)或再扩盘
|
||||
|
||||
## 人物模块重构 v3 - 大模型特征值替代 OpenCV(commit ff01d14 + 68337f8 + 677c5bd,用户提供设计文档)
|
||||
- **核心**:prompt 增加 person_appearances(uid+7特征+action),VLM 直接产出结构化特征,跨视频靠特征合并,彻底移除 cv2
|
||||
- **改动**(12 文件):prompts.py(6原则+schema+规则8条+合并prompt重写)、gemini _normalize 透传、oracle_db(events.person_appearances_json/people.features_json/display_uid + _merge_features + upsert_person/mark_video_processed)、video_processor(validate_video 改 ffprobe 去 cv2、_store_result 聚合 uid 特征落 people、删 thumb/event_thumbs)、person_service(_aggregate_features/_collect_features_text 特征文本合并)、api_gateway 删 3 图接口、NAS db_layer+ddl 加字段、fam-ui 特征卡替代头像+事件人物特征块
|
||||
- **踩坑 2 个**:
|
||||
1. max_tokens 4096→16384:3秒密度+特征使 JSON 巨大被截断解析失败
|
||||
2. **json_parser.validate_schema 白名单丢弃 person_appearances**(在 _normalize 之前执行)→ 补透传
|
||||
- **验证**:video 46 重分析 → lite 输出 16 事件含完整特征(性别男/中年/中等/短发/蓝色POLO衫/无辨识);events 带特征 6、people.features_json 落库;NAS 同步 6 条+人物A特征卡数据 ✓
|
||||
- 部署:Oracle 卸载 opencv/numpy(ffprobe 已有);NAS ALTER 加 3 列
|
||||
- 遗留:NVIDIA 模型链仍不可用(gemini 429 时 lite 兜底);历史视频无特征(前端显示"特征待大模型补充",下段分析自动补)
|
||||
214
.workbuddy/memory/2026-08-22.md
Normal file
214
.workbuddy/memory/2026-08-22.md
Normal file
@@ -0,0 +1,214 @@
|
||||
# 2026-08-22
|
||||
|
||||
## 核查 SS Webhook 配置字段(sentinel-home-ai)
|
||||
- 本机 SS 版本 9.3.0-12139(≥9.1.1),Webhook 动作设备可用。
|
||||
- 关键发现:**SS Webhook 与 motion_bp.py 接收端 schema 不匹配**。
|
||||
- SS Webhook 是「用户自定义参数名 + 模板变量」机制,Motion Detection 支持:
|
||||
%EVENT_TIME%(字符串,非 epoch)、%DEVICE_NAME%(摄像头名文本,非 camera_id)、
|
||||
%EVENT_NAME%、%SERVER_NAME%、%THUMBNAIL_URL%。每事件单次 POST,body 为 form/JSON 的 key=value。
|
||||
- 接收端 motion_bp.py 期望 EventCenter.Event.List 风格:events:[{event_id(int 主键),
|
||||
camera_id, event_type=10, start_time(epoch), duration, thumbnail_url}]。
|
||||
- 因 Webhook 无法提供 event_id,`record_motion_events` 中 `if eid is None: continue`
|
||||
导致真实 webhook 事件 100% 被静默丢弃。
|
||||
- 结论:轮询路径(MotionNotifier._fetch_events 走 EventCenter.Event.List)是唯一可靠数据源,
|
||||
已端到端验证。Webhook 如需启用,须改接收端做字段映射 + 与轮询去重(待用户拍板)。
|
||||
|
||||
## Webhook 驱动改造(去轮询)—— 已完成并端到端验证
|
||||
- 用户决策:**不要轮询,改用 SS Webhook 作为唯一数据源**。
|
||||
- 改造内容(commit ca6d425 / e52d8e9,已 push Gitea + 部署 NAS):
|
||||
- `motion_notifier.py`:`poll_enabled=false`(默认关),关闭轮询线程;新增
|
||||
`refresh_camera_map()`(启动时一次性从 SS Camera API 拉 name→id 映射)、
|
||||
`resolve_camera_id()`、`parse_ss_time_to_epoch()`(EVENT_TIME 字符串→epoch,按 NAS +8)、
|
||||
`_synth_event_id()`(device_name+event_time+thumbnail_url 哈希成稳定 int,幂等去重)、
|
||||
`build_event_from_webhook()`。
|
||||
- `motion_bp.py`:`/api/ss/webhook` 解析 JSON/表单、单条/数组,用 build_event_from_webhook
|
||||
映射后推送甲骨文;SS 端需按固定参数名配置(event_time/device_name/event_name/server_name/thumbnail_url)。
|
||||
- `config.yaml`:加 `camera_name_to_id: {"Generic_ONVIF-001": 2}` 兜底,删原 camera_ids 轮询项。
|
||||
- `app.py`:启动加载摄像头映射;`start_core.sh` 修正 APP_DIR 路径 bug 并显式 source .env。
|
||||
- 验证:模拟 JSON + 表单 + 重复推送,均 pushed=1;甲骨文 ss_motion_events 落库正确
|
||||
(camera_id=2, event_type=10, start_time epoch 北京时区对齐);`has_motion_in_range_local`
|
||||
窗口命中 True / 远离 False;测试数据已清理。
|
||||
- **待用户操作**:在 SS「行動規則 → 事件=偵測到動作 → 動作=Webhook」配置,URL 填
|
||||
`http://127.0.0.1:8000/api/ss/webhook`,参数名严格用 event_time/device_name/event_name/
|
||||
server_name/thumbnail_url(值分别对应 %EVENT_TIME%/%DEVICE_NAME%/%EVENT_NAME%/%SERVER_NAME%/%THUMBNAIL_URL%)。
|
||||
- 注意:甲骨文 ss_motion_events 现存 21 条为历史轮询真实事件(2026-08-22 上午),轮询已停,不再新增。
|
||||
|
||||
## 「webhook→回查 SS 事件列表补全」链路测试(不写生产代码)
|
||||
- 用户要求先测清整条链路再决定是否实现。测试结论:**思路完全可行,各段均真机验证通过**。
|
||||
- **链路 A(SS 事件列表真实字段)**:NAS 上 `SYNO.SurveillanceStation.EventCenter.Event` method=List
|
||||
(camera_ids=2, event_types=10)真实返回 id/start_time/duration/thumbnail_url/thumbnail_dir。
|
||||
实测 e.g. id=25536 dur=63s、id=25535 dur=95s、id=25538 dur=0。即 duration 真实存在(短动作 0~几秒,长动作数十秒)。
|
||||
- **链路 A2(webhook 触发时刻→补全匹配)**:模拟 webhook 触发时刻=事件 start_time(epoch),
|
||||
用 ±120s 窗口找时间最接近事件,diff=0 精准匹配,补全出真实 event_id/start_time/duration/thumbnail。
|
||||
(注:真实 %EVENT_TIME% 是分钟级字符串,解析为 epoch 与真实 start_time 差 ≤ 数秒,120s 窗口足够覆盖。)
|
||||
- **链路 B(补全结果推甲骨文落库)**:POST /api/ss/motion(body 含 token + events[{
|
||||
event_id,camera_id,event_type,start_time,duration,thumbnail_url}])均 stored=1;
|
||||
甲骨文库 ss_motion_events 字段核对正确(event_id=25535 dur=95 / 25536 dur=63)。
|
||||
- **链路 C(预过滤)**:has_motion_in_range_local 窗口含事件=True、远离时段=False,逻辑正确。
|
||||
- 全部测试数据已清理,库恢复 21 条历史真实事件。
|
||||
- **未真机验证的一环**:SS 后台尚未配 Webhook,故「真实 webhook 触发」这步是模拟(用真实 start_time 当 trigger)。
|
||||
其余 SS 查列表 / 推送甲骨文 / 落库 / 预过滤均为真机。
|
||||
- 下一步若实现:在 motion_notifier 加 enrich_event_from_ss(),webhook 收到后本机回查 SS 列表补全真实字段。
|
||||
|
||||
## 「能否输出对应运动视频」调研——可行,直接读录像文件 + ffmpeg 裁剪
|
||||
- 用户问运动事件能否输出对应视频片段。结论:**完全可行,且已真机裁剪出 22s MP4 演示**。
|
||||
- **SS API 导出路径全部不可用(SS 9.3.0-12139)**:
|
||||
- `Recording.Export` 仅剩向导方法(CamEnum/CheckName/Save/Load/Delete,EventArchive.js),Save 需复杂参数(431),
|
||||
非旧文档的 RangeExport(9.3 已无此方法,实测 103)。
|
||||
- `ThirdParty/Recording/Download/v1`(camId/startTime/endTime ISO 字符串)返回 401(需第三方授权,不可用)。
|
||||
- **关键发现:录像文件明文直读**(无需任何 API):
|
||||
- 路径:`/volume1/surveillance/<摄像头名>/<YYYYMMDDAM|PM>/<摄像头名>-YYYYMMDD-HHMMSS-<起始epoch毫秒>-<seq>.mp4`
|
||||
- 每 30 分钟一个片段(约 377MB),h264 2880x1620@14.28fps + pcm_alaw(8kHz) 音频;
|
||||
文件名含起始 epoch 毫秒,可用 (camera_id, start_time) 直接定位(camera_id→目录名需 SS Camera API 映射)。
|
||||
- recording_encrypt.db 存在但当前录像为明文(未启用加密)。
|
||||
- **裁剪验证(真机)**:事件 id=25535(start=1787369601)落在
|
||||
`Generic_ONVIF-001-20260822-110859-1787368139533-7.mp4`(11:08:59 起 30min),偏移=1787369601-1787368139=1462s。
|
||||
`ffmpeg -ss 1462 -t 20 -i 片段 -an -c:v copy` → 22.13s / 4.4MB MP4(copy 模式从关键帧起,±秒级偏差;需精确可重编码)。
|
||||
**必须 -an**:pcm_alaw 音频无法封装进 mp4 容器,-c copy 会报 "Could not find tag for codec pcm_alaw"。
|
||||
- 注意:NAS ffmpeg 为老版本(/usr/bin/ffmpeg,不支持 -show_entries,用 `-i 2>&1 | grep Duration` 查时长)。
|
||||
- 下一步若实现:motion 事件补全 start_time/duration 后,NAS 端按 (camera, start) 定位片段 + ffmpeg 裁剪输出。
|
||||
|
||||
## Webhook vs 事件列表一致性核查(重要认知)
|
||||
- 用户质疑:Webhook 与事件列表是否 1:1?事件列表有的会不会 webhook 没推?
|
||||
- 实测(NAS):**ActionRule List = 0 条**(SS 后台尚未配任何行动规则 → 真实 webhook 零推送);
|
||||
今日 camera2 运动事件 = 141 条(id 25405~25545),事件稀疏突发(35 个 >120s 断档,最长 4h 无动作)。
|
||||
- 机制结论:**Webhook ⊂ 事件列表,不是 1:1**。EventCenter 是全量事件记录;行动规则引擎按
|
||||
「摄像头范围/时间段/去抖合并/开关/规则数」过滤后才触发 Webhook。故**事件列表有、webhook 没推
|
||||
是完全可能的**,纯 Webhook 驱动存在漏事件 → 预过滤误杀风险。
|
||||
- 反方向(webhook 有、列表无):罕见,仅事件落库毫秒级时序,±120s 窗口 + fallback 已兜底。
|
||||
- 对策待用户拍板:A. Webhook 实时 + 每 5 分钟增量对账补推(推荐,对账非事件轮询,只补差异);
|
||||
B. 接受漏风险(规则配全量摄像头+关去抖);C. 回轮询(用户已否决)。
|
||||
|
||||
## Webhook 实现审查 + 用户最终决策:恢复轮询主路径(commit 7adc324,已部署验证)
|
||||
- Webhook 实现审查发现 P0/P1/P2 问题:①合成 event_id 碰撞(同分钟多条事件若 thumbnail 为空 → 同 id 被
|
||||
UNIQUE 吞掉);②推 Oracle 失败无重试直接丢;③无对账兜底(Webhook⊂事件列表);④SS Webhook 请求格式
|
||||
从未真机验证(行动规则 0 条);⑤时间解析失败伪造为 now(UTC) 混时区;⑥camera_id=None 照常入库。
|
||||
- **用户拍板:放弃 Webhook,恢复轮询(简单稳定)**。改动:
|
||||
- motion_notifier.py:poll_enabled 默认 true;_fetch_events limit 100→1000;
|
||||
_init_cursor 优先续用 DB 游标(重启补推停机期间事件),DB 空才初始化为 SS 当前最大 id;
|
||||
_poll_once 仅推送成功批次才前进游标(失败批次下轮窗口回看重试,不丢事件);
|
||||
docstring 改为轮询主路径说明。
|
||||
- config.yaml:poll_enabled: true,加 camera_ids: [2],注释同步。
|
||||
- motion_bp.py:docstring 改为"Webhook 可选补充,非主路径"(端点保留)。
|
||||
- **端到端验证(真机)**:重启后游标续用 DB=25491 → 第一轮补推 55 条停机期间事件(25492~25546)、
|
||||
第二轮增量 2 条(25547/25548);Oracle ss_motion_events 总数 21+55+2=**78**,event_id 全为真实 SS id
|
||||
(25471~25548),duration 真实分布(0~31s);/api/ss/status:poll_enabled=true running=true
|
||||
heartbeat_running=true pushed_total=57。
|
||||
- 结论:轮询路径现在简单稳定、不丢事件(重启补推 + 失败重试 + 真实字段),Webhook 降级为可选。
|
||||
|
||||
## 运动事件驱动架构 v3(commit a1523b4/8d6cfad/ffcc292,已部署验证)
|
||||
- 用户决策:**不再处理整段视频**;timeline 事件轴/人物管理/统计全部用"运动时间处理后的数据"。前端不改。
|
||||
- 澄清:rclone 整段素材**必须保留**(在甲骨文按运动时间分割);音频**保留转码**(aac)。
|
||||
- **实测纠正认知**:SS 事件 duration=0 只出现在动作进行中(查询时未结束),结束必为正数
|
||||
(id=25538 11:38 查 dur=0,12:13 查 dur=2;近 1h 43 条无一条已结束且 dur=0)。
|
||||
故分割只处理已结束事件(start+duration ≤ now+grace 10s),进行中的下轮再分割。
|
||||
- **Oracle 端改动**:oracle_db 加 motion_event_id/camera_id 列 + get_motion_events_in_range/
|
||||
has_unfinished_motion_in_range/get_video_by_motion_event_id;video_processor 素材→分割/片段→分析
|
||||
双分支(ffmpeg -c:v copy -c:a aac 保留音频,motion_event_id 幂等);video_queue 片段入队;
|
||||
config 加 motion_segment 块(clips_dir=/opt/fam-edge/motion_clips)。
|
||||
- **E2E 真机验证**:素材 327(11:08:59)→ 分割 35 段(event_id 25504-25538,dur 1-132s)→ 片段只分析
|
||||
(summary 真实:媳妇/爷爷/汤圆)→ events 绝对时间 ts → NAS 同步 → /api/ui/videos 显示 motion_ 片段
|
||||
(前端契约字段全在)→ /api/ui/videos/<id> 详情 events+people 正常 → /api/proxy/frame 帧图 200。
|
||||
- **踩坑**:① sqlite3.Row 无 .get()(vrow 访问用下标);② ffmpeg subprocess args 首元素必须是可执行
|
||||
文件(漏了 ffmpeg 路径 → "No such file or directory: '-y'");③ **Oracle fam-edge 由 systemd
|
||||
fam-edge.service 守护(Restart=always)**——部署后必须 `sudo systemctl restart fam-edge`,手动
|
||||
setsid 启动会和守护打架(端口冲突 Connection in use)。
|
||||
- 旧整段分析的历史数据(319 个 done 素材)保留展示;新素材按新逻辑分割。
|
||||
|
||||
## 清理旧数据重提取 + 人物管理按运动视频重设计(commits 4cf4fc4/6a29e88/9664459)
|
||||
- **用户决策**:甲骨文+NAS 后台数据全删(整段视频提取的旧结果 + 人物数据),按新框架运动视频重新提取。
|
||||
- **清理**:Oracle videos(364)/events(1687)/people(44)/model_calls/service_activity 全清 + motion_clips 目录清空
|
||||
(保留 ss_motion_events 93 条 + 素材文件 + 配置;先备份 oracle.db.bak);NAS sync_videos/sync_events/sync_people/sync_cursor 全清。
|
||||
- **重提取**:systemctl restart fam-edge → producer 重新登记全部素材 → 分割(8/15-21 素材无运动事件→0 段;
|
||||
8/22 素材→运动片段)→ 片段分析 → 重新聚合人物(3 身份:爷爷/汤圆/媳妇)。
|
||||
- **问题**:历史素材分割 0 段会以"空会话"占满时间轴 → db_layer.get_sync_videos/get_sync_stats 加内容过滤
|
||||
(LEFT(filename,7)='motion_' OR EXISTS 有事件),前端契约不变。
|
||||
- **人物管理重设计**:PersonCard.vue 新增「运动片段」区块(缩略图/时间/摘要/事件数,点击跳 /timeline?video=);
|
||||
后端新增 GET /api/ui/people/clips?label=(db_layer.get_sync_people_clips 按 label/canonical_name 匹配
|
||||
person_list_json → 关联运动片段,含 first_ts/clip_events);Timeline.vue 支持 ?video= 定位。
|
||||
- **踩坑**:pymysql execute 用 % 做参数占位符,SQL 字面量含 'motion_%' 的 % 会报
|
||||
"unsupported format character" 500 → 改用 LEFT(filename,7)='motion_'。
|
||||
- 验证:/api/ui/videos 全为 motion_ 片段(15 条,含重新提取的 12:34 等新片段);people 3 身份;
|
||||
people/clips 返回媳妇 3 个片段。前端 dist 已构建部署(fam-ui/dist 不入库,构建产物单独 tar 部署)。
|
||||
|
||||
## 代码-文档一致性整理(commits 354ff18/01a8ac2,已 push)
|
||||
- README 全量同步 v3:2.1 网络要点(轮询主路径/Webhook 可选);2.2 拓扑图(Streamlit→Vue3、Video-Queue/分割);
|
||||
3.1-3.3 模块表(MotionNotifier poll 主路径+游标/心跳、UI-API、people clips、Person-Service);4.1 表清单
|
||||
(videos 加 motion_event_id/camera_id、ss_motion_events、sync_cursor 双游标);5.1-5.2 API 表(/api/ss/motion、
|
||||
/api/oracle/frame|avatar、/api/ui/people-clips、/api/ui/* 全表、/api/proxy/*);5.3 片段直传;6.1 历史标注;
|
||||
8.1-8.4 部署(systemd fam-edge、start_core.sh source .env、npm build、motion_segment config、数据清理指引);
|
||||
9 快速开始;12 进度。
|
||||
- docs/DEPLOY.md 重写为 v3(Vue3 构建/部署、systemd、tar 管道、验证清单含 motion_clips)。
|
||||
- 删除过时 scripts/start_ui.sh(Streamlit);start_edge.sh 注明 systemd 为准。
|
||||
- 补提交 fam-core/tests/test_motion_notifier.py。
|
||||
- 残留扫描:README 仅剩"不再依赖 Streamlit/从 Streamlit 迁移"正确表述。git 工作区干净(仅 .workbuddy/ 记忆)。
|
||||
|
||||
## 服务状态页「视频分割」状态卡(commit cbcc5d0,已部署)
|
||||
- 用户需求:服务状态界面要能看到视频分割状态(前端口误选方向后澄清)。
|
||||
- Oracle:oracle_db.get_segment_status()(motion_event_id 片段 total/done/pending/failed + ss_motion_events 事件数 +
|
||||
最近 segment 活动);api_gateway /api/oracle/activity 加 segment 字段 + clips_files(motion_clips 目录文件数)。
|
||||
- NAS:/api/ui/service-status 代理自动透传 segment(前端零后端改动)。
|
||||
- 前端:ServiceStatus.vue 新增「视频分割」ServiceCard(运动片段 done/total、文件数、待处理/失败、运动事件数、最近分割活动)。
|
||||
- 验证:Oracle activity.segment 返回 {total:75, done:54, pending:21, failed:0, clips_files:75, motion_events:99};
|
||||
NAS 代理 service-status 透传正常;前端 dist 已构建部署。
|
||||
|
||||
## 事件-分割一致性保障 + 历史事件回灌(commit 177c2a8)
|
||||
- **用户质疑**:Oracle 的 ss_motion_events 是否=SS 全部事件?为何没有 8/22 之前的?
|
||||
- **实测**:SS 事件中心保留 8/15 起全部运动事件(按天 191/47/256/419/334/256/312/196 ≈ 2000 条);
|
||||
Oracle 只有 8/22 起的——NAS 游标初始化故意"不回灌历史",属设计缺陷。
|
||||
- **修复 1(一致性缺口)**:SS 事件在动作进行中 duration=0,NAS 首次推送后游标前进不再重推 →
|
||||
Oracle duration 永久 0 → 分割过滤跳过 → 事件丢失(此前被"重启重推"掩盖)。MotionNotifier 加
|
||||
`_zero_dur_ids`:推送过 duration<=0 的事件记入,下轮窗口回查已结束(duration>0)补推覆盖。
|
||||
- **修复 2(历史回灌)**:NAS 一次性脚本拉取 8/15 00:00~8/22 00:00 的 SS 运动事件(分 6h 段)→
|
||||
POST /api/ss/motion 幂等落库,**1815 条全部入库**(与按天统计吻合);Oracle ss_motion_events 达
|
||||
1947 条(min event_id=1);重置 304 个 8/15-21 已 done 素材为 pending → 重启 Oracle 重新分割,
|
||||
历史运动片段持续生成(60s 内已 404 片段)。
|
||||
- **一致性保障清单**:①事件全覆盖(SS 全量→Oracle 幂等,回灌兜底);②duration 真实(0→补推);
|
||||
③游标续用/失败批次不前进(重启补推、不丢);④分割按 event_id 幂等(motion_event_id 关联);
|
||||
⑤start_time 对齐(SS epoch ↔ 素材文件名解析整秒,±0.5s 素材毫秒偏差可接受)。
|
||||
- **注意**:历史 1800+ 片段分析需大量 Gemini 配额(max_concurrent=1 + 429 重试),会排队跑很久。
|
||||
|
||||
## 事件-片段一致性对账(commit 1dd9df3,已部署)
|
||||
- 用户重复追问"如何保证 SS 事件与分割视频一致(含 start/duration)"→ 补可验证的对账机制。
|
||||
- oracle_db.get_segment_consistency():event_total(SS 全量 1947)/ finished(已结束可分割 1922)/
|
||||
segmented(已分割去重 1793)/ gap_count(缺口 129,动态收敛中)/ gaps 明细(event_id/start_time/duration)/
|
||||
material_range(素材覆盖 8/15 10:31~8/22 12:39)。
|
||||
- api_gateway activity.segment.consistency;前端服务状态页分割卡展示"一致性:事件/已结束/已分割/缺口"。
|
||||
- **缺口解读**:129 缺口主要是素材仍在队列处理(今天 13:0x 事件 + 少量历史 1222-1226),随分割收敛;
|
||||
真正无法分割的(素材窗口外/素材失败)会持续显示,可据此排查。
|
||||
- 一致性三层:数值(SS 原样+DO UPDATE+duration=0 补推)/数量(motion_event_id 幂等 1:1+对账缺口)/
|
||||
文件(-ss offset -t dur,±0.5s 毫秒取整、±1-2s 关键帧对齐)。
|
||||
|
||||
## 人物管理数据彻底清除(用户要求,无代码改动)
|
||||
- 用户:"人物管理全是错的(历史数据原因),人物相关数据都删除,相关的都做下删除"。
|
||||
- **删除范围**:Oracle people 表(9)、videos.people_json(465)、events.person_list_json(683)/person_appearances_json(640);
|
||||
NAS sync_people(7)、sync_videos.people_json(397)、sync_events 两字段(424/416)。备份 oracle.db.bak.20260822_055139。
|
||||
保留 videos(2124)/events(683)/ss_motion_events(1947)/chat_history。
|
||||
- **关键发现**:重启 Oracle 后 producer 会重新分析 **1646 个历史(8/15-21) pending 片段**并立即产生新人物
|
||||
(爷爷/人物A/人物B…),删了还会长 → 必须同时停掉历史片段分析:把 event_start_time < '2026-08-22 00:00:00'
|
||||
的 pending 片段标记 **skipped_reset**(不再分析、不产生人物),只保留 8/22 当天新运动片段分析提取人物。
|
||||
- 重启后 Oracle 正以 known_members=无 分析今日新片段 motion_25579(13:10 事件)→ 人物管理从今日数据重新提取
|
||||
(当前仅 1 个未命名"人物A")。验证:/api/ui/people=1 group、named-members=空、视频详情 people_json=None。
|
||||
|
||||
## 外网访问 8000 + 登录校验(commits 9c51c65/aca1a67,已部署)
|
||||
- 需求:NAS :8000 参考 3000 端口(Gitea)通过外网访问,且先登录才能访问(账号 ericwyuan / 密码 iLoveJava5)。
|
||||
- **外网机制(已查明)**:NAS 跑 frpc(/etc/frp/frpc.toml,S99frpc.sh 守护)→ 甲骨文 129.146.203.203:7000(frps)。
|
||||
已有 gitea(外3000→NAS3000)、wordpress(外8500→NAS8088)。新增 fam-core proxy:外网 **8000→NAS 8000**,
|
||||
S99frpc.sh restart 生效(proxy added: [fam-core gitea wordpress])。甲骨文安全组 8000 已放行(实测 200)。
|
||||
- **登录校验(新模块 auth.py)**:POST /api/login(凭据 FAM_AUTH_USER/FAM_AUTH_PASS,NAS .env 默认 ericwyuan/iLoveJava5)、
|
||||
/api/logout、/api/auth/check、GET /login 内置深色登录页(SPA 零改动);init_auth(app) 全局 before_request:
|
||||
页面未登录 302 /login、/api/* 未登录 401;白名单免登录:/login /api/login /api/logout /api/auth/check /health
|
||||
/api/ss/webhook /assets/* /favicon.ico。登录态=进程内 token+HttpOnly cookie(7 天),重启 fam-core 需重新登录。
|
||||
- **验证(真机)**:内网+外网全链路:未登录 302→/login、登录页 200、API 401、错误密码 401、正确登录 200+cookie、
|
||||
带 cookie 访问 /api/ui/videos 200(15 条运动片段)、/api/auth/check authed true/false、SS webhook 免登录 200。
|
||||
外网 http://129.146.203.203:8000 完整流程通过。测试注入的假 webhook 事件已清理(ss_motion_events 1947)。
|
||||
- **注意**:SS Webhook(/api/ss/webhook)是唯一免登录入站端点(SS 无法带登录态),外网暴露后有被滥发假事件的
|
||||
风险,如介意可后续给 webhook 加独立 token 或 frp secretKey;HTTP 明文传输,HTTPS 需另配。
|
||||
|
||||
## 恢复全部历史片段分析(用户决策,无代码改动)
|
||||
- 用户:"所有分割后没有处理的运动视频都需要处理" → 把之前删人物数据时标记的 **1646 个 skipped_reset 历史片段
|
||||
(8/15-21)全部改回 pending**(清空分析结果字段,文件齐全 0 缺失)。
|
||||
- 机制:video_queue.start() 的 `_enqueue_existing()`(get_pending_videos limit=10000)重启时把 pending 全部补入队;
|
||||
片段走 process_video 片段分支直接分析(motion_event_id 非空,local_path→motion_clips)。
|
||||
- 结果:systemctl restart fam-edge → 日志"启动恢复入队 1646 个待处理视频",queued=1645 + current=1(motion_25148,
|
||||
8/21 09:33 事件),从 8/21 往 8/15 顺序处理。单并发 + Gemini 429 重试,预计跑数小时~十数小时。
|
||||
20
.workbuddy/memory/2026-08-25.md
Normal file
20
.workbuddy/memory/2026-08-25.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# 2026-08-25 工作日志
|
||||
|
||||
## 前端迁移收尾 + 登录故障修复
|
||||
- **前端最终落点**:云服务器 129.146.26.249 的 Caddy :80(`http://129.146.26.249/`),
|
||||
SPA 静态在 `/var/www/fam-ui`,`/api/*` 经 frp 隧道反代回 NAS fam-core :8000。
|
||||
**注意:新服务器 :8000 是 frp 隧道口(直通 NAS API),不是前端入口**,登录请用 :80。
|
||||
- **登录失败根因**:重启 fam-core 时 FAM_AUTH_USER/FAM_AUTH_PASS 未固化进 .env →
|
||||
auth.py fail-closed(未配置即拒绝所有登录)→ 全部 401。已补进
|
||||
`/volume1/web/sentinel-home-ai/.env`(5 键:ORACLE_SYNC_TOKEN/DSM_ACCOUNT/DSM_PASSWORD/
|
||||
FAM_AUTH_USER/FAM_AUTH_PASS=ericwyuan/iLoveJava5)并重启,登录恢复 200。
|
||||
- **旧 IP 清理**:本地仓库 config.yaml(4 处)+ chat_handler/motion_notifier/oracle_sync
|
||||
兜底默认值 + docs/DEPLOY.md + PROGRESS.md 共 6 文件旧 IP 129.146.203.203 → 129.146.26.249,
|
||||
已清 __pycache__。仓库/部署/README 三处一致。**未提交**。
|
||||
- 孤儿云库 sentinel_home_ai + famcore 用户已 DROP(新服务器)。
|
||||
- SS Webhook:代码中为可选补充(motion_bp.py 文档明确),SS 未配行动规则故休眠,
|
||||
是否删除待用户拍板。
|
||||
- **用户拍板:删除 webhook + NAS 前端托管,并已提交推送**(两个 commit 已 push 到 Gitea:
|
||||
`7948f55` docs 迁移文档、`cf3d145` refactor 移除 webhook/static_app 共删 329 行)。
|
||||
**待办:代码尚未部署到 NAS**——需同步 fam-core 到 NAS、删 NAS fam-ui 目录 + start_ui.sh、
|
||||
重启 fam-core 验证 /api 与登录(云端 :80 前端不受影响)。
|
||||
37
.workbuddy/memory/2026-08-29.md
Normal file
37
.workbuddy/memory/2026-08-29.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# 2026-08-29 工作日志
|
||||
|
||||
## 修复 Oracle→NAS 增量同步 1062 (uq_video_raw_uid 重复键) — 已部署验证
|
||||
|
||||
### 现象
|
||||
- 同步状态页:状态"同步中"、`最近 —`、`本次增量 —`、游标卡在 `2026-08-29 04:57:15`
|
||||
- 报错:`(1062, "Duplicate entry '1073-汤圆' for key 'uq_video_raw_uid'")`,游标永不推进
|
||||
|
||||
### 根因(两层)
|
||||
1. **镜像表主键设计缺陷(主因)**:`sync_identity_map` 以 Oracle `person_identity_map.id` 作主键。
|
||||
Oracle 端 id 是 AUTOINCREMENT 代理键,但 Oracle 库重建/恢复后 id 会被复用——实测 Oracle
|
||||
现在的 id=744 是 `(1073,'汤圆')`,而 NAS 上 id=744 还是旧的 `(1302,'人物E')`。旧 upsert 的
|
||||
`ON DUPLICATE KEY UPDATE` 先按 **PK id** 命中旧行,再把它 UPDATE 成 `(1073,'汤圆')`,与
|
||||
已有的 `(1073,'汤圆')` 行在 `uq_video_raw_uid` 上二次冲突 → 1062 → 整批失败 → 游标不推进
|
||||
→ 每 30 分钟重复失败。
|
||||
2. **陈旧 __pycache__(部署陷阱)**:NAS 上 `.pyc` 时间戳比 `.py` 新(部署时 `.py` 带旧 mtime 拷贝),
|
||||
Python 信任 pyc 加载了旧逻辑;另曾有两个 gunicorn master 并存(8487/10914),只有一个绑 8000。
|
||||
|
||||
### 修复
|
||||
- **db_layer.py `upsert_sync_identity_map` v2**:镜像表改为 NAS 本地自增 `id` 主键,
|
||||
业务键 `(video_id, raw_uid)` 唯一;Oracle id 只落 `oracle_id` 列溯源;UPDATE 子句不再改写
|
||||
video_id/raw_uid(只更新 oracle_id/canonical_name/source/updated_at/synced_at)。
|
||||
- **scripts/ddl.sql**:同步更新表定义(`id INT AUTO_INCREMENT PRIMARY KEY` + `oracle_id INT`)。
|
||||
- **现网迁移**:`ADD COLUMN oracle_id` → 回填 `oracle_id=id` → `MODIFY id AUTO_INCREMENT`(无重复对,
|
||||
迁移安全);修复早前测试误改的 3061 行 canonical_name。
|
||||
- **部署**:db_layer.py 用 stdin 管道覆盖 NAS;**清空全部 __pycache__**;杀掉双 gunicorn,单实例重启。
|
||||
|
||||
### 验证(通过)
|
||||
- 09:55 一次补拉成功:`videos+260 events+1456 people+37 model_calls+744 identity_map+285`,游标推进到 09:55:06
|
||||
- `/api/status`:`last_error=null`、`last_sync_at` 有值;手动 `/api/sync/trigger` ok(拉 2videos/2events/1people/6calls/1identity)
|
||||
- `(1073,'汤圆')` 行已自愈:`oracle_id=744`;NAS 旧 `(744→1302,'人物E')` 保留不冲突
|
||||
|
||||
### 待办/注意
|
||||
- 本地 repo 有未提交改动:`fam-core/src/fam_core/db_layer.py`、`scripts/ddl.sql`(等用户拍板提交)
|
||||
- 同类隐患:sync_videos/sync_events/sync_model_calls 同样以 Oracle id 为主键,但 Oracle 侧这些表
|
||||
只插入不 UPDATE、id 稳定,风险低;若将来 Oracle 也重建,需同样改法
|
||||
- NAS 部署后必须清 __pycache__(或 touch .py),否则旧 pyc 被加载(已踩坑)
|
||||
43
.workbuddy/memory/2026-08-31.md
Normal file
43
.workbuddy/memory/2026-08-31.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# 2026-08-31 工作日志
|
||||
|
||||
## fam-core 登录接入 auth-hub 统一登录(已部署 + 端到端验证)
|
||||
|
||||
### 目标(用户明确)
|
||||
`http://129.146.26.249:8000`(fam-core 公网入口,frp → NAS:8000)的登录改走 auth-hub
|
||||
统一登录,账号 `ericwyuan` / `iLoveJava5` 登录。
|
||||
|
||||
### 现状与动作
|
||||
- SSO 代码本地早已提交(`8120d2a feat(fam-core): 登录改接 auth-hub 统一登录`),NAS 未部署(旧密码登录版)。
|
||||
- **auth-hub 侧(甲骨文 /opt/auth-hub)**:
|
||||
- FAM-Core client `a2PJTZuKQYxt1oGh`(已存在)→ 轮换 secret 得明文
|
||||
`yZfJfD3xJmrCj9PQlbt7P8An9ebZ-nT2f3v4BTtOSu8`(旧密钥失效)
|
||||
- redirect_uris 增加 `http://129.146.26.249:8000/api/auth/callback`(原 :80 回调保留,
|
||||
auth-hub 精确匹配,用户入口是 :8000 所以回调也要 :8000 路径)
|
||||
- **NAS 侧(fam-core)**:
|
||||
- venv 补装 `PyJWT==2.13.0` + `cryptography==50.0.1`(RS256 验签必需)
|
||||
- 部署新 `auth.py`(cat 管道覆盖)
|
||||
- `.env` 追加 4 个变量:`AUTH_HUB_ISSUER=http://129.146.26.249:5300`、
|
||||
`AUTH_HUB_CLIENT_ID=a2PJTZuKQYxt1oGh`、`AUTH_HUB_CLIENT_SECRET=<上面>`、
|
||||
`AUTH_HUB_REDIRECT_URI=http://129.146.26.249:8000/api/auth/callback`
|
||||
- 清 __pycache__、杀双 gunicorn(今天 18:56 又一个旧进程 + 8/29 旧 master)、单实例重启
|
||||
|
||||
### 端到端验证(curl cookie jar 全链路)
|
||||
`/login` 302 → auth-hub `/authorize`(PKCE)→ hub 登录(ericwyuan/iLoveJava5 200)→
|
||||
`authorize` 302 回 `:8000/api/auth/callback?code&state` → fam-core 换 token+JWKS 验签 →
|
||||
种 fam_session → `/api/auth/check` = `{"authed":true}`,`/api/status` 200 ✓
|
||||
|
||||
### 注意
|
||||
- 本地 repo 3 个提交待 push(8120d2a SSO + 2 docs),等用户确认推送
|
||||
- NAS 仍跑旧版其余代码(webhook 移除等未部署,独立待办)
|
||||
- 密码登录 `/api/login` 已移除;fam-ui SPA 无登录逻辑,不受影响
|
||||
|
||||
## 补充:webhook/旧前端清理部署到 NAS(用户确认"清理")
|
||||
|
||||
- 全量比对 NAS vs repo:仅 `app.py`、`motion_bp.py`、`config.yaml` 三处是旧版(其余已同步)
|
||||
- 同步:3 个文件(app.py 移除 static_bp/refresh_camera_map 调用;motion_bp.py 移除 webhook 端点;
|
||||
config.yaml 移除 camera_name_to_id/enrich_window_sec)
|
||||
- 删除废弃:`static_app.py`、`fam-ui/` 目录、根 `start_ui.sh`、`scripts/start_ui.sh`
|
||||
- 清 pycache + 单实例重启
|
||||
- 验证:启动日志无 `refresh_camera_map` 告警;`/api/ss/webhook` → 401(已移除白名单与路由);
|
||||
SSO 登录 `/login` 302 → auth-hub 正常、`{"authed":true}`;`/api/ss/status` 200;Oracle 同步正常
|
||||
- 至此 NAS fam-core 代码与本地 repo 全量一致(除 .env/venv/logs)
|
||||
39
.workbuddy/memory/2026-09-03.md
Normal file
39
.workbuddy/memory/2026-09-03.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# 2026-09-03 同步 1062 复发 + Oracle 盒子过载
|
||||
|
||||
## 问题
|
||||
用户报 sync 状态卡在「同步中」+ `1062 Duplicate entry '...mp4' for key 'filename'`。
|
||||
游标停在 `2026-09-02 13:55:21`。
|
||||
|
||||
## 根因(与 8/29 identity_map 同一类)
|
||||
Oracle FAM-Edge 的 SQLite 视频/人物 id 整体重排(2303 段 → 3408/4600+ 段),
|
||||
NAS 镜像表 `sync_videos`/`sync_people` 旧 upsert 以 Oracle id 当主键插入,
|
||||
与已存在的同 `filename`/`label` 行在 UNIQUE 键上二次冲突 → 1062,整批中止、游标不推进。
|
||||
量化:本次积压 348 videos 中 **114 行**会 1062;9 people 中 **6 行**会 1062。
|
||||
events/model_calls 仅 PK id(ON DUPLICATE 按 id 更新,不会 1062)→ 无需改。
|
||||
|
||||
## 修复(已部署 NAS + 提交推送 0238030)
|
||||
- `upsert_sync_videos`:改以 `filename` 业务键去重,命中就地 UPDATE 保留 NAS 原 id
|
||||
(防 sync_events/model_calls/identity_map 的 video_id 外键失效),Oracle id 落
|
||||
`oracle_id` 列溯源;未命中插入优先用 Oracle id 对齐子表引用,主键冲突回退自增。
|
||||
- `upsert_sync_people`:同模式,以 `label` 业务键去重(people.id 无外键引用)。
|
||||
- `scripts/ddl.sql`:sync_videos/sync_people 的 id 改 NAS 本地自增 + 新增 oracle_id 列。
|
||||
- NAS 现网迁移:两表加 oracle_id、回填 oracle_id=id、id 改 AUTO_INCREMENT,重启。
|
||||
- 验证:单批补拉 videos+348 events+1017 people+9 model_calls+500 identity_map+238,
|
||||
游标 → `2026-09-03 08:51:11`,无 1062。目标行保留 id=2303、oracle_id=3408。
|
||||
|
||||
## 第二个问题:Oracle 盒子过载/挂死(未解决,环境层)
|
||||
NAS 日志 08:51–08:56 持续:`推送运动事件到 Oracle 失败 Connection reset` /
|
||||
`500 {"error":"database is locked"}` / `RemoteDisconnected`。
|
||||
FAM-Edge /health 先 200(status ok)后变 HTTP 000;最终整盒 129.146.26.249 全暗
|
||||
(:80/:5000/:5300/:7000/:8123 均 000),但 TCP :22/:5000 端口 OPEN、SSH 握手被拒
|
||||
(kex 阶段 Connection closed)→ 进程卡死/资源耗尽(4C23G 上 FAM-Edge 分析管线 +
|
||||
ai-gateway + garmin + auth-hub + frps + caddy 挤一起,SQLite 锁竞争 + 可能 OOM)。
|
||||
**SSH 连不进去,无法远程修复。** 需用户在 Oracle Cloud 控制台重启 VM,或等其自恢复。
|
||||
恢复后 NAS 的 oracle_sync 后台线程(30min 周期)会自动从游标 08:51:11 续拉,
|
||||
motion_notifier 也会恢复推送。
|
||||
|
||||
## 经验
|
||||
- 所有以 Oracle id 为镜像主键的表迟早会因 Oracle 库重建/重排踩坑。已修:
|
||||
identity_map(8/29)、videos+people(9/3)。events/model_calls 目前 PK-only 安全,
|
||||
但若将来 Oracle 重建导致子表引用错位,需同样改造(加 oracle_id + 业务键)。
|
||||
- Oracle 盒子服务过多挤在 4C23G,建议拆分或给 FAM-Edge SQLite 开 WAL + 限速。
|
||||
45
.workbuddy/memory/MEMORY.md
Normal file
45
.workbuddy/memory/MEMORY.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# sentinel-home-ai 项目长期记忆
|
||||
|
||||
## 项目概况
|
||||
- 家庭多模态智能监控系统,仓库 `http://192.168.50.64:3000/ericwyuan/sentinel-home-ai`(Gitea 内网)
|
||||
- 本地 `/Users/ericwyuan/Desktop/Work/sentinel-home-ai`(monorepo:fam-core NAS 端 + fam-edge Oracle 端 + fam-ui Vue3 前端)
|
||||
|
||||
## 架构 v3(2026-08-22 定稿:运动事件驱动)
|
||||
- **不再分析整段视频**。Google Drive --rclone--> Oracle gdrive_videos(整段素材保留);
|
||||
Oracle 按 NAS 推送的 `ss_motion_events`(start_time/duration,Unix epoch)ffmpeg 分割运动片段
|
||||
(`-c:v copy -c:a aac` 保留音频,产物 `/opt/fam-edge/motion_clips/`),**只分析运动片段**。
|
||||
- **Drive 中转是跨境提速设计(非仅 NAT 原因)**:中国→海外裸 HTTP/SCP 直传 Oracle 很难(跨境单 TCP 流
|
||||
丢包掉速);改走 Google(CloudSync 分块并发 + 断点续传,抗抖动)→ Oracle rclone 每 5min 拉取
|
||||
(海外数据中心互联,Gbps)。**不要轻易提议砍掉 Drive 改 NAS 直连 Oracle**。
|
||||
- NAS MotionNotifier 轮询 SS `EventCenter.Event.List`(60s,camera_ids=2, event_types=10)推送
|
||||
/api/ss/motion;游标 MariaDB 续用 + 失败批次不前进 + 心跳。Webhook 端点保留为可选补充。
|
||||
- **SS duration=0 = 动作进行中**(结束才回填真实时长),分割只处理已结束事件。
|
||||
- 前端(fam-ui Vue3)不改:/api/ui/videos|stats|people|attention-events|... 契约保持;
|
||||
事件帧图 /api/proxy/frame 用绝对 ts − event_start_time 偏移取帧,运动片段天然兼容。
|
||||
|
||||
## 服务器与部署
|
||||
- NAS (192.168.50.64):SSH 2222, ericwyuan/iLoveJava5;fam-core 代码 `/volume1/web/sentinel-home-ai`,
|
||||
venv `fam-core/venv`,`start_core.sh` source .env;重启 = kill gunicorn + `setsid bash start_core.sh`
|
||||
(NAS 无 systemd 守护)。
|
||||
- **Oracle(云服务器 129.146.26.249)**:SSH key `~/.ssh/oracle_new`;fam-edge 由 **systemd
|
||||
`fam-edge.service` 守护(Restart=always)**——部署代码后必须 `sudo systemctl restart fam-edge`,
|
||||
手动 setsid 会端口冲突;venv `/opt/fam-edge/venv`;库 `/opt/fam-edge/data/oracle.db`;
|
||||
素材 `/opt/fam-edge/gdrive_videos`;logs 目录 ubuntu 可写。
|
||||
**前端亦托管于此机 Caddy :80**(fam-ui SPA + /api 反代回 NAS fam-core :8000 经 frp 隧道);旧机 129.146.203.203 已退役。
|
||||
- **登录(2026-08-31 起)**:fam-core 不再自己校验密码,登录全权走 auth-hub 统一登录
|
||||
(OAuth2 Auth Code + PKCE / OIDC,`http://129.146.26.249:5300`)。fam-core client
|
||||
`a2PJTZuKQYxt1oGh`,回调 `:8000/api/auth/callback`(auth-hub 侧注册了 :80 和 :8000 两个回调)。
|
||||
NAS `.env` 配 `AUTH_HUB_ISSUER/CLIENT_ID/CLIENT_SECRET/REDIRECT_URI`(缺任一 fail closed)。
|
||||
账号:`ericwyuan / iLoveJava5`(hub 端)。NAS venv 需 PyJWT + cryptography(RS256 验签)。
|
||||
- 部署:本地 git 提交 push Gitea → tar 管道(NAS 直接解包;Oracle `--strip-components=1` 到临时目录再 cp,避免动 data/venv)。
|
||||
- **NAS 部署坑(2026-08-29 踩过)**:拷贝 `.py` 会带旧 mtime,若 `.pyc` 比 `.py` 新,Python 加载陈旧字节码
|
||||
(旧逻辑上线,曾导致 1062 假象)。部署后必须 `find src -name __pycache__ -exec rm -rf {} +` 再重启;
|
||||
`pkill -f` 会匹配 SSH 自身命令导致断连(用 PID 文件或精确 pattern)。
|
||||
- **镜像表键设计(2026-08-29 定稿)**:`sync_identity_map` 不再以 Oracle `person_identity_map.id` 作主键
|
||||
(Oracle 库重建会复用 id),改 NAS 本地自增 `id` + 唯一键 `(video_id, raw_uid)`,Oracle id 落 `oracle_id` 列溯源;
|
||||
同批 upsert 的 UPDATE 子句不改写键列,避免 uq 二次冲突 1062。sync_videos/events/model_calls 同理(风险低未改)。
|
||||
|
||||
## 提交规范(强制)
|
||||
- 格式 `[阶段X.Y子任务号] 子任务名称 - 完成内容简述`;Bug 修复 `fix(模块): 问题简述`
|
||||
- 一任务一 commit;开工先 `git pull --rebase`;阻塞先 commit 加 `[WIP]`
|
||||
- 禁止 `git push --force` 和 `--no-verify`
|
||||
229
PROGRESS.md
229
PROGRESS.md
@@ -1,21 +1,90 @@
|
||||
# 项目进度追踪
|
||||
|
||||
> 最后更新: 2026-08-20 18:50
|
||||
> 最后更新: 2026-08-31
|
||||
|
||||
## 服务运行状态
|
||||
|
||||
| 服务 | 节点 | 地址 | 状态 | 验证结果 |
|
||||
|------|------|------|------|---------|
|
||||
| FAM-Core | NAS | 0.0.0.0:8000 | ✅ 运行中 | health=ok, gunicorn --threads 4, scheduler+dispatcher+poller 全部 running |
|
||||
| FAM-Edge | Oracle | 0.0.0.0:5000 | ✅ 运行中 | v2.0, SQLite 异步队列 + 消费者线程 + TokenBucket 速率限制 |
|
||||
| FAM-Core | NAS | 0.0.0.0:8000 | ⚠️ 代码已完成,待生产部署 | health=ok, gunicorn 单 worker;Oracle-Sync + MotionNotifier 轮询 SS 事件推送(游标 DB 续用/失败重试);已部署事件时间轴删除功能;`/api/status`/`/api/ss/status` 500 bug 已修复;**登录改接 auth-hub 统一登录(OIDC),本地全链路验证通过,生产 client 注册 + NAS `.env` 配置 + 部署重启尚未执行** |
|
||||
| FAM-Edge | Oracle(新机 129.146.26.249) | 0.0.0.0:5000 | ✅ 运行中 | systemd 守护(fam-edge.service);素材→运动片段分割→只分析片段;问答改为转发 AI-Gateway;队列消费正常;已部署 `/api/oracle/video/delete`;FFmpeg 已补装;新增 DiskGuard 磁盘守护 |
|
||||
| **AI-Gateway** | Oracle | 0.0.0.0:5100 | ✅ 运行中 | systemd 守护(ai-gateway.service),2026-08-23 新增;独立仓库/独立部署;`/health` 正常,端到端问答实测成功(provider=nvidia) |
|
||||
| MariaDB | NAS | 127.0.0.1:3306 | ✅ 运行中 | 10.11.11, 6 张表, utf8mb4 |
|
||||
| Ollama | Oracle | 127.0.0.1:11434 | ✅ 运行中 | qwen2.5:7b,仅智能问答兜底(不参与视觉/融合) |
|
||||
| Ollama | Oracle | 127.0.0.1:11434 | ✅ 运行中 | qwen2.5:7b,仅智能问答兜底(不参与视觉/融合);**被 AI-Gateway 调用,不再被 FAM-Edge 直接调用** |
|
||||
|
||||
## 2026-08-31 FAM-Core 接入 auth-hub 统一登录(OIDC)
|
||||
|
||||
- **背景**:独立的统一登录/SSO 服务 [auth-hub](http://129.146.26.249:3000/ericwyuan/auth-hub)(跟本项目完全独立的仓库/数据库/部署,OAuth2 Authorization Code + PKCE + OIDC)已单独开发验证完成(本地全链路 47 单测通过),目标是自己的几个网站统一接到这一处身份服务,不用每个网站各自维护一份账号密码。FAM-Core 是第一个接入方。
|
||||
- **改造范围**(`fam-core/src/fam_core/auth.py` 全量重写,commit 待提交):
|
||||
- 原来自己校验 `FAM_AUTH_USER`/`FAM_AUTH_PASS` 的账号密码逻辑、内置深色登录表单页全部移除;`GET /login` 改为直接 302 跳转 auth-hub `/authorize`(带 PKCE `code_challenge`/`state`,均存进程内 `_pending` 表,10 分钟过期)
|
||||
- 新增 `GET /api/auth/callback`:收 auth-hub 回跳的 `code`,服务端到服务端 `POST /token` 换 `id_token`(用 `PyJWT` + `PyJWKClient` 拉 auth-hub JWKS 验 RS256 签名 + `iss`/`aud`),验证通过后种回原有的 `fam_session` HttpOnly cookie(**2 小时有效,机制不变**)——下游 `is_authed()`/`init_auth()` 全局拦截逻辑完全没动
|
||||
- 接入参数 `AUTH_HUB_ISSUER`/`AUTH_HUB_CLIENT_ID`/`AUTH_HUB_CLIENT_SECRET`/`AUTH_HUB_REDIRECT_URI` 走环境变量(NAS `.env`,沿用 `FAM_AUTH_*` 时代"未配置齐全直接拒绝所有登录"的 fail-closed 取舍),`AUTH_HUB_REDIRECT_URI` 必须与 auth-hub 端 `manage_clients create` 登记的 redirect_uri 逐字符一致
|
||||
- `requirements.txt` 新增 `PyJWT>=2.8.0`、`cryptography>=42.0.0`
|
||||
- **访问控制取舍(用户明确决策)**:不在 fam-core 侧加用户名白名单——任何在 auth-hub 上(自助注册 + 管理员审批后)拥有账号的人登录后都能访问本系统;不保留 `FAM_AUTH_USER`/`FAM_AUTH_PASS` 作为备用登录方式,SSO 是唯一入口
|
||||
- 测试:`tests/test_auth.py` 全量重写(PKCE 生成、state 校验、token 交换成功/失败、id_token 验签失败、白名单、`before_request` 拦截),fam-core 全量 39/39 通过
|
||||
- **端到端验证(本地)**:本地起了一份 auth-hub 开发实例(:5300)+ 一个仅含 `auth_bp` 的最小 Flask 壳子(模拟 fam-core,跳过 MariaDB 依赖),用真实浏览器走完整 Authorization Code + PKCE 闭环——`/` 未登录 302 `/login` → auth-hub 登录页 → 登录成功回跳 `/api/auth/callback` → 换 token + 验签 → 种 cookie → 落地首页;`/api/auth/check` 确认 cookie 生效;`/api/logout` 确认清会话。验证完把临时创建的 OAuth client 和测试账号都从 auth-hub 本地库删掉了。
|
||||
- **生产部署进度**:
|
||||
1. ✅ 已在 Oracle 生产 auth-hub(`/opt/auth-hub`,:5300)注册正式 client `FAM-Core`(client_id/secret 已生成,明文只显示过一次,未写入仓库,需要的话找 auth-hub 管理后台或 `manage_clients list` 核对 client_id)
|
||||
2. ⏳ **NAS `.env` 待补**(本次会话没有 NAS SSH 免密权限,未执行):`AUTH_HUB_ISSUER=http://129.146.26.249:5300`、`AUTH_HUB_CLIENT_ID`、`AUTH_HUB_CLIENT_SECRET`、`AUTH_HUB_REDIRECT_URI=http://129.146.26.249/api/auth/callback`
|
||||
3. ⏳ 部署新代码到 NAS(tar 管道,见 `docs/DEPLOY.md` §3)并 `bash start_core.sh` 重启——待执行
|
||||
4. ⏳ 生产环境浏览器实测一遍完整登录闭环——待执行
|
||||
|
||||
## 2026-08-28 Oracle 迁移故障排查:FFmpeg 缺失 + 磁盘写满死循环 + DiskGuard
|
||||
|
||||
- **触发**:用户反馈事件时间轴历史图片丢失、"谷歌同步是不是有问题"。逐层排查,发现的是三个叠在一起的独立问题,不是一个:
|
||||
1. **8/25 迁移新机器时漏装 FFmpeg**:`ffprobe: command not found`。导致 `videos.duration_sec` 全部读成 0、运动片段分割 `_segment_motion_clips` 全部失败(`ffmpeg` 也没装),190 个素材全部"分割 0 段",`events` 表完全是空的,`motion_clips/` 目录空的——事件时间轴从 8/25 起就没有任何新内容,图片自然也生成不出来。**已修复**:`apt-get install ffmpeg`,恢复正常。
|
||||
2. **8/25 之前的历史图片无法找回**:迁移时只搬了 `data/oracle.db`(数据库/文字记录),没有搬 `motion_clips/`(磁盘上的运动片段视频文件,缩略图的生成源);旧机器(129.146.203.203)已经释放销毁。历史事件的文字描述还在(早就同步进了 NAS 镜像),但配图永久丢失,无法找回。
|
||||
3. **Oracle↔Google Drive 同步"只下载不删除"**:排查一度走了弯路(先怀疑 NAS CloudSync 停摆,后发现新文件其实一直在下载,是判断证据不足导致的误判)。最终精确对比 Google Drive 远端(141 文件,最早 8/25)vs Oracle 本地(264 文件,最早 8/15)实锤:148 个文件是"远端已删、本地未删"的孤儿文件。根因是磁盘被写满到 100%(`no space left on device`),触发 rclone 内置安全机制——同步过程中遇到 IO 错误就整体拒绝执行删除,形成"越满删不掉,删不掉越满"的死循环。**已修复**:手动删除 148 个孤儿文件,释放 41G,磁盘 100%→59%;重新跑 `rclone_sync.sh` 验证 `exit=0` 无错误,下载+删除恢复正常。
|
||||
- **新增 DiskGuard 磁盘守护**(commit `ef56ae5`,永久性预防措施):`fam-edge/oracle_db.py` 新增 `get_oldest_purgeable_material()`(只挑最旧的、已完成分割阶段的整段素材,绝不碰运动片段和处理中的素材);`disk_guard.py` 新增后台线程,5 分钟检查一次,剩余空间 <10GB 触发清理,删到 15GB 水位为止;`/api/oracle/activity` 新增 `disk` 字段(实时剩余空间 + 最近清理动作)。9 个新测试,fam-edge 全量 139/139 通过。
|
||||
- **用户决策**:8/25-28 期间因 FFmpeg 缺失而"分割 0 段"的 190 个素材/736 个运动事件,明确选择不重新处理,翻页接受这段时间的空白。
|
||||
- **顺带修复**(commit `09708bd`):`/api/status`、`/api/ss/status` 500——上次"移除 SS Webhook"重构(`cf3d145`)删了 `MotionNotifier` 的 `camera_name_to_id` 等属性但 `status()` 忘记同步更新,导致这两个端点自那次重构起就一直是坏的。
|
||||
- **文档同步**(commit `3ec79de`):README 里 5 处 + `vite.config.js` 1 处仍描述"`/api/ss/webhook` 保留为可选"的过时内容,实际已被彻底删除,逐处订正。
|
||||
|
||||
## 2026-08-24 事件时间轴支持删除视频会话
|
||||
|
||||
- **决策**:用户要求事件时间轴能删除某个视频会话;调研确认项目里没有任何 delete 类端点先例,且增量同步(`get_sync_delta`)只做 upsert 感知不到 Oracle 端的物理删除,NAS 镜像必须显式清理,不能靠 `trigger_now()` 拉增量。用户明确要求连同磁盘上的视频文件一起删除,明确选择不做"防止误删事件被重新分割"的额外保护机制(风险极低:素材一旦标记 done 就不会被生产者重新捡起)
|
||||
- **三端实现**(commit `28397e9`):
|
||||
- fam-edge:`oracle_db.py` 新增 `delete_video(video_id)`(写锁保护,先删 events 再删 videos,再删磁盘文件;不清理 `ss_motion_events` 源事件);`api_gateway.py` 新增 `POST /api/oracle/video/delete`
|
||||
- fam-core:`oracle_sync.py` 新增 `push_video_delete()`;`db_layer.py` 新增 `delete_sync_video()`(项目里第一个"NAS 直接写自己镜像表"的函数);`ui_api.py` 新增 `DELETE /api/ui/videos/<id>`(先回推 Oracle 成功后才清本地镜像,回推失败 502 不改本地状态,避免数据不一致)
|
||||
- fam-ui:`Timeline.vue` 详情卡片新增"🗑 删除会话"按钮(原生 `confirm()` 二次确认,删除成功后从本地列表移除 + 重新选中 + 刷新统计卡);`api.js` 新增 `deleteVideo()`
|
||||
- **测试**:`test_oracle_db.py` 新增 6 个 `delete_video` 单元测试(正常删除/磁盘文件删除/文件已缺失不报错/不存在返回 None/不影响 ss_motion_events);fam-edge 全量 130/130 通过
|
||||
- **端到端验证**:全程用自造测试数据(`motion_TESTDELETE*`),未触碰任何真实监控记录——Oracle 侧直接调 `/api/oracle/video/delete` 验证记录+文件删除+幂等 404;NAS 侧手动插入镜像行模拟已同步状态,登录后调 `DELETE /api/ui/videos/<id>`,验证 Oracle 记录+文件、NAS 镜像记录均被清理
|
||||
- **已知限制**:前端浏览器 UI 层面未做可视化验证(fam-core 全站要求登录,出于"不代用户输入密码"的原则没有走浏览器交互式登录),只验证了 `npm run build` 编译通过 + 完整 API 链路
|
||||
|
||||
## 2026-08-23 问答链路抽离为独立 ai-gateway 服务
|
||||
|
||||
- **决策**:原本嵌在 FAM-Edge 里的问答模型降级链(NVIDIA 文字模型链 → Gemini 非 flash 文字模型链 → 本地 Ollama 兜底,含 key 轮换/熔断)跟视频分析业务无关,是通用能力,抽成独立服务(OpenAI 兼容协议 `/v1/chat/completions`),除了 FAM-Edge 自己(改为转发调用),别的项目也能直接接入
|
||||
- **新增独立仓库/项目 `ai-gateway`**(http://192.168.50.64:3000/ericwyuan/ai-gateway):`app.py`(Flask,`/v1/chat/completions` 流式+非流式、`/v1/models` 占位、`/health` 免鉴权)/ `auth.py`(Bearer token 鉴权,fail-closed)/ `orchestrator.py`(`ChatOrchestrator`,按 provider 顺序降级,保留"未吐字才切换、已吐字后中途失败直接结束"语义)/ `adapters/`(NVIDIA/Gemini/Ollama,纯文本,从 fam-edge 对应适配器裁剪 chat 相关代码而来);测试 37/37 通过
|
||||
- **FAM-Edge 侧改动**(commit `5caeb29`):`qa.py` 重写为 HTTP 转发客户端(调 ai-gateway `/v1/chat/completions`,翻译回原有 `run_qa`/`run_qa_stream` 契约,`api_gateway.py` 和 FAM-Core 调用方零改动);删除 `model_adapters/ollama_adapter.py` 及其测试;`gemini_adapter.py`/`nvidia_adapter.py` 移除 `chat()`/`chat_stream()`/问答专用超时(只保留 `analyze_video`);`app.py` 移除 Ollama 预热逻辑;`config.yaml` 移除 3 个问答专用 model 条目,新增 `ai_gateway` 客户端配置块;测试 125/125 通过
|
||||
- **部署**:Oracle 新建 `/opt/ai-gateway/`(独立 venv,Python 3.8),systemd `ai-gateway.service` 守护,gunicorn 绑定 `0.0.0.0:5100`(对外直接开放,Bearer token 鉴权,用户明确选择"别的项目也能从这台机器之外访问"而不是仅本机);`AI_GATEWAY_TOKEN`/`NVIDIA_API_KEY`/`GEMINI_API_KEY*` 存在独立的 `/opt/ai-gateway/.env`(用户选择两份 `.env` 各自独立维护,而非复用 `/opt/fam-edge/.env`,代价是以后轮换 key 需要改两处)
|
||||
- **验证**:`/health` 通过;`curl` 直接测 `/v1/chat/completions`(带 token)端到端成功,provider=nvidia;FAM-Edge `/api/edge/chat/ask` 非流式 + `/api/edge/chat/ask/stream` 流式均验证通过,事件格式(`provider_trying`/`chunk`/`done`)不变,中文无乱码;队列/pending/failed 数量正常,无积压
|
||||
- **已知代价**:NVIDIA/Gemini key 现在两处各存一份(`/opt/fam-edge/.env` + `/opt/ai-gateway/.env`),非最初设计的"复用同一份",用户已知情并选择保留现状
|
||||
|
||||
## 2026-08-22 运动事件驱动架构(v3)
|
||||
|
||||
- **决策**:不再分析整段视频。rclone 整段素材保留在 Oracle,按 NAS 推送的 `ss_motion_events`(start_time/duration)**ffmpeg 分割成运动片段**,只分析片段。
|
||||
- **改动**(commit `a1523b4` + `8d6cfad`,已部署 Oracle systemd 重启):
|
||||
- `oracle_db.py`:videos 表兼容加 `motion_event_id`/`camera_id` 列;新增 `get_motion_events_in_range`(窗口内已结束事件,grace 容差)/`has_unfinished_motion_in_range`/`get_video_by_motion_event_id`
|
||||
- `video_processor.py`:素材→分割(只分割已结束事件,`-c:v copy -c:a aac` 保留音频,motion_event_id 幂等)/片段→只分析 双分支;修复 ffmpeg args 缺可执行文件 bug
|
||||
- `video_queue.py`:素材分割出的片段入队
|
||||
- `config.yaml`:新增 `motion_segment` 块(clips_dir/keep_audio/min_duration/unfinished_grace_sec)
|
||||
- **E2E 验证(真机)**:素材 `Generic_ONVIF-001-20260822-110859-...mp4` → 分割 35 段(真实 event_id 25504-25538、duration 1-132s)→ 片段只分析(summary 真实:人物/动作)→ events(绝对时间 ts)→ NAS 同步 → `/api/ui/videos` 显示 motion_ 片段(字段契约不变:filename/event_start_time/camera_name/event_count/summary_json/compute_provider)→ `/api/ui/videos/<id>` 详情 events + people 正常 → `/api/proxy/frame` 帧图 200 OK
|
||||
- **关键认知**:SS 事件 duration=0 仅在动作**进行中**(结束时必为正数,实测 id=25538 从 0→2s),故只分割已结束事件(start+duration ≤ now+grace),进行中的等下一轮。
|
||||
- **部署注意**:Oracle fam-edge 由 **systemd `fam-edge.service` 守护**(Restart=always),代码部署后必须 `sudo systemctl restart fam-edge`(手动 setsid 会与守护打架导致端口冲突)。
|
||||
|
||||
## 2026-08-22 清数据重提取 + 人物管理重设计 + 文档同步(commits 4cf4fc4/6a29e88/9664459)
|
||||
|
||||
- **清数据重提取**(用户决策:整段提取旧数据全删,按运动视频重新提取):Oracle videos(364)/events(1687)/people(44)/model_calls/service_activity 全清 + motion_clips 清空(保留 ss_motion_events 93 条 + 素材 + 配置,先备份);NAS sync_* 四表全清;重启两端自动重提取(8/22 素材→运动片段;8/15-21 历史素材无运动事件→0 段)
|
||||
- **时间轴过滤**:历史素材"分割 0 段"空会话淹没时间轴 → db_layer.get_sync_videos/get_sync_stats 加内容过滤(`LEFT(filename,7)='motion_'` OR 有事件),前端契约不变;**踩坑:pymysql execute 用 % 做占位符,SQL 字面量 'motion_%' 的 % 报 "unsupported format character" 500 → 改 LEFT 判断**
|
||||
- **人物管理重设计**:`GET /api/ui/people/clips?label=`(db_layer.get_sync_people_clips:按 label/canonical_name 匹配 person_list_json → 关联运动片段,含 first_ts/clip_events);PersonCard.vue 新增「运动片段」区块(缩略图/时间/摘要/事件数,点击跳 `/timeline?video=`);Timeline.vue 支持 query 定位
|
||||
- **文档同步**:README 更新 2.1 网络要点/2.2 拓扑(Streamlit→Vue3)/3.1-3.3 模块表(poll 主路径+people clips)/4.1 表清单(motion_event_id/ss_motion_events)/5.1-5.2 API 表(people/clips+frame/avatar)/6.1 历史标注/8 部署(systemd+Vue3+motion_segment config)/12 进度;docs/DEPLOY.md 重写为 v3;已 push
|
||||
- **验证**:/api/ui/videos 全为 motion_ 片段(15 条);/api/ui/people 3 身份;people/clips 返回媳妇 3 个片段;前端 dist 已构建部署
|
||||
| Tailscale | Oracle ↔ NAS | 100.74 ↔ 100.70 | ⚠️ 待修复 | Tailscale 运行但端口不通,当前用公网IP |
|
||||
| FAM-UI | NAS | 0.0.0.0:8501 | ✅ 运行中 | Streamlit 1.61.1, HTTP 200, health=ok |
|
||||
|
||||
## 环境状态
|
||||
|
||||
### Oracle Cloud (129.146.203.203) - FAM-Edge 节点
|
||||
### Oracle Cloud (129.146.26.249) - FAM-Edge 节点
|
||||
| 项目 | 状态 | 备注 |
|
||||
|------|------|------|
|
||||
| OS | Ubuntu 20.04 ARM64 | Ampere A1 2C12G |
|
||||
@@ -129,6 +198,10 @@
|
||||
| 49 | **串行上传 + 看门狗** — Dispatcher limit=1 避免带宽争抢 + 线程存活检测(is_alive) + 60s 看门狗自动重启 + 退避缩短至 30×(n+1)s | `881ea3f` | 2026-08-20 |
|
||||
| 50 | **fix: 分块大小 5MB + chunk_size 变更防护** — Edge 端 total_chunks 变更自动清理旧分块;NAS 端断点续传检测 total_chunks 不匹配时从头上传;timeout (60,180) | `5915cf4` | 2026-08-20 |
|
||||
|
||||
| 51 | **运动监测重构为 Webhook 驱动(去轮询)** — 关闭 MotionNotifier 轮询;SS 事件经「行動規則/Webhook」`POST :8000/api/ss/webhook` 推送,FAM-Core 映射(%DEVICE_NAME%→camera_id、%EVENT_TIME%→epoch、合成稳定 event_id)后 `POST /api/ss/motion` 推甲骨文;甲骨文 `ss_motion_events` 落库、video_processor 改本地运动预过滤 | `ca6d425` | 2026-08-22 |
|
||||
| 52 | **fix: MotionNotifier 补回 camera_ids 属性** — 避免重新开启轮询时 `_fetch_events` 引用缺失属性崩溃 | `e52d8e9` | 2026-08-22 |
|
||||
| 53 | **文档同步** — README 架构章节(2.2.1 运动监测链路)+ API 表 + 模块表补充 v3 Webhook 架构;PROGRESS 状态/变更记录更新 | - | 2026-08-22 |
|
||||
|
||||
### 待完成
|
||||
|
||||
| # | 任务 | 依赖 | 优先级 |
|
||||
@@ -147,7 +220,7 @@
|
||||
5. **MariaDB JSON 路径兼容** - MariaDB 10.11 不支持 MySQL 的 `$[*]` 通配符 JSON 路径和 `->` 操作符,name_member() 改用 Python 层解析 + 逐行 UPDATE
|
||||
6. **Python 3.10 venv** - NAS 系统 Python 3.8 过旧,用 Synology Python3.10 包创建 venv
|
||||
7. **FAM-Edge 聊天代理** - 历史:/api/edge/chat 直连 Ollama 代理;架构重构后被 `/api/edge/chat/ask` 三模型编排端点取代(旧端点保留兼容)
|
||||
8. **Oracle 公网 IP 替代 Tailscale** - Tailscale 两节点在线但端口不通(防火墙),edge_url 和 qa_url 改用 Oracle 公网 IP 129.146.203.203
|
||||
8. **Oracle 公网 IP 替代 Tailscale** - Tailscale 两节点在线但端口不通(防火墙),edge_url 和 qa_url 改用 Oracle 公网 IP 129.146.26.249
|
||||
9. **Ollama 模型常驻内存** - systemd 加 `OLLAMA_KEEP_ALIVE=-1`,模型加载后永不卸载,消除 55s 冷启动延迟,常驻占用 4.3GB 内存(系统 12GB 够用)
|
||||
10. **关键帧自适应帧数** - 原固定 5-8 帧对长视频太稀疏(30分钟仅8帧=每3.75分钟1帧),改为随视频时长自适应:候选帧 `clamp(duration_min×2, 30, 120)`,关键帧上限 `clamp(duration/150s, 8, 30)`。30分钟→12帧,60分钟→24帧,封顶30帧
|
||||
11. **云端直出直存(架构重构)** - 本地 Ollama 完全移出视频链路:云端 VLM(Gemini 多图单请求 / NVIDIA 逐帧聚合)直接产出结构化 JSON,Edge 仅 `format_cloud_result` 格式化/校验(无模型调用)后直存 NAS DB。根因:CPU 版 Ollama 对无上限融合 prompt 预填充极慢导致 300s 超时
|
||||
@@ -333,7 +406,7 @@ AI 分析瓶颈: 帧5耗时 229s (疑似 ARM CPU 热降频), 其余帧 50-65s
|
||||
/api/edge/chat/ask run_qa 三模型降级编排
|
||||
```
|
||||
|
||||
- FAM-Core Chat-Handler 调用 `qa_url` 配置的 `http://129.146.203.203:5000/api/edge/chat/ask`(不再直连 Ollama)
|
||||
- FAM-Core Chat-Handler 调用 `qa_url` 配置的 `http://129.146.26.249:5000/api/edge/chat/ask`(不再直连 Ollama)
|
||||
- FAM-Edge `run_qa` 按 Gemini → NVIDIA → 本地 Ollama 顺序调用 `chat()`,首个成功即返回 `{answer, provider}`
|
||||
- **验证结果**:
|
||||
- Edge 单测:`/api/edge/chat/ask` → 200,`provider=nvidia`(Gemini 30s 超时后 NVIDIA 兜底成功,耗时 51s)
|
||||
@@ -433,3 +506,145 @@ AI 分析瓶颈: 帧5耗时 229s (疑似 ARM CPU 热降频), 其余帧 50-65s
|
||||
- 修复:导航改为页面顶部 `st.segmented_control` 横向分段控制器,任何屏宽直接可见;侧边栏保留品牌标题和任务队列状态。
|
||||
|
||||
浏览器验证:顶部分段导航 5 页可达、成员命名页显示 张三/汤圆/爸、AI 对话页表单正常、无报错。
|
||||
|
||||
## 摄像头对外入口收口为单一域名 smart-camera.zichuan.xyz (2026-09-01)
|
||||
|
||||
**决策**:摄像头系统(fam-ui 前端 + NAS fam-core :8000 后端)对外只暴露一个域名 `smart-camera.zichuan.xyz`(前端 `/` + 后端 `/api`),下线独立的 `api.zichuan.xyz`,并从 `oracle.zichuan.xyz` 移除摄像头相关路由(其 `/ai`、`/fam`、`/wordpress` 仍保留给其它服务)。
|
||||
|
||||
**改动(基础设施,不在本仓库)**:
|
||||
- Oracle Caddy(`/etc/caddy/Caddyfile`):新增 `smart-camera.zichuan.xyz` 块(`/` 静态 fam-ui + `/api`、`/login` 反代 `127.0.0.1:8000`,经 frp 隧道回源 NAS);删除 `api.zichuan.xyz` 块;`oracle.zichuan.xyz` 与 `:80` 兜底块移除摄像头 root + `/api` 路由(改 404)。
|
||||
- DNS:新增 `smart-camera.zichuan.xyz → 129.146.26.249`(RecordId 2387553886)。
|
||||
- NAS `/volume1/web/sentinel-home-ai/.env`:`AUTH_HUB_ISSUER` 改为 `https://auth.zichuan.xyz`、`AUTH_HUB_REDIRECT_URI` 改为 `https://smart-camera.zichuan.xyz/api/auth/callback`,且 `AUTH_HUB_*` 四行补全 `export` 前缀(见下)。
|
||||
- auth-hub FAM-Core 客户端 `a2PJTZuKQYxt1oGh` 回调改为 `https://smart-camera.zichuan.xyz/api/auth/callback`(保留旧 IP 作兜底)。
|
||||
|
||||
**顺带修复两个生产 Bug**(见 `scripts/start_core.sh`):
|
||||
1. 脚本先 `cd "$APP_DIR"` 再算相对 `SCRIPT_DIR`,导致 `source .env` 路径错位、`set -e` 下直接退出,gunicorn 起不来。
|
||||
2. `.env` 里 `AUTH_HUB_*` 是裸赋值(无 `export`),`source` 后不进环境,gunicorn 子进程读不到 → 登录一直 503 fail-closed(与之前“生产未部署”一致)。脚本加 `set -a` 包裹 source 修复。
|
||||
|
||||
**验证**:`smart-camera.zichuan.xyz/` 200、`/login` 302→auth.zichuan.xyz(redirect_uri=smart-camera)、`/api/*` 401;`oracle.zichuan.xyz/` 404、`/fam` 200;`api.zichuan.xyz` 已不通。fam-core 经修复脚本重启后 health ok。
|
||||
|
||||
## SPA 登录打通:未登录自动跳转 OIDC(2026-09-01)
|
||||
|
||||
**问题**:域名收口后访问 `https://smart-camera.zichuan.xyz/timeline` 显示「⚠ 未登录 / 登陆不了啊」。后端 `/login`(302→auth-hub)与 Caddy `/login` 反代此前已验证可用,根因在**前端零登录逻辑**:`fam-ui` 调 `/api/*` 拿到 401 后只在页面上渲染字面错误 `data.error`("未登录"),从不发起 OIDC 跳转,也没有任何登录入口。
|
||||
|
||||
**改动(本仓库 `fam-ui/`)**:
|
||||
- `src/api.js` `request()`:捕获 401 时 `window.location.href = '/login'` 触发统一登录(后端经 auth-hub 走 Authorization Code + PKCE);用模块级 `_redirectingToLogin` 开关保证单次会话只跳一次,并 `return new Promise(()=>{})` 阻止调用方继续渲染错误态;`/api/auth/check` 等白名单接口不会 401,不受影响。
|
||||
- `src/api.js` `api` 对象新增 `authCheck: () => request('/api/auth/check')`。
|
||||
- `src/App.vue`:`onMounted` 调 `api.authCheck()` 维护 `authed` 状态;左侧栏 + 移动端顶栏新增「🔑 登录」入口(`<a href="/login">`)与「👋 退出登录」按钮(`POST /api/logout` 后回 `/` 由后端 401 自动跳登录)。
|
||||
|
||||
**部署**:`npm run build` → `dist/` 经 `tar | ssh ubuntu@129.146.26.249` 覆盖 `/var/www/fam-ui`(macOS `._*` 元数据已清)。
|
||||
|
||||
**端到端验证**:
|
||||
- `smart-camera.zichuan.xyz/timeline` → 200(SPA)
|
||||
- `/login` → 302 → `auth.zichuan.xyz/authorize?...&redirect_uri=https://smart-camera.zichuan.xyz/api/auth/callback`
|
||||
- `/api/auth/check`(无 cookie)→ `{"authed":false}`(白名单,不 401)
|
||||
- `/api/ui/videos`(无 cookie)→ 401 → 前端据此自动跳 `/login`
|
||||
|
||||
登录链路已通:未登录访问任意页面 → 首个 401 → 自动跳 auth-hub 登录 → 回调种 `fam_session` cookie → 回 `/timeline` 正常加载。
|
||||
|
||||
## 统一登录从 NAS 迁到甲骨文 fam-edge(2026-09-12)
|
||||
|
||||
**触发**:`smart-camera.zichuan.xyz/login` 报 502。排查链路:静态页 `/`、`/timeline` 200(Caddy 读本机磁盘),
|
||||
只有 `/login` 和 `/api/*` 502;甲骨文本机 `curl 127.0.0.1:8000/health` 0.6 秒空响应(curl exit 52,
|
||||
Caddy 日志 `msg:"EOF"`),frps 正常、同隧道的 gitea 也正常 → NAS 上 fam-core 进程没了。
|
||||
局域网直扫 NAS:22/2222/3000/5000/5001/3306 全 OPEN,**只有 8000 closed**,NAS 本身没事。
|
||||
根因是登录入口挂在 NAS 上,而 NAS 上的 fam-core 没有任何守护(DSM 无 systemd),挂了不会自启。
|
||||
|
||||
**决策**:登录是入口,不该依赖家里的机器。整体迁到甲骨文的 fam-edge(前端静态文件、auth-hub 本来就在这台),
|
||||
NAS fam-core 退化成纯数据接口。
|
||||
|
||||
**改动**:
|
||||
- 新增 `fam-edge/src/fam_edge/auth.py`:`/login`、`/api/auth/callback`、`/api/logout`、
|
||||
`/api/auth/check`、`/api/auth/verify`(给 Caddy forward_auth)。跟旧实现三处关键差异:
|
||||
1. 换 token / 拉 JWKS 走 `AUTH_HUB_INTERNAL_BASE`(本机 :5300),不再跨公网 TLS——旧链路上
|
||||
`PyJWKClient` 用 urllib + 系统 CA(群晖易 CERTIFICATE_VERIFY_FAILED)、两机时钟偏差会让 `iat`
|
||||
显得来自未来,这两个坑一起消失;但 `iss` 校验和浏览器跳转仍用公网 issuer
|
||||
2. 会话改无状态 HS256 签名 cookie,服务重启不掉线(旧实现进程内 token 表)
|
||||
3. **回调失败渲染错误页,不再 302 回 `/login`**——旧实现失败即跳 `/login`,而 auth-hub 只要还有
|
||||
会话就立刻再签一个 code 跳回来,两边对跳成死循环,浏览器只报「重定向次数过多」,
|
||||
既看不到登录页也看不到原因(这正是 9/1 那次「跳不到登录页」的成因)
|
||||
- 删除 `fam-core/src/fam_core/auth.py` + `tests/test_auth.py`,`app.py` 去掉 `init_auth`;
|
||||
fam-core 不再有任何鉴权,改由甲骨文 Caddy `forward_auth` 前置拦截
|
||||
- `fam-edge/tests/test_auth.py` 16 个用例(含「失败分支绝不 302」的回归测试、
|
||||
「服务端走内网但 iss 按公网校验」、「cookie 无状态」);fam-edge 157 / fam-core 15 全绿
|
||||
- 写测试时逮到自己写的一个 bug:会话 cookie 也套了 60 秒 leeway(本进程自签自验根本不需要),
|
||||
会让每个会话白白多活 60 秒,已改成只有 id_token 用 leeway
|
||||
|
||||
**遗留风险(已记入 README/DEPLOY)**:frps 在甲骨文绑的是 `*:8000` 且 iptables 明确放行,
|
||||
fam-core 去掉鉴权后,绕过 Caddy 直连 `129.146.26.249:8000` 就是无门禁的全量数据接口,
|
||||
必须靠 `iptables -I INPUT 1 -p tcp --dport 8000 ! -i lo -j DROP` 兜底。
|
||||
另:登录进程并到 fam-edge 后,fam-edge 正在跑视频分析时登录响应可能变慢(单 worker 4 线程)。
|
||||
|
||||
## 全量迁云:NAS 只剩推送进程(2026-09-13)
|
||||
|
||||
**触发**:前一天刚把登录迁到甲骨文,隔天 NAS 上的 fam-core 又挂了导致数据接口 502。
|
||||
用户一句话点破:「NAS 上只有一个无状态的通知甲骨文的服务,剩下的全部在甲骨文呀」。
|
||||
盘点后确认这个判断成立——甲骨文的 SQLite 才是权威数据源(videos 3113 / events 16159 /
|
||||
people 60 / model_calls 9876),NAS 的 MariaDB 全是它的镜像,前端读的数据本来就产自
|
||||
甲骨文,绕了一圈回家又绕回来。
|
||||
|
||||
**改动**:
|
||||
- `fam-core/db_layer.py` 从 725 行重写成 377 行:MySQL 镜像查询 → 直读 fam-edge 的
|
||||
SQLite。5 个 `upsert_sync_*`(约 300 行去重逻辑,8/29 和 9/3 两次 1062 事故的发源地)
|
||||
连同 `oracle_sync.py` 整个删除。SQL 方言:`JSON_CONTAINS` → `json_each`(前置
|
||||
`json_valid`,历史脏数据不会把查询搞崩)、`LEFT()` → `substr()`、`%s` → `?`。
|
||||
函数名 `get_sync_*` 一并改掉——已经没有 sync 这回事了,留着名字会误导。
|
||||
- 新增 `fam-core/edge_client.py`:写操作(改名/删除)、帧图头像、服务状态都打给同机
|
||||
fam-edge,全走 127.0.0.1,不出公网。
|
||||
- 新增 `fam-notifier/`:把 `motion_notifier.py` 从 fam-core 拆出来,游标从 MariaDB
|
||||
换成本地 JSON 文件。NAS 上从此没有 Flask、没有数据库、没有监听端口,只有一个
|
||||
单向推送进程。
|
||||
- fam-core 移到甲骨文 `/opt/fam-core`(systemd,gunicorn -w 2,**只绑 127.0.0.1:5401**——
|
||||
5400 被 chat-relay 占了)。Caddy 的 `/api/*` 从"frp 隧道回源 NAS"改成同机反代,
|
||||
forward_auth 闸门不变。
|
||||
- 前端跟着删:侧边栏同步面板、统计页同步状态、服务状态页的"NAS 同步"卡片和
|
||||
"立即同步"按钮(背后的镜像层已不存在)。"NAS 同步"换成"NAS 运动推送",读
|
||||
fam-edge activity 新增的 `motion` 段(心跳年龄 + 最近事件)。
|
||||
- 顺带修掉一个隐蔽 bug:镜像表为保外键稳定用的是 NAS 本地自增 id,而帧图接口要的是
|
||||
甲骨文的 id,两边在 9/3 那次 id 重排后就对不上了。现在只有一套 id。
|
||||
|
||||
**测试**:fam-core 21(新增 12 个 db_layer 用例:脏 JSON 不崩、人物精确匹配不误伤
|
||||
"人物B"、日期过滤、统计口径、chat_history 懒建表)、fam-notifier 6、fam-edge 157,全绿。
|
||||
|
||||
**验证**:甲骨文 `/api/ui/stats` 返回 videos 2965 / events 16159 / attention 13 /
|
||||
people 59;外网 `/` `/timeline` 200、`/login` 302、`/api/*` 未登录 401——**全程 NAS
|
||||
上的 fam-core 是停着的**,这就是迁云的验收标准。
|
||||
|
||||
**待办**:NAS 侧部署 fam-notifier(只能用户手动,密码登录);chat_history 一次性迁移
|
||||
(`fam-core/scripts/import_chat_history.py`,幂等);frpc.toml 里的 8000 映射可删。
|
||||
|
||||
## 修复 fam-edge 多线程共用 SQLite 连接(2026-09-13)
|
||||
|
||||
**触发**:用户问「甲骨文剩余硬盘小于 10G 会删东西的逻辑怎么没了?」。查下来逻辑一直在、
|
||||
也没被迁云动过(`disk_guard.py` 最后一次改动还是 8/28 新增它那次),但它 95% 的检查在空转:
|
||||
|
||||
```
|
||||
DiskGuard「本轮清理完成」 128 次
|
||||
DiskGuard「检查异常」 2837 次 ← 22 倍
|
||||
```
|
||||
|
||||
失败原因全是 `cannot start a transaction within a transaction`。表现就是磁盘剩余空间在
|
||||
2.7GB 和 16GB 之间来回荡——清理能不能成功全靠运气,赶上 rclone 集中下载
|
||||
(实测 5 分钟写入 12.6GB)就掉进危险区。
|
||||
|
||||
**根因**:`OracleDB.__init__` 建一条 `sqlite3.connect(check_same_thread=False)` 的连接
|
||||
给全进程共用,而 VideoQueue / PersonService / DiskGuard 三个后台线程 + gunicorn 的 4 个
|
||||
请求线程都在并发读写它。sqlite3 的连接对象本来就不是可并发共享的,事务状态互相踩踏。
|
||||
同一个根因在线上刷出三类错误,累计:`database is locked` 71604 次、
|
||||
`cannot start a transaction within a transaction` 378 次、`no more rows available` 92 次
|
||||
(后者堆栈落在 `self._conn.commit()`,是游标被别的线程重置的典型症状)。
|
||||
运动事件推送被 500 打回也是它——NAS 侧失败批次不推进游标,下一轮补推,所以没丢事件。
|
||||
|
||||
**修复**:`_conn` 改成 `@property`,从 `threading.local()` 取当前线程的连接,没有就新建
|
||||
(WAL + busy_timeout=10000)。`close()` 相应改成收掉所有线程开过的连接。因为外部调用方
|
||||
(如 `api_gateway` 的 activity 端点)也在直接用 `db._conn.execute(...)`,做成 property
|
||||
可以让全部现有调用点原样工作,不用逐个改。`_write_lock` 保留,复合写语义不变。
|
||||
|
||||
**测试**:新增 2 个用例(8 线程 × 25 轮并发读写、close 要收掉所有连接)。在旧代码上
|
||||
稳定复现同族错误 `cannot commit transaction - SQL statements in progress`,修复后通过;
|
||||
fam-edge 全套 159 个测试绿。
|
||||
|
||||
**同批修掉的第二个竞态**:生产者列目录之后、读 mtime 之前,DiskGuard 可能刚好把那个
|
||||
文件清掉(两个后台线程的正常竞态),`os.path.getmtime` 抛 FileNotFoundError,代价是
|
||||
**整轮扫描中断**——排在后面的新素材本轮全都登记不上。改成捕获 OSError 跳过该文件,
|
||||
新增 `fam-edge/tests/test_video_queue.py` 两个用例覆盖(被删的跳过 / 仍在写入的照样跳过)。
|
||||
|
||||
494
README.md
494
README.md
@@ -3,7 +3,7 @@
|
||||
|
||||
> 多模型容灾降级 + 交互式命名 + AI 对话的家庭监控系统。
|
||||
> 本文档为项目需求文档与 README 的整合版,按当前代码实际状态(v1.0 E2E 已打通)编写。
|
||||
> 最后更新:2026-08-20(云端多模型方案整合)
|
||||
> 最后更新:2026-08-25(Oracle 迁至新服务器 129.146.26.249;前端由 NAS 迁至云服务器 Caddy :80)
|
||||
|
||||
---
|
||||
|
||||
@@ -13,14 +13,14 @@
|
||||
|
||||
### 1.1 首期范围(已基本完成)
|
||||
|
||||
> **新架构 v2(2026-08-21 重构)**:NAS 不再处理视频,仅作管理后台;视频分析全部上云(Oracle)。
|
||||
> **新架构 v3(2026-08-22 重构:运动事件驱动)**:NAS 不再处理视频,仅作管理后台 + 运动事件推送;视频分析全部上云(Oracle),且**不再分析整段视频**——整段素材按 SS 运动事件**分割成运动片段**后只分析片段。
|
||||
|
||||
- **FAM-Core**(NAS 端单进程):仅 **Oracle-Sync**(每 30 分钟拉增量镜像)+ **Chat-Handler** + **Member-Manager** 三个子模块,CPU 占用极低
|
||||
- **FAM-Edge**(Oracle 端单进程):rclone 实时同步 Google 硬盘视频 → 监听目录 → **整视频直传云端 VLM**(Gemini 用 Files API / NVIDIA 用整视频 `video_url`,不切片不抽帧)→ 结构化 JSON 落本地 SQLite → 对外提供 `/api/oracle/sync` 增量拉取接口(**本地模型不参与视频分析**)
|
||||
- **FAM-UI**(NAS 端):Streamlit 读本地同步镜像(sync_videos / sync_events / sync_people),事件时间轴 + 人物管理 + AI 对话 + 对话历史 + 统计
|
||||
- **数据库**:Oracle 侧 SQLite(videos/events/people/sync_cursor);NAS 侧 MariaDB 镜像(sync_videos / sync_events / sync_people / sync_cursor)+ chat_history
|
||||
- **数据流向**:Google 硬盘 ──rclone──► 甲骨文本地 ──整视频分析──► Oracle SQLite ──每 30 分钟 NAS 拉取──► NAS MariaDB 镜像 ──► FAM-UI
|
||||
- **AI 对话**:查 sync_events 拼上下文 → 经 FAM-Edge 问答编排(Gemini → NVIDIA → 本地 Ollama 兜底)生成回答 → 返回并写 chat_history
|
||||
- **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+)
|
||||
@@ -47,13 +47,13 @@
|
||||
|------|--------|------|------|
|
||||
| **视觉分析 + 结构化输出**(看图识人/动作/衣着 → 直出 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 对话**("汤圆今天干嘛了") | 云端优先 + 本地兜底 | Gemini → NVIDIA NIM → 本地 Ollama | 两云端任一成功即用;**仅当 Gemini 与 NVIDIA 都失败**才回退本地 Ollama qwen2.5:7b |
|
||||
| **AI 对话**("汤圆今天干嘛了") | 独立 **ai-gateway** 服务(OpenAI 兼容协议) | NVIDIA → Gemini → 本地 Ollama | FAM-Edge 不参与问答模型调用,只转发;降级顺序、key 轮换、熔断全部由 ai-gateway 自己管理 |
|
||||
|
||||
- **Google Gemini**(`gemini-flash-latest`,API Key 已验证,支持多图单请求,视觉 + 问答均参与)
|
||||
- **NVIDIA NIM**(`meta/llama-3.2-11b-vision-instruct`,OpenAI 兼容 API,云端 GPU 推理,单请求限 1 图故逐帧调用;视觉 + 问答均参与)
|
||||
- **本地 Ollama**(qwen2.5:7b)**仅参与智能问答,且仅作兜底**:视觉链路两云端全失败 → 任务 FAILED 走重试,**绝不回退本地模型做视觉/融合**
|
||||
- **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` 格式化。问答阶段按 `gemini → nvidia → ollama` 顺序,仅末位本地模型作兜底。
|
||||
Orchestrator 视觉阶段按 `fallback` 模式顺序降级:Gemini → NVIDIA NIM;云端模型直出结构化 JSON 后由 `format_cloud_result` 格式化,均在 FAM-Edge 内完成。问答阶段完全在 **ai-gateway**(独立服务,见 §3.4)内部按 `nvidia → gemini → ollama` 顺序降级,FAM-Edge 只是转发客户端。
|
||||
|
||||
---
|
||||
|
||||
@@ -63,13 +63,14 @@ Orchestrator 视觉阶段按 `fallback` 模式顺序降级:Gemini → NVIDIA N
|
||||
|
||||
| 节点 | 角色 | 硬件 | 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` |
|
||||
| Synology NAS | 摄像头录像 + 运动事件推送(**仅此而已**,2026-09-13 起) | DS220+ (Geminilake), DSM 7 | 家庭局域网 192.168.50.64 | Surveillance Station :5000(录像机本体,搬不走), **fam-notifier**(无端口的推送进程,轮询 SS → 推甲骨文)。MariaDB 与 FAM-Core 已随镜像层一起下线 |
|
||||
| Oracle Cloud(云服务器) | **除录像外的全部**:视频分析 + 数据 + 接口 + 登录 + 前端 | Ampere A1 4C23G ARM64(无 GPU), Ubuntu 20.04 | 公网 129.146.26.249 / Tailscale(已安装未启用,备用) | FAM-Edge :5000(systemd,视频分析 + 统一登录 + SQLite 权威库), **FAM-Core :5401(systemd,UI/对话/成员接口,仅监听 127.0.0.1)**, AI-Gateway :5100(systemd,问答模型降级链), Caddy :80/:443(前端 + 反代 + forward_auth 鉴权), Ollama :11434(仅本地) |
|
||||
| 家庭网络 | 用户入口 | 普通终端 | 公网 / 家庭网络 | 浏览器访问 `https://smart-camera.zichuan.xyz/`(全部由甲骨文提供,**不依赖 NAS 在线**) |
|
||||
|
||||
**网络要点(推送模式)**:
|
||||
- 服务间通信只有一条:**NAS → Oracle 公网 IP:5000**(HTTP 上传视频)。Edge 不需要反向访问 NAS(无 webhook、无视频拉取)
|
||||
- Oracle 端 Ollama 端口 11434 不对外暴露,聊天请求经 FAM-Edge `/api/edge/chat` 代理转发
|
||||
- 服务间通信只有两条出站:**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 镜像)
|
||||
@@ -81,47 +82,87 @@ Orchestrator 视觉阶段按 `fallback` 模式顺序降级:Gemini → NVIDIA N
|
||||
└──────────────┬───────────────────────┘
|
||||
│ rclone 定时同步(systemd timer)
|
||||
▼
|
||||
┌──────────────────────── Oracle Cloud (129.146.203.203) ────────────────────────┐
|
||||
┌──────────────────────── Oracle Cloud / 云服务器 (129.146.26.249) ────────────────────────┐
|
||||
│ FAM-Edge (Flask :5000) │
|
||||
│ ├─ Watch-Processor: 30s 轮询 /opt/fam-edge/gdrive_videos,新视频串行处理 │
|
||||
│ ├─ Video-Processor: 整视频直传云端 VLM(不切片不抽帧) │
|
||||
│ │ Gemini(Files API) → 失败 NVIDIA(整视频 video_url) → 再失败 FAILED │
|
||||
│ ├─ 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 合并规范名 → 回灌视频提示 │
|
||||
│ ├─ OracleDB (SQLite): videos / events / people / sync_cursor │
|
||||
│ └─ API: /api/oracle/sync (增量拉取) · /api/oracle/people/correct (命名校正) · │
|
||||
│ /api/edge/chat/ask (问答编排 Gemini→NVIDIA→Ollama) │
|
||||
└───────────────────────────────┬───────────────────────────────────────────────┘
|
||||
│ HTTP GET /api/oracle/sync?since=&token= (每 30 分钟)
|
||||
▼
|
||||
│ ├─ 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 各自一份) │
|
||||
│ FAM-Core (Flask :5401,仅 127.0.0.1) │
|
||||
│ ├─ UI-API: /api/ui/*(直读下面那个 SQLite,无镜像、无后台线程) │
|
||||
│ ├─ Chat-Handler: /api/chat/ask(查 events 拼上下文 → 转 fam-edge 编排) │
|
||||
│ ├─ Member-Manager: /api/member/name|merge(转 fam-edge,它负责合并人物) │
|
||||
│ └─ chat_history: 本服务唯一写的表,落在同一个 SQLite 里 │
|
||||
│ SQLite /opt/fam-edge/data/oracle.db(WAL):videos / events / people / │
|
||||
│ model_calls / person_identity_map / ss_motion_events / chat_history │
|
||||
│ Caddy :80/:443:前端静态 + /login 与 /api/auth/* → fam-edge │
|
||||
│ + /api/* 先 forward_auth 再反代 fam-core(唯一鉴权闸门) │
|
||||
└───────────────────────────────▲───────────────────────────────────────────────┘
|
||||
│ POST /api/ss/motion(单向推送 + 5 分钟心跳)
|
||||
│
|
||||
┌─────────────────────────────── 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 (Streamlit :8501) 读同步镜像 │
|
||||
│ Surveillance Station :5000(录像机本体,摄像头插在这台上) │
|
||||
│ fam-notifier:轮询 SS EventCenter → 推运动事件给甲骨文。无端口、无数据库, │
|
||||
│ 游标存本地 JSON 文件。挂了重启即可,不影响网站。 │
|
||||
└─────────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
#### 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 仅一条出站 HTTPS/HTTP:`GET /api/oracle/sync`(拉取)与 `POST /api/oracle/people/correct`(命名回推),均走 Oracle 公网 IP:5000,token 鉴权
|
||||
- Oracle Ollama :11434 不对外暴露,问答经 FAM-Edge `/api/edge/chat/ask` 代理
|
||||
- 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 主链路时序(新架构 v2)
|
||||
### 2.3 主链路时序(新架构 v3:运动事件驱动)
|
||||
|
||||
1. Google 硬盘新视频 → rclone 定时同步到 Oracle `/opt/fam-edge/gdrive_videos`
|
||||
2. Watch-Processor 轮询发现新文件 → 登记到 Oracle `videos`(pending)
|
||||
3. Video-Processor 串行处理:整视频上传 Gemini Files API(或 NVIDIA 整视频 `video_url`)→ 模型直出 `{global_summary, events[], people_mentioned[]}` → 写 Oracle `videos` + `events` + `people`
|
||||
4. Person-Service 每 30 分钟汇总全量人物 → LLM 合并为规范名 → 更新 `people.canonical_name` → 生成 `known_members_context` 回灌后续视频提示
|
||||
5. NAS Oracle-Sync 每 30 分钟 `GET /api/oracle/sync?since=<cursor>` → upsert 到本地 `sync_*` 镜像表 → 推进 `sync_cursor`
|
||||
6. FAM-UI 读本地镜像展示;用户命名 → `POST /api/oracle/people/correct` 回推 Oracle,下一周期同步生效
|
||||
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=<cursor>` → 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 仍包含它会被重试
|
||||
- 运动片段分析失败(两云端均不可用)标记 `failed`,下一周期 cursor 仍包含它会被重试
|
||||
- 素材分割幂等:按 `motion_event_id` 去重,片段文件已存在则跳过分割
|
||||
- NAS 同步失败仅记日志,下一周期(30 分钟)自动重试,不阻塞 UI
|
||||
- fam-core 日志双写:stdout + `fam-core/logs/fam-core.log`
|
||||
|
||||
@@ -131,43 +172,61 @@ Orchestrator 视觉阶段按 `fallback` 模式顺序降级:Gemini → NVIDIA N
|
||||
|
||||
### 3.1 FAM-Core(NAS 端)
|
||||
|
||||
> NAS 不再处理视频,仅作管理后台。唯一常驻后台线程是 Oracle-Sync。
|
||||
> NAS 不再处理视频,仅作管理后台。常驻后台线程:Oracle-Sync(增量镜像)+ MotionNotifier(轮询 SS 运动事件)。
|
||||
|
||||
| 模块 | 文件 | 职责 |
|
||||
|------|------|------|
|
||||
| Oracle-Sync | `oracle_sync/oracle_sync.py` | 唯一后台线程:每 30 分钟 `GET /api/oracle/sync?since=<cursor>&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` 问答编排(Gemini→NVIDIA→本地 Ollama 兜底)→ 写 chat_history |
|
||||
| Oracle-Sync | `oracle_sync/oracle_sync.py` | 后台线程:每 30 分钟 `GET /api/oracle/sync?since=<cursor>&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 并即时拉回本地镜像 |
|
||||
| 公共层 | `db_layer.py` / `config_loader.py` / `logger.py` | PyMySQL 连接(unix_socket);同步镜像 CRUD;文件日志 |
|
||||
| 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 端)
|
||||
|
||||
> 整视频分析,不切片、不抽帧、不依赖 OpenCV 人脸。
|
||||
> 整段素材按运动事件分割运动片段,**只分析运动片段**(不再整段送云端)。
|
||||
|
||||
| 模块 | 文件 | 职责 |
|
||||
|------|------|------|
|
||||
| API-Gateway | `api_gateway/api_gateway.py` | `GET /api/oracle/sync`(增量拉取,since+token 校验);`POST /api/oracle/people/correct`(命名校正);`POST /api/edge/chat/ask`(问答编排);`GET /health` |
|
||||
| Video-Queue | `video_queue.py` | 生产-消费队列:生产者 30s 轮询 rclone 同步落地目录登记新视频入队(含重启恢复);消费者(`max_concurrent` 个线程)取队列调 Video-Processor;模型超时 = 原配置 ×`timeout_multiplier`;失败重试上限 `max_retries` |
|
||||
| Video-Processor | `video_processor.py` | 按 `vision_order` 调适配器 `analyze_video`(整视频);首个成功即落库 Oracle `videos`+`events`+`people`;全失败标 `failed` |
|
||||
| Person-Service | `person_service.py` | 汇总全量人物 → LLM 合并为规范名 → `set_canonical`;生成 `known_members_context` 回灌视频提示;manual 命名优先不被覆盖 |
|
||||
| OracleDB | `oracle_db.py` | SQLite:videos / events / people / sync_cursor;`get_sync_delta(since)` 增量导出 |
|
||||
| Model-Adapters | `model_adapters/` | `BaseModelAdapter.analyze_video(video_path, known_members_context, event_start_time)`;Gemini(Files API 整视频)/ NVIDIA(整视频 `video_url`,`num_frames=128`)/ Ollama(纯文本,不参与视频) |
|
||||
| QA-Orchestrator | `qa.py` | 遍历所有适配器 `chat()`,Gemini→NVIDIA→Ollama 三级降级(仅问答) |
|
||||
| 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 端)
|
||||
### 3.3 FAM-UI(云服务器端,Caddy 托管)
|
||||
|
||||
Streamlit 应用(`fam-ui/src/app.py`),侧边栏切换页面(均读本地同步镜像):
|
||||
Vue3 + Vite + Tailwind SPA(`fam-ui/src/views/*.vue`,构建产物 `fam-ui/dist/` 部署到甲骨文云服务器由 Caddy 静态托管,Vue Router history 模式;`/api/*` 经 frp 隧道反代回 NAS FAM-Core),页面均读本地同步镜像:
|
||||
|
||||
| 页面 | 功能 |
|
||||
|------|------|
|
||||
| 🕒 事件时间轴 | 视频会话列表(按处理后时间倒序)+ 选中会话的事件时间线(时间点 + 描述 + 人物/关注徽章,无帧图) |
|
||||
| 🕒 事件时间轴 | 运动片段会话列表(`motion_*.mp4`,只显示有内容的会话)+ 选中会话的事件时间线(时间点 + 描述 + 人物/关注徽章 + 事件帧图);支持 `?video=<id>` 定位跳转;详情卡片支持**删除会话**(原生 `confirm()` 二次确认,2026-08-24 新增) |
|
||||
| 👤 人物管理 | 按规范名/标签聚合,命名/合并(回推 Oracle);**人物卡含「运动片段」区块**(该人物出现过的运动片段:缩略图/时间/摘要/事件数,点击跳时间轴) |
|
||||
| 💬 AI 对话 | 输入框 + 调 `/api/chat/ask`;按 queried_person 预设快捷提问 |
|
||||
| 📝 对话历史 | chat_history 倒序展示 |
|
||||
| 👤 人物管理 | 按规范名/标签聚合,命名/合并(回推 Oracle);不再展示帧照片 |
|
||||
| 📈 统计图表 | 模型来源占比 / 关注事件 / 同步状态 |
|
||||
|
||||
### 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 <AI_GATEWAY_TOKEN>`),**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. 数据库设计
|
||||
@@ -182,19 +241,20 @@ Streamlit 应用(`fam-ui/src/app.py`),侧边栏切换页面(均读本地
|
||||
|
||||
| 表 | 用途 | 关键字段 |
|
||||
|----|------|---------|
|
||||
| `videos` | 视频会话(每视频 1 行) | id, filename(UNIQUE), camera_name, status, summary_json, events_json, people_json, compute_provider, event_start_time, updated_at |
|
||||
| `events` | 视频内时间点事件 | id, video_id, ts, description, person_list_json, is_attention_event |
|
||||
| `people` | 规范人物(Oracle 维护) | id, label(UNIQUE), canonical_name, appearances, source(llm/manual) |
|
||||
| `sync_cursor` | 同步游标 | key, value(上次 server_time) |
|
||||
| `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_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 |
|
||||
| `sync_cursor` | 同步游标 | key='last_since', value |
|
||||
| `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 表关系
|
||||
@@ -210,7 +270,7 @@ chat_history 独立表(问答上下文摘要留存)
|
||||
### 4.3 compute_provider
|
||||
|
||||
- `sync_videos.compute_provider`:字符串,记录该视频实际成功调用的视觉模型(`gemini` / `nvidia`);本地 Ollama 不参与视频分析,不会出现在该字段
|
||||
- 问答链路(Gemini→NVIDIA→Ollama 兜底)的 provider 体现在 `/api/edge/chat/ask` 响应的 `provider` 字段
|
||||
- 问答链路(AI-Gateway 内部 NVIDIA→Gemini→Ollama 降级链)的 provider 体现在 `/api/edge/chat/ask` 响应的 `provider` 字段(由 AI-Gateway 原样透传回来)
|
||||
|
||||
### 4.4 兼容性注意
|
||||
|
||||
@@ -251,27 +311,56 @@ chat_history 独立表(问答上下文摘要留存)
|
||||
- 401:token 校验失败
|
||||
|
||||
**POST /api/oracle/people/correct** — 命名校正回推:`{"label":"人物A","canonical_name":"汤圆","token":...}`(manual 优先,不被 LLM 覆盖)→ `{"status":"ok"}`
|
||||
**POST /api/edge/chat/ask** — 智能问答编排(FAM-Core Chat-Handler 调用):请求 `{"prompt","max_tokens"}` → 响应 `{"answer","provider"}`;内部按 Gemini → NVIDIA → 本地 Ollama 顺序,仅两云端都失败才用本地兜底
|
||||
**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)
|
||||
**登录相关端点**(2026-09-12 从 NAS FAM-Core 迁入,见 `fam-edge/src/fam_edge/auth.py`)
|
||||
|
||||
| 端点 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| `/health` | GET | 服务健康 |
|
||||
| `/api/status` | GET | Oracle-Sync 同步状态(running / last_sync_at / last_error / cursor / last_count) |
|
||||
| `/login` | GET | 登录入口:生成 PKCE 参数后 302 跳 auth-hub 公网 `/authorize` |
|
||||
| `/api/auth/callback` | GET | auth-hub 回跳:用 `AUTH_HUB_INTERNAL_BASE`(本机 :5300)换 token + 拉 JWKS 验签,成功后种 HttpOnly cookie `fam_session`(HS256 无状态签名,2 小时);**任何一步失败都渲染错误页,绝不 302 回 `/login`**——旧实现失败即跳 `/login`,而 auth-hub 有会话时会立刻再签发 code,两边对跳成死循环 |
|
||||
| `/api/logout` | POST | 退出登录(清 cookie,不影响 auth-hub 上的 SSO 会话) |
|
||||
| `/api/auth/check` | GET | 登录态检查:`{"authed": true\|false, "username": "..."}` |
|
||||
| `/api/auth/verify` | ANY | 给甲骨文 Caddy 的 `forward_auth` 用:已登录 204,未登录 401 JSON |
|
||||
|
||||
### 5.2 FAM-Core(甲骨文 127.0.0.1:5401)
|
||||
|
||||
> 2026-09-13 起 FAM-Core 与 FAM-Edge 同机,直读它的 SQLite(不再有 MariaDB 镜像层),且**只监听 127.0.0.1**——唯一客户端是同机 Caddy。登录端点在 FAM-Edge(见 §5.1),鉴权由 Caddy 的 `forward_auth` 前置完成,所以本服务自身不做任何校验。
|
||||
|
||||
| 端点 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| `/health` | GET | 服务健康(**免登录**) |
|
||||
| `/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/<id>` | GET | 会话详情:`{video, events[]}`(事件含 person_list_json / person_appearances_json / is_attention_event) |
|
||||
| `/api/ui/videos/<id>` | 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/<path>`(视频处理职责已迁移至 Oracle)。
|
||||
|
||||
### 5.3 云端结构化输出 JSON Schema
|
||||
|
||||
整视频直传云端 VLM,模型直接产出结构化 JSON(`analyze_video` 返回):
|
||||
运动片段直传云端 VLM,模型直接产出结构化 JSON(`analyze_video` 返回):
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -284,15 +373,19 @@ chat_history 独立表(问答上下文摘要留存)
|
||||
}
|
||||
```
|
||||
|
||||
- Gemini 用 Files API 上传整视频后 `generateContent`;NVIDIA 用整视频 `video_url` + `num_frames=128`(模型内部自行采样帧),均不切片、不抽帧、不依赖 OpenCV
|
||||
- `person_service` 汇总全量 `people_mentioned` → LLM 合并为规范名 → 生成 `known_members_context` 回灌后续视频提示,使模型用真名指代
|
||||
- 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 视频预处理(自适应关键帧)
|
||||
### 6.1 视频预处理(历史记录:关键帧抽帧方案,v3 已废弃)
|
||||
|
||||
> ⚠️ 本小节为旧架构(v1/v2 本地抽帧分析)记录。**v3 运动事件驱动架构不再抽帧**:
|
||||
> 整段素材按运动事件 ffmpeg 分割运动片段(`-c:v copy`),片段直传云端 VLM 分析。
|
||||
> 保留此处仅作历史参考。
|
||||
|
||||
- **粗抽候选帧**:FFmpeg 快速 seek(逐帧 `ffmpeg -ss <ts> -frames:v 1`),替代 fps 滤镜全解码(30min 视频从 180s+ 降到 33s,6-8x 提速)
|
||||
- **帧数自适应**:候选帧 `clamp(时长分钟×2, 30, 120)`;关键帧上限 `clamp(时长/150s, 8, 30)`(30min→12 帧,60min→24 帧,封顶 30)
|
||||
@@ -300,19 +393,19 @@ chat_history 独立表(问答上下文摘要留存)
|
||||
- **压缩**:长边 > 1024px 才缩放,JPEG 质量 80
|
||||
- **异常兜底**:ffprobe 失败退化为 60s 间隔抽帧;帧差异常退化为等距 5 帧
|
||||
|
||||
### 6.2 本地 Ollama(仅智能问答兜底,ARM CPU)
|
||||
### 6.2 本地 Ollama(仅智能问答兜底,归属 AI-Gateway)
|
||||
|
||||
本地 Ollama(qwen2.5:7b,纯文本模型)**不参与视觉分析、不参与云端结果融合**。它只在**智能问答**场景下、且 Gemini 与 NVIDIA 两云端模型都失败时才被启用作为兜底。视觉分析与结构化输出全部由云端模型承担。以下参数作为问答任务的调优依据保留。
|
||||
本地 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,过长生成会拖慢问答响应 |
|
||||
| 视觉/模型 timeout | 600s | 实测 1024px 帧视觉编码 ~36s + 生成 ~12s/60token(问答链路改用 config 中各模型 timeout) |
|
||||
| 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 承担)。
|
||||
**历史问题**:原 llava-phi3 多图单请求基本失效(N 张图一次调用输出长度仅 3~4)。已替换为 qwen2.5:7b(纯文本模型,专职问答兜底,不涉及视觉多图问题;视频分析已全部由云端 VLM 承担)。
|
||||
|
||||
### 6.3 模型适配器架构
|
||||
|
||||
@@ -332,13 +425,14 @@ chat_history 独立表(问答上下文摘要留存)
|
||||
|
||||
| provider | 适配器类 | role | SDK / 协议 | 状态 |
|
||||
|----------|---------|------|------------|------|
|
||||
| `ollama` | `OllamaAdapter` | **text** | requests 直调 REST `/api/chat` | 已实现 |
|
||||
| `gemini` | `GeminiAdapter` | **vision** | requests 直调 REST `:generateContent` | 待实现 |
|
||||
| `nvidia` | `NvidiaVisionAdapter` | **vision** | **openai SDK**(NIM 兼容 OpenAI API 规范) | 待实现 |
|
||||
| `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`:仅参与智能问答(`usage: qa_fallback`),且为 Gemini/NVIDIA 都失败时的兜底,不参与视觉分析、不参与云端结果融合
|
||||
- `text`:**已不在 FAM-Edge 出现**——原来"仅参与智能问答、Gemini/NVIDIA 都失败时兜底"的语义现在完全由独立的 AI-Gateway 服务内部实现(见 §3.4)
|
||||
|
||||
**NvidiaVisionAdapter 关键实现**(`fam_edge/adapters/nvidia_adapter.py`):
|
||||
|
||||
@@ -377,30 +471,40 @@ format_cloud_result:字段归一化 → 补 source_providers=[provider] / comp
|
||||
合法入库结构,随响应返回 NAS 直接落库(本地模型不介入)
|
||||
```
|
||||
|
||||
**智能问答降级链路(chat 场景)**:
|
||||
**智能问答降级链路(chat 场景,2026-08-23 起完全在独立的 AI-Gateway 服务内部,FAM-Edge 只转发)**:
|
||||
|
||||
```
|
||||
Gemini (role=vision, 也参与问答)
|
||||
│ 失败 / 熔断 OPEN / 超时
|
||||
FAM-Edge /api/edge/chat/ask(/stream)
|
||||
│ HTTP 转发(qa.py,本机回环)
|
||||
▼
|
||||
NVIDIA NIM (role=vision, 也参与问答)
|
||||
│ 失败 / 熔断 OPEN / 超时
|
||||
AI-Gateway /v1/chat/completions
|
||||
│
|
||||
▼
|
||||
本地 Ollama (qwen2.5:7b, role=text, usage=qa_fallback) —— 仅当两云端都失败才启用
|
||||
NVIDIA(问答专用文字模型链,跟视觉分析的 omni 模型完全独立)
|
||||
│ 完全没有输出 / 熔断 OPEN / 超时
|
||||
▼
|
||||
Gemini(问答专用非 flash 文字模型链,多 Key 随机轮换,跟视觉分析的 flash 模型完全独立)
|
||||
│ 完全没有输出 / 熔断 OPEN / 超时
|
||||
▼
|
||||
本地 Ollama (qwen2.5:7b) —— 仅当前两者都失败才启用
|
||||
│ 失败
|
||||
▼
|
||||
返回"所有模型均不可用"
|
||||
返回"所有模型均不可用"(HTTP 503)
|
||||
```
|
||||
|
||||
**熔断器策略**(按 provider 独立,仅云端模型启用):
|
||||
> 已经开始吐字之后中途失败:不换下一个 provider 接着写(避免答案风格前后不连贯),直接结束这次生成——这个语义在 AI-Gateway 的 `orchestrator.py` 里实现,FAM-Edge 的 `qa.py` 只是原样转发这个行为,不重复实现。
|
||||
|
||||
| provider | role | threshold | cooldown | enabled |
|
||||
|----------|------|-----------|----------|---------|
|
||||
| Gemini | vision | 3 次连续失败 | 600s | true |
|
||||
| NVIDIA NIM | vision | 3 次连续失败 | 600s | true |
|
||||
| Ollama | text | — | — | false(本地,不熔断) |
|
||||
**熔断器策略**(按 provider 独立):
|
||||
|
||||
**健康探测**:Ollama `GET /api/tags`;Gemini `GET /v1/models?key=...`;NVIDIA `client.models.list()`。视觉模型全部不健康返回 503。
|
||||
| 服务 | 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` 校验非空数组。
|
||||
|
||||
@@ -447,42 +551,77 @@ task_id=289(30s 测试片段)全链路打通:推送 5.7MB → Edge 分析
|
||||
|
||||
| 组件 | 节点 | 路径 | 启动 |
|
||||
|------|------|------|------|
|
||||
| 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` |
|
||||
| FAM-Core | Oracle | `/opt/fam-core/` | **systemd `fam-core.service`**(gunicorn -w 2 --threads 4,绑 127.0.0.1:5401);部署代码后 `sudo systemctl restart fam-core` |
|
||||
| fam-notifier | NAS | `/volume1/web/sentinel-home-ai/fam-notifier/` | `setsid bash scripts/start_notifier.sh &`(DSM 无 systemd,挂了不自启);NAS 上唯一运行的本项目进程 |
|
||||
| FAM-UI | 云服务器(甲骨文,129.146.26.249) | `/var/www/fam-ui/` | Vue3 构建产物,由 Caddy :80 静态托管(无需独立进程);本地改代码后 `npm run build`,`dist/` rsync/tar 到云服务器 |
|
||||
| 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 这条已于 2026-09-13 迁云后废弃**(本系统不再有任何"回源 NAS"的流量,可从 frpc.toml 删除)
|
||||
> - 前端入口 `https://smart-camera.zichuan.xyz/`:静态页、接口、登录全部由甲骨文提供,**NAS 离线也能正常访问**(只是不再有新的运动事件推进来),**需登录**(见 §5.1 `/login`)
|
||||
>
|
||||
> **登录校验(2026-08-22 新增;2026-08-31 接入 auth-hub 统一登录;2026-09-12 整体迁到甲骨文 FAM-Edge)**:登录流程和会话校验现在都在甲骨文 FAM-Edge 的 `auth.py`(§5.1),NAS FAM-Core 自身不再做任何鉴权。迁移的直接原因:NAS 或 frp 隧道一挂,`smart-camera.zichuan.xyz/login` 直接 502,**连登录页都打不开**——登录是入口,不该依赖家里那台机器。
|
||||
>
|
||||
> - **链路**:`/login`(Caddy → 本机 fam-edge :5000)→ 302 到 auth-hub 公网 `/authorize`(Authorization Code + PKCE)→ 用户在 [auth-hub](https://auth.zichuan.xyz) 登录 → 回跳 `/api/auth/callback`(仍是 fam-edge)→ 走**本机** `http://127.0.0.1:5300` 换 token、拉 JWKS 验 `id_token` 签名 → 种 cookie `fam_session`
|
||||
> - **为什么服务端调用走本机**:旧实现是 NAS 跨公网访问 `https://auth.zichuan.xyz`,那条链路上 `jwt.PyJWKClient` 用 urllib + 系统 CA(不像 requests 自带 certifi),群晖上容易 `CERTIFICATE_VERIFY_FAILED`;两台机器的时钟偏差还会让 `iat` 看起来来自未来。同机直连把这两个坑一起消掉。但 `iss` 校验和浏览器跳转仍用公网 `AUTH_HUB_ISSUER`
|
||||
> - **拦截**:甲骨文 Caddy 对 `/api/*` 做 `forward_auth` → fam-edge `/api/auth/verify`,200/204 才反代回 NAS;`/login`、`/api/auth/*`、`/api/logout` 直接由 fam-edge 处理(Caddy 按路径具体程度排序,这几条稳定排在 `/api/*` 前面)
|
||||
> - **NAS :8000 必须靠防火墙兜底**:frps 把它暴露在公网(历史入口 `http://129.146.26.249:8000`),绕过 Caddy 直连就没有任何鉴权。甲骨文 iptables 只允许本机访问 :8000,见 `docs/DEPLOY.md` §2.3
|
||||
> - **参数**(`/opt/fam-edge/.env`,**无硬编码默认值**,缺任一项拒绝所有登录 fail closed):`AUTH_HUB_ISSUER`(公网,浏览器跳转 + `iss` 校验)/ `AUTH_HUB_INTERNAL_BASE`(本机,换 token + JWKS)/ `AUTH_HUB_CLIENT_ID` / `AUTH_HUB_CLIENT_SECRET` / `AUTH_HUB_REDIRECT_URI`(必须跟 auth-hub 登记的逐字符一致,只做精确匹配)/ `FAM_SESSION_SECRET`(会话签名密钥,≥32 字节)
|
||||
> - **会话是无状态签名 cookie**(HS256,2 小时),fam-edge 重启不掉线(旧实现是进程内 token 表,fam-core 一重启全员下线);要强制全员下线就换掉 `FAM_SESSION_SECRET` 再重启
|
||||
> - auth-hub 侧任何审批通过的账号登录后都能访问本系统,**未再加用户名白名单**——自用场景的有意取舍(见项目记忆)
|
||||
|
||||
> 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/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, **openai**(NVIDIA NIM 兼容 OpenAI API 规范,复用同一 SDK);Gemini 用 requests 直调 REST(不依赖 google-generativeai SDK)
|
||||
- **系统级**:FFmpeg(两端)、Ollama + qwen2.5:7b(Oracle)、MariaDB 10.11(NAS)
|
||||
- **FAM-Core(NAS, Python 3.10 venv)**:Flask, Gunicorn, **PyMySQL**(45KB 纯 Python 替代 19MB mysql-connector), PyYAML, requests
|
||||
- **FAM-UI(云服务器, 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,新架构 v2 —— 仅同步 + 问答):
|
||||
**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.203.203:5000"
|
||||
oracle_sync: # 增量镜像线程
|
||||
base_url: "http://129.146.26.249:5000"
|
||||
token: "${ORACLE_SYNC_TOKEN}" # 与 Oracle 端 sync_api.token 一致
|
||||
interval_sec: 1800 # 每 30 分钟拉一次增量
|
||||
timeout: 120
|
||||
chat_handler:
|
||||
qa_url: "http://129.146.203.203:5000/api/edge/chat/ask" # 问答统一走 Oracle 编排
|
||||
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,整视频分析 + 同步 + 人物服务):
|
||||
**fam-edge/config/config.yaml**(Oracle —— 素材分割 + 片段分析 + 同步 + 人物服务;**2026-08-23 起不再含任何问答模型配置**):
|
||||
|
||||
```yaml
|
||||
server:
|
||||
port: 5000
|
||||
gdrive_sync: # rclone 同步落地目录监听
|
||||
gdrive_sync: # rclone 同步落地目录(整段素材)
|
||||
enabled: true
|
||||
local_dir: "/opt/fam-edge/gdrive_videos"
|
||||
watch_interval_sec: 30
|
||||
@@ -491,7 +630,7 @@ gdrive_sync: # rclone 同步落地目录监听
|
||||
oracle_db:
|
||||
path: "/opt/fam-edge/data/oracle.db"
|
||||
sync_api:
|
||||
token: "${ORACLE_SYNC_TOKEN}" # NAS 拉取鉴权(与 NAS oracle_sync.token 一致)
|
||||
token: "${ORACLE_SYNC_TOKEN}"
|
||||
person_service:
|
||||
schedule_interval_sec: 1800 # 每 30 分钟重新汇总人物
|
||||
model: "gemini"
|
||||
@@ -499,42 +638,59 @@ video_processing:
|
||||
max_concurrent: 1 # 单视频串行,避免抢占云端配额
|
||||
timeout: 900
|
||||
vision_order: ["gemini", "nvidia"]
|
||||
models:
|
||||
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-nano-12b-v2-vl" # 整视频 video_url 输入(内部采样帧)
|
||||
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
|
||||
- provider: "ollama"
|
||||
role: "text"
|
||||
usage: "qa_fallback" # 仅智能问答兜底,不参与视频
|
||||
model_name: "qwen2.5:7b"
|
||||
``` api_key: "${NVIDIA_API_KEY}"
|
||||
timeout: 20
|
||||
circuit_breaker:
|
||||
enabled: true
|
||||
threshold: 3
|
||||
cooldown: 600
|
||||
```
|
||||
|
||||
# 3. 本地 Ollama(纯文本,仅 Q&A 兜底,不参与视觉分析、不参与云端结果融合)
|
||||
**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"
|
||||
role: "text" # 仅问答兜底
|
||||
usage: "qa_fallback" # Gemini/NVIDIA 都失败时才启用
|
||||
enabled: true
|
||||
model_name: "qwen2.5:7b"
|
||||
base_url: "http://localhost:11434"
|
||||
timeout: 120
|
||||
num_predict: 512
|
||||
circuit_breaker:
|
||||
enabled: false
|
||||
```
|
||||
|
||||
> 部署时 `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
|
||||
@@ -564,11 +720,15 @@ print('NVIDIA NIM 连接成功:', response.choices[0].message.content)
|
||||
|
||||
### 8.4 运维注意事项
|
||||
|
||||
- NAS 部署目录**不是 git 仓库**(文件拷贝部署),同步代码用 stdin 管道:`ssh ... "cat > 远端路径" < 本地文件`
|
||||
- NAS 部署目录**不是 git 仓库**(文件拷贝部署),同步代码用 stdin 管道:`tar czf - <子目录> | ssh ... 'tar xzf - -C <目标>'`
|
||||
- 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`)
|
||||
- **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 自身命令行导致断连)
|
||||
- **sqlite3 连接不能跨线程共享**(2026-09-13 修):`OracleDB` 原来在 `__init__` 里建一条 `check_same_thread=False` 的连接给全进程用,VideoQueue / PersonService / DiskGuard 三个后台线程加 gunicorn 请求线程并发读写它,事务状态互相踩踏,线上累计刷出 `database is locked` 71604 次、`cannot start a transaction within a transaction` 378 次、`no more rows available` 92 次;DiskGuard 的清理被打断 2837 次,磁盘守护形同虚设(剩余空间在 2.7G 和 16G 之间来回荡)。改成 `threading.local()` 每线程一条连接后,WAL 下读不互斥、写由 SQLite 自己排队。**新增后台线程时不要再去共用某一条连接对象**
|
||||
- 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
|
||||
|
||||
---
|
||||
|
||||
@@ -578,24 +738,24 @@ print('NVIDIA NIM 连接成功:', response.choices[0].message.content)
|
||||
# 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
|
||||
# 2. 启动 FAM-Core(NAS,含运动监测轮询 + Oracle-Sync)
|
||||
cd /volume1/web/sentinel-home-ai && bash start_core.sh
|
||||
|
||||
# 3. 启动 FAM-Edge(Oracle)
|
||||
cd fam-edge && gunicorn -w 1 -b 0.0.0.0:5000 --timeout 1800 src.fam_edge.app:app
|
||||
# 3. 启动 FAM-Edge(Oracle,systemd 守护)
|
||||
sudo systemctl restart fam-edge
|
||||
|
||||
# 4. 启动 FAM-UI(NAS)
|
||||
cd fam-ui && streamlit run src/app.py
|
||||
# 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 && ./scripts/start_ui.sh
|
||||
./scripts/start_core.sh && ./scripts/start_edge.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 服务器访问信息
|
||||
|
||||
### 10.1 Synology NAS(FAM-Core + FAM-UI + MariaDB)
|
||||
### 10.1 Synology NAS(FAM-Core + MariaDB)
|
||||
|
||||
| 项目 | 值 |
|
||||
|------|-----|
|
||||
@@ -620,18 +780,20 @@ cd fam-ui && streamlit run src/app.py
|
||||
|
||||
| 项目 | 值 |
|
||||
|------|-----|
|
||||
| 公网 IP | 129.146.203.203 |
|
||||
| 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` |
|
||||
| 公网 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 | http://192.168.50.64:3000/ericwyuan/sentinel-home-ai |
|
||||
| 账号 / 密码 | ericwyuan / iLoveJava5 |
|
||||
| 仓库 | 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)
|
||||
|
||||
@@ -691,22 +853,17 @@ export NVIDIA_API_KEY="nvapi-9cFAdO5xdbwPuxS8KGRTnlVimn1gJzbbbzWNhPwHa_Yl3pTe-Pf
|
||||
|
||||
## 12. 当前进度与 v1.1 计划
|
||||
|
||||
### 已完成(截至 2026-08-20)
|
||||
### 已完成(截至 2026-08-28)
|
||||
|
||||
- 全部模块代码 + DDL + 部署脚本;NAS/Oracle 双端部署运行
|
||||
- 模型基准测试(原 llava-phi3 预热 4.3s PASS,**已替换为 qwen2.5:7b**);Ollama 常驻内存
|
||||
- 架构改为推送模式(NAS 上传整段视频 → Edge 同步分析 → 结果随响应返回)
|
||||
- 关键帧自适应帧数 + FFmpeg 快速 seek(6-8x 提速)
|
||||
- FAM-Core API 全端点测试通过
|
||||
- FAM-UI 部署(Streamlit 1.61.1)
|
||||
- 真实视频性能基准(30min/360MB → 929s)
|
||||
- **E2E 全链路打通**:task 289 → SUCCESS,monitor_events/event_details 落库正确
|
||||
- 可靠性加固:僵尸任务回收、文件日志、datetime 空值兜底、超时按实测调整
|
||||
- **架构重构(移除本地融合,云端直出直存)**:
|
||||
- 视频分析链路:云端 VLM(Gemini `gemini-flash-latest` / NVIDIA NIM `llama-3.2-11b-vision-instruct`)直出结构化 JSON → Edge `format_cloud_result` 格式化/校验(无模型调用)→ 直存 NAS DB;本地 Ollama 不再参与视频摘要与融合(`run_text_fusion` 已移除)
|
||||
- 智能问答链路:新增 `chat()` 方法,`run_qa` 按 Gemini → NVIDIA → 本地 Ollama 降级编排;端点 `/api/edge/chat/ask`;NAS Chat-Handler 经 Edge 编排(`qa_url`),不再直连 Ollama
|
||||
- 生产目录已切回 `/volume1/surveillance/Generic_ONVIF-001`,285 历史视频 forward-only 占位跳过
|
||||
- **双端部署验证通过**(2026-08-20):Oracle Edge 7 文件部署 + 重启,`/api/edge/chat/ask` 实测 provider=nvidia(Gemini 超时→NVIDIA 兜底成功);NAS chat_handler + config 手术式更新 + HUP 重载,插入临时上下文实测 NAS→Edge 编排链路 43s 返回并落库(测试数据已清理);Edge 日志确认 task 294(360MB)以新架构处理中、三模型健康检查全通过
|
||||
- **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/<id>`);删 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`。
|
||||
|
||||
@@ -717,6 +874,7 @@ export NVIDIA_API_KEY="nvapi-9cFAdO5xdbwPuxS8KGRTnlVimn1gJzbbbzWNhPwHa_Yl3pTe-Pf
|
||||
| 1 | 单元测试(JSON parser / circuit breaker / schema 校验) | 中 |
|
||||
| 2 | Tailscale 修复(NAS userspace 模式升级,流量不走公网) | 低 |
|
||||
| 3 | daily_summaries 每日摘要 | 低 |
|
||||
| 4 | 运动片段音频策略验证(当前保留 aac 转码) | 低 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
279
docs/DEPLOY.md
279
docs/DEPLOY.md
@@ -1,155 +1,190 @@
|
||||
# 部署指南
|
||||
# 部署指南(2026-09-13 全量迁云后同步)
|
||||
|
||||
## 1. NAS 端部署 (FAM-Core + FAM-UI + MariaDB)
|
||||
> **一句话架构**:除了录像本身,全部跑在甲骨文。NAS 只剩 Surveillance Station
|
||||
> 和一个往甲骨文推运动事件的进程(fam-notifier)——NAS 离线不影响网站访问。
|
||||
|
||||
### 1.1 MariaDB
|
||||
## 1. NAS 端部署(只有 fam-notifier)
|
||||
|
||||
2026-09-13 迁云后 NAS 上**不再有** MariaDB、FAM-Core、FAM-UI:镜像层整个删除
|
||||
(数据权威源本来就在甲骨文),接口和前端也都搬走了。旧部署的清理见 §1.2。
|
||||
|
||||
### 1.1 fam-notifier(轮询 Surveillance Station → 推甲骨文)
|
||||
```bash
|
||||
# 安装 MariaDB (通过 Synology 套件中心)
|
||||
# 确认端口 3306 可用
|
||||
|
||||
# 执行 DDL
|
||||
python3 scripts/init_db.py --host 127.0.0.1 --port 3306 --user root --password <密码>
|
||||
```
|
||||
|
||||
### 1.2 FAM-Core
|
||||
```bash
|
||||
cd /path/to/sentinel-home-ai/fam-core
|
||||
cd /volume1/web/sentinel-home-ai/fam-notifier
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
pip install -r requirements.txt # 只有 requests + PyYAML
|
||||
|
||||
# 配置
|
||||
cp config/config.yaml.example config/config.yaml
|
||||
# 编辑 config.yaml: 数据库密码、Oracle Tailscale IP、token 等
|
||||
# 编辑 config.yaml:camera_ids、oracle_base_url;DSM 凭据与 token 走仓库根 .env
|
||||
# 仓库根 .env 需提供 DSM_ACCOUNT / DSM_PASSWORD / ORACLE_SYNC_TOKEN
|
||||
|
||||
# 启动
|
||||
./scripts/start_core.sh
|
||||
# 或手动: gunicorn -w 1 -b 0.0.0.0:8000 --timeout 120 src.fam_core.app:app
|
||||
# 启动(DSM 没有 systemd,用 setsid 脱离 SSH 会话;挂了不会自启,需要人工重启)
|
||||
cd /volume1/web/sentinel-home-ai/fam-notifier
|
||||
setsid bash scripts/start_notifier.sh >> logs/start.log 2>&1 &
|
||||
|
||||
# 确认在跑(进程没有监听端口,只能看进程和日志)
|
||||
ps aux | grep "[f]am_notifier"
|
||||
tail -f logs/fam-notifier.log # 应每 60s 轮询、每 5min 心跳
|
||||
```
|
||||
|
||||
### 1.3 FAM-UI
|
||||
游标存在 `fam-notifier/data/cursor.json`(迁云前存在 MariaDB)。删掉也不致命:
|
||||
窗口回看会把最近的事件补推一遍,甲骨文按 event_id 幂等落库。
|
||||
|
||||
### 1.2 清理旧部署(迁云一次性动作)
|
||||
```bash
|
||||
cd /path/to/sentinel-home-ai/fam-ui
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
# 1) 停掉 NAS 上的 fam-core(它已经不该再跑了;跑着也没用,前端不再连它)
|
||||
ps aux | grep "[f]am-core/venv/bin/gunicorn" # 字符类写法:pkill 会杀掉 SSH 自己
|
||||
kill <上面的 pid>
|
||||
|
||||
# 配置
|
||||
cp config/config.yaml.example config/config.yaml
|
||||
# 编辑 config.yaml: 数据库连接、FAM-Core 地址
|
||||
|
||||
# 启动
|
||||
./scripts/start_ui.sh
|
||||
# 或手动: streamlit run src/app.py --server.port 8501 --server.address 0.0.0.0
|
||||
# 2) frpc 里的 8000 映射可以删了(/etc/frp/frpc.toml 的 fam-core 段),
|
||||
# 甲骨文侧对应的 iptables DROP 规则也就没有存在意义了
|
||||
# 3) MariaDB 的 sentinel_home_ai 库确认 chat_history 已迁移(§2.4)后再考虑删
|
||||
```
|
||||
|
||||
## 2. Oracle 端部署 (FAM-Edge + Ollama + FFmpeg)
|
||||
### 1.3 FAM-UI(Vue3 SPA,构建后托管在甲骨文 Caddy)
|
||||
```bash
|
||||
cd /Users/ericwyuan/Desktop/Work/sentinel-home-ai/fam-ui # 本地开发机
|
||||
npm install
|
||||
npm run build # 产物 fam-ui/dist/
|
||||
tar czf - -C fam-ui dist | ssh -i ~/.ssh/oracle_new ubuntu@129.146.26.249 \
|
||||
'sudo tar xzf - --strip-components=1 -C /var/www/fam-ui && sudo chown -R ubuntu:ubuntu /var/www/fam-ui'
|
||||
# 浏览器访问 https://smart-camera.zichuan.xyz/
|
||||
```
|
||||
|
||||
## 2. Oracle 端部署 (FAM-Edge + FAM-Core + Ollama + FFmpeg)
|
||||
|
||||
### 2.1 系统依赖
|
||||
```bash
|
||||
# FFmpeg
|
||||
sudo apt update && sudo apt install -y ffmpeg
|
||||
sudo apt update && sudo apt install -y ffmpeg python3-opencv
|
||||
|
||||
# OpenCV 依赖
|
||||
sudo apt install -y python3-opencv libopencv-dev
|
||||
|
||||
# Ollama
|
||||
# Ollama + qwen2.5:7b(纯文本,仅问答兜底)
|
||||
curl -fsSL https://ollama.com/install.sh | sh
|
||||
systemctl enable ollama
|
||||
systemctl start ollama
|
||||
|
||||
# 拉取模型
|
||||
ollama pull llava-phi3
|
||||
systemctl enable ollama && systemctl start ollama
|
||||
ollama pull qwen2.5:7b
|
||||
```
|
||||
|
||||
### 2.2 FAM-Edge
|
||||
### 2.2 FAM-Edge(systemd 守护)
|
||||
```bash
|
||||
cd /path/to/sentinel-home-ai/fam-edge
|
||||
cd /opt/fam-edge
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 配置
|
||||
cp config/config.yaml.example config/config.yaml
|
||||
# 编辑 config.yaml: NAS Tailscale IP、media_token、Gemini API Key
|
||||
|
||||
# 设置 Gemini API Key 环境变量
|
||||
export GEMINI_API_KEY="your-api-key-here"
|
||||
|
||||
# 启动
|
||||
./scripts/start_edge.sh
|
||||
# 或手动: gunicorn -w 1 -b 0.0.0.0:5000 --timeout 600 src.fam_edge.app:app
|
||||
```
|
||||
|
||||
### 2.3 模型可行性压测 (阶段一验证)
|
||||
```bash
|
||||
# 测试 llava-phi3 单张图推理耗时
|
||||
ollama run llava-phi3 "描述这张图片" --images /path/to/test.jpg
|
||||
|
||||
# 预期:
|
||||
# - 单张 ≤ 8 秒 → 可行
|
||||
# - 单张 ≤ 30 秒 → 可接受
|
||||
# - 单张 > 30 秒 → 需换更小模型或减帧
|
||||
```
|
||||
|
||||
## 3. Tailscale 组网
|
||||
|
||||
```bash
|
||||
# NAS 端
|
||||
curl -fsSL https://tailscale.com/install.sh | sh
|
||||
sudo tailscale up
|
||||
|
||||
# Oracle 端
|
||||
curl -fsSL https://tailscale.com/install.sh | sh
|
||||
sudo tailscale up
|
||||
|
||||
# 验证
|
||||
# NAS: ping 100.x.x.20 (Oracle Tailscale IP)
|
||||
# Oracle: ping 100.x.x.10 (NAS Tailscale IP)
|
||||
```
|
||||
|
||||
## 4. 开机自启 (可选)
|
||||
|
||||
### NAS 端 (使用任务计划)
|
||||
```bash
|
||||
# Synology DSM -> 控制面板 -> 任务计划 -> 新增 -> 触发的任务 -> 开机
|
||||
# 脚本:
|
||||
cd /path/to/sentinel-home-ai
|
||||
./scripts/start_core.sh &
|
||||
./scripts/start_ui.sh &
|
||||
```
|
||||
|
||||
### Oracle 端 (使用 systemd)
|
||||
```bash
|
||||
sudo tee /etc/systemd/system/fam-edge.service << 'EOF'
|
||||
[Unit]
|
||||
Description=FAM-Edge Service
|
||||
After=network.target ollama.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=ubuntu
|
||||
WorkingDirectory=/path/to/sentinel-home-ai/fam-edge
|
||||
Environment=GEMINI_API_KEY=your-api-key
|
||||
ExecStart=/path/to/sentinel-home-ai/fam-edge/venv/bin/gunicorn -w 1 -b 0.0.0.0:5000 --timeout 600 src.fam_edge.app:app
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
# 配置:config/config.yaml(素材目录/DB/模型/motion_segment);.env 提供 ORACLE_SYNC_TOKEN/GEMINI_API_KEY*/NVIDIA_API_KEY
|
||||
|
||||
# systemd 服务(已配置 /etc/systemd/system/fam-edge.service,Restart=always)
|
||||
sudo systemctl enable fam-edge
|
||||
sudo systemctl start fam-edge
|
||||
sudo systemctl restart fam-edge # 部署代码后必须用 systemctl 重启,勿手动 setsid
|
||||
```
|
||||
|
||||
## 5. 验证清单
|
||||
### 2.3 统一登录(2026-09-12 从 NAS 迁入,fam-edge + Caddy + 防火墙三件套)
|
||||
|
||||
登录入口不再依赖 NAS。三处缺一不可:
|
||||
|
||||
```bash
|
||||
# (1) /opt/fam-edge/.env 追加六项(缺任一项 fam-edge 拒绝所有登录,fail closed)
|
||||
# client_secret 用 rotate-secret 现拿,明文只显示一次:
|
||||
# cd /opt/auth-hub && venv/bin/python -m auth_hub.manage_clients rotate-secret <client_id>
|
||||
# 会话密钥自己生成:openssl rand -hex 32
|
||||
export AUTH_HUB_ISSUER=https://auth.zichuan.xyz # 公网:浏览器跳转 + id_token 的 iss 校验
|
||||
export AUTH_HUB_INTERNAL_BASE=http://127.0.0.1:5300 # 本机:换 token + 拉 JWKS,不走公网 TLS
|
||||
export AUTH_HUB_CLIENT_ID=<auth-hub 注册的 client_id>
|
||||
export AUTH_HUB_CLIENT_SECRET=<rotate-secret 输出>
|
||||
export AUTH_HUB_REDIRECT_URI=https://smart-camera.zichuan.xyz/api/auth/callback # 与 auth-hub 登记的逐字符一致
|
||||
export FAM_SESSION_SECRET=<openssl rand -hex 32> # 会话 cookie 签名密钥,换掉即全员下线
|
||||
|
||||
# (2) venv 补依赖(Python 3.8,pip 会自动选到兼容版本)后重启
|
||||
/opt/fam-edge/venv/bin/pip install "PyJWT>=2.8.0" "cryptography>=42.0.0"
|
||||
sudo systemctl restart fam-edge
|
||||
|
||||
# (3) Caddy:登录端点走本机 fam-edge,其余 /api/* 先 forward_auth 再回源 NAS
|
||||
# 改完先 caddy validate 再 reload,见下方 Caddyfile 片段
|
||||
sudo caddy validate --adapter caddyfile --config /etc/caddy/Caddyfile && sudo systemctl reload caddy
|
||||
```
|
||||
|
||||
```caddyfile
|
||||
smart-camera.zichuan.xyz {
|
||||
handle /login { reverse_proxy 127.0.0.1:5000 }
|
||||
handle /api/auth/* { reverse_proxy 127.0.0.1:5000 }
|
||||
handle /api/logout { reverse_proxy 127.0.0.1:5000 }
|
||||
handle /api/* {
|
||||
forward_auth 127.0.0.1:5000 { uri /api/auth/verify } # 删了等于数据接口全裸
|
||||
reverse_proxy 127.0.0.1:8000
|
||||
}
|
||||
handle { root * /var/www/fam-ui; encode gzip; try_files {path} /index.html; file_server }
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
# (4) 防火墙:frps 把 NAS fam-core 的 :8000 暴露在公网,而 fam-core 自身已无鉴权,
|
||||
# 必须只放行本机(Caddy)访问,否则绕过 Caddy 就能读到全部数据
|
||||
sudo iptables -I INPUT 1 -p tcp --dport 8000 ! -i lo -j DROP
|
||||
sudo netfilter-persistent save # 否则重启后规则丢失
|
||||
```
|
||||
|
||||
### 2.4 FAM-Core(UI/对话/成员接口,2026-09-13 从 NAS 迁入)
|
||||
|
||||
```bash
|
||||
# 代码(本地开发机 → 甲骨文)
|
||||
tar czf - --exclude=venv --exclude=__pycache__ --exclude=logs --exclude='config/config.yaml' fam-core | \
|
||||
ssh -i ~/.ssh/oracle_new ubuntu@129.146.26.249 'tar xzf - --strip-components=1 -C /opt/fam-core'
|
||||
|
||||
# 甲骨文上:venv + 配置
|
||||
python3 -m venv /opt/fam-core/venv
|
||||
/opt/fam-core/venv/bin/pip install -r /opt/fam-core/requirements.txt
|
||||
cp /opt/fam-core/config/config.yaml.example /opt/fam-core/config/config.yaml
|
||||
# .env 只需要 ORACLE_SYNC_TOKEN(与 fam-edge 的一致),config_loader 会自动加载
|
||||
grep "^export ORACLE_SYNC_TOKEN=" /opt/fam-edge/.env > /opt/fam-core/.env && chmod 600 /opt/fam-core/.env
|
||||
|
||||
sudo systemctl restart fam-core # 单元见 /etc/systemd/system/fam-core.service
|
||||
curl -s http://127.0.0.1:5401/api/status # {"db":{"ok":true},...}
|
||||
```
|
||||
|
||||
**端口 5401 不是笔误**:5400 被这台机器上的 chat-relay 占了。fam-core 只绑
|
||||
127.0.0.1,外部进不来,唯一客户端是同机 Caddy(先 forward_auth 再反代)。
|
||||
|
||||
**chat_history 迁移(一次性)**:这是 NAS MariaDB 里唯一不是镜像的表。
|
||||
在 NAS 上导出,再从本地导入甲骨文的 SQLite:
|
||||
```bash
|
||||
# NAS 上导出(用 fam-core 旧 venv 里的 PyMySQL)
|
||||
ssh -p 2222 ericwyuan@192.168.50.64 '/volume1/web/sentinel-home-ai/fam-core/venv/bin/python -c "
|
||||
import pymysql, json, yaml
|
||||
cfg = yaml.safe_load(open(\"/volume1/web/sentinel-home-ai/fam-core/config/config.yaml\"))[\"database\"]
|
||||
c = pymysql.connect(host=\"127.0.0.1\", user=cfg[\"user\"], password=cfg[\"password\"], database=cfg[\"database\"], cursorclass=pymysql.cursors.DictCursor)
|
||||
cur = c.cursor(); cur.execute(\"SELECT * FROM chat_history ORDER BY chat_id\")
|
||||
print(json.dumps(cur.fetchall(), ensure_ascii=False, default=str))
|
||||
"' > /tmp/chat_history.json
|
||||
|
||||
# 本地 → 甲骨文导入(幂等:按 chat_id 跳过已存在的)
|
||||
cat /tmp/chat_history.json | ssh -i ~/.ssh/oracle_new ubuntu@129.146.26.249 \
|
||||
'/opt/fam-core/venv/bin/python /opt/fam-core/scripts/import_chat_history.py'
|
||||
```
|
||||
|
||||
## 3. 代码同步(tar 管道,scp 在 NAS 被禁用)
|
||||
|
||||
```bash
|
||||
# NAS(fam-core):仓库根即部署根,直接解包
|
||||
tar czf - --exclude=venv --exclude=__pycache__ fam-core | \
|
||||
ssh -p 2222 ericwyuan@192.168.50.64 'tar xzf - -C /volume1/web/sentinel-home-ai'
|
||||
|
||||
# Oracle(fam-edge):/opt/fam-edge 是 fam-edge 根,--strip-components=1 解临时目录再 cp
|
||||
tar czf - --exclude=venv --exclude=__pycache__ --exclude=data --exclude=gdrive_videos fam-edge | \
|
||||
ssh ubuntu@129.146.26.249 'mkdir -p /opt/fam-edge/tmp_d && tar xzf - --strip-components=1 -C /opt/fam-edge/tmp_d && \
|
||||
cp -rf /opt/fam-edge/tmp_d/* /opt/fam-edge/ && rm -rf /opt/fam-edge/tmp_d && sudo systemctl restart fam-edge'
|
||||
```
|
||||
|
||||
## 4. 验证清单
|
||||
|
||||
| 项目 | 命令 | 预期 |
|
||||
|------|------|------|
|
||||
| NAS MariaDB | `mysql -u root -p -e "SHOW DATABASES"` | 包含 sentinel_home_ai |
|
||||
| NAS FAM-Core | `curl http://localhost:8000/health` | `{"status":"ok"}` |
|
||||
| NAS FAM-UI | 浏览器访问 `http://192.168.50.64:8501` | Streamlit 页面 |
|
||||
| Oracle FAM-Edge | `curl http://localhost:5000/health` | `{"status":"ok"}` |
|
||||
| Oracle Ollama | `curl http://localhost:11434/api/tags` | 模型列表含 llava-phi3 |
|
||||
| Tailscale | NAS `ping 100.x.x.20` | 通 |
|
||||
| 云服务器 FAM-UI | 浏览器访问 `https://smart-camera.zichuan.xyz/` | 未登录时 `/api/*` 返回 401,前端自动跳 `/login` → auth-hub 登录页;登录后跳回展示 Vue3 SPA |
|
||||
| 登录不依赖 NAS | `curl -sI https://smart-camera.zichuan.xyz/login`(此时哪怕 fam-core 是停的) | 302 到 `auth.zichuan.xyz/authorize?...`,**不再是 502** |
|
||||
| 鉴权闸门 | `curl -s -o /dev/null -w '%{http_code}' https://smart-camera.zichuan.xyz/api/ui/videos` | 401(Caddy forward_auth 拦下,没到 NAS) |
|
||||
| NAS fam-notifier | NAS 上 `ps aux \| grep "[f]am_notifier"` | 有进程;`fam-notifier/logs/fam-notifier.log` 每 60s 一条轮询、每 5min 一条心跳 |
|
||||
| 甲骨文 FAM-Core | `curl -s http://127.0.0.1:5401/api/status` | `{"db":{"ok":true,...}}`;`/api/ui/stats` 返回真实计数 |
|
||||
| NAS 离线也能用 | 关掉 NAS 后访问 `https://smart-camera.zichuan.xyz/timeline` | 页面与数据照常(只是不再有新运动事件);**这是本次迁云的验收标准** |
|
||||
| Oracle FAM-Edge | `curl http://localhost:5000/health` | `{"status":"ok","queue_alive":true}` |
|
||||
| Oracle 运动事件 | `sqlite3 /opt/fam-edge/data/oracle.db "SELECT COUNT(*) FROM ss_motion_events"` | >0(NAS 推送) |
|
||||
| Oracle 运动片段 | `ls /opt/fam-edge/motion_clips/` | 存在 motion_*.mp4(素材分割产物) |
|
||||
| Oracle Ollama | `curl http://localhost:11434/api/tags` | 模型列表含 qwen2.5:7b |
|
||||
|
||||
@@ -22,7 +22,7 @@ database:
|
||||
# 甲骨文同步(每 30 分钟拉增量镜像)
|
||||
oracle_sync:
|
||||
# FAM-Edge 对外同步接口地址(端口同其 server.port=5000)
|
||||
base_url: "http://129.146.203.203:5000"
|
||||
base_url: "http://129.146.26.249:5000"
|
||||
# 与 Oracle 端 sync_api.token 一致(环境变量注入,避免明文入库)
|
||||
token: "${ORACLE_SYNC_TOKEN}"
|
||||
interval_sec: 1800 # 拉取间隔(秒),默认 30 分钟
|
||||
@@ -30,5 +30,31 @@ oracle_sync:
|
||||
|
||||
chat_handler:
|
||||
# 智能问答统一走 FAM-Edge 编排端点(Gemini → NVIDIA → 本地 Ollama 兜底)
|
||||
qa_url: "http://129.146.203.203:5000/api/edge/chat/ask"
|
||||
qa_url: "http://129.146.26.249:5000/api/edge/chat/ask"
|
||||
qa_stream_url: "http://129.146.26.249:5000/api/edge/chat/ask/stream" # 流式版
|
||||
timeout: 120
|
||||
|
||||
# 运动监测通知服务(2026-08-22 定稿:轮询主路径;2026-08-25 移除 Webhook 可选路径)
|
||||
# NAS 本机轮询 SS EventCenter.Event.List(真实 event_id/start_time/duration),
|
||||
# 增量推送到甲骨文 FAM-Edge(单向 NAS -> Oracle,甲骨文不再反向访问 NAS)。
|
||||
# SS 凭据走环境变量(${DSM_ACCOUNT}/${DSM_PASSWORD}),由启动脚本 source 的 .env 提供。
|
||||
motion_notifier:
|
||||
enabled: true
|
||||
poll_enabled: true # 轮询主路径(默认开启)
|
||||
dsm_host: "192.168.50.64" # Surveillance Station 所在地址(NAS 本机)
|
||||
dsm_port: 5000
|
||||
dsm_account: "${DSM_ACCOUNT}"
|
||||
dsm_password: "${DSM_PASSWORD}"
|
||||
camera_ids: [2] # 轮询关注的摄像头(Generic_ONVIF-001)
|
||||
oracle_base_url: "http://129.146.26.249:5000" # 与 oracle_sync.base_url 一致
|
||||
oracle_token: "${ORACLE_SYNC_TOKEN}" # 与 oracle_sync.token 一致
|
||||
timeout_sec: 10 # 单次 SS 请求超时
|
||||
# 心跳:跟轮询 SS 无关,只是定期空 POST 一下甲骨文的 /api/ss/motion,证明
|
||||
# NAS->Oracle 这条推送链路本身还活着(enabled=true 就跑,不受 poll_enabled 影响)。
|
||||
# 甲骨文侧 dsm_motion_prefilter.max_heartbeat_age_sec(默认 900s)据此判断"无运动"
|
||||
# 结论是否可信——这个心跳间隔要明显小于那个阈值,否则会被误判成链路已死。
|
||||
heartbeat_interval_sec: 300
|
||||
# 轮询参数
|
||||
poll_interval_sec: 60 # 轮询间隔
|
||||
poll_window_hours: 2 # 每轮回看窗口(小时),覆盖轮询间隔内的新事件
|
||||
batch_size: 100 # 单批推送上限
|
||||
|
||||
@@ -1,32 +1,28 @@
|
||||
# FAM-Core 配置文件 (NAS 端) - 新架构 v2
|
||||
# FAM-Core 配置文件(2026-09-13 起跑在甲骨文,与 fam-edge 同机)
|
||||
# 复制此文件为 config.yaml 并修改实际值
|
||||
#
|
||||
# NAS 仅作管理后台,不再处理视频。唯一后台线程 Oracle-Sync 每 30 分钟
|
||||
# 从甲骨文 FAM-Edge 拉取增量镜像到本地 MariaDB(sync_videos/events/people)。
|
||||
# 所有视频分析在 Oracle 完成。
|
||||
# 本服务是纯查询/转发层:直读 fam-edge 的 SQLite,写操作转给 fam-edge。
|
||||
# 没有后台线程,没有自己的数据库,重启不影响任何数据。
|
||||
|
||||
server:
|
||||
host: "0.0.0.0"
|
||||
port: 8000
|
||||
# 只监听回环:唯一的客户端是同机 Caddy(它做 forward_auth 鉴权后才反代进来)。
|
||||
# 迁云前这里是 0.0.0.0:8000 并经 frp 暴露到公网,绕过 Caddy 就能拿到全部数据。
|
||||
host: "127.0.0.1"
|
||||
port: 5401
|
||||
|
||||
database:
|
||||
host: "127.0.0.1"
|
||||
port: 3306
|
||||
user: "root"
|
||||
password: ""
|
||||
database: "sentinel_home_ai"
|
||||
unix_socket: "/run/mysqld/mysqld10.sock"
|
||||
# fam-edge 的库,本服务只读它写的表(唯一写的是 chat_history)。
|
||||
# fam-edge 侧已开 WAL,多进程读写安全。
|
||||
path: "/opt/fam-edge/data/oracle.db"
|
||||
|
||||
# 甲骨文同步(每 30 分钟拉增量镜像)
|
||||
oracle_sync:
|
||||
# FAM-Edge 对外同步接口地址(端口同其 server.port=5000)
|
||||
base_url: "http://<oracle-public-ip>:5000"
|
||||
# 与 Oracle 端 sync_api.token 一致
|
||||
token: ""
|
||||
interval_sec: 1800 # 拉取间隔(秒),默认 30 分钟
|
||||
timeout: 120 # 单次拉取超时(秒)
|
||||
# 同机 fam-edge:写操作(改名/删除)、帧图头像、服务状态都打给它
|
||||
edge:
|
||||
base_url: "http://127.0.0.1:5000"
|
||||
token: "${ORACLE_SYNC_TOKEN}" # 与 fam-edge 的 sync_api.token 一致
|
||||
timeout: 60
|
||||
|
||||
chat_handler:
|
||||
# 智能问答统一走 FAM-Edge 编排端点(Gemini → NVIDIA → 本地 Ollama 兜底)
|
||||
qa_url: "http://<oracle-public-ip>:5000/api/edge/chat/ask"
|
||||
# 智能问答走 fam-edge 编排端点(NVIDIA → Gemini → 本地 Ollama 兜底)
|
||||
qa_url: "http://127.0.0.1:5000/api/edge/chat/ask"
|
||||
qa_stream_url: "http://127.0.0.1:5000/api/edge/chat/ask/stream"
|
||||
timeout: 120
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
flask>=3.0.0
|
||||
gunicorn>=21.2.0
|
||||
PyMySQL>=1.1.0
|
||||
PyYAML>=6.0
|
||||
requests>=2.31.0
|
||||
|
||||
57
fam-core/scripts/import_chat_history.py
Normal file
57
fam-core/scripts/import_chat_history.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""把 NAS MariaDB 导出的 chat_history 导入甲骨文 SQLite(2026-09-13 迁云一次性脚本)。
|
||||
|
||||
chat_history 是 NAS 那套库里唯一"不是镜像"的表——其余 sync_* 都能从甲骨文重新
|
||||
读出来,只有问答历史是本地产生的,迁云时必须搬过来。
|
||||
|
||||
用法(JSON 从 stdin 进来,导出命令见 docs/DEPLOY.md §2.4):
|
||||
cat chat_history.json | /opt/fam-core/venv/bin/python scripts/import_chat_history.py
|
||||
|
||||
幂等:按 chat_id 跳过已存在的行,重复执行不会产生重复记录。
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'src'))
|
||||
|
||||
from fam_core import db_layer # noqa: E402
|
||||
|
||||
_COLS = ('chat_id', 'user_question', 'ai_answer', 'context_summary',
|
||||
'queried_date', 'queried_person', 'created_at')
|
||||
|
||||
|
||||
def main():
|
||||
try:
|
||||
rows = json.load(sys.stdin)
|
||||
except ValueError as e:
|
||||
print(f"stdin 不是合法 JSON: {e}", file=sys.stderr)
|
||||
return 1
|
||||
if not isinstance(rows, list):
|
||||
print("期望一个 JSON 数组", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
conn = db_layer.get_conn()
|
||||
try:
|
||||
db_layer._ensure_chat_schema(conn)
|
||||
inserted = skipped = 0
|
||||
for r in rows:
|
||||
cid = r.get('chat_id')
|
||||
if cid is not None and conn.execute(
|
||||
"SELECT 1 FROM chat_history WHERE chat_id=?", (cid,)).fetchone():
|
||||
skipped += 1
|
||||
continue
|
||||
conn.execute(
|
||||
"INSERT INTO chat_history ({}) VALUES ({})".format(
|
||||
','.join(_COLS), ','.join('?' * len(_COLS))),
|
||||
tuple(None if r.get(c) is None else str(r.get(c)) for c in _COLS))
|
||||
inserted += 1
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
print(f"导入 {inserted} 条,跳过 {skipped} 条(chat_id 已存在)")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -1,10 +1,20 @@
|
||||
"""
|
||||
FAM-Core 主应用 - Flask 单进程(新架构 v2)
|
||||
FAM-Core 主应用 - Flask(2026-09-13 从 NAS 迁到甲骨文)
|
||||
|
||||
承载: Oracle-Sync(每 30 分钟拉取增量镜像)+ Member-Manager + Chat-Handler
|
||||
承载: UI-API + Member-Manager + Chat-Handler + Img-Proxy —— 全是查询与转发,
|
||||
**没有任何后台线程**,进程随时可重启,不持有任何状态。
|
||||
|
||||
NAS 不再处理视频:无 Scheduler / Dispatcher / Poller / Event-Receiver / Video-Server。
|
||||
所有视频分析在 Oracle 完成,NAS 仅作管理后台拉取展示,CPU 占用大幅降低。
|
||||
迁云前它跑在 NAS 上,还扛着两个后台线程,现在都不在这里了:
|
||||
- Oracle-Sync(每 30 分钟把甲骨文数据拉一份镜像进 MariaDB)—— 整个删除。
|
||||
本服务现在与 fam-edge 同机,直接读它的 SQLite(见 db_layer.py 开头)。
|
||||
- MotionNotifier(轮询 Surveillance Station 推运动事件)—— 留在 NAS,
|
||||
拆成独立的 fam-notifier(摄像头插在 NAS 上,这部分搬不走)。
|
||||
|
||||
两件跟安全有关的事:
|
||||
- 本服务只监听 127.0.0.1(config.yaml 的 server.host),唯一的客户端是同机
|
||||
Caddy。不像迁云前那样经 frp 把 :8000 暴露到公网。
|
||||
- 登录校验也不在这里:Caddy 用 forward_auth 打 fam-edge 的 /api/auth/verify,
|
||||
通过了才反代进来。
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
@@ -16,57 +26,52 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from .config_loader import load_config
|
||||
from .logger import setup_logger
|
||||
from .oracle_sync import get_sync
|
||||
from .chat_handler.chat_handler import chat_bp
|
||||
from .member_manager.member_manager import member_bp
|
||||
from .img_proxy import img_bp
|
||||
from .ui_api import ui_bp
|
||||
from .static_app import static_bp
|
||||
from . import db_layer, edge_client
|
||||
|
||||
logger = setup_logger('fam-core.app')
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
# 注册蓝图:/api/* 系列必须先于 static_bp 注册——static_bp 是通配兜底路由
|
||||
# (Vue Router history 模式回退 index.html),排在前面会吞掉 API 请求。
|
||||
app.register_blueprint(chat_bp)
|
||||
app.register_blueprint(member_bp)
|
||||
app.register_blueprint(img_bp)
|
||||
app.register_blueprint(ui_bp)
|
||||
app.register_blueprint(static_bp)
|
||||
|
||||
# 健康检查
|
||||
|
||||
@app.route('/health', methods=['GET'])
|
||||
def health():
|
||||
return jsonify({"status": "ok", "service": "fam-core"}), 200
|
||||
|
||||
|
||||
# 初始化后台同步线程(NAS 唯一常驻线程)
|
||||
_sync = None
|
||||
try:
|
||||
_sync = get_sync()
|
||||
_sync.start()
|
||||
logger.info("Oracle-Sync 已启动")
|
||||
# 启动后立刻拉一次,前端无需等待首个周期即有数据
|
||||
try:
|
||||
_sync.trigger_now()
|
||||
logger.info("启动首次同步完成")
|
||||
except Exception as e:
|
||||
logger.warning(f"启动首次同步失败(后续周期会重试): {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Oracle-Sync 启动失败: {e}")
|
||||
|
||||
|
||||
@app.route('/api/status', methods=['GET'])
|
||||
def status():
|
||||
"""系统状态"""
|
||||
"""服务自检:库能不能读、fam-edge 在不在。
|
||||
|
||||
迁云前这里报告的是镜像同步状态(游标/周期/上次增量条数),镜像没了之后
|
||||
那些字段不再存在,前端侧边栏的同步面板也一并去掉了。
|
||||
"""
|
||||
db_ok, db_err = True, None
|
||||
try:
|
||||
conn = db_layer.get_conn()
|
||||
try:
|
||||
conn.execute("SELECT 1 FROM videos LIMIT 1").fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
db_ok, db_err = False, str(e)
|
||||
return jsonify({
|
||||
"service": "fam-core",
|
||||
"sync": _sync.status() if _sync else {"running": False, "error": "未初始化"},
|
||||
"db": {"ok": db_ok, "error": db_err},
|
||||
"edge_base_url": edge_client.base_url(),
|
||||
}), 200
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
cfg = load_config()
|
||||
port = cfg.get('server', {}).get('port', 8000)
|
||||
app.run(host='0.0.0.0', port=port, debug=False)
|
||||
server = cfg.get('server', {})
|
||||
app.run(host=server.get('host', '127.0.0.1'),
|
||||
port=server.get('port', 5401), debug=False)
|
||||
|
||||
@@ -13,7 +13,7 @@ Chat-Handler - Flask 蓝图,接收用户问答(新架构 v2)
|
||||
"""
|
||||
import json
|
||||
import requests
|
||||
from flask import Blueprint, request, jsonify
|
||||
from flask import Blueprint, request, jsonify, Response, stream_with_context
|
||||
|
||||
from ..logger import setup_logger
|
||||
from ..config_loader import load_config
|
||||
@@ -47,7 +47,7 @@ def _call_edge_qa(prompt: str) -> str:
|
||||
"""调用 FAM-Edge 问答编排端点(Gemini → NVIDIA → 本地 Ollama 兜底)"""
|
||||
cfg = load_config()
|
||||
qa_url = cfg.get('chat_handler', {}).get(
|
||||
'qa_url', 'http://129.146.203.203:5000/api/edge/chat/ask'
|
||||
'qa_url', 'http://127.0.0.1:5000/api/edge/chat/ask'
|
||||
)
|
||||
timeout = cfg.get('chat_handler', {}).get('timeout', 120)
|
||||
|
||||
@@ -79,7 +79,7 @@ def chat_ask():
|
||||
|
||||
logger.info(f"Chat: person={queried_person}, date={queried_date}, question={question}")
|
||||
|
||||
rows = db_layer.query_sync_events_for_person_date(queried_person, queried_date)
|
||||
rows = db_layer.query_events_for_person_date(queried_person, queried_date)
|
||||
|
||||
if len(rows) == 0:
|
||||
answer = f"今天没有观察到{queried_person}。"
|
||||
@@ -87,7 +87,7 @@ def chat_ask():
|
||||
else:
|
||||
context = _format_events(rows)
|
||||
context_summary = f"查询 sync_events {len(rows)} 条"
|
||||
members = db_layer.get_sync_known_members_context()
|
||||
members = db_layer.get_known_members_context()
|
||||
prompt = build_chat_prompt(
|
||||
context=context,
|
||||
members=members or queried_person,
|
||||
@@ -115,6 +115,98 @@ def chat_ask():
|
||||
}), 200
|
||||
|
||||
|
||||
@chat_bp.route('/api/chat/ask/stream', methods=['POST'])
|
||||
def chat_ask_stream():
|
||||
"""流式问答:SSE 逐块推送,边生成边显示。
|
||||
|
||||
先立即推一条 context 事件(用了哪些 sync_events,本地查询很快,不用等
|
||||
大模型);再把甲骨文 /api/edge/chat/ask/stream 的分块原样转发给前端;
|
||||
最后一次性把拼好的完整回答写进 chat_history(跟非流式版一致)。
|
||||
"""
|
||||
data = request.get_json(silent=True)
|
||||
if not data:
|
||||
return jsonify({"error": "Invalid JSON"}), 400
|
||||
|
||||
question = data.get('question', '')
|
||||
queried_person = data.get('queried_person', '')
|
||||
queried_date = data.get('queried_date', '')
|
||||
if not question or not queried_person or not queried_date:
|
||||
return jsonify({"error": "缺少必填字段: question, queried_person, queried_date"}), 400
|
||||
|
||||
logger.info(f"Chat(stream): person={queried_person}, date={queried_date}, question={question}")
|
||||
rows = db_layer.query_events_for_person_date(queried_person, queried_date)
|
||||
|
||||
def sse(obj):
|
||||
return f"data: {json.dumps(obj, ensure_ascii=False)}\n\n"
|
||||
|
||||
def generate():
|
||||
if len(rows) == 0:
|
||||
answer = f"今天没有观察到{queried_person}。"
|
||||
context_summary = "查询 sync_events 0 条"
|
||||
yield sse({"type": "context", "count": 0, "summary": context_summary})
|
||||
yield sse({"type": "chunk", "provider": None, "text": answer})
|
||||
yield sse({"type": "done", "provider": None})
|
||||
db_layer.insert_chat_history(
|
||||
user_question=question, ai_answer=answer,
|
||||
context_summary=context_summary,
|
||||
queried_date=queried_date, queried_person=queried_person)
|
||||
return
|
||||
|
||||
context = _format_events(rows)
|
||||
context_summary = f"查询 sync_events {len(rows)} 条"
|
||||
yield sse({"type": "context", "count": len(rows), "summary": context_summary,
|
||||
"preview": context[:800]})
|
||||
|
||||
members = db_layer.get_known_members_context()
|
||||
prompt = build_chat_prompt(
|
||||
context=context, members=members or queried_person,
|
||||
question=question, queried_person=queried_person)
|
||||
|
||||
cfg = load_config()
|
||||
stream_url = cfg.get('chat_handler', {}).get(
|
||||
'qa_stream_url', 'http://127.0.0.1:5000/api/edge/chat/ask/stream')
|
||||
timeout = cfg.get('chat_handler', {}).get('timeout', 120)
|
||||
|
||||
full_answer = []
|
||||
provider_used = None
|
||||
try:
|
||||
resp = requests.post(stream_url, json={"prompt": prompt},
|
||||
timeout=(10, timeout), stream=True)
|
||||
if resp.status_code != 200:
|
||||
raise Exception(f"HTTP {resp.status_code}")
|
||||
# 甲骨文那边的响应体固定是 UTF-8,但 Content-Type 不一定带 charset
|
||||
# 参数,requests 会自己猜编码——猜错就是中文乱码,强制指定跳过嗅探。
|
||||
resp.encoding = 'utf-8'
|
||||
for line in resp.iter_lines(decode_unicode=True):
|
||||
if not line or not line.startswith('data: '):
|
||||
continue
|
||||
yield line + '\n\n' # 原样转发给前端(已经是同样的 SSE 格式)
|
||||
try:
|
||||
obj = json.loads(line[len('data: '):])
|
||||
except ValueError:
|
||||
continue
|
||||
if obj.get('type') == 'chunk' and obj.get('text'):
|
||||
full_answer.append(obj['text'])
|
||||
provider_used = obj.get('provider') or provider_used
|
||||
elif obj.get('type') == 'done':
|
||||
provider_used = obj.get('provider') or provider_used
|
||||
except Exception as e:
|
||||
logger.error(f"流式问答代理失败: {e}")
|
||||
if not full_answer:
|
||||
yield sse({"type": "error", "message": "AI 服务暂时不可用,请稍后重试"})
|
||||
return
|
||||
|
||||
answer = ''.join(full_answer).strip() or "抱歉,暂时无法生成回答。"
|
||||
logger.info(f"流式问答由 {provider_used} 提供回答(长度={len(answer)})")
|
||||
db_layer.insert_chat_history(
|
||||
user_question=question, ai_answer=answer,
|
||||
context_summary=context_summary,
|
||||
queried_date=queried_date, queried_person=queried_person)
|
||||
|
||||
return Response(stream_with_context(generate()), mimetype='text/event-stream; charset=utf-8',
|
||||
headers={'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'})
|
||||
|
||||
|
||||
@chat_bp.route('/api/chat/history', methods=['GET'])
|
||||
def chat_history():
|
||||
"""获取对话历史"""
|
||||
|
||||
@@ -1,11 +1,42 @@
|
||||
"""
|
||||
配置加载器 - 从 config.yaml 读取配置
|
||||
配置加载器 - 从 config.yaml 读取配置,支持 ${ENV_VAR} 解析
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import yaml
|
||||
|
||||
|
||||
def _load_env_file():
|
||||
"""加载部署目录下的 .env(支持 export KEY=VALUE 格式)。
|
||||
|
||||
迁云前是 start_core.sh 负责 source .env 再起 gunicorn;现在由 systemd 拉起,
|
||||
没有那一步,所以在这里兜底加载(跟 fam-edge 的做法一致)。
|
||||
已存在的环境变量不覆盖。
|
||||
"""
|
||||
path = os.environ.get('FAM_ENV_FILE') or os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
|
||||
'.env')
|
||||
if not os.path.isfile(path):
|
||||
return
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line or line.startswith('#'):
|
||||
continue
|
||||
if line.startswith('export '):
|
||||
line = line[7:].strip()
|
||||
if '=' not in line:
|
||||
continue
|
||||
key, _, value = line.partition('=')
|
||||
key = key.strip()
|
||||
value = value.strip().strip('"').strip("'")
|
||||
if key and key not in os.environ:
|
||||
os.environ[key] = value
|
||||
|
||||
|
||||
_load_env_file()
|
||||
|
||||
|
||||
def _resolve_env_vars(value):
|
||||
"""递归解析字符串中的 ${ENV_VAR} 引用"""
|
||||
if isinstance(value, str):
|
||||
|
||||
@@ -1,504 +1,356 @@
|
||||
"""
|
||||
数据库访问层 - MariaDB 连接管理与同步镜像 CRUD
|
||||
DB-Layer - 直读 fam-edge 的 SQLite 库(2026-09-13 迁云重写)
|
||||
|
||||
新架构 (2026-08-21 重构):
|
||||
NAS 不再处理视频,仅作为管理后台。
|
||||
Oracle (FAM-Edge) 处理整视频分析后存 SQLite,NAS 每 30 分钟拉增量,
|
||||
镜像到本地三张表:
|
||||
sync_videos : 视频会话(全局摘要 + 事件 JSON + 人物 JSON)
|
||||
sync_events : 视频拆出的时间点事件(描述 + 涉及人物 + 是否关注)
|
||||
sync_people : 规范人物表(label + canonical_name,Oracle 维护)
|
||||
sync_cursor : 同步游标(上次成功拉取到的 server_time)
|
||||
**这个文件以前是什么样**:fam-core 跑在 NAS 上,每 30 分钟把甲骨文的数据拉一份
|
||||
镜像进 MariaDB(`sync_videos`/`sync_events`/…),前端读镜像。725 行里有一半是
|
||||
镜像 upsert 的去重逻辑——8/29 和 9/3 两次线上事故(1062 主键冲突、游标卡死)
|
||||
都出在那一半。
|
||||
|
||||
本层只服务同步镜像 + 问答历史,旧 process_tasks/event_details/monitor_events/
|
||||
family_members 相关逻辑已全部移除(视频处理职责已迁移至 Oracle)。
|
||||
**现在**:fam-core 跟 fam-edge 同机,直接打开它的 SQLite 库读,镜像层整个不存在了。
|
||||
连带消失的还有一个隐蔽 bug:镜像表为了保外键稳定用的是 NAS 本地自增 id,而帧图
|
||||
接口要的是甲骨文的 id,两边在 9/3 那次 id 重排后就对不上了。现在只有一套 id。
|
||||
|
||||
要点:
|
||||
- 库文件是 fam-edge 的(`database.path`),**本模块只读它写的表**,唯一写的表是
|
||||
`chat_history`(问答历史,迁云时从 NAS MariaDB 搬过来的,fam-edge 不碰)。
|
||||
- fam-edge 那边开了 WAL,读不会阻塞它的写;这边同样设 busy_timeout 兜底。
|
||||
- 视频/人物的写操作(改名、删除)不在这里做,走 `edge_client` 打给 fam-edge——
|
||||
它除了改库还要合并人物、删磁盘素材。
|
||||
"""
|
||||
import json
|
||||
import pymysql
|
||||
from datetime import datetime
|
||||
from typing import Optional, List, Dict, Any
|
||||
import sqlite3
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from .config_loader import load_config
|
||||
from .logger import setup_logger
|
||||
|
||||
logger = setup_logger('fam-core.db')
|
||||
|
||||
_config = None
|
||||
_db_path = None
|
||||
_chat_schema_ready = False
|
||||
|
||||
|
||||
def get_config():
|
||||
global _config
|
||||
if _config is None:
|
||||
_config = load_config()
|
||||
return _config
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone(timedelta(hours=8))).strftime('%Y-%m-%d %H:%M:%S')
|
||||
|
||||
|
||||
def get_conn():
|
||||
"""获取数据库连接(单 worker gunicorn,无需连接池)"""
|
||||
cfg = get_config().get('database', {})
|
||||
kwargs = dict(
|
||||
host=cfg.get('host', '127.0.0.1'),
|
||||
port=cfg.get('port', 3306),
|
||||
user=cfg.get('user', 'root'),
|
||||
password=cfg.get('password', ''),
|
||||
database=cfg.get('database', 'sentinel_home_ai'),
|
||||
charset='utf8mb4',
|
||||
autocommit=False
|
||||
)
|
||||
unix_socket = cfg.get('unix_socket')
|
||||
if unix_socket:
|
||||
kwargs['unix_socket'] = unix_socket
|
||||
return pymysql.connect(**kwargs)
|
||||
def get_config() -> Dict:
|
||||
return load_config()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 同步镜像:sync_videos
|
||||
# ============================================================
|
||||
|
||||
def upsert_sync_videos(rows: List[Dict]) -> int:
|
||||
"""批量 upsert Oracle 传来的 videos 增量。rows 为 Oracle 端 dict 列表。"""
|
||||
if not rows:
|
||||
return 0
|
||||
conn = get_conn()
|
||||
n = 0
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
for r in rows:
|
||||
cur.execute(
|
||||
"""INSERT INTO sync_videos
|
||||
(id, drive_file_id, filename, camera_name, duration_sec,
|
||||
event_start_time, status, summary_json, events_json,
|
||||
people_json, compute_provider, created_at, updated_at,
|
||||
processed_at, synced_at)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s, NOW())
|
||||
ON DUPLICATE KEY UPDATE
|
||||
drive_file_id=VALUES(drive_file_id),
|
||||
filename=VALUES(filename),
|
||||
camera_name=VALUES(camera_name),
|
||||
duration_sec=VALUES(duration_sec),
|
||||
event_start_time=VALUES(event_start_time),
|
||||
status=VALUES(status),
|
||||
summary_json=VALUES(summary_json),
|
||||
events_json=VALUES(events_json),
|
||||
people_json=VALUES(people_json),
|
||||
compute_provider=VALUES(compute_provider),
|
||||
created_at=VALUES(created_at),
|
||||
updated_at=VALUES(updated_at),
|
||||
processed_at=VALUES(processed_at),
|
||||
synced_at=NOW()""",
|
||||
(r.get('id'), r.get('drive_file_id'), r.get('filename'),
|
||||
r.get('camera_name'), r.get('duration_sec') or 0,
|
||||
r.get('event_start_time'), r.get('status'),
|
||||
r.get('summary_json'), r.get('events_json'), r.get('people_json'),
|
||||
r.get('compute_provider'), r.get('created_at'),
|
||||
r.get('updated_at'), r.get('processed_at'))
|
||||
)
|
||||
n += 1
|
||||
conn.commit()
|
||||
return n
|
||||
finally:
|
||||
conn.close()
|
||||
def _path() -> str:
|
||||
global _db_path
|
||||
if _db_path is None:
|
||||
_db_path = load_config().get('database', {}).get(
|
||||
'path', '/opt/fam-edge/data/oracle.db')
|
||||
return _db_path
|
||||
|
||||
|
||||
def get_sync_videos(limit=15, offset=0, date_filter=None) -> List[Dict]:
|
||||
"""获取视频会话列表(已完成优先),支持日期筛选与分页。
|
||||
def get_conn() -> sqlite3.Connection:
|
||||
"""每次调用开一个连接(请求级,跟改写前的 MySQL 用法一致)。
|
||||
|
||||
排序/日期维度按视频实际录制时间(event_start_time,文件名解析),
|
||||
为空回退 processed_at/updated_at/created_at。
|
||||
date_filter 形如 '2026-08-21'。
|
||||
busy_timeout:fam-edge 的写事务提交时会短暂持锁,这里等而不是立刻报
|
||||
`database is locked`(9/3 那次 Oracle 过载时刷过一片这个错)。
|
||||
"""
|
||||
conn = sqlite3.connect(_path(), timeout=10)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA busy_timeout=10000")
|
||||
return conn
|
||||
|
||||
|
||||
def _rows(cur) -> List[Dict]:
|
||||
return [dict(r) for r in cur.fetchall()]
|
||||
|
||||
|
||||
def _row(cur) -> Optional[Dict]:
|
||||
r = cur.fetchone()
|
||||
return dict(r) if r else None
|
||||
|
||||
|
||||
# 人物命中判断:person_list_json 是 JSON 数组文本,用 json_each 展开精确匹配。
|
||||
# 必须先 json_valid——历史数据里有非 JSON 的脏值,直接 json_each 会整条查询报错。
|
||||
_PERSON_HIT = ("{col} IS NOT NULL AND json_valid({col}) "
|
||||
"AND EXISTS(SELECT 1 FROM json_each({col}) WHERE json_each.value = ?)")
|
||||
|
||||
# 日期维度:录制时间优先(文件名解析出来的),回退分析时间
|
||||
_DATE_EXPR = "COALESCE(NULLIF({a}.event_start_time,''), {a}.processed_at, {a}.updated_at, {a}.created_at)"
|
||||
|
||||
# 只展示"有内容"的会话:运动片段,或含事件的视频。
|
||||
# 整段素材分割 0 段的空会话(历史素材无运动事件)不展示,避免淹没时间轴。
|
||||
_CONTENT_FILTER = ("substr(v.filename, 1, 7) = 'motion_' "
|
||||
"OR EXISTS(SELECT 1 FROM events se WHERE se.video_id = v.id)")
|
||||
|
||||
|
||||
# ============================================================
|
||||
# videos / events
|
||||
# ============================================================
|
||||
def get_videos(limit=15, offset=0, date_filter=None) -> List[Dict]:
|
||||
"""视频会话列表(事件时间轴左侧),按录制时间倒序,支持日期筛选与分页。"""
|
||||
d = _DATE_EXPR.format(a='v')
|
||||
sql = f"""SELECT v.id, v.filename, v.camera_name, v.event_start_time, v.status,
|
||||
v.summary_json, v.events_json, v.people_json, v.compute_provider,
|
||||
v.processed_at, v.updated_at,
|
||||
(SELECT COUNT(*) FROM events se WHERE se.video_id = v.id) AS event_count
|
||||
FROM videos v
|
||||
WHERE v.status='done' AND ({_CONTENT_FILTER}) {{extra}}
|
||||
ORDER BY {d} DESC
|
||||
LIMIT ? OFFSET ?"""
|
||||
conn = get_conn()
|
||||
try:
|
||||
cur = conn.cursor(pymysql.cursors.DictCursor)
|
||||
# 录制时间优先,回退分析时间
|
||||
date_expr = "COALESCE(NULLIF(event_start_time,''), processed_at, updated_at, created_at)"
|
||||
if date_filter:
|
||||
cur.execute(
|
||||
f"""SELECT id, filename, camera_name, event_start_time, status,
|
||||
summary_json, events_json, people_json, compute_provider,
|
||||
processed_at, updated_at,
|
||||
(SELECT COUNT(*) FROM sync_events se WHERE se.video_id = sync_videos.id) AS event_count
|
||||
FROM sync_videos
|
||||
WHERE status='done' AND {date_expr} LIKE %s
|
||||
ORDER BY {date_expr} DESC
|
||||
LIMIT %s OFFSET %s""",
|
||||
(f'{date_filter}%', limit, offset))
|
||||
cur = conn.execute(sql.format(extra=f"AND {d} LIKE ?"),
|
||||
(f'{date_filter}%', limit, offset))
|
||||
else:
|
||||
cur.execute(
|
||||
f"""SELECT id, filename, camera_name, event_start_time, status,
|
||||
summary_json, events_json, people_json, compute_provider,
|
||||
processed_at, updated_at,
|
||||
(SELECT COUNT(*) FROM sync_events se WHERE se.video_id = sync_videos.id) AS event_count
|
||||
FROM sync_videos
|
||||
WHERE status='done'
|
||||
ORDER BY {date_expr} DESC
|
||||
LIMIT %s OFFSET %s""",
|
||||
(limit, offset))
|
||||
return cur.fetchall()
|
||||
cur = conn.execute(sql.format(extra=''), (limit, offset))
|
||||
return _rows(cur)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_sync_video(video_id: int) -> Optional[Dict]:
|
||||
def get_video(video_id: int) -> Optional[Dict]:
|
||||
conn = get_conn()
|
||||
try:
|
||||
cur = conn.cursor(pymysql.cursors.DictCursor)
|
||||
cur.execute("SELECT * FROM sync_videos WHERE id=%s", (video_id,))
|
||||
return cur.fetchone()
|
||||
return _row(conn.execute("SELECT * FROM videos WHERE id=?", (video_id,)))
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 同步镜像:sync_events
|
||||
# ============================================================
|
||||
|
||||
def upsert_sync_events(rows: List[Dict]) -> int:
|
||||
"""批量 upsert Oracle 传来的 events 增量。"""
|
||||
if not rows:
|
||||
return 0
|
||||
conn = get_conn()
|
||||
n = 0
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
for r in rows:
|
||||
cur.execute(
|
||||
"""INSERT INTO sync_events
|
||||
(id, video_id, ts, description, person_list_json,
|
||||
person_appearances_json, is_attention_event,
|
||||
updated_at, synced_at)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s, NOW())
|
||||
ON DUPLICATE KEY UPDATE
|
||||
video_id=VALUES(video_id),
|
||||
ts=VALUES(ts),
|
||||
description=VALUES(description),
|
||||
person_list_json=VALUES(person_list_json),
|
||||
person_appearances_json=VALUES(person_appearances_json),
|
||||
is_attention_event=VALUES(is_attention_event),
|
||||
updated_at=VALUES(updated_at),
|
||||
synced_at=NOW()""",
|
||||
(r.get('id'), r.get('video_id'), r.get('ts'), r.get('description'),
|
||||
r.get('person_list_json'), r.get('person_appearances_json'),
|
||||
1 if r.get('is_attention_event') else 0,
|
||||
r.get('updated_at'))
|
||||
)
|
||||
n += 1
|
||||
conn.commit()
|
||||
return n
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_sync_events_for_video(video_id: int) -> List[Dict]:
|
||||
def get_events_for_video(video_id: int) -> List[Dict]:
|
||||
conn = get_conn()
|
||||
try:
|
||||
cur = conn.cursor(pymysql.cursors.DictCursor)
|
||||
cur.execute(
|
||||
return _rows(conn.execute(
|
||||
"""SELECT id, video_id, ts, description, person_list_json,
|
||||
is_attention_event, updated_at
|
||||
FROM sync_events WHERE video_id=%s ORDER BY ts ASC""",
|
||||
(video_id,))
|
||||
return cur.fetchall()
|
||||
is_attention_event
|
||||
FROM events WHERE video_id=? ORDER BY ts ASC""", (video_id,)))
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def query_sync_events_for_person_date(person: str, date_str: str) -> List[Dict]:
|
||||
def get_attention_events(limit: int = 200) -> List[Dict]:
|
||||
"""需关注事件(统计图表页用),按录制日期倒序。"""
|
||||
conn = get_conn()
|
||||
try:
|
||||
return _rows(conn.execute(
|
||||
"""SELECT COALESCE(NULLIF(v.event_start_time,''), v.processed_at) AS ev_date,
|
||||
e.person_list_json
|
||||
FROM events e JOIN videos v ON e.video_id=v.id
|
||||
WHERE e.is_attention_event = 1
|
||||
ORDER BY ev_date DESC LIMIT ?""", (limit,)))
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_people_clips(label: str, limit: int = 10) -> List[Dict]:
|
||||
"""某人物出现过的运动片段列表。
|
||||
|
||||
匹配 label 本身 + 同一 canonical_name 下的全部 label;返回含 first_ts
|
||||
(该人物在片段内最早事件时间点,前端缩略图定位用)与 clip_events。
|
||||
"""
|
||||
hit_e = _PERSON_HIT.format(col='e.person_list_json')
|
||||
hit_e2 = _PERSON_HIT.format(col='e2.person_list_json')
|
||||
hit_e3 = _PERSON_HIT.format(col='e3.person_list_json')
|
||||
conn = get_conn()
|
||||
try:
|
||||
labels = {label}
|
||||
for r in _rows(conn.execute(
|
||||
"SELECT label, canonical_name FROM people WHERE label=? OR canonical_name=?",
|
||||
(label, label))):
|
||||
if r.get('canonical_name'):
|
||||
labels.update(x['label'] for x in _rows(conn.execute(
|
||||
"SELECT label FROM people WHERE canonical_name=?", (r['canonical_name'],))))
|
||||
clips, seen = [], set()
|
||||
for lb in sorted(labels):
|
||||
cur = conn.execute(
|
||||
f"""SELECT DISTINCT v.id AS video_id, v.filename, v.event_start_time,
|
||||
v.duration_sec, v.summary_json, v.camera_name,
|
||||
(SELECT MIN(e2.ts) FROM events e2
|
||||
WHERE e2.video_id=v.id AND {hit_e2}) AS first_ts,
|
||||
(SELECT COUNT(*) FROM events e3
|
||||
WHERE e3.video_id=v.id AND {hit_e3}) AS clip_events
|
||||
FROM events e JOIN videos v ON e.video_id=v.id
|
||||
WHERE {hit_e} AND v.status='done'""",
|
||||
(lb, lb, lb))
|
||||
for row in _rows(cur):
|
||||
if row['video_id'] not in seen:
|
||||
seen.add(row['video_id'])
|
||||
clips.append(row)
|
||||
clips.sort(key=lambda x: x.get('event_start_time') or '', reverse=True)
|
||||
return clips[:int(limit)]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def query_events_for_person_date(person: str, date_str: str) -> List[Dict]:
|
||||
"""问答上下文:某人在某天的事件。
|
||||
|
||||
说明: Oracle 事件 ts 为视频内相对时间点(如 00:01:23),不是绝对日期,
|
||||
因此按所属视频的录制日期(event_start_time,回退 processed_at)过滤,
|
||||
再按 person_list_json 命中人名。
|
||||
person 可为真名或抽象标签(Oracle 回灌上下文用真名,但历史标签也保留)。
|
||||
事件 ts 是视频内的相对时间点(如 00:01:23),不是绝对日期,所以按所属视频的
|
||||
录制日期过滤,再按 person_list_json 命中人名。person 可为真名或抽象标签。
|
||||
"""
|
||||
hit = _PERSON_HIT.format(col='e.person_list_json')
|
||||
conn = get_conn()
|
||||
try:
|
||||
cur = conn.cursor(pymysql.cursors.DictCursor)
|
||||
cur.execute(
|
||||
"""SELECT e.ts, e.description, e.person_list_json, e.is_attention_event,
|
||||
v.camera_name, v.filename, v.event_start_time, v.processed_at
|
||||
FROM sync_events e
|
||||
JOIN sync_videos v ON e.video_id = v.id
|
||||
WHERE COALESCE(NULLIF(v.event_start_time,''), v.processed_at) LIKE %s
|
||||
AND e.person_list_json IS NOT NULL
|
||||
AND JSON_CONTAINS(e.person_list_json, JSON_QUOTE(%s), '$')
|
||||
ORDER BY COALESCE(NULLIF(v.event_start_time,''), v.processed_at) ASC, e.ts ASC""",
|
||||
(f'{date_str}%', person))
|
||||
return cur.fetchall()
|
||||
return _rows(conn.execute(
|
||||
f"""SELECT e.ts, e.description, e.person_list_json, e.is_attention_event,
|
||||
v.camera_name, v.filename, v.event_start_time, v.processed_at
|
||||
FROM events e JOIN videos v ON e.video_id = v.id
|
||||
WHERE COALESCE(NULLIF(v.event_start_time,''), v.processed_at) LIKE ?
|
||||
AND {hit}
|
||||
ORDER BY COALESCE(NULLIF(v.event_start_time,''), v.processed_at) ASC, e.ts ASC""",
|
||||
(f'{date_str}%', person)))
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 同步镜像:sync_people
|
||||
# people
|
||||
# ============================================================
|
||||
|
||||
def upsert_sync_people(rows: List[Dict]) -> int:
|
||||
"""批量 upsert Oracle 传来的 people 增量。"""
|
||||
if not rows:
|
||||
return 0
|
||||
conn = get_conn()
|
||||
n = 0
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
for r in rows:
|
||||
cur.execute(
|
||||
"""INSERT INTO sync_people
|
||||
(id, label, canonical_name, first_seen, appearances,
|
||||
source, features_json, display_uid, updated_at, synced_at)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s, NOW())
|
||||
ON DUPLICATE KEY UPDATE
|
||||
label=VALUES(label),
|
||||
canonical_name=VALUES(canonical_name),
|
||||
first_seen=VALUES(first_seen),
|
||||
appearances=VALUES(appearances),
|
||||
source=VALUES(source),
|
||||
features_json=VALUES(features_json),
|
||||
display_uid=VALUES(display_uid),
|
||||
updated_at=VALUES(updated_at),
|
||||
synced_at=NOW()""",
|
||||
(r.get('id'), r.get('label'), r.get('canonical_name'),
|
||||
r.get('first_seen'), r.get('appearances') or 0,
|
||||
r.get('source'), r.get('features_json'),
|
||||
r.get('display_uid'), r.get('updated_at')))
|
||||
n += 1
|
||||
conn.commit()
|
||||
return n
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_sync_people() -> List[Dict]:
|
||||
def get_people() -> List[Dict]:
|
||||
conn = get_conn()
|
||||
try:
|
||||
cur = conn.cursor(pymysql.cursors.DictCursor)
|
||||
cur.execute(
|
||||
return _rows(conn.execute(
|
||||
"SELECT id, label, canonical_name, first_seen, appearances, source, "
|
||||
"features_json, display_uid, updated_at "
|
||||
"FROM sync_people ORDER BY id ASC")
|
||||
return cur.fetchall()
|
||||
"features_json, display_uid, updated_at FROM people ORDER BY id ASC"))
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def upsert_sync_model_calls(rows: List[Dict]) -> int:
|
||||
"""批量 upsert Oracle 传来的 model_calls 增量(幂等,重复覆盖)。"""
|
||||
if not rows:
|
||||
return 0
|
||||
def get_named_members() -> List[str]:
|
||||
"""已命名成员的真名列表(UI 下拉用)。"""
|
||||
conn = get_conn()
|
||||
n = 0
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
for r in rows:
|
||||
cur.execute(
|
||||
"""INSERT INTO sync_model_calls
|
||||
(id, provider, model, video_id, filename, started_at,
|
||||
duration_sec, success, error, created_at, synced_at)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s, NOW())
|
||||
ON DUPLICATE KEY UPDATE
|
||||
provider=VALUES(provider),
|
||||
model=VALUES(model),
|
||||
video_id=VALUES(video_id),
|
||||
filename=VALUES(filename),
|
||||
started_at=VALUES(started_at),
|
||||
duration_sec=VALUES(duration_sec),
|
||||
success=VALUES(success),
|
||||
error=VALUES(error),
|
||||
created_at=VALUES(created_at),
|
||||
synced_at=NOW()""",
|
||||
(r.get('id'), r.get('provider'), r.get('model'),
|
||||
r.get('video_id'), r.get('filename'), r.get('started_at'),
|
||||
r.get('duration_sec') or 0, 1 if r.get('success') else 0,
|
||||
(r.get('error') or '')[:500], r.get('created_at')))
|
||||
n += 1
|
||||
conn.commit()
|
||||
return n
|
||||
return [r['canonical_name'] for r in _rows(conn.execute(
|
||||
"SELECT DISTINCT canonical_name FROM people "
|
||||
"WHERE canonical_name IS NOT NULL AND canonical_name != '' "
|
||||
"ORDER BY canonical_name ASC"))]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_sync_model_calls(limit: int = 200) -> List[Dict]:
|
||||
"""最近模型调用记录(前端统计展示)。"""
|
||||
def get_known_members_context() -> str:
|
||||
"""人物清单文本,注入问答 Prompt,让模型用真名指代。"""
|
||||
rows = get_people()
|
||||
parts = []
|
||||
for r in rows:
|
||||
name = r['canonical_name'] or r['label']
|
||||
if r['canonical_name'] and r['canonical_name'] != r['label']:
|
||||
parts.append(f"- {name}(标识:{r['label']})")
|
||||
else:
|
||||
parts.append(f"- {name}")
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# model_calls
|
||||
# ============================================================
|
||||
def get_model_calls(limit: int = 200) -> List[Dict]:
|
||||
conn = get_conn()
|
||||
try:
|
||||
cur = conn.cursor(pymysql.cursors.DictCursor)
|
||||
cur.execute(
|
||||
return _rows(conn.execute(
|
||||
"SELECT id, provider, model, video_id, filename, started_at, "
|
||||
"duration_sec, success, error, created_at "
|
||||
"FROM sync_model_calls ORDER BY id DESC LIMIT %s", (limit,))
|
||||
return cur.fetchall()
|
||||
"FROM model_calls ORDER BY id DESC LIMIT ?", (limit,)))
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_sync_model_calls_stats() -> Dict:
|
||||
"""模型调用统计:按 provider+model 聚合成功/失败/平均耗时。"""
|
||||
def get_model_calls_stats() -> List[Dict]:
|
||||
"""按 provider+model 聚合成功/失败/平均耗时。"""
|
||||
conn = get_conn()
|
||||
try:
|
||||
cur = conn.cursor(pymysql.cursors.DictCursor)
|
||||
cur.execute(
|
||||
return _rows(conn.execute(
|
||||
"""SELECT provider, model,
|
||||
SUM(CASE WHEN success=1 THEN 1 ELSE 0 END) AS ok_cnt,
|
||||
SUM(CASE WHEN success=0 THEN 1 ELSE 0 END) AS fail_cnt,
|
||||
ROUND(AVG(duration_sec), 1) AS avg_duration,
|
||||
MAX(created_at) AS last_call
|
||||
FROM sync_model_calls
|
||||
GROUP BY provider, model ORDER BY provider, model""")
|
||||
return cur.fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_sync_named_members() -> List[str]:
|
||||
"""已命名成员的真名列表(供 UI 下拉 / 快捷选择)。"""
|
||||
conn = get_conn()
|
||||
try:
|
||||
cur = conn.cursor(pymysql.cursors.DictCursor)
|
||||
cur.execute(
|
||||
"SELECT DISTINCT canonical_name FROM sync_people "
|
||||
"WHERE canonical_name IS NOT NULL AND canonical_name != '' "
|
||||
"ORDER BY canonical_name ASC")
|
||||
return [r['canonical_name'] for r in cur.fetchall()]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_sync_known_members_context() -> str:
|
||||
"""获取人物清单文本,注入问答 Prompt,让模型用真名指代。"""
|
||||
conn = get_conn()
|
||||
try:
|
||||
cur = conn.cursor(pymysql.cursors.DictCursor)
|
||||
cur.execute(
|
||||
"SELECT label, canonical_name FROM sync_people ORDER BY id ASC")
|
||||
rows = cur.fetchall()
|
||||
if not rows:
|
||||
return ""
|
||||
parts = []
|
||||
for r in rows:
|
||||
name = r['canonical_name'] or r['label']
|
||||
if r['canonical_name'] and r['canonical_name'] != r['label']:
|
||||
parts.append(f"- {name}(标识:{r['label']})")
|
||||
else:
|
||||
parts.append(f"- {name}")
|
||||
return "\n".join(parts)
|
||||
FROM model_calls GROUP BY provider, model ORDER BY provider, model"""))
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 同步游标
|
||||
# 概览统计
|
||||
# ============================================================
|
||||
|
||||
def get_sync_cursor() -> str:
|
||||
def get_stats(date_str: str = None) -> Dict:
|
||||
"""视频数 / 事件数 / 关注事件数 / 出现人物数(可按日期过滤)。"""
|
||||
d = _DATE_EXPR.format(a='sv')
|
||||
conn = get_conn()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT `value` FROM sync_cursor WHERE `key`='last_since'")
|
||||
row = cur.fetchone()
|
||||
return row[0] if row else ''
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def set_sync_cursor(value: str):
|
||||
conn = get_conn()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""INSERT INTO sync_cursor (`key`, `value`) VALUES ('last_since', %s)
|
||||
ON DUPLICATE KEY UPDATE `value`=VALUES(`value`)""",
|
||||
(value,))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 统计
|
||||
# ============================================================
|
||||
|
||||
def get_attention_events(limit: int = 200) -> List[Dict]:
|
||||
"""需关注事件列表(供统计图表页展示日期 + 涉及人物),按录制日期倒序。"""
|
||||
conn = get_conn()
|
||||
try:
|
||||
cur = conn.cursor(pymysql.cursors.DictCursor)
|
||||
cur.execute(
|
||||
"""SELECT COALESCE(NULLIF(v.event_start_time,''), v.processed_at) AS ev_date,
|
||||
e.person_list_json
|
||||
FROM sync_events e JOIN sync_videos v ON e.video_id=v.id
|
||||
WHERE e.is_attention_event = 1
|
||||
ORDER BY ev_date DESC LIMIT %s""",
|
||||
(limit,))
|
||||
return cur.fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_sync_stats(date_str: str = None) -> Dict:
|
||||
"""概览统计:视频数 / 事件数 / 关注事件数 / 出现人物数(按 date 可选过滤)。
|
||||
|
||||
人物数通过对 sync_events.person_list_json LIKE 统计(MariaDB 不支持 JSON 数组展开)。
|
||||
"""
|
||||
conn = get_conn()
|
||||
try:
|
||||
cur = conn.cursor(pymysql.cursors.DictCursor)
|
||||
# 视频/事件/关注数(日期维度=录制时间 event_start_time,回退 processed_at)
|
||||
_D = "COALESCE(NULLIF(sv.event_start_time,''), sv.processed_at)"
|
||||
if date_str:
|
||||
cur.execute(
|
||||
"""SELECT
|
||||
COUNT(*) AS videos,
|
||||
(SELECT COUNT(*) FROM sync_events se
|
||||
JOIN sync_videos sv ON se.video_id=sv.id
|
||||
WHERE {d} LIKE %s) AS events,
|
||||
(SELECT COALESCE(SUM(se.is_attention_event),0) FROM sync_events se
|
||||
JOIN sync_videos sv ON se.video_id=sv.id
|
||||
WHERE {d} LIKE %s) AS attention
|
||||
FROM sync_videos sv WHERE {d} LIKE %s""".format(d=_D),
|
||||
(f'{date_str}%', f'{date_str}%', f'{date_str}%'))
|
||||
like = f'{date_str}%'
|
||||
stat = _row(conn.execute(
|
||||
f"""SELECT
|
||||
(SELECT COUNT(*) FROM videos sv
|
||||
WHERE sv.status='done'
|
||||
AND (substr(sv.filename,1,7)='motion_'
|
||||
OR EXISTS(SELECT 1 FROM events se WHERE se.video_id=sv.id))
|
||||
AND {d} LIKE ?) AS videos,
|
||||
(SELECT COUNT(*) FROM events se JOIN videos sv ON se.video_id=sv.id
|
||||
WHERE {d} LIKE ?) AS events,
|
||||
(SELECT COALESCE(SUM(se.is_attention_event),0) FROM events se
|
||||
JOIN videos sv ON se.video_id=sv.id
|
||||
WHERE {d} LIKE ?) AS attention""",
|
||||
(like, like, like))) or {}
|
||||
else:
|
||||
cur.execute(
|
||||
stat = _row(conn.execute(
|
||||
"""SELECT
|
||||
(SELECT COUNT(*) FROM sync_videos WHERE status='done') AS videos,
|
||||
(SELECT COUNT(*) FROM sync_events) AS events,
|
||||
(SELECT COALESCE(SUM(is_attention_event),0) FROM sync_events) AS attention""")
|
||||
stat = cur.fetchone() or {}
|
||||
(SELECT COUNT(*) FROM videos sv
|
||||
WHERE sv.status='done'
|
||||
AND (substr(sv.filename,1,7)='motion_'
|
||||
OR EXISTS(SELECT 1 FROM events se WHERE se.video_id=sv.id))) AS videos,
|
||||
(SELECT COUNT(*) FROM events) AS events,
|
||||
(SELECT COALESCE(SUM(is_attention_event),0) FROM events) AS attention""")) or {}
|
||||
|
||||
# 人物数(distinct label 命中 sync_events)
|
||||
cur.execute("SELECT id, label, canonical_name FROM sync_people")
|
||||
people = cur.fetchall()
|
||||
# 基于 person_list_json 命中计数:逐 label 统计命中事件数
|
||||
person_hits = 0
|
||||
for p in people:
|
||||
# 出现人物数:逐 label/真名在事件里查有没有命中(沿用改写前的口径)
|
||||
hits = 0
|
||||
for p in _rows(conn.execute("SELECT label, canonical_name FROM people")):
|
||||
name = p['canonical_name'] or p['label']
|
||||
cur.execute(
|
||||
"SELECT COUNT(*) c FROM sync_events WHERE person_list_json LIKE %s",
|
||||
(f'%{name}%',))
|
||||
if cur.fetchone()['c'] > 0:
|
||||
person_hits += 1
|
||||
stat['people'] = person_hits
|
||||
c = conn.execute("SELECT COUNT(*) AS c FROM events "
|
||||
"WHERE person_list_json LIKE ?", (f'%{name}%',)).fetchone()
|
||||
if c and c['c'] > 0:
|
||||
hits += 1
|
||||
stat['people'] = hits
|
||||
return stat
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# chat_history(保留:问答历史)
|
||||
# chat_history(本模块唯一写的表;2026-09-13 从 NAS MariaDB 迁入)
|
||||
# ============================================================
|
||||
def _ensure_chat_schema(conn):
|
||||
global _chat_schema_ready
|
||||
if _chat_schema_ready:
|
||||
return
|
||||
conn.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS chat_history (
|
||||
chat_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_question TEXT NOT NULL,
|
||||
ai_answer TEXT NOT NULL,
|
||||
context_summary TEXT,
|
||||
queried_date TEXT,
|
||||
queried_person TEXT,
|
||||
created_at TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_chat_created ON chat_history(created_at);
|
||||
""")
|
||||
conn.commit()
|
||||
_chat_schema_ready = True
|
||||
|
||||
|
||||
def insert_chat_history(user_question: str, ai_answer: str,
|
||||
context_summary: str, queried_date: str,
|
||||
queried_person: str) -> int:
|
||||
"""插入对话记录"""
|
||||
conn = get_conn()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
_ensure_chat_schema(conn)
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO chat_history
|
||||
(user_question, ai_answer, context_summary, queried_date, queried_person)
|
||||
VALUES (%s, %s, %s, %s, %s)""",
|
||||
(user_question, ai_answer, context_summary, queried_date, queried_person)
|
||||
)
|
||||
(user_question, ai_answer, context_summary, queried_date,
|
||||
queried_person, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)""",
|
||||
(user_question, ai_answer, context_summary, queried_date,
|
||||
queried_person, _now()))
|
||||
conn.commit()
|
||||
return cur.lastrowid
|
||||
finally:
|
||||
@@ -506,24 +358,20 @@ def insert_chat_history(user_question: str, ai_answer: str,
|
||||
|
||||
|
||||
def get_chat_history(limit=20, offset=0, date_filter=None, person_filter=None) -> List[Dict]:
|
||||
"""获取对话历史(分页)"""
|
||||
conn = get_conn()
|
||||
try:
|
||||
cur = conn.cursor(pymysql.cursors.DictCursor)
|
||||
conditions = []
|
||||
params = []
|
||||
_ensure_chat_schema(conn)
|
||||
conds, params = [], []
|
||||
if date_filter:
|
||||
conditions.append("queried_date = %s")
|
||||
conds.append("queried_date = ?")
|
||||
params.append(date_filter)
|
||||
if person_filter:
|
||||
conditions.append("queried_person = %s")
|
||||
conds.append("queried_person = ?")
|
||||
params.append(person_filter)
|
||||
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
|
||||
where = f"WHERE {' AND '.join(conds)}" if conds else ""
|
||||
params.extend([limit, offset])
|
||||
cur.execute(
|
||||
f"SELECT * FROM chat_history {where} ORDER BY created_at DESC LIMIT %s OFFSET %s",
|
||||
params
|
||||
)
|
||||
return cur.fetchall()
|
||||
return _rows(conn.execute(
|
||||
f"SELECT * FROM chat_history {where} ORDER BY created_at DESC LIMIT ? OFFSET ?",
|
||||
params))
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
102
fam-core/src/fam_core/edge_client.py
Normal file
102
fam-core/src/fam_core/edge_client.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""
|
||||
Edge-Client - 访问同机 fam-edge 的薄客户端(2026-09-13 迁云后新增)
|
||||
|
||||
迁云之前这些调用散在 `oracle_sync.py` 里(NAS 跨公网访问甲骨文),随镜像层一起
|
||||
删掉了。现在 fam-core 与 fam-edge 同机,全部走 127.0.0.1:不出公网、无 TLS、
|
||||
无隧道,token 仍然带着(fam-edge 侧的鉴权没变,且它同时对公网监听 :5000)。
|
||||
|
||||
只保留三类调用:
|
||||
- push_name_correct / push_video_delete —— 写操作仍由 fam-edge 处理,因为它
|
||||
除了改库还要做人物合并、删磁盘素材,不是单纯一条 SQL
|
||||
- get_activity —— 服务状态页
|
||||
- fetch_image —— 事件帧图 / 人物头像(图源和裁剪都在 fam-edge)
|
||||
"""
|
||||
import requests
|
||||
|
||||
from .config_loader import load_config
|
||||
from .logger import setup_logger
|
||||
|
||||
logger = setup_logger('fam-core.edge_client')
|
||||
|
||||
_cfg = None
|
||||
|
||||
|
||||
def _conf():
|
||||
global _cfg
|
||||
if _cfg is None:
|
||||
c = load_config().get('edge', {})
|
||||
_cfg = {
|
||||
'base_url': (c.get('base_url') or 'http://127.0.0.1:5000').rstrip('/'),
|
||||
'token': c.get('token') or '',
|
||||
'timeout': int(c.get('timeout', 60)),
|
||||
}
|
||||
return _cfg
|
||||
|
||||
|
||||
def base_url():
|
||||
return _conf()['base_url']
|
||||
|
||||
|
||||
def token():
|
||||
return _conf()['token']
|
||||
|
||||
|
||||
def _post(rel: str, payload: dict):
|
||||
"""返回 (ok, err)。写操作统一用这个,失败时把原因带回给前端。"""
|
||||
c = _conf()
|
||||
body = dict(payload)
|
||||
if c['token']:
|
||||
body['token'] = c['token']
|
||||
try:
|
||||
resp = requests.post(f"{c['base_url']}{rel}", json=body, timeout=(5, c['timeout']))
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"fam-edge {rel} 请求失败: {e}")
|
||||
return False, str(e)
|
||||
if resp.status_code != 200:
|
||||
logger.warning(f"fam-edge {rel} 返回 {resp.status_code}: {resp.text[:160]}")
|
||||
return False, f"HTTP {resp.status_code} {resp.text[:120]}"
|
||||
return True, ''
|
||||
|
||||
|
||||
def push_name_correct(label: str, canonical_name: str):
|
||||
return _post('/api/oracle/people/correct',
|
||||
{'label': label, 'canonical_name': canonical_name})
|
||||
|
||||
|
||||
def push_identity_correct(video_id: int, current_name: str, new_name: str):
|
||||
return _post('/api/oracle/identity/correct',
|
||||
{'video_id': video_id, 'current_name': current_name, 'new_name': new_name})
|
||||
|
||||
|
||||
def push_video_delete(video_id: int):
|
||||
return _post('/api/oracle/video/delete', {'video_id': video_id})
|
||||
|
||||
|
||||
def get_activity():
|
||||
"""服务状态页用:fam-edge 的队列 / 分割 / 模型活动快照。返回 (data, err)。"""
|
||||
c = _conf()
|
||||
try:
|
||||
r = requests.get(f"{c['base_url']}/api/oracle/activity",
|
||||
params={'token': c['token']}, timeout=(5, 15))
|
||||
except requests.RequestException as e:
|
||||
return None, f"连接 fam-edge 失败: {e}"
|
||||
if r.status_code != 200:
|
||||
return None, f"fam-edge activity HTTP {r.status_code}"
|
||||
return r.json(), None
|
||||
|
||||
|
||||
def fetch_image(rel: str, params: dict):
|
||||
"""帧图 / 头像透传,失败返回 None(调用方给 404)。"""
|
||||
c = _conf()
|
||||
p = dict(params)
|
||||
if c['token']:
|
||||
p['token'] = c['token']
|
||||
try:
|
||||
resp = requests.get(f"{c['base_url']}{rel}", params=p, timeout=(5, c['timeout']))
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"fam-edge {rel} 请求失败: {e}")
|
||||
return None
|
||||
if resp.status_code == 200 and resp.content:
|
||||
return resp.content
|
||||
logger.warning(f"fam-edge {rel} 返回 {resp.status_code}")
|
||||
return None
|
||||
@@ -1,14 +1,12 @@
|
||||
"""
|
||||
Img-Proxy - NAS 端图片代理(图源在 Oracle,计算全部在 Oracle)
|
||||
Img-Proxy - 图片代理(图源与裁剪都在 fam-edge)
|
||||
|
||||
浏览器不直连 Oracle(避免公网暴露 5000 端口与 token 外泄),
|
||||
而是访问 NAS fam-core 的 /api/proxy/*,由 NAS 出网到 Oracle 拉取 jpeg 回传。
|
||||
|
||||
Oracle 侧已有磁盘缓存 / VLM 人物定位裁剪,NAS 端仅透传,不做图像计算。
|
||||
浏览器不直连 fam-edge(避免 token 外泄),而是访问 /api/proxy/*,由本服务转一手。
|
||||
迁云后这一跳是同机 127.0.0.1,纯透传,不做图像计算。
|
||||
"""
|
||||
from flask import Blueprint, Response, request
|
||||
|
||||
from .oracle_sync import get_sync
|
||||
from . import edge_client
|
||||
from .logger import setup_logger
|
||||
|
||||
logger = setup_logger('fam-core.img_proxy')
|
||||
@@ -17,21 +15,7 @@ img_bp = Blueprint('img_proxy', __name__)
|
||||
|
||||
|
||||
def _fetch(rel: str, params: dict):
|
||||
import requests
|
||||
sync = get_sync() # 复用 oracle_sync 已解析好的 base_url/token/timeout,避免两处配置各读一份
|
||||
params = dict(params)
|
||||
if sync.token:
|
||||
params['token'] = sync.token
|
||||
try:
|
||||
resp = requests.get(f"{sync.base_url}{rel}", params=params,
|
||||
timeout=(10, sync.timeout))
|
||||
if resp.status_code == 200 and resp.content:
|
||||
return resp.content
|
||||
logger.warning(f"Oracle {rel} 返回 {resp.status_code}: {resp.text[:120]}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Oracle {rel} 请求失败: {e}")
|
||||
return None
|
||||
return edge_client.fetch_image(rel, params)
|
||||
|
||||
|
||||
@img_bp.route('/api/proxy/frame', methods=['GET'])
|
||||
|
||||
@@ -14,7 +14,7 @@ from flask import Blueprint, request, jsonify
|
||||
|
||||
from ..logger import setup_logger
|
||||
from .. import db_layer
|
||||
from ..oracle_sync import get_sync
|
||||
from .. import edge_client
|
||||
|
||||
logger = setup_logger('fam-core.member_manager')
|
||||
|
||||
@@ -24,7 +24,7 @@ member_bp = Blueprint('member_manager', __name__)
|
||||
@member_bp.route('/api/member/unnamed', methods=['GET'])
|
||||
def list_unnamed():
|
||||
"""列出未命名人物(canonical_name 为空)"""
|
||||
members = db_layer.get_sync_people()
|
||||
members = db_layer.get_people()
|
||||
result = []
|
||||
for m in members:
|
||||
canonical = m.get('canonical_name')
|
||||
@@ -40,7 +40,7 @@ def list_unnamed():
|
||||
@member_bp.route('/api/member/list', methods=['GET'])
|
||||
def list_members():
|
||||
"""列出所有人物(按 canonical_name 或 label 展示)"""
|
||||
members = db_layer.get_sync_people()
|
||||
members = db_layer.get_people()
|
||||
result = []
|
||||
for m in members:
|
||||
canonical = m.get('canonical_name')
|
||||
@@ -58,7 +58,7 @@ def list_members():
|
||||
|
||||
@member_bp.route('/api/member/name', methods=['POST'])
|
||||
def name_member():
|
||||
"""命名人物(回推 Oracle + 立即拉回本地镜像)
|
||||
"""命名人物(交给 fam-edge 落库 + 合并人物)
|
||||
|
||||
请求: {"label": "人物A", "canonical_name": "张三"}
|
||||
"""
|
||||
@@ -71,18 +71,13 @@ def name_member():
|
||||
if not label or not canonical_name:
|
||||
return jsonify({"error": "缺少必填字段: label, canonical_name"}), 400
|
||||
|
||||
logger.info(f"命名: {label} -> {canonical_name}(回推 Oracle)")
|
||||
ok, err = get_sync().push_name_correct(label, canonical_name)
|
||||
logger.info(f"命名: {label} -> {canonical_name}(回推 fam-edge)")
|
||||
ok, err = edge_client.push_name_correct(label, canonical_name)
|
||||
if not ok:
|
||||
return jsonify({"error": f"回推 Oracle 失败: {err}"}), 502
|
||||
return jsonify({"error": f"回推 fam-edge 失败: {err}"}), 502
|
||||
|
||||
# 立即拉回最新 people 镜像,前端无需等待下一个 30 分钟周期
|
||||
try:
|
||||
get_sync().trigger_now()
|
||||
except Exception as e:
|
||||
logger.warning(f"命名后即时拉回失败(下一个周期会自动同步): {e}")
|
||||
|
||||
members = db_layer.get_sync_people()
|
||||
members = db_layer.get_people()
|
||||
return jsonify({
|
||||
"status": "ok",
|
||||
"label": label,
|
||||
@@ -114,24 +109,20 @@ def merge_member():
|
||||
return jsonify({"error": "source 与 target 不能相同"}), 400
|
||||
|
||||
# 解析 target 的规范名
|
||||
members = {m['label']: m for m in db_layer.get_sync_people()}
|
||||
members = {m['label']: m for m in db_layer.get_people()}
|
||||
target_row = members.get(target)
|
||||
if target_row and target_row.get('canonical_name'):
|
||||
canonical = target_row['canonical_name']
|
||||
else:
|
||||
canonical = target # target 未命名 -> 以 label 作为规范名
|
||||
|
||||
logger.info(f"合并: {source} -> {canonical}(回推 Oracle)")
|
||||
ok, err = get_sync().push_name_correct(source, canonical)
|
||||
logger.info(f"合并: {source} -> {canonical}(回推 fam-edge)")
|
||||
ok, err = edge_client.push_name_correct(source, canonical)
|
||||
if not ok:
|
||||
return jsonify({"error": f"回推 Oracle 失败: {err}"}), 502
|
||||
return jsonify({"error": f"回推 fam-edge 失败: {err}"}), 502
|
||||
|
||||
try:
|
||||
get_sync().trigger_now()
|
||||
except Exception as e:
|
||||
logger.warning(f"合并后即时拉回失败(下一个周期会自动同步): {e}")
|
||||
|
||||
members = db_layer.get_sync_people()
|
||||
members = db_layer.get_people()
|
||||
return jsonify({
|
||||
"status": "ok",
|
||||
"source": source,
|
||||
@@ -142,3 +133,34 @@ def merge_member():
|
||||
"display_name": m.get('canonical_name') or m['label'],
|
||||
} for m in members]
|
||||
}), 200
|
||||
|
||||
|
||||
@member_bp.route('/api/member/identity-correct', methods=['POST'])
|
||||
def identity_correct():
|
||||
"""事件时间轴"这个人识别错了"纠错入口(比 /api/member/name 粒度更细)。
|
||||
|
||||
请求: {"video_id": 123, "current_name": "爷爷", "new_name": "爸爸"}
|
||||
只改这一段视频里被错误识别的那个人,不影响同名字符串在其他视频里的映射
|
||||
(人物 uid 只在单次视频分析内稳定,同一字符串在不同视频里可能是不同真人,
|
||||
不能像 /api/member/name 那样按全局 label 改)。
|
||||
"""
|
||||
data = request.get_json(silent=True)
|
||||
if not data:
|
||||
return jsonify({"error": "Invalid JSON"}), 400
|
||||
|
||||
video_id = data.get('video_id')
|
||||
current_name = (data.get('current_name') or '').strip()
|
||||
new_name = (data.get('new_name') or '').strip()
|
||||
if not video_id or not current_name or not new_name:
|
||||
return jsonify({"error": "缺少必填字段: video_id, current_name, new_name"}), 400
|
||||
|
||||
logger.info(f"人物纠错: video_id={video_id} {current_name} -> {new_name}(回推 fam-edge)")
|
||||
ok, err = edge_client.push_identity_correct(video_id, current_name, new_name)
|
||||
if not ok:
|
||||
return jsonify({"error": f"回推 fam-edge 失败: {err}"}), 502
|
||||
|
||||
|
||||
return jsonify({
|
||||
"status": "ok", "video_id": video_id,
|
||||
"current_name": current_name, "new_name": new_name,
|
||||
}), 200
|
||||
|
||||
@@ -1,178 +0,0 @@
|
||||
"""
|
||||
Oracle-Sync - NAS 端唯一后台线程
|
||||
|
||||
职责:
|
||||
1. 每 interval_sec(默认 1800s = 30 分钟)从甲骨文 FAM-Edge 拉取增量:
|
||||
GET {base_url}/api/oracle/sync?since=<cursor>&token=<token>
|
||||
返回 {videos, events, people, server_time},写入本地 MariaDB 镜像表
|
||||
(sync_videos / sync_events / sync_people),并推进 sync_cursor。
|
||||
2. 接收命名校正回推: POST {base_url}/api/oracle/people/correct
|
||||
{label, canonical_name, token} —— 手动命名(manual 优先,不被 LLM 覆盖)。
|
||||
|
||||
数据流向(新架构 v2):
|
||||
Google 硬盘 --rclone--> 甲骨文本地 --> 整视频分析 --> Oracle SQLite
|
||||
--> [本线程每 30 分钟拉增量] --> NAS MariaDB 镜像 --> fam-ui 读取展示
|
||||
|
||||
NAS 不再处理任何视频,CPU 占用显著降低。
|
||||
"""
|
||||
import time
|
||||
import threading
|
||||
import requests
|
||||
from datetime import datetime
|
||||
|
||||
from .logger import setup_logger
|
||||
from .config_loader import load_config
|
||||
from . import db_layer
|
||||
|
||||
logger = setup_logger('fam-core.oracle_sync')
|
||||
|
||||
_SYNC_INSTANCE = None
|
||||
|
||||
|
||||
def get_sync():
|
||||
"""模块级单例(app.py 启动时创建并 start,其余模块经此获取)"""
|
||||
global _SYNC_INSTANCE
|
||||
if _SYNC_INSTANCE is None:
|
||||
_SYNC_INSTANCE = OracleSync()
|
||||
return _SYNC_INSTANCE
|
||||
|
||||
|
||||
class OracleSync:
|
||||
def __init__(self):
|
||||
cfg = load_config().get('oracle_sync', {})
|
||||
self.base_url = cfg.get('base_url', 'http://129.146.203.203:5000').rstrip('/')
|
||||
self.token = cfg.get('token', '')
|
||||
self.interval_sec = int(cfg.get('interval_sec', 1800))
|
||||
self.timeout = int(cfg.get('timeout', 120))
|
||||
self._running = False
|
||||
self._thread = None
|
||||
self._last_sync_at = None
|
||||
self._last_error = None
|
||||
self._last_count = None
|
||||
# 拉取互斥锁:trigger_now(命名后即时拉回)与后台 _run 并发时只允许一个执行
|
||||
self._pull_lock = threading.Lock()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
def _pull_once(self) -> bool:
|
||||
"""执行一次增量拉取(加锁防并发双拉)。返回是否成功。"""
|
||||
with self._pull_lock:
|
||||
return self._pull_once_locked()
|
||||
|
||||
def _pull_once_locked(self) -> bool:
|
||||
since = db_layer.get_sync_cursor() or ''
|
||||
params = {'since': since, 'token': self.token}
|
||||
try:
|
||||
resp = requests.get(
|
||||
f"{self.base_url}/api/oracle/sync",
|
||||
params=params, timeout=(10, self.timeout))
|
||||
except requests.RequestException as e:
|
||||
self._last_error = f"请求失败: {e}"
|
||||
logger.error(f"拉取同步失败: {e}")
|
||||
return False
|
||||
|
||||
if resp.status_code == 401:
|
||||
self._last_error = "token 校验失败"
|
||||
logger.error("同步 token 校验失败 (401),请检查 oracle_sync.token 配置")
|
||||
return False
|
||||
if resp.status_code != 200:
|
||||
self._last_error = f"HTTP {resp.status_code}"
|
||||
logger.error(f"同步返回异常: {resp.status_code} {resp.text[:200]}")
|
||||
return False
|
||||
|
||||
try:
|
||||
data = resp.json()
|
||||
except ValueError:
|
||||
self._last_error = "非 JSON 响应"
|
||||
logger.error("同步返回非 JSON 响应")
|
||||
return False
|
||||
|
||||
videos = data.get('videos', []) or []
|
||||
events = data.get('events', []) or []
|
||||
people = data.get('people', []) or []
|
||||
model_calls = data.get('model_calls', []) or []
|
||||
server_time = data.get('server_time', '') or ''
|
||||
|
||||
n_videos = db_layer.upsert_sync_videos(videos)
|
||||
n_events = db_layer.upsert_sync_events(events)
|
||||
n_people = db_layer.upsert_sync_people(people)
|
||||
n_calls = db_layer.upsert_sync_model_calls(model_calls)
|
||||
|
||||
if server_time:
|
||||
db_layer.set_sync_cursor(server_time)
|
||||
|
||||
self._last_sync_at = datetime.now()
|
||||
self._last_error = None
|
||||
self._last_count = (n_videos, n_events, n_people, n_calls)
|
||||
logger.info(
|
||||
f"同步完成: videos+{n_videos} events+{n_events} people+{n_people} "
|
||||
f"model_calls+{n_calls} since={since!r} -> server_time={server_time}")
|
||||
return True
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
def push_name_correct(self, label: str, canonical_name: str):
|
||||
"""回推命名校正到 Oracle(手动命名优先级最高,不被 LLM 覆盖)。
|
||||
|
||||
返回 (success: bool, error: str)
|
||||
"""
|
||||
label = (label or '').strip()
|
||||
canonical_name = (canonical_name or '').strip()
|
||||
if not label or not canonical_name:
|
||||
return False, "缺少 label / canonical_name"
|
||||
try:
|
||||
resp = requests.post(
|
||||
f"{self.base_url}/api/oracle/people/correct",
|
||||
json={"label": label, "canonical_name": canonical_name,
|
||||
"token": self.token},
|
||||
timeout=(10, 30))
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"命名校正回推失败: {e}")
|
||||
return False, str(e)
|
||||
if resp.status_code == 200:
|
||||
return True, ""
|
||||
msg = f"HTTP {resp.status_code}: {resp.text[:200]}"
|
||||
logger.error(f"命名校正回推失败: {msg}")
|
||||
return False, msg
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
def _run(self):
|
||||
logger.info(f"OracleSync 线程启动,间隔 {self.interval_sec}s,目标 {self.base_url}")
|
||||
while self._running:
|
||||
try:
|
||||
self._pull_once()
|
||||
except Exception as e:
|
||||
self._last_error = str(e)
|
||||
logger.error(f"同步异常: {e}", exc_info=True)
|
||||
# 分段休眠,便于 stop 快速唤醒
|
||||
for _ in range(self.interval_sec):
|
||||
if not self._running:
|
||||
break
|
||||
time.sleep(1)
|
||||
|
||||
def start(self):
|
||||
if self._running:
|
||||
return
|
||||
self._running = True
|
||||
self._thread = threading.Thread(target=self._run, daemon=True, name='oracle-sync')
|
||||
self._thread.start()
|
||||
|
||||
def is_alive(self):
|
||||
return self._thread is not None and self._thread.is_alive()
|
||||
|
||||
def stop(self):
|
||||
self._running = False
|
||||
if self._thread:
|
||||
self._thread.join(timeout=5)
|
||||
|
||||
def trigger_now(self) -> bool:
|
||||
"""立即触发一次同步(命名后即时拉回 / 手动)。"""
|
||||
return self._pull_once()
|
||||
|
||||
def status(self) -> dict:
|
||||
return {
|
||||
"running": self.is_alive(),
|
||||
"last_sync_at": self._last_sync_at.isoformat() if self._last_sync_at else None,
|
||||
"last_error": self._last_error,
|
||||
"last_count": self._last_count,
|
||||
"cursor": db_layer.get_sync_cursor(),
|
||||
"interval_sec": self.interval_sec,
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
"""
|
||||
Static-App - 提供 Vue 前端构建产物(新架构 v3:fam-ui 不再单独起 Streamlit 进程)
|
||||
|
||||
fam-ui/dist/ 是本地 `npm run build` 出的静态文件,部署时整个目录拷到 NAS。
|
||||
本模块只做两件事: 命中真实静态文件(如 /assets/xxx.js)直接下发;其余任何路径
|
||||
(Vue Router history 模式的前端路由)一律回退到 index.html,由浏览器端路由接管。
|
||||
|
||||
必须最后注册(app.py 里排在 chat_bp/member_bp/img_bp/ui_bp 之后),否则这里的
|
||||
通配路由会先于 /api/* 匹配,把 API 请求也吞成 index.html。
|
||||
"""
|
||||
import os
|
||||
|
||||
from flask import Blueprint, send_from_directory, abort
|
||||
|
||||
DIST_DIR = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
|
||||
'..', 'fam-ui', 'dist'
|
||||
)
|
||||
DIST_DIR = os.path.normpath(DIST_DIR)
|
||||
|
||||
static_bp = Blueprint('static_app', __name__)
|
||||
|
||||
|
||||
@static_bp.route('/', defaults={'path': ''})
|
||||
@static_bp.route('/<path:path>')
|
||||
def spa(path):
|
||||
if not os.path.isdir(DIST_DIR):
|
||||
abort(404, "前端构建产物不存在,请先 npm run build 并部署 fam-ui/dist")
|
||||
full = os.path.join(DIST_DIR, path)
|
||||
if path and os.path.isfile(full):
|
||||
return send_from_directory(DIST_DIR, path)
|
||||
return send_from_directory(DIST_DIR, 'index.html')
|
||||
@@ -1,12 +1,14 @@
|
||||
"""
|
||||
UI-API - Vue 前端只读数据接口(新架构 v3:Vue SPA 取代 Streamlit)
|
||||
UI-API - Vue 前端数据接口(新架构 v3:Vue SPA 取代 Streamlit)
|
||||
|
||||
全部包装 db_layer.py 里已有的查询函数,不新写查询逻辑。人物按 canonical_name
|
||||
聚合的逻辑从旧 Streamlit 版本搬过来,放在服务端做(前端只管渲染,不重复业务规则)。
|
||||
以只读查询为主,全部包装 db_layer.py 里已有的查询函数,不新写查询逻辑;人物
|
||||
按 canonical_name 聚合的逻辑从旧 Streamlit 版本搬过来,放在服务端做(前端只管
|
||||
渲染,不重复业务规则)。**2026-08-24 新增一个写操作**:`DELETE /api/ui/videos/
|
||||
<id>`(删除视频会话),因为语义上属于 videos 这个资源,比塞进 member_manager.py
|
||||
更清晰;写法沿用项目里"先回推 Oracle,成功后处理本地状态"的既有模式。
|
||||
|
||||
/api/ui/service-status 需要代理 Oracle 的 /api/oracle/activity(浏览器不直连
|
||||
Oracle,避免 token 暴露),写法照抄 img_proxy.py 的模式:复用 oracle_sync.get_sync()
|
||||
已解析好的 base_url/token,不再单独存一份配置。
|
||||
/api/ui/service-status 代理同机 fam-edge 的 /api/oracle/activity(浏览器不直连
|
||||
fam-edge,避免 token 暴露),走 edge_client 统一读 base_url/token。
|
||||
"""
|
||||
import re
|
||||
from datetime import datetime
|
||||
@@ -16,7 +18,7 @@ from flask import Blueprint, request, jsonify
|
||||
|
||||
from .logger import setup_logger
|
||||
from . import db_layer
|
||||
from .oracle_sync import get_sync
|
||||
from . import edge_client
|
||||
|
||||
logger = setup_logger('fam-core.ui_api')
|
||||
|
||||
@@ -47,7 +49,7 @@ def videos():
|
||||
date_filter = request.args.get('date') or None
|
||||
page = max(0, request.args.get('page', 0, type=int))
|
||||
page_size = 15
|
||||
rows = db_layer.get_sync_videos(limit=page_size, offset=page * page_size,
|
||||
rows = db_layer.get_videos(limit=page_size, offset=page * page_size,
|
||||
date_filter=date_filter)
|
||||
return jsonify({"videos": _ser(rows), "page": page, "page_size": page_size}), 200
|
||||
|
||||
@@ -55,18 +57,32 @@ def videos():
|
||||
@ui_bp.route('/api/ui/videos/<int:video_id>', methods=['GET'])
|
||||
def video_detail(video_id):
|
||||
"""单个视频会话详情 + 时间线事件列表(事件时间轴右侧)。"""
|
||||
video = db_layer.get_sync_video(video_id)
|
||||
video = db_layer.get_video(video_id)
|
||||
if not video:
|
||||
return jsonify({"error": "视频不存在"}), 404
|
||||
events = db_layer.get_sync_events_for_video(video_id)
|
||||
events = db_layer.get_events_for_video(video_id)
|
||||
return jsonify({"video": _ser(video), "events": _ser(events)}), 200
|
||||
|
||||
|
||||
@ui_bp.route('/api/ui/videos/<int:video_id>', methods=['DELETE'])
|
||||
def video_delete(video_id):
|
||||
"""删除视频会话(事件时间轴"删除"入口)。
|
||||
|
||||
交给 fam-edge 处理:它删 events + videos 行,还要删磁盘上的运动片段文件。
|
||||
迁云前这里还要再清一次 NAS 本地镜像(增量同步只 upsert,感知不到删除),
|
||||
现在没有镜像了,fam-edge 删完就是最终状态。
|
||||
"""
|
||||
ok, err = edge_client.push_video_delete(video_id)
|
||||
if not ok:
|
||||
return jsonify({"error": f"删除失败: {err}"}), 502
|
||||
return jsonify({"status": "ok", "video_id": video_id}), 200
|
||||
|
||||
|
||||
@ui_bp.route('/api/ui/stats', methods=['GET'])
|
||||
def stats():
|
||||
"""统计卡:视频/事件/人物/关注数(可选按日期过滤)。"""
|
||||
date_filter = request.args.get('date') or None
|
||||
return jsonify(_ser(db_layer.get_sync_stats(date_filter))), 200
|
||||
return jsonify(_ser(db_layer.get_stats(date_filter))), 200
|
||||
|
||||
|
||||
def _clean_person(s: str) -> str:
|
||||
@@ -80,7 +96,7 @@ def people():
|
||||
|
||||
对应旧 Streamlit 版本 app.py 里的分组逻辑,原样搬到服务端。
|
||||
"""
|
||||
rows = db_layer.get_sync_people()
|
||||
rows = db_layer.get_people()
|
||||
groups = {}
|
||||
for p in rows:
|
||||
key = p.get('canonical_name') or p['label']
|
||||
@@ -104,6 +120,22 @@ def people():
|
||||
return jsonify({"groups": _ser(out), "all_labels": all_labels}), 200
|
||||
|
||||
|
||||
@ui_bp.route('/api/ui/people/clips', methods=['GET'])
|
||||
def people_clips():
|
||||
"""某人物出现过的运动片段列表(人物卡「运动片段」区块)。
|
||||
|
||||
按 label/canonical_name 匹配 events.person_list_json → 关联视频
|
||||
(运动片段优先)。返回片段 video_id/filename/event_start_time/duration/
|
||||
summary/camera_name + first_ts(缩略图定位)+ clip_events(片段内事件数)。
|
||||
"""
|
||||
label = request.args.get('label') or ''
|
||||
if not label:
|
||||
return jsonify({"error": "缺少 label 参数"}), 400
|
||||
limit = min(20, request.args.get('limit', 10, type=int) or 10)
|
||||
clips = db_layer.get_people_clips(label, limit)
|
||||
return jsonify({"label": label, "clips": _ser(clips)}), 200
|
||||
|
||||
|
||||
@ui_bp.route('/api/ui/attention-events', methods=['GET'])
|
||||
def attention_events():
|
||||
"""需关注事件列表(统计图表页),已按人物去重规则清洗好 people 字段。"""
|
||||
@@ -127,41 +159,25 @@ def attention_events():
|
||||
@ui_bp.route('/api/ui/named-members', methods=['GET'])
|
||||
def named_members():
|
||||
"""已命名成员真名列表(AI 对话页快捷选择)。"""
|
||||
return jsonify({"members": db_layer.get_sync_named_members()}), 200
|
||||
return jsonify({"members": db_layer.get_named_members()}), 200
|
||||
|
||||
|
||||
@ui_bp.route('/api/ui/model-stats', methods=['GET'])
|
||||
def model_stats():
|
||||
"""云端模型调用统计:按模型聚合 + 最近调用明细。"""
|
||||
agg = db_layer.get_sync_model_calls_stats()
|
||||
calls = db_layer.get_sync_model_calls(limit=100)
|
||||
agg = db_layer.get_model_calls_stats()
|
||||
calls = db_layer.get_model_calls(limit=100)
|
||||
return jsonify({"aggregate": _ser(agg), "recent_calls": _ser(calls)}), 200
|
||||
|
||||
|
||||
@ui_bp.route('/api/ui/service-status', methods=['GET'])
|
||||
def service_status():
|
||||
"""服务状态页:NAS 同步状态 + Oracle 实时活动(代理,token 不下发浏览器)。"""
|
||||
sync = get_sync()
|
||||
nas_status = sync.status()
|
||||
"""服务状态页:fam-edge 的队列/分割/模型活动(代理,token 不下发浏览器)。
|
||||
|
||||
oracle_data = None
|
||||
oracle_error = None
|
||||
if sync.base_url and sync.token:
|
||||
import requests
|
||||
try:
|
||||
r = requests.get(f"{sync.base_url}/api/oracle/activity",
|
||||
params={'token': sync.token}, timeout=15)
|
||||
if r.status_code == 200:
|
||||
oracle_data = r.json()
|
||||
else:
|
||||
oracle_error = f"Oracle activity HTTP {r.status_code}"
|
||||
except Exception as e:
|
||||
oracle_error = f"连接 Oracle 失败: {e}"
|
||||
else:
|
||||
oracle_error = "未配置 oracle_sync.base_url/token"
|
||||
|
||||
return jsonify({
|
||||
"nas_sync": nas_status,
|
||||
"oracle": oracle_data,
|
||||
"oracle_error": oracle_error,
|
||||
}), 200
|
||||
迁云前这里还有一块 "NAS 同步状态"(镜像拉取的游标/周期/上次条数),随镜像层
|
||||
一起删了——现在前端读的就是 fam-edge 写的那份库,没有"同步"这个中间状态。
|
||||
NAS 侧只剩运动事件推送,它的心跳在 fam-edge 的 service_activity 里,
|
||||
已经包含在 activity 快照中。
|
||||
"""
|
||||
data, err = edge_client.get_activity()
|
||||
return jsonify({"oracle": data, "oracle_error": err}), 200
|
||||
|
||||
155
fam-core/tests/test_db_layer.py
Normal file
155
fam-core/tests/test_db_layer.py
Normal file
@@ -0,0 +1,155 @@
|
||||
"""db_layer 直读 SQLite 的单测(2026-09-13 迁云重写后新增)。
|
||||
|
||||
重点验证从 MySQL 翻译过来的几处方言:json_each 取代 JSON_CONTAINS、
|
||||
substr 取代 LEFT、以及历史脏数据(person_list_json 不是合法 JSON)不能
|
||||
把整条查询搞崩——这在 MySQL 下 JSON_CONTAINS 会直接报错,SQLite 下
|
||||
json_each 同样会,所以查询里加了 json_valid 前置判断。
|
||||
"""
|
||||
import sqlite3
|
||||
|
||||
import pytest
|
||||
|
||||
from fam_core import db_layer
|
||||
|
||||
# fam-edge 建表语句的最小子集(列名与 oracle_db.py 保持一致)
|
||||
_SCHEMA = """
|
||||
CREATE TABLE videos (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, filename TEXT UNIQUE, camera_name TEXT,
|
||||
duration_sec REAL, event_start_time TEXT, status TEXT DEFAULT 'pending',
|
||||
summary_json TEXT, events_json TEXT, people_json TEXT, compute_provider TEXT,
|
||||
created_at TEXT, updated_at TEXT, processed_at TEXT);
|
||||
CREATE TABLE events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, video_id INTEGER, ts TEXT, description TEXT,
|
||||
person_list_json TEXT, person_appearances_json TEXT, is_attention_event INTEGER DEFAULT 0);
|
||||
CREATE TABLE people (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, label TEXT UNIQUE, canonical_name TEXT,
|
||||
first_seen TEXT, appearances INTEGER DEFAULT 0, source TEXT, features_json TEXT,
|
||||
display_uid TEXT, updated_at TEXT);
|
||||
CREATE TABLE model_calls (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, provider TEXT, model TEXT, video_id INTEGER,
|
||||
filename TEXT, started_at TEXT, duration_sec REAL, success INTEGER, error TEXT,
|
||||
created_at TEXT);
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db(tmp_path, monkeypatch):
|
||||
path = tmp_path / "oracle.db"
|
||||
conn = sqlite3.connect(path)
|
||||
conn.executescript(_SCHEMA)
|
||||
conn.executescript("""
|
||||
INSERT INTO videos (id, filename, camera_name, event_start_time, status, processed_at)
|
||||
VALUES (1, 'motion_20260913_080000.mp4', '客厅', '2026-09-13 08:00:00', 'done', '2026-09-13 08:10:00'),
|
||||
(2, 'whole_20260912_090000.mp4', '客厅', '2026-09-12 09:00:00', 'done', '2026-09-12 09:10:00'),
|
||||
(3, 'whole_20260911_090000.mp4', '客厅', '2026-09-11 09:00:00', 'done', '2026-09-11 09:10:00'),
|
||||
(4, 'motion_20260910_070000.mp4', '客厅', '2026-09-10 07:00:00', 'pending', NULL);
|
||||
-- video 2 有事件(应展示),video 3 没有事件且不是 motion_(应被过滤掉)
|
||||
INSERT INTO events (video_id, ts, description, person_list_json, is_attention_event)
|
||||
VALUES (1, '00:00:05', '汤圆在客厅玩耍', '["汤圆"]', 0),
|
||||
(1, '00:00:20', '有人靠近门口', '["人物B"]', 1),
|
||||
(2, '00:01:00', '汤圆和奶奶', '["汤圆","奶奶"]', 0),
|
||||
(2, '00:02:00', '脏数据事件', '不是合法JSON', 0);
|
||||
INSERT INTO people (id, label, canonical_name, appearances, source)
|
||||
VALUES (1, '人物A', '汤圆', 12, 'manual'),
|
||||
(2, '人物B', NULL, 3, 'llm'),
|
||||
(3, '汤圆', '汤圆', 5, 'manual');
|
||||
INSERT INTO model_calls (provider, model, success, duration_sec, created_at)
|
||||
VALUES ('gemini', 'flash', 1, 10.0, '2026-09-13 08:10:00'),
|
||||
('gemini', 'flash', 0, 20.0, '2026-09-13 08:20:00'),
|
||||
('nvidia', 'vila', 1, 5.0, '2026-09-12 08:10:00');
|
||||
""")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
monkeypatch.setattr(db_layer, '_db_path', str(path))
|
||||
monkeypatch.setattr(db_layer, '_chat_schema_ready', False)
|
||||
return path
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- videos
|
||||
def test_get_videos_only_returns_content_sessions(db):
|
||||
"""只展示运动片段或含事件的会话:分割 0 段的整段素材不该淹没时间轴。"""
|
||||
ids = [v['id'] for v in db_layer.get_videos()]
|
||||
assert ids == [1, 2] # 3 无事件且非 motion_,4 未完成
|
||||
assert db_layer.get_videos()[0]['event_count'] == 2
|
||||
|
||||
|
||||
def test_get_videos_date_filter_and_paging(db):
|
||||
assert [v['id'] for v in db_layer.get_videos(date_filter='2026-09-12')] == [2]
|
||||
assert [v['id'] for v in db_layer.get_videos(limit=1, offset=1)] == [2]
|
||||
|
||||
|
||||
def test_get_video_and_events(db):
|
||||
assert db_layer.get_video(1)['filename'].startswith('motion_')
|
||||
assert db_layer.get_video(999) is None
|
||||
evs = db_layer.get_events_for_video(1)
|
||||
assert [e['ts'] for e in evs] == ['00:00:05', '00:00:20']
|
||||
|
||||
|
||||
def test_get_attention_events(db):
|
||||
rows = db_layer.get_attention_events()
|
||||
assert len(rows) == 1 and rows[0]['ev_date'].startswith('2026-09-13')
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 人物命中(JSON)
|
||||
def test_people_clips_matches_label_and_its_aliases(db):
|
||||
"""'人物A' 的规范名是"汤圆",而事件里记的是"汤圆"——要能顺着别名找到。"""
|
||||
clips = db_layer.get_people_clips('人物A')
|
||||
assert {c['video_id'] for c in clips} == {1, 2}
|
||||
assert clips[0]['video_id'] == 1 # 按录制时间倒序
|
||||
assert clips[0]['first_ts'] == '00:00:05'
|
||||
assert clips[0]['clip_events'] == 1
|
||||
|
||||
|
||||
def test_person_hit_is_exact_not_substring(db):
|
||||
"""json_each 是精确匹配:查"人物"不该命中"人物B"。"""
|
||||
assert db_layer.get_people_clips('人物') == []
|
||||
assert {c['video_id'] for c in db_layer.get_people_clips('人物B')} == {1}
|
||||
|
||||
|
||||
def test_malformed_person_json_does_not_break_queries(db):
|
||||
"""video 2 里混了一条非 JSON 的脏数据,查询必须照常返回而不是整条报错。"""
|
||||
rows = db_layer.query_events_for_person_date('汤圆', '2026-09-12')
|
||||
assert len(rows) == 1 and rows[0]['ts'] == '00:01:00'
|
||||
|
||||
|
||||
def test_query_events_for_person_date(db):
|
||||
assert db_layer.query_events_for_person_date('汤圆', '2026-09-13')[0]['description'] == '汤圆在客厅玩耍'
|
||||
assert db_layer.query_events_for_person_date('汤圆', '2026-09-01') == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- people / model_calls
|
||||
def test_people_and_named_members(db):
|
||||
assert len(db_layer.get_people()) == 3
|
||||
assert db_layer.get_named_members() == ['汤圆'] # distinct 且非空
|
||||
ctx = db_layer.get_known_members_context()
|
||||
assert '- 汤圆(标识:人物A)' in ctx and '- 人物B' in ctx
|
||||
|
||||
|
||||
def test_model_calls_stats(db):
|
||||
stats = {s['model']: s for s in db_layer.get_model_calls_stats()}
|
||||
assert stats['flash']['ok_cnt'] == 1 and stats['flash']['fail_cnt'] == 1
|
||||
assert stats['flash']['avg_duration'] == 15.0
|
||||
assert len(db_layer.get_model_calls(limit=2)) == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 统计
|
||||
def test_stats_全量与按日(db):
|
||||
total = db_layer.get_stats()
|
||||
assert total['videos'] == 2 and total['events'] == 4 and total['attention'] == 1
|
||||
day = db_layer.get_stats('2026-09-13')
|
||||
assert day['videos'] == 1 and day['events'] == 2
|
||||
# 人物数:汤圆(含 label 人物A/汤圆两行都归一到"汤圆")+ 人物B
|
||||
assert total['people'] >= 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- chat_history
|
||||
def test_chat_history_roundtrip_creates_table_on_demand(db):
|
||||
"""chat_history 是本模块唯一写的表,建表是懒加载的(库文件属于 fam-edge)。"""
|
||||
chat_id = db_layer.insert_chat_history('今天有人来吗', '有,08:00 有人靠近门口',
|
||||
'上下文', '2026-09-13', '汤圆')
|
||||
assert chat_id == 1
|
||||
rows = db_layer.get_chat_history(limit=10)
|
||||
assert rows[0]['user_question'] == '今天有人来吗'
|
||||
assert rows[0]['created_at']
|
||||
assert db_layer.get_chat_history(person_filter='不存在的人') == []
|
||||
assert len(db_layer.get_chat_history(date_filter='2026-09-13')) == 1
|
||||
@@ -29,11 +29,16 @@ oracle_db:
|
||||
sync_api:
|
||||
token: "${ORACLE_SYNC_TOKEN}"
|
||||
|
||||
# 人物识别服务
|
||||
# 人物识别服务(2026-08-22 停用):已被闭集识别 person_identifier 取代
|
||||
# (汤圆/媳妇走性别年龄规则免费识别,爷爷/爸爸走视觉大模型比对参考图,见
|
||||
# person_identifier 配置块)。原 LLM 合并逻辑不可靠(用户原话"现在的识别全是
|
||||
# 错的"),继续跑只会用不可靠的猜测持续覆盖 people 表的 canonical_name,跟新
|
||||
# 系统的结果打架——停用,不删除代码(关键冲突检测逻辑 _features_conflict 仍
|
||||
# 保留供参考/未来复用)。
|
||||
person_service:
|
||||
enabled: true
|
||||
schedule_interval_sec: 1800 # 每 30 分钟重新汇总一次人物
|
||||
model: "gemini" # 用哪个模型做人物合并(vision 模型也支持纯文本)
|
||||
enabled: false
|
||||
schedule_interval_sec: 1800
|
||||
model: "gemini"
|
||||
|
||||
# 视频处理
|
||||
video_processing:
|
||||
@@ -47,24 +52,80 @@ video_processing:
|
||||
# 降级顺序:先 gemini 整视频,失败再 nvidia 整视频;两者都失败 -> 标记 failed
|
||||
vision_order: ["gemini", "nvidia"]
|
||||
|
||||
# DSM 运动侦测预过滤(2026-08-22 新增):分析前先查一下群晖 Surveillance Station
|
||||
# 自己记录的运动侦测事件(SYNO.SurveillanceStation.EventCenter.Event,未公开文档的
|
||||
# 内部接口,参数名是下划线风格 camera_ids/start_time/end_time),这段时间窗口一条
|
||||
# 运动事件都没有就跳过云端分析(标记 done,compute_provider=skipped_no_motion),
|
||||
# 省掉长期无人时段白白消耗的 Gemini/NVIDIA 配额。
|
||||
# 账号密码走 .env(DSM_ACCOUNT/DSM_PASSWORD),不明文入库;查询失败/未配置一律
|
||||
# fail-open(照常送云端分析),不会因为这层可选优化漏检真实事件。
|
||||
# 运动事件(2026-08-22 运动事件驱动架构):
|
||||
# - NAS 端 fam-core MotionNotifier 轮询 SS 事件后,POST 推送到甲骨文
|
||||
# /api/ss/motion,落库 ss_motion_events(start_time/duration 为 Unix epoch);
|
||||
# 并定期用空 events 调接口当心跳,证明推送链路存活。
|
||||
# - video_processor 不再整段分析:整段素材按 ss_motion_events 中【已结束】的
|
||||
# 运动事件分割成运动片段(motion_segment 配置),只分析运动片段。
|
||||
dsm_motion_prefilter:
|
||||
enabled: true
|
||||
host: "192.168.50.64"
|
||||
port: 5000
|
||||
account: "${DSM_ACCOUNT}"
|
||||
password: "${DSM_PASSWORD}"
|
||||
camera_id: 2 # Surveillance Station 里 Generic_ONVIF-001 的 camera_id
|
||||
min_motion_seconds: 0 # 窗口内运动事件总时长需 ≥ 此值才算"有运动"(0=有事件就算)
|
||||
timeout_sec: 10
|
||||
max_heartbeat_age_sec: 900 # 心跳超过 15 分钟没更新 -> 判定推送链路可能已死
|
||||
|
||||
# 智能问答降级链(与视频分析独立):Gemini -> NVIDIA -> 本地 Ollama
|
||||
# 运动片段分割(运动事件驱动架构,2026-08-22 新增):
|
||||
# 整段素材视频(rclone 同步落地)按 ss_motion_events 分割成运动片段再分析。
|
||||
# 只分割已结束事件(start_time+duration 落在当前时刻附近),进行中的事件
|
||||
# 等结束后的下一轮再分割;素材保持 pending 直到窗口内事件全部结束。
|
||||
motion_segment:
|
||||
clips_dir: "/opt/fam-edge/motion_clips" # 分割产物目录
|
||||
keep_audio: true # 保留音频(pcm_alaw -> aac 64k 转码);false 则 -an 去音频
|
||||
min_duration_sec: 1 # 短于该时长的事件不分割
|
||||
unfinished_grace_sec: 10 # start_time+duration 距当前 ≤ 该秒视为"已结束"容差
|
||||
|
||||
# 磁盘空间守护(2026-08-28 新增):Oracle 磁盘曾经被写满,触发 rclone 遇到
|
||||
# IO 错误就拒绝删除的保护机制,形成"越满越删不掉"的死循环,导致新视频下载
|
||||
# 和运动片段分割全部失败。这里加一道独立于 rclone 同步之外的兜底:不管上游
|
||||
# 同步是否正常,剩余空间跌破 min_free_gb 就主动清理最旧的、已完成分割阶段
|
||||
# 的整段素材(gdrive_videos 里的原始录像,不碰 motion_clips 里的运动片段)。
|
||||
disk_guard:
|
||||
enabled: true
|
||||
check_interval_sec: 300 # 5 分钟检查一次
|
||||
min_free_gb: 10 # 剩余空间低于此值触发清理
|
||||
target_free_gb: 15 # 清理到这个水位就停(留缓冲,避免刚清完又立刻再触发)
|
||||
watch_path: "/opt/fam-edge" # 检查这个路径所在磁盘分区的剩余空间
|
||||
max_delete_per_round: 50 # 单轮最多清理几个文件,防止候选异常多时一次删太多
|
||||
|
||||
# 闭集人物识别(2026-08-22 新增,家里固定 4 人:爷爷/爸爸/媳妇/汤圆):
|
||||
# 原来靠大模型自己编的"人物A/B/C"临时 uid + 文字特征描述跨视频合并,验证下来
|
||||
# 不可靠(用户原话"现在的识别全是错的")。人脸向量方案也验证过,家庭监控这种
|
||||
# 大广角/远距离画面下同人内部相似度经常比不同人还低,此路不通。
|
||||
# 现在改成:汤圆(幼儿/儿童特征)/媳妇(唯一成年女性) 直接用 Gemini 已产出的
|
||||
# 性别/年龄字段判断,免费且验证下来接近 100% 准;爷爷/爸爸(两个成年男性,纯
|
||||
# 外观规则/人脸向量都区分不开) 改用视觉大模型"看参考图比对"——NVIDIA 实测
|
||||
# 6/6 全对且配额与主分析链路完全独立,设为优先,Gemini flash-lite(7/8) 兜底。
|
||||
# 参考图目录结构:{ref_dir}/爷爷/*.jpg、{ref_dir}/爸爸/*.jpg(已用确认过身份的
|
||||
# 历史截图种好,见 PROGRESS.md 记录)。
|
||||
person_identifier:
|
||||
enabled: true
|
||||
ref_dir: "/opt/fam-edge/data/person_refs"
|
||||
max_ref_per_person: 6
|
||||
# 连续两次分类调用之间的最小间隔(不分 provider 统一限速):正常处理新片段时
|
||||
# 调用本来就稀疏,这个主要是给历史数据批量回填用的,避免短时间内密集调用打爆配额
|
||||
min_call_interval_sec: 2
|
||||
nvidia:
|
||||
api_key: "${NVIDIA_API_KEY}"
|
||||
model_name: "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning"
|
||||
fallback_models: [] # 目前只验证过这一个能用的 NVIDIA 视觉模型,先留好扩展位
|
||||
timeout: 60
|
||||
max_retries: 3 # 每个模型对瞬时故障(429/5xx/超时)最多重试几次(含首次)
|
||||
retry_backoff_sec: 3 # 重试前基础等待秒数,指数退避(3s -> 6s -> 12s)
|
||||
gemini:
|
||||
api_key: "${GEMINI_API_KEY}"
|
||||
extra_api_keys: ["${GEMINI_API_KEY_2}", "${GEMINI_API_KEY_3}", "${GEMINI_API_KEY_4}"]
|
||||
model_name: "gemini-flash-lite-latest"
|
||||
timeout: 60
|
||||
max_retries: 2
|
||||
retry_backoff_sec: 3
|
||||
|
||||
# 智能问答(2026-08-23 抽离到独立 ai-gateway 服务,OpenAI 兼容协议):
|
||||
# fam-edge 这边只是转发客户端,问答本体的模型降级链/key 轮换/熔断都在
|
||||
# ai-gateway 自己的 config.yaml 里配置,这里只填怎么连它。
|
||||
# token 走 .env AI_GATEWAY_TOKEN,跟 ai-gateway 侧配置的值必须一致。
|
||||
ai_gateway:
|
||||
base_url: "http://127.0.0.1:5100" # 同机部署,走本地回环,不走公网
|
||||
token: "${AI_GATEWAY_TOKEN}"
|
||||
timeout: 60
|
||||
|
||||
# 视频分析模型链
|
||||
models:
|
||||
- provider: "gemini"
|
||||
role: "vision"
|
||||
@@ -126,14 +187,3 @@ models:
|
||||
threshold: 5
|
||||
cooldown: 300
|
||||
|
||||
# 本地模型:纯文本 qwen2.5:7b,仅参与智能问答兜底
|
||||
- provider: "ollama"
|
||||
role: "text"
|
||||
usage: "qa_fallback"
|
||||
enabled: true
|
||||
model_name: "qwen2.5:7b"
|
||||
base_url: "http://localhost:11434"
|
||||
timeout: 120
|
||||
num_predict: 512
|
||||
circuit_breaker:
|
||||
enabled: false
|
||||
|
||||
@@ -2,6 +2,9 @@ flask>=2.0.0
|
||||
gunicorn>=20.0.0
|
||||
requests>=2.28.0
|
||||
PyYAML>=6.0
|
||||
# 统一登录:验 auth-hub 的 id_token 签名(RS256)+ 签发会话 cookie(HS256)
|
||||
PyJWT>=2.8.0
|
||||
cryptography>=42.0.0
|
||||
# google-generativeai 和 openai 为可选依赖(代码用 requests 直接调 REST API)
|
||||
# 如需 SDK 方式调用,取消注释并在 Python 3.9+ 环境安装:
|
||||
# google-generativeai>=0.5.0
|
||||
|
||||
@@ -14,6 +14,7 @@ API-Gateway - Flask 蓝图(新架构 v3)
|
||||
一次 Gemini 调用一并产出(见 ai_orchestrator/prompts.py),frame_service 不再
|
||||
额外调用任何模型。NAS 经 core 代理读取,不在 NAS 做图像计算。
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
|
||||
from flask import Blueprint, request, jsonify, Response
|
||||
@@ -102,6 +103,70 @@ def people_correct():
|
||||
return jsonify({"status": "ok", "label": label, "canonical_name": canonical}), 200
|
||||
|
||||
|
||||
@api_bp.route('/api/oracle/identity/correct', methods=['POST'])
|
||||
def identity_correct():
|
||||
"""事件时间轴/人物管理"这个人识别错了"纠错入口(比 people/correct 粒度更细)。
|
||||
|
||||
请求: {"video_id": 123, "current_name": "爷爷", "new_name": "爸爸", "token": "..."}
|
||||
只改这一段视频里被错误识别的那个人,不影响同名字符串在其他视频里的映射——
|
||||
人物 uid 只在单次视频分析内稳定,同一个"人物A"字符串在不同视频里可能是不同
|
||||
真人,纠错必须落到 (video_id, 当前展示名) 这一粒度,不能按全局 label 改。
|
||||
写 manual 来源,受保护不会被后续自动识别覆盖回去;立即重写这段视频的展示数据。
|
||||
"""
|
||||
if not _check_token():
|
||||
return jsonify({"error": "unauthorized"}), 401
|
||||
data = request.get_json(silent=True)
|
||||
if not data:
|
||||
return jsonify({"error": "Invalid JSON"}), 400
|
||||
video_id = data.get('video_id')
|
||||
current_name = (data.get('current_name') or '').strip()
|
||||
new_name = (data.get('new_name') or '').strip()
|
||||
if not video_id or not current_name or not new_name:
|
||||
return jsonify({"error": "缺少 video_id / current_name / new_name"}), 400
|
||||
try:
|
||||
video_id = int(video_id)
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({"error": "video_id 必须是数字"}), 400
|
||||
try:
|
||||
state.get_db().correct_video_identity(video_id, current_name, new_name)
|
||||
except Exception as e:
|
||||
logger.error(f"identity_correct 异常: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
return jsonify({"status": "ok", "video_id": video_id,
|
||||
"current_name": current_name, "new_name": new_name}), 200
|
||||
|
||||
|
||||
@api_bp.route('/api/oracle/video/delete', methods=['POST'])
|
||||
def video_delete():
|
||||
"""删除视频会话(事件时间轴"删除"入口,NAS 转发)。
|
||||
|
||||
请求: {"video_id": 123, "token": "..."}
|
||||
删 Oracle 端 events + videos 行 + 磁盘上的运动片段文件;不清理对应的
|
||||
ss_motion_events 源事件(独立生命周期,见 oracle_db.delete_video 注释)。
|
||||
"""
|
||||
if not _check_token():
|
||||
return jsonify({"error": "unauthorized"}), 401
|
||||
data = request.get_json(silent=True)
|
||||
if not data:
|
||||
return jsonify({"error": "Invalid JSON"}), 400
|
||||
video_id = data.get('video_id')
|
||||
if not video_id:
|
||||
return jsonify({"error": "缺少 video_id"}), 400
|
||||
try:
|
||||
video_id = int(video_id)
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({"error": "video_id 必须是数字"}), 400
|
||||
try:
|
||||
local_path = state.get_db().delete_video(video_id)
|
||||
except Exception as e:
|
||||
logger.error(f"video_delete 异常: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
if local_path is None:
|
||||
return jsonify({"error": "视频不存在"}), 404
|
||||
state.get_db().record_activity('video', 'delete', f"video_id={video_id}")
|
||||
return jsonify({"status": "ok", "video_id": video_id}), 200
|
||||
|
||||
|
||||
@api_bp.route('/api/edge/chat/ask', methods=['POST'])
|
||||
def chat_ask():
|
||||
"""智能问答编排:Gemini → NVIDIA → 本地 Ollama(两云端都失败才用本地兜底)
|
||||
@@ -114,7 +179,10 @@ def chat_ask():
|
||||
return jsonify({"error": "缺少必填字段: prompt"}), 400
|
||||
|
||||
prompt = data['prompt']
|
||||
max_tokens = int(data.get('max_tokens', 1024))
|
||||
# 默认值从 1024 提到 3072(2026-08-23):问答链路现在优先用推理类模型
|
||||
# (NVIDIA Nemotron-3 系列),回答前会先输出一段思考过程再给最终答案,
|
||||
# 1024 经常在思考阶段就被截断,永远看不到真正的回答。
|
||||
max_tokens = int(data.get('max_tokens', 3072))
|
||||
|
||||
answer, provider = get_qa().run_qa(prompt, max_tokens=max_tokens)
|
||||
if answer is None:
|
||||
@@ -125,6 +193,34 @@ def chat_ask():
|
||||
return jsonify({"answer": answer, "provider": provider}), 200
|
||||
|
||||
|
||||
@api_bp.route('/api/edge/chat/ask/stream', methods=['POST'])
|
||||
def chat_ask_stream():
|
||||
"""智能问答编排(流式版):SSE 逐块推送,边生成边显示,不用等全量回答。
|
||||
|
||||
请求同 /api/edge/chat/ask。响应 Content-Type: text/event-stream,
|
||||
每行 `data: <json>\\n\\n`,json 结构见 qa.QAOrchestrator.run_qa_stream 注释。
|
||||
"""
|
||||
data = request.get_json(silent=True)
|
||||
if not data or 'prompt' not in data:
|
||||
return jsonify({"error": "缺少必填字段: prompt"}), 400
|
||||
|
||||
prompt = data['prompt']
|
||||
# 默认值从 1024 提到 3072(2026-08-23):问答链路现在优先用推理类模型
|
||||
# (NVIDIA Nemotron-3 系列),回答前会先输出一段思考过程再给最终答案,
|
||||
# 1024 经常在思考阶段就被截断,永远看不到真正的回答。
|
||||
max_tokens = int(data.get('max_tokens', 3072))
|
||||
|
||||
def generate():
|
||||
for event in get_qa().run_qa_stream(prompt, max_tokens=max_tokens):
|
||||
yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n"
|
||||
|
||||
# mimetype 显式带 charset=utf-8:响应体本身一直是 UTF-8,但不声明的话下游
|
||||
# (fam-core 转发这一跳、或任何用 requests 消费这个流的客户端)会自己猜
|
||||
# 编码,猜错就是中文乱码——跟 gemini_adapter.py chat_stream() 那个坑同源。
|
||||
return Response(generate(), mimetype='text/event-stream; charset=utf-8',
|
||||
headers={'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'})
|
||||
|
||||
|
||||
@api_bp.route('/api/oracle/activity', methods=['GET'])
|
||||
def activity():
|
||||
"""实时服务状态 + 最近活动流(token 校验)。
|
||||
@@ -153,9 +249,39 @@ def activity():
|
||||
model_calls = db._conn.execute(
|
||||
"SELECT id, provider, model, filename, started_at, duration_sec, "
|
||||
"success, error FROM model_calls ORDER BY id DESC LIMIT 5").fetchall()
|
||||
# 运动片段分割状态(服务状态页「视频分割」卡)
|
||||
segment = db.get_segment_status()
|
||||
try:
|
||||
import os
|
||||
from ..config_loader import load_config
|
||||
seg_dir = load_config().get('motion_segment', {}).get(
|
||||
'clips_dir', '/opt/fam-edge/motion_clips')
|
||||
segment['clips_files'] = len(os.listdir(seg_dir)) if os.path.isdir(seg_dir) else 0
|
||||
except Exception:
|
||||
segment['clips_files'] = 0
|
||||
# 事件↔片段一致性对账(数量可验证)
|
||||
segment['consistency'] = db.get_segment_consistency()
|
||||
# 磁盘空间实时用量 + DiskGuard 最近一次清理动作(2026-08-28 新增)
|
||||
disk_info = {"free_gb": None, "last_activity": _last_activity('disk_guard')}
|
||||
try:
|
||||
import shutil as _shutil
|
||||
from ..config_loader import load_config as _load_config
|
||||
watch_path = _load_config().get('disk_guard', {}).get('watch_path', '/opt/fam-edge')
|
||||
disk_info["free_gb"] = round(_shutil.disk_usage(watch_path).free / (1024 ** 3), 1)
|
||||
except Exception as e:
|
||||
logger.warning(f"磁盘空间查询异常: {e}")
|
||||
# NAS 推送链路(2026-09-13 起 NAS 上只剩 fam-notifier 这一个服务,
|
||||
# 服务状态页的「NAS 运动推送」卡片读这里;心跳由它定期空 POST 刷新)
|
||||
motion = {
|
||||
"heartbeat_age_sec": db.get_motion_heartbeat_age_sec(),
|
||||
"recent": db.get_recent_motion_events(5),
|
||||
}
|
||||
return jsonify({
|
||||
"queue": q_status,
|
||||
"db": db.get_queue_status(),
|
||||
"motion": motion,
|
||||
"segment": segment,
|
||||
"disk": disk_info,
|
||||
"rclone": _last_activity('rclone'),
|
||||
"person": _last_activity('person'),
|
||||
"model_calls": [dict(m) for m in model_calls],
|
||||
@@ -197,6 +323,40 @@ def oracle_avatar():
|
||||
return Response(data, mimetype='image/jpeg')
|
||||
|
||||
|
||||
@api_bp.route('/api/ss/motion', methods=['POST'])
|
||||
def ss_motion():
|
||||
"""接收 NAS 推送的运动侦测事件(单向:NAS -> Oracle)。
|
||||
|
||||
请求体: {"token": "...", "events": [
|
||||
{"event_id": 25349, "camera_id": 2, "event_type": 10,
|
||||
"start_time": 1787318023, "duration": 149, "thumbnail_url": "107471,12075"}
|
||||
]}
|
||||
start_time/duration 为 Unix epoch(与 SS 同源,时区无关)。events 可以是空数组
|
||||
——NAS 侧也会定期用空 events 单纯调一次这个接口当心跳,证明推送链路还活着
|
||||
(鉴权通过就刷新心跳,不要求 stored>0),供 has_motion_in_range_local() 判断
|
||||
"无运动"结论是否可信。
|
||||
落库 ss_motion_events(按 event_id 幂等),供 video_processor 本地预过滤使用。
|
||||
"""
|
||||
if not _check_token():
|
||||
return jsonify({"error": "unauthorized"}), 401
|
||||
data = request.get_json(silent=True)
|
||||
if not data or 'events' not in data:
|
||||
return jsonify({"error": "缺少 events"}), 400
|
||||
events = data.get('events') or []
|
||||
if not isinstance(events, list):
|
||||
return jsonify({"error": "events 必须是数组"}), 400
|
||||
try:
|
||||
stored = state.get_db().record_motion_events(events)
|
||||
state.get_db().record_motion_heartbeat()
|
||||
except Exception as e:
|
||||
logger.error(f"ss_motion 落库异常: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
if stored:
|
||||
state.get_db().record_activity(
|
||||
'motion', 'push', f"接收 NAS 运动事件 {stored} 条")
|
||||
return jsonify({"status": "ok", "received": len(events), "stored": stored}), 200
|
||||
|
||||
|
||||
@api_bp.route('/health', methods=['GET'])
|
||||
def health():
|
||||
"""健康检查:DB 连通性 + producer/consumer 线程存活状态。
|
||||
|
||||
@@ -5,6 +5,9 @@ FAM-Edge 主应用 - Flask 单进程(新架构 v2.1)
|
||||
- API-Gateway(同步拉取 / 命名校正 / 智能问答)
|
||||
- VideoQueue(生产-消费队列:同步落地新视频入队,独立消费者云端分析,模型超时 ×2)
|
||||
- PersonService(人物汇总合并,定时)
|
||||
- DiskGuard(磁盘空间守护,定时检查,剩余空间过低时清理最旧的已完成素材)
|
||||
- Auth(统一登录:/login + OIDC 回调 + 给 Caddy forward_auth 用的会话校验,
|
||||
2026-09-12 从 NAS fam-core 迁入,原因见 auth.py 开头)
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
@@ -15,14 +18,17 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from .config_loader import load_config
|
||||
from .logger import setup_logger
|
||||
from .api_gateway.api_gateway import api_bp
|
||||
from .auth import auth_bp
|
||||
from . import state
|
||||
from .video_queue import VideoQueue
|
||||
from .person_service import PersonService
|
||||
from .disk_guard import DiskGuard
|
||||
|
||||
logger = setup_logger('fam-edge.app')
|
||||
|
||||
app = Flask(__name__)
|
||||
app.register_blueprint(api_bp)
|
||||
app.register_blueprint(auth_bp)
|
||||
|
||||
|
||||
@app.route('/', methods=['GET'])
|
||||
@@ -31,9 +37,10 @@ def root():
|
||||
"mode": "drive-sync + whole-video analysis (producer-consumer queue)"}), 200
|
||||
|
||||
|
||||
# 启动生产-消费队列 + 人物服务
|
||||
# 启动生产-消费队列 + 人物服务 + 磁盘空间守护
|
||||
_queue = None
|
||||
_person = None
|
||||
_disk_guard = None
|
||||
try:
|
||||
db = state.get_db()
|
||||
_queue = VideoQueue(db)
|
||||
@@ -44,6 +51,9 @@ try:
|
||||
_person = PersonService(db)
|
||||
_person.start()
|
||||
logger.info("PersonService 已启动")
|
||||
|
||||
_disk_guard = DiskGuard(db)
|
||||
_disk_guard.start()
|
||||
except Exception as e:
|
||||
logger.error(f"后台服务启动失败: {e}", exc_info=True)
|
||||
|
||||
|
||||
285
fam-edge/src/fam_edge/auth.py
Normal file
285
fam-edge/src/fam_edge/auth.py
Normal file
@@ -0,0 +1,285 @@
|
||||
"""
|
||||
Auth - 摄像头系统统一登录(2026-09-12 从 NAS fam-core 迁到甲骨文 fam-edge)
|
||||
|
||||
背景:登录流程原先跑在 NAS 的 fam-core 里,NAS 一挂或 frp 隧道一断,
|
||||
`smart-camera.zichuan.xyz/login` 直接 502——连登录页都进不去。登录是入口,
|
||||
不该依赖家里那台机器,所以整体搬到甲骨文:前端静态文件和 auth-hub 本来就在这台,
|
||||
搬完之后 NAS 只剩纯数据接口。
|
||||
|
||||
跟旧实现(fam-core/auth.py,已删除)的三处关键差异:
|
||||
|
||||
1. **服务端调用 auth-hub 走本机**:换 token 和拉 JWKS 用 `AUTH_HUB_INTERNAL_BASE`
|
||||
(默认 http://127.0.0.1:5300),不再走公网 TLS。旧链路是 NAS 跨公网访问
|
||||
https://auth.zichuan.xyz,踩过两个坑:`jwt.PyJWKClient` 用 urllib + 系统 CA
|
||||
(不像 requests 自带 certifi),群晖上很容易 CERTIFICATE_VERIFY_FAILED;
|
||||
以及两台机器时钟偏差会让 id_token 的 iat 看起来"来自未来"。
|
||||
但 `iss` 校验和浏览器跳转仍然用公网 `AUTH_HUB_ISSUER`——id_token 里的 iss
|
||||
是 auth-hub 自己配的公网地址,浏览器也只能跳公网地址。
|
||||
|
||||
2. **登录态改成无状态签名 cookie**:HS256 签发,密钥 `FAM_SESSION_SECRET`。
|
||||
旧实现是进程内 token 表,服务一重启所有人被踢下线;fam-edge 是视频分析进程,
|
||||
重启比 fam-core 更频繁,进程内会话在这里完全不成立。
|
||||
|
||||
3. **回调失败渲染错误页,不再 302 回 /login**:旧实现每个失败分支都跳 /login,
|
||||
而 auth-hub 侧只要还有会话,/authorize 会立刻再签发一个 code 跳回来,
|
||||
两边对跳成死循环——浏览器只报"重定向次数过多",既看不到登录页也看不到原因。
|
||||
|
||||
拦截不在本模块做:Caddy 用 forward_auth 打到 `/api/auth/verify`,
|
||||
校验通过才把 `/api/*` 反代到 NAS 的 fam-core。
|
||||
"""
|
||||
import os
|
||||
import secrets
|
||||
import time
|
||||
from base64 import urlsafe_b64encode
|
||||
from hashlib import sha256
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import jwt
|
||||
import requests
|
||||
from flask import Blueprint, Response, jsonify, make_response, redirect, request
|
||||
|
||||
# 导入即加载 /opt/fam-edge/.env(config_loader 模块级 _load_env_file),
|
||||
# 下面所有 os.environ.get 才读得到 AUTH_HUB_* / FAM_SESSION_SECRET。
|
||||
from . import config_loader # noqa: F401
|
||||
from .logger import setup_logger
|
||||
|
||||
logger = setup_logger('fam-edge.auth')
|
||||
|
||||
auth_bp = Blueprint('auth', __name__)
|
||||
|
||||
COOKIE_NAME = 'fam_session'
|
||||
_SESSION_TTL = 2 * 3600 # 登录态有效期 2 小时
|
||||
_PENDING_TTL = 10 * 60 # /login -> /api/auth/callback 之间的等待上限
|
||||
_LEEWAY = 60 # 验 id_token 时容忍的跨机器时钟偏差(秒)
|
||||
|
||||
_pending = {} # state -> {verifier, expires}(进程内,PKCE 用)
|
||||
_warned_unconfigured = False # 只在第一次拒绝登录时打一条警告,别刷屏
|
||||
_jwks_client = None # jwt.PyJWKClient 单例,内建 JWKS 缓存
|
||||
|
||||
|
||||
def _config():
|
||||
"""接入参数全部来自环境变量(/opt/fam-edge/.env)。"""
|
||||
issuer = os.environ.get('AUTH_HUB_ISSUER', '').rstrip('/')
|
||||
return {
|
||||
'issuer': issuer,
|
||||
# 服务端直连 auth-hub 的地址;没配就退回公网 issuer(本地开发用)
|
||||
'internal_base': (os.environ.get('AUTH_HUB_INTERNAL_BASE', '') or issuer).rstrip('/'),
|
||||
'client_id': os.environ.get('AUTH_HUB_CLIENT_ID', ''),
|
||||
'client_secret': os.environ.get('AUTH_HUB_CLIENT_SECRET', ''),
|
||||
'redirect_uri': os.environ.get('AUTH_HUB_REDIRECT_URI', ''),
|
||||
'session_secret': os.environ.get('FAM_SESSION_SECRET', ''),
|
||||
}
|
||||
|
||||
|
||||
def _require_config():
|
||||
"""五个变量缺任何一个都拒绝登录(fail closed)。返回配置 dict 或 None。
|
||||
|
||||
本服务监听 0.0.0.0,没有"配置不全就退回某种默认放行"这种兜底。
|
||||
"""
|
||||
global _warned_unconfigured
|
||||
cfg = _config()
|
||||
if not all([cfg['issuer'], cfg['client_id'], cfg['client_secret'],
|
||||
cfg['redirect_uri'], cfg['session_secret']]):
|
||||
if not _warned_unconfigured:
|
||||
logger.error(
|
||||
"AUTH_HUB_ISSUER/CLIENT_ID/CLIENT_SECRET/REDIRECT_URI 或 "
|
||||
"FAM_SESSION_SECRET 未配置齐全,拒绝所有登录——"
|
||||
"请在 /opt/fam-edge/.env 里补齐后 systemctl restart fam-edge")
|
||||
_warned_unconfigured = True
|
||||
return None
|
||||
return cfg
|
||||
|
||||
|
||||
def _get_jwks_client(jwks_uri):
|
||||
global _jwks_client
|
||||
if _jwks_client is None or _jwks_client.uri != jwks_uri:
|
||||
_jwks_client = jwt.PyJWKClient(jwks_uri)
|
||||
return _jwks_client
|
||||
|
||||
|
||||
def _cleanup_pending():
|
||||
now = time.time()
|
||||
for state in [s for s, v in _pending.items() if v['expires'] < now]:
|
||||
_pending.pop(state, None)
|
||||
|
||||
|
||||
def _is_https():
|
||||
"""Caddy 终止 TLS 后回源是明文 HTTP,request.is_secure 恒为 False,
|
||||
所以 cookie 的 Secure 标志要看 X-Forwarded-Proto。"""
|
||||
return request.headers.get('X-Forwarded-Proto', '') == 'https' or request.is_secure
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 会话(无状态签名 cookie)
|
||||
# ---------------------------------------------------------------------------
|
||||
def _issue_session(secret, sub, username):
|
||||
now = int(time.time())
|
||||
return jwt.encode({'sub': sub, 'username': username,
|
||||
'iat': now, 'exp': now + _SESSION_TTL},
|
||||
secret, algorithm='HS256')
|
||||
|
||||
|
||||
def current_user():
|
||||
"""返回 cookie 里的用户信息(dict),未登录/过期/签名不对返回 None。"""
|
||||
cfg = _config()
|
||||
if not cfg['session_secret']:
|
||||
return None
|
||||
token = request.cookies.get(COOKIE_NAME, '')
|
||||
if not token:
|
||||
return None
|
||||
try:
|
||||
# 这里刻意不给 leeway:cookie 是本进程自签自验的,没有跨机器时钟偏差问题,
|
||||
# 加了只会让每个会话白白多活 60 秒。_LEEWAY 只用于 auth-hub 签发的 id_token。
|
||||
return jwt.decode(token, cfg['session_secret'], algorithms=['HS256'])
|
||||
except jwt.PyJWTError:
|
||||
return None
|
||||
|
||||
|
||||
def is_authed():
|
||||
return current_user() is not None
|
||||
|
||||
|
||||
def _error_page(reason, status=502):
|
||||
"""回调失败时给一个看得懂的页面。
|
||||
|
||||
这里刻意不做自动跳转:auth-hub 有会话时会立刻再发一个 code 回来,
|
||||
自动跳转等于把用户关进死循环。让用户自己点"重新登录",最多再失败一次。
|
||||
"""
|
||||
html = (
|
||||
'<!doctype html><html lang="zh-CN"><head><meta charset="utf-8">'
|
||||
'<meta name="viewport" content="width=device-width,initial-scale=1">'
|
||||
'<title>登录失败</title></head>'
|
||||
'<body style="font-family:system-ui,-apple-system,sans-serif;background:#0b0e14;'
|
||||
'color:#e6ebf2;display:flex;min-height:100vh;align-items:center;justify-content:center;margin:0">'
|
||||
'<div style="max-width:34rem;padding:2rem">'
|
||||
'<h1 style="font-size:1.25rem;margin:0 0 .75rem">登录没能完成</h1>'
|
||||
'<p style="color:#9aa7b8;line-height:1.7;margin:0 0 1.5rem">原因:{reason}</p>'
|
||||
'<a href="/login" style="display:inline-block;background:#5b8cff;color:#fff;'
|
||||
'text-decoration:none;padding:.6rem 1.2rem;border-radius:.6rem">重新登录</a>'
|
||||
'</div></body></html>'
|
||||
).format(reason=reason)
|
||||
return Response(html, status=status, mimetype='text/html; charset=utf-8')
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 端点
|
||||
# ---------------------------------------------------------------------------
|
||||
@auth_bp.route('/login', methods=['GET'])
|
||||
def login():
|
||||
"""不渲染登录表单,直接跳 auth-hub 走 Authorization Code + PKCE。"""
|
||||
if is_authed():
|
||||
return redirect('/')
|
||||
|
||||
cfg = _require_config()
|
||||
if not cfg:
|
||||
return _error_page('本服务的登录参数没配齐(管理员看 fam-edge 日志)', 503)
|
||||
|
||||
_cleanup_pending()
|
||||
verifier = secrets.token_urlsafe(48)
|
||||
challenge = urlsafe_b64encode(
|
||||
sha256(verifier.encode('ascii')).digest()).rstrip(b'=').decode('ascii')
|
||||
state = secrets.token_hex(24)
|
||||
_pending[state] = {'verifier': verifier, 'expires': time.time() + _PENDING_TTL}
|
||||
|
||||
params = {
|
||||
'response_type': 'code',
|
||||
'client_id': cfg['client_id'],
|
||||
'redirect_uri': cfg['redirect_uri'],
|
||||
'scope': 'openid profile',
|
||||
'state': state,
|
||||
'code_challenge': challenge,
|
||||
'code_challenge_method': 'S256',
|
||||
}
|
||||
# 浏览器要跳的是公网 issuer,不是 internal_base
|
||||
return redirect("{}/authorize?{}".format(cfg['issuer'], urlencode(params)))
|
||||
|
||||
|
||||
@auth_bp.route('/api/auth/callback', methods=['GET'])
|
||||
def callback():
|
||||
cfg = _require_config()
|
||||
if not cfg:
|
||||
return _error_page('本服务的登录参数没配齐(管理员看 fam-edge 日志)', 503)
|
||||
|
||||
if request.args.get('error'):
|
||||
logger.warning("auth-hub 拒绝授权: {}".format(request.args.get('error')))
|
||||
return _error_page('auth-hub 拒绝了这次授权:{}'.format(request.args.get('error')), 403)
|
||||
|
||||
state = request.args.get('state', '')
|
||||
code = request.args.get('code', '')
|
||||
pending = _pending.pop(state, None)
|
||||
if not pending or pending['expires'] < time.time() or not code:
|
||||
logger.warning("回调 state 缺失/过期/重放,拒绝")
|
||||
return _error_page('这次登录请求已过期或被重复使用,请重新登录', 400)
|
||||
|
||||
try:
|
||||
resp = requests.post(
|
||||
"{}/token".format(cfg['internal_base']),
|
||||
data={
|
||||
'grant_type': 'authorization_code',
|
||||
'code': code,
|
||||
'redirect_uri': cfg['redirect_uri'],
|
||||
'client_id': cfg['client_id'],
|
||||
'client_secret': cfg['client_secret'],
|
||||
'code_verifier': pending['verifier'],
|
||||
}, timeout=(5, 15))
|
||||
except requests.RequestException as e:
|
||||
logger.error("auth-hub /token 请求失败: {}".format(e))
|
||||
return _error_page('连不上 auth-hub 的 /token({})'.format(e))
|
||||
|
||||
if resp.status_code != 200:
|
||||
logger.warning("auth-hub /token 拒绝: {} {}".format(resp.status_code, resp.text[:200]))
|
||||
return _error_page('auth-hub 拒绝换发 token(HTTP {})'.format(resp.status_code))
|
||||
|
||||
id_token = (resp.json() or {}).get('id_token', '')
|
||||
try:
|
||||
jwks_client = _get_jwks_client(
|
||||
"{}/.well-known/jwks.json".format(cfg['internal_base']))
|
||||
signing_key = jwks_client.get_signing_key_from_jwt(id_token)
|
||||
claims = jwt.decode(id_token, signing_key.key, algorithms=['RS256'],
|
||||
audience=cfg['client_id'], issuer=cfg['issuer'],
|
||||
leeway=_LEEWAY)
|
||||
except jwt.PyJWTError as e:
|
||||
logger.warning("id_token 验签/校验失败: {}".format(e))
|
||||
return _error_page('id_token 校验失败({})'.format(e))
|
||||
|
||||
username = claims.get('preferred_username', '')
|
||||
token = _issue_session(cfg['session_secret'], claims.get('sub', ''), username)
|
||||
resp2 = make_response(redirect('/'))
|
||||
resp2.set_cookie(COOKIE_NAME, token, max_age=_SESSION_TTL, httponly=True,
|
||||
samesite='Lax', secure=_is_https(), path='/')
|
||||
logger.info("登录成功 username={}".format(username))
|
||||
return resp2
|
||||
|
||||
|
||||
@auth_bp.route('/api/logout', methods=['POST'])
|
||||
def logout():
|
||||
"""只清本地 cookie,不动 auth-hub 上的登录态(那是全站 SSO 会话)。
|
||||
|
||||
cookie 是无状态签名的,服务端没法单独作废某一张——真要立刻全员下线,
|
||||
换掉 FAM_SESSION_SECRET 重启即可。
|
||||
"""
|
||||
resp = make_response(jsonify({"ok": True}))
|
||||
resp.delete_cookie(COOKIE_NAME, path='/')
|
||||
return resp
|
||||
|
||||
|
||||
@auth_bp.route('/api/auth/check', methods=['GET'])
|
||||
def check():
|
||||
user = current_user()
|
||||
return jsonify({"authed": user is not None,
|
||||
"username": (user or {}).get('username', '')})
|
||||
|
||||
|
||||
@auth_bp.route('/api/auth/verify', methods=['GET', 'POST', 'PUT', 'DELETE', 'PATCH'])
|
||||
def verify():
|
||||
"""给 Caddy forward_auth 用:2xx 放行,401 拦截。
|
||||
|
||||
Caddy 会把原始请求的 cookie 一起带过来,401 的响应体会原样回给浏览器,
|
||||
前端 api.js 看到 401 就会自动跳 /login。
|
||||
"""
|
||||
user = current_user()
|
||||
if not user:
|
||||
return jsonify({"error": "未登录", "code": 401}), 401
|
||||
resp = make_response('', 204)
|
||||
resp.headers['X-Auth-User'] = user.get('username', '')
|
||||
return resp
|
||||
114
fam-edge/src/fam_edge/disk_guard.py
Normal file
114
fam-edge/src/fam_edge/disk_guard.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""
|
||||
DiskGuard - 磁盘空间守护(2026-08-28 新增)
|
||||
|
||||
背景:Oracle 机器磁盘曾经被写满(gdrive_videos 持续下载新素材、旧文件迟迟
|
||||
没被清理),触发了 rclone 的一个安全机制——同步过程中一旦遇到 IO 错误(写
|
||||
不进去)就整体拒绝执行删除,导致"越满越删不掉,越删不掉越满"的死循环,
|
||||
最终连新视频都下载不了。这个模块是一道独立于 rclone 同步逻辑之外的兜底:
|
||||
不管上游同步是否正常,只要本机磁盘剩余空间跌破阈值,就主动清理最旧的、已
|
||||
经处理完成的整段素材文件,把空间抢回来。
|
||||
|
||||
清理对象:仅限"整段素材"(gdrive_videos 里落地的原始录像文件,filename
|
||||
不是 motion_ 前缀)且 status='done'(已完成分割阶段,不会再被 Video-Queue
|
||||
重新捡起)——不碰运动片段(motion_clips 里的文件,是独立的分析产物,事件
|
||||
时间轴展示、人物头像裁剪都依赖它,删了会导致图片丢失)、不碰还在
|
||||
pending/processing 中的素材(避免删掉还没来得及处理的数据)。
|
||||
"""
|
||||
import shutil
|
||||
import threading
|
||||
import time
|
||||
import os
|
||||
|
||||
from .logger import setup_logger
|
||||
from .config_loader import load_config
|
||||
|
||||
logger = setup_logger('fam-edge.disk_guard')
|
||||
|
||||
|
||||
class DiskGuard:
|
||||
def __init__(self, db):
|
||||
self.db = db
|
||||
cfg = load_config().get('disk_guard', {})
|
||||
self.enabled = bool(cfg.get('enabled', True))
|
||||
self.check_interval_sec = int(cfg.get('check_interval_sec', 300))
|
||||
self.min_free_gb = float(cfg.get('min_free_gb', 10))
|
||||
# 清理到这个水位就停:留出缓冲,避免刚清理完又立刻因为新文件写入
|
||||
# 跌破阈值、频繁触发清理循环
|
||||
self.target_free_gb = float(cfg.get('target_free_gb', 15))
|
||||
self.watch_path = cfg.get('watch_path', '/opt/fam-edge')
|
||||
# 单轮最多清理几个文件:防止候选异常多时一次性删太多,先清一部分
|
||||
# 观察效果,下一轮检查再继续(check_interval_sec 后很快就会再跑一次)
|
||||
self.max_delete_per_round = int(cfg.get('max_delete_per_round', 50))
|
||||
self._running = False
|
||||
self._thread = None
|
||||
|
||||
def start(self):
|
||||
if not self.enabled:
|
||||
logger.info("DiskGuard 未启用")
|
||||
return
|
||||
self._running = True
|
||||
self._thread = threading.Thread(target=self._run, daemon=True, name='disk-guard')
|
||||
self._thread.start()
|
||||
logger.info(
|
||||
f"DiskGuard 已启动(阈值 {self.min_free_gb}GB,"
|
||||
f"目标水位 {self.target_free_gb}GB,检查间隔 {self.check_interval_sec}s)")
|
||||
|
||||
def stop(self):
|
||||
self._running = False
|
||||
if self._thread:
|
||||
self._thread.join(timeout=5)
|
||||
|
||||
def is_alive(self) -> bool:
|
||||
return self._thread is not None and self._thread.is_alive()
|
||||
|
||||
def _free_gb(self) -> float:
|
||||
return shutil.disk_usage(self.watch_path).free / (1024 ** 3)
|
||||
|
||||
def _run(self):
|
||||
while self._running:
|
||||
try:
|
||||
self.check_once()
|
||||
except Exception as e:
|
||||
logger.error(f"DiskGuard 检查异常: {e}", exc_info=True)
|
||||
for _ in range(self.check_interval_sec):
|
||||
if not self._running:
|
||||
return
|
||||
time.sleep(1)
|
||||
|
||||
def check_once(self):
|
||||
"""检查一次磁盘空间,不足则清理最旧的已完成素材直到恢复到目标水位。
|
||||
|
||||
供后台循环调用,也可单独调用做一次性检查(比如手动触发/测试)。
|
||||
"""
|
||||
free_gb = self._free_gb()
|
||||
if free_gb >= self.min_free_gb:
|
||||
return
|
||||
logger.warning(f"磁盘剩余 {free_gb:.1f}GB 低于阈值 {self.min_free_gb}GB,开始清理旧素材")
|
||||
self.db.record_activity(
|
||||
'disk_guard', 'low_space',
|
||||
f"剩余 {free_gb:.1f}GB 低于阈值 {self.min_free_gb}GB,开始清理")
|
||||
|
||||
deleted = 0
|
||||
freed_bytes = 0
|
||||
while deleted < self.max_delete_per_round and self._free_gb() < self.target_free_gb:
|
||||
candidate = self.db.get_oldest_purgeable_material()
|
||||
if not candidate:
|
||||
logger.warning("磁盘空间仍然紧张,但已经没有可清理的素材了")
|
||||
self.db.record_activity(
|
||||
'disk_guard', 'no_candidate',
|
||||
f"剩余 {self._free_gb():.1f}GB 仍不足,且没有可清理的素材")
|
||||
break
|
||||
video_id, local_path = candidate['id'], candidate.get('local_path')
|
||||
size = os.path.getsize(local_path) if local_path and os.path.isfile(local_path) else 0
|
||||
self.db.delete_video(video_id)
|
||||
deleted += 1
|
||||
freed_bytes += size
|
||||
logger.info(f"DiskGuard 清理素材 video_id={video_id}(约 {size/1024/1024:.0f}MB)")
|
||||
|
||||
if deleted:
|
||||
free_gb = self._free_gb()
|
||||
self.db.record_activity(
|
||||
'disk_guard', 'cleaned',
|
||||
f"清理 {deleted} 个素材,释放约 {freed_bytes/1024**3:.1f}GB,"
|
||||
f"当前剩余 {free_gb:.1f}GB")
|
||||
logger.info(f"DiskGuard 本轮清理完成:{deleted} 个文件,当前剩余 {free_gb:.1f}GB")
|
||||
@@ -1,5 +1,15 @@
|
||||
"""
|
||||
DsmMotionClient - 查询群晖 Surveillance Station 的运动侦测事件(预过滤用)
|
||||
[已弃用 / DEPRECATED] 本模块自 2026-08-22 起不再被调用。
|
||||
|
||||
架构约束:甲骨文 FAM-Edge 不得反向访问 NAS。原 video_processor 在此处主动查询
|
||||
NAS 的 Surveillance Station(甲骨文 -> NAS),违反该约束,已停用。
|
||||
|
||||
替代方案:由 NAS 端 fam-core 的 MotionNotifier 轮询/接收 SS 事件后,主动 POST
|
||||
推送到甲骨文的 /api/ss/motion,落库 ss_motion_events;video_processor 改用
|
||||
db.has_motion_in_range_local() 做本地预过滤。本文件保留仅作参考,请勿再实例化。
|
||||
|
||||
---
|
||||
DsmMotionClient(旧实现,仅作历史参考) - 查询群晖 Surveillance Station 的运动侦测事件(预过滤用)
|
||||
|
||||
背景: fam-edge 原来不管这段 30 分钟录像里有没有人走动,一律整段送云端 VLM 分析,
|
||||
配额/耗时都花在长期无人的空转时段上。DSM 的 Surveillance Station 用
|
||||
|
||||
@@ -91,7 +91,10 @@ def extract_frame(db, video_id: int, ts: str, width: int = FRAME_W) -> bytes:
|
||||
offset = 0.0
|
||||
|
||||
_ensure_dir()
|
||||
cache = os.path.join(CACHE_DIR, f"frame_{video_id}_{int(offset)}.jpg")
|
||||
# 缓存 key 必须带 width:同一 (video_id, offset) 不同调用方可能要不同分辨率
|
||||
# (时间轴缩略图 400px / 头像 600px / 人物识别裁人脸要接近原始分辨率 2880px),
|
||||
# 不带 width 会导致后来的高分辨率请求悄悄拿到早先缓存的低分辨率帧。
|
||||
cache = os.path.join(CACHE_DIR, f"frame_{video_id}_{int(offset)}_{width}.jpg")
|
||||
if os.path.isfile(cache) and os.path.getsize(cache) > 0:
|
||||
with open(cache, 'rb') as f:
|
||||
return f.read()
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
"""模型适配器包"""
|
||||
from .base_adapter import BaseModelAdapter
|
||||
from .circuit_breaker import CircuitBreaker
|
||||
from .ollama_adapter import OllamaAdapter
|
||||
from .gemini_adapter import GeminiAdapter
|
||||
from .adapter_factory import build_adapter, build_adapters, register_adapter
|
||||
|
||||
__all__ = [
|
||||
"BaseModelAdapter",
|
||||
"CircuitBreaker",
|
||||
"OllamaAdapter",
|
||||
"GeminiAdapter",
|
||||
"build_adapter",
|
||||
"build_adapters",
|
||||
|
||||
@@ -9,15 +9,16 @@
|
||||
from typing import List
|
||||
|
||||
from .base_adapter import BaseModelAdapter
|
||||
from .ollama_adapter import OllamaAdapter
|
||||
from .gemini_adapter import GeminiAdapter
|
||||
from .nvidia_adapter import NvidiaVisionAdapter
|
||||
from ..logger import setup_logger
|
||||
|
||||
logger = setup_logger('fam-edge.adapter_factory')
|
||||
|
||||
# ollama 已于 2026-08-23 移除:本地模型只在问答链路里当兜底用,问答已经整个
|
||||
# 抽离到独立的 ai-gateway 服务(含它自己的 ollama 适配器),fam-edge 这边
|
||||
# 只剩视频分析(vision 角色),不再需要注册纯文本本地模型。
|
||||
_ADAPTER_REGISTRY = {
|
||||
"ollama": OllamaAdapter,
|
||||
"gemini": GeminiAdapter,
|
||||
"nvidia": NvidiaVisionAdapter,
|
||||
}
|
||||
|
||||
@@ -37,7 +37,8 @@ class BaseModelAdapter(ABC):
|
||||
def __init__(self, provider_name: str, config: dict):
|
||||
self.provider_name = provider_name # 如 "ollama", "gemini"
|
||||
self.config = config
|
||||
# 角色: vision=视觉分析, text=智能问答兜底(本地模型); 默认 vision
|
||||
# 角色: vision=视觉分析, text=智能问答(问答链路已抽离到 ai-gateway,
|
||||
# 这里目前只有 vision 在用;text 角色留给尚未清理的旧 person_service)
|
||||
self.role = config.get('role', 'vision')
|
||||
# 模型调用统计回调(由编排层注入):
|
||||
# hook(provider, model, started_at, duration_sec, success, error)
|
||||
@@ -80,6 +81,15 @@ class BaseModelAdapter(ABC):
|
||||
raise NotImplementedError(
|
||||
f"{self.provider_name} 适配器未实现 chat()(不参与智能问答)")
|
||||
|
||||
def chat_stream(self, prompt: str, max_tokens: int = 512):
|
||||
"""流式问答:逐块 yield 文本增量。默认实现退化为"等 chat() 整段返回后
|
||||
一次性当一个大 chunk 吐出"——子类没有真流式 API(或懒得接)时这样也能
|
||||
用,只是没有逐字显示的效果;Gemini 有原生 SSE 流式接口,重写了这个方法。
|
||||
"""
|
||||
result = self.chat(prompt, max_tokens=max_tokens)
|
||||
if result:
|
||||
yield result
|
||||
|
||||
@abstractmethod
|
||||
def get_timeout(self) -> int:
|
||||
"""该模型的调用超时秒数"""
|
||||
|
||||
@@ -3,8 +3,12 @@ GeminiAdapter - Google Gemini 云端 VLM 适配器
|
||||
|
||||
provider_name = "gemini"
|
||||
模型: gemini-flash-latest
|
||||
角色: vision (整视频直出结构化 JSON) + 智能问答
|
||||
角色: vision (整视频直出结构化 JSON)
|
||||
健康检查: GET /v1beta/models?key=...
|
||||
|
||||
问答(chat/chat_stream)已于 2026-08-23 抽离到独立的 ai-gateway 服务
|
||||
(跟视频分析业务无关,是通用能力),这里不再实现,fam-edge 自己的问答请求
|
||||
转发给 ai-gateway(见 qa.py)。
|
||||
熔断器: 启用
|
||||
整视频分析: 用 Files API 上传完整视频 -> generateContent 直出结构化 JSON
|
||||
(本地不切片、不抽帧;Gemini 原生支持长视频)
|
||||
@@ -50,7 +54,7 @@ logger = setup_logger('fam-edge.gemini_adapter')
|
||||
|
||||
|
||||
class GeminiAdapter(BaseModelAdapter):
|
||||
"""Gemini 云端 VLM 适配器 (整视频直出结构化 JSON + 文本问答)"""
|
||||
"""Gemini 云端 VLM 适配器 (整视频直出结构化 JSON,不参与问答)"""
|
||||
|
||||
def __init__(self, config: dict):
|
||||
super().__init__("gemini", config)
|
||||
@@ -397,61 +401,6 @@ class GeminiAdapter(BaseModelAdapter):
|
||||
camera = load_config().get('gdrive_sync', {}).get('camera_name', '')
|
||||
return build_video_prompt(known_members, event_start_time, camera)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 智能问答:纯文本
|
||||
# ------------------------------------------------------------------
|
||||
def chat(self, prompt: str, max_tokens: int = 512) -> Optional[str]:
|
||||
if self._cb.is_open():
|
||||
logger.warning("Gemini 熔断器 OPEN,跳过问答")
|
||||
return None
|
||||
if not self.api_keys:
|
||||
logger.warning("Gemini API Key 未配置,跳过问答")
|
||||
return None
|
||||
try:
|
||||
result = self._generate_text(prompt, max_tokens=max_tokens, temperature=0.3)
|
||||
except Exception as e:
|
||||
logger.error(f"Gemini 问答异常: {e}")
|
||||
result = None
|
||||
if result:
|
||||
self._cb.record_success()
|
||||
else:
|
||||
self._cb.record_failure()
|
||||
return result
|
||||
|
||||
def _generate_text(self, text: str, max_tokens: int, temperature: float) -> Optional[str]:
|
||||
"""纯文本 generateContent,按 key 轮换(同 analyze_video 共用一套轮转起点)
|
||||
× 模型 fallback 链依次尝试。"""
|
||||
for idx, api_key in self._rotated_keys():
|
||||
key_label = self.key_labels[idx]
|
||||
for model in self.model_chain:
|
||||
try:
|
||||
resp = requests.post(
|
||||
f"{self._base_url}/models/{model}:generateContent?key={api_key}",
|
||||
json={"contents": [{"parts": [{"text": text}]}],
|
||||
"generationConfig": {
|
||||
"temperature": temperature,
|
||||
"maxOutputTokens": max_tokens}},
|
||||
timeout=self.timeout
|
||||
)
|
||||
except requests.Timeout:
|
||||
logger.warning(f"Gemini {key_label} [{model}] 问答超时")
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.error(f"Gemini {key_label} [{model}] 问答异常: {e}")
|
||||
continue
|
||||
if resp.status_code == 200:
|
||||
cands = resp.json().get('candidates', [])
|
||||
out = ''.join(
|
||||
p.get('text', '')
|
||||
for p in (cands[0].get('content', {}) if cands else {}).get('parts', [])
|
||||
).strip() if cands else ''
|
||||
if out:
|
||||
return out
|
||||
elif resp.status_code == 429:
|
||||
logger.warning(f"Gemini {key_label} [{model}] 429,切换下一模型/Key")
|
||||
continue
|
||||
return None
|
||||
|
||||
def get_timeout(self) -> int:
|
||||
return self.timeout
|
||||
|
||||
|
||||
@@ -3,9 +3,12 @@ NvidiaVisionAdapter - NVIDIA NIM 云端 VLM 适配器
|
||||
|
||||
provider_name = "nvidia"
|
||||
模型: nvidia/nemotron-3-nano-omni-30b-a3b-reasoning(唯一实测确认可用的视频理解模型)
|
||||
角色: vision (整视频直出结构化 JSON) + 智能问答
|
||||
角色: vision (整视频直出结构化 JSON)
|
||||
SDK: openai (NIM 兼容 OpenAI API 规范)
|
||||
|
||||
问答(chat/chat_stream)已于 2026-08-23 抽离到独立的 ai-gateway 服务
|
||||
(跟视频分析业务无关,是通用能力),这里不再实现。
|
||||
|
||||
整视频分析实测结论(2026-08-21 用真实短视频逐个探测):
|
||||
- nemotron-3-nano-omni-30b-a3b-reasoning: video_url 只认 base64 data URI
|
||||
(`data:video/mp4;base64,<...>`),Assets API 的 asset_id 引用方式对它直接 500
|
||||
@@ -44,7 +47,7 @@ except ImportError:
|
||||
|
||||
|
||||
class NvidiaVisionAdapter(BaseModelAdapter):
|
||||
"""NVIDIA NIM 云端 VLM 适配器 (整视频单次调用; 文本问答)
|
||||
"""NVIDIA NIM 云端 VLM 适配器 (整视频单次调用,不参与问答)
|
||||
|
||||
多模型降级链(类似 Gemini flash -> flash-lite):
|
||||
- model_chain = [model_name] + fallback_models
|
||||
@@ -192,34 +195,6 @@ class NvidiaVisionAdapter(BaseModelAdapter):
|
||||
camera = load_config().get('gdrive_sync', {}).get('camera_name', '')
|
||||
return build_video_prompt(known_members, event_start_time, camera)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 智能问答:纯文本
|
||||
# ------------------------------------------------------------------
|
||||
def chat(self, prompt: str, max_tokens: int = 2048) -> Optional[str]:
|
||||
if self._cb.is_open():
|
||||
logger.warning("NVIDIA 熔断器 OPEN,跳过问答")
|
||||
return None
|
||||
if self._client is None:
|
||||
logger.warning("NVIDIA 客户端未初始化,跳过问答")
|
||||
return None
|
||||
for model in self.model_chain:
|
||||
try:
|
||||
resp = self._client.chat.completions.create(
|
||||
model=model,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
temperature=0.3,
|
||||
max_tokens=max_tokens,
|
||||
timeout=self.timeout
|
||||
)
|
||||
content = resp.choices[0].message.content
|
||||
if content:
|
||||
self._cb.record_success()
|
||||
return content.strip()
|
||||
except Exception as e:
|
||||
logger.warning(f"NVIDIA [{model}] 问答异常: {e}")
|
||||
self._cb.record_failure()
|
||||
return None
|
||||
|
||||
def get_timeout(self) -> int:
|
||||
return self.timeout
|
||||
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
"""
|
||||
OllamaAdapter - 本地模型适配器(仅智能问答兜底)
|
||||
|
||||
provider_name = "ollama"
|
||||
模型: qwen2.5:7b(纯文本)
|
||||
角色: text(智能问答兜底;Gemini 与 NVIDIA 均失败时启用)
|
||||
健康检查: GET /api/tags
|
||||
不参与视觉分析、不参与视频结构化输出(云端 VLM 直出)
|
||||
"""
|
||||
import requests
|
||||
from typing import Dict, Optional
|
||||
|
||||
from .base_adapter import BaseModelAdapter
|
||||
from .circuit_breaker import CircuitBreaker
|
||||
from ..logger import setup_logger
|
||||
|
||||
logger = setup_logger('fam-edge.ollama_adapter')
|
||||
|
||||
|
||||
class OllamaAdapter(BaseModelAdapter):
|
||||
"""Ollama 本地 VLM 适配器"""
|
||||
|
||||
def __init__(self, config: dict):
|
||||
super().__init__("ollama", config)
|
||||
self.base_url = config.get('base_url', 'http://localhost:11434')
|
||||
self.model_name = config.get('model_name', 'llava-phi3')
|
||||
self.timeout = config.get('timeout', 240)
|
||||
self.num_predict = config.get('num_predict', 500)
|
||||
cb_cfg = config.get('circuit_breaker', {})
|
||||
self._cb = CircuitBreaker(
|
||||
threshold=cb_cfg.get('threshold', 5),
|
||||
cooldown=cb_cfg.get('cooldown', 900),
|
||||
enabled=cb_cfg.get('enabled', False) # 本地模型默认不启用
|
||||
)
|
||||
|
||||
def health_check(self) -> bool:
|
||||
"""GET /api/tags,检查模型是否可用"""
|
||||
try:
|
||||
resp = requests.get(f"{self.base_url}/api/tags", timeout=10)
|
||||
if resp.status_code == 200:
|
||||
models = resp.json().get('models', [])
|
||||
model_names = [m.get('name', '') for m in models]
|
||||
# 兼容 llava-phi3:latest 等后缀
|
||||
has_model = any(self.model_name in name for name in model_names)
|
||||
if has_model:
|
||||
logger.info(f"Ollama 健康检查通过: 模型 {self.model_name} 可用")
|
||||
return True
|
||||
else:
|
||||
logger.warning(f"Ollama 健康检查失败: 模型 {self.model_name} 未找到,可用模型: {model_names}")
|
||||
return False
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Ollama 健康检查异常: {e}")
|
||||
return False
|
||||
|
||||
def analyze_video(self, video_path: str,
|
||||
known_members_context: str,
|
||||
event_start_time: str = '') -> Optional[Dict]:
|
||||
"""Ollama 为纯文本模型,不参与视频分析,返回 None(降级链不会选它做视频)。"""
|
||||
logger.info("Ollama 为纯文本模型,跳过视频分析")
|
||||
return None
|
||||
|
||||
def get_timeout(self) -> int:
|
||||
return self.timeout
|
||||
|
||||
def get_circuit_breaker(self) -> CircuitBreaker:
|
||||
return self._cb
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 智能问答:纯文本(本地模型,仅作 Gemini/NVIDIA 全失败时的兜底)
|
||||
# ------------------------------------------------------------------
|
||||
def chat(self, prompt: str, max_tokens: int = 512) -> Optional[str]:
|
||||
if self._cb.is_open():
|
||||
logger.warning("Ollama 熔断器 OPEN,跳过问答")
|
||||
return None
|
||||
try:
|
||||
resp = requests.post(
|
||||
f"{self.base_url}/api/generate",
|
||||
json={
|
||||
"model": self.model_name,
|
||||
"prompt": prompt,
|
||||
"stream": False,
|
||||
"options": {"temperature": 0.3, "num_predict": max_tokens}
|
||||
},
|
||||
timeout=self.timeout
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
output = resp.json().get('response', '').strip()
|
||||
if output:
|
||||
self._cb.record_success()
|
||||
return output
|
||||
self._cb.record_failure()
|
||||
else:
|
||||
logger.error(f"Ollama 问答失败: {resp.status_code} {resp.text[:200]}")
|
||||
self._cb.record_failure()
|
||||
except requests.Timeout:
|
||||
logger.error(f"Ollama 问答超时 ({self.timeout}s)")
|
||||
self._cb.record_failure()
|
||||
except Exception as e:
|
||||
logger.error(f"Ollama 问答异常: {e}")
|
||||
self._cb.record_failure()
|
||||
return None
|
||||
@@ -33,13 +33,42 @@ class OracleDB:
|
||||
def __init__(self, db_path: str):
|
||||
os.makedirs(os.path.dirname(db_path), exist_ok=True)
|
||||
self.db_path = db_path
|
||||
self._conn = sqlite3.connect(db_path, check_same_thread=False)
|
||||
self._conn.row_factory = sqlite3.Row
|
||||
self._conn.execute("PRAGMA journal_mode=WAL")
|
||||
self._conn.execute("PRAGMA busy_timeout=10000")
|
||||
self._local = threading.local() # 每线程一条连接,见下面的 _conn
|
||||
self._all_conns = [] # 仅供 close() 收尾
|
||||
self._conns_lock = threading.Lock()
|
||||
self._write_lock = threading.Lock() # 复合写(如 DELETE+INSERT+commit)串行化
|
||||
self._init_schema()
|
||||
|
||||
@property
|
||||
def _conn(self) -> sqlite3.Connection:
|
||||
"""当前线程的连接(2026-09-13 从"全进程共用一条"改成每线程一条)。
|
||||
|
||||
原来是 __init__ 里建一条 `check_same_thread=False` 的连接给所有线程共用:
|
||||
VideoQueue / PersonService / DiskGuard 三个后台线程,加上 gunicorn 的请求
|
||||
线程,并发读写同一个连接对象,事务状态互相踩踏。线上长期刷三类报错,
|
||||
全是这一个根因:
|
||||
|
||||
- `cannot start a transaction within a transaction`:一个线程的事务还
|
||||
开着,另一个线程又要开——DiskGuard 的清理被打断 2837 次,磁盘守护基本
|
||||
靠运气生效(表现为剩余空间在 2.7G 和 16G 之间来回荡)
|
||||
- `no more rows available`:commit 时游标已被别的线程重置,87 次
|
||||
- `database is locked`:每小时上百次,NAS 推来的运动事件被 500 打回
|
||||
|
||||
sqlite3 的连接本来就不是可并发共享的对象。改成各线程各拿一条之后:WAL 下
|
||||
多连接读不互斥,写由 SQLite 自己排队(busy_timeout 兜底等 10 秒),而
|
||||
`_write_lock` 继续保证"复合写"在本进程内串行,语义不变。
|
||||
"""
|
||||
conn = getattr(self._local, 'conn', None)
|
||||
if conn is None:
|
||||
conn = sqlite3.connect(self.db_path, timeout=10, check_same_thread=False)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA busy_timeout=10000")
|
||||
self._local.conn = conn
|
||||
with self._conns_lock:
|
||||
self._all_conns.append(conn)
|
||||
return conn
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
def _init_schema(self):
|
||||
c = self._conn
|
||||
@@ -110,10 +139,31 @@ class OracleDB:
|
||||
detail TEXT,
|
||||
ts TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS ss_motion_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
event_id INTEGER UNIQUE,
|
||||
camera_id INTEGER,
|
||||
event_type INTEGER,
|
||||
start_time INTEGER,
|
||||
duration INTEGER,
|
||||
thumbnail_url TEXT,
|
||||
received_at TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS person_identity_map (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
video_id INTEGER,
|
||||
raw_uid TEXT,
|
||||
canonical_name TEXT,
|
||||
source TEXT,
|
||||
updated_at TEXT,
|
||||
UNIQUE(video_id, raw_uid)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_videos_updated ON videos(updated_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_video ON events(video_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_model_calls_created ON model_calls(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_activity_ts ON service_activity(ts);
|
||||
CREATE INDEX IF NOT EXISTS idx_motion_window ON ss_motion_events(start_time, event_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_identity_map_video ON person_identity_map(video_id);
|
||||
""")
|
||||
# 兼容旧库:补 retry_count / file_valid / media 等列(生产-消费队列用)
|
||||
cols = [r[1] for r in c.execute("PRAGMA table_info(videos)").fetchall()]
|
||||
@@ -138,6 +188,14 @@ class OracleDB:
|
||||
]:
|
||||
if col not in pe_cols:
|
||||
c.execute(ddl)
|
||||
# 兼容旧库:videos 表补运动片段字段(运动事件驱动架构加)
|
||||
vid_cols = [r[1] for r in c.execute("PRAGMA table_info(videos)").fetchall()]
|
||||
for col, ddl in [
|
||||
('motion_event_id', "ALTER TABLE videos ADD COLUMN motion_event_id INTEGER"),
|
||||
('camera_id', "ALTER TABLE videos ADD COLUMN camera_id INTEGER"),
|
||||
]:
|
||||
if col not in vid_cols:
|
||||
c.execute(ddl)
|
||||
self._conn.commit()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -193,6 +251,144 @@ class OracleDB:
|
||||
"ORDER BY id DESC LIMIT ?", (int(limit),)).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 运动侦测事件(NAS 推送,单向:NAS -> Oracle,不再反向访问 NAS)
|
||||
# 由 fam-core 的 MotionNotifier 轮询/接收 SS 事件后 POST 到 /api/ss/motion。
|
||||
# ------------------------------------------------------------------
|
||||
def record_motion_events(self, events: List[Dict]) -> int:
|
||||
"""批量 upsert NAS 推送来的运动事件(按 event_id 幂等)。返回成功条数。"""
|
||||
n = 0
|
||||
now = _now_iso()
|
||||
for e in (events or []):
|
||||
eid = e.get('event_id')
|
||||
if eid is None:
|
||||
continue
|
||||
self._conn.execute(
|
||||
"""INSERT INTO ss_motion_events
|
||||
(event_id, camera_id, event_type, start_time, duration,
|
||||
thumbnail_url, received_at)
|
||||
VALUES (?,?,?,?,?,?,?)
|
||||
ON CONFLICT(event_id) DO UPDATE SET
|
||||
camera_id=excluded.camera_id,
|
||||
event_type=excluded.event_type,
|
||||
start_time=excluded.start_time,
|
||||
duration=excluded.duration,
|
||||
thumbnail_url=excluded.thumbnail_url,
|
||||
received_at=excluded.received_at""",
|
||||
(int(eid), e.get('camera_id'), e.get('event_type'),
|
||||
e.get('start_time'), e.get('duration'),
|
||||
e.get('thumbnail_url'), now))
|
||||
n += 1
|
||||
if n:
|
||||
self._conn.commit()
|
||||
return n
|
||||
|
||||
def get_recent_motion_events(self, limit: int = 50) -> List[Dict]:
|
||||
"""最近运动事件(按 start_time 倒序)。"""
|
||||
rows = self._conn.execute(
|
||||
"SELECT id, event_id, camera_id, event_type, start_time, duration, "
|
||||
"thumbnail_url, received_at FROM ss_motion_events "
|
||||
"ORDER BY start_time DESC LIMIT ?", (int(limit),)).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
def record_motion_heartbeat(self):
|
||||
"""NAS 侧推送链路心跳(不管有没有真实事件,每隔几分钟都应该调一次)。
|
||||
|
||||
单独的心跳信号,跟"表里有没有历史数据"是两回事:表非空只能说明"曾经收到
|
||||
过推送",推送链路后来整个挂掉(NAS 服务崩溃/网络断开/DSM 侧 Webhook 规则
|
||||
被误关)之后,表依然非空,has_motion_in_range_local() 原来的判断方式会
|
||||
误以为"链路健康、这段时间确认无运动"从而错误跳过分析——心跳新鲜度检查就是
|
||||
堵这个漏洞的。
|
||||
"""
|
||||
self.set_cursor('motion_heartbeat_at', _now_iso())
|
||||
|
||||
def get_motion_heartbeat_age_sec(self) -> Optional[float]:
|
||||
"""距上次心跳过了多少秒;从未收到过心跳返回 None。"""
|
||||
v = self.get_cursor('motion_heartbeat_at')
|
||||
if not v:
|
||||
return None
|
||||
try:
|
||||
hb = datetime.strptime(v, '%Y-%m-%d %H:%M:%S').replace(
|
||||
tzinfo=timezone(timedelta(hours=8)))
|
||||
except ValueError:
|
||||
return None
|
||||
return (datetime.now(timezone(timedelta(hours=8))) - hb).total_seconds()
|
||||
|
||||
def has_motion_in_range_local(self, start_ts: int, end_ts: int,
|
||||
camera_id: int = None,
|
||||
max_heartbeat_age_sec: float = 900) -> Optional[bool]:
|
||||
"""本地运动预过滤:判断 [start_ts, end_ts] 窗口内是否存在运动事件。
|
||||
|
||||
替代原 dsm_motion_client 反向访问 NAS 的做法。返回:
|
||||
- None : 推送链路心跳缺失或过期(从未收到过 / 距上次心跳超过
|
||||
max_heartbeat_age_sec),说明当前无法确认 NAS -> Oracle 这条
|
||||
推送链路是否存活——调用方必须 fail-open(照常送云端分析),
|
||||
不能当作"无运动"跳过。心跳新鲜(链路确认存活)时才信任查询结果,
|
||||
不再仅凭"表是否曾经非空"判断(那样链路挂了也测不出来)。
|
||||
- True/False : 链路确认存活时,窗口内确有/确无运动事件。
|
||||
start_ts/end_ts 为 Unix epoch(与 SS 事件 start_time 同源,时区无关)。
|
||||
"""
|
||||
age = self.get_motion_heartbeat_age_sec()
|
||||
if age is None or age > max_heartbeat_age_sec:
|
||||
return None
|
||||
sql = ("SELECT COUNT(*) c FROM ss_motion_events "
|
||||
"WHERE event_type = 10 "
|
||||
"AND start_time <= ? "
|
||||
"AND (start_time + COALESCE(duration,0)) >= ?")
|
||||
params = [end_ts, start_ts]
|
||||
if camera_id is not None:
|
||||
sql += " AND camera_id = ?"
|
||||
params.append(camera_id)
|
||||
cnt = self._conn.execute(sql, params).fetchone()['c']
|
||||
return cnt > 0
|
||||
|
||||
def get_motion_events_in_range(self, start_ts: int, end_ts: int,
|
||||
camera_id: int = None,
|
||||
finished_grace_sec: int = 10) -> List[Dict]:
|
||||
"""窗口内【已结束】的运动事件(运动片段分割用)。
|
||||
|
||||
返回按 start_time 升序的 [{event_id, camera_id, start_time, duration,
|
||||
thumbnail_url}]。只返回已结束事件:SS 事件 duration 在动作进行中会显示 0、
|
||||
结束才回填真实时长,因此只分割 start_time+duration 已落在当前时刻
|
||||
(含 finished_grace_sec 秒容差)的事件,进行中的等下轮结束后再处理。
|
||||
"""
|
||||
now_ts = int(datetime.now(timezone(timedelta(hours=8))).timestamp())
|
||||
sql = ("SELECT event_id, camera_id, event_type, start_time, duration, thumbnail_url "
|
||||
"FROM ss_motion_events "
|
||||
"WHERE event_type = 10 "
|
||||
"AND start_time <= ? "
|
||||
"AND (start_time + COALESCE(duration,0)) >= ? "
|
||||
"AND COALESCE(duration,0) > 0 "
|
||||
"AND (start_time + COALESCE(duration,0)) <= ? + ?")
|
||||
params = [end_ts, start_ts, now_ts, int(finished_grace_sec)]
|
||||
if camera_id is not None:
|
||||
sql += " AND camera_id = ?"
|
||||
params.append(camera_id)
|
||||
sql += " ORDER BY start_time ASC"
|
||||
return [dict(r) for r in self._conn.execute(sql, params).fetchall()]
|
||||
|
||||
def has_unfinished_motion_in_range(self, start_ts: int, end_ts: int,
|
||||
camera_id: int = None) -> bool:
|
||||
"""窗口内是否存在【未结束】的运动事件(duration 可能还在增长)。"""
|
||||
now_ts = int(datetime.now(timezone(timedelta(hours=8))).timestamp())
|
||||
sql = ("SELECT COUNT(*) c FROM ss_motion_events "
|
||||
"WHERE event_type = 10 "
|
||||
"AND start_time <= ? "
|
||||
"AND (start_time + COALESCE(duration,0)) >= ? "
|
||||
"AND (start_time + COALESCE(duration,0)) > ?")
|
||||
params = [end_ts, start_ts, now_ts]
|
||||
if camera_id is not None:
|
||||
sql += " AND camera_id = ?"
|
||||
params.append(camera_id)
|
||||
return self._conn.execute(sql, params).fetchone()['c'] > 0
|
||||
|
||||
def get_video_by_motion_event_id(self, motion_event_id: int):
|
||||
"""按运动事件 id 查是否已生成对应运动片段(幂等去重)。"""
|
||||
cur = self._conn.execute(
|
||||
"SELECT * FROM videos WHERE motion_event_id=? LIMIT 1",
|
||||
(int(motion_event_id),))
|
||||
return cur.fetchone()
|
||||
|
||||
def get_queue_status(self) -> Dict:
|
||||
"""实时队列/处理状态(前端服务状态卡用)。"""
|
||||
total = self._conn.execute("SELECT COUNT(*) c FROM videos").fetchone()['c']
|
||||
@@ -212,6 +408,79 @@ class OracleDB:
|
||||
"recent": [dict(r) for r in recent],
|
||||
}
|
||||
|
||||
def get_segment_status(self) -> Dict:
|
||||
"""运动片段分割状态(前端服务状态页「视频分割」卡)。
|
||||
|
||||
统计 motion_event_id 非空的运动片段记录 + 最近分割活动。
|
||||
"""
|
||||
seg_sql = "FROM videos WHERE motion_event_id IS NOT NULL"
|
||||
total = self._conn.execute(f"SELECT COUNT(*) c {seg_sql}").fetchone()['c']
|
||||
done = self._conn.execute(
|
||||
f"SELECT COUNT(*) c {seg_sql} AND status='done'").fetchone()['c']
|
||||
pending = self._conn.execute(
|
||||
f"SELECT COUNT(*) c {seg_sql} AND status='pending'").fetchone()['c']
|
||||
failed = self._conn.execute(
|
||||
f"SELECT COUNT(*) c {seg_sql} AND status='failed'").fetchone()['c']
|
||||
# 已分割事件数(videos.motion_event_id 去重)与待分割事件数(ss_motion_events 未生成片段)
|
||||
motion_total = self._conn.execute(
|
||||
"SELECT COUNT(*) c FROM ss_motion_events WHERE event_type=10 AND COALESCE(duration,0)>0"
|
||||
).fetchone()['c']
|
||||
last = self._conn.execute(
|
||||
"SELECT service, action, detail, ts FROM service_activity "
|
||||
"WHERE service='segment' ORDER BY id DESC LIMIT 1").fetchone()
|
||||
return {
|
||||
"total": total,
|
||||
"done": done,
|
||||
"pending": pending,
|
||||
"failed": failed,
|
||||
"motion_events": motion_total,
|
||||
"last": dict(last) if last else None,
|
||||
}
|
||||
|
||||
def get_segment_consistency(self, gap_limit: int = 20) -> Dict:
|
||||
"""事件↔片段一致性对账(数量可验证)。
|
||||
|
||||
- event_total : ss_motion_events 全部运动事件(SS 已回灌的)
|
||||
- finished : 已结束且 duration>0(具备分割条件)的事件数
|
||||
- segmented : 已生成片段的去重事件数(videos.motion_event_id)
|
||||
- gap_count : 已结束但未生成片段(缺口 = finished - segmented 的下界)
|
||||
- gaps : 缺口明细(event_id/start_time/duration,北京时间)
|
||||
- material_range: 素材文件覆盖的 event_start_time 范围(判断缺口是否因素材缺失)
|
||||
"""
|
||||
now_ts = int(datetime.now(timezone(timedelta(hours=8))).timestamp())
|
||||
fin = ("event_type = 10 AND COALESCE(duration,0) > 0 "
|
||||
"AND (start_time + COALESCE(duration,0)) <= ?")
|
||||
event_total = self._conn.execute(
|
||||
"SELECT COUNT(*) c FROM ss_motion_events WHERE event_type=10"
|
||||
).fetchone()['c']
|
||||
finished = self._conn.execute(
|
||||
f"SELECT COUNT(*) c FROM ss_motion_events WHERE {fin}", (now_ts,)
|
||||
).fetchone()['c']
|
||||
segmented = self._conn.execute(
|
||||
"SELECT COUNT(DISTINCT motion_event_id) c FROM videos "
|
||||
"WHERE motion_event_id IS NOT NULL"
|
||||
).fetchone()['c']
|
||||
gaps = self._conn.execute(
|
||||
f"""SELECT me.event_id, me.camera_id, me.start_time, me.duration
|
||||
FROM ss_motion_events me
|
||||
WHERE {fin}
|
||||
AND NOT EXISTS (SELECT 1 FROM videos v
|
||||
WHERE v.motion_event_id = me.event_id)
|
||||
ORDER BY me.start_time DESC LIMIT ?""",
|
||||
(now_ts, int(gap_limit))).fetchall()
|
||||
mrange = self._conn.execute(
|
||||
"SELECT MIN(event_start_time) mn, MAX(event_start_time) mx "
|
||||
"FROM videos WHERE motion_event_id IS NULL AND event_start_time != ''"
|
||||
).fetchone()
|
||||
return {
|
||||
"event_total": event_total,
|
||||
"finished": finished,
|
||||
"segmented": segmented,
|
||||
"gap_count": max(0, finished - segmented),
|
||||
"gaps": [dict(r) for r in gaps],
|
||||
"material_range": {"min": mrange['mn'], "max": mrange['mx']} if mrange and mrange['mn'] else None,
|
||||
}
|
||||
|
||||
def set_video_file_status(self, video_id: int, valid: bool,
|
||||
error: str = '', media_meta: dict = None):
|
||||
"""登记/更新文件校验结果:valid / file_error / media_meta_json"""
|
||||
@@ -226,23 +495,29 @@ class OracleDB:
|
||||
|
||||
def ensure_video(self, filename: str, local_path: str,
|
||||
camera_name: str = '', event_start_time: str = '',
|
||||
duration_sec: float = 0.0, drive_file_id: str = '') -> int:
|
||||
"""视频进入监听目录时登记;已存在则更新路径。返回 video_id。"""
|
||||
duration_sec: float = 0.0, drive_file_id: str = '',
|
||||
motion_event_id: int = None, camera_id: int = None) -> int:
|
||||
"""视频进入监听目录时登记;已存在则更新路径。返回 video_id。
|
||||
|
||||
motion_event_id: 运动片段关联的 SS 运动事件 id(非空=运动片段,非素材)。
|
||||
"""
|
||||
now = _now_iso()
|
||||
row = self.get_video_by_filename(filename)
|
||||
if row:
|
||||
self._conn.execute(
|
||||
"UPDATE videos SET local_path=?, camera_name=?, event_start_time=?, "
|
||||
"duration_sec=?, updated_at=? WHERE id=?",
|
||||
(local_path, camera_name, event_start_time, duration_sec, now, row['id']))
|
||||
"duration_sec=?, motion_event_id=?, camera_id=?, updated_at=? WHERE id=?",
|
||||
(local_path, camera_name, event_start_time, duration_sec,
|
||||
motion_event_id, camera_id, now, row['id']))
|
||||
self._conn.commit()
|
||||
return row['id']
|
||||
cur = self._conn.execute(
|
||||
"INSERT INTO videos (drive_file_id, filename, local_path, camera_name, "
|
||||
"duration_sec, event_start_time, status, created_at, updated_at) "
|
||||
"VALUES (?,?,?,?,?,?, 'pending', ?, ?)",
|
||||
"duration_sec, event_start_time, motion_event_id, camera_id, "
|
||||
"status, created_at, updated_at) "
|
||||
"VALUES (?,?,?,?,?,?,?,?, 'pending', ?, ?)",
|
||||
(drive_file_id, filename, local_path, camera_name, duration_sec,
|
||||
event_start_time, now, now))
|
||||
event_start_time, motion_event_id, camera_id, now, now))
|
||||
self._conn.commit()
|
||||
return cur.lastrowid
|
||||
|
||||
@@ -297,6 +572,47 @@ class OracleDB:
|
||||
(event_start_time, _now_iso(), video_id))
|
||||
self._conn.commit()
|
||||
|
||||
def delete_video(self, video_id: int) -> Optional[str]:
|
||||
"""删除视频会话(events + videos 行)及其磁盘文件。
|
||||
|
||||
不清理对应的 ss_motion_events 源事件——那是运动侦测硬件推送的原始记录,
|
||||
跟切出来的片段是独立生命周期;只要素材已经处理完(status='done',不再
|
||||
被生产者重新捡起),删除片段后不会被自动重新分割。
|
||||
返回被删视频的 local_path(不存在则返回 None,供上层判断 404)。
|
||||
"""
|
||||
with self._write_lock:
|
||||
row = self._conn.execute(
|
||||
"SELECT local_path FROM videos WHERE id=?", (video_id,)).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
local_path = row['local_path']
|
||||
self._conn.execute("DELETE FROM events WHERE video_id=?", (video_id,))
|
||||
self._conn.execute("DELETE FROM videos WHERE id=?", (video_id,))
|
||||
self._conn.commit()
|
||||
if local_path and os.path.isfile(local_path):
|
||||
try:
|
||||
os.remove(local_path)
|
||||
except OSError as e:
|
||||
logger.warning(f"删除视频文件失败 {local_path}: {e}")
|
||||
return local_path or ''
|
||||
|
||||
def get_oldest_purgeable_material(self) -> Optional[Dict]:
|
||||
"""磁盘空间紧张时的清理候选:最旧的、已完成分割阶段的整段素材
|
||||
(filename 不是 motion_ 前缀)。按 id 升序取第一个(id 越小越早入库)。
|
||||
|
||||
只挑 status='done' 的——素材一旦完成分割就不会再被 Video-Queue 重新
|
||||
捡起,删掉它的本地文件不影响任何功能(运动片段是独立文件,事件时间
|
||||
轴/人物头像只依赖 motion_clips 里的片段,不依赖原始整段素材);绝不
|
||||
碰 pending/processing 中的,避免删掉还没来得及处理的数据。
|
||||
"""
|
||||
row = self._conn.execute(
|
||||
"SELECT id, local_path FROM videos "
|
||||
"WHERE status='done' AND local_path IS NOT NULL AND local_path != '' "
|
||||
"AND filename NOT LIKE 'motion_%' "
|
||||
"ORDER BY id ASC LIMIT 1"
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
def mark_video_invalid(self, video_id: int, error: str = ''):
|
||||
"""文件校验不通过(损坏/非视频等),标记 invalid,producer 不再重试。"""
|
||||
now = _now_iso()
|
||||
@@ -363,6 +679,143 @@ class OracleDB:
|
||||
'features_text': features_text,
|
||||
'event_start_time': best['event_start_time']}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 人物对应关系表(2026-08-22 新增):记录"某个视频里 Gemini 给的原始 uid"
|
||||
# 与"闭集识别解析出的规范名"之间的映射,作为可追溯、可纠错的中间层。
|
||||
#
|
||||
# 设计动机:events.person_appearances_json 里的 uid 只在单次视频分析内稳定,
|
||||
# 同一字符串在不同视频里完全可能指向不同真人——不能靠"改 label 的
|
||||
# canonical_name"来纠错(一个 label 撞了多个真人,改一次就把另一个人也带歪
|
||||
# 了)。所以纠错必须落到 (video_id, raw_uid) 这一粒度,而不是全局 label。
|
||||
#
|
||||
# events/videos 表里实际展示用的 person_list_json / person_appearances_json /
|
||||
# people_json 会在识别(或纠正)时被直接重写成规范名(rewrite_event_person_names),
|
||||
# 保持"读的时候不用现查表拼接"的简单模型;这张表只作为"这次重写是怎么来的"的
|
||||
# 记录 + 纠错操作的定位依据,不参与展示时的实时查询。
|
||||
# ------------------------------------------------------------------
|
||||
def set_identity_mapping(self, video_id: int, raw_uid: str,
|
||||
canonical_name: str, source: str = 'auto_id') -> bool:
|
||||
"""记录/更新 (video_id, raw_uid) -> canonical_name。manual 来源受保护,
|
||||
不会被后续自动识别结果(rule/auto_id)覆盖。返回是否真的发生了变化
|
||||
(调用方据此决定要不要顺带重写 events 展示数据)。"""
|
||||
now = _now_iso()
|
||||
row = self._conn.execute(
|
||||
"SELECT canonical_name, source FROM person_identity_map "
|
||||
"WHERE video_id=? AND raw_uid=?", (video_id, raw_uid)).fetchone()
|
||||
if row:
|
||||
if row['source'] == 'manual' and source != 'manual':
|
||||
return False
|
||||
if row['canonical_name'] == canonical_name and row['source'] == source:
|
||||
return False
|
||||
self._conn.execute(
|
||||
"UPDATE person_identity_map SET canonical_name=?, source=?, updated_at=? "
|
||||
"WHERE video_id=? AND raw_uid=?",
|
||||
(canonical_name, source, now, video_id, raw_uid))
|
||||
else:
|
||||
self._conn.execute(
|
||||
"INSERT INTO person_identity_map "
|
||||
"(video_id, raw_uid, canonical_name, source, updated_at) VALUES (?,?,?,?,?)",
|
||||
(video_id, raw_uid, canonical_name, source, now))
|
||||
self._conn.commit()
|
||||
return True
|
||||
|
||||
def get_identity_map_for_video(self, video_id: int) -> Dict[str, str]:
|
||||
rows = self._conn.execute(
|
||||
"SELECT raw_uid, canonical_name FROM person_identity_map WHERE video_id=?",
|
||||
(video_id,)).fetchall()
|
||||
return {r['raw_uid']: r['canonical_name'] for r in rows if r['canonical_name']}
|
||||
|
||||
def rewrite_event_person_names(self, video_id: int, rename_map: Dict[str, str]):
|
||||
"""按 {当前展示名: 新名} 把该视频全部 events 的 person_list_json /
|
||||
person_appearances_json[].uid / description 文本,以及 videos.people_json /
|
||||
summary_json 文本里的名字替换掉。rename_map 的 key 是"事件数据里当前显示
|
||||
的名字"(可能是原始 uid,也可能是上一轮已经替换过的规范名——纠错场景就是
|
||||
这种情况)。
|
||||
|
||||
description/summary_json 是大模型写的自然语言描述,"人物A"这类 uid 会
|
||||
直接以文字形式出现在句子里(不只是 person_list_json 这种结构化字段)——
|
||||
只替换结构化字段的话,事件卡片上方的人物徽章会显示正确的规范名,但描述
|
||||
文字里还是"人物A/人物B",看着两处对不上。这里做纯文本替换来解决。
|
||||
"""
|
||||
if not rename_map:
|
||||
return
|
||||
now = _now_iso()
|
||||
# 按 key 长度降序替换:uid 可能带 "#2"/"#3" 这类后缀(同名冲突时的派生
|
||||
# label),"人物A" 是 "人物A#2" 的前缀,先替换短的会把长的也错误命中一部分,
|
||||
# 长的先替换就不会被短的抢先破坏。
|
||||
ordered_keys = sorted(rename_map.keys(), key=len, reverse=True)
|
||||
|
||||
def _rewrite_text(text: str) -> str:
|
||||
if not text:
|
||||
return text
|
||||
for old in ordered_keys:
|
||||
if old in text:
|
||||
text = text.replace(old, rename_map[old])
|
||||
return text
|
||||
|
||||
with self._write_lock:
|
||||
rows = self._conn.execute(
|
||||
"SELECT id, person_list_json, person_appearances_json, description "
|
||||
"FROM events WHERE video_id=?", (video_id,)).fetchall()
|
||||
for r in rows:
|
||||
changed = False
|
||||
plist = json.loads(r['person_list_json'] or '[]')
|
||||
new_plist = [rename_map.get(x, x) for x in plist]
|
||||
if new_plist != plist:
|
||||
changed = True
|
||||
pa = json.loads(r['person_appearances_json']) if r['person_appearances_json'] else None
|
||||
if pa:
|
||||
for p in pa:
|
||||
if isinstance(p, dict) and p.get('uid') in rename_map:
|
||||
p['uid'] = rename_map[p['uid']]
|
||||
changed = True
|
||||
new_desc = _rewrite_text(r['description'])
|
||||
if new_desc != r['description']:
|
||||
changed = True
|
||||
if changed:
|
||||
self._conn.execute(
|
||||
"UPDATE events SET person_list_json=?, person_appearances_json=?, "
|
||||
"description=? WHERE id=?",
|
||||
(json.dumps(new_plist, ensure_ascii=False),
|
||||
json.dumps(pa, ensure_ascii=False) if pa is not None
|
||||
else r['person_appearances_json'],
|
||||
new_desc,
|
||||
r['id']))
|
||||
vrow = self._conn.execute(
|
||||
"SELECT people_json, summary_json FROM videos WHERE id=?",
|
||||
(video_id,)).fetchone()
|
||||
if vrow:
|
||||
v_changed = False
|
||||
new_people_json = vrow['people_json']
|
||||
if vrow['people_json']:
|
||||
plist = json.loads(vrow['people_json'])
|
||||
new_plist = [rename_map.get(x, x) for x in plist]
|
||||
if new_plist != plist:
|
||||
new_people_json = json.dumps(new_plist, ensure_ascii=False)
|
||||
v_changed = True
|
||||
new_summary = _rewrite_text(vrow['summary_json'])
|
||||
if new_summary != vrow['summary_json']:
|
||||
v_changed = True
|
||||
if v_changed:
|
||||
self._conn.execute(
|
||||
"UPDATE videos SET people_json=?, summary_json=?, updated_at=? "
|
||||
"WHERE id=?",
|
||||
(new_people_json, new_summary, now, video_id))
|
||||
self._conn.commit()
|
||||
|
||||
def correct_video_identity(self, video_id: int, current_name: str, new_name: str):
|
||||
"""纠错入口(人物管理页 / 事件时间轴"修正"按钮都走这个):把某视频里当前
|
||||
展示为 current_name 的人物改成 new_name。写 manual 来源,受保护不会被后续
|
||||
自动识别覆盖回去;同时立即重写这段视频的展示数据,不用等下一轮识别。"""
|
||||
row = self._conn.execute(
|
||||
"SELECT raw_uid FROM person_identity_map WHERE video_id=? AND canonical_name=?",
|
||||
(video_id, current_name)).fetchone()
|
||||
# 没有映射记录(比如这条数据是老流水线时代产出的,从没跑过闭集识别)
|
||||
# 就把 current_name 本身当 raw_uid 存一条新映射
|
||||
raw_uid = row['raw_uid'] if row else current_name
|
||||
self.set_identity_mapping(video_id, raw_uid, new_name, source='manual')
|
||||
self.rewrite_event_person_names(video_id, {current_name: new_name})
|
||||
|
||||
def get_events_for_label(self, label: str, limit: int = 6):
|
||||
"""该人物(canonical_name 或 UID label)出现的候选事件,按时间倒序(最近优先)。
|
||||
|
||||
@@ -509,8 +962,11 @@ class OracleDB:
|
||||
row['features_json'] if row else None, features) if row else (
|
||||
self._merge_features(None, features))
|
||||
if row:
|
||||
# manual 覆盖 llm;llm 不覆盖 manual
|
||||
if source == 'manual' or row['source'] != 'manual':
|
||||
# manual/auto_id 覆盖 llm;llm 不覆盖 manual/auto_id(auto_id 是闭集人物
|
||||
# 识别的确定性结论——比 llm 的文字特征合并猜测可靠得多,同样需要保护,
|
||||
# 不能被后续 person_service 的 llm 合并跑批悄悄覆盖回去)
|
||||
_protected = ('manual', 'auto_id', 'rule')
|
||||
if source in _protected or row['source'] not in _protected:
|
||||
self._conn.execute(
|
||||
"UPDATE people SET canonical_name=?, source=?, appearances=appearances+1, "
|
||||
"features_json=?, display_uid=?, updated_at=? WHERE label=?",
|
||||
@@ -568,7 +1024,8 @@ class OracleDB:
|
||||
now = _now_iso()
|
||||
row = self._conn.execute("SELECT * FROM people WHERE label=?", (label,)).fetchone()
|
||||
if row:
|
||||
if source == 'manual' or row['source'] != 'manual':
|
||||
_protected = ('manual', 'auto_id', 'rule')
|
||||
if source in _protected or row['source'] not in _protected:
|
||||
self._conn.execute(
|
||||
"UPDATE people SET appearances=?, source=?, updated_at=? WHERE label=?",
|
||||
(int(count), source, now, label))
|
||||
@@ -617,6 +1074,11 @@ class OracleDB:
|
||||
model_calls = self._conn.execute(
|
||||
"SELECT * FROM model_calls WHERE created_at >= ? ORDER BY id ASC",
|
||||
(since_iso,)).fetchall()
|
||||
# 人物对应关系表(甲骨文不稳定,提取出的有效数据都要同步到 NAS 防丢失;
|
||||
# 这张表是识别结果的可追溯记录 + 纠错依据,同样纳入增量同步)
|
||||
identity_map = self._conn.execute(
|
||||
"SELECT * FROM person_identity_map WHERE updated_at > ? ORDER BY id ASC",
|
||||
(since_iso,)).fetchall()
|
||||
|
||||
def _ser(row):
|
||||
d = dict(row)
|
||||
@@ -627,6 +1089,7 @@ class OracleDB:
|
||||
"events": [_ser(e) for e in events],
|
||||
"people": [_ser(p) for p in people],
|
||||
"model_calls": [_ser(m) for m in model_calls],
|
||||
"identity_map": [_ser(m) for m in identity_map],
|
||||
"server_time": _now_iso(),
|
||||
}
|
||||
|
||||
@@ -644,4 +1107,12 @@ class OracleDB:
|
||||
self._conn.commit()
|
||||
|
||||
def close(self):
|
||||
self._conn.close()
|
||||
"""关掉所有线程开过的连接(不只当前线程这一条)。"""
|
||||
with self._conns_lock:
|
||||
conns, self._all_conns = self._all_conns, []
|
||||
for c in conns:
|
||||
try:
|
||||
c.close()
|
||||
except sqlite3.Error:
|
||||
pass
|
||||
self._local = threading.local()
|
||||
|
||||
257
fam-edge/src/fam_edge/person_identifier.py
Normal file
257
fam-edge/src/fam_edge/person_identifier.py
Normal file
@@ -0,0 +1,257 @@
|
||||
"""
|
||||
PersonIdentifier - 闭集人物识别(家里固定 5 个人:爷爷/爸爸/媳妇/奶奶/汤圆)
|
||||
|
||||
背景(2026-08-22): 原来靠大模型每次视频分析自己编的"人物A/B/C"临时 uid + 一段
|
||||
性别/年龄/衣着文字描述做跨视频合并,原理上就不可靠——文字描述会因光线/角度/换衣服
|
||||
对不上,反复出现张冠李戴(用户原话:"现在的识别全是错的")。人脸向量方案也验证
|
||||
过,家庭监控这种大广角/远距离/糊画面下同人内部相似度经常比不同人还低,此路不通。
|
||||
|
||||
现在改成基于已知这个家庭固定成员的闭集规则:
|
||||
- 汤圆(幼儿/儿童)、媳妇/奶奶(成年女性按年龄段区分:老年=奶奶,其余=媳妇,
|
||||
2026-08-29 新增):Gemini 每次分析已经会标性别/年龄段,命中率验证下来接近
|
||||
100%,直接用,不需要额外模型调用。
|
||||
- 爷爷、爸爸(两个成年男性,纯外观规则/人脸向量都区分不开):改用视觉大模型
|
||||
"看图比对"——给几张已确认身份的参考图 + 待判断的截图,直接问模型这是谁。
|
||||
实测 NVIDIA nemotron-omni 在留出测试集上 6/6 全对,Gemini flash-lite 7/8,
|
||||
NVIDIA 配额与 Gemini 完全独立、不跟主分析链路抢配额,设为优先。
|
||||
|
||||
调用粒度:每个运动片段(视频行)只调一次(不是每个事件都调)——同一段视频里
|
||||
人不会中途换衣服,取片段内最大 bbox 的成年男性外观代表整段。
|
||||
|
||||
健壮性(2026-08-22 补,用于支撑历史数据批量回填):
|
||||
- NVIDIA/Gemini 各自支持模型链(model_chain,fallback_models 可再加型号)+
|
||||
每个模型独立重试(429/503/超时/连接错误这类瞬时故障,指数退避),非瞬时错误
|
||||
(400 参数错误等)不重试、直接换下一个模型/provider。
|
||||
- min_call_interval_sec 控制连续两次分类调用之间的最小间隔(不分 provider 统一
|
||||
限速)——批量回填时会短时间内密集调用,需要限速避免打爆配额/被限流。
|
||||
"""
|
||||
import base64
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
import requests
|
||||
|
||||
from .logger import setup_logger
|
||||
|
||||
try:
|
||||
from openai import OpenAI
|
||||
except ImportError:
|
||||
OpenAI = None
|
||||
|
||||
logger = setup_logger('fam-edge.person_identifier')
|
||||
|
||||
REF_DIR_DEFAULT = '/opt/fam-edge/data/person_refs'
|
||||
ADULT_MALE_CANDIDATES = ('爷爷', '爸爸')
|
||||
# HTTP 状态码:值得重试的瞬时故障(配额限流/服务过载),其余(400 参数错误/401 鉴权等)不重试
|
||||
_RETRYABLE_STATUS = (429, 500, 502, 503, 504)
|
||||
|
||||
|
||||
class PersonIdentifier:
|
||||
def __init__(self, config: dict):
|
||||
self.enabled = bool(config.get('enabled', True))
|
||||
self.ref_dir = config.get('ref_dir', REF_DIR_DEFAULT)
|
||||
self.max_ref_per_person = int(config.get('max_ref_per_person', 6))
|
||||
self.min_call_interval_sec = float(config.get('min_call_interval_sec', 2))
|
||||
self._last_call_at = 0.0
|
||||
|
||||
nv = config.get('nvidia', {})
|
||||
self.nvidia_model_chain = [nv.get('model_name', 'nvidia/nemotron-3-nano-omni-30b-a3b-reasoning')] + [
|
||||
m for m in nv.get('fallback_models', []) or [] if m]
|
||||
self.nvidia_base_url = nv.get('base_url', 'https://integrate.api.nvidia.com/v1')
|
||||
self.nvidia_api_key = self._resolve(nv.get('api_key', '${NVIDIA_API_KEY}'))
|
||||
self.nvidia_timeout = int(nv.get('timeout', 60))
|
||||
self.nvidia_max_retries = int(nv.get('max_retries', 3))
|
||||
self.nvidia_retry_backoff = float(nv.get('retry_backoff_sec', 3))
|
||||
|
||||
gm = config.get('gemini', {})
|
||||
self.gemini_model = gm.get('model_name', 'gemini-flash-lite-latest')
|
||||
raw_keys = [gm.get('api_key', '${GEMINI_API_KEY}')] + list(gm.get('extra_api_keys', []) or [])
|
||||
self.gemini_api_keys = [k for k in (self._resolve(r) for r in raw_keys) if k]
|
||||
self.gemini_timeout = int(gm.get('timeout', 60))
|
||||
self.gemini_max_retries = int(gm.get('max_retries', 2))
|
||||
self.gemini_retry_backoff = float(gm.get('retry_backoff_sec', 3))
|
||||
|
||||
self._refs = None # lazy: {person: [base64_str, ...]}
|
||||
|
||||
@staticmethod
|
||||
def _resolve(raw: str) -> str:
|
||||
if isinstance(raw, str) and raw.startswith('${') and raw.endswith('}'):
|
||||
return os.environ.get(raw[2:-1], '')
|
||||
return raw
|
||||
|
||||
def _load_refs(self):
|
||||
if self._refs is not None:
|
||||
return self._refs
|
||||
refs = {}
|
||||
for person in ADULT_MALE_CANDIDATES:
|
||||
d = os.path.join(self.ref_dir, person)
|
||||
files = []
|
||||
if os.path.isdir(d):
|
||||
files = sorted(f for f in os.listdir(d) if f.lower().endswith(('.jpg', '.jpeg', '.png')))
|
||||
imgs = []
|
||||
for f in files[:self.max_ref_per_person]:
|
||||
try:
|
||||
with open(os.path.join(d, f), 'rb') as fh:
|
||||
imgs.append(base64.b64encode(fh.read()).decode('ascii'))
|
||||
except OSError:
|
||||
continue
|
||||
refs[person] = imgs
|
||||
self._refs = refs
|
||||
return refs
|
||||
|
||||
def has_references(self) -> bool:
|
||||
refs = self._load_refs()
|
||||
return all(refs.get(p) for p in ADULT_MALE_CANDIDATES)
|
||||
|
||||
def _pace(self):
|
||||
"""连续两次分类调用之间强制最小间隔,批量回填时避免短时间内打爆配额。"""
|
||||
if self.min_call_interval_sec <= 0:
|
||||
return
|
||||
wait = self.min_call_interval_sec - (time.time() - self._last_call_at)
|
||||
if wait > 0:
|
||||
time.sleep(wait)
|
||||
|
||||
def classify_adult_male(self, crop_bytes: bytes) -> Optional[str]:
|
||||
"""给一张成年男性截图,返回 '爷爷' / '爸爸',判断不了返回 None(调用方保持原样不动)。
|
||||
|
||||
NVIDIA 优先(配额独立、实测更准,模型链+重试),失败/未配置则退回 Gemini
|
||||
flash-lite(多 key 轮换+重试)。两边都失败返回 None——绝不瞎猜,宁可这次不
|
||||
设置 canonical_name,留给下次(或人工在人物管理页确认)。
|
||||
"""
|
||||
if not self.enabled or not self.has_references():
|
||||
return None
|
||||
self._pace()
|
||||
self._last_call_at = time.time()
|
||||
result = self._classify_nvidia(crop_bytes)
|
||||
if result:
|
||||
return result
|
||||
return self._classify_gemini(crop_bytes)
|
||||
|
||||
def _build_prompt_and_images(self, crop_bytes: bytes):
|
||||
refs = self._load_refs()
|
||||
query_b64 = base64.b64encode(crop_bytes).decode('ascii')
|
||||
images = [] # list of (b64, caption)
|
||||
idx = 1
|
||||
for person in ADULT_MALE_CANDIDATES:
|
||||
for b64 in refs.get(person, []):
|
||||
images.append((b64, f'(上图是参考图{idx},此人是:{person})'))
|
||||
idx += 1
|
||||
images.append((query_b64, '(上图是待判断的截图,请判断这是「爷爷」还是「爸爸」)'))
|
||||
prefix = ('下面先给你几张参考图,每张图后面标了这个人是谁'
|
||||
'(这户人家只有这两个成年男性,一个是爷爷,一个是爸爸):')
|
||||
suffix = ('只根据外观线索(体型/发型/衣着/姿态等)判断,用 JSON 回答,'
|
||||
'格式:{"person":"爷爷或爸爸"},不要输出其他内容。')
|
||||
return prefix, images, suffix
|
||||
|
||||
def _extract_json_person(self, text: str) -> Optional[str]:
|
||||
text = (text or '').strip()
|
||||
for cand in ('爷爷', '爸爸'):
|
||||
if cand in text:
|
||||
# 两个都出现时(比如复述了参考图说明)不采信,避免误判
|
||||
if '爷爷' in text and '爸爸' in text:
|
||||
# 优先信 JSON 里 "person" 字段紧跟的那个
|
||||
m = re.search(r'"person"\s*:\s*"(爷爷|爸爸)"', text)
|
||||
if m:
|
||||
return m.group(1)
|
||||
return None
|
||||
return cand
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# NVIDIA:模型链 × 每个模型独立重试(瞬时故障退避重试,非瞬时故障直接换模型)
|
||||
# ------------------------------------------------------------------
|
||||
def _classify_nvidia(self, crop_bytes: bytes) -> Optional[str]:
|
||||
if not self.nvidia_api_key or OpenAI is None:
|
||||
return None
|
||||
prefix, images, suffix = self._build_prompt_and_images(crop_bytes)
|
||||
if len(images) > 12:
|
||||
images = images[-12:] # NVIDIA 单请求最多 12 张图,优先保留最新的参考+待判断图
|
||||
content = [{'type': 'text', 'text': prefix}]
|
||||
for b64, caption in images:
|
||||
content.append({'type': 'image_url', 'image_url': {'url': f'data:image/jpeg;base64,{b64}'}})
|
||||
content.append({'type': 'text', 'text': caption})
|
||||
content.append({'type': 'text', 'text': suffix})
|
||||
|
||||
client = OpenAI(base_url=self.nvidia_base_url, api_key=self.nvidia_api_key)
|
||||
for model in self.nvidia_model_chain:
|
||||
for attempt in range(self.nvidia_max_retries):
|
||||
try:
|
||||
resp = client.chat.completions.create(
|
||||
model=model,
|
||||
messages=[{'role': 'user', 'content': content}],
|
||||
temperature=0.1, max_tokens=200,
|
||||
timeout=self.nvidia_timeout)
|
||||
text = resp.choices[0].message.content
|
||||
person = self._extract_json_person(text)
|
||||
if person:
|
||||
logger.info(f"NVIDIA[{model}] 人物识别: {person}")
|
||||
return person
|
||||
logger.warning(f"NVIDIA[{model}] 返回结果无法解析出人物: {text[:100] if text else text}")
|
||||
break # 解析不出人物是内容问题,不是瞬时故障,重试没用,换下一个模型
|
||||
except Exception as e:
|
||||
status = getattr(getattr(e, 'response', None), 'status_code', None)
|
||||
retryable = status in _RETRYABLE_STATUS or status is None
|
||||
if retryable and attempt < self.nvidia_max_retries - 1:
|
||||
backoff = self.nvidia_retry_backoff * (2 ** attempt)
|
||||
logger.warning(
|
||||
f"NVIDIA[{model}] 第 {attempt+1}/{self.nvidia_max_retries} 次失败"
|
||||
f"(status={status}),{backoff:.1f}s 后重试: {e}")
|
||||
time.sleep(backoff)
|
||||
continue
|
||||
logger.warning(f"NVIDIA[{model}] 失败(status={status}),换下一个模型: {e}")
|
||||
break
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Gemini:多 key 轮换 × 每个 key 独立重试
|
||||
# ------------------------------------------------------------------
|
||||
def _classify_gemini(self, crop_bytes: bytes) -> Optional[str]:
|
||||
prefix, images, suffix = self._build_prompt_and_images(crop_bytes)
|
||||
parts = [{'text': prefix}]
|
||||
for b64, caption in images:
|
||||
parts.append({'inline_data': {'mime_type': 'image/jpeg', 'data': b64}})
|
||||
parts.append({'text': caption})
|
||||
parts.append({'text': suffix})
|
||||
|
||||
for key in self.gemini_api_keys:
|
||||
for attempt in range(self.gemini_max_retries):
|
||||
try:
|
||||
resp = requests.post(
|
||||
f'https://generativelanguage.googleapis.com/v1beta/models/'
|
||||
f'{self.gemini_model}:generateContent?key={key}',
|
||||
json={'contents': [{'parts': parts}],
|
||||
'generationConfig': {'temperature': 0.1, 'maxOutputTokens': 200}},
|
||||
timeout=self.gemini_timeout)
|
||||
data = resp.json()
|
||||
if not data.get('candidates'):
|
||||
err = data.get('error') or {}
|
||||
status = resp.status_code
|
||||
if status in _RETRYABLE_STATUS and attempt < self.gemini_max_retries - 1:
|
||||
backoff = self.gemini_retry_backoff * (2 ** attempt)
|
||||
logger.warning(
|
||||
f"Gemini key 第 {attempt+1}/{self.gemini_max_retries} 次失败"
|
||||
f"(status={status}),{backoff:.1f}s 后重试: {err}")
|
||||
time.sleep(backoff)
|
||||
continue
|
||||
logger.warning(f"Gemini 人物识别失败(status={status}): {err}")
|
||||
break # 这个 key 不行了,换下一个 key
|
||||
text = ''.join(
|
||||
p.get('text', '')
|
||||
for p in data['candidates'][0].get('content', {}).get('parts', []))
|
||||
person = self._extract_json_person(text)
|
||||
if person:
|
||||
logger.info(f"Gemini 人物识别: {person}")
|
||||
return person
|
||||
except requests.RequestException as e:
|
||||
if attempt < self.gemini_max_retries - 1:
|
||||
backoff = self.gemini_retry_backoff * (2 ** attempt)
|
||||
logger.warning(
|
||||
f"Gemini 网络异常,{backoff:.1f}s 后重试(第 {attempt+1}/"
|
||||
f"{self.gemini_max_retries} 次): {e}")
|
||||
time.sleep(backoff)
|
||||
continue
|
||||
logger.warning(f"Gemini 人物识别异常: {e}")
|
||||
break
|
||||
return None
|
||||
@@ -1,35 +1,136 @@
|
||||
"""
|
||||
QA - 智能问答编排
|
||||
QA - 智能问答代理客户端(2026-08-23 问答链路整体抽离到独立 ai-gateway 服务后重写)
|
||||
|
||||
run_qa(prompt): 按 models 顺序尝试 chat(),首个成功返回 (answer, provider)。
|
||||
顺序 = vision 模型(Gemini -> NVIDIA) + text 模型(Ollama 兜底)。
|
||||
即 Gemini -> NVIDIA -> Ollama 三级降级。
|
||||
原来的问答编排本体(NVIDIA 文字模型链 -> Gemini 非 flash 文字模型 -> 本地
|
||||
Ollama 兜底,含 key 轮换/熔断/降级)已经整个搬到独立的 ai-gateway 服务
|
||||
(OpenAI 兼容协议 /v1/chat/completions),跟视频分析业务解耦,别的项目也能
|
||||
直接用 OpenAI SDK 接入。fam-edge 这边现在只是一个转发客户端:调 ai-gateway,
|
||||
把它的 OpenAI 格式响应翻译回 fam-edge 原有的 (answer, provider) / 流式事件
|
||||
字典契约,上层 api_gateway.py 的 /api/edge/chat/ask(/stream) 端点和 fam-core
|
||||
的调用方完全不用改。
|
||||
|
||||
多 provider 之间失败降级(一个模型没吐出任何内容才换下一个、已经开始吐字后
|
||||
中途失败不悄悄换源)现在整个发生在 ai-gateway 内部,对这个客户端不可见——
|
||||
本客户端只会看到最终成功 provider 的分块流,或者全部失败时的空流。
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
from typing import Optional, Tuple
|
||||
|
||||
import requests
|
||||
|
||||
from .logger import setup_logger
|
||||
from .config_loader import load_config
|
||||
from .model_adapters.adapter_factory import build_adapters
|
||||
|
||||
logger = setup_logger('fam-edge.qa')
|
||||
|
||||
|
||||
class QAOrchestrator:
|
||||
def __init__(self):
|
||||
self.config = load_config()
|
||||
self.adapters = build_adapters(self.config.get('models', []))
|
||||
cfg = load_config().get('ai_gateway', {})
|
||||
self.base_url = (cfg.get('base_url') or 'http://127.0.0.1:5100').rstrip('/')
|
||||
self.token = self._resolve_token(cfg.get('token', ''))
|
||||
self.timeout = cfg.get('timeout', 60)
|
||||
|
||||
def _resolve_token(self, raw: str) -> str:
|
||||
if raw.startswith('${') and raw.endswith('}'):
|
||||
return os.environ.get(raw[2:-1], '')
|
||||
return raw
|
||||
|
||||
def _headers(self) -> dict:
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if self.token:
|
||||
headers["Authorization"] = f"Bearer {self.token}"
|
||||
return headers
|
||||
|
||||
def run_qa(self, prompt: str,
|
||||
max_tokens: int = 1024) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""依次尝试各适配器的 chat(),返回 (answer, provider)。"""
|
||||
for adapter in self.adapters:
|
||||
try:
|
||||
answer = adapter.chat(prompt, max_tokens=max_tokens)
|
||||
except Exception as e:
|
||||
logger.warning(f"QA {adapter.provider_name} 异常: {e}")
|
||||
continue
|
||||
if answer:
|
||||
logger.info(f"QA 命中 provider={adapter.provider_name}")
|
||||
return answer, adapter.provider_name
|
||||
logger.info(f"QA {adapter.provider_name} 无返回,降级下一模型")
|
||||
return None, None
|
||||
"""调 ai-gateway 非流式接口,返回 (answer, provider)。"""
|
||||
try:
|
||||
resp = requests.post(
|
||||
f"{self.base_url}/v1/chat/completions",
|
||||
headers=self._headers(),
|
||||
json={"messages": [{"role": "user", "content": prompt}],
|
||||
"max_tokens": max_tokens, "stream": False},
|
||||
timeout=self.timeout)
|
||||
except Exception as e:
|
||||
logger.warning(f"QA ai-gateway 请求异常: {e}")
|
||||
return None, None
|
||||
if resp.status_code != 200:
|
||||
logger.warning(f"QA ai-gateway 返回 {resp.status_code}: {resp.text[:200]}")
|
||||
return None, None
|
||||
try:
|
||||
data = resp.json()
|
||||
answer = data["choices"][0]["message"]["content"]
|
||||
except Exception as e:
|
||||
logger.warning(f"QA ai-gateway 响应解析失败: {e}")
|
||||
return None, None
|
||||
if not answer:
|
||||
return None, None
|
||||
provider = data.get("provider")
|
||||
logger.info(f"QA 命中 provider={provider}")
|
||||
return answer, provider
|
||||
|
||||
def run_qa_stream(self, prompt: str, max_tokens: int = 1024):
|
||||
"""流式版:转发 ai-gateway 的 SSE 分块,翻译回原有事件字典契约。
|
||||
|
||||
事件类型:
|
||||
{"type":"provider_trying","provider":p} 流里第一次看到这个 provider
|
||||
{"type":"chunk","provider":p,"text":t} 文本增量
|
||||
{"type":"done","provider":p} 成功结束(至少吐出过一块)
|
||||
{"type":"all_failed"} 请求失败或没有任何文本产出
|
||||
"""
|
||||
try:
|
||||
resp = requests.post(
|
||||
f"{self.base_url}/v1/chat/completions",
|
||||
headers=self._headers(),
|
||||
json={"messages": [{"role": "user", "content": prompt}],
|
||||
"max_tokens": max_tokens, "stream": True},
|
||||
timeout=self.timeout, stream=True)
|
||||
except Exception as e:
|
||||
logger.warning(f"QA ai-gateway 流式请求异常: {e}")
|
||||
yield {"type": "all_failed"}
|
||||
return
|
||||
if resp.status_code != 200:
|
||||
logger.warning(f"QA ai-gateway 流式返回 {resp.status_code}: {resp.text[:200]}")
|
||||
yield {"type": "all_failed"}
|
||||
return
|
||||
# 响应体固定 UTF-8,但 Content-Type 不一定带 charset,requests 会自己猜
|
||||
# 编码——猜错就是中文乱码,强制指定跳过嗅探(同源坑见 gemini_adapter 历史修复)。
|
||||
resp.encoding = 'utf-8'
|
||||
|
||||
current_provider = None
|
||||
got_any = False
|
||||
try:
|
||||
for line in resp.iter_lines(decode_unicode=True):
|
||||
if not line or not line.startswith('data: '):
|
||||
continue
|
||||
payload = line[len('data: '):]
|
||||
if payload == '[DONE]':
|
||||
break
|
||||
try:
|
||||
chunk = json.loads(payload)
|
||||
except ValueError:
|
||||
continue
|
||||
if 'error' in chunk:
|
||||
logger.warning(f"QA ai-gateway 流式错误: {chunk['error']}")
|
||||
break
|
||||
provider = chunk.get('provider')
|
||||
if provider and provider != current_provider:
|
||||
current_provider = provider
|
||||
yield {"type": "provider_trying", "provider": provider}
|
||||
choices = chunk.get('choices') or []
|
||||
if not choices:
|
||||
continue
|
||||
text = (choices[0].get('delta') or {}).get('content')
|
||||
if text:
|
||||
got_any = True
|
||||
yield {"type": "chunk", "provider": current_provider, "text": text}
|
||||
except Exception as e:
|
||||
logger.warning(f"QA ai-gateway 流式读取异常: {e}")
|
||||
|
||||
if got_any:
|
||||
logger.info(f"QA 流式命中 provider={current_provider}")
|
||||
yield {"type": "done", "provider": current_provider}
|
||||
else:
|
||||
yield {"type": "all_failed"}
|
||||
|
||||
@@ -15,6 +15,7 @@ import os
|
||||
import re
|
||||
import json
|
||||
import subprocess
|
||||
import tempfile
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
@@ -22,7 +23,8 @@ from .logger import setup_logger
|
||||
from .config_loader import load_config
|
||||
from .model_adapters.adapter_factory import build_adapters
|
||||
from .model_adapters.base_adapter import BaseModelAdapter
|
||||
from .dsm_motion_client import DsmMotionClient
|
||||
from .person_identifier import PersonIdentifier
|
||||
from . import frame_service
|
||||
from . import oracle_db
|
||||
|
||||
logger = setup_logger('fam-edge.video_processor')
|
||||
@@ -191,10 +193,22 @@ class VideoProcessor:
|
||||
'file_validate', True))
|
||||
self.parse_start = self.config.get('gdrive_sync', {}).get(
|
||||
'parse_start_from_filename', True)
|
||||
# 运动预过滤"无运动"结论的可信心跳时限:NAS 推送心跳超过这么久没更新,
|
||||
# 就认为推送链路可能已经挂了,fail-open(照常分析)
|
||||
self.motion_max_heartbeat_age_sec = float(self.config.get(
|
||||
'dsm_motion_prefilter', {}).get('max_heartbeat_age_sec', 900))
|
||||
# 运动片段分割(运动事件驱动架构):整段素材按 ss_motion_events 分割片段再分析
|
||||
seg = self.config.get('motion_segment', {})
|
||||
self.motion_clips_dir = seg.get('clips_dir', '/opt/fam-edge/motion_clips')
|
||||
self.motion_keep_audio = bool(seg.get('keep_audio', True))
|
||||
self.motion_min_duration = float(seg.get('min_duration_sec', 1))
|
||||
self.motion_grace_sec = int(seg.get('unfinished_grace_sec', 10))
|
||||
# 闭集人物识别(家里固定 5 人):汤圆/媳妇/奶奶 用性别年龄规则;爷爷/爸爸 用
|
||||
# person_identifier 视觉大模型比对,每片段每个 uid 只调一次
|
||||
self.person_identifier = PersonIdentifier(self.config.get('person_identifier', {}))
|
||||
adapters = build_adapters(self.config.get('models', []))
|
||||
self.vision_adapters: Dict[str, BaseModelAdapter] = {
|
||||
a.provider_name: a for a in adapters if a.get_role() == 'vision'}
|
||||
self.dsm_motion = DsmMotionClient(self.config.get('dsm_motion_prefilter', {}))
|
||||
|
||||
def _ordered_vision_adapters(self) -> List[BaseModelAdapter]:
|
||||
ordered = []
|
||||
@@ -208,16 +222,21 @@ class VideoProcessor:
|
||||
return ordered
|
||||
|
||||
def process_video(self, video_id: int, filename: str, local_path: str,
|
||||
timeout_multiplier: float = 1.0) -> bool:
|
||||
"""处理一个视频记录,返回是否成功。
|
||||
timeout_multiplier: float = 1.0):
|
||||
"""处理一个视频记录。返回 (ok: bool, new_clip_ids: List[int])。
|
||||
|
||||
- 素材整段视频(无 motion_event_id):不再整段分析,而是按 ss_motion_events
|
||||
里【已结束】的运动事件分割成运动片段,返回片段 video_id 列表(调用方负责
|
||||
入队分析);素材记录在全部事件结束后标记 done,仍有未结束事件时保持
|
||||
pending 由生产者下轮重试。
|
||||
- 运动片段(有 motion_event_id):本身即运动时段,直接云端分析(跳过预过滤)。
|
||||
|
||||
timeout_multiplier: 云端模型消费的超时放大倍数(如 2 = 在配置 timeout 上 ×2)。
|
||||
每次调用前临时放大对应 adapter.timeout,调用后恢复,避免影响其他调用方。
|
||||
"""
|
||||
if not os.path.isfile(local_path):
|
||||
logger.error(f"[video_id={video_id}] 文件不存在,跳过: {local_path}")
|
||||
self.db.mark_video_failed(video_id, "file_missing")
|
||||
return False
|
||||
return False, []
|
||||
|
||||
# 处理前二次确认文件有效性(防止登记后文件被破坏/截断;校验结果落库)
|
||||
if self.file_validate:
|
||||
@@ -226,36 +245,29 @@ class VideoProcessor:
|
||||
logger.error(f"[video_id={video_id}] 文件校验失败({verr}),标记 failed: {local_path}")
|
||||
self.db.set_video_file_status(video_id, False, verr)
|
||||
self.db.mark_video_failed(video_id, f"invalid_file:{verr}")
|
||||
return False
|
||||
return False, []
|
||||
if vmeta:
|
||||
self.db.set_video_file_status(video_id, True, '', vmeta)
|
||||
else:
|
||||
vmeta = None
|
||||
|
||||
camera_name = self.db.get_video_by_filename(filename)['camera_name'] or ''
|
||||
event_start = ''
|
||||
if self.parse_start:
|
||||
vrow = self.db.get_video_by_filename(filename)
|
||||
is_motion_clip = bool(vrow and vrow['motion_event_id'])
|
||||
camera_name = vrow['camera_name'] if vrow else ''
|
||||
event_start = vrow['event_start_time'] if vrow else ''
|
||||
if not event_start and self.parse_start:
|
||||
event_start = _parse_event_start_from_filename(filename)
|
||||
# 回写解析到的开始时间
|
||||
if event_start:
|
||||
self.db.set_event_start_time(video_id, event_start)
|
||||
|
||||
# DSM 运动侦测预过滤:这段时间窗口里群晖自己记录的运动事件一条都没有,
|
||||
# 就跳过云端分析(省配额)。查询本身失败/未配置一律 fail-open(照常分析),
|
||||
# 绝不能因为这层可选优化漏检真实事件。
|
||||
duration_sec = (vmeta or {}).get('duration_sec') if self.file_validate else None
|
||||
if event_start and duration_sec:
|
||||
try:
|
||||
start_dt = datetime.strptime(event_start, '%Y-%m-%d %H:%M:%S')
|
||||
has_motion = self.dsm_motion.has_motion_in_range(start_dt, duration_sec)
|
||||
except ValueError:
|
||||
has_motion = None
|
||||
if has_motion is False:
|
||||
logger.info(f"[video_id={video_id}] DSM 运动预过滤:该时段无运动,跳过云端分析")
|
||||
self.db.mark_video_processed(
|
||||
video_id, "(自动跳过:该时段未检测到运动)", [], [], 'skipped_no_motion')
|
||||
return True
|
||||
if not is_motion_clip:
|
||||
# ---- 素材整段视频:分割成运动片段,不整段分析 ----
|
||||
return self._segment_source_video(
|
||||
video_id, local_path, event_start, camera_name, vmeta)
|
||||
|
||||
# ---- 运动片段:本身即运动时段,直接云端分析(跳过运动预过滤)----
|
||||
known = self.db.get_known_members_context()
|
||||
logger.info(f"[video_id={video_id}] 开始整视频分析: {filename} "
|
||||
logger.info(f"[video_id={video_id}] 开始运动片段分析: {filename} "
|
||||
f"(event_start={event_start}, known_members={'有' if known else '无'})")
|
||||
|
||||
last_err = "no_vision_adapter"
|
||||
@@ -271,7 +283,7 @@ class VideoProcessor:
|
||||
logger.info(f"[video_id={video_id}] {adapter.provider_name} 超时 "
|
||||
f"{orig_timeout}s -> {adapter.timeout}s (×{timeout_multiplier})")
|
||||
try:
|
||||
logger.info(f"[video_id={video_id}] 尝试 {adapter.provider_name} 整视频分析")
|
||||
logger.info(f"[video_id={video_id}] 尝试 {adapter.provider_name} 运动片段分析")
|
||||
result = adapter.analyze_video(local_path, known, event_start)
|
||||
except Exception as e:
|
||||
logger.error(f"[video_id={video_id}] {adapter.provider_name} 异常: {e}")
|
||||
@@ -281,14 +293,117 @@ class VideoProcessor:
|
||||
adapter.timeout = orig_timeout
|
||||
if result:
|
||||
self._store_result(video_id, result)
|
||||
return True
|
||||
return True, []
|
||||
else:
|
||||
last_err = f"{adapter.provider_name}_failed"
|
||||
logger.warning(f"[video_id={video_id}] {adapter.provider_name} 未返回结果,降级下一模型")
|
||||
|
||||
logger.error(f"[video_id={video_id}] 所有视觉模型失败,标记 failed: {last_err}")
|
||||
self.db.mark_video_failed(video_id, last_err)
|
||||
return False
|
||||
return False, []
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 运动片段分割(运动事件驱动架构)
|
||||
# ------------------------------------------------------------------
|
||||
def _segment_source_video(self, video_id: int, local_path: str, event_start: str,
|
||||
camera_name: str, vmeta: dict):
|
||||
"""整段素材 -> 按已结束运动事件分割运动片段。返回 (ok, clip_ids)。
|
||||
|
||||
素材视频不再整段送云端分析;仅按运动事件窗口切出片段交给下游分析。
|
||||
"""
|
||||
if not event_start:
|
||||
logger.info(f"[video_id={video_id}] 素材无开始时间,无法对齐运动事件,标记 done")
|
||||
self.db.mark_video_processed(
|
||||
video_id, "(素材无开始时间,无法分割运动片段)", [], [], 'motion_segment')
|
||||
return True, []
|
||||
try:
|
||||
start_dt = datetime.strptime(event_start, '%Y-%m-%d %H:%M:%S').replace(
|
||||
tzinfo=timezone(timedelta(hours=8)))
|
||||
start_ts = int(start_dt.timestamp())
|
||||
except ValueError:
|
||||
logger.warning(f"[video_id={video_id}] 素材开始时间解析失败: {event_start}")
|
||||
self.db.mark_video_processed(
|
||||
video_id, "(素材开始时间解析失败,无法分割)", [], [], 'motion_segment')
|
||||
return True, []
|
||||
if vmeta is None:
|
||||
ok, verr, vmeta = validate_video(local_path)
|
||||
if not ok:
|
||||
self.db.set_video_file_status(video_id, False, verr)
|
||||
self.db.mark_video_failed(video_id, f"invalid_file:{verr}")
|
||||
return False, []
|
||||
dur_sec = float((vmeta or {}).get('duration_sec') or 0)
|
||||
end_ts = start_ts + int(dur_sec) if dur_sec > 0 else start_ts + 3600
|
||||
clip_ids = self._segment_motion_clips(local_path, start_ts, end_ts, camera_name)
|
||||
if self.db.has_unfinished_motion_in_range(start_ts, end_ts):
|
||||
# 仍有进行中的事件(duration 未定型):保持 pending,producer 下轮重试
|
||||
logger.info(f"[video_id={video_id}] 素材仍有未结束运动事件,保持 pending 下轮再分割")
|
||||
return True, clip_ids
|
||||
self.db.mark_video_processed(
|
||||
video_id, f"(整段素材已分割 {len(clip_ids)} 段运动片段)", [], [], 'motion_segment')
|
||||
return True, clip_ids
|
||||
|
||||
def _segment_motion_clips(self, src_path: str, start_ts: int, end_ts: int,
|
||||
camera_name: str) -> List[int]:
|
||||
"""按窗口内已结束运动事件从整段素材分割运动片段。返回片段 video_id 列表。
|
||||
|
||||
幂等:已按 motion_event_id 生成过片段的跳过;片段文件已存在的跳过分割。
|
||||
"""
|
||||
clips: List[int] = []
|
||||
for me in self.db.get_motion_events_in_range(
|
||||
start_ts, end_ts, finished_grace_sec=self.motion_grace_sec):
|
||||
eid = me['event_id']
|
||||
if self.db.get_video_by_motion_event_id(eid):
|
||||
continue
|
||||
dur = float(me.get('duration') or 0)
|
||||
if dur < self.motion_min_duration:
|
||||
continue
|
||||
offset = float(me['start_time'] - start_ts)
|
||||
if offset < -5 or offset > (end_ts - start_ts) + 5:
|
||||
continue # 事件窗口与素材窗口无重叠
|
||||
offset = max(0.0, offset)
|
||||
os.makedirs(self.motion_clips_dir, exist_ok=True)
|
||||
clip_fn = f"motion_{eid}_{me['start_time']}.mp4"
|
||||
clip_path = os.path.join(self.motion_clips_dir, clip_fn)
|
||||
if not os.path.isfile(clip_path):
|
||||
if not self._run_segment_ffmpeg(src_path, offset, dur, clip_path):
|
||||
logger.error(f"运动片段分割失败: {clip_fn}")
|
||||
continue
|
||||
start_iso = datetime.fromtimestamp(
|
||||
me['start_time'], tz=timezone(timedelta(hours=8))
|
||||
).strftime('%Y-%m-%d %H:%M:%S')
|
||||
cid = self.db.ensure_video(
|
||||
filename=clip_fn, local_path=clip_path, camera_name=camera_name,
|
||||
event_start_time=start_iso, duration_sec=dur,
|
||||
drive_file_id=f"motion_{eid}", motion_event_id=eid,
|
||||
camera_id=me.get('camera_id'))
|
||||
self.db.record_activity('segment', 'clip', f"{clip_fn} (event={eid})")
|
||||
clips.append(cid)
|
||||
return clips
|
||||
|
||||
def _run_segment_ffmpeg(self, src_path: str, offset: float, dur: float,
|
||||
out_path: str) -> bool:
|
||||
"""ffmpeg 从整段素材切运动片段:-ss/-t 定位,视频 copy 免转码,
|
||||
音频按配置保留(pcm_alaw -> aac 转码)或丢弃(-an)。"""
|
||||
import shutil
|
||||
ffmpeg = shutil.which('ffmpeg') or 'ffmpeg'
|
||||
args = [ffmpeg, '-y', '-hide_banner', '-loglevel', 'error',
|
||||
'-ss', f'{offset:.3f}', '-i', src_path, '-t', f'{dur:.3f}',
|
||||
'-c:v', 'copy']
|
||||
if self.motion_keep_audio:
|
||||
args += ['-c:a', 'aac', '-b:a', '64k']
|
||||
else:
|
||||
args += ['-an']
|
||||
args += ['-avoid_negative_ts', 'make_zero', out_path]
|
||||
try:
|
||||
proc = subprocess.run(args, capture_output=True, timeout=300)
|
||||
if proc.returncode != 0:
|
||||
logger.error(f"ffmpeg 分割失败: "
|
||||
f"{proc.stderr.decode(errors='ignore')[:200]}")
|
||||
return False
|
||||
return os.path.isfile(out_path) and os.path.getsize(out_path) > 0
|
||||
except (subprocess.TimeoutExpired, OSError) as e:
|
||||
logger.error(f"ffmpeg 分割异常: {e}")
|
||||
return False
|
||||
|
||||
def _store_result(self, video_id: int, result: Dict):
|
||||
events = result.get('events', [])
|
||||
@@ -368,13 +483,131 @@ class VideoProcessor:
|
||||
elif k not in merged:
|
||||
merged[k] = v_str or 'unknown'
|
||||
uid_features[uid] = merged
|
||||
# 闭集人物识别(家里固定 4 人):汤圆/媳妇 用性别年龄规则免费识别(source=rule);
|
||||
# 爷爷/爸爸 每个 uid(同一片段内视为同一人,不逐事件重复调用)用视觉大模型
|
||||
# 比对一次(source=auto_id)。resolved: {原始 uid: (规范名, 来源)}
|
||||
resolved = self._resolve_closed_set_identities(video_id, uid_features, norm_events)
|
||||
|
||||
# 人物对应关系表:记录 (video_id, raw_uid) -> canonical_name,并把这段视频
|
||||
# 展示用的 events/videos 数据直接重写成规范名(读的时候不用现查表拼接)。
|
||||
# manual 纠正过的映射受保护,这里不会覆盖。
|
||||
rename_map = {}
|
||||
for uid, (canonical, source) in resolved.items():
|
||||
if self.db.set_identity_mapping(video_id, uid, canonical, source=source):
|
||||
rename_map[uid] = canonical
|
||||
if rename_map:
|
||||
self.db.rewrite_event_person_names(video_id, rename_map)
|
||||
|
||||
for p in people:
|
||||
if p and p not in ('无人', '无'):
|
||||
feats = uid_features.get(p)
|
||||
canonical, source = resolved.get(p, ('', 'llm'))
|
||||
# 已解析的人物直接用规范名作为 people 表的 label,跨视频天然汇总到
|
||||
# 同一行;解析不了的沿用原始 uid(跟旧行为一致,留给下次/人工确认)
|
||||
label = canonical or p
|
||||
if feats:
|
||||
self.db.upsert_person(p, source='llm', features=feats, display_uid=p)
|
||||
self.db.upsert_person(label, canonical_name=canonical, source=source,
|
||||
features=feats, display_uid=p)
|
||||
else:
|
||||
self.db.upsert_person(p, source='llm')
|
||||
self.db.upsert_person(label, canonical_name=canonical, source=source)
|
||||
logger.info(f"[video_id={video_id}] 已落库: summary={len(summary)}字, "
|
||||
f"events={len(norm_events)}, people={people}, "
|
||||
f"with_features={len(uid_features)}")
|
||||
f"with_features={len(uid_features)}, 闭集识别={resolved}")
|
||||
|
||||
# 家里赤膊/赤裸上身的成年人只有爸爸一个(用户原话:"赤裸的大人都是爸爸,
|
||||
# 家里没有其他人会赤裸")——命中即免费直判,不用等视觉大模型比对,比
|
||||
# auto_id 更快更准(实测已经纠正过一条被 auto_id 误判成爷爷的案例)。
|
||||
# 只匹配成年人(外层已经先过滤掉 age_band 是幼儿/儿童的),小孩光膀子玩
|
||||
# 很正常,不适用这条规则。
|
||||
_SHIRTLESS_KEYWORDS = ('赤裸', '光着上身', '赤膊', '裸体', '光膀子',
|
||||
'上身赤裸', '未穿上衣')
|
||||
|
||||
def _resolve_closed_set_identities(self, video_id: int, uid_features: Dict,
|
||||
norm_events: List[Dict]) -> Dict[str, tuple]:
|
||||
"""闭集人物识别:返回 {uid: (canonical_name, source)}。
|
||||
|
||||
汤圆(幼儿/儿童特征)、媳妇/奶奶(成年女性按年龄段区分:老年=奶奶,
|
||||
其余=媳妇)、赤膊成年人(家里只有爸爸会赤膊)靠 Gemini 已经产出的
|
||||
性别/年龄/衣着字段直接判断(source='rule'),验证过命中率接近
|
||||
100%,不需要额外模型调用。爷爷/爸爸两个成年男性穿戴整齐时外观规则/
|
||||
人脸向量都区分不开(验证过),改用视觉大模型比对参考图
|
||||
(source='auto_id'),每个 uid 只取本片段内最大 bbox 的一次出现判断
|
||||
一次,不逐事件重复调用。
|
||||
|
||||
2026-08-29 新增奶奶:家里从 4 人变成 5 人后,"媳妇=唯一成年女性"
|
||||
这条规则的前提被打破了(现在有两个成年女性)。先用免费的年龄段
|
||||
字段区分(老年=奶奶,参考 Gemini 对"爷爷"的判断也是老年)——如果
|
||||
后续观察发现年龄段判断不稳定导致误判,再考虑改成跟爷爷/爸爸一样
|
||||
的视觉比对方案。
|
||||
"""
|
||||
resolved: Dict[str, tuple] = {}
|
||||
for uid, feats in uid_features.items():
|
||||
gender = str(feats.get('gender', '') or '').strip()
|
||||
age_band = str(feats.get('age_band', '') or '').strip()
|
||||
clothing = str(feats.get('clothing', '') or '')
|
||||
if age_band in ('幼儿', '儿童'):
|
||||
resolved[uid] = ('汤圆', 'rule')
|
||||
elif gender == '女':
|
||||
resolved[uid] = ('奶奶' if age_band == '老年' else '媳妇', 'rule')
|
||||
elif gender == '男':
|
||||
if any(k in clothing for k in self._SHIRTLESS_KEYWORDS):
|
||||
resolved[uid] = ('爸爸', 'rule')
|
||||
continue
|
||||
crop = self._best_crop_for_uid(video_id, uid, norm_events)
|
||||
if crop:
|
||||
person = self.person_identifier.classify_adult_male(crop)
|
||||
if person:
|
||||
resolved[uid] = (person, 'auto_id')
|
||||
return resolved
|
||||
|
||||
def _best_crop_for_uid(self, video_id: int, uid: str, norm_events: List[Dict]) -> Optional[bytes]:
|
||||
"""取该 uid 在本片段里最大 bbox 的一次出现,裁剪成一张人物截图(jpeg bytes)。
|
||||
|
||||
bbox 缺失时(实测偶发:某些云端响应——尤其 flash-lite 兜底——没有带
|
||||
person_appearances.bbox 字段)回退到该 uid 第一次出现时刻的整帧居中裁剪,
|
||||
跟 build_avatar() 已有的兜底逻辑一致,好过直接放弃识别这个人。
|
||||
"""
|
||||
best_ts, best_bbox, best_area = None, None, 0
|
||||
first_ts = None
|
||||
for ev in norm_events:
|
||||
for pa in ev.get('person_appearances', []):
|
||||
if pa.get('uid') != uid:
|
||||
continue
|
||||
if first_ts is None:
|
||||
first_ts = ev.get('timestamp')
|
||||
bbox = pa.get('bbox')
|
||||
if not bbox or len(bbox) != 4:
|
||||
continue
|
||||
ymin, xmin, ymax, xmax = bbox
|
||||
area = max(0, ymax - ymin) * max(0, xmax - xmin)
|
||||
if area > best_area:
|
||||
best_area, best_ts, best_bbox = area, ev.get('timestamp'), bbox
|
||||
if best_ts is None and first_ts is None:
|
||||
return None
|
||||
frame = frame_service.extract_frame(self.db, video_id, best_ts or first_ts, width=2880)
|
||||
if frame is None:
|
||||
return None
|
||||
frame_service._ensure_dir()
|
||||
fd, tmp = tempfile.mkstemp(suffix='.jpg', dir=frame_service.CACHE_DIR)
|
||||
os.close(fd)
|
||||
try:
|
||||
with open(tmp, 'wb') as f:
|
||||
f.write(frame)
|
||||
size = frame_service._out_size(tmp)
|
||||
if not size:
|
||||
return None
|
||||
if best_bbox is not None:
|
||||
px = frame_service._bbox_to_pixels(best_bbox, *size)
|
||||
ok = frame_service._crop_ffmpeg(tmp, px, 300)
|
||||
else:
|
||||
ok = frame_service._center_square_ffmpeg(tmp, 300)
|
||||
if not ok:
|
||||
return None
|
||||
with open(tmp, 'rb') as f:
|
||||
return f.read()
|
||||
finally:
|
||||
if os.path.exists(tmp):
|
||||
try:
|
||||
os.remove(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
@@ -84,7 +84,15 @@ class VideoQueue:
|
||||
if row is None:
|
||||
# 新文件:先过 mtime 稳定窗口 + 可解码校验,通过才登记入队;失败标记 invalid
|
||||
if self.file_validate:
|
||||
if time.time() - os.path.getmtime(path) < self.stable_window_sec:
|
||||
try:
|
||||
mtime = os.path.getmtime(path)
|
||||
except OSError:
|
||||
# 列目录之后、读 mtime 之前,DiskGuard 可能刚好把这个文件清掉了
|
||||
# (两个后台线程的正常竞态)。跳过它就行——不 catch 的话整轮扫描
|
||||
# 会被这一个文件中断,后面的新素材本轮都登记不上。
|
||||
logger.info(f"文件已不在(可能刚被 DiskGuard 清理),跳过本轮: {fn}")
|
||||
continue
|
||||
if time.time() - mtime < self.stable_window_sec:
|
||||
logger.info(f"文件仍在写入(mtime 未稳定),跳过本轮: {fn}")
|
||||
continue
|
||||
ok, verr, vmeta = validate_video(path)
|
||||
@@ -214,9 +222,12 @@ class VideoQueue:
|
||||
"started_at": None}
|
||||
self.db.record_activity('queue', 'process_start', f"video {video_id} {fn}")
|
||||
try:
|
||||
ok = processor.process_video(
|
||||
ok, clip_ids = processor.process_video(
|
||||
video_id, fn, row['local_path'],
|
||||
timeout_multiplier=self.timeout_multiplier)
|
||||
# 素材整段视频分割出的运动片段入队分析(片段不在监听目录,需手动入队)
|
||||
for cid in (clip_ids or []):
|
||||
self._enqueue(cid)
|
||||
if ok:
|
||||
self._stats["consumed_ok"] += 1
|
||||
self.db.record_activity('queue', 'process_done', f"video {video_id} {fn}")
|
||||
|
||||
241
fam-edge/tests/test_auth.py
Normal file
241
fam-edge/tests/test_auth.py
Normal file
@@ -0,0 +1,241 @@
|
||||
"""统一登录(2026-09-12 从 NAS fam-core 迁来)的单测。
|
||||
|
||||
除了把原 fam-core/tests/test_auth.py 的用例搬过来,额外盯死三件迁移时的关键行为:
|
||||
回调失败不准再 302(防死循环复发)、服务端调 auth-hub 走内网地址但 iss 仍按公网校验、
|
||||
会话 cookie 必须是无状态的(不依赖任何进程内状态)。
|
||||
"""
|
||||
import time
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
from flask import Flask
|
||||
|
||||
from fam_edge import auth
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_env_and_state(monkeypatch):
|
||||
for var in ('AUTH_HUB_ISSUER', 'AUTH_HUB_INTERNAL_BASE', 'AUTH_HUB_CLIENT_ID',
|
||||
'AUTH_HUB_CLIENT_SECRET', 'AUTH_HUB_REDIRECT_URI', 'FAM_SESSION_SECRET'):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
auth._pending.clear()
|
||||
auth._warned_unconfigured = False
|
||||
auth._jwks_client = None
|
||||
yield
|
||||
auth._pending.clear()
|
||||
|
||||
|
||||
def _set_env(monkeypatch, internal_base=None):
|
||||
monkeypatch.setenv('AUTH_HUB_ISSUER', 'https://auth.example')
|
||||
monkeypatch.setenv('AUTH_HUB_CLIENT_ID', 'fam-core')
|
||||
monkeypatch.setenv('AUTH_HUB_CLIENT_SECRET', 'sekret')
|
||||
monkeypatch.setenv('AUTH_HUB_REDIRECT_URI', 'https://cam.example/api/auth/callback')
|
||||
monkeypatch.setenv('FAM_SESSION_SECRET', 'session-signing-secret')
|
||||
if internal_base:
|
||||
monkeypatch.setenv('AUTH_HUB_INTERNAL_BASE', internal_base)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
app = Flask(__name__)
|
||||
app.register_blueprint(auth.auth_bp)
|
||||
return app.test_client()
|
||||
|
||||
|
||||
def _cookie(secret='session-signing-secret', username='ericwyuan', ttl=3600):
|
||||
now = int(time.time())
|
||||
return jwt.encode({'sub': '1', 'username': username, 'iat': now, 'exp': now + ttl},
|
||||
secret, algorithm='HS256')
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 配置齐全性(fail closed)
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_require_config_fails_closed_when_unconfigured():
|
||||
assert auth._require_config() is None
|
||||
|
||||
|
||||
def test_require_config_fails_closed_without_session_secret(monkeypatch):
|
||||
_set_env(monkeypatch)
|
||||
monkeypatch.delenv('FAM_SESSION_SECRET')
|
||||
assert auth._require_config() is None
|
||||
|
||||
|
||||
def test_login_rejects_when_unconfigured(client):
|
||||
resp = client.get('/login')
|
||||
assert resp.status_code == 503
|
||||
# 配置缺失也不能跳转,否则同样会跟 auth-hub 对跳
|
||||
assert 'Location' not in resp.headers
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /login
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_login_redirects_to_authorize_with_pkce(client, monkeypatch):
|
||||
_set_env(monkeypatch)
|
||||
resp = client.get('/login')
|
||||
assert resp.status_code == 302
|
||||
location = resp.headers['Location']
|
||||
assert location.startswith('https://auth.example/authorize?')
|
||||
assert 'code_challenge=' in location
|
||||
assert 'code_challenge_method=S256' in location
|
||||
assert 'client_id=fam-core' in location
|
||||
assert len(auth._pending) == 1
|
||||
|
||||
|
||||
def test_login_always_sends_browser_to_public_issuer(client, monkeypatch):
|
||||
"""内网地址只给服务端自己用,浏览器必须跳公网——跳 127.0.0.1 用户当然打不开。"""
|
||||
_set_env(monkeypatch, internal_base='http://127.0.0.1:5300')
|
||||
resp = client.get('/login')
|
||||
assert resp.headers['Location'].startswith('https://auth.example/authorize?')
|
||||
|
||||
|
||||
def test_login_redirects_home_when_already_authed(client, monkeypatch):
|
||||
_set_env(monkeypatch)
|
||||
client.set_cookie('fam_session', _cookie())
|
||||
resp = client.get('/login')
|
||||
assert resp.status_code == 302
|
||||
assert resp.headers['Location'] == '/'
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 回调:失败分支一律错误页,绝不再跳 /login(旧实现死循环的根因)
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_callback_unknown_state_shows_error_page_without_redirecting(client, monkeypatch):
|
||||
_set_env(monkeypatch)
|
||||
resp = client.get('/api/auth/callback?state=nope&code=abc')
|
||||
assert resp.status_code == 400
|
||||
assert 'Location' not in resp.headers
|
||||
assert 'fam_session' not in resp.headers.get('Set-Cookie', '')
|
||||
|
||||
|
||||
def test_callback_idp_error_shows_error_page_without_redirecting(client, monkeypatch):
|
||||
_set_env(monkeypatch)
|
||||
resp = client.get('/api/auth/callback?error=access_denied&state=x')
|
||||
assert resp.status_code == 403
|
||||
assert 'Location' not in resp.headers
|
||||
|
||||
|
||||
def test_callback_token_failure_shows_error_page_without_redirecting(client, monkeypatch):
|
||||
_set_env(monkeypatch)
|
||||
auth._pending['thestate'] = {'verifier': 'v', 'expires': time.time() + 600}
|
||||
|
||||
class _Resp:
|
||||
status_code = 400
|
||||
text = 'invalid_grant'
|
||||
monkeypatch.setattr(auth.requests, 'post', lambda *a, **k: _Resp())
|
||||
|
||||
resp = client.get('/api/auth/callback?state=thestate&code=abc')
|
||||
assert resp.status_code == 502
|
||||
assert 'Location' not in resp.headers
|
||||
assert 'fam_session' not in resp.headers.get('Set-Cookie', '')
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 回调:成功路径
|
||||
# ---------------------------------------------------------------------------
|
||||
def _stub_successful_exchange(monkeypatch, captured):
|
||||
class _Resp:
|
||||
status_code = 200
|
||||
text = ''
|
||||
def json(self):
|
||||
return {'id_token': 'the-id-token', 'access_token': 'x'}
|
||||
|
||||
def _post(url, **kwargs):
|
||||
captured['token_url'] = url
|
||||
return _Resp()
|
||||
monkeypatch.setattr(auth.requests, 'post', _post)
|
||||
|
||||
class _FakeKey:
|
||||
key = 'unused'
|
||||
|
||||
class _FakeJwks:
|
||||
uri = 'unused'
|
||||
def get_signing_key_from_jwt(self, token):
|
||||
return _FakeKey()
|
||||
|
||||
def _jwks(uri):
|
||||
captured['jwks_uri'] = uri
|
||||
return _FakeJwks()
|
||||
monkeypatch.setattr(auth, '_get_jwks_client', _jwks)
|
||||
|
||||
real_decode = auth.jwt.decode
|
||||
|
||||
def _decode(token, key, **kw):
|
||||
# id_token 走 RS256:记下校验参数并返回固定 claims;
|
||||
# 会话 cookie 走 HS256:交给真正的实现,别把验签也 mock 掉
|
||||
if kw.get('algorithms') == ['RS256']:
|
||||
captured['decode_kwargs'] = kw
|
||||
return {'sub': '1', 'preferred_username': 'ericwyuan'}
|
||||
return real_decode(token, key, **kw)
|
||||
monkeypatch.setattr(auth.jwt, 'decode', _decode)
|
||||
|
||||
|
||||
def test_callback_success_sets_cookie_and_goes_home(client, monkeypatch):
|
||||
_set_env(monkeypatch)
|
||||
auth._pending['thestate'] = {'verifier': 'v', 'expires': time.time() + 600}
|
||||
captured = {}
|
||||
_stub_successful_exchange(monkeypatch, captured)
|
||||
|
||||
resp = client.get('/api/auth/callback?state=thestate&code=abc')
|
||||
assert resp.status_code == 302
|
||||
assert resp.headers['Location'] == '/'
|
||||
assert 'fam_session=' in resp.headers['Set-Cookie']
|
||||
assert 'HttpOnly' in resp.headers['Set-Cookie']
|
||||
assert 'thestate' not in auth._pending # 用过的 state 必须立刻作废
|
||||
|
||||
|
||||
def test_callback_talks_to_internal_base_but_validates_public_issuer(client, monkeypatch):
|
||||
"""换 token / 拉 JWKS 走本机,避免公网 TLS 那条链路上的 CA 和时钟坑;
|
||||
但 id_token 里的 iss 是 auth-hub 配置的公网地址,校验必须按公网来。"""
|
||||
_set_env(monkeypatch, internal_base='http://127.0.0.1:5300')
|
||||
auth._pending['thestate'] = {'verifier': 'v', 'expires': time.time() + 600}
|
||||
captured = {}
|
||||
_stub_successful_exchange(monkeypatch, captured)
|
||||
|
||||
client.get('/api/auth/callback?state=thestate&code=abc')
|
||||
assert captured['token_url'] == 'http://127.0.0.1:5300/token'
|
||||
assert captured['jwks_uri'] == 'http://127.0.0.1:5300/.well-known/jwks.json'
|
||||
assert captured['decode_kwargs']['issuer'] == 'https://auth.example'
|
||||
assert captured['decode_kwargs']['audience'] == 'fam-core'
|
||||
assert captured['decode_kwargs']['leeway'] == auth._LEEWAY
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 会话 cookie / check / verify / logout
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_session_is_stateless(client, monkeypatch):
|
||||
"""cookie 自带签名,服务端不存任何东西——换个进程、重启服务照样认。"""
|
||||
_set_env(monkeypatch)
|
||||
assert auth._pending == {}
|
||||
client.set_cookie('fam_session', _cookie())
|
||||
assert client.get('/api/auth/verify').status_code == 204
|
||||
|
||||
|
||||
def test_verify_rejects_missing_expired_and_forged_cookies(client, monkeypatch):
|
||||
_set_env(monkeypatch)
|
||||
assert client.get('/api/auth/verify').status_code == 401
|
||||
|
||||
client.set_cookie('fam_session', _cookie(ttl=-10))
|
||||
assert client.get('/api/auth/verify').status_code == 401
|
||||
|
||||
client.set_cookie('fam_session', _cookie(secret='wrong-secret'))
|
||||
resp = client.get('/api/auth/verify')
|
||||
assert resp.status_code == 401
|
||||
assert resp.get_json()['error'] == '未登录'
|
||||
|
||||
|
||||
def test_check_reports_auth_state(client, monkeypatch):
|
||||
_set_env(monkeypatch)
|
||||
assert client.get('/api/auth/check').get_json() == {'authed': False, 'username': ''}
|
||||
|
||||
client.set_cookie('fam_session', _cookie(username='ericwyuan'))
|
||||
assert client.get('/api/auth/check').get_json() == {'authed': True, 'username': 'ericwyuan'}
|
||||
|
||||
|
||||
def test_logout_clears_cookie(client, monkeypatch):
|
||||
_set_env(monkeypatch)
|
||||
client.set_cookie('fam_session', _cookie())
|
||||
resp = client.post('/api/logout')
|
||||
assert resp.status_code == 200
|
||||
assert 'fam_session=;' in resp.headers['Set-Cookie']
|
||||
120
fam-edge/tests/test_disk_guard.py
Normal file
120
fam-edge/tests/test_disk_guard.py
Normal file
@@ -0,0 +1,120 @@
|
||||
import pytest
|
||||
|
||||
from fam_edge.disk_guard import DiskGuard
|
||||
|
||||
|
||||
def _cfg(**overrides):
|
||||
base = {
|
||||
"enabled": True,
|
||||
"min_free_gb": 10,
|
||||
"target_free_gb": 15,
|
||||
"watch_path": "/opt/fam-edge",
|
||||
"max_delete_per_round": 50,
|
||||
"check_interval_sec": 300,
|
||||
}
|
||||
base.update(overrides)
|
||||
return base
|
||||
|
||||
|
||||
class _FakeDB:
|
||||
def __init__(self, candidates=None):
|
||||
self._candidates = list(candidates or [])
|
||||
self.deleted_ids = []
|
||||
self.activities = []
|
||||
|
||||
def get_oldest_purgeable_material(self):
|
||||
if not self._candidates:
|
||||
return None
|
||||
return self._candidates.pop(0)
|
||||
|
||||
def delete_video(self, video_id):
|
||||
self.deleted_ids.append(video_id)
|
||||
return None
|
||||
|
||||
def record_activity(self, service, action, detail=''):
|
||||
self.activities.append((service, action, detail))
|
||||
|
||||
|
||||
def _guard(monkeypatch, db, free_gb_sequence, **cfg_overrides):
|
||||
"""free_gb_sequence: 每次调用 _free_gb() 依次返回的值列表(最后一个值
|
||||
耗尽后保持不变),用来模拟"清理一个文件后空间逐步恢复"的过程。"""
|
||||
monkeypatch.setattr(
|
||||
"fam_edge.disk_guard.load_config",
|
||||
lambda: {"disk_guard": _cfg(**cfg_overrides)})
|
||||
guard = DiskGuard(db)
|
||||
seq = list(free_gb_sequence)
|
||||
|
||||
def fake_free_gb():
|
||||
if len(seq) > 1:
|
||||
return seq.pop(0)
|
||||
return seq[0]
|
||||
|
||||
monkeypatch.setattr(guard, "_free_gb", fake_free_gb)
|
||||
return guard
|
||||
|
||||
|
||||
def test_check_once_does_nothing_when_space_sufficient(monkeypatch):
|
||||
db = _FakeDB()
|
||||
guard = _guard(monkeypatch, db, [20.0])
|
||||
|
||||
guard.check_once()
|
||||
|
||||
assert db.deleted_ids == []
|
||||
assert db.activities == []
|
||||
|
||||
|
||||
def test_check_once_cleans_until_target_reached(monkeypatch):
|
||||
"""核心诉求: 低于 min_free_gb 触发清理,一直清到 target_free_gb 为止,
|
||||
不是清一个就停(否则马上又会跌破阈值,频繁触发)。"""
|
||||
db = _FakeDB(candidates=[
|
||||
{"id": 1, "local_path": "/tmp/a.mp4"},
|
||||
{"id": 2, "local_path": "/tmp/b.mp4"},
|
||||
{"id": 3, "local_path": "/tmp/c.mp4"},
|
||||
])
|
||||
# 初始 8GB(< min_free_gb=10),每删一个恢复到 8/12/16GB(16 >= target=15 时停)
|
||||
guard = _guard(monkeypatch, db, [8.0, 8.0, 12.0, 16.0])
|
||||
|
||||
guard.check_once()
|
||||
|
||||
assert db.deleted_ids == [1, 2]
|
||||
actions = [a[1] for a in db.activities]
|
||||
assert "low_space" in actions
|
||||
assert "cleaned" in actions
|
||||
|
||||
|
||||
def test_check_once_stops_when_no_candidate_left(monkeypatch):
|
||||
"""核心诉求: 候选清空了但空间依然不足,不能死循环,要停下来并记录一条
|
||||
"没有可清理素材"的警告,让人能在服务状态页看到这个异常情况——即便如此,
|
||||
已经发生的清理动作本身也要记录(能看到确实清过、释放了多少),不因为
|
||||
没完全达标就把 cleaned 记录吞掉。"""
|
||||
db = _FakeDB(candidates=[{"id": 1, "local_path": "/tmp/a.mp4"}])
|
||||
guard = _guard(monkeypatch, db, [8.0, 8.0, 9.0]) # 删完仅 1 个后仍然 <15GB
|
||||
|
||||
guard.check_once()
|
||||
|
||||
assert db.deleted_ids == [1]
|
||||
actions = [a[1] for a in db.activities]
|
||||
assert "no_candidate" in actions
|
||||
assert "cleaned" in actions
|
||||
|
||||
|
||||
def test_check_once_respects_max_delete_per_round(monkeypatch):
|
||||
"""核心诉求: 单轮清理有上限,防止候选异常多时一次性删太多——下一轮检查
|
||||
很快就会再触发,没必要在一轮里清空所有候选。"""
|
||||
candidates = [{"id": i, "local_path": f"/tmp/{i}.mp4"} for i in range(1, 6)]
|
||||
db = _FakeDB(candidates=candidates)
|
||||
# 空间一直卡在 8GB 不涨(模拟每个文件都很小,删多少都到不了 target)
|
||||
guard = _guard(monkeypatch, db, [8.0], max_delete_per_round=3)
|
||||
|
||||
guard.check_once()
|
||||
|
||||
assert db.deleted_ids == [1, 2, 3]
|
||||
|
||||
|
||||
def test_disabled_guard_does_not_start(monkeypatch):
|
||||
db = _FakeDB()
|
||||
guard = _guard(monkeypatch, db, [1.0], enabled=False)
|
||||
|
||||
guard.start()
|
||||
|
||||
assert guard.is_alive() is False
|
||||
@@ -1,3 +1,6 @@
|
||||
import os
|
||||
|
||||
from fam_edge import frame_service
|
||||
from fam_edge.frame_service import _bbox_to_pixels
|
||||
|
||||
|
||||
@@ -20,3 +23,52 @@ def test_bbox_to_pixels_full_frame():
|
||||
def test_bbox_to_pixels_zero_area():
|
||||
x1, y1, x2, y2 = _bbox_to_pixels([500, 500, 500, 500], 400, 300)
|
||||
assert (x1, y1) == (x2, y2)
|
||||
|
||||
|
||||
class _FakeRow(dict):
|
||||
"""支持 row['key'] 访问的假 sqlite3.Row。"""
|
||||
def __getitem__(self, k):
|
||||
return dict.get(self, k)
|
||||
|
||||
|
||||
class _FakeDb:
|
||||
def __init__(self, local_path, event_start_time):
|
||||
self._row = _FakeRow(local_path=local_path, event_start_time=event_start_time)
|
||||
|
||||
def get_video_by_id(self, video_id):
|
||||
return self._row
|
||||
|
||||
|
||||
def test_extract_frame_cache_key_includes_width(tmp_path, monkeypatch):
|
||||
"""核心诉求: 同一 (video_id, ts) 不同调用方要不同分辨率(时间轴缩略图/头像/
|
||||
人物识别裁人脸),缓存 key 不带 width 会导致后来的高分辨率请求悄悄拿到早先
|
||||
缓存的低分辨率帧——这里验证两次不同 width 请求各自落到独立的缓存文件。"""
|
||||
monkeypatch.setattr(frame_service, "CACHE_DIR", str(tmp_path))
|
||||
video_path = tmp_path / "fake_video.mp4"
|
||||
video_path.write_bytes(b"not a real video, ffmpeg call is mocked")
|
||||
db = _FakeDb(str(video_path), "2026-08-22 10:00:00")
|
||||
|
||||
written_widths = []
|
||||
|
||||
def fake_run_ffmpeg(args, timeout=60):
|
||||
# 把请求的 -vf scale=WIDTH:-2 记下来,往输出路径写点假数据模拟成功
|
||||
out_path = args[-1]
|
||||
vf = next((a for a in args if a.startswith('scale=')), '')
|
||||
written_widths.append(vf)
|
||||
with open(out_path, 'wb') as f:
|
||||
f.write(b'\xff\xd8fakejpeg')
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(frame_service, "_run_ffmpeg", fake_run_ffmpeg)
|
||||
|
||||
data_small = frame_service.extract_frame(db, 42, "2026-08-22 10:00:05", width=400)
|
||||
data_large = frame_service.extract_frame(db, 42, "2026-08-22 10:00:05", width=2880)
|
||||
|
||||
assert data_small is not None and data_large is not None
|
||||
cache_files = sorted(os.listdir(tmp_path))
|
||||
frame_caches = [f for f in cache_files if f.startswith('frame_42_5_')]
|
||||
assert len(frame_caches) == 2, f"expected 2 distinct cache files, got {frame_caches}"
|
||||
assert 'frame_42_5_400.jpg' in frame_caches
|
||||
assert 'frame_42_5_2880.jpg' in frame_caches
|
||||
# 两次都真的各自调用了 ffmpeg(第二次没有因为撞到第一次的缓存而被跳过)
|
||||
assert len(written_widths) == 2
|
||||
|
||||
@@ -7,6 +7,13 @@ def _db(tmp_path):
|
||||
return OracleDB(str(tmp_path / "oracle.db"))
|
||||
|
||||
|
||||
def _set_heartbeat_age(db, age_sec):
|
||||
"""把心跳时间戳直接改写成"距现在 age_sec 秒前",用于测试新鲜度阈值边界。"""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
ts = (datetime.now(timezone(timedelta(hours=8))) - timedelta(seconds=age_sec))
|
||||
db.set_cursor('motion_heartbeat_at', ts.strftime('%Y-%m-%d %H:%M:%S'))
|
||||
|
||||
|
||||
def test_upsert_person_same_gender_merges_into_one_row(tmp_path):
|
||||
db = _db(tmp_path)
|
||||
db.upsert_person("人物A", features={"gender": "男", "hair": "短发黑色"})
|
||||
@@ -70,3 +77,449 @@ def test_upsert_person_no_features_never_triggers_split(tmp_path):
|
||||
rows = db._conn.execute("SELECT * FROM people").fetchall()
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["appearances"] == 2
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 运动侦测事件(NAS 推送)
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
def test_record_motion_events_upserts_by_event_id(tmp_path):
|
||||
db = _db(tmp_path)
|
||||
n = db.record_motion_events([
|
||||
{"event_id": 1, "camera_id": 2, "event_type": 10, "start_time": 1000, "duration": 5},
|
||||
{"event_id": 2, "camera_id": 2, "event_type": 10, "start_time": 2000, "duration": 3},
|
||||
])
|
||||
assert n == 2
|
||||
rows = db._conn.execute("SELECT * FROM ss_motion_events ORDER BY event_id").fetchall()
|
||||
assert len(rows) == 2
|
||||
# 重复推送同一个 event_id(幂等)应该更新而不是新增一行
|
||||
db.record_motion_events(
|
||||
[{"event_id": 1, "camera_id": 2, "event_type": 10, "start_time": 1000, "duration": 99}])
|
||||
rows = db._conn.execute("SELECT * FROM ss_motion_events").fetchall()
|
||||
assert len(rows) == 2
|
||||
updated = db._conn.execute(
|
||||
"SELECT duration FROM ss_motion_events WHERE event_id=1").fetchone()
|
||||
assert updated['duration'] == 99
|
||||
|
||||
|
||||
def test_record_motion_events_skips_missing_event_id(tmp_path):
|
||||
db = _db(tmp_path)
|
||||
n = db.record_motion_events([{"camera_id": 2, "start_time": 1000}])
|
||||
assert n == 0
|
||||
|
||||
|
||||
def test_heartbeat_age_none_when_never_recorded(tmp_path):
|
||||
db = _db(tmp_path)
|
||||
assert db.get_motion_heartbeat_age_sec() is None
|
||||
|
||||
|
||||
def test_heartbeat_age_near_zero_right_after_recording(tmp_path):
|
||||
db = _db(tmp_path)
|
||||
db.record_motion_heartbeat()
|
||||
age = db.get_motion_heartbeat_age_sec()
|
||||
assert age is not None and age < 5
|
||||
|
||||
|
||||
def test_has_motion_in_range_local_fails_open_without_heartbeat(tmp_path):
|
||||
"""核心诉求: 从未收到过心跳(冷启动,NAS 推送链路还没接上)必须 fail-open,
|
||||
不能因为本地表是空的就悄悄跳过分析。"""
|
||||
db = _db(tmp_path)
|
||||
assert db.has_motion_in_range_local(1000, 2000) is None
|
||||
|
||||
|
||||
def test_has_motion_in_range_local_fails_open_when_heartbeat_stale(tmp_path):
|
||||
"""核心诉求: 表里有大量历史运动事件(曾经推送链路是健康的),但心跳已经
|
||||
过期太久(NAS 服务挂了/网络断了/DSM Webhook 规则被误关)——这时候不能信任
|
||||
"查询结果是 0 条 = 确认无运动",必须当作链路已死,fail-open。"""
|
||||
db = _db(tmp_path)
|
||||
db.record_motion_events(
|
||||
[{"event_id": 1, "camera_id": 2, "event_type": 10, "start_time": 500, "duration": 10}])
|
||||
_set_heartbeat_age(db, 1000) # 超过默认阈值 900s
|
||||
assert db.has_motion_in_range_local(2000, 3000, max_heartbeat_age_sec=900) is None
|
||||
|
||||
|
||||
def test_has_motion_in_range_local_trusts_result_when_heartbeat_fresh(tmp_path):
|
||||
db = _db(tmp_path)
|
||||
db.record_motion_heartbeat()
|
||||
assert db.has_motion_in_range_local(2000, 3000, max_heartbeat_age_sec=900) is False
|
||||
db.record_motion_events(
|
||||
[{"event_id": 1, "camera_id": 2, "event_type": 10, "start_time": 2500, "duration": 5}])
|
||||
assert db.has_motion_in_range_local(2000, 3000, max_heartbeat_age_sec=900) is True
|
||||
|
||||
|
||||
def test_has_motion_in_range_local_respects_heartbeat_boundary(tmp_path):
|
||||
db = _db(tmp_path)
|
||||
_set_heartbeat_age(db, 899)
|
||||
assert db.has_motion_in_range_local(2000, 3000, max_heartbeat_age_sec=900) is not None
|
||||
_set_heartbeat_age(db, 901)
|
||||
assert db.has_motion_in_range_local(2000, 3000, max_heartbeat_age_sec=900) is None
|
||||
|
||||
|
||||
def test_has_motion_in_range_local_overlap_semantics(tmp_path):
|
||||
"""事件区间 [start_time, start_time+duration] 只要和查询窗口有重叠就算命中,
|
||||
不要求事件完全落在窗口内部(也不要求窗口完全覆盖事件)。"""
|
||||
db = _db(tmp_path)
|
||||
db.record_motion_heartbeat()
|
||||
# 事件在窗口开始之前就开始,但持续到窗口内 -> 应该命中
|
||||
db.record_motion_events(
|
||||
[{"event_id": 1, "camera_id": 2, "event_type": 10, "start_time": 1990, "duration": 20}])
|
||||
assert db.has_motion_in_range_local(2000, 3000) is True
|
||||
|
||||
|
||||
def test_has_motion_in_range_local_ignores_non_motion_event_type(tmp_path):
|
||||
db = _db(tmp_path)
|
||||
db.record_motion_heartbeat()
|
||||
db.record_motion_events(
|
||||
[{"event_id": 1, "camera_id": 2, "event_type": 99, "start_time": 2500, "duration": 5}])
|
||||
assert db.has_motion_in_range_local(2000, 3000) is False
|
||||
|
||||
|
||||
def test_has_motion_in_range_local_filters_by_camera_id(tmp_path):
|
||||
db = _db(tmp_path)
|
||||
db.record_motion_heartbeat()
|
||||
db.record_motion_events(
|
||||
[{"event_id": 1, "camera_id": 99, "event_type": 10, "start_time": 2500, "duration": 5}])
|
||||
assert db.has_motion_in_range_local(2000, 3000, camera_id=2) is False
|
||||
assert db.has_motion_in_range_local(2000, 3000, camera_id=99) is True
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 人物对应关系表(video_id, raw_uid) -> canonical_name
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
def _seed_video_with_events(db, filename="motion_1_1000.mp4"):
|
||||
vid = db.ensure_video(filename, f"/tmp/{filename}", event_start_time="2026-08-22 10:00:00")
|
||||
events = [
|
||||
{"timestamp": "10:00:01", "description": "在客厅走动", "people": ["人物A"],
|
||||
"person_appearances": [{"uid": "人物A", "features": {"gender": "男"}, "action": "走动"}]},
|
||||
{"timestamp": "10:00:05", "description": "坐下", "people": ["人物A", "人物B"],
|
||||
"person_appearances": [
|
||||
{"uid": "人物A", "features": {"gender": "男"}, "action": "坐下"},
|
||||
{"uid": "人物B", "features": {"gender": "女"}, "action": "站立"}]},
|
||||
]
|
||||
db.mark_video_processed(vid, "摘要", events, ["人物A", "人物B"], "gemini")
|
||||
return vid
|
||||
|
||||
|
||||
def test_set_identity_mapping_inserts_new_row(tmp_path):
|
||||
db = _db(tmp_path)
|
||||
assert db.set_identity_mapping(1, "人物A", "爷爷", source="auto_id") is True
|
||||
assert db.get_identity_map_for_video(1) == {"人物A": "爷爷"}
|
||||
|
||||
|
||||
def test_set_identity_mapping_updates_existing_non_manual_row(tmp_path):
|
||||
db = _db(tmp_path)
|
||||
db.set_identity_mapping(1, "人物A", "爷爷", source="auto_id")
|
||||
assert db.set_identity_mapping(1, "人物A", "爸爸", source="auto_id") is True
|
||||
assert db.get_identity_map_for_video(1) == {"人物A": "爸爸"}
|
||||
|
||||
|
||||
def test_set_identity_mapping_manual_protected_from_auto_overwrite(tmp_path):
|
||||
"""核心诉求: 人工纠正过的映射不能被后续自动识别悄悄改回去。"""
|
||||
db = _db(tmp_path)
|
||||
db.set_identity_mapping(1, "人物A", "爸爸", source="manual")
|
||||
changed = db.set_identity_mapping(1, "人物A", "爷爷", source="auto_id")
|
||||
assert changed is False
|
||||
assert db.get_identity_map_for_video(1) == {"人物A": "爸爸"}
|
||||
|
||||
|
||||
def test_set_identity_mapping_manual_can_override_manual(tmp_path):
|
||||
db = _db(tmp_path)
|
||||
db.set_identity_mapping(1, "人物A", "爸爸", source="manual")
|
||||
changed = db.set_identity_mapping(1, "人物A", "爷爷", source="manual")
|
||||
assert changed is True
|
||||
assert db.get_identity_map_for_video(1) == {"人物A": "爷爷"}
|
||||
|
||||
|
||||
def test_set_identity_mapping_no_change_returns_false(tmp_path):
|
||||
db = _db(tmp_path)
|
||||
db.set_identity_mapping(1, "人物A", "爷爷", source="auto_id")
|
||||
changed = db.set_identity_mapping(1, "人物A", "爷爷", source="auto_id")
|
||||
assert changed is False
|
||||
|
||||
|
||||
def test_get_identity_map_for_video_scoped_per_video(tmp_path):
|
||||
"""核心诉求: 同一个 raw_uid 字符串在不同视频里可能是不同真人,映射必须按
|
||||
video_id 隔离,不能串。"""
|
||||
db = _db(tmp_path)
|
||||
db.set_identity_mapping(1, "人物A", "爷爷", source="auto_id")
|
||||
db.set_identity_mapping(2, "人物A", "爸爸", source="auto_id")
|
||||
assert db.get_identity_map_for_video(1) == {"人物A": "爷爷"}
|
||||
assert db.get_identity_map_for_video(2) == {"人物A": "爸爸"}
|
||||
|
||||
|
||||
def test_rewrite_event_person_names_updates_events_and_video(tmp_path):
|
||||
db = _db(tmp_path)
|
||||
vid = _seed_video_with_events(db)
|
||||
db.rewrite_event_person_names(vid, {"人物A": "爷爷", "人物B": "媳妇"})
|
||||
|
||||
rows = db._conn.execute(
|
||||
"SELECT person_list_json, person_appearances_json FROM events "
|
||||
"WHERE video_id=? ORDER BY id", (vid,)).fetchall()
|
||||
assert json.loads(rows[0]["person_list_json"]) == ["爷爷"]
|
||||
pa0 = json.loads(rows[0]["person_appearances_json"])
|
||||
assert pa0[0]["uid"] == "爷爷"
|
||||
assert json.loads(rows[1]["person_list_json"]) == ["爷爷", "媳妇"]
|
||||
pa1 = json.loads(rows[1]["person_appearances_json"])
|
||||
assert {p["uid"] for p in pa1} == {"爷爷", "媳妇"}
|
||||
|
||||
vrow = db._conn.execute("SELECT people_json FROM videos WHERE id=?", (vid,)).fetchone()
|
||||
assert set(json.loads(vrow["people_json"])) == {"爷爷", "媳妇"}
|
||||
|
||||
|
||||
def test_rewrite_event_person_names_rewrites_description_text(tmp_path):
|
||||
"""核心诉求: description 是大模型写的自然语言句子,"人物A/人物B"这类 uid
|
||||
会直接以文字形式嵌在句子里,只改 person_list_json/person_appearances_json
|
||||
这些结构化字段的话,事件卡片上方徽章显示对了,描述文字里还是旧 uid,两处
|
||||
对不上——description 也要做文本替换。"""
|
||||
db = _db(tmp_path)
|
||||
vid = db.ensure_video("motion_2_2000.mp4", "/tmp/motion_2_2000.mp4",
|
||||
event_start_time="2026-08-22 13:00:00")
|
||||
events = [
|
||||
{"timestamp": "13:29:24",
|
||||
"description": "人物B双手叉腰站在客厅中央;人物A在远处厨房;儿童已离开画面。",
|
||||
"people": ["人物A", "人物B"],
|
||||
"person_appearances": [
|
||||
{"uid": "人物A", "features": {"gender": "男"}, "action": "站立"},
|
||||
{"uid": "人物B", "features": {"gender": "女"}, "action": "叉腰"}]},
|
||||
]
|
||||
db.mark_video_processed(vid, "人物A和人物B都在客厅活动。", events,
|
||||
["人物A", "人物B"], "gemini")
|
||||
db.rewrite_event_person_names(vid, {"人物A": "爸爸", "人物B": "媳妇"})
|
||||
|
||||
ev_row = db._conn.execute(
|
||||
"SELECT description FROM events WHERE video_id=?", (vid,)).fetchone()
|
||||
assert ev_row["description"] == "媳妇双手叉腰站在客厅中央;爸爸在远处厨房;儿童已离开画面。"
|
||||
|
||||
v_row = db._conn.execute(
|
||||
"SELECT summary_json FROM videos WHERE id=?", (vid,)).fetchone()
|
||||
assert v_row["summary_json"] == "爸爸和媳妇都在客厅活动。"
|
||||
|
||||
|
||||
def test_rewrite_event_person_names_longer_labels_replaced_before_shorter(tmp_path):
|
||||
"""核心诉求: uid 可能带 "#2"/"#3" 这类同名冲突后缀,"人物A" 是 "人物A#2" 的
|
||||
前缀——如果先替换短的 "人物A","人物A#2" 会被错误地部分命中变成"爷爷#2",
|
||||
而不是走它自己在 rename_map 里对应的正确目标。必须长的先替换。"""
|
||||
db = _db(tmp_path)
|
||||
vid = db.ensure_video("motion_3_3000.mp4", "/tmp/motion_3_3000.mp4",
|
||||
event_start_time="2026-08-22 13:00:00")
|
||||
events = [
|
||||
{"timestamp": "13:00:01",
|
||||
"description": "人物A和人物A#2一起在客厅。",
|
||||
"people": ["人物A", "人物A#2"],
|
||||
"person_appearances": [
|
||||
{"uid": "人物A", "features": {"gender": "男"}, "action": "站立"},
|
||||
{"uid": "人物A#2", "features": {"gender": "女"}, "action": "站立"}]},
|
||||
]
|
||||
db.mark_video_processed(vid, "摘要", events, ["人物A", "人物A#2"], "gemini")
|
||||
db.rewrite_event_person_names(vid, {"人物A": "爷爷", "人物A#2": "媳妇"})
|
||||
|
||||
ev_row = db._conn.execute(
|
||||
"SELECT description FROM events WHERE video_id=?", (vid,)).fetchone()
|
||||
assert ev_row["description"] == "爷爷和媳妇一起在客厅。"
|
||||
|
||||
|
||||
def test_rewrite_event_person_names_noop_on_empty_map(tmp_path):
|
||||
db = _db(tmp_path)
|
||||
vid = _seed_video_with_events(db)
|
||||
before = db._conn.execute(
|
||||
"SELECT person_list_json FROM events WHERE video_id=?", (vid,)).fetchall()
|
||||
db.rewrite_event_person_names(vid, {})
|
||||
after = db._conn.execute(
|
||||
"SELECT person_list_json FROM events WHERE video_id=?", (vid,)).fetchall()
|
||||
assert [r["person_list_json"] for r in before] == [r["person_list_json"] for r in after]
|
||||
|
||||
|
||||
def test_correct_video_identity_end_to_end(tmp_path):
|
||||
"""核心诉求: 纠错入口应该找到当前展示名对应的映射行,改写映射 + 立即重写
|
||||
展示数据,且标记为 manual(受保护)。"""
|
||||
db = _db(tmp_path)
|
||||
vid = _seed_video_with_events(db)
|
||||
db.set_identity_mapping(vid, "人物A", "爷爷", source="auto_id")
|
||||
db.rewrite_event_person_names(vid, {"人物A": "爷爷"})
|
||||
|
||||
db.correct_video_identity(vid, current_name="爷爷", new_name="爸爸")
|
||||
|
||||
assert db.get_identity_map_for_video(vid) == {"人物A": "爸爸"}
|
||||
rows = db._conn.execute(
|
||||
"SELECT person_list_json FROM events WHERE video_id=? ORDER BY id", (vid,)).fetchall()
|
||||
assert json.loads(rows[0]["person_list_json"]) == ["爸爸"]
|
||||
# manual 之后不能被自动识别覆盖回去
|
||||
changed = db.set_identity_mapping(vid, "人物A", "爷爷", source="auto_id")
|
||||
assert changed is False
|
||||
|
||||
|
||||
def test_correct_video_identity_without_prior_mapping_uses_current_name_as_raw_uid(tmp_path):
|
||||
"""核心诉求: 老流水线时代产出的数据从没跑过闭集识别,映射表里没有记录——
|
||||
纠错依然要能生效,把 current_name 本身当 raw_uid 存一条新映射。"""
|
||||
db = _db(tmp_path)
|
||||
vid = db.ensure_video("motion_2_2000.mp4", "/tmp/x.mp4", event_start_time="2026-08-22 10:00:00")
|
||||
events = [{"timestamp": "10:00:01", "description": "走动", "people": ["爷爷"],
|
||||
"person_appearances": [{"uid": "爷爷", "features": {"gender": "男"}, "action": "走动"}]}]
|
||||
db.mark_video_processed(vid, "摘要", events, ["爷爷"], "gemini")
|
||||
|
||||
db.correct_video_identity(vid, current_name="爷爷", new_name="爸爸")
|
||||
assert db.get_identity_map_for_video(vid) == {"爷爷": "爸爸"}
|
||||
rows = db._conn.execute(
|
||||
"SELECT person_list_json FROM events WHERE video_id=?", (vid,)).fetchall()
|
||||
assert json.loads(rows[0]["person_list_json"]) == ["爸爸"]
|
||||
|
||||
|
||||
def test_delete_video_removes_video_and_events_rows(tmp_path):
|
||||
db = _db(tmp_path)
|
||||
vid = _seed_video_with_events(db)
|
||||
|
||||
local_path = db.delete_video(vid)
|
||||
|
||||
assert local_path == f"/tmp/motion_1_1000.mp4"
|
||||
assert db._conn.execute("SELECT * FROM videos WHERE id=?", (vid,)).fetchone() is None
|
||||
assert db._conn.execute("SELECT * FROM events WHERE video_id=?", (vid,)).fetchall() == []
|
||||
|
||||
|
||||
def test_delete_video_removes_disk_file(tmp_path):
|
||||
db = _db(tmp_path)
|
||||
clip_path = tmp_path / "motion_9_2000.mp4"
|
||||
clip_path.write_bytes(b"fake mp4 bytes")
|
||||
vid = db.ensure_video("motion_9_2000.mp4", str(clip_path), event_start_time="2026-08-22 10:00:00")
|
||||
db.mark_video_processed(vid, "摘要", [], [], "gemini")
|
||||
|
||||
db.delete_video(vid)
|
||||
|
||||
assert not clip_path.exists()
|
||||
|
||||
|
||||
def test_delete_video_missing_file_on_disk_does_not_raise(tmp_path):
|
||||
"""核心诉求: local_path 指向的文件已经不存在(比如手动清理过)时,删除记录
|
||||
本身不能因为 os.remove 报错而失败——文件缺失不是数据库操作的错误。"""
|
||||
db = _db(tmp_path)
|
||||
vid = db.ensure_video("motion_9_2000.mp4", str(tmp_path / "already_gone.mp4"),
|
||||
event_start_time="2026-08-22 10:00:00")
|
||||
db.mark_video_processed(vid, "摘要", [], [], "gemini")
|
||||
|
||||
local_path = db.delete_video(vid)
|
||||
|
||||
assert local_path == str(tmp_path / "already_gone.mp4")
|
||||
assert db._conn.execute("SELECT * FROM videos WHERE id=?", (vid,)).fetchone() is None
|
||||
|
||||
|
||||
def test_delete_video_nonexistent_returns_none(tmp_path):
|
||||
db = _db(tmp_path)
|
||||
assert db.delete_video(99999) is None
|
||||
|
||||
|
||||
def test_get_oldest_purgeable_material_none_when_empty(tmp_path):
|
||||
db = _db(tmp_path)
|
||||
assert db.get_oldest_purgeable_material() is None
|
||||
|
||||
|
||||
def test_get_oldest_purgeable_material_ignores_motion_clips(tmp_path):
|
||||
"""核心诉求: 运动片段(motion_ 前缀)是独立的分析产物,事件时间轴/人物
|
||||
头像都依赖它,磁盘清理绝不能碰它,只能清理原始整段素材。"""
|
||||
db = _db(tmp_path)
|
||||
vid = db.ensure_video("motion_1_1000.mp4", "/tmp/motion_1_1000.mp4",
|
||||
event_start_time="2026-08-22 10:00:00")
|
||||
db.mark_video_processed(vid, "摘要", [], [], "gemini")
|
||||
assert db.get_oldest_purgeable_material() is None
|
||||
|
||||
|
||||
def test_get_oldest_purgeable_material_ignores_non_done_status(tmp_path):
|
||||
"""核心诉求: 还在 pending/processing 的素材不能被清理,避免删掉还没
|
||||
来得及处理的数据。"""
|
||||
db = _db(tmp_path)
|
||||
db.ensure_video("Generic_ONVIF-001-20260815-000000.mp4",
|
||||
"/tmp/Generic_ONVIF-001-20260815-000000.mp4")
|
||||
assert db.get_oldest_purgeable_material() is None
|
||||
|
||||
|
||||
def test_get_oldest_purgeable_material_returns_oldest_done_material(tmp_path):
|
||||
db = _db(tmp_path)
|
||||
vid1 = db.ensure_video("Generic_ONVIF-001-20260815-000000.mp4",
|
||||
"/tmp/Generic_ONVIF-001-20260815-000000.mp4")
|
||||
db.mark_video_processed(vid1, "(整段素材已分割 0 段运动片段)", [], [], 'motion_segment')
|
||||
vid2 = db.ensure_video("Generic_ONVIF-001-20260816-000000.mp4",
|
||||
"/tmp/Generic_ONVIF-001-20260816-000000.mp4")
|
||||
db.mark_video_processed(vid2, "(整段素材已分割 0 段运动片段)", [], [], 'motion_segment')
|
||||
|
||||
candidate = db.get_oldest_purgeable_material()
|
||||
|
||||
assert candidate["id"] == vid1
|
||||
assert candidate["local_path"] == "/tmp/Generic_ONVIF-001-20260815-000000.mp4"
|
||||
|
||||
|
||||
def test_delete_video_does_not_touch_ss_motion_events(tmp_path):
|
||||
"""核心诉求: ss_motion_events 是运动侦测源事件,跟切出来的视频片段生命周期
|
||||
独立,删视频不该连带删掉源事件(否则分割逻辑的幂等判断会被破坏)。"""
|
||||
db = _db(tmp_path)
|
||||
db.record_motion_events([
|
||||
{"event_id": 555, "camera_id": 2, "event_type": 10,
|
||||
"start_time": 1700000000, "duration": 10, "thumbnail_url": ""},
|
||||
])
|
||||
vid = db.ensure_video("motion_555_1700000000.mp4", "/tmp/motion_555_1700000000.mp4",
|
||||
event_start_time="2026-08-22 10:00:00", motion_event_id=555)
|
||||
db.mark_video_processed(vid, "摘要", [], [], "gemini")
|
||||
|
||||
db.delete_video(vid)
|
||||
|
||||
assert db.get_video_by_motion_event_id(555) is None
|
||||
row = db._conn.execute("SELECT * FROM ss_motion_events WHERE event_id=?", (555,)).fetchone()
|
||||
assert row is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 并发(2026-09-13:修"全进程共用一条 sqlite 连接"的回归测试)
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_concurrent_writes_from_many_threads(tmp_path):
|
||||
"""多线程同时读写不能互相踩踏。
|
||||
|
||||
改成每线程一条连接之前,这里会稳定炸出三类错误之一:
|
||||
`cannot start a transaction within a transaction` / `no more rows available`
|
||||
/ `database is locked`——线上 DiskGuard 的清理就是被第一种打断了 2837 次。
|
||||
"""
|
||||
import threading
|
||||
|
||||
db = _db(tmp_path)
|
||||
errors = []
|
||||
rounds = 25
|
||||
|
||||
def writer(tid):
|
||||
try:
|
||||
for i in range(rounds):
|
||||
db.record_activity('t%d' % tid, 'act%d' % i, 'detail')
|
||||
db.set_cursor('cursor_t%d' % tid, str(i))
|
||||
db.record_motion_events([{
|
||||
'event_id': tid * 1000 + i, 'camera_id': 2, 'event_type': 10,
|
||||
'start_time': 1789000000 + i, 'duration': 5,
|
||||
'thumbnail_url': '',
|
||||
}])
|
||||
db.get_recent_activities(5)
|
||||
db.get_motion_heartbeat_age_sec()
|
||||
except Exception as e: # noqa: BLE001 —— 要把原始异常带出来看
|
||||
errors.append(f"线程{tid}: {type(e).__name__}: {e}")
|
||||
|
||||
threads = [threading.Thread(target=writer, args=(t,)) for t in range(8)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join(timeout=60)
|
||||
|
||||
assert not errors, "并发写出错:\n" + "\n".join(errors[:5])
|
||||
assert db._conn.execute("SELECT COUNT(*) FROM ss_motion_events").fetchone()[0] == 8 * rounds
|
||||
for tid in range(8):
|
||||
assert db.get_cursor('cursor_t%d' % tid) == str(rounds - 1)
|
||||
|
||||
|
||||
def test_close_releases_every_thread_connection(tmp_path):
|
||||
"""close() 要收掉所有线程开过的连接,不只当前线程那一条。"""
|
||||
import threading
|
||||
|
||||
db = _db(tmp_path)
|
||||
db.record_activity('main', 'x', '')
|
||||
|
||||
def other():
|
||||
db.record_activity('other', 'y', '')
|
||||
t = threading.Thread(target=other)
|
||||
t.start(); t.join()
|
||||
|
||||
assert len(db._all_conns) == 2 # 主线程 + 子线程各一条
|
||||
db.close()
|
||||
assert db._all_conns == []
|
||||
|
||||
347
fam-edge/tests/test_person_identifier.py
Normal file
347
fam-edge/tests/test_person_identifier.py
Normal file
@@ -0,0 +1,347 @@
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from fam_edge.person_identifier import PersonIdentifier
|
||||
|
||||
|
||||
def _cfg(ref_dir, **overrides):
|
||||
base = {
|
||||
"enabled": True,
|
||||
"ref_dir": ref_dir,
|
||||
"max_ref_per_person": 6,
|
||||
"min_call_interval_sec": 0, # 测试不需要真实限速,避免拖慢用例
|
||||
"nvidia": {"api_key": "nvkey", "model_name": "nvidia/test", "timeout": 30,
|
||||
"max_retries": 2, "retry_backoff_sec": 0.01},
|
||||
"gemini": {"api_key": "gkey1", "model_name": "gemini-flash-lite-latest", "timeout": 30,
|
||||
"max_retries": 2, "retry_backoff_sec": 0.01},
|
||||
}
|
||||
base.update(overrides)
|
||||
return base
|
||||
|
||||
|
||||
def _write_refs(tmp_path, grandpa=2, dad=2):
|
||||
for person, n in (("爷爷", grandpa), ("爸爸", dad)):
|
||||
d = tmp_path / person
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
for i in range(n):
|
||||
(d / f"{i:02d}.jpg").write_bytes(b"fakejpegbytes")
|
||||
|
||||
|
||||
class _FakeResp:
|
||||
def __init__(self, status_code=200, payload=None):
|
||||
self.status_code = status_code
|
||||
self._payload = payload or {}
|
||||
|
||||
def json(self):
|
||||
return self._payload
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_real_sleep(monkeypatch):
|
||||
"""全部用例都不需要真的睡(限速/退避都测计数和结果,不测真实耗时)。"""
|
||||
monkeypatch.setattr("fam_edge.person_identifier.time.sleep", lambda s: None)
|
||||
|
||||
|
||||
def test_has_references_false_when_dirs_missing(tmp_path):
|
||||
pi = PersonIdentifier(_cfg(str(tmp_path / "refs")))
|
||||
assert pi.has_references() is False
|
||||
|
||||
|
||||
def test_has_references_true_when_both_present(tmp_path):
|
||||
_write_refs(tmp_path, grandpa=3, dad=2)
|
||||
pi = PersonIdentifier(_cfg(str(tmp_path)))
|
||||
assert pi.has_references() is True
|
||||
|
||||
|
||||
def test_has_references_false_when_only_one_person_has_refs(tmp_path):
|
||||
(tmp_path / "爷爷").mkdir(parents=True)
|
||||
(tmp_path / "爷爷" / "01.jpg").write_bytes(b"x")
|
||||
pi = PersonIdentifier(_cfg(str(tmp_path)))
|
||||
assert pi.has_references() is False
|
||||
|
||||
|
||||
def test_extract_json_person_clean_json():
|
||||
pi = PersonIdentifier(_cfg("/nonexistent"))
|
||||
assert pi._extract_json_person('{"person":"爷爷"}') == '爷爷'
|
||||
assert pi._extract_json_person('{"person":"爸爸"}') == '爸爸'
|
||||
|
||||
|
||||
def test_extract_json_person_no_match_returns_none():
|
||||
pi = PersonIdentifier(_cfg("/nonexistent"))
|
||||
assert pi._extract_json_person('不知道是谁') is None
|
||||
assert pi._extract_json_person('') is None
|
||||
|
||||
|
||||
def test_extract_json_person_both_mentioned_uses_json_field():
|
||||
"""核心诉求: 模型有时会把参考图说明也复述一遍,回复里两个名字都出现——
|
||||
这时候不能瞎猜,要从 JSON 的 person 字段里精确取,取不到就返回 None。"""
|
||||
pi = PersonIdentifier(_cfg("/nonexistent"))
|
||||
text = '参考图1是爷爷,参考图5是爸爸。{"person":"爸爸"}'
|
||||
assert pi._extract_json_person(text) == '爸爸'
|
||||
|
||||
|
||||
def test_extract_json_person_both_mentioned_no_json_field_returns_none():
|
||||
pi = PersonIdentifier(_cfg("/nonexistent"))
|
||||
text = '这个人可能是爷爷,也可能是爸爸,不太确定'
|
||||
assert pi._extract_json_person(text) is None
|
||||
|
||||
|
||||
def test_classify_returns_none_when_disabled(tmp_path):
|
||||
_write_refs(tmp_path)
|
||||
pi = PersonIdentifier(_cfg(str(tmp_path), enabled=False))
|
||||
assert pi.classify_adult_male(b"crop") is None
|
||||
|
||||
|
||||
def test_classify_returns_none_when_no_references(tmp_path):
|
||||
pi = PersonIdentifier(_cfg(str(tmp_path / "empty")))
|
||||
assert pi.classify_adult_male(b"crop") is None
|
||||
|
||||
|
||||
def test_classify_falls_back_to_gemini_when_nvidia_unavailable(tmp_path, monkeypatch):
|
||||
"""openai SDK 未安装时 NVIDIA 路径应该静默跳过(不报错),落到 Gemini。"""
|
||||
_write_refs(tmp_path)
|
||||
monkeypatch.setattr("fam_edge.person_identifier.OpenAI", None)
|
||||
|
||||
def fake_post(url, json=None, timeout=None):
|
||||
return _FakeResp(200, {
|
||||
"candidates": [{"content": {"parts": [{"text": '{"person":"爸爸"}'}]}}]
|
||||
})
|
||||
monkeypatch.setattr("fam_edge.person_identifier.requests.post", fake_post)
|
||||
pi = PersonIdentifier(_cfg(str(tmp_path)))
|
||||
assert pi.classify_adult_male(b"crop") == '爸爸'
|
||||
|
||||
|
||||
def _fake_openai_factory(reply_text=None, exc=None, fail_times=0):
|
||||
"""构造一个假 OpenAI 客户端:先失败 fail_times 次再成功,或者一直抛 exc。"""
|
||||
state = {"calls": 0}
|
||||
|
||||
class FakeMessage:
|
||||
content = reply_text
|
||||
|
||||
class FakeChoice:
|
||||
message = FakeMessage()
|
||||
|
||||
class FakeChatResp:
|
||||
choices = [FakeChoice()]
|
||||
|
||||
class FakeCompletions:
|
||||
def create(self, **kwargs):
|
||||
state["calls"] += 1
|
||||
if state["calls"] <= fail_times:
|
||||
raise (exc or RuntimeError("boom"))
|
||||
if exc and fail_times == 0:
|
||||
raise exc
|
||||
return FakeChatResp()
|
||||
|
||||
class FakeChat:
|
||||
completions = FakeCompletions()
|
||||
|
||||
class FakeOpenAI:
|
||||
def __init__(self, base_url=None, api_key=None):
|
||||
pass
|
||||
chat = FakeChat()
|
||||
|
||||
return FakeOpenAI, state
|
||||
|
||||
|
||||
def test_classify_nvidia_success_skips_gemini(tmp_path, monkeypatch):
|
||||
_write_refs(tmp_path)
|
||||
FakeOpenAI, state = _fake_openai_factory(reply_text='{"person":"爷爷"}')
|
||||
monkeypatch.setattr("fam_edge.person_identifier.OpenAI", FakeOpenAI)
|
||||
|
||||
gemini_called = {"n": 0}
|
||||
def fake_post(url, json=None, timeout=None):
|
||||
gemini_called["n"] += 1
|
||||
return _FakeResp(200, {})
|
||||
monkeypatch.setattr("fam_edge.person_identifier.requests.post", fake_post)
|
||||
|
||||
pi = PersonIdentifier(_cfg(str(tmp_path)))
|
||||
assert pi.classify_adult_male(b"crop") == '爷爷'
|
||||
assert gemini_called["n"] == 0
|
||||
assert state["calls"] == 1
|
||||
|
||||
|
||||
class _FakeHTTPError(Exception):
|
||||
def __init__(self, status_code):
|
||||
self.response = type("R", (), {"status_code": status_code})()
|
||||
|
||||
|
||||
def test_nvidia_retries_transient_error_then_succeeds(tmp_path, monkeypatch):
|
||||
"""核心诉求: 429/503 这类瞬时故障要退避重试,不是第一次失败就放弃换 provider。"""
|
||||
_write_refs(tmp_path)
|
||||
FakeOpenAI, state = _fake_openai_factory(
|
||||
reply_text='{"person":"爸爸"}', exc=_FakeHTTPError(503), fail_times=1)
|
||||
monkeypatch.setattr("fam_edge.person_identifier.OpenAI", FakeOpenAI)
|
||||
pi = PersonIdentifier(_cfg(str(tmp_path)))
|
||||
assert pi.classify_adult_male(b"crop") == '爸爸'
|
||||
assert state["calls"] == 2 # 第一次 503 失败重试一次后成功
|
||||
|
||||
|
||||
def test_nvidia_gives_up_after_max_retries_falls_back_to_gemini(tmp_path, monkeypatch):
|
||||
_write_refs(tmp_path)
|
||||
FakeOpenAI, state = _fake_openai_factory(exc=_FakeHTTPError(503), fail_times=99)
|
||||
monkeypatch.setattr("fam_edge.person_identifier.OpenAI", FakeOpenAI)
|
||||
|
||||
def fake_post(url, json=None, timeout=None):
|
||||
return _FakeResp(200, {
|
||||
"candidates": [{"content": {"parts": [{"text": '{"person":"汤圆"}'}]}}]
|
||||
})
|
||||
# 用一个不属于爷爷/爸爸的返回值只是为了确认真的调用到了 gemini 分支
|
||||
monkeypatch.setattr("fam_edge.person_identifier.requests.post", fake_post)
|
||||
|
||||
cfg = _cfg(str(tmp_path))
|
||||
pi = PersonIdentifier(cfg)
|
||||
pi.classify_adult_male(b"crop")
|
||||
assert state["calls"] == pi.nvidia_max_retries # 重试到上限就放弃,不会无限重试
|
||||
|
||||
|
||||
def test_nvidia_non_retryable_error_gives_up_immediately(tmp_path, monkeypatch):
|
||||
"""核心诉求: 400 参数错误这类非瞬时故障,重试没有意义,应该立刻换下一个模型/provider,
|
||||
不要浪费时间重试一个注定失败的请求。"""
|
||||
_write_refs(tmp_path)
|
||||
FakeOpenAI, state = _fake_openai_factory(exc=_FakeHTTPError(400), fail_times=99)
|
||||
monkeypatch.setattr("fam_edge.person_identifier.OpenAI", FakeOpenAI)
|
||||
monkeypatch.setattr("fam_edge.person_identifier.requests.post",
|
||||
lambda *a, **k: _FakeResp(500, {"error": "down"}))
|
||||
pi = PersonIdentifier(_cfg(str(tmp_path)))
|
||||
pi.classify_adult_male(b"crop")
|
||||
assert state["calls"] == 1 # 400 不重试,一次就放弃这个模型
|
||||
|
||||
|
||||
def test_nvidia_falls_through_model_chain(tmp_path, monkeypatch):
|
||||
"""核心诉求: 第一个模型重试耗尽后,应该换模型链里的下一个型号再试,而不是
|
||||
直接放弃整个 NVIDIA provider。"""
|
||||
_write_refs(tmp_path)
|
||||
calls = []
|
||||
|
||||
class FakeMessage:
|
||||
def __init__(self, content):
|
||||
self.content = content
|
||||
|
||||
class FakeChoice:
|
||||
def __init__(self, content):
|
||||
self.message = FakeMessage(content)
|
||||
|
||||
class FakeChatResp:
|
||||
def __init__(self, content):
|
||||
self.choices = [FakeChoice(content)]
|
||||
|
||||
class FakeCompletions:
|
||||
def create(self, model, **kwargs):
|
||||
calls.append(model)
|
||||
if model == 'nvidia/model-a':
|
||||
raise _FakeHTTPError(503)
|
||||
return FakeChatResp('{"person":"爷爷"}')
|
||||
|
||||
class FakeChat:
|
||||
completions = FakeCompletions()
|
||||
|
||||
class FakeOpenAI:
|
||||
def __init__(self, base_url=None, api_key=None):
|
||||
pass
|
||||
chat = FakeChat()
|
||||
|
||||
monkeypatch.setattr("fam_edge.person_identifier.OpenAI", FakeOpenAI)
|
||||
cfg = _cfg(str(tmp_path), nvidia={
|
||||
"api_key": "nvkey", "model_name": "nvidia/model-a",
|
||||
"fallback_models": ["nvidia/model-b"], "timeout": 30,
|
||||
"max_retries": 2, "retry_backoff_sec": 0.01,
|
||||
})
|
||||
pi = PersonIdentifier(cfg)
|
||||
assert pi.classify_adult_male(b"crop") == '爷爷'
|
||||
assert calls == ['nvidia/model-a', 'nvidia/model-a', 'nvidia/model-b']
|
||||
|
||||
|
||||
def test_gemini_retries_transient_error_on_same_key(tmp_path, monkeypatch):
|
||||
_write_refs(tmp_path)
|
||||
monkeypatch.setattr("fam_edge.person_identifier.OpenAI", None)
|
||||
calls = []
|
||||
def fake_post(url, json=None, timeout=None):
|
||||
calls.append(url.split('key=')[-1])
|
||||
if len(calls) == 1:
|
||||
return _FakeResp(503, {"error": {"code": 503}})
|
||||
return _FakeResp(200, {
|
||||
"candidates": [{"content": {"parts": [{"text": '{"person":"媳妇"}'}]}}]
|
||||
})
|
||||
monkeypatch.setattr("fam_edge.person_identifier.requests.post", fake_post)
|
||||
pi = PersonIdentifier(_cfg(str(tmp_path)))
|
||||
pi.classify_adult_male(b"crop")
|
||||
assert calls == ['gkey1', 'gkey1'] # 同一个 key 重试,不是立刻跳到下一个 key
|
||||
|
||||
|
||||
def test_classify_gemini_rotates_across_keys_after_retries_exhausted(tmp_path, monkeypatch):
|
||||
_write_refs(tmp_path)
|
||||
monkeypatch.setattr("fam_edge.person_identifier.OpenAI", None)
|
||||
|
||||
calls = []
|
||||
def fake_post(url, json=None, timeout=None):
|
||||
key = url.split('key=')[-1]
|
||||
calls.append(key)
|
||||
if key == 'gkey1':
|
||||
return _FakeResp(429, {"error": {"code": 429}})
|
||||
return _FakeResp(200, {
|
||||
"candidates": [{"content": {"parts": [{"text": '{"person":"爷爷"}'}]}}]
|
||||
})
|
||||
monkeypatch.setattr("fam_edge.person_identifier.requests.post", fake_post)
|
||||
|
||||
cfg = _cfg(str(tmp_path), gemini={
|
||||
"api_key": "gkey1", "extra_api_keys": ["gkey2"],
|
||||
"model_name": "gemini-flash-lite-latest", "timeout": 30,
|
||||
"max_retries": 2, "retry_backoff_sec": 0.01,
|
||||
})
|
||||
pi = PersonIdentifier(cfg)
|
||||
assert pi.classify_adult_male(b"crop") == '爷爷'
|
||||
assert calls == ['gkey1', 'gkey1', 'gkey2'] # gkey1 重试用尽才换 gkey2
|
||||
|
||||
|
||||
def test_classify_both_providers_fail_returns_none(tmp_path, monkeypatch):
|
||||
"""核心诉求: NVIDIA 和 Gemini 都失败时绝不能瞎猜,必须返回 None。"""
|
||||
_write_refs(tmp_path)
|
||||
monkeypatch.setattr("fam_edge.person_identifier.OpenAI", None)
|
||||
|
||||
def fake_post(url, json=None, timeout=None):
|
||||
return _FakeResp(500, {"error": "boom"})
|
||||
monkeypatch.setattr("fam_edge.person_identifier.requests.post", fake_post)
|
||||
pi = PersonIdentifier(_cfg(str(tmp_path)))
|
||||
assert pi.classify_adult_male(b"crop") is None
|
||||
|
||||
|
||||
def test_env_var_credentials_resolved(tmp_path):
|
||||
os.environ["TEST_NVIDIA_KEY_XYZ"] = "realkey"
|
||||
try:
|
||||
cfg = _cfg(str(tmp_path), nvidia={"api_key": "${TEST_NVIDIA_KEY_XYZ}"})
|
||||
pi = PersonIdentifier(cfg)
|
||||
assert pi.nvidia_api_key == "realkey"
|
||||
finally:
|
||||
del os.environ["TEST_NVIDIA_KEY_XYZ"]
|
||||
|
||||
|
||||
def test_max_ref_per_person_limits_loaded_refs(tmp_path):
|
||||
_write_refs(tmp_path, grandpa=10, dad=10)
|
||||
pi = PersonIdentifier(_cfg(str(tmp_path), max_ref_per_person=3))
|
||||
refs = pi._load_refs()
|
||||
assert len(refs['爷爷']) == 3
|
||||
assert len(refs['爸爸']) == 3
|
||||
|
||||
|
||||
def test_pace_sleeps_when_called_too_soon(tmp_path, monkeypatch):
|
||||
"""核心诉求: 批量回填会短时间内密集调用,min_call_interval_sec 要真的限速,
|
||||
不能形同虚设。"""
|
||||
_write_refs(tmp_path)
|
||||
slept = []
|
||||
monkeypatch.setattr("fam_edge.person_identifier.time.sleep", lambda s: slept.append(s))
|
||||
pi = PersonIdentifier(_cfg(str(tmp_path), min_call_interval_sec=5))
|
||||
pi._last_call_at = __import__("time").time() # 刚刚调用过
|
||||
pi._pace()
|
||||
assert slept and slept[0] > 0
|
||||
|
||||
|
||||
def test_pace_no_sleep_when_interval_already_elapsed(tmp_path, monkeypatch):
|
||||
_write_refs(tmp_path)
|
||||
slept = []
|
||||
monkeypatch.setattr("fam_edge.person_identifier.time.sleep", lambda s: slept.append(s))
|
||||
pi = PersonIdentifier(_cfg(str(tmp_path), min_call_interval_sec=5))
|
||||
pi._last_call_at = 0 # 很久以前
|
||||
pi._pace()
|
||||
assert slept == []
|
||||
199
fam-edge/tests/test_qa.py
Normal file
199
fam-edge/tests/test_qa.py
Normal file
@@ -0,0 +1,199 @@
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from fam_edge.qa import QAOrchestrator
|
||||
|
||||
|
||||
def _orchestrator(monkeypatch, cfg=None, token='tok'):
|
||||
ai_gateway_cfg = {"base_url": "http://127.0.0.1:5100", "token": token, "timeout": 5}
|
||||
if cfg:
|
||||
ai_gateway_cfg.update(cfg)
|
||||
monkeypatch.setattr(
|
||||
"fam_edge.qa.load_config", lambda: {"ai_gateway": ai_gateway_cfg})
|
||||
return QAOrchestrator()
|
||||
|
||||
|
||||
def test_init_reads_base_url_and_token_from_config(monkeypatch):
|
||||
qa = _orchestrator(monkeypatch, {"base_url": "http://example:5100/"}, token='secret')
|
||||
assert qa.base_url == "http://example:5100"
|
||||
assert qa.token == 'secret'
|
||||
|
||||
|
||||
def test_init_resolves_token_from_env_var(monkeypatch):
|
||||
monkeypatch.setenv("MY_GATEWAY_TOKEN", "resolved-secret")
|
||||
qa = _orchestrator(monkeypatch, token='${MY_GATEWAY_TOKEN}')
|
||||
assert qa.token == 'resolved-secret'
|
||||
|
||||
|
||||
def test_init_defaults_base_url_when_unconfigured(monkeypatch):
|
||||
monkeypatch.setattr("fam_edge.qa.load_config", lambda: {})
|
||||
qa = QAOrchestrator()
|
||||
assert qa.base_url == "http://127.0.0.1:5100"
|
||||
|
||||
|
||||
class _FakeResp:
|
||||
"""模拟 requests.Response:非流式用 status_code/json()/text,
|
||||
流式额外提供 iter_lines()(逐行 yield,跟真实 SSE 消费方式一致)。"""
|
||||
|
||||
def __init__(self, status_code=200, payload=None, text='', lines=None):
|
||||
self.status_code = status_code
|
||||
self._payload = payload
|
||||
self.text = text
|
||||
self._lines = lines if lines is not None else []
|
||||
self.encoding = None
|
||||
|
||||
def json(self):
|
||||
return self._payload
|
||||
|
||||
def iter_lines(self, decode_unicode=True):
|
||||
for line in self._lines:
|
||||
yield line
|
||||
|
||||
|
||||
def _capture_post(monkeypatch, resp):
|
||||
calls = []
|
||||
|
||||
def fake_post(url, headers=None, json=None, timeout=None, stream=False):
|
||||
calls.append({"url": url, "headers": headers, "json": json,
|
||||
"timeout": timeout, "stream": stream})
|
||||
return resp
|
||||
|
||||
monkeypatch.setattr("fam_edge.qa.requests.post", fake_post)
|
||||
return calls
|
||||
|
||||
|
||||
def test_run_qa_success(monkeypatch):
|
||||
qa = _orchestrator(monkeypatch)
|
||||
resp = _FakeResp(payload={"choices": [{"message": {"content": "你好"}}],
|
||||
"provider": "nvidia"})
|
||||
calls = _capture_post(monkeypatch, resp)
|
||||
answer, provider = qa.run_qa("hi", max_tokens=100)
|
||||
assert answer == "你好"
|
||||
assert provider == "nvidia"
|
||||
assert calls[0]["json"] == {"messages": [{"role": "user", "content": "hi"}],
|
||||
"max_tokens": 100, "stream": False}
|
||||
assert calls[0]["headers"]["Authorization"] == "Bearer tok"
|
||||
|
||||
|
||||
def test_run_qa_non_200_returns_none(monkeypatch):
|
||||
qa = _orchestrator(monkeypatch)
|
||||
resp = _FakeResp(status_code=503, text='{"error":{"message":"所有模型均不可用"}}')
|
||||
_capture_post(monkeypatch, resp)
|
||||
answer, provider = qa.run_qa("hi")
|
||||
assert answer is None
|
||||
assert provider is None
|
||||
|
||||
|
||||
def test_run_qa_empty_answer_returns_none(monkeypatch):
|
||||
qa = _orchestrator(monkeypatch)
|
||||
resp = _FakeResp(payload={"choices": [{"message": {"content": ""}}], "provider": "gemini"})
|
||||
_capture_post(monkeypatch, resp)
|
||||
answer, provider = qa.run_qa("hi")
|
||||
assert answer is None
|
||||
assert provider is None
|
||||
|
||||
|
||||
def test_run_qa_connection_error_returns_none(monkeypatch):
|
||||
qa = _orchestrator(monkeypatch)
|
||||
|
||||
def _raise(*args, **kwargs):
|
||||
raise ConnectionError("boom")
|
||||
|
||||
monkeypatch.setattr("fam_edge.qa.requests.post", _raise)
|
||||
answer, provider = qa.run_qa("hi")
|
||||
assert answer is None
|
||||
assert provider is None
|
||||
|
||||
|
||||
def _sse_lines(events):
|
||||
lines = []
|
||||
for e in events:
|
||||
lines.append(f"data: {json.dumps(e, ensure_ascii=False)}")
|
||||
lines.append("data: [DONE]")
|
||||
return lines
|
||||
|
||||
|
||||
def test_run_qa_stream_single_provider_success(monkeypatch):
|
||||
qa = _orchestrator(monkeypatch)
|
||||
lines = _sse_lines([
|
||||
{"provider": "nvidia", "choices": [{"delta": {"content": "你"}}]},
|
||||
{"provider": "nvidia", "choices": [{"delta": {"content": "好"}}]},
|
||||
{"provider": "nvidia", "choices": [{"delta": {}}]},
|
||||
])
|
||||
resp = _FakeResp(lines=lines)
|
||||
_capture_post(monkeypatch, resp)
|
||||
events = list(qa.run_qa_stream("hi"))
|
||||
types = [e["type"] for e in events]
|
||||
assert types == ["provider_trying", "chunk", "chunk", "done"]
|
||||
assert events[1]["text"] == "你"
|
||||
assert events[2]["text"] == "好"
|
||||
assert events[-1]["provider"] == "nvidia"
|
||||
|
||||
|
||||
def test_run_qa_stream_emits_provider_trying_once_per_change(monkeypatch):
|
||||
"""provider 字段没变化时不该重复吐 provider_trying。"""
|
||||
qa = _orchestrator(monkeypatch)
|
||||
lines = _sse_lines([
|
||||
{"provider": "nvidia", "choices": [{"delta": {"content": "a"}}]},
|
||||
{"provider": "nvidia", "choices": [{"delta": {"content": "b"}}]},
|
||||
])
|
||||
resp = _FakeResp(lines=lines)
|
||||
_capture_post(monkeypatch, resp)
|
||||
events = list(qa.run_qa_stream("hi"))
|
||||
trying = [e for e in events if e["type"] == "provider_trying"]
|
||||
assert len(trying) == 1
|
||||
assert trying[0]["provider"] == "nvidia"
|
||||
|
||||
|
||||
def test_run_qa_stream_no_chunks_yields_all_failed(monkeypatch):
|
||||
qa = _orchestrator(monkeypatch)
|
||||
resp = _FakeResp(lines=["data: [DONE]"])
|
||||
_capture_post(monkeypatch, resp)
|
||||
events = list(qa.run_qa_stream("hi"))
|
||||
assert events == [{"type": "all_failed"}]
|
||||
|
||||
|
||||
def test_run_qa_stream_non_200_yields_all_failed(monkeypatch):
|
||||
qa = _orchestrator(monkeypatch)
|
||||
resp = _FakeResp(status_code=503, text='{"error":{"message":"所有模型均不可用"}}')
|
||||
_capture_post(monkeypatch, resp)
|
||||
events = list(qa.run_qa_stream("hi"))
|
||||
assert events == [{"type": "all_failed"}]
|
||||
|
||||
|
||||
def test_run_qa_stream_connection_error_yields_all_failed(monkeypatch):
|
||||
qa = _orchestrator(monkeypatch)
|
||||
|
||||
def _raise(*args, **kwargs):
|
||||
raise ConnectionError("boom")
|
||||
|
||||
monkeypatch.setattr("fam_edge.qa.requests.post", _raise)
|
||||
events = list(qa.run_qa_stream("hi"))
|
||||
assert events == [{"type": "all_failed"}]
|
||||
|
||||
|
||||
def test_run_qa_stream_error_chunk_stops_and_uses_partial_output(monkeypatch):
|
||||
"""已经吐出过内容后遇到错误块:按"至少吐出过一块就算 done"处理,不是 all_failed。"""
|
||||
qa = _orchestrator(monkeypatch)
|
||||
lines = [
|
||||
f"data: {json.dumps({'provider': 'gemini', 'choices': [{'delta': {'content': '先吐'}}]}, ensure_ascii=False)}",
|
||||
f"data: {json.dumps({'error': {'message': 'boom'}}, ensure_ascii=False)}",
|
||||
]
|
||||
resp = _FakeResp(lines=lines)
|
||||
_capture_post(monkeypatch, resp)
|
||||
events = list(qa.run_qa_stream("hi"))
|
||||
types = [e["type"] for e in events]
|
||||
assert types == ["provider_trying", "chunk", "done"]
|
||||
assert events[-1]["provider"] == "gemini"
|
||||
|
||||
|
||||
def test_run_qa_stream_sets_stream_true_and_utf8_encoding(monkeypatch):
|
||||
qa = _orchestrator(monkeypatch)
|
||||
resp = _FakeResp(lines=["data: [DONE]"])
|
||||
calls = _capture_post(monkeypatch, resp)
|
||||
list(qa.run_qa_stream("hi", max_tokens=222))
|
||||
assert calls[0]["json"]["stream"] is True
|
||||
assert calls[0]["json"]["max_tokens"] == 222
|
||||
assert calls[0]["stream"] is True
|
||||
assert resp.encoding == 'utf-8'
|
||||
@@ -1,12 +1,36 @@
|
||||
from datetime import datetime
|
||||
|
||||
from fam_edge.video_processor import (
|
||||
VideoProcessor,
|
||||
_parse_event_start_from_filename,
|
||||
_parse_event_ts,
|
||||
_clean_person,
|
||||
)
|
||||
|
||||
|
||||
class _RaisingPersonIdentifier:
|
||||
"""用于验证"命中免费规则就不该再调用大模型比对"——一旦被调用直接报错,
|
||||
测试能立刻发现规则短路失败。"""
|
||||
def classify_adult_male(self, crop):
|
||||
raise AssertionError("命中了免费规则的 uid 不该再走 person_identifier")
|
||||
|
||||
|
||||
class _FixedPersonIdentifier:
|
||||
def __init__(self, name):
|
||||
self._name = name
|
||||
|
||||
def classify_adult_male(self, crop):
|
||||
return self._name
|
||||
|
||||
|
||||
def _bare_processor(person_identifier):
|
||||
"""跳过 __init__(不需要真的加载 config/建适配器),只测
|
||||
_resolve_closed_set_identities 这一个纯逻辑方法。"""
|
||||
vp = VideoProcessor.__new__(VideoProcessor)
|
||||
vp.person_identifier = person_identifier
|
||||
return vp
|
||||
|
||||
|
||||
def test_parse_filename_pure_digit_format():
|
||||
assert _parse_event_start_from_filename(
|
||||
"Generic_ONVIF-001-20260820-140416-1787205856321-7.mp4"
|
||||
@@ -56,3 +80,70 @@ def test_clean_person_strips_ascii_parens():
|
||||
|
||||
def test_clean_person_no_parens_unchanged():
|
||||
assert _clean_person("汤圆") == "汤圆"
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 闭集人物识别:赤膊成年人规则(用户原话:"赤裸的大人都是爸爸,家里没有
|
||||
# 其他人会赤裸")——命中即免费直判,不调用视觉大模型比对。
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
def test_shirtless_adult_male_resolves_to_dad_without_vlm_call():
|
||||
vp = _bare_processor(_RaisingPersonIdentifier())
|
||||
resolved = vp._resolve_closed_set_identities(
|
||||
1, {"人物A": {"gender": "男", "age_band": "中年", "clothing": "赤膊+深色长裤"}}, [])
|
||||
assert resolved == {"人物A": ("爸爸", "rule")}
|
||||
|
||||
|
||||
def test_shirtless_variants_all_match():
|
||||
for phrase in ("光着上身", "赤膊", "裸体", "光膀子", "上身赤裸", "未穿上衣", "深色长裤,赤裸上身"):
|
||||
vp = _bare_processor(_RaisingPersonIdentifier())
|
||||
resolved = vp._resolve_closed_set_identities(
|
||||
1, {"人物A": {"gender": "男", "age_band": "中年", "clothing": phrase}}, [])
|
||||
assert resolved.get("人物A") == ("爸爸", "rule"), f"未命中: {phrase}"
|
||||
|
||||
|
||||
def test_shirtless_child_still_resolves_to_child_not_dad():
|
||||
"""核心诉求: 用户的规则明确是"赤裸的大人",小孩光膀子玩很正常,不适用
|
||||
这条规则——幼儿/儿童年龄档要走在赤膊判断前面,不能被误判成爸爸。"""
|
||||
vp = _bare_processor(_RaisingPersonIdentifier())
|
||||
resolved = vp._resolve_closed_set_identities(
|
||||
1, {"人物A": {"gender": "男", "age_band": "幼儿", "clothing": "赤裸上身+深色短裤"}}, [])
|
||||
assert resolved == {"人物A": ("汤圆", "rule")}
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 闭集人物识别:成年女性按年龄段区分媳妇/奶奶(2026-08-29 新增,家里从 4 人
|
||||
# 变成 5 人后,"媳妇=唯一成年女性"这条规则的前提被打破)
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
def test_middle_aged_female_resolves_to_wife_without_vlm_call():
|
||||
vp = _bare_processor(_RaisingPersonIdentifier())
|
||||
resolved = vp._resolve_closed_set_identities(
|
||||
1, {"人物A": {"gender": "女", "age_band": "中年", "clothing": "粉色上衣"}}, [])
|
||||
assert resolved == {"人物A": ("媳妇", "rule")}
|
||||
|
||||
|
||||
def test_elderly_female_resolves_to_grandma_without_vlm_call():
|
||||
vp = _bare_processor(_RaisingPersonIdentifier())
|
||||
resolved = vp._resolve_closed_set_identities(
|
||||
1, {"人物A": {"gender": "女", "age_band": "老年", "clothing": "红色上衣"}}, [])
|
||||
assert resolved == {"人物A": ("奶奶", "rule")}
|
||||
|
||||
|
||||
def test_female_with_unknown_age_band_defaults_to_wife():
|
||||
"""核心诉求: age_band 缺失/模型没给出明确判断时,不能因为不确定就放弃
|
||||
识别——默认归到媳妇(原有行为),只有明确判断为"老年"才算奶奶。"""
|
||||
vp = _bare_processor(_RaisingPersonIdentifier())
|
||||
resolved = vp._resolve_closed_set_identities(
|
||||
1, {"人物A": {"gender": "女", "age_band": "unknown", "clothing": ""}}, [])
|
||||
assert resolved == {"人物A": ("媳妇", "rule")}
|
||||
|
||||
|
||||
def test_clothed_adult_male_still_falls_through_to_vlm(monkeypatch):
|
||||
"""核心诉求: 没有赤膊关键词的正常穿戴场景,行为不变——照常走视觉大模型
|
||||
比对(这里用假的 _best_crop_for_uid 避免真的需要 norm_events 数据)。"""
|
||||
vp = _bare_processor(_FixedPersonIdentifier("爷爷"))
|
||||
monkeypatch.setattr(vp, "_best_crop_for_uid", lambda *a, **k: b"fake-jpeg-bytes")
|
||||
resolved = vp._resolve_closed_set_identities(
|
||||
1, {"人物A": {"gender": "男", "age_band": "中年", "clothing": "蓝色Polo衫"}}, [])
|
||||
assert resolved == {"人物A": ("爷爷", "auto_id")}
|
||||
|
||||
73
fam-edge/tests/test_video_queue.py
Normal file
73
fam-edge/tests/test_video_queue.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""VideoQueue 生产者扫描的单测。
|
||||
|
||||
目前只覆盖一个点:生产者列目录之后、读 mtime 之前,DiskGuard 可能刚好把文件清掉
|
||||
(两个后台线程的正常竞态)。线上表现为 `生产者扫描异常: [Errno 2] No such file or
|
||||
directory`,代价是**整轮扫描中断**——排在后面的新素材本轮全都登记不上,要等下一轮。
|
||||
"""
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from fam_edge import video_queue as vq_mod
|
||||
from fam_edge.video_queue import VideoQueue
|
||||
|
||||
|
||||
class _FakeDB:
|
||||
"""只实现生产者路径上用到的方法。"""
|
||||
def __init__(self):
|
||||
self.registered = []
|
||||
self.activities = []
|
||||
|
||||
def get_video_by_filename(self, fn):
|
||||
return None
|
||||
|
||||
def ensure_video(self, fn, path, camera_name=None):
|
||||
self.registered.append(fn)
|
||||
return len(self.registered)
|
||||
|
||||
def set_video_file_status(self, vid, valid, err, meta=None):
|
||||
pass
|
||||
|
||||
def record_activity(self, service, action, detail=''):
|
||||
self.activities.append((service, action))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def queue_with_two_files(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(vq_mod, 'load_config', lambda: {
|
||||
'gdrive_sync': {'local_dir': str(tmp_path), 'camera_name': '客厅'},
|
||||
'video_processing': {'file_validate': True, 'stable_window_sec': 60},
|
||||
})
|
||||
(tmp_path / "a_被删掉的.mp4").write_bytes(b"x")
|
||||
(tmp_path / "b_正常的.mp4").write_bytes(b"y")
|
||||
db = _FakeDB()
|
||||
q = VideoQueue(db)
|
||||
monkeypatch.setattr(q, '_enqueue', lambda vid: None)
|
||||
monkeypatch.setattr(vq_mod, 'validate_video', lambda p: (True, '', {'fps': 25}))
|
||||
return q, db
|
||||
|
||||
|
||||
def test_produce_skips_file_deleted_mid_scan_and_keeps_going(queue_with_two_files, monkeypatch):
|
||||
"""被删掉的那个跳过,后面的照常登记——不能整轮中断。"""
|
||||
q, db = queue_with_two_files
|
||||
real_getmtime = os.path.getmtime
|
||||
|
||||
def fake_getmtime(path):
|
||||
if 'a_被删掉的' in path:
|
||||
raise FileNotFoundError(2, 'No such file or directory', path)
|
||||
return real_getmtime(path) - 3600 # 早于稳定窗口,视为写入完成
|
||||
monkeypatch.setattr(vq_mod.os.path, 'getmtime', fake_getmtime)
|
||||
|
||||
q._produce_once() # 不抛异常
|
||||
|
||||
assert db.registered == ["b_正常的.mp4"] # 被删的跳过,后面的没受影响
|
||||
|
||||
|
||||
def test_produce_still_skips_files_being_written(queue_with_two_files, monkeypatch):
|
||||
"""mtime 太新(rclone 还在写)仍然要跳过,别把半成品入队。"""
|
||||
q, db = queue_with_two_files
|
||||
monkeypatch.setattr(vq_mod.os.path, 'getmtime', lambda p: __import__('time').time())
|
||||
|
||||
q._produce_once()
|
||||
|
||||
assert db.registered == []
|
||||
27
fam-notifier/config/config.yaml.example
Normal file
27
fam-notifier/config/config.yaml.example
Normal file
@@ -0,0 +1,27 @@
|
||||
# fam-notifier 配置(NAS 上唯一保留的服务)
|
||||
# 复制为 config.yaml 后填实际值;${VAR} 会从环境变量解析(start_notifier.sh 会 source .env)
|
||||
|
||||
motion_notifier:
|
||||
enabled: true
|
||||
poll_enabled: true # 轮询主路径(默认开启)
|
||||
dsm_host: "127.0.0.1" # Surveillance Station 就在本机(NAS),走回环即可
|
||||
dsm_port: 5000
|
||||
dsm_account: "${DSM_ACCOUNT}"
|
||||
dsm_password: "${DSM_PASSWORD}"
|
||||
camera_ids: [2] # 轮询关注的摄像头(Generic_ONVIF-001)
|
||||
|
||||
# 推送目标:甲骨文 fam-edge。这是本服务唯一的出口,单向。
|
||||
oracle_base_url: "http://129.146.26.249:5000"
|
||||
oracle_token: "${ORACLE_SYNC_TOKEN}"
|
||||
timeout_sec: 10 # 单次 SS 请求超时
|
||||
|
||||
# 心跳:跟轮询 SS 无关,只是定期空 POST 一下甲骨文的 /api/ss/motion,证明
|
||||
# NAS->甲骨文这条推送链路本身还活着(enabled=true 就跑,不受 poll_enabled 影响)。
|
||||
# 甲骨文侧 dsm_motion_prefilter.max_heartbeat_age_sec(默认 900s)据此判断"无运动"
|
||||
# 结论是否可信——这个心跳间隔要明显小于那个阈值,否则会被误判成链路已死。
|
||||
heartbeat_interval_sec: 300
|
||||
|
||||
# 轮询参数
|
||||
poll_interval_sec: 60 # 轮询间隔
|
||||
poll_window_hours: 2 # 每轮回看窗口(小时),覆盖轮询间隔内的新事件
|
||||
batch_size: 100 # 单批推送上限
|
||||
3
fam-notifier/requirements.txt
Normal file
3
fam-notifier/requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
# NAS 上唯一要装的依赖:一个 HTTP 客户端 + YAML 配置解析
|
||||
requests>=2.31.0
|
||||
PyYAML>=6.0
|
||||
72
fam-notifier/scripts/S99fam-notifier.sh
Executable file
72
fam-notifier/scripts/S99fam-notifier.sh
Executable file
@@ -0,0 +1,72 @@
|
||||
#!/bin/sh
|
||||
# fam-notifier 开机自启(DSM 没有 systemd,/usr/local/etc/rc.d/ 是唯一的守护手段)
|
||||
#
|
||||
# 部署:sudo cp 到 /usr/local/etc/rc.d/S99fam-notifier.sh && sudo chmod 755 同路径
|
||||
#
|
||||
# 为什么非有不可:这个进程是摄像头系统在 NAS 上唯一保留的东西(轮询
|
||||
# Surveillance Station,把运动事件单向推给甲骨文)。它没起来的话事件就悄无声息
|
||||
# 地断流——2026-09-04 到 09-12 断档整整十天,就是因为当时它还在 fam-core 里、
|
||||
# fam-core 挂了没人重启,而且不打开网站根本发现不了。
|
||||
#
|
||||
# 以 ericwyuan 而不是 root 运行:保持 logs/ 和 data/cursor.json 的属主跟手动启动
|
||||
# 时一致,免得 root 建出来的文件之后普通用户改不动。
|
||||
RUN_USER=ericwyuan
|
||||
APP_DIR=/volume1/web/sentinel-home-ai/fam-notifier
|
||||
START="$APP_DIR/scripts/start_notifier.sh"
|
||||
LOG="$APP_DIR/logs/start.log"
|
||||
PIDFILE="$APP_DIR/fam-notifier.pid"
|
||||
|
||||
# DSM 上没有 pgrep,用 grep 字符类找 pid([f] 写法避免匹配到 grep 自己)
|
||||
find_pid() {
|
||||
ps aux | grep "[f]am_notifier" | awk '{print $2}' | head -1
|
||||
}
|
||||
|
||||
start() {
|
||||
RUNNING=$(find_pid)
|
||||
if [ -n "$RUNNING" ]; then
|
||||
echo "fam-notifier 已在运行 (PID $RUNNING)"; echo "$RUNNING" > "$PIDFILE"; return 0
|
||||
fi
|
||||
# 等待数据卷 volume1 挂载就绪(最多 90 秒),避免开机时脚本早于卷挂载而静默失败
|
||||
n=0
|
||||
while [ ! -f "$START" ] && [ $n -lt 90 ]; do
|
||||
sleep 1; n=$((n+1))
|
||||
done
|
||||
if [ ! -f "$START" ]; then
|
||||
echo "等待 $START 超时(数据卷可能未挂载),启动放弃"; return 1
|
||||
fi
|
||||
# su 自身的 stdio 也要断开:否则通过 SSH 执行本脚本时,子进程继承了连接的管道,
|
||||
# 命令跑完了 SSH 会话却迟迟不退出(开机时无所谓,手动敲的时候很烦)
|
||||
su "$RUN_USER" -c "cd '$APP_DIR' && setsid nohup sh scripts/start_notifier.sh >> '$LOG' 2>&1 < /dev/null &" > /dev/null 2>&1 < /dev/null
|
||||
sleep 4
|
||||
PID=$(find_pid)
|
||||
if [ -n "$PID" ]; then
|
||||
echo "$PID" > "$PIDFILE"
|
||||
echo "fam-notifier 已启动 (PID $PID)"
|
||||
else
|
||||
echo "启动失败,请查看 $LOG 与 $APP_DIR/logs/fam-notifier.log"; return 1
|
||||
fi
|
||||
}
|
||||
|
||||
stop() {
|
||||
if [ -f "$PIDFILE" ]; then kill "$(cat "$PIDFILE")" 2>/dev/null; rm -f "$PIDFILE"; fi
|
||||
for p in $(ps aux | grep "[f]am_notifier" | awk '{print $2}'); do kill "$p" 2>/dev/null; done
|
||||
echo "fam-notifier 已停止"
|
||||
}
|
||||
|
||||
status() {
|
||||
PID=$(find_pid)
|
||||
if [ -n "$PID" ]; then
|
||||
echo "运行中 (PID $PID)"
|
||||
tail -3 "$APP_DIR/logs/fam-notifier.log" 2>/dev/null
|
||||
else
|
||||
echo "未运行"; return 1
|
||||
fi
|
||||
}
|
||||
|
||||
case "$1" in
|
||||
start) start ;;
|
||||
stop) stop ;;
|
||||
restart) stop; sleep 2; start ;;
|
||||
status) status ;;
|
||||
*) echo "用法: $0 {start|stop|restart|status}"; exit 1 ;;
|
||||
esac
|
||||
36
fam-notifier/scripts/start_notifier.sh
Executable file
36
fam-notifier/scripts/start_notifier.sh
Executable file
@@ -0,0 +1,36 @@
|
||||
#!/bin/bash
|
||||
# fam-notifier 启动脚本(NAS 端)
|
||||
# 用法: bash scripts/start_notifier.sh
|
||||
#
|
||||
# NAS 上没有 systemd,挂了不会自启——重启方式见 docs/DEPLOY.md §1.2。
|
||||
set -e
|
||||
|
||||
APP_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
CONFIG_FILE="$APP_DIR/config/config.yaml"
|
||||
|
||||
if [ ! -f "$CONFIG_FILE" ]; then
|
||||
echo "错误: 配置文件不存在: $CONFIG_FILE"
|
||||
echo "请复制 config/config.yaml.example 为 config.yaml 并填入实际值"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "$APP_DIR"
|
||||
|
||||
# 加载 DSM_ACCOUNT / DSM_PASSWORD / ORACLE_SYNC_TOKEN。
|
||||
# set -a 包裹:.env 里是裸赋值,不 export 的话子进程读不到(2026-09-01 踩过)。
|
||||
ENV_FILE="$APP_DIR/../.env"
|
||||
if [ -f "$ENV_FILE" ]; then
|
||||
set -a
|
||||
source "$ENV_FILE"
|
||||
set +a
|
||||
fi
|
||||
|
||||
if [ -d "venv" ]; then
|
||||
source venv/bin/activate
|
||||
fi
|
||||
|
||||
# 包在 src/ 下,不加这行 `python -m fam_notifier` 找不到模块
|
||||
export PYTHONPATH="$APP_DIR/src${PYTHONPATH:+:$PYTHONPATH}"
|
||||
|
||||
echo "启动 fam-notifier(轮询 Surveillance Station -> 推送甲骨文)..."
|
||||
exec python -m fam_notifier
|
||||
0
fam-notifier/src/fam_notifier/__init__.py
Normal file
0
fam-notifier/src/fam_notifier/__init__.py
Normal file
34
fam-notifier/src/fam_notifier/__main__.py
Normal file
34
fam-notifier/src/fam_notifier/__main__.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""
|
||||
fam-notifier 入口:`python -m fam_notifier`
|
||||
|
||||
没有 Flask,没有对外端口——这个进程只做一件事:轮询 Surveillance Station,
|
||||
把运动事件单向推给甲骨文的 fam-edge。状态查看去网站的「服务状态」页,
|
||||
心跳和事件都记在甲骨文那边(service_activity / ss_motion_events)。
|
||||
"""
|
||||
import sys
|
||||
import time
|
||||
|
||||
from .logger import setup_logger
|
||||
from .notifier import MotionNotifier
|
||||
|
||||
logger = setup_logger('fam-notifier')
|
||||
|
||||
|
||||
def main():
|
||||
notifier = MotionNotifier()
|
||||
if not notifier.enabled:
|
||||
logger.error("motion_notifier.enabled=false,没什么可做的,退出")
|
||||
return 1
|
||||
notifier.start()
|
||||
logger.info("fam-notifier 已启动(Ctrl+C 退出)")
|
||||
try:
|
||||
while notifier.is_alive():
|
||||
time.sleep(5)
|
||||
except KeyboardInterrupt:
|
||||
logger.info("收到中断,停止轮询")
|
||||
notifier.stop()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
32
fam-notifier/src/fam_notifier/config_loader.py
Normal file
32
fam-notifier/src/fam_notifier/config_loader.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""
|
||||
配置加载器 - 从 config.yaml 读取配置
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import yaml
|
||||
|
||||
|
||||
def _resolve_env_vars(value):
|
||||
"""递归解析字符串中的 ${ENV_VAR} 引用"""
|
||||
if isinstance(value, str):
|
||||
def replace_env(match):
|
||||
env_name = match.group(1)
|
||||
return os.environ.get(env_name, match.group(0))
|
||||
return re.sub(r'\$\{(\w+)\}', replace_env, value)
|
||||
elif isinstance(value, dict):
|
||||
return {k: _resolve_env_vars(v) for k, v in value.items()}
|
||||
elif isinstance(value, list):
|
||||
return [_resolve_env_vars(item) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
def load_config(config_path=None):
|
||||
"""加载 YAML 配置文件,自动解析 ${ENV_VAR} 引用"""
|
||||
if config_path is None:
|
||||
config_path = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
|
||||
'config', 'config.yaml'
|
||||
)
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
raw = yaml.safe_load(f)
|
||||
return _resolve_env_vars(raw)
|
||||
34
fam-notifier/src/fam_notifier/logger.py
Normal file
34
fam-notifier/src/fam_notifier/logger.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""
|
||||
日志工具 - 统一格式(stdout + 文件双写)
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
# 日志目录:fam-notifier/logs/(相对 src 的上一级),失败则退化为仅 stdout
|
||||
_LOG_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), 'logs')
|
||||
|
||||
|
||||
def setup_logger(name='fam-notifier', level=logging.INFO):
|
||||
"""配置并返回 logger(stdout + 文件双写)"""
|
||||
logger = logging.getLogger(name)
|
||||
if logger.handlers:
|
||||
return logger
|
||||
logger.setLevel(level)
|
||||
formatter = logging.Formatter(
|
||||
'%(asctime)s [%(name)s] %(levelname)s %(message)s',
|
||||
datefmt='%Y-%m-%d %H:%M:%S'
|
||||
)
|
||||
handler = logging.StreamHandler(sys.stdout)
|
||||
handler.setFormatter(formatter)
|
||||
logger.addHandler(handler)
|
||||
# 文件输出(daemon 模式下 stdout 不可见,文件是唯一可追溯日志)
|
||||
try:
|
||||
os.makedirs(_LOG_DIR, exist_ok=True)
|
||||
file_handler = logging.FileHandler(
|
||||
os.path.join(_LOG_DIR, 'fam-notifier.log'), encoding='utf-8')
|
||||
file_handler.setFormatter(formatter)
|
||||
logger.addHandler(file_handler)
|
||||
except OSError:
|
||||
pass # 目录不可写时退化为仅 stdout
|
||||
return logger
|
||||
400
fam-notifier/src/fam_notifier/notifier.py
Normal file
400
fam-notifier/src/fam_notifier/notifier.py
Normal file
@@ -0,0 +1,400 @@
|
||||
"""
|
||||
MotionNotifier - NAS 端运动监测通知服务(轮询主路径,2026-08-22 定型;
|
||||
2026-09-13 从 fam-core 拆出,成为 NAS 上唯一保留的服务)
|
||||
|
||||
拆出来的原因:摄像头插在 NAS 上(Surveillance Station 就是 NAS 本身),这部分
|
||||
搬不走;而其余所有东西——数据库、查询接口、AI 问答、登录——都已经迁到甲骨文。
|
||||
拆完之后 NAS 侧没有 Flask、没有 MariaDB、没有对外端口,只有这一个进程单向往
|
||||
甲骨文推事件,挂了重启即可,不影响网站。
|
||||
|
||||
职责:
|
||||
在 NAS 本机**轮询**群晖 Surveillance Station 的运动侦测事件
|
||||
(SYNO.SurveillanceStation.EventCenter.Event method=List,参数名下划线风格
|
||||
camera_ids/start_time/end_time,event_type=10 即运动),增量推送到甲骨文
|
||||
FAM-Edge 的 /api/ss/motion 接口。轮询能拿到真实 event_id/start_time/duration。
|
||||
|
||||
数据方向(关键约束): NAS -> Oracle,单向。甲骨文不再反向访问 NAS。
|
||||
- 原 fam-edge 的 dsm_motion_client(甲骨文主动查 SS API)已停用;
|
||||
- 推送后由甲骨文本地落库 ss_motion_events,供 video_processor 本地预过滤使用。
|
||||
|
||||
可靠性设计:
|
||||
- 游标持久化在本地 JSON 文件(拆出前存 NAS MariaDB 的 sync_cursor 表,
|
||||
现在 NAS 上已经没有数据库了);重启优先续用游标,
|
||||
停机期间的事件由窗口回看补推;仅首次部署时才初始化为 SS 当前最大 id。
|
||||
- 推送失败的批次不推进游标,下一轮窗口回看重试(不丢事件)。
|
||||
- 心跳线程(enabled 即跑,与轮询无关):定期空 POST /api/ss/motion,证明
|
||||
NAS->Oracle 推送链路存活,供甲骨文侧判断"无运动"结论是否可信。
|
||||
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import threading
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import requests
|
||||
|
||||
from .logger import setup_logger
|
||||
from .config_loader import load_config
|
||||
|
||||
logger = setup_logger('fam-notifier')
|
||||
|
||||
# 游标文件:拆出前这是 MariaDB 里的一行,现在 NAS 上没有数据库了。
|
||||
# 丢了也不致命——窗口回看会把最近的事件补推一遍,甲骨文侧按 event_id 幂等落库。
|
||||
_CURSOR_PATH = os.environ.get('FAM_NOTIFIER_CURSOR') or os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
|
||||
'data', 'cursor.json')
|
||||
|
||||
|
||||
def _read_cursor() -> str:
|
||||
try:
|
||||
with open(_CURSOR_PATH, encoding='utf-8') as f:
|
||||
return str(json.load(f).get('motion_last_event_id') or '')
|
||||
except (OSError, ValueError):
|
||||
return ''
|
||||
|
||||
|
||||
def _write_cursor(value: str):
|
||||
try:
|
||||
os.makedirs(os.path.dirname(_CURSOR_PATH), exist_ok=True)
|
||||
with open(_CURSOR_PATH, 'w', encoding='utf-8') as f:
|
||||
json.dump({'motion_last_event_id': str(value)}, f)
|
||||
except OSError as e:
|
||||
logger.warning(f"游标写入失败(下轮窗口回看会补推,不丢事件): {e}")
|
||||
|
||||
_MOTION_NOTIFIER = None
|
||||
|
||||
|
||||
def get_motion_notifier():
|
||||
"""模块级单例(app.py 启动时创建并 start,状态接口经此获取)。"""
|
||||
global _MOTION_NOTIFIER
|
||||
if _MOTION_NOTIFIER is None:
|
||||
_MOTION_NOTIFIER = MotionNotifier()
|
||||
return _MOTION_NOTIFIER
|
||||
|
||||
|
||||
class MotionNotifier:
|
||||
def __init__(self):
|
||||
cfg = load_config().get('motion_notifier', {})
|
||||
self.enabled = bool(cfg.get('enabled', False))
|
||||
self.dsm_host = cfg.get('dsm_host', '192.168.50.64')
|
||||
self.dsm_port = int(cfg.get('dsm_port', 5000))
|
||||
self.dsm_account = self._resolve(cfg.get('dsm_account', ''))
|
||||
self.dsm_password = self._resolve(cfg.get('dsm_password', ''))
|
||||
self.camera_ids = cfg.get('camera_ids', [2])
|
||||
self.oracle_base_url = cfg.get('oracle_base_url',
|
||||
'http://129.146.26.249:5000').rstrip('/')
|
||||
self.oracle_token = self._resolve(cfg.get('oracle_token', '${ORACLE_SYNC_TOKEN}'))
|
||||
self.poll_interval_sec = int(cfg.get('poll_interval_sec', 60))
|
||||
self.poll_window_hours = int(cfg.get('poll_window_hours', 2))
|
||||
self.batch_size = int(cfg.get('batch_size', 100))
|
||||
self.timeout = int(cfg.get('timeout_sec', 10))
|
||||
# 是否启用轮询(默认开启):轮询 EventCenter.Event.List 为主数据源,拿到
|
||||
# 真实 event_id/start_time/duration;Webhook 仅作为可选的低延迟补充
|
||||
# (SS 行动规则未配置时不会触发,不影响主路径)
|
||||
self.poll_enabled = bool(cfg.get('poll_enabled', True))
|
||||
# 心跳:跟"轮询 SS"是两回事——不查 SS,只是定期空 POST 一下甲骨文,证明
|
||||
# NAS->Oracle 这条推送链路本身还活着。enabled=true 时始终跑(不受
|
||||
# poll_enabled 影响),供甲骨文侧 has_motion_in_range_local() 判断
|
||||
# "这段时间没收到运动事件"是真的没运动,还是推送链路已经挂了。
|
||||
self.heartbeat_interval_sec = int(cfg.get('heartbeat_interval_sec', 300))
|
||||
# 轮询路径(poll_enabled=true 时)关注的摄像头
|
||||
self.camera_ids = cfg.get('camera_ids', [2])
|
||||
self._base = f"http://{self.dsm_host}:{self.dsm_port}/webapi"
|
||||
self._sid = None
|
||||
self._running = False
|
||||
self._thread = None
|
||||
self._last_event_id = None
|
||||
# 推送过但 duration<=0(动作进行中,SS 尚未回填真实时长)的事件 id。
|
||||
# 下轮窗口回查时若已结束(duration>0)补推覆盖 Oracle,避免分割永远跳过。
|
||||
self._zero_dur_ids = set()
|
||||
self._last_poll_at = None
|
||||
self._last_error = None
|
||||
self._pushed_total = 0
|
||||
self._hb_running = False
|
||||
self._hb_thread = None
|
||||
self._last_heartbeat_at = None
|
||||
self._last_heartbeat_error = None
|
||||
|
||||
@staticmethod
|
||||
def _resolve(v):
|
||||
"""解析 ${ENV} 引用;非字符串或不含 ${...} 原样返回。"""
|
||||
if isinstance(v, str) and v.startswith('${') and v.endswith('}'):
|
||||
return os.environ.get(v[2:-1], '')
|
||||
return v
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Surveillance Station 登录 / 事件查询
|
||||
# ------------------------------------------------------------------
|
||||
def _login(self):
|
||||
try:
|
||||
resp = requests.get(
|
||||
f"{self._base}/auth.cgi",
|
||||
params={"api": "SYNO.API.Auth", "version": 6, "method": "login",
|
||||
"account": self.dsm_account, "passwd": self.dsm_password,
|
||||
"session": "SurveillanceStation", "format": "sid"},
|
||||
timeout=self.timeout)
|
||||
data = resp.json()
|
||||
if data.get('success'):
|
||||
return data['data']['sid']
|
||||
logger.warning(f"SS 登录失败: {data.get('error')}")
|
||||
except Exception as e:
|
||||
logger.warning(f"SS 登录异常: {e}")
|
||||
return None
|
||||
|
||||
def _fetch_events(self, start_ts: int, end_ts: int):
|
||||
"""查询 [start_ts, end_ts] 窗口内的 SS 事件。返回事件列表或 None(查询失败)。"""
|
||||
if self._sid is None:
|
||||
self._sid = self._login()
|
||||
if self._sid is None:
|
||||
return None
|
||||
for attempt in range(2):
|
||||
try:
|
||||
resp = requests.get(
|
||||
f"{self._base}/entry.cgi",
|
||||
params={"api": "SYNO.SurveillanceStation.EventCenter.Event",
|
||||
"version": 1, "method": "List",
|
||||
"camera_ids": ",".join(str(c) for c in self.camera_ids),
|
||||
"start_time": start_ts, "end_time": end_ts,
|
||||
"limit": 1000, "_sid": self._sid},
|
||||
timeout=self.timeout)
|
||||
data = resp.json()
|
||||
except Exception as e:
|
||||
logger.warning(f"SS 事件查询异常: {e}")
|
||||
return None
|
||||
if not data.get('success'):
|
||||
code = (data.get('error') or {}).get('code')
|
||||
if code in (106, 107, 119) and attempt == 0:
|
||||
# session 过期/被顶掉,重新登录重试一次
|
||||
self._sid = self._login()
|
||||
if self._sid is None:
|
||||
return None
|
||||
continue
|
||||
logger.warning(f"SS 事件查询失败: {data.get('error')}")
|
||||
return None
|
||||
# 响应按 ds_id(CMS 多机场景的服务器 id,单机固定 "0")分组,拉平
|
||||
events = [e for grp in (data.get('data') or {}).values()
|
||||
for e in (grp or [])]
|
||||
return events
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 推送到甲骨文
|
||||
# ------------------------------------------------------------------
|
||||
def push_events_to_oracle(self, events) -> int:
|
||||
"""把标准化后的事件列表推送到 Oracle /api/ss/motion。返回成功推送条数。"""
|
||||
if not events:
|
||||
return 0
|
||||
norm = []
|
||||
for e in events:
|
||||
eid = e.get('id') or e.get('event_id')
|
||||
if eid is None:
|
||||
continue
|
||||
norm.append({
|
||||
"event_id": int(eid),
|
||||
"camera_id": e.get('camera_id'),
|
||||
"event_type": e.get('event_type'),
|
||||
"start_time": e.get('start_time'),
|
||||
"duration": e.get('duration'),
|
||||
"thumbnail_url": e.get('thumbnail_url') or e.get('thumbnail_dir'),
|
||||
})
|
||||
if not norm:
|
||||
return 0
|
||||
try:
|
||||
resp = requests.post(
|
||||
f"{self.oracle_base_url}/api/ss/motion",
|
||||
json={"token": self.oracle_token, "events": norm},
|
||||
timeout=(10, 30))
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"推送运动事件到 Oracle 失败: {e}")
|
||||
self._last_error = str(e)
|
||||
return 0
|
||||
if resp.status_code != 200:
|
||||
logger.error(f"推送运动事件到 Oracle 返回 {resp.status_code}: {resp.text[:200]}")
|
||||
self._last_error = f"HTTP {resp.status_code}"
|
||||
return 0
|
||||
try:
|
||||
stored = resp.json().get('stored', 0)
|
||||
except ValueError:
|
||||
stored = len(norm)
|
||||
self._pushed_total += stored
|
||||
self._last_error = None
|
||||
logger.info(f"运动事件推送成功: {len(norm)} 条 -> Oracle 存储 {stored}")
|
||||
return stored
|
||||
|
||||
def send_heartbeat(self) -> bool:
|
||||
"""空 events 调一次 /api/ss/motion,只为证明 NAS->Oracle 推送链路还活着。
|
||||
|
||||
跟 push_events_to_oracle 分开一个方法,是因为那个方法 events 为空时直接
|
||||
return 0(不发请求)——心跳恰恰就是要在没有真实事件时也发一次请求。
|
||||
"""
|
||||
try:
|
||||
resp = requests.post(
|
||||
f"{self.oracle_base_url}/api/ss/motion",
|
||||
json={"token": self.oracle_token, "events": []},
|
||||
timeout=(10, 30))
|
||||
except requests.RequestException as e:
|
||||
logger.warning(f"运动心跳推送失败: {e}")
|
||||
self._last_heartbeat_error = str(e)
|
||||
return False
|
||||
if resp.status_code != 200:
|
||||
logger.warning(f"运动心跳推送返回 {resp.status_code}: {resp.text[:200]}")
|
||||
self._last_heartbeat_error = f"HTTP {resp.status_code}"
|
||||
return False
|
||||
self._last_heartbeat_at = datetime.now()
|
||||
self._last_heartbeat_error = None
|
||||
return True
|
||||
|
||||
def _heartbeat_run(self):
|
||||
logger.info(f"运动心跳线程启动,间隔 {self.heartbeat_interval_sec}s")
|
||||
while self._hb_running:
|
||||
try:
|
||||
self.send_heartbeat()
|
||||
except Exception as e:
|
||||
self._last_heartbeat_error = str(e)
|
||||
logger.error(f"运动心跳异常: {e}", exc_info=True)
|
||||
for _ in range(self.heartbeat_interval_sec):
|
||||
if not self._hb_running:
|
||||
break
|
||||
time.sleep(1)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 增量轮询主循环
|
||||
# ------------------------------------------------------------------
|
||||
def _init_cursor(self):
|
||||
"""启动时初始化 last_event_id。
|
||||
|
||||
优先使用文件里保存的游标:服务停机/重启期间产生的新事件,会在重启后
|
||||
被下一轮窗口回看捞到并补推(不丢事件)。
|
||||
仅当没有有效游标(首次部署)时,取 SS 当前最大事件 id 作为起点,
|
||||
避免把历史事件全部回灌一遍。
|
||||
"""
|
||||
saved = _read_cursor()
|
||||
if saved and int(saved) > 0:
|
||||
self._last_event_id = int(saved)
|
||||
logger.info(f"运动通知游标初始化(本地文件): last_event_id={self._last_event_id}")
|
||||
return
|
||||
now = int(datetime.now(timezone.utc).timestamp())
|
||||
events = self._fetch_events(now - 3600, now) # 最近 1h 用于定位最大 id
|
||||
if events:
|
||||
self._last_event_id = max(
|
||||
int(e.get('id', 0)) for e in events if e.get('id'))
|
||||
else:
|
||||
self._last_event_id = 0
|
||||
logger.info(f"运动通知游标初始化(SS 当前最大): last_event_id={self._last_event_id}")
|
||||
|
||||
def _poll_once(self):
|
||||
now = int(datetime.now(timezone.utc).timestamp())
|
||||
start_ts = now - int(self.poll_window_hours * 3600)
|
||||
events = self._fetch_events(start_ts, now)
|
||||
if events is None:
|
||||
return # 查询失败,下一轮重试
|
||||
by_id = {int(e.get('id')): e for e in events if e.get('id')}
|
||||
|
||||
# 1) 补推:先前推送时 duration<=0(动作进行中)的事件,现在若已结束
|
||||
# (SS 回填真实 duration>0),重推覆盖 Oracle(ON CONFLICT UPDATE)。
|
||||
# 重启兜底:游标 DB 续用 → 窗口回看会把历史事件整体重推,届时同样覆盖。
|
||||
if self._zero_dur_ids:
|
||||
recheck = []
|
||||
for eid in list(self._zero_dur_ids):
|
||||
e = by_id.get(eid)
|
||||
if e and int(e.get('duration') or 0) > 0:
|
||||
recheck.append(e)
|
||||
self._zero_dur_ids.discard(eid)
|
||||
if recheck:
|
||||
self.push_events_to_oracle(recheck)
|
||||
logger.info(f"补推 {len(recheck)} 条已结束事件的真实 duration")
|
||||
|
||||
# 2) 增量推送新事件(id 大于游标)
|
||||
new = [e for e in events
|
||||
if e.get('id') and int(e.get('id')) > (self._last_event_id or 0)]
|
||||
if not new:
|
||||
return
|
||||
new.sort(key=lambda e: int(e.get('id', 0)))
|
||||
cursor = self._last_event_id
|
||||
for i in range(0, len(new), self.batch_size):
|
||||
batch = new[i:i + self.batch_size]
|
||||
stored = self.push_events_to_oracle(batch)
|
||||
if stored > 0:
|
||||
# 只有推送成功的批次才推进游标;失败批次保持原地,
|
||||
# 下一轮窗口回看会重新捞到并重试(不丢事件)
|
||||
cursor = max(int(e.get('id', 0)) for e in batch)
|
||||
# 记录推送时仍在进行中的事件(duration<=0),待下轮补推真实时长
|
||||
for e in batch:
|
||||
if int(e.get('duration') or 0) <= 0:
|
||||
self._zero_dur_ids.add(int(e.get('id')))
|
||||
self._last_event_id = cursor
|
||||
_write_cursor(str(self._last_event_id))
|
||||
|
||||
def _run(self):
|
||||
logger.info(f"MotionNotifier 启动,轮询间隔 {self.poll_interval_sec}s,"
|
||||
f"目标 SS {self.dsm_host}:{self.dsm_port}")
|
||||
try:
|
||||
self._init_cursor()
|
||||
except Exception as e:
|
||||
logger.warning(f"运动通知游标初始化失败(从 0 开始): {e}")
|
||||
self._last_event_id = 0
|
||||
while self._running:
|
||||
try:
|
||||
self._poll_once()
|
||||
self._last_poll_at = datetime.now()
|
||||
except Exception as e:
|
||||
self._last_error = str(e)
|
||||
logger.error(f"运动通知轮询异常: {e}", exc_info=True)
|
||||
# 分段休眠,便于 stop 快速唤醒
|
||||
for _ in range(self.poll_interval_sec):
|
||||
if not self._running:
|
||||
break
|
||||
time.sleep(1)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
def start(self):
|
||||
if not self.enabled:
|
||||
logger.info("MotionNotifier 未启用(motion_notifier.enabled=false)")
|
||||
return
|
||||
# 心跳线程跟轮询是否开启无关:只要 MotionNotifier 整体 enabled,就该
|
||||
# 持续证明推送链路活着,哪怕当前正好没有真实运动事件可推送。
|
||||
if not self._hb_running:
|
||||
self._hb_running = True
|
||||
self._hb_thread = threading.Thread(
|
||||
target=self._heartbeat_run, daemon=True, name='motion-heartbeat')
|
||||
self._hb_thread.start()
|
||||
if not self.poll_enabled:
|
||||
logger.info("MotionNotifier 轮询已禁用(poll_enabled=false),仅作为 "
|
||||
"Webhook 推送客户端 + 心跳 + 摄像头名映射使用")
|
||||
return
|
||||
if self._running:
|
||||
return
|
||||
self._running = True
|
||||
self._thread = threading.Thread(target=self._run, daemon=True,
|
||||
name='motion-notifier')
|
||||
self._thread.start()
|
||||
|
||||
def is_alive(self):
|
||||
return self._thread is not None and self._thread.is_alive()
|
||||
|
||||
def stop(self):
|
||||
self._running = False
|
||||
if self._thread:
|
||||
self._thread.join(timeout=5)
|
||||
self._hb_running = False
|
||||
if self._hb_thread:
|
||||
self._hb_thread.join(timeout=5)
|
||||
|
||||
def status(self) -> dict:
|
||||
return {
|
||||
"heartbeat_running": self._hb_thread is not None and self._hb_thread.is_alive(),
|
||||
"last_heartbeat_at": self._last_heartbeat_at.isoformat() if self._last_heartbeat_at else None,
|
||||
"last_heartbeat_error": self._last_heartbeat_error,
|
||||
"running": self.is_alive(),
|
||||
"enabled": self.enabled,
|
||||
"poll_enabled": self.poll_enabled,
|
||||
"last_event_id": self._last_event_id,
|
||||
"zero_dur_pending": len(self._zero_dur_ids),
|
||||
"last_poll_at": self._last_poll_at.isoformat() if self._last_poll_at else None,
|
||||
"last_error": self._last_error,
|
||||
"pushed_total": self._pushed_total,
|
||||
"oracle": self.oracle_base_url,
|
||||
"dsm": f"{self.dsm_host}:{self.dsm_port}",
|
||||
}
|
||||
7
fam-notifier/tests/conftest.py
Normal file
7
fam-notifier/tests/conftest.py
Normal file
@@ -0,0 +1,7 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
# 让测试能直接 `from fam_notifier.xxx import yyy`,无需先 pip install -e .
|
||||
_SRC = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'src')
|
||||
if _SRC not in sys.path:
|
||||
sys.path.insert(0, _SRC)
|
||||
114
fam-notifier/tests/test_notifier.py
Normal file
114
fam-notifier/tests/test_notifier.py
Normal file
@@ -0,0 +1,114 @@
|
||||
import time
|
||||
|
||||
from fam_notifier.notifier import MotionNotifier
|
||||
|
||||
|
||||
def _cfg(**overrides):
|
||||
base = {
|
||||
"enabled": True,
|
||||
"poll_enabled": False,
|
||||
"dsm_host": "192.168.50.64",
|
||||
"dsm_port": 5000,
|
||||
"dsm_account": "ericwyuan",
|
||||
"dsm_password": "secret",
|
||||
"oracle_base_url": "http://oracle.example:5000",
|
||||
"oracle_token": "tok123",
|
||||
"heartbeat_interval_sec": 300,
|
||||
"timeout_sec": 5,
|
||||
}
|
||||
base.update(overrides)
|
||||
return {"motion_notifier": base}
|
||||
|
||||
|
||||
def _make_notifier(monkeypatch, **cfg_overrides):
|
||||
monkeypatch.setattr(
|
||||
"fam_notifier.notifier.load_config",
|
||||
lambda: _cfg(**cfg_overrides))
|
||||
return MotionNotifier()
|
||||
|
||||
|
||||
class _FakeResp:
|
||||
def __init__(self, status_code=200, payload=None, text=""):
|
||||
self.status_code = status_code
|
||||
self._payload = payload or {}
|
||||
self.text = text
|
||||
|
||||
def json(self):
|
||||
return self._payload
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 心跳(跟轮询无关,enabled=true 就该跑)
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
def test_send_heartbeat_success_updates_state(monkeypatch):
|
||||
n = _make_notifier(monkeypatch)
|
||||
calls = []
|
||||
|
||||
def fake_post(url, json=None, timeout=None):
|
||||
calls.append((url, json))
|
||||
return _FakeResp(200, {"status": "ok", "received": 0, "stored": 0})
|
||||
monkeypatch.setattr("fam_notifier.notifier.requests.post", fake_post)
|
||||
ok = n.send_heartbeat()
|
||||
assert ok is True
|
||||
assert n._last_heartbeat_at is not None
|
||||
assert n._last_heartbeat_error is None
|
||||
assert calls[0][0] == "http://oracle.example:5000/api/ss/motion"
|
||||
assert calls[0][1]["events"] == []
|
||||
assert calls[0][1]["token"] == "tok123"
|
||||
|
||||
|
||||
def test_send_heartbeat_http_error_records_failure(monkeypatch):
|
||||
n = _make_notifier(monkeypatch)
|
||||
|
||||
def fake_post(url, json=None, timeout=None):
|
||||
return _FakeResp(500, {}, text="boom")
|
||||
monkeypatch.setattr("fam_notifier.notifier.requests.post", fake_post)
|
||||
ok = n.send_heartbeat()
|
||||
assert ok is False
|
||||
assert n._last_heartbeat_error == "HTTP 500"
|
||||
|
||||
|
||||
def test_send_heartbeat_network_exception_records_failure(monkeypatch):
|
||||
import requests as _requests
|
||||
n = _make_notifier(monkeypatch)
|
||||
|
||||
def fake_post(url, json=None, timeout=None):
|
||||
raise _requests.RequestException("connection refused")
|
||||
monkeypatch.setattr("fam_notifier.notifier.requests.post", fake_post)
|
||||
ok = n.send_heartbeat()
|
||||
assert ok is False
|
||||
assert "connection refused" in n._last_heartbeat_error
|
||||
|
||||
|
||||
def test_start_runs_heartbeat_thread_even_when_poll_disabled(monkeypatch):
|
||||
"""核心诉求: 心跳跟"是否轮询 SS"是两回事——poll_enabled=false 时轮询线程
|
||||
不应该启动,但心跳线程必须照样跑,否则甲骨文永远收不到心跳,
|
||||
has_motion_in_range_local() 会一直 fail-open,省配额的效果就没了。"""
|
||||
n = _make_notifier(monkeypatch, poll_enabled=False, heartbeat_interval_sec=3600)
|
||||
monkeypatch.setattr(
|
||||
"fam_notifier.notifier.requests.post",
|
||||
lambda url, json=None, timeout=None: _FakeResp(200, {"stored": 0}))
|
||||
try:
|
||||
n.start()
|
||||
time.sleep(0.2)
|
||||
assert n.is_alive() is False # 轮询线程未启动
|
||||
assert n._hb_thread is not None and n._hb_thread.is_alive()
|
||||
finally:
|
||||
n.stop()
|
||||
|
||||
|
||||
def test_start_does_nothing_when_disabled(monkeypatch):
|
||||
n = _make_notifier(monkeypatch, enabled=False)
|
||||
n.start()
|
||||
time.sleep(0.1)
|
||||
assert n._hb_thread is None
|
||||
assert n.is_alive() is False
|
||||
|
||||
|
||||
def test_status_includes_heartbeat_fields(monkeypatch):
|
||||
n = _make_notifier(monkeypatch)
|
||||
st = n.status()
|
||||
assert "heartbeat_running" in st
|
||||
assert "last_heartbeat_at" in st
|
||||
assert "last_heartbeat_error" in st
|
||||
@@ -1,53 +1,68 @@
|
||||
<script setup>
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { api, fmtDateTime } from './api.js'
|
||||
import { api } from './api.js'
|
||||
import { navItems } from './router.js'
|
||||
|
||||
const route = useRoute()
|
||||
const sync = ref(null)
|
||||
const authed = ref(false)
|
||||
|
||||
async function refreshStatus() {
|
||||
async function checkAuth() {
|
||||
try {
|
||||
const data = await api.status()
|
||||
sync.value = data.sync
|
||||
const r = await api.authCheck()
|
||||
authed.value = !!r.authed
|
||||
} catch {
|
||||
sync.value = null
|
||||
authed.value = false
|
||||
}
|
||||
}
|
||||
|
||||
let timer = null
|
||||
onMounted(() => {
|
||||
refreshStatus()
|
||||
timer = setInterval(refreshStatus, 30000)
|
||||
})
|
||||
onUnmounted(() => clearInterval(timer))
|
||||
|
||||
async function doLogout() {
|
||||
try {
|
||||
await fetch('/api/logout', { method: 'POST' })
|
||||
} catch {
|
||||
// 忽略网络错误,下面强制跳转即可
|
||||
}
|
||||
// 清掉 cookie 后回根路径,未登录态会让后端 401 -> 自动跳 /login 重新登录
|
||||
window.location.href = '/'
|
||||
}
|
||||
|
||||
onMounted(checkAuth)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex min-h-screen flex-col lg:flex-row">
|
||||
<!-- 桌面端左侧栏:品牌 + 同步状态。lg 以下隐藏,改用下面的移动端顶栏 -->
|
||||
<!-- 桌面端左侧栏:品牌 + 登录入口。lg 以下隐藏,改用下面的移动端顶栏 -->
|
||||
<aside class="hidden w-64 shrink-0 border-r border-border bg-[#090c12] px-4 py-6 lg:block">
|
||||
<div class="mb-6">
|
||||
<div class="text-[17px] font-bold text-[#f7f9fc]">🏠 家庭智能监控</div>
|
||||
<div class="mt-1 text-[11px] text-text-mute">SENTINEL HOME AI · 管理后台</div>
|
||||
</div>
|
||||
<div v-if="sync" class="rounded-xl border border-border bg-panel-2 px-4 py-3 text-xs leading-loose text-text-dim">
|
||||
<b class="text-[#f7f9fc]">同步状态</b><br />
|
||||
状态 <span :class="sync.running ? 'font-semibold text-ok' : 'font-semibold text-danger'">{{ sync.running ? '同步中' : '未运行' }}</span><br />
|
||||
最近 <b class="text-[#ccd5e1]">{{ sync.last_sync_at ? fmtDateTime(sync.last_sync_at) : '—' }}</b><br />
|
||||
本次增量 {{ sync.last_count ? `视频+${sync.last_count[0]} / 事件+${sync.last_count[1]} / 人物+${sync.last_count[2]}` : '—' }}<br />
|
||||
游标 {{ sync.cursor ? fmtDateTime(sync.cursor) : '(全量)' }}
|
||||
<div v-if="sync.last_error" class="font-semibold text-danger">⚠ {{ sync.last_error }}</div>
|
||||
<div class="mt-4">
|
||||
<a v-if="!authed" href="/login"
|
||||
class="block rounded-xl bg-gradient-to-br from-accent to-accent-2 px-4 py-2.5 text-center text-sm font-semibold text-white shadow-[0_4px_16px_-4px_rgba(91,140,255,.45)] transition-opacity hover:opacity-90">
|
||||
🔑 登录
|
||||
</a>
|
||||
<button v-else @click="doLogout"
|
||||
class="block w-full rounded-xl border border-border bg-panel-2 px-4 py-2.5 text-center text-sm font-medium text-text-dim transition-colors hover:text-text">
|
||||
👋 退出登录
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- 移动端顶栏:品牌 + 简要同步指示灯,lg 以上隐藏(用左侧栏代替) -->
|
||||
<!-- 移动端顶栏:品牌 + 登录/退出,lg 以上隐藏(用左侧栏代替) -->
|
||||
<header class="flex items-center justify-between border-b border-border bg-[#090c12] px-4 py-3 lg:hidden">
|
||||
<div class="text-[15px] font-bold text-[#f7f9fc]">🏠 家庭智能监控</div>
|
||||
<span v-if="sync" class="flex items-center gap-1.5 text-xs font-medium" :class="sync.running ? 'text-ok' : 'text-danger'">
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-current"></span>{{ sync.running ? '同步中' : '未运行' }}
|
||||
</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<a v-if="!authed" href="/login"
|
||||
class="rounded-lg bg-gradient-to-br from-accent to-accent-2 px-3 py-1.5 text-xs font-semibold text-white shadow-[0_4px_16px_-4px_rgba(91,140,255,.45)]">
|
||||
🔑 登录
|
||||
</a>
|
||||
<button v-else @click="doLogout"
|
||||
class="rounded-lg border border-border bg-panel-2 px-3 py-1.5 text-xs font-medium text-text-dim">
|
||||
退出
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="min-w-0 flex-1 px-4 py-4 sm:px-8 sm:py-6">
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
// API 薄封装:生产环境同源相对路径;开发环境走 vite.config.js 的 /api 代理。
|
||||
|
||||
// 全局:未登录时首个 401 直接跳转到 /login 走 OIDC/PKCE 登录流程。
|
||||
// 用一个模块级开关保证整次会话只触发一次跳转,避免多个并发 401 重复导航。
|
||||
let _redirectingToLogin = false
|
||||
|
||||
async function request(path, options = {}) {
|
||||
const res = await fetch(path, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -12,6 +16,14 @@ async function request(path, options = {}) {
|
||||
// 非 JSON 响应(如 404 空 body),保持 data=null
|
||||
}
|
||||
if (!res.ok) {
|
||||
// 401 未登录:交给后端 /login(302 到 auth-hub)发起统一登录。
|
||||
// /api/auth/check 等白名单接口不会返回 401,所以这里只会命中真正的鉴权失败。
|
||||
if (res.status === 401 && !_redirectingToLogin) {
|
||||
_redirectingToLogin = true
|
||||
window.location.href = '/login'
|
||||
// 返回永挂起的 promise,阻止调用方继续渲染"未登录"错误态(页面即将跳转)
|
||||
return new Promise(() => {})
|
||||
}
|
||||
const msg = (data && (data.error || data.message)) || `HTTP ${res.status}`
|
||||
throw new Error(msg)
|
||||
}
|
||||
@@ -33,8 +45,10 @@ export const api = {
|
||||
|
||||
videos: (params = {}) => request(`/api/ui/videos${qs(params)}`),
|
||||
videoDetail: (id) => request(`/api/ui/videos/${id}`),
|
||||
deleteVideo: (id) => request(`/api/ui/videos/${id}`, { method: 'DELETE' }),
|
||||
stats: (date) => request(`/api/ui/stats${date ? '?date=' + date : ''}`),
|
||||
people: () => request('/api/ui/people'),
|
||||
peopleClips: (label, limit = 10) => request(`/api/ui/people/clips?label=${encodeURIComponent(label)}&limit=${limit}`),
|
||||
namedMembers: () => request('/api/ui/named-members'),
|
||||
modelStats: () => request('/api/ui/model-stats'),
|
||||
attentionEvents: () => request('/api/ui/attention-events'),
|
||||
@@ -49,7 +63,13 @@ export const api = {
|
||||
mergeMember: (source, target) =>
|
||||
request('/api/member/merge', { method: 'POST', body: JSON.stringify({ source, target }) }),
|
||||
|
||||
status: () => request('/api/status'),
|
||||
authCheck: () => request('/api/auth/check'),
|
||||
|
||||
identityCorrect: (video_id, current_name, new_name) =>
|
||||
request('/api/member/identity-correct', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ video_id, current_name, new_name }),
|
||||
}),
|
||||
}
|
||||
|
||||
/** 剥离全角/半角括号备注(如 '人物A(别名:人物B)' -> '人物A'),与后端归一化一致 */
|
||||
|
||||
@@ -1,12 +1,39 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { fmtTime, parsePersons } from '../api.js'
|
||||
import { computed, ref } from 'vue'
|
||||
import { api, fmtTime, parsePersons } from '../api.js'
|
||||
import Badge from './Badge.vue'
|
||||
|
||||
const props = defineProps({
|
||||
event: { type: Object, required: true },
|
||||
videoId: { type: [Number, String], required: true },
|
||||
})
|
||||
const emit = defineEmits(['corrected'])
|
||||
|
||||
// 闭集人物识别只有这 4 个真实成员,纠错时二选/四选一,不是自由填字符串
|
||||
const CANONICAL_NAMES = ['爷爷', '爸爸', '媳妇', '汤圆']
|
||||
const fixingPerson = ref(null) // 当前正在纠错的人物名(打开选择器)
|
||||
const fixingBusy = ref(false)
|
||||
const fixError = ref('')
|
||||
|
||||
function toggleFix(name) {
|
||||
fixError.value = ''
|
||||
fixingPerson.value = fixingPerson.value === name ? null : name
|
||||
}
|
||||
|
||||
async function correctPerson(oldName, newName) {
|
||||
if (oldName === newName) { fixingPerson.value = null; return }
|
||||
fixingBusy.value = true
|
||||
fixError.value = ''
|
||||
try {
|
||||
await api.identityCorrect(props.videoId, oldName, newName)
|
||||
fixingPerson.value = null
|
||||
emit('corrected')
|
||||
} catch (e) {
|
||||
fixError.value = e.message
|
||||
} finally {
|
||||
fixingBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const timeLabel = computed(() => fmtTime(props.event.ts))
|
||||
const persons = computed(() => parsePersons(props.event.person_list_json))
|
||||
@@ -49,10 +76,27 @@ const appearances = computed(() => {
|
||||
<div v-if="thumbUrl" class="mb-2.5 leading-none">
|
||||
<img loading="lazy" alt="事件帧" :src="thumbUrl" class="block w-full rounded-lg border border-border" />
|
||||
</div>
|
||||
<div class="mb-2 flex flex-wrap gap-2">
|
||||
<div class="mb-2 flex flex-wrap items-start gap-2">
|
||||
<Badge v-if="isAttention" tone="danger">⚠ 需关注</Badge>
|
||||
<Badge v-for="p in persons" :key="p" tone="accent">{{ p }}</Badge>
|
||||
<div v-for="p in persons" :key="p" class="relative">
|
||||
<button type="button" class="group inline-flex items-center gap-1" @click="toggleFix(p)" title="点击纠正这个人是谁">
|
||||
<Badge tone="accent">{{ p }}</Badge>
|
||||
<span class="text-[10px] text-text-faint opacity-0 transition-opacity group-hover:opacity-100">✎</span>
|
||||
</button>
|
||||
<div v-if="fixingPerson === p"
|
||||
class="absolute left-0 top-full z-10 mt-1 flex gap-1.5 rounded-lg border border-border-hi bg-panel p-2 shadow-[var(--shadow-card)]">
|
||||
<button v-for="name in CANONICAL_NAMES" :key="name" :disabled="fixingBusy"
|
||||
@click="correctPerson(p, name)"
|
||||
class="rounded-md border px-2 py-1 text-xs disabled:opacity-50"
|
||||
:class="name === p
|
||||
? 'border-accent/50 bg-accent/15 text-accent-bright'
|
||||
: 'border-border bg-panel-2 text-text-dim hover:border-accent/40 hover:text-white'">
|
||||
{{ name }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="fixError" class="mb-2 text-xs text-danger">纠错失败:{{ fixError }}</div>
|
||||
<div v-if="appearances.length" class="mb-2">
|
||||
<div v-for="a in appearances" :key="a.uid" class="my-1.5 rounded-lg border border-border bg-panel-3 px-2.5 py-1.5 text-xs">
|
||||
<b class="text-accent-bright">{{ a.uid }}</b>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { api, fmtMonthDayTime } from '../api.js'
|
||||
import Badge from './Badge.vue'
|
||||
|
||||
@@ -8,6 +9,7 @@ const props = defineProps({
|
||||
allLabels: { type: Array, default: () => [] },
|
||||
})
|
||||
const emit = defineEmits(['named', 'merged'])
|
||||
const router = useRouter()
|
||||
|
||||
const isNamed = computed(() => props.group.is_named)
|
||||
const firstSeenStr = computed(() => {
|
||||
@@ -77,6 +79,31 @@ async function doMerge(sourceLabel, target) {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 运动片段区块:某人物出现过的运动片段(按运动视频提取)
|
||||
const clipsOpen = ref(false)
|
||||
const clips = ref([])
|
||||
const clipsLoading = ref(false)
|
||||
const clipsError = ref('')
|
||||
|
||||
async function toggleClips() {
|
||||
clipsOpen.value = !clipsOpen.value
|
||||
if (!clipsOpen.value || clips.value.length || clipsLoading.value) return
|
||||
clipsLoading.value = true
|
||||
clipsError.value = ''
|
||||
try {
|
||||
const data = await api.peopleClips(props.group.display, 10)
|
||||
clips.value = data.clips || []
|
||||
} catch (e) {
|
||||
clipsError.value = e.message
|
||||
} finally {
|
||||
clipsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goToClip(videoId) {
|
||||
router.push({ path: '/timeline', query: { video: videoId } })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -101,6 +128,28 @@ async function doMerge(sourceLabel, target) {
|
||||
</div>
|
||||
<div v-else class="mt-2 text-[11px] text-text-faint">特征待大模型补充(下段视频分析时由 VLM 落库)</div>
|
||||
|
||||
<button @click="toggleClips" class="mt-3 w-full rounded-lg border border-border bg-panel px-3 py-1.5 text-xs font-medium text-text-dim transition-colors hover:border-border-hi">
|
||||
{{ clipsOpen ? '收起' : '查看' }}运动片段{{ clips.length ? `(${clips.length})` : '' }}
|
||||
</button>
|
||||
<div v-if="clipsOpen" class="mt-2 space-y-2">
|
||||
<div v-if="clipsLoading" class="py-2 text-center text-[11px] text-text-faint">加载中…</div>
|
||||
<div v-else-if="clipsError" class="py-2 text-center text-[11px] text-danger">{{ clipsError }}</div>
|
||||
<div v-else-if="!clips.length" class="py-2 text-center text-[11px] text-text-faint">暂无该人物的运动片段</div>
|
||||
<button v-for="c in clips" :key="c.video_id" @click="goToClip(c.video_id)"
|
||||
class="flex w-full items-start gap-2.5 rounded-xl border border-border bg-panel-3 p-2 text-left transition-colors hover:border-border-hi">
|
||||
<img v-if="c.first_ts" loading="lazy" alt="片段缩略图"
|
||||
:src="`/api/proxy/frame?video_id=${c.video_id}&ts=${encodeURIComponent(c.first_ts)}&w=160`"
|
||||
class="h-[54px] w-[96px] shrink-0 rounded-lg border border-border object-cover" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-[12px] font-medium text-text">{{ fmtMonthDayTime(c.event_start_time) }}
|
||||
<span v-if="c.duration_sec" class="ml-1 font-normal text-text-mute">{{ Math.round(c.duration_sec) }}s</span>
|
||||
</div>
|
||||
<div class="mt-0.5 line-clamp-2 text-[11px] leading-snug text-text-dim">{{ c.summary_json || '(暂无摘要)' }}</div>
|
||||
<div v-if="c.clip_events" class="mt-0.5 text-[10px] text-text-faint">{{ c.clip_events }} 个事件 · {{ c.camera_name || '未知' }}</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="errorMsg" class="mt-2 text-xs text-danger">{{ errorMsg }}</p>
|
||||
|
||||
<div v-if="!isNamed" class="mt-3 space-y-2.5">
|
||||
|
||||
@@ -11,6 +11,9 @@ const selectedQuick = ref('自定义')
|
||||
const loading = ref(false)
|
||||
const errorMsg = ref('')
|
||||
const result = ref(null)
|
||||
const showThinking = ref(true)
|
||||
|
||||
const PROVIDER_LABEL = { gemini: 'Gemini', nvidia: 'NVIDIA', ollama: '本地 Ollama' }
|
||||
|
||||
const quickQuestions = computed(() => [
|
||||
`${queriedPerson.value}今天干嘛了?`,
|
||||
@@ -30,15 +33,66 @@ onMounted(async () => {
|
||||
} catch { /* 忽略:下拉留空即可 */ }
|
||||
})
|
||||
|
||||
function handleEvent(obj) {
|
||||
if (obj.type === 'context') {
|
||||
result.value.contextCount = obj.count
|
||||
result.value.contextSummary = obj.summary
|
||||
result.value.contextPreview = obj.preview || ''
|
||||
} else if (obj.type === 'provider_trying') {
|
||||
result.value.tried.push(obj.provider)
|
||||
result.value.provider = obj.provider
|
||||
} else if (obj.type === 'chunk') {
|
||||
result.value.answer += obj.text
|
||||
if (obj.provider) result.value.provider = obj.provider
|
||||
} else if (obj.type === 'done') {
|
||||
if (obj.provider) result.value.provider = obj.provider
|
||||
result.value.finished = true
|
||||
showThinking.value = false // 回答完了自动收起思考过程,用户可以再点开
|
||||
} else if (obj.type === 'error' || obj.type === 'all_failed') {
|
||||
errorMsg.value = 'AI 服务暂时不可用,请稍后重试'
|
||||
}
|
||||
}
|
||||
|
||||
async function ask() {
|
||||
errorMsg.value = ''
|
||||
result.value = null
|
||||
if (!userQuestion.value.trim()) { errorMsg.value = '请输入问题'; return }
|
||||
if (!queriedPerson.value.trim()) { errorMsg.value = '请输入查询人物'; return }
|
||||
loading.value = true
|
||||
showThinking.value = true
|
||||
result.value = {
|
||||
question: userQuestion.value, answer: '', provider: '', tried: [],
|
||||
contextCount: null, contextSummary: '', contextPreview: '', finished: false,
|
||||
}
|
||||
try {
|
||||
const data = await api.chatAsk(userQuestion.value, queriedPerson.value, queriedDate.value)
|
||||
result.value = { question: userQuestion.value, answer: data.answer, contextSummary: data.context_summary }
|
||||
const res = await fetch('/api/chat/ask/stream', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
question: userQuestion.value,
|
||||
queried_person: queriedPerson.value,
|
||||
queried_date: queriedDate.value,
|
||||
}),
|
||||
})
|
||||
if (!res.ok || !res.body) {
|
||||
let msg = `HTTP ${res.status}`
|
||||
try { msg = (await res.json()).error || msg } catch { /* 非 JSON 错误体 */ }
|
||||
throw new Error(msg)
|
||||
}
|
||||
const reader = res.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buf = ''
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
buf += decoder.decode(value, { stream: true })
|
||||
const parts = buf.split('\n\n')
|
||||
buf = parts.pop() ?? ''
|
||||
for (const part of parts) {
|
||||
const line = part.trim()
|
||||
if (!line.startsWith('data: ')) continue
|
||||
try { handleEvent(JSON.parse(line.slice(6))) } catch { /* 忽略半截 JSON */ }
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
errorMsg.value = e.message
|
||||
} finally {
|
||||
@@ -84,7 +138,7 @@ async function ask() {
|
||||
|
||||
<button :disabled="loading" @click="ask"
|
||||
class="mt-4 rounded-xl bg-gradient-to-br from-accent to-accent-2 px-5 py-2.5 text-sm font-semibold text-white shadow-[0_4px_16px_-2px_rgba(91,140,255,.4)] disabled:opacity-50">
|
||||
{{ loading ? 'AI 正在思考…' : '提问' }}
|
||||
{{ loading ? 'AI 正在回答…' : '提问' }}
|
||||
</button>
|
||||
|
||||
<p v-if="errorMsg" class="mt-3 text-sm text-danger">{{ errorMsg }}</p>
|
||||
@@ -94,10 +148,33 @@ async function ask() {
|
||||
<div class="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-text-mute">❓ 提问</div>
|
||||
{{ result.question }}
|
||||
</div>
|
||||
|
||||
<!-- 思考过程/使用数据:可展开/收起 -->
|
||||
<div class="rounded-2xl border border-border bg-panel-3 shadow-[var(--shadow-card)]">
|
||||
<button type="button" @click="showThinking = !showThinking"
|
||||
class="flex w-full items-center gap-2 px-4 py-2.5 text-left text-xs font-semibold text-text-dim hover:text-white">
|
||||
<span class="inline-block transition-transform" :class="showThinking ? 'rotate-90' : ''">▶</span>
|
||||
<span>🔍 思考过程 · 使用数据</span>
|
||||
<span v-if="!result.finished && loading" class="text-accent">生成中…</span>
|
||||
<span v-if="result.provider" class="ml-auto font-normal text-text-faint">
|
||||
{{ PROVIDER_LABEL[result.provider] || result.provider }}
|
||||
</span>
|
||||
</button>
|
||||
<div v-if="showThinking" class="border-t border-border px-4 py-3 text-xs leading-relaxed text-text-mute">
|
||||
<div v-if="result.contextCount === null">正在检索相关事件…</div>
|
||||
<template v-else>
|
||||
<div>{{ result.contextSummary }}</div>
|
||||
<div v-if="result.contextPreview" class="mt-2 max-h-40 overflow-y-auto whitespace-pre-wrap rounded-lg bg-panel-2 p-2 font-mono text-[11px] text-text-faint">{{ result.contextPreview }}</div>
|
||||
</template>
|
||||
<div v-if="result.tried.length" class="mt-2">
|
||||
尝试模型: {{ result.tried.map(p => PROVIDER_LABEL[p] || p).join(' → ') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-border bg-panel-2 p-4 text-sm leading-relaxed shadow-[var(--shadow-card)]">
|
||||
<div class="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-text-mute">🤖 回答</div>
|
||||
<div class="whitespace-pre-wrap">{{ result.answer }}</div>
|
||||
<div class="whitespace-pre-wrap">{{ result.answer }}<span v-if="loading && !result.finished" class="animate-pulse">▍</span></div>
|
||||
</div>
|
||||
<p v-if="result.contextSummary" class="text-xs text-text-faint">上下文: {{ result.contextSummary }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -24,6 +24,7 @@ async function load() {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
onMounted(load)
|
||||
|
||||
const oracle = computed(() => data.value?.oracle || {})
|
||||
@@ -38,7 +39,11 @@ const qs = computed(() => queue.value.stats || {})
|
||||
|
||||
const rclone = computed(() => oracle.value.rclone)
|
||||
const person = computed(() => oracle.value.person)
|
||||
const nasSync = computed(() => data.value?.nas_sync)
|
||||
const segment = computed(() => oracle.value.segment)
|
||||
const segCons = computed(() => segment.value?.consistency || {})
|
||||
const motion = computed(() => oracle.value.motion || {})
|
||||
// 心跳超过 15 分钟视为推送链路失联(与甲骨文侧 max_heartbeat_age_sec 默认值一致)
|
||||
const motionOk = computed(() => (motion.value.heartbeat_age_sec ?? 1e9) < 900)
|
||||
const modelCalls = computed(() => oracle.value.model_calls || [])
|
||||
const activities = computed(() => oracle.value.activities || [])
|
||||
|
||||
@@ -56,13 +61,13 @@ const SVC_BADGE = {
|
||||
<button :disabled="loading" @click="load" class="rounded-lg border border-border bg-panel-2 px-3.5 py-1.5 text-sm text-text-dim hover:border-accent/40 hover:text-white disabled:opacity-50">
|
||||
{{ loading && data ? '刷新中…' : '🔄 刷新' }}
|
||||
</button>
|
||||
<span class="text-xs text-text-mute">点击刷新立即更新</span>
|
||||
<span class="text-xs text-text-mute">数据直接来自甲骨文,无需同步</span>
|
||||
</div>
|
||||
|
||||
<Spinner v-if="loading && !data" text="加载服务状态…" />
|
||||
<template v-else>
|
||||
<p v-if="oracleError" class="mb-4 text-sm text-warn">{{ oracleError }}</p>
|
||||
<EmptyState v-if="!data?.oracle && !data?.nas_sync" text="暂时无法获取服务状态" />
|
||||
<EmptyState v-if="!data?.oracle" text="暂时无法获取服务状态" />
|
||||
|
||||
<template v-else>
|
||||
<div class="mb-3 text-[15px] font-bold text-[#f7f9fc]">各服务当前状态</div>
|
||||
@@ -88,10 +93,12 @@ const SVC_BADGE = {
|
||||
{{ person ? person.action : '暂无记录' }}
|
||||
<template #sub>{{ person ? `${(person.ts || '').slice(0, 19)} · ${(person.detail || '').slice(0, 46)}` : '—' }}</template>
|
||||
</ServiceCard>
|
||||
<ServiceCard icon="📡" name="NAS 同步" :value-color="nasSync ? '#fbbf24' : undefined">
|
||||
{{ nasSync ? `游标 ${(nasSync.cursor || '').slice(0, 19)}` : '不可达' }}
|
||||
<ServiceCard icon="📡" name="NAS 运动推送" :value-color="motionOk ? '#4ade80' : '#fbbf24'">
|
||||
{{ motion.heartbeat_age_sec == null ? '从未收到心跳'
|
||||
: motionOk ? `正常 · ${Math.round(motion.heartbeat_age_sec)}s 前`
|
||||
: `失联 ${Math.round(motion.heartbeat_age_sec / 60)} 分钟` }}
|
||||
<template #sub>
|
||||
{{ nasSync ? `最近 ${(nasSync.last_sync_at || '').slice(0, 19)} · 增量 V${nasSync.last_count?.[0] ?? 0} E${nasSync.last_count?.[1] ?? 0} P${nasSync.last_count?.[2] ?? 0} M${nasSync.last_count?.[3] ?? 0}` : '—' }}
|
||||
{{ motion.recent?.length ? `最近事件 #${motion.recent[0].event_id} · ${(motion.recent[0].received_at || '').slice(0, 19)}` : '暂无运动事件' }}
|
||||
</template>
|
||||
</ServiceCard>
|
||||
<ServiceCard icon="🧠" name="云端模型" :value-color="modelCalls.length ? '#4ade80' : undefined">
|
||||
@@ -101,6 +108,21 @@ const SVC_BADGE = {
|
||||
近5次 成功{{ modelCalls.filter(m => m.success).length }}/{{ modelCalls.length }}
|
||||
</template>
|
||||
</ServiceCard>
|
||||
<ServiceCard icon="🎬" name="视频分割" :value-color="segment?.total ? '#f472b6' : undefined">
|
||||
{{ segment && segment.total ? `运动片段 ${segment.done ?? 0}/${segment.total} · 文件 ${segment.clips_files ?? 0}` : '暂无片段' }}
|
||||
<template #sub v-if="segment && segment.total">
|
||||
待处理 {{ segment.pending ?? 0 }} · 失败 {{ segment.failed ?? 0 }} · 运动事件 {{ segment.motion_events ?? 0 }}
|
||||
<span v-if="segCons.event_total" class="mt-0.5 block">
|
||||
一致性:事件 {{ segCons.event_total }} · 已结束 {{ segCons.finished }} · 已分割 {{ segCons.segmented }}
|
||||
<span :class="(segCons.gap_count || 0) > 0 ? 'text-warn' : 'text-text-mute'">
|
||||
· 缺口 {{ segCons.gap_count ?? 0 }}
|
||||
</span>
|
||||
</span>
|
||||
<span v-if="segment.last" class="mt-0.5 block text-text-mute">
|
||||
{{ (segment.last.ts || '').slice(0, 19) }} · {{ (segment.last.detail || '').slice(0, 42) }}
|
||||
</span>
|
||||
</template>
|
||||
</ServiceCard>
|
||||
</div>
|
||||
|
||||
<div class="mb-3 text-[15px] font-bold text-[#f7f9fc]">最近活动</div>
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { api, fmtDateOnly, fmtDateTime } from '../api.js'
|
||||
import { api, fmtDateOnly } from '../api.js'
|
||||
import PageHeader from '../components/PageHeader.vue'
|
||||
import EmptyState from '../components/EmptyState.vue'
|
||||
import Spinner from '../components/Spinner.vue'
|
||||
|
||||
const modelChart = ref([]) // [{label, value}]
|
||||
const attention = ref([])
|
||||
const syncStatus = ref(null)
|
||||
const loadError = ref('')
|
||||
const loading = ref(true)
|
||||
|
||||
@@ -18,10 +17,9 @@ async function load() {
|
||||
loadError.value = ''
|
||||
loading.value = true
|
||||
try {
|
||||
const [ms, att, statusData] = await Promise.all([
|
||||
const [ms, att] = await Promise.all([
|
||||
api.modelStats(),
|
||||
api.attentionEvents(),
|
||||
api.status(),
|
||||
])
|
||||
const byProvider = {}
|
||||
for (const row of ms.aggregate) {
|
||||
@@ -30,7 +28,6 @@ async function load() {
|
||||
}
|
||||
modelChart.value = Object.entries(byProvider).map(([label, value]) => ({ label, value }))
|
||||
attention.value = att.events
|
||||
syncStatus.value = statusData.sync
|
||||
} catch (e) {
|
||||
loadError.value = e.message
|
||||
} finally {
|
||||
@@ -42,7 +39,7 @@ onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageHeader icon="📈" title="统计图表" sub="模型来源 / 关注事件 / 同步状态" />
|
||||
<PageHeader icon="📈" title="统计图表" sub="模型来源 / 关注事件" />
|
||||
|
||||
<Spinner v-if="loading" text="加载统计数据…" />
|
||||
<template v-else>
|
||||
@@ -79,15 +76,5 @@ onMounted(load)
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="mb-3 mt-6 text-[15px] font-bold text-[#f7f9fc]">同步状态</div>
|
||||
<div v-if="syncStatus" class="rounded-xl border border-border bg-panel-2 px-4 py-3 text-xs leading-loose text-text-dim">
|
||||
运行状态 <span :class="syncStatus.running ? 'font-semibold text-ok' : 'font-semibold text-danger'">{{ syncStatus.running ? '同步中' : '未运行' }}</span><br />
|
||||
最近同步 <b class="text-[#ccd5e1]">{{ syncStatus.last_sync_at ? fmtDateTime(syncStatus.last_sync_at) : '—' }}</b><br />
|
||||
本次增量 {{ syncStatus.last_count ? `视频+${syncStatus.last_count[0]} / 事件+${syncStatus.last_count[1]} / 人物+${syncStatus.last_count[2]}` : '—' }}<br />
|
||||
游标 <b class="text-[#ccd5e1]">{{ syncStatus.cursor ? fmtDateTime(syncStatus.cursor) : '(全量)' }}</b><br />
|
||||
周期 {{ syncStatus.interval_sec }}s
|
||||
<div v-if="syncStatus.last_error" class="font-semibold text-danger">⚠ {{ syncStatus.last_error }}</div>
|
||||
</div>
|
||||
<EmptyState v-else text="无法获取同步状态" />
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { api, fmtDateOnly, fmtMonthDayTime, fmtDateTime, parseTs } from '../api.js'
|
||||
import PageHeader from '../components/PageHeader.vue'
|
||||
import StatCard from '../components/StatCard.vue'
|
||||
@@ -8,11 +9,14 @@ import EventItem from '../components/EventItem.vue'
|
||||
import Badge from '../components/Badge.vue'
|
||||
import Spinner from '../components/Spinner.vue'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const dateFilter = ref('')
|
||||
const page = ref(0)
|
||||
const videos = ref([])
|
||||
const stats = ref(null)
|
||||
const selectedId = ref(null)
|
||||
// 支持 ?video=<id> 定位(人物卡/其他页跳转);非 null 时不因列表重置选中
|
||||
const selectedId = ref(Number(route.query.video) || null)
|
||||
const detail = ref(null)
|
||||
const loadError = ref('')
|
||||
const videosLoading = ref(true)
|
||||
@@ -32,7 +36,8 @@ async function loadVideos() {
|
||||
try {
|
||||
const data = await api.videos({ date: dateFilter.value || undefined, page: page.value })
|
||||
videos.value = data.videos
|
||||
if (videos.value.length && !videos.value.some(v => v.id === selectedId.value)) {
|
||||
// 仅当没有 query 定位且当前选中不在列表时,才默认选第一条
|
||||
if (videos.value.length && !videos.value.some(v => v.id === selectedId.value) && !route.query.video) {
|
||||
selectedId.value = videos.value[0].id
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -89,6 +94,28 @@ const modelBadges = computed(() => {
|
||||
const provider = detail.value?.video?.compute_provider || ''
|
||||
return provider ? String(provider).split(',').map(p => p.trim()).filter(Boolean) : []
|
||||
})
|
||||
|
||||
const deleteBusy = ref(false)
|
||||
const deleteError = ref('')
|
||||
|
||||
async function deleteVideo(id) {
|
||||
if (!confirm('确定删除这个视频会话吗?此操作不可恢复,会同时删除对应的视频文件。')) return
|
||||
deleteBusy.value = true
|
||||
deleteError.value = ''
|
||||
try {
|
||||
await api.deleteVideo(id)
|
||||
videos.value = videos.value.filter(v => v.id !== id)
|
||||
if (selectedId.value === id) {
|
||||
selectedId.value = videos.value.length ? videos.value[0].id : null
|
||||
if (!selectedId.value) detail.value = null
|
||||
}
|
||||
loadStats()
|
||||
} catch (e) {
|
||||
deleteError.value = e.message
|
||||
} finally {
|
||||
deleteBusy.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -138,14 +165,20 @@ const modelBadges = computed(() => {
|
||||
<span>{{ detail.video.camera_name || '未知摄像头' }}</span>
|
||||
<Badge tone="ok">会话 #{{ detail.video.id }}</Badge>
|
||||
<Badge v-for="m in modelBadges" :key="m" tone="neutral">{{ m }}</Badge>
|
||||
<button :disabled="deleteBusy" @click="deleteVideo(detail.video.id)"
|
||||
class="ml-auto rounded-lg border border-danger/35 bg-danger/14 px-2.5 py-1 text-xs font-medium text-danger transition-colors hover:bg-danger/25 disabled:opacity-40">
|
||||
{{ deleteBusy ? '删除中…' : '🗑 删除会话' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-1.5 font-mono text-[13px] text-text-dim tabular">⏱ {{ rangeStr }} · 文件 {{ detail.video.filename }}</div>
|
||||
<div class="mt-2.5 whitespace-pre-wrap text-sm leading-relaxed text-[#d6dce6]">{{ detail.video.summary_json || '暂无全局摘要' }}</div>
|
||||
<div v-if="deleteError" class="mt-2.5 rounded-lg border border-danger/35 bg-danger/14 px-3 py-2 text-xs text-danger">{{ deleteError }}</div>
|
||||
</div>
|
||||
|
||||
<EmptyState v-if="!detail.events.length" icon="🎞" text="该会话暂无时间点事件" />
|
||||
<div v-else>
|
||||
<EventItem v-for="ev in detail.events" :key="ev.id" :event="ev" :video-id="detail.video.id" />
|
||||
<EventItem v-for="ev in detail.events" :key="ev.id" :event="ev" :video-id="detail.video.id"
|
||||
@corrected="loadDetail(detail.video.id)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,8 @@ import vue from '@vitejs/plugin-vue'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
// 开发环境代理 /api 到 NAS 上的 fam-core,方便本地直接联调真实数据。
|
||||
// 生产环境由 fam-core 的 Flask 同源提供,不需要这个代理。
|
||||
// 生产环境由云服务器 Caddy :80 托管静态产物 + 反代 /api/*(经 frp 隧道回源
|
||||
// 到这里同一个 NAS fam-core :8000),不需要这个代理,跟开发环境走的是同一个后端。
|
||||
export default defineConfig({
|
||||
plugins: [vue(), tailwindcss()],
|
||||
server: {
|
||||
|
||||
@@ -128,8 +128,13 @@ CREATE TABLE IF NOT EXISTS family_members (
|
||||
-- ============================================================
|
||||
|
||||
-- 7.1 视频会话表(Oracle videos 镜像)
|
||||
-- v2 (2026-09-03):id 改为 NAS 本地自增主键(不再直接等于 Oracle id,因 Oracle 库
|
||||
-- 重建/重排会复用 id,导致 filename UNIQUE 二次冲突 1062);Oracle 的 videos.id 仅
|
||||
-- 落 oracle_id 列溯源。业务唯一键仍为 filename。子表 video_id 引用仍用 Oracle id
|
||||
-- (新视频插入时 id=oracle_id;旧视频原地更新保留原 id,外键不失效)。
|
||||
CREATE TABLE IF NOT EXISTS sync_videos (
|
||||
id INT PRIMARY KEY COMMENT 'Oracle videos.id',
|
||||
id INT AUTO_INCREMENT PRIMARY KEY COMMENT 'NAS 本地自增主键',
|
||||
oracle_id INT COMMENT 'Oracle videos.id(仅溯源参考)',
|
||||
drive_file_id VARCHAR(255) COMMENT 'Google 硬盘文件 ID',
|
||||
filename VARCHAR(500) NOT NULL UNIQUE COMMENT '视频文件名(唯一)',
|
||||
camera_name VARCHAR(50) COMMENT '摄像头名称/位置',
|
||||
@@ -164,8 +169,11 @@ CREATE TABLE IF NOT EXISTS sync_events (
|
||||
) ENGINE=InnoDB COMMENT='甲骨文事件镜像表';
|
||||
|
||||
-- 7.3 人物规范表(Oracle people 镜像)
|
||||
-- v2 (2026-09-03):同 7.1,id 改为 NAS 本地自增主键,Oracle people.id 落 oracle_id
|
||||
-- 列溯源;业务唯一键仍为 label。people.id 无外键引用,仅作展示排序。
|
||||
CREATE TABLE IF NOT EXISTS sync_people (
|
||||
id INT PRIMARY KEY COMMENT 'Oracle people.id',
|
||||
id INT AUTO_INCREMENT PRIMARY KEY COMMENT 'NAS 本地自增主键',
|
||||
oracle_id INT COMMENT 'Oracle people.id(仅溯源参考)',
|
||||
label VARCHAR(100) NOT NULL UNIQUE COMMENT '抽象标识,如"人物A"',
|
||||
canonical_name VARCHAR(100) COMMENT '规范名(用户命名或 LLM 合并),NULL 表示未命名',
|
||||
first_seen VARCHAR(32) COMMENT '首次出现时间',
|
||||
@@ -185,6 +193,25 @@ CREATE TABLE IF NOT EXISTS sync_cursor (
|
||||
`value` VARCHAR(64) COMMENT '上次成功拉取到的 server_time(ISO 文本)'
|
||||
) ENGINE=InnoDB COMMENT='同步游标表';
|
||||
|
||||
-- 7.5 人物对应关系表(Oracle person_identity_map 镜像,2026-08-22 新增)
|
||||
-- 闭集人物识别:某视频里 Gemini 给的原始 uid 与解析出的规范名(爷爷/爸爸/媳妇/
|
||||
-- 汤圆)之间的映射,供事件时间轴"纠错"按钮定位、追溯识别来源
|
||||
-- v2 (2026-08-29):id 改为 NAS 本地自增主键,业务键 (video_id, raw_uid) 唯一;
|
||||
-- Oracle 的 person_identity_map.id 只落 oracle_id 列溯源。原以 Oracle id 为主键,
|
||||
-- 一旦 Oracle 库重建/复用 id 会引发 uq_video_raw_uid 二次冲突 (1062)。
|
||||
CREATE TABLE IF NOT EXISTS sync_identity_map (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY COMMENT 'NAS 本地自增主键',
|
||||
oracle_id INT COMMENT 'Oracle person_identity_map.id(仅溯源参考)',
|
||||
video_id INT NOT NULL COMMENT '关联 sync_videos.id',
|
||||
raw_uid VARCHAR(100) COMMENT '该视频里 Gemini 给的原始 uid',
|
||||
canonical_name VARCHAR(100) COMMENT '当前生效的规范名',
|
||||
source VARCHAR(20) COMMENT 'rule / auto_id / manual(manual 优先不被覆盖)',
|
||||
updated_at VARCHAR(32),
|
||||
synced_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uq_video_raw_uid (video_id, raw_uid),
|
||||
INDEX idx_video (video_id)
|
||||
) ENGINE=InnoDB COMMENT='甲骨文人物对应关系镜像表';
|
||||
|
||||
-- 8.1 云端模型调用统计镜像表(Oracle model_calls 镜像)
|
||||
CREATE TABLE IF NOT EXISTS sync_model_calls (
|
||||
id INT PRIMARY KEY COMMENT 'Oracle model_calls.id',
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
# 用法: ./scripts/start_core.sh
|
||||
set -e
|
||||
|
||||
# APP_DIR = <repo>/fam-core
|
||||
APP_DIR="$(cd "$(dirname "$0")/.." && pwd)/fam-core"
|
||||
CONFIG_FILE="$APP_DIR/config/config.yaml"
|
||||
|
||||
@@ -14,10 +15,16 @@ fi
|
||||
|
||||
cd "$APP_DIR"
|
||||
|
||||
# 加载项目级环境变量(ORACLE_SYNC_TOKEN 等,与甲骨文端保持一致)
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
if [ -f "$SCRIPT_DIR/../.env" ]; then
|
||||
source "$SCRIPT_DIR/../.env"
|
||||
# 加载项目级环境变量(ORACLE_SYNC_TOKEN / AUTH_HUB_* 等,与甲骨文端保持一致)
|
||||
# 注意:.env 里 AUTH_HUB_* 是“裸赋值”(无 export 前缀),必须用 set -a 包裹 source,
|
||||
# 否则这些变量只存在于 shell 内、不会 export 给 gunicorn 子进程,导致 fam-core
|
||||
# 启动后读不到 AUTH_HUB_ISSUER 等、登录被 fail-closed 拒绝(见 auth.py)。
|
||||
# 另外 .env 路径基于 $APP_DIR 推导,避免“先 cd 再算 SCRIPT_DIR”的相对路径错位。
|
||||
ENV_FILE="$APP_DIR/../.env"
|
||||
if [ -f "$ENV_FILE" ]; then
|
||||
set -a
|
||||
source "$ENV_FILE"
|
||||
set +a
|
||||
fi
|
||||
|
||||
# 检查虚拟环境
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
#!/bin/bash
|
||||
# FAM-Edge 启动脚本 (Oracle 端)
|
||||
# FAM-Edge 启动脚本 (Oracle 端) —— 仅本地调试用
|
||||
# 生产环境由 systemd fam-edge.service 守护(Restart=always,/opt/fam-edge/venv/bin/gunicorn
|
||||
# -w 1 --threads 4 --timeout 1800);部署后请用 `sudo systemctl restart fam-edge`。
|
||||
# 用法: ./scripts/start_edge.sh
|
||||
set -e
|
||||
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
#!/bin/bash
|
||||
# FAM-UI 启动脚本 (NAS 端)
|
||||
# 用法: ./scripts/start_ui.sh
|
||||
set -e
|
||||
|
||||
APP_DIR="$(cd "$(dirname "$0")/.." && pwd)/fam-ui"
|
||||
CONFIG_FILE="$APP_DIR/config/config.yaml"
|
||||
|
||||
if [ ! -f "$CONFIG_FILE" ]; then
|
||||
echo "错误: 配置文件不存在: $CONFIG_FILE"
|
||||
echo "请复制 config.yaml.example 为 config.yaml 并修改实际值"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "$APP_DIR"
|
||||
|
||||
# 注入项目级 .env(ORACLE_SYNC_TOKEN 等),供 config.yaml ${VAR} 解析
|
||||
if [ -f "$(dirname "$APP_DIR")/.env" ]; then
|
||||
source "$(dirname "$APP_DIR")/.env"
|
||||
fi
|
||||
|
||||
# 检查虚拟环境
|
||||
if [ -d "venv" ]; then
|
||||
source venv/bin/activate
|
||||
fi
|
||||
|
||||
echo "启动 FAM-UI Streamlit (端口 8501)..."
|
||||
exec streamlit run src/app.py --server.port 8501 --server.address 0.0.0.0
|
||||
Reference in New Issue
Block a user