// Package calls provides functionality. package calls import ( "bytes" "context" "encoding/base64" "encoding/json" "errors" "fmt" "io" "log" "os" "strings" "time" "github.com/chromedp/cdproto/network" "github.com/chromedp/cdproto/runtime" "github.com/chromedp/chromedp" "github.com/google/uuid" "github.com/minio/minio-go/v7" "github.com/openai/openai-go/v3" "github.com/openai/openai-go/v3/option" "rocketgit.ru/rsmon/worker/app/models" "rocketgit.ru/rsmon/worker/internal/checkresult" "rocketgit.ru/rsmon/worker/storage" ) const ( verdictError = "error" verdictWarning = "warning" verdictNormal = "normal" ) // Result is a result of an AI health check type Result struct { checkresult.CheckResult // Captured data Screenshot string `json:"screenshot"` // base64 encoded screenshot DOM string `json:"dom"` // HTML content NetworkLogs string `json:"network_logs"` // JSON array of network logs ConsoleLogs string `json:"console_logs"` // JSON array of console logs URL string `json:"url"` // Final URL after redirects StatusCode int `json:"status_code"` // HTTP status code Title string `json:"title"` // Page title // LLM analysis LLMResponse string `json:"llm_response"` // Full LLM response LLMVerdict string `json:"llm_verdict"` // "normal", "error", "warning" LLMReasoning string `json:"llm_reasoning"` // LLM's explanation HasChanges bool `json:"has_changes"` // Whether changes were detected from previous // Previous screenshot comparison PreviousURL string `json:"previous_url"` // URL of previous screenshot (if any) PreviousTime string `json:"previous_time"` // Timestamp of previous screenshot // Metadata CheckID uint `json:"check_id"` // For database reference ScreenshotID uint `json:"screenshot_id"` // ID of stored screenshot } // NetworkLog represents a network request log type NetworkLog struct { URL string `json:"url"` Method string `json:"method"` StatusCode int `json:"status_code"` Type string `json:"type"` Size int64 `json:"size"` Time float64 `json:"time"` } // ConsoleLog represents a console log entry type ConsoleLog struct { Type string `json:"type"` Value string `json:"value"` } // ScreenshotRecord represents a stored screenshot in the database type ScreenshotRecord struct { ID uint `gorm:"primarykey"` CreatedAt time.Time `json:"created_at"` CheckID uint `json:"check_id"` MonitorID uint `json:"monitor_id"` URL string `json:"url"` // ScreenshotPath stores the S3 object path (e.g., "screenshots/monitor_123/2025-01-15_123456.jpg") ScreenshotPath string `json:"screenshot_path" gorm:"type:varchar(500)"` DOM string `gorm:"type:longtext" json:"dom"` NetworkLogs string `gorm:"type:longtext" json:"network_logs"` ConsoleLogs string `gorm:"type:longtext" json:"console_logs"` StatusCode int `json:"status_code"` Title string `json:"title"` LLMVerdict string `json:"llm_verdict"` LLMReasoning string `json:"llm_reasoning"` } // Perform executes the AI health check func Perform(c *models.Check) *Result { result := &Result{ CheckID: uint(c.ID), } result.Warnings = []string{} result.Infos = []string{} start := time.Now() // Get the target URL targetURL := getTargetURL(c) result.URL = targetURL // Step 1: Capture screenshot, DOM, and logs screenshotData, domData, networkLogs, consoleLogs, statusCode, title, err := captureWebData(targetURL) if err != nil { result.Error = fmt.Errorf("failed to capture web data: %w", err) result.Duration = time.Since(start) result.State = "FAIL" return result } result.Screenshot = screenshotData result.DOM = domData result.NetworkLogs = networkLogs result.ConsoleLogs = consoleLogs result.StatusCode = statusCode result.Title = title // Step 2: Store current screenshot in database (if available) var screenshotID uint if isDBAvailable() { screenshotID, err = storeScreenshot(uint(c.MonitorID), uint(c.ID), result) if err != nil { result.Warnings = append(result.Warnings, "Failed to store screenshot: "+err.Error()) log.Printf("[ai-check] Failed to store screenshot: %v", err) } else { result.ScreenshotID = screenshotID result.Infos = append(result.Infos, "Screenshot stored for reference") } } else { result.Infos = append(result.Infos, "Database not available - running in standalone mode") } // Step 3: Get previous screenshots for comparison (if DB available) var previousScreenshots []ScreenshotRecord if isDBAvailable() { previousScreenshots, err = getPreviousScreenshots(uint(c.MonitorID), 3) if err != nil { result.Warnings = append(result.Warnings, "Could not retrieve previous screenshots: "+err.Error()) } } if len(previousScreenshots) > 0 { result.PreviousURL = previousScreenshots[0].URL result.PreviousTime = previousScreenshots[0].CreatedAt.Format(time.RFC3339) result.Infos = append(result.Infos, fmt.Sprintf("Comparing with %d previous screenshot(s)", len(previousScreenshots))) } // Step 4: Analyze with LLM llmVerdict, llmReasoning, hasChanges, err := analyzeWithLLM(targetURL, result, previousScreenshots) if err != nil { result.Error = fmt.Errorf("LLM analysis failed: %w", err) result.LLMResponse = err.Error() result.Duration = time.Since(start) result.State = "FAIL" return result } result.LLMVerdict = llmVerdict result.LLMReasoning = llmReasoning result.HasChanges = hasChanges // Update the screenshot record with LLM analysis if screenshotID > 0 { _ = updateScreenshotWithLLM(screenshotID, llmVerdict, llmReasoning) } // Determine final state based on LLM verdict switch llmVerdict { case verdictError: result.State = "ERR" result.Error = errors.New(llmReasoning) case verdictWarning: result.State = "WARN" result.Warnings = append(result.Warnings, llmReasoning) default: result.State = "OK" if hasChanges { result.Infos = append(result.Infos, "Visual changes detected from previous screenshot") } } result.Duration = time.Since(start) return result } // getTargetURL constructs the target URL from the check func getTargetURL(c *models.Check) string { if c.URL != nil && *c.URL != "" { return *c.URL } // Construct from monitor host host := c.Monitor.Host if !strings.HasPrefix(host, "http://") && !strings.HasPrefix(host, "https://") { return "https://" + host } return host } // captureWebData captures screenshot, DOM, network and console logs using chromedp func captureWebData(targetURL string) (screenshot, dom, networkLogsJSON, consoleLogsJSON string, statusCode int, title string, err error) { // Create a context with timeout ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() // Create chromedp context with options for headless chrome allocCtx, allocCancel := chromedp.NewExecAllocator(ctx, chromedp.NoDefaultBrowserCheck, chromedp.NoFirstRun, chromedp.DisableGPU, chromedp.IgnoreCertErrors, chromedp.Flag("headless", "new"), chromedp.Flag("no-sandbox", true), chromedp.Flag("disable-dev-shm-usage", true), chromedp.Flag("hide-scrollbars", true), chromedp.Flag("mute-audio", true), chromedp.WindowSize(1920, 1080), ) defer allocCancel() taskCtx, taskCancel := chromedp.NewContext(allocCtx) defer taskCancel() // Collect network logs networkLogs := []NetworkLog{} consoleLogs := []ConsoleLog{} var finalStatusCode int // Listen for network events chromedp.ListenTarget(taskCtx, func(ev interface{}) { switch e := ev.(type) { case *network.EventRequestWillBeSent: // Track the main request if e.Type == network.ResourceTypeDocument { networkLogs = append(networkLogs, NetworkLog{ URL: e.Request.URL, Method: e.Request.Method, Type: string(e.Type), }) } case *network.EventResponseReceived: // Update the main request with status code for i, log := range networkLogs { if log.URL == e.Response.URL && log.Method == "" { // Not yet updated networkLogs[i].StatusCode = int(e.Response.Status) networkLogs[i].Size = int64(e.Response.EncodedDataLength) // Capture final status code for main document if e.Type == network.ResourceTypeDocument && finalStatusCode == 0 { finalStatusCode = int(e.Response.Status) } break } } case *runtime.EventConsoleAPICalled: // Capture console logs value := "" for _, arg := range e.Args { // Get the string representation of the argument value += arg.Value.String() } if value != "" && value != "undefined" { consoleLogs = append(consoleLogs, ConsoleLog{ Type: "console." + string(e.Type), Value: strings.TrimSpace(value), }) } } }) // Run the browser tasks var buf []byte var html string var pageTitle string tasks := chromedp.Tasks{ network.Enable(), chromedp.Navigate(targetURL), chromedp.WaitReady("body", chromedp.ByQuery), chromedp.Sleep(2 * time.Second), // Wait for page to fully render chromedp.Title(&pageTitle), chromedp.OuterHTML(":root", &html, chromedp.ByQueryAll), chromedp.FullScreenshot(&buf, 90), // 90% quality JPEG } if err := chromedp.Run(taskCtx, tasks); err != nil { // Check if it's a timeout error if errors.Is(err, context.DeadlineExceeded) { return "", "", "", "", 0, "", fmt.Errorf("page load timeout after 30s") } return "", "", "", "", 0, "", fmt.Errorf("chromedp error: %w", err) } // Encode screenshot as base64 screenshot = base64.StdEncoding.EncodeToString(buf) dom = html // Convert logs to JSON networkBytes, _ := json.Marshal(networkLogs) networkLogsJSON = string(networkBytes) consoleBytes, _ := json.Marshal(consoleLogs) consoleLogsJSON = string(consoleBytes) // Set status code if we got it from network events if finalStatusCode == 0 { finalStatusCode = 200 // Assume OK if we got this far } return screenshot, dom, networkLogsJSON, consoleLogsJSON, finalStatusCode, pageTitle, nil } // storeScreenshot stores the screenshot data in S3 and metadata in the database func storeScreenshot(monitorID, checkID uint, result *Result) (uint, error) { // Initialize storage if not already initialized if !storage.IsAvailable() { if err := storage.Init(); err != nil { return 0, fmt.Errorf("failed to initialize storage: %w", err) } } // Generate a unique filename for the screenshot // Format: screenshots/monitor_/YYYY-MM-DD/.jpg timestamp := time.Now().Format("2006-01-02") screenshotID := uuid.New().String() objectPath := fmt.Sprintf("screenshots/monitor_%d/%s/%s.jpg", monitorID, timestamp, screenshotID) // Decode base64 screenshot data screenshotBytes, err := base64.StdEncoding.DecodeString(result.Screenshot) if err != nil { return 0, fmt.Errorf("failed to decode screenshot: %w", err) } // Upload screenshot to S3 ctx := context.Background() reader := bytes.NewReader(screenshotBytes) objectSize := int64(len(screenshotBytes)) _, err = storage.PutObject(ctx, objectPath, reader, objectSize, minio.PutObjectOptions{ ContentType: "image/jpeg", }) if err != nil { return 0, fmt.Errorf("failed to upload screenshot to S3: %w", err) } log.Printf("[ai-check] Stored screenshot at S3 path: %s (size: %d bytes)", objectPath, objectSize) // Store metadata in database record := &ScreenshotRecord{ MonitorID: monitorID, CheckID: checkID, URL: result.URL, ScreenshotPath: objectPath, DOM: truncateString(result.DOM, 50000), // Limit DOM size NetworkLogs: result.NetworkLogs, ConsoleLogs: result.ConsoleLogs, StatusCode: result.StatusCode, Title: result.Title, } // Auto migrate the table if err := models.DB().AutoMigrate(&ScreenshotRecord{}); err != nil { return 0, fmt.Errorf("failed to auto-migrate: %w", err) } if err := models.DB().Create(record).Error; err != nil { return 0, fmt.Errorf("failed to insert screenshot metadata: %w", err) } return record.ID, nil } // loadScreenshotData loads screenshot data from S3 for a given record func loadScreenshotData(record *ScreenshotRecord) (string, error) { if record.ScreenshotPath == "" { return "", errors.New("no screenshot path in record") } if !storage.IsAvailable() { if err := storage.Init(); err != nil { return "", fmt.Errorf("failed to initialize storage: %w", err) } } ctx := context.Background() object, err := storage.GetObject(ctx, record.ScreenshotPath, minio.GetObjectOptions{}) if err != nil { return "", fmt.Errorf("failed to get screenshot from S3: %w", err) } defer object.Close() //nolint:errcheck // accepted lint exception // Read the object data var buf bytes.Buffer _, err = io.Copy(&buf, object) if err != nil { return "", fmt.Errorf("failed to read screenshot data: %w", err) } // Encode as base64 for LLM analysis return base64.StdEncoding.EncodeToString(buf.Bytes()), nil } // updateScreenshotWithLLM updates a screenshot record with LLM analysis func updateScreenshotWithLLM(screenshotID uint, verdict, reasoning string) error { return models.DB().Model(&ScreenshotRecord{}). Where("id = ?", screenshotID). Updates(map[string]interface{}{ "llm_verdict": verdict, "llm_reasoning": reasoning, }).Error } // isDBAvailable checks if the database is available func isDBAvailable() bool { return models.IsDBAvailable() } // getPreviousScreenshots retrieves previous screenshots for comparison func getPreviousScreenshots(monitorID uint, limit int) ([]ScreenshotRecord, error) { var records []ScreenshotRecord err := models.DB().Model(&ScreenshotRecord{}). Where("monitor_id = ? AND llm_verdict != ''", monitorID). Order("created_at DESC"). Limit(limit). Find(&records).Error if err != nil { return nil, err } return records, nil } // truncateString truncates a string to a maximum length func truncateString(s string, maxLen int) string { if len(s) <= maxLen { return s } return s[:maxLen] + "... (truncated)" } // analyzeWithLLM sends the screenshot and data to the LLM for analysis func analyzeWithLLM(targetURL string, current *Result, previous []ScreenshotRecord) (verdict, reasoning string, hasChanges bool, err error) { //nolint:lll // Get LLM configuration from environment llmKey := firstEnv("LLM_APIKEY", "LLAMA_KEY") llmURL := firstEnv("LLM_URL", "LLAMA_URL") llmModel := firstEnv("LLM_MODEL", "LLAMA_MODEL") if llmKey == "" || llmURL == "" { return "", "", false, errors.New("LLM credentials not configured (LLAMA_KEY, LLAMA_URL)") } if llmModel == "" { llmModel = "llama3.2-vision" // Default model } // Create OpenAI client with custom base URL client := openai.NewClient( option.WithBaseURL(llmURL), option.WithAPIKey(llmKey), ) // Build the system prompt systemPrompt := buildSystemPrompt(len(previous) > 0) // Build user message with current screenshot userContent := buildUserMessage(targetURL, current, previous) // Prepare messages messages := []openai.ChatCompletionMessageParamUnion{ openai.SystemMessage(systemPrompt), openai.UserMessage(userContent), } // Call LLM with timeout ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() params := openai.ChatCompletionNewParams{ Messages: messages, Model: llmModel, MaxTokens: openai.Int(2000), Temperature: openai.Float(0.3), // Lower temperature for more consistent analysis } completion, err := client.Chat.Completions.New(ctx, params) if err != nil { return "", "", false, fmt.Errorf("LLM request failed: %w", err) } if len(completion.Choices) == 0 { return "", "", false, errors.New("LLM returned no choices") } response := completion.Choices[0].Message.Content // Parse the LLM response to extract verdict and reasoning return parseLLMResponse(response) } func firstEnv(keys ...string) string { for _, key := range keys { if value := os.Getenv(key); value != "" { return value } } return "" } // buildSystemPrompt creates the system prompt for the LLM func buildSystemPrompt(hasPrevious bool) string { basePrompt := ` You are a web application health monitoring assistant. Your task is to analyze website screenshots and determine if the website appears to be functioning normally or if there are issues. Consider the following aspects: 1. Visual layout - Is the page rendered correctly? Are elements properly aligned? 2. Error messages - Are there any visible error messages, 404s, 500s, or similar? 3. Loading states - Is the page stuck loading or showing spinners? 4. Broken elements - Are there broken images, missing styles, or layout shifts? 5. Content availability - Is the expected content visible? Respond in the following JSON format: { "verdict": "normal" | "warning" | "error", "reasoning": "Brief explanation of your assessment", "has_changes": true | false } Verdict guidelines: - "error": Page is clearly broken (error messages, completely failed to load, blank page) - "warning": Page loads but has issues (partial content, broken elements, degraded appearance) - "normal": Page appears to be functioning correctly` if hasPrevious { basePrompt += ` You will also be shown previous screenshots of the same website. Compare the current screenshot with previous ones and report any significant visual changes that might indicate problems (layout shifts, missing elements, color changes, etc.).` //nolint:lll } return basePrompt } // buildUserMessage creates the user message with screenshot data func buildUserMessage(targetURL string, current *Result, previous []ScreenshotRecord) string { var sb strings.Builder _, _ = fmt.Fprintf(&sb, "Analyze the screenshot of: %s\n\n", targetURL) _, _ = fmt.Fprintf(&sb, "HTTP Status Code: %d\n", current.StatusCode) _, _ = fmt.Fprintf(&sb, "Page Title: %s\n\n", current.Title) // Add console errors if any var consoleErrors []ConsoleLog json.Unmarshal([]byte(current.ConsoleLogs), &consoleErrors) //nolint:errcheck // accepted lint exception errorCount := 0 for _, log := range consoleErrors { if strings.Contains(log.Type, "error") || strings.Contains(log.Type, "warn") { errorCount++ } } if errorCount > 0 { _, _ = fmt.Fprintf(&sb, "Console Errors/Warnings: %d\n\n", errorCount) } // Add network errors var networkLogs []NetworkLog json.Unmarshal([]byte(current.NetworkLogs), &networkLogs) //nolint:errcheck // accepted lint exception failedRequests := 0 for _, req := range networkLogs { if req.StatusCode >= 400 { failedRequests++ } } if failedRequests > 0 { _, _ = fmt.Fprintf(&sb, "Failed Network Requests: %d\n\n", failedRequests) } sb.WriteString("Current screenshot (base64):\n") sb.WriteString(current.Screenshot) sb.WriteString("\n\n") // Add previous screenshots if available (load from S3) if len(previous) > 0 { _, _ = fmt.Fprintf(&sb, "For comparison, here are %d previous screenshot(s):\n\n", len(previous)) for i, prev := range previous { //nolint:gocritic // range copy is acceptable here // Load screenshot data from S3 screenshotData, err := loadScreenshotData(&prev) if err != nil { log.Printf("[ai-check] Failed to load previous screenshot %d: %v", i+1, err) _, _ = fmt.Fprintf(&sb, "Previous screenshot #%d (from %s): [error loading screenshot]\n\n", i+1, prev.CreatedAt.Format("2006-01-02 15:04:05")) continue } _, _ = fmt.Fprintf(&sb, "Previous screenshot #%d (from %s):\n", i+1, prev.CreatedAt.Format("2006-01-02 15:04:05")) sb.WriteString(screenshotData) sb.WriteString("\n\n") } } return sb.String() } // parseLLMResponse parses the LLM response to extract structured data func parseLLMResponse(response string) (verdict, reasoning string, hasChanges bool, err error) { // Try to extract JSON from the response response = strings.TrimSpace(response) // Look for JSON block jsonStart := strings.Index(response, "{") jsonEnd := strings.LastIndex(response, "}") if jsonStart == -1 || jsonEnd == -1 { // No JSON found, try to parse text response return parseTextResponse(response) } jsonStr := response[jsonStart : jsonEnd+1] var parsed struct { Verdict string `json:"verdict"` Reasoning string `json:"reasoning"` HasChanges bool `json:"has_changes"` } if err := json.Unmarshal([]byte(jsonStr), &parsed); err != nil { // JSON parse failed, fall back to text parsing return parseTextResponse(response) } // Validate verdict switch parsed.Verdict { case "normal", "ok", "healthy": return verdictNormal, parsed.Reasoning, parsed.HasChanges, nil case "warning", "warn": return verdictWarning, parsed.Reasoning, parsed.HasChanges, nil case "error", "fail", "unhealthy": return verdictError, parsed.Reasoning, parsed.HasChanges, nil default: // Unknown verdict, default to normal with warning if parsed.Verdict != "" { return verdictWarning, parsed.Reasoning, parsed.HasChanges, nil } return verdictNormal, "Unable to determine specific issues from analysis", false, nil } } // parseTextResponse parses a non-JSON response func parseTextResponse(response string) (verdict, reasoning string, hasChanges bool, err error) { responseLower := strings.ToLower(response) // Look for keywords hasError := strings.Contains(responseLower, "error") || strings.Contains(responseLower, "broken") || strings.Contains(responseLower, "failed") || strings.Contains(responseLower, "not working") hasWarning := strings.Contains(responseLower, "warning") || strings.Contains(responseLower, "issue") || strings.Contains(responseLower, "problem") || strings.Contains(responseLower, "degraded") hasChange := strings.Contains(responseLower, "change") || strings.Contains(responseLower, "different") || strings.Contains(responseLower, "modified") if hasError { return verdictError, response, hasChange, nil } else if hasWarning { return verdictWarning, response, hasChange, nil } return verdictNormal, response, hasChange, nil }