2026-10-20 TUE UTC 12:42 PM TPE 8:42 PM 每小時自動更新 中文 EN
LIVE 快訊
← 返回網站更新紀錄 Site Changelog

提交原始紀錄

Fix complete morning, afternoon and evening briefings (#155) Use full trailing-24-hour snapshots, preserve in-window stories across editions, add Gemini search fallbacks, and render explicit civil/military coverage notes.

SHA
0d07e372968c0eaf90c3d8ab1c925e5b38edc4cd
提交時間
作者
nnneeeooo-nnneeeooo
提交者
web-flow

變更檔案 (8)

  • config/briefing_editions.json modified · +11 / −11
  • pipeline/CONTRACTS.md modified · +8 / −4
  • pipeline/briefing.py modified · +98 / −18
  • pipeline/build.py modified · +47 / −9
  • pipeline/grounded.py modified · +60 / −30
  • templates/briefing.html modified · +42 / −2
  • tests/test_briefing.py modified · +31 / −20
  • tests/test_grounded.py modified · +31 / −5

完整差異

@@ -2,9 +2,9 @@\n   "morning": {\n     "label_zh_tw": "晨間快報",\n     "label_en": "Morning Briefing",\n-    "title_zh_tw": "AVWIRE 晨間航空與交通快報",\n-    "title_en": "AVWIRE Morning Air & Transport Briefing",\n-    "window_start_hour": 23,\n+    "title_zh_tw": "SKYTICAL 晨間航空與交通運輸快報",\n+    "title_en": "SKYTICAL Morning Air & Transport Briefing",\n+    "window_start_hour": 7,\n     "window_end_hour": 7,\n     "window_start_day_offset": -1,\n     "cutoff_hour": 7,\n@@ -16,11 +16,11 @@\n   "afternoon": {\n     "label_zh_tw": "午後快報",\n     "label_en": "Afternoon Briefing",\n-    "title_zh_tw": "AVWIRE 午後航空與交通快報",\n-    "title_en": "AVWIRE Afternoon Air & Transport Briefing",\n-    "window_start_hour": 7,\n+    "title_zh_tw": "SKYTICAL 午後航空與交通運輸快報",\n+    "title_en": "SKYTICAL Afternoon Air & Transport Briefing",\n+    "window_start_hour": 15,\n     "window_end_hour": 15,\n-    "window_start_day_offset": 0,\n+    "window_start_day_offset": -1,\n     "cutoff_hour": 15,\n     "scheduled_hour": 15,\n     "scheduled_minute": 15,\n@@ -30,11 +30,11 @@\n   "evening": {\n     "label_zh_tw": "晚間快報",\n     "label_en": "Evening Briefing",\n-    "title_zh_tw": "AVWIRE 晚間航空與交通快報",\n-    "title_en": "AVWIRE Evening Air & Transport Briefing",\n-    "window_start_hour": 15,\n+    "title_zh_tw": "SKYTICAL 晚間航空與交通運輸快報",\n+    "title_en": "SKYTICAL Evening Air & Transport Briefing",\n+    "window_start_hour": 23,\n     "window_end_hour": 23,\n-    "window_start_day_offset": 0,\n+    "window_start_day_offset": -1,\n     "cutoff_hour": 23,\n     "scheduled_hour": 23,\n     "scheduled_minute": 15,\n@@ -295,13 +295,17 @@ data/briefings/\n │                          status published|partial|failed, cutoff_time,\n │                          generated_at, item_count}]}\n ├─ <date>-<edition>.json one briefing per edition (schema in briefing.py:\n-│                          fixed half-open Asia/Taipei window, sections,\n-│                          checked_sources, warnings, generation_mode)\n-└─ event_index.json      cross-edition dedup: {"events": {event_id:\n+│                          fixed trailing-24-hour Asia/Taipei window,\n+│                          sections, coverage_notes, checked_sources,\n+│                          warnings, generation_mode)\n+└─ event_index.json      cross-edition event/update tracking: {"events": {event_id:\n                            {fingerprint, urls, fact_keys, updates, ...}}}\n ```\n \n-Briefings select only from data/articles/ (already verified/published);\n+Each morning, afternoon and evening edition is an independently readable\n+24-hour snapshot, so an event may remain visible across editions while it is\n+still in-window. Briefings select only from data/articles/ (already\n+verified/published) plus source-grounded search results;\n failed runs never modify briefing files and never downgrade a\n published/partial index row. build.py renders /briefings/ and\n /briefings/<id>/ for published|partial only.\n@@ -1,9 +1,11 @@\n """briefing.py — thrice-daily air & transport briefing assembler.\n \n Selects already-verified articles (data/articles/) whose source time falls\n-inside a fixed 8-hour Asia/Taipei window, deduplicates events across\n-editions via a persistent event index, and writes one briefing JSON per\n-edition to data/briefings/. Assembly is deterministic by default: verified\n+inside a fixed trailing-24-hour Asia/Taipei window and writes one complete\n+snapshot per edition to data/briefings/. Events may therefore appear in more\n+than one edition while they remain inside that edition's 24-hour window;\n+each morning, afternoon and evening report must be independently readable.\n+Assembly is deterministic by default: verified\n headlines and summaries are reused as-is and no model is called. At most\n one optional batch LLM call per edition (BRIEFING_LLM_INTRO=true) may add\n an intro paragraph; any failure falls back to the deterministic output.\n@@ -13,10 +15,11 @@\n only the published article store enforces that exclusion structurally.\n \n Editions (config/briefing_editions.json is the single source of truth):\n-  morning    23:00 (previous day) <= source_time < 07:00, runs 07:15 TPE\n-  afternoon  07:00               <= source_time < 15:00, runs 15:15 TPE\n-  evening    15:00               <= source_time < 23:00, runs 23:15 TPE\n-Windows are half-open and fixed: a delayed run never widens or shifts them.\n+  morning    07:00 (previous day) <= source_time < 07:00, runs 07:15 TPE\n+  afternoon  15:00 (previous day) <= source_time < 15:00, runs 15:15 TPE\n+  evening    23:00 (previous day) <= source_time < 23:00, runs 23:15 TPE\n+Windows are trailing 24 hours, half-open and fixed: a delayed run never\n+widens or shifts them.\n """\n from __future__ import annotations\n \n@@ -165,7 +168,7 @@ def contains(self, dt: datetime) -> bool:\n \n \n def get_briefing_window(edition: str, taipei_date: date) -> BriefingWindow:\n-    """Fixed 8-hour window for one edition on one Taipei date.\n+    """Fixed trailing-24-hour window for one edition on one Taipei date.\n \n     Derived only from the edition config and the date — never from the\n     clock — so a delayed Actions run keeps the exact same window.\n@@ -547,10 +550,14 @@ def select_items(articles: list[dict], window: BriefingWindow,\n \n         new_facts = [f for f in facts if f not in set(event.get("fact_keys") or [])]\n         if not new_facts:\n-            # Same event, nothing substantive: retitles/reprints are not\n-            # updates. Remember the extra URLs so later reprints match too.\n+            # A briefing is a complete 24-hour snapshot, not an incremental\n+            # change log. Keep the event in later editions while it remains\n+            # in-window, but do not mislabel a retitle/reprint as an update.\n             event["urls"] = sorted(set(event.get("urls") or []) | set(urls))\n             event["last_seen"] = now.isoformat()\n+            picked.append((section, _build_item(\n+                article, section, event_id, "ongoing", event_id, None)))\n+            emitted.add(event_id)\n             continue\n \n         original_claims = [\n@@ -846,7 +853,8 @@ def checked_sources_snapshot() -> tuple[list[dict], list[str]]:\n             # coverage.\n             continue\n         row = {"name": src.get("name"), "ok": bool(src.get("ok")),\n-               "last_fetch_utc": src.get("lastFetchUtc")}\n+               "last_fetch_utc": src.get("lastFetchUtc"),\n+               "url": _direct_source_url(src.get("url"))}\n         rows.append(row)\n         if not row["ok"]:\n             warnings.append(f"source fetch failing: {row['name']} — a fetch "\n@@ -855,12 +863,82 @@ def checked_sources_snapshot() -> tuple[list[dict], list[str]]:\n     return rows, warnings\n \n \n+_COVERAGE_SOURCE_MARKERS = {\n+    "aviation_incidents": ("FAA", "NTSB", "The Aviation Herald", "EASA"),\n+    "taiwan_civil": (\n+        "CAA Taiwan", "長榮航空 EVA Air", "中央社 CNA 國籍航空"),\n+    "taiwan_military": ("國防部・軍聞社", "中央社 CNA"),\n+    "international_aviation": (\n+        "Airbus", "Boeing", "Reuters", "AeroTime",\n+        "Aerospace Global News", "IATA", "Eurocontrol"),\n+    "ground_and_maritime": ("臺灣交通要聞", "中央社 CNA", "Eurocontrol"),\n+}\n+\n+_COVERAGE_EMPTY_COPY = {\n+    "aviation_incidents": (\n+        "本期 24 小時資料窗口內,已查核來源未收錄符合本站門檻的重大飛安事件通報。",\n+        "No major safety event meeting the site's inclusion threshold was found in the checked sources during this 24-hour window."),\n+    "taiwan_civil": (\n+        "本期 24 小時資料窗口內,已查核來源未收錄臺灣民航事故、重大航班異動或機隊受損通報。",\n+        "No Taiwan civil-aviation accident, major disruption or fleet-damage report was found in the checked sources during this 24-hour window."),\n+    "taiwan_military": (\n+        "本期 24 小時資料窗口內,已查核來源未收錄國軍航空器事故、迫降或人員傷亡通報。",\n+        "No ROC military-aircraft accident, forced landing or casualty report was found in the checked sources during this 24-hour window."),\n+    "international_aviation": (\n+        "本期 24 小時資料窗口內,已查核來源未收錄新的重大訂單、首航、監管措施或航空公司營運危機。",\n+        "No new major order, inaugural service, regulatory action or airline operating crisis was found in the checked sources during this 24-hour window."),\n+    "ground_and_maritime": (\n+        "本期 24 小時資料窗口內,已查核來源未收錄符合本站門檻的重大地面或海運交通事件。",\n+        "No major ground or maritime transport event meeting the site's threshold was found in the checked sources during this 24-hour window."),\n+}\n+\n+\n+def build_coverage_notes(sections: dict, checked_sources: list[dict]) -> dict:\n+    """Reader-facing, source-scoped empty-state notes for every report area.\n+\n+    These sentences describe only what this pipeline found in the sources it\n+    actually checked. They deliberately do not claim that nothing happened.\n+    """\n+    empty = {\n+        "aviation_incidents": not sections.get("aviation_incidents"),\n+        "taiwan_civil": not any(\n+            not item.get("military")\n+            for item in sections.get("taiwan_aviation") or []),\n+        "taiwan_military": not any(\n+            item.get("military")\n+            for item in sections.get("taiwan_aviation") or []),\n+        "international_aviation": not sections.get("international_aviation"),\n+        "ground_and_maritime": not sections.get("ground_and_maritime"),\n+    }\n+    notes = {}\n+    for scope, is_empty in empty.items():\n+        if not is_empty:\n+            continue\n+        markers = {marker.casefold()\n+                   for marker in _COVERAGE_SOURCE_MARKERS[scope]}\n+        sources = []\n+        for source in checked_sources:\n+            name = str(source.get("name") or "")\n+            if (source.get("ok") and source.get("url")\n+                    and name.casefold() in markers):\n+                sources.append({"name": name, "url": source["url"]})\n+        zh, en = _COVERAGE_EMPTY_COPY[scope]\n+        notes[scope] = {"zh": zh, "en": en, "sources": sources[:5]}\n+    return notes\n+\n+\n def assemble_briefing(window: BriefingWindow, sections: dict,\n                       checked_sources: list[dict], warnings: list[str],\n                       generated_at: datetime, partial: bool,\n                       grounded_info: dict | None = None) -> dict:\n     cfg = load_editions()[window.edition]\n     total = sum(len(v) for v in sections.values())\n+    grounded_info = grounded_info or {"used": False, "items": 0}\n+    generation_mode = ("grounded_assisted"\n+                       if grounded_info.get("used") else "deterministic")\n+    generation_model = (\n+        {"provider": "gemini", "model": grounded_info.get("model")}\n+        if grounded_info.get("used") else None)\n     return {\n         "content_type": "daily_transport_briefing",\n         "briefing_id": window.briefing_id,\n@@ -876,11 +954,12 @@ def assemble_briefing(window: BriefingWindow, sections: dict,\n         "status": "partial" if partial else "published",\n         "item_count": total,\n         "sections": sections,\n+        "coverage_notes": build_coverage_notes(sections, checked_sources),\n         "checked_sources": checked_sources,\n         "warnings": warnings,\n-        "generation_mode": "deterministic",\n-        "generation_model": None,\n-        "grounded_beta": grounded_info or {"used": False, "items": 0},\n+        "generation_mode": generation_mode,\n+        "generation_model": generation_model,\n+        "grounded_beta": grounded_info,\n     }\n \n \n@@ -947,7 +1026,7 @@ def run_edition(edition: str, taipei_date: date) -> int:\n             if data is None:\n                 # reader-facing copy stays non-technical; details go to CI log\n                 print("briefing: grounded sweep skipped (no API key)")\n-                warnings.append("AI 搜尋服務暫時未啟用,本期未產生條列內容")\n+                warnings.append("AI 搜尋補充暫時未啟用;本期仍依已查證新聞與來源查核結果彙整")\n                 grounded_degraded = True\n             else:\n                 g_items, g_warnings = grounded.sanitize_items(\n@@ -960,7 +1039,8 @@ def run_edition(edition: str, taipei_date: date) -> int:\n                     sections[name].extend(extra)\n                     added += len(extra)\n                 grounded_info = {"used": True,\n-                                 "model": grounded.GROUNDED_MODEL,\n+                                 "model": getattr(\n+                                     shim, "model", grounded.GROUNDED_MODEL),\n                                  "items": added}\n                 event_index["grounded_seen"] = g_seen\n                 if shim is not None:\n@@ -974,8 +1054,8 @@ def run_edition(edition: str, taipei_date: date) -> int:\n         # reader-facing copy stays non-technical; details go to CI log\n         print(f"briefing: grounded sweep failed "\n               f"({type(exc).__name__}: {str(exc)[:300]})")\n-        warnings.append("AI 搜尋服務暫時不穩定,本期未能產生條列內容;"\n-                        "我們正努力恢復服務,系統將於下一期自動重試")\n+        warnings.append("AI 搜尋補充暫時不穩定;本期仍依已查證新聞與來源查核結果彙整,"\n+                        "系統將於下一期自動重試")\n         grounded_degraded = True\n \n     # partial = our own inputs were degraded, never "no events".\n@@ -181,15 +181,17 @@ def static_asset_version() -> str:\n         "radarSource": "航機位置:Airplanes.live 公開社群 ADS-B;航班代碼與航線:ADSBdb;地圖:OpenStreetMap。",\n         # daily briefings\n         "brKicker": "Daily Briefing", "brIndexTitle": "快報",\n-        "brIndexSub": "每日三次(臺北時間 07:15/15:15/23:15)的條列式全球航空與交通要聞快報,以本站已查證新聞為基礎,並由具搜尋能力的模型補充彙整(Beta)。",\n+        "brIndexSub": "每日早、中、晚三次(臺北時間 07:15/15:15/23:15)的完整 24 小時航空與交通運輸匯總,以本站已查證新聞為基礎,並由具搜尋能力的模型補充彙整(Beta)。",\n         "brLatest": "最新快報", "brWindow": "資料範圍", "brCutoff": "資料截止",\n         "brGenerated": "系統整理完成", "brTo": "至",\n         "brEmpty": "截至本期資料截止時間,系統在本次查核的指定來源中,未發現符合收錄門檻的新事件。",\n         "brPartial": "本期部分來源資料未能完整取得,內容可能不完整;來源擷取失敗不代表沒有新事件。",\n         "brChecked": "📡 本期查核來源", "brWarnings": "資料覆蓋提示",\n         "brUpdate": "更新", "brItems": "則",\n         "brNoItems": "本期查核來源中,此分類無新增符合收錄門檻的事件通報",\n-        "brModeDet": "程式化組裝(未使用模型)", "brModeLlm": "模型輔助導言",\n+        "brModeDet": "已查證新聞匯總", "brModeLlm": "模型輔助匯總",\n+        "brModeGrounded": "AI 搜尋輔助匯總",\n+        "brCivil": "民航部分", "brMilitary": "軍航部分",\n         "brBeta": "BETA", "brAiTag": "AI 搜尋",\n         "brBetaNote": "Beta 功能:本快報部分條目由具搜尋能力的模型輔助彙整"\n                       "(標示「AI 搜尋」者),引用連結僅取自檢索實際回傳的來源;"\n@@ -311,7 +313,7 @@ def static_asset_version() -> str:\n         "radarSource": "Aircraft positions: Airplanes.live public community ADS-B; flight codes and routes: ADSBdb; map: OpenStreetMap.",\n         # daily briefings\n         "brKicker": "Daily Briefing", "brIndexTitle": "Briefings",\n-        "brIndexSub": "A bulleted global air and transport briefing three times daily (07:15 / 15:15 / 23:15 Taipei time), based on the site's verified articles and supplemented by a search-capable model (Beta).",\n+        "brIndexSub": "A complete trailing-24-hour air and transport roundup each morning, afternoon and evening (07:15 / 15:15 / 23:15 Taipei time), based on the site's verified articles and supplemented by a search-capable model (Beta).",\n         "brLatest": "Latest briefing", "brWindow": "Data window",\n         "brCutoff": "Data cutoff", "brGenerated": "Compiled", "brTo": "to",\n         "brEmpty": "As of this edition's data cutoff, no new events meeting the inclusion bar were found in the sources checked for this edition.",\n@@ -320,8 +322,10 @@ def static_asset_version() -> str:\n         "brUpdate": "UPDATE", "brItems": "items",\n         "brNoItems": "No new events met the inclusion bar in this section "\n                      "among the sources checked",\n-        "brModeDet": "Deterministic assembly (no model used)",\n-        "brModeLlm": "Model-assisted intro",\n+        "brModeDet": "Verified-news roundup",\n+        "brModeLlm": "Model-assisted roundup",\n+        "brModeGrounded": "AI-search-assisted roundup",\n+        "brCivil": "Civil aviation", "brMilitary": "Military aviation",\n         "brBeta": "BETA", "brAiTag": "AI search",\n         "brBetaNote": "Beta: some briefing entries (tagged \"AI search\") "\n                       "are compiled with a search-capable model; cited "\n@@ -1956,17 +1960,48 @@ def _render_include_articles() -> bool:\n def brief_view(b, lang: str, t, published_ids):\n     sections = []\n     raw_sections = b.get("sections") or {}\n+    coverage_notes = b.get("coverage_notes") or {}\n     include_articles = _render_include_articles()\n     dropped = 0\n+\n+    def note_view(scope):\n+        note = coverage_notes.get(scope) or {}\n+        return {\n+            "text": str(note.get("zh" if lang == "zh" else "en") or ""),\n+            "sources": [\n+                {"name": str(source.get("name") or ""),\n+                 "url": str(source.get("url") or "").strip()}\n+                for source in note.get("sources") or []\n+                if isinstance(source, dict)\n+                and _public_direct_url(source.get("url"))\n+            ],\n+        }\n+\n     for i, name in enumerate(BRIEFING_SECTIONS):\n         raw = [it for it in raw_sections.get(name) or []\n                if isinstance(it, dict)]\n         if not include_articles:\n             kept = [it for it in raw if it.get("origin") == "grounded"]\n             dropped += len(raw) - len(kept)\n             raw = kept\n-        items = [brief_item_view(it, lang, published_ids) for it in raw]\n-        sections.append({"label": t["brSecs"][i], "items": items})\n+        if name == "taiwan_aviation":\n+            civil = [brief_item_view(it, lang, published_ids)\n+                     for it in raw if not it.get("military")]\n+            military = [brief_item_view(it, lang, published_ids)\n+                        for it in raw if it.get("military")]\n+            sections.append({\n+                "label": t["brSecs"][i], "items": civil + military,\n+                "subsections": [\n+                    {"label": t["brCivil"], "items": civil,\n+                     "note": note_view("taiwan_civil")},\n+                    {"label": t["brMilitary"], "items": military,\n+                     "note": note_view("taiwan_military")},\n+                ],\n+            })\n+        else:\n+            items = [brief_item_view(it, lang, published_ids) for it in raw]\n+            sections.append({"label": t["brSecs"][i], "items": items,\n+                             "subsections": [], "note": note_view(name)})\n     gen_model = b.get("generation_model") or {}\n     date_dt = _tpe_dt(b.get("cutoff_time"))\n     # an intro written over the merged item set is stale once items are cut\n@@ -1987,8 +2022,11 @@ def brief_view(b, lang: str, t, published_ids):\n         "total": sum(len(s["items"]) for s in sections),\n         "sections": sections,\n         "intro": intro,\n-        "mode_label": (t["brModeLlm"] if b.get("generation_mode")\n-                       == "llm_assisted" else t["brModeDet"]),\n+        "mode_label": (\n+            t["brModeLlm"] if b.get("generation_mode") == "llm_assisted"\n+            else t["brModeGrounded"]\n+            if b.get("generation_mode") == "grounded_assisted"\n+            else t["brModeDet"]),\n         "model_label": (f"{gen_model.get('provider')}:{gen_model.get('model')}"\n                         if gen_model else ""),\n         "checked": [c for c in b.get("checked_sources") or []\n@@ -15,8 +15,9 @@\n - Social/video domains are rejected; forum sources are allowed only\n   alongside at least one non-forum source (owner's relaxation, kept one\n   notch conservative).\n-- Items are deduped against the pipeline's own briefing items and against\n-  a 72h seen-set so the three editions do not repeat one another.\n+- Items are deduped against the pipeline's own items and within each model\n+  response. Because every edition is a complete trailing-24-hour snapshot,\n+  an event may legitimately remain visible in more than one edition.\n - Any failure (quota, tool unavailable, bad JSON) degrades to the\n   deterministic briefing with a coverage warning - never a failed\n   edition. One call per edition, spend recorded in the usage ledger.\n@@ -36,10 +37,26 @@\n from common import is_google_news_url, squash_text  # noqa: E402\n from providers import extract_json  # noqa: E402\n \n-# `or` (not a get() default) so the empty env var CI passes when the repo\n-# variable is unset still falls back to the real model id.\n-GROUNDED_MODEL = (os.environ.get("BRIEFING_GROUNDED_MODEL")\n-                  or "gemini-3.6-flash")\n+# The primary model can be configured without giving up automatic recovery.\n+# Search quotas are model-specific on some Gemini plans; production recently\n+# returned HTTP 429 for the primary model while other configured Gemini models\n+# remained available. Try the explicit model first, then stable fallbacks.\n+_DEFAULT_GROUNDED_MODELS = (\n+    "gemini-3.6-flash", "gemini-3.5-flash", "gemini-2.5-flash")\n+\n+\n+def _grounded_models() -> tuple[str, ...]:\n+    raw_many = os.environ.get("BRIEFING_GROUNDED_MODELS", "")\n+    explicit = os.environ.get("BRIEFING_GROUNDED_MODEL", "")\n+    requested = [part.strip() for part in raw_many.split(",") if part.strip()]\n+    if explicit.strip():\n+        requested.insert(0, explicit.strip())\n+    ordered = requested + list(_DEFAULT_GROUNDED_MODELS)\n+    return tuple(dict.fromkeys(ordered))\n+\n+\n+GROUNDED_MODELS = _grounded_models()\n+GROUNDED_MODEL = GROUNDED_MODELS[0]  # backward-compatible public name\n TIMEOUT = (10, 180)\n MAX_PER_SECTION = 4\n SEEN_TTL_HOURS = 72\n@@ -64,13 +81,14 @@ def enabled() -> bool:\n \n \n SYSTEM_PROMPT = """\\n-你是 AVWIRE 的航空與交通運輸快報彙整助理。使用 google_search 搜尋「過去 24 小時」\n-的最新報導,聚焦:全球航空事故與事件、台灣民航與軍用航空動態(含國防部共機動態、\n+你是 SKYTICAL 的航空與交通運輸快報彙整助理。使用 google_search 搜尋本期指定的\n+「完整 24 小時資料窗口」,聚焦:全球航空事故與事件、台灣民航與軍用航空動態(含國防部共機動態、\n 華航/長榮/星宇/台灣虎航/立榮/華信)、國際航空產業(訂單/航線/政策/破產)、\n 台灣與全球重大鐵路/公路/海運事件。\n \n 嚴格規則:\n-- 只報導搜尋結果明確支持的事實;不得補充背景知識、不得推測原因或責任。\n+- 只報導搜尋結果明確支持的事實;可加入來源明確支持且有助理解的近期背景,\n+  但必須標示實際日期,不得把舊事件寫成今日事件,不得推測原因或責任。\n - 保留來源的不確定語氣(據報導/初步/調查中),不得升級確定性。\n - 每一則 item 的 sourceChunks 必須列出支持該則內容的檢索結果編號\n   (grounding chunk index,從 0 起算);沒有可引用檢索結果的內容不得輸出。\n@@ -128,16 +146,16 @@ def _final_text(data: dict) -> str | None:\n \n \n def call_grounded(window) -> tuple[dict | None, SimpleNamespace | None]:\n-    """One grounded generateContent call; (response_json, usage_shim)."""\n+    """Grounded search with model-level quota/unavailability fallback."""\n     key = os.environ.get("GEMINI_API_KEY")\n     if not key:\n         return None, None\n     payload = {\n         "systemInstruction": {"parts": [{"text": SYSTEM_PROMPT}]},\n         "contents": [{"role": "user", "parts": [{"text":\n             f"本期快報資料窗口(台北時間):{window.window_start.isoformat()} 至 "\n-            f"{window.window_end.isoformat()}。請搜尋並輸出過去 24 小時內、"\n-            "尚屬最新的事件 JSON。"}]}],\n+            f"{window.window_end.isoformat()}。這是完整 24 小時窗口;請搜尋並輸出"\n+            "窗口內的新事件,以及由來源明確支持、仍有助理解的近期背景 JSON。"}]}],\n         "tools": [{"google_search": {}}],\n         "generationConfig": {\n             # owner-tuned: low temperature + high thinking level so the\n@@ -147,24 +165,32 @@ def call_grounded(window) -> tuple[dict | None, SimpleNamespace | None]:\n             "thinkingConfig": {"thinkingLevel": "high"},\n         },\n     }\n-    resp = requests.post(\n-        "https://generativelanguage.googleapis.com/v1beta/models/"\n-        f"{GROUNDED_MODEL}:generateContent",\n-        json=payload, timeout=TIMEOUT,\n-        headers={"x-goog-api-key": key})\n-    if resp.status_code != 200:\n+    failures = []\n+    for model in GROUNDED_MODELS:\n+        resp = requests.post(\n+            "https://generativelanguage.googleapis.com/v1beta/models/"\n+            f"{model}:generateContent",\n+            json=payload, timeout=TIMEOUT,\n+            headers={"x-goog-api-key": key})\n+        if resp.status_code == 200:\n+            data = resp.json()\n+            meta = data.get("usageMetadata") or {}\n+            shim = SimpleNamespace(\n+                model=model, label=f"gemini:{model}+search",\n+                http_calls=len(failures) + 1,\n+                usage={"inputTokens": int(meta.get("promptTokenCount") or 0),\n+                       "outputTokens": (\n+                           int(meta.get("candidatesTokenCount") or 0)\n+                           + int(meta.get("thoughtsTokenCount") or 0)),\n+                       "usageEvents": 1 if meta else 0})\n+            return data, shim\n         snippet = str(getattr(resp, "text", ""))[:160].replace("\n", " ")\n-        raise RuntimeError(\n-            f"grounded call HTTP {resp.status_code}: {snippet}")\n-    data = resp.json()\n-    meta = data.get("usageMetadata") or {}\n-    shim = SimpleNamespace(\n-        label=f"gemini:{GROUNDED_MODEL}+search", http_calls=1,\n-        usage={"inputTokens": int(meta.get("promptTokenCount") or 0),\n-               "outputTokens": (int(meta.get("candidatesTokenCount") or 0)\n-                                + int(meta.get("thoughtsTokenCount") or 0)),\n-               "usageEvents": 1 if meta else 0})\n-    return data, shim\n+        failures.append(f"{model}=HTTP {resp.status_code}: {snippet}")\n+        print(f"briefing: grounded model {model} unavailable "\n+              f"(HTTP {resp.status_code}); trying fallback")\n+        if resp.status_code not in (404, 408, 429, 500, 502, 503, 504):\n+            break\n+    raise RuntimeError("grounded models unavailable: " + " | ".join(failures))\n \n \n def _seen_key(headline_zh: str) -> str:\n@@ -187,6 +213,7 @@ def sanitize_items(data: dict, existing_titles: list[str],\n     chunks = _chunks_from(data)\n     existing_squashed = [squash_text(t) for t in existing_titles if t]\n     dropped = 0\n+    emitted_keys = set()\n     for item in (parsed.get("items") or [])[:40]:\n         if not isinstance(item, dict):\n             continue\n@@ -212,15 +239,18 @@ def sanitize_items(data: dict, existing_titles: list[str],\n             dropped += 1  # no retrieved source -> no publication path\n             continue\n         key = _seen_key(head_zh)\n-        if key in seen:\n+        if key in emitted_keys:\n             continue\n+        emitted_keys.add(key)\n         squashed = squash_text(head_zh)\n         if any(squashed in t or t in squashed\n                for t in existing_squashed if len(t) >= 8):\n             continue  # pipeline already covers this story\n         if len(out[section]) >= MAX_PER_SECTION:\n             dropped += 1\n             continue\n+        # Kept only for backward-compatible retention/diagnostics. It no\n+        # longer suppresses later editions: each report is a full snapshot.\n         seen[key] = (now + timedelta(hours=SEEN_TTL_HOURS)).isoformat()\n         severity = item.get("severity")\n         out[section].append({\n@@ -27,8 +27,42 @@ <h1 style="font-size:32px;line-height:1.2;margin:10px 0 6px;">✈️ {{ b.title\n       <p class="text-muted" style="font-size:14.5px;line-height:1.9;margin:0;">{{ t.brEmpty }}</p>\n     </div>\n     {% endif %}\n-    {% for sec in (b.sections if b.total > 0 else []) %}\n+    {% for sec in b.sections %}\n     <h4 style="margin:26px 0 4px;">{{ sec.label }}</h4>\n+    {% if sec.subsections %}\n+    {% for sub in sec.subsections %}\n+    <h6 style="margin:16px 0 4px;">{{ sub.label }}</h6>\n+    {% for it in sub['items'] %}\n+    <div style="padding:14px 0;border-bottom:1px solid var(--color-divider);">\n+      <div style="display:flex;gap:10px;align-items:baseline;flex-wrap:wrap;">\n+        {% if it.marks %}<span style="font-size:14px;">{{ it.marks }}</span>{% endif %}\n+        <span class="tag {{ it.sev_cls }}">{{ t.brSevs[it.severity] }}</span>\n+        {% if it.update %}<span class="tag tag-outline">{{ t.brUpdate }}</span>{% endif %}\n+        {% if it.grounded %}<span class="tag tag-outline" title="{{ t.brBetaNote }}">{{ t.brAiTag }}</span>{% endif %}\n+        <span class="text-muted" style="font-size:12px;font-weight:800;">{{ it.time }}</span>\n+      </div>\n+      {% if it.url %}\n+      <a href="{{ it.url }}" style="display:block;font-size:16.5px;font-weight:700;line-height:1.5;margin:8px 0 6px;">{{ it.headline }}</a>\n+      {% else %}\n+      <div style="font-size:16.5px;font-weight:700;line-height:1.5;margin:8px 0 6px;">{{ it.headline }}</div>\n+      {% endif %}\n+      <p class="text-muted" style="font-size:14px;line-height:1.85;margin:0 0 8px;">{{ it.summary }}</p>\n+      <div style="display:flex;gap:12px;flex-wrap:wrap;font-size:12px;">\n+        {% for s in it.sources %}\n+        <a href="{{ s.url }}" target="_blank" rel="noopener" class="text-muted">{{ s.name }}</a>\n+        {% endfor %}\n+      </div>\n+    </div>\n+    {% else %}\n+    <p class="text-muted" style="font-size:13px;line-height:1.8;margin:6px 0 0;">{{ sub.note.text or t.brNoItems }}</p>\n+    {% if sub.note.sources %}\n+    <div style="display:flex;gap:12px;flex-wrap:wrap;font-size:12px;margin-top:5px;">\n+      {% for s in sub.note.sources %}<a href="{{ s.url }}" target="_blank" rel="noopener" class="text-muted">{{ s.name }}</a>{% endfor %}\n+    </div>\n+    {% endif %}\n+    {% endfor %}\n+    {% endfor %}\n+    {% else %}\n     {% for it in sec['items'] %}\n     <div style="padding:14px 0;border-bottom:1px solid var(--color-divider);">\n       <div style="display:flex;gap:10px;align-items:baseline;flex-wrap:wrap;">\n@@ -51,8 +85,14 @@ <h4 style="margin:26px 0 4px;">{{ sec.label }}</h4>\n       </div>\n     </div>\n     {% else %}\n-    <p class="text-muted" style="font-size:13px;line-height:1.8;margin:6px 0 0;">{{ t.brNoItems }}</p>\n+    <p class="text-muted" style="font-size:13px;line-height:1.8;margin:6px 0 0;">{{ sec.note.text or t.brNoItems }}</p>\n+    {% if sec.note.sources %}\n+    <div style="display:flex;gap:12px;flex-wrap:wrap;font-size:12px;margin-top:5px;">\n+      {% for s in sec.note.sources %}<a href="{{ s.url }}" target="_blank" rel="noopener" class="text-muted">{{ s.name }}</a>{% endfor %}\n+    </div>\n+    {% endif %}\n     {% endfor %}\n+    {% endif %}\n     {% endfor %}\n     <div style="border:2px solid var(--color-divider);padding:16px 20px 12px;margin-top:32px;">\n       <h6 style="margin:0 0 10px;">{{ t.brChecked }}</h6>\n@@ -104,8 +104,10 @@ def write_articles(articles, fname="batch.json") -> None:\n \n def write_sources(ok=True) -> None:\n     (TMP / "sources.json").write_text(json.dumps([\n-        {"name": "FAA", "ok": ok, "lastFetchUtc": "2026-07-27T00:00Z"},\n+        {"name": "FAA", "ok": ok, "url": "https://www.faa.gov",\n+         "lastFetchUtc": "2026-07-27T00:00Z"},\n         {"name": "CAA Taiwan", "ok": True,\n+         "url": "https://www.caa.gov.tw",\n          "lastFetchUtc": "2026-07-27T00:00Z"},\n     ]), encoding="utf-8")\n \n@@ -149,32 +151,34 @@ def install_fake_providers(providers):\n     sys.modules["providers"] = fake\n \n \n-# ── 1-6: fixed half-open windows ────────────────────────────────────────────\n+# ── 1-6: fixed trailing-24-hour windows ─────────────────────────────────────\n \n D = date(2026, 7, 27)\n morning = get_briefing_window("morning", D)\n afternoon = get_briefing_window("afternoon", D)\n evening = get_briefing_window("evening", D)\n \n-check("morning window is prev-day 23:00 -> 07:00 TPE",\n-      morning.window_start == tpe(2026, 7, 26, 23)\n+check("morning window is prev-day 07:00 -> 07:00 TPE",\n+      morning.window_start == tpe(2026, 7, 26, 7)\n       and morning.window_end == tpe(2026, 7, 27, 7))\n-check("afternoon window is 07:00 -> 15:00 TPE",\n-      afternoon.window_start == tpe(2026, 7, 27, 7)\n+check("afternoon window is prev-day 15:00 -> 15:00 TPE",\n+      afternoon.window_start == tpe(2026, 7, 26, 15)\n       and afternoon.window_end == tpe(2026, 7, 27, 15))\n-check("evening window is 15:00 -> 23:00 TPE",\n-      evening.window_start == tpe(2026, 7, 27, 15)\n+check("evening window is prev-day 23:00 -> 23:00 TPE",\n+      evening.window_start == tpe(2026, 7, 26, 23)\n       and evening.window_end == tpe(2026, 7, 27, 23))\n check("windows are timezone-aware (+08:00)",\n       morning.window_start.utcoffset() == timedelta(hours=8)\n       and morning.cutoff_time.tzinfo is not None)\n-check("07:00:00 belongs to afternoon, not morning",\n+check("morning retains an event from the preceding daytime",\n+      morning.contains(tpe(2026, 7, 26, 10)))\n+check("07:00 cutoff is excluded from morning but included in afternoon",\n       not morning.contains(tpe(2026, 7, 27, 7))\n       and afternoon.contains(tpe(2026, 7, 27, 7)))\n-check("15:00:00 belongs to evening, not afternoon",\n+check("15:00 cutoff is excluded from afternoon but included in evening",\n       not afternoon.contains(tpe(2026, 7, 27, 15))\n       and evening.contains(tpe(2026, 7, 27, 15)))\n-check("23:00:00 belongs to next-day morning, not evening",\n+check("23:00 cutoff is excluded from evening but included next morning",\n       not evening.contains(tpe(2026, 7, 27, 23))\n       and get_briefing_window("morning", date(2026, 7, 28))\n       .contains(tpe(2026, 7, 27, 23)))\n@@ -227,10 +231,10 @@ def install_fake_providers(providers):\n       'python pipeline/briefing.py --cron "${{ steps.ed.outputs.cron }}"'\n       in _wf)\n \n-# 11: no now-minus-24h briefing logic anywhere in the module.\n+# 11: every edition is explicitly a fixed trailing-24-hour snapshot.\n _src = (REPO / "pipeline" / "briefing.py").read_text(encoding="utf-8")\n-check("no 24-hour rolling-window logic in briefing.py",\n-      "hours=24" not in _src\n+check("briefing documents complete trailing-24-hour snapshots",\n+      "trailing-24-hour" in _src\n       and re.search(r"now\w*\(\)\s*-\s*timedelta", _src) is None)\n \n # ── 10: CLI dispatch (edition + date, and cron form) ─────────────────────────\n@@ -245,7 +249,7 @@ def install_fake_providers(providers):\n check("briefing schema basics", bm["content_type"]\n       == "daily_transport_briefing" and bm["edition"] == "morning"\n       and bm["timezone"] == "Asia/Taipei"\n-      and bm["window_start"] == "2026-07-26T23:00:00+08:00"\n+      and bm["window_start"] == "2026-07-26T07:00:00+08:00"\n       and bm["window_end"] == "2026-07-27T07:00:00+08:00"\n       and bm["cutoff_time"] == bm["window_end"])\n check("morning picked the 06:30 article",\n@@ -288,7 +292,7 @@ def install_fake_providers(providers):\n       bm["item_count"] == 1\n       and any("a-bad" in w for w in bm["warnings"]))\n \n-# ── 15-18: cross-edition dedup, updates, id stability ────────────────────────\n+# ── 15-18: complete later snapshots, updates, id stability ──────────────────\n \n reset()\n write_sources(ok=True)\n@@ -300,15 +304,16 @@ def install_fake_providers(providers):\n briefing.main(["--edition", "morning", "--date", "2026-07-27"])\n ev_id = all_items(load_brief("2026-07-27-morning"))[0]["event_id"]\n \n-# afternoon: same facts, retitled, reprinted on a new URL -> not an update\n+# afternoon: same facts remain visible, but a retitle is not an update\n write_articles([make_article("a-ev2", "換個標題的同一事件",\n                              tpe(2026, 7, 27, 8, 5),\n                              urls=("https://other-site.example/copy",),\n                              **base_kw)], "a.json")\n briefing.main(["--edition", "afternoon", "--date", "2026-07-27"])\n ba = load_brief("2026-07-27-afternoon")\n-check("same event without new facts never repeats in a later edition",\n-      ba["item_count"] == 0)\n+check("same event remains in a later complete snapshot without false update",\n+      ba["item_count"] == 1\n+      and all_items(ba)[0]["item_type"] == "ongoing")\n \n # evening: same event with a genuinely new fact -> update item\n write_articles([make_article(\n@@ -359,6 +364,12 @@ def install_fake_providers(providers):\n       b_empty["generation_mode"] == "deterministic"\n       and b_empty["item_count"] == 0\n       and all(v == [] for v in b_empty["sections"].values()))\n+check("empty edition explains every report area without absolute claims",\n+      set(b_empty["coverage_notes"]) == {\n+          "aviation_incidents", "taiwan_civil", "taiwan_military",\n+          "international_aviation", "ground_and_maritime"}\n+      and all("已查核來源未收錄" in note["zh"]\n+              for note in b_empty["coverage_notes"].values()))\n \n # items with verified summaries -> no per-item rewriting; intro is one batch\n reset()\n@@ -626,7 +637,7 @@ def _art_text(text):\n check("grounded outage keeps article fallback and marks edition partial",\n       bm["status"] == "partial"\n       and bm["item_count"] == 1\n-      and any("AI 搜尋服務暫時不穩定" in w for w in bm["warnings"]))\n+      and any("AI 搜尋補充暫時不穩定" in w for w in bm["warnings"]))\n \n # ── caps ─────────────────────────────────────────────────────────────────────\n \n@@ -127,13 +127,17 @@ def item(section="aviation_incidents", chunks=(0,), **kw):\n       len(out["aviation_incidents"]) == 1\n       and len(out["aviation_incidents"][0]["sources"]) == 2)\n \n-# ── dedupe + caps ────────────────────────────────────────────────────────────\n+# ── per-response dedupe + caps ──────────────────────────────────────────────\n \n seen = {}\n grounded.sanitize_items(resp_data([item()], CHUNKS), [], seen, NOW)\n out2, _ = grounded.sanitize_items(resp_data([item()], CHUNKS), [], seen, NOW)\n-check("seen-set blocks the same story in a later edition",\n-      out2["aviation_incidents"] == [])\n+check("complete snapshots may retain the same story in a later edition",\n+      len(out2["aviation_incidents"]) == 1)\n+out_dupe, _ = grounded.sanitize_items(\n+    resp_data([item(), item()], CHUNKS), [], {}, NOW)\n+check("duplicate items in one model response are collapsed",\n+      len(out_dupe["aviation_incidents"]) == 1)\n check("seen entries carry a TTL and prune",\n       grounded.prune_seen(\n           {"old": (NOW - timedelta(hours=1)).isoformat(),\n@@ -176,7 +180,7 @@ def fake_post(url, json=None, timeout=None, headers=None):\n grounded.requests = types.SimpleNamespace(post=fake_post)\n os.environ["GEMINI_API_KEY"] = "test-not-real"\n window = types.SimpleNamespace(\n-    window_start=NOW, window_end=NOW + timedelta(hours=8))\n+    window_start=NOW, window_end=NOW + timedelta(hours=24))\n data, shim = grounded.call_grounded(window)\n del os.environ["GEMINI_API_KEY"]\n cfg = captured["payload"]["generationConfig"]\n@@ -192,14 +196,36 @@ def fake_post(url, json=None, timeout=None, headers=None):\n       f"models/{grounded.GROUNDED_MODEL}:generateContent"\n       in captured["url"] and grounded.GROUNDED_MODEL)\n \n+fallback_calls = []\n+\n+\n+def fake_post_with_quota_fallback(url, json=None, timeout=None, headers=None):\n+    fallback_calls.append(url)\n+    if len(fallback_calls) == 1:\n+        return types.SimpleNamespace(status_code=429, text="quota")\n+    return types.SimpleNamespace(\n+        status_code=200, json=lambda: resp_data([], []))\n+\n+\n+grounded.requests = types.SimpleNamespace(post=fake_post_with_quota_fallback)\n+os.environ["GEMINI_API_KEY"] = "test-not-real"\n+_fallback_data, fallback_shim = grounded.call_grounded(window)\n+del os.environ["GEMINI_API_KEY"]\n+check("HTTP 429 falls through to the next grounded Gemini model",\n+      len(fallback_calls) == 2\n+      and fallback_shim.model == grounded.GROUNDED_MODELS[1]\n+      and fallback_shim.http_calls == 2)\n+\n # the empty-string env CI passes when the repo var is unset must not\n # blank the model id (this exact bug produced HTTP 404 in production)\n import importlib  # noqa: E402\n \n os.environ["BRIEFING_GROUNDED_MODEL"] = ""\n importlib.reload(grounded)\n check("empty BRIEFING_GROUNDED_MODEL env falls back to the default",\n-      grounded.GROUNDED_MODEL == "gemini-3.6-flash")\n+      grounded.GROUNDED_MODEL == "gemini-3.6-flash"\n+      and grounded.GROUNDED_MODELS[:2]\n+      == ("gemini-3.6-flash", "gemini-3.5-flash"))\n del os.environ["BRIEFING_GROUNDED_MODEL"]\n importlib.reload(grounded)\n 

你發現了隱藏主題!

8-bit 主題已解鎖。重新整理後,就能在主題選單選擇 8-bit。