Files
glpi-neural-brain/internal/engine/relation_research_v10_test.go
jbergner 94dbd4ccab
All checks were successful
release-tag / release-image (push) Successful in 2m32s
RC-4
2026-08-09 18:41:47 +02:00

91 lines
4.6 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package engine
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/local/glpi-neural-brain/internal/model"
"github.com/local/glpi-neural-brain/internal/ollama"
)
func TestNormalizeRelationResearchQueryRemovesInternalNodeIDs(t *testing.T) {
a := model.Node{ID: "8c68b454ce45705357ae1831", Label: "AI Safety Guardrails sicher gestalten und härten"}
b := model.Node{ID: "a0808bae9ec114a144632262", Label: "AI Security Guardrails sicher gestalten und härten"}
decision := model.RelationDecision{RelationType: "same_topic", TopicLabel: "AI Guardrails", ResearchQuery: "Überprüfung der Einträge 8c68b454ce45705357ae1831 und a0808bae9ec114a144632262 auf Duplikat oder unterschiedliche Quellen"}
query, rebuilt, reason := normalizeRelationResearchQuery(a, b, decision)
if !rebuilt || reason != "internal_node_id_removed" {
t.Fatalf("expected deterministic rebuild, rebuilt=%v reason=%q query=%q", rebuilt, reason, query)
}
if strings.Contains(query, a.ID) || strings.Contains(query, b.ID) || internalNodeIDPattern.MatchString(query) {
t.Fatalf("internal IDs leaked into rebuilt query: %q", query)
}
for _, want := range []string{"AI Safety Guardrails", "AI Security Guardrails"} {
if !strings.Contains(query, want) {
t.Fatalf("rebuilt query lost visible topic %q: %q", want, query)
}
}
}
func TestNormalizeRelationResearchQueryRebuildsGenericQueryWithoutTopicAnchor(t *testing.T) {
a := model.Node{Label: "Kubernetes Restore Testing"}
b := model.Node{Label: "Kubernetes Backup Validation"}
decision := model.RelationDecision{RelationType: "supports", ResearchQuery: "Einträge prüfen und Unterschiede validieren"}
query, rebuilt, reason := normalizeRelationResearchQuery(a, b, decision)
if !rebuilt || reason != "missing_topic_anchor" || !strings.Contains(strings.ToLower(query), "kubernetes") {
t.Fatalf("expected topic-anchored rebuild, got rebuilt=%v reason=%q query=%q", rebuilt, reason, query)
}
}
func TestRelationSnippetGateRejectsUnrelatedDuplicateTools(t *testing.T) {
question := model.ResearchQuestion{GapID: "relation-evidence", Question: "AI Safety Guardrails und AI Security Guardrails fachlich vergleichen"}
results := []model.ResearchResult{
{Title: "IBAN auf Fehler prüfen und Bankverbindung identifizieren", URL: "https://example.org/iban", Snippet: "IBAN prüfen und Duplikate finden"},
{Title: "Duplikate in Excel finden", URL: "https://example.org/excel", Snippet: "Doppelte Einträge über mehrere Spalten"},
{Title: "AI Security Guardrails technical guidance", URL: "https://docs.example.org/ai-security/guardrails", Snippet: "AI safety and security guardrails, controls and validation"},
}
ranked := rankResearchCandidatesHeuristic(question, results, false)
selection := selectResearchCandidates(question, ranked, map[string]bool{}, 3, 1, .35, .60, .55)
for _, candidate := range selection.Selected {
if strings.Contains(strings.ToLower(candidate.Result.Title), "iban") || strings.Contains(strings.ToLower(candidate.Result.Title), "excel") {
t.Fatalf("unrelated duplicate-tool result passed relation topic gate: %+v", candidate.Result)
}
}
}
func TestReconsiderOperationalArticleTypeCanChooseReference(t *testing.T) {
mock := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/tags":
_ = json.NewEncoder(w).Encode(map[string]any{"models": []map[string]any{{"name": "qwen3:8b", "digest": "chat"}, {"name": "embeddinggemma", "digest": "embed"}}})
case "/api/chat":
_ = json.NewEncoder(w).Encode(map[string]any{"message": map[string]any{"content": `{"action":"reclassify","article_type":"reference","reason":"Die Evidenz trägt technische Zuordnungen, aber keinen belastbaren Lösungsablauf."}`}})
default:
http.NotFound(w, r)
}
}))
defer mock.Close()
client := ollama.New(mock.URL, "qwen3:8b", "embeddinggemma")
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
client.Start(ctx)
deadline := time.Now().Add(time.Second)
for client.PoolStatus()["healthy_nodes"].(int) < 1 && time.Now().Before(deadline) {
time.Sleep(10 * time.Millisecond)
}
e := &Engine{Ollama: client}
plan := model.ArticlePlanDecision{ArticleType: "troubleshooting", ExpectedValue: "ATT&CK-Techniken einordnen"}
content := model.KnowledgeArticleContent{Title: "ATT&CK-Techniken", TechnicalDetails: []string{"T1018"}, Mappings: []string{"Profil → T1018"}}
result, err := e.reconsiderOperationalArticleType(context.Background(), plan, content, nil, nil)
if err != nil {
t.Fatal(err)
}
if result.Action != "reclassify" || result.ArticleType != "reference" {
t.Fatalf("unexpected reconsideration: %+v", result)
}
}