@lorrylurui/code-intelligence-mcp 2.0.3 → 2.0.5
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.
- package/README.md +8 -70
- package/dist/index.js +1 -0
- package/dist/indexer/babelParser.js +2 -1
- package/dist/indexer/indexProject.js +193 -22
- package/dist/indexer/jsAstNormalizer.js +36 -6
- package/dist/prompts/reusableCodeAdvisorPrompt.js +63 -34
- package/dist/repositories/symbolRepository.js +107 -13
- package/dist/server/createServer.js +24 -12
- package/dist/services/ranking.js +86 -33
- package/dist/services/recommendationService.js +266 -16
- package/dist/services/reindex.js +25 -0
- package/dist/tools/getSymbolDetail.js +2 -1
- package/dist/tools/recommendComponent.js +86 -10
- package/dist/tools/searchByStructure.js +2 -1
- package/dist/tools/searchSymbols.js +54 -19
- package/dist/workers/embeddingWorker.js +0 -1
- package/package.json +1 -1
|
@@ -5,6 +5,39 @@ function uniqueStrings(values = []) {
|
|
|
5
5
|
function normalizeToken(value) {
|
|
6
6
|
return value.trim().toLowerCase();
|
|
7
7
|
}
|
|
8
|
+
const CATEGORY_TYPE_HINTS = [
|
|
9
|
+
{ keywords: ['util'], types: ['function'] },
|
|
10
|
+
{ keywords: ['hook'], types: ['hook'] },
|
|
11
|
+
{ keywords: ['type'], types: ['type', 'interface'] },
|
|
12
|
+
{ keywords: ['class'], types: ['class'] },
|
|
13
|
+
{ keywords: ['component'], types: ['component'] },
|
|
14
|
+
];
|
|
15
|
+
const QUERY_TYPE_HINTS = [
|
|
16
|
+
{
|
|
17
|
+
keywords: [' util', 'util ', 'helper', '函数', '方法', '计算', '获取'],
|
|
18
|
+
types: ['function'],
|
|
19
|
+
},
|
|
20
|
+
{ keywords: ['hook', ' use'], types: ['hook'] },
|
|
21
|
+
];
|
|
22
|
+
function resolveTypesByHints(source, hints) {
|
|
23
|
+
for (const hint of hints) {
|
|
24
|
+
if (hint.keywords.some((keyword) => source.includes(keyword))) {
|
|
25
|
+
return [...new Set(hint.types)];
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return [];
|
|
29
|
+
}
|
|
30
|
+
function inferSearchTypes(input) {
|
|
31
|
+
const query = input.query.toLowerCase();
|
|
32
|
+
const category = (input.category ?? '').toLowerCase();
|
|
33
|
+
const categoryTypes = resolveTypesByHints(category, CATEGORY_TYPE_HINTS);
|
|
34
|
+
if (categoryTypes.length > 0)
|
|
35
|
+
return categoryTypes;
|
|
36
|
+
const queryTypes = resolveTypesByHints(query, QUERY_TYPE_HINTS);
|
|
37
|
+
if (queryTypes.length > 0)
|
|
38
|
+
return queryTypes;
|
|
39
|
+
return ['component'];
|
|
40
|
+
}
|
|
8
41
|
function getMetaStrings(symbol, key) {
|
|
9
42
|
const value = symbol.meta?.[key];
|
|
10
43
|
if (!Array.isArray(value))
|
|
@@ -51,52 +84,249 @@ function mergeCandidates(symbols) {
|
|
|
51
84
|
}
|
|
52
85
|
return merged;
|
|
53
86
|
}
|
|
87
|
+
const NON_REUSABLE_PATH_SEGMENTS = [
|
|
88
|
+
'__tests__',
|
|
89
|
+
'__mocks__',
|
|
90
|
+
'/test/',
|
|
91
|
+
'/tests/',
|
|
92
|
+
'/fixtures/',
|
|
93
|
+
'/stories/',
|
|
94
|
+
'/story/',
|
|
95
|
+
];
|
|
96
|
+
const DEMO_LIKE_PATH_SEGMENTS_STRICT = [
|
|
97
|
+
'/one-ui-demo/',
|
|
98
|
+
'/example/',
|
|
99
|
+
'/examples/',
|
|
100
|
+
'/demo/',
|
|
101
|
+
'/demos/',
|
|
102
|
+
];
|
|
103
|
+
const DEMO_LIKE_PATH_SEGMENTS_SOFT = [
|
|
104
|
+
'/one-ui-demo/',
|
|
105
|
+
'/example/',
|
|
106
|
+
'/examples/',
|
|
107
|
+
];
|
|
108
|
+
const NON_REUSABLE_PATH_PATTERNS = [
|
|
109
|
+
'.test.',
|
|
110
|
+
'.spec.',
|
|
111
|
+
'.stories.',
|
|
112
|
+
'.story.',
|
|
113
|
+
'.mock.',
|
|
114
|
+
];
|
|
115
|
+
const NON_REUSABLE_NAME_TOKENS = ['mock', 'fixture', 'example', 'demo'];
|
|
116
|
+
const MIN_RECOMMENDATION_SCORE = {
|
|
117
|
+
semantic: 0.5,
|
|
118
|
+
keyword: 0.45,
|
|
119
|
+
};
|
|
120
|
+
const MIN_SEMANTIC_TEXT_SCORE = 0.6;
|
|
121
|
+
const MIN_LITERAL_MATCH_SCORE = 0.18;
|
|
122
|
+
function isDemoLikePath(path, strict = false) {
|
|
123
|
+
const normalizedPath = path.toLowerCase();
|
|
124
|
+
const segments = strict
|
|
125
|
+
? DEMO_LIKE_PATH_SEGMENTS_STRICT
|
|
126
|
+
: DEMO_LIKE_PATH_SEGMENTS_SOFT;
|
|
127
|
+
return segments.some((segment) => normalizedPath.includes(segment));
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* 判断是否为可复用候选,过滤掉明显的测试/示例代码。虽然有可能误伤一些真实组件,但优先保证推荐结果的实用性和专业度。
|
|
131
|
+
* @param symbol 要判断的代码符号
|
|
132
|
+
* @returns boolean 是否为可复用候选
|
|
133
|
+
*/
|
|
134
|
+
export function isReusableCandidate(symbol) {
|
|
135
|
+
const path = symbol.path.toLowerCase();
|
|
136
|
+
const name = symbol.name.toLowerCase();
|
|
137
|
+
if (isDemoLikePath(path, true) ||
|
|
138
|
+
NON_REUSABLE_PATH_SEGMENTS.some((segment) => path.includes(segment)) ||
|
|
139
|
+
NON_REUSABLE_PATH_PATTERNS.some((pattern) => path.includes(pattern))) {
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
return !NON_REUSABLE_NAME_TOKENS.some((token) => name === token || name.startsWith(token));
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* 判断推荐结果的props/hooks等结构字段是否满足查询要求,作为强相关推荐的加分项之一。虽然有可能遗漏一些未正确标注字段的结果,但优先保证推荐结果的相关性和准确性。
|
|
146
|
+
* @param symbol 要判断的代码符号
|
|
147
|
+
* @param requiredProps 必需的属性列表
|
|
148
|
+
* @param requiredHooks 必需的钩子列表
|
|
149
|
+
* @returns boolean 是否为强相关推荐结果
|
|
150
|
+
*/
|
|
151
|
+
function hasAllRequiredFields(symbol, requiredProps, requiredHooks) {
|
|
152
|
+
if (requiredProps.length === 0 && requiredHooks.length === 0) {
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
const props = getMetaStrings(symbol, 'props').map(normalizeToken);
|
|
156
|
+
const hooks = getMetaStrings(symbol, 'hooks').map(normalizeToken);
|
|
157
|
+
return (requiredProps.every((field) => props.includes(normalizeToken(field))) &&
|
|
158
|
+
requiredHooks.every((field) => hooks.includes(normalizeToken(field))));
|
|
159
|
+
}
|
|
160
|
+
function extractLiteralTokens(query) {
|
|
161
|
+
const tokens = new Set();
|
|
162
|
+
const genericTokens = new Set([
|
|
163
|
+
'component',
|
|
164
|
+
'components',
|
|
165
|
+
'hook',
|
|
166
|
+
'hooks',
|
|
167
|
+
'util',
|
|
168
|
+
'utils',
|
|
169
|
+
'function',
|
|
170
|
+
'functions',
|
|
171
|
+
'class',
|
|
172
|
+
'classes',
|
|
173
|
+
'type',
|
|
174
|
+
'types',
|
|
175
|
+
]);
|
|
176
|
+
const normalized = query.trim().toLowerCase();
|
|
177
|
+
for (const match of normalized.matchAll(/[a-z0-9_]+/g)) {
|
|
178
|
+
const token = match[0];
|
|
179
|
+
if (token.length >= 3 && !genericTokens.has(token)) {
|
|
180
|
+
tokens.add(token);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return [...tokens];
|
|
184
|
+
}
|
|
185
|
+
// eg: query='useDebounceInput组件', symbol.name='useDebounceInput' => match; query='防抖组件', symbol.name='useDebounceInput' => match; query='input组件', symbol.name='useDebounceInput' => weak match
|
|
186
|
+
function hasStrongLiteralMatch(query, symbol) {
|
|
187
|
+
const normalizedQuery = query.trim().toLowerCase();
|
|
188
|
+
const name = symbol.name.toLowerCase();
|
|
189
|
+
const path = symbol.path.toLowerCase();
|
|
190
|
+
const basename = path.split('/').pop() ?? '';
|
|
191
|
+
if (normalizedQuery &&
|
|
192
|
+
(name === normalizedQuery || path.includes(normalizedQuery))) {
|
|
193
|
+
return true;
|
|
194
|
+
}
|
|
195
|
+
const tokens = extractLiteralTokens(query);
|
|
196
|
+
return tokens.some((token) => name === token || name.includes(token) || basename.includes(token));
|
|
197
|
+
}
|
|
198
|
+
function isStrongEnoughRecommendation(item, query, queriedBy, requiredProps, requiredHooks) {
|
|
199
|
+
const hasRequiredFieldMatch = hasAllRequiredFields(item.symbol, requiredProps, requiredHooks);
|
|
200
|
+
const hasLiteralMatch = hasStrongLiteralMatch(query, item.symbol);
|
|
201
|
+
if (queriedBy === 'semantic') {
|
|
202
|
+
return ((item.score >= MIN_RECOMMENDATION_SCORE.semantic &&
|
|
203
|
+
(item.reason.textMatch.score >= MIN_SEMANTIC_TEXT_SCORE ||
|
|
204
|
+
hasRequiredFieldMatch)) ||
|
|
205
|
+
(hasLiteralMatch && item.score >= MIN_LITERAL_MATCH_SCORE));
|
|
206
|
+
}
|
|
207
|
+
return (item.score >= MIN_RECOMMENDATION_SCORE.keyword ||
|
|
208
|
+
(hasRequiredFieldMatch && item.score >= 0.4));
|
|
209
|
+
}
|
|
210
|
+
function computeRecommendationPriority(item, query) {
|
|
211
|
+
let score = item.score;
|
|
212
|
+
const notes = [];
|
|
213
|
+
const path = item.symbol.path.toLowerCase();
|
|
214
|
+
if (hasStrongLiteralMatch(query, item.symbol)) {
|
|
215
|
+
score += 0.22;
|
|
216
|
+
notes.push('名称或文件名命中查询');
|
|
217
|
+
}
|
|
218
|
+
if (isDemoLikePath(path)) {
|
|
219
|
+
score -= 0.18;
|
|
220
|
+
notes.push('示例工程路径降权');
|
|
221
|
+
}
|
|
222
|
+
return {
|
|
223
|
+
score: Number(Math.max(0, score).toFixed(3)),
|
|
224
|
+
reason: notes.length > 0
|
|
225
|
+
? `${item.reason.summary} + ${notes.join(' + ')}`
|
|
226
|
+
: item.reason.summary,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
54
229
|
export class RecommendationService {
|
|
55
230
|
repository;
|
|
56
231
|
constructor(repository) {
|
|
57
232
|
this.repository = repository;
|
|
58
233
|
}
|
|
59
234
|
async recommendComponent(input) {
|
|
235
|
+
console.error('[code-intelligence-mcp] recommendComponent.start query=%s category=%s semantic=%s limit=%s requiredProps=%s requiredHooks=%s', input.query, input.category ?? '', String(input.semantic ?? true), String(input.limit ?? 5), JSON.stringify(input.requiredProps ?? []), JSON.stringify(input.requiredHooks ?? []));
|
|
60
236
|
const requiredProps = uniqueStrings(input.requiredProps);
|
|
61
237
|
const requiredHooks = uniqueStrings(input.requiredHooks);
|
|
62
|
-
const structureFields = uniqueStrings([
|
|
238
|
+
const structureFields = uniqueStrings([
|
|
239
|
+
...requiredProps,
|
|
240
|
+
...requiredHooks,
|
|
241
|
+
]);
|
|
242
|
+
const searchTypes = inferSearchTypes(input);
|
|
63
243
|
const preferSemantic = input.semantic ?? true;
|
|
64
244
|
const limit = input.limit ?? 5;
|
|
65
245
|
let queriedBy = preferSemantic
|
|
66
246
|
? 'semantic'
|
|
67
247
|
: 'keyword';
|
|
68
248
|
let searchResults;
|
|
249
|
+
console.error('[code-intelligence-mcp] recommendComponent.searchTypes types=%s', JSON.stringify(searchTypes));
|
|
69
250
|
if (preferSemantic) {
|
|
70
251
|
try {
|
|
71
|
-
|
|
72
|
-
type
|
|
252
|
+
const semanticGroups = await Promise.all(searchTypes.map((type) => this.repository.searchSemanticHits(input.query, {
|
|
253
|
+
type,
|
|
73
254
|
limit: Math.max(limit * 4, 12),
|
|
74
|
-
});
|
|
255
|
+
})));
|
|
256
|
+
searchResults = semanticGroups.flat();
|
|
257
|
+
console.error('[code-intelligence-mcp] recommendComponent.semanticHits count=%s top=%s', String(searchResults.length), JSON.stringify(searchResults.slice(0, 3).map((item) => ({
|
|
258
|
+
id: item.symbol.id,
|
|
259
|
+
name: item.symbol.name,
|
|
260
|
+
path: item.symbol.path,
|
|
261
|
+
similarity: Number(item.similarity.toFixed(4)),
|
|
262
|
+
}))));
|
|
75
263
|
}
|
|
76
264
|
catch {
|
|
77
265
|
queriedBy = 'keyword';
|
|
78
|
-
|
|
266
|
+
const keywordGroups = await Promise.all(searchTypes.map((type) => this.repository.search(input.query, type)));
|
|
267
|
+
searchResults = keywordGroups
|
|
268
|
+
.flat()
|
|
269
|
+
.map((symbol) => ({ symbol, similarity: 0 }));
|
|
270
|
+
console.error('[code-intelligence-mcp] recommendComponent.semanticFailed fallback=keyword count=%s top=%s', String(searchResults.length), JSON.stringify(searchResults.slice(0, 3).map((item) => ({
|
|
271
|
+
id: item.symbol.id,
|
|
272
|
+
name: item.symbol.name,
|
|
273
|
+
path: item.symbol.path,
|
|
274
|
+
}))));
|
|
79
275
|
}
|
|
80
276
|
}
|
|
81
277
|
else {
|
|
82
|
-
|
|
278
|
+
const keywordGroups = await Promise.all(searchTypes.map((type) => this.repository.search(input.query, type)));
|
|
279
|
+
searchResults = keywordGroups
|
|
280
|
+
.flat()
|
|
281
|
+
.map((symbol) => ({ symbol, similarity: 0 }));
|
|
282
|
+
console.error('[code-intelligence-mcp] recommendComponent.keywordOnly count=%s top=%s', String(searchResults.length), JSON.stringify(searchResults.slice(0, 3).map((item) => ({
|
|
283
|
+
id: item.symbol.id,
|
|
284
|
+
name: item.symbol.name,
|
|
285
|
+
path: item.symbol.path,
|
|
286
|
+
}))));
|
|
83
287
|
}
|
|
84
288
|
const structureResults = structureFields.length
|
|
85
|
-
? await this.repository.searchByStructure(structureFields, {
|
|
86
|
-
type
|
|
87
|
-
category: input.category,
|
|
289
|
+
? (await Promise.all(searchTypes.map((type) => this.repository.searchByStructure(structureFields, {
|
|
290
|
+
type,
|
|
88
291
|
limit: Math.max(limit * 4, 12),
|
|
89
|
-
})
|
|
292
|
+
})))).flat()
|
|
90
293
|
: [];
|
|
91
|
-
|
|
294
|
+
console.error('[code-intelligence-mcp] recommendComponent.structureHits fields=%s count=%s top=%s', JSON.stringify(structureFields), String(structureResults.length), JSON.stringify(structureResults.slice(0, 3).map((symbol) => ({
|
|
295
|
+
id: symbol.id,
|
|
296
|
+
name: symbol.name,
|
|
297
|
+
path: symbol.path,
|
|
298
|
+
}))));
|
|
299
|
+
// 合并逻辑:先合并语义搜索(或关键词模糊搜索)和结构搜索结果去重
|
|
300
|
+
const mergedBeforeCategory = mergeCandidates([
|
|
92
301
|
...structureResults,
|
|
93
302
|
...searchResults.map((item) => item.symbol),
|
|
94
|
-
])
|
|
303
|
+
]);
|
|
304
|
+
// 再按 category 过滤(如果有 category 限制)
|
|
305
|
+
let combined = mergedBeforeCategory.filter((symbol) => input.category
|
|
95
306
|
? (symbol.category ?? '')
|
|
96
307
|
.toLowerCase()
|
|
97
308
|
.includes(input.category.toLowerCase())
|
|
98
309
|
: true);
|
|
310
|
+
// LLM 可能把 "input" 之类词误当作 category,导致误筛空;若筛空则回退为不按 category 过滤。
|
|
311
|
+
if (combined.length === 0 &&
|
|
312
|
+
input.category &&
|
|
313
|
+
mergedBeforeCategory.length) {
|
|
314
|
+
console.error('[code-intelligence-mcp] recommendComponent.categoryFallback category=%s merged=%s -> useUnfiltered', input.category, String(mergedBeforeCategory.length));
|
|
315
|
+
combined = mergedBeforeCategory;
|
|
316
|
+
}
|
|
317
|
+
const reusableCandidates = combined.filter(isReusableCandidate);
|
|
318
|
+
if (reusableCandidates.length > 0) {
|
|
319
|
+
console.error('[code-intelligence-mcp] recommendComponent.reusableFilter before=%s after=%s removed=%s', String(combined.length), String(reusableCandidates.length), String(combined.length - reusableCandidates.length));
|
|
320
|
+
combined = reusableCandidates;
|
|
321
|
+
}
|
|
322
|
+
console.error('[code-intelligence-mcp] recommendComponent.combine merged=%s afterCategory=%s top=%s', String(mergedBeforeCategory.length), String(combined.length), JSON.stringify(combined.slice(0, 3).map((symbol) => ({
|
|
323
|
+
id: symbol.id,
|
|
324
|
+
name: symbol.name,
|
|
325
|
+
path: symbol.path,
|
|
326
|
+
category: symbol.category,
|
|
327
|
+
}))));
|
|
99
328
|
if (combined.length === 0) {
|
|
329
|
+
console.error('[code-intelligence-mcp] recommendComponent.emptyResult query=%s queriedBy=%s requiredProps=%s requiredHooks=%s', input.query, queriedBy, JSON.stringify(requiredProps), JSON.stringify(requiredHooks));
|
|
100
330
|
return {
|
|
101
331
|
recommended: null,
|
|
102
332
|
alternatives: [],
|
|
@@ -108,14 +338,34 @@ export class RecommendationService {
|
|
|
108
338
|
message: '未找到符合条件的可复用组件。',
|
|
109
339
|
};
|
|
110
340
|
}
|
|
341
|
+
// 最后排序并切分首选/备选
|
|
111
342
|
const ranked = queriedBy === 'semantic'
|
|
112
343
|
? rankSemanticHits(combined.map((symbol) => ({
|
|
113
344
|
symbol,
|
|
114
|
-
similarity: searchResults.find((item) => item.symbol.id === symbol.id)
|
|
115
|
-
|
|
116
|
-
})))
|
|
345
|
+
similarity: searchResults.find((item) => item.symbol.id === symbol.id)?.similarity ?? 0.55,
|
|
346
|
+
})), input.query)
|
|
117
347
|
: rankSymbols(input.query, combined);
|
|
118
|
-
const
|
|
348
|
+
const qualifiedRanked = ranked.filter((item) => isStrongEnoughRecommendation(item, input.query, queriedBy, requiredProps, requiredHooks));
|
|
349
|
+
console.error('[code-intelligence-mcp] recommendComponent.qualityGate before=%s after=%s queriedBy=%s', String(ranked.length), String(qualifiedRanked.length), queriedBy);
|
|
350
|
+
const prioritizedRanked = qualifiedRanked
|
|
351
|
+
.map((item) => {
|
|
352
|
+
const adjusted = computeRecommendationPriority(item, input.query);
|
|
353
|
+
return {
|
|
354
|
+
item,
|
|
355
|
+
adjustedScore: adjusted.score,
|
|
356
|
+
adjustedReason: adjusted.reason,
|
|
357
|
+
};
|
|
358
|
+
})
|
|
359
|
+
.sort((a, b) => b.adjustedScore - a.adjustedScore);
|
|
360
|
+
const candidates = prioritizedRanked.map((entry) => toCandidate(entry.item.symbol, entry.adjustedScore, entry.adjustedReason, requiredProps, requiredHooks));
|
|
361
|
+
console.error('[code-intelligence-mcp] recommendComponent.ranked count=%s top=%s', String(candidates.length), JSON.stringify(candidates.slice(0, 3).map((candidate) => ({
|
|
362
|
+
id: candidate.id,
|
|
363
|
+
name: candidate.name,
|
|
364
|
+
path: candidate.path,
|
|
365
|
+
score: candidate.score,
|
|
366
|
+
matchedProps: candidate.matchedProps,
|
|
367
|
+
matchedHooks: candidate.matchedHooks,
|
|
368
|
+
}))));
|
|
119
369
|
return {
|
|
120
370
|
recommended: candidates[0] ?? null,
|
|
121
371
|
alternatives: candidates.slice(1, limit),
|
package/dist/services/reindex.js
CHANGED
|
@@ -10,6 +10,26 @@ import { computeFileHash } from '../indexer/tsAstNormalizer.js';
|
|
|
10
10
|
import { getRelativePathForDisplay } from '../indexer/heuristics.js';
|
|
11
11
|
import { enqueueEmbeddingBatch, closeEmbeddingQueue, } from '../services/embeddingQueue.js';
|
|
12
12
|
import { SYMBOL_STATUS } from '../config/symbolStatus.js';
|
|
13
|
+
function isCallerDebugEnabled() {
|
|
14
|
+
return /^(1|true|yes|on)$/i.test(process.env.DEBUG_CALLERS ?? '');
|
|
15
|
+
}
|
|
16
|
+
function getCallerDebugMatch() {
|
|
17
|
+
return (process.env.DEBUG_CALLERS_MATCH ?? '').trim().toLowerCase();
|
|
18
|
+
}
|
|
19
|
+
function debugMatchedFiles(stage, files, projectRoot) {
|
|
20
|
+
if (!isCallerDebugEnabled())
|
|
21
|
+
return;
|
|
22
|
+
const match = getCallerDebugMatch();
|
|
23
|
+
const normalized = files.map((file) => getRelativePathForDisplay(projectRoot, file));
|
|
24
|
+
const matched = match
|
|
25
|
+
? normalized.filter((file) => file.toLowerCase().includes(match))
|
|
26
|
+
: normalized;
|
|
27
|
+
console.error(`[callers.debug] ${stage} ${JSON.stringify({
|
|
28
|
+
match: match || null,
|
|
29
|
+
count: matched.length,
|
|
30
|
+
files: matched,
|
|
31
|
+
})}`);
|
|
32
|
+
}
|
|
13
33
|
export async function runReindex(options = {}) {
|
|
14
34
|
const projectRoot = resolve(options.projectRoot ?? process.cwd());
|
|
15
35
|
const { dryRun = false, forceRebuild = false } = options;
|
|
@@ -35,6 +55,7 @@ export async function runReindex(options = {}) {
|
|
|
35
55
|
dot: false,
|
|
36
56
|
});
|
|
37
57
|
console.error(`[reindex] glob found ${allFiles.length} file(s)`);
|
|
58
|
+
debugMatchedFiles('reindex-all-files', allFiles, projectRoot);
|
|
38
59
|
// ─── 2. file_hash 过滤:跳过 AST 未变的文件(CPU 优化)────────────────
|
|
39
60
|
// forceRebuild 时跳过此过滤,file_hash 不可复用(模板/模型变更时相同文件产出不同 content)
|
|
40
61
|
let filesToIndex = allFiles;
|
|
@@ -58,9 +79,13 @@ export async function runReindex(options = {}) {
|
|
|
58
79
|
});
|
|
59
80
|
skippedFiles = allFiles.length - filesToIndex.length;
|
|
60
81
|
console.error(`[reindex] file_hash: ${skippedFiles} unchanged (skipped), ${filesToIndex.length} changed (to parse)`);
|
|
82
|
+
const skippedAbsFiles = allFiles.filter((absPath) => !filesToIndex.includes(absPath));
|
|
83
|
+
debugMatchedFiles('reindex-files-to-parse', filesToIndex, projectRoot);
|
|
84
|
+
debugMatchedFiles('reindex-files-skipped', skippedAbsFiles, projectRoot);
|
|
61
85
|
}
|
|
62
86
|
else if (forceRebuild) {
|
|
63
87
|
console.error(`[reindex] forceRebuild=true, skipping file_hash filter — parsing all ${allFiles.length} file(s)`);
|
|
88
|
+
debugMatchedFiles('reindex-files-to-parse', filesToIndex, projectRoot);
|
|
64
89
|
}
|
|
65
90
|
if (filesToIndex.length === 0) {
|
|
66
91
|
console.error('[reindex] all files unchanged, nothing to do');
|
|
@@ -7,7 +7,8 @@ export function createGetSymbolDetailTool(repository) {
|
|
|
7
7
|
name: 'get_symbol_detail',
|
|
8
8
|
description: '获取单个代码块的完整详情(含源码、参数类型、调用关系、副作用)。\n' +
|
|
9
9
|
'仅在以下情况调用:search_symbols 返回的摘要信息不足以判断是否适用(如签名模糊、副作用不明确)。\n' +
|
|
10
|
-
'通常对 top 1-3
|
|
10
|
+
'通常对 top 1-3 候选调用,不要对所有结果批量调用。\n' +
|
|
11
|
+
'约束:若 recommend_component 已返回结果,默认不要调用本工具;仅在用户明确要求“看源码细节/二次验证”时调用。',
|
|
11
12
|
inputSchema: getSymbolDetailInput.shape,
|
|
12
13
|
handler: async (input) => {
|
|
13
14
|
const symbol = await repository.getByName(input.name);
|
|
@@ -1,4 +1,66 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
+
function formatCallers(callers) {
|
|
3
|
+
if (!callers.length)
|
|
4
|
+
return '新增';
|
|
5
|
+
return callers.map((caller) => `${caller.name}(${caller.path})`).join('; ');
|
|
6
|
+
}
|
|
7
|
+
function formatSideEffects(sideEffects) {
|
|
8
|
+
return sideEffects.length ? sideEffects.join('/') : '无';
|
|
9
|
+
}
|
|
10
|
+
function formatHasResult(result) {
|
|
11
|
+
const recommended = result.recommended;
|
|
12
|
+
if (!recommended)
|
|
13
|
+
return '';
|
|
14
|
+
const reasons = [];
|
|
15
|
+
if (recommended.matchedProps.length) {
|
|
16
|
+
reasons.push(`匹配到必需 props:${recommended.matchedProps.join(', ')}`);
|
|
17
|
+
}
|
|
18
|
+
if (recommended.matchedHooks.length) {
|
|
19
|
+
reasons.push(`匹配到必需 hooks:${recommended.matchedHooks.join(', ')}`);
|
|
20
|
+
}
|
|
21
|
+
if (reasons.length < 2) {
|
|
22
|
+
reasons.push('综合语义、结构字段和可复用性排序后为首选。');
|
|
23
|
+
}
|
|
24
|
+
const alternatives = result.alternatives.length
|
|
25
|
+
? result.alternatives
|
|
26
|
+
.map((item) => `${item.name}(${formatSideEffects(item.sideEffects)})`)
|
|
27
|
+
.join('; ')
|
|
28
|
+
: '无';
|
|
29
|
+
return `首选:${recommended.name} — ${recommended.path}
|
|
30
|
+
symbolId:${recommended.id}
|
|
31
|
+
使用范围:${formatCallers(recommended.callers)}
|
|
32
|
+
副作用:${formatSideEffects(recommended.sideEffects)}
|
|
33
|
+
理由:
|
|
34
|
+
1. ${reasons[0] ?? '匹配度最高,适合直接复用。'}
|
|
35
|
+
2. ${reasons[1] ?? 'API 形态与需求一致,接入成本低。'}
|
|
36
|
+
其他候选:${alternatives}
|
|
37
|
+
用法提示:
|
|
38
|
+
\`\`\`tsx
|
|
39
|
+
<${recommended.name} value={value} onChange={handleChange} />
|
|
40
|
+
\`\`\`
|
|
41
|
+
是否采纳(**请在聊天框输入 1 或 2**):
|
|
42
|
+
1️⃣ 采纳推荐 — 自动调用 inc_usage 记录使用
|
|
43
|
+
2️⃣ 取消`;
|
|
44
|
+
}
|
|
45
|
+
function formatNoResult() {
|
|
46
|
+
return `首选:未找到已有实现
|
|
47
|
+
使用范围:无
|
|
48
|
+
副作用:无
|
|
49
|
+
理由:
|
|
50
|
+
1. 当前索引中没有满足条件的组件(例如必须包含 onChange)
|
|
51
|
+
2. 已尝试可用检索方式,仍无可用候选
|
|
52
|
+
其他候选:无
|
|
53
|
+
用法提示:
|
|
54
|
+
\`\`\`tsx
|
|
55
|
+
// 可先创建一个受控 Input 组件,至少暴露 value + onChange
|
|
56
|
+
\`\`\`
|
|
57
|
+
是否采纳(**请在聊天框输入 1 或 2**):
|
|
58
|
+
1️⃣ 新建最小可复用组件
|
|
59
|
+
2️⃣ 取消`;
|
|
60
|
+
}
|
|
61
|
+
function formatReply(result) {
|
|
62
|
+
return result.recommended ? formatHasResult(result) : formatNoResult();
|
|
63
|
+
}
|
|
2
64
|
export const recommendComponentInput = z.object({
|
|
3
65
|
query: z.string().min(1),
|
|
4
66
|
requiredProps: z.array(z.string().min(1)).optional(),
|
|
@@ -10,18 +72,32 @@ export const recommendComponentInput = z.object({
|
|
|
10
72
|
export function createRecommendComponentTool(recommendationService) {
|
|
11
73
|
return {
|
|
12
74
|
name: 'recommend_component',
|
|
13
|
-
description: '
|
|
75
|
+
description: '【降级链第一步,唯一首选工具】当用户询问有没有可复用的组件/函数/util,或需要找仓库中现有实现时,必须先调用本工具。\n' +
|
|
76
|
+
'本工具会自动完成候选搜索、结构过滤和首选推荐,无需再调其他搜索工具。\n' +
|
|
77
|
+
'⚠️ 输出约束(必须严格遵守):\n' +
|
|
78
|
+
'- recommended != null:立即将工具返回的文本原样输出给用户,完全停止,不得调用任何其他工具,不得改写为散文或追加说明。\n' +
|
|
79
|
+
'- recommended = null:进入降级链第二步,调用 search_symbols(semantic=true)。\n' +
|
|
80
|
+
'- 任何情况下禁止调用 grep/read file/file search 作为本工具的后续动作。',
|
|
14
81
|
inputSchema: recommendComponentInput.shape,
|
|
15
82
|
handler: async (input) => {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
83
|
+
console.error('[code-intelligence-mcp] tool.recommend_component.called query=%s requiredProps=%s requiredHooks=%s category=%s semantic=%s limit=%s', input.query, JSON.stringify(input.requiredProps ?? []), JSON.stringify(input.requiredHooks ?? []), input.category ?? '', String(input.semantic ?? true), String(input.limit ?? 5));
|
|
84
|
+
try {
|
|
85
|
+
const result = await recommendationService.recommendComponent(input);
|
|
86
|
+
console.error('[code-intelligence-mcp] tool.recommend_component.done recommended=%s alternatives=%s queriedBy=%s', result.recommended ? 'yes' : 'no', String(result.alternatives.length), result.queriedBy);
|
|
87
|
+
const formattedReply = formatReply(result);
|
|
88
|
+
return {
|
|
89
|
+
content: [
|
|
90
|
+
{
|
|
91
|
+
type: 'text',
|
|
92
|
+
text: formattedReply,
|
|
93
|
+
},
|
|
94
|
+
],
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
console.error('[code-intelligence-mcp] tool.recommend_component.error', error);
|
|
99
|
+
throw error;
|
|
100
|
+
}
|
|
25
101
|
},
|
|
26
102
|
};
|
|
27
103
|
}
|
|
@@ -15,7 +15,8 @@ export function createSearchByStructureTool(repository) {
|
|
|
15
15
|
name: 'search_by_structure',
|
|
16
16
|
description: '按代码块的结构字段(props/params/hooks)检索,适合已知接口形态时使用。\n' +
|
|
17
17
|
'示例:需要一个接受 value、onChange、error 三个 prop 的输入组件 → fields: ["value", "onChange", "error"], type: "component"\n' +
|
|
18
|
-
'与 search_symbols 配合:先语义检索候选,再用本工具做 API
|
|
18
|
+
'与 search_symbols 配合:先语义检索候选,再用本工具做 API 结构过滤以精确匹配。\n' +
|
|
19
|
+
'约束:当用户是在问“有没有可复用组件/帮我找组件”时,默认不要调用本工具;仅在用户明确要求二次验证时使用。',
|
|
19
20
|
inputSchema: searchByStructureInput.shape,
|
|
20
21
|
handler: async (input) => {
|
|
21
22
|
const rows = await repository.searchByStructure(input.fields, {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { rankSemanticHits, rankSymbols } from '../services/ranking.js';
|
|
3
|
+
import { isReusableCandidate } from '../services/recommendationService.js';
|
|
3
4
|
export const searchSymbolsInput = z.object({
|
|
4
5
|
query: z.string().min(1),
|
|
5
6
|
type: z
|
|
@@ -12,14 +13,33 @@ export const searchSymbolsInput = z.object({
|
|
|
12
13
|
});
|
|
13
14
|
const SCORE_THRESHOLD_FOR_FINAL = 0.45; // 综合排序分阈值(语义相似度占50%权重,原始0.5相似度 ≈ 综合0.35起)
|
|
14
15
|
const TOP_K_FOR_FINAL_RESULTS = 20; // 结果上限,返回相似度高的,保证数据质量
|
|
16
|
+
function toRankedResult(item) {
|
|
17
|
+
return {
|
|
18
|
+
id: item.symbol.id,
|
|
19
|
+
name: item.symbol.name,
|
|
20
|
+
type: item.symbol.type,
|
|
21
|
+
path: item.symbol.path,
|
|
22
|
+
description: item.symbol.description,
|
|
23
|
+
usageCount: item.symbol.usageCount,
|
|
24
|
+
score: item.score,
|
|
25
|
+
reason: item.reason.summary,
|
|
26
|
+
reasonDetail: item.reason,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
function filterReusableSymbols(items) {
|
|
30
|
+
return items.filter((item) => isReusableCandidate(item.symbol));
|
|
31
|
+
}
|
|
15
32
|
export function createSearchSymbolsTool(repository) {
|
|
16
33
|
return {
|
|
17
34
|
name: 'search_symbols',
|
|
18
|
-
description: '
|
|
35
|
+
description: '【降级链第二步,非首选工具】搜索项目中已有的可复用代码块(函数、组件、Hook、类型等)。\n' +
|
|
36
|
+
'⚠️ 调用顺序约束:对于"帮我找 X 组件/util/函数"类问题,第一步必须是 recommend_component,不得跳过直接调用本工具。\n' +
|
|
37
|
+
'本工具仅在 recommend_component 返回 null(无推荐)后才允许调用,作为第二级降级检索。\n' +
|
|
19
38
|
'- 有明确名称时(如 "useDebounce"):semantic=false(默认),直接关键词检索\n' +
|
|
20
|
-
'- 描述功能意图时(如 "防抖"、"处理表单提交"):semantic=true
|
|
21
|
-
'- 不确定 type
|
|
22
|
-
'-
|
|
39
|
+
'- 描述功能意图时(如 "防抖"、"处理表单提交"):semantic=true,进行语义检索\n' +
|
|
40
|
+
'- 不确定 type 时省略该参数\n' +
|
|
41
|
+
'- 本工具返回结果后,必须立即按固定模板输出,停止所有后续工具调用。\n' +
|
|
42
|
+
'- 本工具返回空结果后,输出无结果模板,完全停止,禁止 grep/read file/文件系统兜底。',
|
|
23
43
|
inputSchema: searchSymbolsInput.shape,
|
|
24
44
|
handler: async (input) => {
|
|
25
45
|
if (input.semantic) {
|
|
@@ -27,9 +47,34 @@ export function createSearchSymbolsTool(repository) {
|
|
|
27
47
|
type: input.type,
|
|
28
48
|
limit: input.limit,
|
|
29
49
|
});
|
|
30
|
-
const
|
|
50
|
+
const reusableHits = filterReusableSymbols(hits);
|
|
51
|
+
if (reusableHits.length === 0) {
|
|
52
|
+
const keywordRows = (await repository.search(input.query, input.type)).filter((row) => isReusableCandidate(row));
|
|
53
|
+
const fallbackRows = input.ranked
|
|
54
|
+
? rankSymbols(input.query, keywordRows)
|
|
55
|
+
.map(toRankedResult)
|
|
56
|
+
.filter((x) => x.score >= SCORE_THRESHOLD_FOR_FINAL)
|
|
57
|
+
.slice(0, TOP_K_FOR_FINAL_RESULTS)
|
|
58
|
+
: keywordRows.map((r) => ({
|
|
59
|
+
id: r.id,
|
|
60
|
+
name: r.name,
|
|
61
|
+
type: r.type,
|
|
62
|
+
path: r.path,
|
|
63
|
+
description: r.description,
|
|
64
|
+
usageCount: r.usageCount,
|
|
65
|
+
}));
|
|
66
|
+
return {
|
|
67
|
+
content: [
|
|
68
|
+
{
|
|
69
|
+
type: 'text',
|
|
70
|
+
text: JSON.stringify(fallbackRows, null, 2),
|
|
71
|
+
},
|
|
72
|
+
],
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
const simById = new Map(reusableHits.map((h) => [h.symbol.id, h.similarity]));
|
|
31
76
|
const resultRows = input.ranked
|
|
32
|
-
? rankSemanticHits(
|
|
77
|
+
? rankSemanticHits(reusableHits, input.query)
|
|
33
78
|
.map((item) => ({
|
|
34
79
|
id: item.symbol.id,
|
|
35
80
|
name: item.symbol.name,
|
|
@@ -44,7 +89,7 @@ export function createSearchSymbolsTool(repository) {
|
|
|
44
89
|
}))
|
|
45
90
|
.filter((x) => x.score >= SCORE_THRESHOLD_FOR_FINAL) // 基于综合排序分过滤,保留 usage/recency 高的结果
|
|
46
91
|
.slice(0, TOP_K_FOR_FINAL_RESULTS)
|
|
47
|
-
:
|
|
92
|
+
: reusableHits.map((h) => ({
|
|
48
93
|
id: h.symbol.id,
|
|
49
94
|
name: h.symbol.name,
|
|
50
95
|
type: h.symbol.type,
|
|
@@ -62,20 +107,10 @@ export function createSearchSymbolsTool(repository) {
|
|
|
62
107
|
],
|
|
63
108
|
};
|
|
64
109
|
}
|
|
65
|
-
const rows = await repository.search(input.query, input.type);
|
|
110
|
+
const rows = (await repository.search(input.query, input.type)).filter((row) => isReusableCandidate(row));
|
|
66
111
|
const resultRows = input.ranked
|
|
67
112
|
? rankSymbols(input.query, rows)
|
|
68
|
-
.map(
|
|
69
|
-
id: item.symbol.id,
|
|
70
|
-
name: item.symbol.name,
|
|
71
|
-
type: item.symbol.type,
|
|
72
|
-
path: item.symbol.path,
|
|
73
|
-
description: item.symbol.description,
|
|
74
|
-
usageCount: item.symbol.usageCount,
|
|
75
|
-
score: item.score,
|
|
76
|
-
reason: item.reason.summary,
|
|
77
|
-
reasonDetail: item.reason,
|
|
78
|
-
}))
|
|
113
|
+
.map(toRankedResult)
|
|
79
114
|
.filter((x) => x.score >= SCORE_THRESHOLD_FOR_FINAL) // 基于综合排序分过滤
|
|
80
115
|
.slice(0, TOP_K_FOR_FINAL_RESULTS)
|
|
81
116
|
: rows.map((r) => ({
|
|
@@ -67,7 +67,6 @@ async function processEmbedJob(job, pool) {
|
|
|
67
67
|
const meta = typeof row.meta === 'string' ? JSON.parse(row.meta) : (row.meta ?? {});
|
|
68
68
|
const rowObj = { ...row, meta };
|
|
69
69
|
console.error(`[worker] 🔄 embedding [${ts()}] table=${table} hash=${shortHash}… ${row.path}:${row.name}`);
|
|
70
|
-
// 与 reindex 保持一致:优先用 content(语义模板),降级用 indexedRowToEmbedText
|
|
71
70
|
const doc = row.content ?? indexedRowToEmbedText(rowObj);
|
|
72
71
|
const vectors = await embedClient.embed([doc]);
|
|
73
72
|
vector = vectors[0];
|