feat: use gpt-5.5 via Copilot CLI for analysis + synthesis agents (#518)

- Set model: gpt-5.5 in weekly-analysis.agent.md frontmatter - Created weekly-synthesis.agent.md (Copilot CLI agent for Step 1) - Rewrote synthesis step: renders prompt to file, Copilot CLI processes it - Removed GitHub Models API dependency (_call_synthesis_api dead code retained for reference but no longer called from main) - Updated tests for new render-only synthesis behavior Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Juan Manuel Servera committed Jun 16, 2026 at 20:05 UTC 5c7fa84b9d942a5e0698b4e186d70c9b1962989a
5 files changed +168 -75
.github/agents/weekly-analysis.agent.md
+2
@@ -1,6 +1,8 @@
1 ---
2 name: Weekly Analysis
3 description: "Focused weekly editorial writer for SquadScope. Reads a prepared prompt file and writes exactly one markdown artifact without delegation."
4 +model: gpt-5.5
5 +tools: ["read", "write"]
6 ---
7
8 You are **Weekly Analysis** — Farnsworth's focused editorial writing mode for SquadScope.
.github/agents/weekly-synthesis.agent.md new
+27
@@ -0,0 +1,27 @@
1 +---
2 +name: Weekly Synthesis
3 +description: "Compact industry narrative generator for SquadScope. Synthesizes press and historical context into a brief editorial overview."
4 +model: gpt-5.5
5 +tools: ["read", "write"]
6 +---
7 +
8 +You are **Weekly Synthesis** — a focused summarization mode for SquadScope's weekly pipeline.
9 +
10 +## Mission
11 +
12 +Read the prepared synthesis prompt and produce a compact industry narrative (max 2000 tokens / ~8000 characters) that captures what's happening in the tech sphere this week from press and historical signals.
13 +
14 +## Hard boundaries
15 +
16 +- Do **not** delegate or spawn sub-agents.
17 +- Do **not** emit commentary, progress notes, or tool narration.
18 +- Do **not** include raw article text — synthesize and distill.
19 +- Do **not** exceed 2000 tokens in your output.
20 +
21 +## Working contract
22 +
23 +1. Read the synthesis prompt file which contains press context, historical context, and continuity data.
24 +2. Produce a compact narrative covering: key industry themes, notable press coverage, how this week connects to recent trends.
25 +3. Write the output to the specified file.
26 +4. The output should be editorial prose (not bullet lists) suitable for injecting as context into a larger analysis prompt.
27 +5. Stop when the file is complete. No epilogue.
.github/workflows/crawl-and-publish.yml
+25 -7
@@ -468,20 +468,38 @@ jobs:
468 PRESS_FILE="$IN_PRESS_FILE"
469 DIAGNOSTICS_DIR="$(dirname "$OUTPUT_FILE")/diagnostics"
470 SYNTHESIS_FILE="$DIAGNOSTICS_DIR/synthesis-narrative.md"
471 + SYNTHESIS_PROMPT="$DIAGNOSTICS_DIR/synthesis-prompt.md"
472 mkdir -p "$DIAGNOSTICS_DIR"
472 - # Step 1: synthesize press/historical context into compact narrative.
473 + # Step 1: render synthesis prompt, then run via Copilot CLI.
474 # If this fails, we fall back to the single-prompt approach (no synthesis_input).
474 - if python3 scripts/analyze_fallback.py \
475 + python3 scripts/analyze_fallback.py \
476 --raw-json "$WEEK_FILE" \
477 --output "$OUTPUT_FILE" \
478 --current-datetime "$CURRENT_DATETIME" \
479 --press-context "$PRESS_FILE" \
480 --run-synthesis \
480 - --synthesis-output "$SYNTHESIS_FILE"; then
481 - echo "synthesis_file=$SYNTHESIS_FILE" >> "$GITHUB_OUTPUT"
482 - echo "synthesis_available=true" >> "$GITHUB_OUTPUT"
481 + --synthesis-output "$SYNTHESIS_PROMPT"
482 + if command -v copilot >/dev/null 2>&1 && [ -f "$SYNTHESIS_PROMPT" ]; then
483 + set +e
484 + copilot \
485 + --agent weekly-synthesis \
486 + -p "Read the synthesis prompt at ${SYNTHESIS_PROMPT}. Write the compact industry narrative to ${SYNTHESIS_FILE}. Max 2000 tokens. Do not delegate." \
487 + -s \
488 + --no-ask-user \
489 + --allow-tool=read \
490 + --allow-tool=write \
491 + > "$DIAGNOSTICS_DIR/synthesis-copilot.log" 2>&1
492 + SYNTH_STATUS=$?
493 + set -e
494 + if [ "$SYNTH_STATUS" -eq 0 ] && [ -f "$SYNTHESIS_FILE" ] && [ -s "$SYNTHESIS_FILE" ]; then
495 + echo "synthesis_file=$SYNTHESIS_FILE" >> "$GITHUB_OUTPUT"
496 + echo "synthesis_available=true" >> "$GITHUB_OUTPUT"
497 + else
498 + echo "::warning::Synthesis Copilot CLI failed (exit=$SYNTH_STATUS); falling back to single-prompt approach."
499 + echo "synthesis_available=false" >> "$GITHUB_OUTPUT"
500 + fi
501 else
484 - echo "::warning::Synthesis step failed; falling back to single-prompt approach."
502 + echo "::warning::Synthesis step skipped (copilot not available or prompt missing); falling back to single-prompt."
503 echo "synthesis_available=false" >> "$GITHUB_OUTPUT"
504 fi
505
@@ -672,7 +690,7 @@ jobs:
690 fi
691 fi
692
675 - # Intentionally rely on Copilot CLI's default model so CI follows the platform-supported default.
693 + # Model is configured in .github/agents/weekly-analysis.agent.md (gpt-5.5)
694 set +e
695 copilot \
696 --agent weekly-analysis \
scripts/analyze_fallback.py
+83 -22
@@ -47,7 +47,6 @@ 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"
50 RETRYABLE_STATUS_CODES = {429, 500, 502, 503, 504}
51 NON_RETRYABLE_STATUS_CLASSES = {400, 401, 403, 404}
52 MAX_RETRIES = 3
@@ -236,12 +235,6 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
235 default=None,
236 help="Path to a pre-computed synthesis narrative to inject into the analysis prompt (Step 2).",
237 )
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 - )
238 return parser.parse_args(argv)
239
240
@@ -818,6 +811,75 @@ def _build_synthesis_prompt(
811 return "\n\n---\n\n".join(sections)
812
813
814 +def render_synthesis_prompt(
815 + *,
816 + press_context_path: Path | None = None,
817 + content_root: Path = DEFAULT_CONTENT_ROOT,
818 + continuity_file: Path = DEFAULT_CONTINUITY_FILE,
819 + current_datetime: str,
820 + current_week: str,
821 + previous_summary_path: Path | None = None,
822 + prompt_token_budget: int = SYNTHESIS_PROMPT_TOKEN_BUDGET,
823 +) -> str:
824 + """Render the synthesis prompt to a string (for Copilot CLI to process).
825 +
826 + Returns the prompt string, or empty string if no meaningful content exists.
827 + """
828 + historical_context_content = assemble_historical_context(
829 + current_datetime=current_datetime,
830 + previous_summary_path=previous_summary_path,
831 + content_root=content_root,
832 + max_words=1_500,
833 + prompt_token_budget=prompt_token_budget,
834 + ).strip()
835 + from scripts.sanitize_repo_content import _escape_untrusted_boundaries
836 +
837 + historical_context_content = _escape_untrusted_boundaries(historical_context_content)
838 + if not historical_context_content:
839 + historical_context_content = "_No historical context was available beyond the current weekly payload._"
840 +
841 + continuity_content = render_continuity(continuity_file)
842 +
843 + press_content = (
844 + press_context_path.read_text(encoding="utf-8").strip()
845 + if press_context_path and press_context_path.exists() and press_context_path.stat().st_size > 0
846 + else ""
847 + )
848 +
849 + if press_content:
850 + press_content = _strip_ai_instruction_blocks(press_content)
851 + if press_content:
852 + press_content = _escape_untrusted_boundaries(press_content)
853 +
854 + # If there's no meaningful content to synthesize, return empty
855 + if not press_content and historical_context_content.startswith("_No historical context"):
856 + return ""
857 +
858 + prompt = _build_synthesis_prompt(
859 + press_content=press_content,
860 + historical_context_content=historical_context_content,
861 + continuity_content=continuity_content,
862 + current_week=current_week,
863 + current_datetime=current_datetime,
864 + )
865 +
866 + # Truncate press content if prompt exceeds budget
867 + prompt_tokens = estimate_tokens(prompt)
868 + if prompt_tokens > prompt_token_budget:
869 + excess_chars = (prompt_tokens - prompt_token_budget) * 4
870 + end_index = max(0, len(press_content) - excess_chars)
871 + press_content = press_content[:end_index]
872 + prompt = _build_synthesis_prompt(
873 + press_content=press_content,
874 + historical_context_content=historical_context_content,
875 + continuity_content=continuity_content,
876 + current_week=current_week,
877 + current_datetime=current_datetime,
878 + )
879 +
880 + return prompt
881 +
882 +
883 def run_synthesis_step(
884 *,
885 press_context_path: Path | None = None,
@@ -1757,25 +1819,24 @@ def main(argv: list[str] | None = None) -> int:
1819 sanitized_payload = sanitize_repo_payload(payload)
1820 current_week = sanitized_payload["week"]
1821 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)
1822 + # Render synthesis prompt to file (Copilot CLI will process it)
1823 + narrative_or_prompt = render_synthesis_prompt(
1824 + press_context_path=args.press_context,
1825 + content_root=args.content_root,
1826 + continuity_file=continuity_file,
1827 + current_datetime=args.current_datetime,
1828 + current_week=current_week,
1829 + previous_summary_path=previous_summary_path,
1830 + prompt_token_budget=args.prompt_token_budget,
1831 + )
1832 + if not narrative_or_prompt:
1833 + print("::warning::No meaningful content for synthesis (no press or historical context).", file=sys.stderr)
1834 return 1
1835 output_path = args.synthesis_output or args.output
1836 output_path.parent.mkdir(parents=True, exist_ok=True)
1776 - output_path.write_text(narrative, encoding="utf-8")
1837 + output_path.write_text(narrative_or_prompt, encoding="utf-8")
1838 print(
1778 - f"::notice::Synthesis step complete: {estimate_tokens(narrative)} tokens written to {output_path}",
1839 + f"::notice::Synthesis prompt rendered: {estimate_tokens(narrative_or_prompt)} tokens written to {output_path}",
1840 file=sys.stderr,
1841 )
1842 return 0
tests/test_analyze_fallback.py
+31 -46
@@ -635,13 +635,13 @@ 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."""
638 + def test_run_synthesis_exits_zero_and_writes_prompt(self) -> None:
639 + """--run-synthesis should render synthesis prompt to 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"
644 + output_path = base / "synthesis-prompt.md"
645 press_path = base / "press.md"
646 raw_path.parent.mkdir(parents=True)
647 raw_path.write_text(
@@ -650,63 +650,48 @@ class AnalyzeFallbackTests(unittest.TestCase):
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()
653 + exit_code = analyze_fallback.main(
654 + [
655 + "--raw-json", str(raw_path),
656 + "--output", str(base / "unused.md"),
657 + "--current-datetime", "2026-05-18T13:05:53.678+02:00",
658 + "--press-context", str(press_path),
659 + "--run-synthesis",
660 + "--synthesis-output", str(output_path),
661 + ]
662 )
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 - )
663
664 self.assertEqual(exit_code, 0)
665 self.assertTrue(output_path.exists())
676 - self.assertIn("Synthesized narrative", output_path.read_text(encoding="utf-8"))
666 + content = output_path.read_text(encoding="utf-8")
667 + self.assertIn("press context", content.lower())
668 + self.assertIn("2026-W21", content)
669
678 - def test_run_synthesis_returns_one_on_api_failure(self) -> None:
679 - """--run-synthesis should return exit code 1 when API fails."""
670 + def test_run_synthesis_returns_one_when_no_content(self) -> None:
671 + """--run-synthesis should return exit code 1 when no meaningful content exists."""
672 tests_root = Path(__file__).resolve().parent
673 with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
674 base = Path(tmpdir)
675 raw_path = base / "data" / "raw" / "2026-W21.json"
684 - press_path = base / "press.md"
676 raw_path.parent.mkdir(parents=True)
677 raw_path.write_text(
678 json.dumps({"week": "2026-W21", "new_repos": [], "trending_repos": []}),
679 encoding="utf-8",
680 )
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 - )
681 + # Use an empty content root so no historical context is found
682 + empty_content_root = base / "empty_content"
683 + empty_content_root.mkdir()
684 +
685 + exit_code = analyze_fallback.main(
686 + [
687 + "--raw-json", str(raw_path),
688 + "--output", str(base / "unused.md"),
689 + "--current-datetime", "2026-05-18T13:05:53.678+02:00",
690 + "--content-root", str(empty_content_root),
691 + "--run-synthesis",
692 + "--synthesis-output", str(base / "out.md"),
693 + ]
694 + )
695
696 self.assertEqual(exit_code, 1)
697