package engine import ( "encoding/json" "fmt" "os" "sort" "strings" "github.com/local/glpi-neural-brain/internal/graph" "github.com/local/glpi-neural-brain/internal/model" ) const uncategorizedFilter = graph.UncategorizedFilter const unsourcedFilter = graph.UnsourcedFilter 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"` LearningSources []string `json:"learning_sources"` DisplaySources []string `json:"display_sources"` ThinkingSources []string `json:"thinking_sources"` ViewMode string `json:"view_mode"` MaxDisplayNodes int `json:"max_display_nodes"` LowPowerMode bool `json:"low_power_mode"` } type RuntimeFilterInfo struct { Categories []string `json:"categories"` Sources []string `json:"sources"` CategoriesRestricted bool `json:"categories_restricted"` SourcesRestricted bool `json:"sources_restricted"` MatchesNone bool `json:"matches_none"` } type RuntimeSettingsView struct { RuntimeSettings EffectiveLearning RuntimeFilterInfo `json:"effective_learning"` EffectiveDisplay RuntimeFilterInfo `json:"effective_display"` EffectiveThinking RuntimeFilterInfo `json:"effective_thinking"` AdminLearning RuntimeFilterInfo `json:"admin_learning"` AdminDisplay RuntimeFilterInfo `json:"admin_display"` AdminThinking RuntimeFilterInfo `json:"admin_thinking"` } type CategoryInfo struct { Name string `json:"name"` Count int `json:"count"` } type SourceInfo = CategoryInfo func (e *Engine) defaultRuntimeSettings() RuntimeSettings { // Category/source values in Config are administrative ceilings. The WebUI // starts at "all allowed" (empty selection), not by copying the ceiling into // mutable runtime state. return normalizeRuntimeSettings(RuntimeSettings{ LearningEnabled: e.Cfg.LearningEnabled, ThinkingEnabled: e.Cfg.ThinkingEnabled, ViewMode: e.Cfg.DefaultView, MaxDisplayNodes: e.Cfg.MaxDisplayNodes, LowPowerMode: e.Cfg.LowPowerMode, }) } func normalizeRuntimeSettings(in RuntimeSettings) RuntimeSettings { in.LearningCategories = normalizeValues(in.LearningCategories) in.DisplayCategories = normalizeValues(in.DisplayCategories) in.ThinkingCategories = normalizeValues(in.ThinkingCategories) in.LearningSources = normalizeValues(in.LearningSources) in.DisplaySources = normalizeValues(in.DisplaySources) in.ThinkingSources = normalizeValues(in.ThinkingSources) in.ViewMode = strings.ToLower(strings.TrimSpace(in.ViewMode)) if in.ViewMode == "" { in.ViewMode = "neural" } if in.ViewMode != "neural" && in.ViewMode != "honeycomb" && in.ViewMode != "constellation" { in.ViewMode = "neural" } if in.MaxDisplayNodes < 0 { in.MaxDisplayNodes = 0 } if in.MaxDisplayNodes > 500000 { in.MaxDisplayNodes = 500000 } return in } func normalizeValues(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 normalizeCategories(values []string) []string { return normalizeValues(values) } // loadRuntimeSettings merges individual persisted fields. Older files that do // not contain newly introduced fields inherit defaults instead of silently // zeroing unrelated settings. func (e *Engine) loadRuntimeSettings() { settings := e.defaultRuntimeSettings() if strings.TrimSpace(e.runtimePath) != "" { if data, err := os.ReadFile(e.runtimePath); err == nil { mergeRuntimeSettingsJSON(&settings, data) } } settings = normalizeRuntimeSettings(settings) e.runtimeMu.Lock() e.runtime = settings e.runtimeMu.Unlock() } func mergeRuntimeSettingsJSON(settings *RuntimeSettings, data []byte) { var raw map[string]json.RawMessage if json.Unmarshal(data, &raw) != nil { return } decode := func(key string, target any) { if value, ok := raw[key]; ok { _ = json.Unmarshal(value, target) } } decode("learning_enabled", &settings.LearningEnabled) decode("thinking_enabled", &settings.ThinkingEnabled) decode("learning_categories", &settings.LearningCategories) decode("display_categories", &settings.DisplayCategories) decode("thinking_categories", &settings.ThinkingCategories) decode("learning_sources", &settings.LearningSources) decode("display_sources", &settings.DisplaySources) decode("thinking_sources", &settings.ThinkingSources) decode("view_mode", &settings.ViewMode) decode("max_display_nodes", &settings.MaxDisplayNodes) decode("low_power_mode", &settings.LowPowerMode) } 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...) settings.LearningSources = append([]string{}, settings.LearningSources...) settings.DisplaySources = append([]string{}, settings.DisplaySources...) settings.ThinkingSources = append([]string{}, settings.ThinkingSources...) return settings } func (e *Engine) RuntimeSettingsView() RuntimeSettingsView { settings := e.RuntimeSettings() return RuntimeSettingsView{ RuntimeSettings: settings, EffectiveLearning: filterInfo(e.effectiveLearningFilter()), EffectiveDisplay: filterInfo(e.effectiveDisplayFilter()), EffectiveThinking: filterInfo(e.effectiveThinkingFilter()), AdminLearning: adminFilterInfo(e.Cfg.LearningCategories, e.Cfg.LearningSources), AdminDisplay: adminFilterInfo(e.Cfg.DisplayCategories, e.Cfg.DisplaySources), AdminThinking: adminFilterInfo(e.Cfg.ThinkingCategories, e.Cfg.ThinkingSources), } } func filterInfo(filter graph.NodeFilter) RuntimeFilterInfo { return RuntimeFilterInfo{ Categories: append([]string{}, filter.Categories...), Sources: append([]string{}, filter.Sources...), CategoriesRestricted: dimensionRestricted(filter.Categories) || filter.MatchNone, SourcesRestricted: dimensionRestricted(filter.Sources) || filter.MatchNone, MatchesNone: filter.MatchNone, } } func adminFilterInfo(categories, sources []string) RuntimeFilterInfo { categories = normalizeValues(categories) sources = normalizeValues(sources) return RuntimeFilterInfo{ Categories: categories, Sources: sources, CategoriesRestricted: dimensionRestricted(categories), SourcesRestricted: dimensionRestricted(sources), } } func dimensionRestricted(values []string) bool { if len(values) == 0 { return false } for _, value := range values { if strings.TrimSpace(value) == "*" { return false } } return true } 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 } } view := e.RuntimeSettingsView() e.Broker.Publish(model.Activity{ Type: "runtime.settings.updated", Source: "ui", Phase: "control", Message: "Lern-, Anzeige-, Quellen- 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), "learning_sources": len(settings.LearningSources), "display_sources": len(settings.DisplaySources), "thinking_sources": len(settings.ThinkingSources), "learning_filter_matches_none": view.EffectiveLearning.MatchesNone, "display_filter_matches_none": view.EffectiveDisplay.MatchesNone, "thinking_filter_matches_none": view.EffectiveThinking.MatchesNone, "view_mode": settings.ViewMode, "max_display_nodes": settings.MaxDisplayNodes, "low_power_mode": settings.LowPowerMode, }, }) 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) effectiveLearningFilter() graph.NodeFilter { settings := e.RuntimeSettings() return effectiveNodeFilter(e.Cfg.LearningCategories, settings.LearningCategories, e.Cfg.LearningSources, settings.LearningSources) } func (e *Engine) effectiveDisplayFilter() graph.NodeFilter { settings := e.RuntimeSettings() return effectiveNodeFilter(e.Cfg.DisplayCategories, settings.DisplayCategories, e.Cfg.DisplaySources, settings.DisplaySources) } func (e *Engine) effectiveThinkingFilter() graph.NodeFilter { settings := e.RuntimeSettings() return effectiveNodeFilter(e.Cfg.ThinkingCategories, settings.ThinkingCategories, e.Cfg.ThinkingSources, settings.ThinkingSources) } func effectiveNodeFilter(adminCategories, runtimeCategories, adminSources, runtimeSources []string) graph.NodeFilter { categories, categoryNone := intersectFilterValues(adminCategories, runtimeCategories) sources, sourceNone := intersectFilterValues(adminSources, runtimeSources) return graph.NodeFilter{Categories: categories, Sources: sources, MatchNone: categoryNone || sourceNone} } func intersectFilterValues(admin, runtime []string) ([]string, bool) { admin = normalizeValues(admin) runtime = normalizeValues(runtime) adminRestricted := dimensionRestricted(admin) runtimeRestricted := dimensionRestricted(runtime) if !adminRestricted && !runtimeRestricted { return nil, false } if adminRestricted && !runtimeRestricted { return admin, false } if !adminRestricted && runtimeRestricted { return runtime, false } allowed := make(map[string]string, len(admin)) for _, value := range admin { allowed[strings.ToLower(strings.TrimSpace(value))] = value } intersection := make([]string, 0) for _, value := range runtime { if canonical, ok := allowed[strings.ToLower(strings.TrimSpace(value))]; ok { intersection = append(intersection, canonical) } } intersection = normalizeValues(intersection) return intersection, len(intersection) == 0 } // Compatibility helpers retained for tests and internal callers that only need // the category projection. New code should use the scoped filters above. func (e *Engine) learningCategories() []string { return e.effectiveLearningFilter().Categories } func (e *Engine) thinkingCategories() []string { return e.effectiveThinkingFilter().Categories } 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}) } sortFilterInfo(out) return out } func (e *Engine) Sources() []SourceInfo { snapshot := e.Graph.Snapshot() counts := map[string]int{} names := map[string]string{} unsourced := 0 for _, node := range snapshot.Nodes { if node.Kind != "knowledge" && node.Kind != "ai-think" && node.Kind != "external" { continue } source := strings.TrimSpace(graph.NodeSource(node)) if source == "" { unsourced++ continue } key := strings.ToLower(source) if _, ok := names[key]; !ok { names[key] = source } counts[key]++ } result := make([]SourceInfo, 0, len(counts)+1) for key, count := range counts { result = append(result, SourceInfo{Name: names[key], Count: count}) } if unsourced > 0 { result = append(result, SourceInfo{Name: unsourcedFilter, Count: unsourced}) } sortFilterInfo(result) return result } func sortFilterInfo(out []CategoryInfo) { 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 }) }