完整差異
@@ -19,6 +19,7 @@ on:\n - "pipeline/dedupe.py"\n - "pipeline/fulltext.py"\n - "pipeline/draft_recovery.py"\n+ - "pipeline/news_retry.py"\n - "pipeline/manual_draft.py"\n - "pipeline/airport_codes.py"\n - "config/airport_iata_aliases.json"\n@@ -62,6 +63,8 @@ jobs:\n python tests/test_provider_health.py\n python tests/test_build_resilience.py\n python tests/test_news_freshness.py\n+ python tests/test_news_retry.py\n+ python tests/test_news_chronology.py\n \n update:\n if: github.event_name != 'pull_request'\n@@ -91,7 +94,10 @@ jobs:\n run: python tests/test_airport_codes.py\n \n - name: Test news freshness invariants\n- run: python tests/test_news_freshness.py\n+ run: |\n+ python tests/test_news_freshness.py\n+ python tests/test_news_retry.py\n+ python tests/test_news_chronology.py\n \n - name: Test provider cooldown persistence\n run: python tests/test_provider_health.py\n@@ -1219,34 +1219,32 @@ def side(own, other):\n selected_image = None\n image = normalize_image(selected_image, titles=[\n (raw.get(lang) or {}).get("title") for lang in ("zh", "en")])\n- # Display the SOURCE's newest publication time when the write stage\n- # recorded one. Chronological feeds use this same timestamp so a late\n- # generated article cannot place an older source above newer news.\n+ # Feeds use first site publication; source chronology stays separate.\n display_dt = publication_dt\n+ source_dt = None\n if raw.get("sourcePublishedUtc"):\n try:\n- display_dt = parse_iso(str(raw["sourcePublishedUtc"]))\n+ source_dt = parse_iso(str(raw["sourcePublishedUtc"]))\n except (ValueError, TypeError):\n pass\n tpe = display_dt.astimezone(TPE)\n publication_tpe = publication_dt.astimezone(TPE)\n modified_tpe = modified_dt.astimezone(TPE)\n late_ingest = bool(\n- raw.get("sourcePublishedUtc")\n- and publication_dt - display_dt >= timedelta(hours=24)\n+ source_dt and publication_dt - source_dt >= timedelta(hours=24)\n )\n today_tpe = now_utc().astimezone(TPE).date()\n # Date-only source stamps (FAA/CAA give no clock time -> 00:00Z) must\n # not render as a precise-looking "08:00" TPE: show the date instead.\n- date_only = (raw.get("sourcePublishedUtc")\n- and display_dt.hour == 0 and display_dt.minute == 0)\n- if date_only:\n- time_label = tpe.strftime("%m/%d")\n- meta_ts = tpe.strftime("%Y-%m-%d")\n- else:\n- time_label = (clock_12(tpe) if tpe.date() == today_tpe\n- else tpe.strftime("%m/%d"))\n- meta_ts = f"{tpe:%Y-%m-%d} {clock_12(tpe)} UTC+8"\n+ source_meta_ts = None\n+ if source_dt:\n+ source_tpe = source_dt.astimezone(TPE)\n+ source_meta_ts = (source_tpe.strftime("%Y-%m-%d")\n+ if source_dt.hour == 0 and source_dt.minute == 0 else\n+ f"{source_tpe:%Y-%m-%d} {clock_12(source_tpe)} UTC+8")\n+ time_label = (clock_12(tpe) if tpe.date() == today_tpe\n+ else tpe.strftime("%m/%d"))\n+ meta_ts = f"{tpe:%Y-%m-%d} {clock_12(tpe)} UTC+8"\n return {\n "id": art_id,\n "dt": dt,\n@@ -1266,6 +1264,7 @@ def side(own, other):\n "source": str(raw.get("primarySource") or (sources[0]["name"] if sources else "—")),\n "time": time_label,\n "meta_ts": meta_ts,\n+ "source_meta_ts": source_meta_ts,\n "zh": side(zh, en),\n "en": side(en, zh),\n "sources": sources,\n@@ -1448,6 +1447,7 @@ def art_view(a, lang: str):\n id=a["id"], cat=a["cat"], tag_class=a["tag_class"], image=a["image"],\n cat_label=_bi(CATS.get(a["cat"]), lang, a["cat"]),\n source=a["source"], time=a["time"], meta_ts=a["meta_ts"],\n+ source_meta_ts=a["source_meta_ts"],\n sources=a["sources"], url=page_url(lang, f"news/{a['id']}/"),\n writer_model=a["writer_model"],\n article_format=a["article_format"],\n@@ -11,6 +11,7 @@\n from __future__ import annotations\n \n import re\n+from news_retry import RetryLedger\n from datetime import datetime, timedelta, timezone\n from difflib import SequenceMatcher\n \n@@ -425,7 +426,16 @@ def main() -> None:\n reverse=True,\n )\n major_groups = sum(1 for entry in ranked if entry[1])\n- selected = ranked[:MAX_GROUPS]\n+ retry_ledger = RetryLedger(now)\n+ # Schedule before the discovery cap so cooling or exhausted major\n+ # stories cannot displace fresh news every hour.\n+ candidates = [{"items": entry[5], "rank": entry} for entry in ranked]\n+ eligible, deferred = retry_ledger.plan(candidates, MAX_GROUPS)\n+ selected = [g["rank"] for g in eligible]\n+ # Keep cooling rows visible in pending, within the existing queue cap.\n+ selected += [g["rank"] for g in deferred\n+ if not retry_ledger.exhausted(g)][:MAX_GROUPS - len(selected)]\n+ retry_ledger.save()\n selected_material_groups = sum(1 for entry in selected if entry[2])\n selected_major_groups = sum(1 for entry in selected if entry[1])\n \n@@ -0,0 +1,117 @@\n+"""Persistent, source-keyed news retry budgets across hourly runs.\n+\n+No prompts, generated copy or exception text are persisted. Exhausted rows\n+are the failure register; a genuinely new source URL reopens a story.\n+"""\n+from __future__ import annotations\n+\n+import hashlib\n+from datetime import timedelta\n+\n+from common import DATA_DIR, iso_minute, load_json, norm_url, parse_iso, save_json\n+\n+MAX_RETRIES_PER_RUN = 2\n+RETRY_HOURS = (2, 6, 24)\n+RETENTION_DAYS = 30\n+\n+\n+def source_urls(group):\n+ return sorted({norm_url(str(item["url"]))\n+ for item in group.get("items", [])\n+ if isinstance(item, dict) and item.get("url")})\n+\n+\n+class RetryLedger:\n+ def __init__(self, now):\n+ self.now = now\n+ self.path = DATA_DIR / "news-retry.json"\n+ payload = load_json(self.path, {})\n+ rows = payload.get("stories", {}) if isinstance(payload, dict) else {}\n+ self.rows = {}\n+ for key, row in rows.items() if isinstance(rows, dict) else []:\n+ if not isinstance(row, dict) or not isinstance(row.get("sourceUrls"), list):\n+ continue\n+ try:\n+ updated = parse_iso(str(row.get("updatedUtc")))\n+ for field in ("contentFailures", "serviceFailures"):\n+ if not isinstance(row.get(field), int) or row[field] < 0:\n+ raise ValueError("invalid failure counter")\n+ if row.get("nextRetryUtc"):\n+ parse_iso(str(row["nextRetryUtc"]))\n+ except (ValueError, TypeError):\n+ continue\n+ if updated >= now - timedelta(days=RETENTION_DAYS):\n+ self.rows[key] = row\n+\n+ def save(self):\n+ save_json(self.path, {"schemaVersion": 1, "stories": self.rows})\n+\n+ def state(self, group):\n+ urls = set(source_urls(group))\n+ matches = [(key, row) for key, row in self.rows.items()\n+ if urls.intersection(row["sourceUrls"])]\n+ if not matches:\n+ return None, None\n+ # Joining previously separate groups cannot bypass either retry cap.\n+ key, row = max(matches, key=lambda entry: (\n+ entry[1].get("status") == "exhausted", entry[1]["contentFailures"]))\n+ known = set().union(*(set(r["sourceUrls"]) for _, r in matches))\n+ if urls - known:\n+ history = list(row.get("history") or [])[-4:]\n+ history.append({k: row.get(k) for k in (\n+ "status", "contentFailures", "serviceFailures", "updatedUtc")})\n+ row = {"sourceUrls": sorted(known | urls), "contentFailures": 0,\n+ "serviceFailures": 0, "status": "ready", "history": history,\n+ "updatedUtc": iso_minute(self.now), "nextRetryUtc": None}\n+ for old_key, _ in matches:\n+ self.rows.pop(old_key, None)\n+ self.rows[key] = row\n+ return key, row\n+\n+ def plan(self, groups, limit):\n+ fresh, retries, deferred = [], [], []\n+ for group in groups:\n+ _, row = self.state(group)\n+ if row is None or not (row["contentFailures"] or row["serviceFailures"]):\n+ fresh.append(group)\n+ elif row.get("status") == "exhausted" or (\n+ row.get("nextRetryUtc") and\n+ parse_iso(row["nextRetryUtc"]) > self.now):\n+ deferred.append(group)\n+ else:\n+ retries.append(group)\n+ retry_count = min(MAX_RETRIES_PER_RUN, len(retries), limit)\n+ chosen = fresh[:limit - retry_count] + retries[:retry_count]\n+ # Fill unused capacity with fresh news, never extra old retries.\n+ deferred += fresh[limit - retry_count:] + retries[retry_count:]\n+ return chosen, deferred\n+\n+ def exhausted(self, group):\n+ _, row = self.state(group)\n+ return bool(row and row.get("status") == "exhausted")\n+\n+ def failure(self, group, *, content):\n+ key, row = self.state(group)\n+ urls = source_urls(group)\n+ if not urls:\n+ return # a title alone is not a stable retry identity\n+ if row is None:\n+ key = hashlib.sha256(urls[0].encode()).hexdigest()[:24]\n+ row = {"sourceUrls": urls, "contentFailures": 0,\n+ "serviceFailures": 0, "history": []}\n+ row["sourceUrls"] = sorted(set(row["sourceUrls"]) | set(urls))\n+ field = "contentFailures" if content else "serviceFailures"\n+ row[field] += 1\n+ row["updatedUtc"] = iso_minute(self.now)\n+ row["failureClass"] = "content_validation" if content else "provider_failure"\n+ row["status"] = "exhausted" if row["contentFailures"] > len(RETRY_HOURS) else "waiting"\n+ row["nextRetryUtc"] = (None if row["status"] == "exhausted" else\n+ iso_minute(self.now + timedelta(hours=RETRY_HOURS[min(row[field] - 1, 2)])))\n+ row["title"] = str((group.get("items") or [{}])[0].get("title") or "")[:300]\n+ self.rows[key] = row\n+\n+ def complete(self, group):\n+ urls = set(source_urls(group))\n+ for key, row in list(self.rows.items()):\n+ if urls.intersection(row["sourceUrls"]):\n+ self.rows.pop(key)\n@@ -38,6 +38,7 @@\n from pathlib import Path\n \n from opencc import OpenCC\n+import news_retry\n from draft_recovery import (\n normalize_reader_copy, normalize_fact_ids, repair_prompt, draft_timeout, ProviderCallTimeout,\n )\n@@ -2011,9 +2012,7 @@ def build_article(draft: dict, group: dict, now, used_ids: set, writer=None,\n except (TypeError, ValueError):\n continue\n if source_dates:\n- # The source's own newest publication time - the site displays this,\n- # never the generation time, so multi-day-old releases are not\n- # presented as breaking news.\n+ # Preserve source chronology separately from first site publication.\n article["sourcePublishedUtc"] = iso_minute(max(source_dates))\n elif existing_article and existing_article.get("sourcePublishedUtc"):\n article["sourcePublishedUtc"] = existing_article["sourcePublishedUtc"]\n@@ -2657,6 +2656,8 @@ def main() -> None:\n rejected_groups.append(group)\n rejected_ids.add(group_id)\n groups = scoped_groups\n+ retry_ledger = news_retry.RetryLedger(now)\n+ groups, deferred_groups = retry_ledger.plan(groups, MAX_GROUPS_PER_RUN)\n \n # --- 1. retire and migrate the former manual-review queue ------------\n # Each row is re-checked with the same deterministic source, binding,\n@@ -2826,6 +2827,8 @@ def main() -> None:\n draft, writer, verified = None, None, None\n final_model = None\n stopped_by_refusal = False\n+ content_failed = False\n+ calls_before = sum(ai_calls.values())\n fallback_repair_used = False\n group_deadline = min(draft_deadline,\n time.monotonic() + MAX_DRAFT_SECONDS_PER_GROUP)\n@@ -2846,6 +2849,7 @@ def main() -> None:\n run_trace,\n deadline=group_deadline)\n except DraftInvalid:\n+ content_failed = True\n if provider is not providers[0]:\n fallback_repair_used = True\n _record_provider_response(\n@@ -2897,6 +2901,7 @@ def main() -> None:\n # Genuine refusal / safety block: content-based, so\n # don't shop the group around to a laxer provider.\n stopped_by_refusal = True\n+ content_failed = True\n final_model = provider.label\n break\n final_model = provider.label\n@@ -2918,7 +2923,9 @@ def main() -> None:\n if group.get("id") in rejected_ids:\n continue\n if draft is None:\n- skipped += 1 # provider failures/refusals retry next hour\n+ skipped += 1\n+ if sum(ai_calls.values()) > calls_before:\n+ retry_ledger.failure(group, content=content_failed)\n run_trace.finish(\n "refused" if stopped_by_refusal else "retry_pending",\n "refusal" if stopped_by_refusal else "retry_pending",\n@@ -2933,6 +2940,7 @@ def main() -> None:\n run_trace.add_duration("articleBuild", article_started)\n if article is None:\n skipped += 1\n+ retry_ledger.failure(group, content=True)\n run_trace.finish(\n "retry_pending", "article_build_failed", writer.label)\n continue\n@@ -2989,15 +2997,17 @@ def main() -> None:\n # re-emit from dedupe and burn another API call next hour.\n update_seen(published_groups + rejected_groups, now)\n consumed = published_ids | rejected_ids\n- if consumed:\n- # fulltext lives in data/fulltext.json (cache), never in the\n- # committed pending file - next run re-attaches it for free\n- remaining = [_strip_fulltext(g) for g in groups\n- if g.get("id") not in consumed]\n- save_json(PENDING_PATH, {**pending, "groups": remaining})\n+ for group in published_groups + rejected_groups:\n+ retry_ledger.complete(group)\n+ # Exhausted stories remain only in the failure register. They are not\n+ # marked seen: a genuinely new source can reopen them at discovery.\n+ remaining = [_strip_fulltext(g) for g in groups + deferred_groups\n+ if g.get("id") not in consumed and not retry_ledger.exhausted(g)]\n+ save_json(PENDING_PATH, {**pending, "groups": remaining})\n+ retry_ledger.save()\n \n refresh_stats(now)\n- remaining_count = sum(1 for g in groups if g.get("id") not in consumed)\n+ remaining_count = len(remaining)\n stalled = bool(ai_calls and remaining_count and not new_articles\n and not updated_articles)\n stats = load_json(STATS_PATH, {})\n@@ -3006,6 +3016,8 @@ def main() -> None:\n "updatedUtc": iso_minute(now),\n "published": len(new_articles), "revised": len(updated_articles),\n "pending": remaining_count,\n+ "retryExhausted": sum(row.get("status") == "exhausted"\n+ for row in retry_ledger.rows.values()),\n }\n save_json(STATS_PATH, stats)\n if stalled:\n@@ -20,7 +20,7 @@ <h1 class="article-title">{{ a.title }}</h1>\n <div class="article-meta text-muted">\n <time datetime="{{ a.published_iso }}" style="font-weight:800;color:var(--color-accent);">{{ t.publishedLabel }}{{ t.metaColon }}{{ a.published_meta_ts }}</time>\n {% if a.modified_iso != a.published_iso %}<time datetime="{{ a.modified_iso }}">{{ t.modifiedLabel }}{{ t.metaColon }}{{ a.modified_meta_ts }}</time>{% endif %}\n- <span>{{ t.sourceTimeLabel }}{{ t.metaColon }}{{ a.meta_ts }}</span>\n+ {% if a.source_meta_ts %}<span>{{ t.sourceTimeLabel }}{{ t.metaColon }}{{ a.source_meta_ts }}</span>{% endif %}\n <span>{{ t.articleBylineLabel }}{{ t.metaColon }}<a href="{{ about_url }}">{{ t.articleByline }}</a></span>\n <span>{{ t.autoCompiled }} · {{ t.mainSource }}: {{ a.source }}</span>\n <span style="margin-left:auto;">{{ t.refreshNote }}</span>\n@@ -284,6 +284,18 @@ def test_group_cap_and_recency_order() -> None:\n # Single-source groups rank purely by newest publishedUtc, so the\n # 12 newest stories are emitted in recency order.\n assert [g["items"][0]["title"] for g in groups] == titles[:12]\n+ # Suppression happens before MAX_GROUPS: blocked stories cannot\n+ # monopolize discovery or be reset by regenerated group IDs.\n+ ledger = {"schemaVersion": 1, "stories": {\n+ str(i): {"sourceUrls": [items[i]["url"]], "contentFailures": 4,\n+ "serviceFailures": 0, "status": "exhausted",\n+ "updatedUtc": _stamp(hours=0), "nextRetryUtc": None}\n+ for i in range(12)\n+ }}\n+ (data_dir / "news-retry.json").write_text(json.dumps(ledger), encoding="utf-8")\n+ _run_dedupe(data_dir)\n+ remaining = _load_pending(data_dir)["groups"]\n+ assert [g["items"][0]["title"] for g in remaining] == titles[12:]\n \n \n def test_taiwan_airline_story_is_reserved_ahead_of_general_cap() -> None:\n@@ -38,15 +38,18 @@ def article(id, published, source=None, sort=None):\n with patch.object(build, "ARTICLES_DIR", folder):\n collected = build.collect_articles()\n assert [a["id"] for a in collected] == [\n- "newer-manual", "no-source-time", "older-late-generated",\n- "invalid-source-time", "date-only-source",\n+ "date-only-source", "older-late-generated", "newer-manual",\n+ "no-source-time", "invalid-source-time",\n ]\n- assert collected[0]["meta_ts"] == "2026-09-30 4:50 PM UTC+8"\n- assert collected[-1]["meta_ts"] == "2026-09-26"\n+ assert collected[0]["meta_ts"] == "2026-10-01 11:00 AM UTC+8"\n+ assert collected[0]["source_meta_ts"] == "2026-09-26"\n+ assert collected[-1]["source_meta_ts"] is None\n for prepared in collected:\n indexed = build.search_index_item(prepared, [])\n assert build.parse_iso(indexed["published"]) == prepared["news_dt"]\n- assert collected[0]["published_iso"] == "2026-09-30T08:50:00Z"\n+ assert collected[2]["published_iso"] == "2026-09-30T08:50:00Z"\n+ revised = rows[0] | {"updatedUtc": "2026-10-02T08:00:00Z"}\n+ assert build.prep_article(revised)["news_dt"] == collected[1]["news_dt"]\n \n \n if __name__ == "__main__":\n@@ -41,13 +41,14 @@ def main() -> None:\n assert source["type"] == "rss"\n assert "news.google.com/rss/search" in source["endpoint"]\n \n- # Source publication time drives reader-facing chronology.\n+ # First site publication drives reader-facing chronology.\n late = build.prep_article(_article(\n published="2026-10-01T12:00Z",\n source_published="2026-09-29T10:00Z",\n ))\n assert late is not None\n- assert late["news_dt"].isoformat().startswith("2026-09-29T10:00")\n+ assert late["news_dt"].isoformat().startswith("2026-10-01T12:00")\n+ assert late["source_meta_ts"].startswith("2026-09-29")\n assert late["published_dt"].isoformat().startswith("2026-10-01T12:00")\n assert late["late_ingest"] is True\n \n@@ -0,0 +1,66 @@\n+"""Persistent story retry budgets without API calls or production writes."""\n+import sys\n+import tempfile\n+from datetime import datetime, timedelta, timezone\n+from pathlib import Path\n+from unittest.mock import patch\n+\n+sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "pipeline"))\n+import news_retry\n+\n+\n+def story(n, extra=False):\n+ urls = [f"https://example.com/news/{n}"]\n+ if extra:\n+ urls.append(f"https://official.example.com/news/{n}")\n+ return {"id": str(n), "items": [{"url": u, "title": "Test news"} for u in urls]}\n+\n+\n+def main():\n+ now = datetime(2026, 10, 1, tzinfo=timezone.utc)\n+ with tempfile.TemporaryDirectory() as tmp, patch.object(news_retry, "DATA_DIR", Path(tmp)):\n+ ledger = news_retry.RetryLedger(now)\n+ group = story(1)\n+ for failure, hours in enumerate((2, 6, 24), 1):\n+ ledger.failure(group, content=True)\n+ ledger.save()\n+ ledger = news_retry.RetryLedger(now)\n+ assert ledger.state(group)[1]["contentFailures"] == failure\n+ chosen, deferred = ledger.plan([group | {"id": "regenerated"}], 10)\n+ assert not chosen and len(deferred) == 1\n+ now += timedelta(hours=hours)\n+ ledger = news_retry.RetryLedger(now)\n+ assert ledger.plan([group], 10)[0] == [group]\n+ ledger.failure(group, content=True)\n+ ledger.save()\n+ ledger = news_retry.RetryLedger(now + timedelta(days=1))\n+ assert ledger.exhausted(group)\n+ assert not ledger.plan([group], 10)[0]\n+ renewed = story(1, extra=True)\n+ assert ledger.plan([renewed], 10)[0] == [renewed]\n+ assert ledger.state(renewed)[1]["contentFailures"] == 0\n+ assert not ledger.exhausted(group)\n+ ledger.failure(renewed, content=True)\n+ ledger.complete(renewed)\n+ assert not ledger.rows\n+\n+ for n in range(5):\n+ ledger.failure(story(n), content=False)\n+ ledger.now += timedelta(hours=2)\n+ fresh = [story(n) for n in range(10, 22)]\n+ chosen, deferred = ledger.plan([story(n) for n in range(5)] + fresh, 10)\n+ assert chosen[:8] == fresh[:8]\n+ assert len(chosen) == 10 and len(deferred) == 7\n+ assert all(r["contentFailures"] == 0 for r in ledger.rows.values())\n+ for _ in range(5):\n+ ledger.failure(story(0), content=False)\n+ assert not ledger.exhausted(story(0))\n+ assert ledger.state(story(0))[1]["nextRetryUtc"] == news_retry.iso_minute(\n+ ledger.now + timedelta(hours=24))\n+ ledger.save()\n+ assert not news_retry.RetryLedger(ledger.now + timedelta(days=31)).rows\n+ print("PASS retry persistence, 2/6/24h backoff, cap, new sources and provider separation")\n+\n+\n+if __name__ == "__main__":\n+ main()\n@@ -1145,8 +1145,45 @@ def draft(self, *args):\n "timed-out story is not marked consumed")\n \n \n+def test_persistent_story_retry_budget():\n+ reset_data_dir()\n+ pending = load(DATA / "pending.json")\n+ group = pending["groups"][0]\n+ pending["groups"] = [group]\n+ common.save_json(DATA / "pending.json", pending)\n+ solo = FakeProvider("gemini", [None])\n+ original_build, original_now = write.build_providers, write.now_utc\n+ clock = [NOW]\n+ write.build_providers = lambda: [solo]\n+ write.now_utc = lambda: clock[0]\n+ try:\n+ for hours in (2, 6, 24):\n+ write.main()\n+ attempted = solo.calls\n+ write.main()\n+ check(solo.calls == attempted, "cooling story spends zero model calls")\n+ clock[0] += timedelta(hours=hours)\n+ write.main()\n+ check(solo.calls == 4, "initial failure plus three retries exhausts the budget")\n+ check(not load(DATA / "pending.json")["groups"],\n+ "exhausted story leaves pending and remains in failure register")\n+ check(next(iter(load(DATA / "news-retry.json")["stories"].values()))["status"]\n+ == "exhausted", "exhaustion persists across runs")\n+ pending["groups"] = [group | {"id": "rediscovered"}]\n+ common.save_json(DATA / "pending.json", pending)\n+ write.main()\n+ check(solo.calls == 4, "rediscovery with a new group ID cannot bypass the cap")\n+ group["items"].append(group["items"][0] | {"url": "https://official.example.com/new-source"})\n+ common.save_json(DATA / "pending.json", pending)\n+ write.main()\n+ check(solo.calls == 5, "a new source reopens the story")\n+ finally:\n+ write.build_providers, write.now_utc = original_build, original_now\n+\n+\n def main():\n tests = [test_flexible_publication_keeps_evidence_gates,\n+ test_persistent_story_retry_budget,\n test_story_budget_allows_later_groups,\n test_budget_saves_completed_articles_and_retains_unattempted_groups,\n test_publish_flow,