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) } }