dot-parser 2.1.0__tar.gz → 2.1.2__tar.gz

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 (75) hide show
  1. {dot_parser-2.1.0 → dot_parser-2.1.2}/CHANGELOG.md +20 -1
  2. {dot_parser-2.1.0 → dot_parser-2.1.2}/PKG-INFO +2 -2
  3. {dot_parser-2.1.0 → dot_parser-2.1.2}/README.md +1 -1
  4. {dot_parser-2.1.0 → dot_parser-2.1.2}/benchmark/README.md +1 -1
  5. {dot_parser-2.1.0 → dot_parser-2.1.2}/docs/IMAGE_EXTRACTION.md +2 -2
  6. {dot_parser-2.1.0 → dot_parser-2.1.2}/pyproject.toml +1 -1
  7. {dot_parser-2.1.0 → dot_parser-2.1.2}/src/dot_parser/backends/mistral.py +1 -1
  8. {dot_parser-2.1.0 → dot_parser-2.1.2}/src/dot_parser/chunking.py +35 -25
  9. {dot_parser-2.1.0 → dot_parser-2.1.2}/tests/test_chunking.py +25 -0
  10. {dot_parser-2.1.0 → dot_parser-2.1.2}/tests/test_images.py +3 -3
  11. {dot_parser-2.1.0 → dot_parser-2.1.2}/tests/test_mistral_batch_logging.py +1 -1
  12. {dot_parser-2.1.0 → dot_parser-2.1.2}/uv.lock +1 -1
  13. {dot_parser-2.1.0 → dot_parser-2.1.2}/.gitignore +0 -0
  14. {dot_parser-2.1.0 → dot_parser-2.1.2}/.gitlab-ci.yml +0 -0
  15. {dot_parser-2.1.0 → dot_parser-2.1.2}/.pre-commit-config.yaml +0 -0
  16. {dot_parser-2.1.0 → dot_parser-2.1.2}/.python-version +0 -0
  17. {dot_parser-2.1.0 → dot_parser-2.1.2}/AUTHORS.md +0 -0
  18. {dot_parser-2.1.0 → dot_parser-2.1.2}/CONTRIBUTING.md +0 -0
  19. {dot_parser-2.1.0 → dot_parser-2.1.2}/DCO +0 -0
  20. {dot_parser-2.1.0 → dot_parser-2.1.2}/LICENSE.md +0 -0
  21. {dot_parser-2.1.0 → dot_parser-2.1.2}/benchmark/input/broken_encoding.pdf +0 -0
  22. {dot_parser-2.1.0 → dot_parser-2.1.2}/benchmark/input/image_only.pdf +0 -0
  23. {dot_parser-2.1.0 → dot_parser-2.1.2}/benchmark/input/native_equations.pdf +0 -0
  24. {dot_parser-2.1.0 → dot_parser-2.1.2}/benchmark/input/native_simple.pdf +0 -0
  25. {dot_parser-2.1.0 → dot_parser-2.1.2}/benchmark/input/native_tables.pdf +0 -0
  26. {dot_parser-2.1.0 → dot_parser-2.1.2}/benchmark/output/md/out_docling_broken_encoding.md +0 -0
  27. {dot_parser-2.1.0 → dot_parser-2.1.2}/benchmark/output/md/out_docling_image_only.md +0 -0
  28. {dot_parser-2.1.0 → dot_parser-2.1.2}/benchmark/output/md/out_docling_native_equations.md +0 -0
  29. {dot_parser-2.1.0 → dot_parser-2.1.2}/benchmark/output/md/out_docling_native_simple.md +0 -0
  30. {dot_parser-2.1.0 → dot_parser-2.1.2}/benchmark/output/md/out_docling_native_tables.md +0 -0
  31. {dot_parser-2.1.0 → dot_parser-2.1.2}/benchmark/output/md/out_llama_broken_encoding.md +0 -0
  32. {dot_parser-2.1.0 → dot_parser-2.1.2}/benchmark/output/md/out_llama_image_only.md +0 -0
  33. {dot_parser-2.1.0 → dot_parser-2.1.2}/benchmark/output/md/out_llama_native_equations.md +0 -0
  34. {dot_parser-2.1.0 → dot_parser-2.1.2}/benchmark/output/md/out_llama_native_simple.md +0 -0
  35. {dot_parser-2.1.0 → dot_parser-2.1.2}/benchmark/output/md/out_llama_native_tables.md +0 -0
  36. {dot_parser-2.1.0 → dot_parser-2.1.2}/benchmark/output/md/out_mistral_batch_broken_encoding.md +0 -0
  37. {dot_parser-2.1.0 → dot_parser-2.1.2}/benchmark/output/md/out_mistral_batch_image_only.md +0 -0
  38. {dot_parser-2.1.0 → dot_parser-2.1.2}/benchmark/output/md/out_mistral_batch_native_equations.md +0 -0
  39. {dot_parser-2.1.0 → dot_parser-2.1.2}/benchmark/output/md/out_mistral_batch_native_simple.md +0 -0
  40. {dot_parser-2.1.0 → dot_parser-2.1.2}/benchmark/output/md/out_mistral_batch_native_tables.md +0 -0
  41. {dot_parser-2.1.0 → dot_parser-2.1.2}/benchmark/output/md/out_pymu_broken_encoding.md +0 -0
  42. {dot_parser-2.1.0 → dot_parser-2.1.2}/benchmark/output/md/out_pymu_image_only.md +0 -0
  43. {dot_parser-2.1.0 → dot_parser-2.1.2}/benchmark/output/md/out_pymu_native_equations.md +0 -0
  44. {dot_parser-2.1.0 → dot_parser-2.1.2}/benchmark/output/md/out_pymu_native_simple.md +0 -0
  45. {dot_parser-2.1.0 → dot_parser-2.1.2}/benchmark/output/md/out_pymu_native_tables.md +0 -0
  46. {dot_parser-2.1.0 → dot_parser-2.1.2}/benchmark/output/md/out_pymu_ocr_broken_encoding.md +0 -0
  47. {dot_parser-2.1.0 → dot_parser-2.1.2}/benchmark/output/md/out_pymu_ocr_image_only.md +0 -0
  48. {dot_parser-2.1.0 → dot_parser-2.1.2}/benchmark/output/md/out_pymu_ocr_native_equations.md +0 -0
  49. {dot_parser-2.1.0 → dot_parser-2.1.2}/benchmark/output/md/out_pymu_ocr_native_simple.md +0 -0
  50. {dot_parser-2.1.0 → dot_parser-2.1.2}/benchmark/output/md/out_pymu_ocr_native_tables.md +0 -0
  51. {dot_parser-2.1.0 → dot_parser-2.1.2}/benchmark/output/results.md +0 -0
  52. {dot_parser-2.1.0 → dot_parser-2.1.2}/benchmark/run.py +0 -0
  53. {dot_parser-2.1.0 → dot_parser-2.1.2}/docs/DESIGN.md +0 -0
  54. {dot_parser-2.1.0 → dot_parser-2.1.2}/docs/DEVELOPMENT.md +0 -0
  55. {dot_parser-2.1.0 → dot_parser-2.1.2}/docs/PUBLISHING.md +0 -0
  56. {dot_parser-2.1.0 → dot_parser-2.1.2}/docs/VERSIONING.md +0 -0
  57. {dot_parser-2.1.0 → dot_parser-2.1.2}/src/dot_parser/__init__.py +0 -0
  58. {dot_parser-2.1.0 → dot_parser-2.1.2}/src/dot_parser/backends/__init__.py +0 -0
  59. {dot_parser-2.1.0 → dot_parser-2.1.2}/src/dot_parser/backends/_base.py +0 -0
  60. {dot_parser-2.1.0 → dot_parser-2.1.2}/src/dot_parser/backends/docling.py +0 -0
  61. {dot_parser-2.1.0 → dot_parser-2.1.2}/src/dot_parser/backends/llama.py +0 -0
  62. {dot_parser-2.1.0 → dot_parser-2.1.2}/src/dot_parser/backends/pymu.py +0 -0
  63. {dot_parser-2.1.0 → dot_parser-2.1.2}/src/dot_parser/docx_images.py +0 -0
  64. {dot_parser-2.1.0 → dot_parser-2.1.2}/src/dot_parser/docx_markdown.py +0 -0
  65. {dot_parser-2.1.0 → dot_parser-2.1.2}/src/dot_parser/image_utils.py +0 -0
  66. {dot_parser-2.1.0 → dot_parser-2.1.2}/src/dot_parser/images.py +0 -0
  67. {dot_parser-2.1.0 → dot_parser-2.1.2}/src/dot_parser/markdown_utils.py +0 -0
  68. {dot_parser-2.1.0 → dot_parser-2.1.2}/src/dot_parser/models.py +0 -0
  69. {dot_parser-2.1.0 → dot_parser-2.1.2}/src/dot_parser/parsers.py +0 -0
  70. {dot_parser-2.1.0 → dot_parser-2.1.2}/src/dot_parser/pricing.py +0 -0
  71. {dot_parser-2.1.0 → dot_parser-2.1.2}/src/dot_parser/tokens.py +0 -0
  72. {dot_parser-2.1.0 → dot_parser-2.1.2}/src/dot_parser/vlms.py +0 -0
  73. {dot_parser-2.1.0 → dot_parser-2.1.2}/tests/test_docx_markdown.py +0 -0
  74. {dot_parser-2.1.0 → dot_parser-2.1.2}/tests/test_parsers.py +0 -0
  75. {dot_parser-2.1.0 → dot_parser-2.1.2}/tests/test_tokens.py +0 -0
@@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.1.2] - 2026-10-27
11
+
12
+ ### Fixed
13
+
14
+ - `chunk()` keeps every chunk within `max_tokens`. A paragraph larger than
15
+ the limit on its own (a long table, text without blank lines) is now split
16
+ instead of kept whole, and an undersized chunk is absorbed into its neighbour
17
+ only when the result still fits: a long run of heading-only sections (a table
18
+ of contents, an index) no longer piles up into a single chunk.
19
+
20
+ ## [2.1.1] - 2026-09-30
21
+
22
+ ### Changed
23
+
24
+ - `Mistral` now defaults to `mistral-ocr-4-1`; `mistral-ocr-4-0` retires on
25
+ 2026-09-30. Pricing is unchanged.
26
+
10
27
  ## [2.1.0] - 2026-09-30
11
28
 
12
29
  ### Added
@@ -83,6 +100,8 @@ this release.
83
100
  - `parse_with_images(backend=...)` accepts DOCX- and PPTX-capable backends in
84
101
  its type signature, matching what the function already supported at runtime.
85
102
 
86
- [Unreleased]: https://gitlab.com/deepika6190303/deepika-open-toolbox/dot-parser/-/compare/v2.1.0...main
103
+ [Unreleased]: https://gitlab.com/deepika6190303/deepika-open-toolbox/dot-parser/-/compare/v2.1.2...main
104
+ [2.1.2]: https://gitlab.com/deepika6190303/deepika-open-toolbox/dot-parser/-/compare/v2.1.1...v2.1.2
105
+ [2.1.1]: https://gitlab.com/deepika6190303/deepika-open-toolbox/dot-parser/-/compare/v2.1.0...v2.1.1
87
106
  [2.1.0]: https://gitlab.com/deepika6190303/deepika-open-toolbox/dot-parser/-/compare/v2.0.0...v2.1.0
88
107
  [2.0.0]: https://gitlab.com/deepika6190303/deepika-open-toolbox/dot-parser/-/tags/v2.0.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: dot-parser
3
- Version: 2.1.0
3
+ Version: 2.1.2
4
4
  Summary: Document-to-markdown parser and chunker for RAG pipelines
5
5
  Project-URL: Homepage, https://gitlab.com/deepika6190303/deepika-open-toolbox/dot-parser
6
6
  Project-URL: Repository, https://gitlab.com/deepika6190303/deepika-open-toolbox/dot-parser
@@ -154,7 +154,7 @@ described either by a VLM or by OCR annotations from the same call. See
154
154
 
155
155
  - **`Pymu(ocr_fallback=True)`** -- pymupdf4llm, default. Fast and CPU-only. The built-in OCR fallback kicks in when an OCR engine (tesseract, rapidocr, paddleocr) is available. Set `ocr_fallback=False` to force the no-OCR code path (matches a runtime image without OCR shipped).
156
156
  - **`Docling(force_full_page_ocr=True)`** -- IBM Docling, layout-aware ML pipeline + Tesseract OCR. Robust on image-only and broken-encoding PDFs. Install with `pip install dot-parser[docling]`.
157
- - **`Mistral(api_key=None, model="mistral-ocr-4-0")`** -- Mistral OCR cloud API. Reads `MISTRAL_API_KEY` from env. Implements `parse_pdfs` via the Batch API. Install with `pip install dot-parser[mistral]`.
157
+ - **`Mistral(api_key=None, model="mistral-ocr-4-1")`** -- Mistral OCR cloud API. Reads `MISTRAL_API_KEY` from env. Implements `parse_pdfs` via the Batch API. Install with `pip install dot-parser[mistral]`.
158
158
  - **`Llama(api_key=None, tier="cost_effective")`** -- LlamaCloud parsing API. Reads `LLAMA_CLOUD_API_KEY` from env. Tiers: `fast`, `cost_effective`, `agentic`, `agentic_plus`. Install with `pip install dot-parser[llama]`.
159
159
 
160
160
  #### Mistral OCR enrichments
@@ -116,7 +116,7 @@ described either by a VLM or by OCR annotations from the same call. See
116
116
 
117
117
  - **`Pymu(ocr_fallback=True)`** -- pymupdf4llm, default. Fast and CPU-only. The built-in OCR fallback kicks in when an OCR engine (tesseract, rapidocr, paddleocr) is available. Set `ocr_fallback=False` to force the no-OCR code path (matches a runtime image without OCR shipped).
118
118
  - **`Docling(force_full_page_ocr=True)`** -- IBM Docling, layout-aware ML pipeline + Tesseract OCR. Robust on image-only and broken-encoding PDFs. Install with `pip install dot-parser[docling]`.
119
- - **`Mistral(api_key=None, model="mistral-ocr-4-0")`** -- Mistral OCR cloud API. Reads `MISTRAL_API_KEY` from env. Implements `parse_pdfs` via the Batch API. Install with `pip install dot-parser[mistral]`.
119
+ - **`Mistral(api_key=None, model="mistral-ocr-4-1")`** -- Mistral OCR cloud API. Reads `MISTRAL_API_KEY` from env. Implements `parse_pdfs` via the Batch API. Install with `pip install dot-parser[mistral]`.
120
120
  - **`Llama(api_key=None, tier="cost_effective")`** -- LlamaCloud parsing API. Reads `LLAMA_CLOUD_API_KEY` from env. Tiers: `fast`, `cost_effective`, `agentic`, `agentic_plus`. Install with `pip install dot-parser[llama]`.
121
121
 
122
122
  #### Mistral OCR enrichments
@@ -72,5 +72,5 @@ dropped equations, wrong table cells), so we keep it purely manual.
72
72
  | `pymu` | $0.00 | pymupdf4llm with OCR fallback **disabled** — reproduces le-lab production where no OCR engine is shipped in the runtime image |
73
73
  | `pymu_ocr` | $0.00 | pymupdf4llm with built-in OCR fallback **enabled** — uses tesseract (or rapidocr/paddleocr) automatically when text extraction looks suspect |
74
74
  | `docling` | $0.00 | layout-aware ML pipeline + Tesseract OCR with `force_full_page_ocr=True` |
75
- | `mistral` | $4.00 | `mistral-ocr-4-0` cloud API. With `--mistral-batch`: $2.00 per 1k pages (50% off) via the Mistral Batch API. |
75
+ | `mistral` | $4.00 | `mistral-ocr-4-1` cloud API. With `--mistral-batch`: $2.00 per 1k pages (50% off) via the Mistral Batch API. |
76
76
  | `llama` | $3.00 | LlamaCloud `cost_effective` tier |
@@ -30,7 +30,7 @@ parse_with_images("doc.docx", backend=Mistral())
30
30
 
31
31
  The `.docx` is uploaded to Mistral and `ocr.process` is called with `document_url`
32
32
  plus `bbox_annotation_format`: a single call returns markdown, images and
33
- annotations. Model: `mistral-ocr-latest`.
33
+ annotations. Model: `mistral-ocr-4-1`.
34
34
 
35
35
  - **+** One API call, no per-image round trip.
36
36
  - **+** Handles natively the formats markitdown cannot pass through (the document is rasterised, so SmartArt / EMF / SVG render correctly).
@@ -68,7 +68,7 @@ parse_with_images("doc.pdf") # backend=Mistral() implied
68
68
  parse_with_images("doc.pdf", backend=Mistral()) # explicit, identical
69
69
  ```
70
70
 
71
- Same `bbox_annotation_format` flow as DOCX path B. Model: `mistral-ocr-latest`.
71
+ Same `bbox_annotation_format` flow as DOCX path B. Model: `mistral-ocr-4-1`.
72
72
 
73
73
  - **+** One call returns markdown, images and annotations; robust on scanned or broken-encoding PDFs; exact image positions.
74
74
  - **−** Billed per page; fixed annotation schema (same caveat as DOCX path B).
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dot-parser"
3
- version = "2.1.0"
3
+ version = "2.1.2"
4
4
  description = "Document-to-markdown parser and chunker for RAG pipelines"
5
5
  readme = "README.md"
6
6
  authors = [{ name = "Kannon For Deep Tech", email = "louis.letarnec@deepika.ai" }]
@@ -217,7 +217,7 @@ class Mistral:
217
217
  self,
218
218
  *,
219
219
  api_key: str | None = None,
220
- model: str = "mistral-ocr-4-0",
220
+ model: str = "mistral-ocr-4-1",
221
221
  language: str | None = None,
222
222
  extract_headers_footers: bool = False,
223
223
  confidence_scores: Literal["page", "word"] | None = None,
@@ -3,6 +3,7 @@
3
3
 
4
4
  import re
5
5
  import statistics
6
+ from typing import cast
6
7
 
7
8
  import semchunk
8
9
 
@@ -63,17 +64,16 @@ def _split_oversized(section: dict, max_tokens: int) -> list[dict]:
63
64
  body = section["body"]
64
65
  path = section["section_path"]
65
66
 
66
- full_text = f"{heading}\n\n{body}" if heading else body
67
- if count_tokens(full_text) <= max_tokens:
67
+ prefix = f"{heading}\n\n" if heading else ""
68
+ if count_tokens(prefix + body) <= max_tokens:
68
69
  return [section]
69
70
 
70
71
  # Try paragraph splitting first
71
72
  paragraphs = re.split(r"\n\n+", body)
72
73
  if len(paragraphs) > 1:
73
- sub_chunks = _group_paragraphs(paragraphs, max_tokens, heading)
74
+ sub_chunks = _group_paragraphs(paragraphs, max_tokens, prefix)
74
75
  else:
75
- # Semantic splitting via semchunk
76
- sub_chunks = semchunk.chunk(body, chunk_size=max_tokens, token_counter=count_tokens)
76
+ sub_chunks = _split_text(body, max_tokens, prefix)
77
77
 
78
78
  results = []
79
79
  for i, text in enumerate(sub_chunks):
@@ -96,31 +96,37 @@ def _split_oversized(section: dict, max_tokens: int) -> list[dict]:
96
96
  return results
97
97
 
98
98
 
99
- def _group_paragraphs(paragraphs: list[str], max_tokens: int, heading: str | None) -> list[str]:
100
- """Group paragraphs into chunks that fit within max_tokens."""
99
+ def _split_text(text: str, max_tokens: int, prefix: str) -> list[str]:
100
+ """Split text semantically (semchunk) into pieces that fit within max_tokens
101
+ even with `prefix` (the heading the first piece carries) before them.
102
+ semchunk returns a bare list unless asked for offsets."""
103
+ pieces = semchunk.chunk(
104
+ text, chunk_size=max_tokens, token_counter=lambda t: count_tokens(prefix + t)
105
+ )
106
+ return cast("list[str]", pieces)
107
+
108
+
109
+ def _group_paragraphs(paragraphs: list[str], max_tokens: int, prefix: str) -> list[str]:
110
+ """Group paragraphs into chunks that fit within max_tokens, the first one
111
+ with `prefix` (its heading) before it. A paragraph too large on its own is
112
+ split semantically first."""
101
113
  groups: list[str] = []
102
114
  current: list[str] = []
103
- current_tokens = 0
104
115
 
105
- # Account for heading in first group
106
- heading_tokens = count_tokens(heading + "\n\n") if heading else 0
107
- first_group = True
116
+ def fits(texts: list[str]) -> bool:
117
+ lead = "" if groups else prefix
118
+ return count_tokens(lead + "\n\n".join(texts)) <= max_tokens
108
119
 
109
120
  for para in paragraphs:
110
121
  para = para.strip()
111
122
  if not para:
112
123
  continue
113
- para_tokens = count_tokens(para)
114
- overhead = heading_tokens if first_group and not current else 0
115
-
116
- if current and current_tokens + overhead + para_tokens > max_tokens:
117
- groups.append("\n\n".join(current))
118
- current = [para]
119
- current_tokens = para_tokens
120
- first_group = False
121
- else:
122
- current.append(para)
123
- current_tokens += para_tokens
124
+ pieces = [para] if fits([para]) else _split_text(para, max_tokens, prefix)
125
+ for piece in pieces:
126
+ if current and not fits([*current, piece]):
127
+ groups.append("\n\n".join(current))
128
+ current = []
129
+ current.append(piece)
124
130
 
125
131
  if current:
126
132
  groups.append("\n\n".join(current))
@@ -168,15 +174,19 @@ def _merge_small(sections: list[dict], max_tokens: int) -> list[dict]:
168
174
 
169
175
 
170
176
  def _absorb_undersized(sections: list[dict], max_tokens: int) -> list[dict]:
171
- """Absorb chunks smaller than 15% of max_tokens into neighbors."""
177
+ """Absorb chunks smaller than 15% of max_tokens into neighbors, when the
178
+ result still fits within max_tokens."""
172
179
  threshold = max_tokens * 0.15
173
180
  result: list[dict] = []
174
181
 
182
+ def fits(first: str, second: str) -> bool:
183
+ return count_tokens(first + "\n\n" + second) <= max_tokens
184
+
175
185
  for section in sections:
176
186
  text = _section_text(section)
177
187
  tokens = count_tokens(text)
178
188
 
179
- if tokens < threshold and result:
189
+ if tokens < threshold and result and fits(_section_text(result[-1]), text):
180
190
  # Append to previous
181
191
  prev = result[-1]
182
192
  prev_text = _section_text(prev)
@@ -192,7 +202,7 @@ def _absorb_undersized(sections: list[dict], max_tokens: int) -> list[dict]:
192
202
  # Check if first chunk is undersized and can be prepended to next
193
203
  if len(result) >= 2:
194
204
  first_text = _section_text(result[0])
195
- if count_tokens(first_text) < threshold:
205
+ if count_tokens(first_text) < threshold and fits(first_text, _section_text(result[1])):
196
206
  second = result[1]
197
207
  second_text = _section_text(second)
198
208
  result[1] = {
@@ -1,7 +1,10 @@
1
1
  # SPDX-FileCopyrightText: Kannon For Deep Tech
2
2
  # SPDX-License-Identifier: AGPL-3.0-or-later
3
3
 
4
+ import pytest
5
+
4
6
  from dot_parser import Chunk, chunk
7
+ from dot_parser.tokens import count_tokens
5
8
 
6
9
 
7
10
  class TestChunkBasic:
@@ -62,6 +65,28 @@ class TestSplitOversized:
62
65
  assert c.section_path == ["# Big"]
63
66
 
64
67
 
68
+ class TestMaxTokensBound:
69
+ @pytest.mark.parametrize("merge", [True, False])
70
+ def test_an_oversized_paragraph_among_others_is_split(self, merge):
71
+ md = "# Big\n\nIntro.\n\n" + "word " * 2000 + "\n\nOutro."
72
+ chunks = chunk(md, max_tokens=64, merge=merge)
73
+ assert all(count_tokens(c.content) <= 64 for c in chunks)
74
+ assert chunks[0].heading == "# Big"
75
+
76
+ @pytest.mark.parametrize("merge", [True, False])
77
+ def test_a_table_without_blank_lines_is_split(self, merge):
78
+ table = "\n".join(f"| row {i} | 0.{i:04d} | 12.5 |" for i in range(500))
79
+ md = f"# Results\n\nThe table below.\n\n{table}\n\nEnd."
80
+ chunks = chunk(md, max_tokens=64, merge=merge)
81
+ assert all(count_tokens(c.content) <= 64 for c in chunks)
82
+
83
+ def test_a_run_of_heading_only_sections_stays_within_the_bound(self):
84
+ md = "# Index\n\n" + "\n\n".join(f"## Entry {i}" for i in range(300))
85
+ chunks = chunk(md, max_tokens=64)
86
+ assert all(count_tokens(c.content) <= 64 for c in chunks)
87
+ assert "## Entry 299" in chunks[-1].content
88
+
89
+
65
90
  class TestMerge:
66
91
  def test_small_sections_merged(self):
67
92
  md = "# A\n\n## B\n\nShort.\n\n## C\n\nAlso short."
@@ -129,7 +129,7 @@ def _make_mistral_with_fake_client(pages):
129
129
  constructing the instance manually.
130
130
  """
131
131
  instance = Mistral.__new__(Mistral)
132
- instance._model = "mistral-ocr-4-0"
132
+ instance._model = "mistral-ocr-4-1"
133
133
  instance._language = None
134
134
 
135
135
  uploaded = SimpleNamespace(id="file-xyz")
@@ -261,7 +261,7 @@ class TestMistralParsePdfWithImages:
261
261
  ]
262
262
 
263
263
  instance = Mistral.__new__(Mistral)
264
- instance._model = "mistral-ocr-4-0"
264
+ instance._model = "mistral-ocr-4-1"
265
265
  instance._language = None
266
266
  uploaded = SimpleNamespace(id="file-xyz")
267
267
  signed = SimpleNamespace(url="https://signed.example/doc.pdf")
@@ -297,7 +297,7 @@ class TestMistralParsePdfWithImages:
297
297
  def test_retry_failure_propagates(self):
298
298
  """If the no-annotation retry also fails, the exception escapes."""
299
299
  instance = Mistral.__new__(Mistral)
300
- instance._model = "mistral-ocr-4-0"
300
+ instance._model = "mistral-ocr-4-1"
301
301
  instance._language = None
302
302
  ocr_calls: list[dict] = []
303
303
 
@@ -63,7 +63,7 @@ def _backend(job, downloads: dict[str, bytes]) -> Mistral:
63
63
  no ``mistralai`` import / real key is needed."""
64
64
  m = Mistral.__new__(Mistral)
65
65
  m._client = _FakeClient(job, downloads) # ty: ignore[invalid-assignment]
66
- m._model = "mistral-ocr-4-0"
66
+ m._model = "mistral-ocr-4-1"
67
67
  return m
68
68
 
69
69
 
@@ -654,7 +654,7 @@ standard = [
654
654
 
655
655
  [[package]]
656
656
  name = "dot-parser"
657
- version = "2.1.0"
657
+ version = "2.1.2"
658
658
  source = { editable = "." }
659
659
  dependencies = [
660
660
  { name = "beautifulsoup4" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes