tracecite 0.0.2__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.
- tracecite/__init__.py +35 -0
- tracecite/__main__.py +3 -0
- tracecite/cli.py +656 -0
- tracecite/docs/__init__.py +56 -0
- tracecite/docs/build.py +381 -0
- tracecite/docs/config.py +114 -0
- tracecite/docs/contract.py +17 -0
- tracecite/docs/modes.py +240 -0
- tracecite/docs/stage.py +407 -0
- tracecite/docs/vectorize.py +457 -0
- tracecite/evidence/__init__.py +3 -0
- tracecite/evidence/chunking.py +214 -0
- tracecite/evidence/commands.py +552 -0
- tracecite/evidence/config.py +49 -0
- tracecite/evidence/embedding.py +59 -0
- tracecite/evidence/manifest.py +197 -0
- tracecite/evidence/page_extraction.py +200 -0
- tracecite/evidence/page_output.py +80 -0
- tracecite/evidence/page_selection.py +116 -0
- tracecite/evidence/parsers/__init__.py +1 -0
- tracecite/evidence/parsers/base.py +65 -0
- tracecite/evidence/parsers/markdown.py +142 -0
- tracecite/evidence/parsers/pdf.py +315 -0
- tracecite/evidence/parsers/workbook.py +334 -0
- tracecite/evidence/paths.py +94 -0
- tracecite/evidence/schema.py +570 -0
- tracecite/evidence/source_links.py +185 -0
- tracecite/evidence/sync.py +1434 -0
- tracecite/evidence/vector_backend.py +130 -0
- tracecite/evidence/verify.py +368 -0
- tracecite/skills/routing-documentation-source-links/SKILL.md +167 -0
- tracecite/skills/routing-documentation-source-links/agents/openai.yaml +3 -0
- tracecite/skills/routing-documentation-source-links/references/documenter-integration.md +71 -0
- tracecite/skills/routing-documentation-source-links/references/registry-contract.md +86 -0
- tracecite/skills/routing-documentation-source-links/references/staging-and-rewriting.md +118 -0
- tracecite/skills/routing-documentation-source-links/references/validation-and-failure-modes.md +67 -0
- tracecite/skills/using-tracecite/SKILL.md +97 -0
- tracecite/skills/using-tracecite/agents/openai.yaml +3 -0
- tracecite/skills/using-tracecite/references/architecture.md +68 -0
- tracecite/skills/using-tracecite/references/pdf-and-assets.md +67 -0
- tracecite/skills/using-tracecite/references/retrieval.md +45 -0
- tracecite/skills/using-tracecite/references/schema-and-migrations.md +52 -0
- tracecite/skills/using-tracecite/references/synchronisation.md +98 -0
- tracecite/skills/using-tracecite/references/workspace-runtime.md +44 -0
- tracecite/skills/writing-evidence-backed-reports/SKILL.md +91 -0
- tracecite/skills/writing-evidence-backed-reports/agents/openai.yaml +3 -0
- tracecite/skills/writing-evidence-backed-reports/fixtures/README.md +13 -0
- tracecite/skills/writing-evidence-backed-reports/fixtures/reports/invalid-bad-path.md +5 -0
- tracecite/skills/writing-evidence-backed-reports/fixtures/reports/invalid-grammar.md +8 -0
- tracecite/skills/writing-evidence-backed-reports/fixtures/reports/invalid-missing-definition.md +3 -0
- tracecite/skills/writing-evidence-backed-reports/fixtures/reports/invalid-page-not-indexed.md +5 -0
- tracecite/skills/writing-evidence-backed-reports/fixtures/reports/invalid-unindexed-path.md +5 -0
- tracecite/skills/writing-evidence-backed-reports/fixtures/reports/valid-grammar.md +12 -0
- tracecite/skills/writing-evidence-backed-reports/fixtures/reports/valid-report.md +27 -0
- tracecite/skills/writing-evidence-backed-reports/fixtures/source-links.toml +17 -0
- tracecite/skills/writing-evidence-backed-reports/fixtures/validate_fixtures.py +295 -0
- tracecite/skills/writing-evidence-backed-reports/references/images-and-source-links.md +34 -0
- tracecite/skills/writing-evidence-backed-reports/references/quotation-and-verification.md +43 -0
- tracecite/skills/writing-evidence-backed-reports/references/reference-style-citations.md +48 -0
- tracecite/skills/writing-evidence-backed-reports/references/report-structure.md +58 -0
- tracecite/tables/__init__.py +41 -0
- tracecite/tables/_text.py +142 -0
- tracecite/tables/document.py +582 -0
- tracecite/tables/html.py +299 -0
- tracecite/tables/models.py +119 -0
- tracecite/tables/normalise.py +786 -0
- tracecite/tables/pandoc.py +503 -0
- tracecite/tables/publish.py +238 -0
- tracecite/tables/render.py +128 -0
- tracecite/tables/site.py +457 -0
- tracecite-0.0.2.dist-info/METADATA +320 -0
- tracecite-0.0.2.dist-info/RECORD +76 -0
- tracecite-0.0.2.dist-info/WHEEL +5 -0
- tracecite-0.0.2.dist-info/entry_points.txt +2 -0
- tracecite-0.0.2.dist-info/licenses/LICENSE +21 -0
- tracecite-0.0.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import fnmatch
|
|
4
|
+
import tomllib
|
|
5
|
+
from collections.abc import Mapping, Sequence
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from types import MappingProxyType
|
|
9
|
+
|
|
10
|
+
from .paths import PathAuthorityError, normalise_relative_declaration
|
|
11
|
+
|
|
12
|
+
SOURCE_TYPES = {
|
|
13
|
+
".pdf": "pdf",
|
|
14
|
+
".md": "markdown",
|
|
15
|
+
".xlsx": "workbook",
|
|
16
|
+
".xlsm": "workbook",
|
|
17
|
+
}
|
|
18
|
+
_TOP_LEVEL_KEYS = {
|
|
19
|
+
"schema_version",
|
|
20
|
+
"root",
|
|
21
|
+
"database",
|
|
22
|
+
"model_cache_dir",
|
|
23
|
+
"source",
|
|
24
|
+
"include",
|
|
25
|
+
"exclude",
|
|
26
|
+
}
|
|
27
|
+
_TABLE_KEYS = {
|
|
28
|
+
"source": {"path", "origin"},
|
|
29
|
+
"include": {"glob", "origin"},
|
|
30
|
+
"exclude": {"glob", "origin"},
|
|
31
|
+
}
|
|
32
|
+
_REQUIRED_TABLE_KEYS = {
|
|
33
|
+
"source": {"path"},
|
|
34
|
+
"include": {"glob"},
|
|
35
|
+
"exclude": {"glob"},
|
|
36
|
+
}
|
|
37
|
+
_ORIGINS = {"default", "local"}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ManifestError(ValueError):
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _normalise(value: str | Path) -> str:
|
|
45
|
+
try:
|
|
46
|
+
normalised = normalise_relative_declaration(value)
|
|
47
|
+
except PathAuthorityError as exc:
|
|
48
|
+
raise ManifestError(str(exc)) from exc
|
|
49
|
+
if Path(normalised).suffix.lower() not in SOURCE_TYPES and not any(
|
|
50
|
+
ch in normalised for ch in "*?["
|
|
51
|
+
):
|
|
52
|
+
raise ManifestError(f"unsupported source extension for {value}")
|
|
53
|
+
return normalised
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _matches(path: str, pattern: str) -> bool:
|
|
57
|
+
return fnmatch.fnmatchcase(path, pattern) or (
|
|
58
|
+
"**/" in pattern and fnmatch.fnmatchcase(path, pattern.replace("**/", ""))
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class ManifestRules:
|
|
64
|
+
explicit_paths: tuple[str, ...] = ()
|
|
65
|
+
include_globs: tuple[str, ...] = ()
|
|
66
|
+
exclude_globs: tuple[str, ...] = ()
|
|
67
|
+
origins: Mapping[tuple[str, str], str] = field(
|
|
68
|
+
default_factory=lambda: MappingProxyType({})
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
def selects_path(self, path: str) -> bool:
|
|
72
|
+
path = str(path).replace("\\", "/")
|
|
73
|
+
selected = path in self.explicit_paths or any(
|
|
74
|
+
_matches(path, glob) for glob in self.include_globs
|
|
75
|
+
)
|
|
76
|
+
excluded = any(_matches(path, glob) for glob in self.exclude_globs)
|
|
77
|
+
return selected and not excluded
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass(frozen=True)
|
|
81
|
+
class SourceSelection:
|
|
82
|
+
selected: dict[str, Path]
|
|
83
|
+
missing_explicit_paths: tuple[str, ...]
|
|
84
|
+
unmatched_globs: tuple[str, ...]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def load_manifest(path: Path) -> ManifestRules:
|
|
88
|
+
with Path(path).open("rb") as handle:
|
|
89
|
+
data = tomllib.load(handle)
|
|
90
|
+
if data.get("schema_version") != 1:
|
|
91
|
+
raise ManifestError(f"{path} requires schema_version = 1")
|
|
92
|
+
unknown = set(data) - _TOP_LEVEL_KEYS
|
|
93
|
+
if unknown:
|
|
94
|
+
raise ManifestError(f"unknown field(s) in {path}: {', '.join(sorted(unknown))}")
|
|
95
|
+
|
|
96
|
+
origins: dict[tuple[str, str], str] = {}
|
|
97
|
+
|
|
98
|
+
def table_values(name: str, field: str) -> tuple[str, ...]:
|
|
99
|
+
values = []
|
|
100
|
+
for index, item in enumerate(data.get(name, []), start=1):
|
|
101
|
+
if not isinstance(item, dict):
|
|
102
|
+
raise ManifestError(f"[[{name}]] entry {index} must be a table")
|
|
103
|
+
keys = set(item)
|
|
104
|
+
unknown_keys = keys - _TABLE_KEYS[name]
|
|
105
|
+
missing = _REQUIRED_TABLE_KEYS[name] - keys
|
|
106
|
+
if unknown_keys:
|
|
107
|
+
raise ManifestError(
|
|
108
|
+
f"unknown field(s) in [[{name}]] entry {index}: {', '.join(sorted(unknown_keys))}"
|
|
109
|
+
)
|
|
110
|
+
if missing:
|
|
111
|
+
raise ManifestError(
|
|
112
|
+
f"missing field(s) in [[{name}]] entry {index}: {', '.join(sorted(missing))}"
|
|
113
|
+
)
|
|
114
|
+
origin = item.get("origin", "default")
|
|
115
|
+
if origin not in _ORIGINS:
|
|
116
|
+
raise ManifestError(
|
|
117
|
+
f"unsupported origin in [[{name}]] entry {index}: {origin}"
|
|
118
|
+
)
|
|
119
|
+
identity = _normalise(item[field])
|
|
120
|
+
values.append(identity)
|
|
121
|
+
key = (name, identity)
|
|
122
|
+
if origins.get(key) != "local" or origin == "local":
|
|
123
|
+
origins[key] = origin
|
|
124
|
+
return tuple(values)
|
|
125
|
+
|
|
126
|
+
explicit = table_values("source", "path")
|
|
127
|
+
includes = table_values("include", "glob")
|
|
128
|
+
excludes = table_values("exclude", "glob")
|
|
129
|
+
return ManifestRules(
|
|
130
|
+
tuple(dict.fromkeys(explicit)),
|
|
131
|
+
tuple(dict.fromkeys(includes)),
|
|
132
|
+
tuple(dict.fromkeys(excludes)),
|
|
133
|
+
MappingProxyType(origins),
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def merge_manifests(paths: Sequence[Path]) -> ManifestRules:
|
|
138
|
+
"""Union manifests in order; a ``local`` origin wins over ``default`` for the same declaration."""
|
|
139
|
+
declared: dict[str, dict[str, None]] = {"source": {}, "include": {}, "exclude": {}}
|
|
140
|
+
origins: dict[tuple[str, str], str] = {}
|
|
141
|
+
for path in paths:
|
|
142
|
+
rules = load_manifest(path)
|
|
143
|
+
for kind, values in (
|
|
144
|
+
("source", rules.explicit_paths),
|
|
145
|
+
("include", rules.include_globs),
|
|
146
|
+
("exclude", rules.exclude_globs),
|
|
147
|
+
):
|
|
148
|
+
for value in values:
|
|
149
|
+
declared[kind][value] = None
|
|
150
|
+
origin = rules.origins.get((kind, value), "default")
|
|
151
|
+
if origins.get((kind, value)) != "local" or origin == "local":
|
|
152
|
+
origins[(kind, value)] = origin
|
|
153
|
+
return ManifestRules(
|
|
154
|
+
tuple(declared["source"]),
|
|
155
|
+
tuple(declared["include"]),
|
|
156
|
+
tuple(declared["exclude"]),
|
|
157
|
+
MappingProxyType(origins),
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def resolve_sources(root: Path, rules: ManifestRules) -> SourceSelection:
|
|
162
|
+
"""Scan the declared paths and globs once under ``root``.
|
|
163
|
+
|
|
164
|
+
``selected`` maps each existing, non-symlink, supported file that the
|
|
165
|
+
rules select to its resolved path. Missing explicit paths and include
|
|
166
|
+
globs that match no file are judged before exclusions apply.
|
|
167
|
+
"""
|
|
168
|
+
root = Path(root).resolve()
|
|
169
|
+
candidates = {root / path for path in rules.explicit_paths}
|
|
170
|
+
for pattern in rules.include_globs:
|
|
171
|
+
candidates.update(root.glob(pattern))
|
|
172
|
+
declared: dict[str, Path] = {}
|
|
173
|
+
for path in sorted(candidates):
|
|
174
|
+
if (
|
|
175
|
+
path.is_symlink()
|
|
176
|
+
or not path.is_file()
|
|
177
|
+
or path.suffix.lower() not in SOURCE_TYPES
|
|
178
|
+
):
|
|
179
|
+
continue
|
|
180
|
+
resolved = path.resolve()
|
|
181
|
+
if resolved.is_relative_to(root):
|
|
182
|
+
declared[resolved.relative_to(root).as_posix()] = resolved
|
|
183
|
+
return SourceSelection(
|
|
184
|
+
selected={
|
|
185
|
+
path: resolved
|
|
186
|
+
for path, resolved in declared.items()
|
|
187
|
+
if rules.selects_path(path)
|
|
188
|
+
},
|
|
189
|
+
missing_explicit_paths=tuple(
|
|
190
|
+
path for path in rules.explicit_paths if path not in declared
|
|
191
|
+
),
|
|
192
|
+
unmatched_globs=tuple(
|
|
193
|
+
glob
|
|
194
|
+
for glob in rules.include_globs
|
|
195
|
+
if not any(_matches(path, glob) for path in declared)
|
|
196
|
+
),
|
|
197
|
+
)
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from datetime import UTC, datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from tempfile import TemporaryDirectory
|
|
11
|
+
|
|
12
|
+
from .page_selection import (
|
|
13
|
+
PageSelectionSyntaxError,
|
|
14
|
+
PageSelectionUnavailableError,
|
|
15
|
+
resolve_page_selection,
|
|
16
|
+
)
|
|
17
|
+
from .paths import (
|
|
18
|
+
PathAuthorityError,
|
|
19
|
+
file_sha256,
|
|
20
|
+
normalise_source_path,
|
|
21
|
+
source_row_for_path,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class PageExtractionError(ValueError):
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class PageExtractionMissingError(PageExtractionError):
|
|
30
|
+
"""The source or a selected page is not indexed or not on disk."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class PageExtractionResult:
|
|
35
|
+
pdf_path: Path
|
|
36
|
+
manifest_path: Path
|
|
37
|
+
normalized_pages: list[int]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _slugify(text: str, *, limit: int = 24) -> str:
|
|
41
|
+
slug = re.sub(r"[^A-Za-z0-9._-]+", "-", text).strip(".-_")
|
|
42
|
+
return (slug or "source")[:limit]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _selector_token(selector: str | None, normalized_pages: list[int]) -> str:
|
|
46
|
+
raw = selector if selector is not None else "1"
|
|
47
|
+
return hashlib.sha256(
|
|
48
|
+
f"{raw}|{','.join(map(str, normalized_pages))}".encode()
|
|
49
|
+
).hexdigest()[:12]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _bounded_name(
|
|
53
|
+
source_stem: str, selector: str | None, normalized_pages: list[int], suffix: str
|
|
54
|
+
) -> str:
|
|
55
|
+
base = f"tracecite-extract-{_slugify(source_stem)}-{_selector_token(selector, normalized_pages)}"
|
|
56
|
+
return f"{base[:72]}{suffix}"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _ensure_real_output_dir(output_dir: Path) -> Path:
|
|
60
|
+
output_dir = Path(output_dir)
|
|
61
|
+
if not output_dir.exists() or not output_dir.is_dir():
|
|
62
|
+
raise PageExtractionError(f"output directory does not exist: {output_dir}")
|
|
63
|
+
if output_dir.is_symlink():
|
|
64
|
+
raise PageExtractionError(
|
|
65
|
+
f"output directory must not be a symlink: {output_dir}"
|
|
66
|
+
)
|
|
67
|
+
return output_dir.resolve()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def promote_staged_outputs(
|
|
71
|
+
staged_pdf: Path, staged_manifest: Path, final_pdf: Path, final_manifest: Path
|
|
72
|
+
) -> None:
|
|
73
|
+
reserved: list[Path] = []
|
|
74
|
+
try:
|
|
75
|
+
for final_path in (final_pdf, final_manifest):
|
|
76
|
+
try:
|
|
77
|
+
descriptor = os.open(
|
|
78
|
+
final_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600
|
|
79
|
+
)
|
|
80
|
+
except FileExistsError as exc:
|
|
81
|
+
raise PageExtractionError(
|
|
82
|
+
f"output already exists: {final_path}"
|
|
83
|
+
) from exc
|
|
84
|
+
else:
|
|
85
|
+
os.close(descriptor)
|
|
86
|
+
reserved.append(final_path)
|
|
87
|
+
os.replace(staged_pdf, final_pdf)
|
|
88
|
+
os.replace(staged_manifest, final_manifest)
|
|
89
|
+
except Exception:
|
|
90
|
+
for final_path in reserved:
|
|
91
|
+
final_path.unlink(missing_ok=True)
|
|
92
|
+
raise
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def extract_pages(
|
|
96
|
+
conn,
|
|
97
|
+
root: Path,
|
|
98
|
+
source_path_input: str,
|
|
99
|
+
selector: str | None,
|
|
100
|
+
output_dir: Path,
|
|
101
|
+
) -> PageExtractionResult:
|
|
102
|
+
try:
|
|
103
|
+
source_path = normalise_source_path(root, source_path_input)
|
|
104
|
+
except PathAuthorityError as exc:
|
|
105
|
+
raise PageExtractionError(str(exc)) from exc
|
|
106
|
+
|
|
107
|
+
source_row = source_row_for_path(conn, source_path)
|
|
108
|
+
if source_row is None:
|
|
109
|
+
raise PageExtractionMissingError(f"source not found: {source_path}")
|
|
110
|
+
if source_row["source_type"] != "pdf":
|
|
111
|
+
raise PageExtractionError(f"source is not a pdf: {source_path}")
|
|
112
|
+
|
|
113
|
+
available_pages = [
|
|
114
|
+
row["physical_page"]
|
|
115
|
+
for row in conn.execute(
|
|
116
|
+
"SELECT physical_page FROM pages WHERE source_pk = ? ORDER BY physical_page",
|
|
117
|
+
(source_row["source_pk"],),
|
|
118
|
+
).fetchall()
|
|
119
|
+
]
|
|
120
|
+
if not available_pages:
|
|
121
|
+
raise PageExtractionMissingError(f"no indexed pages for source: {source_path}")
|
|
122
|
+
|
|
123
|
+
try:
|
|
124
|
+
normalized_pages = resolve_page_selection(selector, available_pages)
|
|
125
|
+
except PageSelectionSyntaxError as exc:
|
|
126
|
+
raise PageExtractionError(str(exc)) from exc
|
|
127
|
+
except PageSelectionUnavailableError as exc:
|
|
128
|
+
raise PageExtractionMissingError(
|
|
129
|
+
f"page {exc.page} not indexed for source: {source_path}"
|
|
130
|
+
) from exc
|
|
131
|
+
|
|
132
|
+
output_dir_resolved = _ensure_real_output_dir(output_dir)
|
|
133
|
+
root_resolved = root.resolve()
|
|
134
|
+
if (
|
|
135
|
+
output_dir_resolved == root_resolved
|
|
136
|
+
or output_dir_resolved.is_relative_to(root_resolved)
|
|
137
|
+
or root_resolved.is_relative_to(output_dir_resolved)
|
|
138
|
+
):
|
|
139
|
+
raise PageExtractionError(
|
|
140
|
+
f"output directory overlaps source root: {output_dir}"
|
|
141
|
+
)
|
|
142
|
+
source_pdf = root / source_path
|
|
143
|
+
if not source_pdf.is_file():
|
|
144
|
+
raise PageExtractionMissingError(f"source pdf missing: {source_path}")
|
|
145
|
+
source_pdf_resolved = source_pdf.resolve()
|
|
146
|
+
source_sha256 = file_sha256(source_pdf_resolved)
|
|
147
|
+
if source_sha256 != source_row["sha256"]:
|
|
148
|
+
raise PageExtractionError(f"source pdf changed since indexing: {source_path}")
|
|
149
|
+
|
|
150
|
+
import pymupdf
|
|
151
|
+
|
|
152
|
+
with pymupdf.open(source_pdf_resolved) as document:
|
|
153
|
+
if document.page_count < max(normalized_pages):
|
|
154
|
+
raise PageExtractionError(
|
|
155
|
+
f"source pdf has only {document.page_count} page(s), cannot satisfy selection {normalized_pages}"
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
pdf_name = _bounded_name(
|
|
159
|
+
Path(source_path).stem, selector, normalized_pages, ".pdf"
|
|
160
|
+
)
|
|
161
|
+
manifest_name = _bounded_name(
|
|
162
|
+
Path(source_path).stem, selector, normalized_pages, ".json"
|
|
163
|
+
)
|
|
164
|
+
final_pdf = output_dir_resolved / pdf_name
|
|
165
|
+
final_manifest = output_dir_resolved / manifest_name
|
|
166
|
+
if final_pdf.exists() or final_manifest.exists():
|
|
167
|
+
raise PageExtractionError(
|
|
168
|
+
f"output already exists: {final_pdf if final_pdf.exists() else final_manifest}"
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
with TemporaryDirectory(dir=output_dir_resolved.parent) as temp_root:
|
|
172
|
+
staged_pdf = Path(temp_root) / pdf_name
|
|
173
|
+
staged_manifest = Path(temp_root) / manifest_name
|
|
174
|
+
with pymupdf.open() as derivative:
|
|
175
|
+
for page in normalized_pages:
|
|
176
|
+
derivative.insert_pdf(
|
|
177
|
+
document, from_page=page - 1, to_page=page - 1
|
|
178
|
+
)
|
|
179
|
+
derivative.save(staged_pdf)
|
|
180
|
+
manifest = {
|
|
181
|
+
"schema_version": 1,
|
|
182
|
+
"source_path": source_path,
|
|
183
|
+
"original_selector": selector,
|
|
184
|
+
"selector": selector if selector is not None else "1",
|
|
185
|
+
"normalized_pages": normalized_pages,
|
|
186
|
+
"page_count": len(normalized_pages),
|
|
187
|
+
"source_page_count": document.page_count,
|
|
188
|
+
"source_sha256": source_sha256,
|
|
189
|
+
"derivative_filename": pdf_name,
|
|
190
|
+
"derivative_sha256": file_sha256(staged_pdf),
|
|
191
|
+
"created_at_utc": datetime.now(UTC).isoformat().replace("+00:00", "Z"),
|
|
192
|
+
}
|
|
193
|
+
staged_manifest.write_text(
|
|
194
|
+
json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8"
|
|
195
|
+
)
|
|
196
|
+
promote_staged_outputs(
|
|
197
|
+
staged_pdf, staged_manifest, final_pdf, final_manifest
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
return PageExtractionResult(final_pdf, final_manifest, normalized_pages)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from . import schema
|
|
7
|
+
from .paths import file_sha256
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class PageOutputError(ValueError):
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class PageAssetMissingError(PageOutputError):
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _asset_payload(database_path: Path, asset_row) -> dict:
|
|
19
|
+
try:
|
|
20
|
+
asset_path = schema.resolve_asset_path(database_path, asset_row["asset_path"])
|
|
21
|
+
except ValueError as exc:
|
|
22
|
+
raise PageOutputError(str(exc)) from exc
|
|
23
|
+
resolved_path = asset_path.resolve()
|
|
24
|
+
if not asset_path.is_file():
|
|
25
|
+
raise PageAssetMissingError(f"missing asset file: {asset_row['asset_path']}")
|
|
26
|
+
if file_sha256(asset_path) != asset_row["sha256"]:
|
|
27
|
+
raise PageAssetMissingError(f"asset hash mismatch: {asset_row['asset_path']}")
|
|
28
|
+
try:
|
|
29
|
+
bbox = json.loads(asset_row["bbox_json"]) if asset_row["bbox_json"] else None
|
|
30
|
+
except ValueError as exc:
|
|
31
|
+
raise PageOutputError(
|
|
32
|
+
f"invalid asset bbox json: {asset_row['asset_path']}"
|
|
33
|
+
) from exc
|
|
34
|
+
return {
|
|
35
|
+
"asset_path": asset_row["asset_path"],
|
|
36
|
+
"resolved_path": str(resolved_path),
|
|
37
|
+
"asset_type": asset_row["asset_type"],
|
|
38
|
+
"sha256": asset_row["sha256"],
|
|
39
|
+
"width": asset_row["width"],
|
|
40
|
+
"height": asset_row["height"],
|
|
41
|
+
"label": asset_row["label"],
|
|
42
|
+
"caption": asset_row["caption"],
|
|
43
|
+
"nearby_text": asset_row["nearby_text"],
|
|
44
|
+
"bbox": bbox,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def page_assets_for(
|
|
49
|
+
conn, database_path: Path, source_pk: int, physical_page: int
|
|
50
|
+
) -> tuple[dict | None, list[dict]]:
|
|
51
|
+
rows = conn.execute(
|
|
52
|
+
"SELECT * FROM assets WHERE source_pk = ? AND physical_page = ? ORDER BY asset_type, asset_path",
|
|
53
|
+
(source_pk, physical_page),
|
|
54
|
+
).fetchall()
|
|
55
|
+
page_render = None
|
|
56
|
+
figure_crops: list[dict] = []
|
|
57
|
+
for row in rows:
|
|
58
|
+
payload = _asset_payload(database_path, row)
|
|
59
|
+
if row["asset_type"] == "page-render" and page_render is None:
|
|
60
|
+
page_render = payload
|
|
61
|
+
elif row["asset_type"] == "figure-crop":
|
|
62
|
+
figure_crops.append(payload)
|
|
63
|
+
return page_render, figure_crops
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def page_json_payload(conn, database_path: Path, source_row, page_row) -> dict:
|
|
67
|
+
page_render, figure_crops = page_assets_for(
|
|
68
|
+
conn, database_path, source_row["source_pk"], page_row["physical_page"]
|
|
69
|
+
)
|
|
70
|
+
return {
|
|
71
|
+
"source_path": source_row["path"],
|
|
72
|
+
"physical_page": page_row["physical_page"],
|
|
73
|
+
"text": page_row["text"],
|
|
74
|
+
"printed_label": page_row["printed_label"],
|
|
75
|
+
"extraction_method": page_row["extraction_method"],
|
|
76
|
+
"extraction_status": page_row["extraction_status"],
|
|
77
|
+
"pdf_link": f"{source_row['path']}#page={page_row['physical_page']}",
|
|
78
|
+
"page_render": page_render,
|
|
79
|
+
"figure_crops": figure_crops,
|
|
80
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class PageSelectionError(ValueError):
|
|
7
|
+
pass
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class PageSelectionSyntaxError(PageSelectionError):
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class PageSelectionUnavailableError(PageSelectionError):
|
|
15
|
+
def __init__(self, page: int, message: str | None = None) -> None:
|
|
16
|
+
self.page = page
|
|
17
|
+
super().__init__(message or f"page {page} is unavailable")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _validate_positive_page(text: str, *, selector: str) -> int:
|
|
21
|
+
if not text.isdigit() or text.startswith("0"):
|
|
22
|
+
raise PageSelectionSyntaxError(f"invalid page selector: {selector}")
|
|
23
|
+
page = int(text)
|
|
24
|
+
if page <= 0:
|
|
25
|
+
raise PageSelectionSyntaxError(f"invalid page selector: {selector}")
|
|
26
|
+
return page
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _expand_closed_range(
|
|
30
|
+
start: int, end: int, *, available_set: set[int], selector: str
|
|
31
|
+
) -> list[int]:
|
|
32
|
+
if start > end:
|
|
33
|
+
raise PageSelectionSyntaxError(f"invalid page selector: {selector}")
|
|
34
|
+
pages = list(range(start, end + 1))
|
|
35
|
+
missing = next((page for page in pages if page not in available_set), None)
|
|
36
|
+
if missing is not None:
|
|
37
|
+
raise PageSelectionUnavailableError(missing)
|
|
38
|
+
return pages
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def resolve_page_selection(
|
|
42
|
+
selector: str | None,
|
|
43
|
+
available_pages: Sequence[int],
|
|
44
|
+
*,
|
|
45
|
+
default_page: int = 1,
|
|
46
|
+
) -> list[int]:
|
|
47
|
+
available = sorted({int(page) for page in available_pages})
|
|
48
|
+
available_set = set(available)
|
|
49
|
+
if selector is None:
|
|
50
|
+
if default_page not in available_set:
|
|
51
|
+
raise PageSelectionUnavailableError(default_page)
|
|
52
|
+
return [default_page]
|
|
53
|
+
|
|
54
|
+
selector = selector.strip()
|
|
55
|
+
if not selector:
|
|
56
|
+
raise PageSelectionSyntaxError("invalid page selector: empty selector")
|
|
57
|
+
|
|
58
|
+
terms = [term.strip() for term in selector.split(",")]
|
|
59
|
+
if any(term == "" for term in terms):
|
|
60
|
+
raise PageSelectionSyntaxError(f"invalid page selector: {selector}")
|
|
61
|
+
|
|
62
|
+
if len(terms) == 1 and terms[0] == "all":
|
|
63
|
+
if not available:
|
|
64
|
+
raise PageSelectionUnavailableError(default_page)
|
|
65
|
+
return available
|
|
66
|
+
if any(term == "all" for term in terms):
|
|
67
|
+
raise PageSelectionSyntaxError(f"invalid page selector: {selector}")
|
|
68
|
+
|
|
69
|
+
resolved: set[int] = set()
|
|
70
|
+
max_available = available[-1] if available else None
|
|
71
|
+
for term in terms:
|
|
72
|
+
if term.endswith("-") and term != "-":
|
|
73
|
+
start = _validate_positive_page(term[:-1], selector=selector)
|
|
74
|
+
if not available:
|
|
75
|
+
raise PageSelectionUnavailableError(default_page)
|
|
76
|
+
assert max_available is not None
|
|
77
|
+
if start > max_available:
|
|
78
|
+
raise PageSelectionUnavailableError(start)
|
|
79
|
+
resolved.update(
|
|
80
|
+
_expand_closed_range(
|
|
81
|
+
start, max_available, available_set=available_set, selector=selector
|
|
82
|
+
)
|
|
83
|
+
)
|
|
84
|
+
continue
|
|
85
|
+
if term.startswith("-") and term != "-":
|
|
86
|
+
end = _validate_positive_page(term[1:], selector=selector)
|
|
87
|
+
if not available:
|
|
88
|
+
raise PageSelectionUnavailableError(default_page)
|
|
89
|
+
resolved.update(
|
|
90
|
+
_expand_closed_range(
|
|
91
|
+
1, end, available_set=available_set, selector=selector
|
|
92
|
+
)
|
|
93
|
+
)
|
|
94
|
+
continue
|
|
95
|
+
if "-" in term:
|
|
96
|
+
start_text, end_text = term.split("-", 1)
|
|
97
|
+
if not start_text or not end_text:
|
|
98
|
+
raise PageSelectionSyntaxError(f"invalid page selector: {selector}")
|
|
99
|
+
start = _validate_positive_page(start_text, selector=selector)
|
|
100
|
+
end = _validate_positive_page(end_text, selector=selector)
|
|
101
|
+
if not available:
|
|
102
|
+
raise PageSelectionUnavailableError(default_page)
|
|
103
|
+
resolved.update(
|
|
104
|
+
_expand_closed_range(
|
|
105
|
+
start, end, available_set=available_set, selector=selector
|
|
106
|
+
)
|
|
107
|
+
)
|
|
108
|
+
continue
|
|
109
|
+
page = _validate_positive_page(term, selector=selector)
|
|
110
|
+
if not available:
|
|
111
|
+
raise PageSelectionUnavailableError(default_page)
|
|
112
|
+
if page not in available_set:
|
|
113
|
+
raise PageSelectionUnavailableError(page)
|
|
114
|
+
resolved.add(page)
|
|
115
|
+
|
|
116
|
+
return sorted(resolved)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Format-specific parsers behind a common interface (see ``base.py``)."""
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Parser interface: authoritative files in, retained pages + logical chunk
|
|
2
|
+
input units out. Chunking (chunking.py) turns ``ParsedChunkUnit`` sequences
|
|
3
|
+
into stored chunks; parsers never write to the database directly.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class ParsedPage:
|
|
14
|
+
"""One retained extraction container, such as a page or worksheet."""
|
|
15
|
+
|
|
16
|
+
physical_page: int
|
|
17
|
+
printed_label: str | None
|
|
18
|
+
text: str
|
|
19
|
+
extraction_method: str
|
|
20
|
+
extraction_status: str
|
|
21
|
+
section_candidates: list[str] = field(default_factory=list)
|
|
22
|
+
layout: dict | None = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class ParsedChunkUnit:
|
|
27
|
+
"""One chunkable unit of text with enough locator context to build a
|
|
28
|
+
stable logical key and, later, a semantic-input hash.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
text: str
|
|
32
|
+
logical_key: str
|
|
33
|
+
heading_path: list[str]
|
|
34
|
+
symbol: str | None
|
|
35
|
+
content_type: str
|
|
36
|
+
physical_page: int | None = None
|
|
37
|
+
page_start_offset: int | None = None
|
|
38
|
+
page_end_offset: int | None = None
|
|
39
|
+
line_start: int | None = None
|
|
40
|
+
line_end: int | None = None
|
|
41
|
+
locator: dict = field(default_factory=dict)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class ParseResult:
|
|
46
|
+
pages: list[ParsedPage]
|
|
47
|
+
units: list[ParsedChunkUnit]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def units_from_layout(layout_json: str | None) -> list[ParsedChunkUnit]:
|
|
51
|
+
"""Rebuild Markdown or workbook chunk-input units from retained ``pages.layout_json``."""
|
|
52
|
+
layout = json.loads(layout_json) if layout_json else {}
|
|
53
|
+
return [
|
|
54
|
+
ParsedChunkUnit(
|
|
55
|
+
text=entry["text"],
|
|
56
|
+
logical_key=entry["logical_key"],
|
|
57
|
+
heading_path=entry["heading_path"],
|
|
58
|
+
symbol=None,
|
|
59
|
+
content_type=entry["content_type"],
|
|
60
|
+
line_start=entry.get("line_start"),
|
|
61
|
+
line_end=entry.get("line_end"),
|
|
62
|
+
locator=entry.get("locator", {}),
|
|
63
|
+
)
|
|
64
|
+
for entry in layout.get("units", [])
|
|
65
|
+
]
|