luckiest-co 1.0.39 → 1.0.41
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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/package.json +1 -1
- package/skills/ad-creative/CHANGELOG.md +7 -0
- package/skills/ad-creative/SKILL.md +23 -4
- package/skills/ad-creative/references/generative-tools.md +6 -30
- package/skills/ads/CHANGELOG.md +9 -0
- package/skills/ads/SKILL.md +19 -9
- package/skills/ads/references/reading-google-ads-data.md +121 -0
- package/skills/ai-seo/CHANGELOG.md +11 -0
- package/skills/ai-seo/SKILL.md +21 -22
- package/skills/ai-seo/references/content-types.md +11 -0
- package/skills/ai-seo/references/linkedin-ai-citations.md +122 -0
- package/skills/ai-seo/references/platform-ranking-factors.md +32 -19
- package/skills/ai-seo/references/positioning-and-consensus.md +102 -0
- package/skills/coder/CHANGELOG.md +23 -0
- package/skills/coder/SKILL.md +16 -4
- package/skills/coder-brainstorming/CHANGELOG.md +9 -0
- package/skills/coder-brainstorming/SKILL.md +5 -3
- package/skills/coder-debugging/CHANGELOG.md +11 -0
- package/skills/coder-debugging/SKILL.md +15 -3
- package/skills/coder-executing-plans/CHANGELOG.md +9 -0
- package/skills/coder-executing-plans/SKILL.md +5 -3
- package/skills/coder-finishing-a-branch/CHANGELOG.md +9 -0
- package/skills/coder-finishing-a-branch/SKILL.md +7 -3
- package/skills/coder-guard/CHANGELOG.md +7 -0
- package/skills/coder-guard/SKILL.md +6 -6
- package/skills/coder-receiving-code-review/CHANGELOG.md +9 -0
- package/skills/coder-receiving-code-review/SKILL.md +5 -3
- package/skills/coder-shipping/CHANGELOG.md +9 -0
- package/skills/coder-shipping/SKILL.md +4 -4
- package/skills/coder-subagent-driven-development/CHANGELOG.md +9 -0
- package/skills/coder-subagent-driven-development/SKILL.md +5 -3
- package/skills/coder-writing-plans/CHANGELOG.md +9 -0
- package/skills/coder-writing-plans/SKILL.md +5 -3
- package/skills/cold-email/CHANGELOG.md +7 -0
- package/skills/cold-email/SKILL.md +15 -8
- package/skills/cold-email/references/follow-up-sequences.md +4 -4
- package/skills/cold-email/references/frameworks.md +5 -5
- package/skills/cold-email/references/personalization.md +10 -10
- package/skills/competitor-profiling/CHANGELOG.md +8 -0
- package/skills/competitor-profiling/SKILL.md +34 -9
- package/skills/competitor-profiling/references/tool-reference.md +141 -5
- package/skills/competitors/CHANGELOG.md +8 -0
- package/skills/competitors/SKILL.md +32 -5
- package/skills/competitors/references/content-architecture.md +6 -0
- package/skills/competitors/references/templates.md +3 -0
- package/skills/copy-editing/CHANGELOG.md +10 -0
- package/skills/copy-editing/SKILL.md +41 -9
- package/skills/copy-editing/references/checklist.md +11 -0
- package/skills/copywriting/CHANGELOG.md +11 -0
- package/skills/copywriting/SKILL.md +32 -4
- package/skills/copywriting/references/copy-frameworks.md +2 -3
- package/skills/copywriting/references/natural-transitions.md +4 -20
- package/skills/customer-research/CHANGELOG.md +8 -0
- package/skills/customer-research/SKILL.md +6 -3
- package/skills/directory-submissions/CHANGELOG.md +9 -0
- package/skills/directory-submissions/SKILL.md +21 -10
- package/skills/emails/CHANGELOG.md +8 -0
- package/skills/emails/SKILL.md +22 -3
- package/skills/emails/references/sequence-templates.md +3 -3
- package/skills/image/CHANGELOG.md +8 -0
- package/skills/image/SKILL.md +4 -4
- package/skills/image/references/ai-image-prompting.md +1 -1
- package/skills/marketing-plan/CHANGELOG.md +8 -0
- package/skills/marketing-plan/SKILL.md +8 -4
- package/skills/marketing-plan/references/market-sizing.md +166 -0
- package/skills/marketing-plan/references/methodology.md +2 -0
- package/skills/model-router/CHANGELOG.md +9 -0
- package/skills/model-router/SKILL.md +3 -3
- package/skills/model-router/references/weave-routing-notes.md +1 -0
- package/skills/model-router/scripts/route.py +3 -1
- package/skills/prospecting/CHANGELOG.md +7 -0
- package/skills/prospecting/SKILL.md +3 -3
- package/skills/prospecting/references/saas-prospecting.md +1 -1
- package/skills/public-relations/CHANGELOG.md +10 -0
- package/skills/public-relations/SKILL.md +16 -4
- package/skills/public-relations/references/crisis-communications.md +111 -0
- package/skills/public-relations/references/newsjacking.md +2 -0
- package/skills/public-relations/references/statement-templates.md +143 -0
- package/skills/sales-enablement/CHANGELOG.md +8 -0
- package/skills/sales-enablement/SKILL.md +72 -4
- package/skills/sales-enablement/references/win-loss-analysis.md +72 -0
- package/skills/skill-audit/ATTRIBUTION.md +5 -0
- package/skills/skill-audit/CHANGELOG.md +19 -0
- package/skills/skill-audit/SKILL.md +121 -0
- package/skills/skill-audit-security/ATTRIBUTION.md +14 -0
- package/skills/skill-audit-security/CHANGELOG.md +14 -0
- package/skills/skill-audit-security/LICENSE-APACHE-2.0 +190 -0
- package/skills/skill-audit-security/SKILL.md +99 -0
- package/skills/skill-audit-security/references/rule-ids.md +28 -0
- package/skills/skill-audit-security/references/source-review.md +58 -0
- package/skills/sms/CHANGELOG.md +8 -0
- package/skills/sms/SKILL.md +18 -4
- package/skills/sms/references/whatsapp.md +258 -0
- package/skills/social/CHANGELOG.md +11 -0
- package/skills/social/SKILL.md +31 -7
- package/skills/social/references/listening.md +2 -0
- package/skills/social/references/platforms.md +15 -9
- package/skills/social/references/post-templates.md +9 -9
- package/skills/social/references/short-form-video.md +5 -5
- package/skills/social/references/x-algorithm.md +69 -0
- package/skills/trends/CHANGELOG.md +3 -0
- package/skills/trends/SKILL.md +14 -4
- package/skills/video/CHANGELOG.md +8 -0
- package/skills/video/SKILL.md +11 -25
- package/skills/video-studio/ATTRIBUTION.md +3 -0
- package/skills/video-studio/CHANGELOG.md +25 -0
- package/skills/video-studio/LICENSE-motionmaxxing +202 -0
- package/skills/video-studio/SKILL.md +115 -25
- package/skills/video-studio/references/audio.md +15 -0
- package/skills/video-studio/references/interview.md +75 -0
- package/skills/video-studio/references/step-2-plan.md +39 -7
- package/skills/video-studio/references/storyboard.md +3 -1
- package/skills/video-studio/references/tones.md +5 -5
- package/skills/video-studio/scripts/pyproject.toml +4 -2
- package/skills/video-studio/scripts/qa.mjs +85 -1
- package/skills/video-studio/scripts/qa.test.mjs +37 -1
- package/skills/video-studio/scripts/render-scenes.mjs +1 -0
- package/skills/video-studio/scripts/storyboard.schema.json +6 -1
- package/skills/video-studio/scripts/uv.lock +8 -5
- package/skills/video-studio-art-director/ATTRIBUTION.md +3 -0
- package/skills/video-studio-art-director/CHANGELOG.md +7 -0
- package/skills/video-studio-art-director/LICENSE-motionmaxxing +202 -0
- package/skills/video-studio-art-director/SKILL.md +42 -7
- package/skills/video-studio-composition/ATTRIBUTION.md +3 -0
- package/skills/video-studio-composition/CHANGELOG.md +7 -0
- package/skills/video-studio-composition/LICENSE-motionmaxxing +202 -0
- package/skills/video-studio-composition/SKILL.md +17 -4
- package/skills/video-studio-director/ATTRIBUTION.md +3 -0
- package/skills/video-studio-director/CHANGELOG.md +15 -0
- package/skills/video-studio-director/LICENSE-motionmaxxing +202 -0
- package/skills/video-studio-director/SKILL.md +105 -10
- package/skills/video-studio-motion/ATTRIBUTION.md +3 -0
- package/skills/video-studio-motion/CHANGELOG.md +8 -0
- package/skills/video-studio-motion/LICENSE-motionmaxxing +202 -0
- package/skills/video-studio-motion/SKILL.md +98 -5
- package/skills/video-studio-motion/references/kinetic-type.md +89 -0
- package/skills/video-studio-motion/references/ui-demo.md +59 -0
- package/skills/video-studio-review/CHANGELOG.md +11 -0
- package/skills/video-studio-review/SKILL.md +31 -5
- package/skills/video-studio-review/editor/index.html +21 -3
- package/skills/video-studio-review/references/apply-feedback.md +19 -0
- package/skills/video-studio-review/scripts/build-reel.mjs +35 -10
- package/skills/video-studio-review/scripts/build-reel.test.mjs +15 -0
- package/skills/video-studio-script/ATTRIBUTION.md +6 -0
- package/skills/video-studio-script/CHANGELOG.md +12 -0
- package/skills/video-studio-script/LICENSE-motionmaxxing +202 -0
- package/skills/video-studio-script/SKILL.md +41 -10
- package/skills/video-studio-showreel/CHANGELOG.md +21 -0
- package/skills/video-studio-showreel/SKILL.md +231 -0
- package/skills/video-studio-showreel/references/motion-design-pack-notes.md +28 -0
- package/skills/video-studio-showreel/references/zingage-breakdown.md +47 -0
- package/skills/video-studio-showreel/scripts/build.py +10 -0
- package/skills/video-studio-showreel/templates/showreel.html +264 -0
- package/skills/video-watcher/CHANGELOG.md +6 -0
- package/skills/video-watcher/SKILL.md +3 -3
- package/skills/video-watcher/scripts/setup.py +9 -7
- package/skills/trends/agents/openai.yaml +0 -8
- package/skills/trends/scripts/build-skill.sh +0 -39
- package/skills/trends/scripts/compare.sh +0 -61
- package/skills/trends/scripts/evaluate_search_quality.py +0 -573
- package/skills/trends/scripts/test-v1-vs-v2.sh +0 -220
- package/skills/trends/scripts/test_device_auth.py +0 -128
- 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())
|