contextual-engine 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.
- contextual/__init__.py +18 -0
- contextual/__main__.py +11 -0
- contextual/cli.py +339 -0
- contextual/cli_docs.py +685 -0
- contextual/config.py +7 -0
- contextual/core/__init__.py +11 -0
- contextual/core/errors.py +470 -0
- contextual/core/models.py +590 -0
- contextual/docs/__init__.py +66 -0
- contextual/docs/chunker.py +550 -0
- contextual/docs/pipeline.py +513 -0
- contextual/docs/retrieval.py +654 -0
- contextual/docs/watcher.py +265 -0
- contextual/embedding/__init__.py +87 -0
- contextual/embedding/cache.py +455 -0
- contextual/embedding/embedder.py +414 -0
- contextual/embedding/helpers.py +252 -0
- contextual/git/__init__.py +22 -0
- contextual/git/blame.py +334 -0
- contextual/indexing/__init__.py +20 -0
- contextual/indexing/bug_sweep.py +119 -0
- contextual/indexing/chunker.py +691 -0
- contextual/indexing/embedder.py +271 -0
- contextual/indexing/file_watcher.py +154 -0
- contextual/indexing/incremental.py +260 -0
- contextual/indexing/index_writer.py +442 -0
- contextual/indexing/pipeline.py +438 -0
- contextual/indexing/processor.py +436 -0
- contextual/indexing/queries/readme.md +22 -0
- contextual/indexing/symbol_extractor.py +426 -0
- contextual/indexing/tokenizer.py +203 -0
- contextual/integrations/__init__.py +10 -0
- contextual/mcp/__init__.py +15 -0
- contextual/mcp/__main__.py +24 -0
- contextual/mcp/docs_tools.py +286 -0
- contextual/mcp/server.py +118 -0
- contextual/mcp/tools.py +443 -0
- contextual/observability/__init__.py +21 -0
- contextual/observability/logging.py +115 -0
- contextual/py.typed +0 -0
- contextual/retrieval/__init__.py +24 -0
- contextual/retrieval/context_assembler.py +372 -0
- contextual/retrieval/ranker.py +193 -0
- contextual/retrieval/search.py +548 -0
- contextual/security/__init__.py +52 -0
- contextual/security/paths.py +347 -0
- contextual/security/sanitize.py +349 -0
- contextual/security/workspace.py +348 -0
- contextual/storage/__init__.py +36 -0
- contextual/storage/fts_manager.py +273 -0
- contextual/storage/migration_v2.py +289 -0
- contextual/storage/migrations.py +316 -0
- contextual/storage/schema.py +210 -0
- contextual/storage/sqlite_pool.py +468 -0
- contextual/storage/vec0_manager.py +421 -0
- contextual_engine-0.1.0.dist-info/METADATA +297 -0
- contextual_engine-0.1.0.dist-info/RECORD +60 -0
- contextual_engine-0.1.0.dist-info/WHEEL +4 -0
- contextual_engine-0.1.0.dist-info/entry_points.txt +2 -0
- contextual_engine-0.1.0.dist-info/licenses/LICENSE +111 -0
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
"""Workspace isolation and management.
|
|
2
|
+
|
|
3
|
+
This module provides per-project workspace isolation. Each project gets its own
|
|
4
|
+
.contextual/ directory containing:
|
|
5
|
+
- SQLite database (contextual.db)
|
|
6
|
+
- LanceDB vector store (lance/)
|
|
7
|
+
- Tantivy BM25 index (tantivy/)
|
|
8
|
+
- Configuration (config.toml)
|
|
9
|
+
- Logs (logs/)
|
|
10
|
+
|
|
11
|
+
Workspace isolation prevents:
|
|
12
|
+
- Cross-project data leakage
|
|
13
|
+
- Permission escalation
|
|
14
|
+
- Global state contamination
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import os
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from contextual.core.errors import ErrorCode, SecurityError, security_context
|
|
23
|
+
from contextual.security.paths import safe_path
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class WorkspaceManager:
|
|
27
|
+
"""Manager for per-project workspaces.
|
|
28
|
+
|
|
29
|
+
Each project gets an isolated .contextual/ directory with proper permissions.
|
|
30
|
+
This class handles workspace initialization, validation, and path resolution.
|
|
31
|
+
|
|
32
|
+
Attributes:
|
|
33
|
+
project_root: Absolute path to project being indexed.
|
|
34
|
+
workspace_dir: Path to .contextual/ directory.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(self, project_root: Path) -> None:
|
|
38
|
+
"""Initialize workspace manager.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
project_root: Absolute path to project root.
|
|
42
|
+
|
|
43
|
+
Raises:
|
|
44
|
+
SecurityError: If project_root is invalid.
|
|
45
|
+
"""
|
|
46
|
+
if not project_root.is_absolute():
|
|
47
|
+
msg = "Project root must be absolute"
|
|
48
|
+
raise SecurityError(
|
|
49
|
+
code=ErrorCode.SECURITY_WORKSPACE_INIT_FAILED,
|
|
50
|
+
message=msg,
|
|
51
|
+
context=security_context(path=str(project_root)),
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
if not project_root.exists():
|
|
55
|
+
msg = "Project root does not exist"
|
|
56
|
+
raise SecurityError(
|
|
57
|
+
code=ErrorCode.SECURITY_WORKSPACE_INIT_FAILED,
|
|
58
|
+
message=msg,
|
|
59
|
+
context=security_context(path=str(project_root)),
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
if not project_root.is_dir():
|
|
63
|
+
msg = "Project root is not a directory"
|
|
64
|
+
raise SecurityError(
|
|
65
|
+
code=ErrorCode.SECURITY_WORKSPACE_INIT_FAILED,
|
|
66
|
+
message=msg,
|
|
67
|
+
context=security_context(path=str(project_root)),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
self.project_root = project_root
|
|
71
|
+
self.workspace_dir = project_root / ".contextual"
|
|
72
|
+
|
|
73
|
+
def init_workspace(
|
|
74
|
+
self,
|
|
75
|
+
*,
|
|
76
|
+
workspace_permissions: int = 0o700,
|
|
77
|
+
db_permissions: int = 0o600,
|
|
78
|
+
) -> None:
|
|
79
|
+
"""Initialize workspace directory with proper permissions.
|
|
80
|
+
|
|
81
|
+
Creates .contextual/ directory and subdirectories if they don't exist.
|
|
82
|
+
Sets strict permissions to prevent unauthorized access.
|
|
83
|
+
|
|
84
|
+
Directory structure:
|
|
85
|
+
.contextual/
|
|
86
|
+
├── contextual.db (SQLite database)
|
|
87
|
+
├── lance/ (LanceDB vector store)
|
|
88
|
+
├── tantivy/ (Tantivy BM25 index)
|
|
89
|
+
├── config.toml (Project-local config)
|
|
90
|
+
└── logs/ (Log files)
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
workspace_permissions: Permissions for .contextual/ directory (default 0o700).
|
|
94
|
+
db_permissions: Permissions for database files (default 0o600).
|
|
95
|
+
|
|
96
|
+
Raises:
|
|
97
|
+
SecurityError: If workspace initialization fails.
|
|
98
|
+
"""
|
|
99
|
+
# Create workspace directory
|
|
100
|
+
try:
|
|
101
|
+
self.workspace_dir.mkdir(mode=workspace_permissions, exist_ok=True)
|
|
102
|
+
except OSError as e:
|
|
103
|
+
msg = f"Failed to create workspace directory: {e}"
|
|
104
|
+
raise SecurityError(
|
|
105
|
+
code=ErrorCode.SECURITY_WORKSPACE_INIT_FAILED,
|
|
106
|
+
message=msg,
|
|
107
|
+
context=security_context(path=str(self.workspace_dir)),
|
|
108
|
+
) from e
|
|
109
|
+
|
|
110
|
+
# Set permissions explicitly (mkdir mode can be affected by umask)
|
|
111
|
+
try:
|
|
112
|
+
os.chmod(self.workspace_dir, workspace_permissions)
|
|
113
|
+
except OSError as e:
|
|
114
|
+
msg = f"Failed to set workspace permissions: {e}"
|
|
115
|
+
raise SecurityError(
|
|
116
|
+
code=ErrorCode.SECURITY_WORKSPACE_PERMISSION_DENIED,
|
|
117
|
+
message=msg,
|
|
118
|
+
context=security_context(path=str(self.workspace_dir)),
|
|
119
|
+
) from e
|
|
120
|
+
|
|
121
|
+
# Create subdirectories
|
|
122
|
+
subdirs = ["lance", "tantivy", "logs"]
|
|
123
|
+
for subdir in subdirs:
|
|
124
|
+
subdir_path = self.workspace_dir / subdir
|
|
125
|
+
try:
|
|
126
|
+
subdir_path.mkdir(mode=workspace_permissions, exist_ok=True)
|
|
127
|
+
os.chmod(subdir_path, workspace_permissions)
|
|
128
|
+
except OSError as e:
|
|
129
|
+
msg = f"Failed to create subdirectory {subdir}: {e}"
|
|
130
|
+
raise SecurityError(
|
|
131
|
+
code=ErrorCode.SECURITY_WORKSPACE_INIT_FAILED,
|
|
132
|
+
message=msg,
|
|
133
|
+
context=security_context(path=str(subdir_path)),
|
|
134
|
+
) from e
|
|
135
|
+
|
|
136
|
+
# Create .gitignore to exclude workspace from version control
|
|
137
|
+
gitignore_path = self.workspace_dir / ".gitignore"
|
|
138
|
+
if not gitignore_path.exists():
|
|
139
|
+
gitignore_content = (
|
|
140
|
+
"# Contextual workspace - do not commit\n"
|
|
141
|
+
"*\n"
|
|
142
|
+
"!.gitignore\n"
|
|
143
|
+
"!config.toml\n"
|
|
144
|
+
)
|
|
145
|
+
try:
|
|
146
|
+
gitignore_path.write_text(gitignore_content)
|
|
147
|
+
os.chmod(gitignore_path, 0o644)
|
|
148
|
+
except OSError:
|
|
149
|
+
# Non-fatal - workspace can function without .gitignore
|
|
150
|
+
pass
|
|
151
|
+
|
|
152
|
+
def get_db_path(self) -> Path:
|
|
153
|
+
"""Get path to SQLite database file.
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
Path to contextual.db within workspace.
|
|
157
|
+
|
|
158
|
+
Raises:
|
|
159
|
+
SecurityError: If path validation fails.
|
|
160
|
+
"""
|
|
161
|
+
return self._get_workspace_path("contextual.db")
|
|
162
|
+
|
|
163
|
+
def get_lance_dir(self) -> Path:
|
|
164
|
+
"""Get path to LanceDB directory.
|
|
165
|
+
|
|
166
|
+
Returns:
|
|
167
|
+
Path to lance/ subdirectory within workspace.
|
|
168
|
+
|
|
169
|
+
Raises:
|
|
170
|
+
SecurityError: If path validation fails.
|
|
171
|
+
"""
|
|
172
|
+
return self._get_workspace_path("lance")
|
|
173
|
+
|
|
174
|
+
def get_tantivy_dir(self) -> Path:
|
|
175
|
+
"""Get path to Tantivy index directory.
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
Path to tantivy/ subdirectory within workspace.
|
|
179
|
+
|
|
180
|
+
Raises:
|
|
181
|
+
SecurityError: If path validation fails.
|
|
182
|
+
"""
|
|
183
|
+
return self._get_workspace_path("tantivy")
|
|
184
|
+
|
|
185
|
+
def get_config_path(self) -> Path:
|
|
186
|
+
"""Get path to project-local config file.
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
Path to config.toml within workspace.
|
|
190
|
+
|
|
191
|
+
Raises:
|
|
192
|
+
SecurityError: If path validation fails.
|
|
193
|
+
"""
|
|
194
|
+
return self._get_workspace_path("config.toml")
|
|
195
|
+
|
|
196
|
+
def get_log_dir(self) -> Path:
|
|
197
|
+
"""Get path to log directory.
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
Path to logs/ subdirectory within workspace.
|
|
201
|
+
|
|
202
|
+
Raises:
|
|
203
|
+
SecurityError: If path validation fails.
|
|
204
|
+
"""
|
|
205
|
+
return self._get_workspace_path("logs")
|
|
206
|
+
|
|
207
|
+
def _get_workspace_path(self, relative_path: str) -> Path:
|
|
208
|
+
"""Get a path within the workspace with safety validation.
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
relative_path: Path relative to workspace directory.
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
Validated absolute path within workspace.
|
|
215
|
+
|
|
216
|
+
Raises:
|
|
217
|
+
SecurityError: If path is invalid or outside workspace.
|
|
218
|
+
"""
|
|
219
|
+
# Use safe_path to validate (allow_create for files that might not exist yet)
|
|
220
|
+
return safe_path(self.workspace_dir, relative_path, allow_create=True)
|
|
221
|
+
|
|
222
|
+
def ensure_workspace_exists(self) -> None:
|
|
223
|
+
"""Ensure workspace directory exists.
|
|
224
|
+
|
|
225
|
+
Creates workspace if it doesn't exist.
|
|
226
|
+
|
|
227
|
+
Raises:
|
|
228
|
+
SecurityError: If workspace initialization fails.
|
|
229
|
+
"""
|
|
230
|
+
if not self.workspace_dir.exists():
|
|
231
|
+
self.init_workspace()
|
|
232
|
+
|
|
233
|
+
def validate_workspace_isolation(self) -> None:
|
|
234
|
+
"""Validate that workspace is properly isolated.
|
|
235
|
+
|
|
236
|
+
Checks:
|
|
237
|
+
1. Workspace directory exists within project root
|
|
238
|
+
2. Workspace directory has correct permissions
|
|
239
|
+
3. No symlinks pointing outside project root
|
|
240
|
+
|
|
241
|
+
Raises:
|
|
242
|
+
SecurityError: If isolation is violated.
|
|
243
|
+
"""
|
|
244
|
+
# Check workspace exists
|
|
245
|
+
if not self.workspace_dir.exists():
|
|
246
|
+
msg = "Workspace directory does not exist"
|
|
247
|
+
raise SecurityError(
|
|
248
|
+
code=ErrorCode.SECURITY_WORKSPACE_ISOLATION_VIOLATION,
|
|
249
|
+
message=msg,
|
|
250
|
+
context=security_context(path=str(self.workspace_dir)),
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
# Check workspace is within project root
|
|
254
|
+
try:
|
|
255
|
+
self.workspace_dir.relative_to(self.project_root)
|
|
256
|
+
except ValueError as e:
|
|
257
|
+
msg = "Workspace directory is outside project root"
|
|
258
|
+
raise SecurityError(
|
|
259
|
+
code=ErrorCode.SECURITY_WORKSPACE_ISOLATION_VIOLATION,
|
|
260
|
+
message=msg,
|
|
261
|
+
context=security_context(
|
|
262
|
+
path=str(self.workspace_dir),
|
|
263
|
+
root=str(self.project_root),
|
|
264
|
+
),
|
|
265
|
+
) from e
|
|
266
|
+
|
|
267
|
+
# Check workspace is not a symlink pointing outside project root
|
|
268
|
+
if self.workspace_dir.is_symlink():
|
|
269
|
+
try:
|
|
270
|
+
resolved = self.workspace_dir.resolve(strict=True)
|
|
271
|
+
resolved.relative_to(self.project_root)
|
|
272
|
+
except (ValueError, OSError, RuntimeError) as e:
|
|
273
|
+
msg = "Workspace symlink points outside project root"
|
|
274
|
+
raise SecurityError(
|
|
275
|
+
code=ErrorCode.SECURITY_WORKSPACE_ISOLATION_VIOLATION,
|
|
276
|
+
message=msg,
|
|
277
|
+
context=security_context(path=str(self.workspace_dir)),
|
|
278
|
+
) from e
|
|
279
|
+
|
|
280
|
+
# Check permissions (on Unix-like systems)
|
|
281
|
+
if hasattr(os, "stat"):
|
|
282
|
+
try:
|
|
283
|
+
stat_info = self.workspace_dir.stat()
|
|
284
|
+
mode = stat_info.st_mode & 0o777
|
|
285
|
+
|
|
286
|
+
# Warn if permissions are too permissive (group/other have access)
|
|
287
|
+
if mode & 0o077: # Group or other have any permissions
|
|
288
|
+
# This is a warning, not an error - user might have reasons
|
|
289
|
+
# for broader permissions in a trusted environment
|
|
290
|
+
pass
|
|
291
|
+
except OSError:
|
|
292
|
+
# Permission check failed - non-fatal
|
|
293
|
+
pass
|
|
294
|
+
|
|
295
|
+
def get_workspace_info(self) -> dict[str, str]:
|
|
296
|
+
"""Get information about workspace.
|
|
297
|
+
|
|
298
|
+
Returns:
|
|
299
|
+
Dictionary with workspace paths and status.
|
|
300
|
+
"""
|
|
301
|
+
return {
|
|
302
|
+
"project_root": str(self.project_root),
|
|
303
|
+
"workspace_dir": str(self.workspace_dir),
|
|
304
|
+
"db_path": str(self.get_db_path()),
|
|
305
|
+
"lance_dir": str(self.get_lance_dir()),
|
|
306
|
+
"tantivy_dir": str(self.get_tantivy_dir()),
|
|
307
|
+
"config_path": str(self.get_config_path()),
|
|
308
|
+
"log_dir": str(self.get_log_dir()),
|
|
309
|
+
"exists": str(self.workspace_dir.exists()),
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def init_workspace(project_root: Path) -> WorkspaceManager:
|
|
314
|
+
"""Initialize a workspace for a project.
|
|
315
|
+
|
|
316
|
+
Convenience function that creates a WorkspaceManager and initializes
|
|
317
|
+
the workspace directory.
|
|
318
|
+
|
|
319
|
+
Args:
|
|
320
|
+
project_root: Absolute path to project root.
|
|
321
|
+
|
|
322
|
+
Returns:
|
|
323
|
+
Initialized WorkspaceManager.
|
|
324
|
+
|
|
325
|
+
Raises:
|
|
326
|
+
SecurityError: If initialization fails.
|
|
327
|
+
"""
|
|
328
|
+
manager = WorkspaceManager(project_root)
|
|
329
|
+
manager.init_workspace()
|
|
330
|
+
return manager
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def get_workspace_manager(project_root: Path) -> WorkspaceManager:
|
|
334
|
+
"""Get a workspace manager for a project.
|
|
335
|
+
|
|
336
|
+
Creates WorkspaceManager but does not initialize workspace directory.
|
|
337
|
+
Use this when you want to check workspace status before initialization.
|
|
338
|
+
|
|
339
|
+
Args:
|
|
340
|
+
project_root: Absolute path to project root.
|
|
341
|
+
|
|
342
|
+
Returns:
|
|
343
|
+
WorkspaceManager instance.
|
|
344
|
+
|
|
345
|
+
Raises:
|
|
346
|
+
SecurityError: If project_root is invalid.
|
|
347
|
+
"""
|
|
348
|
+
return WorkspaceManager(project_root)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Storage layer: SQLite, LanceDB, and bi-temporal query infrastructure.
|
|
2
|
+
|
|
3
|
+
Provides connection pooling, schema management, migrations, and temporal query helpers.
|
|
4
|
+
All persistence operations go through this layer.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from contextual.storage.migrations import (
|
|
9
|
+
apply_initial_schema,
|
|
10
|
+
get_current_version,
|
|
11
|
+
record_migration,
|
|
12
|
+
run_migrations,
|
|
13
|
+
)
|
|
14
|
+
from contextual.storage.schema import apply_schema
|
|
15
|
+
from contextual.storage.sqlite_pool import SQLitePool
|
|
16
|
+
from contextual.storage.vec0_manager import (
|
|
17
|
+
count_vectors,
|
|
18
|
+
delete_vectors,
|
|
19
|
+
insert_vectors,
|
|
20
|
+
search_vectors,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"SQLitePool",
|
|
26
|
+
"apply_initial_schema",
|
|
27
|
+
"apply_schema",
|
|
28
|
+
"count_vectors",
|
|
29
|
+
"delete_vectors",
|
|
30
|
+
"get_current_version",
|
|
31
|
+
"insert_vectors",
|
|
32
|
+
"record_migration",
|
|
33
|
+
"run_migrations",
|
|
34
|
+
"search_vectors",
|
|
35
|
+
]
|
|
36
|
+
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
"""FTS5 full-text search manager for code chunks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sqlite3
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from contextual.core.errors import ErrorCode, StorageError, storage_context
|
|
9
|
+
from contextual.observability.logging import get_logger
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from sqlite3 import Connection
|
|
14
|
+
|
|
15
|
+
logger = get_logger(__name__)
|
|
16
|
+
|
|
17
|
+
_FTS_TABLE = "fts_chunks"
|
|
18
|
+
_FTS_ERROR_CODE = getattr(ErrorCode, "STORAGE_FTS_FAILED", ErrorCode.STORAGE_SQLITE_QUERY_FAILED)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def escape_fts_query(query: str) -> str:
|
|
22
|
+
"""Escape a user query for safe FTS5 phrase matching.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
query: Raw user search text.
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
Phrase-quoted FTS5-safe query string.
|
|
29
|
+
"""
|
|
30
|
+
escaped = query.replace("\\", "\\\\").replace('"', '\\"')
|
|
31
|
+
return f'"{escaped}"'
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def search_fts(
|
|
35
|
+
conn: Connection,
|
|
36
|
+
query: str,
|
|
37
|
+
k: int = 10,
|
|
38
|
+
language_filter: str | None = None,
|
|
39
|
+
) -> list[tuple[int, float]]:
|
|
40
|
+
"""Search chunks using SQLite FTS5 and return ranked BM25 scores.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
conn: SQLite connection.
|
|
44
|
+
query: FTS5 query text.
|
|
45
|
+
k: Maximum result count in range [1, 1000].
|
|
46
|
+
language_filter: Optional language suffix (currently ignored - can be implemented via joins).
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
List of `(chunk_id, score)` tuples where score is positive and higher is better.
|
|
50
|
+
|
|
51
|
+
Raises:
|
|
52
|
+
StorageError: If SQLite execution fails unexpectedly.
|
|
53
|
+
"""
|
|
54
|
+
if query is None:
|
|
55
|
+
return []
|
|
56
|
+
if not isinstance(query, str) or not query.strip():
|
|
57
|
+
return []
|
|
58
|
+
if k <= 0 or k > 1000:
|
|
59
|
+
raise ValueError("k must be between 1 and 1000")
|
|
60
|
+
|
|
61
|
+
normalized_query = query.strip()
|
|
62
|
+
escaped_query = escape_fts_query(normalized_query)
|
|
63
|
+
|
|
64
|
+
sql = (
|
|
65
|
+
"SELECT chunk_id, -bm25(fts_chunks) AS score "
|
|
66
|
+
"FROM fts_chunks "
|
|
67
|
+
"WHERE fts_chunks MATCH ?"
|
|
68
|
+
)
|
|
69
|
+
params: list[object] = [escaped_query]
|
|
70
|
+
|
|
71
|
+
# Language filtering can be added later via JOIN with files table if needed
|
|
72
|
+
# TODO: Implement language filtering via JOIN with files table
|
|
73
|
+
|
|
74
|
+
sql += " ORDER BY score DESC LIMIT ?"
|
|
75
|
+
params.append(k)
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
cursor = conn.execute(sql, tuple(params))
|
|
79
|
+
rows = cursor.fetchall()
|
|
80
|
+
return [(int(chunk_id), float(score)) for chunk_id, score in rows]
|
|
81
|
+
except sqlite3.OperationalError as exc:
|
|
82
|
+
# Invalid FTS syntax should not break callers; return no results.
|
|
83
|
+
logger.warning(
|
|
84
|
+
"Invalid FTS5 query; returning empty results",
|
|
85
|
+
query=normalized_query,
|
|
86
|
+
error=str(exc),
|
|
87
|
+
)
|
|
88
|
+
return []
|
|
89
|
+
except sqlite3.Error as exc:
|
|
90
|
+
msg = f"FTS5 search failed: {exc}"
|
|
91
|
+
logger.error("FTS5 search failed", error=str(exc))
|
|
92
|
+
raise StorageError(
|
|
93
|
+
code=_FTS_ERROR_CODE,
|
|
94
|
+
message=msg,
|
|
95
|
+
context=storage_context(table=_FTS_TABLE, query=normalized_query),
|
|
96
|
+
) from exc
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def count_matches(conn: Connection, query: str) -> int:
|
|
100
|
+
"""Count total chunks that match an FTS5 query.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
conn: SQLite connection.
|
|
104
|
+
query: FTS5 query text.
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
Number of matching chunks.
|
|
108
|
+
|
|
109
|
+
Raises:
|
|
110
|
+
StorageError: If SQLite execution fails unexpectedly.
|
|
111
|
+
"""
|
|
112
|
+
if query is None:
|
|
113
|
+
return 0
|
|
114
|
+
if not isinstance(query, str) or not query.strip():
|
|
115
|
+
return 0
|
|
116
|
+
|
|
117
|
+
normalized_query = query.strip()
|
|
118
|
+
escaped_query = escape_fts_query(normalized_query)
|
|
119
|
+
|
|
120
|
+
sql = (
|
|
121
|
+
"SELECT COUNT(*) "
|
|
122
|
+
"FROM fts_chunks "
|
|
123
|
+
"WHERE fts_chunks MATCH ?"
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
try:
|
|
127
|
+
cursor = conn.execute(sql, (escaped_query,))
|
|
128
|
+
row = cursor.fetchone()
|
|
129
|
+
return int(row[0]) if row else 0
|
|
130
|
+
except sqlite3.OperationalError as exc:
|
|
131
|
+
logger.warning(
|
|
132
|
+
"Invalid FTS5 count query; returning zero",
|
|
133
|
+
query=normalized_query,
|
|
134
|
+
error=str(exc),
|
|
135
|
+
)
|
|
136
|
+
return 0
|
|
137
|
+
except sqlite3.Error as exc:
|
|
138
|
+
msg = f"FTS5 count failed: {exc}"
|
|
139
|
+
logger.error("FTS5 count failed", error=str(exc))
|
|
140
|
+
raise StorageError(
|
|
141
|
+
code=_FTS_ERROR_CODE,
|
|
142
|
+
message=msg,
|
|
143
|
+
context=storage_context(table=_FTS_TABLE, query=normalized_query),
|
|
144
|
+
) from exc
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def get_chunk_snippet(
|
|
148
|
+
conn: Connection,
|
|
149
|
+
chunk_id: int,
|
|
150
|
+
query: str,
|
|
151
|
+
max_len: int = 200,
|
|
152
|
+
) -> str:
|
|
153
|
+
"""Get a highlighted snippet for a matching chunk.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
conn: SQLite connection.
|
|
157
|
+
chunk_id: Chunk identifier from `chunks.id`.
|
|
158
|
+
query: Query text used for highlighting.
|
|
159
|
+
max_len: Maximum snippet character length.
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
Highlighted snippet string, truncated to `max_len`, or empty string.
|
|
163
|
+
|
|
164
|
+
Raises:
|
|
165
|
+
StorageError: If SQLite execution fails unexpectedly.
|
|
166
|
+
"""
|
|
167
|
+
if query is None:
|
|
168
|
+
return ""
|
|
169
|
+
if not isinstance(query, str) or not query.strip():
|
|
170
|
+
return ""
|
|
171
|
+
if max_len <= 0:
|
|
172
|
+
return ""
|
|
173
|
+
|
|
174
|
+
normalized_query = query.strip()
|
|
175
|
+
escaped_query = escape_fts_query(normalized_query)
|
|
176
|
+
|
|
177
|
+
sql = (
|
|
178
|
+
"SELECT snippet(fts_chunks, 1, '<b>', '</b>', '...', 20) AS snippet_text "
|
|
179
|
+
"FROM fts_chunks "
|
|
180
|
+
"WHERE chunk_id = ? AND fts_chunks MATCH ?"
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
try:
|
|
184
|
+
cursor = conn.execute(sql, (chunk_id, escaped_query))
|
|
185
|
+
row = cursor.fetchone()
|
|
186
|
+
if not row or row[0] is None:
|
|
187
|
+
return ""
|
|
188
|
+
snippet = str(row[0])
|
|
189
|
+
return snippet if len(snippet) <= max_len else snippet[:max_len]
|
|
190
|
+
except sqlite3.OperationalError as exc:
|
|
191
|
+
logger.warning(
|
|
192
|
+
"Invalid FTS5 snippet query; returning empty snippet",
|
|
193
|
+
query=normalized_query,
|
|
194
|
+
chunk_id=chunk_id,
|
|
195
|
+
error=str(exc),
|
|
196
|
+
)
|
|
197
|
+
return ""
|
|
198
|
+
except sqlite3.Error as exc:
|
|
199
|
+
msg = f"FTS5 snippet retrieval failed: {exc}"
|
|
200
|
+
logger.error("FTS5 snippet retrieval failed", error=str(exc), chunk_id=chunk_id)
|
|
201
|
+
raise StorageError(
|
|
202
|
+
code=_FTS_ERROR_CODE,
|
|
203
|
+
message=msg,
|
|
204
|
+
context=storage_context(table=_FTS_TABLE, query=normalized_query),
|
|
205
|
+
) from exc
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def test_fts_search() -> None:
|
|
209
|
+
"""Validate core FTS5 search and escaping behavior."""
|
|
210
|
+
conn = sqlite3.connect(":memory:")
|
|
211
|
+
|
|
212
|
+
conn.execute("""
|
|
213
|
+
CREATE TABLE chunks (
|
|
214
|
+
id INTEGER PRIMARY KEY,
|
|
215
|
+
file_id INTEGER,
|
|
216
|
+
start_line INTEGER,
|
|
217
|
+
end_line INTEGER,
|
|
218
|
+
symbol_name TEXT,
|
|
219
|
+
symbol_type TEXT,
|
|
220
|
+
content TEXT,
|
|
221
|
+
content_hash TEXT,
|
|
222
|
+
embedding_id INTEGER,
|
|
223
|
+
indexed_at INTEGER
|
|
224
|
+
)
|
|
225
|
+
""")
|
|
226
|
+
|
|
227
|
+
conn.execute("""
|
|
228
|
+
CREATE VIRTUAL TABLE fts_chunks USING fts5(
|
|
229
|
+
chunk_id UNINDEXED,
|
|
230
|
+
content,
|
|
231
|
+
symbol_name,
|
|
232
|
+
tokenize='unicode61 remove_diacritics 2'
|
|
233
|
+
)
|
|
234
|
+
""")
|
|
235
|
+
|
|
236
|
+
conn.execute(
|
|
237
|
+
"INSERT INTO chunks VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
238
|
+
(1, 1, 10, 20, "getUserData", "function", "def getUserData(): return data", "hash1", None, 1000),
|
|
239
|
+
)
|
|
240
|
+
conn.execute(
|
|
241
|
+
"INSERT INTO fts_chunks VALUES (?, ?, ?)",
|
|
242
|
+
(1, "def getUserData(): return data", "getUserData"),
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
results = search_fts(conn, "getUserData", k=10)
|
|
246
|
+
assert len(results) == 1
|
|
247
|
+
assert results[0][0] == 1
|
|
248
|
+
assert results[0][1] > 0
|
|
249
|
+
|
|
250
|
+
escaped = escape_fts_query('test "quote"')
|
|
251
|
+
assert '"' in escaped
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def test_fts_helpers() -> None:
|
|
255
|
+
"""Validate count and snippet helper functions."""
|
|
256
|
+
conn = sqlite3.connect(":memory:")
|
|
257
|
+
conn.execute("""
|
|
258
|
+
CREATE VIRTUAL TABLE fts_chunks USING fts5(
|
|
259
|
+
chunk_id UNINDEXED,
|
|
260
|
+
content,
|
|
261
|
+
symbol_name,
|
|
262
|
+
tokenize='unicode61 remove_diacritics 2'
|
|
263
|
+
)
|
|
264
|
+
""")
|
|
265
|
+
conn.execute(
|
|
266
|
+
"INSERT INTO fts_chunks VALUES (?, ?, ?)",
|
|
267
|
+
(11, "class Service: async def run(self): pass", "Service"),
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
assert count_matches(conn, "Service") == 1
|
|
271
|
+
snippet = get_chunk_snippet(conn, chunk_id=11, query="async def")
|
|
272
|
+
assert snippet
|
|
273
|
+
assert isinstance(snippet, str)
|