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

@@ -475,7 +475,35 @@ class OracleDB:
# 带备注的原始输出分裂出垃圾人物行
label = re.sub(r'[(][^()]*[)]', '', str(label)).strip() or str(label)
now = _now_iso()
display_uid = display_uid or label
row = self._conn.execute("SELECT * FROM people WHERE label=?", (label,)).fetchone()
# 跨视频 uid 复用检测:大模型给的 人物A/B/C 这类 uid 只在单次视频分析内部
# 稳定,不同视频各自独立编号——同一个字符串在不同视频里完全可能指向不同的
# 真人(实测 "人物A" 在 29 个视频里混了至少男女两个人)。这里用性别这个相对
# 稳定的信号做冲突检测:这次新特征和已有行的性别都明确且互相矛盾,说明大概
# 率撞车到了另一个人,不能直接合并覆盖——改挂到一个新的派生 label
# "人物A#2")上,留给 person_service 的特征合并再判断这个新 label 该并进
# 哪个已命名身份避免把两个人的特征越merge越乱。
if row and features:
try:
old_gender = (json.loads(row['features_json'] or '{}') or {}).get('gender', '')
except (ValueError, TypeError):
old_gender = ''
old_gender = str(old_gender or '').strip()
new_gender = str(features.get('gender', '') or '').strip()
if (old_gender and old_gender.lower() not in ('unknown', '未知')
and new_gender and new_gender.lower() not in ('unknown', '未知')
and old_gender != new_gender):
base_label, n = label, 2
while self._conn.execute(
"SELECT 1 FROM people WHERE label=?", (f"{base_label}#{n}",)).fetchone():
n += 1
label = f"{base_label}#{n}"
row = None
if logger:
logger.warning(
f"人物 uid 跨视频复用检测到性别冲突: {base_label} "
f"旧={old_gender} 新={new_gender},拆分为新 label={label}")
# 特征合并(在已有 features_json 基础上)
merged_features = self._merge_features(
row['features_json'] if row else None, features) if row else (

View File

@@ -145,10 +145,17 @@ class PersonService:
def _aggregate_features(self) -> Dict[str, Dict]:
"""从 events.person_appearances_json 聚合每个 uid 的合并特征。
遍历所有事件的 person_appearances按 uid 收集 features dict
合并规则:首次非 unknown 值优先(与 oracle_db._merge_features 一致)。
遍历所有事件的 person_appearances按 uid 收集 features dict合并规则:
非 unknown 值覆盖旧值(与 oracle_db._merge_features 一致)。
大模型给的 人物A/B/C 这类 uid 只在单次视频分析内部稳定,不同视频各自独立
编号——同一个字符串在不同视频里完全可能指向不同的真人(实测 "人物A"
29 个视频里混了男女两个人)。这里按性别做在线聚类:同一 uid 下遇到与已有
分组都冲突都明确且不同的性别时开一个新分组uid#2/uid#3...),不再
把两个人的特征糊成一坨;分组的 key 交给 upsert_person 当新 label 落库,
走 unnamed 流程由 LLM 合并判断该并入哪个已命名身份。
"""
uid_features: Dict[str, Dict] = {}
uid_groups: Dict[str, List[Dict]] = {}
rows = self.db._conn.execute(
"SELECT person_appearances_json FROM events "
"WHERE person_appearances_json IS NOT NULL").fetchall()
@@ -168,17 +175,31 @@ class PersonService:
feats = pa.get('features') or {}
if not isinstance(feats, dict):
continue
if uid not in uid_features:
uid_features[uid] = dict(feats)
else:
merged = dict(uid_features[uid])
for k, v in feats.items():
v_str = str(v).strip() if v is not None else ''
if v_str and v_str.lower() != 'unknown':
merged[k] = v_str
elif k not in merged:
merged[k] = v_str or 'unknown'
uid_features[uid] = merged
groups = uid_groups.setdefault(uid, [])
gender = str(feats.get('gender', '') or '').strip()
target = None
if gender and gender.lower() not in ('unknown', '未知'):
for g in groups:
g_gender = str(g.get('gender', '') or '').strip()
if not g_gender or g_gender.lower() in ('unknown', '未知') or g_gender == gender:
target = g
break
elif groups:
target = groups[0]
if target is None:
target = {}
groups.append(target)
for k, v in feats.items():
v_str = str(v).strip() if v is not None else ''
if v_str and v_str.lower() != 'unknown':
target[k] = v_str
elif k not in target:
target[k] = v_str or 'unknown'
uid_features: Dict[str, Dict] = {}
for base_uid, groups in uid_groups.items():
for i, feats in enumerate(groups):
key = base_uid if i == 0 else f"{base_uid}#{i + 1}"
uid_features[key] = feats
return uid_features
def _collect_features_text(self, labels: List[str]) -> str: