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

124 lines
6.4 KiB
Go

package engine
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"reflect"
"testing"
"time"
"github.com/local/glpi-neural-brain/internal/config"
"github.com/local/glpi-neural-brain/internal/model"
"github.com/local/glpi-neural-brain/internal/ollama"
)
func TestGenerateThenReviewUsesSeparateModels(t *testing.T) {
var requested []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/tags":
_ = json.NewEncoder(w).Encode(map[string]any{"models": []map[string]any{
{"name": "qwen3:8b", "digest": "review"},
{"name": "gemma3:12b", "digest": "author"},
{"name": "embeddinggemma:latest", "digest": "embed"},
}})
case "/api/chat":
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
modelName, _ := body["model"].(string)
requested = append(requested, modelName)
content := ""
if modelName == "gemma3:12b" {
content = `{"title":"VPN prüfen","problem_description":"Der VPN-Tunnel wird nicht aufgebaut.","scope":"Dokumentierter VPN-Client.","symptoms":["Tunnel bleibt getrennt."],"key_points":[],"decision_criteria":[],"prerequisites":["Fehlermeldung liegt vor."],"solution_steps":["Prüfen Sie die Gateway-Adresse."],"validation_steps":["Der Tunnel wird aufgebaut."],"troubleshooting":[],"categories":["VPN"],"keywords":["VPN","Gateway"],"open_questions":[],"research_needed":false,"research_queries":[],"freshness_sensitive":false,"research_reason":""}`
} else {
content = `{"accepted":true,"confidence":0.94,"coverage_complete":true,"coverage_score":0.92,"missing_topics":[],"coverage_issues":[],"research_use_justification":"Die bereitgestellten Quellen decken die Testaussagen vollständig ab.","meta_content_detected":false,"unsupported_claims":[],"issues":[],"claim_reviews":[{"claim":"Gateway-Adresse prüfen","verdict":"supported","source_refs":["SOURCE-1"],"reason":"Direkt im internen Beleg genannt."}],"missing_evidence_queries":[],"rewrite_instructions":[]}`
}
_ = json.NewEncoder(w).Encode(map[string]any{"message": map[string]any{"content": content}})
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
client := ollama.NewPool(ollama.PoolConfig{Nodes: []ollama.NodeConfig{{Name: "only", URL: srv.URL}}, NodeMaxInflight: 1, HealthInterval: time.Minute, RequestTimeout: time.Second, RequireSameModelDigest: true, RequireEmbeddingModel: true}, "qwen3:8b", "embeddinggemma")
if err := client.Ping(context.Background()); err != nil {
t.Fatal(err)
}
e := &Engine{Cfg: config.Config{ArticleLanguage: "de-DE", ArticleSynthesisModel: "gemma3:12b", ArticleReviewModel: "qwen3:8b", MaxContextChars: 8000}, Ollama: client}
sources := []articleSource{{Node: model.Node{ID: "SOURCE-1", Kind: "knowledge", Status: "production", Label: "VPN Gateway", Origin: "knowledge-production"}, Content: "Prüfen Sie die konfigurierte Gateway-Adresse und anschließend den Tunnelaufbau."}}
plan := model.ArticlePlanDecision{Action: "create", ArticleType: "troubleshooting", SourceNodeIDs: []string{"SOURCE-1"}}
brief := model.KnowledgeBrief{Topic: "VPN Gateway", Purpose: "VPN-Verbindung wiederherstellen", ReadyForArticle: false}
content, _, err := e.generateArticleContent(context.Background(), "test-article-run", sources, plan, brief, nil, nil)
if err != nil {
t.Fatal(err)
}
draft := articleContentToDraft(content, plan.SourceNodeIDs, plan.ArticleType)
quality, _, err := e.reviewArticleContent(context.Background(), draft, plan.ArticleType, sources, nil)
if err != nil {
t.Fatal(err)
}
if !quality.Accepted || len(quality.ClaimReviews) != 1 {
t.Fatalf("unexpected quality decision: %+v", quality)
}
if !reflect.DeepEqual(requested, []string{"gemma3:12b", "qwen3:8b"}) {
t.Fatalf("model sequence=%v", requested)
}
}
func TestArticleClaimReviewCounts(t *testing.T) {
got := articleClaimReviewCounts([]model.ArticleClaimReview{
{Verdict: "supported"}, {Verdict: "supported"}, {Verdict: "partially_supported"}, {Verdict: "unsupported"}, {Verdict: "contradicted"},
})
want := map[string]int{"supported": 2, "partially_supported": 1, "unsupported": 1, "contradicted": 1}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got=%v want=%v", got, want)
}
}
func TestReviewedResearchEvidenceUsesOnlySupportedReferences(t *testing.T) {
results := []model.ResearchResult{
{Title: "A", URL: "https://example.com/a"},
{Title: "B", URL: "https://example.com/b"},
{Title: "C", URL: "https://example.com/c"},
}
reviews := []model.ArticleClaimReview{
{Claim: "supported by first", Verdict: "supported", SourceRefs: []string{"R1"}},
{Claim: "supported by URL", Verdict: "partially_supported", SourceRefs: []string{"https://example.com/c"}},
{Claim: "bad claim", Verdict: "unsupported", SourceRefs: []string{"R2"}},
}
got := reviewedResearchEvidence(results, reviews)
if len(got) != 2 {
t.Fatalf("expected 2 grounded results, got %d: %#v", len(got), got)
}
if got[0].URL != "https://example.com/a" || got[1].URL != "https://example.com/c" {
t.Fatalf("unexpected grounded results: %#v", got)
}
}
func TestResearchResultValidationStateDistinguishesMaterialFromEvaluatedEvidence(t *testing.T) {
material := model.ResearchResult{AssessmentReason: "Volltextmaterial für die Artikelsynthese gesammelt; die fachliche Belegprüfung erfolgt anschließend am generierten Artikel."}
if got := researchResultValidationState(material); got != "pending_article_review" {
t.Fatalf("expected pending_article_review, got %q", got)
}
evaluated := model.ResearchResult{AssessmentReason: "Die Quelle schließt die Wissenslücke belastbar."}
if got := researchResultValidationState(evaluated); got != "evaluated" {
t.Fatalf("expected evaluated, got %q", got)
}
}
func TestArticleAuthorBriefExcludesPredraftReadinessGates(t *testing.T) {
brief := model.KnowledgeBrief{Topic: "VPN", ReadyForArticle: false, MissingInformation: []string{"fehlend"}, CriticalGaps: []model.KnowledgeGap{{ID: "KG-1", Description: "Gap"}}, ResearchQueries: []string{"query"}}
got := articleAuthorBrief(brief)
for _, forbidden := range []string{"ready_for_article", "missing_information", "critical_gaps", "optional_gaps", "research_queries"} {
if _, ok := got[forbidden]; ok {
t.Fatalf("author brief must not contain pre-draft gate %q", forbidden)
}
}
if got["topic"] != "VPN" {
t.Fatalf("expected factual topic to remain, got %#v", got["topic"])
}
}