{
  "sha": "7f4234edf5a42e6e85b6180a29cb007c4075b698",
  "message": "Rare-aircraft observations auto-publish through a machine gate\n\nOwner policy change (2026-07-27): routine rare-aircraft observation\nitems no longer wait for a human. write.py now publishes a drafted\nflight event directly when ALL of these hold:\n\n- ADS-B sources are not in conflict (crossCheck != conflicting_sources)\n- the draft is not safety-flavoured (cat != safety, incident == null,\n  no accident/casualty/investigation/conflict risk flags)\n- validation and verbatim-quote verification passed as before\n\nAnything else still goes to data/review.json exactly as promised on the\npublic methodology page (safety events remain human-gated). Repo var\nFLIGHT_AUTO_PUBLISH=false restores review-everything behaviour; the\nflight prompt no longer asks the model to mention any review workflow\ninside article text (publication policy is code's job, not the\nmodel's).\n\ntests: test_flightwatch.py reworked - auto-publish happy path, conflict\ngate, safety-flag gate, kill switch; all 8 suites green.\n\nCo-Authored-By: Claude Fable 5 <noreply@anthropic.com>",
  "author": {
    "login": "avwire",
    "name": "avwire",
    "email": ""
  },
  "committer": {
    "login": "avwire",
    "name": "avwire",
    "email": ""
  },
  "created_at": "2026-07-26T22:36:45Z",
  "files": [
    {
      "filename": ".github/workflows/hourly.yml",
      "previous_filename": "",
      "status": "modified",
      "additions": 2,
      "deletions": 0,
      "changes": 2
    },
    {
      "filename": "pipeline/write.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 67,
      "deletions": 13,
      "changes": 80
    },
    {
      "filename": "prompts/rare-aircraft-news-zh.txt",
      "previous_filename": "",
      "status": "modified",
      "additions": 6,
      "deletions": 3,
      "changes": 9
    },
    {
      "filename": "tests/test_flightwatch.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 72,
      "deletions": 20,
      "changes": 92
    }
  ],
  "diff": "@@ -51,6 +51,8 @@ jobs:\\n           NEWS_MAX_AGE_HOURS: ${{ vars.NEWS_MAX_AGE_HOURS }}\\n           # private usage-dashboard URL token (page skipped when unset)\\n           AVWIRE_USAGE_TOKEN: ${{ secrets.AVWIRE_USAGE_TOKEN }}\\n+          # rare-aircraft auto-publish kill switch (code default: on)\\n+          FLIGHT_AUTO_PUBLISH: ${{ vars.FLIGHT_AUTO_PUBLISH }}\\n         run: |\\n           python pipeline/fetch.py\\n           python pipeline/dedupe.py\\n@@ -27,6 +27,7 @@\\n \"\"\"\\n from __future__ import annotations\\n \\n+import os\\n import re\\n from datetime import timedelta\\n \\n@@ -839,13 +840,47 @@ def _review_entry(candidate: dict, group: dict, writer: str, facts: list,\\n     }\\n \\n \\n+# Machine gate for rare-aircraft auto-publish (owner's 2026-07-27 policy:\\n+# routine observation items publish without a human). Anything that smells\\n+# of a safety event, a source conflict or an incident still goes to the\\n+# human queue - the public methodology page promises that.\\n+_FLIGHT_BLOCKING_FLAGS = {\\n+    \"accident_or_serious_incident\", \"casualties_or_injuries\",\\n+    \"investigation\", \"conflicting_information\",\\n+}\\n+\\n+\\n+def _flight_auto_publish_enabled() -> bool:\\n+    # default ON per the owner; repo var FLIGHT_AUTO_PUBLISH=false restores\\n+    # the review-everything behaviour\\n+    return (os.environ.get(\"FLIGHT_AUTO_PUBLISH\", \"true\").strip().lower()\\n+            not in (\"0\", \"false\", \"no\"))\\n+\\n+\\n+def _flight_review_reason(event, candidate):\\n+    \"\"\"None when the observation may auto-publish, else the zh reason.\"\"\"\\n+    if not _flight_auto_publish_enabled():\\n+        return \"自動發布已停用（FLIGHT_AUTO_PUBLISH=false），須人工確認後發布\"\\n+    if event.get(\"crossCheck\") == \"conflicting_sources\":\\n+        return \"兩個 ADS-B 來源觀測結果不一致，須人工確認後發布\"\\n+    if candidate.get(\"cat\") == \"safety\" or candidate.get(\"incident\"):\\n+        return \"涉及飛安事件，依編輯規範一律人工覆核\"\\n+    flags = candidate.get(\"riskFlags\")\\n+    flags = set(flags) if isinstance(flags, list) else set()\\n+    if flags & _FLIGHT_BLOCKING_FLAGS:\\n+        return \"模型標記高風險旗標，須人工確認後發布\"\\n+    return None\\n+\\n+\\n def _process_flight_events(queue, providers, dead_platforms, dead_auth,\\n-                           ai_calls, queued_entries, now):\\n+                           ai_calls, queued_entries, now,\\n+                           new_articles, new_flashes, used_ids):\\n     \"\"\"Draft queued flight-observation events with the dedicated prompt.\\n \\n-    Outcomes per event: queued into the human-review file (the ONLY path to\\n-    publication), rejected by the model (dropped for good), or left in the\\n-    queue when providers fail (retried next hour). Never auto-published.\\n+    Outcomes per event: auto-published (routine observation that clears\\n+    the machine gate above), queued into the human-review file (gate\\n+    failed), rejected by the model (dropped for good), or left in the\\n+    queue when providers fail (retried next hour).\\n     \"\"\"\\n     remaining = []\\n     prompt = flightnews.flight_prompt()\\n@@ -911,8 +946,28 @@ def _process_flight_events(queue, providers, dead_platforms, dead_auth,\\n                 print(f\"write: {provider.label} flight draft has no \"\\n                       \"machine-verifiable sourceQuote; trying next provider\")\\n                 continue\\n-            # v1 hard rule: rare-aircraft items are ALWAYS human-gated,\\n-            # regardless of what the model claimed.\\n+            review_reason = _flight_review_reason(event, candidate)\\n+            if review_reason is None:\\n+                # Machine gate cleared: routine, non-safety observation with\\n+                # consistent sources - publish without a human.\\n+                candidate[\"status\"] = \"publish\"\\n+                candidate[\"requiresHumanReview\"] = False\\n+                candidate[\"riskFlags\"] = []\\n+                candidate[\"decisionReason\"] = \"\"\\n+                article = build_article(candidate, group, now, used_ids,\\n+                                        provider.label, facts)\\n+                if article is None:\\n+                    print(f\"write: flight event {event.get('eventId')} has \"\\n+                          \"no usable sources; dropping\")\\n+                    outcome = \"rejected\"\\n+                    break\\n+                new_articles.append(article)\\n+                new_flashes.append(build_flash(candidate, article[\"id\"], now))\\n+                print(f\"write: flight event {event.get('eventId')} \"\\n+                      f\"auto-published as {article['id']} \"\\n+                      f\"(crossCheck={event.get('crossCheck')})\")\\n+                outcome = \"published\"\\n+                break\\n             candidate[\"status\"] = \"manual_review\"\\n             candidate[\"requiresHumanReview\"] = True\\n             flags = candidate.get(\"riskFlags\")\\n@@ -921,9 +976,7 @@ def _process_flight_events(queue, providers, dead_platforms, dead_auth,\\n             if \"unconfirmed_or_developing\" not in flags:\\n                 flags.append(\"unconfirmed_or_developing\")\\n             candidate[\"riskFlags\"] = flags\\n-            if not candidate.get(\"decisionReason\"):\\n-                candidate[\"decisionReason\"] = \\\\n-                    \"自動 ADS-B 觀測事件，須人工確認後發布\"\\n+            candidate[\"decisionReason\"] = review_reason\\n             entry = _review_entry(candidate, group, provider.label, facts,\\n                                   now)\\n             entry[\"flight\"] = {\\n@@ -935,7 +988,7 @@ def _process_flight_events(queue, providers, dead_platforms, dead_auth,\\n             }\\n             queued_entries.append(entry)\\n             print(f\"write: flight event {event.get('eventId')} queued for \"\\n-                  \"human review\")\\n+                  f\"human review ({review_reason})\")\\n             outcome = \"queued\"\\n             break\\n         if outcome is None:\\n@@ -1117,13 +1170,14 @@ def main() -> None:\\n \\n     # --- 2b. rare-aircraft flight-observation candidates -------------------\\n     # (queued by pipeline/flightwatch.py; zero AI calls when the queue is\\n-    # empty, and EVERY drafted event is forced into manual review - v1\\n-    # rare-aircraft items never auto-publish.)\\n+    # empty. Routine observations that clear the machine gate publish\\n+    # automatically; safety-flavoured or conflicting events still go to\\n+    # human review.)\\n     flight_queue = flightnews.load_queue()\\n     if flight_queue and providers:\\n         remaining_flight = _process_flight_events(\\n             flight_queue, providers, dead_platforms, dead_auth, ai_calls,\\n-            queued_entries, now)\\n+            queued_entries, now, new_articles, new_flashes, used_ids)\\n         if remaining_flight != flight_queue:\\n             flightnews.save_queue(remaining_flight)\\n \\n@@ -30,9 +30,12 @@ OUTPUT (same schema and language rules as the main pipeline):\\n   body 2-4 short factual paragraphs built only from quoted facts.\\n - cat MUST be \"ops\". incident MUST be null (an arrival is not an ICAO\\n   Annex 13 occurrence). flash: one line per language, hot=false.\\n-- status MUST be \"manual_review\" with requiresHumanReview=true and\\n-  decisionReason explaining this is an automated ADS-B observation awaiting\\n-  editor confirmation. riskFlags MUST include \"unconfirmed_or_developing\".\\n+- status: \"manual_review\" with requiresHumanReview=true (publication policy\\n+  is decided by pipeline code, never by you; it may release routine\\n+  observation items automatically). Never mention any review process,\\n+  pending confirmation or publication workflow inside titles, summaries or\\n+  body text - write the observation itself only. riskFlags MUST include\\n+  \"unconfirmed_or_developing\".\\n - facts: 1-6 atomic claims, each quoting one <SOURCE> line verbatim.\\n - entities: only values literally present in <SOURCE>.\\n - eventStatus: \"confirmed\" refers ONLY to the observed arrival itself.\\n@@ -334,7 +334,17 @@ def _run_write_with(providers_list):\\n         write.build_providers = original\\n \\n \\n-def test_flight_events_into_review_queue():\\n+def _published_articles():\\n+    articles_dir = Path(os.environ[\"AVWIRE_DATA_DIR\"]) / \"articles\"\\n+    out = []\\n+    for f in articles_dir.glob(\"*.json\"):\\n+        out.extend(json.loads(f.read_text(encoding=\"utf-8\"))[\"articles\"])\\n+    return out\\n+\\n+\\n+def test_flight_events_auto_publish():\\n+    \"\"\"Owner policy 2026-07-27: routine observations clear the machine\\n+    gate and publish without a human.\"\"\"\\n     _reset_data()\\n     flightnews.save_queue([json.loads(json.dumps(FLIGHT_EVENT))])\\n     solo = FakeProvider(\"gemini\", [_flight_draft(status=\"publish\")])\\n@@ -343,25 +353,66 @@ def test_flight_events_into_review_queue():\\n     check(solo.calls == 1, f\"one AI call for one event, got {solo.calls}\")\\n     review = common.load_json(Path(os.environ[\"AVWIRE_DATA_DIR\"])\\n                               / \"review.json\", [])\\n-    check(len(review) == 1, \"flight event landed in the review queue\")\\n-    entry = review[0]\\n-    check(entry[\"draft\"][\"status\"] == \"manual_review\"\\n-          and entry[\"draft\"][\"requiresHumanReview\"] is True,\\n-          \"publish output was FORCED down to manual_review\")\\n-    check(\"unconfirmed_or_developing\" in entry[\"draft\"][\"riskFlags\"],\\n-          \"risk flag enforced on flight events\")\\n-    check(entry[\"flight\"][\"eventId\"] == FLIGHT_EVENT[\"eventId\"]\\n-          and entry[\"flight\"][\"crossCheck\"] == \"confirmed_by_two_sources\",\\n-          \"flight metadata stored with the review entry\")\\n-    check(entry[\"group\"][\"items\"][0][\"url\"] == \"https://airplanes.live/\"\\n-          and entry[\"group\"][\"items\"][1][\"url\"] == \"https://www.adsb.lol/\",\\n+    check(review == [], \"clean observation skips the review queue\")\\n+    arts = _published_articles()\\n+    check(len(arts) == 1, \"observation auto-published as one article\")\\n+    art = arts[0]\\n+    check(art[\"riskFlags\"] == [] and art[\"cat\"] == \"ops\",\\n+          \"published observation carries no risk flags, cat=ops\")\\n+    check({s[\"url\"] for s in art[\"sources\"]}\\n+          == {\"https://airplanes.live/\", \"https://www.adsb.lol/\"},\\n           \"attribution links become the article's sources\")\\n-    check(flightnews.load_queue() == [], \"queue emptied after queuing\")\\n-    # No articles were published (manual_review only).\\n-    articles_dir = Path(os.environ[\"AVWIRE_DATA_DIR\"]) / \"articles\"\\n-    check(not list(articles_dir.glob(\"*.json\")),\\n-          \"nothing auto-published for a rare-aircraft event\")\\n-    print(\"test_flight_events_into_review_queue: done\")\\n+    check(flightnews.load_queue() == [], \"queue emptied after publishing\")\\n+    print(\"test_flight_events_auto_publish: done\")\\n+\\n+\\n+def test_flight_gate_blocks_conflicts_safety_and_kill_switch():\\n+    # conflicting ADS-B sources -> human review, never published\\n+    _reset_data()\\n+    event = json.loads(json.dumps(FLIGHT_EVENT))\\n+    event[\"crossCheck\"] = \"conflicting_sources\"\\n+    flightnews.save_queue([event])\\n+    solo = FakeProvider(\"gemini\", [_flight_draft(status=\"publish\")])\\n+    _run_write_with([solo])\\n+    review = common.load_json(Path(os.environ[\"AVWIRE_DATA_DIR\"])\\n+                              / \"review.json\", [])\\n+    check(len(review) == 1 and not _published_articles(),\\n+          \"conflicting sources -> review queue, nothing published\")\\n+    check(review[0][\"draft\"][\"status\"] == \"manual_review\"\\n+          and \"unconfirmed_or_developing\" in review[0][\"draft\"][\"riskFlags\"]\\n+          and \"不一致\" in review[0][\"draft\"][\"decisionReason\"],\\n+          \"conflict entry forced to manual_review with reason\")\\n+    check(review[0][\"flight\"][\"eventId\"] == FLIGHT_EVENT[\"eventId\"],\\n+          \"flight metadata stored with the review entry\")\\n+\\n+    # a safety-flavoured draft -> human review even with clean sources\\n+    _reset_data()\\n+    flightnews.save_queue([json.loads(json.dumps(FLIGHT_EVENT))])\\n+    safety_draft = _flight_draft(status=\"manual_review\")\\n+    safety_draft[\"riskFlags\"] = [\"investigation\"]\\n+    safety_draft[\"requiresHumanReview\"] = True\\n+    safety_draft[\"decisionReason\"] = \"involves an investigation\"\\n+    solo = FakeProvider(\"gemini\", [safety_draft])\\n+    _run_write_with([solo])\\n+    review = common.load_json(Path(os.environ[\"AVWIRE_DATA_DIR\"])\\n+                              / \"review.json\", [])\\n+    check(len(review) == 1 and not _published_articles(),\\n+          \"safety risk flag -> review queue, nothing published\")\\n+\\n+    # kill switch restores review-everything behaviour\\n+    _reset_data()\\n+    flightnews.save_queue([json.loads(json.dumps(FLIGHT_EVENT))])\\n+    os.environ[\"FLIGHT_AUTO_PUBLISH\"] = \"false\"\\n+    try:\\n+        solo = FakeProvider(\"gemini\", [_flight_draft(status=\"publish\")])\\n+        _run_write_with([solo])\\n+    finally:\\n+        del os.environ[\"FLIGHT_AUTO_PUBLISH\"]\\n+    review = common.load_json(Path(os.environ[\"AVWIRE_DATA_DIR\"])\\n+                              / \"review.json\", [])\\n+    check(len(review) == 1 and not _published_articles(),\\n+          \"FLIGHT_AUTO_PUBLISH=false -> review queue, nothing published\")\\n+    print(\"test_flight_gate_blocks_conflicts_safety_and_kill_switch: done\")\\n \\n \\n def test_flight_no_candidates_no_ai():\\n@@ -411,7 +462,8 @@ def main():\\n         test_rarity_and_bootstrap,\\n         test_event_identity_and_config,\\n         test_source_assembly_privacy,\\n-        test_flight_events_into_review_queue,\\n+        test_flight_events_auto_publish,\\n+        test_flight_gate_blocks_conflicts_safety_and_kill_switch,\\n         test_flight_no_candidates_no_ai,\\n         test_flight_bad_quote_and_reject,\\n     ]"
}
