| """Audit taxonomy coverage against real run data. |
| |
| Reports, for the most recent runs: |
| * how many items auto-file, land as a low-confidence suggestion, or fall to |
| review — using the same noise floor / confidence gate as the API |
| * the most common tags on unrouted items (candidate evidence) |
| * which of those tags exist in tags.csv (so they are actually predictable) |
| |
| Usage (from backend/): |
| ../.venv/Scripts/python.exe scripts/audit_taxonomy_coverage.py [run_id ...] |
| """ |
|
|
| from __future__ import annotations |
|
|
| import csv |
| import json |
| import sqlite3 |
| import sys |
| from collections import Counter |
| from pathlib import Path |
|
|
| sys.path.insert(0, str(Path(__file__).resolve().parents[1])) |
|
|
| from app.taxonomy import ( |
| choose_best_destination, |
| get_taxonomy, |
| reload_taxonomy, |
| ) |
|
|
| DB_PATH = Path(__file__).resolve().parents[1] / "app.db" |
| TAGS_CSV = Path(__file__).resolve().parents[2] / "tags.csv" |
|
|
|
|
| def load_csv_tags() -> set[str]: |
| names: set[str] = set() |
| with TAGS_CSV.open("r", encoding="utf-8", newline="") as handle: |
| for row in csv.reader(handle): |
| if not row: |
| continue |
| value = (row[0] or "").strip() |
| if value and value.lower() != "name": |
| names.add(value.lower()) |
| return names |
|
|
|
|
| def selected_folders(conn: sqlite3.Connection) -> set[str]: |
| row = conn.execute("SELECT selected_tags_json FROM settings WHERE id = 1").fetchone() |
| if not row or not row[0]: |
| return set() |
| try: |
| payload = json.loads(row[0]) |
| except json.JSONDecodeError: |
| return set() |
| return {str(t).strip() for t in payload if str(t).strip()} |
|
|
|
|
| def main() -> None: |
| reload_taxonomy() |
| cfg = get_taxonomy() |
| csv_tags = load_csv_tags() |
|
|
| conn = sqlite3.connect(DB_PATH) |
| conn.row_factory = sqlite3.Row |
|
|
| run_ids = [int(a) for a in sys.argv[1:]] |
| if not run_ids: |
| run_ids = [ |
| r[0] |
| for r in conn.execute("SELECT id FROM runs ORDER BY id DESC LIMIT 3").fetchall() |
| ] |
|
|
| selected = selected_folders(conn) |
| print(f"selected folders in settings: {sorted(selected) or '(none)'}") |
| print(f"taxonomy buckets: {len(cfg.buckets)}") |
| print() |
|
|
| |
| all_folders = {b.folder for b in cfg.buckets} |
|
|
| settings = conn.execute("SELECT confidence_threshold FROM settings WHERE id = 1").fetchone() |
| confidence = float(settings[0]) if settings and settings[0] is not None else 0.6 |
| noise_floor = max(0.15, confidence * 0.5) |
| print(f"confidence threshold {confidence:.2f}, noise floor {noise_floor:.2f}") |
| print() |
|
|
| unrouted_tags: Counter[str] = Counter() |
| routed_counts: Counter[str] = Counter() |
| auto_counts: Counter[str] = Counter() |
| weak_counts: Counter[str] = Counter() |
| total = 0 |
| unrouted = 0 |
| below_floor = 0 |
| unrouted_samples: list[tuple[int, list[tuple[str, float]]]] = [] |
|
|
| placeholders = ",".join("?" for _ in run_ids) |
| rows = conn.execute( |
| f"SELECT id, run_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 |
| folder, score, _secondary = choose_best_destination(scores, set(all_folders)) |
| if folder and score is not None and score >= noise_floor: |
| routed_counts[folder] += 1 |
| if score >= confidence: |
| auto_counts[folder] += 1 |
| else: |
| weak_counts[folder] += 1 |
| continue |
| if folder: |
| below_floor += 1 |
| unrouted += 1 |
| for tag, value in scores.items(): |
| if float(value) >= 0.5: |
| unrouted_tags[tag.lower()] += 1 |
| if len(unrouted_samples) < 12: |
| top = sorted(scores.items(), key=lambda kv: -float(kv[1]))[:14] |
| unrouted_samples.append((row["id"], [(k, round(float(v), 2)) for k, v in top])) |
|
|
| auto_total = sum(auto_counts.values()) |
| weak_total = sum(weak_counts.values()) |
| print(f"runs analysed: {run_ids}") |
| print(f"items with scores: {total}") |
| print(f"auto-filed (>= {confidence:.2f}): {auto_total}") |
| print(f"low-confidence suggestion: {weak_total}") |
| print(f"review: {unrouted} (of which {below_floor} scored below the noise floor)") |
| print() |
| print("--- routed by folder (auto-filed / suggestion) ---") |
| for folder, count in routed_counts.most_common(): |
| print(f" {count:5d} ({auto_counts[folder]:4d} / {weak_counts[folder]:4d}) {folder}") |
| print() |
|
|
| known_evidence = set() |
| for bucket in cfg.buckets: |
| for ev in bucket.evidence: |
| known_evidence.add(ev.tag.lower()) |
| for ev in bucket.gated_evidence: |
| known_evidence.add(ev.tag.lower()) |
|
|
| print("--- top tags on UNROUTED items (not already evidence) ---") |
| shown = 0 |
| for tag, count in unrouted_tags.most_common(400): |
| if tag in known_evidence: |
| continue |
| in_csv = "csv" if tag in csv_tags else " " |
| pct = 100.0 * count / max(unrouted, 1) |
| print(f" {count:5d} ({pct:5.1f}%) [{in_csv}] {tag}") |
| shown += 1 |
| if shown >= 120: |
| break |
| print() |
|
|
| print("--- sample unrouted items ---") |
| for item_id, top in unrouted_samples: |
| print(f" item {item_id}: {top}") |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|