feat: publish standalone worker
Separate worker packaging and service lifecycle from the control plane.
Этот коммит содержится в:
376
checks/llmhttp/init.go
Обычный файл
376
checks/llmhttp/init.go
Обычный файл
@@ -0,0 +1,376 @@
|
||||
// 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"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
"rsgit.ru/rsmon/rsmon/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 ""
|
||||
}
|
||||
52
checks/llmhttp/result.go
Обычный файл
52
checks/llmhttp/result.go
Обычный файл
@@ -0,0 +1,52 @@
|
||||
package llmhttp
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
"rsgit.ru/rsmon/rsmon/internal/checkresult"
|
||||
)
|
||||
|
||||
// Result is a result of an LLM HTTP health check
|
||||
type Result struct {
|
||||
checkresult.CheckResult
|
||||
|
||||
// Captured data
|
||||
HTML string `json:"html"` // HTML content
|
||||
URL string `json:"url"` // Final URL after redirects
|
||||
StatusCode int `json:"status_code"` // HTTP status code
|
||||
Title string `json:"title"` // Page title (extracted from HTML)
|
||||
ContentLength int64 `json:"content_length"` // Size of response body
|
||||
ContentType string `json:"content_type"` // Content-Type header
|
||||
|
||||
// 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
|
||||
|
||||
// Metadata
|
||||
CheckID uint `json:"check_id"` // For database reference
|
||||
}
|
||||
|
||||
// InfluxTags provides functionality.
|
||||
func (r *Result) InfluxTags(c models.Check) map[string]string { //nolint:gocritic // hugeParam: accepted for interface compatibility
|
||||
ret := make(map[string]string, 0)
|
||||
ret["check"] = strconv.FormatInt(c.ID, 10)
|
||||
ret["state"] = r.State
|
||||
ret["code"] = strconv.Itoa(r.StatusCode)
|
||||
if r.Error != nil {
|
||||
ret["error"] = r.Error.Error()
|
||||
}
|
||||
ret["warnings"] = strings.Join(r.Warnings, ",")
|
||||
return ret
|
||||
}
|
||||
|
||||
// InfluxFields provides functionality.
|
||||
func (r *Result) InfluxFields() map[string]interface{} {
|
||||
ret := make(map[string]interface{}, 0)
|
||||
ret["took"] = int64(r.Duration / time.Millisecond)
|
||||
|
||||
return ret
|
||||
}
|
||||
133
checks/llmhttp/result_test.go
Обычный файл
133
checks/llmhttp/result_test.go
Обычный файл
@@ -0,0 +1,133 @@
|
||||
package llmhttp
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
"rsgit.ru/rsmon/rsmon/internal/checkresult"
|
||||
)
|
||||
|
||||
func TestInfluxTags(t *testing.T) {
|
||||
c := models.Check{ID: 123}
|
||||
|
||||
r := &Result{
|
||||
CheckResult: checkresult.CheckResult{
|
||||
State: "OK",
|
||||
Error: nil,
|
||||
Warnings: nil,
|
||||
Duration: 100 * time.Millisecond,
|
||||
},
|
||||
StatusCode: 200,
|
||||
}
|
||||
|
||||
tags := r.InfluxTags(c)
|
||||
if tags["check"] != "123" {
|
||||
t.Errorf("check = %q, want 123", tags["check"])
|
||||
}
|
||||
if tags["state"] != "OK" {
|
||||
t.Errorf("state = %q, want OK", tags["state"])
|
||||
}
|
||||
if tags["code"] != "200" {
|
||||
t.Errorf("code = %q, want 200", tags["code"])
|
||||
}
|
||||
if _, ok := tags["error"]; ok {
|
||||
t.Error("error tag should not be present when Error is nil")
|
||||
}
|
||||
|
||||
rWithErr := &Result{
|
||||
CheckResult: checkresult.CheckResult{
|
||||
State: "ERR",
|
||||
Error: errors.New("something went wrong"),
|
||||
Warnings: nil,
|
||||
Duration: 100 * time.Millisecond,
|
||||
},
|
||||
StatusCode: 500,
|
||||
}
|
||||
|
||||
tags = rWithErr.InfluxTags(c)
|
||||
if tags["error"] != "something went wrong" {
|
||||
t.Errorf("error = %q, want 'something went wrong'", tags["error"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestInfluxTags_Warnings(t *testing.T) {
|
||||
c := models.Check{ID: 456}
|
||||
|
||||
r := &Result{
|
||||
CheckResult: checkresult.CheckResult{
|
||||
State: "WARN",
|
||||
Warnings: []string{"redirect detected", "slow response"},
|
||||
Duration: time.Second,
|
||||
},
|
||||
StatusCode: 301,
|
||||
}
|
||||
|
||||
tags := r.InfluxTags(c)
|
||||
wantWarnings := "redirect detected,slow response"
|
||||
if tags["warnings"] != wantWarnings {
|
||||
t.Errorf("warnings = %q, want %q", tags["warnings"], wantWarnings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInfluxFields(t *testing.T) {
|
||||
r := &Result{
|
||||
CheckResult: checkresult.CheckResult{
|
||||
State: "OK",
|
||||
Duration: 1234 * time.Millisecond,
|
||||
},
|
||||
}
|
||||
|
||||
fields := r.InfluxFields()
|
||||
if took, ok := fields["took"]; !ok {
|
||||
t.Error("took field missing")
|
||||
} else if took != int64(1234) {
|
||||
t.Errorf("took = %v (%T), want int64(1234)", took, took)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInfluxFields_NegativeDuration(t *testing.T) {
|
||||
r := &Result{
|
||||
CheckResult: checkresult.CheckResult{
|
||||
State: "OK",
|
||||
Duration: 0,
|
||||
},
|
||||
}
|
||||
|
||||
fields := r.InfluxFields()
|
||||
if took, ok := fields["took"]; !ok {
|
||||
t.Error("took field missing")
|
||||
} else if took != int64(0) {
|
||||
t.Errorf("took = %v, want 0", took)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWarningsJoined(t *testing.T) {
|
||||
c := models.Check{ID: 1}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
warnings []string
|
||||
want string
|
||||
}{
|
||||
{"nil", nil, ""},
|
||||
{"empty", []string{}, ""},
|
||||
{"single", []string{"a"}, "a"},
|
||||
{"multiple", []string{"a", "b", "c"}, "a,b,c"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
r := &Result{
|
||||
CheckResult: checkresult.CheckResult{
|
||||
Warnings: tt.warnings,
|
||||
},
|
||||
}
|
||||
tags := r.InfluxTags(c)
|
||||
if tags["warnings"] != tt.want {
|
||||
t.Errorf("warnings = %q, want %q", tags["warnings"], tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Ссылка в новой задаче
Block a user