File size: 5,650 Bytes
c020606
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
"""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 (  # noqa: E402
    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()

    # Score every bucket regardless of user selection so we see full potential.
    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()