feat: 事件时间轴缩略帧 + 人物管理头像 + 人物合并硬规则校验

## 新架构:Oracle 集中计算 + NAS 代理展示

### Oracle 端 (fam-edge)
- 新增 frame_service: ffmpeg 视频抽帧 + VLM 人物定位裁剪头像(磁盘缓存)
- 新增 /api/oracle/frame: 按 video_id+ts 抽帧返回 jpeg(带 token)
- 新增 /api/oracle/avatar: 按 label 生成人物头像(VLM 定位人物 + 兜底整帧居中)
- 新增 person_identifier: 人物身份识别模块
- Gemini 适配器支持 flash/flash-lite 双模型切换,429 自动降级
- frame_service VLM 全模型 429 时进入 10 分钟熔断,避免每次请求白打配额
- 兜底头像不落缓存,配额恢复后自动重试 VLM 精确定位

### 人物合并硬规则校验(框架级修复)
- person_service: LLM 合并结果落库前加硬冲突检测
  - 性别冲突 → 绝不合并
  - 年龄档跨未成年/成年 → 绝不合并(防止把爷爷/宝宝并进同一人)
- oracle_db: upsert_person 入口剥离括号后缀(人物A(别名:人物B) → 人物A),消灭垃圾人物行
- 修复 set_canonical 丢弃 source 参数的 bug(旧代码硬编码 'manual' 导致错误合并被永久固化)
- get_events_for_label: 只提取该身份组的特征文本,头像定位更精准

### NAS 端 (fam-core)
- 新增 img_proxy: /api/proxy/frame 和 /api/proxy/avatar 代理 Oracle 图片
- app.py 注册 img_bp 蓝图
- oracle_sync / db_layer / member_manager 同步人物表

### UI 端 (fam-ui)
- 事件时间轴: 每条事件卡片加时间点缩略帧
- 人物管理: 每人卡片加头像(150x150 圆角)
- parse_persons: 剥离括号备注,与 Oracle 归一化一致
- 新增 EventItem 组件、Timeline 页改造
- Chat / ServiceStatus 页相应调整

### 数据库
- scripts/ddl.sql: 同步表结构更新
- Oracle people 表: features_json / display_uid / source 字段完善
This commit is contained in:
ericwyuan
2026-08-23 00:13:56 +08:00
parent aca1a674b1
commit 2c5bf950c5
31 changed files with 2166 additions and 25 deletions

View File

@@ -0,0 +1,31 @@
# 2026-08-20 工作记录
## NAS 瘦身 + 甲骨文同步(架构重构收尾,任务 #49/#50/#57/#59
完成 NAS 端从「视频处理节点」到「纯管理后台」的改造:
### fam-coreNAS改动
- 删除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-uiNAS改动
- 改写读 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 部署验证,未做)
- Oraclerclone 配 Google 服务账号 JSONorcLenas@gen-lang-client-0523399799.iam.gserviceaccount.com→ systemd timer 定时同步到 /opt/fam-edge/gdrive_videos设 ORACLE_SYNC_TOKEN 环境变量;重启 fam-edge
- NASMariaDB 跑新 DDL 建 sync_* 表;设 ORACLE_SYNC_TOKEN重启 fam-core + fam-ui卸载 NAS venv 无用包opencv/numpy 等)
- 端到端验证Google 硬盘新视频 → 甲骨文处理 → NAS 30 分钟同步可见
- 服务账号私钥未入库,部署时需单独落到 Oracle 服务器本地

View File

@@ -0,0 +1,180 @@
# 2026-08-21 工作记录
## #51 部署验证 - rclone 同步 Google 硬盘 + 全链路跑通
### Oraclefam-edge部署完成
- rclone v1.75.0 装到 /usr/local/binaarch64zip 解压)
- 服务账号 JSON 存 /opt/fam-edge/gdrive-sa.jsonchmod 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 APIPOST 拿 assetId+uploadUrlPUT 上传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 已 doneprovider=gemini凌晨视频无事件=分析正确)
- NAS 重启 fam-core 触发立即同步sync_videos=21cursor 推进正确
- 剩余 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=2oracle_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.pyapp.py 改启动 VideoQueue
- oracle_dbretry_count 列(建表+ALTER 兼容旧库、get_video_by_id、PRAGMA busy_timeout=10000
- 部署验证:启动恢复入队 14 个、video_id=8 gemini 600→1200s、生产者登记 id=22 新视频、done 持续增长
## 模型调用统计界面 + lite 超时实测×4commit b87b38b + 3e94c2a
- Oracle 端oracle_db 新增 model_calls 表provider/model/video_id/started_at/duration_sec/success/error/created_atBaseModelAdapter 加 model_call_hook + _emit_model_callgemini(_generate_video 每模型每attempt)/nvidia(analyze_video) 每次请求记录get_sync_delta 下发 model_callscreated_at >= since + NAS 幂等 upsert 防漏)
- gemini 支持模型级 model_timeouts最终值不参与 ×2实测 lite 370MB 视频耗时 22.6s(生产 27~34slite 限制 = 22.6×4 ≈ 90sflash 仍 1200s600×2。日志确认flash 本轮 1200s / lite 本轮 90s
- NAS 端sync_model_calls 镜像表ddl 8.1db_layer upsert_sync_model_calls/get_sync_model_calls/get_sync_model_calls_statsoracle_sync 拉 model_callslast_count 变 4 元组)
- fam-ui 新增 "🤖 模型统计" 页:按模型聚合(成功/失败/成功率/平均耗时/最后调用)+ 最近 100 条调用明细(请求时间/模型/耗时/状态/失败原因/视频)
- 验证Oracle model_calls 正常记录flash 429_quota 失败、lite 成功 27~34sNAS 同步 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 上传 OKchat.completions 必失败)。建议从 vision_order 移除或换模型
- gemini-flash-lite 超时 90s → **480s8 分钟)**config model_timeouts 已改并部署(日志确认 480s
- 处理进度done 33 / failed 1video24 待重试)/ pending 12
## NVIDIA 多模型降级链 + 实测结论commit b7b5fe6
- nvidia_adapter 改造model_chainasset 上传一次,逐个模型 video_url 引用尝试)+ switch_interval_sec 切换间隔(默认 5s+ model_timeouts 每模型独立超时 + compute_provider 带模型名nvidia:{model}
- **实测全部 NVIDIA 云端视频候选不可用**omni 500asset_id 引用失败、12b 400、llama-3.2-11b-vision 400不支持视频、cosmos-reason2/phi-3-vision/gemma-3/kosmos-2/fuyu-8b/paligemma 404base64 超 25MBpublic URL 方案被用户否决(不暴露视频)
- configmodel_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}.jpgapi_gateway 新增 GET /api/oracle/video/{id}/thumbtoken 校验,无 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/40KBfam-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}/thumbtoken 校验)
- fam-ui 事件查询加 e.idrender_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.5hGemini 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_atmark_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.yamltoken/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.pybuild_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_PROMPTchat_ask 改调 build_chat_prompt
- 部署验证prompt 生成正常3 秒密度/camera/唯一性均含fam-edge activefam-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且被后续部署重启中断pending28/29 从未处理
4. NAS 镜像状态与 Oracle 错位35-45 NAS 显示 done、Oracle 实为 pending——Oracle 重建后 NAS 增量未对齐
**修复**
1. **日期维度统一改视频实际录制时间**fam-ui事件时间轴统计/列表/关注事件统计)与 db_layerget_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:38/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_statusVideoQueue 打点(register/reanalyze/process_start/process_done/process_fail)+_current 当前处理跟踪+status()PersonService 打点(merge_done/merge_skip)api_gateway 新增 GET /api/oracle/activitytoken 鉴权,返回 queue/db.by_status/rclone/person/model_calls/最近50条活动
- **rclone_sync.sh**:同步结果写 activity 表service=rclone, transferred/deleted/exitTRANS 提取正则修过一次(输出带 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 56rclone sync_done/person merge_done 打点生效401 鉴权NAS→Oracle HTTP 200fam-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 条 +8hNAS 镜像 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=Xtoken 鉴权events.person_list_json 按 `%"label"%` JSON 数组精确匹配,按 id DESC 遍历返回第一个 ev_{id}.jpg 存在的截图(人物出现事件中最新的有截图画面);撤销 event_thumb 的视频首帧兜底(改回 404
- **fam-ui 人物页**:头像改调 avatar 接口(先 requests 探测 200 再渲染 imglabel 用 urllib.parse.quote 编码)
- 验证人物A/B avatar 均 20039-41KB 真实事件画面)、无 token 401、NAS→Oracle 200、UI 200
## Oracle 磁盘扩容(用户控制台扩盘 + 重启生效)
- **背景**rclone 持续同步视频导致 45G 盘写满(剩 46Mgdrive_videos 31G/75 个视频)。用户说已扩容但服务器 lsblk 一直 46.6G(在线未生效)
- **处理**growpart/resize2fs 均 NOCHANGE块设备没变临时清理 /tmp 残留+apt+journal 释放 ~900M禁用 rclone timer 防写满;按用户要求重启服务器
- **重启后扩容生效**sda 46.6G→**150G**Ubuntu cloud-init 开机自动扩展分区+文件系统 → df 146G 可用 90G39%
- **恢复**enable --now rclone-sync.timer同步恢复视频 75→121 个持续下载中fam-edge active
- 经验Oracle 在线扩容偶尔不立即生效重启可触发Ubuntu 自动 growfs扩容后无需手动 growpart
- 提醒视频持续增长121 个已占 56G150G 约可再装 240 个视频,长期需考虑清理策略(如只保留分析完的摘要+删本地视频,需改 rclone 策略避免重新拉回)或再扩盘
## 人物模块重构 v3 - 大模型特征值替代 OpenCVcommit 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→163843秒密度+特征使 JSON 巨大被截断解析失败
2. **json_parser.validate_schema 白名单丢弃 person_appearances**(在 _normalize 之前执行)→ 补透传
- **验证**video 46 重分析 → lite 输出 16 事件含完整特征(性别男/中年/中等/短发/蓝色POLO衫/无辨识events 带特征 6、people.features_json 落库NAS 同步 6 条+人物A特征卡数据 ✓
- 部署Oracle 卸载 opencv/numpyffprobe 已有NAS ALTER 加 3 列
- 遗留NVIDIA 模型链仍不可用gemini 429 时 lite 兜底);历史视频无特征(前端显示"特征待大模型补充",下段分析自动补)

View File

@@ -0,0 +1,214 @@
# 2026-08-22
## 核查 SS Webhook 配置字段sentinel-home-ai
- 本机 SS 版本 9.3.0-12139≥9.1.1Webhook 动作设备可用。
- 关键发现:**SS Webhook 与 motion_bp.py 接收端 schema 不匹配**。
- SS Webhook 是「用户自定义参数名 + 模板变量」机制Motion Detection 支持:
%EVENT_TIME%(字符串,非 epoch、%DEVICE_NAME%(摄像头名文本,非 camera_id
%EVENT_NAME%、%SERVER_NAME%、%THUMBNAIL_URL%。每事件单次 POSTbody 为 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 事件列表补全」链路测试(不写生产代码)
- 用户要求先测清整条链路再决定是否实现。测试结论:**思路完全可行,各段均真机验证通过**。
- **链路 ASS 事件列表真实字段)**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~几秒,长动作数十秒)。
- **链路 A2webhook 触发时刻→补全匹配)**:模拟 webhook 触发时刻=事件 start_timeepoch
用 ±120s 窗口找时间最接近事件diff=0 精准匹配,补全出真实 event_id/start_time/duration/thumbnail。
(注:真实 %EVENT_TIME% 是分钟级字符串,解析为 epoch 与真实 start_time 差 ≤ 数秒120s 窗口足够覆盖。)
- **链路 B补全结果推甲骨文落库**POST /api/ss/motionbody 含 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/DeleteEventArchive.jsSave 需复杂参数431
非旧文档的 RangeExport9.3 已无此方法,实测 103
- `ThirdParty/Recording/Download/v1`camId/startTime/endTime ISO 字符串)返回 401需第三方授权不可用
- **关键发现:录像文件明文直读**(无需任何 API
- 路径:`/volume1/surveillance/<摄像头名>/<YYYYMMDDAM|PM>/<摄像头名>-YYYYMMDD-HHMMSS-<起始epoch毫秒>-<seq>.mp4`
- 每 30 分钟一个片段(约 377MBh264 2880x1620@14.28fps + pcm_alaw(8kHz) 音频;
文件名含起始 epoch 毫秒,可用 (camera_id, start_time) 直接定位camera_id→目录名需 SS Camera API 映射)。
- recording_encrypt.db 存在但当前录像为明文(未启用加密)。
- **裁剪验证(真机)**:事件 id=25535start=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 MP4copy 模式从关键帧起,±秒级偏差;需精确可重编码)。
**必须 -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.pypoll_enabled 默认 true_fetch_events limit 100→1000
_init_cursor 优先续用 DB 游标重启补推停机期间事件DB 空才初始化为 SS 当前最大 id
_poll_once 仅推送成功批次才前进游标(失败批次下轮窗口回看重试,不丢事件);
docstring 改为轮询主路径说明。
- config.yamlpoll_enabled: true加 camera_ids: [2],注释同步。
- motion_bp.pydocstring 改为"Webhook 可选补充,非主路径"(端点保留)。
- **端到端验证(真机)**:重启后游标续用 DB=25491 → 第一轮补推 55 条停机期间事件25492~25546
第二轮增量 2 条25547/25548Oracle ss_motion_events 总数 21+55+2=**78**event_id 全为真实 SS id
25471~25548duration 真实分布0~31s/api/ss/statuspoll_enabled=true running=true
heartbeat_running=true pushed_total=57。
- 结论:轮询路径现在简单稳定、不丢事件(重启补推 + 失败重试 + 真实字段Webhook 降级为可选。
## 运动事件驱动架构 v3commit a1523b4/8d6cfad/ffcc292已部署验证
- 用户决策:**不再处理整段视频**timeline 事件轴/人物管理/统计全部用"运动时间处理后的数据"。前端不改。
- 澄清rclone 整段素材**必须保留**(在甲骨文按运动时间分割);音频**保留转码**aac
- **实测纠正认知**SS 事件 duration=0 只出现在动作进行中(查询时未结束),结束必为正数
id=25538 11:38 查 dur=012: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_idvideo_processor 素材→分割/片段→分析
双分支ffmpeg -c:v copy -c:a aac 保留音频motion_event_id 幂等video_queue 片段入队;
config 加 motion_segment 块clips_dir=/opt/fam-edge/motion_clips
- **E2E 真机验证**:素材 32711:08:59→ 分割 35 段event_id 25504-25538dur 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.bakNAS 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_eventsTimeline.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 全量同步 v32.1 网络要点(轮询主路径/Webhook 可选2.2 拓扑图Streamlit→Vue3、Video-Queue/分割);
3.1-3.3 模块表MotionNotifier poll 主路径+游标/心跳、UI-API、people clips、Person-Service4.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 重写为 v3Vue3 构建/部署、systemd、tar 管道、验证清单含 motion_clips
- 删除过时 scripts/start_ui.shStreamlitstart_edge.sh 注明 systemd 为准。
- 补提交 fam-core/tests/test_motion_notifier.py。
- 残留扫描README 仅剩"不再依赖 Streamlit/从 Streamlit 迁移"正确表述。git 工作区干净(仅 .workbuddy/ 记忆)。
## 服务状态页「视频分割」状态卡commit cbcc5d0已部署
- 用户需求:服务状态界面要能看到视频分割状态(前端口误选方向后澄清)。
- Oracleoracle_db.get_segment_status()motion_event_id 片段 total/done/pending/failed + ss_motion_events 事件数 +
最近 segment 活动api_gateway /api/oracle/activity 加 segment 字段 + clips_filesmotion_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=0NAS 首次推送后游标前进不再重推 →
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_totalSS 全量 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_2557913: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.tomlS99frpc.sh 守护)→ 甲骨文 129.146.203.203:7000frps
已有 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_PASSNAS .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 cookie7 天),重启 fam-core 需重新登录。
- **验证(真机)**:内网+外网全链路:未登录 302→/login、登录页 200、API 401、错误密码 401、正确登录 200+cookie、
带 cookie 访问 /api/ui/videos 20015 条运动片段)、/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 secretKeyHTTP 明文传输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=1motion_25148
8/21 09:33 事件),从 8/21 往 8/15 顺序处理。单并发 + Gemini 429 重试,预计跑数小时~十数小时。

View File

@@ -0,0 +1,30 @@
# sentinel-home-ai 项目长期记忆
## 项目概况
- 家庭多模态智能监控系统,仓库 `http://192.168.50.64:3000/ericwyuan/sentinel-home-ai`Gitea 内网)
- 本地 `/Users/ericwyuan/Desktop/Work/sentinel-home-ai`monorepofam-core NAS 端 + fam-edge Oracle 端 + fam-ui Vue3 前端)
## 架构 v32026-08-22 定稿:运动事件驱动)
- **不再分析整段视频**。Google Drive --rclone--> Oracle gdrive_videos整段素材保留
Oracle 按 NAS 推送的 `ss_motion_events`start_time/durationUnix epochffmpeg 分割运动片段
`-c:v copy -c:a aac` 保留音频,产物 `/opt/fam-edge/motion_clips/`**只分析运动片段**。
- NAS MotionNotifier 轮询 SS `EventCenter.Event.List`60scamera_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/iLoveJava5fam-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.203.203)**SSH key `~/.ssh/oracle_sentinel`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 可写。
- 部署:本地 git 提交 push Gitea → tar 管道NAS 直接解包Oracle `--strip-components=1` 到临时目录再 cp避免动 data/venv
## 提交规范(强制)
- 格式 `[阶段X.Y子任务号] 子任务名称 - 完成内容简述`Bug 修复 `fix(模块): 问题简述`
- 一任务一 commit开工先 `git pull --rebase`;阻塞先 commit 加 `[WIP]`
- 禁止 `git push --force``--no-verify`

View File

@@ -31,6 +31,7 @@ oracle_sync:
chat_handler: chat_handler:
# 智能问答统一走 FAM-Edge 编排端点Gemini → NVIDIA → 本地 Ollama 兜底) # 智能问答统一走 FAM-Edge 编排端点Gemini → NVIDIA → 本地 Ollama 兜底)
qa_url: "http://129.146.203.203:5000/api/edge/chat/ask" qa_url: "http://129.146.203.203:5000/api/edge/chat/ask"
qa_stream_url: "http://129.146.203.203:5000/api/edge/chat/ask/stream" # 流式版
timeout: 120 timeout: 120
# 运动监测通知服务2026-08-22 定稿:轮询主路径) # 运动监测通知服务2026-08-22 定稿:轮询主路径)

View File

@@ -90,6 +90,21 @@ def status():
}), 200 }), 200
@app.route('/api/sync/trigger', methods=['POST'])
def sync_trigger():
"""手动立即触发一次甲骨文增量同步(服务状态页"立即同步"按钮)。
正常情况下后台线程每 30 分钟自动拉一次;这个接口给用户想立刻看到最新数据
时用跟后台线程共用同一把拉取锁oracle_sync._pull_lock不会并发重复拉。
"""
if not _sync:
return jsonify({"error": "同步服务未初始化"}), 503
ok = _sync.trigger_now()
if not ok:
return jsonify({"status": "failed", "error": _sync.status().get("last_error")}), 502
return jsonify({"status": "ok", **_sync.status()}), 200
if __name__ == '__main__': if __name__ == '__main__':
cfg = load_config() cfg = load_config()
port = cfg.get('server', {}).get('port', 8000) port = cfg.get('server', {}).get('port', 8000)

View File

@@ -13,7 +13,7 @@ Chat-Handler - Flask 蓝图,接收用户问答(新架构 v2
""" """
import json import json
import requests import requests
from flask import Blueprint, request, jsonify from flask import Blueprint, request, jsonify, Response, stream_with_context
from ..logger import setup_logger from ..logger import setup_logger
from ..config_loader import load_config from ..config_loader import load_config
@@ -115,6 +115,95 @@ def chat_ask():
}), 200 }), 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_sync_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_sync_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://129.146.203.203: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}")
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',
headers={'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'})
@chat_bp.route('/api/chat/history', methods=['GET']) @chat_bp.route('/api/chat/history', methods=['GET'])
def chat_history(): def chat_history():
"""获取对话历史""" """获取对话历史"""

View File

@@ -370,6 +370,39 @@ def upsert_sync_model_calls(rows: List[Dict]) -> int:
conn.close() conn.close()
def upsert_sync_identity_map(rows: List[Dict]) -> int:
"""批量 upsert Oracle 传来的 person_identity_map 增量(幂等,重复覆盖)。
甲骨文不稳定,提取出的有效数据(含闭集人物识别结果/纠错记录)都要同步到
NAS 防丢失——这张表跟 sync_people/sync_model_calls 走一样的镜像模式。
"""
if not rows:
return 0
conn = get_conn()
n = 0
try:
cur = conn.cursor()
for r in rows:
cur.execute(
"""INSERT INTO sync_identity_map
(id, video_id, raw_uid, canonical_name, source, updated_at, synced_at)
VALUES (%s,%s,%s,%s,%s,%s, NOW())
ON DUPLICATE KEY UPDATE
video_id=VALUES(video_id),
raw_uid=VALUES(raw_uid),
canonical_name=VALUES(canonical_name),
source=VALUES(source),
updated_at=VALUES(updated_at),
synced_at=NOW()""",
(r.get('id'), r.get('video_id'), r.get('raw_uid'),
r.get('canonical_name'), r.get('source'), r.get('updated_at')))
n += 1
conn.commit()
return n
finally:
conn.close()
def get_sync_model_calls(limit: int = 200) -> List[Dict]: def get_sync_model_calls(limit: int = 200) -> List[Dict]:
"""最近模型调用记录(前端统计展示)。""" """最近模型调用记录(前端统计展示)。"""
conn = get_conn() conn = get_conn()

View File

@@ -142,3 +142,38 @@ def merge_member():
"display_name": m.get('canonical_name') or m['label'], "display_name": m.get('canonical_name') or m['label'],
} for m in members] } for m in members]
}), 200 }), 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}(回推 Oracle")
ok, err = get_sync().push_identity_correct(video_id, current_name, new_name)
if not ok:
return jsonify({"error": f"回推 Oracle 失败: {err}"}), 502
try:
get_sync().trigger_now()
except Exception as e:
logger.warning(f"纠错后即时拉回失败(下一个周期会自动同步): {e}")
return jsonify({
"status": "ok", "video_id": video_id,
"current_name": current_name, "new_name": new_name,
}), 200

View File

@@ -90,22 +90,25 @@ class OracleSync:
events = data.get('events', []) or [] events = data.get('events', []) or []
people = data.get('people', []) or [] people = data.get('people', []) or []
model_calls = data.get('model_calls', []) or [] model_calls = data.get('model_calls', []) or []
identity_map = data.get('identity_map', []) or []
server_time = data.get('server_time', '') or '' server_time = data.get('server_time', '') or ''
n_videos = db_layer.upsert_sync_videos(videos) n_videos = db_layer.upsert_sync_videos(videos)
n_events = db_layer.upsert_sync_events(events) n_events = db_layer.upsert_sync_events(events)
n_people = db_layer.upsert_sync_people(people) n_people = db_layer.upsert_sync_people(people)
n_calls = db_layer.upsert_sync_model_calls(model_calls) n_calls = db_layer.upsert_sync_model_calls(model_calls)
n_identity = db_layer.upsert_sync_identity_map(identity_map)
if server_time: if server_time:
db_layer.set_sync_cursor(server_time) db_layer.set_sync_cursor(server_time)
self._last_sync_at = datetime.now() self._last_sync_at = datetime.now()
self._last_error = None self._last_error = None
self._last_count = (n_videos, n_events, n_people, n_calls) self._last_count = (n_videos, n_events, n_people, n_calls, n_identity)
logger.info( logger.info(
f"同步完成: videos+{n_videos} events+{n_events} people+{n_people} " f"同步完成: videos+{n_videos} events+{n_events} people+{n_people} "
f"model_calls+{n_calls} since={since!r} -> server_time={server_time}") f"model_calls+{n_calls} identity_map+{n_identity} since={since!r} "
f"-> server_time={server_time}")
return True return True
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -133,6 +136,39 @@ class OracleSync:
logger.error(f"命名校正回推失败: {msg}") logger.error(f"命名校正回推失败: {msg}")
return False, msg return False, msg
# ------------------------------------------------------------------
def push_identity_correct(self, video_id: int, current_name: str, new_name: str):
"""回推事件时间轴/人物管理"这个人识别错了"纠错到 Oracle。
跟 push_name_correct 的区别:这个按 (video_id, 当前展示名) 定位,只改这
一段视频里错认的那个人,不影响同名字符串在其他视频里的映射(人物 uid
只在单次视频分析内稳定,跨视频复用同一字符串完全可能是不同真人)。
返回 (success: bool, error: str)
"""
try:
video_id = int(video_id)
except (TypeError, ValueError):
return False, "video_id 必须是数字"
current_name = (current_name or '').strip()
new_name = (new_name or '').strip()
if not current_name or not new_name:
return False, "缺少 current_name / new_name"
try:
resp = requests.post(
f"{self.base_url}/api/oracle/identity/correct",
json={"video_id": video_id, "current_name": current_name,
"new_name": new_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): def _run(self):
logger.info(f"OracleSync 线程启动,间隔 {self.interval_sec}s目标 {self.base_url}") logger.info(f"OracleSync 线程启动,间隔 {self.interval_sec}s目标 {self.base_url}")

View File

@@ -66,6 +66,38 @@ motion_segment:
min_duration_sec: 1 # 短于该时长的事件不分割 min_duration_sec: 1 # 短于该时长的事件不分割
unfinished_grace_sec: 10 # start_time+duration 距当前 ≤ 该秒视为"已结束"容差 unfinished_grace_sec: 10 # start_time+duration 距当前 ≤ 该秒视为"已结束"容差
# 闭集人物识别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
# 智能问答降级链与视频分析独立Gemini -> NVIDIA -> 本地 Ollama # 智能问答降级链与视频分析独立Gemini -> NVIDIA -> 本地 Ollama
models: models:
- provider: "gemini" - provider: "gemini"
@@ -90,6 +122,9 @@ models:
- "智能摄像头-3" - "智能摄像头-3"
- "智能摄像头-4" - "智能摄像头-4"
timeout: 600 timeout: 600
# 问答专用超时(跟上面视频分析的 timeout 分开):用户在等交互式回答,一个
# key/模型卡住不该等 10 分钟,超时要短,快速降级到下一个 key/模型/provider
chat_timeout: 20
# 模型级独立超时(最终值,不参与编排层 ×2 放大) # 模型级独立超时(最终值,不参与编排层 ×2 放大)
# gemini-flash-lite 实测 ~22-34s按用户要求放宽至 8 分钟480s避免大视频/排队时过早切断 # gemini-flash-lite 实测 ~22-34s按用户要求放宽至 8 分钟480s避免大视频/排队时过早切断
model_timeouts: model_timeouts:
@@ -119,6 +154,7 @@ models:
base_url: "https://integrate.api.nvidia.com/v1" base_url: "https://integrate.api.nvidia.com/v1"
api_key: "${NVIDIA_API_KEY}" api_key: "${NVIDIA_API_KEY}"
timeout: 600 timeout: 600
chat_timeout: 20 # 问答专用超时,跟视频分析的 timeout 分开
max_base64_mb: 20 # 超过此大小直接跳过 NVIDIA不做注定失败的编码+上传 max_base64_mb: 20 # 超过此大小直接跳过 NVIDIA不做注定失败的编码+上传
switch_interval_sec: 5 # 模型切换间隔:一个失败后等待再试下一个(未来加模型时用) switch_interval_sec: 5 # 模型切换间隔:一个失败后等待再试下一个(未来加模型时用)
model_timeouts: # 模型级独立超时(最终值,不参与 ×2 model_timeouts: # 模型级独立超时(最终值,不参与 ×2

View File

@@ -14,6 +14,7 @@ API-Gateway - Flask 蓝图(新架构 v3
一次 Gemini 调用一并产出(见 ai_orchestrator/prompts.pyframe_service 不再 一次 Gemini 调用一并产出(见 ai_orchestrator/prompts.pyframe_service 不再
额外调用任何模型。NAS 经 core 代理读取,不在 NAS 做图像计算。 额外调用任何模型。NAS 经 core 代理读取,不在 NAS 做图像计算。
""" """
import json
import os import os
from flask import Blueprint, request, jsonify, Response from flask import Blueprint, request, jsonify, Response
@@ -102,6 +103,39 @@ def people_correct():
return jsonify({"status": "ok", "label": label, "canonical_name": canonical}), 200 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/edge/chat/ask', methods=['POST']) @api_bp.route('/api/edge/chat/ask', methods=['POST'])
def chat_ask(): def chat_ask():
"""智能问答编排Gemini → NVIDIA → 本地 Ollama两云端都失败才用本地兜底 """智能问答编排Gemini → NVIDIA → 本地 Ollama两云端都失败才用本地兜底
@@ -125,6 +159,28 @@ def chat_ask():
return jsonify({"answer": answer, "provider": provider}), 200 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']
max_tokens = int(data.get('max_tokens', 1024))
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"
return Response(generate(), mimetype='text/event-stream',
headers={'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'})
@api_bp.route('/api/oracle/activity', methods=['GET']) @api_bp.route('/api/oracle/activity', methods=['GET'])
def activity(): def activity():
"""实时服务状态 + 最近活动流token 校验)。 """实时服务状态 + 最近活动流token 校验)。

View File

@@ -91,7 +91,10 @@ def extract_frame(db, video_id: int, ts: str, width: int = FRAME_W) -> bytes:
offset = 0.0 offset = 0.0
_ensure_dir() _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: if os.path.isfile(cache) and os.path.getsize(cache) > 0:
with open(cache, 'rb') as f: with open(cache, 'rb') as f:
return f.read() return f.read()

View File

@@ -80,6 +80,15 @@ class BaseModelAdapter(ABC):
raise NotImplementedError( raise NotImplementedError(
f"{self.provider_name} 适配器未实现 chat()(不参与智能问答)") 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 @abstractmethod
def get_timeout(self) -> int: def get_timeout(self) -> int:
"""该模型的调用超时秒数""" """该模型的调用超时秒数"""

View File

@@ -74,6 +74,10 @@ class GeminiAdapter(BaseModelAdapter):
self.key_labels.append(str(label)) self.key_labels.append(str(label))
self.api_key = self.api_keys[0] if self.api_keys else '' # 向后兼容单 key 用法 self.api_key = self.api_keys[0] if self.api_keys else '' # 向后兼容单 key 用法
self.timeout = config.get('timeout', 600) self.timeout = config.get('timeout', 600)
# 问答chat专用超时——跟视频分析的 timeout 分开,不能共用 600s
# 智能问答是同步等待用户看结果的交互场景,一个 key/模型卡住不该让用户等
# 10 分钟,超时应该短、快速降级到下一个 key/模型/provider
self.chat_timeout = config.get('chat_timeout', 20)
# 模型级独立超时(最终值,不参与编排层 ×N 放大): {model_name: seconds} # 模型级独立超时(最终值,不参与编排层 ×N 放大): {model_name: seconds}
# 例: {"gemini-flash-lite-latest": 90}(按实测耗时 ×4 配置) # 例: {"gemini-flash-lite-latest": 90}(按实测耗时 ×4 配置)
self.model_timeouts = { self.model_timeouts = {
@@ -431,10 +435,10 @@ class GeminiAdapter(BaseModelAdapter):
"generationConfig": { "generationConfig": {
"temperature": temperature, "temperature": temperature,
"maxOutputTokens": max_tokens}}, "maxOutputTokens": max_tokens}},
timeout=self.timeout timeout=self.chat_timeout
) )
except requests.Timeout: except requests.Timeout:
logger.warning(f"Gemini {key_label} [{model}] 问答超时") logger.warning(f"Gemini {key_label} [{model}] 问答超时({self.chat_timeout}s)")
continue continue
except Exception as e: except Exception as e:
logger.error(f"Gemini {key_label} [{model}] 问答异常: {e}") logger.error(f"Gemini {key_label} [{model}] 问答异常: {e}")
@@ -452,6 +456,69 @@ class GeminiAdapter(BaseModelAdapter):
continue continue
return None return None
def chat_stream(self, prompt: str, max_tokens: int = 512):
"""流式问答:逐块 yield 文本增量。用于聊天界面边生成边显示,不用等全量
返回再展示——之前整段等待是"卡住没反馈"体验差的根源之一。
按 key 轮换 × 模型链依次尝试,但只在"这次尝试还没吐出任何文本"时才允许
换下一个 key/模型;一旦已经开始吐字给用户看了,中途出错就直接结束这次
生成(不再悄悄换 provider 接着写,否则会出现两段风格/内容不连贯的回答
拼在一起,比直接告知"生成中断"更让人困惑)。
"""
if self._cb.is_open():
logger.warning("Gemini 熔断器 OPEN跳过问答流式")
return
if not self.api_keys:
logger.warning("Gemini API Key 未配置,跳过问答(流式)")
return
got_any = False
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}:streamGenerateContent"
f"?alt=sse&key={api_key}",
json={"contents": [{"parts": [{"text": prompt}]}],
"generationConfig": {
"temperature": 0.3, "maxOutputTokens": max_tokens}},
timeout=self.chat_timeout, stream=True,
)
except requests.Timeout:
logger.warning(f"Gemini {key_label} [{model}] 流式问答超时({self.chat_timeout}s)")
continue
except Exception as e:
logger.error(f"Gemini {key_label} [{model}] 流式问答异常: {e}")
continue
if resp.status_code != 200:
logger.warning(f"Gemini {key_label} [{model}] 流式问答 HTTP {resp.status_code}")
resp.close()
continue
try:
for line in resp.iter_lines(decode_unicode=True):
if not line or not line.startswith('data: '):
continue
chunk = line[len('data: '):]
try:
obj = json.loads(chunk)
except ValueError:
continue
cands = obj.get('candidates', [])
text = ''.join(
p.get('text', '')
for p in (cands[0].get('content', {}) if cands else {}).get('parts', []))
if text:
got_any = True
yield text
except Exception as e:
logger.warning(f"Gemini {key_label} [{model}] 流式读取中断: {e}")
finally:
resp.close()
if got_any:
self._cb.record_success()
return # 已经开始吐字,不管这次是否读完都不再换 provider
self._cb.record_failure()
def get_timeout(self) -> int: def get_timeout(self) -> int:
return self.timeout return self.timeout

View File

@@ -62,6 +62,8 @@ class NvidiaVisionAdapter(BaseModelAdapter):
self.api_key = self._resolve_key(config.get('api_key', '')) self.api_key = self._resolve_key(config.get('api_key', ''))
self.base_url = config.get('base_url', 'https://integrate.api.nvidia.com/v1') self.base_url = config.get('base_url', 'https://integrate.api.nvidia.com/v1')
self.timeout = config.get('timeout', 600) self.timeout = config.get('timeout', 600)
# 问答专用超时,跟视频分析分开——交互式问答不该等到跟视频分析一样久
self.chat_timeout = config.get('chat_timeout', 20)
self.max_base64_mb = float(config.get('max_base64_mb', 20)) self.max_base64_mb = float(config.get('max_base64_mb', 20))
# 模型级独立超时(最终值,不参与编排层 ×N 放大): {model_name: seconds} # 模型级独立超时(最终值,不参与编排层 ×N 放大): {model_name: seconds}
self.model_timeouts = { self.model_timeouts = {
@@ -209,7 +211,7 @@ class NvidiaVisionAdapter(BaseModelAdapter):
messages=[{"role": "user", "content": prompt}], messages=[{"role": "user", "content": prompt}],
temperature=0.3, temperature=0.3,
max_tokens=max_tokens, max_tokens=max_tokens,
timeout=self.timeout timeout=self.chat_timeout
) )
content = resp.choices[0].message.content content = resp.choices[0].message.content
if content: if content:

View File

@@ -120,11 +120,21 @@ class OracleDB:
thumbnail_url TEXT, thumbnail_url TEXT,
received_at 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_videos_updated ON videos(updated_at);
CREATE INDEX IF NOT EXISTS idx_events_video ON events(video_id); 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_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_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_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 等列(生产-消费队列用) # 兼容旧库:补 retry_count / file_valid / media 等列(生产-消费队列用)
cols = [r[1] for r in c.execute("PRAGMA table_info(videos)").fetchall()] cols = [r[1] for r in c.execute("PRAGMA table_info(videos)").fetchall()]
@@ -599,6 +609,109 @@ class OracleDB:
'features_text': features_text, 'features_text': features_text,
'event_start_time': best['event_start_time']} '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以及 videos.people_json 里的名字替换掉。
rename_map 的 key 是"事件数据里当前显示的名字"(可能是原始 uid也可能是
上一轮已经替换过的规范名——纠错场景下就是这种情况)。
"""
if not rename_map:
return
now = _now_iso()
with self._write_lock:
rows = self._conn.execute(
"SELECT id, person_list_json, person_appearances_json 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
if changed:
self._conn.execute(
"UPDATE events SET person_list_json=?, person_appearances_json=? "
"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'],
r['id']))
vrow = self._conn.execute(
"SELECT people_json FROM videos WHERE id=?", (video_id,)).fetchone()
if vrow and vrow['people_json']:
plist = json.loads(vrow['people_json'])
new_plist = [rename_map.get(x, x) for x in plist]
if new_plist != plist:
self._conn.execute(
"UPDATE videos SET people_json=?, updated_at=? WHERE id=?",
(json.dumps(new_plist, ensure_ascii=False), 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): def get_events_for_label(self, label: str, limit: int = 6):
"""该人物canonical_name 或 UID label出现的候选事件按时间倒序最近优先 """该人物canonical_name 或 UID label出现的候选事件按时间倒序最近优先
@@ -745,8 +858,11 @@ class OracleDB:
row['features_json'] if row else None, features) if row else ( row['features_json'] if row else None, features) if row else (
self._merge_features(None, features)) self._merge_features(None, features))
if row: if row:
# manual 覆盖 llmllm 不覆盖 manual # manual/auto_id 覆盖 llmllm 不覆盖 manual/auto_idauto_id 是闭集人物
if source == 'manual' or row['source'] != 'manual': # 识别的确定性结论——比 llm 的文字特征合并猜测可靠得多,同样需要保护,
# 不能被后续 person_service 的 llm 合并跑批悄悄覆盖回去)
_protected = ('manual', 'auto_id', 'rule')
if source in _protected or row['source'] not in _protected:
self._conn.execute( self._conn.execute(
"UPDATE people SET canonical_name=?, source=?, appearances=appearances+1, " "UPDATE people SET canonical_name=?, source=?, appearances=appearances+1, "
"features_json=?, display_uid=?, updated_at=? WHERE label=?", "features_json=?, display_uid=?, updated_at=? WHERE label=?",
@@ -804,7 +920,8 @@ class OracleDB:
now = _now_iso() now = _now_iso()
row = self._conn.execute("SELECT * FROM people WHERE label=?", (label,)).fetchone() row = self._conn.execute("SELECT * FROM people WHERE label=?", (label,)).fetchone()
if row: 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( self._conn.execute(
"UPDATE people SET appearances=?, source=?, updated_at=? WHERE label=?", "UPDATE people SET appearances=?, source=?, updated_at=? WHERE label=?",
(int(count), source, now, label)) (int(count), source, now, label))
@@ -853,6 +970,11 @@ class OracleDB:
model_calls = self._conn.execute( model_calls = self._conn.execute(
"SELECT * FROM model_calls WHERE created_at >= ? ORDER BY id ASC", "SELECT * FROM model_calls WHERE created_at >= ? ORDER BY id ASC",
(since_iso,)).fetchall() (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): def _ser(row):
d = dict(row) d = dict(row)
@@ -863,6 +985,7 @@ class OracleDB:
"events": [_ser(e) for e in events], "events": [_ser(e) for e in events],
"people": [_ser(p) for p in people], "people": [_ser(p) for p in people],
"model_calls": [_ser(m) for m in model_calls], "model_calls": [_ser(m) for m in model_calls],
"identity_map": [_ser(m) for m in identity_map],
"server_time": _now_iso(), "server_time": _now_iso(),
} }

View File

@@ -0,0 +1,256 @@
"""
PersonIdentifier - 闭集人物识别(家里固定 4 个人:爷爷/爸爸/媳妇/汤圆)
背景2026-08-22: 原来靠大模型每次视频分析自己编的"人物A/B/C"临时 uid + 一段
性别/年龄/衣着文字描述做跨视频合并,原理上就不可靠——文字描述会因光线/角度/换衣服
对不上,反复出现张冠李戴(用户原话:"现在的识别全是错的")。人脸向量方案也验证
过,家庭监控这种大广角/远距离/糊画面下同人内部相似度经常比不同人还低,此路不通。
现在改成基于已知这个家庭只有 4 个固定成员的闭集规则:
- 汤圆(幼儿/儿童、媳妇唯一成年女性Gemini 每次分析已经会标性别/年龄段,
这两条命中率验证下来接近 100%,直接用,不需要额外模型调用。
- 爷爷、爸爸(两个成年男性,纯外观规则/人脸向量都区分不开):改用视觉大模型
"看图比对"——给几张已确认身份的参考图 + 待判断的截图,直接问模型这是谁。
实测 NVIDIA nemotron-omni 在留出测试集上 6/6 全对Gemini flash-lite 7/8
NVIDIA 配额与 Gemini 完全独立、不跟主分析链路抢配额,设为优先。
调用粒度:每个运动片段(视频行)只调一次(不是每个事件都调)——同一段视频里
人不会中途换衣服,取片段内最大 bbox 的成年男性外观代表整段。
健壮性2026-08-22 补,用于支撑历史数据批量回填):
- NVIDIA/Gemini 各自支持模型链model_chainfallback_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

View File

@@ -33,3 +33,34 @@ class QAOrchestrator:
return answer, adapter.provider_name return answer, adapter.provider_name
logger.info(f"QA {adapter.provider_name} 无返回,降级下一模型") logger.info(f"QA {adapter.provider_name} 无返回,降级下一模型")
return None, None return None, None
def run_qa_stream(self, prompt: str, max_tokens: int = 1024):
"""流式版:依次尝试各适配器的 chat_stream()yield 结构化事件字典。
事件类型:
{"type":"provider_trying","provider":p} 开始尝试这个 provider
{"type":"chunk","provider":p,"text":t} 这个 provider 吐出的文本增量
{"type":"provider_failed","provider":p} 这个 provider 一个字都没吐出就失败,换下一个
{"type":"done","provider":p} 成功结束(这个 provider 至少吐出过一块)
{"type":"all_failed"} 所有 provider 都失败
跟 run_qa 一样"仅在还没吐出任何文本时才允许换下一个 provider"——一旦
开始给用户看字了,中途失败就结束这次生成,不再悄悄换源接着写。
"""
for adapter in self.adapters:
yield {"type": "provider_trying", "provider": adapter.provider_name}
got_any = False
try:
for chunk in adapter.chat_stream(prompt, max_tokens=max_tokens):
if chunk:
got_any = True
yield {"type": "chunk", "provider": adapter.provider_name, "text": chunk}
except Exception as e:
logger.warning(f"QA {adapter.provider_name} 流式异常: {e}")
if got_any:
logger.info(f"QA 流式命中 provider={adapter.provider_name}")
yield {"type": "done", "provider": adapter.provider_name}
return
logger.info(f"QA {adapter.provider_name} 流式无返回,降级下一模型")
yield {"type": "provider_failed", "provider": adapter.provider_name}
yield {"type": "all_failed"}

View File

@@ -15,6 +15,7 @@ import os
import re import re
import json import json
import subprocess import subprocess
import tempfile
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Dict, List, Optional from typing import Dict, List, Optional
@@ -22,6 +23,8 @@ from .logger import setup_logger
from .config_loader import load_config from .config_loader import load_config
from .model_adapters.adapter_factory import build_adapters from .model_adapters.adapter_factory import build_adapters
from .model_adapters.base_adapter import BaseModelAdapter from .model_adapters.base_adapter import BaseModelAdapter
from .person_identifier import PersonIdentifier
from . import frame_service
from . import oracle_db from . import oracle_db
logger = setup_logger('fam-edge.video_processor') logger = setup_logger('fam-edge.video_processor')
@@ -200,6 +203,9 @@ class VideoProcessor:
self.motion_keep_audio = bool(seg.get('keep_audio', True)) self.motion_keep_audio = bool(seg.get('keep_audio', True))
self.motion_min_duration = float(seg.get('min_duration_sec', 1)) self.motion_min_duration = float(seg.get('min_duration_sec', 1))
self.motion_grace_sec = int(seg.get('unfinished_grace_sec', 10)) self.motion_grace_sec = int(seg.get('unfinished_grace_sec', 10))
# 闭集人物识别(家里固定 4 人):汤圆/媳妇 用性别年龄规则;爷爷/爸爸 用
# person_identifier 视觉大模型比对,每片段每个 uid 只调一次
self.person_identifier = PersonIdentifier(self.config.get('person_identifier', {}))
adapters = build_adapters(self.config.get('models', [])) adapters = build_adapters(self.config.get('models', []))
self.vision_adapters: Dict[str, BaseModelAdapter] = { self.vision_adapters: Dict[str, BaseModelAdapter] = {
a.provider_name: a for a in adapters if a.get_role() == 'vision'} a.provider_name: a for a in adapters if a.get_role() == 'vision'}
@@ -477,13 +483,111 @@ class VideoProcessor:
elif k not in merged: elif k not in merged:
merged[k] = v_str or 'unknown' merged[k] = v_str or 'unknown'
uid_features[uid] = merged 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: for p in people:
if p and p not in ('无人', ''): if p and p not in ('无人', ''):
feats = uid_features.get(p) feats = uid_features.get(p)
canonical, source = resolved.get(p, ('', 'llm'))
# 已解析的人物直接用规范名作为 people 表的 label跨视频天然汇总到
# 同一行;解析不了的沿用原始 uid跟旧行为一致留给下次/人工确认)
label = canonical or p
if feats: 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: 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)}字, " logger.info(f"[video_id={video_id}] 已落库: summary={len(summary)}字, "
f"events={len(norm_events)}, people={people}, " f"events={len(norm_events)}, people={people}, "
f"with_features={len(uid_features)}") f"with_features={len(uid_features)}, 闭集识别={resolved}")
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 的一次出现
判断一次,不逐事件重复调用。
"""
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()
if age_band in ('幼儿', '儿童'):
resolved[uid] = ('汤圆', 'rule')
elif gender == '':
resolved[uid] = ('媳妇', 'rule')
elif gender == '':
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

View File

@@ -1,3 +1,6 @@
import os
from fam_edge import frame_service
from fam_edge.frame_service import _bbox_to_pixels 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(): def test_bbox_to_pixels_zero_area():
x1, y1, x2, y2 = _bbox_to_pixels([500, 500, 500, 500], 400, 300) x1, y1, x2, y2 = _bbox_to_pixels([500, 500, 500, 500], 400, 300)
assert (x1, y1) == (x2, y2) 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

View File

@@ -108,3 +108,17 @@ def test_rotated_keys_single_key_never_errors():
a = GeminiAdapter(_cfg()) a = GeminiAdapter(_cfg())
for _ in range(3): for _ in range(3):
assert a._rotated_keys() == [(0, "key-primary")] assert a._rotated_keys() == [(0, "key-primary")]
def test_chat_timeout_defaults_short_not_shared_with_video_timeout():
"""核心诉求: 问答是交互场景,不能沿用视频分析的 600s 超时——否则一个卡住
的 key/模型会让用户在聊天界面一直等,这正是"一直卡着"这个 bug 的根因。"""
a = GeminiAdapter(_cfg(timeout=600))
assert a.timeout == 600
assert a.chat_timeout == 20
assert a.chat_timeout != a.timeout
def test_chat_timeout_configurable():
a = GeminiAdapter(_cfg(chat_timeout=8))
assert a.chat_timeout == 8

View File

@@ -181,3 +181,133 @@ def test_has_motion_in_range_local_filters_by_camera_id(tmp_path):
[{"event_id": 1, "camera_id": 99, "event_type": 10, "start_time": 2500, "duration": 5}]) [{"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=2) is False
assert db.has_motion_in_range_local(2000, 3000, camera_id=99) is True 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_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"]) == ["爸爸"]

View 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 == []

81
fam-edge/tests/test_qa.py Normal file
View File

@@ -0,0 +1,81 @@
from fam_edge.qa import QAOrchestrator
class _FakeAdapter:
def __init__(self, provider_name, chunks=None, raises=False):
self.provider_name = provider_name
self._chunks = chunks or []
self._raises = raises
def chat_stream(self, prompt, max_tokens=512):
if self._raises:
raise RuntimeError("boom")
for c in self._chunks:
yield c
def chat(self, prompt, max_tokens=512):
return ''.join(self._chunks) or None
def _orchestrator(adapters):
qa = QAOrchestrator.__new__(QAOrchestrator) # 跳过 __init__不需要真实 config/adapters
qa.adapters = adapters
return qa
def test_run_qa_stream_first_provider_success():
qa = _orchestrator([_FakeAdapter("gemini", chunks=["", ""])])
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"] == "gemini"
def test_run_qa_stream_falls_back_when_first_yields_nothing():
"""核心诉求: 第一个 provider 一个字都没吐出来才允许换下一个——不是失败就切,
"完全没有产出"才切。"""
qa = _orchestrator([
_FakeAdapter("gemini", chunks=[]),
_FakeAdapter("nvidia", chunks=["答案"]),
])
events = list(qa.run_qa_stream("hi"))
types = [e["type"] for e in events]
assert types == ["provider_trying", "provider_failed", "provider_trying", "chunk", "done"]
assert events[-1]["provider"] == "nvidia"
def test_run_qa_stream_does_not_switch_after_partial_output():
"""核心诉求: 已经开始吐字之后中途失败,不能悄悄换下一个 provider 接着写
(会出现两段风格/内容不连贯的回答拼在一起)——直接结束这次生成。"""
class _PartialThenRaise:
provider_name = "gemini"
def chat_stream(self, prompt, max_tokens=512):
yield "先吐"
raise RuntimeError("connection reset")
qa = _orchestrator([_PartialThenRaise(), _FakeAdapter("nvidia", chunks=["不该被用到"])])
events = list(qa.run_qa_stream("hi"))
types = [e["type"] for e in events]
assert types == ["provider_trying", "chunk", "done"]
assert events[1]["text"] == "先吐"
assert events[-1]["provider"] == "gemini"
def test_run_qa_stream_all_providers_fail():
qa = _orchestrator([
_FakeAdapter("gemini", chunks=[]),
_FakeAdapter("nvidia", chunks=[], raises=True),
])
events = list(qa.run_qa_stream("hi"))
assert events[-1]["type"] == "all_failed"
assert "provider_failed" in [e["type"] for e in events]
def test_run_qa_stream_exception_treated_as_no_output():
qa = _orchestrator([_FakeAdapter("gemini", raises=True), _FakeAdapter("nvidia", chunks=["ok"])])
events = list(qa.run_qa_stream("hi"))
assert events[0] == {"type": "provider_trying", "provider": "gemini"}
assert events[1] == {"type": "provider_failed", "provider": "gemini"}
assert events[-1]["provider"] == "nvidia"

View File

@@ -51,6 +51,13 @@ export const api = {
request('/api/member/merge', { method: 'POST', body: JSON.stringify({ source, target }) }), request('/api/member/merge', { method: 'POST', body: JSON.stringify({ source, target }) }),
status: () => request('/api/status'), status: () => request('/api/status'),
syncTrigger: () => request('/api/sync/trigger', { method: 'POST' }),
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'),与后端归一化一致 */ /** 剥离全角/半角括号备注(如 '人物A别名人物B' -> '人物A'),与后端归一化一致 */

View File

@@ -1,12 +1,39 @@
<script setup> <script setup>
import { computed } from 'vue' import { computed, ref } from 'vue'
import { fmtTime, parsePersons } from '../api.js' import { api, fmtTime, parsePersons } from '../api.js'
import Badge from './Badge.vue' import Badge from './Badge.vue'
const props = defineProps({ const props = defineProps({
event: { type: Object, required: true }, event: { type: Object, required: true },
videoId: { type: [Number, String], 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 timeLabel = computed(() => fmtTime(props.event.ts))
const persons = computed(() => parsePersons(props.event.person_list_json)) 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"> <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" /> <img loading="lazy" alt="事件帧" :src="thumbUrl" class="block w-full rounded-lg border border-border" />
</div> </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-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>
<div v-if="fixError" class="mb-2 text-xs text-danger">纠错失败{{ fixError }}</div>
<div v-if="appearances.length" class="mb-2"> <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"> <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> <b class="text-accent-bright">{{ a.uid }}</b>

View File

@@ -11,6 +11,9 @@ const selectedQuick = ref('自定义')
const loading = ref(false) const loading = ref(false)
const errorMsg = ref('') const errorMsg = ref('')
const result = ref(null) const result = ref(null)
const showThinking = ref(true)
const PROVIDER_LABEL = { gemini: 'Gemini', nvidia: 'NVIDIA', ollama: '本地 Ollama' }
const quickQuestions = computed(() => [ const quickQuestions = computed(() => [
`${queriedPerson.value}今天干嘛了?`, `${queriedPerson.value}今天干嘛了?`,
@@ -30,15 +33,66 @@ onMounted(async () => {
} catch { /* 忽略:下拉留空即可 */ } } 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() { async function ask() {
errorMsg.value = '' errorMsg.value = ''
result.value = null
if (!userQuestion.value.trim()) { errorMsg.value = '请输入问题'; return } if (!userQuestion.value.trim()) { errorMsg.value = '请输入问题'; return }
if (!queriedPerson.value.trim()) { errorMsg.value = '请输入查询人物'; return } if (!queriedPerson.value.trim()) { errorMsg.value = '请输入查询人物'; return }
loading.value = true loading.value = true
showThinking.value = true
result.value = {
question: userQuestion.value, answer: '', provider: '', tried: [],
contextCount: null, contextSummary: '', contextPreview: '', finished: false,
}
try { try {
const data = await api.chatAsk(userQuestion.value, queriedPerson.value, queriedDate.value) const res = await fetch('/api/chat/ask/stream', {
result.value = { question: userQuestion.value, answer: data.answer, contextSummary: data.context_summary } 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) { } catch (e) {
errorMsg.value = e.message errorMsg.value = e.message
} finally { } finally {
@@ -84,7 +138,7 @@ async function ask() {
<button :disabled="loading" @click="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"> 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> </button>
<p v-if="errorMsg" class="mt-3 text-sm text-danger">{{ errorMsg }}</p> <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> <div class="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-text-mute"> 提问</div>
{{ result.question }} {{ result.question }}
</div> </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="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="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> </div>
<p v-if="result.contextSummary" class="text-xs text-text-faint">上下文: {{ result.contextSummary }}</p>
</div> </div>
</template> </template>

View File

@@ -11,6 +11,8 @@ import Spinner from '../components/Spinner.vue'
const data = ref(null) const data = ref(null)
const oracleError = ref('') const oracleError = ref('')
const loading = ref(false) const loading = ref(false)
const syncing = ref(false)
const syncMsg = ref('')
async function load() { async function load() {
loading.value = true loading.value = true
@@ -24,6 +26,23 @@ async function load() {
} }
} }
async function syncNow() {
syncing.value = true
syncMsg.value = ''
try {
const r = await api.syncTrigger()
const c = r.last_count || []
syncMsg.value = c.length
? `同步完成:视频+${c[0] ?? 0} 事件+${c[1] ?? 0} 人物+${c[2] ?? 0} 模型调用+${c[3] ?? 0} 身份映射+${c[4] ?? 0}`
: '同步完成'
await load()
} catch (e) {
syncMsg.value = `同步失败:${e.message}`
} finally {
syncing.value = false
}
}
onMounted(load) onMounted(load)
const oracle = computed(() => data.value?.oracle || {}) const oracle = computed(() => data.value?.oracle || {})
@@ -58,7 +77,10 @@ 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"> <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 ? '刷新中…' : '🔄 刷新' }} {{ loading && data ? '刷新中…' : '🔄 刷新' }}
</button> </button>
<span class="text-xs text-text-mute">点击刷新立即更新</span> <button :disabled="syncing" @click="syncNow" class="rounded-lg border border-accent/40 bg-accent/10 px-3.5 py-1.5 text-sm text-accent hover:bg-accent/20 disabled:opacity-50">
{{ syncing ? '同步中…' : '⇄ 立即同步' }}
</button>
<span class="text-xs text-text-mute">{{ syncMsg || '点击刷新立即更新;立即同步会马上从甲骨文拉一次增量' }}</span>
</div> </div>
<Spinner v-if="loading && !data" text="加载服务状态…" /> <Spinner v-if="loading && !data" text="加载服务状态…" />

View File

@@ -150,7 +150,8 @@ const modelBadges = computed(() => {
<EmptyState v-if="!detail.events.length" icon="🎞" text="该会话暂无时间点事件" /> <EmptyState v-if="!detail.events.length" icon="🎞" text="该会话暂无时间点事件" />
<div v-else> <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> </div>
</div> </div>

View File

@@ -185,6 +185,21 @@ CREATE TABLE IF NOT EXISTS sync_cursor (
`value` VARCHAR(64) COMMENT '上次成功拉取到的 server_timeISO 文本)' `value` VARCHAR(64) COMMENT '上次成功拉取到的 server_timeISO 文本)'
) ENGINE=InnoDB COMMENT='同步游标表'; ) ENGINE=InnoDB COMMENT='同步游标表';
-- 7.5 人物对应关系表Oracle person_identity_map 镜像2026-08-22 新增)
-- 闭集人物识别:某视频里 Gemini 给的原始 uid 与解析出的规范名(爷爷/爸爸/媳妇/
-- 汤圆)之间的映射,供事件时间轴"纠错"按钮定位、追溯识别来源
CREATE TABLE IF NOT EXISTS sync_identity_map (
id INT PRIMARY KEY 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 / manualmanual 优先不被覆盖)',
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 镜像) -- 8.1 云端模型调用统计镜像表Oracle model_calls 镜像)
CREATE TABLE IF NOT EXISTS sync_model_calls ( CREATE TABLE IF NOT EXISTS sync_model_calls (
id INT PRIMARY KEY COMMENT 'Oracle model_calls.id', id INT PRIMARY KEY COMMENT 'Oracle model_calls.id',