doclex-cli 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.
doclex/__init__.py ADDED
File without changes
File without changes
doclex/cache/store.py ADDED
@@ -0,0 +1,196 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import re
6
+ from pathlib import Path
7
+
8
+ from doclex.metadata.models import ExtractedDoc, Segment
9
+
10
+
11
+ class CacheStore:
12
+ """JSON file cache: one file per document, keyed by sha256 of the path."""
13
+
14
+ def __init__(self, cache_dir: Path) -> None:
15
+ self._dir = cache_dir
16
+ self._dir.mkdir(parents=True, exist_ok=True)
17
+
18
+ def _cache_path(self, doc_path: str) -> Path:
19
+ key = hashlib.sha256(doc_path.encode()).hexdigest()
20
+ return self._dir / f"{key}.json"
21
+
22
+ def get(self, doc_path: str) -> tuple[ExtractedDoc, list[Segment]] | None:
23
+ """Return cached (doc, segments) or None if not present."""
24
+ cache_file = self._cache_path(doc_path)
25
+ if not cache_file.exists():
26
+ return None
27
+ try:
28
+ data = json.loads(cache_file.read_text(encoding="utf-8"))
29
+ doc = ExtractedDoc(
30
+ path=data["path"],
31
+ text="", # content lives in index nodes
32
+ mime_type=data["mime_type"],
33
+ metadata=data.get("metadata", {}),
34
+ )
35
+ segments = _flatten_index(data.get("index", {}), data["path"])
36
+ return doc, segments
37
+ except (KeyError, json.JSONDecodeError):
38
+ return None
39
+
40
+ def put(self, doc: ExtractedDoc, segments: list[Segment]) -> None:
41
+ """Write extracted doc and its segments as a unified index tree."""
42
+ cache_file = self._cache_path(doc.path)
43
+ data = {
44
+ "path": doc.path,
45
+ "mime_type": doc.mime_type,
46
+ "metadata": doc.metadata,
47
+ "index": _build_index(Path(doc.path).stem, segments),
48
+ }
49
+ cache_file.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
50
+
51
+ def delete(self, doc_path: str) -> None:
52
+ cache_file = self._cache_path(doc_path)
53
+ if cache_file.exists():
54
+ cache_file.unlink()
55
+
56
+
57
+ # ------------------------------------------------------------------
58
+ # Index tree builder
59
+ # ------------------------------------------------------------------
60
+
61
+ def _build_index(title: str, segments: list[Segment]) -> dict:
62
+ """Build a unified PageIndex-style tree from flat segments."""
63
+ root: dict = {"title": title, "nodes": []}
64
+
65
+ has_hierarchy = any(s.level > 0 for s in segments)
66
+ if not has_hierarchy:
67
+ root["nodes"] = [_seg_to_node(s) for s in segments]
68
+ return root
69
+
70
+ stack: list[tuple[int, dict]] = [(0, root)]
71
+ for seg in segments:
72
+ node = _seg_to_node(seg)
73
+ if seg.level > 0:
74
+ # heading node: pop continuations (level=0) and same/deeper headings
75
+ while len(stack) > 1 and (stack[-1][0] == 0 or stack[-1][0] >= seg.level):
76
+ stack.pop()
77
+ stack[-1][1]["nodes"].append(node)
78
+ stack.append((seg.level, node))
79
+ else:
80
+ if stack[-1][0] == 0:
81
+ # previous was also a continuation — nest inside it
82
+ stack[-1][1]["nodes"].append(node)
83
+ else:
84
+ # first continuation after a heading — create a parent node from heading
85
+ title, _ = _extract_heading(node.get("content", ""))
86
+ if title:
87
+ _, body = _extract_heading(node.get("content", ""))
88
+ group_id = node["id"]
89
+ node["id"] = hashlib.sha256((group_id + "|body").encode()).hexdigest()[:32]
90
+ node["content"] = body
91
+ parent_node: dict = {
92
+ "id": group_id,
93
+ "title": title,
94
+ "location_type": node.get("location_type", "section"),
95
+ "content": "",
96
+ "bm25_doc_id": None,
97
+ "nodes": [node],
98
+ }
99
+ stack[-1][1]["nodes"].append(parent_node)
100
+ stack.append((0, parent_node))
101
+ else:
102
+ stack[-1][1]["nodes"].append(node)
103
+ stack.append((0, node))
104
+
105
+ _assign_navigation_links(root)
106
+ _clean_nodes(root)
107
+ return root
108
+
109
+
110
+ def _extract_heading(content: str) -> tuple[str, str]:
111
+ """Extract the first markdown heading from content, return (title, remaining_body)."""
112
+ lines = content.split("\n", 1)
113
+ first_line = lines[0].strip()
114
+ m = re.match(r"^#{1,3} (.+)", first_line)
115
+ if not m:
116
+ return "", content
117
+ title = m.group(1).strip()
118
+ body = lines[1].strip() if len(lines) > 1 else ""
119
+ return title, body
120
+
121
+
122
+ def _assign_navigation_links(root: dict) -> None:
123
+ """Assign prev_id, next_id, parent_id to every node in pre-order (reading order)."""
124
+ flat: list[tuple[dict, str | None]] = []
125
+
126
+ def collect(nodes: list, parent_id: str | None) -> None:
127
+ for node in nodes:
128
+ flat.append((node, parent_id))
129
+ collect(node.get("nodes", []), node["id"])
130
+
131
+ collect(root.get("nodes", []), None)
132
+
133
+ for i, (node, parent_id) in enumerate(flat):
134
+ node["parent_id"] = parent_id
135
+ node["prev_id"] = flat[i - 1][0]["id"] if i > 0 else None
136
+ node["next_id"] = flat[i + 1][0]["id"] if i < len(flat) - 1 else None
137
+
138
+
139
+ def _clean_nodes(node: dict) -> None:
140
+ """Clean node titles and content to remove redundancy:
141
+ - Leaf nodes: drop title (content is sufficient)
142
+ - Non-leaf heading nodes: keep title, strip heading line from content
143
+ """
144
+ for child in node.get("nodes", []):
145
+ _clean_nodes(child)
146
+ if not node.get("nodes"):
147
+ node.pop("title", None)
148
+ elif node.get("level", 0) > 0 and node.get("title"):
149
+ _, body = _extract_heading(node.get("content", ""))
150
+ node["content"] = body
151
+
152
+
153
+ def _seg_to_node(seg: Segment) -> dict:
154
+ node: dict = {
155
+ "id": seg.id,
156
+ "title": seg.location_label,
157
+ "location_type": seg.location_type,
158
+ "content": seg.content,
159
+ "bm25_doc_id": seg.bm25_doc_id,
160
+ "nodes": [],
161
+ }
162
+ if seg.level > 0:
163
+ node["level"] = seg.level
164
+ return node
165
+
166
+
167
+ # ------------------------------------------------------------------
168
+ # Index tree flattener (get)
169
+ # ------------------------------------------------------------------
170
+
171
+ def _flatten_index(index: dict, file_path: str) -> list[Segment]:
172
+ """Flatten a unified index tree back to a list of Segment objects."""
173
+ segments: list[Segment] = []
174
+
175
+ def walk(nodes: list, section_id: str | None, index: int) -> int:
176
+ for node in nodes:
177
+ node_id = node["id"]
178
+ level = node.get("level", 0)
179
+ seg = Segment(
180
+ id=node_id,
181
+ file_path=file_path,
182
+ content=node["content"],
183
+ location_type=node.get("location_type", "block"),
184
+ location_label=node.get("title") or node["content"].splitlines()[0].lstrip("#").strip()[:80],
185
+ location_index=index,
186
+ char_count=len(node["content"]),
187
+ bm25_doc_id=node.get("bm25_doc_id"),
188
+ section_id=node_id if level > 0 else section_id,
189
+ level=level,
190
+ )
191
+ segments.append(seg)
192
+ index = walk(node.get("nodes", []), seg.section_id, index + 1)
193
+ return index
194
+
195
+ walk(index.get("nodes", []), None, 0)
196
+ return segments
doclex/cli.py ADDED
@@ -0,0 +1,430 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import os
5
+ import shutil
6
+ from pathlib import Path
7
+
8
+ import typer
9
+ from rich.console import Console
10
+ from rich.progress import BarColumn, MofNCompleteColumn, Progress, SpinnerColumn, TextColumn, TimeElapsedColumn
11
+ from rich.table import Table
12
+
13
+ from doclex.config import Config
14
+ from doclex.pipeline import Pipeline
15
+ from doclex.registry import (
16
+ Project,
17
+ ProjectAlreadyExistsError,
18
+ ProjectFolderNotFoundError,
19
+ ProjectNotFoundError,
20
+ ProjectOverlapError,
21
+ ProjectRegistry,
22
+ )
23
+
24
+ app = typer.Typer(
25
+ name="doclex",
26
+ add_completion=False,
27
+ help="Local document search — BM25-based, no embeddings, no cloud.",
28
+ )
29
+ console = Console(width=220)
30
+
31
+
32
+ def _resolve_project(name: str) -> Project:
33
+ try:
34
+ return ProjectRegistry().get(name)
35
+ except ProjectNotFoundError as exc:
36
+ console.print(
37
+ f"[red]Error:[/red] {exc}\n\n"
38
+ "Run [bold]doclex list[/bold] to see registered projects."
39
+ )
40
+ raise typer.Exit(1)
41
+
42
+
43
+ def _build_pipeline(project: Project, *, create: bool = False) -> Pipeline:
44
+ os.environ["DOCLEX_FOLDER"] = str(project.folder)
45
+ config = Config()
46
+ if create:
47
+ config.ensure_dirs()
48
+ elif not config.cache_dir.exists():
49
+ console.print(
50
+ f"[yellow]No index found for project[/yellow] [bold]{project.name}[/bold] "
51
+ f"({project.folder}). Run [bold]doclex index --project {project.name}[/bold] first."
52
+ )
53
+ raise typer.Exit(0)
54
+ return Pipeline(config)
55
+
56
+
57
+ # ------------------------------------------------------------------
58
+ # add / remove / projects
59
+ # ------------------------------------------------------------------
60
+
61
+ @app.command()
62
+ def add(
63
+ name: str = typer.Argument(..., help="Unique name for the project."),
64
+ folder: Path = typer.Argument(..., help="Folder to register."),
65
+ ) -> None:
66
+ """Register a folder under a project name.
67
+
68
+ \b
69
+ Examples:
70
+ doclex add docs ./docs
71
+ """
72
+ try:
73
+ project = ProjectRegistry().add(name, folder)
74
+ except (ProjectAlreadyExistsError, ProjectFolderNotFoundError, ProjectOverlapError) as exc:
75
+ console.print(f"[red]Error:[/red] {exc}")
76
+ raise typer.Exit(1)
77
+ console.print(f"[green]✓[/green] Registered project '{project.name}' → {project.folder}")
78
+
79
+
80
+ @app.command("list")
81
+ def list_projects() -> None:
82
+ """List registered projects."""
83
+ all_projects = ProjectRegistry().list()
84
+ if not all_projects:
85
+ console.print(
86
+ "[yellow]No projects registered.[/yellow] "
87
+ "Use [bold]doclex add <name> <folder>[/bold]."
88
+ )
89
+ return
90
+
91
+ table = Table(show_header=True, header_style="bold")
92
+ table.add_column("Name")
93
+ table.add_column("Folder")
94
+ for p in all_projects:
95
+ table.add_row(p.name, str(p.folder))
96
+ console.print(table)
97
+
98
+
99
+ @app.command()
100
+ def remove(
101
+ name: str = typer.Argument(..., help="Project name to remove."),
102
+ ) -> None:
103
+ """Remove a project and purge its cache/index data."""
104
+ try:
105
+ project = ProjectRegistry().remove(name)
106
+ except ProjectNotFoundError as exc:
107
+ console.print(f"[red]Error:[/red] {exc}")
108
+ raise typer.Exit(1)
109
+
110
+ cache_dir = project.folder / ".doclex"
111
+ shutil.rmtree(cache_dir, ignore_errors=True)
112
+ console.print(f"[green]✓[/green] Removed project '{name}' and its cache ({cache_dir}).")
113
+
114
+
115
+ # ------------------------------------------------------------------
116
+ # index
117
+ # ------------------------------------------------------------------
118
+
119
+ @app.command()
120
+ def index(
121
+ project: str = typer.Option(
122
+ ..., "--project", "-p",
123
+ help="Registered project name.",
124
+ ),
125
+ files: list[Path] | None = typer.Option(
126
+ None, "--file",
127
+ help="One or more specific files to (re)index. Must be inside the project's folder.",
128
+ ),
129
+ force: bool = typer.Option(
130
+ False, "--force",
131
+ help="Force re-extraction of every file, ignoring cached state.",
132
+ ),
133
+ ) -> None:
134
+ """Index a project or re-index specific files.
135
+
136
+ \b
137
+ Examples:
138
+ doclex index --project docs # incremental scan
139
+ doclex index --project docs --force # reindex everything
140
+ doclex index --project docs --file report.pdf # reindex one file
141
+ doclex index --project docs --file a.pdf --file b.docx
142
+ """
143
+ if files and force:
144
+ console.print("[red]Error:[/red] --file and --force are mutually exclusive.")
145
+ raise typer.Exit(1)
146
+
147
+ proj = _resolve_project(project)
148
+
149
+ if files:
150
+ for f in files:
151
+ if not f.exists():
152
+ console.print(f"[red]Error:[/red] File not found: {f}")
153
+ raise typer.Exit(1)
154
+ if not f.resolve().is_relative_to(proj.folder):
155
+ console.print(
156
+ f"[red]Error:[/red] {f} is not inside project '{proj.name}' ({proj.folder})"
157
+ )
158
+ raise typer.Exit(1)
159
+
160
+ pipeline = _build_pipeline(proj, create=True)
161
+
162
+ async def run() -> None:
163
+ await pipeline.startup()
164
+
165
+ # ── Specific files reindex ───────────────────────────────────
166
+ if files:
167
+ with Progress(
168
+ SpinnerColumn(), BarColumn(), MofNCompleteColumn(),
169
+ TimeElapsedColumn(), TextColumn("[progress.description]{task.description}"),
170
+ console=console,
171
+ ) as progress:
172
+ task = progress.add_task("", total=len(files))
173
+ for f in files:
174
+ progress.update(task, advance=1, description=f"[cyan]reindexing[/cyan] {f.name}")
175
+ await pipeline.reindex_file(f)
176
+ console.print(f"[green]✓[/green] Reindexed {len(files)} file(s).")
177
+ await pipeline.shutdown()
178
+ return
179
+
180
+ # ── Force full reindex ───────────────────────────────────────
181
+ if force:
182
+ all_files = await pipeline.list_files()
183
+ total = len(all_files)
184
+ if total == 0:
185
+ console.print("[yellow]No files in index to reindex.[/yellow]")
186
+ await pipeline.shutdown()
187
+ return
188
+
189
+ console.print(f"[bold]Force reindexing[/bold] {total} files…")
190
+ with Progress(
191
+ SpinnerColumn(), BarColumn(), MofNCompleteColumn(),
192
+ TimeElapsedColumn(), TextColumn("[progress.description]{task.description}"),
193
+ console=console,
194
+ ) as progress:
195
+ task = progress.add_task("", total=total)
196
+
197
+ def on_progress_force(path: Path, action: str) -> None:
198
+ progress.update(task, advance=1, description=f"[cyan]{action}[/cyan] {path.name}")
199
+
200
+ await pipeline.reindex_all(on_progress=on_progress_force)
201
+ console.print("[green]✓[/green] Done.")
202
+ await pipeline.shutdown()
203
+ return
204
+
205
+ # ── Incremental reconciliation ───────────────────────────────
206
+ with Progress(SpinnerColumn(), TextColumn("{task.description}"), console=console, transient=True) as spin:
207
+ spin.add_task("Scanning folder…")
208
+ changes = await pipeline.reconcile()
209
+
210
+ total = len(changes.new) + len(changes.modified) + len(changes.deleted)
211
+
212
+ if total == 0:
213
+ console.print("[green]✓[/green] Index is up to date. No changes detected.")
214
+ else:
215
+ console.print(
216
+ f"[bold]Processing:[/bold] {len(changes.new)} new, "
217
+ f"{len(changes.modified)} modified, "
218
+ f"{len(changes.deleted)} deleted"
219
+ )
220
+ with Progress(
221
+ SpinnerColumn(), BarColumn(), MofNCompleteColumn(),
222
+ TimeElapsedColumn(), TextColumn("[progress.description]{task.description}"),
223
+ console=console,
224
+ ) as progress:
225
+ task = progress.add_task("", total=total)
226
+
227
+ def on_progress(path: Path, action: str) -> None:
228
+ progress.update(task, advance=1, description=f"[cyan]{action}[/cyan] {path.name}")
229
+
230
+ await pipeline.apply_changes(changes, on_progress=on_progress)
231
+
232
+ console.print("[green]✓[/green] Done.")
233
+
234
+ await pipeline.shutdown()
235
+
236
+ asyncio.run(run())
237
+
238
+ # ONNX Runtime (layout detection) crashes during Python interpreter shutdown
239
+ # when the GC tries to free native session objects. os._exit bypasses GC
240
+ # teardown; all work is already saved to disk.
241
+ os._exit(0)
242
+
243
+
244
+ # ------------------------------------------------------------------
245
+ # search
246
+ # ------------------------------------------------------------------
247
+
248
+ @app.command()
249
+ def search(
250
+ query: str = typer.Argument(..., help="Search query (natural language or keywords)."),
251
+ project: str = typer.Option(
252
+ ..., "--project", "-p",
253
+ help="Registered project name.",
254
+ ),
255
+ top_k: int = typer.Option(10, "--top", "-k", help="Maximum number of results to return."),
256
+ full: bool = typer.Option(
257
+ False, "--full", "-F",
258
+ help="Show the full segment text instead of a short snippet.",
259
+ ),
260
+ ) -> None:
261
+ """Search indexed documents and show results with citations.
262
+
263
+ \b
264
+ Examples:
265
+ doclex search "GT/DPM" --project docs
266
+ doclex search "presupuesto 2024" --project docs --top 5 --full
267
+ """
268
+ proj = _resolve_project(project)
269
+ pipeline = _build_pipeline(proj)
270
+
271
+ async def run() -> None:
272
+ with Progress(SpinnerColumn(), TextColumn("{task.description}"), console=console, transient=True) as spin:
273
+ spin.add_task("Searching…")
274
+ await pipeline.startup()
275
+ results = await pipeline.search(query, top_k)
276
+ await pipeline.shutdown()
277
+
278
+ if not results:
279
+ console.print("[yellow]No results found.[/yellow]")
280
+ return
281
+
282
+ console.print()
283
+ for i, r in enumerate(results, 1):
284
+ file_name = Path(r.file_path).name
285
+ console.print(
286
+ f"[bold cyan][{i}][/bold cyan] [bold]{file_name}[/bold] — "
287
+ f"[dim]{r.location_label}[/dim] "
288
+ f"[yellow](score: {r.score:.3f})[/yellow]"
289
+ )
290
+ text = r.content if full else r.snippet
291
+ console.print(f' [italic]"{text}"[/italic]')
292
+ console.print()
293
+
294
+ asyncio.run(run())
295
+
296
+
297
+ # ------------------------------------------------------------------
298
+ # status
299
+ # ------------------------------------------------------------------
300
+
301
+ @app.command()
302
+ def status(
303
+ project: str = typer.Option(
304
+ ..., "--project", "-p",
305
+ help="Registered project name.",
306
+ ),
307
+ files: list[Path] | None = typer.Option(
308
+ None, "--file",
309
+ help="Inspect one or more specific files.",
310
+ ),
311
+ all_files: bool = typer.Option(
312
+ False, "--all",
313
+ help="List all indexed files with their status.",
314
+ ),
315
+ filter_status: str | None = typer.Option(
316
+ None, "--filter",
317
+ help="Filter file list by status: indexed | error | pending.",
318
+ ),
319
+ ) -> None:
320
+ """Show corpus status, optionally listing files or inspecting specific files.
321
+
322
+ \b
323
+ Examples:
324
+ doclex status --project docs # corpus summary
325
+ doclex status --project docs --all # list all files
326
+ doclex status --project docs --all --filter error # filtered list
327
+ doclex status --project docs --file report.pdf --file r3.pdf # inspect specific files
328
+ """
329
+ proj = _resolve_project(project)
330
+ pipeline = _build_pipeline(proj)
331
+
332
+ async def run() -> None:
333
+ await pipeline.startup()
334
+
335
+ # ── Specific-file inspect ────────────────────────────────────
336
+ if files:
337
+ for f in files:
338
+ info = await pipeline.get_file_status(f)
339
+ rec = info["record"]
340
+ console.print()
341
+
342
+ t = Table(show_header=False, box=None, padding=(0, 2))
343
+ t.add_column(style="bold dim")
344
+ t.add_column()
345
+ t.add_row("File", str(f))
346
+ t.add_row("On disk", "[green]yes[/green]" if info["on_disk"] else "[red]no[/red]")
347
+ t.add_row("In index", "[green]yes[/green]" if info["in_index"] else "[yellow]no[/yellow]")
348
+
349
+ if rec:
350
+ modified_label = "[yellow]yes — run index to update[/yellow]" if info["is_modified"] else "[green]no[/green]"
351
+ t.add_row("Modified on disk", modified_label)
352
+ t.add_row("Status", rec.status)
353
+ t.add_row("MIME type", rec.mime_type)
354
+ t.add_row("Segments", str(len(info["segments"])))
355
+ if rec.error_msg:
356
+ t.add_row("Error", f"[red]{rec.error_msg}[/red]")
357
+
358
+ console.print(t)
359
+
360
+ await pipeline.shutdown()
361
+ return
362
+
363
+ # ── File list ────────────────────────────────────────────────
364
+ if all_files:
365
+ file_list = await pipeline.list_files(filter_status)
366
+ await pipeline.shutdown()
367
+
368
+ if not file_list:
369
+ console.print("[yellow]No files found.[/yellow]")
370
+ return
371
+
372
+ table = Table(show_header=True, header_style="bold")
373
+ table.add_column("Status", width=10)
374
+ table.add_column("File")
375
+ table.add_column("MIME type", style="dim")
376
+
377
+ style_map = {"indexed": "green", "error": "red", "pending": "yellow"}
378
+ icon_map = {"indexed": "✓", "error": "✗", "pending": "…"}
379
+
380
+ for f in file_list:
381
+ color = style_map.get(f.status, "white")
382
+ icon = icon_map.get(f.status, "?")
383
+ table.add_row(
384
+ f"[{color}]{icon} {f.status}[/{color}]",
385
+ f.path,
386
+ f.mime_type,
387
+ )
388
+ console.print(table)
389
+ return
390
+
391
+ # ── Corpus summary ───────────────────────────────────────────
392
+ info = await pipeline.corpus_status()
393
+ await pipeline.shutdown()
394
+
395
+ t = Table(show_header=False, box=None, padding=(0, 2))
396
+ t.add_column(style="bold dim")
397
+ t.add_column()
398
+
399
+ t.add_row("Folder", str(info["folder"]))
400
+ t.add_row(
401
+ "Indexed",
402
+ f"[green]{info['files_indexed']} files[/green]"
403
+ + (f" [red]{info['files_error']} errors[/red]" if info["files_error"] else "")
404
+ + (f" [yellow]{info['files_pending']} pending[/yellow]" if info["files_pending"] else ""),
405
+ )
406
+ t.add_row("Segments", str(info["segments_total"]))
407
+ t.add_row("BM25 index", str(info["index_size"]) + " docs")
408
+
409
+ if not info["has_index"]:
410
+ t.add_row("Disk state", "[yellow]not indexed — run doclex index --project <name>[/yellow]")
411
+ else:
412
+ pending_total = info["pending_new"] + info["pending_modified"] + info["pending_deleted"]
413
+ if pending_total:
414
+ t.add_row(
415
+ "Needs reindex",
416
+ f"[yellow]{info['pending_new']} new "
417
+ f"{info['pending_modified']} modified "
418
+ f"{info['pending_deleted']} deleted[/yellow]"
419
+ " → run [bold]doclex index[/bold]",
420
+ )
421
+ else:
422
+ t.add_row("Disk state", "[green]up to date[/green]")
423
+
424
+ console.print(t)
425
+
426
+ asyncio.run(run())
427
+
428
+
429
+ if __name__ == "__main__":
430
+ app()
doclex/config.py ADDED
@@ -0,0 +1,43 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+
7
+ class Config:
8
+ def __init__(self) -> None:
9
+ folder_env = os.environ.get("DOCLEX_FOLDER")
10
+ if not folder_env:
11
+ raise ValueError("DOCLEX_FOLDER environment variable is required")
12
+
13
+ self.folder = Path(folder_env).expanduser().resolve()
14
+ if not self.folder.is_dir():
15
+ raise ValueError(f"DOCLEX_FOLDER is not a directory: {self.folder}")
16
+
17
+ cache_env = os.environ.get("DOCLEX_CACHE_DIR")
18
+ self.cache_dir = (
19
+ Path(cache_env).expanduser().resolve()
20
+ if cache_env
21
+ else self.folder / ".doclex"
22
+ )
23
+
24
+ db_env = os.environ.get("DOCLEX_DB_PATH")
25
+ self.db_path = (
26
+ Path(db_env).expanduser().resolve()
27
+ if db_env
28
+ else self.cache_dir / "doclex.db"
29
+ )
30
+
31
+ self.bm25_index_dir = self.cache_dir / "bm25_index"
32
+
33
+ self.top_k = int(os.environ.get("DOCLEX_TOP_K", "10"))
34
+ self.debounce_secs = float(os.environ.get("DOCLEX_DEBOUNCE", "1.0"))
35
+
36
+ def ensure_dirs(self) -> None:
37
+ self.cache_dir.mkdir(parents=True, exist_ok=True)
38
+ self.db_path.parent.mkdir(parents=True, exist_ok=True)
39
+
40
+
41
+ SUPPORTED_EXTENSIONS: frozenset[str] = frozenset(
42
+ {".pdf", ".txt", ".md", ".docx", ".xlsx", ".csv"}
43
+ )
File without changes