## 新架构: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 字段完善
125 lines
4.7 KiB
Python
125 lines
4.7 KiB
Python
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
|