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) } }