package agent import ( "context" "crypto/rand" "crypto/sha256" "encoding/hex" "fmt" "log/slog" "sort" "strings" "sync" "time" "github.com/example/glpi-ai-agent/internal/config" "github.com/example/glpi-ai-agent/internal/knowledge" "github.com/example/glpi-ai-agent/internal/learning" "github.com/example/glpi-ai-agent/internal/metrics" "github.com/example/glpi-ai-agent/internal/model" "github.com/example/glpi-ai-agent/internal/queue" "github.com/example/glpi-ai-agent/internal/state" ) type GLPI interface { Ping(context.Context) error ValidateContract(context.Context) error ListRecentTickets(context.Context, int, string) ([]model.Ticket, error) GetTicket(context.Context, int64) (model.Ticket, error) GetFollowups(context.Context, int64) ([]model.Followup, error) SetCategory(context.Context, int64, int64) error AddFollowup(context.Context, int64, string) error GetCategories(context.Context) ([]model.Category, error) } type AI interface { Ping(context.Context) error Analyse(context.Context, model.Ticket, []model.Category, []model.KnowledgeHit, model.ContextSnapshot) (model.Decision, error) } type ContextCollector interface { Collect(context.Context, model.Ticket) model.ContextSnapshot } type Service struct { cfg config.Config glpi GLPI ai AI knowledge *knowledge.Store learning *learning.Store state *state.Store q *queue.Queue metrics *metrics.Metrics policy Policy context ContextCollector locks sync.Map catMu sync.RWMutex categories []model.Category catAt time.Time } func New(cfg config.Config, g GLPI, ai AI, k *knowledge.Store, l *learning.Store, s *state.Store, q *queue.Queue, m *metrics.Metrics, contextCollector ContextCollector) *Service { return &Service{cfg: cfg, glpi: g, ai: ai, knowledge: k, learning: l, state: s, q: q, metrics: m, context: contextCollector, policy: NewPolicy(cfg.AutoCategory, cfg.AutoReply, cfg.CategoryConfidence, cfg.ReplyConfidence, cfg.KnowledgeMinScore, cfg.KnowledgeAllowedSources, cfg.KnowledgeAutoReplySources, cfg.CommunicationLanguage, cfg.CommunicationStyle, cfg.CommunicationSalutation, cfg.CommunicationClosing, cfg.CommunicationSignature, cfg.ContextBlockReplyOnError, cfg.ContextBlockReplyOnIncident, cfg.ContextRelevanceMinScore)} } func (s *Service) Queue() *queue.Queue { return s.q } func (s *Service) Start(ctx context.Context) { go s.healthLoop(ctx) go s.pollLoop(ctx) for i := 0; i < s.cfg.Workers; i++ { go s.worker(ctx, i) } } func (s *Service) pollLoop(ctx context.Context) { ticker := time.NewTicker(s.cfg.GLPIPollInterval) defer ticker.Stop() s.poll(ctx) for { select { case <-ctx.Done(): return case <-ticker.C: s.poll(ctx) } } } func (s *Service) poll(ctx context.Context) { tickets, err := s.glpi.ListRecentTickets(ctx, s.cfg.GLPIPollLimit, s.cfg.GLPITicketFilter) s.metrics.Polls.Add(1) s.metrics.SetLastPoll(time.Now()) if err != nil { s.metrics.Errors.Add(1) slog.Error("GLPI poll failed", "error", err) return } for _, t := range tickets { version := sourceVersion(t) if !s.state.Seen(t.ID, version) { if s.q.Enqueue(t.ID) { s.metrics.QueueDepth.Store(int64(s.q.Len())) } } } } func (s *Service) healthLoop(ctx context.Context) { check := func() { c, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() gerr := s.glpi.Ping(c) oerr := s.ai.Ping(c) s.metrics.SetHealth(gerr == nil, oerr == nil) } check() ticker := time.NewTicker(30 * time.Second) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: check() } } } func (s *Service) worker(ctx context.Context, n int) { for { id, ok := s.q.Next(ctx) if !ok { return } s.metrics.QueueDepth.Store(int64(s.q.Len())) if err := s.Process(ctx, id); err != nil { slog.Error("ticket processing failed", "worker", n, "ticket_id", id, "error", err) } s.q.Done(id) s.metrics.QueueDepth.Store(int64(s.q.Len())) } } func (s *Service) Process(ctx context.Context, id int64) error { muAny, _ := s.locks.LoadOrStore(id, &sync.Mutex{}) mu := muAny.(*sync.Mutex) mu.Lock() defer mu.Unlock() start := time.Now() run := model.RunRecord{RunID: newRunID(), TicketID: id, 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 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 if s.state.Seen(t.ID, run.SourceVersion) { run.Outcome = "skipped" run.Reason = "already_processed" s.metrics.Skipped.Add(1) finish(nil) return nil } if !s.statusAllowed(t.StatusID) { run.Outcome = "skipped" run.Reason = "status_not_allowed" s.metrics.Skipped.Add(1) finish(nil) return nil } followups, err := s.glpi.GetFollowups(ctx, id) if err != nil { run.Reason = "followup_check_failed" finish(err) return err } canReply := len(followups) == 0 categories, err := s.getCategories(ctx) if err != nil { run.Reason = "categories_failed" finish(err) return err } run.CategoryBeforeName = categoryName(categories, t.CategoryID) promptCats := shortlistCategories(t, categories, s.cfg.CategoryPromptLimit) hits, err := s.knowledge.Search(ctx, t.Name+"\n"+stripHTML(t.Content), s.cfg.KnowledgeTopK) if err != nil { run.Reason = "knowledge_search_failed" finish(err) return err } if len(hits) > 0 { run.KnowledgeTopID = hits[0].Doc.ID run.KnowledgeTopTitle = hits[0].Doc.Title run.KnowledgeScore = hits[0].Score } contextData := model.ContextSnapshot{} if s.context != nil && s.cfg.ContextEnabled { s.metrics.ContextFetches.Add(1) contextData = s.context.Collect(ctx, t) run.ContextChanges = len(contextData.Changes) run.ContextIncidents = len(contextData.MajorIncidents) run.ContextIssues = len(contextData.ServiceIssues) run.ContextDevices = len(contextData.UserDevices) run.ContextWarnings = append([]string(nil), contextData.Warnings...) if contextData.Incomplete { s.metrics.ContextErrors.Add(1) } } decision, err := s.ai.Analyse(ctx, t, promptCats, hits, contextData) if err != nil { run.Reason = "ai_failed" finish(err) return err } result, err := s.policy.Evaluate(t, decision, categories, hits, contextData) if err != nil { run.Reason = "policy_rejected" finish(err) return err } run.AIReason = result.AIReason run.Reason = result.AIReason // backwards compatible audit field run.AIRecommendedCategoryID = result.CategoryRecommendationID run.AIRecommendedCategoryName = result.CategoryRecommendationName run.AICategoryConfidence = result.CategoryConfidence run.CategoryThreshold = result.CategoryThreshold run.CategoryDecision = result.CategoryDecision run.CategoryProposed = result.CategoryID run.CategoryWouldChange = result.ChangeCategory run.AIReplyRecommended = result.ReplyRecommendation run.AIReplyConfidence = result.ReplyConfidence run.ReplyThreshold = result.ReplyThreshold run.AIKnowledgeID = result.ReplyKnowledgeID run.ReplyDecision = result.ReplyDecision run.ReplyProposed = result.Reply run.KnowledgeID = result.KnowledgeID run.PolicyReason = result.CategoryDecision + "; " + result.ReplyDecision if !canReply && result.Reply { run.ReplyProposed = false run.ReplyDecision = "reply_existing_followup" run.PolicyReason = result.CategoryDecision + "; " + run.ReplyDecision } // Re-read the ticket immediately before any write. This prevents a stale // model decision from overwriting a human change made during inference. if (result.ChangeCategory || (result.Reply && canReply)) && !s.cfg.DryRun { fresh, err := s.glpi.GetTicket(ctx, id) if err != nil { run.Reason = "prewrite_ticket_recheck_failed" finish(err) return err } if sourceVersion(fresh) != run.SourceVersion { run.Outcome = "skipped" run.Reason = "ticket_changed_before_write" if result.ChangeCategory { run.CategoryDecision = "category_ticket_changed_before_write" } if result.Reply && canReply { run.ReplyDecision = "reply_ticket_changed_before_write" } run.PolicyReason = run.CategoryDecision + "; " + run.ReplyDecision s.metrics.Skipped.Add(1) finish(nil) return nil } } if result.ChangeCategory && !s.cfg.DryRun { if err := s.glpi.SetCategory(ctx, id, result.CategoryID); err != nil { run.Reason = "category_write_failed" run.CategoryDecision = "category_write_failed" run.PolicyReason = run.CategoryDecision + "; " + run.ReplyDecision finish(err) return err } run.CategoryChanged = true run.CategoryDecision = "category_written" s.metrics.CategoryChanged.Add(1) } else if result.ChangeCategory { run.CategoryDecision = "category_accepted_dry_run" } run.PolicyReason = run.CategoryDecision + "; " + run.ReplyDecision if result.Reply && canReply { // If category was just changed by this process, date_mod will legitimately // differ. Compare the decision-relevant ticket fields instead and require // the category we expect before posting a reply. if !s.cfg.DryRun { fresh, err := s.glpi.GetTicket(ctx, id) if err != nil { run.Reason = "prereply_ticket_recheck_failed" finish(err) return err } expectedCategory := t.CategoryID if result.ChangeCategory { expectedCategory = result.CategoryID } if !sameDecisionSource(t, fresh, expectedCategory) { run.ReplyProposed = false run.Reason = "ticket_changed_before_reply" run.ReplyDecision = "reply_ticket_changed_before_write" run.PolicyReason = run.CategoryDecision + "; " + run.ReplyDecision run.Outcome = "skipped" s.metrics.Skipped.Add(1) finish(nil) return nil } } followups, err = s.glpi.GetFollowups(ctx, id) if err != nil { run.Reason = "followup_recheck_failed" finish(err) return err } if len(followups) > 0 { run.ReplyProposed = false run.Reason = "followup_appeared_before_write" run.ReplyDecision = "reply_followup_appeared_before_write" } else if !s.cfg.DryRun { if err := s.glpi.AddFollowup(ctx, id, result.ReplyText); err != nil { run.Reason = "reply_write_failed" run.ReplyDecision = "reply_write_failed" run.PolicyReason = run.CategoryDecision + "; " + run.ReplyDecision finish(err) return err } run.ReplyWritten = true run.ReplyDecision = "reply_written" s.metrics.Replies.Add(1) } else { run.ReplyDecision = "reply_accepted_dry_run" } run.PolicyReason = run.CategoryDecision + "; " + run.ReplyDecision } // Persist the final GLPI version after our own write so the next poll does // not immediately process the same self-induced modification again. if !s.cfg.DryRun && (run.CategoryChanged || run.ReplyWritten) { if finalTicket, e := s.glpi.GetTicket(ctx, id); e == nil { run.SourceVersion = sourceVersion(finalTicket) } } run.PolicyReason = run.CategoryDecision + "; " + run.ReplyDecision run.Outcome = "processed" s.metrics.Processed.Add(1) finish(nil) return nil } func (s *Service) getCategories(ctx context.Context) ([]model.Category, error) { s.catMu.RLock() if len(s.categories) > 0 && time.Since(s.catAt) < 10*time.Minute { out := append([]model.Category(nil), s.categories...) s.catMu.RUnlock() return s.enrichCategories(out), nil } s.catMu.RUnlock() cats, err := s.glpi.GetCategories(ctx) if err != nil { return nil, err } s.catMu.Lock() s.categories = append([]model.Category(nil), cats...) s.catAt = time.Now() s.catMu.Unlock() return s.enrichCategories(cats), nil } func (s *Service) enrichCategories(cats []model.Category) []model.Category { out := append([]model.Category(nil), cats...) byID := make(map[int64]*model.Category, len(out)) for i := range out { byID[out[i].ID] = &out[i] out[i].Hints = append(out[i].Hints, semanticCategoryHints(out[i])...) } for _, doc := range s.knowledge.List() { for _, id := range doc.Categories { if c := byID[id]; c != nil { c.Hints = appendUnique(c.Hints, doc.Title) for _, k := range doc.Keywords { c.Hints = appendUnique(c.Hints, k) } } } } if s.cfg.LearningEnabled && s.learning != nil { for i := range out { out[i].Examples = s.learning.ExamplesFor(out[i].ID, s.cfg.LearningExamplesPerCategory) } } return out } // Categories exposes the same enriched category catalogue that is supplied to // Ollama. It is used by the authenticated dashboard for human feedback. func (s *Service) Categories(ctx context.Context) ([]model.Category, error) { return s.getCategories(ctx) } func (s *Service) RecordCategoryFeedback(ctx context.Context, runID string, categoryID int64) (model.LearningExample, error) { if !s.cfg.LearningEnabled || s.learning == nil { return model.LearningExample{}, fmt.Errorf("learning is disabled") } run, ok := s.state.FindRun(strings.TrimSpace(runID)) if !ok { return model.LearningExample{}, fmt.Errorf("run not found") } cats, err := s.getCategories(ctx) if err != nil { return model.LearningExample{}, err } name := categoryName(cats, categoryID) if categoryID <= 0 || name == "" { return model.LearningExample{}, fmt.Errorf("unknown category id %d", categoryID) } t, err := s.glpi.GetTicket(ctx, run.TicketID) if err != nil { return model.LearningExample{}, err } if sourceVersion(t) != run.SourceVersion { return model.LearningExample{}, fmt.Errorf("ticket changed since this run; process the current ticket state before teaching it") } ex := model.LearningExample{RunID: run.RunID, TicketID: t.ID, Subject: strings.TrimSpace(t.Name), Text: compactLearningText(stripHTML(t.Content), 1200), CategoryID: categoryID, CategoryName: name, AIRecommendedCategoryID: run.AIRecommendedCategoryID, AIConfidence: run.AICategoryConfidence, Correction: run.AIRecommendedCategoryID != categoryID, Source: "human-confirmed"} return s.learning.Add(ex) } func (s *Service) LearningExamples() []model.LearningExample { if s.learning == nil { return nil } return s.learning.List() } func (s *Service) DeleteLearning(id string) error { if s.learning == nil { return fmt.Errorf("learning is disabled") } return s.learning.Delete(id) } func (s *Service) LearningCount() int { if s.learning == nil { return 0 } return s.learning.Count() } func appendUnique(in []string, v string) []string { v = strings.TrimSpace(v) if v == "" { return in } for _, x := range in { if strings.EqualFold(strings.TrimSpace(x), v) { return in } } return append(in, v) } func compactLearningText(v string, max int) string { v = strings.Join(strings.Fields(v), " ") r := []rune(v) if len(r) <= max { return v } return string(r[:max]) + "…" } func semanticCategoryHints(c model.Category) []string { name := strings.ToLower(c.Name + " " + c.CompleteName) var h []string add := func(vals ...string) { for _, v := range vals { h = appendUnique(h, v) } } if strings.Contains(name, "active directory") || strings.Contains(name, "entra") || strings.Contains(name, "identity") || strings.Contains(name, "benutzerkonto") || strings.Contains(name, "account") { add("Benutzerkonto", "Anmeldung / Login", "Konto gesperrt", "Passwort", "Domänenkonto", "Gruppen und Berechtigungen", "Authentifizierung") } if strings.Contains(name, "druck") || strings.Contains(name, "printer") { add("Drucker", "Drucken nicht möglich", "Druckwarteschlange", "Netzwerkdrucker", "Toner", "Papierstau") } if strings.Contains(name, "vpn") { add("VPN-Verbindung", "Remote Access", "Gateway", "GlobalProtect", "Tunnel", "Verbindungsaufbau") } if strings.Contains(name, "mail") || strings.Contains(name, "outlook") || strings.Contains(name, "exchange") { add("E-Mail", "Outlook", "Postfach", "E-Mail Versand und Empfang", "Exchange") } if strings.Contains(name, "netz") || strings.Contains(name, "network") || strings.Contains(name, "wlan") || strings.Contains(name, "wifi") { add("Netzwerk", "LAN", "WLAN", "Keine Verbindung", "DNS", "IP-Adresse") } if strings.Contains(name, "hardware") || strings.Contains(name, "client") || strings.Contains(name, "arbeitsplatz") { add("Arbeitsplatzgerät", "Notebook", "PC", "Dockingstation", "Peripherie") } return h } func categoryName(categories []model.Category, id int64) string { if id == 0 { return "Nicht gesetzt" } for _, c := range categories { if c.ID == id { return categoryDisplayName(c) } } return "" } func (s *Service) statusAllowed(id int64) bool { for _, allowed := range s.cfg.GLPIAllowedStatusIDs { if id == allowed { return true } } return false } func sourceVersion(t model.Ticket) string { // Do not rely on date_mod alone: two changes can happen within the same // timestamp resolution and some API projections may omit it. Requesters and // linked items are decision-relevant because they feed the context collector. payload := fmt.Sprintf("%d\x00%s\x00%s\x00%s\x00%d\x00%d\x00%v\x00%v", t.ID, t.DateMod, t.Name, t.Content, t.StatusID, t.CategoryID, t.RequesterIDs, t.Items) h := sha256.Sum256([]byte(payload)) return hex.EncodeToString(h[:]) } func sameDecisionSource(original, fresh model.Ticket, expectedCategory int64) bool { if fresh.Name != original.Name || fresh.Content != original.Content || fresh.StatusID != original.StatusID || fresh.CategoryID != expectedCategory { return false } if fmt.Sprint(fresh.RequesterIDs) != fmt.Sprint(original.RequesterIDs) || fmt.Sprint(fresh.Items) != fmt.Sprint(original.Items) { return false } return true } func newRunID() string { b := make([]byte, 8); _, _ = rand.Read(b); return hex.EncodeToString(b) } func stripHTML(s string) string { r := strings.NewReplacer("
", "\n", "
", "\n", "
", "\n", "

", "\n") s = r.Replace(s) var b strings.Builder inside := false for _, ch := range s { if ch == '<' { inside = true continue } if ch == '>' { inside = false continue } if !inside { b.WriteRune(ch) } } return strings.TrimSpace(b.String()) } func shortlistCategories(t model.Ticket, cats []model.Category, limit int) []model.Category { if limit <= 0 || len(cats) <= limit { return cats } q := strings.Fields(strings.ToLower(t.Name + " " + stripHTML(t.Content))) type scored struct { c model.Category s int } ss := make([]scored, 0, len(cats)) for _, c := range cats { name := strings.ToLower(c.Name + " " + c.CompleteName + " " + strings.Join(c.Hints, " ") + " " + strings.Join(c.Examples, " ")) score := 0 for _, w := range q { if len(w) >= 3 && strings.Contains(name, w) { score++ } } if c.ID == t.CategoryID { score += 100 } ss = append(ss, scored{c, score}) } sort.SliceStable(ss, func(i, j int) bool { return ss[i].s > ss[j].s }) out := make([]model.Category, 0, limit) for i := 0; i < limit && i < len(ss); i++ { out = append(out, ss[i].c) } return out }