{
  "sha": "d4c8c6e1f8f2c86b66195ee9961add3b70ea2df1",
  "message": "improve site pipeline resilience and search coverage",
  "author": {
    "login": "nnneeeooo-nnneeeooo",
    "name": "",
    "email": ""
  },
  "committer": {
    "login": "nnneeeooo-nnneeeooo",
    "name": "",
    "email": ""
  },
  "created_at": "2026-09-15T15:41:18Z",
  "files": [
    {
      "filename": "pipeline/common.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 32,
      "deletions": 1,
      "changes": 33
    },
    {
      "filename": "pipeline/search_prompts.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 11,
      "deletions": 1,
      "changes": 12
    },
    {
      "filename": "pipeline/write.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 57,
      "deletions": 3,
      "changes": 60
    },
    {
      "filename": "tests/test_scope.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 11,
      "deletions": 0,
      "changes": 11
    },
    {
      "filename": "tests/test_search_prompts.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 26,
      "deletions": 0,
      "changes": 26
    },
    {
      "filename": "tests/test_seo.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 4,
      "deletions": 3,
      "changes": 7
    },
    {
      "filename": "tests/test_write_offline.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 38,
      "deletions": 1,
      "changes": 39
    }
  ],
  "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\")"
}
