All checks were successful
release-tag / release-image (push) Successful in 2m43s
625 lines
35 KiB
Go
625 lines
35 KiB
Go
package engine
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"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/articlequality"
|
||
"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,"coverage_complete":true,"coverage_score":0.90,"missing_topics":[],"coverage_issues":[],"research_use_justification":"Die bereitgestellten Quellen decken die Testaussagen vollständig ab.","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,"coverage_complete":true,"coverage_score":0.91,"missing_topics":[],"coverage_issues":[],"research_use_justification":"Die externe Evidenz und die internen Quellen decken den Testfall vollständig ab.","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, storedEvent, materializedEvent, 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.stored":
|
||
storedEvent = &event
|
||
case "article.research.grounded.materialized":
|
||
materializedEvent = &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 || storedEvent == nil || materializedEvent == nil || learnedEvent == nil || roundEvent == nil || reviewEvent == nil {
|
||
t.Fatalf("expected adaptive generate-review pipeline, result=%v candidate=%v fetch=%v material=%v stored=%v materialized=%v learned=%v round=%v review=%v", resultEvent != nil, candidateEvent != nil, fetchEvent != nil, materialEvent != nil, storedEvent != nil, materializedEvent != nil, learnedEvent != nil, roundEvent != nil, reviewEvent != nil)
|
||
}
|
||
if storedEvent.Metadata["materialization"] != "evidence_store_only" {
|
||
t.Fatalf("research material should remain evidence-only before review: %#v", storedEvent.Metadata)
|
||
}
|
||
if materializedEvent.Metadata["materialized_nodes"] != 1 {
|
||
t.Fatalf("expected exactly one reviewer-grounded research node, got %#v", materializedEvent.Metadata)
|
||
}
|
||
if storedEvent.Metadata["animation_min_ms"] != 2000 {
|
||
t.Fatalf("research animation minimum missing: %#v", storedEvent.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, CoverageComplete: true, CoverageScore: .9}, articlequality.Result{Algorithm: articlequality.Algorithm, Passed: true, Score: .9, WordCount: 700, SectionCount: 5}, 0, 3, 0, 1, 1, articleSourceFingerprint(sources, plan, nil, e.articlePipelineFingerprintIdentity()))
|
||
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))
|
||
}
|
||
}
|
||
|
||
func TestRelationResearchSkippedForHighSimilarityInternalSameTopic(t *testing.T) {
|
||
e := &Engine{Cfg: config.Config{RelationThreshold: .72}}
|
||
a := model.Node{Kind: "knowledge", Label: "Authoritative DNS – härten"}
|
||
b := model.Node{Kind: "knowledge", Label: "Authoritative DNS – untersuchen"}
|
||
decision := model.RelationDecision{Related: true, RelationType: "same_topic", Confidence: .95, NeedsResearch: true, ResearchQuery: "Beziehung Authoritative DNS Härtung Incident Response"}
|
||
if e.relationResearchNeeded(a, b, .98, decision) {
|
||
t.Fatal("high-similarity same_topic relation should not require external research")
|
||
}
|
||
}
|
||
|
||
func TestRelationResearchKeptForFreshnessOrFactualRelation(t *testing.T) {
|
||
e := &Engine{Cfg: config.Config{RelationThreshold: .72}}
|
||
a := model.Node{Kind: "knowledge", Label: "Produkt A"}
|
||
b := model.Node{Kind: "knowledge", Label: "Produkt B"}
|
||
fresh := model.RelationDecision{Related: true, RelationType: "same_topic", Confidence: .95, NeedsResearch: true, ResearchQuery: "aktuell unterstützte Versionen und Supportstatus"}
|
||
if !e.relationResearchNeeded(a, b, .98, fresh) {
|
||
t.Fatal("freshness-sensitive relation should keep research")
|
||
}
|
||
dep := model.RelationDecision{Related: true, RelationType: "depends_on", Confidence: .95, NeedsResearch: true, ResearchQuery: "Abhängigkeit prüfen"}
|
||
if !e.relationResearchNeeded(a, b, .98, dep) {
|
||
t.Fatal("factual dependency relation should keep research")
|
||
}
|
||
}
|
||
|
||
func TestBootstrapGateReleasesWorkersOnce(t *testing.T) {
|
||
e := &Engine{bootstrapReady: make(chan struct{})}
|
||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||
defer cancel()
|
||
done := make(chan bool, 1)
|
||
go func() { done <- e.waitBootstrap(ctx) }()
|
||
select {
|
||
case <-done:
|
||
t.Fatal("bootstrap gate opened before markBootstrapReady")
|
||
case <-time.After(20 * time.Millisecond):
|
||
}
|
||
e.markBootstrapReady(nil)
|
||
e.markBootstrapReady(nil)
|
||
select {
|
||
case ok := <-done:
|
||
if !ok {
|
||
t.Fatal("bootstrap gate returned false after successful release")
|
||
}
|
||
case <-time.After(time.Second):
|
||
t.Fatal("bootstrap gate did not release worker")
|
||
}
|
||
if !e.bootstrapComplete || e.bootstrapError != "" {
|
||
t.Fatalf("unexpected bootstrap state: complete=%v error=%q", e.bootstrapComplete, e.bootstrapError)
|
||
}
|
||
}
|
||
|
||
func TestBootstrapFailureKeepsWorkersBlockedUntilSuccess(t *testing.T) {
|
||
e := &Engine{bootstrapReady: make(chan struct{})}
|
||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||
defer cancel()
|
||
done := make(chan bool, 1)
|
||
go func() { done <- e.waitBootstrap(ctx) }()
|
||
e.markBootstrapFailure(errors.New("broken knowledge json"))
|
||
select {
|
||
case <-done:
|
||
t.Fatal("bootstrap gate opened after a fatal bootstrap failure")
|
||
case <-time.After(20 * time.Millisecond):
|
||
}
|
||
if e.bootstrapComplete || e.bootstrapError == "" {
|
||
t.Fatalf("expected blocked bootstrap with recorded error: complete=%v error=%q", e.bootstrapComplete, e.bootstrapError)
|
||
}
|
||
e.markBootstrapReady(nil)
|
||
select {
|
||
case ok := <-done:
|
||
if !ok {
|
||
t.Fatal("bootstrap gate returned false after recovery")
|
||
}
|
||
case <-time.After(time.Second):
|
||
t.Fatal("bootstrap gate did not release after successful retry")
|
||
}
|
||
}
|
||
|
||
func TestNormalizeSecurityAssessmentCanonicalizesTrustedMetadata(t *testing.T) {
|
||
out := normalizeSecurityAssessment(securityInboxAssessment{
|
||
Severity: "unknown",
|
||
EventType: "Denial of Service",
|
||
CVEs: []string{"cve-2026-1234", "not-a-cve", "CVE-2026-1234"},
|
||
}, "[UPDATE] [hoch] Product: Schwachstelle")
|
||
if out.Severity != "high" {
|
||
t.Fatalf("expected title-derived high severity, got %q", out.Severity)
|
||
}
|
||
if out.EventType != "denial_of_service" {
|
||
t.Fatalf("expected canonical event type, got %q", out.EventType)
|
||
}
|
||
if len(out.CVEs) != 1 || out.CVEs[0] != "CVE-2026-1234" {
|
||
t.Fatalf("expected one validated canonical CVE, got %#v", out.CVEs)
|
||
}
|
||
}
|