backdraft 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. backdraft/__init__.py +13 -0
  2. backdraft/bind/__init__.py +37 -0
  3. backdraft/bind/binder.py +653 -0
  4. backdraft/bind/cli.py +133 -0
  5. backdraft/bind/evidence.py +232 -0
  6. backdraft/bind/verify/__init__.py +30 -0
  7. backdraft/bind/verify/base.py +70 -0
  8. backdraft/bind/verify/entail.py +156 -0
  9. backdraft/bind/verify/overlap.py +114 -0
  10. backdraft/bind/verify/recompute.py +45 -0
  11. backdraft/bind/verify/value_trace.py +352 -0
  12. backdraft/cli.py +341 -0
  13. backdraft/cli_context.py +152 -0
  14. backdraft/credentials.py +106 -0
  15. backdraft/extract/__init__.py +37 -0
  16. backdraft/extract/base.py +184 -0
  17. backdraft/extract/pdf_text.py +124 -0
  18. backdraft/extract/text.py +44 -0
  19. backdraft/extract/vlm.py +175 -0
  20. backdraft/extract/vlm_settings.py +197 -0
  21. backdraft/extract/xlsx.py +244 -0
  22. backdraft/gate/__init__.py +39 -0
  23. backdraft/gate/cli.py +131 -0
  24. backdraft/gate/reader.py +592 -0
  25. backdraft/gate/searcher.py +123 -0
  26. backdraft/kernel/__init__.py +17 -0
  27. backdraft/kernel/artifact.py +234 -0
  28. backdraft/kernel/chunking.py +162 -0
  29. backdraft/kernel/claims.py +142 -0
  30. backdraft/kernel/errors.py +35 -0
  31. backdraft/kernel/hashing.py +89 -0
  32. backdraft/kernel/model.py +336 -0
  33. backdraft/kernel/tokens.py +291 -0
  34. backdraft/registry/__init__.py +39 -0
  35. backdraft/registry/ledger.py +82 -0
  36. backdraft/registry/schema.sql +90 -0
  37. backdraft/registry/store.py +915 -0
  38. backdraft/render/__init__.py +31 -0
  39. backdraft/render/_text.py +69 -0
  40. backdraft/render/cli.py +107 -0
  41. backdraft/render/footnotes.py +172 -0
  42. backdraft/render/html.py +1211 -0
  43. backdraft/render/markdown.py +313 -0
  44. backdraft/render/placement.py +105 -0
  45. backdraft/render/sidecar.py +164 -0
  46. backdraft-0.2.0.dist-info/METADATA +253 -0
  47. backdraft-0.2.0.dist-info/RECORD +50 -0
  48. backdraft-0.2.0.dist-info/WHEEL +4 -0
  49. backdraft-0.2.0.dist-info/entry_points.txt +2 -0
  50. backdraft-0.2.0.dist-info/licenses/LICENSE +21 -0
backdraft/bind/cli.py ADDED
@@ -0,0 +1,133 @@
1
+ """`backdraft bind` — the CLI surface and the exit-code contract.
2
+
3
+ ```
4
+ backdraft bind <doc.md> [--session S] [--check m1,m2] [--mode frontwalk|backfill]
5
+ ```
6
+
7
+ Exit codes, per SPEC:
8
+
9
+ * **0** — clean: every citation resolved and nothing was left unmatched.
10
+ * **1** — usage or environment error: no registry, no such document, an unknown
11
+ `--mode` or `--check` name.
12
+ * **2** — bind completed and something did not resolve. This is the code hooks
13
+ gate on, so it is about *resolution* only: a `fail` verdict never produces it,
14
+ because verification is evidence, not a gate.
15
+
16
+ NOTE: the spec fixes exit 2 as "completed with non-resolved citations". A
17
+ backfill claim bind could not anchor is the same failure without a citation to
18
+ hang it on, so it exits 2 as well — a backfill run that leaves claims
19
+ unattributed is not a clean run.
20
+
21
+ Registry discovery and session resolution come from `backdraft.cli_context`
22
+ (SPEC Addendum B), imported at module level like every other sub-app's. The
23
+ registry is opened from the *document's* directory rather than the process's
24
+ cwd: `backdraft bind ../notes/memo.md` should find the registry the document
25
+ lives under.
26
+
27
+ Mounting (Addendum B): `app` is a `typer.Typer()` holding one command named
28
+ `bind` and no callback, and the top level adopts its commands rather than
29
+ nesting the app:
30
+
31
+ app.registered_commands.extend(bind_cli.app.registered_commands)
32
+
33
+ so the command lands as `backdraft bind`, with no group name in between.
34
+ `tests/test_bind_cli.py` pins that.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ from pathlib import Path
40
+ from typing import Annotated
41
+
42
+ import typer
43
+
44
+ from ..cli_context import (
45
+ EXIT_UNRESOLVED,
46
+ EXIT_USAGE,
47
+ UsageError,
48
+ guard,
49
+ open_registry,
50
+ resolve_session,
51
+ )
52
+ from ..kernel.artifact import bound_path, sidecar_path
53
+ from .binder import bind as run_bind, record_target
54
+
55
+ __all__ = ["app", "bind", "EXIT_USAGE", "EXIT_UNRESOLVED"]
56
+
57
+ app = typer.Typer(help="Bind an authored document against the registry.")
58
+
59
+
60
+ @app.command("bind")
61
+ def bind(
62
+ doc: Annotated[Path, typer.Argument(help="The authored markdown document.")],
63
+ session: Annotated[
64
+ str | None,
65
+ typer.Option("--session", help="Session whose ledger `not_shown` is judged against."),
66
+ ] = None,
67
+ check: Annotated[
68
+ str | None,
69
+ typer.Option(
70
+ "--check", help="Verification methods to run, comma-separated. Default: none."
71
+ ),
72
+ ] = None,
73
+ mode: Annotated[str, typer.Option("--mode", help="frontwalk | backfill")] = "frontwalk",
74
+ lean: Annotated[
75
+ bool,
76
+ typer.Option("--lean", help="Skip page images in the artifact's evidence."),
77
+ ] = False,
78
+ bound: Annotated[
79
+ bool,
80
+ typer.Option("--bound", help="Also write the rewritten-markdown projection."),
81
+ ] = False,
82
+ ) -> None:
83
+ """Resolve every citation, run enabled checks, rewrite, report."""
84
+ with guard():
85
+ if mode not in ("frontwalk", "backfill"):
86
+ raise UsageError(f"unknown mode {mode!r}; expected frontwalk or backfill")
87
+ if not doc.is_file():
88
+ raise UsageError(f"no such document: {doc}")
89
+ checks = [name.strip() for name in (check or "").split(",") if name.strip()]
90
+ registry = open_registry(doc.resolve().parent)
91
+ session_id = resolve_session(session, registry)
92
+ try:
93
+ report = run_bind(
94
+ doc, registry, mode=mode, session_id=session_id, checks=checks,
95
+ lean=lean, bound=bound,
96
+ )
97
+ except ValueError as error: # unknown --check name
98
+ raise UsageError(str(error)) from error
99
+ finally:
100
+ _close(registry)
101
+ _print_report(report, doc, bound=bound, record=record_target(doc, registry))
102
+ unmatched = [claim for claim in report.claims if claim.unmatched]
103
+ if report.unresolved or unmatched:
104
+ raise typer.Exit(EXIT_UNRESOLVED)
105
+
106
+
107
+ def _print_report(report, doc: Path, *, bound: bool = False, record: Path | None = None) -> None: # noqa: ANN001 - BindReport, kernel-typed
108
+ """The human-readable report: counts, then every line item."""
109
+ summary = report.summary
110
+ typer.echo(
111
+ f"bound {summary['claims']} claim(s), {summary['citations']} citation(s) "
112
+ f"[{report.mode}]"
113
+ )
114
+ for status, count in sorted(summary["by_status"].items()):
115
+ typer.echo(f" {status}: {count}")
116
+ for method, statuses in sorted(summary["by_method"].items()):
117
+ detail = ", ".join(f"{key} {value}" for key, value in sorted(statuses.items()))
118
+ typer.echo(f" {method}: {detail}")
119
+ for citation in report.unresolved:
120
+ reason = f" — {citation.error}" if citation.error else ""
121
+ typer.echo(f" ! {citation.status}: {citation.token}{reason}")
122
+ for claim in report.claims:
123
+ if claim.unmatched:
124
+ typer.echo(f" ! unmatched: {claim.text.strip()[:80]}")
125
+ if bound:
126
+ typer.echo(f"wrote {bound_path(doc)}")
127
+ typer.echo(f"wrote {record or sidecar_path(doc)}")
128
+
129
+
130
+ def _close(registry) -> None: # noqa: ANN001
131
+ close = getattr(registry, "close", None)
132
+ if callable(close):
133
+ close()
@@ -0,0 +1,232 @@
1
+ """Evidence assembly: the context a citation's receipt sits in.
2
+
3
+ Bind is the one step that holds both the resolved claims and the registry, so
4
+ it is where the artifact's evidence is gathered: the cited pages as images (when
5
+ the registry has snapshots), each cited page's extracted text, a small cell
6
+ window around every cited cell, and the full values of every cited sheet for
7
+ the artifact's sheet view. Bounded by what is cited — never the corpus.
8
+
9
+ The result is a plain JSON-shaped dict, carried on `BindReport.evidence` and
10
+ described by `LEGEND["evidence"]`. Everything here is optional context: a
11
+ sidecar without evidence is still a complete record, so assembly degrades
12
+ rather than fails — a registry that cannot answer (a test fake, a missing
13
+ snapshot) simply contributes nothing.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import base64
19
+ import re
20
+ from typing import Any, Iterable
21
+
22
+ from ..kernel.model import Claim
23
+
24
+ __all__ = ["assemble", "WINDOW_ROWS", "WINDOW_COLS"]
25
+
26
+ WINDOW_ROWS = 6
27
+ """Rows kept above and below a cited cell in its window."""
28
+
29
+ WINDOW_COLS = 4
30
+ """Columns kept left and right of a cited cell in its window."""
31
+
32
+ _CELL_RE = re.compile(r"^(?P<sheet>[^!]+)!(?P<col>[A-Z]{1,3})(?P<row>\d+)$")
33
+ _PAGE_RE = re.compile(r"^p(?P<page>\d+)(?:\.c\d+)?$")
34
+
35
+
36
+ def col_num(letters: str) -> int:
37
+ """A1 column letters -> 1-based number."""
38
+ n = 0
39
+ for ch in letters:
40
+ n = n * 26 + (ord(ch) - 64)
41
+ return n
42
+
43
+
44
+ def col_letters(n: int) -> str:
45
+ """1-based column number -> A1 letters."""
46
+ out = ""
47
+ while n:
48
+ n, r = divmod(n - 1, 26)
49
+ out = chr(65 + r) + out
50
+ return out
51
+
52
+
53
+ def assemble(registry, claims: Iterable[Claim], *, lean: bool = False) -> dict[str, Any] | None: # noqa: ANN001
54
+ """Evidence for every cited anchor, or None when there is nothing to give.
55
+
56
+ `lean` skips page images (the only heavy part); text windows and sheet
57
+ values are always cheap enough to include.
58
+ """
59
+ if not callable(getattr(registry, "document", None)) or not callable(
60
+ getattr(registry, "page", None)
61
+ ):
62
+ return None
63
+
64
+ cited_pages: dict[str, set[int]] = {}
65
+ cited_cells: list[tuple[str, str, str, int]] = [] # slug, sheet, col, row
66
+ sheet_pages: list[tuple[str, int]] = []
67
+ slugs: list[str] = []
68
+
69
+ documents: dict[str, Any] = {}
70
+
71
+ def doc_for(slug: str): # noqa: ANN202
72
+ if slug not in documents:
73
+ documents[slug] = registry.document(slug)
74
+ return documents[slug]
75
+
76
+ for claim in claims:
77
+ for citation in claim.citations:
78
+ anchor = citation.anchor
79
+ if anchor is None:
80
+ continue
81
+ document = doc_for(anchor.slug)
82
+ if document is None:
83
+ continue
84
+ if anchor.slug not in slugs:
85
+ slugs.append(anchor.slug)
86
+ locator = str(anchor.locator)
87
+ if m := _CELL_RE.match(locator):
88
+ cited_cells.append((anchor.slug, m["sheet"], m["col"], int(m["row"])))
89
+ elif m := _PAGE_RE.match(locator):
90
+ number = int(m["page"])
91
+ if document.media_type == "xlsx":
92
+ sheet_pages.append((anchor.slug, number))
93
+ else:
94
+ cited_pages.setdefault(anchor.slug, set()).add(number)
95
+
96
+ if not slugs:
97
+ return None
98
+
99
+ evidence: dict[str, Any] = {
100
+ "documents": {
101
+ slug: {
102
+ "filename": documents[slug].filename,
103
+ "media_type": documents[slug].media_type,
104
+ }
105
+ for slug in slugs
106
+ },
107
+ "pages": {},
108
+ "pagetexts": {},
109
+ "windows": {},
110
+ "sheets": {},
111
+ }
112
+
113
+ # ---- cited PDF pages: text always, image when stored and not lean ------
114
+ can_image = callable(getattr(registry, "page_image", None))
115
+ for slug, numbers in cited_pages.items():
116
+ for number in sorted(numbers):
117
+ page = registry.page(slug, number)
118
+ if page is not None:
119
+ evidence["pagetexts"][f"{slug}:p{number}"] = page.text
120
+ if lean or not can_image:
121
+ continue
122
+ image = registry.page_image(slug, number)
123
+ if image is not None:
124
+ evidence["pages"][f"{slug}:p{number}"] = {
125
+ "format": image.format,
126
+ "width": image.width,
127
+ "height": image.height,
128
+ "data": base64.b64encode(image.data).decode("ascii"),
129
+ }
130
+
131
+ # ---- sheets: windows around cited cells, full values per cited sheet ---
132
+ def sheet_page(slug: str, sheet: str): # noqa: ANN202
133
+ for page in registry.pages(slug):
134
+ if page.kind == "sheet" and page.name == sheet:
135
+ return page
136
+ return None
137
+
138
+ def cells_map(page) -> dict[tuple[int, int], str]: # noqa: ANN001
139
+ cells: dict[tuple[int, int], str] = {}
140
+ for cell in page.cells:
141
+ if m := re.match(r"^([A-Z]{1,3})(\d+)$", cell.ref):
142
+ cells[(int(m.group(2)), col_num(m.group(1)))] = cell.value
143
+ return cells
144
+
145
+ def window(cells: dict[tuple[int, int], str], sheet: str,
146
+ col: str | None, row: int | None) -> dict[str, Any]:
147
+ if not cells:
148
+ return {}
149
+ if col is None: # whole-sheet citation: top-left populated block
150
+ r0 = min(r for r, _ in cells)
151
+ c0 = min(c for _, c in cells)
152
+ rows_range = range(r0, r0 + 2 * WINDOW_ROWS + 1)
153
+ cols_range = range(c0, c0 + 2 * WINDOW_COLS + 1)
154
+ cited = None
155
+ else:
156
+ r, c = row, col_num(col)
157
+ rows_range = range(max(1, r - WINDOW_ROWS), r + WINDOW_ROWS + 1)
158
+ cols_range = range(max(1, c - WINDOW_COLS), c + WINDOW_COLS + 1)
159
+ cited = f"{col}{row}"
160
+ live_rows = [r for r in rows_range if any((r, c) in cells for c in cols_range)]
161
+ live_cols = [c for c in cols_range if any((r, c) in cells for r in rows_range)]
162
+ if not live_rows or not live_cols:
163
+ return {}
164
+ rows_out = [
165
+ {
166
+ "n": r,
167
+ "cells": {
168
+ col_letters(c): cells.get((r, c), "")
169
+ for c in range(min(live_cols), max(live_cols) + 1)
170
+ },
171
+ }
172
+ for r in range(min(live_rows), max(live_rows) + 1)
173
+ ]
174
+ return {
175
+ "sheet": sheet,
176
+ "cited": cited,
177
+ "cols": [col_letters(c) for c in range(min(live_cols), max(live_cols) + 1)],
178
+ "rows": rows_out,
179
+ }
180
+
181
+ cited_sheets: dict[tuple[str, str], Any] = {}
182
+
183
+ def note_sheet(slug: str, sheet: str): # noqa: ANN202
184
+ key = (slug, sheet)
185
+ if key not in cited_sheets:
186
+ cited_sheets[key] = sheet_page(slug, sheet)
187
+ return cited_sheets[key]
188
+
189
+ for slug, sheet, col, row in cited_cells:
190
+ key = f"{slug}:{sheet}!{col}{row}"
191
+ if key in evidence["windows"]:
192
+ continue
193
+ page = note_sheet(slug, sheet)
194
+ if page is None:
195
+ continue
196
+ made = window(cells_map(page), sheet, col, row)
197
+ if made:
198
+ evidence["windows"][key] = made
199
+
200
+ for slug, number in sheet_pages:
201
+ page = registry.page(slug, number)
202
+ if page is None or page.kind != "sheet" or not page.name:
203
+ continue
204
+ note_sheet(slug, page.name)
205
+ key = f"{slug}:p{number}"
206
+ if key not in evidence["windows"]:
207
+ made = window(cells_map(page), page.name, None, None)
208
+ if made:
209
+ evidence["windows"][key] = made
210
+
211
+ for (slug, sheet), page in cited_sheets.items():
212
+ if page is None:
213
+ continue
214
+ cells = cells_map(page)
215
+ if not cells:
216
+ continue
217
+ maxr = max(r for r, _ in cells)
218
+ maxc = max(c for _, c in cells)
219
+ evidence["sheets"][f"{slug}:{sheet}"] = {
220
+ "name": sheet,
221
+ "nrows": maxr,
222
+ "ncols": maxc,
223
+ "rows": [
224
+ [cells.get((r, c), "") for c in range(1, maxc + 1)]
225
+ for r in range(1, maxr + 1)
226
+ ],
227
+ }
228
+
229
+ if not any((evidence["pages"], evidence["pagetexts"],
230
+ evidence["windows"], evidence["sheets"])):
231
+ return None
232
+ return evidence
@@ -0,0 +1,30 @@
1
+ """Verification methods: independent switches, recorded as evidence, never gates.
2
+
3
+ Importing this package registers every method in `VERIFIERS`; `selected(names)`
4
+ turns `--check value-trace,overlap` into the list bind runs. All switches are
5
+ off by default — a bind with no `--check` writes no verdict rows at all, and the
6
+ report's shape is unchanged either way.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from .base import VERIFIERS, Verifier, register, selected
12
+ from .entail import Entail, entail
13
+ from .overlap import Overlap, overlap
14
+ from .recompute import Recompute, recompute
15
+ from .value_trace import ValueTrace, value_trace
16
+
17
+ __all__ = [
18
+ "VERIFIERS",
19
+ "Verifier",
20
+ "register",
21
+ "selected",
22
+ "ValueTrace",
23
+ "value_trace",
24
+ "Overlap",
25
+ "overlap",
26
+ "Recompute",
27
+ "recompute",
28
+ "Entail",
29
+ "entail",
30
+ ]
@@ -0,0 +1,70 @@
1
+ """The Verifier protocol and the registry of verification methods.
2
+
3
+ A verifier answers one question about one (claim, citation) pair and records a
4
+ `Verdict`. Verdicts are evidence, never gates: bind's exit code keys off
5
+ citation *resolution*, never off a verdict. All methods are off by default;
6
+ `--check value-trace,overlap` opts in.
7
+
8
+ `applies` is the method's own claim-class filter — value-trace applies only to
9
+ claims carrying a value, `recompute` applies to nothing until `bd:calc` lands.
10
+ An enabled verifier that does not apply writes no verdict row, so a report never
11
+ implies a method looked at a claim it never read.
12
+
13
+ Optional capability, not part of the protocol: a verifier may define
14
+ ``prepare(pairs)`` taking the full list of ``(claim, citation, anchor)`` triples
15
+ bind is about to verify. Bind calls it once, before any `verify`, when it is
16
+ present — this is how `entail` batches its judge calls. Verifiers without it are
17
+ unaffected.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from collections.abc import Sequence
23
+ from typing import Protocol, runtime_checkable
24
+
25
+ from ...kernel.model import Anchor, Citation, Claim, Verdict
26
+
27
+ __all__ = ["Verifier", "VERIFIERS", "register", "selected"]
28
+
29
+
30
+ @runtime_checkable
31
+ class Verifier(Protocol):
32
+ """One verification method."""
33
+
34
+ method: str
35
+ """`value-trace` | `overlap` | `recompute` | `entail`."""
36
+
37
+ def applies(self, claim: Claim, citation: Citation) -> bool:
38
+ """True when this method has something to say about this pair."""
39
+ ...
40
+
41
+ def verify(self, claim: Claim, citation: Citation, anchor: Anchor) -> Verdict:
42
+ """The method's finding, as a `Verdict` carrying `self.method`."""
43
+ ...
44
+
45
+
46
+ VERIFIERS: dict[str, Verifier] = {}
47
+ """Every registered method, by name. Registration happens on import."""
48
+
49
+
50
+ def register(verifier: Verifier) -> Verifier:
51
+ """Register `verifier` under its `method` name and return it."""
52
+ VERIFIERS[verifier.method] = verifier
53
+ return verifier
54
+
55
+
56
+ def selected(names: Sequence[str]) -> list[Verifier]:
57
+ """The verifiers named by `--check`, in the order given.
58
+
59
+ Raises `ValueError` for an unknown method name — a usage error, which the
60
+ CLI reports as exit 1 rather than running a bind with a silent typo.
61
+ """
62
+ chosen: list[Verifier] = []
63
+ for name in names:
64
+ if name not in VERIFIERS:
65
+ known = ", ".join(sorted(VERIFIERS))
66
+ raise ValueError(f"unknown verification method {name!r}; known methods: {known}")
67
+ verifier = VERIFIERS[name]
68
+ if verifier not in chosen:
69
+ chosen.append(verifier)
70
+ return chosen
@@ -0,0 +1,156 @@
1
+ """entail: does the snippet support the claim? A model judge, behind `[entail]`.
2
+
3
+ The qualitative claim class — the one no deterministic check reaches. The
4
+ question asked is deliberately tiny and closed: given a claim and the verbatim
5
+ snippet it cites, answer `yes`, `partial`, or `no`. Nothing is asked about
6
+ truth in the world; only about whether *this* snippet supports *this* claim.
7
+
8
+ The module imports without the extra installed: `anthropic` is import-guarded, so
9
+ `--check entail` is always a valid request. There is no pre-check — without the
10
+ extra, or without `BACKDRAFT_ENTAIL_API_KEY`, every pair verifies as `skip` carrying the
11
+ reason, and the bind still completes. That is the "failures are data" reading:
12
+ a report saying the judge could not run is more use than a refusal to bind, and
13
+ `skip` never touches the exit code, which keys off resolution alone.
14
+
15
+ Batching lives in `prepare`, an optional capability bind calls once with every
16
+ (claim, citation, anchor) triple before any `verify` — see `base.py`. One
17
+ request carries `BATCH_SIZE` numbered pairs and comes back as numbered lines,
18
+ which is what makes a judge affordable over a long document. A pair whose
19
+ answer does not come back — API error, short reply, unparseable line — verifies
20
+ as `skip` with the reason, never as `fail`: a verifier that cannot reach a
21
+ finding has not found anything.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import re
27
+ from collections.abc import Sequence
28
+
29
+ from ...credentials import setting
30
+ from ...kernel.hashing import normalize
31
+ from ...kernel.model import Anchor, Citation, Claim, Verdict, VerdictStatus
32
+ from .base import register
33
+
34
+ try: # pragma: no cover - exercised only by the extra's presence
35
+ import anthropic
36
+ except ImportError: # pragma: no cover - the guarded path
37
+ anthropic = None # type: ignore[assignment]
38
+
39
+ __all__ = ["Entail", "entail", "MODEL", "BATCH_SIZE"]
40
+
41
+ MODEL = "claude-opus-5"
42
+ """Judge model. Override with `BACKDRAFT_ENTAIL_MODEL`."""
43
+
44
+ BATCH_SIZE = 20
45
+ """Pairs per request."""
46
+
47
+ MAX_SNIPPET = 2000
48
+ """Snippet characters sent per pair. Longer receipts are truncated, marked."""
49
+
50
+ _SYSTEM = (
51
+ "You judge whether a source snippet supports a claim. "
52
+ "For each numbered pair, answer with exactly one word: "
53
+ "yes (the snippet states or directly implies the claim), "
54
+ "partial (it supports part of the claim, or supports it with qualification), "
55
+ "no (it does not support the claim). "
56
+ "Reply with one line per pair, formatted '<number>: <answer>', and nothing else."
57
+ )
58
+
59
+ _ANSWER = re.compile(r"^\s*(?P<number>\d+)\s*[:.)]\s*(?P<answer>yes|partial|no)\b", re.IGNORECASE)
60
+
61
+ _STATUS = {
62
+ "yes": VerdictStatus.PASS,
63
+ "partial": VerdictStatus.PARTIAL,
64
+ "no": VerdictStatus.FAIL,
65
+ }
66
+
67
+
68
+ class Entail:
69
+ """Model judge over (claim, snippet) pairs. Batched, cached per run."""
70
+
71
+ method = "entail"
72
+
73
+ def __init__(self) -> None:
74
+ self._answers: dict[tuple[str, str], tuple[VerdictStatus, str]] = {}
75
+
76
+ def applies(self, claim: Claim, citation: Citation) -> bool:
77
+ """True for any claim with text — every claim is a qualitative claim."""
78
+ return bool(claim.text.strip())
79
+
80
+ def prepare(self, pairs: Sequence[tuple[Claim, Citation, Anchor]]) -> None:
81
+ """Judge every pair up front, in batches. Optional capability."""
82
+ questions: list[tuple[str, str]] = []
83
+ for claim, _citation, anchor in pairs:
84
+ key = self._key(claim, anchor)
85
+ if key not in self._answers and key not in questions:
86
+ questions.append(key)
87
+ for start in range(0, len(questions), BATCH_SIZE):
88
+ self._ask(questions[start : start + BATCH_SIZE])
89
+
90
+ def verify(self, claim: Claim, citation: Citation, anchor: Anchor) -> Verdict:
91
+ """The judge's answer, or `skip` with the reason it has none."""
92
+ key = self._key(claim, anchor)
93
+ if key not in self._answers:
94
+ self._ask([key])
95
+ status, detail = self._answers.get(
96
+ key, (VerdictStatus.SKIP, "no answer returned for this pair")
97
+ )
98
+ return Verdict(method=self.method, status=status, detail=detail)
99
+
100
+ def _key(self, claim: Claim, anchor: Anchor) -> tuple[str, str]:
101
+ snippet = normalize(anchor.receipt.snippet)
102
+ if len(snippet) > MAX_SNIPPET:
103
+ snippet = snippet[:MAX_SNIPPET] + " […truncated]"
104
+ return (normalize(claim.text), snippet)
105
+
106
+ def _ask(self, batch: Sequence[tuple[str, str]]) -> None:
107
+ """One request for one batch; record an answer for every pair in it."""
108
+ if not batch:
109
+ return
110
+ if anthropic is None:
111
+ self._record_all(batch, "the [entail] extra is not installed")
112
+ return
113
+ api_key = setting("BACKDRAFT_ENTAIL_API_KEY")
114
+ if not api_key:
115
+ self._record_all(
116
+ batch,
117
+ "BACKDRAFT_ENTAIL_API_KEY is not set (env or .backdraft/env); "
118
+ "ambient ANTHROPIC_API_KEY is deliberately not read",
119
+ )
120
+ return
121
+ prompt = "\n\n".join(
122
+ f"{index}.\nCLAIM: {claim}\nSNIPPET: {snippet}"
123
+ for index, (claim, snippet) in enumerate(batch, start=1)
124
+ )
125
+ try:
126
+ response = anthropic.Anthropic(api_key=api_key).messages.create(
127
+ model=setting("BACKDRAFT_ENTAIL_MODEL") or MODEL,
128
+ max_tokens=4096,
129
+ system=_SYSTEM,
130
+ output_config={"effort": "low"},
131
+ messages=[{"role": "user", "content": prompt}],
132
+ )
133
+ except Exception as error: # noqa: BLE001 - any failure is a `skip`, never a `fail`
134
+ self._record_all(batch, f"judge call failed: {error}")
135
+ return
136
+ text = "\n".join(block.text for block in response.content if block.type == "text")
137
+ answers: dict[int, str] = {}
138
+ for line in text.splitlines():
139
+ if match := _ANSWER.match(line):
140
+ answers[int(match["number"])] = match["answer"].lower()
141
+ for index, key in enumerate(batch, start=1):
142
+ answer = answers.get(index)
143
+ if answer is None:
144
+ self._answers[key] = (
145
+ VerdictStatus.SKIP,
146
+ "judge returned no answer for this pair",
147
+ )
148
+ else:
149
+ self._answers[key] = (_STATUS[answer], f"judge: {answer}")
150
+
151
+ def _record_all(self, batch: Sequence[tuple[str, str]], reason: str) -> None:
152
+ for key in batch:
153
+ self._answers[key] = (VerdictStatus.SKIP, reason)
154
+
155
+
156
+ entail = register(Entail())