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.
- backdraft/__init__.py +13 -0
- backdraft/bind/__init__.py +37 -0
- backdraft/bind/binder.py +653 -0
- backdraft/bind/cli.py +133 -0
- backdraft/bind/evidence.py +232 -0
- backdraft/bind/verify/__init__.py +30 -0
- backdraft/bind/verify/base.py +70 -0
- backdraft/bind/verify/entail.py +156 -0
- backdraft/bind/verify/overlap.py +114 -0
- backdraft/bind/verify/recompute.py +45 -0
- backdraft/bind/verify/value_trace.py +352 -0
- backdraft/cli.py +341 -0
- backdraft/cli_context.py +152 -0
- backdraft/credentials.py +106 -0
- backdraft/extract/__init__.py +37 -0
- backdraft/extract/base.py +184 -0
- backdraft/extract/pdf_text.py +124 -0
- backdraft/extract/text.py +44 -0
- backdraft/extract/vlm.py +175 -0
- backdraft/extract/vlm_settings.py +197 -0
- backdraft/extract/xlsx.py +244 -0
- backdraft/gate/__init__.py +39 -0
- backdraft/gate/cli.py +131 -0
- backdraft/gate/reader.py +592 -0
- backdraft/gate/searcher.py +123 -0
- backdraft/kernel/__init__.py +17 -0
- backdraft/kernel/artifact.py +234 -0
- backdraft/kernel/chunking.py +162 -0
- backdraft/kernel/claims.py +142 -0
- backdraft/kernel/errors.py +35 -0
- backdraft/kernel/hashing.py +89 -0
- backdraft/kernel/model.py +336 -0
- backdraft/kernel/tokens.py +291 -0
- backdraft/registry/__init__.py +39 -0
- backdraft/registry/ledger.py +82 -0
- backdraft/registry/schema.sql +90 -0
- backdraft/registry/store.py +915 -0
- backdraft/render/__init__.py +31 -0
- backdraft/render/_text.py +69 -0
- backdraft/render/cli.py +107 -0
- backdraft/render/footnotes.py +172 -0
- backdraft/render/html.py +1211 -0
- backdraft/render/markdown.py +313 -0
- backdraft/render/placement.py +105 -0
- backdraft/render/sidecar.py +164 -0
- backdraft-0.2.0.dist-info/METADATA +253 -0
- backdraft-0.2.0.dist-info/RECORD +50 -0
- backdraft-0.2.0.dist-info/WHEEL +4 -0
- backdraft-0.2.0.dist-info/entry_points.txt +2 -0
- 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())
|