Files
glpi-ai-agent/internal/agent/status_reply.go

199 lines
9.7 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package agent
import (
"fmt"
"strings"
"github.com/example/glpi-ai-agent/internal/config"
"github.com/example/glpi-ai-agent/internal/model"
)
type statusReplyEvaluation struct {
Accepted bool
DecisionCode string
Type string
Candidate model.ServiceIssueCandidate
AI model.StatusDecision
FinalScore float64
RenderedText string
ReplyText string
ReplyIsHTML bool
Checks []model.RuleCheck
}
func statusIssueCandidates(issues []model.ServiceIssueContext) []model.ServiceIssueCandidate {
out := make([]model.ServiceIssueCandidate, 0, len(issues))
for _, issue := range issues {
if statusReplyType(issue) == "" {
continue
}
out = append(out, model.ServiceIssueCandidate{
ID: fmt.Sprintf("uptime-%d", len(out)+1),
Issue: issue,
})
}
return out
}
func statusReplyType(issue model.ServiceIssueContext) string {
if strings.EqualFold(issue.Kind, "maintenance") || strings.EqualFold(issue.Status, "maintenance") {
return "maintenance"
}
if strings.EqualFold(issue.Kind, "pinned_incident") {
return "incident"
}
switch strings.ToLower(strings.TrimSpace(issue.Status)) {
case "down", "pending", "incident":
return "incident"
default:
return ""
}
}
func statusCandidateName(issue model.ServiceIssueContext) string {
for _, value := range []string{issue.MonitorName, issue.IncidentTitle, issue.StatusPage} {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return "Uptime-Kuma-Eintrag"
}
func auditStatusCandidates(candidates []model.ServiceIssueCandidate, decision model.StatusDecision, cfg config.Config, evaluation statusReplyEvaluation) []model.StatusCandidateAudit {
out := make([]model.StatusCandidateAudit, 0, len(candidates))
for _, candidate := range candidates {
selected := decision.Matched && candidate.ID == decision.CandidateID
finalScore := 0.0
decisionCode := "not_selected"
if selected {
finalScore = clampPolicy01(candidate.Issue.Relevance) * clampPolicy01(decision.Confidence)
decisionCode = statusScoreDecision(candidate.Issue.Relevance, decision.Confidence, finalScore, cfg)
if evaluation.Accepted {
decisionCode = evaluation.DecisionCode
}
}
out = append(out, model.StatusCandidateAudit{
ID: candidate.ID, Name: statusCandidateName(candidate.Issue), Kind: statusReplyType(candidate.Issue),
Status: candidate.Issue.Status, Relevance: candidate.Issue.Relevance, AISelected: selected,
AIConfidence: map[bool]float64{true: decision.Confidence, false: 0}[selected], FinalScore: finalScore, Decision: decisionCode,
})
}
return out
}
func evaluateStatusReply(cfg config.Config, policy Policy, contextData model.ContextSnapshot, candidates []model.ServiceIssueCandidate, decision model.StatusDecision) statusReplyEvaluation {
res := statusReplyEvaluation{AI: decision, DecisionCode: "status_reply_not_selected"}
if !cfg.ContextStatusReplyEnabled {
res.DecisionCode = "status_reply_disabled"
res.Checks = append(res.Checks,
check("status_reply", "status_reply_enabled", "Statusbezogene vordefinierte Antworten aktiviert", "info", false, "nein", "optionale Funktion", "Die Funktion ist bewusst deaktiviert; dies ist kein Fehler und blockiert andere KI-Läufe nicht."),
check("status_reply", "status_reply_context_complete", "Uptime-Kuma-Kontext vollständig verfügbar", passFail(!contextData.Incomplete), contextData.Incomplete, boolText(!contextData.Incomplete), "true", strings.Join(contextData.Warnings, "; ")),
check("status_reply", "status_reply_candidates_present", "Aktive Störungs- oder Wartungskandidaten vorhanden", "na", false, fmt.Sprintf("%d Kandidaten", len(candidates)), "nur bei aktivierter Funktion relevant", "Keine Bewertung, weil Status-Templates deaktiviert sind."),
check("status_reply", "status_reply_ai_match", "KI hat genau einen Uptime-Kuma-Eintrag zugeordnet", "na", false, "", "nur bei aktivierter Funktion relevant", "Kein Status-KI-Aufruf erforderlich."),
check("status_reply", "status_reply_candidate_known", "Ausgewählter Kandidat stammt aus Uptime Kuma", "na", false, "", "nur bei aktivierter Funktion relevant", "Kein Kandidat ausgewählt."),
)
res.Checks = appendStatusScoreNA(res.Checks)
return res
}
res.Checks = append(res.Checks,
check("status_reply", "status_reply_enabled", "Statusbezogene vordefinierte Antworten aktiviert", passFail(cfg.ContextStatusReplyEnabled), !cfg.ContextStatusReplyEnabled, boolText(cfg.ContextStatusReplyEnabled), "true", "Die KI bewertet nur die Zuordnung; der Benutzertext ist fest vorgegeben."),
check("status_reply", "status_reply_context_complete", "Uptime-Kuma-Kontext vollständig verfügbar", passFail(!contextData.Incomplete), contextData.Incomplete, boolText(!contextData.Incomplete), "true", strings.Join(contextData.Warnings, "; ")),
check("status_reply", "status_reply_candidates_present", "Aktive Störungs- oder Wartungskandidaten vorhanden", passFail(len(candidates) > 0), len(candidates) == 0, fmt.Sprintf("%d Kandidaten", len(candidates)), ">= 1 Kandidat", ""),
check("status_reply", "status_reply_ai_match", "KI hat genau einen Uptime-Kuma-Eintrag zugeordnet", passFail(decision.Matched), !decision.Matched, boolText(decision.Matched), "ja", strings.TrimSpace(decision.Reason)),
)
var selected *model.ServiceIssueCandidate
for i := range candidates {
if candidates[i].ID == strings.TrimSpace(decision.CandidateID) {
selected = &candidates[i]
break
}
}
candidateKnown := !decision.Matched || selected != nil
res.Checks = append(res.Checks, check("status_reply", "status_reply_candidate_known", "Ausgewählter Kandidat stammt aus Uptime Kuma", passFail(candidateKnown), !candidateKnown, strings.TrimSpace(decision.CandidateID), "bereitgestellte Kandidaten-ID", ""))
if selected == nil {
res.Checks = appendStatusScoreNA(res.Checks)
return res
}
res.Candidate = *selected
res.Type = statusReplyType(selected.Issue)
res.FinalScore = clampPolicy01(selected.Issue.Relevance) * clampPolicy01(decision.Confidence)
relevanceOK := selected.Issue.Relevance >= cfg.ContextStatusReplyMinRelevance
confidenceOK := decision.Confidence >= cfg.ContextStatusReplyMinAIConfidence
finalOK := res.FinalScore >= cfg.ContextStatusReplyMinFinalScore
template := cfg.ContextIncidentReplyText
if res.Type == "maintenance" {
template = cfg.ContextMaintenanceReplyText
}
templateOK := strings.TrimSpace(template) != ""
res.Checks = append(res.Checks,
check("status_reply", "status_reply_relevance", "Deterministische Ticket-Relevanz erreicht Schwellwert", passFail(relevanceOK), !relevanceOK, percentText(selected.Issue.Relevance), ">= "+percentText(cfg.ContextStatusReplyMinRelevance), ""),
check("status_reply", "status_reply_ai_confidence", "KI-Zuordnung erreicht Schwellwert", passFail(confidenceOK), !confidenceOK, percentText(decision.Confidence), ">= "+percentText(cfg.ContextStatusReplyMinAIConfidence), ""),
check("status_reply", "status_reply_final_score", "Kombinierter Score erreicht Schwellwert", passFail(finalOK), !finalOK, percentText(res.FinalScore), ">= "+percentText(cfg.ContextStatusReplyMinFinalScore), "Relevanz × KI-Confidence"),
check("status_reply", "status_reply_template_present", "Vordefinierter Text für den Status ist vorhanden", passFail(templateOK), !templateOK, boolText(templateOK), "ja", "Es wird kein von der KI formulierter Text verwendet."),
)
if !cfg.ContextStatusReplyEnabled || contextData.Incomplete || !decision.Matched || !candidateKnown || !relevanceOK || !confidenceOK || !finalOK || !templateOK || res.Type == "" {
res.DecisionCode = statusScoreDecision(selected.Issue.Relevance, decision.Confidence, res.FinalScore, cfg)
return res
}
res.RenderedText = renderStatusTemplate(template, selected.Issue)
if policy.AIContentLabelEnabled {
res.ReplyText = policy.formatRichReply(policy.plainTextToHTML(res.RenderedText))
res.ReplyIsHTML = true
} else {
res.ReplyText = policy.formatReply(res.RenderedText)
}
res.Accepted = true
if res.Type == "maintenance" {
res.DecisionCode = "reply_status_maintenance_accepted"
} else {
res.DecisionCode = "reply_status_incident_accepted"
}
return res
}
func appendStatusScoreNA(checks []model.RuleCheck) []model.RuleCheck {
for _, spec := range []struct{ code, label string }{
{"status_reply_relevance", "Deterministische Ticket-Relevanz erreicht Schwellwert"},
{"status_reply_ai_confidence", "KI-Zuordnung erreicht Schwellwert"},
{"status_reply_final_score", "Kombinierter Score erreicht Schwellwert"},
{"status_reply_template_present", "Vordefinierter Text für den Status ist vorhanden"},
} {
checks = append(checks, check("status_reply", spec.code, spec.label, "na", false, "", "ausgewählter Kandidat erforderlich", ""))
}
return checks
}
func statusScoreDecision(relevance, confidence, final float64, cfg config.Config) string {
switch {
case relevance < cfg.ContextStatusReplyMinRelevance:
return "status_reply_relevance_below_threshold"
case confidence < cfg.ContextStatusReplyMinAIConfidence:
return "status_reply_confidence_below_threshold"
case final < cfg.ContextStatusReplyMinFinalScore:
return "status_reply_final_score_below_threshold"
default:
return "status_reply_not_selected"
}
}
func renderStatusTemplate(template string, issue model.ServiceIssueContext) string {
values := map[string]string{
"{{service_name}}": statusCandidateName(issue),
"{{status}}": strings.TrimSpace(issue.Status),
"{{status_page}}": strings.TrimSpace(issue.StatusPage),
"{{message}}": strings.TrimSpace(issue.Message),
"{{incident_title}}": strings.TrimSpace(issue.IncidentTitle),
"{{incident_content}}": strings.TrimSpace(issue.IncidentContent),
"{{last_heartbeat}}": strings.TrimSpace(issue.LastHeartbeat),
}
out := template
for placeholder, value := range values {
out = strings.ReplaceAll(out, placeholder, value)
}
return strings.TrimSpace(out)
}