Fix Startproblem bei großen Mengen von KBs
release-tag / release-image (push) Successful in 1m34s

This commit is contained in:
2026-07-29 08:26:08 +02:00
parent 03108d4ff9
commit 720b2aecdd
8 changed files with 328 additions and 56 deletions
+4
View File
@@ -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.
+14
View File
@@ -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.
Regular → Executable
BIN
View File
Binary file not shown.
+32 -25
View File
@@ -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()
+235 -24
View File
@@ -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()
}
+30
View File
@@ -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())
}
}
+8 -2
View File
@@ -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,
+5 -5
View File
@@ -38,7 +38,7 @@ button,input,textarea,select{font:inherit}button{color:inherit}.app{display:grid
<main class="content">
<header class="topbar">
<div><div class="eyebrow">Operations & Diagnose</div><h1 class="page-title" id="pageTitle">Übersicht</h1><div class="page-sub" id="pageSub">Gesundheit, Verarbeitung und die wichtigsten Stellschrauben auf einen Blick.</div></div>
<div class="toolbar"><span class="chip" id="glpiChip"><span class="status-dot"></span>GLPI</span><span class="chip" id="ollamaChip"><span class="status-dot"></span>Ollama</span><span class="chip"><span id="lastRefresh"></span></span><button class="btn small" id="refreshBtn">Jetzt aktualisieren</button></div>
<div class="toolbar"><span class="chip" id="glpiChip"><span class="status-dot"></span>GLPI</span><span class="chip" id="ollamaChip"><span class="status-dot"></span>Ollama</span><span class="chip" id="knowledgeChip"><span class="status-dot"></span>Knowledge</span><span class="chip"><span id="lastRefresh"></span></span><button class="btn small" id="refreshBtn">Jetzt aktualisieren</button></div>
</header>
<section class="view active" id="view-overview">
@@ -115,11 +115,11 @@ function progress(label,value){const c=scoreClass(value);return `<div class="sco
function outcomeBadge(x){if(x==='error')return badge('Fehler','bad');if(x==='skipped')return badge('Übersprungen','warn');return badge('Verarbeitet','good')}
function policyLabel(code){const map={category_written:['Geändert','good'],category_accepted_dry_run:['Würde ändern','info'],category_accepted:['Freigegeben','good'],category_already_correct:['Bereits korrekt','good'],category_confidence_below_threshold:['Unter Schwellwert','warn'],category_no_recommendation:['Keine Empfehlung','warn'],category_auto_disabled:['Auto-Kategorie aus','warn'],category_unknown:['Kategorie unbekannt','bad'],category_ticket_changed_before_write:['Ticket geändert','warn'],category_write_failed:['Schreibfehler','bad'],reply_written:['Gesendet','good'],reply_accepted_dry_run:['Würde senden','info'],reply_accepted:['Freigegeben','good'],reply_auto_disabled:['Auto-Reply aus','warn'],reply_model_not_recommended:['KI empfiehlt keine Antwort','warn'],reply_confidence_below_threshold:['Confidence zu niedrig','warn'],reply_knowledge_score_below_threshold:['KB-Score zu niedrig (alt)','warn'],reply_knowledge_retrieval_below_floor:['Retrieval zu schwach','warn'],reply_knowledge_evidence_below_threshold:['Evidenz zu niedrig','warn'],reply_no_knowledge_candidates:['Keine KB-Treffer','warn'],reply_no_knowledge_selected:['Keine KB gewählt','warn'],reply_knowledge_not_found:['KB nicht gefunden','bad'],reply_source_not_allowed:['Quelle gesperrt','warn'],reply_source_not_allowed_for_auto_reply:['Quelle nicht für Auto-Reply','warn'],reply_language_mismatch:['Sprache passt nicht','warn'],reply_style_mismatch:['Stil passt nicht','warn'],reply_knowledge_auto_reply_disabled:['Artikel nicht freigegeben','warn'],reply_knowledge_answer_empty:['Antworttext fehlt','warn'],reply_category_not_allowed:['Kategorie nicht freigegeben','warn'],reply_context_incomplete:['Kontext unvollständig','warn'],reply_relevant_incident:['Störung/Incident erkannt','warn'],reply_existing_followup:['Bereits beantwortet','good'],reply_followup_appeared_before_write:['Antwort hinzugekommen','warn'],reply_ticket_changed_before_write:['Ticket geändert','warn'],reply_write_failed:['Schreibfehler','bad']};return map[code]||[code||'Keine Aktion','']}
function configNotice(text,kind=''){return `<div class="notice ${kind}">${text}</div>`}
function renderStatusChrome(){const g=!!statusData.glpi_ok,o=!!statusData.ollama_ok;$('#glpiChip').innerHTML=`<span class="status-dot ${g?'ok':'bad'}"></span>GLPI ${g?'OK':'Fehler'}`;$('#ollamaChip').innerHTML=`<span class="status-dot ${o?'ok':'bad'}"></span>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')}<div style="margin-top:8px">${esc(statusData.ollama_model||'')} · ${esc(statusData.communication_language||'')} / ${esc(statusData.communication_style||'')}</div>`;$('#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=`<span class="status-dot ${g?'ok':'bad'}"></span>GLPI ${g?'OK':'Fehler'}`;$('#ollamaChip').innerHTML=`<span class="status-dot ${o?'ok':'bad'}"></span>Ollama ${o?'OK':'Fehler'}`;$('#knowledgeChip').innerHTML=`<span class="status-dot ${k?'ok':ks==='error'?'bad':''}"></span>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')}<div style="margin-top:8px">${esc(statusData.ollama_model||'')} · ${esc(statusData.communication_language||'')} / ${esc(statusData.communication_style||'')}</div>`;$('#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=>`<div class="stat"><div class="stat-label">${esc(x[0])}</div><div class="stat-value">${esc(x[1])}</div><div class="stat-foot">${esc(x[2])}</div></div>`).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 `<div class="health-row click-row" data-run-id="${esc(x.run_id)}"><div><div class="ticket-title">#${esc(x.ticket_id)} ${esc(x.ticket_name||'')}</div><div class="health-meta">${esc(fmtDate(x.finished_at))}${esc(score)}</div></div>${outcomeBadge(x.outcome)}</div>`}).join(''):'<div class="empty">Noch keine Verarbeitungen.</div>';
const notices=[];if(statusData.dry_run)notices.push(configNotice('<strong>Dry Run aktiv.</strong> Änderungen und Antworten werden nur simuliert.','warn'));if(!statusData.auto_reply)notices.push(configNotice('<strong>Auto-Reply global deaktiviert.</strong> KB-Treffer werden bewertet, aber nicht gesendet.','warn'));if(statusData.knowledge_min_score>=.8)notices.push(configNotice(`<strong>Hoher Evidenz-Schwellwert:</strong> ${pct(statusData.knowledge_min_score)}. Dieser gilt erst nach der KI-Auswahl.`,'warn'));if(statusData.knowledge_retrieval_floor>=.5)notices.push(configNotice(`<strong>Hoher Retrieval-Floor:</strong> ${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(`<strong>GLPI-KB-Sync gestört.</strong> ${esc(statusData.glpi_kb_last_error||'Kein Fehlertext verfügbar.')}`,'bad'));if(statusData.knowledge_unmapped_category_files>0)notices.push(configNotice(`<strong>${esc(statusData.knowledge_unmapped_category_files)} KB-Datei(en) mit nicht zugeordneten Fremdkategorien.</strong> 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(`<strong>${esc(statusData.knowledge_ignored_files)} KB-Datei(en) durch Kompatibilitäts-/Ignore-Regeln übersprungen.</strong>`,'warn'));if(statusData.rag_enabled&&!statusData.knowledge_docs)notices.push(configNotice('<strong>RAG aktiv, aber keine Knowledge-Dokumente geladen.</strong>','bad'));if(statusData.context_fail_closed)notices.push(configNotice('Kontextquellen arbeiten <strong>fail-closed</strong>: 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=>`<div class="health-row"><div class="health-name"><span class="status-dot ${x[1]?'ok':'bad'}"></span><div>${esc(x[0])}<div class="health-meta">${esc(x[2])}</div></div></div>${x[1]?badge('OK','good'):badge('Fehler','bad')}</div>`).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(`<strong>Knowledge-Index wird aufgebaut.</strong> 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(`<strong>Knowledge-Initialisierung fehlgeschlagen.</strong> ${esc(statusData.knowledge_init_error||'Kein Fehlertext verfügbar.')}`,'bad'));if(statusData.dry_run)notices.push(configNotice('<strong>Dry Run aktiv.</strong> Änderungen und Antworten werden nur simuliert.','warn'));if(!statusData.auto_reply)notices.push(configNotice('<strong>Auto-Reply global deaktiviert.</strong> KB-Treffer werden bewertet, aber nicht gesendet.','warn'));if(statusData.knowledge_min_score>=.8)notices.push(configNotice(`<strong>Hoher Evidenz-Schwellwert:</strong> ${pct(statusData.knowledge_min_score)}. Dieser gilt erst nach der KI-Auswahl.`,'warn'));if(statusData.knowledge_retrieval_floor>=.5)notices.push(configNotice(`<strong>Hoher Retrieval-Floor:</strong> ${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(`<strong>GLPI-KB-Sync gestört.</strong> ${esc(statusData.glpi_kb_last_error||'Kein Fehlertext verfügbar.')}`,'bad'));if(statusData.knowledge_unmapped_category_files>0)notices.push(configNotice(`<strong>${esc(statusData.knowledge_unmapped_category_files)} KB-Datei(en) mit nicht zugeordneten Fremdkategorien.</strong> 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(`<strong>${esc(statusData.knowledge_ignored_files)} KB-Datei(en) durch Kompatibilitäts-/Ignore-Regeln übersprungen.</strong>`,'warn'));if(statusData.rag_enabled&&!statusData.knowledge_docs)notices.push(configNotice('<strong>RAG aktiv, aber keine Knowledge-Dokumente geladen.</strong>','bad'));if(statusData.context_fail_closed)notices.push(configNotice('Kontextquellen arbeiten <strong>fail-closed</strong>: 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=>`<div class="health-row"><div class="health-name"><span class="status-dot ${x[1]?'ok':'bad'}"></span><div>${esc(x[0])}<div class="health-meta">${esc(x[2])}</div></div></div>${x[1]?badge('OK','good'):badge('Fehler','bad')}</div>`).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)}<div style="margin-top:14px" class="health-row"><span>Retrieval-Floor</span><strong>${esc(pct(statusData.knowledge_retrieval_floor))}</strong></div><div class="health-row"><span>Finaler Evidenz-Schwellwert</span><strong>${esc(pct(statusData.knowledge_min_score))}</strong></div><div class="health-row"><span>Evidenz: Retrieval / KI / Kategorie</span><strong>${esc(pct(statusData.knowledge_evidence_weight_retrieval))} / ${esc(pct(statusData.knowledge_evidence_weight_ai))} / ${esc(pct(statusData.knowledge_evidence_weight_category))}</strong></div><div class="health-row"><span>Kategorie-Confidence</span><strong>${esc(pct(statusData.category_confidence))}</strong></div><div class="health-row"><span>Reply-Confidence</span><strong>${esc(pct(statusData.reply_confidence))}</strong></div>`}
function categoryMini(x){if(!x.ai_recommended_category_id)return `<div class="decision-mini">${badge('Keine Empfehlung','warn')}<div class="muted small">KI-Sicherheit ${pct(x.ai_category_confidence)}</div></div>`;const p=policyLabel(x.category_decision);return `<div class="decision-mini"><div class="decision-line"><strong>${esc(x.ai_recommended_category_name||`#${x.ai_recommended_category_id}`)}</strong> <span class="score ${scoreClass(x.ai_category_confidence)}">${esc(pct(x.ai_category_confidence))}</span></div>${badge(p[0],p[1])}<div class="muted small">Schwellwert ${esc(pct(x.category_threshold))}</div></div>`}
function replyMini(x){const p=policyLabel(x.reply_decision);return `<div class="decision-mini"><div class="decision-line">${x.ai_reply_recommended?'<strong>KI: Antwort</strong>':'<span class="muted">KI: keine Antwort</span>'} <span class="score ${scoreClass(x.ai_reply_confidence)}">${esc(pct(x.ai_reply_confidence))}</span></div>${badge(p[0],p[1])}${x.knowledge_top_id?`<div class="muted small">${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))}`:''}</div>`:'<div class="muted small">Keine Knowledge-Treffer</div>'}</div>`}
@@ -143,7 +143,7 @@ function renderKB(){const st=kbStatsData();$('#kbStats').innerHTML=st.map(x=>`<d
function renderLearning(){const q=$('#learningSearch').value.trim().toLowerCase(),rows=learningRows.filter(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=>`<div class="stat"><div class="stat-label">${esc(x[0])}</div><div class="stat-value">${fmtNum(x[1])}</div><div class="stat-foot">${esc(x[2])}</div></div>`).join('');$('#learningTable').innerHTML=rows.length?rows.map(x=>`<tr><td><div class="ticket-title">#${esc(x.ticket_id)} ${esc(x.subject)}</div><div class="muted small">${esc((x.text||'').slice(0,220))}</div></td><td><strong>${esc(x.category_name)}</strong> (#${esc(x.category_id)})${x.ai_recommended_category_id?`<div class="muted small">KI: #${esc(x.ai_recommended_category_id)} · ${esc(pct(x.ai_confidence))}</div>`:''}</td><td>${x.correction?badge('Korrektur','warn'):badge('Bestätigung','good')}</td><td class="nowrap">${esc(fmtDate(x.created_at))}</td><td><button class="btn small danger" data-learning-delete="${esc(x.id)}">Löschen</button></td></tr>`).join(''):'<tr><td colspan="5" class="empty">Keine Lernbeispiele.</td></tr>'}
function configCard(title,subtitle,rows){return `<div class="panel config-card"><div class="panel-head"><div><div class="panel-title">${esc(title)}</div><div class="panel-sub">${esc(subtitle)}</div></div></div><div class="config-list">${rows.map(([k,v])=>`<div class="config-row"><div class="config-key">${esc(k)}</div><div class="config-val">${v}</div></div>`).join('')}</div></div>`}
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',`<strong>${pct(s.knowledge_min_score)}</strong>`],['Retrieval-Floor',`<strong>${pct(s.knowledge_retrieval_floor)}</strong>`],['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',`<strong>${pct(s.category_confidence)}</strong>`],['Reply-Confidence',`<strong>${pct(s.reply_confidence)}</strong>`],['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',`<strong>${pct(s.knowledge_min_score)}</strong>`],['Retrieval-Floor',`<strong>${pct(s.knowledge_retrieval_floor)}</strong>`],['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',`<strong>${pct(s.category_confidence)}</strong>`],['Reply-Confidence',`<strong>${pct(s.reply_confidence)}</strong>`],['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='<option value="">Alle Quellen</option>'+sources.map(x=>`<option value="${esc(x)}">${esc(x)}</option>`).join('');if([...$('#kbSourceFilter').options].some(o=>o.value===filterOld))$('#kbSourceFilter').value=filterOld;const allowed=statusData.knowledge_allowed_sources||[];$('#kbSource').innerHTML=allowed.map(x=>`<option value="${esc(x)}">${esc(x)}</option>`).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=>`<label class="category-item"><input type="checkbox" value="${Number(c.id)}" ${kbCategorySelection.has(Number(c.id))?'checked':''}><span>${esc(c.completename||c.name)} <span class="muted">#${Number(c.id)}</span></span></label>`).join('')||'<div class="muted small" style="padding:8px">Keine Kategorie gefunden.</div>'}
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()}