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/pipeline.py ADDED
@@ -0,0 +1,257 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from collections.abc import Callable
5
+ from pathlib import Path
6
+ from typing import Awaitable
7
+
8
+ from doclex.cache.store import CacheStore
9
+ from doclex.config import Config
10
+ from doclex.extraction.extractor import ExtractionError, extract
11
+ from doclex.index.bm25_index import BM25Index, make_snippet
12
+ from doclex.ingestion.scanner import ChangeSet, file_hash, file_record_from_disk, reconcile
13
+ from doclex.ingestion.watcher import FileWatcher
14
+ from doclex.metadata.db import Database
15
+ from doclex.metadata.models import FileRecord, SearchResult
16
+ from doclex.segmentation.segmenter import segment
17
+
18
+
19
+ class Pipeline:
20
+ """Orchestrates extraction → segmentation → index → metadata."""
21
+
22
+ def __init__(self, config: Config) -> None:
23
+ self._config = config
24
+ self._db = Database(config.db_path)
25
+ self._cache = CacheStore(config.cache_dir)
26
+ self._index = BM25Index(config.bm25_index_dir)
27
+ self._watcher: FileWatcher | None = None
28
+
29
+ # ------------------------------------------------------------------
30
+ # Lifecycle
31
+ # ------------------------------------------------------------------
32
+
33
+ async def startup(self) -> None:
34
+ await self._db.connect()
35
+ await self._load_or_rebuild_index()
36
+
37
+ async def shutdown(self) -> None:
38
+ self._index.save()
39
+ await self._db.close()
40
+
41
+ async def _load_or_rebuild_index(self) -> None:
42
+ if self._config.bm25_index_dir.exists() and any(self._config.bm25_index_dir.iterdir()):
43
+ self._index.load_or_create()
44
+ else:
45
+ segments = await self._db.get_all_segments()
46
+ if segments:
47
+ doc_ids = self._index.rebuild_from_segments(segments)
48
+ await self._db.update_bm25_doc_ids(segments, doc_ids)
49
+ else:
50
+ self._index.load_or_create()
51
+
52
+ # ------------------------------------------------------------------
53
+ # Reconciliation
54
+ # ------------------------------------------------------------------
55
+
56
+ async def reconcile(self) -> ChangeSet:
57
+ return await reconcile(self._config.folder, self._db)
58
+
59
+ async def apply_changes(
60
+ self,
61
+ changes: ChangeSet,
62
+ on_progress: Callable[[Path, str], None] | None = None,
63
+ ) -> None:
64
+ for path in changes.new:
65
+ if on_progress:
66
+ on_progress(path, "indexing")
67
+ await self._process_file(path)
68
+
69
+ for path in changes.modified:
70
+ if on_progress:
71
+ on_progress(path, "updating")
72
+ await self._remove_file(str(path))
73
+ await self._process_file(path)
74
+
75
+ for path_str in changes.deleted:
76
+ if on_progress:
77
+ on_progress(Path(path_str), "removing")
78
+ await self._remove_file(path_str)
79
+
80
+ # ------------------------------------------------------------------
81
+ # File processing
82
+ # ------------------------------------------------------------------
83
+
84
+ async def _process_file(self, path: Path) -> None:
85
+ record = file_record_from_disk(path)
86
+ file_id = await self._db.upsert_file(record)
87
+
88
+ try:
89
+ doc = extract(path)
90
+ segments = segment(doc)
91
+
92
+ if not segments:
93
+ await self._db.mark_file_indexed(file_id)
94
+ return
95
+
96
+ doc_ids = self._index.add(segments)
97
+ for seg, doc_id in zip(segments, doc_ids):
98
+ seg.bm25_doc_id = doc_id
99
+
100
+ await self._db.insert_segments(file_id, segments)
101
+ self._cache.put(doc, segments)
102
+ await self._db.mark_file_indexed(file_id)
103
+
104
+ except ExtractionError as exc:
105
+ await self._db.mark_file_error(file_id, str(exc))
106
+
107
+ async def _remove_file(self, path_str: str) -> None:
108
+ record = await self._db.get_file_by_path(path_str)
109
+ if record and record.id is not None:
110
+ bm25_ids = await self._db.get_bm25_doc_ids_for_file(record.id)
111
+ self._index.delete(bm25_ids)
112
+ await self._db.delete_file(path_str)
113
+ self._cache.delete(path_str)
114
+
115
+ # ------------------------------------------------------------------
116
+ # Public commands
117
+ # ------------------------------------------------------------------
118
+
119
+ async def reindex_file(self, path: Path) -> None:
120
+ await self._remove_file(str(path))
121
+ await self._process_file(path)
122
+ self._index.save()
123
+
124
+ async def reindex_all(
125
+ self,
126
+ on_progress: Callable[[Path, str], None] | None = None,
127
+ ) -> None:
128
+ """Force re-extraction and re-indexing of every file currently in the corpus."""
129
+ files = await self._db.get_all_files()
130
+ for rec in files:
131
+ path = Path(rec.path)
132
+ if not path.exists():
133
+ continue
134
+ if on_progress:
135
+ on_progress(path, "reindexing")
136
+ await self._remove_file(str(path))
137
+ await self._process_file(path)
138
+
139
+ # Rebuild BM25 from scratch to eliminate ghost entries from incremental soft-deletes
140
+ segments = await self._db.get_all_segments()
141
+ doc_ids = self._index.rebuild_from_segments(segments)
142
+ await self._db.update_bm25_doc_ids(segments, doc_ids)
143
+
144
+ async def search(self, query: str, top_k: int | None = None) -> list[SearchResult]:
145
+ k = top_k or self._config.top_k
146
+ query = query.strip()
147
+ if not query:
148
+ return []
149
+
150
+ hits = self._index.search(query, k)
151
+ if not hits:
152
+ return []
153
+
154
+ # Map bm25_doc_id → segment via DB
155
+ # We fetch all segments to build the id map (cheap, in-memory)
156
+ all_segments = await self._db.get_all_segments()
157
+ id_map: dict[int, object] = {
158
+ s.bm25_doc_id: s for s in all_segments if s.bm25_doc_id is not None
159
+ }
160
+
161
+ results: list[SearchResult] = []
162
+ for doc_id, score in hits:
163
+ seg = id_map.get(doc_id)
164
+ if seg is None:
165
+ continue
166
+ file_rec = await self._db.get_file_by_path(seg.file_path)
167
+ results.append(
168
+ SearchResult(
169
+ segment_id=seg.id,
170
+ score=score,
171
+ file_path=seg.file_path,
172
+ mime_type=file_rec.mime_type if file_rec else "",
173
+ location_type=seg.location_type,
174
+ location_label=seg.location_label,
175
+ content=seg.content,
176
+ snippet=make_snippet(seg.content, query),
177
+ )
178
+ )
179
+ return results
180
+
181
+ async def list_files(self, status: str | None = None) -> list[FileRecord]:
182
+ files = await self._db.get_all_files()
183
+ if status:
184
+ files = [f for f in files if f.status == status]
185
+ return files
186
+
187
+ async def corpus_status(self) -> dict:
188
+ files = await self._db.get_all_files()
189
+ segments = await self._db.get_all_segments()
190
+ # Only reconcile if there's already an index — avoids scanning large filesystems
191
+ has_index = len(files) > 0
192
+ changes = await reconcile(self._config.folder, self._db) if has_index else ChangeSet(new=[], modified=[], deleted=[])
193
+ return {
194
+ "folder": str(self._config.folder),
195
+ "files_total": len(files),
196
+ "files_indexed": sum(1 for f in files if f.status == "indexed"),
197
+ "files_error": sum(1 for f in files if f.status == "error"),
198
+ "files_pending": sum(1 for f in files if f.status == "pending"),
199
+ "segments_total": len(segments),
200
+ "index_size": len(self._index),
201
+ "pending_new": len(changes.new),
202
+ "pending_modified": len(changes.modified),
203
+ "pending_deleted": len(changes.deleted),
204
+ "has_index": has_index,
205
+ }
206
+
207
+ async def get_file_status(self, path: Path) -> dict:
208
+ """Return detailed status of a specific file: DB record, segments, disk state."""
209
+ record = await self._db.get_file_by_path(str(path))
210
+ on_disk = path.exists()
211
+ is_modified = False
212
+ segments: list = []
213
+
214
+ if on_disk and record:
215
+ current_hash = file_hash(path)
216
+ is_modified = current_hash != record.hash
217
+ if record and record.id is not None:
218
+ segments = await self._db.get_segments_for_file(record.id)
219
+
220
+ return {
221
+ "path": str(path),
222
+ "on_disk": on_disk,
223
+ "in_index": record is not None,
224
+ "is_modified": is_modified,
225
+ "record": record,
226
+ "segments": segments,
227
+ }
228
+
229
+ # ------------------------------------------------------------------
230
+ # Watcher
231
+ # ------------------------------------------------------------------
232
+
233
+ def start_watcher(self, loop: asyncio.AbstractEventLoop) -> None:
234
+ self._watcher = FileWatcher(
235
+ self._config.folder,
236
+ self._config.debounce_secs,
237
+ self._on_file_change,
238
+ )
239
+ self._watcher.start(loop)
240
+
241
+ def stop_watcher(self) -> None:
242
+ if self._watcher:
243
+ self._watcher.stop()
244
+ self._watcher = None
245
+
246
+ def _on_file_change(self, path: Path, event_type: str) -> None:
247
+ loop = asyncio.get_event_loop()
248
+ if event_type == "deleted":
249
+ loop.create_task(self._remove_file(str(path)))
250
+ else:
251
+ loop.create_task(self._reindex_on_change(path))
252
+
253
+ async def _reindex_on_change(self, path: Path) -> None:
254
+ await self._remove_file(str(path))
255
+ if path.exists():
256
+ await self._process_file(path)
257
+ self._index.save()
doclex/registry.py ADDED
@@ -0,0 +1,103 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+
9
+ class RegistryError(Exception):
10
+ pass
11
+
12
+
13
+ class ProjectNotFoundError(RegistryError):
14
+ pass
15
+
16
+
17
+ class ProjectAlreadyExistsError(RegistryError):
18
+ pass
19
+
20
+
21
+ class ProjectFolderNotFoundError(RegistryError):
22
+ pass
23
+
24
+
25
+ class ProjectOverlapError(RegistryError):
26
+ pass
27
+
28
+
29
+ @dataclass(frozen=True, slots=True)
30
+ class Project:
31
+ name: str
32
+ folder: Path
33
+
34
+
35
+ class ProjectRegistry:
36
+ def __init__(self, path: Path | None = None) -> None:
37
+ self._path = path or self._default_path()
38
+
39
+ @staticmethod
40
+ def _default_path() -> Path:
41
+ env = os.environ.get("DOCLEX_REGISTRY_PATH")
42
+ if env:
43
+ return Path(env).expanduser().resolve()
44
+ return Path.home() / ".doclex" / "projects.json"
45
+
46
+ def _load(self) -> dict[str, Project]:
47
+ if not self._path.exists():
48
+ return {}
49
+ data = json.loads(self._path.read_text(encoding="utf-8"))
50
+ return {
51
+ name: Project(name=name, folder=Path(v["folder"]))
52
+ for name, v in data.get("projects", {}).items()
53
+ }
54
+
55
+ def _save(self, projects: dict[str, Project]) -> None:
56
+ self._path.parent.mkdir(parents=True, exist_ok=True)
57
+ payload = {
58
+ "version": 1,
59
+ "projects": {p.name: {"folder": str(p.folder)} for p in projects.values()},
60
+ }
61
+ tmp = self._path.with_suffix(self._path.suffix + ".tmp")
62
+ tmp.write_text(json.dumps(payload, indent=2), encoding="utf-8")
63
+ os.replace(tmp, self._path)
64
+
65
+ def add(self, name: str, folder: Path) -> Project:
66
+ folder = folder.expanduser().resolve()
67
+ if not folder.is_dir():
68
+ raise ProjectFolderNotFoundError(f"Folder does not exist: {folder}")
69
+
70
+ projects = self._load()
71
+ if name in projects:
72
+ raise ProjectAlreadyExistsError(
73
+ f"Project '{name}' already exists (folder: {projects[name].folder})."
74
+ )
75
+
76
+ for existing in projects.values():
77
+ if folder.is_relative_to(existing.folder) or existing.folder.is_relative_to(folder):
78
+ raise ProjectOverlapError(
79
+ f"Folder {folder} overlaps with project '{existing.name}' "
80
+ f"({existing.folder}). Overlapping project folders are not allowed."
81
+ )
82
+
83
+ project = Project(name=name, folder=folder)
84
+ projects[name] = project
85
+ self._save(projects)
86
+ return project
87
+
88
+ def get(self, name: str) -> Project:
89
+ projects = self._load()
90
+ if name not in projects:
91
+ raise ProjectNotFoundError(f"No project named '{name}'.")
92
+ return projects[name]
93
+
94
+ def list(self) -> list[Project]:
95
+ return sorted(self._load().values(), key=lambda p: p.name)
96
+
97
+ def remove(self, name: str) -> Project:
98
+ projects = self._load()
99
+ if name not in projects:
100
+ raise ProjectNotFoundError(f"No project named '{name}'.")
101
+ project = projects.pop(name)
102
+ self._save(projects)
103
+ return project
File without changes