Files
glpi-neural-brain/internal/engine/runtime.go
groot 31d3efb8a2
All checks were successful
release-tag / release-image (push) Successful in 2m17s
Bugfixes
2026-08-05 10:15:56 +02:00

418 lines
14 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"
)
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
})
}