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
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
|
+
...
|
sif/database/__init__.py
ADDED
|
@@ -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()
|