All checks were successful
release-tag / release-image (push) Successful in 2m32s
429 lines
16 KiB
Go
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
|
|
}
|