{
  "sha": "745e354fc76e71cf79d1e35d9e7e5f51b147725b",
  "message": "Harden article evidence binding\n\nAdd deterministic claim-to-quote checks and source-bound entity evidence with backward-compatible evidence v2.",
  "author": {
    "login": "nnneeeooo-nnneeeooo",
    "name": "",
    "email": ""
  },
  "committer": {
    "login": "web-flow",
    "name": "",
    "email": ""
  },
  "created_at": "2026-09-26T05:30:00Z",
  "files": [
    {
      "filename": ".github/workflows/hourly.yml",
      "previous_filename": "",
      "status": "modified",
      "additions": 3,
      "deletions": 0,
      "changes": 3
    },
    {
      "filename": "pipeline/CONTRACTS.md",
      "previous_filename": "",
      "status": "modified",
      "additions": 18,
      "deletions": 2,
      "changes": 20
    },
    {
      "filename": "pipeline/manual_draft.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 12,
      "deletions": 0,
      "changes": 12
    },
    {
      "filename": "pipeline/write.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 278,
      "deletions": 13,
      "changes": 291
    },
    {
      "filename": "tests/test_evidence_binding.py",
      "previous_filename": "",
      "status": "added",
      "additions": 155,
      "deletions": 0,
      "changes": 155
    }
  ],
  "diff": "@@ -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\")"
}
