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
-6
View File
@@ -65,9 +65,6 @@ type Config struct {
APIKey string
LearningEnabled bool
ThinkingEnabled bool
LearningCategories []string
DisplayCategories []string
ThinkingCategories []string
LearningSources []string
DisplaySources []string
ThinkingSources []string
@@ -161,9 +158,6 @@ func Load() (Config, error) {
APIKey: strings.TrimSpace(os.Getenv("BRAIN_API_KEY")),
LearningEnabled: boolean("BRAIN_LEARNING_ENABLED", true),
ThinkingEnabled: boolean("BRAIN_THINKING_ENABLED", true),
LearningCategories: stringList("BRAIN_LEARNING_CATEGORIES"),
DisplayCategories: stringList("BRAIN_DISPLAY_CATEGORIES"),
ThinkingCategories: stringList("BRAIN_THINKING_CATEGORIES"),
LearningSources: stringList("BRAIN_LEARNING_SOURCES"),
DisplaySources: stringList("BRAIN_DISPLAY_SOURCES"),
ThinkingSources: stringList("BRAIN_THINKING_SOURCES"),
-6
View File
@@ -42,9 +42,6 @@ func TestLoadRuntimeControlDefaultsAndFilters(t *testing.T) {
t.Setenv("BRAIN_DATA_DIR", t.TempDir())
t.Setenv("BRAIN_LEARNING_ENABLED", "false")
t.Setenv("BRAIN_THINKING_ENABLED", "false")
t.Setenv("BRAIN_LEARNING_CATEGORIES", "Netzwerk,GLPI KB")
t.Setenv("BRAIN_DISPLAY_CATEGORIES", "GLPI KB")
t.Setenv("BRAIN_THINKING_CATEGORIES", "Netzwerk")
t.Setenv("BRAIN_LEARNING_SOURCES", "GLPI Knowledge Base,internal-kb")
t.Setenv("BRAIN_DISPLAY_SOURCES", "GLPI Knowledge Base")
t.Setenv("BRAIN_THINKING_SOURCES", "internal-kb")
@@ -58,9 +55,6 @@ func TestLoadRuntimeControlDefaultsAndFilters(t *testing.T) {
if cfg.LearningEnabled || cfg.ThinkingEnabled || cfg.DefaultView != "constellation" || cfg.MaxDisplayNodes != 12000 || !cfg.LowPowerMode {
t.Fatalf("unexpected runtime defaults: %+v", cfg)
}
if len(cfg.LearningCategories) != 2 || len(cfg.DisplayCategories) != 1 || len(cfg.ThinkingCategories) != 1 {
t.Fatalf("unexpected category defaults: %+v", cfg)
}
if len(cfg.LearningSources) != 2 || len(cfg.DisplaySources) != 1 || len(cfg.ThinkingSources) != 1 {
t.Fatalf("unexpected source defaults: %+v", cfg)
}
+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)
}
}
+24 -73
View File
@@ -8,96 +8,47 @@ import (
"github.com/local/glpi-neural-brain/internal/model"
)
const (
UncategorizedFilter = "__uncategorized__"
UnsourcedFilter = "__unsourced__"
)
// NodeFilter limits knowledge-bearing nodes by category and logical source.
// Categories and Sources are AND-combined; values inside each dimension use
// OR semantics. Empty dimensions and "*" mean unrestricted. MatchNone is used
// for an empty administrative/runtime intersection and deliberately matches no
// node.
// NodeFilter limits knowledge-bearing nodes by the exact value stored in the
// KB document's source field. Values are compared case-sensitively after
// trimming surrounding whitespace. An empty Sources list means unrestricted.
// No category, origin, URI or taxonomy fallback participates in matching.
type NodeFilter struct {
Categories []string
Sources []string
MatchNone bool
Sources []string
}
func (f NodeFilter) Matches(node model.Node) bool {
if f.MatchNone {
return false
}
return matchesDimension(node.Categories, f.Categories, UncategorizedFilter) &&
matchesDimension(nodeSources(node), f.Sources, UnsourcedFilter)
}
func matchesDimension(values, filters []string, emptyToken string) bool {
if len(filters) == 0 {
if len(f.Sources) == 0 {
return true
}
wanted := make(map[string]struct{}, len(filters))
for _, filter := range filters {
filter = strings.ToLower(strings.TrimSpace(filter))
if filter != "" {
wanted[filter] = struct{}{}
source := strings.TrimSpace(NodeSource(node))
for _, wanted := range f.Sources {
wanted = strings.TrimSpace(wanted)
if wanted == "" {
continue
}
}
if len(wanted) == 0 {
return true
}
if _, ok := wanted["*"]; ok {
return true
}
clean := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value != "" {
clean = append(clean, value)
}
}
if len(clean) == 0 {
_, ok := wanted[emptyToken]
return ok
}
for _, value := range clean {
if _, ok := wanted[strings.ToLower(value)]; ok {
if source == wanted {
return true
}
}
return false
}
// NodeSource returns the logical source shown to users. Explicit source
// metadata wins. Research nodes fall back to their host name; all other nodes
// fall back to their technical origin.
// NodeSource returns only the explicit source metadata copied from the KB
// file's top-level source field (or assigned explicitly to generated research
// evidence). Technical origins such as glpi-kb are deliberately ignored.
func NodeSource(node model.Node) string {
values := nodeSources(node)
if len(values) == 0 {
return ""
}
return values[0]
return metadataText(node.Metadata, "source")
}
func nodeSources(node model.Node) []string {
if source := metadataText(node.Metadata, "source"); source != "" {
return []string{source}
// SourceFromURL derives the explicit source value used for learned web
// evidence. It is kept separate from NodeSource so ordinary nodes never fall
// back to their URI implicitly.
func SourceFromURL(raw string) string {
parsed, err := url.Parse(strings.TrimSpace(raw))
if err != nil {
return ""
}
if node.Kind == "source" && strings.TrimSpace(node.Label) != "" {
return []string{strings.TrimSpace(node.Label)}
}
if node.Kind == "external" {
for _, raw := range []string{node.URI, node.ExternalID} {
parsed, err := url.Parse(strings.TrimSpace(raw))
if err == nil && strings.TrimSpace(parsed.Hostname()) != "" {
return []string{strings.ToLower(strings.TrimSpace(parsed.Hostname()))}
}
}
}
if strings.TrimSpace(node.Origin) != "" {
return []string{strings.TrimSpace(node.Origin)}
}
return nil
return strings.ToLower(strings.TrimSpace(parsed.Hostname()))
}
func metadataText(metadata map[string]any, key string) string {
+25 -22
View File
@@ -6,38 +6,41 @@ import (
"github.com/local/glpi-neural-brain/internal/model"
)
func TestNodeFilterCombinesCategoryAndSource(t *testing.T) {
func TestNodeFilterMatchesExactSourceOnly(t *testing.T) {
node := model.Node{
Kind: "knowledge",
Categories: []string{"IT-Security", "Cloud"},
Origin: "knowledge-production",
Origin: "glpi-kb",
Metadata: map[string]any{"source": "GLPI Knowledge Base"},
}
if !(NodeFilter{Categories: []string{"Cloud"}, Sources: []string{"glpi knowledge base"}}).Matches(node) {
t.Fatal("matching category and source should be accepted case-insensitively")
if !(NodeFilter{Sources: []string{"GLPI Knowledge Base"}}).Matches(node) {
t.Fatal("exact source should match")
}
if (NodeFilter{Categories: []string{"Cloud"}, Sources: []string{"internal-kb"}}).Matches(node) {
t.Fatal("source mismatch must reject even when category matches")
if (NodeFilter{Sources: []string{"glpi knowledge base"}}).Matches(node) {
t.Fatal("source matching must be case-sensitive and exact")
}
if (NodeFilter{Categories: []string{"Backup"}, Sources: []string{"GLPI Knowledge Base"}}).Matches(node) {
t.Fatal("category mismatch must reject even when source matches")
if (NodeFilter{Sources: []string{"glpi-kb"}}).Matches(node) {
t.Fatal("technical origin must not be treated as source")
}
if (NodeFilter{MatchNone: true}).Matches(node) {
t.Fatal("MatchNone must reject every node")
if !(NodeFilter{}).Matches(node) {
t.Fatal("empty source list must be unrestricted")
}
}
func TestNodeFilterVirtualEmptyValuesAndResearchHost(t *testing.T) {
unsourced := model.Node{Kind: "knowledge"}
if !(NodeFilter{Categories: []string{UncategorizedFilter}, Sources: []string{UnsourcedFilter}}).Matches(unsourced) {
t.Fatal("virtual empty category/source filters should match")
func TestNodeFilterUnsourcedAndNoFallback(t *testing.T) {
unsourced := model.Node{Kind: "knowledge", Origin: "internal-category", URI: "https://example.test/a"}
if NodeSource(unsourced) != "" {
t.Fatalf("origin or URI leaked into source: %q", NodeSource(unsourced))
}
research := model.Node{Kind: "external", Origin: "research", URI: "https://docs.example.org/guide", Categories: []string{"Cloud"}}
if (NodeFilter{Sources: []string{"internal-category"}}).Matches(unsourced) {
t.Fatal("node without source must not match a concrete source")
}
research := model.Node{Kind: "external", Origin: "research", URI: "https://docs.example.org/guide", Metadata: map[string]any{"source": SourceFromURL("https://docs.example.org/guide")}}
if got := NodeSource(research); got != "docs.example.org" {
t.Fatalf("unexpected research source %q", got)
t.Fatalf("unexpected explicit research source %q", got)
}
if !(NodeFilter{Sources: []string{"DOCS.EXAMPLE.ORG"}}).Matches(research) {
t.Fatal("research host should be source-filterable")
if !(NodeFilter{Sources: []string{"docs.example.org"}}).Matches(research) {
t.Fatal("explicit research source should be filterable")
}
}
@@ -45,7 +48,7 @@ func TestScopedEmbeddingRetrievalAndThinking(t *testing.T) {
s := &Store{
nodes: map[string]model.Node{
"a": {ID: "a", Kind: "knowledge", Label: "A", Categories: []string{"Cloud"}, Metadata: map[string]any{"source": "internal-kb"}},
"b": {ID: "b", Kind: "knowledge", Label: "B", Categories: []string{"Cloud"}, Metadata: map[string]any{"source": "internal-kb"}},
"b": {ID: "b", Kind: "knowledge", Label: "B", Categories: []string{"Backup"}, Metadata: map[string]any{"source": "internal-kb"}},
"c": {ID: "c", Kind: "knowledge", Label: "C", Categories: []string{"Cloud"}, Metadata: map[string]any{"source": "GLPI Knowledge Base"}},
},
edges: map[string]model.Edge{},
@@ -57,14 +60,14 @@ func TestScopedEmbeddingRetrievalAndThinking(t *testing.T) {
deletedEdges: map[string]uint64{},
deletedVectors: map[string]uint64{},
}
filter := NodeFilter{Categories: []string{"Cloud"}, Sources: []string{"internal-kb"}}
filter := NodeFilter{Sources: []string{"internal-kb"}}
hits := s.SimilarFiltered([]float64{1, 0}, 10, filter)
if len(hits) != 2 {
t.Fatalf("retrieval escaped source filter: %+v", hits)
t.Fatalf("retrieval escaped exact source filter: %+v", hits)
}
a, b, _, ok, _ := s.NextPairScopedDepth(.5, 8, filter, 0)
if !ok || NodeSource(a) != "internal-kb" || NodeSource(b) != "internal-kb" {
t.Fatalf("thinking escaped scoped filter: ok=%v a=%+v b=%+v", ok, a, b)
t.Fatalf("thinking escaped source filter: ok=%v a=%+v b=%+v", ok, a, b)
}
if pending := s.NodesForEmbeddingScoped(NodeFilter{Sources: []string{"GLPI Knowledge Base"}}); len(pending) != 0 {
t.Fatalf("nodes with existing vectors should not be pending: %+v", pending)
+6 -6
View File
@@ -235,8 +235,8 @@ func (s *Store) NodesForEmbedding() []model.Node {
return s.NodesForEmbeddingScoped(NodeFilter{})
}
func (s *Store) NodesForEmbeddingFiltered(categories []string) []model.Node {
return s.NodesForEmbeddingScoped(NodeFilter{Categories: categories})
func (s *Store) NodesForEmbeddingFiltered(sources []string) []model.Node {
return s.NodesForEmbeddingScoped(NodeFilter{Sources: sources})
}
func (s *Store) NodesForEmbeddingScoped(filter NodeFilter) []model.Node {
@@ -547,12 +547,12 @@ func (s *Store) NextPair(min float64, anchorLimit int) (model.Node, model.Node,
return s.NextPairFiltered(min, anchorLimit, nil)
}
func (s *Store) NextPairFiltered(min float64, anchorLimit int, categories []string) (model.Node, model.Node, float64, bool, int) {
return s.NextPairFilteredDepth(min, anchorLimit, categories, 0)
func (s *Store) NextPairFiltered(min float64, anchorLimit int, sources []string) (model.Node, model.Node, float64, bool, int) {
return s.NextPairFilteredDepth(min, anchorLimit, sources, 0)
}
func (s *Store) NextPairFilteredDepth(min float64, anchorLimit int, categories []string, maxAIDepth int) (model.Node, model.Node, float64, bool, int) {
return s.NextPairScopedDepth(min, anchorLimit, NodeFilter{Categories: categories}, maxAIDepth)
func (s *Store) NextPairFilteredDepth(min float64, anchorLimit int, sources []string, maxAIDepth int) (model.Node, model.Node, float64, bool, int) {
return s.NextPairScopedDepth(min, anchorLimit, NodeFilter{Sources: sources}, maxAIDepth)
}
func (s *Store) NextPairScopedDepth(min float64, anchorLimit int, filter NodeFilter, maxAIDepth int) (model.Node, model.Node, float64, bool, int) {
+9 -13
View File
@@ -73,34 +73,30 @@ func TestNextPairUsesBoundedRotatingAnchors(t *testing.T) {
}
}
func TestCategoryFiltersLimitEmbeddingAndThinkingCandidates(t *testing.T) {
func TestSourceFiltersLimitEmbeddingAndThinkingCandidates(t *testing.T) {
s, err := Open(t.TempDir())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = s.Close() })
nodes := []model.Node{
{ID: "net-a", Kind: "knowledge", Label: "Net A", Categories: []string{"Netzwerk"}, Origin: "test"},
{ID: "net-b", Kind: "knowledge", Label: "Net B", Categories: []string{"Netzwerk"}, Origin: "test"},
{ID: "app-a", Kind: "knowledge", Label: "App A", Categories: []string{"Applikation"}, Origin: "test"},
{ID: "internal-a", Kind: "knowledge", Label: "Internal A", Categories: []string{"Netzwerk"}, Origin: "test", Metadata: map[string]any{"source": "internal-category"}},
{ID: "internal-b", Kind: "knowledge", Label: "Internal B", Categories: []string{"Applikation"}, Origin: "test", Metadata: map[string]any{"source": "internal-category"}},
{ID: "glpi-a", Kind: "knowledge", Label: "GLPI A", Categories: []string{"Netzwerk"}, Origin: "glpi-kb", Metadata: map[string]any{"source": "GLPI Knowledge Base"}},
{ID: "none", Kind: "knowledge", Label: "Ohne", Origin: "test"},
}
for _, node := range nodes {
s.UpsertNode(node)
}
pending := s.NodesForEmbeddingFiltered([]string{"Netzwerk"})
pending := s.NodesForEmbeddingFiltered([]string{"internal-category"})
if len(pending) != 2 {
t.Fatalf("expected two network embeddings, got %d", len(pending))
}
uncategorized := s.NodesForEmbeddingFiltered([]string{"__uncategorized__"})
if len(uncategorized) != 1 || uncategorized[0].ID != "none" {
t.Fatalf("unexpected uncategorized nodes: %#v", uncategorized)
t.Fatalf("expected two internal source embeddings, got %d", len(pending))
}
for _, node := range nodes {
s.SetVector(node.ID, []float64{1, .01})
}
a, b, _, ok, _ := s.NextPairFiltered(.5, 8, []string{"Netzwerk"})
if !ok || a.Categories[0] != "Netzwerk" || b.Categories[0] != "Netzwerk" {
t.Fatalf("thinking filter returned wrong pair: ok=%v a=%+v b=%+v", ok, a, b)
a, b, _, ok, _ := s.NextPairFiltered(.5, 8, []string{"internal-category"})
if !ok || NodeSource(a) != "internal-category" || NodeSource(b) != "internal-category" {
t.Fatalf("thinking source filter returned wrong pair: ok=%v a=%+v b=%+v", ok, a, b)
}
}
-5
View File
@@ -36,7 +36,6 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("GET /api/analysis", s.handleAnalysis)
mux.HandleFunc("GET /api/runtime-settings", s.handleGetRuntimeSettings)
mux.HandleFunc("PUT /api/runtime-settings", s.handleSetRuntimeSettings)
mux.HandleFunc("GET /api/categories", s.handleCategories)
mux.HandleFunc("GET /api/sources", s.handleSources)
mux.HandleFunc("GET /api/research/status", s.handleResearchStatus)
mux.HandleFunc("POST /api/research/test", s.handleResearchTest)
@@ -93,10 +92,6 @@ func (s *Server) handleSetRuntimeSettings(w http.ResponseWriter, r *http.Request
writeJSON(w, http.StatusOK, s.Engine.RuntimeSettingsView())
}
func (s *Server) handleCategories(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"categories": s.Engine.Categories()})
}
func (s *Server) handleSources(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"sources": s.Engine.Sources()})
}
+15 -25
View File
@@ -16,7 +16,7 @@ import (
"github.com/local/glpi-neural-brain/internal/research"
)
func TestRuntimeSettingsAndCategoriesAPI(t *testing.T) {
func TestRuntimeSettingsAndSourcesAPI(t *testing.T) {
data := t.TempDir()
g, err := graph.Open(data)
if err != nil {
@@ -34,10 +34,11 @@ func TestRuntimeSettingsAndCategoriesAPI(t *testing.T) {
LearningEnabled: true,
ThinkingEnabled: true,
DefaultView: "neural",
GLPIKBSource: "Configured GLPI Source",
}, g, broker)
h := (&Server{Engine: eng, Graph: g, Broker: broker}).Handler()
body := `{"learning_enabled":false,"thinking_enabled":false,"learning_categories":["GLPI"],"display_categories":["Ollama"],"thinking_categories":["GLPI"],"learning_sources":["GLPI Knowledge Base"],"display_sources":["internal-kb"],"thinking_sources":["GLPI Knowledge Base"],"view_mode":"constellation","max_display_nodes":1500,"low_power_mode":true}`
body := `{"learning_enabled":false,"thinking_enabled":false,"learning_sources":["GLPI Knowledge Base"],"display_sources":["internal-kb"],"thinking_sources":["GLPI Knowledge Base"],"view_mode":"constellation","max_display_nodes":1500,"low_power_mode":true}`
req := httptest.NewRequest(http.MethodPut, "/api/runtime-settings", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
@@ -53,27 +54,6 @@ func TestRuntimeSettingsAndCategoriesAPI(t *testing.T) {
t.Fatalf("unexpected settings: %+v", settings)
}
res = httptest.NewRecorder()
h.ServeHTTP(res, httptest.NewRequest(http.MethodGet, "/api/categories", nil))
if res.Code != http.StatusOK {
t.Fatalf("GET categories returned %d", res.Code)
}
var categories struct {
Categories []engine.CategoryInfo `json:"categories"`
}
if err := json.NewDecoder(res.Body).Decode(&categories); err != nil {
t.Fatal(err)
}
foundUncategorized := false
for _, category := range categories.Categories {
if category.Name == "__uncategorized__" && category.Count == 1 {
foundUncategorized = true
}
}
if !foundUncategorized {
t.Fatalf("uncategorized virtual category missing: %+v", categories.Categories)
}
res = httptest.NewRecorder()
h.ServeHTTP(res, httptest.NewRequest(http.MethodGet, "/api/sources", nil))
if res.Code != http.StatusOK {
@@ -85,8 +65,18 @@ func TestRuntimeSettingsAndCategoriesAPI(t *testing.T) {
if err := json.NewDecoder(res.Body).Decode(&sources); err != nil {
t.Fatal(err)
}
if len(sources.Sources) < 2 {
t.Fatalf("source options missing: %+v", sources.Sources)
foundConfiguredGLPI := false
foundInternal := false
for _, source := range sources.Sources {
if source.Name == "Configured GLPI Source" && source.Count == 0 {
foundConfiguredGLPI = true
}
if source.Name == "internal-kb" && source.Count == 1 {
foundInternal = true
}
}
if !foundConfiguredGLPI || !foundInternal {
t.Fatalf("exact source options or configured GLPI source missing: %+v", sources.Sources)
}
res = httptest.NewRecorder()
+3 -3
View File
@@ -31,7 +31,7 @@ html,body{margin:0;width:100%;height:100%;overflow:hidden;background:radial-grad
.dock .think-action{display:flex;align-items:center;gap:7px;border-color:rgba(255,180,82,.22);background:rgba(255,180,82,.07);color:var(--amber)}.dock .think-action small{font-size:8px;letter-spacing:.11em;opacity:.68}.dock .think-action:hover{background:rgba(255,180,82,.14);box-shadow:0 0 22px rgba(255,180,82,.08)}.dock .think-action.running{border-color:rgba(255,180,82,.48);background:rgba(255,180,82,.13);box-shadow:0 0 24px rgba(255,180,82,.11)}.dock .think-action:disabled{cursor:wait;opacity:.72}
.metrics span[title]{cursor:help}.dock #toggleLOD.active{color:var(--green);border-color:rgba(93,255,189,.22);background:rgba(93,255,189,.065)}
/* Laufzeitsteuerung, Kategorie-Filter und Honeycomb-Ansicht */
/* Laufzeitsteuerung, source-Filter und Visualisierungen */
.dock{max-width:calc(100vw - 390px);overflow-x:auto;scrollbar-width:none}.dock::-webkit-scrollbar{display:none}.dock-separator{width:1px;min-width:1px;background:var(--line);margin:5px 2px}.dock button:disabled{opacity:.32;cursor:not-allowed}.dock #toggleLearning.active{color:var(--blue);border-color:rgba(75,123,255,.25);background:rgba(75,123,255,.08)}.dock #toggleThinking.active{color:var(--amber);border-color:rgba(255,180,82,.25);background:rgba(255,180,82,.08)}.dock #viewHoneycomb.active,.dock #viewNeural.active{color:var(--green);border-color:rgba(93,255,189,.24);background:rgba(93,255,189,.07)}.dock #openSettings{color:#a7bdcc}
body.honeycomb-view .legend{opacity:.58}body.honeycomb-view .mode-status small:after{content:" · Honeycomb"}
.settings-backdrop{position:fixed;inset:0;z-index:20;background:rgba(0,3,9,.55);backdrop-filter:blur(3px)}
@@ -41,7 +41,7 @@ body.honeycomb-view .legend{opacity:.58}body.honeycomb-view .mode-status small:a
.switch-row{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:9px 10px;margin:7px 0;border:1px solid rgba(133,200,255,.1);border-radius:12px;background:rgba(255,255,255,.022);cursor:pointer}.switch-row b{display:block;font-size:11px}.switch-row small{display:block;margin-top:3px;color:#718b9e;font-size:9px;line-height:1.35}.switch-row input{appearance:none;width:38px;height:21px;border-radius:999px;background:rgba(119,146,168,.25);position:relative;cursor:pointer;flex:0 0 auto;outline:1px solid rgba(255,255,255,.06)}.switch-row input:after{content:"";position:absolute;width:15px;height:15px;left:3px;top:3px;border-radius:50%;background:#8196a7;transition:transform .2s,background .2s,box-shadow .2s}.switch-row input:checked{background:rgba(82,231,255,.19)}.switch-row input:checked:after{transform:translateX(17px);background:var(--cyan);box-shadow:0 0 10px rgba(82,231,255,.7)}
.view-selector{display:grid;grid-template-columns:repeat(3,1fr);gap:6px;margin-top:10px}.view-selector button{border:1px solid rgba(133,200,255,.12);background:rgba(255,255,255,.025);color:#738da1;border-radius:10px;padding:9px;font-size:10px;font-weight:700;letter-spacing:.1em;cursor:pointer}.view-selector button.active{color:var(--green);border-color:rgba(93,255,189,.24);background:rgba(93,255,189,.07)}
.category-settings{flex:1;min-height:0;overflow:auto;padding-right:5px}.filter-search{display:block;margin-bottom:12px}.filter-search span{display:block;font-size:9px;color:#6d879a;margin-bottom:5px}.filter-search input{width:100%;border:1px solid rgba(133,200,255,.14);background:rgba(2,8,17,.6);color:var(--text);border-radius:10px;padding:9px 10px;outline:0}.filter-search input:focus{border-color:rgba(82,231,255,.38);box-shadow:0 0 0 3px rgba(82,231,255,.06)}
.filter-block{margin:12px 0 16px}.filter-heading{display:flex;align-items:center;justify-content:space-between;margin-bottom:7px}.filter-heading b{display:block;font-size:10px;color:#c8d9e4}.filter-heading small{display:block;margin-top:2px;color:#607b90;font-size:9px}.filter-heading button{border:1px solid rgba(82,231,255,.13);background:rgba(82,231,255,.04);color:#87a8bc;border-radius:8px;padding:5px 8px;font-size:8px;letter-spacing:.08em;cursor:pointer}.category-list{display:flex;flex-wrap:wrap;gap:5px;max-height:128px;overflow:auto;padding:1px}.category-option{position:relative}.category-option input{position:absolute;opacity:0;pointer-events:none}.category-option span{display:block;border:1px solid rgba(133,200,255,.12);background:rgba(255,255,255,.022);color:#7893a6;border-radius:999px;padding:5px 8px;font-size:9px;cursor:pointer;white-space:nowrap}.category-option input:checked+span{color:#dffaff;border-color:rgba(82,231,255,.32);background:rgba(82,231,255,.11);box-shadow:0 0 12px rgba(82,231,255,.05)}.category-option em{font-style:normal;opacity:.58;margin-left:4px}
.filter-block{margin:12px 0 16px}.filter-heading{display:flex;align-items:center;justify-content:space-between;margin-bottom:7px}.filter-heading b{display:block;font-size:10px;color:#c8d9e4}.filter-heading small{display:block;margin-top:2px;color:#607b90;font-size:9px}.filter-heading button{border:1px solid rgba(82,231,255,.13);background:rgba(82,231,255,.04);color:#87a8bc;border-radius:8px;padding:5px 8px;font-size:8px;letter-spacing:.08em;cursor:pointer}.source-list{display:flex;flex-wrap:wrap;gap:5px;max-height:128px;overflow:auto;padding:1px}.source-option{position:relative}.source-option input{position:absolute;opacity:0;pointer-events:none}.source-option span{display:block;border:1px solid rgba(133,200,255,.12);background:rgba(255,255,255,.022);color:#7893a6;border-radius:999px;padding:5px 8px;font-size:9px;cursor:pointer;white-space:nowrap}.source-option input:checked+span{color:#dffaff;border-color:rgba(82,231,255,.32);background:rgba(82,231,255,.11);box-shadow:0 0 12px rgba(82,231,255,.05)}.source-option em{font-style:normal;opacity:.58;margin-left:4px}
.settings-footer{display:flex;align-items:center;justify-content:space-between;gap:12px;padding-top:13px}.settings-footer span{font-size:9px;color:#7f9aac;line-height:1.35}.settings-footer button{border:1px solid rgba(82,231,255,.3);background:rgba(82,231,255,.1);color:var(--cyan);border-radius:11px;padding:10px 13px;font-size:9px;font-weight:800;letter-spacing:.13em;cursor:pointer}.settings-footer button:disabled{opacity:.5;cursor:wait}
@media(max-width:1100px){.dock{max-width:calc(100vw - 40px)}.settings-panel{right:10px;top:10px;bottom:10px}}
@media(max-width:780px){.settings-panel{left:10px;width:auto}.dock{max-width:calc(100vw - 20px)}.dock-separator{display:none}}
@@ -65,4 +65,4 @@ body.low-power .glass{backdrop-filter:blur(12px)}
.eco-switch input:checked{background:rgba(255,180,82,.2)}
.eco-switch input:checked:after{background:var(--amber);box-shadow:0 0 10px rgba(255,180,82,.72)}
@media(max-width:620px){.view-selector{grid-template-columns:1fr}.metrics span[title]{display:none}}
.filter-scope-summary{margin:2px 0 10px;line-height:1.45}.category-option.disabled{opacity:.38}.category-option.disabled span{cursor:not-allowed;border-style:dashed}.category-option span small{display:block;margin-top:2px;font-size:7px;letter-spacing:.04em;color:#8a6f79}.filter-scope-summary.warn{color:#ffb37f}.filter-scope-summary.ok{color:#7898aa}
.filter-scope-summary{margin:2px 0 10px;line-height:1.45}.source-option.disabled{opacity:.38}.source-option.disabled span{cursor:not-allowed;border-style:dashed}.source-option span small{display:block;margin-top:2px;font-size:7px;letter-spacing:.04em;color:#8a6f79}.filter-scope-summary.warn{color:#ffb37f}.filter-scope-summary.ok{color:#7898aa}
+98 -152
View File
@@ -51,15 +51,15 @@
lodOpenUntil: new Map(), lodHotUntil: new Map(), lodDirty: true, lodLastBuild: 0, lodNextExpiry: 0, lodZoomBand: 2,
renderNodes: [], renderEdges: [], renderIdleEdges: [], renderNodeById: new Map(), renderEdgeById: new Map(), visibleForNode: new Map(),
edgeRenderMap: new Map(), renderActive: new Map(), renderEdgeActive: new Map(), renderStats: {nodes: 0, edges: 0, hiddenNodes: 0, hiddenEdges: 0},
fullSnapshot: null, fullNodeById: new Map(), runtimeSettings: {learning_enabled: true, thinking_enabled: true, learning_categories: [], display_categories: [], thinking_categories: [], learning_sources: [], display_sources: [], thinking_sources: [], effective_learning: {categories: [], sources: [], matches_none: false}, effective_display: {categories: [], sources: [], matches_none: false}, effective_thinking: {categories: [], sources: [], matches_none: false}, admin_learning: {categories: [], sources: []}, admin_display: {categories: [], sources: []}, admin_thinking: {categories: [], sources: []}, view_mode: 'neural', max_display_nodes: 0, low_power_mode: false},
availableCategories: [], availableSources: [], viewMode: 'neural', honeycombNodes: [], honeycombSpacing: 0, honeySlotByID: new Map(), honeyPointPool: [], honeyFreeSlots: [],
fullSnapshot: null, fullNodeById: new Map(), runtimeSettings: {source_filter_version: 1, learning_enabled: true, thinking_enabled: true, learning_sources: [], display_sources: [], thinking_sources: [], glpi_kb_source: '', view_mode: 'neural', max_display_nodes: 0, low_power_mode: false},
availableSources: [], viewMode: 'neural', honeycombNodes: [], honeycombSpacing: 0, honeySlotByID: new Map(), honeyPointPool: [], honeyFreeSlots: [],
constellationNodes: [], constellationLinks: [], settingsOpen: false, settingsDraft: null,
forcedDisplayUntil: new Map(), nextDisplayLimitExpiry: 0, graphVersion: null, displaySignature: '', displayLimitStats: {limit: 0, eligible: 0, shown: 0},
researchAnimations: new Map(), researchSequence: 0,
lowPowerMode: false, performanceProfile: PERFORMANCE_CONFIG.normal, lastPaint: 0, fpsWindowStarted: performance.now(), fpsFrames: 0, fps: 0,
backgroundCanvas: document.createElement('canvas'), backgroundKey: '', cameraFrame: null, projectedSortAt: 0,
retiringRenderNodes: [], retiringRenderEdges: [], retiringRenderNodeById: new Map(), viewGhostNodes: [],
topologyNodeIDs: new Set(), topologyEdgeIDs: new Set(), graphLoadPromise: null, graphLoadQueued: false, graphLoadTimer: 0
topologyNodeIDs: new Set(), topologyEdgeIDs: new Set(), graphLoadPromise: null, graphLoadQueued: false, graphLoadTimer: 0, sourceOptionsTimer: 0
};
function currentPerformanceProfile() {
@@ -141,46 +141,30 @@
return data;
}
function normalizedFilterValues(values) {
return [...new Set((values || []).map(value => String(value).trim().toLowerCase()).filter(Boolean))];
function normalizedSourceValues(values) {
return [...new Set((values || []).map(value => String(value).trim()).filter(Boolean))];
}
// Source filtering intentionally uses only the explicit metadata.source value
// copied from a KB file (or assigned to learned web evidence). No origin,
// category, URI or display-label fallback is used.
function nodeSource(node) {
const explicit = node?.metadata?.source;
if (explicit !== undefined && explicit !== null) {
const value = String(explicit).trim();
if (value && value.toLowerCase() !== '<nil>' && value.toLowerCase() !== 'null') return value;
}
if (node?.kind === 'source' && String(node.label || '').trim()) return String(node.label).trim();
if (node?.kind === 'external') {
for (const raw of [node.uri, node.external_id]) {
try {
const host = new URL(String(raw || '')).hostname.trim().toLowerCase();
if (host) return host;
} catch {}
}
}
return String(node?.origin || '').trim();
if (explicit === undefined || explicit === null) return '';
const value = String(explicit).trim();
if (!value || value.toLowerCase() === '<nil>' || value.toLowerCase() === 'null') return '';
return value;
}
function filterDimensionMatches(values, filters, emptyToken) {
const wanted = new Set(normalizedFilterValues(filters));
if (!wanted.size || wanted.has('*')) return true;
const clean = (values || []).map(value => String(value).trim().toLowerCase()).filter(Boolean);
if (!clean.length) return wanted.has(emptyToken);
return clean.some(value => wanted.has(value));
function sourceFilterMatches(node, sources) {
const wanted = normalizedSourceValues(sources);
if (!wanted.length) return true;
const source = nodeSource(node);
return wanted.some(value => source === value);
}
function nodeFilterMatches(node, filter) {
if (filter?.matches_none) return false;
return filterDimensionMatches(node.categories || [], filter?.categories || [], '__uncategorized__') &&
filterDimensionMatches(nodeSource(node) ? [nodeSource(node)] : [], filter?.sources || [], '__unsourced__');
}
function effectiveRuntimeFilter(key, settings = state.runtimeSettings) {
const effective = settings?.[`effective_${key}`];
if (effective) return effective;
return {categories: settings?.[`${key}_categories`] || [], sources: settings?.[`${key}_sources`] || [], matches_none: false};
function runtimeSources(key, settings = state.runtimeSettings) {
return normalizedSourceValues(settings?.[`${key}_sources`] || []);
}
function activeForcedDisplayIDs(now = Date.now()) {
@@ -265,19 +249,18 @@
}
function filteredSnapshot(snapshot) {
const filter = effectiveRuntimeFilter('display');
const restricted = Boolean(filter?.matches_none || (filter?.categories || []).length || (filter?.sources || []).length);
const sources = runtimeSources('display');
let filtered = snapshot;
if (restricted) {
if (sources.length) {
const visible = new Set();
const noteKinds = new Set(['knowledge', 'ai-think', 'external']);
const taxonomyKinds = new Set(['category', 'source', 'concept']);
const nodeMap = new Map(snapshot.nodes.map(node => [node.id, node]));
for (const node of snapshot.nodes) {
if (noteKinds.has(node.kind) && nodeFilterMatches(node, filter)) visible.add(node.id);
if (noteKinds.has(node.kind) && sourceFilterMatches(node, sources)) visible.add(node.id);
}
// Add only the taxonomy directly attached to an already matching note.
// Never pull another knowledge/external note through a semantic edge.
// Taxonomy is visual context only. Another knowledge-bearing node is
// never pulled through an edge when its exact source does not match.
for (const edge of snapshot.edges) {
const source = nodeMap.get(edge.source);
const target = nodeMap.get(edge.target);
@@ -294,8 +277,7 @@
}
function displaySignatureFor(settings = state.runtimeSettings) {
const filter = effectiveRuntimeFilter('display', settings);
return {categories: filter.categories || [], sources: filter.sources || [], matchesNone: Boolean(filter.matches_none), limit: Number(settings.max_display_nodes || 0)};
return {sources: runtimeSources('display', settings), limit: Number(settings.max_display_nodes || 0)};
}
function currentDisplaySignature() {
@@ -303,16 +285,28 @@
return JSON.stringify({...displaySignatureFor(), forced});
}
async function loadSourceOptions() {
const payload = await api('/api/sources');
state.availableSources = payload.sources || [];
renderSourceFilters($('sourceSearch')?.value || '');
return state.availableSources;
}
function scheduleSourceOptionsLoad(delay = 260) {
clearTimeout(state.sourceOptionsTimer);
state.sourceOptionsTimer = setTimeout(() => {
state.sourceOptionsTimer = 0;
loadSourceOptions().catch(() => {});
}, delay);
}
async function loadRuntimeConfiguration() {
try {
const [settings, categories, sources] = await Promise.all([api('/api/runtime-settings'), api('/api/categories'), api('/api/sources')]);
const [settings] = await Promise.all([api('/api/runtime-settings'), loadSourceOptions()]);
state.runtimeSettings = {...state.runtimeSettings, ...settings};
state.availableCategories = categories.categories || [];
state.availableSources = sources.sources || [];
state.viewMode = ['neural', 'honeycomb', 'constellation'].includes(state.runtimeSettings.view_mode) ? state.runtimeSettings.view_mode : 'neural';
applyPerformanceMode(Boolean(state.runtimeSettings.low_power_mode), true);
syncRuntimeControls();
renderCategoryFilters();
renderSourceFilters();
renderFilterScopeSummary();
} catch {
@@ -347,7 +341,7 @@
if (!hint) return;
const limit = Math.max(0, Math.trunc(Number(value) || 0));
if (!limit) {
hint.textContent = 'Unbegrenzt · Kategorie-Filter und LOD bestimmen die Renderlast.';
hint.textContent = 'Unbegrenzt · Quellenfilter und LOD bestimmen die Renderlast.';
return;
}
const stats = state.displayLimitStats || {};
@@ -355,113 +349,72 @@
hint.textContent = `Maximal ${limit.toLocaleString('de-DE')} Nodes · aktuell ${Math.min(limit, eligible).toLocaleString('de-DE')} von ${eligible.toLocaleString('de-DE')} auswählbar.`;
}
function categoryLabel(name) {
return name === '__uncategorized__' ? 'Ohne Kategorie' : name;
}
function sourceLabel(name) {
return name === '__unsourced__' ? 'Ohne Quelle' : name;
return String(name || '');
}
function filterSet(key, dimension) {
const source = state.settingsDraft || state.runtimeSettings;
return new Set((source[`${key}_${dimension}`] || []).map(value => String(value).toLowerCase()));
}
function adminAllows(key, dimension, value) {
const admin = state.runtimeSettings?.[`admin_${key}`] || {};
const restricted = Boolean(admin[`${dimension}_restricted`]);
if (!restricted) return true;
const allowed = new Set(normalizedFilterValues(admin[dimension] || []));
return allowed.has(String(value).trim().toLowerCase());
}
function renderFilterOptions({dimension, search = '', options, targets, labeler, dataAttribute}) {
const query = String(search || '').trim().toLowerCase();
for (const [key, target] of Object.entries(targets)) {
if (!target) continue;
const selected = filterSet(key, dimension);
target.innerHTML = '';
const visibleOptions = options.filter(option => !query || labeler(option.name).toLowerCase().includes(query));
for (const option of visibleOptions) {
const allowed = adminAllows(key, dimension, option.name);
const label = document.createElement('label');
label.className = `category-option${allowed ? '' : ' disabled'}`;
const checked = selected.has(String(option.name).toLowerCase());
const disabled = allowed ? '' : ' disabled';
const notice = allowed ? '' : '<small>durch ENV ausgeschlossen</small>';
label.innerHTML = `<input type="checkbox" ${dataAttribute}="${key}" value="${escapeHTML(option.name)}" ${checked ? 'checked' : ''}${disabled}><span>${escapeHTML(labeler(option.name))}<em>${Number(option.count || 0).toLocaleString('de-DE')}</em>${notice}</span>`;
target.appendChild(label);
}
if (!visibleOptions.length) target.innerHTML = `<span class="empty-filter">Keine passende ${dimension === 'categories' ? 'Kategorie' : 'Quelle'}</span>`;
}
}
function renderCategoryFilters(search = '') {
renderFilterOptions({
dimension: 'categories', search, options: state.availableCategories,
targets: {learning: $('learningCategoryList'), display: $('displayCategoryList'), thinking: $('thinkingCategoryList')},
labeler: categoryLabel, dataAttribute: 'data-category-filter'
});
function sourceSet(key) {
const settings = state.settingsDraft || state.runtimeSettings;
return new Set(normalizedSourceValues(settings[`${key}_sources`] || []));
}
function renderSourceFilters(search = '') {
renderFilterOptions({
dimension: 'sources', search, options: state.availableSources,
targets: {learning: $('learningSourceList'), display: $('displaySourceList'), thinking: $('thinkingSourceList')},
labeler: sourceLabel, dataAttribute: 'data-source-filter'
});
}
function intersectClientValues(adminValues, runtimeValues, restrictedFlag) {
const runtime = normalizedFilterValues(runtimeValues);
if (!restrictedFlag) return {values: runtime, restricted: runtime.length > 0, none: false};
const admin = normalizedFilterValues(adminValues);
if (!runtime.length) return {values: admin, restricted: true, none: false};
const allowed = new Set(admin);
const values = runtime.filter(value => allowed.has(value));
return {values, restricted: true, none: values.length === 0};
}
function previewEffectiveFilter(key) {
const query = String(search || '').trim().toLowerCase();
const settings = state.settingsDraft || state.runtimeSettings;
const admin = state.runtimeSettings?.[`admin_${key}`] || {};
const categories = intersectClientValues(admin.categories || [], settings?.[`${key}_categories`] || [], Boolean(admin.categories_restricted));
const sources = intersectClientValues(admin.sources || [], settings?.[`${key}_sources`] || [], Boolean(admin.sources_restricted));
return {categories: categories.values, sources: sources.values, matches_none: categories.none || sources.none};
const optionMap = new Map((state.availableSources || []).map(option => [String(option.name), {...option}]));
const configuredGLPI = String(state.runtimeSettings.glpi_kb_source || '').trim();
if (configuredGLPI && !optionMap.has(configuredGLPI)) optionMap.set(configuredGLPI, {name: configuredGLPI, count: 0});
for (const key of ['learning', 'display', 'thinking']) {
for (const source of runtimeSources(key, settings)) {
if (!optionMap.has(source)) optionMap.set(source, {name: source, count: 0});
}
}
const options = [...optionMap.values()].filter(option => !query || sourceLabel(option.name).toLowerCase().includes(query));
const targets = {learning: $('learningSourceList'), display: $('displaySourceList'), thinking: $('thinkingSourceList')};
for (const [key, target] of Object.entries(targets)) {
if (!target) continue;
const selected = sourceSet(key);
target.innerHTML = '';
for (const option of options) {
const label = document.createElement('label');
label.className = 'source-option';
const checked = selected.has(String(option.name));
const configured = option.name === state.runtimeSettings.glpi_kb_source && Number(option.count || 0) === 0 ? '<small>GLPI_KB_SOURCE</small>' : '';
label.innerHTML = `<input type="checkbox" data-source-filter="${key}" value="${escapeHTML(option.name)}" ${checked ? 'checked' : ''}><span>${escapeHTML(sourceLabel(option.name))}<em>${Number(option.count || 0).toLocaleString('de-DE')}</em>${configured}</span>`;
target.appendChild(label);
}
if (!options.length) target.innerHTML = '<span class="empty-filter">Keine passende source gefunden</span>';
}
}
function compactFilterValues(values, labeler) {
if (!values?.length) return 'alle';
const labels = values.slice(0, 3).map(labeler);
function compactSourceValues(values) {
if (!values?.length) return 'alle source-Werte';
const labels = values.slice(0, 3).map(sourceLabel);
return labels.join(', ') + (values.length > 3 ? ` +${values.length - 3}` : '');
}
function renderFilterScopeSummary() {
const target = $('filterScopeSummary');
if (!target) return;
const parts = [];
let hasEmpty = false;
for (const [key, label] of [['learning', 'Lernen'], ['display', 'Anzeige'], ['thinking', 'Thinking']]) {
const effective = previewEffectiveFilter(key);
hasEmpty ||= effective.matches_none;
parts.push(`${label}: ${effective.matches_none ? 'keine Übereinstimmung' : `${compactFilterValues(effective.categories, categoryLabel)} · ${compactFilterValues(effective.sources, sourceLabel)}`}`);
}
target.textContent = `Wirksam (ENV ∩ WebUI) — ${parts.join(' | ')}`;
target.classList.toggle('warn', hasEmpty);
target.classList.toggle('ok', !hasEmpty);
const settings = state.settingsDraft || state.runtimeSettings;
const parts = [
`Lernen: ${compactSourceValues(runtimeSources('learning', settings))}`,
`Anzeige: ${compactSourceValues(runtimeSources('display', settings))}`,
`Thinking: ${compactSourceValues(runtimeSources('thinking', settings))}`
];
target.textContent = `Exakter KB-source-Treffer — ${parts.join(' | ')}`;
target.classList.remove('warn');
target.classList.add('ok');
}
async function persistRuntimeSettings(settings = state.runtimeSettings) {
const normalized = {
source_filter_version: 1,
learning_enabled: Boolean(settings.learning_enabled),
thinking_enabled: Boolean(settings.thinking_enabled),
learning_categories: [...(settings.learning_categories || [])],
display_categories: [...(settings.display_categories || [])],
thinking_categories: [...(settings.thinking_categories || [])],
learning_sources: [...(settings.learning_sources || [])],
display_sources: [...(settings.display_sources || [])],
thinking_sources: [...(settings.thinking_sources || [])],
learning_sources: normalizedSourceValues(settings.learning_sources),
display_sources: normalizedSourceValues(settings.display_sources),
thinking_sources: normalizedSourceValues(settings.thinking_sources),
view_mode: ['neural', 'honeycomb', 'constellation'].includes(settings.view_mode) ? settings.view_mode : 'neural',
max_display_nodes: Math.max(0, Math.min(500000, Math.trunc(Number(settings.max_display_nodes) || 0))),
low_power_mode: Boolean(settings.low_power_mode)
@@ -488,7 +441,6 @@
$('settingsPanel')?.classList.remove('hidden');
$('settingsBackdrop')?.classList.remove('hidden');
syncRuntimeControls();
renderCategoryFilters($('categorySearch')?.value || '');
renderSourceFilters($('sourceSearch')?.value || '');
renderFilterScopeSummary();
}
@@ -2760,7 +2712,10 @@
}
}
addLog(evt);
if (evt.type === 'graph.updated') scheduleGraphLoad(180);
if (evt.type === 'graph.updated') {
scheduleGraphLoad(180);
scheduleSourceOptionsLoad(320);
}
if (evt.type?.startsWith('think.') || evt.type?.startsWith('article.') || evt.type?.startsWith('research.')) loadStatus();
}
@@ -2999,7 +2954,6 @@
$('openSettings').addEventListener('click', openSettingsPanel);
$('closeSettings').addEventListener('click', closeSettingsPanel);
$('settingsBackdrop').addEventListener('click', closeSettingsPanel);
$('categorySearch').addEventListener('input', e => renderCategoryFilters(e.currentTarget.value));
$('sourceSearch').addEventListener('input', e => renderSourceFilters(e.currentTarget.value));
$('settingsLearning').addEventListener('change', e => { if (state.settingsDraft) state.settingsDraft.learning_enabled = e.currentTarget.checked; });
$('settingsThinking').addEventListener('change', e => { if (state.settingsDraft) state.settingsDraft.thinking_enabled = e.currentTarget.checked; });
@@ -3019,23 +2973,15 @@
$('settingsViewHoneycomb').addEventListener('click', () => selectSettingsView('honeycomb'));
$('settingsViewConstellation').addEventListener('click', () => selectSettingsView('constellation'));
$('settingsPanel').addEventListener('change', e => {
const input = e.target.closest('[data-category-filter], [data-source-filter]');
if (!input || !state.settingsDraft || input.disabled) return;
const dimension = input.dataset.categoryFilter !== undefined ? 'categories' : 'sources';
const key = input.dataset.categoryFilter ?? input.dataset.sourceFilter;
const property = `${key}_${dimension}`;
const selected = new Map((state.settingsDraft[property] || []).map(value => [String(value).toLowerCase(), value]));
const normalized = String(input.value).toLowerCase();
if (input.checked) selected.set(normalized, input.value); else selected.delete(normalized);
state.settingsDraft[property] = [...selected.values()];
const input = e.target.closest('[data-source-filter]');
if (!input || !state.settingsDraft) return;
const key = input.dataset.sourceFilter;
const property = `${key}_sources`;
const selected = new Set(normalizedSourceValues(state.settingsDraft[property] || []));
if (input.checked) selected.add(input.value); else selected.delete(input.value);
state.settingsDraft[property] = [...selected];
renderFilterScopeSummary();
});
document.querySelectorAll('[data-clear-filter]').forEach(button => button.addEventListener('click', () => {
if (!state.settingsDraft) return;
state.settingsDraft[`${button.dataset.clearFilter}_categories`] = [];
renderCategoryFilters($('categorySearch').value);
renderFilterScopeSummary();
}));
document.querySelectorAll('[data-clear-source-filter]').forEach(button => button.addEventListener('click', () => {
if (!state.settingsDraft) return;
state.settingsDraft[`${button.dataset.clearSourceFilter}_sources`] = [];
+12 -30
View File
@@ -60,7 +60,7 @@
<button id="toggleLOD" class="active" title="Hierarchische dynamische Verdichtung">LOD</button>
<button id="toggleEco" title="Optimierter Renderpfad für schwächere Systeme">ECO</button>
<button id="resetView">Zentrieren</button>
<button id="openSettings" title="Kategorie-Filter und Laufzeitsteuerung">FILTER</button>
<button id="openSettings" title="Exakte KB-source-Filter und Laufzeitsteuerung">FILTER</button>
<button id="enrichNow" class="think-action" title="Einen autonomen AI-THINK-Zyklus sofort starten"><span>AI-THINK</span><small>STARTEN</small></button>
</nav>
@@ -75,7 +75,7 @@
<div id="settingsBackdrop" class="settings-backdrop hidden"></div>
<aside id="settingsPanel" class="settings-panel glass hidden" aria-label="Brain-Einstellungen">
<div class="settings-header">
<div><strong>BRAIN CONTROL</strong><small>Laufzeitmodi und Kategorie-Filter</small></div>
<div><strong>BRAIN CONTROL</strong><small>Laufzeitmodi und exakte KB-source-Filter</small></div>
<button id="closeSettings" title="Schließen">×</button>
</div>
@@ -114,7 +114,7 @@
<button type="button" data-node-limit="25000">25.000</button>
<button type="button" data-node-limit="0">Unbegrenzt</button>
</div>
<p id="displayLimitHint" class="setting-hint">Unbegrenzt · Kategorie-Filter und LOD bestimmen die Renderlast.</p>
<p id="displayLimitHint" class="setting-hint">Unbegrenzt · Quellenfilter und LOD bestimmen die Renderlast.</p>
</section>
<section class="settings-section research-settings">
@@ -130,40 +130,22 @@
</div>
</section>
<section class="settings-section category-settings">
<div class="settings-section-title"><h2>Kategorie-Filter</h2><span>Leer = alle erlaubten Kategorien</span></div>
<p id="filterScopeSummary" class="setting-hint filter-scope-summary">Environment-Grenzen werden geladen …</p>
<label class="filter-search"><span>Kategorien durchsuchen</span><input id="categorySearch" type="search" placeholder="z. B. GLPI, Netzwerk, Ollama"></label>
<div class="filter-block">
<div class="filter-heading"><div><b>Lernen</b><small>Nur diese Kategorien werden neu eingebettet.</small></div><button type="button" data-clear-filter="learning">Alle</button></div>
<div id="learningCategoryList" class="category-list"></div>
</div>
<div class="filter-block">
<div class="filter-heading"><div><b>Anzeige</b><small>Nur passende Notes und deren Taxonomie werden gerendert.</small></div><button type="button" data-clear-filter="display">Alle</button></div>
<div id="displayCategoryList" class="category-list"></div>
</div>
<div class="filter-block">
<div class="filter-heading"><div><b>Thinking</b><small>Nur diese Kategorien werden für neue AI-Edges geprüft.</small></div><button type="button" data-clear-filter="thinking">Alle</button></div>
<div id="thinkingCategoryList" class="category-list"></div>
</div>
</section>
<section class="settings-section source-settings">
<div class="settings-section-title"><h2>Quellen-Filter</h2><span>Quelle und Kategorie gelten gemeinsam</span></div>
<label class="filter-search"><span>Quellen durchsuchen</span><input id="sourceSearch" type="search" placeholder="z. B. GLPI Knowledge Base, internal-kb, docs.example.org"></label>
<div class="settings-section-title"><h2>KB-source-Filter</h2><span>exakter Treffer auf dem source-Feld</span></div>
<p id="filterScopeSummary" class="setting-hint filter-scope-summary">Quellen werden geladen …</p>
<label class="filter-search"><span>source-Werte durchsuchen</span><input id="sourceSearch" type="search" placeholder="z. B. internal-category oder GLPI Knowledge Base"></label>
<div class="filter-block">
<div class="filter-heading"><div><b>Lernen</b><small>Nur Wissen aus diesen Quellen wird neu eingebettet und bei Abfragen verwendet.</small></div><button type="button" data-clear-source-filter="learning">Alle</button></div>
<div id="learningSourceList" class="category-list"></div>
<div class="filter-heading"><div><b>Lernen</b><small>Nur KB-Dokumente mit exakt diesem source-Wert werden eingebettet und bei Abfragen verwendet.</small></div><button type="button" data-clear-source-filter="learning">Alle</button></div>
<div id="learningSourceList" class="source-list"></div>
</div>
<div class="filter-block">
<div class="filter-heading"><div><b>Anzeige</b><small>Nur passende Quellen werden gerendert; Taxonomie bleibt sichtbar.</small></div><button type="button" data-clear-source-filter="display">Alle</button></div>
<div id="displaySourceList" class="category-list"></div>
<div class="filter-heading"><div><b>Anzeige</b><small>Nur Dokumente mit exakt passendem source-Wert werden gerendert; direkte Taxonomie bleibt sichtbar.</small></div><button type="button" data-clear-source-filter="display">Alle</button></div>
<div id="displaySourceList" class="source-list"></div>
</div>
<div class="filter-block">
<div class="filter-heading"><div><b>Thinking</b><small>Nur diese Quellen dürfen neue Relationen und Artikel speisen.</small></div><button type="button" data-clear-source-filter="thinking">Alle</button></div>
<div id="thinkingSourceList" class="category-list"></div>
<div class="filter-heading"><div><b>Thinking</b><small>Nur Dokumente und Webbelege mit exakt passendem source-Wert speisen neue Relationen und Artikel.</small></div><button type="button" data-clear-source-filter="thinking">Alle</button></div>
<div id="thinkingSourceList" class="source-list"></div>
</div>
</section>