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
@@ -0,0 +1,35 @@
1
+ """Exception hierarchy.
2
+
3
+ One base (`BackdraftError`) so callers can catch everything the library raises;
4
+ below it, only distinctions a caller acts on differently.
5
+
6
+ `MalformedTokenError` means "this is not a token". `UnsupportedTokenError` means
7
+ "this is a token form the grammar reserves but v0 does not implement" — the
8
+ `bd:calc(...)` derivation form. The distinction exists so parsers reject the
9
+ reserved form cleanly rather than crashing or mislabelling it as garbage.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ __all__ = [
15
+ "BackdraftError",
16
+ "TokenError",
17
+ "MalformedTokenError",
18
+ "UnsupportedTokenError",
19
+ ]
20
+
21
+
22
+ class BackdraftError(Exception):
23
+ """Base for every error raised by backdraft."""
24
+
25
+
26
+ class TokenError(BackdraftError):
27
+ """A citation token could not be turned into a `Token`."""
28
+
29
+
30
+ class MalformedTokenError(TokenError):
31
+ """The text does not satisfy the token grammar."""
32
+
33
+
34
+ class UnsupportedTokenError(TokenError):
35
+ """The text is a recognized but unimplemented token form (`bd:calc(...)`)."""
@@ -0,0 +1,89 @@
1
+ """Normalization and hashing — the only normalization in the system.
2
+
3
+ `normalize` is applied before every snippet hash so that a receipt survives
4
+ re-extraction whitespace churn (CRLF vs LF, reflowed lines, NBSP) while staying
5
+ faithful to the words. Case is preserved: a hash that ignored case would let a
6
+ typo'd transcription resolve.
7
+
8
+ Value equivalences (units, scale, number formats) are *not* here — they belong
9
+ to the value-trace verifier, which defines its own.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ import json
16
+ import re
17
+ import unicodedata
18
+ from typing import Any
19
+
20
+ __all__ = [
21
+ "TOKEN_HASH_LENGTHS",
22
+ "normalize",
23
+ "snippet_hash",
24
+ "token_hash",
25
+ "hash_prefix",
26
+ "content_hash",
27
+ "canonical_json",
28
+ "config_hash",
29
+ ]
30
+
31
+ TOKEN_HASH_LENGTHS = (4, 6, 8)
32
+ """Hash prefix lengths a registry mints: 4 by default, extended on collision.
33
+
34
+ The parser accepts any length in 4..8; minting only ever uses these.
35
+ """
36
+
37
+ _WHITESPACE_RUN = re.compile(r"\s+")
38
+
39
+
40
+ def normalize(text: str) -> str:
41
+ """Unicode NFC, then collapse every whitespace run to one space, then strip.
42
+
43
+ Case is preserved. This is the only text normalization in the system.
44
+ """
45
+ return _WHITESPACE_RUN.sub(" ", unicodedata.normalize("NFC", text)).strip()
46
+
47
+
48
+ def snippet_hash(text: str) -> str:
49
+ """`sha256(normalize(text))` as a full lowercase hexdigest."""
50
+ return hashlib.sha256(normalize(text).encode("utf-8")).hexdigest()
51
+
52
+
53
+ def hash_prefix(digest: str, length: int = TOKEN_HASH_LENGTHS[0]) -> str:
54
+ """The `length`-char prefix of a hexdigest, as it appears in a token."""
55
+ if length not in TOKEN_HASH_LENGTHS:
56
+ raise ValueError(f"token hash length must be one of {TOKEN_HASH_LENGTHS}: {length!r}")
57
+ return digest[:length]
58
+
59
+
60
+ def token_hash(text: str, length: int = TOKEN_HASH_LENGTHS[0]) -> str:
61
+ """The hash segment of the token naming a snippet: a prefix of its sha256."""
62
+ return hash_prefix(snippet_hash(text), length)
63
+
64
+
65
+ def content_hash(data: bytes) -> str:
66
+ """Document identity: `sha256` of the file's bytes, unnormalized."""
67
+ return hashlib.sha256(data).hexdigest()
68
+
69
+
70
+ def canonical_json(value: Any) -> str:
71
+ """Canonical JSON: sorted keys, no insignificant whitespace, UTF-8 text.
72
+
73
+ NOTE: the spec says "canonical-JSON" without naming a profile; this is the
74
+ conventional minimum (RFC 8785 without the number-formatting rules, which
75
+ extractor configs do not exercise). Non-ASCII is preserved, not escaped, so
76
+ that the encoded form is stable under NFC-equal inputs.
77
+ """
78
+ return json.dumps(
79
+ value,
80
+ sort_keys=True,
81
+ ensure_ascii=False,
82
+ separators=(",", ":"),
83
+ allow_nan=False,
84
+ )
85
+
86
+
87
+ def config_hash(config: Any) -> str:
88
+ """Extraction config identity: `sha256` of the canonical JSON encoding."""
89
+ return hashlib.sha256(canonical_json(config).encode("utf-8")).hexdigest()
@@ -0,0 +1,336 @@
1
+ """The whole vocabulary, as frozen dataclasses.
2
+
3
+ Every concept in the spec's concept table lives here and nothing else does. New
4
+ nouns require a spec change.
5
+
6
+ These types are pure values: they carry no database rows, no file handles, no
7
+ connections. The registry reads and writes them; the gate mints them; bind
8
+ reports on them; the renderers project them.
9
+
10
+ Timestamps are ISO-8601 UTC strings, matching the DDL, which stores them as TEXT
11
+ everywhere. NOTE: kept as `str` rather than `datetime` so that a value read from
12
+ SQLite and a value about to be written are the same thing.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from dataclasses import dataclass, field
18
+ from enum import StrEnum
19
+ from typing import Any, Literal
20
+
21
+ from .tokens import Locator, format_locator
22
+
23
+ __all__ = [
24
+ "MediaType",
25
+ "PageKind",
26
+ "AnchorKind",
27
+ "BindMode",
28
+ "CitationStatus",
29
+ "VerdictStatus",
30
+ "Document",
31
+ "Extraction",
32
+ "Page",
33
+ "CellValue",
34
+ "Chunk",
35
+ "Receipt",
36
+ "Anchor",
37
+ "Citation",
38
+ "Claim",
39
+ "Verdict",
40
+ "BindReport",
41
+ ]
42
+
43
+ type MediaType = Literal["pdf", "xlsx", "text"]
44
+ type PageKind = Literal["page", "sheet"]
45
+ type AnchorKind = Literal["page", "chunk", "cell", "range"]
46
+ type BindMode = Literal["frontwalk", "backfill"]
47
+
48
+
49
+ class CitationStatus(StrEnum):
50
+ """Closed set. Every non-`RESOLVED` status is a report line item."""
51
+
52
+ RESOLVED = "resolved"
53
+ """Anchor found in the current extraction; snippet hash matches."""
54
+
55
+ DRIFTED = "drifted"
56
+ """Anchor found only in a superseded extraction; the source moved under it."""
57
+
58
+ NOT_SHOWN = "not_shown"
59
+ """Valid anchor, absent from the session ledger. Front-walk mode only."""
60
+
61
+ UNRESOLVED = "unresolved"
62
+ """Well-formed token, no anchor in any generation. Also the pre-bind state."""
63
+
64
+ MALFORMED = "malformed"
65
+ """The token text does not parse — including the reserved `bd:calc(...)` form."""
66
+
67
+
68
+ class VerdictStatus(StrEnum):
69
+ """Closed set. Verdicts are recorded evidence, never gates."""
70
+
71
+ PASS = "pass"
72
+ FAIL = "fail"
73
+ PARTIAL = "partial"
74
+ SKIP = "skip"
75
+
76
+
77
+ @dataclass(frozen=True, slots=True)
78
+ class Document:
79
+ """An ingested file. Identity is the sha256 of its bytes; `slug` is the handle."""
80
+
81
+ slug: str
82
+ sha256: str
83
+ path: str
84
+ filename: str
85
+ media_type: MediaType
86
+ created_at: str
87
+ id: int | None = None
88
+
89
+
90
+ @dataclass(frozen=True, slots=True)
91
+ class Extraction:
92
+ """A snapshot of a document's content produced by one extractor run.
93
+
94
+ Generations are kept; exactly one per document is `is_current`.
95
+ """
96
+
97
+ document_id: int
98
+ extractor: str
99
+ extractor_version: str
100
+ config_hash: str
101
+ deterministic: bool
102
+ created_at: str
103
+ is_current: bool = True
104
+ id: int | None = None
105
+
106
+
107
+ @dataclass(frozen=True, slots=True)
108
+ class CellValue:
109
+ """One spreadsheet cell: its A1 reference and its rendered value.
110
+
111
+ Carried out of an extraction for cell anchors and for value-trace.
112
+ """
113
+
114
+ ref: str
115
+ value: str
116
+
117
+
118
+ @dataclass(frozen=True, slots=True)
119
+ class Page:
120
+ """An ordered unit within an extraction — a PDF page or a sheet.
121
+
122
+ `text` is the snapshot. Receipts quote *this*, not the file.
123
+ """
124
+
125
+ number: int
126
+ kind: PageKind
127
+ text: str
128
+ name: str | None = None
129
+ summary: str | None = None
130
+ cells: tuple[CellValue, ...] = ()
131
+ extraction_id: int | None = None
132
+ id: int | None = None
133
+
134
+
135
+ @dataclass(frozen=True, slots=True)
136
+ class Chunk:
137
+ """A deterministic subdivision of a page's text.
138
+
139
+ `ordinal` is 1-based within the page (the `cN` in a chunk locator).
140
+ `start`/`end` are char offsets into the page text, and are exact:
141
+ `page_text[start:end] == text`.
142
+ """
143
+
144
+ ordinal: int
145
+ text: str
146
+ start: int
147
+ end: int
148
+
149
+
150
+ @dataclass(frozen=True, slots=True)
151
+ class Receipt:
152
+ """The evidence an anchor carries: the verbatim snippet and its hash.
153
+
154
+ An anchor is not a pointer; it is a pointer plus this.
155
+ """
156
+
157
+ snippet: str
158
+ snippet_sha256: str
159
+
160
+
161
+ @dataclass(frozen=True, slots=True)
162
+ class Anchor:
163
+ """An addressable location in an extraction, carrying its receipt.
164
+
165
+ Named by its token: `bd:<slug>:<locator>:<hash>`.
166
+ """
167
+
168
+ slug: str
169
+ locator: Locator
170
+ receipt: Receipt
171
+ token: str
172
+ extraction_id: int | None = None
173
+ page_number: int | None = None
174
+ start: int | None = None
175
+ end: int | None = None
176
+ id: int | None = None
177
+
178
+ @property
179
+ def kind(self) -> AnchorKind:
180
+ """`page` | `chunk` | `cell` | `range`, derived from the locator."""
181
+ return self.locator.kind # type: ignore[return-value]
182
+
183
+ def to_dict(self) -> dict[str, Any]:
184
+ """The anchor as it appears inside a BindReport citation."""
185
+ return {
186
+ "slug": self.slug,
187
+ "locator": format_locator(self.locator),
188
+ "snippet": self.receipt.snippet,
189
+ "snippet_sha256": self.receipt.snippet_sha256,
190
+ }
191
+
192
+
193
+ @dataclass(frozen=True, slots=True)
194
+ class Verdict:
195
+ """One verification method's finding for one (claim, citation)."""
196
+
197
+ method: str
198
+ status: VerdictStatus
199
+ detail: str = ""
200
+
201
+ def to_dict(self) -> dict[str, Any]:
202
+ return {"method": self.method, "status": str(self.status), "detail": self.detail}
203
+
204
+
205
+ @dataclass(frozen=True, slots=True)
206
+ class Citation:
207
+ """One token on a claim, plus everything bind learned about it.
208
+
209
+ `token` is the text exactly as authored, so a malformed citation can still be
210
+ reported verbatim. `error` explains a `MALFORMED` status — including the
211
+ reserved `bd:calc(...)` derivation form, which is recognized but unsupported.
212
+ """
213
+
214
+ token: str
215
+ status: CitationStatus = CitationStatus.UNRESOLVED
216
+ anchor: Anchor | None = None
217
+ drifted_from: str | None = None
218
+ verdicts: tuple[Verdict, ...] = ()
219
+ error: str | None = None
220
+
221
+ def to_dict(self) -> dict[str, Any]:
222
+ out: dict[str, Any] = {"token": self.token, "status": str(self.status)}
223
+ if self.anchor is not None:
224
+ out["anchor"] = self.anchor.to_dict()
225
+ if self.drifted_from is not None:
226
+ out["drifted_from"] = self.drifted_from
227
+ if self.error is not None:
228
+ out["error"] = self.error
229
+ out["verdicts"] = [verdict.to_dict() for verdict in self.verdicts]
230
+ return out
231
+
232
+
233
+ @dataclass(frozen=True, slots=True)
234
+ class Claim:
235
+ """A span of authored text and its citation tokens.
236
+
237
+ `start`/`end` bound the whole markdown link in the authored document, so
238
+ `source[start:end]` is the construct bind rewrites; `text` is the link text
239
+ alone — the words the citations support.
240
+
241
+ `unmatched` is a backfill-mode outcome: a claim bind could not anchor.
242
+ """
243
+
244
+ text: str
245
+ start: int
246
+ end: int
247
+ citations: tuple[Citation, ...] = ()
248
+ unmatched: bool = False
249
+
250
+ def to_dict(self) -> dict[str, Any]:
251
+ return {
252
+ "text": self.text,
253
+ "start": self.start,
254
+ "end": self.end,
255
+ "unmatched": self.unmatched,
256
+ "citations": [citation.to_dict() for citation in self.citations],
257
+ }
258
+
259
+
260
+ @dataclass(frozen=True, slots=True)
261
+ class BindReport:
262
+ """One bind run over one authored document.
263
+
264
+ Serialized into the sidecar artifact and `bindings.report_json`.
265
+ """
266
+
267
+ doc_path: str
268
+ mode: BindMode
269
+ bound_at: str
270
+ claims: tuple[Claim, ...] = field(default_factory=tuple)
271
+ session_id: str | None = None
272
+ evidence: dict[str, Any] | None = None
273
+ """Evidence context for the cited sources, assembled at bind time.
274
+
275
+ JSON-shaped, keyed exactly as the sidecar carries it (`documents`, `pages`,
276
+ `pagetexts`, `windows`, `sheets` — see `LEGEND["evidence"]`). None when
277
+ bind ran without a registry or evidence was skipped; the artifact then
278
+ falls back to snippets alone. Optional by design: a sidecar without
279
+ evidence is still a complete record — evidence is context, receipts are
280
+ the proof.
281
+ """
282
+
283
+ @property
284
+ def summary(self) -> dict[str, Any]:
285
+ """Counts, derived — never stored, so it can never disagree with `claims`.
286
+
287
+ NOTE: the spec fixes the keys but not the shape of `by_method`; it is
288
+ `{method: {verdict status: count}}`, the shape a run-level pass-rate
289
+ table needs.
290
+ """
291
+ by_status: dict[str, int] = {}
292
+ by_method: dict[str, dict[str, int]] = {}
293
+ citations = 0
294
+ for claim in self.claims:
295
+ for citation in claim.citations:
296
+ citations += 1
297
+ key = str(citation.status)
298
+ by_status[key] = by_status.get(key, 0) + 1
299
+ for verdict in citation.verdicts:
300
+ statuses = by_method.setdefault(verdict.method, {})
301
+ vkey = str(verdict.status)
302
+ statuses[vkey] = statuses.get(vkey, 0) + 1
303
+ return {
304
+ "claims": len(self.claims),
305
+ "citations": citations,
306
+ "by_status": by_status,
307
+ "by_method": by_method,
308
+ }
309
+
310
+ @property
311
+ def unresolved(self) -> tuple[Citation, ...]:
312
+ """Every citation that did not resolve, in document order.
313
+
314
+ The list the report and the artifact must show; nothing drops silently.
315
+ """
316
+ return tuple(
317
+ citation
318
+ for claim in self.claims
319
+ for citation in claim.citations
320
+ if citation.status is not CitationStatus.RESOLVED
321
+ )
322
+
323
+ def to_dict(self, *, include_evidence: bool = True) -> dict[str, Any]:
324
+ """Serialize. `include_evidence=False` drops the (heavy) evidence block
325
+ — the shape the registry's bindings row stores."""
326
+ payload: dict[str, Any] = {
327
+ "doc_path": self.doc_path,
328
+ "mode": self.mode,
329
+ "session_id": self.session_id,
330
+ "bound_at": self.bound_at,
331
+ "claims": [claim.to_dict() for claim in self.claims],
332
+ "summary": self.summary,
333
+ }
334
+ if include_evidence and self.evidence is not None:
335
+ payload["evidence"] = self.evidence
336
+ return payload