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,313 @@
|
|
|
1
|
+
"""A small markdown subset, rendered to HTML with stdlib only.
|
|
2
|
+
|
|
3
|
+
The artifact must be one file with no dependency at read time and none at build
|
|
4
|
+
time either, so the document body goes through this renderer rather than a
|
|
5
|
+
markdown library. The subset is exactly what an authored memo uses: headings,
|
|
6
|
+
paragraphs, lists, tables, blockquotes, fenced code, thematic breaks, and the
|
|
7
|
+
inline forms bold / italic / code / link.
|
|
8
|
+
|
|
9
|
+
Claims are not parsed here. `to_html` takes the source ranges bind already
|
|
10
|
+
located and splices caller-supplied HTML into them, so the claim spans in the
|
|
11
|
+
artifact are the spans the report recorded — never a second, disagreeing parse.
|
|
12
|
+
|
|
13
|
+
NOTE (deliberate omissions, the spec is silent on all of them): reference links,
|
|
14
|
+
setext headings, HTML passthrough, footnote syntax, and autolinks are not
|
|
15
|
+
recognized and render as literal text. Images render as their alt text: an
|
|
16
|
+
`<img>` would be an external request, which the artifact does not make.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import html
|
|
22
|
+
import re
|
|
23
|
+
from collections.abc import Sequence
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
|
|
26
|
+
__all__ = ["Span", "to_html", "inline"]
|
|
27
|
+
|
|
28
|
+
_SPLICE = "\x00s{index}\x00"
|
|
29
|
+
_ESCAPED = "\x01e{index}\x01"
|
|
30
|
+
|
|
31
|
+
_HEADING_RE = re.compile(r"(?P<level>#{1,6})\s+(?P<text>.*?)\s*#*\s*$")
|
|
32
|
+
_RULE_RE = re.compile(r"(?:\*\s*){3,}|(?:-\s*){3,}|(?:_\s*){3,}")
|
|
33
|
+
_FENCE_RE = re.compile(r"(?P<indent>\s*)(?P<fence>```+|~~~+)(?P<info>.*)$")
|
|
34
|
+
_BULLET_RE = re.compile(r"(?P<indent>[ \t]*)(?P<marker>[-*+])[ \t]+(?P<text>.*)$")
|
|
35
|
+
_ORDERED_RE = re.compile(r"(?P<indent>[ \t]*)(?P<marker>\d{1,9})[.)][ \t]+(?P<text>.*)$")
|
|
36
|
+
_QUOTE_RE = re.compile(r"[ \t]*>[ \t]?(?P<text>.*)$")
|
|
37
|
+
_ALIGN_RE = re.compile(r":?-{1,}:?")
|
|
38
|
+
_BACKSLASH_RE = re.compile(r"\\([\\`*_{}\[\]()#+\-.!|>~])")
|
|
39
|
+
_SAFE_HREF_RE = re.compile(r"\s*javascript\s*:", re.IGNORECASE)
|
|
40
|
+
|
|
41
|
+
_INLINE_RE = re.compile(
|
|
42
|
+
r"""
|
|
43
|
+
(?P<fence>`+)(?P<code>.+?)(?P=fence)
|
|
44
|
+
| !\[(?P<alt>[^\]]*)\]\([^)]*\)
|
|
45
|
+
| \[(?P<label>(?:[^\[\]]|\[[^\]]*\])*)\]\((?P<href>[^)\s]*)(?:\s+"[^"]*")?\)
|
|
46
|
+
| (?P<strong_mark>\*\*|__)(?P<strong>.+?)(?P=strong_mark)
|
|
47
|
+
| (?P<em_mark>[*_])(?P<em>.+?)(?P=em_mark)
|
|
48
|
+
""",
|
|
49
|
+
re.VERBOSE | re.DOTALL,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True, slots=True)
|
|
54
|
+
class Span:
|
|
55
|
+
"""A source range whose rendered form the caller supplies.
|
|
56
|
+
|
|
57
|
+
`start`/`end` are character offsets into the source; `html` replaces that
|
|
58
|
+
range verbatim and is never escaped or re-parsed.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
start: int
|
|
62
|
+
end: int
|
|
63
|
+
html: str
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def to_html(source: str, spans: Sequence[Span] = ()) -> str:
|
|
67
|
+
"""Render `source` as HTML, splicing each span's HTML into its source range.
|
|
68
|
+
|
|
69
|
+
Spans must not overlap; they are applied in source order.
|
|
70
|
+
"""
|
|
71
|
+
masked, splices = _mask_spans(source, spans)
|
|
72
|
+
rendered = _blocks(masked.expandtabs(4).splitlines())
|
|
73
|
+
for index, markup in enumerate(splices):
|
|
74
|
+
rendered = rendered.replace(_SPLICE.format(index=index), markup)
|
|
75
|
+
return rendered
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def inline(text: str) -> str:
|
|
79
|
+
"""Render one run of inline markdown — the form used inside a claim's text."""
|
|
80
|
+
escapes: list[str] = []
|
|
81
|
+
|
|
82
|
+
def stash(match: re.Match[str]) -> str:
|
|
83
|
+
escapes.append(html.escape(match.group(1), quote=False))
|
|
84
|
+
return _ESCAPED.format(index=len(escapes) - 1)
|
|
85
|
+
|
|
86
|
+
rendered = _inline(_BACKSLASH_RE.sub(stash, text))
|
|
87
|
+
for index, literal in enumerate(escapes):
|
|
88
|
+
rendered = rendered.replace(_ESCAPED.format(index=index), literal)
|
|
89
|
+
return rendered
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _mask_spans(source: str, spans: Sequence[Span]) -> tuple[str, list[str]]:
|
|
93
|
+
"""Replace each span's source range with a splice marker."""
|
|
94
|
+
ordered = sorted(spans, key=lambda span: span.start)
|
|
95
|
+
pieces: list[str] = []
|
|
96
|
+
splices: list[str] = []
|
|
97
|
+
cursor = 0
|
|
98
|
+
for span in ordered:
|
|
99
|
+
if span.start < cursor:
|
|
100
|
+
raise ValueError(f"overlapping span at {span.start}")
|
|
101
|
+
pieces.append(source[cursor : span.start])
|
|
102
|
+
pieces.append(_SPLICE.format(index=len(splices)))
|
|
103
|
+
splices.append(span.html)
|
|
104
|
+
cursor = span.end
|
|
105
|
+
pieces.append(source[cursor:])
|
|
106
|
+
return "".join(pieces), splices
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _blocks(lines: Sequence[str]) -> str:
|
|
110
|
+
"""Render a sequence of lines as block-level HTML."""
|
|
111
|
+
out: list[str] = []
|
|
112
|
+
index = 0
|
|
113
|
+
total = len(lines)
|
|
114
|
+
while index < total:
|
|
115
|
+
line = lines[index]
|
|
116
|
+
if not line.strip():
|
|
117
|
+
index += 1
|
|
118
|
+
continue
|
|
119
|
+
if fence := _FENCE_RE.fullmatch(line):
|
|
120
|
+
index = _code_block(lines, index, fence, out)
|
|
121
|
+
continue
|
|
122
|
+
if heading := _HEADING_RE.fullmatch(line.strip()):
|
|
123
|
+
level = len(heading["level"])
|
|
124
|
+
out.append(f"<h{level}>{inline(heading['text'])}</h{level}>")
|
|
125
|
+
index += 1
|
|
126
|
+
continue
|
|
127
|
+
if _RULE_RE.fullmatch(line.strip()):
|
|
128
|
+
out.append("<hr>")
|
|
129
|
+
index += 1
|
|
130
|
+
continue
|
|
131
|
+
if _QUOTE_RE.fullmatch(line):
|
|
132
|
+
index = _quote_block(lines, index, out)
|
|
133
|
+
continue
|
|
134
|
+
if _BULLET_RE.fullmatch(line) or _ORDERED_RE.fullmatch(line):
|
|
135
|
+
index = _list_block(lines, index, out)
|
|
136
|
+
continue
|
|
137
|
+
if index + 1 < total and _is_table_rule(lines[index + 1]) and "|" in line:
|
|
138
|
+
index = _table_block(lines, index, out)
|
|
139
|
+
continue
|
|
140
|
+
index = _paragraph(lines, index, out)
|
|
141
|
+
return "\n".join(out)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _code_block(lines: Sequence[str], index: int, fence: re.Match[str], out: list[str]) -> int:
|
|
145
|
+
"""Consume a fenced code block. Its content is never inline-rendered."""
|
|
146
|
+
marker = fence["fence"]
|
|
147
|
+
body: list[str] = []
|
|
148
|
+
index += 1
|
|
149
|
+
while index < len(lines) and lines[index].strip() != marker:
|
|
150
|
+
body.append(lines[index])
|
|
151
|
+
index += 1
|
|
152
|
+
escaped = html.escape("\n".join(body), quote=False)
|
|
153
|
+
out.append(f"<pre><code>{escaped}</code></pre>")
|
|
154
|
+
return index + 1 if index < len(lines) else index
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _quote_block(lines: Sequence[str], index: int, out: list[str]) -> int:
|
|
158
|
+
body: list[str] = []
|
|
159
|
+
while index < len(lines) and (match := _QUOTE_RE.fullmatch(lines[index])):
|
|
160
|
+
body.append(match["text"])
|
|
161
|
+
index += 1
|
|
162
|
+
out.append(f"<blockquote>{_blocks(body)}</blockquote>")
|
|
163
|
+
return index
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _list_block(lines: Sequence[str], index: int, out: list[str]) -> int:
|
|
167
|
+
"""Consume one list, including indented continuation lines and sublists."""
|
|
168
|
+
ordered = _ORDERED_RE.fullmatch(lines[index]) is not None
|
|
169
|
+
items: list[list[str]] = []
|
|
170
|
+
loose = False
|
|
171
|
+
pending_blank = False
|
|
172
|
+
while index < len(lines):
|
|
173
|
+
line = lines[index]
|
|
174
|
+
item = _ORDERED_RE.fullmatch(line) if ordered else _BULLET_RE.fullmatch(line)
|
|
175
|
+
if item is not None and len(item["indent"]) < 2:
|
|
176
|
+
items.append([item["text"]])
|
|
177
|
+
loose = loose or (pending_blank and len(items) > 1)
|
|
178
|
+
pending_blank = False
|
|
179
|
+
index += 1
|
|
180
|
+
continue
|
|
181
|
+
if not line.strip():
|
|
182
|
+
if not items:
|
|
183
|
+
break
|
|
184
|
+
pending_blank = True
|
|
185
|
+
index += 1
|
|
186
|
+
continue
|
|
187
|
+
if items and line.startswith(" "):
|
|
188
|
+
if pending_blank:
|
|
189
|
+
items[-1].append("")
|
|
190
|
+
loose = True
|
|
191
|
+
pending_blank = False
|
|
192
|
+
items[-1].append(line[2:])
|
|
193
|
+
index += 1
|
|
194
|
+
continue
|
|
195
|
+
break
|
|
196
|
+
rendered = [f"<li>{_item(body, loose)}</li>" for body in items]
|
|
197
|
+
tag = "ol" if ordered else "ul"
|
|
198
|
+
out.append(f"<{tag}>{''.join(rendered)}</{tag}>")
|
|
199
|
+
return index
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _item(body: Sequence[str], loose: bool) -> str:
|
|
203
|
+
"""One list item: bare inline when it is a single simple line, blocks otherwise."""
|
|
204
|
+
if not loose and len(body) == 1:
|
|
205
|
+
return inline(body[0])
|
|
206
|
+
return _blocks(body)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _table_block(lines: Sequence[str], index: int, out: list[str]) -> int:
|
|
210
|
+
header = _row(lines[index])
|
|
211
|
+
alignments = [_alignment(cell) for cell in _row(lines[index + 1])]
|
|
212
|
+
index += 2
|
|
213
|
+
rows: list[list[str]] = []
|
|
214
|
+
while index < len(lines) and "|" in lines[index] and lines[index].strip():
|
|
215
|
+
rows.append(_row(lines[index]))
|
|
216
|
+
index += 1
|
|
217
|
+
head = "".join(
|
|
218
|
+
f"<th{_align_attr(alignments, column)}>{inline(cell)}</th>"
|
|
219
|
+
for column, cell in enumerate(header)
|
|
220
|
+
)
|
|
221
|
+
body = "".join(
|
|
222
|
+
"<tr>"
|
|
223
|
+
+ "".join(
|
|
224
|
+
f"<td{_align_attr(alignments, column)}>{inline(cell)}</td>"
|
|
225
|
+
for column, cell in enumerate(row)
|
|
226
|
+
)
|
|
227
|
+
+ "</tr>"
|
|
228
|
+
for row in rows
|
|
229
|
+
)
|
|
230
|
+
out.append(
|
|
231
|
+
'<div class="table-wrap"><table>'
|
|
232
|
+
f"<thead><tr>{head}</tr></thead><tbody>{body}</tbody>"
|
|
233
|
+
"</table></div>"
|
|
234
|
+
)
|
|
235
|
+
return index
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _paragraph(lines: Sequence[str], index: int, out: list[str]) -> int:
|
|
239
|
+
body: list[str] = []
|
|
240
|
+
while index < len(lines) and lines[index].strip():
|
|
241
|
+
line = lines[index]
|
|
242
|
+
if body and (
|
|
243
|
+
_HEADING_RE.fullmatch(line.strip())
|
|
244
|
+
or _FENCE_RE.fullmatch(line)
|
|
245
|
+
or _QUOTE_RE.fullmatch(line)
|
|
246
|
+
or _BULLET_RE.fullmatch(line)
|
|
247
|
+
or _ORDERED_RE.fullmatch(line)
|
|
248
|
+
):
|
|
249
|
+
break
|
|
250
|
+
body.append(line.strip())
|
|
251
|
+
index += 1
|
|
252
|
+
out.append(f"<p>{inline(' '.join(body))}</p>")
|
|
253
|
+
return index
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _is_table_rule(line: str) -> bool:
|
|
257
|
+
stripped = line.strip()
|
|
258
|
+
if "-" not in stripped or not stripped:
|
|
259
|
+
return False
|
|
260
|
+
cells = _row(line)
|
|
261
|
+
return bool(cells) and all(_ALIGN_RE.fullmatch(cell) for cell in cells)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _row(line: str) -> list[str]:
|
|
265
|
+
stripped = line.strip()
|
|
266
|
+
if stripped.startswith("|"):
|
|
267
|
+
stripped = stripped[1:]
|
|
268
|
+
if stripped.endswith("|"):
|
|
269
|
+
stripped = stripped[:-1]
|
|
270
|
+
return [cell.strip() for cell in stripped.split("|")]
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _alignment(cell: str) -> str:
|
|
274
|
+
left, right = cell.startswith(":"), cell.endswith(":")
|
|
275
|
+
if left and right:
|
|
276
|
+
return "center"
|
|
277
|
+
if right:
|
|
278
|
+
return "right"
|
|
279
|
+
return "left"
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _align_attr(alignments: Sequence[str], column: int) -> str:
|
|
283
|
+
align = alignments[column] if column < len(alignments) else "left"
|
|
284
|
+
return "" if align == "left" else f' class="t-{align}"'
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _inline(text: str) -> str:
|
|
288
|
+
"""Inline rendering over text whose backslash escapes are already stashed."""
|
|
289
|
+
out: list[str] = []
|
|
290
|
+
cursor = 0
|
|
291
|
+
for match in _INLINE_RE.finditer(text):
|
|
292
|
+
out.append(html.escape(text[cursor : match.start()], quote=False))
|
|
293
|
+
out.append(_inline_match(match))
|
|
294
|
+
cursor = match.end()
|
|
295
|
+
out.append(html.escape(text[cursor:], quote=False))
|
|
296
|
+
return "".join(out)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _inline_match(match: re.Match[str]) -> str:
|
|
300
|
+
if (code := match["code"]) is not None:
|
|
301
|
+
return f"<code>{html.escape(code.strip(), quote=False)}</code>"
|
|
302
|
+
if (alt := match["alt"]) is not None:
|
|
303
|
+
# NOTE: images are not embedded in v0 (no page images, no external
|
|
304
|
+
# requests), so an image renders as its alt text, marked as such.
|
|
305
|
+
return f'<span class="bd-image" title="image omitted">{_inline(alt)}</span>'
|
|
306
|
+
if (label := match["label"]) is not None:
|
|
307
|
+
href = match["href"] or ""
|
|
308
|
+
if _SAFE_HREF_RE.match(href):
|
|
309
|
+
return _inline(label)
|
|
310
|
+
return f'<a href="{html.escape(href, quote=True)}">{_inline(label)}</a>'
|
|
311
|
+
if (strong := match["strong"]) is not None:
|
|
312
|
+
return f"<strong>{_inline(strong)}</strong>"
|
|
313
|
+
return f"<em>{_inline(match['em'])}</em>"
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Finding each claim's span in the document render receives.
|
|
2
|
+
|
|
3
|
+
Render's only inputs are an authored document and a sidecar; the registry may be
|
|
4
|
+
gone. A claim carries `start`/`end` into the document bind read, which is usually
|
|
5
|
+
the document render is handed — but bind also rewrites citations into readable
|
|
6
|
+
form, so the file on disk may be the rewritten one and the offsets may have
|
|
7
|
+
moved. Rather than guess, we try three locators in order and record which claims
|
|
8
|
+
could not be placed at all; an unplaced claim still appears in the artifact, in
|
|
9
|
+
its own section, because nothing drops silently.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from collections.abc import Sequence
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
|
|
17
|
+
from ..kernel.model import Claim
|
|
18
|
+
|
|
19
|
+
__all__ = ["Placement", "locate"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True, slots=True)
|
|
23
|
+
class Placement:
|
|
24
|
+
"""One claim and where it sits in the document being rendered.
|
|
25
|
+
|
|
26
|
+
`number` is the claim's 1-based position in document order — the number the
|
|
27
|
+
artifact labels it with. `start`/`end` are None when the claim's text could
|
|
28
|
+
not be found in the document at all.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
number: int
|
|
32
|
+
claim: Claim
|
|
33
|
+
start: int | None = None
|
|
34
|
+
end: int | None = None
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def placed(self) -> bool:
|
|
38
|
+
"""True when this claim has a span in the document body."""
|
|
39
|
+
return self.start is not None and self.end is not None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def locate(source: str, claims: Sequence[Claim]) -> list[Placement]:
|
|
43
|
+
"""Place every claim in `source`, in document order.
|
|
44
|
+
|
|
45
|
+
Three locators, in order of confidence:
|
|
46
|
+
|
|
47
|
+
1. the recorded offsets, when the construct they bound is still there;
|
|
48
|
+
2. the first `[claim text](` link at or after the previous claim — the shape
|
|
49
|
+
bind leaves behind when it rewrites tokens into readable citations;
|
|
50
|
+
3. the first bare occurrence of the claim's text, for a document whose
|
|
51
|
+
citations were projected away entirely.
|
|
52
|
+
|
|
53
|
+
A claim that matches none of the three is returned unplaced.
|
|
54
|
+
"""
|
|
55
|
+
placements: list[Placement] = []
|
|
56
|
+
cursor = 0
|
|
57
|
+
for number, claim in enumerate(claims, start=1):
|
|
58
|
+
span = _at_offsets(source, claim) or _as_link(source, claim, cursor)
|
|
59
|
+
if span is None:
|
|
60
|
+
span = _as_text(source, claim, cursor)
|
|
61
|
+
if span is None:
|
|
62
|
+
placements.append(Placement(number=number, claim=claim))
|
|
63
|
+
continue
|
|
64
|
+
start, end = span
|
|
65
|
+
placements.append(Placement(number=number, claim=claim, start=start, end=end))
|
|
66
|
+
cursor = end
|
|
67
|
+
return placements
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _at_offsets(source: str, claim: Claim) -> tuple[int, int] | None:
|
|
71
|
+
"""The recorded span, if the document still holds the construct bind saw."""
|
|
72
|
+
if not 0 <= claim.start < claim.end <= len(source):
|
|
73
|
+
return None
|
|
74
|
+
construct = source[claim.start : claim.end]
|
|
75
|
+
if construct == claim.text:
|
|
76
|
+
return (claim.start, claim.end)
|
|
77
|
+
if construct.startswith(f"[{claim.text}](") and construct.endswith(")"):
|
|
78
|
+
return (claim.start, claim.end)
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _as_link(source: str, claim: Claim, cursor: int) -> tuple[int, int] | None:
|
|
83
|
+
"""The first `[claim text](...)` link at or after `cursor`.
|
|
84
|
+
|
|
85
|
+
NOTE: the closing parenthesis is the first one after the opener. Tokens
|
|
86
|
+
cannot contain parentheses, and the readable citations bind writes are
|
|
87
|
+
fragment references, so this is exact for every href render can meet.
|
|
88
|
+
"""
|
|
89
|
+
start = source.find(f"[{claim.text}](", cursor)
|
|
90
|
+
if start < 0:
|
|
91
|
+
return None
|
|
92
|
+
close = source.find(")", start + len(claim.text) + 3)
|
|
93
|
+
if close < 0:
|
|
94
|
+
return None
|
|
95
|
+
return (start, close + 1)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _as_text(source: str, claim: Claim, cursor: int) -> tuple[int, int] | None:
|
|
99
|
+
"""The first bare occurrence of the claim's text at or after `cursor`."""
|
|
100
|
+
if not claim.text:
|
|
101
|
+
return None
|
|
102
|
+
start = source.find(claim.text, cursor)
|
|
103
|
+
if start < 0:
|
|
104
|
+
return None
|
|
105
|
+
return (start, start + len(claim.text))
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""The sidecar: one bind run, written as a standalone self-describing JSON file.
|
|
2
|
+
|
|
3
|
+
The sidecar is the machine-readable half of the artifact. It is exactly a
|
|
4
|
+
`BindReport` payload with two reserved keys in front of it:
|
|
5
|
+
|
|
6
|
+
* ``$format`` — ``backdraft/artifact-v1``, matched exactly (never parsed, never
|
|
7
|
+
range-checked); and
|
|
8
|
+
* ``$legend`` — prose that teaches a reader who has never seen backdraft how to
|
|
9
|
+
decode the rest of the object.
|
|
10
|
+
|
|
11
|
+
The HTML artifact embeds this same payload, byte for byte, in its JSON island:
|
|
12
|
+
``render --to json`` and the island of ``render --to html`` are the same bytes,
|
|
13
|
+
so a reader can treat either as the record.
|
|
14
|
+
|
|
15
|
+
The format itself — `FORMAT`, `LEGEND`, `SIDECAR_SUFFIX`, `sidecar_path`, and the
|
|
16
|
+
`sidecar` / `dumps` writers — lives in `kernel/artifact.py`, because bind writes
|
|
17
|
+
it and render reads it and neither owns it; the file's *name* is part of the
|
|
18
|
+
format for the same reason. This module is the render-side door onto it: it
|
|
19
|
+
re-exports those names and adds the reader that turns a sidecar file back into a
|
|
20
|
+
`BindReport`. `spec/artifact.md` is the prose specification; where it and the
|
|
21
|
+
kernel's legend disagree, the spec file decides.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import json
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
from ..kernel.artifact import ( # noqa: F401 (re-exported: the format is kernel-owned)
|
|
31
|
+
FORMAT,
|
|
32
|
+
LEGEND,
|
|
33
|
+
SIDECAR_SUFFIX,
|
|
34
|
+
dumps,
|
|
35
|
+
record_path,
|
|
36
|
+
sidecar,
|
|
37
|
+
sidecar_path,
|
|
38
|
+
)
|
|
39
|
+
from ..kernel.model import (
|
|
40
|
+
Anchor,
|
|
41
|
+
BindReport,
|
|
42
|
+
Citation,
|
|
43
|
+
CitationStatus,
|
|
44
|
+
Claim,
|
|
45
|
+
Receipt,
|
|
46
|
+
Verdict,
|
|
47
|
+
VerdictStatus,
|
|
48
|
+
)
|
|
49
|
+
from ..kernel.tokens import parse_locator
|
|
50
|
+
|
|
51
|
+
__all__ = [
|
|
52
|
+
"FORMAT",
|
|
53
|
+
"LEGEND",
|
|
54
|
+
"SIDECAR_SUFFIX",
|
|
55
|
+
"sidecar",
|
|
56
|
+
"dumps",
|
|
57
|
+
"write",
|
|
58
|
+
"read",
|
|
59
|
+
"to_report",
|
|
60
|
+
"sidecar_path",
|
|
61
|
+
"find_sidecar",
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def write(report: BindReport, path: Path) -> Path:
|
|
66
|
+
"""Write the sidecar to `path`. Returns the path written."""
|
|
67
|
+
path.write_text(dumps(report), encoding="utf-8")
|
|
68
|
+
return path
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def read(path: Path) -> BindReport:
|
|
72
|
+
"""Read a sidecar file back into a `BindReport`.
|
|
73
|
+
|
|
74
|
+
Raises `ValueError` if the file is not a payload of this exact format.
|
|
75
|
+
"""
|
|
76
|
+
return to_report(json.loads(path.read_text(encoding="utf-8")))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def to_report(payload: dict[str, Any]) -> BindReport:
|
|
80
|
+
"""Rebuild the `BindReport` a sidecar payload carries.
|
|
81
|
+
|
|
82
|
+
`$format` is matched exactly; a payload carrying anything else is refused
|
|
83
|
+
rather than interpreted. Round-trip is stable at the payload level:
|
|
84
|
+
`sidecar(to_report(payload)) == payload` for any payload this accepts.
|
|
85
|
+
|
|
86
|
+
NOTE: `Anchor.extraction_id`, `start` and `end` are registry-side fields the
|
|
87
|
+
sidecar deliberately does not carry, so they come back `None`; `page_number`
|
|
88
|
+
is recovered from the locator when the locator has one.
|
|
89
|
+
"""
|
|
90
|
+
if not isinstance(payload, dict):
|
|
91
|
+
raise ValueError(f"sidecar payload must be an object, not {type(payload).__name__}")
|
|
92
|
+
found = payload.get("$format")
|
|
93
|
+
if found != FORMAT:
|
|
94
|
+
raise ValueError(f"unknown artifact format {found!r}; this reader speaks {FORMAT!r}")
|
|
95
|
+
return BindReport(
|
|
96
|
+
doc_path=payload["doc_path"],
|
|
97
|
+
mode=payload["mode"],
|
|
98
|
+
bound_at=payload["bound_at"],
|
|
99
|
+
session_id=payload.get("session_id"),
|
|
100
|
+
claims=tuple(_claim(entry) for entry in payload.get("claims", ())),
|
|
101
|
+
evidence=payload.get("evidence"),
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def find_sidecar(doc_path: Path) -> Path | None:
|
|
106
|
+
"""The document's record, or None.
|
|
107
|
+
|
|
108
|
+
Looked for in order: beside the document (`<stem>.backdraft.json` — the
|
|
109
|
+
portable form a reader is handed), the whole-filename variant a person
|
|
110
|
+
types (`memo.md.backdraft.json`), then the project's records store —
|
|
111
|
+
`.backdraft/records/` under the nearest ancestor holding a `.backdraft`
|
|
112
|
+
directory, which is where a rooted bind writes.
|
|
113
|
+
"""
|
|
114
|
+
for candidate in (sidecar_path(doc_path), doc_path.with_name(doc_path.name + SIDECAR_SUFFIX)):
|
|
115
|
+
if candidate.is_file():
|
|
116
|
+
return candidate
|
|
117
|
+
resolved = doc_path.resolve()
|
|
118
|
+
for ancestor in resolved.parents:
|
|
119
|
+
if (ancestor / ".backdraft").is_dir():
|
|
120
|
+
candidate = record_path(ancestor, resolved)
|
|
121
|
+
return candidate if candidate.is_file() else None
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _claim(entry: dict[str, Any]) -> Claim:
|
|
126
|
+
return Claim(
|
|
127
|
+
text=entry["text"],
|
|
128
|
+
start=entry["start"],
|
|
129
|
+
end=entry["end"],
|
|
130
|
+
unmatched=bool(entry.get("unmatched", False)),
|
|
131
|
+
citations=tuple(_citation(item) for item in entry.get("citations", ())),
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _citation(entry: dict[str, Any]) -> Citation:
|
|
136
|
+
token = entry["token"]
|
|
137
|
+
anchor = entry.get("anchor")
|
|
138
|
+
return Citation(
|
|
139
|
+
token=token,
|
|
140
|
+
status=CitationStatus(entry["status"]),
|
|
141
|
+
anchor=_anchor(anchor, token) if anchor is not None else None,
|
|
142
|
+
drifted_from=entry.get("drifted_from"),
|
|
143
|
+
error=entry.get("error"),
|
|
144
|
+
verdicts=tuple(_verdict(item) for item in entry.get("verdicts", ())),
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _anchor(entry: dict[str, Any], token: str) -> Anchor:
|
|
149
|
+
locator = parse_locator(entry["locator"])
|
|
150
|
+
return Anchor(
|
|
151
|
+
slug=entry["slug"],
|
|
152
|
+
locator=locator,
|
|
153
|
+
receipt=Receipt(snippet=entry["snippet"], snippet_sha256=entry["snippet_sha256"]),
|
|
154
|
+
token=token,
|
|
155
|
+
page_number=getattr(locator, "page", None),
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _verdict(entry: dict[str, Any]) -> Verdict:
|
|
160
|
+
return Verdict(
|
|
161
|
+
method=entry["method"],
|
|
162
|
+
status=VerdictStatus(entry["status"]),
|
|
163
|
+
detail=entry.get("detail", ""),
|
|
164
|
+
)
|