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/embedding/model.py ADDED
@@ -0,0 +1,126 @@
1
+ """Embedding model interface."""
2
+
3
+ from abc import ABC, abstractmethod
4
+ from typing import Protocol
5
+
6
+ from sif.models.embedding import ModelType
7
+
8
+
9
+ class EmbeddingModel(ABC):
10
+ """Abstract base class for embedding models.
11
+
12
+ Implements a common interface for different embedding providers,
13
+ allowing models to be used interchangeably.
14
+ """
15
+
16
+ def __init__(
17
+ self,
18
+ model_id: str,
19
+ embedding_dim: int,
20
+ max_tokens: int,
21
+ ) -> None:
22
+ """Initialize the embedding model."""
23
+ self._model_id = model_id
24
+ self._embedding_dim = embedding_dim
25
+ self._max_tokens = max_tokens
26
+ self._loaded = False
27
+
28
+ @property
29
+ def model_id(self) -> str:
30
+ """Get the model ID."""
31
+ return self._model_id
32
+
33
+ @property
34
+ def embedding_dim(self) -> int:
35
+ """Get the embedding dimension."""
36
+ return self._embedding_dim
37
+
38
+ @property
39
+ def max_tokens(self) -> int:
40
+ """Get the maximum token count."""
41
+ return self._max_tokens
42
+
43
+ @property
44
+ def loaded(self) -> bool:
45
+ """Check if the model is loaded."""
46
+ return self._loaded
47
+
48
+ @abstractmethod
49
+ def load(self) -> None:
50
+ """Load the model into memory."""
51
+ ...
52
+
53
+ @abstractmethod
54
+ def unload(self) -> None:
55
+ """Unload the model from memory."""
56
+ ...
57
+
58
+ @abstractmethod
59
+ def embed(
60
+ self,
61
+ texts: list[str],
62
+ normalize: bool = True,
63
+ ) -> list[list[float]]:
64
+ """Generate embeddings for a list of texts.
65
+
66
+ Args:
67
+ texts: List of texts to embed
68
+ normalize: Whether to normalize embeddings
69
+
70
+ Returns:
71
+ List of embeddings, one per input text
72
+ """
73
+ ...
74
+
75
+ @abstractmethod
76
+ def embed_single(
77
+ self,
78
+ text: str,
79
+ normalize: bool = True,
80
+ ) -> list[float]:
81
+ """Generate embedding for a single text.
82
+
83
+ Args:
84
+ text: Text to embed
85
+ normalize: Whether to normalize the embedding
86
+
87
+ Returns:
88
+ Embedding vector
89
+ """
90
+ ...
91
+
92
+ def count_tokens(self, text: str) -> int:
93
+ """Count tokens in a text.
94
+
95
+ Default implementation uses a rough estimate.
96
+ Subclasses can override for more accurate counts.
97
+ """
98
+ # Rough estimate: 1 token ~= 4 characters
99
+ return len(text) // 4
100
+
101
+
102
+ class EmbeddingModelFactory(Protocol):
103
+ """Protocol for embedding model factories.
104
+
105
+ Implements the Factory pattern for creating embedding models.
106
+ """
107
+
108
+ def create_model(
109
+ self,
110
+ model_type: ModelType,
111
+ model_path: str | None,
112
+ model_name: str,
113
+ **kwargs: dict[str, any],
114
+ ) -> EmbeddingModel:
115
+ """Create an embedding model instance.
116
+
117
+ Args:
118
+ model_type: Type of model to create
119
+ model_path: Path to model file (for local models)
120
+ model_name: Model name or identifier
121
+ **kwargs: Additional model-specific parameters
122
+
123
+ Returns:
124
+ Configured embedding model instance
125
+ """
126
+ ...
@@ -0,0 +1,21 @@
1
+ """Document indexing functionality for SIF.
2
+
3
+ This package provides document indexing capabilities:
4
+ - File system scanning
5
+ - Markdown parsing and chunking
6
+ - Document metadata extraction
7
+ """
8
+
9
+ from sif.indexing.chunker import Chunker, create_chunker
10
+ from sif.indexing.parser import MarkdownParser, ParsedDocument
11
+ from sif.indexing.scanner import FileScanner, ScanResult
12
+
13
+
14
+ __all__ = [
15
+ "Chunker",
16
+ "FileScanner",
17
+ "MarkdownParser",
18
+ "ParsedDocument",
19
+ "ScanResult",
20
+ "create_chunker",
21
+ ]
@@ -0,0 +1,226 @@
1
+ """Document chunking strategies."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from abc import ABC, abstractmethod
7
+
8
+ from sif.core.models import DocumentChunk
9
+
10
+
11
+ class Chunker(ABC):
12
+ """Base class for document chunkers."""
13
+
14
+ @abstractmethod
15
+ def chunk(self, text: str) -> list[DocumentChunk]:
16
+ """Split text into chunks.
17
+
18
+ Args:
19
+ text: Text to chunk
20
+
21
+ Returns:
22
+ List of chunks
23
+ """
24
+ pass
25
+
26
+
27
+ class FixedSizeChunker(Chunker):
28
+ """Chunk text into fixed-size pieces with overlap."""
29
+
30
+ def __init__(self, chunk_size: int = 1000, overlap: int = 100) -> None:
31
+ """Initialize fixed-size chunker.
32
+
33
+ Args:
34
+ chunk_size: Target chunk size in characters
35
+ overlap: Overlap between chunks in characters
36
+ """
37
+ self.chunk_size = chunk_size
38
+ self.overlap = overlap
39
+
40
+ def chunk(self, text: str) -> list[DocumentChunk]:
41
+ """Split text into fixed-size chunks."""
42
+ chunks = []
43
+ start = 0
44
+ sequence = 0
45
+
46
+ while start < len(text):
47
+ end = start + self.chunk_size
48
+
49
+ # Try to find a good break point
50
+ if end < len(text):
51
+ # Look for newline or space
52
+ for i in range(min(end + 100, len(text)) - 1, end - 1, -1):
53
+ if text[i] in "\n ":
54
+ end = i + 1
55
+ break
56
+
57
+ chunk_text = text[start:end].strip()
58
+ if chunk_text:
59
+ chunks.append(
60
+ DocumentChunk(
61
+ content=chunk_text,
62
+ sequence=sequence,
63
+ start_pos=start,
64
+ end_pos=end,
65
+ )
66
+ )
67
+ sequence += 1
68
+
69
+ # Move start with overlap
70
+ start = end - self.overlap
71
+ start = min(end, start)
72
+
73
+ return chunks
74
+
75
+
76
+ class MarkdownChunker(Chunker):
77
+ """Chunk markdown documents by headings."""
78
+
79
+ def __init__(self, max_chunk_size: int = 2000) -> None:
80
+ """Initialize markdown chunker.
81
+
82
+ Args:
83
+ max_chunk_size: Maximum chunk size in characters
84
+ """
85
+ self.max_chunk_size = max_chunk_size
86
+ self.heading_pattern = re.compile(r"^(#{1,6}\s+.+)$", re.MULTILINE)
87
+
88
+ def chunk(self, text: str) -> list[DocumentChunk]:
89
+ """Split markdown by headings."""
90
+ # Find all headings
91
+ headings = list(self.heading_pattern.finditer(text))
92
+
93
+ if not headings:
94
+ # No headings, use fixed-size chunking
95
+ return FixedSizeChunker(self.max_chunk_size).chunk(text)
96
+
97
+ chunks = []
98
+ sequence = 0
99
+
100
+ for i, match in enumerate(headings):
101
+ start = match.start()
102
+ end = headings[i + 1].start() if i + 1 < len(headings) else len(text)
103
+
104
+ chunk_text = text[start:end].strip()
105
+
106
+ # If chunk is too large, split it further
107
+ if len(chunk_text) > self.max_chunk_size:
108
+ sub_chunks = FixedSizeChunker(self.max_chunk_size, 100).chunk(chunk_text)
109
+ for sub_chunk in sub_chunks:
110
+ sub_chunk.sequence = sequence
111
+ chunks.append(sub_chunk)
112
+ sequence += 1
113
+ else:
114
+ chunks.append(
115
+ DocumentChunk(
116
+ content=chunk_text,
117
+ sequence=sequence,
118
+ start_pos=start,
119
+ end_pos=end,
120
+ )
121
+ )
122
+ sequence += 1
123
+
124
+ return chunks
125
+
126
+
127
+ class CodeChunker(Chunker):
128
+ """Chunk code files by functions/classes."""
129
+
130
+ def __init__(self, max_chunk_size: int = 2000, language: str = "python") -> None:
131
+ """Initialize code chunker.
132
+
133
+ Args:
134
+ max_chunk_size: Maximum chunk size in characters
135
+ language: Programming language
136
+ """
137
+ self.max_chunk_size = max_chunk_size
138
+ self.language = language
139
+
140
+ def chunk(self, text: str) -> list[DocumentChunk]:
141
+ """Split code by functions/classes."""
142
+ # Simple regex-based approach for Python
143
+ if self.language == "python":
144
+ pattern = re.compile(r"^(def\s+|class\s+)", re.MULTILINE)
145
+ else:
146
+ # Fallback to fixed-size for other languages
147
+ return FixedSizeChunker(self.max_chunk_size).chunk(text)
148
+
149
+ matches = list(pattern.finditer(text))
150
+
151
+ if not matches:
152
+ return FixedSizeChunker(self.max_chunk_size).chunk(text)
153
+
154
+ chunks = []
155
+ sequence = 0
156
+
157
+ for i, match in enumerate(matches):
158
+ start = match.start()
159
+ end = matches[i + 1].start() if i + 1 < len(matches) else len(text)
160
+
161
+ chunk_text = text[start:end].strip()
162
+
163
+ if len(chunk_text) > self.max_chunk_size:
164
+ sub_chunks = FixedSizeChunker(self.max_chunk_size, 100).chunk(chunk_text)
165
+ for sub_chunk in sub_chunks:
166
+ sub_chunk.sequence = sequence
167
+ chunks.append(sub_chunk)
168
+ sequence += 1
169
+ else:
170
+ chunks.append(
171
+ DocumentChunk(
172
+ content=chunk_text,
173
+ sequence=sequence,
174
+ start_pos=start,
175
+ end_pos=end,
176
+ )
177
+ )
178
+ sequence += 1
179
+
180
+ return chunks
181
+
182
+
183
+ class AutoChunker(Chunker):
184
+ """Automatically select chunking strategy based on content."""
185
+
186
+ def __init__(self, max_chunk_size: int = 2000) -> None:
187
+ """Initialize auto chunker.
188
+
189
+ Args:
190
+ max_chunk_size: Maximum chunk size in characters
191
+ """
192
+ self.max_chunk_size = max_chunk_size
193
+
194
+ def chunk(self, text: str) -> list[DocumentChunk]:
195
+ """Automatically select and apply chunking strategy."""
196
+ # Check if markdown
197
+ if "#" in text and re.search(r"^#+\s+", text, re.MULTILINE):
198
+ return MarkdownChunker(self.max_chunk_size).chunk(text)
199
+
200
+ # Check if code (has function/class definitions)
201
+ if re.search(r"^(def\s+|class\s+)", text, re.MULTILINE):
202
+ return CodeChunker(self.max_chunk_size).chunk(text)
203
+
204
+ # Default to fixed-size
205
+ return FixedSizeChunker(self.max_chunk_size).chunk(text)
206
+
207
+
208
+ def create_chunker(strategy: str, **kwargs) -> Chunker:
209
+ """Create a chunker by strategy name.
210
+
211
+ Args:
212
+ strategy: Chunking strategy ("fixed", "markdown", "code", "auto")
213
+ **kwargs: Additional arguments for the chunker
214
+
215
+ Returns:
216
+ Chunker instance
217
+ """
218
+ if strategy == "fixed":
219
+ return FixedSizeChunker(**kwargs)
220
+ if strategy == "markdown":
221
+ return MarkdownChunker(**kwargs)
222
+ if strategy == "code":
223
+ return CodeChunker(**kwargs)
224
+ if strategy == "auto":
225
+ return AutoChunker(**kwargs)
226
+ raise ValueError(f"Unknown chunking strategy: {strategy}")
@@ -0,0 +1,226 @@
1
+ """Document indexer for orchestrating the indexing process."""
2
+
3
+ from dataclasses import dataclass
4
+ from enum import Enum, auto
5
+ from pathlib import Path
6
+
7
+ from sif.core.models import Collection, Document, DocumentChunk
8
+ from sif.database.repositories import DocumentRepository
9
+ from sif.embedding.manager import EmbeddingManager
10
+ from sif.indexing.chunker import Chunker
11
+ from sif.indexing.parser import MarkdownParser
12
+ from sif.indexing.scanner import FileScanner
13
+ from sif.utils.logging import get_logger
14
+ from sif.utils.progress import ProgressTracker, RichProgressTracker
15
+
16
+
17
+ logger = get_logger(__name__)
18
+
19
+
20
+ class IndexStatus(Enum):
21
+ """Status of indexing operation."""
22
+
23
+ PENDING = auto()
24
+ INDEXING = auto()
25
+ COMPLETED = auto()
26
+ FAILED = auto()
27
+ SKIPPED = auto()
28
+
29
+
30
+ @dataclass
31
+ class IndexResult:
32
+ """Result of indexing operation."""
33
+
34
+ files_processed: int
35
+ files_added: int
36
+ files_updated: int
37
+ files_skipped: int
38
+ files_failed: int
39
+ chunks_created: int
40
+ errors: list[str]
41
+
42
+
43
+ class DocumentIndexer:
44
+ """Orchestrates the document indexing process.
45
+
46
+ Coordinates file scanning, parsing, chunking, embedding generation,
47
+ and database storage for document indexing.
48
+ """
49
+
50
+ def __init__( # noqa: PLR0913
51
+ self,
52
+ scanner: FileScanner,
53
+ parser: MarkdownParser,
54
+ chunker: Chunker,
55
+ embedding_manager: EmbeddingManager,
56
+ repository: DocumentRepository,
57
+ progress_tracker: ProgressTracker | None = None,
58
+ ) -> None:
59
+ """Initialize document indexer.
60
+
61
+ Args:
62
+ scanner: File scanner
63
+ parser: Markdown parser
64
+ chunker: Document chunker
65
+ embedding_manager: Embedding manager
66
+ repository: Document repository
67
+ progress_tracker: Progress tracker
68
+ """
69
+ self._scanner = scanner
70
+ self._parser = parser
71
+ self._chunker = chunker
72
+ self._embedding_manager = embedding_manager
73
+ self._repository = repository
74
+ self._progress = progress_tracker or RichProgressTracker()
75
+
76
+ def index_collection(self, collection: Collection) -> IndexResult:
77
+ """Index all documents in a collection.
78
+
79
+ Args:
80
+ collection: Collection to index
81
+
82
+ Returns:
83
+ Indexing result
84
+ """
85
+ logger.info(f"Indexing collection: {collection.name}")
86
+
87
+ # Scan for files
88
+ scan_result = self._scanner.scan(
89
+ Path(collection.path),
90
+ pattern=collection.pattern,
91
+ ignore_patterns=collection.ignore_patterns,
92
+ )
93
+
94
+ logger.info(f"Found {scan_result.file_count} files to index")
95
+
96
+ # Index files
97
+ self._progress.start("Indexing documents", total=scan_result.file_count)
98
+
99
+ result = IndexResult(
100
+ files_processed=0,
101
+ files_added=0,
102
+ files_updated=0,
103
+ files_skipped=0,
104
+ files_failed=0,
105
+ chunks_created=0,
106
+ errors=[],
107
+ )
108
+
109
+ for file_path in scan_result.files:
110
+ try:
111
+ file_result = self._index_file(file_path, collection.id)
112
+
113
+ result.files_processed += 1
114
+ if file_result == IndexStatus.ADDED:
115
+ result.files_added += 1
116
+ elif file_result == IndexStatus.COMPLETED:
117
+ result.files_updated += 1
118
+ elif file_result == IndexStatus.SKIPPED:
119
+ result.files_skipped += 1
120
+
121
+ self._progress.update()
122
+
123
+ except Exception as e:
124
+ logger.error(f"Error indexing {file_path}: {e}")
125
+ result.files_failed += 1
126
+ result.errors.append(f"{file_path}: {e!s}")
127
+
128
+ self._progress.finish()
129
+
130
+ # Update collection stats
131
+ collection.document_count = self._repository.list_by_collection(collection.id).__len__()
132
+ collection.mark_indexed()
133
+
134
+ logger.info(
135
+ f"Indexing complete: {result.files_added} added, "
136
+ f"{result.files_updated} updated, {result.files_failed} failed"
137
+ )
138
+
139
+ return result
140
+
141
+ def _index_file(
142
+ self,
143
+ file_path: Path,
144
+ collection_id: str,
145
+ ) -> IndexStatus:
146
+ """Index a single file.
147
+
148
+ Args:
149
+ file_path: Path to file
150
+ collection_id: Collection ID
151
+
152
+ Returns:
153
+ Index status
154
+ """
155
+ import hashlib # noqa: PLC0415
156
+ import uuid # noqa: PLC0415
157
+
158
+ # Calculate checksum
159
+ with open(file_path, "rb") as f:
160
+ checksum = hashlib.sha256(f.read()).hexdigest()
161
+
162
+ # Check if file already indexed
163
+ existing = self._repository.get_by_path(str(file_path), collection_id)
164
+ if existing:
165
+ if existing.checksum == checksum:
166
+ logger.debug(f"File unchanged, skipping: {file_path}")
167
+ return IndexStatus.SKIPPED
168
+
169
+ # Parse file
170
+ parse_result = self._parser.parse(file_path)
171
+
172
+ # Create document
173
+ document = Document(
174
+ id=str(uuid.uuid4()) if not existing else existing.id,
175
+ collection_id=collection_id,
176
+ path=str(file_path),
177
+ content=parse_result.content,
178
+ title=parse_result.title or file_path.name,
179
+ checksum=checksum,
180
+ file_size=file_path.stat().st_size,
181
+ mtime=file_path.stat().st_mtime,
182
+ metadata=parse_result.metadata,
183
+ )
184
+
185
+ # Chunk document
186
+ chunks = self._chunker.chunk(parse_result.content)
187
+
188
+ # Generate embeddings for chunks
189
+ if chunks:
190
+ chunk_texts = [c.content for c in chunks]
191
+ embedding_response = self._embedding_manager.embed(chunk_texts)
192
+
193
+ # Create document chunks with embeddings
194
+ for _i, (chunk, embedding) in enumerate(zip(chunks, embedding_response.embeddings)):
195
+ doc_chunk = DocumentChunk(
196
+ id=str(uuid.uuid4()),
197
+ document_id=document.id,
198
+ content=chunk.content,
199
+ start_pos=chunk.start_pos,
200
+ end_pos=chunk.end_pos,
201
+ token_count=chunk.token_count,
202
+ embedding=embedding,
203
+ )
204
+ document.add_chunk(doc_chunk)
205
+
206
+ # Save to repository
207
+ if existing:
208
+ self._repository.update(document)
209
+ return IndexStatus.COMPLETED
210
+ self._repository.create(document)
211
+ return IndexStatus.ADDED
212
+
213
+ def reindex_collection(self, collection: Collection) -> IndexResult:
214
+ """Reindex all documents in a collection.
215
+
216
+ Args:
217
+ collection: Collection to reindex
218
+
219
+ Returns:
220
+ Indexing result
221
+ """
222
+ # Clear existing documents
223
+ self._repository.delete_by_collection(collection.id)
224
+
225
+ # Reindex
226
+ return self.index_collection(collection)