| 1 | #!/usr/bin/env python3 |
| 2 | from __future__ import annotations |
| 3 | |
| 4 | import argparse |
| 5 | import hashlib |
| 6 | import json |
| 7 | import os |
| 8 | import re |
| 9 | import secrets |
| 10 | import sys |
| 11 | import time |
| 12 | from dataclasses import asdict, dataclass |
| 13 | from datetime import datetime |
| 14 | from pathlib import Path |
| 15 | from typing import Any |
| 16 | from urllib import error, parse, request |
| 17 | |
| 18 | try: |
| 19 | from scripts.assemble_historical_context import ( |
| 20 | DEFAULT_CONTENT_ROOT, |
| 21 | assemble_historical_context, |
| 22 | ) |
| 23 | from scripts.learned_context import render_continuity |
| 24 | from scripts.sanitize_repo_content import sanitize_repo_payload |
| 25 | except ModuleNotFoundError: # pragma: no cover - script execution path |
| 26 | sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) |
| 27 | from scripts.assemble_historical_context import ( |
| 28 | DEFAULT_CONTENT_ROOT, |
| 29 | assemble_historical_context, |
| 30 | ) |
| 31 | from scripts.learned_context import render_continuity |
| 32 | from scripts.sanitize_repo_content import sanitize_repo_payload |
| 33 | |
| 34 | ROOT = Path(__file__).resolve().parent.parent |
| 35 | DEFAULT_PROMPT_TEMPLATE = ROOT / "prompts" / "analyze-weekly.md" |
| 36 | DEFAULT_ANALYZED_DIR = ROOT / "data" / "analyzed" |
| 37 | DEFAULT_WISDOM_FILE = ROOT / ".squad" / "identity" / "wisdom.md" |
| 38 | DEFAULT_SKILLS_DIR = ROOT / ".squad" / "skills" |
| 39 | DEFAULT_CONTINUITY_FILE = ROOT / ".squad" / "identity" / "continuity.md" |
| 40 | DEFAULT_MODELS_ENDPOINT = "https://models.github.ai/inference/chat/completions" |
| 41 | DEFAULT_MODELS_MODEL = "openai/gpt-4o" |
| 42 | # Synthesis step defaults to the same GitHub Models model unless overridden. |
| 43 | DEFAULT_SYNTHESIS_MODEL = DEFAULT_MODELS_MODEL |
| 44 | DEFAULT_MODELS_TIMEOUT = 30 |
| 45 | ALLOWED_MODELS_HOSTS: frozenset[str] = frozenset({"models.github.ai"}) |
| 46 | _JITTER_RANDOM = secrets.SystemRandom() |
| 47 | NO_AI_DIAGNOSTIC_QUALITY_SCORE = 40 |
| 48 | DEFAULT_PROMPT_TOKEN_BUDGET = 90_000 |
| 49 | COMPACTED_NEW_REPOS_LIMIT = 25 |
| 50 | COMPACTED_TRENDING_REPOS_LIMIT = 25 |
| 51 | COMPACTED_PREVIOUS_SUMMARY_CHARS = 8_000 |
| 52 | COMPACTED_WISDOM_CHARS = 8_000 |
| 53 | COMPACTED_SKILLS_CHARS = 10_000 |
| 54 | COMPACTED_CONTINUITY_CHARS = 8_000 |
| 55 | COMPACTED_PRESS_CONTEXT_CHARS = 14_000 |
| 56 | COMPACTED_HISTORICAL_CONTEXT_CHARS = 12_000 |
| 57 | SYNTHESIS_MAX_TOKENS = 2_000 |
| 58 | SYNTHESIS_PROMPT_TOKEN_BUDGET = 20_000 |
| 59 | RETRYABLE_STATUS_CODES = {429, 500, 502, 503, 504} |
| 60 | NON_RETRYABLE_STATUS_CLASSES = {400, 401, 403, 404} |
| 61 | MAX_RETRIES = 3 |
| 62 | BASE_DELAY = 2 # seconds |
| 63 | |
| 64 | |
| 65 | @dataclass |
| 66 | class PromptComponent: |
| 67 | name: str |
| 68 | path: str | None |
| 69 | included: bool |
| 70 | inclusion_reason: str |
| 71 | compaction_decision: str |
| 72 | bytes: int |
| 73 | token_estimate: int |
| 74 | checksum_sha256: str |
| 75 | |
| 76 | |
| 77 | @dataclass |
| 78 | class EvidenceRepoRef: |
| 79 | full_name: str |
| 80 | url: str | None |
| 81 | description: str | None |
| 82 | language: str | None |
| 83 | topics: list[str] |
| 84 | source: str |
| 85 | stars: int | None |
| 86 | stars_gained: int | None |
| 87 | created_at: str | None |
| 88 | |
| 89 | |
| 90 | @dataclass |
| 91 | class EvidencePressRef: |
| 92 | title: str | None |
| 93 | url: str |
| 94 | source: str | None |
| 95 | published_at: str | None |
| 96 | categories: list[str] |
| 97 | relevance_score: float | None |
| 98 | correlation_repos: list[str] |
| 99 | |
| 100 | |
| 101 | @dataclass |
| 102 | class EvidenceInventory: |
| 103 | name: str |
| 104 | path: str |
| 105 | item_count: int |
| 106 | bytes: int |
| 107 | token_estimate: int |
| 108 | checksum_sha256: str |
| 109 | repos: list[EvidenceRepoRef] |
| 110 | |
| 111 | |
| 112 | @dataclass |
| 113 | class PressInventory: |
| 114 | name: str |
| 115 | path: str | None |
| 116 | item_count: int |
| 117 | bytes: int |
| 118 | token_estimate: int |
| 119 | checksum_sha256: str |
| 120 | articles: list[EvidencePressRef] |
| 121 | |
| 122 | |
| 123 | @dataclass |
| 124 | class EvidenceSliceRef: |
| 125 | name: str |
| 126 | path: str | None |
| 127 | item_count: int |
| 128 | bytes: int |
| 129 | token_estimate: int |
| 130 | checksum_sha256: str |
| 131 | provenance: dict[str, Any] |
| 132 | validation_errors: list[str] |
| 133 | |
| 134 | |
| 135 | @dataclass |
| 136 | class PromptPreflight: |
| 137 | schema_version: str |
| 138 | prompt_token_budget: int |
| 139 | prompt_tokens: int |
| 140 | prompt_bytes: int |
| 141 | prompt_checksum_sha256: str |
| 142 | rendered_prompt_estimate: dict[str, int | str] |
| 143 | prompt_within_budget: bool |
| 144 | degraded: bool |
| 145 | publish_eligible: bool |
| 146 | promotion_policy: str |
| 147 | degradation_reason: str | None |
| 148 | fallback_policy: str |
| 149 | components: list[PromptComponent] |
| 150 | deterministic_slices: list[str] |
| 151 | generated_evidence_slices: list[EvidenceSliceRef] |
| 152 | evidence_slice_payloads: dict[str, dict[str, Any]] |
| 153 | evidence_inventories: list[EvidenceInventory] |
| 154 | press_inventories: list[PressInventory] |
| 155 | |
| 156 | |
| 157 | def parse_args(argv: list[str] | None = None) -> argparse.Namespace: |
| 158 | parser = argparse.ArgumentParser( |
| 159 | description="Render/preflight weekly analysis prompts or generate diagnostic no-AI output." |
| 160 | ) |
| 161 | parser.add_argument( |
| 162 | "--raw-json", required=True, type=Path, help="Path to the weekly raw JSON payload." |
| 163 | ) |
| 164 | parser.add_argument( |
| 165 | "--output", required=True, type=Path, help="Path to write the analyzed markdown output." |
| 166 | ) |
| 167 | parser.add_argument( |
| 168 | "--current-datetime", required=True, help="ISO-8601 timestamp for the analysis run." |
| 169 | ) |
| 170 | parser.add_argument( |
| 171 | "--prompt-template", |
| 172 | type=Path, |
| 173 | default=DEFAULT_PROMPT_TEMPLATE, |
| 174 | help="Prompt template path (defaults to prompts/analyze-weekly.md).", |
| 175 | ) |
| 176 | parser.add_argument( |
| 177 | "--analyzed-dir", |
| 178 | type=Path, |
| 179 | default=DEFAULT_ANALYZED_DIR, |
| 180 | help="Directory containing prior weekly summaries.", |
| 181 | ) |
| 182 | parser.add_argument( |
| 183 | "--wisdom-file", |
| 184 | type=Path, |
| 185 | default=DEFAULT_WISDOM_FILE, |
| 186 | help="Path to the learned wisdom markdown file.", |
| 187 | ) |
| 188 | parser.add_argument( |
| 189 | "--skills-dir", |
| 190 | type=Path, |
| 191 | default=DEFAULT_SKILLS_DIR, |
| 192 | help="Directory containing learned skill markdown files.", |
| 193 | ) |
| 194 | parser.add_argument( |
| 195 | "--continuity-file", |
| 196 | type=Path, |
| 197 | default=DEFAULT_CONTINUITY_FILE, |
| 198 | help="Path to the learned continuity capsule markdown file.", |
| 199 | ) |
| 200 | parser.add_argument( |
| 201 | "--content-root", |
| 202 | type=Path, |
| 203 | default=DEFAULT_CONTENT_ROOT, |
| 204 | help="Path to the content root used for historical context assembly.", |
| 205 | ) |
| 206 | parser.add_argument( |
| 207 | "--press-context", |
| 208 | type=Path, |
| 209 | default=None, |
| 210 | help="Path to rendered press context markdown (appended to prompt).", |
| 211 | ) |
| 212 | parser.add_argument( |
| 213 | "--print-prompt", |
| 214 | action="store_true", |
| 215 | help="Render the prompt to stdout without calling GitHub Models.", |
| 216 | ) |
| 217 | parser.add_argument( |
| 218 | "--no-ai", |
| 219 | action="store_true", |
| 220 | help="Generate a data-only summary without calling any AI API.", |
| 221 | ) |
| 222 | parser.add_argument( |
| 223 | "--prompt-token-budget", |
| 224 | type=int, |
| 225 | default=DEFAULT_PROMPT_TOKEN_BUDGET, |
| 226 | help=f"Maximum rendered prompt tokens before model invocation (default: {DEFAULT_PROMPT_TOKEN_BUDGET}).", |
| 227 | ) |
| 228 | parser.add_argument( |
| 229 | "--preflight-report-json", |
| 230 | type=Path, |
| 231 | help="Write deterministic rendered-prompt preflight details as JSON.", |
| 232 | ) |
| 233 | parser.add_argument( |
| 234 | "--preflight-report-md", |
| 235 | type=Path, |
| 236 | help="Write deterministic rendered-prompt preflight details as Markdown.", |
| 237 | ) |
| 238 | parser.add_argument( |
| 239 | "--run-synthesis", |
| 240 | action="store_true", |
| 241 | help="Run Step 1 synthesis (press/historical context → compact narrative) and exit.", |
| 242 | ) |
| 243 | parser.add_argument( |
| 244 | "--synthesis-output", |
| 245 | type=Path, |
| 246 | default=None, |
| 247 | help="Path to write the synthesis narrative output (used with --run-synthesis).", |
| 248 | ) |
| 249 | parser.add_argument( |
| 250 | "--synthesis-input", |
| 251 | type=Path, |
| 252 | default=None, |
| 253 | help="Path to a pre-computed synthesis narrative to inject into the analysis prompt (Step 2).", |
| 254 | ) |
| 255 | return parser.parse_args(argv) |
| 256 | |
| 257 | |
| 258 | def load_json(path: Path) -> dict[str, Any]: |
| 259 | return json.loads(path.read_text(encoding="utf-8")) |
| 260 | |
| 261 | |
| 262 | def estimate_tokens(text: str) -> int: |
| 263 | """Deterministic local estimate used for preflight bounds.""" |
| 264 | return (len(text.encode("utf-8")) + 3) // 4 |
| 265 | |
| 266 | |
| 267 | def checksum_text(text: str) -> str: |
| 268 | return hashlib.sha256(text.encode("utf-8")).hexdigest() |
| 269 | |
| 270 | |
| 271 | def stable_json(payload: Any) -> str: |
| 272 | return json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n" |
| 273 | |
| 274 | |
| 275 | def checksum_payload(payload: Any) -> str: |
| 276 | return checksum_text(stable_json(payload)) |
| 277 | |
| 278 | |
| 279 | def _component( |
| 280 | *, |
| 281 | name: str, |
| 282 | content: str, |
| 283 | path: Path | None, |
| 284 | included: bool, |
| 285 | inclusion_reason: str, |
| 286 | compaction_decision: str, |
| 287 | ) -> PromptComponent: |
| 288 | return PromptComponent( |
| 289 | name=name, |
| 290 | path=path.as_posix() if path else None, |
| 291 | included=included, |
| 292 | inclusion_reason=inclusion_reason, |
| 293 | compaction_decision=compaction_decision, |
| 294 | bytes=len(content.encode("utf-8")), |
| 295 | token_estimate=estimate_tokens(content), |
| 296 | checksum_sha256=checksum_text(content), |
| 297 | ) |
| 298 | |
| 299 | |
| 300 | def _repo_int(value: Any) -> int | None: |
| 301 | return value if isinstance(value, int) and not isinstance(value, bool) else None |
| 302 | |
| 303 | |
| 304 | def _repo_topics(value: Any) -> list[str]: |
| 305 | if not isinstance(value, list): |
| 306 | return [] |
| 307 | return [ |
| 308 | str(topic) for topic in value if isinstance(topic, (str, int, float)) and str(topic).strip() |
| 309 | ] |
| 310 | |
| 311 | |
| 312 | REQUIRED_REPO_SLICE_FIELDS = ( |
| 313 | "full_name", |
| 314 | "url", |
| 315 | "description", |
| 316 | "language", |
| 317 | "topics", |
| 318 | "stars", |
| 319 | "stars_gained", |
| 320 | "created_at", |
| 321 | ) |
| 322 | |
| 323 | |
| 324 | def compact_repo_record(repo: dict[str, Any], *, source: str) -> dict[str, Any]: |
| 325 | full_name = str(repo.get("full_name") or "").strip() |
| 326 | url = repo.get("url") |
| 327 | return { |
| 328 | "full_name": full_name, |
| 329 | "url": url |
| 330 | if isinstance(url, str) and url.strip() |
| 331 | else (f"https://github.com/{full_name}" if full_name else None), |
| 332 | "description": repo.get("description") |
| 333 | if isinstance(repo.get("description"), str) |
| 334 | else None, |
| 335 | "language": repo.get("language") if isinstance(repo.get("language"), str) else None, |
| 336 | "topics": _repo_topics(repo.get("topics")), |
| 337 | "stars": _repo_int(repo.get("stars")), |
| 338 | "stars_gained": _repo_int(repo.get("stars_gained")), |
| 339 | "created_at": repo.get("created_at") if isinstance(repo.get("created_at"), str) else None, |
| 340 | "source": source, |
| 341 | } |
| 342 | |
| 343 | |
| 344 | def _inventory_repo_refs(payload: dict[str, Any], field: str) -> list[EvidenceRepoRef]: |
| 345 | repos = payload.get(field) |
| 346 | if not isinstance(repos, list): |
| 347 | return [] |
| 348 | refs: list[EvidenceRepoRef] = [] |
| 349 | for repo in repos: |
| 350 | if not isinstance(repo, dict): |
| 351 | continue |
| 352 | full_name = repo.get("full_name") |
| 353 | if not isinstance(full_name, str) or "/" not in full_name: |
| 354 | continue |
| 355 | url = repo.get("url") |
| 356 | refs.append( |
| 357 | EvidenceRepoRef( |
| 358 | full_name=full_name.strip(), |
| 359 | url=url if isinstance(url, str) and url.strip() else None, |
| 360 | description=repo.get("description") |
| 361 | if isinstance(repo.get("description"), str) |
| 362 | else None, |
| 363 | language=repo.get("language") if isinstance(repo.get("language"), str) else None, |
| 364 | topics=_repo_topics(repo.get("topics")), |
| 365 | source=field, |
| 366 | stars=_repo_int(repo.get("stars")), |
| 367 | stars_gained=_repo_int(repo.get("stars_gained")), |
| 368 | created_at=repo.get("created_at") |
| 369 | if isinstance(repo.get("created_at"), str) |
| 370 | else None, |
| 371 | ) |
| 372 | ) |
| 373 | return refs |
| 374 | |
| 375 | |
| 376 | def _evidence_inventory( |
| 377 | name: str, payload: dict[str, Any], field: str, path: Path |
| 378 | ) -> EvidenceInventory: |
| 379 | content = json.dumps(payload.get(field, []), indent=2, ensure_ascii=False) |
| 380 | repos = _inventory_repo_refs(payload, field) |
| 381 | return EvidenceInventory( |
| 382 | name=name, |
| 383 | path=path.as_posix(), |
| 384 | item_count=len(repos), |
| 385 | bytes=len(content.encode("utf-8")), |
| 386 | token_estimate=estimate_tokens(content), |
| 387 | checksum_sha256=checksum_text(content), |
| 388 | repos=repos, |
| 389 | ) |
| 390 | |
| 391 | |
| 392 | def _press_paths_for_context( |
| 393 | press_context_path: Path | None, week: str |
| 394 | ) -> tuple[Path | None, Path | None]: |
| 395 | if press_context_path is None: |
| 396 | return None, None |
| 397 | data_dir = press_context_path.parent.parent |
| 398 | external_path = data_dir / "raw" / f"{week}-external-news.json" |
| 399 | legacy_path = data_dir / "raw" / f"{week}-techcrunch.json" |
| 400 | corr_path = data_dir / "analyzed" / f"{week}-correlations.json" |
| 401 | news_path = ( |
| 402 | external_path if external_path.exists() else legacy_path if legacy_path.exists() else None |
| 403 | ) |
| 404 | return news_path, corr_path if corr_path.exists() else None |
| 405 | |
| 406 | |
| 407 | def _safe_load_json(path: Path | None) -> dict[str, Any] | None: |
| 408 | if path is None or not path.exists(): |
| 409 | return None |
| 410 | try: |
| 411 | payload = json.loads(path.read_text(encoding="utf-8")) |
| 412 | except (OSError, json.JSONDecodeError): |
| 413 | return None |
| 414 | return payload if isinstance(payload, dict) else None |
| 415 | |
| 416 | |
| 417 | def _article_inventory( |
| 418 | news_payload: dict[str, Any] | None, |
| 419 | correlation_payload: dict[str, Any] | None, |
| 420 | path: Path | None, |
| 421 | ) -> PressInventory: |
| 422 | articles = news_payload.get("articles", []) if news_payload else [] |
| 423 | correlations = correlation_payload.get("correlations", []) if correlation_payload else [] |
| 424 | repo_by_url: dict[str, set[str]] = {} |
| 425 | for corr in correlations if isinstance(correlations, list) else []: |
| 426 | if not isinstance(corr, dict): |
| 427 | continue |
| 428 | repo = corr.get("repo") |
| 429 | for url in ( |
| 430 | corr.get("matched_articles", []) |
| 431 | if isinstance(corr.get("matched_articles"), list) |
| 432 | else [] |
| 433 | ): |
| 434 | if isinstance(url, str) and isinstance(repo, str): |
| 435 | repo_by_url.setdefault(url, set()).add(repo) |
| 436 | for detail in ( |
| 437 | corr.get("matched_article_details", []) |
| 438 | if isinstance(corr.get("matched_article_details"), list) |
| 439 | else [] |
| 440 | ): |
| 441 | if ( |
| 442 | isinstance(detail, dict) |
| 443 | and isinstance(detail.get("url"), str) |
| 444 | and isinstance(repo, str) |
| 445 | ): |
| 446 | repo_by_url.setdefault(detail["url"], set()).add(repo) |
| 447 | refs: list[EvidencePressRef] = [] |
| 448 | for article in articles if isinstance(articles, list) else []: |
| 449 | if ( |
| 450 | not isinstance(article, dict) |
| 451 | or not isinstance(article.get("url"), str) |
| 452 | or not article["url"].strip() |
| 453 | ): |
| 454 | continue |
| 455 | categories = ( |
| 456 | article.get("categories") if isinstance(article.get("categories"), list) else [] |
| 457 | ) |
| 458 | relevance = article.get("relevance_score") |
| 459 | refs.append( |
| 460 | EvidencePressRef( |
| 461 | title=article.get("title") if isinstance(article.get("title"), str) else None, |
| 462 | url=article["url"], |
| 463 | source=article.get("source") if isinstance(article.get("source"), str) else None, |
| 464 | published_at=article.get("published_at") |
| 465 | if isinstance(article.get("published_at"), str) |
| 466 | else None, |
| 467 | categories=[str(category) for category in categories], |
| 468 | relevance_score=float(relevance) |
| 469 | if isinstance(relevance, (int, float)) and not isinstance(relevance, bool) |
| 470 | else None, |
| 471 | correlation_repos=sorted(repo_by_url.get(article["url"], set())), |
| 472 | ) |
| 473 | ) |
| 474 | content = stable_json([asdict(ref) for ref in refs]) |
| 475 | return PressInventory( |
| 476 | name="press_articles", |
| 477 | path=path.as_posix() if path else None, |
| 478 | item_count=len(refs), |
| 479 | bytes=len(content.encode("utf-8")), |
| 480 | token_estimate=estimate_tokens(content), |
| 481 | checksum_sha256=checksum_text(content), |
| 482 | articles=refs, |
| 483 | ) |
| 484 | |
| 485 | |
| 486 | def _source_ref(path: Path | None, content: str | None = None) -> dict[str, Any] | None: |
| 487 | if path is None and content is None: |
| 488 | return None |
| 489 | if content is None: |
| 490 | if path is None or not path.exists(): |
| 491 | return None |
| 492 | data = path.read_bytes() |
| 493 | return { |
| 494 | "path": path.as_posix(), |
| 495 | "bytes": len(data), |
| 496 | "sha256": hashlib.sha256(data).hexdigest(), |
| 497 | } |
| 498 | encoded = content.encode("utf-8") |
| 499 | return { |
| 500 | "path": path.as_posix() if path else None, |
| 501 | "bytes": len(encoded), |
| 502 | "sha256": checksum_text(content), |
| 503 | } |
| 504 | |
| 505 | |
| 506 | def _slice_checksum_payload(payload: dict[str, Any]) -> dict[str, Any]: |
| 507 | stripped = dict(payload) |
| 508 | stripped.pop("checksum_sha256", None) |
| 509 | return stripped |
| 510 | |
| 511 | |
| 512 | def validate_evidence_slice( |
| 513 | payload: dict[str, Any], *, expected_checksum: str | None = None |
| 514 | ) -> list[str]: |
| 515 | errors: list[str] = [] |
| 516 | for field in ( |
| 517 | "schema_version", |
| 518 | "slice_name", |
| 519 | "component", |
| 520 | "records", |
| 521 | "provenance", |
| 522 | "checksum_sha256", |
| 523 | ): |
| 524 | if field not in payload: |
| 525 | errors.append(f"slice missing {field}") |
| 526 | checksum = payload.get("checksum_sha256") |
| 527 | if isinstance(checksum, str): |
| 528 | actual = checksum_payload(_slice_checksum_payload(payload)) |
| 529 | if checksum != actual: |
| 530 | errors.append("slice checksum mismatch") |
| 531 | if expected_checksum is not None and checksum != expected_checksum: |
| 532 | errors.append("slice checksum does not match manifest reference") |
| 533 | elif "checksum_sha256" in payload: |
| 534 | errors.append("slice checksum_sha256 must be a string") |
| 535 | records = payload.get("records") |
| 536 | if not isinstance(records, list): |
| 537 | errors.append("slice records must be a list") |
| 538 | records = [] |
| 539 | provenance = payload.get("provenance") |
| 540 | if not isinstance(provenance, dict): |
| 541 | errors.append("slice provenance must be an object") |
| 542 | else: |
| 543 | sources = provenance.get("sources") |
| 544 | if not isinstance(sources, dict) or not sources: |
| 545 | errors.append("slice provenance sources missing") |
| 546 | else: |
| 547 | for name, source in sources.items(): |
| 548 | if ( |
| 549 | not isinstance(source, dict) |
| 550 | or not source.get("sha256") |
| 551 | or not isinstance(source.get("bytes"), int) |
| 552 | ): |
| 553 | errors.append(f"slice provenance source {name} missing checksum/bytes") |
| 554 | if payload.get("component") in {"new_repos", "trending_repos"}: |
| 555 | for index, record in enumerate(records): |
| 556 | if not isinstance(record, dict): |
| 557 | errors.append(f"record {index} must be an object") |
| 558 | continue |
| 559 | for field in REQUIRED_REPO_SLICE_FIELDS: |
| 560 | if field not in record: |
| 561 | errors.append(f"record {index} missing {field}") |
| 562 | return errors |
| 563 | |
| 564 | |
| 565 | def _build_slice( |
| 566 | name: str, records: list[dict[str, Any]], provenance: dict[str, Any] |
| 567 | ) -> dict[str, Any]: |
| 568 | payload = { |
| 569 | "schema_version": "analysis_evidence_slice_v1", |
| 570 | "slice_name": name, |
| 571 | "component": name, |
| 572 | "records": records, |
| 573 | "provenance": provenance, |
| 574 | } |
| 575 | payload["checksum_sha256"] = checksum_payload(payload) |
| 576 | return payload |
| 577 | |
| 578 | |
| 579 | def build_evidence_slices( |
| 580 | *, |
| 581 | week: str, |
| 582 | raw_path: Path, |
| 583 | sanitized_payload: dict[str, Any], |
| 584 | payload_for_prompt: dict[str, Any], |
| 585 | press_context_path: Path | None, |
| 586 | press_content: str, |
| 587 | previous_summary_path: Path | None, |
| 588 | previous_summary_content: str, |
| 589 | ) -> dict[str, dict[str, Any]]: |
| 590 | raw_source = _source_ref(raw_path) |
| 591 | press_source = _source_ref(press_context_path, press_content) if press_content else None |
| 592 | previous_source = ( |
| 593 | _source_ref(previous_summary_path, previous_summary_content) |
| 594 | if previous_summary_content |
| 595 | else None |
| 596 | ) |
| 597 | news_path, corr_path = _press_paths_for_context(press_context_path, week) |
| 598 | corr_payload = _safe_load_json(corr_path) |
| 599 | news_source = _source_ref(news_path) |
| 600 | corr_source = _source_ref(corr_path) |
| 601 | base_provenance = {"week": week, "sources": {"raw_json": raw_source} if raw_source else {}} |
| 602 | slices = { |
| 603 | "new_repos": _build_slice( |
| 604 | "new_repos", |
| 605 | [ |
| 606 | compact_repo_record(repo, source="new_repos") |
| 607 | for repo in payload_for_prompt.get("new_repos", []) |
| 608 | if isinstance(repo, dict) |
| 609 | ], |
| 610 | base_provenance, |
| 611 | ), |
| 612 | "trending_repos": _build_slice( |
| 613 | "trending_repos", |
| 614 | [ |
| 615 | compact_repo_record(repo, source="trending_repos") |
| 616 | for repo in payload_for_prompt.get("trending_repos", []) |
| 617 | if isinstance(repo, dict) |
| 618 | ], |
| 619 | base_provenance, |
| 620 | ), |
| 621 | } |
| 622 | press_sources = {} |
| 623 | for key, source in ( |
| 624 | ("raw_json", raw_source), |
| 625 | ("press_context", press_source), |
| 626 | ("external_news", news_source), |
| 627 | ("correlations", corr_source), |
| 628 | ): |
| 629 | if source: |
| 630 | press_sources[key] = source |
| 631 | press_records: list[dict[str, Any]] = [] |
| 632 | correlations = corr_payload.get("correlations", []) if corr_payload else [] |
| 633 | for corr in correlations if isinstance(correlations, list) else []: |
| 634 | if isinstance(corr, dict): |
| 635 | press_records.append( |
| 636 | { |
| 637 | "repo": corr.get("repo"), |
| 638 | "matched_articles": corr.get("matched_articles", []), |
| 639 | "matched_article_details": corr.get("matched_article_details", []), |
| 640 | "match_type": corr.get("match_type"), |
| 641 | "correlation_confidence": corr.get("correlation_confidence"), |
| 642 | "correlation_strength": corr.get("correlation_strength"), |
| 643 | "hype_risk": corr.get("hype_risk"), |
| 644 | } |
| 645 | ) |
| 646 | if not press_records and press_content: |
| 647 | urls = sorted(set(re.findall(r"https?://[^\s)\]]+", press_content))) |
| 648 | press_records = [ |
| 649 | {"url": url.rstrip(".,"), "source": "rendered_press_context"} for url in urls |
| 650 | ] |
| 651 | slices["press_correlations"] = _build_slice( |
| 652 | "press_correlations", |
| 653 | press_records, |
| 654 | {"week": week, "sources": press_sources}, |
| 655 | ) |
| 656 | prior_sources = {"raw_json": raw_source} if raw_source else {} |
| 657 | if previous_source: |
| 658 | prior_sources["prior_summary"] = previous_source |
| 659 | slices["prior_continuity"] = _build_slice( |
| 660 | "prior_continuity", |
| 661 | [ |
| 662 | { |
| 663 | "source_path": previous_summary_path.as_posix() if previous_summary_path else None, |
| 664 | "present": bool(previous_summary_content), |
| 665 | "excerpt": previous_summary_content[:1000], |
| 666 | } |
| 667 | ], |
| 668 | {"week": week, "sources": prior_sources}, |
| 669 | ) |
| 670 | return slices |
| 671 | |
| 672 | |
| 673 | def write_evidence_slices( |
| 674 | slices: dict[str, dict[str, Any]], manifest_path: Path | None |
| 675 | ) -> list[EvidenceSliceRef]: |
| 676 | refs: list[EvidenceSliceRef] = [] |
| 677 | output_dir = manifest_path.parent / "evidence-slices" if manifest_path else None |
| 678 | if output_dir: |
| 679 | output_dir.mkdir(parents=True, exist_ok=True) |
| 680 | for name in sorted(slices): |
| 681 | payload = slices[name] |
| 682 | checksum = str(payload["checksum_sha256"]) |
| 683 | text = stable_json(payload) |
| 684 | path = output_dir / f"{name}-{checksum[:12]}.json" if output_dir else None |
| 685 | if path: |
| 686 | path.write_text(text, encoding="utf-8") |
| 687 | refs.append( |
| 688 | EvidenceSliceRef( |
| 689 | name=name, |
| 690 | path=path.as_posix() if path else None, |
| 691 | item_count=len(payload.get("records", [])) |
| 692 | if isinstance(payload.get("records"), list) |
| 693 | else 0, |
| 694 | bytes=len(text.encode("utf-8")), |
| 695 | token_estimate=estimate_tokens(text), |
| 696 | checksum_sha256=checksum, |
| 697 | provenance=payload.get("provenance", {}) |
| 698 | if isinstance(payload.get("provenance"), dict) |
| 699 | else {}, |
| 700 | validation_errors=validate_evidence_slice(payload), |
| 701 | ) |
| 702 | ) |
| 703 | return refs |
| 704 | |
| 705 | |
| 706 | def truncate_with_notice(content: str, limit: int, label: str) -> tuple[str, str]: |
| 707 | if len(content) <= limit: |
| 708 | return content, "included" |
| 709 | omitted = len(content) - limit |
| 710 | return ( |
| 711 | content[:limit].rstrip() |
| 712 | + f"\n\n[Preflight compaction: truncated {label}; omitted {omitted} characters to stay within prompt budget.]", |
| 713 | "compacted", |
| 714 | ) |
| 715 | |
| 716 | |
| 717 | def _load_yaml(path: Path) -> dict[str, Any]: |
| 718 | try: |
| 719 | import yaml # type: ignore[import-untyped] |
| 720 | except ImportError: |
| 721 | return {} |
| 722 | if not path.exists(): |
| 723 | return {} |
| 724 | with open(path, encoding="utf-8") as f: |
| 725 | payload = yaml.safe_load(f) or {} |
| 726 | return payload if isinstance(payload, dict) else {} |
| 727 | |
| 728 | |
| 729 | def _resolve_existing_path(configured: str | None, fallback: Path) -> Path: |
| 730 | candidates: list[Path] = [] |
| 731 | if configured: |
| 732 | configured_path = Path(configured) |
| 733 | candidates.append( |
| 734 | configured_path if configured_path.is_absolute() else ROOT / configured_path |
| 735 | ) |
| 736 | if not configured_path.is_absolute(): |
| 737 | candidates.append(ROOT / ".squad" / configured_path) |
| 738 | candidates.append(fallback) |
| 739 | for candidate in candidates: |
| 740 | if candidate.exists(): |
| 741 | return candidate |
| 742 | return fallback |
| 743 | |
| 744 | |
| 745 | def resolve_analysis_context_paths() -> tuple[Path, Path, Path]: |
| 746 | """Resolve analysis-specific learned context, avoiding unrelated squad workflow context.""" |
| 747 | config = _load_yaml(ROOT / "squadscope.topic.yml") |
| 748 | topic = config.get("topic") if isinstance(config.get("topic"), dict) else {} |
| 749 | learning = config.get("learning") if isinstance(config.get("learning"), dict) else {} |
| 750 | topic_id = str(topic.get("id") or "general") |
| 751 | wisdom_path = _resolve_existing_path( |
| 752 | learning.get("wisdom_file"), |
| 753 | ROOT / ".squad" / "topics" / topic_id / "wisdom.md", |
| 754 | ) |
| 755 | skills_path = _resolve_existing_path( |
| 756 | learning.get("skills_dir"), |
| 757 | ROOT / ".squad" / "topics" / topic_id / "skills", |
| 758 | ) |
| 759 | continuity_path = _resolve_existing_path( |
| 760 | learning.get("continuity_file"), |
| 761 | ROOT / ".squad" / "topics" / topic_id / "continuity.md", |
| 762 | ) |
| 763 | return wisdom_path, skills_path, continuity_path |
| 764 | |
| 765 | |
| 766 | def find_previous_summary(current_week: str, analyzed_dir: Path) -> Path | None: |
| 767 | if not analyzed_dir.exists(): |
| 768 | return None |
| 769 | |
| 770 | candidates = [] |
| 771 | for path in analyzed_dir.glob("*-summary.md"): |
| 772 | week = path.name.removesuffix("-summary.md") |
| 773 | if week < current_week: |
| 774 | candidates.append(path) |
| 775 | return max(candidates, default=None) |
| 776 | |
| 777 | |
| 778 | def render_wisdom(wisdom_file: Path) -> str: |
| 779 | if not wisdom_file.exists(): |
| 780 | return "_No learned wisdom has been recorded yet._" |
| 781 | |
| 782 | content = wisdom_file.read_text(encoding="utf-8").strip() |
| 783 | if not content: |
| 784 | return "_No learned wisdom has been recorded yet._" |
| 785 | # Sanitize boundary markers to prevent fence escape from prior LLM output |
| 786 | from scripts.sanitize_repo_content import _escape_untrusted_boundaries |
| 787 | |
| 788 | return _escape_untrusted_boundaries(content) |
| 789 | |
| 790 | |
| 791 | def iter_skill_files(skills_dir: Path) -> list[Path]: |
| 792 | if not skills_dir.exists(): |
| 793 | return [] |
| 794 | return sorted(path for path in skills_dir.rglob("*.md") if path.is_file()) |
| 795 | |
| 796 | |
| 797 | def render_skills(skills_dir: Path) -> str: |
| 798 | skill_files = iter_skill_files(skills_dir) |
| 799 | if not skill_files: |
| 800 | return "_No learned skills have been extracted yet._" |
| 801 | |
| 802 | from scripts.sanitize_repo_content import _escape_untrusted_boundaries |
| 803 | |
| 804 | blocks = [] |
| 805 | for path in skill_files: |
| 806 | relative_path = path.relative_to(ROOT) if path.is_relative_to(ROOT) else path |
| 807 | content = path.read_text(encoding="utf-8").strip() |
| 808 | if not content: |
| 809 | continue |
| 810 | # Sanitize boundary markers to prevent fence escape from prior LLM output |
| 811 | content = _escape_untrusted_boundaries(content) |
| 812 | blocks.append(f"--- Skill Source: {relative_path} ---\n{content}") |
| 813 | return "\n\n".join(blocks) if blocks else "_No learned skills have been extracted yet._" |
| 814 | |
| 815 | |
| 816 | def _sort_repos_for_compaction(repos: list[dict[str, Any]], score_key: str) -> list[dict[str, Any]]: |
| 817 | return sorted( |
| 818 | repos, |
| 819 | key=lambda repo: ( |
| 820 | int(repo.get(score_key) or 0), |
| 821 | int(repo.get("stars") or 0), |
| 822 | str(repo.get("full_name") or ""), |
| 823 | ), |
| 824 | reverse=True, |
| 825 | ) |
| 826 | |
| 827 | |
| 828 | def compact_payload(payload: dict[str, Any]) -> tuple[dict[str, Any], dict[str, str]]: |
| 829 | compacted = dict(payload) |
| 830 | decisions = {"new_repos": "included", "trending_repos": "included"} |
| 831 | new_repos = payload.get("new_repos") |
| 832 | if isinstance(new_repos, list) and len(new_repos) > COMPACTED_NEW_REPOS_LIMIT: |
| 833 | compacted["new_repos"] = _sort_repos_for_compaction(new_repos, "stars")[ |
| 834 | :COMPACTED_NEW_REPOS_LIMIT |
| 835 | ] |
| 836 | decisions["new_repos"] = f"compacted to top {COMPACTED_NEW_REPOS_LIMIT} repos by stars" |
| 837 | trending_repos = payload.get("trending_repos") |
| 838 | if isinstance(trending_repos, list) and len(trending_repos) > COMPACTED_TRENDING_REPOS_LIMIT: |
| 839 | compacted["trending_repos"] = _sort_repos_for_compaction(trending_repos, "stars_gained")[ |
| 840 | :COMPACTED_TRENDING_REPOS_LIMIT |
| 841 | ] |
| 842 | decisions["trending_repos"] = ( |
| 843 | f"compacted to top {COMPACTED_TRENDING_REPOS_LIMIT} repos by stars_gained/stars" |
| 844 | ) |
| 845 | if decisions["new_repos"] != "included" or decisions["trending_repos"] != "included": |
| 846 | compacted["_preflight_compaction"] = { |
| 847 | "reason": "Rendered prompt exceeded explicit token budget before model invocation.", |
| 848 | "new_repos_original_count": len(new_repos) if isinstance(new_repos, list) else 0, |
| 849 | "trending_repos_original_count": len(trending_repos) |
| 850 | if isinstance(trending_repos, list) |
| 851 | else 0, |
| 852 | "new_repos_decision": decisions["new_repos"], |
| 853 | "trending_repos_decision": decisions["trending_repos"], |
| 854 | } |
| 855 | return compacted, decisions |
| 856 | |
| 857 | |
| 858 | def _strip_ai_instruction_blocks(text: str) -> str: |
| 859 | """Remove AI-only instruction sections (### Instructions, directives) from press context. |
| 860 | |
| 861 | These blocks are intended for the main analysis prompt and should not be |
| 862 | forwarded into synthesis to reduce prompt-injection surface area. |
| 863 | """ |
| 864 | # Remove markdown sections starting with ### Instructions (case-insensitive) |
| 865 | # up to the next same-or-higher-level heading or end of text |
| 866 | text = re.sub( |
| 867 | r"(?m)^###\s+Instructions?\b.*?(?=^#{1,3}\s|\Z)", |
| 868 | "", |
| 869 | text, |
| 870 | flags=re.DOTALL | re.IGNORECASE, |
| 871 | ) |
| 872 | # Remove divergence directive blocks (commonly marked with special tags) |
| 873 | text = re.sub( |
| 874 | r"(?m)^<!--\s*(?:ai-only|divergence|directive)\b.*?-->.*?(?:<!--\s*/(?:ai-only|divergence|directive)\s*-->|\Z)", |
| 875 | "", |
| 876 | text, |
| 877 | flags=re.DOTALL | re.IGNORECASE, |
| 878 | ) |
| 879 | return text.strip() |
| 880 | |
| 881 | |
| 882 | def _build_synthesis_prompt( |
| 883 | *, |
| 884 | press_content: str, |
| 885 | historical_context_content: str, |
| 886 | continuity_content: str, |
| 887 | current_week: str, |
| 888 | current_datetime: str, |
| 889 | ) -> str: |
| 890 | """Build a compact prompt for Step 1: Industry & Press Synthesis. |
| 891 | |
| 892 | Input: press context + historical context + continuity capsule. |
| 893 | Output instruction: max 2K token narrative of the tech industry landscape this week. |
| 894 | """ |
| 895 | sections = [] |
| 896 | sections.append( |
| 897 | "You are an expert technology industry analyst. Your task is to synthesize " |
| 898 | "the provided press context, historical context, and continuity notes into a " |
| 899 | "compact industry narrative (maximum 2000 tokens / ~1500 words).\n\n" |
| 900 | "Focus on:\n" |
| 901 | "- Key technology trends and shifts happening this week\n" |
| 902 | "- Notable industry movements (acquisitions, launches, pivots)\n" |
| 903 | "- Developer ecosystem changes\n" |
| 904 | "- Connections to longer-term patterns from historical context\n\n" |
| 905 | "Output ONLY the narrative — no headers, no metadata, no instructions. " |
| 906 | "Write in a dense, information-rich style suitable for feeding into a downstream " |
| 907 | "analysis step that will correlate this with GitHub repository data.\n\n" |
| 908 | f"Current week: {current_week}\n" |
| 909 | f"Current datetime: {current_datetime}\n" |
| 910 | ) |
| 911 | if press_content: |
| 912 | sections.append(f"## Press Context\n\n{press_content}") |
| 913 | if historical_context_content: |
| 914 | sections.append(f"## Historical Context\n\n{historical_context_content}") |
| 915 | if ( |
| 916 | continuity_content |
| 917 | and continuity_content != "_No continuity capsule has been recorded yet._" |
| 918 | ): |
| 919 | sections.append(f"## Continuity Notes\n\n{continuity_content}") |
| 920 | |
| 921 | return "\n\n---\n\n".join(sections) |
| 922 | |
| 923 | |
| 924 | def render_synthesis_prompt( |
| 925 | *, |
| 926 | press_context_path: Path | None = None, |
| 927 | content_root: Path = DEFAULT_CONTENT_ROOT, |
| 928 | continuity_file: Path = DEFAULT_CONTINUITY_FILE, |
| 929 | current_datetime: str, |
| 930 | current_week: str, |
| 931 | previous_summary_path: Path | None = None, |
| 932 | prompt_token_budget: int = SYNTHESIS_PROMPT_TOKEN_BUDGET, |
| 933 | ) -> str: |
| 934 | """Render the synthesis prompt to a string (for Copilot CLI to process). |
| 935 | |
| 936 | Returns the prompt string, or empty string if no meaningful content exists. |
| 937 | """ |
| 938 | historical_context_content = assemble_historical_context( |
| 939 | current_datetime=current_datetime, |
| 940 | previous_summary_path=previous_summary_path, |
| 941 | content_root=content_root, |
| 942 | max_words=1_500, |
| 943 | prompt_token_budget=prompt_token_budget, |
| 944 | ).strip() |
| 945 | from scripts.sanitize_repo_content import _escape_untrusted_boundaries |
| 946 | |
| 947 | historical_context_content = _escape_untrusted_boundaries(historical_context_content) |
| 948 | if not historical_context_content: |
| 949 | historical_context_content = ( |
| 950 | "_No historical context was available beyond the current weekly payload._" |
| 951 | ) |
| 952 | |
| 953 | continuity_content = render_continuity(continuity_file) |
| 954 | |
| 955 | press_content = ( |
| 956 | press_context_path.read_text(encoding="utf-8").strip() |
| 957 | if press_context_path |
| 958 | and press_context_path.exists() |
| 959 | and press_context_path.stat().st_size > 0 |
| 960 | else "" |
| 961 | ) |
| 962 | |
| 963 | # The no-press sentinel is a NON-EMPTY string; treat it as ABSENT so the |
| 964 | # synthesis narrative is not built from a fake "## Press Context". |
| 965 | from scripts.render_press_context import NO_PRESS_SENTINEL_MARKER |
| 966 | |
| 967 | if press_content and NO_PRESS_SENTINEL_MARKER.search(press_content): |
| 968 | press_content = "" |
| 969 | |
| 970 | if press_content: |
| 971 | press_content = _strip_ai_instruction_blocks(press_content) |
| 972 | if press_content: |
| 973 | press_content = _escape_untrusted_boundaries(press_content) |
| 974 | |
| 975 | # If there's no meaningful content to synthesize, return empty |
| 976 | if not press_content and historical_context_content.startswith("_No historical context"): |
| 977 | return "" |
| 978 | |
| 979 | prompt = _build_synthesis_prompt( |
| 980 | press_content=press_content, |
| 981 | historical_context_content=historical_context_content, |
| 982 | continuity_content=continuity_content, |
| 983 | current_week=current_week, |
| 984 | current_datetime=current_datetime, |
| 985 | ) |
| 986 | |
| 987 | # Truncate press content if prompt exceeds budget |
| 988 | prompt_tokens = estimate_tokens(prompt) |
| 989 | if prompt_tokens > prompt_token_budget: |
| 990 | excess_chars = (prompt_tokens - prompt_token_budget) * 4 |
| 991 | end_index = max(0, len(press_content) - excess_chars) |
| 992 | press_content = press_content[:end_index] |
| 993 | prompt = _build_synthesis_prompt( |
| 994 | press_content=press_content, |
| 995 | historical_context_content=historical_context_content, |
| 996 | continuity_content=continuity_content, |
| 997 | current_week=current_week, |
| 998 | current_datetime=current_datetime, |
| 999 | ) |
| 1000 | |
| 1001 | return prompt |
| 1002 | |
| 1003 | |
| 1004 | def run_synthesis_step( |
| 1005 | *, |
| 1006 | press_context_path: Path | None = None, |
| 1007 | content_root: Path = DEFAULT_CONTENT_ROOT, |
| 1008 | continuity_file: Path = DEFAULT_CONTINUITY_FILE, |
| 1009 | current_datetime: str, |
| 1010 | current_week: str, |
| 1011 | previous_summary_path: Path | None = None, |
| 1012 | prompt_token_budget: int = SYNTHESIS_PROMPT_TOKEN_BUDGET, |
| 1013 | model: str | None = None, |
| 1014 | ) -> str: |
| 1015 | """Execute Step 1: synthesize press/historical context into a compact narrative. |
| 1016 | |
| 1017 | Returns the narrative string (max ~2K tokens). Raises RuntimeError on API failure. |
| 1018 | """ |
| 1019 | historical_context_content = assemble_historical_context( |
| 1020 | current_datetime=current_datetime, |
| 1021 | previous_summary_path=previous_summary_path, |
| 1022 | content_root=content_root, |
| 1023 | max_words=1_500, |
| 1024 | prompt_token_budget=prompt_token_budget, |
| 1025 | ).strip() |
| 1026 | from scripts.sanitize_repo_content import _escape_untrusted_boundaries |
| 1027 | |
| 1028 | historical_context_content = _escape_untrusted_boundaries(historical_context_content) |
| 1029 | if not historical_context_content: |
| 1030 | historical_context_content = ( |
| 1031 | "_No historical context was available beyond the current weekly payload._" |
| 1032 | ) |
| 1033 | |
| 1034 | continuity_content = render_continuity(continuity_file) |
| 1035 | |
| 1036 | press_content = ( |
| 1037 | press_context_path.read_text(encoding="utf-8").strip() |
| 1038 | if press_context_path |
| 1039 | and press_context_path.exists() |
| 1040 | and press_context_path.stat().st_size > 0 |
| 1041 | else "" |
| 1042 | ) |
| 1043 | |
| 1044 | # The no-press sentinel is a NON-EMPTY string; treat it as ABSENT so |
| 1045 | # synthesis does not treat the sentinel as real press. |
| 1046 | from scripts.render_press_context import NO_PRESS_SENTINEL_MARKER |
| 1047 | |
| 1048 | if press_content and NO_PRESS_SENTINEL_MARKER.search(press_content): |
| 1049 | press_content = "" |
| 1050 | |
| 1051 | # Strip AI-only instruction blocks from press context before synthesis |
| 1052 | if press_content: |
| 1053 | press_content = _strip_ai_instruction_blocks(press_content) |
| 1054 | # Escape boundary markers in untrusted press content |
| 1055 | from scripts.sanitize_repo_content import _escape_untrusted_boundaries |
| 1056 | |
| 1057 | if press_content: |
| 1058 | press_content = _escape_untrusted_boundaries(press_content) |
| 1059 | |
| 1060 | # If there's no meaningful content to synthesize, return empty |
| 1061 | if not press_content and historical_context_content.startswith("_No historical context"): |
| 1062 | return "" |
| 1063 | |
| 1064 | prompt = _build_synthesis_prompt( |
| 1065 | press_content=press_content, |
| 1066 | historical_context_content=historical_context_content, |
| 1067 | continuity_content=continuity_content, |
| 1068 | current_week=current_week, |
| 1069 | current_datetime=current_datetime, |
| 1070 | ) |
| 1071 | |
| 1072 | # Check that synthesis prompt is within its own budget |
| 1073 | prompt_tokens = estimate_tokens(prompt) |
| 1074 | if prompt_tokens > SYNTHESIS_PROMPT_TOKEN_BUDGET: |
| 1075 | # Truncate press content to fit (clamp to avoid negative index) |
| 1076 | excess_chars = (prompt_tokens - SYNTHESIS_PROMPT_TOKEN_BUDGET) * 4 |
| 1077 | end_index = max(0, len(press_content) - excess_chars) |
| 1078 | press_content = press_content[:end_index] |
| 1079 | prompt = _build_synthesis_prompt( |
| 1080 | press_content=press_content, |
| 1081 | historical_context_content=historical_context_content, |
| 1082 | continuity_content=continuity_content, |
| 1083 | current_week=current_week, |
| 1084 | current_datetime=current_datetime, |
| 1085 | ) |
| 1086 | |
| 1087 | return _call_synthesis_api(prompt, model=model or DEFAULT_SYNTHESIS_MODEL) |
| 1088 | |
| 1089 | |
| 1090 | def _call_synthesis_api(prompt: str, *, model: str) -> str: |
| 1091 | """Call GitHub Models API for the synthesis step.""" |
| 1092 | token = os.environ.get("GITHUB_TOKEN") |
| 1093 | if not token: |
| 1094 | raise RuntimeError("GITHUB_TOKEN is required for synthesis step.") |
| 1095 | |
| 1096 | # Inject canary token for output leak detection |
| 1097 | from scripts.canary_token import generate_canary, inject_canary |
| 1098 | |
| 1099 | canary = generate_canary() |
| 1100 | prompt = inject_canary(prompt, canary) |
| 1101 | |
| 1102 | endpoint = os.environ.get("GITHUB_MODELS_ENDPOINT", DEFAULT_MODELS_ENDPOINT) |
| 1103 | validate_https_url(endpoint, label="GitHub Models endpoint", allowed_hosts=ALLOWED_MODELS_HOSTS) |
| 1104 | timeout = int(os.environ.get("GITHUB_MODELS_TIMEOUT", str(DEFAULT_MODELS_TIMEOUT))) |
| 1105 | payload = { |
| 1106 | "model": model, |
| 1107 | "messages": [{"role": "user", "content": prompt}], |
| 1108 | "temperature": 0.2, |
| 1109 | "max_tokens": SYNTHESIS_MAX_TOKENS, # Cap at documented 2K |
| 1110 | } |
| 1111 | body = json.dumps(payload).encode("utf-8") |
| 1112 | |
| 1113 | last_exc: Exception | None = None |
| 1114 | for attempt in range(MAX_RETRIES + 1): |
| 1115 | req = request.Request( |
| 1116 | endpoint, |
| 1117 | data=body, |
| 1118 | headers={ |
| 1119 | "Authorization": f"Bearer {token}", |
| 1120 | "Content-Type": "application/json", |
| 1121 | "Accept": "application/json", |
| 1122 | }, |
| 1123 | method="POST", |
| 1124 | ) |
| 1125 | try: |
| 1126 | with request.urlopen(req, timeout=timeout) as response: # nosec B310 |
| 1127 | response_payload = json.load(response) |
| 1128 | markdown = extract_markdown(response_payload) |
| 1129 | # Validate output for canary leak and injection artifacts |
| 1130 | violations = validate_output_safety(markdown, canary) |
| 1131 | if violations: |
| 1132 | msg = f"Output safety violations detected: {'; '.join(violations)}" |
| 1133 | canary_leaked = any("Canary token leaked" in v for v in violations) |
| 1134 | if canary_leaked: |
| 1135 | raise RuntimeError(f"BLOCKED: {msg}") |
| 1136 | print(f"::warning::{msg}", file=sys.stderr) |
| 1137 | return markdown |
| 1138 | except error.HTTPError as exc: |
| 1139 | if exc.code not in RETRYABLE_STATUS_CODES or attempt == MAX_RETRIES: |
| 1140 | detail = exc.read().decode("utf-8", errors="replace") |
| 1141 | raise RuntimeError(f"Synthesis API request failed ({exc.code}): {detail}") from exc |
| 1142 | # Respect Retry-After header on 429 |
| 1143 | retry_after = None |
| 1144 | if exc.code == 429: |
| 1145 | retry_after_header = exc.headers.get("Retry-After") if exc.headers else None |
| 1146 | if retry_after_header: |
| 1147 | try: |
| 1148 | retry_after = float(retry_after_header) |
| 1149 | except (ValueError, TypeError): |
| 1150 | pass |
| 1151 | delay = ( |
| 1152 | retry_after |
| 1153 | if retry_after |
| 1154 | else BASE_DELAY ** (attempt + 1) + _JITTER_RANDOM.uniform(0, 1) |
| 1155 | ) |
| 1156 | print( |
| 1157 | f"[retry] Synthesis API returned {exc.code}, " |
| 1158 | f"retrying in {delay:.1f}s (attempt {attempt + 1}/{MAX_RETRIES})", |
| 1159 | file=sys.stderr, |
| 1160 | ) |
| 1161 | last_exc = exc |
| 1162 | time.sleep(delay) |
| 1163 | except error.URLError as exc: |
| 1164 | if attempt == MAX_RETRIES: |
| 1165 | raise RuntimeError(f"Synthesis API network error: {exc.reason}") from exc |
| 1166 | delay = BASE_DELAY ** (attempt + 1) + _JITTER_RANDOM.uniform(0, 1) |
| 1167 | print( |
| 1168 | f"[retry] Synthesis API network error: {exc.reason}, " |
| 1169 | f"retrying in {delay:.1f}s (attempt {attempt + 1}/{MAX_RETRIES})", |
| 1170 | file=sys.stderr, |
| 1171 | ) |
| 1172 | last_exc = exc |
| 1173 | time.sleep(delay) |
| 1174 | |
| 1175 | raise RuntimeError("Synthesis API request failed after retries") from last_exc |
| 1176 | |
| 1177 | |
| 1178 | def _build_prompt( |
| 1179 | *, |
| 1180 | prompt_template_path: Path, |
| 1181 | raw_json_path: Path, |
| 1182 | output_path: Path, |
| 1183 | current_datetime: str, |
| 1184 | analyzed_dir: Path, |
| 1185 | content_root: Path = DEFAULT_CONTENT_ROOT, |
| 1186 | wisdom_file: Path = DEFAULT_WISDOM_FILE, |
| 1187 | skills_dir: Path = DEFAULT_SKILLS_DIR, |
| 1188 | continuity_file: Path = DEFAULT_CONTINUITY_FILE, |
| 1189 | press_context_path: Path | None = None, |
| 1190 | prompt_token_budget: int = DEFAULT_PROMPT_TOKEN_BUDGET, |
| 1191 | allow_compaction: bool = True, |
| 1192 | synthesis_narrative: str | None = None, |
| 1193 | ) -> tuple[str, PromptPreflight]: |
| 1194 | payload = load_json(raw_json_path) |
| 1195 | sanitized_payload = sanitize_repo_payload(payload) |
| 1196 | current_week = sanitized_payload["week"] |
| 1197 | previous_summary_path = find_previous_summary(current_week, analyzed_dir) |
| 1198 | previous_summary_content = ( |
| 1199 | previous_summary_path.read_text(encoding="utf-8") if previous_summary_path else "" |
| 1200 | ) |
| 1201 | historical_context_content = assemble_historical_context( |
| 1202 | current_datetime=current_datetime, |
| 1203 | previous_summary_path=previous_summary_path, |
| 1204 | content_root=content_root, |
| 1205 | max_words=1_500, |
| 1206 | prompt_token_budget=prompt_token_budget, |
| 1207 | ).strip() |
| 1208 | from scripts.sanitize_repo_content import _escape_untrusted_boundaries |
| 1209 | |
| 1210 | historical_context_content = _escape_untrusted_boundaries(historical_context_content) |
| 1211 | previous_summary_content = _escape_untrusted_boundaries(previous_summary_content) |
| 1212 | if not historical_context_content: |
| 1213 | historical_context_content = ( |
| 1214 | "_No historical context was available beyond the current weekly payload._" |
| 1215 | ) |
| 1216 | wisdom_content = render_wisdom(wisdom_file) |
| 1217 | skills_content = render_skills(skills_dir) |
| 1218 | continuity_content = render_continuity(continuity_file) |
| 1219 | press_content = ( |
| 1220 | press_context_path.read_text(encoding="utf-8").strip() |
| 1221 | if press_context_path |
| 1222 | and press_context_path.exists() |
| 1223 | and press_context_path.stat().st_size > 0 |
| 1224 | else "" |
| 1225 | ) |
| 1226 | # The no-press sentinel is a NON-EMPTY string, so treat it as ABSENT here: |
| 1227 | # blanking it keeps the "press exists" logic (## Press Context block, |
| 1228 | # included=bool(press_content), press_decision) and the required no-press |
| 1229 | # statement correct for genuinely press-less weeks. |
| 1230 | from scripts.render_press_context import NO_PRESS_SENTINEL_MARKER |
| 1231 | |
| 1232 | if press_content and NO_PRESS_SENTINEL_MARKER.search(press_content): |
| 1233 | press_content = "" |
| 1234 | if press_content: |
| 1235 | press_content = _strip_ai_instruction_blocks(press_content) |
| 1236 | press_content = _escape_untrusted_boundaries(press_content) |
| 1237 | # When a synthesis narrative is available (Step 1 output), it distils the |
| 1238 | # *historical* context into a compact narrative that replaces the bulky |
| 1239 | # historical context block and saves tokens. It must NOT drop the press |
| 1240 | # context: the Step-2 sections "Where Industry Meets Code" and |
| 1241 | # "Press & Industry" still have to be written from the real press data. |
| 1242 | # jmservera/SquadScope#515 blanked press_content here, which silently |
| 1243 | # dropped a populated press context and forced the model to emit |
| 1244 | # "No industry press data was available...". Keep a condensed press |
| 1245 | # context so those sections stay evidence-backed. |
| 1246 | press_condensed_for_synthesis = False |
| 1247 | if synthesis_narrative: |
| 1248 | synthesis_source = "press & historical context" if press_content else "historical context" |
| 1249 | historical_context_content = ( |
| 1250 | f"[Industry narrative synthesized from {synthesis_source}]\n\n{synthesis_narrative}" |
| 1251 | ) |
| 1252 | if press_content and len(press_content) > COMPACTED_PRESS_CONTEXT_CHARS: |
| 1253 | press_content, _ = truncate_with_notice( |
| 1254 | press_content, COMPACTED_PRESS_CONTEXT_CHARS, "press context" |
| 1255 | ) |
| 1256 | press_condensed_for_synthesis = True |
| 1257 | payload_for_prompt = sanitized_payload |
| 1258 | raw_decisions = {"new_repos": "included", "trending_repos": "included"} |
| 1259 | previous_decision = "included" if previous_summary_path else "not included: no previous summary" |
| 1260 | historical_context_decision = ( |
| 1261 | "included" |
| 1262 | if historical_context_content |
| 1263 | != "_No historical context was available beyond the current weekly payload._" |
| 1264 | else "not included: no historical sources available" |
| 1265 | ) |
| 1266 | wisdom_decision = ( |
| 1267 | "included" if wisdom_file.exists() else "not included: no analysis-specific wisdom file" |
| 1268 | ) |
| 1269 | skills_decision = ( |
| 1270 | "included" |
| 1271 | if skills_dir.exists() and iter_skill_files(skills_dir) |
| 1272 | else "not included: no analysis-specific skills" |
| 1273 | ) |
| 1274 | continuity_decision = ( |
| 1275 | "included" |
| 1276 | if continuity_file.exists() |
| 1277 | else "not included: no analysis-specific continuity capsule" |
| 1278 | ) |
| 1279 | if not press_content: |
| 1280 | press_decision = "not included: no press context" |
| 1281 | elif press_condensed_for_synthesis: |
| 1282 | press_decision = "included: condensed alongside synthesis narrative" |
| 1283 | else: |
| 1284 | press_decision = "included" |
| 1285 | degraded = False |
| 1286 | |
| 1287 | def assemble() -> str: |
| 1288 | raw_json_content = json.dumps(payload_for_prompt, indent=2, ensure_ascii=False) |
| 1289 | current_year, _, week_number = current_week.partition("-W") |
| 1290 | generic_title_example = ( |
| 1291 | f"Week {int(week_number)}, {current_year} Analysis" |
| 1292 | if week_number.isdigit() |
| 1293 | else "Week NN, YYYY Analysis" |
| 1294 | ) |
| 1295 | prompt = prompt_template_path.read_text(encoding="utf-8") |
| 1296 | try: |
| 1297 | current_month = datetime.fromisoformat( |
| 1298 | current_datetime.strip().replace("Z", "+00:00") |
| 1299 | ).strftime("%B") |
| 1300 | except (ValueError, TypeError): |
| 1301 | current_month = "" |
| 1302 | replacements = { |
| 1303 | "{{CURRENT_DATETIME}}": current_datetime, |
| 1304 | "{{CURRENT_WEEK}}": current_week, |
| 1305 | "{{CURRENT_YEAR}}": current_year, |
| 1306 | "{{CURRENT_MONTH}}": current_month, |
| 1307 | "{{TITLE_TEMPLATE_HINT}}": ( |
| 1308 | f"Specific editorial headline about {current_week}'s dominant themes " |
| 1309 | f'(not "{generic_title_example}")' |
| 1310 | ), |
| 1311 | "{{RAW_JSON_PATH}}": str(raw_json_path), |
| 1312 | "{{OUTPUT_PATH}}": str(output_path), |
| 1313 | "{{PREVIOUS_SUMMARY_PATH_OR_NONE}}": str(previous_summary_path) |
| 1314 | if previous_summary_path |
| 1315 | else "None", |
| 1316 | "{{HISTORICAL_CONTEXT}}": historical_context_content, |
| 1317 | "{{RAW_JSON_CONTENT}}": raw_json_content, |
| 1318 | "{{PREVIOUS_SUMMARY_CONTENT_OR_EMPTY}}": previous_summary_content.strip(), |
| 1319 | "{{WISDOM}}": wisdom_content, |
| 1320 | "{{SKILLS}}": skills_content, |
| 1321 | "{{CONTINUITY}}": continuity_content, |
| 1322 | } |
| 1323 | for needle, value in replacements.items(): |
| 1324 | prompt = prompt.replace(needle, value) |
| 1325 | if press_content: |
| 1326 | prompt += f"\n\n---\n## Press Context\n\n{press_content}\n" |
| 1327 | return prompt |
| 1328 | |
| 1329 | prompt = assemble() |
| 1330 | if allow_compaction and estimate_tokens(prompt) > prompt_token_budget: |
| 1331 | degraded = True |
| 1332 | payload_for_prompt, raw_decisions = compact_payload(sanitized_payload) |
| 1333 | previous_summary_content, previous_decision = truncate_with_notice( |
| 1334 | previous_summary_content, COMPACTED_PREVIOUS_SUMMARY_CHARS, "prior continuity" |
| 1335 | ) |
| 1336 | if historical_context_decision == "included": |
| 1337 | historical_context_content, historical_context_decision = truncate_with_notice( |
| 1338 | historical_context_content, |
| 1339 | COMPACTED_HISTORICAL_CONTEXT_CHARS, |
| 1340 | "historical context", |
| 1341 | ) |
| 1342 | wisdom_content, wisdom_decision = truncate_with_notice( |
| 1343 | wisdom_content, COMPACTED_WISDOM_CHARS, "analysis wisdom" |
| 1344 | ) |
| 1345 | skills_content, skills_decision = truncate_with_notice( |
| 1346 | skills_content, COMPACTED_SKILLS_CHARS, "analysis skills" |
| 1347 | ) |
| 1348 | continuity_content, continuity_decision = truncate_with_notice( |
| 1349 | continuity_content, COMPACTED_CONTINUITY_CHARS, "analysis continuity" |
| 1350 | ) |
| 1351 | press_content, press_decision = truncate_with_notice( |
| 1352 | press_content, COMPACTED_PRESS_CONTEXT_CHARS, "press correlations" |
| 1353 | ) |
| 1354 | prompt = assemble() |
| 1355 | |
| 1356 | raw_json_content = json.dumps(payload_for_prompt, indent=2, ensure_ascii=False) |
| 1357 | current_year, _, week_number = current_week.partition("-W") |
| 1358 | components = [ |
| 1359 | _component( |
| 1360 | name="prompt_template", |
| 1361 | content=prompt_template_path.read_text(encoding="utf-8"), |
| 1362 | path=prompt_template_path, |
| 1363 | included=True, |
| 1364 | inclusion_reason="Base weekly analysis instructions.", |
| 1365 | compaction_decision="included", |
| 1366 | ), |
| 1367 | _component( |
| 1368 | name="new_repos", |
| 1369 | content=json.dumps( |
| 1370 | payload_for_prompt.get("new_repos", []), indent=2, ensure_ascii=False |
| 1371 | ), |
| 1372 | path=raw_json_path, |
| 1373 | included=True, |
| 1374 | inclusion_reason="Deterministic mapper slice: newly discovered repositories.", |
| 1375 | compaction_decision=raw_decisions["new_repos"], |
| 1376 | ), |
| 1377 | _component( |
| 1378 | name="trending_repos", |
| 1379 | content=json.dumps( |
| 1380 | payload_for_prompt.get("trending_repos", []), indent=2, ensure_ascii=False |
| 1381 | ), |
| 1382 | path=raw_json_path, |
| 1383 | included=True, |
| 1384 | inclusion_reason="Deterministic mapper slice: continuing/trending repositories.", |
| 1385 | compaction_decision=raw_decisions["trending_repos"], |
| 1386 | ), |
| 1387 | _component( |
| 1388 | name="raw_metadata", |
| 1389 | content=raw_json_content, |
| 1390 | path=raw_json_path, |
| 1391 | included=True, |
| 1392 | inclusion_reason=f"Sanitized current weekly payload for {current_year}-W{week_number}.", |
| 1393 | compaction_decision="included" |
| 1394 | if not degraded |
| 1395 | else "included with compacted repo slices", |
| 1396 | ), |
| 1397 | _component( |
| 1398 | name="prior_continuity", |
| 1399 | content=previous_summary_content, |
| 1400 | path=previous_summary_path, |
| 1401 | included=bool(previous_summary_path), |
| 1402 | inclusion_reason="Deterministic mapper slice: prior weekly continuity.", |
| 1403 | compaction_decision=previous_decision, |
| 1404 | ), |
| 1405 | _component( |
| 1406 | name="historical_context", |
| 1407 | content=historical_context_content, |
| 1408 | path=content_root, |
| 1409 | included=bool(historical_context_content), |
| 1410 | inclusion_reason="Bounded historical context synthesized from rolling, previous-week, monthly, and yearly reports.", |
| 1411 | compaction_decision=historical_context_decision, |
| 1412 | ), |
| 1413 | _component( |
| 1414 | name="analysis_wisdom", |
| 1415 | content=wisdom_content, |
| 1416 | path=wisdom_file, |
| 1417 | included=wisdom_file.exists(), |
| 1418 | inclusion_reason="Analysis-specific wisdom capsule from topic learning state.", |
| 1419 | compaction_decision=wisdom_decision, |
| 1420 | ), |
| 1421 | _component( |
| 1422 | name="analysis_skills", |
| 1423 | content=skills_content, |
| 1424 | path=skills_dir, |
| 1425 | included=skills_dir.exists() and bool(iter_skill_files(skills_dir)), |
| 1426 | inclusion_reason="Analysis-specific learned skill capsule from topic learning state.", |
| 1427 | compaction_decision=skills_decision, |
| 1428 | ), |
| 1429 | _component( |
| 1430 | name="analysis_continuity", |
| 1431 | content=continuity_content, |
| 1432 | path=continuity_file, |
| 1433 | included=continuity_file.exists(), |
| 1434 | inclusion_reason="Analysis continuity capsule distilled from recent multi-week learnings.", |
| 1435 | compaction_decision=continuity_decision, |
| 1436 | ), |
| 1437 | _component( |
| 1438 | name="press_correlations", |
| 1439 | content=press_content, |
| 1440 | path=press_context_path, |
| 1441 | included=bool(press_content), |
| 1442 | inclusion_reason="Deterministic mapper slice: press/developer correlation context.", |
| 1443 | compaction_decision=press_decision, |
| 1444 | ), |
| 1445 | _component( |
| 1446 | name="rendered_prompt", |
| 1447 | content=prompt, |
| 1448 | path=None, |
| 1449 | included=True, |
| 1450 | inclusion_reason="Exact prompt that will be passed to Copilot CLI.", |
| 1451 | compaction_decision="included" |
| 1452 | if not degraded |
| 1453 | else "included after deterministic compaction", |
| 1454 | ), |
| 1455 | ] |
| 1456 | prompt_tokens = estimate_tokens(prompt) |
| 1457 | prompt_within_budget = prompt_tokens <= prompt_token_budget |
| 1458 | degradation_reason = ( |
| 1459 | "Prompt was deterministically compacted to fit the configured token budget." |
| 1460 | if degraded |
| 1461 | else None |
| 1462 | ) |
| 1463 | evidence_slices = build_evidence_slices( |
| 1464 | week=current_week, |
| 1465 | raw_path=raw_json_path, |
| 1466 | sanitized_payload=sanitized_payload, |
| 1467 | payload_for_prompt=payload_for_prompt, |
| 1468 | press_context_path=press_context_path, |
| 1469 | press_content=press_content, |
| 1470 | previous_summary_path=previous_summary_path, |
| 1471 | previous_summary_content=previous_summary_content, |
| 1472 | ) |
| 1473 | news_path, corr_path = _press_paths_for_context(press_context_path, current_week) |
| 1474 | press_inventory = _article_inventory( |
| 1475 | _safe_load_json(news_path), _safe_load_json(corr_path), news_path |
| 1476 | ) |
| 1477 | slice_refs = write_evidence_slices(evidence_slices, None) |
| 1478 | preflight = PromptPreflight( |
| 1479 | schema_version="analysis_input_manifest_v1", |
| 1480 | prompt_token_budget=prompt_token_budget, |
| 1481 | prompt_tokens=prompt_tokens, |
| 1482 | prompt_bytes=len(prompt.encode("utf-8")), |
| 1483 | prompt_checksum_sha256=checksum_text(prompt), |
| 1484 | rendered_prompt_estimate={ |
| 1485 | "bytes": len(prompt.encode("utf-8")), |
| 1486 | "tokens": prompt_tokens, |
| 1487 | "checksum_sha256": checksum_text(prompt), |
| 1488 | }, |
| 1489 | prompt_within_budget=prompt_within_budget, |
| 1490 | degraded=degraded, |
| 1491 | publish_eligible=prompt_within_budget, |
| 1492 | promotion_policy=( |
| 1493 | "normal-promotion" |
| 1494 | if prompt_within_budget |
| 1495 | else "staged/candidate-only by default; prompt exceeds token budget." |
| 1496 | ), |
| 1497 | degradation_reason=degradation_reason, |
| 1498 | fallback_policy=( |
| 1499 | "copilot-only; no GitHub Models/OpenAI fallback. no-ai is diagnostic/staged-only and publish-ineligible. " |
| 1500 | "degraded/compacted prompts are staged/candidate-only by default." |
| 1501 | ), |
| 1502 | components=components, |
| 1503 | deterministic_slices=[ |
| 1504 | "new_repos", |
| 1505 | "trending_repos", |
| 1506 | "press_correlations", |
| 1507 | "prior_continuity", |
| 1508 | ], |
| 1509 | generated_evidence_slices=slice_refs, |
| 1510 | evidence_slice_payloads=evidence_slices, |
| 1511 | evidence_inventories=[ |
| 1512 | _evidence_inventory("raw_new_repos", sanitized_payload, "new_repos", raw_json_path), |
| 1513 | _evidence_inventory( |
| 1514 | "raw_trending_repos", sanitized_payload, "trending_repos", raw_json_path |
| 1515 | ), |
| 1516 | _evidence_inventory("prompt_new_repos", payload_for_prompt, "new_repos", raw_json_path), |
| 1517 | _evidence_inventory( |
| 1518 | "prompt_trending_repos", payload_for_prompt, "trending_repos", raw_json_path |
| 1519 | ), |
| 1520 | ], |
| 1521 | press_inventories=[press_inventory], |
| 1522 | ) |
| 1523 | return prompt, preflight |
| 1524 | |
| 1525 | |
| 1526 | def render_prompt( |
| 1527 | *, |
| 1528 | prompt_template_path: Path, |
| 1529 | raw_json_path: Path, |
| 1530 | output_path: Path, |
| 1531 | current_datetime: str, |
| 1532 | analyzed_dir: Path, |
| 1533 | content_root: Path = DEFAULT_CONTENT_ROOT, |
| 1534 | wisdom_file: Path = DEFAULT_WISDOM_FILE, |
| 1535 | skills_dir: Path = DEFAULT_SKILLS_DIR, |
| 1536 | continuity_file: Path = DEFAULT_CONTINUITY_FILE, |
| 1537 | press_context_path: Path | None = None, |
| 1538 | ) -> str: |
| 1539 | if ( |
| 1540 | wisdom_file == DEFAULT_WISDOM_FILE |
| 1541 | and skills_dir == DEFAULT_SKILLS_DIR |
| 1542 | and continuity_file == DEFAULT_CONTINUITY_FILE |
| 1543 | ): |
| 1544 | wisdom_file, skills_dir, continuity_file = resolve_analysis_context_paths() |
| 1545 | prompt, _ = _build_prompt( |
| 1546 | prompt_template_path=prompt_template_path, |
| 1547 | raw_json_path=raw_json_path, |
| 1548 | output_path=output_path, |
| 1549 | current_datetime=current_datetime, |
| 1550 | analyzed_dir=analyzed_dir, |
| 1551 | content_root=content_root, |
| 1552 | wisdom_file=wisdom_file, |
| 1553 | skills_dir=skills_dir, |
| 1554 | continuity_file=continuity_file, |
| 1555 | press_context_path=press_context_path, |
| 1556 | allow_compaction=False, |
| 1557 | ) |
| 1558 | return prompt |
| 1559 | |
| 1560 | |
| 1561 | def write_preflight_reports( |
| 1562 | preflight: PromptPreflight, json_path: Path | None, md_path: Path | None |
| 1563 | ) -> None: |
| 1564 | if json_path and preflight.evidence_slice_payloads: |
| 1565 | preflight.generated_evidence_slices = write_evidence_slices( |
| 1566 | preflight.evidence_slice_payloads, json_path |
| 1567 | ) |
| 1568 | if json_path: |
| 1569 | json_path.parent.mkdir(parents=True, exist_ok=True) |
| 1570 | json_path.write_text( |
| 1571 | json.dumps(asdict(preflight), indent=2, sort_keys=True) + "\n", encoding="utf-8" |
| 1572 | ) |
| 1573 | if md_path: |
| 1574 | md_path.parent.mkdir(parents=True, exist_ok=True) |
| 1575 | rows = [ |
| 1576 | "# Analysis Prompt Preflight", |
| 1577 | "", |
| 1578 | f"- Prompt budget: `{preflight.prompt_token_budget}` tokens", |
| 1579 | f"- Rendered prompt: `{preflight.prompt_tokens}` tokens / `{preflight.prompt_bytes}` bytes", |
| 1580 | f"- Prompt checksum: `{preflight.prompt_checksum_sha256}`", |
| 1581 | f"- Degraded/compacted: `{str(preflight.degraded).lower()}`", |
| 1582 | f"- Degradation reason: {preflight.degradation_reason or 'none'}", |
| 1583 | f"- Publish eligible: `{str(preflight.publish_eligible).lower()}`", |
| 1584 | f"- Promotion policy: {preflight.promotion_policy}", |
| 1585 | f"- Fallback policy: {preflight.fallback_policy}", |
| 1586 | f"- Deterministic slices: {', '.join(preflight.deterministic_slices)}", |
| 1587 | "", |
| 1588 | "| Component | Included | Bytes | Tokens | Checksum | Path | Inclusion reason | Compaction decision |", |
| 1589 | "| --- | --- | ---: | ---: | --- | --- | --- | --- |", |
| 1590 | ] |
| 1591 | for component in preflight.components: |
| 1592 | rows.append( |
| 1593 | "| " |
| 1594 | + " | ".join( |
| 1595 | [ |
| 1596 | component.name, |
| 1597 | str(component.included).lower(), |
| 1598 | str(component.bytes), |
| 1599 | str(component.token_estimate), |
| 1600 | component.checksum_sha256, |
| 1601 | component.path or "", |
| 1602 | component.inclusion_reason.replace("|", "\\|"), |
| 1603 | component.compaction_decision.replace("|", "\\|"), |
| 1604 | ] |
| 1605 | ) |
| 1606 | + " |" |
| 1607 | ) |
| 1608 | md_path.write_text("\n".join(rows) + "\n", encoding="utf-8") |
| 1609 | |
| 1610 | |
| 1611 | def extract_markdown(response_payload: dict[str, Any]) -> str: |
| 1612 | choices = response_payload.get("choices") or [] |
| 1613 | if not choices: |
| 1614 | raise ValueError("GitHub Models response did not include any choices.") |
| 1615 | |
| 1616 | message = choices[0].get("message") or {} |
| 1617 | content = message.get("content") |
| 1618 | |
| 1619 | if isinstance(content, str): |
| 1620 | return content.strip() + "\n" |
| 1621 | |
| 1622 | if isinstance(content, list): |
| 1623 | parts: list[str] = [] |
| 1624 | for item in content: |
| 1625 | if isinstance(item, dict): |
| 1626 | text = item.get("text") or item.get("output_text") |
| 1627 | if text: |
| 1628 | parts.append(text) |
| 1629 | if parts: |
| 1630 | return "\n".join(parts).strip() + "\n" |
| 1631 | |
| 1632 | text = choices[0].get("text") |
| 1633 | if isinstance(text, str) and text.strip(): |
| 1634 | return text.strip() + "\n" |
| 1635 | |
| 1636 | raise ValueError("GitHub Models response did not contain markdown output.") |
| 1637 | |
| 1638 | |
| 1639 | def validate_https_url( |
| 1640 | url: str, *, label: str, allowed_hosts: frozenset[str] | None = None |
| 1641 | ) -> None: |
| 1642 | parsed = parse.urlparse(url) |
| 1643 | if parsed.scheme.lower() != "https": |
| 1644 | raise ValueError(f"{label} must use HTTPS: {url}") |
| 1645 | if parsed.username or parsed.password: |
| 1646 | raise ValueError(f"{label} must not include credentials: {url}") |
| 1647 | if not parsed.hostname: |
| 1648 | raise ValueError(f"{label} must include a hostname: {url}") |
| 1649 | try: |
| 1650 | port = parsed.port |
| 1651 | except ValueError as exc: |
| 1652 | raise ValueError(f"{label} has an invalid port: {url}") from exc |
| 1653 | if port not in (None, 443): |
| 1654 | raise ValueError(f"{label} must not use unexpected ports: {url}") |
| 1655 | if allowed_hosts is not None and parsed.hostname.lower() not in allowed_hosts: |
| 1656 | raise ValueError(f"{label} host must be one of {sorted(allowed_hosts)}: {url}") |
| 1657 | |
| 1658 | |
| 1659 | def validate_output_safety(output: str, canary: str | None = None) -> list[str]: |
| 1660 | """Check generated analysis output for canary leaks and injection artifacts. |
| 1661 | |
| 1662 | Returns a list of security violation messages (empty = safe). |
| 1663 | """ |
| 1664 | from scripts.canary_token import check_output_for_any_canary, check_output_for_leak |
| 1665 | |
| 1666 | violations: list[str] = [] |
| 1667 | |
| 1668 | # Check for specific canary leak |
| 1669 | if canary: |
| 1670 | result = check_output_for_leak(output, canary) |
| 1671 | if result.leaked: |
| 1672 | violations.append( |
| 1673 | f"Canary token leaked at position {result.match_position}: " |
| 1674 | f"model may have been manipulated by injected instructions" |
| 1675 | ) |
| 1676 | |
| 1677 | # Check for any canary pattern (catches leaks from prior invocations) |
| 1678 | any_result = check_output_for_any_canary(output) |
| 1679 | if any_result.leaked and (not canary or any_result.canary.lower() != canary.lower()): |
| 1680 | violations.append( |
| 1681 | f"Unknown canary pattern '{any_result.canary}' found at position " |
| 1682 | f"{any_result.match_position}: possible cross-invocation leak" |
| 1683 | ) |
| 1684 | |
| 1685 | # Check for boundary marker leaks (model reproduced internal framing) |
| 1686 | from scripts.sanitize_repo_content import BOUNDARY_CLOSE, BOUNDARY_OPEN |
| 1687 | |
| 1688 | if BOUNDARY_OPEN in output: |
| 1689 | violations.append( |
| 1690 | "Output contains <untrusted-content> boundary marker — " |
| 1691 | "model may have leaked prompt structure" |
| 1692 | ) |
| 1693 | if BOUNDARY_CLOSE in output: |
| 1694 | violations.append( |
| 1695 | "Output contains </untrusted-content> boundary marker — " |
| 1696 | "model may have leaked prompt structure" |
| 1697 | ) |
| 1698 | |
| 1699 | return violations |
| 1700 | |
| 1701 | |
| 1702 | def call_github_models(prompt: str) -> str: |
| 1703 | token = os.environ.get("GITHUB_TOKEN") |
| 1704 | if not token: |
| 1705 | raise RuntimeError("GITHUB_TOKEN is required for GitHub Models fallback.") |
| 1706 | |
| 1707 | # Inject canary token for output leak detection |
| 1708 | from scripts.canary_token import generate_canary, inject_canary |
| 1709 | |
| 1710 | canary = generate_canary() |
| 1711 | prompt = inject_canary(prompt, canary) |
| 1712 | |
| 1713 | endpoint = os.environ.get("GITHUB_MODELS_ENDPOINT", DEFAULT_MODELS_ENDPOINT) |
| 1714 | validate_https_url(endpoint, label="GitHub Models endpoint", allowed_hosts=ALLOWED_MODELS_HOSTS) |
| 1715 | model = os.environ.get("GITHUB_MODELS_MODEL", DEFAULT_MODELS_MODEL) |
| 1716 | timeout = int(os.environ.get("GITHUB_MODELS_TIMEOUT", str(DEFAULT_MODELS_TIMEOUT))) |
| 1717 | payload = { |
| 1718 | "model": model, |
| 1719 | "messages": [{"role": "user", "content": prompt}], |
| 1720 | "temperature": 0.3, |
| 1721 | } |
| 1722 | body = json.dumps(payload).encode("utf-8") |
| 1723 | |
| 1724 | last_exc: Exception | None = None |
| 1725 | for attempt in range(MAX_RETRIES + 1): |
| 1726 | req = request.Request( |
| 1727 | endpoint, |
| 1728 | data=body, |
| 1729 | headers={ |
| 1730 | "Authorization": f"Bearer {token}", |
| 1731 | "Content-Type": "application/json", |
| 1732 | "Accept": "application/json", |
| 1733 | }, |
| 1734 | method="POST", |
| 1735 | ) |
| 1736 | try: |
| 1737 | with request.urlopen(req, timeout=timeout) as response: # nosec B310 |
| 1738 | response_payload = json.load(response) |
| 1739 | markdown = extract_markdown(response_payload) |
| 1740 | # Validate output for canary leak and injection artifacts |
| 1741 | violations = validate_output_safety(markdown, canary) |
| 1742 | if violations: |
| 1743 | msg = f"Output safety violations detected: {'; '.join(violations)}" |
| 1744 | # Full canary leak = prompt injection confirmed; block publishing |
| 1745 | canary_leaked = any("Canary token leaked" in v for v in violations) |
| 1746 | if canary_leaked: |
| 1747 | raise RuntimeError(f"BLOCKED: {msg}") |
| 1748 | # Partial/boundary leaks are warnings — log but allow |
| 1749 | print(f"::warning::{msg}", file=sys.stderr) |
| 1750 | return markdown |
| 1751 | except error.HTTPError as exc: |
| 1752 | if exc.code not in RETRYABLE_STATUS_CODES or attempt == MAX_RETRIES: |
| 1753 | detail = exc.read().decode("utf-8", errors="replace") |
| 1754 | retry_class = ( |
| 1755 | "non-retryable" |
| 1756 | if exc.code in NON_RETRYABLE_STATUS_CLASSES |
| 1757 | or exc.code not in RETRYABLE_STATUS_CODES |
| 1758 | else "retry-exhausted" |
| 1759 | ) |
| 1760 | access_hint = ( |
| 1761 | " GitHub Models access is unavailable for this model." |
| 1762 | if exc.code == 403 |
| 1763 | else "" |
| 1764 | ) |
| 1765 | raise RuntimeError( |
| 1766 | f"GitHub Models API request failed ({exc.code}, {retry_class}): {detail}{access_hint}" |
| 1767 | ) from exc |
| 1768 | # Determine delay: respect Retry-After header on 429 |
| 1769 | retry_after = ( |
| 1770 | exc.headers.get("Retry-After") |
| 1771 | if exc.code == 429 and exc.headers is not None |
| 1772 | else None |
| 1773 | ) |
| 1774 | if retry_after is not None: |
| 1775 | try: |
| 1776 | delay = float(retry_after) |
| 1777 | except ValueError: |
| 1778 | delay = BASE_DELAY ** (attempt + 1) |
| 1779 | else: |
| 1780 | delay = BASE_DELAY ** (attempt + 1) |
| 1781 | jitter = _JITTER_RANDOM.uniform(0, 1) |
| 1782 | total_delay = delay + jitter |
| 1783 | print( |
| 1784 | f"[retry] GitHub Models API returned {exc.code}, " |
| 1785 | f"retrying in {total_delay:.1f}s (attempt {attempt + 1}/{MAX_RETRIES})", |
| 1786 | file=sys.stderr, |
| 1787 | ) |
| 1788 | last_exc = exc |
| 1789 | time.sleep(total_delay) |
| 1790 | except error.URLError as exc: |
| 1791 | if attempt == MAX_RETRIES: |
| 1792 | raise RuntimeError(f"GitHub Models API request failed: {exc.reason}") from exc |
| 1793 | delay = BASE_DELAY ** (attempt + 1) + _JITTER_RANDOM.uniform(0, 1) |
| 1794 | print( |
| 1795 | f"[retry] GitHub Models API network error: {exc.reason}, " |
| 1796 | f"retrying in {delay:.1f}s (attempt {attempt + 1}/{MAX_RETRIES})", |
| 1797 | file=sys.stderr, |
| 1798 | ) |
| 1799 | last_exc = exc |
| 1800 | time.sleep(delay) |
| 1801 | |
| 1802 | # Should not be reached, but satisfy type checkers |
| 1803 | raise RuntimeError("GitHub Models API request failed after retries") from last_exc |
| 1804 | |
| 1805 | |
| 1806 | def _strip_ai_instructions(content: str) -> str: |
| 1807 | """Remove AI-facing instruction blocks from a rendered press context string. |
| 1808 | |
| 1809 | Strips: |
| 1810 | - The "### Instructions" section (from that heading to the next "###" or EOF) |
| 1811 | - The "#### Divergence Instructions" block (heading + bullet items) |
| 1812 | - Truncates the "### Correlation Summary" list to the first 10 entries, |
| 1813 | appending a "…and N more" summary line when truncation occurs. |
| 1814 | """ |
| 1815 | import re # noqa: PLC0415 |
| 1816 | |
| 1817 | # Strip ### Instructions section (to next ### heading or EOF) |
| 1818 | content = re.sub( |
| 1819 | r"\n### Instructions\n.*?(?=\n###|\Z)", |
| 1820 | "", |
| 1821 | content, |
| 1822 | flags=re.DOTALL, |
| 1823 | ) |
| 1824 | |
| 1825 | # Strip #### Divergence Instructions block (to next #### / ### heading or EOF) |
| 1826 | content = re.sub( |
| 1827 | r"\n#### Divergence Instructions\n.*?(?=\n####|\n###|\Z)", |
| 1828 | "", |
| 1829 | content, |
| 1830 | flags=re.DOTALL, |
| 1831 | ) |
| 1832 | |
| 1833 | # Truncate correlations list to top 10 |
| 1834 | corr_match = re.search( |
| 1835 | r"(### Correlation Summary\n[^\n]*\n)((?:- [^\n]*\n?)+)", |
| 1836 | content, |
| 1837 | ) |
| 1838 | if corr_match: |
| 1839 | header = corr_match.group(1) |
| 1840 | list_block = corr_match.group(2) |
| 1841 | list_lines = [ln for ln in list_block.splitlines() if ln.startswith("- ")] |
| 1842 | total = len(list_lines) |
| 1843 | if total > 10: |
| 1844 | omitted = total - 10 |
| 1845 | truncated = "\n".join(list_lines[:10]) |
| 1846 | truncated += f"\n…and {omitted} more repos with press correlation\n" |
| 1847 | content = ( |
| 1848 | content[: corr_match.start()] + header + truncated + content[corr_match.end() :] |
| 1849 | ) |
| 1850 | |
| 1851 | # Truncate divergence lists to top 10 items each |
| 1852 | for section_header in ( |
| 1853 | r"#### 🔍 Tech Trends Without Dev Activity", |
| 1854 | r"#### 🚀 Dev Activity Without Press Coverage", |
| 1855 | ): |
| 1856 | div_match = re.search( |
| 1857 | rf"({re.escape(section_header)}\n[^\n]*\n\n?)((?:- [^\n]*\n?)+)", |
| 1858 | content, |
| 1859 | ) |
| 1860 | if div_match: |
| 1861 | header = div_match.group(1) |
| 1862 | list_block = div_match.group(2) |
| 1863 | list_lines = [ln for ln in list_block.splitlines() if ln.startswith("- ")] |
| 1864 | total = len(list_lines) |
| 1865 | if total > 10: |
| 1866 | omitted = total - 10 |
| 1867 | truncated = "\n".join(list_lines[:10]) |
| 1868 | truncated += f"\n- …and {omitted} more topics\n" |
| 1869 | content = ( |
| 1870 | content[: div_match.start()] + header + truncated + content[div_match.end() :] |
| 1871 | ) |
| 1872 | |
| 1873 | # Add reader-friendly conclusion if divergences exist but instructions were stripped |
| 1874 | if "### Divergence Analysis" in content and "Divergence Instructions" not in content: |
| 1875 | if "These divergences highlight" not in content: |
| 1876 | content = content.rstrip() |
| 1877 | content += ( |
| 1878 | "\n\nThese divergences highlight gaps between what the tech industry " |
| 1879 | "is reporting and what developers are actually building.\n" |
| 1880 | ) |
| 1881 | |
| 1882 | return content.strip() |
| 1883 | |
| 1884 | |
| 1885 | def _render_press_section_no_ai(press_context_path: Path | None) -> str: |
| 1886 | """Render press context data for the no-AI summary (reader-facing).""" |
| 1887 | if ( |
| 1888 | not press_context_path |
| 1889 | or not press_context_path.exists() |
| 1890 | or press_context_path.stat().st_size == 0 |
| 1891 | ): |
| 1892 | return ( |
| 1893 | "No industry press data was available for this week's analysis. " |
| 1894 | "Future runs with TechCrunch integration enabled will provide " |
| 1895 | "correlation analysis between developer activity and industry coverage, " |
| 1896 | "highlighting press-driven hype versus organic growth patterns." |
| 1897 | ) |
| 1898 | |
| 1899 | # Try to re-render from raw data using reader_mode=True so the narrative |
| 1900 | # divergence format (from PR #136) is used instead of the AI-prompt format. |
| 1901 | stem = press_context_path.stem # e.g. "2026-W21-press-context" |
| 1902 | week = stem.replace("-press-context", "") # e.g. "2026-W21" |
| 1903 | data_dir = press_context_path.parent.parent # data/analyzed/ -> data/ |
| 1904 | external_path = data_dir / "raw" / f"{week}-external-news.json" |
| 1905 | legacy_path = data_dir / "raw" / f"{week}-techcrunch.json" |
| 1906 | tc_path = external_path if external_path.exists() else legacy_path |
| 1907 | corr_path = data_dir / "analyzed" / f"{week}-correlations.json" |
| 1908 | |
| 1909 | if tc_path.exists(): |
| 1910 | from scripts.render_press_context import load_json as rpc_load_json |
| 1911 | from scripts.render_press_context import render_press_context |
| 1912 | |
| 1913 | tc_data = rpc_load_json(tc_path) |
| 1914 | corr_data = rpc_load_json(corr_path) if corr_path.exists() else {} |
| 1915 | if tc_data is not None: |
| 1916 | return render_press_context(tc_data, corr_data or {}, week, reader_mode=True) |
| 1917 | |
| 1918 | # Fallback: strip AI instructions from the pre-rendered file. |
| 1919 | content = press_context_path.read_text(encoding="utf-8").strip() |
| 1920 | return _strip_ai_instructions(content) |
| 1921 | |
| 1922 | |
| 1923 | def generate_no_ai_summary( |
| 1924 | raw_json_path: Path, current_datetime: str, press_context_path: Path | None = None |
| 1925 | ) -> str: |
| 1926 | """Generate a valid summary from raw JSON without any AI API calls.""" |
| 1927 | payload = sanitize_repo_payload(load_json(raw_json_path)) |
| 1928 | week = payload["week"] |
| 1929 | new_repos = payload.get("new_repos", []) |
| 1930 | trending_repos = payload.get("trending_repos", []) |
| 1931 | signals = payload.get("signals", {}) |
| 1932 | raw_topics = signals.get("top_topics", []) |
| 1933 | top_topics = [t["topic"] if isinstance(t, dict) else str(t) for t in raw_topics] |
| 1934 | |
| 1935 | total_stars = sum(r.get("stars", 0) for r in new_repos + trending_repos) |
| 1936 | repos_featured = len(new_repos) + len(trending_repos) |
| 1937 | |
| 1938 | all_repos = sorted(new_repos + trending_repos, key=lambda r: r.get("stars", 0), reverse=True) |
| 1939 | top_repo = all_repos[0]["full_name"] if all_repos else "unknown/unknown" |
| 1940 | |
| 1941 | tags = ( |
| 1942 | top_topics[:5] if len(top_topics) >= 3 else ["open-source", "developer-tools", "automation"] |
| 1943 | ) |
| 1944 | |
| 1945 | # Notable new repos |
| 1946 | notable_new = sorted(new_repos, key=lambda r: r.get("stars", 0), reverse=True)[:10] |
| 1947 | notable_lines = [] |
| 1948 | for repo in notable_new: |
| 1949 | desc = repo.get("description") or "No description provided" |
| 1950 | lang = repo.get("language") or "Unknown" |
| 1951 | notable_lines.append( |
| 1952 | f"- [{repo['full_name']}]({repo.get('url', '#')}) ({lang}, " |
| 1953 | f"{repo.get('stars', 0):,} stars): {desc}" |
| 1954 | ) |
| 1955 | notable_section = ( |
| 1956 | "\n".join(notable_lines) |
| 1957 | if notable_lines |
| 1958 | else "No new repositories were captured this week." |
| 1959 | ) |
| 1960 | |
| 1961 | # Language breakdown |
| 1962 | lang_counts: dict[str, int] = {} |
| 1963 | for repo in all_repos: |
| 1964 | lang = repo.get("language") |
| 1965 | if lang: |
| 1966 | lang_counts[lang] = lang_counts.get(lang, 0) + 1 |
| 1967 | top_langs = sorted(lang_counts.items(), key=lambda x: x[1], reverse=True)[:5] |
| 1968 | lang_summary = ( |
| 1969 | ", ".join(f"{lang} ({count})" for lang, count in top_langs) |
| 1970 | if top_langs |
| 1971 | else "diverse mix of languages" |
| 1972 | ) |
| 1973 | |
| 1974 | year_str = week.split("-W")[0] |
| 1975 | week_num = week.split("-W")[1] |
| 1976 | topics_str = ", ".join(top_topics[:8]) if top_topics else "not available from this crawl" |
| 1977 | title_topics = [topic.replace("-", " ").title() for topic in top_topics[:2] if topic] |
| 1978 | if len(title_topics) == 2: |
| 1979 | fallback_title = f"{title_topics[0]}, {title_topics[1]}, and This Week's Repo Signals" |
| 1980 | elif len(title_topics) == 1: |
| 1981 | fallback_title = f"{title_topics[0]} Leads This Week's Repo Signals" |
| 1982 | else: |
| 1983 | fallback_title = f"{top_repo.split('/')[-1]} Leads This Week's Repo Signals" |
| 1984 | |
| 1985 | markdown = f'''--- |
| 1986 | title: "{fallback_title}" |
| 1987 | date: {current_datetime} |
| 1988 | week: "{week}" |
| 1989 | year: {int(year_str)} |
| 1990 | tags: [{", ".join(tags)}] |
| 1991 | categories: [weekly] |
| 1992 | repos_featured: {repos_featured} |
| 1993 | stars_tracked: {total_stars} |
| 1994 | top_repo: "{top_repo}" |
| 1995 | quality_score: {NO_AI_DIAGNOSTIC_QUALITY_SCORE} |
| 1996 | summary: "Automated data-only summary for {week}. AI analysis was unavailable; this report presents raw crawl statistics and top repositories without editorial commentary." |
| 1997 | --- |
| 1998 | |
| 1999 | ## This Week's Trends |
| 2000 | |
| 2001 | Without AI-powered analysis, this section reports observed patterns from crawl data rather than synthesized editorial trends. The top community topics this week are {topics_str}, and the dominant languages are {lang_summary}. These signals point to where developer attention is concentrated, though qualitative interpretation of which patterns are durable versus incidental requires a full AI-enabled analysis run. |
| 2002 | |
| 2003 | The crawler captured {repos_featured} repositories this week ({len(new_repos)} new, {len(trending_repos)} trending) with {total_stars:,} cumulative stars. The top repository by star count is [{top_repo}](https://github.com/{top_repo}). Raw patterns suggest continued investment in {lang_summary}, but without editorial judgment these should be treated as directional rather than conclusive. |
| 2004 | |
| 2005 | ## Where Industry Meets Code |
| 2006 | |
| 2007 | {_render_press_section_no_ai(press_context_path)} |
| 2008 | |
| 2009 | ## Signal & Noise |
| 2010 | |
| 2011 | The primary observable signal this week comes from language and topic distribution. The top languages are {lang_summary}. The top community topics are {topics_str}. These patterns indicate where developer attention is concentrating and what categories are gaining traction relative to prior weeks. |
| 2012 | |
| 2013 | Without AI-powered filtering, distinguishing signal from noise requires manual review. Some repositories in the crawl may represent low-quality forks, exploit tools, or promotional projects that inflate topic counts without contributing meaningful innovation. Future AI-enabled runs will provide better noise filtering and critical editorial judgment. |
| 2014 | |
| 2015 | ## Blind Spots |
| 2016 | |
| 2017 | This automated summary lacks the editorial judgment that AI analysis would normally provide. Specific blind spots in this report include: comparative trend analysis against prior weeks, qualitative assessment of repository significance, identification of emerging ecosystem patterns not visible from raw metrics, and filtering of low-signal entries that inflate topic counts. The raw data is preserved for future re-analysis when AI capabilities become available. |
| 2018 | |
| 2019 | ## The Week Ahead |
| 2020 | |
| 2021 | Week {week_num} of {year_str} captured {repos_featured} repositories with {total_stars:,} cumulative stars tracked. The top repository is [{top_repo}](https://github.com/{top_repo}). This summary was generated without AI assistance and presents factual crawl statistics only. A full analytical run should be attempted when AI model access is restored to provide trend synthesis and editorial judgment. |
| 2022 | |
| 2023 | ## Key References |
| 2024 | |
| 2025 | ### Notable Projects |
| 2026 | |
| 2027 | {notable_section} |
| 2028 | |
| 2029 | ### Press & Industry |
| 2030 | |
| 2031 | {_render_press_section_no_ai(press_context_path) if press_context_path else "No press data was provided this week."} |
| 2032 | ''' |
| 2033 | return markdown.strip() + "\n" |
| 2034 | |
| 2035 | |
| 2036 | def main(argv: list[str] | None = None) -> int: |
| 2037 | args = parse_args(argv) |
| 2038 | wisdom_file = args.wisdom_file |
| 2039 | skills_dir = args.skills_dir |
| 2040 | continuity_file = args.continuity_file |
| 2041 | if ( |
| 2042 | wisdom_file == DEFAULT_WISDOM_FILE |
| 2043 | and skills_dir == DEFAULT_SKILLS_DIR |
| 2044 | and continuity_file == DEFAULT_CONTINUITY_FILE |
| 2045 | ): |
| 2046 | wisdom_file, skills_dir, continuity_file = resolve_analysis_context_paths() |
| 2047 | |
| 2048 | # Step 1: Run synthesis if requested |
| 2049 | if args.run_synthesis: |
| 2050 | payload = load_json(args.raw_json) |
| 2051 | sanitized_payload = sanitize_repo_payload(payload) |
| 2052 | current_week = sanitized_payload["week"] |
| 2053 | previous_summary_path = find_previous_summary(current_week, args.analyzed_dir) |
| 2054 | # Render synthesis prompt to file (Copilot CLI will process it) |
| 2055 | narrative_or_prompt = render_synthesis_prompt( |
| 2056 | press_context_path=args.press_context, |
| 2057 | content_root=args.content_root, |
| 2058 | continuity_file=continuity_file, |
| 2059 | current_datetime=args.current_datetime, |
| 2060 | current_week=current_week, |
| 2061 | previous_summary_path=previous_summary_path, |
| 2062 | prompt_token_budget=args.prompt_token_budget, |
| 2063 | ) |
| 2064 | if not narrative_or_prompt: |
| 2065 | print( |
| 2066 | "::warning::No meaningful content for synthesis (no press or historical context).", |
| 2067 | file=sys.stderr, |
| 2068 | ) |
| 2069 | return 1 |
| 2070 | output_path = args.synthesis_output or args.output |
| 2071 | output_path.parent.mkdir(parents=True, exist_ok=True) |
| 2072 | output_path.write_text(narrative_or_prompt, encoding="utf-8") |
| 2073 | print( |
| 2074 | f"::notice::Synthesis prompt rendered: {estimate_tokens(narrative_or_prompt)} tokens written to {output_path}", |
| 2075 | file=sys.stderr, |
| 2076 | ) |
| 2077 | return 0 |
| 2078 | |
| 2079 | # Load synthesis narrative from Step 1 output if provided |
| 2080 | synthesis_narrative: str | None = None |
| 2081 | if ( |
| 2082 | args.synthesis_input |
| 2083 | and args.synthesis_input.exists() |
| 2084 | and args.synthesis_input.stat().st_size > 0 |
| 2085 | ): |
| 2086 | synthesis_narrative = args.synthesis_input.read_text(encoding="utf-8").strip() |
| 2087 | if synthesis_narrative: |
| 2088 | # Escape boundary markers — synthesis output is untrusted LLM content |
| 2089 | from scripts.sanitize_repo_content import _escape_untrusted_boundaries |
| 2090 | |
| 2091 | synthesis_narrative = _escape_untrusted_boundaries(synthesis_narrative) |
| 2092 | print( |
| 2093 | f"::notice::Using synthesis narrative ({estimate_tokens(synthesis_narrative)} tokens) from {args.synthesis_input}", |
| 2094 | file=sys.stderr, |
| 2095 | ) |
| 2096 | |
| 2097 | prompt, preflight = _build_prompt( |
| 2098 | prompt_template_path=args.prompt_template, |
| 2099 | raw_json_path=args.raw_json, |
| 2100 | output_path=args.output, |
| 2101 | current_datetime=args.current_datetime, |
| 2102 | analyzed_dir=args.analyzed_dir, |
| 2103 | content_root=args.content_root, |
| 2104 | wisdom_file=wisdom_file, |
| 2105 | skills_dir=skills_dir, |
| 2106 | continuity_file=continuity_file, |
| 2107 | press_context_path=args.press_context, |
| 2108 | prompt_token_budget=args.prompt_token_budget, |
| 2109 | allow_compaction=True, |
| 2110 | synthesis_narrative=synthesis_narrative, |
| 2111 | ) |
| 2112 | write_preflight_reports(preflight, args.preflight_report_json, args.preflight_report_md) |
| 2113 | |
| 2114 | if not preflight.prompt_within_budget: |
| 2115 | print( |
| 2116 | "::error::Rendered analysis prompt exceeds explicit budget after deterministic compaction: " |
| 2117 | f"{preflight.prompt_tokens}/{preflight.prompt_token_budget} tokens.", |
| 2118 | file=sys.stderr, |
| 2119 | ) |
| 2120 | return 1 |
| 2121 | |
| 2122 | if args.print_prompt: |
| 2123 | sys.stdout.write(prompt) |
| 2124 | return 0 |
| 2125 | |
| 2126 | if args.no_ai: |
| 2127 | markdown = generate_no_ai_summary(args.raw_json, args.current_datetime, args.press_context) |
| 2128 | else: |
| 2129 | print( |
| 2130 | "::error::GitHub Models/OpenAI analysis fallback is disabled; use Copilot CLI or --no-ai for staged diagnostics.", |
| 2131 | file=sys.stderr, |
| 2132 | ) |
| 2133 | return 1 |
| 2134 | |
| 2135 | args.output.parent.mkdir(parents=True, exist_ok=True) |
| 2136 | args.output.write_text(markdown, encoding="utf-8") |
| 2137 | return 0 |
| 2138 | |
| 2139 | |
| 2140 | if __name__ == "__main__": |
| 2141 | raise SystemExit(main()) |