diff --git a/.env.example b/.env.example index 1cb9c2c..85618ed 100644 --- a/.env.example +++ b/.env.example @@ -225,40 +225,87 @@ GLPI_TICKET_FILTER=status.id==1 # HTTP-Timeout für GLPI-Aufrufe. GLPI_TIMEOUT=20s ############################################################################### -# 10. GLPI AI AGENT - OLLAMA +# 10. GLPI AI AGENT - OLLAMA-POOL ############################################################################### -# Ollama-Adresse aus Sicht des Agent-Containers. +# Einzelnode-Kompatibilität. Wird nur verwendet, wenn OLLAMA_URLS leer ist. OLLAMA_URL=http://ollama:11434 + +# Mehrere Ollama-Instanzen, durch Komma getrennt. Alle Nodes sollten dieselbe +# Ollama-Version, dasselbe Chat-Modell und dasselbe Embedding-Modell besitzen. +# Beispiel für vorhandene Lenovo-Nodes: +# OLLAMA_URLS=http://10.20.30.21:11434,http://10.20.30.22:11434,http://10.20.30.23:11434 +OLLAMA_URLS= + +# Optionale lesbare Namen; Anzahl muss exakt zu OLLAMA_URLS passen. +# OLLAMA_NODE_NAMES=lenovo-01,lenovo-02,lenovo-03 +OLLAMA_NODE_NAMES= + +# Optionale Gewichte 1..100; nur für OLLAMA_ROUTING_MODE=weighted relevant. +# OLLAMA_NODE_WEIGHTS=1,1,1 +OLLAMA_NODE_WEIGHTS= + +# Routing-Modi: +# least_inflight = Node mit den wenigsten laufenden Requests (empfohlen) +# round_robin = zyklische Verteilung +# weighted = Verteilung anhand OLLAMA_NODE_WEIGHTS und Auslastung +# fastest_recent = bevorzugt die zuletzt schnellsten Nodes +OLLAMA_ROUTING_MODE=least_inflight + +# Maximale parallele Requests JE Node. Für integrierte GPUs/RAM-Sharing 1. +OLLAMA_NODE_MAX_INFLIGHT=1 + +# Regelmäßige Prüfung von /api/tags. +OLLAMA_NODE_HEALTH_INTERVAL=15s + +# Nach einem retryfähigen Netzwerk-/HTTP-Fehler wird der Node so lange nicht +# für neue Requests verwendet. +OLLAMA_NODE_FAILURE_COOLDOWN=30s + +# Maximalzeit für einen einzelnen Request an genau einen Node. Der übergeordnete +# Analyse-Timeout kann kürzer sein und hat dann Vorrang. +OLLAMA_NODE_REQUEST_TIMEOUT=10m + +# Bei Netzwerkfehlern, HTTP 408/429/5xx oder ungültigem Response-JSON auf einen +# anderen kompatiblen Node wechseln. +OLLAMA_FAILOVER_ENABLED=true + +# Maximale Anzahl verschiedener Nodes je logischem Request. 0 bedeutet: +# automatisch alle konfigurierten Nodes. Ein positiver Wert darf höchstens der +# Zahl der OLLAMA_URLS-Einträge entsprechen. +OLLAMA_FAILOVER_ATTEMPTS=0 + +# Bei abweichenden Chat-/Embedding-Modelldigests wird der Pool vollständig +# fail-closed. Für reproduzierbare Entscheidungen unbedingt true lassen. +OLLAMA_REQUIRE_SAME_MODEL_DIGEST=true + +# true: Jeder Node muss auch OLLAMA_EMBEDDING_MODEL installiert haben. +# Bei RAG empfohlen. false erlaubt Chat-only-Nodes; Embedding-Requests werden +# trotzdem nur an Nodes mit erkanntem Embedding-Modell gesendet. +OLLAMA_REQUIRE_EMBEDDING_MODEL=true + # OLLAMA_MODEL ist bereits oben im gemeinsamen Compose-/Ollama-Bereich gesetzt: # OLLAMA_MODEL=qwen3:8b # Embedding-Modell für RAG. OLLAMA_EMBEDDING_MODEL=embeddinggemma + # Modellspezifisches Retrieval-Prompting. -# -# Mögliche Werte: -# -# auto -# Modell automatisch erkennen und passende Retrieval-Prompts verwenden. -# Für embeddinggemma empfohlen. -# -# plain -# keine modellspezifischen Retrieval-Prompts. +# auto = Modell automatisch erkennen; für embeddinggemma empfohlen. +# plain = keine modellspezifischen Retrieval-Prompts. KNOWLEDGE_EMBEDDING_PROFILE=auto -# OLLAMA_TIMEOUT und OLLAMA_MAX_CONCURRENT sind bereits oben gesetzt. + +# Gesamtbudget für Ollama-Aufrufe und Fallback für Node-Request-Timeouts. +# OLLAMA_TIMEOUT ist bereits oben gesetzt. +# OLLAMA_MAX_CONCURRENT bleibt als Legacy-Alias für +# OLLAMA_NODE_MAX_INFLIGHT erhalten, falls der neue Wert nicht gesetzt ist. + # Maximale Anzahl generierter Tokens für strukturierte Antworten. OLLAMA_NUM_PREDICT=768 -# Wiederholungen bei fehlerhaftem / abgeschnittenem JSON. +# Wiederholungen bei semantisch/strukturell fehlerhaftem Modell-JSON. +# Diese Wiederholungen sind von Netzwerk-Failover getrennt. OLLAMA_JSON_RETRIES=1 # Ollama-Modell nach Benutzung im Speicher halten. -# -# Beispiele: -# 5m -# 10m -# 30m OLLAMA_KEEP_ALIVE=10m # Thinking bei unterstützten Modellen deaktivieren. -# -# Für strukturierte Klassifikations-/Policy-Aufgaben empfohlen. OLLAMA_THINK=false ############################################################################### # 11. KNOWLEDGE BASE / RAG - BASIS @@ -726,8 +773,8 @@ GLPI_ESCALATION_LIMIT=100 ############################################################################### # Maximale Anzahl wartender Jobs. QUEUE_SIZE=256 -# Parallele Ticket-Worker. -# -# Darf größer als OLLAMA_MAX_CONCURRENT sein. -# Ollama wird separat begrenzt. +# Parallele Ticket-Worker. Der Ollama-Pool kann nur so viele unabhängige +# Ticketpipelines gleichzeitig verteilen, wie Worker aktiv sind. Für drei +# gleichartige Nodes ist WORKERS=3 ein sinnvoller Lasttest; jeder Node bleibt +# zusätzlich durch OLLAMA_NODE_MAX_INFLIGHT begrenzt. WORKERS=2 \ No newline at end of file diff --git a/BETRIEBSANLEITUNG_GLPI_AI_AGENT.md b/BETRIEBSANLEITUNG_GLPI_AI_AGENT.md new file mode 100644 index 0000000..aa11b4f --- /dev/null +++ b/BETRIEBSANLEITUNG_GLPI_AI_AGENT.md @@ -0,0 +1,1104 @@ +# Betriebsanleitung – GLPI AI Agent + +**Dokumentstand:** 3. August 2026 +**Technische Basis:** Projektstand `glpi-ai-agent-ollama-pool` +**Zielgruppe:** Betrieb, Administration, Service Desk, Informationssicherheit und technische Projektverantwortliche + +> Diese Anleitung beschreibt den tatsächlich vorliegenden Quellstand. Sie trennt bewusst zwischen **Code-Defaults** und den teilweise deutlich offensiveren **Beispielwerten in `.env.example`**. Für eine neue Installation sind die Code-Defaults sicherer; für den produktiven Betrieb muss jede schreibende Funktion schrittweise im Shadow Mode validiert werden. + +## Inhaltsverzeichnis + +1. [Zweck und Systemgrenzen](#1-zweck-und-systemgrenzen) +2. [Architektur und Datenfluss](#2-architektur-und-datenfluss) +3. [Funktionsübersicht und Auswirkungen](#3-funktionsübersicht-und-auswirkungen) +4. [Sicherheits- und Policy-Modell](#4-sicherheits--und-policy-modell) +5. [Installation und Start](#5-installation-und-start) +6. [Empfohlene Inbetriebnahme](#6-empfohlene-inbetriebnahme) +7. [Regelbetrieb](#7-regelbetrieb) +8. [Persistenz, Backup, Reset und Wiederherstellung](#8-persistenz-backup-reset-und-wiederherstellung) +9. [Diagnose, Endpunkte und Monitoring](#9-diagnose-endpunkte-und-monitoring) +10. [Eskalation im Detail](#10-eskalation-im-detail) +11. [Priorisierung im Detail](#11-priorisierung-im-detail) +12. [Knowledge/RAG und automatische Antworten](#12-knowledgerag-und-automatische-antworten) +13. [Vollständige ENV-Referenz](#13-vollständige-env-referenz) +14. [Fehlerbehebung](#14-fehlerbehebung) +15. [Bekannte Grenzen und Abweichungen](#15-bekannte-grenzen-und-abweichungen) +16. [Betriebs-Checklisten](#16-betriebs-checklisten) + +--- + +# 1. Zweck und Systemgrenzen + +Der GLPI AI Agent liest Tickets aus GLPI 11 über die High-Level API, sammelt freigegebene Kontextdaten, führt mehrere voneinander getrennte KI-Analysen über einen oder mehrere Ollama-Nodes aus und übergibt die Ergebnisse an deterministische Go-Policies. Erst die Policy entscheidet, ob eine GLPI-Aktion zulässig ist. + +Das Modell besitzt **keinen direkten GLPI-Werkzeugzugriff**. Es kann daher weder eigenständig Kategorien ändern noch Followups schreiben, Prioritäten setzen, Gruppen zuweisen oder Tickets verknüpfen. Es liefert ausschließlich strukturierte Empfehlungen. + +Der Agent ist für folgende Hauptaufgaben ausgelegt: + +- neue oder geänderte Tickets erkennen und deduplizieren; +- Kategorie aus dem aktuellen GLPI-Katalog auswählen; +- Priorität, Impact, Urgency, Betroffenheitsumfang und Zeitkritikalität analysieren; +- aktive Störungen oder Wartungen aus Uptime Kuma einem Ticket zuordnen; +- einen bereits menschlich erstellten und freigegebenen Knowledge-Artikel als Antwort auswählen; +- offene Tickets unabhängig von `date_mod` zeitgesteuert auf Eskalationsbedarf prüfen; +- Kategorie-, Prioritäts-, Antwort- und Eskalationsentscheidungen vollständig auditieren; +- menschlich bestätigte Kategoriekorrekturen als begrenzte Lernbeispiele speichern; +- lokale und GLPI-interne Knowledge-Inhalte indexieren und verwalten. + +Nicht vorgesehen ist eine freie, vom Modell formulierte Endnutzerantwort. Der Inhalt einer automatischen Antwort stammt aus einem freigegebenen Knowledge-Dokument oder aus einer fest konfigurierten Statusvorlage. + +# 2. Architektur und Datenfluss + +## 2.1 Komponenten + +| Komponente | Aufgabe | +|---|---| +| GLPI High-Level API | Tickets, Kategorien, Followups, Knowledge, Changes, Assets und Schreiboperationen | +| Ollama Pool Router | Healthchecks, Routing, per-Node-Auslastungsgrenzen, Digest-Prüfung und Failover | +| Ollama Chatmodell je Node | Strukturierte Kategorie-, Prioritäts-, Status-, Antwort- und Eskalationsempfehlungen | +| Ollama Embeddingmodell je Node | Semantische Vektoren für Hybrid-Retrieval | +| Knowledge Store | Lokale JSON-Artikel, Web-verwaltete Artikel, GLPI-KB-Cache und persistenter Vektorindex | +| Kontextkollektor | Changes, Major Incidents, Requester-Geräte und Uptime-Kuma-Daten | +| Policy | Deterministische Freigabe oder Blockade jeder Aktion | +| Prioritätsqueue | Manuelle Läufe, Webhooks, Polling und Eskalationsscheduler mit getrennten Prioritäten | +| State Store | Audit in `runs.jsonl` und dauerhafte Deduplizierung in `state-index.json` | +| Weboberfläche | Dashboard, Diagnose, Knowledge-Verwaltung, Lernen, Mapping und manuelle Neuanalyse | + +## 2.2 Ollama-Pool + +Der Agent kann einen Einzelnode oder mehrere unabhängige Ollama-Server verwenden. Jeder Node lädt das vollständige Chat- und Embedding-Modell lokal. Der Pool teilt daher **kein einzelnes Modell und keinen RAM über mehrere Rechner**, sondern verteilt vollständige Inferenzrequests. Das erhöht Gesamtdurchsatz und Verfügbarkeit. + +Für jeden KI-Lauf wählt der Router einen gesunden, kompatiblen Node. Standard ist `least_inflight`: Der Node mit den wenigsten laufenden Requests wird bevorzugt; bei gleicher Auslastung gleicht der Router auch die bisherige Requestzahl aus. Retryfähige Netzwerk-, Timeout-, Rate-Limit-, 5xx- oder Response-JSON-Fehler können auf einem anderen Node wiederholt werden. Die Node-Auswahl und jeder Versuch werden im separaten `AnalysisRun.provider` gespeichert. + +Bei `OLLAMA_REQUIRE_SAME_MODEL_DIGEST=true` arbeitet der Pool fail-closed, sobald erreichbare Nodes unterschiedliche Chat- oder erforderliche Embedding-Digests melden. Dadurch wird verhindert, dass identische Tickets zufällig mit unterschiedlichen Modellständen bewertet werden. + +Beim Prozessstart bleibt die Weboberfläche erreichbar, während der Agent wiederholt auf mindestens einen kompatiblen Node wartet. Knowledge-Initialisierung, Polling und Worker beginnen erst anschließend. Dadurch wird ein noch bootender externer Node nicht zu einem einmaligen dauerhaften Initialisierungsfehler. + +## 2.3 Normaler Ticketlauf + +1. Der Poller lädt bis zu `GLPI_POLL_LIMIT` Tickets mit `GLPI_TICKET_FILTER`. +2. Aus entscheidungsrelevanten Ticketfeldern wird eine `source_version` gebildet. +3. `state-index.json` entscheidet, ob genau diese Ticketversion bereits verarbeitet wurde. +4. Neue Versionen werden in die Queue gestellt. +5. Ein Worker lädt Ticket und Followups erneut und prüft den erlaubten Status. +6. Kategorie-Knowledge und GLPI-Kategorien werden als Kandidaten vorbereitet. +7. Die Kategorie-KI läuft als eigener `AnalysisRun`. +8. Die Policy prüft Kategorie-ID, Confidence und Änderungsbedarf. +9. Die Prioritäts-KI läuft optional als eigener, fail-open begrenzter `AnalysisRun`. +10. Der Kontextkollektor lädt aktivierte Betriebsdaten. +11. Optional wird eine aktive Uptime-Kuma-Störung oder Wartung zugeordnet. +12. Antwort-Knowledge wird nach der effektiven Kategorie neu gerankt. +13. Die Antwort-KI darf ausschließlich einen bereitgestellten Knowledge-Kandidaten auswählen oder ablehnen. +14. Vor jedem Write werden Ticket und Followups erneut geprüft. +15. Der übergeordnete Lauf und alle Analyseläufe werden persistiert. + +## 2.4 Queue-Prioritäten + +| Trigger | Priorität | Wirkung | +|---|---:|---| +| `manual_recheck` / manuell | 100 | Höchste Priorität; kann bekannte Ticketversion einmalig erzwingen | +| `webhook` | 80 | Schnelle Reaktion auf GLPI-Ereignisse | +| `poll` | 50 | Reguläre neue/geänderte Tickets | +| `scheduled_escalation` | 20 | Niedrigste Priorität, damit neue Tickets Vorrang haben | + +Die Queue dedupliziert nach `Ticket-ID + Trigger`. Ein Poll- und ein Eskalationsauftrag für dasselbe Ticket können deshalb gleichzeitig existieren, zwei Poll-Aufträge jedoch nicht. + +# 3. Funktionsübersicht und Auswirkungen + +## 3.1 Ticket-Polling und Webhook + +**Polling** läuft sofort nach Start der Ticketverarbeitung und anschließend in `GLPI_POLL_INTERVAL`. Die API-Abfrage kann serverseitig gefiltert werden; unabhängig davon prüft die lokale Policy `GLPI_ALLOWED_STATUS_IDS`. + +**Webhook** ist nur aktiv, wenn `WEBHOOK_SECRET` gesetzt ist. Der Endpunkt `POST /webhook/glpi` erwartet den Header `X-Webhook-Secret`. Er extrahiert eine Ticket-ID aus mehreren üblichen JSON-Formen oder einer `/Ticket/{id}`-Zeichenfolge und stellt das Ticket mit höherer Queue-Priorität ein. Der Webhook umgeht die Versionserkennung nicht; ein unverändertes, bereits verarbeitetes Ticket kann später als `already_processed` enden. + +## 3.2 Automatische Kategorisierung + +Die Kategorieanalyse erhält nur bekannte GLPI-Kategorien und eine begrenzte Auswahl an Kategorie-Knowledge. Eine empfohlene ID muss im geladenen GLPI-Katalog existieren. `AUTO_CATEGORY=true` erlaubt die Policy-Prüfung; `DRY_RUN=true` simuliert den Write. Kategorie-Knowledge aus `KNOWLEDGE_CATEGORY_SOURCES` ist niemals als Endnutzerantwort zulässig. + +**Auswirkung im Livebetrieb:** `PATCH` des Ticketfeldes für die ITIL-Kategorie. Vor dem Write wird geprüft, ob das Ticket seit der Analyse unverändert ist. + +## 3.3 KI-Priorisierung + +Die Prioritätsanalyse ist ein separater Lauf. Das Modell empfiehlt GLPI-Priorität 1–6 sowie Impact, Urgency, Scope, Zeitkritikalität und Reason Codes. Explizite Ticketbelege wie „mehrere Benutzer“ oder „Ausweichmöglichkeit vorhanden“ werden zusätzlich deterministisch erkannt. + +Die Policy: + +- erlaubt keine automatische Herabstufung; +- begrenzt die Erhöhung auf `PRIORITY_MAX_INCREASE` je Ticketlauf; +- verlangt bei einer Erhöhung Mindest-Confidence und einen erlaubten Reason Code; +- behandelt neutrale Gründe wie `insufficient_information` als „keine Änderung“; +- beendet nur den Prioritätslauf bei Timeout oder Modellfehler; Kategorie und Antwort laufen weiter. + +**Auswirkung im Livebetrieb:** Priorität des Tickets wird auf den policy-begrenzten Zielwert gesetzt. Impact und Urgency werden derzeit diagnostiziert, aber nicht separat geschrieben. + +## 3.4 Operational Context + +Der Kontextkollektor kann folgende Quellen zusammenführen: + +- GLPI Change Calendar innerhalb von Lookback/Lookahead; +- explizit gefilterte Major-Incident-Tickets; +- Geräte/Assets des Requesters; +- Uptime-Kuma-Störungen und Wartungen. + +Bei `CONTEXT_BLOCK_AUTO_REPLY_ON_ERRORS=true` arbeitet die Antwortpolicy fail-closed: Fehler einer aktivierten Kontextquelle können automatische Antworten blockieren. `CONTEXT_BLOCK_AUTO_REPLY_ON_INCIDENT=true` blockiert normale Knowledge-Antworten bei einem relevanten Incident. + +## 3.5 Statusbezogene vordefinierte Antworten + +Ist `CONTEXT_STATUS_REPLY_ENABLED=true`, darf die KI nur einen aktiven Uptime-Kuma-Kandidaten auswählen. Der Text stammt ausschließlich aus `CONTEXT_INCIDENT_REPLY_TEXT` oder `CONTEXT_MAINTENANCE_REPLY_TEXT`. Die Freigabe erfordert gleichzeitig: + +- ausreichende deterministische Relevanz; +- ausreichende KI-Confidence; +- ausreichenden Produktscore `Relevanz × Confidence`; +- vollständigen Kontext; +- einen tatsächlich bekannten Kandidaten. + +Bei erfolgreicher Statusantwort wird die normale Knowledge-Antwortanalyse übersprungen. + +## 3.6 Knowledge Retrieval und Auto-Reply + +Das Retrieval kombiniert Semantik, Betreff/Titel, lexikalische Übereinstimmung, Keywords und Kategorie-/Lernsignale. Lange Tickets und Artikel werden in überlappende Chunks zerlegt. Der Agent schickt nur dynamisch ausgewählte Kandidaten an das Modell. + +Eine automatische Antwort benötigt unter anderem: + +- `AUTO_REPLY=true` und `DRY_RUN=false` für einen echten Write; +- keine vorhandenen Followups; +- einen vom Modell ausgewählten Kandidaten; +- ausreichende KI-Confidence; +- zulässige Source; +- `auto_reply=true` am Dokument; +- passende Sprache und Kommunikationsstil; +- Retrieval-Floor und finale Evidenz; +- passende effektive Ticketkategorie; +- keine blockierende Kontextlage; +- eine zweite Followup-Prüfung unmittelbar vor dem Write. + +**Auswirkung im Livebetrieb:** öffentlicher GLPI-Followup mit festem Knowledge-Inhalt, Anrede, Schlussformel und Signatur. + +## 3.7 GLPI Knowledge Base Connector + +Der Connector synchronisiert sichtbare GLPI-KB-Artikel periodisch. Rich Text bleibt für den Versand erhalten, während RAG und Modell bereinigten Plaintext sehen. Ein lokaler Cache (`glpi-kb-cache.json`) erlaubt den Start mit dem zuletzt synchronisierten Stand, wenn die initiale GLPI-KB-Abfrage ausfällt. + +## 3.8 Knowledge-Webeditor und Kategorie-Mapping + +Bei authentifiziertem Dashboard und `KNOWLEDGE_WEB_EDIT_ENABLED=true` können agenteneigene Knowledge-Dokumente unter `DATA_DIR/knowledge-managed/` erstellt, geändert und gelöscht werden. Statische Dateien im `KNOWLEDGE_DIR` und synchronisierte GLPI-Artikel bleiben read-only. + +Der Mapping-Editor verbindet externe String-Kategorien aus Knowledge-Dateien mit numerischen GLPI-ITIL-Kategorien. Die Änderungen werden in `KNOWLEDGE_CATEGORY_MAP_FILE` gespeichert und in den laufenden Index übernommen. + +## 3.9 Human-in-the-loop-Lernen + +Der Agent lernt nur aus ausdrücklich bestätigten oder korrigierten Beispielen, nicht automatisch aus seinen eigenen Entscheidungen. Die Beispiele beeinflussen spätere Kategorieprompts und Retrievalsignale. Die Datei liegt unter `DATA_DIR/category-learning.json`. + +## 3.10 Zeitgesteuerte Eskalation + +Die Eskalation besitzt einen eigenen Scheduler und ignoriert die normale Ticketversions-Deduplizierung. Sie prüft alte Tickets auch dann, wenn `date_mod` unverändert ist. Ein Lauf kann bis zu drei Aktionen empfehlen. Jede Aktion wird einzeln geprüft und auditiert. + +Unterstützte Aktionen: + +| Aktion | Live-Auswirkung | +|---|---| +| `raise_priority` | Priorität genau um eine Stufe erhöhen, maximal 6 | +| `assign_second_level` | konfigurierte Second-Level-Gruppe zu vorhandenen Gruppen hinzufügen | +| `assign_security_team` | konfigurierte Security-Gruppe hinzufügen; nur bei `security_incident_suspected` | +| `notify_service_owner` | konfigurierte Gruppe/Person hinzufügen und optional Webhook senden | +| `link_major_incident` | Ticket über installationsspezifischen API-Adapter mit relevantestem Major Incident verknüpfen | +| `request_manager_review` | konfigurierte Gruppe/Person hinzufügen und optional Webhook senden | + +Zu jeder erfolgreichen Aktion kann ein privater Followup mit einer festen Vorlage geschrieben werden. Erfolgreiche Aktionsschritte werden je Ticket, Stufe, Aktion und Ziel in `state-index.json` dedupliziert. + +## 3.11 Ollama-Pool, Routing und Failover + +**Auswirkung:** Mehrere Tickets oder voneinander unabhängige Analyseläufe können über mehrere Rechner parallel verarbeitet werden. Die Geschwindigkeit eines einzelnen Requests bleibt durch den ausgewählten Node begrenzt. Fällt ein Node aus, kann ein noch nicht akzeptierter Inferenzrequest auf einem anderen kompatiblen Node fortgesetzt werden. + +Der Pool unterstützt `least_inflight`, `round_robin`, `weighted` und `fastest_recent`. Für gleichartige Lenovo-Systeme mit integrierter GPU ist `least_inflight` zusammen mit `OLLAMA_NODE_MAX_INFLIGHT=1` der empfohlene Start. Für einen später ergänzten leistungsfähigeren GPU-Server kann `weighted` verwendet werden. + +# 4. Sicherheits- und Policy-Modell + +## 4.1 Schalterhierarchie + +| Bereich | Analyse aktiv | Write-Freigabe | Globaler Write-Schalter | +|---|---|---|---| +| Kategorie | immer im normalen Lauf | `AUTO_CATEGORY=true` | `DRY_RUN=false` | +| Antwort | Kandidatenlage und Followup-Status | `AUTO_REPLY=true` | `DRY_RUN=false` | +| Priorität | `PRIORITY_ENABLED=true` | `AUTO_PRIORITY=true` | `DRY_RUN=false` | +| Eskalation | `ESCALATION_ENABLED=true` | `AUTO_ESCALATION=true` | `DRY_RUN=false` | + +`DRY_RUN=true` überstimmt alle Auto-Schalter und simuliert freigegebene Aktionen. + +## 4.2 Race-Schutz + +- Pro Ticket existiert innerhalb eines Prozesses ein Mutex. +- Ticket und Followups werden vor der Analyse geladen. +- Vor einem Live-Write werden entscheidungsrelevanter Ticketzustand und Followups erneut geladen. +- Ändert sich die `source_version`, wird die Aktion abgebrochen. +- GLPI-Schreibfehler werden nicht blind wiederholt. + +Eine vollständig atomare „prüfen und schreiben“-Operation kann ohne serverseitigen Conditional Write dennoch nicht garantiert werden. + +## 4.3 Rechteprinzip + +Das GLPI-Servicekonto sollte nur die tatsächlich aktivierten Rechte besitzen: + +- Lesen von Tickets, Kategorien und Followups; +- Kategorie ändern nur bei Live-Kategorisierung; +- öffentliche Followups schreiben nur bei Auto-Reply; +- Priorität ändern nur bei Live-Priorität oder `raise_priority`; +- private Followups schreiben nur bei Eskalationsnotizen; +- Gruppen/Benutzer zuweisen nur bei entsprechenden Eskalationsaktionen; +- ITIL-Verknüpfungen erstellen nur bei `link_major_incident`. + +# 5. Installation und Start + +## 5.1 Native Windows-Installation + +1. Archiv in ein dauerhaftes Verzeichnis entpacken. +2. `.env.example` nach `.env` kopieren. +3. Für native Ausführung verwenden: + +```env +DATA_DIR=./data +KNOWLEDGE_DIR=./knowledge +OLLAMA_URL=http://localhost:11434 +HTTP_ADDR=:7080 +``` + +4. Modelle installieren: + +```powershell +ollama pull qwen3:8b +ollama pull embeddinggemma +``` + +5. Start über `run.ps1` oder die vorgebaute EXE. `run.ps1` lädt `.env`, korrigiert alte Docker-Pfade und startet derzeit mit `go run ./cmd/agent`. Für einen reinen Binary-Betrieb kann die EXE direkt gestartet werden, nachdem die Variablen im Prozess beziehungsweise Dienst gesetzt wurden. + +## 5.2 Docker Compose + +Die aktuelle Projektfassung enthält mehrere Compose-Varianten. Vor dem Start müssen Listener und Port-Mapping zusammenpassen: + +- `compose_local.yml` mappt `7080:7080`; dazu passt `HTTP_ADDR=:7080`. +- `docker-compose.yml` mappt `127.0.0.1:8080:8080`; dazu muss `HTTP_ADDR=:8080` gesetzt werden **oder** das Mapping auf `127.0.0.1:7080:7080` geändert werden. +- `AGENT_PORT` wird in den vorliegenden Compose-Dateien nicht ausgewertet. + +Startbeispiel: + +```bash +docker compose -f compose_local.yml up -d ollama +docker compose -f compose_local.yml exec ollama ollama pull qwen3:8b +docker compose -f compose_local.yml exec ollama ollama pull embeddinggemma +docker compose -f compose_local.yml up -d +``` + +## 5.3 Registry-Deployment + +```bash +export AGENT_IMAGE=gitea.example.de/organisation/glpi-ai-agent:2026-08-02 +docker compose -f docker-compose.registry.yml pull +docker compose -f docker-compose.registry.yml up -d +``` + +Das bind-mountete Datenverzeichnis muss für UID/GID des Containers schreibbar sein. Das Knowledge-Verzeichnis darf read-only sein; Web-verwaltete Artikel liegen im Datenverzeichnis. + +## 5.4 systemd + +Die mitgelieferte Unit erwartet: + +- Binary unter `/opt/glpi-ai-agent/glpi-ai-agent`; +- Arbeitsverzeichnis `/opt/glpi-ai-agent`; +- ENV-Datei `/etc/glpi-ai-agent.env`; +- schreibbares Datenverzeichnis unter `/var/lib/glpi-ai-agent`. + +Die Pfade in der ENV müssen dazu passen, insbesondere `DATA_DIR=/var/lib/glpi-ai-agent` und ein lesbares `KNOWLEDGE_DIR`. + +# 6. Empfohlene Inbetriebnahme + +## Phase 1 – reine Analyse + +```env +DRY_RUN=true +AUTO_CATEGORY=true +AUTO_REPLY=false +PRIORITY_ENABLED=true +AUTO_PRIORITY=false +ESCALATION_ENABLED=false +AUTO_ESCALATION=false +``` + +Prüfen: Kategorien, Kandidaten, Reason Codes, Mappingwarnungen, Kontextfehler und Laufzeiten. + +## Phase 2 – Eskalation im Shadow Mode + +```env +ESCALATION_ENABLED=true +AUTO_ESCALATION=false +GLPI_ESCALATION_FILTER=status.id==1 +ESCALATION_SCAN_INTERVAL=30m +ESCALATION_MIN_AGE=4h +ESCALATION_MIN_INACTIVITY=2h +``` + +Prüfen: gefundene Kandidaten, Inaktivitätsberechnung, SLA-Felder, Zuweisungen, vorgeschlagene Stufen und Aktionen. + +## Phase 3 – Kategorie live + +```env +DRY_RUN=false +AUTO_CATEGORY=true +AUTO_REPLY=false +AUTO_PRIORITY=false +AUTO_ESCALATION=false +``` + +## Phase 4 – einzelne Eskalationsaktion live + +Zunächst nur: + +```env +ESCALATION_ALLOWED_ACTIONS=none,raise_priority +AUTO_ESCALATION=true +``` + +Danach einzeln Second-Level, Security, Service Owner, Management und zuletzt Major-Incident-Link aktivieren. + +## Phase 5 – Auto-Reply + +Nur freigegebene Sources und Artikel verwenden. Vorher `GLPI_AGENT_USER_ID`, Kommunikationspolicy, Kategoriebindung, Kontextquellen und zweite Followup-Prüfung im Shadow Mode kontrollieren. + +# 7. Regelbetrieb + +## 7.1 Tägliche Kontrollen + +- `/readyz` liefert HTTP 200. +- Dashboard zeigt GLPI, Ollama und Knowledge als bereit. +- Letzter Poll ist aktuell und `poll_last_error` leer. +- Queue bleibt im Normalbetrieb nahe 0. +- Fehlerzähler steigt nicht dauerhaft. +- Neue Runs erscheinen bei geänderten Tickets. +- GLPI-KB-Sync ist aktuell, wenn aktiviert. +- Eskalationsaktionen und private Notizen stimmen fachlich. + +## 7.2 Manuelle Neuanalyse + +Im Dashboard oder per API: + +```http +POST /api/tickets/{ticket_id}/reprocess +``` + +Der Lauf erhält `trigger=manual_recheck` und `Force=true`. Er löscht keine Historie und verändert `state-index.json` nicht rückwirkend. Im Livebetrieb gelten dennoch die normalen Auto-Schalter; für sichere Tests `DRY_RUN=true` verwenden. + +## 7.3 Konfigurationsänderungen + +ENV-Werte werden nur beim Start geladen. Nach Änderungen ist ein Neustart erforderlich. Anschließend `/api/status` auf die effektiven, nicht geheimen Werte prüfen. Ungültige boolesche, numerische oder Dauerwerte können von den Parserhilfen still auf den Code-Default zurückfallen; deshalb nie allein auf den Inhalt der `.env` vertrauen. + +# 8. Persistenz, Backup, Reset und Wiederherstellung + +## 8.1 Wichtige Dateien + +| Pfad unter `DATA_DIR` | Inhalt | Bedeutung beim Löschen | +|---|---|---| +| `runs.jsonl` | vollständige Auditläufe | Diagnosehistorie verschwindet; Deduplizierung bleibt bestehen | +| `state-index.json` | letzte verarbeitete Ticketversionen und erfolgreiche Eskalationsschlüssel | Tickets gelten erneut als unbekannt; Liveaktionen können erneut geprüft werden | +| `knowledge-index/snapshot.gob` | persistenter Knowledge-Index | nächster Start muss Index neu laden/aufbauen | +| `knowledge-index/external-embeddings.json` | externer Embeddingcache | zusätzliche Embeddingarbeit | +| `embeddings.json` | historischer/zusätzlicher Embeddingcache | zusätzliche Embeddingarbeit | +| `glpi-kb-cache.json` | letzter GLPI-KB-Stand | kein Cache-Fallback bis zum nächsten erfolgreichen Sync | +| `knowledge-managed/` | über Web verwaltete Artikel | verwaltete Artikel gehen verloren | +| `category-learning.json` | menschlich bestätigte Lernbeispiele | Lernhistorie geht verloren | +| `knowledge-category-map.json` oder konfigurierter Mappingpfad | Fremdkategorie-Mapping | Kategorien werden je Modus unscoped/skip/strict behandelt | + +`runs.jsonl` wird ab etwa 64 MiB auf die im Speicher gehaltenen letzten 2000 Läufe kompaktiert. `state-index.json` bleibt davon unabhängig. + +## 8.2 Backup + +Vor Updates oder Live-Aktivierung: + +1. Agent stoppen. +2. Gesamtes `DATA_DIR` sichern. +3. `.env` separat und verschlüsselt sichern. +4. Statisches `KNOWLEDGE_DIR` und gegebenenfalls Git-Stand sichern. +5. Prüfsumme oder Snapshot-Zeitpunkt dokumentieren. + +## 8.3 Sicherer Testreset + +Für ein einzelnes Ticket: manuelle Neuanalyse verwenden. + +Für einen vollständigen Testreset: + +1. Agent stoppen. +2. `DRY_RUN=true` sicherstellen. +3. `state-index.json` sichern und löschen. +4. Optional `runs.jsonl` löschen, wenn auch die sichtbare Historie leer sein soll. +5. Agent starten. + +Im Livebetrieb `state-index.json` nicht pauschal löschen. Bereits ausgeführte Kategorie-, Antwort-, Prioritäts- oder Eskalationsentscheidungen können sonst erneut geprüft werden. + +## 8.4 Rollback + +- Alte Binary/Image-Version wiederherstellen. +- Datenverzeichnis grundsätzlich beibehalten. +- Bei inkompatiblem Knowledge-Snapshot den Snapshot sichern und `KNOWLEDGE_INDEX_MODE=rebuild` nutzen. +- `state-index.json` nicht durch eine ältere, unvollständige Kopie ersetzen, wenn seitdem Live-Eskalationen gelaufen sind. + +# 9. Diagnose, Endpunkte und Monitoring + +## 9.1 HTTP-Endpunkte + +| Methode/Pfad | Auth | Zweck | +|---|---|---| +| `GET /healthz` | nein | Prozess lebt; liefert einfach `status=ok` | +| `GET /readyz` | nein | 200 nur wenn GLPI, Ollama und Knowledge bereit sind | +| `GET /metrics` | nein | Prometheus-Metriken | +| `GET /` | Basic Auth, außer anonym | Dashboard | +| `GET /diagnostics` | Basic Auth | Entscheidungsdiagnose | +| `GET /category-mappings` | Basic Auth | Kategorie-Mapping-Editor | +| `GET /api/status` | Basic Auth | effektive nicht geheime Konfiguration und Laufzustand | +| `GET /api/runs?limit=50` | Basic Auth | letzte Runs, maximal 200 | +| `GET /api/diagnostics/run/{id}` | Basic Auth | einzelner Ticketlauf | +| `GET /api/diagnostics/analysis/{id}` | Basic Auth | einzelner AnalysisRun | +| `GET/POST/PUT/DELETE /api/knowledge…` | Basic Auth; Mutation zusätzlich Editfreigabe | Knowledge-Verwaltung | +| `GET/POST/DELETE /api/learning…` | Basic Auth; Mutation | Lernbeispiele | +| `POST /api/tickets/{id}/reprocess` | Basic Auth; Mutation | manuelle erzwungene Neuanalyse | +| `POST /webhook/glpi` | Webhook-Secret | Ticket in Webhook-Queue stellen | + +## 9.2 Prometheus-Metriken + +- `glpi_agent_processed_total` +- `glpi_agent_skipped_total` +- `glpi_agent_errors_total` +- `glpi_agent_category_changes_total` +- `glpi_agent_replies_total` +- `glpi_agent_priority_recommendations_total` +- `glpi_agent_priority_changes_total` +- `glpi_agent_escalation_runs_total` +- `glpi_agent_escalations_total` +- `glpi_agent_context_fetches_total` +- `glpi_agent_context_errors_total` +- `glpi_agent_queue_depth` +- `glpi_agent_glpi_up` +- `glpi_agent_ollama_up` +- `glpi_agent_knowledge_documents` +- `glpi_agent_glpi_kb_up` +- `glpi_agent_glpi_kb_documents` +- `glpi_agent_ollama_node_healthy{node="…"}` +- `glpi_agent_ollama_node_available{node="…"}` +- `glpi_agent_ollama_node_inflight{node="…"}` +- `glpi_agent_ollama_node_requests_total{node="…"}` +- `glpi_agent_ollama_node_failures_total{node="…"}` +- `glpi_agent_ollama_node_average_duration_ms{node="…"}` + +## 9.3 Loginterpretation + +Der Agent schreibt strukturierte JSON-Logs nach stdout. Wichtige Startmeldungen: + +- `web server started` +- `knowledge initialization started in background` +- `persistent knowledge index loaded` oder Aufbaufortschritt +- `GLPI knowledge base synchronized` +- `ticket processing started` +- `initial GLPI ticket poll completed` +- `Ollama pool configured` +- `Ollama node available` beziehungsweise `Ollama node unavailable` + +Der initiale Poll zeigt `fetched`, `already_processed`, `unseen`, `enqueued` und `rejected`. Damit lässt sich unterscheiden, ob GLPI keine Tickets liefert, alle Versionen bereits bekannt sind oder die Queue blockiert. + +# 10. Eskalation im Detail + +## 10.1 Kandidatenauswahl + +Der Scheduler startet sofort und danach alle `ESCALATION_SCAN_INTERVAL`. Er nutzt `GLPI_ESCALATION_FILTER`; ist dieser leer, wird `GLPI_TICKET_FILTER` verwendet. Tickets werden nach Erstellungszeit ausgewählt und erst ab `ESCALATION_MIN_AGE` in die Queue gestellt. + +## 10.2 Deterministische Evidenz + +Vor dem Modell werden berechnet: + +- Ticketalter; +- letzte menschliche Aktivität und Inaktivitätsdauer; +- keine Zuweisung (`AssignedGroups` und `AssignedUsers` leer); +- SLA-Frist aus `time_to_resolve`; +- SLA verletzt oder innerhalb des Risikofensters; +- relevantester Major Incident oberhalb des Schwellwertes. + +Followups des `GLPI_AGENT_USER_ID` zählen nicht als menschliche Aktivität. Jeder andere Followup zählt derzeit als menschlich, auch eine Rückmeldung des Antragstellers. + +## 10.3 Reason Codes + +| Code | Datenbezug/Wirkung | +|---|---| +| `no_human_response` | muss durch Inaktivitätsberechnung belegt sein | +| `unassigned` | muss durch leere Gruppen- und Benutzerzuweisung belegt sein | +| `sla_at_risk` | muss durch Frist innerhalb `ESCALATION_SLA_RISK_WINDOW` belegt sein | +| `sla_breached` | muss durch überschrittene `time_to_resolve` belegt sein | +| `major_incident_candidate` | muss durch relevanten Major-Incident-Kontext belegt sein | +| `security_incident_suspected` | fachlicher Modellgrund; Voraussetzung für Security-Zuweisung | +| `business_deadline` | fachlicher Modellgrund, kann Second-Level unterstützen | +| `no_workaround` | fachlicher Modellgrund, kann Second-Level unterstützen | + +Alle ausgegebenen Codes müssen in `ESCALATION_ALLOWED_REASON_CODES` stehen. Für die deterministisch prüfbaren Codes blockiert eine fehlende Evidenz fail-closed. + +## 10.4 Aktionen und Reihenfolge + +Das Modell darf höchstens drei Aktionen empfehlen. Die Policy dedupliziert und sortiert sie fest: + +1. `assign_security_team` +2. `link_major_incident` +3. `assign_second_level` +4. `raise_priority` +5. `notify_service_owner` +6. `request_manager_review` + +Nur die tatsächlich empfohlenen Aktionen werden ausgeführt; die Reihenfolge verhindert, dass das Modell die Ausführungskette manipuliert. + +## 10.5 Teilweise erfolgreiche Pläne + +Jeder Aktionsschritt besitzt einen eigenen Auditdatensatz. Eine Aktion kann erfolgreich sein, während eine andere fehlschlägt. Erfolgreiche Schritte erhalten sofort ihren dauerhaften Idempotenzschlüssel. Fehlgeschlagene Schritte können in einem späteren Lauf erneut versucht werden. + +Private Notizfehler werden als Warnung am Schritt erfasst; die Hauptaktion kann trotzdem als ausgeführt gelten. Ein Webhookfehler bei Service Owner oder Manager gilt dagegen als Aktionsfehler. + +## 10.6 Webhook + +Der ausgehende Webhook sendet JSON mit Ticket-ID, Entity, Priorität, Stufe, Aktion, Ziel, Reason Codes, Begründung, Confidence und Idempotenzschlüssel. Derselbe Schlüssel steht im Header `Idempotency-Key`. Redirects werden nicht verfolgt. Optional wird `Authorization: Bearer …` gesetzt. + +# 11. Priorisierung im Detail + +## 11.1 Modelloutput + +- `recommended_priority`: 1–6 +- `recommended_impact`: 1–6 +- `recommended_urgency`: 1–6 +- `affected_scope`: `single_user`, `multiple_users`, `site`, `organization`, `unknown` +- `time_criticality`: `low`, `normal`, `high`, `immediate`, `unknown` +- kontrollierte Reason Codes +- Confidence und Begründung + +## 11.2 Erhöhungsgründe + +Standardmäßig freigegeben: + +- `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` + +Neutrale Codes wie `single_user_affected`, `workaround_available` und `insufficient_information` dürfen eine unveränderte Empfehlung erklären, aber keine automatische Erhöhung begründen. + +## 11.3 Fail-open-Eigenschaft + +`PRIORITY_ANALYSIS_TIMEOUT` begrenzt nur den optionalen Prioritätslauf. Timeout, ungültiges JSON oder Modellfehler führen zu `priority_ai_failed`, nicht zum Abbruch der Kategorie- und Antwortpipeline. + +# 12. Knowledge/RAG und automatische Antworten + +## 12.1 Source-Trennung + +- `KNOWLEDGE_ALLOWED_SOURCES`: normale Suche und Antwortkandidaten. +- `KNOWLEDGE_CATEGORY_SOURCES`: nur Kategorieunterstützung; Text/HTML nicht als Antwort nutzbar. +- `KNOWLEDGE_AUTO_REPLY_SOURCES`: Teilmenge der normalen Quellen, die grundsätzlich antworten darf. + +## 12.2 Indexmodi + +- `incremental`: Snapshot sofort laden, Änderungen im Hintergrund einarbeiten. +- `rebuild`: Quellen vollständig neu prüfen und Index neu schreiben. +- `readonly`: ausschließlich kompatiblen Snapshot verwenden; ohne Snapshot Startfehler der Knowledge-Initialisierung. + +Ticketpolling und Worker starten erst nach einem konsistenten lokalen Knowledge-Index. Das Webinterface startet vorher und zeigt den Fortschritt. + +## 12.3 Kategoriekompatibilität + +- `unscoped`: Artikel bleibt nutzbar; unbekannte String-Kategorien blockieren nicht automatisch. +- `skip`: Artikel mit nicht gemappten Kategorien wird ausgelassen. +- `strict`: nicht gemappte Kategorie erzeugt einen Fehler. + +Für gemeinsam genutzte Knowledge-Verzeichnisse ist `unscoped` der kompatibelste Startwert; für streng kontrollierte Auto-Replies ist ein vollständiges Mapping vorzuziehen. + +--- + +# 13. Vollständige ENV-Referenz + +## 13.1 Allgemeine Syntaxregeln + +- **Boolean:** empfohlen ausschließlich `true` oder `false`. +- **Dauer:** Go-Syntax wie `250ms`, `30s`, `5m`, `2h`, `72h`. `1d` ist ungültig; `24h` verwenden. +- **Score/Confidence:** Dezimalpunkt, z. B. `0.88`. +- **Listen:** kommasepariert. Stringlisten erkennen häufig `none` als leere Liste. +- **Templates:** literales `\n` wird bei `envTemplate` in einen Zeilenumbruch umgewandelt. +- **Geheimnisse:** niemals in Diagnoseexporte, Tickets oder Screenshots aufnehmen. +- **Code-Default:** Wert, wenn die Variable nicht gesetzt oder bei vielen Parsern syntaktisch ungültig ist. +- **Beispielwert:** Wert aus der mitgelieferten `.env.example`; er ist nicht automatisch eine sichere Produktionsempfehlung. + +## 00. DEPLOYMENT / IMAGE – CONTAINER REGISTRY +| ENV | Geltungsbereich | Bedeutung und Auswirkung | Mögliche Werte / Format | Code-Default | `.env.example` | Pflicht / Abhängigkeiten | +|---|---|---|---|---|---|---| +| `AGENT_IMAGE` | Compose/optionale KB-App | OCI-Image des Agenten für das Registry-Deployment. | OCI-Image: registry/repository:tag oder registry/repository@sha256:… | nicht vom Agenten gelesen | gitea.example.de/organisation/glpi-ai-agent:latest | Nur docker-compose.registry.yml. | + +## 01. DOCKER COMPOSE - PORTS +| ENV | Geltungsbereich | Bedeutung und Auswirkung | Mögliche Werte / Format | Code-Default | `.env.example` | Pflicht / Abhängigkeiten | +|---|---|---|---|---|---|---| +| `AGENT_PORT` | Compose/optionale KB-App | Veröffentlichter Host-Port des Agent-Dashboards in einer übergeordneten Stack-Konfiguration. | TCP-Port 1–65535; in den aktuellen Compose-Dateien nicht automatisch verwendet. | nicht vom Agenten gelesen | 7080 | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `KB_SEARCH_PORT` | Compose/optionale KB-App | Host-Port der optionalen Knowledge-Suche. | TCP-Port 1–65535. | nicht vom Agenten gelesen | 7081 | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `KB_EDITOR_PORT` | Compose/optionale KB-App | Host-Port der optionalen Knowledge-Administration. | TCP-Port 1–65535. | nicht vom Agenten gelesen | 7082 | Optional; Wirkung abhängig von aktivierten Funktionen. | + +## 02. DOCKER COMPOSE - GEMEINSAME DATENVERZEICHNISSE +| ENV | Geltungsbereich | Bedeutung und Auswirkung | Mögliche Werte / Format | Code-Default | `.env.example` | Pflicht / Abhängigkeiten | +|---|---|---|---|---|---|---| +| `KB_DATA_PATH` | Compose/optionale KB-App | Gemeinsam gemountetes Knowledge-Verzeichnis auf dem Host. | Freier Text beziehungsweise installationsspezifischer Wert. | nicht vom Agenten gelesen | ./knowledge | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `KB_BACKUP_PATH` | Compose/optionale KB-App | Backup-Verzeichnis der optionalen KB-Verwaltung. | Freier Text beziehungsweise installationsspezifischer Wert. | nicht vom Agenten gelesen | ./backups | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `KB_STAGING_PATH` | Compose/optionale KB-App | Staging-Verzeichnis für neu erzeugte oder noch nicht freigegebene KB-Inhalte. | Freier Text beziehungsweise installationsspezifischer Wert. | nicht vom Agenten gelesen | ./staging | Optional; Wirkung abhängig von aktivierten Funktionen. | + +## 03. KNOWLEDGE-BASE WEBANWENDUNGEN – KB EDITOR +| ENV | Geltungsbereich | Bedeutung und Auswirkung | Mögliche Werte / Format | Code-Default | `.env.example` | Pflicht / Abhängigkeiten | +|---|---|---|---|---|---|---| +| `EDITOR_TITLE` | Compose/optionale KB-App | Titel der optionalen KB-Editor-Oberfläche. | Freier Text beziehungsweise installationsspezifischer Wert. | nicht vom Agenten gelesen | KB Administration | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `EDITOR_SUBTITLE` | Compose/optionale KB-App | Untertitel der optionalen KB-Editor-Oberfläche. | Freier Text beziehungsweise installationsspezifischer Wert. | nicht vom Agenten gelesen | Wissensbasis verwalten | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `EDITOR_AUTH_USER` | Compose/optionale KB-App | Basic-Auth-Benutzer der optionalen KB-Editor-Oberfläche. | Freier Text beziehungsweise installationsspezifischer Wert. | nicht vom Agenten gelesen | leer | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `EDITOR_AUTH_PASSWORD` | Compose/optionale KB-App | Basic-Auth-Passwort der optionalen KB-Editor-Oberfläche. | Geheimer Textwert; nicht in Logs, Tickets oder Screenshots veröffentlichen. | nicht vom Agenten gelesen | | Optional; Wirkung abhängig von aktivierten Funktionen. | + +## 03. KNOWLEDGE-BASE WEBANWENDUNGEN – KB SEARCH +| ENV | Geltungsbereich | Bedeutung und Auswirkung | Mögliche Werte / Format | Code-Default | `.env.example` | Pflicht / Abhängigkeiten | +|---|---|---|---|---|---|---| +| `SEARCH_TITLE` | Compose/optionale KB-App | Titel der optionalen KB-Suche. | Freier Text beziehungsweise installationsspezifischer Wert. | nicht vom Agenten gelesen | Stadt Hilden - KB-Datenbank | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `SEARCH_SUBTITLE` | Compose/optionale KB-App | Untertitel der optionalen KB-Suche. | Freier Text beziehungsweise installationsspezifischer Wert. | nicht vom Agenten gelesen | Interne Lösungsdatenbank | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `SEARCH_AUTH_USER` | Compose/optionale KB-App | Basic-Auth-Benutzer der optionalen KB-Suche. | Freier Text beziehungsweise installationsspezifischer Wert. | nicht vom Agenten gelesen | leer | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `SEARCH_AUTH_PASSWORD` | Compose/optionale KB-App | Basic-Auth-Passwort der optionalen KB-Suche. | Geheimer Textwert; nicht in Logs, Tickets oder Screenshots veröffentlichen. | nicht vom Agenten gelesen | | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `AUTO_RELOAD_INTERVAL` | Compose/optionale KB-App | Intervall, in dem die Suchanwendung die KB-Dateien erneut einliest. 30s 60s 5m | Go-Dauer, z. B. 250ms, 30s, 5m, 2h, 72h. Kein Suffix d; 24h statt 1d verwenden. | nicht vom Agenten gelesen | 60s | Optional; Wirkung abhängig von aktivierten Funktionen. | + +## 04. KNOWLEDGE-BASE WEBANWENDUNGEN - OLLAMA +| ENV | Geltungsbereich | Bedeutung und Auswirkung | Mögliche Werte / Format | Code-Default | `.env.example` | Pflicht / Abhängigkeiten | +|---|---|---|---|---|---|---| +| `AI_FALLBACK_ENABLED` | Compose/optionale KB-App | Aktiviert KI-Fallback in den optionalen KB-Webanwendungen, nicht im Agenten. | Freier Text beziehungsweise installationsspezifischer Wert. | nicht vom Agenten gelesen | true | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `OLLAMA_BASE_URL` | Compose/optionale KB-App | Ollama-URL der optionalen KB-Webanwendungen. | Absolute URL; vorzugsweise HTTPS, sofern nicht ausdrücklich lokaler Dienst. | nicht vom Agenten gelesen | http://ollama:11434 | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `OLLAMA_MODEL` | Agent + optionale KB-App | Chat-Modell. Diese Variable wird aktuell sowohl von den KB-Anwendungen als auch vom Agenten verwendet. Dadurch verwenden alle Anwendungen dasselbe Modell. | Freier Text beziehungsweise installationsspezifischer Wert. | qwen3:8b | qwen3:8b | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `OLLAMA_TIMEOUT` | Agent + optionale KB-App | Gemeinsamer Timeout. | Go-Dauer, z. B. 250ms, 30s, 5m, 2h, 72h. Kein Suffix d; 24h statt 1d verwenden. | 10m | 10m | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `OLLAMA_MAX_CONCURRENT` | Agent + optionale KB-App | Rückwärtskompatibler Parallelitätswert. Im Agenten dient er nur als Fallback für `OLLAMA_NODE_MAX_INFLIGHT`, wenn die neue Variable nicht gesetzt ist. | Ganzzahl 1–32. | 1 | 1 | Für neue Pool-Installationen `OLLAMA_NODE_MAX_INFLIGHT` verwenden. | +| `OLLAMA_STAGING_AUTO_REPLY` | Compose/optionale KB-App | Legt fest, ob von KB-Webanwendungen erzeugte Staging-Artikel auto_reply=true erhalten. | Freier Text beziehungsweise installationsspezifischer Wert. | nicht vom Agenten gelesen | false | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `OLLAMA_STAGING_MIN_SCORE` | Compose/optionale KB-App | min_score für von KB-Webanwendungen erzeugte Staging-Artikel. | Dezimalzahl; bei Scores typischerweise 0.0–1.0. | nicht vom Agenten gelesen | 0.70 | Optional; Wirkung abhängig von aktivierten Funktionen. | + +## 05. GLPI AI AGENT - ALLGEMEINER BETRIEB +| ENV | Geltungsbereich | Bedeutung und Auswirkung | Mögliche Werte / Format | Code-Default | `.env.example` | Pflicht / Abhängigkeiten | +|---|---|---|---|---|---|---| +| `DRY_RUN` | Agent | Der Agent analysiert vollständig, schreibt aber keine Änderungen nach GLPI. Durch die Policy freigegebene Aktionen werden tatsächlich ausgeführt. Für Tests / Einführung: true | true \| false | true | true | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `LOG_LEVEL` | Agent | debug info warn error | debug \| info \| warn \| error | info | info | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `HTTP_ADDR` | Agent | HTTP-Listener INNERHALB des Agent-Containers. AGENT_PORT oben bestimmt dagegen den veröffentlichten Host-Port. | Go-Listenadresse, z. B. :7080, 127.0.0.1:7080 oder 0.0.0.0:7080. | :8080 | :7080 | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `DATA_DIR` | Agent | Persistentes Verzeichnis IM Container. Compose mountet: agent-data:/app/data Enthält unter anderem: - Knowledge-Index - Audit/Run-Daten - Category Learning - Managed Knowledge - GLPI-KB-Cache | Freier Text beziehungsweise installationsspezifischer Wert. | ./data | /app/data | Optional; Wirkung abhängig von aktivierten Funktionen. | + +## 06. AGENT WEBUI / API / DIAGNOSE +| ENV | Geltungsbereich | Bedeutung und Auswirkung | Mögliche Werte / Format | Code-Default | `.env.example` | Pflicht / Abhängigkeiten | +|---|---|---|---|---|---|---| +| `WEB_USERNAME` | Agent | Benutzer für Agent-Dashboard, Knowledge-Verwaltung und Diagnose-Cockpit. | Freier Text beziehungsweise installationsspezifischer Wert. | leer | admin | Pflicht, wenn WEB_ALLOW_ANONYMOUS=false. | +| `WEB_PASSWORD` | Agent | Web Password. | Mindestens 12 Zeichen; darf keinen CHANGE_ME-Platzhalter enthalten. | leer | | Pflicht, wenn WEB_ALLOW_ANONYMOUS=false. | +| `WEB_ALLOW_ANONYMOUS` | Agent | Anmeldung erforderlich. Weboberfläche ohne Authentifizierung erreichbar. In Produktion normalerweise false. | true \| false | false | false | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `AI_CONTENT_LABEL_ENABLED` | Agent (derzeit ohne ENV-Bindung) | TrustedNet-Kennzeichnung vor automatisch ausgewählten Antworten. TrustedNet-KI-Badge wird vor Anrede und Antwort eingefügt. keine KI-Kennzeichnung. | true \| false; siehe Hinweis zur aktuellen Build-Abweichung. | effektiv false (Build-Abweichung) | true | Im aktuellen Quellstand nicht durch config.Load eingelesen; siehe bekannte Abweichungen. | + +## 07. OPTIONALER GLPI-WEBHOOK +| ENV | Geltungsbereich | Bedeutung und Auswirkung | Mögliche Werte / Format | Code-Default | `.env.example` | Pflicht / Abhängigkeiten | +|---|---|---|---|---|---|---| +| `WEBHOOK_SECRET` | Agent | Optionales Shared Secret für eingehende GLPI-Webhooks. Der Absender muss dasselbe Secret z. B. über: X-Webhook-Secret übertragen. Leer lassen, falls kein Webhook verwendet wird. | Leer = eingehender Webhook deaktiviert; gesetzt mindestens 24 Zeichen und kein CHANGE_ME-Platzhalter. | leer | | Leer deaktiviert POST /webhook/glpi vollständig. | + +## 08. GLPI 11 / HIGH-LEVEL API / OAUTH2 +| ENV | Geltungsbereich | Bedeutung und Auswirkung | Mögliche Werte / Format | Code-Default | `.env.example` | Pflicht / Abhängigkeiten | +|---|---|---|---|---|---|---| +| `GLPI_URL` | Agent | Glpi Url. | Absolute URL; vorzugsweise HTTPS, sofern nicht ausdrücklich lokaler Dienst. | leer | https://glpi.example.com | Pflicht. | +| `GLPI_API_VERSION` | Agent | Verwendete GLPI High-Level API. | API-Versionssegment, im Projekt für v2.3 ausgelegt. | v2.3 | v2.3 | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `GLPI_CLIENT_ID` | Agent | OAuth2 Service Account. | Geheimer Textwert; nicht in Logs, Tickets oder Screenshots veröffentlichen. | leer | | Pflicht. | +| `GLPI_CLIENT_SECRET` | Agent | Glpi Client Secret. | Geheimer Textwert; nicht in Logs, Tickets oder Screenshots veröffentlichen. | leer | | Pflicht. | +| `GLPI_USERNAME` | Agent | Glpi Username. | Freier Text beziehungsweise installationsspezifischer Wert. | leer | ai | Pflicht. | +| `GLPI_PASSWORD` | Agent | Glpi Password. | Geheimer Textwert; nicht in Logs, Tickets oder Screenshots veröffentlichen. | leer | | Pflicht. | +| `GLPI_AGENT_USER_ID` | Agent | Numerische GLPI-Benutzer-ID des Service-Accounts. Wird unter anderem benötigt, um Agent-Followups von menschlichen Followups unterscheiden zu können. | Positive numerische GLPI-Benutzer-ID; 0 = nicht gesetzt. | 0 | 999 | Pflicht bei AUTO_REPLY=true und AUTO_ESCALATION=true; auch im Shadow Mode zur Aktivitätserkennung empfohlen. | +| `GLPI_ALLOW_INSECURE_HTTP` | Agent | Nur für lokale Testsysteme ohne TLS. Produktion: false | true \| false; true nur für isolierte Tests. | false | false | Optional; Wirkung abhängig von aktivierten Funktionen. | + +## 09. GLPI TICKET-POLLING +| ENV | Geltungsbereich | Bedeutung und Auswirkung | Mögliche Werte / Format | Code-Default | `.env.example` | Pflicht / Abhängigkeiten | +|---|---|---|---|---|---|---| +| `GLPI_ALLOWED_STATUS_IDS` | Agent | Fail-closed Whitelist erlaubter GLPI-Ticketstatus. 1 1,2 Status 1 entspricht typischerweise "Neu". | Kommagetrennte positive Status-IDs, z. B. 1 oder 1,2. | nicht ermittelt | 1 | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `GLPI_POLL_INTERVAL` | Agent | Polling-Intervall. | Go-Dauer, z. B. 250ms, 30s, 5m, 2h, 72h. Kein Suffix d; 24h statt 1d verwenden. | 30s | 30s | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `GLPI_POLL_LIMIT` | Agent | Maximale Anzahl Tickets pro Poll. | Positive Ganzzahl; praktisch passend zur Ticketmenge und API-Latenz wählen. | 50 | 50 | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `GLPI_TICKET_FILTER` | Agent | Optionale serverseitige Vorfilterung. Die Agent-Policy prüft GLPI_ALLOWED_STATUS_IDS anschließend trotzdem selbst. Änderungen der Syntax immer gegen /api.php/doc der eigenen GLPI-Instanz prüfen. | GLPI-High-Level-API-Filterausdruck; Syntax gegen /api.php/doc prüfen. | leer | status.id==1 | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `GLPI_TIMEOUT` | Agent | HTTP-Timeout für GLPI-Aufrufe. | Go-Dauer, z. B. 250ms, 30s, 5m, 2h, 72h. Kein Suffix d; 24h statt 1d verwenden. | 20s | 20s | Optional; Wirkung abhängig von aktivierten Funktionen. | + +## 10. GLPI AI AGENT - OLLAMA +| ENV | Geltungsbereich | Bedeutung und Auswirkung | Mögliche Werte / Format | Code-Default | `.env.example` | Pflicht / Abhängigkeiten | +|---|---|---|---|---|---|---| +| `OLLAMA_URL` | Agent | Rückwärtskompatible Einzelnode-Adresse. Wird nur genutzt, wenn `OLLAMA_URLS` leer ist. | Absolute HTTP-/HTTPS-URL ohne Zugangsdaten. | http://ollama:11434 | http://ollama:11434 | Optional; bei leerem `OLLAMA_URLS` wirksam. | +| `OLLAMA_URLS` | Agent | Kommagetrennte Liste aller Ollama-Nodes. Jeder Node führt vollständige Inferenzrequests aus. | 1..64 absolute HTTP-/HTTPS-URLs, z. B. `http://10.0.0.21:11434,http://10.0.0.22:11434`. Keine Duplikate. | leer; effektiver Fallback auf `OLLAMA_URL` | leer | Für Poolbetrieb erforderlich. | +| `OLLAMA_NODE_NAMES` | Agent | Lesbare, positionsgleiche Namen für Dashboard, Metriken und AnalysisRun-Diagnose. | Kommagetrennte eindeutige, nicht leere Namen; Anzahl exakt wie `OLLAMA_URLS`. Leer = automatisch aus Hostname. | leer | leer | Optional. | +| `OLLAMA_NODE_WEIGHTS` | Agent | Positionsgleiche Leistungsgewichte für `weighted`. Höhere Werte erhalten anteilig mehr Requests. | Kommagetrennte Ganzzahlen 1–100; Anzahl exakt wie `OLLAMA_URLS`. Leer = Gewicht 1 je Node. | leer / effektiv 1 | leer | Nur für `OLLAMA_ROUTING_MODE=weighted`. | +| `OLLAMA_NODE_MAX_INFLIGHT` | Agent | Maximale gleichzeitig laufende Requests **je Node**. | Ganzzahl 1–32. Für integrierte GPUs zunächst 1. | 0 in Parser; effektiver Fallback auf `OLLAMA_MAX_CONCURRENT` = 1 | 1 | Zentraler Ressourcen-Schutz je Node. | +| `OLLAMA_ROUTING_MODE` | Agent | Auswahlstrategie für einen verfügbaren Node. | `least_inflight` \| `round_robin` \| `weighted` \| `fastest_recent` | least_inflight | least_inflight | `least_inflight` für gleichartige Nodes empfohlen. | +| `OLLAMA_NODE_HEALTH_INTERVAL` | Agent | Intervall der `/api/tags`-Prüfung auf Erreichbarkeit, Modelle und Digests. | Go-Dauer >= 1s. | 15s | 15s | Optional. | +| `OLLAMA_NODE_FAILURE_COOLDOWN` | Agent | Sperrzeit nach retryfähigem Requestfehler, um flappende Nodes vorübergehend nicht neu zu belasten. | Go-Dauer >= 0; 0 deaktiviert Cooldown. | 30s | 30s | Optional. | +| `OLLAMA_NODE_REQUEST_TIMEOUT` | Agent | Maximale Dauer eines einzelnen HTTP-Versuchs an genau einen Node. Ein kürzerer Analyse-Kontext-Timeout hat Vorrang. | Go-Dauer > 0. | 0 im Parser; effektiver Fallback auf `OLLAMA_TIMEOUT` = 10m | 10m | Optional. | +| `OLLAMA_FAILOVER_ENABLED` | Agent | Wiederholt einen noch nicht akzeptierten Inferenzrequest bei retryfähigem Fehler auf einem anderen kompatiblen Node. | true \| false | true | true | Kein GLPI-Write findet innerhalb des Failovers statt. | +| `OLLAMA_FAILOVER_ATTEMPTS` | Agent | Maximale Zahl verschiedener Nodes pro HTTP-Request. | 0 = automatisch alle Nodes; sonst Ganzzahl 1 bis Nodeanzahl. | 0 / effektiv Nodeanzahl | 0 | Nur bei aktiviertem Failover. | +| `OLLAMA_REQUIRE_SAME_MODEL_DIGEST` | Agent | Verlangt identische Chat- und erforderliche Embedding-Modelldigests. Bei Abweichung arbeitet der Pool vollständig fail-closed. | true \| false | true | true | Für reproduzierbare Entscheidungen empfohlen. | +| `OLLAMA_REQUIRE_EMBEDDING_MODEL` | Agent | Verlangt das konfigurierte Embedding-Modell auf jedem Node. Bei false dürfen Chat-only-Nodes teilnehmen; Embedding-Requests werden weiterhin nur an Nodes mit Embeddingmodell gesendet. | true \| false | true | true | Bei `RAG_ENABLED=true` empfohlen. | +| `OLLAMA_EMBEDDING_MODEL` | Agent | OLLAMA_MODEL ist bereits oben im gemeinsamen Compose-/Ollama-Bereich gesetzt: OLLAMA_MODEL=qwen3:8b Embedding-Modell für RAG. | Freier Text beziehungsweise installationsspezifischer Wert. | embeddinggemma | embeddinggemma | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `KNOWLEDGE_EMBEDDING_PROFILE` | Agent | Modellspezifisches Retrieval-Prompting. auto Modell automatisch erkennen und passende Retrieval-Prompts verwenden. Für embeddinggemma empfohlen. plain keine modellspezifischen Retrieval-Prompts. | auto \| plain \| embeddinggemma | auto | auto | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `OLLAMA_NUM_PREDICT` | Agent | OLLAMA_TIMEOUT und OLLAMA_MAX_CONCURRENT sind bereits oben gesetzt. Maximale Anzahl generierter Tokens für strukturierte Antworten. | Ganzzahl 1–4096. | 768 | 768 | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `OLLAMA_JSON_RETRIES` | Agent | Wiederholungen bei fehlerhaftem / abgeschnittenem JSON. | Ganzzahl 0–3. | 1 | 1 | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `OLLAMA_KEEP_ALIVE` | Agent | Ollama-Modell nach Benutzung im Speicher halten. 5m 10m 30m | Dauer >= 0; 0 ist zulässig. | 10m | 10m | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `OLLAMA_THINK` | Agent | Thinking bei unterstützten Modellen deaktivieren. Für strukturierte Klassifikations-/Policy-Aufgaben empfohlen. | true \| false | false | false | Optional; Wirkung abhängig von aktivierten Funktionen. | + +## 11. KNOWLEDGE BASE / RAG - BASIS +| ENV | Geltungsbereich | Bedeutung und Auswirkung | Mögliche Werte / Format | Code-Default | `.env.example` | Pflicht / Abhängigkeiten | +|---|---|---|---|---|---|---| +| `KNOWLEDGE_DIR` | Agent | Knowledge-Verzeichnis IM Agent-Container. Compose sollte hierhin KB_DATA_PATH mounten: ${KB_DATA_PATH:-./knowledge}:/app/knowledge:ro | Freier Text beziehungsweise installationsspezifischer Wert. | ./knowledge | /app/knowledge | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `RAG_ENABLED` | Agent | Gesamtes Retrieval-System aktivieren. | true \| false | true | true | Optional; Wirkung abhängig von aktivierten Funktionen. | + +## 12. EXTERNE KNOWLEDGE-KATEGORIEN +| ENV | Geltungsbereich | Bedeutung und Auswirkung | Mögliche Werte / Format | Code-Default | `.env.example` | Pflicht / Abhängigkeiten | +|---|---|---|---|---|---|---| +| `KNOWLEDGE_CATEGORY_MODE` | Agent | Verhalten bei String-/Fremdkategorien, z. B.: "AI-Staging" "Outlook" "E-Mail" "Signatur" unscoped Artikel bleibt nutzbar. Fremdkategorien können als Retrieval-Metadaten dienen. skip Artikel mit unbekannten Kategorien überspringen. strict unbekannte Kategorie als Fehler behandeln. Für eine gemeinsam mit anderen Anwendungen verwendete KB: unscoped | unscoped \| skip \| strict | unscoped | unscoped | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `KNOWLEDGE_CATEGORY_MAP_FILE` | Agent | Optionales Mapping von Fremdkategorien auf GLPI-ITIL-Kategorie-IDs. Beispiel knowledge-category-map.json: { "Outlook": 12, "E-Mail": 12, "Active Directory": 2, "Security": [20,21] } | Freier Text beziehungsweise installationsspezifischer Wert. | leer | /app/data/knowledge-category-map.json | Für den Mapping-Editor zusätzlich KNOWLEDGE_WEB_EDIT_ENABLED=true erforderlich. | +| `KNOWLEDGE_IGNORE_GLOBS` | Agent | Optional bestimmte KB-Dateien ignorieren. KB-SEC-ATTCK-*.json legacy-*.json,external-only-*.json keine zusätzlichen Ignore-Regeln. | Kommagetrennte filepath.Match-Globs; Groß-/Kleinschreibung bleibt erhalten. | leer | leer | Optional; Wirkung abhängig von aktivierten Funktionen. | + +## 13. PERSISTENTER KNOWLEDGE-INDEX +| ENV | Geltungsbereich | Bedeutung und Auswirkung | Mögliche Werte / Format | Code-Default | `.env.example` | Pflicht / Abhängigkeiten | +|---|---|---|---|---|---|---| +| `KNOWLEDGE_INDEX_MODE` | Agent | incremental Persistent gespeicherten Index sofort verwenden. Neue/geänderte Dateien anschließend inkrementell nachziehen. Für Produktion empfohlen. rebuild vollständigen Index neu erzeugen. readonly nur bestehenden Index verwenden, keine Änderungen übernehmen. | incremental \| rebuild \| readonly | incremental | incremental | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `KNOWLEDGE_EMBED_BATCH_SIZE` | Agent | Anzahl Texte pro Embedding-Batch. | 0 oder 1–256. | 64 | 64 | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `KNOWLEDGE_INDEX_SCAN_INTERVAL` | Agent | Intervall für neue/geänderte/gelöschte Dateien. 30s 1m 5m keinen automatischen Hintergrundscan durchführen. | Dauer >= 0; 0 deaktiviert Hintergrundscans. | 5m | 5m | Optional; Wirkung abhängig von aktivierten Funktionen. | + +## 14. RETRIEVAL / DYNAMISCHE KANDIDATENAUSWAHL +| ENV | Geltungsbereich | Bedeutung und Auswirkung | Mögliche Werte / Format | Code-Default | `.env.example` | Pflicht / Abhängigkeiten | +|---|---|---|---|---|---|---| +| `KNOWLEDGE_RETRIEVAL_FLOOR` | Agent | Unterhalb dieses Retrieval-Scores wird eine KB nicht als geeigneter Kandidat betrachtet. Der Wert ist KEINE Wahrscheinlichkeit. | 0.0–1.0. | 0.30 | 0.30 | Optional; Wirkung abhängig von aktivierten Funktionen. | + +## 14. RETRIEVAL / DYNAMISCHE KANDIDATENAUSWAHL – MAX_GAP 0.20 +| ENV | Geltungsbereich | Bedeutung und Auswirkung | Mögliche Werte / Format | Code-Default | `.env.example` | Pflicht / Abhängigkeiten | +|---|---|---|---|---|---|---| +| `KNOWLEDGE_CANDIDATE_MAX_GAP` | Agent | dynamischer Cutoff 0.62 Ein Kandidat mit 0.55 würde dann nicht an die KI gesendet. | 0.0–1.0. | 0.20 | 0.20 | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `KNOWLEDGE_TOP_K` | Agent | Maximale Anzahl Knowledge-Kandidaten, die tatsächlich an Ollama gehen. | 0 oder 1–20; 0 führt im Ticketpfad zum internen Fallback 6. | 6 | 6 | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `KNOWLEDGE_AUDIT_TOP_K` | Agent | Anzahl Kandidaten für Audit / Diagnose. Kann größer als KNOWLEDGE_TOP_K sein. | 0 oder mindestens KNOWLEDGE_TOP_K und höchstens 50. | 10 | 10 | Optional; Wirkung abhängig von aktivierten Funktionen. | + +## 15. HYBRID-RETRIEVAL - RANKING-GEWICHTE +| ENV | Geltungsbereich | Bedeutung und Auswirkung | Mögliche Werte / Format | Code-Default | `.env.example` | Pflicht / Abhängigkeiten | +|---|---|---|---|---|---|---| +| `KNOWLEDGE_WEIGHT_SEMANTIC` | Agent | Die Werte beschreiben die Gewichtung beim KB-Ranking. Summe aktuell: 1.0 Fehlende Metadaten sollen nicht automatisch negativ bewertet werden. Embedding-/Chunk-Semantik. | 0.0–1.0. | 0.45 | 0.45 | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `KNOWLEDGE_WEIGHT_TITLE` | Agent | Ticket-Betreff gegenüber KB-Titel. | 0.0–1.0. | 0.20 | 0.20 | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `KNOWLEDGE_WEIGHT_LEXICAL` | Agent | Lexikalische / sprachliche Übereinstimmung. | 0.0–1.0. | 0.20 | 0.20 | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `KNOWLEDGE_WEIGHT_KEYWORDS` | Agent | KB-Keywords. | 0.0–1.0. | 0.075 | 0.075 | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `KNOWLEDGE_WEIGHT_CATEGORY` | Agent | Kategorie-/Lernsignal. | 0.0–1.0. | 0.075 | 0.075 | Optional; Wirkung abhängig von aktivierten Funktionen. | + +## 16. FINALE EVIDENZ FÜR AUTO-REPLY +| ENV | Geltungsbereich | Bedeutung und Auswirkung | Mögliche Werte / Format | Code-Default | `.env.example` | Pflicht / Abhängigkeiten | +|---|---|---|---|---|---|---| +| `KNOWLEDGE_MIN_SCORE` | Agent | Mindestwert der FINALEN Evidenz. WICHTIG: Das ist nicht der reine Retrieval-Score. Die finale Evidenz kombiniert: - Retrieval - AI Confidence - Kategorieübereinstimmung | 0.0–1.0. | 0.70 | 0.70 | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `KNOWLEDGE_EVIDENCE_WEIGHT_RETRIEVAL` | Agent | Gewicht Retrieval. | >= 0; die Evidenzberechnung normalisiert durch die Summe aktiver Gewichte. | 0.45 | 0.45 | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `KNOWLEDGE_EVIDENCE_WEIGHT_AI` | Agent | Gewicht KI-Auswahl / KI-Confidence. | >= 0; die Evidenzberechnung normalisiert durch die Summe aktiver Gewichte. | 0.35 | 0.35 | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `KNOWLEDGE_EVIDENCE_WEIGHT_CATEGORY` | Agent | Gewicht Kategorieübereinstimmung. | >= 0; die Evidenzberechnung normalisiert durch die Summe aktiver Gewichte. | 0.20 | 0.20 | Optional; Wirkung abhängig von aktivierten Funktionen. | + +## 17. KNOWLEDGE-CHUNKING +| ENV | Geltungsbereich | Bedeutung und Auswirkung | Mögliche Werte / Format | Code-Default | `.env.example` | Pflicht / Abhängigkeiten | +|---|---|---|---|---|---|---| +| `KNOWLEDGE_CHUNK_WORDS` | Agent | Ungefähre Anzahl Wörter pro Dokument-Chunk. | 0 oder 40–1000. | 160 | 160 | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `KNOWLEDGE_CHUNK_OVERLAP_WORDS` | Agent | Überlappung benachbarter Chunks. | >= 0 und kleiner als KNOWLEDGE_CHUNK_WORDS. | 30 | 30 | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `KNOWLEDGE_MAX_CHUNKS_PER_DOC` | Agent | Maximale Anzahl Chunks pro KB-Dokument. | 0 oder 1–100. | 24 | 24 | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `KNOWLEDGE_MAX_QUERY_CHUNKS` | Agent | Maximale Anzahl Query-Chunks bei sehr langen Tickets. | 0 oder 1–200. | 64 | 64 | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `CATEGORY_PROMPT_LIMIT` | Agent | Maximale Anzahl Kategorien im Kategorie-Prompt. | Ganzzahl; 0 bedeutet je nach Variable deaktiviert/nicht gesetzt oder interner Fallback. | 80 | 80 | Optional; Wirkung abhängig von aktivierten Funktionen. | + +## 18. KNOWLEDGE-QUELLEN / TRUST POLICY +| ENV | Geltungsbereich | Bedeutung und Auswirkung | Mögliche Werte / Format | Code-Default | `.env.example` | Pflicht / Abhängigkeiten | +|---|---|---|---|---|---|---| +| `KNOWLEDGE_ALLOWED_SOURCES` | Agent | Quellen für normale Knowledge-Suche und mögliche Antwortkandidaten. Indexiert wird die Vereinigung mit KNOWLEDGE_CATEGORY_SOURCES. internal-kb glpi-kb runbook vendor-docs | Kommagetrennte, kleingeschriebene Source-Namen; mindestens ein Wert. | internal-kb | internal-kb,glpi-kb,vendor-docs,vendor-docs-ms,vendor-docs-linux,vendor-docs-sec | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `KNOWLEDGE_CATEGORY_SOURCES` | Agent | Quellen, die ausschließlich die Kategorieentscheidung unterstützen. Ohne explizite Angabe wird aus Kompatibilitätsgründen KNOWLEDGE_ALLOWED_SOURCES verwendet. Mit "none" wird Knowledge-Einfluss auf die Kategorisierung deaktiviert. | Kommagetrennte Source-Namen; none = keine Kategorie-KB. Nicht gesetzt = Rückfall auf KNOWLEDGE_ALLOWED_SOURCES. | leer | internal-category | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `KNOWLEDGE_AUTO_REPLY_SOURCES` | Agent | Nur diese Quellen dürfen grundsätzlich automatische Antworten liefern. Muss eine Teilmenge von KNOWLEDGE_ALLOWED_SOURCES sein. Beispiel zum kompletten Abschalten: KNOWLEDGE_AUTO_REPLY_SOURCES=none | Kommagetrennte Teilmenge von KNOWLEDGE_ALLOWED_SOURCES; none = keine Knowledge-Quelle für Auto-Reply. | internal-kb | internal-kb,glpi-kb,vendor-docs,vendor-docs-ms,vendor-docs-linux,vendor-docs-sec | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `KNOWLEDGE_WEB_EDIT_ENABLED` | Agent | Webbasierte Bearbeitung von Agent-eigenen Knowledge-Artikeln. Diese werden unter: DATA_DIR/knowledge-managed gespeichert. Das statische KNOWLEDGE_DIR bleibt read-only. | true \| false | false | true | Erfordert WEB_ALLOW_ANONYMOUS=false. | + +## 19. GLPI KNOWLEDGE BASE CONNECTOR +| ENV | Geltungsbereich | Bedeutung und Auswirkung | Mögliche Werte / Format | Code-Default | `.env.example` | Pflicht / Abhängigkeiten | +|---|---|---|---|---|---|---| +| `GLPI_KB_ENABLED` | Agent | GLPI-interne Knowledge Base synchronisieren. | true \| false | false | true | Aktiviert periodische Synchronisierung; Quelle muss in der Index-Source-Union enthalten sein. | +| `GLPI_KB_PATH` | Agent | Agent ermittelt die KnowbaseItem-Route aus /api.php/doc.json. | auto oder absoluter API-Pfad beginnend mit /. | auto | auto | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `GLPI_KB_FILTER` | Agent | Optionaler serverseitiger GLPI-Filter. alle für den Service Account sichtbaren Artikel, begrenzt durch LIMIT. | Freier Text beziehungsweise installationsspezifischer Wert. | leer | leer | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `GLPI_KB_LIMIT` | Agent | Maximale Anzahl GLPI-KB-Artikel. | 1–5000. | 500 | 500 | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `GLPI_KB_SYNC_INTERVAL` | Agent | Synchronisationsintervall. | Dauer >= 1m. | 10m | 10m | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `GLPI_KB_SOURCE` | Agent | source-Wert importierter GLPI-KB-Artikel. | Freier Text beziehungsweise installationsspezifischer Wert. | glpi-kb | glpi-kb | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `GLPI_KB_AUTO_REPLY` | Agent | GLPI-KB-Artikel können grundsätzlich Auto-Replies auslösen. Zusätzlich gelten weiterhin alle anderen Policy-Gates. | true \| false | false | true | Bei true: GLPI_KB_SOURCE muss in normalen und Auto-Reply-Quellen stehen; Kategorie-ID-Whitelist darf nicht leer sein. | +| `GLPI_KB_AUTO_REPLY_CATEGORY_IDS` | Agent | Whitelist der GLPI KNOWLEDGE-BASE-Kategorie-IDs. WICHTIG: Dies sind NICHT die ITIL-/Ticketkategorie-IDs. Mehrere Werte: 1,2,7 | Kommagetrennte positive GLPI-KB-Kategorie-IDs; leer/none = keine. | nicht ermittelt | 1 | Optional; Wirkung abhängig von aktivierten Funktionen. | + +## 20. HUMAN-IN-THE-LOOP / KATEGORIE-LERNEN +| ENV | Geltungsbereich | Bedeutung und Auswirkung | Mögliche Werte / Format | Code-Default | `.env.example` | Pflicht / Abhängigkeiten | +|---|---|---|---|---|---|---| +| `LEARNING_ENABLED` | Agent | Menschlich bestätigte/korrigierte Entscheidungen als Lernbeispiele verwenden. Der Agent lernt NICHT automatisch aus seinen eigenen unbestätigten Entscheidungen. | true \| false | true | true | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `LEARNING_MAX_EXAMPLES` | Agent | Maximale Anzahl gespeicherter Beispiele. | Bei aktiviertem Lernen 1–10000. | 500 | 500 | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `LEARNING_EXAMPLES_PER_CATEGORY` | Agent | Maximale Beispiele pro Kategorie im Prompt. | Bei aktiviertem Lernen 1–20. | 5 | 5 | Optional; Wirkung abhängig von aktivierten Funktionen. | + +## 21. KOMMUNIKATIONSPOLICY +| ENV | Geltungsbereich | Bedeutung und Auswirkung | Mögliche Werte / Format | Code-Default | `.env.example` | Pflicht / Abhängigkeiten | +|---|---|---|---|---|---|---| +| `COMMUNICATION_LANGUAGE` | Agent | Erwartete Sprache von Auto-Reply-KBs. | Freier Text beziehungsweise installationsspezifischer Wert. | de-DE | de-DE | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `COMMUNICATION_STYLE` | Agent | Erwarteter Kommunikationsstil. | formal \| neutral \| informal | formal | formal | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `COMMUNICATION_SALUTATION` | Agent | Wird vor die Knowledge-Antwort gesetzt. | Freier Text beziehungsweise installationsspezifischer Wert. | Guten Tag, | Guten Tag, | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `COMMUNICATION_CLOSING` | Agent | Abschluss. | Freier Text beziehungsweise installationsspezifischer Wert. | Mit freundlichen Grüßen | Mit freundlichen Grüßen | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `COMMUNICATION_SIGNATURE` | Agent | Communication Signature. | Freier Text beziehungsweise installationsspezifischer Wert. | IT-Service | IT-Service | Optional; Wirkung abhängig von aktivierten Funktionen. | + +## 22. OPERATIONAL CONTEXT - GLOBAL +| ENV | Geltungsbereich | Bedeutung und Auswirkung | Mögliche Werte / Format | Code-Default | `.env.example` | Pflicht / Abhängigkeiten | +|---|---|---|---|---|---|---| +| `CONTEXT_ENABLED` | Agent | Globaler Schalter für zusätzliche Betriebsinformationen: - Changes - Major Incidents - Requester-Geräte - Uptime Kuma | true \| false | true | true | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `CONTEXT_TIMEOUT` | Agent | Timeout für Kontextabfragen. | Go-Dauer, z. B. 250ms, 30s, 5m, 2h, 72h. Kein Suffix d; 24h statt 1d verwenden. | 12s | 12s | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `CONTEXT_RELEVANCE_MIN_SCORE` | Agent | Mindestscore, ab dem Incident/Outage als für das Ticket relevant gilt. | 0.0–1.0. | 0.20 | 0.20 | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `CONTEXT_BLOCK_AUTO_REPLY_ON_ERRORS` | Agent | Fehler einer aktivierten Kontextquelle blockieren Auto-Reply. Fail-closed und für Produktion empfohlen. | true \| false | true | true | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `CONTEXT_BLOCK_AUTO_REPLY_ON_INCIDENT` | Agent | relevante zentrale Störung blockiert individuelle Standardantwort. | true \| false | true | true | Optional; Wirkung abhängig von aktivierten Funktionen. | + +## 23. GLPI CHANGE CALENDAR +| ENV | Geltungsbereich | Bedeutung und Auswirkung | Mögliche Werte / Format | Code-Default | `.env.example` | Pflicht / Abhängigkeiten | +|---|---|---|---|---|---|---| +| `CHANGE_CALENDAR_ENABLED` | Agent | Change Calendar Enabled. | true \| false | true | true | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `GLPI_CHANGE_PATH` | Agent | API-Route. | Absoluter API-Pfad, z. B. /Assistance/Change. | /Assistance/Change | /Assistance/Change | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `GLPI_CHANGE_FILTER` | Agent | Optionaler serverseitiger GLPI-Filter. | Freier Text beziehungsweise installationsspezifischer Wert. | leer | leer | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `GLPI_CHANGE_LIMIT` | Agent | Maximale Anzahl geladener Changes. | 1–1000. | 100 | 100 | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `CHANGE_LOOKBACK` | Agent | Betrachteter Zeitraum in der Vergangenheit. | Go-Dauer, z. B. 250ms, 30s, 5m, 2h, 72h. Kein Suffix d; 24h statt 1d verwenden. | 48h | 72h | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `CHANGE_LOOKAHEAD` | Agent | Betrachteter Zeitraum in der Zukunft. | Go-Dauer, z. B. 250ms, 30s, 5m, 2h, 72h. Kein Suffix d; 24h statt 1d verwenden. | 24h | 24h | Optional; Wirkung abhängig von aktivierten Funktionen. | + +## 24. MAJOR INCIDENTS +| ENV | Geltungsbereich | Bedeutung und Auswirkung | Mögliche Werte / Format | Code-Default | `.env.example` | Pflicht / Abhängigkeiten | +|---|---|---|---|---|---|---| +| `MAJOR_INCIDENTS_ENABLED` | Agent | Major Incidents über GLPI-Tickets ermitteln. Erst aktivieren, wenn GLPI_MAJOR_INCIDENT_FILTER getestet wurde. | true \| false | false | false | Bei true ist GLPI_MAJOR_INCIDENT_FILTER Pflicht. | +| `GLPI_MAJOR_INCIDENT_FILTER` | Agent | Expliziter Filter für Tickets, die als Major Incident gelten. | Freier Text beziehungsweise installationsspezifischer Wert. | leer | leer | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `GLPI_MAJOR_INCIDENT_LIMIT` | Agent | Glpi Major Incident Limit. | 1–500. | 20 | 20 | Optional; Wirkung abhängig von aktivierten Funktionen. | + +## 25. REQUESTER -> GERÄT / ASSET CONTEXT +| ENV | Geltungsbereich | Bedeutung und Auswirkung | Mögliche Werte / Format | Code-Default | `.env.example` | Pflicht / Abhängigkeiten | +|---|---|---|---|---|---|---| +| `USER_DEVICE_CONTEXT_ENABLED` | Agent | Zusätzlich zu direkt verknüpften Ticket-Assets Geräte des Requesters suchen. | true \| false | true | true | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `GLPI_USER_DEVICE_PATHS` | Agent | Asset-Routen. | Kommagetrennte absolute API-Pfade. | /Assets/Computer | /Assets/Computer | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `GLPI_USER_DEVICE_FILTER_TEMPLATE` | Agent | {{user_id}} wird vom Agenten ersetzt. | Filtertext mit zwingendem Platzhalter {{user_id}}. | user.id=={{user_id}} | user.id=={{user_id}} | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `GLPI_USER_DEVICE_LIMIT` | Agent | Maximale Anzahl Geräte je Suche. | 1–500. | 20 | 20 | Optional; Wirkung abhängig von aktivierten Funktionen. | + +## 26. UPTIME KUMA +| ENV | Geltungsbereich | Bedeutung und Auswirkung | Mögliche Werte / Format | Code-Default | `.env.example` | Pflicht / Abhängigkeiten | +|---|---|---|---|---|---|---| +| `UPTIME_KUMA_ENABLED` | Agent | Globaler Schalter für Uptime-Kuma-Kontext. | true \| false | false | false | Bei true: URL Pflicht; metrics benötigt API-Key, status_page benötigt Slugs. | +| `UPTIME_KUMA_URL` | Agent | Uptime Kuma Url. | Absolute URL; vorzugsweise HTTPS, sofern nicht ausdrücklich lokaler Dienst. | leer | https://uptime.example.com | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `UPTIME_KUMA_MODE` | Agent | metrics authentifizierte Prometheus-Metrics. status_page öffentliche/publizierte Statusseiten. | metrics \| status_page | metrics | metrics | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `UPTIME_KUMA_API_KEY` | Agent | Nur in metrics erforderlich. | Geheimer Textwert; nicht in Logs, Tickets oder Screenshots veröffentlichen. | leer | | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `UPTIME_KUMA_STATUS_PAGES` | Agent | Nur in status_page erforderlich. Mehrere Slugs: it-services,network,applications | Kommagetrennte Liste; Leerzeichen werden an den Rändern entfernt. | leer | it-services | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `UPTIME_KUMA_TIMEOUT` | Agent | Uptime Kuma Timeout. | Go-Dauer, z. B. 250ms, 30s, 5m, 2h, 72h. Kein Suffix d; 24h statt 1d verwenden. | 10s | 10s | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `UPTIME_KUMA_MAX_ISSUES` | Agent | Maximale Anzahl gleichzeitig berücksichtigter Probleme. | 1–200. | 20 | 20 | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `UPTIME_KUMA_INCLUDE_MAINTENANCE` | Agent | Maintenance ebenfalls als Kontext berücksichtigen. | true \| false | true | true | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `CONTEXT_STATUS_REPLY_ENABLED` | Agent | Optional: bei eindeutig passender Uptime-Kuma-Störung oder Wartung einen ausschließlich vom Betreiber vorgegebenen Text senden. Die KI erzeugt keinen Antworttext; sie wählt nur einen aktiven Kandidaten und liefert eine Confidence. | true \| false | false | false | Erfordert CONTEXT_ENABLED=true, UPTIME_KUMA_ENABLED=true und beide vordefinierten Textvorlagen. | +| `CONTEXT_STATUS_REPLY_MIN_RELEVANCE` | Agent | Context Status Reply Min Relevance. | 0.0–1.0. | 0.50 | 0.50 | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `CONTEXT_STATUS_REPLY_MIN_AI_CONFIDENCE` | Agent | Context Status Reply Min Ai Confidence. | 0.0–1.0. | 0.80 | 0.80 | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `CONTEXT_STATUS_REPLY_MIN_FINAL_SCORE` | Agent | Finaler Score = Relevanz × KI-Confidence. | 0.0–1.0. | 0.45 | 0.45 | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `CONTEXT_INCIDENT_REPLY_TEXT` | Agent | Literal \n wird als Zeilenumbruch interpretiert. Verfügbare Platzhalter: {{service_name}}, {{status}}, {{status_page}}, {{message}}, {{incident_title}}, {{incident_content}}, {{last_heartbeat}} | Textvorlage; literales \n wird zu einem Zeilenumbruch. Nur dokumentierte Platzhalter verwenden. | leer | Zu Ihrer Meldung liegt derzeit wahrscheinlich eine zentrale Störung bei {{service_name}} vor. Die Einschränkung kann damit zusammenhängen. Wir beobachten den Status. | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `CONTEXT_MAINTENANCE_REPLY_TEXT` | Agent | Context Maintenance Reply Text. | Textvorlage; literales \n wird zu einem Zeilenumbruch. Nur dokumentierte Platzhalter verwenden. | leer | Für {{service_name}} läuft derzeit eine Wartung. Die von Ihnen beschriebene Einschränkung kann damit zusammenhängen. Bitte testen Sie den Dienst nach Abschluss der Wartung erneut. | Optional; Wirkung abhängig von aktivierten Funktionen. | + +## 27. POLICY-GATES +| ENV | Geltungsbereich | Bedeutung und Auswirkung | Mögliche Werte / Format | Code-Default | `.env.example` | Pflicht / Abhängigkeiten | +|---|---|---|---|---|---|---| +| `AUTO_CATEGORY` | Agent | Automatische Kategorisierung zulassen. | true \| false | true | true | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `AUTO_REPLY` | Agent | Automatische Antworten grundsätzlich zulassen. DRY_RUN=true verhindert trotzdem das tatsächliche Schreiben nach GLPI. | true \| false | false | true | true erfordert GLPI_AGENT_USER_ID und mindestens eine Auto-Reply-Quelle. | +| `CATEGORY_CONFIDENCE` | Agent | Mindestconfidence der KI für Kategorieänderungen. | 0.0–1.0. | 0.90 | 0.90 | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `REPLY_CONFIDENCE` | Agent | Mindestconfidence der KI für Antwortauswahl. Dies allein reicht NICHT für Auto-Reply. Zusätzlich gelten unter anderem: - Knowledge-Evidenz - Retrieval-Regeln - Source Policy - KB auto_reply - Kommunikationspolicy - Followup-Prüfung - Kontext-/Incident-Regeln - zweite Followup-Prüfung unmittelbar vor dem Schreiben | 0.0–1.0. | 0.97 | 0.97 | Optional; Wirkung abhängig von aktivierten Funktionen. | + +## 28. KI-PRIORISIERUNG +| ENV | Geltungsbereich | Bedeutung und Auswirkung | Mögliche Werte / Format | Code-Default | `.env.example` | Pflicht / Abhängigkeiten | +|---|---|---|---|---|---|---| +| `PRIORITY_ENABLED` | Agent | Separater KI-Lauf zur Empfehlung der GLPI-Priorität. Der Lauf wird im Diagnose-Cockpit unabhängig von Kategorie, Status und Antwort gespeichert. | true \| false | true | true | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `AUTO_PRIORITY` | Agent | Standardmäßig Shadow Mode: Empfehlung und Policy-Gates werden protokolliert, GLPI wird nicht verändert. Für Live-Schreibzugriffe zusätzlich DRY_RUN=false. | true \| false | false | false | true erfordert PRIORITY_ENABLED=true; tatsächlicher Write zusätzlich DRY_RUN=false. | +| `PRIORITY_CONFIDENCE` | Agent | Priority Confidence. | 0.0–1.0. | 0.88 | 0.88 | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `PRIORITY_ANALYSIS_TIMEOUT` | Agent | Eigener Fail-open-Timeout für diesen optionalen KI-Lauf. Kategorie und Antwort laufen danach weiter. | Dauer >= 0; 0 = kein eigener Stufen-Timeout. | 45s | 45s | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `PRIORITY_MAX_INCREASE` | Agent | Automatische Erhöhung je Ticketlauf; Herabstufungen sind grundsätzlich gesperrt. | 0–5; bei AUTO_PRIORITY=true mindestens 1. | 1 | 1 | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `PRIORITY_ALLOWED_REASON_CODES` | Agent | Nur kontrollierte, kommaseparierte Grundcodes dürfen eine Empfehlung tragen. | Kommagetrennte Reason Codes; bei PRIORITY_ENABLED=true mindestens einer. | 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 | 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 | Optional; Wirkung abhängig von aktivierten Funktionen. | + +## 29. ZEITGESTEUERTE KI-ESKALATION +| ENV | Geltungsbereich | Bedeutung und Auswirkung | Mögliche Werte / Format | Code-Default | `.env.example` | Pflicht / Abhängigkeiten | +|---|---|---|---|---|---|---| +| `ESCALATION_ENABLED` | Agent | Unabhängiger Scheduler. Er prüft offene Tickets auch ohne Änderung von date_mod. | true \| false | false | false | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `AUTO_ESCALATION` | Agent | Standardmäßig werden nur Diagnose-/Shadow-Läufe erzeugt. Live-Ausführung benötigt zusätzlich DRY_RUN=false und GLPI_AGENT_USER_ID. | true \| false | false | false | true erfordert ESCALATION_ENABLED=true, mindestens eine ausführbare Aktion, Zielkonfiguration und DRY_RUN=false für Writes. | +| `ESCALATION_SCAN_INTERVAL` | Agent | Escalation Scan Interval. | Dauer >= 1m. | 15m | 15m | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `ESCALATION_MIN_AGE` | Agent | Mindestalter des Tickets seit date_creation, bevor es in den Eskalationsscan gelangt. | Dauer >= 1m. | 4h | 4h | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `ESCALATION_MIN_INACTIVITY` | Agent | Mindestdauer seit der letzten menschlichen Aktivität für den Grund no_human_response. SLA-, Security- und Major-Incident-Gründe können unabhängig davon greifen. Agent-Followups werden über GLPI_AGENT_USER_ID ausgenommen. | 0 oder Dauer >= 1m; 0 verwendet ESCALATION_MIN_AGE. | 2h | 2h | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `ESCALATION_ANALYSIS_TIMEOUT` | Agent | Eigenes KI-Zeitbudget; blockiert die normalen Ticketläufe nicht unbegrenzt. | Dauer >= 0; 0 = kein eigener Stufen-Timeout. | 45s | 45s | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `ESCALATION_CONFIDENCE` | Agent | Escalation Confidence. | 0.0–1.0. | 0.88 | 0.88 | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `ESCALATION_MAX_LEVEL` | Agent | Escalation Max Level. | 1–4. | 3 | 3 | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `ESCALATION_SLA_RISK_WINDOW` | Agent | Zeitfenster vor time_to_resolve, in dem sla_at_risk deterministisch wahr wird. | Dauer >= 0; 0 deaktiviert sla_at_risk. | 2h | 2h | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `ESCALATION_SERVICE_OWNER_MIN_LEVEL` | Agent | Aktionsspezifische Mindeststufen. | 0 oder 1–4; 0 ergibt Laufzeit-Fallback Stufe 2. | 2 | 2 | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `ESCALATION_MANAGER_REVIEW_MIN_LEVEL` | Agent | Escalation Manager Review Min Level. | 0 oder 1–4; 0 ergibt Laufzeit-Fallback Stufe 3. | 3 | 3 | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `ESCALATION_MAJOR_INCIDENT_MIN_RELEVANCE` | Agent | Mindest-Relevanz eines vom Kontextkollektor gelieferten Major Incidents. | 0.0–1.0. | 0.50 | 0.50 | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `ESCALATION_ALLOWED_REASON_CODES` | Agent | Escalation Allowed Reason Codes. | Kommagetrennte kontrollierte Eskalationsgründe; mindestens einer bei aktivierter Eskalation. | no_human_response,sla_at_risk,sla_breached,business_deadline,no_workaround,security_incident_suspected,unassigned,major_incident_candidate | no_human_response,sla_at_risk,sla_breached,business_deadline,no_workaround,security_incident_suspected,unassigned,major_incident_candidate | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `ESCALATION_ALLOWED_ACTIONS` | Agent | Jede Aktion muss einzeln freigegeben werden. Sichere Einführung: zunächst nur none,raise_priority; weitere Aktionen erst nach Konfiguration der Ziele aktivieren. Verfügbar: none,raise_priority,assign_second_level,assign_security_team, notify_service_owner,link_major_incident,request_manager_review | none \| raise_priority \| assign_second_level \| assign_security_team \| notify_service_owner \| link_major_incident \| request_manager_review; kommasepariert. | none,raise_priority | none,raise_priority | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `ESCALATION_SECOND_LEVEL_GROUP_ID` | Agent | Zielgruppen/-benutzer für Zuweisungs- und Benachrichtigungsaktionen. Es handelt sich um numerische GLPI-IDs. | Numerische GLPI-Gruppen-ID; 0 = nicht konfiguriert. | 0 | 0 | Pflicht im Livebetrieb, wenn assign_second_level freigegeben ist. | +| `ESCALATION_SECURITY_GROUP_ID` | Agent | Escalation Security Group Id. | Numerische GLPI-Gruppen-ID; 0 = nicht konfiguriert. | 0 | 0 | Pflicht im Livebetrieb, wenn assign_security_team freigegeben ist. | +| `ESCALATION_SERVICE_OWNER_GROUP_ID` | Agent | Escalation Service Owner Group Id. | Numerische GLPI-Gruppen-ID; 0 = nicht konfiguriert. | 0 | 0 | Mindestens Gruppe, Benutzer oder Webhook für notify_service_owner. | +| `ESCALATION_SERVICE_OWNER_USER_ID` | Agent | Escalation Service Owner User Id. | Numerische GLPI-Benutzer-ID; 0 = nicht konfiguriert. | 0 | 0 | Mindestens Gruppe, Benutzer oder Webhook für notify_service_owner. | +| `ESCALATION_MANAGER_REVIEW_GROUP_ID` | Agent | Escalation Manager Review Group Id. | Numerische GLPI-Gruppen-ID; 0 = nicht konfiguriert. | 0 | 0 | Mindestens Gruppe, Benutzer oder Webhook für request_manager_review. | +| `ESCALATION_MANAGER_REVIEW_USER_ID` | Agent | Escalation Manager Review User Id. | Numerische GLPI-Benutzer-ID; 0 = nicht konfiguriert. | 0 | 0 | Mindestens Gruppe, Benutzer oder Webhook für request_manager_review. | +| `ESCALATION_ADD_PRIVATE_FOLLOWUP` | Agent | Zu jeder ausgeführten Aktion kann ein privater GLPI-Followup geschrieben werden. | true \| false | true | true | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `ESCALATION_SECOND_LEVEL_NOTE` | Agent | Escalation Second Level Note. | Textvorlage; literales \n wird zu einem Zeilenumbruch. Nur dokumentierte Platzhalter verwenden. | Automatische Eskalation Stufe {{level}}: Übergabe an den Second-Level-Support. Gründe: {{reason_codes}}. KI-Begründung: {{reason}} | Automatische Eskalation Stufe {{level}}: Übergabe an den Second-Level-Support. Gründe: {{reason_codes}}. KI-Begründung: {{reason}} | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `ESCALATION_SECURITY_NOTE` | Agent | Escalation Security Note. | Textvorlage; literales \n wird zu einem Zeilenumbruch. Nur dokumentierte Platzhalter verwenden. | Automatische Eskalation Stufe {{level}}: Übergabe an das Security-Team. Gründe: {{reason_codes}}. KI-Begründung: {{reason}} | Automatische Eskalation Stufe {{level}}: Übergabe an das Security-Team. Gründe: {{reason_codes}}. KI-Begründung: {{reason}} | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `ESCALATION_SERVICE_OWNER_NOTE` | Agent | Escalation Service Owner Note. | Textvorlage; literales \n wird zu einem Zeilenumbruch. Nur dokumentierte Platzhalter verwenden. | Automatische Eskalation Stufe {{level}}: Service Owner wurde zur Prüfung einbezogen. Gründe: {{reason_codes}}. KI-Begründung: {{reason}} | Automatische Eskalation Stufe {{level}}: Service Owner wurde zur Prüfung einbezogen. Gründe: {{reason_codes}}. KI-Begründung: {{reason}} | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `ESCALATION_MAJOR_INCIDENT_NOTE` | Agent | Escalation Major Incident Note. | Textvorlage; literales \n wird zu einem Zeilenumbruch. Nur dokumentierte Platzhalter verwenden. | Automatische Eskalation Stufe {{level}}: Verknüpfung mit Major Incident #{{major_incident_id}} ({{major_incident_name}}). Relevanz: {{major_incident_score}}. Gründe: {{reason_codes}}. | Automatische Eskalation Stufe {{level}}: Verknüpfung mit Major Incident #{{major_incident_id}} ({{major_incident_name}}). Relevanz: {{major_incident_score}}. Gründe: {{reason_codes}}. | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `ESCALATION_MANAGER_REVIEW_NOTE` | Agent | Escalation Manager Review Note. | Textvorlage; literales \n wird zu einem Zeilenumbruch. Nur dokumentierte Platzhalter verwenden. | Automatische Eskalation Stufe {{level}}: Management-Review angefordert. Gründe: {{reason_codes}}. KI-Begründung: {{reason}} | Automatische Eskalation Stufe {{level}}: Management-Review angefordert. Gründe: {{reason_codes}}. KI-Begründung: {{reason}} | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `ESCALATION_WEBHOOK_URL` | Agent | Optionaler ausgehender Webhook für Service-Owner- und Management-Benachrichtigungen. Das Token wird nie über die Status-API ausgegeben. | Absolute http(s)-URL; HTTP nur mit ESCALATION_WEBHOOK_ALLOW_INSECURE_HTTP=true. | leer | leer | Optional; Ziel für Service-Owner-/Management-Benachrichtigungen. | +| `ESCALATION_WEBHOOK_BEARER_TOKEN` | Agent | Escalation Webhook Bearer Token. | Geheimer Textwert; nicht in Logs, Tickets oder Screenshots veröffentlichen. | leer | leer | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `ESCALATION_WEBHOOK_TIMEOUT` | Agent | Escalation Webhook Timeout. | Dauer > 0, wenn eine URL gesetzt ist. | 10s | 10s | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `ESCALATION_WEBHOOK_ALLOW_INSECURE_HTTP` | Agent | Nur für isolierte Testnetze; HTTPS ist der sichere Standard. | true \| false; true nur für isolierte Tests. | false | false | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `GLPI_ESCALATION_GROUP_PATCH_FIELD` | Agent | GLPI-Adapter für Zuweisungen. Die Feldnamen müssen zur OpenAPI-Beschreibung der konkreten GLPI-Installation passen. Unterstützte Payload-Formen: assigned_groups/assigned_users = Liste von {"id":...}; group/group_tech/user/user_tech = einzelnes {"id":...}. | Einfacher JSON-Feldname aus Buchstaben, Ziffern und Unterstrich. | assigned_groups | assigned_groups | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `GLPI_ESCALATION_USER_PATCH_FIELD` | Agent | Glpi Escalation User Patch Field. | Einfacher JSON-Feldname aus Buchstaben, Ziffern und Unterstrich. | assigned_users | assigned_users | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `GLPI_ESCALATION_ITIL_LINK_PATH` | Agent | Installationsspezifischer Adapter für link_major_incident. Beide Werte sind erforderlich. Platzhalter im Pfad/JSON: {{ticket_id}}, {{source_ticket_id}}, {{major_incident_id}}, {{target_ticket_id}}. | Absoluter API-Pfad ohne Query/Fragment, mit Ticket-/Major-Incident-Platzhaltern. | leer | leer | Gemeinsam mit GLPI_ESCALATION_ITIL_LINK_BODY; Pflicht für live link_major_incident. | +| `GLPI_ESCALATION_ITIL_LINK_BODY` | Agent | Glpi Escalation Itil Link Body. | Gültiges JSON nach Platzhalterersetzung; muss Quell- und Ziel-ID referenzieren. | leer | leer | Gemeinsam mit GLPI_ESCALATION_ITIL_LINK_PATH; Pflicht für live link_major_incident. | +| `GLPI_ESCALATION_FILTER` | Agent | Leer = GLPI_TICKET_FILTER verwenden. Für Produktion ausdrücklich auf offene, eskalierbare Status und die gewünschte Einheit beschränken. | GLPI-Filter; leer = GLPI_TICKET_FILTER. | leer | leer | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `GLPI_ESCALATION_LIMIT` | Agent | Glpi Escalation Limit. | 1–1000. | 100 | 100 | Optional; Wirkung abhängig von aktivierten Funktionen. | + +## 30. WORKER / PRIORITÄTSQUEUE +| ENV | Geltungsbereich | Bedeutung und Auswirkung | Mögliche Werte / Format | Code-Default | `.env.example` | Pflicht / Abhängigkeiten | +|---|---|---|---|---|---|---| +| `QUEUE_SIZE` | Agent | Maximale Anzahl wartender Jobs. | Ganzzahl >= 1. | 256 | 256 | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `WORKERS` | Agent | Parallele Ticket-Worker. Darf größer als die Gesamtzahl gleichzeitig verfügbarer Node-Slots sein. Ollama wird je Node durch OLLAMA_NODE_MAX_INFLIGHT begrenzt. | Ganzzahl >= 1. | 2 | 2 | Optional; Wirkung abhängig von aktivierten Funktionen. | + +> **Vollständigkeitskontrolle:** In dieser Referenz sind 178 Variablen beschrieben, einschließlich `AGENT_IMAGE` aus dem Registry-Compose und aller 177 Zuweisungen aus `.env.example`. + + +# 14. Fehlerbehebung + +## 14.1 Keine Tickets werden verarbeitet + +1. Dashboard-Pollhinweis lesen. +2. `fetched=0`: GLPI-Filter, Rechte und API prüfen. +3. `fetched>0`, `unseen=0`: alle Treffer stehen in `state-index.json`; neues/geändertes Ticket oder manuelle Neuanalyse verwenden. +4. `unseen>0`, `enqueued=0`, `rejected>0`: Queue voll oder Trigger bereits pending. +5. `enqueued>0`, aber kein Run: Worker, Ollama-Limit und Logs prüfen. +6. `knowledge_ready=false`: erster Indexaufbau läuft oder ist fehlgeschlagen; Ticketverarbeitung wartet. + +## 14.2 Knowledge bleibt nicht bereit + +- `KNOWLEDGE_DIR` existiert und ist lesbar? +- `DATA_DIR` schreibbar? +- Embeddingmodell vorhanden? +- `KNOWLEDGE_INDEX_MODE=readonly` ohne Snapshot? +- Ungültiges JSON, Source nicht erlaubt oder `strict`-Kategoriefehler? +- `/api/status` Felder `knowledge_init_error` und `knowledge_last_scan_error` prüfen. + +## 14.3 Agent startet nicht + +Häufige Konfigurationsfehler: + +- fehlende GLPI-Pflichtvariablen; +- Webpasswort unter 12 Zeichen; +- HTTP-GLPI ohne ausdrückliche Testfreigabe; +- Auto-Reply ohne Agent-Benutzer-ID; +- Auto-Priority ohne Priority-Analyse; +- Auto-Escalation ohne Aktion/Ziel; +- Major Incidents ohne Filter; +- Uptime Kuma im falschen Modus ohne Key/Slug; +- Statusreply ohne Templates; +- ungültiger ITIL-Linkadapter. + +## 14.4 Auto-Reply wird nicht geschrieben + +In der Diagnose die blockierenden Gates prüfen: vorhandener Followup, KI-Ablehnung, Confidence, Source, `auto_reply`, Sprache, Stil, Retrieval-Floor, finale Evidenz, Kategorie-Scope, Kontextfehler, relevanter Incident oder Ticketänderung vor Write. + +## 14.5 Eskalationsaktion bleibt im Shadow Mode + +Ein Schritt ist nur live, wenn gleichzeitig gilt: + +```env +ESCALATION_ENABLED=true +AUTO_ESCALATION=true +DRY_RUN=false +``` + +Zusätzlich müssen Aktion, Ziel, Mindeststufe, Reason Codes, Evidenz, Confidence und Idempotenz passen. + +## 14.6 Port nicht erreichbar + +Listener `HTTP_ADDR` und Container-Mapping müssen denselben Containerport verwenden. Bei nativem Betrieb Firewall und Bind-Adresse prüfen. `127.0.0.1` erlaubt nur lokalen Zugriff; `:7080` bindet alle Interfaces. + +## 14.7 Ollama-Pool hat keine verfügbaren Nodes + +1. `/api/status` prüfen: `ollama_nodes`, `healthy`, `compatible`, `last_error` und Digests. +2. Auf jedem Node `OLLAMA_MODEL` und `OLLAMA_EMBEDDING_MODEL` installieren. +3. Bei Digest-Abweichung die Modell-Tags auf allen Nodes erneut auf denselben Stand ziehen; nicht vorschnell `OLLAMA_REQUIRE_SAME_MODEL_DIGEST=false` setzen. +4. Firewall prüfen: Der Agent muss `/api/tags`, `/api/chat` und `/api/embed` erreichen. +5. `OLLAMA_NODE_MAX_INFLIGHT=1` verwenden und prüfen, ob Requests nur wegen voller Slots warten. +6. Nach einem Fehler `cooldown_until` beachten; der Node wird während des Cooldowns absichtlich nicht gewählt. +7. Neue AnalysisRuns unter `provider.attempts` prüfen. Dort stehen Node, HTTP-Status, Timeout, Retryfähigkeit und Failover. + +## 14.8 Pool verteilt nicht wie erwartet + +- `least_inflight` verteilt nach aktuell laufenden Requests, nicht streng abwechselnd. Bei seriellen Tests kann daher derselbe schnellere Node mehrfach gewählt werden. +- `round_robin` für eine sichtbar zyklische Verteilung verwenden. +- `weighted` benötigt positionsgleiche `OLLAMA_NODE_WEIGHTS`. +- `fastest_recent` bevorzugt die gemessene gleitende Durchschnittslaufzeit und kann langsame Nodes bewusst selten verwenden. +- Ein einzelner KI-Request wird nicht über mehrere Rechner beschleunigt; der Nutzen entsteht bei mehreren parallelen Tickets oder Analyseläufen. + +# 15. Bekannte Grenzen und Abweichungen + +1. **`AI_CONTENT_LABEL_ENABLED`:** Das Feld ist im Modell und in der Policy vorhanden, wird im vorliegenden `config.Load()` aber nicht aus der ENV geladen. Bei normalem Start bleibt der effektive Wert daher `false`, unabhängig von `.env.example`. Vor Nutzung der Kennzeichnung ist eine Codekorrektur erforderlich. +2. **Compose-Portabweichung:** `.env.example` setzt `HTTP_ADDR=:7080`; `docker-compose.yml` mappt jedoch `8080:8080`. Unverändert zusammen verwendet sind Listener und Mapping inkonsistent. `compose_local.yml` passt zu 7080. +3. **`AGENT_PORT`:** Wird in den vorliegenden Compose-Dateien nicht referenziert und ändert den Agent-Listener nicht. Maßgeblich ist `HTTP_ADDR` plus Port-Mapping. +4. **Optionale KB-Webanwendungen:** Die ENV-Blöcke für Editor/Search/Fallback gehören zu einem größeren Stack. Die aktuellen Compose-Dateien dieses Pakets starten nur Agent und Ollama; diese Variablen haben dort keine Wirkung. +5. **Followup-Erkennung:** Bei Eskalationen zählt jeder Nicht-Agent-Followup als menschliche Aktivität, auch ein Followup des Antragstellers. Eine Rollenunterscheidung ist derzeit nicht implementiert. +6. **Prioritätsfelder:** Impact und Urgency werden analysiert und auditiert, aber aktuell nicht separat nach GLPI geschrieben. +7. **Major-Incident-Link:** Pfad und Payload sind installationsspezifisch und müssen gegen die OpenAPI-Dokumentation der konkreten GLPI-Instanz getestet werden. +8. **Zuweisungsfelder:** `assigned_groups`/`assigned_users` passen nicht zwingend zu jeder GLPI-Version oder Plugin-Konfiguration. Im Shadow Mode und mit Testticket validieren. +9. **Parser-Fallback:** Ungültige Booleans, Zahlen und Dauern fallen häufig still auf den Code-Default zurück. Effektive Werte über `/api/status` kontrollieren. +10. **Audit enthält Ticketinhalte:** `runs.jsonl` speichert Input-Snapshots und kann personenbezogene oder vertrauliche Ticketdaten enthalten. Zugriffsrechte, Backup und Löschkonzept entsprechend behandeln. +11. **Keine atomare Servertransaktion:** Prewrite-Recheck reduziert Rennen, ersetzt aber keinen GLPI-seitigen Conditional Write. +12. **Eskalationsscan und Limit:** Bei sehr vielen alten Tickets und kleinem Limit können dieselben ältesten Kandidaten wiederholt zuerst erscheinen. Filter und Limit passend dimensionieren. +13. **Kein Model-Sharding:** Der Ollama-Pool bündelt weder RAM noch GPU-Speicher mehrerer Rechner. Jeder Node muss die verwendeten Modelle vollständig lokal laden können. +14. **Einzelrequest-Latenz:** Ein Request läuft vollständig auf einem Node. Mehr Nodes erhöhen Durchsatz und Ausfallsicherheit, nicht automatisch die Tokens/s eines einzelnen Requests. +15. **Ollama-Netzwerkzugriff:** Node-APIs müssen durch Firewall/VPN/Reverse-Proxy begrenzt werden; der Agent bringt keine eigene Node-Zugangsdatenverwaltung mit. + +# 16. Betriebs-Checklisten + +## 16.1 Vor jedem Releasewechsel + +- [ ] `DATA_DIR` vollständig gesichert. +- [ ] `.env` verschlüsselt gesichert. +- [ ] Aktuelle Binary-/Image-Prüfsumme dokumentiert. +- [ ] Release zunächst mit `DRY_RUN=true` gestartet. +- [ ] `/readyz`, `/api/status` und initialer Poll geprüft. +- [ ] Knowledge-Snapshot kompatibel oder Rebuild eingeplant. +- [ ] Keine unbeabsichtigten Änderungen an `state-index.json`. + +## 16.2 Vor Auto-Reply live + +- [ ] `GLPI_AGENT_USER_ID` korrekt. +- [ ] Source-Whitelists minimal. +- [ ] Knowledge-Artikel fachlich freigegeben. +- [ ] `auto_reply=true` nur gezielt. +- [ ] Sprache, Stil und Kategoriebindung korrekt. +- [ ] Kontextquellen stabil. +- [ ] Mehrtägige Shadow-Auswertung abgeschlossen. + +## 16.3 Vor erweiterten Eskalationsaktionen live + +- [ ] Offene Status und Einheiten im `GLPI_ESCALATION_FILTER` begrenzt. +- [ ] Gruppen- und Benutzer-IDs mit Testticket geprüft. +- [ ] GLPI-Patchfelder gegen OpenAPI geprüft. +- [ ] Private Followup-Texte abgestimmt. +- [ ] Webhook mit Idempotency-Key getestet. +- [ ] Security-Aktion nur bei Security-Grund zulässig. +- [ ] Major-Incident-Adapter separat getestet. +- [ ] `state-index.json` wird gesichert und nicht manuell bereinigt. + +## 16.4 Bei Störung + +- [ ] `PRIORITY_ENABLED=false` setzen, wenn nur der optionale Prioritätslauf auffällig ist. +- [ ] `ESCALATION_ENABLED=false` setzen, wenn Scheduler/Aktionen auffällig sind. +- [ ] `AUTO_REPLY=false`, `AUTO_PRIORITY=false`, `AUTO_ESCALATION=false` setzen, um Writes gezielt zu stoppen. +- [ ] Im Zweifel `DRY_RUN=true` und neu starten. +- [ ] Logs, Run-ID und Analysis-ID sichern. +- [ ] Keine pauschale Löschung von `state-index.json` im Livebetrieb. + +## 16.5 Vor Aktivierung eines Ollama-Pools + +- [ ] Auf allen Nodes identisches Chatmodell installiert. +- [ ] Auf allen RAG-Nodes identisches Embeddingmodell installiert. +- [ ] Modelldigests im Dashboard identisch. +- [ ] Node-Port nur für den Agenten freigegeben. +- [ ] `OLLAMA_NODE_MAX_INFLIGHT=1` als Startwert. +- [ ] Failover mit absichtlich gestopptem Testnode geprüft. +- [ ] Neue AnalysisRuns zeigen `provider.selected_node` und Versuche. +- [ ] RAM, Temperatur und p95-Laufzeit unter paralleler Last beobachtet. + +--- + +**Ende der Betriebsanleitung** diff --git a/BETRIEBSANLEITUNG_GLPI_AI_AGENT_OLLAMA_POOL.md b/BETRIEBSANLEITUNG_GLPI_AI_AGENT_OLLAMA_POOL.md new file mode 100644 index 0000000..aa11b4f --- /dev/null +++ b/BETRIEBSANLEITUNG_GLPI_AI_AGENT_OLLAMA_POOL.md @@ -0,0 +1,1104 @@ +# Betriebsanleitung – GLPI AI Agent + +**Dokumentstand:** 3. August 2026 +**Technische Basis:** Projektstand `glpi-ai-agent-ollama-pool` +**Zielgruppe:** Betrieb, Administration, Service Desk, Informationssicherheit und technische Projektverantwortliche + +> Diese Anleitung beschreibt den tatsächlich vorliegenden Quellstand. Sie trennt bewusst zwischen **Code-Defaults** und den teilweise deutlich offensiveren **Beispielwerten in `.env.example`**. Für eine neue Installation sind die Code-Defaults sicherer; für den produktiven Betrieb muss jede schreibende Funktion schrittweise im Shadow Mode validiert werden. + +## Inhaltsverzeichnis + +1. [Zweck und Systemgrenzen](#1-zweck-und-systemgrenzen) +2. [Architektur und Datenfluss](#2-architektur-und-datenfluss) +3. [Funktionsübersicht und Auswirkungen](#3-funktionsübersicht-und-auswirkungen) +4. [Sicherheits- und Policy-Modell](#4-sicherheits--und-policy-modell) +5. [Installation und Start](#5-installation-und-start) +6. [Empfohlene Inbetriebnahme](#6-empfohlene-inbetriebnahme) +7. [Regelbetrieb](#7-regelbetrieb) +8. [Persistenz, Backup, Reset und Wiederherstellung](#8-persistenz-backup-reset-und-wiederherstellung) +9. [Diagnose, Endpunkte und Monitoring](#9-diagnose-endpunkte-und-monitoring) +10. [Eskalation im Detail](#10-eskalation-im-detail) +11. [Priorisierung im Detail](#11-priorisierung-im-detail) +12. [Knowledge/RAG und automatische Antworten](#12-knowledgerag-und-automatische-antworten) +13. [Vollständige ENV-Referenz](#13-vollständige-env-referenz) +14. [Fehlerbehebung](#14-fehlerbehebung) +15. [Bekannte Grenzen und Abweichungen](#15-bekannte-grenzen-und-abweichungen) +16. [Betriebs-Checklisten](#16-betriebs-checklisten) + +--- + +# 1. Zweck und Systemgrenzen + +Der GLPI AI Agent liest Tickets aus GLPI 11 über die High-Level API, sammelt freigegebene Kontextdaten, führt mehrere voneinander getrennte KI-Analysen über einen oder mehrere Ollama-Nodes aus und übergibt die Ergebnisse an deterministische Go-Policies. Erst die Policy entscheidet, ob eine GLPI-Aktion zulässig ist. + +Das Modell besitzt **keinen direkten GLPI-Werkzeugzugriff**. Es kann daher weder eigenständig Kategorien ändern noch Followups schreiben, Prioritäten setzen, Gruppen zuweisen oder Tickets verknüpfen. Es liefert ausschließlich strukturierte Empfehlungen. + +Der Agent ist für folgende Hauptaufgaben ausgelegt: + +- neue oder geänderte Tickets erkennen und deduplizieren; +- Kategorie aus dem aktuellen GLPI-Katalog auswählen; +- Priorität, Impact, Urgency, Betroffenheitsumfang und Zeitkritikalität analysieren; +- aktive Störungen oder Wartungen aus Uptime Kuma einem Ticket zuordnen; +- einen bereits menschlich erstellten und freigegebenen Knowledge-Artikel als Antwort auswählen; +- offene Tickets unabhängig von `date_mod` zeitgesteuert auf Eskalationsbedarf prüfen; +- Kategorie-, Prioritäts-, Antwort- und Eskalationsentscheidungen vollständig auditieren; +- menschlich bestätigte Kategoriekorrekturen als begrenzte Lernbeispiele speichern; +- lokale und GLPI-interne Knowledge-Inhalte indexieren und verwalten. + +Nicht vorgesehen ist eine freie, vom Modell formulierte Endnutzerantwort. Der Inhalt einer automatischen Antwort stammt aus einem freigegebenen Knowledge-Dokument oder aus einer fest konfigurierten Statusvorlage. + +# 2. Architektur und Datenfluss + +## 2.1 Komponenten + +| Komponente | Aufgabe | +|---|---| +| GLPI High-Level API | Tickets, Kategorien, Followups, Knowledge, Changes, Assets und Schreiboperationen | +| Ollama Pool Router | Healthchecks, Routing, per-Node-Auslastungsgrenzen, Digest-Prüfung und Failover | +| Ollama Chatmodell je Node | Strukturierte Kategorie-, Prioritäts-, Status-, Antwort- und Eskalationsempfehlungen | +| Ollama Embeddingmodell je Node | Semantische Vektoren für Hybrid-Retrieval | +| Knowledge Store | Lokale JSON-Artikel, Web-verwaltete Artikel, GLPI-KB-Cache und persistenter Vektorindex | +| Kontextkollektor | Changes, Major Incidents, Requester-Geräte und Uptime-Kuma-Daten | +| Policy | Deterministische Freigabe oder Blockade jeder Aktion | +| Prioritätsqueue | Manuelle Läufe, Webhooks, Polling und Eskalationsscheduler mit getrennten Prioritäten | +| State Store | Audit in `runs.jsonl` und dauerhafte Deduplizierung in `state-index.json` | +| Weboberfläche | Dashboard, Diagnose, Knowledge-Verwaltung, Lernen, Mapping und manuelle Neuanalyse | + +## 2.2 Ollama-Pool + +Der Agent kann einen Einzelnode oder mehrere unabhängige Ollama-Server verwenden. Jeder Node lädt das vollständige Chat- und Embedding-Modell lokal. Der Pool teilt daher **kein einzelnes Modell und keinen RAM über mehrere Rechner**, sondern verteilt vollständige Inferenzrequests. Das erhöht Gesamtdurchsatz und Verfügbarkeit. + +Für jeden KI-Lauf wählt der Router einen gesunden, kompatiblen Node. Standard ist `least_inflight`: Der Node mit den wenigsten laufenden Requests wird bevorzugt; bei gleicher Auslastung gleicht der Router auch die bisherige Requestzahl aus. Retryfähige Netzwerk-, Timeout-, Rate-Limit-, 5xx- oder Response-JSON-Fehler können auf einem anderen Node wiederholt werden. Die Node-Auswahl und jeder Versuch werden im separaten `AnalysisRun.provider` gespeichert. + +Bei `OLLAMA_REQUIRE_SAME_MODEL_DIGEST=true` arbeitet der Pool fail-closed, sobald erreichbare Nodes unterschiedliche Chat- oder erforderliche Embedding-Digests melden. Dadurch wird verhindert, dass identische Tickets zufällig mit unterschiedlichen Modellständen bewertet werden. + +Beim Prozessstart bleibt die Weboberfläche erreichbar, während der Agent wiederholt auf mindestens einen kompatiblen Node wartet. Knowledge-Initialisierung, Polling und Worker beginnen erst anschließend. Dadurch wird ein noch bootender externer Node nicht zu einem einmaligen dauerhaften Initialisierungsfehler. + +## 2.3 Normaler Ticketlauf + +1. Der Poller lädt bis zu `GLPI_POLL_LIMIT` Tickets mit `GLPI_TICKET_FILTER`. +2. Aus entscheidungsrelevanten Ticketfeldern wird eine `source_version` gebildet. +3. `state-index.json` entscheidet, ob genau diese Ticketversion bereits verarbeitet wurde. +4. Neue Versionen werden in die Queue gestellt. +5. Ein Worker lädt Ticket und Followups erneut und prüft den erlaubten Status. +6. Kategorie-Knowledge und GLPI-Kategorien werden als Kandidaten vorbereitet. +7. Die Kategorie-KI läuft als eigener `AnalysisRun`. +8. Die Policy prüft Kategorie-ID, Confidence und Änderungsbedarf. +9. Die Prioritäts-KI läuft optional als eigener, fail-open begrenzter `AnalysisRun`. +10. Der Kontextkollektor lädt aktivierte Betriebsdaten. +11. Optional wird eine aktive Uptime-Kuma-Störung oder Wartung zugeordnet. +12. Antwort-Knowledge wird nach der effektiven Kategorie neu gerankt. +13. Die Antwort-KI darf ausschließlich einen bereitgestellten Knowledge-Kandidaten auswählen oder ablehnen. +14. Vor jedem Write werden Ticket und Followups erneut geprüft. +15. Der übergeordnete Lauf und alle Analyseläufe werden persistiert. + +## 2.4 Queue-Prioritäten + +| Trigger | Priorität | Wirkung | +|---|---:|---| +| `manual_recheck` / manuell | 100 | Höchste Priorität; kann bekannte Ticketversion einmalig erzwingen | +| `webhook` | 80 | Schnelle Reaktion auf GLPI-Ereignisse | +| `poll` | 50 | Reguläre neue/geänderte Tickets | +| `scheduled_escalation` | 20 | Niedrigste Priorität, damit neue Tickets Vorrang haben | + +Die Queue dedupliziert nach `Ticket-ID + Trigger`. Ein Poll- und ein Eskalationsauftrag für dasselbe Ticket können deshalb gleichzeitig existieren, zwei Poll-Aufträge jedoch nicht. + +# 3. Funktionsübersicht und Auswirkungen + +## 3.1 Ticket-Polling und Webhook + +**Polling** läuft sofort nach Start der Ticketverarbeitung und anschließend in `GLPI_POLL_INTERVAL`. Die API-Abfrage kann serverseitig gefiltert werden; unabhängig davon prüft die lokale Policy `GLPI_ALLOWED_STATUS_IDS`. + +**Webhook** ist nur aktiv, wenn `WEBHOOK_SECRET` gesetzt ist. Der Endpunkt `POST /webhook/glpi` erwartet den Header `X-Webhook-Secret`. Er extrahiert eine Ticket-ID aus mehreren üblichen JSON-Formen oder einer `/Ticket/{id}`-Zeichenfolge und stellt das Ticket mit höherer Queue-Priorität ein. Der Webhook umgeht die Versionserkennung nicht; ein unverändertes, bereits verarbeitetes Ticket kann später als `already_processed` enden. + +## 3.2 Automatische Kategorisierung + +Die Kategorieanalyse erhält nur bekannte GLPI-Kategorien und eine begrenzte Auswahl an Kategorie-Knowledge. Eine empfohlene ID muss im geladenen GLPI-Katalog existieren. `AUTO_CATEGORY=true` erlaubt die Policy-Prüfung; `DRY_RUN=true` simuliert den Write. Kategorie-Knowledge aus `KNOWLEDGE_CATEGORY_SOURCES` ist niemals als Endnutzerantwort zulässig. + +**Auswirkung im Livebetrieb:** `PATCH` des Ticketfeldes für die ITIL-Kategorie. Vor dem Write wird geprüft, ob das Ticket seit der Analyse unverändert ist. + +## 3.3 KI-Priorisierung + +Die Prioritätsanalyse ist ein separater Lauf. Das Modell empfiehlt GLPI-Priorität 1–6 sowie Impact, Urgency, Scope, Zeitkritikalität und Reason Codes. Explizite Ticketbelege wie „mehrere Benutzer“ oder „Ausweichmöglichkeit vorhanden“ werden zusätzlich deterministisch erkannt. + +Die Policy: + +- erlaubt keine automatische Herabstufung; +- begrenzt die Erhöhung auf `PRIORITY_MAX_INCREASE` je Ticketlauf; +- verlangt bei einer Erhöhung Mindest-Confidence und einen erlaubten Reason Code; +- behandelt neutrale Gründe wie `insufficient_information` als „keine Änderung“; +- beendet nur den Prioritätslauf bei Timeout oder Modellfehler; Kategorie und Antwort laufen weiter. + +**Auswirkung im Livebetrieb:** Priorität des Tickets wird auf den policy-begrenzten Zielwert gesetzt. Impact und Urgency werden derzeit diagnostiziert, aber nicht separat geschrieben. + +## 3.4 Operational Context + +Der Kontextkollektor kann folgende Quellen zusammenführen: + +- GLPI Change Calendar innerhalb von Lookback/Lookahead; +- explizit gefilterte Major-Incident-Tickets; +- Geräte/Assets des Requesters; +- Uptime-Kuma-Störungen und Wartungen. + +Bei `CONTEXT_BLOCK_AUTO_REPLY_ON_ERRORS=true` arbeitet die Antwortpolicy fail-closed: Fehler einer aktivierten Kontextquelle können automatische Antworten blockieren. `CONTEXT_BLOCK_AUTO_REPLY_ON_INCIDENT=true` blockiert normale Knowledge-Antworten bei einem relevanten Incident. + +## 3.5 Statusbezogene vordefinierte Antworten + +Ist `CONTEXT_STATUS_REPLY_ENABLED=true`, darf die KI nur einen aktiven Uptime-Kuma-Kandidaten auswählen. Der Text stammt ausschließlich aus `CONTEXT_INCIDENT_REPLY_TEXT` oder `CONTEXT_MAINTENANCE_REPLY_TEXT`. Die Freigabe erfordert gleichzeitig: + +- ausreichende deterministische Relevanz; +- ausreichende KI-Confidence; +- ausreichenden Produktscore `Relevanz × Confidence`; +- vollständigen Kontext; +- einen tatsächlich bekannten Kandidaten. + +Bei erfolgreicher Statusantwort wird die normale Knowledge-Antwortanalyse übersprungen. + +## 3.6 Knowledge Retrieval und Auto-Reply + +Das Retrieval kombiniert Semantik, Betreff/Titel, lexikalische Übereinstimmung, Keywords und Kategorie-/Lernsignale. Lange Tickets und Artikel werden in überlappende Chunks zerlegt. Der Agent schickt nur dynamisch ausgewählte Kandidaten an das Modell. + +Eine automatische Antwort benötigt unter anderem: + +- `AUTO_REPLY=true` und `DRY_RUN=false` für einen echten Write; +- keine vorhandenen Followups; +- einen vom Modell ausgewählten Kandidaten; +- ausreichende KI-Confidence; +- zulässige Source; +- `auto_reply=true` am Dokument; +- passende Sprache und Kommunikationsstil; +- Retrieval-Floor und finale Evidenz; +- passende effektive Ticketkategorie; +- keine blockierende Kontextlage; +- eine zweite Followup-Prüfung unmittelbar vor dem Write. + +**Auswirkung im Livebetrieb:** öffentlicher GLPI-Followup mit festem Knowledge-Inhalt, Anrede, Schlussformel und Signatur. + +## 3.7 GLPI Knowledge Base Connector + +Der Connector synchronisiert sichtbare GLPI-KB-Artikel periodisch. Rich Text bleibt für den Versand erhalten, während RAG und Modell bereinigten Plaintext sehen. Ein lokaler Cache (`glpi-kb-cache.json`) erlaubt den Start mit dem zuletzt synchronisierten Stand, wenn die initiale GLPI-KB-Abfrage ausfällt. + +## 3.8 Knowledge-Webeditor und Kategorie-Mapping + +Bei authentifiziertem Dashboard und `KNOWLEDGE_WEB_EDIT_ENABLED=true` können agenteneigene Knowledge-Dokumente unter `DATA_DIR/knowledge-managed/` erstellt, geändert und gelöscht werden. Statische Dateien im `KNOWLEDGE_DIR` und synchronisierte GLPI-Artikel bleiben read-only. + +Der Mapping-Editor verbindet externe String-Kategorien aus Knowledge-Dateien mit numerischen GLPI-ITIL-Kategorien. Die Änderungen werden in `KNOWLEDGE_CATEGORY_MAP_FILE` gespeichert und in den laufenden Index übernommen. + +## 3.9 Human-in-the-loop-Lernen + +Der Agent lernt nur aus ausdrücklich bestätigten oder korrigierten Beispielen, nicht automatisch aus seinen eigenen Entscheidungen. Die Beispiele beeinflussen spätere Kategorieprompts und Retrievalsignale. Die Datei liegt unter `DATA_DIR/category-learning.json`. + +## 3.10 Zeitgesteuerte Eskalation + +Die Eskalation besitzt einen eigenen Scheduler und ignoriert die normale Ticketversions-Deduplizierung. Sie prüft alte Tickets auch dann, wenn `date_mod` unverändert ist. Ein Lauf kann bis zu drei Aktionen empfehlen. Jede Aktion wird einzeln geprüft und auditiert. + +Unterstützte Aktionen: + +| Aktion | Live-Auswirkung | +|---|---| +| `raise_priority` | Priorität genau um eine Stufe erhöhen, maximal 6 | +| `assign_second_level` | konfigurierte Second-Level-Gruppe zu vorhandenen Gruppen hinzufügen | +| `assign_security_team` | konfigurierte Security-Gruppe hinzufügen; nur bei `security_incident_suspected` | +| `notify_service_owner` | konfigurierte Gruppe/Person hinzufügen und optional Webhook senden | +| `link_major_incident` | Ticket über installationsspezifischen API-Adapter mit relevantestem Major Incident verknüpfen | +| `request_manager_review` | konfigurierte Gruppe/Person hinzufügen und optional Webhook senden | + +Zu jeder erfolgreichen Aktion kann ein privater Followup mit einer festen Vorlage geschrieben werden. Erfolgreiche Aktionsschritte werden je Ticket, Stufe, Aktion und Ziel in `state-index.json` dedupliziert. + +## 3.11 Ollama-Pool, Routing und Failover + +**Auswirkung:** Mehrere Tickets oder voneinander unabhängige Analyseläufe können über mehrere Rechner parallel verarbeitet werden. Die Geschwindigkeit eines einzelnen Requests bleibt durch den ausgewählten Node begrenzt. Fällt ein Node aus, kann ein noch nicht akzeptierter Inferenzrequest auf einem anderen kompatiblen Node fortgesetzt werden. + +Der Pool unterstützt `least_inflight`, `round_robin`, `weighted` und `fastest_recent`. Für gleichartige Lenovo-Systeme mit integrierter GPU ist `least_inflight` zusammen mit `OLLAMA_NODE_MAX_INFLIGHT=1` der empfohlene Start. Für einen später ergänzten leistungsfähigeren GPU-Server kann `weighted` verwendet werden. + +# 4. Sicherheits- und Policy-Modell + +## 4.1 Schalterhierarchie + +| Bereich | Analyse aktiv | Write-Freigabe | Globaler Write-Schalter | +|---|---|---|---| +| Kategorie | immer im normalen Lauf | `AUTO_CATEGORY=true` | `DRY_RUN=false` | +| Antwort | Kandidatenlage und Followup-Status | `AUTO_REPLY=true` | `DRY_RUN=false` | +| Priorität | `PRIORITY_ENABLED=true` | `AUTO_PRIORITY=true` | `DRY_RUN=false` | +| Eskalation | `ESCALATION_ENABLED=true` | `AUTO_ESCALATION=true` | `DRY_RUN=false` | + +`DRY_RUN=true` überstimmt alle Auto-Schalter und simuliert freigegebene Aktionen. + +## 4.2 Race-Schutz + +- Pro Ticket existiert innerhalb eines Prozesses ein Mutex. +- Ticket und Followups werden vor der Analyse geladen. +- Vor einem Live-Write werden entscheidungsrelevanter Ticketzustand und Followups erneut geladen. +- Ändert sich die `source_version`, wird die Aktion abgebrochen. +- GLPI-Schreibfehler werden nicht blind wiederholt. + +Eine vollständig atomare „prüfen und schreiben“-Operation kann ohne serverseitigen Conditional Write dennoch nicht garantiert werden. + +## 4.3 Rechteprinzip + +Das GLPI-Servicekonto sollte nur die tatsächlich aktivierten Rechte besitzen: + +- Lesen von Tickets, Kategorien und Followups; +- Kategorie ändern nur bei Live-Kategorisierung; +- öffentliche Followups schreiben nur bei Auto-Reply; +- Priorität ändern nur bei Live-Priorität oder `raise_priority`; +- private Followups schreiben nur bei Eskalationsnotizen; +- Gruppen/Benutzer zuweisen nur bei entsprechenden Eskalationsaktionen; +- ITIL-Verknüpfungen erstellen nur bei `link_major_incident`. + +# 5. Installation und Start + +## 5.1 Native Windows-Installation + +1. Archiv in ein dauerhaftes Verzeichnis entpacken. +2. `.env.example` nach `.env` kopieren. +3. Für native Ausführung verwenden: + +```env +DATA_DIR=./data +KNOWLEDGE_DIR=./knowledge +OLLAMA_URL=http://localhost:11434 +HTTP_ADDR=:7080 +``` + +4. Modelle installieren: + +```powershell +ollama pull qwen3:8b +ollama pull embeddinggemma +``` + +5. Start über `run.ps1` oder die vorgebaute EXE. `run.ps1` lädt `.env`, korrigiert alte Docker-Pfade und startet derzeit mit `go run ./cmd/agent`. Für einen reinen Binary-Betrieb kann die EXE direkt gestartet werden, nachdem die Variablen im Prozess beziehungsweise Dienst gesetzt wurden. + +## 5.2 Docker Compose + +Die aktuelle Projektfassung enthält mehrere Compose-Varianten. Vor dem Start müssen Listener und Port-Mapping zusammenpassen: + +- `compose_local.yml` mappt `7080:7080`; dazu passt `HTTP_ADDR=:7080`. +- `docker-compose.yml` mappt `127.0.0.1:8080:8080`; dazu muss `HTTP_ADDR=:8080` gesetzt werden **oder** das Mapping auf `127.0.0.1:7080:7080` geändert werden. +- `AGENT_PORT` wird in den vorliegenden Compose-Dateien nicht ausgewertet. + +Startbeispiel: + +```bash +docker compose -f compose_local.yml up -d ollama +docker compose -f compose_local.yml exec ollama ollama pull qwen3:8b +docker compose -f compose_local.yml exec ollama ollama pull embeddinggemma +docker compose -f compose_local.yml up -d +``` + +## 5.3 Registry-Deployment + +```bash +export AGENT_IMAGE=gitea.example.de/organisation/glpi-ai-agent:2026-08-02 +docker compose -f docker-compose.registry.yml pull +docker compose -f docker-compose.registry.yml up -d +``` + +Das bind-mountete Datenverzeichnis muss für UID/GID des Containers schreibbar sein. Das Knowledge-Verzeichnis darf read-only sein; Web-verwaltete Artikel liegen im Datenverzeichnis. + +## 5.4 systemd + +Die mitgelieferte Unit erwartet: + +- Binary unter `/opt/glpi-ai-agent/glpi-ai-agent`; +- Arbeitsverzeichnis `/opt/glpi-ai-agent`; +- ENV-Datei `/etc/glpi-ai-agent.env`; +- schreibbares Datenverzeichnis unter `/var/lib/glpi-ai-agent`. + +Die Pfade in der ENV müssen dazu passen, insbesondere `DATA_DIR=/var/lib/glpi-ai-agent` und ein lesbares `KNOWLEDGE_DIR`. + +# 6. Empfohlene Inbetriebnahme + +## Phase 1 – reine Analyse + +```env +DRY_RUN=true +AUTO_CATEGORY=true +AUTO_REPLY=false +PRIORITY_ENABLED=true +AUTO_PRIORITY=false +ESCALATION_ENABLED=false +AUTO_ESCALATION=false +``` + +Prüfen: Kategorien, Kandidaten, Reason Codes, Mappingwarnungen, Kontextfehler und Laufzeiten. + +## Phase 2 – Eskalation im Shadow Mode + +```env +ESCALATION_ENABLED=true +AUTO_ESCALATION=false +GLPI_ESCALATION_FILTER=status.id==1 +ESCALATION_SCAN_INTERVAL=30m +ESCALATION_MIN_AGE=4h +ESCALATION_MIN_INACTIVITY=2h +``` + +Prüfen: gefundene Kandidaten, Inaktivitätsberechnung, SLA-Felder, Zuweisungen, vorgeschlagene Stufen und Aktionen. + +## Phase 3 – Kategorie live + +```env +DRY_RUN=false +AUTO_CATEGORY=true +AUTO_REPLY=false +AUTO_PRIORITY=false +AUTO_ESCALATION=false +``` + +## Phase 4 – einzelne Eskalationsaktion live + +Zunächst nur: + +```env +ESCALATION_ALLOWED_ACTIONS=none,raise_priority +AUTO_ESCALATION=true +``` + +Danach einzeln Second-Level, Security, Service Owner, Management und zuletzt Major-Incident-Link aktivieren. + +## Phase 5 – Auto-Reply + +Nur freigegebene Sources und Artikel verwenden. Vorher `GLPI_AGENT_USER_ID`, Kommunikationspolicy, Kategoriebindung, Kontextquellen und zweite Followup-Prüfung im Shadow Mode kontrollieren. + +# 7. Regelbetrieb + +## 7.1 Tägliche Kontrollen + +- `/readyz` liefert HTTP 200. +- Dashboard zeigt GLPI, Ollama und Knowledge als bereit. +- Letzter Poll ist aktuell und `poll_last_error` leer. +- Queue bleibt im Normalbetrieb nahe 0. +- Fehlerzähler steigt nicht dauerhaft. +- Neue Runs erscheinen bei geänderten Tickets. +- GLPI-KB-Sync ist aktuell, wenn aktiviert. +- Eskalationsaktionen und private Notizen stimmen fachlich. + +## 7.2 Manuelle Neuanalyse + +Im Dashboard oder per API: + +```http +POST /api/tickets/{ticket_id}/reprocess +``` + +Der Lauf erhält `trigger=manual_recheck` und `Force=true`. Er löscht keine Historie und verändert `state-index.json` nicht rückwirkend. Im Livebetrieb gelten dennoch die normalen Auto-Schalter; für sichere Tests `DRY_RUN=true` verwenden. + +## 7.3 Konfigurationsänderungen + +ENV-Werte werden nur beim Start geladen. Nach Änderungen ist ein Neustart erforderlich. Anschließend `/api/status` auf die effektiven, nicht geheimen Werte prüfen. Ungültige boolesche, numerische oder Dauerwerte können von den Parserhilfen still auf den Code-Default zurückfallen; deshalb nie allein auf den Inhalt der `.env` vertrauen. + +# 8. Persistenz, Backup, Reset und Wiederherstellung + +## 8.1 Wichtige Dateien + +| Pfad unter `DATA_DIR` | Inhalt | Bedeutung beim Löschen | +|---|---|---| +| `runs.jsonl` | vollständige Auditläufe | Diagnosehistorie verschwindet; Deduplizierung bleibt bestehen | +| `state-index.json` | letzte verarbeitete Ticketversionen und erfolgreiche Eskalationsschlüssel | Tickets gelten erneut als unbekannt; Liveaktionen können erneut geprüft werden | +| `knowledge-index/snapshot.gob` | persistenter Knowledge-Index | nächster Start muss Index neu laden/aufbauen | +| `knowledge-index/external-embeddings.json` | externer Embeddingcache | zusätzliche Embeddingarbeit | +| `embeddings.json` | historischer/zusätzlicher Embeddingcache | zusätzliche Embeddingarbeit | +| `glpi-kb-cache.json` | letzter GLPI-KB-Stand | kein Cache-Fallback bis zum nächsten erfolgreichen Sync | +| `knowledge-managed/` | über Web verwaltete Artikel | verwaltete Artikel gehen verloren | +| `category-learning.json` | menschlich bestätigte Lernbeispiele | Lernhistorie geht verloren | +| `knowledge-category-map.json` oder konfigurierter Mappingpfad | Fremdkategorie-Mapping | Kategorien werden je Modus unscoped/skip/strict behandelt | + +`runs.jsonl` wird ab etwa 64 MiB auf die im Speicher gehaltenen letzten 2000 Läufe kompaktiert. `state-index.json` bleibt davon unabhängig. + +## 8.2 Backup + +Vor Updates oder Live-Aktivierung: + +1. Agent stoppen. +2. Gesamtes `DATA_DIR` sichern. +3. `.env` separat und verschlüsselt sichern. +4. Statisches `KNOWLEDGE_DIR` und gegebenenfalls Git-Stand sichern. +5. Prüfsumme oder Snapshot-Zeitpunkt dokumentieren. + +## 8.3 Sicherer Testreset + +Für ein einzelnes Ticket: manuelle Neuanalyse verwenden. + +Für einen vollständigen Testreset: + +1. Agent stoppen. +2. `DRY_RUN=true` sicherstellen. +3. `state-index.json` sichern und löschen. +4. Optional `runs.jsonl` löschen, wenn auch die sichtbare Historie leer sein soll. +5. Agent starten. + +Im Livebetrieb `state-index.json` nicht pauschal löschen. Bereits ausgeführte Kategorie-, Antwort-, Prioritäts- oder Eskalationsentscheidungen können sonst erneut geprüft werden. + +## 8.4 Rollback + +- Alte Binary/Image-Version wiederherstellen. +- Datenverzeichnis grundsätzlich beibehalten. +- Bei inkompatiblem Knowledge-Snapshot den Snapshot sichern und `KNOWLEDGE_INDEX_MODE=rebuild` nutzen. +- `state-index.json` nicht durch eine ältere, unvollständige Kopie ersetzen, wenn seitdem Live-Eskalationen gelaufen sind. + +# 9. Diagnose, Endpunkte und Monitoring + +## 9.1 HTTP-Endpunkte + +| Methode/Pfad | Auth | Zweck | +|---|---|---| +| `GET /healthz` | nein | Prozess lebt; liefert einfach `status=ok` | +| `GET /readyz` | nein | 200 nur wenn GLPI, Ollama und Knowledge bereit sind | +| `GET /metrics` | nein | Prometheus-Metriken | +| `GET /` | Basic Auth, außer anonym | Dashboard | +| `GET /diagnostics` | Basic Auth | Entscheidungsdiagnose | +| `GET /category-mappings` | Basic Auth | Kategorie-Mapping-Editor | +| `GET /api/status` | Basic Auth | effektive nicht geheime Konfiguration und Laufzustand | +| `GET /api/runs?limit=50` | Basic Auth | letzte Runs, maximal 200 | +| `GET /api/diagnostics/run/{id}` | Basic Auth | einzelner Ticketlauf | +| `GET /api/diagnostics/analysis/{id}` | Basic Auth | einzelner AnalysisRun | +| `GET/POST/PUT/DELETE /api/knowledge…` | Basic Auth; Mutation zusätzlich Editfreigabe | Knowledge-Verwaltung | +| `GET/POST/DELETE /api/learning…` | Basic Auth; Mutation | Lernbeispiele | +| `POST /api/tickets/{id}/reprocess` | Basic Auth; Mutation | manuelle erzwungene Neuanalyse | +| `POST /webhook/glpi` | Webhook-Secret | Ticket in Webhook-Queue stellen | + +## 9.2 Prometheus-Metriken + +- `glpi_agent_processed_total` +- `glpi_agent_skipped_total` +- `glpi_agent_errors_total` +- `glpi_agent_category_changes_total` +- `glpi_agent_replies_total` +- `glpi_agent_priority_recommendations_total` +- `glpi_agent_priority_changes_total` +- `glpi_agent_escalation_runs_total` +- `glpi_agent_escalations_total` +- `glpi_agent_context_fetches_total` +- `glpi_agent_context_errors_total` +- `glpi_agent_queue_depth` +- `glpi_agent_glpi_up` +- `glpi_agent_ollama_up` +- `glpi_agent_knowledge_documents` +- `glpi_agent_glpi_kb_up` +- `glpi_agent_glpi_kb_documents` +- `glpi_agent_ollama_node_healthy{node="…"}` +- `glpi_agent_ollama_node_available{node="…"}` +- `glpi_agent_ollama_node_inflight{node="…"}` +- `glpi_agent_ollama_node_requests_total{node="…"}` +- `glpi_agent_ollama_node_failures_total{node="…"}` +- `glpi_agent_ollama_node_average_duration_ms{node="…"}` + +## 9.3 Loginterpretation + +Der Agent schreibt strukturierte JSON-Logs nach stdout. Wichtige Startmeldungen: + +- `web server started` +- `knowledge initialization started in background` +- `persistent knowledge index loaded` oder Aufbaufortschritt +- `GLPI knowledge base synchronized` +- `ticket processing started` +- `initial GLPI ticket poll completed` +- `Ollama pool configured` +- `Ollama node available` beziehungsweise `Ollama node unavailable` + +Der initiale Poll zeigt `fetched`, `already_processed`, `unseen`, `enqueued` und `rejected`. Damit lässt sich unterscheiden, ob GLPI keine Tickets liefert, alle Versionen bereits bekannt sind oder die Queue blockiert. + +# 10. Eskalation im Detail + +## 10.1 Kandidatenauswahl + +Der Scheduler startet sofort und danach alle `ESCALATION_SCAN_INTERVAL`. Er nutzt `GLPI_ESCALATION_FILTER`; ist dieser leer, wird `GLPI_TICKET_FILTER` verwendet. Tickets werden nach Erstellungszeit ausgewählt und erst ab `ESCALATION_MIN_AGE` in die Queue gestellt. + +## 10.2 Deterministische Evidenz + +Vor dem Modell werden berechnet: + +- Ticketalter; +- letzte menschliche Aktivität und Inaktivitätsdauer; +- keine Zuweisung (`AssignedGroups` und `AssignedUsers` leer); +- SLA-Frist aus `time_to_resolve`; +- SLA verletzt oder innerhalb des Risikofensters; +- relevantester Major Incident oberhalb des Schwellwertes. + +Followups des `GLPI_AGENT_USER_ID` zählen nicht als menschliche Aktivität. Jeder andere Followup zählt derzeit als menschlich, auch eine Rückmeldung des Antragstellers. + +## 10.3 Reason Codes + +| Code | Datenbezug/Wirkung | +|---|---| +| `no_human_response` | muss durch Inaktivitätsberechnung belegt sein | +| `unassigned` | muss durch leere Gruppen- und Benutzerzuweisung belegt sein | +| `sla_at_risk` | muss durch Frist innerhalb `ESCALATION_SLA_RISK_WINDOW` belegt sein | +| `sla_breached` | muss durch überschrittene `time_to_resolve` belegt sein | +| `major_incident_candidate` | muss durch relevanten Major-Incident-Kontext belegt sein | +| `security_incident_suspected` | fachlicher Modellgrund; Voraussetzung für Security-Zuweisung | +| `business_deadline` | fachlicher Modellgrund, kann Second-Level unterstützen | +| `no_workaround` | fachlicher Modellgrund, kann Second-Level unterstützen | + +Alle ausgegebenen Codes müssen in `ESCALATION_ALLOWED_REASON_CODES` stehen. Für die deterministisch prüfbaren Codes blockiert eine fehlende Evidenz fail-closed. + +## 10.4 Aktionen und Reihenfolge + +Das Modell darf höchstens drei Aktionen empfehlen. Die Policy dedupliziert und sortiert sie fest: + +1. `assign_security_team` +2. `link_major_incident` +3. `assign_second_level` +4. `raise_priority` +5. `notify_service_owner` +6. `request_manager_review` + +Nur die tatsächlich empfohlenen Aktionen werden ausgeführt; die Reihenfolge verhindert, dass das Modell die Ausführungskette manipuliert. + +## 10.5 Teilweise erfolgreiche Pläne + +Jeder Aktionsschritt besitzt einen eigenen Auditdatensatz. Eine Aktion kann erfolgreich sein, während eine andere fehlschlägt. Erfolgreiche Schritte erhalten sofort ihren dauerhaften Idempotenzschlüssel. Fehlgeschlagene Schritte können in einem späteren Lauf erneut versucht werden. + +Private Notizfehler werden als Warnung am Schritt erfasst; die Hauptaktion kann trotzdem als ausgeführt gelten. Ein Webhookfehler bei Service Owner oder Manager gilt dagegen als Aktionsfehler. + +## 10.6 Webhook + +Der ausgehende Webhook sendet JSON mit Ticket-ID, Entity, Priorität, Stufe, Aktion, Ziel, Reason Codes, Begründung, Confidence und Idempotenzschlüssel. Derselbe Schlüssel steht im Header `Idempotency-Key`. Redirects werden nicht verfolgt. Optional wird `Authorization: Bearer …` gesetzt. + +# 11. Priorisierung im Detail + +## 11.1 Modelloutput + +- `recommended_priority`: 1–6 +- `recommended_impact`: 1–6 +- `recommended_urgency`: 1–6 +- `affected_scope`: `single_user`, `multiple_users`, `site`, `organization`, `unknown` +- `time_criticality`: `low`, `normal`, `high`, `immediate`, `unknown` +- kontrollierte Reason Codes +- Confidence und Begründung + +## 11.2 Erhöhungsgründe + +Standardmäßig freigegeben: + +- `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` + +Neutrale Codes wie `single_user_affected`, `workaround_available` und `insufficient_information` dürfen eine unveränderte Empfehlung erklären, aber keine automatische Erhöhung begründen. + +## 11.3 Fail-open-Eigenschaft + +`PRIORITY_ANALYSIS_TIMEOUT` begrenzt nur den optionalen Prioritätslauf. Timeout, ungültiges JSON oder Modellfehler führen zu `priority_ai_failed`, nicht zum Abbruch der Kategorie- und Antwortpipeline. + +# 12. Knowledge/RAG und automatische Antworten + +## 12.1 Source-Trennung + +- `KNOWLEDGE_ALLOWED_SOURCES`: normale Suche und Antwortkandidaten. +- `KNOWLEDGE_CATEGORY_SOURCES`: nur Kategorieunterstützung; Text/HTML nicht als Antwort nutzbar. +- `KNOWLEDGE_AUTO_REPLY_SOURCES`: Teilmenge der normalen Quellen, die grundsätzlich antworten darf. + +## 12.2 Indexmodi + +- `incremental`: Snapshot sofort laden, Änderungen im Hintergrund einarbeiten. +- `rebuild`: Quellen vollständig neu prüfen und Index neu schreiben. +- `readonly`: ausschließlich kompatiblen Snapshot verwenden; ohne Snapshot Startfehler der Knowledge-Initialisierung. + +Ticketpolling und Worker starten erst nach einem konsistenten lokalen Knowledge-Index. Das Webinterface startet vorher und zeigt den Fortschritt. + +## 12.3 Kategoriekompatibilität + +- `unscoped`: Artikel bleibt nutzbar; unbekannte String-Kategorien blockieren nicht automatisch. +- `skip`: Artikel mit nicht gemappten Kategorien wird ausgelassen. +- `strict`: nicht gemappte Kategorie erzeugt einen Fehler. + +Für gemeinsam genutzte Knowledge-Verzeichnisse ist `unscoped` der kompatibelste Startwert; für streng kontrollierte Auto-Replies ist ein vollständiges Mapping vorzuziehen. + +--- + +# 13. Vollständige ENV-Referenz + +## 13.1 Allgemeine Syntaxregeln + +- **Boolean:** empfohlen ausschließlich `true` oder `false`. +- **Dauer:** Go-Syntax wie `250ms`, `30s`, `5m`, `2h`, `72h`. `1d` ist ungültig; `24h` verwenden. +- **Score/Confidence:** Dezimalpunkt, z. B. `0.88`. +- **Listen:** kommasepariert. Stringlisten erkennen häufig `none` als leere Liste. +- **Templates:** literales `\n` wird bei `envTemplate` in einen Zeilenumbruch umgewandelt. +- **Geheimnisse:** niemals in Diagnoseexporte, Tickets oder Screenshots aufnehmen. +- **Code-Default:** Wert, wenn die Variable nicht gesetzt oder bei vielen Parsern syntaktisch ungültig ist. +- **Beispielwert:** Wert aus der mitgelieferten `.env.example`; er ist nicht automatisch eine sichere Produktionsempfehlung. + +## 00. DEPLOYMENT / IMAGE – CONTAINER REGISTRY +| ENV | Geltungsbereich | Bedeutung und Auswirkung | Mögliche Werte / Format | Code-Default | `.env.example` | Pflicht / Abhängigkeiten | +|---|---|---|---|---|---|---| +| `AGENT_IMAGE` | Compose/optionale KB-App | OCI-Image des Agenten für das Registry-Deployment. | OCI-Image: registry/repository:tag oder registry/repository@sha256:… | nicht vom Agenten gelesen | gitea.example.de/organisation/glpi-ai-agent:latest | Nur docker-compose.registry.yml. | + +## 01. DOCKER COMPOSE - PORTS +| ENV | Geltungsbereich | Bedeutung und Auswirkung | Mögliche Werte / Format | Code-Default | `.env.example` | Pflicht / Abhängigkeiten | +|---|---|---|---|---|---|---| +| `AGENT_PORT` | Compose/optionale KB-App | Veröffentlichter Host-Port des Agent-Dashboards in einer übergeordneten Stack-Konfiguration. | TCP-Port 1–65535; in den aktuellen Compose-Dateien nicht automatisch verwendet. | nicht vom Agenten gelesen | 7080 | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `KB_SEARCH_PORT` | Compose/optionale KB-App | Host-Port der optionalen Knowledge-Suche. | TCP-Port 1–65535. | nicht vom Agenten gelesen | 7081 | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `KB_EDITOR_PORT` | Compose/optionale KB-App | Host-Port der optionalen Knowledge-Administration. | TCP-Port 1–65535. | nicht vom Agenten gelesen | 7082 | Optional; Wirkung abhängig von aktivierten Funktionen. | + +## 02. DOCKER COMPOSE - GEMEINSAME DATENVERZEICHNISSE +| ENV | Geltungsbereich | Bedeutung und Auswirkung | Mögliche Werte / Format | Code-Default | `.env.example` | Pflicht / Abhängigkeiten | +|---|---|---|---|---|---|---| +| `KB_DATA_PATH` | Compose/optionale KB-App | Gemeinsam gemountetes Knowledge-Verzeichnis auf dem Host. | Freier Text beziehungsweise installationsspezifischer Wert. | nicht vom Agenten gelesen | ./knowledge | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `KB_BACKUP_PATH` | Compose/optionale KB-App | Backup-Verzeichnis der optionalen KB-Verwaltung. | Freier Text beziehungsweise installationsspezifischer Wert. | nicht vom Agenten gelesen | ./backups | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `KB_STAGING_PATH` | Compose/optionale KB-App | Staging-Verzeichnis für neu erzeugte oder noch nicht freigegebene KB-Inhalte. | Freier Text beziehungsweise installationsspezifischer Wert. | nicht vom Agenten gelesen | ./staging | Optional; Wirkung abhängig von aktivierten Funktionen. | + +## 03. KNOWLEDGE-BASE WEBANWENDUNGEN – KB EDITOR +| ENV | Geltungsbereich | Bedeutung und Auswirkung | Mögliche Werte / Format | Code-Default | `.env.example` | Pflicht / Abhängigkeiten | +|---|---|---|---|---|---|---| +| `EDITOR_TITLE` | Compose/optionale KB-App | Titel der optionalen KB-Editor-Oberfläche. | Freier Text beziehungsweise installationsspezifischer Wert. | nicht vom Agenten gelesen | KB Administration | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `EDITOR_SUBTITLE` | Compose/optionale KB-App | Untertitel der optionalen KB-Editor-Oberfläche. | Freier Text beziehungsweise installationsspezifischer Wert. | nicht vom Agenten gelesen | Wissensbasis verwalten | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `EDITOR_AUTH_USER` | Compose/optionale KB-App | Basic-Auth-Benutzer der optionalen KB-Editor-Oberfläche. | Freier Text beziehungsweise installationsspezifischer Wert. | nicht vom Agenten gelesen | leer | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `EDITOR_AUTH_PASSWORD` | Compose/optionale KB-App | Basic-Auth-Passwort der optionalen KB-Editor-Oberfläche. | Geheimer Textwert; nicht in Logs, Tickets oder Screenshots veröffentlichen. | nicht vom Agenten gelesen | | Optional; Wirkung abhängig von aktivierten Funktionen. | + +## 03. KNOWLEDGE-BASE WEBANWENDUNGEN – KB SEARCH +| ENV | Geltungsbereich | Bedeutung und Auswirkung | Mögliche Werte / Format | Code-Default | `.env.example` | Pflicht / Abhängigkeiten | +|---|---|---|---|---|---|---| +| `SEARCH_TITLE` | Compose/optionale KB-App | Titel der optionalen KB-Suche. | Freier Text beziehungsweise installationsspezifischer Wert. | nicht vom Agenten gelesen | Stadt Hilden - KB-Datenbank | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `SEARCH_SUBTITLE` | Compose/optionale KB-App | Untertitel der optionalen KB-Suche. | Freier Text beziehungsweise installationsspezifischer Wert. | nicht vom Agenten gelesen | Interne Lösungsdatenbank | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `SEARCH_AUTH_USER` | Compose/optionale KB-App | Basic-Auth-Benutzer der optionalen KB-Suche. | Freier Text beziehungsweise installationsspezifischer Wert. | nicht vom Agenten gelesen | leer | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `SEARCH_AUTH_PASSWORD` | Compose/optionale KB-App | Basic-Auth-Passwort der optionalen KB-Suche. | Geheimer Textwert; nicht in Logs, Tickets oder Screenshots veröffentlichen. | nicht vom Agenten gelesen | | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `AUTO_RELOAD_INTERVAL` | Compose/optionale KB-App | Intervall, in dem die Suchanwendung die KB-Dateien erneut einliest. 30s 60s 5m | Go-Dauer, z. B. 250ms, 30s, 5m, 2h, 72h. Kein Suffix d; 24h statt 1d verwenden. | nicht vom Agenten gelesen | 60s | Optional; Wirkung abhängig von aktivierten Funktionen. | + +## 04. KNOWLEDGE-BASE WEBANWENDUNGEN - OLLAMA +| ENV | Geltungsbereich | Bedeutung und Auswirkung | Mögliche Werte / Format | Code-Default | `.env.example` | Pflicht / Abhängigkeiten | +|---|---|---|---|---|---|---| +| `AI_FALLBACK_ENABLED` | Compose/optionale KB-App | Aktiviert KI-Fallback in den optionalen KB-Webanwendungen, nicht im Agenten. | Freier Text beziehungsweise installationsspezifischer Wert. | nicht vom Agenten gelesen | true | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `OLLAMA_BASE_URL` | Compose/optionale KB-App | Ollama-URL der optionalen KB-Webanwendungen. | Absolute URL; vorzugsweise HTTPS, sofern nicht ausdrücklich lokaler Dienst. | nicht vom Agenten gelesen | http://ollama:11434 | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `OLLAMA_MODEL` | Agent + optionale KB-App | Chat-Modell. Diese Variable wird aktuell sowohl von den KB-Anwendungen als auch vom Agenten verwendet. Dadurch verwenden alle Anwendungen dasselbe Modell. | Freier Text beziehungsweise installationsspezifischer Wert. | qwen3:8b | qwen3:8b | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `OLLAMA_TIMEOUT` | Agent + optionale KB-App | Gemeinsamer Timeout. | Go-Dauer, z. B. 250ms, 30s, 5m, 2h, 72h. Kein Suffix d; 24h statt 1d verwenden. | 10m | 10m | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `OLLAMA_MAX_CONCURRENT` | Agent + optionale KB-App | Rückwärtskompatibler Parallelitätswert. Im Agenten dient er nur als Fallback für `OLLAMA_NODE_MAX_INFLIGHT`, wenn die neue Variable nicht gesetzt ist. | Ganzzahl 1–32. | 1 | 1 | Für neue Pool-Installationen `OLLAMA_NODE_MAX_INFLIGHT` verwenden. | +| `OLLAMA_STAGING_AUTO_REPLY` | Compose/optionale KB-App | Legt fest, ob von KB-Webanwendungen erzeugte Staging-Artikel auto_reply=true erhalten. | Freier Text beziehungsweise installationsspezifischer Wert. | nicht vom Agenten gelesen | false | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `OLLAMA_STAGING_MIN_SCORE` | Compose/optionale KB-App | min_score für von KB-Webanwendungen erzeugte Staging-Artikel. | Dezimalzahl; bei Scores typischerweise 0.0–1.0. | nicht vom Agenten gelesen | 0.70 | Optional; Wirkung abhängig von aktivierten Funktionen. | + +## 05. GLPI AI AGENT - ALLGEMEINER BETRIEB +| ENV | Geltungsbereich | Bedeutung und Auswirkung | Mögliche Werte / Format | Code-Default | `.env.example` | Pflicht / Abhängigkeiten | +|---|---|---|---|---|---|---| +| `DRY_RUN` | Agent | Der Agent analysiert vollständig, schreibt aber keine Änderungen nach GLPI. Durch die Policy freigegebene Aktionen werden tatsächlich ausgeführt. Für Tests / Einführung: true | true \| false | true | true | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `LOG_LEVEL` | Agent | debug info warn error | debug \| info \| warn \| error | info | info | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `HTTP_ADDR` | Agent | HTTP-Listener INNERHALB des Agent-Containers. AGENT_PORT oben bestimmt dagegen den veröffentlichten Host-Port. | Go-Listenadresse, z. B. :7080, 127.0.0.1:7080 oder 0.0.0.0:7080. | :8080 | :7080 | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `DATA_DIR` | Agent | Persistentes Verzeichnis IM Container. Compose mountet: agent-data:/app/data Enthält unter anderem: - Knowledge-Index - Audit/Run-Daten - Category Learning - Managed Knowledge - GLPI-KB-Cache | Freier Text beziehungsweise installationsspezifischer Wert. | ./data | /app/data | Optional; Wirkung abhängig von aktivierten Funktionen. | + +## 06. AGENT WEBUI / API / DIAGNOSE +| ENV | Geltungsbereich | Bedeutung und Auswirkung | Mögliche Werte / Format | Code-Default | `.env.example` | Pflicht / Abhängigkeiten | +|---|---|---|---|---|---|---| +| `WEB_USERNAME` | Agent | Benutzer für Agent-Dashboard, Knowledge-Verwaltung und Diagnose-Cockpit. | Freier Text beziehungsweise installationsspezifischer Wert. | leer | admin | Pflicht, wenn WEB_ALLOW_ANONYMOUS=false. | +| `WEB_PASSWORD` | Agent | Web Password. | Mindestens 12 Zeichen; darf keinen CHANGE_ME-Platzhalter enthalten. | leer | | Pflicht, wenn WEB_ALLOW_ANONYMOUS=false. | +| `WEB_ALLOW_ANONYMOUS` | Agent | Anmeldung erforderlich. Weboberfläche ohne Authentifizierung erreichbar. In Produktion normalerweise false. | true \| false | false | false | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `AI_CONTENT_LABEL_ENABLED` | Agent (derzeit ohne ENV-Bindung) | TrustedNet-Kennzeichnung vor automatisch ausgewählten Antworten. TrustedNet-KI-Badge wird vor Anrede und Antwort eingefügt. keine KI-Kennzeichnung. | true \| false; siehe Hinweis zur aktuellen Build-Abweichung. | effektiv false (Build-Abweichung) | true | Im aktuellen Quellstand nicht durch config.Load eingelesen; siehe bekannte Abweichungen. | + +## 07. OPTIONALER GLPI-WEBHOOK +| ENV | Geltungsbereich | Bedeutung und Auswirkung | Mögliche Werte / Format | Code-Default | `.env.example` | Pflicht / Abhängigkeiten | +|---|---|---|---|---|---|---| +| `WEBHOOK_SECRET` | Agent | Optionales Shared Secret für eingehende GLPI-Webhooks. Der Absender muss dasselbe Secret z. B. über: X-Webhook-Secret übertragen. Leer lassen, falls kein Webhook verwendet wird. | Leer = eingehender Webhook deaktiviert; gesetzt mindestens 24 Zeichen und kein CHANGE_ME-Platzhalter. | leer | | Leer deaktiviert POST /webhook/glpi vollständig. | + +## 08. GLPI 11 / HIGH-LEVEL API / OAUTH2 +| ENV | Geltungsbereich | Bedeutung und Auswirkung | Mögliche Werte / Format | Code-Default | `.env.example` | Pflicht / Abhängigkeiten | +|---|---|---|---|---|---|---| +| `GLPI_URL` | Agent | Glpi Url. | Absolute URL; vorzugsweise HTTPS, sofern nicht ausdrücklich lokaler Dienst. | leer | https://glpi.example.com | Pflicht. | +| `GLPI_API_VERSION` | Agent | Verwendete GLPI High-Level API. | API-Versionssegment, im Projekt für v2.3 ausgelegt. | v2.3 | v2.3 | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `GLPI_CLIENT_ID` | Agent | OAuth2 Service Account. | Geheimer Textwert; nicht in Logs, Tickets oder Screenshots veröffentlichen. | leer | | Pflicht. | +| `GLPI_CLIENT_SECRET` | Agent | Glpi Client Secret. | Geheimer Textwert; nicht in Logs, Tickets oder Screenshots veröffentlichen. | leer | | Pflicht. | +| `GLPI_USERNAME` | Agent | Glpi Username. | Freier Text beziehungsweise installationsspezifischer Wert. | leer | ai | Pflicht. | +| `GLPI_PASSWORD` | Agent | Glpi Password. | Geheimer Textwert; nicht in Logs, Tickets oder Screenshots veröffentlichen. | leer | | Pflicht. | +| `GLPI_AGENT_USER_ID` | Agent | Numerische GLPI-Benutzer-ID des Service-Accounts. Wird unter anderem benötigt, um Agent-Followups von menschlichen Followups unterscheiden zu können. | Positive numerische GLPI-Benutzer-ID; 0 = nicht gesetzt. | 0 | 999 | Pflicht bei AUTO_REPLY=true und AUTO_ESCALATION=true; auch im Shadow Mode zur Aktivitätserkennung empfohlen. | +| `GLPI_ALLOW_INSECURE_HTTP` | Agent | Nur für lokale Testsysteme ohne TLS. Produktion: false | true \| false; true nur für isolierte Tests. | false | false | Optional; Wirkung abhängig von aktivierten Funktionen. | + +## 09. GLPI TICKET-POLLING +| ENV | Geltungsbereich | Bedeutung und Auswirkung | Mögliche Werte / Format | Code-Default | `.env.example` | Pflicht / Abhängigkeiten | +|---|---|---|---|---|---|---| +| `GLPI_ALLOWED_STATUS_IDS` | Agent | Fail-closed Whitelist erlaubter GLPI-Ticketstatus. 1 1,2 Status 1 entspricht typischerweise "Neu". | Kommagetrennte positive Status-IDs, z. B. 1 oder 1,2. | nicht ermittelt | 1 | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `GLPI_POLL_INTERVAL` | Agent | Polling-Intervall. | Go-Dauer, z. B. 250ms, 30s, 5m, 2h, 72h. Kein Suffix d; 24h statt 1d verwenden. | 30s | 30s | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `GLPI_POLL_LIMIT` | Agent | Maximale Anzahl Tickets pro Poll. | Positive Ganzzahl; praktisch passend zur Ticketmenge und API-Latenz wählen. | 50 | 50 | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `GLPI_TICKET_FILTER` | Agent | Optionale serverseitige Vorfilterung. Die Agent-Policy prüft GLPI_ALLOWED_STATUS_IDS anschließend trotzdem selbst. Änderungen der Syntax immer gegen /api.php/doc der eigenen GLPI-Instanz prüfen. | GLPI-High-Level-API-Filterausdruck; Syntax gegen /api.php/doc prüfen. | leer | status.id==1 | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `GLPI_TIMEOUT` | Agent | HTTP-Timeout für GLPI-Aufrufe. | Go-Dauer, z. B. 250ms, 30s, 5m, 2h, 72h. Kein Suffix d; 24h statt 1d verwenden. | 20s | 20s | Optional; Wirkung abhängig von aktivierten Funktionen. | + +## 10. GLPI AI AGENT - OLLAMA +| ENV | Geltungsbereich | Bedeutung und Auswirkung | Mögliche Werte / Format | Code-Default | `.env.example` | Pflicht / Abhängigkeiten | +|---|---|---|---|---|---|---| +| `OLLAMA_URL` | Agent | Rückwärtskompatible Einzelnode-Adresse. Wird nur genutzt, wenn `OLLAMA_URLS` leer ist. | Absolute HTTP-/HTTPS-URL ohne Zugangsdaten. | http://ollama:11434 | http://ollama:11434 | Optional; bei leerem `OLLAMA_URLS` wirksam. | +| `OLLAMA_URLS` | Agent | Kommagetrennte Liste aller Ollama-Nodes. Jeder Node führt vollständige Inferenzrequests aus. | 1..64 absolute HTTP-/HTTPS-URLs, z. B. `http://10.0.0.21:11434,http://10.0.0.22:11434`. Keine Duplikate. | leer; effektiver Fallback auf `OLLAMA_URL` | leer | Für Poolbetrieb erforderlich. | +| `OLLAMA_NODE_NAMES` | Agent | Lesbare, positionsgleiche Namen für Dashboard, Metriken und AnalysisRun-Diagnose. | Kommagetrennte eindeutige, nicht leere Namen; Anzahl exakt wie `OLLAMA_URLS`. Leer = automatisch aus Hostname. | leer | leer | Optional. | +| `OLLAMA_NODE_WEIGHTS` | Agent | Positionsgleiche Leistungsgewichte für `weighted`. Höhere Werte erhalten anteilig mehr Requests. | Kommagetrennte Ganzzahlen 1–100; Anzahl exakt wie `OLLAMA_URLS`. Leer = Gewicht 1 je Node. | leer / effektiv 1 | leer | Nur für `OLLAMA_ROUTING_MODE=weighted`. | +| `OLLAMA_NODE_MAX_INFLIGHT` | Agent | Maximale gleichzeitig laufende Requests **je Node**. | Ganzzahl 1–32. Für integrierte GPUs zunächst 1. | 0 in Parser; effektiver Fallback auf `OLLAMA_MAX_CONCURRENT` = 1 | 1 | Zentraler Ressourcen-Schutz je Node. | +| `OLLAMA_ROUTING_MODE` | Agent | Auswahlstrategie für einen verfügbaren Node. | `least_inflight` \| `round_robin` \| `weighted` \| `fastest_recent` | least_inflight | least_inflight | `least_inflight` für gleichartige Nodes empfohlen. | +| `OLLAMA_NODE_HEALTH_INTERVAL` | Agent | Intervall der `/api/tags`-Prüfung auf Erreichbarkeit, Modelle und Digests. | Go-Dauer >= 1s. | 15s | 15s | Optional. | +| `OLLAMA_NODE_FAILURE_COOLDOWN` | Agent | Sperrzeit nach retryfähigem Requestfehler, um flappende Nodes vorübergehend nicht neu zu belasten. | Go-Dauer >= 0; 0 deaktiviert Cooldown. | 30s | 30s | Optional. | +| `OLLAMA_NODE_REQUEST_TIMEOUT` | Agent | Maximale Dauer eines einzelnen HTTP-Versuchs an genau einen Node. Ein kürzerer Analyse-Kontext-Timeout hat Vorrang. | Go-Dauer > 0. | 0 im Parser; effektiver Fallback auf `OLLAMA_TIMEOUT` = 10m | 10m | Optional. | +| `OLLAMA_FAILOVER_ENABLED` | Agent | Wiederholt einen noch nicht akzeptierten Inferenzrequest bei retryfähigem Fehler auf einem anderen kompatiblen Node. | true \| false | true | true | Kein GLPI-Write findet innerhalb des Failovers statt. | +| `OLLAMA_FAILOVER_ATTEMPTS` | Agent | Maximale Zahl verschiedener Nodes pro HTTP-Request. | 0 = automatisch alle Nodes; sonst Ganzzahl 1 bis Nodeanzahl. | 0 / effektiv Nodeanzahl | 0 | Nur bei aktiviertem Failover. | +| `OLLAMA_REQUIRE_SAME_MODEL_DIGEST` | Agent | Verlangt identische Chat- und erforderliche Embedding-Modelldigests. Bei Abweichung arbeitet der Pool vollständig fail-closed. | true \| false | true | true | Für reproduzierbare Entscheidungen empfohlen. | +| `OLLAMA_REQUIRE_EMBEDDING_MODEL` | Agent | Verlangt das konfigurierte Embedding-Modell auf jedem Node. Bei false dürfen Chat-only-Nodes teilnehmen; Embedding-Requests werden weiterhin nur an Nodes mit Embeddingmodell gesendet. | true \| false | true | true | Bei `RAG_ENABLED=true` empfohlen. | +| `OLLAMA_EMBEDDING_MODEL` | Agent | OLLAMA_MODEL ist bereits oben im gemeinsamen Compose-/Ollama-Bereich gesetzt: OLLAMA_MODEL=qwen3:8b Embedding-Modell für RAG. | Freier Text beziehungsweise installationsspezifischer Wert. | embeddinggemma | embeddinggemma | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `KNOWLEDGE_EMBEDDING_PROFILE` | Agent | Modellspezifisches Retrieval-Prompting. auto Modell automatisch erkennen und passende Retrieval-Prompts verwenden. Für embeddinggemma empfohlen. plain keine modellspezifischen Retrieval-Prompts. | auto \| plain \| embeddinggemma | auto | auto | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `OLLAMA_NUM_PREDICT` | Agent | OLLAMA_TIMEOUT und OLLAMA_MAX_CONCURRENT sind bereits oben gesetzt. Maximale Anzahl generierter Tokens für strukturierte Antworten. | Ganzzahl 1–4096. | 768 | 768 | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `OLLAMA_JSON_RETRIES` | Agent | Wiederholungen bei fehlerhaftem / abgeschnittenem JSON. | Ganzzahl 0–3. | 1 | 1 | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `OLLAMA_KEEP_ALIVE` | Agent | Ollama-Modell nach Benutzung im Speicher halten. 5m 10m 30m | Dauer >= 0; 0 ist zulässig. | 10m | 10m | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `OLLAMA_THINK` | Agent | Thinking bei unterstützten Modellen deaktivieren. Für strukturierte Klassifikations-/Policy-Aufgaben empfohlen. | true \| false | false | false | Optional; Wirkung abhängig von aktivierten Funktionen. | + +## 11. KNOWLEDGE BASE / RAG - BASIS +| ENV | Geltungsbereich | Bedeutung und Auswirkung | Mögliche Werte / Format | Code-Default | `.env.example` | Pflicht / Abhängigkeiten | +|---|---|---|---|---|---|---| +| `KNOWLEDGE_DIR` | Agent | Knowledge-Verzeichnis IM Agent-Container. Compose sollte hierhin KB_DATA_PATH mounten: ${KB_DATA_PATH:-./knowledge}:/app/knowledge:ro | Freier Text beziehungsweise installationsspezifischer Wert. | ./knowledge | /app/knowledge | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `RAG_ENABLED` | Agent | Gesamtes Retrieval-System aktivieren. | true \| false | true | true | Optional; Wirkung abhängig von aktivierten Funktionen. | + +## 12. EXTERNE KNOWLEDGE-KATEGORIEN +| ENV | Geltungsbereich | Bedeutung und Auswirkung | Mögliche Werte / Format | Code-Default | `.env.example` | Pflicht / Abhängigkeiten | +|---|---|---|---|---|---|---| +| `KNOWLEDGE_CATEGORY_MODE` | Agent | Verhalten bei String-/Fremdkategorien, z. B.: "AI-Staging" "Outlook" "E-Mail" "Signatur" unscoped Artikel bleibt nutzbar. Fremdkategorien können als Retrieval-Metadaten dienen. skip Artikel mit unbekannten Kategorien überspringen. strict unbekannte Kategorie als Fehler behandeln. Für eine gemeinsam mit anderen Anwendungen verwendete KB: unscoped | unscoped \| skip \| strict | unscoped | unscoped | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `KNOWLEDGE_CATEGORY_MAP_FILE` | Agent | Optionales Mapping von Fremdkategorien auf GLPI-ITIL-Kategorie-IDs. Beispiel knowledge-category-map.json: { "Outlook": 12, "E-Mail": 12, "Active Directory": 2, "Security": [20,21] } | Freier Text beziehungsweise installationsspezifischer Wert. | leer | /app/data/knowledge-category-map.json | Für den Mapping-Editor zusätzlich KNOWLEDGE_WEB_EDIT_ENABLED=true erforderlich. | +| `KNOWLEDGE_IGNORE_GLOBS` | Agent | Optional bestimmte KB-Dateien ignorieren. KB-SEC-ATTCK-*.json legacy-*.json,external-only-*.json keine zusätzlichen Ignore-Regeln. | Kommagetrennte filepath.Match-Globs; Groß-/Kleinschreibung bleibt erhalten. | leer | leer | Optional; Wirkung abhängig von aktivierten Funktionen. | + +## 13. PERSISTENTER KNOWLEDGE-INDEX +| ENV | Geltungsbereich | Bedeutung und Auswirkung | Mögliche Werte / Format | Code-Default | `.env.example` | Pflicht / Abhängigkeiten | +|---|---|---|---|---|---|---| +| `KNOWLEDGE_INDEX_MODE` | Agent | incremental Persistent gespeicherten Index sofort verwenden. Neue/geänderte Dateien anschließend inkrementell nachziehen. Für Produktion empfohlen. rebuild vollständigen Index neu erzeugen. readonly nur bestehenden Index verwenden, keine Änderungen übernehmen. | incremental \| rebuild \| readonly | incremental | incremental | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `KNOWLEDGE_EMBED_BATCH_SIZE` | Agent | Anzahl Texte pro Embedding-Batch. | 0 oder 1–256. | 64 | 64 | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `KNOWLEDGE_INDEX_SCAN_INTERVAL` | Agent | Intervall für neue/geänderte/gelöschte Dateien. 30s 1m 5m keinen automatischen Hintergrundscan durchführen. | Dauer >= 0; 0 deaktiviert Hintergrundscans. | 5m | 5m | Optional; Wirkung abhängig von aktivierten Funktionen. | + +## 14. RETRIEVAL / DYNAMISCHE KANDIDATENAUSWAHL +| ENV | Geltungsbereich | Bedeutung und Auswirkung | Mögliche Werte / Format | Code-Default | `.env.example` | Pflicht / Abhängigkeiten | +|---|---|---|---|---|---|---| +| `KNOWLEDGE_RETRIEVAL_FLOOR` | Agent | Unterhalb dieses Retrieval-Scores wird eine KB nicht als geeigneter Kandidat betrachtet. Der Wert ist KEINE Wahrscheinlichkeit. | 0.0–1.0. | 0.30 | 0.30 | Optional; Wirkung abhängig von aktivierten Funktionen. | + +## 14. RETRIEVAL / DYNAMISCHE KANDIDATENAUSWAHL – MAX_GAP 0.20 +| ENV | Geltungsbereich | Bedeutung und Auswirkung | Mögliche Werte / Format | Code-Default | `.env.example` | Pflicht / Abhängigkeiten | +|---|---|---|---|---|---|---| +| `KNOWLEDGE_CANDIDATE_MAX_GAP` | Agent | dynamischer Cutoff 0.62 Ein Kandidat mit 0.55 würde dann nicht an die KI gesendet. | 0.0–1.0. | 0.20 | 0.20 | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `KNOWLEDGE_TOP_K` | Agent | Maximale Anzahl Knowledge-Kandidaten, die tatsächlich an Ollama gehen. | 0 oder 1–20; 0 führt im Ticketpfad zum internen Fallback 6. | 6 | 6 | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `KNOWLEDGE_AUDIT_TOP_K` | Agent | Anzahl Kandidaten für Audit / Diagnose. Kann größer als KNOWLEDGE_TOP_K sein. | 0 oder mindestens KNOWLEDGE_TOP_K und höchstens 50. | 10 | 10 | Optional; Wirkung abhängig von aktivierten Funktionen. | + +## 15. HYBRID-RETRIEVAL - RANKING-GEWICHTE +| ENV | Geltungsbereich | Bedeutung und Auswirkung | Mögliche Werte / Format | Code-Default | `.env.example` | Pflicht / Abhängigkeiten | +|---|---|---|---|---|---|---| +| `KNOWLEDGE_WEIGHT_SEMANTIC` | Agent | Die Werte beschreiben die Gewichtung beim KB-Ranking. Summe aktuell: 1.0 Fehlende Metadaten sollen nicht automatisch negativ bewertet werden. Embedding-/Chunk-Semantik. | 0.0–1.0. | 0.45 | 0.45 | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `KNOWLEDGE_WEIGHT_TITLE` | Agent | Ticket-Betreff gegenüber KB-Titel. | 0.0–1.0. | 0.20 | 0.20 | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `KNOWLEDGE_WEIGHT_LEXICAL` | Agent | Lexikalische / sprachliche Übereinstimmung. | 0.0–1.0. | 0.20 | 0.20 | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `KNOWLEDGE_WEIGHT_KEYWORDS` | Agent | KB-Keywords. | 0.0–1.0. | 0.075 | 0.075 | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `KNOWLEDGE_WEIGHT_CATEGORY` | Agent | Kategorie-/Lernsignal. | 0.0–1.0. | 0.075 | 0.075 | Optional; Wirkung abhängig von aktivierten Funktionen. | + +## 16. FINALE EVIDENZ FÜR AUTO-REPLY +| ENV | Geltungsbereich | Bedeutung und Auswirkung | Mögliche Werte / Format | Code-Default | `.env.example` | Pflicht / Abhängigkeiten | +|---|---|---|---|---|---|---| +| `KNOWLEDGE_MIN_SCORE` | Agent | Mindestwert der FINALEN Evidenz. WICHTIG: Das ist nicht der reine Retrieval-Score. Die finale Evidenz kombiniert: - Retrieval - AI Confidence - Kategorieübereinstimmung | 0.0–1.0. | 0.70 | 0.70 | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `KNOWLEDGE_EVIDENCE_WEIGHT_RETRIEVAL` | Agent | Gewicht Retrieval. | >= 0; die Evidenzberechnung normalisiert durch die Summe aktiver Gewichte. | 0.45 | 0.45 | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `KNOWLEDGE_EVIDENCE_WEIGHT_AI` | Agent | Gewicht KI-Auswahl / KI-Confidence. | >= 0; die Evidenzberechnung normalisiert durch die Summe aktiver Gewichte. | 0.35 | 0.35 | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `KNOWLEDGE_EVIDENCE_WEIGHT_CATEGORY` | Agent | Gewicht Kategorieübereinstimmung. | >= 0; die Evidenzberechnung normalisiert durch die Summe aktiver Gewichte. | 0.20 | 0.20 | Optional; Wirkung abhängig von aktivierten Funktionen. | + +## 17. KNOWLEDGE-CHUNKING +| ENV | Geltungsbereich | Bedeutung und Auswirkung | Mögliche Werte / Format | Code-Default | `.env.example` | Pflicht / Abhängigkeiten | +|---|---|---|---|---|---|---| +| `KNOWLEDGE_CHUNK_WORDS` | Agent | Ungefähre Anzahl Wörter pro Dokument-Chunk. | 0 oder 40–1000. | 160 | 160 | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `KNOWLEDGE_CHUNK_OVERLAP_WORDS` | Agent | Überlappung benachbarter Chunks. | >= 0 und kleiner als KNOWLEDGE_CHUNK_WORDS. | 30 | 30 | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `KNOWLEDGE_MAX_CHUNKS_PER_DOC` | Agent | Maximale Anzahl Chunks pro KB-Dokument. | 0 oder 1–100. | 24 | 24 | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `KNOWLEDGE_MAX_QUERY_CHUNKS` | Agent | Maximale Anzahl Query-Chunks bei sehr langen Tickets. | 0 oder 1–200. | 64 | 64 | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `CATEGORY_PROMPT_LIMIT` | Agent | Maximale Anzahl Kategorien im Kategorie-Prompt. | Ganzzahl; 0 bedeutet je nach Variable deaktiviert/nicht gesetzt oder interner Fallback. | 80 | 80 | Optional; Wirkung abhängig von aktivierten Funktionen. | + +## 18. KNOWLEDGE-QUELLEN / TRUST POLICY +| ENV | Geltungsbereich | Bedeutung und Auswirkung | Mögliche Werte / Format | Code-Default | `.env.example` | Pflicht / Abhängigkeiten | +|---|---|---|---|---|---|---| +| `KNOWLEDGE_ALLOWED_SOURCES` | Agent | Quellen für normale Knowledge-Suche und mögliche Antwortkandidaten. Indexiert wird die Vereinigung mit KNOWLEDGE_CATEGORY_SOURCES. internal-kb glpi-kb runbook vendor-docs | Kommagetrennte, kleingeschriebene Source-Namen; mindestens ein Wert. | internal-kb | internal-kb,glpi-kb,vendor-docs,vendor-docs-ms,vendor-docs-linux,vendor-docs-sec | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `KNOWLEDGE_CATEGORY_SOURCES` | Agent | Quellen, die ausschließlich die Kategorieentscheidung unterstützen. Ohne explizite Angabe wird aus Kompatibilitätsgründen KNOWLEDGE_ALLOWED_SOURCES verwendet. Mit "none" wird Knowledge-Einfluss auf die Kategorisierung deaktiviert. | Kommagetrennte Source-Namen; none = keine Kategorie-KB. Nicht gesetzt = Rückfall auf KNOWLEDGE_ALLOWED_SOURCES. | leer | internal-category | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `KNOWLEDGE_AUTO_REPLY_SOURCES` | Agent | Nur diese Quellen dürfen grundsätzlich automatische Antworten liefern. Muss eine Teilmenge von KNOWLEDGE_ALLOWED_SOURCES sein. Beispiel zum kompletten Abschalten: KNOWLEDGE_AUTO_REPLY_SOURCES=none | Kommagetrennte Teilmenge von KNOWLEDGE_ALLOWED_SOURCES; none = keine Knowledge-Quelle für Auto-Reply. | internal-kb | internal-kb,glpi-kb,vendor-docs,vendor-docs-ms,vendor-docs-linux,vendor-docs-sec | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `KNOWLEDGE_WEB_EDIT_ENABLED` | Agent | Webbasierte Bearbeitung von Agent-eigenen Knowledge-Artikeln. Diese werden unter: DATA_DIR/knowledge-managed gespeichert. Das statische KNOWLEDGE_DIR bleibt read-only. | true \| false | false | true | Erfordert WEB_ALLOW_ANONYMOUS=false. | + +## 19. GLPI KNOWLEDGE BASE CONNECTOR +| ENV | Geltungsbereich | Bedeutung und Auswirkung | Mögliche Werte / Format | Code-Default | `.env.example` | Pflicht / Abhängigkeiten | +|---|---|---|---|---|---|---| +| `GLPI_KB_ENABLED` | Agent | GLPI-interne Knowledge Base synchronisieren. | true \| false | false | true | Aktiviert periodische Synchronisierung; Quelle muss in der Index-Source-Union enthalten sein. | +| `GLPI_KB_PATH` | Agent | Agent ermittelt die KnowbaseItem-Route aus /api.php/doc.json. | auto oder absoluter API-Pfad beginnend mit /. | auto | auto | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `GLPI_KB_FILTER` | Agent | Optionaler serverseitiger GLPI-Filter. alle für den Service Account sichtbaren Artikel, begrenzt durch LIMIT. | Freier Text beziehungsweise installationsspezifischer Wert. | leer | leer | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `GLPI_KB_LIMIT` | Agent | Maximale Anzahl GLPI-KB-Artikel. | 1–5000. | 500 | 500 | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `GLPI_KB_SYNC_INTERVAL` | Agent | Synchronisationsintervall. | Dauer >= 1m. | 10m | 10m | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `GLPI_KB_SOURCE` | Agent | source-Wert importierter GLPI-KB-Artikel. | Freier Text beziehungsweise installationsspezifischer Wert. | glpi-kb | glpi-kb | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `GLPI_KB_AUTO_REPLY` | Agent | GLPI-KB-Artikel können grundsätzlich Auto-Replies auslösen. Zusätzlich gelten weiterhin alle anderen Policy-Gates. | true \| false | false | true | Bei true: GLPI_KB_SOURCE muss in normalen und Auto-Reply-Quellen stehen; Kategorie-ID-Whitelist darf nicht leer sein. | +| `GLPI_KB_AUTO_REPLY_CATEGORY_IDS` | Agent | Whitelist der GLPI KNOWLEDGE-BASE-Kategorie-IDs. WICHTIG: Dies sind NICHT die ITIL-/Ticketkategorie-IDs. Mehrere Werte: 1,2,7 | Kommagetrennte positive GLPI-KB-Kategorie-IDs; leer/none = keine. | nicht ermittelt | 1 | Optional; Wirkung abhängig von aktivierten Funktionen. | + +## 20. HUMAN-IN-THE-LOOP / KATEGORIE-LERNEN +| ENV | Geltungsbereich | Bedeutung und Auswirkung | Mögliche Werte / Format | Code-Default | `.env.example` | Pflicht / Abhängigkeiten | +|---|---|---|---|---|---|---| +| `LEARNING_ENABLED` | Agent | Menschlich bestätigte/korrigierte Entscheidungen als Lernbeispiele verwenden. Der Agent lernt NICHT automatisch aus seinen eigenen unbestätigten Entscheidungen. | true \| false | true | true | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `LEARNING_MAX_EXAMPLES` | Agent | Maximale Anzahl gespeicherter Beispiele. | Bei aktiviertem Lernen 1–10000. | 500 | 500 | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `LEARNING_EXAMPLES_PER_CATEGORY` | Agent | Maximale Beispiele pro Kategorie im Prompt. | Bei aktiviertem Lernen 1–20. | 5 | 5 | Optional; Wirkung abhängig von aktivierten Funktionen. | + +## 21. KOMMUNIKATIONSPOLICY +| ENV | Geltungsbereich | Bedeutung und Auswirkung | Mögliche Werte / Format | Code-Default | `.env.example` | Pflicht / Abhängigkeiten | +|---|---|---|---|---|---|---| +| `COMMUNICATION_LANGUAGE` | Agent | Erwartete Sprache von Auto-Reply-KBs. | Freier Text beziehungsweise installationsspezifischer Wert. | de-DE | de-DE | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `COMMUNICATION_STYLE` | Agent | Erwarteter Kommunikationsstil. | formal \| neutral \| informal | formal | formal | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `COMMUNICATION_SALUTATION` | Agent | Wird vor die Knowledge-Antwort gesetzt. | Freier Text beziehungsweise installationsspezifischer Wert. | Guten Tag, | Guten Tag, | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `COMMUNICATION_CLOSING` | Agent | Abschluss. | Freier Text beziehungsweise installationsspezifischer Wert. | Mit freundlichen Grüßen | Mit freundlichen Grüßen | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `COMMUNICATION_SIGNATURE` | Agent | Communication Signature. | Freier Text beziehungsweise installationsspezifischer Wert. | IT-Service | IT-Service | Optional; Wirkung abhängig von aktivierten Funktionen. | + +## 22. OPERATIONAL CONTEXT - GLOBAL +| ENV | Geltungsbereich | Bedeutung und Auswirkung | Mögliche Werte / Format | Code-Default | `.env.example` | Pflicht / Abhängigkeiten | +|---|---|---|---|---|---|---| +| `CONTEXT_ENABLED` | Agent | Globaler Schalter für zusätzliche Betriebsinformationen: - Changes - Major Incidents - Requester-Geräte - Uptime Kuma | true \| false | true | true | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `CONTEXT_TIMEOUT` | Agent | Timeout für Kontextabfragen. | Go-Dauer, z. B. 250ms, 30s, 5m, 2h, 72h. Kein Suffix d; 24h statt 1d verwenden. | 12s | 12s | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `CONTEXT_RELEVANCE_MIN_SCORE` | Agent | Mindestscore, ab dem Incident/Outage als für das Ticket relevant gilt. | 0.0–1.0. | 0.20 | 0.20 | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `CONTEXT_BLOCK_AUTO_REPLY_ON_ERRORS` | Agent | Fehler einer aktivierten Kontextquelle blockieren Auto-Reply. Fail-closed und für Produktion empfohlen. | true \| false | true | true | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `CONTEXT_BLOCK_AUTO_REPLY_ON_INCIDENT` | Agent | relevante zentrale Störung blockiert individuelle Standardantwort. | true \| false | true | true | Optional; Wirkung abhängig von aktivierten Funktionen. | + +## 23. GLPI CHANGE CALENDAR +| ENV | Geltungsbereich | Bedeutung und Auswirkung | Mögliche Werte / Format | Code-Default | `.env.example` | Pflicht / Abhängigkeiten | +|---|---|---|---|---|---|---| +| `CHANGE_CALENDAR_ENABLED` | Agent | Change Calendar Enabled. | true \| false | true | true | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `GLPI_CHANGE_PATH` | Agent | API-Route. | Absoluter API-Pfad, z. B. /Assistance/Change. | /Assistance/Change | /Assistance/Change | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `GLPI_CHANGE_FILTER` | Agent | Optionaler serverseitiger GLPI-Filter. | Freier Text beziehungsweise installationsspezifischer Wert. | leer | leer | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `GLPI_CHANGE_LIMIT` | Agent | Maximale Anzahl geladener Changes. | 1–1000. | 100 | 100 | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `CHANGE_LOOKBACK` | Agent | Betrachteter Zeitraum in der Vergangenheit. | Go-Dauer, z. B. 250ms, 30s, 5m, 2h, 72h. Kein Suffix d; 24h statt 1d verwenden. | 48h | 72h | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `CHANGE_LOOKAHEAD` | Agent | Betrachteter Zeitraum in der Zukunft. | Go-Dauer, z. B. 250ms, 30s, 5m, 2h, 72h. Kein Suffix d; 24h statt 1d verwenden. | 24h | 24h | Optional; Wirkung abhängig von aktivierten Funktionen. | + +## 24. MAJOR INCIDENTS +| ENV | Geltungsbereich | Bedeutung und Auswirkung | Mögliche Werte / Format | Code-Default | `.env.example` | Pflicht / Abhängigkeiten | +|---|---|---|---|---|---|---| +| `MAJOR_INCIDENTS_ENABLED` | Agent | Major Incidents über GLPI-Tickets ermitteln. Erst aktivieren, wenn GLPI_MAJOR_INCIDENT_FILTER getestet wurde. | true \| false | false | false | Bei true ist GLPI_MAJOR_INCIDENT_FILTER Pflicht. | +| `GLPI_MAJOR_INCIDENT_FILTER` | Agent | Expliziter Filter für Tickets, die als Major Incident gelten. | Freier Text beziehungsweise installationsspezifischer Wert. | leer | leer | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `GLPI_MAJOR_INCIDENT_LIMIT` | Agent | Glpi Major Incident Limit. | 1–500. | 20 | 20 | Optional; Wirkung abhängig von aktivierten Funktionen. | + +## 25. REQUESTER -> GERÄT / ASSET CONTEXT +| ENV | Geltungsbereich | Bedeutung und Auswirkung | Mögliche Werte / Format | Code-Default | `.env.example` | Pflicht / Abhängigkeiten | +|---|---|---|---|---|---|---| +| `USER_DEVICE_CONTEXT_ENABLED` | Agent | Zusätzlich zu direkt verknüpften Ticket-Assets Geräte des Requesters suchen. | true \| false | true | true | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `GLPI_USER_DEVICE_PATHS` | Agent | Asset-Routen. | Kommagetrennte absolute API-Pfade. | /Assets/Computer | /Assets/Computer | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `GLPI_USER_DEVICE_FILTER_TEMPLATE` | Agent | {{user_id}} wird vom Agenten ersetzt. | Filtertext mit zwingendem Platzhalter {{user_id}}. | user.id=={{user_id}} | user.id=={{user_id}} | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `GLPI_USER_DEVICE_LIMIT` | Agent | Maximale Anzahl Geräte je Suche. | 1–500. | 20 | 20 | Optional; Wirkung abhängig von aktivierten Funktionen. | + +## 26. UPTIME KUMA +| ENV | Geltungsbereich | Bedeutung und Auswirkung | Mögliche Werte / Format | Code-Default | `.env.example` | Pflicht / Abhängigkeiten | +|---|---|---|---|---|---|---| +| `UPTIME_KUMA_ENABLED` | Agent | Globaler Schalter für Uptime-Kuma-Kontext. | true \| false | false | false | Bei true: URL Pflicht; metrics benötigt API-Key, status_page benötigt Slugs. | +| `UPTIME_KUMA_URL` | Agent | Uptime Kuma Url. | Absolute URL; vorzugsweise HTTPS, sofern nicht ausdrücklich lokaler Dienst. | leer | https://uptime.example.com | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `UPTIME_KUMA_MODE` | Agent | metrics authentifizierte Prometheus-Metrics. status_page öffentliche/publizierte Statusseiten. | metrics \| status_page | metrics | metrics | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `UPTIME_KUMA_API_KEY` | Agent | Nur in metrics erforderlich. | Geheimer Textwert; nicht in Logs, Tickets oder Screenshots veröffentlichen. | leer | | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `UPTIME_KUMA_STATUS_PAGES` | Agent | Nur in status_page erforderlich. Mehrere Slugs: it-services,network,applications | Kommagetrennte Liste; Leerzeichen werden an den Rändern entfernt. | leer | it-services | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `UPTIME_KUMA_TIMEOUT` | Agent | Uptime Kuma Timeout. | Go-Dauer, z. B. 250ms, 30s, 5m, 2h, 72h. Kein Suffix d; 24h statt 1d verwenden. | 10s | 10s | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `UPTIME_KUMA_MAX_ISSUES` | Agent | Maximale Anzahl gleichzeitig berücksichtigter Probleme. | 1–200. | 20 | 20 | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `UPTIME_KUMA_INCLUDE_MAINTENANCE` | Agent | Maintenance ebenfalls als Kontext berücksichtigen. | true \| false | true | true | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `CONTEXT_STATUS_REPLY_ENABLED` | Agent | Optional: bei eindeutig passender Uptime-Kuma-Störung oder Wartung einen ausschließlich vom Betreiber vorgegebenen Text senden. Die KI erzeugt keinen Antworttext; sie wählt nur einen aktiven Kandidaten und liefert eine Confidence. | true \| false | false | false | Erfordert CONTEXT_ENABLED=true, UPTIME_KUMA_ENABLED=true und beide vordefinierten Textvorlagen. | +| `CONTEXT_STATUS_REPLY_MIN_RELEVANCE` | Agent | Context Status Reply Min Relevance. | 0.0–1.0. | 0.50 | 0.50 | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `CONTEXT_STATUS_REPLY_MIN_AI_CONFIDENCE` | Agent | Context Status Reply Min Ai Confidence. | 0.0–1.0. | 0.80 | 0.80 | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `CONTEXT_STATUS_REPLY_MIN_FINAL_SCORE` | Agent | Finaler Score = Relevanz × KI-Confidence. | 0.0–1.0. | 0.45 | 0.45 | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `CONTEXT_INCIDENT_REPLY_TEXT` | Agent | Literal \n wird als Zeilenumbruch interpretiert. Verfügbare Platzhalter: {{service_name}}, {{status}}, {{status_page}}, {{message}}, {{incident_title}}, {{incident_content}}, {{last_heartbeat}} | Textvorlage; literales \n wird zu einem Zeilenumbruch. Nur dokumentierte Platzhalter verwenden. | leer | Zu Ihrer Meldung liegt derzeit wahrscheinlich eine zentrale Störung bei {{service_name}} vor. Die Einschränkung kann damit zusammenhängen. Wir beobachten den Status. | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `CONTEXT_MAINTENANCE_REPLY_TEXT` | Agent | Context Maintenance Reply Text. | Textvorlage; literales \n wird zu einem Zeilenumbruch. Nur dokumentierte Platzhalter verwenden. | leer | Für {{service_name}} läuft derzeit eine Wartung. Die von Ihnen beschriebene Einschränkung kann damit zusammenhängen. Bitte testen Sie den Dienst nach Abschluss der Wartung erneut. | Optional; Wirkung abhängig von aktivierten Funktionen. | + +## 27. POLICY-GATES +| ENV | Geltungsbereich | Bedeutung und Auswirkung | Mögliche Werte / Format | Code-Default | `.env.example` | Pflicht / Abhängigkeiten | +|---|---|---|---|---|---|---| +| `AUTO_CATEGORY` | Agent | Automatische Kategorisierung zulassen. | true \| false | true | true | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `AUTO_REPLY` | Agent | Automatische Antworten grundsätzlich zulassen. DRY_RUN=true verhindert trotzdem das tatsächliche Schreiben nach GLPI. | true \| false | false | true | true erfordert GLPI_AGENT_USER_ID und mindestens eine Auto-Reply-Quelle. | +| `CATEGORY_CONFIDENCE` | Agent | Mindestconfidence der KI für Kategorieänderungen. | 0.0–1.0. | 0.90 | 0.90 | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `REPLY_CONFIDENCE` | Agent | Mindestconfidence der KI für Antwortauswahl. Dies allein reicht NICHT für Auto-Reply. Zusätzlich gelten unter anderem: - Knowledge-Evidenz - Retrieval-Regeln - Source Policy - KB auto_reply - Kommunikationspolicy - Followup-Prüfung - Kontext-/Incident-Regeln - zweite Followup-Prüfung unmittelbar vor dem Schreiben | 0.0–1.0. | 0.97 | 0.97 | Optional; Wirkung abhängig von aktivierten Funktionen. | + +## 28. KI-PRIORISIERUNG +| ENV | Geltungsbereich | Bedeutung und Auswirkung | Mögliche Werte / Format | Code-Default | `.env.example` | Pflicht / Abhängigkeiten | +|---|---|---|---|---|---|---| +| `PRIORITY_ENABLED` | Agent | Separater KI-Lauf zur Empfehlung der GLPI-Priorität. Der Lauf wird im Diagnose-Cockpit unabhängig von Kategorie, Status und Antwort gespeichert. | true \| false | true | true | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `AUTO_PRIORITY` | Agent | Standardmäßig Shadow Mode: Empfehlung und Policy-Gates werden protokolliert, GLPI wird nicht verändert. Für Live-Schreibzugriffe zusätzlich DRY_RUN=false. | true \| false | false | false | true erfordert PRIORITY_ENABLED=true; tatsächlicher Write zusätzlich DRY_RUN=false. | +| `PRIORITY_CONFIDENCE` | Agent | Priority Confidence. | 0.0–1.0. | 0.88 | 0.88 | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `PRIORITY_ANALYSIS_TIMEOUT` | Agent | Eigener Fail-open-Timeout für diesen optionalen KI-Lauf. Kategorie und Antwort laufen danach weiter. | Dauer >= 0; 0 = kein eigener Stufen-Timeout. | 45s | 45s | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `PRIORITY_MAX_INCREASE` | Agent | Automatische Erhöhung je Ticketlauf; Herabstufungen sind grundsätzlich gesperrt. | 0–5; bei AUTO_PRIORITY=true mindestens 1. | 1 | 1 | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `PRIORITY_ALLOWED_REASON_CODES` | Agent | Nur kontrollierte, kommaseparierte Grundcodes dürfen eine Empfehlung tragen. | Kommagetrennte Reason Codes; bei PRIORITY_ENABLED=true mindestens einer. | 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 | 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 | Optional; Wirkung abhängig von aktivierten Funktionen. | + +## 29. ZEITGESTEUERTE KI-ESKALATION +| ENV | Geltungsbereich | Bedeutung und Auswirkung | Mögliche Werte / Format | Code-Default | `.env.example` | Pflicht / Abhängigkeiten | +|---|---|---|---|---|---|---| +| `ESCALATION_ENABLED` | Agent | Unabhängiger Scheduler. Er prüft offene Tickets auch ohne Änderung von date_mod. | true \| false | false | false | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `AUTO_ESCALATION` | Agent | Standardmäßig werden nur Diagnose-/Shadow-Läufe erzeugt. Live-Ausführung benötigt zusätzlich DRY_RUN=false und GLPI_AGENT_USER_ID. | true \| false | false | false | true erfordert ESCALATION_ENABLED=true, mindestens eine ausführbare Aktion, Zielkonfiguration und DRY_RUN=false für Writes. | +| `ESCALATION_SCAN_INTERVAL` | Agent | Escalation Scan Interval. | Dauer >= 1m. | 15m | 15m | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `ESCALATION_MIN_AGE` | Agent | Mindestalter des Tickets seit date_creation, bevor es in den Eskalationsscan gelangt. | Dauer >= 1m. | 4h | 4h | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `ESCALATION_MIN_INACTIVITY` | Agent | Mindestdauer seit der letzten menschlichen Aktivität für den Grund no_human_response. SLA-, Security- und Major-Incident-Gründe können unabhängig davon greifen. Agent-Followups werden über GLPI_AGENT_USER_ID ausgenommen. | 0 oder Dauer >= 1m; 0 verwendet ESCALATION_MIN_AGE. | 2h | 2h | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `ESCALATION_ANALYSIS_TIMEOUT` | Agent | Eigenes KI-Zeitbudget; blockiert die normalen Ticketläufe nicht unbegrenzt. | Dauer >= 0; 0 = kein eigener Stufen-Timeout. | 45s | 45s | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `ESCALATION_CONFIDENCE` | Agent | Escalation Confidence. | 0.0–1.0. | 0.88 | 0.88 | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `ESCALATION_MAX_LEVEL` | Agent | Escalation Max Level. | 1–4. | 3 | 3 | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `ESCALATION_SLA_RISK_WINDOW` | Agent | Zeitfenster vor time_to_resolve, in dem sla_at_risk deterministisch wahr wird. | Dauer >= 0; 0 deaktiviert sla_at_risk. | 2h | 2h | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `ESCALATION_SERVICE_OWNER_MIN_LEVEL` | Agent | Aktionsspezifische Mindeststufen. | 0 oder 1–4; 0 ergibt Laufzeit-Fallback Stufe 2. | 2 | 2 | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `ESCALATION_MANAGER_REVIEW_MIN_LEVEL` | Agent | Escalation Manager Review Min Level. | 0 oder 1–4; 0 ergibt Laufzeit-Fallback Stufe 3. | 3 | 3 | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `ESCALATION_MAJOR_INCIDENT_MIN_RELEVANCE` | Agent | Mindest-Relevanz eines vom Kontextkollektor gelieferten Major Incidents. | 0.0–1.0. | 0.50 | 0.50 | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `ESCALATION_ALLOWED_REASON_CODES` | Agent | Escalation Allowed Reason Codes. | Kommagetrennte kontrollierte Eskalationsgründe; mindestens einer bei aktivierter Eskalation. | no_human_response,sla_at_risk,sla_breached,business_deadline,no_workaround,security_incident_suspected,unassigned,major_incident_candidate | no_human_response,sla_at_risk,sla_breached,business_deadline,no_workaround,security_incident_suspected,unassigned,major_incident_candidate | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `ESCALATION_ALLOWED_ACTIONS` | Agent | Jede Aktion muss einzeln freigegeben werden. Sichere Einführung: zunächst nur none,raise_priority; weitere Aktionen erst nach Konfiguration der Ziele aktivieren. Verfügbar: none,raise_priority,assign_second_level,assign_security_team, notify_service_owner,link_major_incident,request_manager_review | none \| raise_priority \| assign_second_level \| assign_security_team \| notify_service_owner \| link_major_incident \| request_manager_review; kommasepariert. | none,raise_priority | none,raise_priority | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `ESCALATION_SECOND_LEVEL_GROUP_ID` | Agent | Zielgruppen/-benutzer für Zuweisungs- und Benachrichtigungsaktionen. Es handelt sich um numerische GLPI-IDs. | Numerische GLPI-Gruppen-ID; 0 = nicht konfiguriert. | 0 | 0 | Pflicht im Livebetrieb, wenn assign_second_level freigegeben ist. | +| `ESCALATION_SECURITY_GROUP_ID` | Agent | Escalation Security Group Id. | Numerische GLPI-Gruppen-ID; 0 = nicht konfiguriert. | 0 | 0 | Pflicht im Livebetrieb, wenn assign_security_team freigegeben ist. | +| `ESCALATION_SERVICE_OWNER_GROUP_ID` | Agent | Escalation Service Owner Group Id. | Numerische GLPI-Gruppen-ID; 0 = nicht konfiguriert. | 0 | 0 | Mindestens Gruppe, Benutzer oder Webhook für notify_service_owner. | +| `ESCALATION_SERVICE_OWNER_USER_ID` | Agent | Escalation Service Owner User Id. | Numerische GLPI-Benutzer-ID; 0 = nicht konfiguriert. | 0 | 0 | Mindestens Gruppe, Benutzer oder Webhook für notify_service_owner. | +| `ESCALATION_MANAGER_REVIEW_GROUP_ID` | Agent | Escalation Manager Review Group Id. | Numerische GLPI-Gruppen-ID; 0 = nicht konfiguriert. | 0 | 0 | Mindestens Gruppe, Benutzer oder Webhook für request_manager_review. | +| `ESCALATION_MANAGER_REVIEW_USER_ID` | Agent | Escalation Manager Review User Id. | Numerische GLPI-Benutzer-ID; 0 = nicht konfiguriert. | 0 | 0 | Mindestens Gruppe, Benutzer oder Webhook für request_manager_review. | +| `ESCALATION_ADD_PRIVATE_FOLLOWUP` | Agent | Zu jeder ausgeführten Aktion kann ein privater GLPI-Followup geschrieben werden. | true \| false | true | true | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `ESCALATION_SECOND_LEVEL_NOTE` | Agent | Escalation Second Level Note. | Textvorlage; literales \n wird zu einem Zeilenumbruch. Nur dokumentierte Platzhalter verwenden. | Automatische Eskalation Stufe {{level}}: Übergabe an den Second-Level-Support. Gründe: {{reason_codes}}. KI-Begründung: {{reason}} | Automatische Eskalation Stufe {{level}}: Übergabe an den Second-Level-Support. Gründe: {{reason_codes}}. KI-Begründung: {{reason}} | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `ESCALATION_SECURITY_NOTE` | Agent | Escalation Security Note. | Textvorlage; literales \n wird zu einem Zeilenumbruch. Nur dokumentierte Platzhalter verwenden. | Automatische Eskalation Stufe {{level}}: Übergabe an das Security-Team. Gründe: {{reason_codes}}. KI-Begründung: {{reason}} | Automatische Eskalation Stufe {{level}}: Übergabe an das Security-Team. Gründe: {{reason_codes}}. KI-Begründung: {{reason}} | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `ESCALATION_SERVICE_OWNER_NOTE` | Agent | Escalation Service Owner Note. | Textvorlage; literales \n wird zu einem Zeilenumbruch. Nur dokumentierte Platzhalter verwenden. | Automatische Eskalation Stufe {{level}}: Service Owner wurde zur Prüfung einbezogen. Gründe: {{reason_codes}}. KI-Begründung: {{reason}} | Automatische Eskalation Stufe {{level}}: Service Owner wurde zur Prüfung einbezogen. Gründe: {{reason_codes}}. KI-Begründung: {{reason}} | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `ESCALATION_MAJOR_INCIDENT_NOTE` | Agent | Escalation Major Incident Note. | Textvorlage; literales \n wird zu einem Zeilenumbruch. Nur dokumentierte Platzhalter verwenden. | Automatische Eskalation Stufe {{level}}: Verknüpfung mit Major Incident #{{major_incident_id}} ({{major_incident_name}}). Relevanz: {{major_incident_score}}. Gründe: {{reason_codes}}. | Automatische Eskalation Stufe {{level}}: Verknüpfung mit Major Incident #{{major_incident_id}} ({{major_incident_name}}). Relevanz: {{major_incident_score}}. Gründe: {{reason_codes}}. | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `ESCALATION_MANAGER_REVIEW_NOTE` | Agent | Escalation Manager Review Note. | Textvorlage; literales \n wird zu einem Zeilenumbruch. Nur dokumentierte Platzhalter verwenden. | Automatische Eskalation Stufe {{level}}: Management-Review angefordert. Gründe: {{reason_codes}}. KI-Begründung: {{reason}} | Automatische Eskalation Stufe {{level}}: Management-Review angefordert. Gründe: {{reason_codes}}. KI-Begründung: {{reason}} | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `ESCALATION_WEBHOOK_URL` | Agent | Optionaler ausgehender Webhook für Service-Owner- und Management-Benachrichtigungen. Das Token wird nie über die Status-API ausgegeben. | Absolute http(s)-URL; HTTP nur mit ESCALATION_WEBHOOK_ALLOW_INSECURE_HTTP=true. | leer | leer | Optional; Ziel für Service-Owner-/Management-Benachrichtigungen. | +| `ESCALATION_WEBHOOK_BEARER_TOKEN` | Agent | Escalation Webhook Bearer Token. | Geheimer Textwert; nicht in Logs, Tickets oder Screenshots veröffentlichen. | leer | leer | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `ESCALATION_WEBHOOK_TIMEOUT` | Agent | Escalation Webhook Timeout. | Dauer > 0, wenn eine URL gesetzt ist. | 10s | 10s | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `ESCALATION_WEBHOOK_ALLOW_INSECURE_HTTP` | Agent | Nur für isolierte Testnetze; HTTPS ist der sichere Standard. | true \| false; true nur für isolierte Tests. | false | false | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `GLPI_ESCALATION_GROUP_PATCH_FIELD` | Agent | GLPI-Adapter für Zuweisungen. Die Feldnamen müssen zur OpenAPI-Beschreibung der konkreten GLPI-Installation passen. Unterstützte Payload-Formen: assigned_groups/assigned_users = Liste von {"id":...}; group/group_tech/user/user_tech = einzelnes {"id":...}. | Einfacher JSON-Feldname aus Buchstaben, Ziffern und Unterstrich. | assigned_groups | assigned_groups | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `GLPI_ESCALATION_USER_PATCH_FIELD` | Agent | Glpi Escalation User Patch Field. | Einfacher JSON-Feldname aus Buchstaben, Ziffern und Unterstrich. | assigned_users | assigned_users | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `GLPI_ESCALATION_ITIL_LINK_PATH` | Agent | Installationsspezifischer Adapter für link_major_incident. Beide Werte sind erforderlich. Platzhalter im Pfad/JSON: {{ticket_id}}, {{source_ticket_id}}, {{major_incident_id}}, {{target_ticket_id}}. | Absoluter API-Pfad ohne Query/Fragment, mit Ticket-/Major-Incident-Platzhaltern. | leer | leer | Gemeinsam mit GLPI_ESCALATION_ITIL_LINK_BODY; Pflicht für live link_major_incident. | +| `GLPI_ESCALATION_ITIL_LINK_BODY` | Agent | Glpi Escalation Itil Link Body. | Gültiges JSON nach Platzhalterersetzung; muss Quell- und Ziel-ID referenzieren. | leer | leer | Gemeinsam mit GLPI_ESCALATION_ITIL_LINK_PATH; Pflicht für live link_major_incident. | +| `GLPI_ESCALATION_FILTER` | Agent | Leer = GLPI_TICKET_FILTER verwenden. Für Produktion ausdrücklich auf offene, eskalierbare Status und die gewünschte Einheit beschränken. | GLPI-Filter; leer = GLPI_TICKET_FILTER. | leer | leer | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `GLPI_ESCALATION_LIMIT` | Agent | Glpi Escalation Limit. | 1–1000. | 100 | 100 | Optional; Wirkung abhängig von aktivierten Funktionen. | + +## 30. WORKER / PRIORITÄTSQUEUE +| ENV | Geltungsbereich | Bedeutung und Auswirkung | Mögliche Werte / Format | Code-Default | `.env.example` | Pflicht / Abhängigkeiten | +|---|---|---|---|---|---|---| +| `QUEUE_SIZE` | Agent | Maximale Anzahl wartender Jobs. | Ganzzahl >= 1. | 256 | 256 | Optional; Wirkung abhängig von aktivierten Funktionen. | +| `WORKERS` | Agent | Parallele Ticket-Worker. Darf größer als die Gesamtzahl gleichzeitig verfügbarer Node-Slots sein. Ollama wird je Node durch OLLAMA_NODE_MAX_INFLIGHT begrenzt. | Ganzzahl >= 1. | 2 | 2 | Optional; Wirkung abhängig von aktivierten Funktionen. | + +> **Vollständigkeitskontrolle:** In dieser Referenz sind 178 Variablen beschrieben, einschließlich `AGENT_IMAGE` aus dem Registry-Compose und aller 177 Zuweisungen aus `.env.example`. + + +# 14. Fehlerbehebung + +## 14.1 Keine Tickets werden verarbeitet + +1. Dashboard-Pollhinweis lesen. +2. `fetched=0`: GLPI-Filter, Rechte und API prüfen. +3. `fetched>0`, `unseen=0`: alle Treffer stehen in `state-index.json`; neues/geändertes Ticket oder manuelle Neuanalyse verwenden. +4. `unseen>0`, `enqueued=0`, `rejected>0`: Queue voll oder Trigger bereits pending. +5. `enqueued>0`, aber kein Run: Worker, Ollama-Limit und Logs prüfen. +6. `knowledge_ready=false`: erster Indexaufbau läuft oder ist fehlgeschlagen; Ticketverarbeitung wartet. + +## 14.2 Knowledge bleibt nicht bereit + +- `KNOWLEDGE_DIR` existiert und ist lesbar? +- `DATA_DIR` schreibbar? +- Embeddingmodell vorhanden? +- `KNOWLEDGE_INDEX_MODE=readonly` ohne Snapshot? +- Ungültiges JSON, Source nicht erlaubt oder `strict`-Kategoriefehler? +- `/api/status` Felder `knowledge_init_error` und `knowledge_last_scan_error` prüfen. + +## 14.3 Agent startet nicht + +Häufige Konfigurationsfehler: + +- fehlende GLPI-Pflichtvariablen; +- Webpasswort unter 12 Zeichen; +- HTTP-GLPI ohne ausdrückliche Testfreigabe; +- Auto-Reply ohne Agent-Benutzer-ID; +- Auto-Priority ohne Priority-Analyse; +- Auto-Escalation ohne Aktion/Ziel; +- Major Incidents ohne Filter; +- Uptime Kuma im falschen Modus ohne Key/Slug; +- Statusreply ohne Templates; +- ungültiger ITIL-Linkadapter. + +## 14.4 Auto-Reply wird nicht geschrieben + +In der Diagnose die blockierenden Gates prüfen: vorhandener Followup, KI-Ablehnung, Confidence, Source, `auto_reply`, Sprache, Stil, Retrieval-Floor, finale Evidenz, Kategorie-Scope, Kontextfehler, relevanter Incident oder Ticketänderung vor Write. + +## 14.5 Eskalationsaktion bleibt im Shadow Mode + +Ein Schritt ist nur live, wenn gleichzeitig gilt: + +```env +ESCALATION_ENABLED=true +AUTO_ESCALATION=true +DRY_RUN=false +``` + +Zusätzlich müssen Aktion, Ziel, Mindeststufe, Reason Codes, Evidenz, Confidence und Idempotenz passen. + +## 14.6 Port nicht erreichbar + +Listener `HTTP_ADDR` und Container-Mapping müssen denselben Containerport verwenden. Bei nativem Betrieb Firewall und Bind-Adresse prüfen. `127.0.0.1` erlaubt nur lokalen Zugriff; `:7080` bindet alle Interfaces. + +## 14.7 Ollama-Pool hat keine verfügbaren Nodes + +1. `/api/status` prüfen: `ollama_nodes`, `healthy`, `compatible`, `last_error` und Digests. +2. Auf jedem Node `OLLAMA_MODEL` und `OLLAMA_EMBEDDING_MODEL` installieren. +3. Bei Digest-Abweichung die Modell-Tags auf allen Nodes erneut auf denselben Stand ziehen; nicht vorschnell `OLLAMA_REQUIRE_SAME_MODEL_DIGEST=false` setzen. +4. Firewall prüfen: Der Agent muss `/api/tags`, `/api/chat` und `/api/embed` erreichen. +5. `OLLAMA_NODE_MAX_INFLIGHT=1` verwenden und prüfen, ob Requests nur wegen voller Slots warten. +6. Nach einem Fehler `cooldown_until` beachten; der Node wird während des Cooldowns absichtlich nicht gewählt. +7. Neue AnalysisRuns unter `provider.attempts` prüfen. Dort stehen Node, HTTP-Status, Timeout, Retryfähigkeit und Failover. + +## 14.8 Pool verteilt nicht wie erwartet + +- `least_inflight` verteilt nach aktuell laufenden Requests, nicht streng abwechselnd. Bei seriellen Tests kann daher derselbe schnellere Node mehrfach gewählt werden. +- `round_robin` für eine sichtbar zyklische Verteilung verwenden. +- `weighted` benötigt positionsgleiche `OLLAMA_NODE_WEIGHTS`. +- `fastest_recent` bevorzugt die gemessene gleitende Durchschnittslaufzeit und kann langsame Nodes bewusst selten verwenden. +- Ein einzelner KI-Request wird nicht über mehrere Rechner beschleunigt; der Nutzen entsteht bei mehreren parallelen Tickets oder Analyseläufen. + +# 15. Bekannte Grenzen und Abweichungen + +1. **`AI_CONTENT_LABEL_ENABLED`:** Das Feld ist im Modell und in der Policy vorhanden, wird im vorliegenden `config.Load()` aber nicht aus der ENV geladen. Bei normalem Start bleibt der effektive Wert daher `false`, unabhängig von `.env.example`. Vor Nutzung der Kennzeichnung ist eine Codekorrektur erforderlich. +2. **Compose-Portabweichung:** `.env.example` setzt `HTTP_ADDR=:7080`; `docker-compose.yml` mappt jedoch `8080:8080`. Unverändert zusammen verwendet sind Listener und Mapping inkonsistent. `compose_local.yml` passt zu 7080. +3. **`AGENT_PORT`:** Wird in den vorliegenden Compose-Dateien nicht referenziert und ändert den Agent-Listener nicht. Maßgeblich ist `HTTP_ADDR` plus Port-Mapping. +4. **Optionale KB-Webanwendungen:** Die ENV-Blöcke für Editor/Search/Fallback gehören zu einem größeren Stack. Die aktuellen Compose-Dateien dieses Pakets starten nur Agent und Ollama; diese Variablen haben dort keine Wirkung. +5. **Followup-Erkennung:** Bei Eskalationen zählt jeder Nicht-Agent-Followup als menschliche Aktivität, auch ein Followup des Antragstellers. Eine Rollenunterscheidung ist derzeit nicht implementiert. +6. **Prioritätsfelder:** Impact und Urgency werden analysiert und auditiert, aber aktuell nicht separat nach GLPI geschrieben. +7. **Major-Incident-Link:** Pfad und Payload sind installationsspezifisch und müssen gegen die OpenAPI-Dokumentation der konkreten GLPI-Instanz getestet werden. +8. **Zuweisungsfelder:** `assigned_groups`/`assigned_users` passen nicht zwingend zu jeder GLPI-Version oder Plugin-Konfiguration. Im Shadow Mode und mit Testticket validieren. +9. **Parser-Fallback:** Ungültige Booleans, Zahlen und Dauern fallen häufig still auf den Code-Default zurück. Effektive Werte über `/api/status` kontrollieren. +10. **Audit enthält Ticketinhalte:** `runs.jsonl` speichert Input-Snapshots und kann personenbezogene oder vertrauliche Ticketdaten enthalten. Zugriffsrechte, Backup und Löschkonzept entsprechend behandeln. +11. **Keine atomare Servertransaktion:** Prewrite-Recheck reduziert Rennen, ersetzt aber keinen GLPI-seitigen Conditional Write. +12. **Eskalationsscan und Limit:** Bei sehr vielen alten Tickets und kleinem Limit können dieselben ältesten Kandidaten wiederholt zuerst erscheinen. Filter und Limit passend dimensionieren. +13. **Kein Model-Sharding:** Der Ollama-Pool bündelt weder RAM noch GPU-Speicher mehrerer Rechner. Jeder Node muss die verwendeten Modelle vollständig lokal laden können. +14. **Einzelrequest-Latenz:** Ein Request läuft vollständig auf einem Node. Mehr Nodes erhöhen Durchsatz und Ausfallsicherheit, nicht automatisch die Tokens/s eines einzelnen Requests. +15. **Ollama-Netzwerkzugriff:** Node-APIs müssen durch Firewall/VPN/Reverse-Proxy begrenzt werden; der Agent bringt keine eigene Node-Zugangsdatenverwaltung mit. + +# 16. Betriebs-Checklisten + +## 16.1 Vor jedem Releasewechsel + +- [ ] `DATA_DIR` vollständig gesichert. +- [ ] `.env` verschlüsselt gesichert. +- [ ] Aktuelle Binary-/Image-Prüfsumme dokumentiert. +- [ ] Release zunächst mit `DRY_RUN=true` gestartet. +- [ ] `/readyz`, `/api/status` und initialer Poll geprüft. +- [ ] Knowledge-Snapshot kompatibel oder Rebuild eingeplant. +- [ ] Keine unbeabsichtigten Änderungen an `state-index.json`. + +## 16.2 Vor Auto-Reply live + +- [ ] `GLPI_AGENT_USER_ID` korrekt. +- [ ] Source-Whitelists minimal. +- [ ] Knowledge-Artikel fachlich freigegeben. +- [ ] `auto_reply=true` nur gezielt. +- [ ] Sprache, Stil und Kategoriebindung korrekt. +- [ ] Kontextquellen stabil. +- [ ] Mehrtägige Shadow-Auswertung abgeschlossen. + +## 16.3 Vor erweiterten Eskalationsaktionen live + +- [ ] Offene Status und Einheiten im `GLPI_ESCALATION_FILTER` begrenzt. +- [ ] Gruppen- und Benutzer-IDs mit Testticket geprüft. +- [ ] GLPI-Patchfelder gegen OpenAPI geprüft. +- [ ] Private Followup-Texte abgestimmt. +- [ ] Webhook mit Idempotency-Key getestet. +- [ ] Security-Aktion nur bei Security-Grund zulässig. +- [ ] Major-Incident-Adapter separat getestet. +- [ ] `state-index.json` wird gesichert und nicht manuell bereinigt. + +## 16.4 Bei Störung + +- [ ] `PRIORITY_ENABLED=false` setzen, wenn nur der optionale Prioritätslauf auffällig ist. +- [ ] `ESCALATION_ENABLED=false` setzen, wenn Scheduler/Aktionen auffällig sind. +- [ ] `AUTO_REPLY=false`, `AUTO_PRIORITY=false`, `AUTO_ESCALATION=false` setzen, um Writes gezielt zu stoppen. +- [ ] Im Zweifel `DRY_RUN=true` und neu starten. +- [ ] Logs, Run-ID und Analysis-ID sichern. +- [ ] Keine pauschale Löschung von `state-index.json` im Livebetrieb. + +## 16.5 Vor Aktivierung eines Ollama-Pools + +- [ ] Auf allen Nodes identisches Chatmodell installiert. +- [ ] Auf allen RAG-Nodes identisches Embeddingmodell installiert. +- [ ] Modelldigests im Dashboard identisch. +- [ ] Node-Port nur für den Agenten freigegeben. +- [ ] `OLLAMA_NODE_MAX_INFLIGHT=1` als Startwert. +- [ ] Failover mit absichtlich gestopptem Testnode geprüft. +- [ ] Neue AnalysisRuns zeigen `provider.selected_node` und Versuche. +- [ ] RAM, Temperatur und p95-Laufzeit unter paralleler Last beobachtet. + +--- + +**Ende der Betriebsanleitung** diff --git a/IMPLEMENTATION.md b/IMPLEMENTATION.md index fd5b589..e2be746 100644 --- a/IMPLEMENTATION.md +++ b/IMPLEMENTATION.md @@ -6,7 +6,7 @@ Diese Version erweitert die bestehende Ticketverarbeitung um ein generisches, ab 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 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. Die Eskalation unterstützt die freigegebenen Aktionen `raise_priority`, `assign_second_level`, `assign_security_team`, `notify_service_owner`, `link_major_incident` und `request_manager_review`; jede Aktion besitzt eigene Policy-, Ziel- und Idempotenzprüfungen. 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. @@ -60,3 +60,8 @@ Die automatisierten Prüfungen ersetzen keinen Shadow-Mode-Test gegen die konkre 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. + + +## Ollama-Node-Pool + +Der Ollama-Client unterstützt mehrere unabhängige Server mit Healthchecks, Least-In-Flight-, Round-Robin-, Weighted- und Fastest-Recent-Routing, per-Node-Parallelitätsgrenzen, Failover und optionaler Modelldigest-Gleichheit. Jeder KI-Analyselauf speichert den ausgewählten Node und sämtliche HTTP-Versuche unter `provider`. Der Pool erhöht Durchsatz und Verfügbarkeit, teilt jedoch kein einzelnes Modell über mehrere Rechner. diff --git a/OLLAMA-POOL.md b/OLLAMA-POOL.md new file mode 100644 index 0000000..5b717fc --- /dev/null +++ b/OLLAMA-POOL.md @@ -0,0 +1,250 @@ +# Betrieb mit mehreren Ollama-Instanzen + +Der Agent kann bis zu 64 voneinander unabhängige Ollama-Server als gemeinsamen Inferenz-Pool verwenden. Jeder Node lädt das vollständige Chat- und – sofern für RAG erforderlich – Embedding-Modell lokal. Der Pool erhöht damit den **Gesamtdurchsatz und die Ausfallsicherheit**; er teilt ein einzelnes Modell nicht über mehrere Rechner auf. + +## Architektur + +```text +GLPI AI Agent + Queue / Worker / Policies + | + v + Ollama Pool Router + | | | + Node 1 Node 2 Node 3 +``` + +Jeder logische KI-Lauf – Kategorie, Priorität, Status, Antwort oder Eskalation – wird einem verfügbaren Node zugewiesen. Bei retryfähigen Netzwerk- oder Serverfehlern kann derselbe Request auf einem anderen kompatiblen Node wiederholt werden. + +## Voraussetzungen je Node + +Auf allen Nodes sollten installiert sein: + +```text +Chat-Modell: OLLAMA_MODEL +Embedding-Modell: OLLAMA_EMBEDDING_MODEL +``` + +Bei `OLLAMA_REQUIRE_SAME_MODEL_DIGEST=true` prüft der Agent über `/api/tags`, dass alle erreichbaren Nodes exakt dieselben Modelldigests melden. Schon ein abweichender Digest macht den gesamten divergierenden Pool fail-closed, damit identische Tickets nicht aufgrund verschiedener Modellstände unterschiedlich bewertet werden. + +Für Lenovo-Systeme mit integrierter Radeon-Grafik und gemeinsamem RAM ist als Ausgangspunkt sinnvoll: + +```env +OLLAMA_NODE_MAX_INFLIGHT=1 +OLLAMA_ROUTING_MODE=least_inflight +OLLAMA_KEEP_ALIVE=10m +OLLAMA_THINK=false +``` + +Der Gesamtdurchsatz wird zusätzlich durch `WORKERS` begrenzt. Mit drei Nodes und `WORKERS=2` können höchstens zwei Ticketpipelines gleichzeitig Inferenz anfordern. Für einen Lasttest mit drei gleichartigen Nodes ist daher beispielsweise sinnvoll: + +```env +WORKERS=3 +OLLAMA_NODE_MAX_INFLIGHT=1 +``` + +Die Analysestufen eines einzelnen Tickets bleiben aus fachlichen Gründen weitgehend geordnet. Der größte Poolnutzen entsteht deshalb bei mehreren gleichzeitig wartenden Tickets oder Eskalationsläufen. + +## Minimale Pool-Konfiguration + +```env +OLLAMA_URLS=http://10.20.30.21:11434,http://10.20.30.22:11434,http://10.20.30.23:11434 +OLLAMA_NODE_NAMES=lenovo-01,lenovo-02,lenovo-03 +OLLAMA_ROUTING_MODE=least_inflight +OLLAMA_NODE_MAX_INFLIGHT=1 +OLLAMA_FAILOVER_ENABLED=true +OLLAMA_FAILOVER_ATTEMPTS=0 +OLLAMA_REQUIRE_SAME_MODEL_DIGEST=true +OLLAMA_REQUIRE_EMBEDDING_MODEL=true +``` + +`OLLAMA_FAILOVER_ATTEMPTS=0` bedeutet: maximal alle konfigurierten Nodes versuchen. + +## Routing-Modi + +### `least_inflight` + +Empfohlener Standard. Der Node mit den wenigsten laufenden Requests wird bevorzugt. Bei gleicher Auslastung wird zunächst der bislang seltener verwendete Node gewählt; anschließend dienen mittlere Laufzeit und Name als stabile Tie-Breaker. Dadurch verteilt sich auch serieller Verkehr über gleichartige Nodes. + +```env +OLLAMA_ROUTING_MODE=least_inflight +``` + +### `round_robin` + +Requests werden zyklisch verteilt. Dieser Modus ist einfach, berücksichtigt aber Leistungsunterschiede nur begrenzt. + +```env +OLLAMA_ROUTING_MODE=round_robin +``` + +### `weighted` + +Geeignet für gemischte Hardware. Die Gewichte stehen positionsgleich zu `OLLAMA_URLS`. + +```env +OLLAMA_URLS=http://lenovo-1:11434,http://lenovo-2:11434,http://gpu-server:11434 +OLLAMA_NODE_NAMES=lenovo-1,lenovo-2,gpu-server +OLLAMA_NODE_WEIGHTS=1,1,6 +OLLAMA_ROUTING_MODE=weighted +``` + +### `fastest_recent` + +Bevorzugt Nodes mit der niedrigsten gleitenden mittleren Request-Laufzeit. Neue oder zurückgekehrte Nodes ohne Messwert werden zunächst einmal vermessen, damit sie nicht dauerhaft verhungern. + +```env +OLLAMA_ROUTING_MODE=fastest_recent +``` + +## Startverhalten und Docker Compose + +Der Webserver startet unabhängig vom Pool. Vor Knowledge-Initialisierung und Ticketverarbeitung wartet der Agent wiederholt auf mindestens einen gesunden, kompatiblen Ollama-Node. Ein noch bootender Node führt dadurch nicht mehr zu einem einmaligen dauerhaften Knowledge-Fehler; im Dashboard bleibt der Zustand währenddessen sichtbar. + +Die Compose-Dateien besitzen keine harte Abhängigkeit des Agenten vom mitgelieferten `ollama`-Service mehr. Für ausschließlich externe Nodes kann gezielt nur der Agent gestartet werden: + +```bash +docker compose up -d agent +``` + +`OLLAMA_URLS` hat Vorrang vor dem weiterhin aus Kompatibilitätsgründen gesetzten `OLLAMA_URL=http://ollama:11434`. Bei `docker compose up -d` ohne Servicenamen wird der gebündelte lokale Ollama-Service weiterhin mitgestartet, aber nur verwendet, wenn seine URL im effektiven Pool steht. + +## Healthchecks und Cooldown + +```env +OLLAMA_NODE_HEALTH_INTERVAL=15s +OLLAMA_NODE_FAILURE_COOLDOWN=30s +OLLAMA_NODE_REQUEST_TIMEOUT=10m +``` + +Der Healthcheck ruft `/api/tags` auf und prüft: + +- HTTP-Erreichbarkeit, +- Vorhandensein des Chat-Modells, +- Vorhandensein des Embedding-Modells, +- Modelldigests, +- Kompatibilität mit den übrigen Nodes. + +Ein retryfähiger Fehler setzt den betroffenen Node in einen Cooldown. Währenddessen erhält er keine neuen Requests. Ein späterer erfolgreicher Healthcheck macht ihn wieder sichtbar; der Cooldown läuft dennoch bis zu seinem Ende, um Flapping zu dämpfen. + +## Failover + +Failover wird ausgelöst bei: + +- Verbindungsfehlern, +- Zeitüberschreitungen, +- HTTP 408, +- HTTP 429, +- HTTP 5xx, +- ungültigem äußerem Ollama-Response-JSON. + +```env +OLLAMA_FAILOVER_ENABLED=true +OLLAMA_FAILOVER_ATTEMPTS=0 +``` + +Nicht retryfähige 4xx-Fehler werden nicht auf andere Nodes gespiegelt. Die Modellaufrufe sind rein lesende Inferenzaufrufe; GLPI-Schreibaktionen erfolgen erst später durch die deterministische Go-Policy. + +## Analyse-Diagnose + +Jeder `AnalysisRun` speichert unter `provider`: + +```json +{ + "provider": "ollama-pool", + "routing_mode": "least_inflight", + "selected_node": "lenovo-02", + "selected_url": "http://10.20.30.22:11434", + "failover_used": true, + "attempt_count": 2, + "attempts": [ + { + "attempt": 1, + "stage": "priority", + "node_name": "lenovo-01", + "outcome": "error", + "retryable": true + }, + { + "attempt": 2, + "stage": "priority", + "node_name": "lenovo-02", + "outcome": "success" + } + ] +} +``` + +Zusätzlich werden – sofern Ollama sie liefert – Ladezeit, Prompt-Tokens, Generierungstokens und zugehörige Laufzeiten gespeichert. + +## Dashboard und Prometheus + +`/api/status` enthält unter anderem: + +```text +ollama_nodes +ollama_node_count +ollama_healthy_nodes +ollama_available_nodes +ollama_routing_mode +``` + +Prometheus exportiert pro Node: + +```text +glpi_agent_ollama_node_healthy +glpi_agent_ollama_node_available +glpi_agent_ollama_node_inflight +glpi_agent_ollama_node_requests_total +glpi_agent_ollama_node_failures_total +glpi_agent_ollama_node_average_duration_ms +``` + +## Netzwerksicherheit + +Ollama besitzt an seiner lokalen API üblicherweise keine eigene Mandantenauthentifizierung. Die Nodes sollten daher: + +- in einem eigenen Server-/KI-Netz liegen, +- Port 11434 nur vom GLPI-AI-Agent-Host akzeptieren, +- nicht aus Benutzer-VLANs erreichbar sein, +- niemals direkt aus dem Internet erreichbar sein, +- bei standortübergreifender Nutzung über VPN oder einen TLS-Reverse-Proxy mit Netzwerk-/IP-Allowlist angebunden werden. + +Beispiel auf jedem Node: + +```env +OLLAMA_HOST=0.0.0.0:11434 +``` + +Diese Freigabe allein ist nicht ausreichend; eine Host- oder Netzfirewall muss den Zugriff auf die Agent-IP begrenzen. + +## Rollout-Empfehlung + +1. Auf allen Nodes identische Ollama- und Modellstände installieren. +2. Chat- und Embedding-Modell einmal lokal laden. +3. Jeden Node einzeln mit `/api/tags` prüfen. +4. Pool zunächst mit `OLLAMA_REQUIRE_SAME_MODEL_DIGEST=true` starten. +5. Im Dashboard kontrollieren, dass alle Nodes `healthy=true` und `compatible=true` melden. +6. `OLLAMA_NODE_MAX_INFLIGHT=1` beibehalten und mehrere Testtickets parallel analysieren. +7. Erst nach Messung von RAM, Temperatur und Laufzeiten höhere Parallelität testen. + +## Modellupdates bei strikter Digest-Prüfung + +Bei `OLLAMA_REQUIRE_SAME_MODEL_DIGEST=true` ist ein gemischter Modellstand absichtlich nicht verfügbar. Ein Pull oder Austausch nur auf einem einzelnen aktiven Node kann den Pool deshalb beim nächsten Healthcheck fail-closed setzen. Sichere Varianten sind: + +1. Agent in ein Wartungsfenster nehmen und das Modell auf allen Nodes aktualisieren. +2. Einen neuen, eindeutig versionierten Modelltag zunächst auf allen Nodes bereitstellen und erst danach `OLLAMA_MODEL` zentral umstellen. +3. Für Hardwarewartung einen Node aus `OLLAMA_URLS` entfernen, Agent neu starten und ihn erst mit passendem Digest wieder aufnehmen. + +`OLLAMA_REQUIRE_SAME_MODEL_DIGEST=false` sollte nicht als normale Rolling-Update-Strategie verwendet werden, weil dann identische Tickets während der Übergangszeit von unterschiedlichen Modellständen bewertet werden können. + +## Rückfall auf einen Einzelnode + +Die bisherige Konfiguration bleibt kompatibel: + +```env +OLLAMA_URL=http://localhost:11434 +OLLAMA_URLS= +``` + +Ist `OLLAMA_URLS` leer, wird automatisch `OLLAMA_URL` als einzelner Pool-Node verwendet. diff --git a/OLLAMA_POOL_BETRIEB.md b/OLLAMA_POOL_BETRIEB.md new file mode 100644 index 0000000..5b717fc --- /dev/null +++ b/OLLAMA_POOL_BETRIEB.md @@ -0,0 +1,250 @@ +# Betrieb mit mehreren Ollama-Instanzen + +Der Agent kann bis zu 64 voneinander unabhängige Ollama-Server als gemeinsamen Inferenz-Pool verwenden. Jeder Node lädt das vollständige Chat- und – sofern für RAG erforderlich – Embedding-Modell lokal. Der Pool erhöht damit den **Gesamtdurchsatz und die Ausfallsicherheit**; er teilt ein einzelnes Modell nicht über mehrere Rechner auf. + +## Architektur + +```text +GLPI AI Agent + Queue / Worker / Policies + | + v + Ollama Pool Router + | | | + Node 1 Node 2 Node 3 +``` + +Jeder logische KI-Lauf – Kategorie, Priorität, Status, Antwort oder Eskalation – wird einem verfügbaren Node zugewiesen. Bei retryfähigen Netzwerk- oder Serverfehlern kann derselbe Request auf einem anderen kompatiblen Node wiederholt werden. + +## Voraussetzungen je Node + +Auf allen Nodes sollten installiert sein: + +```text +Chat-Modell: OLLAMA_MODEL +Embedding-Modell: OLLAMA_EMBEDDING_MODEL +``` + +Bei `OLLAMA_REQUIRE_SAME_MODEL_DIGEST=true` prüft der Agent über `/api/tags`, dass alle erreichbaren Nodes exakt dieselben Modelldigests melden. Schon ein abweichender Digest macht den gesamten divergierenden Pool fail-closed, damit identische Tickets nicht aufgrund verschiedener Modellstände unterschiedlich bewertet werden. + +Für Lenovo-Systeme mit integrierter Radeon-Grafik und gemeinsamem RAM ist als Ausgangspunkt sinnvoll: + +```env +OLLAMA_NODE_MAX_INFLIGHT=1 +OLLAMA_ROUTING_MODE=least_inflight +OLLAMA_KEEP_ALIVE=10m +OLLAMA_THINK=false +``` + +Der Gesamtdurchsatz wird zusätzlich durch `WORKERS` begrenzt. Mit drei Nodes und `WORKERS=2` können höchstens zwei Ticketpipelines gleichzeitig Inferenz anfordern. Für einen Lasttest mit drei gleichartigen Nodes ist daher beispielsweise sinnvoll: + +```env +WORKERS=3 +OLLAMA_NODE_MAX_INFLIGHT=1 +``` + +Die Analysestufen eines einzelnen Tickets bleiben aus fachlichen Gründen weitgehend geordnet. Der größte Poolnutzen entsteht deshalb bei mehreren gleichzeitig wartenden Tickets oder Eskalationsläufen. + +## Minimale Pool-Konfiguration + +```env +OLLAMA_URLS=http://10.20.30.21:11434,http://10.20.30.22:11434,http://10.20.30.23:11434 +OLLAMA_NODE_NAMES=lenovo-01,lenovo-02,lenovo-03 +OLLAMA_ROUTING_MODE=least_inflight +OLLAMA_NODE_MAX_INFLIGHT=1 +OLLAMA_FAILOVER_ENABLED=true +OLLAMA_FAILOVER_ATTEMPTS=0 +OLLAMA_REQUIRE_SAME_MODEL_DIGEST=true +OLLAMA_REQUIRE_EMBEDDING_MODEL=true +``` + +`OLLAMA_FAILOVER_ATTEMPTS=0` bedeutet: maximal alle konfigurierten Nodes versuchen. + +## Routing-Modi + +### `least_inflight` + +Empfohlener Standard. Der Node mit den wenigsten laufenden Requests wird bevorzugt. Bei gleicher Auslastung wird zunächst der bislang seltener verwendete Node gewählt; anschließend dienen mittlere Laufzeit und Name als stabile Tie-Breaker. Dadurch verteilt sich auch serieller Verkehr über gleichartige Nodes. + +```env +OLLAMA_ROUTING_MODE=least_inflight +``` + +### `round_robin` + +Requests werden zyklisch verteilt. Dieser Modus ist einfach, berücksichtigt aber Leistungsunterschiede nur begrenzt. + +```env +OLLAMA_ROUTING_MODE=round_robin +``` + +### `weighted` + +Geeignet für gemischte Hardware. Die Gewichte stehen positionsgleich zu `OLLAMA_URLS`. + +```env +OLLAMA_URLS=http://lenovo-1:11434,http://lenovo-2:11434,http://gpu-server:11434 +OLLAMA_NODE_NAMES=lenovo-1,lenovo-2,gpu-server +OLLAMA_NODE_WEIGHTS=1,1,6 +OLLAMA_ROUTING_MODE=weighted +``` + +### `fastest_recent` + +Bevorzugt Nodes mit der niedrigsten gleitenden mittleren Request-Laufzeit. Neue oder zurückgekehrte Nodes ohne Messwert werden zunächst einmal vermessen, damit sie nicht dauerhaft verhungern. + +```env +OLLAMA_ROUTING_MODE=fastest_recent +``` + +## Startverhalten und Docker Compose + +Der Webserver startet unabhängig vom Pool. Vor Knowledge-Initialisierung und Ticketverarbeitung wartet der Agent wiederholt auf mindestens einen gesunden, kompatiblen Ollama-Node. Ein noch bootender Node führt dadurch nicht mehr zu einem einmaligen dauerhaften Knowledge-Fehler; im Dashboard bleibt der Zustand währenddessen sichtbar. + +Die Compose-Dateien besitzen keine harte Abhängigkeit des Agenten vom mitgelieferten `ollama`-Service mehr. Für ausschließlich externe Nodes kann gezielt nur der Agent gestartet werden: + +```bash +docker compose up -d agent +``` + +`OLLAMA_URLS` hat Vorrang vor dem weiterhin aus Kompatibilitätsgründen gesetzten `OLLAMA_URL=http://ollama:11434`. Bei `docker compose up -d` ohne Servicenamen wird der gebündelte lokale Ollama-Service weiterhin mitgestartet, aber nur verwendet, wenn seine URL im effektiven Pool steht. + +## Healthchecks und Cooldown + +```env +OLLAMA_NODE_HEALTH_INTERVAL=15s +OLLAMA_NODE_FAILURE_COOLDOWN=30s +OLLAMA_NODE_REQUEST_TIMEOUT=10m +``` + +Der Healthcheck ruft `/api/tags` auf und prüft: + +- HTTP-Erreichbarkeit, +- Vorhandensein des Chat-Modells, +- Vorhandensein des Embedding-Modells, +- Modelldigests, +- Kompatibilität mit den übrigen Nodes. + +Ein retryfähiger Fehler setzt den betroffenen Node in einen Cooldown. Währenddessen erhält er keine neuen Requests. Ein späterer erfolgreicher Healthcheck macht ihn wieder sichtbar; der Cooldown läuft dennoch bis zu seinem Ende, um Flapping zu dämpfen. + +## Failover + +Failover wird ausgelöst bei: + +- Verbindungsfehlern, +- Zeitüberschreitungen, +- HTTP 408, +- HTTP 429, +- HTTP 5xx, +- ungültigem äußerem Ollama-Response-JSON. + +```env +OLLAMA_FAILOVER_ENABLED=true +OLLAMA_FAILOVER_ATTEMPTS=0 +``` + +Nicht retryfähige 4xx-Fehler werden nicht auf andere Nodes gespiegelt. Die Modellaufrufe sind rein lesende Inferenzaufrufe; GLPI-Schreibaktionen erfolgen erst später durch die deterministische Go-Policy. + +## Analyse-Diagnose + +Jeder `AnalysisRun` speichert unter `provider`: + +```json +{ + "provider": "ollama-pool", + "routing_mode": "least_inflight", + "selected_node": "lenovo-02", + "selected_url": "http://10.20.30.22:11434", + "failover_used": true, + "attempt_count": 2, + "attempts": [ + { + "attempt": 1, + "stage": "priority", + "node_name": "lenovo-01", + "outcome": "error", + "retryable": true + }, + { + "attempt": 2, + "stage": "priority", + "node_name": "lenovo-02", + "outcome": "success" + } + ] +} +``` + +Zusätzlich werden – sofern Ollama sie liefert – Ladezeit, Prompt-Tokens, Generierungstokens und zugehörige Laufzeiten gespeichert. + +## Dashboard und Prometheus + +`/api/status` enthält unter anderem: + +```text +ollama_nodes +ollama_node_count +ollama_healthy_nodes +ollama_available_nodes +ollama_routing_mode +``` + +Prometheus exportiert pro Node: + +```text +glpi_agent_ollama_node_healthy +glpi_agent_ollama_node_available +glpi_agent_ollama_node_inflight +glpi_agent_ollama_node_requests_total +glpi_agent_ollama_node_failures_total +glpi_agent_ollama_node_average_duration_ms +``` + +## Netzwerksicherheit + +Ollama besitzt an seiner lokalen API üblicherweise keine eigene Mandantenauthentifizierung. Die Nodes sollten daher: + +- in einem eigenen Server-/KI-Netz liegen, +- Port 11434 nur vom GLPI-AI-Agent-Host akzeptieren, +- nicht aus Benutzer-VLANs erreichbar sein, +- niemals direkt aus dem Internet erreichbar sein, +- bei standortübergreifender Nutzung über VPN oder einen TLS-Reverse-Proxy mit Netzwerk-/IP-Allowlist angebunden werden. + +Beispiel auf jedem Node: + +```env +OLLAMA_HOST=0.0.0.0:11434 +``` + +Diese Freigabe allein ist nicht ausreichend; eine Host- oder Netzfirewall muss den Zugriff auf die Agent-IP begrenzen. + +## Rollout-Empfehlung + +1. Auf allen Nodes identische Ollama- und Modellstände installieren. +2. Chat- und Embedding-Modell einmal lokal laden. +3. Jeden Node einzeln mit `/api/tags` prüfen. +4. Pool zunächst mit `OLLAMA_REQUIRE_SAME_MODEL_DIGEST=true` starten. +5. Im Dashboard kontrollieren, dass alle Nodes `healthy=true` und `compatible=true` melden. +6. `OLLAMA_NODE_MAX_INFLIGHT=1` beibehalten und mehrere Testtickets parallel analysieren. +7. Erst nach Messung von RAM, Temperatur und Laufzeiten höhere Parallelität testen. + +## Modellupdates bei strikter Digest-Prüfung + +Bei `OLLAMA_REQUIRE_SAME_MODEL_DIGEST=true` ist ein gemischter Modellstand absichtlich nicht verfügbar. Ein Pull oder Austausch nur auf einem einzelnen aktiven Node kann den Pool deshalb beim nächsten Healthcheck fail-closed setzen. Sichere Varianten sind: + +1. Agent in ein Wartungsfenster nehmen und das Modell auf allen Nodes aktualisieren. +2. Einen neuen, eindeutig versionierten Modelltag zunächst auf allen Nodes bereitstellen und erst danach `OLLAMA_MODEL` zentral umstellen. +3. Für Hardwarewartung einen Node aus `OLLAMA_URLS` entfernen, Agent neu starten und ihn erst mit passendem Digest wieder aufnehmen. + +`OLLAMA_REQUIRE_SAME_MODEL_DIGEST=false` sollte nicht als normale Rolling-Update-Strategie verwendet werden, weil dann identische Tickets während der Übergangszeit von unterschiedlichen Modellständen bewertet werden können. + +## Rückfall auf einen Einzelnode + +Die bisherige Konfiguration bleibt kompatibel: + +```env +OLLAMA_URL=http://localhost:11434 +OLLAMA_URLS= +``` + +Ist `OLLAMA_URLS` leer, wird automatisch `OLLAMA_URL` als einzelner Pool-Node verwendet. diff --git a/README.md b/README.md index d575123..88a629a 100644 --- a/README.md +++ b/README.md @@ -494,6 +494,25 @@ make build ``` +## Mehrere Ollama-Nodes + +Der Agent unterstützt einen nativen Ollama-Pool mit Least-In-Flight-Routing, Healthchecks, Failover, Modelldigest-Prüfung und Node-Diagnose pro AnalysisRun. Ein einzelnes Modell wird dabei nicht über Rechner verteilt; jeder Node führt vollständige unabhängige Inferenzrequests aus. + +```env +OLLAMA_URLS=http://10.20.30.21:11434,http://10.20.30.22:11434,http://10.20.30.23:11434 +OLLAMA_NODE_NAMES=lenovo-01,lenovo-02,lenovo-03 +OLLAMA_ROUTING_MODE=least_inflight +OLLAMA_NODE_MAX_INFLIGHT=1 +OLLAMA_FAILOVER_ENABLED=true +OLLAMA_FAILOVER_ATTEMPTS=0 +OLLAMA_REQUIRE_SAME_MODEL_DIGEST=true +WORKERS=3 +``` + +`WORKERS` begrenzt die Zahl gleichzeitig aktiver Ticketpipelines. Für drei gleichartige Nodes sind drei Worker ein sinnvoller Lasttest; die Ressourcen jedes einzelnen Rechners bleiben zusätzlich durch `OLLAMA_NODE_MAX_INFLIGHT=1` geschützt. + +Die vollständige Betriebsbeschreibung steht in [OLLAMA-POOL.md](OLLAMA-POOL.md). Der Agent wartet beim Start auf einen kompatiblen Pool, während Dashboard und Node-Diagnose bereits erreichbar bleiben. Bei externen Nodes kann mit `docker compose up -d agent` nur der Agent gestartet werden. + ## Docker troubleshooting: `/app/data` permission denied and slow Ollama The Compose stack contains a one-shot `agent-data-init` service. It prepares the named `agent-data` volume for the non-root agent user before the agent starts. The agent also probes `runs.jsonl` at startup and exits immediately with a clear error if the volume is not writable. @@ -508,7 +527,7 @@ OLLAMA_THINK=false OLLAMA_MAX_CONCURRENT=1 ``` -`OLLAMA_NUM_PREDICT` limits the maximum generated tokens for the small structured decision. `OLLAMA_KEEP_ALIVE` asks Ollama to keep the analysis model loaded between tickets. `OLLAMA_THINK=false` disables optional model thinking for this deterministic classification task. `OLLAMA_MAX_CONCURRENT=1` serializes local Ollama inference even when multiple ticket workers are active, so queued requests do not consume their HTTP timeout while waiting for the model. On very slow CPU-only hosts, use a smaller local model and/or increase `OLLAMA_TIMEOUT`. +`OLLAMA_NUM_PREDICT` limits the maximum generated tokens for the small structured decision. `OLLAMA_KEEP_ALIVE` asks Ollama to keep the analysis model loaded between tickets. `OLLAMA_THINK=false` disables optional model thinking for this deterministic classification task. `OLLAMA_NODE_MAX_INFLIGHT=1` serializes inference on each individual pool node. `OLLAMA_MAX_CONCURRENT` remains a backwards-compatible alias when the new per-node value is not set. On very slow CPU-only hosts, use a smaller local model and/or increase `OLLAMA_TIMEOUT`. After upgrading an existing Compose deployment, recreate the stack so the init service runs: diff --git a/SECURITY.md b/SECURITY.md index dc42dbc..667b04a 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -107,3 +107,15 @@ Das Feld `answer_html` wird ausschließlich vom read-only GLPI-KB-Synchronisiere ## Dynamische Begrenzung des LLM-Kontexts Knowledge-Kandidaten werden nicht allein anhand einer festen Anzahl in den Modellkontext übernommen. Der Agent kombiniert einen absoluten Retrieval-Floor, einen maximalen Abstand zum besten Treffer und eine harte Obergrenze. Dadurch werden bei großen Wissensbeständen schwache, themenfremde Artikel aus dem Ollama-Prompt herausgehalten, bleiben aber optional im Audit sichtbar. + +## Ollama-Pool und Netzwerkgrenze + +Mehrere Ollama-Instanzen bilden eine zusätzliche interne Trust Boundary. Der Agent sendet Ticket-, Knowledge- und Kontextauszüge an jeden Node, der einen Request übernehmen kann. Deshalb dürfen ausschließlich administrierte Systeme in `OLLAMA_URLS` aufgenommen werden. + +- Ollama-Port 11434 nur von der Agent-IP beziehungsweise dem Agent-Subnetz zulassen. +- Nodes nicht aus Benutzer-VLANs und niemals direkt aus dem Internet erreichbar machen. +- Bei standortübergreifender Verbindung VPN oder einen TLS-Reverse-Proxy mit Netzwerk-/IP-Allowlist verwenden. +- Auf allen Nodes dieselben Chat- und Embedding-Modelle installieren. `OLLAMA_REQUIRE_SAME_MODEL_DIGEST=true` lässt den Pool bei divergierenden Digests fail-closed. +- 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. diff --git a/UPGRADE.md b/UPGRADE.md index 79a0a0c..6852e2b 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -362,3 +362,29 @@ 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. + +## Upgrade auf mehrere Ollama-Nodes + +Die bisherige Einzelnode-Konfiguration bleibt kompatibel: + +```env +OLLAMA_URL=http://localhost:11434 +OLLAMA_URLS= +``` + +Für einen Pool ergänzen Sie mindestens: + +```env +OLLAMA_URLS=http://10.20.30.21:11434,http://10.20.30.22:11434,http://10.20.30.23:11434 +OLLAMA_NODE_NAMES=lenovo-01,lenovo-02,lenovo-03 +OLLAMA_ROUTING_MODE=least_inflight +OLLAMA_NODE_MAX_INFLIGHT=1 +OLLAMA_FAILOVER_ENABLED=true +OLLAMA_FAILOVER_ATTEMPTS=0 +OLLAMA_REQUIRE_SAME_MODEL_DIGEST=true +OLLAMA_REQUIRE_EMBEDDING_MODEL=true +``` + +Vor dem ersten Start müssen `OLLAMA_MODEL` und – bei aktivem RAG – `OLLAMA_EMBEDDING_MODEL` auf jedem Node vorhanden sein. Bei aktivierter Digest-Pflicht führt bereits ein abweichender Modellstand dazu, dass der Pool keine Requests annimmt. Das Dashboard zeigt pro Node Erreichbarkeit, Kompatibilität, Digest, Auslastung, Fehler und Laufzeit. + +Bestehende `runs.jsonl`-Einträge bleiben lesbar. Nur neue `AnalysisRun`-Datensätze enthalten den Bereich `provider` mit Node-Auswahl und Failover-Versuchen. `state-index.json` muss beim Upgrade erhalten bleiben. diff --git a/cmd/agent/main.go b/cmd/agent/main.go index 7de6e69..3ae0e90 100644 --- a/cmd/agent/main.go +++ b/cmd/agent/main.go @@ -45,7 +45,31 @@ func main() { ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer cancel() g := glpi.New(cfg.GLPIURL, cfg.GLPIAPIVersion, cfg.GLPIClientID, cfg.GLPIClientSecret, cfg.GLPIUsername, cfg.GLPIPassword, cfg.GLPITimeout) - o := ollama.New(cfg.OllamaURL, cfg.OllamaModel, cfg.OllamaEmbeddingModel, cfg.CommunicationLanguage, cfg.CommunicationStyle, cfg.OllamaTimeout, cfg.OllamaNumPredict, cfg.OllamaKeepAlive, cfg.OllamaThink, cfg.OllamaMaxConcurrent, cfg.OllamaJSONRetries) + nodes := make([]ollama.NodeConfig, 0, len(cfg.OllamaURLs)) + for i, nodeURL := range cfg.OllamaURLs { + name := "" + if i < len(cfg.OllamaNodeNames) { + name = cfg.OllamaNodeNames[i] + } + weight := 1 + if i < len(cfg.OllamaNodeWeights) { + weight = cfg.OllamaNodeWeights[i] + } + nodes = append(nodes, ollama.NodeConfig{Name: name, URL: nodeURL, Weight: weight}) + } + o, err := ollama.NewPool(ollama.PoolConfig{ + Nodes: nodes, RoutingMode: cfg.OllamaRoutingMode, NodeMaxInflight: cfg.OllamaNodeMaxInflight, + HealthInterval: cfg.OllamaNodeHealthInterval, FailureCooldown: cfg.OllamaNodeFailureCooldown, + NodeRequestTimeout: cfg.OllamaNodeRequestTimeout, FailoverEnabled: cfg.OllamaFailoverEnabled, + FailoverAttempts: cfg.OllamaFailoverAttempts, RequireSameModelDigest: cfg.OllamaRequireSameDigest, + RequireEmbeddingModel: cfg.OllamaRequireEmbeddingModel, Model: cfg.OllamaModel, EmbeddingModel: cfg.OllamaEmbeddingModel, + }, cfg.OllamaModel, cfg.OllamaEmbeddingModel, cfg.CommunicationLanguage, cfg.CommunicationStyle, cfg.OllamaNumPredict, cfg.OllamaKeepAlive, cfg.OllamaThink, cfg.OllamaJSONRetries) + if err != nil { + slog.Error("Ollama pool configuration failed", "error", err) + os.Exit(1) + } + o.Start(ctx) + slog.Info("Ollama pool configured", "nodes", len(nodes), "routing", cfg.OllamaRoutingMode, "max_inflight_per_node", cfg.OllamaNodeMaxInflight, "failover", cfg.OllamaFailoverEnabled, "failover_attempts", cfg.OllamaFailoverAttempts, "require_same_digest", cfg.OllamaRequireSameDigest) if err := g.ValidateContract(ctx); err != nil { slog.Error("GLPI API contract validation failed", "error", err) os.Exit(1) @@ -92,7 +116,7 @@ func main() { } contextCollector := contextdata.New(cfg, g, kuma) svc := agent.New(cfg, g, o, k, l, st, q, m, contextCollector) - web, err := webui.New(cfg, m, st, q, k, svc) + web, err := webui.New(cfg, m, st, q, k, svc, o) if err != nil { slog.Error("web UI initialization failed", "error", err) os.Exit(1) @@ -110,6 +134,9 @@ func main() { // Ticket polling/workers remain paused until the local index is ready. go func() { slog.Info("knowledge initialization started in background", "knowledge_dir", cfg.KnowledgeDir, "rag_enabled", cfg.RAGEnabled) + if err := waitForOllamaPool(ctx, o, cfg.OllamaNodeHealthInterval); err != nil { + return + } if err := k.Initialize(ctx); err != nil { slog.Error("knowledge store initialization failed; web UI remains available", "error", err, "knowledge_dir", cfg.KnowledgeDir, "data_dir", cfg.DataDir, "rag_enabled", cfg.RAGEnabled) return @@ -143,6 +170,39 @@ func main() { slog.Info("shutdown complete") } +func waitForOllamaPool(ctx context.Context, client *ollama.Client, retryInterval time.Duration) error { + if retryInterval < 2*time.Second { + retryInterval = 5 * time.Second + } + for { + err := client.Ping(ctx) + if err == nil { + statuses := client.NodeStatuses() + healthy := 0 + for _, status := range statuses { + if status.Healthy && status.Compatible { + healthy++ + } + } + slog.Info("Ollama pool ready", "healthy_nodes", healthy, "nodes", len(statuses), "routing", client.RoutingMode()) + return nil + } + slog.Warn("waiting for compatible Ollama pool", "retry_in", retryInterval.String(), "error", err) + timer := time.NewTimer(retryInterval) + select { + case <-ctx.Done(): + if !timer.Stop() { + select { + case <-timer.C: + default: + } + } + return ctx.Err() + case <-timer.C: + } + } +} + func maxDuration(a, b time.Duration) time.Duration { if a > b { return a diff --git a/compose_local.yml b/compose_local.yml index 56948ff..52f1768 100644 --- a/compose_local.yml +++ b/compose_local.yml @@ -20,9 +20,6 @@ services: volumes: - agent-data:/app/data - ./knowledge:/app/knowledge:ro - depends_on: - ollama: - condition: service_started security_opt: - no-new-privileges:true cap_drop: diff --git a/dist/SHA256SUMS.txt b/dist/SHA256SUMS.txt index c7280a9..3f541d4 100644 --- a/dist/SHA256SUMS.txt +++ b/dist/SHA256SUMS.txt @@ -1,2 +1,2 @@ -f2eebac7aab8b31af1f952ca0db670e87c6a8a2516175e8fd2aeb0a0cfe610a5 glpi-ai-agent-linux-amd64 -1c9728e5282a2074a21e9263d05277144478cc529bdacb27604d00474602c8a0 glpi-ai-agent-windows-amd64.exe +c196be36f1a99e079b13310cc266fd4ba9b8b4b3c29ccc5a6dcda808cdaace0c glpi-ai-agent-linux-amd64 +54e5f3eb9408a640a1608b46bedb2ae52e25cf175e4246a4ffd22429f1be9a9c glpi-ai-agent-windows-amd64.exe diff --git a/dist/glpi-ai-agent-linux-amd64 b/dist/glpi-ai-agent-linux-amd64 index f0e987e..2135700 100644 Binary files a/dist/glpi-ai-agent-linux-amd64 and b/dist/glpi-ai-agent-linux-amd64 differ diff --git a/dist/glpi-ai-agent-windows-amd64.exe b/dist/glpi-ai-agent-windows-amd64.exe index 60ae32b..4f13f86 100644 Binary files a/dist/glpi-ai-agent-windows-amd64.exe and b/dist/glpi-ai-agent-windows-amd64.exe differ diff --git a/docker-compose.registry.yml b/docker-compose.registry.yml index 5e0d2a0..d8eee4c 100644 --- a/docker-compose.registry.yml +++ b/docker-compose.registry.yml @@ -7,7 +7,7 @@ services: environment: DATA_DIR: /app/data KNOWLEDGE_DIR: /app/knowledge - OLLAMA_URL: http://ollama:11434 + OLLAMA_URL: ${OLLAMA_URL:-http://ollama:11434} OLLAMA_TIMEOUT: ${OLLAMA_TIMEOUT:-10m} OLLAMA_NUM_PREDICT: ${OLLAMA_NUM_PREDICT:-768} OLLAMA_JSON_RETRIES: ${OLLAMA_JSON_RETRIES:-1} @@ -21,9 +21,6 @@ services: # Prepare once on the Linux host: mkdir -p data knowledge && chown 65532:65532 data - ./data:/app/data - ./knowledge:/app/knowledge:ro - depends_on: - ollama: - condition: service_started security_opt: - no-new-privileges:true cap_drop: diff --git a/docker-compose.yml b/docker-compose.yml index 290b73a..d6b4492 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,7 +23,7 @@ services: # Container-specific paths/hostnames override the native-friendly .env defaults. DATA_DIR: /app/data KNOWLEDGE_DIR: /app/knowledge - OLLAMA_URL: http://ollama:11434 + OLLAMA_URL: ${OLLAMA_URL:-http://ollama:11434} # Local CPU inference can take several minutes on the first request. OLLAMA_TIMEOUT: ${OLLAMA_TIMEOUT:-10m} OLLAMA_NUM_PREDICT: ${OLLAMA_NUM_PREDICT:-768} @@ -40,8 +40,6 @@ services: depends_on: agent-data-init: condition: service_completed_successfully - ollama: - condition: service_started security_opt: - no-new-privileges:true cap_drop: diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 211658c..2341075 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/ollama" "github.com/example/glpi-ai-agent/internal/prioritysignals" "github.com/example/glpi-ai-agent/internal/queue" "github.com/example/glpi-ai-agent/internal/state" @@ -282,11 +283,13 @@ func (s *Service) ProcessWork(ctx context.Context, item queue.WorkItem) error { 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) + categoryCtx, categoryTrace := ollama.WithTrace(ctx, s.cfg.OllamaRoutingMode) + categoryDecision, err := s.ai.AnalyseCategory(categoryCtx, 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) + attachAnalysisTrace(&categoryAnalysis, categoryTrace) run.Analyses = append(run.Analyses, categoryAnalysis) run.Reason = "category_ai_failed" finish(err) @@ -295,6 +298,7 @@ func (s *Service) ProcessWork(ctx context.Context, item queue.WorkItem) error { 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) + attachAnalysisTrace(&categoryAnalysis, categoryTrace) run.Analyses = append(run.Analyses, categoryAnalysis) categoryAnalysisIndex := len(run.Analyses) - 1 @@ -322,7 +326,8 @@ func (s *Service) ProcessWork(ctx context.Context, item queue.WorkItem) error { run.PriorityBefore = t.Priority run.PriorityThreshold = s.cfg.PriorityConfidence if priorityClient, ok := s.ai.(priorityAI); ok { - priorityCtx, cancelPriority := context.WithTimeout(ctx, priorityTimeout) + priorityBaseCtx, priorityTrace := ollama.WithTrace(ctx, s.cfg.OllamaRoutingMode) + priorityCtx, cancelPriority := context.WithTimeout(priorityBaseCtx, priorityTimeout) priorityDecision, priorityErr := priorityClient.AnalysePriority(priorityCtx, t, replyBasis, contextData) cancelPriority() run.PriorityAnalysisDurationMS = time.Since(priorityStarted).Milliseconds() @@ -352,6 +357,7 @@ func (s *Service) ProcessWork(ctx context.Context, item queue.WorkItem) error { s.metrics.PriorityRecommendations.Add(1) } } + attachAnalysisTrace(&priorityAnalysis, priorityTrace) } else { priorityErr := fmt.Errorf("AI client does not implement priority analysis") run.PriorityDecision = "priority_ai_unavailable" @@ -376,6 +382,7 @@ func (s *Service) ProcessWork(ctx context.Context, item queue.WorkItem) error { var statusEval statusReplyEvaluation statusAnalysisStarted := time.Now() var statusAnalysisErr error + var statusTrace *ollama.Trace switch { case !s.cfg.ContextStatusReplyEnabled: run.StatusAnalysisSkipReason = "status_reply_disabled" @@ -389,7 +396,9 @@ func (s *Service) ProcessWork(ctx context.Context, item queue.WorkItem) error { run.StatusAnalysisSkipReason = "no_status_candidates" default: run.StatusAnalysisExecuted = true - statusDecision, err = s.ai.AnalyseStatus(ctx, t, replyBasis, statusCandidates) + statusCtx, trace := ollama.WithTrace(ctx, s.cfg.OllamaRoutingMode) + statusTrace = trace + statusDecision, err = s.ai.AnalyseStatus(statusCtx, t, replyBasis, statusCandidates) run.StatusAnalysisDurationMS = time.Since(statusAnalysisStarted).Milliseconds() if err != nil { statusAnalysisErr = err @@ -427,6 +436,7 @@ func (s *Service) ProcessWork(ctx context.Context, item queue.WorkItem) error { 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) + attachAnalysisTrace(&statusAnalysis, statusTrace) run.Analyses = append(run.Analyses, statusAnalysis) statusAnalysisIndex := len(run.Analyses) - 1 @@ -457,6 +467,7 @@ func (s *Service) ProcessWork(ctx context.Context, item queue.WorkItem) error { var replyDecision model.Decision replyAnalysisStarted := time.Now() var replyAnalysisErr error + var replyTrace *ollama.Trace switch run.ReplyAnalysisSkipReason { case "status_reply_selected": replyDecision.Reason = "Normale Antwortanalyse nicht ausgeführt: Ein vordefiniertes Status-Template wurde freigegeben." @@ -472,7 +483,9 @@ func (s *Service) ProcessWork(ctx context.Context, item queue.WorkItem) error { 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: run.ReplyAnalysisExecuted = true - replyDecision, err = s.ai.AnalyseReply(ctx, t, replyBasis, replyLLMHits, contextData) + replyCtx, trace := ollama.WithTrace(ctx, s.cfg.OllamaRoutingMode) + replyTrace = trace + replyDecision, err = s.ai.AnalyseReply(replyCtx, t, replyBasis, replyLLMHits, contextData) run.ReplyAnalysisDurationMS = time.Since(replyAnalysisStarted).Milliseconds() if err != nil { replyAnalysisErr = err @@ -495,6 +508,7 @@ func (s *Service) ProcessWork(ctx context.Context, item queue.WorkItem) error { 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) + attachAnalysisTrace(&replyAnalysis, replyTrace) run.Analyses = append(run.Analyses, replyAnalysis) replyAnalysisIndex := len(run.Analyses) - 1 diff --git a/internal/agent/analysis_runs.go b/internal/agent/analysis_runs.go index 5e14d0a..fb36a31 100644 --- a/internal/agent/analysis_runs.go +++ b/internal/agent/analysis_runs.go @@ -12,6 +12,7 @@ import ( "github.com/example/glpi-ai-agent/internal/config" "github.com/example/glpi-ai-agent/internal/model" + "github.com/example/glpi-ai-agent/internal/ollama" "github.com/example/glpi-ai-agent/internal/state" ) @@ -80,6 +81,17 @@ func finishAnalysis(a *model.AnalysisRun, modelName string, decision any, reason } } +func attachAnalysisTrace(a *model.AnalysisRun, trace *ollama.Trace) { + if a == nil || trace == nil { + return + } + snapshot := trace.Snapshot() + if len(snapshot.Attempts) == 0 { + return + } + a.Provider = snapshot +} + func mustJSON(v any) json.RawMessage { b, err := json.Marshal(v) if err != nil { diff --git a/internal/agent/escalation.go b/internal/agent/escalation.go index c84431b..0e63722 100644 --- a/internal/agent/escalation.go +++ b/internal/agent/escalation.go @@ -9,6 +9,7 @@ import ( "time" "github.com/example/glpi-ai-agent/internal/model" + "github.com/example/glpi-ai-agent/internal/ollama" "github.com/example/glpi-ai-agent/internal/queue" ) @@ -116,15 +117,17 @@ func (s *Service) processEscalation(ctx context.Context, item queue.WorkItem) er finish(err) return err } - analysisCtx := ctx + analysisBaseCtx, escalationTrace := ollama.WithTrace(ctx, s.cfg.OllamaRoutingMode) + analysisCtx := analysisBaseCtx cancel := func() {} if s.cfg.EscalationAnalysisTimeout > 0 { - analysisCtx, cancel = context.WithTimeout(ctx, s.cfg.EscalationAnalysisTimeout) + analysisCtx, cancel = context.WithTimeout(analysisBaseCtx, s.cfg.EscalationAnalysisTimeout) } decision, err := ai.AnalyseEscalation(analysisCtx, t, followups, contextData, evidence, constraints) cancel() if err != nil { finishAnalysis(&analysis, s.cfg.OllamaModel, nil, nil, "", 0, nil, model.ActionAudit{Type: "escalation_plan", Result: "skipped: escalation_ai_failed"}, err) + attachAnalysisTrace(&analysis, escalationTrace) run.Analyses = append(run.Analyses, analysis) run.Reason = "escalation_ai_failed" finish(err) @@ -170,6 +173,7 @@ func (s *Service) processEscalation(ctx context.Context, item queue.WorkItem) er action, err = s.executeEscalationPlan(ctx, t, decision, result, contextData) } finishAnalysis(&analysis, s.cfg.OllamaModel, result, decision.ReasonCodes, decision.Reason, decision.Confidence, result.Checks, action, err) + attachAnalysisTrace(&analysis, escalationTrace) run.Analyses = append(run.Analyses, analysis) run.Reason = decision.Reason run.AIReason = decision.Reason diff --git a/internal/config/config.go b/internal/config/config.go index 5aa909e..ecbfc18 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -37,15 +37,27 @@ type Config struct { GLPIAllowInsecureHTTP bool GLPIAllowedStatusIDs []int64 - OllamaURL string - OllamaModel string - OllamaEmbeddingModel string - OllamaTimeout time.Duration - OllamaNumPredict int - OllamaKeepAlive time.Duration - OllamaThink bool - OllamaMaxConcurrent int - OllamaJSONRetries int + OllamaURL string // legacy single-node value + OllamaURLs []string + OllamaNodeNames []string + OllamaNodeWeights []int + OllamaModel string + OllamaEmbeddingModel string + OllamaTimeout time.Duration + OllamaNumPredict int + OllamaKeepAlive time.Duration + OllamaThink bool + OllamaMaxConcurrent int // legacy alias for per-node concurrency + OllamaNodeMaxInflight int + OllamaRoutingMode string + OllamaNodeHealthInterval time.Duration + OllamaNodeFailureCooldown time.Duration + OllamaNodeRequestTimeout time.Duration + OllamaFailoverEnabled bool + OllamaFailoverAttempts int + OllamaRequireSameDigest bool + OllamaRequireEmbeddingModel bool + OllamaJSONRetries int KnowledgeDir string RAGEnabled bool @@ -206,6 +218,9 @@ func Load() (Config, error) { 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), @@ -213,6 +228,15 @@ func Load() (Config, error) { 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), @@ -344,6 +368,21 @@ func Load() (Config, error) { QueueSize: envInt("QUEUE_SIZE", 256), Workers: envInt("WORKERS", 2), } + if len(c.OllamaURLs) == 0 { + c.OllamaURLs = []string{c.OllamaURL} + } + for i := range c.OllamaURLs { + c.OllamaURLs[i] = strings.TrimRight(strings.TrimSpace(c.OllamaURLs[i]), "/") + } + if c.OllamaNodeMaxInflight == 0 { + c.OllamaNodeMaxInflight = c.OllamaMaxConcurrent + } + if c.OllamaNodeRequestTimeout == 0 { + c.OllamaNodeRequestTimeout = c.OllamaTimeout + } + if c.OllamaFailoverAttempts == 0 { + c.OllamaFailoverAttempts = len(c.OllamaURLs) + } // Backwards compatibility: without an explicit category source list, the // same sources used for normal knowledge retrieval also inform classification. if _, configured := os.LookupEnv("KNOWLEDGE_CATEGORY_SOURCES"); !configured { @@ -410,6 +449,87 @@ func (c Config) Validate() error { if c.OllamaMaxConcurrent <= 0 || c.OllamaMaxConcurrent > 32 { return errors.New("OLLAMA_MAX_CONCURRENT must be between 1 and 32") } + ollamaURLs := append([]string(nil), c.OllamaURLs...) + if len(ollamaURLs) == 0 { + legacyURL := strings.TrimSpace(c.OllamaURL) + if legacyURL == "" { + legacyURL = "http://ollama:11434" + } + ollamaURLs = []string{legacyURL} + } + if len(ollamaURLs) > 64 { + return errors.New("OLLAMA_URLS supports at most 64 nodes") + } + seenOllamaURLs := map[string]struct{}{} + for _, raw := range ollamaURLs { + u, err := url.Parse(strings.TrimSpace(raw)) + if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") || u.User != nil || u.RawQuery != "" || u.Fragment != "" { + return fmt.Errorf("invalid Ollama node URL %q", raw) + } + key := strings.TrimRight(u.String(), "/") + if _, ok := seenOllamaURLs[key]; ok { + return fmt.Errorf("duplicate Ollama node URL %q", key) + } + seenOllamaURLs[key] = struct{}{} + } + if len(c.OllamaNodeNames) > 0 && len(c.OllamaNodeNames) != len(ollamaURLs) { + return errors.New("OLLAMA_NODE_NAMES must contain exactly one name per OLLAMA_URLS entry") + } + seenOllamaNames := map[string]struct{}{} + for _, rawName := range c.OllamaNodeNames { + name := strings.TrimSpace(rawName) + if name == "" { + return errors.New("OLLAMA_NODE_NAMES entries must not be empty") + } + if _, ok := seenOllamaNames[name]; ok { + return fmt.Errorf("duplicate Ollama node name %q", name) + } + seenOllamaNames[name] = struct{}{} + } + if len(c.OllamaNodeWeights) > 0 && len(c.OllamaNodeWeights) != len(ollamaURLs) { + return errors.New("OLLAMA_NODE_WEIGHTS must contain exactly one weight per OLLAMA_URLS entry") + } + for _, weight := range c.OllamaNodeWeights { + if weight < 1 || weight > 100 { + return errors.New("OLLAMA_NODE_WEIGHTS values must be between 1 and 100") + } + } + nodeMaxInflight := c.OllamaNodeMaxInflight + if nodeMaxInflight == 0 { + nodeMaxInflight = c.OllamaMaxConcurrent + } + if nodeMaxInflight < 1 || nodeMaxInflight > 32 { + return errors.New("OLLAMA_NODE_MAX_INFLIGHT must be between 1 and 32") + } + routingMode := c.OllamaRoutingMode + if routingMode == "" { + routingMode = "least_inflight" + } + switch routingMode { + case "least_inflight", "round_robin", "weighted", "fastest_recent": + default: + return errors.New("OLLAMA_ROUTING_MODE must be one of: least_inflight, round_robin, weighted, fastest_recent") + } + if c.OllamaNodeHealthInterval != 0 && c.OllamaNodeHealthInterval < time.Second { + return errors.New("OLLAMA_NODE_HEALTH_INTERVAL must be >= 1s") + } + if c.OllamaNodeFailureCooldown < 0 { + return errors.New("OLLAMA_NODE_FAILURE_COOLDOWN must be >= 0") + } + nodeRequestTimeout := c.OllamaNodeRequestTimeout + if nodeRequestTimeout == 0 { + nodeRequestTimeout = c.OllamaTimeout + } + if nodeRequestTimeout <= 0 { + return errors.New("OLLAMA_NODE_REQUEST_TIMEOUT must be > 0") + } + failoverAttempts := c.OllamaFailoverAttempts + if failoverAttempts == 0 { + failoverAttempts = len(ollamaURLs) + } + if failoverAttempts < 1 || failoverAttempts > len(ollamaURLs) { + return errors.New("OLLAMA_FAILOVER_ATTEMPTS must be between 1 and the number of configured Ollama nodes") + } if c.OllamaJSONRetries < 0 || c.OllamaJSONRetries > 3 { return errors.New("OLLAMA_JSON_RETRIES must be between 0 and 3") } @@ -975,6 +1095,23 @@ func envPathList(key, def string) []string { return vals } +func envIntListAllowEmpty(key string) []int { + raw := strings.TrimSpace(os.Getenv(key)) + if raw == "" || strings.EqualFold(raw, "none") { + return nil + } + parts := strings.Split(raw, ",") + out := make([]int, 0, len(parts)) + for _, part := range parts { + n, err := strconv.Atoi(strings.TrimSpace(part)) + if err != nil { + return nil + } + out = append(out, n) + } + return out +} + func envBool(key string, def bool) bool { v := os.Getenv(key) if v == "" { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index bcb7819..2cff975 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -440,3 +440,48 @@ func TestValidateEscalationLinkAdapterRequiresSourceAndTarget(t *testing.T) { t.Fatalf("valid link adapter rejected: %v", err) } } + +func TestValidateOllamaPoolConfiguration(t *testing.T) { + c := validConfig() + c.OllamaURLs = []string{"http://ollama-1.internal:11434", "http://ollama-2.internal:11434"} + c.OllamaNodeNames = []string{"lenovo-1", "lenovo-2"} + c.OllamaNodeWeights = []int{1, 2} + c.OllamaNodeMaxInflight = 1 + c.OllamaRoutingMode = "weighted" + c.OllamaNodeHealthInterval = 10 * time.Second + c.OllamaNodeFailureCooldown = 30 * time.Second + c.OllamaNodeRequestTimeout = time.Minute + c.OllamaFailoverAttempts = 2 + if err := c.Validate(); err != nil { + t.Fatalf("valid pool rejected: %v", err) + } + + c.OllamaNodeNames = []string{"only-one"} + if err := c.Validate(); err == nil { + t.Fatal("expected mismatched node names to be rejected") + } + + c.OllamaNodeNames = []string{"duplicate", "duplicate"} + if err := c.Validate(); err == nil { + t.Fatal("expected duplicate node names to be rejected") + } + + c = validConfig() + c.OllamaURLs = []string{"http://user:secret@ollama-1.internal:11434"} + c.OllamaNodeMaxInflight = 1 + c.OllamaNodeRequestTimeout = time.Minute + c.OllamaFailoverAttempts = 1 + if err := c.Validate(); err == nil { + t.Fatal("expected credentials in Ollama node URL to be rejected") + } + + c = validConfig() + c.OllamaURLs = []string{"http://ollama-1.internal:11434", "http://ollama-2.internal:11434"} + c.OllamaNodeMaxInflight = 1 + c.OllamaNodeRequestTimeout = time.Minute + c.OllamaRoutingMode = "unknown" + c.OllamaFailoverAttempts = 2 + if err := c.Validate(); err == nil { + t.Fatal("expected unknown routing mode to be rejected") + } +} diff --git a/internal/model/model.go b/internal/model/model.go index c500c0b..4faade3 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -337,30 +337,89 @@ type ContextAuditItem struct { } // AnalysisRun is one independently auditable AI or deterministic analysis stage. +// OllamaRequestAttempt captures one concrete HTTP attempt against an Ollama node. +// It is attached to an AnalysisRun so routing and failover remain auditable. +type OllamaRequestAttempt struct { + Attempt int `json:"attempt"` + Stage string `json:"stage,omitempty"` + Path string `json:"path,omitempty"` + NodeName string `json:"node_name"` + NodeURL string `json:"node_url"` + ModelDigest string `json:"model_digest,omitempty"` + StartedAt time.Time `json:"started_at"` + DurationMS int64 `json:"duration_ms"` + InflightAtStart int64 `json:"inflight_at_start,omitempty"` + HTTPStatus int `json:"http_status,omitempty"` + Outcome string `json:"outcome"` + Retryable bool `json:"retryable,omitempty"` + Error string `json:"error,omitempty"` + TotalDurationNS int64 `json:"total_duration_ns,omitempty"` + LoadDurationNS int64 `json:"load_duration_ns,omitempty"` + PromptEvalCount int64 `json:"prompt_eval_count,omitempty"` + PromptEvalDuration int64 `json:"prompt_eval_duration_ns,omitempty"` + EvalCount int64 `json:"eval_count,omitempty"` + EvalDuration int64 `json:"eval_duration_ns,omitempty"` +} + +// OllamaProviderTrace summarizes the routing of one logical analysis. +type OllamaProviderTrace struct { + Provider string `json:"provider,omitempty"` + RoutingMode string `json:"routing_mode,omitempty"` + SelectedNode string `json:"selected_node,omitempty"` + SelectedURL string `json:"selected_url,omitempty"` + FailoverUsed bool `json:"failover_used,omitempty"` + AttemptCount int `json:"attempt_count,omitempty"` + Attempts []OllamaRequestAttempt `json:"attempts,omitempty"` +} + +// OllamaNodeStatus is the read-only operational state exposed in the dashboard. +type OllamaNodeStatus struct { + Name string `json:"name"` + URL string `json:"url"` + Weight int `json:"weight"` + Healthy bool `json:"healthy"` + Compatible bool `json:"compatible"` + Available bool `json:"available"` + InFlight int64 `json:"inflight"` + MaxInFlight int `json:"max_inflight"` + ChatModelDigest string `json:"chat_model_digest,omitempty"` + EmbeddingModelDigest string `json:"embedding_model_digest,omitempty"` + LastCheck time.Time `json:"last_check,omitempty"` + LastSuccess time.Time `json:"last_success,omitempty"` + CooldownUntil time.Time `json:"cooldown_until,omitempty"` + ConsecutiveFailures int `json:"consecutive_failures,omitempty"` + LastError string `json:"last_error,omitempty"` + Requests uint64 `json:"requests"` + Failures uint64 `json:"failures"` + AverageDurationMS float64 `json:"average_duration_ms,omitempty"` + LastRequestDurationMS int64 `json:"last_request_duration_ms,omitempty"` +} + // Decision and InputSnapshot contain the exact structured values used at execution // time, so later diagnostics do not depend on the current ticket or configuration. type AnalysisRun struct { - AnalysisID string `json:"analysis_id"` - ParentRunID string `json:"parent_run_id"` - TicketID int64 `json:"ticket_id"` - AnalysisType string `json:"analysis_type"` - Trigger string `json:"trigger"` - SourceVersion string `json:"source_version"` - Model string `json:"model,omitempty"` - PromptVersion string `json:"prompt_version,omitempty"` - InputHash string `json:"input_hash,omitempty"` - InputSnapshot json.RawMessage `json:"input_snapshot,omitempty"` - StartedAt time.Time `json:"started_at"` - FinishedAt time.Time `json:"finished_at"` - DurationMS int64 `json:"duration_ms"` - Outcome string `json:"outcome"` - Error string `json:"error,omitempty"` - Decision json.RawMessage `json:"decision,omitempty"` - ReasonCodes []string `json:"reason_codes,omitempty"` - Explanation string `json:"explanation,omitempty"` - Confidence float64 `json:"confidence,omitempty"` - Checks []RuleCheck `json:"checks,omitempty"` - Action ActionAudit `json:"action,omitempty"` + AnalysisID string `json:"analysis_id"` + ParentRunID string `json:"parent_run_id"` + TicketID int64 `json:"ticket_id"` + AnalysisType string `json:"analysis_type"` + Trigger string `json:"trigger"` + SourceVersion string `json:"source_version"` + Model string `json:"model,omitempty"` + PromptVersion string `json:"prompt_version,omitempty"` + InputHash string `json:"input_hash,omitempty"` + InputSnapshot json.RawMessage `json:"input_snapshot,omitempty"` + StartedAt time.Time `json:"started_at"` + FinishedAt time.Time `json:"finished_at"` + DurationMS int64 `json:"duration_ms"` + Outcome string `json:"outcome"` + Error string `json:"error,omitempty"` + Decision json.RawMessage `json:"decision,omitempty"` + ReasonCodes []string `json:"reason_codes,omitempty"` + Explanation string `json:"explanation,omitempty"` + Confidence float64 `json:"confidence,omitempty"` + Checks []RuleCheck `json:"checks,omitempty"` + Action ActionAudit `json:"action,omitempty"` + Provider OllamaProviderTrace `json:"provider,omitempty"` } type ActionStepAudit struct { diff --git a/internal/ollama/client.go b/internal/ollama/client.go index c01a4f2..c05fe89 100644 --- a/internal/ollama/client.go +++ b/internal/ollama/client.go @@ -1,13 +1,10 @@ package ollama import ( - "bytes" "context" "encoding/json" "errors" "fmt" - "io" - "net/http" "strings" "time" @@ -16,37 +13,58 @@ import ( ) type Client struct { - baseURL, model, embeddingModel string - language, communicationStyle string - numPredict int - jsonRetries int - keepAlive time.Duration - think bool - sem chan struct{} - http *http.Client + model, embeddingModel string + language, communicationStyle string + numPredict int + jsonRetries int + keepAlive time.Duration + think bool + routingMode string + pool *Pool } +// New preserves the former single-node API and creates a one-node pool. func New(baseURL, model, embeddingModel, language, communicationStyle string, timeout time.Duration, numPredict int, keepAlive time.Duration, think bool, maxConcurrent, jsonRetries int) *Client { - return &Client{ - baseURL: strings.TrimRight(baseURL, "/"), model: model, embeddingModel: embeddingModel, - language: language, communicationStyle: communicationStyle, numPredict: numPredict, keepAlive: keepAlive, think: think, jsonRetries: jsonRetries, - sem: make(chan struct{}, maxConcurrent), - http: &http.Client{Timeout: timeout}, - } -} -func (c *Client) Ping(ctx context.Context) error { - req, _ := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/api/tags", nil) - resp, err := c.http.Do(req) + c, err := NewPool(PoolConfig{ + Nodes: []NodeConfig{{Name: "ollama-1", URL: baseURL, Weight: 1}}, RoutingMode: "least_inflight", + NodeMaxInflight: maxConcurrent, HealthInterval: 15 * time.Second, FailureCooldown: 30 * time.Second, + NodeRequestTimeout: timeout, FailoverEnabled: false, FailoverAttempts: 1, + RequireSameModelDigest: true, RequireEmbeddingModel: true, Model: model, EmbeddingModel: embeddingModel, + }, model, embeddingModel, language, communicationStyle, numPredict, keepAlive, think, jsonRetries) if err != nil { - return err + panic(err) } - defer resp.Body.Close() - if resp.StatusCode/100 != 2 { - return fmt.Errorf("Ollama HTTP %d", resp.StatusCode) + // Backwards-compatible single-node clients historically sent requests + // without a preceding /api/tags probe. Keep that behavior for tests and + // embedded users; the periodic health check will replace this optimistic + // state as soon as Start or Ping is used. + if len(c.pool.nodes) == 1 { + n := c.pool.nodes[0] + n.mu.Lock() + n.healthy = true + n.compatible = true + n.chatDigest = "legacy-unverified" + n.embeddingDigest = "legacy-unverified" + n.lastCheck = time.Now() + n.mu.Unlock() } - return nil + return c } + +func NewPool(poolCfg PoolConfig, model, embeddingModel, language, communicationStyle string, numPredict int, keepAlive time.Duration, think bool, jsonRetries int) (*Client, error) { + p, err := newPool(poolCfg) + if err != nil { + return nil, err + } + return &Client{model: model, embeddingModel: embeddingModel, language: language, communicationStyle: communicationStyle, numPredict: numPredict, keepAlive: keepAlive, think: think, jsonRetries: jsonRetries, routingMode: p.cfg.RoutingMode, pool: p}, nil +} + +func (c *Client) Start(ctx context.Context) { c.pool.Start(ctx) } +func (c *Client) Ping(ctx context.Context) error { return c.pool.Ping(ctx) } +func (c *Client) NodeStatuses() []model.OllamaNodeStatus { return c.pool.NodeStatuses() } +func (c *Client) RoutingMode() string { return c.routingMode } func (c *Client) Embed(ctx context.Context, texts []string) ([][]float64, error) { + ctx = withStage(ctx, "embedding") if len(texts) == 0 { return nil, nil } @@ -63,6 +81,7 @@ func (c *Client) Embed(ctx context.Context, texts []string) ([][]float64, error) return out.Embeddings, nil } func (c *Client) AnalyseCategory(ctx context.Context, t model.Ticket, categories []model.Category, categoryHits []model.KnowledgeHit, contextData model.ContextSnapshot) (model.Decision, error) { + ctx = withStage(ctx, "category") categoryIDs := []int64{0} for _, category := range categories { if category.ID != 0 { @@ -104,6 +123,7 @@ func (c *Client) AnalyseCategory(ctx context.Context, t model.Ticket, categories } func (c *Client) AnalyseStatus(ctx context.Context, t model.Ticket, category model.Category, candidates []model.ServiceIssueCandidate) (model.StatusDecision, error) { + ctx = withStage(ctx, "status_match") if len(candidates) == 0 { return model.StatusDecision{Reason: "Keine aktiven Störungs- oder Wartungskandidaten verfügbar."}, nil } @@ -170,6 +190,7 @@ func (c *Client) AnalyseStatus(ctx context.Context, t model.Ticket, category mod } func (c *Client) AnalyseReply(ctx context.Context, t model.Ticket, category model.Category, replyHits []model.KnowledgeHit, contextData model.ContextSnapshot) (model.Decision, error) { + ctx = withStage(ctx, "reply_selection") if len(replyHits) == 0 { var d model.Decision d.Reason = "Keine Antwort-Knowledge-Kandidaten verfügbar." @@ -257,6 +278,7 @@ func (c *Client) executeDecision(ctx context.Context, payload map[string]any, va } func (c *Client) Analyse(ctx context.Context, t model.Ticket, categories []model.Category, categoryHits, replyHits []model.KnowledgeHit, contextData model.ContextSnapshot) (model.Decision, error) { + ctx = withStage(ctx, "combined") knowledgeIDs := []string{""} knownKnowledge := map[string]struct{}{} for _, h := range replyHits { @@ -348,44 +370,11 @@ func (c *Client) Analyse(ctx context.Context, t model.Ticket, categories []model return model.Decision{}, lastErr } func (c *Client) post(ctx context.Context, path string, payload any, out any) error { - select { - case c.sem <- struct{}{}: - defer func() { <-c.sem }() - case <-ctx.Done(): - return ctx.Err() - } - b, err := json.Marshal(payload) - if err != nil { - return err - } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, bytes.NewReader(b)) - if err != nil { - return err - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "application/json") - resp, err := c.http.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - body, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20)) - if err != nil { - return err - } - if resp.StatusCode/100 != 2 { - return fmt.Errorf("Ollama %s failed: HTTP %d: %s", path, resp.StatusCode, strings.TrimSpace(string(body))) - } - if out == nil { - return nil - } - if len(body) == 0 { - return errors.New("empty Ollama response") - } - return json.Unmarshal(body, out) + return c.pool.post(ctx, path, payload, out) } func (c *Client) AnalysePriority(ctx context.Context, t model.Ticket, category model.Category, contextData model.ContextSnapshot) (model.PriorityDecision, error) { + ctx = withStage(ctx, "priority") evidence := prioritysignals.Extract(t) reasonCodes := []string{ "single_user_affected", "multiple_users_affected", "site_affected", "organization_affected", @@ -429,6 +418,7 @@ Verwende insufficient_information nur, wenn weder Auswirkung noch Dringlichkeit } func (c *Client) AnalyseEscalation(ctx context.Context, t model.Ticket, followups []model.Followup, contextData model.ContextSnapshot, evidence model.EscalationEvidence, constraints model.EscalationConstraints) (model.EscalationDecision, error) { + ctx = withStage(ctx, "escalation") actions := uniqueStrings(constraints.AllowedActions) if !containsString(actions, "none") { actions = append([]string{"none"}, actions...) diff --git a/internal/ollama/pool.go b/internal/ollama/pool.go new file mode 100644 index 0000000..6341700 --- /dev/null +++ b/internal/ollama/pool.go @@ -0,0 +1,783 @@ +package ollama + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "math" + "net/http" + "net/url" + "sort" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/example/glpi-ai-agent/internal/model" +) + +const providerName = "ollama-pool" + +type NodeConfig struct { + Name string + URL string + Weight int +} + +type PoolConfig struct { + Nodes []NodeConfig + RoutingMode string + NodeMaxInflight int + HealthInterval time.Duration + FailureCooldown time.Duration + NodeRequestTimeout time.Duration + FailoverEnabled bool + FailoverAttempts int + RequireSameModelDigest bool + RequireEmbeddingModel bool + Model string + EmbeddingModel string +} + +type requestMeta struct { + TotalDuration int64 `json:"total_duration"` + LoadDuration int64 `json:"load_duration"` + PromptEvalCount int64 `json:"prompt_eval_count"` + PromptEvalDuration int64 `json:"prompt_eval_duration"` + EvalCount int64 `json:"eval_count"` + EvalDuration int64 `json:"eval_duration"` +} + +type poolNode struct { + name string + baseURL string + weight int + sem chan struct{} + inflight atomic.Int64 + requests atomic.Uint64 + failures atomic.Uint64 + + mu sync.RWMutex + healthy bool + compatible bool + chatDigest string + embeddingDigest string + lastCheck time.Time + lastSuccess time.Time + cooldownUntil time.Time + consecutiveFailures int + lastError string + averageDurationMS float64 + lastRequestDurationMS int64 +} + +func (n *poolNode) acquire() (int64, bool) { + select { + case n.sem <- struct{}{}: + return n.inflight.Add(1), true + default: + return n.inflight.Load(), false + } +} + +func (n *poolNode) release() { + n.inflight.Add(-1) + <-n.sem +} + +func (n *poolNode) isEligible(now time.Time, stage string) bool { + n.mu.RLock() + defer n.mu.RUnlock() + if !n.healthy || !n.compatible || now.Before(n.cooldownUntil) { + return false + } + if stage == "embedding" && n.embeddingDigest == "" { + return false + } + return true +} + +func (n *poolNode) recordRequest(duration time.Duration, ok bool, retryable bool, err error) { + n.requests.Add(1) + ms := duration.Milliseconds() + if ms < 0 { + ms = 0 + } + n.mu.Lock() + n.lastRequestDurationMS = ms + if ok { + if n.averageDurationMS == 0 { + n.averageDurationMS = float64(ms) + } else { + n.averageDurationMS = n.averageDurationMS*0.8 + float64(ms)*0.2 + } + n.lastSuccess = time.Now() + n.consecutiveFailures = 0 + n.lastError = "" + n.cooldownUntil = time.Time{} + } else { + n.failures.Add(1) + n.consecutiveFailures++ + if err != nil { + n.lastError = err.Error() + } + if retryable { + // The caller applies the configured cooldown after releasing the lock. + } + } + n.mu.Unlock() +} + +func (n *poolNode) status(maxInflight int) model.OllamaNodeStatus { + n.mu.RLock() + defer n.mu.RUnlock() + now := time.Now() + return model.OllamaNodeStatus{ + Name: n.name, URL: n.baseURL, Weight: n.weight, + Healthy: n.healthy, Compatible: n.compatible, + Available: n.healthy && n.compatible && !now.Before(n.cooldownUntil) && n.inflight.Load() < int64(maxInflight), + InFlight: n.inflight.Load(), MaxInFlight: maxInflight, + ChatModelDigest: n.chatDigest, EmbeddingModelDigest: n.embeddingDigest, + LastCheck: n.lastCheck, LastSuccess: n.lastSuccess, CooldownUntil: n.cooldownUntil, + ConsecutiveFailures: n.consecutiveFailures, LastError: n.lastError, + Requests: n.requests.Load(), Failures: n.failures.Load(), + AverageDurationMS: n.averageDurationMS, LastRequestDurationMS: n.lastRequestDurationMS, + } +} + +type Pool struct { + cfg PoolConfig + nodes []*poolNode + http *http.Client + rr atomic.Uint64 + refresh sync.Mutex + started atomic.Bool +} + +func newPool(cfg PoolConfig) (*Pool, error) { + if len(cfg.Nodes) == 0 { + return nil, errors.New("at least one Ollama node is required") + } + if len(cfg.Nodes) > 64 { + return nil, errors.New("at most 64 Ollama nodes are supported") + } + if strings.TrimSpace(cfg.Model) == "" { + return nil, errors.New("Ollama chat model must not be empty") + } + if cfg.RequireEmbeddingModel && strings.TrimSpace(cfg.EmbeddingModel) == "" { + return nil, errors.New("Ollama embedding model must not be empty when it is required") + } + if cfg.NodeMaxInflight <= 0 { + cfg.NodeMaxInflight = 1 + } + if cfg.HealthInterval <= 0 { + cfg.HealthInterval = 15 * time.Second + } + if cfg.FailureCooldown < 0 { + cfg.FailureCooldown = 0 + } + if cfg.NodeRequestTimeout <= 0 { + cfg.NodeRequestTimeout = 10 * time.Minute + } + if cfg.FailoverAttempts <= 0 { + cfg.FailoverAttempts = len(cfg.Nodes) + } + if cfg.FailoverAttempts > len(cfg.Nodes) { + cfg.FailoverAttempts = len(cfg.Nodes) + } + cfg.RoutingMode = strings.ToLower(strings.TrimSpace(cfg.RoutingMode)) + if cfg.RoutingMode == "" { + cfg.RoutingMode = "least_inflight" + } + switch cfg.RoutingMode { + case "least_inflight", "round_robin", "weighted", "fastest_recent": + default: + return nil, fmt.Errorf("unsupported Ollama routing mode %q", cfg.RoutingMode) + } + p := &Pool{cfg: cfg, http: &http.Client{Transport: &http.Transport{ + Proxy: http.ProxyFromEnvironment, + MaxIdleConns: 100, + MaxIdleConnsPerHost: 16, + IdleConnTimeout: 90 * time.Second, + ResponseHeaderTimeout: cfg.NodeRequestTimeout, + }}} + seenNames := map[string]struct{}{} + seenURLs := map[string]struct{}{} + for i, c := range cfg.Nodes { + base := strings.TrimRight(strings.TrimSpace(c.URL), "/") + u, err := url.Parse(base) + if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") || u.User != nil || u.RawQuery != "" || u.Fragment != "" { + return nil, fmt.Errorf("invalid Ollama node URL %q", c.URL) + } + if _, ok := seenURLs[base]; ok { + return nil, fmt.Errorf("duplicate Ollama node URL %q", base) + } + seenURLs[base] = struct{}{} + name := strings.TrimSpace(c.Name) + if name == "" { + name = u.Hostname() + if name == "" { + name = fmt.Sprintf("ollama-%d", i+1) + } + } + if _, ok := seenNames[name]; ok { + name = fmt.Sprintf("%s-%d", name, i+1) + } + seenNames[name] = struct{}{} + weight := c.Weight + if weight <= 0 { + weight = 1 + } + if weight > 100 { + return nil, fmt.Errorf("Ollama node weight for %q must be between 1 and 100", name) + } + p.nodes = append(p.nodes, &poolNode{name: name, baseURL: base, weight: weight, sem: make(chan struct{}, cfg.NodeMaxInflight)}) + } + return p, nil +} + +func (p *Pool) Start(ctx context.Context) { + if !p.started.CompareAndSwap(false, true) { + return + } + go func() { + p.refreshAll(ctx) + ticker := time.NewTicker(p.cfg.HealthInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + p.refreshAll(ctx) + } + } + }() +} + +func (p *Pool) Ping(ctx context.Context) error { + p.refreshAll(ctx) + for _, n := range p.nodes { + if n.isEligible(time.Now(), "") { + return nil + } + } + return p.unavailableError() +} + +func (p *Pool) NodeStatuses() []model.OllamaNodeStatus { + out := make([]model.OllamaNodeStatus, 0, len(p.nodes)) + for _, n := range p.nodes { + out = append(out, n.status(p.cfg.NodeMaxInflight)) + } + return out +} + +func (p *Pool) refreshAll(ctx context.Context) { + // The first request may arrive while Start is still performing the initial + // health scan. Serialize callers instead of returning early, otherwise a + // healthy pool can briefly look empty during startup. + p.refresh.Lock() + defer p.refresh.Unlock() + + type result struct { + node *poolNode + healthy bool + chatDigest string + embeddingDigest string + err error + } + ch := make(chan result, len(p.nodes)) + for _, n := range p.nodes { + go func(n *poolNode) { + checkCtx := ctx + cancel := func() {} + timeout := p.cfg.NodeRequestTimeout + if timeout <= 0 || timeout > 10*time.Second { + timeout = 10 * time.Second + } + checkCtx, cancel = context.WithTimeout(ctx, timeout) + defer cancel() + chatDigest, embeddingDigest, err := p.checkNode(checkCtx, n) + ch <- result{node: n, healthy: err == nil, chatDigest: chatDigest, embeddingDigest: embeddingDigest, err: err} + }(n) + } + results := make([]result, 0, len(p.nodes)) + for range p.nodes { + results = append(results, <-ch) + } + + chatDigest, chatConflict := commonDigest(results, func(r result) (string, bool) { return r.chatDigest, r.healthy }) + embedDigest, embedConflict := commonDigest(results, func(r result) (string, bool) { return r.embeddingDigest, r.healthy && r.embeddingDigest != "" }) + now := time.Now() + for _, r := range results { + n := r.node + n.mu.Lock() + previousHealthy := n.healthy + previousCompatible := n.compatible + previousError := n.lastError + n.lastCheck = now + n.healthy = r.healthy + n.chatDigest = r.chatDigest + n.embeddingDigest = r.embeddingDigest + n.compatible = r.healthy + if r.healthy && p.cfg.RequireSameModelDigest { + switch { + case chatConflict: + n.compatible = false + r.err = fmt.Errorf("chat model digests differ inside the pool; node digest=%s", r.chatDigest) + case chatDigest != "" && r.chatDigest != chatDigest: + n.compatible = false + r.err = fmt.Errorf("chat model digest differs from pool digest: node=%s pool=%s", r.chatDigest, chatDigest) + } + // Even when chat-only nodes are allowed, all nodes that can serve + // embeddings must expose the same embedding model digest. + switch { + case embedConflict: + n.compatible = false + r.err = fmt.Errorf("embedding model digests differ inside the pool; node digest=%s", r.embeddingDigest) + case r.embeddingDigest != "" && embedDigest != "" && r.embeddingDigest != embedDigest: + n.compatible = false + r.err = fmt.Errorf("embedding model digest differs from pool digest: node=%s pool=%s", r.embeddingDigest, embedDigest) + } + } + if r.err != nil { + n.lastError = r.err.Error() + } else { + n.lastError = "" + n.lastSuccess = now + n.consecutiveFailures = 0 + } + currentHealthy := n.healthy + currentCompatible := n.compatible + currentError := n.lastError + n.mu.Unlock() + if previousHealthy != currentHealthy || previousCompatible != currentCompatible || previousError != currentError { + if currentHealthy && currentCompatible { + slog.Info("Ollama node available", "node", n.name, "url", n.baseURL, "chat_digest", r.chatDigest, "embedding_digest", r.embeddingDigest) + } else { + slog.Warn("Ollama node unavailable", "node", n.name, "url", n.baseURL, "healthy", currentHealthy, "compatible", currentCompatible, "error", currentError) + } + } + } +} + +func commonDigest[T any](results []T, getter func(T) (string, bool)) (string, bool) { + common := "" + for _, r := range results { + digest, include := getter(r) + digest = strings.TrimSpace(digest) + if !include || digest == "" { + continue + } + if common == "" { + common = digest + continue + } + if digest != common { + return "", true + } + } + return common, false +} + +func (p *Pool) checkNode(ctx context.Context, n *poolNode) (string, string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, n.baseURL+"/api/tags", nil) + if err != nil { + return "", "", err + } + resp, err := p.http.Do(req) + if err != nil { + return "", "", err + } + defer resp.Body.Close() + body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20)) + if err != nil { + return "", "", err + } + if resp.StatusCode/100 != 2 { + return "", "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + var tags struct { + Models []struct { + Name string `json:"name"` + Model string `json:"model"` + Digest string `json:"digest"` + } `json:"models"` + } + if err := json.Unmarshal(body, &tags); err != nil { + return "", "", fmt.Errorf("decode /api/tags: %w", err) + } + find := func(wanted string) string { + wanted = strings.TrimSpace(wanted) + for _, m := range tags.Models { + if modelNameMatches(wanted, m.Name) || modelNameMatches(wanted, m.Model) { + return strings.TrimSpace(m.Digest) + } + } + return "" + } + chat := find(p.cfg.Model) + if chat == "" { + return "", "", fmt.Errorf("model %q is not installed", p.cfg.Model) + } + embed := "" + if strings.TrimSpace(p.cfg.EmbeddingModel) != "" { + embed = find(p.cfg.EmbeddingModel) + if p.cfg.RequireEmbeddingModel && embed == "" { + return "", "", fmt.Errorf("embedding model %q is not installed", p.cfg.EmbeddingModel) + } + } + return chat, embed, nil +} + +func modelNameMatches(wanted, got string) bool { + wanted = strings.TrimSpace(wanted) + got = strings.TrimSpace(got) + if wanted == got { + return true + } + if !strings.Contains(wanted, ":") && strings.TrimSuffix(got, ":latest") == wanted { + return true + } + if !strings.Contains(got, ":") && strings.TrimSuffix(wanted, ":latest") == got { + return true + } + return false +} + +func (p *Pool) post(ctx context.Context, path string, payload any, out any) error { + body, err := json.Marshal(payload) + if err != nil { + return err + } + if !p.anyKnownHealthy() { + p.refreshAll(ctx) + } + attemptLimit := 1 + if p.cfg.FailoverEnabled { + attemptLimit = p.cfg.FailoverAttempts + if attemptLimit <= 0 || attemptLimit > len(p.nodes) { + attemptLimit = len(p.nodes) + } + } + attempted := map[string]struct{}{} + var errs []error + for attempt := 1; attempt <= attemptLimit; attempt++ { + n, inflight, selectErr := p.selectNode(ctx, attempted, requestStage(ctx)) + if selectErr != nil { + errs = append(errs, selectErr) + break + } + attempted[n.name] = struct{}{} + started := time.Now() + stage := requestStage(ctx) + status, raw, reqErr := p.doPost(ctx, n, path, body) + if reqErr == nil && len(raw) > 0 { + var envelope struct { + Error string `json:"error"` + } + if json.Unmarshal(raw, &envelope) == nil && strings.TrimSpace(envelope.Error) != "" { + reqErr = fmt.Errorf("Ollama %s returned an error from %s: %s", path, n.name, strings.TrimSpace(envelope.Error)) + } + } + if reqErr == nil && out != nil { + if len(raw) == 0 { + reqErr = errors.New("empty Ollama response") + } else if decodeErr := json.Unmarshal(raw, out); decodeErr != nil { + reqErr = fmt.Errorf("decode Ollama %s response from %s: %w", path, n.name, decodeErr) + } + } + duration := time.Since(started) + n.release() + retryable := isRetryable(reqErr, status) + // A 2xx response that cannot be decoded is safe to fail over because no + // application decision was accepted from this node. + if reqErr != nil && status/100 == 2 { + retryable = true + } + ok := reqErr == nil + n.recordRequest(duration, ok, retryable, reqErr) + if !ok && retryable && p.cfg.FailureCooldown > 0 { + n.mu.Lock() + n.cooldownUntil = time.Now().Add(p.cfg.FailureCooldown) + n.mu.Unlock() + } + meta := requestMeta{} + if len(raw) > 0 { + _ = json.Unmarshal(raw, &meta) + } + recordTraceAttempt(ctx, model.OllamaRequestAttempt{ + Attempt: attempt, Stage: stage, Path: path, NodeName: n.name, NodeURL: n.baseURL, + ModelDigest: n.digestForStage(stage), StartedAt: started, DurationMS: duration.Milliseconds(), + InflightAtStart: inflight, HTTPStatus: status, Outcome: outcomeText(reqErr), Retryable: retryable, + Error: errorText(reqErr), TotalDurationNS: meta.TotalDuration, LoadDurationNS: meta.LoadDuration, + PromptEvalCount: meta.PromptEvalCount, PromptEvalDuration: meta.PromptEvalDuration, + EvalCount: meta.EvalCount, EvalDuration: meta.EvalDuration, + }) + if reqErr == nil { + markTraceSuccess(ctx, n.name, n.baseURL) + return nil + } + errs = append(errs, fmt.Errorf("%s: %w", n.name, reqErr)) + if retryable && p.cfg.FailoverEnabled && attempt < attemptLimit && ctx.Err() == nil { + slog.Warn("Ollama request failed; trying another node", "stage", stage, "path", path, "failed_node", n.name, "attempt", attempt, "max_attempts", attemptLimit, "error", reqErr) + } + if !retryable || !p.cfg.FailoverEnabled || attempt == attemptLimit || ctx.Err() != nil { + break + } + } + if len(errs) == 0 { + return p.unavailableError() + } + return errors.Join(errs...) +} + +func (n *poolNode) digestForStage(stage string) string { + n.mu.RLock() + defer n.mu.RUnlock() + if stage == "embedding" { + return n.embeddingDigest + } + return n.chatDigest +} + +func (p *Pool) doPost(ctx context.Context, n *poolNode, path string, body []byte) (int, []byte, error) { + reqCtx := ctx + cancel := func() {} + if p.cfg.NodeRequestTimeout > 0 { + reqCtx, cancel = context.WithTimeout(ctx, p.cfg.NodeRequestTimeout) + } + defer cancel() + req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, n.baseURL+path, bytes.NewReader(body)) + if err != nil { + return 0, nil, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + resp, err := p.http.Do(req) + if err != nil { + return 0, nil, err + } + defer resp.Body.Close() + raw, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20)) + if err != nil { + return resp.StatusCode, nil, err + } + if resp.StatusCode/100 != 2 { + return resp.StatusCode, raw, fmt.Errorf("Ollama %s failed: HTTP %d: %s", path, resp.StatusCode, strings.TrimSpace(string(raw))) + } + return resp.StatusCode, raw, nil +} + +func (p *Pool) selectNode(ctx context.Context, excluded map[string]struct{}, stage string) (*poolNode, int64, error) { + for { + nodes := p.orderedCandidates(excluded, stage) + if len(nodes) == 0 { + return nil, 0, p.unavailableError() + } + for _, n := range nodes { + if inflight, ok := n.acquire(); ok { + return n, inflight, nil + } + } + select { + case <-ctx.Done(): + return nil, 0, ctx.Err() + case <-time.After(15 * time.Millisecond): + } + } +} + +func (p *Pool) orderedCandidates(excluded map[string]struct{}, stage string) []*poolNode { + now := time.Now() + out := make([]*poolNode, 0, len(p.nodes)) + for _, n := range p.nodes { + if _, skip := excluded[n.name]; skip { + continue + } + if n.isEligible(now, stage) { + out = append(out, n) + } + } + if len(out) <= 1 { + return out + } + switch p.cfg.RoutingMode { + case "round_robin": + start := int(p.rr.Add(1)-1) % len(out) + rotated := append([]*poolNode(nil), out[start:]...) + rotated = append(rotated, out[:start]...) + return rotated + case "fastest_recent": + sort.SliceStable(out, func(i, j int) bool { + ai, aj := out[i].status(p.cfg.NodeMaxInflight), out[j].status(p.cfg.NodeMaxInflight) + // Probe nodes without measurements before permanently preferring a + // known node. This prevents new/recovered nodes from starving. + if ai.AverageDurationMS == 0 && aj.AverageDurationMS != 0 { + return true + } + if aj.AverageDurationMS == 0 && ai.AverageDurationMS != 0 { + return false + } + if ai.AverageDurationMS == aj.AverageDurationMS { + if ai.InFlight == aj.InFlight { + return ai.Requests < aj.Requests + } + return ai.InFlight < aj.InFlight + } + return ai.AverageDurationMS < aj.AverageDurationMS + }) + case "weighted": + sort.SliceStable(out, func(i, j int) bool { + // Weighted least-request scheduling also works for serial traffic; + // using only current inflight values would permanently select the + // highest-weight node whenever requests do not overlap. + si := float64(out[i].requests.Load()+uint64(out[i].inflight.Load())+1) / float64(maxInt(out[i].weight, 1)) + sj := float64(out[j].requests.Load()+uint64(out[j].inflight.Load())+1) / float64(maxInt(out[j].weight, 1)) + if math.Abs(si-sj) < 1e-9 { + return out[i].name < out[j].name + } + return si < sj + }) + default: // least_inflight + sort.SliceStable(out, func(i, j int) bool { + ii, ij := out[i].inflight.Load(), out[j].inflight.Load() + if ii == ij { + ri, rj := out[i].requests.Load(), out[j].requests.Load() + if ri != rj { + return ri < rj + } + si, sj := out[i].status(p.cfg.NodeMaxInflight), out[j].status(p.cfg.NodeMaxInflight) + if si.AverageDurationMS != sj.AverageDurationMS && si.AverageDurationMS > 0 && sj.AverageDurationMS > 0 { + return si.AverageDurationMS < sj.AverageDurationMS + } + return out[i].name < out[j].name + } + return ii < ij + }) + } + return out +} + +func (p *Pool) anyKnownHealthy() bool { + for _, n := range p.nodes { + n.mu.RLock() + known := !n.lastCheck.IsZero() + healthy := n.healthy && n.compatible + n.mu.RUnlock() + if known && healthy { + return true + } + } + return false +} + +func (p *Pool) unavailableError() error { + statuses := p.NodeStatuses() + parts := make([]string, 0, len(statuses)) + for _, s := range statuses { + detail := s.LastError + if detail == "" { + detail = "not available" + } + parts = append(parts, fmt.Sprintf("%s: %s", s.Name, detail)) + } + return fmt.Errorf("no compatible Ollama node available (%s)", strings.Join(parts, "; ")) +} + +func isRetryable(err error, status int) bool { + if err == nil { + return false + } + if status == 0 || status == http.StatusRequestTimeout || status == http.StatusTooManyRequests { + return true + } + return status == http.StatusBadGateway || status == http.StatusServiceUnavailable || status == http.StatusGatewayTimeout || status >= 500 +} + +func outcomeText(err error) string { + if err == nil { + return "success" + } + return "error" +} +func errorText(err error) string { + if err == nil { + return "" + } + return err.Error() +} +func maxInt(a, b int) int { + if a > b { + return a + } + return b +} + +// Trace is a concurrency-safe collector attached to one logical analysis context. +type Trace struct { + mu sync.Mutex + data model.OllamaProviderTrace +} + +type traceKey struct{} +type stageKey struct{} + +func WithTrace(ctx context.Context, routingMode string) (context.Context, *Trace) { + t := &Trace{data: model.OllamaProviderTrace{Provider: providerName, RoutingMode: routingMode}} + return context.WithValue(ctx, traceKey{}, t), t +} + +func withStage(ctx context.Context, stage string) context.Context { + return context.WithValue(ctx, stageKey{}, strings.TrimSpace(stage)) +} + +func requestStage(ctx context.Context) string { + v, _ := ctx.Value(stageKey{}).(string) + return v +} + +func recordTraceAttempt(ctx context.Context, a model.OllamaRequestAttempt) { + t, _ := ctx.Value(traceKey{}).(*Trace) + if t == nil { + return + } + t.mu.Lock() + a.Attempt = len(t.data.Attempts) + 1 + if len(t.data.Attempts) > 0 { + previous := t.data.Attempts[len(t.data.Attempts)-1] + if previous.NodeName != a.NodeName && previous.Retryable && previous.Outcome == "error" { + t.data.FailoverUsed = true + } + } + t.data.Attempts = append(t.data.Attempts, a) + t.data.AttemptCount = len(t.data.Attempts) + t.mu.Unlock() +} + +func markTraceSuccess(ctx context.Context, node, nodeURL string) { + t, _ := ctx.Value(traceKey{}).(*Trace) + if t == nil { + return + } + t.mu.Lock() + t.data.SelectedNode = node + t.data.SelectedURL = nodeURL + t.mu.Unlock() +} + +func (t *Trace) Snapshot() model.OllamaProviderTrace { + if t == nil { + return model.OllamaProviderTrace{} + } + t.mu.Lock() + defer t.mu.Unlock() + out := t.data + out.Attempts = append([]model.OllamaRequestAttempt(nil), t.data.Attempts...) + return out +} diff --git a/internal/ollama/pool_test.go b/internal/ollama/pool_test.go new file mode 100644 index 0000000..ed3bd03 --- /dev/null +++ b/internal/ollama/pool_test.go @@ -0,0 +1,390 @@ +package ollama + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/example/glpi-ai-agent/internal/model" +) + +func tagsResponse(w http.ResponseWriter, chatDigest, embedDigest string) { + _ = json.NewEncoder(w).Encode(map[string]any{"models": []map[string]any{ + {"name": "m:latest", "model": "m:latest", "digest": chatDigest}, + {"name": "e:latest", "model": "e:latest", "digest": embedDigest}, + }}) +} + +func categoryResponse(w http.ResponseWriter, id int64) { + _ = json.NewEncoder(w).Encode(map[string]any{"message": map[string]any{"content": `{"category":{"id":` + jsonNumber(id) + `,"confidence":0.9},"reason":"ok"}`}}) +} + +func jsonNumber(v int64) string { + b, _ := json.Marshal(v) + return string(b) +} + +func newTestPool(t *testing.T, nodes []NodeConfig, routing string) *Client { + t.Helper() + c, err := NewPool(PoolConfig{ + Nodes: nodes, RoutingMode: routing, NodeMaxInflight: 1, + HealthInterval: time.Minute, FailureCooldown: time.Second, + NodeRequestTimeout: 2 * time.Second, FailoverEnabled: true, FailoverAttempts: len(nodes), + RequireSameModelDigest: true, RequireEmbeddingModel: true, Model: "m", EmbeddingModel: "e", + }, "m", "e", "de-DE", "formal", 128, time.Minute, false, 0) + if err != nil { + t.Fatal(err) + } + if err := c.Ping(context.Background()); err != nil { + t.Fatal(err) + } + return c +} + +func TestPoolFailsOverAndRecordsTrace(t *testing.T) { + var badCalls atomic.Int64 + bad := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/tags" { + tagsResponse(w, "chat-digest", "embed-digest") + return + } + badCalls.Add(1) + http.Error(w, "temporarily unavailable", http.StatusServiceUnavailable) + })) + defer bad.Close() + var goodCalls atomic.Int64 + good := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/tags" { + tagsResponse(w, "chat-digest", "embed-digest") + return + } + goodCalls.Add(1) + categoryResponse(w, 1) + })) + defer good.Close() + + c := newTestPool(t, []NodeConfig{{Name: "a-bad", URL: bad.URL}, {Name: "b-good", URL: good.URL}}, "least_inflight") + ctx, trace := WithTrace(context.Background(), c.RoutingMode()) + d, err := c.AnalyseCategory(ctx, model.Ticket{ID: 1}, []model.Category{{ID: 1}}, nil, model.ContextSnapshot{}) + if err != nil { + t.Fatal(err) + } + if d.Category.ID != 1 || badCalls.Load() != 1 || goodCalls.Load() != 1 { + t.Fatalf("decision=%+v bad=%d good=%d", d, badCalls.Load(), goodCalls.Load()) + } + got := trace.Snapshot() + if !got.FailoverUsed || got.SelectedNode != "b-good" || len(got.Attempts) != 2 { + t.Fatalf("unexpected trace: %+v", got) + } + if got.Attempts[0].HTTPStatus != http.StatusServiceUnavailable || !got.Attempts[0].Retryable { + t.Fatalf("unexpected first attempt: %+v", got.Attempts[0]) + } +} + +func TestPoolLeastInflightUsesFreeNode(t *testing.T) { + started := make(chan struct{}) + release := make(chan struct{}) + var once sync.Once + var callsA, callsB atomic.Int64 + a := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/tags" { + tagsResponse(w, "chat-digest", "embed-digest") + return + } + callsA.Add(1) + once.Do(func() { close(started) }) + <-release + categoryResponse(w, 1) + })) + defer a.Close() + b := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/tags" { + tagsResponse(w, "chat-digest", "embed-digest") + return + } + callsB.Add(1) + categoryResponse(w, 1) + })) + defer b.Close() + + c := newTestPool(t, []NodeConfig{{Name: "a", URL: a.URL}, {Name: "b", URL: b.URL}}, "least_inflight") + errCh := make(chan error, 2) + go func() { + _, err := c.AnalyseCategory(context.Background(), model.Ticket{ID: 1}, []model.Category{{ID: 1}}, nil, model.ContextSnapshot{}) + errCh <- err + }() + select { + case <-started: + case <-time.After(time.Second): + t.Fatal("first node did not start") + } + go func() { + _, err := c.AnalyseCategory(context.Background(), model.Ticket{ID: 2}, []model.Category{{ID: 1}}, nil, model.ContextSnapshot{}) + errCh <- err + }() + deadline := time.Now().Add(time.Second) + for callsB.Load() == 0 && time.Now().Before(deadline) { + time.Sleep(5 * time.Millisecond) + } + close(release) + for i := 0; i < 2; i++ { + if err := <-errCh; err != nil { + t.Fatal(err) + } + } + if callsA.Load() != 1 || callsB.Load() != 1 { + t.Fatalf("least-inflight distribution a=%d b=%d", callsA.Load(), callsB.Load()) + } +} + +func TestPoolRejectsMismatchedDigest(t *testing.T) { + server := func(chatDigest string) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + tagsResponse(w, chatDigest, "embed-digest") + })) + } + a := server("aaa") + defer a.Close() + b := server("bbb") + defer b.Close() + c, err := NewPool(PoolConfig{ + Nodes: []NodeConfig{{Name: "a", URL: a.URL}, {Name: "b", URL: b.URL}}, RoutingMode: "least_inflight", NodeMaxInflight: 1, + HealthInterval: time.Minute, NodeRequestTimeout: time.Second, FailoverEnabled: true, FailoverAttempts: 2, + RequireSameModelDigest: true, RequireEmbeddingModel: true, Model: "m", EmbeddingModel: "e", + }, "m", "e", "de-DE", "formal", 128, time.Minute, false, 0) + if err != nil { + t.Fatal(err) + } + if err := c.Ping(context.Background()); err == nil { + t.Fatal("expected pool with divergent model digests to fail closed") + } + statuses := c.NodeStatuses() + for _, status := range statuses { + if status.Compatible { + t.Fatalf("mismatched node must be incompatible: %+v", statuses) + } + } +} + +func TestPoolFirstRequestWaitsForInitialHealthScan(t *testing.T) { + healthStarted := make(chan struct{}) + releaseHealth := make(chan struct{}) + var healthCalls atomic.Int64 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/tags" { + if healthCalls.Add(1) == 1 { + close(healthStarted) + <-releaseHealth + } + tagsResponse(w, "chat-digest", "embed-digest") + return + } + categoryResponse(w, 1) + })) + defer server.Close() + + c, err := NewPool(PoolConfig{ + Nodes: []NodeConfig{{Name: "node-1", URL: server.URL}}, RoutingMode: "least_inflight", NodeMaxInflight: 1, + HealthInterval: time.Minute, NodeRequestTimeout: 2 * time.Second, FailoverEnabled: false, FailoverAttempts: 1, + RequireSameModelDigest: true, RequireEmbeddingModel: true, Model: "m", EmbeddingModel: "e", + }, "m", "e", "de-DE", "formal", 128, time.Minute, false, 0) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + c.Start(ctx) + select { + case <-healthStarted: + case <-time.After(time.Second): + t.Fatal("initial health scan did not start") + } + + errCh := make(chan error, 1) + go func() { + _, err := c.AnalyseCategory(context.Background(), model.Ticket{ID: 1}, []model.Category{{ID: 1}}, nil, model.ContextSnapshot{}) + errCh <- err + }() + select { + case err := <-errCh: + t.Fatalf("request returned before health scan completed: %v", err) + case <-time.After(50 * time.Millisecond): + } + close(releaseHealth) + select { + case err := <-errCh: + if err != nil { + t.Fatal(err) + } + case <-time.After(2 * time.Second): + t.Fatal("request did not continue after health scan") + } +} + +func TestPoolFailsOverOnInvalidOuterJSON(t *testing.T) { + bad := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/tags" { + tagsResponse(w, "chat-digest", "embed-digest") + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"message":`)) + })) + defer bad.Close() + good := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/tags" { + tagsResponse(w, "chat-digest", "embed-digest") + return + } + categoryResponse(w, 1) + })) + defer good.Close() + + c := newTestPool(t, []NodeConfig{{Name: "a-bad-json", URL: bad.URL}, {Name: "b-good", URL: good.URL}}, "least_inflight") + ctx, trace := WithTrace(context.Background(), c.RoutingMode()) + if _, err := c.AnalyseCategory(ctx, model.Ticket{ID: 1}, []model.Category{{ID: 1}}, nil, model.ContextSnapshot{}); err != nil { + t.Fatal(err) + } + got := trace.Snapshot() + if !got.FailoverUsed || got.SelectedNode != "b-good" || len(got.Attempts) != 2 { + t.Fatalf("unexpected trace: %+v", got) + } + if got.Attempts[0].Outcome != "error" || !got.Attempts[0].Retryable || got.Attempts[0].Error == "" { + t.Fatalf("invalid JSON must be recorded as retryable error: %+v", got.Attempts[0]) + } +} + +func TestPoolAllowsChatOnlyNodeButRoutesEmbeddingsToCapableNode(t *testing.T) { + var chatOnlyEmbedCalls atomic.Int64 + chatOnly := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/tags" { + _ = json.NewEncoder(w).Encode(map[string]any{"models": []map[string]any{{"name": "m:latest", "model": "m:latest", "digest": "chat-digest"}}}) + return + } + if r.URL.Path == "/api/embed" { + chatOnlyEmbedCalls.Add(1) + } + categoryResponse(w, 1) + })) + defer chatOnly.Close() + var embeddingCalls atomic.Int64 + embeddingNode := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/tags" { + tagsResponse(w, "chat-digest", "embed-digest") + return + } + if r.URL.Path == "/api/embed" { + embeddingCalls.Add(1) + _ = json.NewEncoder(w).Encode(map[string]any{"embeddings": [][]float64{{0.1, 0.2}}}) + return + } + categoryResponse(w, 1) + })) + defer embeddingNode.Close() + + c, err := NewPool(PoolConfig{ + Nodes: []NodeConfig{{Name: "chat-only", URL: chatOnly.URL}, {Name: "embedding", URL: embeddingNode.URL}}, + RoutingMode: "least_inflight", NodeMaxInflight: 1, HealthInterval: time.Minute, + NodeRequestTimeout: time.Second, FailoverEnabled: true, FailoverAttempts: 2, + RequireSameModelDigest: true, RequireEmbeddingModel: false, Model: "m", EmbeddingModel: "e", + }, "m", "e", "de-DE", "formal", 128, time.Minute, false, 0) + if err != nil { + t.Fatal(err) + } + if err := c.Ping(context.Background()); err != nil { + t.Fatal(err) + } + vectors, err := c.Embed(context.Background(), []string{"test"}) + if err != nil { + t.Fatal(err) + } + if len(vectors) != 1 || embeddingCalls.Load() != 1 || chatOnlyEmbedCalls.Load() != 0 { + t.Fatalf("vectors=%v embedding_calls=%d chat_only_embed_calls=%d", vectors, embeddingCalls.Load(), chatOnlyEmbedCalls.Load()) + } +} + +func TestPoolLeastInflightBalancesSerialRequests(t *testing.T) { + var callsA, callsB atomic.Int64 + server := func(calls *atomic.Int64) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/tags" { + tagsResponse(w, "chat-digest", "embed-digest") + return + } + calls.Add(1) + categoryResponse(w, 1) + })) + } + a := server(&callsA) + defer a.Close() + b := server(&callsB) + defer b.Close() + c := newTestPool(t, []NodeConfig{{Name: "a", URL: a.URL}, {Name: "b", URL: b.URL}}, "least_inflight") + for i := 0; i < 4; i++ { + if _, err := c.AnalyseCategory(context.Background(), model.Ticket{ID: int64(i + 1)}, []model.Category{{ID: 1}}, nil, model.ContextSnapshot{}); err != nil { + t.Fatal(err) + } + } + if callsA.Load() != 2 || callsB.Load() != 2 { + t.Fatalf("serial least-inflight distribution a=%d b=%d", callsA.Load(), callsB.Load()) + } +} + +func TestPoolWeightedBalancesSerialRequestsByWeight(t *testing.T) { + var callsA, callsB atomic.Int64 + server := func(calls *atomic.Int64) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/tags" { + tagsResponse(w, "chat-digest", "embed-digest") + return + } + calls.Add(1) + categoryResponse(w, 1) + })) + } + a := server(&callsA) + defer a.Close() + b := server(&callsB) + defer b.Close() + c := newTestPool(t, []NodeConfig{{Name: "a", URL: a.URL, Weight: 1}, {Name: "b", URL: b.URL, Weight: 3}}, "weighted") + for i := 0; i < 8; i++ { + if _, err := c.AnalyseCategory(context.Background(), model.Ticket{ID: int64(i + 1)}, []model.Category{{ID: 1}}, nil, model.ContextSnapshot{}); err != nil { + t.Fatal(err) + } + } + if callsA.Load() == 0 || callsB.Load() <= callsA.Load() { + t.Fatalf("weighted distribution must use both nodes and prefer weight 3: a=%d b=%d", callsA.Load(), callsB.Load()) + } +} + +func TestPoolFastestRecentProbesUnmeasuredNodes(t *testing.T) { + var callsA, callsB atomic.Int64 + server := func(calls *atomic.Int64) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/tags" { + tagsResponse(w, "chat-digest", "embed-digest") + return + } + calls.Add(1) + categoryResponse(w, 1) + })) + } + a := server(&callsA) + defer a.Close() + b := server(&callsB) + defer b.Close() + c := newTestPool(t, []NodeConfig{{Name: "a", URL: a.URL}, {Name: "b", URL: b.URL}}, "fastest_recent") + for i := 0; i < 2; i++ { + if _, err := c.AnalyseCategory(context.Background(), model.Ticket{ID: int64(i + 1)}, []model.Category{{ID: 1}}, nil, model.ContextSnapshot{}); err != nil { + t.Fatal(err) + } + } + if callsA.Load() != 1 || callsB.Load() != 1 { + t.Fatalf("fastest_recent must measure both nodes before preferring one: a=%d b=%d", callsA.Load(), callsB.Load()) + } +} diff --git a/internal/web/server.go b/internal/web/server.go index caa94da..1706286 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -54,6 +54,10 @@ type DiagnosticsManager interface { DiagnoseRun(context.Context, string) (model.RunRecord, error) DiagnoseKnowledge(context.Context, string, string, string) (model.KnowledgeDiagnostic, error) } +type OllamaNodeProvider interface { + NodeStatuses() []model.OllamaNodeStatus + RoutingMode() string +} type Server struct { cfg config.Config @@ -63,15 +67,19 @@ type Server struct { knowledge KnowledgeManager feedback FeedbackManager diagnostics DiagnosticsManager + ollamaNodes OllamaNodeProvider tpl *template.Template } -func New(cfg config.Config, m *metrics.Metrics, s *state.Store, q *queue.Queue, k KnowledgeManager, f FeedbackManager) (*Server, error) { +func New(cfg config.Config, m *metrics.Metrics, s *state.Store, q *queue.Queue, k KnowledgeManager, f FeedbackManager, providers ...OllamaNodeProvider) (*Server, error) { t, err := template.ParseFS(files, "templates/*.html") if err != nil { return nil, err } srv := &Server{cfg: cfg, metrics: m, state: s, q: q, knowledge: k, feedback: f, tpl: t} + if len(providers) > 0 { + srv.ollamaNodes = providers[0] + } if d, ok := f.(DiagnosticsManager); ok { srv.diagnostics = d } @@ -123,6 +131,38 @@ func (s *Server) ready(w http.ResponseWriter, r *http.Request) { func (s *Server) prom(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/plain; version=0.0.4") s.metrics.WritePrometheus(w) + if s.ollamaNodes == nil { + return + } + io.WriteString(w, "# TYPE glpi_agent_ollama_node_healthy gauge\n") + io.WriteString(w, "# TYPE glpi_agent_ollama_node_available gauge\n") + io.WriteString(w, "# TYPE glpi_agent_ollama_node_inflight gauge\n") + io.WriteString(w, "# TYPE glpi_agent_ollama_node_requests_total counter\n") + io.WriteString(w, "# TYPE glpi_agent_ollama_node_failures_total counter\n") + io.WriteString(w, "# TYPE glpi_agent_ollama_node_average_duration_ms gauge\n") + for _, node := range s.ollamaNodes.NodeStatuses() { + name := prometheusLabel(node.Name) + fmt.Fprintf(w, "glpi_agent_ollama_node_healthy{node=\"%s\"} %d\n", name, boolMetric(node.Healthy && node.Compatible)) + fmt.Fprintf(w, "glpi_agent_ollama_node_available{node=\"%s\"} %d\n", name, boolMetric(node.Available)) + fmt.Fprintf(w, "glpi_agent_ollama_node_inflight{node=\"%s\"} %d\n", name, node.InFlight) + fmt.Fprintf(w, "glpi_agent_ollama_node_requests_total{node=\"%s\"} %d\n", name, node.Requests) + fmt.Fprintf(w, "glpi_agent_ollama_node_failures_total{node=\"%s\"} %d\n", name, node.Failures) + fmt.Fprintf(w, "glpi_agent_ollama_node_average_duration_ms{node=\"%s\"} %.3f\n", name, node.AverageDurationMS) + } +} + +func boolMetric(v bool) int { + if v { + return 1 + } + return 0 +} + +func prometheusLabel(v string) string { + v = strings.ReplaceAll(v, `\`, `\\`) + v = strings.ReplaceAll(v, `"`, `\"`) + v = strings.ReplaceAll(v, "\n", `\n`) + return v } func (s *Server) dashboard(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" { @@ -334,6 +374,22 @@ func (s *Server) status(w http.ResponseWriter, r *http.Request) { kbOK, kbDocs, kbLastSync, kbLastErr := s.metrics.GLPIKBStatus() loadStats := s.knowledge.LoadStats() initStatus := s.knowledge.InitStatus() + ollamaNodes := []model.OllamaNodeStatus{} + ollamaRoutingMode := s.cfg.OllamaRoutingMode + if s.ollamaNodes != nil { + ollamaNodes = s.ollamaNodes.NodeStatuses() + ollamaRoutingMode = s.ollamaNodes.RoutingMode() + } + ollamaHealthyNodes := 0 + ollamaAvailableNodes := 0 + for _, node := range ollamaNodes { + if node.Healthy && node.Compatible { + ollamaHealthyNodes++ + } + if node.Available { + ollamaAvailableNodes++ + } + } respondJSON(w, map[string]any{ "uptime_seconds": int(time.Since(s.metrics.Started).Seconds()), "dry_run": s.cfg.DryRun, "auto_reply": s.cfg.AutoReply, "auto_category": s.cfg.AutoCategory, "priority_enabled": s.cfg.PriorityEnabled, "auto_priority": s.cfg.AutoPriority, "priority_confidence": s.cfg.PriorityConfidence, "priority_analysis_timeout": s.cfg.PriorityAnalysisTimeout.String(), "priority_max_increase": s.cfg.PriorityMaxIncrease, "priority_allowed_reason_codes": s.cfg.PriorityAllowedReasonCodes, @@ -354,6 +410,7 @@ func (s *Server) status(w http.ResponseWriter, r *http.Request) { "context_status_reply_enabled": s.cfg.ContextStatusReplyEnabled, "context_status_reply_min_relevance": s.cfg.ContextStatusReplyMinRelevance, "context_status_reply_min_ai_confidence": s.cfg.ContextStatusReplyMinAIConfidence, "context_status_reply_min_final_score": s.cfg.ContextStatusReplyMinFinalScore, "context_incident_reply_text_configured": strings.TrimSpace(s.cfg.ContextIncidentReplyText) != "", "context_maintenance_reply_text_configured": strings.TrimSpace(s.cfg.ContextMaintenanceReplyText) != "", "workers": s.cfg.Workers, "queue_size": s.cfg.QueueSize, "glpi_api_version": s.cfg.GLPIAPIVersion, "glpi_poll_interval": s.cfg.GLPIPollInterval.String(), "glpi_poll_limit": s.cfg.GLPIPollLimit, "glpi_allowed_status_ids": s.cfg.GLPIAllowedStatusIDs, "glpi_ticket_filter_configured": strings.TrimSpace(s.cfg.GLPITicketFilter) != "", "glpi_timeout": s.cfg.GLPITimeout.String(), "ollama_model": s.cfg.OllamaModel, "ollama_embedding_model": s.cfg.OllamaEmbeddingModel, "ollama_timeout": s.cfg.OllamaTimeout.String(), "ollama_num_predict": s.cfg.OllamaNumPredict, "ollama_keep_alive": s.cfg.OllamaKeepAlive.String(), "ollama_think": s.cfg.OllamaThink, "ollama_max_concurrent": s.cfg.OllamaMaxConcurrent, "ollama_json_retries": s.cfg.OllamaJSONRetries, + "ollama_nodes": ollamaNodes, "ollama_node_count": len(ollamaNodes), "ollama_healthy_nodes": ollamaHealthyNodes, "ollama_available_nodes": ollamaAvailableNodes, "ollama_routing_mode": ollamaRoutingMode, "ollama_node_max_inflight": s.cfg.OllamaNodeMaxInflight, "ollama_node_health_interval": s.cfg.OllamaNodeHealthInterval.String(), "ollama_node_failure_cooldown": s.cfg.OllamaNodeFailureCooldown.String(), "ollama_node_request_timeout": s.cfg.OllamaNodeRequestTimeout.String(), "ollama_failover_enabled": s.cfg.OllamaFailoverEnabled, "ollama_failover_attempts": s.cfg.OllamaFailoverAttempts, "ollama_require_same_model_digest": s.cfg.OllamaRequireSameDigest, "ollama_require_embedding_model": s.cfg.OllamaRequireEmbeddingModel, "rag_enabled": s.cfg.RAGEnabled, "knowledge_top_k": s.cfg.KnowledgeTopK, "knowledge_audit_top_k": s.cfg.KnowledgeAuditTopK, "knowledge_candidate_max_gap": s.cfg.KnowledgeCandidateMaxGap, "category_prompt_limit": s.cfg.CategoryPromptLimit, "knowledge_max_query_chunks": s.cfg.KnowledgeMaxQueryChunks, "glpi_kb_path": s.cfg.GLPIKBPath, "glpi_kb_filter_configured": strings.TrimSpace(s.cfg.GLPIKBFilter) != "", "glpi_kb_limit": s.cfg.GLPIKBLimit, "glpi_kb_auto_reply": s.cfg.GLPIKBAutoReply, "glpi_kb_auto_reply_category_ids": s.cfg.GLPIKBAutoReplyCategoryIDs, "learning_max_examples": s.cfg.LearningMaxExamples, "learning_examples_per_category": s.cfg.LearningExamplesPerCategory, diff --git a/internal/web/server_test.go b/internal/web/server_test.go index f5947cd..4917bcf 100644 --- a/internal/web/server_test.go +++ b/internal/web/server_test.go @@ -9,6 +9,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "strings" "testing" "time" @@ -314,3 +315,31 @@ func TestManualReprocessQueuesForcedWorkItem(t *testing.T) { t.Fatalf("unexpected response: %#v", body) } } + +type fakeOllamaNodeProvider struct { + statuses []model.OllamaNodeStatus +} + +func (f fakeOllamaNodeProvider) NodeStatuses() []model.OllamaNodeStatus { + return append([]model.OllamaNodeStatus(nil), f.statuses...) +} +func (f fakeOllamaNodeProvider) RoutingMode() string { return "least_inflight" } + +func TestPrometheusOllamaNodeMetricTypesAreEmittedOnce(t *testing.T) { + s := &Server{metrics: metrics.New(), ollamaNodes: fakeOllamaNodeProvider{statuses: []model.OllamaNodeStatus{ + {Name: "node-a", Healthy: true, Compatible: true, Available: true}, + {Name: "node-b", Healthy: true, Compatible: true, Available: true}, + }}} + rr := httptest.NewRecorder() + s.prom(rr, httptest.NewRequest(http.MethodGet, "/metrics", nil)) + body := rr.Body.String() + for _, metric := range []string{ + "glpi_agent_ollama_node_healthy", "glpi_agent_ollama_node_available", + "glpi_agent_ollama_node_inflight", "glpi_agent_ollama_node_requests_total", + "glpi_agent_ollama_node_failures_total", "glpi_agent_ollama_node_average_duration_ms", + } { + if got := strings.Count(body, "# TYPE "+metric+" "); got != 1 { + t.Fatalf("TYPE for %s emitted %d times:\n%s", metric, got, body) + } + } +} diff --git a/internal/web/templates/dashboard.html b/internal/web/templates/dashboard.html index e96f89e..6f023d2 100644 --- a/internal/web/templates/dashboard.html +++ b/internal/web/templates/dashboard.html @@ -117,11 +117,11 @@ function progress(label,value){const c=scoreClass(value);return `
${text}
`} -function renderStatusChrome(){const g=!!statusData.glpi_ok,o=!!statusData.ollama_ok,k=!!statusData.knowledge_ready,ks=statusData.knowledge_init_state||'waiting';$('#glpiChip').innerHTML=`GLPI ${g?'OK':'Fehler'}`;$('#ollamaChip').innerHTML=`Ollama ${o?'OK':'Fehler'}`;$('#knowledgeChip').innerHTML=`Knowledge ${k?'bereit':ks==='error'?'Fehler':'lädt'}`;$('#sideMode').innerHTML=`${statusData.dry_run?badge('DRY RUN','warn'):badge('LIVE','good')} ${statusData.auto_reply?badge('Auto-Reply','good'):badge('Auto-Reply aus','warn')} ${statusData.priority_enabled?badge(statusData.auto_priority?'Auto-Priorität':'Priorität Shadow',statusData.auto_priority?'good':'info'):badge('Priorität aus','warn')} ${statusData.escalation_enabled?badge(statusData.auto_escalation?'Auto-Eskalation':'Eskalation Shadow',statusData.auto_escalation?'good':'info'):badge('Eskalation aus','warn')}
${esc(statusData.ollama_model||'–')} · ${esc(statusData.communication_language||'–')} / ${esc(statusData.communication_style||'–')}
`;$('#lastRefresh').textContent=new Date().toLocaleTimeString('de-DE')} +function renderStatusChrome(){const g=!!statusData.glpi_ok,o=!!statusData.ollama_ok,k=!!statusData.knowledge_ready,ks=statusData.knowledge_init_state||'waiting',total=Number(statusData.ollama_node_count||0),healthy=Number(statusData.ollama_healthy_nodes||0);$('#glpiChip').innerHTML=`GLPI ${g?'OK':'Fehler'}`;$('#ollamaChip').innerHTML=`Ollama ${total?`${healthy}/${total}`:(o?'OK':'Fehler')}`;$('#knowledgeChip').innerHTML=`Knowledge ${k?'bereit':ks==='error'?'Fehler':'lädt'}`;$('#sideMode').innerHTML=`${statusData.dry_run?badge('DRY RUN','warn'):badge('LIVE','good')} ${statusData.auto_reply?badge('Auto-Reply','good'):badge('Auto-Reply aus','warn')} ${statusData.priority_enabled?badge(statusData.auto_priority?'Auto-Priorität':'Priorität Shadow',statusData.auto_priority?'good':'info'):badge('Priorität aus','warn')} ${statusData.escalation_enabled?badge(statusData.auto_escalation?'Auto-Eskalation':'Eskalation Shadow',statusData.auto_escalation?'good':'info'):badge('Eskalation aus','warn')}
${esc(statusData.ollama_model||'–')} · Pool ${esc(statusData.ollama_routing_mode||'–')} · ${esc(statusData.communication_language||'–')} / ${esc(statusData.communication_style||'–')}
`;$('#lastRefresh').textContent=new Date().toLocaleTimeString('de-DE')} function renderOverview(){const stats=[['Verarbeitet',fmtNum(statusData.processed),'seit Start'],['Fehler',fmtNum(statusData.errors),statusData.errors?'prüfen':'keine'],['Queue',fmtNum(statusData.queue_depth),`von ${fmtNum(statusData.queue_size)}`],['Knowledge',fmtNum(statusData.knowledge_docs),`${fmtNum(statusData.glpi_kb_documents)} aus GLPI`],['KI-Triage',`${fmtNum(statusData.priority_recommendations)} / ${fmtNum(statusData.escalation_runs)}`,'Priorität / Eskalationsläufe'],['Auto-Aktionen',`${fmtNum(statusData.category_changes)} / ${fmtNum(statusData.priority_changes)} / ${fmtNum(statusData.replies)} / ${fmtNum(statusData.escalations)}`,'Kat. / Prio / Reply / Esk.']];$('#overviewStats').innerHTML=stats.map(x=>`
${esc(x[0])}
${esc(x[1])}
${esc(x[2])}
`).join(''); const recent=runsData.slice(0,6);$('#recentRuns').innerHTML=recent.length?recent.map(x=>{const score=x.knowledge_score?` · KB ${pct(x.knowledge_score)}`:'';return `
#${esc(x.ticket_id)} ${esc(x.ticket_name||'')}
${esc(fmtDate(x.finished_at))}${esc(score)}
${outcomeBadge(x.outcome)}
`}).join(''):'
Noch keine Verarbeitungen.
'; - 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. Verfügbare Aktionen: ${esc((statusData.escalation_allowed_actions||[]).join(', ')||'keine')}.`));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=>`
${esc(x[0])}
${esc(x[2])}
${x[1]?badge('OK','good'):badge('Fehler','bad')}
`).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. Verfügbare Aktionen: ${esc((statusData.escalation_allowed_actions||[]).join(', ')||'keine')}.`));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.'));const ollamaNodes=statusData.ollama_nodes||[];const badNodes=ollamaNodes.filter(n=>!n.healthy||!n.compatible);if(badNodes.length)notices.push(configNotice(`${badNodes.length} Ollama-Node(s) nicht nutzbar. ${badNodes.map(n=>`${esc(n.name)}: ${esc(n.last_error||(!n.compatible?'Modelldigest abweichend':'nicht erreichbar'))}`).join(' · ')}`,'bad'));else if(ollamaNodes.length>1)notices.push(configNotice(`Ollama-Pool aktiv. ${fmtNum(statusData.ollama_healthy_nodes)} von ${fmtNum(statusData.ollama_node_count)} Nodes kompatibel · Routing ${esc(statusData.ollama_routing_mode)} · Failover ${statusData.ollama_failover_enabled?'aktiv':'aus'}.`,'good'));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 nodeHealth=(statusData.ollama_nodes||[]).map(n=>[`Ollama · ${n.name}`,!!(n.healthy&&n.compatible),`${n.inflight||0}/${n.max_inflight||0} aktiv · Ø ${Math.round(Number(n.average_duration_ms||0))} ms${n.last_error?` · ${n.last_error}`:''}`]);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||''],...nodeHealth,['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=>`
${esc(x[0])}
${esc(x[2])}
${x[1]?badge('OK','good'):badge('Fehler','bad')}
`).join(''); $('#scoringOverview').innerHTML=`${progress('Retrieval: Semantik',statusData.knowledge_weight_semantic)}${progress('Retrieval: Titel',statusData.knowledge_weight_title)}${progress('Retrieval: Lexikalisch',statusData.knowledge_weight_lexical)}${progress('Retrieval: Keywords',statusData.knowledge_weight_keywords)}${progress('Retrieval: Kategorie/Lernen',statusData.knowledge_weight_category)}
Retrieval-Floor${esc(pct(statusData.knowledge_retrieval_floor))}
Finaler Evidenz-Schwellwert${esc(pct(statusData.knowledge_min_score))}
Evidenz: Retrieval / KI / Kategorie${esc(pct(statusData.knowledge_evidence_weight_retrieval))} / ${esc(pct(statusData.knowledge_evidence_weight_ai))} / ${esc(pct(statusData.knowledge_evidence_weight_category))}
Kategorie-Confidence${esc(pct(statusData.category_confidence))}
Reply-Confidence${esc(pct(statusData.reply_confidence))}
`} function categoryMini(x){if(!x.ai_recommended_category_id)return `
${badge('Keine Empfehlung','warn')}
KI-Sicherheit ${pct(x.ai_category_confidence)}
`;const p=policyLabel(x.category_decision);return `
${esc(x.ai_recommended_category_name||`#${x.ai_recommended_category_id}`)} ${esc(pct(x.ai_category_confidence))}
${badge(p[0],p[1])}
Schwellwert ${esc(pct(x.category_threshold))}
`} function replyMini(x){const p=policyLabel(x.reply_decision);return `
${x.ai_reply_recommended?'KI: Antwort':'KI: keine Antwort'} ${esc(pct(x.ai_reply_confidence))}
${badge(p[0],p[1])}${x.knowledge_top_id?`
${esc(x.ai_knowledge_id||x.knowledge_top_id)} · Retrieval ${esc(pct(x.knowledge_score))}${x.knowledge_evidence_score?` · Evidenz ${esc(pct(x.knowledge_evidence_score))} / ${esc(pct(x.knowledge_threshold))}`:''}
`:'
Keine Knowledge-Treffer
'}
`} @@ -145,7 +145,7 @@ function renderKB(){const st=kbStatsData();$('#kbStats').innerHTML=st.map(x=>`!q||[x.ticket_id,x.subject,x.text,x.category_name,x.category_id].join(' ').toLowerCase().includes(q));const corrections=learningRows.filter(x=>x.correction).length;$('#learningStats').innerHTML=[['Gesamt',learningRows.length,'bestätigte Beispiele'],['Korrekturen',corrections,'KI lag anders'],['Bestätigungen',learningRows.length-corrections,'KI wurde bestätigt']].map(x=>`
${esc(x[0])}
${fmtNum(x[1])}
${esc(x[2])}
`).join('');$('#learningTable').innerHTML=rows.length?rows.map(x=>`
#${esc(x.ticket_id)} ${esc(x.subject)}
${esc((x.text||'').slice(0,220))}
${esc(x.category_name)} (#${esc(x.category_id)})${x.ai_recommended_category_id?`
KI: #${esc(x.ai_recommended_category_id)} · ${esc(pct(x.ai_confidence))}
`:''}${x.correction?badge('Korrektur','warn'):badge('Bestätigung','good')}${esc(fmtDate(x.created_at))}`).join(''):'Keine Lernbeispiele.'} function configCard(title,subtitle,rows){return `
${esc(title)}
${esc(subtitle)}
${rows.map(([k,v])=>`
${esc(k)}
${v}
`).join('')}
`} function val(v){if(typeof v==='boolean')return v?badge('aktiv','good'):badge('aus','warn');if(Array.isArray(v))return esc(v.length?v.join(', '):'–');return esc(v??'–')} -function renderConfig(){const s=statusData;const groups=[configCard('Agent & GLPI','Polling, Worker und Schreibmodus',[['Dry Run',val(s.dry_run)],['Auto-Kategorie',val(s.auto_category)],['Auto-Reply',val(s.auto_reply)],['Worker',val(s.workers)],['Queue-Größe',val(s.queue_size)],['API-Version',val(s.glpi_api_version)],['Poll-Intervall',val(s.glpi_poll_interval)],['Poll-Limit',val(s.glpi_poll_limit)],['Letzter Poll',val(fmtDate(s.last_poll))],['Poll: abgerufen / bekannt / neu / Queue',val(`${s.poll_last_fetched||0} / ${s.poll_last_seen||0} / ${s.poll_last_unseen||0} / ${s.poll_last_enqueued||0}`)],['Poll-Fehler',val(s.poll_last_error||'–')],['Bekannte Ticketversionen',val(s.processed_version_count||0)],['Ticket-Filter gesetzt',val(s.glpi_ticket_filter_configured)],['Erlaubte Status',val(s.glpi_allowed_status_ids)],['GLPI-Timeout',val(s.glpi_timeout)]]),configCard('Priorität & Eskalation','Separate KI-Läufe mit deterministischen Schreibregeln',[['Prioritätsanalyse',val(s.priority_enabled)],['Auto-Priorität',val(s.auto_priority)],['Prioritäts-Confidence',`${pct(s.priority_confidence)}`],['Prioritäts-Timeout',val(s.priority_analysis_timeout)],['Max. Erhöhung/Lauf',val(s.priority_max_increase)],['Erlaubte Prioritätsgründe',val(s.priority_allowed_reason_codes)],['Eskalationsanalyse',val(s.escalation_enabled)],['Auto-Eskalation',val(s.auto_escalation)],['Scan-Intervall',val(s.escalation_scan_interval)],['Mindestalter',val(s.escalation_min_age)],['Mindest-Inaktivität',val(s.escalation_min_inactivity)],['KI-Zeitbudget',val(s.escalation_analysis_timeout)],['Eskalations-Confidence',`${pct(s.escalation_confidence)}`],['Max. Eskalationsstufe',val(s.escalation_max_level)],['SLA-Risikofenster',val(s.escalation_sla_risk_window)],['Service Owner ab Stufe',val(s.escalation_service_owner_min_level)],['Management-Review ab Stufe',val(s.escalation_manager_review_min_level)],['Major-Incident-Relevanz',pct(s.escalation_major_incident_min_relevance)],['Erlaubte Eskalationsgründe',val(s.escalation_allowed_reason_codes)],['Erlaubte Eskalationsaktionen',val(s.escalation_allowed_actions)],['Second-Level-Gruppe',val(s.escalation_second_level_group_id||'–')],['Security-Gruppe',val(s.escalation_security_group_id||'–')],['Service Owner Gruppe / Benutzer',val(`${s.escalation_service_owner_group_id||'–'} / ${s.escalation_service_owner_user_id||'–'}`)],['Management Gruppe / Benutzer',val(`${s.escalation_manager_review_group_id||'–'} / ${s.escalation_manager_review_user_id||'–'}`)],['Private Eskalationsnotizen',val(s.escalation_add_private_followup)],['Webhook konfiguriert',val(s.escalation_webhook_configured)],['Webhook-Timeout',val(s.escalation_webhook_timeout)],['Unsicheres Webhook-HTTP',val(s.escalation_webhook_allow_insecure_http)],['GLPI Gruppen-/Benutzerfeld',val(`${s.glpi_escalation_group_patch_field||'–'} / ${s.glpi_escalation_user_patch_field||'–'}`)],['Major-Incident-Linkadapter',val(s.glpi_escalation_itil_link_configured)],['GLPI-Eskalationsfilter gesetzt',val(s.glpi_escalation_filter_configured)],['Kandidatenlimit',val(s.glpi_escalation_limit)]]),configCard('Ollama','Modelle und Inferenzbudget',[['Chat-Modell',val(s.ollama_model)],['Embedding-Modell',val(s.ollama_embedding_model)],['Embedding-Profil',val(s.knowledge_embedding_profile)],['Timeout',val(s.ollama_timeout)],['Num Predict',val(s.ollama_num_predict)],['Keep Alive',val(s.ollama_keep_alive)],['Thinking',val(s.ollama_think)],['Max. parallel',val(s.ollama_max_concurrent)],['JSON-Retries',val(s.ollama_json_retries)]]),configCard('Knowledge / RAG','Retrieval, Chunking und Ranking',[['RAG',val(s.rag_enabled)],['Index-Modus',val(s.knowledge_index_mode)],['Snapshot geladen',val(s.knowledge_snapshot_loaded)],['Snapshot gespeichert',val(fmtDate(s.knowledge_snapshot_saved_at))],['Letzter Delta-Scan',val(fmtDate(s.knowledge_last_scan_at))],['Letzter Scanfehler',val(s.knowledge_last_scan_error||'–')],['Geänderte Dateien',val(s.knowledge_changed_files)],['Gelöschte Dateien',val(s.knowledge_deleted_files)],['Wiederverwendete Vektoren',val(s.knowledge_reused_files)],['Embedding-Batch',val(s.knowledge_embed_batch_size)],['Scan-Intervall',val(s.knowledge_index_scan_interval)],['Knowledge bereit',val(s.knowledge_ready)],['Startup-Status',val(s.knowledge_init_state)],['Startup-Phase',val(s.knowledge_init_phase)],['Dateien verarbeitet',val(`${s.knowledge_init_processed_files||0} / ${s.knowledge_init_total_files||0}`)],['Dokumente geladen',val(s.knowledge_init_loaded_docs)],['Dokumente indexiert',val(s.knowledge_init_indexed_docs)],['Embedding-Cache-Treffer',val(s.knowledge_init_cache_hits)],['Offene Embeddings',val(s.knowledge_init_pending_embeddings)],['Startup-Fehler',val(s.knowledge_init_error||'–')],['Max. Kandidaten an KI',val(s.knowledge_top_k)],['Audit Top K',val(s.knowledge_audit_top_k)],['Max. Abstand zum Top-Treffer',pct(s.knowledge_candidate_max_gap)],['Finaler Evidenz-Schwellwert',`${pct(s.knowledge_min_score)}`],['Retrieval-Floor',`${pct(s.knowledge_retrieval_floor)}`],['Evidenzgewicht Retrieval',pct(s.knowledge_evidence_weight_retrieval)],['Evidenzgewicht KI',pct(s.knowledge_evidence_weight_ai)],['Evidenzgewicht Kategorie',pct(s.knowledge_evidence_weight_category)],['Retrieval: Semantik',pct(s.knowledge_weight_semantic)],['Retrieval: Titel',pct(s.knowledge_weight_title)],['Retrieval: Lexikalisch',pct(s.knowledge_weight_lexical)],['Retrieval: Keywords',pct(s.knowledge_weight_keywords)],['Retrieval: Kategorie/Lernen',pct(s.knowledge_weight_category)],['Chunk-Wörter',val(s.knowledge_chunk_words)],['Overlap-Wörter',val(s.knowledge_chunk_overlap_words)],['Max. KB-Chunks',val(s.knowledge_max_chunks_per_doc)],['Max. Ticket-Chunks',val(s.knowledge_max_query_chunks)],['Antwort-/Retrieval-Quellen',val(s.knowledge_allowed_sources)],['Kategorisierungsquellen',val(s.knowledge_category_sources)],['Auto-Reply-Quellen',val(s.knowledge_auto_reply_sources)],['Fremdkategorie-Modus',val(s.knowledge_category_mode)],['Kategorie-Mapping',val(s.knowledge_category_map_configured?'konfiguriert':'–')],['Ignore-Globs',val(s.knowledge_ignore_globs)],['Ignorierte Dateien',val(s.knowledge_ignored_files)],['KBs mit ungemappten Kategorien',val(s.knowledge_unmapped_category_files)],['Ungemappte Kategorien',val(s.knowledge_unmapped_categories)]]),configCard('GLPI Knowledge Base','Synchronisation der GLPI-Wissensdatenbank',[['Aktiv',val(s.glpi_kb_enabled)],['Sync OK',val(s.glpi_kb_ok)],['Dokumente',val(s.glpi_kb_documents)],['Letzter Sync',val(fmtDate(s.glpi_kb_last_sync))],['Intervall',val(s.glpi_kb_sync_interval)],['Pfad',val(s.glpi_kb_path)],['Filter gesetzt',val(s.glpi_kb_filter_configured)],['Limit',val(s.glpi_kb_limit)],['Auto-Reply',val(s.glpi_kb_auto_reply)],['Auto-Reply-Kategorien',val(s.glpi_kb_auto_reply_category_ids)],['Letzter Fehler',val(s.glpi_kb_last_error||'–')]]),configCard('Policy & Kommunikation','Entscheidungsschwellen und Sprache',[['Kategorie-Confidence',`${pct(s.category_confidence)}`],['Reply-Confidence',`${pct(s.reply_confidence)}`],['Sprache',val(s.communication_language)],['Stil',val(s.communication_style)],['KI-Kennzeichnung',val(s.ai_content_label_enabled)],['KB-Webeditor',val(s.knowledge_edit_enabled)],['Lernen',val(s.learning_enabled)],['Max. Lernbeispiele',val(s.learning_max_examples)],['Beispiele/Kategorie',val(s.learning_examples_per_category)]]),configCard('Kontextquellen','Störungen, Changes, Incidents und Geräte',[['Kontext aktiv',val(s.context_enabled)],['Timeout',val(s.context_timeout)],['Relevanz-Minimum',pct(s.context_relevance_min_score)],['Fail-closed',val(s.context_fail_closed)],['Incident blockiert normalen Reply',val(s.context_incident_block)],['Vordefinierte Statusantwort',val(s.context_status_reply_enabled)],['Status: Relevanz-Minimum',pct(s.context_status_reply_min_relevance)],['Status: KI-Minimum',pct(s.context_status_reply_min_ai_confidence)],['Status: Final-Minimum',pct(s.context_status_reply_min_final_score)],['Störungstext konfiguriert',val(s.context_incident_reply_text_configured)],['Wartungstext konfiguriert',val(s.context_maintenance_reply_text_configured)],['Change Calendar',val(s.change_calendar_enabled)],['Lookback',val(s.change_lookback)],['Lookahead',val(s.change_lookahead)],['Major Incidents',val(s.major_incidents_enabled)],['Benutzer-Geräte',val(s.user_device_context_enabled)],['Uptime Kuma',val(s.uptime_kuma_enabled)],['Uptime-Modus',val(s.uptime_kuma_mode)]] )];$('#configGroups').innerHTML=groups.join('')} +function renderConfig(){const s=statusData;const groups=[configCard('Agent & GLPI','Polling, Worker und Schreibmodus',[['Dry Run',val(s.dry_run)],['Auto-Kategorie',val(s.auto_category)],['Auto-Reply',val(s.auto_reply)],['Worker',val(s.workers)],['Queue-Größe',val(s.queue_size)],['API-Version',val(s.glpi_api_version)],['Poll-Intervall',val(s.glpi_poll_interval)],['Poll-Limit',val(s.glpi_poll_limit)],['Letzter Poll',val(fmtDate(s.last_poll))],['Poll: abgerufen / bekannt / neu / Queue',val(`${s.poll_last_fetched||0} / ${s.poll_last_seen||0} / ${s.poll_last_unseen||0} / ${s.poll_last_enqueued||0}`)],['Poll-Fehler',val(s.poll_last_error||'–')],['Bekannte Ticketversionen',val(s.processed_version_count||0)],['Ticket-Filter gesetzt',val(s.glpi_ticket_filter_configured)],['Erlaubte Status',val(s.glpi_allowed_status_ids)],['GLPI-Timeout',val(s.glpi_timeout)]]),configCard('Priorität & Eskalation','Separate KI-Läufe mit deterministischen Schreibregeln',[['Prioritätsanalyse',val(s.priority_enabled)],['Auto-Priorität',val(s.auto_priority)],['Prioritäts-Confidence',`${pct(s.priority_confidence)}`],['Prioritäts-Timeout',val(s.priority_analysis_timeout)],['Max. Erhöhung/Lauf',val(s.priority_max_increase)],['Erlaubte Prioritätsgründe',val(s.priority_allowed_reason_codes)],['Eskalationsanalyse',val(s.escalation_enabled)],['Auto-Eskalation',val(s.auto_escalation)],['Scan-Intervall',val(s.escalation_scan_interval)],['Mindestalter',val(s.escalation_min_age)],['Mindest-Inaktivität',val(s.escalation_min_inactivity)],['KI-Zeitbudget',val(s.escalation_analysis_timeout)],['Eskalations-Confidence',`${pct(s.escalation_confidence)}`],['Max. Eskalationsstufe',val(s.escalation_max_level)],['SLA-Risikofenster',val(s.escalation_sla_risk_window)],['Service Owner ab Stufe',val(s.escalation_service_owner_min_level)],['Management-Review ab Stufe',val(s.escalation_manager_review_min_level)],['Major-Incident-Relevanz',pct(s.escalation_major_incident_min_relevance)],['Erlaubte Eskalationsgründe',val(s.escalation_allowed_reason_codes)],['Erlaubte Eskalationsaktionen',val(s.escalation_allowed_actions)],['Second-Level-Gruppe',val(s.escalation_second_level_group_id||'–')],['Security-Gruppe',val(s.escalation_security_group_id||'–')],['Service Owner Gruppe / Benutzer',val(`${s.escalation_service_owner_group_id||'–'} / ${s.escalation_service_owner_user_id||'–'}`)],['Management Gruppe / Benutzer',val(`${s.escalation_manager_review_group_id||'–'} / ${s.escalation_manager_review_user_id||'–'}`)],['Private Eskalationsnotizen',val(s.escalation_add_private_followup)],['Webhook konfiguriert',val(s.escalation_webhook_configured)],['Webhook-Timeout',val(s.escalation_webhook_timeout)],['Unsicheres Webhook-HTTP',val(s.escalation_webhook_allow_insecure_http)],['GLPI Gruppen-/Benutzerfeld',val(`${s.glpi_escalation_group_patch_field||'–'} / ${s.glpi_escalation_user_patch_field||'–'}`)],['Major-Incident-Linkadapter',val(s.glpi_escalation_itil_link_configured)],['GLPI-Eskalationsfilter gesetzt',val(s.glpi_escalation_filter_configured)],['Kandidatenlimit',val(s.glpi_escalation_limit)]]),configCard('Ollama-Pool','Modelle, Routing, Health und Failover',[['Chat-Modell',val(s.ollama_model)],['Embedding-Modell',val(s.ollama_embedding_model)],['Embedding-Profil',val(s.knowledge_embedding_profile)],['Nodes gesund / gesamt',val(`${s.ollama_healthy_nodes||0} / ${s.ollama_node_count||0}`)],['Routing',val(s.ollama_routing_mode)],['Max. parallel je Node',val(s.ollama_node_max_inflight)],['Health-Intervall',val(s.ollama_node_health_interval)],['Fehler-Cooldown',val(s.ollama_node_failure_cooldown)],['Request-Timeout je Node',val(s.ollama_node_request_timeout)],['Failover',val(s.ollama_failover_enabled)],['Max. Versuche',val(s.ollama_failover_attempts)],['Gleicher Modelldigest Pflicht',val(s.ollama_require_same_model_digest)],['Embedding-Modell je Node Pflicht',val(s.ollama_require_embedding_model)],['Gesamt-Timeout',val(s.ollama_timeout)],['Num Predict',val(s.ollama_num_predict)],['Keep Alive',val(s.ollama_keep_alive)],['Thinking',val(s.ollama_think)],['JSON-Retries',val(s.ollama_json_retries)],['Node-Details',val((s.ollama_nodes||[]).map(n=>`${n.name}: ${n.healthy&&n.compatible?'OK':'Fehler'} · ${n.requests||0} Requests · ${n.failures||0} Fehler`).join(' | ')||'–')]]),configCard('Knowledge / RAG','Retrieval, Chunking und Ranking',[['RAG',val(s.rag_enabled)],['Index-Modus',val(s.knowledge_index_mode)],['Snapshot geladen',val(s.knowledge_snapshot_loaded)],['Snapshot gespeichert',val(fmtDate(s.knowledge_snapshot_saved_at))],['Letzter Delta-Scan',val(fmtDate(s.knowledge_last_scan_at))],['Letzter Scanfehler',val(s.knowledge_last_scan_error||'–')],['Geänderte Dateien',val(s.knowledge_changed_files)],['Gelöschte Dateien',val(s.knowledge_deleted_files)],['Wiederverwendete Vektoren',val(s.knowledge_reused_files)],['Embedding-Batch',val(s.knowledge_embed_batch_size)],['Scan-Intervall',val(s.knowledge_index_scan_interval)],['Knowledge bereit',val(s.knowledge_ready)],['Startup-Status',val(s.knowledge_init_state)],['Startup-Phase',val(s.knowledge_init_phase)],['Dateien verarbeitet',val(`${s.knowledge_init_processed_files||0} / ${s.knowledge_init_total_files||0}`)],['Dokumente geladen',val(s.knowledge_init_loaded_docs)],['Dokumente indexiert',val(s.knowledge_init_indexed_docs)],['Embedding-Cache-Treffer',val(s.knowledge_init_cache_hits)],['Offene Embeddings',val(s.knowledge_init_pending_embeddings)],['Startup-Fehler',val(s.knowledge_init_error||'–')],['Max. Kandidaten an KI',val(s.knowledge_top_k)],['Audit Top K',val(s.knowledge_audit_top_k)],['Max. Abstand zum Top-Treffer',pct(s.knowledge_candidate_max_gap)],['Finaler Evidenz-Schwellwert',`${pct(s.knowledge_min_score)}`],['Retrieval-Floor',`${pct(s.knowledge_retrieval_floor)}`],['Evidenzgewicht Retrieval',pct(s.knowledge_evidence_weight_retrieval)],['Evidenzgewicht KI',pct(s.knowledge_evidence_weight_ai)],['Evidenzgewicht Kategorie',pct(s.knowledge_evidence_weight_category)],['Retrieval: Semantik',pct(s.knowledge_weight_semantic)],['Retrieval: Titel',pct(s.knowledge_weight_title)],['Retrieval: Lexikalisch',pct(s.knowledge_weight_lexical)],['Retrieval: Keywords',pct(s.knowledge_weight_keywords)],['Retrieval: Kategorie/Lernen',pct(s.knowledge_weight_category)],['Chunk-Wörter',val(s.knowledge_chunk_words)],['Overlap-Wörter',val(s.knowledge_chunk_overlap_words)],['Max. KB-Chunks',val(s.knowledge_max_chunks_per_doc)],['Max. Ticket-Chunks',val(s.knowledge_max_query_chunks)],['Antwort-/Retrieval-Quellen',val(s.knowledge_allowed_sources)],['Kategorisierungsquellen',val(s.knowledge_category_sources)],['Auto-Reply-Quellen',val(s.knowledge_auto_reply_sources)],['Fremdkategorie-Modus',val(s.knowledge_category_mode)],['Kategorie-Mapping',val(s.knowledge_category_map_configured?'konfiguriert':'–')],['Ignore-Globs',val(s.knowledge_ignore_globs)],['Ignorierte Dateien',val(s.knowledge_ignored_files)],['KBs mit ungemappten Kategorien',val(s.knowledge_unmapped_category_files)],['Ungemappte Kategorien',val(s.knowledge_unmapped_categories)]]),configCard('GLPI Knowledge Base','Synchronisation der GLPI-Wissensdatenbank',[['Aktiv',val(s.glpi_kb_enabled)],['Sync OK',val(s.glpi_kb_ok)],['Dokumente',val(s.glpi_kb_documents)],['Letzter Sync',val(fmtDate(s.glpi_kb_last_sync))],['Intervall',val(s.glpi_kb_sync_interval)],['Pfad',val(s.glpi_kb_path)],['Filter gesetzt',val(s.glpi_kb_filter_configured)],['Limit',val(s.glpi_kb_limit)],['Auto-Reply',val(s.glpi_kb_auto_reply)],['Auto-Reply-Kategorien',val(s.glpi_kb_auto_reply_category_ids)],['Letzter Fehler',val(s.glpi_kb_last_error||'–')]]),configCard('Policy & Kommunikation','Entscheidungsschwellen und Sprache',[['Kategorie-Confidence',`${pct(s.category_confidence)}`],['Reply-Confidence',`${pct(s.reply_confidence)}`],['Sprache',val(s.communication_language)],['Stil',val(s.communication_style)],['KI-Kennzeichnung',val(s.ai_content_label_enabled)],['KB-Webeditor',val(s.knowledge_edit_enabled)],['Lernen',val(s.learning_enabled)],['Max. Lernbeispiele',val(s.learning_max_examples)],['Beispiele/Kategorie',val(s.learning_examples_per_category)]]),configCard('Kontextquellen','Störungen, Changes, Incidents und Geräte',[['Kontext aktiv',val(s.context_enabled)],['Timeout',val(s.context_timeout)],['Relevanz-Minimum',pct(s.context_relevance_min_score)],['Fail-closed',val(s.context_fail_closed)],['Incident blockiert normalen Reply',val(s.context_incident_block)],['Vordefinierte Statusantwort',val(s.context_status_reply_enabled)],['Status: Relevanz-Minimum',pct(s.context_status_reply_min_relevance)],['Status: KI-Minimum',pct(s.context_status_reply_min_ai_confidence)],['Status: Final-Minimum',pct(s.context_status_reply_min_final_score)],['Störungstext konfiguriert',val(s.context_incident_reply_text_configured)],['Wartungstext konfiguriert',val(s.context_maintenance_reply_text_configured)],['Change Calendar',val(s.change_calendar_enabled)],['Lookback',val(s.change_lookback)],['Lookahead',val(s.change_lookahead)],['Major Incidents',val(s.major_incidents_enabled)],['Benutzer-Geräte',val(s.user_device_context_enabled)],['Uptime Kuma',val(s.uptime_kuma_enabled)],['Uptime-Modus',val(s.uptime_kuma_mode)]] )];$('#configGroups').innerHTML=groups.join('')} function renderSourceOptions(){const filterOld=$('#kbSourceFilter').value,sourceOld=$('#kbSource').value;const sources=[...new Set(kbDocs.map(x=>x.source).filter(Boolean))].sort();$('#kbSourceFilter').innerHTML=''+sources.map(x=>``).join('');if([...$('#kbSourceFilter').options].some(o=>o.value===filterOld))$('#kbSourceFilter').value=filterOld;const allowed=statusData.knowledge_allowed_sources||[];$('#kbSource').innerHTML=allowed.map(x=>``).join('');if([...$('#kbSource').options].some(o=>o.value===sourceOld))$('#kbSource').value=sourceOld;else if([...$('#kbSource').options].some(o=>o.value==='internal-kb'))$('#kbSource').value='internal-kb'} function renderCategoryPicker(filter=''){const q=filter.toLowerCase();$('#kbCategoryList').innerHTML=categories.filter(c=>!q||(c.completename||c.name||'').toLowerCase().includes(q)).map(c=>``).join('')||'
Keine Kategorie gefunden.
'} function clearKbForm(){currentKbId='';kbCategorySelection=new Set();$('#kbForm').reset();$('#kbId').disabled=false;$('#kbId').value='';$('#kbLanguage').value=statusData.communication_language||'de-DE';$('#kbStyle').value=statusData.communication_style||'formal';$('#kbScore').value=Number(statusData.knowledge_min_score||.70).toFixed(2);if([...$('#kbSource').options].some(x=>x.value==='internal-kb'))$('#kbSource').value='internal-kb';$('#kbModalTitle').textContent='Neuen Artikel anlegen';$('#kbModalEyebrow').textContent='Interne Knowledge Base';$('#kbEditState').textContent='Neuer Artikel';$('#kbFormMessage').className='form-message';$('#kbCategorySearch').value='';renderCategoryPicker();updateCounts()} diff --git a/internal/web/templates/diagnostics.html b/internal/web/templates/diagnostics.html index bbb024a..d62a5d6 100644 --- a/internal/web/templates/diagnostics.html +++ b/internal/web/templates/diagnostics.html @@ -59,7 +59,7 @@ function analysisLabel(type){return({category:'Kategorie',priority:'Priorität', function analysisKind(a){return a.outcome==='error'?'bad':a.outcome==='skipped'?'warn':a.action?.executed?'good':'info'} function renderAnalyses(r){const rows=r.analyses||[];$('#analysisRuns').innerHTML=rows.length?rows.map(a=>`
${statusChip(analysisLabel(a.analysis_type),'info')}${statusChip(a.outcome||'–',analysisKind(a))}${a.action?.executed?statusChip('Aktion ausgeführt','good'):a.action?.proposed?statusChip(a.action.dry_run?'Aktion simuliert':'Aktion vorgeschlagen','warn'):''}
${esc(a.analysis_id)}
${esc(a.prompt_version||'ohne Prompt-Version')} · ${Number(a.duration_ms||0).toLocaleString('de-DE')} ms
${esc(a.explanation||a.error||'Keine Begründung gespeichert.')}
`).join(''):'
Historischer Lauf ohne generische AnalysisRun-Datensätze.
';$('#analysisDetail').classList.add('hidden');document.querySelectorAll('[data-analysis]').forEach(x=>x.onclick=()=>showAnalysis(x.dataset.analysis))} function actionStepsHTML(action){const steps=action?.steps||[];if(!steps.length)return '
Keine einzelnen Aktionsschritte.
';return steps.map((x,i)=>`
${x.error?'×':x.executed?'✓':x.dry_run?'◌':'•'}
${esc(x.step||`Schritt ${i+1}`)}${x.target?` · ${esc(x.target)}`:''}
${esc(x.result||'–')}${x.error?` · ${esc(x.error)}`:''}
${x.executed?'ausgeführt':x.dry_run&&x.proposed?'simuliert':x.proposed?'vorgeschlagen':'nicht ausgeführt'}${esc(x.before||'–')} → ${esc(x.after||'–')}
`).join('')} -async function showAnalysis(id){const box=$('#analysisDetail');box.classList.remove('hidden');box.innerHTML='
Lade Analyselauf …
';try{const a=await api(`/api/diagnostics/analysis/${encodeURIComponent(id)}`);const action=a.action||{};box.innerHTML=`
${esc(analysisLabel(a.analysis_type))}

${esc(a.analysis_id)}

Parent ${esc(a.parent_run_id)} · Trigger ${esc(a.trigger)} · ${esc(a.model||'deterministisch')} · ${esc(a.prompt_version||'–')}
${statusChip(a.outcome||'–',analysisKind(a))}${statusChip(`Confidence ${pct(a.confidence)}`,'info')}${action.type?statusChip(action.type,action.executed?'good':action.proposed?'warn':'info'):''}
Begründung / Grundcodes
${esc(a.explanation||'–')}\n\n${esc((a.reason_codes||[]).join(', ')||'keine')}
Aktionsplan
${actionStepsHTML(action)}
${esc(JSON.stringify(action,null,2))}
Policy-Prüfungen
${(a.checks||[]).length?(a.checks||[]).map(ruleHTML).join(''):'
Keine Regelchecks.
'}
Historischer Input-Snapshot · SHA-256 ${esc(a.input_hash||'–')}
${esc(JSON.stringify(a.input_snapshot??{},null,2))}
Strukturierte Entscheidung
${esc(JSON.stringify(a.decision??{},null,2))}
`}catch(e){box.innerHTML=`
${esc(e.message)}
`}} +async function showAnalysis(id){const box=$('#analysisDetail');box.classList.remove('hidden');box.innerHTML='
Lade Analyselauf …
';try{const a=await api(`/api/diagnostics/analysis/${encodeURIComponent(id)}`);const action=a.action||{};box.innerHTML=`
${esc(analysisLabel(a.analysis_type))}

${esc(a.analysis_id)}

Parent ${esc(a.parent_run_id)} · Trigger ${esc(a.trigger)} · ${esc(a.model||'deterministisch')} · ${esc(a.prompt_version||'–')}
${statusChip(a.outcome||'–',analysisKind(a))}${statusChip(`Confidence ${pct(a.confidence)}`,'info')}${action.type?statusChip(action.type,action.executed?'good':action.proposed?'warn':'info'):''}
Begründung / Grundcodes
${esc(a.explanation||'–')}\n\n${esc((a.reason_codes||[]).join(', ')||'keine')}
Aktionsplan
${actionStepsHTML(action)}
${esc(JSON.stringify(action,null,2))}
Policy-Prüfungen
${(a.checks||[]).length?(a.checks||[]).map(ruleHTML).join(''):'
Keine Regelchecks.
'}
Ollama-Routing und Failover
${esc(JSON.stringify(a.provider??{provider:'keine Provider-Diagnose gespeichert'},null,2))}
Historischer Input-Snapshot · SHA-256 ${esc(a.input_hash||'–')}
${esc(JSON.stringify(a.input_snapshot??{},null,2))}
Strukturierte Entscheidung
${esc(JSON.stringify(a.decision??{},null,2))}
`}catch(e){box.innerHTML=`
${esc(e.message)}
`}} function renderRun(){ const r=current,legacy=!r.category_analysis_executed&&!r.reply_analysis_executed&&!r.category_knowledge_candidates; $('#ticketTitle').textContent=`#${r.ticket_id} ${r.ticket_name||''}`;$('#runMeta').textContent=`Run ${r.run_id} · Trigger ${r.trigger||'legacy'} · ${date(r.finished_at)} · ${r.outcome}${r.caused_by_run_id?` · Ursache ${r.caused_by_run_id}`:''}`;