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/config/__init__.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Configuration management for SIF.
|
|
2
|
+
|
|
3
|
+
This package provides configuration handling:
|
|
4
|
+
- Settings management via Pydantic Settings
|
|
5
|
+
- Environment variable support
|
|
6
|
+
- Configuration file handling
|
|
7
|
+
- Default values and validation
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from sif.config.constants import (
|
|
11
|
+
APP_NAME,
|
|
12
|
+
APP_VERSION,
|
|
13
|
+
DEFAULT_CHUNK_OVERLAP,
|
|
14
|
+
DEFAULT_CHUNK_SIZE,
|
|
15
|
+
DEFAULT_DB_PATH,
|
|
16
|
+
DEFAULT_MODEL_PATH,
|
|
17
|
+
)
|
|
18
|
+
from sif.config.settings import Settings, get_settings
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"APP_NAME",
|
|
23
|
+
"APP_VERSION",
|
|
24
|
+
"DEFAULT_CHUNK_OVERLAP",
|
|
25
|
+
"DEFAULT_CHUNK_SIZE",
|
|
26
|
+
"DEFAULT_DB_PATH",
|
|
27
|
+
"DEFAULT_MODEL_PATH",
|
|
28
|
+
"Settings",
|
|
29
|
+
"get_settings",
|
|
30
|
+
]
|
sif/config/constants.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Constants for SIF."""
|
|
2
|
+
|
|
3
|
+
# Application info
|
|
4
|
+
APP_NAME = "sif"
|
|
5
|
+
APP_VERSION = "0.1.0"
|
|
6
|
+
APP_DESCRIPTION = (
|
|
7
|
+
"A local CLI search engine for indexing and searching documents (SIF: Search / Index / Find)"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
# Default paths
|
|
11
|
+
DEFAULT_DB_PATH = "~/.local/share/sif/sif.db"
|
|
12
|
+
DEFAULT_MODEL_PATH = "~/.local/share/sif/models"
|
|
13
|
+
DEFAULT_CONFIG_PATH = "~/.config/sif"
|
|
14
|
+
|
|
15
|
+
# Chunking defaults
|
|
16
|
+
DEFAULT_CHUNK_SIZE = 512
|
|
17
|
+
DEFAULT_CHUNK_OVERLAP = 128
|
|
18
|
+
MIN_CHUNK_SIZE = 100
|
|
19
|
+
MAX_CHUNK_SIZE = 2048
|
|
20
|
+
|
|
21
|
+
# Search defaults
|
|
22
|
+
DEFAULT_SEARCH_LIMIT = 10
|
|
23
|
+
MAX_SEARCH_LIMIT = 100
|
|
24
|
+
DEFAULT_BM25_K1 = 1.5
|
|
25
|
+
DEFAULT_BM25_B = 0.75
|
|
26
|
+
DEFAULT_VECTOR_WEIGHT = 0.7
|
|
27
|
+
DEFAULT_RRF_K = 60
|
|
28
|
+
|
|
29
|
+
# Embedding defaults
|
|
30
|
+
DEFAULT_EMBEDDING_DIM = 1024
|
|
31
|
+
DEFAULT_MAX_TOKENS = 512
|
|
32
|
+
DEFAULT_BATCH_SIZE = 32
|
|
33
|
+
|
|
34
|
+
# MCP server defaults
|
|
35
|
+
DEFAULT_MCP_HOST = "127.0.0.1"
|
|
36
|
+
DEFAULT_MCP_PORT = 8080
|
|
37
|
+
MCP_VERSION = "1.0"
|
|
38
|
+
|
|
39
|
+
# File patterns
|
|
40
|
+
MARKDOWN_EXTENSIONS = {".md", ".markdown", ".mdown", ".mkd", ".mkdn"}
|
|
41
|
+
DEFAULT_IGNORE_PATTERNS = [
|
|
42
|
+
".git",
|
|
43
|
+
"__pycache__",
|
|
44
|
+
"node_modules",
|
|
45
|
+
".obsidian",
|
|
46
|
+
".trash",
|
|
47
|
+
"*.tmp",
|
|
48
|
+
"*.temp",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
# Database schema versions
|
|
52
|
+
CURRENT_SCHEMA_VERSION = 1
|
sif/config/settings.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Configuration settings for SIF."""
|
|
2
|
+
|
|
3
|
+
from functools import lru_cache
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from pydantic import Field, field_validator
|
|
7
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
8
|
+
|
|
9
|
+
from sif.config.constants import APP_NAME, DEFAULT_CHUNK_OVERLAP, DEFAULT_CHUNK_SIZE
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Settings(BaseSettings):
|
|
13
|
+
"""Application settings using Pydantic Settings.
|
|
14
|
+
|
|
15
|
+
Settings are loaded from environment variables and .env files.
|
|
16
|
+
Environment variables should be prefixed with SIF_.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
model_config = SettingsConfigDict(
|
|
20
|
+
env_prefix="SIF_",
|
|
21
|
+
env_file=".env",
|
|
22
|
+
env_file_encoding="utf-8",
|
|
23
|
+
extra="ignore",
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
# Application settings
|
|
27
|
+
app_name: str = Field(default=APP_NAME, description="Application name")
|
|
28
|
+
debug: bool = Field(default=False, description="Debug mode")
|
|
29
|
+
log_level: str = Field(default="INFO", description="Logging level")
|
|
30
|
+
|
|
31
|
+
# Database settings
|
|
32
|
+
db_path: Path | None = Field(
|
|
33
|
+
default=None,
|
|
34
|
+
description="Path to SQLite database file",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
# Model settings
|
|
38
|
+
model_path: Path | None = Field(
|
|
39
|
+
default=None,
|
|
40
|
+
description="Path to embedding model file",
|
|
41
|
+
)
|
|
42
|
+
model_name: str = Field(
|
|
43
|
+
default="Qwen/Qwen3-Embedding-0.6B",
|
|
44
|
+
description="Embedding model name",
|
|
45
|
+
)
|
|
46
|
+
embedding_dim: int = Field(
|
|
47
|
+
default=1024,
|
|
48
|
+
ge=1,
|
|
49
|
+
description="Embedding dimension",
|
|
50
|
+
)
|
|
51
|
+
max_tokens: int = Field(
|
|
52
|
+
default=512,
|
|
53
|
+
ge=1,
|
|
54
|
+
description="Maximum tokens per input",
|
|
55
|
+
)
|
|
56
|
+
batch_size: int = Field(
|
|
57
|
+
default=32,
|
|
58
|
+
ge=1,
|
|
59
|
+
description="Batch size for inference",
|
|
60
|
+
)
|
|
61
|
+
model_type: str = Field(
|
|
62
|
+
default="modelscope",
|
|
63
|
+
description="Embedding model type (gguf, sentence_transformers, openai, huggingface, modelscope)",
|
|
64
|
+
)
|
|
65
|
+
n_gpu_layers: int = Field(
|
|
66
|
+
default=0,
|
|
67
|
+
ge=0,
|
|
68
|
+
description="Number of GPU layers for GGUF models",
|
|
69
|
+
)
|
|
70
|
+
api_key: str | None = Field(
|
|
71
|
+
default=None,
|
|
72
|
+
description="API key for remote embedding models",
|
|
73
|
+
repr=False,
|
|
74
|
+
)
|
|
75
|
+
api_base: str | None = Field(
|
|
76
|
+
default=None,
|
|
77
|
+
description="Base URL for remote embedding API",
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
# Reranker settings
|
|
81
|
+
reranker_model_name: str = Field(
|
|
82
|
+
default="Qwen/Qwen3-Reranker-0.6B",
|
|
83
|
+
description="Reranker model name",
|
|
84
|
+
)
|
|
85
|
+
reranker_model_path: Path | None = Field(
|
|
86
|
+
default=None,
|
|
87
|
+
description="Path to local reranker model file",
|
|
88
|
+
)
|
|
89
|
+
reranker_model_type: str = Field(
|
|
90
|
+
default="sentence_transformers",
|
|
91
|
+
description="Reranker model type (gguf, sentence_transformers)",
|
|
92
|
+
)
|
|
93
|
+
reranker_batch_size: int = Field(
|
|
94
|
+
default=32,
|
|
95
|
+
ge=1,
|
|
96
|
+
description="Batch size for reranker inference",
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
# Chunking settings
|
|
100
|
+
chunk_size: int = Field(
|
|
101
|
+
default=DEFAULT_CHUNK_SIZE,
|
|
102
|
+
ge=100,
|
|
103
|
+
description="Default chunk size in tokens",
|
|
104
|
+
)
|
|
105
|
+
chunk_overlap: int = Field(
|
|
106
|
+
default=DEFAULT_CHUNK_OVERLAP,
|
|
107
|
+
ge=0,
|
|
108
|
+
description="Default chunk overlap in tokens",
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
# Search settings
|
|
112
|
+
default_search_type: str = Field(
|
|
113
|
+
default="hybrid",
|
|
114
|
+
description="Default search type (bm25, vector, hybrid)",
|
|
115
|
+
)
|
|
116
|
+
default_limit: int = Field(
|
|
117
|
+
default=10,
|
|
118
|
+
ge=1,
|
|
119
|
+
le=100,
|
|
120
|
+
description="Default search result limit",
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
# MCP server settings
|
|
124
|
+
mcp_host: str = Field(
|
|
125
|
+
default="127.0.0.1",
|
|
126
|
+
description="MCP HTTP server host",
|
|
127
|
+
)
|
|
128
|
+
mcp_port: int = Field(
|
|
129
|
+
default=8080,
|
|
130
|
+
ge=1,
|
|
131
|
+
le=65535,
|
|
132
|
+
description="MCP HTTP server port",
|
|
133
|
+
)
|
|
134
|
+
mcp_transport: str = Field(
|
|
135
|
+
default="stdio",
|
|
136
|
+
description="MCP transport type (stdio, http)",
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
# Cache settings
|
|
140
|
+
cache_dir: Path | None = Field(
|
|
141
|
+
default=None,
|
|
142
|
+
description="Cache directory path",
|
|
143
|
+
)
|
|
144
|
+
cache_embeddings: bool = Field(
|
|
145
|
+
default=True,
|
|
146
|
+
description="Whether to cache embeddings",
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
@field_validator("model_type")
|
|
150
|
+
@classmethod
|
|
151
|
+
def validate_model_type(cls, v: str) -> str:
|
|
152
|
+
"""Validate model_type is one of the supported backends."""
|
|
153
|
+
valid_types = {"sentence_transformers", "gguf", "openai", "modelscope", "huggingface"}
|
|
154
|
+
if v not in valid_types:
|
|
155
|
+
raise ValueError(f"Invalid model_type: {v}. Must be one of {sorted(valid_types)}")
|
|
156
|
+
return v
|
|
157
|
+
|
|
158
|
+
@field_validator("api_base")
|
|
159
|
+
@classmethod
|
|
160
|
+
def validate_api_base(cls, v: str | None) -> str | None:
|
|
161
|
+
"""Validate api_base is an HTTP or HTTPS URL."""
|
|
162
|
+
if v is None:
|
|
163
|
+
return None
|
|
164
|
+
v_lower = v.lower()
|
|
165
|
+
if not v_lower.startswith("http://") and not v_lower.startswith("https://"):
|
|
166
|
+
raise ValueError("api_base must be an HTTP or HTTPS URL")
|
|
167
|
+
return v
|
|
168
|
+
|
|
169
|
+
@field_validator("db_path", "cache_dir", mode="before")
|
|
170
|
+
@classmethod
|
|
171
|
+
def expand_path(cls, v: str | Path | None) -> Path | None:
|
|
172
|
+
"""Expand user home directory in paths."""
|
|
173
|
+
if v is None:
|
|
174
|
+
return None
|
|
175
|
+
path = Path(v).expanduser()
|
|
176
|
+
return path
|
|
177
|
+
|
|
178
|
+
@field_validator("log_level")
|
|
179
|
+
@classmethod
|
|
180
|
+
def validate_log_level(cls, v: str) -> str:
|
|
181
|
+
"""Validate log level."""
|
|
182
|
+
valid_levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
|
|
183
|
+
v_upper = v.upper()
|
|
184
|
+
if v_upper not in valid_levels:
|
|
185
|
+
raise ValueError(f"Invalid log level: {v}. Must be one of {valid_levels}")
|
|
186
|
+
return v_upper
|
|
187
|
+
|
|
188
|
+
def get_db_path(self) -> Path:
|
|
189
|
+
"""Get the database path, creating default if not set."""
|
|
190
|
+
if self.db_path:
|
|
191
|
+
return self.db_path
|
|
192
|
+
|
|
193
|
+
from platformdirs import user_data_dir # noqa: PLC0415
|
|
194
|
+
|
|
195
|
+
data_dir = Path(user_data_dir(self.app_name))
|
|
196
|
+
data_dir.mkdir(parents=True, exist_ok=True)
|
|
197
|
+
return data_dir / "sif.db"
|
|
198
|
+
|
|
199
|
+
def get_cache_dir(self) -> Path:
|
|
200
|
+
"""Get the cache directory, creating default if not set."""
|
|
201
|
+
if self.cache_dir:
|
|
202
|
+
return self.cache_dir
|
|
203
|
+
|
|
204
|
+
from platformdirs import user_cache_dir # noqa: PLC0415
|
|
205
|
+
|
|
206
|
+
cache_dir = Path(user_cache_dir(self.app_name))
|
|
207
|
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
208
|
+
return cache_dir
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@lru_cache
|
|
212
|
+
def get_settings() -> Settings:
|
|
213
|
+
"""Get cached settings instance.
|
|
214
|
+
|
|
215
|
+
Uses lru_cache to ensure settings are loaded only once.
|
|
216
|
+
"""
|
|
217
|
+
return Settings()
|
sif/core/__init__.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Core domain entities and business logic for SIF.
|
|
2
|
+
|
|
3
|
+
This package contains the core domain models and business logic
|
|
4
|
+
that define the fundamental concepts of the SIF system.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from sif.core.collection import Collection, CollectionManager
|
|
8
|
+
from sif.core.context import Context, ContextManager, ContextType
|
|
9
|
+
from sif.core.document import Document, DocumentChunk, DocumentMetadata
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"Collection",
|
|
14
|
+
"CollectionManager",
|
|
15
|
+
"Context",
|
|
16
|
+
"ContextManager",
|
|
17
|
+
"ContextType",
|
|
18
|
+
"Document",
|
|
19
|
+
"DocumentChunk",
|
|
20
|
+
"DocumentMetadata",
|
|
21
|
+
]
|
sif/core/collection.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Collection domain entity and manager."""
|
|
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 Collection:
|
|
10
|
+
"""Domain entity representing a document collection.
|
|
11
|
+
|
|
12
|
+
A collection is a logical grouping of documents, typically
|
|
13
|
+
corresponding to a directory or set of directories.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
id: str
|
|
17
|
+
name: str
|
|
18
|
+
description: str | None = None
|
|
19
|
+
paths: list[str] = field(default_factory=list)
|
|
20
|
+
document_count: int = 0
|
|
21
|
+
chunk_count: int = 0
|
|
22
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
23
|
+
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
|
24
|
+
updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
|
25
|
+
last_indexed_at: datetime | None = None
|
|
26
|
+
|
|
27
|
+
def add_path(self, path: str) -> None:
|
|
28
|
+
"""Add a path to the collection if not already present."""
|
|
29
|
+
if path not in self.paths:
|
|
30
|
+
self.paths.append(path)
|
|
31
|
+
self.updated_at = datetime.now(timezone.utc)
|
|
32
|
+
|
|
33
|
+
def remove_path(self, path: str) -> None:
|
|
34
|
+
"""Remove a path from the collection."""
|
|
35
|
+
if path in self.paths:
|
|
36
|
+
self.paths.remove(path)
|
|
37
|
+
self.updated_at = datetime.now(timezone.utc)
|
|
38
|
+
|
|
39
|
+
def update_metadata(self, **kwargs: Any) -> None:
|
|
40
|
+
"""Update collection metadata."""
|
|
41
|
+
self.metadata.update(kwargs)
|
|
42
|
+
self.updated_at = datetime.now(timezone.utc)
|
|
43
|
+
|
|
44
|
+
def mark_indexed(self) -> None:
|
|
45
|
+
"""Mark the collection as indexed."""
|
|
46
|
+
self.last_indexed_at = datetime.now(timezone.utc)
|
|
47
|
+
self.updated_at = datetime.now(timezone.utc)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class CollectionRepository(Protocol):
|
|
51
|
+
"""Protocol for collection repository operations."""
|
|
52
|
+
|
|
53
|
+
def get_by_id(self, collection_id: str) -> Collection | None:
|
|
54
|
+
"""Get a collection by ID."""
|
|
55
|
+
...
|
|
56
|
+
|
|
57
|
+
def get_by_name(self, name: str) -> Collection | None:
|
|
58
|
+
"""Get a collection by name."""
|
|
59
|
+
...
|
|
60
|
+
|
|
61
|
+
def list_all(self) -> list[Collection]:
|
|
62
|
+
"""List all collections."""
|
|
63
|
+
...
|
|
64
|
+
|
|
65
|
+
def create(self, collection: Collection) -> Collection:
|
|
66
|
+
"""Create a new collection."""
|
|
67
|
+
...
|
|
68
|
+
|
|
69
|
+
def update(self, collection: Collection) -> Collection:
|
|
70
|
+
"""Update an existing collection."""
|
|
71
|
+
...
|
|
72
|
+
|
|
73
|
+
def delete(self, collection_id: str) -> bool:
|
|
74
|
+
"""Delete a collection by ID."""
|
|
75
|
+
...
|
|
76
|
+
|
|
77
|
+
def exists(self, name: str) -> bool:
|
|
78
|
+
"""Check if a collection with the given name exists."""
|
|
79
|
+
...
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class CollectionManager:
|
|
83
|
+
"""Manager for collection operations.
|
|
84
|
+
|
|
85
|
+
This class provides high-level operations for managing collections,
|
|
86
|
+
coordinating between repositories and other services.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
def __init__(self, repository: CollectionRepository) -> None:
|
|
90
|
+
self._repository = repository
|
|
91
|
+
|
|
92
|
+
def create_collection(
|
|
93
|
+
self,
|
|
94
|
+
name: str,
|
|
95
|
+
description: str | None = None,
|
|
96
|
+
paths: list[str] | None = None,
|
|
97
|
+
metadata: dict[str, Any] | None = None,
|
|
98
|
+
) -> Collection:
|
|
99
|
+
"""Create a new collection."""
|
|
100
|
+
import uuid # noqa: PLC0415
|
|
101
|
+
|
|
102
|
+
if self._repository.exists(name):
|
|
103
|
+
raise ValueError(f"Collection '{name}' already exists")
|
|
104
|
+
|
|
105
|
+
collection = Collection(
|
|
106
|
+
id=str(uuid.uuid4()),
|
|
107
|
+
name=name,
|
|
108
|
+
description=description,
|
|
109
|
+
paths=paths or [],
|
|
110
|
+
metadata=metadata or {},
|
|
111
|
+
)
|
|
112
|
+
return self._repository.create(collection)
|
|
113
|
+
|
|
114
|
+
def get_collection(self, collection_id: str) -> Collection | None:
|
|
115
|
+
"""Get a collection by ID."""
|
|
116
|
+
return self._repository.get_by_id(collection_id)
|
|
117
|
+
|
|
118
|
+
def list_collections(self) -> list[Collection]:
|
|
119
|
+
"""List all collections."""
|
|
120
|
+
return self._repository.list_all()
|
|
121
|
+
|
|
122
|
+
def update_collection(
|
|
123
|
+
self,
|
|
124
|
+
collection_id: str,
|
|
125
|
+
name: str | None = None,
|
|
126
|
+
description: str | None = None,
|
|
127
|
+
) -> Collection:
|
|
128
|
+
"""Update a collection."""
|
|
129
|
+
collection = self._repository.get_by_id(collection_id)
|
|
130
|
+
if not collection:
|
|
131
|
+
raise ValueError(f"Collection '{collection_id}' not found")
|
|
132
|
+
|
|
133
|
+
if name is not None:
|
|
134
|
+
collection.name = name
|
|
135
|
+
if description is not None:
|
|
136
|
+
collection.description = description
|
|
137
|
+
|
|
138
|
+
collection.updated_at = datetime.now(timezone.utc)
|
|
139
|
+
return self._repository.update(collection)
|
|
140
|
+
|
|
141
|
+
def delete_collection(self, collection_id: str) -> bool:
|
|
142
|
+
"""Delete a collection."""
|
|
143
|
+
return self._repository.delete(collection_id)
|
|
144
|
+
|
|
145
|
+
def add_path(self, collection_id: str, path: str) -> Collection:
|
|
146
|
+
"""Add a path to a collection."""
|
|
147
|
+
collection = self._repository.get_by_id(collection_id)
|
|
148
|
+
if not collection:
|
|
149
|
+
raise ValueError(f"Collection '{collection_id}' not found")
|
|
150
|
+
|
|
151
|
+
collection.add_path(path)
|
|
152
|
+
return self._repository.update(collection)
|
|
153
|
+
|
|
154
|
+
def remove_path(self, collection_id: str, path: str) -> Collection:
|
|
155
|
+
"""Remove a path from a collection."""
|
|
156
|
+
collection = self._repository.get_by_id(collection_id)
|
|
157
|
+
if not collection:
|
|
158
|
+
raise ValueError(f"Collection '{collection_id}' not found")
|
|
159
|
+
|
|
160
|
+
collection.remove_path(path)
|
|
161
|
+
return self._repository.update(collection)
|
sif/core/context.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Context domain entity and manager."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from enum import Enum, auto
|
|
6
|
+
from typing import Protocol
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ContextType(Enum):
|
|
10
|
+
"""Type of context."""
|
|
11
|
+
|
|
12
|
+
COLLECTION = auto()
|
|
13
|
+
PATH = auto()
|
|
14
|
+
DOCUMENT = auto()
|
|
15
|
+
GLOBAL = auto()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class Context:
|
|
20
|
+
"""Domain entity representing contextual information.
|
|
21
|
+
|
|
22
|
+
Context provides descriptive metadata that can be attached to
|
|
23
|
+
collections, paths, or documents to improve search relevance.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
id: str
|
|
27
|
+
content: str
|
|
28
|
+
context_type: ContextType
|
|
29
|
+
target_id: str
|
|
30
|
+
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
|
31
|
+
updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
|
32
|
+
|
|
33
|
+
def update_content(self, content: str) -> None:
|
|
34
|
+
"""Update the context content."""
|
|
35
|
+
self.content = content
|
|
36
|
+
self.updated_at = datetime.now(timezone.utc)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ContextRepository(Protocol):
|
|
40
|
+
"""Protocol for context repository operations."""
|
|
41
|
+
|
|
42
|
+
def get_by_id(self, context_id: str) -> Context | None:
|
|
43
|
+
"""Get a context by ID."""
|
|
44
|
+
...
|
|
45
|
+
|
|
46
|
+
def get_by_target(self, target_id: str, context_type: ContextType) -> Context | None:
|
|
47
|
+
"""Get context by target ID and type."""
|
|
48
|
+
...
|
|
49
|
+
|
|
50
|
+
def list_by_type(self, context_type: ContextType) -> list[Context]:
|
|
51
|
+
"""List all contexts of a specific type."""
|
|
52
|
+
...
|
|
53
|
+
|
|
54
|
+
def list_all(self) -> list[Context]:
|
|
55
|
+
"""List all contexts."""
|
|
56
|
+
...
|
|
57
|
+
|
|
58
|
+
def create(self, context: Context) -> Context:
|
|
59
|
+
"""Create a new context."""
|
|
60
|
+
...
|
|
61
|
+
|
|
62
|
+
def update(self, context: Context) -> Context:
|
|
63
|
+
"""Update an existing context."""
|
|
64
|
+
...
|
|
65
|
+
|
|
66
|
+
def delete(self, context_id: str) -> bool:
|
|
67
|
+
"""Delete a context by ID."""
|
|
68
|
+
...
|
|
69
|
+
|
|
70
|
+
def delete_by_target(self, target_id: str) -> bool:
|
|
71
|
+
"""Delete all contexts for a target."""
|
|
72
|
+
...
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class ContextManager:
|
|
76
|
+
"""Manager for context operations.
|
|
77
|
+
|
|
78
|
+
Provides high-level operations for managing contextual information
|
|
79
|
+
attached to collections, paths, and documents.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
def __init__(self, repository: ContextRepository) -> None:
|
|
83
|
+
self._repository = repository
|
|
84
|
+
|
|
85
|
+
def add_context(
|
|
86
|
+
self,
|
|
87
|
+
target_id: str,
|
|
88
|
+
content: str,
|
|
89
|
+
context_type: ContextType,
|
|
90
|
+
) -> Context:
|
|
91
|
+
"""Add context to a target."""
|
|
92
|
+
import uuid # noqa: PLC0415
|
|
93
|
+
|
|
94
|
+
# Check if context already exists for this target
|
|
95
|
+
existing = self._repository.get_by_target(target_id, context_type)
|
|
96
|
+
if existing:
|
|
97
|
+
existing.update_content(content)
|
|
98
|
+
return self._repository.update(existing)
|
|
99
|
+
|
|
100
|
+
context = Context(
|
|
101
|
+
id=str(uuid.uuid4()),
|
|
102
|
+
content=content,
|
|
103
|
+
context_type=context_type,
|
|
104
|
+
target_id=target_id,
|
|
105
|
+
)
|
|
106
|
+
return self._repository.create(context)
|
|
107
|
+
|
|
108
|
+
def get_context(self, context_id: str) -> Context | None:
|
|
109
|
+
"""Get context by ID."""
|
|
110
|
+
return self._repository.get_by_id(context_id)
|
|
111
|
+
|
|
112
|
+
def get_context_for_target(self, target_id: str, context_type: ContextType) -> Context | None:
|
|
113
|
+
"""Get context for a specific target."""
|
|
114
|
+
return self._repository.get_by_target(target_id, context_type)
|
|
115
|
+
|
|
116
|
+
def update_context(self, context_id: str, content: str) -> Context:
|
|
117
|
+
"""Update context content."""
|
|
118
|
+
context = self._repository.get_by_id(context_id)
|
|
119
|
+
if not context:
|
|
120
|
+
raise ValueError(f"Context '{context_id}' not found")
|
|
121
|
+
|
|
122
|
+
context.update_content(content)
|
|
123
|
+
return self._repository.update(context)
|
|
124
|
+
|
|
125
|
+
def remove_context(self, context_id: str) -> bool:
|
|
126
|
+
"""Remove a context."""
|
|
127
|
+
return self._repository.delete(context_id)
|
|
128
|
+
|
|
129
|
+
def list_contexts(self, context_type: ContextType | None = None) -> list[Context]:
|
|
130
|
+
"""List contexts, optionally filtered by type."""
|
|
131
|
+
if context_type:
|
|
132
|
+
return self._repository.list_by_type(context_type)
|
|
133
|
+
return self._repository.list_all()
|
|
134
|
+
|
|
135
|
+
def build_search_context(
|
|
136
|
+
self,
|
|
137
|
+
collection_ids: list[str] | None = None,
|
|
138
|
+
path_prefixes: list[str] | None = None,
|
|
139
|
+
) -> str:
|
|
140
|
+
"""Build a combined context string for search.
|
|
141
|
+
|
|
142
|
+
This aggregates all relevant contexts for the given collections
|
|
143
|
+
and paths to provide additional context for search queries.
|
|
144
|
+
"""
|
|
145
|
+
contexts: list[str] = []
|
|
146
|
+
|
|
147
|
+
# Get global context
|
|
148
|
+
global_contexts = self._repository.list_by_type(ContextType.GLOBAL)
|
|
149
|
+
contexts.extend([c.content for c in global_contexts])
|
|
150
|
+
|
|
151
|
+
# Get collection contexts
|
|
152
|
+
if collection_ids:
|
|
153
|
+
for collection_id in collection_ids:
|
|
154
|
+
ctx = self._repository.get_by_target(collection_id, ContextType.COLLECTION)
|
|
155
|
+
if ctx:
|
|
156
|
+
contexts.append(ctx.content)
|
|
157
|
+
|
|
158
|
+
# Get path contexts
|
|
159
|
+
if path_prefixes:
|
|
160
|
+
all_path_contexts = self._repository.list_by_type(ContextType.PATH)
|
|
161
|
+
for ctx in all_path_contexts:
|
|
162
|
+
for prefix in path_prefixes:
|
|
163
|
+
if ctx.target_id.startswith(prefix):
|
|
164
|
+
contexts.append(ctx.content)
|
|
165
|
+
break
|
|
166
|
+
|
|
167
|
+
return "\n\n".join(contexts) if contexts else ""
|