688 строки
22 KiB
Go
688 строки
22 KiB
Go
// 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"
|
|
|
|
"rsgit.ru/rsmon/rsmon/app/models"
|
|
"rsgit.ru/rsmon/rsmon/internal/checkresult"
|
|
"rsgit.ru/rsmon/rsmon/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_<monitor_id>/YYYY-MM-DD/<uuid>.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
|
|
}
|