From 6b149a0dd0edaee6090e05fb270c1d4515707085 Mon Sep 17 00:00:00 2001 From: ericwyuan Date: Sat, 22 Aug 2026 06:53:51 +0800 Subject: [PATCH] =?UTF-8?q?fix(fam-edge):=20=E4=BA=BA=E7=89=A9=E5=90=88?= =?UTF-8?q?=E5=B9=B6=E5=8A=A0=E7=A1=AC=E8=A7=84=E5=88=99=E5=90=A6=E5=86=B3?= =?UTF-8?q?=20-=20LLM=20=E5=BC=B1=E6=A8=A1=E5=9E=8B=E5=8F=AF=E8=83=BD?= =?UTF-8?q?=E6=8A=8A=E7=88=B7=E7=88=B7/=E5=AE=9D=E5=AE=9D=E8=AF=AF?= =?UTF-8?q?=E5=B9=B6=E6=88=90=E5=90=8C=E4=B8=80=E4=BA=BA=E7=89=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PersonService._merge_labels 原来完全信任 LLM 给出的合并提议(label -> canonical), 但弱模型偶尔会把性别不同或年龄档跨未成年-成年的两个人合并成一个身份(如把"爷爷" 和"宝宝"并到同一个人物)。 新增 _features_conflict 硬冲突检测,落库前校验候选合并双方的 gender/age_band: 性别不同、或年龄档跨 child/adult 边界(幼儿/儿童/少年 vs 青年/中年/老年)一律 否决合并,只把结果当 LLM 的提议,不当最终结论。否决的合并计入 blocked 计数, 连同 updated 一起写入 service_activity 日志,方便观察 LLM 合并的实际可靠性。 Co-Authored-By: Claude Sonnet 5 --- fam-edge/src/fam_edge/person_service.py | 52 +++++++++++++++++++++++-- 1 file changed, 49 insertions(+), 3 deletions(-) diff --git a/fam-edge/src/fam_edge/person_service.py b/fam-edge/src/fam_edge/person_service.py index 77b0b08..7457ab3 100644 --- a/fam-edge/src/fam_edge/person_service.py +++ b/fam-edge/src/fam_edge/person_service.py @@ -25,6 +25,34 @@ from . import oracle_db logger = setup_logger('fam-edge.person_service') +# 年龄档分桶:未成年与成年是稳定特征,跨桶绝不可能是同一个人 +_AGE_CHILD = {'幼儿', '儿童', '婴儿', '小孩', '少年', '青少年'} +_AGE_ADULT = {'青年', '中年', '老年', '成年'} + + +def _age_bucket(v: str) -> str: + if v in _AGE_CHILD: + return 'child' + if v in _AGE_ADULT: + return 'adult' + return '' + + +def _features_conflict(fa: dict, fb: dict) -> str: + """硬冲突检测:性别不同 / 年龄档跨未成年-成年的两个人绝不合并。 + + LLM 合并只作为提议,落库前必须通过本校验(弱模型会把 爷爷/宝宝 全并进 + 人物A 这类错误)。返回冲突原因,无冲突返回空串。 + """ + ga, gb = str(fa.get('gender', '')), str(fb.get('gender', '')) + if ga and gb and 'unknown' not in (ga.lower(), gb.lower()) and ga != gb: + return f'性别冲突 {ga} vs {gb}' + ba = _age_bucket(str(fa.get('age_band', ''))) + bb = _age_bucket(str(fb.get('age_band', ''))) + if ba and bb and ba != bb: + return f'年龄档冲突 {fa.get("age_band")} vs {fb.get("age_band")}' + return '' + class PersonService: def __init__(self, db: oracle_db.OracleDB): @@ -82,19 +110,37 @@ class PersonService: # 4. 落库:canonical 若是另一个 label(target),解析为其已有 canonical,保证同一身份统一显示名 label_to_canonical = {r['label']: (r['canonical_name'] or r['label']) for r in rows} - updated = 0 + + def _parse_feats(js): + try: + f = json.loads(js or '{}') + return f if isinstance(f, dict) else {} + except (ValueError, TypeError): + return {} + + feat_map = {r['label']: _parse_feats(r['features_json']) for r in rows} + updated = blocked = 0 for label, canonical in mapping.items(): if label in manual: continue # 手动命名优先 if canonical and canonical != label: resolved = label_to_canonical.get(canonical, canonical) + conflict = _features_conflict( + feat_map.get(label, {}), feat_map.get(resolved, {})) + if conflict: + # 硬规则否决 LLM 提议:性别/年龄档冲突的绝不可能是同一人 + logger.warning( + f"PersonService: 否决 LLM 合并 {label} -> {resolved}({conflict})") + blocked += 1 + continue self.db.set_canonical(label, resolved, source='llm') updated += 1 self.db.record_activity( 'person', 'merge_done', f"校准 {len(label_videos)} 标签 + 聚合 {len(uid_features)} 特征," - f"LLM 合并更新 {updated} 条({', '.join(list(mapping)[:6])})") - logger.info(f"PersonService: LLM 合并完成,更新 {updated} 条") + f"LLM 合并更新 {updated} 条 / 硬规则否决 {blocked} 条" + f"({', '.join(list(mapping)[:6])})") + logger.info(f"PersonService: LLM 合并完成,更新 {updated} 条,硬规则否决 {blocked} 条") def _aggregate_features(self) -> Dict[str, Dict]: """从 events.person_appearances_json 聚合每个 uid 的合并特征。