根因: 大模型给的 人物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 个人物分组 (我/媳妇/爷爷/汤圆)特征都是内部一致的,不再互相串。
73 lines
3.6 KiB
Python
73 lines
3.6 KiB
Python
import json
|
||
|
||
from fam_edge.oracle_db import OracleDB
|
||
|
||
|
||
def _db(tmp_path):
|
||
return OracleDB(str(tmp_path / "oracle.db"))
|
||
|
||
|
||
def test_upsert_person_same_gender_merges_into_one_row(tmp_path):
|
||
db = _db(tmp_path)
|
||
db.upsert_person("人物A", features={"gender": "男", "hair": "短发黑色"})
|
||
db.upsert_person("人物A", features={"gender": "男", "clothing": "蓝色T恤"})
|
||
rows = db._conn.execute("SELECT * FROM people").fetchall()
|
||
assert len(rows) == 1
|
||
assert rows[0]["appearances"] == 2
|
||
feats = json.loads(rows[0]["features_json"])
|
||
assert feats["hair"] == "短发黑色"
|
||
assert feats["clothing"] == "蓝色T恤"
|
||
|
||
|
||
def test_upsert_person_gender_conflict_splits_into_new_label(tmp_path):
|
||
"""核心诉求: 大模型给的 人物A/B/C 这类 uid 只在单次视频分析内稳定,不同视频
|
||
独立编号,同一字符串完全可能撞到不同真人(实测 "人物A" 混了男女两个人)。
|
||
性别冲突时不能直接合并覆盖,要拆成新 label,避免两个人的特征越merge越乱。"""
|
||
db = _db(tmp_path)
|
||
db.upsert_person("人物A", features={"gender": "男", "clothing": "蓝色Polo衫"})
|
||
db.upsert_person("人物A", features={"gender": "女", "clothing": "白色上衣"})
|
||
rows = {r["label"]: r for r in db._conn.execute("SELECT * FROM people").fetchall()}
|
||
assert set(rows.keys()) == {"人物A", "人物A#2"}
|
||
assert rows["人物A"]["appearances"] == 1
|
||
assert json.loads(rows["人物A"]["features_json"])["gender"] == "男"
|
||
assert rows["人物A#2"]["appearances"] == 1
|
||
assert json.loads(rows["人物A#2"]["features_json"])["gender"] == "女"
|
||
# 派生行的 display_uid 仍然记录原始大模型 uid,方便追溯来源
|
||
assert rows["人物A#2"]["display_uid"] == "人物A"
|
||
|
||
|
||
def test_upsert_person_gender_conflict_allocates_next_free_suffix(tmp_path):
|
||
db = _db(tmp_path)
|
||
db.upsert_person("人物A", features={"gender": "男"})
|
||
db.upsert_person("人物A", features={"gender": "女"}) # -> 人物A#2
|
||
db.upsert_person("人物A", features={"gender": "unknown"}) # unknown 不冲突,合并回 人物A
|
||
db.upsert_person("人物A", features={"gender": "男", "hair": "光头"}) # 冲突,人物A 有 gender 男了不冲突;应仍合并
|
||
labels = {r["label"] for r in db._conn.execute("SELECT label FROM people").fetchall()}
|
||
assert labels == {"人物A", "人物A#2"}
|
||
# 再来一次性别冲突(对着 人物A#2,性别女)应该分配 人物A#3,而不是复用 人物A#2
|
||
db.upsert_person("人物A", features={"gender": "男"})
|
||
db.upsert_person("人物A", features={"gender": "女"})
|
||
# 这次新的女性冲突会先撞到 人物A(此时是男),分裂出下一个空闲后缀
|
||
labels = {r["label"] for r in db._conn.execute("SELECT label FROM people").fetchall()}
|
||
assert "人物A" in labels
|
||
assert len(labels) >= 2
|
||
|
||
|
||
def test_upsert_person_unknown_gender_never_triggers_split(tmp_path):
|
||
db = _db(tmp_path)
|
||
db.upsert_person("人物A", features={"gender": "男"})
|
||
db.upsert_person("人物A", features={"gender": "unknown"})
|
||
db.upsert_person("人物A", features={"gender": "未知"})
|
||
rows = db._conn.execute("SELECT * FROM people").fetchall()
|
||
assert len(rows) == 1
|
||
assert rows[0]["appearances"] == 3
|
||
|
||
|
||
def test_upsert_person_no_features_never_triggers_split(tmp_path):
|
||
db = _db(tmp_path)
|
||
db.upsert_person("人物A", features={"gender": "男"})
|
||
db.upsert_person("人物A") # 无 features(source 更新等场景)
|
||
rows = db._conn.execute("SELECT * FROM people").fetchall()
|
||
assert len(rows) == 1
|
||
assert rows[0]["appearances"] == 2
|