Files
glpi-neural-brain/internal/engine/article_format_test.go
2026-08-07 21:54:37 +02:00

135 lines
6.1 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 TestValidateArticleDraftUsesLowerConceptAnswerMinimum(t *testing.T) {
e := &Engine{Cfg: config.Config{
ArticleMinTextChars: 100,
ArticleMinAnswerChars: 420,
ArticleMinConfidence: .7,
ArticleMinSources: 1,
ArticleMinProductionRatio: 1,
ArticleMaxGenerationDepth: 2,
}}
draft := model.KnowledgeArticleDraft{
Title: "Mobile Authentifizierung einordnen",
Text: strings.Repeat("Fachlich belegte Einordnung. ", 6),
Answer: "## Kernaussagen\n- Authentifizierung bestätigt eine Identität anhand belegter Merkmale.\n- Biometrische Merkmale können die lokale Nutzerprüfung unterstützen.\n\n## Einordnung und Abgrenzung\n- Autorisierung entscheidet anschließend über erlaubte Aktionen und Ressourcen.",
Confidence: .9,
}
sources := []articleSource{{Node: model.Node{Kind: "knowledge", Status: "production"}}}
if err := e.validateArticleDraft(draft, "concept", sources, 1, 1); err != nil {
t.Fatalf("grounded concept draft should pass type-aware validation: %v", err)
}
if err := e.validateArticleDraft(draft, "how_to", sources, 1, 1); err == nil {
t.Fatal("the same short answer must not pass the operational how-to minimum")
}
}
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)
}
}