|
|
const fs = require('fs');
|
|
|
const path = require('path');
|
|
|
const https = require('https');
|
|
|
const http = require('http');
|
|
|
const crypto = require('crypto');
|
|
|
|
|
|
/**
|
|
|
* AI分析服务
|
|
|
* 用于对代码检查结果进行智能分析、去重、风险评估和生成修改建议
|
|
|
* 支持OpenAI和科大讯飞Spark API
|
|
|
*/
|
|
|
class AIAnalyzer {
|
|
|
constructor() {
|
|
|
// OpenAI配置
|
|
|
this.openaiApiKey = process.env.OPENAI_API_KEY || '';
|
|
|
this.openaiApiBase = process.env.OPENAI_API_BASE || 'https://api.openai.com/v1';
|
|
|
this.openaiModel = process.env.OPENAI_MODEL || 'gpt-3.5-turbo';
|
|
|
|
|
|
// 科大讯飞配置(HTTP API)
|
|
|
// 注意:HTTP API使用APIpassword(Bearer Token),不是apikey和apisecret
|
|
|
// 获取地址:https://console.xfyun.cn/services/bmx1
|
|
|
this.xfApiPassword = process.env.XF_API_PASSWORD || process.env.XF_API_KEY || ''; // HTTP协议的APIpassword
|
|
|
this.xfApiKey = process.env.XF_API_KEY || ''; // 兼容旧配置
|
|
|
this.xfApiSecret = process.env.XF_API_SECRET || ''; // 兼容旧配置(WebSocket使用)
|
|
|
this.xfModel = process.env.XF_MODEL || 'spark-x'; // Spark X1.5 模型,使用spark-x
|
|
|
this.xfApiUrl = process.env.XF_API_URL || 'https://spark-api-open.xf-yun.com/v2/chat/completions';
|
|
|
|
|
|
// 选择使用的API提供商
|
|
|
this.provider = process.env.AI_PROVIDER || 'xf'; // 'openai' 或 'xf'
|
|
|
this.enabled = process.env.AI_ANALYZER_ENABLED !== 'false';
|
|
|
|
|
|
console.log(`[AI分析] 使用提供商: ${this.provider}`);
|
|
|
if (this.provider === 'xf') {
|
|
|
if (this.xfApiPassword) {
|
|
|
console.log(`[AI分析] 科大讯飞 HTTP API Password: ${this.xfApiPassword.substring(0, 10)}... (已配置)`);
|
|
|
console.log(`[AI分析] 使用模型: ${this.xfModel}`);
|
|
|
} else {
|
|
|
console.log(`[AI分析] 科大讯飞 HTTP API Password: 未配置`);
|
|
|
console.log(`[AI分析] 请在控制台获取APIpassword: https://console.xfyun.cn/services/bmx1`);
|
|
|
}
|
|
|
} else if (this.provider === 'openai') {
|
|
|
console.log(`[AI分析] OpenAI API Key: ${this.openaiApiKey ? '已配置' : '未配置'}`);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 分析检查结果
|
|
|
* @param {Array} issues - 检查结果列表
|
|
|
* @param {string} projectPath - 项目路径
|
|
|
* @returns {Promise<Object>} 分析结果
|
|
|
*/
|
|
|
async analyzeIssues(issues, projectPath = null) {
|
|
|
if (!this.enabled) {
|
|
|
console.log('[AI分析] AI分析功能已禁用,使用基础去重');
|
|
|
return this.basicDeduplication(issues);
|
|
|
}
|
|
|
|
|
|
try {
|
|
|
// 先进行基础去重
|
|
|
const deduplicated = this.basicDeduplication(issues);
|
|
|
|
|
|
// 检查是否有可用的API配置
|
|
|
const hasApiConfig = (this.provider === 'openai' && this.openaiApiKey) ||
|
|
|
(this.provider === 'xf' && this.xfApiPassword);
|
|
|
|
|
|
if (hasApiConfig) {
|
|
|
return await this.aiAnalysis(deduplicated.issues, projectPath);
|
|
|
} else {
|
|
|
// 否则使用规则基础分析
|
|
|
console.log('[AI分析] 未配置API密钥,使用规则基础分析');
|
|
|
return this.ruleBasedAnalysis(deduplicated.issues);
|
|
|
}
|
|
|
} catch (error) {
|
|
|
console.error('[AI分析] 分析失败,回退到基础分析:', error);
|
|
|
return this.basicDeduplication(issues);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 基础去重 - 基于文件、行号、规则和消息相似度
|
|
|
*/
|
|
|
basicDeduplication(issues) {
|
|
|
const seen = new Map();
|
|
|
const uniqueIssues = [];
|
|
|
const duplicates = [];
|
|
|
|
|
|
for (const issue of issues) {
|
|
|
// 生成唯一键:文件路径 + 行号 + 规则ID
|
|
|
const key = `${issue.relative_path || issue.file}:${issue.line}:${issue.rule || 'unknown'}`;
|
|
|
|
|
|
if (seen.has(key)) {
|
|
|
// 检查消息相似度
|
|
|
const existing = seen.get(key);
|
|
|
const similarity = this.calculateSimilarity(
|
|
|
issue.message || '',
|
|
|
existing.message || ''
|
|
|
);
|
|
|
|
|
|
if (similarity < 0.7) {
|
|
|
// 消息差异较大,可能是不同的问题
|
|
|
uniqueIssues.push(issue);
|
|
|
} else {
|
|
|
// 相似度高,合并工具来源
|
|
|
if (!existing.detected_by) {
|
|
|
existing.detected_by = [existing.tool || 'unknown'];
|
|
|
}
|
|
|
if (issue.tool && !existing.detected_by.includes(issue.tool)) {
|
|
|
existing.detected_by.push(issue.tool);
|
|
|
}
|
|
|
duplicates.push(issue);
|
|
|
}
|
|
|
} else {
|
|
|
issue.detected_by = issue.tool ? [issue.tool] : ['unknown'];
|
|
|
seen.set(key, issue);
|
|
|
uniqueIssues.push(issue);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
return {
|
|
|
issues: uniqueIssues,
|
|
|
total_issues: uniqueIssues.length,
|
|
|
duplicates_removed: duplicates.length,
|
|
|
deduplication_rate: issues.length > 0
|
|
|
? ((duplicates.length / issues.length) * 100).toFixed(2) + '%'
|
|
|
: '0%'
|
|
|
};
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 计算字符串相似度(简单的Jaccard相似度)
|
|
|
*/
|
|
|
calculateSimilarity(str1, str2) {
|
|
|
if (!str1 || !str2) return 0;
|
|
|
const words1 = new Set(str1.toLowerCase().split(/\s+/));
|
|
|
const words2 = new Set(str2.toLowerCase().split(/\s+/));
|
|
|
const intersection = new Set([...words1].filter(x => words2.has(x)));
|
|
|
const union = new Set([...words1, ...words2]);
|
|
|
return intersection.size / union.size;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 基于规则的分析(不使用AI)
|
|
|
*/
|
|
|
ruleBasedAnalysis(issues) {
|
|
|
const analyzed = issues.map(issue => {
|
|
|
const risk = this.assessRisk(issue);
|
|
|
const suggestion = this.generateSuggestion(issue);
|
|
|
|
|
|
return {
|
|
|
...issue,
|
|
|
risk_level: risk.level,
|
|
|
risk_score: risk.score,
|
|
|
suggestion: suggestion,
|
|
|
ai_analyzed: false
|
|
|
};
|
|
|
});
|
|
|
|
|
|
// 按风险评分排序
|
|
|
analyzed.sort((a, b) => b.risk_score - a.risk_score);
|
|
|
|
|
|
return {
|
|
|
issues: analyzed,
|
|
|
total_issues: analyzed.length,
|
|
|
high_risk_count: analyzed.filter(i => i.risk_level === 'high').length,
|
|
|
medium_risk_count: analyzed.filter(i => i.risk_level === 'medium').length,
|
|
|
low_risk_count: analyzed.filter(i => i.risk_level === 'low').length,
|
|
|
analysis_method: 'rule-based'
|
|
|
};
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 使用AI进行分析
|
|
|
*/
|
|
|
async aiAnalysis(issues, projectPath) {
|
|
|
try {
|
|
|
// 一次性发送所有问题,不再分批处理
|
|
|
console.log(`[AI分析] 开始AI分析,共 ${issues.length} 个问题,一次性处理`);
|
|
|
|
|
|
const analyzedIssues = this.provider === 'xf'
|
|
|
? await this.analyzeBatchXf(issues, projectPath)
|
|
|
: await this.analyzeBatch(issues, projectPath);
|
|
|
|
|
|
// 按风险评分排序
|
|
|
analyzedIssues.sort((a, b) => b.risk_score - a.risk_score);
|
|
|
|
|
|
return {
|
|
|
issues: analyzedIssues,
|
|
|
total_issues: analyzedIssues.length,
|
|
|
high_risk_count: analyzedIssues.filter(i => i.risk_level === 'high').length,
|
|
|
medium_risk_count: analyzedIssues.filter(i => i.risk_level === 'medium').length,
|
|
|
low_risk_count: analyzedIssues.filter(i => i.risk_level === 'low').length,
|
|
|
analysis_method: 'ai-powered'
|
|
|
};
|
|
|
} catch (error) {
|
|
|
console.error('[AI分析] AI分析失败:', error);
|
|
|
// 回退到规则基础分析
|
|
|
return this.ruleBasedAnalysis(issues);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 批量分析问题
|
|
|
*/
|
|
|
async analyzeBatch(issues, projectPath) {
|
|
|
const prompt = await this.buildAnalysisPrompt(issues, projectPath);
|
|
|
|
|
|
try {
|
|
|
const requestData = JSON.stringify({
|
|
|
model: this.openaiModel,
|
|
|
messages: [
|
|
|
{
|
|
|
role: 'system',
|
|
|
content: '你是一个专业的Python代码质量分析专家,擅长分析代码问题,评估风险并提供详细、具体的修改建议。你的建议必须包含问题分析、修复步骤、代码示例和最佳实践。'
|
|
|
},
|
|
|
{
|
|
|
role: 'user',
|
|
|
content: prompt
|
|
|
}
|
|
|
],
|
|
|
temperature: 0.3,
|
|
|
max_tokens: 8000 // 增加token数量以支持一次性处理所有问题和完整代码
|
|
|
});
|
|
|
|
|
|
const url = new URL(`${this.openaiApiBase}/chat/completions`);
|
|
|
const options = {
|
|
|
hostname: url.hostname,
|
|
|
port: url.port || (url.protocol === 'https:' ? 443 : 80),
|
|
|
path: url.pathname,
|
|
|
method: 'POST',
|
|
|
headers: {
|
|
|
'Content-Type': 'application/json',
|
|
|
'Authorization': `Bearer ${this.openaiApiKey}`,
|
|
|
'Content-Length': Buffer.byteLength(requestData)
|
|
|
}
|
|
|
};
|
|
|
|
|
|
const data = await new Promise((resolve, reject) => {
|
|
|
const client = url.protocol === 'https:' ? https : http;
|
|
|
const req = client.request(options, (res) => {
|
|
|
let responseData = '';
|
|
|
res.on('data', (chunk) => {
|
|
|
responseData += chunk;
|
|
|
});
|
|
|
res.on('end', () => {
|
|
|
if (res.statusCode !== 200) {
|
|
|
reject(new Error(`AI API错误: ${res.statusCode} ${res.statusMessage}`));
|
|
|
return;
|
|
|
}
|
|
|
try {
|
|
|
resolve(JSON.parse(responseData));
|
|
|
} catch (e) {
|
|
|
reject(new Error(`解析AI响应失败: ${e.message}`));
|
|
|
}
|
|
|
});
|
|
|
});
|
|
|
req.on('error', reject);
|
|
|
req.write(requestData);
|
|
|
req.end();
|
|
|
});
|
|
|
|
|
|
// 提取AI返回的内容
|
|
|
let aiContent = '';
|
|
|
if (data.choices && data.choices.length > 0) {
|
|
|
aiContent = data.choices[0].message.content || '';
|
|
|
} else if (data.content) {
|
|
|
aiContent = data.content;
|
|
|
} else {
|
|
|
throw new Error('AI响应中未找到内容');
|
|
|
}
|
|
|
|
|
|
console.log(`[AI分析-OpenAI] 收到响应,长度: ${aiContent.length} 字符`);
|
|
|
console.log(`[AI分析-OpenAI] 响应内容预览: ${aiContent.substring(0, 300)}`);
|
|
|
|
|
|
// 解析AI响应中的JSON
|
|
|
let analysisResult;
|
|
|
try {
|
|
|
// 尝试直接解析
|
|
|
analysisResult = JSON.parse(aiContent);
|
|
|
} catch (e) {
|
|
|
// 如果不是纯JSON,尝试提取JSON部分
|
|
|
console.log('[AI分析-OpenAI] 直接解析失败,尝试提取JSON部分');
|
|
|
const jsonMatch = aiContent.match(/\{[\s\S]*\}/);
|
|
|
if (jsonMatch) {
|
|
|
try {
|
|
|
analysisResult = JSON.parse(jsonMatch[0]);
|
|
|
console.log('[AI分析-OpenAI] 成功提取并解析JSON');
|
|
|
} catch (e2) {
|
|
|
console.error('[AI分析-OpenAI] 提取的JSON解析失败:', e2);
|
|
|
console.error('[AI分析-OpenAI] 响应内容:', aiContent.substring(0, 1000));
|
|
|
throw new Error('无法解析AI响应为JSON: ' + e2.message);
|
|
|
}
|
|
|
} else {
|
|
|
console.error('[AI分析-OpenAI] 响应中未找到JSON格式');
|
|
|
console.error('[AI分析-OpenAI] 响应内容:', aiContent.substring(0, 1000));
|
|
|
throw new Error('AI响应中未找到有效的JSON格式');
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// 验证分析结果格式
|
|
|
if (!analysisResult.issues || !Array.isArray(analysisResult.issues)) {
|
|
|
console.warn('[AI分析-OpenAI] AI返回的格式不正确,使用规则基础分析');
|
|
|
console.warn('[AI分析-OpenAI] 返回的数据结构:', JSON.stringify(analysisResult).substring(0, 500));
|
|
|
throw new Error('AI返回格式不正确');
|
|
|
}
|
|
|
|
|
|
console.log(`[AI分析-OpenAI] 成功解析,包含 ${analysisResult.issues.length} 个问题的分析结果`);
|
|
|
|
|
|
// 合并分析结果到原始问题
|
|
|
return issues.map((issue, index) => {
|
|
|
const analysis = analysisResult.issues && analysisResult.issues[index] ? analysisResult.issues[index] : {};
|
|
|
const aiSuggestion = analysis.suggestion || '';
|
|
|
|
|
|
// 验证AI返回的建议是否有效
|
|
|
if (!aiSuggestion || aiSuggestion.trim().length < 20) {
|
|
|
console.warn(`[AI分析-OpenAI] 问题 ${index + 1} 的AI建议过短或为空,使用规则基础建议`);
|
|
|
console.warn(`[AI分析-OpenAI] 原始建议: ${aiSuggestion}`);
|
|
|
} else {
|
|
|
console.log(`[AI分析-OpenAI] 问题 ${index + 1} 获得AI建议,长度: ${aiSuggestion.length} 字符`);
|
|
|
}
|
|
|
|
|
|
return {
|
|
|
...issue,
|
|
|
risk_level: analysis.risk_level || this.assessRisk(issue).level,
|
|
|
risk_score: analysis.risk_score !== undefined ? analysis.risk_score : this.assessRisk(issue).score,
|
|
|
suggestion: (aiSuggestion && aiSuggestion.trim().length >= 20) ? aiSuggestion : this.generateSuggestion(issue),
|
|
|
ai_analyzed: (aiSuggestion && aiSuggestion.trim().length >= 20)
|
|
|
};
|
|
|
});
|
|
|
} catch (error) {
|
|
|
console.error('[AI分析] 批量分析失败:', error);
|
|
|
// 回退到规则基础分析
|
|
|
return issues.map(issue => {
|
|
|
const risk = this.assessRisk(issue);
|
|
|
const suggestion = this.generateSuggestion(issue);
|
|
|
return {
|
|
|
...issue,
|
|
|
risk_level: risk.level,
|
|
|
risk_score: risk.score,
|
|
|
suggestion: suggestion,
|
|
|
ai_analyzed: false
|
|
|
};
|
|
|
});
|
|
|
}
|
|
|
}
|
|
|
|
|
|
|
|
|
/**
|
|
|
* 使用科大讯飞API批量分析问题(HTTP方式)
|
|
|
*/
|
|
|
async analyzeBatchXf(issues, projectPath) {
|
|
|
const prompt = await this.buildAnalysisPrompt(issues, projectPath);
|
|
|
|
|
|
try {
|
|
|
// 构建请求数据 - 使用类似OpenAI的格式
|
|
|
const requestData = JSON.stringify({
|
|
|
model: this.xfModel,
|
|
|
messages: [
|
|
|
{
|
|
|
role: 'system',
|
|
|
content: '你是一个专业的Python代码质量分析专家,擅长分析代码问题,评估风险并提供详细、具体的修改建议。你的建议必须包含问题分析、修复步骤、代码示例和最佳实践。'
|
|
|
},
|
|
|
{
|
|
|
role: 'user',
|
|
|
content: prompt
|
|
|
}
|
|
|
],
|
|
|
temperature: 0.3,
|
|
|
max_tokens: 8000 // 增加token数量以支持一次性处理所有问题和完整代码
|
|
|
});
|
|
|
|
|
|
// 解析API URL
|
|
|
const url = new URL(this.xfApiUrl);
|
|
|
|
|
|
// 根据官方文档,HTTP API使用Bearer Token认证(APIpassword)
|
|
|
// 文档:https://www.xfyun.cn/doc/spark/X1http.html
|
|
|
if (!this.xfApiPassword) {
|
|
|
throw new Error('未配置XF_API_PASSWORD(HTTP协议的APIpassword),请在控制台获取:https://console.xfyun.cn/services/bmx1');
|
|
|
}
|
|
|
|
|
|
const options = {
|
|
|
hostname: url.hostname,
|
|
|
port: url.port || 443,
|
|
|
path: url.pathname,
|
|
|
method: 'POST',
|
|
|
headers: {
|
|
|
'Authorization': `Bearer ${this.xfApiPassword}`, // 使用Bearer Token
|
|
|
'Content-Type': 'application/json',
|
|
|
'Content-Length': Buffer.byteLength(requestData)
|
|
|
}
|
|
|
};
|
|
|
|
|
|
console.log(`[AI分析-讯飞] 发送HTTP请求到: ${this.xfApiUrl}`);
|
|
|
console.log(`[AI分析-讯飞] 使用模型: ${this.xfModel}`);
|
|
|
console.log(`[AI分析-讯飞] 请求头部:`);
|
|
|
console.log(` Authorization: Bearer ${this.xfApiPassword.substring(0, 10)}...`);
|
|
|
console.log(` Content-Type: application/json`);
|
|
|
console.log(` Content-Length: ${Buffer.byteLength(requestData)}`);
|
|
|
|
|
|
const data = await new Promise((resolve, reject) => {
|
|
|
const req = https.request(options, (res) => {
|
|
|
let responseData = '';
|
|
|
res.on('data', (chunk) => {
|
|
|
responseData += chunk;
|
|
|
});
|
|
|
res.on('end', () => {
|
|
|
if (res.statusCode !== 200) {
|
|
|
console.error(`[AI分析-讯飞] HTTP错误: ${res.statusCode} ${res.statusMessage}`);
|
|
|
console.error(`[AI分析-讯飞] 响应内容:`, responseData.substring(0, 500));
|
|
|
reject(new Error(`科大讯飞API错误: ${res.statusCode} ${res.statusMessage}`));
|
|
|
return;
|
|
|
}
|
|
|
try {
|
|
|
resolve(JSON.parse(responseData));
|
|
|
} catch (e) {
|
|
|
console.error('[AI分析-讯飞] 解析响应失败:', e);
|
|
|
console.error('[AI分析-讯飞] 响应内容:', responseData.substring(0, 500));
|
|
|
reject(new Error(`解析AI响应失败: ${e.message}`));
|
|
|
}
|
|
|
});
|
|
|
});
|
|
|
req.on('error', (error) => {
|
|
|
console.error('[AI分析-讯飞] 请求错误:', error);
|
|
|
reject(error);
|
|
|
});
|
|
|
req.write(requestData);
|
|
|
req.end();
|
|
|
});
|
|
|
|
|
|
// 提取AI返回的内容
|
|
|
let aiContent = '';
|
|
|
if (data.choices && data.choices.length > 0) {
|
|
|
aiContent = data.choices[0].message.content || '';
|
|
|
} else if (data.content) {
|
|
|
aiContent = data.content;
|
|
|
} else {
|
|
|
throw new Error('AI响应中未找到内容');
|
|
|
}
|
|
|
|
|
|
console.log(`[AI分析-讯飞] 收到响应,长度: ${aiContent.length} 字符`);
|
|
|
console.log(`[AI分析-讯飞] 响应内容预览: ${aiContent.substring(0, 500)}`);
|
|
|
|
|
|
// 如果响应太长,也打印末尾部分
|
|
|
if (aiContent.length > 1000) {
|
|
|
console.log(`[AI分析-讯飞] 响应内容末尾: ...${aiContent.substring(aiContent.length - 500)}`);
|
|
|
}
|
|
|
|
|
|
// 解析AI响应中的JSON
|
|
|
let analysisResult;
|
|
|
try {
|
|
|
// 尝试直接解析
|
|
|
analysisResult = JSON.parse(aiContent);
|
|
|
console.log('[AI分析-讯飞] 直接解析JSON成功');
|
|
|
} catch (e) {
|
|
|
console.log('[AI分析-讯飞] 直接解析失败,尝试提取JSON部分');
|
|
|
console.log('[AI分析-讯飞] 错误信息:', e.message);
|
|
|
|
|
|
// 方法1: 尝试提取markdown代码块中的JSON
|
|
|
let jsonText = '';
|
|
|
const codeBlockMatch = aiContent.match(/```(?:json)?\s*(\{[\s\S]*?\})\s*```/);
|
|
|
if (codeBlockMatch) {
|
|
|
jsonText = codeBlockMatch[1];
|
|
|
console.log('[AI分析-讯飞] 从markdown代码块中提取JSON');
|
|
|
} else {
|
|
|
// 方法2: 尝试提取第一个完整的JSON对象
|
|
|
const jsonMatch = aiContent.match(/\{[\s\S]*\}/);
|
|
|
if (jsonMatch) {
|
|
|
jsonText = jsonMatch[0];
|
|
|
console.log('[AI分析-讯飞] 从响应中提取JSON对象');
|
|
|
}
|
|
|
}
|
|
|
|
|
|
if (jsonText) {
|
|
|
try {
|
|
|
// 尝试清理JSON文本(移除可能的控制字符)
|
|
|
jsonText = jsonText.replace(/[\x00-\x1F\x7F]/g, '');
|
|
|
analysisResult = JSON.parse(jsonText);
|
|
|
console.log('[AI分析-讯飞] 成功提取并解析JSON');
|
|
|
} catch (e2) {
|
|
|
console.error('[AI分析-讯飞] 提取的JSON解析失败:', e2.message);
|
|
|
console.error('[AI分析-讯飞] 错误位置:', e2.message.match(/position (\d+)/)?.[1] || '未知');
|
|
|
|
|
|
// 尝试修复常见的JSON错误
|
|
|
try {
|
|
|
// 先尝试找到JSON的起始和结束位置
|
|
|
let startPos = jsonText.indexOf('{');
|
|
|
let endPos = jsonText.lastIndexOf('}');
|
|
|
|
|
|
if (startPos >= 0 && endPos > startPos) {
|
|
|
jsonText = jsonText.substring(startPos, endPos + 1);
|
|
|
}
|
|
|
|
|
|
// 尝试修复未转义的引号(在字符串值中)
|
|
|
// 使用更智能的方法:识别JSON结构,只修复字符串值中的引号
|
|
|
// 策略:找到 "key": "value" 模式,修复value中的引号
|
|
|
|
|
|
// 使用状态机方法修复JSON中的未转义引号
|
|
|
// 策略:遍历JSON,识别字符串值,转义其中的未转义引号
|
|
|
let fixedJson = '';
|
|
|
let inString = false;
|
|
|
let escapeNext = false;
|
|
|
|
|
|
for (let i = 0; i < jsonText.length; i++) {
|
|
|
const char = jsonText[i];
|
|
|
|
|
|
if (escapeNext) {
|
|
|
// 当前字符是转义序列的一部分
|
|
|
fixedJson += char;
|
|
|
escapeNext = false;
|
|
|
continue;
|
|
|
}
|
|
|
|
|
|
if (char === '\\') {
|
|
|
// 遇到反斜杠,下一个字符是转义字符
|
|
|
fixedJson += char;
|
|
|
escapeNext = true;
|
|
|
continue;
|
|
|
}
|
|
|
|
|
|
if (char === '"') {
|
|
|
if (!inString) {
|
|
|
// 字符串开始
|
|
|
inString = true;
|
|
|
fixedJson += char;
|
|
|
} else {
|
|
|
// 在字符串中遇到引号
|
|
|
// 检查后面的字符,判断这是字符串结束还是字符串值中的引号
|
|
|
let j = i + 1;
|
|
|
// 跳过空白字符
|
|
|
while (j < jsonText.length && /\s/.test(jsonText[j])) {
|
|
|
j++;
|
|
|
}
|
|
|
|
|
|
if (j >= jsonText.length) {
|
|
|
// 到达末尾,这是字符串结束
|
|
|
inString = false;
|
|
|
fixedJson += char;
|
|
|
} else {
|
|
|
const nextNonSpace = jsonText[j];
|
|
|
// 如果下一个非空白字符是:或,或}或],说明这是字符串结束
|
|
|
if (nextNonSpace === ':' || nextNonSpace === ',' || nextNonSpace === '}' || nextNonSpace === ']') {
|
|
|
inString = false;
|
|
|
fixedJson += char;
|
|
|
} else {
|
|
|
// 这是字符串值中的引号,需要转义
|
|
|
fixedJson += '\\"';
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
} else {
|
|
|
fixedJson += char;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
jsonText = fixedJson;
|
|
|
|
|
|
// 尝试解析修复后的JSON
|
|
|
analysisResult = JSON.parse(jsonText);
|
|
|
console.log('[AI分析-讯飞] 修复后成功解析JSON');
|
|
|
} catch (e3) {
|
|
|
console.error('[AI分析-讯飞] 修复后仍然失败:', e3.message);
|
|
|
|
|
|
// 打印错误位置的上下文
|
|
|
const errorPos = parseInt(e3.message.match(/position (\d+)/)?.[1] || '0');
|
|
|
if (errorPos > 0) {
|
|
|
const start = Math.max(0, errorPos - 100);
|
|
|
const end = Math.min(jsonText.length, errorPos + 100);
|
|
|
console.error('[AI分析-讯飞] 错误位置上下文:', jsonText.substring(start, end));
|
|
|
console.error('[AI分析-讯飞] 错误位置标记:', ' '.repeat(Math.min(100, errorPos - start)) + '^');
|
|
|
}
|
|
|
|
|
|
console.error('[AI分析-讯飞] 尝试解析的JSON文本(前1000字符):', jsonText.substring(0, 1000));
|
|
|
if (jsonText.length > 1000) {
|
|
|
console.error('[AI分析-讯飞] 尝试解析的JSON文本(后1000字符):', jsonText.substring(Math.max(0, jsonText.length - 1000)));
|
|
|
}
|
|
|
|
|
|
// 最后尝试:使用eval(不安全,但作为最后的尝试)
|
|
|
// 实际上,我们应该避免使用eval,而是抛出错误让用户知道
|
|
|
throw new Error(`无法解析AI响应为JSON: ${e2.message}。错误位置: ${errorPos}。请检查AI返回的JSON格式是否正确。`);
|
|
|
}
|
|
|
}
|
|
|
} else {
|
|
|
console.error('[AI分析-讯飞] 响应中未找到JSON格式');
|
|
|
console.error('[AI分析-讯飞] 完整响应内容:', aiContent);
|
|
|
throw new Error('AI响应中未找到有效的JSON格式');
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// 验证分析结果格式
|
|
|
if (!analysisResult.issues || !Array.isArray(analysisResult.issues)) {
|
|
|
console.warn('[AI分析-讯飞] AI返回的格式不正确,使用规则基础分析');
|
|
|
console.warn('[AI分析-讯飞] 返回的数据结构:', JSON.stringify(analysisResult).substring(0, 500));
|
|
|
throw new Error('AI返回格式不正确');
|
|
|
}
|
|
|
|
|
|
console.log(`[AI分析-讯飞] 成功解析,包含 ${analysisResult.issues.length} 个问题的分析结果`);
|
|
|
console.log(`[AI分析-讯飞] 原始问题数量: ${issues.length}`);
|
|
|
|
|
|
// 检查返回的issues数组长度
|
|
|
if (analysisResult.issues.length < issues.length) {
|
|
|
console.warn(`[AI分析-讯飞] AI返回的issues数组长度不足: ${analysisResult.issues.length} < ${issues.length}`);
|
|
|
console.warn(`[AI分析-讯飞] 将使用规则基础分析补充缺失的问题`);
|
|
|
|
|
|
// 打印AI返回的每个问题的建议长度,用于调试
|
|
|
analysisResult.issues.forEach((item, idx) => {
|
|
|
const suggestion = item.suggestion || '';
|
|
|
console.log(`[AI分析-讯飞] 问题 ${idx + 1}: suggestion长度=${suggestion.length}, risk_level=${item.risk_level}, risk_score=${item.risk_score}`);
|
|
|
});
|
|
|
}
|
|
|
|
|
|
// 合并分析结果到原始问题
|
|
|
return issues.map((issue, index) => {
|
|
|
const analysis = analysisResult.issues && analysisResult.issues[index] ? analysisResult.issues[index] : {};
|
|
|
const aiSuggestion = analysis.suggestion || '';
|
|
|
|
|
|
// 如果AI返回的数组长度不够,或者建议为空/过短,使用规则基础建议
|
|
|
if (index >= analysisResult.issues.length) {
|
|
|
console.warn(`[AI分析-讯飞] 问题 ${index + 1} 超出AI返回范围,使用规则基础建议`);
|
|
|
const risk = this.assessRisk(issue);
|
|
|
const suggestion = this.generateSuggestion(issue);
|
|
|
return {
|
|
|
...issue,
|
|
|
risk_level: risk.level,
|
|
|
risk_score: risk.score,
|
|
|
suggestion: suggestion,
|
|
|
ai_analyzed: false
|
|
|
};
|
|
|
}
|
|
|
|
|
|
// 验证AI返回的建议是否有效
|
|
|
if (!aiSuggestion || aiSuggestion.trim().length < 20) {
|
|
|
console.warn(`[AI分析-讯飞] 问题 ${index + 1} 的AI建议过短或为空,使用规则基础建议`);
|
|
|
console.warn(`[AI分析-讯飞] 原始建议: "${aiSuggestion}" (长度: ${aiSuggestion.length})`);
|
|
|
const risk = this.assessRisk(issue);
|
|
|
const suggestion = this.generateSuggestion(issue);
|
|
|
return {
|
|
|
...issue,
|
|
|
risk_level: analysis.risk_level || risk.level,
|
|
|
risk_score: analysis.risk_score !== undefined ? analysis.risk_score : risk.score,
|
|
|
suggestion: suggestion,
|
|
|
ai_analyzed: false
|
|
|
};
|
|
|
} else {
|
|
|
console.log(`[AI分析-讯飞] 问题 ${index + 1} 获得AI建议,长度: ${aiSuggestion.length} 字符`);
|
|
|
return {
|
|
|
...issue,
|
|
|
risk_level: analysis.risk_level || this.assessRisk(issue).level,
|
|
|
risk_score: analysis.risk_score !== undefined ? analysis.risk_score : this.assessRisk(issue).score,
|
|
|
suggestion: aiSuggestion,
|
|
|
ai_analyzed: true
|
|
|
};
|
|
|
}
|
|
|
});
|
|
|
} catch (error) {
|
|
|
console.error('[AI分析-讯飞] 批量分析失败:', error);
|
|
|
// 回退到规则基础分析
|
|
|
return issues.map(issue => {
|
|
|
const risk = this.assessRisk(issue);
|
|
|
const suggestion = this.generateSuggestion(issue);
|
|
|
return {
|
|
|
...issue,
|
|
|
risk_level: risk.level,
|
|
|
risk_score: risk.score,
|
|
|
suggestion: suggestion,
|
|
|
ai_analyzed: false
|
|
|
};
|
|
|
});
|
|
|
}
|
|
|
}
|
|
|
|
|
|
|
|
|
/**
|
|
|
* 构建AI分析提示词
|
|
|
* 优化:一次性处理所有问题,按文件分组,读取完整文件内容
|
|
|
*/
|
|
|
async buildAnalysisPrompt(issues, projectPath) {
|
|
|
// 按文件分组问题
|
|
|
const issuesByFile = new Map();
|
|
|
for (const issue of issues) {
|
|
|
const filePath = issue.relative_path || issue.file;
|
|
|
if (!issuesByFile.has(filePath)) {
|
|
|
issuesByFile.set(filePath, []);
|
|
|
}
|
|
|
issuesByFile.get(filePath).push(issue);
|
|
|
}
|
|
|
|
|
|
console.log(`[AI分析] 问题分布在 ${issuesByFile.size} 个文件中`);
|
|
|
|
|
|
// 读取每个文件的完整内容
|
|
|
const filesData = new Map();
|
|
|
for (const [filePath, fileIssues] of issuesByFile.entries()) {
|
|
|
try {
|
|
|
if (filePath && projectPath) {
|
|
|
const fullPath = path.join(projectPath, filePath);
|
|
|
if (fs.existsSync(fullPath)) {
|
|
|
const content = fs.readFileSync(fullPath, 'utf8');
|
|
|
filesData.set(filePath, {
|
|
|
content: content,
|
|
|
issues: fileIssues,
|
|
|
lineCount: content.split('\n').length
|
|
|
});
|
|
|
console.log(`[AI分析] 读取文件: ${filePath} (${content.split('\n').length} 行)`);
|
|
|
}
|
|
|
}
|
|
|
} catch (error) {
|
|
|
console.warn(`[AI分析] 无法读取文件 ${filePath}: ${error.message}`);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// 构建问题列表(包含完整文件内容)
|
|
|
let issuesText = '';
|
|
|
let issueIndex = 1;
|
|
|
|
|
|
for (const [filePath, fileData] of filesData.entries()) {
|
|
|
issuesText += `\n## 文件: ${filePath}\n`;
|
|
|
issuesText += `完整代码(共 ${fileData.lineCount} 行):\n\`\`\`python\n${fileData.content}\n\`\`\`\n\n`;
|
|
|
issuesText += `该文件中的问题:\n`;
|
|
|
|
|
|
for (const issue of fileData.issues) {
|
|
|
issuesText += `${issueIndex}. 行号: ${issue.line}, 列号: ${issue.column || 0}\n`;
|
|
|
issuesText += ` 规则: ${issue.rule || '未知'}\n`;
|
|
|
issuesText += ` 消息: ${issue.message || '无'}\n`;
|
|
|
issuesText += ` 类型: ${issue.type || 'unknown'}\n`;
|
|
|
issuesText += ` 严重程度: ${issue.severity || 'medium'}\n`;
|
|
|
issuesText += ` 检测工具: ${(issue.detected_by || []).join(', ')}\n\n`;
|
|
|
issueIndex++;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// 对于没有文件内容的问题,单独列出
|
|
|
for (const issue of issues) {
|
|
|
const filePath = issue.relative_path || issue.file;
|
|
|
if (!filesData.has(filePath)) {
|
|
|
issuesText += `${issueIndex}. 文件: ${filePath}\n`;
|
|
|
issuesText += ` 行号: ${issue.line}\n`;
|
|
|
issuesText += ` 规则: ${issue.rule || '未知'}\n`;
|
|
|
issuesText += ` 消息: ${issue.message || '无'}\n\n`;
|
|
|
issueIndex++;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
return `你是一个专业的Python代码质量分析专家。请分析以下代码检查问题,并提供统一的修复建议。
|
|
|
|
|
|
**重要任务:**
|
|
|
1. **合并重复问题**:首先识别并合并重复或相似的问题(不同工具可能检测到相同的问题)
|
|
|
2. **统一分析**:对所有问题(包括合并后的)进行统一分析
|
|
|
3. **提供详细建议**:为每个问题提供详细的修复建议
|
|
|
|
|
|
**分析要求:**
|
|
|
1. **风险等级**(high/medium/low):根据问题的严重程度和影响范围评估
|
|
|
2. **风险评分**(0-100):数值越高表示风险越大,需要优先处理
|
|
|
3. **修改建议**:必须提供详细且具体的修复建议,包括:
|
|
|
- 问题原因分析
|
|
|
- 具体的修复步骤
|
|
|
- 修复前后的代码示例对比
|
|
|
- 最佳实践建议
|
|
|
- 避免类似问题的建议
|
|
|
|
|
|
**重要要求:**
|
|
|
- **必须为所有问题提供建议**:返回的issues数组长度必须与原始问题数量完全一致(共 ${issues.length} 个问题)
|
|
|
- **每个问题都必须有suggestion字段**:suggestion字段不能为空,必须包含至少50字的详细建议
|
|
|
- **按顺序对应**:issues数组中的第1个元素对应第1个问题,第2个元素对应第2个问题,以此类推
|
|
|
- **如果问题已合并**:在对应位置返回合并后的分析结果,但必须确保数组长度不变
|
|
|
|
|
|
**注意:**
|
|
|
- 请先合并重复问题,然后对所有问题(包括合并后的)进行分析
|
|
|
- 根据完整代码上下文给出针对性建议
|
|
|
- 建议要具体、可操作,避免空泛的描述
|
|
|
- **绝对不能省略任何问题**:即使问题相似或重复,也必须为每个问题提供独立的分析结果
|
|
|
|
|
|
问题列表(包含完整代码):
|
|
|
${issuesText}
|
|
|
|
|
|
**请严格按照以下JSON格式返回分析结果,必须确保:**
|
|
|
1. issues数组长度 = ${issues.length}(与原始问题数量完全一致)
|
|
|
2. 每个issue对象都包含完整的suggestion字段(至少50字)
|
|
|
3. 所有字段都必须有值,不能为空
|
|
|
4. **只返回纯JSON,不要包含任何markdown代码块标记(不要使用\`\`\`json\`\`\`包裹)**
|
|
|
5. **JSON中的字符串值必须正确转义**:换行符使用\\n,引号使用\\",反斜杠使用\\\\
|
|
|
|
|
|
**返回格式(纯JSON,不要任何其他文本):**
|
|
|
{
|
|
|
"issues": [
|
|
|
{
|
|
|
"risk_level": "high|medium|low",
|
|
|
"risk_score": 0-100,
|
|
|
"suggestion": "详细的问题分析、修复步骤、代码示例和最佳实践建议(至少50字,不能为空,字符串中的特殊字符必须正确转义)"
|
|
|
}
|
|
|
]
|
|
|
}
|
|
|
|
|
|
**重要提示:**
|
|
|
- 只返回JSON对象,不要包含任何解释文字
|
|
|
- 不要使用markdown代码块包裹JSON
|
|
|
- JSON字符串中的换行符、引号等特殊字符必须正确转义
|
|
|
- 必须返回 ${issues.length} 个issues,每个都必须有完整的suggestion!**`;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 评估风险(规则基础)
|
|
|
*/
|
|
|
assessRisk(issue) {
|
|
|
let score = 0;
|
|
|
let level = 'low';
|
|
|
|
|
|
// 基于类型
|
|
|
if (issue.type === 'error') score += 50;
|
|
|
else if (issue.type === 'warning') score += 30;
|
|
|
else score += 10;
|
|
|
|
|
|
// 基于严重程度
|
|
|
if (issue.severity === 'high') score += 40;
|
|
|
else if (issue.severity === 'medium') score += 20;
|
|
|
else score += 10;
|
|
|
|
|
|
// 基于规则类型(安全相关规则风险更高)
|
|
|
const securityKeywords = ['security', 'injection', 'xss', 'csrf', 'sql', 'password', 'secret', 'key', 'token'];
|
|
|
const ruleLower = (issue.rule || '').toLowerCase();
|
|
|
const messageLower = (issue.message || '').toLowerCase();
|
|
|
|
|
|
if (securityKeywords.some(keyword =>
|
|
|
ruleLower.includes(keyword) || messageLower.includes(keyword)
|
|
|
)) {
|
|
|
score += 30;
|
|
|
}
|
|
|
|
|
|
// 确定风险等级
|
|
|
if (score >= 70) level = 'high';
|
|
|
else if (score >= 40) level = 'medium';
|
|
|
else level = 'low';
|
|
|
|
|
|
return { level, score };
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 生成修改建议(规则基础)
|
|
|
*/
|
|
|
generateSuggestion(issue) {
|
|
|
const rule = issue.rule || '';
|
|
|
const message = issue.message || '';
|
|
|
const ruleLower = rule.toLowerCase();
|
|
|
const messageLower = message.toLowerCase();
|
|
|
|
|
|
// 基于规则类型生成建议
|
|
|
if (ruleLower.includes('import') || messageLower.includes('import')) {
|
|
|
return '检查导入语句,确保导入的模块存在且路径正确。考虑使用相对导入或检查PYTHONPATH设置。';
|
|
|
}
|
|
|
|
|
|
if (ruleLower.includes('unused') || messageLower.includes('unused')) {
|
|
|
return '删除未使用的变量、函数或导入。如果确实需要保留,可以在变量名前加下划线(如 _unused_var)。';
|
|
|
}
|
|
|
|
|
|
if (ruleLower.includes('naming') || messageLower.includes('naming')) {
|
|
|
return '遵循PEP 8命名规范:类名使用驼峰命名(CamelCase),函数和变量使用下划线命名(snake_case)。';
|
|
|
}
|
|
|
|
|
|
if (ruleLower.includes('security') || messageLower.includes('security') ||
|
|
|
ruleLower.includes('bandit')) {
|
|
|
return '这是一个安全问题。请仔细检查代码,确保没有安全漏洞。考虑使用安全的替代方案,如使用参数化查询而不是字符串拼接。';
|
|
|
}
|
|
|
|
|
|
if (ruleLower.includes('complexity') || messageLower.includes('complexity')) {
|
|
|
return '代码复杂度过高。考虑将函数拆分为更小的函数,提高代码可读性和可维护性。';
|
|
|
}
|
|
|
|
|
|
if (messageLower.includes('line too long')) {
|
|
|
return '行长度超过限制。将长行拆分为多行,或使用括号、反斜杠进行换行。';
|
|
|
}
|
|
|
|
|
|
if (messageLower.includes('missing docstring')) {
|
|
|
return '添加函数或类的文档字符串(docstring),说明其功能、参数和返回值。';
|
|
|
}
|
|
|
|
|
|
// 默认建议
|
|
|
return `根据规则"${rule}"的建议:${message}。请参考相关代码规范文档进行修改。`;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
module.exports = new AIAnalyzer();
|
|
|
|