feat: split weekly analysis into 2-step pipeline (reduce context pressure) (#515)

* feat: split weekly analysis into 2-step pipeline (#514) Step 1 (synthesis): Distills press context, historical context, and continuity capsule into a compact industry narrative (max 2K tokens) using a cheaper model (gpt-4o-mini by default). Step 2 (analysis): Uses crawl JSON + Step 1 narrative + wisdom/skills + previous summary. Replaces the raw press/historical context with the compact synthesis, dramatically reducing token count. Backward-compatible: if Step 1 fails, the workflow falls back to the current single-prompt behavior (no --synthesis-input passed). New CLI flags: --run-synthesis Run Step 1 only and write narrative to file --synthesis-output Where to write Step 1 output --synthesis-input Pre-computed narrative to inject into Step 2 --synthesis-model Model override for Step 1 Closes #514 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address all review comments on PR #515 - analyze_fallback.py: escape synthesis-input with _escape_untrusted_boundaries - analyze_fallback.py: cap max_tokens at SYNTHESIS_MAX_TOKENS (2K) - analyze_fallback.py: add canary-token + validate_output_safety to _call_synthesis_api - analyze_fallback.py: default run_synthesis_step budget to SYNTHESIS_PROMPT_TOKEN_BUDGET (20K) - analyze_fallback.py: strip AI instruction blocks from press context before synthesis - analyze_fallback.py: clamp truncation end_index to avoid negative slice - analyze_fallback.py: add tests for --run-synthesis, --synthesis-input, and instruction stripping - crawl-and-publish.yml: use bash array for SYNTHESIS_ARGS to avoid word-splitting - crawl-and-publish.yml: add models:read permission to analyze job Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Juan Manuel Servera committed Jun 16, 2026 at 19:35 UTC 0eabbb63869e8e6d4ee9b1d17cbac59ee6d94dac
3 files changed +474 -6
.github/workflows/crawl-and-publish.yml
+41
@@ -293,6 +293,7 @@ jobs:
293 actions: read
294 contents: write
295 issues: write
296 + models: read
297 outputs:
298 week: ${{ steps.analysis-context.outputs.week }}
299 summary_file: ${{ steps.analysis-context.outputs.published_output_file }}
@@ -451,6 +452,39 @@ jobs:
452 PY
453 echo "press_file=$PRESS_FILE" >> "$GITHUB_OUTPUT"
454
455 + - name: Run synthesis step (Step 1)
456 + id: synthesis
457 + env:
458 + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
459 + IN_WEEK_FILE: ${{ steps.analysis-context.outputs.week_file }}
460 + IN_OUTPUT_FILE: ${{ steps.analysis-context.outputs.candidate_output_file }}
461 + IN_CURRENT_DATETIME: ${{ steps.analysis-context.outputs.current_datetime }}
462 + IN_PRESS_FILE: ${{ steps.press-context.outputs.press_file }}
463 + run: |
464 + set -euo pipefail
465 + WEEK_FILE="$IN_WEEK_FILE"
466 + OUTPUT_FILE="$IN_OUTPUT_FILE"
467 + CURRENT_DATETIME="$IN_CURRENT_DATETIME"
468 + PRESS_FILE="$IN_PRESS_FILE"
469 + DIAGNOSTICS_DIR="$(dirname "$OUTPUT_FILE")/diagnostics"
470 + SYNTHESIS_FILE="$DIAGNOSTICS_DIR/synthesis-narrative.md"
471 + mkdir -p "$DIAGNOSTICS_DIR"
472 + # Step 1: synthesize press/historical context into compact narrative.
473 + # If this fails, we fall back to the single-prompt approach (no synthesis_input).
474 + if python3 scripts/analyze_fallback.py \
475 + --raw-json "$WEEK_FILE" \
476 + --output "$OUTPUT_FILE" \
477 + --current-datetime "$CURRENT_DATETIME" \
478 + --press-context "$PRESS_FILE" \
479 + --run-synthesis \
480 + --synthesis-output "$SYNTHESIS_FILE"; then
481 + echo "synthesis_file=$SYNTHESIS_FILE" >> "$GITHUB_OUTPUT"
482 + echo "synthesis_available=true" >> "$GITHUB_OUTPUT"
483 + else
484 + echo "::warning::Synthesis step failed; falling back to single-prompt approach."
485 + echo "synthesis_available=false" >> "$GITHUB_OUTPUT"
486 + fi
487 +
488 - name: Render and preflight analysis prompt
489 id: prompt-preflight
490 env:
@@ -459,6 +493,8 @@ jobs:
493 IN_OUTPUT_FILE: ${{ steps.analysis-context.outputs.candidate_output_file }}
494 IN_CURRENT_DATETIME: ${{ steps.analysis-context.outputs.current_datetime }}
495 IN_PRESS_FILE: ${{ steps.press-context.outputs.press_file }}
496 + IN_SYNTHESIS_AVAILABLE: ${{ steps.synthesis.outputs.synthesis_available }}
497 + IN_SYNTHESIS_FILE: ${{ steps.synthesis.outputs.synthesis_file }}
498 run: |
499 set -euo pipefail
500 WEEK_FILE="$IN_WEEK_FILE"
@@ -475,6 +511,10 @@ jobs:
511 # Hydrate metrics ledger from publish before writing this run's prompt/preflight artifacts.
512 git fetch origin publish 2>/dev/null && \
513 git checkout origin/publish -- data/metrics/ 2>/dev/null || true
514 + SYNTHESIS_ARGS=()
515 + if [ "${IN_SYNTHESIS_AVAILABLE:-false}" = "true" ] && [ -f "${IN_SYNTHESIS_FILE:-}" ]; then
516 + SYNTHESIS_ARGS=(--synthesis-input "$IN_SYNTHESIS_FILE")
517 + fi
518 python3 scripts/analyze_fallback.py \
519 --raw-json "$WEEK_FILE" \
520 --output "$OUTPUT_FILE" \
@@ -483,6 +523,7 @@ jobs:
523 --prompt-token-budget "${ANALYSIS_PROMPT_TOKEN_BUDGET:-90000}" \
524 --preflight-report-json "$PREFLIGHT_JSON" \
525 --preflight-report-md "$PREFLIGHT_MD" \
526 + "${SYNTHESIS_ARGS[@]}" \
527 --print-prompt > "$PROMPT_FILE"
528 cp "$PREFLIGHT_JSON" "$LEGACY_PREFLIGHT_JSON"
529 python3 scripts/preflight_cost_check.py \
scripts/analyze_fallback.py
+306 -6
@@ -45,6 +45,13 @@ COMPACTED_SKILLS_CHARS = 10_000
45 COMPACTED_CONTINUITY_CHARS = 8_000
46 COMPACTED_PRESS_CONTEXT_CHARS = 14_000
47 COMPACTED_HISTORICAL_CONTEXT_CHARS = 12_000
48 +SYNTHESIS_MAX_TOKENS = 2_000
49 +SYNTHESIS_PROMPT_TOKEN_BUDGET = 20_000
50 +DEFAULT_SYNTHESIS_MODEL = "openai/gpt-4o-mini"
51 +RETRYABLE_STATUS_CODES = {429, 500, 502, 503, 504}
52 +NON_RETRYABLE_STATUS_CLASSES = {400, 401, 403, 404}
53 +MAX_RETRIES = 3
54 +BASE_DELAY = 2 # seconds
55
56
57 @dataclass
@@ -212,6 +219,29 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
219 type=Path,
220 help="Write deterministic rendered-prompt preflight details as Markdown.",
221 )
222 + parser.add_argument(
223 + "--run-synthesis",
224 + action="store_true",
225 + help="Run Step 1 synthesis (press/historical context → compact narrative) and exit.",
226 + )
227 + parser.add_argument(
228 + "--synthesis-output",
229 + type=Path,
230 + default=None,
231 + help="Path to write the synthesis narrative output (used with --run-synthesis).",
232 + )
233 + parser.add_argument(
234 + "--synthesis-input",
235 + type=Path,
236 + default=None,
237 + help="Path to a pre-computed synthesis narrative to inject into the analysis prompt (Step 2).",
238 + )
239 + parser.add_argument(
240 + "--synthesis-model",
241 + type=str,
242 + default=None,
243 + help=f"Model to use for synthesis step (default: {DEFAULT_SYNTHESIS_MODEL}).",
244 + )
245 return parser.parse_args(argv)
246
247
@@ -725,6 +755,230 @@ def compact_payload(payload: dict[str, Any]) -> tuple[dict[str, Any], dict[str,
755 return compacted, decisions
756
757
758 +def _strip_ai_instruction_blocks(text: str) -> str:
759 + """Remove AI-only instruction sections (### Instructions, directives) from press context.
760 +
761 + These blocks are intended for the main analysis prompt and should not be
762 + forwarded into synthesis to reduce prompt-injection surface area.
763 + """
764 + # Remove markdown sections starting with ### Instructions (case-insensitive)
765 + # up to the next same-or-higher-level heading or end of text
766 + text = re.sub(
767 + r"(?m)^###\s+Instructions?\b.*?(?=^#{1,3}\s|\Z)",
768 + "",
769 + text,
770 + flags=re.DOTALL | re.IGNORECASE,
771 + )
772 + # Remove divergence directive blocks (commonly marked with special tags)
773 + text = re.sub(
774 + r"(?m)^<!--\s*(?:ai-only|divergence|directive)\b.*?-->.*?(?:<!--\s*/(?:ai-only|divergence|directive)\s*-->|\Z)",
775 + "",
776 + text,
777 + flags=re.DOTALL | re.IGNORECASE,
778 + )
779 + return text.strip()
780 +
781 +
782 +def _build_synthesis_prompt(
783 + *,
784 + press_content: str,
785 + historical_context_content: str,
786 + continuity_content: str,
787 + current_week: str,
788 + current_datetime: str,
789 +) -> str:
790 + """Build a compact prompt for Step 1: Industry & Press Synthesis.
791 +
792 + Input: press context + historical context + continuity capsule.
793 + Output instruction: max 2K token narrative of the tech industry landscape this week.
794 + """
795 + sections = []
796 + sections.append(
797 + "You are an expert technology industry analyst. Your task is to synthesize "
798 + "the provided press context, historical context, and continuity notes into a "
799 + "compact industry narrative (maximum 2000 tokens / ~1500 words).\n\n"
800 + "Focus on:\n"
801 + "- Key technology trends and shifts happening this week\n"
802 + "- Notable industry movements (acquisitions, launches, pivots)\n"
803 + "- Developer ecosystem changes\n"
804 + "- Connections to longer-term patterns from historical context\n\n"
805 + "Output ONLY the narrative — no headers, no metadata, no instructions. "
806 + "Write in a dense, information-rich style suitable for feeding into a downstream "
807 + "analysis step that will correlate this with GitHub repository data.\n\n"
808 + f"Current week: {current_week}\n"
809 + f"Current datetime: {current_datetime}\n"
810 + )
811 + if press_content:
812 + sections.append(f"## Press Context\n\n{press_content}")
813 + if historical_context_content:
814 + sections.append(f"## Historical Context\n\n{historical_context_content}")
815 + if continuity_content and continuity_content != "_No continuity capsule has been recorded yet._":
816 + sections.append(f"## Continuity Notes\n\n{continuity_content}")
817 +
818 + return "\n\n---\n\n".join(sections)
819 +
820 +
821 +def run_synthesis_step(
822 + *,
823 + press_context_path: Path | None = None,
824 + content_root: Path = DEFAULT_CONTENT_ROOT,
825 + continuity_file: Path = DEFAULT_CONTINUITY_FILE,
826 + current_datetime: str,
827 + current_week: str,
828 + previous_summary_path: Path | None = None,
829 + prompt_token_budget: int = SYNTHESIS_PROMPT_TOKEN_BUDGET,
830 + model: str | None = None,
831 +) -> str:
832 + """Execute Step 1: synthesize press/historical context into a compact narrative.
833 +
834 + Returns the narrative string (max ~2K tokens). Raises RuntimeError on API failure.
835 + """
836 + historical_context_content = assemble_historical_context(
837 + current_datetime=current_datetime,
838 + previous_summary_path=previous_summary_path,
839 + content_root=content_root,
840 + max_words=1_500,
841 + prompt_token_budget=prompt_token_budget,
842 + ).strip()
843 + from scripts.sanitize_repo_content import _escape_untrusted_boundaries
844 +
845 + historical_context_content = _escape_untrusted_boundaries(historical_context_content)
846 + if not historical_context_content:
847 + historical_context_content = "_No historical context was available beyond the current weekly payload._"
848 +
849 + continuity_content = render_continuity(continuity_file)
850 +
851 + press_content = (
852 + press_context_path.read_text(encoding="utf-8").strip()
853 + if press_context_path and press_context_path.exists() and press_context_path.stat().st_size > 0
854 + else ""
855 + )
856 +
857 + # Strip AI-only instruction blocks from press context before synthesis
858 + if press_content:
859 + press_content = _strip_ai_instruction_blocks(press_content)
860 + # Escape boundary markers in untrusted press content
861 + from scripts.sanitize_repo_content import _escape_untrusted_boundaries
862 + if press_content:
863 + press_content = _escape_untrusted_boundaries(press_content)
864 +
865 + # If there's no meaningful content to synthesize, return empty
866 + if not press_content and historical_context_content.startswith("_No historical context"):
867 + return ""
868 +
869 + prompt = _build_synthesis_prompt(
870 + press_content=press_content,
871 + historical_context_content=historical_context_content,
872 + continuity_content=continuity_content,
873 + current_week=current_week,
874 + current_datetime=current_datetime,
875 + )
876 +
877 + # Check that synthesis prompt is within its own budget
878 + prompt_tokens = estimate_tokens(prompt)
879 + if prompt_tokens > SYNTHESIS_PROMPT_TOKEN_BUDGET:
880 + # Truncate press content to fit (clamp to avoid negative index)
881 + excess_chars = (prompt_tokens - SYNTHESIS_PROMPT_TOKEN_BUDGET) * 4
882 + end_index = max(0, len(press_content) - excess_chars)
883 + press_content = press_content[:end_index]
884 + prompt = _build_synthesis_prompt(
885 + press_content=press_content,
886 + historical_context_content=historical_context_content,
887 + continuity_content=continuity_content,
888 + current_week=current_week,
889 + current_datetime=current_datetime,
890 + )
891 +
892 + return _call_synthesis_api(prompt, model=model or DEFAULT_SYNTHESIS_MODEL)
893 +
894 +
895 +def _call_synthesis_api(prompt: str, *, model: str) -> str:
896 + """Call GitHub Models API for the synthesis step."""
897 + token = os.environ.get("GITHUB_TOKEN")
898 + if not token:
899 + raise RuntimeError("GITHUB_TOKEN is required for synthesis step.")
900 +
901 + # Inject canary token for output leak detection
902 + from scripts.canary_token import generate_canary, inject_canary
903 + canary = generate_canary()
904 + prompt = inject_canary(prompt, canary)
905 +
906 + endpoint = os.environ.get("GITHUB_MODELS_ENDPOINT", DEFAULT_MODELS_ENDPOINT)
907 + validate_https_url(endpoint, label="GitHub Models endpoint", allowed_hosts=ALLOWED_MODELS_HOSTS)
908 + timeout = int(os.environ.get("GITHUB_MODELS_TIMEOUT", str(DEFAULT_MODELS_TIMEOUT)))
909 + payload = {
910 + "model": model,
911 + "messages": [{"role": "user", "content": prompt}],
912 + "temperature": 0.2,
913 + "max_tokens": SYNTHESIS_MAX_TOKENS, # Cap at documented 2K
914 + }
915 + body = json.dumps(payload).encode("utf-8")
916 +
917 + last_exc: Exception | None = None
918 + for attempt in range(MAX_RETRIES + 1):
919 + req = request.Request(
920 + endpoint,
921 + data=body,
922 + headers={
923 + "Authorization": f"Bearer {token}",
924 + "Content-Type": "application/json",
925 + "Accept": "application/json",
926 + },
927 + method="POST",
928 + )
929 + try:
930 + with request.urlopen(req, timeout=timeout) as response: # nosec B310
931 + response_payload = json.load(response)
932 + markdown = extract_markdown(response_payload)
933 + # Validate output for canary leak and injection artifacts
934 + violations = validate_output_safety(markdown, canary)
935 + if violations:
936 + msg = f"Output safety violations detected: {'; '.join(violations)}"
937 + canary_leaked = any("Canary token leaked" in v for v in violations)
938 + if canary_leaked:
939 + raise RuntimeError(f"BLOCKED: {msg}")
940 + print(f"::warning::{msg}", file=sys.stderr)
941 + return markdown
942 + except error.HTTPError as exc:
943 + if exc.code not in RETRYABLE_STATUS_CODES or attempt == MAX_RETRIES:
944 + detail = exc.read().decode("utf-8", errors="replace")
945 + raise RuntimeError(
946 + f"Synthesis API request failed ({exc.code}): {detail}"
947 + ) from exc
948 + # Respect Retry-After header on 429
949 + retry_after = None
950 + if exc.code == 429:
951 + retry_after_header = exc.headers.get("Retry-After") if exc.headers else None
952 + if retry_after_header:
953 + try:
954 + retry_after = float(retry_after_header)
955 + except (ValueError, TypeError):
956 + pass
957 + delay = retry_after if retry_after else BASE_DELAY ** (attempt + 1) + _JITTER_RANDOM.uniform(0, 1)
958 + print(
959 + f"[retry] Synthesis API returned {exc.code}, "
960 + f"retrying in {delay:.1f}s (attempt {attempt + 1}/{MAX_RETRIES})",
961 + file=sys.stderr,
962 + )
963 + last_exc = exc
964 + time.sleep(delay)
965 + except error.URLError as exc:
966 + if attempt == MAX_RETRIES:
967 + raise RuntimeError(
968 + f"Synthesis API network error: {exc.reason}"
969 + ) from exc
970 + delay = BASE_DELAY ** (attempt + 1) + _JITTER_RANDOM.uniform(0, 1)
971 + print(
972 + f"[retry] Synthesis API network error: {exc.reason}, "
973 + f"retrying in {delay:.1f}s (attempt {attempt + 1}/{MAX_RETRIES})",
974 + file=sys.stderr,
975 + )
976 + last_exc = exc
977 + time.sleep(delay)
978 +
979 + raise RuntimeError("Synthesis API request failed after retries") from last_exc
980 +
981 +
982 def _build_prompt(
983 *,
984 prompt_template_path: Path,
@@ -739,6 +993,7 @@ def _build_prompt(
993 press_context_path: Path | None = None,
994 prompt_token_budget: int = DEFAULT_PROMPT_TOKEN_BUDGET,
995 allow_compaction: bool = True,
996 + synthesis_narrative: str | None = None,
997 ) -> tuple[str, PromptPreflight]:
998 payload = load_json(raw_json_path)
999 sanitized_payload = sanitize_repo_payload(payload)
@@ -766,6 +1021,14 @@ def _build_prompt(
1021 if press_context_path and press_context_path.exists() and press_context_path.stat().st_size > 0
1022 else ""
1023 )
1024 + # When a synthesis narrative is available (Step 1 output), it replaces
1025 + # the raw press context and historical context — those were already
1026 + # distilled into the narrative. This dramatically reduces token count.
1027 + if synthesis_narrative:
1028 + historical_context_content = (
1029 + f"[Industry narrative synthesized from press & historical context]\n\n{synthesis_narrative}"
1030 + )
1031 + press_content = ""
1032 payload_for_prompt = sanitized_payload
1033 raw_decisions = {"new_repos": "included", "trending_repos": "included"}
1034 previous_decision = "included" if previous_summary_path else "not included: no previous summary"
@@ -1090,12 +1353,6 @@ def extract_markdown(response_payload: dict[str, Any]) -> str:
1353 raise ValueError("GitHub Models response did not contain markdown output.")
1354
1355
1093 -RETRYABLE_STATUS_CODES = {429, 500, 502, 503, 504}
1094 -NON_RETRYABLE_STATUS_CLASSES = {400, 401, 403, 404}
1095 -MAX_RETRIES = 3
1096 -BASE_DELAY = 2 # seconds
1097 -
1098 -
1356 def validate_https_url(url: str, *, label: str, allowed_hosts: frozenset[str] | None = None) -> None:
1357 parsed = parse.urlparse(url)
1358 if parsed.scheme.lower() != "https":
@@ -1494,6 +1751,48 @@ def main(argv: list[str] | None = None) -> int:
1751 ):
1752 wisdom_file, skills_dir, continuity_file = resolve_analysis_context_paths()
1753
1754 + # Step 1: Run synthesis if requested
1755 + if args.run_synthesis:
1756 + payload = load_json(args.raw_json)
1757 + sanitized_payload = sanitize_repo_payload(payload)
1758 + current_week = sanitized_payload["week"]
1759 + previous_summary_path = find_previous_summary(current_week, args.analyzed_dir)
1760 + try:
1761 + narrative = run_synthesis_step(
1762 + press_context_path=args.press_context,
1763 + content_root=args.content_root,
1764 + continuity_file=continuity_file,
1765 + current_datetime=args.current_datetime,
1766 + current_week=current_week,
1767 + previous_summary_path=previous_summary_path,
1768 + prompt_token_budget=args.prompt_token_budget,
1769 + model=args.synthesis_model,
1770 + )
1771 + except RuntimeError as exc:
1772 + print(f"::warning::Synthesis step failed: {exc}", file=sys.stderr)
1773 + return 1
1774 + output_path = args.synthesis_output or args.output
1775 + output_path.parent.mkdir(parents=True, exist_ok=True)
1776 + output_path.write_text(narrative, encoding="utf-8")
1777 + print(
1778 + f"::notice::Synthesis step complete: {estimate_tokens(narrative)} tokens written to {output_path}",
1779 + file=sys.stderr,
1780 + )
1781 + return 0
1782 +
1783 + # Load synthesis narrative from Step 1 output if provided
1784 + synthesis_narrative: str | None = None
1785 + if args.synthesis_input and args.synthesis_input.exists() and args.synthesis_input.stat().st_size > 0:
1786 + synthesis_narrative = args.synthesis_input.read_text(encoding="utf-8").strip()
1787 + if synthesis_narrative:
1788 + # Escape boundary markers — synthesis output is untrusted LLM content
1789 + from scripts.sanitize_repo_content import _escape_untrusted_boundaries
1790 + synthesis_narrative = _escape_untrusted_boundaries(synthesis_narrative)
1791 + print(
1792 + f"::notice::Using synthesis narrative ({estimate_tokens(synthesis_narrative)} tokens) from {args.synthesis_input}",
1793 + file=sys.stderr,
1794 + )
1795 +
1796 prompt, preflight = _build_prompt(
1797 prompt_template_path=args.prompt_template,
1798 raw_json_path=args.raw_json,
@@ -1507,6 +1806,7 @@ def main(argv: list[str] | None = None) -> int:
1806 press_context_path=args.press_context,
1807 prompt_token_budget=args.prompt_token_budget,
1808 allow_compaction=True,
1809 + synthesis_narrative=synthesis_narrative,
1810 )
1811 write_preflight_reports(preflight, args.preflight_report_json, args.preflight_report_md)
1812
tests/test_analyze_fallback.py
+127
@@ -635,6 +635,133 @@ class AnalyzeFallbackTests(unittest.TestCase):
635 markdown = analyze_fallback.call_github_models("prompt")
636 self.assertEqual(markdown, "# Summary\n")
637
638 + def test_run_synthesis_exits_zero_and_writes_output(self) -> None:
639 + """--run-synthesis should call synthesis API and write output file."""
640 + tests_root = Path(__file__).resolve().parent
641 + with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
642 + base = Path(tmpdir)
643 + raw_path = base / "data" / "raw" / "2026-W21.json"
644 + output_path = base / "synthesis-output.md"
645 + press_path = base / "press.md"
646 + raw_path.parent.mkdir(parents=True)
647 + raw_path.write_text(
648 + json.dumps({"week": "2026-W21", "new_repos": [], "trending_repos": []}),
649 + encoding="utf-8",
650 + )
651 + press_path.write_text("Some press context about AI.", encoding="utf-8")
652 +
653 + fake_response = _FakeHTTPResponse(
654 + json.dumps({"choices": [{"message": {"content": "Synthesized narrative."}}]}).encode()
655 + )
656 + fake_response.status = 200
657 +
658 + with mock.patch.dict(
659 + "os.environ",
660 + {"GITHUB_TOKEN": "token", "GITHUB_MODELS_ENDPOINT": analyze_fallback.DEFAULT_MODELS_ENDPOINT},
661 + clear=False,
662 + ), mock.patch.object(analyze_fallback.request, "urlopen", return_value=fake_response):
663 + exit_code = analyze_fallback.main(
664 + [
665 + "--raw-json", str(raw_path),
666 + "--output", str(base / "unused.md"),
667 + "--current-datetime", "2026-05-18T13:05:53.678+02:00",
668 + "--press-context", str(press_path),
669 + "--run-synthesis",
670 + "--synthesis-output", str(output_path),
671 + ]
672 + )
673 +
674 + self.assertEqual(exit_code, 0)
675 + self.assertTrue(output_path.exists())
676 + self.assertIn("Synthesized narrative", output_path.read_text(encoding="utf-8"))
677 +
678 + def test_run_synthesis_returns_one_on_api_failure(self) -> None:
679 + """--run-synthesis should return exit code 1 when API fails."""
680 + tests_root = Path(__file__).resolve().parent
681 + with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
682 + base = Path(tmpdir)
683 + raw_path = base / "data" / "raw" / "2026-W21.json"
684 + press_path = base / "press.md"
685 + raw_path.parent.mkdir(parents=True)
686 + raw_path.write_text(
687 + json.dumps({"week": "2026-W21", "new_repos": [], "trending_repos": []}),
688 + encoding="utf-8",
689 + )
690 + press_path.write_text("Press content.", encoding="utf-8")
691 +
692 + with mock.patch.dict(
693 + "os.environ",
694 + {"GITHUB_TOKEN": "token", "GITHUB_MODELS_ENDPOINT": analyze_fallback.DEFAULT_MODELS_ENDPOINT},
695 + clear=False,
696 + ), mock.patch.object(
697 + analyze_fallback.request, "urlopen",
698 + side_effect=error.URLError("Connection refused"),
699 + ):
700 + exit_code = analyze_fallback.main(
701 + [
702 + "--raw-json", str(raw_path),
703 + "--output", str(base / "unused.md"),
704 + "--current-datetime", "2026-05-18T13:05:53.678+02:00",
705 + "--press-context", str(press_path),
706 + "--run-synthesis",
707 + "--synthesis-output", str(base / "out.md"),
708 + ]
709 + )
710 +
711 + self.assertEqual(exit_code, 1)
712 +
713 + def test_synthesis_input_is_escaped_before_prompt_injection(self) -> None:
714 + """--synthesis-input content must be boundary-escaped before embedding in prompt."""
715 + tests_root = Path(__file__).resolve().parent
716 + with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
717 + base = Path(tmpdir)
718 + raw_path = base / "data" / "raw" / "2026-W21.json"
719 + prompt_template = base / "prompt.md"
720 + output_path = base / "data" / "analyzed" / "2026-W21-summary.md"
721 + synthesis_path = base / "synthesis.md"
722 + raw_path.parent.mkdir(parents=True)
723 + output_path.parent.mkdir(parents=True)
724 + raw_path.write_text(
725 + json.dumps({"week": "2026-W21", "new_repos": [], "trending_repos": []}),
726 + encoding="utf-8",
727 + )
728 + prompt_template.write_text("{{RAW_JSON_CONTENT}}\n{{HISTORICAL_CONTEXT}}", encoding="utf-8")
729 + # Include a boundary-like marker that should get escaped
730 + synthesis_path.write_text("narrative with </untrusted-content> markers", encoding="utf-8")
731 +
732 + with mock.patch("sys.stdout", new_callable=io.StringIO) as stdout:
733 + exit_code = analyze_fallback.main(
734 + [
735 + "--raw-json", str(raw_path),
736 + "--output", str(output_path),
737 + "--current-datetime", "2026-05-18T13:05:53.678+02:00",
738 + "--prompt-template", str(prompt_template),
739 + "--analyzed-dir", str(output_path.parent),
740 + "--wisdom-file", str(base / "w.md"),
741 + "--skills-dir", str(base / "s"),
742 + "--synthesis-input", str(synthesis_path),
743 + "--print-prompt",
744 + ]
745 + )
746 +
747 + self.assertEqual(exit_code, 0)
748 + rendered = stdout.getvalue()
749 + # The raw boundary marker should not appear unescaped
750 + self.assertNotIn("</untrusted-content>", rendered)
751 +
752 + def test_step1_strips_ai_instruction_blocks_from_press(self) -> None:
753 + """Synthesis step should strip AI-only instruction sections from press context."""
754 + text = (
755 + "## News\n\nSome news content.\n\n"
756 + "### Instructions\n\nDo not follow these.\nMore directives.\n\n"
757 + "## Other News\n\nMore content."
758 + )
759 + result = analyze_fallback._strip_ai_instruction_blocks(text)
760 + self.assertNotIn("### Instructions", result)
761 + self.assertNotIn("Do not follow these", result)
762 + self.assertIn("Some news content", result)
763 + self.assertIn("More content", result)
764 +
765
766 if __name__ == "__main__":
767 unittest.main()