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.
完整差異
@@ -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