Files
glpi-neural-brain/internal/engine/engine_test.go
2026-08-07 17:05:03 +02:00

526 lines
30 KiB
Go

package engine
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/local/glpi-neural-brain/internal/activity"
"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 TestEnrichCreatesStructuredKnowledgeArticleFromThreeProductionSources(t *testing.T) {
var chatCalls int
mock := 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":
_, _ = w.Write([]byte(`{"models":[{"name":"qwen3:8b","digest":"chat-digest"},{"name":"embeddinggemma:latest","digest":"embed-digest"}]}`))
case "/api/embed":
var req struct {
Input []string `json:"input"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
vectors := make([][]float64, len(req.Input))
for i := range vectors {
vectors[i] = []float64{1, float64(i) * .03, 0}
}
_ = json.NewEncoder(w).Encode(map[string]any{"embeddings": vectors})
case "/api/chat":
chatCalls++
var content string
switch chatCalls {
case 1:
content = `{"related":true,"relation_type":"supports","confidence":0.91,"explanation":"Die drei produktiven Quellen beschreiben zusammen Diagnose, Ursache und Behebung derselben VPN-Störung.","needs_research":false,"research_query":"","topic_label":"VPN-Gateway-Störung","keywords":["VPN","Gateway"]}`
case 2:
content = `{"action":"create","target_article_id":"","reason":"Die Quellen ergänzen sich zu einer vollständigen Anleitung, die noch nicht als einzelner Artikel existiert.","expected_value":"Ein durchgängiger Diagnose- und Lösungsablauf für den Support.","article_type":"troubleshooting","source_node_ids":[],"missing_information":[],"contradictions":[],"needs_research":false,"research_query":""}`
case 3:
content = `{"topic":"VPN-Gateway-Störung","purpose":"VPN-Verbindungsprobleme diagnostizieren und beheben","scope":[{"text":"Gilt für Remotezugriffe über einen VPN-Client.","source_refs":["KB-VPN"]}],"facts":[{"text":"Ein nicht erreichbares Gateway kann den Tunnelaufbau verhindern.","source_refs":["KB-VPN"]}],"symptoms":[{"text":"Der Client meldet ein nicht erreichbares Gateway oder einen Timeout.","source_refs":["KB-VPN","KB-REMOTE"]}],"prerequisites":[{"text":"Die aktuelle Fehlermeldung und der Zeitpunkt des Fehlers liegen vor.","source_refs":["KB-VPN-ESC"]}],"solution_steps":[{"text":"Prüfen Sie die Internetverbindung des betroffenen Rechners.","source_refs":["KB-VPN"]},{"text":"Kontrollieren Sie die konfigurierte Gateway-Adresse und deren Erreichbarkeit.","source_refs":["KB-VPN"]},{"text":"Starten Sie den VPN-Client neu und prüfen Sie den Tunnelaufbau erneut.","source_refs":["KB-REMOTE"]},{"text":"Eskalieren Sie einen anhaltenden Gateway-Ausfall mit Zeitstempel und Fehlermeldung an das Netzwerkteam.","source_refs":["KB-VPN-ESC"]}],"validation_steps":[{"text":"Der VPN-Tunnel wird aufgebaut und interne Ressourcen sind erreichbar.","source_refs":["KB-REMOTE"]}],"troubleshooting":[{"text":"Bei weiterhin nicht erreichbarem Gateway den dokumentierten Eskalationsweg verwenden.","source_refs":["KB-VPN-ESC"]}],"contradictions":[],"missing_information":[],"research_queries":[],"ready_for_article":true}`
case 4:
content = `{"title":"VPN-Gateway-Störung systematisch beheben","problem_description":"Der VPN-Client kann keinen Tunnel zum zentralen Gateway aufbauen. Betroffene Benutzer sehen typischerweise einen Timeout oder die Meldung, dass das Gateway nicht erreichbar ist. Die folgenden Schritte grenzen lokale Verbindungsprobleme von einer zentralen Gateway-Störung ab.","scope":"Die Anleitung gilt für Remotezugriffe über den dokumentierten VPN-Client und das zugehörige zentrale Gateway.","symptoms":["Der Verbindungsaufbau endet mit einem Timeout.","Das konfigurierte Gateway ist nicht erreichbar."],"prerequisites":["Die aktuelle Fehlermeldung und der Zeitpunkt des Fehlers liegen vor.","Zugriff auf die VPN-Client-Konfiguration ist vorhanden."],"solution_steps":["Prüfen Sie, ob der betroffene Rechner eine funktionierende Internetverbindung besitzt.","Kontrollieren Sie die konfigurierte Gateway-Adresse und testen Sie deren Erreichbarkeit.","Starten Sie den VPN-Client neu und führen Sie den Verbindungsversuch erneut aus.","Bleibt das Gateway nicht erreichbar, übergeben Sie Zeitstempel, Fehlermeldung und betroffene Benutzer an das Netzwerkteam."],"validation_steps":["Der VPN-Tunnel wird aufgebaut.","Eine interne Zielressource ist erreichbar."],"troubleshooting":["Bei weiterhin nicht erreichbarem Gateway den dokumentierten Eskalationsweg verwenden."],"categories":["Netzwerk","VPN"],"keywords":["VPN","Gateway","Remotezugriff"],"open_questions":[]}`
default:
content = `{"accepted":true,"confidence":0.92,"meta_content_detected":false,"unsupported_claims":[],"issues":[]}`
}
_ = json.NewEncoder(w).Encode(map[string]any{"message": map[string]any{"content": content}})
default:
http.NotFound(w, r)
}
}))
defer mock.Close()
root := t.TempDir()
knowledge := filepath.Join(root, "knowledge")
staging := filepath.Join(root, "staging")
data := filepath.Join(root, "data")
for _, d := range []string{knowledge, staging, data} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
}
write := func(name, body string) {
if err := os.WriteFile(filepath.Join(knowledge, name), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
write("vpn.json", `{"id":"KB-VPN","title":"VPN Gateway nicht erreichbar","text":"Der Client meldet, dass das Gateway nicht erreichbar ist.","answer":"Internetverbindung und Gateway-Adresse prüfen.","categories":["Netzwerk","VPN"],"keywords":["VPN","Gateway"],"source":"internal-kb"}`)
write("remote.json", `{"id":"KB-REMOTE","title":"Remotezugriff über VPN diagnostizieren","text":"Remotezugriff schlägt mit einem Timeout fehl.","answer":"Fehlermeldung erfassen, Client neu starten und Tunnel prüfen.","categories":["Netzwerk","VPN"],"keywords":["VPN","Remotezugriff"],"source":"internal-kb"}`)
write("escalation.json", `{"id":"KB-VPN-ESC","title":"VPN-Störungen eskalieren","text":"Anhaltende Gateway-Ausfälle benötigen eine strukturierte Eskalation.","answer":"Zeitstempel, Fehlermeldung und betroffene Benutzer an das Netzwerkteam übergeben.","categories":["Netzwerk","VPN"],"keywords":["VPN","Eskalation"],"source":"internal-kb"}`)
g, err := graph.Open(data)
if err != nil {
t.Fatal(err)
}
cfg := config.Config{DataDir: data, KnowledgeDirs: []string{knowledge}, StagingDirs: []string{staging}, OllamaURL: mock.URL, ChatModel: "qwen3:8b", EmbeddingModel: "embeddinggemma", ScanInterval: time.Minute, EnrichInterval: time.Minute, SimilarityThreshold: .5, RelationThreshold: .7, ArticleSynthesisEnabled: true, ArticleMinSources: 3, ArticleMaxSources: 6, ArticleMinProductionRatio: .7, ArticleMaxGenerationDepth: 2, ArticleMinConfidence: .7, ArticleMinTextChars: 80, ArticleMinAnswerChars: 180, TopK: 5, MaxContextChars: 16000}
e := New(cfg, g, activity.New(50))
if err := e.Scan(context.Background()); err != nil {
t.Fatal(err)
}
if !e.ollamaOK {
t.Fatal("mock Ollama should be healthy")
}
if err := e.EnrichOne(context.Background()); err != nil {
t.Fatal(err)
}
if err := e.Flush(context.Background()); err != nil {
t.Fatal(err)
}
if chatCalls != 5 {
t.Fatalf("expected relation, plan, consolidation, draft and quality calls, got %d", chatCalls)
}
files, err := filepath.Glob(filepath.Join(staging, "*.json"))
if err != nil || len(files) != 1 {
t.Fatalf("expected one structured AI-THINK article, files=%v err=%v", files, err)
}
var doc map[string]any
b, _ := os.ReadFile(files[0])
if err := json.Unmarshal(b, &doc); err != nil {
t.Fatal(err)
}
if doc["auto_reply"] != false {
t.Fatalf("AI-THINK must be auto_reply=false: %#v", doc["auto_reply"])
}
if got := doc["answer"].(string); len(got) < 180 || got == "Interne AI-THINK-Arbeitsnotiz. Vor produktiver Nutzung im Editor prüfen, korrigieren und freigeben." {
t.Fatalf("expected a real solution article, got %q", got)
}
if _, exists := doc["ai_think"]; exists {
t.Fatalf("internal assessment metadata must not be written into the KB article: %#v", doc["ai_think"])
}
visible := strings.ToLower(doc["text"].(string) + "\n" + doc["answer"].(string))
for _, forbidden := range []string{"die quellen", "mehrwert", "bewertung", "source_node", "relation"} {
if strings.Contains(visible, forbidden) {
t.Fatalf("visible article contains internal assessment language %q: %s", forbidden, visible)
}
}
metaFiles, err := filepath.Glob(filepath.Join(data, "article-metadata", "*.json"))
if err != nil || len(metaFiles) != 1 {
t.Fatalf("expected one separate article metadata sidecar, files=%v err=%v", metaFiles, err)
}
}
func TestEnrichRelationWithOnlyTwoSourcesDoesNotCreateArticle(t *testing.T) {
var chatCalls int
mock := 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":
_, _ = w.Write([]byte(`{"models":[{"name":"qwen3:8b","digest":"chat-digest"},{"name":"embeddinggemma:latest","digest":"embed-digest"}]}`))
case "/api/embed":
var req struct {
Input []string `json:"input"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
vectors := make([][]float64, len(req.Input))
for i := range vectors {
vectors[i] = []float64{1, float64(i) * .02}
}
_ = json.NewEncoder(w).Encode(map[string]any{"embeddings": vectors})
case "/api/chat":
chatCalls++
content := `{"related":true,"relation_type":"same_topic","confidence":0.9,"explanation":"Beide Quellen behandeln dasselbe Gateway-Thema.","needs_research":false,"research_query":"","topic_label":"VPN","keywords":["VPN"]}`
_ = json.NewEncoder(w).Encode(map[string]any{"message": map[string]any{"content": content}})
}
}))
defer mock.Close()
root := t.TempDir()
knowledge, staging, data := filepath.Join(root, "knowledge"), filepath.Join(root, "staging"), filepath.Join(root, "data")
for _, dir := range []string{knowledge, staging, data} {
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
}
_ = os.WriteFile(filepath.Join(knowledge, "a.json"), []byte(`{"id":"A","title":"VPN A","text":"Gateway","categories":["VPN"]}`), 0o644)
_ = os.WriteFile(filepath.Join(knowledge, "b.json"), []byte(`{"id":"B","title":"VPN B","text":"Gateway","categories":["VPN"]}`), 0o644)
g, _ := graph.Open(data)
e := New(config.Config{DataDir: data, KnowledgeDirs: []string{knowledge}, StagingDirs: []string{staging}, OllamaURL: mock.URL, ChatModel: "qwen3:8b", EmbeddingModel: "embeddinggemma", SimilarityThreshold: .5, RelationThreshold: .7, ArticleSynthesisEnabled: true, ArticleMinSources: 3, ArticleMaxSources: 8, ArticleMinProductionRatio: .7, ArticleMaxGenerationDepth: 2, ArticleMinConfidence: .7, ArticleMinTextChars: 80, ArticleMinAnswerChars: 180, MaxContextChars: 8000}, g, activity.New(20))
if err := e.Scan(context.Background()); err != nil {
t.Fatal(err)
}
if err := e.EnrichOne(context.Background()); err != nil {
t.Fatal(err)
}
if chatCalls != 1 {
t.Fatalf("article planning must not run with only two productive sources; calls=%d", chatCalls)
}
files, _ := filepath.Glob(filepath.Join(staging, "*.json"))
if len(files) != 0 {
t.Fatalf("unexpected article draft: %v", files)
}
if len(g.Snapshot().Edges) == 0 {
t.Fatal("relation edge should still be created")
}
}
func TestSynthesisResearchesUnclearKnowledgeThenLearnsAndLinksArticle(t *testing.T) {
sourceA := graph.ID("knowledge", "A")
sourceB := graph.ID("knowledge", "B")
sourceC := graph.ID("knowledge", "C")
pageServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(`<html><head><title>Hersteller-Dokumentation VPN Gateway</title></head><body><main><h1>Gateway-Verbindung prüfen</h1><p>Prüfen Sie zuerst die Internetverbindung des Rechners und kontrollieren Sie anschließend die konfigurierte Gateway-Adresse.</p><p>Testen Sie die Erreichbarkeit des Gateways mit dem vom Hersteller dokumentierten Verbindungstest. Starten Sie danach den VPN-Client neu.</p><p>Die Prüfung ist erfolgreich, wenn der Tunnel aufgebaut wird und eine interne Testressource erreichbar ist. Erfassen Sie bei einem anhaltenden Fehler Zeitstempel und Fehlermeldung für die Eskalation.</p></main></body></html>`))
}))
defer pageServer.Close()
var chatCalls int
ollama := 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":
_, _ = w.Write([]byte(`{"models":[{"name":"qwen3:8b","digest":"chat-digest"},{"name":"embeddinggemma:latest","digest":"embed-digest"}]}`))
case "/api/embed":
var req struct {
Input []string `json:"input"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
vectors := make([][]float64, len(req.Input))
for i := range vectors {
vectors[i] = []float64{1, .1 + float64(i)*.01, .2}
}
_ = json.NewEncoder(w).Encode(map[string]any{"embeddings": vectors})
case "/api/chat":
chatCalls++
content := ""
switch chatCalls {
case 1:
content = `{"related":true,"relation_type":"same_topic","confidence":0.9,"explanation":"Die Beiträge behandeln denselben VPN-Fehler.","needs_research":false,"research_query":"","topic_label":"VPN Gateway","keywords":["VPN"]}`
case 2:
content = `{"action":"create","target_article_id":"","reason":"Die produktiven Beiträge lassen sich konsolidieren.","expected_value":"Ein vollständiger Diagnoseablauf.","article_type":"troubleshooting","source_node_ids":[],"missing_information":[],"contradictions":[],"needs_research":false,"research_query":""}`
case 3:
content = fmt.Sprintf(`{"topic":"VPN Gateway","purpose":"VPN-Fehler beheben","scope":[],"facts":[],"symptoms":[{"text":"Der VPN-Tunnel wird nicht aufgebaut.","source_refs":[%q]}],"prerequisites":[],"solution_steps":[{"text":"Internetverbindung prüfen.","source_refs":[%q]}],"validation_steps":[],"troubleshooting":[],"contradictions":[{"topic":"Offizieller Gateway-Prüfweg","statements":["Die internen Beiträge nennen keinen verlässlichen Gateway-Test."],"source_refs":[%q,%q,%q],"resolution":"","severity":"critical","needs_research":true,"research_query":"VPN Gateway Erreichbarkeit Hersteller Diagnose"}],"critical_gaps":[{"id":"G-C-1","description":"Ein belastbarer Gateway-Prüf- und Validierungsweg fehlt.","reason":"Ohne ihn ist die Anleitung nicht ausführbar.","research_queries":["VPN Gateway Erreichbarkeit Hersteller Diagnose"]}],"optional_gaps":[],"resolved_gaps":[],"missing_information":["Ein belastbarer Gateway-Prüf- und Validierungsweg fehlt."],"research_queries":["VPN Gateway Erreichbarkeit Hersteller Diagnose"],"ready_for_article":false}`, sourceA, sourceA, sourceA, sourceB, sourceC)
case 4:
content = `{"questions":[{"gap_id":"G-C-1","question":"Wie werden Gateway-Adresse und VPN-Verbindung laut Hersteller geprüft und validiert?","critical":true,"expect_actionable":true,"queries_de":["VPN Gateway Verbindung prüfen Hersteller Dokumentation"],"queries_en":[],"preferred_domains":[]}]}`
case 5:
content = `{"title":"VPN-Gateway-Verbindung diagnostizieren","problem_description":"Der VPN-Client kann keine Verbindung zum konfigurierten Gateway aufbauen. Der Tunnel bleibt getrennt und der Remotezugriff ist nicht möglich.","scope":"Die Anleitung gilt für den dokumentierten VPN-Client und das konfigurierte zentrale Gateway.","symptoms":["Der VPN-Tunnel wird nicht aufgebaut.","Der Client meldet ein nicht erreichbares Gateway."],"key_points":[],"decision_criteria":[],"prerequisites":["Fehlermeldung und Zeitpunkt des Fehlers liegen vor."],"solution_steps":["Prüfen Sie die Internetverbindung des Rechners.","Kontrollieren Sie die konfigurierte Gateway-Adresse und testen Sie deren Erreichbarkeit.","Starten Sie den VPN-Client neu und wiederholen Sie den Verbindungsaufbau.","Eskalieren Sie einen anhaltenden Fehler mit Zeitstempel und Fehlermeldung."],"validation_steps":["Der VPN-Tunnel wird aufgebaut.","Eine interne Ressource ist erreichbar."],"troubleshooting":["Prüfen Sie bei erneutem Fehler die erfasste Meldung und den dokumentierten Eskalationsweg."],"categories":["Netzwerk","VPN"],"keywords":["VPN","Gateway"],"open_questions":[]}`
default:
content = `{"accepted":true,"confidence":0.93,"meta_content_detected":false,"unsupported_claims":[],"issues":[],"claim_reviews":[{"claim":"Gateway-Adresse und Erreichbarkeit prüfen","verdict":"supported","source_refs":["R1"],"reason":"Im Volltext belegt."}],"missing_evidence_queries":[],"rewrite_instructions":[]}`
}
_ = json.NewEncoder(w).Encode(map[string]any{"message": map[string]any{"content": content}})
default:
http.NotFound(w, r)
}
}))
defer ollama.Close()
pageURL := pageServer.URL + "/vpn-gateway"
searx := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"results": []map[string]any{{"title": "Hersteller-Dokumentation VPN Gateway", "url": pageURL, "content": "Konkrete Herstelleranleitung zur Prüfung von Gateway-Adresse, Netzwerkverbindung und Tunnelaufbau."}}})
}))
defer searx.Close()
root := t.TempDir()
knowledge, staging, data := filepath.Join(root, "knowledge"), filepath.Join(root, "staging"), filepath.Join(root, "data")
for _, dir := range []string{knowledge, staging, data} {
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
}
for name, body := range map[string]string{
"a.json": `{"id":"A","title":"VPN nicht verbunden","text":"Der Tunnel wird nicht aufgebaut.","answer":"Internetverbindung prüfen.","categories":["VPN"]}`,
"b.json": `{"id":"B","title":"VPN Client neu starten","text":"Der Client bleibt getrennt.","answer":"Client neu starten und Tunnel erneut prüfen.","categories":["VPN"]}`,
"c.json": `{"id":"C","title":"VPN Eskalation","text":"Anhaltende Fehler müssen eskaliert werden.","answer":"Zeitstempel und Fehlermeldung erfassen.","categories":["VPN"]}`,
} {
if err := os.WriteFile(filepath.Join(knowledge, name), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
g, err := graph.Open(data)
if err != nil {
t.Fatal(err)
}
cfg := config.Config{DataDir: data, KnowledgeDirs: []string{knowledge}, StagingDirs: []string{staging}, OllamaURL: ollama.URL, ChatModel: "qwen3:8b", EmbeddingModel: "embeddinggemma", SearXNGURL: searx.URL, ResearchEnabled: true, SimilarityThreshold: .5, RelationThreshold: .7, ArticleSynthesisEnabled: true, ArticleMinSources: 3, ArticleMaxSources: 6, ArticleMinProductionRatio: .7, ArticleMaxGenerationDepth: 2, ArticleMinConfidence: .7, ArticleMinTextChars: 80, ArticleMinAnswerChars: 180, ArticleMaxResearchQueries: 2, ArticleResearchResults: 4, ArticleResearchRounds: 2, ArticleResearchFetchResults: 1, ArticleResearchMinRelevance: .6, ArticleResearchMinQuality: .4, ArticleResearchPageMaxBytes: 1 << 20, ArticleResearchPageMaxChars: 8000, ArticleResearchFetchTimeout: time.Second, ArticleResearchAllowPrivate: true, MaxContextChars: 16000}
broker := activity.New(200)
e := New(cfg, g, broker)
if err := e.Scan(context.Background()); err != nil {
t.Fatal(err)
}
if err := e.EnrichOne(context.Background()); err != nil {
t.Fatal(err)
}
if err := e.Flush(context.Background()); err != nil {
t.Fatal(err)
}
files, _ := filepath.Glob(filepath.Join(staging, "*.json"))
if len(files) != 1 {
t.Fatalf("expected researched article, files=%v", files)
}
var doc map[string]any
dataBytes, _ := os.ReadFile(files[0])
if err := json.Unmarshal(dataBytes, &doc); err != nil {
t.Fatal(err)
}
if _, exists := doc["ai_think"]; exists {
t.Fatal("assessment metadata leaked into article")
}
articleID := doc["id"].(string)
articleNodeID := graph.ID("knowledge", articleID)
if vector, ok := g.Vector(articleNodeID); !ok || len(vector) == 0 {
t.Fatal("new article was not learned immediately")
}
researchID := graph.ID("external", pageURL)
if vector, ok := g.Vector(researchID); !ok || len(vector) == 0 {
t.Fatal("accepted web evidence was not learned immediately")
}
researchNode, ok := g.GetNode(researchID)
hasVPN := false
for _, category := range researchNode.Categories {
if strings.EqualFold(strings.TrimSpace(category), "VPN") {
hasVPN = true
break
}
}
if !ok || !hasVPN {
t.Fatalf("accepted web evidence did not inherit source categories: %+v", researchNode)
}
evidenceFiles, err := filepath.Glob(filepath.Join(data, "research-evidence", "*.json"))
if err != nil || len(evidenceFiles) != 1 {
t.Fatalf("expected persisted full-text research evidence, files=%v err=%v", evidenceFiles, err)
}
var priorSources []articleSource
for _, externalID := range []string{"A", "B", "C"} {
node, ok := g.LookupExternal(externalID)
if !ok {
t.Fatalf("missing source node %s", externalID)
}
priorSources = append(priorSources, articleSource{Node: node})
}
reused := e.researchEvidenceForSources(priorSources)
if len(reused) != 1 || reused[0].URL != pageURL || !strings.Contains(reused[0].Content, "Gateway-Adresse") {
t.Fatalf("accepted full-text evidence was not reusable: %+v", reused)
}
linked := false
for _, edge := range g.Snapshot().Edges {
if edge.Source == articleNodeID && edge.Target == researchID && edge.Type == "grounded_by" {
linked = true
break
}
}
if !linked {
t.Fatal("new article is not linked to its accepted full-text research evidence")
}
if chatCalls != 6 {
t.Fatalf("expected relation, plan, brief, research plan, synthesis and claim review calls, got %d", chatCalls)
}
var resultEvent, candidateEvent, fetchEvent, materialEvent, ingestEvent, learnedEvent, roundEvent, reviewEvent *model.Activity
for _, event := range broker.Recent() {
event := event
switch event.Type {
case "article.research.results":
resultEvent = &event
case "article.research.candidates":
candidateEvent = &event
case "article.research.fetch.completed":
fetchEvent = &event
case "article.research.material.collected":
materialEvent = &event
case "article.research.material.ingested":
ingestEvent = &event
case "article.research.learned":
learnedEvent = &event
case "article.research.collection.round.completed":
roundEvent = &event
case "article.review.completed":
reviewEvent = &event
}
}
if resultEvent == nil || candidateEvent == nil || fetchEvent == nil || materialEvent == nil || ingestEvent == nil || learnedEvent == nil || roundEvent == nil || reviewEvent == nil {
t.Fatalf("expected research-generate-review pipeline, result=%v candidate=%v fetch=%v material=%v ingest=%v learned=%v round=%v review=%v", resultEvent != nil, candidateEvent != nil, fetchEvent != nil, materialEvent != nil, ingestEvent != nil, learnedEvent != nil, roundEvent != nil, reviewEvent != nil)
}
resultNodeIDs, _ := ingestEvent.Metadata["result_node_ids"].([]string)
if len(resultNodeIDs) != 1 || resultNodeIDs[0] != researchID {
t.Fatalf("unexpected ingested research nodes: %#v", ingestEvent.Metadata["result_node_ids"])
}
if ingestEvent.Metadata["animation_min_ms"] != 2000 {
t.Fatalf("research animation minimum missing: %#v", ingestEvent.Metadata["animation_min_ms"])
}
}
func TestRequestEnrichDoesNotQueueDuplicateCycle(t *testing.T) {
g, err := graph.Open(t.TempDir())
if err != nil {
t.Fatal(err)
}
e := New(config.Config{EnrichBatchSize: 3, EnrichAnchors: 12}, g, activity.New(20))
if !e.RequestEnrich("manual") {
t.Fatal("first AI-THINK request should be queued")
}
if e.RequestEnrich("automatic") {
t.Fatal("duplicate AI-THINK cycle should not be queued")
}
status := e.Status()
if status["enrich_result"] != "queued" {
t.Fatalf("unexpected enrich status: %#v", status["enrich_result"])
}
}
func TestRuntimeSettingsDisableThinkingAndPersist(t *testing.T) {
data := t.TempDir()
g, err := graph.Open(data)
if err != nil {
t.Fatal(err)
}
cfg := config.Config{DataDir: data, RuntimeDefaultsConfigured: true, LearningEnabled: true, ThinkingEnabled: true, DefaultView: "neural", PersistInterval: time.Minute}
e := New(cfg, g, activity.New(20))
updated, err := e.SetRuntimeSettings(RuntimeSettings{
LearningEnabled: false,
ThinkingEnabled: false,
LearningSources: []string{"internal-kb"},
DisplaySources: []string{"GLPI Knowledge Base"},
ThinkingSources: []string{"internal-kb"},
ViewMode: "constellation",
MaxDisplayNodes: 4321,
LowPowerMode: true,
})
if err != nil {
t.Fatal(err)
}
if updated.LearningEnabled || updated.ThinkingEnabled || updated.ViewMode != "constellation" || updated.MaxDisplayNodes != 4321 || !updated.LowPowerMode {
t.Fatalf("unexpected runtime settings: %+v", updated)
}
if e.RequestEnrich("manual") {
t.Fatal("disabled thinking must not queue AI-THINK")
}
if err := e.Scan(context.Background()); err != ErrLearningDisabled {
t.Fatalf("expected ErrLearningDisabled, got %v", err)
}
if err := e.Flush(context.Background()); err != nil {
t.Fatal(err)
}
g2, err := graph.Open(data)
if err != nil {
t.Fatal(err)
}
e2 := New(cfg, g2, activity.New(20))
loaded := e2.RuntimeSettings()
if loaded.LearningEnabled || loaded.ThinkingEnabled || loaded.ViewMode != "constellation" || loaded.MaxDisplayNodes != 4321 || !loaded.LowPowerMode || len(loaded.DisplaySources) != 1 {
t.Fatalf("runtime settings were not restored: %+v", loaded)
}
}
func TestWriteKnowledgeArticleDraftPreservesUpdateTarget(t *testing.T) {
root := t.TempDir()
staging := filepath.Join(root, "staging")
data := filepath.Join(root, "data")
if err := os.MkdirAll(staging, 0o755); err != nil {
t.Fatal(err)
}
g, err := graph.Open(data)
if err != nil {
t.Fatal(err)
}
e := New(config.Config{DataDir: data, StagingDirs: []string{staging}, ChatModel: "qwen3:8b", ArticleMinSources: 3, ArticleMaxSources: 8, ArticleMinProductionRatio: .7, ArticleMaxGenerationDepth: 2, ArticleMinConfidence: .7, ArticleMinTextChars: 50, ArticleMinAnswerChars: 100}, g, activity.New(20))
target := model.Node{ID: graph.ID("knowledge", "KB-TARGET"), Kind: "knowledge", Status: "production", ExternalID: "KB-TARGET", Label: "Bestehender VPN-Artikel", Categories: []string{"VPN"}}
sources := []articleSource{
{Node: target, Content: "Bestehender Artikel", Depth: 0},
{Node: model.Node{ID: graph.ID("knowledge", "KB-SOURCE-2"), Kind: "knowledge", Status: "production", ExternalID: "KB-SOURCE-2", Label: "Diagnose", Categories: []string{"VPN"}}, Content: "Diagnosequelle"},
{Node: model.Node{ID: graph.ID("knowledge", "KB-SOURCE-3"), Kind: "knowledge", Status: "production", ExternalID: "KB-SOURCE-3", Label: "Prüfung", Categories: []string{"VPN"}}, Content: "Prüfquelle"},
}
plan := model.ArticlePlanDecision{Action: "update", TargetArticleID: target.ID, Reason: "Der bestehende Artikel benötigt Diagnose und Validierung.", ExpectedValue: "Vollständiger Ablauf", ArticleType: "troubleshooting", SourceNodeIDs: nodeIDsFromArticleSources(sources)}
draft := model.KnowledgeArticleDraft{Title: "VPN-Artikel vollständig diagnostizieren", Text: strings.Repeat("Problem und Geltungsbereich. ", 5), Answer: strings.Repeat("1. Konkreten Prüfschritt ausführen. ", 8), Validation: []string{"VPN-Tunnel ist aktiv"}, Categories: []string{"VPN"}, Keywords: []string{"VPN"}, SourceNodeIDs: nodeIDsFromArticleSources(sources), Confidence: .9}
path, _, created, err := e.writeKnowledgeArticleDraft(sources, plan, model.KnowledgeBrief{Topic: "VPN", ReadyForArticle: true}, draft, nil, nil, model.ArticleQualityDecision{Accepted: true, Confidence: .9}, 0, 3, 0, 1, 1)
if err != nil {
t.Fatal(err)
}
if !created {
t.Fatal("expected update draft to be queued")
}
if err := e.Flush(context.Background()); err != nil {
t.Fatal(err)
}
dataBytes, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
var doc map[string]any
if err := json.Unmarshal(dataBytes, &doc); err != nil {
t.Fatal(err)
}
if _, exists := doc["ai_think"]; exists {
t.Fatalf("internal metadata leaked into KB document: %#v", doc["ai_think"])
}
metaFiles, err := filepath.Glob(filepath.Join(data, "article-metadata", "*.json"))
if err != nil || len(metaFiles) != 1 {
t.Fatalf("expected separate update metadata sidecar, files=%v err=%v", metaFiles, err)
}
metaBytes, err := os.ReadFile(metaFiles[0])
if err != nil {
t.Fatal(err)
}
var meta map[string]any
if err := json.Unmarshal(metaBytes, &meta); err != nil {
t.Fatal(err)
}
if meta["action"] != "update" || meta["target_article_id"] != "KB-TARGET" || meta["target_node_id"] != target.ID {
t.Fatalf("update target metadata was not preserved in sidecar: %#v", meta)
}
if !strings.Contains(doc["answer"].(string), "## Ergebnis prüfen") {
t.Fatalf("validation section missing from answer: %q", doc["answer"])
}
}
func TestSourceContentReloadsFullLocalKnowledgeDocument(t *testing.T) {
root := t.TempDir()
knowledge := filepath.Join(root, "knowledge")
data := filepath.Join(root, "data")
if err := os.MkdirAll(knowledge, 0o755); err != nil {
t.Fatal(err)
}
longProblem := strings.Repeat("Ausführliche Problembeschreibung. ", 80)
longAnswer := strings.Repeat("Konkreter Lösungsschritt. ", 80)
body, _ := json.Marshal(map[string]any{"id": "KB-LONG", "title": "Langer Artikel", "text": longProblem, "answer": longAnswer, "categories": []string{"Test"}})
if err := os.WriteFile(filepath.Join(knowledge, "long.json"), body, 0o644); err != nil {
t.Fatal(err)
}
g, err := graph.Open(data)
if err != nil {
t.Fatal(err)
}
e := New(config.Config{DataDir: data, KnowledgeDirs: []string{knowledge}, ArticleMinSources: 3, ArticleMaxSources: 8, ArticleMinProductionRatio: .7, ArticleMaxGenerationDepth: 2}, g, activity.New(20))
if _, err := e.Scanner.Scan(); err != nil {
t.Fatal(err)
}
var node model.Node
for _, candidate := range g.Snapshot().Nodes {
if candidate.ExternalID == "KB-LONG" {
node = candidate
break
}
}
if node.ID == "" {
t.Fatal("knowledge node not found")
}
full := e.sourceContent(node)
if !strings.Contains(full, "PROBLEM / BESCHREIBUNG:") || !strings.Contains(full, "LÖSUNG / ANTWORT:") || len(full) <= len(node.Summary) {
t.Fatalf("expected full source content, summary=%d full=%d", len(node.Summary), len(full))
}
}