Bugfixes
release-tag / release-image (push) Successful in 2m17s

This commit is contained in:
2026-08-05 10:15:56 +02:00
parent 18255e4b98
commit 31d3efb8a2
25 changed files with 1034 additions and 184 deletions
+20 -25
View File
@@ -55,7 +55,8 @@ func (e *Engine) synthesizeKnowledgeArticle(ctx context.Context, trigger string,
// Previously accepted full-text evidence is already learned knowledge. It must
// influence the create/update/merge decision, otherwise a later cycle could
// skip before it ever sees the external facts it learned in an earlier cycle.
planningResearch := uniqueResearchEvidence(append(filterUsableResearchEvidence(initialResearch), e.researchEvidenceForSources(sources)...))
initialResearch = e.filterResearchEvidenceForThinking(filterUsableResearchEvidence(initialResearch), categoriesFromArticleSources(sources))
planningResearch := uniqueResearchEvidence(append(initialResearch, e.researchEvidenceForSources(sources)...))
e.Broker.Publish(model.Activity{Type: "article.plan.started", Source: "brain", Phase: "knowledge-planning", NodeIDs: nodeIDsFromArticleSources(sources), Message: fmt.Sprintf("%d Quellen werden auf einen echten Wissensmehrwert geprüft", len(sources)), Strength: .84, Metadata: map[string]any{"trigger": trigger, "productive_sources": productionCount, "ai_sources": aiCount, "production_ratio": productionRatio, "generation_depth": generationDepth, "model": e.Cfg.ChatModel, "learned_research_sources": len(planningResearch)}})
var plan model.ArticlePlanDecision
@@ -85,7 +86,7 @@ func (e *Engine) synthesizeKnowledgeArticle(ctx context.Context, trigger string,
return articleSynthesisOutcome{Skipped: true, Reason: "equivalent_staging_draft", Action: plan.Action}, nil
}
newResearchResults := filterUsableResearchEvidence(initialResearch)
newResearchResults := initialResearch
if len(newResearchResults) > 0 {
e.addResearchToSources(selected, newResearchResults)
e.learnResearchEvidence(ctx, newResearchResults)
@@ -184,13 +185,13 @@ func (e *Engine) selectArticleSources(seeds []model.Node) []articleSource {
direct[edge.Source] += math.Max(.2, math.Max(edge.Confidence, edge.Weight))
}
}
filters := e.thinkingCategories()
filter := e.effectiveThinkingFilter()
var production, ai []articleSource
for _, node := range snapshot.Nodes {
if node.Kind != "knowledge" && node.Kind != "ai-think" {
continue
}
if !matchesCategories(node, filters) {
if !filter.Matches(node) {
continue
}
depth := nodeGenerationDepth(node)
@@ -1093,7 +1094,7 @@ func (e *Engine) addRuntimeArticleNode(articleID string, sources []articleSource
ID: nodeID, Kind: "ai-think", Label: draft.Title, Summary: clamp(strings.TrimSpace(draft.Text)+"\n\n"+formatArticleAnswer(draft), 1400),
Status: "staging", Origin: "knowledge-staging", ExternalID: articleID, URI: "brain://article/" + articleID,
Categories: unique(append([]string{"AI-THINK", "AI-Staging", "AI-Synthesis"}, draft.Categories...)), Keywords: unique(draft.Keywords), Weight: 1.45,
Metadata: map[string]any{"subtype": "knowledge_synthesis", "action": plan.Action, "target_node_id": plan.TargetArticleID, "generation_depth": generationDepth, "confidence": draft.Confidence, "source_node_ids": nodeIDsFromArticleSources(sources), "productive_source_count": productionCount, "ai_source_count": aiCount, "production_ratio": productionRatio}, UpdatedAt: now,
Metadata: map[string]any{"subtype": "knowledge_synthesis", "action": plan.Action, "target_node_id": plan.TargetArticleID, "generation_depth": generationDepth, "confidence": draft.Confidence, "source_node_ids": nodeIDsFromArticleSources(sources), "productive_source_count": productionCount, "ai_source_count": aiCount, "production_ratio": productionRatio, "source": "Neural Brain / " + e.Cfg.ChatModel + " (Knowledge Synthesis)"}, UpdatedAt: now,
}
e.Graph.UpsertNode(node)
for _, source := range sources {
@@ -1116,7 +1117,7 @@ func (e *Engine) learnRuntimeArticle(ctx context.Context, articleID string) {
}
nodeID := graph.ID("knowledge", articleID)
node, ok := e.Graph.GetNode(nodeID)
if !ok || !matchesCategories(node, e.learningCategories()) {
if !ok || !e.effectiveLearningFilter().Matches(node) {
return
}
text := embeddingText(node)
@@ -1208,6 +1209,18 @@ func researchResultNode(id string, result model.ResearchResult, evidencePath, co
return model.Node{ID: id, Kind: "external", Label: result.Title, Summary: clamp(content, 1800), Status: "research", Origin: "research", ExternalID: result.URL, URI: result.URL, Categories: unique(categories), Weight: weight, Metadata: metadata, UpdatedAt: time.Now().UTC()}
}
func (e *Engine) filterResearchEvidenceForThinking(results []model.ResearchResult, categories []string) []model.ResearchResult {
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}
if filter.Matches(node) {
out = append(out, result)
}
}
return out
}
func formatArticleAnswer(draft model.KnowledgeArticleDraft) string {
var b strings.Builder
b.WriteString(strings.TrimSpace(draft.Answer))
@@ -1432,25 +1445,7 @@ func categoryAffinity(node model.Node, seeds []model.Node) float64 {
}
func matchesCategories(node model.Node, filters []string) bool {
if len(filters) == 0 {
return true
}
wanted := map[string]bool{}
for _, filter := range filters {
wanted[strings.ToLower(strings.TrimSpace(filter))] = true
}
if wanted["*"] {
return true
}
if len(node.Categories) == 0 {
return wanted["__uncategorized__"]
}
for _, category := range node.Categories {
if wanted[strings.ToLower(strings.TrimSpace(category))] {
return true
}
}
return false
return (graph.NodeFilter{Categories: filters}).Matches(node)
}
func metadataString(metadata map[string]any, key string) string {
+11
View File
@@ -479,6 +479,8 @@ func (e *Engine) executeArticleResearchQuery(ctx context.Context, trigger string
assessed := e.rankResearchCandidates(ctx, question, fetched, true)
accepted := make([]model.ResearchResult, 0, len(assessed))
thinkingFilter := e.effectiveThinkingFilter()
researchCategories := e.categoriesForNodeIDs(nodeIDs)
for _, candidate := range assessed {
item := candidate.Result
assessment := candidate.Assessment
@@ -493,6 +495,15 @@ 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}
if !thinkingFilter.Matches(researchNode) {
acceptedByGate = false
if strings.TrimSpace(item.AssessmentReason) == "" {
item.AssessmentReason = "Die Quelle liegt außerhalb des wirksamen Thinking-Quellenfilters."
} else {
item.AssessmentReason += " · außerhalb des wirksamen Thinking-Quellenfilters"
}
}
metadata := mergeResearchMetadata(startMetadata, map[string]any{"result_url": item.URL, "result_title": item.Title, "relevance": item.Relevance, "source_quality": item.SourceQuality, "source_quality_score": item.SourceQualityScore, "actionable": item.Actionable, "covered_gap_ids": item.CoveredGapIDs, "assessment_reason": item.AssessmentReason})
if !acceptedByGate {
stats.Rejected++
+3 -2
View File
@@ -86,8 +86,9 @@ func (e *Engine) researchEvidenceForSources(sources []articleSource) []model.Res
return nil
}
nodes := make([]model.Node, 0, len(externalIDs))
filter := e.effectiveThinkingFilter()
for _, node := range snapshot.Nodes {
if externalIDs[node.ID] && node.Kind == "external" {
if externalIDs[node.ID] && node.Kind == "external" && filter.Matches(node) {
nodes = append(nodes, node)
}
}
@@ -199,7 +200,7 @@ func (e *Engine) learnResearchEvidence(ctx context.Context, results []model.Rese
for _, result := range results {
id := graph.ID("external", result.URL)
node, ok := e.Graph.GetNode(id)
if !ok || !matchesCategories(node, e.learningCategories()) {
if !ok || !e.effectiveLearningFilter().Matches(node) {
continue
}
content := strings.TrimSpace(result.Content)
+18 -15
View File
@@ -425,7 +425,7 @@ func (e *Engine) Scan(ctx context.Context) error {
if err != nil {
return err
}
pendingEmbeddings := len(e.Graph.NodesForEmbeddingFiltered(e.learningCategories()))
pendingEmbeddings := len(e.Graph.NodesForEmbeddingScoped(e.effectiveLearningFilter()))
if e.Graph.Version() != beforeVersion || pendingEmbeddings > 0 {
e.Broker.Publish(model.Activity{Type: "scan.started", Source: "brain", Phase: "ingest", Message: "Neue oder geänderte Wissenselemente werden verarbeitet", Strength: .45, Metadata: map[string]any{"pending_embeddings": pendingEmbeddings}})
}
@@ -461,7 +461,7 @@ func (e *Engine) Scan(ctx context.Context) error {
return nil
}
func (e *Engine) ensureEmbeddings(ctx context.Context) error {
pending := e.Graph.NodesForEmbeddingFiltered(e.learningCategories())
pending := e.Graph.NodesForEmbeddingScoped(e.effectiveLearningFilter())
if len(pending) == 0 {
return nil
}
@@ -492,7 +492,7 @@ func (e *Engine) ensureEmbeddings(ctx context.Context) error {
return nil
}
func (e *Engine) ensureFallbackEmbeddings() {
for _, n := range e.Graph.NodesForEmbeddingFiltered(e.learningCategories()) {
for _, n := range e.Graph.NodesForEmbeddingScoped(e.effectiveLearningFilter()) {
e.Graph.SetVector(n.ID, hashEmbedding(embeddingText(n), 256))
}
}
@@ -538,7 +538,7 @@ func (e *Engine) Query(ctx context.Context, q string) (model.QueryResponse, erro
if err != nil || len(vecs) == 0 {
vecs = [][]float64{hashEmbedding(q, 256)}
}
hits := e.Graph.Similar(vecs[0], e.Cfg.TopK)
hits := e.Graph.SimilarFiltered(vecs[0], e.Cfg.TopK, e.effectiveLearningFilter())
nodeIDs := make([]string, 0, len(hits))
for i, h := range hits {
nodeIDs = append(nodeIDs, h.NodeID)
@@ -648,7 +648,7 @@ func (e *Engine) enrichOne(ctx context.Context, trigger string) (EnrichOutcome,
e.setOllamaOK(true)
}
a, b, sim, ok, comparisons := e.Graph.NextPairFilteredDepth(e.Cfg.SimilarityThreshold, e.Cfg.EnrichAnchors, e.thinkingCategories(), e.Cfg.ArticleMaxGenerationDepth)
a, b, sim, ok, comparisons := e.Graph.NextPairScopedDepth(e.Cfg.SimilarityThreshold, e.Cfg.EnrichAnchors, e.effectiveThinkingFilter(), e.Cfg.ArticleMaxGenerationDepth)
if !ok {
e.stateMu.Lock()
e.lastAttempt = time.Now().UTC()
@@ -685,20 +685,23 @@ func (e *Engine) enrichOne(ctx context.Context, trigger string) (EnrichOutcome,
slog.Warn("research failed", "query", decision.ResearchQuery, "base_url", diagnostic.BaseURL, "kind", diagnostic.ErrorKind, "http_status", diagnostic.HTTPStatus, "duration_ms", diagnostic.DurationMS, "error", err)
e.Broker.Publish(model.Activity{Type: "research.failed", Source: "searxng", Phase: "research", NodeIDs: []string{a.ID, b.ID}, Message: "SearXNG-Recherche ist fehlgeschlagen", Strength: .35, Metadata: metadata})
} else {
resultMetadata := mergeResearchMetadata(researchEventMetadata(trigger, researchID, decision.ResearchQuery, results, time.Since(researchStarted)), researchDiagnosticMetadata(diagnostic))
message := fmt.Sprintf("SearXNG hat %d verwertbare Webquellen geliefert", len(results))
if len(results) == 0 {
message = "SearXNG hat keine verwertbaren Webquellen geliefert"
allowedResults := e.filterResearchEvidenceForThinking(results, unique(append(append([]string{}, a.Categories...), b.Categories...)))
resultMetadata := mergeResearchMetadata(researchEventMetadata(trigger, researchID, decision.ResearchQuery, allowedResults, time.Since(researchStarted)), researchDiagnosticMetadata(diagnostic))
resultMetadata["unfiltered_result_count"] = len(results)
resultMetadata["source_filter_rejected_count"] = len(results) - len(allowedResults)
message := fmt.Sprintf("SearXNG hat %d durch den Thinking-Filter erlaubte Webquellen geliefert", len(allowedResults))
if len(allowedResults) == 0 {
message = "SearXNG-Treffer lagen außerhalb des wirksamen Thinking-Quellenfilters"
}
e.Broker.Publish(model.Activity{Type: "research.results", Source: "searxng", Phase: "research-results", NodeIDs: []string{a.ID, b.ID}, Message: message, Strength: .92, Metadata: resultMetadata})
if len(results) > 0 {
researchResults = results
refs := e.addResearch(a, b, results)
if len(allowedResults) > 0 {
researchResults = allowedResults
refs := e.addResearch(a, b, allowedResults)
ingestMetadata := mergeResearchMetadata(resultMetadata, map[string]any{"result_node_ids": refs.NodeIDs, "result_edge_ids": refs.EdgeIDs})
e.Broker.Publish(model.Activity{Type: "research.ingested", Source: "searxng", Phase: "research-ingest", NodeIDs: append([]string{a.ID, b.ID}, refs.NodeIDs...), EdgeIDs: refs.EdgeIDs, Message: fmt.Sprintf("%d Webquellen wurden als neue Forschungs-Nodes in den Graphen übernommen", len(refs.NodeIDs)), Strength: 1, Metadata: ingestMetadata})
var reviewed model.RelationDecision
reviewSystem := "Bewerte die Beziehung erneut anhand der zwei internen Wissenseinträge und der beigefügten Web-Suchergebnisse. Suchtreffer sind Hinweise, keine garantierten Fakten. Erfinde nichts, kennzeichne verbleibende Unsicherheit und gib ausschließlich JSON nach Schema zurück."
if err := e.Ollama.ChatJSON(ctx, reviewSystem, relationContextWithResearch(a, b, sim, results), relationSchema(), &reviewed); err != nil {
if err := e.Ollama.ChatJSON(ctx, reviewSystem, relationContextWithResearch(a, b, sim, allowedResults), relationSchema(), &reviewed); err != nil {
slog.Warn("research review failed; keeping pre-research decision", "error", err)
} else {
decision = reviewed
@@ -743,7 +746,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, 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{"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} {
@@ -777,7 +780,7 @@ func (e *Engine) Status() map[string]any {
"research_enabled": e.Cfg.ResearchEnabled, "chat_model": e.Cfg.ChatModel, "embedding_model": e.Cfg.EmbeddingModel,
"searxng": e.ResearchStatus(),
"ollama_pool": e.Ollama.PoolStatus(), "persistence": e.Persistence.Status(), "graph_storage": e.Graph.StorageStatus(),
"runtime_settings": e.RuntimeSettings(),
"runtime_settings": e.RuntimeSettingsView(),
}
if e.GLPIKB != nil {
status["glpi_kb"] = e.GLPIKB.Status()
+4 -1
View File
@@ -392,6 +392,9 @@ func TestRuntimeSettingsDisableThinkingAndPersist(t *testing.T) {
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,
@@ -418,7 +421,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 {
if loaded.LearningEnabled || loaded.ThinkingEnabled || loaded.ViewMode != "constellation" || loaded.MaxDisplayNodes != 4321 || !loaded.LowPowerMode || len(loaded.DisplayCategories) != 1 || len(loaded.DisplaySources) != 1 {
t.Fatalf("runtime settings were not restored: %+v", loaded)
}
}
+222 -37
View File
@@ -7,10 +7,12 @@ import (
"sort"
"strings"
"github.com/local/glpi-neural-brain/internal/graph"
"github.com/local/glpi-neural-brain/internal/model"
)
const uncategorizedFilter = "__uncategorized__"
const uncategorizedFilter = graph.UncategorizedFilter
const unsourcedFilter = graph.UnsourcedFilter
type RuntimeSettings struct {
LearningEnabled bool `json:"learning_enabled"`
@@ -18,33 +20,59 @@ type RuntimeSettings struct {
LearningCategories []string `json:"learning_categories"`
DisplayCategories []string `json:"display_categories"`
ThinkingCategories []string `json:"thinking_categories"`
LearningSources []string `json:"learning_sources"`
DisplaySources []string `json:"display_sources"`
ThinkingSources []string `json:"thinking_sources"`
ViewMode string `json:"view_mode"`
MaxDisplayNodes int `json:"max_display_nodes"`
LowPowerMode bool `json:"low_power_mode"`
}
type RuntimeFilterInfo struct {
Categories []string `json:"categories"`
Sources []string `json:"sources"`
CategoriesRestricted bool `json:"categories_restricted"`
SourcesRestricted bool `json:"sources_restricted"`
MatchesNone bool `json:"matches_none"`
}
type RuntimeSettingsView struct {
RuntimeSettings
EffectiveLearning RuntimeFilterInfo `json:"effective_learning"`
EffectiveDisplay RuntimeFilterInfo `json:"effective_display"`
EffectiveThinking RuntimeFilterInfo `json:"effective_thinking"`
AdminLearning RuntimeFilterInfo `json:"admin_learning"`
AdminDisplay RuntimeFilterInfo `json:"admin_display"`
AdminThinking RuntimeFilterInfo `json:"admin_thinking"`
}
type CategoryInfo struct {
Name string `json:"name"`
Count int `json:"count"`
}
type SourceInfo = CategoryInfo
func (e *Engine) defaultRuntimeSettings() RuntimeSettings {
// Category/source values in Config are administrative ceilings. The WebUI
// starts at "all allowed" (empty selection), not by copying the ceiling into
// mutable runtime state.
return normalizeRuntimeSettings(RuntimeSettings{
LearningEnabled: e.Cfg.LearningEnabled,
ThinkingEnabled: e.Cfg.ThinkingEnabled,
LearningCategories: append([]string(nil), e.Cfg.LearningCategories...),
DisplayCategories: append([]string(nil), e.Cfg.DisplayCategories...),
ThinkingCategories: append([]string(nil), e.Cfg.ThinkingCategories...),
ViewMode: e.Cfg.DefaultView,
MaxDisplayNodes: e.Cfg.MaxDisplayNodes,
LowPowerMode: e.Cfg.LowPowerMode,
LearningEnabled: e.Cfg.LearningEnabled,
ThinkingEnabled: e.Cfg.ThinkingEnabled,
ViewMode: e.Cfg.DefaultView,
MaxDisplayNodes: e.Cfg.MaxDisplayNodes,
LowPowerMode: e.Cfg.LowPowerMode,
})
}
func normalizeRuntimeSettings(in RuntimeSettings) RuntimeSettings {
in.LearningCategories = normalizeCategories(in.LearningCategories)
in.DisplayCategories = normalizeCategories(in.DisplayCategories)
in.ThinkingCategories = normalizeCategories(in.ThinkingCategories)
in.LearningCategories = normalizeValues(in.LearningCategories)
in.DisplayCategories = normalizeValues(in.DisplayCategories)
in.ThinkingCategories = normalizeValues(in.ThinkingCategories)
in.LearningSources = normalizeValues(in.LearningSources)
in.DisplaySources = normalizeValues(in.DisplaySources)
in.ThinkingSources = normalizeValues(in.ThinkingSources)
in.ViewMode = strings.ToLower(strings.TrimSpace(in.ViewMode))
if in.ViewMode == "" {
in.ViewMode = "neural"
@@ -61,7 +89,7 @@ func normalizeRuntimeSettings(in RuntimeSettings) RuntimeSettings {
return in
}
func normalizeCategories(values []string) []string {
func normalizeValues(values []string) []string {
seen := map[string]string{}
for _, value := range values {
value = strings.TrimSpace(value)
@@ -81,21 +109,47 @@ func normalizeCategories(values []string) []string {
return out
}
func normalizeCategories(values []string) []string { return normalizeValues(values) }
// loadRuntimeSettings merges individual persisted fields. Older files that do
// not contain newly introduced fields inherit defaults instead of silently
// zeroing unrelated settings.
func (e *Engine) loadRuntimeSettings() {
settings := e.defaultRuntimeSettings()
if strings.TrimSpace(e.runtimePath) != "" {
if data, err := os.ReadFile(e.runtimePath); err == nil {
var stored RuntimeSettings
if json.Unmarshal(data, &stored) == nil {
settings = normalizeRuntimeSettings(stored)
}
mergeRuntimeSettingsJSON(&settings, data)
}
}
settings = normalizeRuntimeSettings(settings)
e.runtimeMu.Lock()
e.runtime = settings
e.runtimeMu.Unlock()
}
func mergeRuntimeSettingsJSON(settings *RuntimeSettings, data []byte) {
var raw map[string]json.RawMessage
if json.Unmarshal(data, &raw) != nil {
return
}
decode := func(key string, target any) {
if value, ok := raw[key]; ok {
_ = json.Unmarshal(value, target)
}
}
decode("learning_enabled", &settings.LearningEnabled)
decode("thinking_enabled", &settings.ThinkingEnabled)
decode("learning_categories", &settings.LearningCategories)
decode("display_categories", &settings.DisplayCategories)
decode("thinking_categories", &settings.ThinkingCategories)
decode("learning_sources", &settings.LearningSources)
decode("display_sources", &settings.DisplaySources)
decode("thinking_sources", &settings.ThinkingSources)
decode("view_mode", &settings.ViewMode)
decode("max_display_nodes", &settings.MaxDisplayNodes)
decode("low_power_mode", &settings.LowPowerMode)
}
func (e *Engine) RuntimeSettings() RuntimeSettings {
e.runtimeMu.RLock()
settings := e.runtime
@@ -103,9 +157,58 @@ func (e *Engine) RuntimeSettings() RuntimeSettings {
settings.LearningCategories = append([]string{}, settings.LearningCategories...)
settings.DisplayCategories = append([]string{}, settings.DisplayCategories...)
settings.ThinkingCategories = append([]string{}, settings.ThinkingCategories...)
settings.LearningSources = append([]string{}, settings.LearningSources...)
settings.DisplaySources = append([]string{}, settings.DisplaySources...)
settings.ThinkingSources = append([]string{}, settings.ThinkingSources...)
return settings
}
func (e *Engine) RuntimeSettingsView() RuntimeSettingsView {
settings := e.RuntimeSettings()
return RuntimeSettingsView{
RuntimeSettings: settings,
EffectiveLearning: filterInfo(e.effectiveLearningFilter()),
EffectiveDisplay: filterInfo(e.effectiveDisplayFilter()),
EffectiveThinking: filterInfo(e.effectiveThinkingFilter()),
AdminLearning: adminFilterInfo(e.Cfg.LearningCategories, e.Cfg.LearningSources),
AdminDisplay: adminFilterInfo(e.Cfg.DisplayCategories, e.Cfg.DisplaySources),
AdminThinking: adminFilterInfo(e.Cfg.ThinkingCategories, e.Cfg.ThinkingSources),
}
}
func filterInfo(filter graph.NodeFilter) RuntimeFilterInfo {
return RuntimeFilterInfo{
Categories: append([]string{}, filter.Categories...),
Sources: append([]string{}, filter.Sources...),
CategoriesRestricted: dimensionRestricted(filter.Categories) || filter.MatchNone,
SourcesRestricted: dimensionRestricted(filter.Sources) || filter.MatchNone,
MatchesNone: filter.MatchNone,
}
}
func adminFilterInfo(categories, sources []string) RuntimeFilterInfo {
categories = normalizeValues(categories)
sources = normalizeValues(sources)
return RuntimeFilterInfo{
Categories: categories,
Sources: sources,
CategoriesRestricted: dimensionRestricted(categories),
SourcesRestricted: dimensionRestricted(sources),
}
}
func dimensionRestricted(values []string) bool {
if len(values) == 0 {
return false
}
for _, value := range values {
if strings.TrimSpace(value) == "*" {
return false
}
}
return true
}
func (e *Engine) SetRuntimeSettings(settings RuntimeSettings) (RuntimeSettings, error) {
if settings.MaxDisplayNodes < 0 || settings.MaxDisplayNodes > 500000 {
return e.RuntimeSettings(), fmt.Errorf("max_display_nodes must be between 0 and 500000")
@@ -137,21 +240,28 @@ func (e *Engine) SetRuntimeSettings(settings RuntimeSettings) (RuntimeSettings,
}
}
view := e.RuntimeSettingsView()
e.Broker.Publish(model.Activity{
Type: "runtime.settings.updated",
Source: "ui",
Phase: "control",
Message: "Lern-, Anzeige- und Thinking-Einstellungen wurden aktualisiert",
Message: "Lern-, Anzeige-, Quellen- und Thinking-Einstellungen wurden aktualisiert",
Strength: .32,
Metadata: map[string]any{
"learning_enabled": settings.LearningEnabled,
"thinking_enabled": settings.ThinkingEnabled,
"learning_categories": len(settings.LearningCategories),
"display_categories": len(settings.DisplayCategories),
"thinking_categories": len(settings.ThinkingCategories),
"view_mode": settings.ViewMode,
"max_display_nodes": settings.MaxDisplayNodes,
"low_power_mode": settings.LowPowerMode,
"learning_enabled": settings.LearningEnabled,
"thinking_enabled": settings.ThinkingEnabled,
"learning_categories": len(settings.LearningCategories),
"display_categories": len(settings.DisplayCategories),
"thinking_categories": len(settings.ThinkingCategories),
"learning_sources": len(settings.LearningSources),
"display_sources": len(settings.DisplaySources),
"thinking_sources": len(settings.ThinkingSources),
"learning_filter_matches_none": view.EffectiveLearning.MatchesNone,
"display_filter_matches_none": view.EffectiveDisplay.MatchesNone,
"thinking_filter_matches_none": view.EffectiveThinking.MatchesNone,
"view_mode": settings.ViewMode,
"max_display_nodes": settings.MaxDisplayNodes,
"low_power_mode": settings.LowPowerMode,
},
})
return settings, nil
@@ -171,20 +281,60 @@ func (e *Engine) ThinkingEnabled() bool {
return enabled
}
func (e *Engine) learningCategories() []string {
e.runtimeMu.RLock()
out := append([]string(nil), e.runtime.LearningCategories...)
e.runtimeMu.RUnlock()
return out
func (e *Engine) effectiveLearningFilter() graph.NodeFilter {
settings := e.RuntimeSettings()
return effectiveNodeFilter(e.Cfg.LearningCategories, settings.LearningCategories, e.Cfg.LearningSources, settings.LearningSources)
}
func (e *Engine) thinkingCategories() []string {
e.runtimeMu.RLock()
out := append([]string(nil), e.runtime.ThinkingCategories...)
e.runtimeMu.RUnlock()
return out
func (e *Engine) effectiveDisplayFilter() graph.NodeFilter {
settings := e.RuntimeSettings()
return effectiveNodeFilter(e.Cfg.DisplayCategories, settings.DisplayCategories, e.Cfg.DisplaySources, settings.DisplaySources)
}
func (e *Engine) effectiveThinkingFilter() graph.NodeFilter {
settings := e.RuntimeSettings()
return effectiveNodeFilter(e.Cfg.ThinkingCategories, settings.ThinkingCategories, e.Cfg.ThinkingSources, settings.ThinkingSources)
}
func effectiveNodeFilter(adminCategories, runtimeCategories, adminSources, runtimeSources []string) graph.NodeFilter {
categories, categoryNone := intersectFilterValues(adminCategories, runtimeCategories)
sources, sourceNone := intersectFilterValues(adminSources, runtimeSources)
return graph.NodeFilter{Categories: categories, Sources: sources, MatchNone: categoryNone || sourceNone}
}
func intersectFilterValues(admin, runtime []string) ([]string, bool) {
admin = normalizeValues(admin)
runtime = normalizeValues(runtime)
adminRestricted := dimensionRestricted(admin)
runtimeRestricted := dimensionRestricted(runtime)
if !adminRestricted && !runtimeRestricted {
return nil, false
}
if adminRestricted && !runtimeRestricted {
return admin, false
}
if !adminRestricted && runtimeRestricted {
return runtime, false
}
allowed := make(map[string]string, len(admin))
for _, value := range admin {
allowed[strings.ToLower(strings.TrimSpace(value))] = value
}
intersection := make([]string, 0)
for _, value := range runtime {
if canonical, ok := allowed[strings.ToLower(strings.TrimSpace(value))]; ok {
intersection = append(intersection, canonical)
}
}
intersection = normalizeValues(intersection)
return intersection, len(intersection) == 0
}
// Compatibility helpers retained for tests and internal callers that only need
// the category projection. New code should use the scoped filters above.
func (e *Engine) learningCategories() []string { return e.effectiveLearningFilter().Categories }
func (e *Engine) thinkingCategories() []string { return e.effectiveThinkingFilter().Categories }
func (e *Engine) Categories() []CategoryInfo {
snapshot := e.Graph.Snapshot()
counts := map[string]int{}
@@ -222,11 +372,46 @@ func (e *Engine) Categories() []CategoryInfo {
if uncategorized > 0 {
out = append(out, CategoryInfo{Name: uncategorizedFilter, Count: uncategorized})
}
sortFilterInfo(out)
return out
}
func (e *Engine) Sources() []SourceInfo {
snapshot := e.Graph.Snapshot()
counts := map[string]int{}
names := map[string]string{}
unsourced := 0
for _, node := range snapshot.Nodes {
if node.Kind != "knowledge" && node.Kind != "ai-think" && node.Kind != "external" {
continue
}
source := strings.TrimSpace(graph.NodeSource(node))
if source == "" {
unsourced++
continue
}
key := strings.ToLower(source)
if _, ok := names[key]; !ok {
names[key] = source
}
counts[key]++
}
result := make([]SourceInfo, 0, len(counts)+1)
for key, count := range counts {
result = append(result, SourceInfo{Name: names[key], Count: count})
}
if unsourced > 0 {
result = append(result, SourceInfo{Name: unsourcedFilter, Count: unsourced})
}
sortFilterInfo(result)
return result
}
func sortFilterInfo(out []CategoryInfo) {
sort.Slice(out, func(i, j int) bool {
if out[i].Count == out[j].Count {
return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name)
}
return out[i].Count > out[j].Count
})
return out
}
+45
View File
@@ -0,0 +1,45 @@
package engine
import (
"testing"
"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")
}
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)
}
}
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"]}`))
if !settings.LearningEnabled || !settings.ThinkingEnabled || settings.ViewMode != "constellation" || settings.MaxDisplayNodes != 5000 || !settings.LowPowerMode {
t.Fatalf("missing JSON fields overwrote defaults: %+v", settings)
}
if len(settings.DisplayCategories) != 1 || len(settings.DisplaySources) != 1 {
t.Fatalf("present JSON fields were not merged: %+v", settings)
}
}