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

提交原始紀錄

Merge pull request #222 from nnneeeooo-nnneeeooo/codex/briefing-three-daily-reports 每天早、中、晚產生有來源的航空與交通快報

SHA
3e19d27e68c28e9469d53a4088081ee34343ec90
提交時間
作者
nnneeeooo-nnneeeooo
提交者
web-flow

變更檔案 (7)

  • .github/workflows/briefing.yml modified · +4 / −5
  • pipeline/briefing.py modified · +6 / −3
  • pipeline/build.py modified · +30 / −0
  • pipeline/grounded.py modified · +30 / −5
  • templates/briefing.html modified · +12 / −2
  • tests/test_briefing.py modified · +17 / −0
  • tests/test_grounded.py modified · +15 / −0

完整差異

@@ -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 

你發現了隱藏主題!

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