codebase-navigator 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.
- codebase_navigator/__init__.py +6 -0
- codebase_navigator/ask.py +412 -0
- codebase_navigator/cli.py +230 -0
- codebase_navigator/config.py +102 -0
- codebase_navigator/extractor.py +281 -0
- codebase_navigator/index.py +279 -0
- codebase_navigator/ipc.py +221 -0
- codebase_navigator/tags.py +198 -0
- codebase_navigator/watcher.py +134 -0
- codebase_navigator-0.1.0.dist-info/METADATA +114 -0
- codebase_navigator-0.1.0.dist-info/RECORD +13 -0
- codebase_navigator-0.1.0.dist-info/WHEEL +4 -0
- codebase_navigator-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Configuration, schema definitions, and shared utilities for codebase-navigator."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import hashlib
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Iterator
|
|
10
|
+
|
|
11
|
+
# Ensure portable user caching & offline environment variables before importing ML libraries
|
|
12
|
+
_cache_base = Path(os.environ.get("XDG_CACHE_HOME") or (Path.home() / ".cache"))
|
|
13
|
+
if "TRITON_CACHE_DIR" not in os.environ or "/root/" in os.environ["TRITON_CACHE_DIR"]:
|
|
14
|
+
os.environ["TRITON_CACHE_DIR"] = str(_cache_base / "triton")
|
|
15
|
+
if "TORCH_HOME" not in os.environ or "/root/" in os.environ["TORCH_HOME"]:
|
|
16
|
+
os.environ["TORCH_HOME"] = str(_cache_base / "torch")
|
|
17
|
+
if "HF_HOME" not in os.environ or "/root/" in os.environ["HF_HOME"]:
|
|
18
|
+
os.environ["HF_HOME"] = str(_cache_base / "huggingface")
|
|
19
|
+
|
|
20
|
+
os.environ.setdefault("HF_HUB_OFFLINE", "1")
|
|
21
|
+
os.environ.setdefault("TRANSFORMERS_OFFLINE", "1")
|
|
22
|
+
os.environ.setdefault("HF_HUB_DISABLE_TELEMETRY", "1")
|
|
23
|
+
os.environ.setdefault("HF_HUB_ENABLE_HF_TRANSFER", "0")
|
|
24
|
+
os.environ.setdefault("TRANSFORMERS_VERBOSITY", "error")
|
|
25
|
+
os.environ.setdefault("TOKENIZERS_PARALLELISM", "false")
|
|
26
|
+
|
|
27
|
+
import pyarrow as pa
|
|
28
|
+
|
|
29
|
+
EMBEDDING_MODEL_NAME = "all-MiniLM-L6-v2"
|
|
30
|
+
VECTOR_DIM = 384
|
|
31
|
+
|
|
32
|
+
DOC_SCHEMA = pa.schema([
|
|
33
|
+
pa.field("id", pa.string()),
|
|
34
|
+
pa.field("path", pa.string()),
|
|
35
|
+
pa.field("abs_path", pa.string()),
|
|
36
|
+
pa.field("doc_type", pa.string()), # "markdown" or "code_doc"
|
|
37
|
+
pa.field("title", pa.string()),
|
|
38
|
+
pa.field("start_line", pa.int32()),
|
|
39
|
+
pa.field("end_line", pa.int32()),
|
|
40
|
+
pa.field("content", pa.string()),
|
|
41
|
+
pa.field("vector", pa.list_(pa.float32(), VECTOR_DIM)),
|
|
42
|
+
])
|
|
43
|
+
|
|
44
|
+
CODE_EXTENSIONS = {
|
|
45
|
+
".py", ".rs", ".go", ".ts", ".tsx", ".js", ".jsx",
|
|
46
|
+
".c", ".cpp", ".cc", ".cxx", ".h", ".hpp", ".hh",
|
|
47
|
+
".nix", ".sh", ".bash", ".zsh", ".sql", ".java",
|
|
48
|
+
".kt", ".scala", ".rb", ".php", ".cs", ".swift",
|
|
49
|
+
".lua", ".zig", ".nim", ".elm", ".ex", ".exs",
|
|
50
|
+
".erl", ".hrl", ".hs", ".ml", ".mli", ".pl", ".pm",
|
|
51
|
+
".r", ".jl", ".clj", ".cljs", ".lisp", ".scm",
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
DOC_EXTENSIONS = {
|
|
55
|
+
".md", ".markdown", ".rst", ".adoc", ".org",
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
IGNORE_DIR_NAMES = {
|
|
59
|
+
".git", ".hg", ".svn",
|
|
60
|
+
"node_modules", "target", "build", "dist",
|
|
61
|
+
".venv", "venv", "env", ".direnv",
|
|
62
|
+
"__pycache__", ".pytest_cache", ".mypy_cache", ".ruff_cache",
|
|
63
|
+
".cache", ".devel-index", ".devel-tools", ".codebase-navigator", ".dagster_home", "pipeline-cache",
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@contextlib.contextmanager
|
|
68
|
+
def silence_stdio() -> Iterator[None]:
|
|
69
|
+
"""Silence low-level stdout/stderr (e.g. from C-level libraries and model loaders)."""
|
|
70
|
+
try:
|
|
71
|
+
null_fd = os.open(os.devnull, os.O_RDWR)
|
|
72
|
+
save_stdout = os.dup(1)
|
|
73
|
+
save_stderr = os.dup(2)
|
|
74
|
+
os.dup2(null_fd, 1)
|
|
75
|
+
os.dup2(null_fd, 2)
|
|
76
|
+
try:
|
|
77
|
+
yield
|
|
78
|
+
finally:
|
|
79
|
+
os.dup2(save_stdout, 1)
|
|
80
|
+
os.dup2(save_stderr, 2)
|
|
81
|
+
os.close(null_fd)
|
|
82
|
+
os.close(save_stdout)
|
|
83
|
+
os.close(save_stderr)
|
|
84
|
+
except Exception:
|
|
85
|
+
yield
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def get_cache_dir(folder: Path, custom_index_dir: str | None = None) -> Path:
|
|
89
|
+
"""Determine the persistence directory for vector indexes and tools metadata."""
|
|
90
|
+
if custom_index_dir:
|
|
91
|
+
cdir = Path(custom_index_dir).resolve()
|
|
92
|
+
cdir.mkdir(parents=True, exist_ok=True)
|
|
93
|
+
return cdir
|
|
94
|
+
|
|
95
|
+
target = folder / ".codebase-navigator"
|
|
96
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
97
|
+
return target
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def get_socket_path(folder: Path, custom_index_dir: str | None = None) -> Path:
|
|
101
|
+
"""Return the Unix Domain Socket path used for IPC with cn watch."""
|
|
102
|
+
return get_cache_dir(folder, custom_index_dir) / "watch.sock"
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"""Semantic content extraction from Markdown files and source code comments/docstrings."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
import hashlib
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import re
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DocExtractor:
|
|
13
|
+
"""Extracts semantic chunks from Markdown documentation and code docstrings/comments."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, base_folder: Path):
|
|
16
|
+
self.base_folder = base_folder
|
|
17
|
+
|
|
18
|
+
def extract_markdown(self, path: Path) -> list[dict[str, Any]]:
|
|
19
|
+
"""Extract markdown sections and term definitions preserving headers and line ranges."""
|
|
20
|
+
try:
|
|
21
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
22
|
+
except Exception:
|
|
23
|
+
return []
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
rel_path = str(path.relative_to(self.base_folder))
|
|
27
|
+
except ValueError:
|
|
28
|
+
rel_path = str(path)
|
|
29
|
+
|
|
30
|
+
lines = text.splitlines()
|
|
31
|
+
chunks: list[dict[str, Any]] = []
|
|
32
|
+
|
|
33
|
+
header_re = re.compile(r"^(#{1,6})\s+(.+)$")
|
|
34
|
+
term_re = re.compile(r"^(?:-\s+)?(?:\*\*([^*]+)\*\*|__([^_]+)__)\s*[-—:]\s*(.+)$")
|
|
35
|
+
|
|
36
|
+
current_headers: list[str] = []
|
|
37
|
+
chunk_start = 1
|
|
38
|
+
chunk_lines: list[str] = []
|
|
39
|
+
|
|
40
|
+
def flush_chunk(end_line: int):
|
|
41
|
+
nonlocal chunk_start, chunk_lines
|
|
42
|
+
content = "\n".join(chunk_lines).strip()
|
|
43
|
+
if len(content) >= 20:
|
|
44
|
+
title = " > ".join(current_headers) if current_headers else f"{path.name} (top)"
|
|
45
|
+
chunk_id = hashlib.sha256(f"{rel_path}:{chunk_start}:{title}".encode("utf-8")).hexdigest()[:16]
|
|
46
|
+
chunks.append({
|
|
47
|
+
"id": chunk_id,
|
|
48
|
+
"path": rel_path,
|
|
49
|
+
"abs_path": str(path.resolve()),
|
|
50
|
+
"doc_type": "markdown",
|
|
51
|
+
"title": title,
|
|
52
|
+
"start_line": chunk_start,
|
|
53
|
+
"end_line": end_line,
|
|
54
|
+
"content": f"# {title}\n\n{content}",
|
|
55
|
+
})
|
|
56
|
+
chunk_lines = []
|
|
57
|
+
|
|
58
|
+
# 1. Section-level chunking
|
|
59
|
+
for idx, line in enumerate(lines, start=1):
|
|
60
|
+
match = header_re.match(line)
|
|
61
|
+
if match:
|
|
62
|
+
level = len(match.group(1))
|
|
63
|
+
heading_text = match.group(2).strip()
|
|
64
|
+
|
|
65
|
+
if chunk_lines:
|
|
66
|
+
flush_chunk(idx - 1)
|
|
67
|
+
|
|
68
|
+
while len(current_headers) >= level:
|
|
69
|
+
current_headers.pop()
|
|
70
|
+
current_headers.append(heading_text)
|
|
71
|
+
chunk_start = idx
|
|
72
|
+
chunk_lines.append(line)
|
|
73
|
+
else:
|
|
74
|
+
chunk_lines.append(line)
|
|
75
|
+
|
|
76
|
+
if chunk_lines:
|
|
77
|
+
flush_chunk(len(lines))
|
|
78
|
+
|
|
79
|
+
# 2. Granular term/definition chunking (for glossaries, bullet rules, specifications)
|
|
80
|
+
t_start = 0
|
|
81
|
+
t_name = ""
|
|
82
|
+
t_lines: list[str] = []
|
|
83
|
+
for idx, line in enumerate(lines, start=1):
|
|
84
|
+
s = line.strip()
|
|
85
|
+
match = term_re.match(s)
|
|
86
|
+
if match:
|
|
87
|
+
if t_lines and t_name and len("\n".join(t_lines)) >= 30:
|
|
88
|
+
c_id = hashlib.sha256(f"{rel_path}:{t_start}:{t_name}".encode("utf-8")).hexdigest()[:16]
|
|
89
|
+
chunks.append({
|
|
90
|
+
"id": c_id,
|
|
91
|
+
"path": rel_path,
|
|
92
|
+
"abs_path": str(path.resolve()),
|
|
93
|
+
"doc_type": "markdown",
|
|
94
|
+
"title": f"{path.name} > {t_name}",
|
|
95
|
+
"start_line": t_start,
|
|
96
|
+
"end_line": idx - 1,
|
|
97
|
+
"content": f"# {path.name} > {t_name}\n\n" + "\n".join(t_lines),
|
|
98
|
+
})
|
|
99
|
+
t_name = match.group(1) or match.group(2)
|
|
100
|
+
t_start = idx
|
|
101
|
+
t_lines = [line]
|
|
102
|
+
elif t_lines:
|
|
103
|
+
if s == "" and len(t_lines) >= 2 and not any(
|
|
104
|
+
lines[min(len(lines) - 1, idx)].strip().startswith(p) for p in ["-", "*", ">"]
|
|
105
|
+
):
|
|
106
|
+
if len("\n".join(t_lines)) >= 30:
|
|
107
|
+
c_id = hashlib.sha256(f"{rel_path}:{t_start}:{t_name}".encode("utf-8")).hexdigest()[:16]
|
|
108
|
+
chunks.append({
|
|
109
|
+
"id": c_id,
|
|
110
|
+
"path": rel_path,
|
|
111
|
+
"abs_path": str(path.resolve()),
|
|
112
|
+
"doc_type": "markdown",
|
|
113
|
+
"title": f"{path.name} > {t_name}",
|
|
114
|
+
"start_line": t_start,
|
|
115
|
+
"end_line": idx,
|
|
116
|
+
"content": f"# {path.name} > {t_name}\n\n" + "\n".join(t_lines),
|
|
117
|
+
})
|
|
118
|
+
t_name = ""
|
|
119
|
+
t_lines = []
|
|
120
|
+
else:
|
|
121
|
+
t_lines.append(line)
|
|
122
|
+
|
|
123
|
+
if t_lines and t_name and len("\n".join(t_lines)) >= 30:
|
|
124
|
+
c_id = hashlib.sha256(f"{rel_path}:{t_start}:{t_name}".encode("utf-8")).hexdigest()[:16]
|
|
125
|
+
chunks.append({
|
|
126
|
+
"id": c_id,
|
|
127
|
+
"path": rel_path,
|
|
128
|
+
"abs_path": str(path.resolve()),
|
|
129
|
+
"doc_type": "markdown",
|
|
130
|
+
"title": f"{path.name} > {t_name}",
|
|
131
|
+
"start_line": t_start,
|
|
132
|
+
"end_line": len(lines),
|
|
133
|
+
"content": f"# {path.name} > {t_name}\n\n" + "\n".join(t_lines),
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
return chunks
|
|
137
|
+
|
|
138
|
+
def extract_code_doc(self, path: Path) -> list[dict[str, Any]]:
|
|
139
|
+
"""Extract docstrings and comment blocks from source files."""
|
|
140
|
+
if path.suffix == ".py":
|
|
141
|
+
return self._extract_python(path)
|
|
142
|
+
return self._extract_generic_comments(path)
|
|
143
|
+
|
|
144
|
+
def _extract_python(self, path: Path) -> list[dict[str, Any]]:
|
|
145
|
+
try:
|
|
146
|
+
source = path.read_text(encoding="utf-8", errors="replace")
|
|
147
|
+
except Exception:
|
|
148
|
+
return []
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
rel_path = str(path.relative_to(self.base_folder))
|
|
152
|
+
except ValueError:
|
|
153
|
+
rel_path = str(path)
|
|
154
|
+
|
|
155
|
+
chunks: list[dict[str, Any]] = []
|
|
156
|
+
try:
|
|
157
|
+
tree = ast.parse(source, filename=str(path))
|
|
158
|
+
except (SyntaxError, ValueError):
|
|
159
|
+
return self._extract_generic_comments(path)
|
|
160
|
+
|
|
161
|
+
lines = source.splitlines()
|
|
162
|
+
|
|
163
|
+
# Module docstring
|
|
164
|
+
module_doc = ast.get_docstring(tree)
|
|
165
|
+
if module_doc and len(module_doc.strip()) > 10:
|
|
166
|
+
doc_lines = len(module_doc.splitlines())
|
|
167
|
+
chunk_id = hashlib.sha256(f"{rel_path}:1:module".encode("utf-8")).hexdigest()[:16]
|
|
168
|
+
chunks.append({
|
|
169
|
+
"id": chunk_id,
|
|
170
|
+
"path": rel_path,
|
|
171
|
+
"abs_path": str(path.resolve()),
|
|
172
|
+
"doc_type": "code_doc",
|
|
173
|
+
"title": f"{path.name} (module docstring)",
|
|
174
|
+
"start_line": 1,
|
|
175
|
+
"end_line": min(len(lines), doc_lines + 5),
|
|
176
|
+
"content": f"Module {rel_path}:\n{module_doc.strip()}",
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
# Functions & Classes
|
|
180
|
+
for node in ast.walk(tree):
|
|
181
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
|
182
|
+
doc = ast.get_docstring(node)
|
|
183
|
+
start_l = getattr(node, "lineno", 1)
|
|
184
|
+
end_l = getattr(node, "end_lineno", start_l + 10)
|
|
185
|
+
def_line = lines[start_l - 1].strip() if start_l <= len(lines) else ""
|
|
186
|
+
|
|
187
|
+
decorators = []
|
|
188
|
+
for dec in getattr(node, "decorator_list", []):
|
|
189
|
+
d_line = getattr(dec, "lineno", None)
|
|
190
|
+
if d_line and d_line <= len(lines):
|
|
191
|
+
decorators.append(lines[d_line - 1].strip())
|
|
192
|
+
|
|
193
|
+
kind = "class" if isinstance(node, ast.ClassDef) else "function"
|
|
194
|
+
node_name = node.name
|
|
195
|
+
|
|
196
|
+
text_parts = []
|
|
197
|
+
if decorators:
|
|
198
|
+
text_parts.append("\n".join(decorators))
|
|
199
|
+
text_parts.append(def_line)
|
|
200
|
+
if doc:
|
|
201
|
+
text_parts.append(doc.strip())
|
|
202
|
+
|
|
203
|
+
text = "\n".join(text_parts).strip()
|
|
204
|
+
if len(text) > 30:
|
|
205
|
+
chunk_id = hashlib.sha256(f"{rel_path}:{start_l}:{node_name}".encode("utf-8")).hexdigest()[:16]
|
|
206
|
+
chunks.append({
|
|
207
|
+
"id": chunk_id,
|
|
208
|
+
"path": rel_path,
|
|
209
|
+
"abs_path": str(path.resolve()),
|
|
210
|
+
"doc_type": "code_doc",
|
|
211
|
+
"title": f"{path.name} > {node_name} ({kind})",
|
|
212
|
+
"start_line": start_l,
|
|
213
|
+
"end_line": end_l,
|
|
214
|
+
"content": f"{rel_path} ({kind} {node_name}):\n{text}",
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
return chunks
|
|
218
|
+
|
|
219
|
+
def _extract_generic_comments(self, path: Path) -> list[dict[str, Any]]:
|
|
220
|
+
try:
|
|
221
|
+
source = path.read_text(encoding="utf-8", errors="replace")
|
|
222
|
+
except Exception:
|
|
223
|
+
return []
|
|
224
|
+
|
|
225
|
+
try:
|
|
226
|
+
rel_path = str(path.relative_to(self.base_folder))
|
|
227
|
+
except ValueError:
|
|
228
|
+
rel_path = str(path)
|
|
229
|
+
|
|
230
|
+
lines = source.splitlines()
|
|
231
|
+
chunks: list[dict[str, Any]] = []
|
|
232
|
+
|
|
233
|
+
comment_block: list[str] = []
|
|
234
|
+
block_start = 1
|
|
235
|
+
|
|
236
|
+
for idx, line in enumerate(lines, start=1):
|
|
237
|
+
s = line.strip()
|
|
238
|
+
is_comment = False
|
|
239
|
+
comment_content = ""
|
|
240
|
+
|
|
241
|
+
if s.startswith(("//", "#", "--", ";", "/*", "*")):
|
|
242
|
+
is_comment = True
|
|
243
|
+
comment_content = re.sub(r"^(\/\/|#|--|;|\/\*|\*)\s*", "", s)
|
|
244
|
+
|
|
245
|
+
if is_comment and comment_content:
|
|
246
|
+
if not comment_block:
|
|
247
|
+
block_start = idx
|
|
248
|
+
comment_block.append(comment_content)
|
|
249
|
+
else:
|
|
250
|
+
if len(comment_block) >= 3:
|
|
251
|
+
text = "\n".join(comment_block).strip()
|
|
252
|
+
if len(text) > 40:
|
|
253
|
+
chunk_id = hashlib.sha256(f"{rel_path}:{block_start}:comment".encode("utf-8")).hexdigest()[:16]
|
|
254
|
+
chunks.append({
|
|
255
|
+
"id": chunk_id,
|
|
256
|
+
"path": rel_path,
|
|
257
|
+
"abs_path": str(path.resolve()),
|
|
258
|
+
"doc_type": "code_doc",
|
|
259
|
+
"title": f"{path.name}: comment (L{block_start}-{idx-1})",
|
|
260
|
+
"start_line": block_start,
|
|
261
|
+
"end_line": idx - 1,
|
|
262
|
+
"content": f"{rel_path} (L{block_start}-{idx-1}):\n{text}",
|
|
263
|
+
})
|
|
264
|
+
comment_block = []
|
|
265
|
+
|
|
266
|
+
if len(comment_block) >= 3:
|
|
267
|
+
text = "\n".join(comment_block).strip()
|
|
268
|
+
if len(text) > 40:
|
|
269
|
+
chunk_id = hashlib.sha256(f"{rel_path}:{block_start}:comment".encode("utf-8")).hexdigest()[:16]
|
|
270
|
+
chunks.append({
|
|
271
|
+
"id": chunk_id,
|
|
272
|
+
"path": rel_path,
|
|
273
|
+
"abs_path": str(path.resolve()),
|
|
274
|
+
"doc_type": "code_doc",
|
|
275
|
+
"title": f"{path.name}: comment (L{block_start}-{len(lines)})",
|
|
276
|
+
"start_line": block_start,
|
|
277
|
+
"end_line": len(lines),
|
|
278
|
+
"content": f"{rel_path} (L{block_start}-{len(lines)}):\n{text}",
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
return chunks
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"""LanceDB vector index management and hybrid semantic search."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import re
|
|
8
|
+
from typing import TYPE_CHECKING, Any
|
|
9
|
+
|
|
10
|
+
import lancedb
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from sentence_transformers import SentenceTransformer
|
|
14
|
+
|
|
15
|
+
from .config import (
|
|
16
|
+
DOC_SCHEMA,
|
|
17
|
+
EMBEDDING_MODEL_NAME,
|
|
18
|
+
get_cache_dir,
|
|
19
|
+
silence_stdio,
|
|
20
|
+
)
|
|
21
|
+
from .extractor import DocExtractor
|
|
22
|
+
from .tags import get_available_files
|
|
23
|
+
|
|
24
|
+
COMMON_STOPWORDS = {
|
|
25
|
+
"what", "is", "a", "an", "the", "in", "on", "of", "for", "to",
|
|
26
|
+
"and", "or", "how", "why", "where", "which", "does", "do", "can",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class VectorIndex:
|
|
31
|
+
"""LanceDB index manager with SentenceTransformer embeddings and hybrid re-ranking."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, folder: Path, custom_index_dir: str | None = None):
|
|
34
|
+
self.folder = folder
|
|
35
|
+
self.cache_dir = get_cache_dir(folder, custom_index_dir)
|
|
36
|
+
self.db_dir = self.cache_dir / "lancedb"
|
|
37
|
+
self.meta_file = self.cache_dir / "files_meta.json"
|
|
38
|
+
self.db = lancedb.connect(str(self.db_dir))
|
|
39
|
+
self._model: SentenceTransformer | None = None
|
|
40
|
+
self._ensure_table()
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def model(self) -> SentenceTransformer:
|
|
44
|
+
if self._model is None:
|
|
45
|
+
from sentence_transformers import SentenceTransformer
|
|
46
|
+
with silence_stdio():
|
|
47
|
+
try:
|
|
48
|
+
self._model = SentenceTransformer(EMBEDDING_MODEL_NAME, local_files_only=True)
|
|
49
|
+
except Exception:
|
|
50
|
+
self._model = SentenceTransformer(EMBEDDING_MODEL_NAME)
|
|
51
|
+
return self._model
|
|
52
|
+
|
|
53
|
+
def _ensure_table(self):
|
|
54
|
+
try:
|
|
55
|
+
self.table = self.db.open_table("documents")
|
|
56
|
+
except Exception:
|
|
57
|
+
self.table = self.db.create_table("documents", schema=DOC_SCHEMA, mode="create")
|
|
58
|
+
|
|
59
|
+
def load_meta(self) -> dict[str, dict[str, Any]]:
|
|
60
|
+
if self.meta_file.exists():
|
|
61
|
+
try:
|
|
62
|
+
return json.loads(self.meta_file.read_text(encoding="utf-8"))
|
|
63
|
+
except Exception:
|
|
64
|
+
return {}
|
|
65
|
+
return {}
|
|
66
|
+
|
|
67
|
+
def save_meta(self, meta: dict[str, dict[str, Any]]):
|
|
68
|
+
self.meta_file.write_text(json.dumps(meta, indent=2), encoding="utf-8")
|
|
69
|
+
|
|
70
|
+
def sync(self, force: bool = False) -> tuple[int, int, int]:
|
|
71
|
+
"""Incrementally sync all available files into LanceDB.
|
|
72
|
+
|
|
73
|
+
Returns (files_updated, chunks_indexed, files_pruned).
|
|
74
|
+
"""
|
|
75
|
+
extractor = DocExtractor(self.folder)
|
|
76
|
+
code_files, doc_files = get_available_files(self.folder)
|
|
77
|
+
all_files = code_files + doc_files
|
|
78
|
+
|
|
79
|
+
meta = {} if force else self.load_meta()
|
|
80
|
+
current_rel_paths: set[str] = set()
|
|
81
|
+
|
|
82
|
+
files_to_index: list[tuple[Path, list[dict[str, Any]]]] = []
|
|
83
|
+
all_chunks_to_embed: list[dict[str, Any]] = []
|
|
84
|
+
|
|
85
|
+
for fpath in all_files:
|
|
86
|
+
try:
|
|
87
|
+
rel_p = str(fpath.relative_to(self.folder))
|
|
88
|
+
except ValueError:
|
|
89
|
+
rel_p = str(fpath)
|
|
90
|
+
current_rel_paths.add(rel_p)
|
|
91
|
+
|
|
92
|
+
stat = fpath.stat()
|
|
93
|
+
last_meta = meta.get(rel_p)
|
|
94
|
+
if (
|
|
95
|
+
not force
|
|
96
|
+
and last_meta
|
|
97
|
+
and last_meta.get("mtime") == stat.st_mtime
|
|
98
|
+
and last_meta.get("size") == stat.st_size
|
|
99
|
+
):
|
|
100
|
+
continue
|
|
101
|
+
|
|
102
|
+
if fpath.suffix.lower() in {".md", ".markdown", ".rst", ".adoc", ".org"}:
|
|
103
|
+
chunks = extractor.extract_markdown(fpath)
|
|
104
|
+
else:
|
|
105
|
+
chunks = extractor.extract_code_doc(fpath)
|
|
106
|
+
|
|
107
|
+
if chunks:
|
|
108
|
+
files_to_index.append((fpath, chunks))
|
|
109
|
+
all_chunks_to_embed.extend(chunks)
|
|
110
|
+
|
|
111
|
+
# Prune deleted files
|
|
112
|
+
deleted_paths = [p for p in meta.keys() if p not in current_rel_paths]
|
|
113
|
+
if deleted_paths:
|
|
114
|
+
for p in deleted_paths:
|
|
115
|
+
try:
|
|
116
|
+
escaped_p = p.replace('"', '\\"')
|
|
117
|
+
self.table.delete(f'path = "{escaped_p}"')
|
|
118
|
+
except Exception:
|
|
119
|
+
pass
|
|
120
|
+
meta.pop(p, None)
|
|
121
|
+
|
|
122
|
+
if force:
|
|
123
|
+
try:
|
|
124
|
+
self.db.drop_table("documents")
|
|
125
|
+
except Exception:
|
|
126
|
+
pass
|
|
127
|
+
self.table = self.db.create_table("documents", schema=DOC_SCHEMA, mode="create")
|
|
128
|
+
else:
|
|
129
|
+
for fpath, _ in files_to_index:
|
|
130
|
+
try:
|
|
131
|
+
rel_p = str(fpath.relative_to(self.folder))
|
|
132
|
+
except ValueError:
|
|
133
|
+
rel_p = str(fpath)
|
|
134
|
+
escaped_p = rel_p.replace('"', '\\"')
|
|
135
|
+
try:
|
|
136
|
+
self.table.delete(f'path = "{escaped_p}"')
|
|
137
|
+
except Exception:
|
|
138
|
+
pass
|
|
139
|
+
|
|
140
|
+
if all_chunks_to_embed:
|
|
141
|
+
texts = [c["content"] for c in all_chunks_to_embed]
|
|
142
|
+
embeddings = self.model.encode(
|
|
143
|
+
texts,
|
|
144
|
+
batch_size=256,
|
|
145
|
+
show_progress_bar=False,
|
|
146
|
+
normalize_embeddings=True,
|
|
147
|
+
)
|
|
148
|
+
for chunk, vec in zip(all_chunks_to_embed, embeddings):
|
|
149
|
+
chunk["vector"] = vec.tolist()
|
|
150
|
+
|
|
151
|
+
self.table.add(all_chunks_to_embed)
|
|
152
|
+
|
|
153
|
+
for fpath, chunks in files_to_index:
|
|
154
|
+
try:
|
|
155
|
+
rel_p = str(fpath.relative_to(self.folder))
|
|
156
|
+
except ValueError:
|
|
157
|
+
rel_p = str(fpath)
|
|
158
|
+
stat = fpath.stat()
|
|
159
|
+
meta[rel_p] = {
|
|
160
|
+
"mtime": stat.st_mtime,
|
|
161
|
+
"size": stat.st_size,
|
|
162
|
+
"chunks": len(chunks),
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
self.save_meta(meta)
|
|
166
|
+
return (len(files_to_index), len(all_chunks_to_embed), len(deleted_paths))
|
|
167
|
+
|
|
168
|
+
def update_single_file(self, fpath: Path) -> int:
|
|
169
|
+
"""Incrementally update one file in LanceDB."""
|
|
170
|
+
try:
|
|
171
|
+
rel_p = str(fpath.relative_to(self.folder))
|
|
172
|
+
except ValueError:
|
|
173
|
+
rel_p = str(fpath)
|
|
174
|
+
|
|
175
|
+
escaped_p = rel_p.replace('"', '\\"')
|
|
176
|
+
try:
|
|
177
|
+
self.table.delete(f'path = "{escaped_p}"')
|
|
178
|
+
except Exception:
|
|
179
|
+
pass
|
|
180
|
+
|
|
181
|
+
if not fpath.exists():
|
|
182
|
+
meta = self.load_meta()
|
|
183
|
+
meta.pop(rel_p, None)
|
|
184
|
+
self.save_meta(meta)
|
|
185
|
+
return 0
|
|
186
|
+
|
|
187
|
+
extractor = DocExtractor(self.folder)
|
|
188
|
+
if fpath.suffix.lower() in {".md", ".markdown", ".rst", ".adoc", ".org"}:
|
|
189
|
+
chunks = extractor.extract_markdown(fpath)
|
|
190
|
+
else:
|
|
191
|
+
chunks = extractor.extract_code_doc(fpath)
|
|
192
|
+
|
|
193
|
+
if chunks:
|
|
194
|
+
texts = [c["content"] for c in chunks]
|
|
195
|
+
embeddings = self.model.encode(texts, batch_size=64, show_progress_bar=False, normalize_embeddings=True)
|
|
196
|
+
for chunk, vec in zip(chunks, embeddings):
|
|
197
|
+
chunk["vector"] = vec.tolist()
|
|
198
|
+
self.table.add(chunks)
|
|
199
|
+
|
|
200
|
+
meta = self.load_meta()
|
|
201
|
+
stat = fpath.stat()
|
|
202
|
+
meta[rel_p] = {
|
|
203
|
+
"mtime": stat.st_mtime,
|
|
204
|
+
"size": stat.st_size,
|
|
205
|
+
"chunks": len(chunks),
|
|
206
|
+
}
|
|
207
|
+
self.save_meta(meta)
|
|
208
|
+
return len(chunks)
|
|
209
|
+
|
|
210
|
+
def search(
|
|
211
|
+
self,
|
|
212
|
+
query: str,
|
|
213
|
+
limit: int = 5,
|
|
214
|
+
doc_type: str | None = None,
|
|
215
|
+
) -> list[dict[str, Any]]:
|
|
216
|
+
"""Hybrid semantic vector search with keyword & title match re-ranking."""
|
|
217
|
+
q_vec = self.model.encode(query, normalize_embeddings=True).tolist()
|
|
218
|
+
fetch_limit = max(limit * 4, 20)
|
|
219
|
+
search_query = self.table.search(q_vec).metric("cosine").limit(fetch_limit)
|
|
220
|
+
|
|
221
|
+
if doc_type and doc_type != "all":
|
|
222
|
+
norm_type = "markdown" if doc_type in ["md", "markdown"] else "code_doc" if doc_type in ["code", "code_doc"] else doc_type
|
|
223
|
+
search_query = search_query.where(f'doc_type = "{norm_type}"')
|
|
224
|
+
|
|
225
|
+
try:
|
|
226
|
+
raw_results = search_query.to_list()
|
|
227
|
+
except Exception:
|
|
228
|
+
raw_results = []
|
|
229
|
+
|
|
230
|
+
if not raw_results:
|
|
231
|
+
return []
|
|
232
|
+
|
|
233
|
+
# Extract significant query terms
|
|
234
|
+
clean_terms = [
|
|
235
|
+
w.lower()
|
|
236
|
+
for w in re.findall(r"[A-Za-z0-9_]+", query)
|
|
237
|
+
if len(w) >= 2 and w.lower() not in COMMON_STOPWORDS
|
|
238
|
+
]
|
|
239
|
+
clean_phrase = " ".join(clean_terms)
|
|
240
|
+
|
|
241
|
+
scored_results: list[dict[str, Any]] = []
|
|
242
|
+
for r in raw_results:
|
|
243
|
+
dist = r.get("_distance", 0.0)
|
|
244
|
+
base_score = max(0.0, min(1.0, 1.0 - (dist / 2.0)))
|
|
245
|
+
score = base_score
|
|
246
|
+
|
|
247
|
+
title_lower = r.get("title", "").lower()
|
|
248
|
+
content_lower = r.get("content", "").lower()
|
|
249
|
+
dtype = r.get("doc_type", "")
|
|
250
|
+
|
|
251
|
+
# 1. Exact phrase match in title
|
|
252
|
+
if clean_phrase and clean_phrase in title_lower:
|
|
253
|
+
score += 0.12
|
|
254
|
+
# 2. Individual term matches in title
|
|
255
|
+
for term in clean_terms:
|
|
256
|
+
if term in title_lower:
|
|
257
|
+
score += 0.04
|
|
258
|
+
|
|
259
|
+
# 3. Term definitions or documentation boost
|
|
260
|
+
if dtype == "markdown":
|
|
261
|
+
score += 0.04
|
|
262
|
+
|
|
263
|
+
score = min(0.99, score)
|
|
264
|
+
|
|
265
|
+
scored_results.append({
|
|
266
|
+
"score": round(score, 3),
|
|
267
|
+
"base_score": round(base_score, 3),
|
|
268
|
+
"path": r["path"],
|
|
269
|
+
"abs_path": r["abs_path"],
|
|
270
|
+
"doc_type": r["doc_type"],
|
|
271
|
+
"title": r["title"],
|
|
272
|
+
"start_line": r["start_line"],
|
|
273
|
+
"end_line": r["end_line"],
|
|
274
|
+
"content": r["content"],
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
# Re-sort by boosted hybrid score
|
|
278
|
+
scored_results.sort(key=lambda x: x["score"], reverse=True)
|
|
279
|
+
return scored_results[:limit]
|