semcode-mcp 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.
@@ -0,0 +1,114 @@
1
+ """查询扩展:HyDE 假想文档 + 多查询变体(可选,需 OpenAI 兼容 LLM 端点)。
2
+
3
+ 单次 LLM 调用同时产出 2 条查询变体与 1 段假想代码,
4
+ 变体走 query embedding + BM25,假想代码走 document embedding(标准 HyDE),
5
+ 多路召回在 retriever 里 RRF 融合(原查询权重加倍防稀释)。
6
+
7
+ 配置(全部可选,缺任一则禁用扩展,管线自动回退原样):
8
+ SCM_LLM_BASE_URL OpenAI 兼容端点,如 https://api.example.com/v1
9
+ SCM_LLM_API_KEY 端点 key
10
+ SCM_LLM_MODEL 模型名
11
+ SCM_LLM_TIMEOUT 超时秒(默认 12),超时/异常静默回退
12
+ SCM_QUERY_EXPANSION=on 开启(默认 off)
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import logging
18
+ import os
19
+ import re
20
+ from collections import OrderedDict
21
+
22
+ import requests
23
+
24
+ logger = logging.getLogger("semantic-code-mcp")
25
+
26
+ _PROMPT = """You are a code-search query expander for a codebase retrieval system.
27
+ Given a user query, return ONLY a JSON object (no markdown fence):
28
+ {"variants": ["<rewrite 1>", "<rewrite 2>"], "hypothetical_code": "<snippet>"}
29
+
30
+ Rules:
31
+ - variants: 2 alternative phrasings in English, using likely code identifiers,
32
+ API names and terminology a developer would write in that codebase.
33
+ If the query is not English, variant 1 must be an English translation.
34
+ - hypothetical_code: 5-15 lines of plausible code (signature + key lines + comment)
35
+ that would exist in the codebase and directly answer the query.
36
+ - Keep it terse. JSON only.
37
+
38
+ Query: {query}"""
39
+
40
+
41
+ class QueryExpander:
42
+ """LLM 查询扩展器。未配置端点时 enabled=False,调用方应跳过。"""
43
+
44
+ def __init__(
45
+ self,
46
+ base_url: str | None = None,
47
+ api_key: str | None = None,
48
+ model: str | None = None,
49
+ timeout: float | None = None,
50
+ ) -> None:
51
+ self.base_url = (base_url or os.getenv("SCM_LLM_BASE_URL") or "").rstrip("/")
52
+ self.api_key = api_key or os.getenv("SCM_LLM_API_KEY") or ""
53
+ self.model = model or os.getenv("SCM_LLM_MODEL") or ""
54
+ self.timeout = timeout or float(os.getenv("SCM_LLM_TIMEOUT", "12"))
55
+ self.enabled = bool(self.base_url and self.model)
56
+ self._cache: OrderedDict[str, dict | None] = OrderedDict()
57
+ self._cache_max = 64
58
+
59
+ def expand(self, query: str) -> dict | None:
60
+ """返回 {"variants": [str, ...], "hyde": str} 或 None(禁用/失败)。"""
61
+ if not self.enabled:
62
+ return None
63
+ if query in self._cache:
64
+ self._cache.move_to_end(query)
65
+ return self._cache[query]
66
+ result = self._call(query)
67
+ self._cache[query] = result
68
+ if len(self._cache) > self._cache_max:
69
+ self._cache.popitem(last=False)
70
+ return result
71
+
72
+ def _call(self, query: str) -> dict | None:
73
+ try:
74
+ resp = requests.post(
75
+ f"{self.base_url}/chat/completions",
76
+ headers={"Authorization": f"Bearer {self.api_key}"},
77
+ json={
78
+ "model": self.model,
79
+ "messages": [{"role": "user", "content": _PROMPT.replace("{query}", query)}],
80
+ "temperature": 0.2,
81
+ "max_tokens": 2000,
82
+ },
83
+ timeout=self.timeout,
84
+ )
85
+ resp.raise_for_status()
86
+ content = resp.json()["choices"][0]["message"]["content"]
87
+ return self._parse(content)
88
+ except Exception as e: # 扩展失败不阻断检索
89
+ logger.debug("查询扩展失败,回退原查询: %s", e)
90
+ return None
91
+
92
+ @staticmethod
93
+ def _parse(content: str) -> dict | None:
94
+ """从 LLM 输出中提取 JSON(容忍 markdown fence / 前后杂讯)。"""
95
+ m = re.search(r"\{.*\}", content, re.S)
96
+ if not m:
97
+ return None
98
+ try:
99
+ data = json.loads(m.group(0))
100
+ except json.JSONDecodeError:
101
+ return None
102
+ variants = [v.strip() for v in data.get("variants", []) if isinstance(v, str) and v.strip()]
103
+ hyde = data.get("hypothetical_code") or ""
104
+ if not variants and not hyde:
105
+ return None
106
+ return {"variants": variants[:2], "hyde": hyde if isinstance(hyde, str) else ""}
107
+
108
+
109
+ def create_expander() -> QueryExpander | None:
110
+ """按 SCM_QUERY_EXPANSION 开关创建扩展器,未开启或端点缺失返回 None。"""
111
+ if os.getenv("SCM_QUERY_EXPANSION", "off").lower() not in ("on", "1", "true"):
112
+ return None
113
+ exp = QueryExpander()
114
+ return exp if exp.enabled else None
@@ -0,0 +1,416 @@
1
+ """索引编排:扫描目录 → 切分 → embedding → 存储,支持基于文件 hash 的增量同步。
2
+
3
+ 扫描时跳过常见无关目录、gitignore 命中文件、超大文件与不支持的扩展名。
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import hashlib
8
+ import logging
9
+ import os
10
+ import time
11
+ from collections import deque
12
+ from concurrent.futures import ThreadPoolExecutor
13
+ from pathlib import Path
14
+ from typing import Callable
15
+
16
+ import pathspec
17
+
18
+ from .chunker import EXT_TO_LANG, chunk_file
19
+ from .embedder import Embedder
20
+ from .store import CodeStore
21
+
22
+ logger = logging.getLogger("semantic-code-mcp")
23
+
24
+
25
+ def _bar(pct: int, width: int = 20) -> str:
26
+ """ASCII 进度条,如 █████░░░░░░░░░░░░░░░。"""
27
+ filled = int(width * max(0, min(pct, 100)) / 100)
28
+ return "█" * filled + "░" * (width - filled)
29
+
30
+
31
+ # 默认跳过的目录
32
+ DEFAULT_IGNORE_DIRS = {
33
+ ".git", "node_modules", "__pycache__", ".venv", "venv", "env",
34
+ "dist", "build", "target", "out", ".next", ".nuxt", "vendor",
35
+ ".idea", ".vscode", ".gradle", "bin", "obj", "coverage", ".pytest_cache",
36
+ }
37
+ # 超过此大小(字节)的文件跳过
38
+ MAX_FILE_SIZE = 1_000_000
39
+ # 高噪音生成物(锁文件等),即使扩展名在白名单也跳过
40
+ SKIP_FILENAMES = {
41
+ "package-lock.json", "yarn.lock", "pnpm-lock.yaml", "npm-shrinkwrap.json",
42
+ "poetry.lock", "uv.lock", "Cargo.lock", "composer.lock", "Gemfile.lock",
43
+ }
44
+ # agent 指导文件:架构/规范信息密度最高,很多仓库把它们 gitignore 了,索引时豁免
45
+ FORCE_INCLUDE_FILENAMES = {"AGENTS.md", "CLAUDE.md", "GEMINI.md", "AGENT.md", ".windsurfrules", ".cursorrules"}
46
+ # 单批 embedding 的最大累计块数(跨文件合并,减少 API 往返)
47
+ BATCH_CHUNKS = 256
48
+
49
+
50
+ def _embed_concurrency(embedder) -> int:
51
+ """embedding 并发数:SCM_EMBED_CONCURRENCY 优先,否则用后端建议值。
52
+
53
+ voyage(网络 IO)默认 3;本地推理(算力瓶颈)默认 1;未知后端保守串行。
54
+ """
55
+ env = os.getenv("SCM_EMBED_CONCURRENCY")
56
+ if env:
57
+ try:
58
+ return max(1, int(env))
59
+ except ValueError:
60
+ pass
61
+ return max(1, int(getattr(embedder, "default_concurrency", 1)))
62
+
63
+
64
+ class _EmbedPipeline:
65
+ """有界并发 embedding 流水线。
66
+
67
+ worker 线程只跑 embed_documents(纯网络/纯计算);切分与 sqlite 写回
68
+ 全部留在调用线程,保证 store 单线程访问;按提交顺序写回(FIFO),
69
+ 在飞批次数有界(workers+1)防内存膨胀。失败时异常在调用线程抛出,
70
+ 未写回文件的 hash 不更新,下次同步自然重试。
71
+ """
72
+
73
+ def __init__(self, indexer: "Indexer", workers: int,
74
+ on_write: Callable[[int, int], None] | None = None) -> None:
75
+ self.indexer = indexer
76
+ self.workers = workers
77
+ self.on_write = on_write
78
+ self._executor = ThreadPoolExecutor(
79
+ max_workers=workers, thread_name_prefix="scm-embed",
80
+ ) if workers > 1 else None
81
+ self._pending: deque = deque()
82
+
83
+ def submit(self, batch: list[tuple[str, str, float, int, list]]) -> None:
84
+ """提交一批 (fp, hash, mtime, size, chunks);可能阻塞写回最早的在飞批次。"""
85
+ all_chunks = [c for _, _, _, _, cs in batch for c in cs]
86
+ texts = [_embed_text(c, self.indexer.root) for c in all_chunks]
87
+ if self._executor:
88
+ fut = self._executor.submit(
89
+ self.indexer.embedder.embed_documents, texts) if texts else None
90
+ self._pending.append((batch, all_chunks, fut))
91
+ while len(self._pending) > self.workers:
92
+ self._write_oldest()
93
+ else:
94
+ embeddings = self.indexer.embedder.embed_documents(texts) if texts else []
95
+ self._write(batch, all_chunks, embeddings)
96
+
97
+ def drain(self) -> None:
98
+ """等待并写回全部在飞批次。"""
99
+ while self._pending:
100
+ self._write_oldest()
101
+
102
+ def close(self) -> None:
103
+ """释放线程池(幂等;异常路径下也必须调用,防线程泄漏)。"""
104
+ if self._executor:
105
+ self._executor.shutdown(wait=False, cancel_futures=True)
106
+ self._executor = None
107
+ self._pending.clear()
108
+
109
+ def _write_oldest(self) -> None:
110
+ batch, all_chunks, fut = self._pending.popleft()
111
+ embeddings = fut.result() if fut else []
112
+ self._write(batch, all_chunks, embeddings)
113
+
114
+ def _write(self, batch, all_chunks, embeddings) -> None:
115
+ store = self.indexer.store
116
+ offset = 0
117
+ for fp, fh, mt, sz, cs in batch:
118
+ n = len(cs)
119
+ if n:
120
+ store.add_chunks(cs, embeddings[offset:offset + n])
121
+ offset += n
122
+ store.set_file_hash(fp, fh, mtime=mt, size=sz)
123
+ if self.on_write:
124
+ self.on_write(len(batch), len(all_chunks))
125
+
126
+
127
+ def _embed_text(chunk, root: Path) -> str:
128
+ """构造带上下文的 embedding 文本(不改变存储,只影响向量)。
129
+
130
+ 在裸代码前添加文件路径 + 符号名作为上下文注释,让 embedding 模型
131
+ 理解代码的「角色」和「位置」,显著提升语义对齐(Contextual Retrieval)。
132
+ """
133
+ parts: list[str] = []
134
+ fp = chunk.file_path
135
+ try:
136
+ fp = str(Path(fp).relative_to(root))
137
+ except (ValueError, TypeError):
138
+ # 路径形式不一致(Windows 8.3 短路径等)时 resolve 归一重试:
139
+ # embed 文本必须与索引侧一致,否则同一块产生两种向量(检索漂移)
140
+ try:
141
+ fp = str(Path(fp).resolve().relative_to(root))
142
+ except (ValueError, TypeError, OSError):
143
+ pass
144
+ if fp:
145
+ parts.append(f"# File: {fp}")
146
+ if chunk.symbol:
147
+ parts.append(f"# Symbol: {chunk.symbol}")
148
+ if chunk.language:
149
+ parts.append(f"# Language: {chunk.language}")
150
+ if parts:
151
+ parts.append("")
152
+ parts.append(chunk.code)
153
+ return "\n".join(parts)
154
+
155
+
156
+ class Indexer:
157
+ """单个工作区的索引器。"""
158
+
159
+ def __init__(self, root: str, store: CodeStore, embedder: Embedder) -> None:
160
+ self.root = Path(root).resolve()
161
+ self.store = store
162
+ self.embedder = embedder
163
+ self._gitignore = self._load_gitignore()
164
+
165
+ @staticmethod
166
+ def _scope_pattern(line: str, rel_dir: str) -> str:
167
+ """把嵌套 .gitignore 的模式改写为相对于根目录的模式。
168
+
169
+ 遵循 git 语义:
170
+ - `!` 前缀(否定)必须保留在最前面
171
+ - 不含斜杠的模式匹配该 .gitignore 目录下任意层级 -> 加 `**/`
172
+ - 含斜杠的模式锚定到该 .gitignore 所在目录 -> 直接拼接
173
+ """
174
+ if not rel_dir:
175
+ return line
176
+ neg = line.startswith("!")
177
+ body = line[1:] if neg else line
178
+ anchored = body.startswith("/")
179
+ body = body.lstrip("/")
180
+ if not anchored and "/" not in body.rstrip("/"):
181
+ scoped = f"{rel_dir}/**/{body}"
182
+ else:
183
+ scoped = f"{rel_dir}/{body}"
184
+ return f"!{scoped}" if neg else scoped
185
+
186
+ def _load_gitignore(self) -> pathspec.PathSpec | None:
187
+ """收集所有层级 .gitignore 模式,合并为一个 PathSpec。"""
188
+ patterns: list[str] = []
189
+ for dirpath, dirnames, filenames in os.walk(self.root):
190
+ dirnames[:] = [
191
+ d for d in dirnames
192
+ if d not in DEFAULT_IGNORE_DIRS and not d.startswith(".")
193
+ ]
194
+ if ".gitignore" in filenames:
195
+ gi = Path(dirpath) / ".gitignore"
196
+ try:
197
+ rel_dir = Path(dirpath).relative_to(self.root).as_posix()
198
+ except ValueError:
199
+ rel_dir = ""
200
+ if rel_dir == ".":
201
+ rel_dir = ""
202
+ try:
203
+ for line in gi.read_text(encoding="utf-8", errors="ignore").splitlines():
204
+ line = line.strip()
205
+ if not line or line.startswith("#"):
206
+ continue
207
+ patterns.append(self._scope_pattern(line, rel_dir))
208
+ except Exception:
209
+ pass
210
+ if not patterns:
211
+ return None
212
+ return pathspec.PathSpec.from_lines("gitwildmatch", patterns)
213
+
214
+ def _should_skip(self, p: Path, size: int | None = None) -> bool:
215
+ """统一的文件过滤口径:扩展名 / gitignore / 大小。
216
+
217
+ 全量扫描与 watcher 增量必须走同一套规则,避免口径不一致。
218
+ """
219
+ if p.name in SKIP_FILENAMES:
220
+ return True
221
+ # 豁免文件跳过扩展名与 gitignore 检查(如无扩展名的 .windsurfrules)
222
+ force = p.name in FORCE_INCLUDE_FILENAMES
223
+ if not force and p.suffix.lower() not in EXT_TO_LANG:
224
+ return True
225
+ try:
226
+ rel = p.relative_to(self.root).as_posix()
227
+ except ValueError:
228
+ # 路径形式不一致(Windows 8.3 短路径/大小写/符号链接)时 resolve
229
+ # 归一后重试,否则 watcher/调用方传入的原始形式会被静默跳过
230
+ try:
231
+ rel = p.resolve().relative_to(self.root).as_posix()
232
+ except ValueError:
233
+ return True
234
+ if self._gitignore and not force and self._gitignore.match_file(rel):
235
+ return True
236
+ if size is None:
237
+ try:
238
+ size = p.stat().st_size
239
+ except OSError:
240
+ return True
241
+ return size > MAX_FILE_SIZE
242
+
243
+ def _iter_source_files(self):
244
+ for dirpath, dirnames, filenames in os.walk(self.root):
245
+ # 原地过滤忽略目录与隐藏目录
246
+ dirnames[:] = [
247
+ d for d in dirnames
248
+ if d not in DEFAULT_IGNORE_DIRS and not d.startswith(".")
249
+ ]
250
+ for fn in filenames:
251
+ p = Path(dirpath) / fn
252
+ if self._should_skip(p):
253
+ continue
254
+ yield p
255
+
256
+ @staticmethod
257
+ def _file_hash(path: Path) -> str:
258
+ h = hashlib.sha256()
259
+ try:
260
+ with open(path, "rb") as f:
261
+ for block in iter(lambda: f.read(65536), b""):
262
+ h.update(block)
263
+ except OSError:
264
+ return ""
265
+ return h.hexdigest()
266
+
267
+ def sync_dirty(
268
+ self,
269
+ dirty: set[str],
270
+ deleted: set[str],
271
+ progress: Callable[[int, int, str], None] | None = None,
272
+ ) -> dict:
273
+ """Watcher 驱动的增量同步:只处理变更文件,跳过全量扫描。
274
+
275
+ 比 sync() 快得多(O(变更数) vs O(总文件数))。
276
+ """
277
+ t0 = time.time()
278
+ # 清除已删除文件
279
+ for fp in deleted:
280
+ self.store.delete_file(fp)
281
+ self.store.remove_file_record(fp)
282
+ if deleted:
283
+ logger.info("[增量] 清除 %d 个已删除文件", len(deleted))
284
+ # 只对 dirty 文件做 hash 对比 + 重索引
285
+ to_index: list[tuple[str, str, float, int]] = []
286
+ for fp in dirty:
287
+ p = Path(fp)
288
+ if not p.exists():
289
+ self.store.delete_file(fp)
290
+ self.store.remove_file_record(fp)
291
+ continue
292
+ try:
293
+ st = p.stat()
294
+ except OSError:
295
+ continue
296
+ # 与全量扫描同口径:gitignore / 扩展名 / 大小过滤
297
+ if self._should_skip(p, size=st.st_size):
298
+ continue
299
+ fh = self._file_hash(p)
300
+ if not fh:
301
+ continue
302
+ if self.store.get_file_hash(fp) != fh:
303
+ to_index.append((fp, fh, st.st_mtime, st.st_size))
304
+ total = len(to_index)
305
+ chunks_total = 0
306
+ pipeline = _EmbedPipeline(self, _embed_concurrency(self.embedder))
307
+ buf: list[tuple[str, str, float, int, list]] = []
308
+ buf_chunks = 0
309
+ try:
310
+ for done, (fp, fh, mt, sz) in enumerate(to_index, 1):
311
+ self.store.delete_file(fp)
312
+ chunks = chunk_file(fp)
313
+ chunks_total += len(chunks)
314
+ buf.append((fp, fh, mt, sz, chunks))
315
+ buf_chunks += len(chunks)
316
+ if progress:
317
+ progress(done, total, fp)
318
+ if buf_chunks >= BATCH_CHUNKS:
319
+ pipeline.submit(buf)
320
+ buf, buf_chunks = [], 0
321
+ if buf:
322
+ pipeline.submit(buf)
323
+ pipeline.drain()
324
+ finally:
325
+ pipeline.close()
326
+ if total:
327
+ logger.info("[增量] 完成: %d 文件 / %d 块 (%.1fs)", total, chunks_total, time.time() - t0)
328
+ stats = self.store.stats()
329
+ return {"total_files": stats.get("files", 0), "changed": total, **stats}
330
+
331
+ def sync(self, progress: Callable[[int, int, str], None] | None = None) -> dict:
332
+ """全量/增量同步。
333
+
334
+ 用 mtime+size 快速预筛,只对变化文件算 hash 对比并重索引。
335
+ """
336
+ # 重载 gitignore(.gitignore 可能已变更)
337
+ self._gitignore = self._load_gitignore()
338
+ t_scan = time.time()
339
+ current_paths: set[str] = set()
340
+ to_index: list[tuple[str, str, float, int]] = []
341
+
342
+ for p in self._iter_source_files():
343
+ fp = str(p)
344
+ current_paths.add(fp)
345
+ try:
346
+ st = p.stat()
347
+ except OSError:
348
+ continue
349
+ meta = self.store.get_file_stat(fp)
350
+ if meta and meta["mtime"] == st.st_mtime and meta["size"] == st.st_size:
351
+ continue
352
+ fh = self._file_hash(p)
353
+ if not fh:
354
+ continue
355
+ if meta and meta["hash"] == fh:
356
+ self.store.set_file_hash(fp, fh, mtime=st.st_mtime, size=st.st_size)
357
+ continue
358
+ to_index.append((fp, fh, st.st_mtime, st.st_size))
359
+
360
+ indexed = self.store.all_indexed_files()
361
+
362
+ # 清除已删除文件
363
+ gone_count = 0
364
+ for gone in indexed - current_paths:
365
+ self.store.delete_file(gone)
366
+ self.store.remove_file_record(gone)
367
+ gone_count += 1
368
+
369
+ total = len(to_index)
370
+ logger.info(
371
+ "[1/2 扫描] %s: %d 个源文件, 变更 %d, 删除 %d (%.1fs)",
372
+ self.root.name, len(current_paths), total, gone_count, time.time() - t_scan,
373
+ )
374
+
375
+ # 缓冲多个文件的 chunk,攒够一批提交流水线(并发 embedding,顺序写回)
376
+ t_embed = time.time()
377
+ done_files = [0]
378
+ done_chunks = [0]
379
+
380
+ def _on_write(nfiles: int, nchunks: int) -> None:
381
+ done_files[0] += nfiles
382
+ done_chunks[0] += nchunks
383
+ # 每批一条进度线:进度条 + 文件/块计数 + 已用/预估剩余时长
384
+ elapsed = time.time() - t_embed
385
+ pct = done_files[0] * 100 // total if total else 100
386
+ eta = elapsed / done_files[0] * (total - done_files[0]) if done_files[0] else 0.0
387
+ logger.info(
388
+ "[2/2 向量化] %s %3d%% | %d/%d 文件 | %d 块 | 已用 %.0fs / 预估剩 %.0fs",
389
+ _bar(pct), pct, done_files[0], total, done_chunks[0], elapsed, eta,
390
+ )
391
+
392
+ workers = _embed_concurrency(self.embedder)
393
+ if total and workers > 1:
394
+ logger.info("[2/2 向量化] 并发 %d 路", workers)
395
+ pipeline = _EmbedPipeline(self, workers, on_write=_on_write)
396
+ buf: list[tuple[str, str, float, int, list]] = []
397
+ buf_chunks = 0
398
+ try:
399
+ for done, (fp, fh, mt, sz) in enumerate(to_index, 1):
400
+ self.store.delete_file(fp)
401
+ chunks = chunk_file(fp)
402
+ buf.append((fp, fh, mt, sz, chunks))
403
+ buf_chunks += len(chunks)
404
+ if progress:
405
+ progress(done, total, fp)
406
+ if buf_chunks >= BATCH_CHUNKS:
407
+ pipeline.submit(buf)
408
+ buf, buf_chunks = [], 0
409
+ if buf:
410
+ pipeline.submit(buf)
411
+ pipeline.drain()
412
+ finally:
413
+ pipeline.close()
414
+
415
+ stats = self.store.stats()
416
+ return {"total_files": len(current_paths), "changed": total, **stats}