Files
jbergner 94dbd4ccab
All checks were successful
release-tag / release-image (push) Successful in 2m32s
RC-4
2026-08-09 18:41:47 +02:00

429 lines
16 KiB
Go

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
}