Files
sentinel-home-ai/fam-edge/tests/test_oracle_db.py
ericwyuan f798c31cab 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 个人物分组
(我/媳妇/爷爷/汤圆)特征都是内部一致的,不再互相串。
2026-08-22 08:45:24 +08:00

73 lines
3.6 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.
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") # 无 featuressource 更新等场景)
rows = db._conn.execute("SELECT * FROM people").fetchall()
assert len(rows) == 1
assert rows[0]["appearances"] == 2