diff --git a/.dockerignore b/.dockerignore
index fb2645d..db5c4b8 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -1,6 +1,8 @@
.git
.env
+.env_local
data
*.zip
*.log
README.local.md
+/agent
diff --git a/.env.example b/.env.example
index ce5a519..8ba9bb6 100644
--- a/.env.example
+++ b/.env.example
@@ -611,7 +611,7 @@ AUTO_CATEGORY=true
# DRY_RUN=true verhindert trotzdem das tatsächliche Schreiben nach GLPI.
AUTO_REPLY=true
# Mindestconfidence der KI für Kategorieänderungen.
-CATEGORY_CONFIDENCE=0.70
+CATEGORY_CONFIDENCE=0.90
# Mindestconfidence der KI für Antwortauswahl.
#
# Dies allein reicht NICHT für Auto-Reply.
@@ -625,9 +625,42 @@ CATEGORY_CONFIDENCE=0.70
# - Followup-Prüfung
# - Kontext-/Incident-Regeln
# - zweite Followup-Prüfung unmittelbar vor dem Schreiben
-REPLY_CONFIDENCE=0.70
+REPLY_CONFIDENCE=0.97
###############################################################################
-# 28. WORKER / QUEUE
+# 28. KI-PRIORISIERUNG
+###############################################################################
+# Separater KI-Lauf zur Empfehlung der GLPI-Priorität. Der Lauf wird im
+# Diagnose-Cockpit unabhängig von Kategorie, Status und Antwort gespeichert.
+PRIORITY_ENABLED=true
+# Standardmäßig Shadow Mode: Empfehlung und Policy-Gates werden protokolliert,
+# GLPI wird nicht verändert. Für Live-Schreibzugriffe zusätzlich DRY_RUN=false.
+AUTO_PRIORITY=false
+PRIORITY_CONFIDENCE=0.88
+# Eigener Fail-open-Timeout für diesen optionalen KI-Lauf. Kategorie und Antwort laufen danach weiter.
+PRIORITY_ANALYSIS_TIMEOUT=45s
+# Automatische Erhöhung je Ticketlauf; Herabstufungen sind grundsätzlich gesperrt.
+PRIORITY_MAX_INCREASE=1
+# Nur kontrollierte, kommaseparierte Grundcodes dürfen eine Empfehlung tragen.
+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
+###############################################################################
+# 29. ZEITGESTEUERTE KI-ESKALATION
+###############################################################################
+# Unabhängiger Scheduler. Er prüft offene Tickets auch ohne Änderung von date_mod.
+ESCALATION_ENABLED=false
+# Standardmäßig werden nur Diagnose-/Shadow-Läufe erzeugt.
+AUTO_ESCALATION=false
+ESCALATION_SCAN_INTERVAL=15m
+ESCALATION_MIN_AGE=4h
+ESCALATION_CONFIDENCE=0.88
+ESCALATION_MAX_LEVEL=3
+ESCALATION_ALLOWED_REASON_CODES=no_human_response,sla_at_risk,sla_breached,business_deadline,no_workaround,security_incident_suspected,unassigned,major_incident_candidate
+# Aktuell sicher implementierte automatische Aktion: raise_priority.
+ESCALATION_ALLOWED_ACTIONS=none,raise_priority
+# Leer = GLPI_TICKET_FILTER verwenden. Für Produktion möglichst explizit setzen.
+GLPI_ESCALATION_FILTER=
+GLPI_ESCALATION_LIMIT=100
+###############################################################################
+# 30. WORKER / PRIORITÄTSQUEUE
###############################################################################
# Maximale Anzahl wartender Jobs.
QUEUE_SIZE=256
diff --git a/.gitignore b/.gitignore
index 56dd765..ed24d0e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,5 @@
.env
+.env_local
/data/*
!/data/.gitkeep
*.log
diff --git a/EMERGENCY-HOTFIX-TICKETVERARBEITUNG.md b/EMERGENCY-HOTFIX-TICKETVERARBEITUNG.md
new file mode 100644
index 0000000..f1048ec
--- /dev/null
+++ b/EMERGENCY-HOTFIX-TICKETVERARBEITUNG.md
@@ -0,0 +1,62 @@
+# Emergency-Hotfix: Ticketverarbeitung wird nicht mehr durch Prioritätsanalyse blockiert
+
+## Symptom
+
+Nach Installation von `priority-v3` kann die Weboberfläche erreichbar sein, während neue Tickets scheinbar nicht mehr verarbeitet werden oder sehr lange in der Queue verbleiben.
+
+## Technische Ursache
+
+Der Prioritätslauf war zwar fachlich optional, verwendete aber den allgemeinen Ollama-Kontext und konnte bei einer semantisch widersprüchlichen Modellantwort einen zweiten Modellaufruf starten. Bei `OLLAMA_MAX_CONCURRENT=1` blockiert ein solcher Aufruf auch die nachfolgenden Kategorie- und Antwortaufrufe anderer Worker. Abhängig von `OLLAMA_TIMEOUT` konnte dies mehrere Minuten dauern.
+
+Der Hotfix macht die Prioritätsanalyse konsequent fail-open:
+
+- eigener Timeout `PRIORITY_ANALYSIS_TIMEOUT`, Standard `45s`,
+- kein erneuter Ollama-Aufruf wegen semantischer Inkonsistenzen,
+- deterministische Normalisierung von Scope und Reason Codes,
+- keine Erhöhung der Modell-Confidence,
+- bei Timeout oder Fehler wird nur der Prioritätslauf als `priority_ai_failed` markiert,
+- Kategorie-, Status- und Antwortpfad laufen weiter,
+- Diagnoseversion `priority-v4`.
+
+## Sofortige Wiederherstellung ohne Update
+
+Bis der Hotfix installiert ist:
+
+```env
+PRIORITY_ENABLED=false
+```
+
+Danach den Agenten neu starten. Die Kategorisierung und Antwortauswahl funktionieren unabhängig davon weiter.
+
+## Konfiguration nach Installation
+
+```env
+PRIORITY_ENABLED=true
+AUTO_PRIORITY=false
+PRIORITY_ANALYSIS_TIMEOUT=45s
+```
+
+Bei langsamer CPU-Inferenz kann der Wert erhöht werden. Er sollte deutlich unter `OLLAMA_TIMEOUT` bleiben.
+
+## Wichtiger Upgrade-Hinweis
+
+Beim nativen Betrieb nur das Programm beziehungsweise die geänderten Quelldateien ersetzen. Nicht löschen oder überschreiben:
+
+- `.env` / `.env_local`
+- `data/`
+- `knowledge/`
+
+Wenn `data/` entfernt wurde, muss der Knowledge-Index neu aufgebaut werden. Während der Initialisierung bleibt die Ticketverarbeitung absichtlich pausiert. Im Dashboard sind dann `knowledge_ready=false` und der aktuelle Initialisierungsstatus sichtbar.
+
+## Diagnose, falls weiterhin keine Tickets verarbeitet werden
+
+Mit `PRIORITY_ENABLED=false` neu starten. Wenn weiterhin kein neuer Lauf entsteht, liegt die Ursache nicht im Prioritätslauf. Dann sind insbesondere zu prüfen:
+
+- `knowledge_ready`
+- `knowledge_init_state`
+- `knowledge_init_error`
+- `glpi_ok`
+- `ollama_ok`
+- `queue_depth`
+- Startprotokoll ab `knowledge initialization started in background`
+
diff --git a/HOTFIX-POLL-DIAGNOSE.md b/HOTFIX-POLL-DIAGNOSE.md
new file mode 100644
index 0000000..5e7de6f
--- /dev/null
+++ b/HOTFIX-POLL-DIAGNOSE.md
@@ -0,0 +1,25 @@
+# Hotfix: Poll-Diagnose und manuelle Neuanalyse
+
+Dieser Hotfix behebt nicht den Poller selbst, sondern die fehlende Sichtbarkeit seines Ergebnisses. Der Agent filtert unveränderte, bereits verarbeitete Ticketversionen bereits vor der Queue. Dadurch konnten Dashboard, Queue und Audit leer aussehen, obwohl der GLPI-Poll korrekt lief.
+
+## Neue Diagnosewerte
+
+`GET /api/status` liefert zusätzlich:
+
+- `polls_total`
+- `last_poll`
+- `poll_last_fetched`
+- `poll_last_seen`
+- `poll_last_unseen`
+- `poll_last_enqueued`
+- `poll_last_rejected`
+- `poll_last_error`
+- `processed_version_count`
+
+Der erste erfolgreiche Poll wird außerdem einmalig auf INFO-Level protokolliert. Weitere Polls erscheinen auf DEBUG-Level.
+
+## Manuelle Neuanalyse
+
+Im Dashboard kann eine Ticket-ID manuell neu analysiert werden. Der Lauf erhält den Trigger `manual_recheck` und umgeht die Versions-Deduplizierung genau für diesen Lauf. Der gespeicherte Betriebszustand wird nicht gelöscht.
+
+Im LIVE-Modus gelten weiterhin alle konfigurierten Auto-Aktionen. Vor der manuellen Neuanalyse sollte daher geprüft werden, ob automatische Kategorie-, Prioritäts- oder Antwortaktionen aktiv sind.
diff --git a/HOTFIX-PRIORITAET.md b/HOTFIX-PRIORITAET.md
new file mode 100644
index 0000000..bd55ee2
--- /dev/null
+++ b/HOTFIX-PRIORITAET.md
@@ -0,0 +1,56 @@
+# Hotfix: Prioritätsanalyse in Diagnose und Quellpaket
+
+## Fehlerbild
+
+Ein Ticketlauf enthielt weder `priority_analysis_executed` noch `priority_decision`, `priority_checks` oder einen Eintrag mit `analysis_type: "priority"` in `analyses`. In der Diagnose waren deshalb nur Kategorie und Status sichtbar.
+
+## Ursache
+
+Das vorherige Quellarchiv wurde mit einem zu breiten Ausschlussmuster `agent` erstellt. Dadurch fehlten ausgerechnet `cmd/agent` und `internal/agent` im Quellpaket. Wer die übrigen Änderungen über einen bestehenden Projektstand kopierte oder den alten Agent-Einstieg weiterverwendete, erhielt zwar Konfiguration, Modelltypen und UI-Teile, aber nicht die zentrale Ausführung der Prioritäts- und Eskalationsstufen.
+
+## Korrektur
+
+Dieses Paket enthält wieder den vollständigen Quellstand einschließlich `cmd/agent` und `internal/agent`. Neue normale Ticketläufe speichern die Prioritätsentscheidung zusätzlich in zwei Formen:
+
+- gut sichtbare Top-Level-Felder wie `priority_before`, `ai_recommended_priority`, `priority_proposed`, `priority_would_change`, `priority_decision`, `priority_reason_codes` und `priority_checks`,
+- einen eigenständigen Eintrag in `analyses` mit `analysis_type: "priority"`, Input-Snapshot, Prompt-Version, Reason Codes, Policy-Prüfungen und Action-Audit.
+
+Die Diagnoseoberfläche zeigt eine eigene Karte **Prioritätsentscheidung** mit dem Ablauf **Aktuell → KI-Empfehlung → Policy-Ziel**. Damit ist auch im Shadow Mode eindeutig sichtbar, ob und auf welchen Wert die Priorität geändert worden wäre und welche Regel eine Änderung gegebenenfalls blockiert hat.
+
+## Wichtig nach dem Austausch
+
+Historische Zeilen in `data/runs.jsonl` werden nicht nachträglich um eine Prioritätsanalyse ergänzt. Nach Neustart muss ein neuer Ticketlauf entstehen. Dafür kann das Ticket geändert, ein Webhook ausgelöst oder eine noch nicht verarbeitete Ticketversion verwendet werden. Bei einem bereits verarbeiteten unveränderten Ticket greift weiterhin die Versions-Deduplizierung.
+
+Empfohlener Shadow Mode:
+
+```env
+PRIORITY_ENABLED=true
+AUTO_PRIORITY=false
+DRY_RUN=true
+```
+
+`AUTO_PRIORITY=false` bedeutet nur, dass nicht geschrieben wird. Die KI-Analyse und sämtliche Policy-Prüfungen werden trotzdem ausgeführt und angezeigt.
+
+## Ergänzung: `insufficient_information` ist kein Policy-Fehler
+
+Ein unverändertes Ergebnis wie `#3 → #3` mit dem Reason Code
+`insufficient_information` ist eine bewusste Enthaltung der Prioritäts-KI. Dieser
+Grund soll keine Höherstufung auslösen, ist aber auch kein verbotener Aktionsgrund.
+
+Der Hotfix unterscheidet deshalb jetzt zwischen:
+
+- **Aktionsgründen**, die eine Erhöhung begründen dürfen und über
+ `PRIORITY_ALLOWED_REASON_CODES` freigegeben werden,
+- **neutralen Kontext-/Enthaltungsgründen** wie `single_user_affected`,
+ `workaround_available` und `insufficient_information`.
+
+Bei unveränderter Empfehlung wird `insufficient_information` als
+`priority_no_change_insufficient_information` protokolliert. Confidence und
+Reason-Allowlist sind dabei nicht anwendbare Schreib-Gates (`status: "na"`),
+weil keine Änderung vorgeschlagen wird. Empfiehlt das Modell trotz
+`insufficient_information` eine Erhöhung, blockiert die Policy diese weiterhin
+mit `priority_insufficient_information`.
+
+Reason Codes werden vor Policy und Audit getrimmt, kleingeschrieben und
+dedupliziert. Damit wird eine Ausgabe wie sechs identische
+`insufficient_information`-Einträge als genau ein Grund gespeichert.
diff --git a/HOTFIX-TRIAGE-KONSISTENZ.md b/HOTFIX-TRIAGE-KONSISTENZ.md
new file mode 100644
index 0000000..eaad6ff
--- /dev/null
+++ b/HOTFIX-TRIAGE-KONSISTENZ.md
@@ -0,0 +1,37 @@
+# Hotfix: Prioritätsbelege und Kategorie-Mapping-Diagnose
+
+Dieser Stand behebt eine semantische Schwäche des separaten Prioritätslaufs.
+
+## Prioritätsanalyse `priority-v3`
+
+Vor dem Ollama-Aufruf werden ausschließlich explizite Aussagen aus Betreff und Tickettext als konservative Belege extrahiert. Beispiele:
+
+- `meine Kollegen und ich` -> `multiple_users_affected`
+- `die Bürodrucker laufen noch` -> `workaround_available`
+- `gesamter Standort` -> `site_affected`
+- `kein Workaround` -> `no_workaround`
+
+Diese Belege entscheiden nicht selbst über die Priorität. Sie werden im Input-Snapshot unter `deterministic_evidence` gespeichert und verhindern lediglich widersprüchliche Modellausgaben.
+
+Ein Modellresultat wird erneut angefordert, wenn es beispielsweise trotz eines expliziten Mehrbenutzer-Belegs `insufficient_information` ausgibt oder wenn `affected_scope` und `reason_codes` nicht zusammenpassen. Nach Ausschöpfung von `OLLAMA_JSON_RETRIES` schlägt nur der Prioritätslauf fehl; Kategorie und Antwortpfad bleiben fail-closed funktionsfähig.
+
+Die Diagnose speichert und zeigt nun zusätzlich:
+
+- `ai_recommended_impact`
+- `ai_recommended_urgency`
+- `priority_affected_scope`
+- `priority_time_criticality`
+
+Eine Ausweichmöglichkeit kann trotz mehrerer Betroffener weiterhin zu einer unveränderten Priorität führen. Der Hotfix erzwingt daher keine Erhöhung, sondern nur eine sachlich konsistente Begründung.
+
+## Kategorie-Mapping-Diagnose
+
+Wenn ein Kategorisierungs-Wissenseintrag auf eine GLPI-ID gemappt wird, deren Name deutlich vom externen Auswahlziel abweicht, erscheint ein nicht blockierender Warnhinweis `category_external_mapping_review`.
+
+Beispiel:
+
+```text
+Drucken, Scannen und Kopieren > Netzwerkdrucker -> #67 Arbeitsplatzdrucker
+```
+
+Solche Mappings können organisatorisch beabsichtigt sein. Sie beeinflussen jedoch Hints, Kandidaten und die KI-Begründung und sollten deshalb bewusst geprüft werden.
diff --git a/IMPLEMENTATION.md b/IMPLEMENTATION.md
new file mode 100644
index 0000000..fd5b589
--- /dev/null
+++ b/IMPLEMENTATION.md
@@ -0,0 +1,62 @@
+# Umsetzung: eigenständige KI-Läufe, Priorisierung und Eskalation
+
+## Gelieferter Funktionsumfang
+
+Diese Version erweitert die bestehende Ticketverarbeitung um ein generisches, abwärtskompatibles Modell für eigenständige Analyseläufe. Kategorie, Priorität, Statuszuordnung, Antwortauswahl und zeitgesteuerte Eskalation besitzen jeweils eine eigene Analyse-ID, Prompt-Version, Eingangsdaten-Snapshot, Eingangsdaten-Hash, Laufzeit, strukturierte Entscheidung, Grundcodes, Policy-Prüfungen und ein separates Action-Audit.
+
+Die Ticketpriorisierung läuft standardmäßig im Shadow Mode. Das Modell empfiehlt eine GLPI-Priorität und kontrollierte Grundcodes; Go entscheidet anschließend deterministisch. Automatische Herabstufungen sind gesperrt, Erhöhungen je Lauf begrenzt und Live-Schreibzugriffe zusätzlich durch `AUTO_PRIORITY` und `DRY_RUN` geschützt.
+
+Die Eskalation besitzt einen unabhängigen Scheduler und ist nicht an `date_mod` oder die normale FIFO-/Polling-Deduplizierung gebunden. Alte offene Tickets können dadurch erneut geprüft werden. Alter, Modellentscheidung, Confidence, Stufe, Grundcodes, Aktion, menschliche Aktivität, aktueller Ticketzustand und Idempotenz werden getrennt validiert. Als automatische Aktion ist absichtlich nur `raise_priority` implementiert.
+
+Die interne Queue ist eine priorisierte Heap-Queue. Manuelle Läufe, Webhooks, Polling und Scheduler-Läufe können unterschiedlich gewichtet werden. Dedupliziert wird je Ticket und Trigger, sodass ein normaler Ticketlauf und eine zeitgesteuerte Eskalation desselben Tickets parallel vorgemerkt werden dürfen, aber nicht doppelt je Trigger.
+
+## Diagnose und Persistenz
+
+`runs.jsonl` bleibt der ausführliche Audit-Trail. Zusätzlich speichert `state-index.json` den kompakten Betriebszustand für die letzte erfolgreich verarbeitete Ticketversion und bereits ausgeführte Eskalationsstufen. Beide Dateien werden synchronisiert beziehungsweise atomar ersetzt. Alte Auditzeilen bleiben lesbar.
+
+Die Diagnoseoberfläche stellt Analysen dynamisch dar. Neue Analysearten benötigen dadurch keine zusätzlichen festen Felder in der UI. Die Statusübersicht zeigt Shadow-/Live-Modus, Prioritäts- und Eskalationsmetriken sowie die wirksamen Allow- und Schwellenwerte.
+
+## Sichere Einführung
+
+Empfohlene erste Konfiguration:
+
+```env
+DRY_RUN=true
+PRIORITY_ENABLED=true
+AUTO_PRIORITY=false
+ESCALATION_ENABLED=false
+AUTO_ESCALATION=false
+```
+
+Nach der fachlichen Auswertung der Prioritätsläufe kann die Eskalation zunächst ebenfalls ohne Aktionen aktiviert werden:
+
+```env
+ESCALATION_ENABLED=true
+AUTO_ESCALATION=false
+```
+
+Erst nach Prüfung der GLPI-Felder, Filter, Rechte und Diagnoseergebnisse sollten einzelne automatische Schreibpfade aktiviert werden. Für `AUTO_ESCALATION=true` ist ein dediziertes `GLPI_AGENT_USER_ID` erforderlich.
+
+## Bewusst nicht automatisch aktivierte Erweiterungen
+
+Routing zu Bearbeitergruppen, Dublettenzusammenführung und SLA-Prognosen benötigen installationsspezifische Gruppenlisten, Verknüpfungsfelder beziehungsweise belastbare historische Daten. Die generische Analyse-Run-Infrastruktur und die dynamische Diagnose sind dafür vorbereitet; ohne diese Zielsystemdaten wurden keine spekulativen GLPI-Schreiboperationen eingebaut.
+
+## Verifikation
+
+Vor der Auslieferung wurden ausgeführt:
+
+```text
+go test ./...
+go vet ./...
+go test -race ./...
+node --check (Dashboard-JavaScript)
+go build -trimpath -ldflags="-s -w" ./cmd/agent
+```
+
+Die automatisierten Prüfungen ersetzen keinen Shadow-Mode-Test gegen die konkrete GLPI-Installation und deren generierte OpenAPI-Beschreibung.
+
+## Prioritätskonsistenz `priority-v3`
+
+Der Prioritätslauf erhält konservativ extrahierte, im Ticket ausdrücklich vorhandene Belege. Ollama bleibt die entscheidende Analyseinstanz; Go validiert jedoch, dass Scope, Reason Codes und Begründung den belegten Tatsachen nicht widersprechen. Die Belege und die zusätzlichen Impact-/Urgency-/Scope-Felder werden im separaten `AnalysisRun` gespeichert.
+
+Zusätzlich erzeugt die Kategorieanalyse einen nicht blockierenden Diagnosehinweis, wenn eine externe Knowledge-Kategorie auf eine GLPI-Kategorie mit deutlich anderem Namen gemappt ist.
diff --git a/Makefile b/Makefile
index 4c45c7d..e55713b 100644
--- a/Makefile
+++ b/Makefile
@@ -1,7 +1,13 @@
-.PHONY: build test race vet fmt check run zip
+.PHONY: build dist test race vet fmt check run zip
build:
- go build -trimpath ./cmd/agent
+ CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o glpi-ai-agent ./cmd/agent
+
+dist:
+ mkdir -p dist
+ CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o dist/glpi-ai-agent-linux-amd64 ./cmd/agent
+ CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o dist/glpi-ai-agent-windows-amd64.exe ./cmd/agent
+ cd dist && sha256sum glpi-ai-agent-linux-amd64 glpi-ai-agent-windows-amd64.exe > SHA256SUMS.txt
test:
go test ./...
@@ -22,4 +28,4 @@ run:
zip:
cd .. && zip -qr glpi-ai-agent.zip glpi-ai-agent \
- -x 'glpi-ai-agent/.env' 'glpi-ai-agent/data/*.json' 'glpi-ai-agent/*.zip' 'glpi-ai-agent/glpi-ai-agent'
+ -x 'glpi-ai-agent/.env' 'glpi-ai-agent/.env_local' 'glpi-ai-agent/data/*' 'glpi-ai-agent/*.zip' 'glpi-ai-agent/agent' 'glpi-ai-agent/glpi-ai-agent'
diff --git a/README.md b/README.md
index 49422c5..5678419 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# GLPI AI Agent (Go + Ollama)
-Produktionsorientierter, bewusst **policy-gesteuerter** Ticket-Agent für GLPI 11. Er liest neue/geänderte Tickets über die GLPI High-Level API, schlägt eine Kategorie vor, sucht freigegebene Wissenseinträge und kann – nach mehreren harten Sicherheitsprüfungen – eine erste Antwort schreiben.
+Produktionsorientierter, bewusst **policy-gesteuerter** Ticket-Agent für GLPI 11. Er liest neue/geänderte Tickets über die GLPI High-Level API, führt getrennte KI-Läufe für Kategorie, Priorität, Störungszuordnung und Antwortauswahl aus und kann offene Tickets in einem unabhängigen Scheduler auf Eskalationsbedarf prüfen. Schreiboperationen erfolgen ausschließlich nach deterministischen Go-Policies.
## Sicherheitsmodell
@@ -18,7 +18,10 @@ Produktionsorientierter, bewusst **policy-gesteuerter** Ticket-Agent für GLPI 1
- Pro Ticket wird innerhalb eines Prozesses seriell gearbeitet; Polling/Webhook-Ereignisse werden dedupliziert.
- GLPI-Schreibfehler werden nicht automatisch wiederholt, um Doppelwrites zu vermeiden.
- Das Dashboard ist read-only und standardmäßig mit HTTP Basic Auth geschützt.
-- Audit-Trail: `data/runs.jsonl`.
+- Jeder KI-Schritt wird als eigenständiger `AnalysisRun` mit Input-Hash, Prompt-Version, Reason Codes, Policy-Checks und Action-Audit gespeichert.
+- Zeitgesteuerte Eskalationsläufe sind von `date_mod` und der normalen Ticket-Deduplizierung unabhängig.
+- Die interne Verarbeitung nutzt eine deduplizierende Prioritätsqueue; Webhooks, manuelle Läufe, Polling und Scheduler besitzen getrennte Prioritäten.
+- Audit-Trail: `data/runs.jsonl` mit automatischer Kompaktierung bei starkem Wachstum. Der kompakte Betriebszustand für letzte Ticketversionen und ausgeführte Eskalationsstufen liegt getrennt in `data/state-index.json`.
> Wichtige Grenze: Die zweite Followup-Prüfung minimiert Race Conditions, kann ohne einen atomaren Conditional-Write auf GLPI-Seite aber kein mathematisch vollständig atomisches "check-and-write" garantieren. Für einen einzelnen Agent-Prozess ist zusätzlich ein Ticket-Lock aktiv.
@@ -82,6 +85,10 @@ Vor dem ersten Live-Betrieb unbedingt mehrere Tage/Wochen im Shadow Mode lassen:
DRY_RUN=true
AUTO_CATEGORY=true
AUTO_REPLY=false
+PRIORITY_ENABLED=true
+AUTO_PRIORITY=false
+ESCALATION_ENABLED=false
+AUTO_ESCALATION=false
```
Danach zunächst nur Kategorieänderungen:
@@ -99,12 +106,45 @@ AUTO_REPLY=true
GLPI_AGENT_USER_ID=123
```
+## Getrennte KI-Läufe: Priorität und Eskalation
+
+Jede Analyse besitzt eine eigene ID und wird unabhängig diagnostiziert. Der übergeordnete Ticketlauf enthält lediglich die zeitliche und kausale Klammer. Die Diagnose zeigt je Analyse unter anderem Modell, Prompt-Version, Eingabe-Snapshot und -Hash, strukturierte Entscheidung, Grundcodes, Confidence, Policy-Gates und die tatsächlich ausgeführte Aktion.
+
+Die Prioritätsanalyse ist standardmäßig aktiv, aber im Shadow Mode:
+
+```env
+PRIORITY_ENABLED=true
+AUTO_PRIORITY=false
+PRIORITY_CONFIDENCE=0.88
+PRIORITY_MAX_INCREASE=1
+```
+
+Das Modell empfiehlt eine GLPI-Priorität und kontrollierte Grundcodes. Die Go-Policy verhindert Herabstufungen, begrenzt Erhöhungen und akzeptiert nur konfigurierte Gründe. Für einen kontrollierten Live-Betrieb sind **beide** Schalter erforderlich:
+
+```env
+DRY_RUN=false
+AUTO_PRIORITY=true
+```
+
+Die Eskalation verwendet einen separaten Scheduler und findet deshalb auch unveränderte, ältere Tickets:
+
+```env
+ESCALATION_ENABLED=true
+AUTO_ESCALATION=false
+ESCALATION_SCAN_INTERVAL=15m
+ESCALATION_MIN_AGE=4h
+GLPI_ESCALATION_FILTER=status.id==1
+```
+
+Zuerst sollte `AUTO_ESCALATION=false` bleiben. Der Scheduler erzeugt dann vollständige Eskalationsanalysen, führt aber keine Aktion aus. Die derzeit bewusst eng begrenzte automatische Aktion ist `raise_priority`; identische ausgeführte Eskalationsstufen werden über einen in `state-index.json` dauerhaft gespeicherten Idempotenzschlüssel nicht erneut geschrieben. Followups des konfigurierten Agent-Benutzers werden von menschlicher Aktivität unterschieden. Für `AUTO_ESCALATION=true` muss `GLPI_AGENT_USER_ID` auf das dedizierte GLPI-Agentkonto zeigen; andernfalls verweigert die Konfiguration den Start. Das Konto benötigt für Live-Betrieb ausschließlich die tatsächlich verwendeten Rechte, insbesondere Ticketlesen und – bei freigegebenen Aktionen – Prioritätsänderungen.
+
## GLPI-Endpunkte
Default ist `GLPI_API_VERSION=v2.3`. Der Client verwendet:
- OAuth: `POST /api.php/token`
-- Tickets: `/api.php/v2.3/Assistance/Ticket`
+- Tickets lesen und Eskalationskandidaten suchen: `/api.php/v2.3/Assistance/Ticket`
+- Kategorie und – bei expliziter Freigabe – Priorität schreiben: `PATCH /api.php/v2.3/Assistance/Ticket/{id}`
- Followups: `/api.php/v2.3/Assistance/Ticket/{id}/Timeline/Followup`
- Kategorien: `/api.php/v2.3/Dropdowns/ITILCategory`
- OpenAPI-Prüfung: `/api.php/doc.json`
@@ -391,6 +431,7 @@ Mit den sicheren Defaults gilt:
- `/` – Dashboard (Basic Auth)
- `/api/status` – Status JSON inklusive aktiver Sprache/Stil- und Quellenpolicy (Basic Auth)
- `/api/runs?limit=50` – letzte Audit-Läufe (Basic Auth)
+- `/api/diagnostics/analysis/{analysis_id}` – einzelner, eigenständiger KI-Analyselauf (Basic Auth)
- `/healthz` – Prozess lebt
- `/readyz` – GLPI und Ollama erreichbar
- `/metrics` – Prometheus Textformat
@@ -579,6 +620,8 @@ Mit `AI_CONTENT_LABEL_ENABLED=true` wird der konfigurierte TrustedNet-Kennzeichn
Neben dem normalen Control Center steht unter `/diagnostics` ein separates Diagnose-Cockpit zur Verfügung. Es verwendet die vom Agenten selbst gespeicherten Policy-Regeln und zeigt pro Ticketlauf u. a.:
+- alle eigenständigen Analyseläufe (`category`, `priority`, `status_match`, `reply_selection`, `escalation`) als separat öffnbare Karten,
+
- alle Kategorie-Gates mit Ist-/Sollwert und Blockierstatus,
- alle Auto-Reply-Gates (Quelle, Sprache, Stil, Artikel-Freigabe, Retrieval-Floor, Evidenz, Kategoriebindung, Kontext),
- Ausführungs-/Race-Protection (Followups, Dry-Run, Ticket-Recheck, GLPI-Write),
@@ -604,3 +647,17 @@ aus lokalen Knowledge-JSONs mit den aktuell aus GLPI gelesenen ITIL-Kategorien.
Der Editor unterstützt Mehrfachzuordnungen, Verwendungshäufigkeiten, Filter für
nicht zugeordnete und verwaiste Einträge sowie unverbindliche Namensvorschläge.
Schreibzugriff ist nur bei `KNOWLEDGE_WEB_EDIT_ENABLED=true` möglich.
+
+### Prioritätslauf blockiert die Queue nicht
+
+Der optionale Prioritätslauf besitzt ein eigenes Zeitbudget:
+
+```env
+PRIORITY_ANALYSIS_TIMEOUT=45s
+```
+
+Läuft das Modell in einen Timeout oder liefert es eine nicht verwertbare Antwort, wird ausschließlich der Prioritätslauf mit `priority_ai_failed` beendet. Kategorie- und Antwortverarbeitung laufen weiter. Semantische Inkonsistenzen zwischen expliziten Ticketbelegen und Reason Codes werden lokal normalisiert; dafür wird kein zusätzlicher Ollama-Aufruf gestartet.
+
+## Poll-Diagnose und manuelle Neuanalyse
+
+Das Dashboard zeigt den letzten GLPI-Poll jetzt mit Anzahl der abgerufenen, bereits bekannten, neuen und eingereihten Ticketversionen. Unveränderte, bereits verarbeitete Tickets werden weiterhin nicht automatisch erneut analysiert. Für gezielte Tests steht in der Betriebsdiagnose eine manuelle Neuanalyse per Ticket-ID zur Verfügung; sie erzeugt einen separaten Lauf mit dem Trigger `manual_recheck`.
diff --git a/SECURITY.md b/SECURITY.md
index bdec0d4..d6564ef 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -1,5 +1,21 @@
# Security model
+## Prioritäts- und Eskalationsentscheidungen
+
+Priorität und Eskalation folgen demselben Grundsatz wie Kategorie und Antwort: Das LLM ist ausschließlich beratend und besitzt keinen GLPI-Toolzugriff. Es liefert strukturierte Empfehlungen mit einem kontrollierten Grundcode-Vokabular. Deterministische Go-Policies validieren Confidence, Reason-Code-Allowlist, zulässige Eskalationsstufe und Aktion sowie den aktuellen Ticketzustand.
+
+Zusätzliche Schutzmaßnahmen:
+
+- Prioritäten werden automatisch nur erhöht, niemals herabgesetzt.
+- Die Erhöhung pro Ticketlauf ist durch `PRIORITY_MAX_INCREASE` begrenzt.
+- Vor jedem Live-Write wird das Ticket erneut geladen; bei verändertem Source-Hash wird der Write verworfen.
+- Eskalationsprüfungen laufen zeitgesteuert, die Ausführung identischer Stufen wird jedoch über einen in `DATA_DIR/state-index.json` persistierten Idempotenzschlüssel dedupliziert. Die Datei ist abgeleiteter, aber sicherheitsrelevanter Betriebszustand und muss zusammen mit `runs.jsonl` geschützt und gesichert werden.
+- Menschliche Followups werden von Followups des dedizierten Agent-Benutzers unterschieden. Ein Konflikt mit menschlicher Aktivität blockiert insbesondere den Grund `no_human_response`.
+- `AUTO_PRIORITY` und `AUTO_ESCALATION` sind standardmäßig deaktiviert; `DRY_RUN=true` blockiert Live-Writes zusätzlich.
+- Aktuell ist als automatische Eskalationsaktion bewusst nur `raise_priority` implementiert. Weitere Modellvorschläge bleiben reine Diagnoseinformationen.
+
+Jeder Schritt besitzt einen separaten Auditdatensatz mit Prompt-Version, Input-Hash, strukturiertem Ergebnis, Policy-Prüfungen und Action-Audit. Die Input-Snapshots können Ticket- und Kontextinhalte enthalten; `DATA_DIR` ist daher wie Supportdaten mit personenbezogenen oder vertraulichen Informationen zu behandeln. Damit können Entscheidungen geprüft werden, ohne Analysearten miteinander zu vermischen.
+
## Trust boundaries
1. **Ticket content is untrusted.** It can contain prompt injection, HTML, links and attacker-controlled instructions.
@@ -40,8 +56,10 @@ Without a GLPI API primitive that atomically combines "no followup exists" and "
- Use strong Basic Auth credentials or put the dashboard behind your SSO reverse proxy.
- Keep `/metrics` and health endpoints on a trusted network.
- Keep `.env` outside source control and restrict filesystem permissions.
-- Start with `DRY_RUN=true`, then category-only, then explicitly approved auto-replies.
-- Review `data/runs.jsonl` and GLPI audit logs regularly.
+- Start with `DRY_RUN=true`; review priority and escalation in Shadow Mode before enabling either automatic write path.
+- Grant the dedicated GLPI account priority-write permission only when `AUTO_PRIORITY` or `AUTO_ESCALATION` is intentionally enabled.
+- Protect and back up both `data/runs.jsonl` and `data/state-index.json`; both can contain security-relevant audit or operational state.
+- Review GLPI audit logs regularly.
## Kommunikations- und Quellenpolicy
diff --git a/UPGRADE.md b/UPGRADE.md
index ef91bbe..5591067 100644
--- a/UPGRADE.md
+++ b/UPGRADE.md
@@ -1,4 +1,41 @@
-# Upgrade-Hinweise: Learning + Web-KB
+## 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`.
+
+# Upgrade-Hinweise
+
+## Upgrade: eigenständige Analyseläufe, Priorisierung und Eskalation
+
+Diese Version erweitert den Audit-Datensatz abwärtskompatibel um `trigger` und `analyses`. Alte Zeilen in `data/runs.jsonl` bleiben lesbar; neue Läufe enthalten zusätzlich eigenständige Analyseobjekte. Beim ersten Start wird keine manuelle Datenmigration benötigt. Aus vorhandenen Auditzeilen wird zusätzlich `DATA_DIR/state-index.json` aufgebaut; diese Datei hält die letzte verarbeitete Version je Ticket und ausgeführte Eskalationsschlüssel unabhängig von der Diagnose-Aufbewahrung fest. Sehr große Auditdateien werden nach einem erfolgreichen Append automatisch auf die konfiguriert vorgehaltenen Läufe kompaktiert. Vor dem Upgrade sollte trotzdem eine Sicherung von `DATA_DIR` erstellt werden.
+
+Empfohlener erster Start:
+
+```env
+DRY_RUN=true
+PRIORITY_ENABLED=true
+AUTO_PRIORITY=false
+ESCALATION_ENABLED=false
+AUTO_ESCALATION=false
+```
+
+Damit entstehen separate Prioritätsanalysen, aber keine neuen GLPI-Schreiboperationen. Nach der Auswertung im Diagnose-Cockpit kann die zeitgesteuerte Eskalation ebenfalls im Shadow Mode aktiviert werden:
+
+```env
+ESCALATION_ENABLED=true
+AUTO_ESCALATION=false
+ESCALATION_SCAN_INTERVAL=15m
+ESCALATION_MIN_AGE=4h
+GLPI_ESCALATION_FILTER=status.id==1
+```
+
+Vor `AUTO_PRIORITY=true` oder `AUTO_ESCALATION=true` ist zu prüfen, ob der GLPI-Servicebenutzer Ticketprioritäten ändern darf und ob der verwendete GLPI-API-Vertrag das Feld `priority` beim Ticket-PATCH akzeptiert. Live-Aktionen benötigen zusätzlich `DRY_RUN=false`. Automatische Herabstufungen sind nicht implementiert.
+
+Neue Variablen:
+
+- `PRIORITY_ENABLED`, `AUTO_PRIORITY`, `PRIORITY_CONFIDENCE`, `PRIORITY_MAX_INCREASE`, `PRIORITY_ALLOWED_REASON_CODES`
+- `ESCALATION_ENABLED`, `AUTO_ESCALATION`, `ESCALATION_SCAN_INTERVAL`, `ESCALATION_MIN_AGE`, `ESCALATION_CONFIDENCE`, `ESCALATION_MAX_LEVEL`
+- `ESCALATION_ALLOWED_REASON_CODES`, `ESCALATION_ALLOWED_ACTIONS`, `GLPI_ESCALATION_FILTER`, `GLPI_ESCALATION_LIMIT`
+
## Vordefinierte Uptime-Kuma-Statusantworten
@@ -295,3 +332,30 @@ Beim Speichern wird die Mapping-Datei atomar ersetzt. Anschließend werden die
lokalen KB-Dateien hinsichtlich Kategoriezuordnung und Policy neu bewertet.
Vorhandene Embeddings werden wiederverwendet, weil Kategorie-Mappings den an das
Embedding-Modell gesendeten Text nicht verändern.
+
+## Prioritäts-Hotfix: neutrale Enthaltungsgründe
+
+Nach diesem Update werden doppelte KI-Reason-Codes normalisiert. Eine unveränderte
+Prioritätsempfehlung mit `insufficient_information` erscheint nicht mehr als
+`priority_reason_not_allowed`, sondern als
+`priority_no_change_insufficient_information`. Es ist keine neue ENV-Variable
+erforderlich. Historische Läufe bleiben unverändert; die neue Semantik gilt für
+neu ausgeführte Prioritätsanalysen.
+
+## Upgrade auf Prioritäts-Prompt `priority-v3`
+
+Es sind keine neuen Pflichtvariablen erforderlich. `OLLAMA_JSON_RETRIES=1` wird empfohlen, damit eine inkonsistente erste Modellausgabe einmal mit dem konkreten Validierungsfehler erneut angefordert werden kann.
+
+Nach dem Neustart gelten nur neue Ticketläufe als `priority-v3`. Historische Auditdaten werden nicht verändert.
+
+## Emergency-Hotfix priority-v4
+
+`priority-v3` konnte bei semantisch widersprüchlichen Modellantworten einen Wiederholungsaufruf erzeugen. Mit nur einem parallelen Ollama-Aufruf konnte dies die gesamte Ticketpipeline bis zum allgemeinen Ollama-Timeout verzögern.
+
+Neu:
+
+```env
+PRIORITY_ANALYSIS_TIMEOUT=45s
+```
+
+Der Prioritätslauf ist nun strikt fail-open. Semantische Widersprüche werden deterministisch normalisiert und führen nicht mehr zu einem weiteren Modellaufruf. Beim Austausch des Releases `data/`, `knowledge/` und lokale Umgebungsdateien beibehalten.
diff --git a/data/.gitkeep b/data/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/dist/README.md b/dist/README.md
new file mode 100644
index 0000000..8140128
--- /dev/null
+++ b/dist/README.md
@@ -0,0 +1,7 @@
+# Vorgebaute Programme
+
+- `glpi-ai-agent-linux-amd64`: Linux amd64, statisch gebaut (`CGO_ENABLED=0`)
+- `glpi-ai-agent-windows-amd64.exe`: Windows amd64, statisch gebaut (`CGO_ENABLED=0`)
+- `SHA256SUMS.txt`: SHA-256-Prüfsummen der beiden Programme
+
+Die Programme wurden aus dem gemeinsam ausgelieferten Quellstand mit `make dist` erstellt. Für andere Architekturen kann das Go-Projekt direkt neu gebaut werden.
diff --git a/dist/SHA256SUMS.txt b/dist/SHA256SUMS.txt
new file mode 100644
index 0000000..52b20e2
--- /dev/null
+++ b/dist/SHA256SUMS.txt
@@ -0,0 +1,2 @@
+bedf14c695941798f7e441110ca38dd76749615d237fffe1b9781adb77ba46a3 glpi-ai-agent-linux-amd64
+598bf715949d0419f077ec4b5f62ff959630eaae2e1a2a23f8405d28fe192c42 glpi-ai-agent-windows-amd64.exe
diff --git a/dist/glpi-ai-agent-linux-amd64 b/dist/glpi-ai-agent-linux-amd64
new file mode 100644
index 0000000..74ce106
Binary files /dev/null and b/dist/glpi-ai-agent-linux-amd64 differ
diff --git a/dist/glpi-ai-agent-windows-amd64.exe b/dist/glpi-ai-agent-windows-amd64.exe
new file mode 100644
index 0000000..033eb7e
Binary files /dev/null and b/dist/glpi-ai-agent-windows-amd64.exe differ
diff --git a/docker-compose.registry.yml b/docker-compose.registry.yml
index e1d1abc..5e0d2a0 100644
--- a/docker-compose.registry.yml
+++ b/docker-compose.registry.yml
@@ -14,6 +14,7 @@ services:
OLLAMA_KEEP_ALIVE: ${OLLAMA_KEEP_ALIVE:-10m}
OLLAMA_THINK: ${OLLAMA_THINK:-false}
OLLAMA_MAX_CONCURRENT: ${OLLAMA_MAX_CONCURRENT:-1}
+ PRIORITY_ANALYSIS_TIMEOUT: ${PRIORITY_ANALYSIS_TIMEOUT:-45s}
ports:
- "127.0.0.1:8080:8080"
volumes:
diff --git a/docker-compose.yml b/docker-compose.yml
index 6223562..290b73a 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -31,6 +31,7 @@ services:
OLLAMA_KEEP_ALIVE: ${OLLAMA_KEEP_ALIVE:-10m}
OLLAMA_THINK: ${OLLAMA_THINK:-false}
OLLAMA_MAX_CONCURRENT: ${OLLAMA_MAX_CONCURRENT:-1}
+ PRIORITY_ANALYSIS_TIMEOUT: ${PRIORITY_ANALYSIS_TIMEOUT:-45s}
ports:
- "127.0.0.1:8080:8080"
volumes:
diff --git a/internal/agent/agent.go b/internal/agent/agent.go
index c11ea9e..211658c 100644
--- a/internal/agent/agent.go
+++ b/internal/agent/agent.go
@@ -17,6 +17,7 @@ import (
"github.com/example/glpi-ai-agent/internal/learning"
"github.com/example/glpi-ai-agent/internal/metrics"
"github.com/example/glpi-ai-agent/internal/model"
+ "github.com/example/glpi-ai-agent/internal/prioritysignals"
"github.com/example/glpi-ai-agent/internal/queue"
"github.com/example/glpi-ai-agent/internal/state"
)
@@ -41,20 +42,21 @@ type ContextCollector interface {
Collect(context.Context, model.Ticket) model.ContextSnapshot
}
type Service struct {
- cfg config.Config
- glpi GLPI
- ai AI
- knowledge *knowledge.Store
- learning *learning.Store
- state *state.Store
- q *queue.Queue
- metrics *metrics.Metrics
- policy Policy
- context ContextCollector
- locks sync.Map
- catMu sync.RWMutex
- categories []model.Category
- catAt time.Time
+ cfg config.Config
+ glpi GLPI
+ ai AI
+ knowledge *knowledge.Store
+ learning *learning.Store
+ state *state.Store
+ q *queue.Queue
+ metrics *metrics.Metrics
+ policy Policy
+ context ContextCollector
+ locks sync.Map
+ catMu sync.RWMutex
+ pollLogOnce sync.Once
+ categories []model.Category
+ catAt time.Time
}
func New(cfg config.Config, g GLPI, ai AI, k *knowledge.Store, l *learning.Store, s *state.Store, q *queue.Queue, m *metrics.Metrics, contextCollector ContextCollector) *Service {
@@ -64,6 +66,9 @@ func (s *Service) Queue() *queue.Queue { return s.q }
func (s *Service) Start(ctx context.Context) {
go s.healthLoop(ctx)
go s.pollLoop(ctx)
+ if s.cfg.EscalationEnabled {
+ go s.escalationLoop(ctx)
+ }
for i := 0; i < s.cfg.Workers; i++ {
go s.worker(ctx, i)
}
@@ -82,22 +87,36 @@ func (s *Service) pollLoop(ctx context.Context) {
}
}
func (s *Service) poll(ctx context.Context) {
+ pollAt := time.Now()
tickets, err := s.glpi.ListRecentTickets(ctx, s.cfg.GLPIPollLimit, s.cfg.GLPITicketFilter)
s.metrics.Polls.Add(1)
- s.metrics.SetLastPoll(time.Now())
if err != nil {
+ s.metrics.SetPollStatus(metrics.PollStatus{At: pollAt, Error: err.Error()})
s.metrics.Errors.Add(1)
slog.Error("GLPI poll failed", "error", err)
return
}
+ seen, unseen, enqueued, rejected := 0, 0, 0, 0
for _, t := range tickets {
version := sourceVersion(t)
- if !s.state.Seen(t.ID, version) {
- if s.q.Enqueue(t.ID) {
- s.metrics.QueueDepth.Store(int64(s.q.Len()))
- }
+ if s.state.Seen(t.ID, version) {
+ seen++
+ continue
+ }
+ unseen++
+ if s.q.EnqueueWork(queue.WorkItem{TicketID: t.ID, Trigger: "poll", Priority: queue.PriorityPoll}) {
+ enqueued++
+ s.metrics.QueueDepth.Store(int64(s.q.Len()))
+ } else {
+ rejected++
}
}
+ status := metrics.PollStatus{At: pollAt, Fetched: len(tickets), Seen: seen, Unseen: unseen, Enqueued: enqueued, Rejected: rejected}
+ s.metrics.SetPollStatus(status)
+ s.pollLogOnce.Do(func() {
+ slog.Info("initial GLPI ticket poll completed", "fetched", status.Fetched, "already_processed", status.Seen, "unseen", status.Unseen, "enqueued", status.Enqueued, "rejected", status.Rejected, "filter_configured", strings.TrimSpace(s.cfg.GLPITicketFilter) != "")
+ })
+ slog.Debug("GLPI ticket poll completed", "fetched", status.Fetched, "already_processed", status.Seen, "unseen", status.Unseen, "enqueued", status.Enqueued, "rejected", status.Rejected)
}
func (s *Service) healthLoop(ctx context.Context) {
check := func() {
@@ -121,25 +140,42 @@ func (s *Service) healthLoop(ctx context.Context) {
}
func (s *Service) worker(ctx context.Context, n int) {
for {
- id, ok := s.q.Next(ctx)
+ item, ok := s.q.NextWork(ctx)
if !ok {
return
}
s.metrics.QueueDepth.Store(int64(s.q.Len()))
- if err := s.Process(ctx, id); err != nil {
- slog.Error("ticket processing failed", "worker", n, "ticket_id", id, "error", err)
+ if err := s.ProcessWork(ctx, item); err != nil {
+ slog.Error("ticket processing failed", "worker", n, "ticket_id", item.TicketID, "trigger", item.Trigger, "error", err)
}
- s.q.Done(id)
+ s.q.DoneWork(item)
s.metrics.QueueDepth.Store(int64(s.q.Len()))
}
}
+
+// Process remains the compatibility entry point used by tests and manual callers.
func (s *Service) Process(ctx context.Context, id int64) error {
+ return s.ProcessWork(ctx, queue.WorkItem{TicketID: id, Trigger: "manual", Priority: queue.PriorityManual})
+}
+
+func (s *Service) ProcessWork(ctx context.Context, item queue.WorkItem) error {
+ if strings.EqualFold(strings.TrimSpace(item.Trigger), "scheduled_escalation") {
+ return s.processEscalation(ctx, item)
+ }
+ id := item.TicketID
muAny, _ := s.locks.LoadOrStore(id, &sync.Mutex{})
mu := muAny.(*sync.Mutex)
mu.Lock()
- defer mu.Unlock()
+ defer func() {
+ mu.Unlock()
+ s.locks.Delete(id)
+ }()
start := time.Now()
- run := model.RunRecord{RunID: newRunID(), TicketID: id, StartedAt: start, DryRun: s.cfg.DryRun, Outcome: "error"}
+ trigger := strings.TrimSpace(item.Trigger)
+ if trigger == "" {
+ trigger = "poll"
+ }
+ run := model.RunRecord{RunID: newRunID(), TicketID: id, Trigger: trigger, StartedAt: start, DryRun: s.cfg.DryRun, Outcome: "error"}
finish := func(err error) {
run.FinishedAt = time.Now()
if err != nil {
@@ -161,8 +197,13 @@ func (s *Service) Process(ctx context.Context, id int64) error {
run.CategoryBefore = t.CategoryID
run.ExecutionChecks = append(run.ExecutionChecks, model.RuleCheck{Code: "execution_ticket_loaded", Group: "eligibility", Label: "Ticket konnte geladen werden", Status: "pass", Actual: "ja", Expected: "ja"})
alreadySeen := s.state.Seen(t.ID, run.SourceVersion)
- run.ExecutionChecks = append(run.ExecutionChecks, model.RuleCheck{Code: "execution_not_already_processed", Group: "eligibility", Label: "Diese Ticketversion wurde noch nicht verarbeitet", Status: passFail(!alreadySeen), Blocking: alreadySeen, Actual: boolText(!alreadySeen), Expected: "ja"})
- if alreadySeen {
+ eligibleVersion := !alreadySeen || item.Force
+ versionDetail := ""
+ if item.Force && alreadySeen {
+ versionDetail = "Manuelle Neuanalyse erzwingt einen einmaligen Lauf für die bereits bekannte Ticketversion."
+ }
+ run.ExecutionChecks = append(run.ExecutionChecks, model.RuleCheck{Code: "execution_not_already_processed", Group: "eligibility", Label: "Diese Ticketversion wurde noch nicht verarbeitet", Status: passFail(eligibleVersion), Blocking: !eligibleVersion, Actual: boolText(!alreadySeen), Expected: "ja oder manuell erzwungen", Detail: versionDetail})
+ if alreadySeen && !item.Force {
run.Outcome = "skipped"
run.Reason = "already_processed"
s.metrics.Skipped.Add(1)
@@ -239,22 +280,90 @@ func (s *Service) Process(ctx context.Context, id int64) error {
// Stage 1: classify the ticket using only category knowledge. The result is
// persisted separately and becomes the deterministic basis for reply retrieval.
categoryStarted := time.Now()
+ categoryAnalysis := newAnalysis(run, "category", categoryPromptVersion, map[string]any{"ticket": t, "categories": promptCats, "knowledge_candidates": categoryLLMHits, "context": contextData}, categoryStarted)
run.CategoryAnalysisExecuted = true
categoryDecision, err := s.ai.AnalyseCategory(ctx, t, promptCats, categoryLLMHits, contextData)
run.CategoryAnalysisDurationMS = time.Since(categoryStarted).Milliseconds()
if err != nil {
run.ExecutionChecks = append(run.ExecutionChecks, model.RuleCheck{Code: "execution_category_ai", Group: "execution", Label: "Kategorieanalyse konnte ausgeführt werden", Status: "fail", Blocking: true, Actual: err.Error(), Expected: "erfolgreich"})
+ finishAnalysis(&categoryAnalysis, s.cfg.OllamaModel, nil, nil, "", 0, nil, model.ActionAudit{Type: "set_category", Result: "skipped: category_ai_failed"}, err)
+ run.Analyses = append(run.Analyses, categoryAnalysis)
run.Reason = "category_ai_failed"
finish(err)
return err
}
run.ExecutionChecks = append(run.ExecutionChecks, model.RuleCheck{Code: "execution_category_ai", Group: "execution", Label: "Kategorieanalyse konnte ausgeführt werden", Status: "pass", Actual: "erfolgreich", Expected: "erfolgreich"})
run.CategoryAIReason = strings.TrimSpace(categoryDecision.Reason)
+ finishAnalysis(&categoryAnalysis, s.cfg.OllamaModel, categoryDecision.Category, nil, categoryDecision.Reason, categoryDecision.Category.Confidence, nil, model.ActionAudit{Type: "set_category"}, nil)
+ run.Analyses = append(run.Analyses, categoryAnalysis)
+ categoryAnalysisIndex := len(run.Analyses) - 1
replyBasis := effectiveReplyCategory(t, categoryDecision, categories, s.cfg.AutoCategory, s.cfg.CategoryConfidence)
run.ReplyBasisCategoryID = replyBasis.ID
run.ReplyBasisCategoryName = categoryDisplayName(replyBasis)
+ // Independent priority stage. The model only recommends a GLPI priority and
+ // controlled reason codes; deterministic Go policy decides whether a change
+ // would be permitted. In the default Shadow Mode no write occurs.
+ var priorityResult model.PriorityResult
+ priorityAnalysisIndex := -1
+ if s.cfg.PriorityEnabled {
+ priorityStarted := time.Now()
+ priorityEvidence := prioritysignals.Extract(t)
+ priorityTimeout := s.cfg.PriorityAnalysisTimeout
+ if priorityTimeout <= 0 {
+ priorityTimeout = 45 * time.Second
+ }
+ if s.cfg.OllamaTimeout > 0 && priorityTimeout > s.cfg.OllamaTimeout {
+ priorityTimeout = s.cfg.OllamaTimeout
+ }
+ priorityAnalysis := newAnalysis(run, "priority", priorityPromptVersion, map[string]any{"ticket": t, "effective_category": replyBasis, "context": contextData, "deterministic_evidence": priorityEvidence, "allowed_reason_codes": s.cfg.PriorityAllowedReasonCodes, "neutral_reason_codes": []string{"single_user_affected", "workaround_available", "insufficient_information"}, "threshold": s.cfg.PriorityConfidence, "max_increase": s.cfg.PriorityMaxIncrease, "analysis_timeout": priorityTimeout.String()}, priorityStarted)
+ run.PriorityAnalysisExecuted = true
+ run.PriorityBefore = t.Priority
+ run.PriorityThreshold = s.cfg.PriorityConfidence
+ if priorityClient, ok := s.ai.(priorityAI); ok {
+ priorityCtx, cancelPriority := context.WithTimeout(ctx, priorityTimeout)
+ priorityDecision, priorityErr := priorityClient.AnalysePriority(priorityCtx, t, replyBasis, contextData)
+ cancelPriority()
+ run.PriorityAnalysisDurationMS = time.Since(priorityStarted).Milliseconds()
+ if priorityErr != nil {
+ run.PriorityDecision = "priority_ai_failed"
+ finishAnalysis(&priorityAnalysis, s.cfg.OllamaModel, nil, nil, "", 0, nil, model.ActionAudit{Type: "set_priority", Result: "skipped: priority_ai_failed"}, priorityErr)
+ run.ExecutionChecks = append(run.ExecutionChecks, model.RuleCheck{Code: "execution_priority_ai", Group: "execution", Label: "Prioritätsanalyse konnte ausgeführt werden", Status: "warn", Actual: priorityErr.Error(), Expected: "erfolgreich", Detail: "Kategorie- und Antwortverarbeitung laufen weiter; es wird keine Priorität geändert."})
+ s.metrics.Errors.Add(1)
+ } else {
+ priorityResult = evaluatePriority(s.cfg, t, priorityDecision)
+ run.PriorityAIReason = strings.TrimSpace(priorityDecision.Reason)
+ run.AIRecommendedPriority = priorityDecision.RecommendedPriority
+ run.AIRecommendedImpact = priorityDecision.RecommendedImpact
+ run.AIRecommendedUrgency = priorityDecision.RecommendedUrgency
+ run.PriorityAffectedScope = strings.TrimSpace(priorityDecision.AffectedScope)
+ run.PriorityTimeCriticality = strings.TrimSpace(priorityDecision.TimeCriticality)
+ run.AIPriorityConfidence = priorityDecision.Confidence
+ run.PriorityReasonCodes = append([]string(nil), priorityResult.ReasonCodes...)
+ run.PriorityChecks = append([]model.RuleCheck(nil), priorityResult.Checks...)
+ run.PriorityDecision = priorityResult.Decision
+ run.PriorityProposed = priorityResult.PriorityAfter
+ run.PriorityWouldChange = priorityResult.ChangePriority
+ action := model.ActionAudit{Type: "set_priority", Proposed: priorityResult.ChangePriority, DryRun: s.cfg.DryRun || !s.cfg.AutoPriority, Before: fmt.Sprintf("priority=%d", t.Priority), After: fmt.Sprintf("priority=%d", priorityResult.PriorityAfter), Result: priorityResult.Decision}
+ finishAnalysis(&priorityAnalysis, s.cfg.OllamaModel, priorityResult, priorityResult.ReasonCodes, priorityDecision.Reason, priorityDecision.Confidence, priorityResult.Checks, action, nil)
+ run.ExecutionChecks = append(run.ExecutionChecks, model.RuleCheck{Code: "execution_priority_ai", Group: "execution", Label: "Prioritätsanalyse konnte ausgeführt werden", Status: "pass", Actual: "erfolgreich", Expected: "erfolgreich"})
+ if priorityDecision.RecommendedPriority > 0 {
+ s.metrics.PriorityRecommendations.Add(1)
+ }
+ }
+ } else {
+ priorityErr := fmt.Errorf("AI client does not implement priority analysis")
+ run.PriorityDecision = "priority_ai_unavailable"
+ finishAnalysis(&priorityAnalysis, s.cfg.OllamaModel, nil, nil, "", 0, nil, model.ActionAudit{Type: "set_priority", Result: "skipped: priority_ai_unavailable"}, priorityErr)
+ run.ExecutionChecks = append(run.ExecutionChecks, model.RuleCheck{Code: "execution_priority_ai", Group: "execution", Label: "Prioritätsanalyse konnte ausgeführt werden", Status: "warn", Actual: priorityErr.Error(), Expected: "erfolgreich"})
+ }
+ run.Analyses = append(run.Analyses, priorityAnalysis)
+ priorityAnalysisIndex = len(run.Analyses) - 1
+ } else {
+ run.PriorityDecision = "priority_disabled"
+ }
+
// Stage 2: the model may only decide whether one active Uptime Kuma entry
// clearly explains the ticket. It never receives or returns an end-user text.
// A deterministic Go policy combines this confidence with the existing
@@ -265,6 +374,8 @@ func (s *Service) Process(ctx context.Context, id int64) error {
run.StatusReplyMinFinalScore = s.cfg.ContextStatusReplyMinFinalScore
var statusDecision model.StatusDecision
var statusEval statusReplyEvaluation
+ statusAnalysisStarted := time.Now()
+ var statusAnalysisErr error
switch {
case !s.cfg.ContextStatusReplyEnabled:
run.StatusAnalysisSkipReason = "status_reply_disabled"
@@ -277,11 +388,11 @@ func (s *Service) Process(ctx context.Context, id int64) error {
case len(statusCandidates) == 0:
run.StatusAnalysisSkipReason = "no_status_candidates"
default:
- statusStarted := time.Now()
run.StatusAnalysisExecuted = true
statusDecision, err = s.ai.AnalyseStatus(ctx, t, replyBasis, statusCandidates)
- run.StatusAnalysisDurationMS = time.Since(statusStarted).Milliseconds()
+ run.StatusAnalysisDurationMS = time.Since(statusAnalysisStarted).Milliseconds()
if err != nil {
+ statusAnalysisErr = err
run.StatusAnalysisSkipReason = "status_ai_failed"
run.StatusAIReason = "Statuszuordnung fehlgeschlagen: " + err.Error()
run.ExecutionChecks = append(run.ExecutionChecks, model.RuleCheck{Code: "execution_status_ai", Group: "execution", Label: "Status- und Störungszuordnung konnte ausgeführt werden", Status: "warn", Actual: err.Error(), Expected: "erfolgreich", Detail: "Es wird kein Status-Template verwendet; der normale Reply-Pfad bleibt fail-closed."})
@@ -310,6 +421,14 @@ func (s *Service) Process(ctx context.Context, id int64) error {
run.StatusReplyCandidateStatus = statusEval.Candidate.Issue.Status
run.StatusReplyRelevance = statusEval.Candidate.Issue.Relevance
}
+ statusAnalysis := newAnalysis(run, "status_match", statusPromptVersion, map[string]any{"ticket": t, "effective_category": replyBasis, "candidates": statusCandidates, "thresholds": map[string]float64{"relevance": s.cfg.ContextStatusReplyMinRelevance, "ai_confidence": s.cfg.ContextStatusReplyMinAIConfidence, "final_score": s.cfg.ContextStatusReplyMinFinalScore}}, statusAnalysisStarted)
+ statusActionResult := statusEval.DecisionCode
+ if !run.StatusAnalysisExecuted {
+ statusActionResult = "skipped: " + run.StatusAnalysisSkipReason
+ }
+ finishAnalysis(&statusAnalysis, s.cfg.OllamaModel, statusEval, nil, statusDecision.Reason, statusDecision.Confidence, statusEval.Checks, model.ActionAudit{Type: "add_status_followup", Proposed: statusEval.Accepted, DryRun: s.cfg.DryRun, Result: statusActionResult}, statusAnalysisErr)
+ run.Analyses = append(run.Analyses, statusAnalysis)
+ statusAnalysisIndex := len(run.Analyses) - 1
// Stage 3 starts only after the category and optional status result are known. Reply knowledge is
// reranked and selected against the effective category, so unrelated articles
@@ -336,6 +455,8 @@ func (s *Service) Process(ctx context.Context, id int64) error {
run.KnowledgeCandidates = append([]model.KnowledgeCandidateAudit(nil), run.ReplyKnowledgeCandidates...)
var replyDecision model.Decision
+ replyAnalysisStarted := time.Now()
+ var replyAnalysisErr error
switch run.ReplyAnalysisSkipReason {
case "status_reply_selected":
replyDecision.Reason = "Normale Antwortanalyse nicht ausgeführt: Ein vordefiniertes Status-Template wurde freigegeben."
@@ -350,11 +471,11 @@ func (s *Service) Process(ctx context.Context, id int64) error {
replyDecision.Reason = "Antwortanalyse nicht ausgeführt: Keine Antwort-KB erreichte die Kandidatenauswahl."
run.ExecutionChecks = append(run.ExecutionChecks, model.RuleCheck{Code: "execution_reply_ai", Group: "execution", Label: "Antwortanalyse wurde benötigt", Status: "info", Actual: "übersprungen: keine Kandidaten", Expected: "mindestens ein Antwortkandidat"})
default:
- replyStarted := time.Now()
run.ReplyAnalysisExecuted = true
replyDecision, err = s.ai.AnalyseReply(ctx, t, replyBasis, replyLLMHits, contextData)
- run.ReplyAnalysisDurationMS = time.Since(replyStarted).Milliseconds()
+ run.ReplyAnalysisDurationMS = time.Since(replyAnalysisStarted).Milliseconds()
if err != nil {
+ replyAnalysisErr = err
// A failed second stage must not discard a valid category result. The
// reply is disabled and the category continues through the Go policy.
run.ReplyAnalysisSkipReason = "reply_ai_failed"
@@ -368,6 +489,14 @@ func (s *Service) Process(ctx context.Context, id int64) error {
}
}
run.ReplyAIReason = strings.TrimSpace(replyDecision.Reason)
+ replyAnalysis := newAnalysis(run, "reply_selection", replyPromptVersion, map[string]any{"ticket": t, "effective_category": replyBasis, "knowledge_candidates": replyLLMHits, "context": contextData}, replyAnalysisStarted)
+ replyActionResult := "reply_analysis_completed"
+ if run.ReplyAnalysisSkipReason != "" {
+ replyActionResult = "skipped: " + run.ReplyAnalysisSkipReason
+ }
+ finishAnalysis(&replyAnalysis, s.cfg.OllamaModel, replyDecision.Reply, nil, replyDecision.Reason, replyDecision.Reply.Confidence, nil, model.ActionAudit{Type: "add_followup", DryRun: s.cfg.DryRun, Result: replyActionResult}, replyAnalysisErr)
+ run.Analyses = append(run.Analyses, replyAnalysis)
+ replyAnalysisIndex := len(run.Analyses) - 1
decision := model.Decision{}
decision.Category = categoryDecision.Category
@@ -393,6 +522,9 @@ func (s *Service) Process(ctx context.Context, id int64) error {
}
}
result, err := s.policy.Evaluate(t, decision, categories, hits, contextData)
+ if err == nil {
+ result.CategoryChecks = append(result.CategoryChecks, categoryKnowledgeMappingChecks(categoryLLMHits, categories, result.CategoryRecommendationID)...)
+ }
if err != nil {
run.Reason = "policy_rejected"
finish(err)
@@ -438,19 +570,43 @@ func (s *Service) Process(ctx context.Context, id int64) error {
run.KnowledgeCategoryAligned = result.KnowledgeCategoryAligned
run.CategoryChecks = append([]model.RuleCheck(nil), result.CategoryChecks...)
run.ReplyChecks = append([]model.RuleCheck(nil), result.ReplyChecks...)
+ if categoryAnalysisIndex >= 0 && categoryAnalysisIndex < len(run.Analyses) {
+ a := &run.Analyses[categoryAnalysisIndex]
+ a.Checks = append([]model.RuleCheck(nil), result.CategoryChecks...)
+ a.Action = model.ActionAudit{Type: "set_category", Proposed: result.ChangeCategory, DryRun: s.cfg.DryRun, Before: fmt.Sprintf("category=%d", t.CategoryID), After: fmt.Sprintf("category=%d", result.CategoryID), Result: result.CategoryDecision}
+ }
+ if replyAnalysisIndex >= 0 && replyAnalysisIndex < len(run.Analyses) {
+ a := &run.Analyses[replyAnalysisIndex]
+ a.Checks = append([]model.RuleCheck(nil), result.ReplyChecks...)
+ a.Action.Proposed = result.Reply && canReply && !statusEval.Accepted
+ a.Action.Result = result.ReplyDecision
+ }
+ if statusAnalysisIndex >= 0 && statusAnalysisIndex < len(run.Analyses) {
+ run.Analyses[statusAnalysisIndex].Action.Proposed = statusEval.Accepted && canReply
+ run.Analyses[statusAnalysisIndex].Action.Result = statusEval.DecisionCode
+ }
run.ExecutionChecks = append(run.ExecutionChecks, model.RuleCheck{Code: "execution_dry_run", Group: "execution", Label: "Live-Schreibmodus aktiv", Status: map[bool]string{true: "info", false: "pass"}[s.cfg.DryRun], Blocking: false, Actual: map[bool]string{true: "DRY RUN", false: "LIVE"}[s.cfg.DryRun], Expected: "LIVE für tatsächliche Änderungen", Detail: "Im DRY RUN werden freigegebene Aktionen nur simuliert."})
- run.PolicyReason = result.CategoryDecision + "; " + result.ReplyDecision
+ run.PolicyReason = policySummary(result.CategoryDecision, run.PriorityDecision, result.ReplyDecision)
if !canReply {
+ if replyAnalysisIndex >= 0 {
+ run.Analyses[replyAnalysisIndex].Action.Proposed = false
+ run.Analyses[replyAnalysisIndex].Action.Result = "reply_existing_followup"
+ }
+ if statusAnalysisIndex >= 0 {
+ run.Analyses[statusAnalysisIndex].Action.Proposed = false
+ run.Analyses[statusAnalysisIndex].Action.Result = "reply_existing_followup"
+ }
// An existing followup is the authoritative execution-level reason why
// no reply can be proposed, regardless of the model/policy recommendation.
run.ReplyProposed = false
run.ReplyDecision = "reply_existing_followup"
- run.PolicyReason = result.CategoryDecision + "; " + run.ReplyDecision
+ run.PolicyReason = policySummary(result.CategoryDecision, run.PriorityDecision, run.ReplyDecision)
}
// Re-read the ticket immediately before any write. This prevents a stale
// model decision from overwriting a human change made during inference.
- if (result.ChangeCategory || (result.Reply && canReply)) && !s.cfg.DryRun {
+ priorityWritePlanned := priorityResult.ChangePriority && s.cfg.AutoPriority
+ if (result.ChangeCategory || priorityWritePlanned || (result.Reply && canReply)) && !s.cfg.DryRun {
fresh, err := s.glpi.GetTicket(ctx, id)
if err != nil {
run.Reason = "prewrite_ticket_recheck_failed"
@@ -467,7 +623,7 @@ func (s *Service) Process(ctx context.Context, id int64) error {
if result.Reply && canReply {
run.ReplyDecision = "reply_ticket_changed_before_write"
}
- run.PolicyReason = run.CategoryDecision + "; " + run.ReplyDecision
+ run.PolicyReason = policySummary(run.CategoryDecision, run.PriorityDecision, run.ReplyDecision)
s.metrics.Skipped.Add(1)
finish(nil)
return nil
@@ -480,7 +636,7 @@ func (s *Service) Process(ctx context.Context, id int64) error {
run.ExecutionChecks = append(run.ExecutionChecks, model.RuleCheck{Code: "execution_category_write", Group: "execution", Label: "Kategorie konnte in GLPI geschrieben werden", Status: "fail", Blocking: true, Actual: err.Error(), Expected: "erfolgreich"})
run.Reason = "category_write_failed"
run.CategoryDecision = "category_write_failed"
- run.PolicyReason = run.CategoryDecision + "; " + run.ReplyDecision
+ run.PolicyReason = policySummary(run.CategoryDecision, run.PriorityDecision, run.ReplyDecision)
finish(err)
return err
}
@@ -491,7 +647,71 @@ func (s *Service) Process(ctx context.Context, id int64) error {
} else if result.ChangeCategory {
run.CategoryDecision = "category_accepted_dry_run"
}
- run.PolicyReason = run.CategoryDecision + "; " + run.ReplyDecision
+ if categoryAnalysisIndex >= 0 {
+ run.Analyses[categoryAnalysisIndex].Action.Executed = run.CategoryChanged
+ run.Analyses[categoryAnalysisIndex].Action.Result = run.CategoryDecision
+ }
+ run.PolicyReason = policySummary(run.CategoryDecision, run.PriorityDecision, run.ReplyDecision)
+
+ if priorityResult.ChangePriority {
+ if !s.cfg.AutoPriority {
+ run.PriorityDecision = "priority_accepted_shadow"
+ } else if s.cfg.DryRun {
+ run.PriorityDecision = "priority_accepted_dry_run"
+ } else {
+ fresh, loadErr := s.glpi.GetTicket(ctx, id)
+ if loadErr != nil {
+ run.PriorityDecision = "priority_prewrite_recheck_failed"
+ if priorityAnalysisIndex >= 0 {
+ run.Analyses[priorityAnalysisIndex].Action.Error = loadErr.Error()
+ run.Analyses[priorityAnalysisIndex].Action.Result = run.PriorityDecision
+ }
+ finish(loadErr)
+ return loadErr
+ }
+ expectedCategory := t.CategoryID
+ if run.CategoryChanged {
+ expectedCategory = result.CategoryID
+ }
+ if !sameDecisionSource(t, fresh, expectedCategory) || fresh.Priority != t.Priority {
+ run.PriorityDecision = "priority_ticket_changed_before_write"
+ run.PriorityWouldChange = false
+ if priorityAnalysisIndex >= 0 {
+ run.Analyses[priorityAnalysisIndex].Action.Proposed = false
+ run.Analyses[priorityAnalysisIndex].Action.Result = run.PriorityDecision
+ }
+ } else if writer, ok := s.glpi.(priorityWriter); !ok {
+ writeErr := fmt.Errorf("GLPI connector does not implement priority writes")
+ run.PriorityDecision = "priority_write_unavailable"
+ if priorityAnalysisIndex >= 0 {
+ run.Analyses[priorityAnalysisIndex].Action.Error = writeErr.Error()
+ run.Analyses[priorityAnalysisIndex].Action.Result = run.PriorityDecision
+ }
+ finish(writeErr)
+ return writeErr
+ } else if writeErr := writer.SetPriority(ctx, id, priorityResult.PriorityAfter); writeErr != nil {
+ run.PriorityDecision = "priority_write_failed"
+ if priorityAnalysisIndex >= 0 {
+ run.Analyses[priorityAnalysisIndex].Action.Error = writeErr.Error()
+ run.Analyses[priorityAnalysisIndex].Action.Result = run.PriorityDecision
+ }
+ finish(writeErr)
+ return writeErr
+ } else {
+ run.PriorityChanged = true
+ run.PriorityDecision = "priority_written"
+ s.metrics.PriorityChanges.Add(1)
+ if priorityAnalysisIndex >= 0 {
+ run.Analyses[priorityAnalysisIndex].Action.Executed = true
+ run.Analyses[priorityAnalysisIndex].Action.DryRun = false
+ run.Analyses[priorityAnalysisIndex].Action.Result = "priority_written"
+ }
+ }
+ }
+ if priorityAnalysisIndex >= 0 {
+ run.Analyses[priorityAnalysisIndex].Action.Result = run.PriorityDecision
+ }
+ }
if result.Reply && canReply {
// If category was just changed by this process, date_mod will legitimately
@@ -512,7 +732,7 @@ func (s *Service) Process(ctx context.Context, id int64) error {
run.ReplyProposed = false
run.Reason = "ticket_changed_before_reply"
run.ReplyDecision = "reply_ticket_changed_before_write"
- run.PolicyReason = run.CategoryDecision + "; " + run.ReplyDecision
+ run.PolicyReason = policySummary(run.CategoryDecision, run.PriorityDecision, run.ReplyDecision)
run.Outcome = "skipped"
s.metrics.Skipped.Add(1)
finish(nil)
@@ -530,34 +750,55 @@ func (s *Service) Process(ctx context.Context, id int64) error {
run.ReplyProposed = false
run.Reason = "followup_appeared_before_write"
run.ReplyDecision = "reply_followup_appeared_before_write"
+ if statusEval.Accepted && statusAnalysisIndex >= 0 {
+ run.Analyses[statusAnalysisIndex].Action.Proposed = false
+ run.Analyses[statusAnalysisIndex].Action.Result = run.ReplyDecision
+ } else if replyAnalysisIndex >= 0 {
+ run.Analyses[replyAnalysisIndex].Action.Proposed = false
+ run.Analyses[replyAnalysisIndex].Action.Result = run.ReplyDecision
+ }
} else if !s.cfg.DryRun {
run.ExecutionChecks = append(run.ExecutionChecks, model.RuleCheck{Code: "execution_followup_recheck", Group: "execution", Label: "Unmittelbar vor Antwort ist weiterhin kein Followup vorhanden", Status: "pass", Actual: "0 Followups", Expected: "0 Followups"})
if err := s.glpi.AddFollowup(ctx, id, result.ReplyText, result.ReplyIsHTML); err != nil {
run.ExecutionChecks = append(run.ExecutionChecks, model.RuleCheck{Code: "execution_reply_write", Group: "execution", Label: "Antwort konnte in GLPI geschrieben werden", Status: "fail", Blocking: true, Actual: err.Error(), Expected: "erfolgreich"})
run.Reason = "reply_write_failed"
run.ReplyDecision = "reply_write_failed"
- run.PolicyReason = run.CategoryDecision + "; " + run.ReplyDecision
+ run.PolicyReason = policySummary(run.CategoryDecision, run.PriorityDecision, run.ReplyDecision)
finish(err)
return err
}
run.ExecutionChecks = append(run.ExecutionChecks, model.RuleCheck{Code: "execution_reply_write", Group: "execution", Label: "Antwort konnte in GLPI geschrieben werden", Status: "pass", Actual: "erfolgreich", Expected: "erfolgreich"})
run.ReplyWritten = true
run.ReplyDecision = "reply_written"
+ if statusEval.Accepted && statusAnalysisIndex >= 0 {
+ run.Analyses[statusAnalysisIndex].Action.Executed = true
+ run.Analyses[statusAnalysisIndex].Action.DryRun = false
+ run.Analyses[statusAnalysisIndex].Action.Result = run.ReplyDecision
+ } else if replyAnalysisIndex >= 0 {
+ run.Analyses[replyAnalysisIndex].Action.Executed = true
+ run.Analyses[replyAnalysisIndex].Action.DryRun = false
+ run.Analyses[replyAnalysisIndex].Action.Result = run.ReplyDecision
+ }
s.metrics.Replies.Add(1)
} else {
run.ReplyDecision = "reply_accepted_dry_run"
+ if statusEval.Accepted && statusAnalysisIndex >= 0 {
+ run.Analyses[statusAnalysisIndex].Action.Result = run.ReplyDecision
+ } else if replyAnalysisIndex >= 0 {
+ run.Analyses[replyAnalysisIndex].Action.Result = run.ReplyDecision
+ }
}
- run.PolicyReason = run.CategoryDecision + "; " + run.ReplyDecision
+ run.PolicyReason = policySummary(run.CategoryDecision, run.PriorityDecision, run.ReplyDecision)
}
// Persist the final GLPI version after our own write so the next poll does
// not immediately process the same self-induced modification again.
- if !s.cfg.DryRun && (run.CategoryChanged || run.ReplyWritten) {
+ if !s.cfg.DryRun && (run.CategoryChanged || run.PriorityChanged || run.ReplyWritten) {
if finalTicket, e := s.glpi.GetTicket(ctx, id); e == nil {
run.SourceVersion = sourceVersion(finalTicket)
}
}
- run.PolicyReason = run.CategoryDecision + "; " + run.ReplyDecision
+ run.PolicyReason = policySummary(run.CategoryDecision, run.PriorityDecision, run.ReplyDecision)
run.Outcome = "processed"
s.metrics.Processed.Add(1)
finish(nil)
@@ -817,6 +1058,17 @@ func effectiveReplyCategory(t model.Ticket, d model.Decision, categories []model
return model.Category{ID: effectiveID, Name: fmt.Sprintf("Kategorie #%d", effectiveID)}
}
+func policySummary(decisions ...string) string {
+ parts := make([]string, 0, len(decisions))
+ for _, decision := range decisions {
+ decision = strings.TrimSpace(decision)
+ if decision != "" {
+ parts = append(parts, decision)
+ }
+ }
+ return strings.Join(parts, "; ")
+}
+
func joinAIReasons(reasons ...string) string {
labels := []string{"Kategorie", "Status", "Antwort"}
parts := make([]string, 0, len(reasons))
@@ -1062,21 +1314,93 @@ func sourceVersion(t model.Ticket) string {
// Do not rely on date_mod alone: two changes can happen within the same
// timestamp resolution and some API projections may omit it. Requesters and
// linked items are decision-relevant because they feed the context collector.
- payload := fmt.Sprintf("%d\x00%s\x00%s\x00%s\x00%d\x00%d\x00%v\x00%v", t.ID, t.DateMod, t.Name, t.Content, t.StatusID, t.CategoryID, t.RequesterIDs, t.Items)
+ payload := fmt.Sprintf("%d\x00%s\x00%s\x00%s\x00%s\x00%d\x00%d\x00%d\x00%d\x00%d\x00%d\x00%d\x00%s\x00%v\x00%v\x00%v\x00%v", t.ID, t.DateCreation, t.DateMod, t.Name, t.Content, t.StatusID, t.CategoryID, t.Priority, t.Impact, t.Urgency, t.EntityID, t.LocationID, t.TimeToResolve, t.RequesterIDs, t.AssignedGroups, t.AssignedUsers, t.Items)
h := sha256.Sum256([]byte(payload))
return hex.EncodeToString(h[:])
}
func sameDecisionSource(original, fresh model.Ticket, expectedCategory int64) bool {
- if fresh.Name != original.Name || fresh.Content != original.Content || fresh.StatusID != original.StatusID || fresh.CategoryID != expectedCategory {
+ if fresh.Name != original.Name || fresh.Content != original.Content || fresh.StatusID != original.StatusID || fresh.CategoryID != expectedCategory || fresh.Impact != original.Impact || fresh.Urgency != original.Urgency || fresh.EntityID != original.EntityID || fresh.LocationID != original.LocationID || fresh.TimeToResolve != original.TimeToResolve {
return false
}
- if fmt.Sprint(fresh.RequesterIDs) != fmt.Sprint(original.RequesterIDs) || fmt.Sprint(fresh.Items) != fmt.Sprint(original.Items) {
+ if fmt.Sprint(fresh.RequesterIDs) != fmt.Sprint(original.RequesterIDs) || fmt.Sprint(fresh.AssignedGroups) != fmt.Sprint(original.AssignedGroups) || fmt.Sprint(fresh.AssignedUsers) != fmt.Sprint(original.AssignedUsers) || fmt.Sprint(fresh.Items) != fmt.Sprint(original.Items) {
return false
}
return true
}
func newRunID() string { b := make([]byte, 8); _, _ = rand.Read(b); return hex.EncodeToString(b) }
+
+func categoryKnowledgeMappingChecks(hits []model.KnowledgeHit, categories []model.Category, selectedID int64) []model.RuleCheck {
+ if selectedID <= 0 || len(hits) == 0 {
+ return nil
+ }
+ byID := make(map[int64]model.Category, len(categories))
+ for _, c := range categories {
+ byID[c.ID] = c
+ }
+ selected, ok := byID[selectedID]
+ if !ok {
+ return nil
+ }
+ selectedName := selected.CompleteName
+ if strings.TrimSpace(selectedName) == "" {
+ selectedName = selected.Name
+ }
+ selectedLeaf := normalizeCategoryLeaf(selectedName)
+ if selectedLeaf == "" {
+ return nil
+ }
+ var mismatches []string
+ for _, hit := range hits {
+ mapped := false
+ for _, id := range hit.Doc.Categories {
+ if id == selectedID {
+ mapped = true
+ break
+ }
+ }
+ if !mapped || len(hit.Doc.ExternalCategories) == 0 {
+ continue
+ }
+ matches := false
+ for _, label := range hit.Doc.ExternalCategories {
+ if normalizeCategoryLeaf(label) == selectedLeaf {
+ matches = true
+ break
+ }
+ }
+ if !matches {
+ mismatches = append(mismatches, fmt.Sprintf("%s: %s → #%d %s", hit.Doc.ID, strings.Join(hit.Doc.ExternalCategories, " | "), selectedID, selectedName))
+ }
+ }
+ if len(mismatches) == 0 {
+ return nil
+ }
+ return []model.RuleCheck{{
+ Code: "category_external_mapping_review",
+ Group: "category",
+ Label: "Externe Knowledge-Kategorie passt namentlich zum GLPI-Ziel",
+ Status: "warn",
+ Blocking: false,
+ Actual: strings.Join(mismatches, "; "),
+ Expected: "Mapping fachlich geprüft",
+ Detail: "Nicht blockierend: Externe Taxonomien dürfen bewusst zusammengeführt werden. Die Abweichung sollte aber geprüft werden, weil sie Hints und KI-Begründung beeinflusst.",
+ }}
+}
+
+func normalizeCategoryLeaf(v string) string {
+ v = strings.TrimSpace(v)
+ if i := strings.LastIndex(v, ">"); i >= 0 {
+ v = v[i+1:]
+ }
+ if i := strings.LastIndex(v, "/"); i >= 0 {
+ v = v[i+1:]
+ }
+ v = strings.ToLower(strings.TrimSpace(v))
+ v = strings.NewReplacer("ä", "ae", "ö", "oe", "ü", "ue", "ß", "ss", " und ", " ", "-", " ", "_", " ").Replace(v)
+ return strings.Join(strings.Fields(v), "")
+}
+
func stripHTML(s string) string {
r := strings.NewReplacer(" ", "\n", " ", "\n", " ", "\n", "
`;$('#lastRefresh').textContent=new Date().toLocaleTimeString('de-DE')}
-function renderOverview(){const stats=[['Verarbeitet',fmtNum(statusData.processed),'seit Start'],['Fehler',fmtNum(statusData.errors),statusData.errors?'prüfen':'keine'],['Queue',fmtNum(statusData.queue_depth),`von ${fmtNum(statusData.queue_size)}`],['Knowledge',fmtNum(statusData.knowledge_docs),`${fmtNum(statusData.glpi_kb_documents)} aus GLPI`],['Lernbeispiele',fmtNum(statusData.learning_examples),`${fmtNum(statusData.learning_examples_per_category)} je Kategorie im Prompt`],['Auto-Aktionen',`${fmtNum(statusData.category_changes)} / ${fmtNum(statusData.replies)}`,'Kategorie / Antwort']];$('#overviewStats').innerHTML=stats.map(x=>`
';
- const notices=[];if(!statusData.knowledge_ready&&statusData.knowledge_init_state!=='error'){const total=Number(statusData.knowledge_init_total_files||0),done=Number(statusData.knowledge_init_processed_files||0),idx=Number(statusData.knowledge_init_indexed_docs||0),docs=Number(statusData.knowledge_init_loaded_docs||0);notices.push(configNotice(`Knowledge-Index wird aufgebaut. Phase: ${esc(statusData.knowledge_init_phase||'–')} · Dateien ${fmtNum(done)} / ${fmtNum(total)} · Dokumente ${fmtNum(docs)} · indexiert ${fmtNum(idx)}. Ticketverarbeitung bleibt bis zur Bereitschaft pausiert.`,'warn'))}if(statusData.knowledge_snapshot_loaded)notices.push(configNotice(`Persistenter Knowledge-Index geladen. ${fmtNum(statusData.knowledge_docs)} Artikel waren sofort verfügbar; Quelldateien werden inkrementell im Hintergrund geprüft.`,'good'));if(statusData.knowledge_last_scan_error)notices.push(configNotice(`Letzter inkrementeller Knowledge-Scan fehlgeschlagen. Der vorherige Index bleibt aktiv. ${esc(statusData.knowledge_last_scan_error)}`,'warn'));if(statusData.knowledge_init_state==='error')notices.push(configNotice(`Knowledge-Initialisierung fehlgeschlagen. ${esc(statusData.knowledge_init_error||'Kein Fehlertext verfügbar.')}`,'bad'));if(statusData.dry_run)notices.push(configNotice('Dry Run aktiv. Änderungen und Antworten werden nur simuliert.','warn'));if(!statusData.auto_reply)notices.push(configNotice('Auto-Reply global deaktiviert. KB-Treffer werden bewertet, aber nicht gesendet.','warn'));if(statusData.knowledge_min_score>=.8)notices.push(configNotice(`Hoher Evidenz-Schwellwert: ${pct(statusData.knowledge_min_score)}. Dieser gilt erst nach der KI-Auswahl.`,'warn'));if(statusData.knowledge_retrieval_floor>=.5)notices.push(configNotice(`Hoher Retrieval-Floor: ${pct(statusData.knowledge_retrieval_floor)}. Kurze Tickets könnten bereits vor dem Evidenz-Reranking blockiert werden.`,'warn'));if(statusData.glpi_kb_enabled&&!statusData.glpi_kb_ok)notices.push(configNotice(`GLPI-KB-Sync gestört. ${esc(statusData.glpi_kb_last_error||'Kein Fehlertext verfügbar.')}`,'bad'));if(statusData.knowledge_unmapped_category_files>0)notices.push(configNotice(`${esc(statusData.knowledge_unmapped_category_files)} KB-Datei(en) mit nicht zugeordneten Fremdkategorien. Diese Artikel bleiben suchbar, Auto-Reply ist dafür fail-closed deaktiviert. Nicht zugeordnet: ${esc((statusData.knowledge_unmapped_categories||[]).join(', ')||'–')}`,'warn'));if(statusData.knowledge_ignored_files>0)notices.push(configNotice(`${esc(statusData.knowledge_ignored_files)} KB-Datei(en) durch Kompatibilitäts-/Ignore-Regeln übersprungen.`,'warn'));if(statusData.rag_enabled&&!statusData.knowledge_docs)notices.push(configNotice('RAG aktiv, aber keine Knowledge-Dokumente geladen.','bad'));if(statusData.context_fail_closed)notices.push(configNotice('Kontextquellen arbeiten fail-closed: Fehler können Auto-Replies blockieren.'));if(!notices.length)notices.push(configNotice('Keine auffälligen Konfigurationshinweise erkannt.','good'));$('#diagnosticNotices').innerHTML=notices.join('');
+ const notices=[];const pollAt=statusData.last_poll?fmtDate(statusData.last_poll):'–';if(statusData.poll_last_error){notices.push(configNotice(`Letzter GLPI-Poll fehlgeschlagen. ${esc(statusData.poll_last_error)}`,'bad'))}else if(!statusData.last_poll){notices.push(configNotice('Noch kein GLPI-Ticket-Poll abgeschlossen. Die Ticketverarbeitung wurde noch nicht aktiv oder der erste Abruf läuft.','warn'))}else if(Number(statusData.poll_last_fetched||0)===0){notices.push(configNotice(`GLPI-Poll aktiv, aber ohne Treffer. Letzter Abruf ${esc(pollAt)} · 0 Tickets. Prüfen Sie insbesondere GLPI_TICKET_FILTER und die API-Berechtigung auf /Assistance/Ticket.`,'warn'))}else if(Number(statusData.poll_last_unseen||0)===0){notices.push(configNotice(`GLPI-Poll funktioniert. ${fmtNum(statusData.poll_last_fetched)} Ticket(s) abgerufen, davon ${fmtNum(statusData.poll_last_seen)} bereits in derselben Version verarbeitet; deshalb 0 neue Queue-Einträge. Dauerhaft bekannte Ticketversionen: ${fmtNum(statusData.processed_version_count||0)}.`,'good'))}else{notices.push(configNotice(`GLPI-Poll funktioniert. ${fmtNum(statusData.poll_last_fetched)} abgerufen · ${fmtNum(statusData.poll_last_unseen)} neu · ${fmtNum(statusData.poll_last_enqueued)} eingereiht · ${fmtNum(statusData.poll_last_rejected)} abgewiesen.`,'good'))}if(!statusData.knowledge_ready&&statusData.knowledge_init_state!=='error'){const total=Number(statusData.knowledge_init_total_files||0),done=Number(statusData.knowledge_init_processed_files||0),idx=Number(statusData.knowledge_init_indexed_docs||0),docs=Number(statusData.knowledge_init_loaded_docs||0);notices.push(configNotice(`Knowledge-Index wird aufgebaut. Phase: ${esc(statusData.knowledge_init_phase||'–')} · Dateien ${fmtNum(done)} / ${fmtNum(total)} · Dokumente ${fmtNum(docs)} · indexiert ${fmtNum(idx)}. Ticketverarbeitung bleibt bis zur Bereitschaft pausiert.`,'warn'))}if(statusData.knowledge_snapshot_loaded)notices.push(configNotice(`Persistenter Knowledge-Index geladen. ${fmtNum(statusData.knowledge_docs)} Artikel waren sofort verfügbar; Quelldateien werden inkrementell im Hintergrund geprüft.`,'good'));if(statusData.knowledge_last_scan_error)notices.push(configNotice(`Letzter inkrementeller Knowledge-Scan fehlgeschlagen. Der vorherige Index bleibt aktiv. ${esc(statusData.knowledge_last_scan_error)}`,'warn'));if(statusData.knowledge_init_state==='error')notices.push(configNotice(`Knowledge-Initialisierung fehlgeschlagen. ${esc(statusData.knowledge_init_error||'Kein Fehlertext verfügbar.')}`,'bad'));if(statusData.dry_run)notices.push(configNotice('Dry Run aktiv. Änderungen und Antworten werden nur simuliert.','warn'));if(!statusData.auto_reply)notices.push(configNotice('Auto-Reply global deaktiviert. KB-Treffer werden bewertet, aber nicht gesendet.','warn'));if(statusData.priority_enabled&&!statusData.auto_priority)notices.push(configNotice('Prioritätsanalyse im Shadow Mode. Empfehlungen werden als eigener KI-Lauf protokolliert, GLPI bleibt unverändert.'));if(statusData.auto_priority)notices.push(configNotice(`Automatische Priorisierung aktiv. Erhöhungen sind auf ${esc(statusData.priority_max_increase)} Stufe(n) je Lauf begrenzt.${statusData.dry_run?' Dry Run verhindert Schreibzugriffe.':''}`,statusData.dry_run?'warn':'bad'));if(statusData.escalation_enabled&&!statusData.auto_escalation)notices.push(configNotice('Eskalationsanalyse im Shadow Mode. Zeittrigger erzeugen eigene Diagnose-Läufe, führen aber keine Aktion aus.'));if(statusData.auto_escalation)notices.push(configNotice(`Automatische Eskalation aktiv. Der Scheduler darf freigegebene Aktionen bis Stufe ${esc(statusData.escalation_max_level)} ausführen.${statusData.dry_run?' Dry Run verhindert Schreibzugriffe.':''}`,statusData.dry_run?'warn':'bad'));if(statusData.knowledge_min_score>=.8)notices.push(configNotice(`Hoher Evidenz-Schwellwert: ${pct(statusData.knowledge_min_score)}. Dieser gilt erst nach der KI-Auswahl.`,'warn'));if(statusData.knowledge_retrieval_floor>=.5)notices.push(configNotice(`Hoher Retrieval-Floor: ${pct(statusData.knowledge_retrieval_floor)}. Kurze Tickets könnten bereits vor dem Evidenz-Reranking blockiert werden.`,'warn'));if(statusData.glpi_kb_enabled&&!statusData.glpi_kb_ok)notices.push(configNotice(`GLPI-KB-Sync gestört. ${esc(statusData.glpi_kb_last_error||'Kein Fehlertext verfügbar.')}`,'bad'));if(statusData.knowledge_unmapped_category_files>0)notices.push(configNotice(`${esc(statusData.knowledge_unmapped_category_files)} KB-Datei(en) mit nicht zugeordneten Fremdkategorien. Diese Artikel bleiben suchbar, Auto-Reply ist dafür fail-closed deaktiviert. Nicht zugeordnet: ${esc((statusData.knowledge_unmapped_categories||[]).join(', ')||'–')}`,'warn'));if(statusData.knowledge_ignored_files>0)notices.push(configNotice(`${esc(statusData.knowledge_ignored_files)} KB-Datei(en) durch Kompatibilitäts-/Ignore-Regeln übersprungen.`,'warn'));if(statusData.rag_enabled&&!statusData.knowledge_docs)notices.push(configNotice('RAG aktiv, aber keine Knowledge-Dokumente geladen.','bad'));if(statusData.context_fail_closed)notices.push(configNotice('Kontextquellen arbeiten fail-closed: Fehler können Auto-Replies blockieren.'));if(!notices.length)notices.push(configNotice('Keine auffälligen Konfigurationshinweise erkannt.','good'));$('#diagnosticNotices').innerHTML=notices.join('');
const kTotal=Number(statusData.knowledge_init_loaded_docs||0),kIndexed=Number(statusData.knowledge_init_indexed_docs||0),kPct=kTotal?Math.round((kIndexed/kTotal)*100):0;const health=[['Lokale Knowledge Base',!!statusData.knowledge_ready,statusData.knowledge_ready?`${fmtNum(statusData.knowledge_docs)} Artikel bereit`:`${esc(statusData.knowledge_init_phase||'waiting')} · ${fmtNum(kIndexed)}/${fmtNum(kTotal)} indexiert (${kPct}%)`],['GLPI API',statusData.glpi_ok,statusData.glpi_api_version||''],['Ollama',statusData.ollama_ok,statusData.ollama_model||''],['GLPI Knowledge Base',!statusData.glpi_kb_enabled||statusData.glpi_kb_ok,statusData.glpi_kb_enabled?`${statusData.glpi_kb_documents||0} Artikel · Sync ${fmtDate(statusData.glpi_kb_last_sync)}`:'deaktiviert'],['Uptime Kuma',true,statusData.uptime_kuma_enabled?`aktiv · ${statusData.uptime_kuma_mode}`:'deaktiviert'],['Change Calendar',true,statusData.change_calendar_enabled?'aktiv':'deaktiviert'],['Major Incidents',true,statusData.major_incidents_enabled?'aktiv':'deaktiviert'],['Benutzer ↔ Geräte',true,statusData.user_device_context_enabled?'aktiv':'deaktiviert']];$('#integrationHealth').innerHTML=health.map(x=>`
`}
@@ -145,7 +145,7 @@ function renderKB(){const st=kbStatsData();$('#kbStats').innerHTML=st.map(x=>`!q||[x.ticket_id,x.subject,x.text,x.category_name,x.category_id].join(' ').toLowerCase().includes(q));const corrections=learningRows.filter(x=>x.correction).length;$('#learningStats').innerHTML=[['Gesamt',learningRows.length,'bestätigte Beispiele'],['Korrekturen',corrections,'KI lag anders'],['Bestätigungen',learningRows.length-corrections,'KI wurde bestätigt']].map(x=>`