Files
sentinel-home-ai/fam-edge/tests/test_json_parser.py
ericwyuan 61db82cb9b 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>
2026-08-22 00:22:57 +08:00

88 lines
2.9 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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