Update 2+3
release-tag / release-image (push) Successful in 1m36s

This commit is contained in:
2026-08-04 12:07:45 +02:00
parent 5cb00c45d5
commit e94fb23f0c
27 changed files with 1816 additions and 201 deletions
+171 -17
View File
@@ -7,15 +7,18 @@ import (
"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 TestEnrichWritesAIThinkOnlyAfterStructuredQwenDecision(t *testing.T) {
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 {
@@ -28,11 +31,20 @@ func TestEnrichWritesAIThinkOnlyAfterStructuredQwenDecision(t *testing.T) {
_ = json.NewDecoder(r.Body).Decode(&req)
vectors := make([][]float64, len(req.Input))
for i := range vectors {
vectors[i] = []float64{1, float64(i) * .05, 0}
vectors[i] = []float64{1, float64(i) * .03, 0}
}
_ = json.NewEncoder(w).Encode(map[string]any{"embeddings": vectors})
case "/api/chat":
content := `{"related":true,"relation_type":"supports","confidence":0.91,"explanation":"Beide Einträge behandeln denselben VPN-Störungsablauf.","needs_research":false,"research_query":"","title":"VPN-Gateway und Remotezugriff","synthesis":"Der Gateway-Fehler ist ein konkreter Teilbereich des Remotezugriffs.","keywords":["VPN","Gateway"]}`
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)
@@ -54,15 +66,16 @@ func TestEnrichWritesAIThinkOnlyAfterStructuredQwenDecision(t *testing.T) {
t.Fatal(err)
}
}
write("vpn.json", `{"id":"KB-VPN","title":"VPN Gateway","text":"Gateway nicht erreichbar","categories":["Netzwerk"],"keywords":["VPN","Gateway"],"source":"internal-kb"}`)
write("remote.json", `{"id":"KB-REMOTE","title":"Remotezugriff und VPN","text":"Remotezugriff über VPN und Gateway","categories":["Netzwerk"],"keywords":["VPN","Remotezugriff"],"source":"internal-kb"}`)
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, TopK: 5, MaxContextChars: 8000}
e := New(cfg, g, activity.New(20))
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)
}
@@ -75,9 +88,12 @@ func TestEnrichWritesAIThinkOnlyAfterStructuredQwenDecision(t *testing.T) {
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 AI-THINK file, files=%v err=%v", files, err)
t.Fatalf("expected one structured AI-THINK article, files=%v err=%v", files, err)
}
var doc map[string]any
b, _ := os.ReadFile(files[0])
@@ -87,15 +103,68 @@ func TestEnrichWritesAIThinkOnlyAfterStructuredQwenDecision(t *testing.T) {
if doc["auto_reply"] != false {
t.Fatalf("AI-THINK must be auto_reply=false: %#v", doc["auto_reply"])
}
cats, _ := doc["categories"].([]any)
found := false
for _, c := range cats {
if c == "AI-THINK" {
found = true
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)
}
}
if !found {
t.Fatalf("AI-THINK category missing: %#v", cats)
_ = 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")
}
}
@@ -132,11 +201,12 @@ func TestRuntimeSettingsDisableThinkingAndPersist(t *testing.T) {
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" {
if updated.LearningEnabled || updated.ThinkingEnabled || updated.ViewMode != "honeycomb" || updated.MaxDisplayNodes != 4321 {
t.Fatalf("unexpected runtime settings: %+v", updated)
}
if e.RequestEnrich("manual") {
@@ -155,7 +225,91 @@ func TestRuntimeSettingsDisableThinkingAndPersist(t *testing.T) {
}
e2 := New(cfg, g2, activity.New(20))
loaded := e2.RuntimeSettings()
if loaded.LearningEnabled || loaded.ThinkingEnabled || loaded.ViewMode != "honeycomb" || len(loaded.DisplayCategories) != 1 {
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))
}
}