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.
完整差異
@@ -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()