diff --git a/BETRIEBSANLEITUNG_GLPI_AI_AGENT.md b/BETRIEBSANLEITUNG_GLPI_AI_AGENT.md index aa11b4f..3db2d43 100644 --- a/BETRIEBSANLEITUNG_GLPI_AI_AGENT.md +++ b/BETRIEBSANLEITUNG_GLPI_AI_AGENT.md @@ -817,8 +817,9 @@ Für gemeinsam genutzte Knowledge-Verzeichnisse ist `unscoped` der kompatibelste | `GLPI_KB_LIMIT` | Agent | Maximale Anzahl GLPI-KB-Artikel. | 1–5000. | 500 | 500 | Optional; Wirkung abhängig von aktivierten Funktionen. | | `GLPI_KB_SYNC_INTERVAL` | Agent | Synchronisationsintervall. | Dauer >= 1m. | 10m | 10m | Optional; Wirkung abhängig von aktivierten Funktionen. | | `GLPI_KB_SOURCE` | Agent | source-Wert importierter GLPI-KB-Artikel. | Freier Text beziehungsweise installationsspezifischer Wert. | glpi-kb | glpi-kb | Optional; Wirkung abhängig von aktivierten Funktionen. | -| `GLPI_KB_AUTO_REPLY` | Agent | GLPI-KB-Artikel können grundsätzlich Auto-Replies auslösen. Zusätzlich gelten weiterhin alle anderen Policy-Gates. | true \| false | false | true | Bei true: GLPI_KB_SOURCE muss in normalen und Auto-Reply-Quellen stehen; Kategorie-ID-Whitelist darf nicht leer sein. | -| `GLPI_KB_AUTO_REPLY_CATEGORY_IDS` | Agent | Whitelist der GLPI KNOWLEDGE-BASE-Kategorie-IDs. WICHTIG: Dies sind NICHT die ITIL-/Ticketkategorie-IDs. Mehrere Werte: 1,2,7 | Kommagetrennte positive GLPI-KB-Kategorie-IDs; leer/none = keine. | nicht ermittelt | 1 | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `GLPI_KB_AUTO_REPLY` | Agent | GLPI-KB-Artikel können grundsätzlich Auto-Replies auslösen. Zusätzlich gelten weiterhin alle anderen Policy-Gates. | true \| false | false | true | Bei true: GLPI_KB_SOURCE muss in normalen und Auto-Reply-Quellen stehen; mindestens eine der beiden Kategorie-Whitelists muss befüllt sein. | +| `GLPI_KB_AUTO_REPLY_CATEGORY_IDS` | Agent | Whitelist der GLPI KNOWLEDGE-BASE-Kategorie-IDs. WICHTIG: Dies sind NICHT die ITIL-/Ticketkategorie-IDs. Sind beide Whitelists befüllt, genügt ein Treffer in einer der Listen. | Kommagetrennte positive GLPI-KB-Kategorie-IDs; leer/none = keine. | leer | 1 | Alternative zu oder Kombination mit GLPI_KB_AUTO_REPLY_ITIL_CATEGORY_IDS. | +| `GLPI_KB_AUTO_REPLY_ITIL_CATEGORY_IDS` | Agent | Alternative Whitelist der gemappten GLPI Ticket-/ITIL-Kategorie-IDs. Diese IDs entsprechen den im Ticket und in der Kategorie-Diagnose sichtbaren Kategorien. | Kommagetrennte positive ITIL-Kategorie-IDs; leer/none = keine. | leer | leer | Bei GLPI_KB_AUTO_REPLY=true muss diese oder die KB-Kategorie-Whitelist befüllt sein. | ## 20. HUMAN-IN-THE-LOOP / KATEGORIE-LERNEN | ENV | Geltungsbereich | Bedeutung und Auswirkung | Mögliche Werte / Format | Code-Default | `.env.example` | Pflicht / Abhängigkeiten | @@ -952,7 +953,7 @@ Für gemeinsam genutzte Knowledge-Verzeichnisse ist `unscoped` der kompatibelste | `QUEUE_SIZE` | Agent | Maximale Anzahl wartender Jobs. | Ganzzahl >= 1. | 256 | 256 | Optional; Wirkung abhängig von aktivierten Funktionen. | | `WORKERS` | Agent | Parallele Ticket-Worker. Darf größer als die Gesamtzahl gleichzeitig verfügbarer Node-Slots sein. Ollama wird je Node durch OLLAMA_NODE_MAX_INFLIGHT begrenzt. | Ganzzahl >= 1. | 2 | 2 | Optional; Wirkung abhängig von aktivierten Funktionen. | -> **Vollständigkeitskontrolle:** In dieser Referenz sind 178 Variablen beschrieben, einschließlich `AGENT_IMAGE` aus dem Registry-Compose und aller 177 Zuweisungen aus `.env.example`. +> **Vollständigkeitskontrolle:** In dieser Referenz sind 191 Variablen beschrieben, einschließlich `AGENT_IMAGE` aus dem Registry-Compose und aller 190 Zuweisungen aus `.env.example`. # 14. Fehlerbehebung diff --git a/HOTFIX-GLPI-KB-AUTO-REPLY.md b/HOTFIX-GLPI-KB-AUTO-REPLY.md new file mode 100644 index 0000000..8533b87 --- /dev/null +++ b/HOTFIX-GLPI-KB-AUTO-REPLY.md @@ -0,0 +1,106 @@ +# Hotfix: GLPI-KB-Artikel für Auto-Antworten freigeben + +## Problem + +Ein synchronisierter GLPI-Wissensartikel konnte die Prüfung zur effektiven Ticketkategorie bestehen und trotzdem an folgender Regel scheitern: + +```text +Artikel ist für Auto-Reply freigegeben +``` + +Das sind zwei getrennte Prüfungen: + +1. **Kategorie-Scope:** Passt die gemappte GLPI-Ticket-/ITIL-Kategorie des Artikels zum Ticket? +2. **Artikel-Freigabe:** Wurde der synchronisierte GLPI-KB-Artikel ausdrücklich für automatische Antworten freigegeben? + +Bisher konnte die zweite Freigabe ausschließlich über die separaten **GLPI-Knowledge-Base-Kategorie-IDs** erfolgen: + +```env +GLPI_KB_AUTO_REPLY_CATEGORY_IDS=... +``` + +Wer dort versehentlich die im Ticket sichtbaren ITIL-Kategorie-IDs eingetragen hat, erhielt einen positiven Kategorie-Scope, aber weiterhin `auto_reply=false`. + +## Neue Konfigurationsmöglichkeit + +Zusätzlich steht jetzt eine Whitelist für die gemappten Ticket-/ITIL-Kategorien zur Verfügung: + +```env +GLPI_KB_AUTO_REPLY_ITIL_CATEGORY_IDS=38,67 +``` + +Ein GLPI-KB-Artikel wird grundsätzlich für Auto-Reply markiert, wenn alle allgemeinen Voraussetzungen gelten und mindestens eine der beiden ausdrücklich konfigurierten Whitelists trifft: + +```env +GLPI_KB_AUTO_REPLY=true +KNOWLEDGE_ALLOWED_SOURCES=internal-kb,glpi-kb +KNOWLEDGE_AUTO_REPLY_SOURCES=internal-kb,glpi-kb + +# Variante A: separate GLPI-KB-Kategorie-IDs +GLPI_KB_AUTO_REPLY_CATEGORY_IDS=4,7 + +# Variante B: im Ticket sichtbare ITIL-Kategorie-IDs +GLPI_KB_AUTO_REPLY_ITIL_CATEGORY_IDS=38,67 +``` + +Sind beide Listen befüllt, genügt ein Treffer in einer der Listen. Ohne mindestens eine Liste verweigert die Konfigurationsprüfung den Start bei `GLPI_KB_AUTO_REPLY=true`. + +## Diagnose + +Jeder synchronisierte GLPI-KB-Artikel enthält nun: + +```json +{ + "auto_reply": false, + "auto_reply_decision": "glpi_kb_category_not_whitelisted", + "auto_reply_detail": "GLPI-KB-Kategorien: [9]; gemappte ITIL-Kategorien: [38]; freigegebene GLPI-KB-Kategorien: [4, 7]; freigegebene ITIL-Kategorien: []; keine konfigurierte Freigabe-Whitelist trifft zu" +} +``` + +Mögliche Entscheidungen sind unter anderem: + +| Entscheidung | Bedeutung | +|---|---| +| `glpi_kb_auto_reply_approved` | Eine konfigurierte KB- oder ITIL-Whitelist trifft zu. | +| `glpi_kb_auto_reply_disabled` | `GLPI_KB_AUTO_REPLY=false`. | +| `glpi_kb_article_without_category` | Der Artikel besitzt keine aus GLPI gelesene KB-Kategorie. | +| `glpi_kb_category_not_mapped_to_itil` | Die KB-Kategorie ist keiner Ticket-/ITIL-Kategorie zugeordnet. | +| `glpi_kb_category_not_whitelisted` | Weder die KB- noch die ITIL-Whitelist trifft zu. | +| `glpi_kb_auto_reply_whitelist_empty` | Keine Whitelist ist wirksam. | + +Die Regel **„Artikel ist für Auto-Reply freigegeben“** zeigt diese Ursache jetzt direkt im Detailtext. Auch der Knowledge-Inspector und die Kandidatenaudits enthalten die Freigabeentscheidung. + +Das Dashboard zeigt außerdem: + +- Anzahl freigegebener GLPI-KB-Artikel, +- Anzahl blockierter GLPI-KB-Artikel, +- Verteilung der Freigabeentscheidungen, +- konfigurierte KB-Kategorie-IDs, +- konfigurierte ITIL-Kategorie-IDs. + +## Migrationsbeispiel + +Wenn bisher beispielsweise die Ticketkategorie `38` irrtümlich hier eingetragen war: + +```env +GLPI_KB_AUTO_REPLY_CATEGORY_IDS=38 +``` + +sollte die Konfiguration geändert werden zu: + +```env +GLPI_KB_AUTO_REPLY_CATEGORY_IDS= +GLPI_KB_AUTO_REPLY_ITIL_CATEGORY_IDS=38 +``` + +Der Agent gibt zusätzlich eine Warnung aus, wenn Werte in `GLPI_KB_AUTO_REPLY_CATEGORY_IDS` nicht als KB-Kategorie vorkommen, aber als ITIL-Kategorie existieren. + +## Nach der Änderung + +1. Agent neu starten. +2. Auf den Logeintrag `GLPI knowledge base synchronized` warten. +3. Dort `auto_reply_approved` und `auto_reply_blocked` prüfen. +4. Im Knowledge-Inspector den Artikel öffnen. +5. Einen neuen Ticketlauf oder eine manuelle Neuanalyse starten. + +Der bestehende `glpi-kb-cache.json` wird beim erfolgreichen initialen Sync überschrieben. Historische Ticketläufe werden nicht rückwirkend verändert. diff --git a/README.md b/README.md index 88a629a..8257b0c 100644 --- a/README.md +++ b/README.md @@ -250,7 +250,14 @@ GLPI_KB_SOURCE=glpi-kb Erfolgreich synchronisierte Artikel werden normalisiert, lokal unter `DATA_DIR/glpi-kb-cache.json` gecacht und in denselben RAG-Index wie lokale Knowledge-Dokumente aufgenommen. Unveränderte Dokumente behalten ihre gecachten Embeddings; nur neue oder geänderte Artikel werden erneut eingebettet. Fällt GLPI bei einem späteren Start/Sync aus, kann der zuletzt gespeicherte Cache weiter als read-only Wissensstand geladen werden. -GLPI-KB-Auto-Replies sind absichtlich separat gesperrt. Für eine Freigabe müssen **alle** Bedingungen erfüllt sein: `glpi-kb` ist in `KNOWLEDGE_AUTO_REPLY_SOURCES`, `GLPI_KB_AUTO_REPLY=true`, die GLPI-KB-Kategorie des Artikels steht in `GLPI_KB_AUTO_REPLY_CATEGORY_IDS`, und GLPI liefert eine Zuordnung dieser KB-Kategorie zu mindestens einer ITIL-Kategorie. Beispiel: +GLPI-KB-Auto-Replies sind absichtlich separat gesperrt. Für eine Freigabe müssen **alle** allgemeinen Bedingungen erfüllt sein: `glpi-kb` ist in `KNOWLEDGE_AUTO_REPLY_SOURCES`, `GLPI_KB_AUTO_REPLY=true`, GLPI liefert eine Zuordnung zu mindestens einer ITIL-Kategorie und mindestens eine ausdrücklich konfigurierte Kategorie-Whitelist trifft zu. + +Zur Freigabe stehen zwei alternative Whitelists zur Verfügung: + +- `GLPI_KB_AUTO_REPLY_CATEGORY_IDS`: GLPI-**Knowledge-Base-Kategorie-IDs** des Artikels. +- `GLPI_KB_AUTO_REPLY_ITIL_CATEGORY_IDS`: gemappte GLPI-**Ticket-/ITIL-Kategorie-IDs**. + +Sind beide Listen gesetzt, genügt ein Treffer in einer der Listen. Beispiel über die KB-Kategorie: ```env KNOWLEDGE_AUTO_REPLY_SOURCES=internal-kb,glpi-kb @@ -258,7 +265,16 @@ GLPI_KB_AUTO_REPLY=true GLPI_KB_AUTO_REPLY_CATEGORY_IDS=4,7 ``` -`GLPI_KB_AUTO_REPLY_CATEGORY_IDS` enthält **Knowledge-Base-Kategorie-IDs aus GLPI**, nicht Ticket-/ITIL-Kategorie-IDs. Ohne diese explizite Whitelist dienen GLPI-Artikel nur Recherche, RAG und Kategorie-Hinweisen. +Alternativ kann direkt mit den im Ticket sichtbaren ITIL-Kategorien freigegeben werden: + +```env +KNOWLEDGE_AUTO_REPLY_SOURCES=internal-kb,glpi-kb +GLPI_KB_AUTO_REPLY=true +GLPI_KB_AUTO_REPLY_CATEGORY_IDS= +GLPI_KB_AUTO_REPLY_ITIL_CATEGORY_IDS=38,67 +``` + +Ohne mindestens eine explizite Whitelist dienen GLPI-Artikel nur Recherche, RAG und Kategorie-Hinweisen. Die Diagnose zeigt bei jedem synchronisierten Artikel künftig den konkreten Freigabegrund, die GLPI-KB-Kategorie-IDs, die gemappten ITIL-Kategorie-IDs und die wirksamen Whitelists. Optional kann `GLPI_KB_FILTER` gesetzt werden, um die von GLPI gelieferte Collection zusätzlich serverseitig einzuschränken. Die konkrete Filtersyntax und verfügbare Felder stammen aus der OpenAPI-Dokumentation deiner installierten GLPI-Version. diff --git a/dist/SHA256SUMS.txt b/dist/SHA256SUMS.txt index 3f541d4..c7b5495 100644 --- a/dist/SHA256SUMS.txt +++ b/dist/SHA256SUMS.txt @@ -1,2 +1,2 @@ -c196be36f1a99e079b13310cc266fd4ba9b8b4b3c29ccc5a6dcda808cdaace0c glpi-ai-agent-linux-amd64 -54e5f3eb9408a640a1608b46bedb2ae52e25cf175e4246a4ffd22429f1be9a9c glpi-ai-agent-windows-amd64.exe +a4ff8028aa7d59607eab68080b24a5b9e5a396bc17663730c5f56814e00b796e glpi-ai-agent-linux-amd64 +7c70db8ce9b89f382484865164101f02bc967e804209b4b53516c902e53a97b0 glpi-ai-agent-windows-amd64.exe diff --git a/dist/glpi-ai-agent-linux-amd64 b/dist/glpi-ai-agent-linux-amd64 index 2135700..3d294b4 100644 Binary files a/dist/glpi-ai-agent-linux-amd64 and b/dist/glpi-ai-agent-linux-amd64 differ diff --git a/dist/glpi-ai-agent-windows-amd64.exe b/dist/glpi-ai-agent-windows-amd64.exe index 4f13f86..20b82ea 100644 Binary files a/dist/glpi-ai-agent-windows-amd64.exe and b/dist/glpi-ai-agent-windows-amd64.exe differ diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 2341075..2cfe056 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -1009,6 +1009,7 @@ func auditKnowledgeCandidates(hits []model.KnowledgeHit, globalMin float64, limi ID: h.Doc.ID, Title: h.Doc.Title, Source: h.Doc.Source, Score: h.Score, SemanticScore: h.SemanticScore, TitleScore: h.TitleScore, LexicalScore: h.LexicalScore, KeywordScore: h.KeywordScore, CategoryScore: h.CategoryScore, RequiredScore: required, AutoReply: h.Doc.AutoReply, + AutoReplyDecision: h.Doc.AutoReplyDecision, AutoReplyDetail: h.Doc.AutoReplyDetail, BestChunkExcerpt: h.BestChunkExcerpt, BestQueryExcerpt: h.BestQueryExcerpt, QueryChunkCount: h.QueryChunkCount, DocumentChunkCount: h.DocumentChunkCount, SentToAI: wasSent, RetrievalRank: idx + 1, SelectionReason: reason, }) diff --git a/internal/agent/policy.go b/internal/agent/policy.go index 7e0f877..8a779a4 100644 --- a/internal/agent/policy.go +++ b/internal/agent/policy.go @@ -171,8 +171,20 @@ func (p Policy) Evaluate(t model.Ticket, d model.Decision, categories []model.Ca check("communication", "reply_style_match", "Stil des Artikels passt zur Kommunikationspolicy", passFail(styleOK), !styleOK, selected.Doc.CommunicationStyle, p.CommunicationStyle, ""), ) autoDetail := "" + if strings.TrimSpace(selected.Doc.AutoReplyDecision) != "" { + autoDetail = selected.Doc.AutoReplyDecision + } + if strings.TrimSpace(selected.Doc.AutoReplyDetail) != "" { + if autoDetail != "" { + autoDetail += ": " + } + autoDetail += selected.Doc.AutoReplyDetail + } if len(selected.Doc.UnmappedExternalCategories) > 0 { - autoDetail = "Nicht zugeordnete externe Kategorien: " + strings.Join(selected.Doc.UnmappedExternalCategories, ", ") + if autoDetail != "" { + autoDetail += "; " + } + autoDetail += "Nicht zugeordnete externe Kategorien: " + strings.Join(selected.Doc.UnmappedExternalCategories, ", ") } res.ReplyChecks = append(res.ReplyChecks, check("knowledge", "reply_knowledge_auto_reply", "Artikel ist für Auto-Reply freigegeben", passFail(selected.Doc.AutoReply), !selected.Doc.AutoReply, boolText(selected.Doc.AutoReply), "true", autoDetail)) diff --git a/internal/config/config.go b/internal/config/config.go index ecbfc18..ca58ecf 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -93,6 +93,11 @@ type Config struct { GLPIKBSource string GLPIKBAutoReply bool GLPIKBAutoReplyCategoryIDs []int64 + // GLPIKBAutoReplyITILCategoryIDs optionally whitelists the mapped GLPI + // ticket/ITIL categories for synchronized KB articles. This complements + // GLPIKBAutoReplyCategoryIDs, which contains GLPI knowledge-base category + // IDs. A match in either explicitly configured whitelist is sufficient. + GLPIKBAutoReplyITILCategoryIDs []int64 LearningEnabled bool LearningMaxExamples int @@ -272,6 +277,7 @@ func Load() (Config, error) { GLPIKBSource: strings.ToLower(env("GLPI_KB_SOURCE", "glpi-kb")), GLPIKBAutoReply: envBool("GLPI_KB_AUTO_REPLY", false), GLPIKBAutoReplyCategoryIDs: envInt64ListAllowEmpty("GLPI_KB_AUTO_REPLY_CATEGORY_IDS"), + GLPIKBAutoReplyITILCategoryIDs: envInt64ListAllowEmpty("GLPI_KB_AUTO_REPLY_ITIL_CATEGORY_IDS"), LearningEnabled: envBool("LEARNING_ENABLED", true), LearningMaxExamples: envInt("LEARNING_MAX_EXAMPLES", 500), LearningExamplesPerCategory: envInt("LEARNING_EXAMPLES_PER_CATEGORY", 5), @@ -666,8 +672,8 @@ func (c Config) Validate() error { if _, ok := allowedSources[glpiKBSource]; !ok { return fmt.Errorf("GLPI_KB_SOURCE %q must be present in KNOWLEDGE_ALLOWED_SOURCES when GLPI_KB_AUTO_REPLY=true", c.GLPIKBSource) } - if len(c.GLPIKBAutoReplyCategoryIDs) == 0 { - return errors.New("GLPI_KB_AUTO_REPLY_CATEGORY_IDS must contain at least one GLPI KB category when GLPI_KB_AUTO_REPLY=true") + if len(c.GLPIKBAutoReplyCategoryIDs) == 0 && len(c.GLPIKBAutoReplyITILCategoryIDs) == 0 { + return errors.New("GLPI_KB_AUTO_REPLY_CATEGORY_IDS or GLPI_KB_AUTO_REPLY_ITIL_CATEGORY_IDS must contain at least one category when GLPI_KB_AUTO_REPLY=true") } found := false for _, source := range c.KnowledgeAutoReplySources { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 2cff975..4e3f873 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -154,12 +154,17 @@ func TestValidateGLPIKBAutoReplyRequiresCategoryWhitelist(t *testing.T) { c.KnowledgeAutoReplySources = []string{"internal-kb", "glpi-kb"} c.GLPIKBAutoReply = true if err := c.Validate(); err == nil { - t.Fatal("expected auto reply category whitelist to be required") + t.Fatal("expected an auto reply category whitelist to be required") } c.GLPIKBAutoReplyCategoryIDs = []int64{3} if err := c.Validate(); err != nil { t.Fatalf("expected explicit category whitelist to validate: %v", err) } + c.GLPIKBAutoReplyCategoryIDs = nil + c.GLPIKBAutoReplyITILCategoryIDs = []int64{38} + if err := c.Validate(); err != nil { + t.Fatalf("expected explicit ITIL category whitelist to validate: %v", err) + } } func TestValidateRejectsInvalidKnowledgeScoringConfig(t *testing.T) { diff --git a/internal/glpikb/sync.go b/internal/glpikb/sync.go index 1ce54cf..931351f 100644 --- a/internal/glpikb/sync.go +++ b/internal/glpikb/sync.go @@ -116,6 +116,7 @@ func (s *Syncer) Sync(ctx context.Context) error { return fmt.Errorf("load GLPI ITIL categories for KB mapping: %w", err) } docs := s.normalize(items, cats) + autoReplyApproved, autoReplyBlocked := autoReplyCounts(docs) if err := s.store.ReplaceExternalSource(ctx, s.cfg.GLPIKBSource, docs); err != nil { s.fail(err) return fmt.Errorf("replace GLPI knowledge source: %w", err) @@ -134,10 +135,27 @@ func (s *Syncer) Sync(ctx context.Context) error { s.mu.Unlock() s.metrics.SetGLPIKBStatus(true, len(docs), now, "") s.metrics.SetKnowledgeDocs(s.store.Count()) - slog.Info("GLPI knowledge base synchronized", "documents", len(docs), "path", path) + slog.Info( + "GLPI knowledge base synchronized", + "documents", len(docs), + "auto_reply_approved", autoReplyApproved, + "auto_reply_blocked", autoReplyBlocked, + "path", path, + ) return nil } +func autoReplyCounts(docs []model.KnowledgeDoc) (approved, blocked int) { + for _, doc := range docs { + if doc.AutoReply { + approved++ + } else { + blocked++ + } + } + return approved, blocked +} + func (s *Syncer) Start(ctx context.Context) { if !s.cfg.GLPIKBEnabled { return @@ -162,15 +180,24 @@ func (s *Syncer) Start(ctx context.Context) { func (s *Syncer) normalize(items []model.GLPIKnowledgeItem, cats []model.Category) []model.KnowledgeDoc { kbToITIL := map[int64][]model.Category{} + itilCategoryIDs := map[int64]struct{}{} for _, c := range cats { + if c.ID > 0 { + itilCategoryIDs[c.ID] = struct{}{} + } if c.KnowbaseCategoryID > 0 { kbToITIL[c.KnowbaseCategoryID] = append(kbToITIL[c.KnowbaseCategoryID], c) } } - autoCats := map[int64]struct{}{} + autoKBCats := map[int64]struct{}{} for _, id := range s.cfg.GLPIKBAutoReplyCategoryIDs { - autoCats[id] = struct{}{} + autoKBCats[id] = struct{}{} } + autoITILCats := map[int64]struct{}{} + for _, id := range s.cfg.GLPIKBAutoReplyITILCategoryIDs { + autoITILCats[id] = struct{}{} + } + warnLikelyITILIDs(s.cfg.GLPIKBAutoReply, s.cfg.GLPIKBAutoReplyCategoryIDs, items, itilCategoryIDs) out := make([]model.KnowledgeDoc, 0, len(items)) for _, item := range items { text := cleanHTML(item.Content) @@ -194,18 +221,13 @@ func (s *Syncer) normalize(items []model.GLPIKnowledgeItem, cats []model.Categor } } sort.Slice(itilIDs, func(i, j int) bool { return itilIDs[i] < itilIDs[j] }) - auto := false - if s.cfg.GLPIKBAutoReply { - for _, id := range item.CategoryIDs { - if _, ok := autoCats[id]; ok { - auto = true - break - } - } - } - if auto && len(itilIDs) == 0 { - auto = false - } + auto, autoDecision, autoDetail := autoReplyApproval( + s.cfg.GLPIKBAutoReply, + item.CategoryIDs, + itilIDs, + autoKBCats, + autoITILCats, + ) language := strings.TrimSpace(item.Language) if language == "" { language = s.cfg.CommunicationLanguage @@ -213,7 +235,8 @@ func (s *Syncer) normalize(items []model.GLPIKnowledgeItem, cats []model.Categor out = append(out, model.KnowledgeDoc{ ID: "GLPI-KB-" + strconv.FormatInt(item.ID, 10), Title: strings.TrimSpace(item.Title), Text: text, Answer: text, AnswerHTML: strings.TrimSpace(item.Content), - AutoReply: auto, MinScore: 0, Categories: itilIDs, Keywords: uniqueStrings(keywords), + AutoReply: auto, AutoReplyDecision: autoDecision, AutoReplyDetail: autoDetail, + MinScore: 0, Categories: itilIDs, Keywords: uniqueStrings(keywords), Source: s.cfg.GLPIKBSource, SourceURI: "glpi://KnowbaseItem/" + strconv.FormatInt(item.ID, 10), SourceCategoryIDs: append([]int64(nil), item.CategoryIDs...), SourceModifiedAt: item.ModifiedAt, Language: language, CommunicationStyle: s.cfg.CommunicationStyle, @@ -222,6 +245,86 @@ func (s *Syncer) normalize(items []model.GLPIKnowledgeItem, cats []model.Categor return out } +func autoReplyApproval(enabled bool, sourceCategoryIDs, itilCategoryIDs []int64, allowedSourceCategories, allowedITILCategories map[int64]struct{}) (bool, string, string) { + detail := fmt.Sprintf( + "GLPI-KB-Kategorien: %v; gemappte ITIL-Kategorien: %v; freigegebene GLPI-KB-Kategorien: %v; freigegebene ITIL-Kategorien: %v", + sourceCategoryIDs, + itilCategoryIDs, + sortedSetIDs(allowedSourceCategories), + sortedSetIDs(allowedITILCategories), + ) + if !enabled { + return false, "glpi_kb_auto_reply_disabled", detail + "; GLPI_KB_AUTO_REPLY=false" + } + if len(sourceCategoryIDs) == 0 { + return false, "glpi_kb_article_without_category", detail + "; der Artikel besitzt keine GLPI-KB-Kategorie" + } + if len(itilCategoryIDs) == 0 { + return false, "glpi_kb_category_not_mapped_to_itil", detail + "; keine GLPI-Ticketkategorie ist mit der KB-Kategorie verknüpft" + } + sourceMatch := intersectsSet(sourceCategoryIDs, allowedSourceCategories) + itilMatch := intersectsSet(itilCategoryIDs, allowedITILCategories) + if sourceMatch || itilMatch { + matchedBy := "GLPI-KB-Kategorie" + if itilMatch && !sourceMatch { + matchedBy = "ITIL-/Ticketkategorie" + } else if sourceMatch && itilMatch { + matchedBy = "GLPI-KB- und ITIL-/Ticketkategorie" + } + return true, "glpi_kb_auto_reply_approved", detail + "; Freigabe über " + matchedBy + } + if len(allowedSourceCategories) == 0 && len(allowedITILCategories) == 0 { + return false, "glpi_kb_auto_reply_whitelist_empty", detail + "; keine Freigabe-Whitelist konfiguriert" + } + return false, "glpi_kb_category_not_whitelisted", detail + "; keine konfigurierte Freigabe-Whitelist trifft zu" +} + +func intersectsSet(ids []int64, allowed map[int64]struct{}) bool { + for _, id := range ids { + if _, ok := allowed[id]; ok { + return true + } + } + return false +} + +func sortedSetIDs(values map[int64]struct{}) []int64 { + out := make([]int64, 0, len(values)) + for id := range values { + out = append(out, id) + } + sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) + return out +} + +func warnLikelyITILIDs(enabled bool, configured []int64, items []model.GLPIKnowledgeItem, itilCategoryIDs map[int64]struct{}) { + if !enabled || len(configured) == 0 { + return + } + sourceIDs := map[int64]struct{}{} + for _, item := range items { + for _, id := range item.CategoryIDs { + sourceIDs[id] = struct{}{} + } + } + var suspicious []int64 + for _, id := range configured { + _, existsAsSource := sourceIDs[id] + _, existsAsITIL := itilCategoryIDs[id] + if !existsAsSource && existsAsITIL { + suspicious = append(suspicious, id) + } + } + if len(suspicious) > 0 { + sort.Slice(suspicious, func(i, j int) bool { return suspicious[i] < suspicious[j] }) + slog.Warn( + "GLPI_KB_AUTO_REPLY_CATEGORY_IDS appears to contain ITIL/ticket category IDs", + "ids", suspicious, + "hint", "move these IDs to GLPI_KB_AUTO_REPLY_ITIL_CATEGORY_IDS or configure the actual GLPI knowledge-base category IDs", + ) + } +} + func (s *Syncer) currentPath() string { s.mu.RLock(); defer s.mu.RUnlock(); return s.resolvedPath } func (s *Syncer) fail(err error) { s.mu.Lock() diff --git a/internal/glpikb/sync_test.go b/internal/glpikb/sync_test.go index e2903e5..0aeb51c 100644 --- a/internal/glpikb/sync_test.go +++ b/internal/glpikb/sync_test.go @@ -3,6 +3,7 @@ package glpikb import ( "context" "path/filepath" + "strings" "testing" "time" @@ -46,7 +47,53 @@ func TestSyncMapsGLPIKBAndAutoReplyWhitelist(t *testing.T) { if d.ID != "GLPI-KB-12" || !d.AutoReply || len(d.Categories) != 1 || d.Categories[0] != 2 || d.Text != "Bitte entsperren." || d.AnswerHTML != "
Bitte entsperren.
" { t.Fatalf("unexpected doc: %+v", d) } + if d.AutoReplyDecision != "glpi_kb_auto_reply_approved" || d.AutoReplyDetail == "" { + t.Fatalf("expected auditable approval reason, got decision=%q detail=%q", d.AutoReplyDecision, d.AutoReplyDetail) + } if _, err := filepath.Abs(cfg.DataDir); err != nil { t.Fatal(err) } } + +func TestSyncAllowsAutoReplyByMappedITILCategory(t *testing.T) { + cfg := config.Config{ + DataDir: t.TempDir(), GLPIKBEnabled: true, GLPIKBPath: "auto", GLPIKBLimit: 50, + GLPIKBSyncInterval: time.Minute, GLPIKBSource: "glpi-kb", GLPIKBAutoReply: true, + GLPIKBAutoReplyITILCategoryIDs: []int64{2}, CommunicationLanguage: "de-DE", + CommunicationStyle: "formal", GLPITimeout: time.Second, + } + st := &fakeStore{} + s := New(cfg, fakeSource{}, st, metrics.New()) + if err := s.Sync(context.Background()); err != nil { + t.Fatal(err) + } + if len(st.docs) != 1 || !st.docs[0].AutoReply { + t.Fatalf("mapped ITIL category should approve auto reply: %+v", st.docs) + } + if got := st.docs[0].AutoReplyDetail; !strings.Contains(got, "ITIL-/Ticketkategorie") { + t.Fatalf("approval detail should name ITIL category path, got %q", got) + } +} + +func TestSyncExplainsBlockedAutoReply(t *testing.T) { + cfg := config.Config{ + DataDir: t.TempDir(), GLPIKBEnabled: true, GLPIKBPath: "auto", GLPIKBLimit: 50, + GLPIKBSyncInterval: time.Minute, GLPIKBSource: "glpi-kb", GLPIKBAutoReply: true, + GLPIKBAutoReplyCategoryIDs: []int64{99}, CommunicationLanguage: "de-DE", + CommunicationStyle: "formal", GLPITimeout: time.Second, + } + st := &fakeStore{} + s := New(cfg, fakeSource{}, st, metrics.New()) + if err := s.Sync(context.Background()); err != nil { + t.Fatal(err) + } + if len(st.docs) != 1 || st.docs[0].AutoReply { + t.Fatalf("non-matching whitelist must keep auto reply disabled: %+v", st.docs) + } + if got := st.docs[0].AutoReplyDecision; got != "glpi_kb_category_not_whitelisted" { + t.Fatalf("decision=%q", got) + } + if got := st.docs[0].AutoReplyDetail; !strings.Contains(got, "GLPI-KB-Kategorien: [9]") || !strings.Contains(got, "freigegebene GLPI-KB-Kategorien: [99]") { + t.Fatalf("detail should expose source and configured IDs, got %q", got) + } +} diff --git a/internal/knowledge/store.go b/internal/knowledge/store.go index 0c186a1..5c762c8 100644 --- a/internal/knowledge/store.go +++ b/internal/knowledge/store.go @@ -17,7 +17,6 @@ import ( "time" "unicode" - "github.com/example/glpi-ai-agent/internal/brainactivity" "github.com/example/glpi-ai-agent/internal/model" ) @@ -1074,7 +1073,6 @@ func safeID(v string) bool { // are scored separately. Missing metadata does not lower a document's score: // the weights of available components are normalized dynamically. func (s *Store) Search(ctx context.Context, text string, topK int, categorySets ...[]model.Category) ([]model.KnowledgeHit, error) { - startedAt := time.Now() if s == nil { return nil, fmt.Errorf("knowledge store is not initialized") } @@ -1193,11 +1191,6 @@ func (s *Store) Search(ctx context.Context, text string, topK int, categorySets if topK > 0 && len(hits) > topK { hits = hits[:topK] } - activityHits := make([]brainactivity.Hit, 0, len(hits)) - for _, hit := range hits { - activityHits = append(activityHits, brainactivity.Hit{ID: hit.Doc.ID, Score: hit.Score}) - } - brainactivity.EmitSearch("agent", text, activityHits, time.Since(startedAt)) return hits, nil } diff --git a/internal/model/model.go b/internal/model/model.go index 4faade3..fa5a863 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -72,8 +72,13 @@ type KnowledgeDoc struct { Answer string `json:"answer"` // AnswerHTML contains trusted rich text from a synchronized GLPI KB item. // It is never sent to the LLM or used for embeddings. - AnswerHTML string `json:"answer_html,omitempty"` - AutoReply bool `json:"auto_reply"` + AnswerHTML string `json:"answer_html,omitempty"` + AutoReply bool `json:"auto_reply"` + // AutoReplyDecision explains why a synchronized source document is or is + // not eligible for automatic replies. Local JSON documents may leave this + // empty because their explicit auto_reply flag is already authoritative. + AutoReplyDecision string `json:"auto_reply_decision,omitempty"` + AutoReplyDetail string `json:"auto_reply_detail,omitempty"` MinScore float64 `json:"min_score"` Categories []int64 `json:"categories"` ExternalCategories []string `json:"external_categories,omitempty"` @@ -305,6 +310,8 @@ type KnowledgeCandidateAudit struct { CategoryScore float64 `json:"category_score,omitempty"` RequiredScore float64 `json:"required_score,omitempty"` AutoReply bool `json:"auto_reply"` + AutoReplyDecision string `json:"auto_reply_decision,omitempty"` + AutoReplyDetail string `json:"auto_reply_detail,omitempty"` BestChunkExcerpt string `json:"best_chunk_excerpt,omitempty"` BestQueryExcerpt string `json:"best_query_excerpt,omitempty"` QueryChunkCount int `json:"query_chunk_count,omitempty"` diff --git a/internal/web/server.go b/internal/web/server.go index 1706286..e28951d 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -348,11 +348,13 @@ func (s *Server) diagnosticKnowledgeSearch(w http.ResponseWriter, r *http.Reques limit = n } type item struct { - ID string `json:"id"` - Title string `json:"title"` - Source string `json:"source"` - AutoReply bool `json:"auto_reply"` - Unmapped []string `json:"unmapped_categories,omitempty"` + ID string `json:"id"` + Title string `json:"title"` + Source string `json:"source"` + AutoReply bool `json:"auto_reply"` + AutoReplyDecision string `json:"auto_reply_decision,omitempty"` + AutoReplyDetail string `json:"auto_reply_detail,omitempty"` + Unmapped []string `json:"unmapped_categories,omitempty"` } out := make([]item, 0, limit) for _, d := range s.knowledge.List() { @@ -360,7 +362,7 @@ func (s *Server) diagnosticKnowledgeSearch(w http.ResponseWriter, r *http.Reques if q != "" && !strings.Contains(hay, q) { continue } - out = append(out, item{ID: d.ID, Title: d.Title, Source: d.Source, AutoReply: d.AutoReply, Unmapped: append([]string(nil), d.UnmappedExternalCategories...)}) + out = append(out, item{ID: d.ID, Title: d.Title, Source: d.Source, AutoReply: d.AutoReply, AutoReplyDecision: d.AutoReplyDecision, AutoReplyDetail: d.AutoReplyDetail, Unmapped: append([]string(nil), d.UnmappedExternalCategories...)}) if len(out) >= limit { break } @@ -372,6 +374,24 @@ func (s *Server) status(w http.ResponseWriter, r *http.Request) { g, o := s.metrics.Health() poll := s.metrics.PollStatus() kbOK, kbDocs, kbLastSync, kbLastErr := s.metrics.GLPIKBStatus() + glpiKBAutoReplyApproved := 0 + glpiKBAutoReplyBlocked := 0 + glpiKBAutoReplyDecisions := map[string]int{} + for _, doc := range s.knowledge.List() { + if !strings.EqualFold(strings.TrimSpace(doc.Source), strings.TrimSpace(s.cfg.GLPIKBSource)) { + continue + } + if doc.AutoReply { + glpiKBAutoReplyApproved++ + } else { + glpiKBAutoReplyBlocked++ + } + decision := strings.TrimSpace(doc.AutoReplyDecision) + if decision == "" { + decision = "legacy_or_unspecified" + } + glpiKBAutoReplyDecisions[decision]++ + } loadStats := s.knowledge.LoadStats() initStatus := s.knowledge.InitStatus() ollamaNodes := []model.OllamaNodeStatus{} @@ -405,14 +425,14 @@ func (s *Server) status(w http.ResponseWriter, r *http.Request) { "context_enabled": s.cfg.ContextEnabled, "context_fetches": s.metrics.ContextFetches.Load(), "context_errors": s.metrics.ContextErrors.Load(), "change_calendar_enabled": s.cfg.ChangeCalendarEnabled, "major_incidents_enabled": s.cfg.MajorIncidentsEnabled, "user_device_context_enabled": s.cfg.UserDeviceContextEnabled, "knowledge_edit_enabled": s.cfg.KnowledgeWebEditEnabled, "learning_enabled": s.cfg.LearningEnabled, "learning_examples": s.feedback.LearningCount(), - "glpi_kb_enabled": s.cfg.GLPIKBEnabled, "glpi_kb_ok": kbOK, "glpi_kb_documents": kbDocs, "glpi_kb_last_sync": kbLastSync, "glpi_kb_last_error": kbLastErr, "glpi_kb_source": s.cfg.GLPIKBSource, "glpi_kb_sync_interval": s.cfg.GLPIKBSyncInterval.String(), + "glpi_kb_enabled": s.cfg.GLPIKBEnabled, "glpi_kb_ok": kbOK, "glpi_kb_documents": kbDocs, "glpi_kb_last_sync": kbLastSync, "glpi_kb_last_error": kbLastErr, "glpi_kb_source": s.cfg.GLPIKBSource, "glpi_kb_sync_interval": s.cfg.GLPIKBSyncInterval.String(), "glpi_kb_auto_reply_approved": glpiKBAutoReplyApproved, "glpi_kb_auto_reply_blocked": glpiKBAutoReplyBlocked, "glpi_kb_auto_reply_decisions": glpiKBAutoReplyDecisions, "uptime_kuma_enabled": s.cfg.UptimeKumaEnabled, "uptime_kuma_mode": s.cfg.UptimeKumaMode, "uptime_kuma_status_pages": s.cfg.UptimeKumaStatusPages, "context_fail_closed": s.cfg.ContextBlockReplyOnError, "context_incident_block": s.cfg.ContextBlockReplyOnIncident, "context_status_reply_enabled": s.cfg.ContextStatusReplyEnabled, "context_status_reply_min_relevance": s.cfg.ContextStatusReplyMinRelevance, "context_status_reply_min_ai_confidence": s.cfg.ContextStatusReplyMinAIConfidence, "context_status_reply_min_final_score": s.cfg.ContextStatusReplyMinFinalScore, "context_incident_reply_text_configured": strings.TrimSpace(s.cfg.ContextIncidentReplyText) != "", "context_maintenance_reply_text_configured": strings.TrimSpace(s.cfg.ContextMaintenanceReplyText) != "", "workers": s.cfg.Workers, "queue_size": s.cfg.QueueSize, "glpi_api_version": s.cfg.GLPIAPIVersion, "glpi_poll_interval": s.cfg.GLPIPollInterval.String(), "glpi_poll_limit": s.cfg.GLPIPollLimit, "glpi_allowed_status_ids": s.cfg.GLPIAllowedStatusIDs, "glpi_ticket_filter_configured": strings.TrimSpace(s.cfg.GLPITicketFilter) != "", "glpi_timeout": s.cfg.GLPITimeout.String(), "ollama_model": s.cfg.OllamaModel, "ollama_embedding_model": s.cfg.OllamaEmbeddingModel, "ollama_timeout": s.cfg.OllamaTimeout.String(), "ollama_num_predict": s.cfg.OllamaNumPredict, "ollama_keep_alive": s.cfg.OllamaKeepAlive.String(), "ollama_think": s.cfg.OllamaThink, "ollama_max_concurrent": s.cfg.OllamaMaxConcurrent, "ollama_json_retries": s.cfg.OllamaJSONRetries, "ollama_nodes": ollamaNodes, "ollama_node_count": len(ollamaNodes), "ollama_healthy_nodes": ollamaHealthyNodes, "ollama_available_nodes": ollamaAvailableNodes, "ollama_routing_mode": ollamaRoutingMode, "ollama_node_max_inflight": s.cfg.OllamaNodeMaxInflight, "ollama_node_health_interval": s.cfg.OllamaNodeHealthInterval.String(), "ollama_node_failure_cooldown": s.cfg.OllamaNodeFailureCooldown.String(), "ollama_node_request_timeout": s.cfg.OllamaNodeRequestTimeout.String(), "ollama_failover_enabled": s.cfg.OllamaFailoverEnabled, "ollama_failover_attempts": s.cfg.OllamaFailoverAttempts, "ollama_require_same_model_digest": s.cfg.OllamaRequireSameDigest, "ollama_require_embedding_model": s.cfg.OllamaRequireEmbeddingModel, "rag_enabled": s.cfg.RAGEnabled, "knowledge_top_k": s.cfg.KnowledgeTopK, "knowledge_audit_top_k": s.cfg.KnowledgeAuditTopK, "knowledge_candidate_max_gap": s.cfg.KnowledgeCandidateMaxGap, "category_prompt_limit": s.cfg.CategoryPromptLimit, "knowledge_max_query_chunks": s.cfg.KnowledgeMaxQueryChunks, - "glpi_kb_path": s.cfg.GLPIKBPath, "glpi_kb_filter_configured": strings.TrimSpace(s.cfg.GLPIKBFilter) != "", "glpi_kb_limit": s.cfg.GLPIKBLimit, "glpi_kb_auto_reply": s.cfg.GLPIKBAutoReply, "glpi_kb_auto_reply_category_ids": s.cfg.GLPIKBAutoReplyCategoryIDs, + "glpi_kb_path": s.cfg.GLPIKBPath, "glpi_kb_filter_configured": strings.TrimSpace(s.cfg.GLPIKBFilter) != "", "glpi_kb_limit": s.cfg.GLPIKBLimit, "glpi_kb_auto_reply": s.cfg.GLPIKBAutoReply, "glpi_kb_auto_reply_category_ids": s.cfg.GLPIKBAutoReplyCategoryIDs, "glpi_kb_auto_reply_itil_category_ids": s.cfg.GLPIKBAutoReplyITILCategoryIDs, "learning_max_examples": s.cfg.LearningMaxExamples, "learning_examples_per_category": s.cfg.LearningExamplesPerCategory, "context_timeout": s.cfg.ContextTimeout.String(), "context_relevance_min_score": s.cfg.ContextRelevanceMinScore, "change_lookback": s.cfg.ChangeLookback.String(), "change_lookahead": s.cfg.ChangeLookahead.String(), }) diff --git a/internal/web/templates/dashboard.html b/internal/web/templates/dashboard.html index 6f023d2..dc15085 100644 --- a/internal/web/templates/dashboard.html +++ b/internal/web/templates/dashboard.html @@ -145,7 +145,7 @@ function renderKB(){const st=kbStatsData();$('#kbStats').innerHTML=st.map(x=>`