Tune LiteParse OCR defaults

Add an adaptive OCR heuristic that samples PDF text density and disables LiteParse OCR for large text-rich PDFs before the OCR path reaches timeout territory. Keep LiteParse isolated in a subprocess regardless of stale user config, remove the subprocess toggle from the settings UI, and raise the default LiteParse worker count to 2 for a safer multi-chat speedup. Update Document Query docs and focused tests for the new heuristic, mandatory isolation, and worker default.

Alessandro committed May 30, 2026 at 19:02 UTC 9e4b2f1843b2a9ab60d15ea2548400f16194aa90
5 files changed +352 -34
plugins/_document_query/README.md
+6 -2
@@ -8,6 +8,7 @@ timeouts and thread-safe parsers.
8 - **Strategy-pattern parsers** - MIME-type routing to dedicated parser classes
9 - **Centralized fetching** - local and HTTP(S) resources are fetched once, size-checked, then passed to parsers
10 - **LiteParse first path** - fast local parsing for PDFs and supported document/image formats, with legacy fallbacks
11 +- **Adaptive OCR** - large text-rich PDFs skip OCR automatically to avoid pathological parse times
12 - **Bounded parser execution** - sync parsers are offloaded to asyncio.to_thread and globally capped across chats
13 - **Configurable timeouts** - per-document and gather-level timeouts
14 - **Expanded format support** - PDF, HTML, text, YAML, XML, TOML, JS, TS, images, and catch-all Unstructured
@@ -29,14 +30,17 @@ See default_config.yaml for all options. Key settings:
30 | chunk_overlap | 100 | Text splitter overlap |
31 | search_threshold | 0.5 | Similarity search threshold |
32 | liteparse_enabled | true | Prefer LiteParse before legacy parser fallbacks |
32 -| liteparse_num_workers | 1 | Max LiteParse OCR workers per parser job |
33 -| liteparse_subprocess | true | Run LiteParse in a child process so native crashes fall back safely |
33 +| liteparse_num_workers | 2 | Max LiteParse OCR workers per parser job |
34 +| liteparse_ocr_auto_disable_pages | 30 | Disable OCR for text-rich PDFs at or above this effective page count |
35 | thread_offload | true | Offload sync parsers to thread pool |
36
37 LiteParse is installed into the Agent Zero framework runtime from hooks.py during
38 plugin install/startup. If installation fails, the plugin logs the error and
39 continues with the legacy parser fallbacks.
40
41 +LiteParse always runs in a child process so native parser and OCR failures stay
42 +isolated from the Web UI process.
43 +
44 ## Parsers
45
46 | Parser | MIME Types | Backend |
plugins/_document_query/default_config.yaml
+5 -2
@@ -28,7 +28,10 @@ liteparse_target_pages:
28 liteparse_dpi: 150
29 liteparse_preserve_very_small_text: false
30 liteparse_output_format: text
31 -liteparse_num_workers: 1 # LiteParse defaults to CPU cores - 1; cap it for web runtime stability
32 -liteparse_subprocess: true # isolate LiteParse native runtime crashes from the Web UI process
31 +liteparse_num_workers: 2 # balanced default for OCR speed without overloading shared Web UI runtime
32 +liteparse_ocr_auto_disable: true # disable OCR automatically for large text-rich PDFs
33 +liteparse_ocr_auto_disable_pages: 30 # OCR-on runtime climbs sharply around this page count
34 +liteparse_ocr_auto_min_chars_per_page: 80
35 +liteparse_ocr_auto_sample_pages: 5
36 pdf_ocr_fallback: true # enable legacy Tesseract fallback after PyMuPDF
37 thread_offload: true # offload sync parsers to thread pool
plugins/_document_query/helpers/parsers/liteparse.py
+171 -7
@@ -6,16 +6,29 @@ import json
6 import os
7 import subprocess
8 import sys
9 +from dataclasses import dataclass
10 from pathlib import Path
11
12 from plugins._document_query.helpers.fetch import FetchedDocument
13 from .base import BaseParser
14
15
16 +DEFAULT_OCR_AUTO_DISABLE_PAGES = 30
17 +DEFAULT_OCR_AUTO_MIN_CHARS_PER_PAGE = 80
18 +DEFAULT_OCR_AUTO_SAMPLE_PAGES = 5
19 +
20 +
21 +@dataclass(frozen=True)
22 +class _PdfTextProfile:
23 + page_count: int
24 + sampled_pages: int
25 + text_chars: int
26 +
27 +
28 class LiteParseParser(BaseParser):
29 """Fast parser powered by run-llama/liteparse when available."""
30
18 - DEFAULT_NUM_WORKERS = 1
31 + DEFAULT_NUM_WORKERS = 2
32
33 mimetypes = [
34 "application/pdf",
@@ -35,9 +48,8 @@ class LiteParseParser(BaseParser):
48 return bool(config.get("liteparse_enabled", True))
49
50 def _parse_sync(self, document: FetchedDocument, config: dict) -> str:
38 - if config.get("liteparse_subprocess", True):
39 - return self._parse_subprocess(document, config)
40 - return self._parse_in_process(document, config)
51 + # Keep LiteParse native/OCR failures isolated from the Web UI process.
52 + return self._parse_subprocess(document, config)
53
54 def _parse_in_process(self, document: FetchedDocument, config: dict) -> str:
55 try:
@@ -58,7 +70,7 @@ class LiteParseParser(BaseParser):
70 with document.local_file() as file_path:
71 payload = {
72 "file_path": file_path,
61 - "kwargs": self._liteparse_kwargs(config),
73 + "kwargs": self._liteparse_kwargs(config, document, file_path),
74 }
75 env = os.environ.copy()
76 project_root = str(Path(__file__).resolve().parents[4])
@@ -104,9 +116,18 @@ class LiteParseParser(BaseParser):
116 raise ValueError("LiteParse returned no text")
117 return text
118
107 - def _liteparse_kwargs(self, config: dict) -> dict:
119 + def _liteparse_kwargs(
120 + self,
121 + config: dict,
122 + document: FetchedDocument | None = None,
123 + file_path: str | None = None,
124 + ) -> dict:
125 + ocr_enabled = bool(config.get("liteparse_ocr_enabled", True))
126 + if ocr_enabled and self._should_disable_ocr(config, document, file_path):
127 + ocr_enabled = False
128 +
129 kwargs = {
109 - "ocr_enabled": bool(config.get("liteparse_ocr_enabled", True)),
130 + "ocr_enabled": ocr_enabled,
131 "ocr_language": config.get("liteparse_ocr_language", "eng"),
132 "max_pages": int(config.get("liteparse_max_pages", 1000)),
133 "dpi": float(config.get("liteparse_dpi", 150)),
@@ -138,6 +159,149 @@ class LiteParseParser(BaseParser):
159
160 return kwargs
161
162 + def _should_disable_ocr(
163 + self,
164 + config: dict,
165 + document: FetchedDocument | None,
166 + file_path: str | None,
167 + ) -> bool:
168 + if not bool(config.get("liteparse_ocr_auto_disable", True)):
169 + return False
170 + if not document or document.mimetype != "application/pdf" or not file_path:
171 + return False
172 +
173 + profile = _pdf_text_profile(file_path, config)
174 + if not profile or profile.sampled_pages <= 0:
175 + return False
176 +
177 + effective_pages = _effective_page_budget(config, profile.page_count)
178 + auto_disable_pages = _positive_int(
179 + config.get("liteparse_ocr_auto_disable_pages"),
180 + DEFAULT_OCR_AUTO_DISABLE_PAGES,
181 + )
182 + if effective_pages < auto_disable_pages:
183 + return False
184 +
185 + min_chars_per_page = _positive_int(
186 + config.get("liteparse_ocr_auto_min_chars_per_page"),
187 + DEFAULT_OCR_AUTO_MIN_CHARS_PER_PAGE,
188 + )
189 + return (profile.text_chars / profile.sampled_pages) >= min_chars_per_page
190 +
191 +
192 +def _pdf_text_profile(file_path: str, config: dict) -> _PdfTextProfile | None:
193 + try:
194 + import fitz
195 + except Exception:
196 + return None
197 +
198 + try:
199 + with fitz.open(file_path) as doc:
200 + page_count = doc.page_count
201 + if page_count <= 0:
202 + return _PdfTextProfile(0, 0, 0)
203 + sample_indexes = _sample_page_indexes(
204 + page_count=page_count,
205 + sample_pages=_positive_int(
206 + config.get("liteparse_ocr_auto_sample_pages"),
207 + DEFAULT_OCR_AUTO_SAMPLE_PAGES,
208 + ),
209 + target_pages=config.get("liteparse_target_pages"),
210 + )
211 + text_chars = 0
212 + for page_index in sample_indexes:
213 + text = doc[page_index].get_text("text") or ""
214 + text_chars += len("".join(text.split()))
215 + return _PdfTextProfile(
216 + page_count=page_count,
217 + sampled_pages=len(sample_indexes),
218 + text_chars=text_chars,
219 + )
220 + except Exception:
221 + return None
222 +
223 +
224 +def _effective_page_budget(config: dict, page_count: int) -> int:
225 + target_pages = config.get("liteparse_target_pages")
226 + if target_pages not in (None, ""):
227 + parsed_target_count = _target_page_count(str(target_pages), page_count)
228 + if parsed_target_count:
229 + return parsed_target_count
230 +
231 + max_pages = _positive_int(config.get("liteparse_max_pages"), 1000)
232 + return min(max_pages, page_count)
233 +
234 +
235 +def _target_page_count(value: str, page_count: int) -> int:
236 + pages = _target_page_numbers(value, page_count)
237 + if pages is None:
238 + return 0
239 + return len(pages)
240 +
241 +
242 +def _target_page_numbers(value: str, page_count: int) -> set[int] | None:
243 + pages: set[int] = set()
244 + for raw_part in value.split(","):
245 + part = raw_part.strip()
246 + if not part:
247 + continue
248 + if "-" in part:
249 + start_raw, end_raw = part.split("-", 1)
250 + try:
251 + start = int(start_raw.strip())
252 + end = int(end_raw.strip())
253 + except ValueError:
254 + return None
255 + if start <= 0 or end <= 0:
256 + return None
257 + if start > end:
258 + start, end = end, start
259 + pages.update(range(start, min(end, page_count) + 1))
260 + else:
261 + try:
262 + page = int(part)
263 + except ValueError:
264 + return None
265 + if page <= 0:
266 + return None
267 + if page <= page_count:
268 + pages.add(page)
269 + return pages
270 +
271 +
272 +def _sample_page_indexes(
273 + page_count: int,
274 + sample_pages: int,
275 + target_pages: str | None = None,
276 +) -> list[int]:
277 + if page_count <= 0 or sample_pages <= 0:
278 + return []
279 +
280 + candidate_indexes: list[int]
281 + if target_pages not in (None, ""):
282 + target_page_numbers = _target_page_numbers(str(target_pages), page_count)
283 + if target_page_numbers:
284 + candidate_indexes = sorted(page - 1 for page in target_page_numbers)
285 + else:
286 + candidate_indexes = list(range(page_count))
287 + else:
288 + candidate_indexes = list(range(page_count))
289 +
290 + if len(candidate_indexes) <= sample_pages:
291 + return candidate_indexes
292 +
293 + anchors = [0, 1, 2, len(candidate_indexes) // 2, len(candidate_indexes) - 1]
294 + indexes = []
295 + seen = set()
296 + for anchor in anchors:
297 + index = candidate_indexes[min(max(anchor, 0), len(candidate_indexes) - 1)]
298 + if index not in seen:
299 + seen.add(index)
300 + indexes.append(index)
301 + if len(indexes) >= sample_pages:
302 + break
303 + return indexes
304 +
305
306 def _detect_tessdata_path() -> str:
307 env_path = os.getenv("TESSDATA_PREFIX", "")
plugins/_document_query/webui/config.html
+3 -18
@@ -28,8 +28,7 @@
28 liteparse_dpi: 150,
29 liteparse_preserve_very_small_text: false,
30 liteparse_output_format: 'text',
31 - liteparse_num_workers: 1,
32 - liteparse_subprocess: true,
31 + liteparse_num_workers: 2,
32 pdf_ocr_fallback: true,
33 thread_offload: true,
34 },
@@ -51,7 +50,7 @@
50 this.ensureNumber('gather_timeout', 120, 1);
51 this.ensureInt('liteparse_max_pages', 1000, 1);
52 this.ensureNumber('liteparse_dpi', 150, 72);
54 - this.ensureInt('liteparse_num_workers', 1, 1);
53 + this.ensureInt('liteparse_num_workers', 2, 1);
54 this.syncChunkOverlap();
55 },
56 ensureInt(key, fallback, min = null, max = null) {
@@ -293,7 +292,7 @@
292 </div>
293 <div class="field-control">
294 <input type="number" min="1" step="1"
296 - @change="ensureInt('liteparse_num_workers', 1, 1)"
295 + @change="ensureInt('liteparse_num_workers', 2, 1)"
296 x-model.number="config.liteparse_num_workers" />
297 </div>
298 </div>
@@ -430,20 +429,6 @@
429 </div>
430 </div>
431
433 - <div class="field">
434 - <div class="field-label">
435 - <div class="field-title">Run LiteParse in subprocess</div>
436 - <div class="field-description">
437 - Isolate LiteParse native runtime failures from the Web UI process.
438 - </div>
439 - </div>
440 - <div class="field-control">
441 - <label class="toggle">
442 - <input type="checkbox" x-model="config.liteparse_subprocess" />
443 - <span class="toggler"></span>
444 - </label>
445 - </div>
446 - </div>
432 </div>
433 </template>
434
tests/test_document_query_plugin.py
+167 -5
@@ -10,6 +10,7 @@ from plugins._document_query.helpers.fetch import FetchedDocument, fetch_public_
10 from plugins._document_query.helpers.document_query import DocumentQueryHelper
11 from plugins._document_query.helpers.parsers.base import BaseParser
12 from plugins._document_query.helpers.parsers import get_parsers_for_mimetype
13 +from plugins._document_query.helpers.parsers import liteparse as liteparse_module
14 from plugins._document_query.helpers.parsers.liteparse import LiteParseParser
15 from plugins._document_query.helpers.parsers.text import TextParser
16
@@ -119,8 +120,9 @@ def test_default_config_bounds_liteparse_runtime_concurrency():
120
121 assert "parser_concurrency: 1" in default_config
122 assert "context_intro_chunks: 2" in default_config
122 - assert "liteparse_num_workers: 1" in default_config
123 - assert "liteparse_subprocess: true" in default_config
123 + assert "liteparse_num_workers: 2" in default_config
124 + assert "liteparse_ocr_auto_disable_pages: 30" in default_config
125 + assert "liteparse_subprocess" not in default_config
126
127
128 def test_config_panel_exposes_document_query_settings():
@@ -153,11 +155,11 @@ def test_config_panel_exposes_document_query_settings():
155 "liteparse_preserve_very_small_text",
156 "liteparse_output_format",
157 "liteparse_num_workers",
156 - "liteparse_subprocess",
158 "pdf_ocr_fallback",
159 "thread_offload",
160 ]:
161 assert f"config.{setting}" in config_html
162 + assert "liteparse_subprocess" not in config_html
163
164
165 def test_document_query_thumbnail_matches_plugin_hub_limits():
@@ -173,9 +175,169 @@ def test_document_query_thumbnail_matches_plugin_hub_limits():
175 def test_liteparse_parser_caps_workers_by_default():
176 parser = LiteParseParser()
177
176 - assert parser._liteparse_kwargs({})["num_workers"] == 1
178 + assert parser._liteparse_kwargs({})["num_workers"] == 2
179 assert parser._liteparse_kwargs({"liteparse_num_workers": "3"})["num_workers"] == 3
178 - assert parser._liteparse_kwargs({"liteparse_num_workers": ""})["num_workers"] == 1
180 + assert parser._liteparse_kwargs({"liteparse_num_workers": ""})["num_workers"] == 2
181 +
182 +
183 +def test_liteparse_parser_always_uses_subprocess(monkeypatch):
184 + fetched = FetchedDocument(
185 + uri="/tmp/report.pdf",
186 + source_uri="/tmp/report.pdf",
187 + scheme="file",
188 + mimetype="application/pdf",
189 + content=b"",
190 + local_path="/tmp/report.pdf",
191 + )
192 + parser = LiteParseParser()
193 +
194 + monkeypatch.setattr(parser, "_parse_subprocess", lambda _document, _config: "ok")
195 +
196 + def fail_in_process(_document, _config):
197 + raise AssertionError("LiteParse must stay isolated from the Web UI process")
198 +
199 + monkeypatch.setattr(parser, "_parse_in_process", fail_in_process)
200 +
201 + assert parser._parse_sync(fetched, {"liteparse_subprocess": False}) == "ok"
202 +
203 +
204 +def test_liteparse_auto_disables_ocr_for_large_text_pdf(monkeypatch):
205 + parser = LiteParseParser()
206 + fetched = FetchedDocument(
207 + uri="/tmp/report.pdf",
208 + source_uri="/tmp/report.pdf",
209 + scheme="file",
210 + mimetype="application/pdf",
211 + content=b"",
212 + local_path="/tmp/report.pdf",
213 + )
214 + monkeypatch.setattr(
215 + liteparse_module,
216 + "_pdf_text_profile",
217 + lambda _file_path, _config: liteparse_module._PdfTextProfile(
218 + page_count=277,
219 + sampled_pages=5,
220 + text_chars=2500,
221 + ),
222 + )
223 +
224 + kwargs = parser._liteparse_kwargs({}, fetched, "/tmp/report.pdf")
225 +
226 + assert kwargs["ocr_enabled"] is False
227 +
228 +
229 +def test_liteparse_keeps_ocr_for_small_pdf(monkeypatch):
230 + parser = LiteParseParser()
231 + fetched = FetchedDocument(
232 + uri="/tmp/bill.pdf",
233 + source_uri="/tmp/bill.pdf",
234 + scheme="file",
235 + mimetype="application/pdf",
236 + content=b"",
237 + local_path="/tmp/bill.pdf",
238 + )
239 + monkeypatch.setattr(
240 + liteparse_module,
241 + "_pdf_text_profile",
242 + lambda _file_path, _config: liteparse_module._PdfTextProfile(
243 + page_count=10,
244 + sampled_pages=5,
245 + text_chars=2500,
246 + ),
247 + )
248 +
249 + kwargs = parser._liteparse_kwargs({}, fetched, "/tmp/bill.pdf")
250 +
251 + assert kwargs["ocr_enabled"] is True
252 +
253 +
254 +def test_liteparse_keeps_ocr_for_large_text_sparse_pdf(monkeypatch):
255 + parser = LiteParseParser()
256 + fetched = FetchedDocument(
257 + uri="/tmp/scan.pdf",
258 + source_uri="/tmp/scan.pdf",
259 + scheme="file",
260 + mimetype="application/pdf",
261 + content=b"",
262 + local_path="/tmp/scan.pdf",
263 + )
264 + monkeypatch.setattr(
265 + liteparse_module,
266 + "_pdf_text_profile",
267 + lambda _file_path, _config: liteparse_module._PdfTextProfile(
268 + page_count=277,
269 + sampled_pages=5,
270 + text_chars=20,
271 + ),
272 + )
273 +
274 + kwargs = parser._liteparse_kwargs({}, fetched, "/tmp/scan.pdf")
275 +
276 + assert kwargs["ocr_enabled"] is True
277 +
278 +
279 +def test_liteparse_respects_explicit_ocr_disabled(monkeypatch):
280 + parser = LiteParseParser()
281 + fetched = FetchedDocument(
282 + uri="/tmp/bill.pdf",
283 + source_uri="/tmp/bill.pdf",
284 + scheme="file",
285 + mimetype="application/pdf",
286 + content=b"",
287 + local_path="/tmp/bill.pdf",
288 + )
289 + monkeypatch.setattr(
290 + liteparse_module,
291 + "_pdf_text_profile",
292 + lambda _file_path, _config: liteparse_module._PdfTextProfile(
293 + page_count=10,
294 + sampled_pages=5,
295 + text_chars=0,
296 + ),
297 + )
298 +
299 + kwargs = parser._liteparse_kwargs(
300 + {"liteparse_ocr_enabled": False},
301 + fetched,
302 + "/tmp/bill.pdf",
303 + )
304 +
305 + assert kwargs["ocr_enabled"] is False
306 +
307 +
308 +def test_liteparse_target_pages_can_keep_ocr_enabled_for_large_pdf(monkeypatch):
309 + parser = LiteParseParser()
310 + fetched = FetchedDocument(
311 + uri="/tmp/report.pdf",
312 + source_uri="/tmp/report.pdf",
313 + scheme="file",
314 + mimetype="application/pdf",
315 + content=b"",
316 + local_path="/tmp/report.pdf",
317 + )
318 + monkeypatch.setattr(
319 + liteparse_module,
320 + "_pdf_text_profile",
321 + lambda _file_path, _config: liteparse_module._PdfTextProfile(
322 + page_count=277,
323 + sampled_pages=5,
324 + text_chars=2500,
325 + ),
326 + )
327 +
328 + small_range = parser._liteparse_kwargs(
329 + {"liteparse_target_pages": "1-10"},
330 + fetched,
331 + "/tmp/report.pdf",
332 + )
333 + large_range = parser._liteparse_kwargs(
334 + {"liteparse_target_pages": "1-40"},
335 + fetched,
336 + "/tmp/report.pdf",
337 + )
338 +
339 + assert small_range["ocr_enabled"] is True
340 + assert large_range["ocr_enabled"] is False
341
342
343 def test_query_optimize_prompt_filename_is_spelled_correctly():