vera 0.3.2__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.
@@ -0,0 +1,58 @@
1
+ Metadata-Version: 2.5
2
+ Name: vera
3
+ Version: 0.3.2
4
+ Summary: Command-line interface for VERA documents
5
+ Project-URL: Homepage, https://github.com/dkylewillis/vera
6
+ Project-URL: Repository, https://github.com/dkylewillis/vera
7
+ Project-URL: Documentation, https://dkylewillis.github.io/vera/packages/vera-cli/
8
+ Author: Kyle Willis
9
+ License: Apache-2.0
10
+ Keywords: cli,document,semantic-search,sqlite
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: License :: OSI Approved :: Apache Software License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Text Processing :: Indexing
15
+ Requires-Python: >=3.10
16
+ Requires-Dist: vera-doc>=0.3.2
17
+ Requires-Dist: vera-embed-openai>=0.3.2
18
+ Requires-Dist: vera-ingest-pymupdf>=0.3.2
19
+ Requires-Dist: vera-ingest>=0.3.2
20
+ Provides-Extra: docling
21
+ Requires-Dist: vera-ingest-docling>=0.3.2; extra == 'docling'
22
+ Provides-Extra: mcp
23
+ Requires-Dist: vera-mcp>=0.3.2; extra == 'mcp'
24
+ Description-Content-Type: text/markdown
25
+
26
+ # vera
27
+
28
+ `vera` provides the `vera` command-line interface over `vera-doc`,
29
+ `vera-ingest`, `vera-ingest-pymupdf`, and `vera-embed-openai`. It owns command parsing, text and
30
+ JSON output, exit codes, and retrieval evaluation. The Python import remains
31
+ `vera_cli`.
32
+
33
+ `vera convert` accepts repeatable `--pipeline-option KEY=VALUE` flags for
34
+ provider-owned ingest settings. Legacy flags such as `--chunk-size`,
35
+ `--overlap`, `--ocr`, `--ocr-language`, and `--ocr-dpi` remain compatibility
36
+ aliases for pipelines that accept them (`--ocr-language`/`--ocr-dpi` are
37
+ Tesseract/PyMuPDF); explicit `--pipeline-option` values win for the same key.
38
+
39
+ ## Install
40
+
41
+ ```bash
42
+ python -m pip install "vera>=0.3.2"
43
+ ```
44
+
45
+ Install the `mcp` extra to enable `vera mcp`, or the `docling` extra for
46
+ Docling PDF layout conversion and search-only DOCX/PPTX/XLSX/HTML ingest:
47
+
48
+ ```bash
49
+ python -m pip install "vera[mcp]>=0.3.2"
50
+ python -m pip install "vera[docling]>=0.3.2"
51
+ ```
52
+
53
+ `pip install vera-cli` remains a compatibility alias that depends on `vera`.
54
+
55
+ See the [vera CLI documentation](https://dkylewillis.github.io/vera/packages/vera-cli/)
56
+ for installation, recipes, evaluation, and command reference.
57
+
58
+ See the [CLI reference](https://github.com/dkylewillis/vera/blob/main/docs/cli-reference.md).
@@ -0,0 +1,9 @@
1
+ vera_cli/__init__.py,sha256=IqMxxpNCm1rDJr-Rrr_xHzwPFzNKPpQuLSj275eMSIA,122
2
+ vera_cli/__main__.py,sha256=EwrK-u-Eg2JchXI165ldrOFBIaW6EO7Cx0ZMnwRRU6c,49
3
+ vera_cli/commands.py,sha256=MVqJXhchP8oeXjU7ypAg9UiftizVcKw_MQLtoCHGpxQ,21337
4
+ vera_cli/evaluate.py,sha256=uWeYdP9PoepsLyxLHL6d5cUK0-7GbowG5straIk3oIM,4990
5
+ vera_cli/main.py,sha256=vywzuYin0FLArTOon-ycRt1bjVpZIwOxCCJBSFxm1HM,14627
6
+ vera-0.3.2.dist-info/METADATA,sha256=pKL7iCnX6eUTI2Z9-yV9EJVScIzQu0PJZxS-8c8SsAs,2221
7
+ vera-0.3.2.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
8
+ vera-0.3.2.dist-info/entry_points.txt,sha256=5_U6LyQcS-MRU9VE7mWzLEvzBLNlDtHDjm91So4wy4Q,39
9
+ vera-0.3.2.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ vera = vera_cli:main
vera_cli/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .commands import str_to_bool
2
+ from .main import build_parser, main
3
+
4
+ __all__ = ["str_to_bool", "build_parser", "main"]
vera_cli/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .main import main
2
+
3
+ raise SystemExit(main())
vera_cli/commands.py ADDED
@@ -0,0 +1,626 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ from vera_doc import Citation, UnknownEmbeddingModelError
8
+ from vera_doc.collection import build_library_index, library_index_status, update_library_index
9
+ from vera_doc.corpus import VeraCorpus
10
+ from vera_doc.document import VeraDocument
11
+ from vera_embed_openai import (
12
+ OpenAIEmbedderError,
13
+ )
14
+ from vera_embed_openai import (
15
+ ensure_registered as ensure_openai_embedder_registered,
16
+ )
17
+ from vera_ingest import (
18
+ ReservedMetadataKeyError,
19
+ UnknownIngestPipelineError,
20
+ batch_convert,
21
+ convert,
22
+ )
23
+ from vera_ingest.viewer import (
24
+ ensure_requested_figures,
25
+ export_figures,
26
+ export_source_document,
27
+ figures,
28
+ get_chunk_json,
29
+ get_source_document,
30
+ result_payload,
31
+ )
32
+ from vera_ingest_pymupdf import (
33
+ OCRLanguageDownloadError,
34
+ UnknownOCRLanguageError,
35
+ describe_ocr_languages,
36
+ download_ocr_language_data,
37
+ )
38
+
39
+ ensure_openai_embedder_registered()
40
+
41
+ _TRUE_TOKENS = {"1", "true", "yes", "y", "on"}
42
+ _FALSE_TOKENS = {"0", "false", "no", "n", "off", ""}
43
+
44
+
45
+ def str_to_bool(value: str) -> bool:
46
+ lowered = str(value).strip().lower()
47
+ if lowered in _TRUE_TOKENS:
48
+ return True
49
+ if lowered in _FALSE_TOKENS:
50
+ return False
51
+ raise ValueError(f"invalid boolean value: {value!r}")
52
+
53
+
54
+ def _document_for(target, result) -> VeraDocument:
55
+ if isinstance(target, VeraCorpus):
56
+ return target.document(result.file)
57
+ return target
58
+
59
+
60
+ def _archive_locator(requested: str, document: VeraDocument) -> dict[str, str]:
61
+ return {"file": requested, "path": str(Path(document.path).resolve())}
62
+
63
+
64
+ def _pipeline_options_from_args(args) -> dict[str, object]:
65
+ return _key_value_options_from_args(getattr(args, "pipeline_options", []) or [])
66
+
67
+
68
+ def _embedder_options_from_args(args) -> dict[str, object]:
69
+ return _key_value_options_from_args(getattr(args, "embedder_options", []) or [])
70
+
71
+
72
+ def _coerce_option_value(raw: str) -> object:
73
+ """Coerce a KEY=VALUE token without ``float()``.
74
+
75
+ Dotted tokens such as ``3.10`` or ``ocr_language=1.0`` stay strings.
76
+ Whole-digit tokens become ints; ``true``/``false``/``yes``/``no``/``on``/
77
+ ``off`` become bools.
78
+ """
79
+ text = str(raw).strip()
80
+ lowered = text.lower()
81
+ if lowered in {"true", "yes", "y", "on"}:
82
+ return True
83
+ if lowered in {"false", "no", "n", "off"}:
84
+ return False
85
+ if text.isdigit() or (text.startswith("-") and text[1:].isdigit()):
86
+ return int(text)
87
+ return text
88
+
89
+
90
+ def _key_value_options_from_args(pairs) -> dict[str, object]:
91
+ options: dict[str, object] = {}
92
+ for key, raw in pairs:
93
+ options[key] = _coerce_option_value(raw)
94
+ return options
95
+
96
+
97
+ def _metadata_from_args(args) -> dict[str, object] | None:
98
+ pairs = getattr(args, "metadata", None) or []
99
+ return _key_value_options_from_args(pairs) or None
100
+
101
+
102
+ def _where_from_args(pairs) -> dict[str, object] | None:
103
+ """Parse ``--where KEY=VALUE`` pairs: AND across keys, comma IN, union repeats."""
104
+ if not pairs:
105
+ return None
106
+ where: dict[str, object] = {}
107
+ for key, raw in pairs:
108
+ parts = str(raw).split(",")
109
+ if any(not part.strip() for part in parts):
110
+ raise ValueError("where values must not be empty")
111
+ values = [_coerce_option_value(part.strip()) for part in parts]
112
+ existing = where.get(key)
113
+ if existing is None:
114
+ where[key] = values[0] if len(values) == 1 else values
115
+ continue
116
+ current = existing if isinstance(existing, list) else [existing]
117
+ for value in values:
118
+ if value not in current:
119
+ current.append(value)
120
+ where[key] = current[0] if len(current) == 1 else current
121
+ return where
122
+
123
+
124
+ def cmd_convert(args) -> int:
125
+ input_path = Path(args.input)
126
+ pipeline_options = _pipeline_options_from_args(args)
127
+ embedder_options = _embedder_options_from_args(args)
128
+ metadata = _metadata_from_args(args)
129
+ try:
130
+ if input_path.is_dir():
131
+ if args.output:
132
+ message = (
133
+ "Directory conversion creates each .vera beside its source file; "
134
+ "do not provide an output path."
135
+ )
136
+ if args.json:
137
+ print(json.dumps({"ok": False, "error": message}))
138
+ else:
139
+ print(message, file=sys.stderr)
140
+ return 2
141
+ report = batch_convert(
142
+ args.input,
143
+ recursive=args.recursive,
144
+ overwrite=args.overwrite,
145
+ model=args.model,
146
+ parser=args.parser,
147
+ chunk_size=args.chunk_size,
148
+ overlap=args.overlap,
149
+ store_original=args.store_original,
150
+ ocr_mode=args.ocr_mode,
151
+ ocr_language=args.ocr_language,
152
+ ocr_dpi=args.ocr_dpi,
153
+ ocr_download=args.ocr_allow_download,
154
+ pipeline_options=pipeline_options or None,
155
+ embedder_options=embedder_options or None,
156
+ metadata=metadata,
157
+ )
158
+ unsuccessful = report["failed"] + report["malformed"]
159
+ if args.json:
160
+ print(json.dumps({"ok": unsuccessful == 0, **report}))
161
+ else:
162
+ print(
163
+ f"Found {report['discovered']} files: {report['converted']} converted, "
164
+ f"{report['skipped']} skipped, {report['malformed']} malformed, "
165
+ f"{report['failed']} failed"
166
+ )
167
+ for entry in report["malformed_existing"]:
168
+ issues = "; ".join(entry["issues"])
169
+ print(f"Malformed {entry['output']}: {issues}", file=sys.stderr)
170
+ for entry in report["errors"]:
171
+ print(f"Failed {entry['input']}: {entry['error']}", file=sys.stderr)
172
+ return 1 if unsuccessful else 0
173
+
174
+ output = args.output or str(input_path.with_suffix(".vera"))
175
+ path = convert(
176
+ args.input,
177
+ output,
178
+ model=args.model,
179
+ parser=args.parser,
180
+ chunk_size=args.chunk_size,
181
+ overlap=args.overlap,
182
+ store_original=args.store_original,
183
+ ocr_mode=args.ocr_mode,
184
+ ocr_language=args.ocr_language,
185
+ ocr_dpi=args.ocr_dpi,
186
+ ocr_download=args.ocr_allow_download,
187
+ pipeline_options=pipeline_options or None,
188
+ embedder_options=embedder_options or None,
189
+ metadata=metadata,
190
+ )
191
+ except (
192
+ UnknownIngestPipelineError,
193
+ UnknownEmbeddingModelError,
194
+ ReservedMetadataKeyError,
195
+ ) as exc:
196
+ if args.json:
197
+ print(json.dumps({"ok": False, "error": str(exc)}))
198
+ else:
199
+ print(str(exc), file=sys.stderr)
200
+ return 2
201
+ except (ValueError, FileNotFoundError, OpenAIEmbedderError) as exc:
202
+ if args.json:
203
+ print(json.dumps({"ok": False, "error": str(exc)}))
204
+ else:
205
+ print(str(exc), file=sys.stderr)
206
+ return 1
207
+ if args.json:
208
+ print(json.dumps({"ok": True, "output": str(path)}))
209
+ else:
210
+ print(f"Created {path}")
211
+ return 0
212
+
213
+
214
+ def cmd_inspect(args) -> int:
215
+ doc = VeraDocument.open(args.file)
216
+ try:
217
+ info = doc.inspect()
218
+ if args.json:
219
+ print(json.dumps({**info, **_archive_locator(args.file, doc)}))
220
+ return 0
221
+ print(f"File: {args.file}")
222
+ print(f"Format: {info.get('format_name', 'VERA')} v{info.get('format_version')}")
223
+ print(f"Source: {info.get('source_file_name') or info.get('source')}")
224
+ print(f"Pages: {info.get('pages')}")
225
+ print(f"Chunks: {info.get('chunks')}")
226
+ print(f"Embedding model: {info.get('default_embedding_model')}")
227
+ print(f"Embedding dimensions: {info.get('default_embedding_dimension')}")
228
+ print(f"Embedding normalization: {info.get('default_embedding_normalization', 'unknown')}")
229
+ print(f"Parser: {info.get('parser_name')}")
230
+ print(f"Created: {info.get('created_at')}")
231
+ finally:
232
+ doc.close()
233
+ return 0
234
+
235
+
236
+ def _chunk_not_found_message(chunk_id: str) -> str:
237
+ return f"chunk not found: {chunk_id}"
238
+
239
+
240
+ def _emit_get_error(args, message: str) -> int:
241
+ if args.json:
242
+ print(json.dumps({"ok": False, "error": message}))
243
+ else:
244
+ print(message, file=sys.stderr)
245
+ return 1
246
+
247
+
248
+ def cmd_get(args) -> int:
249
+ chunk_id = args.chunk_id
250
+ if not str(chunk_id).strip():
251
+ return _emit_get_error(args, _chunk_not_found_message(chunk_id))
252
+
253
+ doc = VeraDocument.open(args.file)
254
+ try:
255
+ try:
256
+ records = doc.get(ids=[chunk_id])
257
+ if not records:
258
+ return _emit_get_error(args, _chunk_not_found_message(chunk_id))
259
+ payload = get_chunk_json(
260
+ args.file,
261
+ doc,
262
+ records[0],
263
+ include_figures=args.figures,
264
+ include_regions=args.regions,
265
+ )
266
+ except ValueError as exc:
267
+ return _emit_get_error(args, str(exc))
268
+ if args.json:
269
+ print(json.dumps(payload))
270
+ return 0
271
+ citation = Citation.from_metadata(records[0].metadata)
272
+ print(f"Source: {citation.source_filename}")
273
+ page = (
274
+ citation.page_start
275
+ if citation.page_start == citation.page_end
276
+ else f"{citation.page_start}-{citation.page_end}"
277
+ )
278
+ print(f"Page: {page}")
279
+ print(f"Heading: {citation.heading_path or ''}")
280
+ print()
281
+ print(records[0].text)
282
+ finally:
283
+ doc.close()
284
+ return 0
285
+
286
+
287
+ def _emit_cli_error(args, message: str, *, code: int = 1) -> int:
288
+ if getattr(args, "json", False):
289
+ print(json.dumps({"ok": False, "error": message}))
290
+ else:
291
+ print(message, file=sys.stderr)
292
+ return code
293
+
294
+
295
+ def cmd_search(args) -> int:
296
+ target_path = Path(args.file)
297
+ includes = getattr(args, "include", None)
298
+ if includes and not target_path.is_dir():
299
+ return _emit_cli_error(args, "--include applies to directory search only", code=2)
300
+ try:
301
+ where = _where_from_args(getattr(args, "where", None) or [])
302
+ except ValueError as exc:
303
+ return _emit_cli_error(args, str(exc), code=2)
304
+ target = (
305
+ VeraCorpus.open(
306
+ args.file,
307
+ recursive=True if getattr(args, "recursive", False) else None,
308
+ excludes=getattr(args, "exclude", None),
309
+ includes=includes,
310
+ )
311
+ if target_path.is_dir()
312
+ else VeraDocument.open(args.file)
313
+ )
314
+ try:
315
+ try:
316
+ results = target.search(
317
+ text=args.query,
318
+ mode=args.mode,
319
+ top_k=args.top_k,
320
+ context_chunks=args.context_chunks,
321
+ where=where,
322
+ )
323
+ except OpenAIEmbedderError as exc:
324
+ return _emit_cli_error(args, str(exc), code=1)
325
+ if args.json:
326
+ payload = []
327
+ for result in results:
328
+ entry = result_payload(
329
+ result,
330
+ document=_document_for(target, result),
331
+ include_figures=args.figures,
332
+ include_regions=args.regions,
333
+ )
334
+ payload.append(entry)
335
+ response = {"query": args.query, "mode": args.mode, "results": payload}
336
+ if isinstance(target, VeraCorpus):
337
+ response["index"] = target.index_search_report()
338
+ response["skipped_files"] = target.invalid_files
339
+ response["skipped_semantic_model_groups"] = target.skipped_semantic_model_groups
340
+ print(json.dumps(response))
341
+ return 0
342
+ if isinstance(target, VeraCorpus):
343
+ report = target.index_search_report()
344
+ if report.get("used"):
345
+ print(f"Index: {report.get('index')} (active)")
346
+ elif report.get("exists"):
347
+ reasons = "; ".join(report.get("reasons", []))
348
+ print(f"Index: fallback ({reasons})")
349
+ for group in target.skipped_semantic_model_groups:
350
+ print(
351
+ "Warning: skipped semantic model group "
352
+ f"{group['model_name']} ({group['dimension']} dimensions): "
353
+ f"{group['error']}"
354
+ )
355
+ for result in results:
356
+ print(f"Score: {result.score:.4f}")
357
+ file = getattr(result, "file", None)
358
+ if file:
359
+ print(f"File: {file}")
360
+ citation = result.citation
361
+ print(f"Source: {citation.source_filename}")
362
+ page = (
363
+ citation.page_start
364
+ if citation.page_start == citation.page_end
365
+ else f"{citation.page_start}-{citation.page_end}"
366
+ )
367
+ print(f"Page: {page}")
368
+ print(f"Heading: {citation.heading_path or ''}")
369
+ print()
370
+ print(result.record.text)
371
+ print("-" * 72)
372
+ finally:
373
+ target.close()
374
+ return 0
375
+
376
+
377
+ def _print_index_report(report: dict) -> None:
378
+ print(f"Index: {report['index']}")
379
+ print(f"Directory: {report['directory']}")
380
+ if "fresh" in report:
381
+ print(f"Status: {'fresh' if report['fresh'] else 'stale'}")
382
+ for reason in report.get("reasons", []):
383
+ print(f"- {reason}")
384
+ return
385
+ print(f"Files: {report['indexed']}/{report['discovered']}")
386
+ print(f"Chunks: {report['chunks']}")
387
+ print(
388
+ "Changes: "
389
+ f"{report['added']} added, {report['changed']} changed, "
390
+ f"{report['moved']} moved, {report['removed']} removed"
391
+ )
392
+ for category in ("invalid", "incompatible"):
393
+ for item in report.get(category, []):
394
+ print(f"{category.title()}: {item['file']}: {item['reason']}")
395
+
396
+
397
+ def cmd_index_build(args) -> int:
398
+ report = build_library_index(
399
+ args.directory,
400
+ recursive=args.recursive,
401
+ excludes=args.exclude or (),
402
+ includes=args.include or (),
403
+ )
404
+ if args.json:
405
+ print(json.dumps(report))
406
+ else:
407
+ _print_index_report(report)
408
+ return 0
409
+
410
+
411
+ def cmd_index_update(args) -> int:
412
+ report = update_library_index(args.directory)
413
+ if args.json:
414
+ print(json.dumps(report))
415
+ else:
416
+ _print_index_report(report)
417
+ return 0
418
+
419
+
420
+ def cmd_index_status(args) -> int:
421
+ report = library_index_status(args.directory)
422
+ if args.json:
423
+ print(json.dumps(report))
424
+ else:
425
+ _print_index_report(report)
426
+ return 0 if report.get("fresh") else 1
427
+
428
+
429
+ def cmd_validate(args) -> int:
430
+ doc = VeraDocument.open(args.file)
431
+ try:
432
+ report = doc.validate()
433
+ finally:
434
+ doc.close()
435
+ if args.json:
436
+ print(json.dumps({**report, **_archive_locator(args.file, doc)}))
437
+ return 0 if report["ok"] else 1
438
+ print(f"VERA validation: {'PASS' if report['ok'] else 'FAIL'}")
439
+ print(f"File: {args.file}")
440
+ counts = report["counts"]
441
+ print(f"Chunks: {counts['chunks']}")
442
+ print(f"Embeddings: {counts['embeddings']}")
443
+ print(f"Attachments: {counts['attachments']}")
444
+ print(f"FTS rows: {counts['fts_rows']}")
445
+ print(
446
+ f"Original document: {'present' if report['checks']['original_document_present'] else 'missing'}"
447
+ )
448
+ print(f"Issues: {len(report['issues'])}")
449
+ for issue in report["issues"]:
450
+ print(f"- {issue}")
451
+ return 0 if report["ok"] else 1
452
+
453
+
454
+ def cmd_export(args) -> int:
455
+ doc = VeraDocument.open(args.file)
456
+ try:
457
+ try:
458
+ path = export_source_document(doc, args.output)
459
+ except ValueError as exc:
460
+ if args.json:
461
+ print(json.dumps({"ok": False, "error": str(exc)}))
462
+ else:
463
+ print(f"Error: {exc}", file=sys.stderr)
464
+ return 1
465
+ source = get_source_document(doc)
466
+ finally:
467
+ doc.close()
468
+ if args.json:
469
+ print(
470
+ json.dumps(
471
+ {
472
+ "ok": True,
473
+ "output": path,
474
+ "filename": source.filename,
475
+ "mime_type": source.media_type,
476
+ "hash": source.checksum,
477
+ }
478
+ )
479
+ )
480
+ else:
481
+ print(f"Exported {path}")
482
+ return 0
483
+
484
+
485
+ def cmd_figures(args) -> int:
486
+ doc = VeraDocument.open(args.file)
487
+ try:
488
+ requested = list(args.asset_id) if args.asset_id else None
489
+ try:
490
+ if args.out_dir:
491
+ items = export_figures(
492
+ doc,
493
+ args.out_dir,
494
+ asset_ids=requested,
495
+ page_start=args.page_start,
496
+ page_end=args.page_end,
497
+ )
498
+ else:
499
+ items = figures(
500
+ doc,
501
+ page_start=args.page_start,
502
+ page_end=args.page_end,
503
+ attachment_ids=requested,
504
+ )
505
+ ensure_requested_figures(requested, items)
506
+ except ValueError as exc:
507
+ if args.json:
508
+ print(json.dumps({"ok": False, "error": str(exc)}))
509
+ else:
510
+ print(f"Error: {exc}", file=sys.stderr)
511
+ return 1
512
+ finally:
513
+ doc.close()
514
+ if args.json:
515
+ print(
516
+ json.dumps(
517
+ {
518
+ "ok": True,
519
+ "file": args.file,
520
+ "out_dir": args.out_dir,
521
+ "figures": items,
522
+ }
523
+ )
524
+ )
525
+ return 0
526
+ if args.out_dir:
527
+ noun = "figure" if len(items) == 1 else "figures"
528
+ print(f"Wrote {len(items)} {noun} to {args.out_dir}")
529
+ elif not items:
530
+ print("No figures")
531
+ return 0
532
+ for item in items:
533
+ location = item.get("path") or item.get("asset_id")
534
+ page = item.get("page_number")
535
+ caption = item.get("caption") or ""
536
+ line = f"{location} page {page}"
537
+ if caption:
538
+ line = f"{line} {caption}"
539
+ print(line)
540
+ return 0
541
+
542
+
543
+ def cmd_mcp(args) -> int:
544
+ try:
545
+ from vera_mcp import main as mcp_main
546
+ except ImportError:
547
+ print(
548
+ "vera mcp requires the optional MCP extra. "
549
+ "Install with: python -m pip install 'vera[mcp]>=0.3.2'",
550
+ file=sys.stderr,
551
+ )
552
+ return 2
553
+ return mcp_main()
554
+
555
+
556
+ def cmd_ocr_languages_list(args) -> int:
557
+ entries = describe_ocr_languages(args.language)
558
+ if args.json:
559
+ print(json.dumps({"ok": True, "languages": entries}))
560
+ return 0
561
+ for entry in entries:
562
+ state = "bundled" if entry["bundled"] else ("cached" if entry["cached"] else "not cached")
563
+ size = entry.get("size_bytes")
564
+ size_note = f", {size / (1024 * 1024):.1f} MB" if isinstance(size, int) else ""
565
+ downloadable = "" if entry["downloadable"] else " (manual TESSDATA_PREFIX install required)"
566
+ print(f"{entry['code']:<8} {entry['name']:<24} {state}{size_note}{downloadable}")
567
+ return 0
568
+
569
+
570
+ def cmd_ocr_languages_download(args) -> int:
571
+ downloaded: list[str] = []
572
+
573
+ def report_progress(code: str, downloaded_bytes: int, total_bytes: int) -> None:
574
+ if args.json or total_bytes <= 0:
575
+ return
576
+ percent = min(100, int(downloaded_bytes * 100 / total_bytes))
577
+ print(f"\r{code}: {percent}%", end="", file=sys.stderr, flush=True)
578
+
579
+ try:
580
+ cache_dir = download_ocr_language_data(args.language, progress=report_progress)
581
+ except (UnknownOCRLanguageError, OCRLanguageDownloadError) as exc:
582
+ if args.json:
583
+ print(json.dumps({"ok": False, "error": str(exc)}))
584
+ else:
585
+ print(str(exc), file=sys.stderr)
586
+ return 2
587
+ if not args.json:
588
+ print(file=sys.stderr) # newline after the last progress line
589
+ downloaded = [part.strip() for part in args.language.split("+") if part.strip()]
590
+ if args.json:
591
+ print(
592
+ json.dumps(
593
+ {
594
+ "ok": True,
595
+ "language": args.language,
596
+ "downloaded": downloaded,
597
+ "cache_dir": cache_dir,
598
+ }
599
+ )
600
+ )
601
+ else:
602
+ print(f"Downloaded {', '.join(downloaded)} into {cache_dir}")
603
+ return 0
604
+
605
+
606
+ def cmd_eval(args) -> int:
607
+ from vera_cli.evaluate import evaluate
608
+
609
+ summary = evaluate(args.file, args.queries, mode=args.mode, top_k=args.top_k)
610
+ all_ok = all(report["hits"] == report["total"] for report in summary["reports"])
611
+ if args.json:
612
+ print(json.dumps(summary))
613
+ return 0 if all_ok else 1
614
+ print(f"File: {summary['file']}")
615
+ print(f"Queries: {summary['queries_file']}")
616
+ for report in summary["reports"]:
617
+ print()
618
+ print(f"Mode: {report['mode']} (top_k={report['top_k']})")
619
+ for entry in report["queries"]:
620
+ status = f"HIT rank={entry['rank']}" if entry["hit"] else "MISS"
621
+ note = f" # {entry['note']}" if entry["note"] else ""
622
+ print(f" [{status:>10}] {entry['query']}{note}")
623
+ print(
624
+ f" Hits: {report['hits']}/{report['total']} ({report['hit_rate']:.0%}) MRR: {report['mrr']:.3f}"
625
+ )
626
+ return 0 if all_ok else 1
vera_cli/evaluate.py ADDED
@@ -0,0 +1,149 @@
1
+ """Retrieval quality evaluation for VERA files.
2
+
3
+ Runs a set of expected-answer queries against a VERA document and reports
4
+ hit rate and mean reciprocal rank (MRR) so chunking/embedding/search changes
5
+ can be compared objectively.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from dataclasses import dataclass, field
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from vera_doc import QueryResult, VeraDocument
16
+
17
+ MODES = ("semantic", "keyword", "hybrid")
18
+
19
+
20
+ @dataclass
21
+ class QueryCase:
22
+ query: str
23
+ expected_pages: list[int] = field(default_factory=list)
24
+ expected_terms: list[str] = field(default_factory=list)
25
+ note: str = ""
26
+
27
+ def __post_init__(self) -> None:
28
+ if not self.query or not str(self.query).strip():
29
+ raise ValueError("Query case is missing 'query' text")
30
+ if not self.expected_pages and not self.expected_terms:
31
+ raise ValueError(
32
+ f"Query case {self.query!r} needs expected_pages and/or expected_terms"
33
+ )
34
+
35
+ def matches(self, result: QueryResult) -> bool:
36
+ if self.expected_pages:
37
+ citation = result.citation
38
+ pages = {p for p in (citation.page_start, citation.page_end) if isinstance(p, int)}
39
+ if isinstance(citation.page_start, int) and isinstance(citation.page_end, int):
40
+ pages.update(range(citation.page_start, citation.page_end + 1))
41
+ if not pages.intersection(self.expected_pages):
42
+ return False
43
+ if self.expected_terms:
44
+ text = result.record.text.lower()
45
+ if not all(term.lower() in text for term in self.expected_terms):
46
+ return False
47
+ return True
48
+
49
+
50
+ def _normalize_case(raw: dict[str, Any]) -> QueryCase:
51
+ pages = raw.get("expected_pages") or []
52
+ if "expected_page" in raw and raw["expected_page"] is not None:
53
+ pages = list(pages) + [raw["expected_page"]]
54
+ return QueryCase(
55
+ query=str(raw.get("query", "")).strip(),
56
+ expected_pages=[int(p) for p in pages],
57
+ expected_terms=[str(t) for t in (raw.get("expected_terms") or [])],
58
+ note=str(raw.get("note", "")),
59
+ )
60
+
61
+
62
+ def load_queries(path: str) -> list[QueryCase]:
63
+ """Load query cases from a JSON (or YAML, if pyyaml is installed) file."""
64
+ file = Path(path)
65
+ if not file.exists():
66
+ raise FileNotFoundError(path)
67
+ text = file.read_text(encoding="utf-8")
68
+ if file.suffix.lower() in {".yaml", ".yml"}:
69
+ try:
70
+ import yaml
71
+ except ImportError as exc:
72
+ raise RuntimeError(
73
+ "YAML query files require pyyaml: pip install pyyaml (or use a .json file)"
74
+ ) from exc
75
+ data = yaml.safe_load(text)
76
+ else:
77
+ data = json.loads(text)
78
+ if not isinstance(data, list) or not data:
79
+ raise ValueError("Query file must contain a non-empty list of query cases")
80
+ return [_normalize_case(item) for item in data]
81
+
82
+
83
+ def evaluate_document(
84
+ doc: VeraDocument,
85
+ cases: list[QueryCase],
86
+ mode: str = "hybrid",
87
+ top_k: int = 5,
88
+ ) -> dict[str, Any]:
89
+ """Evaluate one search mode against all query cases."""
90
+ per_query = []
91
+ reciprocal_ranks = []
92
+ hits = 0
93
+ for case in cases:
94
+ results = doc.search(text=case.query, mode=mode, top_k=top_k) # type: ignore[arg-type]
95
+ rank = None
96
+ for idx, result in enumerate(results, start=1):
97
+ if case.matches(result):
98
+ rank = idx
99
+ break
100
+ hit = rank is not None
101
+ hits += int(hit)
102
+ reciprocal_ranks.append(1.0 / rank if rank else 0.0)
103
+ per_query.append(
104
+ {
105
+ "query": case.query,
106
+ "note": case.note,
107
+ "hit": hit,
108
+ "rank": rank,
109
+ "top_score": results[0].score if results else None,
110
+ "top_page": (results[0].citation.page_start if results else None),
111
+ }
112
+ )
113
+ total = len(cases)
114
+ return {
115
+ "mode": mode,
116
+ "top_k": top_k,
117
+ "total": total,
118
+ "hits": hits,
119
+ "hit_rate": hits / total if total else 0.0,
120
+ "mrr": sum(reciprocal_ranks) / total if total else 0.0,
121
+ "queries": per_query,
122
+ }
123
+
124
+
125
+ def evaluate(
126
+ vera_path: str,
127
+ queries_path: str,
128
+ mode: str = "hybrid",
129
+ top_k: int = 5,
130
+ ) -> dict[str, Any]:
131
+ """Evaluate a VERA file against a query file.
132
+
133
+ mode may be one of semantic/keyword/hybrid, or "all" to compare every mode.
134
+ """
135
+ cases = load_queries(queries_path)
136
+ modes = list(MODES) if mode == "all" else [mode]
137
+ for m in modes:
138
+ if m not in MODES:
139
+ raise ValueError(f"mode must be one of {', '.join(MODES)}, or 'all'")
140
+ doc = VeraDocument.open(vera_path)
141
+ try:
142
+ reports = [evaluate_document(doc, cases, mode=m, top_k=top_k) for m in modes]
143
+ finally:
144
+ doc.close()
145
+ return {
146
+ "file": vera_path,
147
+ "queries_file": queries_path,
148
+ "reports": reports,
149
+ }
vera_cli/main.py ADDED
@@ -0,0 +1,405 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+
5
+ from .commands import (
6
+ cmd_convert,
7
+ cmd_eval,
8
+ cmd_export,
9
+ cmd_figures,
10
+ cmd_get,
11
+ cmd_index_build,
12
+ cmd_index_status,
13
+ cmd_index_update,
14
+ cmd_inspect,
15
+ cmd_mcp,
16
+ cmd_ocr_languages_download,
17
+ cmd_ocr_languages_list,
18
+ cmd_search,
19
+ cmd_validate,
20
+ str_to_bool,
21
+ )
22
+
23
+
24
+ def non_negative_int(value: str) -> int:
25
+ parsed = int(value)
26
+ if parsed < 0:
27
+ raise argparse.ArgumentTypeError("must be non-negative")
28
+ return parsed
29
+
30
+
31
+ def positive_int(value: str) -> int:
32
+ parsed = int(value)
33
+ if parsed <= 0:
34
+ raise argparse.ArgumentTypeError("must be positive")
35
+ return parsed
36
+
37
+
38
+ def pipeline_option(value: str) -> tuple[str, str]:
39
+ """Parse ``KEY=VALUE`` pairs for ``--pipeline-option`` / ``--embedder-option``."""
40
+ if "=" not in value:
41
+ raise argparse.ArgumentTypeError("must be KEY=VALUE")
42
+ key, raw = value.split("=", 1)
43
+ key = key.strip()
44
+ if not key:
45
+ raise argparse.ArgumentTypeError("option key must be non-empty")
46
+ return key, raw
47
+
48
+
49
+ embedder_option = pipeline_option
50
+
51
+
52
+ def build_parser() -> argparse.ArgumentParser:
53
+ """Build the ``vera`` command-line argument parser."""
54
+ parser = argparse.ArgumentParser(
55
+ prog="vera", description="Vector-Embedded Retrieval Archive CLI"
56
+ )
57
+ sub = parser.add_subparsers(dest="command", required=True)
58
+
59
+ convert_p = sub.add_parser(
60
+ "convert",
61
+ help="Convert a PDF, Markdown, Office/HTML (Docling extra), or a directory of sources to VERA files",
62
+ )
63
+ convert_p.add_argument(
64
+ "input",
65
+ help="PDF, Markdown, Office/HTML (with Docling), or directory containing supported source files",
66
+ )
67
+ convert_p.add_argument(
68
+ "output", nargs="?", default=None, help="Output .vera path for a single source file"
69
+ )
70
+ convert_p.add_argument(
71
+ "--model",
72
+ default="hashing",
73
+ help=(
74
+ "Embedding model spec: provider:model-id "
75
+ "(e.g. hashing, hashing:vera-hashing-384, "
76
+ "sentence-transformers:all-MiniLM-L6-v2, "
77
+ "openai:text-embedding-3-small). "
78
+ "Unknown providers exit with an error."
79
+ ),
80
+ )
81
+ convert_p.add_argument(
82
+ "--parser",
83
+ default=None,
84
+ help=(
85
+ "Ingest pipeline spec: provider[:variant] "
86
+ "(omitted: choose from the file extension; PDF -> pymupdf, "
87
+ "Markdown -> markdown, DOCX/PPTX/XLSX/HTML -> docling when "
88
+ "vera-ingest-docling is installed; optional docling or docling:hybrid). "
89
+ "Unknown providers exit with an error."
90
+ ),
91
+ )
92
+ convert_p.add_argument(
93
+ "--chunk-size",
94
+ type=int,
95
+ default=None,
96
+ help=(
97
+ "Compatibility alias for pipeline chunk_size (PyMuPDF: whitespace-split "
98
+ "words; Docling: whitespace tokens). Omitted uses the selected "
99
+ "pipeline's default."
100
+ ),
101
+ )
102
+ convert_p.add_argument(
103
+ "--overlap",
104
+ type=int,
105
+ default=None,
106
+ help=(
107
+ "Compatibility alias for pipeline overlap (PyMuPDF: whitespace-split "
108
+ "words; not forwarded to Docling). Omitted uses the selected "
109
+ "pipeline's default."
110
+ ),
111
+ )
112
+ convert_p.add_argument("--store-original", type=str_to_bool, default=True)
113
+ convert_p.add_argument(
114
+ "--ocr",
115
+ dest="ocr_mode",
116
+ choices=["auto", "off", "force"],
117
+ default=None,
118
+ help=(
119
+ "OCR mode: auto scans image-based pages, off disables OCR, force OCRs "
120
+ "every page. Compatibility alias; omitted uses the selected pipeline's default."
121
+ ),
122
+ )
123
+ convert_p.add_argument(
124
+ "--ocr-language",
125
+ default=None,
126
+ help=(
127
+ "Tesseract language code (PyMuPDF compatibility alias; not "
128
+ "forwarded to Docling/RapidOCR). Omitted uses the selected "
129
+ "pipeline's default."
130
+ ),
131
+ )
132
+ convert_p.add_argument(
133
+ "--ocr-dpi",
134
+ type=positive_int,
135
+ default=None,
136
+ help=(
137
+ "OCR render resolution (compatibility alias). Omitted uses the "
138
+ "selected pipeline's default."
139
+ ),
140
+ )
141
+ convert_p.add_argument(
142
+ "--ocr-allow-download",
143
+ action="store_true",
144
+ help=(
145
+ "Fetch missing --ocr-language Tesseract data from VERA's curated, "
146
+ "checksum-verified registry and cache it locally (PyMuPDF only; "
147
+ "compatibility alias). See 'vera ocr-languages list'."
148
+ ),
149
+ )
150
+ convert_p.add_argument(
151
+ "--pipeline-option",
152
+ dest="pipeline_options",
153
+ action="append",
154
+ type=pipeline_option,
155
+ default=[],
156
+ metavar="KEY=VALUE",
157
+ help=(
158
+ "Provider-owned ingest option (repeatable). Overrides compatibility "
159
+ "aliases for the same key when the selected pipeline accepts it."
160
+ ),
161
+ )
162
+ convert_p.add_argument(
163
+ "--embedder-option",
164
+ dest="embedder_options",
165
+ action="append",
166
+ type=embedder_option,
167
+ default=[],
168
+ metavar="KEY=VALUE",
169
+ help=(
170
+ "Provider-owned embedding option (repeatable). Forwarded to the "
171
+ "selected embedding provider (for example --embedder-option "
172
+ "batch_size=64 or --embedder-option dimension=256)."
173
+ ),
174
+ )
175
+ convert_p.add_argument(
176
+ "--metadata",
177
+ dest="metadata",
178
+ action="append",
179
+ type=pipeline_option,
180
+ default=[],
181
+ metavar="KEY=VALUE",
182
+ help=(
183
+ "Caller metadata stamped onto the archive and every chunk "
184
+ "(repeatable). Reserved ingest, citation, and format keys are rejected."
185
+ ),
186
+ )
187
+ convert_p.add_argument(
188
+ "--recursive",
189
+ action="store_true",
190
+ help="Discover supported source files recursively when input is a directory",
191
+ )
192
+ convert_p.add_argument(
193
+ "--overwrite",
194
+ action="store_true",
195
+ help="Overwrite existing .vera files during directory conversion",
196
+ )
197
+ convert_p.add_argument("--json", action="store_true", help="Emit machine-readable JSON")
198
+ convert_p.set_defaults(func=cmd_convert)
199
+
200
+ inspect_p = sub.add_parser("inspect", help="Inspect a VERA file")
201
+ inspect_p.add_argument("file")
202
+ inspect_p.add_argument("--json", action="store_true", help="Emit machine-readable JSON")
203
+ inspect_p.set_defaults(func=cmd_inspect)
204
+
205
+ get_p = sub.add_parser("get", help="Fetch one chunk by id from a VERA file")
206
+ get_p.add_argument("file", help="Path to a .vera file")
207
+ get_p.add_argument("chunk_id", help="Exact chunk id (case-sensitive)")
208
+ get_p.add_argument("--json", action="store_true", help="Emit machine-readable JSON")
209
+ get_p.add_argument(
210
+ "--figures", action="store_true", help="Include figure metadata/captions in --json output"
211
+ )
212
+ get_p.add_argument(
213
+ "--regions",
214
+ action="store_true",
215
+ help="Include page/bbox highlight regions in --json output",
216
+ )
217
+ get_p.set_defaults(func=cmd_get)
218
+
219
+ search_p = sub.add_parser(
220
+ "search", help="Search a VERA file, or a directory of VERA files as one corpus"
221
+ )
222
+ search_p.add_argument("file", help="Path to a .vera file or a directory containing .vera files")
223
+ search_p.add_argument("query")
224
+ search_p.add_argument("--mode", choices=["semantic", "keyword", "hybrid"], default="hybrid")
225
+ search_p.add_argument("--top-k", type=non_negative_int, default=10)
226
+ search_p.add_argument(
227
+ "--context-chunks",
228
+ type=non_negative_int,
229
+ default=0,
230
+ help="Include N chunks before and after each search result in JSON output",
231
+ )
232
+ search_p.add_argument("--json", action="store_true", help="Emit machine-readable JSON")
233
+ search_p.add_argument(
234
+ "--figures", action="store_true", help="Include figure metadata/captions in --json output"
235
+ )
236
+ search_p.add_argument(
237
+ "--recursive",
238
+ action="store_true",
239
+ help="Search nested .vera files when the target is an unindexed directory",
240
+ )
241
+ search_p.add_argument(
242
+ "--exclude",
243
+ action="append",
244
+ default=None,
245
+ help="Exclude a relative path or name pattern (repeatable)",
246
+ )
247
+ search_p.add_argument(
248
+ "--include",
249
+ action="append",
250
+ default=None,
251
+ help="Include only relative paths matching this pattern (repeatable; directory search)",
252
+ )
253
+ search_p.add_argument(
254
+ "--where",
255
+ dest="where",
256
+ action="append",
257
+ type=pipeline_option,
258
+ default=[],
259
+ metavar="KEY=VALUE",
260
+ help=(
261
+ "Filter stored metadata before top_k (repeatable). Distinct keys are AND; "
262
+ "comma-separated values are IN."
263
+ ),
264
+ )
265
+ search_p.add_argument(
266
+ "--regions",
267
+ action="store_true",
268
+ help="Include page/bbox highlight regions for each result in --json output",
269
+ )
270
+ search_p.set_defaults(func=cmd_search)
271
+
272
+ index_p = sub.add_parser("index", help="Build and manage a local VERA library index")
273
+ index_sub = index_p.add_subparsers(dest="index_command", required=True)
274
+
275
+ index_build_p = index_sub.add_parser("build", help="Build a library index")
276
+ index_build_p.add_argument("directory", help="Root directory containing .vera files")
277
+ index_build_p.add_argument(
278
+ "--recursive", action="store_true", help="Discover .vera files recursively"
279
+ )
280
+ index_build_p.add_argument(
281
+ "--exclude",
282
+ action="append",
283
+ default=None,
284
+ help="Exclude a relative path or name pattern (repeatable)",
285
+ )
286
+ index_build_p.add_argument(
287
+ "--include",
288
+ action="append",
289
+ default=None,
290
+ help="Include only relative paths matching this pattern (repeatable)",
291
+ )
292
+ index_build_p.add_argument("--json", action="store_true", help="Emit machine-readable JSON")
293
+ index_build_p.set_defaults(func=cmd_index_build)
294
+
295
+ index_update_p = index_sub.add_parser("update", help="Update an existing library index")
296
+ index_update_p.add_argument("directory", help="Indexed library root")
297
+ index_update_p.add_argument("--json", action="store_true", help="Emit machine-readable JSON")
298
+ index_update_p.set_defaults(func=cmd_index_update)
299
+
300
+ index_status_p = index_sub.add_parser("status", help="Show library index freshness")
301
+ index_status_p.add_argument("directory", help="Indexed library root")
302
+ index_status_p.add_argument("--json", action="store_true", help="Emit machine-readable JSON")
303
+ index_status_p.set_defaults(func=cmd_index_status)
304
+
305
+ validate_p = sub.add_parser("validate", help="Validate a VERA file")
306
+ validate_p.add_argument("file")
307
+ validate_p.add_argument("--json", action="store_true", help="Emit machine-readable JSON")
308
+ validate_p.set_defaults(func=cmd_validate)
309
+
310
+ export_p = sub.add_parser("export", help="Export the original source document from a VERA file")
311
+ export_p.add_argument("file")
312
+ export_p.add_argument(
313
+ "output",
314
+ nargs="?",
315
+ default=None,
316
+ help="Output path or directory (default: stored filename)",
317
+ )
318
+ export_p.add_argument("--json", action="store_true", help="Emit machine-readable JSON")
319
+ export_p.set_defaults(func=cmd_export)
320
+
321
+ figures_p = sub.add_parser("figures", help="List or export figure attachments from a VERA file")
322
+ figures_p.add_argument("file", help="Path to a .vera file")
323
+ figures_p.add_argument(
324
+ "--out-dir",
325
+ default=None,
326
+ help="Write figure image files to this directory and include paths in JSON",
327
+ )
328
+ figures_p.add_argument(
329
+ "--asset-id",
330
+ action="append",
331
+ default=None,
332
+ help="Limit to one figure attachment id (repeatable)",
333
+ )
334
+ figures_p.add_argument(
335
+ "--page-start",
336
+ type=int,
337
+ default=None,
338
+ help="Include figures on or after this 1-based page number",
339
+ )
340
+ figures_p.add_argument(
341
+ "--page-end",
342
+ type=int,
343
+ default=None,
344
+ help="Include figures on or before this 1-based page number",
345
+ )
346
+ figures_p.add_argument("--json", action="store_true", help="Emit machine-readable JSON")
347
+ figures_p.set_defaults(func=cmd_figures)
348
+
349
+ mcp_p = sub.add_parser(
350
+ "mcp", help="Run the MCP server (stdio) exposing VERA tools to AI agents"
351
+ )
352
+ mcp_p.set_defaults(func=cmd_mcp)
353
+
354
+ ocr_languages_p = sub.add_parser(
355
+ "ocr-languages",
356
+ help="List or download Tesseract OCR language data used by the pymupdf parser",
357
+ )
358
+ ocr_languages_sub = ocr_languages_p.add_subparsers(dest="ocr_languages_command", required=True)
359
+
360
+ ocr_languages_list_p = ocr_languages_sub.add_parser(
361
+ "list", help="List bundled, cached, and downloadable OCR language codes"
362
+ )
363
+ ocr_languages_list_p.add_argument(
364
+ "language",
365
+ nargs="?",
366
+ default=None,
367
+ help="Limit to specific '+'-joined codes (e.g. eng+fra); defaults to every known code",
368
+ )
369
+ ocr_languages_list_p.add_argument(
370
+ "--json", action="store_true", help="Emit machine-readable JSON"
371
+ )
372
+ ocr_languages_list_p.set_defaults(func=cmd_ocr_languages_list)
373
+
374
+ ocr_languages_download_p = ocr_languages_sub.add_parser(
375
+ "download", help="Download one or more Tesseract language codes into the local cache"
376
+ )
377
+ ocr_languages_download_p.add_argument(
378
+ "language", help="'+'-joined Tesseract language code(s) to fetch, e.g. fra or fra+deu"
379
+ )
380
+ ocr_languages_download_p.add_argument(
381
+ "--json", action="store_true", help="Emit machine-readable JSON"
382
+ )
383
+ ocr_languages_download_p.set_defaults(func=cmd_ocr_languages_download)
384
+
385
+ eval_p = sub.add_parser("eval", help="Evaluate retrieval quality against a query file")
386
+ eval_p.add_argument("file")
387
+ eval_p.add_argument("queries", help="JSON or YAML file with query cases")
388
+ eval_p.add_argument("--mode", choices=["semantic", "keyword", "hybrid", "all"], default="all")
389
+ eval_p.add_argument("--top-k", type=non_negative_int, default=5)
390
+ eval_p.add_argument("--json", action="store_true", help="Emit machine-readable JSON")
391
+ eval_p.set_defaults(func=cmd_eval)
392
+ return parser
393
+
394
+
395
+ def main(argv: list[str] | None = None) -> int:
396
+ """Run the ``vera`` CLI and return a process exit code.
397
+
398
+ Args:
399
+ argv: Optional argument list. Defaults to ``sys.argv[1:]``.
400
+
401
+ Returns:
402
+ Integer exit code (0 on success).
403
+ """
404
+ args = build_parser().parse_args(argv)
405
+ return args.func(args)