// Package llmhttp provides LLM-based HTTP check functionality for RSMon. package llmhttp import ( "context" "encoding/json" "errors" "fmt" "io" "log" "net/http" "os" "regexp" "strings" "time" "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" ) const ( verdictNormal = "normal" verdictWarning = "warning" verdictError = "error" stateOK = "OK" stateERR = "ERR" stateWARN = "WARN" ) var titleRe = regexp.MustCompile(`]*>(.*?)`) // Perform executes the LLM HTTP health check func Perform(c *models.Check) *Result { result := &Result{ CheckID: uint(c.ID), } start := time.Now() // Get the target URL targetURL := getTargetURL(c) result.URL = targetURL // Step 1: Fetch HTML content html, statusCode, contentType, contentLength, err := fetchHTML(targetURL) if err != nil { return &Result{ CheckResult: checkresult.CheckResult{ State: "FAIL", Error: fmt.Errorf("failed to fetch HTML: %w", err), Duration: time.Since(start), }, URL: targetURL, } } result.HTML = html result.StatusCode = statusCode result.ContentType = contentType result.ContentLength = contentLength // Extract title from HTML result.Title = extractTitle(html) // Step 2: Analyze with LLM llmVerdict, llmReasoning, err := analyzeWithLLM(targetURL, result) if err != nil { return &Result{ CheckResult: checkresult.CheckResult{ State: "FAIL", Error: fmt.Errorf("LLM analysis failed: %w", err), Duration: time.Since(start), }, URL: targetURL, HTML: html, StatusCode: statusCode, } } result.LLMVerdict = llmVerdict result.LLMReasoning = llmReasoning // Determine final state based on LLM verdict var state string var crErr error switch llmVerdict { case verdictError: state = stateERR crErr = errors.New(llmReasoning) case verdictWarning: state = stateWARN default: state = stateOK } return &Result{ CheckResult: checkresult.CheckResult{ State: state, Error: crErr, Duration: time.Since(start), Warnings: []string{}, Infos: []string{}, }, URL: targetURL, HTML: html, StatusCode: statusCode, Title: result.Title, ContentType: contentType, ContentLength: contentLength, LLMVerdict: llmVerdict, LLMReasoning: llmReasoning, CheckID: uint(c.ID), } } // 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 } // fetchHTML fetches HTML content from the target URL func fetchHTML(targetURL string) (html string, statusCode int, contentType string, contentLength int64, err error) { // Create HTTP client with timeout client := &http.Client{ Timeout: 30 * time.Second, // Follow redirects CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return nil // Allow up to 10 redirects by default }, } // Create request req, err := http.NewRequest("GET", targetURL, http.NoBody) if err != nil { return "", 0, "", 0, fmt.Errorf("failed to create request: %w", err) } // Set user agent to avoid being blocked req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; RSMon-LLM-Checker/1.0)") req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") // Make request resp, err := client.Do(req) if err != nil { return "", 0, "", 0, fmt.Errorf("HTTP request failed: %w", err) } defer resp.Body.Close() //nolint:errcheck statusCode = resp.StatusCode contentType = resp.Header.Get("Content-Type") contentLength = resp.ContentLength // Check status code if statusCode >= 400 { return "", statusCode, contentType, contentLength, fmt.Errorf("HTTP status code: %d", statusCode) } // Read response body body, err := io.ReadAll(resp.Body) if err != nil { return "", statusCode, contentType, contentLength, fmt.Errorf("failed to read response body: %w", err) } html = string(body) // Truncate HTML if too large for LLM context maxHTMLSize := 50000 // About 50k chars if len(html) > maxHTMLSize { log.Printf("[llm-http] HTML too large (%d chars), truncating to %d", len(html), maxHTMLSize) html = html[:maxHTMLSize] + "\n\n... (truncated)" } return html, statusCode, contentType, contentLength, nil } // extractTitle extracts the title from HTML content func extractTitle(html string) string { matches := titleRe.FindStringSubmatch(html) if len(matches) > 1 { return strings.TrimSpace(matches[1]) } return "" } // analyzeWithLLM sends the HTML content to the LLM for analysis func analyzeWithLLM(targetURL string, result *Result) (verdict, reasoning string, err error) { // Get LLM configuration from environment llmKey := getEnv("LLM_APIKEY", "LLAMA_KEY") llmURL := getEnv("LLM_URL", "LLAMA_URL") llmModel := getEnv("LLM_MODEL", "LLAMA_MODEL") if llmKey == "" || llmURL == "" { return "", "", errors.New("LLM credentials not configured (LLAMA_KEY, LLAMA_URL)") } if llmModel == "" { llmModel = "llama3.2" // Default model (text-only) } // Create OpenAI client with custom base URL client := openai.NewClient( option.WithBaseURL(llmURL), option.WithAPIKey(llmKey), ) // Build the system prompt systemPrompt := buildSystemPrompt() // Build user message with HTML content userContent := buildUserMessage(targetURL, result) // 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 "", "", fmt.Errorf("LLM request failed: %w", err) } if len(completion.Choices) == 0 { return "", "", errors.New("LLM returned no choices") } response := completion.Choices[0].Message.Content // Parse the LLM response to extract verdict and reasoning return parseLLMResponse(response) } // buildSystemPrompt creates the system prompt for the LLM func buildSystemPrompt() string { return ` You are a web application health monitoring assistant. Your task is to analyze website HTML content and determine if the website appears to be functioning normally or if there are issues. Consider the following aspects: 1. HTML structure - Is the page structured correctly? 2. Error messages - Are there any visible error messages, 404s, 500s, or similar in the HTML? 3. Title and meta tags - Are they present and reasonable? 4. Content availability - Is there actual content or is the page mostly empty? 5. Response status - Consider the HTTP status code provided 6. Content type - Verify the content type is appropriate Respond in the following JSON format: { "verdict": verdictNormal | verdictWarning | verdictError, "reasoning": "Brief explanation of your assessment" } Verdict guidelines: - verdictError: Page is clearly broken (5xx status codes, error messages in HTML, completely empty page, "404 Not Found" in title) - verdictWarning: Page loads but has issues (4xx status codes, incomplete content, unusual title, suspicious patterns) - verdictNormal: Page appears to be functioning correctly (2xx status, proper HTML structure, reasonable content)` } // buildUserMessage creates the user message with HTML data func buildUserMessage(targetURL string, result *Result) string { var sb strings.Builder _, _ = fmt.Fprintf(&sb, "Analyze the HTML content of: %s\n\n", targetURL) _, _ = fmt.Fprintf(&sb, "HTTP Status Code: %d\n", result.StatusCode) _, _ = fmt.Fprintf(&sb, "Content-Type: %s\n", result.ContentType) _, _ = fmt.Fprintf(&sb, "Content Length: %d bytes\n", result.ContentLength) _, _ = fmt.Fprintf(&sb, "Page Title: %s\n\n", result.Title) sb.WriteString("HTML content:\n") sb.WriteString("```\n") sb.WriteString(result.HTML) sb.WriteString("```\n") return sb.String() } // parseLLMResponse parses the LLM response to extract structured data func parseLLMResponse(response string) (verdict, reasoning string, 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"` } 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 verdictNormal, "ok", "healthy": return verdictNormal, parsed.Reasoning, nil case verdictWarning, "warn": return verdictWarning, parsed.Reasoning, nil case verdictError, "fail", "unhealthy": return verdictError, parsed.Reasoning, nil default: // Unknown verdict, default to normal with warning if parsed.Verdict != "" { return verdictWarning, parsed.Reasoning, nil } return verdictNormal, "Unable to determine specific issues from analysis", nil } } // parseTextResponse parses a non-JSON response func parseTextResponse(response string) (verdict, reasoning string, err error) { responseLower := strings.ToLower(response) // Look for keywords hasError := strings.Contains(responseLower, verdictError) || strings.Contains(responseLower, "broken") || strings.Contains(responseLower, "failed") || strings.Contains(responseLower, "not working") hasWarning := strings.Contains(responseLower, verdictWarning) || strings.Contains(responseLower, "issue") || strings.Contains(responseLower, "problem") || strings.Contains(responseLower, "degraded") if hasError { return verdictError, response, nil } else if hasWarning { return verdictWarning, response, nil } return verdictNormal, response, nil } // getEnv gets an environment variable or returns empty string func getEnv(keys ...string) string { for _, key := range keys { if value := os.Getenv(key); value != "" { return value } } return "" }