Merge pull request #222 from nnneeeooo-nnneeeooo/codex/briefing-three-daily-reports
每天早、中、晚產生有來源的航空與交通快報
完整差異
@@ -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