|
| 1 | +"""Ablation runner — 3-config evaluation of the annotation pipeline. |
| 2 | +
|
| 3 | +Config 1: Single LLM call, no multi-agent, no MCP (baseline) |
| 4 | +Config 2: Multi-agent without MCP (structural benefit only) |
| 5 | +Config 3: Full pipeline — multi-agent + MCP guidelines + Redis examples |
| 6 | +
|
| 7 | +Usage: |
| 8 | + uv run python eval/run_eval.py --smoke --n 10 |
| 9 | + uv run python eval/run_eval.py --configs 3 --n 50 |
| 10 | + uv run python eval/run_eval.py --output results.json |
| 11 | +""" |
| 12 | + |
| 13 | +from __future__ import annotations |
| 14 | + |
| 15 | +import argparse |
| 16 | +import json |
| 17 | +import sys |
| 18 | +import time |
| 19 | +from pathlib import Path |
| 20 | + |
| 21 | +from dotenv import load_dotenv |
| 22 | + |
| 23 | +load_dotenv() |
| 24 | + |
| 25 | +FIXTURES_DIR = Path(__file__).parent.parent / "tests" / "fixtures" / "items" |
| 26 | +HOLDOUT_PATH = Path(__file__).parent.parent / "data" / "splits" / "holdout.parquet" |
| 27 | + |
| 28 | + |
| 29 | +# ---------- item loading ---------- |
| 30 | + |
| 31 | + |
| 32 | +def _load_smoke_items(n: int) -> list[dict]: |
| 33 | + items = [json.loads(f.read_text()) for f in sorted(FIXTURES_DIR.glob("item_*.json"))] |
| 34 | + return items[:n] |
| 35 | + |
| 36 | + |
| 37 | +def _load_holdout_items(n: int | None) -> list[dict]: |
| 38 | + if not HOLDOUT_PATH.exists(): |
| 39 | + print( |
| 40 | + f"ERROR: {HOLDOUT_PATH} not found. Run eval/datasets.py first.", |
| 41 | + file=sys.stderr, |
| 42 | + ) |
| 43 | + sys.exit(1) |
| 44 | + import pandas as pd |
| 45 | + |
| 46 | + df = pd.read_parquet(HOLDOUT_PATH) |
| 47 | + if n: |
| 48 | + df = df.head(n) |
| 49 | + return [ |
| 50 | + {"query_id": row.query_id, "text": row.text, "true_intent": row.category} |
| 51 | + for row in df.itertuples() |
| 52 | + ] |
| 53 | + |
| 54 | + |
| 55 | +# ---------- Config 1: single LLM ---------- |
| 56 | + |
| 57 | +_SINGLE_LLM_SYSTEM = """You are a banking customer service intent classifier. |
| 58 | +Assign exactly one intent label from the provided list. |
| 59 | +Respond with ONLY valid JSON: {"label": "<intent_name>"}""" |
| 60 | + |
| 61 | + |
| 62 | +def _run_config1(items: list[dict]) -> tuple[list, float]: |
| 63 | + from groq import Groq |
| 64 | + |
| 65 | + from eval.evaluators import EvalResult |
| 66 | + from mcp_servers.label_schema_mcp.server import list_valid_intents |
| 67 | + |
| 68 | + client = Groq() |
| 69 | + valid = {e["intent_name"] for e in list_valid_intents()} |
| 70 | + intent_list = "\n".join(f"- {n}" for n in sorted(valid)) |
| 71 | + |
| 72 | + results: list[EvalResult] = [] |
| 73 | + t_start = time.perf_counter() |
| 74 | + |
| 75 | + for i, item in enumerate(items, 1): |
| 76 | + print(f" [1/{len(items)}→{i}] {item['query_id']}", end=" ", flush=True) |
| 77 | + t0 = time.perf_counter() |
| 78 | + try: |
| 79 | + resp = client.chat.completions.create( |
| 80 | + model="llama-3.1-8b-instant", |
| 81 | + messages=[ |
| 82 | + {"role": "system", "content": _SINGLE_LLM_SYSTEM}, |
| 83 | + { |
| 84 | + "role": "user", |
| 85 | + "content": ( |
| 86 | + f"Customer query: {item['text']}\n\nValid intents:\n{intent_list}" |
| 87 | + ), |
| 88 | + }, |
| 89 | + ], |
| 90 | + response_format={"type": "json_object"}, |
| 91 | + temperature=0, |
| 92 | + ) |
| 93 | + raw = json.loads(resp.choices[0].message.content) |
| 94 | + label = raw.get("label", "").strip() |
| 95 | + if label not in valid: |
| 96 | + label = "__invalid__" |
| 97 | + except Exception as exc: |
| 98 | + print(f"[ERROR: {exc}]", end=" ") |
| 99 | + label = "__error__" |
| 100 | + |
| 101 | + elapsed = time.perf_counter() - t0 |
| 102 | + print(f"→ {label} ({elapsed:.1f}s)") |
| 103 | + results.append( |
| 104 | + EvalResult( |
| 105 | + query_id=item["query_id"], |
| 106 | + predicted_label=label, |
| 107 | + true_label=item["true_intent"], |
| 108 | + elapsed_seconds=elapsed, |
| 109 | + ) |
| 110 | + ) |
| 111 | + |
| 112 | + return results, time.perf_counter() - t_start |
| 113 | + |
| 114 | + |
| 115 | +# ---------- Config 2: multi-agent, no MCP ---------- |
| 116 | + |
| 117 | + |
| 118 | +def _run_config2(items: list[dict]) -> tuple[list, float]: |
| 119 | + from agents.arbitrator import CONFIDENCE_THRESHOLD, arbitrate |
| 120 | + from agents.primary_annotator import annotate |
| 121 | + from agents.router import route as router_route |
| 122 | + from agents.validator import agrees_with, validate |
| 123 | + from eval.evaluators import EvalResult |
| 124 | + |
| 125 | + results: list[EvalResult] = [] |
| 126 | + t_start = time.perf_counter() |
| 127 | + |
| 128 | + for i, item in enumerate(items, 1): |
| 129 | + print(f" [2/{len(items)}→{i}] {item['query_id']}", end=" ", flush=True) |
| 130 | + t0 = time.perf_counter() |
| 131 | + query = item["text"] |
| 132 | + |
| 133 | + route_decision = router_route(query) |
| 134 | + primary = annotate(query, use_mcp=False) |
| 135 | + final_label = primary.label |
| 136 | + route_to_human = False |
| 137 | + |
| 138 | + if route_decision.route == "COMPLEX": |
| 139 | + validator = validate(query, use_mcp=False) |
| 140 | + if not agrees_with(validator, primary.label): |
| 141 | + arb = arbitrate(query, primary, validator) |
| 142 | + final_label = arb.final_label |
| 143 | + route_to_human = arb.confidence < CONFIDENCE_THRESHOLD |
| 144 | + |
| 145 | + elapsed = time.perf_counter() - t0 |
| 146 | + status = "→human" if route_to_human else f"→ {final_label}" |
| 147 | + print(f"{route_decision.route} {status} ({elapsed:.1f}s)") |
| 148 | + results.append( |
| 149 | + EvalResult( |
| 150 | + query_id=item["query_id"], |
| 151 | + predicted_label=final_label, |
| 152 | + true_label=item["true_intent"], |
| 153 | + route_to_human=route_to_human, |
| 154 | + elapsed_seconds=elapsed, |
| 155 | + ) |
| 156 | + ) |
| 157 | + |
| 158 | + return results, time.perf_counter() - t_start |
| 159 | + |
| 160 | + |
| 161 | +# ---------- Config 3: full pipeline ---------- |
| 162 | + |
| 163 | + |
| 164 | +def _run_config3(items: list[dict]) -> tuple[list, float]: |
| 165 | + from eval.evaluators import EvalResult |
| 166 | + from graph.pipeline import build_pipeline |
| 167 | + |
| 168 | + pipeline = build_pipeline() |
| 169 | + results: list[EvalResult] = [] |
| 170 | + t_start = time.perf_counter() |
| 171 | + |
| 172 | + for i, item in enumerate(items, 1): |
| 173 | + print(f" [3/{len(items)}→{i}] {item['query_id']}", end=" ", flush=True) |
| 174 | + t0 = time.perf_counter() |
| 175 | + state = pipeline.invoke( |
| 176 | + { |
| 177 | + "query_id": item["query_id"], |
| 178 | + "query": item["text"], |
| 179 | + "batch_stats": {}, |
| 180 | + } |
| 181 | + ) |
| 182 | + elapsed = time.perf_counter() - t0 |
| 183 | + final_label = state.get("final_label", "__none__") |
| 184 | + route_to_human = bool(state.get("route_to_human")) |
| 185 | + status = "→human" if route_to_human else f"→ {final_label}" |
| 186 | + print(f"{state.get('route', '?')} {status} ({elapsed:.1f}s)") |
| 187 | + results.append( |
| 188 | + EvalResult( |
| 189 | + query_id=item["query_id"], |
| 190 | + predicted_label=final_label, |
| 191 | + true_label=item["true_intent"], |
| 192 | + route_to_human=route_to_human, |
| 193 | + elapsed_seconds=elapsed, |
| 194 | + ) |
| 195 | + ) |
| 196 | + |
| 197 | + return results, time.perf_counter() - t_start |
| 198 | + |
| 199 | + |
| 200 | +# ---------- main ---------- |
| 201 | + |
| 202 | +_CONFIG_NAMES = { |
| 203 | + "1": "Config 1: Single LLM (baseline)", |
| 204 | + "2": "Config 2: Multi-agent, no MCP", |
| 205 | + "3": "Config 3: Full pipeline (MCP)", |
| 206 | +} |
| 207 | + |
| 208 | +_CONFIG_FNS = { |
| 209 | + "1": _run_config1, |
| 210 | + "2": _run_config2, |
| 211 | + "3": _run_config3, |
| 212 | +} |
| 213 | + |
| 214 | + |
| 215 | +def main() -> None: |
| 216 | + parser = argparse.ArgumentParser(description="Run ablation eval on the annotation pipeline") |
| 217 | + parser.add_argument("--smoke", action="store_true", help="Use fixture items instead of holdout") |
| 218 | + parser.add_argument("--n", type=int, default=10, help="Number of items to evaluate") |
| 219 | + parser.add_argument( |
| 220 | + "--configs", |
| 221 | + nargs="+", |
| 222 | + choices=["1", "2", "3"], |
| 223 | + default=["1", "2", "3"], |
| 224 | + metavar="N", |
| 225 | + help="Which configs to run (default: 1 2 3)", |
| 226 | + ) |
| 227 | + parser.add_argument("--output", type=str, default=None, help="Save results to JSON file") |
| 228 | + args = parser.parse_args() |
| 229 | + |
| 230 | + if args.smoke: |
| 231 | + items = _load_smoke_items(args.n) |
| 232 | + print(f"Smoke mode: {len(items)} fixture items") |
| 233 | + else: |
| 234 | + items = _load_holdout_items(args.n) |
| 235 | + print(f"Holdout mode: {len(items)} items") |
| 236 | + |
| 237 | + if not items: |
| 238 | + print("No items to evaluate.", file=sys.stderr) |
| 239 | + sys.exit(1) |
| 240 | + |
| 241 | + from eval.evaluators import compute_summary, print_comparison |
| 242 | + |
| 243 | + all_results: dict[str, list] = {} |
| 244 | + all_summaries = [] |
| 245 | + |
| 246 | + for cfg in args.configs: |
| 247 | + print(f"\nRunning {_CONFIG_NAMES[cfg]} ...") |
| 248 | + results, elapsed = _CONFIG_FNS[cfg](items) |
| 249 | + summary = compute_summary(_CONFIG_NAMES[cfg], results, elapsed) |
| 250 | + all_results[cfg] = [vars(r) for r in results] |
| 251 | + all_summaries.append(summary) |
| 252 | + |
| 253 | + print_comparison(all_summaries) |
| 254 | + |
| 255 | + if args.output: |
| 256 | + out_path = Path(args.output) |
| 257 | + payload = { |
| 258 | + "summaries": [vars(s) for s in all_summaries], |
| 259 | + "per_config_results": all_results, |
| 260 | + } |
| 261 | + out_path.write_text(json.dumps(payload, indent=2)) |
| 262 | + print(f"Results saved to {out_path}") |
| 263 | + |
| 264 | + |
| 265 | +if __name__ == "__main__": |
| 266 | + main() |
0 commit comments