BugFix
release-tag / release-image (push) Successful in 2m24s

This commit is contained in:
2026-08-05 11:58:25 +02:00
parent 31d3efb8a2
commit b3bd3d5ffd
25 changed files with 679 additions and 719 deletions
+2 -6
View File
@@ -1198,7 +1198,7 @@ func researchResultNode(id string, result model.ResearchResult, evidencePath, co
content = result.Snippet
}
weight := .8 + result.Relevance*.35 + result.SourceQualityScore*.25
metadata := map[string]any{"query": result.Query, "language": result.Language, "round": result.Round, "fetched": result.Fetched, "content_type": result.ContentType, "relevant": result.Relevant, "relevance": result.Relevance, "source_quality": result.SourceQuality, "source_quality_score": result.SourceQualityScore, "actionable": result.Actionable, "covered_gap_ids": result.CoveredGapIDs, "assessment_reason": result.AssessmentReason}
metadata := map[string]any{"source": graph.SourceFromURL(result.URL), "query": result.Query, "language": result.Language, "round": result.Round, "fetched": result.Fetched, "content_type": result.ContentType, "relevant": result.Relevant, "relevance": result.Relevance, "source_quality": result.SourceQuality, "source_quality_score": result.SourceQualityScore, "actionable": result.Actionable, "covered_gap_ids": result.CoveredGapIDs, "assessment_reason": result.AssessmentReason}
if evidencePath != "" {
metadata["evidence_path"] = evidencePath
metadata["evidence_schema"] = researchEvidenceSchemaVersion
@@ -1213,7 +1213,7 @@ func (e *Engine) filterResearchEvidenceForThinking(results []model.ResearchResul
filter := e.effectiveThinkingFilter()
out := make([]model.ResearchResult, 0, len(results))
for _, result := range results {
node := model.Node{Kind: "external", Origin: "research", URI: result.URL, ExternalID: result.URL, Categories: categories}
node := model.Node{Kind: "external", Origin: "research", URI: result.URL, ExternalID: result.URL, Categories: categories, Metadata: map[string]any{"source": graph.SourceFromURL(result.URL)}}
if filter.Matches(node) {
out = append(out, result)
}
@@ -1444,10 +1444,6 @@ func categoryAffinity(node model.Node, seeds []model.Node) float64 {
return float64(matches)
}
func matchesCategories(node model.Node, filters []string) bool {
return (graph.NodeFilter{Categories: filters}).Matches(node)
}
func metadataString(metadata map[string]any, key string) string {
if metadata == nil {
return ""
+2 -1
View File
@@ -12,6 +12,7 @@ import (
"time"
"unicode"
"github.com/local/glpi-neural-brain/internal/graph"
"github.com/local/glpi-neural-brain/internal/model"
"github.com/local/glpi-neural-brain/internal/research"
)
@@ -495,7 +496,7 @@ func (e *Engine) executeArticleResearchQuery(ctx context.Context, trigger string
if question.ExpectActionable && !assessment.Actionable {
acceptedByGate = false
}
researchNode := model.Node{Kind: "external", Origin: "research", URI: item.URL, ExternalID: item.URL, Categories: researchCategories}
researchNode := model.Node{Kind: "external", Origin: "research", URI: item.URL, ExternalID: item.URL, Categories: researchCategories, Metadata: map[string]any{"source": graph.SourceFromURL(item.URL)}}
if !thinkingFilter.Matches(researchNode) {
acceptedByGate = false
if strings.TrimSpace(item.AssessmentReason) == "" {
+4 -1
View File
@@ -78,6 +78,9 @@ type Engine struct {
}
func New(cfg config.Config, g *graph.Store, b *activity.Broker) *Engine {
if strings.TrimSpace(cfg.GLPIKBSource) == "" {
cfg.GLPIKBSource = "GLPI Knowledge Base"
}
if !cfg.RuntimeDefaultsConfigured {
cfg.LearningEnabled = true
cfg.ThinkingEnabled = true
@@ -746,7 +749,7 @@ func (e *Engine) addResearch(a, b model.Node, results []model.ResearchResult) re
refs := researchGraphRefs{}
for _, r := range results {
id := graph.ID("external", r.URL)
n := model.Node{ID: id, Kind: "external", Label: r.Title, Summary: clamp(r.Content, 700), Status: "research", Origin: "research", ExternalID: r.URL, URI: r.URL, Categories: unique(append(append([]string{}, a.Categories...), b.Categories...)), Weight: .8, Metadata: map[string]any{"query_pair": []string{a.ID, b.ID}}, UpdatedAt: time.Now().UTC()}
n := model.Node{ID: id, Kind: "external", Label: r.Title, Summary: clamp(r.Content, 700), Status: "research", Origin: "research", ExternalID: r.URL, URI: r.URL, Categories: unique(append(append([]string{}, a.Categories...), b.Categories...)), Weight: .8, Metadata: map[string]any{"source": graph.SourceFromURL(r.URL), "query_pair": []string{a.ID, b.ID}}, UpdatedAt: time.Now().UTC()}
e.Graph.UpsertNode(n)
refs.NodeIDs = append(refs.NodeIDs, id)
for _, targetID := range []string{a.ID, b.ID} {
+17 -13
View File
@@ -296,7 +296,14 @@ func TestSynthesisResearchesUnclearKnowledgeThenLearnsAndLinksArticle(t *testing
t.Fatal("accepted web evidence was not learned immediately")
}
researchNode, ok := g.GetNode(researchID)
if !ok || !matchesCategories(researchNode, []string{"VPN"}) {
hasVPN := false
for _, category := range researchNode.Categories {
if strings.EqualFold(strings.TrimSpace(category), "VPN") {
hasVPN = true
break
}
}
if !ok || !hasVPN {
t.Fatalf("accepted web evidence did not inherit source categories: %+v", researchNode)
}
evidenceFiles, err := filepath.Glob(filepath.Join(data, "research-evidence", "*.json"))
@@ -387,17 +394,14 @@ func TestRuntimeSettingsDisableThinkingAndPersist(t *testing.T) {
cfg := config.Config{DataDir: data, RuntimeDefaultsConfigured: true, LearningEnabled: true, ThinkingEnabled: true, DefaultView: "neural", PersistInterval: time.Minute}
e := New(cfg, g, activity.New(20))
updated, err := e.SetRuntimeSettings(RuntimeSettings{
LearningEnabled: false,
ThinkingEnabled: false,
LearningCategories: []string{"Netzwerk"},
DisplayCategories: []string{"GLPI KB"},
ThinkingCategories: []string{"Netzwerk"},
LearningSources: []string{"internal-kb"},
DisplaySources: []string{"GLPI Knowledge Base"},
ThinkingSources: []string{"internal-kb"},
ViewMode: "constellation",
MaxDisplayNodes: 4321,
LowPowerMode: true,
LearningEnabled: false,
ThinkingEnabled: false,
LearningSources: []string{"internal-kb"},
DisplaySources: []string{"GLPI Knowledge Base"},
ThinkingSources: []string{"internal-kb"},
ViewMode: "constellation",
MaxDisplayNodes: 4321,
LowPowerMode: true,
})
if err != nil {
t.Fatal(err)
@@ -421,7 +425,7 @@ func TestRuntimeSettingsDisableThinkingAndPersist(t *testing.T) {
}
e2 := New(cfg, g2, activity.New(20))
loaded := e2.RuntimeSettings()
if loaded.LearningEnabled || loaded.ThinkingEnabled || loaded.ViewMode != "constellation" || loaded.MaxDisplayNodes != 4321 || !loaded.LowPowerMode || len(loaded.DisplayCategories) != 1 || len(loaded.DisplaySources) != 1 {
if loaded.LearningEnabled || loaded.ThinkingEnabled || loaded.ViewMode != "constellation" || loaded.MaxDisplayNodes != 4321 || !loaded.LowPowerMode || len(loaded.DisplaySources) != 1 {
t.Fatalf("runtime settings were not restored: %+v", loaded)
}
}
+95 -239
View File
@@ -11,68 +11,52 @@ import (
"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"`
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"`
}
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"`
GLPIKBSource string `json:"glpi_kb_source"`
}
type CategoryInfo struct {
type SourceInfo 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.
// 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{
LearningEnabled: e.Cfg.LearningEnabled,
ThinkingEnabled: e.Cfg.ThinkingEnabled,
ViewMode: e.Cfg.DefaultView,
MaxDisplayNodes: e.Cfg.MaxDisplayNodes,
LowPowerMode: e.Cfg.LowPowerMode,
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,
})
}
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)
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"
@@ -89,31 +73,35 @@ func normalizeRuntimeSettings(in RuntimeSettings) RuntimeSettings {
return in
}
func normalizeValues(values []string) []string {
seen := map[string]string{}
// 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
}
key := strings.ToLower(value)
if _, exists := seen[key]; !exists {
seen[key] = value
if _, exists := seen[value]; exists {
continue
}
}
out := make([]string, 0, len(seen))
for _, value := range seen {
seen[value] = struct{}{}
out = append(out, value)
}
sort.Slice(out, func(i, j int) bool { return strings.ToLower(out[i]) < strings.ToLower(out[j]) })
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
}
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.
// 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) != "" {
@@ -139,12 +127,14 @@ func mergeRuntimeSettingsJSON(settings *RuntimeSettings, data []byte) {
}
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)
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)
@@ -154,9 +144,6 @@ 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...)
@@ -164,49 +151,7 @@ func (e *Engine) RuntimeSettings() RuntimeSettings {
}
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
return RuntimeSettingsView{RuntimeSettings: e.RuntimeSettings(), GLPIKBSource: strings.TrimSpace(e.Cfg.GLPIKBSource)}
}
func (e *Engine) SetRuntimeSettings(settings RuntimeSettings) (RuntimeSettings, error) {
@@ -240,28 +185,22 @@ 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-, Quellen- und Thinking-Einstellungen wurden aktualisiert",
Message: "Laufzeitmodi und exakte KB-Quellenfilter 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,
"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,
},
})
return settings, nil
@@ -282,136 +221,53 @@ func (e *Engine) ThinkingEnabled() bool {
}
func (e *Engine) effectiveLearningFilter() graph.NodeFilter {
settings := e.RuntimeSettings()
return effectiveNodeFilter(e.Cfg.LearningCategories, settings.LearningCategories, e.Cfg.LearningSources, settings.LearningSources)
return graph.NodeFilter{Sources: e.RuntimeSettings().LearningSources}
}
func (e *Engine) effectiveDisplayFilter() graph.NodeFilter {
settings := e.RuntimeSettings()
return effectiveNodeFilter(e.Cfg.DisplayCategories, settings.DisplayCategories, e.Cfg.DisplaySources, settings.DisplaySources)
return graph.NodeFilter{Sources: e.RuntimeSettings().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
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 {
snapshot := e.Graph.Snapshot()
return sourceInfos(e.Graph.Snapshot(), e.Cfg.GLPIKBSource)
}
func sourceInfos(snapshot model.Snapshot, configuredGLPI string) []SourceInfo {
counts := map[string]int{}
names := map[string]string{}
unsourced := 0
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 == "" {
unsourced++
continue
}
key := strings.ToLower(source)
if _, ok := names[key]; !ok {
names[key] = source
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
}
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[i].Count > result[j].Count
})
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
})
}
+54 -29
View File
@@ -3,43 +3,68 @@ package engine
import (
"testing"
"github.com/local/glpi-neural-brain/internal/config"
"github.com/local/glpi-neural-brain/internal/model"
)
func TestEffectiveFilterIsEnvironmentIntersectionRuntime(t *testing.T) {
filter := effectiveNodeFilter(
[]string{"IT-Security", "Netzwerk"}, []string{"Netzwerk", "Backup"},
[]string{"GLPI Knowledge Base", "internal-kb"}, []string{"internal-kb"},
)
if filter.MatchNone {
t.Fatal("intersection should not be empty")
func TestRuntimeUsesSingleExactSourceSelection(t *testing.T) {
e := &Engine{Cfg: config.Config{LearningSources: []string{"ENV-only"}}}
e.runtime = RuntimeSettings{SourceFilterVersion: 1, LearningSources: []string{"internal-category"}}
filter := e.effectiveLearningFilter()
if len(filter.Sources) != 1 || filter.Sources[0] != "internal-category" {
t.Fatalf("runtime source should be authoritative without ENV intersection: %+v", filter)
}
if len(filter.Categories) != 1 || filter.Categories[0] != "Netzwerk" {
t.Fatalf("unexpected category intersection: %#v", filter.Categories)
}
if len(filter.Sources) != 1 || filter.Sources[0] != "internal-kb" {
t.Fatalf("unexpected source intersection: %#v", filter.Sources)
matching := model.Node{Metadata: map[string]any{"source": "internal-category"}}
wrongCase := model.Node{Metadata: map[string]any{"source": "Internal-Category"}}
if !filter.Matches(matching) || filter.Matches(wrongCase) {
t.Fatal("source matching is not exact")
}
}
func TestEffectiveFilterDisjointSelectionMatchesNothing(t *testing.T) {
filter := effectiveNodeFilter([]string{"IT-Security"}, []string{"Backup"}, nil, nil)
if !filter.MatchNone {
t.Fatalf("disjoint administrative/runtime filters must match none: %+v", filter)
}
node := model.Node{Kind: "knowledge", Categories: []string{"IT-Security"}, Metadata: map[string]any{"source": "internal-kb"}}
if filter.Matches(node) {
t.Fatal("empty intersection must not fall back to all")
}
}
func TestRuntimeJSONFieldMergePreservesDefaults(t *testing.T) {
settings := RuntimeSettings{LearningEnabled: true, ThinkingEnabled: true, ViewMode: "constellation", MaxDisplayNodes: 5000, LowPowerMode: true}
mergeRuntimeSettingsJSON(&settings, []byte(`{"display_categories":["Cloud"],"display_sources":["internal-kb"]}`))
func TestLegacyRuntimeFilterFieldsAreReset(t *testing.T) {
settings := RuntimeSettings{SourceFilterVersion: 1, LearningEnabled: true, ThinkingEnabled: true, LearningSources: []string{"ENV-default"}, DisplaySources: []string{"ENV-display"}, ViewMode: "constellation", MaxDisplayNodes: 5000, LowPowerMode: true}
mergeRuntimeSettingsJSON(&settings, []byte(`{"display_categories":["Cloud"],"display_sources":["glpi-kb"]}`))
if !settings.LearningEnabled || !settings.ThinkingEnabled || settings.ViewMode != "constellation" || settings.MaxDisplayNodes != 5000 || !settings.LowPowerMode {
t.Fatalf("missing JSON fields overwrote defaults: %+v", settings)
t.Fatalf("legacy JSON overwrote unrelated defaults: %+v", settings)
}
if len(settings.DisplayCategories) != 1 || len(settings.DisplaySources) != 1 {
t.Fatalf("present JSON fields were not merged: %+v", settings)
if len(settings.LearningSources) != 1 || settings.LearningSources[0] != "ENV-default" || len(settings.DisplaySources) != 1 || settings.DisplaySources[0] != "ENV-display" {
t.Fatalf("legacy source filters should be ignored during migration: %+v", settings)
}
}
func TestVersionedRuntimeSourceFieldsAreLoaded(t *testing.T) {
settings := RuntimeSettings{SourceFilterVersion: 1, LearningSources: []string{"ENV-default"}}
mergeRuntimeSettingsJSON(&settings, []byte(`{"source_filter_version":1,"learning_sources":["internal-category"],"display_sources":["GLPI Knowledge Base"],"thinking_sources":["docs.example.org"]}`))
if len(settings.LearningSources) != 1 || settings.LearningSources[0] != "internal-category" || len(settings.DisplaySources) != 1 || settings.DisplaySources[0] != "GLPI Knowledge Base" || len(settings.ThinkingSources) != 1 || settings.ThinkingSources[0] != "docs.example.org" {
t.Fatalf("versioned source fields were not loaded: %+v", settings)
}
}
func TestSourceInfosUseExactMetadataAndAlwaysIncludeConfiguredGLPI(t *testing.T) {
snapshot := model.Snapshot{Nodes: []model.Node{
{ID: "a", Kind: "knowledge", Origin: "glpi-kb", Metadata: map[string]any{"source": "GLPI Custom"}},
{ID: "b", Kind: "knowledge", Origin: "internal-category", Metadata: map[string]any{"source": "internal-category"}},
{ID: "c", Kind: "knowledge", Origin: "internal-category"},
{ID: "taxonomy", Kind: "source", Label: "must-not-count"},
}}
infos := sourceInfos(snapshot, "Configured GLPI")
counts := map[string]int{}
for _, info := range infos {
counts[info.Name] = info.Count
}
if counts["Configured GLPI"] != 0 {
t.Fatalf("configured GLPI source should be present with zero count: %+v", infos)
}
if counts["GLPI Custom"] != 1 || counts["internal-category"] != 1 {
t.Fatalf("source values were not counted exactly: %+v", infos)
}
if _, exists := counts["glpi-kb"]; exists {
t.Fatalf("technical origin leaked into source options: %+v", infos)
}
if _, exists := counts["must-not-count"]; exists {
t.Fatalf("taxonomy node leaked into source options: %+v", infos)
}
if _, exists := counts[""]; exists {
t.Fatalf("nodes without source must not become a synthetic filter option: %+v", infos)
}
}