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/search/strategy.py ADDED
@@ -0,0 +1,97 @@
1
+ """Search strategy interface using the Strategy pattern."""
2
+
3
+ from abc import ABC, abstractmethod
4
+ from dataclasses import dataclass
5
+ from typing import Any, Protocol
6
+
7
+ from sif.models.search import SearchOptions, SearchResult
8
+
9
+
10
+ @dataclass
11
+ class SearchContext:
12
+ """Context passed to search strategies.
13
+
14
+ Contains all necessary information for performing a search,
15
+ including pre-computed embeddings and context strings.
16
+ """
17
+
18
+ query: str
19
+ query_embedding: list[float] | None = None
20
+ expanded_query: str | None = None
21
+ collection_ids: list[str] | None = None
22
+ context_string: str = ""
23
+ filters: dict[str, Any] | None = None
24
+
25
+
26
+ class SearchStrategy(ABC):
27
+ """Abstract base class for search strategies.
28
+
29
+ Implements the Strategy pattern to allow different search
30
+ algorithms to be used interchangeably.
31
+ """
32
+
33
+ @abstractmethod
34
+ def search(
35
+ self,
36
+ context: SearchContext,
37
+ options: SearchOptions,
38
+ ) -> list[SearchResult]:
39
+ """Execute search and return results.
40
+
41
+ Args:
42
+ context: Search context with query and embeddings
43
+ options: Search options and parameters
44
+
45
+ Returns:
46
+ List of search results ordered by relevance
47
+ """
48
+ ...
49
+
50
+ @abstractmethod
51
+ def search_batch(
52
+ self,
53
+ contexts: list[SearchContext],
54
+ options: SearchOptions,
55
+ ) -> list[list[SearchResult]]:
56
+ """Execute batch search.
57
+
58
+ Args:
59
+ contexts: List of search contexts
60
+ options: Search options and parameters
61
+
62
+ Returns:
63
+ List of search results for each context
64
+ """
65
+ ...
66
+
67
+
68
+ class Searchable(Protocol):
69
+ """Protocol for searchable repositories."""
70
+
71
+ def search_fts(
72
+ self,
73
+ query: str,
74
+ collection_ids: list[str] | None,
75
+ limit: int,
76
+ offset: int,
77
+ ) -> list[tuple[str, float]]:
78
+ """Search using full-text search.
79
+
80
+ Returns:
81
+ List of (document_id, score) tuples
82
+ """
83
+ ...
84
+
85
+ def search_vector(
86
+ self,
87
+ embedding: list[float],
88
+ collection_ids: list[str] | None,
89
+ limit: int,
90
+ offset: int,
91
+ ) -> list[tuple[str, float]]:
92
+ """Search using vector similarity.
93
+
94
+ Returns:
95
+ List of (document_id, score) tuples
96
+ """
97
+ ...
sif/search/vector.py ADDED
@@ -0,0 +1,177 @@
1
+ """Vector similarity search implementation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import sqlite3
8
+
9
+ from sif.core.models import SearchOptions, SearchResult
10
+
11
+
12
+ class VectorSearcher:
13
+ """Vector similarity search using sqlite-vec or fallback."""
14
+
15
+ def __init__(self, db: sqlite3.Connection, embedding_dim: int = 768) -> None:
16
+ self.db = db
17
+ self.embedding_dim = embedding_dim
18
+ self._vec_available = self._check_vec_extension()
19
+ if not self._vec_available:
20
+ raise RuntimeError(
21
+ "sqlite-vec extension is not available. Install sqlite-vec to use vector search."
22
+ )
23
+
24
+ def _check_vec_extension(self) -> bool:
25
+ """Check if sqlite-vec extension is available."""
26
+ try:
27
+ self.db.execute("SELECT vec_version()")
28
+ return True
29
+ except sqlite3.OperationalError:
30
+ return False
31
+
32
+ def search(
33
+ self,
34
+ query_embedding: list[float],
35
+ options: SearchOptions | None = None,
36
+ ) -> list[SearchResult]:
37
+ """Search documents by vector similarity."""
38
+ if options is None:
39
+ options = SearchOptions()
40
+ return self._search_with_vec(query_embedding, options)
41
+
42
+ def _search_with_vec(
43
+ self,
44
+ query_embedding: list[float],
45
+ options: SearchOptions,
46
+ ) -> list[SearchResult]:
47
+ """Search using sqlite-vec."""
48
+ # Convert embedding to vec format
49
+ embedding_str = self._embedding_to_vec(query_embedding)
50
+
51
+ # Build collection filter
52
+ collection_filter = ""
53
+ params = [embedding_str]
54
+
55
+ if options.collection_ids:
56
+ placeholders = ", ".join(["?"] * len(options.collection_ids))
57
+ collection_filter = f"AND d.collection_id IN ({placeholders})"
58
+ params.extend(options.collection_ids)
59
+
60
+ sql = f"""
61
+ SELECT
62
+ d.id as document_id,
63
+ d.title,
64
+ d.path,
65
+ c.name as collection_name,
66
+ distance as score
67
+ FROM document_embeddings de
68
+ JOIN documents d ON de.document_id = d.id
69
+ JOIN collections c ON d.collection_id = c.id
70
+ WHERE embedding MATCH ? AND k = {options.limit} {collection_filter}
71
+ ORDER BY distance
72
+ """
73
+
74
+ cursor = self.db.execute(sql, params)
75
+ results = []
76
+
77
+ for rank, row in enumerate(cursor.fetchall(), 1):
78
+ # Convert distance to score (lower distance = higher score)
79
+ # Cosine distance is 0-2, convert to 0-1 score
80
+ score = 1.0 - (row["score"] / 2.0)
81
+
82
+ if score < options.min_score:
83
+ continue
84
+
85
+ result = SearchResult(
86
+ document_id=row["document_id"],
87
+ title=row["title"] or "",
88
+ path=row["path"],
89
+ collection_name=row["collection_name"],
90
+ score=score,
91
+ rank=rank,
92
+ )
93
+
94
+ if options.include_content:
95
+ result.content = self._get_document_content(row["document_id"])
96
+
97
+ results.append(result)
98
+
99
+ return self._attach_contexts(results)
100
+
101
+ def _attach_contexts(self, results: list[SearchResult]) -> list[SearchResult]:
102
+ """Attach path context descriptions to search results via batch query."""
103
+ if not results:
104
+ return results
105
+ paths = list({r.path for r in results})
106
+ placeholders = ", ".join(["?"] * len(paths))
107
+ sql = f"""
108
+ SELECT target_id, content FROM contexts
109
+ WHERE context_type = 'path' AND target_id IN ({placeholders})
110
+ """
111
+ cursor = self.db.execute(sql, paths)
112
+ # Normalize keys for cross-platform matching (macOS /private/tmp, etc.)
113
+ context_map = {
114
+ os.path.realpath(row["target_id"]): row["content"] for row in cursor.fetchall()
115
+ }
116
+ for result in results:
117
+ result.context_description = context_map.get(os.path.realpath(result.path))
118
+ return results
119
+
120
+ def _embedding_to_vec(self, embedding: list[float]) -> str:
121
+ """Convert embedding to sqlite-vec format."""
122
+ return json.dumps(embedding)
123
+
124
+ def _get_document_content(self, document_id: str) -> str | None:
125
+ """Get document content."""
126
+ cursor = self.db.execute("SELECT content FROM documents WHERE id = ?", (document_id,))
127
+ row = cursor.fetchone()
128
+ return row[0] if row else None
129
+
130
+ def add_embedding(
131
+ self,
132
+ embedding_id: str,
133
+ document_id: str,
134
+ chunk_id: str | None,
135
+ embedding: list[float],
136
+ ) -> None:
137
+ """Add an embedding to the index."""
138
+ self._add_embedding_vec(embedding_id, document_id, chunk_id, embedding)
139
+
140
+ def _add_embedding_vec(
141
+ self,
142
+ embedding_id: str,
143
+ document_id: str,
144
+ chunk_id: str | None,
145
+ embedding: list[float],
146
+ ) -> None:
147
+ """Add embedding using sqlite-vec."""
148
+ embedding_str = self._embedding_to_vec(embedding)
149
+
150
+ self.db.execute(
151
+ """
152
+ INSERT OR REPLACE INTO document_embeddings
153
+ (embedding_id, document_id, chunk_id, embedding)
154
+ VALUES (?, ?, ?, vec_f32(?))
155
+ """,
156
+ (embedding_id, document_id, chunk_id, embedding_str),
157
+ )
158
+
159
+ def add_embeddings_batch(
160
+ self,
161
+ items: list[tuple[str, str, str | None, list[float]]],
162
+ ) -> None:
163
+ """Add multiple embeddings in a single batch operation.
164
+
165
+ Each item is a tuple of (embedding_id, document_id, chunk_id, embedding_vector).
166
+ """
167
+ if not items:
168
+ return
169
+ rows = [(eid, doc_id, chunk_id, json.dumps(vec)) for eid, doc_id, chunk_id, vec in items]
170
+ self.db.executemany(
171
+ """
172
+ INSERT OR REPLACE INTO document_embeddings
173
+ (embedding_id, document_id, chunk_id, embedding)
174
+ VALUES (?, ?, ?, vec_f32(?))
175
+ """,
176
+ rows,
177
+ )
sif/utils/__init__.py ADDED
@@ -0,0 +1,26 @@
1
+ """Utility functions and helpers for SIF.
2
+
3
+ This package provides various utility functions:
4
+ - Logging configuration
5
+ - File path utilities
6
+ - Text processing helpers
7
+ - Progress indicators
8
+ """
9
+
10
+ from sif.utils.logging import get_logger, setup_logging
11
+ from sif.utils.paths import get_cache_dir, get_config_dir, get_data_dir
12
+ from sif.utils.progress import ProgressTracker
13
+ from sif.utils.text import count_tokens, normalize_text, truncate_text
14
+
15
+
16
+ __all__ = [
17
+ "ProgressTracker",
18
+ "count_tokens",
19
+ "get_cache_dir",
20
+ "get_config_dir",
21
+ "get_data_dir",
22
+ "get_logger",
23
+ "normalize_text",
24
+ "setup_logging",
25
+ "truncate_text",
26
+ ]
sif/utils/logging.py ADDED
@@ -0,0 +1,143 @@
1
+ """Logging utilities."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import logging
7
+ import os
8
+ import sys
9
+ import warnings
10
+
11
+
12
+ # Default logging format
13
+ DEFAULT_FORMAT = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
14
+ SIMPLE_FORMAT = "%(levelname)s: %(message)s"
15
+
16
+ # Noisy third-party loggers to suppress in quiet mode
17
+ _NOISY_LOGGERS = [
18
+ "modelscope",
19
+ "transformers",
20
+ "sentence_transformers",
21
+ "torch",
22
+ "urllib3",
23
+ "huggingface_hub",
24
+ "auto_gptq",
25
+ "bitsandbytes",
26
+ ]
27
+
28
+ # Global quiet mode flag
29
+ _quiet_mode = False
30
+
31
+
32
+ def is_quiet() -> bool:
33
+ """Check if quiet mode is enabled."""
34
+ return _quiet_mode
35
+
36
+
37
+ def set_quiet(quiet: bool = True) -> None:
38
+ """Set quiet mode globally.
39
+
40
+ Use this when quiet mode is triggered after setup_logging has already run.
41
+ """
42
+ global _quiet_mode
43
+ _quiet_mode = quiet
44
+ if quiet:
45
+ _apply_quiet_mode()
46
+
47
+
48
+ def _apply_quiet_mode() -> None:
49
+ """Apply quiet mode side effects (logging, env vars, warnings)."""
50
+ logging.disable(logging.INFO)
51
+ for name in _NOISY_LOGGERS:
52
+ lib_logger = logging.getLogger(name)
53
+ lib_logger.setLevel(logging.WARNING)
54
+ for h in lib_logger.handlers[:]:
55
+ lib_logger.removeHandler(h)
56
+ lib_logger.propagate = False
57
+
58
+ os.environ.setdefault("TQDM_DISABLE", "1")
59
+ os.environ.setdefault("TRANSFORMERS_VERBOSITY", "error")
60
+ os.environ.setdefault("HF_HUB_DISABLE_PROGRESS_BARS", "1")
61
+
62
+ try:
63
+ from transformers import logging as transformers_logging # noqa: PLC0415
64
+
65
+ transformers_logging.set_verbosity_error()
66
+ except ImportError:
67
+ pass
68
+
69
+ warnings.filterwarnings("ignore")
70
+
71
+
72
+ @contextlib.contextmanager
73
+ def suppress_output():
74
+ """Suppress both stdout and stderr output within the context block."""
75
+ with open(os.devnull, "w") as devnull:
76
+ with contextlib.redirect_stdout(devnull), contextlib.redirect_stderr(devnull):
77
+ yield
78
+
79
+
80
+ @contextlib.contextmanager
81
+ def suppress_stdout():
82
+ """Suppress stdout output within the context block."""
83
+ with open(os.devnull, "w") as devnull, contextlib.redirect_stdout(devnull):
84
+ yield
85
+
86
+
87
+ @contextlib.contextmanager
88
+ def suppress_stderr():
89
+ """Suppress stderr output within the context block."""
90
+ with open(os.devnull, "w") as devnull, contextlib.redirect_stderr(devnull):
91
+ yield
92
+
93
+
94
+ def setup_logging(
95
+ level: str = "INFO",
96
+ format_str: str | None = None,
97
+ handler: logging.Handler | None = None,
98
+ ) -> None:
99
+ """Setup logging configuration.
100
+
101
+ Args:
102
+ level: Logging level (DEBUG, INFO, WARNING, ERROR)
103
+ format_str: Log format string
104
+ handler: Custom log handler
105
+ """
106
+ global _quiet_mode
107
+ _quiet_mode = level.upper() == "WARNING"
108
+
109
+ if format_str is None:
110
+ format_str = SIMPLE_FORMAT if level in ("INFO", "WARNING") else DEFAULT_FORMAT
111
+
112
+ # Create formatter
113
+ formatter = logging.Formatter(format_str)
114
+
115
+ # Create handler if not provided
116
+ if handler is None:
117
+ handler = logging.StreamHandler(sys.stderr)
118
+ handler.setFormatter(formatter)
119
+
120
+ # Configure root logger
121
+ root_logger = logging.getLogger("sif")
122
+ root_logger.setLevel(getattr(logging, level.upper()))
123
+ root_logger.handlers = []
124
+ root_logger.addHandler(handler)
125
+
126
+ # Don't propagate to parent
127
+ root_logger.propagate = False
128
+
129
+ # In quiet mode, suppress noisy third-party libraries and progress bars
130
+ if _quiet_mode:
131
+ _apply_quiet_mode()
132
+
133
+
134
+ def get_logger(name: str) -> logging.Logger:
135
+ """Get a logger instance.
136
+
137
+ Args:
138
+ name: Logger name (typically __name__)
139
+
140
+ Returns:
141
+ Logger instance
142
+ """
143
+ return logging.getLogger(f"sif.{name}")
sif/utils/paths.py ADDED
@@ -0,0 +1,82 @@
1
+ """Path utilities for SIF."""
2
+
3
+ from pathlib import Path
4
+
5
+ from platformdirs import user_cache_dir, user_config_dir, user_data_dir
6
+
7
+ from sif.config.constants import APP_NAME
8
+
9
+
10
+ def get_data_dir() -> Path:
11
+ """Get the application data directory.
12
+
13
+ Returns:
14
+ Path to the data directory
15
+ """
16
+ path = Path(user_data_dir(APP_NAME))
17
+ path.mkdir(parents=True, exist_ok=True)
18
+ return path
19
+
20
+
21
+ def get_cache_dir() -> Path:
22
+ """Get the application cache directory.
23
+
24
+ Returns:
25
+ Path to the cache directory
26
+ """
27
+ path = Path(user_cache_dir(APP_NAME))
28
+ path.mkdir(parents=True, exist_ok=True)
29
+ return path
30
+
31
+
32
+ def get_config_dir() -> Path:
33
+ """Get the application config directory.
34
+
35
+ Returns:
36
+ Path to the config directory
37
+ """
38
+ path = Path(user_config_dir(APP_NAME))
39
+ path.mkdir(parents=True, exist_ok=True)
40
+ return path
41
+
42
+
43
+ def expand_path(path: str | Path) -> Path:
44
+ """Expand a path, resolving ~ and environment variables.
45
+
46
+ Args:
47
+ path: Path to expand
48
+
49
+ Returns:
50
+ Expanded absolute path
51
+ """
52
+ return Path(path).expanduser().resolve()
53
+
54
+
55
+ def is_markdown_file(path: Path) -> bool:
56
+ """Check if a file is a markdown file.
57
+
58
+ Args:
59
+ path: File path to check
60
+
61
+ Returns:
62
+ True if the file has a markdown extension
63
+ """
64
+ from sif.config.constants import MARKDOWN_EXTENSIONS # noqa: PLC0415
65
+
66
+ return path.suffix.lower() in MARKDOWN_EXTENSIONS
67
+
68
+
69
+ def get_relative_path(path: Path, base: Path) -> Path:
70
+ """Get path relative to a base directory.
71
+
72
+ Args:
73
+ path: Absolute path
74
+ base: Base directory
75
+
76
+ Returns:
77
+ Relative path from base to path
78
+ """
79
+ try:
80
+ return path.relative_to(base)
81
+ except ValueError:
82
+ return path
sif/utils/progress.py ADDED
@@ -0,0 +1,83 @@
1
+ """Progress tracking utilities."""
2
+
3
+ from typing import Protocol
4
+
5
+ from rich.progress import BarColumn, Progress as RichProgress, SpinnerColumn, TaskID, TextColumn
6
+
7
+ from sif.utils.logging import get_logger
8
+
9
+
10
+ logger = get_logger(__name__)
11
+
12
+
13
+ class ProgressTracker(Protocol):
14
+ """Protocol for progress tracking."""
15
+
16
+ def start(self, description: str, total: int | None = None) -> None:
17
+ """Start tracking progress."""
18
+ ...
19
+
20
+ def update(self, advance: int = 1) -> None:
21
+ """Update progress."""
22
+ ...
23
+
24
+ def finish(self) -> None:
25
+ """Finish tracking."""
26
+ ...
27
+
28
+
29
+ class RichProgressTracker:
30
+ """Progress tracker using Rich library."""
31
+
32
+ def __init__(self) -> None:
33
+ """Initialize progress tracker."""
34
+ self._progress: RichProgress | None = None
35
+ self._task_id: TaskID | None = None
36
+
37
+ def start(self, description: str, total: int | None = None) -> None:
38
+ """Start tracking progress.
39
+
40
+ Args:
41
+ description: Task description
42
+ total: Total items (None for indeterminate)
43
+ """
44
+ self._progress = RichProgress(
45
+ SpinnerColumn(),
46
+ TextColumn("[progress.description]{task.description}"),
47
+ BarColumn() if total else None,
48
+ TextColumn("[progress.percentage]{task.percentage:>3.0f}%") if total else None,
49
+ )
50
+ self._progress.start()
51
+ self._task_id = self._progress.add_task(description, total=total)
52
+
53
+ def update(self, advance: int = 1) -> None:
54
+ """Update progress.
55
+
56
+ Args:
57
+ advance: Number of items to advance
58
+ """
59
+ if self._progress and self._task_id is not None:
60
+ self._progress.update(self._task_id, advance=advance)
61
+
62
+ def finish(self) -> None:
63
+ """Finish tracking."""
64
+ if self._progress:
65
+ self._progress.stop()
66
+ self._progress = None
67
+ self._task_id = None
68
+
69
+
70
+ class NullProgressTracker:
71
+ """No-op progress tracker for silent operation."""
72
+
73
+ def start(self, description: str, total: int | None = None) -> None:
74
+ """Start tracking (no-op)."""
75
+ pass
76
+
77
+ def update(self, advance: int = 1) -> None:
78
+ """Update progress (no-op)."""
79
+ pass
80
+
81
+ def finish(self) -> None:
82
+ """Finish tracking (no-op)."""
83
+ pass