#feature: update AI log analysis
This commit is contained in:
@@ -8,6 +8,7 @@ use Symfony\Component\Process\Process;
|
||||
|
||||
class CodeAnalysisService
|
||||
{
|
||||
public const TOOL_GEMINI = 'gemini';
|
||||
public const TOOL_CLAUDE = 'claude';
|
||||
public const TOOL_CODEX = 'codex';
|
||||
|
||||
@@ -22,11 +23,10 @@ class CodeAnalysisService
|
||||
* 使用配置的工具在项目中分析日志问题
|
||||
*
|
||||
* @param string $appName 应用名称
|
||||
* @param string $logsContent 日志内容
|
||||
* @param string|null $aiSummary AI 初步分析摘要
|
||||
* @param array $aiAnalysisResult AI 分析结果(包含 summary, impact, core_anomalies 等)
|
||||
* @return array 分析结果
|
||||
*/
|
||||
public function analyze(string $appName, string $logsContent, ?string $aiSummary): array
|
||||
public function analyze(string $appName, array $aiAnalysisResult): array
|
||||
{
|
||||
$repoPath = $this->codeContextService->getRepoPath($appName);
|
||||
|
||||
@@ -40,7 +40,7 @@ class CodeAnalysisService
|
||||
$tool = $this->getConfiguredTool();
|
||||
|
||||
try {
|
||||
$prompt = $this->buildPrompt($logsContent, $aiSummary);
|
||||
$prompt = $this->buildPrompt($aiAnalysisResult);
|
||||
$output = $this->runTool($tool, $repoPath, $prompt);
|
||||
|
||||
return [
|
||||
@@ -83,10 +83,10 @@ class CodeAnalysisService
|
||||
*/
|
||||
public function getConfiguredTool(): string
|
||||
{
|
||||
$tool = $this->configService->get('log_analysis.code_analysis_tool', self::TOOL_CLAUDE);
|
||||
$tool = $this->configService->get('log_analysis.code_analysis_tool', self::TOOL_GEMINI);
|
||||
|
||||
if (!in_array($tool, [self::TOOL_CLAUDE, self::TOOL_CODEX])) {
|
||||
return self::TOOL_CLAUDE;
|
||||
if (!in_array($tool, [self::TOOL_GEMINI, self::TOOL_CLAUDE, self::TOOL_CODEX])) {
|
||||
return self::TOOL_GEMINI;
|
||||
}
|
||||
|
||||
return $tool;
|
||||
@@ -97,14 +97,14 @@ class CodeAnalysisService
|
||||
*/
|
||||
public function setTool(string $tool): void
|
||||
{
|
||||
if (!in_array($tool, [self::TOOL_CLAUDE, self::TOOL_CODEX])) {
|
||||
if (!in_array($tool, [self::TOOL_GEMINI, self::TOOL_CLAUDE, self::TOOL_CODEX])) {
|
||||
throw new \InvalidArgumentException("不支持的工具: {$tool}");
|
||||
}
|
||||
|
||||
$this->configService->set(
|
||||
'log_analysis.code_analysis_tool',
|
||||
$tool,
|
||||
'代码分析工具 (claude/codex)'
|
||||
'代码分析工具 (gemini/claude/codex)'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -114,6 +114,10 @@ class CodeAnalysisService
|
||||
public function getAvailableTools(): array
|
||||
{
|
||||
return [
|
||||
self::TOOL_GEMINI => [
|
||||
'name' => 'Gemini CLI',
|
||||
'description' => 'Google Gemini 命令行工具',
|
||||
],
|
||||
self::TOOL_CLAUDE => [
|
||||
'name' => 'Claude CLI',
|
||||
'description' => 'Anthropic Claude 命令行工具',
|
||||
@@ -126,21 +130,52 @@ class CodeAnalysisService
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建提示词
|
||||
* 构建提示词(基于 AI 分析汇总结果)
|
||||
*/
|
||||
private function buildPrompt(string $logsContent, ?string $aiSummary): string
|
||||
private function buildPrompt(array $aiAnalysisResult): string
|
||||
{
|
||||
$prompt = "分析以下错误日志,在代码库中排查根本原因并给出具体优化方案:\n\n";
|
||||
$prompt .= "=== 日志内容 ===\n{$logsContent}\n\n";
|
||||
$prompt = "根据以下日志分析结果,在代码库中排查根本原因并给出具体优化方案:\n\n";
|
||||
|
||||
if ($aiSummary) {
|
||||
$prompt .= "=== AI 初步分析 ===\n{$aiSummary}\n\n";
|
||||
// 影响级别
|
||||
$impact = $aiAnalysisResult['impact'] ?? 'unknown';
|
||||
$prompt .= "=== 影响级别 ===\n{$impact}\n\n";
|
||||
|
||||
// AI 摘要
|
||||
$summary = $aiAnalysisResult['summary'] ?? '';
|
||||
if ($summary) {
|
||||
$prompt .= "=== 问题摘要 ===\n{$summary}\n\n";
|
||||
}
|
||||
|
||||
// 核心异常列表
|
||||
$anomalies = $aiAnalysisResult['core_anomalies'] ?? [];
|
||||
if (!empty($anomalies)) {
|
||||
$prompt .= "=== 异常列表 ===\n";
|
||||
foreach ($anomalies as $idx => $anomaly) {
|
||||
$num = $idx + 1;
|
||||
$type = $anomaly['type'] ?? 'unknown';
|
||||
$classification = $anomaly['classification'] ?? '';
|
||||
$count = $anomaly['count'] ?? 1;
|
||||
$cause = $anomaly['possible_cause'] ?? '';
|
||||
$sample = $anomaly['sample'] ?? '';
|
||||
|
||||
$prompt .= "{$num}. [{$type}] {$classification} (出现 {$count} 次)\n";
|
||||
if ($cause) {
|
||||
$prompt .= " 可能原因: {$cause}\n";
|
||||
}
|
||||
if ($sample) {
|
||||
// 限制样本长度,避免过长
|
||||
$sampleTruncated = mb_strlen($sample) > 500 ? mb_substr($sample, 0, 500) . '...' : $sample;
|
||||
$prompt .= " 日志样本: {$sampleTruncated}\n";
|
||||
}
|
||||
}
|
||||
$prompt .= "\n";
|
||||
}
|
||||
|
||||
$prompt .= "请:\n";
|
||||
$prompt .= "1. 定位相关代码文件\n";
|
||||
$prompt .= "2. 分析根本原因\n";
|
||||
$prompt .= "3. 给出具体修复方案\n";
|
||||
$prompt .= "\n注意:仅进行分析和提供建议,不要修改任何代码文件。\n";
|
||||
|
||||
return $prompt;
|
||||
}
|
||||
@@ -151,24 +186,60 @@ class CodeAnalysisService
|
||||
private function runTool(string $tool, string $workingDirectory, string $prompt): string
|
||||
{
|
||||
return match ($tool) {
|
||||
self::TOOL_CLAUDE => $this->runClaude($workingDirectory, $prompt),
|
||||
self::TOOL_CODEX => $this->runCodex($workingDirectory, $prompt),
|
||||
default => $this->runClaude($workingDirectory, $prompt),
|
||||
default => $this->runGemini($workingDirectory, $prompt),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行 Gemini CLI 命令
|
||||
*/
|
||||
private function runGemini(string $workingDirectory, string $prompt): string
|
||||
{
|
||||
$process = new Process(
|
||||
['gemini', '--approval-mode', 'plan', '-o', 'json', $prompt],
|
||||
$workingDirectory,
|
||||
$this->getEnvWithPath()
|
||||
);
|
||||
$process->setTimeout($this->timeout);
|
||||
$process->mustRun();
|
||||
|
||||
$output = trim($process->getOutput());
|
||||
|
||||
// 解析 JSON 格式输出,提取完整的分析结果
|
||||
$json = json_decode($output, true);
|
||||
if ($json && isset($json['response'])) {
|
||||
return $json['response'];
|
||||
}
|
||||
|
||||
// 如果解析失败,返回原始输出
|
||||
return $output;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行 Claude CLI 命令
|
||||
*/
|
||||
private function runClaude(string $workingDirectory, string $prompt): string
|
||||
{
|
||||
$process = new Process(
|
||||
['claude', '--print', $prompt],
|
||||
$workingDirectory
|
||||
['claude', '--print', '--output-format', 'json', $prompt],
|
||||
$workingDirectory,
|
||||
$this->getEnvWithPath()
|
||||
);
|
||||
$process->setTimeout($this->timeout);
|
||||
$process->mustRun();
|
||||
|
||||
return trim($process->getOutput());
|
||||
$output = trim($process->getOutput());
|
||||
|
||||
// 解析 JSON 格式输出,提取完整的分析结果
|
||||
$json = json_decode($output, true);
|
||||
if ($json && isset($json['result'])) {
|
||||
return $json['result'];
|
||||
}
|
||||
|
||||
// 如果解析失败,返回原始输出
|
||||
return $output;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -176,16 +247,100 @@ class CodeAnalysisService
|
||||
*/
|
||||
private function runCodex(string $workingDirectory, string $prompt): string
|
||||
{
|
||||
// 使用临时文件保存最终消息,避免输出被截断
|
||||
$outputFile = sys_get_temp_dir() . '/codex_output_' . uniqid() . '.txt';
|
||||
|
||||
$process = new Process(
|
||||
['codex', '--quiet', '--full-auto', $prompt],
|
||||
$workingDirectory
|
||||
['codex', 'exec', '--sandbox', 'read-only', '-o', $outputFile, $prompt],
|
||||
$workingDirectory,
|
||||
$this->getEnvWithPath()
|
||||
);
|
||||
$process->setTimeout($this->timeout);
|
||||
$process->mustRun();
|
||||
|
||||
// 从输出文件读取完整结果
|
||||
if (file_exists($outputFile)) {
|
||||
$output = trim(file_get_contents($outputFile));
|
||||
@unlink($outputFile);
|
||||
return $output;
|
||||
}
|
||||
|
||||
// 如果文件不存在,回退到标准输出
|
||||
return trim($process->getOutput());
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取包含用户 PATH 的环境变量
|
||||
* 确保 nvm、npm 全局安装的命令可以被找到
|
||||
*/
|
||||
private function getEnvWithPath(): array
|
||||
{
|
||||
$env = getenv();
|
||||
$homeDir = getenv('HOME') ?: '/home/' . get_current_user();
|
||||
|
||||
// 添加常见的用户级 bin 目录到 PATH
|
||||
$additionalPaths = [
|
||||
"{$homeDir}/.local/bin",
|
||||
"{$homeDir}/.npm-global/bin",
|
||||
'/usr/local/bin',
|
||||
];
|
||||
|
||||
// 查找 nvm 当前使用的 Node.js 版本的 bin 目录
|
||||
$nvmDir = "{$homeDir}/.nvm/versions/node";
|
||||
if (is_dir($nvmDir)) {
|
||||
// 获取最新版本的 Node.js(按版本号排序)
|
||||
$versions = @scandir($nvmDir);
|
||||
if ($versions) {
|
||||
$versions = array_filter($versions, fn($v) => $v !== '.' && $v !== '..');
|
||||
if (!empty($versions)) {
|
||||
usort($versions, 'version_compare');
|
||||
$latestVersion = end($versions);
|
||||
$additionalPaths[] = "{$nvmDir}/{$latestVersion}/bin";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$currentPath = $env['PATH'] ?? '/usr/bin:/bin';
|
||||
$env['PATH'] = implode(':', $additionalPaths) . ':' . $currentPath;
|
||||
|
||||
// 添加 Gemini API Key(用于非交互式模式)
|
||||
$geminiApiKey = $this->configService->get('log_analysis.gemini_api_key') ?: config('services.gemini.api_key');
|
||||
if ($geminiApiKey) {
|
||||
$env['GEMINI_API_KEY'] = $geminiApiKey;
|
||||
}
|
||||
|
||||
// 确保代理环境变量被传递(后台任务可能没有继承)
|
||||
$proxyUrl = config('services.proxy.url');
|
||||
if ($proxyUrl) {
|
||||
$env['HTTP_PROXY'] = $proxyUrl;
|
||||
$env['HTTPS_PROXY'] = $proxyUrl;
|
||||
$env['http_proxy'] = $proxyUrl;
|
||||
$env['https_proxy'] = $proxyUrl;
|
||||
}
|
||||
|
||||
return $env;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 Gemini API Key
|
||||
*/
|
||||
public function setGeminiApiKey(string $apiKey): void
|
||||
{
|
||||
$this->configService->set(
|
||||
'log_analysis.gemini_api_key',
|
||||
$apiKey,
|
||||
'Gemini CLI API Key (用于非交互式模式)'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Gemini API Key
|
||||
*/
|
||||
public function getGeminiApiKey(): ?string
|
||||
{
|
||||
return $this->configService->get('log_analysis.gemini_api_key') ?: config('services.gemini.api_key');
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置超时时间
|
||||
*/
|
||||
|
||||
@@ -132,10 +132,19 @@ class JiraService
|
||||
throw new \InvalidArgumentException('用户名不能为空');
|
||||
}
|
||||
|
||||
// 定义已完成的状态列表(需要排除的状态)
|
||||
$completedStatuses = [
|
||||
'Done',
|
||||
'已上线',
|
||||
'已完成',
|
||||
];
|
||||
$statusExclusion = implode('", "', $completedStatuses);
|
||||
|
||||
// 查询分配给用户且未完成的需求(Story/需求类型,不包括子任务)
|
||||
$jql = sprintf(
|
||||
'assignee = "%s" AND status != "Done" AND issuetype in ("Story", "需求") ORDER BY created ASC',
|
||||
$username
|
||||
'assignee = "%s" AND status NOT IN ("%s") AND issuetype in ("Story", "需求") ORDER BY created ASC',
|
||||
$username,
|
||||
$statusExclusion
|
||||
);
|
||||
|
||||
try {
|
||||
@@ -203,6 +212,25 @@ class JiraService
|
||||
}
|
||||
}
|
||||
|
||||
// 没有Sprint的需求
|
||||
if ($organizedTasks->has('stories') && $organizedTasks['stories']->isNotEmpty()) {
|
||||
$markdown .= "### 需求\n";
|
||||
foreach ($organizedTasks['stories'] as $task) {
|
||||
$checkbox = $this->isTaskCompleted($task['status']) ? '[x]' : '[ ]';
|
||||
$markdown .= "- {$checkbox} [{$task['key']}]({$task['url']}) {$task['summary']}\n";
|
||||
|
||||
if ($task['subtasks']->isNotEmpty()) {
|
||||
// 按创建时间排序子任务
|
||||
$sortedSubtasks = $task['subtasks']->sortBy('created');
|
||||
foreach ($sortedSubtasks as $subtask) {
|
||||
$subtaskCheckbox = $this->isTaskCompleted($subtask['status']) ? '[x]' : '[ ]';
|
||||
$markdown .= " - {$subtaskCheckbox} [{$subtask['key']}]({$subtask['url']}) {$subtask['summary']}\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
$markdown .= "\n";
|
||||
}
|
||||
|
||||
// 单独列出的任务
|
||||
if ($organizedTasks->has('tasks') && $organizedTasks['tasks']->isNotEmpty()) {
|
||||
$markdown .= "### 任务\n";
|
||||
@@ -675,9 +703,10 @@ class JiraService
|
||||
*/
|
||||
private function isTaskCompleted(string $status): bool
|
||||
{
|
||||
// 定义"进行中"的状态列表
|
||||
// 定义"未完成"的状态列表(包括未开始和进行中)
|
||||
// 如果状态不在这个列表中,则认为任务已完成
|
||||
$inProgressStatuses = [
|
||||
$incompleteStatuses = [
|
||||
'未开始',
|
||||
'需求已确认',
|
||||
'开发中',
|
||||
'需求调研中',
|
||||
@@ -688,8 +717,8 @@ class JiraService
|
||||
'需求设计中',
|
||||
];
|
||||
|
||||
// 如果状态不在"进行中"列表中,则标记为已完成
|
||||
return !in_array($status, $inProgressStatuses, true);
|
||||
// 如果状态不在"未完成"列表中,则标记为已完成
|
||||
return !in_array($status, $incompleteStatuses, true);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -699,6 +728,7 @@ class JiraService
|
||||
{
|
||||
$organized = collect([
|
||||
'sprints' => collect(),
|
||||
'stories' => collect(), // 没有Sprint的需求
|
||||
'tasks' => collect(),
|
||||
'bugs' => collect(),
|
||||
]);
|
||||
@@ -779,8 +809,25 @@ class JiraService
|
||||
}
|
||||
|
||||
$this->addTaskToSprintOrTaskList($organized['sprints'][$sprintName], $workLog);
|
||||
} elseif ($isStory && !$workLog['sprint']) {
|
||||
// Story类型但没有Sprint的,放入需求分类
|
||||
$this->addTaskToSprintOrTaskList($organized['stories'], $workLog);
|
||||
} elseif ($isSubtask && !$workLog['sprint'] && $workLog['parent_task']) {
|
||||
// 子任务没有Sprint,检查父任务类型来决定分类
|
||||
$parentKey = $workLog['parent_task']['key'];
|
||||
$parentDetails = $this->getIssueDetails($parentKey);
|
||||
$parentType = $parentDetails ? ($parentDetails->fields->issuetype->name ?? '') : '';
|
||||
$isParentStory = in_array($parentType, ['Story', 'story', '需求']);
|
||||
|
||||
if ($isParentStory) {
|
||||
// 父任务是Story,放入需求分类
|
||||
$this->addTaskToSprintOrTaskList($organized['stories'], $workLog);
|
||||
} else {
|
||||
// 父任务不是Story,放入任务分类
|
||||
$this->addTaskToSprintOrTaskList($organized['tasks'], $workLog);
|
||||
}
|
||||
} else {
|
||||
// 其他任务单独列出(非Story/子任务类型或没有Sprint的)
|
||||
// 其他任务单独列出(非Story/子任务类型)
|
||||
$this->addTaskToSprintOrTaskList($organized['tasks'], $workLog);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,10 +34,10 @@ class LogAnalysisService
|
||||
AnalysisMode $mode = AnalysisMode::Logs,
|
||||
bool $pushNotification = false
|
||||
): LogAnalysisReport {
|
||||
// 如果没有指定查询条件,默认只获取 ERROR 和 WARNING 级别的日志
|
||||
// 如果没有指定查询条件,默认只获取 ERROR 级别的日志
|
||||
$effectiveQuery = $query;
|
||||
if (empty($query)) {
|
||||
$effectiveQuery = 'ERROR or WARNING';
|
||||
$effectiveQuery = 'content.level: ERROR';
|
||||
}
|
||||
|
||||
// 创建 pending 状态的报告
|
||||
@@ -114,8 +114,7 @@ class LogAnalysisService
|
||||
if (in_array($impact, ['high', 'medium'])) {
|
||||
$codeAnalysisResult = $this->codeAnalysisService->analyze(
|
||||
$appName,
|
||||
$logsContent,
|
||||
$results[$appName]['summary'] ?? null
|
||||
$results[$appName]
|
||||
);
|
||||
$results[$appName]['code_analysis'] = $codeAnalysisResult;
|
||||
}
|
||||
|
||||
@@ -2,18 +2,18 @@
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Clients\AgentClient;
|
||||
use App\Clients\MonoClient;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Collection;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class MessageSyncService
|
||||
{
|
||||
private AgentClient $agentClient;
|
||||
private MonoClient $monoClient;
|
||||
|
||||
public function __construct(AgentClient $agentClient)
|
||||
public function __construct(MonoClient $monoClient)
|
||||
{
|
||||
$this->agentClient = $agentClient;
|
||||
$this->monoClient = $monoClient;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -57,80 +57,51 @@ class MessageSyncService
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量同步消息到agent
|
||||
* 批量同步消息(通过mono消费)
|
||||
*/
|
||||
public function syncMessages(array $messageIds): array
|
||||
{
|
||||
$messages = $this->getMessagesByIds($messageIds);
|
||||
$results = [];
|
||||
|
||||
foreach ($messages as $message) {
|
||||
$result = $this->syncSingleMessage($message);
|
||||
$results[] = [
|
||||
'msg_id' => $message['msg_id'],
|
||||
'success' => $result['success'],
|
||||
'response' => $result['response'] ?? null,
|
||||
'error' => $result['error'] ?? null,
|
||||
'request_data' => $result['request_data'] ?? null,
|
||||
];
|
||||
foreach ($messageIds as $msgId) {
|
||||
$results[] = $this->syncSingleMessage($msgId);
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步单个消息到agent
|
||||
* 通过mono消费单个消息
|
||||
*/
|
||||
private function syncSingleMessage(array $message): array
|
||||
private function syncSingleMessage(string $msgId): array
|
||||
{
|
||||
try {
|
||||
$requestData = $this->buildAgentRequest($message);
|
||||
$response = $this->monoClient->consumeMessage($msgId);
|
||||
$body = $response->json();
|
||||
|
||||
$response = $this->agentClient->dispatchMessage($requestData);
|
||||
|
||||
if ($response->successful()) {
|
||||
if ($response->successful() && ($body['code'] ?? -1) === 0) {
|
||||
return [
|
||||
'msg_id' => $msgId,
|
||||
'success' => true,
|
||||
'response' => $response->json(),
|
||||
'request_data' => $requestData,
|
||||
'response' => $body,
|
||||
];
|
||||
} else {
|
||||
return [
|
||||
'msg_id' => $msgId,
|
||||
'success' => false,
|
||||
'error' => 'HTTP ' . $response->status() . ': ' . $response->body(),
|
||||
'request_data' => $requestData,
|
||||
'error' => $body['message'] ?? ('HTTP ' . $response->status() . ': ' . $response->body()),
|
||||
'response' => $body,
|
||||
];
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
return [
|
||||
'msg_id' => $msgId,
|
||||
'success' => false,
|
||||
'error' => '请求失败: ' . $e->getMessage(),
|
||||
'request_data' => $requestData ?? null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建agent接口请求数据
|
||||
*/
|
||||
private function buildAgentRequest(array $message): array
|
||||
{
|
||||
$parsedParam = $message['parsed_param'];
|
||||
$parsedProperty = $message['parsed_property'];
|
||||
|
||||
return [
|
||||
'topic_name' => $message['event_type'],
|
||||
'msg_body' => [
|
||||
'id' => $message['msg_id'],
|
||||
'data' => $parsedParam,
|
||||
'timestamp' => $message['timestamp'],
|
||||
'property' => $parsedProperty,
|
||||
],
|
||||
'target_service' => [1], // 默认目标服务
|
||||
'trace_id' => $message['trace_id'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析JSON字段
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user