docsif 0.1.0__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.
- docsif-0.1.0.dist-info/METADATA +393 -0
- docsif-0.1.0.dist-info/RECORD +86 -0
- docsif-0.1.0.dist-info/WHEEL +4 -0
- docsif-0.1.0.dist-info/entry_points.txt +3 -0
- docsif-0.1.0.dist-info/licenses/LICENSE +21 -0
- sif/__init__.py +24 -0
- sif/_version.py +3 -0
- sif/cli/__init__.py +11 -0
- sif/cli/commands/__init__.py +18 -0
- sif/cli/commands/bench.py +148 -0
- sif/cli/commands/collection.py +360 -0
- sif/cli/commands/context.py +154 -0
- sif/cli/commands/get.py +200 -0
- sif/cli/commands/index.py +294 -0
- sif/cli/commands/ls.py +67 -0
- sif/cli/commands/mcp.py +93 -0
- sif/cli/commands/pull.py +111 -0
- sif/cli/commands/search.py +574 -0
- sif/cli/config.py +206 -0
- sif/cli/formatters.py +269 -0
- sif/cli/main.py +205 -0
- sif/config/__init__.py +30 -0
- sif/config/constants.py +52 -0
- sif/config/settings.py +217 -0
- sif/core/__init__.py +21 -0
- sif/core/collection.py +161 -0
- sif/core/context.py +167 -0
- sif/core/document.py +125 -0
- sif/core/models.py +298 -0
- sif/database/__init__.py +23 -0
- sif/database/connection.py +139 -0
- sif/database/database.py +136 -0
- sif/database/migrations.py +187 -0
- sif/database/repositories.py +500 -0
- sif/database/repository.py +171 -0
- sif/database/schema.py +362 -0
- sif/embedding/__init__.py +22 -0
- sif/embedding/cache.py +196 -0
- sif/embedding/embedder.py +328 -0
- sif/embedding/factory.py +92 -0
- sif/embedding/manager.py +200 -0
- sif/embedding/model.py +126 -0
- sif/indexing/__init__.py +21 -0
- sif/indexing/chunker.py +226 -0
- sif/indexing/indexer.py +226 -0
- sif/indexing/parser.py +192 -0
- sif/indexing/scanner.py +148 -0
- sif/indexing/watcher.py +156 -0
- sif/mcp/README.md +258 -0
- sif/mcp/__init__.py +9 -0
- sif/mcp/claude_desktop_config.json +16 -0
- sif/mcp/cli.py +92 -0
- sif/mcp/protocol.py +336 -0
- sif/mcp/server.py +433 -0
- sif/mcp/server_http.py +54 -0
- sif/mcp/test_mcp.py +108 -0
- sif/mcp/tools.py +497 -0
- sif/mcp/transport_http.py +424 -0
- sif/mcp/transport_stdio.py +292 -0
- sif/mcp_server/__init__.py +26 -0
- sif/mcp_server/handlers.py +68 -0
- sif/mcp_server/server.py +58 -0
- sif/mcp_server/tools.py +216 -0
- sif/mcp_server/transport.py +221 -0
- sif/models/__init__.py +70 -0
- sif/models/collection.py +51 -0
- sif/models/context.py +45 -0
- sif/models/document.py +76 -0
- sif/models/download.py +208 -0
- sif/models/embedding.py +71 -0
- sif/models/search.py +92 -0
- sif/search/__init__.py +22 -0
- sif/search/benchmark.py +110 -0
- sif/search/bm25.py +216 -0
- sif/search/expansion.py +157 -0
- sif/search/hybrid.py +323 -0
- sif/search/rerank.py +390 -0
- sif/search/rrf.py +186 -0
- sif/search/snippets.py +130 -0
- sif/search/strategy.py +97 -0
- sif/search/vector.py +177 -0
- sif/utils/__init__.py +26 -0
- sif/utils/logging.py +143 -0
- sif/utils/paths.py +82 -0
- sif/utils/progress.py +83 -0
- sif/utils/text.py +128 -0
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
"""Repository implementations for SIF database."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sqlite3
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
|
|
9
|
+
from sif.core.models import (
|
|
10
|
+
Collection,
|
|
11
|
+
Document,
|
|
12
|
+
DocumentChunk,
|
|
13
|
+
PathContext,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class CollectionRepository:
|
|
18
|
+
"""Repository for collection operations."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, db: sqlite3.Connection) -> None:
|
|
21
|
+
self.db = db
|
|
22
|
+
|
|
23
|
+
def create(self, collection: Collection) -> Collection:
|
|
24
|
+
"""Create a new collection."""
|
|
25
|
+
self.db.execute(
|
|
26
|
+
"""
|
|
27
|
+
INSERT INTO collections (
|
|
28
|
+
id, name, path, pattern, ignore_patterns, include_by_default,
|
|
29
|
+
description, pre_update_cmd, created_at, updated_at, document_count, chunk_count
|
|
30
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
31
|
+
""",
|
|
32
|
+
(
|
|
33
|
+
collection.id,
|
|
34
|
+
collection.name,
|
|
35
|
+
collection.path,
|
|
36
|
+
collection.pattern,
|
|
37
|
+
json.dumps(collection.ignore_patterns),
|
|
38
|
+
int(collection.include_by_default),
|
|
39
|
+
collection.description,
|
|
40
|
+
collection.pre_update_cmd,
|
|
41
|
+
collection.created_at.isoformat(),
|
|
42
|
+
collection.updated_at.isoformat(),
|
|
43
|
+
collection.document_count,
|
|
44
|
+
collection.chunk_count,
|
|
45
|
+
),
|
|
46
|
+
)
|
|
47
|
+
return collection
|
|
48
|
+
|
|
49
|
+
def get_by_id(self, collection_id: str) -> Collection | None:
|
|
50
|
+
"""Get collection by ID."""
|
|
51
|
+
cursor = self.db.execute("SELECT * FROM collections WHERE id = ?", (collection_id,))
|
|
52
|
+
row = cursor.fetchone()
|
|
53
|
+
return self._row_to_collection(row) if row else None
|
|
54
|
+
|
|
55
|
+
def get_by_name(self, name: str) -> Collection | None:
|
|
56
|
+
"""Get collection by name."""
|
|
57
|
+
cursor = self.db.execute("SELECT * FROM collections WHERE name = ?", (name,))
|
|
58
|
+
row = cursor.fetchone()
|
|
59
|
+
return self._row_to_collection(row) if row else None
|
|
60
|
+
|
|
61
|
+
def list_all(self) -> list[Collection]:
|
|
62
|
+
"""List all collections."""
|
|
63
|
+
cursor = self.db.execute("SELECT * FROM collections ORDER BY name")
|
|
64
|
+
return [self._row_to_collection(row) for row in cursor.fetchall()]
|
|
65
|
+
|
|
66
|
+
def update(self, collection: Collection) -> Collection:
|
|
67
|
+
"""Update a collection."""
|
|
68
|
+
collection.updated_at = datetime.now(timezone.utc)
|
|
69
|
+
self.db.execute(
|
|
70
|
+
"""
|
|
71
|
+
UPDATE collections SET
|
|
72
|
+
name = ?, path = ?, pattern = ?, ignore_patterns = ?,
|
|
73
|
+
include_by_default = ?, description = ?, pre_update_cmd = ?, updated_at = ?,
|
|
74
|
+
document_count = ?, chunk_count = ?, last_indexed_at = ?
|
|
75
|
+
WHERE id = ?
|
|
76
|
+
""",
|
|
77
|
+
(
|
|
78
|
+
collection.name,
|
|
79
|
+
collection.path,
|
|
80
|
+
collection.pattern,
|
|
81
|
+
json.dumps(collection.ignore_patterns),
|
|
82
|
+
int(collection.include_by_default),
|
|
83
|
+
collection.description,
|
|
84
|
+
collection.pre_update_cmd,
|
|
85
|
+
collection.updated_at.isoformat(),
|
|
86
|
+
collection.document_count,
|
|
87
|
+
collection.chunk_count,
|
|
88
|
+
collection.last_indexed_at.isoformat() if collection.last_indexed_at else None,
|
|
89
|
+
collection.id,
|
|
90
|
+
),
|
|
91
|
+
)
|
|
92
|
+
return collection
|
|
93
|
+
|
|
94
|
+
def delete(self, collection_id: str) -> bool:
|
|
95
|
+
"""Delete a collection."""
|
|
96
|
+
cursor = self.db.execute("DELETE FROM collections WHERE id = ?", (collection_id,))
|
|
97
|
+
return cursor.rowcount > 0
|
|
98
|
+
|
|
99
|
+
def exists(self, name: str) -> bool:
|
|
100
|
+
"""Check if collection exists."""
|
|
101
|
+
cursor = self.db.execute("SELECT 1 FROM collections WHERE name = ?", (name,))
|
|
102
|
+
return cursor.fetchone() is not None
|
|
103
|
+
|
|
104
|
+
def list_enabled(self) -> list[Collection]:
|
|
105
|
+
"""List all collections enabled for default searches."""
|
|
106
|
+
cursor = self.db.execute(
|
|
107
|
+
"SELECT * FROM collections WHERE include_by_default = 1 ORDER BY name"
|
|
108
|
+
)
|
|
109
|
+
return [self._row_to_collection(row) for row in cursor.fetchall()]
|
|
110
|
+
|
|
111
|
+
def _row_to_collection(self, row: sqlite3.Row) -> Collection:
|
|
112
|
+
"""Convert database row to Collection."""
|
|
113
|
+
return Collection(
|
|
114
|
+
id=row["id"],
|
|
115
|
+
name=row["name"],
|
|
116
|
+
path=row["path"],
|
|
117
|
+
pattern=row["pattern"],
|
|
118
|
+
ignore_patterns=json.loads(row["ignore_patterns"]),
|
|
119
|
+
include_by_default=bool(row["include_by_default"]),
|
|
120
|
+
description=row["description"],
|
|
121
|
+
pre_update_cmd=row["pre_update_cmd"],
|
|
122
|
+
created_at=datetime.fromisoformat(row["created_at"]),
|
|
123
|
+
updated_at=datetime.fromisoformat(row["updated_at"]),
|
|
124
|
+
document_count=row["document_count"],
|
|
125
|
+
chunk_count=row["chunk_count"],
|
|
126
|
+
last_indexed_at=datetime.fromisoformat(row["last_indexed_at"])
|
|
127
|
+
if row["last_indexed_at"]
|
|
128
|
+
else None,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class DocumentRepository:
|
|
133
|
+
"""Repository for document operations."""
|
|
134
|
+
|
|
135
|
+
def __init__(self, db: sqlite3.Connection) -> None:
|
|
136
|
+
self.db = db
|
|
137
|
+
|
|
138
|
+
def create(self, document: Document) -> Document:
|
|
139
|
+
"""Create a new document."""
|
|
140
|
+
self.db.execute(
|
|
141
|
+
"""
|
|
142
|
+
INSERT INTO documents (
|
|
143
|
+
id, collection_id, path, filename, title, content,
|
|
144
|
+
checksum, file_size, mtime, created_at, updated_at, metadata
|
|
145
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
146
|
+
""",
|
|
147
|
+
(
|
|
148
|
+
document.id,
|
|
149
|
+
document.collection_id,
|
|
150
|
+
document.path,
|
|
151
|
+
document.filename,
|
|
152
|
+
document.title,
|
|
153
|
+
document.content,
|
|
154
|
+
document.checksum,
|
|
155
|
+
document.file_size,
|
|
156
|
+
document.mtime,
|
|
157
|
+
document.created_at.isoformat(),
|
|
158
|
+
document.updated_at.isoformat(),
|
|
159
|
+
json.dumps(document.metadata) if document.metadata else "{}",
|
|
160
|
+
),
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
return document
|
|
164
|
+
|
|
165
|
+
def get_by_id(self, document_id: str) -> Document | None:
|
|
166
|
+
"""Get document by ID."""
|
|
167
|
+
cursor = self.db.execute("SELECT * FROM documents WHERE id = ?", (document_id,))
|
|
168
|
+
row = cursor.fetchone()
|
|
169
|
+
return self._row_to_document(row) if row else None
|
|
170
|
+
|
|
171
|
+
def get_by_path(self, path: str, collection_id: str) -> Document | None:
|
|
172
|
+
"""Get document by path and collection."""
|
|
173
|
+
cursor = self.db.execute(
|
|
174
|
+
"SELECT * FROM documents WHERE path = ? AND collection_id = ?", (path, collection_id)
|
|
175
|
+
)
|
|
176
|
+
row = cursor.fetchone()
|
|
177
|
+
return self._row_to_document(row) if row else None
|
|
178
|
+
|
|
179
|
+
def get_by_filename(self, filename: str) -> Document | None:
|
|
180
|
+
"""Get document by filename (across all collections).
|
|
181
|
+
|
|
182
|
+
Returns the first match if multiple documents share the same filename.
|
|
183
|
+
"""
|
|
184
|
+
cursor = self.db.execute("SELECT * FROM documents WHERE filename = ? LIMIT 1", (filename,))
|
|
185
|
+
row = cursor.fetchone()
|
|
186
|
+
return self._row_to_document(row) if row else None
|
|
187
|
+
|
|
188
|
+
def list_by_collection(self, collection_id: str) -> list[Document]:
|
|
189
|
+
"""List documents in a collection."""
|
|
190
|
+
cursor = self.db.execute(
|
|
191
|
+
"SELECT * FROM documents WHERE collection_id = ? ORDER BY filename", (collection_id,)
|
|
192
|
+
)
|
|
193
|
+
return [self._row_to_document(row) for row in cursor.fetchall()]
|
|
194
|
+
|
|
195
|
+
def update(self, document: Document) -> Document:
|
|
196
|
+
"""Update a document."""
|
|
197
|
+
document.updated_at = datetime.now(timezone.utc)
|
|
198
|
+
self.db.execute(
|
|
199
|
+
"""
|
|
200
|
+
UPDATE documents SET
|
|
201
|
+
title = ?, content = ?, checksum = ?, file_size = ?,
|
|
202
|
+
mtime = ?, updated_at = ?, metadata = ?
|
|
203
|
+
WHERE id = ?
|
|
204
|
+
""",
|
|
205
|
+
(
|
|
206
|
+
document.title,
|
|
207
|
+
document.content,
|
|
208
|
+
document.checksum,
|
|
209
|
+
document.file_size,
|
|
210
|
+
document.mtime,
|
|
211
|
+
document.updated_at.isoformat(),
|
|
212
|
+
json.dumps(document.metadata) if document.metadata else "{}",
|
|
213
|
+
document.id,
|
|
214
|
+
),
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
return document
|
|
218
|
+
|
|
219
|
+
def delete(self, document_id: str) -> bool:
|
|
220
|
+
"""Delete a document."""
|
|
221
|
+
# Delete document chunks first
|
|
222
|
+
from sif.database.repositories import DocumentChunkRepository # noqa: PLC0415
|
|
223
|
+
|
|
224
|
+
chunk_repo = DocumentChunkRepository(self.db)
|
|
225
|
+
chunk_repo.delete_by_document(document_id)
|
|
226
|
+
|
|
227
|
+
cursor = self.db.execute("DELETE FROM documents WHERE id = ?", (document_id,))
|
|
228
|
+
return cursor.rowcount > 0
|
|
229
|
+
|
|
230
|
+
def delete_by_collection(self, collection_id: str) -> int:
|
|
231
|
+
"""Delete all documents in a collection."""
|
|
232
|
+
# Get document IDs
|
|
233
|
+
cursor = self.db.execute(
|
|
234
|
+
"SELECT id FROM documents WHERE collection_id = ?", (collection_id,)
|
|
235
|
+
)
|
|
236
|
+
doc_ids = [row[0] for row in cursor.fetchall()]
|
|
237
|
+
|
|
238
|
+
# Delete chunks for each document
|
|
239
|
+
from sif.database.repositories import DocumentChunkRepository # noqa: PLC0415
|
|
240
|
+
|
|
241
|
+
chunk_repo = DocumentChunkRepository(self.db)
|
|
242
|
+
for doc_id in doc_ids:
|
|
243
|
+
chunk_repo.delete_by_document(doc_id)
|
|
244
|
+
|
|
245
|
+
cursor = self.db.execute("DELETE FROM documents WHERE collection_id = ?", (collection_id,))
|
|
246
|
+
return cursor.rowcount
|
|
247
|
+
|
|
248
|
+
def get_checksum(self, document_id: str) -> str | None:
|
|
249
|
+
"""Get document checksum."""
|
|
250
|
+
cursor = self.db.execute("SELECT checksum FROM documents WHERE id = ?", (document_id,))
|
|
251
|
+
row = cursor.fetchone()
|
|
252
|
+
return row[0] if row else None
|
|
253
|
+
|
|
254
|
+
def exists(self, path: str, collection_id: str) -> bool:
|
|
255
|
+
"""Check if document exists."""
|
|
256
|
+
cursor = self.db.execute(
|
|
257
|
+
"SELECT 1 FROM documents WHERE path = ? AND collection_id = ?", (path, collection_id)
|
|
258
|
+
)
|
|
259
|
+
return cursor.fetchone() is not None
|
|
260
|
+
|
|
261
|
+
def _row_to_document(self, row: sqlite3.Row) -> Document:
|
|
262
|
+
"""Convert database row to Document."""
|
|
263
|
+
doc = Document(
|
|
264
|
+
id=row["id"],
|
|
265
|
+
path=row["path"],
|
|
266
|
+
collection_id=row["collection_id"],
|
|
267
|
+
content=row["content"],
|
|
268
|
+
title=row["title"],
|
|
269
|
+
)
|
|
270
|
+
doc.filename = row["filename"]
|
|
271
|
+
doc.checksum = row["checksum"]
|
|
272
|
+
doc.file_size = row["file_size"]
|
|
273
|
+
doc.mtime = row["mtime"]
|
|
274
|
+
doc.created_at = datetime.fromisoformat(row["created_at"])
|
|
275
|
+
doc.updated_at = datetime.fromisoformat(row["updated_at"])
|
|
276
|
+
doc.metadata = json.loads(row["metadata"])
|
|
277
|
+
return doc
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
class DocumentChunkRepository:
|
|
281
|
+
"""Repository for document chunk operations."""
|
|
282
|
+
|
|
283
|
+
def __init__(self, db: sqlite3.Connection) -> None:
|
|
284
|
+
self.db = db
|
|
285
|
+
|
|
286
|
+
def create(self, chunk: DocumentChunk) -> DocumentChunk:
|
|
287
|
+
"""Create a new chunk."""
|
|
288
|
+
self.db.execute(
|
|
289
|
+
"""
|
|
290
|
+
INSERT INTO document_chunks (
|
|
291
|
+
id, document_id, sequence, content, start_pos, end_pos,
|
|
292
|
+
token_count, embedding_id, created_at
|
|
293
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
294
|
+
""",
|
|
295
|
+
(
|
|
296
|
+
chunk.id,
|
|
297
|
+
chunk.document_id,
|
|
298
|
+
chunk.sequence,
|
|
299
|
+
chunk.content,
|
|
300
|
+
chunk.start_pos,
|
|
301
|
+
chunk.end_pos,
|
|
302
|
+
chunk.token_count,
|
|
303
|
+
chunk.embedding_id,
|
|
304
|
+
datetime.now(timezone.utc).isoformat(),
|
|
305
|
+
),
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
return chunk
|
|
309
|
+
|
|
310
|
+
def create_many(self, chunks: list[DocumentChunk]) -> list[DocumentChunk]:
|
|
311
|
+
"""Create multiple chunks."""
|
|
312
|
+
for chunk in chunks:
|
|
313
|
+
self.create(chunk)
|
|
314
|
+
return chunks
|
|
315
|
+
|
|
316
|
+
def get_by_document(self, document_id: str) -> list[DocumentChunk]:
|
|
317
|
+
"""Get chunks for a document."""
|
|
318
|
+
cursor = self.db.execute(
|
|
319
|
+
"""
|
|
320
|
+
SELECT * FROM document_chunks
|
|
321
|
+
WHERE document_id = ?
|
|
322
|
+
ORDER BY sequence
|
|
323
|
+
""",
|
|
324
|
+
(document_id,),
|
|
325
|
+
)
|
|
326
|
+
return [self._row_to_chunk(row) for row in cursor.fetchall()]
|
|
327
|
+
|
|
328
|
+
def delete_by_document(self, document_id: str) -> int:
|
|
329
|
+
"""Delete all chunks for a document."""
|
|
330
|
+
cursor = self.db.execute(
|
|
331
|
+
"DELETE FROM document_chunks WHERE document_id = ?", (document_id,)
|
|
332
|
+
)
|
|
333
|
+
return cursor.rowcount
|
|
334
|
+
|
|
335
|
+
def _row_to_chunk(self, row: sqlite3.Row) -> DocumentChunk:
|
|
336
|
+
"""Convert database row to DocumentChunk."""
|
|
337
|
+
return DocumentChunk(
|
|
338
|
+
id=row["id"],
|
|
339
|
+
document_id=row["document_id"],
|
|
340
|
+
content=row["content"],
|
|
341
|
+
sequence=row["sequence"],
|
|
342
|
+
start_pos=row["start_pos"],
|
|
343
|
+
end_pos=row["end_pos"],
|
|
344
|
+
token_count=row["token_count"],
|
|
345
|
+
embedding_id=row["embedding_id"],
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
class ContextRepository:
|
|
350
|
+
"""Repository for context operations."""
|
|
351
|
+
|
|
352
|
+
def __init__(self, db: sqlite3.Connection) -> None:
|
|
353
|
+
self.db = db
|
|
354
|
+
|
|
355
|
+
def create(self, context: PathContext) -> PathContext:
|
|
356
|
+
"""Create a new context."""
|
|
357
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
358
|
+
self.db.execute(
|
|
359
|
+
"""
|
|
360
|
+
INSERT INTO contexts (id, target_id, context_type, content, created_at, updated_at)
|
|
361
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
362
|
+
""",
|
|
363
|
+
(context.id, context.path, context.context_type, context.context, now, now),
|
|
364
|
+
)
|
|
365
|
+
return context
|
|
366
|
+
|
|
367
|
+
def get_by_target(self, target_id: str, context_type: str = "path") -> PathContext | None:
|
|
368
|
+
"""Get context by target_id and context_type."""
|
|
369
|
+
cursor = self.db.execute(
|
|
370
|
+
"SELECT * FROM contexts WHERE target_id = ? AND context_type = ?",
|
|
371
|
+
(target_id, context_type),
|
|
372
|
+
)
|
|
373
|
+
row = cursor.fetchone()
|
|
374
|
+
return self._row_to_context(row) if row else None
|
|
375
|
+
|
|
376
|
+
def get_by_path(self, path: str) -> PathContext | None:
|
|
377
|
+
"""Get context by path (backward-compatible alias)."""
|
|
378
|
+
return self.get_by_target(path, "path")
|
|
379
|
+
|
|
380
|
+
def list_all(self) -> list[PathContext]:
|
|
381
|
+
"""List all contexts."""
|
|
382
|
+
cursor = self.db.execute("SELECT * FROM contexts ORDER BY context_type, target_id")
|
|
383
|
+
return [self._row_to_context(row) for row in cursor.fetchall()]
|
|
384
|
+
|
|
385
|
+
def list_by_type(self, context_type: str) -> list[PathContext]:
|
|
386
|
+
"""List contexts by type."""
|
|
387
|
+
cursor = self.db.execute(
|
|
388
|
+
"SELECT * FROM contexts WHERE context_type = ? ORDER BY target_id", (context_type,)
|
|
389
|
+
)
|
|
390
|
+
return [self._row_to_context(row) for row in cursor.fetchall()]
|
|
391
|
+
|
|
392
|
+
def list_by_collection(self, collection_id: str) -> list[PathContext]:
|
|
393
|
+
"""List contexts for a collection."""
|
|
394
|
+
cursor = self.db.execute(
|
|
395
|
+
"SELECT * FROM contexts WHERE context_type = 'collection' "
|
|
396
|
+
"AND target_id = ? ORDER BY target_id",
|
|
397
|
+
(collection_id,),
|
|
398
|
+
)
|
|
399
|
+
return [self._row_to_context(row) for row in cursor.fetchall()]
|
|
400
|
+
|
|
401
|
+
def update(self, context: PathContext) -> PathContext:
|
|
402
|
+
"""Update a context."""
|
|
403
|
+
self.db.execute(
|
|
404
|
+
"""
|
|
405
|
+
UPDATE contexts
|
|
406
|
+
SET content = ?, updated_at = ?
|
|
407
|
+
WHERE id = ?
|
|
408
|
+
""",
|
|
409
|
+
(context.context, datetime.now(timezone.utc).isoformat(), context.id),
|
|
410
|
+
)
|
|
411
|
+
return context
|
|
412
|
+
|
|
413
|
+
def delete(self, context_id: str) -> bool:
|
|
414
|
+
"""Delete a context."""
|
|
415
|
+
cursor = self.db.execute("DELETE FROM contexts WHERE id = ?", (context_id,))
|
|
416
|
+
return cursor.rowcount > 0
|
|
417
|
+
|
|
418
|
+
def delete_orphaned_paths(self) -> int:
|
|
419
|
+
"""Delete path contexts whose target_id no longer exists in documents table."""
|
|
420
|
+
cursor = self.db.execute("""
|
|
421
|
+
DELETE FROM contexts
|
|
422
|
+
WHERE context_type = 'path'
|
|
423
|
+
AND target_id NOT IN (SELECT path FROM documents)
|
|
424
|
+
""")
|
|
425
|
+
return cursor.rowcount
|
|
426
|
+
|
|
427
|
+
def _row_to_context(self, row: sqlite3.Row) -> PathContext:
|
|
428
|
+
"""Convert database row to PathContext."""
|
|
429
|
+
return PathContext(
|
|
430
|
+
id=row["id"],
|
|
431
|
+
path=row["target_id"],
|
|
432
|
+
collection_id=None, # No longer stored; acceptable for transition
|
|
433
|
+
context_type=row["context_type"],
|
|
434
|
+
context=row["content"],
|
|
435
|
+
created_at=datetime.fromisoformat(row["created_at"]),
|
|
436
|
+
updated_at=datetime.fromisoformat(row["updated_at"]),
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
# Backward-compatible alias
|
|
441
|
+
PathContextRepository = ContextRepository
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
class LLMCacheRepository:
|
|
445
|
+
"""Repository for LLM cache operations."""
|
|
446
|
+
|
|
447
|
+
def __init__(self, db: sqlite3.Connection) -> None:
|
|
448
|
+
self.db = db
|
|
449
|
+
|
|
450
|
+
def get(self, model_name: str, prompt_hash: str) -> str | None:
|
|
451
|
+
"""Get cached response."""
|
|
452
|
+
cursor = self.db.execute(
|
|
453
|
+
"""
|
|
454
|
+
SELECT response FROM llm_cache
|
|
455
|
+
WHERE model_name = ? AND prompt_hash = ?
|
|
456
|
+
AND (expires_at IS NULL OR expires_at > ?)
|
|
457
|
+
""",
|
|
458
|
+
(model_name, prompt_hash, datetime.now(timezone.utc).isoformat()),
|
|
459
|
+
)
|
|
460
|
+
row = cursor.fetchone()
|
|
461
|
+
return row[0] if row else None
|
|
462
|
+
|
|
463
|
+
def set(
|
|
464
|
+
self,
|
|
465
|
+
model_name: str,
|
|
466
|
+
prompt_hash: str,
|
|
467
|
+
prompt: str,
|
|
468
|
+
response: str,
|
|
469
|
+
ttl_seconds: int | None = None,
|
|
470
|
+
) -> None:
|
|
471
|
+
"""Cache a response."""
|
|
472
|
+
expires_at = None
|
|
473
|
+
if ttl_seconds:
|
|
474
|
+
expires_at = datetime.now(timezone.utc).timestamp() + ttl_seconds
|
|
475
|
+
expires_at = datetime.fromtimestamp(expires_at).isoformat()
|
|
476
|
+
|
|
477
|
+
self.db.execute(
|
|
478
|
+
"""
|
|
479
|
+
INSERT OR REPLACE INTO llm_cache
|
|
480
|
+
(id, model_name, prompt_hash, prompt, response, created_at, expires_at)
|
|
481
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
482
|
+
""",
|
|
483
|
+
(
|
|
484
|
+
f"{model_name}:{prompt_hash}",
|
|
485
|
+
model_name,
|
|
486
|
+
prompt_hash,
|
|
487
|
+
prompt,
|
|
488
|
+
response,
|
|
489
|
+
datetime.now(timezone.utc).isoformat(),
|
|
490
|
+
expires_at,
|
|
491
|
+
),
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
def clear_expired(self) -> int:
|
|
495
|
+
"""Clear expired cache entries."""
|
|
496
|
+
cursor = self.db.execute(
|
|
497
|
+
"DELETE FROM llm_cache WHERE expires_at IS NOT NULL AND expires_at <= ?",
|
|
498
|
+
(datetime.now(timezone.utc).isoformat(),),
|
|
499
|
+
)
|
|
500
|
+
return cursor.rowcount
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Repository interface definitions."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from typing import Any, Generic, TypeVar
|
|
5
|
+
|
|
6
|
+
from sif.core.collection import Collection
|
|
7
|
+
from sif.core.context import Context, ContextType
|
|
8
|
+
from sif.core.document import Document, DocumentChunk
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
T = TypeVar("T")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Repository(ABC, Generic[T]):
|
|
15
|
+
"""Abstract base class for repositories.
|
|
16
|
+
|
|
17
|
+
Implements the Repository pattern for data access abstraction.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
@abstractmethod
|
|
21
|
+
def get_by_id(self, entity_id: str) -> T | None:
|
|
22
|
+
"""Get an entity by ID."""
|
|
23
|
+
...
|
|
24
|
+
|
|
25
|
+
@abstractmethod
|
|
26
|
+
def create(self, entity: T) -> T:
|
|
27
|
+
"""Create a new entity."""
|
|
28
|
+
...
|
|
29
|
+
|
|
30
|
+
@abstractmethod
|
|
31
|
+
def update(self, entity: T) -> T:
|
|
32
|
+
"""Update an existing entity."""
|
|
33
|
+
...
|
|
34
|
+
|
|
35
|
+
@abstractmethod
|
|
36
|
+
def delete(self, entity_id: str) -> bool:
|
|
37
|
+
"""Delete an entity by ID."""
|
|
38
|
+
...
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class CollectionRepository(Repository[Collection]):
|
|
42
|
+
"""Repository for Collection entities."""
|
|
43
|
+
|
|
44
|
+
@abstractmethod
|
|
45
|
+
def get_by_name(self, name: str) -> Collection | None:
|
|
46
|
+
"""Get a collection by name."""
|
|
47
|
+
...
|
|
48
|
+
|
|
49
|
+
@abstractmethod
|
|
50
|
+
def list_all(self) -> list[Collection]:
|
|
51
|
+
"""List all collections."""
|
|
52
|
+
...
|
|
53
|
+
|
|
54
|
+
@abstractmethod
|
|
55
|
+
def exists(self, name: str) -> bool:
|
|
56
|
+
"""Check if a collection with the given name exists."""
|
|
57
|
+
...
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class DocumentRepository(Repository[Document]):
|
|
61
|
+
"""Repository for Document entities."""
|
|
62
|
+
|
|
63
|
+
@abstractmethod
|
|
64
|
+
def get_by_path(self, path: str, collection_id: str) -> Document | None:
|
|
65
|
+
"""Get a document by path within a collection."""
|
|
66
|
+
...
|
|
67
|
+
|
|
68
|
+
@abstractmethod
|
|
69
|
+
def list_by_collection(self, collection_id: str) -> list[Document]:
|
|
70
|
+
"""List all documents in a collection."""
|
|
71
|
+
...
|
|
72
|
+
|
|
73
|
+
@abstractmethod
|
|
74
|
+
def delete_by_collection(self, collection_id: str) -> int:
|
|
75
|
+
"""Delete all documents in a collection. Returns count deleted."""
|
|
76
|
+
...
|
|
77
|
+
|
|
78
|
+
@abstractmethod
|
|
79
|
+
def exists(self, path: str, collection_id: str) -> bool:
|
|
80
|
+
"""Check if a document exists at the given path."""
|
|
81
|
+
...
|
|
82
|
+
|
|
83
|
+
@abstractmethod
|
|
84
|
+
def get_checksum(self, document_id: str) -> str | None:
|
|
85
|
+
"""Get the checksum of a document."""
|
|
86
|
+
...
|
|
87
|
+
|
|
88
|
+
@abstractmethod
|
|
89
|
+
def get_chunks(self, document_id: str) -> list[DocumentChunk]:
|
|
90
|
+
"""Get all chunks for a document."""
|
|
91
|
+
...
|
|
92
|
+
|
|
93
|
+
@abstractmethod
|
|
94
|
+
def save_chunks(self, document_id: str, chunks: list[DocumentChunk]) -> list[DocumentChunk]:
|
|
95
|
+
"""Save chunks for a document."""
|
|
96
|
+
...
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class ContextRepository(Repository[Context]):
|
|
100
|
+
"""Repository for Context entities."""
|
|
101
|
+
|
|
102
|
+
@abstractmethod
|
|
103
|
+
def get_by_target(self, target_id: str, context_type: ContextType) -> Context | None:
|
|
104
|
+
"""Get context by target ID and type."""
|
|
105
|
+
...
|
|
106
|
+
|
|
107
|
+
@abstractmethod
|
|
108
|
+
def list_by_type(self, context_type: ContextType) -> list[Context]:
|
|
109
|
+
"""List all contexts of a specific type."""
|
|
110
|
+
...
|
|
111
|
+
|
|
112
|
+
@abstractmethod
|
|
113
|
+
def list_all(self) -> list[Context]:
|
|
114
|
+
"""List all contexts."""
|
|
115
|
+
...
|
|
116
|
+
|
|
117
|
+
@abstractmethod
|
|
118
|
+
def delete_by_target(self, target_id: str) -> bool:
|
|
119
|
+
"""Delete all contexts for a target."""
|
|
120
|
+
...
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class SearchRepository(ABC):
|
|
124
|
+
"""Repository for search operations."""
|
|
125
|
+
|
|
126
|
+
@abstractmethod
|
|
127
|
+
def search_fts(
|
|
128
|
+
self,
|
|
129
|
+
query: str,
|
|
130
|
+
collection_ids: list[str] | None,
|
|
131
|
+
limit: int,
|
|
132
|
+
offset: int,
|
|
133
|
+
) -> list[tuple[str, float]]:
|
|
134
|
+
"""Search using full-text search (BM25).
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
List of (document_id, score) tuples
|
|
138
|
+
"""
|
|
139
|
+
...
|
|
140
|
+
|
|
141
|
+
@abstractmethod
|
|
142
|
+
def search_vector(
|
|
143
|
+
self,
|
|
144
|
+
embedding: list[float],
|
|
145
|
+
collection_ids: list[str] | None,
|
|
146
|
+
limit: int,
|
|
147
|
+
offset: int,
|
|
148
|
+
) -> list[tuple[str, float]]:
|
|
149
|
+
"""Search using vector similarity.
|
|
150
|
+
|
|
151
|
+
Returns:
|
|
152
|
+
List of (document_id, score) tuples
|
|
153
|
+
"""
|
|
154
|
+
...
|
|
155
|
+
|
|
156
|
+
@abstractmethod
|
|
157
|
+
def get_document_for_result(
|
|
158
|
+
self, document_id: str, include_chunks: bool = True
|
|
159
|
+
) -> dict[str, Any] | None:
|
|
160
|
+
"""Get document details for a search result."""
|
|
161
|
+
...
|
|
162
|
+
|
|
163
|
+
@abstractmethod
|
|
164
|
+
def get_highlights(
|
|
165
|
+
self,
|
|
166
|
+
document_id: str,
|
|
167
|
+
query: str,
|
|
168
|
+
max_highlights: int = 3,
|
|
169
|
+
) -> list[str]:
|
|
170
|
+
"""Get highlighted snippets for a document."""
|
|
171
|
+
...
|