451 lines
26 KiB
Go
451 lines
26 KiB
Go
package engine
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"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) {
|
|
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 = `{"topic":"VPN Gateway","purpose":"VPN-Fehler beheben","scope":[],"facts":[],"symptoms":[{"text":"Der VPN-Tunnel wird nicht aufgebaut.","source_refs":["A"]}],"prerequisites":[],"solution_steps":[{"text":"Internetverbindung prüfen.","source_refs":["A"]}],"validation_steps":[],"troubleshooting":[],"contradictions":[{"topic":"Offizieller Gateway-Prüfweg","statements":["Die internen Beiträge nennen keinen verlässlichen Gateway-Test."],"source_refs":["A","B","C"],"resolution":"","needs_research":true,"research_query":"Hersteller VPN Gateway Erreichbarkeit Diagnose"}],"missing_information":["Offizieller Gateway-Prüfweg"],"research_queries":["Hersteller VPN Gateway Erreichbarkeit Diagnose"],"ready_for_article":false}`
|
|
case 4:
|
|
content = `{"topic":"VPN Gateway","purpose":"VPN-Fehler beheben","scope":[{"text":"Gilt für den dokumentierten VPN-Client.","source_refs":["A"]}],"facts":[{"text":"Der Hersteller empfiehlt, die Gateway-Adresse und die Netzwerkverbindung zu prüfen.","source_refs":["R1"]}],"symptoms":[{"text":"Der VPN-Tunnel wird nicht aufgebaut.","source_refs":["A"]}],"prerequisites":[{"text":"Fehlermeldung und Zeitpunkt liegen vor.","source_refs":["C"]}],"solution_steps":[{"text":"Prüfen Sie die Internetverbindung.","source_refs":["A"]},{"text":"Prüfen Sie die konfigurierte Gateway-Adresse und deren Erreichbarkeit.","source_refs":["R1"]},{"text":"Starten Sie den VPN-Client neu.","source_refs":["B"]}],"validation_steps":[{"text":"Der VPN-Tunnel wird aufgebaut.","source_refs":["B"]}],"troubleshooting":[{"text":"Bei anhaltendem Fehler mit Zeitstempel und Meldung eskalieren.","source_refs":["C"]}],"contradictions":[],"missing_information":[],"research_queries":[],"ready_for_article":true}`
|
|
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."],"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":[]}`
|
|
}
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"message": map[string]any{"content": content}})
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
defer ollama.Close()
|
|
|
|
searx := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"results":[{"title":"Hersteller-Dokumentation","url":"https://vendor.example/vpn-gateway","content":"Prüfen Sie die konfigurierte Gateway-Adresse und die Netzwerkverbindung. Starten Sie danach den Client neu."}]}`))
|
|
}))
|
|
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: 3, ArticleResearchResults: 4, MaxContextChars: 16000}
|
|
e := New(cfg, g, activity.New(100))
|
|
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", "https://vendor.example/vpn-gateway")
|
|
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 research evidence")
|
|
}
|
|
if chatCalls != 6 {
|
|
t.Fatalf("expected relation, plan, two consolidations, article and quality calls, got %d", chatCalls)
|
|
}
|
|
}
|
|
|
|
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,
|
|
LearningCategories: []string{"Netzwerk"},
|
|
DisplayCategories: []string{"GLPI KB"},
|
|
ThinkingCategories: []string{"Netzwerk"},
|
|
ViewMode: "honeycomb",
|
|
MaxDisplayNodes: 4321,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if updated.LearningEnabled || updated.ThinkingEnabled || updated.ViewMode != "honeycomb" || updated.MaxDisplayNodes != 4321 {
|
|
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 != "honeycomb" || loaded.MaxDisplayNodes != 4321 || len(loaded.DisplayCategories) != 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, 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))
|
|
}
|
|
}
|