This commit is contained in:
2026-08-05 19:13:45 +02:00
parent 31fa84f243
commit d415863604
22 changed files with 941 additions and 250 deletions

View File

@@ -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
###############################################################################

View File

@@ -817,9 +817,39 @@ Für gemeinsam genutzte Knowledge-Verzeichnisse ist `unscoped` der kompatibelste
| `GLPI_KB_LIMIT` | Agent | Maximale Anzahl GLPI-KB-Artikel. | 15000. | 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 |

View File

@@ -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

View File

@@ -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=
```

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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`.

4
dist/SHA256SUMS.txt vendored
View File

@@ -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

Binary file not shown.

Binary file not shown.

View File

@@ -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
}

View File

@@ -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,
})
}

View File

@@ -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
}
}

View File

@@ -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)
}
}

View File

@@ -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 {

View File

@@ -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)
}
}

View File

@@ -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",
)
}
}

View File

@@ -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: "<p>Drucker neu verbinden.</p>", 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)
}
}

View File

@@ -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.

View File

@@ -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(),
})

File diff suppressed because one or more lines are too long