diff --git a/README.md b/README.md index 8ff7518..9491fa2 100644 --- a/README.md +++ b/README.md @@ -514,3 +514,7 @@ Die interne Knowledge Base kann bei `KNOWLEDGE_WEB_EDIT_ENABLED=true` direkt im ### GLPI-KB Rich Text Rich-Text-Formatierungen aus synchronisierten GLPI-KB-Artikeln bleiben in Ticketantworten erhalten; RAG und LLM sehen weiterhin nur bereinigten Plaintext. + +### Große Knowledge-Verzeichnisse + +Bei großen lokalen Korpora startet das Dashboard sofort und zeigt den Hintergrundaufbau des Knowledge-Index an. Der Agent verarbeitet noch keine Tickets, solange die lokale KB nicht `ready` ist. Unter `/api/status` stehen unter anderem `knowledge_init_phase`, `knowledge_init_processed_files`, `knowledge_init_loaded_docs`, `knowledge_init_indexed_docs`, `knowledge_init_cache_hits` und `knowledge_init_pending_embeddings` zur Verfügung. diff --git a/UPGRADE.md b/UPGRADE.md index 5cbec04..076f951 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -142,3 +142,17 @@ KNOWLEDGE_IGNORE_GLOBS= ``` Unmapped labels no longer crash startup in `unscoped` mode. Such documents remain searchable but their `auto_reply` is disabled until all external labels are mapped. Use `skip` to ignore those documents or `strict` to retain fail-fast behavior. + +## Große lokale Knowledge Bases (vNext) + +Lokale Knowledge-Verzeichnisse werden beim Prozessstart nicht mehr synchron vor dem HTTP-Server indexiert. Das WebUI startet zuerst; Scan, JSON-Validierung, Cache-Prüfung und Embeddings laufen anschließend im Hintergrund. + +Währenddessen gilt: + +- `/healthz` bleibt erreichbar. +- `/readyz` liefert HTTP 503, bis GLPI, Ollama und die lokale Knowledge Base bereit sind. +- Ticket-Polling und Worker starten erst nach erfolgreicher Knowledge-Initialisierung. +- `/api/status` und das Dashboard zeigen Phase, Datei-/Dokumentfortschritt, Cache-Treffer, offene Embeddings und Fehler. +- Bei einem fehlerhaften KB-Dokument bleibt das WebUI erreichbar und zeigt den Initialisierungsfehler an. + +Die Embedding-Erzeugung verarbeitet große Korpora dokumentweise in Batches und schreibt periodische Cache-Checkpoints. Dadurch wird ein Verzeichnis mit vielen tausend Dateien nicht mehr als ein riesiger Embedding-Request im Speicher aufgebaut. diff --git a/agent b/agent old mode 100644 new mode 100755 index 40cdc86..2652be8 Binary files a/agent and b/agent differ diff --git a/cmd/agent/main.go b/cmd/agent/main.go index a9e5bf2..ae24dad 100644 --- a/cmd/agent/main.go +++ b/cmd/agent/main.go @@ -69,18 +69,13 @@ func main() { os.Exit(1) } embeddingProfile := knowledge.ResolveEmbeddingProfile(cfg.KnowledgeEmbeddingProfile, cfg.OllamaEmbeddingModel) - k, err := knowledge.Load(ctx, cfg.KnowledgeDir, cfg.DataDir, o, cfg.RAGEnabled, cfg.KnowledgeAllowedSources, knowledge.ScoringConfig{ + k, err := knowledge.NewStore(cfg.KnowledgeDir, cfg.DataDir, o, cfg.RAGEnabled, cfg.KnowledgeAllowedSources, knowledge.ScoringConfig{ SemanticWeight: cfg.KnowledgeSemanticWeight, TitleWeight: cfg.KnowledgeTitleWeight, LexicalWeight: cfg.KnowledgeLexicalWeight, KeywordWeight: cfg.KnowledgeKeywordWeight, CategoryWeight: cfg.KnowledgeCategoryWeight, EmbeddingProfile: embeddingProfile, EmbeddingIdentity: cfg.OllamaEmbeddingModel, ChunkWords: cfg.KnowledgeChunkWords, ChunkOverlap: cfg.KnowledgeChunkOverlapWords, MaxChunksPerDoc: cfg.KnowledgeMaxChunksPerDoc, MaxQueryChunks: cfg.KnowledgeMaxQueryChunks, CategoryMode: cfg.KnowledgeCategoryMode, CategoryMapFile: cfg.KnowledgeCategoryMapFile, IgnoreGlobs: cfg.KnowledgeIgnoreGlobs, }) if err != nil { - slog.Error("knowledge store initialization failed", - "error", err, - "knowledge_dir", cfg.KnowledgeDir, - "data_dir", cfg.DataDir, - "rag_enabled", cfg.RAGEnabled, - ) + slog.Error("knowledge store configuration failed", "error", err) os.Exit(1) } l, err := learning.Open(cfg.DataDir, cfg.LearningMaxExamples) @@ -88,24 +83,7 @@ func main() { slog.Error("learning store initialization failed", "error", err) os.Exit(1) } - stats := k.LoadStats() - if stats.IgnoredFiles > 0 || stats.UnmappedCategoryFiles > 0 { - slog.Warn("knowledge loaded with compatibility rules", "ignored_files", stats.IgnoredFiles, "unmapped_category_files", stats.UnmappedCategoryFiles, "unmapped_categories", stats.UnmappedCategories, "category_mode", cfg.KnowledgeCategoryMode) - } m := metrics.New() - m.SetKnowledgeDocs(k.Count()) - if cfg.GLPIKBEnabled { - kbSync := glpikb.New(cfg, g, k, m) - if err := kbSync.LoadCache(ctx); err != nil { - slog.Warn("GLPI knowledge cache unavailable", "error", err) - } - syncCtx, syncCancel := context.WithTimeout(ctx, maxDuration(cfg.GLPITimeout*3, 30*time.Second)) - if err := kbSync.Sync(syncCtx); err != nil { - slog.Error("initial GLPI knowledge base sync failed; continuing with local/cache knowledge", "error", err) - } - syncCancel() - kbSync.Start(ctx) - } q := queue.New(cfg.QueueSize) var kuma *uptimekuma.Client if cfg.UptimeKumaEnabled { @@ -113,7 +91,6 @@ func main() { } contextCollector := contextdata.New(cfg, g, kuma) svc := agent.New(cfg, g, o, k, l, st, q, m, contextCollector) - svc.Start(ctx) web, err := webui.New(cfg, m, st, q, k, svc) if err != nil { slog.Error("web UI initialization failed", "error", err) @@ -127,6 +104,36 @@ func main() { cancel() } }() + + // Large local knowledge bases are initialized after the HTTP server is up. + // 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 := 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 + } + m.SetKnowledgeDocs(k.Count()) + stats := k.LoadStats() + if stats.IgnoredFiles > 0 || stats.UnmappedCategoryFiles > 0 { + slog.Warn("knowledge loaded with compatibility rules", "ignored_files", stats.IgnoredFiles, "unmapped_category_files", stats.UnmappedCategoryFiles, "unmapped_categories", stats.UnmappedCategories, "category_mode", cfg.KnowledgeCategoryMode) + } + if cfg.GLPIKBEnabled { + kbSync := glpikb.New(cfg, g, k, m) + if err := kbSync.LoadCache(ctx); err != nil { + slog.Warn("GLPI knowledge cache unavailable", "error", err) + } + syncCtx, syncCancel := context.WithTimeout(ctx, maxDuration(cfg.GLPITimeout*3, 30*time.Second)) + if err := kbSync.Sync(syncCtx); err != nil { + slog.Error("initial GLPI knowledge base sync failed; continuing with local/cache knowledge", "error", err) + } + syncCancel() + kbSync.Start(ctx) + m.SetKnowledgeDocs(k.Count()) + } + svc.Start(ctx) + slog.Info("ticket processing started", "knowledge_docs", k.Count()) + }() <-ctx.Done() shutdownCtx, c := context.WithTimeout(context.Background(), 10*time.Second) defer c() diff --git a/internal/knowledge/store.go b/internal/knowledge/store.go index 8555435..f97d90e 100644 --- a/internal/knowledge/store.go +++ b/internal/knowledge/store.go @@ -6,6 +6,7 @@ import ( "encoding/hex" "encoding/json" "fmt" + "log/slog" "math" "os" "path/filepath" @@ -13,6 +14,7 @@ import ( "strconv" "strings" "sync" + "time" "unicode" "github.com/example/glpi-ai-agent/internal/model" @@ -50,8 +52,27 @@ type LoadStats struct { UnmappedCategories []string `json:"unmapped_categories,omitempty"` } +// InitStatus exposes the asynchronous local knowledge startup state to the +// dashboard and readiness endpoint. The HTTP server can therefore be available +// while a large corpus is still being scanned or embedded. +type InitStatus struct { + State string `json:"state"` + Phase string `json:"phase"` + TotalFiles int `json:"total_files"` + ProcessedFiles int `json:"processed_files"` + LoadedDocs int `json:"loaded_docs"` + IndexedDocs int `json:"indexed_docs"` + CacheHits int `json:"cache_hits"` + PendingEmbeddings int `json:"pending_embeddings"` + StartedAt time.Time `json:"started_at,omitempty"` + FinishedAt time.Time `json:"finished_at,omitempty"` + LastError string `json:"last_error,omitempty"` +} + type Store struct { mu sync.RWMutex + initMu sync.Mutex + initStatus InitStatus dir string managedDir string docs []model.KnowledgeDoc @@ -118,7 +139,7 @@ func normalizeScoring(c ScoringConfig) ScoringConfig { return c } -func Load(ctx context.Context, dir, dataDir string, embedder Embedder, rag bool, allowedSources []string, scoring ...ScoringConfig) (*Store, error) { +func NewStore(dir, dataDir string, embedder Embedder, rag bool, allowedSources []string, scoring ...ScoringConfig) (*Store, error) { managedDir := filepath.Join(dataDir, "knowledge-managed") if err := os.MkdirAll(managedDir, 0o750); err != nil { return nil, fmt.Errorf("create managed knowledge directory: %w", err) @@ -135,29 +156,97 @@ func Load(ctx context.Context, dir, dataDir string, embedder Embedder, rag bool, if err != nil { return nil, err } - s := &Store{dir: dir, managedDir: managedDir, titleVectors: map[string][]float64{}, chunkVectors: map[string][][]float64{}, chunks: map[string][]string{}, files: map[string]string{}, managed: map[string]bool{}, external: map[string]string{}, staticDocs: map[string]model.KnowledgeDoc{}, embedder: embedder, rag: rag, cachePath: filepath.Join(dataDir, "embeddings.json"), allowedSources: map[string]struct{}{}, scoring: scoreCfg, loadOptions: loadOpts, categoryMap: categoryMap} + s := &Store{ + dir: dir, managedDir: managedDir, + titleVectors: map[string][]float64{}, chunkVectors: map[string][][]float64{}, chunks: map[string][]string{}, + files: map[string]string{}, managed: map[string]bool{}, external: map[string]string{}, staticDocs: map[string]model.KnowledgeDoc{}, + embedder: embedder, rag: rag, cachePath: filepath.Join(dataDir, "embeddings.json"), allowedSources: map[string]struct{}{}, + scoring: scoreCfg, loadOptions: loadOpts, categoryMap: categoryMap, + initStatus: InitStatus{State: "waiting", Phase: "waiting"}, + } for _, source := range allowedSources { s.allowedSources[strings.ToLower(strings.TrimSpace(source))] = struct{}{} } - static, staticFiles, stats, err := readDocs(dir, s.allowedSources, loadOpts, categoryMap) + return s, nil +} + +// Load keeps the synchronous API used by tests and small deployments. The main +// application uses NewStore + Initialize in a goroutine so the Web UI is +// reachable immediately even for very large knowledge directories. +func Load(ctx context.Context, dir, dataDir string, embedder Embedder, rag bool, allowedSources []string, scoring ...ScoringConfig) (*Store, error) { + s, err := NewStore(dir, dataDir, embedder, rag, allowedSources, scoring...) if err != nil { return nil, err } - s.loadStats = stats - for i, d := range static { - s.staticDocs[d.ID] = d - s.files[d.ID] = staticFiles[i] + if err := s.Initialize(ctx); err != nil { + return s, err } - managed, managedFiles, managedStats, err := readDocs(managedDir, s.allowedSources, LoadOptions{CategoryMode: "strict"}, categoryMap) + return s, nil +} + +// Initialize scans, validates and indexes the local knowledge corpus. It is +// safe to call from a background goroutine. Until it succeeds Ready() is false, +// so ticket processing can stay paused while the dashboard remains available. +func (s *Store) Initialize(ctx context.Context) (err error) { + if s == nil { + return fmt.Errorf("knowledge store is not initialized") + } + s.initMu.Lock() + defer s.initMu.Unlock() + + s.setInitStatus(func(st *InitStatus) { + *st = InitStatus{State: "loading", Phase: "scanning", StartedAt: time.Now()} + }) + defer func() { + if err != nil { + s.setInitStatus(func(st *InitStatus) { + st.State = "error" + st.Phase = "error" + st.LastError = err.Error() + st.FinishedAt = time.Now() + }) + } + }() + + staticProcessed, staticTotal := 0, 0 + static, staticFiles, stats, err := readDocs(s.dir, s.allowedSources, s.loadOptions, s.categoryMap, func(total, processed, loaded int) { + staticTotal, staticProcessed = total, processed + s.setInitStatus(func(st *InitStatus) { + st.TotalFiles = total + st.ProcessedFiles = processed + st.LoadedDocs = loaded + }) + if processed > 0 && processed%1000 == 0 { + slog.Info("knowledge scan progress", "processed_files", processed, "total_files", total, "loaded_docs", loaded) + } + }) if err != nil { - return nil, err + return err } - s.loadStats.IgnoredFiles += managedStats.IgnoredFiles - s.loadStats.UnmappedCategoryFiles += managedStats.UnmappedCategoryFiles - s.loadStats.UnmappedCategories = mergeStrings(s.loadStats.UnmappedCategories, managedStats.UnmappedCategories) + managedTotal := 0 + managed, managedFiles, managedStats, err := readDocs(s.managedDir, s.allowedSources, LoadOptions{CategoryMode: "strict"}, s.categoryMap, func(total, processed, loaded int) { + managedTotal = total + s.setInitStatus(func(st *InitStatus) { + st.TotalFiles = staticTotal + total + st.ProcessedFiles = staticProcessed + processed + st.LoadedDocs = len(static) + loaded + }) + }) + if err != nil { + return err + } + stats.IgnoredFiles += managedStats.IgnoredFiles + stats.UnmappedCategoryFiles += managedStats.UnmappedCategoryFiles + stats.UnmappedCategories = mergeStrings(stats.UnmappedCategories, managedStats.UnmappedCategories) + + files := map[string]string{} + managedMap := map[string]bool{} + staticMap := map[string]model.KnowledgeDoc{} merged := map[string]model.KnowledgeDoc{} order := []string{} - for _, d := range static { + for i, d := range static { + staticMap[d.ID] = d + files[d.ID] = staticFiles[i] if _, ok := merged[d.ID]; !ok { order = append(order, d.ID) } @@ -168,24 +257,70 @@ func Load(ctx context.Context, dir, dataDir string, embedder Embedder, rag bool, order = append(order, d.ID) } merged[d.ID] = d - s.files[d.ID] = managedFiles[i] - s.managed[d.ID] = true + files[d.ID] = managedFiles[i] + managedMap[d.ID] = true } + docs := make([]model.KnowledgeDoc, 0, len(order)) for _, id := range order { - s.docs = append(s.docs, merged[id]) + docs = append(docs, merged[id]) } - if rag && len(s.docs) > 0 { + + s.mu.Lock() + s.docs = docs + s.files = files + s.managed = managedMap + s.staticDocs = staticMap + s.loadStats = stats + s.titleVectors = map[string][]float64{} + s.chunkVectors = map[string][][]float64{} + s.chunks = map[string][]string{} + s.mu.Unlock() + s.setInitStatus(func(st *InitStatus) { + st.Phase = "indexing" + st.TotalFiles = staticTotal + managedTotal + st.ProcessedFiles = st.TotalFiles + st.LoadedDocs = len(docs) + }) + slog.Info("knowledge scan complete", "documents", len(docs), "ignored_files", stats.IgnoredFiles, "unmapped_category_files", stats.UnmappedCategoryFiles) + + if s.rag && len(docs) > 0 { if s.embedder == nil { - return nil, fmt.Errorf("RAG is enabled but no embedding provider is configured") + return fmt.Errorf("RAG is enabled but no embedding provider is configured") } if err := s.index(ctx); err != nil { - return s, err + return err } } - return s, nil + s.setInitStatus(func(st *InitStatus) { + st.State = "ready" + st.Phase = "ready" + st.IndexedDocs = len(docs) + st.PendingEmbeddings = 0 + st.FinishedAt = time.Now() + st.LastError = "" + }) + slog.Info("knowledge store ready", "documents", len(docs), "rag_enabled", s.rag) + return nil } -func readDocs(dir string, allowed map[string]struct{}, opts LoadOptions, categoryMap map[string][]int64) ([]model.KnowledgeDoc, []string, LoadStats, error) { +func (s *Store) setInitStatus(fn func(*InitStatus)) { + s.mu.Lock() + fn(&s.initStatus) + s.mu.Unlock() +} + +func (s *Store) InitStatus() InitStatus { + if s == nil { + return InitStatus{State: "error", Phase: "error", LastError: "knowledge store is nil"} + } + s.mu.RLock() + defer s.mu.RUnlock() + return s.initStatus +} + +func (s *Store) Ready() bool { return s != nil && s.InitStatus().State == "ready" } + +func readDocs(dir string, allowed map[string]struct{}, opts LoadOptions, categoryMap map[string][]int64, progress func(total, processed, loaded int)) ([]model.KnowledgeDoc, []string, LoadStats, error) { entries, err := os.ReadDir(dir) if err != nil { return nil, nil, LoadStats{}, fmt.Errorf("read knowledge directory %q: %w", dir, err) @@ -193,12 +328,26 @@ func readDocs(dir string, allowed map[string]struct{}, opts LoadOptions, categor var docs []model.KnowledgeDoc var files []string stats := LoadStats{} + total := 0 + for _, e := range entries { + if !e.IsDir() && strings.HasSuffix(strings.ToLower(e.Name()), ".json") { + total++ + } + } + processed := 0 + if progress != nil { + progress(total, 0, 0) + } for _, e := range entries { if e.IsDir() || !strings.HasSuffix(strings.ToLower(e.Name()), ".json") { continue } + processed++ if matchesAnyGlob(e.Name(), opts.IgnoreGlobs) { stats.IgnoredFiles++ + if progress != nil { + progress(total, processed, len(docs)) + } continue } path := filepath.Join(dir, e.Name()) @@ -216,6 +365,9 @@ func readDocs(dir string, allowed map[string]struct{}, opts LoadOptions, categor } if skip { stats.IgnoredFiles++ + if progress != nil { + progress(total, processed, len(docs)) + } continue } if d.ID == "" || d.Title == "" { @@ -229,12 +381,18 @@ func readDocs(dir string, allowed map[string]struct{}, opts LoadOptions, categor return nil, nil, stats, fmt.Errorf("%s: source required", e.Name()) } if _, ok := allowed[d.Source]; !ok { + if progress != nil { + progress(total, processed, len(docs)) + } continue } d.Language = strings.TrimSpace(d.Language) d.CommunicationStyle = strings.ToLower(strings.TrimSpace(d.CommunicationStyle)) docs = append(docs, d) files = append(files, path) + if progress != nil { + progress(total, processed, len(docs)) + } } return docs, files, stats, nil } @@ -497,6 +655,9 @@ func (s *Store) Upsert(ctx context.Context, d model.KnowledgeDoc) error { if s == nil { return fmt.Errorf("knowledge store is not initialized") } + if !s.Ready() { + return fmt.Errorf("knowledge store is still initializing") + } d.ID = strings.TrimSpace(d.ID) d.Title = strings.TrimSpace(d.Title) d.Text = strings.TrimSpace(d.Text) @@ -589,6 +750,9 @@ func (s *Store) Delete(id string) error { if s == nil { return fmt.Errorf("knowledge store is not initialized") } + if !s.Ready() { + return fmt.Errorf("knowledge store is still initializing") + } id = strings.TrimSpace(id) if !safeID(id) { return fmt.Errorf("invalid knowledge id") @@ -975,26 +1139,73 @@ func (s *Store) index(ctx context.Context) error { _ = os.MkdirAll(filepath.Dir(s.cachePath), 0o750) cf := loadCache(s.cachePath) var need []model.KnowledgeDoc + cacheHits := 0 for _, d := range s.docs { bodyChunks := chunkText(d.Text, s.scoring.ChunkWords, s.scoring.ChunkOverlap, s.scoring.MaxChunksPerDoc) + s.mu.Lock() s.chunks[d.ID] = bodyChunks + s.mu.Unlock() h := hashDoc(d, s.scoring) if cf.Hashes[d.ID] == h && len(cf.TitleVectors[d.ID]) > 0 && len(cf.ChunkVectors[d.ID]) == len(bodyChunks) { + s.mu.Lock() s.titleVectors[d.ID] = append([]float64(nil), cf.TitleVectors[d.ID]...) s.chunkVectors[d.ID] = cloneChunkVectors(cf.ChunkVectors[d.ID]) + s.mu.Unlock() + cacheHits++ } else { need = append(need, d) } } - if len(need) > 0 { - embedded, err := s.embedDocuments(ctx, need) + s.setInitStatus(func(st *InitStatus) { + st.CacheHits = cacheHits + st.IndexedDocs = cacheHits + st.PendingEmbeddings = len(need) + }) + slog.Info("knowledge index prepared", "documents", len(s.docs), "cache_hits", cacheHits, "documents_to_embed", len(need)) + if len(need) == 0 { + return s.persistVectorCache() + } + + // Batch by documents, not by the whole corpus. A corpus with tens of + // thousands of files can otherwise allocate hundreds of thousands of + // embedding input strings before the first request is sent to Ollama. + const docsPerBatch = 20 + const checkpointEvery = 1000 + embeddedDocs := 0 + for start := 0; start < len(need); start += docsPerBatch { + if err := ctx.Err(); err != nil { + return err + } + end := start + docsPerBatch + if end > len(need) { + end = len(need) + } + batch := need[start:end] + embedded, err := s.embedDocuments(ctx, batch) if err != nil { return err } - for _, d := range need { + s.mu.Lock() + for _, d := range batch { s.titleVectors[d.ID] = embedded[d.ID].title s.chunkVectors[d.ID] = embedded[d.ID].chunks } + s.mu.Unlock() + embeddedDocs += len(batch) + indexed := cacheHits + embeddedDocs + pending := len(need) - embeddedDocs + s.setInitStatus(func(st *InitStatus) { + st.IndexedDocs = indexed + st.PendingEmbeddings = pending + }) + if embeddedDocs%500 == 0 || embeddedDocs == len(need) { + slog.Info("knowledge embedding progress", "indexed_docs", indexed, "total_docs", len(s.docs), "cache_hits", cacheHits, "pending_embeddings", pending) + } + if embeddedDocs%checkpointEvery == 0 { + if err := s.persistVectorCache(); err != nil { + slog.Warn("knowledge embedding checkpoint failed", "error", err, "indexed_docs", indexed) + } + } } return s.persistVectorCache() } diff --git a/internal/knowledge/store_test.go b/internal/knowledge/store_test.go index b7d862b..9803b7a 100644 --- a/internal/knowledge/store_test.go +++ b/internal/knowledge/store_test.go @@ -3,6 +3,7 @@ package knowledge import ( "context" "encoding/json" + "fmt" "os" "path/filepath" "strings" @@ -392,3 +393,32 @@ func TestKnowledgeIgnoreGlobs(t *testing.T) { t.Fatalf("stats=%+v", s.LoadStats()) } } + +func TestNewStoreSupportsBackgroundInitialization(t *testing.T) { + dir := t.TempDir() + data := t.TempDir() + for i := 0; i < 250; i++ { + doc := model.KnowledgeDoc{ID: fmt.Sprintf("KB-%04d", i), Title: fmt.Sprintf("Artikel %d", i), Text: "Testwissen Anmeldung", Source: "internal-kb", Language: "de-DE", CommunicationStyle: "formal"} + b, _ := json.Marshal(doc) + if err := os.WriteFile(filepath.Join(dir, fmt.Sprintf("kb-%04d.json", i)), b, 0o644); err != nil { + t.Fatal(err) + } + } + s, err := NewStore(dir, data, nil, false, []string{"internal-kb"}) + if err != nil { + t.Fatal(err) + } + if s.Ready() { + t.Fatal("new store must not be ready before Initialize") + } + if st := s.InitStatus(); st.State != "waiting" { + t.Fatalf("state=%q want waiting", st.State) + } + if err := s.Initialize(context.Background()); err != nil { + t.Fatal(err) + } + st := s.InitStatus() + if !s.Ready() || st.State != "ready" || st.ProcessedFiles != 250 || st.LoadedDocs != 250 || s.Count() != 250 { + t.Fatalf("unexpected init status: %+v count=%d", st, s.Count()) + } +} diff --git a/internal/web/server.go b/internal/web/server.go index 609b88d..b225f96 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -36,6 +36,8 @@ type KnowledgeManager interface { IsManaged(string) bool Origin(string) string LoadStats() knowledgepkg.LoadStats + InitStatus() knowledgepkg.InitStatus + Ready() bool } type FeedbackManager interface { Categories(context.Context) ([]model.Category, error) @@ -88,11 +90,13 @@ func (s *Server) health(w http.ResponseWriter, r *http.Request) { } func (s *Server) ready(w http.ResponseWriter, r *http.Request) { g, o := s.metrics.Health() + k := s.knowledge.Ready() + ks := s.knowledge.InitStatus() w.Header().Set("Content-Type", "application/json") - if !g || !o { + if !g || !o || !k { w.WriteHeader(http.StatusServiceUnavailable) } - json.NewEncoder(w).Encode(map[string]any{"glpi": g, "ollama": o}) + json.NewEncoder(w).Encode(map[string]any{"glpi": g, "ollama": o, "knowledge": k, "knowledge_state": ks.State, "knowledge_phase": ks.Phase}) } func (s *Server) prom(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/plain; version=0.0.4") @@ -110,10 +114,12 @@ func (s *Server) status(w http.ResponseWriter, r *http.Request) { g, o := s.metrics.Health() kbOK, kbDocs, kbLastSync, kbLastErr := s.metrics.GLPIKBStatus() loadStats := s.knowledge.LoadStats() + initStatus := s.knowledge.InitStatus() 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, "processed": s.metrics.Processed.Load(), "skipped": s.metrics.Skipped.Load(), "errors": s.metrics.Errors.Load(), "category_changes": s.metrics.CategoryChanged.Load(), "replies": s.metrics.Replies.Load(), "queue_depth": s.q.Len(), "glpi_ok": g, "ollama_ok": o, "knowledge_docs": s.metrics.KnowledgeDocs(), "last_poll": s.metrics.LastPoll(), + "knowledge_ready": s.knowledge.Ready(), "knowledge_init_state": initStatus.State, "knowledge_init_phase": initStatus.Phase, "knowledge_init_total_files": initStatus.TotalFiles, "knowledge_init_processed_files": initStatus.ProcessedFiles, "knowledge_init_loaded_docs": initStatus.LoadedDocs, "knowledge_init_indexed_docs": initStatus.IndexedDocs, "knowledge_init_cache_hits": initStatus.CacheHits, "knowledge_init_pending_embeddings": initStatus.PendingEmbeddings, "knowledge_init_started_at": initStatus.StartedAt, "knowledge_init_finished_at": initStatus.FinishedAt, "knowledge_init_error": initStatus.LastError, "communication_language": s.cfg.CommunicationLanguage, "communication_style": s.cfg.CommunicationStyle, "knowledge_allowed_sources": s.cfg.KnowledgeAllowedSources, "knowledge_auto_reply_sources": s.cfg.KnowledgeAutoReplySources, "knowledge_category_mode": s.cfg.KnowledgeCategoryMode, "knowledge_category_map_configured": strings.TrimSpace(s.cfg.KnowledgeCategoryMapFile) != "", "knowledge_ignore_globs": s.cfg.KnowledgeIgnoreGlobs, "knowledge_ignored_files": loadStats.IgnoredFiles, "knowledge_unmapped_category_files": loadStats.UnmappedCategoryFiles, "knowledge_unmapped_categories": loadStats.UnmappedCategories, "category_confidence": s.cfg.CategoryConfidence, "reply_confidence": s.cfg.ReplyConfidence, "knowledge_min_score": s.cfg.KnowledgeMinScore, "knowledge_retrieval_floor": s.cfg.KnowledgeRetrievalFloor, "knowledge_evidence_weight_retrieval": s.cfg.KnowledgeEvidenceRetrievalWeight, "knowledge_evidence_weight_ai": s.cfg.KnowledgeEvidenceAIWeight, "knowledge_evidence_weight_category": s.cfg.KnowledgeEvidenceCategoryWeight, "knowledge_weight_semantic": s.cfg.KnowledgeSemanticWeight, "knowledge_weight_title": s.cfg.KnowledgeTitleWeight, "knowledge_weight_lexical": s.cfg.KnowledgeLexicalWeight, "knowledge_weight_keywords": s.cfg.KnowledgeKeywordWeight, "knowledge_weight_category": s.cfg.KnowledgeCategoryWeight, "knowledge_embedding_profile": s.cfg.KnowledgeEmbeddingProfile, diff --git a/internal/web/templates/dashboard.html b/internal/web/templates/dashboard.html index fa510da..809633c 100644 --- a/internal/web/templates/dashboard.html +++ b/internal/web/templates/dashboard.html @@ -38,7 +38,7 @@ button,input,textarea,select{font:inherit}button{color:inherit}.app{display:grid
Operations & Diagnose

Übersicht

Gesundheit, Verarbeitung und die wichtigsten Stellschrauben auf einen Blick.
-
GLPIOllama
+
GLPIOllamaKnowledge
@@ -115,11 +115,11 @@ function progress(label,value){const c=scoreClass(value);return `
${text}
`} -function renderStatusChrome(){const g=!!statusData.glpi_ok,o=!!statusData.ollama_ok;$('#glpiChip').innerHTML=`GLPI ${g?'OK':'Fehler'}`;$('#ollamaChip').innerHTML=`Ollama ${o?'OK':'Fehler'}`;$('#sideMode').innerHTML=`${statusData.dry_run?badge('DRY RUN','warn'):badge('LIVE','good')} ${statusData.auto_reply?badge('Auto-Reply','good'):badge('Auto-Reply 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';$('#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')}
${esc(statusData.ollama_model||'–')} · ${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`],['Lernbeispiele',fmtNum(statusData.learning_examples),`${fmtNum(statusData.learning_examples_per_category)} je Kategorie im Prompt`],['Auto-Aktionen',`${fmtNum(statusData.category_changes)} / ${fmtNum(statusData.replies)}`,'Kategorie / Antwort']];$('#overviewStats').innerHTML=stats.map(x=>`
${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=[];if(statusData.dry_run)notices.push(configNotice('Dry Run aktiv. Änderungen und Antworten werden nur simuliert.','warn'));if(!statusData.auto_reply)notices.push(configNotice('Auto-Reply global deaktiviert. KB-Treffer werden bewertet, aber nicht gesendet.','warn'));if(statusData.knowledge_min_score>=.8)notices.push(configNotice(`Hoher Evidenz-Schwellwert: ${pct(statusData.knowledge_min_score)}. Dieser gilt erst nach der KI-Auswahl.`,'warn'));if(statusData.knowledge_retrieval_floor>=.5)notices.push(configNotice(`Hoher Retrieval-Floor: ${pct(statusData.knowledge_retrieval_floor)}. Kurze Tickets könnten bereits vor dem Evidenz-Reranking blockiert werden.`,'warn'));if(statusData.glpi_kb_enabled&&!statusData.glpi_kb_ok)notices.push(configNotice(`GLPI-KB-Sync gestört. ${esc(statusData.glpi_kb_last_error||'Kein Fehlertext verfügbar.')}`,'bad'));if(statusData.knowledge_unmapped_category_files>0)notices.push(configNotice(`${esc(statusData.knowledge_unmapped_category_files)} KB-Datei(en) mit nicht zugeordneten Fremdkategorien. Diese Artikel bleiben suchbar, Auto-Reply ist dafür fail-closed deaktiviert. Nicht zugeordnet: ${esc((statusData.knowledge_unmapped_categories||[]).join(', ')||'–')}`,'warn'));if(statusData.knowledge_ignored_files>0)notices.push(configNotice(`${esc(statusData.knowledge_ignored_files)} KB-Datei(en) durch Kompatibilitäts-/Ignore-Regeln übersprungen.`,'warn'));if(statusData.rag_enabled&&!statusData.knowledge_docs)notices.push(configNotice('RAG aktiv, aber keine Knowledge-Dokumente geladen.','bad'));if(statusData.context_fail_closed)notices.push(configNotice('Kontextquellen arbeiten fail-closed: Fehler können Auto-Replies blockieren.'));if(!notices.length)notices.push(configNotice('Keine auffälligen Konfigurationshinweise erkannt.','good'));$('#diagnosticNotices').innerHTML=notices.join(''); - const health=[['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=[];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_init_state==='error')notices.push(configNotice(`Knowledge-Initialisierung fehlgeschlagen. ${esc(statusData.knowledge_init_error||'Kein Fehlertext verfügbar.')}`,'bad'));if(statusData.dry_run)notices.push(configNotice('Dry Run aktiv. Änderungen und Antworten werden nur simuliert.','warn'));if(!statusData.auto_reply)notices.push(configNotice('Auto-Reply global deaktiviert. KB-Treffer werden bewertet, aber nicht gesendet.','warn'));if(statusData.knowledge_min_score>=.8)notices.push(configNotice(`Hoher Evidenz-Schwellwert: ${pct(statusData.knowledge_min_score)}. Dieser gilt erst nach der KI-Auswahl.`,'warn'));if(statusData.knowledge_retrieval_floor>=.5)notices.push(configNotice(`Hoher Retrieval-Floor: ${pct(statusData.knowledge_retrieval_floor)}. Kurze Tickets könnten bereits vor dem Evidenz-Reranking blockiert werden.`,'warn'));if(statusData.glpi_kb_enabled&&!statusData.glpi_kb_ok)notices.push(configNotice(`GLPI-KB-Sync gestört. ${esc(statusData.glpi_kb_last_error||'Kein Fehlertext verfügbar.')}`,'bad'));if(statusData.knowledge_unmapped_category_files>0)notices.push(configNotice(`${esc(statusData.knowledge_unmapped_category_files)} KB-Datei(en) mit nicht zugeordneten Fremdkategorien. Diese Artikel bleiben suchbar, Auto-Reply ist dafür fail-closed deaktiviert. Nicht zugeordnet: ${esc((statusData.knowledge_unmapped_categories||[]).join(', ')||'–')}`,'warn'));if(statusData.knowledge_ignored_files>0)notices.push(configNotice(`${esc(statusData.knowledge_ignored_files)} KB-Datei(en) durch Kompatibilitäts-/Ignore-Regeln übersprungen.`,'warn'));if(statusData.rag_enabled&&!statusData.knowledge_docs)notices.push(configNotice('RAG aktiv, aber keine Knowledge-Dokumente geladen.','bad'));if(statusData.context_fail_closed)notices.push(configNotice('Kontextquellen arbeiten fail-closed: Fehler können Auto-Replies blockieren.'));if(!notices.length)notices.push(configNotice('Keine auffälligen Konfigurationshinweise erkannt.','good'));$('#diagnosticNotices').innerHTML=notices.join(''); + const 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(''); $('#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
'}
`} @@ -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)],['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)],['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)],['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)],['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)],['Erlaubte Quellen',val(s.knowledge_allowed_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)],['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()}