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

166 lines
5.3 KiB
Go

package agent
import (
"context"
"fmt"
"log/slog"
"strings"
"sync"
"time"
"github.com/example/glpi-ai-agent/internal/model"
"github.com/example/glpi-ai-agent/internal/queue"
)
func (s *Service) escalationLoop(ctx context.Context) {
s.scanEscalations(ctx)
ticker := time.NewTicker(s.cfg.EscalationScanInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.scanEscalations(ctx)
}
}
}
func (s *Service) scanEscalations(ctx context.Context) {
lister, ok := s.glpi.(escalationLister)
if !ok {
slog.Error("escalation scanner unavailable", "reason", "GLPI connector does not implement ListEscalationCandidates")
return
}
filter := strings.TrimSpace(s.cfg.GLPIEscalationFilter)
if filter == "" {
filter = s.cfg.GLPITicketFilter
}
tickets, err := lister.ListEscalationCandidates(ctx, s.cfg.GLPIEscalationLimit, filter)
if err != nil {
s.metrics.Errors.Add(1)
slog.Error("GLPI escalation scan failed", "error", err)
return
}
now := time.Now()
for _, ticket := range tickets {
created, ok := parseGLPITime(ticket.DateCreation)
if !ok || now.Sub(created) < s.cfg.EscalationMinAge {
continue
}
if s.q.EnqueueWork(queue.WorkItem{TicketID: ticket.ID, Trigger: "scheduled_escalation", Priority: queue.PriorityScheduled}) {
s.metrics.QueueDepth.Store(int64(s.q.Len()))
}
}
}
func (s *Service) processEscalation(ctx context.Context, item queue.WorkItem) error {
id := item.TicketID
muAny, _ := s.locks.LoadOrStore(id, &sync.Mutex{})
mu := muAny.(*sync.Mutex)
mu.Lock()
defer func() {
mu.Unlock()
s.locks.Delete(id)
}()
start := time.Now()
run := model.RunRecord{RunID: newRunID(), TicketID: id, Trigger: "scheduled_escalation", StartedAt: start, DryRun: s.cfg.DryRun, Outcome: "error"}
finish := func(err error) {
run.FinishedAt = time.Now()
if err != nil {
run.Error = err.Error()
s.metrics.Errors.Add(1)
}
if e := s.state.Append(run); e != nil {
slog.Error("persist escalation run failed", "error", e)
}
}
t, err := s.glpi.GetTicket(ctx, id)
if err != nil {
run.Reason = "ticket_load_failed"
finish(err)
return err
}
run.TicketName = t.Name
run.SourceVersion = sourceVersion(t)
run.CategoryBefore = t.CategoryID
run.PriorityBefore = t.Priority
if parent, ok := s.state.LatestTicketRun(id, "scheduled_escalation"); ok {
run.CausedByRunID = parent.RunID
}
followups, err := s.glpi.GetFollowups(ctx, id)
if err != nil {
run.Reason = "followup_check_failed"
finish(err)
return err
}
contextData := model.ContextSnapshot{}
if s.context != nil && s.cfg.ContextEnabled {
contextData = s.context.Collect(ctx, t)
}
started := time.Now()
analysis := newAnalysis(run, "escalation", escalationPromptVersion, map[string]any{
"ticket": t, "followups": followups, "context": contextData,
"minimum_age": s.cfg.EscalationMinAge.String(), "max_level": s.cfg.EscalationMaxLevel,
}, started)
ai, ok := s.ai.(escalationAI)
if !ok {
err = fmt.Errorf("AI client does not implement escalation analysis")
finishAnalysis(&analysis, s.cfg.OllamaModel, nil, nil, "", 0, nil, model.ActionAudit{Type: "escalation", Result: "skipped: escalation_ai_unavailable"}, err)
run.Analyses = append(run.Analyses, analysis)
run.Reason = "escalation_ai_unavailable"
finish(err)
return err
}
decision, err := ai.AnalyseEscalation(ctx, t, followups, contextData)
if err != nil {
finishAnalysis(&analysis, s.cfg.OllamaModel, nil, nil, "", 0, nil, model.ActionAudit{Type: "escalation", Result: "skipped: escalation_ai_failed"}, err)
run.Analyses = append(run.Analyses, analysis)
run.Reason = "escalation_ai_failed"
finish(err)
return err
}
result := evaluateEscalation(s.cfg, s.state, t, followups, decision, time.Now())
action := model.ActionAudit{Type: result.Action, Proposed: result.Accepted && result.Action != "none", DryRun: s.cfg.DryRun || !s.cfg.AutoEscalation, Before: fmt.Sprintf("priority=%d", t.Priority), Result: result.Decision}
if result.Accepted && result.Action == "raise_priority" {
target := t.Priority + 1
if target > 6 {
target = 6
}
action.After = fmt.Sprintf("priority=%d", target)
if t.Priority < 1 || t.Priority >= 6 {
result.Accepted = false
result.Decision = "escalation_priority_not_changeable"
action.Proposed = false
action.Result = result.Decision
} else if s.cfg.AutoEscalation && !s.cfg.DryRun {
fresh, loadErr := s.glpi.GetTicket(ctx, id)
if loadErr != nil {
err = loadErr
} else if sourceVersion(fresh) != run.SourceVersion {
result.Decision = "escalation_ticket_changed_before_write"
action.Result = result.Decision
} else if writer, ok := s.glpi.(priorityWriter); !ok {
err = fmt.Errorf("GLPI connector does not implement priority writes")
} else if writeErr := writer.SetPriority(ctx, id, target); writeErr != nil {
err = writeErr
} else {
action.Executed = true
action.Result = result.IdempotencyKey + "; priority_written"
s.metrics.Escalations.Add(1)
}
}
}
finishAnalysis(&analysis, s.cfg.OllamaModel, result, decision.ReasonCodes, decision.Reason, decision.Confidence, result.Checks, action, err)
run.Analyses = append(run.Analyses, analysis)
run.Reason = decision.Reason
run.AIReason = decision.Reason
run.PolicyReason = result.Decision
run.Outcome = "processed"
s.metrics.EscalationRuns.Add(1)
finish(err)
return err
}