{
  "sha": "3e19d27e68c28e9469d53a4088081ee34343ec90",
  "message": "Merge pull request #222 from nnneeeooo-nnneeeooo/codex/briefing-three-daily-reports\n\n每天早、中、晚產生有來源的航空與交通快報",
  "author": {
    "login": "nnneeeooo-nnneeeooo",
    "name": "",
    "email": ""
  },
  "committer": {
    "login": "web-flow",
    "name": "",
    "email": ""
  },
  "created_at": "2026-09-25T20:27:48Z",
  "files": [
    {
      "filename": ".github/workflows/briefing.yml",
      "previous_filename": "",
      "status": "modified",
      "additions": 4,
      "deletions": 5,
      "changes": 9
    },
    {
      "filename": "pipeline/briefing.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 6,
      "deletions": 3,
      "changes": 9
    },
    {
      "filename": "pipeline/build.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 30,
      "deletions": 0,
      "changes": 30
    },
    {
      "filename": "pipeline/grounded.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 30,
      "deletions": 5,
      "changes": 35
    },
    {
      "filename": "templates/briefing.html",
      "previous_filename": "",
      "status": "modified",
      "additions": 12,
      "deletions": 2,
      "changes": 14
    },
    {
      "filename": "tests/test_briefing.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 17,
      "deletions": 0,
      "changes": 17
    },
    {
      "filename": "tests/test_grounded.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 15,
      "deletions": 0,
      "changes": 15
    }
  ],
  "diff": "@@ -77,8 +77,7 @@ jobs:\\n \\n       - name: Assemble briefing and build site\\n         env:\\n-          # Existing free model keys, used ONLY for the optional single\\n-          # batch intro call (BRIEFING_LLM_INTRO=true). Default: no call.\\n+          # Existing model keys support one sourced search and one overview.\\n           GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}\\n           NVIDIA_API_KEY: ${{ secrets.NVIDIA_API_KEY || secrets.API_1_NVIDIA_NEMOTRON_3_ULTRA_550B_A55B }}\\n           OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}\\n@@ -87,7 +86,7 @@ jobs:\\n           ADSBLOL_API_KEY: ${{ secrets.ADSBLOL_API_KEY }} # optional\\n           AVWIRE_WECHAT_MODEL: ${{ vars.AVWIRE_WECHAT_MODEL }}\\n           AVWIRE_PROVIDER_ORDER: gemini:gemini-3.6-flash,gemini:gemini-3.5-flash,nvidia:nvidia/nemotron-3-ultra-550b-a55b,wechat:Deepseek-v4-flash,openrouter:nvidia/nemotron-3-ultra-550b-a55b:free,nvidia:nvidia/nemotron-3-super-120b-a12b\\n-          BRIEFING_LLM_INTRO: ${{ vars.BRIEFING_LLM_INTRO }}\\n+          BRIEFING_LLM_INTRO: \"true\"\\n           BRIEFING_MAX_ITEMS: ${{ vars.BRIEFING_MAX_ITEMS }}\\n           BRIEFING_MAX_ITEMS_PER_SECTION: ${{ vars.BRIEFING_MAX_ITEMS_PER_SECTION }}\\n           BRIEFING_EXTRA_LLM_CALLS_MAX: ${{ vars.BRIEFING_EXTRA_LLM_CALLS_MAX }}\\n@@ -96,8 +95,8 @@ jobs:\\n           # private manual-drafting URL/encryption token\\n           AVWIRE_MANUAL_TOKEN: ${{ secrets.AVWIRE_MANUAL_TOKEN }}\\n           SKYTICAL_COPILOT_ENABLED: ${{ vars.SKYTICAL_COPILOT_ENABLED || 'false' }}\\n-          # BETA grounded search sweep (owner-approved; off unless var set)\\n-          BRIEFING_GROUNDED: ${{ vars.BRIEFING_GROUNDED }}\\n+          # Search each edition; verified site articles remain the fallback.\\n+          BRIEFING_GROUNDED: \"true\"\\n           BRIEFING_GROUNDED_MODEL: ${{ vars.BRIEFING_GROUNDED_MODEL }}\\n           # verified site articles are the reliable fallback; false opts out\\n           BRIEFING_INCLUDE_ARTICLES: ${{ vars.BRIEFING_INCLUDE_ARTICLES }}\\n@@ -275,10 +275,13 @@ def classify_section(article: dict) -> str:\\n     blob = _text_blob(article)\\n     if _has_marker(blob, _GROUND_MARKERS) or _has_marker(blob, _MARITIME_MARKERS):\\n         return \"ground_and_maritime\"\\n-    if article.get(\"cat\") == \"safety\":\\n-        return \"aviation_incidents\"\\n     if _has_marker(blob, _TAIWAN_MARKERS):\\n         return \"taiwan_aviation\"\\n+    if (article.get(\"cat\") == \"safety\"\\n+            or _has_marker(blob, _SERIOUS_MARKERS)\\n+            or _has_marker(blob, (\"跑道偏出\", \"衝出跑道\", \"runway excursion\",\\n+                                  \"engine failure\", \"發動機故障\"))):\\n+        return \"aviation_incidents\"\\n     return \"international_aviation\"\\n \\n \\n@@ -1030,7 +1033,7 @@ def run_edition(edition: str, taipei_date: date) -> int:\\n                 grounded_degraded = True\\n             else:\\n                 g_items, g_warnings = grounded.sanitize_items(\\n-                    data, existing_titles, g_seen, now)\\n+                    data, existing_titles, g_seen, now, window)\\n                 warnings.extend(g_warnings)\\n                 grounded_degraded = bool(g_warnings)\\n                 added = 0\\n@@ -199,6 +199,7 @@ def static_asset_version() -> str:\\n         \"brEmpty\": \"截至本期資料截止時間，系統在本次查核的指定來源中，未發現符合收錄門檻的新事件。\",\\n         \"brPartial\": \"本期部分來源資料未能完整取得，內容可能不完整；來源擷取失敗不代表沒有新事件。\",\\n         \"brChecked\": \"📡 本期查核來源\", \"brWarnings\": \"資料覆蓋提示\",\\n+        \"brReferences\": \"📡 來源參考\",\\n         \"brUpdate\": \"更新\", \"brItems\": \"則\",\\n         \"brNoItems\": \"本期查核來源中，此分類無新增符合收錄門檻的事件通報\",\\n         \"brModeDet\": \"已查證新聞匯總\", \"brModeLlm\": \"模型輔助匯總\",\\n@@ -334,6 +335,7 @@ def static_asset_version() -> str:\\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         \"brPartial\": \"Some sources could not be fetched in full for this edition; coverage may be incomplete. A fetch failure does not mean no events occurred.\",\\n         \"brChecked\": \"📡 Sources checked\", \"brWarnings\": \"Coverage notes\",\\n+        \"brReferences\": \"📡 References\",\\n         \"brUpdate\": \"UPDATE\", \"brItems\": \"items\",\\n         \"brNoItems\": \"No new events met the inclusion bar in this section \"\\n                      \"among the sources checked\",\\n@@ -2382,6 +2384,32 @@ def note_view(scope):\\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+    for section in sections:\\n+        headlines = [item[\"headline\"] for item in section[\"items\"][:2]]\\n+        if headlines:\\n+            count = len(section[\"items\"])\\n+            section[\"lead\"] = (\\n+                f\"本期收錄 {count} 則動態，包含{'、'.join(headlines)}。\"\\n+                if lang == \"zh\" else\\n+                f\"This edition includes {count} item(s), including \"\\n+                f\"{' and '.join(headlines)}.\")\\n+        else:\\n+            section[\"lead\"] = \"\"\\n+    references = []\\n+    referenced_urls = set()\\n+    for section in sections:\\n+        for item in section[\"items\"]:\\n+            for source in item[\"sources\"]:\\n+                if source[\"url\"] not in referenced_urls:\\n+                    references.append(source)\\n+                    referenced_urls.add(source[\"url\"])\\n+        notes = ([sub[\"note\"] for sub in section[\"subsections\"]]\\n+                 if section[\"subsections\"] else [section[\"note\"]])\\n+        for note in notes:\\n+            for source in note[\"sources\"]:\\n+                if source[\"url\"] not in referenced_urls:\\n+                    references.append(source)\\n+                    referenced_urls.add(source[\"url\"])\\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@@ -2394,13 +2422,15 @@ def note_view(scope):\\n         \"title\": _brief_title(b, lang),\\n         \"label\": _brief_label(b, lang),\\n         \"date_label\": _fmt_tpe_date(date_dt, lang) if date_dt else \"—\",\\n+        \"date_iso\": str(b.get(\"date\") or \"\"),\\n         \"window_start\": _fmt_tpe(b.get(\"window_start\"), lang),\\n         \"window_end\": _fmt_tpe(b.get(\"window_end\"), lang),\\n         \"cutoff\": _fmt_tpe(b.get(\"cutoff_time\"), lang),\\n         \"generated\": _fmt_tpe(b.get(\"generated_at\"), lang, time_only=True),\\n         \"partial\": b.get(\"status\") == \"partial\",\\n         \"total\": sum(len(s[\"items\"]) for s in sections),\\n         \"sections\": sections,\\n+        \"references\": references,\\n         \"intro\": intro,\\n         \"mode_label\": (\\n             t[\"brModeLlm\"] if b.get(\"generation_mode\") == \"llm_assisted\"\\n@@ -27,9 +27,10 @@\\n import hashlib\\n import os\\n import sys\\n-from datetime import timedelta\\n+from datetime import datetime, timedelta\\n from pathlib import Path\\n from types import SimpleNamespace\\n+from urllib.parse import urlsplit\\n \\n import requests\\n \\n@@ -96,6 +97,9 @@ def enabled() -> bool:\\n   最多只能寫成「媒體報導稱……，官方尚未證實」，不得自行推定原因或永久性。\\n - 每一則 item 的 sourceChunks 必須列出支持該則內容的檢索結果編號\\n   （grounding chunk index，從 0 起算）；沒有可引用檢索結果的內容不得輸出。\\n+- 每則須填 sourcePublishedAt（來源頁面明示的發布或更新時間，ISO 8601 且含時區）。\\n+  無法查證時間，或時間不在指定 24 小時窗口內的事件，請勿輸出；舊事件可在\\n+  當期新報導中作為有日期的背景，不得當作本期新事件。\\n - 繁體中文（台灣用語）為主，並提供英文版本。\\n - 事故有死亡者 severity=\"fatal\"；重大事故 \"serious\"；一般飛安事件\\n   \"significant\"；其他 \"routine\"。military=true 僅限軍用航空相關。\\n@@ -105,7 +109,7 @@ def enabled() -> bool:\\n {\"items\":[{\"section\":\"aviation_incidents|taiwan_aviation|international_aviation|ground_and_maritime\",\\n \"headline_zh\":\"18-40字\",\"summary_zh\":\"26-38字；須含主體與明確事件；完整短句優先，否則以最多兩個全形分號『；』串接可獨立閱讀的事實子句；禁止拆開人名、航空器型號或機場代碼；禁止刪節號與孤立標籤\",\"headline_en\":\"...\",\"summary_en\":\"10-18 words; include a named subject and clear event; use readable clauses with at most two semicolons; never split proper names, aircraft models or airport codes; no ellipsis or isolated labels\",\\n \"severity\":\"routine|significant|serious|fatal\",\"taiwan\":false,\"military\":false,\\n-\"sourceChunks\":[0]}]}\\n+\"sourcePublishedAt\":\"YYYY-MM-DDTHH:MM:SS+08:00\",\"sourceChunks\":[0]}]}\\n \"\"\"\\n \\n \\n@@ -123,6 +127,14 @@ def _is_forum(chunk) -> bool:\\n     return any(h in blob for h in _FORUM_HOSTS)\\n \\n \\n+def _direct_web_url(uri: str) -> bool:\\n+    try:\\n+        parts = urlsplit(str(uri or \"\"))\\n+        return parts.scheme.lower() in (\"http\", \"https\") and bool(parts.netloc)\\n+    except ValueError:\\n+        return False\\n+\\n+\\n def _chunks_from(data: dict) -> list[dict]:\\n     candidates = data.get(\"candidates\") or []\\n     if not candidates:\\n@@ -217,7 +229,7 @@ def _seen_key(headline_zh: str) -> str:\\n \\n \\n def sanitize_items(data: dict, existing_titles: list[str],\\n-                   seen: dict, now) -> tuple[dict, list[str]]:\\n+                   seen: dict, now, window=None) -> tuple[dict, list[str]]:\\n     \"\"\"Validated grounded items per section + warnings. Mutates `seen`.\"\"\"\\n     warnings: list[str] = []\\n     out: dict = {name: [] for name in SECTIONS}\\n@@ -242,12 +254,24 @@ def sanitize_items(data: dict, existing_titles: list[str],\\n                 or not (20 <= len(sum_zh) <= 400)):\\n             dropped += 1\\n             continue\\n+        published_at = None\\n+        if window is not None:\\n+            try:\\n+                published_at = datetime.fromisoformat(\\n+                    str(item.get(\"sourcePublishedAt\") or \"\").replace(\"Z\", \"+00:00\"))\\n+                if (published_at.tzinfo is None\\n+                        or not window.window_start <= published_at < window.window_end):\\n+                    raise ValueError(\"outside briefing window\")\\n+            except (TypeError, ValueError):\\n+                dropped += 1\\n+                continue\\n         refs = item.get(\"sourceChunks\")\\n         refs = [r for r in refs if isinstance(r, int)\\n                 and 0 <= r < len(chunks)] if isinstance(refs, list) else []\\n         cited = [chunks[r] for r in dict.fromkeys(refs)]\\n         cited = [c for c in cited\\n-                 if c[\"uri\"] and not _is_blocked(c)\\n+                 if _direct_web_url(c[\"uri\"])\\n+                 and not _is_blocked(c)\\n                  and not is_google_news_url(c[\"uri\"])]\\n         # forums only alongside at least one non-forum source\\n         if cited and all(_is_forum(c) for c in cited):\\n@@ -282,7 +306,8 @@ def sanitize_items(data: dict, existing_titles: list[str],\\n             \"headline_en\": str(item.get(\"headline_en\") or \"\").strip(),\\n             \"summary_en\": str(item.get(\"summary_en\") or \"\").strip(),\\n             \"event_time\": None,\\n-            \"source_published_at\": None,\\n+            \"source_published_at\": (published_at.isoformat()\\n+                                    if published_at else None),\\n             \"source_updated_at\": None,\\n             \"location\": None,\\n             \"entities\": [],\\n@@ -3,8 +3,7 @@\\n   <div style=\"max-width:860px;margin:0 auto;padding:28px 20px 64px;\">\\n     <a href=\"{{ base }}/{% if lang == 'en' %}en/{% endif %}briefings/\" class=\"btn btn-ghost\" style=\"margin-bottom:18px;\">← {{ t.brIndexTitle }}</a>\\n     <span class=\"card-kicker\">{{ t.brKicker }}</span>\\n-    <h1 style=\"font-size:32px;line-height:1.2;margin:10px 0 6px;\">✈️ {{ b.title }}<sup style=\"font-size:10px;font-weight:800;color:var(--color-bg);background:var(--color-accent);padding:2px 5px;margin-left:6px;vertical-align:super;letter-spacing:.05em;\">{{ t.brBeta }}</sup></h1>\\n-    <div style=\"font-size:17px;font-weight:700;margin-bottom:14px;\">{{ b.date_label }}</div>\\n+    <h1 style=\"font-size:32px;line-height:1.2;margin:10px 0 14px;\">✈️ [{{ b.date_iso }}] {{ b.title }}<sup style=\"font-size:10px;font-weight:800;color:var(--color-bg);background:var(--color-accent);padding:2px 5px;margin-left:6px;vertical-align:super;letter-spacing:.05em;\">{{ t.brBeta }}</sup></h1>\\n     <div class=\"text-muted\" style=\"font-size:13px;line-height:2;border-top:2px solid var(--color-divider);border-bottom:2px solid var(--color-divider);padding:10px 0;margin-bottom:6px;\">\\n       <div><span style=\"font-weight:800;\">{{ t.brWindow }}</span>：{{ b.window_start }} {{ t.brTo }} {{ b.window_end }}（UTC+8）</div>\\n       <div><span style=\"font-weight:800;\">{{ t.brCutoff }}</span>：{{ b.cutoff }}</div>\\n@@ -29,6 +28,7 @@ <h1 style=\"font-size:32px;line-height:1.2;margin:10px 0 6px;\">✈️ {{ b.title\\n     {% endif %}\\n     {% for sec in b.sections %}\\n     <h4 style=\"margin:26px 0 4px;\">{{ sec.label }}</h4>\\n+    {% if sec.lead %}<p style=\"font-size:14px;line-height:1.8;margin:6px 0 10px;\">{{ sec.lead }}</p>{% endif %}\\n     {% if sec.subsections %}\\n     {% for sub in sec.subsections %}\\n     <h6 style=\"margin:16px 0 4px;\">{{ sub.label }}</h6>\\n@@ -94,6 +94,16 @@ <h6 style=\"margin:16px 0 4px;\">{{ sub.label }}</h6>\\n     {% endfor %}\\n     {% endif %}\\n     {% endfor %}\\n+    {% if b.references %}\\n+    <section style=\"border-top:2px solid var(--color-divider);margin-top:32px;padding-top:16px;\">\\n+      <h4 style=\"margin:0 0 10px;\">{{ t.brReferences }}</h4>\\n+      <ul style=\"margin:0;padding-left:22px;line-height:1.8;\">\\n+        {% for source in b.references %}\\n+        <li><a href=\"{{ source.url }}\" target=\"_blank\" rel=\"noopener\">{{ source.name or source.url }}</a></li>\\n+        {% endfor %}\\n+      </ul>\\n+    </section>\\n+    {% endif %}\\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       <div style=\"display:flex;gap:10px 18px;flex-wrap:wrap;font-size:12.5px;\">\\n@@ -535,6 +535,10 @@ def _art_text(text):\\n       and briefing._has_marker(\\n           briefing._text_blob(_art_text(\"中共軍機臺海周邊動態\")),\\n           briefing._MILITARY_MARKERS))\\n+_raf = _art_text(\"英國皇家空軍 Hawk T2 教練機在威爾斯墜毀\")\\n+_raf[\"entities\"][\"organizations\"] = [\"Royal Air Force\"]\\n+check(\"RAF crash is a safety event even when the article category is ops\",\\n+      briefing.classify_section(_raf) == \"aviation_incidents\")\\n \\n import build as _b  # noqa: E402\\n \\n@@ -585,6 +589,19 @@ def _art_text(text):\\n _bv = _b.brief_view(_stale, \"zh\", _b.L[\"zh\"], set())\\n check(\"render gate includes verified articles by default\",\\n       _bv[\"total\"] == 3 and _bv[\"intro\"] == \"為合併版寫的導言\")\\n+_cited_item = dict(_stale_item, sources=[\\n+    {\"name\": \"Official\", \"url\": \"https://example.gov/report\"},\\n+    {\"name\": \"Repeated\", \"url\": \"https://example.gov/report\"},\\n+    {\"name\": \"Blocked\", \"url\": \"javascript:alert(1)\"},\\n+])\\n+_cited = dict(_stale, sections={\\n+    \"aviation_incidents\": [_cited_item],\\n+    \"international_aviation\": [_cited_item],\\n+})\\n+_cited_view = _b.brief_view(_cited, \"zh\", _b.L[\"zh\"], set())\\n+check(\"reference list contains only cited, deduplicated direct URLs\",\\n+      _cited_view[\"references\"] == [\\n+          {\"name\": \"Official\", \"url\": \"https://example.gov/report\"}])\\n os.environ[\"BRIEFING_INCLUDE_ARTICLES\"] = \"false\"\\n _bv2 = _b.brief_view(_stale, \"zh\", _b.L[\"zh\"], set())\\n os.environ[\"BRIEFING_INCLUDE_ARTICLES\"] = \"true\"\\n@@ -64,6 +64,7 @@ def item(section=\"aviation_incidents\", chunks=(0,), **kw):\\n         \"summary_en\": \"A private light aircraft crashed into a residential \"\\n                       \"roof in northwest Germany; both occupants died.\",\\n         \"severity\": \"fatal\", \"taiwan\": False, \"military\": False,\\n+        \"sourcePublishedAt\": \"2026-07-27T04:00:00+00:00\",\\n         \"sourceChunks\": list(chunks),\\n     }\\n     base.update(kw)\\n@@ -93,6 +94,20 @@ def item(section=\"aviation_incidents\", chunks=(0,), **kw):\\n       len(got) == 1 and got[0][\"origin\"] == \"grounded\"\\n       and got[0][\"sources\"][0][\"url\"]\\n       == \"https://vertexaisearch.example/redirect1\")\\n+window_for_items = types.SimpleNamespace(\\n+    window_start=NOW, window_end=NOW + timedelta(hours=24))\\n+dated, _ = grounded.sanitize_items(\\n+    resp_data([item()], CHUNKS), [], {}, NOW, window_for_items)\\n+check(\"grounded item retains its in-window source time\",\\n+      dated[\"aviation_incidents\"][0][\"source_published_at\"]\\n+      == \"2026-07-27T04:00:00+00:00\")\\n+for bad_date in (\"\", \"2026-07-26T23:59:59+00:00\",\\n+                 \"2026-07-28T00:00:00+00:00\", \"2026-07-27T04:00:00\"):\\n+    rejected, _ = grounded.sanitize_items(\\n+        resp_data([item(sourcePublishedAt=bad_date)], CHUNKS),\\n+        [], {}, NOW, window_for_items)\\n+    check(f\"missing, out-of-window or timezone-free date rejected: {bad_date!r}\",\\n+          rejected[\"aviation_incidents\"] == [])\\n check(\"item severity/marks metadata preserved\",\\n       got[0][\"severity\"] == \"fatal\" and got[0][\"event_id\"].startswith(\"gr-\"))\\n "
}
