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
+116
View File
@@ -0,0 +1,116 @@
package graph
import (
"fmt"
"net/url"
"strings"
"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.
type NodeFilter struct {
Categories []string
Sources []string
MatchNone bool
}
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 {
return true
}
wanted := make(map[string]struct{}, len(filters))
for _, filter := range filters {
filter = strings.ToLower(strings.TrimSpace(filter))
if filter != "" {
wanted[filter] = struct{}{}
}
}
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 {
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.
func NodeSource(node model.Node) string {
values := nodeSources(node)
if len(values) == 0 {
return ""
}
return values[0]
}
func nodeSources(node model.Node) []string {
if source := metadataText(node.Metadata, "source"); source != "" {
return []string{source}
}
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
}
func metadataText(metadata map[string]any, key string) string {
if metadata == nil {
return ""
}
value, ok := metadata[key]
if !ok || value == nil {
return ""
}
text := strings.TrimSpace(fmt.Sprint(value))
if text == "" || strings.EqualFold(text, "<nil>") || strings.EqualFold(text, "null") {
return ""
}
return text
}
+76
View File
@@ -0,0 +1,76 @@
package graph
import (
"testing"
"github.com/local/glpi-neural-brain/internal/model"
)
func TestNodeFilterCombinesCategoryAndSource(t *testing.T) {
node := model.Node{
Kind: "knowledge",
Categories: []string{"IT-Security", "Cloud"},
Origin: "knowledge-production",
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{Categories: []string{"Cloud"}, Sources: []string{"internal-kb"}}).Matches(node) {
t.Fatal("source mismatch must reject even when category matches")
}
if (NodeFilter{Categories: []string{"Backup"}, Sources: []string{"GLPI Knowledge Base"}}).Matches(node) {
t.Fatal("category mismatch must reject even when source matches")
}
if (NodeFilter{MatchNone: true}).Matches(node) {
t.Fatal("MatchNone must reject every node")
}
}
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")
}
research := model.Node{Kind: "external", Origin: "research", URI: "https://docs.example.org/guide", Categories: []string{"Cloud"}}
if got := NodeSource(research); got != "docs.example.org" {
t.Fatalf("unexpected research source %q", got)
}
if !(NodeFilter{Sources: []string{"DOCS.EXAMPLE.ORG"}}).Matches(research) {
t.Fatal("research host should be source-filterable")
}
}
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"}},
"c": {ID: "c", Kind: "knowledge", Label: "C", Categories: []string{"Cloud"}, Metadata: map[string]any{"source": "GLPI Knowledge Base"}},
},
edges: map[string]model.Edge{},
vectors: map[string][]float32{"a": {1, 0}, "b": {.99, .01}, "c": {1, 0}},
dirtyNodes: map[string]uint64{},
dirtyEdges: map[string]uint64{},
dirtyVectors: map[string]uint64{},
deletedNodes: map[string]uint64{},
deletedEdges: map[string]uint64{},
deletedVectors: map[string]uint64{},
}
filter := NodeFilter{Categories: []string{"Cloud"}, Sources: []string{"internal-kb"}}
hits := s.SimilarFiltered([]float64{1, 0}, 10, filter)
if len(hits) != 2 {
t.Fatalf("retrieval escaped 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)
}
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)
}
delete(s.vectors, "c")
if pending := s.NodesForEmbeddingScoped(NodeFilter{Sources: []string{"GLPI Knowledge Base"}}); len(pending) != 1 || pending[0].ID != "c" {
t.Fatalf("embedding scope escaped source filter: %+v", pending)
}
}
+16 -33
View File
@@ -232,10 +232,14 @@ func (s *Store) ClearVectorsByDimension(dim int) int {
}
func (s *Store) NodesForEmbedding() []model.Node {
return s.NodesForEmbeddingFiltered(nil)
return s.NodesForEmbeddingScoped(NodeFilter{})
}
func (s *Store) NodesForEmbeddingFiltered(categories []string) []model.Node {
return s.NodesForEmbeddingScoped(NodeFilter{Categories: categories})
}
func (s *Store) NodesForEmbeddingScoped(filter NodeFilter) []model.Node {
s.mu.RLock()
defer s.mu.RUnlock()
out := []model.Node{}
@@ -243,7 +247,7 @@ func (s *Store) NodesForEmbeddingFiltered(categories []string) []model.Node {
if n.Kind != "knowledge" && n.Kind != "ai-think" && n.Kind != "external" {
continue
}
if !nodeMatchesCategories(n, categories) {
if !filter.Matches(n) {
continue
}
if _, ok := s.vectors[n.ID]; !ok {
@@ -513,12 +517,16 @@ func (s *Store) Snapshot() model.Snapshot {
return model.Snapshot{Version: s.version, Nodes: n, Edges: e, UpdatedAt: time.Now().UTC()}
}
func (s *Store) Similar(query []float64, limit int) []model.Hit {
return s.SimilarFiltered(query, limit, NodeFilter{})
}
func (s *Store) SimilarFiltered(query []float64, limit int, filter NodeFilter) []model.Hit {
s.mu.RLock()
defer s.mu.RUnlock()
hits := []model.Hit{}
for id, v := range s.vectors {
n, ok := s.nodes[id]
if !ok || (n.Kind != "knowledge" && n.Kind != "ai-think" && n.Kind != "external") {
if !ok || (n.Kind != "knowledge" && n.Kind != "ai-think" && n.Kind != "external") || !filter.Matches(n) {
continue
}
score := cosineMixed(query, v)
@@ -544,6 +552,10 @@ func (s *Store) NextPairFiltered(min float64, anchorLimit int, categories []stri
}
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) NextPairScopedDepth(min float64, anchorLimit int, filter NodeFilter, maxAIDepth int) (model.Node, model.Node, float64, bool, int) {
s.mu.Lock()
defer s.mu.Unlock()
@@ -552,7 +564,7 @@ func (s *Store) NextPairFilteredDepth(min float64, anchorLimit int, categories [
if n.Kind != "knowledge" && n.Kind != "ai-think" {
continue
}
if !nodeMatchesCategories(n, categories) {
if !filter.Matches(n) {
continue
}
if n.Kind == "ai-think" && maxAIDepth > 0 && graphNodeGenerationDepth(n) >= maxAIDepth {
@@ -660,35 +672,6 @@ func (s *Store) ConnectingEdges(ids []string) []string {
}
return out
}
func nodeMatchesCategories(n model.Node, filters []string) bool {
if len(filters) == 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{}{}
}
}
if len(wanted) == 0 {
return true
}
if _, ok := wanted["*"]; ok {
return true
}
if len(n.Categories) == 0 {
_, ok := wanted["__uncategorized__"]
return ok
}
for _, category := range n.Categories {
if _, ok := wanted[strings.ToLower(strings.TrimSpace(category))]; ok {
return true
}
}
return false
}
func graphNodeGenerationDepth(n model.Node) int {
if n.Kind != "ai-think" {
return 0