autosnippet 2.18.0 → 2.19.0
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/dashboard/dist/assets/{icons-C6kshpB1.js → icons-C7FN32VL.js} +1 -1
- package/dashboard/dist/assets/index-D8dCXLzr.js +129 -0
- package/dashboard/dist/index.html +2 -2
- package/lib/external/ai/AiProvider.js +42 -11
- package/lib/external/ai/providers/ClaudeProvider.js +4 -2
- package/lib/external/ai/providers/GoogleGeminiProvider.js +66 -8
- package/lib/external/ai/providers/OpenAiProvider.js +48 -2
- package/lib/external/mcp/handlers/bootstrap.js +1 -2
- package/lib/http/HttpServer.js +4 -0
- package/lib/http/routes/candidates.js +405 -0
- package/lib/http/routes/search.js +113 -0
- package/lib/infrastructure/vector/Chunker.js +3 -8
- package/lib/infrastructure/vector/JsonVectorAdapter.js +2 -9
- package/lib/service/candidate/SimilarityService.js +7 -35
- package/lib/service/chat/ChatAgent.js +28 -686
- package/lib/service/chat/ContextWindow.js +87 -3
- package/lib/service/chat/ConversationStore.js +3 -4
- package/lib/service/chat/ProjectSemanticMemory.js +9 -14
- package/lib/service/chat/ReasoningLayer.js +10 -54
- package/lib/service/chat/ToolRegistry.js +0 -52
- package/lib/service/chat/tools.js +7 -6
- package/lib/service/cursor/TokenBudget.js +4 -21
- package/lib/service/search/CrossEncoderReranker.js +163 -0
- package/lib/service/search/RetrievalFunnel.js +9 -36
- package/lib/service/skills/SignalCollector.js +28 -28
- package/lib/shared/similarity.js +101 -0
- package/lib/shared/token-utils.js +46 -0
- package/package.json +1 -1
- package/dashboard/dist/assets/index-9byoG7kd.js +0 -129
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Candidates API 路由
|
|
3
|
+
* 候选条目的 AI 补齐、润色预览/应用
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import express from 'express';
|
|
7
|
+
import { asyncHandler } from '../middleware/errorHandler.js';
|
|
8
|
+
import { getServiceContainer } from '../../injection/ServiceContainer.js';
|
|
9
|
+
import { ValidationError } from '../../shared/errors/index.js';
|
|
10
|
+
import Logger from '../../infrastructure/logging/Logger.js';
|
|
11
|
+
|
|
12
|
+
const router = express.Router();
|
|
13
|
+
const logger = Logger.getInstance();
|
|
14
|
+
|
|
15
|
+
/* ═══ AI 语义字段补齐 ════════════════════════════════════ */
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* POST /api/v1/candidates/enrich
|
|
19
|
+
* 对若干候选条目进行 AI 语义字段补全
|
|
20
|
+
* Body: { candidateIds: string[] }
|
|
21
|
+
*/
|
|
22
|
+
router.post('/enrich', asyncHandler(async (req, res) => {
|
|
23
|
+
const { candidateIds } = req.body;
|
|
24
|
+
if (!Array.isArray(candidateIds) || candidateIds.length === 0) {
|
|
25
|
+
throw new ValidationError('candidateIds array is required and must not be empty');
|
|
26
|
+
}
|
|
27
|
+
if (candidateIds.length > 20) {
|
|
28
|
+
throw new ValidationError('Max 20 candidates per enrichment call');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const container = getServiceContainer();
|
|
32
|
+
const knowledgeService = container.get('knowledgeService');
|
|
33
|
+
const aiProvider = container.get('aiProvider');
|
|
34
|
+
|
|
35
|
+
// 收集候选条目
|
|
36
|
+
const candidates = [];
|
|
37
|
+
for (const id of candidateIds) {
|
|
38
|
+
try {
|
|
39
|
+
const entry = await knowledgeService.get(id);
|
|
40
|
+
if (entry) {
|
|
41
|
+
const json = typeof entry.toJSON === 'function' ? entry.toJSON() : entry;
|
|
42
|
+
candidates.push({
|
|
43
|
+
id: json.id,
|
|
44
|
+
title: json.title,
|
|
45
|
+
language: json.language,
|
|
46
|
+
category: json.category,
|
|
47
|
+
description: json.description,
|
|
48
|
+
code: json.content?.pattern || '',
|
|
49
|
+
rationale: json.content?.rationale,
|
|
50
|
+
knowledgeType: json.knowledgeType,
|
|
51
|
+
complexity: json.complexity,
|
|
52
|
+
scope: json.scope,
|
|
53
|
+
steps: json.content?.steps,
|
|
54
|
+
constraints: json.constraints,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
} catch (err) {
|
|
58
|
+
logger.warn(`enrich: failed to load candidate ${id}`, { error: err.message });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (candidates.length === 0) {
|
|
63
|
+
return res.json({ success: true, data: { enriched: 0, total: 0, results: [] } });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let enrichedCount = 0;
|
|
67
|
+
const results = [];
|
|
68
|
+
|
|
69
|
+
if (aiProvider) {
|
|
70
|
+
try {
|
|
71
|
+
const enriched = await aiProvider.enrichCandidates(candidates);
|
|
72
|
+
for (const item of enriched) {
|
|
73
|
+
const idx = item.index ?? 0;
|
|
74
|
+
const cand = candidates[idx];
|
|
75
|
+
if (!cand) continue;
|
|
76
|
+
|
|
77
|
+
const updateData = {};
|
|
78
|
+
let changed = false;
|
|
79
|
+
|
|
80
|
+
if (item.rationale && !cand.rationale) {
|
|
81
|
+
updateData['content.rationale'] = item.rationale;
|
|
82
|
+
// 需要合并到 content 对象
|
|
83
|
+
const entry = await knowledgeService.get(cand.id);
|
|
84
|
+
const json = typeof entry.toJSON === 'function' ? entry.toJSON() : entry;
|
|
85
|
+
updateData.content = { ...(json.content || {}), rationale: item.rationale };
|
|
86
|
+
changed = true;
|
|
87
|
+
}
|
|
88
|
+
if (item.knowledgeType && !cand.knowledgeType) {
|
|
89
|
+
updateData.knowledgeType = item.knowledgeType;
|
|
90
|
+
changed = true;
|
|
91
|
+
}
|
|
92
|
+
if (item.complexity && !cand.complexity) {
|
|
93
|
+
updateData.complexity = item.complexity;
|
|
94
|
+
changed = true;
|
|
95
|
+
}
|
|
96
|
+
if (item.scope && !cand.scope) {
|
|
97
|
+
updateData.scope = item.scope;
|
|
98
|
+
changed = true;
|
|
99
|
+
}
|
|
100
|
+
if (item.steps && (!cand.steps || cand.steps.length === 0)) {
|
|
101
|
+
const entry = await knowledgeService.get(cand.id);
|
|
102
|
+
const json = typeof entry.toJSON === 'function' ? entry.toJSON() : entry;
|
|
103
|
+
updateData.content = { ...(json.content || {}), ...(updateData.content || {}), steps: item.steps };
|
|
104
|
+
changed = true;
|
|
105
|
+
}
|
|
106
|
+
if (item.constraints && !cand.constraints?.preconditions?.length) {
|
|
107
|
+
updateData.constraints = item.constraints;
|
|
108
|
+
changed = true;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (changed) {
|
|
112
|
+
await knowledgeService.update(cand.id, updateData, { userId: 'dashboard-enrich' });
|
|
113
|
+
enrichedCount++;
|
|
114
|
+
}
|
|
115
|
+
results.push({ id: cand.id, enriched: changed, filledFields: Object.keys(item).filter(k => k !== 'index') });
|
|
116
|
+
}
|
|
117
|
+
} catch (err) {
|
|
118
|
+
logger.warn('AI enrichCandidates failed', { error: err.message });
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
res.json({
|
|
123
|
+
success: true,
|
|
124
|
+
data: { enriched: enrichedCount, total: candidates.length, results },
|
|
125
|
+
});
|
|
126
|
+
}));
|
|
127
|
+
|
|
128
|
+
/* ═══ Bootstrap 内容润色 ═════════════════════════════════ */
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* POST /api/v1/candidates/bootstrap-refine
|
|
132
|
+
* AI 内容润色(适用于 Bootstrap 产出的批量候选)
|
|
133
|
+
* Body: { candidateIds?: string[], userPrompt?: string, dryRun?: boolean }
|
|
134
|
+
*/
|
|
135
|
+
router.post('/bootstrap-refine', asyncHandler(async (req, res) => {
|
|
136
|
+
const { candidateIds, userPrompt, dryRun } = req.body;
|
|
137
|
+
|
|
138
|
+
const container = getServiceContainer();
|
|
139
|
+
|
|
140
|
+
// 复用 MCP handler 的 bootstrapRefine 逻辑
|
|
141
|
+
const { bootstrapRefine } = await import('../../external/mcp/handlers/bootstrap.js');
|
|
142
|
+
const ctx = { container, logger };
|
|
143
|
+
const result = await bootstrapRefine(ctx, { candidateIds, userPrompt, dryRun });
|
|
144
|
+
|
|
145
|
+
const data = result?.data || result?.content?.[0]?.text
|
|
146
|
+
? JSON.parse(result.content[0].text)?.data
|
|
147
|
+
: { refined: 0, total: 0, errors: [], results: [] };
|
|
148
|
+
|
|
149
|
+
res.json({ success: true, data });
|
|
150
|
+
}));
|
|
151
|
+
|
|
152
|
+
/* ═══ 对话式润色 — 工具函数 ═══════════════════════════════ */
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* 从 KnowledgeEntry 提取前端 DiffView 所需的 before 字段
|
|
156
|
+
* 与前端 extractBefore() 保持一致
|
|
157
|
+
*/
|
|
158
|
+
function extractBeforeFields(json) {
|
|
159
|
+
return {
|
|
160
|
+
title: json.title || '',
|
|
161
|
+
description: json.description || '',
|
|
162
|
+
pattern: json.content?.pattern || '',
|
|
163
|
+
tags: json.tags || [],
|
|
164
|
+
confidence: json.reasoning?.confidence ?? 0.6,
|
|
165
|
+
relations: json.relations || {},
|
|
166
|
+
aiInsight: json.aiInsight || null,
|
|
167
|
+
agentNotes: json.agentNotes || null,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* 构造直接润色提示词 —— 以用户 prompt 为主指令
|
|
173
|
+
* @param {object} before - extractBeforeFields 的输出
|
|
174
|
+
* @param {string} userPrompt - 用户输入的润色指令
|
|
175
|
+
* @returns {string}
|
|
176
|
+
*/
|
|
177
|
+
function buildRefinePrompt(before, userPrompt) {
|
|
178
|
+
return `你是一位知识库条目润色助手。你必须**严格按照用户指令**修改知识条目。
|
|
179
|
+
|
|
180
|
+
## 可修改字段(字段名 → UI 名称)
|
|
181
|
+
|
|
182
|
+
| JSON key | UI 标签 | 说明 |
|
|
183
|
+
|---------------|-----------|--------------------------|
|
|
184
|
+
| description | 摘要 | 条目的简要概述 |
|
|
185
|
+
| pattern | 内容文档 / 设计原理 | 条目的详细内容、代码模式、设计原理 |
|
|
186
|
+
| tags | 标签 | 分类标签数组 |
|
|
187
|
+
| confidence | 置信度 | 0.0-1.0 的评分 |
|
|
188
|
+
| aiInsight | AI 洞察 | AI 生成的架构洞察 |
|
|
189
|
+
| agentNotes | Agent 笔记 | Agent 生成的笔记 |
|
|
190
|
+
| relations | 关联关系 | 与其他条目的关系 |
|
|
191
|
+
|
|
192
|
+
## 当前条目信息
|
|
193
|
+
|
|
194
|
+
标题: ${before.title}
|
|
195
|
+
|
|
196
|
+
【摘要 / description】
|
|
197
|
+
${before.description || '(空)'}
|
|
198
|
+
|
|
199
|
+
【内容文档 / pattern】
|
|
200
|
+
${(before.pattern || '(空)').substring(0, 3000)}
|
|
201
|
+
|
|
202
|
+
【标签 / tags】
|
|
203
|
+
${JSON.stringify(before.tags)}
|
|
204
|
+
|
|
205
|
+
【置信度 / confidence】
|
|
206
|
+
${before.confidence}
|
|
207
|
+
|
|
208
|
+
【关联关系 / relations】
|
|
209
|
+
${JSON.stringify(before.relations)}
|
|
210
|
+
|
|
211
|
+
【AI 洞察 / aiInsight】
|
|
212
|
+
${before.aiInsight || '(空)'}
|
|
213
|
+
|
|
214
|
+
【Agent 笔记 / agentNotes】
|
|
215
|
+
${JSON.stringify(before.agentNotes || [])}
|
|
216
|
+
|
|
217
|
+
## 用户指令
|
|
218
|
+
|
|
219
|
+
${userPrompt}
|
|
220
|
+
|
|
221
|
+
## 严格约束
|
|
222
|
+
|
|
223
|
+
1. **只修改用户指令明确提到的字段**。用户说"设计原理"或"内容"指 pattern 字段,说"摘要"或"描述"指 description 字段。
|
|
224
|
+
2. **未提及的字段必须原样返回**,不得做任何改写、改善、优化或翻译。
|
|
225
|
+
3. 如果你不确定用户指的是哪个字段,优先修改 pattern(内容文档)。
|
|
226
|
+
|
|
227
|
+
请返回 JSON(所有字段都必须包含):
|
|
228
|
+
{
|
|
229
|
+
"description": "原样或修改后的摘要",
|
|
230
|
+
"pattern": "原样或修改后的内容文档",
|
|
231
|
+
"tags": ["原样或修改后的标签数组"],
|
|
232
|
+
"confidence": 原样或修改后的数字,
|
|
233
|
+
"aiInsight": "原样或修改后的AI洞察 或 null",
|
|
234
|
+
"agentNotes": ["原样或修改后的笔记数组"] 或 null,
|
|
235
|
+
"relations": {原样或修改后的关联关系}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
仅返回 JSON,不要添加其他文字。`;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* 将 AI 返回的润色结果合并到 before 上生成 after,并构造 knowledgeService.update() 所需的 updateData
|
|
243
|
+
*/
|
|
244
|
+
function buildUpdateFromRefineResult(before, parsed) {
|
|
245
|
+
const after = { ...before };
|
|
246
|
+
const updateData = {};
|
|
247
|
+
let changed = false;
|
|
248
|
+
|
|
249
|
+
if (parsed.description != null && parsed.description !== before.description) {
|
|
250
|
+
after.description = parsed.description;
|
|
251
|
+
updateData.description = parsed.description;
|
|
252
|
+
changed = true;
|
|
253
|
+
}
|
|
254
|
+
if (parsed.pattern != null && parsed.pattern !== before.pattern) {
|
|
255
|
+
after.pattern = parsed.pattern;
|
|
256
|
+
// pattern 需要写入 content.pattern
|
|
257
|
+
updateData._patternChanged = parsed.pattern;
|
|
258
|
+
changed = true;
|
|
259
|
+
}
|
|
260
|
+
if (parsed.tags != null && Array.isArray(parsed.tags)) {
|
|
261
|
+
const newTags = JSON.stringify(parsed.tags);
|
|
262
|
+
if (newTags !== JSON.stringify(before.tags)) {
|
|
263
|
+
after.tags = parsed.tags;
|
|
264
|
+
updateData.tags = parsed.tags;
|
|
265
|
+
changed = true;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
if (typeof parsed.confidence === 'number' && parsed.confidence !== before.confidence) {
|
|
269
|
+
after.confidence = parsed.confidence;
|
|
270
|
+
updateData._confidenceChanged = parsed.confidence;
|
|
271
|
+
changed = true;
|
|
272
|
+
}
|
|
273
|
+
if (parsed.aiInsight !== undefined && parsed.aiInsight !== before.aiInsight) {
|
|
274
|
+
after.aiInsight = parsed.aiInsight;
|
|
275
|
+
updateData.aiInsight = parsed.aiInsight;
|
|
276
|
+
changed = true;
|
|
277
|
+
}
|
|
278
|
+
if (parsed.agentNotes !== undefined) {
|
|
279
|
+
const newNotes = JSON.stringify(parsed.agentNotes);
|
|
280
|
+
if (newNotes !== JSON.stringify(before.agentNotes)) {
|
|
281
|
+
after.agentNotes = parsed.agentNotes;
|
|
282
|
+
updateData.agentNotes = parsed.agentNotes;
|
|
283
|
+
changed = true;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
if (parsed.relations !== undefined) {
|
|
287
|
+
const newRels = JSON.stringify(parsed.relations);
|
|
288
|
+
if (newRels !== JSON.stringify(before.relations)) {
|
|
289
|
+
after.relations = parsed.relations;
|
|
290
|
+
updateData.relations = parsed.relations;
|
|
291
|
+
changed = true;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return { after, updateData, changed };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/* ═══ 对话式润色 — 预览 ══════════════════════════════════ */
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* POST /api/v1/candidates/refine-preview
|
|
302
|
+
* 直接用用户提示词调用 AI 润色,返回 before/after 对比
|
|
303
|
+
* Body: { candidateId: string, userPrompt: string }
|
|
304
|
+
*/
|
|
305
|
+
router.post('/refine-preview', asyncHandler(async (req, res) => {
|
|
306
|
+
const { candidateId, userPrompt } = req.body;
|
|
307
|
+
if (!candidateId) throw new ValidationError('candidateId is required');
|
|
308
|
+
if (!userPrompt || !userPrompt.trim()) throw new ValidationError('userPrompt is required');
|
|
309
|
+
|
|
310
|
+
const container = getServiceContainer();
|
|
311
|
+
const knowledgeService = container.get('knowledgeService');
|
|
312
|
+
const aiProvider = container.get('aiProvider');
|
|
313
|
+
if (!aiProvider) throw new ValidationError('AI provider not configured');
|
|
314
|
+
|
|
315
|
+
const entry = await knowledgeService.get(candidateId);
|
|
316
|
+
if (!entry) throw new ValidationError('Candidate not found');
|
|
317
|
+
const json = typeof entry.toJSON === 'function' ? entry.toJSON() : entry;
|
|
318
|
+
const before = extractBeforeFields(json);
|
|
319
|
+
|
|
320
|
+
const prompt = buildRefinePrompt(before, userPrompt.trim());
|
|
321
|
+
const parsed = await aiProvider.chatWithStructuredOutput(prompt, { temperature: 0.3 });
|
|
322
|
+
|
|
323
|
+
if (!parsed) {
|
|
324
|
+
return res.json({
|
|
325
|
+
success: true,
|
|
326
|
+
data: { candidateId, before, after: before, preview: {} },
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const { after } = buildUpdateFromRefineResult(before, parsed);
|
|
331
|
+
|
|
332
|
+
res.json({
|
|
333
|
+
success: true,
|
|
334
|
+
data: { candidateId, before, after, preview: parsed },
|
|
335
|
+
});
|
|
336
|
+
}));
|
|
337
|
+
|
|
338
|
+
/* ═══ 对话式润色 — 应用 ══════════════════════════════════ */
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* POST /api/v1/candidates/refine-apply
|
|
342
|
+
* 应用润色预览的结果。优先使用前端传回的 preview 数据(避免重复调 AI),
|
|
343
|
+
* 若未提供 preview 则 fallback 重新调用 AI。
|
|
344
|
+
* Body: { candidateId: string, userPrompt?: string, preview?: object }
|
|
345
|
+
*/
|
|
346
|
+
router.post('/refine-apply', asyncHandler(async (req, res) => {
|
|
347
|
+
const { candidateId, userPrompt, preview } = req.body;
|
|
348
|
+
if (!candidateId) throw new ValidationError('candidateId is required');
|
|
349
|
+
|
|
350
|
+
const container = getServiceContainer();
|
|
351
|
+
const knowledgeService = container.get('knowledgeService');
|
|
352
|
+
|
|
353
|
+
const entry = await knowledgeService.get(candidateId);
|
|
354
|
+
if (!entry) throw new ValidationError('Candidate not found');
|
|
355
|
+
const json = typeof entry.toJSON === 'function' ? entry.toJSON() : entry;
|
|
356
|
+
const before = extractBeforeFields(json);
|
|
357
|
+
|
|
358
|
+
// 优先使用前端传回的 preview(与预览阶段完全一致),否则重新调 AI
|
|
359
|
+
let parsed = preview || null;
|
|
360
|
+
if (!parsed) {
|
|
361
|
+
if (!userPrompt || !userPrompt.trim()) {
|
|
362
|
+
throw new ValidationError('Either preview or userPrompt is required');
|
|
363
|
+
}
|
|
364
|
+
const aiProvider = container.get('aiProvider');
|
|
365
|
+
if (!aiProvider) throw new ValidationError('AI provider not configured');
|
|
366
|
+
const prompt = buildRefinePrompt(before, userPrompt.trim());
|
|
367
|
+
parsed = await aiProvider.chatWithStructuredOutput(prompt, { temperature: 0.3 });
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
if (!parsed) {
|
|
371
|
+
return res.json({
|
|
372
|
+
success: true,
|
|
373
|
+
data: { refined: 0, total: 1, candidate: json },
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const { after, updateData, changed } = buildUpdateFromRefineResult(before, parsed);
|
|
378
|
+
|
|
379
|
+
if (changed) {
|
|
380
|
+
// 处理需要嵌套写入的字段
|
|
381
|
+
const finalUpdate = { ...updateData };
|
|
382
|
+
delete finalUpdate._patternChanged;
|
|
383
|
+
delete finalUpdate._confidenceChanged;
|
|
384
|
+
|
|
385
|
+
if (updateData._patternChanged != null) {
|
|
386
|
+
finalUpdate.content = { ...(json.content || {}), pattern: updateData._patternChanged };
|
|
387
|
+
}
|
|
388
|
+
if (updateData._confidenceChanged != null) {
|
|
389
|
+
finalUpdate.reasoning = { ...(json.reasoning || {}), confidence: updateData._confidenceChanged };
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
await knowledgeService.update(candidateId, finalUpdate, { userId: 'dashboard-refine' });
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// 返回更新后的条目
|
|
396
|
+
const updated = changed ? await knowledgeService.get(candidateId) : entry;
|
|
397
|
+
const updatedJson = typeof updated?.toJSON === 'function' ? updated.toJSON() : updated;
|
|
398
|
+
|
|
399
|
+
res.json({
|
|
400
|
+
success: true,
|
|
401
|
+
data: { refined: changed ? 1 : 0, total: 1, candidate: updatedJson },
|
|
402
|
+
});
|
|
403
|
+
}));
|
|
404
|
+
|
|
405
|
+
export default router;
|
|
@@ -280,4 +280,117 @@ router.post('/context-aware', asyncHandler(async (req, res) => {
|
|
|
280
280
|
});
|
|
281
281
|
}));
|
|
282
282
|
|
|
283
|
+
/* ═══ 相似度检测 ════════════════════════════════════════ */
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* POST /api/v1/search/similarity
|
|
287
|
+
* 候选与已有 Recipe 的相似度检测
|
|
288
|
+
* Body: { code, language } 或 { targetName, candidateId } 或 { candidate: {title, summary, code} }
|
|
289
|
+
*/
|
|
290
|
+
router.post('/similarity', asyncHandler(async (req, res) => {
|
|
291
|
+
const { code, language, targetName, candidateId, candidate } = req.body;
|
|
292
|
+
const projectRoot = process.env.ASD_PROJECT_DIR || process.cwd();
|
|
293
|
+
|
|
294
|
+
let candidateObj;
|
|
295
|
+
|
|
296
|
+
if (candidateId && targetName) {
|
|
297
|
+
// 从知识库加载候选
|
|
298
|
+
try {
|
|
299
|
+
const container = getServiceContainer();
|
|
300
|
+
const knowledgeService = container.get('knowledgeService');
|
|
301
|
+
const entry = await knowledgeService.get(candidateId);
|
|
302
|
+
if (entry) {
|
|
303
|
+
const json = typeof entry.toJSON === 'function' ? entry.toJSON() : entry;
|
|
304
|
+
candidateObj = {
|
|
305
|
+
title: json.title || '',
|
|
306
|
+
summary: json.description || '',
|
|
307
|
+
code: json.content?.pattern || '',
|
|
308
|
+
usageGuide: json.content?.markdown || '',
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
} catch (err) {
|
|
312
|
+
logger.warn('similarity: failed to load candidate', { candidateId, error: err.message });
|
|
313
|
+
}
|
|
314
|
+
} else if (candidate) {
|
|
315
|
+
candidateObj = {
|
|
316
|
+
title: candidate.title || '',
|
|
317
|
+
summary: candidate.summary || candidate.description || '',
|
|
318
|
+
code: candidate.code || candidate.pattern || '',
|
|
319
|
+
usageGuide: candidate.usageGuide || candidate.markdown || '',
|
|
320
|
+
};
|
|
321
|
+
} else if (code) {
|
|
322
|
+
candidateObj = { title: '', summary: '', code: code || '', usageGuide: '' };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (!candidateObj) {
|
|
326
|
+
return res.json({ success: true, data: { similar: [] } });
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
try {
|
|
330
|
+
const { findSimilarRecipes } = await import('../../service/candidate/SimilarityService.js');
|
|
331
|
+
const similar = findSimilarRecipes(projectRoot, candidateObj, { threshold: 0.3, topK: 10 });
|
|
332
|
+
|
|
333
|
+
// 映射为前端期望格式
|
|
334
|
+
const mapped = similar.map(s => ({
|
|
335
|
+
recipeName: s.title || s.file?.replace(/\.md$/, '') || '',
|
|
336
|
+
similarity: s.similarity,
|
|
337
|
+
file: s.file,
|
|
338
|
+
}));
|
|
339
|
+
|
|
340
|
+
res.json({ success: true, data: { similar: mapped } });
|
|
341
|
+
} catch (err) {
|
|
342
|
+
logger.warn('similarity search failed', { error: err.message });
|
|
343
|
+
res.json({ success: true, data: { similar: [] } });
|
|
344
|
+
}
|
|
345
|
+
}));
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* POST /api/v1/search/xcode-simulate
|
|
349
|
+
* Xcode 编辑器上下文模拟搜索
|
|
350
|
+
* Body: { keyword, currentFile?, language?, limit? }
|
|
351
|
+
*/
|
|
352
|
+
router.post('/xcode-simulate', asyncHandler(async (req, res) => {
|
|
353
|
+
const { keyword, currentFile, language, limit = 10 } = req.body;
|
|
354
|
+
if (!keyword) {
|
|
355
|
+
throw new ValidationError('keyword is required');
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const container = getServiceContainer();
|
|
359
|
+
const pageSize = Math.min(limit || 10, 50);
|
|
360
|
+
let results = [];
|
|
361
|
+
|
|
362
|
+
// 复用 context-aware 搜索,注入 Xcode 上下文
|
|
363
|
+
try {
|
|
364
|
+
const searchEngine = container.get('searchEngine');
|
|
365
|
+
const result = await searchEngine.search(keyword, {
|
|
366
|
+
mode: 'bm25',
|
|
367
|
+
limit: pageSize,
|
|
368
|
+
rank: true,
|
|
369
|
+
context: {
|
|
370
|
+
intent: 'xcode-suggest',
|
|
371
|
+
language: language || 'swift',
|
|
372
|
+
currentFile,
|
|
373
|
+
},
|
|
374
|
+
});
|
|
375
|
+
results = (result?.items || []).map(r => {
|
|
376
|
+
let contentStr = '';
|
|
377
|
+
try {
|
|
378
|
+
const c = typeof r.content === 'string' && r.content.startsWith('{') ? JSON.parse(r.content) : (r.content || {});
|
|
379
|
+
contentStr = c.pattern || c.markdown || c.code || '';
|
|
380
|
+
} catch { contentStr = r.content || ''; }
|
|
381
|
+
return {
|
|
382
|
+
name: (r.title || r.id) + '.md',
|
|
383
|
+
content: contentStr,
|
|
384
|
+
similarity: r.score || 0,
|
|
385
|
+
trigger: r.trigger || '',
|
|
386
|
+
matchType: result.ranked ? 'ranked' : 'bm25',
|
|
387
|
+
};
|
|
388
|
+
});
|
|
389
|
+
} catch (err) {
|
|
390
|
+
logger.warn('xcode-simulate search failed', { error: err.message });
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
res.json({ success: true, data: { results, total: results.length } });
|
|
394
|
+
}));
|
|
395
|
+
|
|
283
396
|
export default router;
|
|
@@ -3,17 +3,12 @@
|
|
|
3
3
|
* 支持 4 种策略:whole、section(按标题)、fixed(固定大小+重叠)、auto(自适应)
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import { estimateTokens } from '../../shared/token-utils.js';
|
|
7
|
+
export { estimateTokens };
|
|
8
|
+
|
|
6
9
|
const DEFAULT_MAX_CHUNK_TOKENS = 512;
|
|
7
10
|
const DEFAULT_OVERLAP_TOKENS = 50;
|
|
8
11
|
|
|
9
|
-
/**
|
|
10
|
-
* 估算 token 数(~4 chars/token)
|
|
11
|
-
*/
|
|
12
|
-
export function estimateTokens(text) {
|
|
13
|
-
if (!text) return 0;
|
|
14
|
-
return Math.ceil(text.length / 4);
|
|
15
|
-
}
|
|
16
|
-
|
|
17
12
|
/**
|
|
18
13
|
* 将内容分块
|
|
19
14
|
* @param {string} content
|
|
@@ -8,6 +8,7 @@ import { VectorStore } from './VectorStore.js';
|
|
|
8
8
|
import { writeFileSync, readFileSync, mkdirSync, existsSync, statSync } from 'node:fs';
|
|
9
9
|
import { join, dirname } from 'node:path';
|
|
10
10
|
import pathGuard from '../../shared/PathGuard.js';
|
|
11
|
+
import { cosineSimilarity } from '../../shared/similarity.js';
|
|
11
12
|
|
|
12
13
|
export class JsonVectorAdapter extends VectorStore {
|
|
13
14
|
#indexPath;
|
|
@@ -206,15 +207,7 @@ export class JsonVectorAdapter extends VectorStore {
|
|
|
206
207
|
}
|
|
207
208
|
|
|
208
209
|
#cosineSimilarity(a, b) {
|
|
209
|
-
|
|
210
|
-
let dotProduct = 0, normA = 0, normB = 0;
|
|
211
|
-
for (let i = 0; i < a.length; i++) {
|
|
212
|
-
dotProduct += a[i] * b[i];
|
|
213
|
-
normA += a[i] * a[i];
|
|
214
|
-
normB += b[i] * b[i];
|
|
215
|
-
}
|
|
216
|
-
const denom = Math.sqrt(normA) * Math.sqrt(normB);
|
|
217
|
-
return denom > 0 ? dotProduct / denom : 0;
|
|
210
|
+
return cosineSimilarity(a, b);
|
|
218
211
|
}
|
|
219
212
|
|
|
220
213
|
#load() {
|
|
@@ -1,51 +1,23 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { getProjectRecipesPath } from '../../infrastructure/config/Paths.js';
|
|
4
|
+
import { tokenizeForSimilarity, jaccardSimilarity } from '../../shared/similarity.js';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* SimilarityService — 轻量级 Recipe 相似度检测
|
|
7
|
-
* 基于 Jaccard
|
|
8
|
+
* 基于 Jaccard 相似度对候选与已有 Recipe 进行去重检测
|
|
8
9
|
*/
|
|
9
10
|
|
|
10
|
-
/**
|
|
11
|
-
* 将文本拆分为 n-gram token 集合
|
|
12
|
-
*/
|
|
13
|
-
function tokenize(text, n = 2) {
|
|
14
|
-
if (!text) return new Set();
|
|
15
|
-
const lower = text.toLowerCase().replace(/[^a-z0-9\u4e00-\u9fff]+/g, ' ').trim();
|
|
16
|
-
const tokens = new Set();
|
|
17
|
-
const words = lower.split(/\s+/);
|
|
18
|
-
for (const w of words) {
|
|
19
|
-
if (w.length >= n) tokens.add(w);
|
|
20
|
-
for (let i = 0; i <= w.length - n; i++) {
|
|
21
|
-
tokens.add(w.slice(i, i + n));
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
return tokens;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* 计算两个 token 集合的 Jaccard 相似度
|
|
29
|
-
*/
|
|
30
|
-
function jaccard(a, b) {
|
|
31
|
-
if (a.size === 0 && b.size === 0) return 0;
|
|
32
|
-
let intersection = 0;
|
|
33
|
-
for (const t of a) {
|
|
34
|
-
if (b.has(t)) intersection++;
|
|
35
|
-
}
|
|
36
|
-
return intersection / (a.size + b.size - intersection);
|
|
37
|
-
}
|
|
38
|
-
|
|
39
11
|
/**
|
|
40
12
|
* 计算候选与单个 Recipe 的综合相似度
|
|
41
13
|
*/
|
|
42
14
|
function computeSimilarity(candidate, recipe) {
|
|
43
|
-
const titleSim =
|
|
44
|
-
const summarySim =
|
|
45
|
-
|
|
46
|
-
|
|
15
|
+
const titleSim = jaccardSimilarity(tokenizeForSimilarity(candidate.title), tokenizeForSimilarity(recipe.title));
|
|
16
|
+
const summarySim = jaccardSimilarity(
|
|
17
|
+
tokenizeForSimilarity(candidate.summary || candidate.description),
|
|
18
|
+
tokenizeForSimilarity(recipe.summary || recipe.description),
|
|
47
19
|
);
|
|
48
|
-
const codeSim =
|
|
20
|
+
const codeSim = jaccardSimilarity(tokenizeForSimilarity(candidate.code, 3), tokenizeForSimilarity(recipe.code, 3));
|
|
49
21
|
// 加权: title 30%, summary 30%, code 40%
|
|
50
22
|
return titleSim * 0.3 + summarySim * 0.3 + codeSim * 0.4;
|
|
51
23
|
}
|