316 lines
15 KiB
Go
316 lines
15 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":""}`
|
|
default:
|
|
content = `{"title":"VPN-Gateway-Störung systematisch beheben","text":"Dieser Artikel gilt für Remotezugriffe, bei denen der VPN-Client keine Verbindung zum Gateway herstellen kann. Typische Symptome sind Zeitüberschreitungen, ein nicht erreichbares Gateway oder ein Verbindungsabbruch unmittelbar nach dem Start. Die Anleitung grenzt lokale Clientfehler von einer Störung des zentralen Gateways ab.","answer":"1. Prüfen Sie zuerst, ob der betroffene Rechner eine funktionierende Internetverbindung besitzt. 2. Kontrollieren Sie, ob der konfigurierte Gateway-Name erreichbar ist. 3. Vergleichen Sie die Fehlermeldung mit dem dokumentierten Gateway-Ausfallbild. 4. Starten Sie den VPN-Client neu und führen Sie den Verbindungsversuch erneut aus. 5. Bleibt das Gateway nicht erreichbar, eskalieren Sie den Fall mit Zeitstempel und Fehlermeldung an das Netzwerkteam.","prerequisites":["Zugriff auf die VPN-Client-Konfiguration","Aktuelle Fehlermeldung des Benutzers"],"validation":["Der VPN-Tunnel wird aufgebaut","Die interne Zielressource ist erreichbar"],"troubleshooting":["Bei weiterhin nicht erreichbarem Gateway den dokumentierten Eskalationsweg verwenden"],"categories":["Netzwerk","VPN"],"keywords":["VPN","Gateway","Remotezugriff"],"source_node_ids":[],"confidence":0.92,"open_questions":[]}`
|
|
}
|
|
_ = 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 != 3 {
|
|
t.Fatalf("expected relation, plan and draft 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)
|
|
}
|
|
ai, ok := doc["ai_think"].(map[string]any)
|
|
if !ok || ai["subtype"] != "knowledge_synthesis" || ai["action"] != "create" {
|
|
t.Fatalf("unexpected AI-THINK metadata: %#v", doc["ai_think"])
|
|
}
|
|
if ai["productive_source_count"].(float64) < 3 {
|
|
t.Fatalf("expected at least three productive sources: %#v", ai)
|
|
}
|
|
}
|
|
|
|
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 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, 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)
|
|
}
|
|
ai := doc["ai_think"].(map[string]any)
|
|
if ai["action"] != "update" || ai["target_article_id"] != "KB-TARGET" || ai["target_node_id"] != target.ID {
|
|
t.Fatalf("update target metadata was not preserved: %#v", ai)
|
|
}
|
|
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))
|
|
}
|
|
}
|