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
sif/core/document.py ADDED
@@ -0,0 +1,125 @@
1
+ """Document domain entity."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from datetime import datetime, timezone
5
+ from typing import Any, Protocol
6
+
7
+
8
+ @dataclass
9
+ class DocumentMetadata:
10
+ """Metadata extracted from document frontmatter."""
11
+
12
+ title: str | None = None
13
+ author: str | None = None
14
+ date: datetime | None = None
15
+ tags: list[str] = field(default_factory=list)
16
+ categories: list[str] = field(default_factory=list)
17
+ custom: dict[str, Any] = field(default_factory=dict)
18
+
19
+
20
+ @dataclass
21
+ class DocumentChunk:
22
+ """A chunk of a document with embedding information."""
23
+
24
+ id: str
25
+ document_id: str
26
+ content: str
27
+ start_line: int
28
+ end_line: int
29
+ token_count: int
30
+ embedding_id: str | None = None
31
+ embedding: list[float] | None = None
32
+
33
+ def __post_init__(self) -> None:
34
+ """Validate chunk data."""
35
+ if self.start_line > self.end_line:
36
+ raise ValueError("start_line cannot be greater than end_line")
37
+
38
+
39
+ @dataclass
40
+ class Document:
41
+ """Domain entity representing a document.
42
+
43
+ A document is a single markdown file that has been indexed.
44
+ It contains the document content, metadata, and chunks.
45
+ """
46
+
47
+ id: str
48
+ collection_id: str
49
+ path: str
50
+ content: str
51
+ checksum: str
52
+ file_size: int
53
+ metadata: DocumentMetadata = field(default_factory=DocumentMetadata)
54
+ chunks: list[DocumentChunk] = field(default_factory=list)
55
+ created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
56
+ updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
57
+ indexed_at: datetime | None = None
58
+
59
+ @property
60
+ def title(self) -> str | None:
61
+ """Get document title from metadata or path."""
62
+ if self.metadata.title:
63
+ return self.metadata.title
64
+ # Extract filename as fallback
65
+ import os # noqa: PLC0415
66
+
67
+ return os.path.splitext(os.path.basename(self.path))[0]
68
+
69
+ @property
70
+ def chunk_count(self) -> int:
71
+ """Get the number of chunks."""
72
+ return len(self.chunks)
73
+
74
+ def add_chunk(self, chunk: DocumentChunk) -> None:
75
+ """Add a chunk to the document."""
76
+ self.chunks.append(chunk)
77
+
78
+ def clear_chunks(self) -> None:
79
+ """Remove all chunks from the document."""
80
+ self.chunks.clear()
81
+
82
+ def mark_indexed(self) -> None:
83
+ """Mark the document as indexed."""
84
+ self.indexed_at = datetime.now(timezone.utc)
85
+ self.updated_at = datetime.now(timezone.utc)
86
+
87
+
88
+ class DocumentRepository(Protocol):
89
+ """Protocol for document repository operations."""
90
+
91
+ def get_by_id(self, document_id: str) -> Document | None:
92
+ """Get a document by ID."""
93
+ ...
94
+
95
+ def get_by_path(self, path: str, collection_id: str) -> Document | None:
96
+ """Get a document by path within a collection."""
97
+ ...
98
+
99
+ def list_by_collection(self, collection_id: str) -> list[Document]:
100
+ """List all documents in a collection."""
101
+ ...
102
+
103
+ def create(self, document: Document) -> Document:
104
+ """Create a new document."""
105
+ ...
106
+
107
+ def update(self, document: Document) -> Document:
108
+ """Update an existing document."""
109
+ ...
110
+
111
+ def delete(self, document_id: str) -> bool:
112
+ """Delete a document by ID."""
113
+ ...
114
+
115
+ def delete_by_collection(self, collection_id: str) -> int:
116
+ """Delete all documents in a collection. Returns count deleted."""
117
+ ...
118
+
119
+ def exists(self, path: str, collection_id: str) -> bool:
120
+ """Check if a document exists at the given path."""
121
+ ...
122
+
123
+ def get_checksum(self, document_id: str) -> str | None:
124
+ """Get the checksum of a document."""
125
+ ...
sif/core/models.py ADDED
@@ -0,0 +1,298 @@
1
+ """Core data models for SIF."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import uuid
7
+ from dataclasses import dataclass, field
8
+ from datetime import datetime, timezone
9
+ from enum import Enum
10
+ from pathlib import Path
11
+ from typing import Any, Protocol
12
+
13
+
14
+ class ChunkStrategy(Enum):
15
+ """Document chunking strategies."""
16
+
17
+ FIXED = "fixed"
18
+ MARKDOWN = "markdown"
19
+ CODE = "code"
20
+ AUTO = "auto"
21
+
22
+
23
+ class SearchType(Enum):
24
+ """Search types."""
25
+
26
+ BM25 = "bm25"
27
+ VECTOR = "vector"
28
+ HYBRID = "hybrid"
29
+ HYDE = "hyde"
30
+ EXPAND = "expand"
31
+
32
+
33
+ @dataclass
34
+ class Collection:
35
+ """A document collection."""
36
+
37
+ name: str
38
+ path: str
39
+ pattern: str = "**/*.md"
40
+ ignore_patterns: list[str] = field(default_factory=list)
41
+ include_by_default: bool = True
42
+ description: str | None = None
43
+ pre_update_cmd: str | None = None
44
+ id: str = field(default_factory=lambda: str(uuid.uuid4()))
45
+ created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
46
+ updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
47
+ document_count: int = 0
48
+ chunk_count: int = 0
49
+ last_indexed_at: datetime | None = None
50
+
51
+ def mark_indexed(self) -> None:
52
+ """Mark collection as indexed, updating timestamps."""
53
+ self.last_indexed_at = datetime.now(timezone.utc)
54
+ self.updated_at = datetime.now(timezone.utc)
55
+
56
+ def to_dict(self) -> dict[str, Any]:
57
+ """Convert to dictionary."""
58
+ return {
59
+ "id": self.id,
60
+ "name": self.name,
61
+ "path": self.path,
62
+ "pattern": self.pattern,
63
+ "ignore_patterns": self.ignore_patterns,
64
+ "include_by_default": self.include_by_default,
65
+ "description": self.description,
66
+ "pre_update_cmd": self.pre_update_cmd,
67
+ "created_at": self.created_at.isoformat(),
68
+ "updated_at": self.updated_at.isoformat(),
69
+ "document_count": self.document_count,
70
+ "chunk_count": self.chunk_count,
71
+ "last_indexed_at": self.last_indexed_at.isoformat() if self.last_indexed_at else None,
72
+ }
73
+
74
+ @classmethod
75
+ def from_dict(cls, data: dict[str, Any]) -> Collection:
76
+ """Create from dictionary."""
77
+ return cls(
78
+ id=data.get("id", str(uuid.uuid4())),
79
+ name=data["name"],
80
+ path=data["path"],
81
+ pattern=data.get("pattern", "**/*.md"),
82
+ ignore_patterns=data.get("ignore_patterns", []),
83
+ include_by_default=data.get("include_by_default", True),
84
+ description=data.get("description"),
85
+ pre_update_cmd=data.get("pre_update_cmd"),
86
+ created_at=datetime.fromisoformat(data["created_at"])
87
+ if "created_at" in data
88
+ else datetime.now(timezone.utc),
89
+ updated_at=datetime.fromisoformat(data["updated_at"])
90
+ if "updated_at" in data
91
+ else datetime.now(timezone.utc),
92
+ document_count=data.get("document_count", 0),
93
+ chunk_count=data.get("chunk_count", 0),
94
+ last_indexed_at=datetime.fromisoformat(data["last_indexed_at"])
95
+ if data.get("last_indexed_at")
96
+ else None,
97
+ )
98
+
99
+
100
+ @dataclass
101
+ class DocumentChunk:
102
+ """A chunk of a document."""
103
+
104
+ content: str
105
+ sequence: int
106
+ start_pos: int
107
+ end_pos: int
108
+ id: str = field(default_factory=lambda: str(uuid.uuid4()))
109
+ document_id: str | None = None
110
+ token_count: int = 0
111
+ embedding: list[float] | None = None
112
+ embedding_id: str | None = None
113
+
114
+ def to_dict(self) -> dict[str, Any]:
115
+ """Convert to dictionary."""
116
+ return {
117
+ "id": self.id,
118
+ "document_id": self.document_id,
119
+ "content": self.content,
120
+ "sequence": self.sequence,
121
+ "start_pos": self.start_pos,
122
+ "end_pos": self.end_pos,
123
+ "token_count": self.token_count,
124
+ "embedding_id": self.embedding_id,
125
+ }
126
+
127
+
128
+ @dataclass
129
+ class Document:
130
+ """A document in the index."""
131
+
132
+ path: str
133
+ collection_id: str
134
+ content: str
135
+ title: str | None = None
136
+ id: str = field(default_factory=lambda: str(uuid.uuid4()))
137
+ filename: str = field(init=False)
138
+ checksum: str = field(init=False)
139
+ file_size: int = field(init=False)
140
+ mtime: float = field(default_factory=lambda: datetime.now(timezone.utc).timestamp())
141
+ created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
142
+ updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
143
+ chunks: list[DocumentChunk] = field(default_factory=list)
144
+ metadata: dict[str, Any] = field(default_factory=dict)
145
+
146
+ def __post_init__(self):
147
+ """Initialize computed fields."""
148
+ self.filename = Path(self.path).name
149
+ self.checksum = hashlib.sha256(self.content.encode()).hexdigest()
150
+ self.file_size = len(self.content.encode())
151
+ if self.title is None:
152
+ self.title = self.filename
153
+
154
+ def to_dict(self) -> dict[str, Any]:
155
+ """Convert to dictionary."""
156
+ return {
157
+ "id": self.id,
158
+ "path": self.path,
159
+ "collection_id": self.collection_id,
160
+ "filename": self.filename,
161
+ "title": self.title,
162
+ "checksum": self.checksum,
163
+ "file_size": self.file_size,
164
+ "mtime": self.mtime,
165
+ "created_at": self.created_at.isoformat(),
166
+ "updated_at": self.updated_at.isoformat(),
167
+ "metadata": self.metadata,
168
+ "chunk_count": len(self.chunks),
169
+ }
170
+
171
+
172
+ @dataclass
173
+ class PathContext:
174
+ """Context information for a path."""
175
+
176
+ path: str
177
+ context: str
178
+ id: str = field(default_factory=lambda: str(uuid.uuid4()))
179
+ collection_id: str | None = None
180
+ context_type: str = "path"
181
+ created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
182
+ updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
183
+
184
+ def to_dict(self) -> dict[str, Any]:
185
+ """Convert to dictionary."""
186
+ return {
187
+ "id": self.id,
188
+ "path": self.path,
189
+ "collection_id": self.collection_id,
190
+ "context_type": self.context_type,
191
+ "context": self.context,
192
+ "created_at": self.created_at.isoformat(),
193
+ "updated_at": self.updated_at.isoformat(),
194
+ }
195
+
196
+
197
+ @dataclass
198
+ class SearchResult:
199
+ """A search result."""
200
+
201
+ document_id: str
202
+ title: str
203
+ path: str
204
+ collection_name: str
205
+ score: float
206
+ content: str | None = None
207
+ highlights: list[str] = field(default_factory=list)
208
+ rank: int = 0
209
+ scores: dict[str, float | None] = field(default_factory=dict)
210
+ snippet: str | None = None
211
+ context_description: str | None = None
212
+
213
+ def to_dict(self) -> dict[str, Any]:
214
+ """Convert to dictionary."""
215
+ return {
216
+ "document_id": self.document_id,
217
+ "title": self.title,
218
+ "path": self.path,
219
+ "collection_name": self.collection_name,
220
+ "score": self.score,
221
+ "content": self.content,
222
+ "highlights": self.highlights,
223
+ "rank": self.rank,
224
+ "scores": self.scores,
225
+ "snippet": self.snippet,
226
+ "context_description": self.context_description,
227
+ }
228
+
229
+
230
+ @dataclass
231
+ class SearchOptions:
232
+ """Options for search."""
233
+
234
+ limit: int = 10
235
+ offset: int = 0
236
+ collection_ids: list[str] | None = None
237
+ min_score: float = 0.0
238
+ include_content: bool = False
239
+ include_highlights: bool = True
240
+ max_highlights: int = 3
241
+ explain: bool = False
242
+ candidate_limit: int = 20
243
+ intent: str | None = None
244
+ snippet_max_length: int = 300
245
+
246
+
247
+ @dataclass
248
+ class IndexStats:
249
+ """Statistics about the index."""
250
+
251
+ collection_count: int = 0
252
+ document_count: int = 0
253
+ chunk_count: int = 0
254
+ total_size_bytes: int = 0
255
+ last_indexed_at: datetime | None = None
256
+
257
+ def to_dict(self) -> dict[str, Any]:
258
+ """Convert to dictionary."""
259
+ return {
260
+ "collection_count": self.collection_count,
261
+ "document_count": self.document_count,
262
+ "chunk_count": self.chunk_count,
263
+ "total_size_bytes": self.total_size_bytes,
264
+ "last_indexed_at": self.last_indexed_at.isoformat() if self.last_indexed_at else None,
265
+ }
266
+
267
+
268
+ class Embedder(Protocol):
269
+ """Protocol for embedding models."""
270
+
271
+ def embed(self, text: str) -> list[float]:
272
+ """Embed a single text."""
273
+ ...
274
+
275
+ def embed_batch(self, texts: list[str]) -> list[list[float]]:
276
+ """Embed multiple texts."""
277
+ ...
278
+
279
+ @property
280
+ def dimension(self) -> int:
281
+ """Get embedding dimension."""
282
+ ...
283
+
284
+
285
+ class Reranker(Protocol):
286
+ """Protocol for reranking models."""
287
+
288
+ def rerank(self, query: str, documents: list[str]) -> list[tuple[int, float]]:
289
+ """Rerank documents for a query."""
290
+ ...
291
+
292
+
293
+ class QueryExpander(Protocol):
294
+ """Protocol for query expansion."""
295
+
296
+ def expand(self, query: str) -> list[str]:
297
+ """Expand a query into multiple variants."""
298
+ ...
@@ -0,0 +1,23 @@
1
+ """Database access layer for SIF."""
2
+
3
+ from sif.database.connection import ConnectionPool, DatabaseConnection
4
+ from sif.database.migrations import MigrationManager
5
+ from sif.database.repositories import (
6
+ CollectionRepository,
7
+ DocumentChunkRepository,
8
+ DocumentRepository,
9
+ LLMCacheRepository,
10
+ PathContextRepository,
11
+ )
12
+
13
+
14
+ __all__ = [
15
+ "CollectionRepository",
16
+ "ConnectionPool",
17
+ "DatabaseConnection",
18
+ "DocumentChunkRepository",
19
+ "DocumentRepository",
20
+ "LLMCacheRepository",
21
+ "MigrationManager",
22
+ "PathContextRepository",
23
+ ]
@@ -0,0 +1,139 @@
1
+ """Database connection management."""
2
+
3
+ import sqlite3
4
+ from collections.abc import Generator
5
+ from contextlib import contextmanager
6
+ from pathlib import Path
7
+
8
+ import sqlite_vec
9
+
10
+
11
+ class DatabaseConnection:
12
+ """Manages SQLite database connections.
13
+
14
+ Provides connection pooling and ensures proper setup of
15
+ required extensions (FTS5, sqlite-vec).
16
+ """
17
+
18
+ def __init__(self, db_path: Path | str) -> None:
19
+ self._db_path = Path(db_path)
20
+ self._db_path.parent.mkdir(parents=True, exist_ok=True)
21
+
22
+ def _create_connection(self) -> sqlite3.Connection:
23
+ """Create a new database connection with extensions loaded."""
24
+ conn = sqlite3.connect(str(self._db_path), check_same_thread=False)
25
+ conn.row_factory = sqlite3.Row
26
+
27
+ # Enable foreign keys
28
+ conn.execute("PRAGMA foreign_keys = ON")
29
+
30
+ # Load sqlite-vec extension
31
+ conn.enable_load_extension(True)
32
+ sqlite_vec.load(conn)
33
+ conn.enable_load_extension(False)
34
+
35
+ return conn
36
+
37
+ @contextmanager
38
+ def connect(self) -> Generator[sqlite3.Connection, None, None]:
39
+ """Get a database connection as a context manager."""
40
+ conn = self._create_connection()
41
+ try:
42
+ yield conn
43
+ finally:
44
+ conn.close()
45
+
46
+ @contextmanager
47
+ def transaction(self) -> Generator[sqlite3.Connection, None, None]:
48
+ """Execute operations within a transaction."""
49
+ conn = self._create_connection()
50
+ try:
51
+ yield conn
52
+ conn.commit()
53
+ except Exception:
54
+ conn.rollback()
55
+ raise
56
+ finally:
57
+ conn.close()
58
+
59
+ def execute(
60
+ self,
61
+ query: str,
62
+ parameters: tuple | dict | None = None,
63
+ ) -> sqlite3.Cursor:
64
+ """Execute a query and return the cursor."""
65
+ with self.connect() as conn:
66
+ cursor = conn.execute(query, parameters) if parameters else conn.execute(query)
67
+ conn.commit()
68
+ return cursor
69
+
70
+ def executemany(
71
+ self,
72
+ query: str,
73
+ parameters: list[tuple | dict],
74
+ ) -> sqlite3.Cursor:
75
+ """Execute a query multiple times."""
76
+ with self.connect() as conn:
77
+ cursor = conn.executemany(query, parameters)
78
+ conn.commit()
79
+ return cursor
80
+
81
+ def fetchone(
82
+ self,
83
+ query: str,
84
+ parameters: tuple | dict | None = None,
85
+ ) -> sqlite3.Row | None:
86
+ """Fetch a single row."""
87
+ with self.connect() as conn:
88
+ cursor = conn.execute(query, parameters or ())
89
+ return cursor.fetchone()
90
+
91
+ def fetchall(
92
+ self,
93
+ query: str,
94
+ parameters: tuple | dict | None = None,
95
+ ) -> list[sqlite3.Row]:
96
+ """Fetch all rows."""
97
+ with self.connect() as conn:
98
+ cursor = conn.execute(query, parameters or ())
99
+ return cursor.fetchall()
100
+
101
+
102
+ class ConnectionPool:
103
+ """Simple connection pool for database connections.
104
+
105
+ Note: For SQLite, this is primarily useful for read-heavy workloads.
106
+ Writes should use a single connection to avoid locking issues.
107
+ """
108
+
109
+ def __init__(
110
+ self,
111
+ db_path: Path | str,
112
+ max_connections: int = 5,
113
+ ) -> None:
114
+ self._db_path = Path(db_path)
115
+ self._max_connections = max_connections
116
+ self._connections: list[sqlite3.Connection] = []
117
+
118
+ def get_connection(self) -> sqlite3.Connection:
119
+ """Get a connection from the pool or create a new one."""
120
+ if self._connections:
121
+ return self._connections.pop()
122
+
123
+ conn = sqlite3.connect(str(self._db_path), check_same_thread=False)
124
+ conn.row_factory = sqlite3.Row
125
+ conn.execute("PRAGMA foreign_keys = ON")
126
+ return conn
127
+
128
+ def release_connection(self, conn: sqlite3.Connection) -> None:
129
+ """Return a connection to the pool."""
130
+ if len(self._connections) < self._max_connections:
131
+ self._connections.append(conn)
132
+ else:
133
+ conn.close()
134
+
135
+ def close_all(self) -> None:
136
+ """Close all connections in the pool."""
137
+ for conn in self._connections:
138
+ conn.close()
139
+ self._connections.clear()