Files
sentinel-home-ai/fam-edge/tests/test_person_service.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

71 lines
3.1 KiB
Python
Raw Permalink 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
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 == {}