{
  "sha": "0d07e372968c0eaf90c3d8ab1c925e5b38edc4cd",
  "message": "Fix complete morning, afternoon and evening briefings (#155)\n\nUse full trailing-24-hour snapshots, preserve in-window stories across editions, add Gemini search fallbacks, and render explicit civil/military coverage notes.",
  "author": {
    "login": "nnneeeooo-nnneeeooo",
    "name": "",
    "email": ""
  },
  "committer": {
    "login": "web-flow",
    "name": "",
    "email": ""
  },
  "created_at": "2026-08-29T22:17:55Z",
  "files": [
    {
      "filename": "config/briefing_editions.json",
      "previous_filename": "",
      "status": "modified",
      "additions": 11,
      "deletions": 11,
      "changes": 22
    },
    {
      "filename": "pipeline/CONTRACTS.md",
      "previous_filename": "",
      "status": "modified",
      "additions": 8,
      "deletions": 4,
      "changes": 12
    },
    {
      "filename": "pipeline/briefing.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 98,
      "deletions": 18,
      "changes": 116
    },
    {
      "filename": "pipeline/build.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 47,
      "deletions": 9,
      "changes": 56
    },
    {
      "filename": "pipeline/grounded.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 60,
      "deletions": 30,
      "changes": 90
    },
    {
      "filename": "templates/briefing.html",
      "previous_filename": "",
      "status": "modified",
      "additions": 42,
      "deletions": 2,
      "changes": 44
    },
    {
      "filename": "tests/test_briefing.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 31,
      "deletions": 20,
      "changes": 51
    },
    {
      "filename": "tests/test_grounded.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 31,
      "deletions": 5,
      "changes": 36
    }
  ],
  "diff": "@@ -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 "
}
