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.
- vera-0.3.2.dist-info/METADATA +58 -0
- vera-0.3.2.dist-info/RECORD +9 -0
- vera-0.3.2.dist-info/WHEEL +4 -0
- vera-0.3.2.dist-info/entry_points.txt +2 -0
- vera_cli/__init__.py +4 -0
- vera_cli/__main__.py +3 -0
- vera_cli/commands.py +626 -0
- vera_cli/evaluate.py +149 -0
- vera_cli/main.py +405 -0
|
@@ -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,,
|
vera_cli/__init__.py
ADDED
vera_cli/__main__.py
ADDED
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)
|