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" ) type RuntimeSettings struct { SourceFilterVersion int `json:"source_filter_version"` LearningEnabled bool `json:"learning_enabled"` ThinkingEnabled bool `json:"thinking_enabled"` 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"` SpeedMode bool `json:"speed_mode"` SpeedCPUWorkers int `json:"speed_cpu_tasks"` SpeedGPUInflight int `json:"speed_gpu_tasks"` ProcessingMode string `json:"processing_mode"` AutonomousResearchEnabled bool `json:"autonomous_research_enabled"` AutonomousResearchIdleOnly bool `json:"autonomous_research_idle_only"` AutonomousResearchMinPriority float64 `json:"autonomous_research_min_priority"` AutonomousResearchMaxTasksPerDay int `json:"autonomous_research_max_tasks_per_day"` AutonomousResearchTasksPerCycle int `json:"autonomous_research_tasks_per_cycle"` } type RuntimeSettingsView struct { RuntimeSettings GLPIKBSource string `json:"glpi_kb_source"` } type SourceInfo struct { Name string `json:"name"` Count int `json:"count"` } func (e *Engine) defaultRuntimeSettings() RuntimeSettings { // Environment source lists are startup defaults only. Once a corresponding // field exists in runtime-settings.json, that persisted WebUI value is the // single authoritative selection. There is no ENV/WebUI intersection. return normalizeRuntimeSettings(RuntimeSettings{ SourceFilterVersion: 1, LearningEnabled: e.Cfg.LearningEnabled, ThinkingEnabled: e.Cfg.ThinkingEnabled, LearningSources: append([]string(nil), e.Cfg.LearningSources...), DisplaySources: append([]string(nil), e.Cfg.DisplaySources...), ThinkingSources: append([]string(nil), e.Cfg.ThinkingSources...), ViewMode: e.Cfg.DefaultView, MaxDisplayNodes: e.Cfg.MaxDisplayNodes, LowPowerMode: e.Cfg.LowPowerMode, SpeedMode: e.Cfg.SpeedMode, SpeedCPUWorkers: e.Cfg.SpeedCPUWorkers, SpeedGPUInflight: e.Cfg.SpeedGPUInflight, ProcessingMode: e.Cfg.ProcessingMode, AutonomousResearchEnabled: e.Cfg.AutonomousResearchEnabled, AutonomousResearchIdleOnly: e.Cfg.AutonomousResearchIdleOnly, AutonomousResearchMinPriority: e.Cfg.AutonomousResearchMinPriority, AutonomousResearchMaxTasksPerDay: e.Cfg.AutonomousResearchMaxTasksPerDay, AutonomousResearchTasksPerCycle: e.Cfg.AutonomousResearchTasksPerCycle, }) } func normalizeRuntimeSettings(in RuntimeSettings) RuntimeSettings { if in.SourceFilterVersion != 1 { in.SourceFilterVersion = 1 } in.LearningSources = normalizeSources(in.LearningSources) in.DisplaySources = normalizeSources(in.DisplaySources) in.ThinkingSources = normalizeSources(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" } in.ProcessingMode = strings.ToLower(strings.TrimSpace(in.ProcessingMode)) if in.ProcessingMode != "clustered" { in.ProcessingMode = "precise" } if in.MaxDisplayNodes < 0 { in.MaxDisplayNodes = 0 } if in.MaxDisplayNodes > 500000 { in.MaxDisplayNodes = 500000 } if in.SpeedCPUWorkers < 1 { in.SpeedCPUWorkers = 1 } if in.SpeedCPUWorkers > 256 { in.SpeedCPUWorkers = 256 } if in.SpeedGPUInflight < 1 { in.SpeedGPUInflight = 1 } if in.SpeedGPUInflight > 64 { in.SpeedGPUInflight = 64 } if in.SpeedMode { // Eco and Speed have opposite scheduling/rendering goals. Speed wins when // an older client accidentally submits both flags. in.LowPowerMode = false } if in.AutonomousResearchMinPriority < 0 { in.AutonomousResearchMinPriority = 0 } if in.AutonomousResearchMinPriority > 1 { in.AutonomousResearchMinPriority = 1 } if in.AutonomousResearchMaxTasksPerDay < 1 { in.AutonomousResearchMaxTasksPerDay = 1 } if in.AutonomousResearchMaxTasksPerDay > 500 { in.AutonomousResearchMaxTasksPerDay = 500 } if in.AutonomousResearchTasksPerCycle < 1 { in.AutonomousResearchTasksPerCycle = 1 } if in.AutonomousResearchTasksPerCycle > 8 { in.AutonomousResearchTasksPerCycle = 8 } return in } // normalizeSources preserves the exact source spelling used by KB files. Only // surrounding whitespace and byte-identical duplicates are removed. func normalizeSources(values []string) []string { seen := map[string]struct{}{} out := make([]string, 0, len(values)) for _, value := range values { value = strings.TrimSpace(value) if value == "" { continue } if _, exists := seen[value]; exists { continue } seen[value] = struct{}{} out = append(out, value) } sort.Slice(out, func(i, j int) bool { li, lj := strings.ToLower(out[i]), strings.ToLower(out[j]) if li == lj { return out[i] < out[j] } return li < lj }) return out } // loadRuntimeSettings merges individual persisted fields. Legacy category fields // and unversioned source selections are intentionally ignored so the previous // ambiguous filter system cannot silently survive this source-only migration. 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) var version int decode("source_filter_version", &version) if version == 1 { settings.SourceFilterVersion = 1 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) decode("speed_mode", &settings.SpeedMode) decode("speed_cpu_tasks", &settings.SpeedCPUWorkers) decode("speed_gpu_tasks", &settings.SpeedGPUInflight) decode("processing_mode", &settings.ProcessingMode) decode("autonomous_research_enabled", &settings.AutonomousResearchEnabled) decode("autonomous_research_idle_only", &settings.AutonomousResearchIdleOnly) decode("autonomous_research_min_priority", &settings.AutonomousResearchMinPriority) decode("autonomous_research_max_tasks_per_day", &settings.AutonomousResearchMaxTasksPerDay) decode("autonomous_research_tasks_per_cycle", &settings.AutonomousResearchTasksPerCycle) } func (e *Engine) RuntimeSettings() RuntimeSettings { e.runtimeMu.RLock() settings := e.runtime e.runtimeMu.RUnlock() 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 { return RuntimeSettingsView{RuntimeSettings: e.RuntimeSettings(), GLPIKBSource: strings.TrimSpace(e.Cfg.GLPIKBSource)} } // ApplyRuntimeSettingsJSON treats the request as a field-wise patch. This keeps // cached/older WebUI clients from resetting newly added settings merely because // their JSON payload does not contain the corresponding fields. func (e *Engine) ApplyRuntimeSettingsJSON(data []byte) (RuntimeSettings, error) { if !json.Valid(data) { return e.RuntimeSettings(), fmt.Errorf("invalid runtime settings JSON") } settings := e.RuntimeSettings() mergeRuntimeSettingsJSON(&settings, data) return e.SetRuntimeSettings(settings) } func (e *Engine) SetRuntimeSettings(settings RuntimeSettings) (RuntimeSettings, error) { previous := e.RuntimeSettings() // Older WebUI/API clients do not know the autonomous-research numeric fields. // Preserve the current values instead of rejecting an otherwise valid runtime // update with zero-value JSON fields. if settings.AutonomousResearchMaxTasksPerDay == 0 { settings.AutonomousResearchMaxTasksPerDay = previous.AutonomousResearchMaxTasksPerDay } if settings.AutonomousResearchTasksPerCycle == 0 { settings.AutonomousResearchTasksPerCycle = previous.AutonomousResearchTasksPerCycle } if settings.SpeedCPUWorkers == 0 { settings.SpeedCPUWorkers = previous.SpeedCPUWorkers } if settings.SpeedGPUInflight == 0 { settings.SpeedGPUInflight = previous.SpeedGPUInflight } if settings.ProcessingMode == "" { settings.ProcessingMode = previous.ProcessingMode } if settings.ProcessingMode != "precise" && settings.ProcessingMode != "clustered" { return e.RuntimeSettings(), fmt.Errorf("processing_mode must be precise or clustered") } if settings.MaxDisplayNodes < 0 || settings.MaxDisplayNodes > 500000 { return e.RuntimeSettings(), fmt.Errorf("max_display_nodes must be between 0 and 500000") } if settings.SpeedCPUWorkers < 1 || settings.SpeedCPUWorkers > 256 { return e.RuntimeSettings(), fmt.Errorf("speed_cpu_tasks must be between 1 and 256") } if settings.SpeedGPUInflight < 1 || settings.SpeedGPUInflight > 64 { return e.RuntimeSettings(), fmt.Errorf("speed_gpu_tasks must be between 1 and 64") } if settings.AutonomousResearchMinPriority < 0 || settings.AutonomousResearchMinPriority > 1 { return e.RuntimeSettings(), fmt.Errorf("autonomous_research_min_priority must be between 0 and 1") } if settings.AutonomousResearchMaxTasksPerDay < 1 || settings.AutonomousResearchMaxTasksPerDay > 500 { return e.RuntimeSettings(), fmt.Errorf("autonomous_research_max_tasks_per_day must be between 1 and 500") } if settings.AutonomousResearchTasksPerCycle < 1 || settings.AutonomousResearchTasksPerCycle > 8 { return e.RuntimeSettings(), fmt.Errorf("autonomous_research_tasks_per_cycle must be between 1 and 8") } if settings.AutonomousResearchEnabled && e.Research == nil { return e.RuntimeSettings(), fmt.Errorf("autonomous research requires SEARXNG_URL") } settings = normalizeRuntimeSettings(settings) e.runtimeMu.Lock() previous = e.runtime e.runtime = settings e.runtimeMu.Unlock() e.applyRuntimePerformance(settings) if settings.SpeedMode && !previous.SpeedMode { e.signalSpeedWork() } if settings.AutonomousResearchEnabled && !previous.AutonomousResearchEnabled { e.signalAutonomousResearch() } 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() e.applyRuntimePerformance(previous) return previous, err } } e.Broker.Publish(model.Activity{ Type: "runtime.settings.updated", Source: "ui", Phase: "control", Message: "Laufzeitmodi und exakte KB-Quellenfilter wurden aktualisiert", Strength: .32, Metadata: map[string]any{ "source_filter_version": settings.SourceFilterVersion, "learning_enabled": settings.LearningEnabled, "thinking_enabled": settings.ThinkingEnabled, "learning_sources": len(settings.LearningSources), "display_sources": len(settings.DisplaySources), "thinking_sources": len(settings.ThinkingSources), "view_mode": settings.ViewMode, "max_display_nodes": settings.MaxDisplayNodes, "low_power_mode": settings.LowPowerMode, "speed_mode": settings.SpeedMode, "speed_cpu_tasks": settings.SpeedCPUWorkers, "speed_gpu_tasks": settings.SpeedGPUInflight, "processing_mode": settings.ProcessingMode, "autonomous_research_enabled": settings.AutonomousResearchEnabled, "autonomous_research_idle_only": settings.AutonomousResearchIdleOnly, "autonomous_research_min_priority": settings.AutonomousResearchMinPriority, "autonomous_research_max_tasks_per_day": settings.AutonomousResearchMaxTasksPerDay, "autonomous_research_tasks_per_cycle": settings.AutonomousResearchTasksPerCycle, }, }) return settings, nil } func (e *Engine) SpeedModeEnabled() bool { e.runtimeMu.RLock() enabled := e.runtime.SpeedMode e.runtimeMu.RUnlock() return enabled } 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) AutonomousResearchEnabled() bool { e.runtimeMu.RLock() enabled := e.runtime.AutonomousResearchEnabled e.runtimeMu.RUnlock() return enabled } func (e *Engine) ResearchEnabledForRuntime() bool { return e != nil && e.Research != nil && (e.Cfg.ResearchEnabled || e.AutonomousResearchEnabled()) } func (e *Engine) effectiveLearningFilter() graph.NodeFilter { return graph.NodeFilter{Sources: e.RuntimeSettings().LearningSources} } func (e *Engine) effectiveDisplayFilter() graph.NodeFilter { return graph.NodeFilter{Sources: e.RuntimeSettings().DisplaySources} } func (e *Engine) effectiveThinkingFilter() graph.NodeFilter { return graph.NodeFilter{Sources: e.RuntimeSettings().ThinkingSources} } // Sources lists exact source values seen on knowledge-bearing nodes. The GLPI // source configured through GLPI_KB_SOURCE is always present, even before the // first GLPI sync or while GLPI ingestion is disabled. func (e *Engine) Sources() []SourceInfo { return sourceInfos(e.Graph.Snapshot(), e.Cfg.GLPIKBSource) } func sourceInfos(snapshot model.Snapshot, configuredGLPI string) []SourceInfo { counts := map[string]int{} glpiSource := strings.TrimSpace(configuredGLPI) if glpiSource != "" { counts[glpiSource] = 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 == "" { continue } counts[source]++ } result := make([]SourceInfo, 0, len(counts)) for name, count := range counts { result = append(result, SourceInfo{Name: name, Count: count}) } sort.Slice(result, func(i, j int) bool { if result[i].Count == result[j].Count { li, lj := strings.ToLower(result[i].Name), strings.ToLower(result[j].Name) if li == lj { return result[i].Name < result[j].Name } return li < lj } return result[i].Count > result[j].Count }) return result }