+222
-37
@@ -7,10 +7,12 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/local/glpi-neural-brain/internal/graph"
|
||||
"github.com/local/glpi-neural-brain/internal/model"
|
||||
)
|
||||
|
||||
const uncategorizedFilter = "__uncategorized__"
|
||||
const uncategorizedFilter = graph.UncategorizedFilter
|
||||
const unsourcedFilter = graph.UnsourcedFilter
|
||||
|
||||
type RuntimeSettings struct {
|
||||
LearningEnabled bool `json:"learning_enabled"`
|
||||
@@ -18,33 +20,59 @@ type RuntimeSettings struct {
|
||||
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,
|
||||
LearningCategories: append([]string(nil), e.Cfg.LearningCategories...),
|
||||
DisplayCategories: append([]string(nil), e.Cfg.DisplayCategories...),
|
||||
ThinkingCategories: append([]string(nil), e.Cfg.ThinkingCategories...),
|
||||
ViewMode: e.Cfg.DefaultView,
|
||||
MaxDisplayNodes: e.Cfg.MaxDisplayNodes,
|
||||
LowPowerMode: e.Cfg.LowPowerMode,
|
||||
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 = normalizeCategories(in.LearningCategories)
|
||||
in.DisplayCategories = normalizeCategories(in.DisplayCategories)
|
||||
in.ThinkingCategories = normalizeCategories(in.ThinkingCategories)
|
||||
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"
|
||||
@@ -61,7 +89,7 @@ func normalizeRuntimeSettings(in RuntimeSettings) RuntimeSettings {
|
||||
return in
|
||||
}
|
||||
|
||||
func normalizeCategories(values []string) []string {
|
||||
func normalizeValues(values []string) []string {
|
||||
seen := map[string]string{}
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
@@ -81,21 +109,47 @@ func normalizeCategories(values []string) []string {
|
||||
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 {
|
||||
var stored RuntimeSettings
|
||||
if json.Unmarshal(data, &stored) == nil {
|
||||
settings = normalizeRuntimeSettings(stored)
|
||||
}
|
||||
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
|
||||
@@ -103,9 +157,58 @@ func (e *Engine) RuntimeSettings() RuntimeSettings {
|
||||
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")
|
||||
@@ -137,21 +240,28 @@ func (e *Engine) SetRuntimeSettings(settings RuntimeSettings) (RuntimeSettings,
|
||||
}
|
||||
}
|
||||
|
||||
view := e.RuntimeSettingsView()
|
||||
e.Broker.Publish(model.Activity{
|
||||
Type: "runtime.settings.updated",
|
||||
Source: "ui",
|
||||
Phase: "control",
|
||||
Message: "Lern-, Anzeige- und Thinking-Einstellungen wurden aktualisiert",
|
||||
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),
|
||||
"view_mode": settings.ViewMode,
|
||||
"max_display_nodes": settings.MaxDisplayNodes,
|
||||
"low_power_mode": settings.LowPowerMode,
|
||||
"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
|
||||
@@ -171,20 +281,60 @@ func (e *Engine) ThinkingEnabled() bool {
|
||||
return enabled
|
||||
}
|
||||
|
||||
func (e *Engine) learningCategories() []string {
|
||||
e.runtimeMu.RLock()
|
||||
out := append([]string(nil), e.runtime.LearningCategories...)
|
||||
e.runtimeMu.RUnlock()
|
||||
return out
|
||||
func (e *Engine) effectiveLearningFilter() graph.NodeFilter {
|
||||
settings := e.RuntimeSettings()
|
||||
return effectiveNodeFilter(e.Cfg.LearningCategories, settings.LearningCategories, e.Cfg.LearningSources, settings.LearningSources)
|
||||
}
|
||||
|
||||
func (e *Engine) thinkingCategories() []string {
|
||||
e.runtimeMu.RLock()
|
||||
out := append([]string(nil), e.runtime.ThinkingCategories...)
|
||||
e.runtimeMu.RUnlock()
|
||||
return out
|
||||
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{}
|
||||
@@ -222,11 +372,46 @@ func (e *Engine) Categories() []CategoryInfo {
|
||||
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
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user