| """Diff two taxonomy revisions over recorded run scores. |
| |
| Shows which folder each item moves from and to, so a taxonomy edit can be |
| reviewed for regressions (content leaving a specific folder for a catch-all) |
| before it is applied to a real library. |
| |
| The baseline is either a git revision (``HEAD``, a branch, a SHA) or a path to |
| a taxonomy JSON file. |
| |
| Usage (from backend/): |
| ../.venv/Scripts/python.exe scripts/diff_taxonomy_routing.py HEAD |
| """ |
|
|
| from __future__ import annotations |
|
|
| import json |
| import sqlite3 |
| import subprocess |
| import sys |
| import tempfile |
| from collections import Counter, defaultdict |
| from pathlib import Path |
|
|
| sys.path.insert(0, str(Path(__file__).resolve().parents[1])) |
|
|
| from app.taxonomy import ( |
| DEFAULT_TAXONOMY_PATH, |
| choose_best_destination, |
| load_taxonomy, |
| ) |
|
|
| DB_PATH = Path(__file__).resolve().parents[1] / "app.db" |
| MAX_EXAMPLES = 2 |
| REPO_ROOT = Path(__file__).resolve().parents[2] |
| TRACKED_PATH = "backend/app/data/taxonomy.json" |
|
|
|
|
| def baseline_path(ref: str) -> Path: |
| """Resolve a taxonomy baseline given a file path or a git revision.""" |
| candidate = Path(ref) |
| if candidate.is_file(): |
| return candidate |
| blob = subprocess.run( |
| ["git", "show", f"{ref}:{TRACKED_PATH}"], |
| cwd=REPO_ROOT, |
| capture_output=True, |
| check=True, |
| ).stdout |
| tmp = Path(tempfile.mkdtemp()) / "taxonomy_baseline.json" |
| tmp.write_bytes(blob) |
| return tmp |
|
|
|
|
| def main() -> None: |
| if len(sys.argv) < 2: |
| raise SystemExit("usage: diff_taxonomy_routing.py <git-ref|path> [run_id ...]") |
|
|
| before = load_taxonomy(baseline_path(sys.argv[1])) |
| after = load_taxonomy(DEFAULT_TAXONOMY_PATH) |
| before_folders = {b.folder for b in before.buckets} |
| after_folders = {b.folder for b in after.buckets} |
|
|
| conn = sqlite3.connect(DB_PATH) |
| conn.row_factory = sqlite3.Row |
| run_ids = [int(a) for a in sys.argv[2:]] |
| if not run_ids: |
| run_ids = [ |
| r[0] |
| for r in conn.execute("SELECT id FROM runs ORDER BY id DESC LIMIT 3").fetchall() |
| ] |
|
|
| moves: Counter[tuple[str, str]] = Counter() |
| examples: dict[tuple[str, str], list[str]] = defaultdict(list) |
| total = 0 |
|
|
| placeholders = ",".join("?" for _ in run_ids) |
| rows = conn.execute( |
| f"SELECT id, full_scores_json FROM items WHERE run_id IN ({placeholders})", |
| run_ids, |
| ).fetchall() |
|
|
| for row in rows: |
| raw = row["full_scores_json"] |
| if not raw: |
| continue |
| try: |
| scores = json.loads(raw) |
| except json.JSONDecodeError: |
| continue |
| if not isinstance(scores, dict) or not scores: |
| continue |
| total += 1 |
| old = choose_best_destination(scores, set(before_folders), taxonomy=before)[0] |
| new = choose_best_destination(scores, set(after_folders), taxonomy=after)[0] |
| key = (old or "(review)", new or "(review)") |
| moves[key] += 1 |
| if old != new and len(examples[key]) < MAX_EXAMPLES: |
| top = sorted(scores.items(), key=lambda kv: -float(kv[1]))[:8] |
| examples[key].append(", ".join(f"{k}:{float(v):.2f}" for k, v in top)) |
|
|
| unchanged = sum(count for (old, new), count in moves.items() if old == new) |
| print(f"runs {run_ids}: {total} scored items") |
| print(f"unchanged: {unchanged} changed: {total - unchanged}") |
| print() |
|
|
| print("--- items LEAVING each folder (possible regressions) ---") |
| losses: Counter[str] = Counter() |
| for (old, new), count in moves.items(): |
| if old != new and old != "(review)": |
| losses[old] += count |
| for old, count in losses.most_common(): |
| print(f" {old} loses {count}:") |
| for (o, n), c in sorted(moves.items(), key=lambda kv: -kv[1]): |
| if o != old or o == n: |
| continue |
| print(f" -> {n:22s} {c}") |
| for sample in examples[(o, n)]: |
| print(f" {sample}") |
| print() |
|
|
| print("--- newly routed (was review) ---") |
| for (old, new), count in sorted(moves.items(), key=lambda kv: -kv[1]): |
| if old == "(review)" and new != "(review)": |
| print(f" {count:5d} -> {new}") |
| print() |
|
|
| print("--- newly unrouted (now review) ---") |
| for (old, new), count in sorted(moves.items(), key=lambda kv: -kv[1]): |
| if new == "(review)" and old != "(review)": |
| print(f" {count:5d} {old} -> review") |
| for sample in examples[(old, new)]: |
| print(f" {sample}") |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|