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,457 @@
|
|
|
1
|
+
"""Adapter that feeds retained documentation into the existing evidence stack."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import shutil
|
|
8
|
+
import tempfile
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
from ..evidence.paths import file_sha256
|
|
14
|
+
from ..tables import augment_document_with_embedding_text
|
|
15
|
+
from .contract import DocsEvidenceContract
|
|
16
|
+
from .stage import is_publication_excluded, validate_retained_source_links
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from ..evidence.sync import SyncOptions, SyncReport
|
|
20
|
+
|
|
21
|
+
INDEX_INPUT_NAME = "index-input"
|
|
22
|
+
MANIFEST_NAME = "index-input.manifest.toml"
|
|
23
|
+
|
|
24
|
+
_EXCLUDED_DIR_NAMES = {
|
|
25
|
+
".git",
|
|
26
|
+
".quarto",
|
|
27
|
+
"_quarto",
|
|
28
|
+
"_site",
|
|
29
|
+
"site_libs",
|
|
30
|
+
"_tracecite",
|
|
31
|
+
".tracecite",
|
|
32
|
+
"build",
|
|
33
|
+
".pytest_cache",
|
|
34
|
+
".venv",
|
|
35
|
+
"dist",
|
|
36
|
+
"__pycache__",
|
|
37
|
+
"model-cache",
|
|
38
|
+
}
|
|
39
|
+
_EXCLUDED_SUFFIXES = {".html"}
|
|
40
|
+
_RESOURCE_REFERENCE_RE = re.compile(
|
|
41
|
+
r"(?:!\[[^\]]*\]|\[[^\]]*\])\(([^)]+)\)|^\s*\[[^\]]+\]:\s*(\S+)",
|
|
42
|
+
re.MULTILINE,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True, slots=True)
|
|
47
|
+
class DocsIndexProfile:
|
|
48
|
+
input_root: Path
|
|
49
|
+
manifest_path: Path
|
|
50
|
+
database_path: Path
|
|
51
|
+
model_cache_dir: Path
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True, slots=True)
|
|
55
|
+
class DocsIndexResult:
|
|
56
|
+
profile: DocsIndexProfile
|
|
57
|
+
tables_normalized: int
|
|
58
|
+
sync_report: SyncReport
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def resolve_docs_index_profile(contract: DocsEvidenceContract) -> DocsIndexProfile:
|
|
62
|
+
database_path = contract.index_output.resolve()
|
|
63
|
+
return DocsIndexProfile(
|
|
64
|
+
input_root=(contract.staged_root / INDEX_INPUT_NAME).resolve(),
|
|
65
|
+
manifest_path=(contract.staged_root / MANIFEST_NAME).resolve(),
|
|
66
|
+
database_path=database_path,
|
|
67
|
+
model_cache_dir=(database_path.parent / "model-cache").resolve(),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _inside(path: Path, root: Path) -> bool:
|
|
72
|
+
return path.resolve().is_relative_to(root.resolve())
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _should_skip_retained_path(path: Path, retained_root: Path) -> bool:
|
|
76
|
+
relative = path.relative_to(retained_root)
|
|
77
|
+
return (
|
|
78
|
+
not path.is_file()
|
|
79
|
+
or any(
|
|
80
|
+
part in _EXCLUDED_DIR_NAMES or part.startswith(".tracecite")
|
|
81
|
+
for part in relative.parts
|
|
82
|
+
)
|
|
83
|
+
or path.suffix.lower() in _EXCLUDED_SUFFIXES
|
|
84
|
+
or path.name.endswith("_files")
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _mirror_exclude_globs(contract: DocsEvidenceContract) -> tuple[str, ...]:
|
|
89
|
+
retained = contract.retained_root.resolve()
|
|
90
|
+
globs: set[str] = set()
|
|
91
|
+
for exclude in contract.publication_exclude:
|
|
92
|
+
target = exclude.resolve()
|
|
93
|
+
if not _inside(target, retained):
|
|
94
|
+
continue
|
|
95
|
+
relative = target.relative_to(retained).as_posix()
|
|
96
|
+
if not relative or relative == ".":
|
|
97
|
+
raise ValueError(
|
|
98
|
+
f"publication_exclude cannot exclude the entire retained root: {exclude}"
|
|
99
|
+
)
|
|
100
|
+
if ".." in relative.split("/"):
|
|
101
|
+
raise ValueError(
|
|
102
|
+
f"publication_exclude cannot be represented safely in the mirror: {exclude}"
|
|
103
|
+
)
|
|
104
|
+
globs.add(f"{relative}/**" if target.is_dir() else relative)
|
|
105
|
+
for name in sorted(_EXCLUDED_DIR_NAMES):
|
|
106
|
+
globs.add(f"**/{name}/**")
|
|
107
|
+
for suffix in sorted(_EXCLUDED_SUFFIXES):
|
|
108
|
+
globs.add(f"**/*{suffix}")
|
|
109
|
+
globs.add("**/*_files/**")
|
|
110
|
+
return tuple(sorted(globs))
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _retained_markdown_paths(contract: DocsEvidenceContract) -> list[Path]:
|
|
114
|
+
retained = contract.retained_root.resolve()
|
|
115
|
+
return [
|
|
116
|
+
path
|
|
117
|
+
for path in sorted(retained.rglob("*.md"))
|
|
118
|
+
if not _should_skip_retained_path(path, retained)
|
|
119
|
+
and not is_publication_excluded(path, contract)
|
|
120
|
+
]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _local_resource_destinations(markdown: str) -> set[str]:
|
|
124
|
+
destinations: set[str] = set()
|
|
125
|
+
for match in _RESOURCE_REFERENCE_RE.finditer(markdown):
|
|
126
|
+
destination = (match.group(1) or match.group(2) or "").strip()
|
|
127
|
+
if not destination or destination.startswith("<"):
|
|
128
|
+
continue
|
|
129
|
+
lowered = destination.lower()
|
|
130
|
+
if lowered.startswith(("http://", "https://", "mailto:")):
|
|
131
|
+
continue
|
|
132
|
+
if "#page=" in lowered or lowered.endswith(".pdf"):
|
|
133
|
+
continue
|
|
134
|
+
destination = destination.split("#", 1)[0].strip()
|
|
135
|
+
if destination:
|
|
136
|
+
destinations.add(destination)
|
|
137
|
+
return destinations
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _copy_resource_files(
|
|
141
|
+
*,
|
|
142
|
+
source_markdown: Path,
|
|
143
|
+
retained_root: Path,
|
|
144
|
+
mirror_root: Path,
|
|
145
|
+
original_markdown: str,
|
|
146
|
+
) -> None:
|
|
147
|
+
for destination in sorted(_local_resource_destinations(original_markdown)):
|
|
148
|
+
source = (source_markdown.parent / destination).resolve()
|
|
149
|
+
if not source.is_file() or not _inside(source, retained_root):
|
|
150
|
+
continue
|
|
151
|
+
target = mirror_root / source.relative_to(retained_root)
|
|
152
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
153
|
+
shutil.copy2(source, target)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _render_manifest(exclude_globs: tuple[str, ...]) -> str:
|
|
157
|
+
lines = ["schema_version = 1", "", "[[include]]", 'glob = "**/*.md"']
|
|
158
|
+
for pattern in exclude_globs:
|
|
159
|
+
lines.extend(["", "[[exclude]]", f'glob = "{pattern}"'])
|
|
160
|
+
return "\n".join(lines) + "\n"
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _snapshot_retained(retained_root: Path) -> dict[Path, bytes]:
|
|
164
|
+
return {
|
|
165
|
+
path.relative_to(retained_root): path.read_bytes()
|
|
166
|
+
for path in sorted(retained_root.rglob("*"))
|
|
167
|
+
if path.is_file()
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _assert_retained_unchanged(retained_root: Path, before: dict[Path, bytes]) -> None:
|
|
172
|
+
after = _snapshot_retained(retained_root)
|
|
173
|
+
if before != after:
|
|
174
|
+
changed = sorted(
|
|
175
|
+
set(before) ^ set(after)
|
|
176
|
+
| {path for path in before if before.get(path) != after.get(path)}
|
|
177
|
+
)
|
|
178
|
+
raise RuntimeError(
|
|
179
|
+
f"retained Markdown was mutated during index-input preparation: {changed[0]}"
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _atomic_promote_pair(replacements: tuple[tuple[Path, Path], ...]) -> None:
|
|
184
|
+
"""Replace every destination with its staged temp path, or roll all of them back.
|
|
185
|
+
|
|
186
|
+
``replacements`` is a sequence of ``(temp_path, destination)`` pairs. If any
|
|
187
|
+
individual replace fails partway through, every destination already moved is
|
|
188
|
+
restored to its prior content and no stray ``.previous`` backup is left behind.
|
|
189
|
+
"""
|
|
190
|
+
backups: list[tuple[Path, Path]] = []
|
|
191
|
+
try:
|
|
192
|
+
for _temp, destination in replacements:
|
|
193
|
+
if destination.exists():
|
|
194
|
+
backup = destination.with_name(f".{destination.name}.previous")
|
|
195
|
+
if backup.exists():
|
|
196
|
+
shutil.rmtree(backup) if backup.is_dir() else backup.unlink()
|
|
197
|
+
os.replace(destination, backup)
|
|
198
|
+
backups.append((destination, backup))
|
|
199
|
+
for temp, destination in replacements:
|
|
200
|
+
os.replace(temp, destination)
|
|
201
|
+
except Exception:
|
|
202
|
+
for _temp, destination in replacements:
|
|
203
|
+
if destination.exists():
|
|
204
|
+
shutil.rmtree(
|
|
205
|
+
destination
|
|
206
|
+
) if destination.is_dir() else destination.unlink()
|
|
207
|
+
for destination, backup in backups:
|
|
208
|
+
os.replace(backup, destination)
|
|
209
|
+
raise
|
|
210
|
+
else:
|
|
211
|
+
for _destination, backup in backups:
|
|
212
|
+
if backup.exists():
|
|
213
|
+
shutil.rmtree(backup) if backup.is_dir() else backup.unlink()
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def prepare_docs_index_input(
|
|
217
|
+
contract: DocsEvidenceContract,
|
|
218
|
+
*,
|
|
219
|
+
repo_root: str | Path,
|
|
220
|
+
strict_tables: bool = False,
|
|
221
|
+
pandoc: str | Path | None = None,
|
|
222
|
+
allow_pipe_fallback: bool = False,
|
|
223
|
+
) -> tuple[DocsIndexProfile, int]:
|
|
224
|
+
"""Write the index-input mirror and manifest; return the profile and the number of normalised tables."""
|
|
225
|
+
root = Path(repo_root).resolve()
|
|
226
|
+
profile = resolve_docs_index_profile(contract)
|
|
227
|
+
retained = contract.retained_root.resolve()
|
|
228
|
+
before = _snapshot_retained(retained)
|
|
229
|
+
|
|
230
|
+
link_issues = validate_retained_source_links(contract, repo_root=root)
|
|
231
|
+
if link_issues:
|
|
232
|
+
raise ValueError("; ".join(link_issues))
|
|
233
|
+
|
|
234
|
+
manifest_text = _render_manifest(_mirror_exclude_globs(contract))
|
|
235
|
+
markdown_paths = _retained_markdown_paths(contract)
|
|
236
|
+
|
|
237
|
+
contract.staged_root.parent.mkdir(parents=True, exist_ok=True)
|
|
238
|
+
contract.staged_root.mkdir(parents=True, exist_ok=True)
|
|
239
|
+
temporary = Path(
|
|
240
|
+
tempfile.mkdtemp(prefix=".index-input-", dir=contract.staged_root.parent)
|
|
241
|
+
)
|
|
242
|
+
temp_input = temporary / INDEX_INPUT_NAME
|
|
243
|
+
temp_manifest = temporary / MANIFEST_NAME
|
|
244
|
+
tables_normalized = 0
|
|
245
|
+
|
|
246
|
+
try:
|
|
247
|
+
temp_input.mkdir(parents=True, exist_ok=True)
|
|
248
|
+
for source_path in markdown_paths:
|
|
249
|
+
relative = source_path.relative_to(retained)
|
|
250
|
+
original = source_path.read_text(encoding="utf-8")
|
|
251
|
+
transformed = augment_document_with_embedding_text(
|
|
252
|
+
original,
|
|
253
|
+
document_path=relative.as_posix(),
|
|
254
|
+
strict=strict_tables,
|
|
255
|
+
pandoc=pandoc,
|
|
256
|
+
allow_pipe_fallback=allow_pipe_fallback,
|
|
257
|
+
)
|
|
258
|
+
tables_normalized += len(transformed.tables)
|
|
259
|
+
destination = temp_input / relative
|
|
260
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
261
|
+
destination.write_text(transformed.markdown, encoding="utf-8")
|
|
262
|
+
_copy_resource_files(
|
|
263
|
+
source_markdown=source_path,
|
|
264
|
+
retained_root=retained,
|
|
265
|
+
mirror_root=temp_input,
|
|
266
|
+
original_markdown=original,
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
temp_manifest.write_text(manifest_text, encoding="utf-8")
|
|
270
|
+
|
|
271
|
+
_atomic_promote_pair(
|
|
272
|
+
(
|
|
273
|
+
(temp_input, profile.input_root),
|
|
274
|
+
(temp_manifest, profile.manifest_path),
|
|
275
|
+
)
|
|
276
|
+
)
|
|
277
|
+
finally:
|
|
278
|
+
if temporary.exists():
|
|
279
|
+
shutil.rmtree(temporary)
|
|
280
|
+
_assert_retained_unchanged(retained, before)
|
|
281
|
+
|
|
282
|
+
return profile, tables_normalized
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def sync_docs_index(
|
|
286
|
+
contract: DocsEvidenceContract,
|
|
287
|
+
*,
|
|
288
|
+
repo_root: str | Path,
|
|
289
|
+
options: SyncOptions | None = None,
|
|
290
|
+
embedder=None,
|
|
291
|
+
strict_tables: bool = False,
|
|
292
|
+
pandoc: str | Path | None = None,
|
|
293
|
+
allow_pipe_fallback: bool = False,
|
|
294
|
+
) -> DocsIndexResult:
|
|
295
|
+
profile, tables_normalized = prepare_docs_index_input(
|
|
296
|
+
contract,
|
|
297
|
+
repo_root=repo_root,
|
|
298
|
+
strict_tables=strict_tables,
|
|
299
|
+
pandoc=pandoc,
|
|
300
|
+
allow_pipe_fallback=allow_pipe_fallback,
|
|
301
|
+
)
|
|
302
|
+
from ..evidence import sync as sync_module
|
|
303
|
+
|
|
304
|
+
report = sync_module.sync(
|
|
305
|
+
profile.input_root,
|
|
306
|
+
profile.manifest_path,
|
|
307
|
+
profile.database_path,
|
|
308
|
+
options=options,
|
|
309
|
+
embedder=embedder,
|
|
310
|
+
model_cache_dir=profile.model_cache_dir,
|
|
311
|
+
)
|
|
312
|
+
return DocsIndexResult(profile, tables_normalized, report)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def search_docs_index(
|
|
316
|
+
contract: DocsEvidenceContract,
|
|
317
|
+
query: str,
|
|
318
|
+
*,
|
|
319
|
+
limit: int = 10,
|
|
320
|
+
fts_limit: int = 50,
|
|
321
|
+
vector_limit: int = 50,
|
|
322
|
+
embedder=None,
|
|
323
|
+
) -> list[dict]:
|
|
324
|
+
from ..evidence import schema
|
|
325
|
+
from ..evidence.commands import active_embedding_model, hybrid_search
|
|
326
|
+
|
|
327
|
+
profile = resolve_docs_index_profile(contract)
|
|
328
|
+
if not profile.database_path.is_file():
|
|
329
|
+
return []
|
|
330
|
+
conn = schema.connect_existing(profile.database_path, read_only=True)
|
|
331
|
+
try:
|
|
332
|
+
schema.ensure_schema(conn)
|
|
333
|
+
embedder = embedder or active_embedding_model(conn, profile.model_cache_dir)
|
|
334
|
+
return hybrid_search(conn, query, limit, fts_limit, vector_limit, embedder)
|
|
335
|
+
finally:
|
|
336
|
+
conn.close()
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _index_input_freshness(
|
|
340
|
+
contract: DocsEvidenceContract, profile: DocsIndexProfile
|
|
341
|
+
) -> tuple[str, ...]:
|
|
342
|
+
diagnostics: list[str] = []
|
|
343
|
+
if not profile.input_root.is_dir():
|
|
344
|
+
diagnostics.append("index-input mirror is missing")
|
|
345
|
+
return tuple(diagnostics)
|
|
346
|
+
if not profile.manifest_path.is_file():
|
|
347
|
+
diagnostics.append("index-input manifest is missing")
|
|
348
|
+
expected_paths = {
|
|
349
|
+
path.relative_to(contract.retained_root).as_posix()
|
|
350
|
+
for path in _retained_markdown_paths(contract)
|
|
351
|
+
}
|
|
352
|
+
actual_paths = {
|
|
353
|
+
path.relative_to(profile.input_root).as_posix()
|
|
354
|
+
for path in sorted(profile.input_root.rglob("*.md"))
|
|
355
|
+
if path.is_file()
|
|
356
|
+
}
|
|
357
|
+
for path in sorted(expected_paths - actual_paths):
|
|
358
|
+
diagnostics.append(f"index-input missing: {path}")
|
|
359
|
+
for path in sorted(actual_paths - expected_paths):
|
|
360
|
+
diagnostics.append(f"index-input extra: {path}")
|
|
361
|
+
for relative in sorted(expected_paths & actual_paths):
|
|
362
|
+
source = contract.retained_root / relative
|
|
363
|
+
mirror = profile.input_root / relative
|
|
364
|
+
expected = augment_document_with_embedding_text(
|
|
365
|
+
source.read_text(encoding="utf-8"),
|
|
366
|
+
document_path=relative,
|
|
367
|
+
).markdown
|
|
368
|
+
if mirror.read_text(encoding="utf-8") != expected:
|
|
369
|
+
diagnostics.append(f"index-input stale (retained changed): {relative}")
|
|
370
|
+
for path in sorted(profile.input_root.rglob("*")):
|
|
371
|
+
if not path.is_file():
|
|
372
|
+
continue
|
|
373
|
+
relative = path.relative_to(profile.input_root)
|
|
374
|
+
if any(part in _EXCLUDED_DIR_NAMES for part in relative.parts):
|
|
375
|
+
diagnostics.append(f"index-input leaked artifact: {relative.as_posix()}")
|
|
376
|
+
if path.suffix.lower() in _EXCLUDED_SUFFIXES:
|
|
377
|
+
diagnostics.append(f"index-input leaked artifact: {relative.as_posix()}")
|
|
378
|
+
return tuple(diagnostics)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _manifest_freshness(
|
|
382
|
+
contract: DocsEvidenceContract, profile: DocsIndexProfile
|
|
383
|
+
) -> tuple[str, ...]:
|
|
384
|
+
if not profile.manifest_path.is_file():
|
|
385
|
+
return ()
|
|
386
|
+
expected = _render_manifest(_mirror_exclude_globs(contract))
|
|
387
|
+
try:
|
|
388
|
+
actual = profile.manifest_path.read_text(encoding="utf-8")
|
|
389
|
+
except OSError as exc:
|
|
390
|
+
return (f"index-input manifest is unreadable: {exc}",)
|
|
391
|
+
if actual != expected:
|
|
392
|
+
return (
|
|
393
|
+
"index-input manifest is stale or does not match the current docs contract",
|
|
394
|
+
)
|
|
395
|
+
return ()
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _database_freshness(profile: DocsIndexProfile) -> tuple[str, ...]:
|
|
399
|
+
if not profile.manifest_path.is_file():
|
|
400
|
+
return ()
|
|
401
|
+
from ..evidence import manifest, schema
|
|
402
|
+
|
|
403
|
+
try:
|
|
404
|
+
rules = manifest.load_manifest(profile.manifest_path)
|
|
405
|
+
except Exception: # noqa: BLE001
|
|
406
|
+
# An unparsable manifest is already reported by _manifest_freshness;
|
|
407
|
+
# there is no usable source selection to compare the database against.
|
|
408
|
+
return ()
|
|
409
|
+
discovered = manifest.resolve_sources(profile.input_root, rules).selected
|
|
410
|
+
conn = schema.connect_existing(profile.database_path, read_only=True)
|
|
411
|
+
try:
|
|
412
|
+
schema.ensure_schema(conn)
|
|
413
|
+
rows = {
|
|
414
|
+
row["path"]: row["sha256"]
|
|
415
|
+
for row in conn.execute("SELECT path, sha256 FROM sources")
|
|
416
|
+
}
|
|
417
|
+
finally:
|
|
418
|
+
conn.close()
|
|
419
|
+
diagnostics: list[str] = []
|
|
420
|
+
for path in sorted(set(discovered) - set(rows)):
|
|
421
|
+
diagnostics.append(f"documentation index database missing source: {path}")
|
|
422
|
+
for path in sorted(set(rows) - set(discovered)):
|
|
423
|
+
diagnostics.append(f"documentation index database has stale source: {path}")
|
|
424
|
+
for path in sorted(set(discovered) & set(rows)):
|
|
425
|
+
if file_sha256(discovered[path]) != rows[path]:
|
|
426
|
+
diagnostics.append(
|
|
427
|
+
f"documentation index database is stale relative to mirror: {path}"
|
|
428
|
+
)
|
|
429
|
+
return tuple(diagnostics)
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def docs_index_freshness_diagnostics(contract: DocsEvidenceContract) -> tuple[str, ...]:
|
|
433
|
+
profile = resolve_docs_index_profile(contract)
|
|
434
|
+
diagnostics = list(_index_input_freshness(contract, profile))
|
|
435
|
+
diagnostics.extend(_manifest_freshness(contract, profile))
|
|
436
|
+
if not profile.database_path.is_file():
|
|
437
|
+
diagnostics.append("documentation index database is missing")
|
|
438
|
+
else:
|
|
439
|
+
diagnostics.extend(_database_freshness(profile))
|
|
440
|
+
return tuple(diagnostics)
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def doctor_docs_index(contract: DocsEvidenceContract) -> tuple[str, ...]:
|
|
444
|
+
profile = resolve_docs_index_profile(contract)
|
|
445
|
+
issues = list(docs_index_freshness_diagnostics(contract))
|
|
446
|
+
if not profile.database_path.is_file():
|
|
447
|
+
return tuple(issues)
|
|
448
|
+
from ..evidence import schema
|
|
449
|
+
from ..evidence import sync as sync_module
|
|
450
|
+
|
|
451
|
+
conn = schema.connect_existing(profile.database_path, read_only=True)
|
|
452
|
+
try:
|
|
453
|
+
schema.ensure_schema(conn)
|
|
454
|
+
issues.extend(sync_module.integrity_check(conn))
|
|
455
|
+
finally:
|
|
456
|
+
conn.close()
|
|
457
|
+
return tuple(issues)
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""Greedy char-budget chunker, normalisation, and chunk-identity hashing.
|
|
2
|
+
|
|
3
|
+
Parsers emit fine-grained ``ParsedChunkUnit`` sequences (for example, a PDF
|
|
4
|
+
text block, Markdown paragraph, or workbook row range). This module groups
|
|
5
|
+
adjacent units into retrieval-sized chunks and computes two identity
|
|
6
|
+
hashes:
|
|
7
|
+
|
|
8
|
+
- ``lexical_hash``: sha256 of the raw (unnormalised) chunk body.
|
|
9
|
+
- ``semantic_input_hash``: sha256 of
|
|
10
|
+
``normalisation_version + source_type + semantic_context + normalised body``.
|
|
11
|
+
Path, line, page, and modification time are locators, not meaning, and are
|
|
12
|
+
deliberately excluded.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import hashlib
|
|
18
|
+
import json
|
|
19
|
+
import re
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
|
|
22
|
+
from .parsers.base import ParsedChunkUnit
|
|
23
|
+
|
|
24
|
+
DEFAULT_MAX_CHUNK_CHARS = 1200
|
|
25
|
+
_CELL_REFERENCE_RE = re.compile(r"^([A-Z]+)([1-9][0-9]*)$")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class ChunkCandidate:
|
|
30
|
+
logical_key: str
|
|
31
|
+
ordinal: int
|
|
32
|
+
heading_path: list[str]
|
|
33
|
+
symbol: str | None
|
|
34
|
+
body: str
|
|
35
|
+
content_type: str
|
|
36
|
+
physical_page: int | None
|
|
37
|
+
page_start_offset: int | None
|
|
38
|
+
page_end_offset: int | None
|
|
39
|
+
page_range_start: int | None
|
|
40
|
+
page_range_end: int | None
|
|
41
|
+
line_start: int | None
|
|
42
|
+
line_end: int | None
|
|
43
|
+
locator: dict = field(default_factory=dict)
|
|
44
|
+
semantic_input_hash: str = ""
|
|
45
|
+
lexical_hash: str = ""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def normalise_text(text: str) -> str:
|
|
49
|
+
"""Collapse whitespace so trivial reflow never changes semantic identity."""
|
|
50
|
+
|
|
51
|
+
return " ".join(text.split())
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def compute_lexical_hash(body: str) -> str:
|
|
55
|
+
return hashlib.sha256(body.encode("utf-8")).hexdigest()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def compute_semantic_input_hash(
|
|
59
|
+
*,
|
|
60
|
+
normalisation_version: str,
|
|
61
|
+
source_type: str,
|
|
62
|
+
heading_path: list[str],
|
|
63
|
+
symbol: str | None,
|
|
64
|
+
body: str,
|
|
65
|
+
) -> str:
|
|
66
|
+
normalised_body = normalise_text(body)
|
|
67
|
+
context = json.dumps([heading_path, symbol], sort_keys=True)
|
|
68
|
+
payload = (
|
|
69
|
+
f"{normalisation_version}\x1f{source_type}\x1f{context}\x1f{normalised_body}"
|
|
70
|
+
)
|
|
71
|
+
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def group_units(
|
|
75
|
+
units: list[ParsedChunkUnit], max_chars: int = DEFAULT_MAX_CHUNK_CHARS
|
|
76
|
+
) -> list[list[ParsedChunkUnit]]:
|
|
77
|
+
"""Greedily group consecutive units into chunks bounded by ``max_chars``.
|
|
78
|
+
|
|
79
|
+
A single unit is never split. Grouping only combines adjacent units that
|
|
80
|
+
share the same heading path so a chunk never silently straddles a
|
|
81
|
+
section boundary.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
groups: list[list[ParsedChunkUnit]] = []
|
|
85
|
+
current: list[ParsedChunkUnit] = []
|
|
86
|
+
current_len = 0
|
|
87
|
+
for unit in units:
|
|
88
|
+
unit_len = len(unit.text)
|
|
89
|
+
breaks_group = current and (
|
|
90
|
+
current_len + unit_len > max_chars
|
|
91
|
+
or unit.heading_path != current[-1].heading_path
|
|
92
|
+
or unit.physical_page != current[-1].physical_page
|
|
93
|
+
)
|
|
94
|
+
if breaks_group:
|
|
95
|
+
groups.append(current)
|
|
96
|
+
current = []
|
|
97
|
+
current_len = 0
|
|
98
|
+
current.append(unit)
|
|
99
|
+
current_len += unit_len + 2
|
|
100
|
+
if current:
|
|
101
|
+
groups.append(current)
|
|
102
|
+
return groups
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _cell_coordinates(reference: str) -> tuple[int, int] | None:
|
|
106
|
+
match = _CELL_REFERENCE_RE.fullmatch(reference)
|
|
107
|
+
if match is None:
|
|
108
|
+
return None
|
|
109
|
+
column = 0
|
|
110
|
+
for character in match.group(1):
|
|
111
|
+
column = column * 26 + ord(character) - ord("A") + 1
|
|
112
|
+
return column, int(match.group(2))
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _column_label(column: int) -> str:
|
|
116
|
+
label = ""
|
|
117
|
+
while column:
|
|
118
|
+
column, remainder = divmod(column - 1, 26)
|
|
119
|
+
label = chr(ord("A") + remainder) + label
|
|
120
|
+
return label
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _excel_bounding_range(locators: list[dict]) -> str | None:
|
|
124
|
+
coordinates: list[tuple[int, int]] = []
|
|
125
|
+
for locator in locators:
|
|
126
|
+
for key in ("start_cell", "end_cell"):
|
|
127
|
+
parsed = _cell_coordinates(str(locator.get(key, "")))
|
|
128
|
+
if parsed is None:
|
|
129
|
+
return None
|
|
130
|
+
coordinates.append(parsed)
|
|
131
|
+
min_column = min(column for column, _row in coordinates)
|
|
132
|
+
max_column = max(column for column, _row in coordinates)
|
|
133
|
+
min_row = min(row for _column, row in coordinates)
|
|
134
|
+
max_row = max(row for _column, row in coordinates)
|
|
135
|
+
start = f"{_column_label(min_column)}{min_row}"
|
|
136
|
+
end = f"{_column_label(max_column)}{max_row}"
|
|
137
|
+
return start if start == end else f"{start}:{end}"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _merged_locator(group: list[ParsedChunkUnit]) -> dict:
|
|
141
|
+
locators = [unit.locator for unit in group if unit.locator]
|
|
142
|
+
if not locators:
|
|
143
|
+
return {"unit_count": len(group)}
|
|
144
|
+
if all(locator.get("kind") == "excel-range" for locator in locators):
|
|
145
|
+
sheets = {locator.get("sheet") for locator in locators}
|
|
146
|
+
if len(sheets) == 1:
|
|
147
|
+
first = locators[0]
|
|
148
|
+
exact_ranges = [locator["range"] for locator in locators]
|
|
149
|
+
return {
|
|
150
|
+
"kind": "excel-range",
|
|
151
|
+
"sheet": first.get("sheet"),
|
|
152
|
+
"sheet_index": first.get("sheet_index"),
|
|
153
|
+
"sheet_state": first.get("sheet_state"),
|
|
154
|
+
"range": _excel_bounding_range(locators),
|
|
155
|
+
"range_kind": "bounding",
|
|
156
|
+
"exact_ranges": exact_ranges,
|
|
157
|
+
"row_start": min(locator["row_start"] for locator in locators),
|
|
158
|
+
"row_end": max(locator["row_end"] for locator in locators),
|
|
159
|
+
"unit_count": len(group),
|
|
160
|
+
}
|
|
161
|
+
if len(locators) == 1:
|
|
162
|
+
return {**locators[0], "unit_count": len(group)}
|
|
163
|
+
return {"unit_count": len(group), "parts": locators}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def build_chunk_candidates(
|
|
167
|
+
groups: list[list[ParsedChunkUnit]],
|
|
168
|
+
*,
|
|
169
|
+
source_type: str,
|
|
170
|
+
normalisation_version: str,
|
|
171
|
+
) -> list[ChunkCandidate]:
|
|
172
|
+
candidates: list[ChunkCandidate] = []
|
|
173
|
+
for ordinal, group in enumerate(groups):
|
|
174
|
+
first = group[0]
|
|
175
|
+
last = group[-1]
|
|
176
|
+
body = "\n\n".join(unit.text for unit in group)
|
|
177
|
+
content_types = {unit.content_type for unit in group}
|
|
178
|
+
content_type = first.content_type if len(content_types) == 1 else "mixed"
|
|
179
|
+
|
|
180
|
+
physical_pages = {
|
|
181
|
+
unit.physical_page for unit in group if unit.physical_page is not None
|
|
182
|
+
}
|
|
183
|
+
physical_page = first.physical_page if len(physical_pages) <= 1 else None
|
|
184
|
+
page_range_start = min(physical_pages) if physical_pages else None
|
|
185
|
+
page_range_end = max(physical_pages) if physical_pages else None
|
|
186
|
+
|
|
187
|
+
semantic_hash = compute_semantic_input_hash(
|
|
188
|
+
normalisation_version=normalisation_version,
|
|
189
|
+
source_type=source_type,
|
|
190
|
+
heading_path=first.heading_path,
|
|
191
|
+
symbol=first.symbol,
|
|
192
|
+
body=body,
|
|
193
|
+
)
|
|
194
|
+
candidates.append(
|
|
195
|
+
ChunkCandidate(
|
|
196
|
+
logical_key=first.logical_key,
|
|
197
|
+
ordinal=ordinal,
|
|
198
|
+
heading_path=first.heading_path,
|
|
199
|
+
symbol=first.symbol,
|
|
200
|
+
body=body,
|
|
201
|
+
content_type=content_type,
|
|
202
|
+
physical_page=physical_page,
|
|
203
|
+
page_start_offset=first.page_start_offset,
|
|
204
|
+
page_end_offset=last.page_end_offset,
|
|
205
|
+
page_range_start=page_range_start,
|
|
206
|
+
page_range_end=page_range_end,
|
|
207
|
+
line_start=first.line_start,
|
|
208
|
+
line_end=last.line_end,
|
|
209
|
+
locator=_merged_locator(group),
|
|
210
|
+
semantic_input_hash=semantic_hash,
|
|
211
|
+
lexical_hash=compute_lexical_hash(body),
|
|
212
|
+
)
|
|
213
|
+
)
|
|
214
|
+
return candidates
|