#feature: AI analysis update

This commit is contained in:
2026-01-15 15:39:49 +08:00
parent ae6c169f5f
commit bbe68839e3
7 changed files with 343 additions and 62 deletions

View File

@@ -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,

View File

@@ -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;
}

View File

@@ -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,
];
}
}

View File

@@ -0,0 +1,196 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Log;
use Symfony\Component\Process\Exception\ProcessFailedException;
use Symfony\Component\Process\Process;
class CodeAnalysisService
{
public const TOOL_CLAUDE = 'claude';
public const TOOL_CODEX = 'codex';
private int $timeout = 300;
public function __construct(
private readonly CodeContextService $codeContextService,
private readonly ConfigService $configService
) {}
/**
* 使用配置的工具在项目中分析日志问题
*
* @param string $appName 应用名称
* @param string $logsContent 日志内容
* @param string|null $aiSummary AI 初步分析摘要
* @return array 分析结果
*/
public function analyze(string $appName, string $logsContent, ?string $aiSummary): array
{
$repoPath = $this->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;
}
}

View File

@@ -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(),
];
}

View File

@@ -356,10 +356,39 @@
<span class="px-2 py-0.5 bg-blue-100 text-blue-700 rounded text-xs">{{ anomaly.classification }}</span>
<span class="text-sm text-gray-500">x{{ anomaly.count }}</span>
</div>
<!-- 原始日志示例 -->
<div v-if="anomaly.sample" class="mb-2 p-2 bg-gray-100 rounded border border-gray-200 overflow-x-auto">
<p class="text-xs text-gray-500 mb-1"><strong>原始日志:</strong></p>
<pre class="text-xs text-gray-700 whitespace-pre-wrap break-all font-mono">{{ anomaly.sample }}</pre>
</div>
<p class="text-sm text-gray-700 mb-1"><strong>可能原因:</strong> {{ anomaly.possible_cause }}</p>
<p class="text-sm text-gray-600"><strong>建议:</strong> {{ anomaly.suggestion }}</p>
</div>
</div>
<!-- 代码深度排查结果 -->
<div v-if="result.code_analysis" class="mt-6 border-t border-gray-200 pt-4">
<h4 class="font-medium text-purple-700 mb-3 flex items-center">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
</svg>
代码深度排查
<span class="ml-2 px-2 py-0.5 bg-purple-100 text-purple-700 rounded text-xs">
{{ result.code_analysis.tool === 'codex' ? 'Codex' : 'Claude CLI' }}
</span>
</h4>
<div v-if="result.code_analysis.success" class="bg-purple-50 p-4 rounded-lg border border-purple-200">
<div class="text-xs text-gray-500 mb-3">
<span class="font-medium">项目路径:</span> {{ result.code_analysis.repo_path }}
</div>
<pre class="whitespace-pre-wrap text-sm text-gray-800 font-mono leading-relaxed">{{ result.code_analysis.output }}</pre>
</div>
<div v-else class="bg-red-50 p-4 rounded-lg border border-red-200">
<p class="text-red-600 text-sm">
<strong>排查失败:</strong> {{ result.code_analysis.error }}
</p>
</div>
</div>
</div>
</div>
</div>

View File

@@ -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 日志并推送到钉钉