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/cli/commands/get.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""Document retrieval commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
|
|
10
|
+
from sif.cli.formatters import prepend_line_numbers
|
|
11
|
+
from sif.core.models import Document
|
|
12
|
+
from sif.database.database import Database
|
|
13
|
+
from sif.database.repositories import DocumentRepository
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
console = Console()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@click.group("get")
|
|
20
|
+
def get_group() -> None:
|
|
21
|
+
"""Retrieve documents."""
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@get_group.command("get")
|
|
26
|
+
@click.argument("path_or_docid")
|
|
27
|
+
@click.option("--from-line", "-f", type=int, help="Start from line number")
|
|
28
|
+
@click.option("--lines", "-l", type=int, help="Number of lines to show")
|
|
29
|
+
@click.option("--line-numbers", is_flag=True, help="Show line numbers")
|
|
30
|
+
@click.pass_context
|
|
31
|
+
def get_cmd( # noqa: C901, PLR0912
|
|
32
|
+
ctx: click.Context,
|
|
33
|
+
path_or_docid: str,
|
|
34
|
+
from_line: int | None,
|
|
35
|
+
lines: int | None,
|
|
36
|
+
line_numbers: bool,
|
|
37
|
+
) -> None:
|
|
38
|
+
"""Get a document by path or document ID."""
|
|
39
|
+
index_path = ctx.obj["index_path"]
|
|
40
|
+
|
|
41
|
+
if not index_path.exists():
|
|
42
|
+
console.print("[yellow]No index found.[/yellow]")
|
|
43
|
+
return
|
|
44
|
+
|
|
45
|
+
db = Database(index_path)
|
|
46
|
+
db.init_schema()
|
|
47
|
+
|
|
48
|
+
with db.connection:
|
|
49
|
+
doc_repo = DocumentRepository(db.connection)
|
|
50
|
+
|
|
51
|
+
# Try to find by ID first
|
|
52
|
+
doc = doc_repo.get_by_id(path_or_docid)
|
|
53
|
+
|
|
54
|
+
if not doc:
|
|
55
|
+
# Try to find by path
|
|
56
|
+
# Need collection ID - search all collections
|
|
57
|
+
from sif.database.repositories import CollectionRepository # noqa: PLC0415
|
|
58
|
+
|
|
59
|
+
coll_repo = CollectionRepository(db.connection)
|
|
60
|
+
|
|
61
|
+
for coll in coll_repo.list_all():
|
|
62
|
+
doc = doc_repo.get_by_path(path_or_docid, coll.id)
|
|
63
|
+
if doc:
|
|
64
|
+
break
|
|
65
|
+
|
|
66
|
+
if not doc:
|
|
67
|
+
# Try matching by filename (short name lookup)
|
|
68
|
+
doc = doc_repo.get_by_filename(path_or_docid)
|
|
69
|
+
|
|
70
|
+
if not doc:
|
|
71
|
+
# Try as file path
|
|
72
|
+
path = Path(path_or_docid)
|
|
73
|
+
if path.exists():
|
|
74
|
+
try:
|
|
75
|
+
content = path.read_text()
|
|
76
|
+
console.print(f"[bold]{path}[/bold]")
|
|
77
|
+
console.print(content)
|
|
78
|
+
return
|
|
79
|
+
except Exception as e:
|
|
80
|
+
raise click.ClickException(f"Cannot read file: {e}") from e
|
|
81
|
+
|
|
82
|
+
raise click.ClickException(f"Document not found: {path_or_docid}")
|
|
83
|
+
|
|
84
|
+
# Display document
|
|
85
|
+
console.print(f"[bold cyan]{doc.title or doc.filename}[/bold cyan]")
|
|
86
|
+
console.print(f"[dim]Path: {doc.path}[/dim]")
|
|
87
|
+
console.print(f"[dim]Collection: {doc.collection_id}[/dim]")
|
|
88
|
+
console.print("")
|
|
89
|
+
|
|
90
|
+
# Get content
|
|
91
|
+
content = doc.content
|
|
92
|
+
|
|
93
|
+
# Apply line filtering
|
|
94
|
+
if from_line is not None or lines is not None:
|
|
95
|
+
content_lines = content.split("\n")
|
|
96
|
+
|
|
97
|
+
start = (from_line or 1) - 1 # Convert to 0-indexed
|
|
98
|
+
start = max(0, start)
|
|
99
|
+
|
|
100
|
+
if lines is not None:
|
|
101
|
+
end = start + lines
|
|
102
|
+
content_lines = content_lines[start:end]
|
|
103
|
+
else:
|
|
104
|
+
content_lines = content_lines[start:]
|
|
105
|
+
|
|
106
|
+
content = "\n".join(content_lines)
|
|
107
|
+
|
|
108
|
+
if line_numbers:
|
|
109
|
+
content = prepend_line_numbers(content)
|
|
110
|
+
|
|
111
|
+
# Display with syntax highlighting if markdown
|
|
112
|
+
if doc.path.endswith(".md"):
|
|
113
|
+
console.print(content)
|
|
114
|
+
else:
|
|
115
|
+
console.print(content)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@get_group.command("multi-get")
|
|
119
|
+
@click.argument("pattern")
|
|
120
|
+
@click.option("--max-bytes", "-b", type=int, default=100000, help="Max bytes per file")
|
|
121
|
+
@click.option("--line-numbers", is_flag=True, help="Show line numbers")
|
|
122
|
+
@click.pass_context
|
|
123
|
+
def multi_get_cmd( # noqa: C901
|
|
124
|
+
ctx: click.Context,
|
|
125
|
+
pattern: str,
|
|
126
|
+
max_bytes: int,
|
|
127
|
+
line_numbers: bool,
|
|
128
|
+
) -> None:
|
|
129
|
+
"""Get multiple documents matching a pattern."""
|
|
130
|
+
index_path = ctx.obj["index_path"]
|
|
131
|
+
|
|
132
|
+
if not index_path.exists():
|
|
133
|
+
console.print("[yellow]No index found.[/yellow]")
|
|
134
|
+
return
|
|
135
|
+
|
|
136
|
+
import fnmatch # noqa: PLC0415
|
|
137
|
+
|
|
138
|
+
db = Database(index_path)
|
|
139
|
+
db.init_schema()
|
|
140
|
+
|
|
141
|
+
with db.connection:
|
|
142
|
+
doc_repo = DocumentRepository(db.connection)
|
|
143
|
+
from sif.database.repositories import CollectionRepository # noqa: PLC0415
|
|
144
|
+
|
|
145
|
+
coll_repo = CollectionRepository(db.connection)
|
|
146
|
+
|
|
147
|
+
matched_docs: list[Document] = []
|
|
148
|
+
|
|
149
|
+
def _lookup_by_id_or_path_or_filename(item: str) -> Document | None:
|
|
150
|
+
"""Try ID, then exact path, then filename match."""
|
|
151
|
+
doc = doc_repo.get_by_id(item)
|
|
152
|
+
if doc:
|
|
153
|
+
return doc
|
|
154
|
+
for coll in coll_repo.list_all():
|
|
155
|
+
doc = doc_repo.get_by_path(item, coll.id)
|
|
156
|
+
if doc:
|
|
157
|
+
return doc
|
|
158
|
+
return doc_repo.get_by_filename(item)
|
|
159
|
+
|
|
160
|
+
if "," in pattern:
|
|
161
|
+
# Comma-separated IDs/paths
|
|
162
|
+
items = [item.strip() for item in pattern.split(",") if item.strip()]
|
|
163
|
+
seen_ids: set[str] = set()
|
|
164
|
+
for item in items:
|
|
165
|
+
doc = _lookup_by_id_or_path_or_filename(item)
|
|
166
|
+
if doc and doc.id not in seen_ids:
|
|
167
|
+
matched_docs.append(doc)
|
|
168
|
+
seen_ids.add(doc.id)
|
|
169
|
+
elif "*" in pattern or "?" in pattern:
|
|
170
|
+
# Glob pattern
|
|
171
|
+
for coll in coll_repo.list_all():
|
|
172
|
+
matched_docs.extend(
|
|
173
|
+
doc
|
|
174
|
+
for doc in doc_repo.list_by_collection(coll.id)
|
|
175
|
+
if fnmatch.fnmatch(doc.path, pattern) or fnmatch.fnmatch(doc.filename, pattern)
|
|
176
|
+
)
|
|
177
|
+
else:
|
|
178
|
+
# Single item
|
|
179
|
+
doc = _lookup_by_id_or_path_or_filename(pattern)
|
|
180
|
+
if doc:
|
|
181
|
+
matched_docs.append(doc)
|
|
182
|
+
|
|
183
|
+
if not matched_docs:
|
|
184
|
+
console.print(f"[yellow]No documents matching pattern: {pattern}[/yellow]")
|
|
185
|
+
return
|
|
186
|
+
|
|
187
|
+
console.print(f"[green]Found {len(matched_docs)} matching document(s)[/green]")
|
|
188
|
+
console.print("")
|
|
189
|
+
|
|
190
|
+
for doc in matched_docs:
|
|
191
|
+
content = doc.content
|
|
192
|
+
if len(content.encode()) > max_bytes:
|
|
193
|
+
content = content[:max_bytes] + "\n... [truncated]"
|
|
194
|
+
|
|
195
|
+
if line_numbers:
|
|
196
|
+
content = prepend_line_numbers(content)
|
|
197
|
+
|
|
198
|
+
console.print(f"[bold cyan]=== {doc.path} ===[/bold cyan]")
|
|
199
|
+
console.print(content)
|
|
200
|
+
console.print("")
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"""Index management commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
11
|
+
|
|
12
|
+
from sif.core.models import Document
|
|
13
|
+
from sif.database.database import Database
|
|
14
|
+
from sif.database.repositories import (
|
|
15
|
+
CollectionRepository,
|
|
16
|
+
DocumentChunkRepository,
|
|
17
|
+
DocumentRepository,
|
|
18
|
+
)
|
|
19
|
+
from sif.indexing.chunker import create_chunker
|
|
20
|
+
from sif.indexing.parser import MarkdownParser
|
|
21
|
+
from sif.indexing.scanner import FileScanner
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
console = Console()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@click.group("index")
|
|
28
|
+
def index_group() -> None:
|
|
29
|
+
"""Index management commands."""
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@index_group.command("update")
|
|
34
|
+
@click.option("--collection", "-c", help="Update specific collection only")
|
|
35
|
+
@click.option("--force", "-f", is_flag=True, help="Force re-index all documents")
|
|
36
|
+
@click.pass_context
|
|
37
|
+
def update_cmd(ctx: click.Context, collection: str | None, force: bool) -> None: # noqa: C901, PLR0912, PLR0915
|
|
38
|
+
"""Update the index by scanning collections."""
|
|
39
|
+
index_path = ctx.obj["index_path"]
|
|
40
|
+
|
|
41
|
+
db = Database(index_path)
|
|
42
|
+
db.init_schema()
|
|
43
|
+
|
|
44
|
+
with db.connection:
|
|
45
|
+
coll_repo = CollectionRepository(db.connection)
|
|
46
|
+
doc_repo = DocumentRepository(db.connection)
|
|
47
|
+
|
|
48
|
+
# Get collections to update
|
|
49
|
+
if collection:
|
|
50
|
+
collections = [coll_repo.get_by_name(collection)]
|
|
51
|
+
if not collections[0]:
|
|
52
|
+
raise click.ClickException(f"Collection '{collection}' not found")
|
|
53
|
+
else:
|
|
54
|
+
collections = coll_repo.list_all()
|
|
55
|
+
|
|
56
|
+
if not collections:
|
|
57
|
+
console.print(
|
|
58
|
+
"[yellow]No collections found. Add one with 'sif collection add'.[/yellow]"
|
|
59
|
+
)
|
|
60
|
+
return
|
|
61
|
+
|
|
62
|
+
total_added = 0
|
|
63
|
+
total_updated = 0
|
|
64
|
+
total_removed = 0
|
|
65
|
+
|
|
66
|
+
for coll in collections:
|
|
67
|
+
if not coll:
|
|
68
|
+
continue
|
|
69
|
+
|
|
70
|
+
console.print(f"\n[bold]Updating collection: {coll.name}[/bold]")
|
|
71
|
+
|
|
72
|
+
if coll.pre_update_cmd:
|
|
73
|
+
console.print(f" Running pre-update command: {coll.pre_update_cmd}")
|
|
74
|
+
result = subprocess.run(
|
|
75
|
+
coll.pre_update_cmd,
|
|
76
|
+
shell=True,
|
|
77
|
+
capture_output=True,
|
|
78
|
+
text=True,
|
|
79
|
+
check=False,
|
|
80
|
+
)
|
|
81
|
+
if result.returncode != 0:
|
|
82
|
+
err_msg = (
|
|
83
|
+
f"Pre-update command failed for '{coll.name}' "
|
|
84
|
+
f"(exit {result.returncode}): "
|
|
85
|
+
f"{result.stderr.strip() or result.stdout.strip()}"
|
|
86
|
+
)
|
|
87
|
+
raise click.ClickException(err_msg)
|
|
88
|
+
|
|
89
|
+
# Scan files
|
|
90
|
+
scanner = FileScanner()
|
|
91
|
+
scan_result = scanner.scan(
|
|
92
|
+
Path(coll.path),
|
|
93
|
+
pattern=coll.pattern,
|
|
94
|
+
ignore_patterns=coll.ignore_patterns,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
console.print(f" Found {scan_result.file_count} files")
|
|
98
|
+
|
|
99
|
+
# Get existing documents
|
|
100
|
+
existing_docs = {d.path: d for d in doc_repo.list_by_collection(coll.id)}
|
|
101
|
+
scanned_paths = set()
|
|
102
|
+
|
|
103
|
+
# Process each file
|
|
104
|
+
parser = MarkdownParser()
|
|
105
|
+
|
|
106
|
+
with Progress(
|
|
107
|
+
SpinnerColumn(),
|
|
108
|
+
TextColumn("[progress.description]{task.description}"),
|
|
109
|
+
console=console,
|
|
110
|
+
) as progress:
|
|
111
|
+
task = progress.add_task(f"Indexing {coll.name}...", total=scan_result.file_count)
|
|
112
|
+
|
|
113
|
+
for file_path in scan_result.files:
|
|
114
|
+
scanned_paths.add(str(file_path))
|
|
115
|
+
|
|
116
|
+
# Parse document
|
|
117
|
+
try:
|
|
118
|
+
parsed = parser.parse(file_path)
|
|
119
|
+
except Exception as e:
|
|
120
|
+
console.print(f" [red]Error parsing {file_path}: {e}[/red]")
|
|
121
|
+
progress.advance(task)
|
|
122
|
+
continue
|
|
123
|
+
|
|
124
|
+
# Check if document exists
|
|
125
|
+
existing = existing_docs.get(str(file_path))
|
|
126
|
+
|
|
127
|
+
if existing:
|
|
128
|
+
# Check if changed
|
|
129
|
+
if existing.checksum == parsed.checksum and not force:
|
|
130
|
+
console.print(f" [dim]Unchanged: {file_path.name}[/dim]")
|
|
131
|
+
progress.advance(task)
|
|
132
|
+
continue
|
|
133
|
+
|
|
134
|
+
# Update document
|
|
135
|
+
existing.content = parsed.content
|
|
136
|
+
existing.title = parsed.title
|
|
137
|
+
existing.metadata = parsed.metadata
|
|
138
|
+
existing.mtime = parsed.mtime
|
|
139
|
+
doc_repo.update(existing)
|
|
140
|
+
total_updated += 1
|
|
141
|
+
else:
|
|
142
|
+
# Create new document
|
|
143
|
+
doc = Document(
|
|
144
|
+
path=str(file_path),
|
|
145
|
+
collection_id=coll.id,
|
|
146
|
+
content=parsed.content,
|
|
147
|
+
title=parsed.title,
|
|
148
|
+
metadata=parsed.metadata,
|
|
149
|
+
mtime=parsed.mtime,
|
|
150
|
+
)
|
|
151
|
+
doc_repo.create(doc)
|
|
152
|
+
total_added += 1
|
|
153
|
+
|
|
154
|
+
progress.advance(task)
|
|
155
|
+
|
|
156
|
+
# Remove documents that no longer exist
|
|
157
|
+
for path, doc in existing_docs.items():
|
|
158
|
+
if path not in scanned_paths:
|
|
159
|
+
doc_repo.delete(doc.id)
|
|
160
|
+
total_removed += 1
|
|
161
|
+
|
|
162
|
+
# Update collection stats
|
|
163
|
+
coll.document_count = len(doc_repo.list_by_collection(coll.id))
|
|
164
|
+
from datetime import datetime, timezone # noqa: PLC0415
|
|
165
|
+
|
|
166
|
+
coll.last_indexed_at = datetime.now(timezone.utc)
|
|
167
|
+
coll_repo.update(coll)
|
|
168
|
+
|
|
169
|
+
console.print("\n[green]Index updated:[/green]")
|
|
170
|
+
console.print(f" Added: {total_added}")
|
|
171
|
+
console.print(f" Updated: {total_updated}")
|
|
172
|
+
console.print(f" Removed: {total_removed}")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@index_group.command("embed")
|
|
176
|
+
@click.option("--collection", "-c", help="Embed specific collection only")
|
|
177
|
+
@click.option("--force", "-f", is_flag=True, help="Force re-embed all documents")
|
|
178
|
+
@click.option("--chunk-strategy", default="auto", help="Chunking strategy")
|
|
179
|
+
@click.option("--model", "-m", help="Embedding model name")
|
|
180
|
+
@click.option(
|
|
181
|
+
"--model-type",
|
|
182
|
+
type=click.Choice(["sentence_transformers", "gguf", "openai", "modelscope"]),
|
|
183
|
+
help="Embedding model type override",
|
|
184
|
+
)
|
|
185
|
+
@click.pass_context
|
|
186
|
+
def embed_cmd( # noqa: C901, PLR0912, PLR0913, PLR0915
|
|
187
|
+
ctx: click.Context,
|
|
188
|
+
collection: str | None,
|
|
189
|
+
force: bool, # noqa: ARG001
|
|
190
|
+
chunk_strategy: str,
|
|
191
|
+
model: str | None,
|
|
192
|
+
model_type: str | None = None,
|
|
193
|
+
) -> None:
|
|
194
|
+
"""Generate embeddings for documents."""
|
|
195
|
+
index_path = ctx.obj["index_path"]
|
|
196
|
+
|
|
197
|
+
db = Database(index_path)
|
|
198
|
+
db.init_schema()
|
|
199
|
+
|
|
200
|
+
# Initialize embedder
|
|
201
|
+
from sif.config.settings import get_settings # noqa: PLC0415
|
|
202
|
+
from sif.embedding.manager import EmbeddingManager # noqa: PLC0415
|
|
203
|
+
from sif.search.vector import VectorSearcher # noqa: PLC0415
|
|
204
|
+
|
|
205
|
+
settings = get_settings()
|
|
206
|
+
if model:
|
|
207
|
+
settings = settings.model_copy(update={"model_name": model})
|
|
208
|
+
if model_type:
|
|
209
|
+
settings = settings.model_copy(update={"model_type": model_type})
|
|
210
|
+
|
|
211
|
+
try:
|
|
212
|
+
manager = EmbeddingManager.from_settings(settings)
|
|
213
|
+
except ImportError as e:
|
|
214
|
+
console.print(f"[red]Embedding backend not installed: {e}[/red]")
|
|
215
|
+
return
|
|
216
|
+
except Exception as e:
|
|
217
|
+
console.print(f"[red]Failed to load embedding model: {e}[/red]")
|
|
218
|
+
return
|
|
219
|
+
|
|
220
|
+
with db.connection:
|
|
221
|
+
coll_repo = CollectionRepository(db.connection)
|
|
222
|
+
doc_repo = DocumentRepository(db.connection)
|
|
223
|
+
chunk_repo = DocumentChunkRepository(db.connection)
|
|
224
|
+
|
|
225
|
+
# Get collections
|
|
226
|
+
if collection:
|
|
227
|
+
collections = [coll_repo.get_by_name(collection)]
|
|
228
|
+
if not collections[0]:
|
|
229
|
+
raise click.ClickException(f"Collection '{collection}' not found")
|
|
230
|
+
else:
|
|
231
|
+
collections = coll_repo.list_all()
|
|
232
|
+
|
|
233
|
+
total_chunks = 0
|
|
234
|
+
|
|
235
|
+
for coll in collections:
|
|
236
|
+
if not coll:
|
|
237
|
+
continue
|
|
238
|
+
|
|
239
|
+
console.print(f"\n[bold]Embedding collection: {coll.name}[/bold]")
|
|
240
|
+
documents = doc_repo.list_by_collection(coll.id)
|
|
241
|
+
chunker = create_chunker(chunk_strategy)
|
|
242
|
+
|
|
243
|
+
# Collect all chunks across documents
|
|
244
|
+
all_chunks = [] # (chunk, document_id)
|
|
245
|
+
doc_chunks_map: dict[str, list] = {}
|
|
246
|
+
|
|
247
|
+
for doc in documents:
|
|
248
|
+
chunk_repo.delete_by_document(doc.id)
|
|
249
|
+
chunks = chunker.chunk(doc.content)
|
|
250
|
+
doc_chunks_map[doc.id] = chunks
|
|
251
|
+
for i, chunk in enumerate(chunks):
|
|
252
|
+
chunk.document_id = doc.id
|
|
253
|
+
chunk.sequence = i
|
|
254
|
+
all_chunks.append((chunk, doc.id))
|
|
255
|
+
|
|
256
|
+
# Batch embed all chunks
|
|
257
|
+
if all_chunks:
|
|
258
|
+
try:
|
|
259
|
+
chunk_texts = [c.content for c, _ in all_chunks]
|
|
260
|
+
embedding_response = manager.embed(chunk_texts)
|
|
261
|
+
embeddings = embedding_response.embeddings
|
|
262
|
+
|
|
263
|
+
# Persist chunks and embeddings
|
|
264
|
+
batch_items: list[tuple[str, str, str | None, list[float]]] = []
|
|
265
|
+
for (chunk, doc_id), embedding in zip(all_chunks, embeddings):
|
|
266
|
+
chunk_repo.create(chunk)
|
|
267
|
+
batch_items.append((chunk.id, doc_id, chunk.id, embedding))
|
|
268
|
+
|
|
269
|
+
if batch_items:
|
|
270
|
+
vector_searcher = VectorSearcher(
|
|
271
|
+
db.connection, len(manager.embed_single("probe"))
|
|
272
|
+
)
|
|
273
|
+
vector_searcher.add_embeddings_batch(batch_items)
|
|
274
|
+
|
|
275
|
+
total_chunks += len(all_chunks)
|
|
276
|
+
except Exception as e:
|
|
277
|
+
console.print(f" [red]Error embedding collection {coll.name}: {e}[/red]")
|
|
278
|
+
|
|
279
|
+
# Update collection stats
|
|
280
|
+
documents = doc_repo.list_by_collection(coll.id)
|
|
281
|
+
coll.chunk_count = sum(len(chunk_repo.get_by_document(d.id)) for d in documents)
|
|
282
|
+
coll_repo.update(coll)
|
|
283
|
+
|
|
284
|
+
console.print(f"\n[green]Embedding complete: {total_chunks} chunks embedded[/green]")
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
@index_group.command("status")
|
|
288
|
+
@click.pass_context
|
|
289
|
+
def index_status_cmd(ctx: click.Context) -> None:
|
|
290
|
+
"""Show index status."""
|
|
291
|
+
# Delegate to main status command
|
|
292
|
+
from sif.cli.main import status_cmd # noqa: PLC0415
|
|
293
|
+
|
|
294
|
+
ctx.invoke(status_cmd)
|
sif/cli/commands/ls.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Top-level ls command for indexed documents."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.tree import Tree
|
|
8
|
+
|
|
9
|
+
from sif.database.database import Database
|
|
10
|
+
from sif.database.repositories import CollectionRepository, DocumentRepository
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
console = Console()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@click.command("ls")
|
|
17
|
+
@click.argument("collection", required=False)
|
|
18
|
+
@click.argument("subpath", required=False)
|
|
19
|
+
@click.pass_context
|
|
20
|
+
def ls_cmd(
|
|
21
|
+
ctx: click.Context,
|
|
22
|
+
collection: str | None,
|
|
23
|
+
subpath: str | None,
|
|
24
|
+
) -> None:
|
|
25
|
+
"""List indexed documents as a virtual file tree."""
|
|
26
|
+
index_path = ctx.obj["index_path"]
|
|
27
|
+
|
|
28
|
+
if not index_path.exists():
|
|
29
|
+
console.print("[yellow]No index found.[/yellow]")
|
|
30
|
+
return
|
|
31
|
+
|
|
32
|
+
db = Database(index_path)
|
|
33
|
+
db.init_schema()
|
|
34
|
+
|
|
35
|
+
with db.connection:
|
|
36
|
+
doc_repo = DocumentRepository(db.connection)
|
|
37
|
+
coll_repo = CollectionRepository(db.connection)
|
|
38
|
+
|
|
39
|
+
if collection:
|
|
40
|
+
collections = [coll_repo.get_by_name(collection)]
|
|
41
|
+
if not collections[0]:
|
|
42
|
+
raise click.ClickException(f"Collection '{collection}' not found")
|
|
43
|
+
else:
|
|
44
|
+
collections = coll_repo.list_all()
|
|
45
|
+
|
|
46
|
+
for coll in collections:
|
|
47
|
+
if not coll:
|
|
48
|
+
continue
|
|
49
|
+
|
|
50
|
+
docs = doc_repo.list_by_collection(coll.id)
|
|
51
|
+
if subpath:
|
|
52
|
+
prefix = subpath.strip("/")
|
|
53
|
+
docs = [d for d in docs if d.path.strip("/").startswith(prefix)]
|
|
54
|
+
|
|
55
|
+
if not docs:
|
|
56
|
+
continue
|
|
57
|
+
|
|
58
|
+
tree = Tree(f"[bold cyan]{coll.name}[/bold cyan]")
|
|
59
|
+
for doc in sorted(docs, key=lambda d: d.path):
|
|
60
|
+
parts = doc.path.strip("/").split("/")
|
|
61
|
+
node = tree
|
|
62
|
+
for part in parts[:-1]:
|
|
63
|
+
found = next((c for c in node.children if str(c.label) == part), None)
|
|
64
|
+
node = found if found else node.add(part)
|
|
65
|
+
node.add(parts[-1])
|
|
66
|
+
|
|
67
|
+
console.print(tree)
|
sif/cli/commands/mcp.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""MCP server commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
console = Console()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@click.group("mcp")
|
|
16
|
+
def mcp_group() -> None:
|
|
17
|
+
"""MCP server commands."""
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@mcp_group.command("stdio")
|
|
22
|
+
@click.pass_context
|
|
23
|
+
def mcp_stdio_cmd(ctx: click.Context) -> None:
|
|
24
|
+
"""Run MCP server in stdio mode."""
|
|
25
|
+
index_path = ctx.obj["index_path"]
|
|
26
|
+
|
|
27
|
+
if not Path(index_path).exists():
|
|
28
|
+
console.print(
|
|
29
|
+
"[yellow]Warning: No index found. Some features may not work.[/yellow]", file=sys.stderr
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
from sif.mcp.server import run_stdio_server # noqa: PLC0415
|
|
34
|
+
|
|
35
|
+
run_stdio_server(str(index_path))
|
|
36
|
+
except ImportError as e:
|
|
37
|
+
console.print(f"[red]MCP server not available: {e}[/red]")
|
|
38
|
+
raise click.ClickException("Install with: pip install sif[mcp]") from None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@mcp_group.command("http")
|
|
42
|
+
@click.option("--host", "-h", default="127.0.0.1", help="Host to bind to")
|
|
43
|
+
@click.option("--port", "-p", type=int, default=3000, help="Port to listen on")
|
|
44
|
+
@click.option("--reload", is_flag=True, help="Enable auto-reload")
|
|
45
|
+
@click.pass_context
|
|
46
|
+
def mcp_http_cmd(
|
|
47
|
+
ctx: click.Context,
|
|
48
|
+
host: str,
|
|
49
|
+
port: int,
|
|
50
|
+
reload: bool,
|
|
51
|
+
) -> None:
|
|
52
|
+
"""Run MCP server in HTTP mode."""
|
|
53
|
+
index_path = ctx.obj["index_path"]
|
|
54
|
+
|
|
55
|
+
console.print(f"[green]Starting MCP HTTP server on {host}:{port}[/green]")
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
from sif.mcp.server_http import run_http_server # noqa: PLC0415
|
|
59
|
+
|
|
60
|
+
run_http_server(str(index_path), host=host, port=port, reload=reload)
|
|
61
|
+
except ImportError as e:
|
|
62
|
+
console.print(f"[red]HTTP server not available: {e}[/red]")
|
|
63
|
+
raise click.ClickException("Install with: pip install sif[http]") from None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@mcp_group.command("daemon")
|
|
67
|
+
@click.option("--host", "-h", default="127.0.0.1", help="Host to bind to")
|
|
68
|
+
@click.option("--port", "-p", type=int, default=3000, help="Port to listen on")
|
|
69
|
+
@click.option("--pid-file", help="PID file path")
|
|
70
|
+
@click.option("--log-file", help="Log file path")
|
|
71
|
+
@click.option("--stop", is_flag=True, help="Stop the daemon")
|
|
72
|
+
@click.pass_context
|
|
73
|
+
def mcp_daemon_cmd( # noqa: PLR0913
|
|
74
|
+
ctx: click.Context,
|
|
75
|
+
host: str,
|
|
76
|
+
port: int,
|
|
77
|
+
pid_file: str | None, # noqa: ARG001
|
|
78
|
+
log_file: str | None, # noqa: ARG001
|
|
79
|
+
stop: bool,
|
|
80
|
+
) -> None:
|
|
81
|
+
"""Run MCP server as a daemon."""
|
|
82
|
+
if stop:
|
|
83
|
+
console.print("[yellow]Stopping daemon...[/yellow]")
|
|
84
|
+
# Implementation would read PID file and kill process
|
|
85
|
+
console.print("[green]Daemon stopped[/green]")
|
|
86
|
+
return
|
|
87
|
+
|
|
88
|
+
console.print("[green]Starting MCP daemon...[/green]")
|
|
89
|
+
console.print(f"[dim]Host: {host}:{port}[/dim]")
|
|
90
|
+
|
|
91
|
+
# In a real implementation, this would daemonize the process
|
|
92
|
+
# For now, just run the HTTP server
|
|
93
|
+
ctx.invoke(mcp_http_cmd, host=host, port=port, reload=False)
|