luckiest-co 1.0.39 → 1.0.40

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 (141) 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/ad-creative/CHANGELOG.md +7 -0
  5. package/skills/ad-creative/SKILL.md +23 -4
  6. package/skills/ad-creative/references/generative-tools.md +6 -30
  7. package/skills/ads/CHANGELOG.md +9 -0
  8. package/skills/ads/SKILL.md +19 -9
  9. package/skills/ads/references/reading-google-ads-data.md +121 -0
  10. package/skills/ai-seo/CHANGELOG.md +11 -0
  11. package/skills/ai-seo/SKILL.md +21 -22
  12. package/skills/ai-seo/references/content-types.md +11 -0
  13. package/skills/ai-seo/references/linkedin-ai-citations.md +122 -0
  14. package/skills/ai-seo/references/platform-ranking-factors.md +32 -19
  15. package/skills/ai-seo/references/positioning-and-consensus.md +102 -0
  16. package/skills/coder/CHANGELOG.md +23 -0
  17. package/skills/coder/SKILL.md +16 -4
  18. package/skills/coder-brainstorming/CHANGELOG.md +9 -0
  19. package/skills/coder-brainstorming/SKILL.md +5 -3
  20. package/skills/coder-debugging/CHANGELOG.md +11 -0
  21. package/skills/coder-debugging/SKILL.md +15 -3
  22. package/skills/coder-executing-plans/CHANGELOG.md +9 -0
  23. package/skills/coder-executing-plans/SKILL.md +5 -3
  24. package/skills/coder-finishing-a-branch/CHANGELOG.md +9 -0
  25. package/skills/coder-finishing-a-branch/SKILL.md +7 -3
  26. package/skills/coder-guard/CHANGELOG.md +7 -0
  27. package/skills/coder-guard/SKILL.md +6 -6
  28. package/skills/coder-receiving-code-review/CHANGELOG.md +9 -0
  29. package/skills/coder-receiving-code-review/SKILL.md +5 -3
  30. package/skills/coder-shipping/CHANGELOG.md +9 -0
  31. package/skills/coder-shipping/SKILL.md +4 -4
  32. package/skills/coder-subagent-driven-development/CHANGELOG.md +9 -0
  33. package/skills/coder-subagent-driven-development/SKILL.md +5 -3
  34. package/skills/coder-writing-plans/CHANGELOG.md +9 -0
  35. package/skills/coder-writing-plans/SKILL.md +5 -3
  36. package/skills/cold-email/CHANGELOG.md +7 -0
  37. package/skills/cold-email/SKILL.md +15 -8
  38. package/skills/cold-email/references/follow-up-sequences.md +4 -4
  39. package/skills/cold-email/references/frameworks.md +5 -5
  40. package/skills/cold-email/references/personalization.md +10 -10
  41. package/skills/competitor-profiling/CHANGELOG.md +8 -0
  42. package/skills/competitor-profiling/SKILL.md +34 -9
  43. package/skills/competitor-profiling/references/tool-reference.md +141 -5
  44. package/skills/competitors/CHANGELOG.md +8 -0
  45. package/skills/competitors/SKILL.md +32 -5
  46. package/skills/competitors/references/content-architecture.md +6 -0
  47. package/skills/competitors/references/templates.md +3 -0
  48. package/skills/copy-editing/CHANGELOG.md +10 -0
  49. package/skills/copy-editing/SKILL.md +41 -9
  50. package/skills/copy-editing/references/checklist.md +11 -0
  51. package/skills/copywriting/CHANGELOG.md +11 -0
  52. package/skills/copywriting/SKILL.md +32 -4
  53. package/skills/copywriting/references/copy-frameworks.md +2 -3
  54. package/skills/copywriting/references/natural-transitions.md +4 -20
  55. package/skills/customer-research/CHANGELOG.md +8 -0
  56. package/skills/customer-research/SKILL.md +6 -3
  57. package/skills/directory-submissions/CHANGELOG.md +9 -0
  58. package/skills/directory-submissions/SKILL.md +21 -10
  59. package/skills/emails/CHANGELOG.md +8 -0
  60. package/skills/emails/SKILL.md +22 -3
  61. package/skills/emails/references/sequence-templates.md +3 -3
  62. package/skills/image/CHANGELOG.md +8 -0
  63. package/skills/image/SKILL.md +4 -4
  64. package/skills/image/references/ai-image-prompting.md +1 -1
  65. package/skills/marketing-plan/CHANGELOG.md +8 -0
  66. package/skills/marketing-plan/SKILL.md +8 -4
  67. package/skills/marketing-plan/references/market-sizing.md +166 -0
  68. package/skills/marketing-plan/references/methodology.md +2 -0
  69. package/skills/model-router/CHANGELOG.md +9 -0
  70. package/skills/model-router/SKILL.md +3 -3
  71. package/skills/model-router/references/weave-routing-notes.md +1 -0
  72. package/skills/model-router/scripts/route.py +3 -1
  73. package/skills/prospecting/CHANGELOG.md +7 -0
  74. package/skills/prospecting/SKILL.md +3 -3
  75. package/skills/prospecting/references/saas-prospecting.md +1 -1
  76. package/skills/public-relations/CHANGELOG.md +10 -0
  77. package/skills/public-relations/SKILL.md +16 -4
  78. package/skills/public-relations/references/crisis-communications.md +111 -0
  79. package/skills/public-relations/references/newsjacking.md +2 -0
  80. package/skills/public-relations/references/statement-templates.md +143 -0
  81. package/skills/sales-enablement/CHANGELOG.md +8 -0
  82. package/skills/sales-enablement/SKILL.md +72 -4
  83. package/skills/sales-enablement/references/win-loss-analysis.md +72 -0
  84. package/skills/skill-audit/ATTRIBUTION.md +5 -0
  85. package/skills/skill-audit/CHANGELOG.md +19 -0
  86. package/skills/skill-audit/SKILL.md +121 -0
  87. package/skills/skill-audit-security/ATTRIBUTION.md +14 -0
  88. package/skills/skill-audit-security/CHANGELOG.md +14 -0
  89. package/skills/skill-audit-security/LICENSE-APACHE-2.0 +190 -0
  90. package/skills/skill-audit-security/SKILL.md +99 -0
  91. package/skills/skill-audit-security/references/rule-ids.md +28 -0
  92. package/skills/skill-audit-security/references/source-review.md +58 -0
  93. package/skills/sms/CHANGELOG.md +8 -0
  94. package/skills/sms/SKILL.md +18 -4
  95. package/skills/sms/references/whatsapp.md +258 -0
  96. package/skills/social/CHANGELOG.md +11 -0
  97. package/skills/social/SKILL.md +31 -7
  98. package/skills/social/references/listening.md +2 -0
  99. package/skills/social/references/platforms.md +15 -9
  100. package/skills/social/references/post-templates.md +9 -9
  101. package/skills/social/references/short-form-video.md +5 -5
  102. package/skills/social/references/x-algorithm.md +69 -0
  103. package/skills/trends/CHANGELOG.md +3 -0
  104. package/skills/trends/SKILL.md +14 -4
  105. package/skills/video/CHANGELOG.md +8 -0
  106. package/skills/video/SKILL.md +11 -25
  107. package/skills/video-studio/CHANGELOG.md +16 -0
  108. package/skills/video-studio/SKILL.md +86 -22
  109. package/skills/video-studio/references/interview.md +75 -0
  110. package/skills/video-studio/references/step-2-plan.md +25 -1
  111. package/skills/video-studio/references/storyboard.md +3 -1
  112. package/skills/video-studio/scripts/pyproject.toml +4 -2
  113. package/skills/video-studio/scripts/render-scenes.mjs +1 -0
  114. package/skills/video-studio/scripts/storyboard.schema.json +3 -1
  115. package/skills/video-studio/scripts/uv.lock +8 -5
  116. package/skills/video-studio-director/CHANGELOG.md +7 -0
  117. package/skills/video-studio-director/SKILL.md +19 -6
  118. package/skills/video-studio-review/CHANGELOG.md +11 -0
  119. package/skills/video-studio-review/SKILL.md +31 -5
  120. package/skills/video-studio-review/editor/index.html +21 -3
  121. package/skills/video-studio-review/references/apply-feedback.md +19 -0
  122. package/skills/video-studio-review/scripts/build-reel.mjs +35 -10
  123. package/skills/video-studio-review/scripts/build-reel.test.mjs +15 -0
  124. package/skills/video-studio-script/CHANGELOG.md +5 -0
  125. package/skills/video-studio-script/SKILL.md +16 -7
  126. package/skills/video-studio-showreel/CHANGELOG.md +21 -0
  127. package/skills/video-studio-showreel/SKILL.md +231 -0
  128. package/skills/video-studio-showreel/references/motion-design-pack-notes.md +28 -0
  129. package/skills/video-studio-showreel/references/zingage-breakdown.md +47 -0
  130. package/skills/video-studio-showreel/scripts/build.py +10 -0
  131. package/skills/video-studio-showreel/templates/showreel.html +264 -0
  132. package/skills/video-watcher/CHANGELOG.md +6 -0
  133. package/skills/video-watcher/SKILL.md +3 -3
  134. package/skills/video-watcher/scripts/setup.py +9 -7
  135. package/skills/trends/agents/openai.yaml +0 -8
  136. package/skills/trends/scripts/build-skill.sh +0 -39
  137. package/skills/trends/scripts/compare.sh +0 -61
  138. package/skills/trends/scripts/evaluate_search_quality.py +0 -573
  139. package/skills/trends/scripts/test-v1-vs-v2.sh +0 -220
  140. package/skills/trends/scripts/test_device_auth.py +0 -128
  141. package/skills/trends/scripts/verify_v3.py +0 -195
@@ -1,573 +0,0 @@
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
- if search:
336
- cmd.extend(["--search", search])
337
- if quick:
338
- cmd.append("--quick")
339
- if mock:
340
- cmd.append("--mock")
341
- result = subprocess.run(
342
- cmd,
343
- cwd=repo_dir,
344
- env=env,
345
- capture_output=True,
346
- text=True,
347
- timeout=timeout_seconds,
348
- check=False,
349
- )
350
- if result.returncode != 0:
351
- raise RuntimeError(f"{repo_dir.name} failed for '{topic}' with exit {result.returncode}\n{result.stderr.strip()}")
352
- return json.loads(result.stdout)
353
-
354
-
355
- def create_worktree(rev: str) -> Path:
356
- worktree_dir = Path(tempfile.mkdtemp(prefix="last30days-eval-"))
357
- subprocess.run(
358
- ["git", "worktree", "add", "--detach", str(worktree_dir), rev],
359
- cwd=REPO_ROOT,
360
- check=True,
361
- capture_output=True,
362
- text=True,
363
- )
364
- return worktree_dir
365
-
366
-
367
- def resolve_repo_dir(label: str) -> tuple[Path, bool]:
368
- """Resolve a benchmark label into a repo directory and whether it is temporary."""
369
- if label == "WORKTREE":
370
- return REPO_ROOT, False
371
- return create_worktree(label), True
372
-
373
-
374
- def remove_worktree(path: Path) -> None:
375
- subprocess.run(
376
- ["git", "worktree", "remove", "--force", str(path)],
377
- cwd=REPO_ROOT,
378
- check=False,
379
- capture_output=True,
380
- text=True,
381
- )
382
- try:
383
- os.rmdir(path)
384
- except OSError:
385
- pass
386
-
387
-
388
- 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]:
389
- baseline_ranked = build_ranked_items(baseline_report, limit)
390
- candidate_ranked = build_ranked_items(candidate_report, limit)
391
- baseline_sets = source_sets(baseline_report, limit)
392
- candidate_sets = source_sets(candidate_report, limit)
393
- overall_left = set().union(*baseline_sets.values()) if baseline_sets else set()
394
- overall_right = set().union(*candidate_sets.values()) if candidate_sets else set()
395
- sources = sorted(set(baseline_sets) | set(candidate_sets))
396
- return {
397
- "topic": topic,
398
- "query_type": query_type,
399
- "baseline": {
400
- "precision_at_5": precision_at_k(baseline_ranked, judgments, 5),
401
- "ndcg_at_5": ndcg_at_k(baseline_ranked, judgments, 5, judged_pool),
402
- "source_coverage_recall": source_coverage_recall(baseline_ranked, judged_pool, judgments),
403
- },
404
- "candidate": {
405
- "precision_at_5": precision_at_k(candidate_ranked, judgments, 5),
406
- "ndcg_at_5": ndcg_at_k(candidate_ranked, judgments, 5, judged_pool),
407
- "source_coverage_recall": source_coverage_recall(candidate_ranked, judged_pool, judgments),
408
- },
409
- "stability": {
410
- "overall_jaccard": jaccard(overall_left, overall_right),
411
- "overall_retention_vs_baseline": retention(overall_left, overall_right),
412
- "per_source": {
413
- source: {
414
- "baseline_count": len(baseline_sets.get(source, set())),
415
- "candidate_count": len(candidate_sets.get(source, set())),
416
- "jaccard": jaccard(baseline_sets.get(source, set()), candidate_sets.get(source, set())),
417
- "retention_vs_baseline": retention(baseline_sets.get(source, set()), candidate_sets.get(source, set())),
418
- }
419
- for source in sources
420
- },
421
- },
422
- }
423
-
424
-
425
- def write_summary(output_dir: Path, baseline_label: str, candidate_label: str, summaries: list[dict[str, Any]]) -> None:
426
- output_dir.mkdir(parents=True, exist_ok=True)
427
- payload = {
428
- "generated_at": datetime.now().isoformat(timespec="seconds"),
429
- "baseline": baseline_label,
430
- "candidate": candidate_label,
431
- "topics": summaries,
432
- }
433
- (output_dir / "metrics.json").write_text(json.dumps(payload, indent=2))
434
-
435
- lines = [
436
- "# Search Quality Evaluation",
437
- "",
438
- f"- Baseline: `{baseline_label}`",
439
- f"- Candidate: `{candidate_label}`",
440
- f"- Generated: {payload['generated_at']}",
441
- "",
442
- "| Topic | Base P@5 | Cand P@5 | Base nDCG@5 | Cand nDCG@5 | Jaccard | Retention |",
443
- "|---|---:|---:|---:|---:|---:|---:|",
444
- ]
445
- for row in summaries:
446
- lines.append(
447
- "| {topic} | {bp:.2f} | {cp:.2f} | {bn:.2f} | {cn:.2f} | {jac:.2f} | {ret:.2f} |".format(
448
- topic=row["topic"],
449
- bp=row["baseline"]["precision_at_5"],
450
- cp=row["candidate"]["precision_at_5"],
451
- bn=row["baseline"]["ndcg_at_5"],
452
- cn=row["candidate"]["ndcg_at_5"],
453
- jac=row["stability"]["overall_jaccard"],
454
- ret=row["stability"]["overall_retention_vs_baseline"],
455
- )
456
- )
457
- (output_dir / "summary.md").write_text("\n".join(lines) + "\n")
458
-
459
-
460
- def write_failure_summary(
461
- output_dir: Path,
462
- baseline_label: str,
463
- candidate_label: str,
464
- summaries: list[dict[str, Any]],
465
- failures: list[dict[str, Any]],
466
- ) -> None:
467
- write_summary(output_dir, baseline_label, candidate_label, summaries)
468
- metrics_path = output_dir / "metrics.json"
469
- payload = json.loads(metrics_path.read_text()) if metrics_path.exists() else {
470
- "generated_at": datetime.now().isoformat(timespec="seconds"),
471
- "baseline": baseline_label,
472
- "candidate": candidate_label,
473
- "topics": [],
474
- }
475
- payload["failures"] = failures
476
- metrics_path.write_text(json.dumps(payload, indent=2))
477
-
478
- summary_path = output_dir / "summary.md"
479
- lines = summary_path.read_text().splitlines() if summary_path.exists() else ["# Search Quality Evaluation", ""]
480
- if failures:
481
- lines.extend([
482
- "",
483
- "## Failures",
484
- "",
485
- ])
486
- for failure in failures:
487
- lines.append(f"- `{failure['topic']}`: {failure['error']}")
488
- summary_path.write_text("\n".join(lines).rstrip() + "\n")
489
-
490
-
491
- def parse_topics_file(path: Path) -> list[tuple[str, str]]:
492
- rows = json.loads(path.read_text())
493
- return [(str(row["topic"]), str(row.get("query_type") or "general")) for row in rows]
494
-
495
-
496
- def build_parser() -> argparse.ArgumentParser:
497
- parser = argparse.ArgumentParser(description="Compare two last30days revisions on ranked candidate quality")
498
- parser.add_argument("--baseline", default="HEAD~1")
499
- parser.add_argument("--candidate", default="WORKTREE")
500
- parser.add_argument("--search", default=DEFAULT_SEARCH)
501
- parser.add_argument("--output-dir", default="tmp/search-quality")
502
- parser.add_argument("--judge-model", default=DEFAULT_JUDGE_MODEL)
503
- parser.add_argument("--timeout", type=int, default=240)
504
- parser.add_argument("--limit", type=int, default=20)
505
- parser.add_argument("--mock", action="store_true")
506
- parser.add_argument("--quick", action="store_true")
507
- parser.add_argument("--topics-file")
508
- return parser
509
-
510
-
511
- def main() -> int:
512
- args = build_parser().parse_args()
513
- topics = parse_topics_file(Path(args.topics_file)) if args.topics_file else DEFAULT_TOPICS
514
- output_dir = Path(args.output_dir).resolve()
515
- config = envlib.get_config()
516
- gemini_api_key = resolve_google_judge_api_key(config)
517
- run_env = create_eval_env()
518
-
519
- baseline_dir, baseline_temp = resolve_repo_dir(args.baseline)
520
- candidate_dir, candidate_temp = resolve_repo_dir(args.candidate)
521
- try:
522
- summaries = []
523
- failures = []
524
- for topic, query_type in topics:
525
- try:
526
- baseline_report = run_last30days(
527
- baseline_dir,
528
- topic,
529
- search=args.search,
530
- timeout_seconds=args.timeout,
531
- quick=args.quick,
532
- mock=args.mock,
533
- env=run_env,
534
- )
535
- candidate_report = run_last30days(
536
- candidate_dir,
537
- topic,
538
- search=args.search,
539
- timeout_seconds=args.timeout,
540
- quick=args.quick,
541
- mock=args.mock,
542
- env=run_env,
543
- )
544
- judged_pool_map = {
545
- item["key"]: item
546
- for item in build_ranked_items(baseline_report, args.limit) + build_ranked_items(candidate_report, args.limit)
547
- }
548
- judged_pool = list(judged_pool_map.values())
549
- judgments = get_judgments(
550
- output_dir=output_dir,
551
- slug="".join(char.lower() if char.isalnum() else "-" for char in topic).strip("-"),
552
- topic=topic,
553
- query_type=query_type,
554
- items=judged_pool,
555
- judge_model=args.judge_model,
556
- gemini_api_key=gemini_api_key,
557
- )
558
- summaries.append(summarize_topic(topic, query_type, baseline_report, candidate_report, judgments, judged_pool, args.limit))
559
- except Exception as exc:
560
- failures.append({"topic": topic, "query_type": query_type, "error": str(exc)})
561
- write_failure_summary(output_dir, args.baseline, args.candidate, summaries, failures)
562
- finally:
563
- if baseline_temp:
564
- remove_worktree(baseline_dir)
565
- if candidate_temp:
566
- remove_worktree(candidate_dir)
567
- result = {"output_dir": str(output_dir), "topics": len(topics), "failures": len(failures)}
568
- print(json.dumps(result, indent=2))
569
- return 1 if failures else 0
570
-
571
-
572
- if __name__ == "__main__":
573
- raise SystemExit(main())