Files
glpi-neural-brain/internal/engine/engine_test.go
2026-08-04 05:28:51 +02:00

162 lines
5.5 KiB
Go

package engine
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"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"
)
func TestEnrichWritesAIThinkOnlyAfterStructuredQwenDecision(t *testing.T) {
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) * .05, 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"]}`
_ = 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","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"}`)
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))
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)
}
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)
}
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"])
}
cats, _ := doc["categories"].([]any)
found := false
for _, c := range cats {
if c == "AI-THINK" {
found = true
}
}
if !found {
t.Fatalf("AI-THINK category missing: %#v", cats)
}
}
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",
})
if err != nil {
t.Fatal(err)
}
if updated.LearningEnabled || updated.ThinkingEnabled || updated.ViewMode != "honeycomb" {
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" || len(loaded.DisplayCategories) != 1 {
t.Fatalf("runtime settings were not restored: %+v", loaded)
}
}