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,31 @@
|
|
|
1
|
+
"""render — the artifact and its projections.
|
|
2
|
+
|
|
3
|
+
Three renderings of one bind run, from an authored document plus its sidecar and
|
|
4
|
+
nothing else (the registry is never opened here):
|
|
5
|
+
|
|
6
|
+
* `html` — the single-file artifact: the document, a receipt behind every claim,
|
|
7
|
+
a visible Unresolved section, and a JSON island that teaches its own decoding.
|
|
8
|
+
* `footnotes` — the plain-markdown projection.
|
|
9
|
+
* `sidecar` — the machine-readable record alone, `backdraft/artifact-v1`.
|
|
10
|
+
|
|
11
|
+
Export style (shared by every package above the kernel): **names**, never
|
|
12
|
+
submodules. The three renderers each spell their entry point `render`, so they
|
|
13
|
+
are reached by module path — `from backdraft.render import html`, then
|
|
14
|
+
`html.render(...)` — which also means importing this package does not drag all
|
|
15
|
+
three in. `render.cli` is not imported here either: it needs typer; the library
|
|
16
|
+
does not.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from .placement import Placement, locate
|
|
22
|
+
from .sidecar import FORMAT, LEGEND, SIDECAR_SUFFIX, sidecar_path
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"Placement",
|
|
26
|
+
"locate",
|
|
27
|
+
"FORMAT",
|
|
28
|
+
"LEGEND",
|
|
29
|
+
"SIDECAR_SUFFIX",
|
|
30
|
+
"sidecar_path",
|
|
31
|
+
]
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Presentation helpers the renderers share: status prose, elision, quoting.
|
|
2
|
+
|
|
3
|
+
Both renderers project the same report, so both have to say the same things about
|
|
4
|
+
it. When the artifact and the markdown projection each carried their own copy of
|
|
5
|
+
these, they drifted — the same `not_shown` citation was "a valid anchor the
|
|
6
|
+
writer was never shown" in one and "A valid anchor, but the writer was never
|
|
7
|
+
shown it." in the other, which is two answers to one question.
|
|
8
|
+
|
|
9
|
+
The wording here is checked against `kernel/artifact.py`'s `LEGEND`
|
|
10
|
+
("citation_status"), which is the normative statement of what each status means;
|
|
11
|
+
these are its one-line renderings, not a second definition.
|
|
12
|
+
|
|
13
|
+
Private to `render/`: the dependency rule forbids sideways imports, so bind keeps
|
|
14
|
+
its own quoting even where the code looks alike.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from ..kernel.model import CitationStatus
|
|
20
|
+
|
|
21
|
+
__all__ = ["STATUS_NOTE", "quote_lines", "sentence", "short", "status_note"]
|
|
22
|
+
|
|
23
|
+
STATUS_NOTE: dict[CitationStatus, str] = {
|
|
24
|
+
CitationStatus.DRIFTED: "the source changed after this claim was written",
|
|
25
|
+
CitationStatus.NOT_SHOWN: "a valid anchor, but the writer was never shown it",
|
|
26
|
+
CitationStatus.UNRESOLVED: "no anchor for this token in any generation",
|
|
27
|
+
CitationStatus.MALFORMED: "the citation text is not a token",
|
|
28
|
+
}
|
|
29
|
+
"""Why a citation did not resolve, as a lowercase fragment.
|
|
30
|
+
|
|
31
|
+
`resolved` is deliberately absent: a resolved citation needs no explanation, and
|
|
32
|
+
`status_note` returning "" is what the callers test for.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def status_note(status: CitationStatus) -> str:
|
|
37
|
+
"""The one-line reason for a status, or "" when there is nothing to say."""
|
|
38
|
+
return STATUS_NOTE.get(status, "")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def sentence(text: str) -> str:
|
|
42
|
+
"""A fragment as a standalone sentence: capitalized, terminally punctuated.
|
|
43
|
+
|
|
44
|
+
The markdown projection puts these after a colon, where a fragment reads
|
|
45
|
+
correctly; the artifact shows them on their own, where it does not.
|
|
46
|
+
"""
|
|
47
|
+
if not text:
|
|
48
|
+
return ""
|
|
49
|
+
return text[0].upper() + text[1:] + ("" if text.endswith((".", "!", "?")) else ".")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def short(text: str, *, limit: int) -> str:
|
|
53
|
+
"""A claim's text, collapsed to one line and elided at `limit` characters.
|
|
54
|
+
|
|
55
|
+
`limit` is the caller's, because the two renderers have different room: an
|
|
56
|
+
artifact's failure list is a styled block, a markdown list item is a line.
|
|
57
|
+
"""
|
|
58
|
+
flat = " ".join(text.split())
|
|
59
|
+
return flat if len(flat) <= limit else flat[: limit - 1].rstrip() + "…"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def quote_lines(snippet: str) -> list[str]:
|
|
63
|
+
"""A verbatim snippet as markdown blockquote lines, line for line.
|
|
64
|
+
|
|
65
|
+
NOTE: `bind.binder` has its own, on purpose — `bind` may not import `render`,
|
|
66
|
+
and its References section quotes with slightly different blank-line handling
|
|
67
|
+
besides.
|
|
68
|
+
"""
|
|
69
|
+
return [f"> {line}" if line.strip() else ">" for line in snippet.splitlines() or [""]]
|
backdraft/render/cli.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""`backdraft render` — the artifact, the markdown projection, or the sidecar.
|
|
2
|
+
|
|
3
|
+
Render's inputs are an authored document and the sidecar bind wrote beside it.
|
|
4
|
+
The registry is never opened here: an artifact must be reproducible from the two
|
|
5
|
+
files a reader was handed, on a machine that has never seen the sources.
|
|
6
|
+
|
|
7
|
+
Mounted by the top-level CLI as `app`, per SPEC Addendum B:
|
|
8
|
+
|
|
9
|
+
from backdraft.render import cli as render_cli
|
|
10
|
+
app.registered_commands.extend(render_cli.app.registered_commands)
|
|
11
|
+
|
|
12
|
+
which puts this module's one command at `backdraft render <doc.md>`.
|
|
13
|
+
|
|
14
|
+
NOTE: typer collapses a single-command app into a bare command, so invoking this
|
|
15
|
+
module's `app` on its own takes the document as its first argument and does not
|
|
16
|
+
want the word `render`. The command name only exists once it is mounted next to
|
|
17
|
+
the top level's other commands.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import sys
|
|
23
|
+
from enum import StrEnum
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Annotated
|
|
26
|
+
|
|
27
|
+
import typer
|
|
28
|
+
|
|
29
|
+
from ..cli_context import UsageError, guard
|
|
30
|
+
from ..kernel.artifact import (
|
|
31
|
+
ARTIFACT_SUFFIX,
|
|
32
|
+
FOOTNOTES_SUFFIX,
|
|
33
|
+
SIDECAR_SUFFIX,
|
|
34
|
+
)
|
|
35
|
+
from . import footnotes, html, sidecar
|
|
36
|
+
|
|
37
|
+
__all__ = ["app", "Target"]
|
|
38
|
+
|
|
39
|
+
app = typer.Typer(help="Render a bound document as a self-contained artifact.")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Target(StrEnum):
|
|
43
|
+
"""What to render. `html` is the artifact; the others are projections of it."""
|
|
44
|
+
|
|
45
|
+
HTML = "html"
|
|
46
|
+
FOOTNOTES = "footnotes"
|
|
47
|
+
JSON = "json"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
_SUFFIX = {
|
|
51
|
+
Target.HTML: ARTIFACT_SUFFIX,
|
|
52
|
+
Target.FOOTNOTES: FOOTNOTES_SUFFIX,
|
|
53
|
+
Target.JSON: SIDECAR_SUFFIX,
|
|
54
|
+
}
|
|
55
|
+
"""The naming family is the format's, so it comes from `kernel/artifact.py`."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@app.command()
|
|
59
|
+
def render(
|
|
60
|
+
doc: Annotated[Path, typer.Argument(help="The authored markdown document.")],
|
|
61
|
+
to: Annotated[Target, typer.Option("--to", help="Output form.")] = Target.HTML,
|
|
62
|
+
out: Annotated[
|
|
63
|
+
Path | None,
|
|
64
|
+
typer.Option("-o", "--out", help="Output file; '-' writes to stdout."),
|
|
65
|
+
] = None,
|
|
66
|
+
) -> None:
|
|
67
|
+
"""Render `doc` and its sidecar.
|
|
68
|
+
|
|
69
|
+
The sidecar is found beside the document as `<doc>.backdraft.json` (see
|
|
70
|
+
spec/artifact.md). Exit codes: 0 on success, 1 when the document or its
|
|
71
|
+
sidecar is missing or unreadable.
|
|
72
|
+
|
|
73
|
+
NOTE: the spec's exit code 2 belongs to `bind` — render reports unresolved
|
|
74
|
+
citations in the artifact, which is the whole point, and does not fail on
|
|
75
|
+
them.
|
|
76
|
+
"""
|
|
77
|
+
with guard():
|
|
78
|
+
if not doc.is_file():
|
|
79
|
+
raise UsageError(f"no such document: {doc}")
|
|
80
|
+
found = sidecar.find_sidecar(doc)
|
|
81
|
+
if found is None:
|
|
82
|
+
raise UsageError(
|
|
83
|
+
f"no sidecar beside {doc.name}: expected {sidecar.sidecar_path(doc).name}. "
|
|
84
|
+
"Run `backdraft bind` first."
|
|
85
|
+
)
|
|
86
|
+
try:
|
|
87
|
+
report = sidecar.read(found)
|
|
88
|
+
except (ValueError, KeyError, OSError) as error:
|
|
89
|
+
raise UsageError(f"unreadable sidecar {found.name}: {error}") from error
|
|
90
|
+
|
|
91
|
+
source = doc.read_text(encoding="utf-8")
|
|
92
|
+
if to is Target.HTML:
|
|
93
|
+
text = html.render(source, report)
|
|
94
|
+
elif to is Target.FOOTNOTES:
|
|
95
|
+
text = footnotes.render(source, report)
|
|
96
|
+
else:
|
|
97
|
+
text = sidecar.dumps(report)
|
|
98
|
+
|
|
99
|
+
# NOTE: `--to json` writes to stdout unless told otherwise — its default file
|
|
100
|
+
# name is the sidecar it just read, and rewriting an input in place is not a
|
|
101
|
+
# thing a render command should do quietly.
|
|
102
|
+
if (out is not None and str(out) == "-") or (out is None and to is Target.JSON):
|
|
103
|
+
sys.stdout.write(text)
|
|
104
|
+
return
|
|
105
|
+
target = out or doc.with_name(doc.stem + _SUFFIX[to])
|
|
106
|
+
target.write_text(text, encoding="utf-8")
|
|
107
|
+
typer.secho(str(target), fg=typer.colors.GREEN)
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""The markdown projection: claims become footnote refs, receipts become notes.
|
|
2
|
+
|
|
3
|
+
The lossy, portable rendering — for a pull request, an email, a wiki, anywhere
|
|
4
|
+
HTML cannot go. Every receipt still travels: document, locator, verbatim quote,
|
|
5
|
+
snippet hash, statuses and verdicts. What it gives up is the click.
|
|
6
|
+
|
|
7
|
+
The projection is total. Every claim gets a ref, every citation gets a note, and
|
|
8
|
+
every non-resolved citation is repeated in a visible Unresolved section, because
|
|
9
|
+
failures are data.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from ..kernel.model import BindReport, Citation, CitationStatus
|
|
15
|
+
from ._text import quote_lines as _quote
|
|
16
|
+
from ._text import short, status_note
|
|
17
|
+
from .placement import Placement, locate
|
|
18
|
+
from .sidecar import FORMAT, SIDECAR_SUFFIX
|
|
19
|
+
|
|
20
|
+
__all__ = ["render", "LABEL_PREFIX"]
|
|
21
|
+
|
|
22
|
+
LABEL_PREFIX = "bd"
|
|
23
|
+
"""Footnote labels are `bd1`, `bd2`, ... in document order."""
|
|
24
|
+
|
|
25
|
+
CLAIM_ECHO_CHARS = 72
|
|
26
|
+
"""How much of a claim a list entry echoes: one markdown line's worth."""
|
|
27
|
+
|
|
28
|
+
_Note = tuple[str, list[str]]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def render(source: str, report: BindReport) -> str:
|
|
32
|
+
"""Render `source` and its bind report as plain markdown with footnotes."""
|
|
33
|
+
placements = locate(source, report.claims)
|
|
34
|
+
notes: list[_Note] = []
|
|
35
|
+
body = _body(source, placements, notes)
|
|
36
|
+
sections = [body.rstrip(), "---", _receipts(report, notes), _unresolved(placements)]
|
|
37
|
+
return "\n\n".join(section for section in sections if section) + "\n"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _body(source: str, placements: list[Placement], notes: list[_Note]) -> str:
|
|
41
|
+
"""The document with each claim's citation construct replaced by refs."""
|
|
42
|
+
pieces: list[str] = []
|
|
43
|
+
cursor = 0
|
|
44
|
+
for placement in placements:
|
|
45
|
+
claim_notes = _notes_for(placement, len(notes))
|
|
46
|
+
notes.extend(claim_notes)
|
|
47
|
+
if placement.start is None or placement.end is None:
|
|
48
|
+
continue
|
|
49
|
+
pieces.append(source[cursor : placement.start])
|
|
50
|
+
pieces.append(
|
|
51
|
+
placement.claim.text + "".join(f"[^{label}]" for label, _ in claim_notes)
|
|
52
|
+
)
|
|
53
|
+
cursor = placement.end
|
|
54
|
+
pieces.append(source[cursor:])
|
|
55
|
+
return "".join(pieces)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _notes_for(placement: Placement, offset: int) -> list[_Note]:
|
|
59
|
+
"""One note per citation; a claim with no citation still gets one."""
|
|
60
|
+
claim = placement.claim
|
|
61
|
+
if not claim.citations:
|
|
62
|
+
return [(f"{LABEL_PREFIX}{offset + 1}", _unanchored_note(placement))]
|
|
63
|
+
return [
|
|
64
|
+
(f"{LABEL_PREFIX}{offset + position}", _note(citation, placement))
|
|
65
|
+
for position, citation in enumerate(claim.citations, start=1)
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _note(citation: Citation, placement: Placement) -> list[str]:
|
|
70
|
+
"""The lines of one footnote: source, quote, token, drift, verdicts.
|
|
71
|
+
|
|
72
|
+
Groups are separated by blank lines so that the quote is a blockquote in the
|
|
73
|
+
footnote and not a lazy continuation of the line above it.
|
|
74
|
+
"""
|
|
75
|
+
anchor = citation.anchor
|
|
76
|
+
if anchor is not None:
|
|
77
|
+
groups = [
|
|
78
|
+
[f"**{anchor.slug}** · `{anchor.locator}` · {citation.status}"],
|
|
79
|
+
_quote(anchor.receipt.snippet),
|
|
80
|
+
[f"Token `{citation.token}` · sha256 `{anchor.receipt.snippet_sha256}`"],
|
|
81
|
+
]
|
|
82
|
+
else:
|
|
83
|
+
groups = [[f"unanchored · {citation.status}"], [f"Token `{citation.token}`"]]
|
|
84
|
+
if citation.drifted_from is not None:
|
|
85
|
+
groups.append(["As cited, before the source changed:"])
|
|
86
|
+
groups.append(_quote(citation.drifted_from))
|
|
87
|
+
if citation.error:
|
|
88
|
+
groups.append([f"Error: {citation.error}"])
|
|
89
|
+
groups.append(
|
|
90
|
+
["Verdicts: " + (_verdicts(citation) if citation.verdicts else "none run.")]
|
|
91
|
+
)
|
|
92
|
+
groups.append(_claim_notes(placement))
|
|
93
|
+
return _joined(groups)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _unanchored_note(placement: Placement) -> list[str]:
|
|
97
|
+
"""A claim bind carried but could not cite at all."""
|
|
98
|
+
head = [f"unmatched · no citation — claim: “{_echo(placement.claim.text)}”"]
|
|
99
|
+
return _joined([head, _claim_notes(placement)])
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _claim_notes(placement: Placement) -> list[str]:
|
|
103
|
+
"""What is true of the claim itself, rather than of one citation."""
|
|
104
|
+
lines: list[str] = []
|
|
105
|
+
if placement.claim.unmatched:
|
|
106
|
+
lines.append("This claim is unmatched: bind could not anchor it.")
|
|
107
|
+
if not placement.placed:
|
|
108
|
+
lines.append("This claim was not located in the document text above.")
|
|
109
|
+
return lines
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _joined(groups: list[list[str]]) -> list[str]:
|
|
113
|
+
"""Non-empty groups, separated by one blank line."""
|
|
114
|
+
out: list[str] = []
|
|
115
|
+
for group in groups:
|
|
116
|
+
if not group:
|
|
117
|
+
continue
|
|
118
|
+
if out:
|
|
119
|
+
out.append("")
|
|
120
|
+
out.extend(group)
|
|
121
|
+
return out
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _verdicts(citation: Citation) -> str:
|
|
125
|
+
return "; ".join(
|
|
126
|
+
f"{verdict.method} {verdict.status}" + (f" — {verdict.detail}" if verdict.detail else "")
|
|
127
|
+
for verdict in citation.verdicts
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _receipts(report: BindReport, notes: list[_Note]) -> str:
|
|
132
|
+
"""The footnote definitions, under a provenance line."""
|
|
133
|
+
session = f", session `{report.session_id}`" if report.session_id else ""
|
|
134
|
+
stem = report.doc_path.rsplit("/", 1)[-1].rsplit(".", 1)[0]
|
|
135
|
+
lines = [
|
|
136
|
+
"## Receipts",
|
|
137
|
+
"",
|
|
138
|
+
f"Bound from `{report.doc_path}` — {report.mode}{session}, {report.bound_at}. "
|
|
139
|
+
f"Machine-readable record: `{stem}{SIDECAR_SUFFIX}` (`{FORMAT}`).",
|
|
140
|
+
"",
|
|
141
|
+
]
|
|
142
|
+
for label, note in notes:
|
|
143
|
+
first, *rest = note
|
|
144
|
+
lines.append(f"[^{label}]: {first}")
|
|
145
|
+
lines.extend(f" {line}" if line else "" for line in rest)
|
|
146
|
+
lines.append("")
|
|
147
|
+
return "\n".join(lines).rstrip()
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _unresolved(placements: list[Placement]) -> str:
|
|
151
|
+
"""Every non-resolved citation and every unmatched claim, visibly."""
|
|
152
|
+
items: list[str] = []
|
|
153
|
+
for placement in placements:
|
|
154
|
+
for citation in placement.claim.citations:
|
|
155
|
+
if citation.status is CitationStatus.RESOLVED:
|
|
156
|
+
continue
|
|
157
|
+
items.append(
|
|
158
|
+
f"- **{citation.status}** — `{citation.token}` "
|
|
159
|
+
f"in claim {placement.number}, “{_echo(placement.claim.text)}”: "
|
|
160
|
+
f"{citation.error or status_note(citation.status)}."
|
|
161
|
+
)
|
|
162
|
+
if placement.claim.unmatched:
|
|
163
|
+
items.append(
|
|
164
|
+
f"- **unmatched** — claim {placement.number}, "
|
|
165
|
+
f"“{_echo(placement.claim.text)}”: bind could not anchor it."
|
|
166
|
+
)
|
|
167
|
+
return "\n".join(["## Unresolved", "", *(items or ["Every citation resolved."])])
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _echo(text: str) -> str:
|
|
171
|
+
"""A claim's text, collapsed to one line for a list entry."""
|
|
172
|
+
return short(text, limit=CLAIM_ECHO_CHARS)
|