lorme 0.1.1__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.
lorme/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ """lorme — Local Offline Retrieval & MCP Engine.
2
+
3
+ See docs/architecture.md for system design and docs/configuration.md
4
+ for environment variables.
5
+ """
6
+
7
+ try:
8
+ from lorme._version import __version__
9
+ except ImportError:
10
+ __version__ = "0.0.0.dev0+unknown"
lorme/_version.py ADDED
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.1.1'
22
+ __version_tuple__ = version_tuple = (0, 1, 1)
23
+
24
+ __commit_id__ = commit_id = None
lorme/bootstrap.yaml ADDED
@@ -0,0 +1,89 @@
1
+ # lorme configuration
2
+ # Generated by: lorme init
3
+ # All values shown are defaults. Uncomment to override.
4
+ # Without this file, lorme works with XDG defaults.
5
+ # Docs: docs/configuration.md
6
+
7
+ # === Database ===
8
+
9
+ # Common: base directory for all collections
10
+ # database:
11
+ # dir: ~/.local/share/lorme/
12
+ # default_collection: default
13
+
14
+ # === Embedding ===
15
+
16
+ # Common: model name (references llm registry entry below)
17
+ # embedding:
18
+ # model: nomic-embed
19
+
20
+ # Advanced
21
+ # mode: api # api | builtin:gpu | builtin:cpu
22
+
23
+ # === Chunking ===
24
+
25
+ # Common
26
+ # chunking:
27
+ # size: 1024
28
+
29
+ # Advanced
30
+ # overlap: 128
31
+
32
+ # === Search ===
33
+
34
+ # Common
35
+ # search:
36
+ # top_k: 5
37
+
38
+ # Advanced
39
+ # reranking_model: "" # llm registry entry name, empty = no reranking
40
+ # mmr: false # Maximal Marginal Relevance for diversity
41
+ # window_size: 0 # adjacent chunk expansion, 0 = disabled
42
+ # max_per_source: 0 # max chunks per source, 0 = no limit
43
+
44
+ # === add_source defaults ===
45
+
46
+ # Common
47
+ # add_source:
48
+ # preprocess: true
49
+
50
+ # Advanced
51
+ # enrich: [] # [context, qa, meta]
52
+ # optimize: false
53
+
54
+ # === Enrichment ===
55
+
56
+ # Advanced
57
+ # enrich:
58
+ # techniques: [] # [context, qa, meta, stt_correction]
59
+ # models: [] # llm registry entry names
60
+ # prompts_file: "" # custom prompts.yaml path
61
+
62
+ # === Parse ===
63
+
64
+ # Advanced
65
+ # parse:
66
+ # ocr_engine: "" # tesseract | ""
67
+ # ocr_lang: [] # [eng, fra]
68
+ # stt_model: "" # llm registry entry name
69
+ # video_frame_strategy: scene # scene | fixed | hybrid | ocr
70
+ # frame_interval: 30 # seconds between frames (fixed/hybrid)
71
+ # scene_threshold: 0.3 # scene change sensitivity
72
+
73
+ # === LLM Registry ===
74
+
75
+ # Define inference services. Referenced by name in other sections.
76
+ # llm:
77
+ # - name: nomic-embed
78
+ # model: nomic-ai/nomic-embed-text-v2-moe
79
+ # api_url: http://127.0.0.1:8082/v1/embeddings
80
+ # resource: local_gpu # local_gpu | local_cpu | remote
81
+ #
82
+ # # Advanced service lifecycle (must be self-contained)
83
+ # # start: mkdir -p /path/to/logs; podman rm -f svc 2>/dev/null; podman run -d --name svc ...
84
+ # # stop: podman stop svc; podman rm -f svc
85
+ # # start_timeout: 120
86
+ # # timeout: 180
87
+ # # concurrency: 1
88
+ # # batch_size: 32
89
+ # # verify_ssl: true
lorme/build.py ADDED
@@ -0,0 +1,334 @@
1
+ """Build workflow: manifest + models → optimized .db + metadata. See docs/architecture.md."""
2
+
3
+ import json
4
+ import logging
5
+ from pathlib import Path
6
+
7
+ from lorme.embedder import Embedder
8
+ from lorme.eval import run_optimize
9
+ from lorme.ingest import ingest_with_manifest
10
+ from lorme.recipe import parse_recipe
11
+ from lorme.metadata import generate_all
12
+ from lorme.preprocess import preprocess_sources
13
+ from lorme.store import ChunkStore
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ QUESTIONS_FILE = "questions.json"
18
+ SCORES_FILE = "scores.jsonl"
19
+
20
+
21
+ def validate_models(
22
+ configs: list[dict],
23
+ embedders: dict | None = None,
24
+ ) -> list[str]:
25
+ """Validate all model configs before starting. Returns list of errors."""
26
+ from lorme.embedder import _probe_api
27
+
28
+ errors = []
29
+ for cfg in configs:
30
+ name = cfg["name"]
31
+ mode = cfg.get("mode", "builtin")
32
+
33
+ if mode == "api":
34
+ url = cfg.get("api_url")
35
+ if not url:
36
+ errors.append(f"{name}: mode=api but no api_url")
37
+ continue
38
+ if not _probe_api(url, cfg.get("api_model", name), verify=False):
39
+ errors.append(f"{name}: API endpoint unreachable ({url})")
40
+ elif embedders and name in embedders:
41
+ pass
42
+ else:
43
+ cache_path = Path.home() / ".cache" / "huggingface" / "hub" / f"models--{name.replace('/', '--')}"
44
+ if not cache_path.exists():
45
+ errors.append(f"{name}: not in HuggingFace cache, use --allow-download")
46
+
47
+ return errors
48
+
49
+
50
+ def _start_embedders(config) -> dict:
51
+ """Create embedders from config, starting services if needed. See E12.68."""
52
+ from lorme.embedder import create_embedder
53
+
54
+ if config.embedding_models:
55
+ return {
56
+ emb_cfg["name"]: create_embedder(config, emb_cfg)
57
+ for emb_cfg in config.embedding_models
58
+ }
59
+
60
+ entry = config.get_embedding_entry()
61
+ if entry:
62
+ from lorme.preprocess.service import start_service
63
+ start_service(entry)
64
+
65
+ emb = create_embedder(config, entry)
66
+ return {emb.model_name: emb}
67
+
68
+
69
+ def run_build(
70
+ recipe_path: str,
71
+ docs_dir: str,
72
+ output_dir: str,
73
+ config,
74
+ embedders: dict | None = None,
75
+ embedder: Embedder | None = None,
76
+ ) -> dict:
77
+ """Full build pipeline: [preprocess →] validate → optimize → index → metadata.
78
+
79
+ All params resolved from config. embedders passed as instantiated objects.
80
+ """
81
+ force = config.force
82
+ output_level = config.output_level
83
+ skip_optimize = config.skip_optimize
84
+ report_path = config.report_path or None
85
+ chunk_sizes = config.optimize_chunk_sizes
86
+ chunk_overlaps = config.optimize_chunk_overlaps
87
+ top_ks = config.optimize_top_ks
88
+ num_questions = config.optimize_num_questions
89
+
90
+ _build_dir = config.build_dir or output_dir
91
+
92
+ from lorme.task_manager import report_progress, check_cancelled
93
+
94
+ if config.preprocess:
95
+ prep_recipe_path = Path(_build_dir) / (
96
+ Path(recipe_path).stem + "-prep" + Path(recipe_path).suffix
97
+ )
98
+ config.preprocess_recipe_out = str(prep_recipe_path)
99
+ check_cancelled()
100
+ report_progress("Preprocessing sources")
101
+ preprocess_sources(recipe_path, docs_dir, config)
102
+ recipe_path = str(prep_recipe_path)
103
+ docs_dir = str(Path(_build_dir) / "prep")
104
+
105
+ # Use prep dir as docs_dir if it exists (previous preprocess run)
106
+ if not config.preprocess and _build_dir:
107
+ prep_dir = Path(_build_dir) / "prep"
108
+ prep_manifest = Path(_build_dir) / (
109
+ Path(recipe_path).stem + "-prep" + Path(recipe_path).suffix
110
+ )
111
+ if prep_dir.exists() and any(prep_dir.iterdir()):
112
+ docs_dir = str(prep_dir)
113
+ if prep_manifest.exists():
114
+ recipe_path = str(prep_manifest)
115
+
116
+ recipe = parse_recipe(recipe_path)
117
+ collection = getattr(config, "collection_override", "") or recipe["collection"]
118
+ output_path = Path(_build_dir)
119
+ output_path.mkdir(parents=True, exist_ok=True)
120
+
121
+ if embedders is None and embedder is not None:
122
+ embedders = {embedder.model_name: embedder}
123
+ if not embedders:
124
+ embedders = _start_embedders(config)
125
+
126
+ work_path = output_path / ".work"
127
+ work_path.mkdir(parents=True, exist_ok=True)
128
+
129
+ from lorme.progress import ProgressReporter
130
+ import time as _time
131
+
132
+ build_reporter = ProgressReporter(
133
+ collection=collection,
134
+ models=list(embedders.keys()),
135
+ total_configs=0,
136
+ level=output_level,
137
+ )
138
+ build_start = _time.time()
139
+
140
+ resumed = False
141
+ optimization = None
142
+ winning_model = next(iter(embedders))
143
+ if skip_optimize:
144
+ winning_chunk_size = config.chunk_size
145
+ winning_chunk_overlap = config.chunk_overlap
146
+ else:
147
+ winning_chunk_size = chunk_sizes[0] if chunk_sizes else 1024
148
+ winning_chunk_overlap = chunk_overlaps[0] if chunk_overlaps else 128
149
+
150
+ if not skip_optimize:
151
+ check_cancelled()
152
+ report_progress("Optimizing chunking parameters")
153
+ optimization = _run_optimization(
154
+ recipe_path=recipe_path,
155
+ docs_dir=docs_dir,
156
+ embedders=embedders,
157
+ work_dir=str(work_path),
158
+ force=force,
159
+ output_level=output_level,
160
+ config=config,
161
+ )
162
+ resumed = optimization.get("resumed", False)
163
+ best = optimization.get("best", {})
164
+ if best:
165
+ winning_model = best.get("model_name", winning_model)
166
+ winning_chunk_size = best.get("chunk_size", winning_chunk_size)
167
+ winning_chunk_overlap = best.get("chunk_overlap", winning_chunk_overlap)
168
+
169
+ for emb in embedders.values():
170
+ emb.unload()
171
+
172
+ build_reporter.print_section(
173
+ f"Final build — {winning_model} chunk={winning_chunk_size}/{winning_chunk_overlap}"
174
+ )
175
+
176
+ final_db = str(output_path / f"{collection}.db")
177
+ final_emb = embedders[winning_model]
178
+
179
+ t0 = _time.time()
180
+ cache_valid = False
181
+ if not force and Path(final_db).exists():
182
+ try:
183
+ _store = ChunkStore(final_db)
184
+ _meta = _store.get_meta()
185
+ _src = _store.list_sources()
186
+ _store.close()
187
+ cache_valid = (
188
+ len(_src) > 0
189
+ and _meta.get("chunk_size") == str(winning_chunk_size)
190
+ and _meta.get("chunk_overlap") == str(winning_chunk_overlap)
191
+ and _meta.get("model_name") == winning_model
192
+ )
193
+ except Exception:
194
+ cache_valid = False
195
+
196
+ if cache_valid:
197
+ build_reporter.print_step("Skipped (cached)")
198
+ report_progress("Indexing skipped (cached)")
199
+ else:
200
+ if Path(final_db).exists():
201
+ Path(final_db).unlink()
202
+ check_cancelled()
203
+ report_progress(f"Indexing {collection} ({winning_model})")
204
+ ingest_with_manifest(
205
+ recipe_path, docs_dir, str(output_path), final_emb,
206
+ chunk_size=winning_chunk_size, chunk_overlap=winning_chunk_overlap,
207
+ collection=collection,
208
+ )
209
+ build_reporter.print_step("Indexing", elapsed=_time.time() - t0)
210
+
211
+ final_emb.unload()
212
+
213
+ t0 = _time.time()
214
+ check_cancelled()
215
+ report_progress("Generating metadata")
216
+ generate_all(final_db)
217
+ build_reporter.print_step("Metadata", elapsed=_time.time() - t0)
218
+
219
+ _final_store = ChunkStore(final_db)
220
+ sources = _final_store.list_sources()
221
+ _final_store.close()
222
+
223
+ report = {
224
+ "collection": collection,
225
+ "model_name": winning_model,
226
+ "chunk_size": winning_chunk_size,
227
+ "chunk_overlap": winning_chunk_overlap,
228
+ "file_count": len(sources),
229
+ "chunk_count": sum(s["count"] for s in sources),
230
+ "optimization": optimization,
231
+ "resumed": resumed,
232
+ }
233
+
234
+ json_report_path = output_path / "build-report.json"
235
+ json_report_path.write_text(
236
+ json.dumps(report, indent=2, ensure_ascii=False),
237
+ encoding="utf-8",
238
+ )
239
+
240
+ build_reporter.print_summary(
241
+ files=len(sources),
242
+ chunks=sum(s["count"] for s in sources),
243
+ configs_tested=len(optimization.get("all", [])) if optimization else 0,
244
+ elapsed=_time.time() - build_start,
245
+ report_path=str(json_report_path),
246
+ )
247
+
248
+ if not config.keep_intermediates:
249
+ import shutil
250
+ _prep = output_path / "prep"
251
+ _work = output_path / ".work"
252
+ if _prep.exists():
253
+ shutil.rmtree(_prep)
254
+ if _work.exists():
255
+ shutil.rmtree(_work)
256
+
257
+ return report
258
+
259
+
260
+ def _run_optimization(
261
+ recipe_path: str,
262
+ docs_dir: str,
263
+ embedders: dict,
264
+ work_dir: str,
265
+ chunk_sizes: list[int] | None = None,
266
+ chunk_overlaps: list[int] | None = None,
267
+ top_ks: list[int] | None = None,
268
+ num_questions: int = 50,
269
+ force: bool = False,
270
+ output_level: str = "default",
271
+ metrics: list[str] | None = None,
272
+ judge_url: str = "",
273
+ judge_model: str = "",
274
+ judge_verify_ssl: bool = True,
275
+ report_path: str | None = None,
276
+ config=None,
277
+ ) -> dict:
278
+ """Run optimization with resumability."""
279
+ if config is not None:
280
+ if chunk_sizes is None:
281
+ chunk_sizes = config.optimize_chunk_sizes
282
+ if chunk_overlaps is None:
283
+ chunk_overlaps = config.optimize_chunk_overlaps
284
+ if top_ks is None:
285
+ top_ks = config.optimize_top_ks
286
+ if not num_questions or num_questions == 50:
287
+ num_questions = config.optimize_num_questions or 50
288
+ if metrics is None:
289
+ metrics = config.optimize_metrics or None
290
+ if not judge_url:
291
+ judge_url = config.llm_api_url
292
+ if not judge_model:
293
+ judge_model = config.llm_model
294
+ judge_verify_ssl = config.llm_verify_ssl
295
+
296
+ work_path = Path(work_dir)
297
+ scores_path = work_path / SCORES_FILE
298
+ questions_path = work_path / QUESTIONS_FILE
299
+
300
+ existing_scores = []
301
+ resumed = False
302
+
303
+ if not force and scores_path.exists():
304
+ existing_scores = [
305
+ json.loads(line)
306
+ for line in scores_path.read_text().strip().split("\n")
307
+ if line.strip()
308
+ ]
309
+ resumed = True
310
+ logger.info("Resuming: %d existing scores", len(existing_scores))
311
+
312
+ result = run_optimize(
313
+ embedders=embedders,
314
+ db_dir=work_dir,
315
+ recipe_path=recipe_path,
316
+ docs_dir=docs_dir,
317
+ chunk_sizes=chunk_sizes,
318
+ chunk_overlaps=chunk_overlaps,
319
+ top_ks=top_ks,
320
+ num_questions=num_questions,
321
+ output_level=output_level,
322
+ metrics=metrics,
323
+ judge_url=judge_url,
324
+ judge_model=judge_model,
325
+ judge_verify_ssl=judge_verify_ssl,
326
+ report_path=report_path,
327
+ )
328
+
329
+ with open(scores_path, "w", encoding="utf-8") as f:
330
+ for entry in result.get("all", []):
331
+ f.write(json.dumps(entry, ensure_ascii=False) + "\n")
332
+
333
+ result["resumed"] = resumed
334
+ return result
lorme/build_config.py ADDED
@@ -0,0 +1,58 @@
1
+ """Unified build configuration. See docs/architecture.md."""
2
+
3
+ from dataclasses import dataclass, field
4
+
5
+ import yaml
6
+
7
+
8
+ def _read_embedding_key(data: dict, path: str) -> list:
9
+ """Read embedding models, reject old key names."""
10
+ if "models" in data or "embedding_models" in data:
11
+ raise ValueError(
12
+ f"Use 'embedding:' key (not 'models:' or 'embedding_models:') "
13
+ f"in {path}"
14
+ )
15
+ return data.get("embedding", [])
16
+
17
+
18
+ @dataclass
19
+ class BuildConfig:
20
+ """Unified configuration for lorme build."""
21
+ embedding_models: list[dict] = field(default_factory=list)
22
+ judge_model: str = ""
23
+ judge_api_url: str = ""
24
+ judge_verify_ssl: bool = True
25
+ metrics: list[str] = field(default_factory=lambda: ["score_spread", "source_diversity", "result_diversity"])
26
+ chunk_sizes: list[int] = field(default_factory=lambda: [512, 1024, 2048])
27
+ chunk_overlaps: list[int] = field(default_factory=lambda: [64, 128])
28
+ top_ks: list[int] = field(default_factory=lambda: [3, 5, 10])
29
+ num_questions: int = 50
30
+ default_model: str = ""
31
+ default_chunk_size: int = 1024
32
+ default_chunk_overlap: int = 128
33
+
34
+ @classmethod
35
+ def from_file(cls, path: str) -> "BuildConfig":
36
+ """Load build config from a YAML file."""
37
+ with open(path, encoding="utf-8") as f:
38
+ data = yaml.safe_load(f) or {}
39
+
40
+ judge = data.get("judge", {})
41
+ optimize = data.get("optimize", {})
42
+ defaults = data.get("defaults", {})
43
+
44
+ return cls(
45
+ embedding_models=_read_embedding_key(data, path),
46
+ judge_model=judge.get("model", ""),
47
+ judge_api_url=judge.get("api_url", ""),
48
+ judge_verify_ssl=judge.get("verify_ssl", True),
49
+ metrics=data.get("metrics", ["score_spread", "source_diversity", "result_diversity"]),
50
+ chunk_sizes=optimize.get("chunk_sizes", [512, 1024, 2048]),
51
+ chunk_overlaps=optimize.get("chunk_overlaps", [64, 128]),
52
+ top_ks=optimize.get("top_ks", [3, 5, 10]),
53
+ num_questions=optimize.get("num_questions", 50),
54
+ default_model=defaults.get("model", ""),
55
+ default_chunk_size=defaults.get("chunk_size", 1024),
56
+ default_chunk_overlap=defaults.get("chunk_overlap", 128),
57
+ )
58
+