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.
@@ -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
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