diff --git a/.env.example b/.env.example index 4f08aed..7a0f3c0 100644 --- a/.env.example +++ b/.env.example @@ -48,7 +48,13 @@ OLLAMA_MAX_CONCURRENT=1 # RAG / Knowledge KNOWLEDGE_DIR=./knowledge RAG_ENABLED=true -KNOWLEDGE_TOP_K=3 +# Maximum number of dynamically selected KB candidates sent to Ollama. +KNOWLEDGE_TOP_K=6 +# Keep more candidates in the audit/dashboard than are sent to Ollama. Must be >= KNOWLEDGE_TOP_K. +KNOWLEDGE_AUDIT_TOP_K=10 +# Only candidates within this absolute score gap of the best retrieval hit are sent to Ollama. +# Example: best=0.78 and gap=0.20 => candidates below 0.58 are excluded (retrieval floor still applies). +KNOWLEDGE_CANDIDATE_MAX_GAP=0.20 # Final evidence threshold after the model has selected a KB candidate. This is not the raw retrieval score. KNOWLEDGE_MIN_SCORE=0.70 # Broad-recall floor for the deterministic retrieval/ranking stage. Candidates below this never reach auto-reply. diff --git a/Dockerfile b/Dockerfile index 1043322..37f1c88 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.26-alpine AS build +FROM golang:1.23-alpine AS build WORKDIR /src COPY go.mod ./ COPY cmd ./cmd @@ -7,7 +7,7 @@ RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/glpi-ai # One-shot helper used by docker compose to prepare the persistent volume for # the distroless non-root runtime user (UID/GID 65532). -FROM golang:1.26-alpine AS data-init +FROM golang:1.23-alpine AS data-init ENTRYPOINT ["sh", "-c", "mkdir -p /app/data && chown -R 65532:65532 /app/data && chmod 0750 /app/data"] FROM gcr.io/distroless/static-debian12:nonroot diff --git a/README.md b/README.md index df77c5e..d7b9c09 100644 --- a/README.md +++ b/README.md @@ -186,24 +186,42 @@ Ein Auto-Reply ist nur erlaubt, wenn `language` und `communication_style` des fr Bei aktiviertem RAG erzeugt Ollama Embeddings über `/api/embed`; der Cache landet in `data/embeddings.json`. Für Ticket und Knowledge wird dasselbe Embedding-Modell verwendet. -### Realistisches Hybrid-Scoring +### Realistisches Hybrid-Scoring und dynamische Kandidatenauswahl -Knowledge-Treffer werden nicht mehr nur über eine einzelne Cosine-Similarity bewertet. Lange Artikel werden in überlappende Abschnitte zerlegt und der beste semantische Abschnitt wird mit Titel-, Keyword- und Kategorie-/Lernsignalen kombiniert. Standardgewichte: +Knowledge-Treffer werden nicht nur über eine einzelne Cosine-Similarity bewertet. Lange Artikel werden in überlappende Abschnitte zerlegt und der beste semantische Abschnitt wird mit Titel-, lexikalischen, Keyword- und Kategorie-/Lernsignalen kombiniert. Empfohlene Standardwerte: ```env KNOWLEDGE_MIN_SCORE=0.70 -KNOWLEDGE_WEIGHT_SEMANTIC=0.50 -KNOWLEDGE_WEIGHT_TITLE=0.25 -KNOWLEDGE_WEIGHT_KEYWORDS=0.15 -KNOWLEDGE_WEIGHT_CATEGORY=0.10 +KNOWLEDGE_RETRIEVAL_FLOOR=0.30 +KNOWLEDGE_WEIGHT_SEMANTIC=0.45 +KNOWLEDGE_WEIGHT_TITLE=0.20 +KNOWLEDGE_WEIGHT_LEXICAL=0.20 +KNOWLEDGE_WEIGHT_KEYWORDS=0.075 +KNOWLEDGE_WEIGHT_CATEGORY=0.075 KNOWLEDGE_CHUNK_WORDS=160 KNOWLEDGE_CHUNK_OVERLAP_WORDS=30 KNOWLEDGE_MAX_CHUNKS_PER_DOC=24 +KNOWLEDGE_MAX_QUERY_CHUNKS=64 + +# Dynamisches Top-K +KNOWLEDGE_TOP_K=6 +KNOWLEDGE_AUDIT_TOP_K=10 +KNOWLEDGE_CANDIDATE_MAX_GAP=0.20 ``` -Der angezeigte Hybrid-Score ist **keine Wahrscheinlichkeit**. Er ist ein nachvollziehbarer Ranking-Score. Die Semantik verwendet die Ähnlichkeit des besten Body-Chunks; der Titel kombiniert Embedding- und exakten/lexikalischen Titelmatch; Keywords werden explizit gegen den Tickettext geprüft. Ist ein Knowledge-Dokument GLPI-ITIL-Kategorien zugeordnet, fließen deren Namen, semantische Hints und menschlich bestätigte Lernbeispiele als Kategorie-Signal ein. Fehlen einem Artikel Keywords oder Kategoriezuordnungen, wird er nicht pauschal abgestraft: Nur vorhandene Komponenten werden in die Gewichtung aufgenommen. +`KNOWLEDGE_TOP_K` ist jetzt **die maximale Anzahl von Kandidaten, die Ollama sehen darf**, nicht die Anzahl, die blind immer übergeben wird. Nach dem Retrieval wird ein dynamischer Cutoff berechnet: -Im Audit-Dashboard werden `Hybrid`, `Semantik`, `Titel`, `Keywords`, `Kategorie/Lernen`, der beste gefundene Abschnitt und der tatsächlich erforderliche KB-Schwellwert getrennt angezeigt. Der effektive Schwellwert bleibt `max(KNOWLEDGE_MIN_SCORE, min_score des Artikels)`. +```text +cutoff = max(KNOWLEDGE_RETRIEVAL_FLOOR, bester_score - KNOWLEDGE_CANDIDATE_MAX_GAP) +``` + +Beispiel: Bei Scores `0.82, 0.79, 0.76, 0.43` und `KNOWLEDGE_CANDIDATE_MAX_GAP=0.20` gehen nur die ersten drei Treffer an Ollama, weil der Cutoff `0.62` beträgt. Bei einem unklareren Fall `0.66, 0.64, 0.63, 0.61, 0.59` dürfen dagegen bis zu fünf Kandidaten in den Modellkontext. Liegt bereits der beste Treffer unter `KNOWLEDGE_RETRIEVAL_FLOOR`, erhält Ollama **keinen** KB-Kandidaten. + +`KNOWLEDGE_AUDIT_TOP_K` ist davon getrennt. Das Dashboard kann z. B. die besten zehn Treffer zur Diagnose zeigen, während höchstens sechs und meist deutlich weniger an Ollama gesendet werden. Jeder Audit-Kandidat wird mit `an KI gesendet` oder `nur Audit` gekennzeichnet. + +Der angezeigte Retrieval-/Hybrid-Score ist **keine Wahrscheinlichkeit**. Er ist ein nachvollziehbarer Ranking-Score. Die Semantik verwendet die Ähnlichkeit des besten Body-Chunks; der Titel kombiniert Embedding- und lexikalischen Titelmatch; Keywords und Kategorie-/Lernsignale dienen als positive Evidenz. Fehlen solche Metadaten, werden sie nicht als Null-Strafe eingerechnet. + +Für die spätere Auto-Reply-Freigabe gilt weiterhin die getrennte Evidenzlogik aus Retrieval, KI-Auswahl und Kategorieübereinstimmung. Der effektive finale Schwellwert ist `max(KNOWLEDGE_MIN_SCORE, min_score des Artikels)`. ## Operativer Kontext: Changes, Major Incidents, Uptime Kuma und Geräte diff --git a/SECURITY.md b/SECURITY.md index 06c0e58..0f1f36f 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -83,3 +83,8 @@ The normalized cache is stored in `DATA_DIR/glpi-kb-cache.json`; embeddings rema ## Rich Text aus GLPI KB Das Feld `answer_html` wird ausschließlich vom read-only GLPI-KB-Synchronisierer befüllt. Web-verwaltete Knowledge-Einträge können dieses Feld nicht setzen. Rich HTML wird weder an Ollama übertragen noch für Embeddings verwendet. Beim Schreiben eines Followups wird das von derselben GLPI-Instanz gelieferte Rich-Text-Markup an GLPI zurückgegeben; GLPI behält seine eigene serverseitige Rich-Text-/HTML-Validierung bei. + + +## 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. diff --git a/UPGRADE.md b/UPGRADE.md index ddd6109..8aa81af 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -115,3 +115,17 @@ Synchronisierte GLPI-KB-Artikel behalten ab dieser Version zwei getrennte Darste Dadurch bleiben bei Auto-Replies unter anderem Überschriften, Fett/Kursiv, Listen, Tabellen und Links erhalten. Das Rich-Text-Markup wird nicht an Ollama gesendet und beeinflusst keine Embeddings. Anrede und Signatur werden HTML-sicher um den KB-Inhalt ergänzt. Es sind keine neuen ENV-Variablen erforderlich. Nach dem Upgrade führt der initiale GLPI-KB-Sync automatisch dazu, dass `answer_html` im lokalen GLPI-KB-Cache ergänzt wird. + + +## Dynamisches Knowledge Top-K + +Für Installationen mit vielen Knowledge-Artikeln wird die Kandidatenauswahl ab dieser Version dynamisch begrenzt. Empfohlene Werte: + +```env +KNOWLEDGE_TOP_K=6 +KNOWLEDGE_AUDIT_TOP_K=10 +KNOWLEDGE_CANDIDATE_MAX_GAP=0.20 +KNOWLEDGE_RETRIEVAL_FLOOR=0.30 +``` + +`KNOWLEDGE_TOP_K` ist die maximale Anzahl von Artikeln im Ollama-Prompt. Artikel werden nur übergeben, wenn sie mindestens den Retrieval-Floor erreichen und nicht mehr als `KNOWLEDGE_CANDIDATE_MAX_GAP` unter dem besten Treffer liegen. `KNOWLEDGE_AUDIT_TOP_K` steuert separat, wie viele Treffer für Dashboard/Audit aufbewahrt werden. Bestehende `.env`-Dateien sollten die drei neuen/angepassten Werte explizit ergänzen. diff --git a/go.mod b/go.mod index 1983a6a..334dce7 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ module github.com/example/glpi-ai-agent -go 1.26 +go 1.23 diff --git a/internal/agent/agent.go b/internal/agent/agent.go index d0926d4..a8034f9 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -186,12 +186,29 @@ func (s *Service) Process(ctx context.Context, id int64) error { } run.CategoryBeforeName = categoryName(categories, t.CategoryID) promptCats := shortlistCategories(t, categories, s.cfg.CategoryPromptLimit) - hits, err := s.knowledge.Search(ctx, t.Name+"\n"+stripHTML(t.Content), s.cfg.KnowledgeTopK, categories) + auditTopK := s.cfg.KnowledgeAuditTopK + if auditTopK <= 0 { + auditTopK = s.cfg.KnowledgeTopK + if auditTopK <= 0 { + auditTopK = 10 + } + } + llmTopK := s.cfg.KnowledgeTopK + if llmTopK <= 0 { + llmTopK = 6 + } + retrievalHits, err := s.knowledge.Search(ctx, t.Name+"\n"+stripHTML(t.Content), auditTopK, categories) if err != nil { run.Reason = "knowledge_search_failed" finish(err) return err } + llmHits, candidateCutoff := selectKnowledgeCandidates(retrievalHits, llmTopK, s.cfg.KnowledgeRetrievalFloor, s.cfg.KnowledgeCandidateMaxGap) + run.KnowledgeLLMCandidates = len(llmHits) + run.KnowledgeCandidateCutoff = candidateCutoff + run.KnowledgeCandidateMaxGap = s.cfg.KnowledgeCandidateMaxGap + run.KnowledgeAuditTopK = auditTopK + llmCandidateIDs := knowledgeHitIDSet(llmHits) contextData := model.ContextSnapshot{} if s.context != nil && s.cfg.ContextEnabled { s.metrics.ContextFetches.Add(1) @@ -206,7 +223,7 @@ func (s *Service) Process(ctx context.Context, id int64) error { s.metrics.ContextErrors.Add(1) } } - decision, err := s.ai.Analyse(ctx, t, promptCats, hits, contextData) + decision, err := s.ai.Analyse(ctx, t, promptCats, llmHits, contextData) if err != nil { run.Reason = "ai_failed" finish(err) @@ -215,9 +232,9 @@ func (s *Service) Process(ctx context.Context, id int64) error { // The classifier provides an independent category recommendation. Knowledge // explicitly mapped to that category receives a deterministic post-retrieval // alignment signal before the final policy gate. - hits = s.knowledge.RerankForCategory(hits, decision.Category.ID) + hits := s.knowledge.RerankForCategory(retrievalHits, decision.Category.ID) if len(hits) > 0 { - run.KnowledgeCandidates = auditKnowledgeCandidates(hits, s.cfg.KnowledgeMinScore, 5) + run.KnowledgeCandidates = auditKnowledgeCandidates(hits, s.cfg.KnowledgeMinScore, auditTopK, llmCandidateIDs) run.KnowledgeTopID = hits[0].Doc.ID run.KnowledgeTopTitle = hits[0].Doc.Title run.KnowledgeScore = hits[0].Score @@ -377,7 +394,7 @@ func (s *Service) Process(ctx context.Context, id int64) error { finish(nil) return nil } -func auditKnowledgeCandidates(hits []model.KnowledgeHit, globalMin float64, limit int) []model.KnowledgeCandidateAudit { +func auditKnowledgeCandidates(hits []model.KnowledgeHit, globalMin float64, limit int, sentToAI map[string]struct{}) []model.KnowledgeCandidateAudit { if limit <= 0 || limit > len(hits) { limit = len(hits) } @@ -387,17 +404,55 @@ func auditKnowledgeCandidates(hits []model.KnowledgeHit, globalMin float64, limi if h.Doc.MinScore > required { required = h.Doc.MinScore } + _, wasSent := sentToAI[h.Doc.ID] out = append(out, model.KnowledgeCandidateAudit{ ID: h.Doc.ID, Title: h.Doc.Title, Source: h.Doc.Source, Score: h.Score, SemanticScore: h.SemanticScore, TitleScore: h.TitleScore, LexicalScore: h.LexicalScore, KeywordScore: h.KeywordScore, CategoryScore: h.CategoryScore, RequiredScore: required, AutoReply: h.Doc.AutoReply, BestChunkExcerpt: h.BestChunkExcerpt, BestQueryExcerpt: h.BestQueryExcerpt, - QueryChunkCount: h.QueryChunkCount, DocumentChunkCount: h.DocumentChunkCount, + QueryChunkCount: h.QueryChunkCount, DocumentChunkCount: h.DocumentChunkCount, SentToAI: wasSent, }) } return out } +func selectKnowledgeCandidates(hits []model.KnowledgeHit, maxCandidates int, retrievalFloor, maxGap float64) ([]model.KnowledgeHit, float64) { + if len(hits) == 0 || maxCandidates <= 0 { + return nil, retrievalFloor + } + best := hits[0].Score + if best < retrievalFloor { + return nil, retrievalFloor + } + cutoff := best - maxGap + if cutoff < retrievalFloor { + cutoff = retrievalFloor + } + capacity := maxCandidates + if len(hits) < capacity { + capacity = len(hits) + } + out := make([]model.KnowledgeHit, 0, capacity) + for _, h := range hits { + if h.Score < cutoff || h.Score < retrievalFloor { + break + } + out = append(out, h) + if len(out) >= maxCandidates { + break + } + } + return out, cutoff +} + +func knowledgeHitIDSet(hits []model.KnowledgeHit) map[string]struct{} { + out := make(map[string]struct{}, len(hits)) + for _, h := range hits { + out[h.Doc.ID] = struct{}{} + } + return out +} + func auditContextDetails(c model.ContextSnapshot, limit int) []model.ContextAuditItem { if limit <= 0 { limit = 5 diff --git a/internal/agent/candidates_test.go b/internal/agent/candidates_test.go new file mode 100644 index 0000000..c8b730d --- /dev/null +++ b/internal/agent/candidates_test.go @@ -0,0 +1,58 @@ +package agent + +import ( + "math" + "testing" + + "github.com/example/glpi-ai-agent/internal/model" +) + +func hit(id string, score float64) model.KnowledgeHit { + return model.KnowledgeHit{Doc: model.KnowledgeDoc{ID: id}, Score: score} +} + +func TestSelectKnowledgeCandidatesDynamicGap(t *testing.T) { + hits := []model.KnowledgeHit{ + hit("a", 0.82), hit("b", 0.79), hit("c", 0.76), hit("d", 0.43), hit("e", 0.39), + } + got, cutoff := selectKnowledgeCandidates(hits, 6, 0.30, 0.20) + if math.Abs(cutoff-0.62) > 1e-9 { + t.Fatalf("cutoff=%v want 0.62", cutoff) + } + if len(got) != 3 { + t.Fatalf("len=%d want 3", len(got)) + } + if got[0].Doc.ID != "a" || got[2].Doc.ID != "c" { + t.Fatalf("unexpected candidates: %#v", got) + } +} + +func TestSelectKnowledgeCandidatesRespectsMaxAndFloor(t *testing.T) { + hits := []model.KnowledgeHit{ + hit("a", 0.66), hit("b", 0.64), hit("c", 0.63), hit("d", 0.61), hit("e", 0.59), hit("f", 0.58), hit("g", 0.57), + } + got, cutoff := selectKnowledgeCandidates(hits, 5, 0.30, 0.20) + if math.Abs(cutoff-0.46) > 1e-9 { + t.Fatalf("cutoff=%v want 0.46", cutoff) + } + if len(got) != 5 { + t.Fatalf("len=%d want max 5", len(got)) + } + + low := []model.KnowledgeHit{hit("x", 0.29), hit("y", 0.28)} + got, cutoff = selectKnowledgeCandidates(low, 6, 0.30, 0.20) + if len(got) != 0 || math.Abs(cutoff-0.30) > 1e-9 { + t.Fatalf("below floor: len=%d cutoff=%v", len(got), cutoff) + } +} + +func TestSelectKnowledgeCandidatesUsesFloorAsCutoff(t *testing.T) { + hits := []model.KnowledgeHit{hit("a", 0.44), hit("b", 0.35), hit("c", 0.31), hit("d", 0.29)} + got, cutoff := selectKnowledgeCandidates(hits, 6, 0.30, 0.20) + if math.Abs(cutoff-0.30) > 1e-9 { + t.Fatalf("cutoff=%v want floor 0.30", cutoff) + } + if len(got) != 3 { + t.Fatalf("len=%d want 3", len(got)) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 9d58d81..16ac7e8 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -48,6 +48,8 @@ type Config struct { KnowledgeDir string RAGEnabled bool KnowledgeTopK int + KnowledgeAuditTopK int + KnowledgeCandidateMaxGap float64 CategoryPromptLimit int KnowledgeAllowedSources []string KnowledgeAutoReplySources []string @@ -156,7 +158,9 @@ func Load() (Config, error) { OllamaJSONRetries: envInt("OLLAMA_JSON_RETRIES", 1), KnowledgeDir: env("KNOWLEDGE_DIR", "./knowledge"), RAGEnabled: envBool("RAG_ENABLED", true), - KnowledgeTopK: envInt("KNOWLEDGE_TOP_K", 3), + KnowledgeTopK: envInt("KNOWLEDGE_TOP_K", 6), + KnowledgeAuditTopK: envInt("KNOWLEDGE_AUDIT_TOP_K", 10), + KnowledgeCandidateMaxGap: envFloat("KNOWLEDGE_CANDIDATE_MAX_GAP", 0.20), CategoryPromptLimit: envInt("CATEGORY_PROMPT_LIMIT", 80), KnowledgeAllowedSources: envStringList("KNOWLEDGE_ALLOWED_SOURCES", "internal-kb"), KnowledgeAutoReplySources: envStringList("KNOWLEDGE_AUTO_REPLY_SOURCES", "internal-kb"), @@ -294,6 +298,17 @@ func (c Config) Validate() error { if c.KnowledgeWebEditEnabled && c.WebAllowAnonymous { return errors.New("KNOWLEDGE_WEB_EDIT_ENABLED requires authenticated dashboard access; WEB_ALLOW_ANONYMOUS must be false") } + if c.KnowledgeTopK != 0 && (c.KnowledgeTopK < 1 || c.KnowledgeTopK > 20) { + return errors.New("KNOWLEDGE_TOP_K must be between 1 and 20") + } + if c.KnowledgeAuditTopK != 0 { + if c.KnowledgeAuditTopK > 50 || (c.KnowledgeTopK > 0 && c.KnowledgeAuditTopK < c.KnowledgeTopK) { + return errors.New("KNOWLEDGE_AUDIT_TOP_K must be >= KNOWLEDGE_TOP_K and <= 50") + } + } + if c.KnowledgeCandidateMaxGap < 0 || c.KnowledgeCandidateMaxGap > 1 { + return errors.New("KNOWLEDGE_CANDIDATE_MAX_GAP must be between 0 and 1") + } weights := []float64{c.KnowledgeSemanticWeight, c.KnowledgeTitleWeight, c.KnowledgeLexicalWeight, c.KnowledgeKeywordWeight, c.KnowledgeCategoryWeight} weightSum := 0.0 for _, w := range weights { diff --git a/internal/model/model.go b/internal/model/model.go index 2270bbe..cc31ca8 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -235,6 +235,7 @@ type KnowledgeCandidateAudit struct { BestQueryExcerpt string `json:"best_query_excerpt,omitempty"` QueryChunkCount int `json:"query_chunk_count,omitempty"` DocumentChunkCount int `json:"document_chunk_count,omitempty"` + SentToAI bool `json:"sent_to_ai,omitempty"` } // ContextAuditItem is a compact snapshot of context that influenced a run. @@ -292,6 +293,10 @@ type RunRecord struct { KnowledgeBestQueryChunk string `json:"knowledge_best_query_chunk,omitempty"` KnowledgeQueryChunks int `json:"knowledge_query_chunks,omitempty"` KnowledgeDocumentChunks int `json:"knowledge_document_chunks,omitempty"` + KnowledgeLLMCandidates int `json:"knowledge_llm_candidates,omitempty"` + KnowledgeCandidateCutoff float64 `json:"knowledge_candidate_cutoff,omitempty"` + KnowledgeCandidateMaxGap float64 `json:"knowledge_candidate_max_gap,omitempty"` + KnowledgeAuditTopK int `json:"knowledge_audit_top_k,omitempty"` ContextChanges int `json:"context_changes,omitempty"` ContextIncidents int `json:"context_incidents,omitempty"` ContextIssues int `json:"context_issues,omitempty"` diff --git a/internal/web/server.go b/internal/web/server.go index 016173b..0ab7851 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -122,7 +122,7 @@ func (s *Server) status(w http.ResponseWriter, r *http.Request) { "uptime_kuma_enabled": s.cfg.UptimeKumaEnabled, "uptime_kuma_mode": s.cfg.UptimeKumaMode, "uptime_kuma_status_pages": s.cfg.UptimeKumaStatusPages, "context_fail_closed": s.cfg.ContextBlockReplyOnError, "context_incident_block": s.cfg.ContextBlockReplyOnIncident, "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, - "rag_enabled": s.cfg.RAGEnabled, "knowledge_top_k": s.cfg.KnowledgeTopK, "category_prompt_limit": s.cfg.CategoryPromptLimit, "knowledge_max_query_chunks": s.cfg.KnowledgeMaxQueryChunks, + "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, "context_timeout": s.cfg.ContextTimeout.String(), "context_relevance_min_score": s.cfg.ContextRelevanceMinScore, "change_lookback": s.cfg.ChangeLookback.String(), "change_lookahead": s.cfg.ChangeLookahead.String(), diff --git a/internal/web/templates/dashboard.html b/internal/web/templates/dashboard.html index 2b74bc5..48a878d 100644 --- a/internal/web/templates/dashboard.html +++ b/internal/web/templates/dashboard.html @@ -126,14 +126,14 @@ function replyMini(x){const p=policyLabel(x.reply_decision);return `
#${esc(x.ticket_id)} ${esc(x.ticket_name||'')}
${esc(fmtDate(x.finished_at))}
${outcomeBadge(x.outcome)}${x.dry_run?'
Dry Run
':''}${categoryMini(x)}${replyMini(x)}
C:${esc(x.context_changes||0)} · I:${esc(x.context_incidents||0)} · U:${esc(x.context_issues||0)} · D:${esc(x.context_devices||0)}
${(x.context_warnings||[]).length?badge(`${x.context_warnings.length} Warnung(en)`,'warn'):''}›`).join(''):'Keine passenden Läufe.'} function contextKindLabel(k){return ({change:'Change',incident:'Major Incident',uptime:'Uptime Kuma',device:'Gerät'})[k]||k} -function renderRunDrawer(x){currentRun=x;$('#runDrawerTitle').textContent=`#${x.ticket_id} ${x.ticket_name||''}`;const catP=policyLabel(x.category_decision),repP=policyLabel(x.reply_decision);const candidates=(x.knowledge_candidates||[]).map((c,i)=>`
${i+1}. ${esc(c.title)}
${esc(c.id)} · ${esc(c.source)} ${c.auto_reply?'· Auto-Reply freigegeben':''}
Retrieval ${esc(pct(c.score))}
${progress('Semantik (raw)',c.semantic_score)}${progress('Titel',c.title_score)}${progress('Lexikalisch',c.lexical_score)}${progress('Keywords',c.keyword_score)}${progress('Kategorie/Lernen',c.category_score)}
Evidenz-Schwelle: ${esc(pct(c.required_score))} · Chunks ${esc(c.query_chunk_count||0)} × ${esc(c.document_chunk_count||0)}
${c.best_query_excerpt?`
Ticket: ${esc(c.best_query_excerpt)}
`:''}${c.best_chunk_excerpt?`
KB: ${esc(c.best_chunk_excerpt)}
`:''}
`).join('')||'
Keine Knowledge-Kandidaten im Audit gespeichert.
'; +function renderRunDrawer(x){currentRun=x;$('#runDrawerTitle').textContent=`#${x.ticket_id} ${x.ticket_name||''}`;const catP=policyLabel(x.category_decision),repP=policyLabel(x.reply_decision);const candidates=(x.knowledge_candidates||[]).map((c,i)=>`
${i+1}. ${esc(c.title)}
${esc(c.id)} · ${esc(c.source)} ${c.auto_reply?'· Auto-Reply freigegeben':''} · ${c.sent_to_ai?'an KI gesendet':'nur Audit'}
Retrieval ${esc(pct(c.score))}
${progress('Semantik (raw)',c.semantic_score)}${progress('Titel',c.title_score)}${progress('Lexikalisch',c.lexical_score)}${progress('Keywords',c.keyword_score)}${progress('Kategorie/Lernen',c.category_score)}
Evidenz-Schwelle: ${esc(pct(c.required_score))} · Chunks ${esc(c.query_chunk_count||0)} × ${esc(c.document_chunk_count||0)}
${c.best_query_excerpt?`
Ticket: ${esc(c.best_query_excerpt)}
`:''}${c.best_chunk_excerpt?`
KB: ${esc(c.best_chunk_excerpt)}
`:''}
`).join('')||'
Keine Knowledge-Kandidaten im Audit gespeichert.
'; const contexts=(x.context_details||[]).map(c=>`
${esc(contextKindLabel(c.kind))}
${esc(c.name||`#${c.id}`)}${c.relevance?` ${esc(pct(c.relevance))}`:''}${c.status?` ${badge(c.status)}`:''}${c.detail?`
${esc(c.detail)}
`:''}
`).join('')||'
Keine Kontextdetails gespeichert.
'; $('#runDrawerBody').innerHTML=`
Ergebnis
${outcomeBadge(x.outcome)} ${x.dry_run?badge('DRY RUN','warn'):badge('LIVE','good')}
${esc(fmtDate(x.finished_at))}
Quelle
${esc(x.source_version||'–')}
Kategorie
Aktuell: ${esc(x.category_before_name||'Nicht gesetzt')} (#${esc(x.category_before||0)})
KI: ${x.ai_recommended_category_id?`${esc(x.ai_recommended_category_name||'')} (#${esc(x.ai_recommended_category_id)}) · ${esc(pct(x.ai_category_confidence))}`:'keine Empfehlung'}
${badge(catP[0],catP[1])} Schwellwert ${esc(pct(x.category_threshold))}
Antwort
KI: ${x.ai_reply_recommended?'Ja':'Nein'} · ${esc(pct(x.ai_reply_confidence))}
KB: ${esc(x.ai_knowledge_id||x.knowledge_top_id||'keine')}
${badge(repP[0],repP[1])} Schwellwert ${esc(pct(x.reply_threshold))}
Top-Knowledge-Kandidat
${x.knowledge_top_id?`${progress('Retrieval / Ranking',x.knowledge_score)}${x.knowledge_evidence_score?progress('Finale Evidenz',x.knowledge_evidence_score):''}${progress('Semantik (raw)',x.knowledge_semantic_score)}${progress('Titel',x.knowledge_title_score)}${progress('Lexikalisch',x.knowledge_lexical_score)}${progress('Keywords',x.knowledge_keyword_score)}${progress('Kategorie/Lernen',x.knowledge_category_score)}
${esc(x.knowledge_top_title)} · ${esc(x.knowledge_top_id)} · Retrieval-Floor ${esc(pct(x.knowledge_retrieval_floor||0))} · Evidenz erforderlich ${esc(pct(x.knowledge_threshold))}${x.knowledge_category_aligned?' · Kategorie exakt zugeordnet':''}
`:'
Kein Treffer.
'}
KI-Begründung
${esc(x.ai_reason||x.reason||'–')}
Policy
${esc(x.policy_reason||'–')}
${x.error?`
Fehler: ${esc(x.error)}
`:''}
-
Knowledge-Ranking
${candidates}
Kontextquellen
${contexts}${(x.context_warnings||[]).length?`
${x.context_warnings.map(esc).join('
')}
`:''}
+
Knowledge-Ranking
An KI gesendet: ${esc(x.knowledge_llm_candidates||0)} · Kandidaten-Cutoff ${esc(pct(x.knowledge_candidate_cutoff||0))} · Max. Abstand ${esc(pct(x.knowledge_candidate_max_gap||0))} · Audit Top K ${esc(x.knowledge_audit_top_k||0)}
${candidates}
Kontextquellen
${contexts}${(x.context_warnings||[]).length?`
${x.context_warnings.map(esc).join('
')}
`:''}
Lernen
${categories.length?``:'Kategorien nicht geladen.'}
Audit-JSON anzeigen
${esc(JSON.stringify(x,null,2))}
`;openRunDrawer()} function openRunDrawer(){ $('#runBackdrop').classList.add('show');$('#runDrawer').classList.add('show') } function closeRunDrawer(){ $('#runBackdrop').classList.remove('show');$('#runDrawer').classList.remove('show');currentRun=null } @@ -143,7 +143,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)],['Ticket-Filter gesetzt',val(s.glpi_ticket_filter_configured)],['Erlaubte Status',val(s.glpi_allowed_status_ids)],['GLPI-Timeout',val(s.glpi_timeout)]]),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)],['Top K',val(s.knowledge_top_k)],['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)],['Erlaubte Quellen',val(s.knowledge_allowed_sources)],['Auto-Reply-Quellen',val(s.knowledge_auto_reply_sources)]]),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)],['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 Reply',val(s.context_incident_block)],['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)],['Ticket-Filter gesetzt',val(s.glpi_ticket_filter_configured)],['Erlaubte Status',val(s.glpi_allowed_status_ids)],['GLPI-Timeout',val(s.glpi_timeout)]]),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)],['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)],['Erlaubte Quellen',val(s.knowledge_allowed_sources)],['Auto-Reply-Quellen',val(s.knowledge_auto_reply_sources)]]),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)],['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 Reply',val(s.context_incident_block)],['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()}