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,142 @@
1
+ """Markdown parser: heading-hierarchy and paragraph-aware extraction.
2
+
3
+ Markdown has no physical pages, so this parser synthesises a single
4
+ retained-extraction "page" row (``physical_page=1``) holding the complete
5
+ document text and a structural unit list in ``layout_json``, so a
6
+ chunker-only or normalisation-only change can rebuild chunk-input units
7
+ without re-reading the file (mirroring the PDF parser's retained-page reuse).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+
14
+ from .base import ParsedChunkUnit, ParsedPage, ParseResult
15
+
16
+ NAME = "markdown-heading"
17
+
18
+ _HEADING_RE = re.compile(r"^(#{1,6})\s+(.*)$")
19
+ DEFAULT_MAX_UNIT_CHARS = 2400
20
+
21
+
22
+ def _split_oversized(text: str, max_chars: int) -> list[str]:
23
+ if len(text) <= max_chars:
24
+ return [text]
25
+ words = text.split(" ")
26
+ parts: list[str] = []
27
+ current: list[str] = []
28
+ current_len = 0
29
+ for word in words:
30
+ word_len = len(word) + 1
31
+ if current and current_len + word_len > max_chars:
32
+ parts.append(" ".join(current))
33
+ current = []
34
+ current_len = 0
35
+ current.append(word)
36
+ current_len += word_len
37
+ if current:
38
+ parts.append(" ".join(current))
39
+ return parts
40
+
41
+
42
+ def parse(path, config: dict | None = None) -> ParseResult:
43
+ config = config or {}
44
+ max_unit_chars = int(config.get("max_unit_chars", DEFAULT_MAX_UNIT_CHARS))
45
+
46
+ text = path.read_text(encoding="utf-8")
47
+ lines = text.splitlines()
48
+
49
+ units: list[ParsedChunkUnit] = []
50
+ heading_path: list[str] = []
51
+ heading_occurrence: dict[str, int] = {}
52
+ heading_key = "root#0"
53
+ para_index = 0
54
+ para_lines: list[str] = []
55
+ para_start_line: int | None = None
56
+
57
+ def flush_paragraph(end_line: int) -> None:
58
+ nonlocal para_lines, para_start_line, para_index
59
+ if not para_lines:
60
+ return
61
+ body = "\n".join(para_lines).strip()
62
+ para_lines = []
63
+ start_line = para_start_line
64
+ para_start_line = None
65
+ if not body:
66
+ return
67
+ for sub_index, sub_body in enumerate(_split_oversized(body, max_unit_chars)):
68
+ key = f"{heading_key}:p{para_index:04d}"
69
+ if sub_index:
70
+ key = f"{key}:sub{sub_index:02d}"
71
+ units.append(
72
+ ParsedChunkUnit(
73
+ text=sub_body,
74
+ logical_key=key,
75
+ heading_path=list(heading_path),
76
+ symbol=None,
77
+ content_type="body",
78
+ physical_page=None,
79
+ line_start=start_line,
80
+ line_end=end_line,
81
+ locator={},
82
+ )
83
+ )
84
+ para_index += 1
85
+
86
+ for line_no, raw_line in enumerate(lines, start=1):
87
+ stripped = raw_line.strip()
88
+ heading_match = _HEADING_RE.match(raw_line)
89
+ if heading_match:
90
+ flush_paragraph(line_no - 1)
91
+ level = len(heading_match.group(1))
92
+ title = heading_match.group(2).strip()
93
+ heading_path = heading_path[: level - 1] + [title]
94
+ key_base = "/".join(heading_path)
95
+ occurrence = heading_occurrence.get(key_base, 0)
96
+ heading_occurrence[key_base] = occurrence + 1
97
+ heading_key = f"{key_base}#{occurrence}"
98
+ para_index = 0
99
+ units.append(
100
+ ParsedChunkUnit(
101
+ text=title,
102
+ logical_key=f"{heading_key}:heading",
103
+ heading_path=list(heading_path),
104
+ symbol=None,
105
+ content_type="heading",
106
+ physical_page=None,
107
+ line_start=line_no,
108
+ line_end=line_no,
109
+ locator={},
110
+ )
111
+ )
112
+ continue
113
+ if not stripped:
114
+ flush_paragraph(line_no - 1)
115
+ continue
116
+ if para_start_line is None:
117
+ para_start_line = line_no
118
+ para_lines.append(raw_line)
119
+ flush_paragraph(len(lines))
120
+
121
+ layout_units = [
122
+ {
123
+ "text": unit.text,
124
+ "logical_key": unit.logical_key,
125
+ "heading_path": unit.heading_path,
126
+ "content_type": unit.content_type,
127
+ "line_start": unit.line_start,
128
+ "line_end": unit.line_end,
129
+ }
130
+ for unit in units
131
+ ]
132
+
133
+ page = ParsedPage(
134
+ physical_page=1,
135
+ printed_label=None,
136
+ text=text,
137
+ extraction_method=NAME,
138
+ extraction_status="ok" if units else "empty",
139
+ section_candidates=[u.text for u in units if u.content_type == "heading"],
140
+ layout={"units": layout_units},
141
+ )
142
+ return ParseResult(pages=[page], units=units)
@@ -0,0 +1,315 @@
1
+ """PyMuPDF-backed PDF parser: page-, block-, and layout-aware extraction.
2
+
3
+ All PyMuPDF interaction (text extraction, page rendering, image crops) is
4
+ kept behind this module so the database and command contracts never depend
5
+ on the specific PDF backend.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import subprocess
12
+ import tempfile
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+
16
+ import pymupdf
17
+
18
+ from .base import ParsedChunkUnit, ParsedPage, ParseResult
19
+
20
+ NAME = "pdf-pymupdf"
21
+ HEADING_MAX_CHARS = 70
22
+
23
+ # A page whose extracted text is shorter than this is treated as a scanned
24
+ # page without a text layer and goes through the OCR fallback. 20 keeps a
25
+ # near-blank title page out of OCR while staying above the few stray
26
+ # characters PyMuPDF can extract from a rasterised page's metadata.
27
+ MIN_EXTRACTED_TEXT_CHARS = 20
28
+ DEFAULT_OCR_LANG = "eng"
29
+ OCR_DPI = 300
30
+ TESSERACT_CMD = "tesseract"
31
+
32
+ # A text block is "nearby" a figure crop when it overlaps the crop
33
+ # horizontally (with tolerance, since captions are often narrower than the
34
+ # image) and sits within 72pt (1 inch) above or below it: close enough for a
35
+ # caption without pulling in body text further down the page.
36
+ NEARBY_TEXT_MAX_VERTICAL_DISTANCE = 72.0
37
+ NEARBY_TEXT_HORIZONTAL_TOLERANCE = 20.0
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class RenderedAsset:
42
+ physical_page: int
43
+ asset_type: str
44
+ image_bytes: bytes
45
+ ext: str
46
+ width: int
47
+ height: int
48
+ bbox: tuple[float, float, float, float] | None
49
+ label: str | None = None
50
+ caption: str | None = None
51
+ nearby_text: str | None = None
52
+
53
+
54
+ def _looks_like_heading(text: str) -> bool:
55
+ stripped = text.strip()
56
+ return (
57
+ bool(stripped)
58
+ and len(stripped) <= HEADING_MAX_CHARS
59
+ and not stripped.endswith((".", ",", ";"))
60
+ )
61
+
62
+
63
+ def _blocks_for_page(page: pymupdf.Page) -> list[dict]:
64
+ raw_blocks = page.get_text("blocks")
65
+ text_blocks = [b for b in raw_blocks if b[6] == 0 and b[4].strip()]
66
+ text_blocks.sort(key=lambda b: (round(b[1], 1), round(b[0], 1)))
67
+
68
+ blocks: list[dict] = []
69
+ heading_path: list[str] = []
70
+ offset = 0
71
+ for block in text_blocks:
72
+ x0, y0, x1, y1, text, _block_no, _block_type = block
73
+ text = text.strip()
74
+ is_heading = _looks_like_heading(text)
75
+ if is_heading:
76
+ heading_path = [text]
77
+ start = offset
78
+ end = start + len(text)
79
+ offset = end + 2 # matches the "\n\n" join used to build page text
80
+ blocks.append(
81
+ {
82
+ "text": text,
83
+ "bbox": [x0, y0, x1, y1],
84
+ "heading_path": list(heading_path),
85
+ "content_type": "heading" if is_heading else "body",
86
+ "start_offset": start,
87
+ "end_offset": end,
88
+ }
89
+ )
90
+ return blocks
91
+
92
+
93
+ def _nearby_text_for_bbox(
94
+ blocks: list[dict],
95
+ bbox: tuple[float, float, float, float],
96
+ *,
97
+ max_vertical_distance: float = NEARBY_TEXT_MAX_VERTICAL_DISTANCE,
98
+ horizontal_tolerance: float = NEARBY_TEXT_HORIZONTAL_TOLERANCE,
99
+ ) -> str | None:
100
+ """Find text blocks (from the page's already-computed
101
+ :func:`_blocks_for_page` output) vertically close to a figure crop's
102
+ bbox -- typically a caption directly above or below the figure -- and
103
+ join their text in reading order. Returns ``None`` if nothing qualifies.
104
+ """
105
+
106
+ x0, _y0, x1, _y1 = bbox
107
+ matches: list[tuple[float, str]] = []
108
+ for block in blocks:
109
+ bx0, by0, bx1, by1 = block["bbox"]
110
+ horizontally_overlaps = (
111
+ bx0 <= x1 + horizontal_tolerance and bx1 >= x0 - horizontal_tolerance
112
+ )
113
+ if not horizontally_overlaps:
114
+ continue
115
+ if by1 < bbox[1]:
116
+ vertical_distance = bbox[1] - by1
117
+ elif by0 > bbox[3]:
118
+ vertical_distance = by0 - bbox[3]
119
+ else:
120
+ vertical_distance = 0.0
121
+ if vertical_distance <= max_vertical_distance:
122
+ matches.append((by0, block["text"]))
123
+ if not matches:
124
+ return None
125
+ matches.sort(key=lambda item: item[0])
126
+ return "\n".join(text for _, text in matches)
127
+
128
+
129
+ def _pymupdf_ocr_text(page: pymupdf.Page, *, lang: str, dpi: int) -> str:
130
+ """First OCR fallback stage: PyMuPDF's own built-in OCR support."""
131
+
132
+ try:
133
+ textpage = page.get_textpage_ocr(
134
+ flags=pymupdf.TEXTFLAGS_TEXT | pymupdf.TEXT_PRESERVE_WHITESPACE,
135
+ language=lang,
136
+ dpi=dpi,
137
+ full=True,
138
+ )
139
+ return page.get_text("text", textpage=textpage).strip()
140
+ except (RuntimeError, OSError):
141
+ # PyMuPDF's OCR path raises if Tesseract/tessdata is not resolvable
142
+ # in this environment; treat that as "no text", not a hard failure,
143
+ # so the caller can still try the Tesseract-CLI stage.
144
+ return ""
145
+
146
+
147
+ def _tesseract_cli_ocr_text(page: pymupdf.Page, *, lang: str, dpi: int) -> str:
148
+ """Second OCR fallback stage: run the Tesseract CLI on a rendered page image."""
149
+
150
+ pixmap = page.get_pixmap(dpi=dpi)
151
+ with tempfile.TemporaryDirectory() as tmp_dir:
152
+ image_path = Path(tmp_dir) / "page.png"
153
+ pixmap.save(str(image_path))
154
+ try:
155
+ result = subprocess.run(
156
+ [TESSERACT_CMD, str(image_path), "stdout", "-l", lang],
157
+ capture_output=True,
158
+ text=True,
159
+ check=True,
160
+ )
161
+ except (OSError, subprocess.CalledProcessError):
162
+ return ""
163
+ return result.stdout.strip()
164
+
165
+
166
+ def _ocr_fallback_text(page: pymupdf.Page, *, lang: str, dpi: int) -> tuple[str, str]:
167
+ """Two-stage OCR fallback for a page whose normal text extraction came
168
+ back effectively empty: try PyMuPDF's built-in OCR first, and only if
169
+ that still returns too little text, fall back to the Tesseract CLI.
170
+
171
+ Returns ``(text, extraction_method)`` so the caller can record which
172
+ stage (if any) actually produced usable text.
173
+ """
174
+
175
+ pymupdf_text = _pymupdf_ocr_text(page, lang=lang, dpi=dpi)
176
+ if len(pymupdf_text) >= MIN_EXTRACTED_TEXT_CHARS:
177
+ return pymupdf_text, f"{NAME}-ocr-pymupdf"
178
+
179
+ tesseract_text = _tesseract_cli_ocr_text(page, lang=lang, dpi=dpi)
180
+ if len(tesseract_text) > len(pymupdf_text):
181
+ return tesseract_text, f"{NAME}-ocr-tesseract-cli"
182
+ return pymupdf_text, f"{NAME}-ocr-pymupdf"
183
+
184
+
185
+ def parse(path: Path, config: dict | None = None) -> ParseResult:
186
+ config = config or {}
187
+ ocr_lang = config.get("ocr_lang", DEFAULT_OCR_LANG)
188
+ pages: list[ParsedPage] = []
189
+ units: list[ParsedChunkUnit] = []
190
+
191
+ with pymupdf.open(path) as document:
192
+ for index, page in enumerate(document):
193
+ physical_page = index + 1
194
+ blocks = _blocks_for_page(page)
195
+ page_text = "\n\n".join(block["text"] for block in blocks)
196
+ extraction_method = NAME
197
+
198
+ if len(page_text.strip()) < MIN_EXTRACTED_TEXT_CHARS:
199
+ ocr_text, ocr_method = _ocr_fallback_text(
200
+ page, lang=ocr_lang, dpi=OCR_DPI
201
+ )
202
+ ocr_text = ocr_text.strip()
203
+ if len(ocr_text) > len(page_text.strip()):
204
+ page_rect = page.rect
205
+ blocks = [
206
+ {
207
+ "text": ocr_text,
208
+ "bbox": [
209
+ page_rect.x0,
210
+ page_rect.y0,
211
+ page_rect.x1,
212
+ page_rect.y1,
213
+ ],
214
+ "heading_path": [],
215
+ "content_type": "body",
216
+ "start_offset": 0,
217
+ "end_offset": len(ocr_text),
218
+ }
219
+ ]
220
+ page_text = ocr_text
221
+ extraction_method = ocr_method
222
+
223
+ section_candidates = [
224
+ b["text"] for b in blocks if b["content_type"] == "heading"
225
+ ]
226
+
227
+ pages.append(
228
+ ParsedPage(
229
+ physical_page=physical_page,
230
+ printed_label=None,
231
+ text=page_text,
232
+ extraction_method=extraction_method,
233
+ extraction_status="ok" if blocks else "empty",
234
+ section_candidates=section_candidates,
235
+ layout={"blocks": blocks},
236
+ )
237
+ )
238
+ units.extend(_block_units(physical_page, blocks))
239
+
240
+ return ParseResult(pages=pages, units=units)
241
+
242
+
243
+ def _block_units(physical_page: int, blocks: list[dict]) -> list[ParsedChunkUnit]:
244
+ return [
245
+ ParsedChunkUnit(
246
+ text=block["text"],
247
+ logical_key=f"page{physical_page:04d}-block{block_index:03d}",
248
+ heading_path=block["heading_path"],
249
+ symbol=None,
250
+ content_type=block["content_type"],
251
+ physical_page=physical_page,
252
+ page_start_offset=block["start_offset"],
253
+ page_end_offset=block["end_offset"],
254
+ locator={"bbox": block["bbox"]},
255
+ )
256
+ for block_index, block in enumerate(blocks)
257
+ ]
258
+
259
+
260
+ def units_from_page_layout(
261
+ physical_page: int, layout_json: str | None
262
+ ) -> list[ParsedChunkUnit]:
263
+ """Rebuild chunk-input units from retained ``pages.layout_json``.
264
+
265
+ A chunker- or normalisation-only change rechunks from this layout
266
+ without reopening the PDF.
267
+ """
268
+ return _block_units(
269
+ physical_page, json.loads(layout_json)["blocks"] if layout_json else []
270
+ )
271
+
272
+
273
+ def render_page(path: Path, physical_page: int, zoom: float = 2.0) -> RenderedAsset:
274
+ with pymupdf.open(path) as document:
275
+ page = document[physical_page - 1]
276
+ matrix = pymupdf.Matrix(zoom, zoom)
277
+ pixmap = page.get_pixmap(matrix=matrix)
278
+ return RenderedAsset(
279
+ physical_page=physical_page,
280
+ asset_type="page-render",
281
+ image_bytes=pixmap.tobytes("png"),
282
+ ext="png",
283
+ width=pixmap.width,
284
+ height=pixmap.height,
285
+ bbox=None,
286
+ )
287
+
288
+
289
+ def render_figure_crops(path: Path, physical_page: int) -> list[RenderedAsset]:
290
+ rendered: list[RenderedAsset] = []
291
+ with pymupdf.open(path) as document:
292
+ page = document[physical_page - 1]
293
+ blocks = _blocks_for_page(page)
294
+ for image_index, image in enumerate(page.get_images(full=True)):
295
+ xref = image[0]
296
+ info = document.extract_image(xref)
297
+ rects = page.get_image_rects(xref)
298
+ bbox = tuple(rects[0]) if rects else None
299
+ nearby_text = (
300
+ _nearby_text_for_bbox(blocks, bbox) if bbox is not None else None
301
+ )
302
+ rendered.append(
303
+ RenderedAsset(
304
+ physical_page=physical_page,
305
+ asset_type="figure-crop",
306
+ image_bytes=info["image"],
307
+ ext=info["ext"],
308
+ width=info.get("width", 0),
309
+ height=info.get("height", 0),
310
+ bbox=bbox,
311
+ label=f"page-{physical_page:03d}-image-{image_index:02d}",
312
+ nearby_text=nearby_text,
313
+ )
314
+ )
315
+ return rendered