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(JSON.stringify(x,null,2))}