199 lines
9.7 KiB
Go
199 lines
9.7 KiB
Go
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)
|
||
}
|