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.
Files changed (86) hide show
  1. docsif-0.1.0.dist-info/METADATA +393 -0
  2. docsif-0.1.0.dist-info/RECORD +86 -0
  3. docsif-0.1.0.dist-info/WHEEL +4 -0
  4. docsif-0.1.0.dist-info/entry_points.txt +3 -0
  5. docsif-0.1.0.dist-info/licenses/LICENSE +21 -0
  6. sif/__init__.py +24 -0
  7. sif/_version.py +3 -0
  8. sif/cli/__init__.py +11 -0
  9. sif/cli/commands/__init__.py +18 -0
  10. sif/cli/commands/bench.py +148 -0
  11. sif/cli/commands/collection.py +360 -0
  12. sif/cli/commands/context.py +154 -0
  13. sif/cli/commands/get.py +200 -0
  14. sif/cli/commands/index.py +294 -0
  15. sif/cli/commands/ls.py +67 -0
  16. sif/cli/commands/mcp.py +93 -0
  17. sif/cli/commands/pull.py +111 -0
  18. sif/cli/commands/search.py +574 -0
  19. sif/cli/config.py +206 -0
  20. sif/cli/formatters.py +269 -0
  21. sif/cli/main.py +205 -0
  22. sif/config/__init__.py +30 -0
  23. sif/config/constants.py +52 -0
  24. sif/config/settings.py +217 -0
  25. sif/core/__init__.py +21 -0
  26. sif/core/collection.py +161 -0
  27. sif/core/context.py +167 -0
  28. sif/core/document.py +125 -0
  29. sif/core/models.py +298 -0
  30. sif/database/__init__.py +23 -0
  31. sif/database/connection.py +139 -0
  32. sif/database/database.py +136 -0
  33. sif/database/migrations.py +187 -0
  34. sif/database/repositories.py +500 -0
  35. sif/database/repository.py +171 -0
  36. sif/database/schema.py +362 -0
  37. sif/embedding/__init__.py +22 -0
  38. sif/embedding/cache.py +196 -0
  39. sif/embedding/embedder.py +328 -0
  40. sif/embedding/factory.py +92 -0
  41. sif/embedding/manager.py +200 -0
  42. sif/embedding/model.py +126 -0
  43. sif/indexing/__init__.py +21 -0
  44. sif/indexing/chunker.py +226 -0
  45. sif/indexing/indexer.py +226 -0
  46. sif/indexing/parser.py +192 -0
  47. sif/indexing/scanner.py +148 -0
  48. sif/indexing/watcher.py +156 -0
  49. sif/mcp/README.md +258 -0
  50. sif/mcp/__init__.py +9 -0
  51. sif/mcp/claude_desktop_config.json +16 -0
  52. sif/mcp/cli.py +92 -0
  53. sif/mcp/protocol.py +336 -0
  54. sif/mcp/server.py +433 -0
  55. sif/mcp/server_http.py +54 -0
  56. sif/mcp/test_mcp.py +108 -0
  57. sif/mcp/tools.py +497 -0
  58. sif/mcp/transport_http.py +424 -0
  59. sif/mcp/transport_stdio.py +292 -0
  60. sif/mcp_server/__init__.py +26 -0
  61. sif/mcp_server/handlers.py +68 -0
  62. sif/mcp_server/server.py +58 -0
  63. sif/mcp_server/tools.py +216 -0
  64. sif/mcp_server/transport.py +221 -0
  65. sif/models/__init__.py +70 -0
  66. sif/models/collection.py +51 -0
  67. sif/models/context.py +45 -0
  68. sif/models/document.py +76 -0
  69. sif/models/download.py +208 -0
  70. sif/models/embedding.py +71 -0
  71. sif/models/search.py +92 -0
  72. sif/search/__init__.py +22 -0
  73. sif/search/benchmark.py +110 -0
  74. sif/search/bm25.py +216 -0
  75. sif/search/expansion.py +157 -0
  76. sif/search/hybrid.py +323 -0
  77. sif/search/rerank.py +390 -0
  78. sif/search/rrf.py +186 -0
  79. sif/search/snippets.py +130 -0
  80. sif/search/strategy.py +97 -0
  81. sif/search/vector.py +177 -0
  82. sif/utils/__init__.py +26 -0
  83. sif/utils/logging.py +143 -0
  84. sif/utils/paths.py +82 -0
  85. sif/utils/progress.py +83 -0
  86. 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
+ ...