523 lines
31 KiB
Go
523 lines
31 KiB
Go
package engine
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/local/glpi-neural-brain/internal/activity"
|
|
"github.com/local/glpi-neural-brain/internal/config"
|
|
"github.com/local/glpi-neural-brain/internal/graph"
|
|
"github.com/local/glpi-neural-brain/internal/model"
|
|
)
|
|
|
|
func TestEnrichCreatesStructuredKnowledgeArticleFromThreeProductionSources(t *testing.T) {
|
|
var chatCalls int
|
|
mock := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
switch r.URL.Path {
|
|
case "/api/tags":
|
|
_, _ = w.Write([]byte(`{"models":[{"name":"qwen3:8b","digest":"chat-digest"},{"name":"embeddinggemma:latest","digest":"embed-digest"}]}`))
|
|
case "/api/embed":
|
|
var req struct {
|
|
Input []string `json:"input"`
|
|
}
|
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
|
vectors := make([][]float64, len(req.Input))
|
|
for i := range vectors {
|
|
vectors[i] = []float64{1, float64(i) * .03, 0}
|
|
}
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"embeddings": vectors})
|
|
case "/api/chat":
|
|
chatCalls++
|
|
var content string
|
|
switch chatCalls {
|
|
case 1:
|
|
content = `{"related":true,"relation_type":"supports","confidence":0.91,"explanation":"Die drei produktiven Quellen beschreiben zusammen Diagnose, Ursache und Behebung derselben VPN-Störung.","needs_research":false,"research_query":"","topic_label":"VPN-Gateway-Störung","keywords":["VPN","Gateway"]}`
|
|
case 2:
|
|
content = `{"action":"create","target_article_id":"","reason":"Die Quellen ergänzen sich zu einer vollständigen Anleitung, die noch nicht als einzelner Artikel existiert.","expected_value":"Ein durchgängiger Diagnose- und Lösungsablauf für den Support.","article_type":"troubleshooting","source_node_ids":[],"missing_information":[],"contradictions":[],"needs_research":false,"research_query":""}`
|
|
case 3:
|
|
content = `{"topic":"VPN-Gateway-Störung","purpose":"VPN-Verbindungsprobleme diagnostizieren und beheben","scope":[{"text":"Gilt für Remotezugriffe über einen VPN-Client.","source_refs":["KB-VPN"]}],"facts":[{"text":"Ein nicht erreichbares Gateway kann den Tunnelaufbau verhindern.","source_refs":["KB-VPN"]}],"symptoms":[{"text":"Der Client meldet ein nicht erreichbares Gateway oder einen Timeout.","source_refs":["KB-VPN","KB-REMOTE"]}],"prerequisites":[{"text":"Die aktuelle Fehlermeldung und der Zeitpunkt des Fehlers liegen vor.","source_refs":["KB-VPN-ESC"]}],"solution_steps":[{"text":"Prüfen Sie die Internetverbindung des betroffenen Rechners.","source_refs":["KB-VPN"]},{"text":"Kontrollieren Sie die konfigurierte Gateway-Adresse und deren Erreichbarkeit.","source_refs":["KB-VPN"]},{"text":"Starten Sie den VPN-Client neu und prüfen Sie den Tunnelaufbau erneut.","source_refs":["KB-REMOTE"]},{"text":"Eskalieren Sie einen anhaltenden Gateway-Ausfall mit Zeitstempel und Fehlermeldung an das Netzwerkteam.","source_refs":["KB-VPN-ESC"]}],"validation_steps":[{"text":"Der VPN-Tunnel wird aufgebaut und interne Ressourcen sind erreichbar.","source_refs":["KB-REMOTE"]}],"troubleshooting":[{"text":"Bei weiterhin nicht erreichbarem Gateway den dokumentierten Eskalationsweg verwenden.","source_refs":["KB-VPN-ESC"]}],"contradictions":[],"missing_information":[],"research_queries":[],"ready_for_article":true}`
|
|
case 4:
|
|
content = `{"title":"VPN-Gateway-Störung systematisch beheben","problem_description":"Der VPN-Client kann keinen Tunnel zum zentralen Gateway aufbauen. Betroffene Benutzer sehen typischerweise einen Timeout oder die Meldung, dass das Gateway nicht erreichbar ist. Die folgenden Schritte grenzen lokale Verbindungsprobleme von einer zentralen Gateway-Störung ab.","scope":"Die Anleitung gilt für Remotezugriffe über den dokumentierten VPN-Client und das zugehörige zentrale Gateway.","symptoms":["Der Verbindungsaufbau endet mit einem Timeout.","Das konfigurierte Gateway ist nicht erreichbar."],"prerequisites":["Die aktuelle Fehlermeldung und der Zeitpunkt des Fehlers liegen vor.","Zugriff auf die VPN-Client-Konfiguration ist vorhanden."],"solution_steps":["Prüfen Sie, ob der betroffene Rechner eine funktionierende Internetverbindung besitzt.","Kontrollieren Sie die konfigurierte Gateway-Adresse und testen Sie deren Erreichbarkeit.","Starten Sie den VPN-Client neu und führen Sie den Verbindungsversuch erneut aus.","Bleibt das Gateway nicht erreichbar, übergeben Sie Zeitstempel, Fehlermeldung und betroffene Benutzer an das Netzwerkteam."],"validation_steps":["Der VPN-Tunnel wird aufgebaut.","Eine interne Zielressource ist erreichbar."],"troubleshooting":["Bei weiterhin nicht erreichbarem Gateway den dokumentierten Eskalationsweg verwenden."],"categories":["Netzwerk","VPN"],"keywords":["VPN","Gateway","Remotezugriff"],"open_questions":[]}`
|
|
default:
|
|
content = `{"accepted":true,"confidence":0.92,"meta_content_detected":false,"unsupported_claims":[],"issues":[]}`
|
|
}
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"message": map[string]any{"content": content}})
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
defer mock.Close()
|
|
|
|
root := t.TempDir()
|
|
knowledge := filepath.Join(root, "knowledge")
|
|
staging := filepath.Join(root, "staging")
|
|
data := filepath.Join(root, "data")
|
|
for _, d := range []string{knowledge, staging, data} {
|
|
if err := os.MkdirAll(d, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
write := func(name, body string) {
|
|
if err := os.WriteFile(filepath.Join(knowledge, name), []byte(body), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
write("vpn.json", `{"id":"KB-VPN","title":"VPN Gateway nicht erreichbar","text":"Der Client meldet, dass das Gateway nicht erreichbar ist.","answer":"Internetverbindung und Gateway-Adresse prüfen.","categories":["Netzwerk","VPN"],"keywords":["VPN","Gateway"],"source":"internal-kb"}`)
|
|
write("remote.json", `{"id":"KB-REMOTE","title":"Remotezugriff über VPN diagnostizieren","text":"Remotezugriff schlägt mit einem Timeout fehl.","answer":"Fehlermeldung erfassen, Client neu starten und Tunnel prüfen.","categories":["Netzwerk","VPN"],"keywords":["VPN","Remotezugriff"],"source":"internal-kb"}`)
|
|
write("escalation.json", `{"id":"KB-VPN-ESC","title":"VPN-Störungen eskalieren","text":"Anhaltende Gateway-Ausfälle benötigen eine strukturierte Eskalation.","answer":"Zeitstempel, Fehlermeldung und betroffene Benutzer an das Netzwerkteam übergeben.","categories":["Netzwerk","VPN"],"keywords":["VPN","Eskalation"],"source":"internal-kb"}`)
|
|
|
|
g, err := graph.Open(data)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cfg := config.Config{DataDir: data, KnowledgeDirs: []string{knowledge}, StagingDirs: []string{staging}, OllamaURL: mock.URL, ChatModel: "qwen3:8b", EmbeddingModel: "embeddinggemma", ScanInterval: time.Minute, EnrichInterval: time.Minute, SimilarityThreshold: .5, RelationThreshold: .7, ArticleSynthesisEnabled: true, ArticleMinSources: 3, ArticleMaxSources: 6, ArticleMinProductionRatio: .7, ArticleMaxGenerationDepth: 2, ArticleMinConfidence: .7, ArticleMinTextChars: 80, ArticleMinAnswerChars: 180, TopK: 5, MaxContextChars: 16000}
|
|
e := New(cfg, g, activity.New(50))
|
|
if err := e.Scan(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !e.ollamaOK {
|
|
t.Fatal("mock Ollama should be healthy")
|
|
}
|
|
if err := e.EnrichOne(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := e.Flush(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if chatCalls != 5 {
|
|
t.Fatalf("expected relation, plan, consolidation, draft and quality calls, got %d", chatCalls)
|
|
}
|
|
files, err := filepath.Glob(filepath.Join(staging, "*.json"))
|
|
if err != nil || len(files) != 1 {
|
|
t.Fatalf("expected one structured AI-THINK article, files=%v err=%v", files, err)
|
|
}
|
|
var doc map[string]any
|
|
b, _ := os.ReadFile(files[0])
|
|
if err := json.Unmarshal(b, &doc); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if doc["auto_reply"] != false {
|
|
t.Fatalf("AI-THINK must be auto_reply=false: %#v", doc["auto_reply"])
|
|
}
|
|
if got := doc["answer"].(string); len(got) < 180 || got == "Interne AI-THINK-Arbeitsnotiz. Vor produktiver Nutzung im Editor prüfen, korrigieren und freigeben." {
|
|
t.Fatalf("expected a real solution article, got %q", got)
|
|
}
|
|
if _, exists := doc["ai_think"]; exists {
|
|
t.Fatalf("internal assessment metadata must not be written into the KB article: %#v", doc["ai_think"])
|
|
}
|
|
visible := strings.ToLower(doc["text"].(string) + "\n" + doc["answer"].(string))
|
|
for _, forbidden := range []string{"die quellen", "mehrwert", "bewertung", "source_node", "relation"} {
|
|
if strings.Contains(visible, forbidden) {
|
|
t.Fatalf("visible article contains internal assessment language %q: %s", forbidden, visible)
|
|
}
|
|
}
|
|
metaFiles, err := filepath.Glob(filepath.Join(data, "article-metadata", "*.json"))
|
|
if err != nil || len(metaFiles) != 1 {
|
|
t.Fatalf("expected one separate article metadata sidecar, files=%v err=%v", metaFiles, err)
|
|
}
|
|
}
|
|
|
|
func TestEnrichRelationWithOnlyTwoSourcesDoesNotCreateArticle(t *testing.T) {
|
|
var chatCalls int
|
|
mock := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
switch r.URL.Path {
|
|
case "/api/tags":
|
|
_, _ = w.Write([]byte(`{"models":[{"name":"qwen3:8b","digest":"chat-digest"},{"name":"embeddinggemma:latest","digest":"embed-digest"}]}`))
|
|
case "/api/embed":
|
|
var req struct {
|
|
Input []string `json:"input"`
|
|
}
|
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
|
vectors := make([][]float64, len(req.Input))
|
|
for i := range vectors {
|
|
vectors[i] = []float64{1, float64(i) * .02}
|
|
}
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"embeddings": vectors})
|
|
case "/api/chat":
|
|
chatCalls++
|
|
content := `{"related":true,"relation_type":"same_topic","confidence":0.9,"explanation":"Beide Quellen behandeln dasselbe Gateway-Thema.","needs_research":false,"research_query":"","topic_label":"VPN","keywords":["VPN"]}`
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"message": map[string]any{"content": content}})
|
|
}
|
|
}))
|
|
defer mock.Close()
|
|
root := t.TempDir()
|
|
knowledge, staging, data := filepath.Join(root, "knowledge"), filepath.Join(root, "staging"), filepath.Join(root, "data")
|
|
for _, dir := range []string{knowledge, staging, data} {
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
_ = os.WriteFile(filepath.Join(knowledge, "a.json"), []byte(`{"id":"A","title":"VPN A","text":"Gateway","categories":["VPN"]}`), 0o644)
|
|
_ = os.WriteFile(filepath.Join(knowledge, "b.json"), []byte(`{"id":"B","title":"VPN B","text":"Gateway","categories":["VPN"]}`), 0o644)
|
|
g, _ := graph.Open(data)
|
|
e := New(config.Config{DataDir: data, KnowledgeDirs: []string{knowledge}, StagingDirs: []string{staging}, OllamaURL: mock.URL, ChatModel: "qwen3:8b", EmbeddingModel: "embeddinggemma", SimilarityThreshold: .5, RelationThreshold: .7, ArticleSynthesisEnabled: true, ArticleMinSources: 3, ArticleMaxSources: 8, ArticleMinProductionRatio: .7, ArticleMaxGenerationDepth: 2, ArticleMinConfidence: .7, ArticleMinTextChars: 80, ArticleMinAnswerChars: 180, MaxContextChars: 8000}, g, activity.New(20))
|
|
if err := e.Scan(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := e.EnrichOne(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if chatCalls != 1 {
|
|
t.Fatalf("article planning must not run with only two productive sources; calls=%d", chatCalls)
|
|
}
|
|
files, _ := filepath.Glob(filepath.Join(staging, "*.json"))
|
|
if len(files) != 0 {
|
|
t.Fatalf("unexpected article draft: %v", files)
|
|
}
|
|
if len(g.Snapshot().Edges) == 0 {
|
|
t.Fatal("relation edge should still be created")
|
|
}
|
|
}
|
|
|
|
func TestSynthesisResearchesUnclearKnowledgeThenLearnsAndLinksArticle(t *testing.T) {
|
|
sourceA := graph.ID("knowledge", "A")
|
|
sourceB := graph.ID("knowledge", "B")
|
|
sourceC := graph.ID("knowledge", "C")
|
|
pageServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
_, _ = w.Write([]byte(`<html><head><title>Hersteller-Dokumentation VPN Gateway</title></head><body><main><h1>Gateway-Verbindung prüfen</h1><p>Prüfen Sie zuerst die Internetverbindung des Rechners und kontrollieren Sie anschließend die konfigurierte Gateway-Adresse.</p><p>Testen Sie die Erreichbarkeit des Gateways mit dem vom Hersteller dokumentierten Verbindungstest. Starten Sie danach den VPN-Client neu.</p><p>Die Prüfung ist erfolgreich, wenn der Tunnel aufgebaut wird und eine interne Testressource erreichbar ist. Erfassen Sie bei einem anhaltenden Fehler Zeitstempel und Fehlermeldung für die Eskalation.</p></main></body></html>`))
|
|
}))
|
|
defer pageServer.Close()
|
|
|
|
var chatCalls int
|
|
ollama := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
switch r.URL.Path {
|
|
case "/api/tags":
|
|
_, _ = w.Write([]byte(`{"models":[{"name":"qwen3:8b","digest":"chat-digest"},{"name":"embeddinggemma:latest","digest":"embed-digest"}]}`))
|
|
case "/api/embed":
|
|
var req struct {
|
|
Input []string `json:"input"`
|
|
}
|
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
|
vectors := make([][]float64, len(req.Input))
|
|
for i := range vectors {
|
|
vectors[i] = []float64{1, .1 + float64(i)*.01, .2}
|
|
}
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"embeddings": vectors})
|
|
case "/api/chat":
|
|
chatCalls++
|
|
content := ""
|
|
switch chatCalls {
|
|
case 1:
|
|
content = `{"related":true,"relation_type":"same_topic","confidence":0.9,"explanation":"Die Beiträge behandeln denselben VPN-Fehler.","needs_research":false,"research_query":"","topic_label":"VPN Gateway","keywords":["VPN"]}`
|
|
case 2:
|
|
content = `{"action":"create","target_article_id":"","reason":"Die produktiven Beiträge lassen sich konsolidieren.","expected_value":"Ein vollständiger Diagnoseablauf.","article_type":"troubleshooting","source_node_ids":[],"missing_information":[],"contradictions":[],"needs_research":false,"research_query":""}`
|
|
case 3:
|
|
content = fmt.Sprintf(`{"topic":"VPN Gateway","purpose":"VPN-Fehler beheben","scope":[],"facts":[],"symptoms":[{"text":"Der VPN-Tunnel wird nicht aufgebaut.","source_refs":[%q]}],"prerequisites":[],"solution_steps":[{"text":"Internetverbindung prüfen.","source_refs":[%q]}],"validation_steps":[],"troubleshooting":[],"contradictions":[{"topic":"Offizieller Gateway-Prüfweg","statements":["Die internen Beiträge nennen keinen verlässlichen Gateway-Test."],"source_refs":[%q,%q,%q],"resolution":"","severity":"critical","needs_research":true,"research_query":"VPN Gateway Erreichbarkeit Hersteller Diagnose"}],"critical_gaps":[{"id":"G-C-1","description":"Ein belastbarer Gateway-Prüf- und Validierungsweg fehlt.","reason":"Ohne ihn ist die Anleitung nicht ausführbar.","research_queries":["VPN Gateway Erreichbarkeit Hersteller Diagnose"]}],"optional_gaps":[],"resolved_gaps":[],"missing_information":["Ein belastbarer Gateway-Prüf- und Validierungsweg fehlt."],"research_queries":["VPN Gateway Erreichbarkeit Hersteller Diagnose"],"ready_for_article":false}`, sourceA, sourceA, sourceA, sourceB, sourceC)
|
|
case 4:
|
|
content = `{"questions":[{"gap_id":"G-C-1","question":"Wie werden Gateway-Adresse und VPN-Verbindung laut Hersteller geprüft und validiert?","critical":true,"expect_actionable":true,"queries_de":["VPN Gateway Verbindung prüfen Hersteller Dokumentation"],"queries_en":[],"preferred_domains":[]}]}`
|
|
case 5:
|
|
content = `{"assessments":[{"index":1,"relevant":true,"relevance":0.94,"source_quality":"primary","source_quality_score":0.91,"actionable":true,"covered_gap_ids":["G-C-1"],"reason":"Direkte Herstelleranleitung mit Prüf- und Validierungsschritten."}]}`
|
|
case 6:
|
|
content = `{"assessments":[{"index":1,"relevant":true,"relevance":0.97,"source_quality":"primary","source_quality_score":0.94,"actionable":true,"covered_gap_ids":["G-C-1"],"reason":"Der Volltext enthält konkrete Diagnose-, Neustart- und Validierungsschritte."}]}`
|
|
case 7:
|
|
content = fmt.Sprintf(`{"topic":"VPN Gateway","purpose":"VPN-Fehler beheben","scope":[{"text":"Gilt für den dokumentierten VPN-Client.","source_refs":[%q]}],"facts":[{"text":"Gateway-Adresse und Netzwerkverbindung müssen geprüft werden.","source_refs":["R1"]}],"symptoms":[{"text":"Der VPN-Tunnel wird nicht aufgebaut.","source_refs":[%q]}],"prerequisites":[{"text":"Fehlermeldung und Zeitpunkt liegen vor.","source_refs":[%q]}],"solution_steps":[{"text":"Prüfen Sie die Internetverbindung.","source_refs":[%q,"R1"]},{"text":"Prüfen Sie die konfigurierte Gateway-Adresse und deren Erreichbarkeit.","source_refs":["R1"]},{"text":"Starten Sie den VPN-Client neu.","source_refs":[%q,"R1"]}],"validation_steps":[{"text":"Prüfen Sie, ob der VPN-Tunnel aufgebaut wird und eine interne Ressource erreichbar ist.","source_refs":["R1"]}],"troubleshooting":[{"text":"Bei anhaltendem Fehler mit Zeitstempel und Meldung eskalieren.","source_refs":[%q,"R1"]}],"contradictions":[],"critical_gaps":[],"optional_gaps":[{"id":"G-O-1","description":"Produktspezifische Screenshots fehlen.","reason":"Für die Ausführung nicht erforderlich.","research_queries":[]}],"resolved_gaps":[{"id":"G-C-1","description":"Gateway-Prüf- und Validierungsweg","source_refs":["R1"]}],"missing_information":["Produktspezifische Screenshots fehlen."],"research_queries":[],"ready_for_article":true}`, sourceA, sourceA, sourceC, sourceA, sourceB, sourceC)
|
|
case 8:
|
|
content = `{"title":"VPN-Gateway-Verbindung diagnostizieren","problem_description":"Der VPN-Client kann keine Verbindung zum konfigurierten Gateway aufbauen. Der Tunnel bleibt getrennt und der Remotezugriff ist nicht möglich.","scope":"Die Anleitung gilt für den dokumentierten VPN-Client und das konfigurierte zentrale Gateway.","symptoms":["Der VPN-Tunnel wird nicht aufgebaut.","Der Client meldet ein nicht erreichbares Gateway."],"key_points":[],"decision_criteria":[],"prerequisites":["Fehlermeldung und Zeitpunkt des Fehlers liegen vor."],"solution_steps":["Prüfen Sie die Internetverbindung des Rechners.","Kontrollieren Sie die konfigurierte Gateway-Adresse und testen Sie deren Erreichbarkeit.","Starten Sie den VPN-Client neu und wiederholen Sie den Verbindungsaufbau.","Eskalieren Sie einen anhaltenden Fehler mit Zeitstempel und Fehlermeldung."],"validation_steps":["Der VPN-Tunnel wird aufgebaut.","Eine interne Ressource ist erreichbar."],"troubleshooting":["Prüfen Sie bei erneutem Fehler die erfasste Meldung und den dokumentierten Eskalationsweg."],"categories":["Netzwerk","VPN"],"keywords":["VPN","Gateway"],"open_questions":[]}`
|
|
default:
|
|
content = `{"accepted":true,"confidence":0.93,"meta_content_detected":false,"unsupported_claims":[],"issues":[]}`
|
|
}
|
|
_ = 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)
|
|
if !ok || !matchesCategories(researchNode, []string{"VPN"}) {
|
|
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 != 9 {
|
|
t.Fatalf("expected relation, plan, brief, research plan, two relevance gates, reconsolidation, article and quality calls, got %d", chatCalls)
|
|
}
|
|
var resultEvent, candidateEvent, fetchEvent, acceptedEvent, ingestEvent, learnedEvent, roundEvent *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.evidence.accepted":
|
|
acceptedEvent = &event
|
|
case "article.research.ingested":
|
|
ingestEvent = &event
|
|
case "article.research.learned":
|
|
learnedEvent = &event
|
|
case "article.research.round.completed":
|
|
roundEvent = &event
|
|
}
|
|
}
|
|
if resultEvent == nil || candidateEvent == nil || fetchEvent == nil || acceptedEvent == nil || ingestEvent == nil || learnedEvent == nil || roundEvent == nil {
|
|
t.Fatalf("expected complete visible research pipeline, result=%v candidate=%v fetch=%v accepted=%v ingest=%v learned=%v round=%v", resultEvent != nil, candidateEvent != nil, fetchEvent != nil, acceptedEvent != nil, ingestEvent != nil, learnedEvent != nil, roundEvent != nil)
|
|
}
|
|
resultNodeIDs, _ := ingestEvent.Metadata["result_node_ids"].([]string)
|
|
if len(resultNodeIDs) != 1 || resultNodeIDs[0] != researchID {
|
|
t.Fatalf("unexpected ingested research nodes: %#v", ingestEvent.Metadata["result_node_ids"])
|
|
}
|
|
if ingestEvent.Metadata["animation_min_ms"] != 2000 {
|
|
t.Fatalf("research animation minimum missing: %#v", ingestEvent.Metadata["animation_min_ms"])
|
|
}
|
|
}
|
|
|
|
func TestRequestEnrichDoesNotQueueDuplicateCycle(t *testing.T) {
|
|
g, err := graph.Open(t.TempDir())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
e := New(config.Config{EnrichBatchSize: 3, EnrichAnchors: 12}, g, activity.New(20))
|
|
if !e.RequestEnrich("manual") {
|
|
t.Fatal("first AI-THINK request should be queued")
|
|
}
|
|
if e.RequestEnrich("automatic") {
|
|
t.Fatal("duplicate AI-THINK cycle should not be queued")
|
|
}
|
|
status := e.Status()
|
|
if status["enrich_result"] != "queued" {
|
|
t.Fatalf("unexpected enrich status: %#v", status["enrich_result"])
|
|
}
|
|
}
|
|
|
|
func TestRuntimeSettingsDisableThinkingAndPersist(t *testing.T) {
|
|
data := t.TempDir()
|
|
g, err := graph.Open(data)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cfg := config.Config{DataDir: data, RuntimeDefaultsConfigured: true, LearningEnabled: true, ThinkingEnabled: true, DefaultView: "neural", PersistInterval: time.Minute}
|
|
e := New(cfg, g, activity.New(20))
|
|
updated, err := e.SetRuntimeSettings(RuntimeSettings{
|
|
LearningEnabled: false,
|
|
ThinkingEnabled: false,
|
|
LearningCategories: []string{"Netzwerk"},
|
|
DisplayCategories: []string{"GLPI KB"},
|
|
ThinkingCategories: []string{"Netzwerk"},
|
|
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.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))
|
|
}
|
|
}
|