Honeycomb und Control-Patch
This commit is contained in:
+52
-10
@@ -28,7 +28,11 @@ import (
|
||||
"github.com/local/glpi-neural-brain/internal/research"
|
||||
)
|
||||
|
||||
var ErrNoCandidate = errors.New("no enrichment candidate")
|
||||
var (
|
||||
ErrNoCandidate = errors.New("no enrichment candidate")
|
||||
ErrLearningDisabled = errors.New("learning is disabled")
|
||||
ErrThinkingDisabled = errors.New("thinking is disabled")
|
||||
)
|
||||
|
||||
type EnrichOutcome struct {
|
||||
Result string
|
||||
@@ -63,9 +67,17 @@ type Engine struct {
|
||||
enrichCreated uint64
|
||||
enrichRejected uint64
|
||||
enrichRequests chan string
|
||||
runtimeMu sync.RWMutex
|
||||
runtime RuntimeSettings
|
||||
runtimePath string
|
||||
}
|
||||
|
||||
func New(cfg config.Config, g *graph.Store, b *activity.Broker) *Engine {
|
||||
if !cfg.RuntimeDefaultsConfigured {
|
||||
cfg.LearningEnabled = true
|
||||
cfg.ThinkingEnabled = true
|
||||
cfg.DefaultView = "neural"
|
||||
}
|
||||
if cfg.EnrichBatchSize < 1 {
|
||||
cfg.EnrichBatchSize = 1
|
||||
}
|
||||
@@ -96,13 +108,14 @@ func New(cfg config.Config, g *graph.Store, b *activity.Broker) *Engine {
|
||||
RequireEmbeddingModel: cfg.OllamaRequireEmbeddingModel,
|
||||
}, cfg.ChatModel, cfg.EmbeddingModel)
|
||||
persistence := persist.New(g, b, cfg.PersistInterval)
|
||||
e := &Engine{Cfg: cfg, Graph: g, Broker: b, Ollama: pool, Persistence: persistence, Scanner: &ingest.KnowledgeScanner{Graph: g, ProductionDirs: cfg.KnowledgeDirs, StagingDirs: cfg.StagingDirs}, enrichRequests: make(chan string, 1)}
|
||||
e := &Engine{Cfg: cfg, Graph: g, Broker: b, Ollama: pool, Persistence: persistence, Scanner: &ingest.KnowledgeScanner{Graph: g, ProductionDirs: cfg.KnowledgeDirs, StagingDirs: cfg.StagingDirs}, enrichRequests: make(chan string, 1), runtimePath: filepath.Join(cfg.DataDir, "runtime-settings.json")}
|
||||
e.loadRuntimeSettings()
|
||||
if cfg.SearXNGURL != "" {
|
||||
e.Research = research.New(cfg.SearXNGURL)
|
||||
}
|
||||
if cfg.GLPIKBEnabled {
|
||||
client := glpi.New(cfg.GLPIURL, cfg.GLPIAPIVersion, cfg.GLPIClientID, cfg.GLPIClientSecret, cfg.GLPIUsername, cfg.GLPIPassword, cfg.GLPITimeout)
|
||||
e.GLPIKB = ingest.NewGLPIKBSyncer(ingest.GLPIKBConfig{Enabled: true, Path: cfg.GLPIKBPath, Filter: cfg.GLPIKBFilter, Limit: cfg.GLPIKBLimit, SyncInterval: cfg.GLPIKBSyncInterval, Source: cfg.GLPIKBSource, CachePath: filepath.Join(cfg.DataDir, "glpi-kb-cache.json")}, client, g, b, persistence)
|
||||
e.GLPIKB = ingest.NewGLPIKBSyncer(ingest.GLPIKBConfig{Enabled: true, Path: cfg.GLPIKBPath, Filter: cfg.GLPIKBFilter, Limit: cfg.GLPIKBLimit, SyncInterval: cfg.GLPIKBSyncInterval, Source: cfg.GLPIKBSource, CachePath: filepath.Join(cfg.DataDir, "glpi-kb-cache.json"), ShouldSync: e.LearningEnabled}, client, g, b, persistence)
|
||||
}
|
||||
return e
|
||||
}
|
||||
@@ -113,8 +126,10 @@ func (e *Engine) Start(ctx context.Context) {
|
||||
e.GLPIKB.Start(ctx)
|
||||
}
|
||||
go func() {
|
||||
if err := e.Scan(ctx); err != nil {
|
||||
slog.Error("initial brain scan failed", "error", err)
|
||||
if e.LearningEnabled() {
|
||||
if err := e.Scan(ctx); err != nil && !errors.Is(err, ErrLearningDisabled) {
|
||||
slog.Error("initial brain scan failed", "error", err)
|
||||
}
|
||||
}
|
||||
ticker := time.NewTicker(e.Cfg.ScanInterval)
|
||||
defer ticker.Stop()
|
||||
@@ -123,7 +138,10 @@ func (e *Engine) Start(ctx context.Context) {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
if err := e.Scan(ctx); err != nil {
|
||||
if !e.LearningEnabled() {
|
||||
continue
|
||||
}
|
||||
if err := e.Scan(ctx); err != nil && !errors.Is(err, ErrLearningDisabled) {
|
||||
slog.Error("brain scan failed", "error", err)
|
||||
}
|
||||
}
|
||||
@@ -169,6 +187,13 @@ func (e *Engine) enrichmentWorker(ctx context.Context) {
|
||||
}
|
||||
|
||||
func (e *Engine) RequestEnrich(trigger string) bool {
|
||||
if !e.ThinkingEnabled() {
|
||||
e.stateMu.Lock()
|
||||
e.enrichResult = "disabled"
|
||||
e.enrichError = ErrThinkingDisabled.Error()
|
||||
e.stateMu.Unlock()
|
||||
return false
|
||||
}
|
||||
if strings.TrimSpace(trigger) == "" {
|
||||
trigger = "manual"
|
||||
}
|
||||
@@ -213,6 +238,10 @@ func (e *Engine) runEnrichmentCycle(ctx context.Context, trigger string) {
|
||||
result := "completed"
|
||||
var cycleErr error
|
||||
for step := 0; step < e.Cfg.EnrichBatchSize; step++ {
|
||||
if !e.ThinkingEnabled() {
|
||||
result = "disabled"
|
||||
break
|
||||
}
|
||||
outcome, err := e.enrichOne(ctx, trigger)
|
||||
if err != nil {
|
||||
cycleErr = err
|
||||
@@ -306,6 +335,9 @@ func (e *Engine) idle(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
func (e *Engine) Scan(ctx context.Context) error {
|
||||
if !e.LearningEnabled() {
|
||||
return ErrLearningDisabled
|
||||
}
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
beforeVersion := e.Graph.Version()
|
||||
@@ -313,7 +345,7 @@ func (e *Engine) Scan(ctx context.Context) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pendingEmbeddings := len(e.Graph.NodesForEmbedding())
|
||||
pendingEmbeddings := len(e.Graph.NodesForEmbeddingFiltered(e.learningCategories()))
|
||||
if e.Graph.Version() != beforeVersion || pendingEmbeddings > 0 {
|
||||
e.Broker.Publish(model.Activity{Type: "scan.started", Source: "brain", Phase: "ingest", Message: "Neue oder geänderte Wissenselemente werden verarbeitet", Strength: .45, Metadata: map[string]any{"pending_embeddings": pendingEmbeddings}})
|
||||
}
|
||||
@@ -346,7 +378,7 @@ func (e *Engine) Scan(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
func (e *Engine) ensureEmbeddings(ctx context.Context) error {
|
||||
pending := e.Graph.NodesForEmbedding()
|
||||
pending := e.Graph.NodesForEmbeddingFiltered(e.learningCategories())
|
||||
if len(pending) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -377,7 +409,7 @@ func (e *Engine) ensureEmbeddings(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
func (e *Engine) ensureFallbackEmbeddings() {
|
||||
for _, n := range e.Graph.NodesForEmbedding() {
|
||||
for _, n := range e.Graph.NodesForEmbeddingFiltered(e.learningCategories()) {
|
||||
e.Graph.SetVector(n.ID, hashEmbedding(embeddingText(n), 256))
|
||||
}
|
||||
}
|
||||
@@ -490,11 +522,17 @@ func (e *Engine) fallbackAnswer(q string, hits []model.Hit) string {
|
||||
}
|
||||
|
||||
func (e *Engine) EnrichOne(ctx context.Context) error {
|
||||
if !e.ThinkingEnabled() {
|
||||
return ErrThinkingDisabled
|
||||
}
|
||||
_, err := e.enrichOne(ctx, "direct")
|
||||
return err
|
||||
}
|
||||
|
||||
func (e *Engine) enrichOne(ctx context.Context, trigger string) (EnrichOutcome, error) {
|
||||
if !e.ThinkingEnabled() {
|
||||
return EnrichOutcome{Result: "disabled"}, ErrThinkingDisabled
|
||||
}
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
|
||||
@@ -509,7 +547,7 @@ func (e *Engine) enrichOne(ctx context.Context, trigger string) (EnrichOutcome,
|
||||
e.setOllamaOK(true)
|
||||
}
|
||||
|
||||
a, b, sim, ok, comparisons := e.Graph.NextPair(e.Cfg.SimilarityThreshold, e.Cfg.EnrichAnchors)
|
||||
a, b, sim, ok, comparisons := e.Graph.NextPairFiltered(e.Cfg.SimilarityThreshold, e.Cfg.EnrichAnchors, e.thinkingCategories())
|
||||
if !ok {
|
||||
e.stateMu.Lock()
|
||||
e.lastAttempt = time.Now().UTC()
|
||||
@@ -631,6 +669,7 @@ func (e *Engine) Status() map[string]any {
|
||||
"enrich_batch_size": e.Cfg.EnrichBatchSize, "enrich_anchors": e.Cfg.EnrichAnchors,
|
||||
"research_enabled": e.Cfg.ResearchEnabled, "chat_model": e.Cfg.ChatModel, "embedding_model": e.Cfg.EmbeddingModel,
|
||||
"ollama_pool": e.Ollama.PoolStatus(), "persistence": e.Persistence.Status(),
|
||||
"runtime_settings": e.RuntimeSettings(),
|
||||
}
|
||||
if e.GLPIKB != nil {
|
||||
status["glpi_kb"] = e.GLPIKB.Status()
|
||||
@@ -646,6 +685,9 @@ func (e *Engine) Flush(ctx context.Context) error {
|
||||
}
|
||||
|
||||
func (e *Engine) SyncGLPIKB(ctx context.Context) error {
|
||||
if !e.LearningEnabled() {
|
||||
return ErrLearningDisabled
|
||||
}
|
||||
if e.GLPIKB == nil {
|
||||
return fmt.Errorf("GLPI knowledge-base integration is disabled")
|
||||
}
|
||||
|
||||
@@ -116,3 +116,46 @@ func TestRequestEnrichDoesNotQueueDuplicateCycle(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/local/glpi-neural-brain/internal/model"
|
||||
)
|
||||
|
||||
const uncategorizedFilter = "__uncategorized__"
|
||||
|
||||
type RuntimeSettings struct {
|
||||
LearningEnabled bool `json:"learning_enabled"`
|
||||
ThinkingEnabled bool `json:"thinking_enabled"`
|
||||
LearningCategories []string `json:"learning_categories"`
|
||||
DisplayCategories []string `json:"display_categories"`
|
||||
ThinkingCategories []string `json:"thinking_categories"`
|
||||
ViewMode string `json:"view_mode"`
|
||||
}
|
||||
|
||||
type CategoryInfo struct {
|
||||
Name string `json:"name"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
func (e *Engine) defaultRuntimeSettings() RuntimeSettings {
|
||||
return normalizeRuntimeSettings(RuntimeSettings{
|
||||
LearningEnabled: e.Cfg.LearningEnabled,
|
||||
ThinkingEnabled: e.Cfg.ThinkingEnabled,
|
||||
LearningCategories: append([]string(nil), e.Cfg.LearningCategories...),
|
||||
DisplayCategories: append([]string(nil), e.Cfg.DisplayCategories...),
|
||||
ThinkingCategories: append([]string(nil), e.Cfg.ThinkingCategories...),
|
||||
ViewMode: e.Cfg.DefaultView,
|
||||
})
|
||||
}
|
||||
|
||||
func normalizeRuntimeSettings(in RuntimeSettings) RuntimeSettings {
|
||||
in.LearningCategories = normalizeCategories(in.LearningCategories)
|
||||
in.DisplayCategories = normalizeCategories(in.DisplayCategories)
|
||||
in.ThinkingCategories = normalizeCategories(in.ThinkingCategories)
|
||||
in.ViewMode = strings.ToLower(strings.TrimSpace(in.ViewMode))
|
||||
if in.ViewMode == "" {
|
||||
in.ViewMode = "neural"
|
||||
}
|
||||
if in.ViewMode != "neural" && in.ViewMode != "honeycomb" {
|
||||
in.ViewMode = "neural"
|
||||
}
|
||||
return in
|
||||
}
|
||||
|
||||
func normalizeCategories(values []string) []string {
|
||||
seen := map[string]string{}
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(value)
|
||||
if _, exists := seen[key]; !exists {
|
||||
seen[key] = value
|
||||
}
|
||||
}
|
||||
out := make([]string, 0, len(seen))
|
||||
for _, value := range seen {
|
||||
out = append(out, value)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return strings.ToLower(out[i]) < strings.ToLower(out[j]) })
|
||||
return out
|
||||
}
|
||||
|
||||
func (e *Engine) loadRuntimeSettings() {
|
||||
settings := e.defaultRuntimeSettings()
|
||||
if strings.TrimSpace(e.runtimePath) != "" {
|
||||
if data, err := os.ReadFile(e.runtimePath); err == nil {
|
||||
var stored RuntimeSettings
|
||||
if json.Unmarshal(data, &stored) == nil {
|
||||
settings = normalizeRuntimeSettings(stored)
|
||||
}
|
||||
}
|
||||
}
|
||||
e.runtimeMu.Lock()
|
||||
e.runtime = settings
|
||||
e.runtimeMu.Unlock()
|
||||
}
|
||||
|
||||
func (e *Engine) RuntimeSettings() RuntimeSettings {
|
||||
e.runtimeMu.RLock()
|
||||
settings := e.runtime
|
||||
e.runtimeMu.RUnlock()
|
||||
settings.LearningCategories = append([]string{}, settings.LearningCategories...)
|
||||
settings.DisplayCategories = append([]string{}, settings.DisplayCategories...)
|
||||
settings.ThinkingCategories = append([]string{}, settings.ThinkingCategories...)
|
||||
return settings
|
||||
}
|
||||
|
||||
func (e *Engine) SetRuntimeSettings(settings RuntimeSettings) (RuntimeSettings, error) {
|
||||
settings = normalizeRuntimeSettings(settings)
|
||||
e.runtimeMu.Lock()
|
||||
previous := e.runtime
|
||||
e.runtime = settings
|
||||
e.runtimeMu.Unlock()
|
||||
|
||||
if !settings.ThinkingEnabled {
|
||||
e.stateMu.Lock()
|
||||
if !e.enrichRunning && e.enrichResult == "queued" {
|
||||
e.enrichResult = "disabled"
|
||||
}
|
||||
e.stateMu.Unlock()
|
||||
}
|
||||
|
||||
if e.Persistence != nil && strings.TrimSpace(e.runtimePath) != "" {
|
||||
data, err := json.MarshalIndent(settings, "", " ")
|
||||
if err != nil {
|
||||
return previous, err
|
||||
}
|
||||
if _, err := e.Persistence.QueueFile(e.runtimePath, append(data, '\n'), 0o640); err != nil {
|
||||
e.runtimeMu.Lock()
|
||||
e.runtime = previous
|
||||
e.runtimeMu.Unlock()
|
||||
return previous, err
|
||||
}
|
||||
}
|
||||
|
||||
e.Broker.Publish(model.Activity{
|
||||
Type: "runtime.settings.updated",
|
||||
Source: "ui",
|
||||
Phase: "control",
|
||||
Message: "Lern-, Anzeige- und Thinking-Einstellungen wurden aktualisiert",
|
||||
Strength: .32,
|
||||
Metadata: map[string]any{
|
||||
"learning_enabled": settings.LearningEnabled,
|
||||
"thinking_enabled": settings.ThinkingEnabled,
|
||||
"learning_categories": len(settings.LearningCategories),
|
||||
"display_categories": len(settings.DisplayCategories),
|
||||
"thinking_categories": len(settings.ThinkingCategories),
|
||||
"view_mode": settings.ViewMode,
|
||||
},
|
||||
})
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
func (e *Engine) LearningEnabled() bool {
|
||||
e.runtimeMu.RLock()
|
||||
enabled := e.runtime.LearningEnabled
|
||||
e.runtimeMu.RUnlock()
|
||||
return enabled
|
||||
}
|
||||
|
||||
func (e *Engine) ThinkingEnabled() bool {
|
||||
e.runtimeMu.RLock()
|
||||
enabled := e.runtime.ThinkingEnabled
|
||||
e.runtimeMu.RUnlock()
|
||||
return enabled
|
||||
}
|
||||
|
||||
func (e *Engine) learningCategories() []string {
|
||||
e.runtimeMu.RLock()
|
||||
out := append([]string(nil), e.runtime.LearningCategories...)
|
||||
e.runtimeMu.RUnlock()
|
||||
return out
|
||||
}
|
||||
|
||||
func (e *Engine) thinkingCategories() []string {
|
||||
e.runtimeMu.RLock()
|
||||
out := append([]string(nil), e.runtime.ThinkingCategories...)
|
||||
e.runtimeMu.RUnlock()
|
||||
return out
|
||||
}
|
||||
|
||||
func (e *Engine) Categories() []CategoryInfo {
|
||||
snapshot := e.Graph.Snapshot()
|
||||
counts := map[string]int{}
|
||||
names := map[string]string{}
|
||||
uncategorized := 0
|
||||
for _, node := range snapshot.Nodes {
|
||||
if node.Kind != "knowledge" && node.Kind != "ai-think" && node.Kind != "external" {
|
||||
continue
|
||||
}
|
||||
if len(node.Categories) == 0 {
|
||||
uncategorized++
|
||||
continue
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, category := range node.Categories {
|
||||
category = strings.TrimSpace(category)
|
||||
if category == "" {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(category)
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
if _, ok := names[key]; !ok {
|
||||
names[key] = category
|
||||
}
|
||||
counts[key]++
|
||||
}
|
||||
}
|
||||
out := make([]CategoryInfo, 0, len(counts)+1)
|
||||
for key, count := range counts {
|
||||
out = append(out, CategoryInfo{Name: names[key], Count: count})
|
||||
}
|
||||
if uncategorized > 0 {
|
||||
out = append(out, CategoryInfo{Name: uncategorizedFilter, Count: uncategorized})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].Count == out[j].Count {
|
||||
return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name)
|
||||
}
|
||||
return out[i].Count > out[j].Count
|
||||
})
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user