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
|
@@ -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
|