| 1 | #!/usr/bin/env python3 |
| 2 | """Compare two last30days revisions on the v3 ranked candidate output.""" |
| 3 | |
| 4 | from __future__ import annotations |
| 5 | |
| 6 | import argparse |
| 7 | import json |
| 8 | import math |
| 9 | import os |
| 10 | import subprocess |
| 11 | import sys |
| 12 | import tempfile |
| 13 | from datetime import datetime |
| 14 | from pathlib import Path |
| 15 | from typing import Any |
| 16 | from urllib.error import HTTPError, URLError |
| 17 | from urllib.request import Request, urlopen |
| 18 | |
| 19 | sys.path.insert(0, str(Path(__file__).parent)) |
| 20 | |
| 21 | from lib import env as envlib |
| 22 | from lib import schema |
| 23 | from lib.providers import GEMINI_FLASH_LITE |
| 24 | |
| 25 | |
| 26 | SKILL_ROOT = Path(__file__).resolve().parents[1] |
| 27 | REPO_ROOT = Path(__file__).resolve().parents[3] |
| 28 | EVAL_TOPICS_FILE = REPO_ROOT / "fixtures" / "eval_topics.json" |
| 29 | |
| 30 | |
| 31 | def _load_default_topics() -> list[tuple[str, str]]: |
| 32 | if EVAL_TOPICS_FILE.exists(): |
| 33 | rows = json.loads(EVAL_TOPICS_FILE.read_text()) |
| 34 | return [(row["topic"], row["query_type"]) for row in rows] |
| 35 | return [ |
| 36 | ("nano banana pro prompting", "product"), |
| 37 | ("codex vs claude code", "comparison"), |
| 38 | ("openclaw vs nanoclaw vs ironclaw", "comparison"), |
| 39 | ("anthropic odds", "prediction"), |
| 40 | ("kanye west", "breaking_news"), |
| 41 | ("remotion animations for Claude Code", "how_to"), |
| 42 | ] |
| 43 | |
| 44 | |
| 45 | DEFAULT_TOPICS = _load_default_topics() |
| 46 | DEFAULT_SEARCH = "" |
| 47 | DEFAULT_JUDGE_MODEL = GEMINI_FLASH_LITE |
| 48 | GEMINI_API_URL = "https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={api_key}" |
| 49 | EVAL_CREDENTIAL_ENV_KEYS = ( |
| 50 | "GOOGLE_API_KEY", |
| 51 | "GEMINI_API_KEY", |
| 52 | "GOOGLE_GENAI_API_KEY", |
| 53 | "OPENAI_API_KEY", |
| 54 | "XAI_API_KEY", |
| 55 | "SCRAPECREATORS_API_KEY", |
| 56 | "BSKY_HANDLE", |
| 57 | "BSKY_APP_PASSWORD", |
| 58 | "TRUTHSOCIAL_TOKEN", |
| 59 | "AUTH_TOKEN", |
| 60 | "CT0", |
| 61 | ) |
| 62 | |
| 63 | |
| 64 | def stable_item_key(item: dict[str, Any]) -> str: |
| 65 | return str(item.get("candidate_id") or item.get("url") or item.get("title") or "") |
| 66 | |
| 67 | |
| 68 | def row_sources(row: dict[str, Any]) -> list[str]: |
| 69 | candidate = schema.candidate_from_dict(row) |
| 70 | return schema.candidate_sources(candidate) |
| 71 | |
| 72 | |
| 73 | def row_best_date(row: dict[str, Any]) -> str | None: |
| 74 | candidate = schema.candidate_from_dict(row) |
| 75 | return schema.candidate_best_published_at(candidate) |
| 76 | |
| 77 | |
| 78 | V2_SOURCE_KEYS = [ |
| 79 | ("reddit", "title"), |
| 80 | ("x", "text"), |
| 81 | ("youtube", "title"), |
| 82 | ("tiktok", "text"), |
| 83 | ("instagram", "text"), |
| 84 | ("hackernews", "title"), |
| 85 | ("bluesky", "text"), |
| 86 | ("truthsocial", "text"), |
| 87 | ("polymarket", "question"), |
| 88 | ("web", "title"), |
| 89 | ] |
| 90 | |
| 91 | |
| 92 | def build_ranked_items(report: dict[str, Any], limit: int) -> list[dict[str, Any]]: |
| 93 | # v3 format: ranked_candidates list |
| 94 | if report.get("ranked_candidates"): |
| 95 | ranked = [] |
| 96 | for row in report["ranked_candidates"][:limit]: |
| 97 | candidate_sources = row_sources(row) |
| 98 | ranked.append({ |
| 99 | "key": stable_item_key(row), |
| 100 | "source": ", ".join(candidate_sources), |
| 101 | "sources": candidate_sources, |
| 102 | "url": str(row.get("url") or ""), |
| 103 | "text": str(row.get("title") or ""), |
| 104 | "date": row_best_date(row), |
| 105 | "score": float(row.get("final_score") or 0.0), |
| 106 | }) |
| 107 | return ranked |
| 108 | |
| 109 | # v2 format: per-source lists (reddit, x, youtube, etc.) |
| 110 | all_items = [] |
| 111 | for source_key, text_field in V2_SOURCE_KEYS: |
| 112 | for item in report.get(source_key) or []: |
| 113 | if not isinstance(item, dict): |
| 114 | continue |
| 115 | all_items.append({ |
| 116 | "key": str(item.get("url") or item.get("id") or item.get(text_field) or ""), |
| 117 | "source": source_key, |
| 118 | "sources": [source_key], |
| 119 | "url": str(item.get("url") or ""), |
| 120 | "text": str(item.get(text_field) or item.get("title") or ""), |
| 121 | "date": item.get("date"), |
| 122 | "score": float(item.get("score") or 0.0), |
| 123 | }) |
| 124 | all_items.sort(key=lambda x: x["score"], reverse=True) |
| 125 | return all_items[:limit] |
| 126 | |
| 127 | |
| 128 | def source_sets(report: dict[str, Any], limit: int) -> dict[str, set[str]]: |
| 129 | grouped: dict[str, set[str]] = {} |
| 130 | for item in build_ranked_items(report, limit): |
| 131 | for source in item["sources"]: |
| 132 | grouped.setdefault(source, set()).add(item["key"]) |
| 133 | return grouped |
| 134 | |
| 135 | |
| 136 | def jaccard(left: set[str], right: set[str]) -> float: |
| 137 | if not left and not right: |
| 138 | return 1.0 |
| 139 | union = left | right |
| 140 | if not union: |
| 141 | return 1.0 |
| 142 | return len(left & right) / len(union) |
| 143 | |
| 144 | |
| 145 | def retention(left: set[str], right: set[str]) -> float: |
| 146 | if not left: |
| 147 | return 1.0 |
| 148 | return len(left & right) / len(left) |
| 149 | |
| 150 | |
| 151 | def precision_at_k(ranking: list[dict[str, Any]], judgments: dict[str, int], k: int) -> float: |
| 152 | top = ranking[:k] |
| 153 | if not top: |
| 154 | return 0.0 |
| 155 | return sum(1 for item in top if judgments.get(item["key"], 0) >= 2) / len(top) |
| 156 | |
| 157 | |
| 158 | def ndcg_at_k(ranking: list[dict[str, Any]], judgments: dict[str, int], k: int, judged_pool: list[dict[str, Any]]) -> float: |
| 159 | top = ranking[:k] |
| 160 | if not top: |
| 161 | return 0.0 |
| 162 | |
| 163 | def dcg(grades: list[int]) -> float: |
| 164 | total = 0.0 |
| 165 | for index, grade in enumerate(grades, start=1): |
| 166 | total += (2**grade - 1) / math.log2(index + 1) |
| 167 | return total |
| 168 | |
| 169 | actual = [judgments.get(item["key"], 0) for item in top] |
| 170 | ideal = sorted((judgments.get(item["key"], 0) for item in judged_pool), reverse=True)[: len(top)] |
| 171 | ideal_score = dcg(ideal) |
| 172 | if ideal_score == 0: |
| 173 | return 0.0 |
| 174 | return dcg(actual) / ideal_score |
| 175 | |
| 176 | |
| 177 | def source_coverage_recall(ranking: list[dict[str, Any]], judged_pool: list[dict[str, Any]], judgments: dict[str, int]) -> float: |
| 178 | good_sources = { |
| 179 | source |
| 180 | for item in judged_pool |
| 181 | if judgments.get(item["key"], 0) >= 2 |
| 182 | for source in item["sources"] |
| 183 | } |
| 184 | if not good_sources: |
| 185 | return 1.0 |
| 186 | hit_sources = { |
| 187 | source |
| 188 | for item in ranking |
| 189 | if judgments.get(item["key"], 0) >= 2 |
| 190 | for source in item["sources"] |
| 191 | } |
| 192 | return len(hit_sources & good_sources) / len(good_sources) |
| 193 | |
| 194 | |
| 195 | def resolve_google_judge_api_key(config: dict[str, Any]) -> str | None: |
| 196 | return ( |
| 197 | os.environ.get("GOOGLE_API_KEY") |
| 198 | or config.get("GOOGLE_API_KEY") |
| 199 | or os.environ.get("GEMINI_API_KEY") |
| 200 | or config.get("GEMINI_API_KEY") |
| 201 | or os.environ.get("GOOGLE_GENAI_API_KEY") |
| 202 | or config.get("GOOGLE_GENAI_API_KEY") |
| 203 | ) |
| 204 | |
| 205 | |
| 206 | def extract_gemini_text(payload: dict[str, Any]) -> str: |
| 207 | for candidate in payload.get("candidates") or []: |
| 208 | content = candidate.get("content") or {} |
| 209 | for part in content.get("parts") or []: |
| 210 | if part.get("text"): |
| 211 | return part["text"] |
| 212 | raise ValueError("Gemini response did not contain text.") |
| 213 | |
| 214 | |
| 215 | def call_gemini_judge(api_key: str, model: str, prompt: str) -> dict[str, Any]: |
| 216 | body = { |
| 217 | "contents": [{"parts": [{"text": prompt}]}], |
| 218 | "generationConfig": {"temperature": 0, "responseMimeType": "application/json"}, |
| 219 | } |
| 220 | request = Request( |
| 221 | GEMINI_API_URL.format(model=model, api_key=api_key), |
| 222 | data=json.dumps(body).encode("utf-8"), |
| 223 | headers={"Content-Type": "application/json"}, |
| 224 | method="POST", |
| 225 | ) |
| 226 | try: |
| 227 | with urlopen(request, timeout=120) as response: |
| 228 | payload = json.loads(response.read().decode("utf-8")) |
| 229 | except HTTPError as exc: |
| 230 | detail = exc.read().decode("utf-8", errors="replace") |
| 231 | raise RuntimeError(f"Gemini HTTP {exc.code}: {detail}") from exc |
| 232 | except URLError as exc: |
| 233 | raise RuntimeError(f"Gemini request failed: {exc}") from exc |
| 234 | return json.loads(extract_gemini_text(payload)) |
| 235 | |
| 236 | |
| 237 | def build_judge_prompt(topic: str, query_type: str, items: list[dict[str, Any]]) -> str: |
| 238 | item_lines = [] |
| 239 | for item in items: |
| 240 | item_lines.append( |
| 241 | "\n".join([ |
| 242 | f"- id: {item['key']}", |
| 243 | f" source: {item['source']}", |
| 244 | f" title: {item['text'][:220]}", |
| 245 | f" url: {item['url']}", |
| 246 | f" date: {item.get('date') or 'unknown'}", |
| 247 | ]) |
| 248 | ) |
| 249 | return f""" |
| 250 | Judge search-result relevance for a last-30-days research tool. |
| 251 | |
| 252 | Topic: {topic} |
| 253 | Query type: {query_type} |
| 254 | |
| 255 | Score each item on this 0-3 scale: |
| 256 | - 0 = off-topic or clearly bad |
| 257 | - 1 = weak or tangential |
| 258 | - 2 = relevant and useful |
| 259 | - 3 = highly relevant, one of the best results |
| 260 | |
| 261 | Return JSON only: |
| 262 | {{ |
| 263 | "judgments": [ |
| 264 | {{"id": "ITEM_ID", "grade": 0}} |
| 265 | ] |
| 266 | }} |
| 267 | |
| 268 | Items: |
| 269 | {chr(10).join(item_lines)} |
| 270 | """.strip() |
| 271 | |
| 272 | |
| 273 | def get_judgments( |
| 274 | *, |
| 275 | output_dir: Path, |
| 276 | slug: str, |
| 277 | topic: str, |
| 278 | query_type: str, |
| 279 | items: list[dict[str, Any]], |
| 280 | judge_model: str, |
| 281 | gemini_api_key: str | None, |
| 282 | ) -> dict[str, int]: |
| 283 | cache_file = output_dir / "judgments" / f"{slug}.json" |
| 284 | cache_file.parent.mkdir(parents=True, exist_ok=True) |
| 285 | stale_cache = False |
| 286 | if cache_file.exists(): |
| 287 | payload = json.loads(cache_file.read_text()) |
| 288 | # The cache key is the topic slug alone, but judgments are model- |
| 289 | # specific. Only reuse the cache when it was produced by the same judge |
| 290 | # model; otherwise re-judge, so a --judge-model change cannot return |
| 291 | # stale grades that silently skew precision@k / nDCG. Caches written |
| 292 | # before judge_model was recorded miss here and get refreshed once. |
| 293 | if payload.get("judge_model") == judge_model: |
| 294 | return {row["id"]: int(row["grade"]) for row in payload.get("judgments") or []} |
| 295 | stale_cache = True |
| 296 | if not gemini_api_key or not items: |
| 297 | if stale_cache: |
| 298 | # Discarded a different-model cache but can't re-judge. Returning {} |
| 299 | # scores every item as ungraded (zero precision@k / nDCG); say so |
| 300 | # rather than letting the run report silently wrong numbers. |
| 301 | sys.stderr.write( |
| 302 | f"[Eval] Cached judgments for {slug!r} were graded by a different " |
| 303 | f"judge model and no Gemini API key is set to re-judge; returning " |
| 304 | f"no grades (metrics for this topic will be zero).\n" |
| 305 | ) |
| 306 | return {} |
| 307 | payload = call_gemini_judge(gemini_api_key, judge_model, build_judge_prompt(topic, query_type, items)) |
| 308 | payload["judge_model"] = judge_model |
| 309 | cache_file.write_text(json.dumps(payload, indent=2)) |
| 310 | return {row["id"]: int(row["grade"]) for row in payload.get("judgments") or []} |
| 311 | |
| 312 | |
| 313 | def create_eval_env() -> dict[str, str]: |
| 314 | config = envlib.get_config() |
| 315 | passthrough = { |
| 316 | "PATH": os.environ.get("PATH", ""), |
| 317 | "LANG": os.environ.get("LANG", "en_US.UTF-8"), |
| 318 | "LC_ALL": os.environ.get("LC_ALL", ""), |
| 319 | "TMPDIR": os.environ.get("TMPDIR", ""), |
| 320 | "PYTHONUTF8": "1", |
| 321 | "LAST30DAYS_CONFIG_DIR": "", |
| 322 | } |
| 323 | for key in EVAL_CREDENTIAL_ENV_KEYS: |
| 324 | value = os.environ.get(key) or config.get(key) |
| 325 | if value: |
| 326 | passthrough[key] = value |
| 327 | return passthrough |
| 328 | |
| 329 | |
| 330 | def run_last30days(repo_dir: Path, topic: str, *, search: str, timeout_seconds: int, quick: bool, mock: bool, env: dict[str, str]) -> dict[str, Any]: |
| 331 | engine = repo_dir / "skills" / "last30days" / "scripts" / "last30days.py" |
| 332 | if not engine.exists(): |
| 333 | engine = repo_dir / "scripts" / "last30days.py" |
| 334 | cmd = [sys.executable, str(engine), topic, "--emit=json"] |
| 335 | # Current engines default to the stable agent export, while older revisions |
| 336 | # used by the evaluator implicitly emit the raw report and do not recognize |
| 337 | # --json-profile. Request raw explicitly whenever the checked-out engine |
| 338 | # supports the selector. |
| 339 | if not engine.exists() or "--json-profile" in engine.read_text(encoding="utf-8"): |
| 340 | cmd.append("--json-profile=raw") |
| 341 | if search: |
| 342 | cmd.extend(["--search", search]) |
| 343 | if quick: |
| 344 | cmd.append("--quick") |
| 345 | if mock: |
| 346 | cmd.append("--mock") |
| 347 | result = subprocess.run( |
| 348 | cmd, |
| 349 | cwd=repo_dir, |
| 350 | env=env, |
| 351 | capture_output=True, |
| 352 | text=True, |
| 353 | timeout=timeout_seconds, |
| 354 | check=False, |
| 355 | ) |
| 356 | if result.returncode != 0: |
| 357 | raise RuntimeError(f"{repo_dir.name} failed for '{topic}' with exit {result.returncode}\n{result.stderr.strip()}") |
| 358 | payload = json.loads(result.stdout) |
| 359 | # Shape guard: the evaluator compares raw Report fields. If the engine |
| 360 | # emitted the agent profile anyway (flag detection missed a future |
| 361 | # spelling), fail loudly instead of scoring empty ranked_candidates. |
| 362 | if "schema_version" in payload and "ranked_candidates" not in payload: |
| 363 | raise RuntimeError( |
| 364 | f"{repo_dir.name} emitted the agent JSON profile; the evaluator " |
| 365 | "requires the raw Report (--json-profile=raw)." |
| 366 | ) |
| 367 | return payload |
| 368 | |
| 369 | |
| 370 | def create_worktree(rev: str) -> Path: |
| 371 | worktree_dir = Path(tempfile.mkdtemp(prefix="last30days-eval-")) |
| 372 | subprocess.run( |
| 373 | ["git", "worktree", "add", "--detach", str(worktree_dir), rev], |
| 374 | cwd=REPO_ROOT, |
| 375 | check=True, |
| 376 | capture_output=True, |
| 377 | text=True, |
| 378 | ) |
| 379 | return worktree_dir |
| 380 | |
| 381 | |
| 382 | def resolve_repo_dir(label: str) -> tuple[Path, bool]: |
| 383 | """Resolve a benchmark label into a repo directory and whether it is temporary.""" |
| 384 | if label == "WORKTREE": |
| 385 | return REPO_ROOT, False |
| 386 | return create_worktree(label), True |
| 387 | |
| 388 | |
| 389 | def remove_worktree(path: Path) -> None: |
| 390 | subprocess.run( |
| 391 | ["git", "worktree", "remove", "--force", str(path)], |
| 392 | cwd=REPO_ROOT, |
| 393 | check=False, |
| 394 | capture_output=True, |
| 395 | text=True, |
| 396 | ) |
| 397 | try: |
| 398 | os.rmdir(path) |
| 399 | except OSError: |
| 400 | pass |
| 401 | |
| 402 | |
| 403 | def summarize_topic(topic: str, query_type: str, baseline_report: dict[str, Any], candidate_report: dict[str, Any], judgments: dict[str, int], judged_pool: list[dict[str, Any]], limit: int) -> dict[str, Any]: |
| 404 | baseline_ranked = build_ranked_items(baseline_report, limit) |
| 405 | candidate_ranked = build_ranked_items(candidate_report, limit) |
| 406 | baseline_sets = source_sets(baseline_report, limit) |
| 407 | candidate_sets = source_sets(candidate_report, limit) |
| 408 | overall_left = set().union(*baseline_sets.values()) if baseline_sets else set() |
| 409 | overall_right = set().union(*candidate_sets.values()) if candidate_sets else set() |
| 410 | sources = sorted(set(baseline_sets) | set(candidate_sets)) |
| 411 | return { |
| 412 | "topic": topic, |
| 413 | "query_type": query_type, |
| 414 | "baseline": { |
| 415 | "precision_at_5": precision_at_k(baseline_ranked, judgments, 5), |
| 416 | "ndcg_at_5": ndcg_at_k(baseline_ranked, judgments, 5, judged_pool), |
| 417 | "source_coverage_recall": source_coverage_recall(baseline_ranked, judged_pool, judgments), |
| 418 | }, |
| 419 | "candidate": { |
| 420 | "precision_at_5": precision_at_k(candidate_ranked, judgments, 5), |
| 421 | "ndcg_at_5": ndcg_at_k(candidate_ranked, judgments, 5, judged_pool), |
| 422 | "source_coverage_recall": source_coverage_recall(candidate_ranked, judged_pool, judgments), |
| 423 | }, |
| 424 | "stability": { |
| 425 | "overall_jaccard": jaccard(overall_left, overall_right), |
| 426 | "overall_retention_vs_baseline": retention(overall_left, overall_right), |
| 427 | "per_source": { |
| 428 | source: { |
| 429 | "baseline_count": len(baseline_sets.get(source, set())), |
| 430 | "candidate_count": len(candidate_sets.get(source, set())), |
| 431 | "jaccard": jaccard(baseline_sets.get(source, set()), candidate_sets.get(source, set())), |
| 432 | "retention_vs_baseline": retention(baseline_sets.get(source, set()), candidate_sets.get(source, set())), |
| 433 | } |
| 434 | for source in sources |
| 435 | }, |
| 436 | }, |
| 437 | } |
| 438 | |
| 439 | |
| 440 | def write_summary(output_dir: Path, baseline_label: str, candidate_label: str, summaries: list[dict[str, Any]]) -> None: |
| 441 | output_dir.mkdir(parents=True, exist_ok=True) |
| 442 | payload = { |
| 443 | "generated_at": datetime.now().isoformat(timespec="seconds"), |
| 444 | "baseline": baseline_label, |
| 445 | "candidate": candidate_label, |
| 446 | "topics": summaries, |
| 447 | } |
| 448 | (output_dir / "metrics.json").write_text(json.dumps(payload, indent=2)) |
| 449 | |
| 450 | lines = [ |
| 451 | "# Search Quality Evaluation", |
| 452 | "", |
| 453 | f"- Baseline: `{baseline_label}`", |
| 454 | f"- Candidate: `{candidate_label}`", |
| 455 | f"- Generated: {payload['generated_at']}", |
| 456 | "", |
| 457 | "| Topic | Base P@5 | Cand P@5 | Base nDCG@5 | Cand nDCG@5 | Jaccard | Retention |", |
| 458 | "|---|---:|---:|---:|---:|---:|---:|", |
| 459 | ] |
| 460 | for row in summaries: |
| 461 | lines.append( |
| 462 | "| {topic} | {bp:.2f} | {cp:.2f} | {bn:.2f} | {cn:.2f} | {jac:.2f} | {ret:.2f} |".format( |
| 463 | topic=row["topic"], |
| 464 | bp=row["baseline"]["precision_at_5"], |
| 465 | cp=row["candidate"]["precision_at_5"], |
| 466 | bn=row["baseline"]["ndcg_at_5"], |
| 467 | cn=row["candidate"]["ndcg_at_5"], |
| 468 | jac=row["stability"]["overall_jaccard"], |
| 469 | ret=row["stability"]["overall_retention_vs_baseline"], |
| 470 | ) |
| 471 | ) |
| 472 | (output_dir / "summary.md").write_text("\n".join(lines) + "\n") |
| 473 | |
| 474 | |
| 475 | def write_failure_summary( |
| 476 | output_dir: Path, |
| 477 | baseline_label: str, |
| 478 | candidate_label: str, |
| 479 | summaries: list[dict[str, Any]], |
| 480 | failures: list[dict[str, Any]], |
| 481 | ) -> None: |
| 482 | write_summary(output_dir, baseline_label, candidate_label, summaries) |
| 483 | metrics_path = output_dir / "metrics.json" |
| 484 | payload = json.loads(metrics_path.read_text()) if metrics_path.exists() else { |
| 485 | "generated_at": datetime.now().isoformat(timespec="seconds"), |
| 486 | "baseline": baseline_label, |
| 487 | "candidate": candidate_label, |
| 488 | "topics": [], |
| 489 | } |
| 490 | payload["failures"] = failures |
| 491 | metrics_path.write_text(json.dumps(payload, indent=2)) |
| 492 | |
| 493 | summary_path = output_dir / "summary.md" |
| 494 | lines = summary_path.read_text().splitlines() if summary_path.exists() else ["# Search Quality Evaluation", ""] |
| 495 | if failures: |
| 496 | lines.extend([ |
| 497 | "", |
| 498 | "## Failures", |
| 499 | "", |
| 500 | ]) |
| 501 | for failure in failures: |
| 502 | lines.append(f"- `{failure['topic']}`: {failure['error']}") |
| 503 | summary_path.write_text("\n".join(lines).rstrip() + "\n") |
| 504 | |
| 505 | |
| 506 | def parse_topics_file(path: Path) -> list[tuple[str, str]]: |
| 507 | rows = json.loads(path.read_text()) |
| 508 | return [(str(row["topic"]), str(row.get("query_type") or "general")) for row in rows] |
| 509 | |
| 510 | |
| 511 | def build_parser() -> argparse.ArgumentParser: |
| 512 | parser = argparse.ArgumentParser(description="Compare two last30days revisions on ranked candidate quality") |
| 513 | parser.add_argument("--baseline", default="HEAD~1") |
| 514 | parser.add_argument("--candidate", default="WORKTREE") |
| 515 | parser.add_argument("--search", default=DEFAULT_SEARCH) |
| 516 | parser.add_argument("--output-dir", default="tmp/search-quality") |
| 517 | parser.add_argument("--judge-model", default=DEFAULT_JUDGE_MODEL) |
| 518 | parser.add_argument("--timeout", type=int, default=240) |
| 519 | parser.add_argument("--limit", type=int, default=20) |
| 520 | parser.add_argument("--mock", action="store_true") |
| 521 | parser.add_argument("--quick", action="store_true") |
| 522 | parser.add_argument("--topics-file") |
| 523 | return parser |
| 524 | |
| 525 | |
| 526 | def main() -> int: |
| 527 | args = build_parser().parse_args() |
| 528 | topics = parse_topics_file(Path(args.topics_file)) if args.topics_file else DEFAULT_TOPICS |
| 529 | output_dir = Path(args.output_dir).resolve() |
| 530 | config = envlib.get_config() |
| 531 | gemini_api_key = resolve_google_judge_api_key(config) |
| 532 | run_env = create_eval_env() |
| 533 | |
| 534 | baseline_dir, baseline_temp = resolve_repo_dir(args.baseline) |
| 535 | candidate_dir, candidate_temp = resolve_repo_dir(args.candidate) |
| 536 | try: |
| 537 | summaries = [] |
| 538 | failures = [] |
| 539 | for topic, query_type in topics: |
| 540 | try: |
| 541 | baseline_report = run_last30days( |
| 542 | baseline_dir, |
| 543 | topic, |
| 544 | search=args.search, |
| 545 | timeout_seconds=args.timeout, |
| 546 | quick=args.quick, |
| 547 | mock=args.mock, |
| 548 | env=run_env, |
| 549 | ) |
| 550 | candidate_report = run_last30days( |
| 551 | candidate_dir, |
| 552 | topic, |
| 553 | search=args.search, |
| 554 | timeout_seconds=args.timeout, |
| 555 | quick=args.quick, |
| 556 | mock=args.mock, |
| 557 | env=run_env, |
| 558 | ) |
| 559 | judged_pool_map = { |
| 560 | item["key"]: item |
| 561 | for item in build_ranked_items(baseline_report, args.limit) + build_ranked_items(candidate_report, args.limit) |
| 562 | } |
| 563 | judged_pool = list(judged_pool_map.values()) |
| 564 | judgments = get_judgments( |
| 565 | output_dir=output_dir, |
| 566 | slug="".join(char.lower() if char.isalnum() else "-" for char in topic).strip("-"), |
| 567 | topic=topic, |
| 568 | query_type=query_type, |
| 569 | items=judged_pool, |
| 570 | judge_model=args.judge_model, |
| 571 | gemini_api_key=gemini_api_key, |
| 572 | ) |
| 573 | summaries.append(summarize_topic(topic, query_type, baseline_report, candidate_report, judgments, judged_pool, args.limit)) |
| 574 | except Exception as exc: |
| 575 | failures.append({"topic": topic, "query_type": query_type, "error": str(exc)}) |
| 576 | write_failure_summary(output_dir, args.baseline, args.candidate, summaries, failures) |
| 577 | finally: |
| 578 | if baseline_temp: |
| 579 | remove_worktree(baseline_dir) |
| 580 | if candidate_temp: |
| 581 | remove_worktree(candidate_dir) |
| 582 | result = {"output_dir": str(output_dir), "topics": len(topics), "failures": len(failures)} |
| 583 | print(json.dumps(result, indent=2)) |
| 584 | return 1 if failures else 0 |
| 585 | |
| 586 | |
| 587 | if __name__ == "__main__": |
| 588 | raise SystemExit(main()) |
| 589 |