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/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
|
+
...
|
sif/indexing/__init__.py
ADDED
|
@@ -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
|
+
]
|
sif/indexing/chunker.py
ADDED
|
@@ -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}")
|
sif/indexing/indexer.py
ADDED
|
@@ -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)
|