All checks were successful
release-tag / release-image (push) Successful in 2m43s
286 lines
14 KiB
Go
286 lines
14 KiB
Go
package engine
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/local/glpi-neural-brain/internal/config"
|
|
"github.com/local/glpi-neural-brain/internal/graph"
|
|
"github.com/local/glpi-neural-brain/internal/model"
|
|
)
|
|
|
|
func TestArticleContentToDraftFormatsConceptWithoutInventedSteps(t *testing.T) {
|
|
content := model.KnowledgeArticleContent{
|
|
Title: "Btrfs-Snapshots und ZFS-History in einer Timeline einordnen",
|
|
ProblemDescription: "Bei der forensischen Timeline-Analyse müssen Dateisystemartefakte mit unterschiedlicher Semantik korrekt eingeordnet werden.",
|
|
Scope: "Gilt für die vergleichende Analyse von Btrfs- und ZFS-Artefakten.",
|
|
KeyPoints: []string{
|
|
"Ein Snapshot beschreibt einen referenzierten Dateisystemzustand und nicht automatisch eine vollständige Ereignishistorie.",
|
|
"Zeitstempel müssen mit Artefakttyp, Erzeugungsmechanismus und Datenquelle dokumentiert werden.",
|
|
},
|
|
DecisionCriteria: []string{
|
|
"Für eine Ereignistimeline sind nur Zeitangaben geeignet, deren Herkunft und Semantik nachvollziehbar sind.",
|
|
"Snapshot-Zeitpunkte dürfen nicht ohne zusätzliche Belege als Zeitpunkt jeder enthaltenen Dateiänderung interpretiert werden.",
|
|
},
|
|
}
|
|
|
|
draft := articleContentToDraft(content, []string{"S1", "S2"}, "concept")
|
|
if strings.TrimSpace(draft.Answer) == "" {
|
|
t.Fatal("concept article must have a useful answer without artificial solution steps")
|
|
}
|
|
if !strings.Contains(draft.Answer, "## Kernaussagen") || !strings.Contains(draft.Answer, "## Einordnung und Abgrenzung") {
|
|
t.Fatalf("concept sections missing: %s", draft.Answer)
|
|
}
|
|
if strings.Contains(draft.Answer, "1. ") {
|
|
t.Fatalf("concept article contains invented numbered steps: %s", draft.Answer)
|
|
}
|
|
}
|
|
|
|
func TestArticleContentToDraftKeepsOperationalStepsNumbered(t *testing.T) {
|
|
content := model.KnowledgeArticleContent{
|
|
Title: "Audit Logging prüfen",
|
|
ProblemDescription: "Audit-Ereignisse fehlen in der zentralen Protokollierung.",
|
|
SolutionSteps: []string{
|
|
"Aktivieren Sie die zentrale Audit-Protokollierung.",
|
|
"Erzeugen Sie ein dokumentiertes Testereignis.",
|
|
},
|
|
}
|
|
|
|
draft := articleContentToDraft(content, []string{"S1"}, "troubleshooting")
|
|
if !strings.Contains(draft.Answer, "1. Aktivieren") || !strings.Contains(draft.Answer, "2. Erzeugen") {
|
|
t.Fatalf("operational steps were not numbered: %s", draft.Answer)
|
|
}
|
|
}
|
|
|
|
func TestNormalizeArticleContentCleansConceptFields(t *testing.T) {
|
|
content := normalizeArticleContent(model.KnowledgeArticleContent{
|
|
KeyPoints: []string{" 1. Erster Punkt ", "Erster Punkt"},
|
|
DecisionCriteria: []string{" - Kriterium A ", ""},
|
|
})
|
|
if len(content.KeyPoints) != 1 || content.KeyPoints[0] != "Erster Punkt" {
|
|
t.Fatalf("unexpected key points: %#v", content.KeyPoints)
|
|
}
|
|
if len(content.DecisionCriteria) != 1 || content.DecisionCriteria[0] != "Kriterium A" {
|
|
t.Fatalf("unexpected decision criteria: %#v", content.DecisionCriteria)
|
|
}
|
|
}
|
|
|
|
func TestArticleQualityContextCarriesArticleType(t *testing.T) {
|
|
e := &Engine{}
|
|
contextValue := e.articleQualityContext(model.KnowledgeArticleDraft{Title: "Vergleich", Text: "Beschreibung", Answer: "Kernaussagen"}, "concept", nil, nil)
|
|
if !strings.Contains(contextValue, "ARTIKELTYP: concept") {
|
|
t.Fatalf("article type missing from quality context: %s", contextValue)
|
|
}
|
|
}
|
|
|
|
func TestArticleDraftContextCarriesArticleType(t *testing.T) {
|
|
e := &Engine{Cfg: config.Config{MaxContextChars: 4000}}
|
|
contextValue := e.articleDraftContext(nil, model.ArticlePlanDecision{ArticleType: "decision_guide", Action: "create"}, model.KnowledgeBrief{}, nil)
|
|
if !strings.Contains(contextValue, "ARTIKELTYP: decision_guide") {
|
|
t.Fatalf("article type missing from draft context: %s", contextValue)
|
|
}
|
|
}
|
|
|
|
func TestValidateArticleDraftRejectsShallowConceptAndAcceptsSubstantialReference(t *testing.T) {
|
|
e := &Engine{Cfg: config.Config{
|
|
ArticleMinTextChars: 100,
|
|
ArticleMinAnswerChars: 420,
|
|
ArticleMinConfidence: .7,
|
|
ArticleMinSources: 1,
|
|
ArticleMinProductionRatio: 1,
|
|
ArticleMaxGenerationDepth: 2,
|
|
}}
|
|
sources := []articleSource{{Node: model.Node{Kind: "knowledge", Status: "production"}}}
|
|
short := model.KnowledgeArticleDraft{
|
|
Title: "Mobile Authentifizierung einordnen",
|
|
Text: strings.Repeat("Fachlich belegte Einordnung. ", 6),
|
|
Answer: "## Kernaussagen\n- Authentifizierung bestätigt eine Identität.\n- Biometrie kann die lokale Prüfung unterstützen.\n\n## Abgrenzung\nAutorisierung regelt anschließend erlaubte Aktionen.",
|
|
Confidence: .9,
|
|
}
|
|
if err := e.validateArticleDraft(short, "concept", sources, 1, 1); err == nil {
|
|
t.Fatal("short concept note must no longer pass as a knowledge article")
|
|
}
|
|
paragraph := "Die technische Einordnung beschreibt Identitätsprüfung, Vertrauensanker, Gerätezustand, Sitzungsbindung und die Abgrenzung zur Autorisierung anhand konkreter Betriebs- und Sicherheitsaspekte. "
|
|
long := model.KnowledgeArticleDraft{
|
|
Title: "Referenz zur mobilen Authentifizierung",
|
|
Text: strings.Repeat("Die Referenz ordnet den Einsatzkontext und die fachliche Zielsetzung nachvollziehbar ein. ", 8),
|
|
Answer: "## Kernaussagen\n" + strings.Repeat(paragraph, 5) + "\n\n## Technischer Hintergrund\n" + strings.Repeat(paragraph, 5) + "\n\n## Technische Zuordnung und Details\n" + strings.Repeat(paragraph, 5) + "\n\n## Operative Nutzung\n" + strings.Repeat(paragraph, 5) + "\n\n## Grenzen und Fehlinterpretationen\n" + strings.Repeat(paragraph, 5) + "\n\n- Abgrenzung zur Autorisierung dokumentieren.\n- Betriebsgrenzen und Fehlinterpretationen benennen.\n- Technische Zuordnung erläutern.\n- Vertrauensanker erklären.\n- Gerätezustand einordnen.\n- Sitzungsbindung abgrenzen.",
|
|
Confidence: .9,
|
|
}
|
|
if err := e.validateArticleDraft(long, "reference", sources, 1, 1); err != nil {
|
|
t.Fatalf("substantial reference should pass deterministic final validation: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestArticleDraftValidationMetadataIsStructured(t *testing.T) {
|
|
err := newArticleDraftValidationError("answer_too_short", "answer", 311, 420, "too short")
|
|
metadata := articleDraftValidationMetadata(err)
|
|
if metadata["reason"] != "answer_too_short" || metadata["field"] != "answer" || metadata["actual"] != 311 || metadata["required"] != 420 {
|
|
t.Fatalf("unexpected validation metadata: %#v", metadata)
|
|
}
|
|
}
|
|
|
|
func TestSelectReviewEvidenceLimitsAndDiversifies(t *testing.T) {
|
|
results := []model.ResearchResult{
|
|
{Title: "A1", URL: "https://a.example/1", Relevance: .9, SourceQualityScore: .9, Fetched: true},
|
|
{Title: "A2", URL: "https://a.example/2", Relevance: .89, SourceQualityScore: .9, Fetched: true},
|
|
{Title: "B", URL: "https://b.example/1", Relevance: .8, SourceQualityScore: .95, Fetched: true},
|
|
{Title: "C", URL: "https://c.example/1", Relevance: .7, SourceQualityScore: .8, Fetched: true},
|
|
}
|
|
selected := selectReviewEvidence(results, 3)
|
|
if len(selected) != 3 {
|
|
t.Fatalf("expected 3 evidence items, got %d", len(selected))
|
|
}
|
|
domains := map[string]bool{}
|
|
for _, result := range selected {
|
|
domains[graph.SourceFromURL(result.URL)] = true
|
|
}
|
|
if len(domains) != 3 {
|
|
t.Fatalf("expected domain diversity, got %+v", selected)
|
|
}
|
|
}
|
|
|
|
func TestValidateArticleDraftRequiresOperationalStepsAndValidation(t *testing.T) {
|
|
e := &Engine{Cfg: config.Config{
|
|
ArticleMinTextChars: 40,
|
|
ArticleMinAnswerChars: 80,
|
|
ArticleMinConfidence: .7,
|
|
ArticleMinSources: 1,
|
|
ArticleMinProductionRatio: 1,
|
|
ArticleMaxGenerationDepth: 2,
|
|
}}
|
|
sources := []articleSource{{Node: model.Node{Kind: "knowledge", Status: "production"}}}
|
|
base := model.KnowledgeArticleDraft{
|
|
Title: "Kafka Netzwerkzugriff konfigurieren",
|
|
Text: strings.Repeat("Belegte technische Beschreibung. ", 3),
|
|
Answer: "1. Listener-Konfiguration prüfen.\n2. TLS-Konfiguration anwenden.",
|
|
Validation: []string{"Client-Verbindung erfolgreich testen."},
|
|
Confidence: .9,
|
|
}
|
|
if err := e.validateArticleDraft(base, "how_to", sources, 1, 1); err == nil || !strings.Contains(err.Error(), "fewer than three") {
|
|
t.Fatalf("two-step how-to must be rejected, got %v", err)
|
|
}
|
|
base.Answer += "\n3. Client-Verbindung mit der neuen Konfiguration testen."
|
|
base.Validation = nil
|
|
if err := e.validateArticleDraft(base, "how_to", sources, 1, 1); err == nil || !strings.Contains(err.Error(), "verify the result") {
|
|
t.Fatalf("how-to without validation must be rejected, got %v", err)
|
|
}
|
|
base.Validation = []string{"Client-Verbindung erfolgreich testen."}
|
|
if err := e.validateArticleDraft(base, "how_to", sources, 1, 1); err != nil {
|
|
t.Fatalf("complete operational how-to should pass: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestArticleContentNeedsOperationalEvidenceIsDeterministic(t *testing.T) {
|
|
content := model.KnowledgeArticleContent{Title: "Database Secrets Rotation", SolutionSteps: []string{"Neues Secret erzeugen.", "Anwendung umstellen."}}
|
|
if !articleContentNeedsOperationalEvidence(content, "how_to") {
|
|
t.Fatal("two-step how-to must trigger evidence acquisition")
|
|
}
|
|
content.SolutionSteps = append(content.SolutionSteps, "Altes Secret widerrufen.")
|
|
if !articleContentNeedsOperationalEvidence(content, "how_to") {
|
|
t.Fatal("how-to without validation must still trigger evidence acquisition")
|
|
}
|
|
content.ValidationSteps = []string{"Neue Credentials testen und erfolgreiche Verbindungen verifizieren."}
|
|
if articleContentNeedsOperationalEvidence(content, "how_to") {
|
|
t.Fatal("three-step how-to with validation should satisfy the deterministic operational gate")
|
|
}
|
|
if articleContentNeedsOperationalEvidence(model.KnowledgeArticleContent{}, "concept") {
|
|
t.Fatal("concept articles must not be forced into procedural research")
|
|
}
|
|
}
|
|
|
|
func TestValidateArticleDraftAllowsTwoProductionSourcesOnlyWithGroundedResearch(t *testing.T) {
|
|
e := &Engine{Cfg: config.Config{
|
|
ArticleMinTextChars: 40,
|
|
ArticleMinAnswerChars: 80,
|
|
ArticleMinConfidence: .7,
|
|
ArticleMinSources: 3,
|
|
ArticleMinProductionRatio: .6,
|
|
ArticleMaxGenerationDepth: 2,
|
|
}}
|
|
draft := model.KnowledgeArticleDraft{
|
|
Title: "Database Secrets Rotation durchführen",
|
|
Text: strings.Repeat("Belegte technische Beschreibung. ", 3),
|
|
Answer: "1. Neues Secret erzeugen und parallel bereitstellen.\n2. Anwendung auf das neue Secret umstellen.\n3. Altes Secret nach erfolgreicher Migration widerrufen.",
|
|
Validation: []string{"Neue Verbindung testen und alte Credentials als ungültig verifizieren."},
|
|
Confidence: .9,
|
|
}
|
|
sources := []articleSource{
|
|
{Node: model.Node{Kind: "knowledge", Status: "production"}},
|
|
{Node: model.Node{Kind: "knowledge", Status: "production"}},
|
|
}
|
|
if err := e.validateArticleDraft(draft, "how_to", sources, 1, 1); err == nil || !strings.Contains(err.Error(), "productive sources") {
|
|
t.Fatalf("two internal sources without grounded research must fail, got %v", err)
|
|
}
|
|
if err := e.validateArticleDraft(draft, "how_to", sources, 1, 1, 1); err != nil {
|
|
t.Fatalf("two coherent internal sources plus grounded research should pass: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSanitizeArticleMetaContentKeepsTechnicalSubstance(t *testing.T) {
|
|
content := model.KnowledgeArticleContent{
|
|
Title: "Kafka Netzwerkzugriff absichern",
|
|
ProblemDescription: "Die Quellen sollten zunächst bewertet werden.\nKafka Listener sind aus nicht vorgesehenen Netzen erreichbar und müssen auf freigegebene Netzwerkpfade begrenzt werden.",
|
|
SolutionSteps: []string{
|
|
"Für einen belastbaren Artikel sollten die Quellen verglichen werden.",
|
|
"Listener auf die vorgesehenen Interfaces und Ports begrenzen.",
|
|
"TLS für Client- und Broker-Verbindungen aktivieren.",
|
|
"Firewall-Regeln auf notwendige Quellnetze begrenzen.",
|
|
},
|
|
ValidationSteps: []string{"Mit einem Testclient die erlaubte Verbindung prüfen und abgewiesene Quellnetze verifizieren."},
|
|
}
|
|
clean := sanitizeArticleMetaContent(content)
|
|
if strings.Contains(strings.ToLower(clean.ProblemDescription), "quellen sollten") {
|
|
t.Fatalf("planning language remained in problem description: %q", clean.ProblemDescription)
|
|
}
|
|
if len(clean.SolutionSteps) != 3 {
|
|
t.Fatalf("expected only technical steps to remain, got %#v", clean.SolutionSteps)
|
|
}
|
|
if !articleContentHasSubstance(clean) {
|
|
t.Fatalf("technical content should remain substantive after sanitizer: %#v", clean)
|
|
}
|
|
}
|
|
|
|
func TestArticlePlanGapResearchQueriesAreTaskDirected(t *testing.T) {
|
|
queries := articlePlanGapResearchQueries(model.ArticlePlanDecision{ArticleType: "how_to"}, model.RelationDecision{TopicLabel: "Kafka Netzwerkzugriff"}, nil)
|
|
if len(queries) == 0 {
|
|
t.Fatal("operational planner gap must generate deterministic research queries")
|
|
}
|
|
joined := strings.ToLower(strings.Join(queries, " "))
|
|
if !strings.Contains(joined, "kafka") || (!strings.Contains(joined, "schritt") && !strings.Contains(joined, "step")) {
|
|
t.Fatalf("queries should target the topic and missing procedural evidence: %#v", queries)
|
|
}
|
|
}
|
|
|
|
func TestNormalizeArticleTypeForRelationTurnsErrorCodeHowToIntoTroubleshooting(t *testing.T) {
|
|
relation := model.RelationDecision{TopicLabel: "Windows Update 0x80D02002 DELIVERY_OPTIMIZATION_TIMEOUT"}
|
|
if got := normalizeArticleTypeForRelation("how_to", relation); got != "troubleshooting" {
|
|
t.Fatalf("expected concrete error code to force troubleshooting, got %q", got)
|
|
}
|
|
if got := normalizeArticleTypeForRelation("how_to", model.RelationDecision{TopicLabel: "Windows Update Ring konfigurieren"}); got != "how_to" {
|
|
t.Fatalf("intentional configuration must remain how_to, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestSplitArticleEvidenceBudgetDoesNotDoubleContext(t *testing.T) {
|
|
source, research := splitArticleEvidenceBudget(12000, true)
|
|
if source+research != 12000 || source <= research || research < 1800 {
|
|
t.Fatalf("unexpected shared evidence budget: source=%d research=%d", source, research)
|
|
}
|
|
source, research = splitArticleEvidenceBudget(12000, false)
|
|
if source != 12000 || research != 0 {
|
|
t.Fatalf("without research the full evidence budget should remain internal: %d %d", source, research)
|
|
}
|
|
}
|
|
|
|
func TestArticleQualitySampleBoundsLongSourcesAndKeepsTail(t *testing.T) {
|
|
value := strings.Repeat("A", 9000) + strings.Repeat("M", 9000) + strings.Repeat("Z", 9000)
|
|
got := articleQualitySample(value, 12000)
|
|
if len([]rune(got)) > 12100 {
|
|
t.Fatalf("quality sample exceeded bounded payload: %d", len([]rune(got)))
|
|
}
|
|
if !strings.Contains(got, strings.Repeat("A", 100)) || !strings.Contains(got, strings.Repeat("Z", 100)) {
|
|
t.Fatal("quality sample must preserve head and tail context")
|
|
}
|
|
}
|