Rare-aircraft observations auto-publish through a machine gate
Owner policy change (2026-07-27): routine rare-aircraft observation
items no longer wait for a human. write.py now publishes a drafted
flight event directly when ALL of these hold:
- ADS-B sources are not in conflict (crossCheck != conflicting_sources)
- the draft is not safety-flavoured (cat != safety, incident == null,
no accident/casualty/investigation/conflict risk flags)
- validation and verbatim-quote verification passed as before
Anything else still goes to data/review.json exactly as promised on the
public methodology page (safety events remain human-gated). Repo var
FLIGHT_AUTO_PUBLISH=false restores review-everything behaviour; the
flight prompt no longer asks the model to mention any review workflow
inside article text (publication policy is code's job, not the
model's).
tests: test_flightwatch.py reworked - auto-publish happy path, conflict
gate, safety-flag gate, kill switch; all 8 suites green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Full 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 ]