UNPKG

autosnippet

Version:

Extract code patterns into a knowledge base for AI coding assistants

232 lines (231 loc) 9.6 kB
/** * AiScanService — `asd ais [Target]` 的核心逻辑 * * 按文件粒度扫描 Target 源码,通过 AgentFactory.scanKnowledge 提取 Recipe, * 创建后自动发布(PENDING → ACTIVE),无需 Dashboard 人工审核。 * * Agent(LLM) 直接分析代码 + 使用 AST 工具,输出 Recipe 结构化 JSON。 * 本服务可脱离 MCP 独立在 CLI 运行。 */ import fs from 'node:fs'; import path from 'node:path'; import Logger from '../infrastructure/logging/Logger.js'; import { LanguageService } from '../shared/LanguageService.js'; export class AiScanService { agentFactory; container; logger; projectRoot; /** * @param opts.container ServiceContainer 实例 * @param opts.projectRoot 项目根目录 */ constructor({ container, projectRoot, }) { this.container = container; this.projectRoot = projectRoot; this.logger = Logger.getInstance(); this.agentFactory = null; } /** * 扫描指定 Target(或全部 Target)的源文件并提取 Recipe,创建后直接发布 * @param targetName Target 名称;null 时扫描全部 * @param opts { maxFiles, dryRun, concurrency } * @returns >} */ async scan(targetName, opts = {}) { const { maxFiles = 200, dryRun = false } = opts; const report = { published: 0, files: 0, errors: [], skipped: 0 }; // 1. 初始化 AgentFactory (内置 AI Provider + ToolExecutionPipeline + 中间件) try { this.agentFactory = this.container.get('agentFactory'); // 通过 AiProviderManager 统一检查 AI 可用性 const manager = this.container.singletons?._aiProviderManager; if (manager.isMock) { throw new Error('AI Provider 为 mock 模式'); } } catch (err) { throw new Error(`AI Provider 不可用: ${err.message}\n请在 .env 中配置 ASD_GOOGLE_API_KEY / ASD_OPENAI_API_KEY 等`); } // 2. 收集源文件 const files = await this._collectFiles(targetName, maxFiles); if (files.length === 0) { report.errors.push(targetName ? `Target "${targetName}" 未找到或无源文件` : '未找到任何 SPM Target 源文件'); return report; } report.files = files.length; const knowledgeService = this.container.get('knowledgeService'); // 3. 按文件调用 AI 提取 (通过 Agent 统一管道) for (const file of files) { try { const content = fs.readFileSync(file.path, 'utf8'); const lines = content.split('\n').length; // 跳过过小的文件(< 10 行) if (lines < 10) { report.skipped++; continue; } // 截断过大的文件(> 500 行只取前 500 行) const truncated = lines > 500 ? `${content.split('\n').slice(0, 500).join('\n')}\n// ... (truncated)` : content; const fileData = [{ name: file.name, content: truncated }]; // 委托 AgentFactory.scanKnowledge — Agent(LLM) 直接分析 const extractResult = await this.agentFactory.scanKnowledge({ label: file.targetName, files: fileData, task: 'extract', }); const recipes = extractResult.recipes || []; if (!Array.isArray(recipes) || recipes.length === 0) { report.skipped++; continue; } // 4. 创建并发布 Recipe // Agent 已完成: 代码分析 + Recipe JSON 输出 // 此处仅补充 AiScanService 专属元数据 for (const recipe of recipes) { if (!recipe.content?.pattern || recipe.content.pattern.length < 20) { continue; } if (dryRun) { report.published++; continue; } try { // AiScanService 专属标记 recipe.source = 'ai-scan'; recipe.tags = [...new Set([...(recipe.tags || []), 'ai-scan', file.targetName])]; recipe.moduleName = file.targetName; // 注意:不设置 sourceFile,由 KnowledgeFileWriter 持久化时自动设置为 md 文件路径 if (!recipe.aiInsight && recipe.description) { recipe.aiInsight = recipe.description; } const saved = await knowledgeService.create(recipe, { userId: 'ai-scan' }); // 直接发布:PENDING → ACTIVE await knowledgeService.publish(saved.id, { userId: 'ai-scan' }); report.published++; } catch (err) { report.errors.push(`${file.name}: recipe publish failed — ${err.message}`); } } } catch (err) { report.errors.push(`${file.name}: ${err.message}`); } } return report; } /** 收集 Target 源文件 */ async _collectFiles(targetName, maxFiles) { const files = []; try { // 使用 ModuleService(多语言统一入口) let service; try { const { ModuleService } = await import('../service/module/ModuleService.js'); service = new ModuleService(this.projectRoot); } catch (e) { this.logger.warn(`[AiScanService] ModuleService 加载失败: ${e.message}`); return files; } await service.load(); const targets = await service.listTargets(); const filtered = targetName ? targets.filter((t) => { const name = typeof t === 'string' ? t : String(t.name ?? ''); return name === targetName || name.toLowerCase() === targetName.toLowerCase(); }) : targets; if (filtered.length === 0 && targetName) { return files; } const seenPaths = new Set(); for (const t of filtered) { const tName = typeof t === 'string' ? t : String(t.name ?? ''); try { const fileList = await service.getTargetFiles(t); for (const f of fileList) { const fp = (typeof f === 'string' ? f : f.path); if (seenPaths.has(fp)) { continue; } seenPaths.add(fp); files.push({ name: f.name || path.basename(fp), path: fp, relativePath: f.relativePath || path.basename(fp), targetName: tName, }); if (files.length >= maxFiles) { break; } } } catch { /* skip target */ } if (files.length >= maxFiles) { break; } } } catch (err) { this.logger.warn(`SPM file collection failed: ${err.message}, falling back to directory scan`); // Fallback: 直接扫描目录 const srcDirs = ['Sources', 'src', 'lib']; for (const dir of srcDirs) { const dirPath = path.join(this.projectRoot, dir); if (fs.existsSync(dirPath)) { this._walkDir(dirPath, files, maxFiles, dir); } } } return files; } /** 递归扫描目录(fallback) */ _walkDir(dir, files, maxFiles, targetName) { if (files.length >= maxFiles) { return; } const sourceExts = LanguageService.sourceExts; const skipDirs = LanguageService.scanSkipDirs; let entries; try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; } for (const entry of entries) { if (files.length >= maxFiles) { return; } if (entry.name.startsWith('.')) { continue; } const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) { if (skipDirs.has(entry.name)) { continue; } this._walkDir(fullPath, files, maxFiles, targetName); } else if (sourceExts.has(path.extname(entry.name).toLowerCase())) { files.push({ name: entry.name, path: fullPath, relativePath: path.relative(this.projectRoot, fullPath), targetName, }); } } } /** 从文件名推断语言 */ _inferLanguage(filename) { return LanguageService.inferLang(filename); } } export default AiScanService;