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:
ericwyuan
2026-08-22 00:22:57 +08:00
parent 677c5bdcc7
commit 61db82cb9b
21 changed files with 1272 additions and 399 deletions

View File

@@ -0,0 +1,181 @@
#!/usr/bin/env python3
"""
fam-edge 发版前稳定性检查 - 依次调用线上接口做基本断言,输出 pass/fail 清单。
不是 CI项目没有 CI 基础设施),是给人在部署后手动跑的检查脚本。
用法:
python3 smoke_test.py --base-url http://127.0.0.1:5000 --token <ORACLE_SYNC_TOKEN> \
[--video-id 173 --ts "2026-08-21 16:11:25"] [--label 人物A] [--skip-chat]
video-id/ts/label 是可选的:不传就跳过 /api/oracle/frame 和 /api/oracle/avatar 检查
(这两个接口需要库里真实存在的数据,不同部署环境的数据不一样,没法硬编码)。
"""
import argparse
import sys
import time
import requests
PASS = "PASS"
FAIL = "FAIL"
SKIP = "SKIP"
class Report:
def __init__(self):
self.rows = []
def add(self, name, status, detail=""):
self.rows.append((name, status, detail))
mark = {"PASS": "", "FAIL": "", "SKIP": "-"}[status]
print(f"[{mark}] {name}: {status}" + (f" ({detail})" if detail else ""))
def ok(self):
return all(r[1] != FAIL for r in self.rows)
def check_health(report, base_url):
try:
t0 = time.time()
resp = requests.get(f"{base_url}/health", timeout=10)
dt = time.time() - t0
if resp.status_code != 200:
report.add("GET /health", FAIL, f"HTTP {resp.status_code}")
return
data = resp.json()
if data.get("status") != "ok":
report.add("GET /health", FAIL, f"status={data.get('status')}")
return
report.add("GET /health", PASS, f"{dt:.2f}s, processed_videos={data.get('processed_videos')}")
except Exception as e:
report.add("GET /health", FAIL, str(e))
def check_sync(report, base_url, token):
try:
t0 = time.time()
resp = requests.get(f"{base_url}/api/oracle/sync",
params={"since": "", "token": token}, timeout=30)
dt = time.time() - t0
if resp.status_code != 200:
report.add("GET /api/oracle/sync", FAIL, f"HTTP {resp.status_code}: {resp.text[:150]}")
return
data = resp.json()
missing = [k for k in ("videos", "events", "people", "server_time") if k not in data]
if missing:
report.add("GET /api/oracle/sync", FAIL, f"缺字段: {missing}")
return
report.add("GET /api/oracle/sync", PASS,
f"{dt:.2f}s, videos={len(data['videos'])} events={len(data['events'])}")
except Exception as e:
report.add("GET /api/oracle/sync", FAIL, str(e))
def check_activity(report, base_url, token):
try:
t0 = time.time()
resp = requests.get(f"{base_url}/api/oracle/activity", params={"token": token}, timeout=15)
dt = time.time() - t0
if resp.status_code != 200:
report.add("GET /api/oracle/activity", FAIL, f"HTTP {resp.status_code}")
return
data = resp.json()
if "queue" not in data or "db" not in data:
report.add("GET /api/oracle/activity", FAIL, "缺 queue/db 字段")
return
report.add("GET /api/oracle/activity", PASS, f"{dt:.2f}s, queue={data.get('queue')}")
except Exception as e:
report.add("GET /api/oracle/activity", FAIL, str(e))
def check_frame(report, base_url, token, video_id, ts):
if not video_id or not ts:
report.add("GET /api/oracle/frame", SKIP, "未提供 --video-id/--ts")
return
try:
t0 = time.time()
resp = requests.get(f"{base_url}/api/oracle/frame",
params={"video_id": video_id, "ts": ts, "token": token, "w": 440},
timeout=60)
dt = time.time() - t0
if resp.status_code != 200:
report.add("GET /api/oracle/frame", FAIL, f"HTTP {resp.status_code}")
return
if resp.headers.get("content-type") != "image/jpeg" or len(resp.content) < 100:
report.add("GET /api/oracle/frame", FAIL, "返回内容不是合法 JPEG")
return
report.add("GET /api/oracle/frame", PASS, f"{dt:.2f}s, {len(resp.content)} bytes")
except Exception as e:
report.add("GET /api/oracle/frame", FAIL, str(e))
def check_avatar(report, base_url, token, label):
if not label:
report.add("GET /api/oracle/avatar", SKIP, "未提供 --label")
return
try:
t0 = time.time()
resp = requests.get(f"{base_url}/api/oracle/avatar",
params={"label": label, "token": token, "w": 160}, timeout=60)
dt = time.time() - t0
if resp.status_code != 200:
report.add("GET /api/oracle/avatar", FAIL, f"HTTP {resp.status_code}")
return
if resp.headers.get("content-type") != "image/jpeg" or len(resp.content) < 100:
report.add("GET /api/oracle/avatar", FAIL, "返回内容不是合法 JPEG")
return
report.add("GET /api/oracle/avatar", PASS, f"{dt:.2f}s, {len(resp.content)} bytes")
except Exception as e:
report.add("GET /api/oracle/avatar", FAIL, str(e))
def check_chat(report, base_url):
try:
t0 = time.time()
resp = requests.post(f"{base_url}/api/edge/chat/ask",
json={"prompt": "只回复两个字:在线", "max_tokens": 30}, timeout=120)
dt = time.time() - t0
if resp.status_code != 200:
report.add("POST /api/edge/chat/ask", FAIL, f"HTTP {resp.status_code}: {resp.text[:150]}")
return
data = resp.json()
if not data.get("answer"):
report.add("POST /api/edge/chat/ask", FAIL, "answer 为空")
return
report.add("POST /api/edge/chat/ask", PASS, f"{dt:.2f}s, provider={data.get('provider')}")
except Exception as e:
report.add("POST /api/edge/chat/ask", FAIL, str(e))
def main():
p = argparse.ArgumentParser(description=__doc__)
p.add_argument("--base-url", default="http://127.0.0.1:5000")
p.add_argument("--token", required=True, help="ORACLE_SYNC_TOKEN")
p.add_argument("--video-id", type=int, default=None)
p.add_argument("--ts", default=None, help="例如 '2026-08-21 16:11:25'")
p.add_argument("--label", default=None, help="例如 人物A")
p.add_argument("--skip-chat", action="store_true", help="跳过问答检查(耗时最长且可能扣配额)")
args = p.parse_args()
report = Report()
check_health(report, args.base_url)
check_sync(report, args.base_url, args.token)
check_activity(report, args.base_url, args.token)
check_frame(report, args.base_url, args.token, args.video_id, args.ts)
check_avatar(report, args.base_url, args.token, args.label)
if not args.skip_chat:
check_chat(report, args.base_url)
else:
report.add("POST /api/edge/chat/ask", SKIP, "--skip-chat")
print()
n_pass = sum(1 for r in report.rows if r[1] == PASS)
n_fail = sum(1 for r in report.rows if r[1] == FAIL)
n_skip = sum(1 for r in report.rows if r[1] == SKIP)
print(f"结果: {n_pass} PASS / {n_fail} FAIL / {n_skip} SKIP")
sys.exit(0 if report.ok() else 1)
if __name__ == "__main__":
main()