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
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from kreuzberg import ExtractionConfig, LayoutDetectionConfig, OutputFormat, ResultFormat, extract_file_sync
|
|
6
|
+
from kreuzberg.exceptions import KreuzbergError
|
|
7
|
+
|
|
8
|
+
from doclex.metadata.models import ExtractedDoc
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def extract(path: Path) -> ExtractedDoc:
|
|
12
|
+
"""Extract text from a file using Kreuzberg with layout detection (RT-DETR v2 / TATR).
|
|
13
|
+
|
|
14
|
+
Raises:
|
|
15
|
+
ExtractionError: if Kreuzberg fails to extract the file.
|
|
16
|
+
"""
|
|
17
|
+
config = ExtractionConfig(
|
|
18
|
+
layout=LayoutDetectionConfig(),
|
|
19
|
+
output_format=OutputFormat.MARKDOWN,
|
|
20
|
+
result_format=ResultFormat.UNIFIED,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
result = extract_file_sync(str(path), None, config)
|
|
25
|
+
except (KreuzbergError, OSError) as exc:
|
|
26
|
+
raise ExtractionError(str(path), str(exc)) from exc
|
|
27
|
+
except (KeyboardInterrupt, SystemExit):
|
|
28
|
+
raise
|
|
29
|
+
except BaseException as exc:
|
|
30
|
+
# pyo3_runtime.PanicException inherits from BaseException, not Exception.
|
|
31
|
+
# Catch Kreuzberg Rust panics (e.g. UTF-8 char boundary bug in markdown renderer)
|
|
32
|
+
# and fall back to standard extraction so the file is not lost.
|
|
33
|
+
if "char boundary" in str(exc):
|
|
34
|
+
try:
|
|
35
|
+
result = extract_file_sync(str(path), None, ExtractionConfig())
|
|
36
|
+
except (KreuzbergError, OSError) as exc2:
|
|
37
|
+
raise ExtractionError(str(path), str(exc2)) from exc2
|
|
38
|
+
else:
|
|
39
|
+
raise ExtractionError(str(path), str(exc)) from exc
|
|
40
|
+
|
|
41
|
+
tables = list(result.tables) if result.tables else []
|
|
42
|
+
|
|
43
|
+
metadata = dict(result.metadata) if result.metadata else {}
|
|
44
|
+
if "pages" in metadata and isinstance(metadata["pages"], dict):
|
|
45
|
+
metadata["pages"].pop("boundaries", None)
|
|
46
|
+
|
|
47
|
+
return ExtractedDoc(
|
|
48
|
+
path=str(path),
|
|
49
|
+
text=result.content or "",
|
|
50
|
+
mime_type=result.mime_type or _mime_from_suffix(path),
|
|
51
|
+
metadata=metadata,
|
|
52
|
+
tables=tables,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _mime_from_suffix(path: Path) -> str:
|
|
57
|
+
mapping = {
|
|
58
|
+
".pdf": "application/pdf",
|
|
59
|
+
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
60
|
+
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
61
|
+
".csv": "text/csv",
|
|
62
|
+
".txt": "text/plain",
|
|
63
|
+
".md": "text/markdown",
|
|
64
|
+
}
|
|
65
|
+
return mapping.get(path.suffix.lower(), "application/octet-stream")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class ExtractionError(Exception):
|
|
69
|
+
def __init__(self, path: str, reason: str) -> None:
|
|
70
|
+
super().__init__(f"Failed to extract {path!r}: {reason}")
|
|
71
|
+
self.path = path
|
|
72
|
+
self.reason = reason
|
doclex/index/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from bm25x import BM25
|
|
7
|
+
|
|
8
|
+
from doclex.metadata.models import Segment
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
_SNIPPET_WINDOW = 200 # chars around the query match for snippet generation
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class BM25Index:
|
|
15
|
+
"""Wrapper around BM25X with persistence and segment-level CRUD."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, index_dir: Path) -> None:
|
|
18
|
+
self._dir = index_dir
|
|
19
|
+
self._index: BM25 | None = None
|
|
20
|
+
|
|
21
|
+
def load_or_create(self) -> None:
|
|
22
|
+
"""Load existing index from disk or create an empty one."""
|
|
23
|
+
if self._dir.exists() and any(self._dir.iterdir()):
|
|
24
|
+
self._index = BM25.load(str(self._dir))
|
|
25
|
+
else:
|
|
26
|
+
self._dir.mkdir(parents=True, exist_ok=True)
|
|
27
|
+
self._index = BM25()
|
|
28
|
+
|
|
29
|
+
def rebuild_from_segments(self, segments: list[Segment]) -> list[int]:
|
|
30
|
+
"""Rebuild the full index from a list of segments. Returns new bm25_doc_ids."""
|
|
31
|
+
self._dir.mkdir(parents=True, exist_ok=True)
|
|
32
|
+
self._index = BM25()
|
|
33
|
+
doc_ids: list[int] = []
|
|
34
|
+
if segments:
|
|
35
|
+
doc_ids = self._index.add([s.content for s in segments])
|
|
36
|
+
self.save()
|
|
37
|
+
return doc_ids
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def _bm25(self) -> BM25:
|
|
41
|
+
if self._index is None:
|
|
42
|
+
raise RuntimeError("Index not loaded. Call load_or_create() first.")
|
|
43
|
+
return self._index
|
|
44
|
+
|
|
45
|
+
def add(self, segments: list[Segment]) -> list[int]:
|
|
46
|
+
"""Add segments to the index. Returns assigned bm25_doc_ids."""
|
|
47
|
+
if not segments:
|
|
48
|
+
return []
|
|
49
|
+
doc_ids: list[int] = self._bm25.add([s.content for s in segments])
|
|
50
|
+
return doc_ids
|
|
51
|
+
|
|
52
|
+
def delete(self, bm25_doc_ids: list[int]) -> None:
|
|
53
|
+
"""Remove segments by their BM25X doc ids."""
|
|
54
|
+
if bm25_doc_ids:
|
|
55
|
+
self._bm25.delete(bm25_doc_ids)
|
|
56
|
+
|
|
57
|
+
def search(self, query: str, k: int) -> list[tuple[int, float]]:
|
|
58
|
+
"""Search the index. Returns list of (bm25_doc_id, score)."""
|
|
59
|
+
if len(self._bm25) == 0:
|
|
60
|
+
return []
|
|
61
|
+
results = self._bm25.search(query, k)
|
|
62
|
+
return [(doc_id, float(score)) for doc_id, score in results]
|
|
63
|
+
|
|
64
|
+
def save(self) -> None:
|
|
65
|
+
self._bm25.save(str(self._dir))
|
|
66
|
+
|
|
67
|
+
def __len__(self) -> int:
|
|
68
|
+
return len(self._bm25) if self._index is not None else 0
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def make_snippet(content: str, query: str, window: int = _SNIPPET_WINDOW) -> str:
|
|
72
|
+
"""Extract a snippet centered on the window with the highest density of query-term matches.
|
|
73
|
+
|
|
74
|
+
Query terms are split on non-alphanumeric characters (matching how BM25X
|
|
75
|
+
tokenizes documents, e.g. "GT/DPM" -> "gt", "dpm") instead of whitespace only,
|
|
76
|
+
so acronyms and punctuation-joined terms can still anchor the snippet. Among
|
|
77
|
+
all match positions, the one whose surrounding window covers the most distinct
|
|
78
|
+
query terms wins (ties broken by total match count, then earliest position) —
|
|
79
|
+
not just the first occurrence of the first term — so the snippet favors the
|
|
80
|
+
part of the segment where the query actually clusters. The window is then
|
|
81
|
+
snapped to word boundaries so it never cuts a word in half.
|
|
82
|
+
"""
|
|
83
|
+
terms = [t for t in re.split(r"\W+", query.lower()) if t]
|
|
84
|
+
|
|
85
|
+
matches: list[tuple[int, int, str]] = [
|
|
86
|
+
(m.start(), m.end(), term)
|
|
87
|
+
for term in terms
|
|
88
|
+
for m in re.finditer(re.escape(term), content, re.IGNORECASE)
|
|
89
|
+
]
|
|
90
|
+
|
|
91
|
+
if not matches:
|
|
92
|
+
return content[:window].rstrip() + ("…" if len(content) > window else "")
|
|
93
|
+
|
|
94
|
+
def density(match: tuple[int, int, str]) -> tuple[int, int, int]:
|
|
95
|
+
pos = match[0]
|
|
96
|
+
lo, hi = pos - window // 2, pos + window // 2
|
|
97
|
+
nearby = [t for s, _, t in matches if lo <= s <= hi]
|
|
98
|
+
return (len(set(nearby)), len(nearby), -pos)
|
|
99
|
+
|
|
100
|
+
match_start, match_end, _ = max(matches, key=density)
|
|
101
|
+
|
|
102
|
+
start = max(0, match_start - window // 2)
|
|
103
|
+
end = min(len(content), match_end + window // 2)
|
|
104
|
+
snippet = content[start:end]
|
|
105
|
+
|
|
106
|
+
if start > 0:
|
|
107
|
+
ws_positions = [p for p in (snippet.find(" "), snippet.find("\n"), snippet.find("\t")) if p != -1]
|
|
108
|
+
if ws_positions:
|
|
109
|
+
snippet = snippet[min(ws_positions) + 1 :]
|
|
110
|
+
if end < len(content):
|
|
111
|
+
last_ws = max(snippet.rfind(" "), snippet.rfind("\n"), snippet.rfind("\t"))
|
|
112
|
+
if last_ws > 0:
|
|
113
|
+
snippet = snippet[:last_ws]
|
|
114
|
+
|
|
115
|
+
snippet = snippet.strip() or content[start:end].strip()
|
|
116
|
+
if start > 0:
|
|
117
|
+
snippet = "…" + snippet
|
|
118
|
+
if end < len(content):
|
|
119
|
+
snippet = snippet + "…"
|
|
120
|
+
return snippet
|
|
File without changes
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from doclex.config import SUPPORTED_EXTENSIONS
|
|
8
|
+
from doclex.metadata.db import Database
|
|
9
|
+
from doclex.metadata.models import FileRecord
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class ChangeSet:
|
|
14
|
+
new: list[Path]
|
|
15
|
+
modified: list[Path]
|
|
16
|
+
deleted: list[str] # paths of files no longer on disk
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def file_hash(path: Path) -> str:
|
|
20
|
+
h = hashlib.sha256()
|
|
21
|
+
with path.open("rb") as f:
|
|
22
|
+
for chunk in iter(lambda: f.read(65536), b""):
|
|
23
|
+
h.update(chunk)
|
|
24
|
+
return h.hexdigest()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def file_record_from_disk(path: Path) -> FileRecord:
|
|
28
|
+
stat = path.stat()
|
|
29
|
+
return FileRecord(
|
|
30
|
+
path=str(path),
|
|
31
|
+
hash=file_hash(path),
|
|
32
|
+
mtime=stat.st_mtime,
|
|
33
|
+
size=stat.st_size,
|
|
34
|
+
mime_type=_mime_from_suffix(path),
|
|
35
|
+
status="pending",
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
async def reconcile(folder: Path, db: Database) -> ChangeSet:
|
|
40
|
+
"""Compare files on disk against SQLite state and return what changed."""
|
|
41
|
+
# Files on disk
|
|
42
|
+
on_disk: dict[str, Path] = {}
|
|
43
|
+
for path in folder.rglob("*"):
|
|
44
|
+
if path.is_file() and path.suffix.lower() in SUPPORTED_EXTENSIONS:
|
|
45
|
+
# Skip the .doclex directory itself
|
|
46
|
+
if ".doclex" in path.parts:
|
|
47
|
+
continue
|
|
48
|
+
on_disk[str(path)] = path
|
|
49
|
+
|
|
50
|
+
# Files in SQLite
|
|
51
|
+
db_records: dict[str, FileRecord] = {
|
|
52
|
+
r.path: r for r in await db.get_all_files()
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
new: list[Path] = []
|
|
56
|
+
modified: list[Path] = []
|
|
57
|
+
deleted: list[str] = []
|
|
58
|
+
|
|
59
|
+
for path_str, path in on_disk.items():
|
|
60
|
+
if path_str not in db_records:
|
|
61
|
+
new.append(path)
|
|
62
|
+
else:
|
|
63
|
+
db_rec = db_records[path_str]
|
|
64
|
+
stat = path.stat()
|
|
65
|
+
# Use mtime as cheap pre-check before computing hash
|
|
66
|
+
if stat.st_mtime != db_rec.mtime or stat.st_size != db_rec.size:
|
|
67
|
+
current_hash = file_hash(path)
|
|
68
|
+
if current_hash != db_rec.hash:
|
|
69
|
+
modified.append(path)
|
|
70
|
+
|
|
71
|
+
for path_str in db_records:
|
|
72
|
+
if path_str not in on_disk:
|
|
73
|
+
deleted.append(path_str)
|
|
74
|
+
|
|
75
|
+
return ChangeSet(new=new, modified=modified, deleted=deleted)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _mime_from_suffix(path: Path) -> str:
|
|
79
|
+
mapping = {
|
|
80
|
+
".pdf": "application/pdf",
|
|
81
|
+
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
82
|
+
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
83
|
+
".csv": "text/csv",
|
|
84
|
+
".txt": "text/plain",
|
|
85
|
+
".md": "text/markdown",
|
|
86
|
+
}
|
|
87
|
+
return mapping.get(path.suffix.lower(), "application/octet-stream")
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import threading
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from watchdog.events import FileSystemEvent, FileSystemEventHandler
|
|
9
|
+
from watchdog.observers import Observer
|
|
10
|
+
|
|
11
|
+
from doclex.config import SUPPORTED_EXTENSIONS
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class _Handler(FileSystemEventHandler):
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
debounce_secs: float,
|
|
18
|
+
on_change: Callable[[Path, str], None],
|
|
19
|
+
loop: asyncio.AbstractEventLoop,
|
|
20
|
+
) -> None:
|
|
21
|
+
super().__init__()
|
|
22
|
+
self._debounce = debounce_secs
|
|
23
|
+
self._on_change = on_change
|
|
24
|
+
self._loop = loop
|
|
25
|
+
self._timers: dict[str, threading.Timer] = {}
|
|
26
|
+
self._lock = threading.Lock()
|
|
27
|
+
|
|
28
|
+
def _is_supported(self, path: str) -> bool:
|
|
29
|
+
return Path(path).suffix.lower() in SUPPORTED_EXTENSIONS
|
|
30
|
+
|
|
31
|
+
def _schedule(self, path: str, event_type: str) -> None:
|
|
32
|
+
if not self._is_supported(path):
|
|
33
|
+
return
|
|
34
|
+
if ".doclex" in Path(path).parts:
|
|
35
|
+
return
|
|
36
|
+
with self._lock:
|
|
37
|
+
if path in self._timers:
|
|
38
|
+
self._timers[path].cancel()
|
|
39
|
+
timer = threading.Timer(
|
|
40
|
+
self._debounce,
|
|
41
|
+
self._fire,
|
|
42
|
+
args=(path, event_type),
|
|
43
|
+
)
|
|
44
|
+
self._timers[path] = timer
|
|
45
|
+
timer.start()
|
|
46
|
+
|
|
47
|
+
def _fire(self, path: str, event_type: str) -> None:
|
|
48
|
+
with self._lock:
|
|
49
|
+
self._timers.pop(path, None)
|
|
50
|
+
self._loop.call_soon_threadsafe(self._on_change, Path(path), event_type)
|
|
51
|
+
|
|
52
|
+
def on_created(self, event: FileSystemEvent) -> None:
|
|
53
|
+
if not event.is_directory:
|
|
54
|
+
self._schedule(event.src_path, "created")
|
|
55
|
+
|
|
56
|
+
def on_modified(self, event: FileSystemEvent) -> None:
|
|
57
|
+
if not event.is_directory:
|
|
58
|
+
self._schedule(event.src_path, "modified")
|
|
59
|
+
|
|
60
|
+
def on_deleted(self, event: FileSystemEvent) -> None:
|
|
61
|
+
if not event.is_directory:
|
|
62
|
+
self._schedule(event.src_path, "deleted")
|
|
63
|
+
|
|
64
|
+
def on_moved(self, event: FileSystemEvent) -> None:
|
|
65
|
+
if not event.is_directory:
|
|
66
|
+
self._schedule(event.src_path, "deleted")
|
|
67
|
+
self._schedule(event.dest_path, "created")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class FileWatcher:
|
|
71
|
+
"""Watchdog-based folder monitor with debounce."""
|
|
72
|
+
|
|
73
|
+
def __init__(
|
|
74
|
+
self,
|
|
75
|
+
folder: Path,
|
|
76
|
+
debounce_secs: float,
|
|
77
|
+
on_change: Callable[[Path, str], None],
|
|
78
|
+
) -> None:
|
|
79
|
+
self._folder = folder
|
|
80
|
+
self._debounce = debounce_secs
|
|
81
|
+
self._on_change = on_change
|
|
82
|
+
self._observer: Observer | None = None
|
|
83
|
+
|
|
84
|
+
def start(self, loop: asyncio.AbstractEventLoop) -> None:
|
|
85
|
+
handler = _Handler(self._debounce, self._on_change, loop)
|
|
86
|
+
self._observer = Observer()
|
|
87
|
+
self._observer.schedule(handler, str(self._folder), recursive=True)
|
|
88
|
+
self._observer.start()
|
|
89
|
+
|
|
90
|
+
def stop(self) -> None:
|
|
91
|
+
if self._observer:
|
|
92
|
+
self._observer.stop()
|
|
93
|
+
self._observer.join()
|
|
94
|
+
self._observer = None
|
|
File without changes
|
doclex/metadata/db.py
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
import aiosqlite
|
|
8
|
+
|
|
9
|
+
from .models import FileRecord, Segment
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
SCHEMA = """
|
|
15
|
+
CREATE TABLE IF NOT EXISTS files (
|
|
16
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
17
|
+
path TEXT NOT NULL UNIQUE,
|
|
18
|
+
hash TEXT NOT NULL,
|
|
19
|
+
mtime REAL NOT NULL,
|
|
20
|
+
size INTEGER NOT NULL,
|
|
21
|
+
mime_type TEXT NOT NULL,
|
|
22
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
23
|
+
indexed_at REAL,
|
|
24
|
+
error_msg TEXT
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
CREATE TABLE IF NOT EXISTS segments (
|
|
28
|
+
id TEXT PRIMARY KEY,
|
|
29
|
+
file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE,
|
|
30
|
+
content TEXT NOT NULL,
|
|
31
|
+
location_type TEXT NOT NULL,
|
|
32
|
+
location_label TEXT NOT NULL,
|
|
33
|
+
location_index INTEGER NOT NULL,
|
|
34
|
+
char_count INTEGER NOT NULL,
|
|
35
|
+
bm25_doc_id INTEGER
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
CREATE INDEX IF NOT EXISTS idx_files_path ON files(path);
|
|
39
|
+
CREATE INDEX IF NOT EXISTS idx_files_hash ON files(hash);
|
|
40
|
+
CREATE INDEX IF NOT EXISTS idx_segs_file_id ON segments(file_id);
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Database:
|
|
45
|
+
def __init__(self, db_path: Path) -> None:
|
|
46
|
+
self._path = db_path
|
|
47
|
+
self._conn: aiosqlite.Connection | None = None
|
|
48
|
+
|
|
49
|
+
async def connect(self) -> None:
|
|
50
|
+
self._conn = await aiosqlite.connect(self._path)
|
|
51
|
+
self._conn.row_factory = aiosqlite.Row
|
|
52
|
+
await self._conn.execute("PRAGMA journal_mode=WAL")
|
|
53
|
+
await self._conn.execute("PRAGMA foreign_keys=ON")
|
|
54
|
+
await self._conn.executescript(SCHEMA)
|
|
55
|
+
await self._conn.commit()
|
|
56
|
+
|
|
57
|
+
async def close(self) -> None:
|
|
58
|
+
if self._conn:
|
|
59
|
+
await self._conn.close()
|
|
60
|
+
self._conn = None
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def conn(self) -> aiosqlite.Connection:
|
|
64
|
+
if self._conn is None:
|
|
65
|
+
raise RuntimeError("Database not connected. Call connect() first.")
|
|
66
|
+
return self._conn
|
|
67
|
+
|
|
68
|
+
# ------------------------------------------------------------------
|
|
69
|
+
# Files
|
|
70
|
+
# ------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
async def upsert_file(self, record: FileRecord) -> int:
|
|
73
|
+
"""Insert or update a file record. Returns the file id."""
|
|
74
|
+
async with self.conn.execute(
|
|
75
|
+
"""
|
|
76
|
+
INSERT INTO files (path, hash, mtime, size, mime_type, status, indexed_at, error_msg)
|
|
77
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
78
|
+
ON CONFLICT(path) DO UPDATE SET
|
|
79
|
+
hash = excluded.hash,
|
|
80
|
+
mtime = excluded.mtime,
|
|
81
|
+
size = excluded.size,
|
|
82
|
+
mime_type = excluded.mime_type,
|
|
83
|
+
status = excluded.status,
|
|
84
|
+
indexed_at = excluded.indexed_at,
|
|
85
|
+
error_msg = excluded.error_msg
|
|
86
|
+
""",
|
|
87
|
+
(
|
|
88
|
+
record.path,
|
|
89
|
+
record.hash,
|
|
90
|
+
record.mtime,
|
|
91
|
+
record.size,
|
|
92
|
+
record.mime_type,
|
|
93
|
+
record.status,
|
|
94
|
+
record.indexed_at,
|
|
95
|
+
record.error_msg,
|
|
96
|
+
),
|
|
97
|
+
) as cursor:
|
|
98
|
+
await self.conn.commit()
|
|
99
|
+
# Fetch id after upsert
|
|
100
|
+
async with self.conn.execute(
|
|
101
|
+
"SELECT id FROM files WHERE path = ?", (record.path,)
|
|
102
|
+
) as cur2:
|
|
103
|
+
row = await cur2.fetchone()
|
|
104
|
+
return row["id"]
|
|
105
|
+
|
|
106
|
+
async def get_file_by_path(self, path: str) -> FileRecord | None:
|
|
107
|
+
async with self.conn.execute(
|
|
108
|
+
"SELECT * FROM files WHERE path = ?", (path,)
|
|
109
|
+
) as cursor:
|
|
110
|
+
row = await cursor.fetchone()
|
|
111
|
+
return _row_to_file(row) if row else None
|
|
112
|
+
|
|
113
|
+
async def get_all_files(self) -> list[FileRecord]:
|
|
114
|
+
async with self.conn.execute("SELECT * FROM files ORDER BY path") as cursor:
|
|
115
|
+
rows = await cursor.fetchall()
|
|
116
|
+
return [_row_to_file(r) for r in rows]
|
|
117
|
+
|
|
118
|
+
async def mark_file_indexed(self, file_id: int) -> None:
|
|
119
|
+
await self.conn.execute(
|
|
120
|
+
"UPDATE files SET status = 'indexed', indexed_at = ?, error_msg = NULL WHERE id = ?",
|
|
121
|
+
(time.time(), file_id),
|
|
122
|
+
)
|
|
123
|
+
await self.conn.commit()
|
|
124
|
+
|
|
125
|
+
async def mark_file_error(self, file_id: int, error_msg: str) -> None:
|
|
126
|
+
await self.conn.execute(
|
|
127
|
+
"UPDATE files SET status = 'error', error_msg = ? WHERE id = ?",
|
|
128
|
+
(error_msg, file_id),
|
|
129
|
+
)
|
|
130
|
+
await self.conn.commit()
|
|
131
|
+
|
|
132
|
+
async def delete_file(self, path: str) -> None:
|
|
133
|
+
"""Deletes file and cascades to its segments."""
|
|
134
|
+
await self.conn.execute("DELETE FROM files WHERE path = ?", (path,))
|
|
135
|
+
await self.conn.commit()
|
|
136
|
+
|
|
137
|
+
# ------------------------------------------------------------------
|
|
138
|
+
# Segments
|
|
139
|
+
# ------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
async def insert_segments(self, file_id: int, segments: list[Segment]) -> None:
|
|
142
|
+
await self.conn.executemany(
|
|
143
|
+
"""
|
|
144
|
+
INSERT OR REPLACE INTO segments
|
|
145
|
+
(id, file_id, content, location_type, location_label, location_index, char_count, bm25_doc_id)
|
|
146
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
147
|
+
""",
|
|
148
|
+
[
|
|
149
|
+
(
|
|
150
|
+
s.id,
|
|
151
|
+
file_id,
|
|
152
|
+
s.content,
|
|
153
|
+
s.location_type,
|
|
154
|
+
s.location_label,
|
|
155
|
+
s.location_index,
|
|
156
|
+
s.char_count,
|
|
157
|
+
s.bm25_doc_id,
|
|
158
|
+
)
|
|
159
|
+
for s in segments
|
|
160
|
+
],
|
|
161
|
+
)
|
|
162
|
+
await self.conn.commit()
|
|
163
|
+
|
|
164
|
+
async def get_segments_for_file(self, file_id: int) -> list[Segment]:
|
|
165
|
+
async with self.conn.execute(
|
|
166
|
+
"""
|
|
167
|
+
SELECT s.*, f.path as file_path FROM segments s
|
|
168
|
+
JOIN files f ON f.id = s.file_id
|
|
169
|
+
WHERE s.file_id = ?
|
|
170
|
+
ORDER BY s.location_index
|
|
171
|
+
""",
|
|
172
|
+
(file_id,),
|
|
173
|
+
) as cursor:
|
|
174
|
+
rows = await cursor.fetchall()
|
|
175
|
+
return [_row_to_segment(r) for r in rows]
|
|
176
|
+
|
|
177
|
+
async def get_segment_by_id(self, segment_id: str) -> Segment | None:
|
|
178
|
+
async with self.conn.execute(
|
|
179
|
+
"""
|
|
180
|
+
SELECT s.*, f.path as file_path FROM segments s
|
|
181
|
+
JOIN files f ON f.id = s.file_id
|
|
182
|
+
WHERE s.id = ?
|
|
183
|
+
""",
|
|
184
|
+
(segment_id,),
|
|
185
|
+
) as cursor:
|
|
186
|
+
row = await cursor.fetchone()
|
|
187
|
+
return _row_to_segment(row) if row else None
|
|
188
|
+
|
|
189
|
+
async def get_bm25_doc_ids_for_file(self, file_id: int) -> list[int]:
|
|
190
|
+
async with self.conn.execute(
|
|
191
|
+
"SELECT bm25_doc_id FROM segments WHERE file_id = ? AND bm25_doc_id IS NOT NULL",
|
|
192
|
+
(file_id,),
|
|
193
|
+
) as cursor:
|
|
194
|
+
rows = await cursor.fetchall()
|
|
195
|
+
return [r["bm25_doc_id"] for r in rows]
|
|
196
|
+
|
|
197
|
+
async def delete_segments_for_file(self, file_id: int) -> None:
|
|
198
|
+
await self.conn.execute("DELETE FROM segments WHERE file_id = ?", (file_id,))
|
|
199
|
+
await self.conn.commit()
|
|
200
|
+
|
|
201
|
+
async def update_bm25_doc_ids(self, segments: list, doc_ids: list[int]) -> None:
|
|
202
|
+
"""Update bm25_doc_id for each segment after an index rebuild."""
|
|
203
|
+
await self.conn.executemany(
|
|
204
|
+
"UPDATE segments SET bm25_doc_id = ? WHERE id = ?",
|
|
205
|
+
[(doc_id, seg.id) for seg, doc_id in zip(segments, doc_ids)],
|
|
206
|
+
)
|
|
207
|
+
await self.conn.commit()
|
|
208
|
+
|
|
209
|
+
async def get_all_segments(self) -> list[Segment]:
|
|
210
|
+
"""Used to rebuild the BM25 index on startup."""
|
|
211
|
+
async with self.conn.execute(
|
|
212
|
+
"""
|
|
213
|
+
SELECT s.*, f.path as file_path FROM segments s
|
|
214
|
+
JOIN files f ON f.id = s.file_id
|
|
215
|
+
ORDER BY s.bm25_doc_id
|
|
216
|
+
"""
|
|
217
|
+
) as cursor:
|
|
218
|
+
rows = await cursor.fetchall()
|
|
219
|
+
return [_row_to_segment(r) for r in rows]
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
# ------------------------------------------------------------------
|
|
223
|
+
# Row helpers
|
|
224
|
+
# ------------------------------------------------------------------
|
|
225
|
+
|
|
226
|
+
def _row_to_file(row: aiosqlite.Row) -> FileRecord:
|
|
227
|
+
return FileRecord(
|
|
228
|
+
id=row["id"],
|
|
229
|
+
path=row["path"],
|
|
230
|
+
hash=row["hash"],
|
|
231
|
+
mtime=row["mtime"],
|
|
232
|
+
size=row["size"],
|
|
233
|
+
mime_type=row["mime_type"],
|
|
234
|
+
status=row["status"],
|
|
235
|
+
indexed_at=row["indexed_at"],
|
|
236
|
+
error_msg=row["error_msg"],
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _row_to_segment(row: aiosqlite.Row) -> Segment:
|
|
241
|
+
return Segment(
|
|
242
|
+
id=row["id"],
|
|
243
|
+
file_path=row["file_path"],
|
|
244
|
+
content=row["content"],
|
|
245
|
+
location_type=row["location_type"],
|
|
246
|
+
location_label=row["location_label"],
|
|
247
|
+
location_index=row["location_index"],
|
|
248
|
+
char_count=row["char_count"],
|
|
249
|
+
bm25_doc_id=row["bm25_doc_id"],
|
|
250
|
+
)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class FileRecord:
|
|
8
|
+
path: str
|
|
9
|
+
hash: str
|
|
10
|
+
mtime: float
|
|
11
|
+
size: int
|
|
12
|
+
mime_type: str
|
|
13
|
+
status: str # pending | indexed | error
|
|
14
|
+
id: int | None = None
|
|
15
|
+
indexed_at: float | None = None
|
|
16
|
+
error_msg: str | None = None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class ExtractedDoc:
|
|
21
|
+
path: str
|
|
22
|
+
text: str
|
|
23
|
+
mime_type: str
|
|
24
|
+
metadata: dict = field(default_factory=dict)
|
|
25
|
+
tables: list = field(default_factory=list) # kreuzberg extracted tables
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class Segment:
|
|
30
|
+
id: str # sha256(path + location_label + str(location_index))
|
|
31
|
+
file_path: str
|
|
32
|
+
content: str
|
|
33
|
+
location_type: str # page | sheet | section | block | table
|
|
34
|
+
location_label: str # "Page 3", "Sheet: Ventas", "# Introducción"
|
|
35
|
+
location_index: int
|
|
36
|
+
char_count: int = 0
|
|
37
|
+
bm25_doc_id: int | None = None # internal BM25X index assigned on add()
|
|
38
|
+
section_id: str | None = None # reference to tree node id
|
|
39
|
+
level: int = 0 # heading level (1-3); 0 for non-heading nodes
|
|
40
|
+
|
|
41
|
+
def __post_init__(self) -> None:
|
|
42
|
+
if not self.char_count:
|
|
43
|
+
self.char_count = len(self.content)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class SearchResult:
|
|
48
|
+
segment_id: str
|
|
49
|
+
score: float
|
|
50
|
+
file_path: str
|
|
51
|
+
mime_type: str
|
|
52
|
+
location_type: str
|
|
53
|
+
location_label: str
|
|
54
|
+
content: str
|
|
55
|
+
snippet: str # ~200 chars alrededor del match
|