Preserve good weekly analysis on unsafe reruns (#276)

* fix: preserve good weekly analysis on reruns (#250) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: fail fast on Copilot access failures Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Avoid derived quality reasons for missing summaries 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 6, 2026 at 21:45 UTC bb8e5a92343801c5df46777d0bf535aa44f66a43
7 files changed +411 -18
.github/workflows/crawl-and-publish.yml
+27 -6
@@ -480,10 +480,14 @@ jobs:
480 --run-id "${GITHUB_RUN_ID:-local}")
481 FINAL_FAILURE_CLASS="$FAILURE_CLASS"
482 echo "::warning::Copilot CLI failed on attempt $((ATTEMPT + 1)); class=${FAILURE_CLASS}; report=${COPILOT_FAILURE_REPORT}"
483 - if [ "$FAILURE_CLASS" = "copilot_token_failure" ] || [ "$FAILURE_CLASS" = "copilot_inaccessible" ] || [ "$FAILURE_CLASS" = "context_too_large" ]; then
484 - echo "::error::Non-retryable Copilot analysis failure (${FAILURE_CLASS}). See ${COPILOT_FAILURE_REPORT}."
483 + if [ "$FAILURE_CLASS" = "copilot_token_failure" ] || [ "$FAILURE_CLASS" = "copilot_inaccessible" ]; then
484 + echo "::error::Copilot access failure (${FAILURE_CLASS}). Renewal issue was created or updated; failing without no-AI fallback. See ${COPILOT_FAILURE_REPORT}."
485 exit 1
486 fi
487 + if [ "$FAILURE_CLASS" = "context_too_large" ]; then
488 + echo "::warning::Non-retryable Copilot analysis failure (${FAILURE_CLASS}). Writing a non-publishable no-AI candidate so the current published article can be preserved. See ${COPILOT_FAILURE_REPORT}."
489 + break
490 + fi
491 ATTEMPT=$((ATTEMPT + 1))
492 continue
493 fi
@@ -517,12 +521,24 @@ jobs:
521 done
522 else
523 FINAL_FAILURE_CLASS="copilot_inaccessible"
520 - echo "::error::Copilot CLI unavailable; this repository has no GitHub Models/OpenAI fallback."
524 + COPILOT_LOG="$DIAGNOSTICS_DIR/copilot-cli-unavailable.log"
525 + COPILOT_FAILURE_REPORT="$DIAGNOSTICS_DIR/copilot-cli-failure-unavailable.json"
526 + echo "copilot is not available: command not found" > "$COPILOT_LOG"
527 + python3 scripts/copilot_failure.py \
528 + --log "$COPILOT_LOG" \
529 + --exit-code 127 \
530 + --report-json "$COPILOT_FAILURE_REPORT" \
531 + --create-token-issue \
532 + --repo "${GITHUB_REPOSITORY:-jmservera/SquadScope}" \
533 + --assignee "jmservera" \
534 + --week "$WEEK" \
535 + --run-id "${GITHUB_RUN_ID:-local}" >/dev/null
536 + echo "::error::Copilot CLI unavailable. Renewal issue was created or updated; failing without no-AI fallback. See ${COPILOT_FAILURE_REPORT}."
537 exit 1
538 fi
539
540 if [ "$GATE_PASSED" = "false" ]; then
525 - echo "::error::No publishable Copilot summary was produced; this repository has no GitHub Models/OpenAI fallback. Final failure class: ${FINAL_FAILURE_CLASS:-quality_gate}"
541 + echo "::warning::No publishable Copilot summary was produced. Final failure class: ${FINAL_FAILURE_CLASS:-quality_gate}. The publish manifest will preserve any existing good weekly article."
542 python3 scripts/analyze_fallback.py \
543 --raw-json "$WEEK_FILE" \
544 --output "$OUTPUT_FILE" \
@@ -533,7 +549,8 @@ jobs:
549 NO_AI_GATE_REPORT="$DIAGNOSTICS_DIR/gate-no-ai-attempt-0.json"
550 run_quality_gate no-ai "$NO_AI_GATE_REPORT" || true
551 cp "$OUTPUT_FILE" "$DIAGNOSTICS_DIR/candidate-no-ai-attempt-0.md" 2>/dev/null || true
536 - exit 1
552 + ANALYSIS_SOURCE="no-ai"
553 + ANALYSIS_MODEL="none"
554 fi
555
556 # Copy final transcript to canonical location
@@ -566,6 +583,8 @@ jobs:
583 echo "analysis_model=$ANALYSIS_MODEL" >> "$GITHUB_OUTPUT"
584
585 - name: quality-check
586 + id: quality-check
587 + continue-on-error: true
588 env:
589 ANALYSIS_FILE: ${{ steps.analysis-context.outputs.candidate_output_file }}
590 ANALYSIS_SOURCE: ${{ steps.run-analysis.outputs.analysis_source }}
@@ -588,9 +607,11 @@ jobs:
607 RAW_JSON_FILE: ${{ steps.analysis-context.outputs.week_file }}
608 ANALYSIS_SOURCE: ${{ steps.run-analysis.outputs.analysis_source }}
609 ANALYSIS_MODEL: ${{ steps.run-analysis.outputs.analysis_model }}
610 + VALIDATION_STATUS: ${{ steps.quality-check.outcome == 'success' && 'passed' || 'failed' }}
611 MANIFEST_FILE: ${{ steps.analysis-context.outputs.publish_manifest_file }}
612 run: |
613 set -euo pipefail
614 + git fetch origin publish 2>/dev/null && git checkout origin/publish -- "$PUBLISHED_SUMMARY" 2>/dev/null || true
615 ARTIFACT_ARGS=()
616 for candidate in \
617 "external_news=data/raw/${WEEK}-external-news.json" \
@@ -610,7 +631,7 @@ jobs:
631 --raw-json "$RAW_JSON_FILE" \
632 --analysis-source "$ANALYSIS_SOURCE" \
633 --analysis-model "$ANALYSIS_MODEL" \
613 - --validation-status passed \
634 + --validation-status "$VALIDATION_STATUS" \
635 --output "$MANIFEST_FILE" \
636 "${ARTIFACT_ARGS[@]}"
637
docs/pipeline-validation.md
+2 -2
@@ -65,8 +65,8 @@ Required secrets/tokens:
65 - Current raw file week matches the run week
66 - Correlation and press-context steps consume compact external-news data with legacy `YYYY-WNN-techcrunch.json` fallback
67 - Press context preserves source names, article URLs/titles/dates, strong-vs-weak labels, and partial-source caveats while staying under the ~8k token budget
68 -- Copilot CLI output is written to `data/analyzed/`; if Copilot cannot produce publishable analysis, no-AI output is diagnostic only and the run fails.
69 -- `scripts/analysis_gate.py` passes before publish continues
68 +- Copilot CLI output is staged under `data/candidates/YYYY-WNN/<run-id>/`; if Copilot cannot produce publishable analysis, a no-AI candidate is kept as a rejected artifact and the run fails closed before promotion.
69 +- `scripts/analysis_gate.py` passes before publish continues, and `scripts/publish_manifest.py` records promote-vs-preserve decisions against any existing good published summary.
70 - Job permissions include `actions: read`, `contents: write`, and `issues: write`
71
72 ### 3. Generate
scripts/copilot_failure.py
+3 -3
@@ -118,14 +118,14 @@ def issue_title() -> str:
118 def issue_body(report: CopilotFailure, *, week: str, run_id: str) -> str:
119 return "\n".join(
120 [
121 - "The weekly analysis workflow cannot run because GitHub Copilot authentication failed.",
121 + "The weekly analysis workflow cannot run because GitHub Copilot authentication or access failed.",
122 "",
123 f"- Week: `{week}`",
124 f"- Run ID: `{run_id}`",
125 f"- Failure class: `{report.failure_class}`",
126 f"- Diagnostic: {report.diagnostic}",
127 "",
128 - "Please renew or replace the `COPILOT_GH_TOKEN` secret, then rerun the workflow.",
128 + "Please renew or replace the `COPILOT_GH_TOKEN` secret, verify Copilot access, then rerun the workflow.",
129 ]
130 )
131
@@ -214,7 +214,7 @@ def main(argv: list[str] | None = None) -> int:
214 payload = asdict(report)
215 payload["log_path"] = args.log.as_posix()
216
217 - if args.create_token_issue and report.failure_class == "copilot_token_failure":
217 + if args.create_token_issue and report.failure_class in {"copilot_token_failure", "copilot_inaccessible"}:
218 payload["issue"] = create_or_update_token_issue(
219 report,
220 repo=args.repo,
scripts/publish_manifest.py
+175 -2
@@ -4,6 +4,7 @@ from __future__ import annotations
4 import argparse
5 import hashlib
6 import json
7 +import re
8 from datetime import UTC, datetime
9 from pathlib import Path
10 from typing import Any
@@ -11,6 +12,14 @@ from typing import Any
12
13 SCHEMA_VERSION = "publish_eligibility_v1"
14 AI_SOURCES = {"copilot-cli", "github-models"}
15 +MIN_PUBLISH_QUALITY_SCORE = 60
16 +NO_AI_MARKERS = (
17 + "AI analysis was unavailable",
18 + "without AI-powered analysis",
19 + "Automated data-only summary",
20 + "generated without AI assistance",
21 +)
22 +FRONTMATTER_PATTERN = re.compile(r"^---\n(?P<frontmatter>.*?)\n---\n(?P<body>.*)\Z", re.DOTALL)
23
24
25 def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
@@ -73,6 +82,133 @@ def load_json(path: Path) -> dict[str, Any] | None:
82 return payload if isinstance(payload, dict) else None
83
84
85 +def _parse_scalar(value: str) -> Any:
86 + stripped = value.strip().strip('"\'')
87 + if re.fullmatch(r"-?\d+", stripped):
88 + return int(stripped)
89 + if re.fullmatch(r"-?\d+\.\d+", stripped):
90 + return float(stripped)
91 + if stripped.lower() == "true":
92 + return True
93 + if stripped.lower() == "false":
94 + return False
95 + return stripped
96 +
97 +
98 +def markdown_metadata(path: Path) -> dict[str, Any]:
99 + if not path.exists() or not path.is_file():
100 + return {
101 + "exists": False,
102 + "path": path.as_posix(),
103 + "sha256": None,
104 + "quality_score": None,
105 + "week": None,
106 + "ai_status": "missing",
107 + "reasons": ["summary missing"],
108 + }
109 +
110 + text = path.read_text(encoding="utf-8", errors="replace")
111 + match = FRONTMATTER_PATTERN.match(text)
112 + frontmatter: dict[str, Any] = {}
113 + reasons: list[str] = []
114 + if match:
115 + for line in match.group("frontmatter").splitlines():
116 + if ":" not in line or line.startswith((" ", "\t")):
117 + continue
118 + key, value = line.split(":", 1)
119 + frontmatter[key.strip()] = _parse_scalar(value)
120 + else:
121 + reasons.append("summary lacks YAML frontmatter")
122 +
123 + quality = frontmatter.get("quality_score")
124 + if not isinstance(quality, (int, float)):
125 + quality = None
126 + ai_status = "no-ai" if any(marker in text for marker in NO_AI_MARKERS) else "unknown"
127 + source = str(frontmatter.get("analysis_source") or frontmatter.get("source") or "").strip()
128 + if source in AI_SOURCES:
129 + ai_status = "ai"
130 + elif source == "no-ai":
131 + ai_status = "no-ai"
132 +
133 + return {
134 + "exists": True,
135 + "path": path.as_posix(),
136 + "sha256": sha256_file(path),
137 + "quality_score": quality,
138 + "week": frontmatter.get("week"),
139 + "title": frontmatter.get("title"),
140 + "ai_status": ai_status,
141 + "reasons": reasons,
142 + }
143 +
144 +
145 +def _published_manifest_paths(published_summary: Path, week: str) -> list[Path]:
146 + root = published_summary
147 + for parent in [published_summary, *published_summary.parents]:
148 + if (parent / "data").exists():
149 + root = parent
150 + break
151 + candidate_root = root / "data" / "candidates" / week
152 + return sorted(candidate_root.glob("*/publish-manifest.json")) if candidate_root.exists() else []
153 +
154 +
155 +def _ai_status_from_manifest(payload: dict[str, Any]) -> str:
156 + analysis = payload.get("analysis")
157 + if isinstance(analysis, dict):
158 + ai_status = analysis.get("ai_status")
159 + if ai_status in {"ai", "no-ai"}:
160 + return ai_status
161 + ai_provenance = payload.get("ai_provenance")
162 + if isinstance(ai_provenance, dict):
163 + source = ai_provenance.get("source")
164 + if source in AI_SOURCES:
165 + return "ai"
166 + if source == "no-ai":
167 + return "no-ai"
168 + return "unknown"
169 +
170 +
171 +def published_summary_status(path: Path, week: str) -> dict[str, Any]:
172 + status = markdown_metadata(path)
173 + if not status["exists"]:
174 + status.update({"good": False, "provenance_source": "missing"})
175 + return status
176 +
177 + matching_manifest: dict[str, Any] | None = None
178 + summary_sha = status.get("sha256")
179 + for manifest_path in _published_manifest_paths(path, week):
180 + payload = load_json(manifest_path)
181 + if not payload:
182 + continue
183 + candidate = payload.get("candidate")
184 + candidate_sha = candidate.get("summary_sha256") if isinstance(candidate, dict) else None
185 + if candidate_sha == summary_sha:
186 + matching_manifest = payload
187 + status["provenance_manifest_path"] = manifest_path.as_posix()
188 + break
189 +
190 + if matching_manifest:
191 + status["ai_status"] = _ai_status_from_manifest(matching_manifest)
192 + status["provenance_source"] = "publish-manifest"
193 + else:
194 + status["provenance_source"] = "summary"
195 +
196 + reasons = list(status.get("reasons", []))
197 + if status.get("week") != week:
198 + reasons.append(f"published summary week mismatch: expected {week}, found {status.get('week')!r}")
199 + quality = status.get("quality_score")
200 + if quality is None:
201 + reasons.append("published summary lacks quality_score")
202 + elif quality < MIN_PUBLISH_QUALITY_SCORE:
203 + reasons.append(f"published summary quality_score below {MIN_PUBLISH_QUALITY_SCORE}: {quality}")
204 + if status.get("ai_status") == "no-ai":
205 + reasons.append("published summary is no-AI fallback")
206 +
207 + status["reasons"] = reasons
208 + status["good"] = not reasons and status.get("ai_status") != "no-ai"
209 + return status
210 +
211 +
212 def same_day_reuse_status(payload: dict[str, Any] | None) -> dict[str, Any]:
213 metadata = payload.get("metadata", {}) if isinstance(payload, dict) else {}
214 if not isinstance(metadata, dict):
@@ -171,9 +307,29 @@ def create_manifest(args: argparse.Namespace) -> int:
307
308 analysis_source = args.analysis_source.strip()
309 ai_status = "ai" if analysis_source in AI_SOURCES else "no-ai" if analysis_source == "no-ai" else "unknown"
310 + candidate_metadata = markdown_metadata(args.summary)
311 + published_status = published_summary_status(args.published_summary, args.week)
312 candidate_exists = args.summary.exists()
313 validation_passed = args.validation_status == "passed"
176 - eligible = candidate_exists and validation_passed and ai_status == "ai" and not artifact_reasons
314 + candidate_quality = candidate_metadata.get("quality_score")
315 + comparison_reasons: list[str] = []
316 + if candidate_exists:
317 + if candidate_metadata.get("week") not in {None, args.week}:
318 + comparison_reasons.append(
319 + f"candidate summary week mismatch: expected {args.week}, found {candidate_metadata.get('week')!r}"
320 + )
321 + if candidate_quality is None:
322 + comparison_reasons.append("candidate summary lacks quality_score")
323 + elif candidate_quality < MIN_PUBLISH_QUALITY_SCORE:
324 + comparison_reasons.append(f"candidate quality_score below {MIN_PUBLISH_QUALITY_SCORE}: {candidate_quality}")
325 + if published_status.get("good") and isinstance(candidate_quality, (int, float)):
326 + published_quality = published_status.get("quality_score")
327 + if isinstance(published_quality, (int, float)) and candidate_quality < published_quality:
328 + comparison_reasons.append(
329 + f"candidate quality_score {candidate_quality} is lower than published good quality_score {published_quality}"
330 + )
331 +
332 + eligible = candidate_exists and validation_passed and ai_status == "ai" and not artifact_reasons and not comparison_reasons
333
334 reasons: list[str] = []
335 if not candidate_exists:
@@ -183,6 +339,10 @@ def create_manifest(args: argparse.Namespace) -> int:
339 if ai_status != "ai":
340 reasons.append(f"analysis source is not AI-publishable: {analysis_source or 'unknown'}")
341 reasons.extend(artifact_reasons)
342 + reasons.extend(comparison_reasons)
343 +
344 + preserve_existing = bool(published_status.get("good") and not eligible)
345 + decision = "promote" if eligible else "preserve" if preserve_existing else "block"
346
347 manifest = {
348 "schema_version": SCHEMA_VERSION,
@@ -193,7 +353,10 @@ def create_manifest(args: argparse.Namespace) -> int:
353 "summary_path": args.summary.as_posix(),
354 "published_summary_path": args.published_summary.as_posix(),
355 "summary_sha256": sha256_file(args.summary),
356 + "quality_score": candidate_quality,
357 + "ai_status": ai_status,
358 },
359 + "published": published_status,
360 "source_artifacts": source_artifacts,
361 "analysis": {
362 "ai_status": ai_status,
@@ -216,14 +379,24 @@ def create_manifest(args: argparse.Namespace) -> int:
379 },
380 "promotion": {
381 "eligible": eligible,
219 - "decision": "promote" if eligible else "block",
382 + "decision": decision,
383 "reasons": reasons,
384 },
385 + "preservation": {
386 + "preserve_existing": preserve_existing,
387 + "preserved_summary_path": args.published_summary.as_posix() if preserve_existing else None,
388 + "rejected_candidate_path": args.summary.as_posix() if not eligible and candidate_exists else None,
389 + "reasons": reasons if preserve_existing else [],
390 + },
391 }
392
393 args.output.parent.mkdir(parents=True, exist_ok=True)
394 args.output.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8")
395 print(f"Publish manifest decision={manifest['promotion']['decision']} path={args.output}")
396 + if preserve_existing:
397 + print(f"Preserving published summary: {args.published_summary}")
398 + if candidate_exists:
399 + print(f"Rejected candidate summary: {args.summary}")
400 if reasons:
401 for reason in reasons:
402 print(f"- {reason}")
tests/test_copilot_failure.py
+35
@@ -87,6 +87,41 @@ def test_main_creates_or_updates_issue_for_token_failure(tmp_path: Path) -> None
87 issue_mock.assert_called_once()
88
89
90 +def test_main_creates_or_updates_issue_for_copilot_inaccessible(tmp_path: Path) -> None:
91 + log_path = tmp_path / "copilot.log"
92 + report_path = tmp_path / "report.json"
93 + log_path.write_text("copilot is not available: command not found", encoding="utf-8")
94 +
95 + with mock.patch.object(
96 + copilot_failure,
97 + "create_or_update_token_issue",
98 + return_value="https://github.com/jmservera/SquadScope/issues/123",
99 + ) as issue_mock:
100 + exit_code = copilot_failure.main(
101 + [
102 + "--log",
103 + str(log_path),
104 + "--exit-code",
105 + "127",
106 + "--report-json",
107 + str(report_path),
108 + "--create-token-issue",
109 + "--repo",
110 + "jmservera/SquadScope",
111 + "--week",
112 + "2026-W23",
113 + "--run-id",
114 + "27055543722",
115 + ]
116 + )
117 +
118 + payload = json.loads(report_path.read_text(encoding="utf-8"))
119 + assert exit_code == 0
120 + assert payload["failure_class"] == "copilot_inaccessible"
121 + assert payload["issue"] == "https://github.com/jmservera/SquadScope/issues/123"
122 + issue_mock.assert_called_once()
123 +
124 +
125 def test_create_or_update_token_issue_returns_consistent_url_for_existing_issue() -> None:
126 report = copilot_failure.classify_log("invalid token")
127 responses = [
tests/test_pipeline.py
+8 -3
@@ -248,12 +248,16 @@ class WorkflowConfigTests(unittest.TestCase):
248 self.assertIn("python3 scripts/copilot_failure.py", run_analysis)
249 self.assertIn("--create-token-issue", run_analysis)
250 self.assertIn('FINAL_FAILURE_CLASS=""', run_analysis)
251 + self.assertIn('if [ "$FAILURE_CLASS" = "copilot_token_failure" ] || [ "$FAILURE_CLASS" = "copilot_inaccessible" ]; then', run_analysis)
252 + self.assertIn("failing without no-AI fallback", run_analysis)
253 + self.assertIn('echo "copilot is not available: command not found" > "$COPILOT_LOG"', run_analysis)
254 + self.assertIn("--exit-code 127", run_analysis)
255 self.assertIn("No publishable Copilot summary was produced", run_analysis)
252 - self.assertIn("no GitHub Models/OpenAI fallback", run_analysis)
256 + self.assertIn("current published article can be preserved", run_analysis)
257 self.assertIn("python3 scripts/analyze_fallback.py", run_analysis)
258 self.assertIn('--press-context "$PRESS_FILE"', run_analysis)
259 self.assertIn("--no-ai", run_analysis)
256 - self.assertIn("exit 1", run_analysis)
260 + self.assertIn('ANALYSIS_SOURCE="no-ai"', run_analysis)
261 self.assertNotIn('ANALYSIS_SOURCE="github-models"', run_analysis)
262 self.assertNotIn("falling back to GitHub Models API", run_analysis)
263
@@ -381,7 +385,8 @@ class WorkflowConfigTests(unittest.TestCase):
385 self.assertIn("scripts/publish_manifest.py create", manifest_run)
386 self.assertIn("--analysis-source", manifest_run)
387 self.assertIn("--analysis-model", manifest_run)
384 - self.assertIn("--validation-status passed", manifest_run)
388 + self.assertIn('git checkout origin/publish -- "$PUBLISHED_SUMMARY"', manifest_run)
389 + self.assertIn('--validation-status "$VALIDATION_STATUS"', manifest_run)
390
391 assert_step = next((s for s in analyze["steps"] if s.get("name") == "Assert candidate is eligible for promotion"), None)
392 self.assertIsNotNone(assert_step)
tests/test_publish_manifest.py
+161 -2
@@ -38,7 +38,32 @@ def write_raw(
38
39 def write_summary(path: Path) -> None:
40 path.parent.mkdir(parents=True, exist_ok=True)
41 - path.write_text("---\nweek: 2026-W21\n---\n\nbody\n", encoding="utf-8")
41 + path.write_text('---\nweek: "2026-W21"\nquality_score: 75\n---\n\nbody\n', encoding="utf-8")
42 +
43 +
44 +def write_good_summary(path: Path, *, quality_score: int = 90) -> None:
45 + path.parent.mkdir(parents=True, exist_ok=True)
46 + path.write_text(
47 + f"""---
48 +title: "Good AI Article"
49 +date: {CURRENT_DATETIME}
50 +week: "{WEEK}"
51 +year: 2026
52 +tags: [ai]
53 +categories: [weekly]
54 +repos_featured: 1
55 +stars_tracked: 100
56 +top_repo: "owner/good"
57 +quality_score: {quality_score}
58 +summary: "A good AI-authored weekly summary."
59 +---
60 +
61 +## This Week's Trends
62 +
63 +Canonical good analysis.
64 +""",
65 + encoding="utf-8",
66 + )
67
68
69 class PublishManifestTests(unittest.TestCase):
@@ -126,19 +151,61 @@ class PublishManifestTests(unittest.TestCase):
151
152 payload = json.loads(manifest.read_text(encoding="utf-8"))
153 self.assertFalse(payload["promotion"]["eligible"])
154 + self.assertEqual(payload["promotion"]["decision"], "block")
155 self.assertIn("analysis source is not AI-publishable", payload["promotion"]["reasons"][0])
156 with self.assertRaises(SystemExit):
157 publish_manifest.main(["assert-eligible", "--manifest", str(manifest)])
158
159 + def test_missing_candidate_summary_only_reports_missing_summary(self) -> None:
160 + tests_root = Path(__file__).resolve().parent
161 + with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
162 + base = Path(tmpdir)
163 + raw = base / "data/raw/2026-W21.json"
164 + summary = base / "data/candidates/2026-W21/123456/2026-W21-summary.md"
165 + manifest = base / "data/candidates/2026-W21/123456/publish-manifest.json"
166 + write_raw(raw)
167 +
168 + publish_manifest.main(
169 + [
170 + "create",
171 + "--week",
172 + WEEK,
173 + "--run-id",
174 + RUN_ID,
175 + "--current-datetime",
176 + CURRENT_DATETIME,
177 + "--summary",
178 + str(summary),
179 + "--published-summary",
180 + str(base / "data/analyzed/2026-W21-summary.md"),
181 + "--raw-json",
182 + str(raw),
183 + "--analysis-source",
184 + "copilot-cli",
185 + "--analysis-model",
186 + "copilot-default",
187 + "--validation-status",
188 + "passed",
189 + "--output",
190 + str(manifest),
191 + ]
192 + )
193 +
194 + reasons = json.loads(manifest.read_text(encoding="utf-8"))["promotion"]["reasons"]
195 + self.assertTrue(any(reason.startswith("candidate summary missing:") for reason in reasons))
196 + self.assertFalse(any("quality_score" in reason for reason in reasons))
197 +
198 def test_stale_source_artifact_blocks_promotion(self) -> None:
199 tests_root = Path(__file__).resolve().parent
200 with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
201 base = Path(tmpdir)
202 raw = base / "data/raw/2026-W21.json"
203 summary = base / "data/candidates/2026-W21/123456/2026-W21-summary.md"
204 + published = base / "data/analyzed/2026-W21-summary.md"
205 manifest = base / "data/candidates/2026-W21/123456/publish-manifest.json"
206 write_raw(raw, crawled_at="2026-05-11T08:00:00Z")
207 write_summary(summary)
208 + write_good_summary(published)
209
210 publish_manifest.main(
211 [
@@ -152,7 +219,7 @@ class PublishManifestTests(unittest.TestCase):
219 "--summary",
220 str(summary),
221 "--published-summary",
155 - str(base / "data/analyzed/2026-W21-summary.md"),
222 + str(published),
223 "--raw-json",
224 str(raw),
225 "--analysis-source",
@@ -168,9 +235,101 @@ class PublishManifestTests(unittest.TestCase):
235
236 payload = json.loads(manifest.read_text(encoding="utf-8"))
237 self.assertFalse(payload["promotion"]["eligible"])
238 + self.assertEqual(payload["promotion"]["decision"], "preserve")
239 + self.assertTrue(payload["preservation"]["preserve_existing"])
240 self.assertEqual(payload["source_artifacts"][0]["freshness"]["status"], "stale")
241 self.assertTrue(any("timestamp week mismatch" in reason for reason in payload["promotion"]["reasons"]))
242
243 + def test_no_ai_candidate_preserves_existing_good_summary(self) -> None:
244 + tests_root = Path(__file__).resolve().parent
245 + with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
246 + base = Path(tmpdir)
247 + raw = base / "data/raw/2026-W21.json"
248 + summary = base / "data/candidates/2026-W21/123456/2026-W21-summary.md"
249 + published = base / "data/analyzed/2026-W21-summary.md"
250 + manifest = base / "data/candidates/2026-W21/123456/publish-manifest.json"
251 + write_raw(raw)
252 + write_summary(summary)
253 + write_good_summary(published)
254 +
255 + publish_manifest.main(
256 + [
257 + "create",
258 + "--week",
259 + WEEK,
260 + "--run-id",
261 + RUN_ID,
262 + "--current-datetime",
263 + CURRENT_DATETIME,
264 + "--summary",
265 + str(summary),
266 + "--published-summary",
267 + str(published),
268 + "--raw-json",
269 + str(raw),
270 + "--analysis-source",
271 + "no-ai",
272 + "--analysis-model",
273 + "none",
274 + "--validation-status",
275 + "passed",
276 + "--output",
277 + str(manifest),
278 + ]
279 + )
280 +
281 + payload = json.loads(manifest.read_text(encoding="utf-8"))
282 + self.assertFalse(payload["promotion"]["eligible"])
283 + self.assertEqual(payload["promotion"]["decision"], "preserve")
284 + self.assertTrue(payload["published"]["good"])
285 + self.assertTrue(payload["preservation"]["preserve_existing"])
286 + self.assertEqual(payload["preservation"]["preserved_summary_path"], published.as_posix())
287 + self.assertEqual(payload["preservation"]["rejected_candidate_path"], summary.as_posix())
288 +
289 + def test_lower_quality_candidate_preserves_existing_good_summary(self) -> None:
290 + tests_root = Path(__file__).resolve().parent
291 + with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
292 + base = Path(tmpdir)
293 + raw = base / "data/raw/2026-W21.json"
294 + summary = base / "data/candidates/2026-W21/123456/2026-W21-summary.md"
295 + published = base / "data/analyzed/2026-W21-summary.md"
296 + manifest = base / "data/candidates/2026-W21/123456/publish-manifest.json"
297 + write_raw(raw)
298 + write_good_summary(summary, quality_score=70)
299 + write_good_summary(published, quality_score=90)
300 +
301 + publish_manifest.main(
302 + [
303 + "create",
304 + "--week",
305 + WEEK,
306 + "--run-id",
307 + RUN_ID,
308 + "--current-datetime",
309 + CURRENT_DATETIME,
310 + "--summary",
311 + str(summary),
312 + "--published-summary",
313 + str(published),
314 + "--raw-json",
315 + str(raw),
316 + "--analysis-source",
317 + "copilot-cli",
318 + "--analysis-model",
319 + "copilot-default",
320 + "--validation-status",
321 + "passed",
322 + "--output",
323 + str(manifest),
324 + ]
325 + )
326 +
327 + payload = json.loads(manifest.read_text(encoding="utf-8"))
328 + self.assertEqual(payload["promotion"]["decision"], "preserve")
329 + self.assertTrue(
330 + any("lower than published good quality_score" in reason for reason in payload["promotion"]["reasons"])
331 + )
332 +
333 def test_structured_same_day_reuse_metadata_remains_machine_readable(self) -> None:
334 tests_root = Path(__file__).resolve().parent
335 with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir: