Spaces:
Sleeping
Sleeping
File size: 5,186 Bytes
db284b9 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 | """Data-integrity linter for the TraceRAG graph.
python scripts/audit_data.py [--db memory.lbug] [--alias-threshold 0.85] [--json report.json]
"""
from __future__ import annotations
import argparse
import json
import logging
import sys
from collections import defaultdict
from pathlib import Path
import numpy as np
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from tracerag import config # noqa: E402
from tracerag.db import TraceDB # noqa: E402
logger = logging.getLogger("tracerag.audit")
_OWNABLE_TYPES = ("Service", "Repo", "Ticket", "PR", "Tool")
_OWNER_TYPES = ("Person", "Team")
def find_orphaned_nodes(db: TraceDB) -> list[dict]:
"""Entities with no RELATES_TO edges (isolated in the entity graph)."""
rows = db._records(db.execute(
f"MATCH (e:{config.NODE_TABLE}) "
f"OPTIONAL MATCH (e)-[r:{config.REL_TABLE}]-(:{config.NODE_TABLE}) "
f"RETURN e.id AS id, e.label AS label, e.type AS type, count(r) AS degree;"
))
return [r for r in rows if int(r["degree"]) == 0]
def find_conflicting_edges(db: TraceDB) -> list[dict]:
"""Ownable nodes linked to more than one distinct Person/Team."""
rows = db._records(db.execute(
f"MATCH (a:{config.NODE_TABLE})-[:{config.REL_TABLE}]-(b:{config.NODE_TABLE}) "
f"WHERE a.type IN $ownable AND b.type IN $owners "
f"RETURN a.id AS aid, a.label AS alabel, a.type AS atype, "
f"b.label AS owner, b.type AS owner_type;",
{"ownable": list(_OWNABLE_TYPES), "owners": list(_OWNER_TYPES)},
))
grouped: dict[str, dict] = {}
for r in rows:
g = grouped.setdefault(r["aid"], {
"id": r["aid"], "label": r["alabel"], "type": r["atype"], "owners": set()
})
g["owners"].add(f'{r["owner"]} ({r["owner_type"]})')
conflicts = []
for g in grouped.values():
if len(g["owners"]) > 1:
g["owners"] = sorted(g["owners"])
conflicts.append(g)
return conflicts
def find_alias_drift(db: TraceDB, threshold: float) -> list[dict]:
"""Separate nodes with near-duplicate embeddings (cosine >= threshold)."""
rows = db._records(db.execute(
f"MATCH (e:{config.NODE_TABLE}) "
f"RETURN e.id AS id, e.label AS label, e.type AS type, e.embedding AS emb;"
))
rows = [r for r in rows if r.get("emb") is not None]
if len(rows) < 2:
return []
mat = np.asarray([r["emb"] for r in rows], dtype=np.float32) # pre-normalized
sims = mat @ mat.T
pairs = []
for i in range(len(rows)):
for j in range(i + 1, len(rows)):
s = float(sims[i, j])
if s >= threshold:
pairs.append({
"a": rows[i]["label"], "a_type": rows[i]["type"],
"b": rows[j]["label"], "b_type": rows[j]["type"],
"similarity": round(s, 4),
})
pairs.sort(key=lambda p: p["similarity"], reverse=True)
return pairs
def _section(title: str, items: list, render) -> None:
print(f"\n{'=' * 70}\n {title} ({len(items)} found)\n{'-' * 70}")
if not items:
print(" (none)")
return
for it in items[:40]:
print(" " + render(it))
if len(items) > 40:
print(f" ... and {len(items) - 40} more")
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(description="TraceRAG data-integrity linter.")
p.add_argument("--db", type=Path, default=config.DB_PATH)
p.add_argument("--alias-threshold", type=float, default=config.DEEP_MERGE_THRESHOLD,
help="Cosine >= this between distinct nodes flags alias drift.")
p.add_argument("--json", type=Path, default=None, help="Also write the report as JSON.")
args = p.parse_args(argv)
logging.basicConfig(level=logging.WARNING)
db = TraceDB(args.db)
try:
total_nodes = db.count_nodes()
orphaned = find_orphaned_nodes(db)
conflicts = find_conflicting_edges(db)
drift = find_alias_drift(db, args.alias_threshold)
finally:
db.close()
print(f"\nTraceRAG data audit — {args.db} ({total_nodes} entities)")
_section("ORPHANED NODES (0 RELATES_TO edges)", orphaned,
lambda r: f'{r["label"]!r} ({r["type"]}) id={r["id"]}')
_section("CONFLICTING OWNERSHIP (>1 Person/Team)", conflicts,
lambda c: f'{c["label"]!r} ({c["type"]}) -> {", ".join(c["owners"])}')
_section(f"ALIAS DRIFT (cosine >= {args.alias_threshold})", drift,
lambda d: f'{d["similarity"]} {d["a"]!r} ({d["a_type"]}) ~ {d["b"]!r} ({d["b_type"]})')
summary = {
"db": str(args.db), "total_nodes": total_nodes,
"orphaned": orphaned, "conflicts": conflicts, "alias_drift": drift,
}
print(f"\n{'=' * 70}\n SUMMARY: {len(orphaned)} orphaned | "
f"{len(conflicts)} ownership conflicts | {len(drift)} alias-drift pairs\n")
if args.json:
args.json.write_text(json.dumps(summary, indent=2), encoding="utf-8")
print(f"Wrote JSON report -> {args.json}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
|