Update
This commit is contained in:
23
.env.example
23
.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
|
||||
###############################################################################
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
92
HOTFIX-GLPI-KB-SIMPLE-AUTO-REPLY.md
Normal file
92
HOTFIX-GLPI-KB-SIMPLE-AUTO-REPLY.md
Normal 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=
|
||||
```
|
||||
81
HOTFIX-GLPI-KB-UNCATEGORIZED-AUTO-REPLY.md
Normal file
81
HOTFIX-GLPI-KB-UNCATEGORIZED-AUTO-REPLY.md
Normal 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.
|
||||
29
README.md
29
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.
|
||||
|
||||
|
||||
10
SECURITY.md
10
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.
|
||||
|
||||
25
UPGRADE.md
25
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`.
|
||||
|
||||
4
dist/SHA256SUMS.txt
vendored
4
dist/SHA256SUMS.txt
vendored
@@ -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
|
||||
|
||||
BIN
dist/glpi-ai-agent-linux-amd64
vendored
BIN
dist/glpi-ai-agent-linux-amd64
vendored
Binary file not shown.
BIN
dist/glpi-ai-agent-windows-amd64.exe
vendored
BIN
dist/glpi-ai-agent-windows-amd64.exe
vendored
Binary file not shown.
128
glpi-ai-agent-neural-brain.patch
Normal file
128
glpi-ai-agent-neural-brain.patch
Normal 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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user