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():