statcatlib 0.8.0__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.
- statcatlib/__init__.py +187 -0
- statcatlib/bundle.py +565 -0
- statcatlib/cli/__init__.py +546 -0
- statcatlib/expr.py +167 -0
- statcatlib/model/__init__.py +56 -0
- statcatlib/model/agents.py +32 -0
- statcatlib/model/catalog.py +71 -0
- statcatlib/model/classification.py +19 -0
- statcatlib/model/dataset.py +198 -0
- statcatlib/model/distribution.py +50 -0
- statcatlib/model/licence.py +18 -0
- statcatlib/model/quality.py +31 -0
- statcatlib/model/skos.py +141 -0
- statcatlib/model/spatial.py +19 -0
- statcatlib/model/temporal.py +33 -0
- statcatlib/model/types.py +5 -0
- statcatlib/py.typed +0 -0
- statcatlib/query.py +924 -0
- statcatlib/sources/__init__.py +13 -0
- statcatlib/sources/ckan.py +490 -0
- statcatlib/sources/sdmx.py +321 -0
- statcatlib/store/__init__.py +38 -0
- statcatlib/store/lance.py +1494 -0
- statcatlib/store/row.py +30 -0
- statcatlib/store/schema.py +403 -0
- statcatlib/store/scopes.py +1419 -0
- statcatlib/store/sentinel.py +99 -0
- statcatlib/store/store.py +6836 -0
- statcatlib/store/xhtml.py +206 -0
- statcatlib/urn.py +108 -0
- statcatlib-0.8.0.dist-info/METADATA +429 -0
- statcatlib-0.8.0.dist-info/RECORD +35 -0
- statcatlib-0.8.0.dist-info/WHEEL +4 -0
- statcatlib-0.8.0.dist-info/entry_points.txt +3 -0
- statcatlib-0.8.0.dist-info/licenses/LICENSE +180 -0
|
@@ -0,0 +1,546 @@
|
|
|
1
|
+
"""statcatlib command-line interface.
|
|
2
|
+
|
|
3
|
+
Top-level entry point installed as the ``statcatlib`` console script. Today's
|
|
4
|
+
single command is `build_index`, which rebuilds a bundle's
|
|
5
|
+
``search/`` LanceDB index. The CLI is intentionally thin — all real work
|
|
6
|
+
lives in `statcatlib.Catalog.build_index`.
|
|
7
|
+
|
|
8
|
+
Requires the ``[search]`` extra (typer + sentence-transformers + lancedb).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
import os
|
|
15
|
+
import sys
|
|
16
|
+
import time
|
|
17
|
+
import warnings
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
import typer
|
|
21
|
+
from rich.console import Console
|
|
22
|
+
from rich.progress import (
|
|
23
|
+
BarColumn,
|
|
24
|
+
MofNCompleteColumn,
|
|
25
|
+
Progress,
|
|
26
|
+
SpinnerColumn,
|
|
27
|
+
TaskID,
|
|
28
|
+
TaskProgressColumn,
|
|
29
|
+
TextColumn,
|
|
30
|
+
TimeElapsedColumn,
|
|
31
|
+
TimeRemainingColumn,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
import statcatlib as sc
|
|
35
|
+
from statcatlib.store.store import ASYMMETRIC_MODEL_PREFIXES
|
|
36
|
+
|
|
37
|
+
__all__ = ["app", "main"]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
_DEFAULT_MODEL = "intfloat/multilingual-e5-base"
|
|
41
|
+
|
|
42
|
+
_ACCEPTED_CODELIST_MODES = frozenset({"fts_only"})
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _parse_codelist_policy(raw: str) -> sc.CodelistIndexPolicy:
|
|
46
|
+
"""Parse a ``URN[,URN...]=mode`` string into a CodelistIndexPolicy."""
|
|
47
|
+
urns_part, sep, mode = raw.rpartition("=")
|
|
48
|
+
if not sep or not mode.strip() or not urns_part.strip():
|
|
49
|
+
msg = f"Expected 'URN[,URN...]=mode', got {raw!r}"
|
|
50
|
+
raise typer.BadParameter(msg)
|
|
51
|
+
mode = mode.strip()
|
|
52
|
+
urns = tuple(u.strip() for u in urns_part.split(",") if u.strip())
|
|
53
|
+
if not urns:
|
|
54
|
+
msg = f"No URNs in codelist policy {raw!r}"
|
|
55
|
+
raise typer.BadParameter(msg)
|
|
56
|
+
if mode not in _ACCEPTED_CODELIST_MODES:
|
|
57
|
+
accepted = ", ".join(sorted(_ACCEPTED_CODELIST_MODES))
|
|
58
|
+
msg = f"Unknown codelist policy mode {mode!r}; accepted: {accepted}"
|
|
59
|
+
raise typer.BadParameter(msg)
|
|
60
|
+
return sc.CodelistIndexPolicy(urns=urns, mode=mode)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _parse_reference_metadata_blend(raw: str) -> sc.ReferenceMetadataBlend:
|
|
64
|
+
"""Parse an ``ATTRIBUTE_PATH[:max_chars]`` string into a blend.
|
|
65
|
+
|
|
66
|
+
``max_chars`` is optional and defaults to ``ReferenceMetadataBlend``'s
|
|
67
|
+
own default. Examples: ``"FOOTNOTE_TEXT"`` or ``"FOOTNOTE_TEXT:240"``.
|
|
68
|
+
"""
|
|
69
|
+
path, sep, max_chars_part = raw.partition(":")
|
|
70
|
+
path = path.strip()
|
|
71
|
+
if not path:
|
|
72
|
+
msg = f"Expected 'ATTRIBUTE_PATH[:max_chars]', got {raw!r}"
|
|
73
|
+
raise typer.BadParameter(msg)
|
|
74
|
+
if not sep:
|
|
75
|
+
return sc.ReferenceMetadataBlend(attribute_path=path)
|
|
76
|
+
try:
|
|
77
|
+
max_chars = int(max_chars_part.strip())
|
|
78
|
+
except ValueError:
|
|
79
|
+
msg = f"max_chars must be an integer in reference-metadata blend {raw!r}"
|
|
80
|
+
raise typer.BadParameter(msg) from None
|
|
81
|
+
if max_chars <= 0:
|
|
82
|
+
msg = f"max_chars must be positive in reference-metadata blend {raw!r}"
|
|
83
|
+
raise typer.BadParameter(msg)
|
|
84
|
+
return sc.ReferenceMetadataBlend(attribute_path=path, max_chars=max_chars)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
app = typer.Typer(
|
|
88
|
+
name="statcat",
|
|
89
|
+
help="Operational tools for statcatlib Catalog bundles.",
|
|
90
|
+
no_args_is_help=True,
|
|
91
|
+
add_completion=False,
|
|
92
|
+
)
|
|
93
|
+
bundle_app = typer.Typer(
|
|
94
|
+
name="bundle",
|
|
95
|
+
help="Manage statcatlib bundles (inspect and rebind data paths, emit ATTACH commands).",
|
|
96
|
+
no_args_is_help=True,
|
|
97
|
+
add_completion=False,
|
|
98
|
+
)
|
|
99
|
+
app.add_typer(bundle_app, name="bundle")
|
|
100
|
+
|
|
101
|
+
from statcatlib.store.store import EmbeddingModel
|
|
102
|
+
|
|
103
|
+
console = Console()
|
|
104
|
+
log = logging.getLogger("statcatlib.cli")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _require_bundle(bundle: Path) -> Path:
|
|
108
|
+
"""Validate that ``bundle`` looks like a statcatlib Catalog bundle."""
|
|
109
|
+
if not (bundle / "meta.duckdb").is_file():
|
|
110
|
+
console.print(
|
|
111
|
+
f"[red]Not a statcatlib Catalog bundle:[/] {bundle}\n"
|
|
112
|
+
f"Expected to find [bold]meta.duckdb[/] at the top level."
|
|
113
|
+
)
|
|
114
|
+
raise typer.Exit(code=1)
|
|
115
|
+
if not (bundle / "lake.ducklake").is_file():
|
|
116
|
+
console.print(
|
|
117
|
+
f"[red]Not a statcatlib Catalog bundle:[/] {bundle}\n"
|
|
118
|
+
f"Expected to find [bold]lake.ducklake[/] at the top level."
|
|
119
|
+
)
|
|
120
|
+
raise typer.Exit(code=1)
|
|
121
|
+
return bundle
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@bundle_app.command("rebind")
|
|
125
|
+
def bundle_rebind(
|
|
126
|
+
bundle: Path = typer.Argument(
|
|
127
|
+
...,
|
|
128
|
+
help="Path to a statcatlib Catalog bundle (the folder containing meta.duckdb).",
|
|
129
|
+
exists=True,
|
|
130
|
+
file_okay=False,
|
|
131
|
+
dir_okay=True,
|
|
132
|
+
),
|
|
133
|
+
to: str | None = typer.Option(
|
|
134
|
+
None,
|
|
135
|
+
"--to",
|
|
136
|
+
help=(
|
|
137
|
+
"Where the observation parquet lives. A local directory or a remote URI "
|
|
138
|
+
"(abfss://, s3://, gs://). Defaults to <bundle>/parquet/."
|
|
139
|
+
),
|
|
140
|
+
),
|
|
141
|
+
scratch_dir: Path | None = typer.Option(
|
|
142
|
+
None,
|
|
143
|
+
"--scratch-dir",
|
|
144
|
+
help=(
|
|
145
|
+
"Fallback directory when the lake file is on a read-only mount. "
|
|
146
|
+
"The lake file is byte-copied there and rewritten in place."
|
|
147
|
+
),
|
|
148
|
+
),
|
|
149
|
+
force: bool = typer.Option(
|
|
150
|
+
False,
|
|
151
|
+
"--force",
|
|
152
|
+
help="Permit rewriting a remote data_path to a local one, discarding the only record of where the data lives.",
|
|
153
|
+
),
|
|
154
|
+
) -> None:
|
|
155
|
+
"""Point a bundle at where its observation parquet actually lives.
|
|
156
|
+
|
|
157
|
+
A deliberate, once-per-deploy act — the init-container step for a
|
|
158
|
+
bundle synced from object storage, whose stamp nobody set at publish
|
|
159
|
+
time. Opening a bundle does not do this; ``statcat bundle explain``
|
|
160
|
+
shows what an open would decide without changing anything.
|
|
161
|
+
|
|
162
|
+
Idempotent and read-first: a bundle already pointing at the target
|
|
163
|
+
completes with no writes, no lock contention, and exits 0.
|
|
164
|
+
"""
|
|
165
|
+
from statcatlib.bundle import BundleDataPathError, normalize_lake_data_path # noqa: PLC0415
|
|
166
|
+
|
|
167
|
+
bundle = _require_bundle(bundle.resolve())
|
|
168
|
+
target = to if to is not None else f"{bundle / 'parquet'}/"
|
|
169
|
+
|
|
170
|
+
try:
|
|
171
|
+
result = normalize_lake_data_path(
|
|
172
|
+
bundle / "lake.ducklake",
|
|
173
|
+
target,
|
|
174
|
+
scratch_dir=scratch_dir,
|
|
175
|
+
force=force,
|
|
176
|
+
)
|
|
177
|
+
except BundleDataPathError as exc:
|
|
178
|
+
console.print(f"[red]refused[/] — {exc}")
|
|
179
|
+
raise typer.Exit(code=1) from exc
|
|
180
|
+
|
|
181
|
+
if result == bundle / "lake.ducklake":
|
|
182
|
+
console.print(f"[green]ok[/] — data_path = [bold]{target}[/]")
|
|
183
|
+
else:
|
|
184
|
+
console.print(f"[yellow]copied[/] read-only lake to [bold]{result}[/] with data_path = [bold]{target}[/]")
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@bundle_app.command("explain")
|
|
188
|
+
def bundle_explain(
|
|
189
|
+
bundle: Path = typer.Argument(
|
|
190
|
+
...,
|
|
191
|
+
help="Path to a statcatlib Catalog bundle (the folder containing meta.duckdb).",
|
|
192
|
+
exists=True,
|
|
193
|
+
file_okay=False,
|
|
194
|
+
dir_okay=True,
|
|
195
|
+
),
|
|
196
|
+
) -> None:
|
|
197
|
+
"""Report where a bundle says its data is, and what an open would do.
|
|
198
|
+
|
|
199
|
+
Read-only — takes no write lock and changes nothing. Run it in an
|
|
200
|
+
init container before serving: a stale data_path does not fail at
|
|
201
|
+
ATTACH, it fails on the first query that reads observations, so this
|
|
202
|
+
is what turns a latent runtime failure into a startup one.
|
|
203
|
+
|
|
204
|
+
Exits 1 when the layout is ambiguous, so it composes with ``&&``.
|
|
205
|
+
"""
|
|
206
|
+
from statcatlib.bundle import inspect_bundle # noqa: PLC0415
|
|
207
|
+
|
|
208
|
+
bundle = _require_bundle(bundle.resolve())
|
|
209
|
+
info = inspect_bundle(bundle)
|
|
210
|
+
|
|
211
|
+
console.print(f"bundle [bold]{info.bundle}[/]")
|
|
212
|
+
console.print(f"data_path [bold]{info.stamp}[/]")
|
|
213
|
+
console.print(f"remote {info.is_remote}")
|
|
214
|
+
console.print(f"resolves {'not checked (remote)' if info.stamp_resolves is None else info.stamp_resolves}")
|
|
215
|
+
console.print(f"parquet/ {info.colocated_exists}")
|
|
216
|
+
console.print(f"data files {info.data_files}")
|
|
217
|
+
attach = "honour the embedded data_path" if info.target is None else info.target
|
|
218
|
+
console.print(f"would open {attach}")
|
|
219
|
+
console.print(f"would write {info.will_write}")
|
|
220
|
+
|
|
221
|
+
ambiguous = info.target is None and info.will_write is False and info.verdict.startswith("ambiguous")
|
|
222
|
+
colour = "red" if ambiguous else "green"
|
|
223
|
+
console.print(f"[{colour}]{info.verdict}[/]")
|
|
224
|
+
if ambiguous:
|
|
225
|
+
raise typer.Exit(code=1)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
@bundle_app.command("attach-cmd")
|
|
229
|
+
def bundle_attach_cmd(
|
|
230
|
+
bundle: Path = typer.Argument(
|
|
231
|
+
...,
|
|
232
|
+
help="Path to a statcatlib Catalog bundle (the folder containing meta.duckdb).",
|
|
233
|
+
exists=True,
|
|
234
|
+
file_okay=False,
|
|
235
|
+
dir_okay=True,
|
|
236
|
+
),
|
|
237
|
+
data_path: str | None = typer.Option(
|
|
238
|
+
None,
|
|
239
|
+
"--data-path",
|
|
240
|
+
help="Override DATA_PATH in the printed ATTACH (defaults to <bundle>/parquet/).",
|
|
241
|
+
),
|
|
242
|
+
read_only: bool = typer.Option(
|
|
243
|
+
False,
|
|
244
|
+
"--read-only",
|
|
245
|
+
help="Include READ_ONLY in the ATTACH options.",
|
|
246
|
+
),
|
|
247
|
+
) -> None:
|
|
248
|
+
"""Print the ``ATTACH`` statement for ad-hoc ``duckdb`` CLI use."""
|
|
249
|
+
bundle = _require_bundle(bundle.resolve())
|
|
250
|
+
target = data_path if data_path is not None else f"{bundle / 'parquet'}/"
|
|
251
|
+
options = f"DATA_PATH '{target}'"
|
|
252
|
+
if read_only:
|
|
253
|
+
options += ", READ_ONLY"
|
|
254
|
+
# Plain stdout — rich.Console wraps lines, but this is meant to be
|
|
255
|
+
# piped into the duckdb CLI or copy-pasted verbatim.
|
|
256
|
+
typer.echo(f"ATTACH 'ducklake:{bundle / 'lake.ducklake'}' AS lake ({options});")
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
@app.callback()
|
|
260
|
+
def _main() -> None:
|
|
261
|
+
"""Force multi-command mode.
|
|
262
|
+
|
|
263
|
+
Without an explicit callback, Typer treats a single-command app as
|
|
264
|
+
that command and the command name becomes implicit — so
|
|
265
|
+
``statcat build-index <bundle>`` would parse ``build-index`` as the
|
|
266
|
+
BUNDLE argument. This empty callback keeps the subcommand seam in
|
|
267
|
+
place even while ``build-index`` is the only command.
|
|
268
|
+
"""
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _resolve_prefixes(
|
|
272
|
+
model: str,
|
|
273
|
+
document_prefix: str | None,
|
|
274
|
+
query_prefix: str | None,
|
|
275
|
+
) -> tuple[str, str]:
|
|
276
|
+
"""Decide which document/query prefixes to use.
|
|
277
|
+
|
|
278
|
+
Explicit CLI overrides win. When omitted, look up the model in
|
|
279
|
+
`ASYMMETRIC_MODEL_PREFIXES`; an unrecognized model gets empty prefixes
|
|
280
|
+
(correct for symmetric encoders, harmless for ones that don't care).
|
|
281
|
+
|
|
282
|
+
The table used to live here, which left the Python API — the interface
|
|
283
|
+
#81 was reported against — with no such default at all.
|
|
284
|
+
"""
|
|
285
|
+
default_doc, default_q = ASYMMETRIC_MODEL_PREFIXES.get(model, ("", ""))
|
|
286
|
+
doc = default_doc if document_prefix is None else document_prefix
|
|
287
|
+
qry = default_q if query_prefix is None else query_prefix
|
|
288
|
+
return doc, qry
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _hush_encoder_logs() -> None:
|
|
292
|
+
"""Silence the noisy encoder/runtime libraries before they emit anything."""
|
|
293
|
+
for noisy in ("httpx", "huggingface_hub", "transformers", "sentence_transformers"):
|
|
294
|
+
logging.getLogger(noisy).setLevel(logging.CRITICAL)
|
|
295
|
+
warnings.filterwarnings("ignore", message=".*unauthenticated.*")
|
|
296
|
+
try:
|
|
297
|
+
import transformers # noqa: PLC0415
|
|
298
|
+
|
|
299
|
+
transformers.logging.set_verbosity_error()
|
|
300
|
+
transformers.logging.disable_progress_bar()
|
|
301
|
+
except ImportError:
|
|
302
|
+
return
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _load_encoder(model_name: str, device: str | None) -> EmbeddingModel:
|
|
306
|
+
"""Load a SentenceTransformer encoder, preferring offline files.
|
|
307
|
+
|
|
308
|
+
Returns `EmbeddingModel` — the Protocol naming the one method statcatlib
|
|
309
|
+
calls — rather than the concrete class. The import stays inside the
|
|
310
|
+
function so the search extra is only required when indexing.
|
|
311
|
+
|
|
312
|
+
Tries ``local_files_only=True`` first so cached models avoid hitting
|
|
313
|
+
the network on every invocation; falls back to a normal load (which
|
|
314
|
+
will download if needed). ``device`` may be ``None`` / ``"auto"`` to
|
|
315
|
+
let SentenceTransformers pick.
|
|
316
|
+
"""
|
|
317
|
+
try:
|
|
318
|
+
from sentence_transformers import SentenceTransformer # noqa: PLC0415
|
|
319
|
+
except ImportError as exc:
|
|
320
|
+
console.print(
|
|
321
|
+
f"[red]Missing dependency:[/] {exc}.\n"
|
|
322
|
+
f"Install the search extra: [bold]uv pip install 'statcatlib[search]'[/]"
|
|
323
|
+
)
|
|
324
|
+
raise typer.Exit(code=1) from exc
|
|
325
|
+
|
|
326
|
+
resolved_device = None if device in (None, "auto") else device
|
|
327
|
+
|
|
328
|
+
try:
|
|
329
|
+
model = SentenceTransformer(model_name, device=resolved_device, local_files_only=True)
|
|
330
|
+
except OSError:
|
|
331
|
+
# Cache miss — fall back to a network-allowed load.
|
|
332
|
+
model = SentenceTransformer(model_name, device=resolved_device)
|
|
333
|
+
|
|
334
|
+
# No isinstance narrowing needed: `SentenceTransformer` satisfies
|
|
335
|
+
# `EmbeddingModel` structurally, because the Protocol asks only for a
|
|
336
|
+
# positional `encode` returning `object`. An earlier draft promised
|
|
337
|
+
# `-> Iterable[float]` there and had to narrow here, which basedpyright
|
|
338
|
+
# correctly called unsafe — a runtime_checkable Protocol tests method
|
|
339
|
+
# presence, never signatures, so that check would have passed on anything
|
|
340
|
+
# with an `encode` attribute.
|
|
341
|
+
return model
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
@app.command("build-index")
|
|
345
|
+
def build_index(
|
|
346
|
+
bundle: Path = typer.Argument(
|
|
347
|
+
...,
|
|
348
|
+
help="Path to a statcatlib Catalog bundle (the folder containing meta.duckdb).",
|
|
349
|
+
exists=True,
|
|
350
|
+
file_okay=False,
|
|
351
|
+
dir_okay=True,
|
|
352
|
+
),
|
|
353
|
+
model: str = typer.Option(
|
|
354
|
+
_DEFAULT_MODEL,
|
|
355
|
+
"--model",
|
|
356
|
+
help="SentenceTransformers / Hugging Face model id.",
|
|
357
|
+
),
|
|
358
|
+
document_prefix: str | None = typer.Option(
|
|
359
|
+
None,
|
|
360
|
+
"--document-prefix",
|
|
361
|
+
help="Prefix to prepend to documents at index time. Auto-derived for known model families.",
|
|
362
|
+
),
|
|
363
|
+
query_prefix: str | None = typer.Option(
|
|
364
|
+
None,
|
|
365
|
+
"--query-prefix",
|
|
366
|
+
help="Prefix to prepend to queries at search time. Auto-derived for known model families.",
|
|
367
|
+
),
|
|
368
|
+
batch_size: int = typer.Option(
|
|
369
|
+
256,
|
|
370
|
+
"--batch-size",
|
|
371
|
+
help="Encoder batch size. Sets STATCAT_EMBED_BATCH_SIZE for this run.",
|
|
372
|
+
min=1,
|
|
373
|
+
),
|
|
374
|
+
device: str = typer.Option(
|
|
375
|
+
"auto",
|
|
376
|
+
"--device",
|
|
377
|
+
help="Encoder device: auto | cpu | cuda | mps.",
|
|
378
|
+
),
|
|
379
|
+
include_codes: bool = typer.Option(
|
|
380
|
+
True,
|
|
381
|
+
"--include-codes/--no-include-codes",
|
|
382
|
+
help="Index SDMX code rows for BM25 search.",
|
|
383
|
+
),
|
|
384
|
+
embed_codes: bool = typer.Option(
|
|
385
|
+
False,
|
|
386
|
+
"--embed-codes/--no-embed-codes",
|
|
387
|
+
help="Also compute vector embeddings for code rows (slow; hours on large catalogs).",
|
|
388
|
+
),
|
|
389
|
+
max_codes_per_codelist: int = typer.Option(
|
|
390
|
+
500,
|
|
391
|
+
"--max-codes-per-codelist",
|
|
392
|
+
help="Skip codelists with more entries than this to avoid index bloat.",
|
|
393
|
+
min=1,
|
|
394
|
+
),
|
|
395
|
+
codelist_policy: list[str] = typer.Option(
|
|
396
|
+
[],
|
|
397
|
+
"--codelist-policy",
|
|
398
|
+
help=(
|
|
399
|
+
"Per-codelist override 'URN[,URN...]=mode' (repeatable). "
|
|
400
|
+
"Bypasses --max-codes-per-codelist for the listed URNs. "
|
|
401
|
+
"Accepted modes: fts_only."
|
|
402
|
+
),
|
|
403
|
+
),
|
|
404
|
+
reference_metadata_blend: list[str] = typer.Option(
|
|
405
|
+
[],
|
|
406
|
+
"--reference-metadata-blend",
|
|
407
|
+
help=(
|
|
408
|
+
"Blend an SDMX MetadataSet attribute into each code's search "
|
|
409
|
+
"text, 'ATTRIBUTE_PATH[:max_chars]' (repeatable). "
|
|
410
|
+
"E.g. 'FOOTNOTE_TEXT:240'. Requires the bundle to carry "
|
|
411
|
+
"reference-metadata tables, else build fails fast."
|
|
412
|
+
),
|
|
413
|
+
),
|
|
414
|
+
include_reference_metadata: bool = typer.Option(
|
|
415
|
+
False,
|
|
416
|
+
"--include-reference-metadata/--no-include-reference-metadata",
|
|
417
|
+
help=(
|
|
418
|
+
"Index SDMX reference-metadata sections (reference guides, "
|
|
419
|
+
"methodology reports) as their own searchable 'reference_metadata' "
|
|
420
|
+
"rows, chunked one unit per section. Complements "
|
|
421
|
+
"--reference-metadata-blend: the blend boosts a code's recall, "
|
|
422
|
+
"this makes the document itself retrievable."
|
|
423
|
+
),
|
|
424
|
+
),
|
|
425
|
+
embed_reference_metadata: bool = typer.Option(
|
|
426
|
+
True,
|
|
427
|
+
"--embed-reference-metadata/--no-embed-reference-metadata",
|
|
428
|
+
help="Compute vector embeddings for reference-metadata rows (default on).",
|
|
429
|
+
),
|
|
430
|
+
reference_metadataflow: list[str] = typer.Option(
|
|
431
|
+
[],
|
|
432
|
+
"--reference-metadataflow",
|
|
433
|
+
help=(
|
|
434
|
+
"Restrict reference-metadata indexing to sets whose structure is "
|
|
435
|
+
"one of these MSD/Metadataflow URNs (repeatable). Empty = all sets."
|
|
436
|
+
),
|
|
437
|
+
),
|
|
438
|
+
) -> None:
|
|
439
|
+
"""Build or rebuild the LanceDB vector index for a Catalog bundle.
|
|
440
|
+
|
|
441
|
+
The bundle's ``search/embeddings.json`` sidecar is updated with the
|
|
442
|
+
chosen model, prefixes, embedding dimensionality, and row counts so
|
|
443
|
+
that a query-side client can call
|
|
444
|
+
`statcatlib.Catalog.check_index_compatible` to validate its encoder.
|
|
445
|
+
"""
|
|
446
|
+
logging.basicConfig(
|
|
447
|
+
level=logging.INFO,
|
|
448
|
+
format="%(asctime)s %(levelname)-8s %(message)s",
|
|
449
|
+
datefmt="%H:%M:%S",
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
if not (bundle / "meta.duckdb").is_file():
|
|
453
|
+
console.print(
|
|
454
|
+
f"[red]Not a statcatlib Catalog bundle:[/] {bundle}\n"
|
|
455
|
+
f"Expected to find [bold]meta.duckdb[/] at the top level."
|
|
456
|
+
)
|
|
457
|
+
raise typer.Exit(code=1)
|
|
458
|
+
|
|
459
|
+
if embed_codes and not include_codes:
|
|
460
|
+
console.print("[yellow]--embed-codes implies --include-codes; enabling.[/]")
|
|
461
|
+
include_codes = True
|
|
462
|
+
|
|
463
|
+
policies = [_parse_codelist_policy(s) for s in codelist_policy]
|
|
464
|
+
blends = [_parse_reference_metadata_blend(s) for s in reference_metadata_blend]
|
|
465
|
+
|
|
466
|
+
os.environ["STATCAT_EMBED_BATCH_SIZE"] = str(batch_size)
|
|
467
|
+
_hush_encoder_logs()
|
|
468
|
+
|
|
469
|
+
doc_pref, qry_pref = _resolve_prefixes(model, document_prefix, query_prefix)
|
|
470
|
+
|
|
471
|
+
console.print(
|
|
472
|
+
f"[dim]bundle:[/] {bundle}\n"
|
|
473
|
+
f"[dim]model:[/] {model} ([dim]device={device}, batch_size={batch_size}[/])\n"
|
|
474
|
+
f"[dim]prefixes:[/] document={doc_pref!r} query={qry_pref!r}"
|
|
475
|
+
)
|
|
476
|
+
if policies:
|
|
477
|
+
for p in policies:
|
|
478
|
+
console.print(f"[dim]codelist-policy:[/] {','.join(p.urns)} = {p.mode}")
|
|
479
|
+
if blends:
|
|
480
|
+
for b in blends:
|
|
481
|
+
console.print(f"[dim]reference-metadata-blend:[/] {b.attribute_path} (max_chars={b.max_chars})")
|
|
482
|
+
if include_reference_metadata:
|
|
483
|
+
scope = ", ".join(reference_metadataflow) if reference_metadataflow else "all sets"
|
|
484
|
+
console.print(f"[dim]reference-metadata index:[/] on (embed={embed_reference_metadata}, scope={scope})")
|
|
485
|
+
|
|
486
|
+
with console.status(f"[dim]Loading encoder {model!r}…[/]"):
|
|
487
|
+
encoder = _load_encoder(model, device)
|
|
488
|
+
|
|
489
|
+
with console.status("[dim]Opening catalog…[/]"):
|
|
490
|
+
store = sc.Catalog(str(bundle)).open()
|
|
491
|
+
|
|
492
|
+
progress = Progress(
|
|
493
|
+
SpinnerColumn(),
|
|
494
|
+
TextColumn("[bold cyan]Embedding[/]"),
|
|
495
|
+
BarColumn(bar_width=40),
|
|
496
|
+
MofNCompleteColumn(),
|
|
497
|
+
TaskProgressColumn(),
|
|
498
|
+
TimeElapsedColumn(),
|
|
499
|
+
TimeRemainingColumn(),
|
|
500
|
+
console=console,
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
task_id: TaskID | None = None
|
|
504
|
+
|
|
505
|
+
def _on_progress(current: int, total: int) -> None:
|
|
506
|
+
nonlocal task_id
|
|
507
|
+
if task_id is None:
|
|
508
|
+
task_id = progress.add_task("embed", total=total)
|
|
509
|
+
else:
|
|
510
|
+
progress.update(task_id, total=total)
|
|
511
|
+
progress.update(task_id, completed=current)
|
|
512
|
+
|
|
513
|
+
start = time.monotonic()
|
|
514
|
+
with progress:
|
|
515
|
+
n = store.build_index(
|
|
516
|
+
embedding_model=encoder,
|
|
517
|
+
progress=_on_progress,
|
|
518
|
+
include_codes=include_codes,
|
|
519
|
+
embed_codes=embed_codes,
|
|
520
|
+
max_codes_per_codelist=max_codes_per_codelist,
|
|
521
|
+
codelist_policies=policies,
|
|
522
|
+
reference_metadata_blends=blends,
|
|
523
|
+
include_reference_metadata=include_reference_metadata,
|
|
524
|
+
embed_reference_metadata=embed_reference_metadata,
|
|
525
|
+
reference_metadataflow_urns=reference_metadataflow or None,
|
|
526
|
+
document_prefix=doc_pref,
|
|
527
|
+
query_prefix=qry_pref,
|
|
528
|
+
model_name=model,
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
elapsed = time.monotonic() - start
|
|
532
|
+
meta = store.embedding_metadata
|
|
533
|
+
dim = meta.embedding_dim if meta is not None else None
|
|
534
|
+
dim_str = f"dim={dim}" if dim is not None else "dim=?"
|
|
535
|
+
console.print(
|
|
536
|
+
f"[bold green]Done[/] — [green]{n:,}[/] datasets indexed ([{dim_str}], {elapsed:.1f}s) → {bundle / 'search'}"
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def main() -> None:
|
|
541
|
+
"""Console-script entry point for ``statcatlib``."""
|
|
542
|
+
app()
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
if __name__ == "__main__":
|
|
546
|
+
sys.exit(app() or 0)
|