返回 last30days-skill
evaluate_search_quality.py
根目录 / skills / last30days / scripts / evaluate_search_quality.py
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
589 lines PYTHON