UNPKG

autosnippet

Version:

Extract code patterns into a knowledge base for AI coding assistants

341 lines (340 loc) 13.9 kB
/** * Search API 路由 * 统一搜索接口 - 搜 Recipe(含所有知识类型) */ import express from 'express'; import { ContextAwareSearchBody, GraphImpactQuery, GraphQuery, SearchQuery, SimilarityBody, } from '#shared/schemas/http-requests.js'; import Logger from '../../infrastructure/logging/Logger.js'; import { getServiceContainer } from '../../injection/ServiceContainer.js'; import { validate, validateQuery } from '../middleware/validate.js'; import { safeInt } from '../utils/routeHelpers.js'; const router = express.Router(); const logger = Logger.getInstance(); /** * GET /api/v1/search * 统一搜索 * ?q=keyword&type=all|recipe|solution|rule&limit=20&mode=keyword|bm25|semantic&groupByKind=true */ router.get('/', validateQuery(SearchQuery), async (req, res) => { const { q, type = 'all', mode = 'keyword' } = req.query; const limit = safeInt(req.query.limit, 20, 1, 100); const page = safeInt(req.query.page, 1); const groupByKind = req.query.groupByKind === 'true' || req.query.groupByKind === true; const container = getServiceContainer(); // 所有模式优先通过 SearchEngine(含 auto/bm25/semantic/keyword/ranking) try { const searchEngine = container.get('searchEngine'); const result = await searchEngine.search(q, { type, limit, mode, groupByKind }); return void res.json({ success: true, data: result }); } catch (err) { logger.warn('SearchEngine 搜索失败,降级到传统搜索', { mode, error: err.message }); } const results = {}; const pagination = { page, pageSize: limit }; // SearchEngine 不可用时的降级路径(Dashboard 冷启动场景) // recipes + candidates 共用 knowledgeService.search(),避免重复查询 if (type === 'all' || type === 'recipe' || type === 'solution' || type === 'candidate') { try { const knowledgeService = container.get('knowledgeService'); const searchResult = await knowledgeService.search(q, pagination); if (type === 'all') { results.recipes = searchResult; results.candidates = searchResult; // 同源数据,避免二次查询 } else if (type === 'candidate') { results.candidates = searchResult; } else { results.recipes = searchResult; } } catch (err) { logger.warn('Knowledge 搜索失败', { query: q, error: err.message }); if (type === 'all' || type === 'recipe' || type === 'solution') { results.recipes = { data: [], pagination: { page, pageSize: limit, total: 0, pages: 0 } }; } if (type === 'all' || type === 'candidate') { results.candidates = { data: [], pagination: { page, pageSize: limit, total: 0, pages: 0 }, }; } } } // 搜索 Guard Rule(boundary-constraint 类型的 Recipe) if (type === 'all' || type === 'rule') { try { const guardService = container.get('guardService'); results.rules = await guardService.searchRules(q, pagination); } catch (err) { logger.warn('Guard Rule 搜索失败', { query: q, error: err.message }); results.rules = { data: [], pagination: { page, pageSize: limit, total: 0, pages: 0 } }; } } const totalResults = Object.values(results).reduce((sum, r) => sum + (r.pagination?.total || r.data?.length || 0), 0); res.json({ success: true, data: { query: q, type, mode, totalResults, ...results, }, }); }); /** * GET /api/v1/search/graph * 知识图谱查询 * ?nodeId=xxx&nodeType=recipe */ router.get('/graph', validateQuery(GraphQuery), async (req, res) => { const { nodeId, nodeType, relation, direction = 'both' } = req.query; const container = getServiceContainer(); const graphService = container.get('knowledgeGraphService'); if (!graphService) { return void res.json({ success: true, data: { outgoing: [], incoming: [] } }); } const edges = relation ? await graphService.getRelated(nodeId, nodeType, relation) : await graphService.getEdges(nodeId, nodeType, direction); res.json({ success: true, data: edges }); }); /** * GET /api/v1/search/graph/impact * 影响分析 */ router.get('/graph/impact', validateQuery(GraphImpactQuery), async (req, res) => { const { nodeId, nodeType } = req.query; const maxDepth = safeInt(req.query.maxDepth, 3, 1, 5); const container = getServiceContainer(); const graphService = container.get('knowledgeGraphService'); if (!graphService) { return void res.json({ success: true, data: [] }); } const impact = await graphService.getImpactAnalysis(nodeId, nodeType, maxDepth); res.json({ success: true, data: impact }); }); /** * GET /api/v1/search/graph/all * 全量知识图谱边(Dashboard 可视化用) * ?limit=500 */ router.get('/graph/all', async (req, res) => { const limit = safeInt(req.query.limit, 500, 1, 2000); const container = getServiceContainer(); const graphService = container.get('knowledgeGraphService'); if (!graphService) { return void res.json({ success: true, data: { edges: [], nodeLabels: {} } }); } // 默认不过滤 nodeType,返回所有知识相关边(recipe + knowledge) // 仅当显式指定 nodeType 时才过滤(module 类由 /spm/dep-graph 提供) const rawNodeType = req.query.nodeType; const nodeType = rawNodeType === 'all' ? undefined : rawNodeType || undefined; const edges = await graphService.getAllEdges(limit, nodeType); // 收集节点 ID + 类型 → 按类型查标签 const nodeMap = new Map(); // id → Set<type> for (const e of edges) { if (!nodeMap.has(e.fromId)) { nodeMap.set(e.fromId, new Set()); } nodeMap.get(e.fromId).add(e.fromType); if (!nodeMap.has(e.toId)) { nodeMap.set(e.toId, new Set()); } nodeMap.get(e.toId).add(e.toType); } const nodeLabels = {}; const nodeTypes = {}; // id → 主要类型(供前端区分渲染) const nodeCategories = {}; // id → category/target 名(供前端分组布局) if (nodeMap.size > 0) { const knowledgeRepo = container.get('knowledgeRepository'); for (const [id, types] of nodeMap) { const primaryType = types.has('recipe') ? 'recipe' : [...types][0]; nodeTypes[id] = primaryType; if ((primaryType === 'recipe' || primaryType === 'knowledge') && knowledgeRepo) { try { const r = (await knowledgeRepo.findById(id)); if (r) { nodeLabels[id] = r.title || id; nodeCategories[id] = r.category || ''; continue; } } catch { /* not found – fall through */ } } nodeLabels[id] = id; } } res.json({ success: true, data: { edges, nodeLabels, nodeTypes, nodeCategories } }); }); /** * GET /api/v1/search/graph/stats * 图谱统计 */ router.get('/graph/stats', async (req, res) => { const container = getServiceContainer(); const graphService = container.get('knowledgeGraphService'); if (!graphService) { return void res.json({ success: true, data: { totalEdges: 0, byRelation: {}, nodeTypes: [] }, }); } const rawStatsType = req.query.nodeType; const statsNodeType = rawStatsType === 'all' ? undefined : rawStatsType || undefined; const stats = await graphService.getStats(statsNodeType); res.json({ success: true, data: stats }); }); /** * POST /api/v1/search/context-aware * 上下文感知搜索 — SearchEngine 内置 Ranking Pipeline(CoarseRanker + MultiSignalRanker + ContextBoost) */ router.post('/context-aware', validate(ContextAwareSearchBody), async (req, res) => { const { keyword, limit, language, sessionHistory } = req.body; const t0 = Date.now(); const container = getServiceContainer(); const pageSize = Math.min(limit || 10, 100); let results = []; let source = 'knowledgeService'; // SearchEngine BM25 + 内置 Ranking Pipeline try { const searchEngine = container.get('searchEngine'); const result = await searchEngine.search(keyword, { mode: 'bm25', limit: pageSize, rank: true, context: { intent: 'search', language, sessionHistory: sessionHistory || [] }, }); const items = result?.items || []; if (items.length > 0) { source = result.ranked ? 'search-engine+ranking' : 'search-engine'; results = items.map((r) => { let contentStr = ''; try { const c = typeof r.content === 'string' && r.content.startsWith('{') ? JSON.parse(r.content) : r.content || {}; contentStr = c.pattern || c.markdown || c.code || ''; } catch { contentStr = (r.content || r.code || ''); } return { name: `${r.title || r.id}.md`, content: contentStr, similarity: r.score || 0, authority: r.authorityScore || 0, matchType: result.ranked ? 'ranked' : 'bm25', qualityScore: r.qualityScore || 0, usageCount: r.usageCount || 0, }; }); } } catch (err) { logger.warn('SearchEngine context-aware 失败,降级到 KnowledgeService', { error: err.message, }); } // 降级: SearchEngine 完全不可用时,KnowledgeService SQL LIKE (Dashboard 冷启动) if (results.length === 0) { try { const knowledgeService = container.get('knowledgeService'); const list = await knowledgeService.search(keyword, { page: 1, pageSize }); const items = list.data || []; results = items.map((r) => ({ name: `${r.title || r.id}.md`, content: r.content?.pattern || r.content?.markdown || '', similarity: 1, authority: r.quality?.overall || 0, matchType: 'keyword', qualityScore: r.quality?.overall || 0, })); source = 'knowledgeService'; } catch { /* 全部失败 */ } } const elapsed = Date.now() - t0; res.json({ success: true, data: { results, context: {}, total: results.length, hasAiEvaluation: false, searchTime: elapsed, source, }, }); }); /* ═══ 相似度检测 ════════════════════════════════════════ */ /** * POST /api/v1/search/similarity * 候选与已有 Recipe 的相似度检测 * Body: { code, language } 或 { targetName, candidateId } 或 { candidate: {title, summary, code} } */ router.post('/similarity', validate(SimilarityBody), async (req, res) => { const { code, targetName, candidateId, candidate } = req.body; const projectRoot = process.env.ASD_PROJECT_DIR || process.cwd(); let candidateObj; if (candidateId && targetName) { // 从知识库加载候选 try { const container = getServiceContainer(); const knowledgeService = container.get('knowledgeService'); const entry = await knowledgeService.get(candidateId); if (entry) { const json = typeof entry.toJSON === 'function' ? entry.toJSON() : entry; candidateObj = { title: json.title || '', summary: json.description || '', code: json.content?.pattern || '', usageGuide: json.content?.markdown || '', }; } } catch (err) { logger.warn('similarity: failed to load candidate', { candidateId, error: err.message, }); } } else if (candidate) { candidateObj = { title: candidate.title || '', summary: candidate.summary || candidate.description || '', code: candidate.code || candidate.pattern || '', usageGuide: candidate.usageGuide || candidate.markdown || '', }; } else if (code) { candidateObj = { title: '', summary: '', code: code || '', usageGuide: '' }; } if (!candidateObj) { return void res.json({ success: true, data: { similar: [] } }); } try { const { findSimilarRecipes } = await import('../../service/candidate/SimilarityService.js'); const similar = findSimilarRecipes(projectRoot, candidateObj, { threshold: 0.3, topK: 10 }); // 映射为前端期望格式 const mapped = similar.map((s) => ({ recipeName: s.title || s.file?.replace(/\.md$/, '') || '', similarity: s.similarity, file: s.file, })); res.json({ success: true, data: { similar: mapped } }); } catch (err) { logger.warn('similarity search failed', { error: err.message }); res.json({ success: true, data: { similar: [] } }); } }); export default router;