Compare commits

...

52 Commits

Author SHA1 Message Date
ericwyuan
deaebf22b4 fix(fam-edge): 生产者扫描跳过已被 DiskGuard 清理的文件
生产者列目录之后、读 mtime 之前,DiskGuard 可能刚好把那个文件删了(两个后台线程
的正常竞态),os.path.getmtime 抛 FileNotFoundError。代价不是少处理一个文件,而是
**整轮扫描中断**——排在后面的新素材本轮全都登记不上,要等下一轮。

线上日志:生产者扫描异常: [Errno 2] No such file or directory:
/opt/fam-edge/gdrive_videos/20260911PM/Generic_ONVIF-001-20260911-234730-...mp4
(01:30:01 抛出,同一秒 DiskGuard 正在清理那批文件)

改成捕获 OSError 跳过该文件继续扫。新增 tests/test_video_queue.py:被删的跳过且
后面的照常登记、仍在写入的(mtime 太新)依然跳过。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 09:35:15 +08:00
ericwyuan
3b5f7db51d fix(fam-edge): SQLite 改每线程一条连接,修好 DiskGuard 等一切并发写
OracleDB 原来在 __init__ 里建一条 check_same_thread=False 的连接给全进程共用,
而 VideoQueue / PersonService / DiskGuard 三个后台线程 + gunicorn 的请求线程都在
并发读写它。sqlite3 的连接对象本来就不是可并发共享的,事务状态互相踩踏,线上
累计刷出三类错误(同一个根因):

  database is locked                            71604 次
  cannot start a transaction within a transaction  378 次
  no more rows available                           92 次(堆栈落在 commit())

最严重的后果是磁盘守护形同虚设:DiskGuard 的清理被打断 2837 次,成功仅 128 次,
剩余空间在 2.7GB 和 16GB 之间来回荡——赶上 rclone 集中下载(实测 5 分钟写入
12.6GB)就掉进危险区。NAS 推来的运动事件被 500 打回也是它(失败批次不推进游标,
下一轮补推,所以没丢事件)。

改法:_conn 改成 @property,从 threading.local() 取当前线程的连接,没有就新建
(WAL + busy_timeout=10000)。做成 property 是因为外部调用方(api_gateway 的
activity 端点)也在直接用 db._conn.execute(...),这样全部现有调用点原样工作。
close() 相应改成收掉所有线程开过的连接。_write_lock 保留,复合写语义不变。

测试:新增 8 线程 × 25 轮并发读写、close 收连接两个用例;在旧代码上稳定复现
同族错误 cannot commit transaction - SQL statements in progress。

线上验证:重启后 DiskGuard 立刻跑通一轮(清 40 个文件,剩余回到 15.3GB),
三类 SQLite 错误在重启后的日志里均为 0。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 09:34:24 +08:00
ericwyuan
a494d60361 docs: 补入 2026-09-03 工作日志(同步 1062 复发 + 甲骨文过载)
这份日志一直没入库,同目录 8/20 到 8/31 的都在。内容是 9/3 那次排查记录:
Oracle 侧 SQLite id 整体重排导致 sync_videos/sync_people 二次 1062、游标卡死
(修复即 0238030),以及同一天甲骨文整机过载挂死、SSH 握手被拒的环境问题。

后半段那个「Oracle 盒子过载」当时记为未解决,现在有了后续:运动事件推送从那天
起断流十天,直到 2026-09-13 全量迁云时才发现并恢复(见 PROGRESS 同日条目)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 08:55:08 +08:00
ericwyuan
da60365214 feat(fam-notifier): 补开机自启脚本,NAS 重启后推送不再静默断流
NAS 上没有 systemd,rc.d 是唯一的守护手段。旁边 frpc / nas-monitor / comic-api
都有 S99 脚本,唯独这个推送进程没有——它一旦随重启消失,运动事件就悄无声息地
断流,而且不打开网站根本发现不了。2026-09-04 到 09-12 断档整整十天就是这个剧本
(当时它还在 fam-core 里,fam-core 挂了没人重启)。

- 照抄 S99nas-monitor.sh 的写法:等 volume1 挂载就绪(最多 90s)、pidfile 判重
- 以 ericwyuan 而非 root 运行,保持 logs/ 与 data/cursor.json 属主跟手动启动一致
- DSM 上没有 pgrep,用 grep 字符类找 pid
- su 自身的 stdio 也断开:否则通过 SSH 执行时命令跑完了会话却不退出(实测踩到)

已装到 NAS 的 /usr/local/etc/rc.d/ 并验证 start/stop/restart/status;
restart 后游标从本地文件正确读回(33081),没有被重置成 SS 当前最大 id。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 08:35:36 +08:00
ericwyuan
1964e976f4 refactor: 全量迁云——fam-core 直读甲骨文 SQLite,NAS 只剩推送进程
前一天刚把登录迁到甲骨文,隔天 NAS 上的 fam-core 又挂了导致数据接口 502。
盘点后确认:甲骨文的 SQLite 才是权威数据源(videos 3113 / events 16159 /
people 60 / model_calls 9876),NAS 的 MariaDB 全是它的镜像——前端读的数据
本来就产自甲骨文,绕了一圈回家又绕回来。

改动:
- db_layer.py 从 725 行重写成 377 行:MySQL 镜像查询改为直读 fam-edge 的
  SQLite。5 个 upsert_sync_*(约 300 行去重逻辑,8/29 和 9/3 两次 1062 事故的
  发源地)连同 oracle_sync.py 整个删除。SQL 方言:JSON_CONTAINS -> json_each
  (前置 json_valid,历史脏数据不会把查询搞崩)、LEFT() -> substr()、%s -> ?。
  函数名 get_sync_* 一并改掉——已经没有 sync 这回事了
- 新增 edge_client.py:写操作(改名/删除)、帧图头像、服务状态都打给同机
  fam-edge,全走 127.0.0.1
- 新增 fam-notifier/:motion_notifier 从 fam-core 拆出独立成服务,游标从
  MariaDB 换成本地 JSON 文件。NAS 上从此没有 Flask、没有数据库、没有监听端口
- fam-core 移到甲骨文 /opt/fam-core(systemd,gunicorn -w 2,只绑
  127.0.0.1:5401——5400 被 chat-relay 占了)。Caddy 的 /api/* 从"frp 隧道
  回源 NAS"改成同机反代,forward_auth 闸门不变
- 前端删掉侧边栏同步面板、统计页同步状态、服务状态页的"NAS 同步"卡片与
  "立即同步"按钮(背后的镜像层已不存在);换成"NAS 运动推送"卡片,读
  fam-edge activity 新增的 motion 段(心跳年龄 + 最近事件)
- 顺带修掉一个隐蔽 bug:镜像表为保外键稳定用的是 NAS 本地自增 id,而帧图接口
  要的是甲骨文的 id,两边在 9/3 那次 id 重排后就对不上了。现在只有一套 id

测试:fam-core 21(新增 12 个 db_layer 用例:脏 JSON 不崩、人物精确匹配不误伤
"人物B"、日期过滤、统计口径、chat_history 懒建表)、fam-notifier 6、
fam-edge 157,全绿。

生产验证:甲骨文 /api/ui/stats 返回 videos 2965 / events 16159 / people 59;
NAS 侧 fam-notifier 已推送成功(事件 33070-33072 落库,心跳新鲜);
chat_history 19 条经 scripts/import_chat_history.py 迁移完成。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 08:03:32 +08:00
ericwyuan
dc284f4bf5 feat(fam-edge): 统一登录从 NAS fam-core 迁入,回调失败不再对跳死循环
登录入口原先跑在 NAS 的 fam-core 里,NAS 或 frp 隧道一挂,
smart-camera.zichuan.xyz/login 直接 502——连登录页都打不开。登录是入口,
不该依赖家里那台机器,整体搬到甲骨文(前端静态文件和 auth-hub 本来就在这台)。

跟旧实现的三处关键差异:
- 换 token / 拉 JWKS 走 AUTH_HUB_INTERNAL_BASE(本机 :5300),不再跨公网 TLS。
  旧链路上 PyJWKClient 用 urllib + 系统 CA(群晖易 CERTIFICATE_VERIFY_FAILED)、
  两机时钟偏差会让 iat 显得来自未来,这两个坑一起消失;iss 校验和浏览器跳转
  仍用公网 issuer
- 会话改无状态 HS256 签名 cookie,服务重启不掉线(旧实现是进程内 token 表)
- 回调失败渲染错误页,不再 302 回 /login。旧实现失败即跳 /login,而 auth-hub
  只要还有会话就立刻再签一个 code 跳回来,两边对跳成死循环,浏览器只报
  「重定向次数过多」,既看不到登录页也看不到原因

fam-core 侧删除 auth.py 与 test_auth.py,鉴权改由甲骨文 Caddy 的 forward_auth
前置拦截(打 /api/auth/verify)。

测试:fam-edge 新增 16 个用例,含「失败分支绝不 302」回归测试、
「服务端走内网但 iss 按公网校验」、「cookie 无状态」。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 08:02:59 +08:00
ericwyuan
0238030128 fix(sync): 修复 Oracle id 重排导致的 sync_videos/sync_people 1062
Oracle 库重建/重排后 videos/people 的 id 跳变(2303 段→3408/4600+ 段),
旧 upsert 按 Oracle id 作主键插入时与已存在同 filename/label 行在 UNIQUE 键
上二次冲突报 1062,整批中止、游标不推进。

- upsert_sync_videos: 改为以 filename 业务键去重,命中就地 UPDATE 保留 NAS 原
  id(防 sync_events/model_calls/identity_map 的 video_id 外键失效),Oracle id
  落 oracle_id 列溯源;未命中插入优先用 Oracle id 对齐子表引用,主键冲突回退自增
- upsert_sync_people: 同模式,以 label 业务键去重(people.id 无外键引用)
- ddl.sql: sync_videos/sync_people 的 id 改为 NAS 本地自增主键 + 新增 oracle_id 列
- NAS 现网已迁移(加列/回填/改自增)并重启验证:单批补拉 videos+348 events+1017
  people+9 model_calls+500 identity_map+238,游标 09-02 13:55:21 → 09-03 08:51:11
2026-09-03 09:00:50 +08:00
ericwyuan
2ad0472578 fix(fam-ui): 未登录自动跳转 OIDC + 登录/退出入口,打通 smart-camera 登录 2026-09-01 12:23:48 +08:00
ericwyuan
db4f46140b fix(start_core): export AUTH_HUB_* env + fix .env path; docs: cutover to smart-camera.zichuan.xyz 2026-09-01 12:14:51 +08:00
ericwyuan
9c4db682d5 docs: 记录 8/29 同步 1062 修复与 8/31 auth-hub SSO 接入/清理部署;gitignore 排除独立项目 ai-gateway 2026-08-31 22:38:29 +08:00
ericwyuan
feb096ff3d docs: 订正 FAM-UI 部署位置描述(NAS -> 云服务器 Caddy)
多处历史遗留描述仍写 FAM-UI 部署在 NAS(含不存在的 static_app.py),
实际已迁至甲骨文云服务器由 Caddy 静态托管,逐处订正。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 21:50:42 +08:00
ericwyuan
5e1230d9b0 docs: 记录 auth-hub 生产 client 已注册,NAS 部署待执行
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 21:33:16 +08:00
ericwyuan
8120d2ae6a feat(fam-core): 登录改接 auth-hub 统一登录(OAuth2 Authorization Code + PKCE / OIDC)
移除本地 FAM_AUTH_USER/PASS 账号密码校验和内置登录表单,/login 改为 302
跳转 auth-hub /authorize,新增 /api/auth/callback 完成 code 换 token +
id_token 签名验证(PyJWT + JWKS),验证通过后种回原有 fam_session cookie,
is_authed()/全局登录拦截逻辑不变。接入参数走环境变量,未配置齐全 fail
closed。本地起 auth-hub 开发实例 + 真实浏览器验证过完整登录/登出闭环。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 21:32:26 +08:00
ericwyuan
988c63a8f9 fix(fam-core): 修复 sync_identity_map 增量同步 1062 主键冲突导致游标卡死
Oracle→NAS 增量同步反复报 (1062, "Duplicate entry '1073-汤圆' for key
'uq_video_raw_uid'"),整批失败、游标永不推进,同步状态页卡在"同步中"。

根因:sync_identity_map 以 Oracle person_identity_map.id(代理自增键)
作 NAS 主键,但 Oracle 库重建/恢复后这个 id 会被复用——实测 Oracle 现在
的 id=744 对应 (1073,'汤圆'),NAS 上 id=744 还是旧的 (1302,'人物E')。
upsert 先按 PK id 命中旧行,UPDATE 成 (1073,'汤圆') 后与已存在的同名行
在 uq_video_raw_uid 上二次冲突。

修复:镜像表改为 NAS 本地自增 id 主键,业务键 (video_id, raw_uid) 唯一,
Oracle 的 id 只落 oracle_id 列做溯源;upsert 的 UPDATE 子句不再改写
video_id/raw_uid 这两个键列,只更新 oracle_id/canonical_name/source 等。
ddl.sql 同步更新表定义。

已在 NAS 上完成现网迁移(ADD COLUMN oracle_id -> 回填 -> 主键改自增)并
验证:补拉同步成功恢复,游标正常推进,手动触发同步验证通过。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 23:11:10 +08:00
ericwyuan
05c7a8b70a feat(fam-edge): 闭集人物识别新增奶奶(成年女性按年龄段区分媳妇/奶奶)
家里从 4 人变成 5 人后,"媳妇=唯一成年女性"这条免费规则的前提被打破——
人物管理页面已经能看到一条 label="奶奶" 但从未稳定命中的记录(只出现过
1 次,特征都没落库),说明现在系统大概率把奶奶也误判成了媳妇。

先用免费的年龄段字段区分(老年=奶奶,其余=媳妇),不引入额外的模型调用
(区别于爷爷/爸爸那种"两个成年男性区分不开必须靠视觉比对参考图"的方案)
——如果后续观察发现年龄段判断不稳定导致误判,再考虑改成视觉比对。

新增 3 个单元测试覆盖(中年→媳妇/老年→奶奶/年龄段缺失默认媳妇),fam-edge
全量 142/142 通过。已部署 Oracle 并重启验证。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 22:42:26 +08:00
ericwyuan
c9354ed1a1 docs: 同步 8/28 迁移故障排查记录(FFmpeg 缺失 + 磁盘写满死循环 + DiskGuard)
README.md 新增 Disk-Guard 模块表条目、修正一处遗漏的机器规格描述
(2C12G -> 4C23G)、当前进度补充这次完整排查记录;PROGRESS.md 服务运行
状态表更新 + 新增详细的故障排查时间线记录。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 12:10:14 +08:00
ericwyuan
ef56ae5662 feat(fam-edge): 新增 DiskGuard 磁盘空间守护,剩余空间不足自动清理旧素材
背景:Oracle 磁盘曾经被写满(gdrive_videos 持续下载新素材、旧文件迟迟没
清理),触发 rclone 的一个安全机制——同步遇到 IO 错误就整体拒绝执行删除,
形成"越满越删不掉,越删不掉越满"的死循环,最终连新视频都下载不了。这次
排查+手动清理已经解决了当次故障,但需要一道独立于 rclone 同步之外的兜底,
防止再次悄悄写满没人发现。

- oracle_db.py 新增 get_oldest_purgeable_material():只挑最旧的、已完成
  分割阶段(status='done')的整段素材(非 motion_ 前缀),绝不碰运动片段
  (事件时间轴/人物头像依赖它)和还在处理中的素材
- disk_guard.py 新增 DiskGuard 后台线程:5 分钟检查一次,剩余空间 <10GB
  触发清理,删到 15GB 水位为止(留缓冲避免刚清完又立刻触发),复用已有的
  delete_video() 完成实际删除
- app.py 启动这个后台服务;/api/oracle/activity 新增 disk 字段(实时剩余
  空间 + 最近一次清理动作),供服务状态页展示
- 新增 9 个单元测试,全部通过(139/139)

已部署 Oracle 验证:DiskGuard 正常启动,配置生效。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 12:08:04 +08:00
ericwyuan
3ec79de911 docs: 清理已删除的 /api/ss/webhook 端点在文档中的残留描述
上次 SS Webhook 重构(cf3d145)已经彻底移除了 /api/ss/webhook 接收端点
(motion_bp.py 不再注册该路由),但 README.md 里 5 处描述、鉴权白名单
说明都还停留在"保留为可选低延迟补充",跟实际代码不符,逐处订正。

顺带更新 vite.config.js 里一句过时注释(还写"生产环境由 fam-core 的
Flask 同源提供",实际前端已经迁到云服务器 Caddy 托管)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 09:31:41 +08:00
ericwyuan
09708bd9a4 fix(fam-core): 修复 /api/status 和 /api/ss/status 500(移除 Webhook 重构遗留死引用)
上次"移除 SS Webhook 可选路径"重构(cf3d145)删掉了 MotionNotifier 的
camera_name_to_id/_ss_name_to_id/_camera_loaded 三个属性初始化,但
status() 方法忘记同步删除对它们的引用,导致任何调用 status() 的地方
(/api/status、/api/ss/status)直接抛 AttributeError 500——这两个端点
自那次重构部署后就一直是坏的,服务状态页无法访问。

顺带修 test_auth.py 里断言 /api/ss/webhook 应该被鉴权白名单放行的过时
测试——这个端点本身已经在同一次重构里被彻底删除(motion_bp.py 不再
注册该路由),白名单移除是对的,是测试没跟上。

已在 NAS 上部署验证:登录后 /api/status 恢复正常返回。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 09:29:09 +08:00
ericwyuan
cf3d145c57 refactor(fam-core): 移除 SS Webhook 可选路径与 NAS 静态前端托管,兜底 Oracle 地址更新为 129.146.26.249 2026-08-25 23:56:02 +08:00
ericwyuan
7948f55b02 docs: 同步 8/25 迁移(前端迁云 Caddy:80、Oracle 换新机 129.146.26.249、旧 IP 清理、Drive 中转设计说明) 2026-08-25 23:55:57 +08:00
ericwyuan
3cb08c9749 docs: 同步事件时间轴删除视频功能到 README/PROGRESS
补充 §5.1/§5.2 新增的 POST /api/oracle/video/delete + DELETE /api/ui/videos/<id>
端点说明、FAM-UI 页面表格、§12 当前进度;PROGRESS.md 记录完整变更 + 端到端
验证过程。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 04:28:55 +08:00
ericwyuan
28397e9e6a feat(timeline): 事件时间轴支持删除视频会话
三端联动实现(fam-ui -> fam-core -> fam-edge),沿用项目里"NAS 转发写请求
到 Oracle"的既有模式(照抄 identity_correct 那套骨架):

- fam-edge:oracle_db.py 新增 delete_video()(删 events+videos 行 + 磁盘上
  的运动片段文件;不清理 ss_motion_events 源事件,因为素材一旦处理完就不会
  被生产者重新捡起,删片段不会触发重新分割);api_gateway.py 新增
  POST /api/oracle/video/delete
- fam-core:新增 db_layer.delete_sync_video()(第一个"NAS 直接写自己镜像表"
  的函数——增量同步只做 upsert 感知不到删除,不能像纠错那样靠 trigger_now()
  拉增量顺带清理)+ oracle_sync.push_video_delete() + ui_api.py 新增
  DELETE /api/ui/videos/<id>(先回推 Oracle,成功后才清本地镜像,避免数据
  不一致)
- fam-ui:Timeline.vue 详情卡片加删除按钮(原生 confirm() 二次确认,项目里
  之前没有确认弹窗组件先例);api.js 新增 deleteVideo()

新增 6 个 delete_video 单元测试;已在 Oracle/NAS 用自造测试数据完整跑通端
到端链路(转发成功、两端记录清理、磁盘文件删除、幂等 404),未触碰任何真
实监控数据。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 04:25:43 +08:00
ericwyuan
2e370ab668 docs: 同步问答链路抽离到 ai-gateway 后的架构文档
README.md/PROGRESS.md 里大量描述还停留在"FAM-Edge 自己维护 Gemini→NVIDIA→
Ollama 问答降级链"的旧架构,跟实际代码(FAM-Edge 已改为转发客户端,模型链
整个搬到独立的 ai-gateway 服务)不一致,逐处订正:

- 新增 §3.4 AI-Gateway 模块章节、部署拓扑图、Gitea 仓库表新增 ai-gateway 条目
- 模块表(FAM-Core/FAM-Edge)、API 文档、数据库 compute_provider 说明更新
- §6.2/6.3 本地 Ollama 与模型适配器章节:去掉已删除的 OllamaAdapter/role=text,
  问答降级链路图重画为"FAM-Edge 转发 -> AI-Gateway 内部降级"
- 修复已经损坏(合并冲突残留)且过时的 fam-edge/config.yaml 示例,新增
  ai-gateway/config.yaml 示例;如实记录两份 .env 各自独立维护的实际部署状态
- PROGRESS.md 补一条 2026-08-23 变更记录 + 服务运行状态表新增 AI-Gateway 行

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 17:40:55 +08:00
ericwyuan
5caeb299a4 refactor(fam-edge): 问答链路抽离到独立 ai-gateway 服务,fam-edge 改为转发客户端
原本嵌在 fam-edge 里的问答模型降级链(NVIDIA 文字模型 -> Gemini 非 flash 文字
模型 -> 本地 Ollama 兜底,含 key 轮换/熔断)跟视频分析业务无关,是通用能力,
抽成独立 ai-gateway 服务(OpenAI 兼容协议),除了 fam-edge 自己,别的项目也能
直接接入。

- qa.py 重写为 HTTP 转发客户端,调 ai-gateway 的 /v1/chat/completions,翻译回
  原有 run_qa/run_qa_stream 契约,api_gateway.py 和 fam-core 调用方零改动
- 删除 model_adapters/ollama_adapter.py 及其测试(问答专用,视频分析不需要本地模型)
- gemini_adapter.py / nvidia_adapter.py 移除 chat()/chat_stream() 及问答专用超时
  (只保留视频分析用的 analyze_video)
- app.py 移除 Ollama 预热逻辑(现在由 ai-gateway 自己负责)
- config.yaml 移除 3 个问答专用 model 条目,新增 ai_gateway 客户端配置块

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 14:12:01 +08:00
ericwyuan
9dff19e6ac feat(fam-edge): 赤膊成年人直判爸爸(免费规则) - 用户确认家里只有爸爸会光膀子
用户原话:"赤裸的大人都是爸爸,家里没有其他人会赤裸"——加进闭集识别的免费
规则层(跟汤圆/媳妇同级),命中就不用再调用视觉大模型比对参考图,比 auto_id
更快更准。只对成年人生效(判断顺序上幼儿/儿童年龄档在前面,小孩光膀子玩很
正常,不适用这条规则)。

排查历史数据时用这条规则筛出了一个真实误判:video 1092 的一次赤膊出现被
auto_id(NVIDIA 视觉比对)错误识别成"爷爷",实际是"爸爸"。已用
correct_video_identity 手动纠正(标记 source=manual,受保护不会被以后的自动
识别覆盖回去),描述文字已确认同步更新。

顺带确认了另一批命中同样关键词的历史数据(74 条里有 3 条是"汤圆"光膀子)
是正确的——3 岁小孩光膀子玩正常,是通过幼儿/儿童年龄档规则识别出来的,跟
赤膊无关,不是误判,没有动。

新增 4 个测试:赤膊成年男性直判爸爸且不调用大模型(用会报错的假
person_identifier 验证短路生效)、覆盖各种赤膊近义词表述、赤膊小孩仍然
正确判成汤圆不受影响、没有赤膊关键词时行为不变照常走大模型比对。
2026-08-23 11:00:43 +08:00
ericwyuan
5f7b27bf04 fix(fam-edge): 人物识别结果没同步进事件描述文字
用户反馈:事件卡片上方的人物徽章正确显示"媳妇/爸爸",但描述文字里还是
"人物B双手叉腰站在客厅中央;人物A在远处厨房"这种原始 uid——两处对不上。

根因:rewrite_event_person_names() 只改了 person_list_json /
person_appearances_json[].uid 这些结构化字段,从没碰过 description(事件
描述)和 videos.summary_json(会话摘要)——这两个是大模型写的自然语言,
"人物A"这类 uid 是直接以文字形式嵌在句子里的,结构化字段改名不会带着改。

现在 rewrite_event_person_names() 额外对 description/summary_json 做文本
替换。替换顺序按 rename_map 的 key 长度降序——uid 可能带 "#2"/"#3" 这类
同名冲突后缀,"人物A" 是 "人物A#2" 的前缀,短的先替换会把长的错误地部分
命中,长的先替换才对。

同时跑了两次一次性回填修历史数据:
1. 对所有已有 person_identity_map 映射的 1265 个视频重新跑一遍
   rewrite_event_person_names,把之前只改了徽章、没改描述文字的历史数据
   补上(events 里"纯人物X不带真名"的还剩 258 条,涉及 94 个视频——都是
   性别/年龄特征本来就是 unknown 的画面,闭集识别本来就该留空不瞎猜,
   不是回填没覆盖到,是真的没法从图像信息判断是谁)
2. 找到 9 个从未跑过闭集识别的漏网视频(应该是 person_identifier 功能上线
   前处理的),重新识别后 1 个(video 2194)成功解析出汤圆+爸爸,其余 8 个
   同样是性别未知,正确保持未解析

新增 2 个测试覆盖 description/summary_json 文本替换,以及带后缀 label 的
替换顺序正确性。
2026-08-23 10:50:52 +08:00
ericwyuan
63b28663f1 feat(fam-edge): 启动时预热 Ollama,避免兜底触发时冷启动等 1-2 分钟
排查"Ollama 模型没有常驻内存":systemd 里 OLLAMA_KEEP_ALIVE=-1 其实一直配置
正确,但这个环境变量只保证"加载过之后不因为空闲被换出",不负责启动时主动
预加载。查 model_calls 表,ollama provider 从来没有一条调用记录——因为它是
问答链路最后一级兜底,前面 NVIDIA/Gemini 一直调用成功,从未真正轮到它,
自然也就从未被加载进内存过。

新增 OllamaAdapter.warm_up():送一条 num_predict=1 的最小请求强制模型加载,
超时给够 180s(冷启动实测能到 1-2 分钟)。app.py 启动时在后台线程调用,不
阻塞主服务启动;找不到 ollama 配置或预热失败都只记警告,不影响服务本身。

实测部署验证:重启后 36 秒完成预热,ollama ps 显示 qwen2.5:7b 已加载,
expires_at 显示不过期(keep_alive=-1 生效)——真正需要兜底的那一刻不会再有
冷启动延迟。

新增 test_ollama_adapter.py 4 个用例覆盖预热成功/HTTP失败/异常不上抛/超时
时长。
2026-08-23 09:33:49 +08:00
ericwyuan
bdf561a415 fix(fam-core): 登录密码不再硬编码兜底(fail-closed) + 会话有效期改 2 小时
之前 FAM_AUTH_USER/FAM_AUTH_PASS 没配置时会退回代码里写死的 ericwyuan/
iLoveJava5——这两个值跟 NAS SSH 密码是同一个,且这个登录页已经通过 frp 暴露在
公网上。.env 万一没配置好(这个项目里已经发生过好几次"忘了 source .env"的
情况),公网入口就会用一个和 SSH 密码相同、且写在源码里的已知密码兜底,双重
风险叠一起。

现在改成 fail-closed:FAM_AUTH_USER/FAM_AUTH_PASS 只要有一个没配置,直接拒绝
所有登录(打一条 ERROR 日志提醒去配 .env),不再有任何硬编码默认值。

会话有效期从 7 天改成 2 小时(按用户要求)。

新增 test_auth.py 11 个用例覆盖:未配置时 fail-closed、正确/错误凭据校验、
只配了一半也要拒绝、session 过期判定、白名单路径匹配。

已实测验证:当前 .env 里 FAM_AUTH_USER/PASS 仍是配置好的(还是 ericwyuan/
iLoveJava5,这个值本身没改——只是不再硬编码在代码里,运维如果想换成不同于
SSH 的密码,现在改 .env 就行,不用改代码),登录/拒绝错误密码都验证正常,
cookie Max-Age=7200 确认生效。
2026-08-23 07:56:33 +08:00
ericwyuan
9bba7b7e14 fix(chat): 流式问答中文乱码 + 问答模型链换成专用文字大模型
## 乱码修复
Gemini 流式接口 resp.iter_lines(decode_unicode=True) 没有显式设置
resp.encoding,requests 自己猜编码猜错了(Gemini 的 SSE 响应 Content-Type
不带 charset 参数),导致中文变成典型的 UTF-8 被当 Latin-1 解码的乱码。
fam-core 转发这一跳的 requests.iter_lines 调用有同样的坑,一并修了。
两处响应的 Content-Type 都补上 charset=utf-8,减少下游再犯同样错误的机会。
顺手用 latin-1/utf-8 往返把 NAS chat_history 里已经存了乱码的 3 条历史记录
恢复成正常文字。

## 问答模型链重新设计(跟视频分析完全独立)
用户要求问答不再用 flash/omni,优先用 NVIDIA 免费文字模型里上下文最大的几个,
不行再退 Gemini 非 flash 文字模型,最后本地 Ollama 兜底。

- base_adapter 新增 usage 字段(纯标记,不影响行为)
- qa.py 的 QAOrchestrator 只挑 role='text' 的适配器参与问答(按 config 出现
  顺序决定降级顺序),role='vision' 的视频分析适配器(gemini-flash-latest /
  nvidia omni)不再参与问答,两条链路彻底解耦
- 新增问答专用 NVIDIA 文字模型链(同一适配器内部 model_chain 降级):
  nemotron-3-ultra-550b-a55b(1M上下文/561B) -> nemotron-3-super-120b-a12b
  (1M上下文/124B) -> openai/gpt-oss-120b(131K上下文/117B)。三个都用真实
  API 调用验证过当前账号可访问;同时验证过 llama-3.1-70b-instruct 和
  nemotron-super-49b-v1.5 都收到"08/25/2026 起弃用"通知,排除;
  nemotron-ultra-253b/mistral-large-2/nemotron-4-340b/kimi-k2.6 在目录里
  能看到但实测调用返回 404"账号无权限",排除
- 新增问答专用 Gemini 非 flash 文字模型:gemini-pro-latest -> gemini-2.5-pro
  (1M 上下文,复用视频分析同一批多 key 轮换)
- /api/edge/chat/ask(/stream) 默认 max_tokens 从 1024 提到 3072:新链路
  优先用的都是"推理"模型,回答前会先吐一段思考过程,1024 经常在思考阶段
  就被截断,用户永远看不到真正答案

新增 test_qa.py::test_init_only_keeps_text_role_adapters_in_config_order
验证角色过滤+顺序正确。

本地检查过 Ollama 服务本身:systemd enabled+running(8h 正常运行,
qwen2.5:7b 已加载),并非没启动——用户观察到的现象另有原因。
2026-08-23 07:46:48 +08:00
ericwyuan
22bd4d4ff9 fix(fam-edge): 停用旧 person_service LLM 合并 - 已被闭集识别取代且仍在持续产生错误合并
person_identifier 闭集识别(汤圆/媳妇走性别年龄规则,爷爷/爸爸走视觉大模型比对
参考图)已经稳定跑起来了,people 表里 4 个人的真实姓名行数据干净(汤圆692/
爸爸323/爷爷320/媳妇290)。但发现 person_service 的老 LLM 合并调度还在跑(每
30 分钟一次),持续把不可靠的猜测结果(如"人物E"→"人物T"这类无意义合并)写进
canonical_name,跟新系统打架,是 people 表里 392 条历史垃圾行的根源。

停用 person_service.enabled,改用新系统。順帶清理 Oracle 库里已经产生的 392
条垃圾行:10 条能对应上真实姓名的(如"幼儿"→汤圆 100 次、"人物A"→爷爷 37 次)
appearances 累加合并回真正的规范行,其余 382 条(老 LLM 合并的无意义中间结果,
canonical_name 对不上任何真实姓名)直接删除——不影响 events/videos 里已经落库
的原始分析数据,只是清理 people 表的聚合展示行。
2026-08-23 02:23:00 +08:00
ericwyuan
2c5bf950c5 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 字段完善
2026-08-23 00:13:56 +08:00
ericwyuan
aca1a674b1 [文档] README 补充登录校验(auth 模块/凭据/白名单)与 frp 外网 8000 映射说明 2026-08-22 14:20:40 +08:00
ericwyuan
9c51c65e62 [功能] FAM-Core 登录校验 + frp 外网暴露 8000 - 新增 auth 模块(POST /api/login 校验 FAM_AUTH_USER/PASS 默认 ericwyuan/iLoveJava5, HttpOnly cookie 7天, 页面未登录 302 /login, /api/* 未登录 401; 白名单: /login /health /api/ss/webhook /assets/*); 内置深色登录页(SPA 零改动); frpc.toml 增加 fam-core 代理 外网8000->NAS8000 2026-08-22 14:19:54 +08:00
ericwyuan
1dd9df34c2 [功能] 事件-片段一致性对账 - /api/oracle/activity 加 segment.consistency(事件总数/已结束/已分割/缺口+明细/素材覆盖范围), 服务状态页展示一致性数字 2026-08-22 13:40:42 +08:00
ericwyuan
177c2a83b7 [修复] 进行中事件 duration=0 分割丢失 - 推送过 duration<=0 的事件记入 _zero_dur_ids, 下轮窗口回查已结束(duration>0)补推覆盖 Oracle; 保证 ss_motion_events 最终持有真实 duration, 分割不再跳过 2026-08-22 13:25:20 +08:00
ericwyuan
cbcc5d0325 [功能] 服务状态页新增「视频分割」状态 - Oracle /api/oracle/activity 加 segment(运动片段 done/pending/failed/文件数/最近分割活动), ServiceStatus.vue 加视频分割卡片 2026-08-22 13:18:57 +08:00
ericwyuan
01a8ac2a3f [文档] 拓扑图 FAM-UI 标注同步为 Vue3(清理残留 Streamlit :8501) 2026-08-22 13:15:04 +08:00
ericwyuan
354ff18101 [文档] 代码-文档一致性整理 - README 同步 v3 全量(2.1网络/3模块表/4表结构/5 API含people-clips/6.1历史标注/8部署systemd+Vue3+config/12进度); docs/DEPLOY.md 重写为 v3; 删除过时 start_ui.sh(Streamlit); start_edge.sh 注明 systemd 为准; 补提交 test_motion_notifier 2026-08-22 13:14:38 +08:00
ericwyuan
96644599d3 [修复] pymysql execute 字面 % 冲突 - SQL 中 motion_% 的 % 被当参数占位符导致 /api/ui/videos 500, 改用 LEFT(filename,7)='motion_' 判断前缀 2026-08-22 13:06:52 +08:00
ericwyuan
6a29e88d86 [优化] 时间轴/统计过滤空会话 - 只展示运动片段(motion_)或有事件的视频, 整段素材分割0段的空会话不占时间轴; get_sync_videos/get_sync_stats 加内容过滤 2026-08-22 12:59:21 +08:00
ericwyuan
4cf4fc4bc2 [功能] 人物管理按运动视频重设计 - 人物卡新增「运动片段」区块(缩略图/时间/摘要/事件数,点击跳时间轴定位); 新增 GET /api/ui/people/clips 后端接口(按 label/canonical_name 查关联运动片段,含 first_ts/clip_events); Timeline 支持 ?video= 定位 2026-08-22 12:55:30 +08:00
ericwyuan
ffcc292cce [文档] 同步 v3 运动事件驱动架构 - 整段素材分割运动片段再分析(不再整段送云端): README 1.1/2.2/2.2.1/2.3/模块表, PROGRESS 运行状态+变更记录; 记录 systemd 守护部署方式 2026-08-22 12:46:33 +08:00
ericwyuan
8d6cfad6c0 [修复] 运动片段分割 ffmpeg 调用缺可执行文件 - args 首元素补 ffmpeg 路径(此前被当成可执行文件导致全部分割失败) 2026-08-22 12:41:06 +08:00
ericwyuan
a1523b46c8 [重构] 运动事件驱动架构 - 整段素材按 ss_motion_events 分割运动片段再分析(不再整段送云端): oracle_db 加 motion_event_id/camera_id 列 + get_motion_events_in_range/has_unfinished_motion_in_range/get_video_by_motion_event_id; video_processor 素材→分割/片段→分析双分支(ffmpeg -c:v copy -c:a aac 保留音频); video_queue 片段入队; config 加 motion_segment 块 2026-08-22 12:19:17 +08:00
ericwyuan
2d9d96de56 [功能] fam-edge 运动心跳支持 - /api/ss/motion 空 events 心跳刷新, has_motion_in_range_local 按 max_heartbeat_age_sec(900s) fail-open 2026-08-22 12:08:00 +08:00
ericwyuan
7adc32486a [重构] 运动监测恢复轮询主路径(简单稳定) - poll_enabled 默认开启;修复3隐患: 推送失败批次不前进游标(下轮重试不丢事件)、重启优先续用DB游标(停机期间事件补推)、fetch limit 提到1000;Webhook降级为可选补充 2026-08-22 12:00:03 +08:00
ericwyuan
b2b537dde5 [文档] 同步 v3 运动监测架构(Webhook 驱动/去轮询) - README 补充 2.2.1 链路/API 表/模块表,PROGRESS 更新运行状态与变更记录 51-53 2026-08-22 11:11:15 +08:00
ericwyuan
e52d8e9103 [修复] MotionNotifier 补回 camera_ids 属性 - 避免重新开启轮询时 _fetch_events 引用缺失属性崩溃 2026-08-22 10:58:56 +08:00
ericwyuan
ca6d425b19 [重构] 运动监测改为 Webhook 驱动(去轮询) - 关闭 MotionNotifier 轮询,NAS 端合成稳定 event_id、摄像头名映射 camera_id、EVENT_TIME 解析为 epoch,SS Webhook 直接推送甲骨文 2026-08-22 10:53:03 +08:00
ericwyuan
01bbb39750 [新增] NAS 运动监测通知服务 MotionNotifier - 轮询/接收 SS 事件后主动 POST 推送到甲骨文 /api/ss/motion(单向 NAS->Oracle):新增 motion_notifier 子包、/api/ss/webhook 实时转发蓝图、db_layer 运动游标、app.py 接线与 config 块 2026-08-22 10:26:03 +08:00
ericwyuan
38dea6ba2e [重构] 运动预过滤改为本地事件(NAS推送) - 甲骨文不再反向访问NAS:新增 ss_motion_events 表/record_motion_events/has_motion_in_range_local,/api/ss/motion 接收端点,video_processor 改用本地运动事件预过滤,停用 dsm_motion_client 反向查询 2026-08-22 10:25:56 +08:00
78 changed files with 6723 additions and 1450 deletions

3
.gitignore vendored
View File

@@ -42,3 +42,6 @@ logs/
# Env
.env
.env.local
# 其他独立项目(各有独立仓库,勿提交进本仓库)
ai-gateway/

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,20 @@
# 2026-08-25 工作日志
## 前端迁移收尾 + 登录故障修复
- **前端最终落点**:云服务器 129.146.26.249 的 Caddy :80`http://129.146.26.249/`
SPA 静态在 `/var/www/fam-ui``/api/*` 经 frp 隧道反代回 NAS fam-core :8000。
**注意:新服务器 :8000 是 frp 隧道口(直通 NAS API不是前端入口**,登录请用 :80。
- **登录失败根因**:重启 fam-core 时 FAM_AUTH_USER/FAM_AUTH_PASS 未固化进 .env →
auth.py fail-closed未配置即拒绝所有登录→ 全部 401。已补进
`/volume1/web/sentinel-home-ai/.env`5 键ORACLE_SYNC_TOKEN/DSM_ACCOUNT/DSM_PASSWORD/
FAM_AUTH_USER/FAM_AUTH_PASS=ericwyuan/iLoveJava5并重启登录恢复 200。
- **旧 IP 清理**:本地仓库 config.yaml4 处)+ chat_handler/motion_notifier/oracle_sync
兜底默认值 + docs/DEPLOY.md + PROGRESS.md 共 6 文件旧 IP 129.146.203.203 → 129.146.26.249
已清 __pycache__。仓库/部署/README 三处一致。**未提交**。
- 孤儿云库 sentinel_home_ai + famcore 用户已 DROP新服务器
- SS Webhook代码中为可选补充motion_bp.py 文档明确SS 未配行动规则故休眠,
是否删除待用户拍板。
- **用户拍板:删除 webhook + NAS 前端托管,并已提交推送**(两个 commit 已 push 到 Gitea
`7948f55` docs 迁移文档、`cf3d145` refactor 移除 webhook/static_app 共删 329 行)。
**待办:代码尚未部署到 NAS**——需同步 fam-core 到 NAS、删 NAS fam-ui 目录 + start_ui.sh、
重启 fam-core 验证 /api 与登录(云端 :80 前端不受影响)。

View File

@@ -0,0 +1,37 @@
# 2026-08-29 工作日志
## 修复 Oracle→NAS 增量同步 1062 (uq_video_raw_uid 重复键) — 已部署验证
### 现象
- 同步状态页:状态"同步中"、`最近 —``本次增量 —`、游标卡在 `2026-08-29 04:57:15`
- 报错:`(1062, "Duplicate entry '1073-汤圆' for key 'uq_video_raw_uid'")`,游标永不推进
### 根因(两层)
1. **镜像表主键设计缺陷(主因)**`sync_identity_map` 以 Oracle `person_identity_map.id` 作主键。
Oracle 端 id 是 AUTOINCREMENT 代理键,但 Oracle 库重建/恢复后 id 会被复用——实测 Oracle
现在的 id=744 是 `(1073,'汤圆')`,而 NAS 上 id=744 还是旧的 `(1302,'人物E')`。旧 upsert 的
`ON DUPLICATE KEY UPDATE` 先按 **PK id** 命中旧行,再把它 UPDATE 成 `(1073,'汤圆')`,与
已有的 `(1073,'汤圆')` 行在 `uq_video_raw_uid` 上二次冲突 → 1062 → 整批失败 → 游标不推进
→ 每 30 分钟重复失败。
2. **陈旧 __pycache__部署陷阱**NAS 上 `.pyc` 时间戳比 `.py` 新(部署时 `.py` 带旧 mtime 拷贝),
Python 信任 pyc 加载了旧逻辑;另曾有两个 gunicorn master 并存8487/10914只有一个绑 8000。
### 修复
- **db_layer.py `upsert_sync_identity_map` v2**:镜像表改为 NAS 本地自增 `id` 主键,
业务键 `(video_id, raw_uid)` 唯一Oracle id 只落 `oracle_id` 列溯源UPDATE 子句不再改写
video_id/raw_uid只更新 oracle_id/canonical_name/source/updated_at/synced_at
- **scripts/ddl.sql**:同步更新表定义(`id INT AUTO_INCREMENT PRIMARY KEY` + `oracle_id INT`)。
- **现网迁移**`ADD COLUMN oracle_id` → 回填 `oracle_id=id``MODIFY id AUTO_INCREMENT`(无重复对,
迁移安全);修复早前测试误改的 3061 行 canonical_name。
- **部署**db_layer.py 用 stdin 管道覆盖 NAS**清空全部 __pycache__**;杀掉双 gunicorn单实例重启。
### 验证(通过)
- 09:55 一次补拉成功:`videos+260 events+1456 people+37 model_calls+744 identity_map+285`,游标推进到 09:55:06
- `/api/status``last_error=null``last_sync_at` 有值;手动 `/api/sync/trigger` ok拉 2videos/2events/1people/6calls/1identity
- `(1073,'汤圆')` 行已自愈:`oracle_id=744`NAS 旧 `(744→1302,'人物E')` 保留不冲突
### 待办/注意
- 本地 repo 有未提交改动:`fam-core/src/fam_core/db_layer.py``scripts/ddl.sql`(等用户拍板提交)
- 同类隐患sync_videos/sync_events/sync_model_calls 同样以 Oracle id 为主键,但 Oracle 侧这些表
只插入不 UPDATE、id 稳定,风险低;若将来 Oracle 也重建,需同样改法
- NAS 部署后必须清 __pycache__(或 touch .py否则旧 pyc 被加载(已踩坑)

View File

@@ -0,0 +1,43 @@
# 2026-08-31 工作日志
## fam-core 登录接入 auth-hub 统一登录(已部署 + 端到端验证)
### 目标(用户明确)
`http://129.146.26.249:8000`fam-core 公网入口frp → NAS:8000的登录改走 auth-hub
统一登录,账号 `ericwyuan` / `iLoveJava5` 登录。
### 现状与动作
- SSO 代码本地早已提交(`8120d2a feat(fam-core): 登录改接 auth-hub 统一登录`NAS 未部署(旧密码登录版)。
- **auth-hub 侧(甲骨文 /opt/auth-hub**
- FAM-Core client `a2PJTZuKQYxt1oGh`(已存在)→ 轮换 secret 得明文
`yZfJfD3xJmrCj9PQlbt7P8An9ebZ-nT2f3v4BTtOSu8`(旧密钥失效)
- redirect_uris 增加 `http://129.146.26.249:8000/api/auth/callback`(原 :80 回调保留,
auth-hub 精确匹配,用户入口是 :8000 所以回调也要 :8000 路径)
- **NAS 侧fam-core**
- venv 补装 `PyJWT==2.13.0` + `cryptography==50.0.1`RS256 验签必需)
- 部署新 `auth.py`cat 管道覆盖)
- `.env` 追加 4 个变量:`AUTH_HUB_ISSUER=http://129.146.26.249:5300`
`AUTH_HUB_CLIENT_ID=a2PJTZuKQYxt1oGh``AUTH_HUB_CLIENT_SECRET=<上面>`
`AUTH_HUB_REDIRECT_URI=http://129.146.26.249:8000/api/auth/callback`
-__pycache__、杀双 gunicorn今天 18:56 又一个旧进程 + 8/29 旧 master、单实例重启
### 端到端验证curl cookie jar 全链路)
`/login` 302 → auth-hub `/authorize`PKCE→ hub 登录ericwyuan/iLoveJava5 200
`authorize` 302 回 `:8000/api/auth/callback?code&state` → fam-core 换 token+JWKS 验签 →
种 fam_session → `/api/auth/check` = `{"authed":true}``/api/status` 200 ✓
### 注意
- 本地 repo 3 个提交待 push8120d2a SSO + 2 docs等用户确认推送
- NAS 仍跑旧版其余代码webhook 移除等未部署,独立待办)
- 密码登录 `/api/login` 已移除fam-ui SPA 无登录逻辑,不受影响
## 补充webhook/旧前端清理部署到 NAS用户确认"清理"
- 全量比对 NAS vs repo`app.py``motion_bp.py``config.yaml` 三处是旧版(其余已同步)
- 同步3 个文件app.py 移除 static_bp/refresh_camera_map 调用motion_bp.py 移除 webhook 端点;
config.yaml 移除 camera_name_to_id/enrich_window_sec
- 删除废弃:`static_app.py``fam-ui/` 目录、根 `start_ui.sh``scripts/start_ui.sh`
- 清 pycache + 单实例重启
- 验证:启动日志无 `refresh_camera_map` 告警;`/api/ss/webhook` → 401已移除白名单与路由
SSO 登录 `/login` 302 → auth-hub 正常、`{"authed":true}``/api/ss/status` 200Oracle 同步正常
- 至此 NAS fam-core 代码与本地 repo 全量一致(除 .env/venv/logs

View File

@@ -0,0 +1,39 @@
# 2026-09-03 同步 1062 复发 + Oracle 盒子过载
## 问题
用户报 sync 状态卡在「同步中」+ `1062 Duplicate entry '...mp4' for key 'filename'`
游标停在 `2026-09-02 13:55:21`
## 根因(与 8/29 identity_map 同一类)
Oracle FAM-Edge 的 SQLite 视频/人物 id 整体重排2303 段 → 3408/4600+ 段),
NAS 镜像表 `sync_videos`/`sync_people` 旧 upsert 以 Oracle id 当主键插入,
与已存在的同 `filename`/`label` 行在 UNIQUE 键上二次冲突 → 1062整批中止、游标不推进。
量化:本次积压 348 videos 中 **114 行**会 10629 people 中 **6 行**会 1062。
events/model_calls 仅 PK idON DUPLICATE 按 id 更新,不会 1062→ 无需改。
## 修复(已部署 NAS + 提交推送 0238030
- `upsert_sync_videos`:改以 `filename` 业务键去重,命中就地 UPDATE 保留 NAS 原 id
(防 sync_events/model_calls/identity_map 的 video_id 外键失效Oracle id 落
`oracle_id` 列溯源;未命中插入优先用 Oracle id 对齐子表引用,主键冲突回退自增。
- `upsert_sync_people`:同模式,以 `label` 业务键去重people.id 无外键引用)。
- `scripts/ddl.sql`sync_videos/sync_people 的 id 改 NAS 本地自增 + 新增 oracle_id 列。
- NAS 现网迁移:两表加 oracle_id、回填 oracle_id=id、id 改 AUTO_INCREMENT重启。
- 验证:单批补拉 videos+348 events+1017 people+9 model_calls+500 identity_map+238
游标 → `2026-09-03 08:51:11`,无 1062。目标行保留 id=2303、oracle_id=3408。
## 第二个问题Oracle 盒子过载/挂死(未解决,环境层)
NAS 日志 08:5108:56 持续:`推送运动事件到 Oracle 失败 Connection reset` /
`500 {"error":"database is locked"}` / `RemoteDisconnected`
FAM-Edge /health 先 200status ok后变 HTTP 000最终整盒 129.146.26.249 全暗
:80/:5000/:5300/:7000/:8123 均 000但 TCP :22/:5000 端口 OPEN、SSH 握手被拒
kex 阶段 Connection closed→ 进程卡死/资源耗尽4C23G 上 FAM-Edge 分析管线 +
ai-gateway + garmin + auth-hub + frps + caddy 挤一起SQLite 锁竞争 + 可能 OOM
**SSH 连不进去,无法远程修复。** 需用户在 Oracle Cloud 控制台重启 VM或等其自恢复。
恢复后 NAS 的 oracle_sync 后台线程30min 周期)会自动从游标 08:51:11 续拉,
motion_notifier 也会恢复推送。
## 经验
- 所有以 Oracle id 为镜像主键的表迟早会因 Oracle 库重建/重排踩坑。已修:
identity_map(8/29)、videos+people(9/3)。events/model_calls 目前 PK-only 安全,
但若将来 Oracle 重建导致子表引用错位,需同样改造(加 oracle_id + 业务键)。
- Oracle 盒子服务过多挤在 4C23G建议拆分或给 FAM-Edge SQLite 开 WAL + 限速。

View File

@@ -0,0 +1,45 @@
# sentinel-home-ai 项目长期记忆
## 项目概况
- 家庭多模态智能监控系统,仓库 `http://192.168.50.64:3000/ericwyuan/sentinel-home-ai`Gitea 内网)
- 本地 `/Users/ericwyuan/Desktop/Work/sentinel-home-ai`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/`**只分析运动片段**。
- **Drive 中转是跨境提速设计(非仅 NAT 原因)**:中国→海外裸 HTTP/SCP 直传 Oracle 很难(跨境单 TCP 流
丢包掉速);改走 GoogleCloudSync 分块并发 + 断点续传,抗抖动)→ Oracle rclone 每 5min 拉取
海外数据中心互联Gbps。**不要轻易提议砍掉 Drive 改 NAS 直连 Oracle**。
- 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.26.249**SSH key `~/.ssh/oracle_new`fam-edge 由 **systemd
`fam-edge.service` 守护Restart=always**——部署代码后必须 `sudo systemctl restart fam-edge`
手动 setsid 会端口冲突venv `/opt/fam-edge/venv`;库 `/opt/fam-edge/data/oracle.db`
素材 `/opt/fam-edge/gdrive_videos`logs 目录 ubuntu 可写。
**前端亦托管于此机 Caddy :80**fam-ui SPA + /api 反代回 NAS fam-core :8000 经 frp 隧道);旧机 129.146.203.203 已退役。
- **登录2026-08-31 起)**fam-core 不再自己校验密码,登录全权走 auth-hub 统一登录
OAuth2 Auth Code + PKCE / OIDC`http://129.146.26.249:5300`。fam-core client
`a2PJTZuKQYxt1oGh`,回调 `:8000/api/auth/callback`auth-hub 侧注册了 :80 和 :8000 两个回调)。
NAS `.env``AUTH_HUB_ISSUER/CLIENT_ID/CLIENT_SECRET/REDIRECT_URI`(缺任一 fail closed
账号:`ericwyuan / iLoveJava5`hub 端。NAS venv 需 PyJWT + cryptographyRS256 验签)。
- 部署:本地 git 提交 push Gitea → tar 管道NAS 直接解包Oracle `--strip-components=1` 到临时目录再 cp避免动 data/venv
- **NAS 部署坑2026-08-29 踩过)**:拷贝 `.py` 会带旧 mtime`.pyc``.py`Python 加载陈旧字节码
(旧逻辑上线,曾导致 1062 假象)。部署后必须 `find src -name __pycache__ -exec rm -rf {} +` 再重启;
`pkill -f` 会匹配 SSH 自身命令导致断连(用 PID 文件或精确 pattern
- **镜像表键设计2026-08-29 定稿)**`sync_identity_map` 不再以 Oracle `person_identity_map.id` 作主键
Oracle 库重建会复用 id改 NAS 本地自增 `id` + 唯一键 `(video_id, raw_uid)`Oracle id 落 `oracle_id` 列溯源;
同批 upsert 的 UPDATE 子句不改写键列,避免 uq 二次冲突 1062。sync_videos/events/model_calls 同理(风险低未改)。
## 提交规范(强制)
- 格式 `[阶段X.Y子任务号] 子任务名称 - 完成内容简述`Bug 修复 `fix(模块): 问题简述`
- 一任务一 commit开工先 `git pull --rebase`;阻塞先 commit 加 `[WIP]`
- 禁止 `git push --force``--no-verify`

View File

@@ -1,21 +1,90 @@
# 项目进度追踪
> 最后更新: 2026-08-20 18:50
> 最后更新: 2026-08-31
## 服务运行状态
| 服务 | 节点 | 地址 | 状态 | 验证结果 |
|------|------|------|------|---------|
| FAM-Core | NAS | 0.0.0.0:8000 | ✅ 运行中 | health=ok, gunicorn --threads 4, scheduler+dispatcher+poller 全部 running |
| FAM-Edge | Oracle | 0.0.0.0:5000 | ✅ 运行中 | v2.0, SQLite 异步队列 + 消费者线程 + TokenBucket 速率限制 |
| FAM-Core | NAS | 0.0.0.0:8000 | ⚠️ 代码已完成,待生产部署 | health=ok, gunicorn 单 workerOracle-Sync + MotionNotifier 轮询 SS 事件推送(游标 DB 续用/失败重试);已部署事件时间轴删除功能;`/api/status`/`/api/ss/status` 500 bug 已修复;**登录改接 auth-hub 统一登录OIDC本地全链路验证通过生产 client 注册 + NAS `.env` 配置 + 部署重启尚未执行** |
| FAM-Edge | Oracle(新机 129.146.26.249 | 0.0.0.0:5000 | ✅ 运行中 | systemd 守护fam-edge.service素材→运动片段分割→只分析片段问答改为转发 AI-Gateway队列消费正常已部署 `/api/oracle/video/delete`FFmpeg 已补装;新增 DiskGuard 磁盘守护 |
| **AI-Gateway** | Oracle | 0.0.0.0:5100 | ✅ 运行中 | systemd 守护ai-gateway.service2026-08-23 新增;独立仓库/独立部署;`/health` 正常端到端问答实测成功provider=nvidia |
| MariaDB | NAS | 127.0.0.1:3306 | ✅ 运行中 | 10.11.11, 6 张表, utf8mb4 |
| Ollama | Oracle | 127.0.0.1:11434 | ✅ 运行中 | qwen2.5:7b仅智能问答兜底不参与视觉/融合) |
| Ollama | Oracle | 127.0.0.1:11434 | ✅ 运行中 | qwen2.5:7b仅智能问答兜底不参与视觉/融合)**被 AI-Gateway 调用,不再被 FAM-Edge 直接调用** |
## 2026-08-31 FAM-Core 接入 auth-hub 统一登录OIDC
- **背景**:独立的统一登录/SSO 服务 [auth-hub](http://129.146.26.249:3000/ericwyuan/auth-hub)(跟本项目完全独立的仓库/数据库/部署OAuth2 Authorization Code + PKCE + OIDC已单独开发验证完成本地全链路 47 单测通过目标是自己的几个网站统一接到这一处身份服务不用每个网站各自维护一份账号密码。FAM-Core 是第一个接入方。
- **改造范围**`fam-core/src/fam_core/auth.py` 全量重写commit 待提交):
- 原来自己校验 `FAM_AUTH_USER`/`FAM_AUTH_PASS` 的账号密码逻辑、内置深色登录表单页全部移除;`GET /login` 改为直接 302 跳转 auth-hub `/authorize`(带 PKCE `code_challenge`/`state`,均存进程内 `_pending`10 分钟过期)
- 新增 `GET /api/auth/callback`:收 auth-hub 回跳的 `code`,服务端到服务端 `POST /token``id_token`(用 `PyJWT` + `PyJWKClient` 拉 auth-hub JWKS 验 RS256 签名 + `iss`/`aud`),验证通过后种回原有的 `fam_session` HttpOnly cookie**2 小时有效,机制不变**)——下游 `is_authed()`/`init_auth()` 全局拦截逻辑完全没动
- 接入参数 `AUTH_HUB_ISSUER`/`AUTH_HUB_CLIENT_ID`/`AUTH_HUB_CLIENT_SECRET`/`AUTH_HUB_REDIRECT_URI` 走环境变量NAS `.env`,沿用 `FAM_AUTH_*` 时代"未配置齐全直接拒绝所有登录"的 fail-closed 取舍),`AUTH_HUB_REDIRECT_URI` 必须与 auth-hub 端 `manage_clients create` 登记的 redirect_uri 逐字符一致
- `requirements.txt` 新增 `PyJWT>=2.8.0``cryptography>=42.0.0`
- **访问控制取舍(用户明确决策)**:不在 fam-core 侧加用户名白名单——任何在 auth-hub 上(自助注册 + 管理员审批后)拥有账号的人登录后都能访问本系统;不保留 `FAM_AUTH_USER`/`FAM_AUTH_PASS` 作为备用登录方式SSO 是唯一入口
- 测试:`tests/test_auth.py` 全量重写PKCE 生成、state 校验、token 交换成功/失败、id_token 验签失败、白名单、`before_request` 拦截fam-core 全量 39/39 通过
- **端到端验证(本地)**:本地起了一份 auth-hub 开发实例(:5300+ 一个仅含 `auth_bp` 的最小 Flask 壳子(模拟 fam-core跳过 MariaDB 依赖),用真实浏览器走完整 Authorization Code + PKCE 闭环——`/` 未登录 302 `/login` → auth-hub 登录页 → 登录成功回跳 `/api/auth/callback` → 换 token + 验签 → 种 cookie → 落地首页;`/api/auth/check` 确认 cookie 生效;`/api/logout` 确认清会话。验证完把临时创建的 OAuth client 和测试账号都从 auth-hub 本地库删掉了。
- **生产部署进度**
1. ✅ 已在 Oracle 生产 auth-hub`/opt/auth-hub`:5300注册正式 client `FAM-Core`client_id/secret 已生成,明文只显示过一次,未写入仓库,需要的话找 auth-hub 管理后台或 `manage_clients list` 核对 client_id
2.**NAS `.env` 待补**(本次会话没有 NAS SSH 免密权限,未执行):`AUTH_HUB_ISSUER=http://129.146.26.249:5300``AUTH_HUB_CLIENT_ID``AUTH_HUB_CLIENT_SECRET``AUTH_HUB_REDIRECT_URI=http://129.146.26.249/api/auth/callback`
3. ⏳ 部署新代码到 NAStar 管道,见 `docs/DEPLOY.md` §3`bash start_core.sh` 重启——待执行
4. ⏳ 生产环境浏览器实测一遍完整登录闭环——待执行
## 2026-08-28 Oracle 迁移故障排查FFmpeg 缺失 + 磁盘写满死循环 + DiskGuard
- **触发**:用户反馈事件时间轴历史图片丢失、"谷歌同步是不是有问题"。逐层排查,发现的是三个叠在一起的独立问题,不是一个:
1. **8/25 迁移新机器时漏装 FFmpeg**`ffprobe: command not found`。导致 `videos.duration_sec` 全部读成 0、运动片段分割 `_segment_motion_clips` 全部失败(`ffmpeg` 也没装190 个素材全部"分割 0 段"`events` 表完全是空的,`motion_clips/` 目录空的——事件时间轴从 8/25 起就没有任何新内容,图片自然也生成不出来。**已修复**`apt-get install ffmpeg`,恢复正常。
2. **8/25 之前的历史图片无法找回**:迁移时只搬了 `data/oracle.db`(数据库/文字记录),没有搬 `motion_clips/`磁盘上的运动片段视频文件缩略图的生成源旧机器129.146.203.203)已经释放销毁。历史事件的文字描述还在(早就同步进了 NAS 镜像),但配图永久丢失,无法找回。
3. **Oracle↔Google Drive 同步"只下载不删除"**:排查一度走了弯路(先怀疑 NAS CloudSync 停摆,后发现新文件其实一直在下载,是判断证据不足导致的误判)。最终精确对比 Google Drive 远端141 文件,最早 8/25vs Oracle 本地264 文件,最早 8/15实锤148 个文件是"远端已删、本地未删"的孤儿文件。根因是磁盘被写满到 100%`no space left on device`),触发 rclone 内置安全机制——同步过程中遇到 IO 错误就整体拒绝执行删除,形成"越满删不掉,删不掉越满"的死循环。**已修复**:手动删除 148 个孤儿文件,释放 41G磁盘 100%→59%;重新跑 `rclone_sync.sh` 验证 `exit=0` 无错误,下载+删除恢复正常。
- **新增 DiskGuard 磁盘守护**commit `ef56ae5`,永久性预防措施):`fam-edge/oracle_db.py` 新增 `get_oldest_purgeable_material()`(只挑最旧的、已完成分割阶段的整段素材,绝不碰运动片段和处理中的素材);`disk_guard.py` 新增后台线程5 分钟检查一次,剩余空间 <10GB 触发清理,删到 15GB 水位为止;`/api/oracle/activity` 新增 `disk` 字段(实时剩余空间 + 最近清理动作。9 个新测试fam-edge 全量 139/139 通过。
- **用户决策**8/25-28 期间因 FFmpeg 缺失而"分割 0 段"的 190 个素材/736 个运动事件,明确选择不重新处理,翻页接受这段时间的空白。
- **顺带修复**commit `09708bd``/api/status``/api/ss/status` 500——上次"移除 SS Webhook"重构(`cf3d145`)删了 `MotionNotifier``camera_name_to_id` 等属性但 `status()` 忘记同步更新,导致这两个端点自那次重构起就一直是坏的。
- **文档同步**commit `3ec79de`README 里 5 处 + `vite.config.js` 1 处仍描述"`/api/ss/webhook` 保留为可选"的过时内容,实际已被彻底删除,逐处订正。
## 2026-08-24 事件时间轴支持删除视频会话
- **决策**:用户要求事件时间轴能删除某个视频会话;调研确认项目里没有任何 delete 类端点先例,且增量同步(`get_sync_delta`)只做 upsert 感知不到 Oracle 端的物理删除NAS 镜像必须显式清理,不能靠 `trigger_now()` 拉增量。用户明确要求连同磁盘上的视频文件一起删除,明确选择不做"防止误删事件被重新分割"的额外保护机制(风险极低:素材一旦标记 done 就不会被生产者重新捡起)
- **三端实现**commit `28397e9`
- fam-edge`oracle_db.py` 新增 `delete_video(video_id)`(写锁保护,先删 events 再删 videos再删磁盘文件不清理 `ss_motion_events` 源事件);`api_gateway.py` 新增 `POST /api/oracle/video/delete`
- fam-core`oracle_sync.py` 新增 `push_video_delete()``db_layer.py` 新增 `delete_sync_video()`(项目里第一个"NAS 直接写自己镜像表"的函数);`ui_api.py` 新增 `DELETE /api/ui/videos/<id>`(先回推 Oracle 成功后才清本地镜像,回推失败 502 不改本地状态,避免数据不一致)
- fam-ui`Timeline.vue` 详情卡片新增"🗑 删除会话"按钮(原生 `confirm()` 二次确认,删除成功后从本地列表移除 + 重新选中 + 刷新统计卡);`api.js` 新增 `deleteVideo()`
- **测试**`test_oracle_db.py` 新增 6 个 `delete_video` 单元测试(正常删除/磁盘文件删除/文件已缺失不报错/不存在返回 None/不影响 ss_motion_eventsfam-edge 全量 130/130 通过
- **端到端验证**:全程用自造测试数据(`motion_TESTDELETE*`未触碰任何真实监控记录——Oracle 侧直接调 `/api/oracle/video/delete` 验证记录+文件删除+幂等 404NAS 侧手动插入镜像行模拟已同步状态,登录后调 `DELETE /api/ui/videos/<id>`,验证 Oracle 记录+文件、NAS 镜像记录均被清理
- **已知限制**:前端浏览器 UI 层面未做可视化验证fam-core 全站要求登录,出于"不代用户输入密码"的原则没有走浏览器交互式登录),只验证了 `npm run build` 编译通过 + 完整 API 链路
## 2026-08-23 问答链路抽离为独立 ai-gateway 服务
- **决策**:原本嵌在 FAM-Edge 里的问答模型降级链NVIDIA 文字模型链 → Gemini 非 flash 文字模型链 → 本地 Ollama 兜底,含 key 轮换/熔断跟视频分析业务无关是通用能力抽成独立服务OpenAI 兼容协议 `/v1/chat/completions`),除了 FAM-Edge 自己(改为转发调用),别的项目也能直接接入
- **新增独立仓库/项目 `ai-gateway`**http://192.168.50.64:3000/ericwyuan/ai-gateway`app.py`Flask`/v1/chat/completions` 流式+非流式、`/v1/models` 占位、`/health` 免鉴权)/ `auth.py`Bearer token 鉴权fail-closed/ `orchestrator.py``ChatOrchestrator`,按 provider 顺序降级,保留"未吐字才切换、已吐字后中途失败直接结束"语义)/ `adapters/`NVIDIA/Gemini/Ollama纯文本从 fam-edge 对应适配器裁剪 chat 相关代码而来);测试 37/37 通过
- **FAM-Edge 侧改动**commit `5caeb29``qa.py` 重写为 HTTP 转发客户端(调 ai-gateway `/v1/chat/completions`,翻译回原有 `run_qa`/`run_qa_stream` 契约,`api_gateway.py` 和 FAM-Core 调用方零改动);删除 `model_adapters/ollama_adapter.py` 及其测试;`gemini_adapter.py`/`nvidia_adapter.py` 移除 `chat()`/`chat_stream()`/问答专用超时(只保留 `analyze_video``app.py` 移除 Ollama 预热逻辑;`config.yaml` 移除 3 个问答专用 model 条目,新增 `ai_gateway` 客户端配置块;测试 125/125 通过
- **部署**Oracle 新建 `/opt/ai-gateway/`(独立 venvPython 3.8systemd `ai-gateway.service` 守护gunicorn 绑定 `0.0.0.0:5100`对外直接开放Bearer token 鉴权,用户明确选择"别的项目也能从这台机器之外访问"而不是仅本机);`AI_GATEWAY_TOKEN`/`NVIDIA_API_KEY`/`GEMINI_API_KEY*` 存在独立的 `/opt/ai-gateway/.env`(用户选择两份 `.env` 各自独立维护,而非复用 `/opt/fam-edge/.env`,代价是以后轮换 key 需要改两处)
- **验证**`/health` 通过;`curl` 直接测 `/v1/chat/completions`(带 token端到端成功provider=nvidiaFAM-Edge `/api/edge/chat/ask` 非流式 + `/api/edge/chat/ask/stream` 流式均验证通过,事件格式(`provider_trying`/`chunk`/`done`)不变,中文无乱码;队列/pending/failed 数量正常,无积压
- **已知代价**NVIDIA/Gemini key 现在两处各存一份(`/opt/fam-edge/.env` + `/opt/ai-gateway/.env`),非最初设计的"复用同一份",用户已知情并选择保留现状
## 2026-08-22 运动事件驱动架构v3
- **决策**不再分析整段视频。rclone 整段素材保留在 Oracle按 NAS 推送的 `ss_motion_events`start_time/duration**ffmpeg 分割成运动片段**,只分析片段。
- **改动**commit `a1523b4` + `8d6cfad`,已部署 Oracle systemd 重启):
- `oracle_db.py`videos 表兼容加 `motion_event_id`/`camera_id` 列;新增 `get_motion_events_in_range`窗口内已结束事件grace 容差)/`has_unfinished_motion_in_range`/`get_video_by_motion_event_id`
- `video_processor.py`:素材→分割(只分割已结束事件,`-c:v copy -c:a aac` 保留音频motion_event_id 幂等)/片段→只分析 双分支;修复 ffmpeg args 缺可执行文件 bug
- `video_queue.py`:素材分割出的片段入队
- `config.yaml`:新增 `motion_segment`clips_dir/keep_audio/min_duration/unfinished_grace_sec
- **E2E 验证(真机)**:素材 `Generic_ONVIF-001-20260822-110859-...mp4` → 分割 35 段(真实 event_id 25504-25538、duration 1-132s→ 片段只分析summary 真实:人物/动作)→ events绝对时间 ts→ NAS 同步 → `/api/ui/videos` 显示 motion_ 片段字段契约不变filename/event_start_time/camera_name/event_count/summary_json/compute_provider`/api/ui/videos/<id>` 详情 events + people 正常 → `/api/proxy/frame` 帧图 200 OK
- **关键认知**SS 事件 duration=0 仅在动作**进行中**(结束时必为正数,实测 id=25538 从 0→2s故只分割已结束事件start+duration ≤ now+grace进行中的等下一轮。
- **部署注意**Oracle fam-edge 由 **systemd `fam-edge.service` 守护**Restart=always代码部署后必须 `sudo systemctl restart fam-edge`(手动 setsid 会与守护打架导致端口冲突)。
## 2026-08-22 清数据重提取 + 人物管理重设计 + 文档同步commits 4cf4fc4/6a29e88/9664459
- **清数据重提取**用户决策整段提取旧数据全删按运动视频重新提取Oracle videos(364)/events(1687)/people(44)/model_calls/service_activity 全清 + motion_clips 清空(保留 ss_motion_events 93 条 + 素材 + 配置先备份NAS sync_* 四表全清重启两端自动重提取8/22 素材→运动片段8/15-21 历史素材无运动事件→0 段)
- **时间轴过滤**:历史素材"分割 0 段"空会话淹没时间轴 → db_layer.get_sync_videos/get_sync_stats 加内容过滤(`LEFT(filename,7)='motion_'` OR 有事件),前端契约不变;**踩坑pymysql execute 用 % 做占位符SQL 字面量 'motion_%' 的 % 报 "unsupported format character" 500 → 改 LEFT 判断**
- **人物管理重设计**`GET /api/ui/people/clips?label=`db_layer.get_sync_people_clips按 label/canonical_name 匹配 person_list_json → 关联运动片段,含 first_ts/clip_eventsPersonCard.vue 新增「运动片段」区块(缩略图/时间/摘要/事件数,点击跳 `/timeline?video=`Timeline.vue 支持 query 定位
- **文档同步**README 更新 2.1 网络要点/2.2 拓扑(Streamlit→Vue3)/3.1-3.3 模块表(poll 主路径+people clips)/4.1 表清单(motion_event_id/ss_motion_events)/5.1-5.2 API 表(people/clips+frame/avatar)/6.1 历史标注/8 部署(systemd+Vue3+motion_segment config)/12 进度docs/DEPLOY.md 重写为 v3已 push
- **验证**/api/ui/videos 全为 motion_ 片段15 条);/api/ui/people 3 身份people/clips 返回媳妇 3 个片段;前端 dist 已构建部署
| Tailscale | Oracle ↔ NAS | 100.74 ↔ 100.70 | ⚠️ 待修复 | Tailscale 运行但端口不通当前用公网IP |
| FAM-UI | NAS | 0.0.0.0:8501 | ✅ 运行中 | Streamlit 1.61.1, HTTP 200, health=ok |
## 环境状态
### Oracle Cloud (129.146.203.203) - FAM-Edge 节点
### Oracle Cloud (129.146.26.249) - FAM-Edge 节点
| 项目 | 状态 | 备注 |
|------|------|------|
| OS | Ubuntu 20.04 ARM64 | Ampere A1 2C12G |
@@ -129,6 +198,10 @@
| 49 | **串行上传 + 看门狗** — Dispatcher limit=1 避免带宽争抢 + 线程存活检测(is_alive) + 60s 看门狗自动重启 + 退避缩短至 30×(n+1)s | `881ea3f` | 2026-08-20 |
| 50 | **fix: 分块大小 5MB + chunk_size 变更防护** — Edge 端 total_chunks 变更自动清理旧分块NAS 端断点续传检测 total_chunks 不匹配时从头上传timeout (60,180) | `5915cf4` | 2026-08-20 |
| 51 | **运动监测重构为 Webhook 驱动(去轮询)** — 关闭 MotionNotifier 轮询SS 事件经「行動規則/Webhook」`POST :8000/api/ss/webhook` 推送FAM-Core 映射(%DEVICE_NAME%→camera_id、%EVENT_TIME%→epoch、合成稳定 event_id`POST /api/ss/motion` 推甲骨文;甲骨文 `ss_motion_events` 落库、video_processor 改本地运动预过滤 | `ca6d425` | 2026-08-22 |
| 52 | **fix: MotionNotifier 补回 camera_ids 属性** — 避免重新开启轮询时 `_fetch_events` 引用缺失属性崩溃 | `e52d8e9` | 2026-08-22 |
| 53 | **文档同步** — README 架构章节2.2.1 运动监测链路)+ API 表 + 模块表补充 v3 Webhook 架构PROGRESS 状态/变更记录更新 | - | 2026-08-22 |
### 待完成
| # | 任务 | 依赖 | 优先级 |
@@ -147,7 +220,7 @@
5. **MariaDB JSON 路径兼容** - MariaDB 10.11 不支持 MySQL 的 `$[*]` 通配符 JSON 路径和 `->` 操作符name_member() 改用 Python 层解析 + 逐行 UPDATE
6. **Python 3.10 venv** - NAS 系统 Python 3.8 过旧,用 Synology Python3.10 包创建 venv
7. **FAM-Edge 聊天代理** - 历史:/api/edge/chat 直连 Ollama 代理;架构重构后被 `/api/edge/chat/ask` 三模型编排端点取代(旧端点保留兼容)
8. **Oracle 公网 IP 替代 Tailscale** - Tailscale 两节点在线但端口不通防火墙edge_url 和 qa_url 改用 Oracle 公网 IP 129.146.203.203
8. **Oracle 公网 IP 替代 Tailscale** - Tailscale 两节点在线但端口不通防火墙edge_url 和 qa_url 改用 Oracle 公网 IP 129.146.26.249
9. **Ollama 模型常驻内存** - systemd 加 `OLLAMA_KEEP_ALIVE=-1`,模型加载后永不卸载,消除 55s 冷启动延迟,常驻占用 4.3GB 内存(系统 12GB 够用)
10. **关键帧自适应帧数** - 原固定 5-8 帧对长视频太稀疏30分钟仅8帧=每3.75分钟1帧改为随视频时长自适应候选帧 `clamp(duration_min×2, 30, 120)`,关键帧上限 `clamp(duration/150s, 8, 30)`。30分钟→12帧60分钟→24帧封顶30帧
11. **云端直出直存(架构重构)** - 本地 Ollama 完全移出视频链路:云端 VLMGemini 多图单请求 / NVIDIA 逐帧聚合)直接产出结构化 JSONEdge 仅 `format_cloud_result` 格式化/校验(无模型调用)后直存 NAS DB。根因CPU 版 Ollama 对无上限融合 prompt 预填充极慢导致 300s 超时
@@ -333,7 +406,7 @@ AI 分析瓶颈: 帧5耗时 229s (疑似 ARM CPU 热降频), 其余帧 50-65s
/api/edge/chat/ask run_qa 三模型降级编排
```
- FAM-Core Chat-Handler 调用 `qa_url` 配置的 `http://129.146.203.203:5000/api/edge/chat/ask`(不再直连 Ollama
- FAM-Core Chat-Handler 调用 `qa_url` 配置的 `http://129.146.26.249:5000/api/edge/chat/ask`(不再直连 Ollama
- FAM-Edge `run_qa` 按 Gemini → NVIDIA → 本地 Ollama 顺序调用 `chat()`,首个成功即返回 `{answer, provider}`
- **验证结果**
- Edge 单测:`/api/edge/chat/ask` → 200`provider=nvidia`Gemini 30s 超时后 NVIDIA 兜底成功,耗时 51s
@@ -433,3 +506,145 @@ AI 分析瓶颈: 帧5耗时 229s (疑似 ARM CPU 热降频), 其余帧 50-65s
- 修复:导航改为页面顶部 `st.segmented_control` 横向分段控制器,任何屏宽直接可见;侧边栏保留品牌标题和任务队列状态。
浏览器验证:顶部分段导航 5 页可达、成员命名页显示 张三/汤圆/爸、AI 对话页表单正常、无报错。
## 摄像头对外入口收口为单一域名 smart-camera.zichuan.xyz (2026-09-01)
**决策**摄像头系统fam-ui 前端 + NAS fam-core :8000 后端)对外只暴露一个域名 `smart-camera.zichuan.xyz`(前端 `/` + 后端 `/api`),下线独立的 `api.zichuan.xyz`,并从 `oracle.zichuan.xyz` 移除摄像头相关路由(其 `/ai``/fam``/wordpress` 仍保留给其它服务)。
**改动(基础设施,不在本仓库)**
- Oracle Caddy`/etc/caddy/Caddyfile`):新增 `smart-camera.zichuan.xyz` 块(`/` 静态 fam-ui + `/api``/login` 反代 `127.0.0.1:8000`,经 frp 隧道回源 NAS删除 `api.zichuan.xyz` 块;`oracle.zichuan.xyz``:80` 兜底块移除摄像头 root + `/api` 路由(改 404
- DNS新增 `smart-camera.zichuan.xyz → 129.146.26.249`RecordId 2387553886
- NAS `/volume1/web/sentinel-home-ai/.env``AUTH_HUB_ISSUER` 改为 `https://auth.zichuan.xyz``AUTH_HUB_REDIRECT_URI` 改为 `https://smart-camera.zichuan.xyz/api/auth/callback`,且 `AUTH_HUB_*` 四行补全 `export` 前缀(见下)。
- auth-hub FAM-Core 客户端 `a2PJTZuKQYxt1oGh` 回调改为 `https://smart-camera.zichuan.xyz/api/auth/callback`(保留旧 IP 作兜底)。
**顺带修复两个生产 Bug**(见 `scripts/start_core.sh`
1. 脚本先 `cd "$APP_DIR"` 再算相对 `SCRIPT_DIR`,导致 `source .env` 路径错位、`set -e` 下直接退出gunicorn 起不来。
2. `.env``AUTH_HUB_*` 是裸赋值(无 `export``source` 后不进环境gunicorn 子进程读不到 → 登录一直 503 fail-closed与之前“生产未部署”一致。脚本加 `set -a` 包裹 source 修复。
**验证**`smart-camera.zichuan.xyz/` 200、`/login` 302→auth.zichuan.xyzredirect_uri=smart-camera`/api/*` 401`oracle.zichuan.xyz/` 404、`/fam` 200`api.zichuan.xyz` 已不通。fam-core 经修复脚本重启后 health ok。
## SPA 登录打通:未登录自动跳转 OIDC2026-09-01
**问题**:域名收口后访问 `https://smart-camera.zichuan.xyz/timeline` 显示「⚠ 未登录 / 登陆不了啊」。后端 `/login`302→auth-hub与 Caddy `/login` 反代此前已验证可用,根因在**前端零登录逻辑**`fam-ui``/api/*` 拿到 401 后只在页面上渲染字面错误 `data.error`"未登录"),从不发起 OIDC 跳转,也没有任何登录入口。
**改动(本仓库 `fam-ui/`**
- `src/api.js` `request()`:捕获 401 时 `window.location.href = '/login'` 触发统一登录(后端经 auth-hub 走 Authorization Code + PKCE用模块级 `_redirectingToLogin` 开关保证单次会话只跳一次,并 `return new Promise(()=>{})` 阻止调用方继续渲染错误态;`/api/auth/check` 等白名单接口不会 401不受影响。
- `src/api.js` `api` 对象新增 `authCheck: () => request('/api/auth/check')`
- `src/App.vue``onMounted``api.authCheck()` 维护 `authed` 状态;左侧栏 + 移动端顶栏新增「🔑 登录」入口(`<a href="/login">`)与「👋 退出登录」按钮(`POST /api/logout` 后回 `/` 由后端 401 自动跳登录)。
**部署**`npm run build``dist/``tar | ssh ubuntu@129.146.26.249` 覆盖 `/var/www/fam-ui`macOS `._*` 元数据已清)。
**端到端验证**
- `smart-camera.zichuan.xyz/timeline` → 200SPA
- `/login` → 302 → `auth.zichuan.xyz/authorize?...&redirect_uri=https://smart-camera.zichuan.xyz/api/auth/callback`
- `/api/auth/check`(无 cookie`{"authed":false}`(白名单,不 401
- `/api/ui/videos`(无 cookie→ 401 → 前端据此自动跳 `/login`
登录链路已通:未登录访问任意页面 → 首个 401 → 自动跳 auth-hub 登录 → 回调种 `fam_session` cookie → 回 `/timeline` 正常加载。
## 统一登录从 NAS 迁到甲骨文 fam-edge2026-09-12
**触发**`smart-camera.zichuan.xyz/login` 报 502。排查链路静态页 `/``/timeline` 200Caddy 读本机磁盘),
只有 `/login``/api/*` 502甲骨文本机 `curl 127.0.0.1:8000/health` 0.6 秒空响应curl exit 52
Caddy 日志 `msg:"EOF"`frps 正常、同隧道的 gitea 也正常 → NAS 上 fam-core 进程没了。
局域网直扫 NAS22/2222/3000/5000/5001/3306 全 OPEN**只有 8000 closed**NAS 本身没事。
根因是登录入口挂在 NAS 上,而 NAS 上的 fam-core 没有任何守护DSM 无 systemd挂了不会自启。
**决策**:登录是入口,不该依赖家里的机器。整体迁到甲骨文的 fam-edge前端静态文件、auth-hub 本来就在这台),
NAS fam-core 退化成纯数据接口。
**改动**
- 新增 `fam-edge/src/fam_edge/auth.py``/login``/api/auth/callback``/api/logout`
`/api/auth/check``/api/auth/verify`(给 Caddy forward_auth。跟旧实现三处关键差异
1. 换 token / 拉 JWKS 走 `AUTH_HUB_INTERNAL_BASE`(本机 :5300不再跨公网 TLS——旧链路上
`PyJWKClient` 用 urllib + 系统 CA群晖易 CERTIFICATE_VERIFY_FAILED、两机时钟偏差会让 `iat`
显得来自未来,这两个坑一起消失;但 `iss` 校验和浏览器跳转仍用公网 issuer
2. 会话改无状态 HS256 签名 cookie服务重启不掉线旧实现进程内 token 表)
3. **回调失败渲染错误页,不再 302 回 `/login`**——旧实现失败即跳 `/login`,而 auth-hub 只要还有
会话就立刻再签一个 code 跳回来,两边对跳成死循环,浏览器只报「重定向次数过多」,
既看不到登录页也看不到原因(这正是 9/1 那次「跳不到登录页」的成因)
- 删除 `fam-core/src/fam_core/auth.py` + `tests/test_auth.py``app.py` 去掉 `init_auth`
fam-core 不再有任何鉴权,改由甲骨文 Caddy `forward_auth` 前置拦截
- `fam-edge/tests/test_auth.py` 16 个用例(含「失败分支绝不 302」的回归测试、
「服务端走内网但 iss 按公网校验」、「cookie 无状态」fam-edge 157 / fam-core 15 全绿
- 写测试时逮到自己写的一个 bug会话 cookie 也套了 60 秒 leeway本进程自签自验根本不需要
会让每个会话白白多活 60 秒,已改成只有 id_token 用 leeway
**遗留风险(已记入 README/DEPLOY**frps 在甲骨文绑的是 `*:8000` 且 iptables 明确放行,
fam-core 去掉鉴权后,绕过 Caddy 直连 `129.146.26.249:8000` 就是无门禁的全量数据接口,
必须靠 `iptables -I INPUT 1 -p tcp --dport 8000 ! -i lo -j DROP` 兜底。
另:登录进程并到 fam-edge 后fam-edge 正在跑视频分析时登录响应可能变慢(单 worker 4 线程)。
## 全量迁云NAS 只剩推送进程2026-09-13
**触发**:前一天刚把登录迁到甲骨文,隔天 NAS 上的 fam-core 又挂了导致数据接口 502。
用户一句话点破「NAS 上只有一个无状态的通知甲骨文的服务,剩下的全部在甲骨文呀」。
盘点后确认这个判断成立——甲骨文的 SQLite 才是权威数据源videos 3113 / events 16159 /
people 60 / model_calls 9876NAS 的 MariaDB 全是它的镜像,前端读的数据本来就产自
甲骨文,绕了一圈回家又绕回来。
**改动**
- `fam-core/db_layer.py` 从 725 行重写成 377 行MySQL 镜像查询 → 直读 fam-edge 的
SQLite。5 个 `upsert_sync_*`(约 300 行去重逻辑8/29 和 9/3 两次 1062 事故的发源地)
连同 `oracle_sync.py` 整个删除。SQL 方言:`JSON_CONTAINS``json_each`(前置
`json_valid`,历史脏数据不会把查询搞崩)、`LEFT()``substr()``%s``?`
函数名 `get_sync_*` 一并改掉——已经没有 sync 这回事了,留着名字会误导。
- 新增 `fam-core/edge_client.py`:写操作(改名/删除)、帧图头像、服务状态都打给同机
fam-edge全走 127.0.0.1,不出公网。
- 新增 `fam-notifier/`:把 `motion_notifier.py` 从 fam-core 拆出来,游标从 MariaDB
换成本地 JSON 文件。NAS 上从此没有 Flask、没有数据库、没有监听端口只有一个
单向推送进程。
- fam-core 移到甲骨文 `/opt/fam-core`systemdgunicorn -w 2**只绑 127.0.0.1:5401**——
5400 被 chat-relay 占了。Caddy 的 `/api/*` 从"frp 隧道回源 NAS"改成同机反代,
forward_auth 闸门不变。
- 前端跟着删:侧边栏同步面板、统计页同步状态、服务状态页的"NAS 同步"卡片和
"立即同步"按钮(背后的镜像层已不存在)。"NAS 同步"换成"NAS 运动推送",读
fam-edge activity 新增的 `motion` 段(心跳年龄 + 最近事件)。
- 顺带修掉一个隐蔽 bug镜像表为保外键稳定用的是 NAS 本地自增 id而帧图接口要的是
甲骨文的 id两边在 9/3 那次 id 重排后就对不上了。现在只有一套 id。
**测试**fam-core 21新增 12 个 db_layer 用例:脏 JSON 不崩、人物精确匹配不误伤
"人物B"、日期过滤、统计口径、chat_history 懒建表、fam-notifier 6、fam-edge 157全绿。
**验证**:甲骨文 `/api/ui/stats` 返回 videos 2965 / events 16159 / attention 13 /
people 59外网 `/` `/timeline` 200、`/login` 302、`/api/*` 未登录 401——**全程 NAS
上的 fam-core 是停着的**,这就是迁云的验收标准。
**待办**NAS 侧部署 fam-notifier只能用户手动密码登录chat_history 一次性迁移
`fam-core/scripts/import_chat_history.py`幂等frpc.toml 里的 8000 映射可删。
## 修复 fam-edge 多线程共用 SQLite 连接2026-09-13
**触发**:用户问「甲骨文剩余硬盘小于 10G 会删东西的逻辑怎么没了?」。查下来逻辑一直在、
也没被迁云动过(`disk_guard.py` 最后一次改动还是 8/28 新增它那次),但它 95% 的检查在空转:
```
DiskGuard「本轮清理完成」 128 次
DiskGuard「检查异常」 2837 次 ← 22 倍
```
失败原因全是 `cannot start a transaction within a transaction`。表现就是磁盘剩余空间在
2.7GB 和 16GB 之间来回荡——清理能不能成功全靠运气,赶上 rclone 集中下载
(实测 5 分钟写入 12.6GB)就掉进危险区。
**根因**`OracleDB.__init__` 建一条 `sqlite3.connect(check_same_thread=False)` 的连接
给全进程共用,而 VideoQueue / PersonService / DiskGuard 三个后台线程 + gunicorn 的 4 个
请求线程都在并发读写它。sqlite3 的连接对象本来就不是可并发共享的,事务状态互相踩踏。
同一个根因在线上刷出三类错误,累计:`database is locked` 71604 次、
`cannot start a transaction within a transaction` 378 次、`no more rows available` 92 次
(后者堆栈落在 `self._conn.commit()`,是游标被别的线程重置的典型症状)。
运动事件推送被 500 打回也是它——NAS 侧失败批次不推进游标,下一轮补推,所以没丢事件。
**修复**`_conn` 改成 `@property`,从 `threading.local()` 取当前线程的连接,没有就新建
WAL + busy_timeout=10000`close()` 相应改成收掉所有线程开过的连接。因为外部调用方
(如 `api_gateway` 的 activity 端点)也在直接用 `db._conn.execute(...)`,做成 property
可以让全部现有调用点原样工作,不用逐个改。`_write_lock` 保留,复合写语义不变。
**测试**:新增 2 个用例8 线程 × 25 轮并发读写、close 要收掉所有连接)。在旧代码上
稳定复现同族错误 `cannot commit transaction - SQL statements in progress`,修复后通过;
fam-edge 全套 159 个测试绿。
**同批修掉的第二个竞态**:生产者列目录之后、读 mtime 之前DiskGuard 可能刚好把那个
文件清掉(两个后台线程的正常竞态),`os.path.getmtime` 抛 FileNotFoundError代价是
**整轮扫描中断**——排在后面的新素材本轮全都登记不上。改成捕获 OSError 跳过该文件,
新增 `fam-edge/tests/test_video_queue.py` 两个用例覆盖(被删的跳过 / 仍在写入的照样跳过)。

494
README.md
View File

@@ -3,7 +3,7 @@
> 多模型容灾降级 + 交互式命名 + AI 对话的家庭监控系统。
> 本文档为项目需求文档与 README 的整合版按当前代码实际状态v1.0 E2E 已打通)编写。
> 最后更新2026-08-20云端多模型方案整合
> 最后更新2026-08-25Oracle 迁至新服务器 129.146.26.249;前端由 NAS 迁至云服务器 Caddy :80
---
@@ -13,14 +13,14 @@
### 1.1 首期范围(已基本完成)
> **新架构 v22026-08-21 重构)**NAS 不再处理视频仅作管理后台视频分析全部上云Oracle
> **新架构 v32026-08-22 重构:运动事件驱动**NAS 不再处理视频,仅作管理后台 + 运动事件推送视频分析全部上云Oracle,且**不再分析整段视频**——整段素材按 SS 运动事件**分割成运动片段**后只分析片段
- **FAM-Core**NAS 端单进程):**Oracle-Sync**(每 30 分钟拉增量镜像)+ **Chat-Handler** + **Member-Manager** 三个子模块CPU 占用极低
- **FAM-Edge**Oracle 端单进程rclone 实时同步 Google 硬盘视频 → 监听目录 → **整视频直传云端 VLM**Gemini 用 Files API / NVIDIA 整视频 `video_url`,不切片不抽帧)→ 结构化 JSON 落本地 SQLite → 对外提供 `/api/oracle/sync` 增量拉取接口(**本地模型不参与视频分析**
- **FAM-UI**NAS 端Streamlit 读本地同步镜像sync_videos / sync_events / sync_people事件时间轴 + 人物管理 + AI 对话 + 对话历史 + 统计
- **数据库**Oracle 侧 SQLitevideos/events/people/sync_cursorNAS 侧 MariaDB 镜像sync_videos / sync_events / sync_people / sync_cursor+ chat_history
- **数据流向**Google 硬盘 ──rclone──► 甲骨文本地 ──整视频分析──► Oracle SQLite ──每 30 分钟 NAS 拉取──► NAS MariaDB 镜像 ──► FAM-UI
- **AI 对话**:查 sync_events 拼上下文 → FAM-Edge 问答编排Gemini → NVIDIA → 本地 Ollama 兜底)生成回答 → 返回并写 chat_history
- **FAM-Core**NAS 端单进程):**Oracle-Sync**(每 30 分钟拉增量镜像)+ **MotionNotifier**(轮询 SS 运动事件推送 Oracle+ **Chat-Handler** + **Member-Manager**CPU 占用极低
- **FAM-Edge**Oracle 端单进程rclone 实时同步 Google 硬盘视频**整段素材**)→ 按 `ss_motion_events` 运动事件start_time/duration**ffmpeg 分割运动片段** → **只把运动片段送云端 VLM**Gemini 用 Files API / NVIDIA 整视频 `video_url`)→ 结构化 JSON 落本地 SQLite → 对外提供 `/api/oracle/sync` 增量拉取接口(**本地模型不参与视频分析**
- **FAM-UI**云服务器端,由 Caddy :80 托管Vue3 SPA 读本地同步镜像sync_videos / sync_events / sync_people事件时间轴 + 人物管理 + AI 对话 + 对话历史 + 统计;浏览器经云服务器访问,/api 经 frp 隧道反代回 NAS FAM-Core
- **数据库**Oracle 侧 SQLitevideos/events/people/ss_motion_events/sync_cursorNAS 侧 MariaDB 镜像sync_videos / sync_events / sync_people / sync_cursor+ chat_history
- **数据流向**Google 硬盘 ──rclone──► 甲骨文整段素材 ──按运动事件分割片段──► 片段云端分析 ──► Oracle SQLite ──每 30 分钟 NAS 拉取──► NAS MariaDB 镜像 ──► FAM-UI;运动事件由 NAS 轮询 SS 推送 OracleNAS → Oracle 单向)
- **AI 对话**:查 sync_events 拼上下文 → FAM-Edge 转发到独立 **ai-gateway** 服务OpenAI 兼容协议NVIDIA → Gemini → 本地 Ollama 降级链在 ai-gateway 内部完成FAM-Edge 不再自己维护模型链)→ 返回并写 chat_history
- **人物命名**:用户命名/合并某 label → 回推 Oracle `/api/oracle/people/correct`manual 优先)→ 下一周期同步回 NASOracle 独立 person_service 汇总全量人物 → LLM 合并为规范名 → 回灌视频提示
### 1.2 不在首期范围(推迟 v1.1+
@@ -47,13 +47,13 @@
|------|--------|------|------|
| **视觉分析 + 结构化输出**(看图识人/动作/衣着 → 直出 JSON | 云端 | Gemini → NVIDIA NIMfallback 降级) | 云端 VLM 直接产出 `global_summary` / `entities_json` / `frame_details`Edge 仅做**格式化校验**后直存 NAS**本地模型不介入** |
| **结果格式化**(云端 JSON → 入库 schema | Edge 进程 | 无模型调用 | `format_cloud_result`:字段归一化、补 `source_providers`/`compute_provider`、推导 `entities`、缺失 `global_summary` 时事实拼接;纯数据转换,非 LLM 二次汇总 |
| **AI 对话**"汤圆今天干嘛了" | 云端优先 + 本地兜底 | Gemini → NVIDIA NIM → 本地 Ollama | 两云端任一成功即用;**仅当 Gemini 与 NVIDIA 都失败**才回退本地 Ollama qwen2.5:7b |
| **AI 对话**"汤圆今天干嘛了" | 独立 **ai-gateway** 服务OpenAI 兼容协议) | NVIDIA → Gemini → 本地 Ollama | FAM-Edge 不参与问答模型调用只转发降级顺序、key 轮换、熔断全部由 ai-gateway 自己管理 |
- **Google Gemini**`gemini-flash-latest`API Key 已验证,支持多图单请求,视觉 + 问答均参与
- **NVIDIA NIM**`meta/llama-3.2-11b-vision-instruct`OpenAI 兼容 API云端 GPU 推理,单请求限 1 图故逐帧调用;视觉 + 问答均参与
- **本地 Ollama**qwen2.5:7b**仅参与智能问答,且仅作兜底**:视觉链路两云端全失败 → 任务 FAILED 走重试,**绝不回退本地模型做视觉/融合**
- **Google Gemini**`gemini-flash-latest`API Key 已验证,支持多图单请求**问答场景改用 ai-gateway 里独立的非 flash 文字模型链**,不复用视觉分析这个 flash 实例
- **NVIDIA NIM**视觉分析用 `nvidia/nemotron-3-nano-omni-30b-a3b-reasoning` 整视频输入;**问答场景改用 ai-gateway 里独立的文字模型链**,不复用视觉分析实例
- **本地 Ollama**qwen2.5:7b**已从 FAM-Edge 移除2026-08-23 起归属独立的 ai-gateway 服务**:仅参与智能问答,且仅在 ai-gateway 内部 NVIDIA/Gemini 都失败时作兜底;视觉分析链路完全不涉及本地模型
Orchestrator 视觉阶段按 `fallback` 模式顺序降级Gemini → NVIDIA NIM云端模型直出结构化 JSON 后由 `format_cloud_result` 格式化。问答阶段按 `gemini → nvidia → ollama` 顺序,仅末位本地模型作兜底
Orchestrator 视觉阶段按 `fallback` 模式顺序降级Gemini → NVIDIA NIM云端模型直出结构化 JSON 后由 `format_cloud_result` 格式化,均在 FAM-Edge 内完成。问答阶段完全在 **ai-gateway**(独立服务,见 §3.4)内部按 `nvidia → gemini → ollama` 顺序降级FAM-Edge 只是转发客户端
---
@@ -63,13 +63,14 @@ Orchestrator 视觉阶段按 `fallback` 模式顺序降级Gemini → NVIDIA N
| 节点 | 角色 | 硬件 | IP | 服务与端口 |
|------|------|------|-----|-----------|
| Synology NAS | FAM-Core + FAM-UI + 数据库 | DS220+ (Geminilake), DSM 7 | 家庭局域网 192.168.50.64 / Tailscale 100.70.234.39 | FAM-Core :8000, FAM-UI :8501, MariaDB :3306, Surveillance Station |
| Oracle Cloud | FAM-Edge | Ampere A1 2C12G ARM64无 GPU, Ubuntu 20.04 | 公网 129.146.203.203 / Tailscale 100.74.137.126 | FAM-Edge :5000, Ollama :11434仅本地 |
| 家庭网络 | 用户入口 | 普通终端 | 192.168.50.0/24 | 浏览器访问 `http://192.168.50.64:8501` |
| Synology NAS | 摄像头录像 + 运动事件推送(**仅此而已**2026-09-13 起) | DS220+ (Geminilake), DSM 7 | 家庭局域网 192.168.50.64 | Surveillance Station :5000(录像机本体,搬不走), **fam-notifier**(无端口的推送进程,轮询 SS → 推甲骨文。MariaDB 与 FAM-Core 已随镜像层一起下线 |
| Oracle Cloud(云服务器) | **除录像外的全部**:视频分析 + 数据 + 接口 + 登录 + 前端 | Ampere A1 4C23G ARM64无 GPU, Ubuntu 20.04 | 公网 129.146.26.249 / Tailscale(已安装未启用,备用) | FAM-Edge :5000systemd视频分析 + 统一登录 + SQLite 权威库), **FAM-Core :5401systemdUI/对话/成员接口,仅监听 127.0.0.1**, AI-Gateway :5100systemd问答模型降级链, Caddy :80/:443前端 + 反代 + forward_auth 鉴权), Ollama :11434仅本地 |
| 家庭网络 | 用户入口 | 普通终端 | 公网 / 家庭网络 | 浏览器访问 `https://smart-camera.zichuan.xyz/`(全部由甲骨文提供,**不依赖 NAS 在线** |
**网络要点(推送模式)**
- 服务间通信只有一条**NAS → Oracle 公网 IP:5000**HTTP 上传视频。Edge 不需要反向访问 NAS(无 webhook、无视频拉取
- Oracle 端 Ollama 端口 11434 不对外暴露,聊天请求经 FAM-Edge `/api/edge/chat` 代理转发
- 服务间通信只有两条出站**NAS → Oracle 公网 IP:5000**`GET /api/oracle/sync` 拉增量 + `POST /api/oracle/people/correct` 命名回推 ② `POST /api/ss/motion` 推送运动侦测事件。Edge 不需要反向访问 NAS
- **运动事件获取NAS 轮询驱动)**FAM-Core MotionNotifier 每 60s 轮询本机 Surveillance Station 事件列表 API`SYNO.SurveillanceStation.EventCenter.Event`),增量推送 Oracle。**2026-08-25 起 `/api/ss/webhook` 接收端点已彻底移除**`motion_bp.py` 不再注册该路由),轮询是唯一路径,不再保留 Webhook 可选补充。
- Oracle 端 Ollama 端口 11434 不对外暴露;聊天请求 NAS → FAM-Edge `/api/edge/chat/ask`(保持不变的对外契约)→ **FAM-Edge 转发到同机的 AI-Gateway :5100**`/v1/chat/completions`OpenAI 兼容协议Bearer token 鉴权)→ NVIDIA/Gemini/Ollama 降级链。AI-Gateway 自己对外监听 0.0.0.0:5100Bearer token 鉴权 fail-closed供其他项目直接接入不止服务本系统
- Tailscale 两节点已安装在线,但 NAS tailscaled 为 userspace 模式且防火墙端口不通,暂走公网 IP
### 2.2 部署拓扑与数据流(新架构 v2Oracle 分析 + NAS 镜像)
@@ -81,47 +82,87 @@ Orchestrator 视觉阶段按 `fallback` 模式顺序降级Gemini → NVIDIA N
└──────────────┬───────────────────────┘
│ rclone 定时同步systemd timer
┌──────────────────────── Oracle Cloud (129.146.203.203) ────────────────────────┐
┌──────────────────────── Oracle Cloud / 云服务器 (129.146.26.249) ────────────────────────┐
│ FAM-Edge (Flask :5000) │
│ ├─ Watch-Processor: 30s 轮询 /opt/fam-edge/gdrive_videos,新视频串行处理
│ ├─ Video-Processor: 整视频直传云端 VLM不切片不抽帧
│ │ Gemini(Files API) → 失败 NVIDIA(整视频 video_url) → 再失败 FAILED
│ ├─ Video-Queue: 30s 轮询 /opt/fam-edge/gdrive_videos 登记整段素材
│ ├─ Video-Processor: 素材按 ss_motion_events 分割运动片段 → 只分析片段
│ │ (ffmpeg -c:v copy -c:a aac) → Gemini → 失败 NVIDIA → 再失败 FAILED │
│ ├─ Person-Service: 汇总人物 → LLM 合并规范名 → 回灌视频提示 │
│ ├─ OracleDB (SQLite): videos / events / people / sync_cursor
API: /api/oracle/sync (增量拉取) · /api/oracle/people/correct (命名校正) ·
/api/edge/chat/ask (问答编排 Gemini→NVIDIA→Ollama)
└───────────────────────────────┬───────────────────────────────────────────────┘
│ HTTP GET /api/oracle/sync?since=&token= (每 30 分钟)
│ ├─ QA-Proxy (qa.py): /api/edge/chat/ask(/stream) 原样转发到 AI-Gateway
OracleDB (SQLite): videos / events / people / ss_motion_events
└─ API: /api/oracle/sync (增量拉取) · /api/ss/motion (运动事件) ·
│ /api/oracle/people/correct (命名校正) · /api/edge/chat/ask (问答代理) │
│ HTTP (本机回环 + 公网均可达) │
│ ▼
│ AI-Gateway (Flask :5100独立项目/服务/git 仓库OpenAI 兼容协议) │
│ ├─ /v1/chat/completionsNVIDIA → Gemini多 Key 轮换)→ 本地 Ollama 降级链 │
│ ├─ Bearer token 鉴权fail-closed对外 0.0.0.0:5100非本系统专属 │
│ └─ 独立 .env/opt/ai-gateway/.envNVIDIA/Gemini key 与 FAM-Edge 各自一份) │
│ FAM-Core (Flask :5401仅 127.0.0.1) │
│ ├─ UI-API: /api/ui/*(直读下面那个 SQLite无镜像、无后台线程
│ ├─ Chat-Handler: /api/chat/ask查 events 拼上下文 → 转 fam-edge 编排) │
│ ├─ Member-Manager: /api/member/name|merge转 fam-edge它负责合并人物
│ └─ chat_history: 本服务唯一写的表,落在同一个 SQLite 里 │
│ SQLite /opt/fam-edge/data/oracle.dbWALvideos / events / people / │
│ model_calls / person_identity_map / ss_motion_events / chat_history │
│ Caddy :80/:443前端静态 + /login 与 /api/auth/* → fam-edge │
│ + /api/* 先 forward_auth 再反代 fam-core唯一鉴权闸门
└───────────────────────────────▲───────────────────────────────────────────────┘
│ POST /api/ss/motion单向推送 + 5 分钟心跳)
┌─────────────────────────────── NAS (192.168.50.64) ────────────────────────────┐
FAM-Core (Flask :8000)
├─ Oracle-Sync: 唯一后台线程,拉增量写 MariaDB 镜像 + 维护 sync_cursor
├─ Chat-Handler: /api/chat/ask查 sync_events 拼上下文 → 走 Oracle 编排)
│ ├─ Member-Manager: /api/member/name|merge回推 Oracle + 即时拉回) │
│ ▼ │
│ MariaDB (sentinel_home_ai): sync_videos / sync_events / sync_people / │
│ sync_cursor / chat_history │
│ FAM-UI (Streamlit :8501) 读同步镜像 │
Surveillance Station :5000(录像机本体,摄像头插在这台上)
fam-notifier轮询 SS EventCenter → 推运动事件给甲骨文。无端口、无数据库,
游标存本地 JSON 文件。挂了重启即可,不影响网站。
└─────────────────────────────────────────────────────────────────────────────────┘
```
#### 2.2.1 运动监测链路v3NAS 轮询驱动)
摄像头动作事件由 **NAS 端 MotionNotifier 轮询 Surveillance Station 事件列表**获取简单稳定事件不遗漏2026-08-25 起 Webhook 接收端点已彻底移除,轮询是唯一路径):
Surveillance Station (NAS 本机)
│ SYNO.SurveillanceStation.EventCenter.Event method=List
│ camera_ids=2, event_types=10, start_time/end_time下划线风格
NAS FAM-Core MotionNotifiermotion_notifier.py每 60s
│ ├─ 增量游标MariaDB sync_cursor.motion_last_event_idDB 续用/重启补推)
│ ├─ 推送失败批次不前进游标(下轮重试,不丢事件)
│ └─ 定期空 events 心跳(证明推送链路存活)
│ HTTP POST /api/ss/motion?token=ORACLE_SYNC_TOKEN真实 event_id/start_time/duration
Oracle FAM-Edge :5000 /api/ss/motion (api_gateway.py)
│ → OracleDB.ss_motion_eventsevent_id UNIQUE自动去重start_time/duration 为 Unix epoch
Oracle video_processor整段素材按运动事件【分割运动片段】→ 只分析片段(见 2.3
> 设计要点:甲骨文**不反向访问** NAS只分割**已结束**事件SS 事件 duration 在动作进行中为 0、结束才回填真实时长
> 失败-open心跳超过 `max_heartbeat_age_sec`900s未更新时预过滤/分割 fail-open不误判"无运动"。
**网络要点(新架构)**
- NAS → Oracle 仅一条出站 HTTPS/HTTP`GET /api/oracle/sync`(拉取)与 `POST /api/oracle/people/correct`(命名回推)均走 Oracle 公网 IP:5000token 鉴权
- Oracle Ollama :11434 不对外暴露,问答经 FAM-Edge `/api/edge/chat/ask` 代理
- NAS → Oracle 出站共两条:① `GET /api/oracle/sync`(拉取增量)+ `POST /api/oracle/people/correct`(命名回推);② `POST /api/ss/motion`(运动侦测事件推送)。均走 Oracle 公网 IP:5000token 鉴权
- Oracle Ollama :11434 不对外暴露,问答经 FAM-Edge `/api/edge/chat/ask` → 同机 AI-Gateway `:5100` 两跳代理
- AI-Gateway `:5100` 本身对公网直接开放Bearer token 鉴权),跟 FAM-Edge `:5000` 是两个独立监听端口,非本系统的其他项目可以跳过 FAM-Edge 直接接入
- Tailscale 两节点在线但 NAS 无法反向访问 Oracle故全部走 NAS 主动出站拉取模式
### 2.3 主链路时序(新架构 v2
### 2.3 主链路时序(新架构 v3运动事件驱动
1. Google 硬盘新视频 → rclone 定时同步到 Oracle `/opt/fam-edge/gdrive_videos`
2. Watch-Processor 轮询发现新文件 → 登记到 Oracle `videos`pending
3. Video-Processor 串行处理:整视频上传 Gemini Files API或 NVIDIA 整视频 `video_url`)→ 模型直出 `{global_summary, events[], people_mentioned[]}` → 写 Oracle `videos` + `events` + `people`
4. Person-Service 每 30 分钟汇总全量人物 → LLM 合并为规范名 → 更新 `people.canonical_name` → 生成 `known_members_context` 回灌后续视频提示
5. NAS Oracle-Sync 每 30 分钟 `GET /api/oracle/sync?since=<cursor>` → upsert 到本地 `sync_*` 镜像表 → 推进 `sync_cursor`
6. FAM-UI 读本地镜像展示;用户命名 → `POST /api/oracle/people/correct` 回推 Oracle下一周期同步生效
1. Google 硬盘新视频(整段素材)→ rclone 定时同步到 Oracle `/opt/fam-edge/gdrive_videos`
2. Video-Queue 轮询发现新文件 → 登记到 Oracle `videos`pending
3. Video-Processor 处理素材:按文件名解析开始时间 → 查窗口内 `ss_motion_events` **已结束**运动事件 → ffmpeg 分割运动片段(`-c:v copy -c:a aac` 保留音频)→ 片段登记 `videos`pending`motion_event_id` 关联)并入队;素材标记"已分割 N 段"(仍有未结束事件则保持 pending 下轮再分割)
4. Video-Processor 处理运动片段:**只分析片段**(不分析整段)→ Gemini或 NVIDIA 兜底)直出 `{global_summary, events[], people_mentioned[]}` → 写 Oracle `videos` + `events` + `people`
5. Person-Service 每 30 分钟汇总全量人物 → LLM 合并为规范名 → 更新 `people.canonical_name` → 生成 `known_members_context` 回灌后续片段提示
6. NAS Oracle-Sync 每 30 分钟 `GET /api/oracle/sync?since=<cursor>` → upsert 到本地 `sync_*` 镜像表 → 推进 `sync_cursor`
7. FAM-UI 读本地镜像展示(时间轴/人物/统计字段契约不变);用户命名 → `POST /api/oracle/people/correct` 回推 Oracle下一周期同步生效
**运动监测支流NAS 轮询驱动)**
- MotionNotifier 每 60s 轮询 SS `EventCenter.Event.List`camera_ids=2, event_types=10→ 增量(游标)推送 `POST /api/ss/motion` 落库 `ss_motion_events`(真实 event_id/start_time/duration
- 整段素材处理时按 `ss_motion_events` 分割运动片段只分割已结束事件duration=0 的进行中事件等结束后的下轮);片段分析结果的时间点(`events.ts`)为绝对时间,前端时间轴与帧图(`/api/proxy/frame`,绝对 ts event_start_time 偏移取帧)天然兼容
**容错设计**
- Oracle 单视频串行(`max_concurrent=1`)避免多视频抢占云端配额
- 视频分析失败(两云端均不可用)标记 `failed`,下一周期 cursor 仍包含它会被重试
- 运动片段分析失败(两云端均不可用)标记 `failed`,下一周期 cursor 仍包含它会被重试
- 素材分割幂等:按 `motion_event_id` 去重,片段文件已存在则跳过分割
- NAS 同步失败仅记日志下一周期30 分钟)自动重试,不阻塞 UI
- fam-core 日志双写stdout + `fam-core/logs/fam-core.log`
@@ -131,43 +172,61 @@ Orchestrator 视觉阶段按 `fallback` 模式顺序降级Gemini → NVIDIA N
### 3.1 FAM-CoreNAS 端)
> NAS 不再处理视频,仅作管理后台。唯一常驻后台线程Oracle-Sync。
> NAS 不再处理视频,仅作管理后台。常驻后台线程Oracle-Sync(增量镜像)+ MotionNotifier轮询 SS 运动事件)
| 模块 | 文件 | 职责 |
|------|------|------|
| Oracle-Sync | `oracle_sync/oracle_sync.py` | 唯一后台线程:每 30 分钟 `GET /api/oracle/sync?since=<cursor>&token=` 拉增量 → upsert 到 `sync_videos`/`sync_events`/`sync_people` → 推进 `sync_cursor``push_name_correct()` 回推命名校正;`trigger_now()` 立即同步 |
| Chat-Handler | `chat_handler/chat_handler.py` | `/api/chat/ask``sync_events` 拼上下文 → 经 Oracle `/api/edge/chat/ask` 问答编排Gemini→NVIDIA→本地 Ollama 兜底)→ 写 chat_history |
| Oracle-Sync | `oracle_sync/oracle_sync.py` | 后台线程:每 30 分钟 `GET /api/oracle/sync?since=<cursor>&token=` 拉增量 → upsert 到 `sync_videos`/`sync_events`/`sync_people` → 推进 `sync_cursor``push_name_correct()` 回推命名校正;`trigger_now()` 立即同步 |
| Chat-Handler | `chat_handler/chat_handler.py` | `/api/chat/ask``sync_events` 拼上下文 → 经 Oracle `/api/edge/chat/ask` 问答FAM-Edge 转发到独立 AI-Gateway 服务,接口契约不变)→ 写 chat_history |
| Member-Manager | `member_manager/member_manager.py` | `/api/member/unnamed` / `/api/member/list` / `/api/member/name` / `/api/member/merge`;命名/合并回推 Oracle 并即时拉回本地镜像 |
| 公共层 | `db_layer.py` / `config_loader.py` / `logger.py` | PyMySQL 连接unix_socket同步镜像 CRUD文件日志 |
| MotionNotifier | `motion_notifier/motion_notifier.py` | **轮询主路径**`poll_enabled=true`):每 `poll_interval_sec`(60s) 查 SS `EventCenter.Event.List`camera_ids/event_types=10→ 增量推送 Oracle `/api/ss/motion`;游标存 MariaDB重启续用/补推停机期间事件,失败批次不前进);心跳线程定期空 POST 证明链路存活;启动时拉取 SS 摄像头「名→id」映射并配置兜底 |
| Motion-BP | `motion_bp.py` | **2026-08-25 起不再提供 `/api/ss/webhook` 接收端点**(轮询是唯一路径);`GET /api/ss/status` 查询 MotionNotifier 状态 |
| UI-API | `ui_api.py` | `/api/ui/*` 只读接口videos/stats/people/people-clips/attention-events/named-members/model-stats/service-status供 Vue 前端渲染 |
| 公共层 | `db_layer.py` / `config_loader.py` / `logger.py` | PyMySQL 连接unix_socket同步镜像 CRUD`get_sync_people_clips()` 人物→运动片段查询;文件日志 |
> 已删除Task-Scheduler / Dispatcher / Poller / Event-Receiver / Video-Server视频上传、切片、抽帧、关键帧落盘等职责全部迁移至 Oracle 端NAS CPU 占用大幅降低)。
### 3.2 FAM-EdgeOracle 端)
> 视频分析,不切片、不抽帧、不依赖 OpenCV 人脸
> 段素材按运动事件分割运动片段,**只分析运动片段**(不再整段送云端)
| 模块 | 文件 | 职责 |
|------|------|------|
| API-Gateway | `api_gateway/api_gateway.py` | `GET /api/oracle/sync`增量拉取since+token 校验);`POST /api/oracle/people/correct`(命名校正);`POST /api/edge/chat/ask`(问答编排`GET /health` |
| Video-Queue | `video_queue.py` | 生产-消费队列:生产者 30s 轮询 rclone 同步落地目录登记新视频入队(含重启恢复);消费者(`max_concurrent` 个线程)取队列调 Video-Processor模型超时 = 原配置 ×`timeout_multiplier`;失败重试上限 `max_retries` |
| Video-Processor | `video_processor.py` | 按 `vision_order` 调适配器 `analyze_video`(整视频);首个成功即落库 Oracle `videos`+`events`+`people`;全失败标 `failed` |
| Person-Service | `person_service.py` | 汇总全量人物 → LLM 合并为规范名 → `set_canonical`;生成 `known_members_context` 回灌视频提示manual 命名优先不被覆盖 |
| OracleDB | `oracle_db.py` | SQLitevideos / events / people / sync_cursor`get_sync_delta(since)` 增量导出 |
| Model-Adapters | `model_adapters/` | `BaseModelAdapter.analyze_video(video_path, known_members_context, event_start_time)`GeminiFiles API 整视频)/ NVIDIA整视频 `video_url``num_frames=128`/ Ollama纯文本不参与视频 |
| QA-Orchestrator | `qa.py` | 遍历所有适配器 `chat()`Gemini→NVIDIA→Ollama 三级降级(仅问答) |
| API-Gateway | `api_gateway/api_gateway.py` | `GET /api/oracle/sync`增量拉取since+token 校验);`POST /api/oracle/people/correct`(命名校正);`POST /api/edge/chat/ask(/stream)`(问答,转发到 AI-Gateway见 §3.4`POST /api/ss/motion`运动事件接收token 校验,落库 `ss_motion_events` + 刷新心跳);`GET /api/oracle/frame|avatar`(帧/头像`GET /health` |
| Video-Queue | `video_queue.py` | 生产-消费队列:生产者 30s 轮询 rclone 同步落地目录登记整段素材入队(含重启恢复);消费者(`max_concurrent` 个线程)取队列调 Video-Processor素材分割出的运动片段自动入队;模型超时 = 原配置 ×`timeout_multiplier`;失败重试上限 `max_retries` |
| Video-Processor | `video_processor.py` | **素材→分割/片段→分析**双分支:素材按 `ss_motion_events` 已结束运动事件 ffmpeg 分割运动片段(`-c:v copy -c:a aac` 保留音频,`motion_event_id` 幂等),片段只送云端 VLM 分析;`vision_order` 调适配器;首个成功即落库 Oracle `videos`+`events`+`people`;全失败标 `failed` |
| Person-Service | `person_service.py` | 汇总全量人物 → LLM 合并为规范名 → `set_canonical`;生成 `known_members_context` 回灌片段提示manual 命名优先不被覆盖 |
| Disk-Guard | `disk_guard.py` | **2026-08-28 新增**5 分钟检查一次磁盘剩余空间,低于 `min_free_gb`(默认 10GB就清理最旧的、已完成分割阶段的整段素材删到 `target_free_gb`(默认 15GB水位为止只碰原始素材`gdrive_videos`),绝不碰运动片段(`motion_clips`,事件时间轴/人物头像依赖它)或还在处理中的素材 |
| OracleDB | `oracle_db.py` | SQLitevideos`motion_event_id`/`camera_id` 列)/ events / people / sync_cursor / ss_motion_events`get_sync_delta(since)` 增量导出;`record_motion_events` / `get_motion_events_in_range`(已结束运动事件窗口查询,分割用)/ `has_motion_in_range_local` / 心跳 |
| Model-Adapters | `model_adapters/` | `BaseModelAdapter.analyze_video(video_path, known_members_context, event_start_time)`GeminiFiles API/ NVIDIA整视频 `video_url`)——**只有视觉分析用2026-08-23 起不再含 Ollama/文字模型**,问答模型完全移交 AI-Gateway |
| QA-Proxy | `qa.py` | **2026-08-23 重写为 HTTP 转发客户端**(原来自己遍历适配器 `chat()` 做 NVIDIA→Gemini→Ollama 三级降级的逻辑已整个搬到 AI-Gateway调 AI-Gateway `/v1/chat/completions`,把 OpenAI 兼容响应翻译回原有 `run_qa`/`run_qa_stream` 契约,`api_gateway.py` 和 FAM-Core 调用方零改动 |
### 3.3 FAM-UINAS 端
### 3.3 FAM-UI云服务器端Caddy 托管
Streamlit 应用(`fam-ui/src/app.py`),侧边栏切换页面均读本地同步镜像
Vue3 + Vite + Tailwind SPA`fam-ui/src/views/*.vue`,构建产物 `fam-ui/dist/` 部署到甲骨文云服务器由 Caddy 静态托管Vue Router history 模式;`/api/*` 经 frp 隧道反代回 NAS FAM-Core页面均读本地同步镜像:
| 页面 | 功能 |
|------|------|
| 🕒 事件时间轴 | 视频会话列表(按处理后时间倒序+ 选中会话的事件时间线(时间点 + 描述 + 人物/关注徽章,无帧图 |
| 🕒 事件时间轴 | 运动片段会话列表(`motion_*.mp4`,只显示有内容的会话+ 选中会话的事件时间线(时间点 + 描述 + 人物/关注徽章 + 事件帧图);支持 `?video=<id>` 定位跳转;详情卡片支持**删除会话**(原生 `confirm()` 二次确认2026-08-24 新增 |
| 👤 人物管理 | 按规范名/标签聚合,命名/合并(回推 Oracle**人物卡含「运动片段」区块**(该人物出现过的运动片段:缩略图/时间/摘要/事件数,点击跳时间轴) |
| 💬 AI 对话 | 输入框 + 调 `/api/chat/ask`;按 queried_person 预设快捷提问 |
| 📝 对话历史 | chat_history 倒序展示 |
| 👤 人物管理 | 按规范名/标签聚合,命名/合并(回推 Oracle不再展示帧照片 |
| 📈 统计图表 | 模型来源占比 / 关注事件 / 同步状态 |
### 3.4 AI-GatewayOracle 端,独立项目/服务2026-08-23 新增)
> 独立的 git 仓库/部署单元(`ai-gateway/`Gitea 见 §10.4),跟 FAM-Edge/FAM-Core 不是同一个代码库。原本嵌在 FAM-Edge 里的问答模型降级链(跟视频分析业务无关,是通用能力)整个抽出来,做成 OpenAI 兼容协议的独立服务——除了 FAM-Edge 自己(改为转发调用),任何支持自定义 `base_url` 的 OpenAI SDK/工具都能直接接入,不限于本系统。
| 模块 | 文件 | 职责 |
|------|------|------|
| App | `app.py` | `POST /v1/chat/completions`核心端点OpenAI 兼容请求/响应结构,支持 `stream: true` 流式与非流式);`GET /v1/models`(占位实现);`GET /health`(免鉴权) |
| Auth | `auth.py` | Bearer token 鉴权(`Authorization: Bearer <AI_GATEWAY_TOKEN>`**fail-closed**:未配置 token 时全部需鉴权接口直接拒绝503不会退回任何默认值 |
| Orchestrator | `orchestrator.py` | `ChatOrchestrator`:按 `config.yaml``models` 数组顺序依次尝试适配器 `chat()`/`chat_stream()`,一个 provider 完全没有输出才换下一个;已开始吐字后中途失败直接结束,不悄悄换源接着写 |
| Adapters | `adapters/` | `NvidiaAdapter`(多模型链)/ `GeminiAdapter`(多 Key 随机轮换,`_messages_to_gemini()` 转换为原生 `contents`/`systemInstruction`/ `OllamaAdapter``/api/chat` 原生多轮,含 `warm_up()` 启动预热)——**纯文本,均为 chat-only不含视觉分析** |
| Config | `config_loader.py` | 部署时用 `FAM_ENV_FILE` 指向共享的 `.env` 复用密钥当前实际部署未启用这个复用AI-Gateway 用自己独立的 `/opt/ai-gateway/.env`,见 §8.3 说明) |
**降级链**`config.yaml``models` 数组顺序NVIDIA多模型链自动降级→ Gemini多 Key 随机轮换)→ 本地 Ollama兜底`OLLAMA_KEEP_ALIVE=-1` + 启动预热避免冷启动)。
---
## 4. 数据库设计
@@ -182,19 +241,20 @@ Streamlit 应用(`fam-ui/src/app.py`),侧边栏切换页面(均读本地
| 表 | 用途 | 关键字段 |
|----|------|---------|
| `videos` | 视频会话(每视频 1 行 | id, filename(UNIQUE), camera_name, status, summary_json, events_json, people_json, compute_provider, event_start_time, updated_at |
| `events` | 视频内时间点事件 | id, video_id, ts, description, person_list_json, is_attention_event |
| `people` | 规范人物Oracle 维护) | id, label(UNIQUE), canonical_name, appearances, source(llm/manual) |
| `sync_cursor` | 同步游标 | key, value上次 server_time |
| `videos` | 视频会话(整段素材 + 运动片段均在此表 | id, filename(UNIQUE), camera_name, status, summary_json, events_json, people_json, compute_provider, event_start_time, duration_sec, **motion_event_id**(运动片段关联的 SS 事件 id, **camera_id**, updated_at |
| `events` | 视频内时间点事件 | id, video_id, ts, description, person_list_json, person_appearances_json, is_attention_event |
| `people` | 规范人物Oracle 维护) | id, label(UNIQUE), canonical_name, appearances, source(llm/manual), features_json, display_uid |
| `ss_motion_events` | NAS 推送的运动事件(分割素材的依据) | id, event_id(UNIQUE), camera_id, event_type(10=运动), start_time(epoch), duration, thumbnail_url, received_at |
| `sync_cursor` | 同步游标 + 心跳 | key, valuesync_cursor=上次 server_timemotion_heartbeat_at=推送链路心跳) |
**NASMariaDB同步镜像`scripts/ddl.sql`**
| 表 | 用途 | 关键字段 |
|----|------|---------|
| `sync_videos` | 视频会话镜像(对齐 Oracle videos | id, filename, camera_name, status, summary_json, events_json, people_json, compute_provider, processed_at |
| `sync_videos` | 视频会话镜像(对齐 Oracle videos,含运动片段 | id, filename, camera_name, status, summary_json, events_json, people_json, compute_provider, processed_at |
| `sync_events` | 事件镜像(对齐 Oracle events | id, video_id, ts, description, person_list_json, is_attention_event |
| `sync_people` | 人物镜像(对齐 Oracle people | id, label, canonical_name, appearances, source |
| `sync_cursor` | 同步游标 | key='last_since', value |
| `sync_people` | 人物镜像(对齐 Oracle people | id, label, canonical_name, appearances, source, features_json, display_uid |
| `sync_cursor` | 同步游标 + 运动游标 | key='last_since'key='motion_last_event_id'MotionNotifier 增量游标) |
| `chat_history` | AI 问答记录 | chat_id, user_question, ai_answer, context_summary, queried_date, queried_person |
### 4.2 表关系
@@ -210,7 +270,7 @@ chat_history 独立表(问答上下文摘要留存)
### 4.3 compute_provider
- `sync_videos.compute_provider`:字符串,记录该视频实际成功调用的视觉模型(`gemini` / `nvidia`);本地 Ollama 不参与视频分析,不会出现在该字段
- 问答链路(Gemini→NVIDIA→Ollama 兜底)的 provider 体现在 `/api/edge/chat/ask` 响应的 `provider` 字段
- 问答链路(AI-Gateway 内部 NVIDIA→Gemini→Ollama 降级链)的 provider 体现在 `/api/edge/chat/ask` 响应的 `provider` 字段(由 AI-Gateway 原样透传回来)
### 4.4 兼容性注意
@@ -251,27 +311,56 @@ chat_history 独立表(问答上下文摘要留存)
- 401token 校验失败
**POST /api/oracle/people/correct** — 命名校正回推:`{"label":"人物A","canonical_name":"汤圆","token":...}`manual 优先,不被 LLM 覆盖)→ `{"status":"ok"}`
**POST /api/edge/chat/ask** — 智能问答编排FAM-Core Chat-Handler 调用):请求 `{"prompt","max_tokens"}` → 响应 `{"answer","provider"}`;内部按 Gemini → NVIDIA → 本地 Ollama 顺序,仅两云端都失败才用本地兜底
**POST /api/oracle/video/delete**2026-08-24 新增)— 删除视频会话NAS 转发):`{"video_id":123,"token":...}` → 删 `events`+`videos` 行 + 磁盘上的运动片段文件 → `{"status":"ok","video_id":123}`**不清理**对应的 `ss_motion_events` 源事件那是硬件推送的原始事件记录跟切出来的片段生命周期独立素材一旦处理完就不会被生产者重新捡起删片段不会触发重新分割video_id 不存在返回 404
**POST /api/edge/chat/ask(/stream)** — 智能问答FAM-Core Chat-Handler 调用):请求 `{"prompt","max_tokens"}` → 响应 `{"answer","provider"}`(流式版为 SSE事件 `provider_trying`/`chunk`/`done`/`all_failed`**2026-08-23 起 FAM-Edge 自己不跑模型,转发到同机 AI-Gateway `/v1/chat/completions`**,内部按 NVIDIA → Gemini → 本地 Ollama 顺序降级(对 FAM-Core 不可见,接口契约不变)
**POST /api/ss/motion** — 运动事件接收NAS MotionNotifier 推送):`{"token", "events":[{event_id, camera_id, event_type, start_time, duration, thumbnail_url}]}` → 幂等落库 `ss_motion_events` + 刷新心跳;`events: []` 空数组即心跳
**GET /api/oracle/frame** — 事件帧图:`?video_id=&ts=&w=` → jpeg`ts` 为绝对时间,`frame_service``ts event_start_time` 偏移从视频文件取帧)
**GET /api/oracle/avatar** — 人物头像:`?label=&w=` → jpeg从该人物候选事件 `person_appearances` bbox 裁剪)
**GET /health** — 服务状态(含已处理视频数)
### 5.2 FAM-CoreNAS :8000
**登录相关端点**2026-09-12 从 NAS FAM-Core 迁入,见 `fam-edge/src/fam_edge/auth.py`
| 端点 | 方法 | 说明 |
|------|------|------|
| `/health` | GET | 服务健康 |
| `/api/status` | GET | Oracle-Sync 同步状态running / last_sync_at / last_error / cursor / last_count |
| `/login` | GET | 登录入口:生成 PKCE 参数后 302 跳 auth-hub 公网 `/authorize` |
| `/api/auth/callback` | GET | auth-hub 回跳:用 `AUTH_HUB_INTERNAL_BASE`(本机 :5300换 token + 拉 JWKS 验签,成功后种 HttpOnly cookie `fam_session`HS256 无状态签名2 小时);**任何一步失败都渲染错误页,绝不 302 回 `/login`**——旧实现失败即跳 `/login`,而 auth-hub 有会话时会立刻再签发 code两边对跳成死循环 |
| `/api/logout` | POST | 退出登录(清 cookie不影响 auth-hub 上的 SSO 会话) |
| `/api/auth/check` | GET | 登录态检查:`{"authed": true\|false, "username": "..."}` |
| `/api/auth/verify` | ANY | 给甲骨文 Caddy 的 `forward_auth` 用:已登录 204未登录 401 JSON |
### 5.2 FAM-Core甲骨文 127.0.0.1:5401
> 2026-09-13 起 FAM-Core 与 FAM-Edge 同机,直读它的 SQLite不再有 MariaDB 镜像层),且**只监听 127.0.0.1**——唯一客户端是同机 Caddy。登录端点在 FAM-Edge见 §5.1),鉴权由 Caddy 的 `forward_auth` 前置完成,所以本服务自身不做任何校验。
| 端点 | 方法 | 说明 |
|------|------|------|
| `/health` | GET | 服务健康(**免登录** |
| `/api/status` | GET | Oracle-Sync + MotionNotifier 状态sync running/cursormotion poll_enabled/pushed_total/heartbeat |
| `/api/chat/ask` | POST | 用户问答:`{"question","queried_person","queried_date"}``{"answer","context_summary","chat_id"}`(上下文来自 sync_events |
| `/api/chat/history` | GET | 对话历史(`?date=``?person=&limit=` |
| `/api/member/unnamed` | GET | 未命名人物列表label / 出现次数 / 首见时间) |
| `/api/member/list` | GET | 全部人物label + canonical_name + 是否命名) |
| `/api/member/name` | POST | 命名:`{"label","canonical_name"}` → 回推 Oracle 并即时拉回本地镜像 |
| `/api/member/merge` | POST | 合并:`{"source","target"}` → 将 source 并入 target 身份(统一 canonical_name |
| `/api/ss/status` | GET | 运动监测状态:`poll_enabled` / `camera_loaded` / `camera_map` / `pushed_total` / `last_error` / 心跳 |
| `/api/ui/videos` | GET | 运动片段会话列表(`?date=&page=`**只返回 `motion_` 前缀或有事件的会话**0 段素材不展示) |
| `/api/ui/videos/<id>` | GET | 会话详情:`{video, events[]}`(事件含 person_list_json / person_appearances_json / is_attention_event |
| `/api/ui/videos/<id>` | DELETE | **2026-08-24 新增)**删除会话:先回推 Oracle 物理删除(含磁盘文件),成功后清本地镜像(`sync_events`+`sync_videos`增量同步感知不到删除必须显式清理Oracle 回推失败返回 502不改本地状态 |
| `/api/ui/stats` | GET | 统计卡videos / events / people / attention`?date=` 过滤,口径与列表一致) |
| `/api/ui/people` | GET | 人物列表(按 canonical_name 聚合display / labels / appearances / first_seen / features_json / display_uid |
| `/api/ui/people/clips` | GET | 人物运动片段:`?label=&limit=` → 该人物出现过的运动片段video_id / event_start_time / duration / summary / first_ts 缩略图定位 / clip_events |
| `/api/ui/attention-events` | GET | 需关注事件(日期 + 涉及人物,已去重清洗) |
| `/api/ui/named-members` | GET | 已命名成员真名列表AI 对话快捷选择) |
| `/api/ui/model-stats` | GET | 云端模型调用统计(按模型聚合 + 最近明细) |
| `/api/ui/service-status` | GET | NAS 同步状态 + Oracle 实时活动代理token 不下发浏览器) |
| `/api/proxy/frame` | GET | 事件帧图代理:`?video_id=&ts=&w=` → Oracle `/api/oracle/frame`(浏览器不直连 Oracle |
| `/api/proxy/avatar` | GET | 人物头像代理:`?label=&w=` → Oracle `/api/oracle/avatar` |
> 已删除:`/api/core/callback/event`、`/media/<path>`(视频处理职责已迁移至 Oracle
### 5.3 云端结构化输出 JSON Schema
整视频直传云端 VLM模型直接产出结构化 JSON`analyze_video` 返回):
运动片段直传云端 VLM模型直接产出结构化 JSON`analyze_video` 返回):
```json
{
@@ -284,15 +373,19 @@ chat_history 独立表(问答上下文摘要留存)
}
```
- Gemini 用 Files API 上传整视频`generateContent`NVIDIA 用整视频 `video_url` + `num_frames=128`(模型内部自行采样帧),均不切片、不抽帧、不依赖 OpenCV
- `person_service` 汇总全量 `people_mentioned` → LLM 合并为规范名 → 生成 `known_members_context` 回灌后续视频提示,使模型用真名指代
- Gemini 用 Files API 上传运动片段`generateContent`NVIDIA 用整视频 `video_url` + `num_frames=128`(模型内部自行采样帧);均不依赖 OpenCV 人脸
- `person_service` 汇总全量 `people_mentioned` → LLM 合并为规范名 → 生成 `known_members_context` 回灌后续片段提示,使模型用真名指代
- `action` / 描述由 AI 自由生成无枚举过滤,`is_attention_event` 由 AI 自行判断
---
## 6. 关键技术
### 6.1 视频预处理(自适应关键帧
### 6.1 视频预处理(历史记录关键帧抽帧方案v3 已废弃
> ⚠️ 本小节为旧架构v1/v2 本地抽帧分析)记录。**v3 运动事件驱动架构不再抽帧**
> 整段素材按运动事件 ffmpeg 分割运动片段(`-c:v copy`),片段直传云端 VLM 分析。
> 保留此处仅作历史参考。
- **粗抽候选帧**FFmpeg 快速 seek逐帧 `ffmpeg -ss <ts> -frames:v 1`),替代 fps 滤镜全解码30min 视频从 180s+ 降到 33s6-8x 提速)
- **帧数自适应**:候选帧 `clamp(时长分钟×2, 30, 120)`;关键帧上限 `clamp(时长/150s, 8, 30)`30min→12 帧60min→24 帧,封顶 30
@@ -300,19 +393,19 @@ chat_history 独立表(问答上下文摘要留存)
- **压缩**:长边 > 1024px 才缩放JPEG 质量 80
- **异常兜底**ffprobe 失败退化为 60s 间隔抽帧;帧差异常退化为等距 5 帧
### 6.2 本地 Ollama仅智能问答兜底ARM CPU
### 6.2 本地 Ollama仅智能问答兜底归属 AI-Gateway
本地 Ollamaqwen2.5:7b纯文本模型**不参与视觉分析、不参与云端结果融合**。它只在**智能问答**场景下、且 Gemini 与 NVIDIA 两云端模型都失败时才被启用作为兜底。视觉分析与结构化输出全部由云端模型承担。以下参数作为问答任务的调优依据保留。
本地 Ollamaqwen2.5:7b纯文本模型**不参与视觉分析、不参与云端结果融合**。**2026-08-23 起 Ollama 相关代码(`OllamaAdapter`、启动预热逻辑)已从 FAM-Edge 整个移除,归属独立的 AI-Gateway 服务**——它只在 AI-Gateway 内部 NVIDIA 与 Gemini 都失败时才被启用作为兜底,进程仍然跑在 Oracle 同一台机器上,只是调用方从 FAM-Edge 换成了 AI-Gateway。视觉分析与结构化输出全部由云端模型承担,跟本地模型无关。以下参数作为问答任务的调优依据保留。
| 参数 | 值 | 依据 |
|------|-----|------|
| `OLLAMA_KEEP_ALIVE=-1` | 模型常驻内存 | 消除 55s 冷启动(常驻约 4.3GB12GB 内存够用) |
| `num_predict=512` | 限制生成 token | ARM 约 5 tok/s过长生成会拖慢问答响应 |
| 视觉/模型 timeout | 600s | 实测 1024px 帧视觉编码 ~36s + 生成 ~12s/60token问答链路改用 config 中各模型 timeout |
| AI-Gateway 启动预热 | 后台线程 `warm_up()` | `OLLAMA_KEEP_ALIVE=-1` 只保证加载后不换出不负责主动预加载NVIDIA/Gemini 一直成功时 Ollama 永远不会被自然触发,加了启动时预热避免真正兜底时才发现要等 1-2 分钟冷启动 |
| gunicornEdge | `--timeout 1800` | 同步分析模式,默认 30s 会杀 worker |
| push_timeoutNAS | 1800s | 覆盖最坏情况30min 视频实测 929s |
**已知问题**:原 llava-phi3 多图单请求基本失效N 张图一次调用输出长度仅 3~4**已替换为 qwen2.5:7b**(纯文本模型,专职问答兜底,不涉及视觉多图问题;视频分析已全部由云端 VLM 承担)。
**历史问题**:原 llava-phi3 多图单请求基本失效N 张图一次调用输出长度仅 3~4。已替换为 qwen2.5:7b纯文本模型专职问答兜底不涉及视觉多图问题视频分析已全部由云端 VLM 承担)。
### 6.3 模型适配器架构
@@ -332,13 +425,14 @@ chat_history 独立表(问答上下文摘要留存)
| provider | 适配器类 | role | SDK / 协议 | 状态 |
|----------|---------|------|------------|------|
| `ollama` | `OllamaAdapter` | **text** | requests 直调 REST `/api/chat` | 已实现 |
| `gemini` | `GeminiAdapter` | **vision** | requests 直调 REST `:generateContent` | 实现 |
| `nvidia` | `NvidiaVisionAdapter` | **vision** | **openai SDK**NIM 兼容 OpenAI API 规范) | 待实现 |
| `gemini` | `GeminiAdapter` | **vision** | requests 直调 REST `:generateContent` | 已实现 |
| `nvidia` | `NvidiaVisionAdapter` | **vision** | **openai SDK**NIM 兼容 OpenAI API 规范) | 实现 |
> `OllamaAdapter` 已于 2026-08-23 从 FAM-Edge 移除(连同 `role='text'` 的问答适配器整体搬到独立的 AI-Gateway 服务,见 §3.4。FAM-Edge 现在的 `model_adapters/` 只剩 `vision` 角色,`role` 字段本身仍保留(`get_role()` 仍被 `video_processor.py` 用于筛选视觉适配器),只是不会再出现 `text` 取值。
**role 语义**
- `vision`:参与视觉分析阶段,按 fallback 顺序降级,直出结构化 JSON
- `text`仅参与智能问答(`usage: qa_fallback`),且为 Gemini/NVIDIA 都失败时兜底,不参与视觉分析、不参与云端结果融合
- `text`**已不在 FAM-Edge 出现**——原来"仅参与智能问答、Gemini/NVIDIA 都失败时兜底"的语义现在完全由独立的 AI-Gateway 服务内部实现(见 §3.4
**NvidiaVisionAdapter 关键实现**`fam_edge/adapters/nvidia_adapter.py`
@@ -377,30 +471,40 @@ format_cloud_result字段归一化 → 补 source_providers=[provider] / comp
合法入库结构,随响应返回 NAS 直接落库(本地模型不介入)
```
**智能问答降级链路chat 场景)**
**智能问答降级链路chat 场景2026-08-23 起完全在独立的 AI-Gateway 服务内部FAM-Edge 只转发**
```
Gemini (role=vision, 也参与问答)
失败 / 熔断 OPEN / 超时
FAM-Edge /api/edge/chat/ask(/stream)
HTTP 转发qa.py本机回环
NVIDIA NIM (role=vision, 也参与问答)
失败 / 熔断 OPEN / 超时
AI-Gateway /v1/chat/completions
本地 Ollama (qwen2.5:7b, role=text, usage=qa_fallback) —— 仅当两云端都失败才启用
NVIDIA问答专用文字模型链跟视觉分析的 omni 模型完全独立)
│ 完全没有输出 / 熔断 OPEN / 超时
Gemini问答专用非 flash 文字模型链,多 Key 随机轮换,跟视觉分析的 flash 模型完全独立)
│ 完全没有输出 / 熔断 OPEN / 超时
本地 Ollama (qwen2.5:7b) —— 仅当前两者都失败才启用
│ 失败
返回"所有模型均不可用"
返回"所有模型均不可用"HTTP 503
```
**熔断器策略**(按 provider 独立,仅云端模型启用):
> 已经开始吐字之后中途失败:不换下一个 provider 接着写(避免答案风格前后不连贯),直接结束这次生成——这个语义在 AI-Gateway 的 `orchestrator.py` 里实现FAM-Edge 的 `qa.py` 只是原样转发这个行为,不重复实现。
| provider | role | threshold | cooldown | enabled |
|----------|------|-----------|----------|---------|
| Gemini | vision | 3 次连续失败 | 600s | true |
| NVIDIA NIM | vision | 3 次连续失败 | 600s | true |
| Ollama | text | — | — | false本地不熔断 |
**熔断器策略**(按 provider 独立):
**健康探测**Ollama `GET /api/tags`Gemini `GET /v1/models?key=...`NVIDIA `client.models.list()`。视觉模型全部不健康返回 503。
| 服务 | provider | role | threshold | cooldown | enabled |
|------|----------|------|-----------|----------|---------|
| FAM-Edge | Gemini | vision | 3 次连续失败 | 600s | true |
| FAM-Edge | NVIDIA NIM | vision | 3 次连续失败 | 600s | true |
| AI-Gateway | NVIDIA问答 | — | 见 ai-gateway 配置 | — | true |
| AI-Gateway | Gemini问答 | — | 见 ai-gateway 配置 | — | true |
| AI-Gateway | Ollama | — | — | — | false本地不熔断 |
**健康探测**FAM-Edge 侧 Gemini `GET /v1/models?key=...`NVIDIA `client.models.list()`,视觉模型全部不健康返回 503。AI-Gateway 侧 Ollama `GET /api/tags`Gemini/NVIDIA 同上,各自独立。
**多模型标签兼容**`compute_provider``event_details.source_providers` 的 JSON 数组值新增 `"nvidia"` 标签(与 `"ollama"` / `"gemini"` 并列);`validate_schema` 校验非空数组。
@@ -447,42 +551,77 @@ task_id=28930s 测试片段)全链路打通:推送 5.7MB → Edge 分析
| 组件 | 节点 | 路径 | 启动 |
|------|------|------|------|
| FAM-Core | NAS | `/volume1/web/sentinel-home-ai/fam-core/` | `./venv/bin/gunicorn --chdir <路径> -w 1 -b 0.0.0.0:8000 --timeout 120 --daemon --pid /tmp/fam-core-gunicorn.pid src.fam_core.app:app` |
| FAM-UI | NAS | `/volume1/web/sentinel-home-ai/fam-ui/` | `./venv/bin/streamlit run src/app.py`headless, :8501 |
| FAM-Edge | Oracle | `/opt/fam-edge/` | `venv/bin/gunicorn -w 1 -b 0.0.0.0:5000 --timeout 1800 src.fam_edge.app:app`(日志 `/tmp/fam-edge.log` |
| Ollama | Oracle | systemd 托管 | 环境变量 `OLLAMA_KEEP_ALIVE=-1` |
| FAM-Core | Oracle | `/opt/fam-core/` | **systemd `fam-core.service`**gunicorn -w 2 --threads 4绑 127.0.0.1:5401部署代码后 `sudo systemctl restart fam-core` |
| fam-notifier | NAS | `/volume1/web/sentinel-home-ai/fam-notifier/` | `setsid bash scripts/start_notifier.sh &`DSM 无 systemd挂了不自启NAS 上唯一运行的本项目进程 |
| FAM-UI | 云服务器甲骨文129.146.26.249 | `/var/www/fam-ui/` | Vue3 构建产物,由 Caddy :80 静态托管(无需独立进程);本地改代码后 `npm run build``dist/` rsync/tar 到云服务器 |
| FAM-Edge | Oracle | `/opt/fam-edge/` | **systemd `fam-edge.service` 守护**Restart=always部署代码后 `sudo systemctl restart fam-edge`(勿手动 setsid会端口冲突 |
| **AI-Gateway** | Oracle | `/opt/ai-gateway/` | **systemd `ai-gateway.service` 守护**Restart=always独立 venvPython 3.8gunicorn 绑定 `0.0.0.0:5100`(对外直接开放,非仅本机);部署代码后 `sudo systemctl restart ai-gateway` |
| Ollama | Oracle | systemd 托管 | 环境变量 `OLLAMA_KEEP_ALIVE=-1`**被 AI-Gateway 调用,不再被 FAM-Edge 调用** |
> **外网访问frp 内网穿透)**NAS 跑 `frpc``/etc/frp/frpc.toml`S99frpc.sh 守护),映射到云服务器 `129.146.26.249`frps :7000
> - `3000` → NAS Gitea、`8500` → NAS WordPress(8088)
> - **`8000` → NAS FAM-Core 这条已于 2026-09-13 迁云后废弃**(本系统不再有任何"回源 NAS"的流量,可从 frpc.toml 删除)
> - 前端入口 `https://smart-camera.zichuan.xyz/`:静态页、接口、登录全部由甲骨文提供,**NAS 离线也能正常访问**(只是不再有新的运动事件推进来),**需登录**(见 §5.1 `/login`
>
> **登录校验2026-08-22 新增2026-08-31 接入 auth-hub 统一登录2026-09-12 整体迁到甲骨文 FAM-Edge**:登录流程和会话校验现在都在甲骨文 FAM-Edge 的 `auth.py`§5.1NAS FAM-Core 自身不再做任何鉴权。迁移的直接原因NAS 或 frp 隧道一挂,`smart-camera.zichuan.xyz/login` 直接 502**连登录页都打不开**——登录是入口,不该依赖家里那台机器。
>
> - **链路**`/login`Caddy → 本机 fam-edge :5000→ 302 到 auth-hub 公网 `/authorize`Authorization Code + PKCE→ 用户在 [auth-hub](https://auth.zichuan.xyz) 登录 → 回跳 `/api/auth/callback`(仍是 fam-edge→ 走**本机** `http://127.0.0.1:5300` 换 token、拉 JWKS 验 `id_token` 签名 → 种 cookie `fam_session`
> - **为什么服务端调用走本机**:旧实现是 NAS 跨公网访问 `https://auth.zichuan.xyz`,那条链路上 `jwt.PyJWKClient` 用 urllib + 系统 CA不像 requests 自带 certifi群晖上容易 `CERTIFICATE_VERIFY_FAILED`;两台机器的时钟偏差还会让 `iat` 看起来来自未来。同机直连把这两个坑一起消掉。但 `iss` 校验和浏览器跳转仍用公网 `AUTH_HUB_ISSUER`
> - **拦截**:甲骨文 Caddy 对 `/api/*` 做 `forward_auth` → fam-edge `/api/auth/verify`200/204 才反代回 NAS`/login`、`/api/auth/*`、`/api/logout` 直接由 fam-edge 处理Caddy 按路径具体程度排序,这几条稳定排在 `/api/*` 前面)
> - **NAS :8000 必须靠防火墙兜底**frps 把它暴露在公网(历史入口 `http://129.146.26.249:8000`),绕过 Caddy 直连就没有任何鉴权。甲骨文 iptables 只允许本机访问 :8000见 `docs/DEPLOY.md` §2.3
> - **参数**`/opt/fam-edge/.env`**无硬编码默认值**,缺任一项拒绝所有登录 fail closed`AUTH_HUB_ISSUER`(公网,浏览器跳转 + `iss` 校验)/ `AUTH_HUB_INTERNAL_BASE`(本机,换 token + JWKS/ `AUTH_HUB_CLIENT_ID` / `AUTH_HUB_CLIENT_SECRET` / `AUTH_HUB_REDIRECT_URI`(必须跟 auth-hub 登记的逐字符一致,只做精确匹配)/ `FAM_SESSION_SECRET`会话签名密钥≥32 字节)
> - **会话是无状态签名 cookie**HS2562 小时fam-edge 重启不掉线(旧实现是进程内 token 表fam-core 一重启全员下线);要强制全员下线就换掉 `FAM_SESSION_SECRET` 再重启
> - auth-hub 侧任何审批通过的账号登录后都能访问本系统,**未再加用户名白名单**——自用场景的有意取舍(见项目记忆)
> Oracle 部署方式:本地 git 提交 push Gitea → tar 管道到 `/opt/fam-edge``--strip-components=1` 解临时目录再 cp避免动 data/venv/gdrive_videos。**AI-Gateway 是独立 git 仓库**http://192.168.50.64:3000/ericwyuan/ai-gateway见 §10.4),同样 tar 管道部署到 `/opt/ai-gateway`,互不影响。
### 8.2 依赖
- **FAM-Core/UINAS, Python 3.10 venv**Flask, Gunicorn, **PyMySQL**45KB 纯 Python 替代 19MB mysql-connector, PyYAML, requestsFAM-UI 另需 Streamlit + pandas
- **FAM-EdgeOracle, Python 3.8+ venv**Flask, Gunicorn, requests, PyYAML, opencv-python, numpy, **openai**NVIDIA NIM 兼容 OpenAI API 规范,复用同一 SDKGemini 用 requests 直调 REST不依赖 google-generativeai SDK
- **系统级**FFmpeg两端、Ollama + qwen2.5:7bOracle、MariaDB 10.11NAS
- **FAM-CoreNAS, Python 3.10 venv**Flask, Gunicorn, **PyMySQL**45KB 纯 Python 替代 19MB mysql-connector, PyYAML, requests
- **FAM-UI云服务器, Node**Vue3 + Vite + Tailwind`fam-ui/`,构建产物 dist 不入库);**不再依赖 Streamlit**
- **FAM-EdgeOracle, Python venv**Flask, Gunicorn, requests, PyYAML, opencv-python, numpy, **openai**NVIDIA NIM 兼容 OpenAI API 规范仅视觉分析用Gemini 用 requests 直调 REST**问答不再直接调模型,只用 requests 转发到 AI-Gateway**
- **AI-GatewayOracle, Python 3.8 venv独立部署单元**Flask, Gunicorn, requests, PyYAML, **openai**NVIDIA 问答模型用)
- **系统级**FFmpeg两端Oracle 端用于运动片段分割 + 帧图/头像、Ollama + qwen2.5:7bOracle**被 AI-Gateway 调用**、MariaDB 10.11NAS、rcloneOracle同步 Google Drive 素材)
### 8.3 配置文件要点
**fam-core/config/config.yaml**NAS,新架构 v2 —— 同步 + 问答):
**fam-core/config/config.yaml**NAS —— 同步 + 问答 + 运动监测
```yaml
server:
port: 8000
database: # MariaDBunix_socket 优先)
unix_socket: "/run/mysqld/mysqld10.sock"
oracle_sync: # 唯一后台线程配置
base_url: "http://129.146.203.203:5000"
oracle_sync: # 增量镜像线程
base_url: "http://129.146.26.249:5000"
token: "${ORACLE_SYNC_TOKEN}" # 与 Oracle 端 sync_api.token 一致
interval_sec: 1800 # 每 30 分钟拉一次增量
timeout: 120
chat_handler:
qa_url: "http://129.146.203.203:5000/api/edge/chat/ask" # 问答统一走 Oracle 编排
qa_url: "http://129.146.26.249:5000/api/edge/chat/ask"
timeout: 120
motion_notifier: # 运动监测(轮询主路径)
enabled: true
poll_enabled: true
dsm_host: "192.168.50.64" # Surveillance Station
dsm_port: 5000
dsm_account: "${DSM_ACCOUNT}"
dsm_password: "${DSM_PASSWORD}"
camera_ids: [2]
camera_name_to_id: {"Generic_ONVIF-001": 2}
oracle_base_url: "http://129.146.26.249:5000"
oracle_token: "${ORACLE_SYNC_TOKEN}"
poll_interval_sec: 60
poll_window_hours: 2
batch_size: 100
heartbeat_interval_sec: 300 # 心跳(证明推送链路存活,< Oracle 侧 900s 阈值)
```
**fam-edge/config/config.yaml**Oracle,整视频分析 + 同步 + 人物服务):
**fam-edge/config/config.yaml**Oracle —— 素材分割 + 片段分析 + 同步 + 人物服务**2026-08-23 起不再含任何问答模型配置**
```yaml
server:
port: 5000
gdrive_sync: # rclone 同步落地目录监听
gdrive_sync: # rclone 同步落地目录(整段素材)
enabled: true
local_dir: "/opt/fam-edge/gdrive_videos"
watch_interval_sec: 30
@@ -491,7 +630,7 @@ gdrive_sync: # rclone 同步落地目录监听
oracle_db:
path: "/opt/fam-edge/data/oracle.db"
sync_api:
token: "${ORACLE_SYNC_TOKEN}" # NAS 拉取鉴权(与 NAS oracle_sync.token 一致)
token: "${ORACLE_SYNC_TOKEN}"
person_service:
schedule_interval_sec: 1800 # 每 30 分钟重新汇总人物
model: "gemini"
@@ -499,42 +638,59 @@ video_processing:
max_concurrent: 1 # 单视频串行,避免抢占云端配额
timeout: 900
vision_order: ["gemini", "nvidia"]
models:
motion_segment: # 运动片段分割(运动事件驱动架构)
clips_dir: "/opt/fam-edge/motion_clips"
keep_audio: true # 保留音频pcm_alaw -> aac 64k 转码)
min_duration_sec: 1
unfinished_grace_sec: 10 # start+duration 距当前 ≤10s 视为已结束
ai_gateway: # 问答转发客户端配置2026-08-23 新增,取代原来的问答专用 models 条目)
base_url: "http://127.0.0.1:5100"
token: "${AI_GATEWAY_TOKEN}"
timeout: 60
models: # 只剩视觉分析用的两个 providerrole 全是 vision
- provider: "gemini"
role: "vision"
model_name: "gemini-flash-latest"
fallback_models: ["gemini-flash-lite-latest"]
api_key: "${GEMINI_API_KEY}"
extra_api_keys: ["${GEMINI_API_KEY_2}", "${GEMINI_API_KEY_3}", "${GEMINI_API_KEY_4}"]
timeout: 600
- provider: "nvidia"
role: "vision"
model_name: "nvidia/nemotron-nano-12b-v2-vl" # 整视频 video_url 输入(内部采样帧)
model_name: "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning" # 整视频 video_url 输入(内部采样帧)
base_url: "https://integrate.api.nvidia.com/v1"
api_key: "${NVIDIA_API_KEY}"
timeout: 600
- provider: "ollama"
role: "text"
usage: "qa_fallback" # 仅智能问答兜底,不参与视频
model_name: "qwen2.5:7b"
``` api_key: "${NVIDIA_API_KEY}"
timeout: 20
circuit_breaker:
enabled: true
threshold: 3
cooldown: 600
```
# 3. 本地 Ollama纯文本仅 Q&A 兜底,不参与视觉分析、不参与云端结果融合)
**ai-gateway/config/config.yaml**Oracle —— 独立服务,问答模型降级链,`server.port: 5100`
```yaml
server:
port: 5100
models: # 顺序即降级优先级chat-only不含视觉
- provider: "nvidia"
enabled: true
model_name: "nvidia/nemotron-3-ultra-550b-a55b" # 问答专用文字模型链,跟视觉分析的 omni 模型不同实例
fallback_models: ["nvidia/nemotron-3-super-120b-a12b", "openai/gpt-oss-120b"]
api_key: "${NVIDIA_API_KEY}"
timeout: 600
- provider: "gemini"
enabled: true
model_name: "gemini-pro-latest" # 问答专用非 flash 模型链,跟视觉分析的 flash 模型不同实例
fallback_models: ["gemini-2.5-pro"]
api_key: "${GEMINI_API_KEY}"
extra_api_keys: ["${GEMINI_API_KEY_2}", "${GEMINI_API_KEY_3}", "${GEMINI_API_KEY_4}"]
timeout: 60
- provider: "ollama"
role: "text" # 仅问答兜底
usage: "qa_fallback" # Gemini/NVIDIA 都失败时才启用
enabled: true
model_name: "qwen2.5:7b"
base_url: "http://localhost:11434"
timeout: 120
num_predict: 512
circuit_breaker:
enabled: false
```
> 部署时 `config_loader.py` 支持 `FAM_ENV_FILE` 环境变量指向另一个 `.env` 复用密钥(设计初衷是复用 FAM-Edge 已配置的 `NVIDIA_API_KEY`/`GEMINI_API_KEY*`,避免同一份密钥维护两份);**当前实际部署选择了各自独立**`/opt/ai-gateway/.env` 自己存了一份 `NVIDIA_API_KEY`/`GEMINI_API_KEY*`/`AI_GATEWAY_TOKEN`,跟 `/opt/fam-edge/.env` 没有关联,以后轮换 key 需要两处都改。
**环境变量**Oracle 节点,写入 `~/.bashrc` 或 systemd 环境变量文件):
```bash
@@ -564,11 +720,15 @@ print('NVIDIA NIM 连接成功:', response.choices[0].message.content)
### 8.4 运维注意事项
- NAS 部署目录**不是 git 仓库**(文件拷贝部署),同步代码用 stdin 管道:`ssh ... "cat > 远端路径" < 本地文件`
- NAS 部署目录**不是 git 仓库**(文件拷贝部署),同步代码用 stdin 管道:`tar czf - <子目录> | ssh ... 'tar xzf - -C <目标>'`
- NAS scp 子系统被禁用,同样用 stdin 管道传文件
- 远端 kill gunicorn 时 pkill/pgrep 会匹配 SSH 自身命令行导致断连,用 `pgrep -f 'gunicorn -w [1]'` 字符类技巧或 PID 文件
- fam-core 启动模块路径是 `src.fam_core.app:app`(不是 `fam_core.app:app`
- **Oracle fam-edge 由 systemd `fam-edge.service` 守护Restart=always**:部署代码后必须 `sudo systemctl restart fam-edge`;手动 `setsid` 启动会和守护打架(端口 `Connection in use`
- NAS 远端 kill gunicorn 用 `ps aux | grep "[f]am-core/venv/bin/gunicorn"` 字符类技巧pkill/pgrep 会匹配 SSH 自身命令行导致断连
- **sqlite3 连接不能跨线程共享**2026-09-13 修):`OracleDB` 原来在 `__init__` 里建一条 `check_same_thread=False` 的连接给全进程用VideoQueue / PersonService / DiskGuard 三个后台线程加 gunicorn 请求线程并发读写它,事务状态互相踩踏,线上累计刷出 `database is locked` 71604 次、`cannot start a transaction within a transaction` 378 次、`no more rows available` 92 次DiskGuard 的清理被打断 2837 次,磁盘守护形同虚设(剩余空间在 2.7G 和 16G 之间来回荡)。改成 `threading.local()` 每线程一条连接后WAL 下读不互斥、写由 SQLite 自己排队。**新增后台线程时不要再去共用某一条连接对象**
- fam-core 启动模块路径是 `src.fam_core.app:app`(不是 `fam_core.app:app``start_core.sh` 会 source 仓库根 `.env` 注入 `DSM_*/ORACLE_SYNC_TOKEN`
- Edge 单 worker 处理任务期间 `/health` 可能不响应,属正常
- **运动数据清理**:切换架构/重新提取时清 Oracle `videos/events/people` + `motion_clips/`(保留 `ss_motion_events` 与素材)与 NAS `sync_*` 镜像,重启两端自动重新分割分析
- **前端构建产物 `fam-ui/dist` 不入库**.gitignore改前端后需 `npm run build` 并单独 tar 部署 dist
---
@@ -578,24 +738,24 @@ print('NVIDIA NIM 连接成功:', response.choices[0].message.content)
# 1. 初始化数据库NAS
python scripts/init_db.py
# 2. 启动 FAM-CoreNAS
cd fam-core && gunicorn -w 1 -b 0.0.0.0:8000 --timeout 120 src.fam_core.app:app
# 2. 启动 FAM-CoreNAS,含运动监测轮询 + Oracle-Sync
cd /volume1/web/sentinel-home-ai && bash start_core.sh
# 3. 启动 FAM-EdgeOracle
cd fam-edge && gunicorn -w 1 -b 0.0.0.0:5000 --timeout 1800 src.fam_edge.app:app
# 3. 启动 FAM-EdgeOraclesystemd 守护
sudo systemctl restart fam-edge
# 4. 启动 FAM-UINAS
cd fam-ui && streamlit run src/app.py
# 4. 前端Vue3构建后由云服务器 Caddy :80 托管,/api 经 frp 隧道反代回 NAS FAM-Core无需在 NAS 独立进程
cd fam-ui && npm run build # 产物 fam-ui/dist/
# 或使用脚本
./scripts/start_core.sh && ./scripts/start_edge.sh && ./scripts/start_ui.sh
./scripts/start_core.sh && ./scripts/start_edge.sh
```
---
## 10. 服务器访问信息
### 10.1 Synology NASFAM-Core + FAM-UI + MariaDB
### 10.1 Synology NASFAM-Core + MariaDB
| 项目 | 值 |
|------|-----|
@@ -620,18 +780,20 @@ cd fam-ui && streamlit run src/app.py
| 项目 | 值 |
|------|-----|
| 公网 IP | 129.146.203.203 |
| SSH 用户 | ubuntu密钥 `~/.ssh/oracle_sentinel` |
| 系统 | aarch64 (Ampere A1 2C12G), Ubuntu 20.04 LTS |
| Tailscale | 100.74.137.126(已安装在线,与 NAS 端口不通 |
| 登录命令 | `ssh -i ~/.ssh/oracle_sentinel ubuntu@129.146.203.203` |
| 公网 IP | 129.146.26.249 |
| SSH 用户 | ubuntu密钥 `~/.ssh/oracle_new` |
| 系统 | aarch64 (Ampere A1 4C23G), Ubuntu 20.04 LTS |
| Tailscale | 已安装未启用(备用;新机公网直连为主 |
| 登录命令 | `ssh -i ~/.ssh/oracle_new ubuntu@129.146.26.249` |
### 10.4 Gitea 代码仓库
| 项目 | |
|------|-----|
| URL | http://192.168.50.64:3000/ericwyuan/sentinel-home-ai |
| 账号 / 密码 | ericwyuan / iLoveJava5 |
| 仓库 | URL | 说明 |
|------|-----|------|
| sentinel-home-ai本仓库 | http://192.168.50.64:3000/ericwyuan/sentinel-home-ai | monorepofam-core + fam-edge + fam-ui |
| ai-gateway2026-08-23 新增) | http://192.168.50.64:3000/ericwyuan/ai-gateway | 独立仓库/独立部署单元,问答网关服务 |
账号 / 密码ericwyuan / iLoveJava5两个仓库共用
### 10.5 Gemini APIGoogle AI Studio
@@ -691,22 +853,17 @@ export NVIDIA_API_KEY="nvapi-9cFAdO5xdbwPuxS8KGRTnlVimn1gJzbbbzWNhPwHa_Yl3pTe-Pf
## 12. 当前进度与 v1.1 计划
### 已完成(截至 2026-08-20
### 已完成(截至 2026-08-28
- 全部模块代码 + DDL + 部署脚本NAS/Oracle 双端部署运行
- 模型基准测试(原 llava-phi3 预热 4.3s PASS**已替换为 qwen2.5:7b**Ollama 常驻内存
- 架构改为推送模式NAS 上传整段视频 → Edge 同步分析 → 结果随响应返回)
- 关键帧自适应帧数 + FFmpeg 快速 seek6-8x 提速)
- FAM-Core API 全端点测试通过
- FAM-UI 部署Streamlit 1.61.1
- 真实视频性能基准30min/360MB → 929s
- **E2E 全链路打通**task 289 → SUCCESSmonitor_events/event_details 落库正确
- 可靠性加固僵尸任务回收、文件日志、datetime 空值兜底、超时按实测调整
- **架构重构(移除本地融合,云端直出直存)**
- 视频分析链路:云端 VLMGemini `gemini-flash-latest` / NVIDIA NIM `llama-3.2-11b-vision-instruct`)直出结构化 JSON → Edge `format_cloud_result` 格式化/校验(无模型调用)→ 直存 NAS DB本地 Ollama 不再参与视频摘要与融合(`run_text_fusion` 已移除)
- 智能问答链路:新增 `chat()` 方法,`run_qa` 按 Gemini → NVIDIA → 本地 Ollama 降级编排;端点 `/api/edge/chat/ask`NAS Chat-Handler 经 Edge 编排(`qa_url`),不再直连 Ollama
- 生产目录已切回 `/volume1/surveillance/Generic_ONVIF-001`285 历史视频 forward-only 占位跳过
- **双端部署验证通过**2026-08-20Oracle Edge 7 文件部署 + 重启,`/api/edge/chat/ask` 实测 provider=nvidiaGemini 超时→NVIDIA 兜底成功NAS chat_handler + config 手术式更新 + HUP 重载,插入临时上下文实测 NAS→Edge 编排链路 43s 返回并落库测试数据已清理Edge 日志确认 task 294360MB以新架构处理中、三模型健康检查全通过
- **Oracle 迁移故障排查 + DiskGuard 磁盘守护**2026-08-288/25 迁移新机器时漏装了 FFmpeg导致运动片段分割和帧图抽取全部失效`ffprobe: command not found`);同时 `gdrive_videos` 持续下载新素材没有配套清理,磁盘被写满到 100%,触发 rclone 的安全机制IO 错误时拒绝执行删除),形成"越满越删不掉"的死循环,表现为"只下载不删除"。已装回 FFmpeg、手动清理了 148 个远端已不存在的孤儿文件(释放 41G恢复同步新增 `DiskGuard` 后台服务作为永久兜底,剩余空间 <10GB 自动清理最旧的已完成素材。8/25 之前旧机器时代的历史事件帧图因源文件未随迁移保留、旧机器已销毁无法找回8/25-28 期间的 190 个素材/736 个运动事件按用户决定不重新处理
- **事件时间轴支持删除视频会话**2026-08-24三端联动沿用"NAS 转发写请求到 Oracle"的既有模式(新增 `POST /api/oracle/video/delete` + `DELETE /api/ui/videos/<id>`);删 events+videos 行 + 磁盘文件,不清理 `ss_motion_events` 源事件;`db_layer.delete_sync_video()` 是项目里第一个"NAS 直接写自己镜像表"的函数(增量同步机制感知不到删除,不能靠 `trigger_now()` 拉增量清理)
- **问答链路抽离为独立 ai-gateway 服务**2026-08-23FAM-Edge 原本自己维护的问答模型降级链NVIDIA→Gemini→Ollama含 key 轮换/熔断)整个搬到独立仓库/独立部署单元 `ai-gateway`OpenAI 兼容协议 `/v1/chat/completions`Bearer token 鉴权,对外 `:5100`FAM-Edge `qa.py` 重写为转发客户端,`/api/edge/chat/ask(/stream)` 对 FAM-Core 的契约不变;`OllamaAdapter` 从 FAM-Edge 删除
- **运动事件驱动**v32026-08-22不再分析整段视频。NAS MotionNotifier 轮询 SS 事件列表60s游标续用/失败重试/心跳)推送 `ss_motion_events`Oracle 整段素材按运动事件 ffmpeg 分割运动片段(`-c:v copy -c:a aac`,只分割已结束事件,`motion_event_id` 幂等),**只分析运动片段**;前端契约不变
- **数据迁移**2026-08-22整段提取的旧数据Oracle videos/events/people + NAS 镜像已清空并按运动视频重新提取历史素材NAS 轮询启动前)无运动事件,时间轴已过滤其空会话
- **人物管理重设计**2026-08-22人物卡新增「运动片段」区块缩略图/时间/摘要/事件数,点击跳时间轴定位);新增 `GET /api/ui/people/clips`Timeline 支持 `?video=` 定位
- 前端从 Streamlit 迁移为 **Vue3 + Vite + Tailwind SPA**2026-08-25 起前端托管由 NAS 迁至云服务器 Caddy :80/api 经 frp 隧道反代回 NAS FAM-Core
- 运动事件链路NAS→Oracle 单向推送)+ 心跳 fail-openGemini 多 Key 轮换 + 模型调用统计
- 双端部署NAS `start_core.sh`source .envOracle **systemd `fam-edge.service` 守护**
详细进度见 `PROGRESS.md`
@@ -717,6 +874,7 @@ export NVIDIA_API_KEY="nvapi-9cFAdO5xdbwPuxS8KGRTnlVimn1gJzbbbzWNhPwHa_Yl3pTe-Pf
| 1 | 单元测试JSON parser / circuit breaker / schema 校验) | 中 |
| 2 | Tailscale 修复NAS userspace 模式升级,流量不走公网) | 低 |
| 3 | daily_summaries 每日摘要 | 低 |
| 4 | 运动片段音频策略验证(当前保留 aac 转码) | 低 |
---

View File

@@ -1,155 +1,190 @@
# 部署指南
# 部署指南2026-09-13 全量迁云后同步)
## 1. NAS 端部署 (FAM-Core + FAM-UI + MariaDB)
> **一句话架构**除了录像本身全部跑在甲骨文。NAS 只剩 Surveillance Station
> 和一个往甲骨文推运动事件的进程fam-notifier——NAS 离线不影响网站访问。
### 1.1 MariaDB
## 1. NAS 端部署(只有 fam-notifier
2026-09-13 迁云后 NAS 上**不再有** MariaDB、FAM-Core、FAM-UI镜像层整个删除
(数据权威源本来就在甲骨文),接口和前端也都搬走了。旧部署的清理见 §1.2。
### 1.1 fam-notifier轮询 Surveillance Station → 推甲骨文)
```bash
# 安装 MariaDB (通过 Synology 套件中心)
# 确认端口 3306 可用
# 执行 DDL
python3 scripts/init_db.py --host 127.0.0.1 --port 3306 --user root --password <密码>
```
### 1.2 FAM-Core
```bash
cd /path/to/sentinel-home-ai/fam-core
cd /volume1/web/sentinel-home-ai/fam-notifier
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
pip install -r requirements.txt # 只有 requests + PyYAML
# 配置
cp config/config.yaml.example config/config.yaml
# 编辑 config.yaml: 数据库密码、Oracle Tailscale IP、token 等
# 编辑 config.yamlcamera_ids、oracle_base_urlDSM 凭据与 token 走仓库根 .env
# 仓库根 .env 需提供 DSM_ACCOUNT / DSM_PASSWORD / ORACLE_SYNC_TOKEN
# 启动
./scripts/start_core.sh
# 或手动: gunicorn -w 1 -b 0.0.0.0:8000 --timeout 120 src.fam_core.app:app
# 启动DSM 没有 systemd用 setsid 脱离 SSH 会话;挂了不会自启,需要人工重启)
cd /volume1/web/sentinel-home-ai/fam-notifier
setsid bash scripts/start_notifier.sh >> logs/start.log 2>&1 &
# 确认在跑(进程没有监听端口,只能看进程和日志)
ps aux | grep "[f]am_notifier"
tail -f logs/fam-notifier.log # 应每 60s 轮询、每 5min 心跳
```
### 1.3 FAM-UI
游标存在 `fam-notifier/data/cursor.json`(迁云前存在 MariaDB。删掉也不致命
窗口回看会把最近的事件补推一遍,甲骨文按 event_id 幂等落库。
### 1.2 清理旧部署(迁云一次性动作)
```bash
cd /path/to/sentinel-home-ai/fam-ui
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
# 1) 停掉 NAS 上的 fam-core它已经不该再跑了跑着也没用前端不再连它
ps aux | grep "[f]am-core/venv/bin/gunicorn" # 字符类写法pkill 会杀掉 SSH 自己
kill <上面的 pid>
# 配置
cp config/config.yaml.example config/config.yaml
# 编辑 config.yaml: 数据库连接、FAM-Core 地址
# 启动
./scripts/start_ui.sh
# 或手动: streamlit run src/app.py --server.port 8501 --server.address 0.0.0.0
# 2) frpc 里的 8000 映射可以删了(/etc/frp/frpc.toml 的 fam-core 段),
# 甲骨文侧对应的 iptables DROP 规则也就没有存在意义了
# 3) MariaDB 的 sentinel_home_ai 库确认 chat_history 已迁移§2.4)后再考虑删
```
## 2. Oracle 端部署 (FAM-Edge + Ollama + FFmpeg)
### 1.3 FAM-UIVue3 SPA构建后托管在甲骨文 Caddy
```bash
cd /Users/ericwyuan/Desktop/Work/sentinel-home-ai/fam-ui # 本地开发机
npm install
npm run build # 产物 fam-ui/dist/
tar czf - -C fam-ui dist | ssh -i ~/.ssh/oracle_new ubuntu@129.146.26.249 \
'sudo tar xzf - --strip-components=1 -C /var/www/fam-ui && sudo chown -R ubuntu:ubuntu /var/www/fam-ui'
# 浏览器访问 https://smart-camera.zichuan.xyz/
```
## 2. Oracle 端部署 (FAM-Edge + FAM-Core + Ollama + FFmpeg)
### 2.1 系统依赖
```bash
# FFmpeg
sudo apt update && sudo apt install -y ffmpeg
sudo apt update && sudo apt install -y ffmpeg python3-opencv
# OpenCV 依赖
sudo apt install -y python3-opencv libopencv-dev
# Ollama
# Ollama + qwen2.5:7b纯文本仅问答兜底
curl -fsSL https://ollama.com/install.sh | sh
systemctl enable ollama
systemctl start ollama
# 拉取模型
ollama pull llava-phi3
systemctl enable ollama && systemctl start ollama
ollama pull qwen2.5:7b
```
### 2.2 FAM-Edge
### 2.2 FAM-Edgesystemd 守护)
```bash
cd /path/to/sentinel-home-ai/fam-edge
cd /opt/fam-edge
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
# 配置
cp config/config.yaml.example config/config.yaml
# 编辑 config.yaml: NAS Tailscale IP、media_token、Gemini API Key
# 设置 Gemini API Key 环境变量
export GEMINI_API_KEY="your-api-key-here"
# 启动
./scripts/start_edge.sh
# 或手动: gunicorn -w 1 -b 0.0.0.0:5000 --timeout 600 src.fam_edge.app:app
```
### 2.3 模型可行性压测 (阶段一验证)
```bash
# 测试 llava-phi3 单张图推理耗时
ollama run llava-phi3 "描述这张图片" --images /path/to/test.jpg
# 预期:
# - 单张 ≤ 8 秒 → 可行
# - 单张 ≤ 30 秒 → 可接受
# - 单张 > 30 秒 → 需换更小模型或减帧
```
## 3. Tailscale 组网
```bash
# NAS 端
curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale up
# Oracle 端
curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale up
# 验证
# NAS: ping 100.x.x.20 (Oracle Tailscale IP)
# Oracle: ping 100.x.x.10 (NAS Tailscale IP)
```
## 4. 开机自启 (可选)
### NAS 端 (使用任务计划)
```bash
# Synology DSM -> 控制面板 -> 任务计划 -> 新增 -> 触发的任务 -> 开机
# 脚本:
cd /path/to/sentinel-home-ai
./scripts/start_core.sh &
./scripts/start_ui.sh &
```
### Oracle 端 (使用 systemd)
```bash
sudo tee /etc/systemd/system/fam-edge.service << 'EOF'
[Unit]
Description=FAM-Edge Service
After=network.target ollama.service
[Service]
Type=simple
User=ubuntu
WorkingDirectory=/path/to/sentinel-home-ai/fam-edge
Environment=GEMINI_API_KEY=your-api-key
ExecStart=/path/to/sentinel-home-ai/fam-edge/venv/bin/gunicorn -w 1 -b 0.0.0.0:5000 --timeout 600 src.fam_edge.app:app
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
EOF
# 配置config/config.yaml素材目录/DB/模型/motion_segment.env 提供 ORACLE_SYNC_TOKEN/GEMINI_API_KEY*/NVIDIA_API_KEY
# systemd 服务(已配置 /etc/systemd/system/fam-edge.serviceRestart=always
sudo systemctl enable fam-edge
sudo systemctl start fam-edge
sudo systemctl restart fam-edge # 部署代码后必须用 systemctl 重启,勿手动 setsid
```
## 5. 验证清单
### 2.3 统一登录2026-09-12 从 NAS 迁入fam-edge + Caddy + 防火墙三件套)
登录入口不再依赖 NAS。三处缺一不可
```bash
# (1) /opt/fam-edge/.env 追加六项(缺任一项 fam-edge 拒绝所有登录fail closed
# client_secret 用 rotate-secret 现拿,明文只显示一次:
# cd /opt/auth-hub && venv/bin/python -m auth_hub.manage_clients rotate-secret <client_id>
# 会话密钥自己生成openssl rand -hex 32
export AUTH_HUB_ISSUER=https://auth.zichuan.xyz # 公网:浏览器跳转 + id_token 的 iss 校验
export AUTH_HUB_INTERNAL_BASE=http://127.0.0.1:5300 # 本机:换 token + 拉 JWKS不走公网 TLS
export AUTH_HUB_CLIENT_ID=<auth-hub 注册的 client_id>
export AUTH_HUB_CLIENT_SECRET=<rotate-secret 输出>
export AUTH_HUB_REDIRECT_URI=https://smart-camera.zichuan.xyz/api/auth/callback # 与 auth-hub 登记的逐字符一致
export FAM_SESSION_SECRET=<openssl rand -hex 32> # 会话 cookie 签名密钥,换掉即全员下线
# (2) venv 补依赖Python 3.8pip 会自动选到兼容版本)后重启
/opt/fam-edge/venv/bin/pip install "PyJWT>=2.8.0" "cryptography>=42.0.0"
sudo systemctl restart fam-edge
# (3) Caddy登录端点走本机 fam-edge其余 /api/* 先 forward_auth 再回源 NAS
# 改完先 caddy validate 再 reload见下方 Caddyfile 片段
sudo caddy validate --adapter caddyfile --config /etc/caddy/Caddyfile && sudo systemctl reload caddy
```
```caddyfile
smart-camera.zichuan.xyz {
handle /login { reverse_proxy 127.0.0.1:5000 }
handle /api/auth/* { reverse_proxy 127.0.0.1:5000 }
handle /api/logout { reverse_proxy 127.0.0.1:5000 }
handle /api/* {
forward_auth 127.0.0.1:5000 { uri /api/auth/verify } # 删了等于数据接口全裸
reverse_proxy 127.0.0.1:8000
}
handle { root * /var/www/fam-ui; encode gzip; try_files {path} /index.html; file_server }
}
```
```bash
# (4) 防火墙frps 把 NAS fam-core 的 :8000 暴露在公网,而 fam-core 自身已无鉴权,
# 必须只放行本机Caddy访问否则绕过 Caddy 就能读到全部数据
sudo iptables -I INPUT 1 -p tcp --dport 8000 ! -i lo -j DROP
sudo netfilter-persistent save # 否则重启后规则丢失
```
### 2.4 FAM-CoreUI/对话/成员接口2026-09-13 从 NAS 迁入)
```bash
# 代码(本地开发机 → 甲骨文)
tar czf - --exclude=venv --exclude=__pycache__ --exclude=logs --exclude='config/config.yaml' fam-core | \
ssh -i ~/.ssh/oracle_new ubuntu@129.146.26.249 'tar xzf - --strip-components=1 -C /opt/fam-core'
# 甲骨文上venv + 配置
python3 -m venv /opt/fam-core/venv
/opt/fam-core/venv/bin/pip install -r /opt/fam-core/requirements.txt
cp /opt/fam-core/config/config.yaml.example /opt/fam-core/config/config.yaml
# .env 只需要 ORACLE_SYNC_TOKEN与 fam-edge 的一致config_loader 会自动加载
grep "^export ORACLE_SYNC_TOKEN=" /opt/fam-edge/.env > /opt/fam-core/.env && chmod 600 /opt/fam-core/.env
sudo systemctl restart fam-core # 单元见 /etc/systemd/system/fam-core.service
curl -s http://127.0.0.1:5401/api/status # {"db":{"ok":true},...}
```
**端口 5401 不是笔误**5400 被这台机器上的 chat-relay 占了。fam-core 只绑
127.0.0.1,外部进不来,唯一客户端是同机 Caddy先 forward_auth 再反代)。
**chat_history 迁移(一次性)**:这是 NAS MariaDB 里唯一不是镜像的表。
在 NAS 上导出,再从本地导入甲骨文的 SQLite
```bash
# NAS 上导出(用 fam-core 旧 venv 里的 PyMySQL
ssh -p 2222 ericwyuan@192.168.50.64 '/volume1/web/sentinel-home-ai/fam-core/venv/bin/python -c "
import pymysql, json, yaml
cfg = yaml.safe_load(open(\"/volume1/web/sentinel-home-ai/fam-core/config/config.yaml\"))[\"database\"]
c = pymysql.connect(host=\"127.0.0.1\", user=cfg[\"user\"], password=cfg[\"password\"], database=cfg[\"database\"], cursorclass=pymysql.cursors.DictCursor)
cur = c.cursor(); cur.execute(\"SELECT * FROM chat_history ORDER BY chat_id\")
print(json.dumps(cur.fetchall(), ensure_ascii=False, default=str))
"' > /tmp/chat_history.json
# 本地 → 甲骨文导入(幂等:按 chat_id 跳过已存在的)
cat /tmp/chat_history.json | ssh -i ~/.ssh/oracle_new ubuntu@129.146.26.249 \
'/opt/fam-core/venv/bin/python /opt/fam-core/scripts/import_chat_history.py'
```
## 3. 代码同步tar 管道scp 在 NAS 被禁用)
```bash
# NASfam-core仓库根即部署根直接解包
tar czf - --exclude=venv --exclude=__pycache__ fam-core | \
ssh -p 2222 ericwyuan@192.168.50.64 'tar xzf - -C /volume1/web/sentinel-home-ai'
# Oraclefam-edge/opt/fam-edge 是 fam-edge 根,--strip-components=1 解临时目录再 cp
tar czf - --exclude=venv --exclude=__pycache__ --exclude=data --exclude=gdrive_videos fam-edge | \
ssh ubuntu@129.146.26.249 'mkdir -p /opt/fam-edge/tmp_d && tar xzf - --strip-components=1 -C /opt/fam-edge/tmp_d && \
cp -rf /opt/fam-edge/tmp_d/* /opt/fam-edge/ && rm -rf /opt/fam-edge/tmp_d && sudo systemctl restart fam-edge'
```
## 4. 验证清单
| 项目 | 命令 | 预期 |
|------|------|------|
| NAS MariaDB | `mysql -u root -p -e "SHOW DATABASES"` | 包含 sentinel_home_ai |
| NAS FAM-Core | `curl http://localhost:8000/health` | `{"status":"ok"}` |
| NAS FAM-UI | 浏览器访问 `http://192.168.50.64:8501` | Streamlit 页面 |
| Oracle FAM-Edge | `curl http://localhost:5000/health` | `{"status":"ok"}` |
| Oracle Ollama | `curl http://localhost:11434/api/tags` | 模型列表含 llava-phi3 |
| Tailscale | NAS `ping 100.x.x.20` | 通 |
| 云服务器 FAM-UI | 浏览器访问 `https://smart-camera.zichuan.xyz/` | 未登录时 `/api/*` 返回 401前端自动跳 `/login` → auth-hub 登录页;登录后跳回展示 Vue3 SPA |
| 登录不依赖 NAS | `curl -sI https://smart-camera.zichuan.xyz/login`(此时哪怕 fam-core 是停的) | 302 到 `auth.zichuan.xyz/authorize?...`**不再是 502** |
| 鉴权闸门 | `curl -s -o /dev/null -w '%{http_code}' https://smart-camera.zichuan.xyz/api/ui/videos` | 401Caddy forward_auth 拦下,没到 NAS |
| NAS fam-notifier | NAS 上 `ps aux \| grep "[f]am_notifier"` | 有进程;`fam-notifier/logs/fam-notifier.log` 每 60s 一条轮询、每 5min 一条心跳 |
| 甲骨文 FAM-Core | `curl -s http://127.0.0.1:5401/api/status` | `{"db":{"ok":true,...}}``/api/ui/stats` 返回真实计数 |
| NAS 离线也能用 | 关掉 NAS 后访问 `https://smart-camera.zichuan.xyz/timeline` | 页面与数据照常(只是不再有新运动事件);**这是本次迁云的验收标准** |
| Oracle FAM-Edge | `curl http://localhost:5000/health` | `{"status":"ok","queue_alive":true}` |
| Oracle 运动事件 | `sqlite3 /opt/fam-edge/data/oracle.db "SELECT COUNT(*) FROM ss_motion_events"` | >0NAS 推送) |
| Oracle 运动片段 | `ls /opt/fam-edge/motion_clips/` | 存在 motion_*.mp4素材分割产物 |
| Oracle Ollama | `curl http://localhost:11434/api/tags` | 模型列表含 qwen2.5:7b |

View File

@@ -22,7 +22,7 @@ database:
# 甲骨文同步(每 30 分钟拉增量镜像)
oracle_sync:
# FAM-Edge 对外同步接口地址(端口同其 server.port=5000
base_url: "http://129.146.203.203:5000"
base_url: "http://129.146.26.249:5000"
# 与 Oracle 端 sync_api.token 一致(环境变量注入,避免明文入库)
token: "${ORACLE_SYNC_TOKEN}"
interval_sec: 1800 # 拉取间隔(秒),默认 30 分钟
@@ -30,5 +30,31 @@ oracle_sync:
chat_handler:
# 智能问答统一走 FAM-Edge 编排端点Gemini → NVIDIA → 本地 Ollama 兜底)
qa_url: "http://129.146.203.203:5000/api/edge/chat/ask"
qa_url: "http://129.146.26.249:5000/api/edge/chat/ask"
qa_stream_url: "http://129.146.26.249:5000/api/edge/chat/ask/stream" # 流式版
timeout: 120
# 运动监测通知服务2026-08-22 定稿轮询主路径2026-08-25 移除 Webhook 可选路径)
# NAS 本机轮询 SS EventCenter.Event.List真实 event_id/start_time/duration
# 增量推送到甲骨文 FAM-Edge单向 NAS -> Oracle甲骨文不再反向访问 NAS
# SS 凭据走环境变量(${DSM_ACCOUNT}/${DSM_PASSWORD}),由启动脚本 source 的 .env 提供。
motion_notifier:
enabled: true
poll_enabled: true # 轮询主路径(默认开启)
dsm_host: "192.168.50.64" # Surveillance Station 所在地址NAS 本机)
dsm_port: 5000
dsm_account: "${DSM_ACCOUNT}"
dsm_password: "${DSM_PASSWORD}"
camera_ids: [2] # 轮询关注的摄像头Generic_ONVIF-001
oracle_base_url: "http://129.146.26.249:5000" # 与 oracle_sync.base_url 一致
oracle_token: "${ORACLE_SYNC_TOKEN}" # 与 oracle_sync.token 一致
timeout_sec: 10 # 单次 SS 请求超时
# 心跳:跟轮询 SS 无关,只是定期空 POST 一下甲骨文的 /api/ss/motion证明
# NAS->Oracle 这条推送链路本身还活着enabled=true 就跑,不受 poll_enabled 影响)。
# 甲骨文侧 dsm_motion_prefilter.max_heartbeat_age_sec默认 900s据此判断"无运动"
# 结论是否可信——这个心跳间隔要明显小于那个阈值,否则会被误判成链路已死。
heartbeat_interval_sec: 300
# 轮询参数
poll_interval_sec: 60 # 轮询间隔
poll_window_hours: 2 # 每轮回看窗口(小时),覆盖轮询间隔内的新事件
batch_size: 100 # 单批推送上限

View File

@@ -1,32 +1,28 @@
# FAM-Core 配置文件 (NAS 端) - 新架构 v2
# FAM-Core 配置文件2026-09-13 起跑在甲骨文,与 fam-edge 同机)
# 复制此文件为 config.yaml 并修改实际值
#
# NAS 仅作管理后台,不再处理视频。唯一后台线程 Oracle-Sync 每 30 分钟
# 从甲骨文 FAM-Edge 拉取增量镜像到本地 MariaDBsync_videos/events/people
# 所有视频分析在 Oracle 完成。
# 本服务是纯查询/转发层:直读 fam-edge 的 SQLite写操作转给 fam-edge。
# 没有后台线程,没有自己的数据库,重启不影响任何数据
server:
host: "0.0.0.0"
port: 8000
# 只监听回环:唯一的客户端是同机 Caddy它做 forward_auth 鉴权后才反代进来)。
# 迁云前这里是 0.0.0.0:8000 并经 frp 暴露到公网,绕过 Caddy 就能拿到全部数据。
host: "127.0.0.1"
port: 5401
database:
host: "127.0.0.1"
port: 3306
user: "root"
password: ""
database: "sentinel_home_ai"
unix_socket: "/run/mysqld/mysqld10.sock"
# fam-edge 的库,本服务只读它写的表(唯一写的是 chat_history
# fam-edge 侧已开 WAL多进程读写安全。
path: "/opt/fam-edge/data/oracle.db"
# 甲骨文同步(每 30 分钟拉增量镜像)
oracle_sync:
# FAM-Edge 对外同步接口地址(端口同其 server.port=5000
base_url: "http://<oracle-public-ip>:5000"
# 与 Oracle 端 sync_api.token 一致
token: ""
interval_sec: 1800 # 拉取间隔(秒),默认 30 分钟
timeout: 120 # 单次拉取超时(秒)
# 同机 fam-edge写操作改名/删除)、帧图头像、服务状态都打给它
edge:
base_url: "http://127.0.0.1:5000"
token: "${ORACLE_SYNC_TOKEN}" # 与 fam-edge 的 sync_api.token 一致
timeout: 60
chat_handler:
# 智能问答统一走 FAM-Edge 编排端点Gemini → NVIDIA → 本地 Ollama 兜底)
qa_url: "http://<oracle-public-ip>:5000/api/edge/chat/ask"
# 智能问答走 fam-edge 编排端点NVIDIA → Gemini → 本地 Ollama 兜底)
qa_url: "http://127.0.0.1:5000/api/edge/chat/ask"
qa_stream_url: "http://127.0.0.1:5000/api/edge/chat/ask/stream"
timeout: 120

View File

@@ -1,5 +1,4 @@
flask>=3.0.0
gunicorn>=21.2.0
PyMySQL>=1.1.0
PyYAML>=6.0
requests>=2.31.0

View File

@@ -0,0 +1,57 @@
"""把 NAS MariaDB 导出的 chat_history 导入甲骨文 SQLite2026-09-13 迁云一次性脚本)。
chat_history 是 NAS 那套库里唯一"不是镜像"的表——其余 sync_* 都能从甲骨文重新
读出来,只有问答历史是本地产生的,迁云时必须搬过来。
用法JSON 从 stdin 进来,导出命令见 docs/DEPLOY.md §2.4
cat chat_history.json | /opt/fam-core/venv/bin/python scripts/import_chat_history.py
幂等:按 chat_id 跳过已存在的行,重复执行不会产生重复记录。
"""
import json
import os
import sys
sys.path.insert(0, os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'src'))
from fam_core import db_layer # noqa: E402
_COLS = ('chat_id', 'user_question', 'ai_answer', 'context_summary',
'queried_date', 'queried_person', 'created_at')
def main():
try:
rows = json.load(sys.stdin)
except ValueError as e:
print(f"stdin 不是合法 JSON: {e}", file=sys.stderr)
return 1
if not isinstance(rows, list):
print("期望一个 JSON 数组", file=sys.stderr)
return 1
conn = db_layer.get_conn()
try:
db_layer._ensure_chat_schema(conn)
inserted = skipped = 0
for r in rows:
cid = r.get('chat_id')
if cid is not None and conn.execute(
"SELECT 1 FROM chat_history WHERE chat_id=?", (cid,)).fetchone():
skipped += 1
continue
conn.execute(
"INSERT INTO chat_history ({}) VALUES ({})".format(
','.join(_COLS), ','.join('?' * len(_COLS))),
tuple(None if r.get(c) is None else str(r.get(c)) for c in _COLS))
inserted += 1
conn.commit()
finally:
conn.close()
print(f"导入 {inserted} 条,跳过 {skipped}chat_id 已存在)")
return 0
if __name__ == '__main__':
sys.exit(main())

View File

@@ -1,10 +1,20 @@
"""
FAM-Core 主应用 - Flask 单进程(新架构 v2
FAM-Core 主应用 - Flask2026-09-13 从 NAS 迁到甲骨文
承载: Oracle-Sync每 30 分钟拉取增量镜像)+ Member-Manager + Chat-Handler
承载: UI-API + Member-Manager + Chat-Handler + Img-Proxy —— 全是查询与转发,
**没有任何后台线程**,进程随时可重启,不持有任何状态。
NAS 不再处理视频:无 Scheduler / Dispatcher / Poller / Event-Receiver / Video-Server。
所有视频分析在 Oracle 完成NAS 仅作管理后台拉取展示CPU 占用大幅降低
迁云前它跑在 NAS 上,还扛着两个后台线程,现在都不在这里了:
- Oracle-Sync每 30 分钟把甲骨文数据拉一份镜像进 MariaDB—— 整个删除
本服务现在与 fam-edge 同机,直接读它的 SQLite见 db_layer.py 开头)。
- MotionNotifier轮询 Surveillance Station 推运动事件)—— 留在 NAS
拆成独立的 fam-notifier摄像头插在 NAS 上,这部分搬不走)。
两件跟安全有关的事:
- 本服务只监听 127.0.0.1config.yaml 的 server.host唯一的客户端是同机
Caddy。不像迁云前那样经 frp 把 :8000 暴露到公网。
- 登录校验也不在这里Caddy 用 forward_auth 打 fam-edge 的 /api/auth/verify
通过了才反代进来。
"""
import os
import sys
@@ -16,57 +26,52 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from .config_loader import load_config
from .logger import setup_logger
from .oracle_sync import get_sync
from .chat_handler.chat_handler import chat_bp
from .member_manager.member_manager import member_bp
from .img_proxy import img_bp
from .ui_api import ui_bp
from .static_app import static_bp
from . import db_layer, edge_client
logger = setup_logger('fam-core.app')
app = Flask(__name__)
# 注册蓝图:/api/* 系列必须先于 static_bp 注册——static_bp 是通配兜底路由
# Vue Router history 模式回退 index.html排在前面会吞掉 API 请求。
app.register_blueprint(chat_bp)
app.register_blueprint(member_bp)
app.register_blueprint(img_bp)
app.register_blueprint(ui_bp)
app.register_blueprint(static_bp)
# 健康检查
@app.route('/health', methods=['GET'])
def health():
return jsonify({"status": "ok", "service": "fam-core"}), 200
# 初始化后台同步线程NAS 唯一常驻线程)
_sync = None
try:
_sync = get_sync()
_sync.start()
logger.info("Oracle-Sync 已启动")
# 启动后立刻拉一次,前端无需等待首个周期即有数据
try:
_sync.trigger_now()
logger.info("启动首次同步完成")
except Exception as e:
logger.warning(f"启动首次同步失败(后续周期会重试): {e}")
except Exception as e:
logger.error(f"Oracle-Sync 启动失败: {e}")
@app.route('/api/status', methods=['GET'])
def status():
"""系统状态"""
"""服务自检库能不能读、fam-edge 在不在。
迁云前这里报告的是镜像同步状态(游标/周期/上次增量条数),镜像没了之后
那些字段不再存在,前端侧边栏的同步面板也一并去掉了。
"""
db_ok, db_err = True, None
try:
conn = db_layer.get_conn()
try:
conn.execute("SELECT 1 FROM videos LIMIT 1").fetchone()
finally:
conn.close()
except Exception as e:
db_ok, db_err = False, str(e)
return jsonify({
"service": "fam-core",
"sync": _sync.status() if _sync else {"running": False, "error": "未初始化"},
"db": {"ok": db_ok, "error": db_err},
"edge_base_url": edge_client.base_url(),
}), 200
if __name__ == '__main__':
cfg = load_config()
port = cfg.get('server', {}).get('port', 8000)
app.run(host='0.0.0.0', port=port, debug=False)
server = cfg.get('server', {})
app.run(host=server.get('host', '127.0.0.1'),
port=server.get('port', 5401), debug=False)

View File

@@ -13,7 +13,7 @@ Chat-Handler - Flask 蓝图,接收用户问答(新架构 v2
"""
import json
import requests
from flask import Blueprint, request, jsonify
from flask import Blueprint, request, jsonify, Response, stream_with_context
from ..logger import setup_logger
from ..config_loader import load_config
@@ -47,7 +47,7 @@ def _call_edge_qa(prompt: str) -> str:
"""调用 FAM-Edge 问答编排端点Gemini → NVIDIA → 本地 Ollama 兜底)"""
cfg = load_config()
qa_url = cfg.get('chat_handler', {}).get(
'qa_url', 'http://129.146.203.203:5000/api/edge/chat/ask'
'qa_url', 'http://127.0.0.1:5000/api/edge/chat/ask'
)
timeout = cfg.get('chat_handler', {}).get('timeout', 120)
@@ -79,7 +79,7 @@ def chat_ask():
logger.info(f"Chat: person={queried_person}, date={queried_date}, question={question}")
rows = db_layer.query_sync_events_for_person_date(queried_person, queried_date)
rows = db_layer.query_events_for_person_date(queried_person, queried_date)
if len(rows) == 0:
answer = f"今天没有观察到{queried_person}"
@@ -87,7 +87,7 @@ def chat_ask():
else:
context = _format_events(rows)
context_summary = f"查询 sync_events {len(rows)}"
members = db_layer.get_sync_known_members_context()
members = db_layer.get_known_members_context()
prompt = build_chat_prompt(
context=context,
members=members or queried_person,
@@ -115,6 +115,98 @@ def chat_ask():
}), 200
@chat_bp.route('/api/chat/ask/stream', methods=['POST'])
def chat_ask_stream():
"""流式问答SSE 逐块推送,边生成边显示。
先立即推一条 context 事件(用了哪些 sync_events本地查询很快不用等
大模型);再把甲骨文 /api/edge/chat/ask/stream 的分块原样转发给前端;
最后一次性把拼好的完整回答写进 chat_history跟非流式版一致
"""
data = request.get_json(silent=True)
if not data:
return jsonify({"error": "Invalid JSON"}), 400
question = data.get('question', '')
queried_person = data.get('queried_person', '')
queried_date = data.get('queried_date', '')
if not question or not queried_person or not queried_date:
return jsonify({"error": "缺少必填字段: question, queried_person, queried_date"}), 400
logger.info(f"Chat(stream): person={queried_person}, date={queried_date}, question={question}")
rows = db_layer.query_events_for_person_date(queried_person, queried_date)
def sse(obj):
return f"data: {json.dumps(obj, ensure_ascii=False)}\n\n"
def generate():
if len(rows) == 0:
answer = f"今天没有观察到{queried_person}"
context_summary = "查询 sync_events 0 条"
yield sse({"type": "context", "count": 0, "summary": context_summary})
yield sse({"type": "chunk", "provider": None, "text": answer})
yield sse({"type": "done", "provider": None})
db_layer.insert_chat_history(
user_question=question, ai_answer=answer,
context_summary=context_summary,
queried_date=queried_date, queried_person=queried_person)
return
context = _format_events(rows)
context_summary = f"查询 sync_events {len(rows)}"
yield sse({"type": "context", "count": len(rows), "summary": context_summary,
"preview": context[:800]})
members = db_layer.get_known_members_context()
prompt = build_chat_prompt(
context=context, members=members or queried_person,
question=question, queried_person=queried_person)
cfg = load_config()
stream_url = cfg.get('chat_handler', {}).get(
'qa_stream_url', 'http://127.0.0.1:5000/api/edge/chat/ask/stream')
timeout = cfg.get('chat_handler', {}).get('timeout', 120)
full_answer = []
provider_used = None
try:
resp = requests.post(stream_url, json={"prompt": prompt},
timeout=(10, timeout), stream=True)
if resp.status_code != 200:
raise Exception(f"HTTP {resp.status_code}")
# 甲骨文那边的响应体固定是 UTF-8但 Content-Type 不一定带 charset
# 参数requests 会自己猜编码——猜错就是中文乱码,强制指定跳过嗅探。
resp.encoding = 'utf-8'
for line in resp.iter_lines(decode_unicode=True):
if not line or not line.startswith('data: '):
continue
yield line + '\n\n' # 原样转发给前端(已经是同样的 SSE 格式)
try:
obj = json.loads(line[len('data: '):])
except ValueError:
continue
if obj.get('type') == 'chunk' and obj.get('text'):
full_answer.append(obj['text'])
provider_used = obj.get('provider') or provider_used
elif obj.get('type') == 'done':
provider_used = obj.get('provider') or provider_used
except Exception as e:
logger.error(f"流式问答代理失败: {e}")
if not full_answer:
yield sse({"type": "error", "message": "AI 服务暂时不可用,请稍后重试"})
return
answer = ''.join(full_answer).strip() or "抱歉,暂时无法生成回答。"
logger.info(f"流式问答由 {provider_used} 提供回答(长度={len(answer)}")
db_layer.insert_chat_history(
user_question=question, ai_answer=answer,
context_summary=context_summary,
queried_date=queried_date, queried_person=queried_person)
return Response(stream_with_context(generate()), mimetype='text/event-stream; charset=utf-8',
headers={'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'})
@chat_bp.route('/api/chat/history', methods=['GET'])
def chat_history():
"""获取对话历史"""

View File

@@ -1,11 +1,42 @@
"""
配置加载器 - 从 config.yaml 读取配置
配置加载器 - 从 config.yaml 读取配置,支持 ${ENV_VAR} 解析
"""
import os
import re
import yaml
def _load_env_file():
"""加载部署目录下的 .env支持 export KEY=VALUE 格式)。
迁云前是 start_core.sh 负责 source .env 再起 gunicorn现在由 systemd 拉起,
没有那一步,所以在这里兜底加载(跟 fam-edge 的做法一致)。
已存在的环境变量不覆盖。
"""
path = os.environ.get('FAM_ENV_FILE') or os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
'.env')
if not os.path.isfile(path):
return
with open(path, 'r', encoding='utf-8') as f:
for line in f:
line = line.strip()
if not line or line.startswith('#'):
continue
if line.startswith('export '):
line = line[7:].strip()
if '=' not in line:
continue
key, _, value = line.partition('=')
key = key.strip()
value = value.strip().strip('"').strip("'")
if key and key not in os.environ:
os.environ[key] = value
_load_env_file()
def _resolve_env_vars(value):
"""递归解析字符串中的 ${ENV_VAR} 引用"""
if isinstance(value, str):

View File

@@ -1,504 +1,356 @@
"""
数据库访问层 - MariaDB 连接管理与同步镜像 CRUD
DB-Layer - 直读 fam-edge 的 SQLite 库2026-09-13 迁云重写)
新架构 (2026-08-21 重构):
NAS 不再处理视频,仅作为管理后台。
Oracle (FAM-Edge) 处理整视频分析后存 SQLiteNAS 每 30 分钟拉增量,
镜像到本地三张表:
sync_videos : 视频会话(全局摘要 + 事件 JSON + 人物 JSON
sync_events : 视频拆出的时间点事件(描述 + 涉及人物 + 是否关注)
sync_people : 规范人物表label + canonical_nameOracle 维护)
sync_cursor : 同步游标(上次成功拉取到的 server_time
**这个文件以前是什么样**fam-core 跑在 NAS 上,每 30 分钟把甲骨文的数据拉一份
镜像进 MariaDB`sync_videos`/`sync_events`/…前端读镜像。725 行里有一半是
镜像 upsert 的去重逻辑——8/29 和 9/3 两次线上事故1062 主键冲突、游标卡死)
都出在那一半。
本层只服务同步镜像 + 问答历史,旧 process_tasks/event_details/monitor_events/
family_members 相关逻辑已全部移除(视频处理职责已迁移至 Oracle
**现在**fam-core 跟 fam-edge 同机,直接打开它的 SQLite 库读,镜像层整个不存在了。
连带消失的还有一个隐蔽 bug镜像表为了保外键稳定用的是 NAS 本地自增 id而帧图
接口要的是甲骨文的 id两边在 9/3 那次 id 重排后就对不上了。现在只有一套 id。
要点:
- 库文件是 fam-edge 的(`database.path`**本模块只读它写的表**,唯一写的表是
`chat_history`(问答历史,迁云时从 NAS MariaDB 搬过来的fam-edge 不碰)。
- fam-edge 那边开了 WAL读不会阻塞它的写这边同样设 busy_timeout 兜底。
- 视频/人物的写操作(改名、删除)不在这里做,走 `edge_client` 打给 fam-edge——
它除了改库还要合并人物、删磁盘素材。
"""
import json
import pymysql
from datetime import datetime
from typing import Optional, List, Dict, Any
import sqlite3
from datetime import datetime, timedelta, timezone
from typing import Dict, List, Optional
from .config_loader import load_config
from .logger import setup_logger
logger = setup_logger('fam-core.db')
_config = None
_db_path = None
_chat_schema_ready = False
def get_config():
global _config
if _config is None:
_config = load_config()
return _config
def _now() -> str:
return datetime.now(timezone(timedelta(hours=8))).strftime('%Y-%m-%d %H:%M:%S')
def get_conn():
"""获取数据库连接(单 worker gunicorn无需连接池"""
cfg = get_config().get('database', {})
kwargs = dict(
host=cfg.get('host', '127.0.0.1'),
port=cfg.get('port', 3306),
user=cfg.get('user', 'root'),
password=cfg.get('password', ''),
database=cfg.get('database', 'sentinel_home_ai'),
charset='utf8mb4',
autocommit=False
)
unix_socket = cfg.get('unix_socket')
if unix_socket:
kwargs['unix_socket'] = unix_socket
return pymysql.connect(**kwargs)
def get_config() -> Dict:
return load_config()
# ============================================================
# 同步镜像sync_videos
# ============================================================
def upsert_sync_videos(rows: List[Dict]) -> int:
"""批量 upsert Oracle 传来的 videos 增量。rows 为 Oracle 端 dict 列表。"""
if not rows:
return 0
conn = get_conn()
n = 0
try:
cur = conn.cursor()
for r in rows:
cur.execute(
"""INSERT INTO sync_videos
(id, drive_file_id, filename, camera_name, duration_sec,
event_start_time, status, summary_json, events_json,
people_json, compute_provider, created_at, updated_at,
processed_at, synced_at)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s, NOW())
ON DUPLICATE KEY UPDATE
drive_file_id=VALUES(drive_file_id),
filename=VALUES(filename),
camera_name=VALUES(camera_name),
duration_sec=VALUES(duration_sec),
event_start_time=VALUES(event_start_time),
status=VALUES(status),
summary_json=VALUES(summary_json),
events_json=VALUES(events_json),
people_json=VALUES(people_json),
compute_provider=VALUES(compute_provider),
created_at=VALUES(created_at),
updated_at=VALUES(updated_at),
processed_at=VALUES(processed_at),
synced_at=NOW()""",
(r.get('id'), r.get('drive_file_id'), r.get('filename'),
r.get('camera_name'), r.get('duration_sec') or 0,
r.get('event_start_time'), r.get('status'),
r.get('summary_json'), r.get('events_json'), r.get('people_json'),
r.get('compute_provider'), r.get('created_at'),
r.get('updated_at'), r.get('processed_at'))
)
n += 1
conn.commit()
return n
finally:
conn.close()
def _path() -> str:
global _db_path
if _db_path is None:
_db_path = load_config().get('database', {}).get(
'path', '/opt/fam-edge/data/oracle.db')
return _db_path
def get_sync_videos(limit=15, offset=0, date_filter=None) -> List[Dict]:
"""获取视频会话列表(已完成优先),支持日期筛选与分页
def get_conn() -> sqlite3.Connection:
"""每次调用开一个连接(请求级,跟改写前的 MySQL 用法一致)
排序/日期维度按视频实际录制时间event_start_time文件名解析
为空回退 processed_at/updated_at/created_at
date_filter 形如 '2026-08-21'
busy_timeoutfam-edge 的写事务提交时会短暂持锁,这里等而不是立刻报
`database is locked`9/3 那次 Oracle 过载时刷过一片这个错)
"""
conn = sqlite3.connect(_path(), timeout=10)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA busy_timeout=10000")
return conn
def _rows(cur) -> List[Dict]:
return [dict(r) for r in cur.fetchall()]
def _row(cur) -> Optional[Dict]:
r = cur.fetchone()
return dict(r) if r else None
# 人物命中判断person_list_json 是 JSON 数组文本,用 json_each 展开精确匹配。
# 必须先 json_valid——历史数据里有非 JSON 的脏值,直接 json_each 会整条查询报错。
_PERSON_HIT = ("{col} IS NOT NULL AND json_valid({col}) "
"AND EXISTS(SELECT 1 FROM json_each({col}) WHERE json_each.value = ?)")
# 日期维度:录制时间优先(文件名解析出来的),回退分析时间
_DATE_EXPR = "COALESCE(NULLIF({a}.event_start_time,''), {a}.processed_at, {a}.updated_at, {a}.created_at)"
# 只展示"有内容"的会话:运动片段,或含事件的视频。
# 整段素材分割 0 段的空会话(历史素材无运动事件)不展示,避免淹没时间轴。
_CONTENT_FILTER = ("substr(v.filename, 1, 7) = 'motion_' "
"OR EXISTS(SELECT 1 FROM events se WHERE se.video_id = v.id)")
# ============================================================
# videos / events
# ============================================================
def get_videos(limit=15, offset=0, date_filter=None) -> List[Dict]:
"""视频会话列表(事件时间轴左侧),按录制时间倒序,支持日期筛选与分页。"""
d = _DATE_EXPR.format(a='v')
sql = f"""SELECT v.id, v.filename, v.camera_name, v.event_start_time, v.status,
v.summary_json, v.events_json, v.people_json, v.compute_provider,
v.processed_at, v.updated_at,
(SELECT COUNT(*) FROM events se WHERE se.video_id = v.id) AS event_count
FROM videos v
WHERE v.status='done' AND ({_CONTENT_FILTER}) {{extra}}
ORDER BY {d} DESC
LIMIT ? OFFSET ?"""
conn = get_conn()
try:
cur = conn.cursor(pymysql.cursors.DictCursor)
# 录制时间优先,回退分析时间
date_expr = "COALESCE(NULLIF(event_start_time,''), processed_at, updated_at, created_at)"
if date_filter:
cur.execute(
f"""SELECT id, filename, camera_name, event_start_time, status,
summary_json, events_json, people_json, compute_provider,
processed_at, updated_at,
(SELECT COUNT(*) FROM sync_events se WHERE se.video_id = sync_videos.id) AS event_count
FROM sync_videos
WHERE status='done' AND {date_expr} LIKE %s
ORDER BY {date_expr} DESC
LIMIT %s OFFSET %s""",
(f'{date_filter}%', limit, offset))
cur = conn.execute(sql.format(extra=f"AND {d} LIKE ?"),
(f'{date_filter}%', limit, offset))
else:
cur.execute(
f"""SELECT id, filename, camera_name, event_start_time, status,
summary_json, events_json, people_json, compute_provider,
processed_at, updated_at,
(SELECT COUNT(*) FROM sync_events se WHERE se.video_id = sync_videos.id) AS event_count
FROM sync_videos
WHERE status='done'
ORDER BY {date_expr} DESC
LIMIT %s OFFSET %s""",
(limit, offset))
return cur.fetchall()
cur = conn.execute(sql.format(extra=''), (limit, offset))
return _rows(cur)
finally:
conn.close()
def get_sync_video(video_id: int) -> Optional[Dict]:
def get_video(video_id: int) -> Optional[Dict]:
conn = get_conn()
try:
cur = conn.cursor(pymysql.cursors.DictCursor)
cur.execute("SELECT * FROM sync_videos WHERE id=%s", (video_id,))
return cur.fetchone()
return _row(conn.execute("SELECT * FROM videos WHERE id=?", (video_id,)))
finally:
conn.close()
# ============================================================
# 同步镜像sync_events
# ============================================================
def upsert_sync_events(rows: List[Dict]) -> int:
"""批量 upsert Oracle 传来的 events 增量。"""
if not rows:
return 0
conn = get_conn()
n = 0
try:
cur = conn.cursor()
for r in rows:
cur.execute(
"""INSERT INTO sync_events
(id, video_id, ts, description, person_list_json,
person_appearances_json, is_attention_event,
updated_at, synced_at)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s, NOW())
ON DUPLICATE KEY UPDATE
video_id=VALUES(video_id),
ts=VALUES(ts),
description=VALUES(description),
person_list_json=VALUES(person_list_json),
person_appearances_json=VALUES(person_appearances_json),
is_attention_event=VALUES(is_attention_event),
updated_at=VALUES(updated_at),
synced_at=NOW()""",
(r.get('id'), r.get('video_id'), r.get('ts'), r.get('description'),
r.get('person_list_json'), r.get('person_appearances_json'),
1 if r.get('is_attention_event') else 0,
r.get('updated_at'))
)
n += 1
conn.commit()
return n
finally:
conn.close()
def get_sync_events_for_video(video_id: int) -> List[Dict]:
def get_events_for_video(video_id: int) -> List[Dict]:
conn = get_conn()
try:
cur = conn.cursor(pymysql.cursors.DictCursor)
cur.execute(
return _rows(conn.execute(
"""SELECT id, video_id, ts, description, person_list_json,
is_attention_event, updated_at
FROM sync_events WHERE video_id=%s ORDER BY ts ASC""",
(video_id,))
return cur.fetchall()
is_attention_event
FROM events WHERE video_id=? ORDER BY ts ASC""", (video_id,)))
finally:
conn.close()
def query_sync_events_for_person_date(person: str, date_str: str) -> List[Dict]:
def get_attention_events(limit: int = 200) -> List[Dict]:
"""需关注事件(统计图表页用),按录制日期倒序。"""
conn = get_conn()
try:
return _rows(conn.execute(
"""SELECT COALESCE(NULLIF(v.event_start_time,''), v.processed_at) AS ev_date,
e.person_list_json
FROM events e JOIN videos v ON e.video_id=v.id
WHERE e.is_attention_event = 1
ORDER BY ev_date DESC LIMIT ?""", (limit,)))
finally:
conn.close()
def get_people_clips(label: str, limit: int = 10) -> List[Dict]:
"""某人物出现过的运动片段列表。
匹配 label 本身 + 同一 canonical_name 下的全部 label返回含 first_ts
(该人物在片段内最早事件时间点,前端缩略图定位用)与 clip_events。
"""
hit_e = _PERSON_HIT.format(col='e.person_list_json')
hit_e2 = _PERSON_HIT.format(col='e2.person_list_json')
hit_e3 = _PERSON_HIT.format(col='e3.person_list_json')
conn = get_conn()
try:
labels = {label}
for r in _rows(conn.execute(
"SELECT label, canonical_name FROM people WHERE label=? OR canonical_name=?",
(label, label))):
if r.get('canonical_name'):
labels.update(x['label'] for x in _rows(conn.execute(
"SELECT label FROM people WHERE canonical_name=?", (r['canonical_name'],))))
clips, seen = [], set()
for lb in sorted(labels):
cur = conn.execute(
f"""SELECT DISTINCT v.id AS video_id, v.filename, v.event_start_time,
v.duration_sec, v.summary_json, v.camera_name,
(SELECT MIN(e2.ts) FROM events e2
WHERE e2.video_id=v.id AND {hit_e2}) AS first_ts,
(SELECT COUNT(*) FROM events e3
WHERE e3.video_id=v.id AND {hit_e3}) AS clip_events
FROM events e JOIN videos v ON e.video_id=v.id
WHERE {hit_e} AND v.status='done'""",
(lb, lb, lb))
for row in _rows(cur):
if row['video_id'] not in seen:
seen.add(row['video_id'])
clips.append(row)
clips.sort(key=lambda x: x.get('event_start_time') or '', reverse=True)
return clips[:int(limit)]
finally:
conn.close()
def query_events_for_person_date(person: str, date_str: str) -> List[Dict]:
"""问答上下文:某人在某天的事件。
说明: Oracle 事件 ts 视频内相对时间点(如 00:01:23不是绝对日期
因此按所属视频的录制日期event_start_time回退 processed_at过滤
再按 person_list_json 命中人名。
person 可为真名或抽象标签Oracle 回灌上下文用真名,但历史标签也保留)。
事件 ts 视频内相对时间点(如 00:01:23不是绝对日期所以按所属视频的
录制日期过滤,再按 person_list_json 命中人名。person 可为真名或抽象标签。
"""
hit = _PERSON_HIT.format(col='e.person_list_json')
conn = get_conn()
try:
cur = conn.cursor(pymysql.cursors.DictCursor)
cur.execute(
"""SELECT e.ts, e.description, e.person_list_json, e.is_attention_event,
v.camera_name, v.filename, v.event_start_time, v.processed_at
FROM sync_events e
JOIN sync_videos v ON e.video_id = v.id
WHERE COALESCE(NULLIF(v.event_start_time,''), v.processed_at) LIKE %s
AND e.person_list_json IS NOT NULL
AND JSON_CONTAINS(e.person_list_json, JSON_QUOTE(%s), '$')
ORDER BY COALESCE(NULLIF(v.event_start_time,''), v.processed_at) ASC, e.ts ASC""",
(f'{date_str}%', person))
return cur.fetchall()
return _rows(conn.execute(
f"""SELECT e.ts, e.description, e.person_list_json, e.is_attention_event,
v.camera_name, v.filename, v.event_start_time, v.processed_at
FROM events e JOIN videos v ON e.video_id = v.id
WHERE COALESCE(NULLIF(v.event_start_time,''), v.processed_at) LIKE ?
AND {hit}
ORDER BY COALESCE(NULLIF(v.event_start_time,''), v.processed_at) ASC, e.ts ASC""",
(f'{date_str}%', person)))
finally:
conn.close()
# ============================================================
# 同步镜像sync_people
# people
# ============================================================
def upsert_sync_people(rows: List[Dict]) -> int:
"""批量 upsert Oracle 传来的 people 增量。"""
if not rows:
return 0
conn = get_conn()
n = 0
try:
cur = conn.cursor()
for r in rows:
cur.execute(
"""INSERT INTO sync_people
(id, label, canonical_name, first_seen, appearances,
source, features_json, display_uid, updated_at, synced_at)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s, NOW())
ON DUPLICATE KEY UPDATE
label=VALUES(label),
canonical_name=VALUES(canonical_name),
first_seen=VALUES(first_seen),
appearances=VALUES(appearances),
source=VALUES(source),
features_json=VALUES(features_json),
display_uid=VALUES(display_uid),
updated_at=VALUES(updated_at),
synced_at=NOW()""",
(r.get('id'), r.get('label'), r.get('canonical_name'),
r.get('first_seen'), r.get('appearances') or 0,
r.get('source'), r.get('features_json'),
r.get('display_uid'), r.get('updated_at')))
n += 1
conn.commit()
return n
finally:
conn.close()
def get_sync_people() -> List[Dict]:
def get_people() -> List[Dict]:
conn = get_conn()
try:
cur = conn.cursor(pymysql.cursors.DictCursor)
cur.execute(
return _rows(conn.execute(
"SELECT id, label, canonical_name, first_seen, appearances, source, "
"features_json, display_uid, updated_at "
"FROM sync_people ORDER BY id ASC")
return cur.fetchall()
"features_json, display_uid, updated_at FROM people ORDER BY id ASC"))
finally:
conn.close()
def upsert_sync_model_calls(rows: List[Dict]) -> int:
"""批量 upsert Oracle 传来的 model_calls 增量(幂等,重复覆盖)。"""
if not rows:
return 0
def get_named_members() -> List[str]:
"""已命名成员的真名列表UI 下拉用)。"""
conn = get_conn()
n = 0
try:
cur = conn.cursor()
for r in rows:
cur.execute(
"""INSERT INTO sync_model_calls
(id, provider, model, video_id, filename, started_at,
duration_sec, success, error, created_at, synced_at)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s, NOW())
ON DUPLICATE KEY UPDATE
provider=VALUES(provider),
model=VALUES(model),
video_id=VALUES(video_id),
filename=VALUES(filename),
started_at=VALUES(started_at),
duration_sec=VALUES(duration_sec),
success=VALUES(success),
error=VALUES(error),
created_at=VALUES(created_at),
synced_at=NOW()""",
(r.get('id'), r.get('provider'), r.get('model'),
r.get('video_id'), r.get('filename'), r.get('started_at'),
r.get('duration_sec') or 0, 1 if r.get('success') else 0,
(r.get('error') or '')[:500], r.get('created_at')))
n += 1
conn.commit()
return n
return [r['canonical_name'] for r in _rows(conn.execute(
"SELECT DISTINCT canonical_name FROM people "
"WHERE canonical_name IS NOT NULL AND canonical_name != '' "
"ORDER BY canonical_name ASC"))]
finally:
conn.close()
def get_sync_model_calls(limit: int = 200) -> List[Dict]:
"""最近模型调用记录(前端统计展示)"""
def get_known_members_context() -> str:
"""人物清单文本,注入问答 Prompt让模型用真名指代"""
rows = get_people()
parts = []
for r in rows:
name = r['canonical_name'] or r['label']
if r['canonical_name'] and r['canonical_name'] != r['label']:
parts.append(f"- {name}(标识:{r['label']}")
else:
parts.append(f"- {name}")
return "\n".join(parts)
# ============================================================
# model_calls
# ============================================================
def get_model_calls(limit: int = 200) -> List[Dict]:
conn = get_conn()
try:
cur = conn.cursor(pymysql.cursors.DictCursor)
cur.execute(
return _rows(conn.execute(
"SELECT id, provider, model, video_id, filename, started_at, "
"duration_sec, success, error, created_at "
"FROM sync_model_calls ORDER BY id DESC LIMIT %s", (limit,))
return cur.fetchall()
"FROM model_calls ORDER BY id DESC LIMIT ?", (limit,)))
finally:
conn.close()
def get_sync_model_calls_stats() -> Dict:
"""模型调用统计:按 provider+model 聚合成功/失败/平均耗时。"""
def get_model_calls_stats() -> List[Dict]:
"""按 provider+model 聚合成功/失败/平均耗时。"""
conn = get_conn()
try:
cur = conn.cursor(pymysql.cursors.DictCursor)
cur.execute(
return _rows(conn.execute(
"""SELECT provider, model,
SUM(CASE WHEN success=1 THEN 1 ELSE 0 END) AS ok_cnt,
SUM(CASE WHEN success=0 THEN 1 ELSE 0 END) AS fail_cnt,
ROUND(AVG(duration_sec), 1) AS avg_duration,
MAX(created_at) AS last_call
FROM sync_model_calls
GROUP BY provider, model ORDER BY provider, model""")
return cur.fetchall()
finally:
conn.close()
def get_sync_named_members() -> List[str]:
"""已命名成员的真名列表(供 UI 下拉 / 快捷选择)。"""
conn = get_conn()
try:
cur = conn.cursor(pymysql.cursors.DictCursor)
cur.execute(
"SELECT DISTINCT canonical_name FROM sync_people "
"WHERE canonical_name IS NOT NULL AND canonical_name != '' "
"ORDER BY canonical_name ASC")
return [r['canonical_name'] for r in cur.fetchall()]
finally:
conn.close()
def get_sync_known_members_context() -> str:
"""获取人物清单文本,注入问答 Prompt让模型用真名指代。"""
conn = get_conn()
try:
cur = conn.cursor(pymysql.cursors.DictCursor)
cur.execute(
"SELECT label, canonical_name FROM sync_people ORDER BY id ASC")
rows = cur.fetchall()
if not rows:
return ""
parts = []
for r in rows:
name = r['canonical_name'] or r['label']
if r['canonical_name'] and r['canonical_name'] != r['label']:
parts.append(f"- {name}(标识:{r['label']}")
else:
parts.append(f"- {name}")
return "\n".join(parts)
FROM model_calls GROUP BY provider, model ORDER BY provider, model"""))
finally:
conn.close()
# ============================================================
# 同步游标
# 概览统计
# ============================================================
def get_sync_cursor() -> str:
def get_stats(date_str: str = None) -> Dict:
"""视频数 / 事件数 / 关注事件数 / 出现人物数(可按日期过滤)。"""
d = _DATE_EXPR.format(a='sv')
conn = get_conn()
try:
cur = conn.cursor()
cur.execute("SELECT `value` FROM sync_cursor WHERE `key`='last_since'")
row = cur.fetchone()
return row[0] if row else ''
finally:
conn.close()
def set_sync_cursor(value: str):
conn = get_conn()
try:
cur = conn.cursor()
cur.execute(
"""INSERT INTO sync_cursor (`key`, `value`) VALUES ('last_since', %s)
ON DUPLICATE KEY UPDATE `value`=VALUES(`value`)""",
(value,))
conn.commit()
finally:
conn.close()
# ============================================================
# 统计
# ============================================================
def get_attention_events(limit: int = 200) -> List[Dict]:
"""需关注事件列表(供统计图表页展示日期 + 涉及人物),按录制日期倒序。"""
conn = get_conn()
try:
cur = conn.cursor(pymysql.cursors.DictCursor)
cur.execute(
"""SELECT COALESCE(NULLIF(v.event_start_time,''), v.processed_at) AS ev_date,
e.person_list_json
FROM sync_events e JOIN sync_videos v ON e.video_id=v.id
WHERE e.is_attention_event = 1
ORDER BY ev_date DESC LIMIT %s""",
(limit,))
return cur.fetchall()
finally:
conn.close()
def get_sync_stats(date_str: str = None) -> Dict:
"""概览统计:视频数 / 事件数 / 关注事件数 / 出现人物数(按 date 可选过滤)。
人物数通过对 sync_events.person_list_json LIKE 统计MariaDB 不支持 JSON 数组展开)。
"""
conn = get_conn()
try:
cur = conn.cursor(pymysql.cursors.DictCursor)
# 视频/事件/关注数(日期维度=录制时间 event_start_time回退 processed_at
_D = "COALESCE(NULLIF(sv.event_start_time,''), sv.processed_at)"
if date_str:
cur.execute(
"""SELECT
COUNT(*) AS videos,
(SELECT COUNT(*) FROM sync_events se
JOIN sync_videos sv ON se.video_id=sv.id
WHERE {d} LIKE %s) AS events,
(SELECT COALESCE(SUM(se.is_attention_event),0) FROM sync_events se
JOIN sync_videos sv ON se.video_id=sv.id
WHERE {d} LIKE %s) AS attention
FROM sync_videos sv WHERE {d} LIKE %s""".format(d=_D),
(f'{date_str}%', f'{date_str}%', f'{date_str}%'))
like = f'{date_str}%'
stat = _row(conn.execute(
f"""SELECT
(SELECT COUNT(*) FROM videos sv
WHERE sv.status='done'
AND (substr(sv.filename,1,7)='motion_'
OR EXISTS(SELECT 1 FROM events se WHERE se.video_id=sv.id))
AND {d} LIKE ?) AS videos,
(SELECT COUNT(*) FROM events se JOIN videos sv ON se.video_id=sv.id
WHERE {d} LIKE ?) AS events,
(SELECT COALESCE(SUM(se.is_attention_event),0) FROM events se
JOIN videos sv ON se.video_id=sv.id
WHERE {d} LIKE ?) AS attention""",
(like, like, like))) or {}
else:
cur.execute(
stat = _row(conn.execute(
"""SELECT
(SELECT COUNT(*) FROM sync_videos WHERE status='done') AS videos,
(SELECT COUNT(*) FROM sync_events) AS events,
(SELECT COALESCE(SUM(is_attention_event),0) FROM sync_events) AS attention""")
stat = cur.fetchone() or {}
(SELECT COUNT(*) FROM videos sv
WHERE sv.status='done'
AND (substr(sv.filename,1,7)='motion_'
OR EXISTS(SELECT 1 FROM events se WHERE se.video_id=sv.id))) AS videos,
(SELECT COUNT(*) FROM events) AS events,
(SELECT COALESCE(SUM(is_attention_event),0) FROM events) AS attention""")) or {}
# 人物数distinct label 命中 sync_events
cur.execute("SELECT id, label, canonical_name FROM sync_people")
people = cur.fetchall()
# 基于 person_list_json 命中计数:逐 label 统计命中事件数
person_hits = 0
for p in people:
# 出现人物数:逐 label/真名在事件里查有没有命中(沿用改写前的口径
hits = 0
for p in _rows(conn.execute("SELECT label, canonical_name FROM people")):
name = p['canonical_name'] or p['label']
cur.execute(
"SELECT COUNT(*) c FROM sync_events WHERE person_list_json LIKE %s",
(f'%{name}%',))
if cur.fetchone()['c'] > 0:
person_hits += 1
stat['people'] = person_hits
c = conn.execute("SELECT COUNT(*) AS c FROM events "
"WHERE person_list_json LIKE ?", (f'%{name}%',)).fetchone()
if c and c['c'] > 0:
hits += 1
stat['people'] = hits
return stat
finally:
conn.close()
# ============================================================
# chat_history保留:问答历史
# chat_history本模块唯一写的表2026-09-13 从 NAS MariaDB 迁入
# ============================================================
def _ensure_chat_schema(conn):
global _chat_schema_ready
if _chat_schema_ready:
return
conn.executescript("""
CREATE TABLE IF NOT EXISTS chat_history (
chat_id INTEGER PRIMARY KEY AUTOINCREMENT,
user_question TEXT NOT NULL,
ai_answer TEXT NOT NULL,
context_summary TEXT,
queried_date TEXT,
queried_person TEXT,
created_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_chat_created ON chat_history(created_at);
""")
conn.commit()
_chat_schema_ready = True
def insert_chat_history(user_question: str, ai_answer: str,
context_summary: str, queried_date: str,
queried_person: str) -> int:
"""插入对话记录"""
conn = get_conn()
try:
cur = conn.cursor()
cur.execute(
_ensure_chat_schema(conn)
cur = conn.execute(
"""INSERT INTO chat_history
(user_question, ai_answer, context_summary, queried_date, queried_person)
VALUES (%s, %s, %s, %s, %s)""",
(user_question, ai_answer, context_summary, queried_date, queried_person)
)
(user_question, ai_answer, context_summary, queried_date,
queried_person, created_at)
VALUES (?, ?, ?, ?, ?, ?)""",
(user_question, ai_answer, context_summary, queried_date,
queried_person, _now()))
conn.commit()
return cur.lastrowid
finally:
@@ -506,24 +358,20 @@ def insert_chat_history(user_question: str, ai_answer: str,
def get_chat_history(limit=20, offset=0, date_filter=None, person_filter=None) -> List[Dict]:
"""获取对话历史(分页)"""
conn = get_conn()
try:
cur = conn.cursor(pymysql.cursors.DictCursor)
conditions = []
params = []
_ensure_chat_schema(conn)
conds, params = [], []
if date_filter:
conditions.append("queried_date = %s")
conds.append("queried_date = ?")
params.append(date_filter)
if person_filter:
conditions.append("queried_person = %s")
conds.append("queried_person = ?")
params.append(person_filter)
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
where = f"WHERE {' AND '.join(conds)}" if conds else ""
params.extend([limit, offset])
cur.execute(
f"SELECT * FROM chat_history {where} ORDER BY created_at DESC LIMIT %s OFFSET %s",
params
)
return cur.fetchall()
return _rows(conn.execute(
f"SELECT * FROM chat_history {where} ORDER BY created_at DESC LIMIT ? OFFSET ?",
params))
finally:
conn.close()

View File

@@ -0,0 +1,102 @@
"""
Edge-Client - 访问同机 fam-edge 的薄客户端2026-09-13 迁云后新增)
迁云之前这些调用散在 `oracle_sync.py` 里NAS 跨公网访问甲骨文),随镜像层一起
删掉了。现在 fam-core 与 fam-edge 同机,全部走 127.0.0.1:不出公网、无 TLS、
无隧道token 仍然带着fam-edge 侧的鉴权没变,且它同时对公网监听 :5000
只保留三类调用:
- push_name_correct / push_video_delete —— 写操作仍由 fam-edge 处理,因为它
除了改库还要做人物合并、删磁盘素材,不是单纯一条 SQL
- get_activity —— 服务状态页
- fetch_image —— 事件帧图 / 人物头像(图源和裁剪都在 fam-edge
"""
import requests
from .config_loader import load_config
from .logger import setup_logger
logger = setup_logger('fam-core.edge_client')
_cfg = None
def _conf():
global _cfg
if _cfg is None:
c = load_config().get('edge', {})
_cfg = {
'base_url': (c.get('base_url') or 'http://127.0.0.1:5000').rstrip('/'),
'token': c.get('token') or '',
'timeout': int(c.get('timeout', 60)),
}
return _cfg
def base_url():
return _conf()['base_url']
def token():
return _conf()['token']
def _post(rel: str, payload: dict):
"""返回 (ok, err)。写操作统一用这个,失败时把原因带回给前端。"""
c = _conf()
body = dict(payload)
if c['token']:
body['token'] = c['token']
try:
resp = requests.post(f"{c['base_url']}{rel}", json=body, timeout=(5, c['timeout']))
except requests.RequestException as e:
logger.error(f"fam-edge {rel} 请求失败: {e}")
return False, str(e)
if resp.status_code != 200:
logger.warning(f"fam-edge {rel} 返回 {resp.status_code}: {resp.text[:160]}")
return False, f"HTTP {resp.status_code} {resp.text[:120]}"
return True, ''
def push_name_correct(label: str, canonical_name: str):
return _post('/api/oracle/people/correct',
{'label': label, 'canonical_name': canonical_name})
def push_identity_correct(video_id: int, current_name: str, new_name: str):
return _post('/api/oracle/identity/correct',
{'video_id': video_id, 'current_name': current_name, 'new_name': new_name})
def push_video_delete(video_id: int):
return _post('/api/oracle/video/delete', {'video_id': video_id})
def get_activity():
"""服务状态页用fam-edge 的队列 / 分割 / 模型活动快照。返回 (data, err)。"""
c = _conf()
try:
r = requests.get(f"{c['base_url']}/api/oracle/activity",
params={'token': c['token']}, timeout=(5, 15))
except requests.RequestException as e:
return None, f"连接 fam-edge 失败: {e}"
if r.status_code != 200:
return None, f"fam-edge activity HTTP {r.status_code}"
return r.json(), None
def fetch_image(rel: str, params: dict):
"""帧图 / 头像透传,失败返回 None调用方给 404"""
c = _conf()
p = dict(params)
if c['token']:
p['token'] = c['token']
try:
resp = requests.get(f"{c['base_url']}{rel}", params=p, timeout=(5, c['timeout']))
except requests.RequestException as e:
logger.error(f"fam-edge {rel} 请求失败: {e}")
return None
if resp.status_code == 200 and resp.content:
return resp.content
logger.warning(f"fam-edge {rel} 返回 {resp.status_code}")
return None

View File

@@ -1,14 +1,12 @@
"""
Img-Proxy - NAS 端图片代理(图源在 Oracle计算全部在 Oracle
Img-Proxy - 图片代理(图源与裁剪都在 fam-edge
浏览器不直连 Oracle避免公网暴露 5000 端口与 token 外泄),
而是访问 NAS fam-core 的 /api/proxy/*,由 NAS 出网到 Oracle 拉取 jpeg 回传
Oracle 侧已有磁盘缓存 / VLM 人物定位裁剪NAS 端仅透传,不做图像计算。
浏览器不直连 fam-edge避免 token 外泄),而是访问 /api/proxy/*,由本服务转一手。
迁云后这一跳是同机 127.0.0.1,纯透传,不做图像计算
"""
from flask import Blueprint, Response, request
from .oracle_sync import get_sync
from . import edge_client
from .logger import setup_logger
logger = setup_logger('fam-core.img_proxy')
@@ -17,21 +15,7 @@ img_bp = Blueprint('img_proxy', __name__)
def _fetch(rel: str, params: dict):
import requests
sync = get_sync() # 复用 oracle_sync 已解析好的 base_url/token/timeout避免两处配置各读一份
params = dict(params)
if sync.token:
params['token'] = sync.token
try:
resp = requests.get(f"{sync.base_url}{rel}", params=params,
timeout=(10, sync.timeout))
if resp.status_code == 200 and resp.content:
return resp.content
logger.warning(f"Oracle {rel} 返回 {resp.status_code}: {resp.text[:120]}")
return None
except Exception as e:
logger.error(f"Oracle {rel} 请求失败: {e}")
return None
return edge_client.fetch_image(rel, params)
@img_bp.route('/api/proxy/frame', methods=['GET'])

View File

@@ -14,7 +14,7 @@ from flask import Blueprint, request, jsonify
from ..logger import setup_logger
from .. import db_layer
from ..oracle_sync import get_sync
from .. import edge_client
logger = setup_logger('fam-core.member_manager')
@@ -24,7 +24,7 @@ member_bp = Blueprint('member_manager', __name__)
@member_bp.route('/api/member/unnamed', methods=['GET'])
def list_unnamed():
"""列出未命名人物canonical_name 为空)"""
members = db_layer.get_sync_people()
members = db_layer.get_people()
result = []
for m in members:
canonical = m.get('canonical_name')
@@ -40,7 +40,7 @@ def list_unnamed():
@member_bp.route('/api/member/list', methods=['GET'])
def list_members():
"""列出所有人物(按 canonical_name 或 label 展示)"""
members = db_layer.get_sync_people()
members = db_layer.get_people()
result = []
for m in members:
canonical = m.get('canonical_name')
@@ -58,7 +58,7 @@ def list_members():
@member_bp.route('/api/member/name', methods=['POST'])
def name_member():
"""命名人物(回推 Oracle + 立即拉回本地镜像
"""命名人物(交给 fam-edge 落库 + 合并人物
请求: {"label": "人物A", "canonical_name": "张三"}
"""
@@ -71,18 +71,13 @@ def name_member():
if not label or not canonical_name:
return jsonify({"error": "缺少必填字段: label, canonical_name"}), 400
logger.info(f"命名: {label} -> {canonical_name}(回推 Oracle")
ok, err = get_sync().push_name_correct(label, canonical_name)
logger.info(f"命名: {label} -> {canonical_name}(回推 fam-edge")
ok, err = edge_client.push_name_correct(label, canonical_name)
if not ok:
return jsonify({"error": f"回推 Oracle 失败: {err}"}), 502
return jsonify({"error": f"回推 fam-edge 失败: {err}"}), 502
# 立即拉回最新 people 镜像,前端无需等待下一个 30 分钟周期
try:
get_sync().trigger_now()
except Exception as e:
logger.warning(f"命名后即时拉回失败(下一个周期会自动同步): {e}")
members = db_layer.get_sync_people()
members = db_layer.get_people()
return jsonify({
"status": "ok",
"label": label,
@@ -114,24 +109,20 @@ def merge_member():
return jsonify({"error": "source 与 target 不能相同"}), 400
# 解析 target 的规范名
members = {m['label']: m for m in db_layer.get_sync_people()}
members = {m['label']: m for m in db_layer.get_people()}
target_row = members.get(target)
if target_row and target_row.get('canonical_name'):
canonical = target_row['canonical_name']
else:
canonical = target # target 未命名 -> 以 label 作为规范名
logger.info(f"合并: {source} -> {canonical}(回推 Oracle")
ok, err = get_sync().push_name_correct(source, canonical)
logger.info(f"合并: {source} -> {canonical}(回推 fam-edge")
ok, err = edge_client.push_name_correct(source, canonical)
if not ok:
return jsonify({"error": f"回推 Oracle 失败: {err}"}), 502
return jsonify({"error": f"回推 fam-edge 失败: {err}"}), 502
try:
get_sync().trigger_now()
except Exception as e:
logger.warning(f"合并后即时拉回失败(下一个周期会自动同步): {e}")
members = db_layer.get_sync_people()
members = db_layer.get_people()
return jsonify({
"status": "ok",
"source": source,
@@ -142,3 +133,34 @@ def merge_member():
"display_name": m.get('canonical_name') or m['label'],
} for m in members]
}), 200
@member_bp.route('/api/member/identity-correct', methods=['POST'])
def identity_correct():
"""事件时间轴"这个人识别错了"纠错入口(比 /api/member/name 粒度更细)。
请求: {"video_id": 123, "current_name": "爷爷", "new_name": "爸爸"}
只改这一段视频里被错误识别的那个人,不影响同名字符串在其他视频里的映射
(人物 uid 只在单次视频分析内稳定,同一字符串在不同视频里可能是不同真人,
不能像 /api/member/name 那样按全局 label 改)。
"""
data = request.get_json(silent=True)
if not data:
return jsonify({"error": "Invalid JSON"}), 400
video_id = data.get('video_id')
current_name = (data.get('current_name') or '').strip()
new_name = (data.get('new_name') or '').strip()
if not video_id or not current_name or not new_name:
return jsonify({"error": "缺少必填字段: video_id, current_name, new_name"}), 400
logger.info(f"人物纠错: video_id={video_id} {current_name} -> {new_name}(回推 fam-edge")
ok, err = edge_client.push_identity_correct(video_id, current_name, new_name)
if not ok:
return jsonify({"error": f"回推 fam-edge 失败: {err}"}), 502
return jsonify({
"status": "ok", "video_id": video_id,
"current_name": current_name, "new_name": new_name,
}), 200

View File

@@ -1,178 +0,0 @@
"""
Oracle-Sync - NAS 端唯一后台线程
职责:
1. 每 interval_sec默认 1800s = 30 分钟)从甲骨文 FAM-Edge 拉取增量:
GET {base_url}/api/oracle/sync?since=<cursor>&token=<token>
返回 {videos, events, people, server_time},写入本地 MariaDB 镜像表
(sync_videos / sync_events / sync_people),并推进 sync_cursor。
2. 接收命名校正回推: POST {base_url}/api/oracle/people/correct
{label, canonical_name, token} —— 手动命名manual 优先,不被 LLM 覆盖)。
数据流向(新架构 v2:
Google 硬盘 --rclone--> 甲骨文本地 --> 整视频分析 --> Oracle SQLite
--> [本线程每 30 分钟拉增量] --> NAS MariaDB 镜像 --> fam-ui 读取展示
NAS 不再处理任何视频CPU 占用显著降低。
"""
import time
import threading
import requests
from datetime import datetime
from .logger import setup_logger
from .config_loader import load_config
from . import db_layer
logger = setup_logger('fam-core.oracle_sync')
_SYNC_INSTANCE = None
def get_sync():
"""模块级单例app.py 启动时创建并 start其余模块经此获取"""
global _SYNC_INSTANCE
if _SYNC_INSTANCE is None:
_SYNC_INSTANCE = OracleSync()
return _SYNC_INSTANCE
class OracleSync:
def __init__(self):
cfg = load_config().get('oracle_sync', {})
self.base_url = cfg.get('base_url', 'http://129.146.203.203:5000').rstrip('/')
self.token = cfg.get('token', '')
self.interval_sec = int(cfg.get('interval_sec', 1800))
self.timeout = int(cfg.get('timeout', 120))
self._running = False
self._thread = None
self._last_sync_at = None
self._last_error = None
self._last_count = None
# 拉取互斥锁trigger_now命名后即时拉回与后台 _run 并发时只允许一个执行
self._pull_lock = threading.Lock()
# ------------------------------------------------------------------
def _pull_once(self) -> bool:
"""执行一次增量拉取(加锁防并发双拉)。返回是否成功。"""
with self._pull_lock:
return self._pull_once_locked()
def _pull_once_locked(self) -> bool:
since = db_layer.get_sync_cursor() or ''
params = {'since': since, 'token': self.token}
try:
resp = requests.get(
f"{self.base_url}/api/oracle/sync",
params=params, timeout=(10, self.timeout))
except requests.RequestException as e:
self._last_error = f"请求失败: {e}"
logger.error(f"拉取同步失败: {e}")
return False
if resp.status_code == 401:
self._last_error = "token 校验失败"
logger.error("同步 token 校验失败 (401),请检查 oracle_sync.token 配置")
return False
if resp.status_code != 200:
self._last_error = f"HTTP {resp.status_code}"
logger.error(f"同步返回异常: {resp.status_code} {resp.text[:200]}")
return False
try:
data = resp.json()
except ValueError:
self._last_error = "非 JSON 响应"
logger.error("同步返回非 JSON 响应")
return False
videos = data.get('videos', []) or []
events = data.get('events', []) or []
people = data.get('people', []) or []
model_calls = data.get('model_calls', []) or []
server_time = data.get('server_time', '') or ''
n_videos = db_layer.upsert_sync_videos(videos)
n_events = db_layer.upsert_sync_events(events)
n_people = db_layer.upsert_sync_people(people)
n_calls = db_layer.upsert_sync_model_calls(model_calls)
if server_time:
db_layer.set_sync_cursor(server_time)
self._last_sync_at = datetime.now()
self._last_error = None
self._last_count = (n_videos, n_events, n_people, n_calls)
logger.info(
f"同步完成: videos+{n_videos} events+{n_events} people+{n_people} "
f"model_calls+{n_calls} since={since!r} -> server_time={server_time}")
return True
# ------------------------------------------------------------------
def push_name_correct(self, label: str, canonical_name: str):
"""回推命名校正到 Oracle手动命名优先级最高不被 LLM 覆盖)。
返回 (success: bool, error: str)
"""
label = (label or '').strip()
canonical_name = (canonical_name or '').strip()
if not label or not canonical_name:
return False, "缺少 label / canonical_name"
try:
resp = requests.post(
f"{self.base_url}/api/oracle/people/correct",
json={"label": label, "canonical_name": canonical_name,
"token": self.token},
timeout=(10, 30))
except requests.RequestException as e:
logger.error(f"命名校正回推失败: {e}")
return False, str(e)
if resp.status_code == 200:
return True, ""
msg = f"HTTP {resp.status_code}: {resp.text[:200]}"
logger.error(f"命名校正回推失败: {msg}")
return False, msg
# ------------------------------------------------------------------
def _run(self):
logger.info(f"OracleSync 线程启动,间隔 {self.interval_sec}s目标 {self.base_url}")
while self._running:
try:
self._pull_once()
except Exception as e:
self._last_error = str(e)
logger.error(f"同步异常: {e}", exc_info=True)
# 分段休眠,便于 stop 快速唤醒
for _ in range(self.interval_sec):
if not self._running:
break
time.sleep(1)
def start(self):
if self._running:
return
self._running = True
self._thread = threading.Thread(target=self._run, daemon=True, name='oracle-sync')
self._thread.start()
def is_alive(self):
return self._thread is not None and self._thread.is_alive()
def stop(self):
self._running = False
if self._thread:
self._thread.join(timeout=5)
def trigger_now(self) -> bool:
"""立即触发一次同步(命名后即时拉回 / 手动)。"""
return self._pull_once()
def status(self) -> dict:
return {
"running": self.is_alive(),
"last_sync_at": self._last_sync_at.isoformat() if self._last_sync_at else None,
"last_error": self._last_error,
"last_count": self._last_count,
"cursor": db_layer.get_sync_cursor(),
"interval_sec": self.interval_sec,
}

View File

@@ -1,32 +0,0 @@
"""
Static-App - 提供 Vue 前端构建产物(新架构 v3fam-ui 不再单独起 Streamlit 进程)
fam-ui/dist/ 是本地 `npm run build` 出的静态文件,部署时整个目录拷到 NAS。
本模块只做两件事: 命中真实静态文件(如 /assets/xxx.js直接下发其余任何路径
Vue Router history 模式的前端路由)一律回退到 index.html由浏览器端路由接管。
必须最后注册app.py 里排在 chat_bp/member_bp/img_bp/ui_bp 之后),否则这里的
通配路由会先于 /api/* 匹配,把 API 请求也吞成 index.html。
"""
import os
from flask import Blueprint, send_from_directory, abort
DIST_DIR = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
'..', 'fam-ui', 'dist'
)
DIST_DIR = os.path.normpath(DIST_DIR)
static_bp = Blueprint('static_app', __name__)
@static_bp.route('/', defaults={'path': ''})
@static_bp.route('/<path:path>')
def spa(path):
if not os.path.isdir(DIST_DIR):
abort(404, "前端构建产物不存在,请先 npm run build 并部署 fam-ui/dist")
full = os.path.join(DIST_DIR, path)
if path and os.path.isfile(full):
return send_from_directory(DIST_DIR, path)
return send_from_directory(DIST_DIR, 'index.html')

View File

@@ -1,12 +1,14 @@
"""
UI-API - Vue 前端只读数据接口(新架构 v3Vue SPA 取代 Streamlit
UI-API - Vue 前端数据接口(新架构 v3Vue SPA 取代 Streamlit
全部包装 db_layer.py 里已有的查询函数,不新写查询逻辑人物按 canonical_name
聚合的逻辑从旧 Streamlit 版本搬过来,放在服务端做(前端只管渲染,不重复业务规则)。
以只读查询为主,全部包装 db_layer.py 里已有的查询函数,不新写查询逻辑人物
按 canonical_name 聚合的逻辑从旧 Streamlit 版本搬过来,放在服务端做(前端只管
渲染,不重复业务规则)。**2026-08-24 新增一个写操作**`DELETE /api/ui/videos/
<id>`(删除视频会话),因为语义上属于 videos 这个资源,比塞进 member_manager.py
更清晰;写法沿用项目里"先回推 Oracle成功后处理本地状态"的既有模式。
/api/ui/service-status 需要代理 Oracle 的 /api/oracle/activity浏览器不直连
Oracle避免 token 暴露),写法照抄 img_proxy.py 的模式:复用 oracle_sync.get_sync()
已解析好的 base_url/token不再单独存一份配置。
/api/ui/service-status 代理同机 fam-edge 的 /api/oracle/activity浏览器不直连
fam-edge避免 token 暴露),走 edge_client 统一读 base_url/token。
"""
import re
from datetime import datetime
@@ -16,7 +18,7 @@ from flask import Blueprint, request, jsonify
from .logger import setup_logger
from . import db_layer
from .oracle_sync import get_sync
from . import edge_client
logger = setup_logger('fam-core.ui_api')
@@ -47,7 +49,7 @@ def videos():
date_filter = request.args.get('date') or None
page = max(0, request.args.get('page', 0, type=int))
page_size = 15
rows = db_layer.get_sync_videos(limit=page_size, offset=page * page_size,
rows = db_layer.get_videos(limit=page_size, offset=page * page_size,
date_filter=date_filter)
return jsonify({"videos": _ser(rows), "page": page, "page_size": page_size}), 200
@@ -55,18 +57,32 @@ def videos():
@ui_bp.route('/api/ui/videos/<int:video_id>', methods=['GET'])
def video_detail(video_id):
"""单个视频会话详情 + 时间线事件列表(事件时间轴右侧)。"""
video = db_layer.get_sync_video(video_id)
video = db_layer.get_video(video_id)
if not video:
return jsonify({"error": "视频不存在"}), 404
events = db_layer.get_sync_events_for_video(video_id)
events = db_layer.get_events_for_video(video_id)
return jsonify({"video": _ser(video), "events": _ser(events)}), 200
@ui_bp.route('/api/ui/videos/<int:video_id>', methods=['DELETE'])
def video_delete(video_id):
"""删除视频会话(事件时间轴"删除"入口)。
交给 fam-edge 处理:它删 events + videos 行,还要删磁盘上的运动片段文件。
迁云前这里还要再清一次 NAS 本地镜像(增量同步只 upsert感知不到删除
现在没有镜像了fam-edge 删完就是最终状态。
"""
ok, err = edge_client.push_video_delete(video_id)
if not ok:
return jsonify({"error": f"删除失败: {err}"}), 502
return jsonify({"status": "ok", "video_id": video_id}), 200
@ui_bp.route('/api/ui/stats', methods=['GET'])
def stats():
"""统计卡:视频/事件/人物/关注数(可选按日期过滤)。"""
date_filter = request.args.get('date') or None
return jsonify(_ser(db_layer.get_sync_stats(date_filter))), 200
return jsonify(_ser(db_layer.get_stats(date_filter))), 200
def _clean_person(s: str) -> str:
@@ -80,7 +96,7 @@ def people():
对应旧 Streamlit 版本 app.py 里的分组逻辑,原样搬到服务端。
"""
rows = db_layer.get_sync_people()
rows = db_layer.get_people()
groups = {}
for p in rows:
key = p.get('canonical_name') or p['label']
@@ -104,6 +120,22 @@ def people():
return jsonify({"groups": _ser(out), "all_labels": all_labels}), 200
@ui_bp.route('/api/ui/people/clips', methods=['GET'])
def people_clips():
"""某人物出现过的运动片段列表(人物卡「运动片段」区块)。
按 label/canonical_name 匹配 events.person_list_json → 关联视频
(运动片段优先)。返回片段 video_id/filename/event_start_time/duration/
summary/camera_name + first_ts缩略图定位+ clip_events片段内事件数
"""
label = request.args.get('label') or ''
if not label:
return jsonify({"error": "缺少 label 参数"}), 400
limit = min(20, request.args.get('limit', 10, type=int) or 10)
clips = db_layer.get_people_clips(label, limit)
return jsonify({"label": label, "clips": _ser(clips)}), 200
@ui_bp.route('/api/ui/attention-events', methods=['GET'])
def attention_events():
"""需关注事件列表(统计图表页),已按人物去重规则清洗好 people 字段。"""
@@ -127,41 +159,25 @@ def attention_events():
@ui_bp.route('/api/ui/named-members', methods=['GET'])
def named_members():
"""已命名成员真名列表AI 对话页快捷选择)。"""
return jsonify({"members": db_layer.get_sync_named_members()}), 200
return jsonify({"members": db_layer.get_named_members()}), 200
@ui_bp.route('/api/ui/model-stats', methods=['GET'])
def model_stats():
"""云端模型调用统计:按模型聚合 + 最近调用明细。"""
agg = db_layer.get_sync_model_calls_stats()
calls = db_layer.get_sync_model_calls(limit=100)
agg = db_layer.get_model_calls_stats()
calls = db_layer.get_model_calls(limit=100)
return jsonify({"aggregate": _ser(agg), "recent_calls": _ser(calls)}), 200
@ui_bp.route('/api/ui/service-status', methods=['GET'])
def service_status():
"""服务状态页:NAS 同步状态 + Oracle 实时活动代理token 不下发浏览器)。"""
sync = get_sync()
nas_status = sync.status()
"""服务状态页:fam-edge 的队列/分割/模型活动代理token 不下发浏览器)。
oracle_data = None
oracle_error = None
if sync.base_url and sync.token:
import requests
try:
r = requests.get(f"{sync.base_url}/api/oracle/activity",
params={'token': sync.token}, timeout=15)
if r.status_code == 200:
oracle_data = r.json()
else:
oracle_error = f"Oracle activity HTTP {r.status_code}"
except Exception as e:
oracle_error = f"连接 Oracle 失败: {e}"
else:
oracle_error = "未配置 oracle_sync.base_url/token"
return jsonify({
"nas_sync": nas_status,
"oracle": oracle_data,
"oracle_error": oracle_error,
}), 200
迁云前这里还有一块 "NAS 同步状态"(镜像拉取的游标/周期/上次条数),随镜像层
一起删了——现在前端读的就是 fam-edge 写的那份库,没有"同步"这个中间状态。
NAS 侧只剩运动事件推送,它的心跳在 fam-edge 的 service_activity 里,
已经包含在 activity 快照中。
"""
data, err = edge_client.get_activity()
return jsonify({"oracle": data, "oracle_error": err}), 200

View File

@@ -0,0 +1,155 @@
"""db_layer 直读 SQLite 的单测2026-09-13 迁云重写后新增)。
重点验证从 MySQL 翻译过来的几处方言json_each 取代 JSON_CONTAINS、
substr 取代 LEFT、以及历史脏数据person_list_json 不是合法 JSON不能
把整条查询搞崩——这在 MySQL 下 JSON_CONTAINS 会直接报错SQLite 下
json_each 同样会,所以查询里加了 json_valid 前置判断。
"""
import sqlite3
import pytest
from fam_core import db_layer
# fam-edge 建表语句的最小子集(列名与 oracle_db.py 保持一致)
_SCHEMA = """
CREATE TABLE videos (
id INTEGER PRIMARY KEY AUTOINCREMENT, filename TEXT UNIQUE, camera_name TEXT,
duration_sec REAL, event_start_time TEXT, status TEXT DEFAULT 'pending',
summary_json TEXT, events_json TEXT, people_json TEXT, compute_provider TEXT,
created_at TEXT, updated_at TEXT, processed_at TEXT);
CREATE TABLE events (
id INTEGER PRIMARY KEY AUTOINCREMENT, video_id INTEGER, ts TEXT, description TEXT,
person_list_json TEXT, person_appearances_json TEXT, is_attention_event INTEGER DEFAULT 0);
CREATE TABLE people (
id INTEGER PRIMARY KEY AUTOINCREMENT, label TEXT UNIQUE, canonical_name TEXT,
first_seen TEXT, appearances INTEGER DEFAULT 0, source TEXT, features_json TEXT,
display_uid TEXT, updated_at TEXT);
CREATE TABLE model_calls (
id INTEGER PRIMARY KEY AUTOINCREMENT, provider TEXT, model TEXT, video_id INTEGER,
filename TEXT, started_at TEXT, duration_sec REAL, success INTEGER, error TEXT,
created_at TEXT);
"""
@pytest.fixture
def db(tmp_path, monkeypatch):
path = tmp_path / "oracle.db"
conn = sqlite3.connect(path)
conn.executescript(_SCHEMA)
conn.executescript("""
INSERT INTO videos (id, filename, camera_name, event_start_time, status, processed_at)
VALUES (1, 'motion_20260913_080000.mp4', '客厅', '2026-09-13 08:00:00', 'done', '2026-09-13 08:10:00'),
(2, 'whole_20260912_090000.mp4', '客厅', '2026-09-12 09:00:00', 'done', '2026-09-12 09:10:00'),
(3, 'whole_20260911_090000.mp4', '客厅', '2026-09-11 09:00:00', 'done', '2026-09-11 09:10:00'),
(4, 'motion_20260910_070000.mp4', '客厅', '2026-09-10 07:00:00', 'pending', NULL);
-- video 2 有事件应展示video 3 没有事件且不是 motion_应被过滤掉
INSERT INTO events (video_id, ts, description, person_list_json, is_attention_event)
VALUES (1, '00:00:05', '汤圆在客厅玩耍', '["汤圆"]', 0),
(1, '00:00:20', '有人靠近门口', '["人物B"]', 1),
(2, '00:01:00', '汤圆和奶奶', '["汤圆","奶奶"]', 0),
(2, '00:02:00', '脏数据事件', '不是合法JSON', 0);
INSERT INTO people (id, label, canonical_name, appearances, source)
VALUES (1, '人物A', '汤圆', 12, 'manual'),
(2, '人物B', NULL, 3, 'llm'),
(3, '汤圆', '汤圆', 5, 'manual');
INSERT INTO model_calls (provider, model, success, duration_sec, created_at)
VALUES ('gemini', 'flash', 1, 10.0, '2026-09-13 08:10:00'),
('gemini', 'flash', 0, 20.0, '2026-09-13 08:20:00'),
('nvidia', 'vila', 1, 5.0, '2026-09-12 08:10:00');
""")
conn.commit()
conn.close()
monkeypatch.setattr(db_layer, '_db_path', str(path))
monkeypatch.setattr(db_layer, '_chat_schema_ready', False)
return path
# ---------------------------------------------------------------- videos
def test_get_videos_only_returns_content_sessions(db):
"""只展示运动片段或含事件的会话:分割 0 段的整段素材不该淹没时间轴。"""
ids = [v['id'] for v in db_layer.get_videos()]
assert ids == [1, 2] # 3 无事件且非 motion_4 未完成
assert db_layer.get_videos()[0]['event_count'] == 2
def test_get_videos_date_filter_and_paging(db):
assert [v['id'] for v in db_layer.get_videos(date_filter='2026-09-12')] == [2]
assert [v['id'] for v in db_layer.get_videos(limit=1, offset=1)] == [2]
def test_get_video_and_events(db):
assert db_layer.get_video(1)['filename'].startswith('motion_')
assert db_layer.get_video(999) is None
evs = db_layer.get_events_for_video(1)
assert [e['ts'] for e in evs] == ['00:00:05', '00:00:20']
def test_get_attention_events(db):
rows = db_layer.get_attention_events()
assert len(rows) == 1 and rows[0]['ev_date'].startswith('2026-09-13')
# ---------------------------------------------------------------- 人物命中JSON
def test_people_clips_matches_label_and_its_aliases(db):
"""'人物A' 的规范名是"汤圆",而事件里记的是"汤圆"——要能顺着别名找到。"""
clips = db_layer.get_people_clips('人物A')
assert {c['video_id'] for c in clips} == {1, 2}
assert clips[0]['video_id'] == 1 # 按录制时间倒序
assert clips[0]['first_ts'] == '00:00:05'
assert clips[0]['clip_events'] == 1
def test_person_hit_is_exact_not_substring(db):
"""json_each 是精确匹配:查"人物"不该命中"人物B""""
assert db_layer.get_people_clips('人物') == []
assert {c['video_id'] for c in db_layer.get_people_clips('人物B')} == {1}
def test_malformed_person_json_does_not_break_queries(db):
"""video 2 里混了一条非 JSON 的脏数据,查询必须照常返回而不是整条报错。"""
rows = db_layer.query_events_for_person_date('汤圆', '2026-09-12')
assert len(rows) == 1 and rows[0]['ts'] == '00:01:00'
def test_query_events_for_person_date(db):
assert db_layer.query_events_for_person_date('汤圆', '2026-09-13')[0]['description'] == '汤圆在客厅玩耍'
assert db_layer.query_events_for_person_date('汤圆', '2026-09-01') == []
# ---------------------------------------------------------------- people / model_calls
def test_people_and_named_members(db):
assert len(db_layer.get_people()) == 3
assert db_layer.get_named_members() == ['汤圆'] # distinct 且非空
ctx = db_layer.get_known_members_context()
assert '- 汤圆标识人物A' in ctx and '- 人物B' in ctx
def test_model_calls_stats(db):
stats = {s['model']: s for s in db_layer.get_model_calls_stats()}
assert stats['flash']['ok_cnt'] == 1 and stats['flash']['fail_cnt'] == 1
assert stats['flash']['avg_duration'] == 15.0
assert len(db_layer.get_model_calls(limit=2)) == 2
# ---------------------------------------------------------------- 统计
def test_stats_全量与按日(db):
total = db_layer.get_stats()
assert total['videos'] == 2 and total['events'] == 4 and total['attention'] == 1
day = db_layer.get_stats('2026-09-13')
assert day['videos'] == 1 and day['events'] == 2
# 人物数:汤圆(含 label 人物A/汤圆两行都归一到"汤圆"+ 人物B
assert total['people'] >= 2
# ---------------------------------------------------------------- chat_history
def test_chat_history_roundtrip_creates_table_on_demand(db):
"""chat_history 是本模块唯一写的表,建表是懒加载的(库文件属于 fam-edge"""
chat_id = db_layer.insert_chat_history('今天有人来吗', '08:00 有人靠近门口',
'上下文', '2026-09-13', '汤圆')
assert chat_id == 1
rows = db_layer.get_chat_history(limit=10)
assert rows[0]['user_question'] == '今天有人来吗'
assert rows[0]['created_at']
assert db_layer.get_chat_history(person_filter='不存在的人') == []
assert len(db_layer.get_chat_history(date_filter='2026-09-13')) == 1

View File

@@ -29,11 +29,16 @@ oracle_db:
sync_api:
token: "${ORACLE_SYNC_TOKEN}"
# 人物识别服务
# 人物识别服务2026-08-22 停用):已被闭集识别 person_identifier 取代
# (汤圆/媳妇走性别年龄规则免费识别,爷爷/爸爸走视觉大模型比对参考图,见
# person_identifier 配置块)。原 LLM 合并逻辑不可靠(用户原话"现在的识别全是
# 错的"),继续跑只会用不可靠的猜测持续覆盖 people 表的 canonical_name跟新
# 系统的结果打架——停用,不删除代码(关键冲突检测逻辑 _features_conflict 仍
# 保留供参考/未来复用)。
person_service:
enabled: true
schedule_interval_sec: 1800 # 每 30 分钟重新汇总一次人物
model: "gemini" # 用哪个模型做人物合并vision 模型也支持纯文本)
enabled: false
schedule_interval_sec: 1800
model: "gemini"
# 视频处理
video_processing:
@@ -47,24 +52,80 @@ video_processing:
# 降级顺序:先 gemini 整视频,失败再 nvidia 整视频;两者都失败 -> 标记 failed
vision_order: ["gemini", "nvidia"]
# DSM 运动侦测预过滤2026-08-22 新增):分析前先查一下群晖 Surveillance Station
# 自己记录的运动侦测事件SYNO.SurveillanceStation.EventCenter.Event未公开文档的
# 内部接口,参数名是下划线风格 camera_ids/start_time/end_time这段时间窗口一条
# 运动事件都没有就跳过云端分析(标记 donecompute_provider=skipped_no_motion
# 省掉长期无人时段白白消耗的 Gemini/NVIDIA 配额。
# 账号密码走 .envDSM_ACCOUNT/DSM_PASSWORD不明文入库查询失败/未配置一律
# fail-open照常送云端分析不会因为这层可选优化漏检真实事件。
# 运动事件2026-08-22 运动事件驱动架构):
# - NAS 端 fam-core MotionNotifier 轮询 SS 事件后POST 推送到甲骨文
# /api/ss/motion落库 ss_motion_eventsstart_time/duration 为 Unix epoch
# 并定期用空 events 调接口当心跳,证明推送链路存活。
# - video_processor 不再整段分析:整段素材按 ss_motion_events 中【已结束】的
# 运动事件分割成运动片段motion_segment 配置),只分析运动片段。
dsm_motion_prefilter:
enabled: true
host: "192.168.50.64"
port: 5000
account: "${DSM_ACCOUNT}"
password: "${DSM_PASSWORD}"
camera_id: 2 # Surveillance Station 里 Generic_ONVIF-001 的 camera_id
min_motion_seconds: 0 # 窗口内运动事件总时长需 ≥ 此值才算"有运动"0=有事件就算)
timeout_sec: 10
max_heartbeat_age_sec: 900 # 心跳超过 15 分钟没更新 -> 判定推送链路可能已死
# 智能问答降级链与视频分析独立Gemini -> NVIDIA -> 本地 Ollama
# 运动片段分割运动事件驱动架构2026-08-22 新增):
# 整段素材视频rclone 同步落地)按 ss_motion_events 分割成运动片段再分析。
# 只分割已结束事件start_time+duration 落在当前时刻附近),进行中的事件
# 等结束后的下一轮再分割;素材保持 pending 直到窗口内事件全部结束。
motion_segment:
clips_dir: "/opt/fam-edge/motion_clips" # 分割产物目录
keep_audio: true # 保留音频pcm_alaw -> aac 64k 转码false 则 -an 去音频
min_duration_sec: 1 # 短于该时长的事件不分割
unfinished_grace_sec: 10 # start_time+duration 距当前 ≤ 该秒视为"已结束"容差
# 磁盘空间守护2026-08-28 新增Oracle 磁盘曾经被写满,触发 rclone 遇到
# IO 错误就拒绝删除的保护机制,形成"越满越删不掉"的死循环,导致新视频下载
# 和运动片段分割全部失败。这里加一道独立于 rclone 同步之外的兜底:不管上游
# 同步是否正常,剩余空间跌破 min_free_gb 就主动清理最旧的、已完成分割阶段
# 的整段素材gdrive_videos 里的原始录像,不碰 motion_clips 里的运动片段)。
disk_guard:
enabled: true
check_interval_sec: 300 # 5 分钟检查一次
min_free_gb: 10 # 剩余空间低于此值触发清理
target_free_gb: 15 # 清理到这个水位就停(留缓冲,避免刚清完又立刻再触发)
watch_path: "/opt/fam-edge" # 检查这个路径所在磁盘分区的剩余空间
max_delete_per_round: 50 # 单轮最多清理几个文件,防止候选异常多时一次删太多
# 闭集人物识别2026-08-22 新增,家里固定 4 人:爷爷/爸爸/媳妇/汤圆):
# 原来靠大模型自己编的"人物A/B/C"临时 uid + 文字特征描述跨视频合并,验证下来
# 不可靠(用户原话"现在的识别全是错的")。人脸向量方案也验证过,家庭监控这种
# 大广角/远距离画面下同人内部相似度经常比不同人还低,此路不通。
# 现在改成:汤圆(幼儿/儿童特征)/媳妇(唯一成年女性) 直接用 Gemini 已产出的
# 性别/年龄字段判断,免费且验证下来接近 100% 准;爷爷/爸爸(两个成年男性,纯
# 外观规则/人脸向量都区分不开) 改用视觉大模型"看参考图比对"——NVIDIA 实测
# 6/6 全对且配额与主分析链路完全独立设为优先Gemini flash-lite(7/8) 兜底。
# 参考图目录结构:{ref_dir}/爷爷/*.jpg、{ref_dir}/爸爸/*.jpg已用确认过身份的
# 历史截图种好,见 PROGRESS.md 记录)。
person_identifier:
enabled: true
ref_dir: "/opt/fam-edge/data/person_refs"
max_ref_per_person: 6
# 连续两次分类调用之间的最小间隔(不分 provider 统一限速):正常处理新片段时
# 调用本来就稀疏,这个主要是给历史数据批量回填用的,避免短时间内密集调用打爆配额
min_call_interval_sec: 2
nvidia:
api_key: "${NVIDIA_API_KEY}"
model_name: "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning"
fallback_models: [] # 目前只验证过这一个能用的 NVIDIA 视觉模型,先留好扩展位
timeout: 60
max_retries: 3 # 每个模型对瞬时故障429/5xx/超时)最多重试几次(含首次)
retry_backoff_sec: 3 # 重试前基础等待秒数指数退避3s -> 6s -> 12s
gemini:
api_key: "${GEMINI_API_KEY}"
extra_api_keys: ["${GEMINI_API_KEY_2}", "${GEMINI_API_KEY_3}", "${GEMINI_API_KEY_4}"]
model_name: "gemini-flash-lite-latest"
timeout: 60
max_retries: 2
retry_backoff_sec: 3
# 智能问答2026-08-23 抽离到独立 ai-gateway 服务OpenAI 兼容协议):
# fam-edge 这边只是转发客户端,问答本体的模型降级链/key 轮换/熔断都在
# ai-gateway 自己的 config.yaml 里配置,这里只填怎么连它。
# token 走 .env AI_GATEWAY_TOKEN跟 ai-gateway 侧配置的值必须一致。
ai_gateway:
base_url: "http://127.0.0.1:5100" # 同机部署,走本地回环,不走公网
token: "${AI_GATEWAY_TOKEN}"
timeout: 60
# 视频分析模型链
models:
- provider: "gemini"
role: "vision"
@@ -126,14 +187,3 @@ models:
threshold: 5
cooldown: 300
# 本地模型:纯文本 qwen2.5:7b仅参与智能问答兜底
- provider: "ollama"
role: "text"
usage: "qa_fallback"
enabled: true
model_name: "qwen2.5:7b"
base_url: "http://localhost:11434"
timeout: 120
num_predict: 512
circuit_breaker:
enabled: false

View File

@@ -2,6 +2,9 @@ flask>=2.0.0
gunicorn>=20.0.0
requests>=2.28.0
PyYAML>=6.0
# 统一登录:验 auth-hub 的 id_token 签名RS256+ 签发会话 cookieHS256
PyJWT>=2.8.0
cryptography>=42.0.0
# google-generativeai 和 openai 为可选依赖(代码用 requests 直接调 REST API
# 如需 SDK 方式调用,取消注释并在 Python 3.9+ 环境安装:
# google-generativeai>=0.5.0

View File

@@ -14,6 +14,7 @@ API-Gateway - Flask 蓝图(新架构 v3
一次 Gemini 调用一并产出(见 ai_orchestrator/prompts.pyframe_service 不再
额外调用任何模型。NAS 经 core 代理读取,不在 NAS 做图像计算。
"""
import json
import os
from flask import Blueprint, request, jsonify, Response
@@ -102,6 +103,70 @@ def people_correct():
return jsonify({"status": "ok", "label": label, "canonical_name": canonical}), 200
@api_bp.route('/api/oracle/identity/correct', methods=['POST'])
def identity_correct():
"""事件时间轴/人物管理"这个人识别错了"纠错入口(比 people/correct 粒度更细)。
请求: {"video_id": 123, "current_name": "爷爷", "new_name": "爸爸", "token": "..."}
只改这一段视频里被错误识别的那个人,不影响同名字符串在其他视频里的映射——
人物 uid 只在单次视频分析内稳定,同一个"人物A"字符串在不同视频里可能是不同
真人,纠错必须落到 (video_id, 当前展示名) 这一粒度,不能按全局 label 改。
写 manual 来源,受保护不会被后续自动识别覆盖回去;立即重写这段视频的展示数据。
"""
if not _check_token():
return jsonify({"error": "unauthorized"}), 401
data = request.get_json(silent=True)
if not data:
return jsonify({"error": "Invalid JSON"}), 400
video_id = data.get('video_id')
current_name = (data.get('current_name') or '').strip()
new_name = (data.get('new_name') or '').strip()
if not video_id or not current_name or not new_name:
return jsonify({"error": "缺少 video_id / current_name / new_name"}), 400
try:
video_id = int(video_id)
except (TypeError, ValueError):
return jsonify({"error": "video_id 必须是数字"}), 400
try:
state.get_db().correct_video_identity(video_id, current_name, new_name)
except Exception as e:
logger.error(f"identity_correct 异常: {e}")
return jsonify({"error": str(e)}), 500
return jsonify({"status": "ok", "video_id": video_id,
"current_name": current_name, "new_name": new_name}), 200
@api_bp.route('/api/oracle/video/delete', methods=['POST'])
def video_delete():
"""删除视频会话(事件时间轴"删除"入口NAS 转发)。
请求: {"video_id": 123, "token": "..."}
删 Oracle 端 events + videos 行 + 磁盘上的运动片段文件;不清理对应的
ss_motion_events 源事件(独立生命周期,见 oracle_db.delete_video 注释)。
"""
if not _check_token():
return jsonify({"error": "unauthorized"}), 401
data = request.get_json(silent=True)
if not data:
return jsonify({"error": "Invalid JSON"}), 400
video_id = data.get('video_id')
if not video_id:
return jsonify({"error": "缺少 video_id"}), 400
try:
video_id = int(video_id)
except (TypeError, ValueError):
return jsonify({"error": "video_id 必须是数字"}), 400
try:
local_path = state.get_db().delete_video(video_id)
except Exception as e:
logger.error(f"video_delete 异常: {e}")
return jsonify({"error": str(e)}), 500
if local_path is None:
return jsonify({"error": "视频不存在"}), 404
state.get_db().record_activity('video', 'delete', f"video_id={video_id}")
return jsonify({"status": "ok", "video_id": video_id}), 200
@api_bp.route('/api/edge/chat/ask', methods=['POST'])
def chat_ask():
"""智能问答编排Gemini → NVIDIA → 本地 Ollama两云端都失败才用本地兜底
@@ -114,7 +179,10 @@ def chat_ask():
return jsonify({"error": "缺少必填字段: prompt"}), 400
prompt = data['prompt']
max_tokens = int(data.get('max_tokens', 1024))
# 默认值从 1024 提到 30722026-08-23问答链路现在优先用推理类模型
# NVIDIA Nemotron-3 系列),回答前会先输出一段思考过程再给最终答案,
# 1024 经常在思考阶段就被截断,永远看不到真正的回答。
max_tokens = int(data.get('max_tokens', 3072))
answer, provider = get_qa().run_qa(prompt, max_tokens=max_tokens)
if answer is None:
@@ -125,6 +193,34 @@ def chat_ask():
return jsonify({"answer": answer, "provider": provider}), 200
@api_bp.route('/api/edge/chat/ask/stream', methods=['POST'])
def chat_ask_stream():
"""智能问答编排流式版SSE 逐块推送,边生成边显示,不用等全量回答。
请求同 /api/edge/chat/ask。响应 Content-Type: text/event-stream
每行 `data: <json>\\n\\n`json 结构见 qa.QAOrchestrator.run_qa_stream 注释。
"""
data = request.get_json(silent=True)
if not data or 'prompt' not in data:
return jsonify({"error": "缺少必填字段: prompt"}), 400
prompt = data['prompt']
# 默认值从 1024 提到 30722026-08-23问答链路现在优先用推理类模型
# NVIDIA Nemotron-3 系列),回答前会先输出一段思考过程再给最终答案,
# 1024 经常在思考阶段就被截断,永远看不到真正的回答。
max_tokens = int(data.get('max_tokens', 3072))
def generate():
for event in get_qa().run_qa_stream(prompt, max_tokens=max_tokens):
yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n"
# mimetype 显式带 charset=utf-8响应体本身一直是 UTF-8但不声明的话下游
# fam-core 转发这一跳、或任何用 requests 消费这个流的客户端)会自己猜
# 编码,猜错就是中文乱码——跟 gemini_adapter.py chat_stream() 那个坑同源。
return Response(generate(), mimetype='text/event-stream; charset=utf-8',
headers={'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'})
@api_bp.route('/api/oracle/activity', methods=['GET'])
def activity():
"""实时服务状态 + 最近活动流token 校验)。
@@ -153,9 +249,39 @@ def activity():
model_calls = db._conn.execute(
"SELECT id, provider, model, filename, started_at, duration_sec, "
"success, error FROM model_calls ORDER BY id DESC LIMIT 5").fetchall()
# 运动片段分割状态(服务状态页「视频分割」卡)
segment = db.get_segment_status()
try:
import os
from ..config_loader import load_config
seg_dir = load_config().get('motion_segment', {}).get(
'clips_dir', '/opt/fam-edge/motion_clips')
segment['clips_files'] = len(os.listdir(seg_dir)) if os.path.isdir(seg_dir) else 0
except Exception:
segment['clips_files'] = 0
# 事件↔片段一致性对账(数量可验证)
segment['consistency'] = db.get_segment_consistency()
# 磁盘空间实时用量 + DiskGuard 最近一次清理动作2026-08-28 新增)
disk_info = {"free_gb": None, "last_activity": _last_activity('disk_guard')}
try:
import shutil as _shutil
from ..config_loader import load_config as _load_config
watch_path = _load_config().get('disk_guard', {}).get('watch_path', '/opt/fam-edge')
disk_info["free_gb"] = round(_shutil.disk_usage(watch_path).free / (1024 ** 3), 1)
except Exception as e:
logger.warning(f"磁盘空间查询异常: {e}")
# NAS 推送链路2026-09-13 起 NAS 上只剩 fam-notifier 这一个服务,
# 服务状态页的「NAS 运动推送」卡片读这里;心跳由它定期空 POST 刷新)
motion = {
"heartbeat_age_sec": db.get_motion_heartbeat_age_sec(),
"recent": db.get_recent_motion_events(5),
}
return jsonify({
"queue": q_status,
"db": db.get_queue_status(),
"motion": motion,
"segment": segment,
"disk": disk_info,
"rclone": _last_activity('rclone'),
"person": _last_activity('person'),
"model_calls": [dict(m) for m in model_calls],
@@ -197,6 +323,40 @@ def oracle_avatar():
return Response(data, mimetype='image/jpeg')
@api_bp.route('/api/ss/motion', methods=['POST'])
def ss_motion():
"""接收 NAS 推送的运动侦测事件单向NAS -> Oracle
请求体: {"token": "...", "events": [
{"event_id": 25349, "camera_id": 2, "event_type": 10,
"start_time": 1787318023, "duration": 149, "thumbnail_url": "107471,12075"}
]}
start_time/duration 为 Unix epoch与 SS 同源时区无关。events 可以是空数组
——NAS 侧也会定期用空 events 单纯调一次这个接口当心跳,证明推送链路还活着
(鉴权通过就刷新心跳,不要求 stored>0供 has_motion_in_range_local() 判断
"无运动"结论是否可信。
落库 ss_motion_events按 event_id 幂等),供 video_processor 本地预过滤使用。
"""
if not _check_token():
return jsonify({"error": "unauthorized"}), 401
data = request.get_json(silent=True)
if not data or 'events' not in data:
return jsonify({"error": "缺少 events"}), 400
events = data.get('events') or []
if not isinstance(events, list):
return jsonify({"error": "events 必须是数组"}), 400
try:
stored = state.get_db().record_motion_events(events)
state.get_db().record_motion_heartbeat()
except Exception as e:
logger.error(f"ss_motion 落库异常: {e}")
return jsonify({"error": str(e)}), 500
if stored:
state.get_db().record_activity(
'motion', 'push', f"接收 NAS 运动事件 {stored}")
return jsonify({"status": "ok", "received": len(events), "stored": stored}), 200
@api_bp.route('/health', methods=['GET'])
def health():
"""健康检查DB 连通性 + producer/consumer 线程存活状态。

View File

@@ -5,6 +5,9 @@ FAM-Edge 主应用 - Flask 单进程(新架构 v2.1
- API-Gateway同步拉取 / 命名校正 / 智能问答)
- VideoQueue生产-消费队列:同步落地新视频入队,独立消费者云端分析,模型超时 ×2
- PersonService人物汇总合并定时
- DiskGuard磁盘空间守护定时检查剩余空间过低时清理最旧的已完成素材
- Auth统一登录/login + OIDC 回调 + 给 Caddy forward_auth 用的会话校验,
2026-09-12 从 NAS fam-core 迁入,原因见 auth.py 开头)
"""
import os
import sys
@@ -15,14 +18,17 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from .config_loader import load_config
from .logger import setup_logger
from .api_gateway.api_gateway import api_bp
from .auth import auth_bp
from . import state
from .video_queue import VideoQueue
from .person_service import PersonService
from .disk_guard import DiskGuard
logger = setup_logger('fam-edge.app')
app = Flask(__name__)
app.register_blueprint(api_bp)
app.register_blueprint(auth_bp)
@app.route('/', methods=['GET'])
@@ -31,9 +37,10 @@ def root():
"mode": "drive-sync + whole-video analysis (producer-consumer queue)"}), 200
# 启动生产-消费队列 + 人物服务
# 启动生产-消费队列 + 人物服务 + 磁盘空间守护
_queue = None
_person = None
_disk_guard = None
try:
db = state.get_db()
_queue = VideoQueue(db)
@@ -44,6 +51,9 @@ try:
_person = PersonService(db)
_person.start()
logger.info("PersonService 已启动")
_disk_guard = DiskGuard(db)
_disk_guard.start()
except Exception as e:
logger.error(f"后台服务启动失败: {e}", exc_info=True)

View File

@@ -0,0 +1,285 @@
"""
Auth - 摄像头系统统一登录2026-09-12 从 NAS fam-core 迁到甲骨文 fam-edge
背景:登录流程原先跑在 NAS 的 fam-core 里NAS 一挂或 frp 隧道一断,
`smart-camera.zichuan.xyz/login` 直接 502——连登录页都进不去。登录是入口
不该依赖家里那台机器,所以整体搬到甲骨文:前端静态文件和 auth-hub 本来就在这台,
搬完之后 NAS 只剩纯数据接口。
跟旧实现fam-core/auth.py已删除的三处关键差异
1. **服务端调用 auth-hub 走本机**:换 token 和拉 JWKS 用 `AUTH_HUB_INTERNAL_BASE`
(默认 http://127.0.0.1:5300不再走公网 TLS。旧链路是 NAS 跨公网访问
https://auth.zichuan.xyz踩过两个坑`jwt.PyJWKClient` 用 urllib + 系统 CA
(不像 requests 自带 certifi群晖上很容易 CERTIFICATE_VERIFY_FAILED
以及两台机器时钟偏差会让 id_token 的 iat 看起来"来自未来"
但 `iss` 校验和浏览器跳转仍然用公网 `AUTH_HUB_ISSUER`——id_token 里的 iss
是 auth-hub 自己配的公网地址,浏览器也只能跳公网地址。
2. **登录态改成无状态签名 cookie**HS256 签发,密钥 `FAM_SESSION_SECRET`。
旧实现是进程内 token 表服务一重启所有人被踢下线fam-edge 是视频分析进程,
重启比 fam-core 更频繁,进程内会话在这里完全不成立。
3. **回调失败渲染错误页,不再 302 回 /login**:旧实现每个失败分支都跳 /login
而 auth-hub 侧只要还有会话,/authorize 会立刻再签发一个 code 跳回来,
两边对跳成死循环——浏览器只报"重定向次数过多",既看不到登录页也看不到原因。
拦截不在本模块做Caddy 用 forward_auth 打到 `/api/auth/verify`
校验通过才把 `/api/*` 反代到 NAS 的 fam-core。
"""
import os
import secrets
import time
from base64 import urlsafe_b64encode
from hashlib import sha256
from urllib.parse import urlencode
import jwt
import requests
from flask import Blueprint, Response, jsonify, make_response, redirect, request
# 导入即加载 /opt/fam-edge/.envconfig_loader 模块级 _load_env_file
# 下面所有 os.environ.get 才读得到 AUTH_HUB_* / FAM_SESSION_SECRET。
from . import config_loader # noqa: F401
from .logger import setup_logger
logger = setup_logger('fam-edge.auth')
auth_bp = Blueprint('auth', __name__)
COOKIE_NAME = 'fam_session'
_SESSION_TTL = 2 * 3600 # 登录态有效期 2 小时
_PENDING_TTL = 10 * 60 # /login -> /api/auth/callback 之间的等待上限
_LEEWAY = 60 # 验 id_token 时容忍的跨机器时钟偏差(秒)
_pending = {} # state -> {verifier, expires}进程内PKCE 用)
_warned_unconfigured = False # 只在第一次拒绝登录时打一条警告,别刷屏
_jwks_client = None # jwt.PyJWKClient 单例,内建 JWKS 缓存
def _config():
"""接入参数全部来自环境变量(/opt/fam-edge/.env"""
issuer = os.environ.get('AUTH_HUB_ISSUER', '').rstrip('/')
return {
'issuer': issuer,
# 服务端直连 auth-hub 的地址;没配就退回公网 issuer本地开发用
'internal_base': (os.environ.get('AUTH_HUB_INTERNAL_BASE', '') or issuer).rstrip('/'),
'client_id': os.environ.get('AUTH_HUB_CLIENT_ID', ''),
'client_secret': os.environ.get('AUTH_HUB_CLIENT_SECRET', ''),
'redirect_uri': os.environ.get('AUTH_HUB_REDIRECT_URI', ''),
'session_secret': os.environ.get('FAM_SESSION_SECRET', ''),
}
def _require_config():
"""五个变量缺任何一个都拒绝登录fail closed。返回配置 dict 或 None。
本服务监听 0.0.0.0,没有"配置不全就退回某种默认放行"这种兜底。
"""
global _warned_unconfigured
cfg = _config()
if not all([cfg['issuer'], cfg['client_id'], cfg['client_secret'],
cfg['redirect_uri'], cfg['session_secret']]):
if not _warned_unconfigured:
logger.error(
"AUTH_HUB_ISSUER/CLIENT_ID/CLIENT_SECRET/REDIRECT_URI 或 "
"FAM_SESSION_SECRET 未配置齐全,拒绝所有登录——"
"请在 /opt/fam-edge/.env 里补齐后 systemctl restart fam-edge")
_warned_unconfigured = True
return None
return cfg
def _get_jwks_client(jwks_uri):
global _jwks_client
if _jwks_client is None or _jwks_client.uri != jwks_uri:
_jwks_client = jwt.PyJWKClient(jwks_uri)
return _jwks_client
def _cleanup_pending():
now = time.time()
for state in [s for s, v in _pending.items() if v['expires'] < now]:
_pending.pop(state, None)
def _is_https():
"""Caddy 终止 TLS 后回源是明文 HTTPrequest.is_secure 恒为 False
所以 cookie 的 Secure 标志要看 X-Forwarded-Proto。"""
return request.headers.get('X-Forwarded-Proto', '') == 'https' or request.is_secure
# ---------------------------------------------------------------------------
# 会话(无状态签名 cookie
# ---------------------------------------------------------------------------
def _issue_session(secret, sub, username):
now = int(time.time())
return jwt.encode({'sub': sub, 'username': username,
'iat': now, 'exp': now + _SESSION_TTL},
secret, algorithm='HS256')
def current_user():
"""返回 cookie 里的用户信息dict未登录/过期/签名不对返回 None。"""
cfg = _config()
if not cfg['session_secret']:
return None
token = request.cookies.get(COOKIE_NAME, '')
if not token:
return None
try:
# 这里刻意不给 leewaycookie 是本进程自签自验的,没有跨机器时钟偏差问题,
# 加了只会让每个会话白白多活 60 秒。_LEEWAY 只用于 auth-hub 签发的 id_token。
return jwt.decode(token, cfg['session_secret'], algorithms=['HS256'])
except jwt.PyJWTError:
return None
def is_authed():
return current_user() is not None
def _error_page(reason, status=502):
"""回调失败时给一个看得懂的页面。
这里刻意不做自动跳转auth-hub 有会话时会立刻再发一个 code 回来,
自动跳转等于把用户关进死循环。让用户自己点"重新登录",最多再失败一次。
"""
html = (
'<!doctype html><html lang="zh-CN"><head><meta charset="utf-8">'
'<meta name="viewport" content="width=device-width,initial-scale=1">'
'<title>登录失败</title></head>'
'<body style="font-family:system-ui,-apple-system,sans-serif;background:#0b0e14;'
'color:#e6ebf2;display:flex;min-height:100vh;align-items:center;justify-content:center;margin:0">'
'<div style="max-width:34rem;padding:2rem">'
'<h1 style="font-size:1.25rem;margin:0 0 .75rem">登录没能完成</h1>'
'<p style="color:#9aa7b8;line-height:1.7;margin:0 0 1.5rem">原因:{reason}</p>'
'<a href="/login" style="display:inline-block;background:#5b8cff;color:#fff;'
'text-decoration:none;padding:.6rem 1.2rem;border-radius:.6rem">重新登录</a>'
'</div></body></html>'
).format(reason=reason)
return Response(html, status=status, mimetype='text/html; charset=utf-8')
# ---------------------------------------------------------------------------
# 端点
# ---------------------------------------------------------------------------
@auth_bp.route('/login', methods=['GET'])
def login():
"""不渲染登录表单,直接跳 auth-hub 走 Authorization Code + PKCE。"""
if is_authed():
return redirect('/')
cfg = _require_config()
if not cfg:
return _error_page('本服务的登录参数没配齐(管理员看 fam-edge 日志)', 503)
_cleanup_pending()
verifier = secrets.token_urlsafe(48)
challenge = urlsafe_b64encode(
sha256(verifier.encode('ascii')).digest()).rstrip(b'=').decode('ascii')
state = secrets.token_hex(24)
_pending[state] = {'verifier': verifier, 'expires': time.time() + _PENDING_TTL}
params = {
'response_type': 'code',
'client_id': cfg['client_id'],
'redirect_uri': cfg['redirect_uri'],
'scope': 'openid profile',
'state': state,
'code_challenge': challenge,
'code_challenge_method': 'S256',
}
# 浏览器要跳的是公网 issuer不是 internal_base
return redirect("{}/authorize?{}".format(cfg['issuer'], urlencode(params)))
@auth_bp.route('/api/auth/callback', methods=['GET'])
def callback():
cfg = _require_config()
if not cfg:
return _error_page('本服务的登录参数没配齐(管理员看 fam-edge 日志)', 503)
if request.args.get('error'):
logger.warning("auth-hub 拒绝授权: {}".format(request.args.get('error')))
return _error_page('auth-hub 拒绝了这次授权:{}'.format(request.args.get('error')), 403)
state = request.args.get('state', '')
code = request.args.get('code', '')
pending = _pending.pop(state, None)
if not pending or pending['expires'] < time.time() or not code:
logger.warning("回调 state 缺失/过期/重放,拒绝")
return _error_page('这次登录请求已过期或被重复使用,请重新登录', 400)
try:
resp = requests.post(
"{}/token".format(cfg['internal_base']),
data={
'grant_type': 'authorization_code',
'code': code,
'redirect_uri': cfg['redirect_uri'],
'client_id': cfg['client_id'],
'client_secret': cfg['client_secret'],
'code_verifier': pending['verifier'],
}, timeout=(5, 15))
except requests.RequestException as e:
logger.error("auth-hub /token 请求失败: {}".format(e))
return _error_page('连不上 auth-hub 的 /token{}'.format(e))
if resp.status_code != 200:
logger.warning("auth-hub /token 拒绝: {} {}".format(resp.status_code, resp.text[:200]))
return _error_page('auth-hub 拒绝换发 tokenHTTP {}'.format(resp.status_code))
id_token = (resp.json() or {}).get('id_token', '')
try:
jwks_client = _get_jwks_client(
"{}/.well-known/jwks.json".format(cfg['internal_base']))
signing_key = jwks_client.get_signing_key_from_jwt(id_token)
claims = jwt.decode(id_token, signing_key.key, algorithms=['RS256'],
audience=cfg['client_id'], issuer=cfg['issuer'],
leeway=_LEEWAY)
except jwt.PyJWTError as e:
logger.warning("id_token 验签/校验失败: {}".format(e))
return _error_page('id_token 校验失败({}'.format(e))
username = claims.get('preferred_username', '')
token = _issue_session(cfg['session_secret'], claims.get('sub', ''), username)
resp2 = make_response(redirect('/'))
resp2.set_cookie(COOKIE_NAME, token, max_age=_SESSION_TTL, httponly=True,
samesite='Lax', secure=_is_https(), path='/')
logger.info("登录成功 username={}".format(username))
return resp2
@auth_bp.route('/api/logout', methods=['POST'])
def logout():
"""只清本地 cookie不动 auth-hub 上的登录态(那是全站 SSO 会话)。
cookie 是无状态签名的,服务端没法单独作废某一张——真要立刻全员下线,
换掉 FAM_SESSION_SECRET 重启即可。
"""
resp = make_response(jsonify({"ok": True}))
resp.delete_cookie(COOKIE_NAME, path='/')
return resp
@auth_bp.route('/api/auth/check', methods=['GET'])
def check():
user = current_user()
return jsonify({"authed": user is not None,
"username": (user or {}).get('username', '')})
@auth_bp.route('/api/auth/verify', methods=['GET', 'POST', 'PUT', 'DELETE', 'PATCH'])
def verify():
"""给 Caddy forward_auth 用2xx 放行401 拦截。
Caddy 会把原始请求的 cookie 一起带过来401 的响应体会原样回给浏览器,
前端 api.js 看到 401 就会自动跳 /login。
"""
user = current_user()
if not user:
return jsonify({"error": "未登录", "code": 401}), 401
resp = make_response('', 204)
resp.headers['X-Auth-User'] = user.get('username', '')
return resp

View File

@@ -0,0 +1,114 @@
"""
DiskGuard - 磁盘空间守护2026-08-28 新增)
背景Oracle 机器磁盘曾经被写满gdrive_videos 持续下载新素材、旧文件迟迟
没被清理),触发了 rclone 的一个安全机制——同步过程中一旦遇到 IO 错误(写
不进去)就整体拒绝执行删除,导致"越满越删不掉,越删不掉越满"的死循环,
最终连新视频都下载不了。这个模块是一道独立于 rclone 同步逻辑之外的兜底:
不管上游同步是否正常,只要本机磁盘剩余空间跌破阈值,就主动清理最旧的、已
经处理完成的整段素材文件,把空间抢回来。
清理对象:仅限"整段素材"gdrive_videos 里落地的原始录像文件filename
不是 motion_ 前缀)且 status='done'(已完成分割阶段,不会再被 Video-Queue
重新捡起——不碰运动片段motion_clips 里的文件,是独立的分析产物,事件
时间轴展示、人物头像裁剪都依赖它,删了会导致图片丢失)、不碰还在
pending/processing 中的素材(避免删掉还没来得及处理的数据)。
"""
import shutil
import threading
import time
import os
from .logger import setup_logger
from .config_loader import load_config
logger = setup_logger('fam-edge.disk_guard')
class DiskGuard:
def __init__(self, db):
self.db = db
cfg = load_config().get('disk_guard', {})
self.enabled = bool(cfg.get('enabled', True))
self.check_interval_sec = int(cfg.get('check_interval_sec', 300))
self.min_free_gb = float(cfg.get('min_free_gb', 10))
# 清理到这个水位就停:留出缓冲,避免刚清理完又立刻因为新文件写入
# 跌破阈值、频繁触发清理循环
self.target_free_gb = float(cfg.get('target_free_gb', 15))
self.watch_path = cfg.get('watch_path', '/opt/fam-edge')
# 单轮最多清理几个文件:防止候选异常多时一次性删太多,先清一部分
# 观察效果下一轮检查再继续check_interval_sec 后很快就会再跑一次)
self.max_delete_per_round = int(cfg.get('max_delete_per_round', 50))
self._running = False
self._thread = None
def start(self):
if not self.enabled:
logger.info("DiskGuard 未启用")
return
self._running = True
self._thread = threading.Thread(target=self._run, daemon=True, name='disk-guard')
self._thread.start()
logger.info(
f"DiskGuard 已启动(阈值 {self.min_free_gb}GB"
f"目标水位 {self.target_free_gb}GB检查间隔 {self.check_interval_sec}s")
def stop(self):
self._running = False
if self._thread:
self._thread.join(timeout=5)
def is_alive(self) -> bool:
return self._thread is not None and self._thread.is_alive()
def _free_gb(self) -> float:
return shutil.disk_usage(self.watch_path).free / (1024 ** 3)
def _run(self):
while self._running:
try:
self.check_once()
except Exception as e:
logger.error(f"DiskGuard 检查异常: {e}", exc_info=True)
for _ in range(self.check_interval_sec):
if not self._running:
return
time.sleep(1)
def check_once(self):
"""检查一次磁盘空间,不足则清理最旧的已完成素材直到恢复到目标水位。
供后台循环调用,也可单独调用做一次性检查(比如手动触发/测试)。
"""
free_gb = self._free_gb()
if free_gb >= self.min_free_gb:
return
logger.warning(f"磁盘剩余 {free_gb:.1f}GB 低于阈值 {self.min_free_gb}GB开始清理旧素材")
self.db.record_activity(
'disk_guard', 'low_space',
f"剩余 {free_gb:.1f}GB 低于阈值 {self.min_free_gb}GB开始清理")
deleted = 0
freed_bytes = 0
while deleted < self.max_delete_per_round and self._free_gb() < self.target_free_gb:
candidate = self.db.get_oldest_purgeable_material()
if not candidate:
logger.warning("磁盘空间仍然紧张,但已经没有可清理的素材了")
self.db.record_activity(
'disk_guard', 'no_candidate',
f"剩余 {self._free_gb():.1f}GB 仍不足,且没有可清理的素材")
break
video_id, local_path = candidate['id'], candidate.get('local_path')
size = os.path.getsize(local_path) if local_path and os.path.isfile(local_path) else 0
self.db.delete_video(video_id)
deleted += 1
freed_bytes += size
logger.info(f"DiskGuard 清理素材 video_id={video_id}(约 {size/1024/1024:.0f}MB")
if deleted:
free_gb = self._free_gb()
self.db.record_activity(
'disk_guard', 'cleaned',
f"清理 {deleted} 个素材,释放约 {freed_bytes/1024**3:.1f}GB"
f"当前剩余 {free_gb:.1f}GB")
logger.info(f"DiskGuard 本轮清理完成:{deleted} 个文件,当前剩余 {free_gb:.1f}GB")

View File

@@ -1,5 +1,15 @@
"""
DsmMotionClient - 查询群晖 Surveillance Station 的运动侦测事件(预过滤用)
[已弃用 / DEPRECATED] 本模块自 2026-08-22 起不再被调用。
架构约束:甲骨文 FAM-Edge 不得反向访问 NAS。原 video_processor 在此处主动查询
NAS 的 Surveillance Station甲骨文 -> NAS违反该约束已停用。
替代方案:由 NAS 端 fam-core 的 MotionNotifier 轮询/接收 SS 事件后,主动 POST
推送到甲骨文的 /api/ss/motion落库 ss_motion_eventsvideo_processor 改用
db.has_motion_in_range_local() 做本地预过滤。本文件保留仅作参考,请勿再实例化。
---
DsmMotionClient旧实现仅作历史参考 - 查询群晖 Surveillance Station 的运动侦测事件(预过滤用)
背景: fam-edge 原来不管这段 30 分钟录像里有没有人走动,一律整段送云端 VLM 分析,
配额/耗时都花在长期无人的空转时段上。DSM 的 Surveillance Station 用

View File

@@ -91,7 +91,10 @@ def extract_frame(db, video_id: int, ts: str, width: int = FRAME_W) -> bytes:
offset = 0.0
_ensure_dir()
cache = os.path.join(CACHE_DIR, f"frame_{video_id}_{int(offset)}.jpg")
# 缓存 key 必须带 width同一 (video_id, offset) 不同调用方可能要不同分辨率
# (时间轴缩略图 400px / 头像 600px / 人物识别裁人脸要接近原始分辨率 2880px
# 不带 width 会导致后来的高分辨率请求悄悄拿到早先缓存的低分辨率帧。
cache = os.path.join(CACHE_DIR, f"frame_{video_id}_{int(offset)}_{width}.jpg")
if os.path.isfile(cache) and os.path.getsize(cache) > 0:
with open(cache, 'rb') as f:
return f.read()

View File

@@ -1,14 +1,12 @@
"""模型适配器包"""
from .base_adapter import BaseModelAdapter
from .circuit_breaker import CircuitBreaker
from .ollama_adapter import OllamaAdapter
from .gemini_adapter import GeminiAdapter
from .adapter_factory import build_adapter, build_adapters, register_adapter
__all__ = [
"BaseModelAdapter",
"CircuitBreaker",
"OllamaAdapter",
"GeminiAdapter",
"build_adapter",
"build_adapters",

View File

@@ -9,15 +9,16 @@
from typing import List
from .base_adapter import BaseModelAdapter
from .ollama_adapter import OllamaAdapter
from .gemini_adapter import GeminiAdapter
from .nvidia_adapter import NvidiaVisionAdapter
from ..logger import setup_logger
logger = setup_logger('fam-edge.adapter_factory')
# ollama 已于 2026-08-23 移除:本地模型只在问答链路里当兜底用,问答已经整个
# 抽离到独立的 ai-gateway 服务(含它自己的 ollama 适配器fam-edge 这边
# 只剩视频分析vision 角色),不再需要注册纯文本本地模型。
_ADAPTER_REGISTRY = {
"ollama": OllamaAdapter,
"gemini": GeminiAdapter,
"nvidia": NvidiaVisionAdapter,
}

View File

@@ -37,7 +37,8 @@ class BaseModelAdapter(ABC):
def __init__(self, provider_name: str, config: dict):
self.provider_name = provider_name # 如 "ollama", "gemini"
self.config = config
# 角色: vision=视觉分析, text=智能问答兜底(本地模型); 默认 vision
# 角色: vision=视觉分析, text=智能问答(问答链路已抽离到 ai-gateway
# 这里目前只有 vision 在用text 角色留给尚未清理的旧 person_service
self.role = config.get('role', 'vision')
# 模型调用统计回调(由编排层注入):
# hook(provider, model, started_at, duration_sec, success, error)
@@ -80,6 +81,15 @@ class BaseModelAdapter(ABC):
raise NotImplementedError(
f"{self.provider_name} 适配器未实现 chat()(不参与智能问答)")
def chat_stream(self, prompt: str, max_tokens: int = 512):
"""流式问答:逐块 yield 文本增量。默认实现退化为"等 chat() 整段返回后
一次性当一个大 chunk 吐出"——子类没有真流式 API或懒得接时这样也能
只是没有逐字显示的效果Gemini 有原生 SSE 流式接口,重写了这个方法。
"""
result = self.chat(prompt, max_tokens=max_tokens)
if result:
yield result
@abstractmethod
def get_timeout(self) -> int:
"""该模型的调用超时秒数"""

View File

@@ -3,8 +3,12 @@ GeminiAdapter - Google Gemini 云端 VLM 适配器
provider_name = "gemini"
模型: gemini-flash-latest
角色: vision (整视频直出结构化 JSON) + 智能问答
角色: vision (整视频直出结构化 JSON)
健康检查: GET /v1beta/models?key=...
问答chat/chat_stream已于 2026-08-23 抽离到独立的 ai-gateway 服务
跟视频分析业务无关是通用能力这里不再实现fam-edge 自己的问答请求
转发给 ai-gateway见 qa.py
熔断器: 启用
整视频分析: 用 Files API 上传完整视频 -> generateContent 直出结构化 JSON
本地不切片、不抽帧Gemini 原生支持长视频)
@@ -50,7 +54,7 @@ logger = setup_logger('fam-edge.gemini_adapter')
class GeminiAdapter(BaseModelAdapter):
"""Gemini 云端 VLM 适配器 (整视频直出结构化 JSON + 文本问答)"""
"""Gemini 云端 VLM 适配器 (整视频直出结构化 JSON,不参与问答)"""
def __init__(self, config: dict):
super().__init__("gemini", config)
@@ -397,61 +401,6 @@ class GeminiAdapter(BaseModelAdapter):
camera = load_config().get('gdrive_sync', {}).get('camera_name', '')
return build_video_prompt(known_members, event_start_time, camera)
# ------------------------------------------------------------------
# 智能问答:纯文本
# ------------------------------------------------------------------
def chat(self, prompt: str, max_tokens: int = 512) -> Optional[str]:
if self._cb.is_open():
logger.warning("Gemini 熔断器 OPEN跳过问答")
return None
if not self.api_keys:
logger.warning("Gemini API Key 未配置,跳过问答")
return None
try:
result = self._generate_text(prompt, max_tokens=max_tokens, temperature=0.3)
except Exception as e:
logger.error(f"Gemini 问答异常: {e}")
result = None
if result:
self._cb.record_success()
else:
self._cb.record_failure()
return result
def _generate_text(self, text: str, max_tokens: int, temperature: float) -> Optional[str]:
"""纯文本 generateContent按 key 轮换(同 analyze_video 共用一套轮转起点)
× 模型 fallback 链依次尝试。"""
for idx, api_key in self._rotated_keys():
key_label = self.key_labels[idx]
for model in self.model_chain:
try:
resp = requests.post(
f"{self._base_url}/models/{model}:generateContent?key={api_key}",
json={"contents": [{"parts": [{"text": text}]}],
"generationConfig": {
"temperature": temperature,
"maxOutputTokens": max_tokens}},
timeout=self.timeout
)
except requests.Timeout:
logger.warning(f"Gemini {key_label} [{model}] 问答超时")
continue
except Exception as e:
logger.error(f"Gemini {key_label} [{model}] 问答异常: {e}")
continue
if resp.status_code == 200:
cands = resp.json().get('candidates', [])
out = ''.join(
p.get('text', '')
for p in (cands[0].get('content', {}) if cands else {}).get('parts', [])
).strip() if cands else ''
if out:
return out
elif resp.status_code == 429:
logger.warning(f"Gemini {key_label} [{model}] 429切换下一模型/Key")
continue
return None
def get_timeout(self) -> int:
return self.timeout

View File

@@ -3,9 +3,12 @@ NvidiaVisionAdapter - NVIDIA NIM 云端 VLM 适配器
provider_name = "nvidia"
模型: nvidia/nemotron-3-nano-omni-30b-a3b-reasoning唯一实测确认可用的视频理解模型
角色: vision (整视频直出结构化 JSON) + 智能问答
角色: vision (整视频直出结构化 JSON)
SDK: openai (NIM 兼容 OpenAI API 规范)
问答chat/chat_stream已于 2026-08-23 抽离到独立的 ai-gateway 服务
(跟视频分析业务无关,是通用能力),这里不再实现。
整视频分析实测结论2026-08-21 用真实短视频逐个探测):
- nemotron-3-nano-omni-30b-a3b-reasoning: video_url 只认 base64 data URI
`data:video/mp4;base64,<...>`Assets API 的 asset_id 引用方式对它直接 500
@@ -44,7 +47,7 @@ except ImportError:
class NvidiaVisionAdapter(BaseModelAdapter):
"""NVIDIA NIM 云端 VLM 适配器 (整视频单次调用; 文本问答)
"""NVIDIA NIM 云端 VLM 适配器 (整视频单次调用,不参与问答)
多模型降级链(类似 Gemini flash -> flash-lite:
- model_chain = [model_name] + fallback_models
@@ -192,34 +195,6 @@ class NvidiaVisionAdapter(BaseModelAdapter):
camera = load_config().get('gdrive_sync', {}).get('camera_name', '')
return build_video_prompt(known_members, event_start_time, camera)
# ------------------------------------------------------------------
# 智能问答:纯文本
# ------------------------------------------------------------------
def chat(self, prompt: str, max_tokens: int = 2048) -> Optional[str]:
if self._cb.is_open():
logger.warning("NVIDIA 熔断器 OPEN跳过问答")
return None
if self._client is None:
logger.warning("NVIDIA 客户端未初始化,跳过问答")
return None
for model in self.model_chain:
try:
resp = self._client.chat.completions.create(
model=model,
messages=[{"role": "user", "content": prompt}],
temperature=0.3,
max_tokens=max_tokens,
timeout=self.timeout
)
content = resp.choices[0].message.content
if content:
self._cb.record_success()
return content.strip()
except Exception as e:
logger.warning(f"NVIDIA [{model}] 问答异常: {e}")
self._cb.record_failure()
return None
def get_timeout(self) -> int:
return self.timeout

View File

@@ -1,102 +0,0 @@
"""
OllamaAdapter - 本地模型适配器(仅智能问答兜底)
provider_name = "ollama"
模型: qwen2.5:7b纯文本
角色: text智能问答兜底Gemini 与 NVIDIA 均失败时启用)
健康检查: GET /api/tags
不参与视觉分析、不参与视频结构化输出(云端 VLM 直出)
"""
import requests
from typing import Dict, Optional
from .base_adapter import BaseModelAdapter
from .circuit_breaker import CircuitBreaker
from ..logger import setup_logger
logger = setup_logger('fam-edge.ollama_adapter')
class OllamaAdapter(BaseModelAdapter):
"""Ollama 本地 VLM 适配器"""
def __init__(self, config: dict):
super().__init__("ollama", config)
self.base_url = config.get('base_url', 'http://localhost:11434')
self.model_name = config.get('model_name', 'llava-phi3')
self.timeout = config.get('timeout', 240)
self.num_predict = config.get('num_predict', 500)
cb_cfg = config.get('circuit_breaker', {})
self._cb = CircuitBreaker(
threshold=cb_cfg.get('threshold', 5),
cooldown=cb_cfg.get('cooldown', 900),
enabled=cb_cfg.get('enabled', False) # 本地模型默认不启用
)
def health_check(self) -> bool:
"""GET /api/tags检查模型是否可用"""
try:
resp = requests.get(f"{self.base_url}/api/tags", timeout=10)
if resp.status_code == 200:
models = resp.json().get('models', [])
model_names = [m.get('name', '') for m in models]
# 兼容 llava-phi3:latest 等后缀
has_model = any(self.model_name in name for name in model_names)
if has_model:
logger.info(f"Ollama 健康检查通过: 模型 {self.model_name} 可用")
return True
else:
logger.warning(f"Ollama 健康检查失败: 模型 {self.model_name} 未找到,可用模型: {model_names}")
return False
return False
except Exception as e:
logger.error(f"Ollama 健康检查异常: {e}")
return False
def analyze_video(self, video_path: str,
known_members_context: str,
event_start_time: str = '') -> Optional[Dict]:
"""Ollama 为纯文本模型,不参与视频分析,返回 None降级链不会选它做视频"""
logger.info("Ollama 为纯文本模型,跳过视频分析")
return None
def get_timeout(self) -> int:
return self.timeout
def get_circuit_breaker(self) -> CircuitBreaker:
return self._cb
# ------------------------------------------------------------------
# 智能问答:纯文本(本地模型,仅作 Gemini/NVIDIA 全失败时的兜底)
# ------------------------------------------------------------------
def chat(self, prompt: str, max_tokens: int = 512) -> Optional[str]:
if self._cb.is_open():
logger.warning("Ollama 熔断器 OPEN跳过问答")
return None
try:
resp = requests.post(
f"{self.base_url}/api/generate",
json={
"model": self.model_name,
"prompt": prompt,
"stream": False,
"options": {"temperature": 0.3, "num_predict": max_tokens}
},
timeout=self.timeout
)
if resp.status_code == 200:
output = resp.json().get('response', '').strip()
if output:
self._cb.record_success()
return output
self._cb.record_failure()
else:
logger.error(f"Ollama 问答失败: {resp.status_code} {resp.text[:200]}")
self._cb.record_failure()
except requests.Timeout:
logger.error(f"Ollama 问答超时 ({self.timeout}s)")
self._cb.record_failure()
except Exception as e:
logger.error(f"Ollama 问答异常: {e}")
self._cb.record_failure()
return None

View File

@@ -33,13 +33,42 @@ class OracleDB:
def __init__(self, db_path: str):
os.makedirs(os.path.dirname(db_path), exist_ok=True)
self.db_path = db_path
self._conn = sqlite3.connect(db_path, check_same_thread=False)
self._conn.row_factory = sqlite3.Row
self._conn.execute("PRAGMA journal_mode=WAL")
self._conn.execute("PRAGMA busy_timeout=10000")
self._local = threading.local() # 每线程一条连接,见下面的 _conn
self._all_conns = [] # 仅供 close() 收尾
self._conns_lock = threading.Lock()
self._write_lock = threading.Lock() # 复合写(如 DELETE+INSERT+commit串行化
self._init_schema()
@property
def _conn(self) -> sqlite3.Connection:
"""当前线程的连接2026-09-13 从"全进程共用一条"改成每线程一条)。
原来是 __init__ 里建一条 `check_same_thread=False` 的连接给所有线程共用:
VideoQueue / PersonService / DiskGuard 三个后台线程,加上 gunicorn 的请求
线程,并发读写同一个连接对象,事务状态互相踩踏。线上长期刷三类报错,
全是这一个根因:
- `cannot start a transaction within a transaction`:一个线程的事务还
开着另一个线程又要开——DiskGuard 的清理被打断 2837 次,磁盘守护基本
靠运气生效(表现为剩余空间在 2.7G 和 16G 之间来回荡)
- `no more rows available`commit 时游标已被别的线程重置87 次
- `database is locked`每小时上百次NAS 推来的运动事件被 500 打回
sqlite3 的连接本来就不是可并发共享的对象。改成各线程各拿一条之后WAL 下
多连接读不互斥,写由 SQLite 自己排队busy_timeout 兜底等 10 秒),而
`_write_lock` 继续保证"复合写"在本进程内串行,语义不变。
"""
conn = getattr(self._local, 'conn', None)
if conn is None:
conn = sqlite3.connect(self.db_path, timeout=10, check_same_thread=False)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=10000")
self._local.conn = conn
with self._conns_lock:
self._all_conns.append(conn)
return conn
# ------------------------------------------------------------------
def _init_schema(self):
c = self._conn
@@ -110,10 +139,31 @@ class OracleDB:
detail TEXT,
ts TEXT
);
CREATE TABLE IF NOT EXISTS ss_motion_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_id INTEGER UNIQUE,
camera_id INTEGER,
event_type INTEGER,
start_time INTEGER,
duration INTEGER,
thumbnail_url TEXT,
received_at TEXT
);
CREATE TABLE IF NOT EXISTS person_identity_map (
id INTEGER PRIMARY KEY AUTOINCREMENT,
video_id INTEGER,
raw_uid TEXT,
canonical_name TEXT,
source TEXT,
updated_at TEXT,
UNIQUE(video_id, raw_uid)
);
CREATE INDEX IF NOT EXISTS idx_videos_updated ON videos(updated_at);
CREATE INDEX IF NOT EXISTS idx_events_video ON events(video_id);
CREATE INDEX IF NOT EXISTS idx_model_calls_created ON model_calls(created_at);
CREATE INDEX IF NOT EXISTS idx_activity_ts ON service_activity(ts);
CREATE INDEX IF NOT EXISTS idx_motion_window ON ss_motion_events(start_time, event_type);
CREATE INDEX IF NOT EXISTS idx_identity_map_video ON person_identity_map(video_id);
""")
# 兼容旧库:补 retry_count / file_valid / media 等列(生产-消费队列用)
cols = [r[1] for r in c.execute("PRAGMA table_info(videos)").fetchall()]
@@ -138,6 +188,14 @@ class OracleDB:
]:
if col not in pe_cols:
c.execute(ddl)
# 兼容旧库videos 表补运动片段字段(运动事件驱动架构加)
vid_cols = [r[1] for r in c.execute("PRAGMA table_info(videos)").fetchall()]
for col, ddl in [
('motion_event_id', "ALTER TABLE videos ADD COLUMN motion_event_id INTEGER"),
('camera_id', "ALTER TABLE videos ADD COLUMN camera_id INTEGER"),
]:
if col not in vid_cols:
c.execute(ddl)
self._conn.commit()
# ------------------------------------------------------------------
@@ -193,6 +251,144 @@ class OracleDB:
"ORDER BY id DESC LIMIT ?", (int(limit),)).fetchall()
return [dict(r) for r in rows]
# ------------------------------------------------------------------
# 运动侦测事件NAS 推送单向NAS -> Oracle不再反向访问 NAS
# 由 fam-core 的 MotionNotifier 轮询/接收 SS 事件后 POST 到 /api/ss/motion。
# ------------------------------------------------------------------
def record_motion_events(self, events: List[Dict]) -> int:
"""批量 upsert NAS 推送来的运动事件(按 event_id 幂等)。返回成功条数。"""
n = 0
now = _now_iso()
for e in (events or []):
eid = e.get('event_id')
if eid is None:
continue
self._conn.execute(
"""INSERT INTO ss_motion_events
(event_id, camera_id, event_type, start_time, duration,
thumbnail_url, received_at)
VALUES (?,?,?,?,?,?,?)
ON CONFLICT(event_id) DO UPDATE SET
camera_id=excluded.camera_id,
event_type=excluded.event_type,
start_time=excluded.start_time,
duration=excluded.duration,
thumbnail_url=excluded.thumbnail_url,
received_at=excluded.received_at""",
(int(eid), e.get('camera_id'), e.get('event_type'),
e.get('start_time'), e.get('duration'),
e.get('thumbnail_url'), now))
n += 1
if n:
self._conn.commit()
return n
def get_recent_motion_events(self, limit: int = 50) -> List[Dict]:
"""最近运动事件(按 start_time 倒序)。"""
rows = self._conn.execute(
"SELECT id, event_id, camera_id, event_type, start_time, duration, "
"thumbnail_url, received_at FROM ss_motion_events "
"ORDER BY start_time DESC LIMIT ?", (int(limit),)).fetchall()
return [dict(r) for r in rows]
def record_motion_heartbeat(self):
"""NAS 侧推送链路心跳(不管有没有真实事件,每隔几分钟都应该调一次)。
单独的心跳信号,跟"表里有没有历史数据"是两回事:表非空只能说明"曾经收到
过推送"推送链路后来整个挂掉NAS 服务崩溃/网络断开/DSM 侧 Webhook 规则
被误关之后表依然非空has_motion_in_range_local() 原来的判断方式会
误以为"链路健康、这段时间确认无运动"从而错误跳过分析——心跳新鲜度检查就是
堵这个漏洞的。
"""
self.set_cursor('motion_heartbeat_at', _now_iso())
def get_motion_heartbeat_age_sec(self) -> Optional[float]:
"""距上次心跳过了多少秒;从未收到过心跳返回 None。"""
v = self.get_cursor('motion_heartbeat_at')
if not v:
return None
try:
hb = datetime.strptime(v, '%Y-%m-%d %H:%M:%S').replace(
tzinfo=timezone(timedelta(hours=8)))
except ValueError:
return None
return (datetime.now(timezone(timedelta(hours=8))) - hb).total_seconds()
def has_motion_in_range_local(self, start_ts: int, end_ts: int,
camera_id: int = None,
max_heartbeat_age_sec: float = 900) -> Optional[bool]:
"""本地运动预过滤:判断 [start_ts, end_ts] 窗口内是否存在运动事件。
替代原 dsm_motion_client 反向访问 NAS 的做法。返回:
- None : 推送链路心跳缺失或过期(从未收到过 / 距上次心跳超过
max_heartbeat_age_sec说明当前无法确认 NAS -> Oracle 这条
推送链路是否存活——调用方必须 fail-open照常送云端分析
不能当作"无运动"跳过。心跳新鲜(链路确认存活)时才信任查询结果,
不再仅凭"表是否曾经非空"判断(那样链路挂了也测不出来)。
- True/False : 链路确认存活时,窗口内确有/确无运动事件。
start_ts/end_ts 为 Unix epoch与 SS 事件 start_time 同源,时区无关)。
"""
age = self.get_motion_heartbeat_age_sec()
if age is None or age > max_heartbeat_age_sec:
return None
sql = ("SELECT COUNT(*) c FROM ss_motion_events "
"WHERE event_type = 10 "
"AND start_time <= ? "
"AND (start_time + COALESCE(duration,0)) >= ?")
params = [end_ts, start_ts]
if camera_id is not None:
sql += " AND camera_id = ?"
params.append(camera_id)
cnt = self._conn.execute(sql, params).fetchone()['c']
return cnt > 0
def get_motion_events_in_range(self, start_ts: int, end_ts: int,
camera_id: int = None,
finished_grace_sec: int = 10) -> List[Dict]:
"""窗口内【已结束】的运动事件(运动片段分割用)。
返回按 start_time 升序的 [{event_id, camera_id, start_time, duration,
thumbnail_url}]。只返回已结束事件SS 事件 duration 在动作进行中会显示 0、
结束才回填真实时长,因此只分割 start_time+duration 已落在当前时刻
(含 finished_grace_sec 秒容差)的事件,进行中的等下轮结束后再处理。
"""
now_ts = int(datetime.now(timezone(timedelta(hours=8))).timestamp())
sql = ("SELECT event_id, camera_id, event_type, start_time, duration, thumbnail_url "
"FROM ss_motion_events "
"WHERE event_type = 10 "
"AND start_time <= ? "
"AND (start_time + COALESCE(duration,0)) >= ? "
"AND COALESCE(duration,0) > 0 "
"AND (start_time + COALESCE(duration,0)) <= ? + ?")
params = [end_ts, start_ts, now_ts, int(finished_grace_sec)]
if camera_id is not None:
sql += " AND camera_id = ?"
params.append(camera_id)
sql += " ORDER BY start_time ASC"
return [dict(r) for r in self._conn.execute(sql, params).fetchall()]
def has_unfinished_motion_in_range(self, start_ts: int, end_ts: int,
camera_id: int = None) -> bool:
"""窗口内是否存在【未结束】的运动事件duration 可能还在增长)。"""
now_ts = int(datetime.now(timezone(timedelta(hours=8))).timestamp())
sql = ("SELECT COUNT(*) c FROM ss_motion_events "
"WHERE event_type = 10 "
"AND start_time <= ? "
"AND (start_time + COALESCE(duration,0)) >= ? "
"AND (start_time + COALESCE(duration,0)) > ?")
params = [end_ts, start_ts, now_ts]
if camera_id is not None:
sql += " AND camera_id = ?"
params.append(camera_id)
return self._conn.execute(sql, params).fetchone()['c'] > 0
def get_video_by_motion_event_id(self, motion_event_id: int):
"""按运动事件 id 查是否已生成对应运动片段(幂等去重)。"""
cur = self._conn.execute(
"SELECT * FROM videos WHERE motion_event_id=? LIMIT 1",
(int(motion_event_id),))
return cur.fetchone()
def get_queue_status(self) -> Dict:
"""实时队列/处理状态(前端服务状态卡用)。"""
total = self._conn.execute("SELECT COUNT(*) c FROM videos").fetchone()['c']
@@ -212,6 +408,79 @@ class OracleDB:
"recent": [dict(r) for r in recent],
}
def get_segment_status(self) -> Dict:
"""运动片段分割状态(前端服务状态页「视频分割」卡)。
统计 motion_event_id 非空的运动片段记录 + 最近分割活动。
"""
seg_sql = "FROM videos WHERE motion_event_id IS NOT NULL"
total = self._conn.execute(f"SELECT COUNT(*) c {seg_sql}").fetchone()['c']
done = self._conn.execute(
f"SELECT COUNT(*) c {seg_sql} AND status='done'").fetchone()['c']
pending = self._conn.execute(
f"SELECT COUNT(*) c {seg_sql} AND status='pending'").fetchone()['c']
failed = self._conn.execute(
f"SELECT COUNT(*) c {seg_sql} AND status='failed'").fetchone()['c']
# 已分割事件数videos.motion_event_id 去重与待分割事件数ss_motion_events 未生成片段)
motion_total = self._conn.execute(
"SELECT COUNT(*) c FROM ss_motion_events WHERE event_type=10 AND COALESCE(duration,0)>0"
).fetchone()['c']
last = self._conn.execute(
"SELECT service, action, detail, ts FROM service_activity "
"WHERE service='segment' ORDER BY id DESC LIMIT 1").fetchone()
return {
"total": total,
"done": done,
"pending": pending,
"failed": failed,
"motion_events": motion_total,
"last": dict(last) if last else None,
}
def get_segment_consistency(self, gap_limit: int = 20) -> Dict:
"""事件↔片段一致性对账(数量可验证)。
- event_total : ss_motion_events 全部运动事件SS 已回灌的)
- finished : 已结束且 duration>0具备分割条件的事件数
- segmented : 已生成片段的去重事件数videos.motion_event_id
- gap_count : 已结束但未生成片段(缺口 = finished - segmented 的下界)
- gaps : 缺口明细event_id/start_time/duration北京时间
- material_range: 素材文件覆盖的 event_start_time 范围(判断缺口是否因素材缺失)
"""
now_ts = int(datetime.now(timezone(timedelta(hours=8))).timestamp())
fin = ("event_type = 10 AND COALESCE(duration,0) > 0 "
"AND (start_time + COALESCE(duration,0)) <= ?")
event_total = self._conn.execute(
"SELECT COUNT(*) c FROM ss_motion_events WHERE event_type=10"
).fetchone()['c']
finished = self._conn.execute(
f"SELECT COUNT(*) c FROM ss_motion_events WHERE {fin}", (now_ts,)
).fetchone()['c']
segmented = self._conn.execute(
"SELECT COUNT(DISTINCT motion_event_id) c FROM videos "
"WHERE motion_event_id IS NOT NULL"
).fetchone()['c']
gaps = self._conn.execute(
f"""SELECT me.event_id, me.camera_id, me.start_time, me.duration
FROM ss_motion_events me
WHERE {fin}
AND NOT EXISTS (SELECT 1 FROM videos v
WHERE v.motion_event_id = me.event_id)
ORDER BY me.start_time DESC LIMIT ?""",
(now_ts, int(gap_limit))).fetchall()
mrange = self._conn.execute(
"SELECT MIN(event_start_time) mn, MAX(event_start_time) mx "
"FROM videos WHERE motion_event_id IS NULL AND event_start_time != ''"
).fetchone()
return {
"event_total": event_total,
"finished": finished,
"segmented": segmented,
"gap_count": max(0, finished - segmented),
"gaps": [dict(r) for r in gaps],
"material_range": {"min": mrange['mn'], "max": mrange['mx']} if mrange and mrange['mn'] else None,
}
def set_video_file_status(self, video_id: int, valid: bool,
error: str = '', media_meta: dict = None):
"""登记/更新文件校验结果valid / file_error / media_meta_json"""
@@ -226,23 +495,29 @@ class OracleDB:
def ensure_video(self, filename: str, local_path: str,
camera_name: str = '', event_start_time: str = '',
duration_sec: float = 0.0, drive_file_id: str = '') -> int:
"""视频进入监听目录时登记;已存在则更新路径。返回 video_id。"""
duration_sec: float = 0.0, drive_file_id: str = '',
motion_event_id: int = None, camera_id: int = None) -> int:
"""视频进入监听目录时登记;已存在则更新路径。返回 video_id。
motion_event_id: 运动片段关联的 SS 运动事件 id非空=运动片段,非素材)。
"""
now = _now_iso()
row = self.get_video_by_filename(filename)
if row:
self._conn.execute(
"UPDATE videos SET local_path=?, camera_name=?, event_start_time=?, "
"duration_sec=?, updated_at=? WHERE id=?",
(local_path, camera_name, event_start_time, duration_sec, now, row['id']))
"duration_sec=?, motion_event_id=?, camera_id=?, updated_at=? WHERE id=?",
(local_path, camera_name, event_start_time, duration_sec,
motion_event_id, camera_id, now, row['id']))
self._conn.commit()
return row['id']
cur = self._conn.execute(
"INSERT INTO videos (drive_file_id, filename, local_path, camera_name, "
"duration_sec, event_start_time, status, created_at, updated_at) "
"VALUES (?,?,?,?,?,?, 'pending', ?, ?)",
"duration_sec, event_start_time, motion_event_id, camera_id, "
"status, created_at, updated_at) "
"VALUES (?,?,?,?,?,?,?,?, 'pending', ?, ?)",
(drive_file_id, filename, local_path, camera_name, duration_sec,
event_start_time, now, now))
event_start_time, motion_event_id, camera_id, now, now))
self._conn.commit()
return cur.lastrowid
@@ -297,6 +572,47 @@ class OracleDB:
(event_start_time, _now_iso(), video_id))
self._conn.commit()
def delete_video(self, video_id: int) -> Optional[str]:
"""删除视频会话events + videos 行)及其磁盘文件。
不清理对应的 ss_motion_events 源事件——那是运动侦测硬件推送的原始记录,
跟切出来的片段是独立生命周期只要素材已经处理完status='done',不再
被生产者重新捡起),删除片段后不会被自动重新分割。
返回被删视频的 local_path不存在则返回 None供上层判断 404
"""
with self._write_lock:
row = self._conn.execute(
"SELECT local_path FROM videos WHERE id=?", (video_id,)).fetchone()
if not row:
return None
local_path = row['local_path']
self._conn.execute("DELETE FROM events WHERE video_id=?", (video_id,))
self._conn.execute("DELETE FROM videos WHERE id=?", (video_id,))
self._conn.commit()
if local_path and os.path.isfile(local_path):
try:
os.remove(local_path)
except OSError as e:
logger.warning(f"删除视频文件失败 {local_path}: {e}")
return local_path or ''
def get_oldest_purgeable_material(self) -> Optional[Dict]:
"""磁盘空间紧张时的清理候选:最旧的、已完成分割阶段的整段素材
filename 不是 motion_ 前缀)。按 id 升序取第一个id 越小越早入库)。
只挑 status='done' 的——素材一旦完成分割就不会再被 Video-Queue 重新
捡起,删掉它的本地文件不影响任何功能(运动片段是独立文件,事件时间
轴/人物头像只依赖 motion_clips 里的片段,不依赖原始整段素材);绝不
碰 pending/processing 中的,避免删掉还没来得及处理的数据。
"""
row = self._conn.execute(
"SELECT id, local_path FROM videos "
"WHERE status='done' AND local_path IS NOT NULL AND local_path != '' "
"AND filename NOT LIKE 'motion_%' "
"ORDER BY id ASC LIMIT 1"
).fetchone()
return dict(row) if row else None
def mark_video_invalid(self, video_id: int, error: str = ''):
"""文件校验不通过(损坏/非视频等),标记 invalidproducer 不再重试。"""
now = _now_iso()
@@ -363,6 +679,143 @@ class OracleDB:
'features_text': features_text,
'event_start_time': best['event_start_time']}
# ------------------------------------------------------------------
# 人物对应关系表2026-08-22 新增):记录"某个视频里 Gemini 给的原始 uid"
# 与"闭集识别解析出的规范名"之间的映射,作为可追溯、可纠错的中间层。
#
# 设计动机events.person_appearances_json 里的 uid 只在单次视频分析内稳定,
# 同一字符串在不同视频里完全可能指向不同真人——不能靠"改 label 的
# canonical_name"来纠错(一个 label 撞了多个真人,改一次就把另一个人也带歪
# 了)。所以纠错必须落到 (video_id, raw_uid) 这一粒度,而不是全局 label。
#
# events/videos 表里实际展示用的 person_list_json / person_appearances_json /
# people_json 会在识别或纠正时被直接重写成规范名rewrite_event_person_names
# 保持"读的时候不用现查表拼接"的简单模型;这张表只作为"这次重写是怎么来的"的
# 记录 + 纠错操作的定位依据,不参与展示时的实时查询。
# ------------------------------------------------------------------
def set_identity_mapping(self, video_id: int, raw_uid: str,
canonical_name: str, source: str = 'auto_id') -> bool:
"""记录/更新 (video_id, raw_uid) -> canonical_name。manual 来源受保护,
不会被后续自动识别结果rule/auto_id覆盖。返回是否真的发生了变化
(调用方据此决定要不要顺带重写 events 展示数据)。"""
now = _now_iso()
row = self._conn.execute(
"SELECT canonical_name, source FROM person_identity_map "
"WHERE video_id=? AND raw_uid=?", (video_id, raw_uid)).fetchone()
if row:
if row['source'] == 'manual' and source != 'manual':
return False
if row['canonical_name'] == canonical_name and row['source'] == source:
return False
self._conn.execute(
"UPDATE person_identity_map SET canonical_name=?, source=?, updated_at=? "
"WHERE video_id=? AND raw_uid=?",
(canonical_name, source, now, video_id, raw_uid))
else:
self._conn.execute(
"INSERT INTO person_identity_map "
"(video_id, raw_uid, canonical_name, source, updated_at) VALUES (?,?,?,?,?)",
(video_id, raw_uid, canonical_name, source, now))
self._conn.commit()
return True
def get_identity_map_for_video(self, video_id: int) -> Dict[str, str]:
rows = self._conn.execute(
"SELECT raw_uid, canonical_name FROM person_identity_map WHERE video_id=?",
(video_id,)).fetchall()
return {r['raw_uid']: r['canonical_name'] for r in rows if r['canonical_name']}
def rewrite_event_person_names(self, video_id: int, rename_map: Dict[str, str]):
"""{当前展示名: 新名} 把该视频全部 events 的 person_list_json /
person_appearances_json[].uid / description 文本,以及 videos.people_json /
summary_json 文本里的名字替换掉。rename_map 的 key 是"事件数据里当前显示
的名字"(可能是原始 uid也可能是上一轮已经替换过的规范名——纠错场景就是
这种情况)。
description/summary_json 是大模型写的自然语言描述,"人物A"这类 uid 会
直接以文字形式出现在句子里(不只是 person_list_json 这种结构化字段)——
只替换结构化字段的话,事件卡片上方的人物徽章会显示正确的规范名,但描述
文字里还是"人物A/人物B",看着两处对不上。这里做纯文本替换来解决。
"""
if not rename_map:
return
now = _now_iso()
# 按 key 长度降序替换uid 可能带 "#2"/"#3" 这类后缀(同名冲突时的派生
# label"人物A" 是 "人物A#2" 的前缀,先替换短的会把长的也错误命中一部分,
# 长的先替换就不会被短的抢先破坏。
ordered_keys = sorted(rename_map.keys(), key=len, reverse=True)
def _rewrite_text(text: str) -> str:
if not text:
return text
for old in ordered_keys:
if old in text:
text = text.replace(old, rename_map[old])
return text
with self._write_lock:
rows = self._conn.execute(
"SELECT id, person_list_json, person_appearances_json, description "
"FROM events WHERE video_id=?", (video_id,)).fetchall()
for r in rows:
changed = False
plist = json.loads(r['person_list_json'] or '[]')
new_plist = [rename_map.get(x, x) for x in plist]
if new_plist != plist:
changed = True
pa = json.loads(r['person_appearances_json']) if r['person_appearances_json'] else None
if pa:
for p in pa:
if isinstance(p, dict) and p.get('uid') in rename_map:
p['uid'] = rename_map[p['uid']]
changed = True
new_desc = _rewrite_text(r['description'])
if new_desc != r['description']:
changed = True
if changed:
self._conn.execute(
"UPDATE events SET person_list_json=?, person_appearances_json=?, "
"description=? WHERE id=?",
(json.dumps(new_plist, ensure_ascii=False),
json.dumps(pa, ensure_ascii=False) if pa is not None
else r['person_appearances_json'],
new_desc,
r['id']))
vrow = self._conn.execute(
"SELECT people_json, summary_json FROM videos WHERE id=?",
(video_id,)).fetchone()
if vrow:
v_changed = False
new_people_json = vrow['people_json']
if vrow['people_json']:
plist = json.loads(vrow['people_json'])
new_plist = [rename_map.get(x, x) for x in plist]
if new_plist != plist:
new_people_json = json.dumps(new_plist, ensure_ascii=False)
v_changed = True
new_summary = _rewrite_text(vrow['summary_json'])
if new_summary != vrow['summary_json']:
v_changed = True
if v_changed:
self._conn.execute(
"UPDATE videos SET people_json=?, summary_json=?, updated_at=? "
"WHERE id=?",
(new_people_json, new_summary, now, video_id))
self._conn.commit()
def correct_video_identity(self, video_id: int, current_name: str, new_name: str):
"""纠错入口(人物管理页 / 事件时间轴"修正"按钮都走这个):把某视频里当前
展示为 current_name 的人物改成 new_name。写 manual 来源,受保护不会被后续
自动识别覆盖回去;同时立即重写这段视频的展示数据,不用等下一轮识别。"""
row = self._conn.execute(
"SELECT raw_uid FROM person_identity_map WHERE video_id=? AND canonical_name=?",
(video_id, current_name)).fetchone()
# 没有映射记录(比如这条数据是老流水线时代产出的,从没跑过闭集识别)
# 就把 current_name 本身当 raw_uid 存一条新映射
raw_uid = row['raw_uid'] if row else current_name
self.set_identity_mapping(video_id, raw_uid, new_name, source='manual')
self.rewrite_event_person_names(video_id, {current_name: new_name})
def get_events_for_label(self, label: str, limit: int = 6):
"""该人物canonical_name 或 UID label出现的候选事件按时间倒序最近优先
@@ -509,8 +962,11 @@ class OracleDB:
row['features_json'] if row else None, features) if row else (
self._merge_features(None, features))
if row:
# manual 覆盖 llmllm 不覆盖 manual
if source == 'manual' or row['source'] != 'manual':
# manual/auto_id 覆盖 llmllm 不覆盖 manual/auto_idauto_id 是闭集人物
# 识别的确定性结论——比 llm 的文字特征合并猜测可靠得多,同样需要保护,
# 不能被后续 person_service 的 llm 合并跑批悄悄覆盖回去)
_protected = ('manual', 'auto_id', 'rule')
if source in _protected or row['source'] not in _protected:
self._conn.execute(
"UPDATE people SET canonical_name=?, source=?, appearances=appearances+1, "
"features_json=?, display_uid=?, updated_at=? WHERE label=?",
@@ -568,7 +1024,8 @@ class OracleDB:
now = _now_iso()
row = self._conn.execute("SELECT * FROM people WHERE label=?", (label,)).fetchone()
if row:
if source == 'manual' or row['source'] != 'manual':
_protected = ('manual', 'auto_id', 'rule')
if source in _protected or row['source'] not in _protected:
self._conn.execute(
"UPDATE people SET appearances=?, source=?, updated_at=? WHERE label=?",
(int(count), source, now, label))
@@ -617,6 +1074,11 @@ class OracleDB:
model_calls = self._conn.execute(
"SELECT * FROM model_calls WHERE created_at >= ? ORDER BY id ASC",
(since_iso,)).fetchall()
# 人物对应关系表(甲骨文不稳定,提取出的有效数据都要同步到 NAS 防丢失;
# 这张表是识别结果的可追溯记录 + 纠错依据,同样纳入增量同步)
identity_map = self._conn.execute(
"SELECT * FROM person_identity_map WHERE updated_at > ? ORDER BY id ASC",
(since_iso,)).fetchall()
def _ser(row):
d = dict(row)
@@ -627,6 +1089,7 @@ class OracleDB:
"events": [_ser(e) for e in events],
"people": [_ser(p) for p in people],
"model_calls": [_ser(m) for m in model_calls],
"identity_map": [_ser(m) for m in identity_map],
"server_time": _now_iso(),
}
@@ -644,4 +1107,12 @@ class OracleDB:
self._conn.commit()
def close(self):
self._conn.close()
"""关掉所有线程开过的连接(不只当前线程这一条)。"""
with self._conns_lock:
conns, self._all_conns = self._all_conns, []
for c in conns:
try:
c.close()
except sqlite3.Error:
pass
self._local = threading.local()

View File

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

@@ -1,35 +1,136 @@
"""
QA - 智能问答编排
QA - 智能问答代理客户端2026-08-23 问答链路整体抽离到独立 ai-gateway 服务后重写)
run_qa(prompt): 按 models 顺序尝试 chat(),首个成功返回 (answer, provider)。
顺序 = vision 模型(Gemini -> NVIDIA) + text 模型(Ollama 兜底)。
即 Gemini -> NVIDIA -> Ollama 三级降级。
原来的问答编排本体NVIDIA 文字模型链 -> Gemini 非 flash 文字模型 -> 本地
Ollama 兜底,含 key 轮换/熔断/降级)已经整个搬到独立的 ai-gateway 服务
OpenAI 兼容协议 /v1/chat/completions跟视频分析业务解耦别的项目也能
直接用 OpenAI SDK 接入。fam-edge 这边现在只是一个转发客户端:调 ai-gateway
把它的 OpenAI 格式响应翻译回 fam-edge 原有的 (answer, provider) / 流式事件
字典契约,上层 api_gateway.py 的 /api/edge/chat/ask(/stream) 端点和 fam-core
的调用方完全不用改。
多 provider 之间失败降级(一个模型没吐出任何内容才换下一个、已经开始吐字后
中途失败不悄悄换源)现在整个发生在 ai-gateway 内部,对这个客户端不可见——
本客户端只会看到最终成功 provider 的分块流,或者全部失败时的空流。
"""
import json
import os
from typing import Optional, Tuple
import requests
from .logger import setup_logger
from .config_loader import load_config
from .model_adapters.adapter_factory import build_adapters
logger = setup_logger('fam-edge.qa')
class QAOrchestrator:
def __init__(self):
self.config = load_config()
self.adapters = build_adapters(self.config.get('models', []))
cfg = load_config().get('ai_gateway', {})
self.base_url = (cfg.get('base_url') or 'http://127.0.0.1:5100').rstrip('/')
self.token = self._resolve_token(cfg.get('token', ''))
self.timeout = cfg.get('timeout', 60)
def _resolve_token(self, raw: str) -> str:
if raw.startswith('${') and raw.endswith('}'):
return os.environ.get(raw[2:-1], '')
return raw
def _headers(self) -> dict:
headers = {"Content-Type": "application/json"}
if self.token:
headers["Authorization"] = f"Bearer {self.token}"
return headers
def run_qa(self, prompt: str,
max_tokens: int = 1024) -> Tuple[Optional[str], Optional[str]]:
"""依次尝试各适配器的 chat(),返回 (answer, provider)。"""
for adapter in self.adapters:
try:
answer = adapter.chat(prompt, max_tokens=max_tokens)
except Exception as e:
logger.warning(f"QA {adapter.provider_name} 异常: {e}")
continue
if answer:
logger.info(f"QA 命中 provider={adapter.provider_name}")
return answer, adapter.provider_name
logger.info(f"QA {adapter.provider_name} 无返回,降级下一模型")
return None, None
"""调 ai-gateway 非流式接口,返回 (answer, provider)。"""
try:
resp = requests.post(
f"{self.base_url}/v1/chat/completions",
headers=self._headers(),
json={"messages": [{"role": "user", "content": prompt}],
"max_tokens": max_tokens, "stream": False},
timeout=self.timeout)
except Exception as e:
logger.warning(f"QA ai-gateway 请求异常: {e}")
return None, None
if resp.status_code != 200:
logger.warning(f"QA ai-gateway 返回 {resp.status_code}: {resp.text[:200]}")
return None, None
try:
data = resp.json()
answer = data["choices"][0]["message"]["content"]
except Exception as e:
logger.warning(f"QA ai-gateway 响应解析失败: {e}")
return None, None
if not answer:
return None, None
provider = data.get("provider")
logger.info(f"QA 命中 provider={provider}")
return answer, provider
def run_qa_stream(self, prompt: str, max_tokens: int = 1024):
"""流式版:转发 ai-gateway 的 SSE 分块,翻译回原有事件字典契约。
事件类型:
{"type":"provider_trying","provider":p} 流里第一次看到这个 provider
{"type":"chunk","provider":p,"text":t} 文本增量
{"type":"done","provider":p} 成功结束(至少吐出过一块)
{"type":"all_failed"} 请求失败或没有任何文本产出
"""
try:
resp = requests.post(
f"{self.base_url}/v1/chat/completions",
headers=self._headers(),
json={"messages": [{"role": "user", "content": prompt}],
"max_tokens": max_tokens, "stream": True},
timeout=self.timeout, stream=True)
except Exception as e:
logger.warning(f"QA ai-gateway 流式请求异常: {e}")
yield {"type": "all_failed"}
return
if resp.status_code != 200:
logger.warning(f"QA ai-gateway 流式返回 {resp.status_code}: {resp.text[:200]}")
yield {"type": "all_failed"}
return
# 响应体固定 UTF-8但 Content-Type 不一定带 charsetrequests 会自己猜
# 编码——猜错就是中文乱码,强制指定跳过嗅探(同源坑见 gemini_adapter 历史修复)。
resp.encoding = 'utf-8'
current_provider = None
got_any = False
try:
for line in resp.iter_lines(decode_unicode=True):
if not line or not line.startswith('data: '):
continue
payload = line[len('data: '):]
if payload == '[DONE]':
break
try:
chunk = json.loads(payload)
except ValueError:
continue
if 'error' in chunk:
logger.warning(f"QA ai-gateway 流式错误: {chunk['error']}")
break
provider = chunk.get('provider')
if provider and provider != current_provider:
current_provider = provider
yield {"type": "provider_trying", "provider": provider}
choices = chunk.get('choices') or []
if not choices:
continue
text = (choices[0].get('delta') or {}).get('content')
if text:
got_any = True
yield {"type": "chunk", "provider": current_provider, "text": text}
except Exception as e:
logger.warning(f"QA ai-gateway 流式读取异常: {e}")
if got_any:
logger.info(f"QA 流式命中 provider={current_provider}")
yield {"type": "done", "provider": current_provider}
else:
yield {"type": "all_failed"}

View File

@@ -15,6 +15,7 @@ import os
import re
import json
import subprocess
import tempfile
from datetime import datetime, timedelta, timezone
from typing import Dict, List, Optional
@@ -22,7 +23,8 @@ from .logger import setup_logger
from .config_loader import load_config
from .model_adapters.adapter_factory import build_adapters
from .model_adapters.base_adapter import BaseModelAdapter
from .dsm_motion_client import DsmMotionClient
from .person_identifier import PersonIdentifier
from . import frame_service
from . import oracle_db
logger = setup_logger('fam-edge.video_processor')
@@ -191,10 +193,22 @@ class VideoProcessor:
'file_validate', True))
self.parse_start = self.config.get('gdrive_sync', {}).get(
'parse_start_from_filename', True)
# 运动预过滤"无运动"结论的可信心跳时限NAS 推送心跳超过这么久没更新,
# 就认为推送链路可能已经挂了fail-open照常分析
self.motion_max_heartbeat_age_sec = float(self.config.get(
'dsm_motion_prefilter', {}).get('max_heartbeat_age_sec', 900))
# 运动片段分割(运动事件驱动架构):整段素材按 ss_motion_events 分割片段再分析
seg = self.config.get('motion_segment', {})
self.motion_clips_dir = seg.get('clips_dir', '/opt/fam-edge/motion_clips')
self.motion_keep_audio = bool(seg.get('keep_audio', True))
self.motion_min_duration = float(seg.get('min_duration_sec', 1))
self.motion_grace_sec = int(seg.get('unfinished_grace_sec', 10))
# 闭集人物识别(家里固定 5 人):汤圆/媳妇/奶奶 用性别年龄规则;爷爷/爸爸 用
# person_identifier 视觉大模型比对,每片段每个 uid 只调一次
self.person_identifier = PersonIdentifier(self.config.get('person_identifier', {}))
adapters = build_adapters(self.config.get('models', []))
self.vision_adapters: Dict[str, BaseModelAdapter] = {
a.provider_name: a for a in adapters if a.get_role() == 'vision'}
self.dsm_motion = DsmMotionClient(self.config.get('dsm_motion_prefilter', {}))
def _ordered_vision_adapters(self) -> List[BaseModelAdapter]:
ordered = []
@@ -208,16 +222,21 @@ class VideoProcessor:
return ordered
def process_video(self, video_id: int, filename: str, local_path: str,
timeout_multiplier: float = 1.0) -> bool:
"""处理一个视频记录返回是否成功
timeout_multiplier: float = 1.0):
"""处理一个视频记录返回 (ok: bool, new_clip_ids: List[int])
- 素材整段视频(无 motion_event_id不再整段分析而是按 ss_motion_events
里【已结束】的运动事件分割成运动片段,返回片段 video_id 列表(调用方负责
入队分析);素材记录在全部事件结束后标记 done仍有未结束事件时保持
pending 由生产者下轮重试。
- 运动片段(有 motion_event_id本身即运动时段直接云端分析跳过预过滤
timeout_multiplier: 云端模型消费的超时放大倍数(如 2 = 在配置 timeout 上 ×2
每次调用前临时放大对应 adapter.timeout调用后恢复避免影响其他调用方。
"""
if not os.path.isfile(local_path):
logger.error(f"[video_id={video_id}] 文件不存在,跳过: {local_path}")
self.db.mark_video_failed(video_id, "file_missing")
return False
return False, []
# 处理前二次确认文件有效性(防止登记后文件被破坏/截断;校验结果落库)
if self.file_validate:
@@ -226,36 +245,29 @@ class VideoProcessor:
logger.error(f"[video_id={video_id}] 文件校验失败({verr}),标记 failed: {local_path}")
self.db.set_video_file_status(video_id, False, verr)
self.db.mark_video_failed(video_id, f"invalid_file:{verr}")
return False
return False, []
if vmeta:
self.db.set_video_file_status(video_id, True, '', vmeta)
else:
vmeta = None
camera_name = self.db.get_video_by_filename(filename)['camera_name'] or ''
event_start = ''
if self.parse_start:
vrow = self.db.get_video_by_filename(filename)
is_motion_clip = bool(vrow and vrow['motion_event_id'])
camera_name = vrow['camera_name'] if vrow else ''
event_start = vrow['event_start_time'] if vrow else ''
if not event_start and self.parse_start:
event_start = _parse_event_start_from_filename(filename)
# 回写解析到的开始时间
if event_start:
self.db.set_event_start_time(video_id, event_start)
# DSM 运动侦测预过滤:这段时间窗口里群晖自己记录的运动事件一条都没有,
# 就跳过云端分析(省配额)。查询本身失败/未配置一律 fail-open照常分析
# 绝不能因为这层可选优化漏检真实事件。
duration_sec = (vmeta or {}).get('duration_sec') if self.file_validate else None
if event_start and duration_sec:
try:
start_dt = datetime.strptime(event_start, '%Y-%m-%d %H:%M:%S')
has_motion = self.dsm_motion.has_motion_in_range(start_dt, duration_sec)
except ValueError:
has_motion = None
if has_motion is False:
logger.info(f"[video_id={video_id}] DSM 运动预过滤:该时段无运动,跳过云端分析")
self.db.mark_video_processed(
video_id, "(自动跳过:该时段未检测到运动)", [], [], 'skipped_no_motion')
return True
if not is_motion_clip:
# ---- 素材整段视频:分割成运动片段,不整段分析 ----
return self._segment_source_video(
video_id, local_path, event_start, camera_name, vmeta)
# ---- 运动片段:本身即运动时段,直接云端分析(跳过运动预过滤)----
known = self.db.get_known_members_context()
logger.info(f"[video_id={video_id}] 开始整视频分析: {filename} "
logger.info(f"[video_id={video_id}] 开始运动片段分析: {filename} "
f"(event_start={event_start}, known_members={'' if known else ''})")
last_err = "no_vision_adapter"
@@ -271,7 +283,7 @@ class VideoProcessor:
logger.info(f"[video_id={video_id}] {adapter.provider_name} 超时 "
f"{orig_timeout}s -> {adapter.timeout}s (×{timeout_multiplier})")
try:
logger.info(f"[video_id={video_id}] 尝试 {adapter.provider_name} 整视频分析")
logger.info(f"[video_id={video_id}] 尝试 {adapter.provider_name} 运动片段分析")
result = adapter.analyze_video(local_path, known, event_start)
except Exception as e:
logger.error(f"[video_id={video_id}] {adapter.provider_name} 异常: {e}")
@@ -281,14 +293,117 @@ class VideoProcessor:
adapter.timeout = orig_timeout
if result:
self._store_result(video_id, result)
return True
return True, []
else:
last_err = f"{adapter.provider_name}_failed"
logger.warning(f"[video_id={video_id}] {adapter.provider_name} 未返回结果,降级下一模型")
logger.error(f"[video_id={video_id}] 所有视觉模型失败,标记 failed: {last_err}")
self.db.mark_video_failed(video_id, last_err)
return False
return False, []
# ------------------------------------------------------------------
# 运动片段分割(运动事件驱动架构)
# ------------------------------------------------------------------
def _segment_source_video(self, video_id: int, local_path: str, event_start: str,
camera_name: str, vmeta: dict):
"""整段素材 -> 按已结束运动事件分割运动片段。返回 (ok, clip_ids)。
素材视频不再整段送云端分析;仅按运动事件窗口切出片段交给下游分析。
"""
if not event_start:
logger.info(f"[video_id={video_id}] 素材无开始时间,无法对齐运动事件,标记 done")
self.db.mark_video_processed(
video_id, "(素材无开始时间,无法分割运动片段)", [], [], 'motion_segment')
return True, []
try:
start_dt = datetime.strptime(event_start, '%Y-%m-%d %H:%M:%S').replace(
tzinfo=timezone(timedelta(hours=8)))
start_ts = int(start_dt.timestamp())
except ValueError:
logger.warning(f"[video_id={video_id}] 素材开始时间解析失败: {event_start}")
self.db.mark_video_processed(
video_id, "(素材开始时间解析失败,无法分割)", [], [], 'motion_segment')
return True, []
if vmeta is None:
ok, verr, vmeta = validate_video(local_path)
if not ok:
self.db.set_video_file_status(video_id, False, verr)
self.db.mark_video_failed(video_id, f"invalid_file:{verr}")
return False, []
dur_sec = float((vmeta or {}).get('duration_sec') or 0)
end_ts = start_ts + int(dur_sec) if dur_sec > 0 else start_ts + 3600
clip_ids = self._segment_motion_clips(local_path, start_ts, end_ts, camera_name)
if self.db.has_unfinished_motion_in_range(start_ts, end_ts):
# 仍有进行中的事件duration 未定型):保持 pendingproducer 下轮重试
logger.info(f"[video_id={video_id}] 素材仍有未结束运动事件,保持 pending 下轮再分割")
return True, clip_ids
self.db.mark_video_processed(
video_id, f"(整段素材已分割 {len(clip_ids)} 段运动片段)", [], [], 'motion_segment')
return True, clip_ids
def _segment_motion_clips(self, src_path: str, start_ts: int, end_ts: int,
camera_name: str) -> List[int]:
"""按窗口内已结束运动事件从整段素材分割运动片段。返回片段 video_id 列表。
幂等:已按 motion_event_id 生成过片段的跳过;片段文件已存在的跳过分割。
"""
clips: List[int] = []
for me in self.db.get_motion_events_in_range(
start_ts, end_ts, finished_grace_sec=self.motion_grace_sec):
eid = me['event_id']
if self.db.get_video_by_motion_event_id(eid):
continue
dur = float(me.get('duration') or 0)
if dur < self.motion_min_duration:
continue
offset = float(me['start_time'] - start_ts)
if offset < -5 or offset > (end_ts - start_ts) + 5:
continue # 事件窗口与素材窗口无重叠
offset = max(0.0, offset)
os.makedirs(self.motion_clips_dir, exist_ok=True)
clip_fn = f"motion_{eid}_{me['start_time']}.mp4"
clip_path = os.path.join(self.motion_clips_dir, clip_fn)
if not os.path.isfile(clip_path):
if not self._run_segment_ffmpeg(src_path, offset, dur, clip_path):
logger.error(f"运动片段分割失败: {clip_fn}")
continue
start_iso = datetime.fromtimestamp(
me['start_time'], tz=timezone(timedelta(hours=8))
).strftime('%Y-%m-%d %H:%M:%S')
cid = self.db.ensure_video(
filename=clip_fn, local_path=clip_path, camera_name=camera_name,
event_start_time=start_iso, duration_sec=dur,
drive_file_id=f"motion_{eid}", motion_event_id=eid,
camera_id=me.get('camera_id'))
self.db.record_activity('segment', 'clip', f"{clip_fn} (event={eid})")
clips.append(cid)
return clips
def _run_segment_ffmpeg(self, src_path: str, offset: float, dur: float,
out_path: str) -> bool:
"""ffmpeg 从整段素材切运动片段:-ss/-t 定位,视频 copy 免转码,
音频按配置保留pcm_alaw -> aac 转码)或丢弃(-an"""
import shutil
ffmpeg = shutil.which('ffmpeg') or 'ffmpeg'
args = [ffmpeg, '-y', '-hide_banner', '-loglevel', 'error',
'-ss', f'{offset:.3f}', '-i', src_path, '-t', f'{dur:.3f}',
'-c:v', 'copy']
if self.motion_keep_audio:
args += ['-c:a', 'aac', '-b:a', '64k']
else:
args += ['-an']
args += ['-avoid_negative_ts', 'make_zero', out_path]
try:
proc = subprocess.run(args, capture_output=True, timeout=300)
if proc.returncode != 0:
logger.error(f"ffmpeg 分割失败: "
f"{proc.stderr.decode(errors='ignore')[:200]}")
return False
return os.path.isfile(out_path) and os.path.getsize(out_path) > 0
except (subprocess.TimeoutExpired, OSError) as e:
logger.error(f"ffmpeg 分割异常: {e}")
return False
def _store_result(self, video_id: int, result: Dict):
events = result.get('events', [])
@@ -368,13 +483,131 @@ class VideoProcessor:
elif k not in merged:
merged[k] = v_str or 'unknown'
uid_features[uid] = merged
# 闭集人物识别(家里固定 4 人):汤圆/媳妇 用性别年龄规则免费识别source=rule
# 爷爷/爸爸 每个 uid同一片段内视为同一人不逐事件重复调用用视觉大模型
# 比对一次source=auto_id。resolved: {原始 uid: (规范名, 来源)}
resolved = self._resolve_closed_set_identities(video_id, uid_features, norm_events)
# 人物对应关系表:记录 (video_id, raw_uid) -> canonical_name并把这段视频
# 展示用的 events/videos 数据直接重写成规范名(读的时候不用现查表拼接)。
# manual 纠正过的映射受保护,这里不会覆盖。
rename_map = {}
for uid, (canonical, source) in resolved.items():
if self.db.set_identity_mapping(video_id, uid, canonical, source=source):
rename_map[uid] = canonical
if rename_map:
self.db.rewrite_event_person_names(video_id, rename_map)
for p in people:
if p and p not in ('无人', ''):
feats = uid_features.get(p)
canonical, source = resolved.get(p, ('', 'llm'))
# 已解析的人物直接用规范名作为 people 表的 label跨视频天然汇总到
# 同一行;解析不了的沿用原始 uid跟旧行为一致留给下次/人工确认)
label = canonical or p
if feats:
self.db.upsert_person(p, source='llm', features=feats, display_uid=p)
self.db.upsert_person(label, canonical_name=canonical, source=source,
features=feats, display_uid=p)
else:
self.db.upsert_person(p, source='llm')
self.db.upsert_person(label, canonical_name=canonical, source=source)
logger.info(f"[video_id={video_id}] 已落库: summary={len(summary)}字, "
f"events={len(norm_events)}, people={people}, "
f"with_features={len(uid_features)}")
f"with_features={len(uid_features)}, 闭集识别={resolved}")
# 家里赤膊/赤裸上身的成年人只有爸爸一个(用户原话:"赤裸的大人都是爸爸,
# 家里没有其他人会赤裸")——命中即免费直判,不用等视觉大模型比对,比
# auto_id 更快更准(实测已经纠正过一条被 auto_id 误判成爷爷的案例)。
# 只匹配成年人(外层已经先过滤掉 age_band 是幼儿/儿童的),小孩光膀子玩
# 很正常,不适用这条规则。
_SHIRTLESS_KEYWORDS = ('赤裸', '光着上身', '赤膊', '裸体', '光膀子',
'上身赤裸', '未穿上衣')
def _resolve_closed_set_identities(self, video_id: int, uid_features: Dict,
norm_events: List[Dict]) -> Dict[str, tuple]:
"""闭集人物识别:返回 {uid: (canonical_name, source)}。
汤圆(幼儿/儿童特征)、媳妇/奶奶(成年女性按年龄段区分:老年=奶奶,
其余=媳妇)、赤膊成年人(家里只有爸爸会赤膊)靠 Gemini 已经产出的
性别/年龄/衣着字段直接判断source='rule'),验证过命中率接近
100%,不需要额外模型调用。爷爷/爸爸两个成年男性穿戴整齐时外观规则/
人脸向量都区分不开(验证过),改用视觉大模型比对参考图
source='auto_id'),每个 uid 只取本片段内最大 bbox 的一次出现判断
一次,不逐事件重复调用。
2026-08-29 新增奶奶:家里从 4 人变成 5 人后,"媳妇=唯一成年女性"
这条规则的前提被打破了(现在有两个成年女性)。先用免费的年龄段
字段区分(老年=奶奶,参考 Gemini 对"爷爷"的判断也是老年)——如果
后续观察发现年龄段判断不稳定导致误判,再考虑改成跟爷爷/爸爸一样
的视觉比对方案。
"""
resolved: Dict[str, tuple] = {}
for uid, feats in uid_features.items():
gender = str(feats.get('gender', '') or '').strip()
age_band = str(feats.get('age_band', '') or '').strip()
clothing = str(feats.get('clothing', '') or '')
if age_band in ('幼儿', '儿童'):
resolved[uid] = ('汤圆', 'rule')
elif gender == '':
resolved[uid] = ('奶奶' if age_band == '老年' else '媳妇', 'rule')
elif gender == '':
if any(k in clothing for k in self._SHIRTLESS_KEYWORDS):
resolved[uid] = ('爸爸', 'rule')
continue
crop = self._best_crop_for_uid(video_id, uid, norm_events)
if crop:
person = self.person_identifier.classify_adult_male(crop)
if person:
resolved[uid] = (person, 'auto_id')
return resolved
def _best_crop_for_uid(self, video_id: int, uid: str, norm_events: List[Dict]) -> Optional[bytes]:
"""取该 uid 在本片段里最大 bbox 的一次出现裁剪成一张人物截图jpeg bytes
bbox 缺失时(实测偶发:某些云端响应——尤其 flash-lite 兜底——没有带
person_appearances.bbox 字段)回退到该 uid 第一次出现时刻的整帧居中裁剪,
跟 build_avatar() 已有的兜底逻辑一致,好过直接放弃识别这个人。
"""
best_ts, best_bbox, best_area = None, None, 0
first_ts = None
for ev in norm_events:
for pa in ev.get('person_appearances', []):
if pa.get('uid') != uid:
continue
if first_ts is None:
first_ts = ev.get('timestamp')
bbox = pa.get('bbox')
if not bbox or len(bbox) != 4:
continue
ymin, xmin, ymax, xmax = bbox
area = max(0, ymax - ymin) * max(0, xmax - xmin)
if area > best_area:
best_area, best_ts, best_bbox = area, ev.get('timestamp'), bbox
if best_ts is None and first_ts is None:
return None
frame = frame_service.extract_frame(self.db, video_id, best_ts or first_ts, width=2880)
if frame is None:
return None
frame_service._ensure_dir()
fd, tmp = tempfile.mkstemp(suffix='.jpg', dir=frame_service.CACHE_DIR)
os.close(fd)
try:
with open(tmp, 'wb') as f:
f.write(frame)
size = frame_service._out_size(tmp)
if not size:
return None
if best_bbox is not None:
px = frame_service._bbox_to_pixels(best_bbox, *size)
ok = frame_service._crop_ffmpeg(tmp, px, 300)
else:
ok = frame_service._center_square_ffmpeg(tmp, 300)
if not ok:
return None
with open(tmp, 'rb') as f:
return f.read()
finally:
if os.path.exists(tmp):
try:
os.remove(tmp)
except OSError:
pass

View File

@@ -84,7 +84,15 @@ class VideoQueue:
if row is None:
# 新文件:先过 mtime 稳定窗口 + 可解码校验,通过才登记入队;失败标记 invalid
if self.file_validate:
if time.time() - os.path.getmtime(path) < self.stable_window_sec:
try:
mtime = os.path.getmtime(path)
except OSError:
# 列目录之后、读 mtime 之前DiskGuard 可能刚好把这个文件清掉了
# (两个后台线程的正常竞态)。跳过它就行——不 catch 的话整轮扫描
# 会被这一个文件中断,后面的新素材本轮都登记不上。
logger.info(f"文件已不在(可能刚被 DiskGuard 清理),跳过本轮: {fn}")
continue
if time.time() - mtime < self.stable_window_sec:
logger.info(f"文件仍在写入mtime 未稳定),跳过本轮: {fn}")
continue
ok, verr, vmeta = validate_video(path)
@@ -214,9 +222,12 @@ class VideoQueue:
"started_at": None}
self.db.record_activity('queue', 'process_start', f"video {video_id} {fn}")
try:
ok = processor.process_video(
ok, clip_ids = processor.process_video(
video_id, fn, row['local_path'],
timeout_multiplier=self.timeout_multiplier)
# 素材整段视频分割出的运动片段入队分析(片段不在监听目录,需手动入队)
for cid in (clip_ids or []):
self._enqueue(cid)
if ok:
self._stats["consumed_ok"] += 1
self.db.record_activity('queue', 'process_done', f"video {video_id} {fn}")

241
fam-edge/tests/test_auth.py Normal file
View File

@@ -0,0 +1,241 @@
"""统一登录2026-09-12 从 NAS fam-core 迁来)的单测。
除了把原 fam-core/tests/test_auth.py 的用例搬过来,额外盯死三件迁移时的关键行为:
回调失败不准再 302防死循环复发、服务端调 auth-hub 走内网地址但 iss 仍按公网校验、
会话 cookie 必须是无状态的(不依赖任何进程内状态)。
"""
import time
import jwt
import pytest
from flask import Flask
from fam_edge import auth
@pytest.fixture(autouse=True)
def _clean_env_and_state(monkeypatch):
for var in ('AUTH_HUB_ISSUER', 'AUTH_HUB_INTERNAL_BASE', 'AUTH_HUB_CLIENT_ID',
'AUTH_HUB_CLIENT_SECRET', 'AUTH_HUB_REDIRECT_URI', 'FAM_SESSION_SECRET'):
monkeypatch.delenv(var, raising=False)
auth._pending.clear()
auth._warned_unconfigured = False
auth._jwks_client = None
yield
auth._pending.clear()
def _set_env(monkeypatch, internal_base=None):
monkeypatch.setenv('AUTH_HUB_ISSUER', 'https://auth.example')
monkeypatch.setenv('AUTH_HUB_CLIENT_ID', 'fam-core')
monkeypatch.setenv('AUTH_HUB_CLIENT_SECRET', 'sekret')
monkeypatch.setenv('AUTH_HUB_REDIRECT_URI', 'https://cam.example/api/auth/callback')
monkeypatch.setenv('FAM_SESSION_SECRET', 'session-signing-secret')
if internal_base:
monkeypatch.setenv('AUTH_HUB_INTERNAL_BASE', internal_base)
@pytest.fixture
def client():
app = Flask(__name__)
app.register_blueprint(auth.auth_bp)
return app.test_client()
def _cookie(secret='session-signing-secret', username='ericwyuan', ttl=3600):
now = int(time.time())
return jwt.encode({'sub': '1', 'username': username, 'iat': now, 'exp': now + ttl},
secret, algorithm='HS256')
# ---------------------------------------------------------------------------
# 配置齐全性fail closed
# ---------------------------------------------------------------------------
def test_require_config_fails_closed_when_unconfigured():
assert auth._require_config() is None
def test_require_config_fails_closed_without_session_secret(monkeypatch):
_set_env(monkeypatch)
monkeypatch.delenv('FAM_SESSION_SECRET')
assert auth._require_config() is None
def test_login_rejects_when_unconfigured(client):
resp = client.get('/login')
assert resp.status_code == 503
# 配置缺失也不能跳转,否则同样会跟 auth-hub 对跳
assert 'Location' not in resp.headers
# ---------------------------------------------------------------------------
# /login
# ---------------------------------------------------------------------------
def test_login_redirects_to_authorize_with_pkce(client, monkeypatch):
_set_env(monkeypatch)
resp = client.get('/login')
assert resp.status_code == 302
location = resp.headers['Location']
assert location.startswith('https://auth.example/authorize?')
assert 'code_challenge=' in location
assert 'code_challenge_method=S256' in location
assert 'client_id=fam-core' in location
assert len(auth._pending) == 1
def test_login_always_sends_browser_to_public_issuer(client, monkeypatch):
"""内网地址只给服务端自己用,浏览器必须跳公网——跳 127.0.0.1 用户当然打不开。"""
_set_env(monkeypatch, internal_base='http://127.0.0.1:5300')
resp = client.get('/login')
assert resp.headers['Location'].startswith('https://auth.example/authorize?')
def test_login_redirects_home_when_already_authed(client, monkeypatch):
_set_env(monkeypatch)
client.set_cookie('fam_session', _cookie())
resp = client.get('/login')
assert resp.status_code == 302
assert resp.headers['Location'] == '/'
# ---------------------------------------------------------------------------
# 回调:失败分支一律错误页,绝不再跳 /login旧实现死循环的根因
# ---------------------------------------------------------------------------
def test_callback_unknown_state_shows_error_page_without_redirecting(client, monkeypatch):
_set_env(monkeypatch)
resp = client.get('/api/auth/callback?state=nope&code=abc')
assert resp.status_code == 400
assert 'Location' not in resp.headers
assert 'fam_session' not in resp.headers.get('Set-Cookie', '')
def test_callback_idp_error_shows_error_page_without_redirecting(client, monkeypatch):
_set_env(monkeypatch)
resp = client.get('/api/auth/callback?error=access_denied&state=x')
assert resp.status_code == 403
assert 'Location' not in resp.headers
def test_callback_token_failure_shows_error_page_without_redirecting(client, monkeypatch):
_set_env(monkeypatch)
auth._pending['thestate'] = {'verifier': 'v', 'expires': time.time() + 600}
class _Resp:
status_code = 400
text = 'invalid_grant'
monkeypatch.setattr(auth.requests, 'post', lambda *a, **k: _Resp())
resp = client.get('/api/auth/callback?state=thestate&code=abc')
assert resp.status_code == 502
assert 'Location' not in resp.headers
assert 'fam_session' not in resp.headers.get('Set-Cookie', '')
# ---------------------------------------------------------------------------
# 回调:成功路径
# ---------------------------------------------------------------------------
def _stub_successful_exchange(monkeypatch, captured):
class _Resp:
status_code = 200
text = ''
def json(self):
return {'id_token': 'the-id-token', 'access_token': 'x'}
def _post(url, **kwargs):
captured['token_url'] = url
return _Resp()
monkeypatch.setattr(auth.requests, 'post', _post)
class _FakeKey:
key = 'unused'
class _FakeJwks:
uri = 'unused'
def get_signing_key_from_jwt(self, token):
return _FakeKey()
def _jwks(uri):
captured['jwks_uri'] = uri
return _FakeJwks()
monkeypatch.setattr(auth, '_get_jwks_client', _jwks)
real_decode = auth.jwt.decode
def _decode(token, key, **kw):
# id_token 走 RS256记下校验参数并返回固定 claims
# 会话 cookie 走 HS256交给真正的实现别把验签也 mock 掉
if kw.get('algorithms') == ['RS256']:
captured['decode_kwargs'] = kw
return {'sub': '1', 'preferred_username': 'ericwyuan'}
return real_decode(token, key, **kw)
monkeypatch.setattr(auth.jwt, 'decode', _decode)
def test_callback_success_sets_cookie_and_goes_home(client, monkeypatch):
_set_env(monkeypatch)
auth._pending['thestate'] = {'verifier': 'v', 'expires': time.time() + 600}
captured = {}
_stub_successful_exchange(monkeypatch, captured)
resp = client.get('/api/auth/callback?state=thestate&code=abc')
assert resp.status_code == 302
assert resp.headers['Location'] == '/'
assert 'fam_session=' in resp.headers['Set-Cookie']
assert 'HttpOnly' in resp.headers['Set-Cookie']
assert 'thestate' not in auth._pending # 用过的 state 必须立刻作废
def test_callback_talks_to_internal_base_but_validates_public_issuer(client, monkeypatch):
"""换 token / 拉 JWKS 走本机,避免公网 TLS 那条链路上的 CA 和时钟坑;
但 id_token 里的 iss 是 auth-hub 配置的公网地址,校验必须按公网来。"""
_set_env(monkeypatch, internal_base='http://127.0.0.1:5300')
auth._pending['thestate'] = {'verifier': 'v', 'expires': time.time() + 600}
captured = {}
_stub_successful_exchange(monkeypatch, captured)
client.get('/api/auth/callback?state=thestate&code=abc')
assert captured['token_url'] == 'http://127.0.0.1:5300/token'
assert captured['jwks_uri'] == 'http://127.0.0.1:5300/.well-known/jwks.json'
assert captured['decode_kwargs']['issuer'] == 'https://auth.example'
assert captured['decode_kwargs']['audience'] == 'fam-core'
assert captured['decode_kwargs']['leeway'] == auth._LEEWAY
# ---------------------------------------------------------------------------
# 会话 cookie / check / verify / logout
# ---------------------------------------------------------------------------
def test_session_is_stateless(client, monkeypatch):
"""cookie 自带签名,服务端不存任何东西——换个进程、重启服务照样认。"""
_set_env(monkeypatch)
assert auth._pending == {}
client.set_cookie('fam_session', _cookie())
assert client.get('/api/auth/verify').status_code == 204
def test_verify_rejects_missing_expired_and_forged_cookies(client, monkeypatch):
_set_env(monkeypatch)
assert client.get('/api/auth/verify').status_code == 401
client.set_cookie('fam_session', _cookie(ttl=-10))
assert client.get('/api/auth/verify').status_code == 401
client.set_cookie('fam_session', _cookie(secret='wrong-secret'))
resp = client.get('/api/auth/verify')
assert resp.status_code == 401
assert resp.get_json()['error'] == '未登录'
def test_check_reports_auth_state(client, monkeypatch):
_set_env(monkeypatch)
assert client.get('/api/auth/check').get_json() == {'authed': False, 'username': ''}
client.set_cookie('fam_session', _cookie(username='ericwyuan'))
assert client.get('/api/auth/check').get_json() == {'authed': True, 'username': 'ericwyuan'}
def test_logout_clears_cookie(client, monkeypatch):
_set_env(monkeypatch)
client.set_cookie('fam_session', _cookie())
resp = client.post('/api/logout')
assert resp.status_code == 200
assert 'fam_session=;' in resp.headers['Set-Cookie']

View File

@@ -0,0 +1,120 @@
import pytest
from fam_edge.disk_guard import DiskGuard
def _cfg(**overrides):
base = {
"enabled": True,
"min_free_gb": 10,
"target_free_gb": 15,
"watch_path": "/opt/fam-edge",
"max_delete_per_round": 50,
"check_interval_sec": 300,
}
base.update(overrides)
return base
class _FakeDB:
def __init__(self, candidates=None):
self._candidates = list(candidates or [])
self.deleted_ids = []
self.activities = []
def get_oldest_purgeable_material(self):
if not self._candidates:
return None
return self._candidates.pop(0)
def delete_video(self, video_id):
self.deleted_ids.append(video_id)
return None
def record_activity(self, service, action, detail=''):
self.activities.append((service, action, detail))
def _guard(monkeypatch, db, free_gb_sequence, **cfg_overrides):
"""free_gb_sequence: 每次调用 _free_gb() 依次返回的值列表(最后一个值
耗尽后保持不变),用来模拟"清理一个文件后空间逐步恢复"的过程。"""
monkeypatch.setattr(
"fam_edge.disk_guard.load_config",
lambda: {"disk_guard": _cfg(**cfg_overrides)})
guard = DiskGuard(db)
seq = list(free_gb_sequence)
def fake_free_gb():
if len(seq) > 1:
return seq.pop(0)
return seq[0]
monkeypatch.setattr(guard, "_free_gb", fake_free_gb)
return guard
def test_check_once_does_nothing_when_space_sufficient(monkeypatch):
db = _FakeDB()
guard = _guard(monkeypatch, db, [20.0])
guard.check_once()
assert db.deleted_ids == []
assert db.activities == []
def test_check_once_cleans_until_target_reached(monkeypatch):
"""核心诉求: 低于 min_free_gb 触发清理,一直清到 target_free_gb 为止,
不是清一个就停(否则马上又会跌破阈值,频繁触发)。"""
db = _FakeDB(candidates=[
{"id": 1, "local_path": "/tmp/a.mp4"},
{"id": 2, "local_path": "/tmp/b.mp4"},
{"id": 3, "local_path": "/tmp/c.mp4"},
])
# 初始 8GB< min_free_gb=10每删一个恢复到 8/12/16GB16 >= target=15 时停)
guard = _guard(monkeypatch, db, [8.0, 8.0, 12.0, 16.0])
guard.check_once()
assert db.deleted_ids == [1, 2]
actions = [a[1] for a in db.activities]
assert "low_space" in actions
assert "cleaned" in actions
def test_check_once_stops_when_no_candidate_left(monkeypatch):
"""核心诉求: 候选清空了但空间依然不足,不能死循环,要停下来并记录一条
"没有可清理素材"的警告,让人能在服务状态页看到这个异常情况——即便如此,
已经发生的清理动作本身也要记录(能看到确实清过、释放了多少),不因为
没完全达标就把 cleaned 记录吞掉。"""
db = _FakeDB(candidates=[{"id": 1, "local_path": "/tmp/a.mp4"}])
guard = _guard(monkeypatch, db, [8.0, 8.0, 9.0]) # 删完仅 1 个后仍然 <15GB
guard.check_once()
assert db.deleted_ids == [1]
actions = [a[1] for a in db.activities]
assert "no_candidate" in actions
assert "cleaned" in actions
def test_check_once_respects_max_delete_per_round(monkeypatch):
"""核心诉求: 单轮清理有上限,防止候选异常多时一次性删太多——下一轮检查
很快就会再触发,没必要在一轮里清空所有候选。"""
candidates = [{"id": i, "local_path": f"/tmp/{i}.mp4"} for i in range(1, 6)]
db = _FakeDB(candidates=candidates)
# 空间一直卡在 8GB 不涨(模拟每个文件都很小,删多少都到不了 target
guard = _guard(monkeypatch, db, [8.0], max_delete_per_round=3)
guard.check_once()
assert db.deleted_ids == [1, 2, 3]
def test_disabled_guard_does_not_start(monkeypatch):
db = _FakeDB()
guard = _guard(monkeypatch, db, [1.0], enabled=False)
guard.start()
assert guard.is_alive() is False

View File

@@ -1,3 +1,6 @@
import os
from fam_edge import frame_service
from fam_edge.frame_service import _bbox_to_pixels
@@ -20,3 +23,52 @@ def test_bbox_to_pixels_full_frame():
def test_bbox_to_pixels_zero_area():
x1, y1, x2, y2 = _bbox_to_pixels([500, 500, 500, 500], 400, 300)
assert (x1, y1) == (x2, y2)
class _FakeRow(dict):
"""支持 row['key'] 访问的假 sqlite3.Row。"""
def __getitem__(self, k):
return dict.get(self, k)
class _FakeDb:
def __init__(self, local_path, event_start_time):
self._row = _FakeRow(local_path=local_path, event_start_time=event_start_time)
def get_video_by_id(self, video_id):
return self._row
def test_extract_frame_cache_key_includes_width(tmp_path, monkeypatch):
"""核心诉求: 同一 (video_id, ts) 不同调用方要不同分辨率(时间轴缩略图/头像/
人物识别裁人脸),缓存 key 不带 width 会导致后来的高分辨率请求悄悄拿到早先
缓存的低分辨率帧——这里验证两次不同 width 请求各自落到独立的缓存文件。"""
monkeypatch.setattr(frame_service, "CACHE_DIR", str(tmp_path))
video_path = tmp_path / "fake_video.mp4"
video_path.write_bytes(b"not a real video, ffmpeg call is mocked")
db = _FakeDb(str(video_path), "2026-08-22 10:00:00")
written_widths = []
def fake_run_ffmpeg(args, timeout=60):
# 把请求的 -vf scale=WIDTH:-2 记下来,往输出路径写点假数据模拟成功
out_path = args[-1]
vf = next((a for a in args if a.startswith('scale=')), '')
written_widths.append(vf)
with open(out_path, 'wb') as f:
f.write(b'\xff\xd8fakejpeg')
return True
monkeypatch.setattr(frame_service, "_run_ffmpeg", fake_run_ffmpeg)
data_small = frame_service.extract_frame(db, 42, "2026-08-22 10:00:05", width=400)
data_large = frame_service.extract_frame(db, 42, "2026-08-22 10:00:05", width=2880)
assert data_small is not None and data_large is not None
cache_files = sorted(os.listdir(tmp_path))
frame_caches = [f for f in cache_files if f.startswith('frame_42_5_')]
assert len(frame_caches) == 2, f"expected 2 distinct cache files, got {frame_caches}"
assert 'frame_42_5_400.jpg' in frame_caches
assert 'frame_42_5_2880.jpg' in frame_caches
# 两次都真的各自调用了 ffmpeg第二次没有因为撞到第一次的缓存而被跳过
assert len(written_widths) == 2

View File

@@ -7,6 +7,13 @@ def _db(tmp_path):
return OracleDB(str(tmp_path / "oracle.db"))
def _set_heartbeat_age(db, age_sec):
"""把心跳时间戳直接改写成"距现在 age_sec 秒前",用于测试新鲜度阈值边界。"""
from datetime import datetime, timedelta, timezone
ts = (datetime.now(timezone(timedelta(hours=8))) - timedelta(seconds=age_sec))
db.set_cursor('motion_heartbeat_at', ts.strftime('%Y-%m-%d %H:%M:%S'))
def test_upsert_person_same_gender_merges_into_one_row(tmp_path):
db = _db(tmp_path)
db.upsert_person("人物A", features={"gender": "", "hair": "短发黑色"})
@@ -70,3 +77,449 @@ def test_upsert_person_no_features_never_triggers_split(tmp_path):
rows = db._conn.execute("SELECT * FROM people").fetchall()
assert len(rows) == 1
assert rows[0]["appearances"] == 2
# ----------------------------------------------------------------------
# 运动侦测事件NAS 推送)
# ----------------------------------------------------------------------
def test_record_motion_events_upserts_by_event_id(tmp_path):
db = _db(tmp_path)
n = db.record_motion_events([
{"event_id": 1, "camera_id": 2, "event_type": 10, "start_time": 1000, "duration": 5},
{"event_id": 2, "camera_id": 2, "event_type": 10, "start_time": 2000, "duration": 3},
])
assert n == 2
rows = db._conn.execute("SELECT * FROM ss_motion_events ORDER BY event_id").fetchall()
assert len(rows) == 2
# 重复推送同一个 event_id幂等应该更新而不是新增一行
db.record_motion_events(
[{"event_id": 1, "camera_id": 2, "event_type": 10, "start_time": 1000, "duration": 99}])
rows = db._conn.execute("SELECT * FROM ss_motion_events").fetchall()
assert len(rows) == 2
updated = db._conn.execute(
"SELECT duration FROM ss_motion_events WHERE event_id=1").fetchone()
assert updated['duration'] == 99
def test_record_motion_events_skips_missing_event_id(tmp_path):
db = _db(tmp_path)
n = db.record_motion_events([{"camera_id": 2, "start_time": 1000}])
assert n == 0
def test_heartbeat_age_none_when_never_recorded(tmp_path):
db = _db(tmp_path)
assert db.get_motion_heartbeat_age_sec() is None
def test_heartbeat_age_near_zero_right_after_recording(tmp_path):
db = _db(tmp_path)
db.record_motion_heartbeat()
age = db.get_motion_heartbeat_age_sec()
assert age is not None and age < 5
def test_has_motion_in_range_local_fails_open_without_heartbeat(tmp_path):
"""核心诉求: 从未收到过心跳冷启动NAS 推送链路还没接上)必须 fail-open
不能因为本地表是空的就悄悄跳过分析。"""
db = _db(tmp_path)
assert db.has_motion_in_range_local(1000, 2000) is None
def test_has_motion_in_range_local_fails_open_when_heartbeat_stale(tmp_path):
"""核心诉求: 表里有大量历史运动事件(曾经推送链路是健康的),但心跳已经
过期太久NAS 服务挂了/网络断了/DSM Webhook 规则被误关)——这时候不能信任
"查询结果是 0 条 = 确认无运动"必须当作链路已死fail-open。"""
db = _db(tmp_path)
db.record_motion_events(
[{"event_id": 1, "camera_id": 2, "event_type": 10, "start_time": 500, "duration": 10}])
_set_heartbeat_age(db, 1000) # 超过默认阈值 900s
assert db.has_motion_in_range_local(2000, 3000, max_heartbeat_age_sec=900) is None
def test_has_motion_in_range_local_trusts_result_when_heartbeat_fresh(tmp_path):
db = _db(tmp_path)
db.record_motion_heartbeat()
assert db.has_motion_in_range_local(2000, 3000, max_heartbeat_age_sec=900) is False
db.record_motion_events(
[{"event_id": 1, "camera_id": 2, "event_type": 10, "start_time": 2500, "duration": 5}])
assert db.has_motion_in_range_local(2000, 3000, max_heartbeat_age_sec=900) is True
def test_has_motion_in_range_local_respects_heartbeat_boundary(tmp_path):
db = _db(tmp_path)
_set_heartbeat_age(db, 899)
assert db.has_motion_in_range_local(2000, 3000, max_heartbeat_age_sec=900) is not None
_set_heartbeat_age(db, 901)
assert db.has_motion_in_range_local(2000, 3000, max_heartbeat_age_sec=900) is None
def test_has_motion_in_range_local_overlap_semantics(tmp_path):
"""事件区间 [start_time, start_time+duration] 只要和查询窗口有重叠就算命中,
不要求事件完全落在窗口内部(也不要求窗口完全覆盖事件)。"""
db = _db(tmp_path)
db.record_motion_heartbeat()
# 事件在窗口开始之前就开始,但持续到窗口内 -> 应该命中
db.record_motion_events(
[{"event_id": 1, "camera_id": 2, "event_type": 10, "start_time": 1990, "duration": 20}])
assert db.has_motion_in_range_local(2000, 3000) is True
def test_has_motion_in_range_local_ignores_non_motion_event_type(tmp_path):
db = _db(tmp_path)
db.record_motion_heartbeat()
db.record_motion_events(
[{"event_id": 1, "camera_id": 2, "event_type": 99, "start_time": 2500, "duration": 5}])
assert db.has_motion_in_range_local(2000, 3000) is False
def test_has_motion_in_range_local_filters_by_camera_id(tmp_path):
db = _db(tmp_path)
db.record_motion_heartbeat()
db.record_motion_events(
[{"event_id": 1, "camera_id": 99, "event_type": 10, "start_time": 2500, "duration": 5}])
assert db.has_motion_in_range_local(2000, 3000, camera_id=2) is False
assert db.has_motion_in_range_local(2000, 3000, camera_id=99) is True
# ----------------------------------------------------------------------
# 人物对应关系表video_id, raw_uid) -> canonical_name
# ----------------------------------------------------------------------
def _seed_video_with_events(db, filename="motion_1_1000.mp4"):
vid = db.ensure_video(filename, f"/tmp/{filename}", event_start_time="2026-08-22 10:00:00")
events = [
{"timestamp": "10:00:01", "description": "在客厅走动", "people": ["人物A"],
"person_appearances": [{"uid": "人物A", "features": {"gender": ""}, "action": "走动"}]},
{"timestamp": "10:00:05", "description": "坐下", "people": ["人物A", "人物B"],
"person_appearances": [
{"uid": "人物A", "features": {"gender": ""}, "action": "坐下"},
{"uid": "人物B", "features": {"gender": ""}, "action": "站立"}]},
]
db.mark_video_processed(vid, "摘要", events, ["人物A", "人物B"], "gemini")
return vid
def test_set_identity_mapping_inserts_new_row(tmp_path):
db = _db(tmp_path)
assert db.set_identity_mapping(1, "人物A", "爷爷", source="auto_id") is True
assert db.get_identity_map_for_video(1) == {"人物A": "爷爷"}
def test_set_identity_mapping_updates_existing_non_manual_row(tmp_path):
db = _db(tmp_path)
db.set_identity_mapping(1, "人物A", "爷爷", source="auto_id")
assert db.set_identity_mapping(1, "人物A", "爸爸", source="auto_id") is True
assert db.get_identity_map_for_video(1) == {"人物A": "爸爸"}
def test_set_identity_mapping_manual_protected_from_auto_overwrite(tmp_path):
"""核心诉求: 人工纠正过的映射不能被后续自动识别悄悄改回去。"""
db = _db(tmp_path)
db.set_identity_mapping(1, "人物A", "爸爸", source="manual")
changed = db.set_identity_mapping(1, "人物A", "爷爷", source="auto_id")
assert changed is False
assert db.get_identity_map_for_video(1) == {"人物A": "爸爸"}
def test_set_identity_mapping_manual_can_override_manual(tmp_path):
db = _db(tmp_path)
db.set_identity_mapping(1, "人物A", "爸爸", source="manual")
changed = db.set_identity_mapping(1, "人物A", "爷爷", source="manual")
assert changed is True
assert db.get_identity_map_for_video(1) == {"人物A": "爷爷"}
def test_set_identity_mapping_no_change_returns_false(tmp_path):
db = _db(tmp_path)
db.set_identity_mapping(1, "人物A", "爷爷", source="auto_id")
changed = db.set_identity_mapping(1, "人物A", "爷爷", source="auto_id")
assert changed is False
def test_get_identity_map_for_video_scoped_per_video(tmp_path):
"""核心诉求: 同一个 raw_uid 字符串在不同视频里可能是不同真人,映射必须按
video_id 隔离,不能串。"""
db = _db(tmp_path)
db.set_identity_mapping(1, "人物A", "爷爷", source="auto_id")
db.set_identity_mapping(2, "人物A", "爸爸", source="auto_id")
assert db.get_identity_map_for_video(1) == {"人物A": "爷爷"}
assert db.get_identity_map_for_video(2) == {"人物A": "爸爸"}
def test_rewrite_event_person_names_updates_events_and_video(tmp_path):
db = _db(tmp_path)
vid = _seed_video_with_events(db)
db.rewrite_event_person_names(vid, {"人物A": "爷爷", "人物B": "媳妇"})
rows = db._conn.execute(
"SELECT person_list_json, person_appearances_json FROM events "
"WHERE video_id=? ORDER BY id", (vid,)).fetchall()
assert json.loads(rows[0]["person_list_json"]) == ["爷爷"]
pa0 = json.loads(rows[0]["person_appearances_json"])
assert pa0[0]["uid"] == "爷爷"
assert json.loads(rows[1]["person_list_json"]) == ["爷爷", "媳妇"]
pa1 = json.loads(rows[1]["person_appearances_json"])
assert {p["uid"] for p in pa1} == {"爷爷", "媳妇"}
vrow = db._conn.execute("SELECT people_json FROM videos WHERE id=?", (vid,)).fetchone()
assert set(json.loads(vrow["people_json"])) == {"爷爷", "媳妇"}
def test_rewrite_event_person_names_rewrites_description_text(tmp_path):
"""核心诉求: description 是大模型写的自然语言句子,"人物A/人物B"这类 uid
会直接以文字形式嵌在句子里,只改 person_list_json/person_appearances_json
这些结构化字段的话,事件卡片上方徽章显示对了,描述文字里还是旧 uid两处
对不上——description 也要做文本替换。"""
db = _db(tmp_path)
vid = db.ensure_video("motion_2_2000.mp4", "/tmp/motion_2_2000.mp4",
event_start_time="2026-08-22 13:00:00")
events = [
{"timestamp": "13:29:24",
"description": "人物B双手叉腰站在客厅中央人物A在远处厨房儿童已离开画面。",
"people": ["人物A", "人物B"],
"person_appearances": [
{"uid": "人物A", "features": {"gender": ""}, "action": "站立"},
{"uid": "人物B", "features": {"gender": ""}, "action": "叉腰"}]},
]
db.mark_video_processed(vid, "人物A和人物B都在客厅活动。", events,
["人物A", "人物B"], "gemini")
db.rewrite_event_person_names(vid, {"人物A": "爸爸", "人物B": "媳妇"})
ev_row = db._conn.execute(
"SELECT description FROM events WHERE video_id=?", (vid,)).fetchone()
assert ev_row["description"] == "媳妇双手叉腰站在客厅中央;爸爸在远处厨房;儿童已离开画面。"
v_row = db._conn.execute(
"SELECT summary_json FROM videos WHERE id=?", (vid,)).fetchone()
assert v_row["summary_json"] == "爸爸和媳妇都在客厅活动。"
def test_rewrite_event_person_names_longer_labels_replaced_before_shorter(tmp_path):
"""核心诉求: uid 可能带 "#2"/"#3" 这类同名冲突后缀,"人物A""人物A#2"
前缀——如果先替换短的 "人物A""人物A#2" 会被错误地部分命中变成"爷爷#2"
而不是走它自己在 rename_map 里对应的正确目标。必须长的先替换。"""
db = _db(tmp_path)
vid = db.ensure_video("motion_3_3000.mp4", "/tmp/motion_3_3000.mp4",
event_start_time="2026-08-22 13:00:00")
events = [
{"timestamp": "13:00:01",
"description": "人物A和人物A#2一起在客厅。",
"people": ["人物A", "人物A#2"],
"person_appearances": [
{"uid": "人物A", "features": {"gender": ""}, "action": "站立"},
{"uid": "人物A#2", "features": {"gender": ""}, "action": "站立"}]},
]
db.mark_video_processed(vid, "摘要", events, ["人物A", "人物A#2"], "gemini")
db.rewrite_event_person_names(vid, {"人物A": "爷爷", "人物A#2": "媳妇"})
ev_row = db._conn.execute(
"SELECT description FROM events WHERE video_id=?", (vid,)).fetchone()
assert ev_row["description"] == "爷爷和媳妇一起在客厅。"
def test_rewrite_event_person_names_noop_on_empty_map(tmp_path):
db = _db(tmp_path)
vid = _seed_video_with_events(db)
before = db._conn.execute(
"SELECT person_list_json FROM events WHERE video_id=?", (vid,)).fetchall()
db.rewrite_event_person_names(vid, {})
after = db._conn.execute(
"SELECT person_list_json FROM events WHERE video_id=?", (vid,)).fetchall()
assert [r["person_list_json"] for r in before] == [r["person_list_json"] for r in after]
def test_correct_video_identity_end_to_end(tmp_path):
"""核心诉求: 纠错入口应该找到当前展示名对应的映射行,改写映射 + 立即重写
展示数据,且标记为 manual受保护"""
db = _db(tmp_path)
vid = _seed_video_with_events(db)
db.set_identity_mapping(vid, "人物A", "爷爷", source="auto_id")
db.rewrite_event_person_names(vid, {"人物A": "爷爷"})
db.correct_video_identity(vid, current_name="爷爷", new_name="爸爸")
assert db.get_identity_map_for_video(vid) == {"人物A": "爸爸"}
rows = db._conn.execute(
"SELECT person_list_json FROM events WHERE video_id=? ORDER BY id", (vid,)).fetchall()
assert json.loads(rows[0]["person_list_json"]) == ["爸爸"]
# manual 之后不能被自动识别覆盖回去
changed = db.set_identity_mapping(vid, "人物A", "爷爷", source="auto_id")
assert changed is False
def test_correct_video_identity_without_prior_mapping_uses_current_name_as_raw_uid(tmp_path):
"""核心诉求: 老流水线时代产出的数据从没跑过闭集识别,映射表里没有记录——
纠错依然要能生效,把 current_name 本身当 raw_uid 存一条新映射。"""
db = _db(tmp_path)
vid = db.ensure_video("motion_2_2000.mp4", "/tmp/x.mp4", event_start_time="2026-08-22 10:00:00")
events = [{"timestamp": "10:00:01", "description": "走动", "people": ["爷爷"],
"person_appearances": [{"uid": "爷爷", "features": {"gender": ""}, "action": "走动"}]}]
db.mark_video_processed(vid, "摘要", events, ["爷爷"], "gemini")
db.correct_video_identity(vid, current_name="爷爷", new_name="爸爸")
assert db.get_identity_map_for_video(vid) == {"爷爷": "爸爸"}
rows = db._conn.execute(
"SELECT person_list_json FROM events WHERE video_id=?", (vid,)).fetchall()
assert json.loads(rows[0]["person_list_json"]) == ["爸爸"]
def test_delete_video_removes_video_and_events_rows(tmp_path):
db = _db(tmp_path)
vid = _seed_video_with_events(db)
local_path = db.delete_video(vid)
assert local_path == f"/tmp/motion_1_1000.mp4"
assert db._conn.execute("SELECT * FROM videos WHERE id=?", (vid,)).fetchone() is None
assert db._conn.execute("SELECT * FROM events WHERE video_id=?", (vid,)).fetchall() == []
def test_delete_video_removes_disk_file(tmp_path):
db = _db(tmp_path)
clip_path = tmp_path / "motion_9_2000.mp4"
clip_path.write_bytes(b"fake mp4 bytes")
vid = db.ensure_video("motion_9_2000.mp4", str(clip_path), event_start_time="2026-08-22 10:00:00")
db.mark_video_processed(vid, "摘要", [], [], "gemini")
db.delete_video(vid)
assert not clip_path.exists()
def test_delete_video_missing_file_on_disk_does_not_raise(tmp_path):
"""核心诉求: local_path 指向的文件已经不存在(比如手动清理过)时,删除记录
本身不能因为 os.remove 报错而失败——文件缺失不是数据库操作的错误。"""
db = _db(tmp_path)
vid = db.ensure_video("motion_9_2000.mp4", str(tmp_path / "already_gone.mp4"),
event_start_time="2026-08-22 10:00:00")
db.mark_video_processed(vid, "摘要", [], [], "gemini")
local_path = db.delete_video(vid)
assert local_path == str(tmp_path / "already_gone.mp4")
assert db._conn.execute("SELECT * FROM videos WHERE id=?", (vid,)).fetchone() is None
def test_delete_video_nonexistent_returns_none(tmp_path):
db = _db(tmp_path)
assert db.delete_video(99999) is None
def test_get_oldest_purgeable_material_none_when_empty(tmp_path):
db = _db(tmp_path)
assert db.get_oldest_purgeable_material() is None
def test_get_oldest_purgeable_material_ignores_motion_clips(tmp_path):
"""核心诉求: 运动片段motion_ 前缀)是独立的分析产物,事件时间轴/人物
头像都依赖它,磁盘清理绝不能碰它,只能清理原始整段素材。"""
db = _db(tmp_path)
vid = db.ensure_video("motion_1_1000.mp4", "/tmp/motion_1_1000.mp4",
event_start_time="2026-08-22 10:00:00")
db.mark_video_processed(vid, "摘要", [], [], "gemini")
assert db.get_oldest_purgeable_material() is None
def test_get_oldest_purgeable_material_ignores_non_done_status(tmp_path):
"""核心诉求: 还在 pending/processing 的素材不能被清理,避免删掉还没
来得及处理的数据。"""
db = _db(tmp_path)
db.ensure_video("Generic_ONVIF-001-20260815-000000.mp4",
"/tmp/Generic_ONVIF-001-20260815-000000.mp4")
assert db.get_oldest_purgeable_material() is None
def test_get_oldest_purgeable_material_returns_oldest_done_material(tmp_path):
db = _db(tmp_path)
vid1 = db.ensure_video("Generic_ONVIF-001-20260815-000000.mp4",
"/tmp/Generic_ONVIF-001-20260815-000000.mp4")
db.mark_video_processed(vid1, "(整段素材已分割 0 段运动片段)", [], [], 'motion_segment')
vid2 = db.ensure_video("Generic_ONVIF-001-20260816-000000.mp4",
"/tmp/Generic_ONVIF-001-20260816-000000.mp4")
db.mark_video_processed(vid2, "(整段素材已分割 0 段运动片段)", [], [], 'motion_segment')
candidate = db.get_oldest_purgeable_material()
assert candidate["id"] == vid1
assert candidate["local_path"] == "/tmp/Generic_ONVIF-001-20260815-000000.mp4"
def test_delete_video_does_not_touch_ss_motion_events(tmp_path):
"""核心诉求: ss_motion_events 是运动侦测源事件,跟切出来的视频片段生命周期
独立,删视频不该连带删掉源事件(否则分割逻辑的幂等判断会被破坏)。"""
db = _db(tmp_path)
db.record_motion_events([
{"event_id": 555, "camera_id": 2, "event_type": 10,
"start_time": 1700000000, "duration": 10, "thumbnail_url": ""},
])
vid = db.ensure_video("motion_555_1700000000.mp4", "/tmp/motion_555_1700000000.mp4",
event_start_time="2026-08-22 10:00:00", motion_event_id=555)
db.mark_video_processed(vid, "摘要", [], [], "gemini")
db.delete_video(vid)
assert db.get_video_by_motion_event_id(555) is None
row = db._conn.execute("SELECT * FROM ss_motion_events WHERE event_id=?", (555,)).fetchone()
assert row is not None
# ---------------------------------------------------------------------------
# 并发2026-09-13修"全进程共用一条 sqlite 连接"的回归测试)
# ---------------------------------------------------------------------------
def test_concurrent_writes_from_many_threads(tmp_path):
"""多线程同时读写不能互相踩踏。
改成每线程一条连接之前,这里会稳定炸出三类错误之一:
`cannot start a transaction within a transaction` / `no more rows available`
/ `database is locked`——线上 DiskGuard 的清理就是被第一种打断了 2837 次。
"""
import threading
db = _db(tmp_path)
errors = []
rounds = 25
def writer(tid):
try:
for i in range(rounds):
db.record_activity('t%d' % tid, 'act%d' % i, 'detail')
db.set_cursor('cursor_t%d' % tid, str(i))
db.record_motion_events([{
'event_id': tid * 1000 + i, 'camera_id': 2, 'event_type': 10,
'start_time': 1789000000 + i, 'duration': 5,
'thumbnail_url': '',
}])
db.get_recent_activities(5)
db.get_motion_heartbeat_age_sec()
except Exception as e: # noqa: BLE001 —— 要把原始异常带出来看
errors.append(f"线程{tid}: {type(e).__name__}: {e}")
threads = [threading.Thread(target=writer, args=(t,)) for t in range(8)]
for t in threads:
t.start()
for t in threads:
t.join(timeout=60)
assert not errors, "并发写出错:\n" + "\n".join(errors[:5])
assert db._conn.execute("SELECT COUNT(*) FROM ss_motion_events").fetchone()[0] == 8 * rounds
for tid in range(8):
assert db.get_cursor('cursor_t%d' % tid) == str(rounds - 1)
def test_close_releases_every_thread_connection(tmp_path):
"""close() 要收掉所有线程开过的连接,不只当前线程那一条。"""
import threading
db = _db(tmp_path)
db.record_activity('main', 'x', '')
def other():
db.record_activity('other', 'y', '')
t = threading.Thread(target=other)
t.start(); t.join()
assert len(db._all_conns) == 2 # 主线程 + 子线程各一条
db.close()
assert db._all_conns == []

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

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

@@ -0,0 +1,199 @@
import json
import pytest
from fam_edge.qa import QAOrchestrator
def _orchestrator(monkeypatch, cfg=None, token='tok'):
ai_gateway_cfg = {"base_url": "http://127.0.0.1:5100", "token": token, "timeout": 5}
if cfg:
ai_gateway_cfg.update(cfg)
monkeypatch.setattr(
"fam_edge.qa.load_config", lambda: {"ai_gateway": ai_gateway_cfg})
return QAOrchestrator()
def test_init_reads_base_url_and_token_from_config(monkeypatch):
qa = _orchestrator(monkeypatch, {"base_url": "http://example:5100/"}, token='secret')
assert qa.base_url == "http://example:5100"
assert qa.token == 'secret'
def test_init_resolves_token_from_env_var(monkeypatch):
monkeypatch.setenv("MY_GATEWAY_TOKEN", "resolved-secret")
qa = _orchestrator(monkeypatch, token='${MY_GATEWAY_TOKEN}')
assert qa.token == 'resolved-secret'
def test_init_defaults_base_url_when_unconfigured(monkeypatch):
monkeypatch.setattr("fam_edge.qa.load_config", lambda: {})
qa = QAOrchestrator()
assert qa.base_url == "http://127.0.0.1:5100"
class _FakeResp:
"""模拟 requests.Response非流式用 status_code/json()/text
流式额外提供 iter_lines()(逐行 yield跟真实 SSE 消费方式一致)。"""
def __init__(self, status_code=200, payload=None, text='', lines=None):
self.status_code = status_code
self._payload = payload
self.text = text
self._lines = lines if lines is not None else []
self.encoding = None
def json(self):
return self._payload
def iter_lines(self, decode_unicode=True):
for line in self._lines:
yield line
def _capture_post(monkeypatch, resp):
calls = []
def fake_post(url, headers=None, json=None, timeout=None, stream=False):
calls.append({"url": url, "headers": headers, "json": json,
"timeout": timeout, "stream": stream})
return resp
monkeypatch.setattr("fam_edge.qa.requests.post", fake_post)
return calls
def test_run_qa_success(monkeypatch):
qa = _orchestrator(monkeypatch)
resp = _FakeResp(payload={"choices": [{"message": {"content": "你好"}}],
"provider": "nvidia"})
calls = _capture_post(monkeypatch, resp)
answer, provider = qa.run_qa("hi", max_tokens=100)
assert answer == "你好"
assert provider == "nvidia"
assert calls[0]["json"] == {"messages": [{"role": "user", "content": "hi"}],
"max_tokens": 100, "stream": False}
assert calls[0]["headers"]["Authorization"] == "Bearer tok"
def test_run_qa_non_200_returns_none(monkeypatch):
qa = _orchestrator(monkeypatch)
resp = _FakeResp(status_code=503, text='{"error":{"message":"所有模型均不可用"}}')
_capture_post(monkeypatch, resp)
answer, provider = qa.run_qa("hi")
assert answer is None
assert provider is None
def test_run_qa_empty_answer_returns_none(monkeypatch):
qa = _orchestrator(monkeypatch)
resp = _FakeResp(payload={"choices": [{"message": {"content": ""}}], "provider": "gemini"})
_capture_post(monkeypatch, resp)
answer, provider = qa.run_qa("hi")
assert answer is None
assert provider is None
def test_run_qa_connection_error_returns_none(monkeypatch):
qa = _orchestrator(monkeypatch)
def _raise(*args, **kwargs):
raise ConnectionError("boom")
monkeypatch.setattr("fam_edge.qa.requests.post", _raise)
answer, provider = qa.run_qa("hi")
assert answer is None
assert provider is None
def _sse_lines(events):
lines = []
for e in events:
lines.append(f"data: {json.dumps(e, ensure_ascii=False)}")
lines.append("data: [DONE]")
return lines
def test_run_qa_stream_single_provider_success(monkeypatch):
qa = _orchestrator(monkeypatch)
lines = _sse_lines([
{"provider": "nvidia", "choices": [{"delta": {"content": ""}}]},
{"provider": "nvidia", "choices": [{"delta": {"content": ""}}]},
{"provider": "nvidia", "choices": [{"delta": {}}]},
])
resp = _FakeResp(lines=lines)
_capture_post(monkeypatch, resp)
events = list(qa.run_qa_stream("hi"))
types = [e["type"] for e in events]
assert types == ["provider_trying", "chunk", "chunk", "done"]
assert events[1]["text"] == ""
assert events[2]["text"] == ""
assert events[-1]["provider"] == "nvidia"
def test_run_qa_stream_emits_provider_trying_once_per_change(monkeypatch):
"""provider 字段没变化时不该重复吐 provider_trying。"""
qa = _orchestrator(monkeypatch)
lines = _sse_lines([
{"provider": "nvidia", "choices": [{"delta": {"content": "a"}}]},
{"provider": "nvidia", "choices": [{"delta": {"content": "b"}}]},
])
resp = _FakeResp(lines=lines)
_capture_post(monkeypatch, resp)
events = list(qa.run_qa_stream("hi"))
trying = [e for e in events if e["type"] == "provider_trying"]
assert len(trying) == 1
assert trying[0]["provider"] == "nvidia"
def test_run_qa_stream_no_chunks_yields_all_failed(monkeypatch):
qa = _orchestrator(monkeypatch)
resp = _FakeResp(lines=["data: [DONE]"])
_capture_post(monkeypatch, resp)
events = list(qa.run_qa_stream("hi"))
assert events == [{"type": "all_failed"}]
def test_run_qa_stream_non_200_yields_all_failed(monkeypatch):
qa = _orchestrator(monkeypatch)
resp = _FakeResp(status_code=503, text='{"error":{"message":"所有模型均不可用"}}')
_capture_post(monkeypatch, resp)
events = list(qa.run_qa_stream("hi"))
assert events == [{"type": "all_failed"}]
def test_run_qa_stream_connection_error_yields_all_failed(monkeypatch):
qa = _orchestrator(monkeypatch)
def _raise(*args, **kwargs):
raise ConnectionError("boom")
monkeypatch.setattr("fam_edge.qa.requests.post", _raise)
events = list(qa.run_qa_stream("hi"))
assert events == [{"type": "all_failed"}]
def test_run_qa_stream_error_chunk_stops_and_uses_partial_output(monkeypatch):
"""已经吐出过内容后遇到错误块:按"至少吐出过一块就算 done"处理,不是 all_failed。"""
qa = _orchestrator(monkeypatch)
lines = [
f"data: {json.dumps({'provider': 'gemini', 'choices': [{'delta': {'content': '先吐'}}]}, ensure_ascii=False)}",
f"data: {json.dumps({'error': {'message': 'boom'}}, ensure_ascii=False)}",
]
resp = _FakeResp(lines=lines)
_capture_post(monkeypatch, resp)
events = list(qa.run_qa_stream("hi"))
types = [e["type"] for e in events]
assert types == ["provider_trying", "chunk", "done"]
assert events[-1]["provider"] == "gemini"
def test_run_qa_stream_sets_stream_true_and_utf8_encoding(monkeypatch):
qa = _orchestrator(monkeypatch)
resp = _FakeResp(lines=["data: [DONE]"])
calls = _capture_post(monkeypatch, resp)
list(qa.run_qa_stream("hi", max_tokens=222))
assert calls[0]["json"]["stream"] is True
assert calls[0]["json"]["max_tokens"] == 222
assert calls[0]["stream"] is True
assert resp.encoding == 'utf-8'

View File

@@ -1,12 +1,36 @@
from datetime import datetime
from fam_edge.video_processor import (
VideoProcessor,
_parse_event_start_from_filename,
_parse_event_ts,
_clean_person,
)
class _RaisingPersonIdentifier:
"""用于验证"命中免费规则就不该再调用大模型比对"——一旦被调用直接报错,
测试能立刻发现规则短路失败。"""
def classify_adult_male(self, crop):
raise AssertionError("命中了免费规则的 uid 不该再走 person_identifier")
class _FixedPersonIdentifier:
def __init__(self, name):
self._name = name
def classify_adult_male(self, crop):
return self._name
def _bare_processor(person_identifier):
"""跳过 __init__不需要真的加载 config/建适配器),只测
_resolve_closed_set_identities 这一个纯逻辑方法。"""
vp = VideoProcessor.__new__(VideoProcessor)
vp.person_identifier = person_identifier
return vp
def test_parse_filename_pure_digit_format():
assert _parse_event_start_from_filename(
"Generic_ONVIF-001-20260820-140416-1787205856321-7.mp4"
@@ -56,3 +80,70 @@ def test_clean_person_strips_ascii_parens():
def test_clean_person_no_parens_unchanged():
assert _clean_person("汤圆") == "汤圆"
# ----------------------------------------------------------------------
# 闭集人物识别:赤膊成年人规则(用户原话:"赤裸的大人都是爸爸,家里没有
# 其他人会赤裸")——命中即免费直判,不调用视觉大模型比对。
# ----------------------------------------------------------------------
def test_shirtless_adult_male_resolves_to_dad_without_vlm_call():
vp = _bare_processor(_RaisingPersonIdentifier())
resolved = vp._resolve_closed_set_identities(
1, {"人物A": {"gender": "", "age_band": "中年", "clothing": "赤膊+深色长裤"}}, [])
assert resolved == {"人物A": ("爸爸", "rule")}
def test_shirtless_variants_all_match():
for phrase in ("光着上身", "赤膊", "裸体", "光膀子", "上身赤裸", "未穿上衣", "深色长裤,赤裸上身"):
vp = _bare_processor(_RaisingPersonIdentifier())
resolved = vp._resolve_closed_set_identities(
1, {"人物A": {"gender": "", "age_band": "中年", "clothing": phrase}}, [])
assert resolved.get("人物A") == ("爸爸", "rule"), f"未命中: {phrase}"
def test_shirtless_child_still_resolves_to_child_not_dad():
"""核心诉求: 用户的规则明确是"赤裸的大人",小孩光膀子玩很正常,不适用
这条规则——幼儿/儿童年龄档要走在赤膊判断前面,不能被误判成爸爸。"""
vp = _bare_processor(_RaisingPersonIdentifier())
resolved = vp._resolve_closed_set_identities(
1, {"人物A": {"gender": "", "age_band": "幼儿", "clothing": "赤裸上身+深色短裤"}}, [])
assert resolved == {"人物A": ("汤圆", "rule")}
# ----------------------------------------------------------------------
# 闭集人物识别:成年女性按年龄段区分媳妇/奶奶2026-08-29 新增,家里从 4 人
# 变成 5 人后,"媳妇=唯一成年女性"这条规则的前提被打破)
# ----------------------------------------------------------------------
def test_middle_aged_female_resolves_to_wife_without_vlm_call():
vp = _bare_processor(_RaisingPersonIdentifier())
resolved = vp._resolve_closed_set_identities(
1, {"人物A": {"gender": "", "age_band": "中年", "clothing": "粉色上衣"}}, [])
assert resolved == {"人物A": ("媳妇", "rule")}
def test_elderly_female_resolves_to_grandma_without_vlm_call():
vp = _bare_processor(_RaisingPersonIdentifier())
resolved = vp._resolve_closed_set_identities(
1, {"人物A": {"gender": "", "age_band": "老年", "clothing": "红色上衣"}}, [])
assert resolved == {"人物A": ("奶奶", "rule")}
def test_female_with_unknown_age_band_defaults_to_wife():
"""核心诉求: age_band 缺失/模型没给出明确判断时,不能因为不确定就放弃
识别——默认归到媳妇(原有行为),只有明确判断为"老年"才算奶奶。"""
vp = _bare_processor(_RaisingPersonIdentifier())
resolved = vp._resolve_closed_set_identities(
1, {"人物A": {"gender": "", "age_band": "unknown", "clothing": ""}}, [])
assert resolved == {"人物A": ("媳妇", "rule")}
def test_clothed_adult_male_still_falls_through_to_vlm(monkeypatch):
"""核心诉求: 没有赤膊关键词的正常穿戴场景,行为不变——照常走视觉大模型
比对(这里用假的 _best_crop_for_uid 避免真的需要 norm_events 数据)。"""
vp = _bare_processor(_FixedPersonIdentifier("爷爷"))
monkeypatch.setattr(vp, "_best_crop_for_uid", lambda *a, **k: b"fake-jpeg-bytes")
resolved = vp._resolve_closed_set_identities(
1, {"人物A": {"gender": "", "age_band": "中年", "clothing": "蓝色Polo衫"}}, [])
assert resolved == {"人物A": ("爷爷", "auto_id")}

View File

@@ -0,0 +1,73 @@
"""VideoQueue 生产者扫描的单测。
目前只覆盖一个点:生产者列目录之后、读 mtime 之前DiskGuard 可能刚好把文件清掉
(两个后台线程的正常竞态)。线上表现为 `生产者扫描异常: [Errno 2] No such file or
directory`,代价是**整轮扫描中断**——排在后面的新素材本轮全都登记不上,要等下一轮。
"""
import os
import pytest
from fam_edge import video_queue as vq_mod
from fam_edge.video_queue import VideoQueue
class _FakeDB:
"""只实现生产者路径上用到的方法。"""
def __init__(self):
self.registered = []
self.activities = []
def get_video_by_filename(self, fn):
return None
def ensure_video(self, fn, path, camera_name=None):
self.registered.append(fn)
return len(self.registered)
def set_video_file_status(self, vid, valid, err, meta=None):
pass
def record_activity(self, service, action, detail=''):
self.activities.append((service, action))
@pytest.fixture
def queue_with_two_files(tmp_path, monkeypatch):
monkeypatch.setattr(vq_mod, 'load_config', lambda: {
'gdrive_sync': {'local_dir': str(tmp_path), 'camera_name': '客厅'},
'video_processing': {'file_validate': True, 'stable_window_sec': 60},
})
(tmp_path / "a_被删掉的.mp4").write_bytes(b"x")
(tmp_path / "b_正常的.mp4").write_bytes(b"y")
db = _FakeDB()
q = VideoQueue(db)
monkeypatch.setattr(q, '_enqueue', lambda vid: None)
monkeypatch.setattr(vq_mod, 'validate_video', lambda p: (True, '', {'fps': 25}))
return q, db
def test_produce_skips_file_deleted_mid_scan_and_keeps_going(queue_with_two_files, monkeypatch):
"""被删掉的那个跳过,后面的照常登记——不能整轮中断。"""
q, db = queue_with_two_files
real_getmtime = os.path.getmtime
def fake_getmtime(path):
if 'a_被删掉的' in path:
raise FileNotFoundError(2, 'No such file or directory', path)
return real_getmtime(path) - 3600 # 早于稳定窗口,视为写入完成
monkeypatch.setattr(vq_mod.os.path, 'getmtime', fake_getmtime)
q._produce_once() # 不抛异常
assert db.registered == ["b_正常的.mp4"] # 被删的跳过,后面的没受影响
def test_produce_still_skips_files_being_written(queue_with_two_files, monkeypatch):
"""mtime 太新rclone 还在写)仍然要跳过,别把半成品入队。"""
q, db = queue_with_two_files
monkeypatch.setattr(vq_mod.os.path, 'getmtime', lambda p: __import__('time').time())
q._produce_once()
assert db.registered == []

View File

@@ -0,0 +1,27 @@
# fam-notifier 配置NAS 上唯一保留的服务)
# 复制为 config.yaml 后填实际值;${VAR} 会从环境变量解析start_notifier.sh 会 source .env
motion_notifier:
enabled: true
poll_enabled: true # 轮询主路径(默认开启)
dsm_host: "127.0.0.1" # Surveillance Station 就在本机NAS走回环即可
dsm_port: 5000
dsm_account: "${DSM_ACCOUNT}"
dsm_password: "${DSM_PASSWORD}"
camera_ids: [2] # 轮询关注的摄像头Generic_ONVIF-001
# 推送目标:甲骨文 fam-edge。这是本服务唯一的出口单向。
oracle_base_url: "http://129.146.26.249:5000"
oracle_token: "${ORACLE_SYNC_TOKEN}"
timeout_sec: 10 # 单次 SS 请求超时
# 心跳:跟轮询 SS 无关,只是定期空 POST 一下甲骨文的 /api/ss/motion证明
# NAS->甲骨文这条推送链路本身还活着enabled=true 就跑,不受 poll_enabled 影响)。
# 甲骨文侧 dsm_motion_prefilter.max_heartbeat_age_sec默认 900s据此判断"无运动"
# 结论是否可信——这个心跳间隔要明显小于那个阈值,否则会被误判成链路已死。
heartbeat_interval_sec: 300
# 轮询参数
poll_interval_sec: 60 # 轮询间隔
poll_window_hours: 2 # 每轮回看窗口(小时),覆盖轮询间隔内的新事件
batch_size: 100 # 单批推送上限

View File

@@ -0,0 +1,3 @@
# NAS 上唯一要装的依赖:一个 HTTP 客户端 + YAML 配置解析
requests>=2.31.0
PyYAML>=6.0

View File

@@ -0,0 +1,72 @@
#!/bin/sh
# fam-notifier 开机自启DSM 没有 systemd/usr/local/etc/rc.d/ 是唯一的守护手段)
#
# 部署sudo cp 到 /usr/local/etc/rc.d/S99fam-notifier.sh && sudo chmod 755 同路径
#
# 为什么非有不可:这个进程是摄像头系统在 NAS 上唯一保留的东西(轮询
# Surveillance Station把运动事件单向推给甲骨文。它没起来的话事件就悄无声息
# 地断流——2026-09-04 到 09-12 断档整整十天,就是因为当时它还在 fam-core 里、
# fam-core 挂了没人重启,而且不打开网站根本发现不了。
#
# 以 ericwyuan 而不是 root 运行:保持 logs/ 和 data/cursor.json 的属主跟手动启动
# 时一致,免得 root 建出来的文件之后普通用户改不动。
RUN_USER=ericwyuan
APP_DIR=/volume1/web/sentinel-home-ai/fam-notifier
START="$APP_DIR/scripts/start_notifier.sh"
LOG="$APP_DIR/logs/start.log"
PIDFILE="$APP_DIR/fam-notifier.pid"
# DSM 上没有 pgrep用 grep 字符类找 pid[f] 写法避免匹配到 grep 自己)
find_pid() {
ps aux | grep "[f]am_notifier" | awk '{print $2}' | head -1
}
start() {
RUNNING=$(find_pid)
if [ -n "$RUNNING" ]; then
echo "fam-notifier 已在运行 (PID $RUNNING)"; echo "$RUNNING" > "$PIDFILE"; return 0
fi
# 等待数据卷 volume1 挂载就绪(最多 90 秒),避免开机时脚本早于卷挂载而静默失败
n=0
while [ ! -f "$START" ] && [ $n -lt 90 ]; do
sleep 1; n=$((n+1))
done
if [ ! -f "$START" ]; then
echo "等待 $START 超时(数据卷可能未挂载),启动放弃"; return 1
fi
# su 自身的 stdio 也要断开:否则通过 SSH 执行本脚本时,子进程继承了连接的管道,
# 命令跑完了 SSH 会话却迟迟不退出(开机时无所谓,手动敲的时候很烦)
su "$RUN_USER" -c "cd '$APP_DIR' && setsid nohup sh scripts/start_notifier.sh >> '$LOG' 2>&1 < /dev/null &" > /dev/null 2>&1 < /dev/null
sleep 4
PID=$(find_pid)
if [ -n "$PID" ]; then
echo "$PID" > "$PIDFILE"
echo "fam-notifier 已启动 (PID $PID)"
else
echo "启动失败,请查看 $LOG$APP_DIR/logs/fam-notifier.log"; return 1
fi
}
stop() {
if [ -f "$PIDFILE" ]; then kill "$(cat "$PIDFILE")" 2>/dev/null; rm -f "$PIDFILE"; fi
for p in $(ps aux | grep "[f]am_notifier" | awk '{print $2}'); do kill "$p" 2>/dev/null; done
echo "fam-notifier 已停止"
}
status() {
PID=$(find_pid)
if [ -n "$PID" ]; then
echo "运行中 (PID $PID)"
tail -3 "$APP_DIR/logs/fam-notifier.log" 2>/dev/null
else
echo "未运行"; return 1
fi
}
case "$1" in
start) start ;;
stop) stop ;;
restart) stop; sleep 2; start ;;
status) status ;;
*) echo "用法: $0 {start|stop|restart|status}"; exit 1 ;;
esac

View File

@@ -0,0 +1,36 @@
#!/bin/bash
# fam-notifier 启动脚本NAS 端)
# 用法: bash scripts/start_notifier.sh
#
# NAS 上没有 systemd挂了不会自启——重启方式见 docs/DEPLOY.md §1.2。
set -e
APP_DIR="$(cd "$(dirname "$0")/.." && pwd)"
CONFIG_FILE="$APP_DIR/config/config.yaml"
if [ ! -f "$CONFIG_FILE" ]; then
echo "错误: 配置文件不存在: $CONFIG_FILE"
echo "请复制 config/config.yaml.example 为 config.yaml 并填入实际值"
exit 1
fi
cd "$APP_DIR"
# 加载 DSM_ACCOUNT / DSM_PASSWORD / ORACLE_SYNC_TOKEN。
# set -a 包裹:.env 里是裸赋值,不 export 的话子进程读不到2026-09-01 踩过)。
ENV_FILE="$APP_DIR/../.env"
if [ -f "$ENV_FILE" ]; then
set -a
source "$ENV_FILE"
set +a
fi
if [ -d "venv" ]; then
source venv/bin/activate
fi
# 包在 src/ 下,不加这行 `python -m fam_notifier` 找不到模块
export PYTHONPATH="$APP_DIR/src${PYTHONPATH:+:$PYTHONPATH}"
echo "启动 fam-notifier轮询 Surveillance Station -> 推送甲骨文)..."
exec python -m fam_notifier

View File

@@ -0,0 +1,34 @@
"""
fam-notifier 入口:`python -m fam_notifier`
没有 Flask没有对外端口——这个进程只做一件事轮询 Surveillance Station
把运动事件单向推给甲骨文的 fam-edge。状态查看去网站的「服务状态」页
心跳和事件都记在甲骨文那边service_activity / ss_motion_events
"""
import sys
import time
from .logger import setup_logger
from .notifier import MotionNotifier
logger = setup_logger('fam-notifier')
def main():
notifier = MotionNotifier()
if not notifier.enabled:
logger.error("motion_notifier.enabled=false没什么可做的退出")
return 1
notifier.start()
logger.info("fam-notifier 已启动Ctrl+C 退出)")
try:
while notifier.is_alive():
time.sleep(5)
except KeyboardInterrupt:
logger.info("收到中断,停止轮询")
notifier.stop()
return 0
if __name__ == '__main__':
sys.exit(main())

View File

@@ -0,0 +1,32 @@
"""
配置加载器 - 从 config.yaml 读取配置
"""
import os
import re
import yaml
def _resolve_env_vars(value):
"""递归解析字符串中的 ${ENV_VAR} 引用"""
if isinstance(value, str):
def replace_env(match):
env_name = match.group(1)
return os.environ.get(env_name, match.group(0))
return re.sub(r'\$\{(\w+)\}', replace_env, value)
elif isinstance(value, dict):
return {k: _resolve_env_vars(v) for k, v in value.items()}
elif isinstance(value, list):
return [_resolve_env_vars(item) for item in value]
return value
def load_config(config_path=None):
"""加载 YAML 配置文件,自动解析 ${ENV_VAR} 引用"""
if config_path is None:
config_path = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
'config', 'config.yaml'
)
with open(config_path, 'r', encoding='utf-8') as f:
raw = yaml.safe_load(f)
return _resolve_env_vars(raw)

View File

@@ -0,0 +1,34 @@
"""
日志工具 - 统一格式stdout + 文件双写)
"""
import logging
import os
import sys
# 日志目录fam-notifier/logs/(相对 src 的上一级),失败则退化为仅 stdout
_LOG_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), 'logs')
def setup_logger(name='fam-notifier', level=logging.INFO):
"""配置并返回 loggerstdout + 文件双写)"""
logger = logging.getLogger(name)
if logger.handlers:
return logger
logger.setLevel(level)
formatter = logging.Formatter(
'%(asctime)s [%(name)s] %(levelname)s %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(formatter)
logger.addHandler(handler)
# 文件输出daemon 模式下 stdout 不可见,文件是唯一可追溯日志)
try:
os.makedirs(_LOG_DIR, exist_ok=True)
file_handler = logging.FileHandler(
os.path.join(_LOG_DIR, 'fam-notifier.log'), encoding='utf-8')
file_handler.setFormatter(formatter)
logger.addHandler(file_handler)
except OSError:
pass # 目录不可写时退化为仅 stdout
return logger

View File

@@ -0,0 +1,400 @@
"""
MotionNotifier - NAS 端运动监测通知服务轮询主路径2026-08-22 定型;
2026-09-13 从 fam-core 拆出,成为 NAS 上唯一保留的服务)
拆出来的原因:摄像头插在 NAS 上Surveillance Station 就是 NAS 本身),这部分
搬不走而其余所有东西——数据库、查询接口、AI 问答、登录——都已经迁到甲骨文。
拆完之后 NAS 侧没有 Flask、没有 MariaDB、没有对外端口只有这一个进程单向往
甲骨文推事件,挂了重启即可,不影响网站。
职责:
在 NAS 本机**轮询**群晖 Surveillance Station 的运动侦测事件
SYNO.SurveillanceStation.EventCenter.Event method=List参数名下划线风格
camera_ids/start_time/end_timeevent_type=10 即运动),增量推送到甲骨文
FAM-Edge 的 /api/ss/motion 接口。轮询能拿到真实 event_id/start_time/duration。
数据方向(关键约束): NAS -> Oracle单向。甲骨文不再反向访问 NAS。
- 原 fam-edge 的 dsm_motion_client甲骨文主动查 SS API已停用
- 推送后由甲骨文本地落库 ss_motion_events供 video_processor 本地预过滤使用。
可靠性设计:
- 游标持久化在本地 JSON 文件(拆出前存 NAS MariaDB 的 sync_cursor 表,
现在 NAS 上已经没有数据库了);重启优先续用游标,
停机期间的事件由窗口回看补推;仅首次部署时才初始化为 SS 当前最大 id。
- 推送失败的批次不推进游标,下一轮窗口回看重试(不丢事件)。
- 心跳线程enabled 即跑,与轮询无关):定期空 POST /api/ss/motion证明
NAS->Oracle 推送链路存活,供甲骨文侧判断"无运动"结论是否可信。
"""
import json
import os
import re
import time
import threading
from datetime import datetime, timezone
import requests
from .logger import setup_logger
from .config_loader import load_config
logger = setup_logger('fam-notifier')
# 游标文件:拆出前这是 MariaDB 里的一行,现在 NAS 上没有数据库了。
# 丢了也不致命——窗口回看会把最近的事件补推一遍,甲骨文侧按 event_id 幂等落库。
_CURSOR_PATH = os.environ.get('FAM_NOTIFIER_CURSOR') or os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
'data', 'cursor.json')
def _read_cursor() -> str:
try:
with open(_CURSOR_PATH, encoding='utf-8') as f:
return str(json.load(f).get('motion_last_event_id') or '')
except (OSError, ValueError):
return ''
def _write_cursor(value: str):
try:
os.makedirs(os.path.dirname(_CURSOR_PATH), exist_ok=True)
with open(_CURSOR_PATH, 'w', encoding='utf-8') as f:
json.dump({'motion_last_event_id': str(value)}, f)
except OSError as e:
logger.warning(f"游标写入失败(下轮窗口回看会补推,不丢事件): {e}")
_MOTION_NOTIFIER = None
def get_motion_notifier():
"""模块级单例app.py 启动时创建并 start状态接口经此获取"""
global _MOTION_NOTIFIER
if _MOTION_NOTIFIER is None:
_MOTION_NOTIFIER = MotionNotifier()
return _MOTION_NOTIFIER
class MotionNotifier:
def __init__(self):
cfg = load_config().get('motion_notifier', {})
self.enabled = bool(cfg.get('enabled', False))
self.dsm_host = cfg.get('dsm_host', '192.168.50.64')
self.dsm_port = int(cfg.get('dsm_port', 5000))
self.dsm_account = self._resolve(cfg.get('dsm_account', ''))
self.dsm_password = self._resolve(cfg.get('dsm_password', ''))
self.camera_ids = cfg.get('camera_ids', [2])
self.oracle_base_url = cfg.get('oracle_base_url',
'http://129.146.26.249:5000').rstrip('/')
self.oracle_token = self._resolve(cfg.get('oracle_token', '${ORACLE_SYNC_TOKEN}'))
self.poll_interval_sec = int(cfg.get('poll_interval_sec', 60))
self.poll_window_hours = int(cfg.get('poll_window_hours', 2))
self.batch_size = int(cfg.get('batch_size', 100))
self.timeout = int(cfg.get('timeout_sec', 10))
# 是否启用轮询(默认开启):轮询 EventCenter.Event.List 为主数据源,拿到
# 真实 event_id/start_time/durationWebhook 仅作为可选的低延迟补充
# SS 行动规则未配置时不会触发,不影响主路径)
self.poll_enabled = bool(cfg.get('poll_enabled', True))
# 心跳:跟"轮询 SS"是两回事——不查 SS只是定期空 POST 一下甲骨文,证明
# NAS->Oracle 这条推送链路本身还活着。enabled=true 时始终跑(不受
# poll_enabled 影响),供甲骨文侧 has_motion_in_range_local() 判断
# "这段时间没收到运动事件"是真的没运动,还是推送链路已经挂了。
self.heartbeat_interval_sec = int(cfg.get('heartbeat_interval_sec', 300))
# 轮询路径poll_enabled=true 时)关注的摄像头
self.camera_ids = cfg.get('camera_ids', [2])
self._base = f"http://{self.dsm_host}:{self.dsm_port}/webapi"
self._sid = None
self._running = False
self._thread = None
self._last_event_id = None
# 推送过但 duration<=0动作进行中SS 尚未回填真实时长)的事件 id。
# 下轮窗口回查时若已结束duration>0补推覆盖 Oracle避免分割永远跳过。
self._zero_dur_ids = set()
self._last_poll_at = None
self._last_error = None
self._pushed_total = 0
self._hb_running = False
self._hb_thread = None
self._last_heartbeat_at = None
self._last_heartbeat_error = None
@staticmethod
def _resolve(v):
"""解析 ${ENV} 引用;非字符串或不含 ${...} 原样返回。"""
if isinstance(v, str) and v.startswith('${') and v.endswith('}'):
return os.environ.get(v[2:-1], '')
return v
# ------------------------------------------------------------------
# Surveillance Station 登录 / 事件查询
# ------------------------------------------------------------------
def _login(self):
try:
resp = requests.get(
f"{self._base}/auth.cgi",
params={"api": "SYNO.API.Auth", "version": 6, "method": "login",
"account": self.dsm_account, "passwd": self.dsm_password,
"session": "SurveillanceStation", "format": "sid"},
timeout=self.timeout)
data = resp.json()
if data.get('success'):
return data['data']['sid']
logger.warning(f"SS 登录失败: {data.get('error')}")
except Exception as e:
logger.warning(f"SS 登录异常: {e}")
return None
def _fetch_events(self, start_ts: int, end_ts: int):
"""查询 [start_ts, end_ts] 窗口内的 SS 事件。返回事件列表或 None查询失败"""
if self._sid is None:
self._sid = self._login()
if self._sid is None:
return None
for attempt in range(2):
try:
resp = requests.get(
f"{self._base}/entry.cgi",
params={"api": "SYNO.SurveillanceStation.EventCenter.Event",
"version": 1, "method": "List",
"camera_ids": ",".join(str(c) for c in self.camera_ids),
"start_time": start_ts, "end_time": end_ts,
"limit": 1000, "_sid": self._sid},
timeout=self.timeout)
data = resp.json()
except Exception as e:
logger.warning(f"SS 事件查询异常: {e}")
return None
if not data.get('success'):
code = (data.get('error') or {}).get('code')
if code in (106, 107, 119) and attempt == 0:
# session 过期/被顶掉,重新登录重试一次
self._sid = self._login()
if self._sid is None:
return None
continue
logger.warning(f"SS 事件查询失败: {data.get('error')}")
return None
# 响应按 ds_idCMS 多机场景的服务器 id单机固定 "0")分组,拉平
events = [e for grp in (data.get('data') or {}).values()
for e in (grp or [])]
return events
return None
# ------------------------------------------------------------------
# 推送到甲骨文
# ------------------------------------------------------------------
def push_events_to_oracle(self, events) -> int:
"""把标准化后的事件列表推送到 Oracle /api/ss/motion。返回成功推送条数。"""
if not events:
return 0
norm = []
for e in events:
eid = e.get('id') or e.get('event_id')
if eid is None:
continue
norm.append({
"event_id": int(eid),
"camera_id": e.get('camera_id'),
"event_type": e.get('event_type'),
"start_time": e.get('start_time'),
"duration": e.get('duration'),
"thumbnail_url": e.get('thumbnail_url') or e.get('thumbnail_dir'),
})
if not norm:
return 0
try:
resp = requests.post(
f"{self.oracle_base_url}/api/ss/motion",
json={"token": self.oracle_token, "events": norm},
timeout=(10, 30))
except requests.RequestException as e:
logger.error(f"推送运动事件到 Oracle 失败: {e}")
self._last_error = str(e)
return 0
if resp.status_code != 200:
logger.error(f"推送运动事件到 Oracle 返回 {resp.status_code}: {resp.text[:200]}")
self._last_error = f"HTTP {resp.status_code}"
return 0
try:
stored = resp.json().get('stored', 0)
except ValueError:
stored = len(norm)
self._pushed_total += stored
self._last_error = None
logger.info(f"运动事件推送成功: {len(norm)} 条 -> Oracle 存储 {stored}")
return stored
def send_heartbeat(self) -> bool:
"""空 events 调一次 /api/ss/motion只为证明 NAS->Oracle 推送链路还活着。
跟 push_events_to_oracle 分开一个方法,是因为那个方法 events 为空时直接
return 0不发请求——心跳恰恰就是要在没有真实事件时也发一次请求。
"""
try:
resp = requests.post(
f"{self.oracle_base_url}/api/ss/motion",
json={"token": self.oracle_token, "events": []},
timeout=(10, 30))
except requests.RequestException as e:
logger.warning(f"运动心跳推送失败: {e}")
self._last_heartbeat_error = str(e)
return False
if resp.status_code != 200:
logger.warning(f"运动心跳推送返回 {resp.status_code}: {resp.text[:200]}")
self._last_heartbeat_error = f"HTTP {resp.status_code}"
return False
self._last_heartbeat_at = datetime.now()
self._last_heartbeat_error = None
return True
def _heartbeat_run(self):
logger.info(f"运动心跳线程启动,间隔 {self.heartbeat_interval_sec}s")
while self._hb_running:
try:
self.send_heartbeat()
except Exception as e:
self._last_heartbeat_error = str(e)
logger.error(f"运动心跳异常: {e}", exc_info=True)
for _ in range(self.heartbeat_interval_sec):
if not self._hb_running:
break
time.sleep(1)
# ------------------------------------------------------------------
# 增量轮询主循环
# ------------------------------------------------------------------
def _init_cursor(self):
"""启动时初始化 last_event_id。
优先使用文件里保存的游标:服务停机/重启期间产生的新事件,会在重启后
被下一轮窗口回看捞到并补推(不丢事件)。
仅当没有有效游标(首次部署)时,取 SS 当前最大事件 id 作为起点,
避免把历史事件全部回灌一遍。
"""
saved = _read_cursor()
if saved and int(saved) > 0:
self._last_event_id = int(saved)
logger.info(f"运动通知游标初始化(本地文件): last_event_id={self._last_event_id}")
return
now = int(datetime.now(timezone.utc).timestamp())
events = self._fetch_events(now - 3600, now) # 最近 1h 用于定位最大 id
if events:
self._last_event_id = max(
int(e.get('id', 0)) for e in events if e.get('id'))
else:
self._last_event_id = 0
logger.info(f"运动通知游标初始化SS 当前最大): last_event_id={self._last_event_id}")
def _poll_once(self):
now = int(datetime.now(timezone.utc).timestamp())
start_ts = now - int(self.poll_window_hours * 3600)
events = self._fetch_events(start_ts, now)
if events is None:
return # 查询失败,下一轮重试
by_id = {int(e.get('id')): e for e in events if e.get('id')}
# 1) 补推:先前推送时 duration<=0动作进行中的事件现在若已结束
# SS 回填真实 duration>0重推覆盖 OracleON CONFLICT UPDATE
# 重启兜底:游标 DB 续用 → 窗口回看会把历史事件整体重推,届时同样覆盖。
if self._zero_dur_ids:
recheck = []
for eid in list(self._zero_dur_ids):
e = by_id.get(eid)
if e and int(e.get('duration') or 0) > 0:
recheck.append(e)
self._zero_dur_ids.discard(eid)
if recheck:
self.push_events_to_oracle(recheck)
logger.info(f"补推 {len(recheck)} 条已结束事件的真实 duration")
# 2) 增量推送新事件id 大于游标)
new = [e for e in events
if e.get('id') and int(e.get('id')) > (self._last_event_id or 0)]
if not new:
return
new.sort(key=lambda e: int(e.get('id', 0)))
cursor = self._last_event_id
for i in range(0, len(new), self.batch_size):
batch = new[i:i + self.batch_size]
stored = self.push_events_to_oracle(batch)
if stored > 0:
# 只有推送成功的批次才推进游标;失败批次保持原地,
# 下一轮窗口回看会重新捞到并重试(不丢事件)
cursor = max(int(e.get('id', 0)) for e in batch)
# 记录推送时仍在进行中的事件duration<=0待下轮补推真实时长
for e in batch:
if int(e.get('duration') or 0) <= 0:
self._zero_dur_ids.add(int(e.get('id')))
self._last_event_id = cursor
_write_cursor(str(self._last_event_id))
def _run(self):
logger.info(f"MotionNotifier 启动,轮询间隔 {self.poll_interval_sec}s"
f"目标 SS {self.dsm_host}:{self.dsm_port}")
try:
self._init_cursor()
except Exception as e:
logger.warning(f"运动通知游标初始化失败(从 0 开始): {e}")
self._last_event_id = 0
while self._running:
try:
self._poll_once()
self._last_poll_at = datetime.now()
except Exception as e:
self._last_error = str(e)
logger.error(f"运动通知轮询异常: {e}", exc_info=True)
# 分段休眠,便于 stop 快速唤醒
for _ in range(self.poll_interval_sec):
if not self._running:
break
time.sleep(1)
# ------------------------------------------------------------------
def start(self):
if not self.enabled:
logger.info("MotionNotifier 未启用motion_notifier.enabled=false")
return
# 心跳线程跟轮询是否开启无关:只要 MotionNotifier 整体 enabled就该
# 持续证明推送链路活着,哪怕当前正好没有真实运动事件可推送。
if not self._hb_running:
self._hb_running = True
self._hb_thread = threading.Thread(
target=self._heartbeat_run, daemon=True, name='motion-heartbeat')
self._hb_thread.start()
if not self.poll_enabled:
logger.info("MotionNotifier 轮询已禁用poll_enabled=false仅作为 "
"Webhook 推送客户端 + 心跳 + 摄像头名映射使用")
return
if self._running:
return
self._running = True
self._thread = threading.Thread(target=self._run, daemon=True,
name='motion-notifier')
self._thread.start()
def is_alive(self):
return self._thread is not None and self._thread.is_alive()
def stop(self):
self._running = False
if self._thread:
self._thread.join(timeout=5)
self._hb_running = False
if self._hb_thread:
self._hb_thread.join(timeout=5)
def status(self) -> dict:
return {
"heartbeat_running": self._hb_thread is not None and self._hb_thread.is_alive(),
"last_heartbeat_at": self._last_heartbeat_at.isoformat() if self._last_heartbeat_at else None,
"last_heartbeat_error": self._last_heartbeat_error,
"running": self.is_alive(),
"enabled": self.enabled,
"poll_enabled": self.poll_enabled,
"last_event_id": self._last_event_id,
"zero_dur_pending": len(self._zero_dur_ids),
"last_poll_at": self._last_poll_at.isoformat() if self._last_poll_at else None,
"last_error": self._last_error,
"pushed_total": self._pushed_total,
"oracle": self.oracle_base_url,
"dsm": f"{self.dsm_host}:{self.dsm_port}",
}

View File

@@ -0,0 +1,7 @@
import os
import sys
# 让测试能直接 `from fam_notifier.xxx import yyy`,无需先 pip install -e .
_SRC = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'src')
if _SRC not in sys.path:
sys.path.insert(0, _SRC)

View File

@@ -0,0 +1,114 @@
import time
from fam_notifier.notifier import MotionNotifier
def _cfg(**overrides):
base = {
"enabled": True,
"poll_enabled": False,
"dsm_host": "192.168.50.64",
"dsm_port": 5000,
"dsm_account": "ericwyuan",
"dsm_password": "secret",
"oracle_base_url": "http://oracle.example:5000",
"oracle_token": "tok123",
"heartbeat_interval_sec": 300,
"timeout_sec": 5,
}
base.update(overrides)
return {"motion_notifier": base}
def _make_notifier(monkeypatch, **cfg_overrides):
monkeypatch.setattr(
"fam_notifier.notifier.load_config",
lambda: _cfg(**cfg_overrides))
return MotionNotifier()
class _FakeResp:
def __init__(self, status_code=200, payload=None, text=""):
self.status_code = status_code
self._payload = payload or {}
self.text = text
def json(self):
return self._payload
# ----------------------------------------------------------------------
# 心跳跟轮询无关enabled=true 就该跑)
# ----------------------------------------------------------------------
def test_send_heartbeat_success_updates_state(monkeypatch):
n = _make_notifier(monkeypatch)
calls = []
def fake_post(url, json=None, timeout=None):
calls.append((url, json))
return _FakeResp(200, {"status": "ok", "received": 0, "stored": 0})
monkeypatch.setattr("fam_notifier.notifier.requests.post", fake_post)
ok = n.send_heartbeat()
assert ok is True
assert n._last_heartbeat_at is not None
assert n._last_heartbeat_error is None
assert calls[0][0] == "http://oracle.example:5000/api/ss/motion"
assert calls[0][1]["events"] == []
assert calls[0][1]["token"] == "tok123"
def test_send_heartbeat_http_error_records_failure(monkeypatch):
n = _make_notifier(monkeypatch)
def fake_post(url, json=None, timeout=None):
return _FakeResp(500, {}, text="boom")
monkeypatch.setattr("fam_notifier.notifier.requests.post", fake_post)
ok = n.send_heartbeat()
assert ok is False
assert n._last_heartbeat_error == "HTTP 500"
def test_send_heartbeat_network_exception_records_failure(monkeypatch):
import requests as _requests
n = _make_notifier(monkeypatch)
def fake_post(url, json=None, timeout=None):
raise _requests.RequestException("connection refused")
monkeypatch.setattr("fam_notifier.notifier.requests.post", fake_post)
ok = n.send_heartbeat()
assert ok is False
assert "connection refused" in n._last_heartbeat_error
def test_start_runs_heartbeat_thread_even_when_poll_disabled(monkeypatch):
"""核心诉求: 心跳跟"是否轮询 SS"是两回事——poll_enabled=false 时轮询线程
不应该启动,但心跳线程必须照样跑,否则甲骨文永远收不到心跳,
has_motion_in_range_local() 会一直 fail-open省配额的效果就没了。"""
n = _make_notifier(monkeypatch, poll_enabled=False, heartbeat_interval_sec=3600)
monkeypatch.setattr(
"fam_notifier.notifier.requests.post",
lambda url, json=None, timeout=None: _FakeResp(200, {"stored": 0}))
try:
n.start()
time.sleep(0.2)
assert n.is_alive() is False # 轮询线程未启动
assert n._hb_thread is not None and n._hb_thread.is_alive()
finally:
n.stop()
def test_start_does_nothing_when_disabled(monkeypatch):
n = _make_notifier(monkeypatch, enabled=False)
n.start()
time.sleep(0.1)
assert n._hb_thread is None
assert n.is_alive() is False
def test_status_includes_heartbeat_fields(monkeypatch):
n = _make_notifier(monkeypatch)
st = n.status()
assert "heartbeat_running" in st
assert "last_heartbeat_at" in st
assert "last_heartbeat_error" in st

View File

@@ -1,53 +1,68 @@
<script setup>
import { onMounted, onUnmounted, ref } from 'vue'
import { onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { api, fmtDateTime } from './api.js'
import { api } from './api.js'
import { navItems } from './router.js'
const route = useRoute()
const sync = ref(null)
const authed = ref(false)
async function refreshStatus() {
async function checkAuth() {
try {
const data = await api.status()
sync.value = data.sync
const r = await api.authCheck()
authed.value = !!r.authed
} catch {
sync.value = null
authed.value = false
}
}
let timer = null
onMounted(() => {
refreshStatus()
timer = setInterval(refreshStatus, 30000)
})
onUnmounted(() => clearInterval(timer))
async function doLogout() {
try {
await fetch('/api/logout', { method: 'POST' })
} catch {
// 忽略网络错误,下面强制跳转即可
}
// 清掉 cookie 后回根路径,未登录态会让后端 401 -> 自动跳 /login 重新登录
window.location.href = '/'
}
onMounted(checkAuth)
</script>
<template>
<div class="flex min-h-screen flex-col lg:flex-row">
<!-- 桌面端左侧栏品牌 + 同步状态lg 以下隐藏改用下面的移动端顶栏 -->
<!-- 桌面端左侧栏品牌 + 登录入口lg 以下隐藏改用下面的移动端顶栏 -->
<aside class="hidden w-64 shrink-0 border-r border-border bg-[#090c12] px-4 py-6 lg:block">
<div class="mb-6">
<div class="text-[17px] font-bold text-[#f7f9fc]">🏠 家庭智能监控</div>
<div class="mt-1 text-[11px] text-text-mute">SENTINEL HOME AI · 管理后台</div>
</div>
<div v-if="sync" class="rounded-xl border border-border bg-panel-2 px-4 py-3 text-xs leading-loose text-text-dim">
<b class="text-[#f7f9fc]">同步状态</b><br />
状态 <span :class="sync.running ? 'font-semibold text-ok' : 'font-semibold text-danger'">{{ sync.running ? '同步中' : '未运行' }}</span><br />
最近 <b class="text-[#ccd5e1]">{{ sync.last_sync_at ? fmtDateTime(sync.last_sync_at) : '—' }}</b><br />
本次增量 {{ sync.last_count ? `视频+${sync.last_count[0]} / 事件+${sync.last_count[1]} / 人物+${sync.last_count[2]}` : '—' }}<br />
游标 {{ sync.cursor ? fmtDateTime(sync.cursor) : '(全量)' }}
<div v-if="sync.last_error" class="font-semibold text-danger"> {{ sync.last_error }}</div>
<div class="mt-4">
<a v-if="!authed" href="/login"
class="block rounded-xl bg-gradient-to-br from-accent to-accent-2 px-4 py-2.5 text-center text-sm font-semibold text-white shadow-[0_4px_16px_-4px_rgba(91,140,255,.45)] transition-opacity hover:opacity-90">
🔑 登录
</a>
<button v-else @click="doLogout"
class="block w-full rounded-xl border border-border bg-panel-2 px-4 py-2.5 text-center text-sm font-medium text-text-dim transition-colors hover:text-text">
👋 退出登录
</button>
</div>
</aside>
<!-- 移动端顶栏品牌 + 简要同步指示灯lg 以上隐藏用左侧栏代替 -->
<!-- 移动端顶栏品牌 + 登录/退出lg 以上隐藏用左侧栏代替 -->
<header class="flex items-center justify-between border-b border-border bg-[#090c12] px-4 py-3 lg:hidden">
<div class="text-[15px] font-bold text-[#f7f9fc]">🏠 家庭智能监控</div>
<span v-if="sync" class="flex items-center gap-1.5 text-xs font-medium" :class="sync.running ? 'text-ok' : 'text-danger'">
<span class="h-1.5 w-1.5 rounded-full bg-current"></span>{{ sync.running ? '同步中' : '未运行' }}
</span>
<div class="flex items-center gap-2">
<a v-if="!authed" href="/login"
class="rounded-lg bg-gradient-to-br from-accent to-accent-2 px-3 py-1.5 text-xs font-semibold text-white shadow-[0_4px_16px_-4px_rgba(91,140,255,.45)]">
🔑 登录
</a>
<button v-else @click="doLogout"
class="rounded-lg border border-border bg-panel-2 px-3 py-1.5 text-xs font-medium text-text-dim">
退出
</button>
</div>
</header>
<div class="min-w-0 flex-1 px-4 py-4 sm:px-8 sm:py-6">

View File

@@ -1,5 +1,9 @@
// API 薄封装:生产环境同源相对路径;开发环境走 vite.config.js 的 /api 代理。
// 全局:未登录时首个 401 直接跳转到 /login 走 OIDC/PKCE 登录流程。
// 用一个模块级开关保证整次会话只触发一次跳转,避免多个并发 401 重复导航。
let _redirectingToLogin = false
async function request(path, options = {}) {
const res = await fetch(path, {
headers: { 'Content-Type': 'application/json' },
@@ -12,6 +16,14 @@ async function request(path, options = {}) {
// 非 JSON 响应(如 404 空 body保持 data=null
}
if (!res.ok) {
// 401 未登录:交给后端 /login302 到 auth-hub发起统一登录。
// /api/auth/check 等白名单接口不会返回 401所以这里只会命中真正的鉴权失败。
if (res.status === 401 && !_redirectingToLogin) {
_redirectingToLogin = true
window.location.href = '/login'
// 返回永挂起的 promise阻止调用方继续渲染"未登录"错误态(页面即将跳转)
return new Promise(() => {})
}
const msg = (data && (data.error || data.message)) || `HTTP ${res.status}`
throw new Error(msg)
}
@@ -33,8 +45,10 @@ export const api = {
videos: (params = {}) => request(`/api/ui/videos${qs(params)}`),
videoDetail: (id) => request(`/api/ui/videos/${id}`),
deleteVideo: (id) => request(`/api/ui/videos/${id}`, { method: 'DELETE' }),
stats: (date) => request(`/api/ui/stats${date ? '?date=' + date : ''}`),
people: () => request('/api/ui/people'),
peopleClips: (label, limit = 10) => request(`/api/ui/people/clips?label=${encodeURIComponent(label)}&limit=${limit}`),
namedMembers: () => request('/api/ui/named-members'),
modelStats: () => request('/api/ui/model-stats'),
attentionEvents: () => request('/api/ui/attention-events'),
@@ -49,7 +63,13 @@ export const api = {
mergeMember: (source, target) =>
request('/api/member/merge', { method: 'POST', body: JSON.stringify({ source, target }) }),
status: () => request('/api/status'),
authCheck: () => request('/api/auth/check'),
identityCorrect: (video_id, current_name, new_name) =>
request('/api/member/identity-correct', {
method: 'POST',
body: JSON.stringify({ video_id, current_name, new_name }),
}),
}
/** 剥离全角/半角括号备注(如 '人物A别名人物B' -> '人物A'),与后端归一化一致 */

View File

@@ -1,12 +1,39 @@
<script setup>
import { computed } from 'vue'
import { fmtTime, parsePersons } from '../api.js'
import { computed, ref } from 'vue'
import { api, fmtTime, parsePersons } from '../api.js'
import Badge from './Badge.vue'
const props = defineProps({
event: { type: Object, required: true },
videoId: { type: [Number, String], required: true },
})
const emit = defineEmits(['corrected'])
// 闭集人物识别只有这 4 个真实成员,纠错时二选/四选一,不是自由填字符串
const CANONICAL_NAMES = ['爷爷', '爸爸', '媳妇', '汤圆']
const fixingPerson = ref(null) // 当前正在纠错的人物名(打开选择器)
const fixingBusy = ref(false)
const fixError = ref('')
function toggleFix(name) {
fixError.value = ''
fixingPerson.value = fixingPerson.value === name ? null : name
}
async function correctPerson(oldName, newName) {
if (oldName === newName) { fixingPerson.value = null; return }
fixingBusy.value = true
fixError.value = ''
try {
await api.identityCorrect(props.videoId, oldName, newName)
fixingPerson.value = null
emit('corrected')
} catch (e) {
fixError.value = e.message
} finally {
fixingBusy.value = false
}
}
const timeLabel = computed(() => fmtTime(props.event.ts))
const persons = computed(() => parsePersons(props.event.person_list_json))
@@ -49,10 +76,27 @@ const appearances = computed(() => {
<div v-if="thumbUrl" class="mb-2.5 leading-none">
<img loading="lazy" alt="事件帧" :src="thumbUrl" class="block w-full rounded-lg border border-border" />
</div>
<div class="mb-2 flex flex-wrap gap-2">
<div class="mb-2 flex flex-wrap items-start gap-2">
<Badge v-if="isAttention" tone="danger"> 需关注</Badge>
<Badge v-for="p in persons" :key="p" tone="accent">{{ p }}</Badge>
<div v-for="p in persons" :key="p" class="relative">
<button type="button" class="group inline-flex items-center gap-1" @click="toggleFix(p)" title="点击纠正这个人是谁">
<Badge tone="accent">{{ p }}</Badge>
<span class="text-[10px] text-text-faint opacity-0 transition-opacity group-hover:opacity-100"></span>
</button>
<div v-if="fixingPerson === p"
class="absolute left-0 top-full z-10 mt-1 flex gap-1.5 rounded-lg border border-border-hi bg-panel p-2 shadow-[var(--shadow-card)]">
<button v-for="name in CANONICAL_NAMES" :key="name" :disabled="fixingBusy"
@click="correctPerson(p, name)"
class="rounded-md border px-2 py-1 text-xs disabled:opacity-50"
:class="name === p
? 'border-accent/50 bg-accent/15 text-accent-bright'
: 'border-border bg-panel-2 text-text-dim hover:border-accent/40 hover:text-white'">
{{ name }}
</button>
</div>
</div>
</div>
<div v-if="fixError" class="mb-2 text-xs text-danger">纠错失败{{ fixError }}</div>
<div v-if="appearances.length" class="mb-2">
<div v-for="a in appearances" :key="a.uid" class="my-1.5 rounded-lg border border-border bg-panel-3 px-2.5 py-1.5 text-xs">
<b class="text-accent-bright">{{ a.uid }}</b>

View File

@@ -1,5 +1,6 @@
<script setup>
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { api, fmtMonthDayTime } from '../api.js'
import Badge from './Badge.vue'
@@ -8,6 +9,7 @@ const props = defineProps({
allLabels: { type: Array, default: () => [] },
})
const emit = defineEmits(['named', 'merged'])
const router = useRouter()
const isNamed = computed(() => props.group.is_named)
const firstSeenStr = computed(() => {
@@ -77,6 +79,31 @@ async function doMerge(sourceLabel, target) {
busy.value = false
}
}
// 运动片段区块:某人物出现过的运动片段(按运动视频提取)
const clipsOpen = ref(false)
const clips = ref([])
const clipsLoading = ref(false)
const clipsError = ref('')
async function toggleClips() {
clipsOpen.value = !clipsOpen.value
if (!clipsOpen.value || clips.value.length || clipsLoading.value) return
clipsLoading.value = true
clipsError.value = ''
try {
const data = await api.peopleClips(props.group.display, 10)
clips.value = data.clips || []
} catch (e) {
clipsError.value = e.message
} finally {
clipsLoading.value = false
}
}
function goToClip(videoId) {
router.push({ path: '/timeline', query: { video: videoId } })
}
</script>
<template>
@@ -101,6 +128,28 @@ async function doMerge(sourceLabel, target) {
</div>
<div v-else class="mt-2 text-[11px] text-text-faint">特征待大模型补充下段视频分析时由 VLM 落库</div>
<button @click="toggleClips" class="mt-3 w-full rounded-lg border border-border bg-panel px-3 py-1.5 text-xs font-medium text-text-dim transition-colors hover:border-border-hi">
{{ clipsOpen ? '收起' : '查看' }}运动片段{{ clips.length ? `${clips.length}` : '' }}
</button>
<div v-if="clipsOpen" class="mt-2 space-y-2">
<div v-if="clipsLoading" class="py-2 text-center text-[11px] text-text-faint">加载中</div>
<div v-else-if="clipsError" class="py-2 text-center text-[11px] text-danger">{{ clipsError }}</div>
<div v-else-if="!clips.length" class="py-2 text-center text-[11px] text-text-faint">暂无该人物的运动片段</div>
<button v-for="c in clips" :key="c.video_id" @click="goToClip(c.video_id)"
class="flex w-full items-start gap-2.5 rounded-xl border border-border bg-panel-3 p-2 text-left transition-colors hover:border-border-hi">
<img v-if="c.first_ts" loading="lazy" alt="片段缩略图"
:src="`/api/proxy/frame?video_id=${c.video_id}&ts=${encodeURIComponent(c.first_ts)}&w=160`"
class="h-[54px] w-[96px] shrink-0 rounded-lg border border-border object-cover" />
<div class="min-w-0 flex-1">
<div class="text-[12px] font-medium text-text">{{ fmtMonthDayTime(c.event_start_time) }}
<span v-if="c.duration_sec" class="ml-1 font-normal text-text-mute">{{ Math.round(c.duration_sec) }}s</span>
</div>
<div class="mt-0.5 line-clamp-2 text-[11px] leading-snug text-text-dim">{{ c.summary_json || '(暂无摘要)' }}</div>
<div v-if="c.clip_events" class="mt-0.5 text-[10px] text-text-faint">{{ c.clip_events }} 个事件 · {{ c.camera_name || '未知' }}</div>
</div>
</button>
</div>
<p v-if="errorMsg" class="mt-2 text-xs text-danger">{{ errorMsg }}</p>
<div v-if="!isNamed" class="mt-3 space-y-2.5">

View File

@@ -11,6 +11,9 @@ const selectedQuick = ref('自定义')
const loading = ref(false)
const errorMsg = ref('')
const result = ref(null)
const showThinking = ref(true)
const PROVIDER_LABEL = { gemini: 'Gemini', nvidia: 'NVIDIA', ollama: '本地 Ollama' }
const quickQuestions = computed(() => [
`${queriedPerson.value}今天干嘛了?`,
@@ -30,15 +33,66 @@ onMounted(async () => {
} catch { /* 忽略:下拉留空即可 */ }
})
function handleEvent(obj) {
if (obj.type === 'context') {
result.value.contextCount = obj.count
result.value.contextSummary = obj.summary
result.value.contextPreview = obj.preview || ''
} else if (obj.type === 'provider_trying') {
result.value.tried.push(obj.provider)
result.value.provider = obj.provider
} else if (obj.type === 'chunk') {
result.value.answer += obj.text
if (obj.provider) result.value.provider = obj.provider
} else if (obj.type === 'done') {
if (obj.provider) result.value.provider = obj.provider
result.value.finished = true
showThinking.value = false // 回答完了自动收起思考过程,用户可以再点开
} else if (obj.type === 'error' || obj.type === 'all_failed') {
errorMsg.value = 'AI 服务暂时不可用,请稍后重试'
}
}
async function ask() {
errorMsg.value = ''
result.value = null
if (!userQuestion.value.trim()) { errorMsg.value = '请输入问题'; return }
if (!queriedPerson.value.trim()) { errorMsg.value = '请输入查询人物'; return }
loading.value = true
showThinking.value = true
result.value = {
question: userQuestion.value, answer: '', provider: '', tried: [],
contextCount: null, contextSummary: '', contextPreview: '', finished: false,
}
try {
const data = await api.chatAsk(userQuestion.value, queriedPerson.value, queriedDate.value)
result.value = { question: userQuestion.value, answer: data.answer, contextSummary: data.context_summary }
const res = await fetch('/api/chat/ask/stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
question: userQuestion.value,
queried_person: queriedPerson.value,
queried_date: queriedDate.value,
}),
})
if (!res.ok || !res.body) {
let msg = `HTTP ${res.status}`
try { msg = (await res.json()).error || msg } catch { /* 非 JSON 错误体 */ }
throw new Error(msg)
}
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buf = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buf += decoder.decode(value, { stream: true })
const parts = buf.split('\n\n')
buf = parts.pop() ?? ''
for (const part of parts) {
const line = part.trim()
if (!line.startsWith('data: ')) continue
try { handleEvent(JSON.parse(line.slice(6))) } catch { /* 忽略半截 JSON */ }
}
}
} catch (e) {
errorMsg.value = e.message
} finally {
@@ -84,7 +138,7 @@ async function ask() {
<button :disabled="loading" @click="ask"
class="mt-4 rounded-xl bg-gradient-to-br from-accent to-accent-2 px-5 py-2.5 text-sm font-semibold text-white shadow-[0_4px_16px_-2px_rgba(91,140,255,.4)] disabled:opacity-50">
{{ loading ? 'AI 正在思考…' : '提问' }}
{{ loading ? 'AI 正在回答…' : '提问' }}
</button>
<p v-if="errorMsg" class="mt-3 text-sm text-danger">{{ errorMsg }}</p>
@@ -94,10 +148,33 @@ async function ask() {
<div class="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-text-mute"> 提问</div>
{{ result.question }}
</div>
<!-- 思考过程/使用数据可展开/收起 -->
<div class="rounded-2xl border border-border bg-panel-3 shadow-[var(--shadow-card)]">
<button type="button" @click="showThinking = !showThinking"
class="flex w-full items-center gap-2 px-4 py-2.5 text-left text-xs font-semibold text-text-dim hover:text-white">
<span class="inline-block transition-transform" :class="showThinking ? 'rotate-90' : ''"></span>
<span>🔍 思考过程 · 使用数据</span>
<span v-if="!result.finished && loading" class="text-accent">生成中</span>
<span v-if="result.provider" class="ml-auto font-normal text-text-faint">
{{ PROVIDER_LABEL[result.provider] || result.provider }}
</span>
</button>
<div v-if="showThinking" class="border-t border-border px-4 py-3 text-xs leading-relaxed text-text-mute">
<div v-if="result.contextCount === null">正在检索相关事件</div>
<template v-else>
<div>{{ result.contextSummary }}</div>
<div v-if="result.contextPreview" class="mt-2 max-h-40 overflow-y-auto whitespace-pre-wrap rounded-lg bg-panel-2 p-2 font-mono text-[11px] text-text-faint">{{ result.contextPreview }}</div>
</template>
<div v-if="result.tried.length" class="mt-2">
尝试模型: {{ result.tried.map(p => PROVIDER_LABEL[p] || p).join(' → ') }}
</div>
</div>
</div>
<div class="rounded-2xl border border-border bg-panel-2 p-4 text-sm leading-relaxed shadow-[var(--shadow-card)]">
<div class="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-text-mute">🤖 回答</div>
<div class="whitespace-pre-wrap">{{ result.answer }}</div>
<div class="whitespace-pre-wrap">{{ result.answer }}<span v-if="loading && !result.finished" class="animate-pulse"></span></div>
</div>
<p v-if="result.contextSummary" class="text-xs text-text-faint">上下文: {{ result.contextSummary }}</p>
</div>
</template>

View File

@@ -24,6 +24,7 @@ async function load() {
}
}
onMounted(load)
const oracle = computed(() => data.value?.oracle || {})
@@ -38,7 +39,11 @@ const qs = computed(() => queue.value.stats || {})
const rclone = computed(() => oracle.value.rclone)
const person = computed(() => oracle.value.person)
const nasSync = computed(() => data.value?.nas_sync)
const segment = computed(() => oracle.value.segment)
const segCons = computed(() => segment.value?.consistency || {})
const motion = computed(() => oracle.value.motion || {})
// 心跳超过 15 分钟视为推送链路失联(与甲骨文侧 max_heartbeat_age_sec 默认值一致)
const motionOk = computed(() => (motion.value.heartbeat_age_sec ?? 1e9) < 900)
const modelCalls = computed(() => oracle.value.model_calls || [])
const activities = computed(() => oracle.value.activities || [])
@@ -56,13 +61,13 @@ const SVC_BADGE = {
<button :disabled="loading" @click="load" class="rounded-lg border border-border bg-panel-2 px-3.5 py-1.5 text-sm text-text-dim hover:border-accent/40 hover:text-white disabled:opacity-50">
{{ loading && data ? '刷新中…' : '🔄 刷新' }}
</button>
<span class="text-xs text-text-mute">点击刷新立即更新</span>
<span class="text-xs text-text-mute">数据直接来自甲骨文无需同步</span>
</div>
<Spinner v-if="loading && !data" text="加载服务状态…" />
<template v-else>
<p v-if="oracleError" class="mb-4 text-sm text-warn">{{ oracleError }}</p>
<EmptyState v-if="!data?.oracle && !data?.nas_sync" text="暂时无法获取服务状态" />
<EmptyState v-if="!data?.oracle" text="暂时无法获取服务状态" />
<template v-else>
<div class="mb-3 text-[15px] font-bold text-[#f7f9fc]">各服务当前状态</div>
@@ -88,10 +93,12 @@ const SVC_BADGE = {
{{ person ? person.action : '暂无记录' }}
<template #sub>{{ person ? `${(person.ts || '').slice(0, 19)} · ${(person.detail || '').slice(0, 46)}` : '—' }}</template>
</ServiceCard>
<ServiceCard icon="📡" name="NAS 同步" :value-color="nasSync ? '#fbbf24' : undefined">
{{ nasSync ? `游标 ${(nasSync.cursor || '').slice(0, 19)}` : '不可达' }}
<ServiceCard icon="📡" name="NAS 运动推送" :value-color="motionOk ? '#4ade80' : '#fbbf24'">
{{ motion.heartbeat_age_sec == null ? '从未收到心跳'
: motionOk ? `正常 · ${Math.round(motion.heartbeat_age_sec)}s 前`
: `失联 ${Math.round(motion.heartbeat_age_sec / 60)} 分钟` }}
<template #sub>
{{ nasSync ? `最近 ${(nasSync.last_sync_at || '').slice(0, 19)} · 增量 V${nasSync.last_count?.[0] ?? 0} E${nasSync.last_count?.[1] ?? 0} P${nasSync.last_count?.[2] ?? 0} M${nasSync.last_count?.[3] ?? 0}` : '' }}
{{ motion.recent?.length ? `最近事件 #${motion.recent[0].event_id} · ${(motion.recent[0].received_at || '').slice(0, 19)}` : '暂无运动事件' }}
</template>
</ServiceCard>
<ServiceCard icon="🧠" name="云端模型" :value-color="modelCalls.length ? '#4ade80' : undefined">
@@ -101,6 +108,21 @@ const SVC_BADGE = {
近5次 成功{{ modelCalls.filter(m => m.success).length }}/{{ modelCalls.length }}
</template>
</ServiceCard>
<ServiceCard icon="🎬" name="视频分割" :value-color="segment?.total ? '#f472b6' : undefined">
{{ segment && segment.total ? `运动片段 ${segment.done ?? 0}/${segment.total} · 文件 ${segment.clips_files ?? 0}` : '暂无片段' }}
<template #sub v-if="segment && segment.total">
待处理 {{ segment.pending ?? 0 }} · 失败 {{ segment.failed ?? 0 }} · 运动事件 {{ segment.motion_events ?? 0 }}
<span v-if="segCons.event_total" class="mt-0.5 block">
一致性事件 {{ segCons.event_total }} · 已结束 {{ segCons.finished }} · 已分割 {{ segCons.segmented }}
<span :class="(segCons.gap_count || 0) > 0 ? 'text-warn' : 'text-text-mute'">
· 缺口 {{ segCons.gap_count ?? 0 }}
</span>
</span>
<span v-if="segment.last" class="mt-0.5 block text-text-mute">
{{ (segment.last.ts || '').slice(0, 19) }} · {{ (segment.last.detail || '').slice(0, 42) }}
</span>
</template>
</ServiceCard>
</div>
<div class="mb-3 text-[15px] font-bold text-[#f7f9fc]">最近活动</div>

View File

@@ -1,13 +1,12 @@
<script setup>
import { computed, onMounted, ref } from 'vue'
import { api, fmtDateOnly, fmtDateTime } from '../api.js'
import { api, fmtDateOnly } from '../api.js'
import PageHeader from '../components/PageHeader.vue'
import EmptyState from '../components/EmptyState.vue'
import Spinner from '../components/Spinner.vue'
const modelChart = ref([]) // [{label, value}]
const attention = ref([])
const syncStatus = ref(null)
const loadError = ref('')
const loading = ref(true)
@@ -18,10 +17,9 @@ async function load() {
loadError.value = ''
loading.value = true
try {
const [ms, att, statusData] = await Promise.all([
const [ms, att] = await Promise.all([
api.modelStats(),
api.attentionEvents(),
api.status(),
])
const byProvider = {}
for (const row of ms.aggregate) {
@@ -30,7 +28,6 @@ async function load() {
}
modelChart.value = Object.entries(byProvider).map(([label, value]) => ({ label, value }))
attention.value = att.events
syncStatus.value = statusData.sync
} catch (e) {
loadError.value = e.message
} finally {
@@ -42,7 +39,7 @@ onMounted(load)
</script>
<template>
<PageHeader icon="📈" title="统计图表" sub="模型来源 / 关注事件 / 同步状态" />
<PageHeader icon="📈" title="统计图表" sub="模型来源 / 关注事件" />
<Spinner v-if="loading" text="加载统计数据…" />
<template v-else>
@@ -79,15 +76,5 @@ onMounted(load)
</table>
</div>
<div class="mb-3 mt-6 text-[15px] font-bold text-[#f7f9fc]">同步状态</div>
<div v-if="syncStatus" class="rounded-xl border border-border bg-panel-2 px-4 py-3 text-xs leading-loose text-text-dim">
运行状态 <span :class="syncStatus.running ? 'font-semibold text-ok' : 'font-semibold text-danger'">{{ syncStatus.running ? '同步中' : '未运行' }}</span><br />
最近同步 <b class="text-[#ccd5e1]">{{ syncStatus.last_sync_at ? fmtDateTime(syncStatus.last_sync_at) : '—' }}</b><br />
本次增量 {{ syncStatus.last_count ? `视频+${syncStatus.last_count[0]} / 事件+${syncStatus.last_count[1]} / 人物+${syncStatus.last_count[2]}` : '—' }}<br />
游标 <b class="text-[#ccd5e1]">{{ syncStatus.cursor ? fmtDateTime(syncStatus.cursor) : '(全量)' }}</b><br />
周期 {{ syncStatus.interval_sec }}s
<div v-if="syncStatus.last_error" class="font-semibold text-danger"> {{ syncStatus.last_error }}</div>
</div>
<EmptyState v-else text="无法获取同步状态" />
</template>
</template>

View File

@@ -1,5 +1,6 @@
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { api, fmtDateOnly, fmtMonthDayTime, fmtDateTime, parseTs } from '../api.js'
import PageHeader from '../components/PageHeader.vue'
import StatCard from '../components/StatCard.vue'
@@ -8,11 +9,14 @@ import EventItem from '../components/EventItem.vue'
import Badge from '../components/Badge.vue'
import Spinner from '../components/Spinner.vue'
const route = useRoute()
const dateFilter = ref('')
const page = ref(0)
const videos = ref([])
const stats = ref(null)
const selectedId = ref(null)
// 支持 ?video=<id> 定位(人物卡/其他页跳转);非 null 时不因列表重置选中
const selectedId = ref(Number(route.query.video) || null)
const detail = ref(null)
const loadError = ref('')
const videosLoading = ref(true)
@@ -32,7 +36,8 @@ async function loadVideos() {
try {
const data = await api.videos({ date: dateFilter.value || undefined, page: page.value })
videos.value = data.videos
if (videos.value.length && !videos.value.some(v => v.id === selectedId.value)) {
// 仅当没有 query 定位且当前选中不在列表时,才默认选第一条
if (videos.value.length && !videos.value.some(v => v.id === selectedId.value) && !route.query.video) {
selectedId.value = videos.value[0].id
}
} catch (e) {
@@ -89,6 +94,28 @@ const modelBadges = computed(() => {
const provider = detail.value?.video?.compute_provider || ''
return provider ? String(provider).split(',').map(p => p.trim()).filter(Boolean) : []
})
const deleteBusy = ref(false)
const deleteError = ref('')
async function deleteVideo(id) {
if (!confirm('确定删除这个视频会话吗?此操作不可恢复,会同时删除对应的视频文件。')) return
deleteBusy.value = true
deleteError.value = ''
try {
await api.deleteVideo(id)
videos.value = videos.value.filter(v => v.id !== id)
if (selectedId.value === id) {
selectedId.value = videos.value.length ? videos.value[0].id : null
if (!selectedId.value) detail.value = null
}
loadStats()
} catch (e) {
deleteError.value = e.message
} finally {
deleteBusy.value = false
}
}
</script>
<template>
@@ -138,14 +165,20 @@ const modelBadges = computed(() => {
<span>{{ detail.video.camera_name || '未知摄像头' }}</span>
<Badge tone="ok">会话 #{{ detail.video.id }}</Badge>
<Badge v-for="m in modelBadges" :key="m" tone="neutral">{{ m }}</Badge>
<button :disabled="deleteBusy" @click="deleteVideo(detail.video.id)"
class="ml-auto rounded-lg border border-danger/35 bg-danger/14 px-2.5 py-1 text-xs font-medium text-danger transition-colors hover:bg-danger/25 disabled:opacity-40">
{{ deleteBusy ? '删除中…' : '🗑 删除会话' }}
</button>
</div>
<div class="mt-1.5 font-mono text-[13px] text-text-dim tabular"> {{ rangeStr }} · 文件 {{ detail.video.filename }}</div>
<div class="mt-2.5 whitespace-pre-wrap text-sm leading-relaxed text-[#d6dce6]">{{ detail.video.summary_json || '暂无全局摘要' }}</div>
<div v-if="deleteError" class="mt-2.5 rounded-lg border border-danger/35 bg-danger/14 px-3 py-2 text-xs text-danger">{{ deleteError }}</div>
</div>
<EmptyState v-if="!detail.events.length" icon="🎞" text="该会话暂无时间点事件" />
<div v-else>
<EventItem v-for="ev in detail.events" :key="ev.id" :event="ev" :video-id="detail.video.id" />
<EventItem v-for="ev in detail.events" :key="ev.id" :event="ev" :video-id="detail.video.id"
@corrected="loadDetail(detail.video.id)" />
</div>
</div>
</div>

View File

@@ -3,7 +3,8 @@ import vue from '@vitejs/plugin-vue'
import tailwindcss from '@tailwindcss/vite'
// 开发环境代理 /api 到 NAS 上的 fam-core方便本地直接联调真实数据。
// 生产环境由 fam-core 的 Flask 同源提供,不需要这个代理。
// 生产环境由云服务器 Caddy :80 托管静态产物 + 反代 /api/*(经 frp 隧道回源
// 到这里同一个 NAS fam-core :8000不需要这个代理跟开发环境走的是同一个后端。
export default defineConfig({
plugins: [vue(), tailwindcss()],
server: {

View File

@@ -128,8 +128,13 @@ CREATE TABLE IF NOT EXISTS family_members (
-- ============================================================
-- 7.1 视频会话表Oracle videos 镜像)
-- v2 (2026-09-03)id 改为 NAS 本地自增主键(不再直接等于 Oracle id因 Oracle 库
-- 重建/重排会复用 id导致 filename UNIQUE 二次冲突 1062Oracle 的 videos.id 仅
-- 落 oracle_id 列溯源。业务唯一键仍为 filename。子表 video_id 引用仍用 Oracle id
-- (新视频插入时 id=oracle_id旧视频原地更新保留原 id外键不失效
CREATE TABLE IF NOT EXISTS sync_videos (
id INT PRIMARY KEY COMMENT 'Oracle videos.id',
id INT AUTO_INCREMENT PRIMARY KEY COMMENT 'NAS 本地自增主键',
oracle_id INT COMMENT 'Oracle videos.id仅溯源参考',
drive_file_id VARCHAR(255) COMMENT 'Google 硬盘文件 ID',
filename VARCHAR(500) NOT NULL UNIQUE COMMENT '视频文件名(唯一)',
camera_name VARCHAR(50) COMMENT '摄像头名称/位置',
@@ -164,8 +169,11 @@ CREATE TABLE IF NOT EXISTS sync_events (
) ENGINE=InnoDB COMMENT='甲骨文事件镜像表';
-- 7.3 人物规范表Oracle people 镜像)
-- v2 (2026-09-03):同 7.1id 改为 NAS 本地自增主键Oracle people.id 落 oracle_id
-- 列溯源;业务唯一键仍为 label。people.id 无外键引用,仅作展示排序。
CREATE TABLE IF NOT EXISTS sync_people (
id INT PRIMARY KEY COMMENT 'Oracle people.id',
id INT AUTO_INCREMENT PRIMARY KEY COMMENT 'NAS 本地自增主键',
oracle_id INT COMMENT 'Oracle people.id仅溯源参考',
label VARCHAR(100) NOT NULL UNIQUE COMMENT '抽象标识,如"人物A"',
canonical_name VARCHAR(100) COMMENT '规范名(用户命名或 LLM 合并NULL 表示未命名',
first_seen VARCHAR(32) COMMENT '首次出现时间',
@@ -185,6 +193,25 @@ CREATE TABLE IF NOT EXISTS sync_cursor (
`value` VARCHAR(64) COMMENT '上次成功拉取到的 server_timeISO 文本)'
) ENGINE=InnoDB COMMENT='同步游标表';
-- 7.5 人物对应关系表Oracle person_identity_map 镜像2026-08-22 新增)
-- 闭集人物识别:某视频里 Gemini 给的原始 uid 与解析出的规范名(爷爷/爸爸/媳妇/
-- 汤圆)之间的映射,供事件时间轴"纠错"按钮定位、追溯识别来源
-- v2 (2026-08-29)id 改为 NAS 本地自增主键,业务键 (video_id, raw_uid) 唯一;
-- Oracle 的 person_identity_map.id 只落 oracle_id 列溯源。原以 Oracle id 为主键,
-- 一旦 Oracle 库重建/复用 id 会引发 uq_video_raw_uid 二次冲突 (1062)。
CREATE TABLE IF NOT EXISTS sync_identity_map (
id INT AUTO_INCREMENT PRIMARY KEY COMMENT 'NAS 本地自增主键',
oracle_id INT COMMENT 'Oracle person_identity_map.id仅溯源参考',
video_id INT NOT NULL COMMENT '关联 sync_videos.id',
raw_uid VARCHAR(100) COMMENT '该视频里 Gemini 给的原始 uid',
canonical_name VARCHAR(100) COMMENT '当前生效的规范名',
source VARCHAR(20) COMMENT 'rule / auto_id / 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 镜像)
CREATE TABLE IF NOT EXISTS sync_model_calls (
id INT PRIMARY KEY COMMENT 'Oracle model_calls.id',

View File

@@ -3,6 +3,7 @@
# 用法: ./scripts/start_core.sh
set -e
# APP_DIR = <repo>/fam-core
APP_DIR="$(cd "$(dirname "$0")/.." && pwd)/fam-core"
CONFIG_FILE="$APP_DIR/config/config.yaml"
@@ -14,10 +15,16 @@ fi
cd "$APP_DIR"
# 加载项目级环境变量ORACLE_SYNC_TOKEN 等,与甲骨文端保持一致)
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
if [ -f "$SCRIPT_DIR/../.env" ]; then
source "$SCRIPT_DIR/../.env"
# 加载项目级环境变量ORACLE_SYNC_TOKEN / AUTH_HUB_* 等,与甲骨文端保持一致)
# 注意:.env 里 AUTH_HUB_* 是“裸赋值”(无 export 前缀),必须用 set -a 包裹 source
# 否则这些变量只存在于 shell 内、不会 export 给 gunicorn 子进程,导致 fam-core
# 启动后读不到 AUTH_HUB_ISSUER 等、登录被 fail-closed 拒绝(见 auth.py
# 另外 .env 路径基于 $APP_DIR 推导,避免“先 cd 再算 SCRIPT_DIR”的相对路径错位。
ENV_FILE="$APP_DIR/../.env"
if [ -f "$ENV_FILE" ]; then
set -a
source "$ENV_FILE"
set +a
fi
# 检查虚拟环境

View File

@@ -1,5 +1,7 @@
#!/bin/bash
# FAM-Edge 启动脚本 (Oracle 端)
# FAM-Edge 启动脚本 (Oracle 端) —— 仅本地调试用
# 生产环境由 systemd fam-edge.service 守护Restart=always/opt/fam-edge/venv/bin/gunicorn
# -w 1 --threads 4 --timeout 1800部署后请用 `sudo systemctl restart fam-edge`。
# 用法: ./scripts/start_edge.sh
set -e

View File

@@ -1,28 +0,0 @@
#!/bin/bash
# FAM-UI 启动脚本 (NAS 端)
# 用法: ./scripts/start_ui.sh
set -e
APP_DIR="$(cd "$(dirname "$0")/.." && pwd)/fam-ui"
CONFIG_FILE="$APP_DIR/config/config.yaml"
if [ ! -f "$CONFIG_FILE" ]; then
echo "错误: 配置文件不存在: $CONFIG_FILE"
echo "请复制 config.yaml.example 为 config.yaml 并修改实际值"
exit 1
fi
cd "$APP_DIR"
# 注入项目级 .envORACLE_SYNC_TOKEN 等),供 config.yaml ${VAR} 解析
if [ -f "$(dirname "$APP_DIR")/.env" ]; then
source "$(dirname "$APP_DIR")/.env"
fi
# 检查虚拟环境
if [ -d "venv" ]; then
source venv/bin/activate
fi
echo "启动 FAM-UI Streamlit (端口 8501)..."
exec streamlit run src/app.py --server.port 8501 --server.address 0.0.0.0