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,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,3 @@
1
+ """Optional TraceCite evidence indexing and verification subsystem."""
2
+
3
+ from __future__ import annotations
@@ -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