File size: 8,559 Bytes
b43bd50
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
"""RAG eval harness for the /narrative explanatory layer.

Reads `backend/tests/fixtures/narrative_golden_set.json`, runs each case
through `rag_narrative.generate_narrative()` with `_call_openai_json` mocked
to return the case's prepared LLM response, and scores aggregate metrics.

Metrics:
  - kind_agreement_rate:      result.kind == expected.kind
  - reason_agreement_rate:    (result.reason or null) == (expected.reason or null)
  - citation_groundedness_rate: among happy_path cases, fraction returning
    `kind=narrative` AND all citations validate.
  - gate_correctness_rate:    among low_context cases, fraction returning
    `kind=low_confidence` with the right reason.
  - hallucination_rejection_rate: among hallucinated_citation cases, fraction
    returning `kind=unavailable, reason=citation-hallucinated`.

Writes a summary JSON to `factory/artifacts/RAG_EVAL_RESULT.json` so the
result is committable and reviewable in PRs that touch rag_narrative.py.

Run:
    python -m backend.scripts.run_rag_eval [--out PATH]

Returns non-zero exit code if any baseline gate fails (kind_agreement < 1.0,
gate_correctness < 1.0, hallucination_rejection < 1.0). Those gates are
load-bearing — any regression in them is a real bug in the validation layer.
"""

from __future__ import annotations

import argparse
import json
import sys
from collections import Counter
from pathlib import Path
from typing import Any
from unittest.mock import patch

REPO_ROOT = Path(__file__).resolve().parents[3]
GOLDEN_SET_PATH = REPO_ROOT / "backend" / "tests" / "fixtures" / "narrative_golden_set.json"
DEFAULT_OUT_PATH = REPO_ROOT / "factory" / "artifacts" / "RAG_EVAL_RESULT.json"


def _load_golden_set(path: Path) -> list[dict]:
    data = json.loads(path.read_text())
    cases = data.get("cases", [])
    if not cases:
        raise ValueError(f"empty golden set at {path}")
    return cases


def _build_context(spec: dict):
    """Materialize a NarrativeContext from a golden-set spec dict."""
    from backend import rag_narrative

    return rag_narrative.NarrativeContext(
        queryFingerprint=spec["queryFingerprint"],
        trackId=spec["trackId"],
        title=spec["title"],
        artist=spec.get("artist"),
        queryWindow=tuple(spec["queryWindow"]),
        matchWindow=tuple(spec["matchWindow"]),
        rawCosine=float(spec["rawCosine"]),
        criteria=[
            rag_narrative.CriterionContext(**c) for c in spec["criteria"]
        ],
        acrcloudCoverSongId=spec.get("acrcloudCoverSongId"),
    )


def _run_case(case: dict) -> dict:
    """Run one golden-set case end-to-end and capture the result vs expected.

    Returns a per-case report row.
    """
    from backend import rag_narrative

    mocked = case["mocked_llm_response"]
    expected_kind = case["expected_kind"]
    expected_reason = case.get("expected_reason")
    must_not_call_llm = mocked == "MUST_NOT_BE_CALLED"

    # Sentinel "MUST_NOT_BE_CALLED" lives only in the JSON for readability;
    # the patched helper still needs a real return value if called. We use
    # None so a wrongful call surfaces as openai-error and the case will
    # fail the kind-agreement assertion, surfacing the bug.
    patch_return = None if must_not_call_llm else mocked

    context = _build_context(case["context"])

    with patch(
        "backend.rag_narrative._call_openai_json", return_value=patch_return
    ) as call_mock:
        result = rag_narrative.generate_narrative(
            context,
            case["mode"],
            model_sha="eval-model-sha",
            catalog_sha="eval-catalog-sha",
        )

    llm_was_called = call_mock.called
    actual_kind = getattr(result, "kind", None) or (
        result.get("kind") if isinstance(result, dict) else None
    )
    actual_reason = getattr(result, "reason", None) or (
        result.get("reason") if isinstance(result, dict) else None
    )

    return {
        "name": case["name"],
        "category": case["category"],
        "mode": case["mode"],
        "expected_kind": expected_kind,
        "actual_kind": actual_kind,
        "expected_reason": expected_reason,
        "actual_reason": actual_reason,
        "llm_was_called": llm_was_called,
        "must_not_call_llm": must_not_call_llm,
        "kind_match": actual_kind == expected_kind,
        "reason_match": (actual_reason or None) == (expected_reason or None),
        "gate_respected": (not must_not_call_llm) or (not llm_was_called),
    }


def _aggregate(rows: list[dict]) -> dict:
    n = len(rows)
    kind_correct = sum(1 for r in rows if r["kind_match"])
    reason_correct = sum(1 for r in rows if r["reason_match"])
    by_cat = {}
    cat_total: Counter[str] = Counter()
    cat_kind_correct: Counter[str] = Counter()
    cat_reason_correct: Counter[str] = Counter()
    cat_gate_respected: Counter[str] = Counter()
    for r in rows:
        cat_total[r["category"]] += 1
        if r["kind_match"]:
            cat_kind_correct[r["category"]] += 1
        if r["reason_match"]:
            cat_reason_correct[r["category"]] += 1
        if r["gate_respected"]:
            cat_gate_respected[r["category"]] += 1

    for cat, total in cat_total.items():
        by_cat[cat] = {
            "total": total,
            "kind_agreement_rate": round(cat_kind_correct[cat] / total, 4),
            "reason_agreement_rate": round(cat_reason_correct[cat] / total, 4),
            "gate_respected_rate": round(cat_gate_respected[cat] / total, 4),
        }

    return {
        "n_cases": n,
        "kind_agreement_rate": round(kind_correct / n, 4) if n else 0.0,
        "reason_agreement_rate": round(reason_correct / n, 4) if n else 0.0,
        "by_category": by_cat,
        "baseline_gates": {
            # Each gate is "must be 1.0 to pass" — any regression here is a
            # real bug in validation / gating, not noise.
            "happy_path_kind_agreement": by_cat.get("happy_path", {}).get("kind_agreement_rate", 0.0),
            "low_context_gate_correctness": by_cat.get("low_context", {}).get("reason_agreement_rate", 0.0),
            "hallucination_rejection": by_cat.get("hallucinated_citation", {}).get("reason_agreement_rate", 0.0),
            "malformed_rejection": by_cat.get("malformed_output", {}).get("reason_agreement_rate", 0.0),
            "openai_error_handling": by_cat.get("openai_error", {}).get("reason_agreement_rate", 0.0),
        },
    }


def run_eval(golden_set_path: Path = GOLDEN_SET_PATH) -> dict:
    """Public entry point — runs the eval and returns the aggregate dict.

    Pure function from disk path → results dict. The CLI wrapper writes the
    results to disk; the pytest gate calls this directly and asserts.
    """
    cases = _load_golden_set(golden_set_path)
    rows = [_run_case(c) for c in cases]
    summary = _aggregate(rows)
    return {"summary": summary, "rows": rows}


def main() -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument(
        "--out",
        type=Path,
        default=DEFAULT_OUT_PATH,
        help="Where to write the eval result JSON.",
    )
    parser.add_argument(
        "--golden",
        type=Path,
        default=GOLDEN_SET_PATH,
        help="Override golden-set path (for experiments).",
    )
    args = parser.parse_args()

    result = run_eval(args.golden)
    args.out.parent.mkdir(parents=True, exist_ok=True)
    args.out.write_text(json.dumps(result, indent=2) + "\n")

    summary = result["summary"]
    print(f"RAG eval — {summary['n_cases']} cases")
    print(f"  kind agreement   : {summary['kind_agreement_rate']:.2%}")
    print(f"  reason agreement : {summary['reason_agreement_rate']:.2%}")
    print()
    print("  Baseline gates (each MUST be 1.0):")
    failed: list[str] = []
    for gate, score in summary["baseline_gates"].items():
        status = "OK " if score >= 1.0 else "FAIL"
        print(f"    [{status}] {gate}: {score:.2%}")
        if score < 1.0:
            failed.append(gate)

    print()
    print("  By category:")
    for cat, stats in summary["by_category"].items():
        print(
            f"    {cat:<26} n={stats['total']:>2} kind={stats['kind_agreement_rate']:.2%} "
            f"reason={stats['reason_agreement_rate']:.2%} gate_respected={stats['gate_respected_rate']:.2%}"
        )

    print()
    print(f"Result written to {args.out}")

    if failed:
        print(f"FAILED gates: {', '.join(failed)}", file=sys.stderr)
        return 1
    return 0


if __name__ == "__main__":
    sys.exit(main())