langparse 0.1.0__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 (101) hide show
  1. langparse/__init__.py +55 -0
  2. langparse/autoparser.py +25 -0
  3. langparse/chunkers/__init__.py +12 -0
  4. langparse/chunkers/blocks.py +151 -0
  5. langparse/chunkers/profiles.py +53 -0
  6. langparse/chunkers/registry.py +38 -0
  7. langparse/chunkers/semantic.py +242 -0
  8. langparse/chunkers/text.py +96 -0
  9. langparse/chunkers/workbook.py +942 -0
  10. langparse/cli.py +329 -0
  11. langparse/config.py +169 -0
  12. langparse/core/__init__.py +0 -0
  13. langparse/core/chunker.py +16 -0
  14. langparse/core/engine.py +37 -0
  15. langparse/core/parser.py +35 -0
  16. langparse/core/rendering.py +49 -0
  17. langparse/engines/__init__.py +1 -0
  18. langparse/engines/pdf/__init__.py +1 -0
  19. langparse/engines/pdf/deepdoc/__init__.py +55 -0
  20. langparse/engines/pdf/deepdoc/layout_recognizer.py +235 -0
  21. langparse/engines/pdf/deepdoc/model_loader.py +101 -0
  22. langparse/engines/pdf/deepdoc/ocr.py +641 -0
  23. langparse/engines/pdf/deepdoc/operators.py +684 -0
  24. langparse/engines/pdf/deepdoc/pdf_parser.py +1894 -0
  25. langparse/engines/pdf/deepdoc/postprocess.py +339 -0
  26. langparse/engines/pdf/deepdoc/recognizer.py +418 -0
  27. langparse/engines/pdf/deepdoc/rendering.py +210 -0
  28. langparse/engines/pdf/deepdoc/table_structure_recognizer.py +559 -0
  29. langparse/engines/pdf/deepdoc/tokenizer.py +30 -0
  30. langparse/engines/pdf/deepdoc/utils.py +36 -0
  31. langparse/engines/pdf/deepdoc_engine.py +164 -0
  32. langparse/engines/pdf/mineru.py +259 -0
  33. langparse/engines/pdf/mineru_client.py +318 -0
  34. langparse/engines/pdf/mineru_service.py +225 -0
  35. langparse/engines/pdf/ocr.py +101 -0
  36. langparse/engines/pdf/other.py +20 -0
  37. langparse/engines/pdf/simple.py +134 -0
  38. langparse/engines/pdf/vision_llm.py +27 -0
  39. langparse/errors.py +70 -0
  40. langparse/logging.py +27 -0
  41. langparse/metrics.py +129 -0
  42. langparse/parsers/__init__.py +0 -0
  43. langparse/parsers/docx_parser.py +114 -0
  44. langparse/parsers/excel_parser.py +220 -0
  45. langparse/parsers/markdown_parser.py +34 -0
  46. langparse/parsers/pdf_parser.py +31 -0
  47. langparse/parsers/registry.py +48 -0
  48. langparse/parsers/sniff.py +72 -0
  49. langparse/progress.py +77 -0
  50. langparse/py.typed +0 -0
  51. langparse/services/__init__.py +11 -0
  52. langparse/services/batch_service.py +339 -0
  53. langparse/services/benchmark_service.py +202 -0
  54. langparse/services/fidelity.py +154 -0
  55. langparse/services/output_paths.py +86 -0
  56. langparse/services/parse_service.py +523 -0
  57. langparse/services/quality.py +65 -0
  58. langparse/services/workbook_ambiguity_benchmark.py +563 -0
  59. langparse/services/workbook_quality_benchmark.py +230 -0
  60. langparse/types.py +97 -0
  61. langparse/workbooks/__init__.py +103 -0
  62. langparse/workbooks/adapters.py +474 -0
  63. langparse/workbooks/assembly.py +993 -0
  64. langparse/workbooks/blocks.py +209 -0
  65. langparse/workbooks/bundle-v1.schema.json +71 -0
  66. langparse/workbooks/bundle.py +341 -0
  67. langparse/workbooks/classification.py +393 -0
  68. langparse/workbooks/continuation.py +577 -0
  69. langparse/workbooks/evaluation/__init__.py +45 -0
  70. langparse/workbooks/evaluation/evaluator.py +381 -0
  71. langparse/workbooks/evaluation/schema.py +419 -0
  72. langparse/workbooks/labels.py +14 -0
  73. langparse/workbooks/lineage.py +117 -0
  74. langparse/workbooks/modeling/__init__.py +52 -0
  75. langparse/workbooks/modeling/cache.py +20 -0
  76. langparse/workbooks/modeling/config.py +87 -0
  77. langparse/workbooks/modeling/contract.py +628 -0
  78. langparse/workbooks/modeling/disambiguation.py +800 -0
  79. langparse/workbooks/modeling/openai_adapter.py +192 -0
  80. langparse/workbooks/modeling/policy.py +79 -0
  81. langparse/workbooks/modeling/ports.py +44 -0
  82. langparse/workbooks/modeling/pricing.py +17 -0
  83. langparse/workbooks/modeling/types.py +251 -0
  84. langparse/workbooks/objects.py +229 -0
  85. langparse/workbooks/quality/__init__.py +23 -0
  86. langparse/workbooks/quality/bundle.py +53 -0
  87. langparse/workbooks/quality/evaluator.py +266 -0
  88. langparse/workbooks/quality/facts.py +142 -0
  89. langparse/workbooks/quality/schema.py +462 -0
  90. langparse/workbooks/reference_types.py +73 -0
  91. langparse/workbooks/references.py +178 -0
  92. langparse/workbooks/regions.py +932 -0
  93. langparse/workbooks/rendering.py +222 -0
  94. langparse/workbooks/tables.py +477 -0
  95. langparse/workbooks/types.py +257 -0
  96. langparse-0.1.0.dist-info/METADATA +790 -0
  97. langparse-0.1.0.dist-info/RECORD +101 -0
  98. langparse-0.1.0.dist-info/WHEEL +5 -0
  99. langparse-0.1.0.dist-info/entry_points.txt +2 -0
  100. langparse-0.1.0.dist-info/licenses/LICENSE +192 -0
  101. langparse-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,942 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from dataclasses import asdict
5
+
6
+ from openpyxl.utils import get_column_letter, range_boundaries
7
+
8
+ from langparse.chunkers.profiles import (
9
+ WorkbookChunkPolicy,
10
+ WorkbookChunkProfile,
11
+ resolve_workbook_chunk_policy,
12
+ )
13
+ from langparse.core.rendering import document_metadata
14
+ from langparse.types import Chunk, ParsedDocumentResult
15
+ from langparse.workbooks.types import (
16
+ FormBlock,
17
+ FormField,
18
+ LogicalRow,
19
+ LogicalTable,
20
+ MatrixBlock,
21
+ MatrixHeader,
22
+ SourceRef,
23
+ TableContinuation,
24
+ TextBlock,
25
+ TextLine,
26
+ WorkbookBlock,
27
+ WorkbookIR,
28
+ )
29
+
30
+
31
+ class WorkbookStructuralChunker:
32
+ """Pack complete raw-grid rows directly from workbook compatibility facts."""
33
+
34
+ def __init__(
35
+ self,
36
+ max_chunk_size: int | None = None,
37
+ length_function: Callable[[str], int] = len,
38
+ *,
39
+ profile: str | WorkbookChunkProfile | None = None,
40
+ ):
41
+ self.policy: WorkbookChunkPolicy = resolve_workbook_chunk_policy(profile)
42
+ resolved_size = (
43
+ self.policy.default_max_chunk_size if max_chunk_size is None else max_chunk_size
44
+ )
45
+ if resolved_size <= 0:
46
+ raise ValueError("max_chunk_size must be positive")
47
+ self.max_chunk_size = resolved_size
48
+ self.length_function = length_function
49
+
50
+ def chunk(self, parsed: ParsedDocumentResult) -> list[Chunk]:
51
+ if not isinstance(parsed.structure, WorkbookIR):
52
+ raise TypeError("WorkbookStructuralChunker requires WorkbookIR structure")
53
+
54
+ ir_sheets = {sheet.index: sheet for sheet in parsed.structure.sheets}
55
+ chunks: list[Chunk] = []
56
+ for page in parsed.pages:
57
+ sheet_name = page.metadata.get("sheet_name")
58
+ sheet_ir = ir_sheets.get(page.page_number - 1)
59
+ if sheet_ir is not None and sheet_ir.blocks:
60
+ for block in sheet_ir.blocks:
61
+ chunks.extend(
62
+ self._chunk_block(
63
+ parsed,
64
+ str(sheet_name),
65
+ page.page_number,
66
+ block,
67
+ len(chunks),
68
+ )
69
+ )
70
+ continue
71
+ confidence = (
72
+ sheet_ir.blocks[0].confidence if sheet_ir is not None and sheet_ir.blocks else 1.0
73
+ )
74
+ for table in page.tables:
75
+ rows = table.get("rows", [])
76
+ if not rows:
77
+ continue
78
+ columns = [str(value) for value in table.get("columns") or rows[0]]
79
+ data_rows = [[str(value) for value in row] for row in rows[1:]]
80
+ row_numbers = list(table.get("row_numbers", []))
81
+ if len(row_numbers) != len(data_rows):
82
+ row_numbers = _row_numbers(table.get("source_range"), len(data_rows))
83
+ chunks.extend(
84
+ self._pack_table(
85
+ parsed=parsed,
86
+ sheet_name=str(sheet_name),
87
+ sheet_ordinal=page.page_number,
88
+ columns=columns,
89
+ rows=data_rows,
90
+ row_numbers=row_numbers,
91
+ confidence=confidence,
92
+ chunk_index_offset=len(chunks),
93
+ )
94
+ )
95
+ self._finalize_chunks(parsed, chunks)
96
+ self._validate_chunks(parsed, chunks)
97
+ return chunks
98
+
99
+ def _finalize_chunks(self, parsed: ParsedDocumentResult, chunks: list[Chunk]) -> None:
100
+ workbook_ir = parsed.structure
101
+ assert isinstance(workbook_ir, WorkbookIR)
102
+ for index, chunk in enumerate(chunks):
103
+ chunk.metadata["chunk_index"] = index
104
+ chunk.metadata["chunk_profile"] = self.policy.name.value
105
+ chunk.metadata["chunk_profile_version"] = self.policy.version
106
+ ordinal = int(chunk.metadata["sheet_ordinal"])
107
+ sheet_ir = workbook_ir.sheets[ordinal - 1]
108
+ snapshot = workbook_ir.snapshot
109
+ sheet_snapshot = snapshot.sheets[ordinal - 1] if snapshot is not None else None
110
+ hidden_rows = set(sheet_snapshot.hidden_rows) if sheet_snapshot is not None else set()
111
+ referenced_rows = set(chunk.metadata.get("row_numbers", []))
112
+ if not referenced_rows:
113
+ referenced_rows = _row_numbers_from_source_ranges(chunk.metadata["source_ranges"])
114
+ chunk.metadata["sheet_visibility"] = (
115
+ sheet_snapshot.visibility if sheet_snapshot is not None else sheet_ir.visibility
116
+ )
117
+ chunk.metadata["hidden_row_numbers"] = sorted(referenced_rows & hidden_rows)
118
+ if self.policy.analysis_records:
119
+ lineage = workbook_ir.lineage
120
+ dependencies = []
121
+ for source in chunk.metadata["source_ranges"]:
122
+ name, cell_range = source.rsplit("!", 1)
123
+ for edge in (
124
+ lineage.dependencies_of(SourceRef(name, cell_range)) if lineage else []
125
+ ):
126
+ payload = asdict(edge)
127
+ if payload not in dependencies:
128
+ dependencies.append(payload)
129
+ chunk.structured_payload["dependencies"] = dependencies
130
+
131
+ def _validate_chunks(self, parsed: ParsedDocumentResult, chunks: list[Chunk]) -> None:
132
+ workbook_ir = parsed.structure
133
+ assert isinstance(workbook_ir, WorkbookIR)
134
+ expected_row_ids = [
135
+ row.row_id
136
+ for sheet in workbook_ir.sheets
137
+ for block in sheet.blocks
138
+ if block.logical_table is not None
139
+ for row in block.logical_table.rows
140
+ if row.role in {"data", "total"}
141
+ ]
142
+ actual_row_ids = [
143
+ row_id
144
+ for chunk in chunks
145
+ if chunk.metadata["chunk_type"] == "table_rows"
146
+ for row_id in chunk.metadata["row_ids"]
147
+ ]
148
+ if len(actual_row_ids) != len(set(actual_row_ids)) or set(actual_row_ids) != set(
149
+ expected_row_ids
150
+ ):
151
+ raise ValueError("Workbook chunk row conservation failed")
152
+ if [chunk.metadata["chunk_index"] for chunk in chunks] != list(range(len(chunks))):
153
+ raise ValueError("Workbook chunk indexes are not contiguous")
154
+
155
+ for chunk in chunks:
156
+ source_ranges = chunk.metadata["source_ranges"]
157
+ for source_range in source_ranges:
158
+ if chunk.metadata["chunk_type"] in {"chart", "image"}:
159
+ from langparse.workbooks.objects import validate_object_source
160
+
161
+ validate_object_source(workbook_ir.snapshot, source_range)
162
+ else:
163
+ _source_range_is_valid(workbook_ir.snapshot, source_range)
164
+ if chunk.metadata["chunk_type"] != "table_rows":
165
+ continue
166
+ payload = chunk.structured_payload
167
+ if len(chunk.metadata["row_ids"]) != len(payload["rows"]):
168
+ raise ValueError("Workbook table chunk row payload mismatch")
169
+ if self.policy.analysis_records:
170
+ records = payload["records"]
171
+ if len(chunk.metadata["row_ids"]) != len(records):
172
+ raise ValueError("Workbook table chunk analysis record mismatch")
173
+ record_ranges = list(
174
+ dict.fromkeys(
175
+ source_ref for record in records for source_ref in record["source_refs"]
176
+ )
177
+ )
178
+ if source_ranges != record_ranges:
179
+ raise ValueError("Workbook table chunk source ranges mismatch")
180
+
181
+ def _chunk_block(
182
+ self,
183
+ parsed: ParsedDocumentResult,
184
+ sheet_name: str,
185
+ sheet_ordinal: int,
186
+ block: WorkbookBlock,
187
+ chunk_index_offset: int,
188
+ ) -> list[Chunk]:
189
+ if block.kind in {"chart", "image"}:
190
+ from langparse.workbooks.objects import render_object
191
+
192
+ content = render_object(block)
193
+ metadata = document_metadata(parsed)
194
+ metadata.update(
195
+ {
196
+ "chunk_type": block.kind,
197
+ "chunk_index": chunk_index_offset,
198
+ "sheet_name": sheet_name,
199
+ "sheet_ordinal": sheet_ordinal,
200
+ "source_ranges": [ref.key for ref in block.source_refs],
201
+ "object_id": block.block_id,
202
+ "oversized": self.length_function(content) > self.max_chunk_size,
203
+ }
204
+ )
205
+ return [
206
+ Chunk(
207
+ content=content,
208
+ metadata=metadata,
209
+ structured_payload={"object": block.metadata["object"]},
210
+ )
211
+ ]
212
+ if block.logical_table is not None:
213
+ return self._chunk_logical_table(
214
+ parsed,
215
+ sheet_name,
216
+ sheet_ordinal,
217
+ block.logical_table,
218
+ chunk_index_offset,
219
+ )
220
+ if block.form is not None:
221
+ return self._chunk_form(
222
+ parsed, sheet_name, sheet_ordinal, block.form, chunk_index_offset
223
+ )
224
+ if block.matrix is not None:
225
+ return self._chunk_matrix(
226
+ parsed, sheet_name, sheet_ordinal, block.matrix, chunk_index_offset
227
+ )
228
+ if block.text is not None:
229
+ return self._chunk_text(
230
+ parsed, sheet_name, sheet_ordinal, block.text, chunk_index_offset
231
+ )
232
+ source_range = block.source_refs[0].range
233
+ columns, rows, row_numbers = _raw_block_grid(
234
+ parsed.structure,
235
+ sheet_ordinal,
236
+ source_range,
237
+ )
238
+ return self._pack_table(
239
+ parsed=parsed,
240
+ sheet_name=sheet_name,
241
+ sheet_ordinal=sheet_ordinal,
242
+ columns=columns,
243
+ rows=rows,
244
+ row_numbers=row_numbers,
245
+ confidence=block.confidence,
246
+ chunk_index_offset=chunk_index_offset,
247
+ )
248
+
249
+ def _chunk_logical_table(
250
+ self,
251
+ parsed: ParsedDocumentResult,
252
+ sheet_name: str,
253
+ sheet_ordinal: int,
254
+ table: LogicalTable,
255
+ chunk_index_offset: int,
256
+ ) -> list[Chunk]:
257
+ columns = [" / ".join(column.path) or column.coordinate for column in table.columns]
258
+ continuation = _continuation_for_table(parsed.structure, table)
259
+ eligible = [row for row in table.rows if row.role in {"data", "total"}]
260
+ grouped: list[tuple[list[str], list[LogicalRow]]] = []
261
+ for row in eligible:
262
+ if not grouped or grouped[-1][0] != row.section_path:
263
+ grouped.append((list(row.section_path), []))
264
+ grouped[-1][1].append(row)
265
+
266
+ chunks: list[Chunk] = []
267
+ for section_path, rows in grouped:
268
+ pending = []
269
+ for row in rows:
270
+ candidate = [*pending, row]
271
+ content = _render_logical_chunk(table.title, section_path, columns, candidate)
272
+ if pending and self.length_function(content) > self.max_chunk_size:
273
+ chunks.append(
274
+ _logical_chunk(
275
+ parsed,
276
+ table,
277
+ continuation,
278
+ sheet_name,
279
+ sheet_ordinal,
280
+ section_path,
281
+ columns,
282
+ pending,
283
+ chunk_index_offset + len(chunks),
284
+ self.max_chunk_size,
285
+ self.length_function,
286
+ self.policy,
287
+ )
288
+ )
289
+ pending = []
290
+ pending.append(row)
291
+ if pending:
292
+ chunks.append(
293
+ _logical_chunk(
294
+ parsed,
295
+ table,
296
+ continuation,
297
+ sheet_name,
298
+ sheet_ordinal,
299
+ section_path,
300
+ columns,
301
+ pending,
302
+ chunk_index_offset + len(chunks),
303
+ self.max_chunk_size,
304
+ self.length_function,
305
+ self.policy,
306
+ )
307
+ )
308
+ return chunks
309
+
310
+ def _chunk_form(
311
+ self,
312
+ parsed: ParsedDocumentResult,
313
+ sheet_name: str,
314
+ sheet_ordinal: int,
315
+ form: FormBlock,
316
+ chunk_index_offset: int,
317
+ ) -> list[Chunk]:
318
+ chunks: list[Chunk] = []
319
+ pending: list[FormField] = []
320
+
321
+ def emit(*, oversized: bool = False) -> None:
322
+ if not pending:
323
+ return
324
+ chunks.append(
325
+ _form_chunk(
326
+ parsed,
327
+ form,
328
+ sheet_name,
329
+ sheet_ordinal,
330
+ pending,
331
+ [],
332
+ chunk_index_offset + len(chunks),
333
+ oversized,
334
+ analysis_records=self.policy.analysis_records,
335
+ )
336
+ )
337
+ pending.clear()
338
+
339
+ for field in form.fields:
340
+ candidate = [*pending, field]
341
+ content = _render_form_chunk(form.title, candidate, [])
342
+ if pending and self.length_function(content) > self.max_chunk_size:
343
+ emit()
344
+ candidate = [field]
345
+ content = _render_form_chunk(form.title, candidate, [])
346
+ pending.append(field)
347
+ if self.length_function(content) > self.max_chunk_size:
348
+ emit(oversized=True)
349
+ emit()
350
+ if form.free_text:
351
+ content = _render_form_chunk(form.title, [], form.free_text)
352
+ chunks.append(
353
+ _form_chunk(
354
+ parsed,
355
+ form,
356
+ sheet_name,
357
+ sheet_ordinal,
358
+ [],
359
+ form.free_text,
360
+ chunk_index_offset + len(chunks),
361
+ self.length_function(content) > self.max_chunk_size,
362
+ analysis_records=self.policy.analysis_records,
363
+ )
364
+ )
365
+ return chunks
366
+
367
+ def _chunk_matrix(
368
+ self,
369
+ parsed: ParsedDocumentResult,
370
+ sheet_name: str,
371
+ sheet_ordinal: int,
372
+ matrix: MatrixBlock,
373
+ chunk_index_offset: int,
374
+ ) -> list[Chunk]:
375
+ chunks: list[Chunk] = []
376
+ pending: list[tuple[MatrixHeader, list[str], list]] = []
377
+
378
+ def emit(*, oversized: bool = False) -> None:
379
+ if not pending:
380
+ return
381
+ chunks.append(
382
+ _matrix_chunk(
383
+ parsed,
384
+ matrix,
385
+ sheet_name,
386
+ sheet_ordinal,
387
+ pending,
388
+ chunk_index_offset + len(chunks),
389
+ oversized,
390
+ analysis_records=self.policy.analysis_records,
391
+ )
392
+ )
393
+ pending.clear()
394
+
395
+ rows = zip(
396
+ matrix.row_headers,
397
+ matrix.values,
398
+ matrix.value_source_refs,
399
+ strict=True,
400
+ )
401
+ for header, values, refs in rows:
402
+ candidate = [*pending, (header, values, refs)]
403
+ content = _render_matrix_chunk(matrix, candidate)
404
+ if pending and self.length_function(content) > self.max_chunk_size:
405
+ emit()
406
+ candidate = [(header, values, refs)]
407
+ content = _render_matrix_chunk(matrix, candidate)
408
+ pending.append((header, values, refs))
409
+ if self.length_function(content) > self.max_chunk_size:
410
+ emit(oversized=True)
411
+ emit()
412
+ return chunks
413
+
414
+ def _chunk_text(
415
+ self,
416
+ parsed: ParsedDocumentResult,
417
+ sheet_name: str,
418
+ sheet_ordinal: int,
419
+ text: TextBlock,
420
+ chunk_index_offset: int,
421
+ ) -> list[Chunk]:
422
+ chunks: list[Chunk] = []
423
+ pending: list[TextLine] = []
424
+
425
+ def emit(*, oversized: bool = False) -> None:
426
+ if not pending:
427
+ return
428
+ chunks.append(
429
+ _text_chunk(
430
+ parsed,
431
+ text,
432
+ sheet_name,
433
+ sheet_ordinal,
434
+ pending,
435
+ chunk_index_offset + len(chunks),
436
+ oversized,
437
+ analysis_records=self.policy.analysis_records,
438
+ )
439
+ )
440
+ pending.clear()
441
+
442
+ for line in text.lines:
443
+ candidate = [*pending, line]
444
+ content = "\n".join(item.text for item in candidate)
445
+ if pending and self.length_function(content) > self.max_chunk_size:
446
+ emit()
447
+ candidate = [line]
448
+ content = line.text
449
+ pending.append(line)
450
+ if self.length_function(content) > self.max_chunk_size:
451
+ emit(oversized=True)
452
+ emit()
453
+ return chunks
454
+
455
+ def _pack_table(
456
+ self,
457
+ *,
458
+ parsed: ParsedDocumentResult,
459
+ sheet_name: str,
460
+ sheet_ordinal: int,
461
+ columns: list[str],
462
+ rows: list[list[str]],
463
+ row_numbers: list[int],
464
+ confidence: float,
465
+ chunk_index_offset: int,
466
+ ) -> list[Chunk]:
467
+ packed: list[Chunk] = []
468
+ pending_rows: list[list[str]] = []
469
+ pending_numbers: list[int] = []
470
+
471
+ def emit(*, oversized: bool = False) -> None:
472
+ if not pending_rows:
473
+ return
474
+ source_range = _source_range(sheet_name, columns, pending_numbers)
475
+ content = _render_chunk(sheet_name, source_range, columns, pending_rows)
476
+ payload = {
477
+ "columns": list(columns),
478
+ "rows": [list(row) for row in pending_rows],
479
+ }
480
+ if self.policy.analysis_records:
481
+ payload["column_schema"] = [
482
+ {
483
+ "column_index": index,
484
+ "coordinate": column,
485
+ "header_path": [],
486
+ }
487
+ for index, column in enumerate(columns)
488
+ ]
489
+ payload["records"] = [
490
+ {
491
+ "row_number": row_number,
492
+ "role": "raw",
493
+ "section_path": [],
494
+ "values": list(row),
495
+ "source_refs": [_source_range(sheet_name, columns, [row_number])],
496
+ }
497
+ for row, row_number in zip(pending_rows, pending_numbers, strict=True)
498
+ ]
499
+ metadata = document_metadata(parsed)
500
+ metadata.update(
501
+ {
502
+ "chunk_type": "raw_grid_rows",
503
+ "chunk_index": chunk_index_offset + len(packed),
504
+ "sheet_name": sheet_name,
505
+ "sheet_ordinal": sheet_ordinal,
506
+ "source_ranges": [source_range],
507
+ "row_numbers": list(pending_numbers),
508
+ "confidence": confidence,
509
+ "warnings": list(parsed.diagnostics.warnings)
510
+ if parsed.diagnostics is not None
511
+ else [],
512
+ }
513
+ )
514
+ if oversized:
515
+ metadata["oversized"] = True
516
+ packed.append(
517
+ Chunk(
518
+ content=content,
519
+ metadata=metadata,
520
+ structured_payload=payload,
521
+ )
522
+ )
523
+ pending_rows.clear()
524
+ pending_numbers.clear()
525
+
526
+ for row, row_number in zip(rows, row_numbers, strict=True):
527
+ candidate_rows = [*pending_rows, row]
528
+ candidate_numbers = [*pending_numbers, row_number]
529
+ candidate_range = _source_range(sheet_name, columns, candidate_numbers)
530
+ candidate = _render_chunk(sheet_name, candidate_range, columns, candidate_rows)
531
+ if pending_rows and self.length_function(candidate) > self.max_chunk_size:
532
+ emit()
533
+ candidate_rows = [row]
534
+ candidate_numbers = [row_number]
535
+ candidate_range = _source_range(sheet_name, columns, candidate_numbers)
536
+ candidate = _render_chunk(sheet_name, candidate_range, columns, candidate_rows)
537
+
538
+ pending_rows.append(row)
539
+ pending_numbers.append(row_number)
540
+ if self.length_function(candidate) > self.max_chunk_size:
541
+ emit(oversized=True)
542
+
543
+ emit()
544
+ return packed
545
+
546
+
547
+ def _row_numbers(source_range: str | None, count: int) -> list[int]:
548
+ if not source_range:
549
+ return list(range(1, count + 1))
550
+ _, min_row, _, _ = range_boundaries(source_range)
551
+ return list(range(min_row, min_row + count))
552
+
553
+
554
+ def _row_numbers_from_source_ranges(source_ranges: list[str]) -> set[int]:
555
+ row_numbers = set()
556
+ for source_range in source_ranges:
557
+ _, cell_range = source_range.rsplit("!", 1)
558
+ _, min_row, _, max_row = range_boundaries(cell_range)
559
+ row_numbers.update(range(min_row, max_row + 1))
560
+ return row_numbers
561
+
562
+
563
+ def _source_range_is_valid(snapshot, source_ref: str) -> None:
564
+ if snapshot is None:
565
+ raise ValueError("WorkbookIR snapshot is required for source-range validation")
566
+ sheet_name, cell_range = source_ref.rsplit("!", 1)
567
+ sheet = next((item for item in snapshot.sheets if item.name == sheet_name), None)
568
+ if sheet is None:
569
+ raise ValueError(f"Workbook source range references unknown sheet: {sheet_name}")
570
+ if sheet.used_range is None:
571
+ raise ValueError(f"Workbook sheet used_range is required: {sheet_name}")
572
+ try:
573
+ min_col, min_row, max_col, max_row = range_boundaries(cell_range)
574
+ used_min_col, used_min_row, used_max_col, used_max_row = range_boundaries(sheet.used_range)
575
+ except ValueError as exc:
576
+ raise ValueError(f"Workbook source range is invalid: {source_ref}") from exc
577
+ if not (
578
+ used_min_col <= min_col <= max_col <= used_max_col
579
+ and used_min_row <= min_row <= max_row <= used_max_row
580
+ ):
581
+ raise ValueError(f"Workbook source range is outside sheet used_range: {source_ref}")
582
+
583
+
584
+ def _source_range(sheet_name: str, columns: list[str], row_numbers: list[int]) -> str:
585
+ first_row = min(row_numbers)
586
+ last_row = max(row_numbers)
587
+ return f"{sheet_name}!{columns[0]}{first_row}:{columns[-1]}{last_row}"
588
+
589
+
590
+ def _render_chunk(
591
+ sheet_name: str,
592
+ source_range: str,
593
+ columns: list[str],
594
+ rows: list[list[str]],
595
+ ) -> str:
596
+ heading = f"## Sheet: {sheet_name}"
597
+ source_comment = f"<!-- source_range: {source_range} -->"
598
+ header = _markdown_row(columns)
599
+ separator = "| " + " | ".join("---" for _ in columns) + " |"
600
+ body = [_markdown_row(row) for row in rows]
601
+ return "\n\n".join((heading, source_comment, "\n".join((header, separator, *body))))
602
+
603
+
604
+ def _markdown_row(row: list[str]) -> str:
605
+ escaped = [
606
+ str(value)
607
+ .replace("\r\n", "\n")
608
+ .replace("\r", "\n")
609
+ .replace("|", r"\|")
610
+ .replace("\n", "<br>")
611
+ for value in row
612
+ ]
613
+ return "| " + " | ".join(escaped) + " |"
614
+
615
+
616
+ def _render_form_chunk(
617
+ title: str,
618
+ fields: list[FormField],
619
+ lines: list[TextLine],
620
+ ) -> str:
621
+ parts = [f"### Form: {title}"] if title else []
622
+ if fields:
623
+ parts.append(
624
+ "\n".join(
625
+ [
626
+ _markdown_row(["Field", "Value"]),
627
+ "| --- | --- |",
628
+ *[_markdown_row([field.label, field.value]) for field in fields],
629
+ ]
630
+ )
631
+ )
632
+ parts.extend(line.text for line in lines)
633
+ return "\n\n".join(parts)
634
+
635
+
636
+ def _form_chunk(
637
+ parsed: ParsedDocumentResult,
638
+ form: FormBlock,
639
+ sheet_name: str,
640
+ sheet_ordinal: int,
641
+ fields: list[FormField],
642
+ lines: list[TextLine],
643
+ chunk_index: int,
644
+ oversized: bool,
645
+ *,
646
+ analysis_records: bool,
647
+ ) -> Chunk:
648
+ metadata = document_metadata(parsed)
649
+ source_ranges = [
650
+ ref.key for field in fields for ref in [*field.label_source_refs, *field.value_source_refs]
651
+ ]
652
+ source_ranges.extend(ref.key for line in lines for ref in line.source_refs)
653
+ metadata.update(
654
+ {
655
+ "chunk_type": "form_fields",
656
+ "chunk_index": chunk_index,
657
+ "sheet_name": sheet_name,
658
+ "sheet_ordinal": sheet_ordinal,
659
+ "form_id": form.form_id,
660
+ "field_ids": [field.field_id for field in fields],
661
+ "source_ranges": source_ranges,
662
+ "confidence": min([form.confidence, *[field.confidence for field in fields]]),
663
+ "warnings": list(parsed.diagnostics.warnings) if parsed.diagnostics is not None else [],
664
+ }
665
+ )
666
+ if oversized:
667
+ metadata["oversized"] = True
668
+ payload = {
669
+ "fields": [[field.label, field.value] for field in fields],
670
+ "free_text": [line.text for line in lines],
671
+ }
672
+ if analysis_records:
673
+ payload["records"] = [
674
+ {
675
+ "record_type": "field",
676
+ "field_id": field.field_id,
677
+ "label": field.label,
678
+ "value": field.value,
679
+ "label_source_refs": [ref.key for ref in field.label_source_refs],
680
+ "value_source_refs": [ref.key for ref in field.value_source_refs],
681
+ }
682
+ for field in fields
683
+ ]
684
+ payload["records"].extend(
685
+ {
686
+ "record_type": "text",
687
+ "text": line.text,
688
+ "source_refs": [ref.key for ref in line.source_refs],
689
+ }
690
+ for line in lines
691
+ )
692
+ return Chunk(
693
+ content=_render_form_chunk(form.title, fields, lines),
694
+ metadata=metadata,
695
+ structured_payload=payload,
696
+ )
697
+
698
+
699
+ def _render_matrix_chunk(matrix: MatrixBlock, rows: list[tuple]) -> str:
700
+ parts = [f"### Matrix: {matrix.title}"] if matrix.title else []
701
+ columns = ["", *[header.value for header in matrix.column_headers]]
702
+ table_lines = [
703
+ _markdown_row(columns),
704
+ "| " + " | ".join("---" for _ in columns) + " |",
705
+ *[_markdown_row([header.value, *values]) for header, values, _ in rows],
706
+ ]
707
+ parts.append("\n".join(table_lines))
708
+ return "\n\n".join(parts)
709
+
710
+
711
+ def _matrix_chunk(
712
+ parsed: ParsedDocumentResult,
713
+ matrix: MatrixBlock,
714
+ sheet_name: str,
715
+ sheet_ordinal: int,
716
+ rows: list[tuple],
717
+ chunk_index: int,
718
+ oversized: bool,
719
+ *,
720
+ analysis_records: bool,
721
+ ) -> Chunk:
722
+ metadata = document_metadata(parsed)
723
+ source_ranges = []
724
+ for header, _, refs in rows:
725
+ source_ranges.extend(ref.key for ref in header.source_refs)
726
+ source_ranges.extend(ref.key for ref in refs if ref is not None)
727
+ metadata.update(
728
+ {
729
+ "chunk_type": "matrix_rows",
730
+ "chunk_index": chunk_index,
731
+ "sheet_name": sheet_name,
732
+ "sheet_ordinal": sheet_ordinal,
733
+ "matrix_id": matrix.matrix_id,
734
+ "row_headers": [header.value for header, _, _ in rows],
735
+ "source_ranges": source_ranges,
736
+ "confidence": matrix.confidence,
737
+ "warnings": list(parsed.diagnostics.warnings) if parsed.diagnostics is not None else [],
738
+ }
739
+ )
740
+ if oversized:
741
+ metadata["oversized"] = True
742
+ payload = {
743
+ "column_headers": [header.value for header in matrix.column_headers],
744
+ "row_headers": [header.value for header, _, _ in rows],
745
+ "values": [list(values) for _, values, _ in rows],
746
+ }
747
+ if analysis_records:
748
+ payload["records"] = [
749
+ {
750
+ "row_header": header.value,
751
+ "row_header_source_refs": [ref.key for ref in header.source_refs],
752
+ "values": list(values),
753
+ "value_source_refs": [ref.key if ref is not None else None for ref in refs],
754
+ }
755
+ for header, values, refs in rows
756
+ ]
757
+ return Chunk(
758
+ content=_render_matrix_chunk(matrix, rows),
759
+ metadata=metadata,
760
+ structured_payload=payload,
761
+ )
762
+
763
+
764
+ def _text_chunk(
765
+ parsed: ParsedDocumentResult,
766
+ text: TextBlock,
767
+ sheet_name: str,
768
+ sheet_ordinal: int,
769
+ lines: list[TextLine],
770
+ chunk_index: int,
771
+ oversized: bool,
772
+ *,
773
+ analysis_records: bool,
774
+ ) -> Chunk:
775
+ metadata = document_metadata(parsed)
776
+ metadata.update(
777
+ {
778
+ "chunk_type": "text_block",
779
+ "chunk_index": chunk_index,
780
+ "sheet_name": sheet_name,
781
+ "sheet_ordinal": sheet_ordinal,
782
+ "text_id": text.text_id,
783
+ "source_ranges": [ref.key for line in lines for ref in line.source_refs],
784
+ "confidence": text.confidence,
785
+ "warnings": list(parsed.diagnostics.warnings) if parsed.diagnostics is not None else [],
786
+ }
787
+ )
788
+ if oversized:
789
+ metadata["oversized"] = True
790
+ payload = {"lines": [line.text for line in lines]}
791
+ if analysis_records:
792
+ payload["records"] = [
793
+ {"text": line.text, "source_refs": [ref.key for ref in line.source_refs]}
794
+ for line in lines
795
+ ]
796
+ return Chunk(
797
+ content="\n".join(line.text for line in lines),
798
+ metadata=metadata,
799
+ structured_payload=payload,
800
+ )
801
+
802
+
803
+ def _raw_block_grid(
804
+ workbook_ir: WorkbookIR,
805
+ sheet_ordinal: int,
806
+ source_range: str,
807
+ ) -> tuple[list[str], list[list[str]], list[int]]:
808
+ if workbook_ir.snapshot is None:
809
+ raise ValueError("WorkbookIR snapshot is required for raw block chunks")
810
+ sheet = workbook_ir.snapshot.sheets[sheet_ordinal - 1]
811
+ min_col, min_row, max_col, max_row = range_boundaries(source_range)
812
+ columns = [get_column_letter(column) for column in range(min_col, max_col + 1)]
813
+ row_numbers = list(range(min_row, max_row + 1))
814
+ rows = []
815
+ for row_number in row_numbers:
816
+ row = []
817
+ for column in range(min_col, max_col + 1):
818
+ coordinate = f"{get_column_letter(column)}{row_number}"
819
+ cell = sheet.cells.get(coordinate)
820
+ row.append("" if cell is None or cell.merge_anchor is not None else cell.display_value)
821
+ rows.append(row)
822
+ return columns, rows, row_numbers
823
+
824
+
825
+ def _render_logical_chunk(
826
+ title: str,
827
+ section_path: list[str],
828
+ columns: list[str],
829
+ rows: list[LogicalRow],
830
+ ) -> str:
831
+ headings = [f"### Table: {title}"] if title else []
832
+ if section_path:
833
+ headings.append(f"#### Section: {' / '.join(section_path)}")
834
+ table_lines = [
835
+ _markdown_row(columns),
836
+ "| " + " | ".join("---" for _ in columns) + " |",
837
+ *[_markdown_row(row.values) for row in rows],
838
+ ]
839
+ return "\n\n".join([*headings, "\n".join(table_lines)])
840
+
841
+
842
+ def _logical_chunk(
843
+ parsed: ParsedDocumentResult,
844
+ table: LogicalTable,
845
+ continuation: TableContinuation | None,
846
+ sheet_name: str,
847
+ sheet_ordinal: int,
848
+ section_path: list[str],
849
+ columns: list[str],
850
+ rows: list[LogicalRow],
851
+ chunk_index: int,
852
+ max_chunk_size: int,
853
+ length_function: Callable[[str], int],
854
+ policy: WorkbookChunkPolicy,
855
+ ) -> Chunk:
856
+ content = _render_logical_chunk(table.title, section_path, columns, rows)
857
+ metadata = document_metadata(parsed)
858
+ metadata.update(
859
+ {
860
+ "chunk_type": "table_rows",
861
+ "chunk_index": chunk_index,
862
+ "sheet_name": sheet_name,
863
+ "sheet_ordinal": sheet_ordinal,
864
+ "table_id": table.table_id,
865
+ "section_path": list(section_path),
866
+ "header_paths": [list(column.path) for column in table.columns],
867
+ "row_ids": [row.row_id for row in rows],
868
+ "row_numbers": [row.metadata["row_number"] for row in rows],
869
+ "source_ranges": [row.source_ref.key for row in rows],
870
+ "fragment_ranges": _fragment_ranges_for_rows(table, rows),
871
+ "confidence": min([table.confidence, *[row.confidence for row in rows]]),
872
+ "warnings": list(parsed.diagnostics.warnings) if parsed.diagnostics is not None else [],
873
+ }
874
+ )
875
+ if continuation is not None:
876
+ metadata.update(
877
+ {
878
+ "continuation_id": continuation.continuation_id,
879
+ "continuation_role": table.continuation_role,
880
+ "continuation_member_table_ids": list(continuation.member_table_ids),
881
+ "continuation_source_ranges": [ref.key for ref in continuation.source_refs],
882
+ }
883
+ )
884
+ if length_function(content) > max_chunk_size:
885
+ metadata["oversized"] = True
886
+ payload = {
887
+ "columns": columns,
888
+ "rows": [list(row.values) for row in rows],
889
+ "roles": [row.role for row in rows],
890
+ }
891
+ if policy.analysis_records:
892
+ payload["column_schema"] = [
893
+ {
894
+ "column_index": index,
895
+ "coordinate": column.coordinate,
896
+ "header_path": list(column.path),
897
+ }
898
+ for index, column in enumerate(table.columns)
899
+ ]
900
+ payload["records"] = [
901
+ {
902
+ "row_id": row.row_id,
903
+ "row_number": int(row.metadata["row_number"]),
904
+ "role": row.role,
905
+ "section_path": list(row.section_path),
906
+ "values": list(row.values),
907
+ "source_refs": [row.source_ref.key],
908
+ }
909
+ for row in rows
910
+ ]
911
+ return Chunk(
912
+ content=content,
913
+ metadata=metadata,
914
+ structured_payload=payload,
915
+ )
916
+
917
+
918
+ def _continuation_for_table(
919
+ workbook_ir: WorkbookIR,
920
+ table: LogicalTable,
921
+ ) -> TableContinuation | None:
922
+ if table.continuation_id is None:
923
+ return None
924
+ return next(
925
+ (
926
+ group
927
+ for group in workbook_ir.table_continuations
928
+ if group.continuation_id == table.continuation_id
929
+ and table.table_id in group.member_table_ids
930
+ ),
931
+ None,
932
+ )
933
+
934
+
935
+ def _fragment_ranges_for_rows(table: LogicalTable, rows: list[LogicalRow]) -> list[str]:
936
+ row_numbers = {int(row.metadata["row_number"]) for row in rows}
937
+ ranges = []
938
+ for fragment in table.fragments:
939
+ _, min_row, _, max_row = range_boundaries(fragment.source_ref.range)
940
+ if any(min_row <= row_number <= max_row for row_number in row_numbers):
941
+ ranges.append(fragment.source_ref.key)
942
+ return ranges