"""图投影对账:检查图里的关系与权威画像是否一致,可选自动修复。 图是投影(MySQL 才是唯一真相),所以它可能因为投影漏投、记忆失效后未清理、或图库故障 而与画像不一致。本工具用**数据层对账**回答"图里现在的内容对不对",与 `ProjectionReconciliationService` 的**事件层对账**(哪些投影事件还没投递)互补。 用法: ```powershell # 只检查,不改动 python tools/reconcile_graph.py 9001 # 检查并修复(缺失的补写、多余的删除) python tools/reconcile_graph.py 9001 --repair # 检查所有有记忆数据的客户 python tools/reconcile_graph.py --all ``` 退出码:0 = 全部一致(或已修复),1 = 存在不一致(未修复)或对账降级。 """ import argparse import asyncio import sys from sqlalchemy import select from app.infrastructure.db import SessionFactory from app.infrastructure.graph import build_graph_driver from app.model.memory import MemoryUnit from app.service.profile_graph_projection_service import ProfileGraphProjectionService from app.service.relationship_service import RelationshipService # GBK 控制台下图里的中文标签可能含不可编码字符,替换而不是让脚本自身崩掉 sys.stdout.reconfigure(errors="replace") async def reconcile_one(customer_id: int, *, repair: bool) -> bool: driver = build_graph_driver() if driver is None: print("图驱动不可用:检查 .env 中的 NEO4J_URI / NEO4J_PASSWORD") return False graph = RelationshipService(driver) try: async with SessionFactory() as session: service = ProfileGraphProjectionService(session, graph) outcome = await service.reconcile_customer(customer_id, repair=repair) finally: await driver.close() print(f"\n== 客户 {customer_id} ==") if outcome.degraded: print(f" 对账降级:{outcome.reason}") return False print(f" 应有 {len(outcome.expected)} 条 / 图里 {len(outcome.actual)} 条") if outcome.missing: print(f" 缺失(画像有、图里没有){len(outcome.missing)} 条:") for relation, target_type, target_id in outcome.missing: print(f" + {relation} -> {target_type}:{target_id}") if outcome.orphaned: print(f" 多余(图里有、画像已无){len(outcome.orphaned)} 条:") for relation, target_type, target_id in outcome.orphaned: print(f" - {relation} -> {target_type}:{target_id}") if outcome.consistent: print(" 一致") elif repair and outcome.repaired: print(" 已按画像修复(缺失已补写、多余已删除)") else: print(" 不一致:加 --repair 可按画像修复") return outcome.consistent async def main() -> int: parser = argparse.ArgumentParser(description="图投影对账") parser.add_argument("customer_id", nargs="?", type=int, help="客户 id") parser.add_argument("--all", action="store_true", help="对账所有有记忆数据的客户") parser.add_argument("--repair", action="store_true", help="按画像修复差异") args = parser.parse_args() if args.all: async with SessionFactory() as session: rows = list(await session.scalars(select(MemoryUnit.customer_id).distinct())) targets = sorted({int(row) for row in rows}) if not targets: print("没有任何客户有记忆数据,无需对账") return 0 print(f"将对账 {len(targets)} 个客户:{targets}") results = [await reconcile_one(cid, repair=args.repair) for cid in targets] return 0 if all(results) else 1 if args.customer_id is None: parser.print_help() return 2 return 0 if await reconcile_one(args.customer_id, repair=args.repair) else 1 sys.exit(asyncio.run(main()))