luckiest-co 1.0.52 → 1.0.53

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