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

提交原始紀錄

補回星宇聯營及越航期中報告來源,縮短故障模型等待 (#231) * Backfill missed codeshare and safety-report discovery with bounded expiry * Align offline fixtures with per-quote and entity evidence contracts * Keep FAA-only test evidence free of unrelated IATA metadata

SHA
a0fd69b602e23054be269549559affc6f136b686
提交時間
作者
nnneeeooo-nnneeeooo
提交者
web-flow

變更檔案 (7)

  • .github/workflows/hourly.yml modified · +5 / −1
  • data/news-recovery.json added · +31 / −0
  • pipeline/dedupe.py modified · +26 / −2
  • pipeline/fulltext.py modified · +2 / −0
  • tests/test_dedupe.py modified · +14 / −13
  • tests/test_draft_recovery.py modified · +26 / −0
  • tests/test_write_offline.py modified · +28 / −5

完整差異

@@ -13,6 +13,8 @@ on:\n       - "pipeline/flightnews.py"\n       - "pipeline/write.py"\n       - "pipeline/common.py"\n+      - "pipeline/dedupe.py"\n+      - "pipeline/fulltext.py"\n       - "pipeline/draft_recovery.py"\n       - "pipeline/manual_draft.py"\n       - "pipeline/airport_codes.py"\n@@ -58,6 +60,8 @@ jobs:\n           python tests/test_manufacturer_sources.py\n           python tests/test_source_links.py\n           python tests/test_draft_recovery.py\n+          python tests/test_dedupe.py\n+          python tests/test_write_offline.py\n \n   update:\n     if: github.event_name != 'pull_request'\n@@ -120,7 +124,7 @@ jobs:\n           AVWIRE_GEMINI_MODEL: ${{ vars.AVWIRE_GEMINI_MODEL }}\n           AVWIRE_NVIDIA_MODEL: ${{ vars.AVWIRE_NVIDIA_MODEL }}\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+          AVWIRE_PROVIDER_ORDER: gemini:gemini-3.6-flash,gemini:gemini-3.5-flash,wechat:Deepseek-v4-flash,nvidia:nvidia/nemotron-3-ultra-550b-a55b,openrouter:nvidia/nemotron-3-ultra-550b-a55b:free,nvidia:nvidia/nemotron-3-super-120b-a12b\n           # freshness window (hours); code default 120, validated 24-336\n           NEWS_MAX_AGE_HOURS: ${{ vars.NEWS_MAX_AGE_HOURS }}\n           # private usage-dashboard URL token (page skipped when unset)\n@@ -0,0 +1,31 @@\n+{\n+  "_comment": "User-requested recovery of missed news, verified against publisher pages on 2026-09-30. Original source dates are retained; this temporary discovery input expires and normal seen/evidence gates remain authoritative.",\n+  "expiresUtc": "2026-10-07T00:00Z",\n+  "items": [\n+    {\n+      "title": "STARLUX Airlines and American Airlines launch codeshare partnership",\n+      "url": "https://latestnews.starlux-airlines.com/en-TW/about-us/travel-advisories/advisories/latest-news/aa-codeshare",\n+      "publishedUtc": "2026-09-23T00:00Z",\n+      "summary": "with tickets available starting September 23, 2026, and flights officially launching on September 30, 2026! 20 popular North American cities",\n+      "source": "STARLUX Airlines",\n+      "sourceKey": "starlux-official-recovery"\n+    },\n+    {\n+      "title": "American Airlines and STARLUX Airlines launch codeshare partnership",\n+      "url": "https://news.aa.com/news/news-details/2026/American-Airlines-and-STARLUX-Airlines-launch-codeshare-partnership-to-expand-trans-Pacific-flight-options-NET-ALP-09/default.aspx",\n+      "publishedUtc": "2026-09-23T00:00Z",\n+      "dateInferred": true,\n+      "summary": "American Airlines and STARLUX Airlines have launched a new codeshare partnership connecting North America with Taipei (TPE).",\n+      "source": "American Airlines",\n+      "sourceKey": "american-official-recovery"\n+    },\n+    {\n+      "title": "BFU interim findings on Vietnam Airlines Munich runway accident",\n+      "url": "https://aerospaceglobalnews.com/news/vietnam-airlines-munich-runway-incident-investigation/",\n+      "publishedUtc": "2026-09-29T00:00Z",\n+      "summary": "The BFU stresses that it has not yet reached a final conclusion about the cause of the accident.",\n+      "source": "Aerospace Global News",\n+      "sourceKey": "aerospaceglobalnews"\n+    }\n+  ]\n+}\n@@ -189,6 +189,24 @@ def _load_raw_items() -> list[dict]:\n             item["source"] = _source_label(source, item)\n             item["sourceKey"] = key\n             items.append(item)\n+    # Explicit editorial recovery inputs are bounded by an expiry and retain\n+    # the source's original date. They are still subject to unseen, source,\n+    # prompt-injection and evidence gates; no article is published here.\n+    recovery = load_json(DATA_DIR / "news-recovery.json", {})\n+    if isinstance(recovery, dict):\n+        try:\n+            until = parse_iso(str(recovery.get("expiresUtc") or ""))\n+        except (TypeError, ValueError):\n+            until = None\n+        if until is not None and now_utc() < until:\n+            for raw in recovery.get("items") or []:\n+                if (not isinstance(raw, dict)\n+                        or not str(raw.get("url") or "").startswith("https://")\n+                        or not raw.get("source") or not raw.get("sourceKey")):\n+                    continue\n+                item = dict(raw)\n+                item["_recoveryUntilUtc"] = iso_minute(until)\n+                items.append(item)\n     return items\n \n \n@@ -220,8 +238,13 @@ def _is_fresh(item: dict, now: datetime, stats: dict) -> bool:\n     published = _published(item)\n     if published is not None:\n         if now - published > timedelta(hours=MAX_ITEM_AGE_HOURS):\n-            stats["skipped_too_old"] += 1\n-            return False\n+            try:\n+                recovery_until = parse_iso(str(item.get("_recoveryUntilUtc") or ""))\n+            except (TypeError, ValueError):\n+                recovery_until = None\n+            if recovery_until is None or now >= recovery_until:\n+                stats["skipped_too_old"] += 1\n+                return False\n         if published - now > timedelta(hours=FUTURE_SKEW_HOURS):\n             # Beyond timezone/parse skew: a source clock error, not news.\n             stats["skipped_future_date"] += 1\n@@ -448,3 +471,4 @@ def main() -> None:\n \n if __name__ == "__main__":\n     main()\n+\n@@ -90,6 +90,7 @@\n     "english.comac.cc", "www.comac.cc", "comac.cc",\n     # Editorial sources with direct article URLs. robots.txt is still checked\n     # on every host and any unreachable/denied policy fails closed.\n+    "latestnews.starlux-airlines.com", "news.aa.com",\n     "www.aerotime.aero", "aerotime.aero",\n     "www.aerospaceglobalnews.com", "aerospaceglobalnews.com",\n     "www.flightglobal.com", "flightglobal.com",\n@@ -242,3 +243,4 @@ def enrich_pending(groups: list) -> int:\n     if fetches or enriched:\n         print(f"fulltext: {fetches} fetch(es), {enriched} item(s) enriched")\n     return enriched\n+\n@@ -142,9 +142,10 @@ def test_filtering_grouping_and_ranking() -> None:\n         # ok=false snapshots are carried forward by fetch.py — items survive.\n         assert "EASA carried-forward item survives source flap" in all_titles\n \n-        # Group 1: the multi-source Denver story ranks first; Reuters has\n-        # the longer summary so it is primary and its item comes first.\n-        denver = groups[0]\n+        # Safety investigation updates may rank first. Within the Denver\n+        # event, Reuters has the longer summary and remains primary.\n+        denver = next(g for g in groups if {i["sourceKey"] for i in g["items"]}\n+                      == {"faa", "reuters"})\n         assert {i["sourceKey"] for i in denver["items"]} == {"faa", "reuters"}\n         assert denver["primarySource"] == "Reuters"\n         assert denver["items"][0]["sourceKey"] == "reuters"\n@@ -155,16 +156,15 @@ def test_filtering_grouping_and_ranking() -> None:\n         }\n         assert denver["items"][0]["image"] == "https://www.reuters.com/img/denver.jpg"\n \n-        # Remaining single-source groups ranked by newest publishedUtc. The\n-        # Boeing URL is new, so its seen-title match becomes an article update.\n-        assert groups[1]["items"][0]["sourceKey"] == "easa"\n-        assert groups[2]["items"][0]["title"] == \\n-            "Boeing delivers 100th 787 to Emirates!"\n-        assert groups[2]["updateCandidate"] is True\n-        assert groups[3]["items"][0]["title"] == (\n-            "Airbus opens new A320 production line in Toulouse"\n-        )  # seen-title match is 30 days old -> outside the 21-day window\n-        assert groups[4]["items"][0]["title"].startswith("NTSB opens investigation")\n+        # Investigation updates get priority, then material/multi-source\n+        # events; the remaining ordinary groups retain recency ordering.\n+        assert groups[0]["editorialPriority"] == "major"\n+        assert groups[0]["items"][0]["title"].startswith("NTSB opens investigation")\n+        assert groups[1] is denver\n+        assert groups[2]["items"][0]["sourceKey"] == "easa"\n+        assert groups[3]["items"][0]["title"] == "Boeing delivers 100th 787 to Emirates!"\n+        assert groups[3]["updateCandidate"] is True\n+        assert groups[4]["items"][0]["title"] == "Airbus opens new A320 production line in Toulouse"\n \n         # Balloon group fits the six-item current-source cap.\n         balloons = groups[5]\n@@ -498,3 +498,4 @@ def main() -> None:\n \n if __name__ == "__main__":\n     main()\n+\n@@ -1,11 +1,16 @@\n """Offline regression tests: presentation recovery never rewrites evidence."""\n import sys\n import unittest\n+import json\n+import tempfile\n+from datetime import datetime, timedelta, timezone\n+from unittest.mock import patch\n from pathlib import Path\n \n sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "pipeline"))\n from draft_recovery import normalize_reader_copy, repair_prompt\n import common\n+import dedupe\n \n \n class Converter:\n@@ -42,6 +47,27 @@ def test_retry_keeps_sources_and_feedback_without_padding_permission(self):\n         self.assertIn("preserve verbatim source quotes", prompt)\n         self.assertIn("Do not add facts", prompt)\n \n+    def test_recovery_expiry_original_dates_and_seen_gate(self):\n+        now = datetime(2026, 9, 30, 12, tzinfo=timezone.utc)\n+        old = {"title": "STARLUX launches American Airlines codeshare", "url": "https://example.com/news",\n+               "publishedUtc": "2026-09-23T00:00Z", "source": "Publisher", "sourceKey": "publisher"}\n+        with tempfile.TemporaryDirectory() as tmp:\n+            root = Path(tmp)\n+            path = root / "news-recovery.json"\n+            path.write_text(json.dumps({"expiresUtc": "2026-10-07T00:00Z", "items": [old]}))\n+            with patch.object(dedupe, "DATA_DIR", root), patch.object(dedupe, "RAW_DIR", root / "raw"), patch.object(dedupe, "now_utc", lambda: now):\n+                rows = dedupe._load_raw_items()\n+                self.assertEqual(len(rows), 1)\n+                self.assertEqual(rows[0]["publishedUtc"], old["publishedUtc"])\n+                stats = {key: 0 for key in ("skipped_untitled", "skipped_too_old", "skipped_future_date")}\n+                self.assertTrue(dedupe._is_fresh(rows[0], now, stats))\n+                self.assertFalse(dedupe._is_unseen(rows[0], {common.norm_url(old["url"])}, [])[0])\n+                future = {**rows[0], "publishedUtc": "2026-11-01T00:00Z"}\n+                self.assertFalse(dedupe._is_fresh(future, now, stats))\n+                path.write_text(json.dumps({"expiresUtc": "2026-09-29T00:00Z", "items": [old]}))\n+                self.assertEqual(dedupe._load_raw_items(), [])\n+                self.assertFalse(dedupe._is_fresh(rows[0], now + timedelta(days=8), stats))\n+\n     def test_safety_findings_and_codeshare_are_retained_before_enrichment(self):\n         for title in (\n             "Vietnam Airlines Munich runway incident: Investigation reveals brakes activated during takeoff roll",\n@@ -70,7 +70,7 @@\n     "facts": [\n         {"factId": "F1",\n          "claim": "NTSB 已對丹佛 737 衝出跑道事件展開調查",\n-         "sourceQuote": "NTSB said it is investigating a runway excursion"},\n+         "sourceQuote": "The NTSB said it is investigating a runway excursion involving a United Airlines Boeing 737-8 at Denver International Airport during landing on July 25."},\n         {"factId": "F2",\n          "claim": "無人受傷",\n          "sourceQuote": "No injuries were reported."},\n@@ -122,7 +122,7 @@\n          "sourceQuote": "rose 8.2% in June 2026"},\n         {"factId": "F2",\n          "claim": "需求以貨運噸公里計算並與 2025 年六月比較",\n-         "sourceQuote": "measured in cargo tonne-kilometers"},\n+         "sourceQuote": "measured in cargo tonne-kilometers, rose 8.2% in June 2026 compared with June 2025."},\n     ],\n     "headlineSupportedBy": ["F1"],\n     "summarySupportedBy": ["F1", "F2"],\n@@ -150,6 +150,22 @@\n     "incident": None,\n }\n \n+# Fixtures must satisfy the current per-entity evidence contract. Keep the\n+# source quote intact while normalizing displayed Chinese as production does.\n+DRAFT_SAFETY["entityEvidence"] = [\n+    {"entityType": key, "value": value,\n+     "sourceQuote": DRAFT_SAFETY["facts"][0]["sourceQuote"],\n+     "sourceUrl": "https://www.ntsb.gov/news/press-releases/denver-737"}\n+    for key, values in DRAFT_SAFETY["entities"].items() for value in values\n+]\n+DRAFT_BIZ["entityEvidence"] = [{\n+    "entityType": "organizations", "value": "International Air Transport Association",\n+    "sourceQuote": "The International Air Transport Association reported",\n+    "sourceUrl": "https://www.iata.org/en/pressroom/2026-releases/2026-07-24-01/",\n+}]\n+DRAFT_SAFETY = write.normalize_reader_copy(DRAFT_SAFETY, write._S2T)\n+DRAFT_BIZ = write.normalize_reader_copy(DRAFT_BIZ, write._S2T)\n+\n _pass = 0\n _fail = 0\n \n@@ -429,6 +445,8 @@ def test_group_cap_and_unique_ids():\n          "sourceQuote": "operational details for certificate holders"},\n     ]\n     cap_draft["summarySupportedBy"] = ["F1", "F2"]\n+    cap_draft["entities"] = dict(_EMPTY_ENTITIES)\n+    cap_draft["entityEvidence"] = []\n     original = write.draft_group\n     write.draft_group = lambda client, group: cap_draft\n     try:\n@@ -589,7 +607,8 @@ def test_extract_json_and_validate_draft():\n         retrying_provider, cargo_group, tries=2)\n     check(retrying_provider.calls == 2,\n           "short body automatically triggers the allowed retry")\n-    check(candidate == DRAFT_BIZ and facts,\n+    check(candidate["zh"] == DRAFT_BIZ["zh"]\n+          and write.validate_draft(candidate) is None and facts,\n           "length-compliant retry survives validation and quote checks")\n     check(write.validate_draft("nope") is not None, "non-dict rejected")\n     broken = json.loads(json.dumps(DRAFT_BIZ))\n@@ -701,8 +720,8 @@ def test_provider_failover():\n           "fallback publication does not create review entries")\n     check(primary.calls == 1,\n           f"dead primary is never called again, got {primary.calls} calls")\n-    check(middle.calls == 3 and backup.calls == 3,\n-          "surviving providers tried once per group")\n+    check(middle.calls == 6 and backup.calls == 3,\n+          "first responsive fallback gets one bounded repair per group")\n     pending = load(DATA / "pending.json")\n     ids = [g["id"] for g in pending["groups"]]\n     check(ids == ["g-20260726-0503-3"],\n@@ -848,6 +867,7 @@ def test_editorial_gate():\n     fabricated = json.loads(json.dumps(DRAFT_BIZ))\n     fabricated["facts"] = [{"factId": "F1", "claim": "完全捏造的主張",\n                             "sourceQuote": "this text appears nowhere"}]\n+    fabricated["summarySupportedBy"] = ["F1"]\n     partial = json.loads(json.dumps(DRAFT_BIZ))\n     partial["facts"] = [\n         {"factId": "F1", "claim": "FAA 發布鬧事乘客執法聲明",\n@@ -859,6 +879,8 @@ def test_editorial_gate():\n     ]\n     partial["headlineSupportedBy"] = ["F1"]\n     partial["summarySupportedBy"] = ["F1", "F2"]\n+    partial["entities"] = dict(_EMPTY_ENTITIES)\n+    partial["entityEvidence"] = []\n     # g1 -> editorial reject; g2 -> all quotes fabricated; g3 -> one good.\n     solo = FakeProvider("gemini", [reject_draft, fabricated, partial])\n \n@@ -1042,3 +1064,4 @@ def main():\n \n if __name__ == "__main__":\n     main()\n+

你發現了隱藏主題!

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