From bbe68839e304abb0d4b457e7fccae7103dbd95e2 Mon Sep 17 00:00:00 2001 From: tradewind Date: Thu, 15 Jan 2026 15:39:49 +0800 Subject: [PATCH] #feature: AI analysis update --- app/Clients/AiClient.php | 99 +++++++-- app/Clients/SlsClient.php | 3 +- app/Jobs/LogAnalysisJob.php | 30 +-- app/Services/CodeAnalysisService.php | 196 ++++++++++++++++++ app/Services/LogAnalysisService.php | 46 ++-- .../components/log-analysis/LogAnalysis.vue | 29 +++ routes/console.php | 2 - 7 files changed, 343 insertions(+), 62 deletions(-) create mode 100644 app/Services/CodeAnalysisService.php diff --git a/app/Clients/AiClient.php b/app/Clients/AiClient.php index 34dd19d..850c7a3 100644 --- a/app/Clients/AiClient.php +++ b/app/Clients/AiClient.php @@ -104,9 +104,10 @@ class AiClient * * @param array $messages 消息数组 * @param string|null $systemPrompt 系统提示词 + * @param int $maxRetries 最大重试次数 * @return string AI 响应内容 */ - public function chat(array $messages, ?string $systemPrompt = null): string + public function chat(array $messages, ?string $systemPrompt = null, int $maxRetries = 3): string { $provider = $this->getActiveProvider(); if (!$provider) { @@ -124,24 +125,45 @@ class AiClient $endpoint = rtrim($provider['endpoint'], '/') . '/chat/completions'; - $response = Http::timeout($provider['timeout'] ?? 120) - ->withHeaders([ - 'Authorization' => 'Bearer ' . $provider['api_key'], - 'Content-Type' => 'application/json', - ]) - ->post($endpoint, [ - 'model' => $provider['model'], - 'messages' => $allMessages, - 'temperature' => $provider['temperature'] ?? 0.3, - 'max_tokens' => $provider['max_tokens'] ?? 4096, - ]); + $lastException = null; + for ($attempt = 1; $attempt <= $maxRetries; $attempt++) { + $response = Http::timeout($provider['timeout'] ?? 120) + ->withHeaders([ + 'Authorization' => 'Bearer ' . $provider['api_key'], + 'Content-Type' => 'application/json', + ]) + ->post($endpoint, [ + 'model' => $provider['model'], + 'messages' => $allMessages, + 'temperature' => $provider['temperature'] ?? 0.3, + 'max_tokens' => $provider['max_tokens'] ?? 4096, + ]); + + if ($response->successful()) { + return $response->json('choices.0.message.content', ''); + } + + // 处理 429 Too Many Requests 错误 + if ($response->status() === 429) { + $retryAfter = $response->header('Retry-After'); + $waitSeconds = $retryAfter ? (int) $retryAfter : min(pow(2, $attempt) * 5, 60); + + if ($attempt < $maxRetries) { + sleep($waitSeconds); + continue; + } + } - if (!$response->successful()) { $error = $response->json('error.message') ?? $response->body(); - throw new RuntimeException("AI 请求失败: {$error}"); + $lastException = new RuntimeException("AI 请求失败: {$error}"); + + // 对于非 429 错误,不重试 + if ($response->status() !== 429) { + throw $lastException; + } } - return $response->json('choices.0.message.content', ''); + throw $lastException ?? new RuntimeException('AI 请求失败: 未知错误'); } /** @@ -200,7 +222,7 @@ class AiClient return <<<'PROMPT' 你是一个专业的日志分析专家。你的任务是分析提供的日志内容,识别异常和问题,并给出分类和建议。 -请按照以下 JSON 格式返回分析结果: +请严格按照以下 JSON 格式返回分析结果,不要添加任何额外的文字说明或代码块标记: { "core_anomalies": [ { @@ -222,7 +244,7 @@ class AiClient - classification: database(数据库), network(网络), application(应用逻辑), configuration(配置), resource(资源), other(其他) - impact: high(高影响), medium(中等影响), low(低影响) -请确保返回有效的 JSON 格式。 +重要:只返回纯 JSON,不要使用 markdown 代码块(```json)包裹。 PROMPT; } @@ -239,16 +261,47 @@ PROMPT; private function parseAnalysisResponse(string $response): array { - // 尝试提取 JSON - if (preg_match('/\{[\s\S]*}/', $response, $matches)) { - $json = $matches[0]; - $decoded = json_decode($json, true); - if (json_last_error() === JSON_ERROR_NONE) { + $jsonContent = $response; + + // 1. 首先尝试从 markdown 代码块中提取 JSON + // 匹配 ```json ... ``` 或 ``` ... ``` 格式(使用贪婪匹配获取最后一个代码块) + if (preg_match_all('/```(?:json)?\s*([\s\S]*?)```/', $response, $matches)) { + // 尝试每个匹配的代码块,找到有效的 JSON + foreach ($matches[1] as $match) { + $trimmed = trim($match); + $decoded = json_decode($trimmed, true); + if (json_last_error() === JSON_ERROR_NONE && is_array($decoded) && isset($decoded['core_anomalies'])) { + return $decoded; + } + } + // 如果没有找到有效的 JSON,使用第一个匹配 + $jsonContent = trim($matches[1][0]); + } + + // 2. 尝试直接解析(可能已经是纯 JSON) + $decoded = json_decode($jsonContent, true); + if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) { + return $decoded; + } + + // 3. 尝试从内容中提取 JSON 对象(处理前后有其他文本的情况) + // 使用更精确的匹配:找到包含 core_anomalies 的 JSON 对象 + if (preg_match('/\{[^{}]*"core_anomalies"[\s\S]*}/', $response, $matches)) { + $decoded = json_decode($matches[0], true); + if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) { return $decoded; } } - // 如果无法解析 JSON,返回原始响应 + // 4. 最后尝试匹配任意 JSON 对象 + if (preg_match('/\{[\s\S]*}/', $jsonContent, $matches)) { + $decoded = json_decode($matches[0], true); + if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) { + return $decoded; + } + } + + // 5. 如果无法解析 JSON,返回原始响应 return [ 'core_anomalies' => [], 'summary' => $response, diff --git a/app/Clients/SlsClient.php b/app/Clients/SlsClient.php index 7518efe..316975e 100644 --- a/app/Clients/SlsClient.php +++ b/app/Clients/SlsClient.php @@ -207,7 +207,8 @@ class SlsClient $allLogs = array_merge($allLogs, $result['logs']); - if ($result['complete'] || count($result['logs']) < $batchSize) { + // 如果返回的日志数量小于批次大小,说明没有更多数据了 + if (count($result['logs']) < $batchSize) { break; } diff --git a/app/Jobs/LogAnalysisJob.php b/app/Jobs/LogAnalysisJob.php index a391b32..e2c5503 100644 --- a/app/Jobs/LogAnalysisJob.php +++ b/app/Jobs/LogAnalysisJob.php @@ -5,6 +5,7 @@ namespace App\Jobs; use App\Enums\AnalysisMode; use App\Models\LogAnalysisReport; use App\Services\AiService; +use App\Services\CodeAnalysisService; use App\Services\CodeContextService; use App\Services\ConfigService; use App\Services\DingTalkService; @@ -38,6 +39,7 @@ class LogAnalysisJob implements ShouldQueue SlsService $slsService, AiService $aiService, CodeContextService $codeContextService, + CodeAnalysisService $codeAnalysisService, ConfigService $configService, DingTalkService $dingTalkService ): void { @@ -85,28 +87,30 @@ class LogAnalysisJob implements ShouldQueue $appLogsCollection = $appLogsCollection->take($maxLogsPerApp); } - // 获取代码上下文(如果需要) - $codeContext = null; - if ($this->mode === AnalysisMode::LogsWithCode) { - $repoPath = $codeContextService->getRepoPath($appName); - if ($repoPath) { - $codeContext = $codeContextService->extractRelevantCode($repoPath, $appLogsCollection); - } - } - // 准备日志内容 $logsContent = $this->formatLogsForAnalysis($appLogsCollection); - // AI 分析 + // AI 分析(不再携带代码上下文) try { - $results[$appName] = $aiService->analyzeLogs($logsContent, $codeContext); + $results[$appName] = $aiService->analyzeLogs($logsContent, null); $results[$appName]['log_count'] = $appLogsCollection->count(); - $results[$appName]['has_code_context'] = $codeContext !== null; + + // 如果是 logs+code 模式,且 impact 为 high/medium,触发代码分析 + if ($this->mode === AnalysisMode::LogsWithCode) { + $impact = $results[$appName]['impact'] ?? 'unknown'; + if (in_array($impact, ['high', 'medium'])) { + $codeAnalysisResult = $codeAnalysisService->analyze( + $appName, + $logsContent, + $results[$appName]['summary'] ?? null + ); + $results[$appName]['code_analysis'] = $codeAnalysisResult; + } + } } catch (\Exception $e) { $results[$appName] = [ 'error' => $e->getMessage(), 'log_count' => $appLogsCollection->count(), - 'has_code_context' => false, ]; } } diff --git a/app/Services/CodeAnalysisService.php b/app/Services/CodeAnalysisService.php new file mode 100644 index 0000000..367b671 --- /dev/null +++ b/app/Services/CodeAnalysisService.php @@ -0,0 +1,196 @@ +codeContextService->getRepoPath($appName); + + if (!$repoPath) { + return [ + 'success' => false, + 'error' => "未配置项目路径: {$appName}", + ]; + } + + $tool = $this->getConfiguredTool(); + + try { + $prompt = $this->buildPrompt($logsContent, $aiSummary); + $output = $this->runTool($tool, $repoPath, $prompt); + + return [ + 'success' => true, + 'output' => $output, + 'repo_path' => $repoPath, + 'tool' => $tool, + ]; + } catch (ProcessFailedException $e) { + Log::error("{$tool} execution failed", [ + 'app_name' => $appName, + 'repo_path' => $repoPath, + 'tool' => $tool, + 'error' => $e->getMessage(), + ]); + + return [ + 'success' => false, + 'error' => "{$tool} 执行失败: " . $e->getProcess()->getErrorOutput(), + 'tool' => $tool, + ]; + } catch (\Exception $e) { + Log::error("Code analysis failed", [ + 'app_name' => $appName, + 'repo_path' => $repoPath, + 'tool' => $tool, + 'error' => $e->getMessage(), + ]); + + return [ + 'success' => false, + 'error' => $e->getMessage(), + 'tool' => $tool, + ]; + } + } + + /** + * 获取配置的分析工具 + */ + public function getConfiguredTool(): string + { + $tool = $this->configService->get('log_analysis.code_analysis_tool', self::TOOL_CLAUDE); + + if (!in_array($tool, [self::TOOL_CLAUDE, self::TOOL_CODEX])) { + return self::TOOL_CLAUDE; + } + + return $tool; + } + + /** + * 设置分析工具 + */ + public function setTool(string $tool): void + { + if (!in_array($tool, [self::TOOL_CLAUDE, self::TOOL_CODEX])) { + throw new \InvalidArgumentException("不支持的工具: {$tool}"); + } + + $this->configService->set( + 'log_analysis.code_analysis_tool', + $tool, + '代码分析工具 (claude/codex)' + ); + } + + /** + * 获取可用的工具列表 + */ + public function getAvailableTools(): array + { + return [ + self::TOOL_CLAUDE => [ + 'name' => 'Claude CLI', + 'description' => 'Anthropic Claude 命令行工具', + ], + self::TOOL_CODEX => [ + 'name' => 'Codex CLI', + 'description' => 'OpenAI Codex 命令行工具', + ], + ]; + } + + /** + * 构建提示词 + */ + private function buildPrompt(string $logsContent, ?string $aiSummary): string + { + $prompt = "分析以下错误日志,在代码库中排查根本原因并给出具体优化方案:\n\n"; + $prompt .= "=== 日志内容 ===\n{$logsContent}\n\n"; + + if ($aiSummary) { + $prompt .= "=== AI 初步分析 ===\n{$aiSummary}\n\n"; + } + + $prompt .= "请:\n"; + $prompt .= "1. 定位相关代码文件\n"; + $prompt .= "2. 分析根本原因\n"; + $prompt .= "3. 给出具体修复方案\n"; + + return $prompt; + } + + /** + * 执行分析工具 + */ + private function runTool(string $tool, string $workingDirectory, string $prompt): string + { + return match ($tool) { + self::TOOL_CODEX => $this->runCodex($workingDirectory, $prompt), + default => $this->runClaude($workingDirectory, $prompt), + }; + } + + /** + * 执行 Claude CLI 命令 + */ + private function runClaude(string $workingDirectory, string $prompt): string + { + $process = new Process( + ['claude', '--print', $prompt], + $workingDirectory + ); + $process->setTimeout($this->timeout); + $process->mustRun(); + + return trim($process->getOutput()); + } + + /** + * 执行 Codex CLI 命令 + */ + private function runCodex(string $workingDirectory, string $prompt): string + { + $process = new Process( + ['codex', '--quiet', '--full-auto', $prompt], + $workingDirectory + ); + $process->setTimeout($this->timeout); + $process->mustRun(); + + return trim($process->getOutput()); + } + + /** + * 设置超时时间 + */ + public function setTimeout(int $timeout): void + { + $this->timeout = $timeout; + } +} diff --git a/app/Services/LogAnalysisService.php b/app/Services/LogAnalysisService.php index 663d5a5..72d617f 100644 --- a/app/Services/LogAnalysisService.php +++ b/app/Services/LogAnalysisService.php @@ -13,8 +13,7 @@ class LogAnalysisService public function __construct( private readonly SlsService $slsService, private readonly AiService $aiService, - private readonly CodeContextService $codeContextService, - private readonly ConfigService $configService, + private readonly CodeAnalysisService $codeAnalysisService, private readonly DingTalkService $dingTalkService ) {} @@ -97,39 +96,34 @@ class LogAnalysisService // 3. 分析每个分组 $results = []; - $settings = $this->configService->get('log_analysis.settings', []); - $maxLogsPerApp = $settings['max_logs_per_app'] ?? 500; foreach ($grouped as $appName => $appLogs) { $appLogsCollection = collect($appLogs); - // 限制每个 app 的日志数量 - if ($appLogsCollection->count() > $maxLogsPerApp) { - $appLogsCollection = $appLogsCollection->take($maxLogsPerApp); - } - - // 获取代码上下文(如果需要) - $codeContext = null; - if ($mode === AnalysisMode::LogsWithCode) { - $repoPath = $this->codeContextService->getRepoPath($appName); - if ($repoPath) { - $codeContext = $this->codeContextService->extractRelevantCode($repoPath, $appLogsCollection); - } - } - // 准备日志内容 $logsContent = $this->formatLogsForAnalysis($appLogsCollection); // AI 分析 try { - $results[$appName] = $this->aiService->analyzeLogs($logsContent, $codeContext); + $results[$appName] = $this->aiService->analyzeLogs($logsContent, null); $results[$appName]['log_count'] = $appLogsCollection->count(); - $results[$appName]['has_code_context'] = $codeContext !== null; + + // 如果是 logs+code 模式,且 impact 为 high/medium,触发代码分析 + if ($mode === AnalysisMode::LogsWithCode) { + $impact = $results[$appName]['impact'] ?? 'unknown'; + if (in_array($impact, ['high', 'medium'])) { + $codeAnalysisResult = $this->codeAnalysisService->analyze( + $appName, + $logsContent, + $results[$appName]['summary'] ?? null + ); + $results[$appName]['code_analysis'] = $codeAnalysisResult; + } + } } catch (\Exception $e) { $results[$appName] = [ 'error' => $e->getMessage(), 'log_count' => $appLogsCollection->count(), - 'has_code_context' => false, ]; } } @@ -180,11 +174,14 @@ class LogAnalysisService $grouped = $this->slsService->groupByAppName($logs); $statistics = $this->slsService->getStatistics($logs); + // 使用传入的 limit 参数,如果未指定则默认显示全部 + $displayLimit = $limit ?? $logs->count(); + return [ 'total' => $logs->count(), 'statistics' => $statistics, 'grouped' => array_map(fn($g) => count($g), $grouped), - 'logs' => $logs->take(100)->values()->toArray(), + 'logs' => $logs->take($displayLimit)->values()->toArray(), ]; } @@ -228,11 +225,14 @@ class LogAnalysisService } } + // 使用传入的 limit 参数,如果未指定则默认显示全部 + $displayLimit = $limit ?? $allLogs->count(); + return [ 'total' => $statistics['total'], 'statistics' => $statistics, 'grouped_by_logstore' => $groupedByLogstore, - 'logs' => $allLogs->take(100)->values()->toArray(), + 'logs' => $allLogs->take($displayLimit)->values()->toArray(), ]; } diff --git a/resources/js/components/log-analysis/LogAnalysis.vue b/resources/js/components/log-analysis/LogAnalysis.vue index b64cf9d..32a7429 100644 --- a/resources/js/components/log-analysis/LogAnalysis.vue +++ b/resources/js/components/log-analysis/LogAnalysis.vue @@ -356,10 +356,39 @@ {{ anomaly.classification }} x{{ anomaly.count }} + +
+

原始日志:

+
{{ anomaly.sample }}
+

可能原因: {{ anomaly.possible_cause }}

建议: {{ anomaly.suggestion }}

+ + +
+

+ + + + 代码深度排查 + + {{ result.code_analysis.tool === 'codex' ? 'Codex' : 'Claude CLI' }} + +

+
+
+ 项目路径: {{ result.code_analysis.repo_path }} +
+
{{ result.code_analysis.output }}
+
+
+

+ 排查失败: {{ result.code_analysis.error }} +

+
+
diff --git a/routes/console.php b/routes/console.php index 1cdb7c9..183651e 100644 --- a/routes/console.php +++ b/routes/console.php @@ -36,7 +36,6 @@ Schedule::command('git-monitor:cache') // SLS 日志分析定时任务 - 每天凌晨 2 点执行 // 分析过去 24 小时的 ERROR 和 WARNING 日志并推送到钉钉 // 可通过数据库配置 log_analysis.settings.daily_schedule_enabled 控制是否启用 -/* Schedule::command('log-analysis:run --from="-24h" --to="now" --query="ERROR or WARNING" --push') ->dailyAt('02:00') ->withoutOverlapping() @@ -53,7 +52,6 @@ Schedule::command('log-analysis:run --from="-24h" --to="now" --query="ERROR or W ->onFailure(function () { Log::error('每日日志分析定时任务执行失败'); }); -*/ // SLS 日志分析定时任务 - 每 4 小时执行一次 // 分析过去 6 小时的 ERROR 和 WARNING 日志并推送到钉钉