2026-10-20 TUE UTC 12:42 PM TPE 8:42 PM Auto-updates hourly 中文 EN
LIVE WIRE
← Back to the site changelog Site Changelog

Original commit record

improve site pipeline resilience and search coverage

SHA
d4c8c6e1f8f2c86b66195ee9961add3b70ea2df1
Commit date
Author
nnneeeooo-nnneeeooo
Committer
nnneeeooo-nnneeeooo

Changed files (7)

  • pipeline/common.py modified · +32 / −1
  • pipeline/search_prompts.py modified · +11 / −1
  • pipeline/write.py modified · +57 / −3
  • tests/test_scope.py modified · +11 / −0
  • tests/test_search_prompts.py modified · +26 / −0
  • tests/test_seo.py modified · +4 / −3
  • tests/test_write_offline.py modified · +38 / −1

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")

You found a secret theme!

8-bit mode is unlocked. Refresh to add it to your theme selector.