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.
Files changed (76) hide show
  1. tracecite/__init__.py +35 -0
  2. tracecite/__main__.py +3 -0
  3. tracecite/cli.py +656 -0
  4. tracecite/docs/__init__.py +56 -0
  5. tracecite/docs/build.py +381 -0
  6. tracecite/docs/config.py +114 -0
  7. tracecite/docs/contract.py +17 -0
  8. tracecite/docs/modes.py +240 -0
  9. tracecite/docs/stage.py +407 -0
  10. tracecite/docs/vectorize.py +457 -0
  11. tracecite/evidence/__init__.py +3 -0
  12. tracecite/evidence/chunking.py +214 -0
  13. tracecite/evidence/commands.py +552 -0
  14. tracecite/evidence/config.py +49 -0
  15. tracecite/evidence/embedding.py +59 -0
  16. tracecite/evidence/manifest.py +197 -0
  17. tracecite/evidence/page_extraction.py +200 -0
  18. tracecite/evidence/page_output.py +80 -0
  19. tracecite/evidence/page_selection.py +116 -0
  20. tracecite/evidence/parsers/__init__.py +1 -0
  21. tracecite/evidence/parsers/base.py +65 -0
  22. tracecite/evidence/parsers/markdown.py +142 -0
  23. tracecite/evidence/parsers/pdf.py +315 -0
  24. tracecite/evidence/parsers/workbook.py +334 -0
  25. tracecite/evidence/paths.py +94 -0
  26. tracecite/evidence/schema.py +570 -0
  27. tracecite/evidence/source_links.py +185 -0
  28. tracecite/evidence/sync.py +1434 -0
  29. tracecite/evidence/vector_backend.py +130 -0
  30. tracecite/evidence/verify.py +368 -0
  31. tracecite/skills/routing-documentation-source-links/SKILL.md +167 -0
  32. tracecite/skills/routing-documentation-source-links/agents/openai.yaml +3 -0
  33. tracecite/skills/routing-documentation-source-links/references/documenter-integration.md +71 -0
  34. tracecite/skills/routing-documentation-source-links/references/registry-contract.md +86 -0
  35. tracecite/skills/routing-documentation-source-links/references/staging-and-rewriting.md +118 -0
  36. tracecite/skills/routing-documentation-source-links/references/validation-and-failure-modes.md +67 -0
  37. tracecite/skills/using-tracecite/SKILL.md +97 -0
  38. tracecite/skills/using-tracecite/agents/openai.yaml +3 -0
  39. tracecite/skills/using-tracecite/references/architecture.md +68 -0
  40. tracecite/skills/using-tracecite/references/pdf-and-assets.md +67 -0
  41. tracecite/skills/using-tracecite/references/retrieval.md +45 -0
  42. tracecite/skills/using-tracecite/references/schema-and-migrations.md +52 -0
  43. tracecite/skills/using-tracecite/references/synchronisation.md +98 -0
  44. tracecite/skills/using-tracecite/references/workspace-runtime.md +44 -0
  45. tracecite/skills/writing-evidence-backed-reports/SKILL.md +91 -0
  46. tracecite/skills/writing-evidence-backed-reports/agents/openai.yaml +3 -0
  47. tracecite/skills/writing-evidence-backed-reports/fixtures/README.md +13 -0
  48. tracecite/skills/writing-evidence-backed-reports/fixtures/reports/invalid-bad-path.md +5 -0
  49. tracecite/skills/writing-evidence-backed-reports/fixtures/reports/invalid-grammar.md +8 -0
  50. tracecite/skills/writing-evidence-backed-reports/fixtures/reports/invalid-missing-definition.md +3 -0
  51. tracecite/skills/writing-evidence-backed-reports/fixtures/reports/invalid-page-not-indexed.md +5 -0
  52. tracecite/skills/writing-evidence-backed-reports/fixtures/reports/invalid-unindexed-path.md +5 -0
  53. tracecite/skills/writing-evidence-backed-reports/fixtures/reports/valid-grammar.md +12 -0
  54. tracecite/skills/writing-evidence-backed-reports/fixtures/reports/valid-report.md +27 -0
  55. tracecite/skills/writing-evidence-backed-reports/fixtures/source-links.toml +17 -0
  56. tracecite/skills/writing-evidence-backed-reports/fixtures/validate_fixtures.py +295 -0
  57. tracecite/skills/writing-evidence-backed-reports/references/images-and-source-links.md +34 -0
  58. tracecite/skills/writing-evidence-backed-reports/references/quotation-and-verification.md +43 -0
  59. tracecite/skills/writing-evidence-backed-reports/references/reference-style-citations.md +48 -0
  60. tracecite/skills/writing-evidence-backed-reports/references/report-structure.md +58 -0
  61. tracecite/tables/__init__.py +41 -0
  62. tracecite/tables/_text.py +142 -0
  63. tracecite/tables/document.py +582 -0
  64. tracecite/tables/html.py +299 -0
  65. tracecite/tables/models.py +119 -0
  66. tracecite/tables/normalise.py +786 -0
  67. tracecite/tables/pandoc.py +503 -0
  68. tracecite/tables/publish.py +238 -0
  69. tracecite/tables/render.py +128 -0
  70. tracecite/tables/site.py +457 -0
  71. tracecite-0.0.2.dist-info/METADATA +320 -0
  72. tracecite-0.0.2.dist-info/RECORD +76 -0
  73. tracecite-0.0.2.dist-info/WHEEL +5 -0
  74. tracecite-0.0.2.dist-info/entry_points.txt +2 -0
  75. tracecite-0.0.2.dist-info/licenses/LICENSE +21 -0
  76. 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
+ ]