702 lines
28 KiB
Go
702 lines
28 KiB
Go
package agent
|
||
|
||
import (
|
||
"context"
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"fmt"
|
||
"sort"
|
||
"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/ollama"
|
||
"github.com/example/glpi-ai-agent/internal/state"
|
||
)
|
||
|
||
const (
|
||
categoryPromptVersion = "category-v2"
|
||
priorityPromptVersion = "priority-v4"
|
||
statusPromptVersion = "status-v1"
|
||
replyPromptVersion = "reply-v2"
|
||
escalationPromptVersion = "escalation-v2"
|
||
)
|
||
|
||
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, evidence model.EscalationEvidence, constraints model.EscalationConstraints) (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 attachAnalysisTrace(a *model.AnalysisRun, trace *ollama.Trace) {
|
||
if a == nil || trace == nil {
|
||
return
|
||
}
|
||
snapshot := trace.Snapshot()
|
||
if len(snapshot.Attempts) == 0 {
|
||
return
|
||
}
|
||
a.Provider = snapshot
|
||
}
|
||
|
||
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, contextData model.ContextSnapshot, d model.EscalationDecision, now time.Time) model.EscalationResult {
|
||
d.ReasonCodes = model.NormalizeReasonCodes(d.ReasonCodes)
|
||
actions := normalizeEscalationActions(d)
|
||
evidence := buildEscalationEvidence(cfg, t, followups, contextData, now)
|
||
result := model.EscalationResult{Level: d.Level, 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)
|
||
|
||
inactivityRequired := cfg.EscalationMinInactivity
|
||
if inactivityRequired <= 0 {
|
||
inactivityRequired = cfg.EscalationMinAge
|
||
}
|
||
requiresInactivity := model.HasReasonCode(d.ReasonCodes, "no_human_response")
|
||
activityDatesOK := !evidence.HumanActivityIncomplete
|
||
if requiresInactivity {
|
||
add("escalation_activity_timestamps", "Zeitpunkte menschlicher Followups sind auswertbar", passFail(activityDatesOK), boolText(activityDatesOK), "true", "Nicht auswertbare menschliche Followups blockieren no_human_response fail-closed.", !activityDatesOK)
|
||
} else {
|
||
add("escalation_activity_timestamps", "Zeitpunkte menschlicher Followups sind auswertbar", "na", boolText(activityDatesOK), "nur für no_human_response erforderlich", "Andere belegte Eskalationsgründe wie SLA, Security oder Major Incident bleiben unabhängig auswertbar.", false)
|
||
}
|
||
inactiveOK := evidence.NoHumanResponse && activityDatesOK
|
||
activityActual := "keine menschliche Aktivität seit Erstellung"
|
||
if evidence.LastHumanActivity != "" {
|
||
activityActual = evidence.LastHumanActivity + " · inaktiv seit " + evidence.InactiveFor
|
||
}
|
||
if requiresInactivity {
|
||
add("escalation_inactivity", "Ticket ist lange genug ohne menschliche Aktivität", passFail(inactiveOK), activityActual, ">= "+inactivityRequired.String(), "Agent-Followups werden anhand GLPI_AGENT_USER_ID ausgenommen. Eine ältere menschliche Bearbeitung verhindert eine spätere Eskalation nicht dauerhaft.", !inactiveOK)
|
||
} else {
|
||
add("escalation_inactivity", "Ticket ist lange genug ohne menschliche Aktivität", "na", activityActual, "nur für Grund no_human_response erforderlich", "Der aktuelle Eskalationsgrund ist nicht von Inaktivität abhängig.", false)
|
||
}
|
||
|
||
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
|
||
if d.Escalate {
|
||
add("escalation_level", "Eskalationsstufe ist freigegeben", passFail(levelOK), fmt.Sprintf("Stufe %d", d.Level), fmt.Sprintf("1 bis %d", cfg.EscalationMaxLevel), "", !levelOK)
|
||
} else {
|
||
add("escalation_level", "Eskalationsstufe ist freigegeben", "na", fmt.Sprintf("Stufe %d", d.Level), "nur bei Eskalation relevant", "", false)
|
||
}
|
||
confidenceOK := d.Confidence >= cfg.EscalationConfidence
|
||
if d.Escalate {
|
||
add("escalation_confidence", "KI-Confidence erreicht Schwellwert", passFail(confidenceOK), percentText(d.Confidence), ">= "+percentText(cfg.EscalationConfidence), "", !confidenceOK)
|
||
} else {
|
||
add("escalation_confidence", "KI-Confidence erreicht Schwellwert", "na", percentText(d.Confidence), "nur bei Eskalation relevant", "", false)
|
||
}
|
||
reasonPresent := len(d.ReasonCodes) > 0
|
||
if d.Escalate {
|
||
add("escalation_reason_present", "Mindestens ein strukturierter Eskalationsgrund ist vorhanden", passFail(reasonPresent), strings.Join(d.ReasonCodes, ", "), ">= 1 Grundcode", "Eine Eskalation ohne kontrollierten Grundcode wird nie ausgeführt.", !reasonPresent)
|
||
} else {
|
||
add("escalation_reason_present", "Mindestens ein strukturierter Eskalationsgrund ist vorhanden", "na", strings.Join(d.ReasonCodes, ", "), "nur bei Eskalation relevant", "", false)
|
||
}
|
||
reasonAllowed := reasonPresent && allAllowed(d.ReasonCodes, cfg.EscalationAllowedReasonCodes)
|
||
if d.Escalate {
|
||
add("escalation_reasons_allowed", "Alle KI-Gründe sind freigegeben", passFail(reasonAllowed), strings.Join(d.ReasonCodes, ", "), strings.Join(cfg.EscalationAllowedReasonCodes, ", "), "", !reasonAllowed)
|
||
} else {
|
||
add("escalation_reasons_allowed", "Alle KI-Gründe sind freigegeben", "na", strings.Join(d.ReasonCodes, ", "), "nur bei Eskalation relevant", "", false)
|
||
}
|
||
mismatches := escalationReasonEvidenceMismatches(d.ReasonCodes, evidence)
|
||
reasonEvidenceOK := len(mismatches) == 0
|
||
if d.Escalate {
|
||
detail := "Deterministische Grundcodes müssen durch Ticket-, SLA- oder Kontextdaten belegt sein."
|
||
if !reasonEvidenceOK {
|
||
detail += " Nicht belegt: " + strings.Join(mismatches, ", ")
|
||
}
|
||
add("escalation_reasons_evidenced", "Deterministische KI-Gründe sind durch Daten belegt", passFail(reasonEvidenceOK), strings.Join(d.ReasonCodes, ", "), "keine unbelegten Grundcodes", detail, !reasonEvidenceOK)
|
||
} else {
|
||
add("escalation_reasons_evidenced", "Deterministische KI-Gründe sind durch Daten belegt", "na", strings.Join(d.ReasonCodes, ", "), "nur bei Eskalation relevant", "", false)
|
||
}
|
||
|
||
commonDecision := "escalation_accepted"
|
||
switch {
|
||
case !cfg.EscalationEnabled:
|
||
commonDecision = "escalation_disabled"
|
||
case !ageOK:
|
||
commonDecision = "escalation_too_young"
|
||
case requiresInactivity && !activityDatesOK:
|
||
commonDecision = "escalation_human_activity_time_unknown"
|
||
case requiresInactivity && !inactiveOK:
|
||
commonDecision = "escalation_recent_human_activity"
|
||
case !d.Escalate:
|
||
commonDecision = "escalation_not_recommended"
|
||
case !levelOK:
|
||
commonDecision = "escalation_level_not_allowed"
|
||
case !confidenceOK:
|
||
commonDecision = "escalation_confidence_below_threshold"
|
||
case !reasonPresent:
|
||
commonDecision = "escalation_reason_missing"
|
||
case !reasonAllowed:
|
||
commonDecision = "escalation_reason_not_allowed"
|
||
case !reasonEvidenceOK:
|
||
commonDecision = "escalation_reason_not_evidenced"
|
||
}
|
||
|
||
acceptedActions := 0
|
||
for _, actionName := range actions {
|
||
actionResult := evaluateEscalationAction(cfg, st, t, contextData, d, evidence, actionName, commonDecision)
|
||
result.Actions = append(result.Actions, actionResult)
|
||
if actionResult.Accepted {
|
||
acceptedActions++
|
||
result.Accepted = true
|
||
if result.Action == "" {
|
||
result.Action = actionResult.Action
|
||
result.IdempotencyKey = actionResult.IdempotencyKey
|
||
}
|
||
}
|
||
}
|
||
if len(actions) == 0 && d.Escalate && commonDecision == "escalation_accepted" {
|
||
result.Checks = append(result.Checks, model.RuleCheck{Code: "escalation_actions_present", Group: "escalation", Label: "Mindestens eine Aktion wurde empfohlen", Status: "fail", Actual: "keine", Expected: "1 bis 3 Aktionen", Blocking: true})
|
||
commonDecision = "escalation_no_action_recommended"
|
||
}
|
||
if commonDecision != "escalation_accepted" {
|
||
result.Decision = commonDecision
|
||
} else if result.Accepted && acceptedActions < len(actions) {
|
||
result.Decision = "escalation_partially_accepted"
|
||
} else if result.Accepted {
|
||
result.Decision = "escalation_accepted"
|
||
} else {
|
||
result.Decision = "escalation_no_action_accepted"
|
||
}
|
||
return result
|
||
}
|
||
|
||
func normalizeEscalationActions(d model.EscalationDecision) []string {
|
||
raw := append([]string(nil), d.RecommendedActions...)
|
||
if len(raw) == 0 && strings.TrimSpace(d.RecommendedAction) != "" {
|
||
raw = append(raw, d.RecommendedAction)
|
||
}
|
||
seen := map[string]struct{}{}
|
||
var out []string
|
||
for _, value := range raw {
|
||
action := strings.ToLower(strings.TrimSpace(value))
|
||
if action == "" || action == "none" {
|
||
continue
|
||
}
|
||
if _, ok := seen[action]; ok {
|
||
continue
|
||
}
|
||
seen[action] = struct{}{}
|
||
out = append(out, action)
|
||
if len(out) == 3 {
|
||
break
|
||
}
|
||
}
|
||
order := map[string]int{
|
||
"assign_security_team": 10,
|
||
"link_major_incident": 20,
|
||
"assign_second_level": 30,
|
||
"raise_priority": 40,
|
||
"notify_service_owner": 50,
|
||
"request_manager_review": 60,
|
||
}
|
||
sort.SliceStable(out, func(i, j int) bool {
|
||
left, lok := order[out[i]]
|
||
right, rok := order[out[j]]
|
||
if !lok {
|
||
left = 100
|
||
}
|
||
if !rok {
|
||
right = 100
|
||
}
|
||
if left == right {
|
||
return out[i] < out[j]
|
||
}
|
||
return left < right
|
||
})
|
||
return out
|
||
}
|
||
|
||
func buildEscalationEvidence(cfg config.Config, t model.Ticket, followups []model.Followup, contextData model.ContextSnapshot, now time.Time) model.EscalationEvidence {
|
||
e := model.EscalationEvidence{Unassigned: len(t.AssignedGroups) == 0 && len(t.AssignedUsers) == 0}
|
||
if created, ok := parseGLPITime(t.DateCreation); ok {
|
||
e.TicketAge = now.Sub(created).Round(time.Second).String()
|
||
}
|
||
inactivityRequired := cfg.EscalationMinInactivity
|
||
if inactivityRequired <= 0 {
|
||
inactivityRequired = cfg.EscalationMinAge
|
||
}
|
||
e.InactivityRequired = inactivityRequired.String()
|
||
lastActivity := time.Time{}
|
||
if created, ok := parseGLPITime(t.DateCreation); ok {
|
||
lastActivity = created
|
||
}
|
||
for _, followup := range followups {
|
||
if cfg.GLPIAgentUserID > 0 && followup.UserID == cfg.GLPIAgentUserID {
|
||
continue
|
||
}
|
||
if _, ok := parseGLPITime(followup.Date); !ok {
|
||
e.HumanActivityIncomplete = true
|
||
break
|
||
}
|
||
}
|
||
if human := lastHumanFollowup(followups, cfg.GLPIAgentUserID); human != nil {
|
||
if parsed, ok := parseGLPITime(human.Date); ok && parsed.After(lastActivity) {
|
||
lastActivity = parsed
|
||
e.LastHumanActivity = parsed.Format(time.RFC3339)
|
||
}
|
||
}
|
||
if !lastActivity.IsZero() {
|
||
inactiveFor := now.Sub(lastActivity)
|
||
e.InactiveFor = inactiveFor.Round(time.Second).String()
|
||
e.NoHumanResponse = !e.HumanActivityIncomplete && inactiveFor >= inactivityRequired
|
||
}
|
||
if deadline, ok := parseGLPITime(t.TimeToResolve); ok {
|
||
e.SLADeadline = deadline.Format(time.RFC3339)
|
||
remaining := deadline.Sub(now)
|
||
e.SLARemaining = remaining.Round(time.Second).String()
|
||
e.SLABreached = remaining <= 0
|
||
e.SLAAtRisk = !e.SLABreached && cfg.EscalationSLARiskWindow > 0 && remaining <= cfg.EscalationSLARiskWindow
|
||
}
|
||
if incident, ok := selectMajorIncident(contextData, cfg.EscalationMajorIncidentMinScore); ok {
|
||
e.MajorIncidentID = incident.ID
|
||
e.MajorIncidentName = incident.Name
|
||
e.MajorIncidentScore = incident.Relevance
|
||
}
|
||
return e
|
||
}
|
||
|
||
func selectMajorIncident(contextData model.ContextSnapshot, minScore float64) (model.MajorIncidentContext, bool) {
|
||
var best model.MajorIncidentContext
|
||
for _, incident := range contextData.MajorIncidents {
|
||
if incident.ID <= 0 || incident.Relevance < minScore {
|
||
continue
|
||
}
|
||
if best.ID == 0 || incident.Relevance > best.Relevance {
|
||
best = incident
|
||
}
|
||
}
|
||
return best, best.ID > 0
|
||
}
|
||
|
||
func evaluateEscalationAction(cfg config.Config, st *state.Store, t model.Ticket, contextData model.ContextSnapshot, d model.EscalationDecision, evidence model.EscalationEvidence, actionName, commonDecision string) model.EscalationActionResult {
|
||
result := model.EscalationActionResult{Action: actionName}
|
||
add := func(code, label, status, actual, expected, detail string, blocking bool) {
|
||
result.Checks = append(result.Checks, model.RuleCheck{Code: code, Group: "escalation_action", Label: label, Status: status, Actual: actual, Expected: expected, Detail: detail, Blocking: blocking})
|
||
}
|
||
allowed := containsFold(cfg.EscalationAllowedActions, actionName)
|
||
add("escalation_action_allowed", "Aktion ist freigegeben", passFail(allowed), actionName, strings.Join(cfg.EscalationAllowedActions, ", "), "Jede Eskalationsaktion muss separat in ESCALATION_ALLOWED_ACTIONS freigegeben werden.", !allowed)
|
||
|
||
targetReady := true
|
||
prerequisiteOK := true
|
||
alreadyApplied := false
|
||
minLevelOK := true
|
||
detail := ""
|
||
switch actionName {
|
||
case "raise_priority":
|
||
result.Target = fmt.Sprintf("priority:%d", minInt64(6, t.Priority+1))
|
||
targetReady = t.Priority >= 1 && t.Priority < 6
|
||
alreadyApplied = t.Priority >= 6
|
||
detail = "Priorität wird deterministisch um genau eine Stufe erhöht."
|
||
case "assign_second_level":
|
||
result.Target = fmt.Sprintf("group:%d", cfg.EscalationSecondLevelGroupID)
|
||
targetReady = cfg.EscalationSecondLevelGroupID > 0
|
||
alreadyApplied = containsInt64(t.AssignedGroups, cfg.EscalationSecondLevelGroupID)
|
||
prerequisiteOK = evidence.NoHumanResponse || evidence.Unassigned || evidence.SLAAtRisk || evidence.SLABreached || hasAnyReason(d.ReasonCodes, "business_deadline", "no_workaround")
|
||
detail = "Die konfigurierte Second-Level-Gruppe wird zu den vorhandenen Zuweisungen hinzugefügt."
|
||
case "assign_security_team":
|
||
result.Target = fmt.Sprintf("group:%d", cfg.EscalationSecurityGroupID)
|
||
targetReady = cfg.EscalationSecurityGroupID > 0
|
||
alreadyApplied = containsInt64(t.AssignedGroups, cfg.EscalationSecurityGroupID)
|
||
prerequisiteOK = model.HasReasonCode(d.ReasonCodes, "security_incident_suspected")
|
||
detail = "Die Security-Gruppe ist nur bei ausdrücklich erkanntem Sicherheitsverdacht zulässig."
|
||
case "notify_service_owner":
|
||
result.Target = actorTarget(cfg.EscalationServiceOwnerGroupID, cfg.EscalationServiceOwnerUserID, cfg.EscalationWebhookURL != "")
|
||
targetReady = cfg.EscalationServiceOwnerGroupID > 0 || cfg.EscalationServiceOwnerUserID > 0 || cfg.EscalationWebhookURL != ""
|
||
minLevel := cfg.EscalationServiceOwnerMinLevel
|
||
if minLevel <= 0 {
|
||
minLevel = 2
|
||
}
|
||
minLevelOK = d.Level >= minLevel
|
||
detail = fmt.Sprintf("Service-Owner-Einbindung ist ab Stufe %d zulässig.", minLevel)
|
||
case "link_major_incident":
|
||
incident, ok := selectMajorIncident(contextData, cfg.EscalationMajorIncidentMinScore)
|
||
if ok {
|
||
result.Target = fmt.Sprintf("ticket:%d", incident.ID)
|
||
}
|
||
targetReady = ok && strings.TrimSpace(cfg.GLPIEscalationITILLinkPath) != "" && strings.TrimSpace(cfg.GLPIEscalationITILLinkBody) != ""
|
||
prerequisiteOK = model.HasReasonCode(d.ReasonCodes, "major_incident_candidate")
|
||
detail = "Das Ziel wird deterministisch als relevantester Major-Incident-Kandidat oberhalb des Schwellwerts gewählt."
|
||
case "request_manager_review":
|
||
result.Target = actorTarget(cfg.EscalationManagerReviewGroupID, cfg.EscalationManagerReviewUserID, cfg.EscalationWebhookURL != "")
|
||
targetReady = cfg.EscalationManagerReviewGroupID > 0 || cfg.EscalationManagerReviewUserID > 0 || cfg.EscalationWebhookURL != ""
|
||
minLevel := cfg.EscalationManagerReviewMinLevel
|
||
if minLevel <= 0 {
|
||
minLevel = 3
|
||
}
|
||
minLevelOK = d.Level >= minLevel
|
||
detail = fmt.Sprintf("Management-Review ist ab Stufe %d zulässig.", minLevel)
|
||
default:
|
||
targetReady = false
|
||
prerequisiteOK = false
|
||
detail = "Unbekannte Aktion."
|
||
}
|
||
add("escalation_action_target", "Konfiguriertes Aktionsziel ist verfügbar", passFail(targetReady), emptyDash(result.Target), "gültiges Ziel", detail, !targetReady)
|
||
add("escalation_action_prerequisite", "Fachliche Voraussetzung der Aktion ist erfüllt", passFail(prerequisiteOK), strings.Join(d.ReasonCodes, ", "), "aktionsspezifischer Grund", detail, !prerequisiteOK)
|
||
add("escalation_action_level", "Eskalationsstufe erlaubt diese Aktion", passFail(minLevelOK), fmt.Sprintf("Stufe %d", d.Level), "aktionsspezifisches Minimum", detail, !minLevelOK)
|
||
add("escalation_action_not_already_applied", "Ziel ist noch nicht am Ticket gesetzt", passFail(!alreadyApplied), boolText(!alreadyApplied), "true", result.Target, alreadyApplied)
|
||
|
||
key := fmt.Sprintf("ticket=%d;level=%d;action=%s", t.ID, d.Level, actionName)
|
||
// A priority target changes after a successful increase. Keeping it out of
|
||
// the key prevents repeated +1 writes for the same escalation level.
|
||
if result.Target != "" && actionName != "raise_priority" {
|
||
key += ";target=" + result.Target
|
||
}
|
||
result.IdempotencyKey = key
|
||
duplicate := st != nil && st.HasEscalationKey(key)
|
||
if actionName == "raise_priority" && st != nil && st.HasEscalationKey(fmt.Sprintf("ticket=%d;level=%d", t.ID, d.Level)) {
|
||
duplicate = true
|
||
}
|
||
add("escalation_action_not_duplicate", "Diese Aktion wurde für Stufe und Ziel noch nicht ausgeführt", passFail(!duplicate), boolText(!duplicate), "true", key, duplicate)
|
||
|
||
switch {
|
||
case commonDecision != "escalation_accepted":
|
||
result.Decision = commonDecision
|
||
case !allowed:
|
||
result.Decision = "escalation_action_not_allowed"
|
||
case !targetReady:
|
||
result.Decision = "escalation_action_target_missing"
|
||
case !prerequisiteOK:
|
||
result.Decision = "escalation_action_prerequisite_missing"
|
||
case !minLevelOK:
|
||
result.Decision = "escalation_action_level_too_low"
|
||
case alreadyApplied:
|
||
result.Decision = "escalation_action_already_applied"
|
||
case duplicate:
|
||
result.Decision = "escalation_action_duplicate"
|
||
default:
|
||
result.Accepted = true
|
||
result.Decision = "escalation_action_accepted"
|
||
}
|
||
return result
|
||
}
|
||
|
||
func escalationReasonEvidenceMismatches(codes []string, evidence model.EscalationEvidence) []string {
|
||
var mismatches []string
|
||
for _, code := range model.NormalizeReasonCodes(codes) {
|
||
consistent := true
|
||
switch code {
|
||
case "no_human_response":
|
||
consistent = evidence.NoHumanResponse
|
||
case "unassigned":
|
||
consistent = evidence.Unassigned
|
||
case "sla_at_risk":
|
||
consistent = evidence.SLAAtRisk
|
||
case "sla_breached":
|
||
consistent = evidence.SLABreached
|
||
case "major_incident_candidate":
|
||
consistent = evidence.MajorIncidentID > 0
|
||
}
|
||
if !consistent {
|
||
mismatches = append(mismatches, code)
|
||
}
|
||
}
|
||
return mismatches
|
||
}
|
||
|
||
func hasAnyReason(codes []string, wanted ...string) bool {
|
||
for _, code := range wanted {
|
||
if model.HasReasonCode(codes, code) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func containsInt64(values []int64, wanted int64) bool {
|
||
if wanted <= 0 {
|
||
return false
|
||
}
|
||
for _, value := range values {
|
||
if value == wanted {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func actorTarget(groupID, userID int64, webhook bool) string {
|
||
parts := make([]string, 0, 3)
|
||
if groupID > 0 {
|
||
parts = append(parts, fmt.Sprintf("group:%d", groupID))
|
||
}
|
||
if userID > 0 {
|
||
parts = append(parts, fmt.Sprintf("user:%d", userID))
|
||
}
|
||
if webhook {
|
||
parts = append(parts, "webhook")
|
||
}
|
||
return strings.Join(parts, ",")
|
||
}
|
||
|
||
func emptyDash(value string) string {
|
||
if strings.TrimSpace(value) == "" {
|
||
return "–"
|
||
}
|
||
return value
|
||
}
|
||
|
||
func minInt64(a, b int64) int64 {
|
||
if a < b {
|
||
return a
|
||
}
|
||
return b
|
||
}
|
||
|
||
func lastHumanFollowup(followups []model.Followup, agentUserID int64) *model.Followup {
|
||
var latest *model.Followup
|
||
var latestAt time.Time
|
||
for i := range followups {
|
||
f := &followups[i]
|
||
if agentUserID > 0 && f.UserID == agentUserID {
|
||
continue
|
||
}
|
||
at, ok := parseGLPITime(f.Date)
|
||
if !ok {
|
||
continue
|
||
}
|
||
if latest == nil || at.After(latestAt) {
|
||
copy := *f
|
||
latest = ©
|
||
latestAt = at
|
||
}
|
||
}
|
||
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
|
||
}
|