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:
7
fam-edge/tests/conftest.py
Normal file
7
fam-edge/tests/conftest.py
Normal file
@@ -0,0 +1,7 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
# 让测试能直接 `from fam_edge.xxx import yyy`,无需先 pip install -e .
|
||||
_SRC = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'src')
|
||||
if _SRC not in sys.path:
|
||||
sys.path.insert(0, _SRC)
|
||||
55
fam-edge/tests/test_circuit_breaker.py
Normal file
55
fam-edge/tests/test_circuit_breaker.py
Normal file
@@ -0,0 +1,55 @@
|
||||
import time
|
||||
|
||||
from fam_edge.model_adapters.circuit_breaker import CircuitBreaker
|
||||
|
||||
|
||||
def test_closed_stays_closed_below_threshold():
|
||||
cb = CircuitBreaker(threshold=3, cooldown=1)
|
||||
cb.record_failure()
|
||||
cb.record_failure()
|
||||
assert not cb.is_open()
|
||||
assert cb.state == 'CLOSED'
|
||||
|
||||
|
||||
def test_opens_at_threshold():
|
||||
cb = CircuitBreaker(threshold=3, cooldown=1)
|
||||
for _ in range(3):
|
||||
cb.record_failure()
|
||||
assert cb.is_open()
|
||||
assert cb.state == 'OPEN'
|
||||
|
||||
|
||||
def test_half_open_after_cooldown_and_clears_stale_failures():
|
||||
"""进入 HALF_OPEN 时应清空旧失败计数,探测调用失败一次不该立刻把它"凑数"重新 OPEN。"""
|
||||
cb = CircuitBreaker(threshold=3, cooldown=0.05)
|
||||
for _ in range(3):
|
||||
cb.record_failure()
|
||||
assert cb.is_open()
|
||||
time.sleep(0.1)
|
||||
# 冷却期已过,第一次 is_open() 调用把状态转为 HALF_OPEN 并放行一次探测
|
||||
assert cb.is_open() is False
|
||||
assert cb.state == 'HALF_OPEN'
|
||||
assert len(cb.failures) == 0
|
||||
# 探测失败一次:由于 deque 已清空,不应该只凭这一次失败就再次 OPEN
|
||||
# (state 仍是 HALF_OPEN,不是 OPEN,is_open() 只在 state=='OPEN' 时为 True)
|
||||
cb.record_failure()
|
||||
assert cb.state != 'OPEN'
|
||||
assert not cb.is_open()
|
||||
|
||||
|
||||
def test_half_open_probe_success_closes():
|
||||
cb = CircuitBreaker(threshold=2, cooldown=0.05)
|
||||
cb.record_failure()
|
||||
cb.record_failure()
|
||||
time.sleep(0.1)
|
||||
assert cb.is_open() is False # 触发 HALF_OPEN 转换
|
||||
cb.record_success()
|
||||
assert cb.state == 'CLOSED'
|
||||
assert len(cb.failures) == 0
|
||||
|
||||
|
||||
def test_disabled_never_opens():
|
||||
cb = CircuitBreaker(threshold=1, cooldown=1, enabled=False)
|
||||
cb.record_failure()
|
||||
cb.record_failure()
|
||||
assert not cb.is_open()
|
||||
22
fam-edge/tests/test_frame_service.py
Normal file
22
fam-edge/tests/test_frame_service.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from fam_edge.frame_service import _bbox_to_pixels
|
||||
|
||||
|
||||
def test_bbox_to_pixels_basic():
|
||||
# [ymin,xmin,ymax,xmax] 0-1000 归一化 -> 像素 (x1,y1,x2,y2)
|
||||
# 实测样本:Gemini 对 440x248 帧返回 [0, 690, 203, 725],
|
||||
# 对应画面右上角门厅处的一个人(今天用真实截图验证过)。
|
||||
x1, y1, x2, y2 = _bbox_to_pixels([0, 690, 203, 725], 440, 248)
|
||||
assert x1 == int(690 / 1000 * 440)
|
||||
assert y1 == 0
|
||||
assert x2 == int(725 / 1000 * 440)
|
||||
assert y2 == int(203 / 1000 * 248)
|
||||
|
||||
|
||||
def test_bbox_to_pixels_full_frame():
|
||||
x1, y1, x2, y2 = _bbox_to_pixels([0, 0, 1000, 1000], 400, 300)
|
||||
assert (x1, y1, x2, y2) == (0, 0, 400, 300)
|
||||
|
||||
|
||||
def test_bbox_to_pixels_zero_area():
|
||||
x1, y1, x2, y2 = _bbox_to_pixels([500, 500, 500, 500], 400, 300)
|
||||
assert (x1, y1) == (x2, y2)
|
||||
40
fam-edge/tests/test_gemini_adapter.py
Normal file
40
fam-edge/tests/test_gemini_adapter.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from fam_edge.model_adapters.gemini_adapter import GeminiAdapter
|
||||
|
||||
|
||||
def _cfg(**overrides):
|
||||
base = {
|
||||
"provider": "gemini",
|
||||
"model_name": "gemini-flash-latest",
|
||||
"api_key": "key-primary",
|
||||
"circuit_breaker": {"enabled": False},
|
||||
}
|
||||
base.update(overrides)
|
||||
return base
|
||||
|
||||
|
||||
def test_single_key_backward_compat():
|
||||
a = GeminiAdapter(_cfg())
|
||||
assert a.api_keys == ["key-primary"]
|
||||
assert a.api_key == "key-primary"
|
||||
|
||||
|
||||
def test_extra_keys_appended_in_order():
|
||||
a = GeminiAdapter(_cfg(extra_api_keys=["key-2", "key-3", "key-4"]))
|
||||
assert a.api_keys == ["key-primary", "key-2", "key-3", "key-4"]
|
||||
|
||||
|
||||
def test_extra_keys_dedup_against_primary():
|
||||
a = GeminiAdapter(_cfg(extra_api_keys=["key-primary", "key-2"]))
|
||||
assert a.api_keys == ["key-primary", "key-2"]
|
||||
|
||||
|
||||
def test_missing_env_var_keys_are_dropped():
|
||||
"""extra_api_keys 里未设置的 ${ENV_VAR} 解析为空字符串,不应该混进 api_keys 列表。"""
|
||||
a = GeminiAdapter(_cfg(extra_api_keys=["${SOME_UNSET_GEMINI_KEY_VAR}", "key-2"]))
|
||||
assert a.api_keys == ["key-primary", "key-2"]
|
||||
|
||||
|
||||
def test_no_keys_at_all():
|
||||
a = GeminiAdapter(_cfg(api_key=""))
|
||||
assert a.api_keys == []
|
||||
assert a.api_key == ""
|
||||
87
fam-edge/tests/test_json_parser.py
Normal file
87
fam-edge/tests/test_json_parser.py
Normal 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
|
||||
58
fam-edge/tests/test_video_processor.py
Normal file
58
fam-edge/tests/test_video_processor.py
Normal file
@@ -0,0 +1,58 @@
|
||||
from datetime import datetime
|
||||
|
||||
from fam_edge.video_processor import (
|
||||
_parse_event_start_from_filename,
|
||||
_parse_event_ts,
|
||||
_clean_person,
|
||||
)
|
||||
|
||||
|
||||
def test_parse_filename_pure_digit_format():
|
||||
assert _parse_event_start_from_filename(
|
||||
"Generic_ONVIF-001-20260820-140416-1787205856321-7.mp4"
|
||||
) == "2026-08-20 14:04:16"
|
||||
|
||||
|
||||
def test_parse_filename_underscore_date_format():
|
||||
assert _parse_event_start_from_filename("20260821_081500.mp4") == "2026-08-21 08:15:00"
|
||||
|
||||
|
||||
def test_parse_filename_dashed_date_format():
|
||||
assert _parse_event_start_from_filename("2026-08-21_081500.mp4") == "2026-08-21 08:15:00"
|
||||
|
||||
|
||||
def test_parse_filename_no_match_returns_empty():
|
||||
assert _parse_event_start_from_filename("客厅.mp4") == ""
|
||||
|
||||
|
||||
def test_parse_event_ts_relative_offset():
|
||||
start = datetime(2026, 8, 21, 15, 53, 3)
|
||||
abs_ts, offset = _parse_event_ts("00:18:22", start)
|
||||
assert abs_ts == "2026-08-21 16:11:25"
|
||||
assert offset == 18 * 60 + 22
|
||||
|
||||
|
||||
def test_parse_event_ts_relative_offset_no_start():
|
||||
abs_ts, offset = _parse_event_ts("00:01:23", None)
|
||||
assert abs_ts == "00:01:23"
|
||||
assert offset == 83.0
|
||||
|
||||
|
||||
def test_parse_event_ts_over_6_hours_falls_back_to_absolute():
|
||||
"""相对时间 > 6 小时视为模型误输出绝对时间,不强行按偏移定位。"""
|
||||
start = datetime(2026, 8, 21, 8, 0, 0)
|
||||
abs_ts, offset = _parse_event_ts("2026-08-21 09:00:00", start)
|
||||
assert abs_ts == "2026-08-21 09:00:00"
|
||||
assert offset == 3600.0
|
||||
|
||||
|
||||
def test_clean_person_strips_fullwidth_parens():
|
||||
assert _clean_person("人物A(别名/标识:人物B)") == "人物A"
|
||||
|
||||
|
||||
def test_clean_person_strips_ascii_parens():
|
||||
assert _clean_person("人物A(alias: 人物B)") == "人物A"
|
||||
|
||||
|
||||
def test_clean_person_no_parens_unchanged():
|
||||
assert _clean_person("汤圆") == "汤圆"
|
||||
Reference in New Issue
Block a user