diff --git a/.env.example b/.env.example index 85618ed..cd429fc 100644 --- a/.env.example +++ b/.env.example @@ -518,16 +518,35 @@ GLPI_KB_SOURCE=glpi-kb # true: # GLPI-KB-Artikel können grundsätzlich Auto-Replies auslösen. # -# Zusätzlich gelten weiterhin alle anderen Policy-Gates. +# Zusätzlich gelten weiterhin alle anderen Policy-Gates wie Retrieval, +# KI-Auswahl, Evidenz, Sprache, Stil und vorhandene Antworten. GLPI_KB_AUTO_REPLY=true # Whitelist der GLPI KNOWLEDGE-BASE-Kategorie-IDs. # # WICHTIG: -# Dies sind NICHT die ITIL-/Ticketkategorie-IDs. +# Dies sind NICHT die ITIL-/Ticketkategorie-IDs. Ein kategorisierter Artikel +# ist genau dann grundsätzlich für Auto-Reply freigegeben, wenn mindestens +# eine seiner GLPI-KB-Kategorien hier enthalten ist. # # Mehrere Werte: # 1,2,7 GLPI_KB_AUTO_REPLY_CATEGORY_IDS=1 +# VERALTET / WIRD IGNORIERT: +# Ticket-/ITIL-Kategorien geben einen GLPI-Wissensartikel nicht mehr für +# Auto-Reply frei. Die Variable bleibt nur erhalten, damit alte .env-Dateien +# verständlich migriert werden können. Wert bitte leeren oder entfernen. +GLPI_KB_AUTO_REPLY_ITIL_CATEGORY_IDS= +# GLPI-KB-Artikel ohne Knowledge-Base-Kategorie bleiben standardmäßig gesperrt. +# +# true: +# Solche Artikel dürfen ausschließlich dann Auto-Reply verwenden, wenn ihre +# konkrete GLPI-KnowbaseItem-ID zusätzlich in +# GLPI_KB_AUTO_REPLY_UNCATEGORIZED_ARTICLE_IDS steht. +GLPI_KB_AUTO_REPLY_ALLOW_UNCATEGORIZED=false +# Explizite GLPI-KnowbaseItem-IDs für unkategorisierte Artikel. +# Beispiel: Das synchronisierte Dokument GLPI-KB-1 entspricht Artikel-ID 1. +# Diese Liste ist bei ALLOW_UNCATEGORIZED=true verpflichtend. +GLPI_KB_AUTO_REPLY_UNCATEGORIZED_ARTICLE_IDS= ############################################################################### # 20. HUMAN-IN-THE-LOOP / KATEGORIE-LERNEN ############################################################################### diff --git a/BETRIEBSANLEITUNG_GLPI_AI_AGENT.md b/BETRIEBSANLEITUNG_GLPI_AI_AGENT.md index 3db2d43..8198c0a 100644 --- a/BETRIEBSANLEITUNG_GLPI_AI_AGENT.md +++ b/BETRIEBSANLEITUNG_GLPI_AI_AGENT.md @@ -817,9 +817,39 @@ 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; 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. | +| `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; außerdem ist mindestens eine KB-Kategorie oder eine explizite Allowlist unkategorisierter Artikel erforderlich. | +| `GLPI_KB_AUTO_REPLY_CATEGORY_IDS` | Agent | Whitelist der GLPI-Knowledge-Base-Kategorie-IDs. Ein kategorisierter Artikel ist nur dann grundsätzlich für Auto-Reply freigegeben, wenn mindestens eine seiner KB-Kategorien enthalten ist. Dies sind nicht die ITIL-/Ticketkategorie-IDs. | Kommagetrennte positive GLPI-KB-Kategorie-IDs; leer/none = keine. | leer | 1 | Kann allein oder zusammen mit der Allowlist unkategorisierter Artikel verwendet werden. | +| `GLPI_KB_AUTO_REPLY_ITIL_CATEGORY_IDS` | Agent | Veraltet und wirkungslos. ITIL-/Ticketkategorien geben Wissensartikel nicht mehr für Auto-Reply frei. Vorhandene Werte werden ignoriert und beim Start protokolliert. | Leer lassen oder Variable entfernen. | leer | leer | Nur für die Migration alter `.env`-Dateien dokumentiert. | +| `GLPI_KB_AUTO_REPLY_ALLOW_UNCATEGORIZED` | Agent | Erlaubt GLPI-KB-Artikel ohne KB-Kategorie ausschließlich über eine explizite Artikel-ID-Allowlist. | `true` \| `false` | false | false | Erfordert `GLPI_KB_AUTO_REPLY=true` und eine nicht leere `GLPI_KB_AUTO_REPLY_UNCATEGORIZED_ARTICLE_IDS`. | +| `GLPI_KB_AUTO_REPLY_UNCATEGORIZED_ARTICLE_IDS` | Agent | Explizite Allowlist der GLPI-`KnowbaseItem`-IDs, die den kategorielosen Auto-Reply-Fallback verwenden dürfen. Die Dokument-ID `GLPI-KB-1` entspricht der Artikel-ID `1`. | Kommagetrennte positive GLPI-KnowbaseItem-IDs; leer = keine. | leer | leer | Bei `GLPI_KB_AUTO_REPLY_ALLOW_UNCATEGORIZED=true` verpflichtend. Verhindert, dass automatisch alle kategorielosen GLPI-Artikel freigegeben werden. | + +### Kategorielose GLPI-KB-Artikel + +Die Auto-Reply-Grundfreigabe ist zweistufig und bewusst einfach: + +- Ein Artikel **mit** KB-Kategorie wird über `GLPI_KB_AUTO_REPLY_CATEGORY_IDS` freigegeben. +- Ein Artikel **ohne** KB-Kategorie wird über `GLPI_KB_AUTO_REPLY_ALLOW_UNCATEGORIZED=true` und seine konkrete `KnowbaseItem`-ID freigegeben. + +Beispiel: + +```env +GLPI_KB_AUTO_REPLY=true +GLPI_KB_AUTO_REPLY_CATEGORY_IDS=4,7 +GLPI_KB_AUTO_REPLY_ALLOW_UNCATEGORIZED=true +GLPI_KB_AUTO_REPLY_UNCATEGORIZED_ARTICLE_IDS=1,5 +GLPI_KB_AUTO_REPLY_ITIL_CATEGORY_IDS= +``` + +Dabei gilt: + +- Kategorien `4` und `7` sind GLPI-Knowledge-Base-Kategorien. +- Nur die unkategorisierten Artikel `1` und `5` sind zusätzlich freigegeben. +- Die Freigabeentscheidung lautet bei ihnen `glpi_kb_uncategorized_article_approved`. +- ITIL-/Ticketkategorien sind keine Freigabeliste mehr. +- Ein vorhandenes GLPI-Mapping zu ITIL-Kategorien kann weiterhin die fachliche Kategoriepassung und Evidenz beeinflussen. +- Fehlt ein solches Mapping, wird der Artikel nicht allein deshalb blockiert; Retrieval, KI-Auswahl, Evidenz, Sprache, Stil und Kontextregeln entscheiden weiter. +- Alte GLPI-KB-Caches mit der früheren ITIL-Freigabelogik werden nicht geladen und nach dem nächsten erfolgreichen Sync ersetzt. + ## 20. HUMAN-IN-THE-LOOP / KATEGORIE-LERNEN | ENV | Geltungsbereich | Bedeutung und Auswirkung | Mögliche Werte / Format | Code-Default | `.env.example` | Pflicht / Abhängigkeiten | diff --git a/HOTFIX-GLPI-KB-AUTO-REPLY.md b/HOTFIX-GLPI-KB-AUTO-REPLY.md index 8533b87..b4c7ed3 100644 --- a/HOTFIX-GLPI-KB-AUTO-REPLY.md +++ b/HOTFIX-GLPI-KB-AUTO-REPLY.md @@ -1,3 +1,5 @@ +> **Historischer Stand:** Dieses Dokument beschreibt eine ältere Freigabelogik. Maßgeblich ist jetzt `HOTFIX-GLPI-KB-SIMPLE-AUTO-REPLY.md`. ITIL-Kategorien geben Artikel nicht mehr für Auto-Reply frei. + # Hotfix: GLPI-KB-Artikel für Auto-Antworten freigeben ## Problem diff --git a/HOTFIX-GLPI-KB-SIMPLE-AUTO-REPLY.md b/HOTFIX-GLPI-KB-SIMPLE-AUTO-REPLY.md new file mode 100644 index 0000000..44a62fa --- /dev/null +++ b/HOTFIX-GLPI-KB-SIMPLE-AUTO-REPLY.md @@ -0,0 +1,92 @@ +# Hotfix: vereinfachte GLPI-KB-Auto-Reply-Freigabe + +## Ziel + +Die Grundfreigabe eines GLPI-Wissensartikels ist vollständig von der fachlichen Eignungsprüfung getrennt. + +## Neue Freigaberegel + +### Artikel mit GLPI-Knowledge-Base-Kategorie + +Ein Artikel ist grundsätzlich für Auto-Reply freigegeben, wenn mindestens eine seiner GLPI-KB-Kategorie-IDs in dieser Liste steht: + +```env +GLPI_KB_AUTO_REPLY_CATEGORY_IDS=4,7 +``` + +ITIL-/Ticketkategorien spielen für diese Grundfreigabe keine Rolle. + +### Artikel ohne GLPI-Knowledge-Base-Kategorie + +Ein unkategorisierter Artikel ist nur freigegeben, wenn beide Bedingungen erfüllt sind: + +```env +GLPI_KB_AUTO_REPLY_ALLOW_UNCATEGORIZED=true +GLPI_KB_AUTO_REPLY_UNCATEGORIZED_ARTICLE_IDS=1,5 +``` + +Die IDs sind die numerischen GLPI-`KnowbaseItem`-IDs. `GLPI-KB-1` entspricht Artikel-ID `1`. + +## Veraltete Variable + +```env +GLPI_KB_AUTO_REPLY_ITIL_CATEGORY_IDS= +``` + +Die Variable wird aus Kompatibilitätsgründen noch eingelesen, aber nicht mehr ausgewertet. Ist sie befüllt, schreibt der Agent eine Warnung ins Log. Der Wert sollte geleert oder die Variable entfernt werden. + +## Fachliche Eignung bleibt separat + +Nach der Grundfreigabe müssen weiterhin alle fachlichen und technischen Gates bestehen: + +- Retrieval-Floor, +- KI-Auswahl, +- KI-Confidence, +- finale Knowledge-Evidenz, +- Sprache und Kommunikationsstil, +- Antwortinhalt, +- Kontext- und Incident-Regeln, +- vorhandene Followups, +- Dry-Run-/Live-Schreibregeln. + +Soweit GLPI ein Mapping von KB-Kategorien auf ITIL-Kategorien liefert, wird es nur für die separate Prüfung **„Artikel passt zur effektiven Ticketkategorie“** und für Kategorie-Evidenz verwendet. Fehlt das Mapping oder ist der Kategorie-Endpunkt nicht erreichbar, läuft der KB-Sync weiter; die Freigabe über die KB-Kategorie bleibt gültig. + +## Neue Diagnoseentscheidungen + +- `glpi_kb_auto_reply_approved`: Freigabe über eine GLPI-KB-Kategorie. +- `glpi_kb_uncategorized_article_approved`: Freigabe eines unkategorisierten Artikels über seine konkrete Artikel-ID. +- `glpi_kb_category_not_whitelisted`: Keine Artikel-KB-Kategorie steht in der Allowlist. +- `glpi_kb_article_without_category`: Artikel ist unkategorisiert, aber der Fallback ist deaktiviert. +- `glpi_kb_uncategorized_article_not_whitelisted`: Unkategorisierter Artikel ist nicht explizit freigegeben. +- `glpi_kb_auto_reply_whitelist_empty`: Für kategorisierte Artikel ist keine KB-Kategorie freigegeben. + +Die Policyentscheidung bei fehlender Grundfreigabe lautet jetzt: + +```text +reply_knowledge_auto_reply_not_approved +``` + +## Cache-Migration + +Der GLPI-KB-Cache enthält eine Policy-Version. Caches aus der vorherigen ITIL-basierten Freigabelogik werden aus Sicherheitsgründen nicht geladen. Beim nächsten erfolgreichen GLPI-KB-Sync wird `data/glpi-kb-cache.json` automatisch im neuen Format erstellt. + +## Empfohlene Konfiguration + +```env +AUTO_REPLY=true +KNOWLEDGE_ALLOWED_SOURCES=internal-kb,glpi-kb +KNOWLEDGE_AUTO_REPLY_SOURCES=internal-kb,glpi-kb + +GLPI_KB_ENABLED=true +GLPI_KB_AUTO_REPLY=true + +# Kategorisierte Artikel +GLPI_KB_AUTO_REPLY_CATEGORY_IDS=4,7 + +# Unkategorisierte Artikel +GLPI_KB_AUTO_REPLY_ALLOW_UNCATEGORIZED=true +GLPI_KB_AUTO_REPLY_UNCATEGORIZED_ARTICLE_IDS=1,5 + +# Veraltet; leer lassen +GLPI_KB_AUTO_REPLY_ITIL_CATEGORY_IDS= +``` diff --git a/HOTFIX-GLPI-KB-UNCATEGORIZED-AUTO-REPLY.md b/HOTFIX-GLPI-KB-UNCATEGORIZED-AUTO-REPLY.md new file mode 100644 index 0000000..05a016e --- /dev/null +++ b/HOTFIX-GLPI-KB-UNCATEGORIZED-AUTO-REPLY.md @@ -0,0 +1,81 @@ +> **Historischer Stand:** Dieses Dokument beschreibt eine ältere Freigabelogik. Maßgeblich ist jetzt `HOTFIX-GLPI-KB-SIMPLE-AUTO-REPLY.md`. ITIL-Kategorien geben Artikel nicht mehr für Auto-Reply frei. + +# Hotfix: Auto-Reply mit kategorielosen GLPI-KB-Artikeln + +## Problem + +GLPI kann einem Knowledge-Base-Artikel keine ITIL-/Ticketkategorie direkt zuweisen. Besitzt der Artikel außerdem keine GLPI-KB-Kategorie, liefert die Synchronisierung: + +```text +glpi_kb_article_without_category +``` + +Die bisherige statische Freigabe konnte deshalb nicht erkennen, für welche Ticketkategorien der Artikel verwendet werden darf. + +## Lösung + +Neu: + +```env +GLPI_KB_AUTO_REPLY_ALLOW_UNCATEGORIZED=true +GLPI_KB_AUTO_REPLY_UNCATEGORIZED_ARTICLE_IDS=1 +``` + +Ein kategorieloser Artikel wird damit nur bedingt freigegeben. Die tatsächliche Freigabe erfolgt beim Ticketlauf gegen: + +```env +GLPI_KB_AUTO_REPLY_ITIL_CATEGORY_IDS=38,67 +``` + +Die IDs werden in der `.env` konfiguriert; sie müssen und können nicht am GLPI-Artikel eingetragen werden. + +## Sicherheitslogik + +Ein Auto-Reply ist nur möglich, wenn: + +1. `AUTO_REPLY=true` +2. `GLPI_KB_AUTO_REPLY=true` +3. `glpi-kb` in `KNOWLEDGE_ALLOWED_SOURCES` und `KNOWLEDGE_AUTO_REPLY_SOURCES` steht +4. `GLPI_KB_AUTO_REPLY_ALLOW_UNCATEGORIZED=true` +5. die GLPI-KnowbaseItem-ID in `GLPI_KB_AUTO_REPLY_UNCATEGORIZED_ARTICLE_IDS` steht +6. die effektive Ticketkategorie in `GLPI_KB_AUTO_REPLY_ITIL_CATEGORY_IDS` steht +7. Retrieval, KI-Auswahl, Confidence, Evidenz, Sprache, Stil, Kontext und Antwortinhalt alle bestehen + +Die ITIL-Allowlist wird nicht als Artikelkategorie gespeichert und erhöht nicht künstlich die Kategorie-Evidenz. + +## Diagnose + +Synchronisierung: + +```text +glpi_kb_uncategorized_conditionally_approved +``` + +Passendes Ticket: + +```text +Artikel ist für Auto-Reply freigegeben: ja +Erwartet: effektive Ticketkategorie in [38 67] +``` + +Nicht passende Ticketkategorie: + +```text +reply_knowledge_auto_reply_category_not_allowed +``` + +## Beispiel + +```env +AUTO_REPLY=true +KNOWLEDGE_ALLOWED_SOURCES=internal-kb,glpi-kb +KNOWLEDGE_AUTO_REPLY_SOURCES=internal-kb,glpi-kb +GLPI_KB_ENABLED=true +GLPI_KB_AUTO_REPLY=true +GLPI_KB_AUTO_REPLY_CATEGORY_IDS= +GLPI_KB_AUTO_REPLY_ITIL_CATEGORY_IDS=4,5,6,7,8,9,10 +GLPI_KB_AUTO_REPLY_ALLOW_UNCATEGORIZED=true +GLPI_KB_AUTO_REPLY_UNCATEGORIZED_ARTICLE_IDS=1 +``` + +Sehr breite ITIL-Allowlisten sollten zunächst im `DRY_RUN=true` getestet werden. diff --git a/README.md b/README.md index 8257b0c..9beba41 100644 --- a/README.md +++ b/README.md @@ -250,31 +250,40 @@ 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** 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. +GLPI-KB-Auto-Replies sind absichtlich separat gesperrt. Die Grundfreigabe ist jetzt bewusst einfach und entspricht der GLPI-Datenstruktur: -Zur Freigabe stehen zwei alternative Whitelists zur Verfügung: +1. **Artikel mit GLPI-Knowledge-Base-Kategorie:** Mindestens eine Artikel-KB-Kategorie muss in `GLPI_KB_AUTO_REPLY_CATEGORY_IDS` enthalten sein. +2. **Artikel ohne GLPI-Knowledge-Base-Kategorie:** `GLPI_KB_AUTO_REPLY_ALLOW_UNCATEGORIZED=true` und die konkrete GLPI-KnowbaseItem-ID muss in `GLPI_KB_AUTO_REPLY_UNCATEGORIZED_ARTICLE_IDS` stehen. +3. **ITIL-/Ticketkategorien geben keinen Artikel frei.** Sie können, soweit GLPI ein Mapping liefert, weiterhin als fachliches Signal für Retrieval, Evidenz und die separate Prüfung „Artikel passt zur effektiven Ticketkategorie“ dienen. -- `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: +Beispiel für kategorisierte Artikel: ```env KNOWLEDGE_AUTO_REPLY_SOURCES=internal-kb,glpi-kb GLPI_KB_AUTO_REPLY=true GLPI_KB_AUTO_REPLY_CATEGORY_IDS=4,7 +GLPI_KB_AUTO_REPLY_ALLOW_UNCATEGORIZED=false +GLPI_KB_AUTO_REPLY_UNCATEGORIZED_ARTICLE_IDS= ``` -Alternativ kann direkt mit den im Ticket sichtbaren ITIL-Kategorien freigegeben werden: +Beispiel für einen ausdrücklich freigegebenen Artikel ohne KB-Kategorie: ```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 +GLPI_KB_AUTO_REPLY_CATEGORY_IDS=4,7 +GLPI_KB_AUTO_REPLY_ALLOW_UNCATEGORIZED=true +GLPI_KB_AUTO_REPLY_UNCATEGORIZED_ARTICLE_IDS=1,5 ``` -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. +Damit sind die kategorisierten Artikel der KB-Kategorien `4` und `7` sowie ausschließlich die unkategorisierten GLPI-Artikel `1` und `5` grundsätzlich freigegeben. Alle weiteren Retrieval-, KI-, Evidenz-, Sprach-, Stil-, Kontext- und Ausführungsprüfungen bleiben unverändert. + +`GLPI_KB_AUTO_REPLY_ITIL_CATEGORY_IDS` ist veraltet und wird ignoriert. Alte Konfigurationen sollten den Wert leeren oder die Variable entfernen. Ein alter `glpi-kb-cache.json`, der noch mit der vorherigen ITIL-Freigabelogik erzeugt wurde, wird aus Sicherheitsgründen nicht geladen und beim nächsten erfolgreichen GLPI-KB-Sync im neuen Format ersetzt. + +Die Diagnose trennt jetzt zwei Fragen klar: + +- **„Artikel darf für Auto-Reply verwendet werden“**: reine Grundfreigabe über KB-Kategorie oder konkrete Artikel-ID. +- **„Artikel passt zur effektiven Ticketkategorie“**: fachliche Eignung auf Basis eines vorhandenen Mappings; fehlt ein Mapping, entscheiden Retrieval, KI-Auswahl und Evidenz. 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/SECURITY.md b/SECURITY.md index 667b04a..5b4f72e 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -119,3 +119,13 @@ Mehrere Ollama-Instanzen bilden eine zusätzliche interne Trust Boundary. Der Ag - Node-URLs, Namen und Modelldigests erscheinen in der Betriebsdiagnose. Keine Zugangsdaten in URLs einbetten. - Failover wiederholt ausschließlich den noch nicht akzeptierten Inferenzrequest. GLPI-Schreiboperationen erfolgen erst nach dem vollständigen KI-Lauf und den deterministischen Policies. - `OLLAMA_NODE_MAX_INFLIGHT=1` ist für integrierte GPUs und gemeinsam genutzten RAM der sichere Ausgangswert. + +## GLPI-KB-Auto-Reply-Freigabe + +Die Grundfreigabe synchronisierter GLPI-Wissensartikel ist fail-closed und von der fachlichen Ticketpassung getrennt: + +- Kategorisierte Artikel benötigen eine GLPI-Knowledge-Base-Kategorie aus `GLPI_KB_AUTO_REPLY_CATEGORY_IDS`. +- Unkategorisierte Artikel benötigen `GLPI_KB_AUTO_REPLY_ALLOW_UNCATEGORIZED=true` und ihre konkrete `KnowbaseItem`-ID in `GLPI_KB_AUTO_REPLY_UNCATEGORIZED_ARTICLE_IDS`. +- ITIL-/Ticketkategorien sind keine Freigabeschranke und `GLPI_KB_AUTO_REPLY_ITIL_CATEGORY_IDS` wird ignoriert. +- Ein vorhandenes ITIL-Mapping darf nur Retrieval, Evidenz und die separate fachliche Kategoriepassung beeinflussen. +- Der GLPI-KB-Cache enthält eine Policy-Version und einen Hash der Freigabekonfiguration. Veraltete oder mit einer anderen Allowlist erzeugte Caches werden nicht geladen. diff --git a/UPGRADE.md b/UPGRADE.md index 6852e2b..e6fb5a6 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -1,3 +1,28 @@ +# Upgrade: vereinfachte GLPI-KB-Auto-Reply-Freigabe + +Die ITIL-basierte Artikelfreigabe wurde entfernt. Vor dem Start sollte die `.env` angepasst werden: + +```env +# Nur GLPI-Knowledge-Base-Kategorie-IDs +GLPI_KB_AUTO_REPLY_CATEGORY_IDS=4,7 + +# Unkategorisierte Artikel nur über konkrete KnowbaseItem-IDs +GLPI_KB_AUTO_REPLY_ALLOW_UNCATEGORIZED=true +GLPI_KB_AUTO_REPLY_UNCATEGORIZED_ARTICLE_IDS=1,5 + +# Veraltet und wirkungslos +GLPI_KB_AUTO_REPLY_ITIL_CATEGORY_IDS= +``` + +Ein alter `data/glpi-kb-cache.json` wird wegen der geänderten Sicherheitsregel nicht geladen. Der Agent synchronisiert die GLPI-KB neu und schreibt danach einen Cache mit aktueller Policy-Version und einem Hash der relevanten Freigabekonfiguration. Ändert sich später die Allowlist, wird ein Cache mit der alten Freigabe ebenfalls nicht verwendet. + +Die Diagnose trennt jetzt: + +- `Artikel darf für Auto-Reply verwendet werden`: Grundfreigabe über KB-Kategorie oder Artikel-ID. +- `Artikel passt zur effektiven Ticketkategorie`: optionale fachliche Prüfung über ein vorhandenes GLPI-Mapping. + +Details stehen in `HOTFIX-GLPI-KB-SIMPLE-AUTO-REPLY.md`. + ## Hotfix für fehlende Prioritätsläufe Das vorherige Quellarchiv konnte durch ein zu breites Paket-Ausschlussmuster die Verzeichnisse `cmd/agent` und `internal/agent` verlieren. In diesem Fall enthielten neue Laufdatensätze keine `analyses` und keine Prioritätsfelder. Dieses Paket enthält den vollständigen Quellstand. Bitte den Agenten vollständig ersetzen und neu bauen beziehungsweise eines der neuen Programme aus `dist/` verwenden. Historische Läufe werden nicht rückwirkend ergänzt; erst ein neuer Ticketlauf zeigt die Prioritätsdiagnose. Weitere Einzelheiten stehen in `HOTFIX-PRIORITAET.md`. diff --git a/dist/SHA256SUMS.txt b/dist/SHA256SUMS.txt index c7b5495..a5a20a1 100644 --- a/dist/SHA256SUMS.txt +++ b/dist/SHA256SUMS.txt @@ -1,2 +1,2 @@ -a4ff8028aa7d59607eab68080b24a5b9e5a396bc17663730c5f56814e00b796e glpi-ai-agent-linux-amd64 -7c70db8ce9b89f382484865164101f02bc967e804209b4b53516c902e53a97b0 glpi-ai-agent-windows-amd64.exe +4b9859394e592706bc73da7754c8e7a3cf71459e8517c8d75bed0da2331fa98a dist/glpi-ai-agent-linux-amd64 +62c2f41fd05171a0d5750c97881f4c80196664016a5800ee69d7806ca059a698 dist/glpi-ai-agent-windows-amd64.exe diff --git a/dist/glpi-ai-agent-linux-amd64 b/dist/glpi-ai-agent-linux-amd64 index 3d294b4..3fafc4a 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 20b82ea..0be372b 100644 Binary files a/dist/glpi-ai-agent-windows-amd64.exe and b/dist/glpi-ai-agent-windows-amd64.exe differ diff --git a/glpi-ai-agent-neural-brain.patch b/glpi-ai-agent-neural-brain.patch new file mode 100644 index 0000000..ae000cf --- /dev/null +++ b/glpi-ai-agent-neural-brain.patch @@ -0,0 +1,128 @@ +diff --git a/internal/brainactivity/client.go b/internal/brainactivity/client.go +new file mode 100644 +index 0000000..fb16e0a +--- /dev/null ++++ b/internal/brainactivity/client.go +@@ -0,0 +1,90 @@ ++package brainactivity ++ ++import ( ++ "bytes" ++ "encoding/json" ++ "net/http" ++ "os" ++ "strings" ++ "sync" ++ "time" ++) ++ ++type Hit struct { ++ ID string `json:"id"` ++ Score float64 `json:"score,omitempty"` ++} ++ ++type event struct { ++ Type string `json:"type"` ++ Source string `json:"source"` ++ Query string `json:"query,omitempty"` ++ Message string `json:"message,omitempty"` ++ Hits []Hit `json:"hits,omitempty"` ++ Metadata map[string]any `json:"metadata,omitempty"` ++} ++ ++var sender = newSender() ++ ++type asyncSender struct { ++ once sync.Once ++ url string ++ key string ++ ch chan event ++ http *http.Client ++} ++ ++func newSender() *asyncSender { ++ return &asyncSender{ch: make(chan event, 128), http: &http.Client{Timeout: 3 * time.Second}} ++} ++ ++// EmitSearch is fail-open and has no effect unless BRAIN_ACTIVITY_URL is set. ++// It never blocks the ticket-processing path and silently drops telemetry when ++// the optional visualization is unavailable or the local queue is full. ++func EmitSearch(source, query string, hits []Hit, duration time.Duration) { ++ sender.once.Do(sender.start) ++ if sender.url == "" { ++ return ++ } ++ query = strings.TrimSpace(query) ++ if len([]rune(query)) > 4000 { ++ query = string([]rune(query)[:4000]) ++ } ++ e := event{ ++ Type: "knowledge.search", Source: source, Query: query, ++ Message: "Wissenssuche aus " + source, ++ Hits: hits, Metadata: map[string]any{"duration_ms": duration.Milliseconds(), "result_count": len(hits)}, ++ } ++ select { ++ case sender.ch <- e: ++ default: ++ } ++} ++ ++func (s *asyncSender) start() { ++ s.url = strings.TrimSpace(os.Getenv("BRAIN_ACTIVITY_URL")) ++ s.key = strings.TrimSpace(os.Getenv("BRAIN_ACTIVITY_API_KEY")) ++ if s.url == "" { ++ return ++ } ++ go func() { ++ for e := range s.ch { ++ b, err := json.Marshal(e) ++ if err != nil { ++ continue ++ } ++ req, err := http.NewRequest(http.MethodPost, s.url, bytes.NewReader(b)) ++ if err != nil { ++ continue ++ } ++ req.Header.Set("Content-Type", "application/json") ++ if s.key != "" { ++ req.Header.Set("Authorization", "Bearer "+s.key) ++ } ++ resp, err := s.http.Do(req) ++ if err == nil { ++ _ = resp.Body.Close() ++ } ++ } ++ }() ++} +diff --git a/internal/knowledge/store.go b/internal/knowledge/store.go +index 5c762c8..0c186a1 100644 +--- a/internal/knowledge/store.go ++++ b/internal/knowledge/store.go +@@ -17,6 +17,7 @@ import ( + "time" + "unicode" + ++ "github.com/example/glpi-ai-agent/internal/brainactivity" + "github.com/example/glpi-ai-agent/internal/model" + ) + +@@ -1073,6 +1074,7 @@ 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") + } +@@ -1191,6 +1193,11 @@ 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/agent/agent.go b/internal/agent/agent.go index 2cfe056..9aa12de 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -1010,7 +1010,7 @@ func auditKnowledgeCandidates(hits []model.KnowledgeHit, globalMin float64, limi 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, + 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 8a779a4..a33c6cf 100644 --- a/internal/agent/policy.go +++ b/internal/agent/policy.go @@ -159,7 +159,12 @@ func (p Policy) Evaluate(t model.Ticket, d model.Decision, categories []model.Ca res.ReplyChecks = append(res.ReplyChecks, check("knowledge", "reply_knowledge_exists", "Ausgewählter Knowledge-Artikel ist verfügbar", "pass", false, selected.Doc.ID, "Kandidat im übergebenen Set", selected.Doc.Title)) } + selectedAutoReplyAllowed := false if selected != nil { + effectiveCategoryID := t.CategoryID + if res.ChangeCategory { + effectiveCategoryID = res.CategoryID + } sourceAllowed := p.sourceAllowed(selected.Doc.Source) sourceReplyAllowed := p.sourceAllowedForReply(selected.Doc.Source) langOK := strings.EqualFold(strings.TrimSpace(selected.Doc.Language), p.CommunicationLanguage) @@ -186,7 +191,8 @@ func (p Policy) Evaluate(t model.Ticket, d model.Decision, categories []model.Ca } 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)) + selectedAutoReplyAllowed = knowledgeAutoReplyAllowed(selected.Doc) + res.ReplyChecks = append(res.ReplyChecks, check("knowledge", "reply_knowledge_auto_reply", "Artikel darf für Auto-Reply verwendet werden", passFail(selectedAutoReplyAllowed), !selectedAutoReplyAllowed, boolText(selectedAutoReplyAllowed), "true", autoDetail)) threshold := p.KnowledgeMinScore if selected.Doc.MinScore > threshold { @@ -198,12 +204,7 @@ func (p Policy) Evaluate(t model.Ticket, d model.Decision, categories []model.Ca retrievalOK := selected.Score >= p.KnowledgeRetrievalFloor res.ReplyChecks = append(res.ReplyChecks, check("knowledge", "reply_retrieval_floor", "Retrieval-Score erreicht Mindestfloor", passFail(retrievalOK), !retrievalOK, percentText(selected.Score), ">= "+percentText(p.KnowledgeRetrievalFloor), "Der Retrieval-Floor entscheidet, ob ein Artikel überhaupt als plausibler Kandidat gilt.")) - catIDForEvidence := t.CategoryID - if res.ChangeCategory { - catIDForEvidence = res.CategoryID - } else if d.Category.ID != 0 && d.Category.ID == t.CategoryID { - catIDForEvidence = d.Category.ID - } + catIDForEvidence := effectiveCategoryID categoryEvidence, categoryAvailable := 0.0, false if len(selected.Doc.Categories) > 0 && catIDForEvidence != 0 { categoryAvailable = true @@ -226,34 +227,23 @@ func (p Policy) Evaluate(t model.Ticket, d model.Decision, categories []model.Ca answerPresent := strings.TrimSpace(selected.Doc.Answer) != "" || strings.TrimSpace(selected.Doc.AnswerHTML) != "" res.ReplyChecks = append(res.ReplyChecks, check("knowledge", "reply_answer_present", "Freigegebener Antwortinhalt vorhanden", passFail(answerPresent), !answerPresent, boolText(answerPresent), "true", "")) - categoryAllowed := true - categoryDetail := "Artikel gilt für alle ITIL-Kategorien." - catID := t.CategoryID - if res.ChangeCategory { - catID = res.CategoryID - } + categoryAllowed := knowledgeCategoryAllowed(selected.Doc, t.CategoryID, res.ChangeCategory, res.CategoryID) + categoryDetail := "Keine gemappte ITIL-Kategorie am Artikel verfügbar; die fachliche Eignung wird über Retrieval, KI-Auswahl und Evidenz geprüft." if len(selected.Doc.Categories) > 0 { - categoryAllowed = false - for _, id := range selected.Doc.Categories { - if id == catID { - categoryAllowed = true - break - } - } - categoryDetail = fmt.Sprintf("Artikel-Kategorien: %v; effektive Ticketkategorie: #%d", selected.Doc.Categories, catID) + categoryDetail = fmt.Sprintf("Gemappte Artikel-ITIL-Kategorien: %v; effektive Ticketkategorie: #%d", selected.Doc.Categories, effectiveCategoryID) } - res.ReplyChecks = append(res.ReplyChecks, check("knowledge", "reply_category_scope", "Artikel ist für die effektive Ticketkategorie freigegeben", passFail(categoryAllowed), !categoryAllowed, boolText(categoryAllowed), "true", categoryDetail)) + res.ReplyChecks = append(res.ReplyChecks, check("knowledge", "reply_category_scope", "Artikel passt zur effektiven Ticketkategorie", passFail(categoryAllowed), !categoryAllowed, boolText(categoryAllowed), "true", categoryDetail)) } else { for _, spec := range []struct{ code, label string }{ {"reply_source_allowed", "Knowledge-Quelle ist für Retrieval erlaubt"}, {"reply_source_auto_allowed", "Knowledge-Quelle ist für Auto-Reply erlaubt"}, {"reply_language_match", "Sprache des Artikels passt zur Kommunikationspolicy"}, {"reply_style_match", "Stil des Artikels passt zur Kommunikationspolicy"}, - {"reply_knowledge_auto_reply", "Artikel ist für Auto-Reply freigegeben"}, + {"reply_knowledge_auto_reply", "Artikel darf für Auto-Reply verwendet werden"}, {"reply_retrieval_floor", "Retrieval-Score erreicht Mindestfloor"}, {"reply_evidence_threshold", "Finale Knowledge-Evidenz erreicht Schwellwert"}, {"reply_answer_present", "Freigegebener Antwortinhalt vorhanden"}, - {"reply_category_scope", "Artikel ist für die effektive Ticketkategorie freigegeben"}, + {"reply_category_scope", "Artikel passt zur effektiven Ticketkategorie"}, } { res.ReplyChecks = append(res.ReplyChecks, check("knowledge", spec.code, spec.label, "na", false, "–", "Knowledge-Artikel erforderlich", "Kein gültiger Knowledge-Artikel ausgewählt.")) } @@ -286,8 +276,8 @@ func (p Policy) Evaluate(t model.Ticket, d model.Decision, categories []model.Ca res.ReplyDecision = "reply_language_mismatch" case !strings.EqualFold(strings.TrimSpace(selected.Doc.CommunicationStyle), p.CommunicationStyle): res.ReplyDecision = "reply_style_mismatch" - case !selected.Doc.AutoReply: - res.ReplyDecision = "reply_knowledge_auto_reply_disabled" + case !selectedAutoReplyAllowed: + res.ReplyDecision = "reply_knowledge_auto_reply_not_approved" case selected.Score < p.KnowledgeRetrievalFloor: res.ReplyDecision = "reply_knowledge_retrieval_below_floor" case res.KnowledgeEvidenceScore < res.KnowledgeThreshold: @@ -345,8 +335,16 @@ func knowledgeCategoryAllowed(d model.KnowledgeDoc, currentCategory int64, chang if len(d.Categories) == 0 { return true } - for _, id := range d.Categories { - if id == catID { + return containsPolicyInt64(d.Categories, catID) +} + +func knowledgeAutoReplyAllowed(d model.KnowledgeDoc) bool { + return d.AutoReply +} + +func containsPolicyInt64(values []int64, target int64) bool { + for _, value := range values { + if value == target { return true } } diff --git a/internal/agent/policy_test.go b/internal/agent/policy_test.go index 1b2a35a..0c35f4a 100644 --- a/internal/agent/policy_test.go +++ b/internal/agent/policy_test.go @@ -220,3 +220,53 @@ func TestPolicyCanDisableAIContentLabel(t *testing.T) { t.Fatalf("disabled AI content label must preserve plain reply behaviour: %+v", r) } } + +func TestPolicyAllowsExplicitlyApprovedUncategorizedGLPIArticle(t *testing.T) { + p := NewPolicy(true, true, .70, .70, .70, .30, .45, .35, .20, + []string{"glpi-kb"}, []string{"glpi-kb"}, "de-DE", "formal", "", "", "", false, false, false, .2) + d := replyDecision() + d.Category.ID = 67 + d.Category.Confidence = .99 + d.Reply.Confidence = .99 + hits := []model.KnowledgeHit{{ + Doc: model.KnowledgeDoc{ + ID: "GLPI-KB-21", Answer: "Drucker neu verbinden.", AutoReply: true, + AutoReplyDecision: "glpi_kb_uncategorized_article_approved", + Source: "glpi-kb", Language: "de-DE", CommunicationStyle: "formal", + }, + Score: .95, + }} + d.Reply.KnowledgeID = "GLPI-KB-21" + r, err := p.Evaluate(model.Ticket{CategoryID: 67}, d, []model.Category{{ID: 67}}, hits, model.ContextSnapshot{}) + if err != nil { + t.Fatal(err) + } + if !r.Reply || r.ReplyDecision != "reply_accepted" { + t.Fatalf("expected explicitly approved uncategorized GLPI article to be accepted: %+v", r) + } +} + +func TestPolicyDoesNotApplyLegacyITILAutoReplyGateToUncategorizedArticle(t *testing.T) { + p := NewPolicy(true, true, .70, .70, .70, .30, .45, .35, .20, + []string{"glpi-kb"}, []string{"glpi-kb"}, "de-DE", "formal", "", "", "", false, false, false, .2) + d := replyDecision() + d.Category.ID = 66 + d.Category.Confidence = .99 + d.Reply.Confidence = .99 + d.Reply.KnowledgeID = "GLPI-KB-21" + hits := []model.KnowledgeHit{{ + Doc: model.KnowledgeDoc{ + ID: "GLPI-KB-21", Answer: "Drucker neu verbinden.", AutoReply: true, + AutoReplyDecision: "glpi_kb_uncategorized_article_approved", + Source: "glpi-kb", Language: "de-DE", CommunicationStyle: "formal", + }, + Score: .95, + }} + r, err := p.Evaluate(model.Ticket{CategoryID: 66}, d, []model.Category{{ID: 66}}, hits, model.ContextSnapshot{}) + if err != nil { + t.Fatal(err) + } + if !r.Reply || r.ReplyDecision != "reply_accepted" { + t.Fatalf("legacy ITIL auto-reply gate must not block explicitly approved uncategorized article: %+v", r) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index ca58ecf..0c2c1f4 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -93,11 +93,18 @@ 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 is retained only for configuration + // compatibility. It no longer participates in Auto-Reply approval. Ticket + // categories remain a fachliches retrieval/policy signal, not an article + // release mechanism. GLPIKBAutoReplyITILCategoryIDs []int64 + // GLPIKBAutoReplyAllowUncategorized permits synchronized GLPI KB articles + // without a KB category to be approved only when their concrete KnowbaseItem + // ID is listed in GLPIKBAutoReplyUncategorizedArticleIDs. + GLPIKBAutoReplyAllowUncategorized bool + // GLPIKBAutoReplyUncategorizedArticleIDs is an explicit allowlist of GLPI + // KnowbaseItem IDs that may use the uncategorized runtime fallback. + GLPIKBAutoReplyUncategorizedArticleIDs []int64 LearningEnabled bool LearningMaxExamples int @@ -201,142 +208,144 @@ type Config struct { func Load() (Config, error) { c := Config{ - HTTPAddr: env("HTTP_ADDR", ":8080"), - DataDir: env("DATA_DIR", "./data"), - DryRun: envBool("DRY_RUN", true), - LogLevel: env("LOG_LEVEL", "info"), - WebUsername: os.Getenv("WEB_USERNAME"), - WebPassword: os.Getenv("WEB_PASSWORD"), - WebAllowAnonymous: envBool("WEB_ALLOW_ANONYMOUS", false), - WebhookSecret: os.Getenv("WEBHOOK_SECRET"), - GLPIURL: strings.TrimRight(os.Getenv("GLPI_URL"), "/"), - GLPIAPIVersion: env("GLPI_API_VERSION", "v2.3"), - GLPIClientID: os.Getenv("GLPI_CLIENT_ID"), - GLPIClientSecret: os.Getenv("GLPI_CLIENT_SECRET"), - GLPIUsername: os.Getenv("GLPI_USERNAME"), - GLPIPassword: os.Getenv("GLPI_PASSWORD"), - GLPIPollInterval: envDuration("GLPI_POLL_INTERVAL", 30*time.Second), - GLPIPollLimit: envInt("GLPI_POLL_LIMIT", 50), - GLPITicketFilter: os.Getenv("GLPI_TICKET_FILTER"), - GLPITimeout: envDuration("GLPI_TIMEOUT", 20*time.Second), - GLPIAgentUserID: envInt64("GLPI_AGENT_USER_ID", 0), - GLPIAllowInsecureHTTP: envBool("GLPI_ALLOW_INSECURE_HTTP", false), - GLPIAllowedStatusIDs: envInt64List("GLPI_ALLOWED_STATUS_IDS", "1"), - OllamaURL: strings.TrimRight(env("OLLAMA_URL", "http://ollama:11434"), "/"), - OllamaURLs: envStringListPreserveCase("OLLAMA_URLS", ""), - OllamaNodeNames: envStringListPreserveCase("OLLAMA_NODE_NAMES", ""), - OllamaNodeWeights: envIntListAllowEmpty("OLLAMA_NODE_WEIGHTS"), - OllamaModel: env("OLLAMA_MODEL", "qwen3:8b"), - OllamaEmbeddingModel: env("OLLAMA_EMBEDDING_MODEL", "embeddinggemma"), - OllamaTimeout: envDuration("OLLAMA_TIMEOUT", 10*time.Minute), - OllamaNumPredict: envInt("OLLAMA_NUM_PREDICT", 768), - OllamaKeepAlive: envDuration("OLLAMA_KEEP_ALIVE", 10*time.Minute), - OllamaThink: envBool("OLLAMA_THINK", false), - OllamaMaxConcurrent: envInt("OLLAMA_MAX_CONCURRENT", 1), - OllamaNodeMaxInflight: envInt("OLLAMA_NODE_MAX_INFLIGHT", 0), - OllamaRoutingMode: envNormalizedLower("OLLAMA_ROUTING_MODE", "least_inflight"), - OllamaNodeHealthInterval: envDuration("OLLAMA_NODE_HEALTH_INTERVAL", 15*time.Second), - OllamaNodeFailureCooldown: envDuration("OLLAMA_NODE_FAILURE_COOLDOWN", 30*time.Second), - OllamaNodeRequestTimeout: envDuration("OLLAMA_NODE_REQUEST_TIMEOUT", 0), - OllamaFailoverEnabled: envBool("OLLAMA_FAILOVER_ENABLED", true), - OllamaFailoverAttempts: envInt("OLLAMA_FAILOVER_ATTEMPTS", 0), - OllamaRequireSameDigest: envBool("OLLAMA_REQUIRE_SAME_MODEL_DIGEST", true), - OllamaRequireEmbeddingModel: envBool("OLLAMA_REQUIRE_EMBEDDING_MODEL", true), - OllamaJSONRetries: envInt("OLLAMA_JSON_RETRIES", 1), - KnowledgeDir: env("KNOWLEDGE_DIR", "./knowledge"), - RAGEnabled: envBool("RAG_ENABLED", true), - KnowledgeTopK: envInt("KNOWLEDGE_TOP_K", 6), - KnowledgeAuditTopK: envInt("KNOWLEDGE_AUDIT_TOP_K", 10), - KnowledgeCandidateMaxGap: envFloat("KNOWLEDGE_CANDIDATE_MAX_GAP", 0.20), - CategoryPromptLimit: envInt("CATEGORY_PROMPT_LIMIT", 80), - KnowledgeAllowedSources: envStringList("KNOWLEDGE_ALLOWED_SOURCES", "internal-kb"), - KnowledgeCategorySources: envStringList("KNOWLEDGE_CATEGORY_SOURCES", ""), - KnowledgeAutoReplySources: envStringList("KNOWLEDGE_AUTO_REPLY_SOURCES", "internal-kb"), - KnowledgeWebEditEnabled: envBool("KNOWLEDGE_WEB_EDIT_ENABLED", false), - KnowledgeCategoryMode: envNormalizedLower("KNOWLEDGE_CATEGORY_MODE", "unscoped"), - KnowledgeCategoryMapFile: strings.TrimSpace(os.Getenv("KNOWLEDGE_CATEGORY_MAP_FILE")), - KnowledgeIgnoreGlobs: envStringListPreserveCase("KNOWLEDGE_IGNORE_GLOBS", ""), - KnowledgeSemanticWeight: envFloat("KNOWLEDGE_WEIGHT_SEMANTIC", 0.45), - KnowledgeTitleWeight: envFloat("KNOWLEDGE_WEIGHT_TITLE", 0.20), - KnowledgeLexicalWeight: envFloat("KNOWLEDGE_WEIGHT_LEXICAL", 0.20), - KnowledgeKeywordWeight: envFloat("KNOWLEDGE_WEIGHT_KEYWORDS", 0.075), - KnowledgeCategoryWeight: envFloat("KNOWLEDGE_WEIGHT_CATEGORY", 0.075), - KnowledgeEmbeddingProfile: envNormalizedLower("KNOWLEDGE_EMBEDDING_PROFILE", "auto"), - KnowledgeChunkWords: envInt("KNOWLEDGE_CHUNK_WORDS", 160), - KnowledgeChunkOverlapWords: envInt("KNOWLEDGE_CHUNK_OVERLAP_WORDS", 30), - KnowledgeMaxChunksPerDoc: envInt("KNOWLEDGE_MAX_CHUNKS_PER_DOC", 24), - KnowledgeMaxQueryChunks: envInt("KNOWLEDGE_MAX_QUERY_CHUNKS", 64), - KnowledgeIndexMode: envNormalizedLower("KNOWLEDGE_INDEX_MODE", "incremental"), - KnowledgeEmbedBatchSize: envInt("KNOWLEDGE_EMBED_BATCH_SIZE", 64), - KnowledgeIndexScanInterval: envDuration("KNOWLEDGE_INDEX_SCAN_INTERVAL", 5*time.Minute), - GLPIKBEnabled: envBool("GLPI_KB_ENABLED", false), - GLPIKBPath: env("GLPI_KB_PATH", "auto"), - GLPIKBFilter: strings.TrimSpace(os.Getenv("GLPI_KB_FILTER")), - GLPIKBLimit: envInt("GLPI_KB_LIMIT", 500), - GLPIKBSyncInterval: envDuration("GLPI_KB_SYNC_INTERVAL", 10*time.Minute), - 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), - CommunicationLanguage: env("COMMUNICATION_LANGUAGE", "de-DE"), - CommunicationStyle: strings.ToLower(env("COMMUNICATION_STYLE", "formal")), - CommunicationSalutation: env("COMMUNICATION_SALUTATION", "Guten Tag,"), - CommunicationClosing: env("COMMUNICATION_CLOSING", "Mit freundlichen Grüßen"), - CommunicationSignature: env("COMMUNICATION_SIGNATURE", "IT-Service"), - AutoCategory: envBool("AUTO_CATEGORY", true), - AutoReply: envBool("AUTO_REPLY", false), - PriorityEnabled: envBool("PRIORITY_ENABLED", true), - AutoPriority: envBool("AUTO_PRIORITY", false), - PriorityConfidence: envFloat("PRIORITY_CONFIDENCE", 0.88), - PriorityAnalysisTimeout: envDuration("PRIORITY_ANALYSIS_TIMEOUT", 45*time.Second), - PriorityMaxIncrease: envInt64("PRIORITY_MAX_INCREASE", 1), - PriorityAllowedReasonCodes: envStringList("PRIORITY_ALLOWED_REASON_CODES", "multiple_users_affected,site_affected,organization_affected,core_service_unavailable,security_incident_suspected,data_loss_possible,legal_or_regulatory_risk,business_deadline,no_workaround,safety_relevant,exam_or_event_critical"), - EscalationEnabled: envBool("ESCALATION_ENABLED", false), - AutoEscalation: envBool("AUTO_ESCALATION", false), - EscalationScanInterval: envDuration("ESCALATION_SCAN_INTERVAL", 15*time.Minute), - EscalationMinAge: envDuration("ESCALATION_MIN_AGE", 4*time.Hour), - EscalationMinInactivity: envDuration("ESCALATION_MIN_INACTIVITY", 2*time.Hour), - EscalationAnalysisTimeout: envDuration("ESCALATION_ANALYSIS_TIMEOUT", 45*time.Second), - EscalationConfidence: envFloat("ESCALATION_CONFIDENCE", 0.88), - EscalationMaxLevel: envInt("ESCALATION_MAX_LEVEL", 3), - EscalationSLARiskWindow: envDuration("ESCALATION_SLA_RISK_WINDOW", 2*time.Hour), - EscalationServiceOwnerMinLevel: envInt("ESCALATION_SERVICE_OWNER_MIN_LEVEL", 2), - EscalationManagerReviewMinLevel: envInt("ESCALATION_MANAGER_REVIEW_MIN_LEVEL", 3), - EscalationMajorIncidentMinScore: envFloat("ESCALATION_MAJOR_INCIDENT_MIN_RELEVANCE", 0.50), - EscalationAllowedReasonCodes: envStringList("ESCALATION_ALLOWED_REASON_CODES", "no_human_response,sla_at_risk,sla_breached,business_deadline,no_workaround,security_incident_suspected,unassigned,major_incident_candidate"), - EscalationAllowedActions: envStringList("ESCALATION_ALLOWED_ACTIONS", "none,raise_priority"), - EscalationSecondLevelGroupID: envInt64("ESCALATION_SECOND_LEVEL_GROUP_ID", 0), - EscalationSecurityGroupID: envInt64("ESCALATION_SECURITY_GROUP_ID", 0), - EscalationServiceOwnerGroupID: envInt64("ESCALATION_SERVICE_OWNER_GROUP_ID", 0), - EscalationServiceOwnerUserID: envInt64("ESCALATION_SERVICE_OWNER_USER_ID", 0), - EscalationManagerReviewGroupID: envInt64("ESCALATION_MANAGER_REVIEW_GROUP_ID", 0), - EscalationManagerReviewUserID: envInt64("ESCALATION_MANAGER_REVIEW_USER_ID", 0), - EscalationAddPrivateFollowup: envBool("ESCALATION_ADD_PRIVATE_FOLLOWUP", true), - EscalationSecondLevelNote: envTemplate("ESCALATION_SECOND_LEVEL_NOTE", "Automatische Eskalation Stufe {{level}}: Übergabe an den Second-Level-Support. Gründe: {{reason_codes}}. KI-Begründung: {{reason}}"), - EscalationSecurityNote: envTemplate("ESCALATION_SECURITY_NOTE", "Automatische Eskalation Stufe {{level}}: Übergabe an das Security-Team. Gründe: {{reason_codes}}. KI-Begründung: {{reason}}"), - EscalationServiceOwnerNote: envTemplate("ESCALATION_SERVICE_OWNER_NOTE", "Automatische Eskalation Stufe {{level}}: Service Owner wurde zur Prüfung einbezogen. Gründe: {{reason_codes}}. KI-Begründung: {{reason}}"), - EscalationMajorIncidentNote: envTemplate("ESCALATION_MAJOR_INCIDENT_NOTE", "Automatische Eskalation Stufe {{level}}: Verknüpfung mit Major Incident #{{major_incident_id}} ({{major_incident_name}}). Relevanz: {{major_incident_score}}. Gründe: {{reason_codes}}."), - EscalationManagerReviewNote: envTemplate("ESCALATION_MANAGER_REVIEW_NOTE", "Automatische Eskalation Stufe {{level}}: Management-Review angefordert. Gründe: {{reason_codes}}. KI-Begründung: {{reason}}"), - EscalationWebhookURL: strings.TrimSpace(os.Getenv("ESCALATION_WEBHOOK_URL")), - EscalationWebhookBearerToken: strings.TrimSpace(os.Getenv("ESCALATION_WEBHOOK_BEARER_TOKEN")), - EscalationWebhookTimeout: envDuration("ESCALATION_WEBHOOK_TIMEOUT", 10*time.Second), - EscalationWebhookAllowInsecureHTTP: envBool("ESCALATION_WEBHOOK_ALLOW_INSECURE_HTTP", false), - GLPIEscalationGroupPatchField: env("GLPI_ESCALATION_GROUP_PATCH_FIELD", "assigned_groups"), - GLPIEscalationUserPatchField: env("GLPI_ESCALATION_USER_PATCH_FIELD", "assigned_users"), - GLPIEscalationITILLinkPath: strings.TrimSpace(os.Getenv("GLPI_ESCALATION_ITIL_LINK_PATH")), - GLPIEscalationITILLinkBody: envTemplate("GLPI_ESCALATION_ITIL_LINK_BODY", ""), - GLPIEscalationFilter: strings.TrimSpace(os.Getenv("GLPI_ESCALATION_FILTER")), - GLPIEscalationLimit: envInt("GLPI_ESCALATION_LIMIT", 100), - CategoryConfidence: envFloat("CATEGORY_CONFIDENCE", 0.90), - ReplyConfidence: envFloat("REPLY_CONFIDENCE", 0.97), - KnowledgeMinScore: envFloat("KNOWLEDGE_MIN_SCORE", 0.70), - KnowledgeRetrievalFloor: envFloat("KNOWLEDGE_RETRIEVAL_FLOOR", 0.30), - KnowledgeEvidenceRetrievalWeight: envFloat("KNOWLEDGE_EVIDENCE_WEIGHT_RETRIEVAL", 0.45), - KnowledgeEvidenceAIWeight: envFloat("KNOWLEDGE_EVIDENCE_WEIGHT_AI", 0.35), - KnowledgeEvidenceCategoryWeight: envFloat("KNOWLEDGE_EVIDENCE_WEIGHT_CATEGORY", 0.20), + HTTPAddr: env("HTTP_ADDR", ":8080"), + DataDir: env("DATA_DIR", "./data"), + DryRun: envBool("DRY_RUN", true), + LogLevel: env("LOG_LEVEL", "info"), + WebUsername: os.Getenv("WEB_USERNAME"), + WebPassword: os.Getenv("WEB_PASSWORD"), + WebAllowAnonymous: envBool("WEB_ALLOW_ANONYMOUS", false), + WebhookSecret: os.Getenv("WEBHOOK_SECRET"), + GLPIURL: strings.TrimRight(os.Getenv("GLPI_URL"), "/"), + GLPIAPIVersion: env("GLPI_API_VERSION", "v2.3"), + GLPIClientID: os.Getenv("GLPI_CLIENT_ID"), + GLPIClientSecret: os.Getenv("GLPI_CLIENT_SECRET"), + GLPIUsername: os.Getenv("GLPI_USERNAME"), + GLPIPassword: os.Getenv("GLPI_PASSWORD"), + GLPIPollInterval: envDuration("GLPI_POLL_INTERVAL", 30*time.Second), + GLPIPollLimit: envInt("GLPI_POLL_LIMIT", 50), + GLPITicketFilter: os.Getenv("GLPI_TICKET_FILTER"), + GLPITimeout: envDuration("GLPI_TIMEOUT", 20*time.Second), + GLPIAgentUserID: envInt64("GLPI_AGENT_USER_ID", 0), + GLPIAllowInsecureHTTP: envBool("GLPI_ALLOW_INSECURE_HTTP", false), + GLPIAllowedStatusIDs: envInt64List("GLPI_ALLOWED_STATUS_IDS", "1"), + OllamaURL: strings.TrimRight(env("OLLAMA_URL", "http://ollama:11434"), "/"), + OllamaURLs: envStringListPreserveCase("OLLAMA_URLS", ""), + OllamaNodeNames: envStringListPreserveCase("OLLAMA_NODE_NAMES", ""), + OllamaNodeWeights: envIntListAllowEmpty("OLLAMA_NODE_WEIGHTS"), + OllamaModel: env("OLLAMA_MODEL", "qwen3:8b"), + OllamaEmbeddingModel: env("OLLAMA_EMBEDDING_MODEL", "embeddinggemma"), + OllamaTimeout: envDuration("OLLAMA_TIMEOUT", 10*time.Minute), + OllamaNumPredict: envInt("OLLAMA_NUM_PREDICT", 768), + OllamaKeepAlive: envDuration("OLLAMA_KEEP_ALIVE", 10*time.Minute), + OllamaThink: envBool("OLLAMA_THINK", false), + OllamaMaxConcurrent: envInt("OLLAMA_MAX_CONCURRENT", 1), + OllamaNodeMaxInflight: envInt("OLLAMA_NODE_MAX_INFLIGHT", 0), + OllamaRoutingMode: envNormalizedLower("OLLAMA_ROUTING_MODE", "least_inflight"), + OllamaNodeHealthInterval: envDuration("OLLAMA_NODE_HEALTH_INTERVAL", 15*time.Second), + OllamaNodeFailureCooldown: envDuration("OLLAMA_NODE_FAILURE_COOLDOWN", 30*time.Second), + OllamaNodeRequestTimeout: envDuration("OLLAMA_NODE_REQUEST_TIMEOUT", 0), + OllamaFailoverEnabled: envBool("OLLAMA_FAILOVER_ENABLED", true), + OllamaFailoverAttempts: envInt("OLLAMA_FAILOVER_ATTEMPTS", 0), + OllamaRequireSameDigest: envBool("OLLAMA_REQUIRE_SAME_MODEL_DIGEST", true), + OllamaRequireEmbeddingModel: envBool("OLLAMA_REQUIRE_EMBEDDING_MODEL", true), + OllamaJSONRetries: envInt("OLLAMA_JSON_RETRIES", 1), + KnowledgeDir: env("KNOWLEDGE_DIR", "./knowledge"), + RAGEnabled: envBool("RAG_ENABLED", true), + KnowledgeTopK: envInt("KNOWLEDGE_TOP_K", 6), + KnowledgeAuditTopK: envInt("KNOWLEDGE_AUDIT_TOP_K", 10), + KnowledgeCandidateMaxGap: envFloat("KNOWLEDGE_CANDIDATE_MAX_GAP", 0.20), + CategoryPromptLimit: envInt("CATEGORY_PROMPT_LIMIT", 80), + KnowledgeAllowedSources: envStringList("KNOWLEDGE_ALLOWED_SOURCES", "internal-kb"), + KnowledgeCategorySources: envStringList("KNOWLEDGE_CATEGORY_SOURCES", ""), + KnowledgeAutoReplySources: envStringList("KNOWLEDGE_AUTO_REPLY_SOURCES", "internal-kb"), + KnowledgeWebEditEnabled: envBool("KNOWLEDGE_WEB_EDIT_ENABLED", false), + KnowledgeCategoryMode: envNormalizedLower("KNOWLEDGE_CATEGORY_MODE", "unscoped"), + KnowledgeCategoryMapFile: strings.TrimSpace(os.Getenv("KNOWLEDGE_CATEGORY_MAP_FILE")), + KnowledgeIgnoreGlobs: envStringListPreserveCase("KNOWLEDGE_IGNORE_GLOBS", ""), + KnowledgeSemanticWeight: envFloat("KNOWLEDGE_WEIGHT_SEMANTIC", 0.45), + KnowledgeTitleWeight: envFloat("KNOWLEDGE_WEIGHT_TITLE", 0.20), + KnowledgeLexicalWeight: envFloat("KNOWLEDGE_WEIGHT_LEXICAL", 0.20), + KnowledgeKeywordWeight: envFloat("KNOWLEDGE_WEIGHT_KEYWORDS", 0.075), + KnowledgeCategoryWeight: envFloat("KNOWLEDGE_WEIGHT_CATEGORY", 0.075), + KnowledgeEmbeddingProfile: envNormalizedLower("KNOWLEDGE_EMBEDDING_PROFILE", "auto"), + KnowledgeChunkWords: envInt("KNOWLEDGE_CHUNK_WORDS", 160), + KnowledgeChunkOverlapWords: envInt("KNOWLEDGE_CHUNK_OVERLAP_WORDS", 30), + KnowledgeMaxChunksPerDoc: envInt("KNOWLEDGE_MAX_CHUNKS_PER_DOC", 24), + KnowledgeMaxQueryChunks: envInt("KNOWLEDGE_MAX_QUERY_CHUNKS", 64), + KnowledgeIndexMode: envNormalizedLower("KNOWLEDGE_INDEX_MODE", "incremental"), + KnowledgeEmbedBatchSize: envInt("KNOWLEDGE_EMBED_BATCH_SIZE", 64), + KnowledgeIndexScanInterval: envDuration("KNOWLEDGE_INDEX_SCAN_INTERVAL", 5*time.Minute), + GLPIKBEnabled: envBool("GLPI_KB_ENABLED", false), + GLPIKBPath: env("GLPI_KB_PATH", "auto"), + GLPIKBFilter: strings.TrimSpace(os.Getenv("GLPI_KB_FILTER")), + GLPIKBLimit: envInt("GLPI_KB_LIMIT", 500), + GLPIKBSyncInterval: envDuration("GLPI_KB_SYNC_INTERVAL", 10*time.Minute), + 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"), + GLPIKBAutoReplyAllowUncategorized: envBool("GLPI_KB_AUTO_REPLY_ALLOW_UNCATEGORIZED", false), + GLPIKBAutoReplyUncategorizedArticleIDs: envInt64ListAllowEmpty("GLPI_KB_AUTO_REPLY_UNCATEGORIZED_ARTICLE_IDS"), + LearningEnabled: envBool("LEARNING_ENABLED", true), + LearningMaxExamples: envInt("LEARNING_MAX_EXAMPLES", 500), + LearningExamplesPerCategory: envInt("LEARNING_EXAMPLES_PER_CATEGORY", 5), + CommunicationLanguage: env("COMMUNICATION_LANGUAGE", "de-DE"), + CommunicationStyle: strings.ToLower(env("COMMUNICATION_STYLE", "formal")), + CommunicationSalutation: env("COMMUNICATION_SALUTATION", "Guten Tag,"), + CommunicationClosing: env("COMMUNICATION_CLOSING", "Mit freundlichen Grüßen"), + CommunicationSignature: env("COMMUNICATION_SIGNATURE", "IT-Service"), + AutoCategory: envBool("AUTO_CATEGORY", true), + AutoReply: envBool("AUTO_REPLY", false), + PriorityEnabled: envBool("PRIORITY_ENABLED", true), + AutoPriority: envBool("AUTO_PRIORITY", false), + PriorityConfidence: envFloat("PRIORITY_CONFIDENCE", 0.88), + PriorityAnalysisTimeout: envDuration("PRIORITY_ANALYSIS_TIMEOUT", 45*time.Second), + PriorityMaxIncrease: envInt64("PRIORITY_MAX_INCREASE", 1), + PriorityAllowedReasonCodes: envStringList("PRIORITY_ALLOWED_REASON_CODES", "multiple_users_affected,site_affected,organization_affected,core_service_unavailable,security_incident_suspected,data_loss_possible,legal_or_regulatory_risk,business_deadline,no_workaround,safety_relevant,exam_or_event_critical"), + EscalationEnabled: envBool("ESCALATION_ENABLED", false), + AutoEscalation: envBool("AUTO_ESCALATION", false), + EscalationScanInterval: envDuration("ESCALATION_SCAN_INTERVAL", 15*time.Minute), + EscalationMinAge: envDuration("ESCALATION_MIN_AGE", 4*time.Hour), + EscalationMinInactivity: envDuration("ESCALATION_MIN_INACTIVITY", 2*time.Hour), + EscalationAnalysisTimeout: envDuration("ESCALATION_ANALYSIS_TIMEOUT", 45*time.Second), + EscalationConfidence: envFloat("ESCALATION_CONFIDENCE", 0.88), + EscalationMaxLevel: envInt("ESCALATION_MAX_LEVEL", 3), + EscalationSLARiskWindow: envDuration("ESCALATION_SLA_RISK_WINDOW", 2*time.Hour), + EscalationServiceOwnerMinLevel: envInt("ESCALATION_SERVICE_OWNER_MIN_LEVEL", 2), + EscalationManagerReviewMinLevel: envInt("ESCALATION_MANAGER_REVIEW_MIN_LEVEL", 3), + EscalationMajorIncidentMinScore: envFloat("ESCALATION_MAJOR_INCIDENT_MIN_RELEVANCE", 0.50), + EscalationAllowedReasonCodes: envStringList("ESCALATION_ALLOWED_REASON_CODES", "no_human_response,sla_at_risk,sla_breached,business_deadline,no_workaround,security_incident_suspected,unassigned,major_incident_candidate"), + EscalationAllowedActions: envStringList("ESCALATION_ALLOWED_ACTIONS", "none,raise_priority"), + EscalationSecondLevelGroupID: envInt64("ESCALATION_SECOND_LEVEL_GROUP_ID", 0), + EscalationSecurityGroupID: envInt64("ESCALATION_SECURITY_GROUP_ID", 0), + EscalationServiceOwnerGroupID: envInt64("ESCALATION_SERVICE_OWNER_GROUP_ID", 0), + EscalationServiceOwnerUserID: envInt64("ESCALATION_SERVICE_OWNER_USER_ID", 0), + EscalationManagerReviewGroupID: envInt64("ESCALATION_MANAGER_REVIEW_GROUP_ID", 0), + EscalationManagerReviewUserID: envInt64("ESCALATION_MANAGER_REVIEW_USER_ID", 0), + EscalationAddPrivateFollowup: envBool("ESCALATION_ADD_PRIVATE_FOLLOWUP", true), + EscalationSecondLevelNote: envTemplate("ESCALATION_SECOND_LEVEL_NOTE", "Automatische Eskalation Stufe {{level}}: Übergabe an den Second-Level-Support. Gründe: {{reason_codes}}. KI-Begründung: {{reason}}"), + EscalationSecurityNote: envTemplate("ESCALATION_SECURITY_NOTE", "Automatische Eskalation Stufe {{level}}: Übergabe an das Security-Team. Gründe: {{reason_codes}}. KI-Begründung: {{reason}}"), + EscalationServiceOwnerNote: envTemplate("ESCALATION_SERVICE_OWNER_NOTE", "Automatische Eskalation Stufe {{level}}: Service Owner wurde zur Prüfung einbezogen. Gründe: {{reason_codes}}. KI-Begründung: {{reason}}"), + EscalationMajorIncidentNote: envTemplate("ESCALATION_MAJOR_INCIDENT_NOTE", "Automatische Eskalation Stufe {{level}}: Verknüpfung mit Major Incident #{{major_incident_id}} ({{major_incident_name}}). Relevanz: {{major_incident_score}}. Gründe: {{reason_codes}}."), + EscalationManagerReviewNote: envTemplate("ESCALATION_MANAGER_REVIEW_NOTE", "Automatische Eskalation Stufe {{level}}: Management-Review angefordert. Gründe: {{reason_codes}}. KI-Begründung: {{reason}}"), + EscalationWebhookURL: strings.TrimSpace(os.Getenv("ESCALATION_WEBHOOK_URL")), + EscalationWebhookBearerToken: strings.TrimSpace(os.Getenv("ESCALATION_WEBHOOK_BEARER_TOKEN")), + EscalationWebhookTimeout: envDuration("ESCALATION_WEBHOOK_TIMEOUT", 10*time.Second), + EscalationWebhookAllowInsecureHTTP: envBool("ESCALATION_WEBHOOK_ALLOW_INSECURE_HTTP", false), + GLPIEscalationGroupPatchField: env("GLPI_ESCALATION_GROUP_PATCH_FIELD", "assigned_groups"), + GLPIEscalationUserPatchField: env("GLPI_ESCALATION_USER_PATCH_FIELD", "assigned_users"), + GLPIEscalationITILLinkPath: strings.TrimSpace(os.Getenv("GLPI_ESCALATION_ITIL_LINK_PATH")), + GLPIEscalationITILLinkBody: envTemplate("GLPI_ESCALATION_ITIL_LINK_BODY", ""), + GLPIEscalationFilter: strings.TrimSpace(os.Getenv("GLPI_ESCALATION_FILTER")), + GLPIEscalationLimit: envInt("GLPI_ESCALATION_LIMIT", 100), + CategoryConfidence: envFloat("CATEGORY_CONFIDENCE", 0.90), + ReplyConfidence: envFloat("REPLY_CONFIDENCE", 0.97), + KnowledgeMinScore: envFloat("KNOWLEDGE_MIN_SCORE", 0.70), + KnowledgeRetrievalFloor: envFloat("KNOWLEDGE_RETRIEVAL_FLOOR", 0.30), + KnowledgeEvidenceRetrievalWeight: envFloat("KNOWLEDGE_EVIDENCE_WEIGHT_RETRIEVAL", 0.45), + KnowledgeEvidenceAIWeight: envFloat("KNOWLEDGE_EVIDENCE_WEIGHT_AI", 0.35), + KnowledgeEvidenceCategoryWeight: envFloat("KNOWLEDGE_EVIDENCE_WEIGHT_CATEGORY", 0.20), ContextEnabled: envBool("CONTEXT_ENABLED", true), ContextTimeout: envDuration("CONTEXT_TIMEOUT", 12*time.Second), @@ -668,12 +677,21 @@ func (c Config) Validate() error { if _, ok := indexedSources[glpiKBSource]; !ok { return fmt.Errorf("GLPI_KB_SOURCE %q must be present in KNOWLEDGE_ALLOWED_SOURCES or KNOWLEDGE_CATEGORY_SOURCES", c.GLPIKBSource) } + if c.GLPIKBAutoReplyAllowUncategorized && !c.GLPIKBAutoReply { + return errors.New("GLPI_KB_AUTO_REPLY_ALLOW_UNCATEGORIZED=true requires GLPI_KB_AUTO_REPLY=true") + } + if len(c.GLPIKBAutoReplyUncategorizedArticleIDs) > 0 && !c.GLPIKBAutoReplyAllowUncategorized { + return errors.New("GLPI_KB_AUTO_REPLY_UNCATEGORIZED_ARTICLE_IDS requires GLPI_KB_AUTO_REPLY_ALLOW_UNCATEGORIZED=true") + } if c.GLPIKBAutoReply { 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 && 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") + if c.GLPIKBAutoReplyAllowUncategorized && len(c.GLPIKBAutoReplyUncategorizedArticleIDs) == 0 { + return errors.New("GLPI_KB_AUTO_REPLY_ALLOW_UNCATEGORIZED=true requires GLPI_KB_AUTO_REPLY_UNCATEGORIZED_ARTICLE_IDS") + } + if len(c.GLPIKBAutoReplyCategoryIDs) == 0 && (!c.GLPIKBAutoReplyAllowUncategorized || len(c.GLPIKBAutoReplyUncategorizedArticleIDs) == 0) { + return errors.New("GLPI_KB_AUTO_REPLY=true requires GLPI_KB_AUTO_REPLY_CATEGORY_IDS and/or an explicit uncategorized article allowlist") } found := false for _, source := range c.KnowledgeAutoReplySources { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 4e3f873..666100e 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -162,8 +162,13 @@ func TestValidateGLPIKBAutoReplyRequiresCategoryWhitelist(t *testing.T) { } c.GLPIKBAutoReplyCategoryIDs = nil c.GLPIKBAutoReplyITILCategoryIDs = []int64{38} + if err := c.Validate(); err == nil { + t.Fatal("expected legacy ITIL category list not to approve GLPI KB auto reply") + } + c.GLPIKBAutoReplyAllowUncategorized = true + c.GLPIKBAutoReplyUncategorizedArticleIDs = []int64{1} if err := c.Validate(); err != nil { - t.Fatalf("expected explicit ITIL category whitelist to validate: %v", err) + t.Fatalf("expected explicit uncategorized article allowlist to validate: %v", err) } } @@ -490,3 +495,23 @@ func TestValidateOllamaPoolConfiguration(t *testing.T) { t.Fatal("expected unknown routing mode to be rejected") } } + +func TestValidateGLPIKBAutoReplyUncategorizedRequiresArticleAllowlist(t *testing.T) { + c := validConfig() + c.GLPIKBEnabled = true + c.GLPIKBPath = "auto" + c.GLPIKBLimit = 100 + c.GLPIKBSyncInterval = 10 * time.Minute + c.GLPIKBSource = "glpi-kb" + c.KnowledgeAllowedSources = []string{"internal-kb", "glpi-kb"} + c.KnowledgeAutoReplySources = []string{"internal-kb", "glpi-kb"} + c.GLPIKBAutoReply = true + c.GLPIKBAutoReplyAllowUncategorized = true + if err := c.Validate(); err == nil { + t.Fatal("expected uncategorized auto reply to require an explicit article allowlist") + } + c.GLPIKBAutoReplyUncategorizedArticleIDs = []int64{1} + if err := c.Validate(); err != nil { + t.Fatalf("expected uncategorized auto reply with explicit article allowlist to validate: %v", err) + } +} diff --git a/internal/glpikb/sync.go b/internal/glpikb/sync.go index 931351f..2c5cd28 100644 --- a/internal/glpikb/sync.go +++ b/internal/glpikb/sync.go @@ -2,6 +2,8 @@ package glpikb import ( "context" + "crypto/sha256" + "encoding/hex" "encoding/json" "fmt" stdhtml "html" @@ -45,10 +47,14 @@ type Syncer struct { count int } +const cachePolicyVersion = 2 + type cacheFile struct { - SyncedAt time.Time `json:"synced_at"` - Path string `json:"path"` - Documents []model.KnowledgeDoc `json:"documents"` + PolicyVersion int `json:"policy_version"` + ApprovalHash string `json:"approval_hash"` + SyncedAt time.Time `json:"synced_at"` + Path string `json:"path"` + Documents []model.KnowledgeDoc `json:"documents"` } type Status struct { @@ -60,6 +66,13 @@ type Status struct { } func New(cfg config.Config, g Source, store Store, m *metrics.Metrics) *Syncer { + if len(cfg.GLPIKBAutoReplyITILCategoryIDs) > 0 { + slog.Warn( + "GLPI_KB_AUTO_REPLY_ITIL_CATEGORY_IDS is deprecated and ignored", + "ids", cfg.GLPIKBAutoReplyITILCategoryIDs, + "hint", "approve categorized articles with GLPI_KB_AUTO_REPLY_CATEGORY_IDS or uncategorized articles with GLPI_KB_AUTO_REPLY_UNCATEGORIZED_ARTICLE_IDS", + ) + } return &Syncer{cfg: cfg, glpi: g, store: store, metrics: m, cachePath: filepath.Join(cfg.DataDir, "glpi-kb-cache.json")} } @@ -81,6 +94,23 @@ func (s *Syncer) LoadCache(ctx context.Context) error { if err := json.Unmarshal(b, &cf); err != nil { return fmt.Errorf("decode GLPI KB cache: %w", err) } + if cf.PolicyVersion != cachePolicyVersion { + slog.Warn( + "ignoring GLPI knowledge cache created with an older Auto-Reply policy", + "path", s.cachePath, + "cache_policy_version", cf.PolicyVersion, + "required_policy_version", cachePolicyVersion, + ) + return nil + } + currentApprovalHash := approvalConfigHash(s.cfg) + if cf.ApprovalHash == "" || cf.ApprovalHash != currentApprovalHash { + slog.Warn( + "ignoring GLPI knowledge cache because Auto-Reply approval configuration changed", + "path", s.cachePath, + ) + return nil + } if err := s.store.ReplaceExternalSource(ctx, s.cfg.GLPIKBSource, cf.Documents); err != nil { return fmt.Errorf("load GLPI KB cache into knowledge store: %w", err) } @@ -112,8 +142,12 @@ func (s *Syncer) Sync(ctx context.Context) error { } cats, err := s.glpi.GetCategories(ctx) if err != nil { - s.fail(err) - return fmt.Errorf("load GLPI ITIL categories for KB mapping: %w", err) + slog.Warn( + "GLPI ITIL categories unavailable for optional KB relevance mapping", + "error", err, + "impact", "KB synchronization and Auto-Reply approval continue using GLPI knowledge-base categories", + ) + cats = nil } docs := s.normalize(items, cats) autoReplyApproved, autoReplyBlocked := autoReplyCounts(docs) @@ -122,7 +156,7 @@ func (s *Syncer) Sync(ctx context.Context) error { return fmt.Errorf("replace GLPI knowledge source: %w", err) } now := time.Now() - cf := cacheFile{SyncedAt: now, Path: path, Documents: docs} + cf := cacheFile{PolicyVersion: cachePolicyVersion, ApprovalHash: approvalConfigHash(s.cfg), SyncedAt: now, Path: path, Documents: docs} if err := writeAtomicJSON(s.cachePath, cf); err != nil { s.fail(err) return fmt.Errorf("persist GLPI KB cache: %w", err) @@ -145,6 +179,28 @@ func (s *Syncer) Sync(ctx context.Context) error { return nil } +func approvalConfigHash(cfg config.Config) string { + kbIDs := append([]int64(nil), cfg.GLPIKBAutoReplyCategoryIDs...) + articleIDs := append([]int64(nil), cfg.GLPIKBAutoReplyUncategorizedArticleIDs...) + sort.Slice(kbIDs, func(i, j int) bool { return kbIDs[i] < kbIDs[j] }) + sort.Slice(articleIDs, func(i, j int) bool { return articleIDs[i] < articleIDs[j] }) + payload, _ := json.Marshal(struct { + Enabled bool `json:"enabled"` + Source string `json:"source"` + KBCategoryIDs []int64 `json:"kb_category_ids"` + AllowUncategorized bool `json:"allow_uncategorized"` + ArticleIDs []int64 `json:"article_ids"` + }{ + Enabled: cfg.GLPIKBAutoReply, + Source: strings.ToLower(strings.TrimSpace(cfg.GLPIKBSource)), + KBCategoryIDs: kbIDs, + AllowUncategorized: cfg.GLPIKBAutoReplyAllowUncategorized, + ArticleIDs: articleIDs, + }) + sum := sha256.Sum256(payload) + return hex.EncodeToString(sum[:]) +} + func autoReplyCounts(docs []model.KnowledgeDoc) (approved, blocked int) { for _, doc := range docs { if doc.AutoReply { @@ -193,9 +249,9 @@ func (s *Syncer) normalize(items []model.GLPIKnowledgeItem, cats []model.Categor for _, id := range s.cfg.GLPIKBAutoReplyCategoryIDs { autoKBCats[id] = struct{}{} } - autoITILCats := map[int64]struct{}{} - for _, id := range s.cfg.GLPIKBAutoReplyITILCategoryIDs { - autoITILCats[id] = struct{}{} + autoUncategorizedArticles := map[int64]struct{}{} + for _, id := range s.cfg.GLPIKBAutoReplyUncategorizedArticleIDs { + autoUncategorizedArticles[id] = struct{}{} } warnLikelyITILIDs(s.cfg.GLPIKBAutoReply, s.cfg.GLPIKBAutoReplyCategoryIDs, items, itilCategoryIDs) out := make([]model.KnowledgeDoc, 0, len(items)) @@ -223,10 +279,12 @@ func (s *Syncer) normalize(items []model.GLPIKnowledgeItem, cats []model.Categor sort.Slice(itilIDs, func(i, j int) bool { return itilIDs[i] < itilIDs[j] }) auto, autoDecision, autoDetail := autoReplyApproval( s.cfg.GLPIKBAutoReply, + s.cfg.GLPIKBAutoReplyAllowUncategorized, + item.ID, item.CategoryIDs, itilIDs, autoKBCats, - autoITILCats, + autoUncategorizedArticles, ) language := strings.TrimSpace(item.Language) if language == "" { @@ -245,38 +303,34 @@ 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) { +func autoReplyApproval(enabled, allowUncategorized bool, articleID int64, sourceCategoryIDs, itilCategoryIDs []int64, allowedSourceCategories, allowedUncategorizedArticles 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", + "GLPI-Artikel-ID: %d; GLPI-KB-Kategorien: %v; gemappte ITIL-Kategorien (nur fachliches Signal): %v; freigegebene GLPI-KB-Kategorien: %v; freigegebene kategorielose Artikel: %v", + articleID, sourceCategoryIDs, itilCategoryIDs, sortedSetIDs(allowedSourceCategories), - sortedSetIDs(allowedITILCategories), + sortedSetIDs(allowedUncategorizedArticles), ) 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" + if !allowUncategorized { + return false, "glpi_kb_article_without_category", detail + "; der Artikel besitzt keine GLPI-KB-Kategorie und GLPI_KB_AUTO_REPLY_ALLOW_UNCATEGORIZED=false" } - return true, "glpi_kb_auto_reply_approved", detail + "; Freigabe über " + matchedBy + if _, ok := allowedUncategorizedArticles[articleID]; !ok { + return false, "glpi_kb_uncategorized_article_not_whitelisted", detail + fmt.Sprintf("; GLPI-KB-Artikel #%d ist nicht in GLPI_KB_AUTO_REPLY_UNCATEGORIZED_ARTICLE_IDS freigegeben", articleID) + } + return true, "glpi_kb_uncategorized_article_approved", detail + "; Freigabe über die konkrete GLPI-Artikel-ID; die fachliche Eignung für das Ticket wird separat geprüft" } - if len(allowedSourceCategories) == 0 && len(allowedITILCategories) == 0 { - return false, "glpi_kb_auto_reply_whitelist_empty", detail + "; keine Freigabe-Whitelist konfiguriert" + if intersectsSet(sourceCategoryIDs, allowedSourceCategories) { + return true, "glpi_kb_auto_reply_approved", detail + "; Freigabe über eine GLPI-Knowledge-Base-Kategorie" } - return false, "glpi_kb_category_not_whitelisted", detail + "; keine konfigurierte Freigabe-Whitelist trifft zu" + if len(allowedSourceCategories) == 0 { + return false, "glpi_kb_auto_reply_whitelist_empty", detail + "; keine GLPI-Knowledge-Base-Kategorie für Auto-Reply freigegeben" + } + return false, "glpi_kb_category_not_whitelisted", detail + "; keine Artikel-Knowledge-Base-Kategorie ist in GLPI_KB_AUTO_REPLY_CATEGORY_IDS freigegeben" } func intersectsSet(ids []int64, allowed map[int64]struct{}) bool { @@ -320,7 +374,7 @@ func warnLikelyITILIDs(enabled bool, configured []int64, items []model.GLPIKnowl 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", + "hint", "configure the actual GLPI knowledge-base category IDs; ITIL/ticket category IDs do not release articles for Auto-Reply", ) } } diff --git a/internal/glpikb/sync_test.go b/internal/glpikb/sync_test.go index 0aeb51c..6bea9bd 100644 --- a/internal/glpikb/sync_test.go +++ b/internal/glpikb/sync_test.go @@ -2,6 +2,9 @@ package glpikb import ( "context" + "encoding/json" + "errors" + "os" "path/filepath" "strings" "testing" @@ -55,7 +58,7 @@ func TestSyncMapsGLPIKBAndAutoReplyWhitelist(t *testing.T) { } } -func TestSyncAllowsAutoReplyByMappedITILCategory(t *testing.T) { +func TestSyncDoesNotUseMappedITILCategoryForAutoReplyApproval(t *testing.T) { cfg := config.Config{ DataDir: t.TempDir(), GLPIKBEnabled: true, GLPIKBPath: "auto", GLPIKBLimit: 50, GLPIKBSyncInterval: time.Minute, GLPIKBSource: "glpi-kb", GLPIKBAutoReply: true, @@ -67,11 +70,11 @@ func TestSyncAllowsAutoReplyByMappedITILCategory(t *testing.T) { 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 len(st.docs) != 1 || st.docs[0].AutoReply { + t.Fatalf("mapped ITIL category must not 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) + if got := st.docs[0].AutoReplyDecision; got != "glpi_kb_auto_reply_whitelist_empty" { + t.Fatalf("decision=%q", got) } } @@ -97,3 +100,150 @@ func TestSyncExplainsBlockedAutoReply(t *testing.T) { t.Fatalf("detail should expose source and configured IDs, got %q", got) } } + +type categoryUnavailableSource struct{ fakeSource } + +func (categoryUnavailableSource) GetCategories(context.Context) ([]model.Category, error) { + return nil, errors.New("category endpoint unavailable") +} + +func TestSyncApprovesByKBCategoryWhenITILCategoriesAreUnavailable(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{9}, CommunicationLanguage: "de-DE", + CommunicationStyle: "formal", GLPITimeout: time.Second, + } + st := &fakeStore{} + s := New(cfg, categoryUnavailableSource{}, 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("KB category approval must not depend on ITIL category loading: %+v", st.docs) + } + if len(st.docs[0].Categories) != 0 { + t.Fatalf("optional ITIL mapping should be absent after category endpoint failure: %v", st.docs[0].Categories) + } +} + +func TestLoadCacheIgnoresOlderAutoReplyPolicy(t *testing.T) { + dir := t.TempDir() + cfg := config.Config{DataDir: dir, GLPIKBSource: "glpi-kb"} + legacy := cacheFile{ + PolicyVersion: 0, + SyncedAt: time.Now(), + Path: "/Knowledge/KnowbaseItem", + Documents: []model.KnowledgeDoc{{ + ID: "GLPI-KB-1", Title: "Legacy", AutoReply: true, Source: "glpi-kb", + }}, + } + b, err := json.Marshal(legacy) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "glpi-kb-cache.json"), b, 0o600); err != nil { + t.Fatal(err) + } + st := &fakeStore{} + s := New(cfg, fakeSource{}, st, metrics.New()) + if err := s.LoadCache(context.Background()); err != nil { + t.Fatal(err) + } + if len(st.docs) != 0 { + t.Fatalf("legacy approval cache must be ignored: %+v", st.docs) + } +} + +type uncategorizedSource struct{} + +func TestLoadCacheIgnoresChangedAutoReplyApprovalConfig(t *testing.T) { + dir := t.TempDir() + oldCfg := config.Config{ + DataDir: dir, GLPIKBSource: "glpi-kb", GLPIKBAutoReply: true, + GLPIKBAutoReplyCategoryIDs: []int64{9}, + } + cf := cacheFile{ + PolicyVersion: cachePolicyVersion, + ApprovalHash: approvalConfigHash(oldCfg), + SyncedAt: time.Now(), + Path: "/Knowledge/KnowbaseItem", + Documents: []model.KnowledgeDoc{{ + ID: "GLPI-KB-1", Title: "Old config", AutoReply: true, Source: "glpi-kb", + }}, + } + b, err := json.Marshal(cf) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "glpi-kb-cache.json"), b, 0o600); err != nil { + t.Fatal(err) + } + newCfg := oldCfg + newCfg.GLPIKBAutoReplyCategoryIDs = []int64{10} + st := &fakeStore{} + s := New(newCfg, fakeSource{}, st, metrics.New()) + if err := s.LoadCache(context.Background()); err != nil { + t.Fatal(err) + } + if len(st.docs) != 0 { + t.Fatalf("cache approved under a different allowlist must be ignored: %+v", st.docs) + } +} + +func (uncategorizedSource) DiscoverKnowledgeBasePath(context.Context, string) (string, error) { + return "/Knowledge/KnowbaseItem", nil +} +func (uncategorizedSource) ListKnowledgeBaseItems(context.Context, string, int, string) ([]model.GLPIKnowledgeItem, error) { + return []model.GLPIKnowledgeItem{{ID: 21, Title: "Allgemeine Druckerhilfe", Content: "

Drucker neu verbinden.

", CategoryIDs: nil, ModifiedAt: "now"}}, nil +} +func (uncategorizedSource) GetCategories(context.Context) ([]model.Category, error) { + return []model.Category{{ID: 67, Name: "Arbeitsplatzdrucker"}, {ID: 68, Name: "Multifunktionsgerät"}}, nil +} + +func TestSyncConditionallyApprovesUncategorizedArticle(t *testing.T) { + cfg := config.Config{ + DataDir: t.TempDir(), GLPIKBEnabled: true, GLPIKBPath: "auto", GLPIKBLimit: 50, + GLPIKBSyncInterval: time.Minute, GLPIKBSource: "glpi-kb", GLPIKBAutoReply: true, + GLPIKBAutoReplyAllowUncategorized: true, + GLPIKBAutoReplyUncategorizedArticleIDs: []int64{21}, + CommunicationLanguage: "de-DE", CommunicationStyle: "formal", GLPITimeout: time.Second, + } + st := &fakeStore{} + s := New(cfg, uncategorizedSource{}, st, metrics.New()) + if err := s.Sync(context.Background()); err != nil { + t.Fatal(err) + } + if len(st.docs) != 1 { + t.Fatalf("docs=%d", len(st.docs)) + } + d := st.docs[0] + if !d.AutoReply || d.AutoReplyDecision != "glpi_kb_uncategorized_article_approved" { + t.Fatalf("uncategorized article should be explicitly approved: %+v", d) + } + if got := strings.TrimSpace(d.AutoReplyDetail); !strings.Contains(got, "konkrete GLPI-Artikel-ID") { + t.Fatalf("detail should explain article-ID approval, got %q", got) + } + if len(d.Categories) != 0 { + t.Fatalf("uncategorized article must not pretend to have semantic ITIL categories: %v", d.Categories) + } +} + +func TestSyncKeepsUncategorizedArticleBlockedByDefault(t *testing.T) { + cfg := config.Config{ + DataDir: t.TempDir(), GLPIKBEnabled: true, GLPIKBPath: "auto", GLPIKBLimit: 50, + GLPIKBSyncInterval: time.Minute, GLPIKBSource: "glpi-kb", GLPIKBAutoReply: true, + CommunicationLanguage: "de-DE", CommunicationStyle: "formal", GLPITimeout: time.Second, + } + st := &fakeStore{} + s := New(cfg, uncategorizedSource{}, 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("uncategorized article must remain blocked without explicit opt-in: %+v", st.docs) + } + if got := st.docs[0].AutoReplyDecision; got != "glpi_kb_article_without_category" { + t.Fatalf("decision=%q", got) + } +} diff --git a/internal/model/model.go b/internal/model/model.go index fa5a863..86fd023 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -77,8 +77,8 @@ type KnowledgeDoc struct { // 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"` + 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"` @@ -299,26 +299,26 @@ type PolicyResult struct { // KnowledgeCandidateAudit captures the top retrieval candidates used for a run. // It intentionally stores only normalized, non-secret diagnostic information. type KnowledgeCandidateAudit struct { - ID string `json:"id"` - Title string `json:"title"` - Source string `json:"source"` - Score float64 `json:"score"` - SemanticScore float64 `json:"semantic_score,omitempty"` - TitleScore float64 `json:"title_score,omitempty"` - LexicalScore float64 `json:"lexical_score,omitempty"` - KeywordScore float64 `json:"keyword_score,omitempty"` - 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"` - DocumentChunkCount int `json:"document_chunk_count,omitempty"` - SentToAI bool `json:"sent_to_ai,omitempty"` - RetrievalRank int `json:"retrieval_rank,omitempty"` - SelectionReason string `json:"selection_reason,omitempty"` + ID string `json:"id"` + Title string `json:"title"` + Source string `json:"source"` + Score float64 `json:"score"` + SemanticScore float64 `json:"semantic_score,omitempty"` + TitleScore float64 `json:"title_score,omitempty"` + LexicalScore float64 `json:"lexical_score,omitempty"` + KeywordScore float64 `json:"keyword_score,omitempty"` + 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"` + DocumentChunkCount int `json:"document_chunk_count,omitempty"` + SentToAI bool `json:"sent_to_ai,omitempty"` + RetrievalRank int `json:"retrieval_rank,omitempty"` + SelectionReason string `json:"selection_reason,omitempty"` } // ContextAuditItem is a compact snapshot of context that influenced a run. diff --git a/internal/web/server.go b/internal/web/server.go index e28951d..2530d46 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -432,7 +432,7 @@ func (s *Server) status(w http.ResponseWriter, r *http.Request) { "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_auto_reply_itil_category_ids": s.cfg.GLPIKBAutoReplyITILCategoryIDs, + "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_allow_uncategorized": s.cfg.GLPIKBAutoReplyAllowUncategorized, "glpi_kb_auto_reply_uncategorized_article_ids": s.cfg.GLPIKBAutoReplyUncategorizedArticleIDs, "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 dc15085..b84b44d 100644 --- a/internal/web/templates/dashboard.html +++ b/internal/web/templates/dashboard.html @@ -115,7 +115,7 @@ function setView(view){if(!viewMeta[view])view='overview';$$('.view').forEach(x= function badge(text,kind=''){return `${esc(text)}`} function progress(label,value){const c=scoreClass(value);return `
${esc(label)}
${esc(pct(value))}
`} function outcomeBadge(x){if(x==='error')return badge('Fehler','bad');if(x==='skipped')return badge('Übersprungen','warn');return badge('Verarbeitet','good')} -function policyLabel(code){const map={category_written:['Geändert','good'],category_accepted_dry_run:['Würde ändern','info'],category_accepted:['Freigegeben','good'],category_already_correct:['Bereits korrekt','good'],category_confidence_below_threshold:['Unter Schwellwert','warn'],category_no_recommendation:['Keine Empfehlung','warn'],category_auto_disabled:['Auto-Kategorie aus','warn'],category_unknown:['Kategorie unbekannt','bad'],category_ticket_changed_before_write:['Ticket geändert','warn'],category_write_failed:['Schreibfehler','bad'],reply_written:['Gesendet','good'],reply_accepted_dry_run:['Würde senden','info'],reply_accepted:['Freigegeben','good'],reply_auto_disabled:['Auto-Reply aus','warn'],reply_model_not_recommended:['KI empfiehlt keine Antwort','warn'],reply_confidence_below_threshold:['Confidence zu niedrig','warn'],reply_knowledge_score_below_threshold:['KB-Score zu niedrig (alt)','warn'],reply_knowledge_retrieval_below_floor:['Retrieval zu schwach','warn'],reply_knowledge_evidence_below_threshold:['Evidenz zu niedrig','warn'],reply_no_knowledge_candidates:['Keine KB-Treffer','warn'],reply_no_knowledge_selected:['Keine KB gewählt','warn'],reply_knowledge_not_found:['KB nicht gefunden','bad'],reply_source_not_allowed:['Quelle gesperrt','warn'],reply_source_not_allowed_for_auto_reply:['Quelle nicht für Auto-Reply','warn'],reply_language_mismatch:['Sprache passt nicht','warn'],reply_style_mismatch:['Stil passt nicht','warn'],reply_knowledge_auto_reply_disabled:['Artikel nicht freigegeben','warn'],reply_knowledge_answer_empty:['Antworttext fehlt','warn'],reply_category_not_allowed:['Kategorie nicht freigegeben','warn'],reply_context_incomplete:['Kontext unvollständig','warn'],reply_relevant_incident:['Störung/Incident erkannt','warn'],reply_status_incident_accepted:['Störungstext freigegeben','good'],reply_status_maintenance_accepted:['Wartungstext freigegeben','good'],status_reply_relevance_below_threshold:['Status-Relevanz zu niedrig','warn'],status_reply_confidence_below_threshold:['Status-Confidence zu niedrig','warn'],status_reply_final_score_below_threshold:['Status-Score zu niedrig','warn'],reply_existing_followup:['Bereits beantwortet','good'],reply_followup_appeared_before_write:['Antwort hinzugekommen','warn'],reply_ticket_changed_before_write:['Ticket geändert','warn'],reply_write_failed:['Schreibfehler','bad']};return map[code]||[code||'Keine Aktion','']} +function policyLabel(code){const map={category_written:['Geändert','good'],category_accepted_dry_run:['Würde ändern','info'],category_accepted:['Freigegeben','good'],category_already_correct:['Bereits korrekt','good'],category_confidence_below_threshold:['Unter Schwellwert','warn'],category_no_recommendation:['Keine Empfehlung','warn'],category_auto_disabled:['Auto-Kategorie aus','warn'],category_unknown:['Kategorie unbekannt','bad'],category_ticket_changed_before_write:['Ticket geändert','warn'],category_write_failed:['Schreibfehler','bad'],reply_written:['Gesendet','good'],reply_accepted_dry_run:['Würde senden','info'],reply_accepted:['Freigegeben','good'],reply_auto_disabled:['Auto-Reply aus','warn'],reply_model_not_recommended:['KI empfiehlt keine Antwort','warn'],reply_confidence_below_threshold:['Confidence zu niedrig','warn'],reply_knowledge_score_below_threshold:['KB-Score zu niedrig (alt)','warn'],reply_knowledge_retrieval_below_floor:['Retrieval zu schwach','warn'],reply_knowledge_evidence_below_threshold:['Evidenz zu niedrig','warn'],reply_no_knowledge_candidates:['Keine KB-Treffer','warn'],reply_no_knowledge_selected:['Keine KB gewählt','warn'],reply_knowledge_not_found:['KB nicht gefunden','bad'],reply_source_not_allowed:['Quelle gesperrt','warn'],reply_source_not_allowed_for_auto_reply:['Quelle nicht für Auto-Reply','warn'],reply_language_mismatch:['Sprache passt nicht','warn'],reply_style_mismatch:['Stil passt nicht','warn'],reply_knowledge_auto_reply_disabled:['Artikel nicht freigegeben','warn'],reply_knowledge_auto_reply_not_approved:['Artikel nicht freigegeben','warn'],reply_knowledge_answer_empty:['Antworttext fehlt','warn'],reply_category_not_allowed:['Kategorie nicht freigegeben','warn'],reply_context_incomplete:['Kontext unvollständig','warn'],reply_relevant_incident:['Störung/Incident erkannt','warn'],reply_status_incident_accepted:['Störungstext freigegeben','good'],reply_status_maintenance_accepted:['Wartungstext freigegeben','good'],status_reply_relevance_below_threshold:['Status-Relevanz zu niedrig','warn'],status_reply_confidence_below_threshold:['Status-Confidence zu niedrig','warn'],status_reply_final_score_below_threshold:['Status-Score zu niedrig','warn'],reply_existing_followup:['Bereits beantwortet','good'],reply_followup_appeared_before_write:['Antwort hinzugekommen','warn'],reply_ticket_changed_before_write:['Ticket geändert','warn'],reply_write_failed:['Schreibfehler','bad']};return map[code]||[code||'Keine Aktion','']} function configNotice(text,kind=''){return `
${text}
`} function renderStatusChrome(){const g=!!statusData.glpi_ok,o=!!statusData.ollama_ok,k=!!statusData.knowledge_ready,ks=statusData.knowledge_init_state||'waiting',total=Number(statusData.ollama_node_count||0),healthy=Number(statusData.ollama_healthy_nodes||0);$('#glpiChip').innerHTML=`GLPI ${g?'OK':'Fehler'}`;$('#ollamaChip').innerHTML=`Ollama ${total?`${healthy}/${total}`:(o?'OK':'Fehler')}`;$('#knowledgeChip').innerHTML=`Knowledge ${k?'bereit':ks==='error'?'Fehler':'lädt'}`;$('#sideMode').innerHTML=`${statusData.dry_run?badge('DRY RUN','warn'):badge('LIVE','good')} ${statusData.auto_reply?badge('Auto-Reply','good'):badge('Auto-Reply aus','warn')} ${statusData.priority_enabled?badge(statusData.auto_priority?'Auto-Priorität':'Priorität Shadow',statusData.auto_priority?'good':'info'):badge('Priorität aus','warn')} ${statusData.escalation_enabled?badge(statusData.auto_escalation?'Auto-Eskalation':'Eskalation Shadow',statusData.auto_escalation?'good':'info'):badge('Eskalation aus','warn')}
${esc(statusData.ollama_model||'–')} · Pool ${esc(statusData.ollama_routing_mode||'–')} · ${esc(statusData.communication_language||'–')} / ${esc(statusData.communication_style||'–')}
`;$('#lastRefresh').textContent=new Date().toLocaleTimeString('de-DE')} function renderOverview(){const stats=[['Verarbeitet',fmtNum(statusData.processed),'seit Start'],['Fehler',fmtNum(statusData.errors),statusData.errors?'prüfen':'keine'],['Queue',fmtNum(statusData.queue_depth),`von ${fmtNum(statusData.queue_size)}`],['Knowledge',fmtNum(statusData.knowledge_docs),`${fmtNum(statusData.glpi_kb_documents)} aus GLPI`],['KI-Triage',`${fmtNum(statusData.priority_recommendations)} / ${fmtNum(statusData.escalation_runs)}`,'Priorität / Eskalationsläufe'],['Auto-Aktionen',`${fmtNum(statusData.category_changes)} / ${fmtNum(statusData.priority_changes)} / ${fmtNum(statusData.replies)} / ${fmtNum(statusData.escalations)}`,'Kat. / Prio / Reply / Esk.']];$('#overviewStats').innerHTML=stats.map(x=>`
${esc(x[0])}
${esc(x[1])}
${esc(x[2])}
`).join(''); @@ -145,7 +145,7 @@ function renderKB(){const st=kbStatsData();$('#kbStats').innerHTML=st.map(x=>`!q||[x.ticket_id,x.subject,x.text,x.category_name,x.category_id].join(' ').toLowerCase().includes(q));const corrections=learningRows.filter(x=>x.correction).length;$('#learningStats').innerHTML=[['Gesamt',learningRows.length,'bestätigte Beispiele'],['Korrekturen',corrections,'KI lag anders'],['Bestätigungen',learningRows.length-corrections,'KI wurde bestätigt']].map(x=>`
${esc(x[0])}
${fmtNum(x[1])}
${esc(x[2])}
`).join('');$('#learningTable').innerHTML=rows.length?rows.map(x=>`
#${esc(x.ticket_id)} ${esc(x.subject)}
${esc((x.text||'').slice(0,220))}
${esc(x.category_name)} (#${esc(x.category_id)})${x.ai_recommended_category_id?`
KI: #${esc(x.ai_recommended_category_id)} · ${esc(pct(x.ai_confidence))}
`:''}${x.correction?badge('Korrektur','warn'):badge('Bestätigung','good')}${esc(fmtDate(x.created_at))}`).join(''):'Keine Lernbeispiele.'} function configCard(title,subtitle,rows){return `
${esc(title)}
${esc(subtitle)}
${rows.map(([k,v])=>`
${esc(k)}
${v}
`).join('')}
`} function val(v){if(typeof v==='boolean')return v?badge('aktiv','good'):badge('aus','warn');if(Array.isArray(v))return esc(v.length?v.join(', '):'–');return esc(v??'–')} -function renderConfig(){const s=statusData;const groups=[configCard('Agent & GLPI','Polling, Worker und Schreibmodus',[['Dry Run',val(s.dry_run)],['Auto-Kategorie',val(s.auto_category)],['Auto-Reply',val(s.auto_reply)],['Worker',val(s.workers)],['Queue-Größe',val(s.queue_size)],['API-Version',val(s.glpi_api_version)],['Poll-Intervall',val(s.glpi_poll_interval)],['Poll-Limit',val(s.glpi_poll_limit)],['Letzter Poll',val(fmtDate(s.last_poll))],['Poll: abgerufen / bekannt / neu / Queue',val(`${s.poll_last_fetched||0} / ${s.poll_last_seen||0} / ${s.poll_last_unseen||0} / ${s.poll_last_enqueued||0}`)],['Poll-Fehler',val(s.poll_last_error||'–')],['Bekannte Ticketversionen',val(s.processed_version_count||0)],['Ticket-Filter gesetzt',val(s.glpi_ticket_filter_configured)],['Erlaubte Status',val(s.glpi_allowed_status_ids)],['GLPI-Timeout',val(s.glpi_timeout)]]),configCard('Priorität & Eskalation','Separate KI-Läufe mit deterministischen Schreibregeln',[['Prioritätsanalyse',val(s.priority_enabled)],['Auto-Priorität',val(s.auto_priority)],['Prioritäts-Confidence',`${pct(s.priority_confidence)}`],['Prioritäts-Timeout',val(s.priority_analysis_timeout)],['Max. Erhöhung/Lauf',val(s.priority_max_increase)],['Erlaubte Prioritätsgründe',val(s.priority_allowed_reason_codes)],['Eskalationsanalyse',val(s.escalation_enabled)],['Auto-Eskalation',val(s.auto_escalation)],['Scan-Intervall',val(s.escalation_scan_interval)],['Mindestalter',val(s.escalation_min_age)],['Mindest-Inaktivität',val(s.escalation_min_inactivity)],['KI-Zeitbudget',val(s.escalation_analysis_timeout)],['Eskalations-Confidence',`${pct(s.escalation_confidence)}`],['Max. Eskalationsstufe',val(s.escalation_max_level)],['SLA-Risikofenster',val(s.escalation_sla_risk_window)],['Service Owner ab Stufe',val(s.escalation_service_owner_min_level)],['Management-Review ab Stufe',val(s.escalation_manager_review_min_level)],['Major-Incident-Relevanz',pct(s.escalation_major_incident_min_relevance)],['Erlaubte Eskalationsgründe',val(s.escalation_allowed_reason_codes)],['Erlaubte Eskalationsaktionen',val(s.escalation_allowed_actions)],['Second-Level-Gruppe',val(s.escalation_second_level_group_id||'–')],['Security-Gruppe',val(s.escalation_security_group_id||'–')],['Service Owner Gruppe / Benutzer',val(`${s.escalation_service_owner_group_id||'–'} / ${s.escalation_service_owner_user_id||'–'}`)],['Management Gruppe / Benutzer',val(`${s.escalation_manager_review_group_id||'–'} / ${s.escalation_manager_review_user_id||'–'}`)],['Private Eskalationsnotizen',val(s.escalation_add_private_followup)],['Webhook konfiguriert',val(s.escalation_webhook_configured)],['Webhook-Timeout',val(s.escalation_webhook_timeout)],['Unsicheres Webhook-HTTP',val(s.escalation_webhook_allow_insecure_http)],['GLPI Gruppen-/Benutzerfeld',val(`${s.glpi_escalation_group_patch_field||'–'} / ${s.glpi_escalation_user_patch_field||'–'}`)],['Major-Incident-Linkadapter',val(s.glpi_escalation_itil_link_configured)],['GLPI-Eskalationsfilter gesetzt',val(s.glpi_escalation_filter_configured)],['Kandidatenlimit',val(s.glpi_escalation_limit)]]),configCard('Ollama-Pool','Modelle, Routing, Health und Failover',[['Chat-Modell',val(s.ollama_model)],['Embedding-Modell',val(s.ollama_embedding_model)],['Embedding-Profil',val(s.knowledge_embedding_profile)],['Nodes gesund / gesamt',val(`${s.ollama_healthy_nodes||0} / ${s.ollama_node_count||0}`)],['Routing',val(s.ollama_routing_mode)],['Max. parallel je Node',val(s.ollama_node_max_inflight)],['Health-Intervall',val(s.ollama_node_health_interval)],['Fehler-Cooldown',val(s.ollama_node_failure_cooldown)],['Request-Timeout je Node',val(s.ollama_node_request_timeout)],['Failover',val(s.ollama_failover_enabled)],['Max. Versuche',val(s.ollama_failover_attempts)],['Gleicher Modelldigest Pflicht',val(s.ollama_require_same_model_digest)],['Embedding-Modell je Node Pflicht',val(s.ollama_require_embedding_model)],['Gesamt-Timeout',val(s.ollama_timeout)],['Num Predict',val(s.ollama_num_predict)],['Keep Alive',val(s.ollama_keep_alive)],['Thinking',val(s.ollama_think)],['JSON-Retries',val(s.ollama_json_retries)],['Node-Details',val((s.ollama_nodes||[]).map(n=>`${n.name}: ${n.healthy&&n.compatible?'OK':'Fehler'} · ${n.requests||0} Requests · ${n.failures||0} Fehler`).join(' | ')||'–')]]),configCard('Knowledge / RAG','Retrieval, Chunking und Ranking',[['RAG',val(s.rag_enabled)],['Index-Modus',val(s.knowledge_index_mode)],['Snapshot geladen',val(s.knowledge_snapshot_loaded)],['Snapshot gespeichert',val(fmtDate(s.knowledge_snapshot_saved_at))],['Letzter Delta-Scan',val(fmtDate(s.knowledge_last_scan_at))],['Letzter Scanfehler',val(s.knowledge_last_scan_error||'–')],['Geänderte Dateien',val(s.knowledge_changed_files)],['Gelöschte Dateien',val(s.knowledge_deleted_files)],['Wiederverwendete Vektoren',val(s.knowledge_reused_files)],['Embedding-Batch',val(s.knowledge_embed_batch_size)],['Scan-Intervall',val(s.knowledge_index_scan_interval)],['Knowledge bereit',val(s.knowledge_ready)],['Startup-Status',val(s.knowledge_init_state)],['Startup-Phase',val(s.knowledge_init_phase)],['Dateien verarbeitet',val(`${s.knowledge_init_processed_files||0} / ${s.knowledge_init_total_files||0}`)],['Dokumente geladen',val(s.knowledge_init_loaded_docs)],['Dokumente indexiert',val(s.knowledge_init_indexed_docs)],['Embedding-Cache-Treffer',val(s.knowledge_init_cache_hits)],['Offene Embeddings',val(s.knowledge_init_pending_embeddings)],['Startup-Fehler',val(s.knowledge_init_error||'–')],['Max. Kandidaten an KI',val(s.knowledge_top_k)],['Audit Top K',val(s.knowledge_audit_top_k)],['Max. Abstand zum Top-Treffer',pct(s.knowledge_candidate_max_gap)],['Finaler Evidenz-Schwellwert',`${pct(s.knowledge_min_score)}`],['Retrieval-Floor',`${pct(s.knowledge_retrieval_floor)}`],['Evidenzgewicht Retrieval',pct(s.knowledge_evidence_weight_retrieval)],['Evidenzgewicht KI',pct(s.knowledge_evidence_weight_ai)],['Evidenzgewicht Kategorie',pct(s.knowledge_evidence_weight_category)],['Retrieval: Semantik',pct(s.knowledge_weight_semantic)],['Retrieval: Titel',pct(s.knowledge_weight_title)],['Retrieval: Lexikalisch',pct(s.knowledge_weight_lexical)],['Retrieval: Keywords',pct(s.knowledge_weight_keywords)],['Retrieval: Kategorie/Lernen',pct(s.knowledge_weight_category)],['Chunk-Wörter',val(s.knowledge_chunk_words)],['Overlap-Wörter',val(s.knowledge_chunk_overlap_words)],['Max. KB-Chunks',val(s.knowledge_max_chunks_per_doc)],['Max. Ticket-Chunks',val(s.knowledge_max_query_chunks)],['Antwort-/Retrieval-Quellen',val(s.knowledge_allowed_sources)],['Kategorisierungsquellen',val(s.knowledge_category_sources)],['Auto-Reply-Quellen',val(s.knowledge_auto_reply_sources)],['Fremdkategorie-Modus',val(s.knowledge_category_mode)],['Kategorie-Mapping',val(s.knowledge_category_map_configured?'konfiguriert':'–')],['Ignore-Globs',val(s.knowledge_ignore_globs)],['Ignorierte Dateien',val(s.knowledge_ignored_files)],['KBs mit ungemappten Kategorien',val(s.knowledge_unmapped_category_files)],['Ungemappte Kategorien',val(s.knowledge_unmapped_categories)]]),configCard('GLPI Knowledge Base','Synchronisation der GLPI-Wissensdatenbank',[['Aktiv',val(s.glpi_kb_enabled)],['Sync OK',val(s.glpi_kb_ok)],['Dokumente',val(s.glpi_kb_documents)],['Letzter Sync',val(fmtDate(s.glpi_kb_last_sync))],['Intervall',val(s.glpi_kb_sync_interval)],['Pfad',val(s.glpi_kb_path)],['Filter gesetzt',val(s.glpi_kb_filter_configured)],['Limit',val(s.glpi_kb_limit)],['Auto-Reply',val(s.glpi_kb_auto_reply)],['Auto-Reply freigegeben / blockiert',val(`${s.glpi_kb_auto_reply_approved||0} / ${s.glpi_kb_auto_reply_blocked||0}`)],['Auto-Reply-Entscheidungen',val(s.glpi_kb_auto_reply_decisions)],['Auto-Reply-KB-Kategorien',val(s.glpi_kb_auto_reply_category_ids)],['Auto-Reply-ITIL-Kategorien',val(s.glpi_kb_auto_reply_itil_category_ids)],['Letzter Fehler',val(s.glpi_kb_last_error||'–')]]),configCard('Policy & Kommunikation','Entscheidungsschwellen und Sprache',[['Kategorie-Confidence',`${pct(s.category_confidence)}`],['Reply-Confidence',`${pct(s.reply_confidence)}`],['Sprache',val(s.communication_language)],['Stil',val(s.communication_style)],['KI-Kennzeichnung',val(s.ai_content_label_enabled)],['KB-Webeditor',val(s.knowledge_edit_enabled)],['Lernen',val(s.learning_enabled)],['Max. Lernbeispiele',val(s.learning_max_examples)],['Beispiele/Kategorie',val(s.learning_examples_per_category)]]),configCard('Kontextquellen','Störungen, Changes, Incidents und Geräte',[['Kontext aktiv',val(s.context_enabled)],['Timeout',val(s.context_timeout)],['Relevanz-Minimum',pct(s.context_relevance_min_score)],['Fail-closed',val(s.context_fail_closed)],['Incident blockiert normalen Reply',val(s.context_incident_block)],['Vordefinierte Statusantwort',val(s.context_status_reply_enabled)],['Status: Relevanz-Minimum',pct(s.context_status_reply_min_relevance)],['Status: KI-Minimum',pct(s.context_status_reply_min_ai_confidence)],['Status: Final-Minimum',pct(s.context_status_reply_min_final_score)],['Störungstext konfiguriert',val(s.context_incident_reply_text_configured)],['Wartungstext konfiguriert',val(s.context_maintenance_reply_text_configured)],['Change Calendar',val(s.change_calendar_enabled)],['Lookback',val(s.change_lookback)],['Lookahead',val(s.change_lookahead)],['Major Incidents',val(s.major_incidents_enabled)],['Benutzer-Geräte',val(s.user_device_context_enabled)],['Uptime Kuma',val(s.uptime_kuma_enabled)],['Uptime-Modus',val(s.uptime_kuma_mode)]] )];$('#configGroups').innerHTML=groups.join('')} +function renderConfig(){const s=statusData;const groups=[configCard('Agent & GLPI','Polling, Worker und Schreibmodus',[['Dry Run',val(s.dry_run)],['Auto-Kategorie',val(s.auto_category)],['Auto-Reply',val(s.auto_reply)],['Worker',val(s.workers)],['Queue-Größe',val(s.queue_size)],['API-Version',val(s.glpi_api_version)],['Poll-Intervall',val(s.glpi_poll_interval)],['Poll-Limit',val(s.glpi_poll_limit)],['Letzter Poll',val(fmtDate(s.last_poll))],['Poll: abgerufen / bekannt / neu / Queue',val(`${s.poll_last_fetched||0} / ${s.poll_last_seen||0} / ${s.poll_last_unseen||0} / ${s.poll_last_enqueued||0}`)],['Poll-Fehler',val(s.poll_last_error||'–')],['Bekannte Ticketversionen',val(s.processed_version_count||0)],['Ticket-Filter gesetzt',val(s.glpi_ticket_filter_configured)],['Erlaubte Status',val(s.glpi_allowed_status_ids)],['GLPI-Timeout',val(s.glpi_timeout)]]),configCard('Priorität & Eskalation','Separate KI-Läufe mit deterministischen Schreibregeln',[['Prioritätsanalyse',val(s.priority_enabled)],['Auto-Priorität',val(s.auto_priority)],['Prioritäts-Confidence',`${pct(s.priority_confidence)}`],['Prioritäts-Timeout',val(s.priority_analysis_timeout)],['Max. Erhöhung/Lauf',val(s.priority_max_increase)],['Erlaubte Prioritätsgründe',val(s.priority_allowed_reason_codes)],['Eskalationsanalyse',val(s.escalation_enabled)],['Auto-Eskalation',val(s.auto_escalation)],['Scan-Intervall',val(s.escalation_scan_interval)],['Mindestalter',val(s.escalation_min_age)],['Mindest-Inaktivität',val(s.escalation_min_inactivity)],['KI-Zeitbudget',val(s.escalation_analysis_timeout)],['Eskalations-Confidence',`${pct(s.escalation_confidence)}`],['Max. Eskalationsstufe',val(s.escalation_max_level)],['SLA-Risikofenster',val(s.escalation_sla_risk_window)],['Service Owner ab Stufe',val(s.escalation_service_owner_min_level)],['Management-Review ab Stufe',val(s.escalation_manager_review_min_level)],['Major-Incident-Relevanz',pct(s.escalation_major_incident_min_relevance)],['Erlaubte Eskalationsgründe',val(s.escalation_allowed_reason_codes)],['Erlaubte Eskalationsaktionen',val(s.escalation_allowed_actions)],['Second-Level-Gruppe',val(s.escalation_second_level_group_id||'–')],['Security-Gruppe',val(s.escalation_security_group_id||'–')],['Service Owner Gruppe / Benutzer',val(`${s.escalation_service_owner_group_id||'–'} / ${s.escalation_service_owner_user_id||'–'}`)],['Management Gruppe / Benutzer',val(`${s.escalation_manager_review_group_id||'–'} / ${s.escalation_manager_review_user_id||'–'}`)],['Private Eskalationsnotizen',val(s.escalation_add_private_followup)],['Webhook konfiguriert',val(s.escalation_webhook_configured)],['Webhook-Timeout',val(s.escalation_webhook_timeout)],['Unsicheres Webhook-HTTP',val(s.escalation_webhook_allow_insecure_http)],['GLPI Gruppen-/Benutzerfeld',val(`${s.glpi_escalation_group_patch_field||'–'} / ${s.glpi_escalation_user_patch_field||'–'}`)],['Major-Incident-Linkadapter',val(s.glpi_escalation_itil_link_configured)],['GLPI-Eskalationsfilter gesetzt',val(s.glpi_escalation_filter_configured)],['Kandidatenlimit',val(s.glpi_escalation_limit)]]),configCard('Ollama-Pool','Modelle, Routing, Health und Failover',[['Chat-Modell',val(s.ollama_model)],['Embedding-Modell',val(s.ollama_embedding_model)],['Embedding-Profil',val(s.knowledge_embedding_profile)],['Nodes gesund / gesamt',val(`${s.ollama_healthy_nodes||0} / ${s.ollama_node_count||0}`)],['Routing',val(s.ollama_routing_mode)],['Max. parallel je Node',val(s.ollama_node_max_inflight)],['Health-Intervall',val(s.ollama_node_health_interval)],['Fehler-Cooldown',val(s.ollama_node_failure_cooldown)],['Request-Timeout je Node',val(s.ollama_node_request_timeout)],['Failover',val(s.ollama_failover_enabled)],['Max. Versuche',val(s.ollama_failover_attempts)],['Gleicher Modelldigest Pflicht',val(s.ollama_require_same_model_digest)],['Embedding-Modell je Node Pflicht',val(s.ollama_require_embedding_model)],['Gesamt-Timeout',val(s.ollama_timeout)],['Num Predict',val(s.ollama_num_predict)],['Keep Alive',val(s.ollama_keep_alive)],['Thinking',val(s.ollama_think)],['JSON-Retries',val(s.ollama_json_retries)],['Node-Details',val((s.ollama_nodes||[]).map(n=>`${n.name}: ${n.healthy&&n.compatible?'OK':'Fehler'} · ${n.requests||0} Requests · ${n.failures||0} Fehler`).join(' | ')||'–')]]),configCard('Knowledge / RAG','Retrieval, Chunking und Ranking',[['RAG',val(s.rag_enabled)],['Index-Modus',val(s.knowledge_index_mode)],['Snapshot geladen',val(s.knowledge_snapshot_loaded)],['Snapshot gespeichert',val(fmtDate(s.knowledge_snapshot_saved_at))],['Letzter Delta-Scan',val(fmtDate(s.knowledge_last_scan_at))],['Letzter Scanfehler',val(s.knowledge_last_scan_error||'–')],['Geänderte Dateien',val(s.knowledge_changed_files)],['Gelöschte Dateien',val(s.knowledge_deleted_files)],['Wiederverwendete Vektoren',val(s.knowledge_reused_files)],['Embedding-Batch',val(s.knowledge_embed_batch_size)],['Scan-Intervall',val(s.knowledge_index_scan_interval)],['Knowledge bereit',val(s.knowledge_ready)],['Startup-Status',val(s.knowledge_init_state)],['Startup-Phase',val(s.knowledge_init_phase)],['Dateien verarbeitet',val(`${s.knowledge_init_processed_files||0} / ${s.knowledge_init_total_files||0}`)],['Dokumente geladen',val(s.knowledge_init_loaded_docs)],['Dokumente indexiert',val(s.knowledge_init_indexed_docs)],['Embedding-Cache-Treffer',val(s.knowledge_init_cache_hits)],['Offene Embeddings',val(s.knowledge_init_pending_embeddings)],['Startup-Fehler',val(s.knowledge_init_error||'–')],['Max. Kandidaten an KI',val(s.knowledge_top_k)],['Audit Top K',val(s.knowledge_audit_top_k)],['Max. Abstand zum Top-Treffer',pct(s.knowledge_candidate_max_gap)],['Finaler Evidenz-Schwellwert',`${pct(s.knowledge_min_score)}`],['Retrieval-Floor',`${pct(s.knowledge_retrieval_floor)}`],['Evidenzgewicht Retrieval',pct(s.knowledge_evidence_weight_retrieval)],['Evidenzgewicht KI',pct(s.knowledge_evidence_weight_ai)],['Evidenzgewicht Kategorie',pct(s.knowledge_evidence_weight_category)],['Retrieval: Semantik',pct(s.knowledge_weight_semantic)],['Retrieval: Titel',pct(s.knowledge_weight_title)],['Retrieval: Lexikalisch',pct(s.knowledge_weight_lexical)],['Retrieval: Keywords',pct(s.knowledge_weight_keywords)],['Retrieval: Kategorie/Lernen',pct(s.knowledge_weight_category)],['Chunk-Wörter',val(s.knowledge_chunk_words)],['Overlap-Wörter',val(s.knowledge_chunk_overlap_words)],['Max. KB-Chunks',val(s.knowledge_max_chunks_per_doc)],['Max. Ticket-Chunks',val(s.knowledge_max_query_chunks)],['Antwort-/Retrieval-Quellen',val(s.knowledge_allowed_sources)],['Kategorisierungsquellen',val(s.knowledge_category_sources)],['Auto-Reply-Quellen',val(s.knowledge_auto_reply_sources)],['Fremdkategorie-Modus',val(s.knowledge_category_mode)],['Kategorie-Mapping',val(s.knowledge_category_map_configured?'konfiguriert':'–')],['Ignore-Globs',val(s.knowledge_ignore_globs)],['Ignorierte Dateien',val(s.knowledge_ignored_files)],['KBs mit ungemappten Kategorien',val(s.knowledge_unmapped_category_files)],['Ungemappte Kategorien',val(s.knowledge_unmapped_categories)]]),configCard('GLPI Knowledge Base','Synchronisation der GLPI-Wissensdatenbank',[['Aktiv',val(s.glpi_kb_enabled)],['Sync OK',val(s.glpi_kb_ok)],['Dokumente',val(s.glpi_kb_documents)],['Letzter Sync',val(fmtDate(s.glpi_kb_last_sync))],['Intervall',val(s.glpi_kb_sync_interval)],['Pfad',val(s.glpi_kb_path)],['Filter gesetzt',val(s.glpi_kb_filter_configured)],['Limit',val(s.glpi_kb_limit)],['Auto-Reply',val(s.glpi_kb_auto_reply)],['Auto-Reply freigegeben / blockiert',val(`${s.glpi_kb_auto_reply_approved||0} / ${s.glpi_kb_auto_reply_blocked||0}`)],['Auto-Reply-Entscheidungen',val(s.glpi_kb_auto_reply_decisions)],['Auto-Reply-KB-Kategorien',val(s.glpi_kb_auto_reply_category_ids)],['Kategorielose Artikel zulassen',val(s.glpi_kb_auto_reply_allow_uncategorized)],['Freigegebene kategorielose Artikel-IDs',val(s.glpi_kb_auto_reply_uncategorized_article_ids)],['Letzter Fehler',val(s.glpi_kb_last_error||'–')]]),configCard('Policy & Kommunikation','Entscheidungsschwellen und Sprache',[['Kategorie-Confidence',`${pct(s.category_confidence)}`],['Reply-Confidence',`${pct(s.reply_confidence)}`],['Sprache',val(s.communication_language)],['Stil',val(s.communication_style)],['KI-Kennzeichnung',val(s.ai_content_label_enabled)],['KB-Webeditor',val(s.knowledge_edit_enabled)],['Lernen',val(s.learning_enabled)],['Max. Lernbeispiele',val(s.learning_max_examples)],['Beispiele/Kategorie',val(s.learning_examples_per_category)]]),configCard('Kontextquellen','Störungen, Changes, Incidents und Geräte',[['Kontext aktiv',val(s.context_enabled)],['Timeout',val(s.context_timeout)],['Relevanz-Minimum',pct(s.context_relevance_min_score)],['Fail-closed',val(s.context_fail_closed)],['Incident blockiert normalen Reply',val(s.context_incident_block)],['Vordefinierte Statusantwort',val(s.context_status_reply_enabled)],['Status: Relevanz-Minimum',pct(s.context_status_reply_min_relevance)],['Status: KI-Minimum',pct(s.context_status_reply_min_ai_confidence)],['Status: Final-Minimum',pct(s.context_status_reply_min_final_score)],['Störungstext konfiguriert',val(s.context_incident_reply_text_configured)],['Wartungstext konfiguriert',val(s.context_maintenance_reply_text_configured)],['Change Calendar',val(s.change_calendar_enabled)],['Lookback',val(s.change_lookback)],['Lookahead',val(s.change_lookahead)],['Major Incidents',val(s.major_incidents_enabled)],['Benutzer-Geräte',val(s.user_device_context_enabled)],['Uptime Kuma',val(s.uptime_kuma_enabled)],['Uptime-Modus',val(s.uptime_kuma_mode)]] )];$('#configGroups').innerHTML=groups.join('')} function renderSourceOptions(){const filterOld=$('#kbSourceFilter').value,sourceOld=$('#kbSource').value;const sources=[...new Set(kbDocs.map(x=>x.source).filter(Boolean))].sort();$('#kbSourceFilter').innerHTML=''+sources.map(x=>``).join('');if([...$('#kbSourceFilter').options].some(o=>o.value===filterOld))$('#kbSourceFilter').value=filterOld;const allowed=statusData.knowledge_allowed_sources||[];$('#kbSource').innerHTML=allowed.map(x=>``).join('');if([...$('#kbSource').options].some(o=>o.value===sourceOld))$('#kbSource').value=sourceOld;else if([...$('#kbSource').options].some(o=>o.value==='internal-kb'))$('#kbSource').value='internal-kb'} function renderCategoryPicker(filter=''){const q=filter.toLowerCase();$('#kbCategoryList').innerHTML=categories.filter(c=>!q||(c.completename||c.name||'').toLowerCase().includes(q)).map(c=>``).join('')||'
Keine Kategorie gefunden.
'} function clearKbForm(){currentKbId='';kbCategorySelection=new Set();$('#kbForm').reset();$('#kbId').disabled=false;$('#kbId').value='';$('#kbLanguage').value=statusData.communication_language||'de-DE';$('#kbStyle').value=statusData.communication_style||'formal';$('#kbScore').value=Number(statusData.knowledge_min_score||.70).toFixed(2);if([...$('#kbSource').options].some(x=>x.value==='internal-kb'))$('#kbSource').value='internal-kb';$('#kbModalTitle').textContent='Neuen Artikel anlegen';$('#kbModalEyebrow').textContent='Interne Knowledge Base';$('#kbEditState').textContent='Neuer Artikel';$('#kbFormMessage').className='form-message';$('#kbCategorySearch').value='';renderCategoryPicker();updateCounts()}