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.
- semantic_code_mcp/__init__.py +3 -0
- semantic_code_mcp/chunker.py +399 -0
- semantic_code_mcp/embedder.py +192 -0
- semantic_code_mcp/expander.py +114 -0
- semantic_code_mcp/indexer.py +416 -0
- semantic_code_mcp/retriever.py +705 -0
- semantic_code_mcp/server.py +284 -0
- semantic_code_mcp/store.py +488 -0
- semantic_code_mcp/watcher.py +156 -0
- semantic_code_mcp/workspace.py +218 -0
- semcode_mcp-0.1.0.dist-info/METADATA +272 -0
- semcode_mcp-0.1.0.dist-info/RECORD +15 -0
- semcode_mcp-0.1.0.dist-info/WHEEL +4 -0
- semcode_mcp-0.1.0.dist-info/entry_points.txt +2 -0
- semcode_mcp-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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}
|