Files
sentinel-home-ai/fam-edge/tests/test_gemini_adapter.py
ericwyuan 2c5bf950c5 feat: 事件时间轴缩略帧 + 人物管理头像 + 人物合并硬规则校验
## 新架构:Oracle 集中计算 + NAS 代理展示

### Oracle 端 (fam-edge)
- 新增 frame_service: ffmpeg 视频抽帧 + VLM 人物定位裁剪头像(磁盘缓存)
- 新增 /api/oracle/frame: 按 video_id+ts 抽帧返回 jpeg(带 token)
- 新增 /api/oracle/avatar: 按 label 生成人物头像(VLM 定位人物 + 兜底整帧居中)
- 新增 person_identifier: 人物身份识别模块
- Gemini 适配器支持 flash/flash-lite 双模型切换,429 自动降级
- frame_service VLM 全模型 429 时进入 10 分钟熔断,避免每次请求白打配额
- 兜底头像不落缓存,配额恢复后自动重试 VLM 精确定位

### 人物合并硬规则校验(框架级修复)
- person_service: LLM 合并结果落库前加硬冲突检测
  - 性别冲突 → 绝不合并
  - 年龄档跨未成年/成年 → 绝不合并(防止把爷爷/宝宝并进同一人)
- oracle_db: upsert_person 入口剥离括号后缀(人物A(别名:人物B) → 人物A),消灭垃圾人物行
- 修复 set_canonical 丢弃 source 参数的 bug(旧代码硬编码 'manual' 导致错误合并被永久固化)
- get_events_for_label: 只提取该身份组的特征文本,头像定位更精准

### NAS 端 (fam-core)
- 新增 img_proxy: /api/proxy/frame 和 /api/proxy/avatar 代理 Oracle 图片
- app.py 注册 img_bp 蓝图
- oracle_sync / db_layer / member_manager 同步人物表

### UI 端 (fam-ui)
- 事件时间轴: 每条事件卡片加时间点缩略帧
- 人物管理: 每人卡片加头像(150x150 圆角)
- parse_persons: 剥离括号备注,与 Oracle 归一化一致
- 新增 EventItem 组件、Timeline 页改造
- Chat / ServiceStatus 页相应调整

### 数据库
- scripts/ddl.sql: 同步表结构更新
- Oracle people 表: features_json / display_uid / source 字段完善
2026-08-23 00:13:56 +08:00

125 lines
4.7 KiB
Python
Raw 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.
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 == ""
def test_key_labels_default_to_generic_when_not_configured():
a = GeminiAdapter(_cfg(extra_api_keys=["key-2", "key-3"]))
assert a.key_labels == ["key1", "key2", "key3"]
def test_key_labels_use_configured_project_names():
a = GeminiAdapter(_cfg(
extra_api_keys=["key-2", "key-3", "key-4"],
key_labels=["智能摄像头-1", "智能摄像头-2", "智能摄像头-3", "智能摄像头-4"],
))
assert a.key_labels == ["智能摄像头-1", "智能摄像头-2", "智能摄像头-3", "智能摄像头-4"]
def test_key_labels_stay_aligned_when_a_key_is_dropped():
"""如果某个 ${ENV_VAR} 没设置被丢弃,剩下的 key_labels 要跟着剩下的 key 对齐,
不能因为下标错位把别的项目名安到错的 key 上。"""
a = GeminiAdapter(_cfg(
extra_api_keys=["${SOME_UNSET_GEMINI_KEY_VAR}", "key-2"],
key_labels=["智能摄像头-1", "智能摄像头-2", "智能摄像头-3"],
))
assert a.api_keys == ["key-primary", "key-2"]
assert a.key_labels == ["智能摄像头-1", "智能摄像头-3"]
def test_rotated_keys_cascades_from_random_start(monkeypatch):
"""起点由 random.randrange 决定;固定住随机数就能验证级联顺序是"从起点绕一圈""""
a = GeminiAdapter(_cfg(extra_api_keys=["key-2", "key-3", "key-4"]))
monkeypatch.setattr(
"fam_edge.model_adapters.gemini_adapter.random.randrange", lambda n: 2)
order = a._rotated_keys()
assert [k for _, k in order] == ["key-3", "key-4", "key-primary", "key-2"]
assert [i for i, _ in order] == [2, 3, 0, 1]
def test_rotated_keys_uses_full_key_range(monkeypatch):
"""random.randrange 的调用范围必须是 len(api_keys),否则会漏掉某些 key 永远选不到。"""
a = GeminiAdapter(_cfg(extra_api_keys=["key-2", "key-3", "key-4"]))
seen_n = []
def fake_randrange(n):
seen_n.append(n)
return 0
monkeypatch.setattr(
"fam_edge.model_adapters.gemini_adapter.random.randrange", fake_randrange)
a._rotated_keys()
assert seen_n == [4]
def test_rotated_keys_starts_roughly_uniform_over_many_calls():
"""核心诉求: 每次调用应该是真随机(每个 key 命中概率均等 1/n而不是像旧的
round-robin 那样顺序推进——用大样本统计每个 key 被选为起点的频率,应该接近
1/4且不应该出现某个 key 明显被冷落或独占(对应此前"全部流量压在同一个
key"的 bug"""
a = GeminiAdapter(_cfg(extra_api_keys=["key-2", "key-3", "key-4"]))
n_trials = 4000
counts = {"key-primary": 0, "key-2": 0, "key-3": 0, "key-4": 0}
for _ in range(n_trials):
counts[a._rotated_keys()[0][1]] += 1
for key, c in counts.items():
share = c / n_trials
assert 0.20 <= share <= 0.30, f"{key} 起点占比 {share} 明显偏离 1/4"
def test_rotated_keys_single_key_never_errors():
a = GeminiAdapter(_cfg())
for _ in range(3):
assert a._rotated_keys() == [(0, "key-primary")]
def test_chat_timeout_defaults_short_not_shared_with_video_timeout():
"""核心诉求: 问答是交互场景,不能沿用视频分析的 600s 超时——否则一个卡住
的 key/模型会让用户在聊天界面一直等,这正是"一直卡着"这个 bug 的根因。"""
a = GeminiAdapter(_cfg(timeout=600))
assert a.timeout == 600
assert a.chat_timeout == 20
assert a.chat_timeout != a.timeout
def test_chat_timeout_configurable():
a = GeminiAdapter(_cfg(chat_timeout=8))
assert a.chat_timeout == 8