Files
glpi-ai-agent/internal/agent/analysis_runs.go
T

332 lines
14 KiB
Go
Raw 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 (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/example/glpi-ai-agent/internal/config"
"github.com/example/glpi-ai-agent/internal/model"
"github.com/example/glpi-ai-agent/internal/state"
)
const (
categoryPromptVersion = "category-v2"
priorityPromptVersion = "priority-v4"
statusPromptVersion = "status-v1"
replyPromptVersion = "reply-v2"
escalationPromptVersion = "escalation-v1"
)
type priorityAI interface {
AnalysePriority(ctx context.Context, t model.Ticket, category model.Category, contextData model.ContextSnapshot) (model.PriorityDecision, error)
}
type escalationAI interface {
AnalyseEscalation(ctx context.Context, t model.Ticket, followups []model.Followup, contextData model.ContextSnapshot) (model.EscalationDecision, error)
}
type priorityWriter interface {
SetPriority(ctx context.Context, id, priority int64) error
}
type escalationLister interface {
ListEscalationCandidates(ctx context.Context, limit int, filter string) ([]model.Ticket, error)
}
func newAnalysis(run model.RunRecord, analysisType, promptVersion string, snapshot any, started time.Time) model.AnalysisRun {
raw := mustJSON(snapshot)
h := sha256.Sum256(raw)
return model.AnalysisRun{
AnalysisID: newRunID(),
ParentRunID: run.RunID,
TicketID: run.TicketID,
AnalysisType: analysisType,
Trigger: run.Trigger,
SourceVersion: run.SourceVersion,
PromptVersion: promptVersion,
InputHash: hex.EncodeToString(h[:]),
InputSnapshot: raw,
StartedAt: started,
Outcome: "processed",
}
}
func finishAnalysis(a *model.AnalysisRun, modelName string, decision any, reasonCodes []string, explanation string, confidence float64, checks []model.RuleCheck, action model.ActionAudit, err error) {
a.Model = strings.TrimSpace(modelName)
a.FinishedAt = time.Now()
a.DurationMS = a.FinishedAt.Sub(a.StartedAt).Milliseconds()
if a.DurationMS < 0 {
a.DurationMS = 0
}
if decision != nil {
a.Decision = mustJSON(decision)
}
a.ReasonCodes = model.NormalizeReasonCodes(reasonCodes)
a.Explanation = strings.TrimSpace(explanation)
a.Confidence = confidence
a.Checks = append([]model.RuleCheck(nil), checks...)
a.Action = action
if err != nil {
a.Outcome = "error"
a.Error = err.Error()
} else if strings.HasPrefix(strings.ToLower(action.Result), "skipped") {
a.Outcome = "skipped"
}
}
func mustJSON(v any) json.RawMessage {
b, err := json.Marshal(v)
if err != nil {
return json.RawMessage(fmt.Sprintf(`{"snapshot_error":%q}`, err.Error()))
}
return b
}
func evaluatePriority(cfg config.Config, t model.Ticket, d model.PriorityDecision) model.PriorityResult {
reasonCodes := model.NormalizeReasonCodes(d.ReasonCodes)
result := model.PriorityResult{
PriorityBefore: t.Priority,
PriorityAfter: t.Priority,
RecommendedPriority: d.RecommendedPriority,
RecommendedImpact: d.RecommendedImpact,
RecommendedUrgency: d.RecommendedUrgency,
AffectedScope: strings.TrimSpace(d.AffectedScope),
TimeCriticality: strings.TrimSpace(d.TimeCriticality),
Confidence: d.Confidence,
ReasonCodes: append([]string(nil), reasonCodes...),
}
add := func(code, label, status, actual, expected, detail string, blocking bool) {
result.Checks = append(result.Checks, model.RuleCheck{Code: code, Group: "priority", Label: label, Status: status, Actual: actual, Expected: expected, Detail: detail, Blocking: blocking})
}
add("priority_enabled", "KI-Prioritätsanalyse aktiviert", passFail(cfg.PriorityEnabled), boolText(cfg.PriorityEnabled), "true", "Separater KI-Lauf; Schreibzugriff benötigt zusätzlich AUTO_PRIORITY.", !cfg.PriorityEnabled)
if !cfg.PriorityEnabled {
result.Decision = "priority_disabled"
return result
}
validRecommendation := d.RecommendedPriority >= 1 && d.RecommendedPriority <= 6
add("priority_recommendation_valid", "KI hat eine gültige GLPI-Priorität empfohlen", passFail(validRecommendation), fmt.Sprintf("#%d", d.RecommendedPriority), "1 bis 6", "", !validRecommendation)
currentKnown := t.Priority >= 1 && t.Priority <= 6
add("priority_current_known", "Aktuelle GLPI-Priorität ist verfügbar", passFail(currentKnown), fmt.Sprintf("#%d", t.Priority), "1 bis 6", "Ohne aktuellen Ausgangswert wird keine automatische Änderung vorgenommen.", !currentKnown)
increaseRequested := validRecommendation && currentKnown && d.RecommendedPriority > t.Priority
decreaseRequested := validRecommendation && currentKnown && d.RecommendedPriority < t.Priority
hasInsufficientInformation := model.HasReasonCode(reasonCodes, "insufficient_information")
confidenceOK := d.Confidence >= cfg.PriorityConfidence
if increaseRequested {
add("priority_confidence", "KI-Confidence erreicht Schwellwert", passFail(confidenceOK), percentText(d.Confidence), ">= "+percentText(cfg.PriorityConfidence), "Die Confidence ist nur für eine tatsächliche Erhöhung ein Schreib-Gate.", !confidenceOK)
} else {
add("priority_confidence", "KI-Confidence erreicht Schwellwert", "na", percentText(d.Confidence), "nur bei empfohlener Erhöhung relevant", "Ohne Erhöhung wird die Confidence angezeigt, blockiert aber keine neutrale Keine-Änderung-Entscheidung.", false)
}
allowed := stringSet(cfg.PriorityAllowedReasonCodes)
neutral := stringSet([]string{"single_user_affected", "workaround_available", "insufficient_information"})
actionReasonCount := 0
var disallowed []string
for _, reason := range reasonCodes {
if _, ok := allowed[reason]; ok {
actionReasonCount++
continue
}
if _, ok := neutral[reason]; ok {
continue
}
disallowed = append(disallowed, reason)
}
reasonsOK := actionReasonCount > 0 && len(disallowed) == 0 && !hasInsufficientInformation
if increaseRequested {
detail := "Für eine Erhöhung ist mindestens ein freigegebener Aktionsgrund erforderlich; neutrale Grundcodes allein reichen nicht aus."
if hasInsufficientInformation {
detail = "insufficient_information ist ein bewusster Enthaltungsgrund und sperrt jede automatische Erhöhung."
} else if len(disallowed) > 0 {
detail = "Nicht freigegeben: " + strings.Join(disallowed, ", ")
}
add("priority_reasons_allowed", "Freigegebener Grund für eine Erhöhung vorhanden", passFail(reasonsOK), strings.Join(reasonCodes, ", "), strings.Join(cfg.PriorityAllowedReasonCodes, ", "), detail, !reasonsOK)
} else {
add("priority_reasons_allowed", "Freigegebener Grund für eine Erhöhung vorhanden", "na", strings.Join(reasonCodes, ", "), "nur bei empfohlener Erhöhung relevant", "Die Allowlist steuert ausschließlich Prioritätserhöhungen. Eine unveränderte Empfehlung benötigt keinen freigegebenen Eskalationsgrund.", false)
}
noDecrease := !decreaseRequested
add("priority_no_decrease", "KI empfiehlt keine Herabstufung", passFail(noDecrease), fmt.Sprintf("#%d → #%d", t.Priority, d.RecommendedPriority), "empfohlen >= aktuell", "Automatische Herabstufungen sind grundsätzlich gesperrt.", !noDecrease)
switch {
case !validRecommendation:
result.Decision = "priority_invalid_recommendation"
case !currentKnown:
result.Decision = "priority_current_unknown"
case decreaseRequested:
result.Decision = "priority_decrease_blocked"
case d.RecommendedPriority == t.Priority:
result.Accepted = true
if hasInsufficientInformation {
result.Decision = "priority_no_change_insufficient_information"
} else {
result.Decision = "priority_already_matches"
}
case hasInsufficientInformation:
result.Decision = "priority_insufficient_information"
case !confidenceOK:
result.Decision = "priority_confidence_below_threshold"
case !reasonsOK:
result.Decision = "priority_reason_not_allowed"
default:
result.Accepted = true
result.ChangePriority = true
target := d.RecommendedPriority
if max := t.Priority + cfg.PriorityMaxIncrease; cfg.PriorityMaxIncrease > 0 && target > max {
target = max
}
if target > 6 {
target = 6
}
result.PriorityAfter = target
result.Decision = "priority_accepted"
}
if result.ChangePriority {
add("priority_max_increase", "Erhöhung bleibt innerhalb der maximalen Schrittweite", "pass", fmt.Sprintf("#%d → #%d", t.Priority, result.PriorityAfter), fmt.Sprintf("maximal +%d", cfg.PriorityMaxIncrease), fmt.Sprintf("Die KI empfahl #%d; die Policy begrenzt den Zielwert deterministisch.", d.RecommendedPriority), false)
} else {
add("priority_max_increase", "Erhöhung bleibt innerhalb der maximalen Schrittweite", "na", "–", fmt.Sprintf("maximal +%d", cfg.PriorityMaxIncrease), "Keine Erhöhung freigegeben.", false)
}
return result
}
func evaluateEscalation(cfg config.Config, st *state.Store, t model.Ticket, followups []model.Followup, d model.EscalationDecision, now time.Time) model.EscalationResult {
d.ReasonCodes = model.NormalizeReasonCodes(d.ReasonCodes)
result := model.EscalationResult{Level: d.Level, Action: strings.TrimSpace(d.RecommendedAction), ReasonCodes: append([]string(nil), d.ReasonCodes...)}
add := func(code, label, status, actual, expected, detail string, blocking bool) {
result.Checks = append(result.Checks, model.RuleCheck{Code: code, Group: "escalation", Label: label, Status: status, Actual: actual, Expected: expected, Detail: detail, Blocking: blocking})
}
created, createdOK := parseGLPITime(t.DateCreation)
age := time.Duration(0)
if createdOK {
age = now.Sub(created)
}
ageOK := createdOK && age >= cfg.EscalationMinAge
add("escalation_min_age", "Ticket hat das Mindestalter erreicht", passFail(ageOK), durationText(age, createdOK), ">= "+cfg.EscalationMinAge.String(), "Der Scheduler bestimmt nur Kandidaten; die KI entscheidet nicht über den Prüfzeitpunkt.", !ageOK)
human := lastHumanFollowup(followups, cfg.GLPIAgentUserID)
noHuman := human == nil
actualHuman := "keine menschliche Aktivität"
if human != nil {
actualHuman = fmt.Sprintf("Followup #%d von Benutzer #%d", human.ID, human.UserID)
}
add("escalation_no_human_activity", "Seit Erstellung liegt keine menschliche Bearbeitung vor", passFail(noHuman), actualHuman, "keine menschliche Aktivität", "Agent-Followups werden anhand GLPI_AGENT_USER_ID ausgenommen.", !noHuman)
add("escalation_model_recommends", "KI empfiehlt eine Eskalation", passFail(d.Escalate), boolText(d.Escalate), "true", "", !d.Escalate)
levelOK := d.Level >= 1 && d.Level <= cfg.EscalationMaxLevel
add("escalation_level", "Eskalationsstufe ist freigegeben", passFail(levelOK), fmt.Sprintf("Stufe %d", d.Level), fmt.Sprintf("1 bis %d", cfg.EscalationMaxLevel), "", !levelOK)
confidenceOK := d.Confidence >= cfg.EscalationConfidence
add("escalation_confidence", "KI-Confidence erreicht Schwellwert", passFail(confidenceOK), percentText(d.Confidence), ">= "+percentText(cfg.EscalationConfidence), "", !confidenceOK)
reasonAllowed := allAllowed(d.ReasonCodes, cfg.EscalationAllowedReasonCodes)
add("escalation_reasons_allowed", "Alle KI-Gründe sind freigegeben", passFail(reasonAllowed), strings.Join(d.ReasonCodes, ", "), strings.Join(cfg.EscalationAllowedReasonCodes, ", "), "", !reasonAllowed)
actionAllowed := containsFold(cfg.EscalationAllowedActions, d.RecommendedAction)
add("escalation_action_allowed", "Empfohlene Aktion ist freigegeben", passFail(actionAllowed), d.RecommendedAction, strings.Join(cfg.EscalationAllowedActions, ", "), "Aktuell ist nur raise_priority als automatische GLPI-Aktion implementiert.", !actionAllowed)
key := fmt.Sprintf("ticket=%d;level=%d", t.ID, d.Level)
result.IdempotencyKey = key
duplicate := st != nil && st.HasEscalationKey(key)
add("escalation_not_duplicate", "Diese Eskalationsstufe wurde noch nicht ausgeführt", passFail(!duplicate), boolText(!duplicate), "true", key, duplicate)
switch {
case !cfg.EscalationEnabled:
result.Decision = "escalation_disabled"
case !ageOK:
result.Decision = "escalation_too_young"
case !noHuman:
result.Decision = "escalation_human_activity"
case !d.Escalate:
result.Decision = "escalation_not_recommended"
case !levelOK:
result.Decision = "escalation_level_not_allowed"
case !confidenceOK:
result.Decision = "escalation_confidence_below_threshold"
case !reasonAllowed:
result.Decision = "escalation_reason_not_allowed"
case !actionAllowed:
result.Decision = "escalation_action_not_allowed"
case duplicate:
result.Decision = "escalation_duplicate"
default:
result.Accepted = true
result.Decision = "escalation_accepted"
}
return result
}
func lastHumanFollowup(followups []model.Followup, agentUserID int64) *model.Followup {
var latest *model.Followup
for i := range followups {
f := &followups[i]
if agentUserID > 0 && f.UserID == agentUserID {
continue
}
if latest == nil || f.Date > latest.Date {
copy := *f
latest = &copy
}
}
return latest
}
func parseGLPITime(v string) (time.Time, bool) {
v = strings.TrimSpace(v)
for _, layout := range []string{time.RFC3339Nano, time.RFC3339} {
if t, err := time.Parse(layout, v); err == nil {
return t, true
}
}
for _, layout := range []string{"2006-01-02 15:04:05", "2006-01-02T15:04:05"} {
if t, err := time.ParseInLocation(layout, v, time.Local); err == nil {
return t, true
}
}
return time.Time{}, false
}
func durationText(v time.Duration, ok bool) string {
if !ok {
return "Erstellungszeit unbekannt"
}
if v < 0 {
v = 0
}
return v.Round(time.Minute).String()
}
func allAllowed(values, allowedValues []string) bool {
if len(values) == 0 {
return false
}
allowed := stringSet(allowedValues)
for _, value := range values {
if _, ok := allowed[strings.ToLower(strings.TrimSpace(value))]; !ok {
return false
}
}
return true
}
func stringSet(values []string) map[string]struct{} {
out := make(map[string]struct{}, len(values))
for _, value := range values {
value = strings.ToLower(strings.TrimSpace(value))
if value != "" {
out[value] = struct{}{}
}
}
return out
}
func containsFold(values []string, target string) bool {
target = strings.TrimSpace(target)
for _, value := range values {
if strings.EqualFold(strings.TrimSpace(value), target) {
return true
}
}
return false
}