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 字段完善
This commit is contained in:
ericwyuan
2026-08-23 00:13:56 +08:00
parent aca1a674b1
commit 2c5bf950c5
31 changed files with 2166 additions and 25 deletions

View File

@@ -0,0 +1,347 @@
import os
import pytest
from fam_edge.person_identifier import PersonIdentifier
def _cfg(ref_dir, **overrides):
base = {
"enabled": True,
"ref_dir": ref_dir,
"max_ref_per_person": 6,
"min_call_interval_sec": 0, # 测试不需要真实限速,避免拖慢用例
"nvidia": {"api_key": "nvkey", "model_name": "nvidia/test", "timeout": 30,
"max_retries": 2, "retry_backoff_sec": 0.01},
"gemini": {"api_key": "gkey1", "model_name": "gemini-flash-lite-latest", "timeout": 30,
"max_retries": 2, "retry_backoff_sec": 0.01},
}
base.update(overrides)
return base
def _write_refs(tmp_path, grandpa=2, dad=2):
for person, n in (("爷爷", grandpa), ("爸爸", dad)):
d = tmp_path / person
d.mkdir(parents=True, exist_ok=True)
for i in range(n):
(d / f"{i:02d}.jpg").write_bytes(b"fakejpegbytes")
class _FakeResp:
def __init__(self, status_code=200, payload=None):
self.status_code = status_code
self._payload = payload or {}
def json(self):
return self._payload
@pytest.fixture(autouse=True)
def _no_real_sleep(monkeypatch):
"""全部用例都不需要真的睡(限速/退避都测计数和结果,不测真实耗时)。"""
monkeypatch.setattr("fam_edge.person_identifier.time.sleep", lambda s: None)
def test_has_references_false_when_dirs_missing(tmp_path):
pi = PersonIdentifier(_cfg(str(tmp_path / "refs")))
assert pi.has_references() is False
def test_has_references_true_when_both_present(tmp_path):
_write_refs(tmp_path, grandpa=3, dad=2)
pi = PersonIdentifier(_cfg(str(tmp_path)))
assert pi.has_references() is True
def test_has_references_false_when_only_one_person_has_refs(tmp_path):
(tmp_path / "爷爷").mkdir(parents=True)
(tmp_path / "爷爷" / "01.jpg").write_bytes(b"x")
pi = PersonIdentifier(_cfg(str(tmp_path)))
assert pi.has_references() is False
def test_extract_json_person_clean_json():
pi = PersonIdentifier(_cfg("/nonexistent"))
assert pi._extract_json_person('{"person":"爷爷"}') == '爷爷'
assert pi._extract_json_person('{"person":"爸爸"}') == '爸爸'
def test_extract_json_person_no_match_returns_none():
pi = PersonIdentifier(_cfg("/nonexistent"))
assert pi._extract_json_person('不知道是谁') is None
assert pi._extract_json_person('') is None
def test_extract_json_person_both_mentioned_uses_json_field():
"""核心诉求: 模型有时会把参考图说明也复述一遍,回复里两个名字都出现——
这时候不能瞎猜,要从 JSON 的 person 字段里精确取,取不到就返回 None。"""
pi = PersonIdentifier(_cfg("/nonexistent"))
text = '参考图1是爷爷参考图5是爸爸。{"person":"爸爸"}'
assert pi._extract_json_person(text) == '爸爸'
def test_extract_json_person_both_mentioned_no_json_field_returns_none():
pi = PersonIdentifier(_cfg("/nonexistent"))
text = '这个人可能是爷爷,也可能是爸爸,不太确定'
assert pi._extract_json_person(text) is None
def test_classify_returns_none_when_disabled(tmp_path):
_write_refs(tmp_path)
pi = PersonIdentifier(_cfg(str(tmp_path), enabled=False))
assert pi.classify_adult_male(b"crop") is None
def test_classify_returns_none_when_no_references(tmp_path):
pi = PersonIdentifier(_cfg(str(tmp_path / "empty")))
assert pi.classify_adult_male(b"crop") is None
def test_classify_falls_back_to_gemini_when_nvidia_unavailable(tmp_path, monkeypatch):
"""openai SDK 未安装时 NVIDIA 路径应该静默跳过(不报错),落到 Gemini。"""
_write_refs(tmp_path)
monkeypatch.setattr("fam_edge.person_identifier.OpenAI", None)
def fake_post(url, json=None, timeout=None):
return _FakeResp(200, {
"candidates": [{"content": {"parts": [{"text": '{"person":"爸爸"}'}]}}]
})
monkeypatch.setattr("fam_edge.person_identifier.requests.post", fake_post)
pi = PersonIdentifier(_cfg(str(tmp_path)))
assert pi.classify_adult_male(b"crop") == '爸爸'
def _fake_openai_factory(reply_text=None, exc=None, fail_times=0):
"""构造一个假 OpenAI 客户端:先失败 fail_times 次再成功,或者一直抛 exc。"""
state = {"calls": 0}
class FakeMessage:
content = reply_text
class FakeChoice:
message = FakeMessage()
class FakeChatResp:
choices = [FakeChoice()]
class FakeCompletions:
def create(self, **kwargs):
state["calls"] += 1
if state["calls"] <= fail_times:
raise (exc or RuntimeError("boom"))
if exc and fail_times == 0:
raise exc
return FakeChatResp()
class FakeChat:
completions = FakeCompletions()
class FakeOpenAI:
def __init__(self, base_url=None, api_key=None):
pass
chat = FakeChat()
return FakeOpenAI, state
def test_classify_nvidia_success_skips_gemini(tmp_path, monkeypatch):
_write_refs(tmp_path)
FakeOpenAI, state = _fake_openai_factory(reply_text='{"person":"爷爷"}')
monkeypatch.setattr("fam_edge.person_identifier.OpenAI", FakeOpenAI)
gemini_called = {"n": 0}
def fake_post(url, json=None, timeout=None):
gemini_called["n"] += 1
return _FakeResp(200, {})
monkeypatch.setattr("fam_edge.person_identifier.requests.post", fake_post)
pi = PersonIdentifier(_cfg(str(tmp_path)))
assert pi.classify_adult_male(b"crop") == '爷爷'
assert gemini_called["n"] == 0
assert state["calls"] == 1
class _FakeHTTPError(Exception):
def __init__(self, status_code):
self.response = type("R", (), {"status_code": status_code})()
def test_nvidia_retries_transient_error_then_succeeds(tmp_path, monkeypatch):
"""核心诉求: 429/503 这类瞬时故障要退避重试,不是第一次失败就放弃换 provider。"""
_write_refs(tmp_path)
FakeOpenAI, state = _fake_openai_factory(
reply_text='{"person":"爸爸"}', exc=_FakeHTTPError(503), fail_times=1)
monkeypatch.setattr("fam_edge.person_identifier.OpenAI", FakeOpenAI)
pi = PersonIdentifier(_cfg(str(tmp_path)))
assert pi.classify_adult_male(b"crop") == '爸爸'
assert state["calls"] == 2 # 第一次 503 失败重试一次后成功
def test_nvidia_gives_up_after_max_retries_falls_back_to_gemini(tmp_path, monkeypatch):
_write_refs(tmp_path)
FakeOpenAI, state = _fake_openai_factory(exc=_FakeHTTPError(503), fail_times=99)
monkeypatch.setattr("fam_edge.person_identifier.OpenAI", FakeOpenAI)
def fake_post(url, json=None, timeout=None):
return _FakeResp(200, {
"candidates": [{"content": {"parts": [{"text": '{"person":"汤圆"}'}]}}]
})
# 用一个不属于爷爷/爸爸的返回值只是为了确认真的调用到了 gemini 分支
monkeypatch.setattr("fam_edge.person_identifier.requests.post", fake_post)
cfg = _cfg(str(tmp_path))
pi = PersonIdentifier(cfg)
pi.classify_adult_male(b"crop")
assert state["calls"] == pi.nvidia_max_retries # 重试到上限就放弃,不会无限重试
def test_nvidia_non_retryable_error_gives_up_immediately(tmp_path, monkeypatch):
"""核心诉求: 400 参数错误这类非瞬时故障,重试没有意义,应该立刻换下一个模型/provider
不要浪费时间重试一个注定失败的请求。"""
_write_refs(tmp_path)
FakeOpenAI, state = _fake_openai_factory(exc=_FakeHTTPError(400), fail_times=99)
monkeypatch.setattr("fam_edge.person_identifier.OpenAI", FakeOpenAI)
monkeypatch.setattr("fam_edge.person_identifier.requests.post",
lambda *a, **k: _FakeResp(500, {"error": "down"}))
pi = PersonIdentifier(_cfg(str(tmp_path)))
pi.classify_adult_male(b"crop")
assert state["calls"] == 1 # 400 不重试,一次就放弃这个模型
def test_nvidia_falls_through_model_chain(tmp_path, monkeypatch):
"""核心诉求: 第一个模型重试耗尽后,应该换模型链里的下一个型号再试,而不是
直接放弃整个 NVIDIA provider。"""
_write_refs(tmp_path)
calls = []
class FakeMessage:
def __init__(self, content):
self.content = content
class FakeChoice:
def __init__(self, content):
self.message = FakeMessage(content)
class FakeChatResp:
def __init__(self, content):
self.choices = [FakeChoice(content)]
class FakeCompletions:
def create(self, model, **kwargs):
calls.append(model)
if model == 'nvidia/model-a':
raise _FakeHTTPError(503)
return FakeChatResp('{"person":"爷爷"}')
class FakeChat:
completions = FakeCompletions()
class FakeOpenAI:
def __init__(self, base_url=None, api_key=None):
pass
chat = FakeChat()
monkeypatch.setattr("fam_edge.person_identifier.OpenAI", FakeOpenAI)
cfg = _cfg(str(tmp_path), nvidia={
"api_key": "nvkey", "model_name": "nvidia/model-a",
"fallback_models": ["nvidia/model-b"], "timeout": 30,
"max_retries": 2, "retry_backoff_sec": 0.01,
})
pi = PersonIdentifier(cfg)
assert pi.classify_adult_male(b"crop") == '爷爷'
assert calls == ['nvidia/model-a', 'nvidia/model-a', 'nvidia/model-b']
def test_gemini_retries_transient_error_on_same_key(tmp_path, monkeypatch):
_write_refs(tmp_path)
monkeypatch.setattr("fam_edge.person_identifier.OpenAI", None)
calls = []
def fake_post(url, json=None, timeout=None):
calls.append(url.split('key=')[-1])
if len(calls) == 1:
return _FakeResp(503, {"error": {"code": 503}})
return _FakeResp(200, {
"candidates": [{"content": {"parts": [{"text": '{"person":"媳妇"}'}]}}]
})
monkeypatch.setattr("fam_edge.person_identifier.requests.post", fake_post)
pi = PersonIdentifier(_cfg(str(tmp_path)))
pi.classify_adult_male(b"crop")
assert calls == ['gkey1', 'gkey1'] # 同一个 key 重试,不是立刻跳到下一个 key
def test_classify_gemini_rotates_across_keys_after_retries_exhausted(tmp_path, monkeypatch):
_write_refs(tmp_path)
monkeypatch.setattr("fam_edge.person_identifier.OpenAI", None)
calls = []
def fake_post(url, json=None, timeout=None):
key = url.split('key=')[-1]
calls.append(key)
if key == 'gkey1':
return _FakeResp(429, {"error": {"code": 429}})
return _FakeResp(200, {
"candidates": [{"content": {"parts": [{"text": '{"person":"爷爷"}'}]}}]
})
monkeypatch.setattr("fam_edge.person_identifier.requests.post", fake_post)
cfg = _cfg(str(tmp_path), gemini={
"api_key": "gkey1", "extra_api_keys": ["gkey2"],
"model_name": "gemini-flash-lite-latest", "timeout": 30,
"max_retries": 2, "retry_backoff_sec": 0.01,
})
pi = PersonIdentifier(cfg)
assert pi.classify_adult_male(b"crop") == '爷爷'
assert calls == ['gkey1', 'gkey1', 'gkey2'] # gkey1 重试用尽才换 gkey2
def test_classify_both_providers_fail_returns_none(tmp_path, monkeypatch):
"""核心诉求: NVIDIA 和 Gemini 都失败时绝不能瞎猜,必须返回 None。"""
_write_refs(tmp_path)
monkeypatch.setattr("fam_edge.person_identifier.OpenAI", None)
def fake_post(url, json=None, timeout=None):
return _FakeResp(500, {"error": "boom"})
monkeypatch.setattr("fam_edge.person_identifier.requests.post", fake_post)
pi = PersonIdentifier(_cfg(str(tmp_path)))
assert pi.classify_adult_male(b"crop") is None
def test_env_var_credentials_resolved(tmp_path):
os.environ["TEST_NVIDIA_KEY_XYZ"] = "realkey"
try:
cfg = _cfg(str(tmp_path), nvidia={"api_key": "${TEST_NVIDIA_KEY_XYZ}"})
pi = PersonIdentifier(cfg)
assert pi.nvidia_api_key == "realkey"
finally:
del os.environ["TEST_NVIDIA_KEY_XYZ"]
def test_max_ref_per_person_limits_loaded_refs(tmp_path):
_write_refs(tmp_path, grandpa=10, dad=10)
pi = PersonIdentifier(_cfg(str(tmp_path), max_ref_per_person=3))
refs = pi._load_refs()
assert len(refs['爷爷']) == 3
assert len(refs['爸爸']) == 3
def test_pace_sleeps_when_called_too_soon(tmp_path, monkeypatch):
"""核心诉求: 批量回填会短时间内密集调用min_call_interval_sec 要真的限速,
不能形同虚设。"""
_write_refs(tmp_path)
slept = []
monkeypatch.setattr("fam_edge.person_identifier.time.sleep", lambda s: slept.append(s))
pi = PersonIdentifier(_cfg(str(tmp_path), min_call_interval_sec=5))
pi._last_call_at = __import__("time").time() # 刚刚调用过
pi._pace()
assert slept and slept[0] > 0
def test_pace_no_sleep_when_interval_already_elapsed(tmp_path, monkeypatch):
_write_refs(tmp_path)
slept = []
monkeypatch.setattr("fam_edge.person_identifier.time.sleep", lambda s: slept.append(s))
pi = PersonIdentifier(_cfg(str(tmp_path), min_call_interval_sec=5))
pi._last_call_at = 0 # 很久以前
pi._pace()
assert slept == []