UNPKG

autosnippet

Version:

Extract code patterns into a knowledge base for AI coding assistants

726 lines (702 loc) 29.6 kB
/** * Candidates API 路由 * 候选条目的 AI 补齐、润色预览/应用 */ import express from 'express'; import { BootstrapRefineBody, EnrichBody, RefineApplyBody, RefinePreviewBody, } from '#shared/schemas/http-requests.js'; import Logger from '../../infrastructure/logging/Logger.js'; import { getServiceContainer } from '../../injection/ServiceContainer.js'; import { ValidationError } from '../../shared/errors/index.js'; import { validate } from '../middleware/validate.js'; import { createStreamSession, getStreamSession } from '../utils/sse-sessions.js'; const router = express.Router(); const logger = Logger.getInstance(); /* ═══ AI 语义字段补齐 ════════════════════════════════════ */ /** * POST /api/v1/candidates/enrich * 对若干候选条目进行 AI 语义字段补全 * Body: { candidateIds: string[] } */ router.post('/enrich', validate(EnrichBody), async (req, res) => { const { candidateIds } = req.body; const container = getServiceContainer(); const knowledgeService = container.get('knowledgeService'); const aiProvider = container.get('aiProvider'); // 收集候选条目 const candidates = []; for (const id of candidateIds) { try { const entry = await knowledgeService.get(id); if (entry) { const json = typeof entry.toJSON === 'function' ? entry.toJSON() : entry; candidates.push({ id: json.id, title: json.title, language: json.language, category: json.category, description: json.description, code: json.content?.pattern || '', rationale: json.content?.rationale, knowledgeType: json.knowledgeType, complexity: json.complexity, scope: json.scope, steps: json.content?.steps, constraints: json.constraints, }); } } catch (err) { logger.warn(`enrich: failed to load candidate ${id}`, { error: err.message }); } } if (candidates.length === 0) { return void res.json({ success: true, data: { enriched: 0, total: 0, results: [] } }); } let enrichedCount = 0; const results = []; if (aiProvider) { // Mock 模式下跳过 AI enrichment if (aiProvider.name === 'mock') { return void res.json({ success: true, data: { enriched: 0, total: candidates.length, results: [], mock: true }, }); } let enriched = []; try { // 获取用户语言偏好 let lang = 'en'; try { lang = container.getLang?.() || 'en'; } catch { /* lang not available */ } enriched = await aiProvider.enrichCandidates(candidates, { lang }); } catch (err) { logger.warn('AI enrichCandidates failed', { error: err.message }); } for (const item of enriched) { // 安全的 index 映射:AI 未返回 index 时根据数组位置推断 const idx = typeof item.index === 'number' ? item.index : enriched.indexOf(item); const cand = candidates[idx]; if (!cand) { continue; } try { const updateData = {}; let changed = false; // content 嵌套字段(rationale / steps)共用一次 DB 读取 const needsContentMerge = (item.rationale && !cand.rationale) || (item.steps && (!cand.steps || cand.steps.length === 0)); let contentBase = null; if (needsContentMerge) { const entry = await knowledgeService.get(cand.id); const json = typeof entry.toJSON === 'function' ? entry.toJSON() : entry; contentBase = { ...(json.content || {}) }; } if (item.rationale && !cand.rationale) { contentBase.rationale = item.rationale; changed = true; } if (item.steps && (!cand.steps || cand.steps.length === 0)) { contentBase.steps = item.steps; changed = true; } if (contentBase && changed) { updateData.content = contentBase; } if (item.knowledgeType && !cand.knowledgeType) { updateData.knowledgeType = item.knowledgeType; changed = true; } if (item.complexity && !cand.complexity) { updateData.complexity = item.complexity; changed = true; } if (item.scope && !cand.scope) { updateData.scope = item.scope; changed = true; } if (item.constraints && !cand.constraints?.preconditions?.length) { updateData.constraints = item.constraints; changed = true; } if (changed) { await knowledgeService.update(cand.id, updateData, { userId: 'dashboard-enrich', }); enrichedCount++; } results.push({ id: cand.id, enriched: changed, filledFields: Object.keys(item).filter((k) => k !== 'index'), }); } catch (err) { logger.warn(`enrich: failed to update candidate ${cand.id}`, { error: err.message, }); results.push({ id: cand.id, enriched: false, filledFields: [], error: err.message, }); } } } res.json({ success: true, data: { enriched: enrichedCount, total: candidates.length, results }, }); }); /* ═══ Bootstrap 内容润色 ═════════════════════════════════ */ /** * POST /api/v1/candidates/bootstrap-refine * AI 内容润色(适用于 Bootstrap 产出的批量候选) * Body: { candidateIds?: string[], userPrompt?: string, dryRun?: boolean } */ router.post('/bootstrap-refine', validate(BootstrapRefineBody), async (req, res) => { const { candidateIds, userPrompt, dryRun } = req.body; const container = getServiceContainer(); // 复用 MCP handler 的 bootstrapRefine 逻辑 const { bootstrapRefine } = await import('../../external/mcp/handlers/bootstrap-internal.js'); const ctx = { container, logger }; const result = await bootstrapRefine(ctx, { candidateIds, userPrompt, dryRun }); // envelope 返回 { success, data, meta, ... },直接取 data const data = result?.data ?? { refined: 0, total: 0, errors: [], results: [] }; res.json({ success: true, data }); }); /* ═══ 对话式润色 — 工具函数 ═══════════════════════════════ */ /** * 从 KnowledgeEntry 提取前端 DiffView 所需的 before 字段 * 与前端 extractBefore() 保持一致 */ function extractBeforeFields(json) { return { title: json.title || '', description: json.description || '', pattern: json.content?.pattern || '', markdown: json.content?.markdown || '', rationale: json.content?.rationale || '', tags: json.tags || [], confidence: json.reasoning?.confidence ?? 0.6, relations: json.relations || {}, aiInsight: json.aiInsight || null, agentNotes: json.agentNotes || null, }; } /** * 构造直接润色提示词 —— 以用户 prompt 为主指令 * @param before extractBeforeFields 的输出 * @param userPrompt 用户输入的润色指令 */ function buildRefinePrompt(before, userPrompt) { return `你是一位知识库条目润色助手。你必须**严格按照用户指令**修改知识条目。 ## ⭐ JSON key 规范(最高优先级) 返回的 JSON 必须且只能使用以下 9 个 key,大小写必须完全一致: description → 摘要(string) pattern → 代码/标准用法(string) markdown → Markdown 文档(string) rationale → 设计原理(string) tags → 标签(string[]) confidence → 置信度(number 0.01.0) aiInsight → AI 洞察(string | null) agentNotes → Agent 笔记(string[] | null) relations → 关联关系(object) 禁止使用其他 key。不允许用 content/summary/insight/notes/title 等替代名。 ## 字段与 UI 子标题的对应关系 用户输入的指令可能使用 UI 上显示的子标题名称,对应规则如下: - “摘要”“描述” → description - “代码”“标准用法”“代码/标准用法” → pattern - “Markdown 文档”“markdown” → markdown - “设计原理”“原理” → rationale - “标签” → tags - “AI 洞察” → aiInsight - “Agent 笔记” → agentNotes - “关联关系” → relations ## 当前条目信息 标题: ${before.title} 【description】摘要 ${before.description || '(空)'} 【pattern】代码/标准用法 ${(String(before.pattern || '(空)')).substring(0, 3000)} 【markdown】Markdown 文档 ${(String(before.markdown || '(空)')).substring(0, 3000)} 【rationale】设计原理 ${before.rationale || '(空)'} 【tags】标签 ${JSON.stringify(before.tags)} 【confidence】置信度 ${before.confidence} 【relations】关联关系 ${JSON.stringify(before.relations)} 【aiInsight】AI 洞察 ${before.aiInsight || '(空)'} 【agentNotes】Agent 笔记 ${JSON.stringify(before.agentNotes || [])} ## 用户指令 ${userPrompt} ## 严格约束 1. **只修改用户指令涉及的字段**。参考上方“字段与 UI 子标题的对应关系”识别用户指的是哪个字段。 2. **未涉及的字段必须原样返回**,不得做任何改写、改善、优化或翻译。 3. 如果不确定用户指的是哪个字段,优先修改 description(摘要)、pattern(代码)、markdown(文档)、rationale(设计原理)。 4. **翻译/语言转换类指令**(如“翻译为中文”): 翻译 description、pattern、markdown、rationale、aiInsight、agentNotes 等文本字段,但 tags/relations/confidence 保持原样。 5. **tags 和 relations** 只在用户明确提及“标签”或“关联”时才修改,其他情况一律原样返回。6. **relations 格式**: object,key 为关系类型,value 为 Array<{target: string, description: string}>。示例: {"related": [{"target": "某 Recipe", "description": "原因"}]}。 ## 输出格式 返回严格符合以下结构的 JSON,不要添加任何其他文字或代码块标记: {"description": "...", "pattern": "...", "markdown": "...", "rationale": "...", "tags": [...], "confidence": 0.6, "aiInsight": "...or null", "agentNotes": ["..."] or null, "relations": {...}} 每个 key 都必须存在,key 名称必须与上述完全一致。`; } /** 将 AI 返回的润色结果合并到 before 上生成 after,并构造 knowledgeService.update() 所需的 updateData */ function buildUpdateFromRefineResult(before, parsed) { // ─── key 别名归一化:AI 可能返回不精确的 key,统一映射到标准 key ─── const KEY_ALIASES = { // description 别名 summary: 'description', desc: 'description', 摘要: 'description', 描述: 'description', // pattern 别名 content: 'pattern', designPattern: 'pattern', 内容: 'pattern', 代码: 'pattern', 标准用法: 'pattern', // markdown 别名 markdownDoc: 'markdown', Markdown文档: 'markdown', 文档: 'markdown', doc: 'markdown', // rationale 别名 design: 'rationale', 设计原理: 'rationale', 原理: 'rationale', design_rationale: 'rationale', designRationale: 'rationale', // tags 别名 tag: 'tags', label: 'tags', labels: 'tags', 标签: 'tags', // confidence 别名 score: 'confidence', 置信度: 'confidence', 评分: 'confidence', // aiInsight 别名 ai_insight: 'aiInsight', insight: 'aiInsight', aiinsight: 'aiInsight', 洞察: 'aiInsight', // agentNotes 别名 agent_notes: 'agentNotes', notes: 'agentNotes', agentnotes: 'agentNotes', 笔记: 'agentNotes', // relations 别名 relation: 'relations', 关联: 'relations', 关联关系: 'relations', }; const VALID_KEYS = new Set([ 'description', 'pattern', 'markdown', 'rationale', 'tags', 'confidence', 'aiInsight', 'agentNotes', 'relations', ]); const normalized = {}; for (const [key, value] of Object.entries(parsed)) { if (VALID_KEYS.has(key)) { normalized[key] = value; } else { const mapped = KEY_ALIASES[key] || KEY_ALIASES[key.toLowerCase()]; if (mapped && !(mapped in normalized)) { normalized[mapped] = value; } } } // 确保未返回的字段保留 before 值 for (const k of VALID_KEYS) { if (!(k in normalized)) { normalized[k] = before[k]; } } const after = { ...before }; const updateData = {}; let changed = false; if (normalized.description != null && normalized.description !== before.description) { after.description = normalized.description; updateData.description = normalized.description; changed = true; } if (normalized.pattern != null && normalized.pattern !== before.pattern) { after.pattern = normalized.pattern; updateData._patternChanged = normalized.pattern; changed = true; } if (normalized.markdown != null && normalized.markdown !== before.markdown) { after.markdown = normalized.markdown; updateData._markdownChanged = normalized.markdown; changed = true; } if (normalized.rationale != null && normalized.rationale !== before.rationale) { after.rationale = normalized.rationale; updateData._rationaleChanged = normalized.rationale; changed = true; } if (normalized.tags != null && Array.isArray(normalized.tags)) { const newTags = JSON.stringify(normalized.tags); if (newTags !== JSON.stringify(before.tags)) { after.tags = normalized.tags; updateData.tags = normalized.tags; changed = true; } } if (typeof normalized.confidence === 'number' && normalized.confidence !== before.confidence) { after.confidence = normalized.confidence; updateData._confidenceChanged = normalized.confidence; changed = true; } if (normalized.aiInsight !== undefined && normalized.aiInsight !== before.aiInsight) { after.aiInsight = normalized.aiInsight; updateData.aiInsight = normalized.aiInsight; changed = true; } if (normalized.agentNotes !== undefined) { const newNotes = JSON.stringify(normalized.agentNotes); if (newNotes !== JSON.stringify(before.agentNotes)) { after.agentNotes = normalized.agentNotes; updateData.agentNotes = normalized.agentNotes; changed = true; } } if (normalized.relations !== undefined) { const newRels = JSON.stringify(normalized.relations); if (newRels !== JSON.stringify(before.relations)) { after.relations = normalized.relations; updateData.relations = normalized.relations; changed = true; } } return { after, updateData, changed }; } /* ═══ 对话式润色 — 预览 ══════════════════════════════════ */ /** * POST /api/v1/candidates/refine-preview * 直接用用户提示词调用 AI 润色,返回 before/after 对比 * Body: { candidateId: string, userPrompt: string } */ router.post('/refine-preview', validate(RefinePreviewBody), async (req, res) => { const { candidateId, userPrompt } = req.body; const container = getServiceContainer(); const knowledgeService = container.get('knowledgeService'); const aiProvider = container.get('aiProvider'); if (!aiProvider || aiProvider.name === 'mock') { throw new ValidationError('AI Provider 未配置,当前为 Mock 模式。请先配置 API Key。'); } const entry = await knowledgeService.get(candidateId); if (!entry) { throw new ValidationError('Candidate not found'); } const json = typeof entry.toJSON === 'function' ? entry.toJSON() : entry; const before = extractBeforeFields(json); const prompt = buildRefinePrompt(before, userPrompt.trim()); const parsed = await aiProvider.chatWithStructuredOutput(prompt, { temperature: 0.3 }); if (!parsed) { return void res.json({ success: true, data: { candidateId, before, after: before, preview: {} }, }); } const { after } = buildUpdateFromRefineResult(before, parsed); res.json({ success: true, data: { candidateId, before, after, preview: parsed }, }); }); /* ═══ 对话式润色 — 流式预览 (SSE) ═══════════════════════ */ /** * POST /api/v1/candidates/refine-preview-stream * 润色预览 — 统一 SSE 协议,使用 chatWithStructuredOutput 获取可靠结构化结果 * * 不再流式推送 JSON 碎片。改为: * stream:start — 会话开始 * data:progress — AI 润色进度(前端展示进度条/加载动画) * stream:done — 完成,携带 before/after/preview * stream:error — 错误 * * Body: { candidateId: string, userPrompt: string } */ router.post('/refine-preview-stream', validate(RefinePreviewBody), async (req, res) => { const { candidateId, userPrompt } = req.body; const container = getServiceContainer(); const knowledgeService = container.get('knowledgeService'); const aiProvider = container.get('aiProvider'); if (!aiProvider || aiProvider.name === 'mock') { throw new ValidationError('AI Provider 未配置,当前为 Mock 模式。请先配置 API Key。'); } const entry = await knowledgeService.get(candidateId); if (!entry) { throw new ValidationError('Candidate not found'); } const json = typeof entry.toJSON === 'function' ? entry.toJSON() : entry; const before = extractBeforeFields(json); // ─── Session + EventSource 架构 ─── const session = createStreamSession('refine'); const prompt = buildRefinePrompt(before, userPrompt.trim()); // 立即返回 sessionId res.json({ sessionId: session.sessionId }); // 异步执行 AI 润色,通过 session 推送进度事件 setImmediate(async () => { try { // 进度事件: AI 调用开始 session.send({ type: 'data:progress', stage: 'ai_calling', message: 'AI 润色中...' }); // 定时进度心跳 — AI 调用是阻塞的,前端需要看到动态变化 const progressMsgs = [ { delay: 3000, stage: 'analyzing', message: '正在分析候选内容...' }, { delay: 8000, stage: 'generating', message: '正在生成润色建议...' }, { delay: 16000, stage: 'thinking', message: 'AI 深度分析中...' }, { delay: 28000, stage: 'almost_done', message: '即将完成,请稍候...' }, ]; const progressTimers = []; let aiDone = false; for (const pm of progressMsgs) { const t = setTimeout(() => { if (!aiDone) { session.send({ type: 'data:progress', stage: pm.stage, message: pm.message }); } }, pm.delay); progressTimers.push(t); } // 超过 35 秒后每 15 秒报一次耗时 const longTimer = setInterval(() => { if (aiDone) { return; } const elapsed = Math.round((Date.now() - session.createdAt) / 1000); session.send({ type: 'data:progress', stage: 'waiting', message: `AI 仍在处理中 (${elapsed}s)...`, }); }, 15_000); const longTimerStart = setTimeout(() => { }, 35_000); // placeholder progressTimers.push(longTimerStart); function clearProgressTimers() { aiDone = true; for (const t of progressTimers) { clearTimeout(t); } clearInterval(longTimer); } // 使用 chatWithStructuredOutput 获取可靠的 JSON 结果(非流式),120 秒超时 let parsed; try { parsed = await Promise.race([ aiProvider.chatWithStructuredOutput(prompt, { temperature: 0.3 }), new Promise((_, reject) => setTimeout(() => reject(new Error('AI refine timeout (120s)')), 120_000)), ]); } finally { clearProgressTimers(); } if (parsed) { // 进度事件: 构建 diff session.send({ type: 'data:progress', stage: 'building_diff', message: '生成修改对比...', }); const { after } = buildUpdateFromRefineResult(before, parsed); session.end({ candidateId, before, after, preview: parsed }); } else { // 结构化输出失败,回退到 chat() 重试 session.send({ type: 'data:progress', stage: 'fallback', message: 'AI 正在重新生成...' }); const fullText = await aiProvider.chat(prompt, { temperature: 0.3 }); let fallbackParsed = null; try { const jsonStr = fullText .replace(/^```(?:json)?\s*\n?/m, '') .replace(/\n?```\s*$/m, '') .trim(); fallbackParsed = JSON.parse(jsonStr); } catch { const match = fullText.match(/\{[\s\S]*\}/); if (match) { try { fallbackParsed = JSON.parse(match[0]); } catch { /* ignore */ } } } if (fallbackParsed) { const { after } = buildUpdateFromRefineResult(before, fallbackParsed); session.end({ candidateId, before, after, preview: fallbackParsed }); } else { session.end({ candidateId, before, after: before, preview: null, rawText: fullText }); } } } catch (err) { logger.warn('SSE refine-preview stream error', { error: err.message }); session.error(err.message, 'REFINE_ERROR'); } }); }); /** * GET /api/v1/candidates/refine-preview/events/:sessionId * EventSource SSE 端点 — 消费润色预览进度事件 * * 复用 scan/events 相同的 SSE 交付模式:回放缓冲 → 订阅实时 → 心跳保活 */ router.get('/refine-preview/events/:sessionId', (req, res) => { const session = getStreamSession(req.params.sessionId); if (!session) { res.status(404).json({ success: false, error: 'Session not found or expired' }); return; } // ─── SSE Headers ─── res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); res.setHeader('X-Accel-Buffering', 'no'); res.flushHeaders(); if (res.socket) { res.socket.setNoDelay(true); res.socket.setTimeout(0); } function writeEvent(event) { if (res.writableEnded) { return; } res.write(`data: ${JSON.stringify(event)}\n\n`); } // 1) 回放缓冲区 let isDone = false; for (const event of session.buffer) { writeEvent(event); if (event.type === 'stream:done' || event.type === 'stream:error') { isDone = true; } } if (isDone || session.completed) { res.end(); return; } // 2) 订阅实时事件 const unsubscribe = session.on((event) => { writeEvent(event); if (event.type === 'stream:done' || event.type === 'stream:error') { unsubscribe(); clearInterval(heartbeat); res.end(); } }); // 心跳保活 (每 15 秒) const heartbeat = setInterval(() => { if (res.writableEnded) { clearInterval(heartbeat); return; } res.write(`: ping ${Date.now()}\n\n`); }, 15_000); // 客户端断开连接时清理 res.on('close', () => { unsubscribe(); clearInterval(heartbeat); }); }); /* ═══ 对话式润色 — 应用 ══════════════════════════════════ */ /** * POST /api/v1/candidates/refine-apply * 应用润色预览的结果。优先使用前端传回的 preview 数据(避免重复调 AI), * 若未提供 preview 则 fallback 重新调用 AI。 * Body: { candidateId: string, userPrompt?: string, preview?: object } */ router.post('/refine-apply', validate(RefineApplyBody), async (req, res) => { const { candidateId, userPrompt, preview } = req.body; const container = getServiceContainer(); const knowledgeService = container.get('knowledgeService'); const entry = await knowledgeService.get(candidateId); if (!entry) { throw new ValidationError('Candidate not found'); } const json = typeof entry.toJSON === 'function' ? entry.toJSON() : entry; const before = extractBeforeFields(json); // 优先使用前端传回的 preview(与预览阶段完全一致),否则重新调 AI let parsed = preview || null; if (!parsed) { if (!userPrompt || !userPrompt.trim()) { throw new ValidationError('Either preview or userPrompt is required'); } const aiProvider = container.get('aiProvider'); if (!aiProvider) { throw new ValidationError('AI provider not configured'); } const prompt = buildRefinePrompt(before, userPrompt.trim()); parsed = await aiProvider.chatWithStructuredOutput(prompt, { temperature: 0.3 }); } if (!parsed) { return void res.json({ success: true, data: { refined: 0, total: 1, candidate: json }, }); } const { updateData, changed } = buildUpdateFromRefineResult(before, parsed); if (changed) { // 处理需要嵌套写入的字段 const finalUpdate = { ...updateData }; delete finalUpdate._patternChanged; delete finalUpdate._confidenceChanged; delete finalUpdate._markdownChanged; delete finalUpdate._rationaleChanged; const contentPatch = { ...(json.content || {}) }; let contentChanged = false; if (updateData._patternChanged != null) { contentPatch.pattern = updateData._patternChanged; contentChanged = true; } if (updateData._markdownChanged != null) { contentPatch.markdown = updateData._markdownChanged; contentChanged = true; } if (updateData._rationaleChanged != null) { contentPatch.rationale = updateData._rationaleChanged; contentChanged = true; } if (contentChanged) { finalUpdate.content = contentPatch; } if (updateData._confidenceChanged != null) { finalUpdate.reasoning = { ...(json.reasoning || {}), confidence: updateData._confidenceChanged, }; } await knowledgeService.update(candidateId, finalUpdate, { userId: 'dashboard-refine' }); } // 返回更新后的条目 const updated = changed ? await knowledgeService.get(candidateId) : entry; const updatedJson = typeof updated?.toJSON === 'function' ? updated.toJSON() : updated; res.json({ success: true, data: { refined: changed ? 1 : 0, total: 1, candidate: updatedJson }, }); }); export default router;