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:
181
fam-edge/scripts/smoke_test.py
Normal file
181
fam-edge/scripts/smoke_test.py
Normal 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()
|
||||
Reference in New Issue
Block a user