Files
worker/checks/llmhttp/init.go
Gleb Tv 2c884c5612
Некоторые проверки не удались
CI / test (push) Successful in 2m5s
Docker / Build and publish worker image (push) Failing after 31s
refactor: adopt worker module path
2026-07-13 17:56:12 +03:00

377 строки
10 KiB
Go

// 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(`<title[^>]*>(.*?)</title>`)
// 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 ""
}