Harden article evidence binding
Add deterministic claim-to-quote checks and source-bound entity evidence with backward-compatible evidence v2.
完整差異
@@ -11,6 +11,8 @@ on:\n - "pipeline/adsb.py"\n - "pipeline/flightwatch.py"\n - "pipeline/flightnews.py"\n+ - "pipeline/write.py"\n+ - "pipeline/manual_draft.py"\n - "pipeline/airport_codes.py"\n - "config/airport_iata_aliases.json"\n - "tests/**"\n@@ -48,6 +50,7 @@ jobs:\n python tests/test_usage.py\n python tests/test_fulltext.py\n python tests/test_glossary.py\n+ python tests/test_evidence_binding.py\n python tests/test_briefing.py\n python tests/test_images.py\n python tests/test_manufacturer_sources.py\n@@ -160,15 +160,31 @@ absent when nothing new that hour)\n // (subset of write.RISK_FLAGS; non-empty\n // only on human-approved articles)\n "eventStatus": "under_investigation", // OPTIONAL write.EVENT_STATUSES value\n- "entities": {"airlines": ["..."]} // OPTIONAL source-grounded entities\n- // (subset of write.ENTITY_KEYS arrays)\n+ "entities": {"airlines": ["KLM"]}, // OPTIONAL verified source wording\n+ "entityEvidence": [ // OPTIONAL on legacy articles; REQUIRED\n+ {"entityType": "airlines", // for every non-empty entity on v2 drafts\n+ "value": "KLM",\n+ "sourceQuote": "... KLM ...",\n+ "sourceUrl": "https://..."}\n+ ],\n+ "evidenceVersion": 2 // OPTIONAL; absent on legacy articles\n }\n ]\n }\n ```\n \n ## data/review.json and data/review-archive.json\n \n+Evidence v2 is deliberately additive and backward-compatible. Existing\n+articles without `entityEvidence` or `evidenceVersion` remain readable.\n+New drafts must bind every structured entity value to a verbatim current-source\n+quote. The writer also deterministically checks high-risk atoms inside each\n+fact claim (explicit dates/times, counts and technical identifiers) against\n+that fact's own `sourceQuote`; facts may not borrow those details from another\n+fact. The original evidence row is retained so future deterministic\n+alias/canonical-name layers can normalize search/display values without\n+discarding source provenance.\n+\n The former human-review mechanism is retired. `data/review.json` is read only\n as a one-time migration source: rows no older than 14 days are re-checked by\n the current deterministic source, quote, binding, glossary and fabrication\n@@ -55,13 +55,15 @@\n DRAFT_SCHEMA,\n SYSTEM_PROMPT,\n _evidence_binding_problem,\n+ claim_evidence_problem,\n build_article,\n build_flash,\n build_incident,\n fabrication_problem,\n glossary_problem,\n group_prompt,\n validate_draft,\n+ verify_entity_evidence,\n verify_facts,\n )\n import usage as usage_ledger # noqa: E402\n@@ -1025,6 +1027,16 @@ def generate(job_id: str, payload: dict) -> dict:\n candidate["facts"] = facts\n problem = (\n _evidence_binding_problem(candidate, facts)\n+ or claim_evidence_problem(facts)\n+ )\n+ if not problem:\n+ clean_entities, entity_evidence, problem = \\n+ verify_entity_evidence(candidate, group, provider.label)\n+ if not problem:\n+ candidate["entities"] = clean_entities\n+ candidate["entityEvidence"] = entity_evidence\n+ problem = (\n+ problem\n or glossary_problem(candidate, group)\n or fabrication_problem(candidate, group)\n )\n@@ -234,6 +234,21 @@ def simplified_chinese_error(draft: dict) -> str | None:\n "additionalProperties": False,\n }\n \n+# Evidence for structured metadata is intentionally separate from facts.\n+# value must use wording present in sourceQuote. Future evidence versions may\n+# add deterministic alias/canonical-name resolution without weakening this.\n+_ENTITY_EVIDENCE_SCHEMA = {\n+ "type": "object",\n+ "properties": {\n+ "entityType": {"type": "string", "enum": list(ENTITY_KEYS)},\n+ "value": {"type": "string"},\n+ "sourceQuote": {"type": "string"},\n+ "sourceUrl": {"type": "string"},\n+ },\n+ "required": ["entityType", "value", "sourceQuote", "sourceUrl"],\n+ "additionalProperties": False,\n+}\n+\n DRAFT_SCHEMA = {\n "type": "object",\n "properties": {\n@@ -260,6 +275,11 @@ def simplified_chinese_error(draft: dict) -> str | None:\n "summarySupportedBy": {"type": "array",\n "items": {"type": "string"}},\n "entities": _ENTITIES_SCHEMA,\n+ "entityEvidence": {\n+ "type": "array",\n+ "items": _ENTITY_EVIDENCE_SCHEMA,\n+ "maxItems": 80,\n+ },\n "eventStatus": {"type": "string", "enum": list(EVENT_STATUSES)},\n "riskFlags": {\n "type": "array",\n@@ -269,8 +289,8 @@ def simplified_chinese_error(draft: dict) -> str | None:\n },\n "required": ["status", "decisionReason", "cat", "zh", "en", "flash",\n "incident", "facts", "headlineSupportedBy",\n- "summarySupportedBy", "entities", "eventStatus",\n- "riskFlags", "requiresHumanReview"],\n+ "summarySupportedBy", "entities", "entityEvidence",\n+ "eventStatus", "riskFlags", "requiresHumanReview"],\n "additionalProperties": False,\n }\n \n@@ -328,6 +348,18 @@ def simplified_chinese_error(draft: dict) -> str | None:\n substring from ONE current source field or ONE selected archive fact. Aim\n under 200 characters and never quote a whole paragraph. Every load-bearing\n title and summary claim must bind to a fact.\n+- A fact claim may contain ONLY details supported by its OWN sourceQuote.\n+ If one sentence needs evidence from two separate excerpts, split it into\n+ two facts or choose one contiguous quote that supports the entire claim.\n+ Never borrow a date, number, aircraft/engine code, flight/registration,\n+ route, status or other detail from another fact.\n+- entityEvidence is mandatory for every non-empty value in entities. Use one\n+ evidence row per entity value. entityType is the entities key; value MUST\n+ use wording that appears verbatim in its own current SOURCE sourceQuote\n+ apart from case and whitespace. sourceQuote must itself be a verbatim\n+ contiguous SOURCE substring. Do not translate, canonicalize or infer an\n+ entity alias here; unsupported entities must be omitted. This conservative\n+ contract can later be expanded by deterministic alias tables.\n - Current facts: evidenceScope="source", archiveEventId=null,\n archiveContext=false, and sourceUrl is the current item URL.\n - Historical facts: evidenceScope="archive", archiveContext=true,\n@@ -1131,6 +1163,18 @@ def validate_draft(draft):\n entities = draft.get("entities")\n if entities is not None and not isinstance(entities, dict):\n return "entities must be an object"\n+ entity_evidence = draft.get("entityEvidence")\n+ if entity_evidence is not None:\n+ if not isinstance(entity_evidence, list):\n+ return "entityEvidence must be an array"\n+ for row in entity_evidence:\n+ if not isinstance(row, dict):\n+ return "entityEvidence entries must be objects"\n+ if row.get("entityType") not in ENTITY_KEYS:\n+ return "bad entityEvidence.entityType"\n+ for key in ("value", "sourceQuote", "sourceUrl"):\n+ if not isinstance(row.get(key), str):\n+ return f"entityEvidence.{key} must be a string"\n incident = draft.get("incident")\n if incident is not None:\n if not isinstance(incident, dict):\n@@ -1445,6 +1489,14 @@ def _validated_draft(provider, group: dict, tries: int, ai_calls=None,\n if facts:\n evidence_started = time.perf_counter()\n problem = _evidence_binding_problem(candidate, facts)\n+ if problem is None:\n+ problem = claim_evidence_problem(facts)\n+ if problem is None:\n+ clean_entities, entity_evidence, problem = \\n+ verify_entity_evidence(candidate, group, provider.label)\n+ if problem is None:\n+ candidate["entities"] = clean_entities\n+ candidate["entityEvidence"] = entity_evidence\n if run_trace is not None:\n run_trace.add_duration("evidenceBinding", evidence_started)\n problem_stage = "evidenceBinding"\n@@ -1494,18 +1546,47 @@ def _validated_draft(provider, group: dict, tries: int, ai_calls=None,\n \n _squash = squash_text # shared with common.item_has_material\n \n+_MONTH_NAMES = {\n+ 1: ("january", "jan"), 2: ("february", "feb"),\n+ 3: ("march", "mar"), 4: ("april", "apr"), 5: ("may",),\n+ 6: ("june", "jun"), 7: ("july", "jul"), 8: ("august", "aug"),\n+ 9: ("september", "sep", "sept"), 10: ("october", "oct"),\n+ 11: ("november", "nov"), 12: ("december", "dec"),\n+}\n+_MONTH_TO_NUM = {\n+ name: number for number, names in _MONTH_NAMES.items() for name in names\n+}\n+_DATE_PATTERNS = (\n+ re.compile(r"(?P<y>20\d{2})[-/.](?P<m>0?[1-9]|1[0-2])[-/.](?P<d>0?[1-9]|[12]\d|3[01])"),\n+ re.compile(r"(?P<y>20\d{2})年(?P<m>0?[1-9]|1[0-2])月(?P<d>0?[1-9]|[12]\d|3[01])日"),\n+ re.compile(r"(?P<d>0?[1-9]|[12]\d|3[01])\s+(?P<mon>January|February|March|April|May|June|July|August|September|October|November|December)\s+(?P<y>20\d{2})", re.I),\n+ re.compile(r"(?P<mon>January|February|March|April|May|June|July|August|September|October|November|December)\s+(?P<d>0?[1-9]|[12]\d|3[01])(?:st|nd|rd|th)?[,]?\s+(?P<y>20\d{2})", re.I),\n+)\n+_TECH_ATOM_RE = re.compile(\n+ r"(?<![A-Za-z0-9])(?=[A-Za-z0-9-]*[A-Za-z])(?=[A-Za-z0-9-]*\d)"\n+ r"[A-Za-z0-9]+(?:-[A-Za-z0-9]+)+(?![A-Za-z0-9])"\n+ r"|(?<![A-Za-z0-9])\d{2,4}-\d{1,3}[A-Za-z]?(?![A-Za-z0-9])"\n+ r"|(?<![A-Za-z0-9])[A-Za-z]{1,5}\d{2,5}[A-Za-z]{0,5}(?![A-Za-z0-9])"\n+)\n+_TIME_ATOM_RE = re.compile(r"(?<!\d)(?:[01]?\d|2[0-3]):[0-5]\d(?!\d)")\n+_NUMBER_ATOM_RE = re.compile(\n+ r"(?<![A-Za-z0-9.])(?:\d+\.\d+|\d{1,3}(?:,\d{3})+|\d{2,})"\n+ r"(?:\s*[-–]\s*(?:\d+\.\d+|\d{1,3}(?:,\d{3})+|\d{2,}))?"\n+ r"(?![A-Za-z0-9.])"\n+)\n+_SINGLE_COUNT_RE = re.compile(\n+ r"(?<![\d.])(?P<n>[0-9])\s*(?P<u>%|percent|people|persons|crew|aircraft|"\n+ r"jets|flights|routes|cities|countries|days|hours|minutes|人|名|架|班|條|個|次)"\n+ r"(?![A-Za-z])", re.I)\n+_NUMBER_WORDS = {\n+ "0": ("zero",), "1": ("one",), "2": ("two",), "3": ("three",),\n+ "4": ("four",), "5": ("five",), "6": ("six",), "7": ("seven",),\n+ "8": ("eight",), "9": ("nine",),\n+}\n \n-def verify_facts(draft: dict, group: dict, label: str) -> list:\n- """Keep only facts whose sourceQuote is a verbatim substring of the\n- material actually shown to the model (whitespace-squashed, case-folded).\n \n- The quote requirement is enforced HERE, in code, because prompt-level\n- rules alone cannot be trusted: a fabricated or paraphrased quote is\n- silently dropped, and a draft with zero surviving quotes is unpublishable.\n- """\n- # Keep current fields separate so a quote cannot straddle fields and so\n- # the surviving fact can be bound to the exact current source URL.\n- source_fields = []\n+def _current_source_fields(group: dict) -> list[tuple[str, list[str]]]:\n+ fields_by_url = []\n for item in group.get("items", []):\n url = str(item.get("url") or "").strip()\n if not url and item.get("source") == "SKYTICAL 資料庫" \\n@@ -1519,7 +1600,178 @@ def verify_facts(draft: dict, group: dict, label: str) -> list:\n _squash(_clean_source_text(item.get("fulltext"))\n .strip()[:FULLTEXT_PROMPT_CHARS]),\n ]\n- source_fields.append((url, fields))\n+ fields_by_url.append((url, fields))\n+ return fields_by_url\n+\n+\n+def _date_rows(text: str):\n+ rows = []\n+ for pattern in _DATE_PATTERNS:\n+ for match in pattern.finditer(str(text or "")):\n+ try:\n+ gd = match.groupdict()\n+ month = int(gd["m"]) if gd.get("m") else _MONTH_TO_NUM[gd["mon"].casefold()]\n+ rows.append(((int(gd["y"]), month, int(gd["d"])), match.span()))\n+ except (KeyError, TypeError, ValueError):\n+ continue\n+ rows.sort(key=lambda row: row[1][0])\n+ return rows\n+\n+\n+def _date_supported(date_value, quote: str) -> bool:\n+ year, month, day = date_value\n+ q = str(quote or "").casefold()\n+ if str(year) not in q:\n+ return False\n+ if not re.search(rf"(?<!\d)0?{day}(?:st|nd|rd|th)?(?!\d)", q):\n+ return False\n+ month_names = _MONTH_NAMES.get(month, ())\n+ return (\n+ any(re.search(rf"\b{re.escape(name)}\b", q) for name in month_names)\n+ or re.search(rf"(?:^|\D)0?{month}(?:\D|$)", q) is not None\n+ )\n+\n+\n+def _normalized_atom(value: str) -> str:\n+ return re.sub(r"[\s,–—]+", "", str(value or "").casefold())\n+\n+\n+def _atom_supported(atom: str, quote: str) -> bool:\n+ target = _normalized_atom(atom)\n+ q_norm = _normalized_atom(quote)\n+ if target and target in q_norm:\n+ return True\n+ if atom in _NUMBER_WORDS:\n+ q = str(quote or "").casefold()\n+ return any(re.search(rf"\b{word}\b", q)\n+ for word in _NUMBER_WORDS[atom])\n+ return False\n+\n+\n+def claim_evidence_problem(facts: list[dict]) -> str | None:\n+ """Block high-risk details that are absent from a fact's own quote.\n+\n+ This is deliberately deterministic instead of using a second model. It\n+ focuses on the details most damaging to aviation-news accuracy: explicit\n+ dates/times, counts and technical identifiers. The gate can be extended\n+ without changing the persisted evidence contract.\n+ """\n+ for fact in facts:\n+ fact_id = str(fact.get("factId") or "?")\n+ claim = str(fact.get("claim") or "")\n+ quote = str(fact.get("sourceQuote") or "")\n+ masked = list(claim)\n+ for date_value, (start, end) in _date_rows(claim):\n+ if not _date_supported(date_value, quote):\n+ return f"{fact_id} claim date is not supported by its sourceQuote"\n+ for index in range(start, end):\n+ masked[index] = " "\n+ residual = "".join(masked)\n+ atoms = []\n+ atoms.extend(m.group() for m in _TIME_ATOM_RE.finditer(residual))\n+ atoms.extend(m.group() for m in _TECH_ATOM_RE.finditer(residual))\n+ atoms.extend(m.group() for m in _NUMBER_ATOM_RE.finditer(residual))\n+ atoms.extend(m.group("n") for m in _SINGLE_COUNT_RE.finditer(residual))\n+ for atom in dict.fromkeys(atoms):\n+ if not _atom_supported(atom, quote):\n+ return (\n+ f"{fact_id} claim adds high-risk atom {atom!r} not present "\n+ "in its own sourceQuote"\n+ )\n+ return None\n+\n+\n+def verify_entity_evidence(draft: dict, group: dict, label: str):\n+ """Require every structured entity to bind to current source evidence.\n+\n+ v1 intentionally requires exact source wording. A future deterministic\n+ alias/canonicalization layer can map verified source values onto normalized\n+ display/search values while preserving these original evidence rows.\n+ """\n+ entities = draft.get("entities") or {}\n+ rows = draft.get("entityEvidence")\n+ if rows is None:\n+ rows = []\n+ if not isinstance(entities, dict) or not isinstance(rows, list):\n+ return {}, [], "entities/entityEvidence shape is invalid"\n+\n+ listed = []\n+ for key in ENTITY_KEYS:\n+ values = entities.get(key) or []\n+ if not isinstance(values, list):\n+ return {}, [], f"entities.{key} must be an array"\n+ for value in values:\n+ value = str(value or "").strip()\n+ if value:\n+ listed.append((key, value))\n+ listed_set = set(listed)\n+ if listed_set and not rows:\n+ return {}, [], "every entity requires entityEvidence"\n+\n+ source_fields = _current_source_fields(group)\n+ verified, bound = [], set()\n+ for row in rows:\n+ if not isinstance(row, dict):\n+ return {}, [], "entityEvidence entry is not an object"\n+ key = str(row.get("entityType") or "")\n+ value = str(row.get("value") or "").strip()\n+ quote = _squash(row.get("sourceQuote"))\n+ if key not in ENTITY_KEYS or not value or len(quote) < 2:\n+ return {}, [], "entityEvidence entry is incomplete"\n+ matched_url = next(\n+ (url for url, fields in source_fields\n+ if any(quote in field for field in fields)),\n+ None,\n+ )\n+ if not matched_url:\n+ return {}, [], (\n+ f"entity evidence quote not found in current source: "\n+ f"{key}={value!r}"\n+ )\n+ if _squash(value).casefold() not in quote.casefold():\n+ return {}, [], (\n+ f"entity value {value!r} is not verbatim in its sourceQuote"\n+ )\n+ pair = (key, value)\n+ if pair not in listed_set:\n+ return {}, [], (\n+ f"entityEvidence references unlisted entity {key}={value!r}"\n+ )\n+ if pair in bound:\n+ continue\n+ bound.add(pair)\n+ verified.append({\n+ "entityType": key,\n+ "value": value,\n+ "sourceQuote": str(row.get("sourceQuote") or ""),\n+ "sourceUrl": matched_url,\n+ })\n+\n+ missing = listed_set - bound\n+ if missing:\n+ key, value = sorted(missing)[0]\n+ return {}, [], f"entity lacks verified evidence: {key}={value!r}"\n+\n+ clean = {}\n+ for key in ENTITY_KEYS:\n+ values = [value for entity_key, value in listed\n+ if entity_key == key and (entity_key, value) in bound]\n+ if values:\n+ clean[key] = list(dict.fromkeys(values))\n+ return clean, verified, None\n+\n+\n+def verify_facts(draft: dict, group: dict, label: str) -> list:\n+ """Keep only facts whose sourceQuote is a verbatim substring of the\n+ material actually shown to the model (whitespace-squashed, case-folded).\n+\n+ The quote requirement is enforced HERE, in code, because prompt-level\n+ rules alone cannot be trusted: a fabricated or paraphrased quote is\n+ silently dropped, and a draft with zero surviving quotes is unpublishable.\n+ """\n+ # Keep current fields separate so a quote cannot straddle fields and so\n+ # the surviving fact can be bound to the exact current source URL.\n+ source_fields = _current_source_fields(group)\n verified = []\n for fact in draft.get("facts", []):\n quote = _squash(fact.get("sourceQuote"))\n@@ -1756,6 +2008,19 @@ def build_article(draft: dict, group: dict, now, used_ids: set, writer=None,\n cleaned[key] = unique\n if cleaned:\n article["entities"] = cleaned\n+ entity_evidence = draft.get("entityEvidence")\n+ if isinstance(entity_evidence, list):\n+ verified_rows = [\n+ row for row in entity_evidence\n+ if isinstance(row, dict)\n+ and row.get("entityType") in ENTITY_KEYS\n+ and row.get("value")\n+ and row.get("sourceQuote")\n+ and row.get("sourceUrl")\n+ ]\n+ if verified_rows:\n+ article["entityEvidence"] = verified_rows\n+ article["evidenceVersion"] = 2\n return article\n \n \n@@ -0,0 +1,155 @@\n+"""Offline regression tests for deterministic evidence binding v2.\n+\n+Run from repository root:\n+ python tests/test_evidence_binding.py\n+"""\n+from __future__ import annotations\n+\n+import os\n+import sys\n+import tempfile\n+from pathlib import Path\n+\n+REPO = Path(__file__).resolve().parent.parent\n+os.environ["AVWIRE_DATA_DIR"] = tempfile.mkdtemp(prefix="skytical-evidence-")\n+sys.path.insert(0, str(REPO / "pipeline"))\n+\n+import write # noqa: E402\n+\n+CHECKS = 0\n+FAILED = 0\n+\n+\n+def check(name, condition):\n+ global CHECKS, FAILED\n+ CHECKS += 1\n+ print(("PASS " if condition else "FAIL ") + name)\n+ if not condition:\n+ FAILED += 1\n+\n+\n+GROUP = {\n+ "id": "g-evidence-v2",\n+ "primarySource": "Fixture",\n+ "items": [{\n+ "source": "Fixture",\n+ "url": "https://example.test/story",\n+ "title": "KLM plans A321XLR service update",\n+ "summary": (\n+ "On September 23, 2026, KLM said it plans A321XLR service. "\n+ "The FAA identified the aircraft as a Eurocopter AS332 Super Puma. "\n+ "Two crew members were on board and the event occurred at 14:55."\n+ ),\n+ "fulltext": "",\n+ }],\n+}\n+\n+good_facts = [{\n+ "factId": "F1",\n+ "claim": "On September 23, 2026, KLM said it plans A321XLR service.",\n+ "sourceQuote": "On September 23, 2026, KLM said it plans A321XLR service.",\n+}]\n+check(\n+ "claim atoms supported by the same quote pass",\n+ write.claim_evidence_problem(good_facts) is None,\n+)\n+\n+bad_date = [{\n+ "factId": "F1",\n+ "claim": "The agreement was signed in New York on 23 September 2026.",\n+ "sourceQuote": "The deal was finalized in New York during the UN General Assembly this week.",\n+}]\n+check(\n+ "date borrowed from outside a fact quote is rejected",\n+ write.claim_evidence_problem(bad_date) is not None,\n+)\n+\n+bad_model = [{\n+ "factId": "F1",\n+ "claim": "The aircraft was a Eurocopter AS332 Super Puma.",\n+ "sourceQuote": "The helicopter was conducting water drops near Ostrander Lake.",\n+}]\n+check(\n+ "aircraft model borrowed from another fact is rejected",\n+ write.claim_evidence_problem(bad_model) is not None,\n+)\n+\n+good_entities = {\n+ "entities": {\n+ **{key: [] for key in write.ENTITY_KEYS},\n+ "organizations": ["KLM"],\n+ "aircraft_models": ["A321XLR"],\n+ "event_dates": ["September 23, 2026"],\n+ },\n+ "entityEvidence": [\n+ {\n+ "entityType": "organizations",\n+ "value": "KLM",\n+ "sourceQuote": "On September 23, 2026, KLM said it plans A321XLR service.",\n+ "sourceUrl": "https://example.test/story",\n+ },\n+ {\n+ "entityType": "aircraft_models",\n+ "value": "A321XLR",\n+ "sourceQuote": "On September 23, 2026, KLM said it plans A321XLR service.",\n+ "sourceUrl": "https://example.test/story",\n+ },\n+ {\n+ "entityType": "event_dates",\n+ "value": "September 23, 2026",\n+ "sourceQuote": "On September 23, 2026, KLM said it plans A321XLR service.",\n+ "sourceUrl": "https://example.test/story",\n+ },\n+ ],\n+}\n+clean, evidence, problem = write.verify_entity_evidence(\n+ good_entities, GROUP, "fixture")\n+check("source-bound entities verify", problem is None)\n+check("verified entities are retained", clean.get("organizations") == ["KLM"])\n+check("evidence rows are persisted-ready", len(evidence) == 3)\n+\n+missing = {\n+ "entities": {**{key: [] for key in write.ENTITY_KEYS},\n+ "organizations": ["KLM"]},\n+ "entityEvidence": [],\n+}\n+check(\n+ "entity without evidence is rejected",\n+ write.verify_entity_evidence(missing, GROUP, "fixture")[2] is not None,\n+)\n+\n+invented_alias = {\n+ "entities": {**{key: [] for key in write.ENTITY_KEYS},\n+ "organizations": ["KLM Royal Dutch Airlines"]},\n+ "entityEvidence": [{\n+ "entityType": "organizations",\n+ "value": "KLM Royal Dutch Airlines",\n+ "sourceQuote": "On September 23, 2026, KLM said it plans A321XLR service.",\n+ "sourceUrl": "https://example.test/story",\n+ }],\n+}\n+check(\n+ "unquoted canonicalized entity alias is rejected",\n+ write.verify_entity_evidence(\n+ invented_alias, GROUP, "fixture")[2] is not None,\n+)\n+\n+wrong_quote = {\n+ "entities": {**{key: [] for key in write.ENTITY_KEYS},\n+ "organizations": ["KLM"]},\n+ "entityEvidence": [{\n+ "entityType": "organizations",\n+ "value": "KLM",\n+ "sourceQuote": "KLM invented quote that is not in the source.",\n+ "sourceUrl": "https://example.test/story",\n+ }],\n+}\n+check(\n+ "fabricated entity quote is rejected",\n+ write.verify_entity_evidence(wrong_quote, GROUP, "fixture")[2] is not None,\n+)\n+\n+if FAILED:\n+ print(f"{FAILED}/{CHECKS} evidence-binding checks failed")\n+ raise SystemExit(1)\n+print(f"PASS: {CHECKS} evidence-binding checks")