refactor(fam-edge): 重构第一阶段 - 人物图片零额外调用 + 运行时稳定性 + 工程质量
人物图片功能重做: bbox 随核心视频分析那一次 Gemini 调用一并产出(prompts.py 加
person_appearances.bbox 字段, [ymin,xmin,ymax,xmax] 0-1000 归一化), frame_service
直接用存好的 bbox 裁剪头像/事件缩略图, 删除原来"展示时额外调用 Gemini 定位人物"的
整套逻辑(locate_person_bbox/VLM 校验/熔断), 从架构上消除与核心视频分析共抢配额的
问题; 用真实数据验证裁剪结果正确框住人物本体。
NVIDIA 模型修复: 实测原配置的 3 个模型均不可用(asset_id 引用 500/400, 不支持视频),
改用 nemotron-3-nano-omni 的 base64 内嵌视频方式(唯一实测打通), 加 max_base64_mb
防止对大文件做注定失败的编码。
Gemini 多 Key 轮换: 支持 extra_api_keys 配置多个独立项目的 key, 配额用尽时依次
换 key 重试(每换 key 需重新上传, Files API 按项目隔离)。
稳定性加固: CircuitBreaker HALF_OPEN 清空旧失败计数(修复探测一失败就重新 OPEN 的
bug); chat() 统一接入熔断器(原来只有视频分析路径检查); NVIDIA 适配器改用共享
json_parser(原来自己重复实现且不做 schema 校验); Gemini Files API 上传超时也尝试
清理远程孤儿文件; video_processor/video_queue 里直接操作 OracleDB._conn 的裸 SQL
改走新增的 set_event_start_time/mark_video_invalid/reset_video_to_pending 方法;
/health 加入队列线程存活状态; 密钥改用 ${ENV_VAR} 引用(.env 已支持自动加载),
不再明文写入 config.yaml。
工程质量: 新增 fam-edge/tests(32 个单元测试, 覆盖熔断器状态机/JSON 解析容错/
时间戳解析/bbox 坐标换算/多 key 解析), 新增 scripts/smoke_test.py(发版前接口
稳定性检查); 清理死代码(OllamaAdapter.analyze_frames、get_sync_delta 死分支、
未使用的 vision_timeout/max_concurrent_tasks 配置项); 修正 get_events_for_label
排序(改最近优先 + 过滤畸形历史时间戳)。
已部署 Oracle 并跑通 smoke test 全部 6 项检查。
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,7 @@ Oracle 本地库(SQLite) - 视频摘要 / 事件 / 人物 存储
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
import threading
|
||||
from datetime import datetime, timezone, timedelta
|
||||
@@ -289,6 +290,170 @@ class OracleDB:
|
||||
(error, now, now, video_id))
|
||||
self._conn.commit()
|
||||
|
||||
def set_event_start_time(self, video_id: int, event_start_time: str):
|
||||
"""回填从文件名解析出的视频开始时间(补录/纠偏用)。"""
|
||||
self._conn.execute(
|
||||
"UPDATE videos SET event_start_time=?, updated_at=? WHERE id=?",
|
||||
(event_start_time, _now_iso(), video_id))
|
||||
self._conn.commit()
|
||||
|
||||
def mark_video_invalid(self, video_id: int, error: str = ''):
|
||||
"""文件校验不通过(损坏/非视频等),标记 invalid,producer 不再重试。"""
|
||||
now = _now_iso()
|
||||
self._conn.execute(
|
||||
"UPDATE videos SET status='invalid', file_valid=0, file_error=?, "
|
||||
"updated_at=? WHERE id=?",
|
||||
(error or '', now, video_id))
|
||||
self._conn.commit()
|
||||
|
||||
def reset_video_to_pending(self, video_id: int):
|
||||
"""文件被重新同步覆盖(mtime 变化)时,清掉旧分析结果重新排队处理。"""
|
||||
now = _now_iso()
|
||||
self._conn.execute(
|
||||
"UPDATE videos SET status='pending', retry_count=0, summary_json=NULL, "
|
||||
"events_json=NULL, people_json=NULL, compute_provider=NULL, "
|
||||
"processed_at=NULL, file_valid=1, updated_at=? WHERE id=?",
|
||||
(now, video_id))
|
||||
self._conn.commit()
|
||||
|
||||
def get_first_event_for_label(self, label: str):
|
||||
"""找到某人物(canonical_name 或 UID label)最早一次出现的事件。
|
||||
|
||||
返回 dict{video_id, ts, features_text, event_start_time} 或 None。
|
||||
features_text 从该事件 person_appearances_json 中对应 uid 的特征拼出,
|
||||
供 frame_service 用大模型在画面中定位该人物。
|
||||
"""
|
||||
# canonical_name -> 其下所有 label;否则按 label 本身匹配
|
||||
rows = self._conn.execute(
|
||||
"SELECT label FROM people WHERE canonical_name=?", (label,)).fetchall()
|
||||
labels = {r['label'] for r in rows} if rows else {label}
|
||||
|
||||
best = None
|
||||
for lb in labels:
|
||||
pattern = f'%{lb}%'
|
||||
row = self._conn.execute(
|
||||
"""SELECT e.video_id, e.ts, e.person_appearances_json,
|
||||
v.event_start_time
|
||||
FROM events e JOIN videos v ON v.id=e.video_id
|
||||
WHERE v.status='done'
|
||||
AND (e.person_list_json LIKE ? OR e.person_appearances_json LIKE ?)
|
||||
ORDER BY e.ts ASC LIMIT 1""",
|
||||
(pattern, pattern)).fetchone()
|
||||
if row and row['video_id'] and \
|
||||
(best is None or (row['ts'] or '') < (best['ts'] or '')):
|
||||
best = row
|
||||
if not best:
|
||||
return None
|
||||
|
||||
features_text = ''
|
||||
try:
|
||||
pa = json.loads(best['person_appearances_json'] or '[]')
|
||||
except (ValueError, TypeError):
|
||||
pa = []
|
||||
if isinstance(pa, list):
|
||||
for p in pa:
|
||||
uid = str((p.get('uid') or '')).strip()
|
||||
if uid and uid in labels and isinstance(p.get('features'), dict):
|
||||
bits = [str(v) for v in p['features'].values()
|
||||
if v and str(v).strip().lower() != 'unknown']
|
||||
if bits:
|
||||
features_text = ','.join(bits)
|
||||
break
|
||||
return {'video_id': best['video_id'], 'ts': best['ts'],
|
||||
'features_text': features_text,
|
||||
'event_start_time': best['event_start_time']}
|
||||
|
||||
def get_events_for_label(self, label: str, limit: int = 6):
|
||||
"""该人物(canonical_name 或 UID label)出现的候选事件,按时间倒序(最近优先)。
|
||||
|
||||
返回 [{video_id, ts, features_text, bbox}](dict 列表):features_text 是该
|
||||
事件中该人物的结构化特征文本;bbox 是视频分析时随该人物一并产出的包围框
|
||||
([ymin,xmin,ymax,xmax],0-1000 归一化,取不到为 None)——frame_service 直接
|
||||
用它做头像裁剪,不再额外调用模型定位。取最近的事件而不是最早的:bbox 是新
|
||||
加的字段,老事件普遍没有,最近优先能更快用上新数据,也更能反映人物当前样貌。
|
||||
"""
|
||||
rows = self._conn.execute(
|
||||
"SELECT label FROM people WHERE canonical_name=?", (label,)).fetchall()
|
||||
labels = {r['label'] for r in rows} | {label}
|
||||
|
||||
seen = set()
|
||||
out = []
|
||||
for lb in labels:
|
||||
pattern = f'%{lb}%'
|
||||
rs = self._conn.execute(
|
||||
"""SELECT e.video_id, e.ts, e.person_appearances_json, v.event_start_time
|
||||
FROM events e JOIN videos v ON v.id=e.video_id
|
||||
WHERE v.status='done'
|
||||
AND (e.person_list_json LIKE ? OR e.person_appearances_json LIKE ?)
|
||||
-- 排除历史遗留的畸形 ts(如缺日期的 "26:21"):这类值既不能
|
||||
-- 正确排序(字符串比较会排到最前面),extract_frame 也没法从
|
||||
-- 中算出正确偏移,只会抽到视频开头的错误画面
|
||||
AND e.ts GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9] [0-9][0-9]:[0-9][0-9]:[0-9][0-9]'
|
||||
ORDER BY e.ts DESC LIMIT ?""",
|
||||
(pattern, pattern, limit)).fetchall()
|
||||
for r in rs:
|
||||
key = (r['video_id'], r['ts'])
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
d = dict(r)
|
||||
d['features_text'] = self._features_text_for(
|
||||
d.get('person_appearances_json'), labels)
|
||||
d['bbox'] = self._bbox_for_uids(d.get('person_appearances_json'), labels)
|
||||
out.append(d)
|
||||
out.sort(key=lambda r: (r['ts'] or ''), reverse=True)
|
||||
return out[:limit]
|
||||
|
||||
@staticmethod
|
||||
def _bbox_for_uids(pa_json, uids):
|
||||
"""从 person_appearances_json 里取属于 uids 身份组那个人物的 bbox
|
||||
([ymin,xmin,ymax,xmax],0-1000 归一化)。bbox 随视频分析一次性产出,
|
||||
取不到/非法一律返回 None(调用方退回整帧兜底,不再额外调用模型定位)。"""
|
||||
try:
|
||||
pa = json.loads(pa_json or '[]')
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
if not isinstance(pa, list):
|
||||
return None
|
||||
for p in pa:
|
||||
if not isinstance(p, dict):
|
||||
continue
|
||||
p_uid = re.sub(r'[((][^()()]*[))]', '', str(p.get('uid', ''))).strip()
|
||||
if p_uid not in uids:
|
||||
continue
|
||||
bbox = p.get('bbox')
|
||||
if isinstance(bbox, list) and len(bbox) == 4:
|
||||
try:
|
||||
return [float(v) for v in bbox]
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _features_text_for(pa_json, uids) -> str:
|
||||
"""从 person_appearances_json 提取属于 uids 身份组的人物特征文本。
|
||||
|
||||
uid 先剥离括号再匹配(历史事件里存在 '人物A(别名:人物B)' 这类原始输出),
|
||||
只取该组人物的特征,避免把同帧其他人的特征混进头像定位 prompt。
|
||||
"""
|
||||
try:
|
||||
pa = json.loads(pa_json or '[]')
|
||||
except (ValueError, TypeError):
|
||||
return ''
|
||||
if not isinstance(pa, list):
|
||||
return ''
|
||||
bits = []
|
||||
for p in pa:
|
||||
if not isinstance(p, dict):
|
||||
continue
|
||||
uid = re.sub(r'[((][^()()]*[))]', '', str(p.get('uid', ''))).strip()
|
||||
if uid not in uids or not isinstance(p.get('features'), dict):
|
||||
continue
|
||||
for v in p['features'].values():
|
||||
if v and str(v).strip().lower() != 'unknown':
|
||||
bits.append(str(v))
|
||||
return ','.join(bits)
|
||||
|
||||
def get_all_videos(self) -> List[sqlite3.Row]:
|
||||
return self._conn.execute(
|
||||
"SELECT * FROM videos WHERE status='done' ORDER BY id ASC").fetchall()
|
||||
@@ -306,6 +471,9 @@ class OracleDB:
|
||||
覆盖,新非 unknown 字段补齐)。None 时不更新特征列。
|
||||
display_uid: 大模型给的人物 UID(如 "人物A")。label 本身就是 UID 时可省略。
|
||||
"""
|
||||
# 剥离括号后缀(如 '人物A(别名/标识:人物B)' -> '人物A'),防止大模型
|
||||
# 带备注的原始输出分裂出垃圾人物行
|
||||
label = re.sub(r'[((][^()()]*[))]', '', str(label)).strip() or str(label)
|
||||
now = _now_iso()
|
||||
row = self._conn.execute("SELECT * FROM people WHERE label=?", (label,)).fetchone()
|
||||
# 特征合并(在已有 features_json 基础上)
|
||||
@@ -363,11 +531,12 @@ class OracleDB:
|
||||
return json.dumps(merged, ensure_ascii=False)
|
||||
|
||||
def set_canonical(self, label: str, canonical_name: str, source: str = 'manual'):
|
||||
"""手动命名:设置规范名(label 可视为别名)。"""
|
||||
self.upsert_person(label, canonical_name, source='manual')
|
||||
"""设置规范名(label 可视为别名)。source 透传:llm 的可被后续纠正,manual 优先。"""
|
||||
self.upsert_person(label, canonical_name, source=source)
|
||||
|
||||
def set_person_appearances(self, label: str, count: int, source: str = 'llm'):
|
||||
"""覆盖设置出现次数(reconcile 时用 distinct 视频数校准,避免累加膨胀)。"""
|
||||
label = re.sub(r'[((][^()()]*[))]', '', str(label)).strip() or str(label)
|
||||
now = _now_iso()
|
||||
row = self._conn.execute("SELECT * FROM people WHERE label=?", (label,)).fetchone()
|
||||
if row:
|
||||
@@ -409,9 +578,8 @@ class OracleDB:
|
||||
videos = self._conn.execute(
|
||||
"SELECT * FROM videos WHERE updated_at > ? ORDER BY id ASC", (since_iso,)
|
||||
).fetchall()
|
||||
# events 表本身没有 updated_at 列,变更判断借用所属 video 的 updated_at
|
||||
events = self._conn.execute(
|
||||
"SELECT * FROM events WHERE updated_at > ? ORDER BY id ASC", (since_iso,)
|
||||
).fetchall() if False else self._conn.execute(
|
||||
"SELECT e.* FROM events e JOIN videos v ON e.video_id=v.id "
|
||||
"WHERE v.updated_at > ? ORDER BY e.id ASC", (since_iso,)).fetchall()
|
||||
people = self._conn.execute(
|
||||
|
||||
Reference in New Issue
Block a user