Files
glpi-neural-brain/internal/engine/article_adaptive_test.go
jbergner 440423c5b6
All checks were successful
release-tag / release-image (push) Successful in 2m43s
RC-3
2026-08-09 11:29:13 +02:00

274 lines
16 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 (
"testing"
"github.com/local/glpi-neural-brain/internal/config"
"github.com/local/glpi-neural-brain/internal/model"
)
func TestDetectArticleFreshnessNeedStaticTopicDoesNotForceWeb(t *testing.T) {
plan := model.ArticlePlanDecision{ExpectedValue: "Least Privilege in Windows-Netzwerken konfigurieren", ArticleType: "how_to"}
relation := model.RelationDecision{TopicLabel: "Least Privilege", Keywords: []string{"Windows", "Berechtigungen"}}
got := detectArticleFreshnessNeed(plan, relation, []articleSource{{Node: model.Node{Label: "Least Privilege Grundlagen"}}})
if got.Required {
t.Fatalf("static configuration topic must not force web research: %+v", got)
}
}
func TestDetectArticleFreshnessNeedCurrentVersionForcesWeb(t *testing.T) {
plan := model.ArticlePlanDecision{ExpectedValue: "Aktuell unterstützte Kubernetes Versionen und Supportstatus"}
got := detectArticleFreshnessNeed(plan, model.RelationDecision{}, nil)
if !got.Required || len(got.Queries) == 0 {
t.Fatalf("freshness-sensitive topic should trigger focused web research: %+v", got)
}
}
func TestEffectiveArticleResearchStrategyAutoFollowsProcessingMode(t *testing.T) {
e := &Engine{Cfg: config.Config{ArticleResearchStrategy: "auto"}}
e.runtime.ProcessingMode = "clustered"
if got := e.effectiveArticleResearchStrategy(); got != "adaptive" {
t.Fatalf("clustered auto strategy=%q", got)
}
e.runtime.ProcessingMode = "precise"
if got := e.effectiveArticleResearchStrategy(); got != "always" {
t.Fatalf("precise auto strategy=%q", got)
}
}
func TestNormalizeArticleContentClearsUnusedResearchQueries(t *testing.T) {
got := normalizeArticleContent(model.KnowledgeArticleContent{ResearchNeeded: false, ResearchQueries: []string{"current version"}, ResearchReason: "not needed"})
if len(got.ResearchQueries) != 0 {
t.Fatalf("unused author research queries must be cleared: %#v", got.ResearchQueries)
}
}
func TestArticleSourceFingerprintChangesWithContent(t *testing.T) {
plan := model.ArticlePlanDecision{Action: "update", TargetArticleID: "target", ArticleType: "how_to"}
a := []articleSource{{Node: model.Node{ID: "A"}, Content: "old content"}}
b := []articleSource{{Node: model.Node{ID: "A"}, Content: "new content"}}
if articleSourceFingerprint(a, plan, nil, "test-pipeline") == articleSourceFingerprint(b, plan, nil, "test-pipeline") {
t.Fatal("source fingerprint must change when source content changes")
}
}
func TestReviewReferenceUsesExactReviewerSubset(t *testing.T) {
full := []model.ResearchResult{
{Title: "weak", URL: "https://a.example/weak", Relevance: .1, SourceQualityScore: .1},
{Title: "best", URL: "https://b.example/best", Relevance: .99, SourceQualityScore: .99, Fetched: true},
{Title: "other", URL: "https://c.example/other", Relevance: .8, SourceQualityScore: .8, Fetched: true},
}
subset := selectReviewEvidence(full, 2)
if len(subset) != 2 || subset[0].URL != "https://b.example/best" {
t.Fatalf("unexpected reviewer subset: %#v", subset)
}
grounded := reviewedResearchEvidence(subset, []model.ArticleClaimReview{{Claim: "x", Verdict: "supported", SourceRefs: []string{"R1"}}})
if len(grounded) != 1 || grounded[0].URL != "https://b.example/best" {
t.Fatalf("R1 must resolve against the exact reviewer subset, got %#v", grounded)
}
}
func TestClusterPendingArticleCandidatesGroupsSharedSeed(t *testing.T) {
shared := model.Node{ID: "shared", Label: "Backup Repository", Categories: []string{"Backup"}}
a := &pendingArticleCandidate{Seeds: []model.Node{shared, {ID: "a", Label: "Ransomware Schutz"}}, Relation: model.RelationDecision{TopicLabel: "Backup Hardening"}}
b := &pendingArticleCandidate{Seeds: []model.Node{shared, {ID: "b", Label: "Immutable Backup"}}, Relation: model.RelationDecision{TopicLabel: "Backup Resilience"}}
clusters := clusterPendingArticleCandidates([]*pendingArticleCandidate{a, b})
if len(clusters) != 1 || len(clusters[0]) != 2 {
t.Fatalf("shared seed should produce one article cluster: %#v", clusters)
}
}
func TestClusterPendingArticleCandidatesDoesNotLetSharedGenericSeedBypassTopicGuard(t *testing.T) {
shared := model.Node{ID: "shared", Label: "Security-Handbuch Übersicht"}
a := &pendingArticleCandidate{Seeds: []model.Node{shared, {ID: "water", Label: "Water Leak Detection"}}, Relation: model.RelationDecision{TopicLabel: "Water Leak Detection"}}
b := &pendingArticleCandidate{Seeds: []model.Node{shared, {ID: "bgp", Label: "BGP Prefix Filtering"}}, Relation: model.RelationDecision{TopicLabel: "BGP Prefix Filtering"}}
clusters := clusterPendingArticleCandidates([]*pendingArticleCandidate{a, b})
if len(clusters) != 2 {
t.Fatalf("shared generic seed must not bypass strict topic guard, got %#v", clusters)
}
}
func TestArticleWorkFingerprintChangesWhenGroundedResearchChanges(t *testing.T) {
sources := []articleSource{{Node: model.Node{ID: "A"}, Content: "stable internal"}}
relation := model.RelationDecision{RelationType: "same_topic", TopicLabel: "Backup Hardening", Keywords: []string{"backup"}}
a := articleWorkFingerprint(sources, relation, []model.ResearchResult{{URL: "https://example.test/a", Content: "old external evidence"}}, "test-pipeline")
b := articleWorkFingerprint(sources, relation, []model.ResearchResult{{URL: "https://example.test/a", Content: "new external evidence"}}, "test-pipeline")
if a == b {
t.Fatal("new grounded research must invalidate the work fingerprint")
}
}
func TestClusterPendingArticleCandidatesDoesNotMergeSecurityBoilerplateTopics(t *testing.T) {
values := []*pendingArticleCandidate{
{Seeds: []model.Node{{ID: "water-a", Label: "Water Leak Detection präventiv absichern"}, {ID: "water-b", Label: "Water Leak Detection Vorfälle erkennen und untersuchen"}}, Relation: model.RelationDecision{TopicLabel: "Water Leak Detection Sicherheitsmaßnahmen"}},
{Seeds: []model.Node{{ID: "triple-a", Label: "Triple Extortion Risiko präventiv und resilient gestalten"}, {ID: "triple-b", Label: "Triple Extortion Risiko incident-forensisch untersuchen"}}, Relation: model.RelationDecision{TopicLabel: "Triple Extortion Risiko Sicherheitsmaßnahmen"}},
{Seeds: []model.Node{{ID: "bgp-a", Label: "BGP Prefix Filtering sicher gestalten und härten"}, {ID: "bgp-b", Label: "BGP Prefix Filtering bei Sicherheitsvorfällen untersuchen"}}, Relation: model.RelationDecision{TopicLabel: "BGP Prefix Filtering Sicherheitsmaßnahmen"}},
}
clusters := clusterPendingArticleCandidates(values)
if len(clusters) != 3 {
t.Fatalf("unrelated template-heavy security topics must remain separate, got %d clusters: %#v", len(clusters), clusters)
}
}
func TestClusterPendingArticleCandidatesMergesStrongTopicOverlap(t *testing.T) {
a := &pendingArticleCandidate{Seeds: []model.Node{{ID: "a1", Label: "AI Security Guardrails härten"}}, Relation: model.RelationDecision{TopicLabel: "AI Security Guardrails"}}
b := &pendingArticleCandidate{Seeds: []model.Node{{ID: "b1", Label: "AI Safety Guardrails überwachen"}}, Relation: model.RelationDecision{TopicLabel: "AI Safety Guardrails"}}
clusters := clusterPendingArticleCandidates([]*pendingArticleCandidate{a, b})
if len(clusters) != 1 {
t.Fatalf("strong topic/entity overlap should still batch, got %#v", clusters)
}
}
func TestArticleDraftTopicConflictGroupsRejectsObservedWaterLeakMix(t *testing.T) {
draft := model.KnowledgeArticleDraft{Title: "Water Leak Detection in der IT-Security"}
sources := []articleSource{
{Node: model.Node{ID: "w1", Label: "Water Leak Detection präventiv absichern"}},
{Node: model.Node{ID: "w2", Label: "Water Leak Detection Vorfälle erkennen und untersuchen"}},
{Node: model.Node{ID: "t1", Label: "Triple Extortion Risiko präventiv und resilient gestalten"}},
{Node: model.Node{ID: "t2", Label: "Triple Extortion Risiko incident-forensisch untersuchen"}},
{Node: model.Node{ID: "b1", Label: "BGP Prefix Filtering sicher entwerfen und härten"}},
{Node: model.Node{ID: "b2", Label: "BGP Prefix Filtering bei Sicherheitsvorfällen untersuchen"}},
}
conflicts := articleDraftTopicConflictGroups(draft, sources)
if len(conflicts) != 2 {
t.Fatalf("expected Triple Extortion and BGP conflict groups, got %#v", conflicts)
}
}
func TestArticleDraftTopicConflictGroupsAllowsSingleSupportingOutlier(t *testing.T) {
draft := model.KnowledgeArticleDraft{Title: "HAProxy Hardening und Überwachung"}
sources := []articleSource{
{Node: model.Node{ID: "h1", Label: "HAProxy Hardening präventiv absichern"}},
{Node: model.Node{ID: "h2", Label: "HAProxy Hardening forensisch untersuchen"}},
{Node: model.Node{ID: "tls", Label: "TLS Cipher Suites Referenz"}},
}
if conflicts := articleDraftTopicConflictGroups(draft, sources); len(conflicts) != 0 {
t.Fatalf("one supporting outlier must not reject a coherent article: %#v", conflicts)
}
}
func TestArticleTopicTermsIgnoreTemplatedSuffix(t *testing.T) {
a := articleTopicTermsFromText("Rate Limit Testing Ergebnisse verifizieren")
b := articleTopicTermsFromText("Purple Team Lessons Learned Ergebnisse verifizieren")
if a["ergebnisse"] || a["verifizieren"] || b["ergebnisse"] || b["verifizieren"] {
t.Fatalf("templated suffix leaked into topic terms: a=%v b=%v", a, b)
}
if boolSetIntersectionSize(a, b) != 0 {
t.Fatalf("unrelated topics became related through suffix terms: a=%v b=%v", a, b)
}
}
func TestFilterTopicCoherentArticleSourcesKeepsOnlyOneSupportingOutlier(t *testing.T) {
sources := []articleSource{
{Node: model.Node{ID: "r1", Label: "Rate Limit Testing planen"}, Score: 10},
{Node: model.Node{ID: "r2", Label: "Rate Limit Testing verifizieren"}, Score: 9},
{Node: model.Node{ID: "w1", Label: "Web Security Testing planen"}, Score: 8.5},
{Node: model.Node{ID: "p1", Label: "Purple Team Lessons Learned planen"}, Score: 8},
{Node: model.Node{ID: "s1", Label: "Security Test Reporting dokumentieren"}, Score: 7},
}
kept, removed := filterTopicCoherentArticleSources(sources, nil, model.RelationDecision{TopicLabel: "Rate Limit Testing"})
if len(kept) != 3 || len(removed) != 2 {
t.Fatalf("expected two topical sources plus one outlier; kept=%v removed=%v", nodeIDsFromArticleSources(kept), nodeIDsFromArticleSources(removed))
}
if kept[2].Node.ID != "w1" || removed[0].Node.ID != "p1" || removed[1].Node.ID != "s1" {
t.Fatalf("highest-scoring outlier should be retained, kept=%v removed=%v", nodeIDsFromArticleSources(kept), nodeIDsFromArticleSources(removed))
}
}
func TestArticleSourceIDJaccardMatchesObservedOverlap(t *testing.T) {
wanted := map[string]bool{"a": true, "b": true, "c": true, "d": true, "e": true, "f": true, "g": true, "h": true}
existing := []string{"a", "b", "c", "d", "e", "f", "x", "y"}
if got := articleSourceIDJaccard(wanted, existing); got != .6 {
t.Fatalf("expected 0.6 source jaccard, got %.4f", got)
}
}
func TestArticlePlanTopicTermsPreferMergeTarget(t *testing.T) {
plan := model.ArticlePlanDecision{Action: "merge", TargetArticleID: "target", ExpectedValue: "Generischer Security-Artikel"}
sources := []articleSource{
{Node: model.Node{ID: "target", Label: "Ransomware Containment im Vorfall erkennen"}},
{Node: model.Node{ID: "other", Label: "Ransomware Detection präventiv gestalten"}},
}
terms := articlePlanTopicTerms(plan, sources)
if !terms["ransomware"] || !terms["containment"] || terms["security"] {
t.Fatalf("merge target should define the core topic, got %v", terms)
}
}
func TestArticleDirectTopicSourceCountDoesNotCountSupportingOutlier(t *testing.T) {
sources := []articleSource{
{Node: model.Node{ID: "r1", Label: "Rate Limit Testing planen"}},
{Node: model.Node{ID: "r2", Label: "Rate Limit Testing verifizieren"}},
{Node: model.Node{ID: "w1", Label: "Web Security Testing planen"}},
{Node: model.Node{ID: "p1", Label: "Purple Team Lessons Learned planen"}},
}
if got := articleDirectTopicSourceCount(sources, nil, model.RelationDecision{TopicLabel: "Rate Limit Testing"}); got != 2 {
t.Fatalf("supporting outlier must not satisfy direct topic evidence, got %d", got)
}
}
func TestArticlePlanOperationalResearchQueryUsesTopicAndTaskType(t *testing.T) {
plan := model.ArticlePlanDecision{ArticleType: "how_to", ExpectedValue: "Generischer Plan"}
got := articlePlanOperationalResearchQuery(plan, model.RelationDecision{TopicLabel: "Rate Limit Testing"})
if got == "" || got[:18] != "Rate Limit Testing" {
t.Fatalf("query must start from relation topic, got %q", got)
}
trouble := articlePlanOperationalResearchQuery(model.ArticlePlanDecision{ArticleType: "troubleshooting"}, model.RelationDecision{TopicLabel: "Kafka Netzwerkzugriff"})
if trouble == "" || trouble == got {
t.Fatalf("troubleshooting query should be task-specific, got %q", trouble)
}
}
func TestObservedRateLimitClusterIsReducedToDirectTopicEvidence(t *testing.T) {
sources := []articleSource{
{Node: model.Node{ID: "rate-verify", Label: "Rate Limit Testing Ergebnisse verifizieren"}, Score: 10},
{Node: model.Node{ID: "report", Label: "Security Test Reporting Ergebnisse verifizieren"}, Score: 9},
{Node: model.Node{ID: "web-plan", Label: "Web Security Testing sicher planen und durchführen"}, Score: 8},
{Node: model.Node{ID: "purple-verify", Label: "Purple Team Lessons Learned Ergebnisse verifizieren"}, Score: 7},
{Node: model.Node{ID: "purple-plan", Label: "Purple Team Lessons Learned sicher planen und durchführen"}, Score: 6},
{Node: model.Node{ID: "rate-plan", Label: "Rate Limit Testing sicher planen und durchführen"}, Score: 5},
{Node: model.Node{ID: "architecture", Label: "Security Architecture Testing sicher planen und durchführen"}, Score: 4},
{Node: model.Node{ID: "web-verify", Label: "Web Security Testing Ergebnisse verifizieren"}, Score: 3},
}
kept, removed := filterTopicCoherentArticleSources(sources, nil, model.RelationDecision{TopicLabel: "Rate Limit Testing"})
if got := articleDirectTopicSourceCount(kept, nil, model.RelationDecision{TopicLabel: "Rate Limit Testing"}); got != 2 {
t.Fatalf("observed cluster should contain exactly two direct Rate Limit sources after filtering, got %d (%v)", got, nodeIDsFromArticleSources(kept))
}
if len(kept) != 3 || len(removed) != 5 {
t.Fatalf("observed 8-source cluster should become 2 direct + 1 support; kept=%v removed=%v", nodeIDsFromArticleSources(kept), nodeIDsFromArticleSources(removed))
}
}
func TestFilterTopicCoherentArticleSourcesKeepsRequiredAutonomousSeeds(t *testing.T) {
sources := []articleSource{
{Node: model.Node{ID: "a", Kind: "knowledge", Status: "production", Label: "OWASP SAMM Governance"}},
{Node: model.Node{ID: "b", Kind: "knowledge", Status: "production", Label: "MITRE ATT&CK Mapping"}},
{Node: model.Node{ID: "c", Kind: "knowledge", Status: "production", Label: "Unrelated Proxmox Hardening"}},
}
required := map[string]bool{"a": true, "b": true}
kept, removed := filterTopicCoherentArticleSources(sources, nil, model.RelationDecision{TopicLabel: "Frameworks & Standards / Security Framework / Standard"}, required)
if len(kept) != 3 {
t.Fatalf("required seeds plus at most one supporting source should remain, got kept=%#v removed=%#v", kept, removed)
}
ids := map[string]bool{}
for _, source := range kept {
ids[source.Node.ID] = true
}
if !ids["a"] || !ids["b"] {
t.Fatalf("required autonomous seeds must survive topic filtering: %#v", ids)
}
}
func TestMergeRequiredArticleSourceIDsPrependsOpportunitySeeds(t *testing.T) {
got := mergeRequiredArticleSourceIDs([]string{"c", "a"}, []string{"a", "b", "c", "d"}, map[string]bool{"a": true, "b": true})
want := []string{"a", "b", "c"}
if len(got) != len(want) {
t.Fatalf("unexpected merged ids: %#v", got)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("required source ordering mismatch: got %#v want %#v", got, want)
}
}
}