package engine import ( "encoding/json" "fmt" "os" "sort" "strings" "github.com/local/glpi-neural-brain/internal/model" ) const uncategorizedFilter = "__uncategorized__" type RuntimeSettings struct { LearningEnabled bool `json:"learning_enabled"` ThinkingEnabled bool `json:"thinking_enabled"` LearningCategories []string `json:"learning_categories"` DisplayCategories []string `json:"display_categories"` ThinkingCategories []string `json:"thinking_categories"` ViewMode string `json:"view_mode"` MaxDisplayNodes int `json:"max_display_nodes"` } type CategoryInfo struct { Name string `json:"name"` Count int `json:"count"` } func (e *Engine) defaultRuntimeSettings() RuntimeSettings { return normalizeRuntimeSettings(RuntimeSettings{ LearningEnabled: e.Cfg.LearningEnabled, ThinkingEnabled: e.Cfg.ThinkingEnabled, LearningCategories: append([]string(nil), e.Cfg.LearningCategories...), DisplayCategories: append([]string(nil), e.Cfg.DisplayCategories...), ThinkingCategories: append([]string(nil), e.Cfg.ThinkingCategories...), ViewMode: e.Cfg.DefaultView, MaxDisplayNodes: e.Cfg.MaxDisplayNodes, }) } func normalizeRuntimeSettings(in RuntimeSettings) RuntimeSettings { in.LearningCategories = normalizeCategories(in.LearningCategories) in.DisplayCategories = normalizeCategories(in.DisplayCategories) in.ThinkingCategories = normalizeCategories(in.ThinkingCategories) in.ViewMode = strings.ToLower(strings.TrimSpace(in.ViewMode)) if in.ViewMode == "" { in.ViewMode = "neural" } if in.ViewMode != "neural" && in.ViewMode != "honeycomb" { in.ViewMode = "neural" } if in.MaxDisplayNodes < 0 { in.MaxDisplayNodes = 0 } if in.MaxDisplayNodes > 500000 { in.MaxDisplayNodes = 500000 } return in } func normalizeCategories(values []string) []string { seen := map[string]string{} for _, value := range values { value = strings.TrimSpace(value) if value == "" { continue } key := strings.ToLower(value) if _, exists := seen[key]; !exists { seen[key] = value } } out := make([]string, 0, len(seen)) for _, value := range seen { out = append(out, value) } sort.Slice(out, func(i, j int) bool { return strings.ToLower(out[i]) < strings.ToLower(out[j]) }) return out } func (e *Engine) loadRuntimeSettings() { settings := e.defaultRuntimeSettings() if strings.TrimSpace(e.runtimePath) != "" { if data, err := os.ReadFile(e.runtimePath); err == nil { var stored RuntimeSettings if json.Unmarshal(data, &stored) == nil { settings = normalizeRuntimeSettings(stored) } } } e.runtimeMu.Lock() e.runtime = settings e.runtimeMu.Unlock() } func (e *Engine) RuntimeSettings() RuntimeSettings { e.runtimeMu.RLock() settings := e.runtime e.runtimeMu.RUnlock() settings.LearningCategories = append([]string{}, settings.LearningCategories...) settings.DisplayCategories = append([]string{}, settings.DisplayCategories...) settings.ThinkingCategories = append([]string{}, settings.ThinkingCategories...) return settings } func (e *Engine) SetRuntimeSettings(settings RuntimeSettings) (RuntimeSettings, error) { if settings.MaxDisplayNodes < 0 || settings.MaxDisplayNodes > 500000 { return e.RuntimeSettings(), fmt.Errorf("max_display_nodes must be between 0 and 500000") } settings = normalizeRuntimeSettings(settings) e.runtimeMu.Lock() previous := e.runtime e.runtime = settings e.runtimeMu.Unlock() if !settings.ThinkingEnabled { e.stateMu.Lock() if !e.enrichRunning && e.enrichResult == "queued" { e.enrichResult = "disabled" } e.stateMu.Unlock() } if e.Persistence != nil && strings.TrimSpace(e.runtimePath) != "" { data, err := json.MarshalIndent(settings, "", " ") if err != nil { return previous, err } if _, err := e.Persistence.QueueFile(e.runtimePath, append(data, '\n'), 0o640); err != nil { e.runtimeMu.Lock() e.runtime = previous e.runtimeMu.Unlock() return previous, err } } e.Broker.Publish(model.Activity{ Type: "runtime.settings.updated", Source: "ui", Phase: "control", Message: "Lern-, Anzeige- und Thinking-Einstellungen wurden aktualisiert", Strength: .32, Metadata: map[string]any{ "learning_enabled": settings.LearningEnabled, "thinking_enabled": settings.ThinkingEnabled, "learning_categories": len(settings.LearningCategories), "display_categories": len(settings.DisplayCategories), "thinking_categories": len(settings.ThinkingCategories), "view_mode": settings.ViewMode, "max_display_nodes": settings.MaxDisplayNodes, }, }) return settings, nil } func (e *Engine) LearningEnabled() bool { e.runtimeMu.RLock() enabled := e.runtime.LearningEnabled e.runtimeMu.RUnlock() return enabled } func (e *Engine) ThinkingEnabled() bool { e.runtimeMu.RLock() enabled := e.runtime.ThinkingEnabled e.runtimeMu.RUnlock() return enabled } func (e *Engine) learningCategories() []string { e.runtimeMu.RLock() out := append([]string(nil), e.runtime.LearningCategories...) e.runtimeMu.RUnlock() return out } func (e *Engine) thinkingCategories() []string { e.runtimeMu.RLock() out := append([]string(nil), e.runtime.ThinkingCategories...) e.runtimeMu.RUnlock() return out } func (e *Engine) Categories() []CategoryInfo { snapshot := e.Graph.Snapshot() counts := map[string]int{} names := map[string]string{} uncategorized := 0 for _, node := range snapshot.Nodes { if node.Kind != "knowledge" && node.Kind != "ai-think" && node.Kind != "external" { continue } if len(node.Categories) == 0 { uncategorized++ continue } seen := map[string]bool{} for _, category := range node.Categories { category = strings.TrimSpace(category) if category == "" { continue } key := strings.ToLower(category) if seen[key] { continue } seen[key] = true if _, ok := names[key]; !ok { names[key] = category } counts[key]++ } } out := make([]CategoryInfo, 0, len(counts)+1) for key, count := range counts { out = append(out, CategoryInfo{Name: names[key], Count: count}) } if uncategorized > 0 { out = append(out, CategoryInfo{Name: uncategorizedFilter, Count: uncategorized}) } sort.Slice(out, func(i, j int) bool { if out[i].Count == out[j].Count { return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name) } return out[i].Count > out[j].Count }) return out }