main
py 309 lines 9.55 KB
Raw
1 #!/usr/bin/env python3
2 """Hype risk scoring model for SquadScope.
3
4 Classifies repos based on the relationship between press coverage
5 and GitHub activity patterns.
6 """
7
8 from __future__ import annotations
9
10 import argparse
11 import json
12 import sys
13 from pathlib import Path
14
15 sys.path.insert(0, str(Path(__file__).resolve().parent))
16
17
18 # Risk level definitions
19 RISK_LEVELS = {
20 "very_low": "Organic growth",
21 "low": "Press-validated, community-sustained",
22 "medium": "Announced but unbuilt",
23 "high": "Press-driven hype, fading",
24 "none": "No press signal",
25 }
26
27
28 def classify_repo(
29 repo_name: str,
30 press_correlated: bool,
31 current_stars: int | None = None,
32 current_stars_gained: int | None = None,
33 previous_stars: int | None = None,
34 previous_stars_gained: int | None = None,
35 ) -> dict:
36 """Classify a single repo's hype risk.
37
38 Returns an assessment dict with risk level, label, confidence, and reasoning.
39 """
40 if not press_correlated:
41 return _assessment(
42 repo_name,
43 risk="none",
44 press_correlated=False,
45 stars_trend="unknown",
46 confidence=0.9,
47 reasoning="No press correlation detected",
48 )
49
50 # Press correlated but no previous data
51 if previous_stars is None or previous_stars_gained is None:
52 return _assessment(
53 repo_name,
54 risk="medium",
55 press_correlated=True,
56 stars_trend="unknown",
57 confidence=0.4,
58 reasoning="Press correlated but insufficient historical data to assess sustainability",
59 )
60
61 # Press correlated with previous data available
62 current_gained = current_stars_gained or 0
63 previous_gained = previous_stars_gained or 0
64
65 # Check decay first: previous spike much larger than current → fading
66 if previous_gained > 0 and current_gained < previous_gained * 0.5:
67 return _assessment(
68 repo_name,
69 risk="high",
70 press_correlated=True,
71 stars_trend="decaying",
72 confidence=0.7,
73 reasoning=(
74 f"Stars spiked after press but are fading "
75 f"(previous: +{previous_gained}, current: +{current_gained})"
76 ),
77 )
78
79 # Check if stars were already growing before press (organic)
80 if previous_gained > 0 and current_gained > 0:
81 if previous_gained >= current_gained * 0.5:
82 # Growth was already happening before press
83 return _assessment(
84 repo_name,
85 risk="very_low",
86 press_correlated=True,
87 stars_trend="organic",
88 confidence=0.8,
89 reasoning=(
90 f"Stars were already growing before press coverage "
91 f"(previous: +{previous_gained}, current: +{current_gained})"
92 ),
93 )
94
95 # Stars spiked after article - check sustainability
96 if current_gained > 0 and previous_gained >= 0:
97 total_recent_gain = current_gained + previous_gained
98 if total_recent_gain > 0 and current_gained > total_recent_gain * 0.5:
99 # Current week still has significant growth - sustained
100 return _assessment(
101 repo_name,
102 risk="low",
103 press_correlated=True,
104 stars_trend="sustained",
105 confidence=0.75,
106 reasoning=(
107 f"Stars grew after press coverage and maintained "
108 f"(+{current_gained} this week, +{previous_gained} previous)"
109 ),
110 )
111
112 # Fallback: press correlated but no clear activity spike
113 return _assessment(
114 repo_name,
115 risk="medium",
116 press_correlated=True,
117 stars_trend="flat",
118 confidence=0.5,
119 reasoning="Press coverage detected but no significant GitHub activity spike",
120 )
121
122
123 def _assessment(
124 repo: str,
125 risk: str,
126 press_correlated: bool,
127 stars_trend: str,
128 confidence: float,
129 reasoning: str,
130 ) -> dict:
131 return {
132 "repo": repo,
133 "hype_risk": risk,
134 "label": RISK_LEVELS[risk],
135 "press_correlated": press_correlated,
136 "stars_trend": stars_trend,
137 "confidence": confidence,
138 "reasoning": reasoning,
139 }
140
141
142 def _find_repo_in_raw(raw_repos: list[dict], repo_name: str) -> dict | None:
143 """Find a repo entry in raw data by name."""
144 for repo in raw_repos:
145 name = repo.get("full_name") or repo.get("repo") or repo.get("name", "")
146 if name == repo_name:
147 return repo
148 return None
149
150
151 def score_hype_risk(
152 correlations: dict,
153 raw_data: dict | list | None = None,
154 previous_data: dict | list | None = None,
155 ) -> list[dict]:
156 """Score hype risk for all repos in correlations data.
157
158 Args:
159 correlations: Correlation analysis output with correlated repos.
160 raw_data: Current week raw GitHub data.
161 previous_data: Previous week raw GitHub data.
162
163 Returns:
164 List of assessment dicts.
165 """
166 # Extract correlated repos
167 correlated_repos = set()
168 corr_entries = correlations.get("correlations", correlations.get("repos", []))
169 if isinstance(corr_entries, list):
170 for entry in corr_entries:
171 repo_name = entry.get("repo") or entry.get("full_name", "")
172 if entry.get("press_correlated", False):
173 correlated_repos.add(repo_name)
174
175 # Normalize raw data to lists
176 raw_repos = _normalize_raw(raw_data)
177 prev_repos = _normalize_raw(previous_data)
178
179 # Collect all repo names from raw data
180 all_repos = set()
181 for repo in raw_repos:
182 name = repo.get("full_name") or repo.get("repo") or repo.get("name", "")
183 if name:
184 all_repos.add(name)
185 # Also include correlated repos even if not in current raw
186 all_repos.update(correlated_repos)
187
188 assessments = []
189 for repo_name in sorted(all_repos):
190 press_correlated = repo_name in correlated_repos
191
192 current = _find_repo_in_raw(raw_repos, repo_name)
193 previous = _find_repo_in_raw(prev_repos, repo_name)
194
195 current_stars = current.get("stars") if current else None
196 current_gained = current.get("stars_gained") if current else None
197 prev_stars = previous.get("stars") if previous else None
198 prev_gained = previous.get("stars_gained") if previous else None
199
200 assessment = classify_repo(
201 repo_name,
202 press_correlated=press_correlated,
203 current_stars=current_stars,
204 current_stars_gained=current_gained,
205 previous_stars=prev_stars,
206 previous_stars_gained=prev_gained,
207 )
208 assessments.append(assessment)
209
210 return assessments
211
212
213 def _normalize_raw(data: dict | list | None) -> list[dict]:
214 """Normalize raw data to a list of repo dicts."""
215 if data is None:
216 return []
217 if isinstance(data, list):
218 return data
219 # Could be wrapped in a dict with 'repos' or 'repositories' key
220 if isinstance(data, dict):
221 for key in ("repos", "repositories", "items"):
222 if key in data and isinstance(data[key], list):
223 return data[key]
224 return []
225 return []
226
227
228 def extract_week(filepath: str | Path | None) -> str:
229 """Try to extract week identifier from a filepath like 2026-W21.json."""
230 if filepath is None:
231 return "unknown"
232 name = Path(filepath).stem
233 # Remove suffixes like -correlations, -hype-risk
234 for suffix in ("-correlations", "-hype-risk", "-metrics"):
235 if name.endswith(suffix):
236 name = name[: -len(suffix)]
237 return name
238
239
240 def main(argv: list[str] | None = None) -> None:
241 parser = argparse.ArgumentParser(description="Hype risk scoring model")
242 parser.add_argument(
243 "--correlations",
244 help="Path to correlations JSON file",
245 )
246 parser.add_argument(
247 "--raw",
248 help="Path to current week raw data JSON",
249 )
250 parser.add_argument(
251 "--previous",
252 help="Path to previous week raw data JSON",
253 )
254 parser.add_argument(
255 "--output",
256 help="Output path for hype risk JSON",
257 )
258 parser.add_argument(
259 "--topic",
260 help="Topic ID for path resolution",
261 )
262 args = parser.parse_args(argv)
263
264 # Resolve paths
265 corr_path = Path(args.correlations) if args.correlations else None
266 raw_path = Path(args.raw) if args.raw else None
267 prev_path = Path(args.previous) if args.previous else None
268 out_path = Path(args.output) if args.output else None
269
270 if corr_path is None:
271 print("Error: --correlations is required", file=sys.stderr)
272 sys.exit(1)
273
274 # Load data
275 with open(corr_path, encoding="utf-8") as f:
276 correlations = json.load(f)
277
278 raw_data = None
279 if raw_path and raw_path.exists():
280 with open(raw_path, encoding="utf-8") as f:
281 raw_data = json.load(f)
282
283 previous_data = None
284 if prev_path and prev_path.exists():
285 with open(prev_path, encoding="utf-8") as f:
286 previous_data = json.load(f)
287
288 # Score
289 assessments = score_hype_risk(correlations, raw_data, previous_data)
290
291 # Build output
292 week = extract_week(args.raw or args.correlations)
293 output = {
294 "week": week,
295 "assessments": assessments,
296 }
297
298 # Write or print
299 if out_path:
300 out_path.parent.mkdir(parents=True, exist_ok=True)
301 with open(out_path, "w", encoding="utf-8") as f:
302 json.dump(output, f, indent=2)
303 print(f"Wrote {len(assessments)} assessments to {out_path}")
304 else:
305 print(json.dumps(output, indent=2))
306
307
308 if __name__ == "__main__":
309 main()