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: