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 +0 -0
- doclex/cache/__init__.py +0 -0
- doclex/cache/store.py +196 -0
- doclex/cli.py +430 -0
- doclex/config.py +43 -0
- doclex/extraction/__init__.py +0 -0
- doclex/extraction/extractor.py +72 -0
- doclex/index/__init__.py +0 -0
- doclex/index/bm25_index.py +120 -0
- doclex/ingestion/__init__.py +0 -0
- doclex/ingestion/scanner.py +87 -0
- doclex/ingestion/watcher.py +94 -0
- doclex/metadata/__init__.py +0 -0
- doclex/metadata/db.py +250 -0
- doclex/metadata/models.py +55 -0
- doclex/pipeline.py +257 -0
- doclex/registry.py +103 -0
- doclex/segmentation/__init__.py +0 -0
- doclex/segmentation/segmenter.py +398 -0
- doclex_cli-0.1.1.dist-info/METADATA +221 -0
- doclex_cli-0.1.1.dist-info/RECORD +24 -0
- doclex_cli-0.1.1.dist-info/WHEEL +4 -0
- doclex_cli-0.1.1.dist-info/entry_points.txt +2 -0
- doclex_cli-0.1.1.dist-info/licenses/LICENSE +21 -0
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
|