feat(document_query): expand settings panel and thumbnail

Expose the main Document Query parser, retrieval, fetch, LiteParse/OCR, and fallback controls in the plugin settings UI. Add a generated 256x256 JPEG thumbnail under the plugin size limit and cover both the settings wiring and thumbnail constraints with focused tests.

Alessandro committed May 29, 2026 at 16:47 UTC 98f6c17d15d8309218e9cff356e1b6ce9ab161ec
3 files changed +500 -6
plugins/_document_query/webui/config.html
+459 -3
@@ -4,9 +4,80 @@
4 </head>
5
6 <body>
7 - <div x-data>
7 + <div x-data="{
8 + defaults: {
9 + fetch_timeout: 30,
10 + fetch_retries: 3,
11 + fetch_retry_backoff: 1.0,
12 + per_document_timeout: 60,
13 + gather_timeout: 120,
14 + parser_concurrency: 1,
15 + context_intro_chunks: 2,
16 + chunk_size: 1000,
17 + chunk_overlap: 100,
18 + search_threshold: 0.5,
19 + search_limit: 100,
20 + max_remote_bytes: 52428800,
21 + liteparse_enabled: true,
22 + liteparse_ocr_enabled: true,
23 + liteparse_ocr_language: 'eng',
24 + liteparse_ocr_server_url: '',
25 + liteparse_tessdata_path: '',
26 + liteparse_max_pages: 1000,
27 + liteparse_target_pages: '',
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,
33 + pdf_ocr_fallback: true,
34 + thread_offload: true,
35 + },
36 + initDefaults() {
37 + for (const [key, value] of Object.entries(this.defaults)) {
38 + if (config[key] === undefined || config[key] === null) config[key] = value;
39 + }
40 + this.ensureInt('parser_concurrency', 1, 1);
41 + this.ensureInt('context_intro_chunks', 2, 0);
42 + this.ensureInt('chunk_size', 1000, 100);
43 + this.ensureInt('chunk_overlap', 100, 0);
44 + this.ensureInt('search_limit', 100, 1);
45 + this.ensureInt('max_remote_bytes', 52428800, 1);
46 + this.ensureNumber('search_threshold', 0.5, 0, 1);
47 + this.ensureNumber('fetch_timeout', 30, 1);
48 + this.ensureInt('fetch_retries', 3, 1);
49 + this.ensureNumber('fetch_retry_backoff', 1.0, 0);
50 + this.ensureNumber('per_document_timeout', 60, 1);
51 + this.ensureNumber('gather_timeout', 120, 1);
52 + this.ensureInt('liteparse_max_pages', 1000, 1);
53 + this.ensureNumber('liteparse_dpi', 150, 72);
54 + this.ensureInt('liteparse_num_workers', 1, 1);
55 + this.syncChunkOverlap();
56 + },
57 + ensureInt(key, fallback, min = null, max = null) {
58 + let value = parseInt(config[key], 10);
59 + if (!Number.isFinite(value)) value = fallback;
60 + if (min !== null) value = Math.max(min, value);
61 + if (max !== null) value = Math.min(max, value);
62 + config[key] = value;
63 + },
64 + ensureNumber(key, fallback, min = null, max = null) {
65 + let value = parseFloat(config[key]);
66 + if (!Number.isFinite(value)) value = fallback;
67 + if (min !== null) value = Math.max(min, value);
68 + if (max !== null) value = Math.min(max, value);
69 + config[key] = value;
70 + },
71 + syncChunkOverlap() {
72 + this.ensureInt('chunk_size', 1000, 100);
73 + this.ensureInt('chunk_overlap', 100, 0);
74 + if (config.chunk_overlap >= config.chunk_size) {
75 + config.chunk_overlap = Math.max(0, config.chunk_size - 1);
76 + }
77 + },
78 + }">
79 <template x-if="config">
9 - <div>
80 + <div x-init="initDefaults()">
81 <div class="section-title">Document Query</div>
82 <div class="section-description">
83 Settings for document parsing and retrieval.
@@ -21,10 +92,395 @@
92 </div>
93 <div class="field-control">
94 <input type="number" min="1" step="1"
24 - x-init="config.parser_concurrency = Math.max(1, parseInt(config.parser_concurrency || 1, 10) || 1)"
95 + @change="ensureInt('parser_concurrency', 1, 1)"
96 x-model.number="config.parser_concurrency" />
97 </div>
98 </div>
99 +
100 + <div class="field">
101 + <div class="field-label">
102 + <div class="field-title">Per-document timeout</div>
103 + <div class="field-description">
104 + Seconds allowed for a single document parse before trying the next parser or returning an error.
105 + </div>
106 + </div>
107 + <div class="field-control">
108 + <input type="number" min="1" step="1"
109 + @change="ensureNumber('per_document_timeout', 60, 1)"
110 + x-model.number="config.per_document_timeout" />
111 + </div>
112 + </div>
113 +
114 + <div class="field">
115 + <div class="field-label">
116 + <div class="field-title">Batch timeout</div>
117 + <div class="field-description">
118 + Seconds allowed for a document-query call that processes multiple files.
119 + </div>
120 + </div>
121 + <div class="field-control">
122 + <input type="number" min="1" step="1"
123 + @change="ensureNumber('gather_timeout', 120, 1)"
124 + x-model.number="config.gather_timeout" />
125 + </div>
126 + </div>
127 +
128 + <div class="section-title">Retrieval</div>
129 + <div class="section-description">
130 + Controls how parsed text is split, indexed, and selected for Q&A.
131 + </div>
132 +
133 + <div class="field">
134 + <div class="field-label">
135 + <div class="field-title">Chunk size</div>
136 + <div class="field-description">
137 + Target character count for indexed chunks.
138 + </div>
139 + </div>
140 + <div class="field-control">
141 + <input type="number" min="100" step="50"
142 + @change="syncChunkOverlap()"
143 + x-model.number="config.chunk_size" />
144 + </div>
145 + </div>
146 +
147 + <div class="field">
148 + <div class="field-label">
149 + <div class="field-title">Chunk overlap</div>
150 + <div class="field-description">
151 + Characters repeated between neighboring chunks.
152 + </div>
153 + </div>
154 + <div class="field-control">
155 + <input type="number" min="0" step="25"
156 + :max="Math.max(0, config.chunk_size - 1)"
157 + @change="syncChunkOverlap()"
158 + x-model.number="config.chunk_overlap" />
159 + </div>
160 + </div>
161 +
162 + <div class="field">
163 + <div class="field-label">
164 + <div class="field-title">Search threshold</div>
165 + <div class="field-description">
166 + Minimum vector similarity score for retrieved chunks.
167 + </div>
168 + </div>
169 + <div class="field-control">
170 + <input type="number" min="0" max="1" step="0.05"
171 + @change="ensureNumber('search_threshold', 0.5, 0, 1)"
172 + x-model.number="config.search_threshold" />
173 + </div>
174 + </div>
175 +
176 + <div class="field">
177 + <div class="field-label">
178 + <div class="field-title">Search limit</div>
179 + <div class="field-description">
180 + Maximum matching chunks considered during document Q&A.
181 + </div>
182 + </div>
183 + <div class="field-control">
184 + <input type="number" min="1" step="1"
185 + @change="ensureInt('search_limit', 100, 1)"
186 + x-model.number="config.search_limit" />
187 + </div>
188 + </div>
189 +
190 + <div class="field">
191 + <div class="field-label">
192 + <div class="field-title">Intro chunks</div>
193 + <div class="field-description">
194 + Leading chunks always included per document to preserve titles, abstracts, and setup context.
195 + </div>
196 + </div>
197 + <div class="field-control">
198 + <input type="number" min="0" step="1"
199 + @change="ensureInt('context_intro_chunks', 2, 0)"
200 + x-model.number="config.context_intro_chunks" />
201 + </div>
202 + </div>
203 +
204 + <div class="section-title">Remote Files</div>
205 + <div class="section-description">
206 + Limits for documents loaded from HTTP or HTTPS URLs.
207 + </div>
208 +
209 + <div class="field">
210 + <div class="field-label">
211 + <div class="field-title">Fetch timeout</div>
212 + <div class="field-description">
213 + Seconds allowed for each remote fetch attempt.
214 + </div>
215 + </div>
216 + <div class="field-control">
217 + <input type="number" min="1" step="1"
218 + @change="ensureNumber('fetch_timeout', 30, 1)"
219 + x-model.number="config.fetch_timeout" />
220 + </div>
221 + </div>
222 +
223 + <div class="field">
224 + <div class="field-label">
225 + <div class="field-title">Fetch retries</div>
226 + <div class="field-description">
227 + Attempts made before giving up on a remote document.
228 + </div>
229 + </div>
230 + <div class="field-control">
231 + <input type="number" min="1" step="1"
232 + @change="ensureInt('fetch_retries', 3, 1)"
233 + x-model.number="config.fetch_retries" />
234 + </div>
235 + </div>
236 +
237 + <div class="field">
238 + <div class="field-label">
239 + <div class="field-title">Retry backoff</div>
240 + <div class="field-description">
241 + Delay in seconds between remote fetch retries.
242 + </div>
243 + </div>
244 + <div class="field-control">
245 + <input type="number" min="0" step="0.1"
246 + @change="ensureNumber('fetch_retry_backoff', 1.0, 0)"
247 + x-model.number="config.fetch_retry_backoff" />
248 + </div>
249 + </div>
250 +
251 + <div class="field">
252 + <div class="field-label">
253 + <div class="field-title">Max remote bytes</div>
254 + <div class="field-description">
255 + Maximum size accepted for a remote document.
256 + </div>
257 + </div>
258 + <div class="field-control">
259 + <input type="number" min="1" step="1048576"
260 + @change="ensureInt('max_remote_bytes', 52428800, 1)"
261 + x-model.number="config.max_remote_bytes" />
262 + </div>
263 + </div>
264 +
265 + <div class="section-title">LiteParse and OCR</div>
266 + <div class="section-description">
267 + Parser preference and OCR controls for PDFs, document images, and mixed-format inputs.
268 + </div>
269 +
270 + <div class="field">
271 + <div class="field-label">
272 + <div class="field-title">Use LiteParse first</div>
273 + <div class="field-description">
274 + Prefer LiteParse before legacy parser fallbacks.
275 + </div>
276 + </div>
277 + <div class="field-control">
278 + <label class="toggle">
279 + <input type="checkbox" x-model="config.liteparse_enabled" />
280 + <span class="toggler"></span>
281 + </label>
282 + </div>
283 + </div>
284 +
285 + <template x-if="config.liteparse_enabled !== false">
286 + <div>
287 + <div class="field">
288 + <div class="field-label">
289 + <div class="field-title">LiteParse workers</div>
290 + <div class="field-description">
291 + Worker count passed to LiteParse for a single parser job.
292 + </div>
293 + </div>
294 + <div class="field-control">
295 + <input type="number" min="1" step="1"
296 + @change="ensureInt('liteparse_num_workers', 1, 1)"
297 + x-model.number="config.liteparse_num_workers" />
298 + </div>
299 + </div>
300 +
301 + <div class="field">
302 + <div class="field-label">
303 + <div class="field-title">Max pages</div>
304 + <div class="field-description">
305 + Maximum pages LiteParse should parse from one document.
306 + </div>
307 + </div>
308 + <div class="field-control">
309 + <input type="number" min="1" step="1"
310 + @change="ensureInt('liteparse_max_pages', 1000, 1)"
311 + x-model.number="config.liteparse_max_pages" />
312 + </div>
313 + </div>
314 +
315 + <div class="field">
316 + <div class="field-label">
317 + <div class="field-title">Target pages</div>
318 + <div class="field-description">
319 + Optional page range expression passed to LiteParse.
320 + </div>
321 + </div>
322 + <div class="field-control">
323 + <input type="text"
324 + placeholder="all pages"
325 + x-model="config.liteparse_target_pages" />
326 + </div>
327 + </div>
328 +
329 + <div class="field">
330 + <div class="field-label">
331 + <div class="field-title">Enable OCR</div>
332 + <div class="field-description">
333 + Allow OCR for scanned PDFs and document images.
334 + </div>
335 + </div>
336 + <div class="field-control">
337 + <label class="toggle">
338 + <input type="checkbox" x-model="config.liteparse_ocr_enabled" />
339 + <span class="toggler"></span>
340 + </label>
341 + </div>
342 + </div>
343 +
344 + <template x-if="config.liteparse_ocr_enabled !== false">
345 + <div>
346 + <div class="field">
347 + <div class="field-label">
348 + <div class="field-title">OCR language</div>
349 + <div class="field-description">
350 + Tesseract language code used by LiteParse OCR.
351 + </div>
352 + </div>
353 + <div class="field-control">
354 + <input type="text"
355 + placeholder="eng"
356 + x-model="config.liteparse_ocr_language" />
357 + </div>
358 + </div>
359 +
360 + <div class="field">
361 + <div class="field-label">
362 + <div class="field-title">OCR DPI</div>
363 + <div class="field-description">
364 + Render density used before OCR.
365 + </div>
366 + </div>
367 + <div class="field-control">
368 + <input type="number" min="72" step="1"
369 + @change="ensureNumber('liteparse_dpi', 150, 72)"
370 + x-model.number="config.liteparse_dpi" />
371 + </div>
372 + </div>
373 +
374 + <div class="field">
375 + <div class="field-label">
376 + <div class="field-title">OCR server URL</div>
377 + <div class="field-description">
378 + Optional external OCR service URL.
379 + </div>
380 + </div>
381 + <div class="field-control">
382 + <input type="text"
383 + placeholder="none"
384 + x-model="config.liteparse_ocr_server_url" />
385 + </div>
386 + </div>
387 +
388 + <div class="field">
389 + <div class="field-label">
390 + <div class="field-title">Tessdata path</div>
391 + <div class="field-description">
392 + Optional path to Tesseract language data.
393 + </div>
394 + </div>
395 + <div class="field-control">
396 + <input type="text"
397 + placeholder="auto-detect"
398 + x-model="config.liteparse_tessdata_path" />
399 + </div>
400 + </div>
401 + </div>
402 + </template>
403 +
404 + <div class="field">
405 + <div class="field-label">
406 + <div class="field-title">Output format</div>
407 + <div class="field-description">
408 + LiteParse output format passed to the parser runtime.
409 + </div>
410 + </div>
411 + <div class="field-control">
412 + <input type="text"
413 + placeholder="text"
414 + x-model="config.liteparse_output_format" />
415 + </div>
416 + </div>
417 +
418 + <div class="field">
419 + <div class="field-label">
420 + <div class="field-title">Preserve very small text</div>
421 + <div class="field-description">
422 + Keep tiny text regions that OCR or PDF extraction might otherwise discard.
423 + </div>
424 + </div>
425 + <div class="field-control">
426 + <label class="toggle">
427 + <input type="checkbox" x-model="config.liteparse_preserve_very_small_text" />
428 + <span class="toggler"></span>
429 + </label>
430 + </div>
431 + </div>
432 +
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>
447 + </div>
448 + </template>
449 +
450 + <div class="section-title">Fallbacks</div>
451 + <div class="section-description">
452 + Legacy parser behavior used when the preferred parser cannot produce text.
453 + </div>
454 +
455 + <div class="field">
456 + <div class="field-label">
457 + <div class="field-title">PDF OCR fallback</div>
458 + <div class="field-description">
459 + Try legacy Tesseract OCR when direct PDF text extraction is empty.
460 + </div>
461 + </div>
462 + <div class="field-control">
463 + <label class="toggle">
464 + <input type="checkbox" x-model="config.pdf_ocr_fallback" />
465 + <span class="toggler"></span>
466 + </label>
467 + </div>
468 + </div>
469 +
470 + <div class="field">
471 + <div class="field-label">
472 + <div class="field-title">Thread offload</div>
473 + <div class="field-description">
474 + Run synchronous parser fallbacks in a worker thread.
475 + </div>
476 + </div>
477 + <div class="field-control">
478 + <label class="toggle">
479 + <input type="checkbox" x-model="config.thread_offload" />
480 + <span class="toggler"></span>
481 + </label>
482 + </div>
483 + </div>
484 </div>
485 </template>
486 </div>
plugins/_document_query/webui/thumbnail.jpg
Binary files /dev/null and b/plugins/_document_query/webui/thumbnail.jpg differ
tests/test_document_query_plugin.py
+41 -3
@@ -4,6 +4,7 @@ import asyncio
4 from pathlib import Path
5
6 import pytest
7 +from PIL import Image
8
9 from plugins._document_query.helpers.fetch import FetchedDocument, fetch_public_resource
10 from plugins._document_query.helpers.document_query import DocumentQueryHelper
@@ -122,14 +123,51 @@ def test_default_config_bounds_liteparse_runtime_concurrency():
123 assert "liteparse_subprocess: true" in default_config
124
125
125 -def test_config_panel_exposes_parser_concurrency_setting():
126 +def test_config_panel_exposes_document_query_settings():
127 config_html = (
128 ROOT / "plugins" / "_document_query" / "webui" / "config.html"
129 ).read_text(encoding="utf-8")
130
131 assert "Max parser concurrency" in config_html
131 - assert 'x-model.number="config.parser_concurrency"' in config_html
132 - assert 'min="1"' in config_html
132 + for setting in [
133 + "parser_concurrency",
134 + "per_document_timeout",
135 + "gather_timeout",
136 + "chunk_size",
137 + "chunk_overlap",
138 + "search_threshold",
139 + "search_limit",
140 + "context_intro_chunks",
141 + "fetch_timeout",
142 + "fetch_retries",
143 + "fetch_retry_backoff",
144 + "max_remote_bytes",
145 + "liteparse_enabled",
146 + "liteparse_ocr_enabled",
147 + "liteparse_ocr_language",
148 + "liteparse_ocr_server_url",
149 + "liteparse_tessdata_path",
150 + "liteparse_max_pages",
151 + "liteparse_target_pages",
152 + "liteparse_dpi",
153 + "liteparse_preserve_very_small_text",
154 + "liteparse_output_format",
155 + "liteparse_num_workers",
156 + "liteparse_subprocess",
157 + "pdf_ocr_fallback",
158 + "thread_offload",
159 + ]:
160 + assert f"config.{setting}" in config_html
161 +
162 +
163 +def test_document_query_thumbnail_matches_plugin_hub_limits():
164 + thumbnail = ROOT / "plugins" / "_document_query" / "webui" / "thumbnail.jpg"
165 +
166 + assert thumbnail.exists()
167 + assert thumbnail.stat().st_size <= 20 * 1024
168 + with Image.open(thumbnail) as image:
169 + assert image.format == "JPEG"
170 + assert image.size == (256, 256)
171
172
173 def test_liteparse_parser_caps_workers_by_default():