332 lines
14 KiB
Go
332 lines
14 KiB
Go
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 = ©
|
||
}
|
||
}
|
||
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
|
||
}
|