2026-10-20 TUE UTC 12:42 PM TPE 8:42 PM Auto-updates hourly 中文 EN
LIVE WIRE
← Back to the site changelog Site Changelog

Original commit record

Add military news category Adds a Military category, reclassifies existing military stories at build time, persists future military stories as mil, and keeps the category control usable on mobile.

SHA
867faafe27770f851b703ea1c2249ae8d06df1db
Commit date
Author
nnneeeooo-nnneeeooo
Committer
web-flow

Changed files (6)

  • pipeline/CONTRACTS.md modified · +1 / −1
  • pipeline/build.py modified · +14 / −5
  • pipeline/common.py modified · +61 / −1
  • pipeline/write.py modified · +10 / −4
  • static/site.css modified · +13 / −0
  • tests/test_categories.py added · +104 / −0

Full diff

@@ -103,7 +103,7 @@ absent when nothing new that hour)\n     {\n       "id": "a-20260726-0547-denver737",   // a-YYYYMMDD-HHMM-<slug>\n       "publishedUtc": "2026-07-26T05:47Z",\n-      "cat": "safety",                     // safety | reg | biz | ops\n+      "cat": "safety",                     // safety | reg | biz | ops | mil\n       "primarySource": "Reuters",\n       "image": null,                       // og:image URL or null\n       "zh": {"title": "...", "summary": "<=80字", "body": ["段落1", "段落2"]},\n@@ -37,6 +37,7 @@\n     norm_url,\n     now_utc,\n     parse_iso,\n+    story_category,\n )\n \n # Asia/Taipei via zoneinfo where tz data exists (Linux CI); the zone has had a\n@@ -85,7 +86,7 @@\n         "footerAbout": "全自動航空新聞聚合站。GitHub Actions 每小時抓取各可信來源,自動撰寫並發布,每篇文末標註原始出處。本頁為設計原型,內容為示意樣本。",\n         "footerSources": "資料來源 Data Sources",\n         "nav": ["最新", "快報", "事故資料庫", "來源", "方法論"],\n-        "cats": ["全部", "事故", "法規", "商業", "營運"],\n+        "cats": ["全部", "事故", "法規", "商業", "營運", "軍事"],\n         # daily briefings\n         "brKicker": "Daily Briefing", "brIndexTitle": "快報",\n         "brIndexSub": "每日三次(臺北時間 07:15/15:15/23:15)的條列式全球航空與交通要聞快報,由具搜尋能力的模型彙整(Beta);本站正式新聞請見「最新」。",\n@@ -146,7 +147,8 @@\n         "footerAbout": "A fully automated aviation news aggregator. GitHub Actions fetches trusted sources hourly, writes and publishes automatically, and credits originals at the end of every article. Design prototype; sample content.",\n         "footerSources": "Data Sources",\n         "nav": ["Latest", "Briefings", "Incident DB", "Sources", "Methodology"],\n-        "cats": ["All", "Safety", "Regulation", "Business", "Operations"],\n+        "cats": ["All", "Safety", "Regulation", "Business", "Operations",\n+                 "Military"],\n         # daily briefings\n         "brKicker": "Daily Briefing", "brIndexTitle": "Briefings",\n         "brIndexSub": "A bulleted global air & transport bulletin three times daily (07:15 / 15:15 / 23:15 Taipei time), compiled by a search-capable model (Beta); the site's formal articles live under Latest.",\n@@ -188,6 +190,7 @@\n     "reg": {"zh": "法規", "en": "Regulation"},\n     "biz": {"zh": "商業", "en": "Business"},\n     "ops": {"zh": "營運", "en": "Operations"},\n+    "mil": {"zh": "軍事", "en": "Military"},\n }\n SEV = {\n     "acc": {"zh": "事故", "en": "Accident", "cls": "tag-accent"},\n@@ -205,7 +208,13 @@\n     "media": {"zh": "新聞媒體", "en": "News media"},\n     "data": {"zh": "數據平台", "en": "Data platform"},\n }\n-TAGCLS = {"safety": "tag-accent", "reg": "tag-neutral", "biz": "tag-neutral", "ops": "tag-neutral"}\n+TAGCLS = {\n+    "safety": "tag-accent",\n+    "reg": "tag-neutral",\n+    "biz": "tag-neutral",\n+    "ops": "tag-neutral",\n+    "mil": "tag-accent-2",\n+}\n \n \n # ── methodology page (user-authored copy, 2026-07-27) ────────────────────────\n@@ -767,7 +776,7 @@ def side(own, other):\n         # Hard site rule: every published article must credit >=1 source.\n         return None\n \n-    cat = raw.get("cat") if isinstance(raw.get("cat"), str) else "ops"\n+    cat = story_category(raw.get("cat"), raw)\n     image = normalize_image(raw.get("image"))\n     # Display the SOURCE's newest publication time when the write stage\n     # recorded one; the generation time (dt) is used for ordering only, so\n@@ -1413,7 +1422,7 @@ def main() -> int:\n         "tpe_hm": tpe_now.strftime("%H:%M"),\n         "stamp": now.strftime("%Y-%m-%d %H:%M"),\n     }\n-    cat_keys = ["all", "safety", "reg", "biz", "ops"]\n+    cat_keys = ["all", "safety", "reg", "biz", "ops", "mil"]\n     sev_keys = ["all", "acc", "ser", "inc"]\n     pages = 0\n     failed = 0\n@@ -336,7 +336,67 @@ def group_has_material(group: dict) -> bool:\n     },\n }\n \n-CATEGORIES = ("safety", "reg", "biz", "ops")\n+CATEGORIES = ("safety", "reg", "biz", "ops", "mil")\n+\n+# Military takes precedence over the other editorial categories.  Keep the\n+# terms deliberately specific: generic aviation-security language such as\n+# "counter-drone" or "defence against interference" must not turn a civil FAA\n+# story into military news.\n+_MILITARY_ZH_TERMS = (\n+    "國防部",\n+    "國軍",\n+    "共軍",\n+    "共機",\n+    "共艦",\n+    "解放軍",\n+    "軍聞社",\n+    "漢光",\n+    "軍事",\n+    "軍機",\n+    "軍艦",\n+    "戰機",\n+    "轟炸機",\n+    "空軍",\n+    "海軍",\n+    "陸軍",\n+    "國防預算",\n+    "中科院",\n+    "聯合防禦操演",\n+    "戰略預備隊",\n+)\n+_MILITARY_EN_RE = re.compile(\n+    r"\b(?:"\n+    r"military|armed forces|people(?:'|’)?s liberation army|pla|"\n+    r"air force|navy|warships?|fighter jets?|fighter aircraft|bombers?|"\n+    r"taiwan mnd|mndnews|mnd\.gov\.tw|"\n+    r"(?:ministry|department) of (?:national )?defen[cs]e|"\n+    r"defen[cs]e (?:ministry|minister|budget)|pentagon|"\n+    r"han kuang|joint defen[cs]e exercise|strategic reserve|ncsist"\n+    r")\b",\n+    re.IGNORECASE,\n+)\n+\n+\n+def is_military_story(*records) -> bool:\n+    """Return True when article/draft/group material is explicitly military.\n+\n+    The accepted records are JSON-shaped pipeline objects.  Serialising them\n+    keeps this guard compatible with published articles, pending groups and\n+    review drafts without maintaining three subtly different field walkers.\n+    """\n+    try:\n+        text = json.dumps(records, ensure_ascii=False, default=str)\n+    except (TypeError, ValueError):\n+        text = " ".join(str(record) for record in records)\n+    return (any(term in text for term in _MILITARY_ZH_TERMS)\n+            or _MILITARY_EN_RE.search(text) is not None)\n+\n+\n+def story_category(category, *records) -> str:\n+    """Return the canonical category, with explicit military material first."""\n+    if is_military_story(*records):\n+        return "mil"\n+    return category if category in CATEGORIES else "ops"\n \n \n def now_utc() -> datetime:\n@@ -34,6 +34,7 @@\n \n from common import (\n     ARTICLES_DIR,\n+    CATEGORIES,\n     DATA_DIR,\n     MAX_FLASHES,\n     RAW_DIR,\n@@ -46,6 +47,7 @@\n     save_json,\n     slugify,\n     squash_text,\n+    story_category,\n )\n import flightnews\n from fulltext import FULLTEXT_PROMPT_CHARS, enrich_pending\n@@ -160,7 +162,7 @@\n         "status": {"type": "string",\n                    "enum": ["publish", "manual_review", "reject"]},\n         "decisionReason": {"type": "string"},\n-        "cat": {"type": "string", "enum": ["safety", "reg", "biz", "ops"]},\n+        "cat": {"type": "string", "enum": list(CATEGORIES)},\n         "zh": _LANG_SCHEMA,\n         "en": _LANG_SCHEMA,\n         "flash": {\n@@ -355,7 +357,11 @@\n   correct. Never pad, repeat, editorialize or add background the material\n   does not state to reach a length.\n - cat: classify the story as exactly one of:\n-  safety (事故/飛安), reg (法規/監理), biz (商業/財務), ops (營運/航網).\n+  safety (事故/飛安), reg (法規/監理), biz (商業/財務), ops (營運/航網),\n+  mil (軍事/國防/軍用航空). Military takes priority: use mil whenever the\n+  story concerns armed forces, military aircraft or vessels, exercises,\n+  defence ministries or military activity, even if it also concerns safety,\n+  regulation, business or operations.\n - flash: a one-line news flash in both languages; the zh flash is at most\n   40 characters. Set hot=true ONLY for breaking safety events.\n - incident: fill this object ONLY when cat="safety" AND the story describes\n@@ -584,7 +590,7 @@ def validate_draft(draft):\n         return f"bad status {status!r}"\n     if status == "reject":\n         return None\n-    if draft.get("cat") not in ("safety", "reg", "biz", "ops"):\n+    if draft.get("cat") not in CATEGORIES:\n         return f"bad cat {draft.get('cat')!r}"\n     for lang in ("zh", "en"):\n         block = draft.get(lang)\n@@ -773,7 +779,7 @@ def build_article(draft: dict, group: dict, now, used_ids: set, writer=None,\n     article = {\n         "id": article_id,\n         "publishedUtc": iso_minute(now),\n-        "cat": draft.get("cat", "ops"),\n+        "cat": story_category(draft.get("cat", "ops"), group, draft),\n         "primarySource": group.get("primarySource", ""),\n         "image": image,\n         "zh": draft.get("zh", {}),\n@@ -155,3 +155,16 @@ a.flash-row:hover { color: inherit; }\n   .rail-right { border-left: 0; border-top: 2px solid var(--color-divider); }\n   .footer-grid { grid-template-columns: 1fr; }\n }\n+\n+@media (max-width: 640px) {\n+  .seg-cat {\n+    display: grid;\n+    grid-template-columns: repeat(3, minmax(0, 1fr));\n+    width: 100%;\n+  }\n+  .seg-cat .seg-btn { justify-content: center; }\n+  .seg-cat .seg-btn:nth-child(4) { border-left: 0; }\n+  .seg-cat .seg-btn:nth-child(n + 4) {\n+    border-top: 1px solid var(--color-divider);\n+  }\n+}\n@@ -0,0 +1,104 @@\n+"""Offline tests for AVWIRE's editorial category contract."""\n+from __future__ import annotations\n+\n+import sys\n+from datetime import datetime, timezone\n+from pathlib import Path\n+\n+ROOT = Path(__file__).resolve().parent.parent\n+sys.path.insert(0, str(ROOT / "pipeline"))\n+\n+import build  # noqa: E402\n+import common  # noqa: E402\n+import write  # noqa: E402\n+\n+\n+def article(title: str, category: str = "ops") -> dict:\n+    return {\n+        "id": "a-20260727-1200-category-test",\n+        "publishedUtc": "2026-07-27T12:00Z",\n+        "cat": category,\n+        "primarySource": "中央社 CNA",\n+        "image": None,\n+        "zh": {"title": title, "summary": "測試摘要", "body": ["測試內文。"]},\n+        "en": {"title": "Category test", "summary": "Test summary.",\n+               "body": ["Test body."]},\n+        "sources": [{"name": "中央社 CNA",\n+                     "url": "https://www.cna.com.tw/news/test.aspx"}],\n+    }\n+\n+\n+def valid_draft(category: str) -> dict:\n+    return {\n+        "status": "publish",\n+        "decisionReason": "",\n+        "cat": category,\n+        "zh": {"title": "測試標題", "summary": "測試摘要",\n+               "body": ["測試內文。"]},\n+        "en": {"title": "Test title", "summary": "Test summary.",\n+               "body": ["Test body."]},\n+        "flash": {"zh": "測試快訊", "en": "Test flash", "hot": False},\n+        "incident": None,\n+        "facts": [{"factId": "F1", "claim": "Test claim",\n+                   "sourceQuote": "Test source quote"}],\n+        "headlineSupportedBy": ["F1"],\n+        "summarySupportedBy": ["F1"],\n+        "entities": {key: [] for key in write.ENTITY_KEYS},\n+        "eventStatus": "confirmed",\n+        "riskFlags": [],\n+        "requiresHumanReview": False,\n+    }\n+\n+\n+def main() -> None:\n+    assert common.CATEGORIES == ("safety", "reg", "biz", "ops", "mil")\n+    assert write.DRAFT_SCHEMA["properties"]["cat"]["enum"] \\n+        == list(common.CATEGORIES)\n+    assert write.validate_draft(valid_draft("mil")) is None\n+\n+    published = build.prep_article(\n+        article("國防部公布共機艦動態", category="ops"))\n+    assert published is not None\n+    assert published["cat"] == "mil"\n+    assert build.CATS["mil"] == {"zh": "軍事", "en": "Military"}\n+    assert build.TAGCLS["mil"] == "tag-accent-2"\n+    assert build.L["zh"]["cats"][-1] == "軍事"\n+    assert build.L["en"]["cats"][-1] == "Military"\n+\n+    group = {\n+        "id": "g-military-test",\n+        "primarySource": "中央社 CNA",\n+        "items": [{\n+            "sourceKey": "cnataiwan",\n+            "source": "中央社 CNA",\n+            "title": "漢光演習驗證國軍聯合作戰能力",\n+            "summary": "國防部說明演習規劃。",\n+            "url": "https://www.cna.com.tw/news/test.aspx",\n+            "publishedUtc": "2026-07-27T11:00Z",\n+        }],\n+    }\n+    drafted_as_ops = valid_draft("ops")\n+    military_article = write.build_article(\n+        drafted_as_ops, group, datetime.now(timezone.utc), set())\n+    assert military_article is not None\n+    assert military_article["cat"] == "mil"\n+\n+    civil_counter_drone = {\n+        "title": "FAA reports counter-drone enforcement at World Cup",\n+        "summary": "The civil aviation regulator seized unauthorised drones.",\n+        "sourceKey": "faa",\n+    }\n+    assert common.story_category("reg", civil_counter_drone) == "reg"\n+    assert common.story_category("biz", {\n+        "title": "PLA builds models of Taiwan facilities",\n+    }) == "mil"\n+    assert common.story_category("ops", {\n+        "sourceKey": "mndnews",\n+        "title": "Daily activity report",\n+    }) == "mil"\n+\n+    print("test_categories: OK")\n+\n+\n+if __name__ == "__main__":\n+    main()

You found a secret theme!

8-bit mode is unlocked. Refresh to add it to your theme selector.