Full diff
@@ -658,6 +658,36 @@ def _headline_values(value):\n yield block["title"]\n \n \n+_GENERIC_AIRLINE_ENTITY_NAMES = {\n+ "air", "airline", "airlines", "aviation", "carrier", "operator",\n+}\n+\n+\n+def _headline_names_airline(headline: str, records) -> bool:\n+ """Whether a headline directly names a structured airline entity.\n+\n+ Some carrier brands, such as airBaltic, do not contain the standalone word\n+ ``airline``. Requiring the verified entity itself to appear in the title\n+ keeps this check title-scoped without mistaking a body-only partnership or\n+ background mention for the article's main transport subject.\n+ """\n+ for record in records:\n+ if not isinstance(record, dict):\n+ continue\n+ entities = record.get("entities")\n+ airlines = (entities.get("airlines")\n+ if isinstance(entities, dict) else None)\n+ for value in airlines if isinstance(airlines, list) else ():\n+ name = re.sub(r"\s+", " ", str(value or "")).strip()\n+ if len(name) < 3 or name.casefold() in _GENERIC_AIRLINE_ENTITY_NAMES:\n+ continue\n+ if re.search(\n+ rf"(?<![A-Za-z0-9]){re.escape(name)}(?![A-Za-z0-9])",\n+ headline, re.IGNORECASE):\n+ return True\n+ return False\n+\n+\n def is_transport_headline(*records) -> bool:\n """True when a story's headline itself names a transport subject.\n \n@@ -676,7 +706,8 @@ def is_transport_headline(*records) -> bool:\n return (is_transport_story({"title": headline})\n or any(term in headline for term in _TRANSPORT_HEADLINE_ZH_TERMS)\n or _TRANSPORT_HEADLINE_EN_RE.search(headline) is not None\n- or _AIRCRAFT_MODEL_RE.search(headline) is not None)\n+ or _AIRCRAFT_MODEL_RE.search(headline) is not None\n+ or _headline_names_airline(headline, records))\n \n \n _TAIWAN_AIRLINE_ZH_TERMS = TAIWAN_AIRLINE_KEYWORDS\n@@ -16,7 +16,15 @@\n from pathlib import Path\n \n sys.path.insert(0, str(Path(__file__).resolve().parent))\n-from common import DATA_DIR, iso_minute, load_json, now_utc, parse_iso, save_json # noqa: E402\n+from common import ( # noqa: E402\n+ DATA_DIR,\n+ is_transport_headline,\n+ iso_minute,\n+ load_json,\n+ now_utc,\n+ parse_iso,\n+ save_json,\n+)\n \n TPE = timezone(timedelta(hours=8), "UTC+8")\n OUTPUT_NAME = "search-prompts.json"\n@@ -86,6 +94,8 @@ def collect_candidates(articles_dir: Path, now: datetime) -> tuple[list[dict], s\n for raw in article_rows if isinstance(article_rows, list) else []:\n if not isinstance(raw, dict) or raw.get("archived") is True:\n continue\n+ if not is_transport_headline(raw):\n+ continue\n article_id = _text(raw.get("id"))\n published = _article_time(raw)\n if not article_id or published is None or published > now + timedelta(hours=6):\n@@ -4,9 +4,9 @@\n LLM call drafts the article, flash and optional incident row. Providers are\n tried in AVWIRE_PROVIDER_ORDER (opencode / anthropic / gemini / nvidia /\n openrouter; see providers.py): the first configured provider is the primary\n-writer and the\n-rest are fallbacks — a provider that hits an auth or quota error is disabled\n-for the rest of the run and the next one takes over.\n+writer and the rest are fallbacks. A provider that hits an auth/quota error or\n+repeated transport outage is disabled for the rest of the run and the next one\n+takes over.\n \n Each draft carries an editorial status: "publish" and "publish_brief" go\n straight to the site and "reject" is dropped. The former manual-review\n@@ -71,6 +71,7 @@\n )\n \n MAX_GROUPS_PER_RUN = 10\n+TRANSIENT_PLATFORM_FAILURE_LIMIT = 2\n MAX_INCIDENTS = 60\n SEEN_MAX_AGE_DAYS = 21\n SERIOUS_WINDOW_DAYS = 7\n@@ -1272,6 +1273,45 @@ def _problem_class(stage: str, problem: str) -> str:\n return "validation_failed"\n \n \n+def _is_transient_platform_failure(exc) -> bool:\n+ """Return whether a failure is likely shared by a provider platform.\n+\n+ Content/schema failures stay model- and story-specific. Network failures,\n+ repeated short-window rate limits and HTTP 5xx responses instead indicate\n+ that immediately trying the same platform for every remaining group is\n+ unlikely to help.\n+ """\n+ failure_class = classify_failure(exc)\n+ if failure_class in {"timeout", "connection", "rate_limit"}:\n+ return True\n+ return bool(re.search(r"\bHTTP\s+5\d\d\b", str(exc), re.IGNORECASE))\n+\n+\n+def _record_transient_platform_failure(provider, exc, alive: list,\n+ dead_platforms: set,\n+ failure_counts: dict) -> bool:\n+ """Trip a run-local circuit after repeated infrastructure failures."""\n+ name = provider.name\n+ if not _is_transient_platform_failure(exc):\n+ failure_counts.pop(name, None)\n+ return False\n+ count = failure_counts.get(name, 0) + 1\n+ failure_counts[name] = count\n+ if count < TRANSIENT_PLATFORM_FAILURE_LIMIT:\n+ return False\n+ alive[:] = [candidate for candidate in alive\n+ if candidate.name != name]\n+ dead_platforms.add(name)\n+ print(f"write: '{name}' hit {count} consecutive transient failures; "\n+ "disabling the platform for this run")\n+ return True\n+\n+\n+def _record_provider_response(provider, failure_counts: dict) -> None:\n+ """A completed response proves the platform circuit should recover."""\n+ failure_counts.pop(provider.name, None)\n+\n+\n def _normalize_reject_reason(candidate: dict) -> None:\n """Keep archive completeness from becoming the stated rejection cause."""\n reason = str(candidate.get("decisionReason") or "").strip()\n@@ -2237,6 +2277,7 @@ def main() -> None:\n rejected_groups, rejected_ids = [], set()\n dead_auth: set = set()\n dead_platforms: set = set()\n+ transient_failure_counts: dict = {}\n ai_calls: dict = {}\n skipped = 0\n \n@@ -2422,6 +2463,8 @@ def main() -> None:\n tries, ai_calls,\n run_trace)\n except DraftInvalid:\n+ _record_provider_response(\n+ provider, transient_failure_counts)\n continue # try the next provider in the chain\n except ProviderAuthError as exc:\n # Account-level failure: every model on this platform\n@@ -2432,6 +2475,7 @@ def main() -> None:\n alive[:] = [p for p in alive if p.name != provider.name]\n dead_auth.add(provider.name)\n dead_platforms.add(provider.name)\n+ transient_failure_counts.pop(provider.name, None)\n run_trace.disable_last_provider()\n continue\n except ProviderQuotaError as exc:\n@@ -2441,17 +2485,27 @@ def main() -> None:\n "for this run")\n alive[:] = [p for p in alive if p.name != provider.name]\n dead_platforms.add(provider.name)\n+ transient_failure_counts.pop(provider.name, None)\n run_trace.disable_last_provider()\n continue\n except ProviderError as exc:\n print(f"write: {provider.label} error on group "\n f"{group.get('id')}: {exc}; trying next provider")\n+ if _record_transient_platform_failure(\n+ provider, exc, alive, dead_platforms,\n+ transient_failure_counts):\n+ run_trace.disable_last_provider()\n continue\n except Exception as exc: # one bad group must not kill a run\n print(f"write: unexpected {provider.label} error on group"\n f" {group.get('id')}: {type(exc).__name__}: {exc}; "\n "trying next provider")\n+ if _record_transient_platform_failure(\n+ provider, exc, alive, dead_platforms,\n+ transient_failure_counts):\n+ run_trace.disable_last_provider()\n continue\n+ _record_provider_response(provider, transient_failure_counts)\n if candidate is None:\n # Genuine refusal / safety block: content-based, so\n # don't shop the group around to a laxer provider.\n@@ -75,6 +75,17 @@ def main() -> None:\n "summary": "中華航空參與聯盟,推動資源循環。",\n }\n assert not common.is_transport_headline(summary_only)\n+ branded_airline = {\n+ "zh": {"title": "airBaltic向美國法院申請第11章破產保護"},\n+ "en": {"title": "airBaltic Files for Chapter 11 Protection"},\n+ "entities": {"airlines": ["airBaltic"]},\n+ }\n+ assert common.is_transport_headline(branded_airline)\n+ assert not common.is_transport_headline({\n+ "title": "工研院成立循環示範聯盟",\n+ "summary": "中華航空參與聯盟。",\n+ "entities": {"airlines": ["中華航空"]},\n+ })\n assert not fetch._matches_keywords(\n summary_only, common.TAIWAN_TRANSPORT_KEYWORDS)\n assert fetch._matches_keywords(\n@@ -68,6 +68,32 @@ def main() -> None:\n assert len(candidates) == 8\n assert window == "today"\n \n+ branded_dir = data_dir / "branded" / "articles"\n+ branded_dir.mkdir(parents=True)\n+ branded = {\n+ "id": "a-20260810-0010-airbaltic-restructuring",\n+ "publishedUtc": "2026-08-10T00:10:00Z",\n+ "zh": {"title": "airBaltic向法院申請第11章重組",\n+ "summary": "航空公司啟動重組程序。"},\n+ "en": {"title": "airBaltic Files for Chapter 11 Restructuring",\n+ "summary": "The airline started restructuring."},\n+ "entities": {"airlines": ["airBaltic"]},\n+ }\n+ unrelated = {\n+ "id": "a-20260810-0011-unrelated-partnership",\n+ "publishedUtc": "2026-08-10T00:11:00Z",\n+ "title": "Research institute forms recycling partnership",\n+ "summary": "China Airlines joined as one of several partners.",\n+ "entities": {"airlines": ["China Airlines"]},\n+ }\n+ (branded_dir / "batch.json").write_text(\n+ json.dumps({"articles": [branded, unrelated]}, ensure_ascii=False),\n+ encoding="utf-8",\n+ )\n+ branded_candidates, _ = search_prompts.collect_candidates(\n+ branded_dir, now)\n+ assert [row["id"] for row in branded_candidates] == [branded["id"]]\n+\n provider = FakeProvider()\n changed = search_prompts.update_daily_prompts(\n data_dir=data_dir, now=now, providers=[provider],\n@@ -143,9 +143,10 @@ def main() -> None:\n assert "可追溯來源" in home\n assert "設計原型" not in home and "示意樣本" not in home\n assert "即時航班雷達" in home\n- assert "今日全球延誤" in home and "今日全球取消" in home\n- assert "來源未設定" in home\n- assert "FlightAware AeroAPI 尚未設定" in home\n+ assert "全球延誤與取消" in home and "查看即時統計" in home\n+ assert 'href="https://www.flightaware.com/live/cancelled/today/"' in home\n+ assert "今日全球延誤" not in home and "今日全球取消" not in home\n+ assert "來源未設定" not in home\n assert "全球延誤率" not in home and "生效中 NOTAM" not in home\n assert 'type="application/rss+xml"' in home\n \n@@ -709,7 +709,7 @@ def test_provider_failover():\n \n \n def test_model_chain_and_routing_policy():\n- """name:model order tokens; platform-level death; primary retry-once."""\n+ """Model order, run-local platform circuits and primary retry policy."""\n # --- order tokens: same platform may appear with different models -----\n saved = {k: os.environ.get(k) for k in (\n "AVWIRE_PROVIDER_ORDER", "NVIDIA_API_KEY", "GEMINI_API_KEY",\n@@ -759,6 +759,43 @@ def test_model_chain_and_routing_policy():\n check(len(articles) == 1 and articles[0]["writer"] == "gemini:fake",\n "cross-platform fallback wrote the publishable article")\n \n+ # --- repeated transport outages trip a run-local platform circuit -----\n+ reset_data_dir()\n+ gem_primary = FakeProvider(\n+ "gemini", [providers.ProviderError("HTTP 503")])\n+ gem_fallback = FakeProvider(\n+ "gemini", [providers.ProviderError("HTTP 503")])\n+ healthy = FakeProvider("anthropic", [DRAFT_SAFETY, DRAFT_BIZ, None])\n+\n+ original = write.build_providers\n+ write.build_providers = lambda: [gem_primary, gem_fallback, healthy]\n+ try:\n+ write.main()\n+ finally:\n+ write.build_providers = original\n+\n+ check(gem_primary.calls == 1 and gem_fallback.calls == 1,\n+ "two same-platform 503s stop repeated calls for later groups")\n+ articles = all_articles()\n+ check(len(articles) == 2\n+ and all(article["writer"] == "anthropic:fake"\n+ for article in articles),\n+ "healthy cross-platform fallback handles remaining groups")\n+ runs = load(DATA / "usage.json").get("recentRuns") or []\n+ disabled_attempts = [\n+ attempt for row in runs for attempt in row.get("attempts", [])\n+ if attempt.get("label") == "gemini:fake"\n+ and attempt.get("disabledForRun")\n+ ]\n+ check(len(disabled_attempts) == 1,\n+ "circuit-breaking failure is marked disabled in the run ledger")\n+ check(write._is_transient_platform_failure(\n+ providers.ProviderError("finishReason MAX_TOKENS")) is False,\n+ "model truncation does not trip the platform circuit")\n+ check(write._is_transient_platform_failure(\n+ providers.ProviderError("HTTP 400")) is False,\n+ "request-specific HTTP 400 does not trip the platform circuit")\n+\n # --- primary model retries ONCE on a validation failure ----------------\n reset_data_dir()\n pending_fixture = load(FIXTURES / "pending.json")