{
  "sha": "867faafe27770f851b703ea1c2249ae8d06df1db",
  "message": "Add military news category\n\nAdds a Military category, reclassifies existing military stories at build time, persists future military stories as mil, and keeps the category control usable on mobile.",
  "author": {
    "login": "nnneeeooo-nnneeeooo",
    "name": "",
    "email": ""
  },
  "committer": {
    "login": "web-flow",
    "name": "",
    "email": ""
  },
  "created_at": "2026-07-27T07:08:12Z",
  "files": [
    {
      "filename": "pipeline/CONTRACTS.md",
      "previous_filename": "",
      "status": "modified",
      "additions": 1,
      "deletions": 1,
      "changes": 2
    },
    {
      "filename": "pipeline/build.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 14,
      "deletions": 5,
      "changes": 19
    },
    {
      "filename": "pipeline/common.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 61,
      "deletions": 1,
      "changes": 62
    },
    {
      "filename": "pipeline/write.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 10,
      "deletions": 4,
      "changes": 14
    },
    {
      "filename": "static/site.css",
      "previous_filename": "",
      "status": "modified",
      "additions": 13,
      "deletions": 0,
      "changes": 13
    },
    {
      "filename": "tests/test_categories.py",
      "previous_filename": "",
      "status": "added",
      "additions": 104,
      "deletions": 0,
      "changes": 104
    }
  ],
  "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()"
}
