feat: wire prediction scorecard into reskill (#67) (#105)

* test: end-to-end integration test with ai-ml topic (#73) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: wire prediction scorecard into reskill (#67) - Add scripts/load_scorecard.py to load and summarize recent scorecards - Add prompts/reskill-scorecard.md template for scorecard-driven reskill - Update reskill.py with --scorecard, --scorecard-count, --topic flags - Add {{SCORECARD}} placeholder to prompts/reskill.md - Add tests/test_load_scorecard.py (14 tests) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Juan Manuel Servera committed May 19, 2026 at 16:06 UTC 0f4bb907ba4fda4e45d970f0396f58f8f58eee52
6 files changed +669
prompts/reskill-scorecard.md new
+8
@@ -0,0 +1,8 @@
1 +You are reviewing prediction accuracy for the {topic_name} topic.
2 +
3 +{scorecard_summary}
4 +
5 +Based on the above performance data, suggest specific adjustments to the topic's wisdom file to improve future predictions. Focus on:
6 +1. Patterns in incorrect predictions — what signals were misleading?
7 +2. Patterns in correct predictions — what signals are reliable?
8 +3. Specific threshold or weight changes to recommend
prompts/reskill.md
+4
@@ -29,6 +29,10 @@ Your job is to review recent analysis output, calibrate the analyst's judgment,
29
30 {{SNAPSHOT_CONTEXT}}
31
32 +### Prediction scorecard
33 +
34 +{{SCORECARD}}
35 +
36 ## Objective
37
38 Write the full contents of `{{OUTPUT_PATH}}` as a markdown reskill report.
scripts/load_scorecard.py new
+140
@@ -0,0 +1,140 @@
1 +#!/usr/bin/env python3
2 +"""Load and summarize prediction scorecards for reskill integration.
3 +
4 +Reads scorecard JSON files from data/metrics/{topic}/scorecards/ and produces
5 +a markdown summary suitable for injection into reskill prompts.
6 +"""
7 +
8 +from __future__ import annotations
9 +
10 +import json
11 +from pathlib import Path
12 +from typing import Any
13 +
14 +from scripts.topic_paths import metrics_dir
15 +
16 +DEFAULT_SCORECARD_COUNT = 4
17 +
18 +
19 +def scorecard_dir(topic_id: str | None = None) -> Path:
20 + """Return the scorecards directory for a given topic."""
21 + return metrics_dir(topic_id) / "scorecards"
22 +
23 +
24 +def load_scorecards(topic_id: str | None = None, count: int = DEFAULT_SCORECARD_COUNT) -> list[dict[str, Any]]:
25 + """Load the most recent N scorecards for a topic, sorted oldest-first."""
26 + directory = scorecard_dir(topic_id)
27 + if not directory.exists():
28 + return []
29 + files = sorted(directory.glob("*-scorecard.json"))
30 + if count > 0:
31 + files = files[-count:]
32 + cards: list[dict[str, Any]] = []
33 + for path in files:
34 + try:
35 + with open(path, encoding="utf-8") as f:
36 + cards.append(json.load(f))
37 + except (json.JSONDecodeError, OSError):
38 + continue
39 + return cards
40 +
41 +
42 +def _aggregate_stats(cards: list[dict[str, Any]]) -> tuple[int, int, int, dict[str, dict[str, int]]]:
43 + """Aggregate totals across multiple scorecards.
44 +
45 + Returns (total_validated, total_correct, total_incorrect, by_type).
46 + """
47 + total_validated = 0
48 + total_correct = 0
49 + total_incorrect = 0
50 + by_type: dict[str, dict[str, int]] = {}
51 +
52 + for card in cards:
53 + total_validated += card.get("validated", 0)
54 + total_correct += card.get("correct", 0)
55 + total_incorrect += card.get("incorrect", 0)
56 + for pred_type, stats in card.get("by_type", {}).items():
57 + if pred_type not in by_type:
58 + by_type[pred_type] = {"total": 0, "correct": 0}
59 + by_type[pred_type]["total"] += stats.get("total", 0)
60 + by_type[pred_type]["correct"] += stats.get("correct", 0)
61 +
62 + return total_validated, total_correct, total_incorrect, by_type
63 +
64 +
65 +def _format_by_type_analysis(by_type: dict[str, dict[str, int]]) -> list[str]:
66 + """Produce per-type accuracy lines for the summary."""
67 + lines: list[str] = []
68 + for pred_type, stats in sorted(by_type.items()):
69 + total = stats["total"]
70 + correct = stats["correct"]
71 + if total == 0:
72 + continue
73 + accuracy = correct / total
74 + pct = int(round(accuracy * 100))
75 + lines.append(f"- \"{pred_type}\" predictions: {pct}% accurate ({correct}/{total})")
76 + return lines
77 +
78 +
79 +def _format_recommendations(by_type: dict[str, dict[str, int]], overall_accuracy: float) -> list[str]:
80 + """Generate adjustment recommendations based on type performance."""
81 + recs: list[str] = []
82 + for pred_type, stats in sorted(by_type.items()):
83 + total = stats["total"]
84 + correct = stats["correct"]
85 + if total == 0:
86 + continue
87 + accuracy = correct / total
88 + if accuracy < 0.5:
89 + recs.append(
90 + f"- \"{pred_type}\" predictions are underperforming ({int(round(accuracy * 100))}%) "
91 + f"— raise confidence threshold or require additional signals"
92 + )
93 + elif accuracy >= 0.8:
94 + recs.append(
95 + f"- \"{pred_type}\" predictions are strong ({int(round(accuracy * 100))}%) "
96 + f"— current heuristics are reliable"
97 + )
98 + if not recs:
99 + if overall_accuracy < 0.6:
100 + recs.append("- Overall accuracy is low — review signal weighting across all prediction types")
101 + else:
102 + recs.append("- No specific type-level adjustments needed at this time")
103 + return recs
104 +
105 +
106 +def format_scorecard_summary(cards: list[dict[str, Any]]) -> str:
107 + """Format loaded scorecards into a markdown summary section.
108 +
109 + Returns empty string if cards is empty.
110 + """
111 + if not cards:
112 + return ""
113 +
114 + total_validated, total_correct, total_incorrect, by_type = _aggregate_stats(cards)
115 +
116 + if total_validated == 0:
117 + return ""
118 +
119 + overall_accuracy = total_correct / total_validated if total_validated else 0.0
120 + pct = int(round(overall_accuracy * 100))
121 + weeks = len(cards)
122 + week_range = f"last {weeks} week{'s' if weeks != 1 else ''}"
123 +
124 + lines: list[str] = []
125 + lines.append(f"## Prediction Performance ({week_range})")
126 + lines.append(f"Overall accuracy: {pct}% ({total_correct}/{total_validated} correct)")
127 + lines.append("")
128 + lines.append("### Per-Type Accuracy:")
129 + lines.extend(_format_by_type_analysis(by_type))
130 + lines.append("")
131 + lines.append("### Recommended Adjustments:")
132 + lines.extend(_format_recommendations(by_type, overall_accuracy))
133 +
134 + return "\n".join(lines)
135 +
136 +
137 +def render_scorecard_section(topic_id: str | None = None, count: int = DEFAULT_SCORECARD_COUNT) -> str:
138 + """Load scorecards and return formatted summary, or empty string if none exist."""
139 + cards = load_scorecards(topic_id, count)
140 + return format_scorecard_summary(cards)
scripts/reskill.py
+12
@@ -15,6 +15,7 @@ if str(ROOT) not in sys.path:
15 sys.path.insert(0, str(ROOT))
16
17 from scripts import track_quality
18 +from scripts.load_scorecard import render_scorecard_section
19
20 DEFAULT_PROMPT_TEMPLATE = ROOT / "prompts" / "reskill.md"
21 DEFAULT_ANALYZED_DIR = ROOT / "data" / "analyzed"
@@ -66,6 +67,9 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
67 help="Path to write the reskill report. Defaults to .squad/reskill/YYYY-WNN.md.",
68 )
69 parser.add_argument("--limit", type=int, default=5, help="Maximum number of analyzed summaries to include.")
70 + parser.add_argument("--scorecard", action="store_true", help="Include prediction scorecard data in the reskill prompt.")
71 + parser.add_argument("--scorecard-count", type=int, default=4, help="Number of recent scorecards to include (default: 4).")
72 + parser.add_argument("--topic", default=None, help="Topic ID for scorecard resolution.")
73 parser.add_argument(
74 "--print-prompt",
75 action="store_true",
@@ -185,6 +189,7 @@ def render_prompt(
189 wisdom_file: Path,
190 skills_dir: Path,
191 limit: int,
192 + scorecard_section: str = "",
193 ) -> str:
194 prompt = prompt_template_path.read_text(encoding="utf-8")
195 replacements = {
@@ -195,6 +200,7 @@ def render_prompt(
200 "{{QUALITY_TREND}}": track_quality.build_quality_report(analyzed_dir).strip(),
201 "{{RECENT_ANALYSES}}": render_recent_analyses(analyzed_dir, limit),
202 "{{SNAPSHOT_CONTEXT}}": render_snapshot_context(analyzed_dir, snapshots_dir, limit),
203 + "{{SCORECARD}}": scorecard_section,
204 }
205 for needle, value in replacements.items():
206 prompt = prompt.replace(needle, value)
@@ -269,6 +275,11 @@ def call_github_models(prompt: str) -> str:
275 def main(argv: list[str] | None = None) -> int:
276 args = parse_args(argv)
277 output_path = args.output or default_output_path(args.current_datetime)
278 +
279 + scorecard_section = ""
280 + if args.scorecard:
281 + scorecard_section = render_scorecard_section(args.topic, args.scorecard_count)
282 +
283 prompt = render_prompt(
284 prompt_template_path=args.prompt_template,
285 current_datetime=args.current_datetime,
@@ -278,6 +289,7 @@ def main(argv: list[str] | None = None) -> int:
289 wisdom_file=args.wisdom_file,
290 skills_dir=args.skills_dir,
291 limit=args.limit,
292 + scorecard_section=scorecard_section,
293 )
294
295 if args.print_prompt:
tests/test_end_to_end_topic.py new
+355
@@ -0,0 +1,355 @@
1 +#!/usr/bin/env python3
2 +"""End-to-end integration test for the SquadScope topic pipeline with ai-ml config.
3 +
4 +Exercises the full pipeline: validate → crawl (mocked) → score → quality gate →
5 +prediction ledger → content verification, using realistic AI/ML mock data.
6 +"""
7 +
8 +from __future__ import annotations
9 +
10 +import json
11 +import os
12 +import shutil
13 +import tempfile
14 +import unittest
15 +from pathlib import Path
16 +
17 +import yaml
18 +
19 +
20 +REPO_ROOT = Path(__file__).resolve().parent.parent
21 +
22 +MOCK_REPOS = [
23 + {
24 + "full_name": "org/pytorch-trainer",
25 + "language": "Python",
26 + "topics": ["machine-learning", "pytorch", "deep-learning"],
27 + "stars": 500,
28 + "stars_gained": 50,
29 + "forks": 80,
30 + "created_at": "2024-06-01T00:00:00Z",
31 + "description": "High-performance PyTorch training framework",
32 + },
33 + {
34 + "full_name": "org/llm-finetune",
35 + "language": "Python",
36 + "topics": ["llm", "transformers", "machine-learning"],
37 + "stars": 1200,
38 + "stars_gained": 200,
39 + "forks": 150,
40 + "created_at": "2024-03-15T00:00:00Z",
41 + "description": "Fine-tuning toolkit for large language models",
42 + },
43 + {
44 + "full_name": "org/neural-notebook",
45 + "language": "Jupyter Notebook",
46 + "topics": ["deep-learning", "neural-network"],
47 + "stars": 80,
48 + "stars_gained": 30,
49 + "forks": 12,
50 + "created_at": "2025-01-10T00:00:00Z",
51 + "description": "Interactive notebooks for neural network experiments",
52 + },
53 + {
54 + "full_name": "org/ai-image-gen",
55 + "language": "Python",
56 + "topics": ["artificial-intelligence", "deep-learning", "transformers"],
57 + "stars": 3000,
58 + "stars_gained": 400,
59 + "forks": 500,
60 + "created_at": "2023-11-20T00:00:00Z",
61 + "description": "State-of-the-art image generation with diffusion models",
62 + },
63 + {
64 + "full_name": "org/ml-pipeline",
65 + "language": "Python",
66 + "topics": ["machine-learning", "mlops"],
67 + "stars": 250,
68 + "stars_gained": 35,
69 + "forks": 40,
70 + "created_at": "2024-09-01T00:00:00Z",
71 + "description": "End-to-end ML pipeline orchestration",
72 + },
73 + {
74 + "full_name": "org/transformer-serving",
75 + "language": "Python",
76 + "topics": ["transformers", "llm", "inference"],
77 + "stars": 900,
78 + "stars_gained": 120,
79 + "forks": 95,
80 + "created_at": "2024-05-01T00:00:00Z",
81 + "description": "Scalable transformer model serving",
82 + },
83 + {
84 + "full_name": "org/rust-cli-tool",
85 + "language": "Rust",
86 + "topics": ["cli-tool", "developer-tools"],
87 + "stars": 300,
88 + "stars_gained": 15,
89 + "forks": 20,
90 + "created_at": "2024-07-01T00:00:00Z",
91 + "description": "Fast CLI utility written in Rust",
92 + },
93 + {
94 + "full_name": "org/web-dashboard",
95 + "language": "JavaScript",
96 + "topics": ["web", "dashboard", "react"],
97 + "stars": 400,
98 + "stars_gained": 25,
99 + "forks": 50,
100 + "created_at": "2024-04-01T00:00:00Z",
101 + "description": "Modern web dashboard framework",
102 + },
103 + {
104 + "full_name": "org/tiny-nn",
105 + "language": "Python",
106 + "topics": ["neural-network", "deep-learning"],
107 + "stars": 150,
108 + "stars_gained": 45,
109 + "forks": 25,
110 + "created_at": "2025-02-01T00:00:00Z",
111 + "description": "Minimal neural network library for education",
112 + },
113 + {
114 + "full_name": "org/data-viz",
115 + "language": "JavaScript",
116 + "topics": ["visualization", "charts"],
117 + "stars": 600,
118 + "stars_gained": 20,
119 + "forks": 70,
120 + "created_at": "2023-06-01T00:00:00Z",
121 + "description": "Data visualization library",
122 + },
123 +]
124 +
125 +# Raw data format expected by prediction_ledger
126 +MOCK_RAW_DATA = {
127 + "week": "2025-W25",
128 + "new_repos": MOCK_REPOS[:5],
129 + "trending_repos": MOCK_REPOS[5:],
130 +}
131 +
132 +MOCK_SUMMARY_CONTENT = """---
133 +topic: ai-ml
134 +week: 2025-W25
135 +repos_scored: 8
136 +---
137 +
138 +# AI & ML Weekly Digest — 2025-W25
139 +
140 +## Highlights
141 +
142 +- [org/pytorch-trainer](https://github.com/org/pytorch-trainer) — High-performance training
143 +- [org/llm-finetune](https://github.com/org/llm-finetune) — LLM fine-tuning toolkit
144 +- [org/ai-image-gen](https://github.com/org/ai-image-gen) — Diffusion image generation
145 +- [org/transformer-serving](https://github.com/org/transformer-serving) — Model serving at scale
146 +"""
147 +
148 +
149 +class TestEndToEndTopicPipeline(unittest.TestCase):
150 + """Full pipeline integration test with ai-ml topic config."""
151 +
152 + def setUp(self):
153 + """Create temp directory structure with ai-ml config and mock data."""
154 + self.temp_dir = tempfile.TemporaryDirectory()
155 + self.work_dir = Path(self.temp_dir.name)
156 +
157 + # Copy the real ai-ml config
158 + src_config = REPO_ROOT / "squadscope.topic.yml"
159 + self.config_path = self.work_dir / "squadscope.topic.yml"
160 + shutil.copy(src_config, self.config_path)
161 +
162 + # Create data directories
163 + self.raw_path = self.work_dir / "data" / "raw" / "ai-ml"
164 + self.analyzed_path = self.work_dir / "data" / "analyzed" / "ai-ml"
165 + self.metrics_path = self.work_dir / "data" / "metrics" / "ai-ml"
166 + for d in (self.raw_path, self.analyzed_path, self.metrics_path):
167 + d.mkdir(parents=True, exist_ok=True)
168 +
169 + # Write mock raw crawl data (list format for score_repos)
170 + self.raw_json_path = self.raw_path / "2025-W25.json"
171 + self.raw_json_path.write_text(json.dumps(MOCK_REPOS, indent=2), encoding="utf-8")
172 +
173 + # Write raw data in dict format for prediction_ledger
174 + self.raw_dict_path = self.raw_path / "2025-W25-full.json"
175 + self.raw_dict_path.write_text(json.dumps(MOCK_RAW_DATA, indent=2), encoding="utf-8")
176 +
177 + # Write mock summary for prediction ledger
178 + self.summary_path = self.analyzed_path / "2025-W25-summary.md"
179 + self.summary_path.write_text(MOCK_SUMMARY_CONTENT, encoding="utf-8")
180 +
181 + # Change to work dir so relative paths in scripts work
182 + self._orig_cwd = os.getcwd()
183 + os.chdir(self.work_dir)
184 +
185 + def tearDown(self):
186 + os.chdir(self._orig_cwd)
187 + self.temp_dir.cleanup()
188 +
189 + def test_full_pipeline_ai_ml(self):
190 + """Run the full pipeline: validate → score → quality gate → predictions."""
191 + import sys
192 + sys.path.insert(0, str(REPO_ROOT))
193 +
194 + from scripts.validate_topic_config import validate_file
195 + from scripts.score_repos import score_repos, load_config, get_scoring_config
196 + from scripts.quality_gate import check_quality, get_quality_config, write_metric, week_slug
197 + from scripts.prediction_ledger import generate_predictions, append_predictions
198 +
199 + # --- Stage 1: Validate config ---
200 + config_model = validate_file(str(self.config_path))
201 + self.assertEqual(config_model.topic.id, "ai-ml")
202 + self.assertEqual(config_model.topic.name, "AI & Machine Learning")
203 + self.assertGreater(len(config_model.queries.primary), 0)
204 + self.assertIn("Python", config_model.scoring.language_boost)
205 +
206 + # --- Stage 2: Mock crawl data already created in setUp ---
207 + raw_repos = json.loads(self.raw_json_path.read_text(encoding="utf-8"))
208 + self.assertEqual(len(raw_repos), 10)
209 +
210 + # --- Stage 3: Score repos ---
211 + config = load_config(self.config_path)
212 + scoring_config = get_scoring_config(config)
213 + scored = score_repos(raw_repos, scoring_config)
214 +
215 + # Verify scoring filters and ranks correctly
216 + self.assertGreater(len(scored), 0)
217 + self.assertLessEqual(len(scored), len(raw_repos))
218 +
219 + # All scored repos should meet minimum relevance threshold
220 + min_score = scoring_config["min_relevance_score"]
221 + for repo in scored:
222 + self.assertGreaterEqual(repo["relevance_score"], min_score)
223 + self.assertIn("relevance_score", repo)
224 +
225 + # Verify sorted descending
226 + scores = [r["relevance_score"] for r in scored]
227 + self.assertEqual(scores, sorted(scores, reverse=True))
228 +
229 + # AI/ML repos should score higher than non-AI repos
230 + ai_repos = [r for r in scored if r["full_name"] in (
231 + "org/pytorch-trainer", "org/llm-finetune", "org/ai-image-gen"
232 + )]
233 + non_ai_repos = [r for r in scored if r["full_name"] in (
234 + "org/rust-cli-tool", "org/web-dashboard", "org/data-viz"
235 + )]
236 + if ai_repos and non_ai_repos:
237 + max_non_ai = max(r["relevance_score"] for r in non_ai_repos)
238 + min_ai = min(r["relevance_score"] for r in ai_repos)
239 + self.assertGreater(min_ai, max_non_ai,
240 + "AI/ML repos should outscore non-AI repos")
241 +
242 + # Write scored output for quality gate
243 + scored_path = self.work_dir / "scored.json"
244 + scored_path.write_text(json.dumps(scored, indent=2), encoding="utf-8")
245 +
246 + # --- Stage 4: Quality gate ---
247 + quality_config = get_quality_config(config)
248 + scoring_cfg = config.get("scoring", {})
249 + metric = check_quality(scored, quality_config, scoring_cfg)
250 +
251 + self.assertIn("repos_scored", metric)
252 + self.assertIn("repos_passing", metric)
253 + self.assertIn("status", metric)
254 + self.assertIn("warnings", metric)
255 + self.assertGreater(metric["repos_scored"], 0)
256 +
257 + # Write metric file
258 + week = "2025-W25"
259 + metric_path = write_metric("ai-ml", metric, week)
260 + self.assertTrue(metric_path.exists())
261 + metric_data = json.loads(metric_path.read_text(encoding="utf-8"))
262 + self.assertEqual(metric_data["topic"], "ai-ml")
263 + self.assertEqual(metric_data["week"], week)
264 +
265 + # --- Stage 5: Prediction ledger ---
266 + summary_content = self.summary_path.read_text(encoding="utf-8")
267 + raw_data = json.loads(self.raw_dict_path.read_text(encoding="utf-8"))
268 + predictions = generate_predictions(summary_content, raw_data, week)
269 +
270 + self.assertGreaterEqual(len(predictions), 1)
271 + self.assertLessEqual(len(predictions), 5)
272 +
273 + for pred in predictions:
274 + self.assertIn("week", pred)
275 + self.assertIn("repo", pred)
276 + self.assertIn("prediction", pred)
277 + self.assertIn("confidence", pred)
278 + self.assertIn("reason", pred)
279 + self.assertEqual(pred["week"], week)
280 + self.assertGreater(pred["confidence"], 0)
281 + self.assertIn(pred["prediction"], [
282 + "rising_star", "emerging_topic", "momentum_shift",
283 + "breakout_candidate", "declining_signal",
284 + ])
285 +
286 + # Write predictions to ledger
287 + predictions_path = self.metrics_path / "predictions.jsonl"
288 + append_predictions(predictions, predictions_path)
289 + self.assertTrue(predictions_path.exists())
290 +
291 + # Verify JSONL format
292 + lines = predictions_path.read_text(encoding="utf-8").strip().split("\n")
293 + self.assertEqual(len(lines), len(predictions))
294 + for line in lines:
295 + entry = json.loads(line)
296 + self.assertIn("repo", entry)
297 + self.assertIn("prediction", entry)
298 +
299 + # --- Stage 6: Content generation verification ---
300 + # Verify scored data is suitable for content generation
301 + top_repos = scored[:5]
302 + for repo in top_repos:
303 + self.assertIn("full_name", repo)
304 + self.assertIn("description", repo)
305 + self.assertIn("relevance_score", repo)
306 + # Verify topic-specific content (not generic)
307 + topics = repo.get("topics", [])
308 + ai_topics = {"machine-learning", "deep-learning", "artificial-intelligence",
309 + "neural-network", "llm", "transformers"}
310 + has_ai_topic = bool(set(t.lower() for t in topics) & ai_topics)
311 + self.assertTrue(has_ai_topic,
312 + f"Top repo {repo['full_name']} should have AI/ML topics")
313 +
314 + def test_config_validation_rejects_invalid(self):
315 + """Ensure validate rejects malformed configs."""
316 + import sys
317 + sys.path.insert(0, str(REPO_ROOT))
318 + from scripts.validate_topic_config import validate_file
319 +
320 + # Write an invalid config (missing required fields)
321 + bad_config = self.work_dir / "bad.yml"
322 + bad_config.write_text("topic:\n id: INVALID ID WITH SPACES\n", encoding="utf-8")
323 +
324 + with self.assertRaises(Exception):
325 + validate_file(str(bad_config))
326 +
327 + def test_scoring_respects_language_boost(self):
328 + """Verify Python repos get a language boost over unlisted languages."""
329 + import sys
330 + sys.path.insert(0, str(REPO_ROOT))
331 + from scripts.score_repos import compute_relevance_score, load_config, get_scoring_config
332 +
333 + config = load_config(self.config_path)
334 + scoring_config = get_scoring_config(config)
335 +
336 + base_repo = {
337 + "full_name": "test/repo",
338 + "stars": 500,
339 + "stars_gained": 50,
340 + "topics": ["machine-learning"],
341 + "created_at": "2024-06-01T00:00:00Z",
342 + }
343 +
344 + python_repo = {**base_repo, "language": "Python"}
345 + rust_repo = {**base_repo, "language": "Rust"}
346 +
347 + python_score = compute_relevance_score(python_repo, scoring_config)
348 + rust_score = compute_relevance_score(rust_repo, scoring_config)
349 +
350 + self.assertGreater(python_score, rust_score,
351 + "Python should score higher with language_boost configured")
352 +
353 +
354 +if __name__ == "__main__":
355 + unittest.main()
tests/test_load_scorecard.py new
+150
@@ -0,0 +1,150 @@
1 +"""Tests for scripts/load_scorecard.py"""
2 +
3 +from __future__ import annotations
4 +
5 +import json
6 +from pathlib import Path
7 +
8 +import pytest
9 +
10 +from scripts.load_scorecard import (
11 + format_scorecard_summary,
12 + load_scorecards,
13 + render_scorecard_section,
14 + scorecard_dir,
15 +)
16 +
17 +
18 +def _make_scorecard(week: str, topic: str = "ai-ml", validated: int = 5, correct: int = 3, incorrect: int = 2, by_type: dict | None = None) -> dict:
19 + return {
20 + "week": week,
21 + "topic": topic,
22 + "total_predictions": validated + 2,
23 + "validated": validated,
24 + "correct": correct,
25 + "incorrect": incorrect,
26 + "accuracy": correct / validated if validated else 0,
27 + "by_type": by_type or {"rising_star": {"total": 3, "correct": 2}, "declining_signal": {"total": 2, "correct": 1}},
28 + "details": [],
29 + }
30 +
31 +
32 +@pytest.fixture
33 +def scorecards_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
34 + """Create a fake scorecards directory and patch metrics_dir."""
35 + topic = "ai-ml"
36 + sc_dir = tmp_path / "data" / "metrics" / topic / "scorecards"
37 + sc_dir.mkdir(parents=True)
38 +
39 + monkeypatch.setattr("scripts.load_scorecard.metrics_dir", lambda topic_id=None: tmp_path / "data" / "metrics" / (topic_id or "general"))
40 +
41 + return sc_dir
42 +
43 +
44 +class TestScorecardDir:
45 + def test_returns_scorecards_subdir(self):
46 + path = scorecard_dir("ai-ml")
47 + assert path.name == "scorecards"
48 + assert "ai-ml" in str(path)
49 +
50 + def test_general_topic(self):
51 + path = scorecard_dir(None)
52 + assert "scorecards" in str(path)
53 +
54 +
55 +class TestLoadScorecards:
56 + def test_empty_when_no_dir(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
57 + monkeypatch.setattr("scripts.load_scorecard.metrics_dir", lambda topic_id=None: tmp_path / "nonexistent")
58 + result = load_scorecards("ai-ml")
59 + assert result == []
60 +
61 + def test_loads_recent_scorecards(self, scorecards_dir: Path):
62 + for i, week in enumerate(["2026-W18", "2026-W19", "2026-W20", "2026-W21", "2026-W22"]):
63 + card = _make_scorecard(week, correct=i + 1, validated=5)
64 + (scorecards_dir / f"{week}-scorecard.json").write_text(json.dumps(card))
65 +
66 + cards = load_scorecards("ai-ml", count=4)
67 + assert len(cards) == 4
68 + assert cards[0]["week"] == "2026-W19"
69 + assert cards[-1]["week"] == "2026-W22"
70 +
71 + def test_loads_all_when_fewer_than_count(self, scorecards_dir: Path):
72 + card = _make_scorecard("2026-W21")
73 + (scorecards_dir / "2026-W21-scorecard.json").write_text(json.dumps(card))
74 +
75 + cards = load_scorecards("ai-ml", count=4)
76 + assert len(cards) == 1
77 +
78 + def test_skips_invalid_json(self, scorecards_dir: Path):
79 + (scorecards_dir / "2026-W20-scorecard.json").write_text("not json")
80 + card = _make_scorecard("2026-W21")
81 + (scorecards_dir / "2026-W21-scorecard.json").write_text(json.dumps(card))
82 +
83 + cards = load_scorecards("ai-ml", count=4)
84 + assert len(cards) == 1
85 + assert cards[0]["week"] == "2026-W21"
86 +
87 +
88 +class TestFormatScorecardSummary:
89 + def test_empty_cards_returns_empty(self):
90 + assert format_scorecard_summary([]) == ""
91 +
92 + def test_zero_validated_returns_empty(self):
93 + card = _make_scorecard("2026-W21", validated=0, correct=0, incorrect=0)
94 + assert format_scorecard_summary([card]) == ""
95 +
96 + def test_single_card_summary(self):
97 + card = _make_scorecard("2026-W21", validated=5, correct=3, incorrect=2)
98 + result = format_scorecard_summary([card])
99 +
100 + assert "## Prediction Performance (last 1 week)" in result
101 + assert "Overall accuracy: 60% (3/5 correct)" in result
102 + assert "rising_star" in result
103 + assert "declining_signal" in result
104 +
105 + def test_multiple_cards_aggregate(self):
106 + cards = [
107 + _make_scorecard("2026-W20", validated=5, correct=4, incorrect=1,
108 + by_type={"rising_star": {"total": 3, "correct": 2}, "breakout": {"total": 2, "correct": 2}}),
109 + _make_scorecard("2026-W21", validated=5, correct=3, incorrect=2,
110 + by_type={"rising_star": {"total": 3, "correct": 1}, "breakout": {"total": 2, "correct": 2}}),
111 + ]
112 + result = format_scorecard_summary(cards)
113 +
114 + assert "last 2 weeks" in result
115 + assert "70% (7/10 correct)" in result
116 + # rising_star: 3/6 = 50%
117 + assert "\"rising_star\" predictions: 50%" in result
118 + # breakout: 4/4 = 100%
119 + assert "\"breakout\" predictions: 100%" in result
120 +
121 + def test_recommendations_for_low_accuracy(self):
122 + cards = [
123 + _make_scorecard("2026-W21", validated=10, correct=3, incorrect=7,
124 + by_type={"rising_star": {"total": 10, "correct": 3}}),
125 + ]
126 + result = format_scorecard_summary(cards)
127 + assert "raise confidence threshold" in result
128 +
129 + def test_recommendations_for_high_accuracy(self):
130 + cards = [
131 + _make_scorecard("2026-W21", validated=10, correct=9, incorrect=1,
132 + by_type={"declining_signal": {"total": 10, "correct": 9}}),
133 + ]
134 + result = format_scorecard_summary(cards)
135 + assert "reliable" in result
136 +
137 +
138 +class TestRenderScorecardSection:
139 + def test_returns_empty_when_no_scorecards(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
140 + monkeypatch.setattr("scripts.load_scorecard.metrics_dir", lambda topic_id=None: tmp_path / "nonexistent")
141 + result = render_scorecard_section("ai-ml")
142 + assert result == ""
143 +
144 + def test_returns_formatted_summary(self, scorecards_dir: Path):
145 + card = _make_scorecard("2026-W21", validated=5, correct=4, incorrect=1)
146 + (scorecards_dir / "2026-W21-scorecard.json").write_text(json.dumps(card))
147 +
148 + result = render_scorecard_section("ai-ml")
149 + assert "Prediction Performance" in result
150 + assert "80%" in result