Некоторые проверки не удались
CI / test (push) Successful in 2m5s
Docker / Build and publish worker image (push) Failing after 31s
377 строки
10 KiB
Go
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 ""
|
|
}
|