fix(fam-edge): 人物 uid 跨视频复用导致特征串人 - 拆分同 uid 下的性别冲突

根因: 大模型给的 人物A/B/C 这类临时 uid 只在单次视频分析内部稳定,不同视频各
自独立编号——同一个字符串在不同视频里完全可能指向不同的真人(实测生产数据里
"人物A" 在 29 个视频里混了男女两个人,"人物B" 混了男/女/儿童三个人)。但
people 表 label 全局 UNIQUE,upsert_person / person_service 的特征聚合都直接
按这个字符串当全局稳定身份用,导致不同真人的特征被硬合并进同一行,"我"这张卡
显示出来的描述其实是我和媳妇两个人的特征混在一起。

两处落地点都加了同一条硬规则(性别是相对稳定信号,冲突大概率是撞了另一个人):
1. oracle_db.upsert_person(): 单视频入库时,新特征性别与已有行冲突就不覆盖合
   并,改分配 uid#2/uid#3 这样的派生 label 单独建行。
2. person_service._aggregate_features(): 每次全量重新聚合时按性别在线聚类,
   同一 uid 下冲突的性别拆成独立分组,不再无脑覆盖成一坨。

拆出来的派生 label 走已有的"未命名 -> LLM 合并 -> 硬规则否决"流程,由现有机制
判断该并入哪个已命名身份。

新增 test_oracle_db.py(5 例)+ test_person_service.py(5 例)覆盖同性别合并 /
性别冲突拆分 / 后缀分配 / unknown 不触发拆分等场景。

生产数据已用新逻辑重新 reconcile 并手动核对 3 个因数据量太大 LLM 没能正确认领
的派生 label(人物A#2/人物B#2#2 -> 媳妇,爷爷#2 -> 爷爷),现在 4 个人物分组
(我/媳妇/爷爷/汤圆)特征都是内部一致的,不再互相串。
This commit is contained in:
ericwyuan
2026-08-22 08:45:24 +08:00
parent 708b6365f4
commit f798c31cab
4 changed files with 205 additions and 14 deletions

View File

@@ -0,0 +1,70 @@
import json
from fam_edge.oracle_db import OracleDB
from fam_edge.person_service import PersonService
def _service(tmp_path):
db = OracleDB(str(tmp_path / "oracle.db"))
svc = PersonService.__new__(PersonService) # 跳过 __init__不需要真的建 LLM 适配器)
svc.db = db
return svc, db
def _insert_event(db, video_id, appearances):
db._conn.execute(
"INSERT INTO events (video_id, ts, description, person_appearances_json) "
"VALUES (?, ?, ?, ?)",
(video_id, "2026-08-21 00:00:00", "desc", json.dumps(appearances, ensure_ascii=False)))
db._conn.commit()
def test_aggregate_features_merges_same_gender_across_videos(tmp_path):
svc, db = _service(tmp_path)
_insert_event(db, 1, [{"uid": "人物A", "features": {"gender": "", "hair": "短发黑色"}}])
_insert_event(db, 2, [{"uid": "人物A", "features": {"gender": "", "clothing": "蓝色T恤"}}])
result = svc._aggregate_features()
assert set(result.keys()) == {"人物A"}
assert result["人物A"]["hair"] == "短发黑色"
assert result["人物A"]["clothing"] == "蓝色T恤"
def test_aggregate_features_splits_gender_conflict_across_videos(tmp_path):
"""核心场景: 复现 "人物A" 在 29 个视频里混了男女两个人的真实 bug——不同视频各自
独立编号的 uid字符串相同不代表同一个真人性别冲突时必须拆成独立分组。"""
svc, db = _service(tmp_path)
_insert_event(db, 1, [{"uid": "人物A", "features": {"gender": "", "clothing": "蓝色Polo衫"}}])
_insert_event(db, 2, [{"uid": "人物A", "features": {"gender": "", "clothing": "白色上衣"}}])
result = svc._aggregate_features()
assert set(result.keys()) == {"人物A", "人物A#2"}
assert result["人物A"]["gender"] == ""
assert result["人物A#2"]["gender"] == ""
def test_aggregate_features_unknown_gender_joins_first_group(tmp_path):
svc, db = _service(tmp_path)
_insert_event(db, 1, [{"uid": "人物A", "features": {"gender": ""}}])
_insert_event(db, 2, [{"uid": "人物A", "features": {"gender": "unknown", "hair": "光头"}}])
result = svc._aggregate_features()
assert set(result.keys()) == {"人物A"}
assert result["人物A"]["gender"] == ""
assert result["人物A"]["hair"] == "光头"
def test_aggregate_features_three_way_gender_reuse_creates_three_groups(tmp_path):
svc, db = _service(tmp_path)
_insert_event(db, 1, [{"uid": "人物B", "features": {"gender": ""}}])
_insert_event(db, 2, [{"uid": "人物B", "features": {"gender": ""}}])
_insert_event(db, 3, [{"uid": "人物B", "features": {"gender": ""}}]) # 应并回第一组
result = svc._aggregate_features()
assert set(result.keys()) == {"人物B", "人物B#2"}
assert result["人物B"]["gender"] == ""
assert result["人物B#2"]["gender"] == ""
def test_aggregate_features_ignores_placeholder_uids(tmp_path):
svc, db = _service(tmp_path)
_insert_event(db, 1, [{"uid": "无人", "features": {"gender": ""}}])
_insert_event(db, 2, [{"uid": "", "features": {"gender": ""}}])
result = svc._aggregate_features()
assert result == {}