787 lines
31 KiB
Go
787 lines
31 KiB
Go
package engine
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"math"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
"unicode"
|
|
|
|
"github.com/local/glpi-neural-brain/internal/activity"
|
|
"github.com/local/glpi-neural-brain/internal/config"
|
|
"github.com/local/glpi-neural-brain/internal/glpi"
|
|
"github.com/local/glpi-neural-brain/internal/graph"
|
|
"github.com/local/glpi-neural-brain/internal/ingest"
|
|
"github.com/local/glpi-neural-brain/internal/model"
|
|
"github.com/local/glpi-neural-brain/internal/ollama"
|
|
"github.com/local/glpi-neural-brain/internal/persist"
|
|
"github.com/local/glpi-neural-brain/internal/research"
|
|
)
|
|
|
|
var (
|
|
ErrNoCandidate = errors.New("no enrichment candidate")
|
|
ErrLearningDisabled = errors.New("learning is disabled")
|
|
ErrThinkingDisabled = errors.New("thinking is disabled")
|
|
)
|
|
|
|
type EnrichOutcome struct {
|
|
Result string
|
|
Candidate bool
|
|
Created bool
|
|
Rejected bool
|
|
Comparisons int
|
|
}
|
|
|
|
type Engine struct {
|
|
Cfg config.Config
|
|
Graph *graph.Store
|
|
Broker *activity.Broker
|
|
Ollama *ollama.Client
|
|
Research *research.Client
|
|
Scanner *ingest.KnowledgeScanner
|
|
GLPIKB *ingest.GLPIKBSyncer
|
|
Persistence *persist.Coordinator
|
|
|
|
mu sync.Mutex
|
|
stateMu sync.RWMutex
|
|
lastScan time.Time
|
|
lastEnrich time.Time
|
|
lastAttempt time.Time
|
|
nextEnrich time.Time
|
|
ollamaOK bool
|
|
enrichRunning bool
|
|
enrichTrigger string
|
|
enrichResult string
|
|
enrichError string
|
|
enrichCycles uint64
|
|
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
|
|
}
|
|
if cfg.EnrichAnchors < 1 {
|
|
cfg.EnrichAnchors = 48
|
|
}
|
|
ollamaURLs := append([]string(nil), cfg.OllamaURLs...)
|
|
if len(ollamaURLs) == 0 && strings.TrimSpace(cfg.OllamaURL) != "" {
|
|
ollamaURLs = []string{cfg.OllamaURL}
|
|
}
|
|
nodes := make([]ollama.NodeConfig, 0, len(ollamaURLs))
|
|
for i, rawURL := range ollamaURLs {
|
|
name := fmt.Sprintf("ollama-%d", i+1)
|
|
if i < len(cfg.OllamaNodeNames) && strings.TrimSpace(cfg.OllamaNodeNames[i]) != "" {
|
|
name = strings.TrimSpace(cfg.OllamaNodeNames[i])
|
|
}
|
|
weight := 1
|
|
if i < len(cfg.OllamaNodeWeights) && cfg.OllamaNodeWeights[i] > 0 {
|
|
weight = cfg.OllamaNodeWeights[i]
|
|
}
|
|
nodes = append(nodes, ollama.NodeConfig{Name: name, URL: rawURL, Weight: weight})
|
|
}
|
|
pool := ollama.NewPool(ollama.PoolConfig{
|
|
Nodes: nodes, RoutingMode: cfg.OllamaRoutingMode, NodeMaxInflight: cfg.OllamaNodeMaxInflight,
|
|
HealthInterval: cfg.OllamaHealthInterval, FailureCooldown: cfg.OllamaFailureCooldown,
|
|
RequestTimeout: cfg.OllamaRequestTimeout, FailoverEnabled: cfg.OllamaFailoverEnabled,
|
|
FailoverAttempts: cfg.OllamaFailoverAttempts, RequireSameModelDigest: cfg.OllamaRequireSameDigest,
|
|
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), 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"), ShouldSync: e.LearningEnabled}, client, g, b, persistence)
|
|
}
|
|
return e
|
|
}
|
|
func (e *Engine) Start(ctx context.Context) {
|
|
e.Persistence.Start(ctx)
|
|
e.Ollama.Start(ctx)
|
|
if e.GLPIKB != nil {
|
|
e.GLPIKB.Start(ctx)
|
|
}
|
|
go func() {
|
|
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()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
if !e.LearningEnabled() {
|
|
continue
|
|
}
|
|
if err := e.Scan(ctx); err != nil && !errors.Is(err, ErrLearningDisabled) {
|
|
slog.Error("brain scan failed", "error", err)
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
|
|
go e.enrichmentWorker(ctx)
|
|
if e.Cfg.AutoEnrich {
|
|
go e.enrichmentScheduler(ctx)
|
|
}
|
|
go e.idle(ctx)
|
|
}
|
|
|
|
func (e *Engine) enrichmentScheduler(ctx context.Context) {
|
|
firstDelay := 12 * time.Second
|
|
e.setNextEnrich(time.Now().Add(firstDelay))
|
|
timer := time.NewTimer(firstDelay)
|
|
defer timer.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-timer.C:
|
|
if !e.RequestEnrich("automatic") {
|
|
slog.Debug("automatic enrichment already queued")
|
|
}
|
|
next := time.Now().Add(e.Cfg.EnrichInterval)
|
|
e.setNextEnrich(next)
|
|
timer.Reset(e.Cfg.EnrichInterval)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (e *Engine) enrichmentWorker(ctx context.Context) {
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case trigger := <-e.enrichRequests:
|
|
e.runEnrichmentCycle(ctx, trigger)
|
|
}
|
|
}
|
|
}
|
|
|
|
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"
|
|
}
|
|
e.stateMu.Lock()
|
|
if e.enrichRunning || e.enrichResult == "queued" {
|
|
e.stateMu.Unlock()
|
|
return false
|
|
}
|
|
e.enrichResult = "queued"
|
|
e.enrichTrigger = trigger
|
|
e.enrichError = ""
|
|
e.stateMu.Unlock()
|
|
|
|
select {
|
|
case e.enrichRequests <- trigger:
|
|
e.Broker.Publish(model.Activity{Type: "think.queued", Source: "brain", Phase: "queue", Message: "AI-THINK-Zyklus wurde eingeplant", Strength: .38, Metadata: map[string]any{"trigger": trigger, "batch_size": e.Cfg.EnrichBatchSize}})
|
|
return true
|
|
default:
|
|
e.stateMu.Lock()
|
|
if e.enrichResult == "queued" && e.enrichTrigger == trigger {
|
|
e.enrichResult = "idle"
|
|
}
|
|
e.stateMu.Unlock()
|
|
return false
|
|
}
|
|
}
|
|
|
|
func (e *Engine) runEnrichmentCycle(ctx context.Context, trigger string) {
|
|
started := time.Now()
|
|
e.stateMu.Lock()
|
|
e.enrichRunning = true
|
|
e.enrichTrigger = trigger
|
|
e.enrichResult = "running"
|
|
e.enrichError = ""
|
|
e.lastAttempt = started.UTC()
|
|
e.enrichCycles++
|
|
e.stateMu.Unlock()
|
|
|
|
e.Broker.Publish(model.Activity{Type: "think.cycle.started", Source: "brain", Phase: "autonomous", Message: fmt.Sprintf("Autonomer AI-THINK-Zyklus startet · bis zu %d sequenzielle Prüfungen", e.Cfg.EnrichBatchSize), Strength: .72, Metadata: map[string]any{"trigger": trigger, "batch_size": e.Cfg.EnrichBatchSize, "anchors": e.Cfg.EnrichAnchors}})
|
|
|
|
created, rejected, checked := 0, 0, 0
|
|
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
|
|
result = "failed"
|
|
break
|
|
}
|
|
if !outcome.Candidate {
|
|
if checked == 0 {
|
|
result = "no_candidate"
|
|
}
|
|
break
|
|
}
|
|
checked++
|
|
if outcome.Created {
|
|
created++
|
|
}
|
|
if outcome.Rejected {
|
|
rejected++
|
|
}
|
|
if step+1 < e.Cfg.EnrichBatchSize && e.Cfg.EnrichStepDelay > 0 {
|
|
select {
|
|
case <-ctx.Done():
|
|
cycleErr = ctx.Err()
|
|
result = "cancelled"
|
|
step = e.Cfg.EnrichBatchSize
|
|
case <-time.After(e.Cfg.EnrichStepDelay):
|
|
}
|
|
}
|
|
}
|
|
|
|
e.stateMu.Lock()
|
|
e.enrichRunning = false
|
|
e.enrichResult = result
|
|
if cycleErr != nil {
|
|
e.enrichError = cycleErr.Error()
|
|
} else {
|
|
e.enrichError = ""
|
|
}
|
|
e.enrichCreated += uint64(created)
|
|
e.enrichRejected += uint64(rejected)
|
|
e.stateMu.Unlock()
|
|
|
|
metadata := map[string]any{"trigger": trigger, "checked": checked, "created": created, "rejected": rejected, "duration_ms": time.Since(started).Milliseconds(), "result": result}
|
|
if cycleErr != nil {
|
|
e.Broker.Publish(model.Activity{Type: "think.cycle.failed", Source: "brain", Phase: "autonomous", Message: "AI-THINK-Zyklus wurde mit Fehler beendet", Strength: .45, Metadata: metadata})
|
|
slog.Warn("enrichment cycle failed", "trigger", trigger, "error", cycleErr)
|
|
return
|
|
}
|
|
message := fmt.Sprintf("AI-THINK-Zyklus abgeschlossen · %d geprüft · %d erstellt · %d verworfen", checked, created, rejected)
|
|
if result == "no_candidate" {
|
|
message = "AI-THINK hat im aktuell geprüften Graphbereich keinen Kandidaten oberhalb des Schwellwerts gefunden"
|
|
}
|
|
e.Broker.Publish(model.Activity{Type: "think.cycle.completed", Source: "brain", Phase: "autonomous", Message: message, Strength: .58, Metadata: metadata})
|
|
}
|
|
|
|
func (e *Engine) setNextEnrich(t time.Time) {
|
|
e.stateMu.Lock()
|
|
e.nextEnrich = t.UTC()
|
|
e.stateMu.Unlock()
|
|
}
|
|
|
|
func (e *Engine) setOllamaOK(ok bool) {
|
|
e.stateMu.Lock()
|
|
e.ollamaOK = ok
|
|
e.stateMu.Unlock()
|
|
}
|
|
|
|
func (e *Engine) isOllamaOK() bool {
|
|
e.stateMu.RLock()
|
|
ok := e.ollamaOK
|
|
e.stateMu.RUnlock()
|
|
return ok
|
|
}
|
|
|
|
func (e *Engine) idle(ctx context.Context) {
|
|
ticker := time.NewTicker(7 * time.Second)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
s := e.Graph.Snapshot()
|
|
if len(s.Nodes) == 0 {
|
|
continue
|
|
}
|
|
idx := int(time.Now().Unix()/7) % len(s.Nodes)
|
|
n := s.Nodes[idx]
|
|
e.Broker.Publish(model.Activity{Type: "brain.idle", Source: "brain", Phase: "idle", Message: "Leise Hintergrundaktivität", NodeIDs: []string{n.ID}, Strength: .18})
|
|
}
|
|
}
|
|
}
|
|
func (e *Engine) Scan(ctx context.Context) error {
|
|
if !e.LearningEnabled() {
|
|
return ErrLearningDisabled
|
|
}
|
|
e.mu.Lock()
|
|
defer e.mu.Unlock()
|
|
beforeVersion := e.Graph.Version()
|
|
count, err := e.Scanner.Scan()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
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}})
|
|
}
|
|
pingCtx, pingCancel := context.WithTimeout(ctx, 3*time.Second)
|
|
pingErr := e.Ollama.Ping(pingCtx)
|
|
pingCancel()
|
|
if pingErr != nil {
|
|
slog.Warn("Ollama unavailable; using deterministic local fallback", "error", pingErr)
|
|
e.ensureFallbackEmbeddings()
|
|
e.setOllamaOK(false)
|
|
} else {
|
|
// Local fallback vectors use 256 dimensions. Once Ollama becomes available,
|
|
// discard those placeholders and replace them with real model embeddings.
|
|
e.Graph.ClearVectorsByDimension(256)
|
|
if err := e.ensureEmbeddings(ctx); err != nil {
|
|
slog.Warn("Ollama embeddings failed; using deterministic local fallback", "error", err)
|
|
e.ensureFallbackEmbeddings()
|
|
e.setOllamaOK(false)
|
|
} else {
|
|
e.setOllamaOK(true)
|
|
}
|
|
}
|
|
e.stateMu.Lock()
|
|
e.lastScan = time.Now().UTC()
|
|
e.stateMu.Unlock()
|
|
if e.Graph.Version() != beforeVersion {
|
|
s := e.Graph.Snapshot()
|
|
e.Broker.Publish(model.Activity{Type: "graph.updated", Source: "brain", Phase: "indexed", Message: fmt.Sprintf("%d Wissenselemente · %d Nodes · %d Edges", count, len(s.Nodes), len(s.Edges)), Strength: .55, Metadata: map[string]any{"nodes": len(s.Nodes), "edges": len(s.Edges), "knowledge_elements": count}})
|
|
}
|
|
return nil
|
|
}
|
|
func (e *Engine) ensureEmbeddings(ctx context.Context) error {
|
|
pending := e.Graph.NodesForEmbeddingFiltered(e.learningCategories())
|
|
if len(pending) == 0 {
|
|
return nil
|
|
}
|
|
for start := 0; start < len(pending); start += 16 {
|
|
end := start + 16
|
|
if end > len(pending) {
|
|
end = len(pending)
|
|
}
|
|
texts := make([]string, 0, end-start)
|
|
for _, n := range pending[start:end] {
|
|
texts = append(texts, embeddingText(n))
|
|
}
|
|
cctx, cancel := context.WithTimeout(ctx, 4*time.Minute)
|
|
vecs, err := e.Ollama.Embed(cctx, texts)
|
|
cancel()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for i, v := range vecs {
|
|
e.Graph.SetVector(pending[start+i].ID, v)
|
|
}
|
|
ids := []string{}
|
|
for _, n := range pending[start:end] {
|
|
ids = append(ids, n.ID)
|
|
}
|
|
e.Broker.Publish(model.Activity{Type: "embedding.batch", Source: "ollama", Phase: "embedding", Message: fmt.Sprintf("EmbeddingGemma verarbeitet %d Elemente", len(ids)), NodeIDs: ids, Strength: .38, Metadata: map[string]any{"batch_count": len(ids), "model": e.Cfg.EmbeddingModel, "batch_start": start, "batch_total": len(pending)}})
|
|
}
|
|
return nil
|
|
}
|
|
func (e *Engine) ensureFallbackEmbeddings() {
|
|
for _, n := range e.Graph.NodesForEmbeddingFiltered(e.learningCategories()) {
|
|
e.Graph.SetVector(n.ID, hashEmbedding(embeddingText(n), 256))
|
|
}
|
|
}
|
|
func embeddingText(n model.Node) string {
|
|
return strings.TrimSpace(n.Label + "\n" + strings.Join(n.Categories, " · ") + "\n" + strings.Join(n.Keywords, " · ") + "\n" + n.Summary)
|
|
}
|
|
func hashEmbedding(s string, dims int) []float64 {
|
|
v := make([]float64, dims)
|
|
tokens := strings.FieldsFunc(strings.ToLower(s), func(r rune) bool { return !unicode.IsLetter(r) && !unicode.IsDigit(r) })
|
|
for _, t := range tokens {
|
|
if t == "" {
|
|
continue
|
|
}
|
|
h := sha256.Sum256([]byte(t))
|
|
idx := (int(h[0])<<8 | int(h[1])) % dims
|
|
sign := 1.0
|
|
if h[2]&1 == 1 {
|
|
sign = -1
|
|
}
|
|
v[idx] += sign * (1 + float64(h[3])/255)
|
|
}
|
|
var norm float64
|
|
for _, x := range v {
|
|
norm += x * x
|
|
}
|
|
if norm > 0 {
|
|
norm = math.Sqrt(norm)
|
|
for i := range v {
|
|
v[i] /= norm
|
|
}
|
|
}
|
|
return v
|
|
}
|
|
|
|
func (e *Engine) Query(ctx context.Context, q string) (model.QueryResponse, error) {
|
|
start := time.Now()
|
|
q = strings.TrimSpace(q)
|
|
if len([]rune(q)) < 2 {
|
|
return model.QueryResponse{}, fmt.Errorf("query is too short")
|
|
}
|
|
e.Broker.Publish(model.Activity{Type: "query.started", Source: "ui", Phase: "perception", Query: q, Message: "Anfrage trifft im neuronalen Feld ein", Strength: 1})
|
|
vecs, err := e.Ollama.Embed(ctx, []string{q})
|
|
if err != nil || len(vecs) == 0 {
|
|
vecs = [][]float64{hashEmbedding(q, 256)}
|
|
}
|
|
hits := e.Graph.Similar(vecs[0], e.Cfg.TopK)
|
|
nodeIDs := make([]string, 0, len(hits))
|
|
for i, h := range hits {
|
|
nodeIDs = append(nodeIDs, h.NodeID)
|
|
e.Broker.Publish(model.Activity{Type: "node.activated", Source: "brain", Phase: "retrieval", Query: q, NodeIDs: []string{h.NodeID}, Message: fmt.Sprintf("Treffer %d · %.0f%% · %s", i+1, h.Score*100, h.Label), Strength: math.Max(.25, h.Score)})
|
|
time.Sleep(55 * time.Millisecond)
|
|
}
|
|
edgeIDs := e.Graph.ConnectingEdges(nodeIDs)
|
|
if len(edgeIDs) > 0 {
|
|
e.Broker.Publish(model.Activity{Type: "edges.traversed", Source: "brain", Phase: "association", Query: q, NodeIDs: nodeIDs, EdgeIDs: edgeIDs, Message: fmt.Sprintf("%d Wissensverbindungen werden durchlaufen", len(edgeIDs)), Strength: .92})
|
|
}
|
|
answer := e.fallbackAnswer(q, hits)
|
|
used := append([]string(nil), nodeIDs...)
|
|
var uncertainties []string
|
|
if e.isOllamaOK() && len(hits) > 0 {
|
|
system := "Du beantwortest Fragen ausschließlich aus dem bereitgestellten Wissensgraphen. Markiere Unklarheiten offen. Gib valides JSON nach Schema zurück. used_node_ids dürfen nur IDs aus dem Kontext sein."
|
|
user := e.answerContext(q, hits)
|
|
var dec model.AnswerDecision
|
|
if err := e.Ollama.ChatJSON(ctx, system, user, answerSchema(), &dec); err == nil && strings.TrimSpace(dec.Answer) != "" {
|
|
answer = dec.Answer
|
|
used = validIDs(dec.UsedNodeIDs, nodeIDs)
|
|
uncertainties = dec.Uncertainties
|
|
} else if err != nil {
|
|
slog.Warn("structured answer failed; fallback used", "error", err)
|
|
}
|
|
}
|
|
e.Broker.Publish(model.Activity{Type: "query.completed", Source: "brain", Phase: "synthesis", Query: q, NodeIDs: used, EdgeIDs: e.Graph.ConnectingEdges(used), Message: "Antwortsynthese abgeschlossen", Strength: 1, Metadata: map[string]any{"duration_ms": time.Since(start).Milliseconds(), "hit_count": len(hits), "used_nodes": len(used), "uncertainty_count": len(uncertainties)}})
|
|
return model.QueryResponse{Query: q, Answer: answer, Hits: hits, UsedNodeIDs: used, Uncertainties: uncertainties, DurationMS: time.Since(start).Milliseconds()}, nil
|
|
}
|
|
func (e *Engine) answerContext(q string, hits []model.Hit) string {
|
|
var b strings.Builder
|
|
b.WriteString("FRAGE:\n" + q + "\n\nKONTEXT:\n")
|
|
used := 0
|
|
for _, h := range hits {
|
|
n, ok := e.Graph.GetNode(h.NodeID)
|
|
if !ok {
|
|
continue
|
|
}
|
|
part := fmt.Sprintf("\nNODE_ID: %s\nTITEL: %s\nSTATUS: %s\nKATEGORIEN: %s\nINHALT: %s\n", n.ID, n.Label, n.Status, strings.Join(n.Categories, ", "), n.Summary)
|
|
if used+len(part) > e.Cfg.MaxContextChars {
|
|
break
|
|
}
|
|
b.WriteString(part)
|
|
used += len(part)
|
|
}
|
|
return b.String()
|
|
}
|
|
func (e *Engine) fallbackAnswer(q string, hits []model.Hit) string {
|
|
if len(hits) == 0 {
|
|
return "Im aktuellen Wissensgraphen wurde kein belastbarer Zusammenhang gefunden."
|
|
}
|
|
var b strings.Builder
|
|
b.WriteString("Die stärksten passenden Wissensbereiche sind: ")
|
|
for i, h := range hits {
|
|
if i >= 4 {
|
|
break
|
|
}
|
|
if i > 0 {
|
|
b.WriteString("; ")
|
|
}
|
|
b.WriteString(h.Label)
|
|
}
|
|
b.WriteString(". Die Visualisierung zeigt die zugehörigen Aktivierungspfade. Ohne erreichbares Qwen-Modell bleibt dies eine Retrieval-Zusammenfassung.")
|
|
return b.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()
|
|
|
|
if !e.isOllamaOK() {
|
|
pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
|
err := e.Ollama.Ping(pingCtx)
|
|
cancel()
|
|
if err != nil {
|
|
e.Broker.Publish(model.Activity{Type: "think.paused", Source: "brain", Phase: "waiting", Message: "AI-THINK wartet auf ein erreichbares Ollama/Qwen-Modell", Strength: .25, Metadata: map[string]any{"trigger": trigger, "error": err.Error()}})
|
|
return EnrichOutcome{Result: "ollama_unavailable"}, fmt.Errorf("Ollama/Qwen is unavailable; no AI edge or AI-THINK draft was created: %w", err)
|
|
}
|
|
e.setOllamaOK(true)
|
|
}
|
|
|
|
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()
|
|
e.stateMu.Unlock()
|
|
e.Broker.Publish(model.Activity{Type: "think.no_candidate", Source: "brain", Phase: "candidate-search", Message: "Im aktuell geprüften Graphbereich wurde keine ungeprüfte Beziehung oberhalb des Ähnlichkeitsschwellwerts gefunden", Strength: .28, Metadata: map[string]any{"trigger": trigger, "threshold": e.Cfg.SimilarityThreshold, "anchors": e.Cfg.EnrichAnchors, "comparisons": comparisons}})
|
|
return EnrichOutcome{Result: "no_candidate", Comparisons: comparisons}, nil
|
|
}
|
|
|
|
now := time.Now().UTC()
|
|
e.stateMu.Lock()
|
|
e.lastAttempt = now
|
|
e.lastEnrich = now
|
|
e.stateMu.Unlock()
|
|
e.Broker.Publish(model.Activity{Type: "think.started", Source: "brain", Phase: "association", NodeIDs: []string{a.ID, b.ID}, Message: fmt.Sprintf("Verwandtschaft wird geprüft · %.0f%% semantische Nähe", sim*100), Strength: .88, Metadata: map[string]any{"trigger": trigger, "semantic_similarity": sim, "source_label": a.Label, "target_label": b.Label, "model": e.Cfg.ChatModel, "candidate_comparisons": comparisons}})
|
|
|
|
system := "Analysiere zwei interne Wissenseinträge. Erfinde keine Fakten. Entscheide, ob eine belastbare Beziehung besteht. Wenn externe Fakten fehlen, setze needs_research=true. Gib ausschließlich JSON nach Schema zurück."
|
|
var decision model.RelationDecision
|
|
if err := e.Ollama.ChatJSON(ctx, system, relationContext(a, b, sim), relationSchema(), &decision); err != nil {
|
|
e.Broker.Publish(model.Activity{Type: "think.failed", Source: "brain", Phase: "inference", NodeIDs: []string{a.ID, b.ID}, Message: "Qwen-Beziehungsanalyse ist fehlgeschlagen; es wurde nichts gespeichert", Strength: .35, Metadata: map[string]any{"trigger": trigger, "error": err.Error(), "model": e.Cfg.ChatModel}})
|
|
return EnrichOutcome{Result: "inference_failed", Candidate: true, Comparisons: comparisons}, fmt.Errorf("relation inference failed: %w", err)
|
|
}
|
|
|
|
var researchResults []model.ResearchResult
|
|
if decision.NeedsResearch && e.Cfg.ResearchEnabled && e.Research != nil && strings.TrimSpace(decision.ResearchQuery) != "" {
|
|
e.Broker.Publish(model.Activity{Type: "research.started", Source: "brain", Phase: "research", NodeIDs: []string{a.ID, b.ID}, Message: "Unklarheit erkannt · kontrollierte Webrecherche startet", Strength: .9, Metadata: map[string]any{"trigger": trigger, "research_query": decision.ResearchQuery, "source_label": a.Label, "target_label": b.Label}})
|
|
results, err := e.Research.Search(ctx, decision.ResearchQuery, 4)
|
|
if err != nil {
|
|
slog.Warn("research failed", "error", err)
|
|
} else if len(results) > 0 {
|
|
researchResults = results
|
|
e.addResearch(a, b, results)
|
|
var reviewed model.RelationDecision
|
|
reviewSystem := "Bewerte die Beziehung erneut anhand der zwei internen Wissenseinträge und der beigefügten Web-Suchergebnisse. Suchtreffer sind Hinweise, keine garantierten Fakten. Erfinde nichts, kennzeichne verbleibende Unsicherheit und gib ausschließlich JSON nach Schema zurück."
|
|
if err := e.Ollama.ChatJSON(ctx, reviewSystem, relationContextWithResearch(a, b, sim, results), relationSchema(), &reviewed); err != nil {
|
|
slog.Warn("research review failed; keeping pre-research decision", "error", err)
|
|
} else {
|
|
decision = reviewed
|
|
}
|
|
}
|
|
}
|
|
|
|
status := "staging"
|
|
if !decision.Related || decision.Confidence < e.Cfg.RelationThreshold {
|
|
status = "rejected"
|
|
}
|
|
edge := model.Edge{
|
|
Source: a.ID, Target: b.ID, Type: safeRelation(decision.RelationType), Origin: "ai-inference", Status: status,
|
|
Confidence: decision.Confidence, Weight: math.Max(.2, decision.Confidence), Explanation: decision.Explanation,
|
|
Evidence: []model.Evidence{{NodeID: a.ID, URI: a.URI, Excerpt: clamp(a.Summary, 220)}, {NodeID: b.ID, URI: b.URI, Excerpt: clamp(b.Summary, 220)}},
|
|
Metadata: map[string]any{"semantic_similarity": sim, "model": e.Cfg.ChatModel, "research_result_count": len(researchResults), "trigger": trigger},
|
|
}
|
|
e.Graph.UpsertEdge(edge)
|
|
edge.ID = graph.EdgeID(edge.Source, edge.Target, edge.Type, edge.Origin)
|
|
outcome := EnrichOutcome{Result: status, Candidate: true, Comparisons: comparisons}
|
|
if status == "staging" {
|
|
path, err := e.writeAIThink(a, b, decision, researchResults)
|
|
if err != nil {
|
|
return outcome, err
|
|
}
|
|
outcome.Created = true
|
|
e.Broker.Publish(model.Activity{Type: "think.created", Source: "brain", Phase: "staging", NodeIDs: []string{a.ID, b.ID}, EdgeIDs: []string{edge.ID}, Message: "Neuer AI-THINK-Beitrag wurde erzeugt und für den gebündelten Staging-Schreibvorgang vorgemerkt", Strength: 1, Metadata: map[string]any{"trigger": trigger, "path": path, "relation_type": safeRelation(decision.RelationType), "confidence": decision.Confidence, "semantic_similarity": sim, "research_result_count": len(researchResults), "title": decision.Title, "write_pending": true}})
|
|
} else {
|
|
outcome.Rejected = true
|
|
e.Broker.Publish(model.Activity{Type: "think.rejected", Source: "brain", Phase: "validation", NodeIDs: []string{a.ID, b.ID}, Message: "Ähnlichkeit geprüft, aber nicht als belastbare Edge übernommen", Strength: .42, Metadata: map[string]any{"trigger": trigger, "relation_type": safeRelation(decision.RelationType), "confidence": decision.Confidence, "semantic_similarity": sim, "explanation": decision.Explanation}})
|
|
}
|
|
return outcome, nil
|
|
}
|
|
|
|
func (e *Engine) addResearch(a, b model.Node, results []model.ResearchResult) {
|
|
for _, r := range results {
|
|
id := graph.ID("external", r.URL)
|
|
n := model.Node{ID: id, Kind: "external", Label: r.Title, Summary: clamp(r.Content, 700), Status: "research", Origin: "research", ExternalID: r.URL, URI: r.URL, Weight: .8, Metadata: map[string]any{"query_pair": []string{a.ID, b.ID}}, UpdatedAt: time.Now().UTC()}
|
|
e.Graph.UpsertNode(n)
|
|
e.Graph.UpsertEdge(model.Edge{Source: id, Target: a.ID, Type: "research_evidence", Origin: "research", Status: "staging", Confidence: .55, Weight: .4})
|
|
e.Graph.UpsertEdge(model.Edge{Source: id, Target: b.ID, Type: "research_evidence", Origin: "research", Status: "staging", Confidence: .55, Weight: .4})
|
|
}
|
|
}
|
|
func (e *Engine) writeAIThink(a, b model.Node, d model.RelationDecision, results []model.ResearchResult) (string, error) {
|
|
if len(e.Cfg.StagingDirs) == 0 {
|
|
return "", fmt.Errorf("no BRAIN_STAGING_DIRS configured")
|
|
}
|
|
dir := e.Cfg.StagingDirs[0]
|
|
pair := a.ID + "\x00" + b.ID
|
|
sum := sha256.Sum256([]byte(pair))
|
|
short := strings.ToUpper(hex.EncodeToString(sum[:5]))
|
|
now := time.Now().UTC()
|
|
id := fmt.Sprintf("KB-AI-THINK-%s-%s", now.Format("20060102"), short)
|
|
cats := unique(append([]string{"AI-THINK", "AI-Staging"}, common(a.Categories, b.Categories)...))
|
|
keywords := unique(append(append([]string{}, d.Keywords...), first(a.Keywords, 4)...))
|
|
keywords = unique(append(keywords, first(b.Keywords, 4)...))
|
|
var evidence []map[string]any
|
|
for _, r := range results {
|
|
evidence = append(evidence, map[string]any{"title": r.Title, "url": r.URL, "excerpt": clamp(r.Content, 300)})
|
|
}
|
|
doc := map[string]any{"id": id, "title": nonempty(d.Title, "Zusammenhang: "+a.Label+" ↔ "+b.Label), "text": nonempty(d.Synthesis, d.Explanation), "answer": "Interne AI-THINK-Arbeitsnotiz. Vor produktiver Nutzung im Editor prüfen, korrigieren und freigeben.", "auto_reply": false, "min_score": 0.78, "categories": cats, "keywords": keywords, "source": "Neural Brain / " + e.Cfg.ChatModel + " (AI-THINK)", "source_uri": "brain://edge/" + short, "language": "de-DE", "communication_style": "formal", "ai_think": map[string]any{"status": "staging", "generated_at": now, "source_nodes": []string{a.ExternalID, b.ExternalID}, "source_node_ids": []string{a.ID, b.ID}, "relation_type": safeRelation(d.RelationType), "confidence": d.Confidence, "explanation": d.Explanation, "needs_research": d.NeedsResearch, "research_query": d.ResearchQuery, "research_evidence": evidence}}
|
|
bts, err := json.MarshalIndent(doc, "", " ")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
path := filepath.Join(dir, strings.ToLower(id)+".json")
|
|
if e.Persistence.Pending(path) {
|
|
return path, nil
|
|
}
|
|
if _, err := os.Stat(path); err == nil {
|
|
return path, nil
|
|
}
|
|
return e.Persistence.QueueFile(path, append(bts, '\n'), 0o640)
|
|
}
|
|
func (e *Engine) Status() map[string]any {
|
|
s := e.Graph.Snapshot()
|
|
e.stateMu.RLock()
|
|
status := map[string]any{
|
|
"ok": true, "nodes": len(s.Nodes), "edges": len(s.Edges), "version": s.Version,
|
|
"last_scan": e.lastScan, "last_enrich": e.lastEnrich, "last_enrich_attempt": e.lastAttempt,
|
|
"next_enrich": e.nextEnrich, "ollama_ok": e.ollamaOK, "auto_enrich": e.Cfg.AutoEnrich,
|
|
"enrich_running": e.enrichRunning, "enrich_trigger": e.enrichTrigger, "enrich_result": e.enrichResult,
|
|
"enrich_error": e.enrichError, "enrich_cycles": e.enrichCycles, "enrich_created": e.enrichCreated,
|
|
"enrich_rejected": e.enrichRejected, "enrich_interval": e.Cfg.EnrichInterval.String(),
|
|
"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()
|
|
} else {
|
|
status["glpi_kb"] = ingest.GLPIKBStatus{Enabled: false}
|
|
}
|
|
e.stateMu.RUnlock()
|
|
return status
|
|
}
|
|
|
|
func (e *Engine) Flush(ctx context.Context) error {
|
|
return e.Persistence.Flush(ctx, "manual")
|
|
}
|
|
|
|
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")
|
|
}
|
|
return e.GLPIKB.Sync(ctx, "manual")
|
|
}
|
|
|
|
func relationContextWithResearch(a, b model.Node, sim float64, results []model.ResearchResult) string {
|
|
var out strings.Builder
|
|
out.WriteString(relationContext(a, b, sim))
|
|
out.WriteString("\n\nWEB-SUCHERGEBNISSE (ungeprüfte Hinweise):\n")
|
|
for i, r := range results {
|
|
fmt.Fprintf(&out, "\n%d. %s\nURL: %s\nAuszug: %s\n", i+1, r.Title, r.URL, clamp(r.Content, 700))
|
|
}
|
|
return out.String()
|
|
}
|
|
|
|
func relationContext(a, b model.Node, sim float64) string {
|
|
return fmt.Sprintf("SEMANTISCHE_NÄHE: %.4f\n\nA\nID: %s\nTitel: %s\nKategorien: %s\nInhalt: %s\n\nB\nID: %s\nTitel: %s\nKategorien: %s\nInhalt: %s", sim, a.ID, a.Label, strings.Join(a.Categories, ", "), a.Summary, b.ID, b.Label, strings.Join(b.Categories, ", "), b.Summary)
|
|
}
|
|
func relationSchema() map[string]any {
|
|
return map[string]any{"type": "object", "properties": map[string]any{"related": map[string]any{"type": "boolean"}, "relation_type": map[string]any{"type": "string", "enum": []string{"related_to", "depends_on", "supports", "contradicts", "extends", "same_topic", "caused_by"}}, "confidence": map[string]any{"type": "number", "minimum": 0, "maximum": 1}, "explanation": map[string]any{"type": "string"}, "needs_research": map[string]any{"type": "boolean"}, "research_query": map[string]any{"type": "string"}, "title": map[string]any{"type": "string"}, "synthesis": map[string]any{"type": "string"}, "keywords": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}}, "required": []string{"related", "relation_type", "confidence", "explanation", "needs_research", "research_query", "title", "synthesis", "keywords"}}
|
|
}
|
|
func answerSchema() map[string]any {
|
|
return map[string]any{"type": "object", "properties": map[string]any{"answer": map[string]any{"type": "string"}, "used_node_ids": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, "uncertainties": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}}, "required": []string{"answer", "used_node_ids", "uncertainties"}}
|
|
}
|
|
func validIDs(in, allowed []string) []string {
|
|
set := map[string]bool{}
|
|
for _, x := range allowed {
|
|
set[x] = true
|
|
}
|
|
var out []string
|
|
for _, x := range in {
|
|
if set[x] {
|
|
out = append(out, x)
|
|
}
|
|
}
|
|
if len(out) == 0 {
|
|
return allowed
|
|
}
|
|
return unique(out)
|
|
}
|
|
func safeRelation(s string) string {
|
|
switch s {
|
|
case "related_to", "depends_on", "supports", "contradicts", "extends", "same_topic", "caused_by":
|
|
return s
|
|
default:
|
|
return "related_to"
|
|
}
|
|
}
|
|
func common(a, b []string) []string {
|
|
set := map[string]string{}
|
|
for _, x := range a {
|
|
set[strings.ToLower(x)] = x
|
|
}
|
|
var out []string
|
|
for _, x := range b {
|
|
if v, ok := set[strings.ToLower(x)]; ok {
|
|
out = append(out, v)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
func first(in []string, n int) []string {
|
|
if len(in) > n {
|
|
return in[:n]
|
|
}
|
|
return in
|
|
}
|
|
func unique(in []string) []string {
|
|
set := map[string]bool{}
|
|
var out []string
|
|
for _, x := range in {
|
|
x = strings.TrimSpace(x)
|
|
k := strings.ToLower(x)
|
|
if x == "" || set[k] {
|
|
continue
|
|
}
|
|
set[k] = true
|
|
out = append(out, x)
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
func clamp(s string, n int) string {
|
|
r := []rune(strings.TrimSpace(s))
|
|
if len(r) <= n {
|
|
return string(r)
|
|
}
|
|
return string(r[:n]) + "…"
|
|
}
|
|
func nonempty(a, b string) string {
|
|
if strings.TrimSpace(a) != "" {
|
|
return strings.TrimSpace(a)
|
|
}
|
|
return b
|
|
}
|