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,87 @@
import pytest
from fam_edge.ai_orchestrator.json_parser import parse_vlm_json, validate_schema, VLMOutputInvalidError
def _base():
return {
"global_summary": "客厅监控摘要",
"events": [
{"timestamp": "00:00:03", "description": "人物A走进客厅",
"people": ["人物A"], "is_attention_event": False,
"person_appearances": [
{"uid": "人物A", "features": {"gender": ""}, "action": "走动",
"bbox": [10, 20, 500, 400]}
]}
],
"people_mentioned": ["人物A"],
}
def test_direct_json_parses():
import json
raw = json.dumps(_base(), ensure_ascii=False)
result = parse_vlm_json(raw)
assert result["global_summary"] == "客厅监控摘要"
assert len(result["events"]) == 1
assert result["events"][0]["person_appearances"][0]["bbox"] == [10.0, 20.0, 500.0, 400.0]
def test_markdown_fence_extraction():
import json
raw = f"这是模型的解释文字\n```json\n{json.dumps(_base(), ensure_ascii=False)}\n```\n谢谢"
result = parse_vlm_json(raw)
assert result["events"][0]["timestamp"] == "00:00:03"
def test_greedy_brace_extraction():
import json
raw = f"废话前缀 {json.dumps(_base(), ensure_ascii=False)} 废话后缀"
result = parse_vlm_json(raw)
assert result["people_mentioned"] == ["人物A"]
def test_invalid_json_raises():
with pytest.raises(VLMOutputInvalidError):
parse_vlm_json("这不是 JSON也没有大括号")
def test_missing_required_field_raises():
with pytest.raises(VLMOutputInvalidError):
validate_schema({"events": []})
def test_frame_details_legacy_compat():
data = {
"global_summary": "旧结构",
"frame_details": [
{"frame_timestamp": "00:00:05", "action": "走动", "person": "人物A",
"is_attention_event": True}
],
}
result = validate_schema(data)
assert len(result["events"]) == 1
assert result["events"][0]["description"] == "走动"
assert result["events"][0]["people"] == ["人物A"]
assert result["events"][0]["is_attention_event"] is True
def test_bbox_missing_or_null_becomes_none():
data = _base()
data["events"][0]["person_appearances"][0]["bbox"] = None
result = validate_schema(data)
assert result["events"][0]["person_appearances"][0]["bbox"] is None
def test_bbox_wrong_shape_becomes_none():
data = _base()
data["events"][0]["person_appearances"][0]["bbox"] = [1, 2, 3] # 长度不对
result = validate_schema(data)
assert result["events"][0]["person_appearances"][0]["bbox"] is None
def test_bbox_non_numeric_becomes_none():
data = _base()
data["events"][0]["person_appearances"][0]["bbox"] = ["a", "b", "c", "d"]
result = validate_schema(data)
assert result["events"][0]["person_appearances"][0]["bbox"] is None