Bugfix-Visual

This commit is contained in:
2026-08-04 03:58:17 +02:00
parent 54babd6fe7
commit 76c6a25637
21 changed files with 857 additions and 81 deletions
+3
View File
@@ -21,6 +21,9 @@ OLLAMA_EMBEDDING_MODEL=embeddinggemma
BRAIN_AUTO_ENRICH=true
BRAIN_SCAN_INTERVAL=20s
BRAIN_ENRICH_INTERVAL=90s
BRAIN_ENRICH_BATCH_SIZE=3
BRAIN_ENRICH_STEP_DELAY=3s
BRAIN_ENRICH_ANCHORS=48
BRAIN_SIMILARITY_THRESHOLD=0.68
BRAIN_RELATION_THRESHOLD=0.72
BRAIN_TOP_K=8
+19 -5
View File
@@ -11,7 +11,7 @@ Eigenständiger Go-Dienst für deine beiden GLPI-Projekte. Er liest die produkti
- Optionale, nicht blockierende Telemetrie-Patches für Agent- und Knowledgebase-Suchanfragen.
- Embeddings über Ollama `embeddinggemma`; lokaler Feature-Hash-Fallback, falls Ollama gerade nicht erreichbar ist.
- Suchanfragen über Agent, Knowledgebase oder API: Retrieval, aktivierte Nodes/Edges und strukturierte Verarbeitung durch `qwen3:8b`. Die Webansicht bleibt bewusst eine reine Visualisierung ohne Eingabefeld.
- Sequenzielle, automatische Verknüpfungsanalyse. Es wird immer nur ein Kandidatenpaar gleichzeitig geprüft.
- Sequenzielle, automatische Verknüpfungsanalyse mit sichtbarem Worker-Status. Pro Zyklus werden standardmäßig bis zu drei Kandidaten nacheinander geprüft; niemals parallel.
- KI-Edges mit Herkunft, Confidence, Erklärung und Evidenz. Abgelehnte Paare werden intern markiert, damit sie nicht endlos erneut geprüft werden.
- Optional kontrollierte Recherche über eine eigene SearXNG-Instanz.
- Automatische AI-THINK-Beiträge im bestehenden Staging-JSON-Format, stets mit `auto_reply: false`.
@@ -27,7 +27,7 @@ Die Fullscreen-Ansicht wechselt selbstständig zwischen drei Darstellungsstufen:
Die Themenstruktur wird nicht nur aus dem ersten Kategorie-Feld abgeleitet. Kategorien bilden feste Anker; Konzepte, Quellen und externe Recherche-Nodes übernehmen über gewichtete Nachbarschafts-Propagation das stärkste verbundene Themengebiet. Verwandte Bereiche ziehen sich an, nicht verwandte Bereiche stoßen sich ab. Die zwölf stärksten Cortex-Areale erhalten bewusst deutlich getrennte Farben.
Der Aktivitätsfeed zeigt nur relevante Ereignisse und ergänzt sofern vorhanden Cortex-Bereich, Trefferzahl, verwendete Quellen, Laufzeit, semantische Nähe, Relationstyp, Konfidenz, Recherchequellen, Ticket-ID und Staging-Pfad.
Der Aktivitätsfeed zeigt nur relevante Ereignisse und ergänzt sofern vorhanden Cortex-Bereich, Trefferzahl, verwendete Quellen, Laufzeit, semantische Nähe, Relationstyp, Konfidenz, Recherchequellen, Ticket-ID und Staging-Pfad. Der Statusblock zeigt außerdem, ob der autonome Worker läuft, wartet, keine Kandidaten findet oder Ollama nicht erreicht. Über den wieder eingeblendeten **AI-THINK**-Button kann jederzeit ein manueller Zyklus angestoßen werden.
## Schutz der Basisprojekte
@@ -83,9 +83,9 @@ Auf Linux ist `host.docker.internal` über `extra_hosts` eingebunden. Alternativ
## Automatische Anreicherung
Der Enrichment-Loop arbeitet bewusst seriell:
Der Enrichment-Loop arbeitet bewusst seriell. Die Kandidatensuche verwendet einen rotierenden, begrenzten Anchor-Satz statt eines vollständigen O(n²)-Vergleichs über den gesamten Graphen. Dadurch bleibt sie auch bei zehntausenden Nodes reaktionsfähig und besucht langfristig trotzdem den gesamten Wissensbestand.
1. ähnlichstes noch ungeprüftes Wissenspaar auswählen;
1. einen rotierenden Bereich des Graphen nach dem stärksten noch ungeprüften Wissenspaar durchsuchen;
2. Qwen-Beziehungsanalyse mit festem JSON-Schema;
3. Edge als `staging` oder intern als `rejected` speichern;
4. bei Unklarheit optional SearXNG-Recherche durchführen;
@@ -95,6 +95,19 @@ Der Enrichment-Loop arbeitet bewusst seriell:
Ein erzeugter Entwurf enthält zusätzlich ein `ai_think`-Objekt mit Quell-Nodes, Relation, Confidence, Forschungsstatus und Evidenz. Die vorhandene Editor-Raw-JSON-Ansicht kann diese Daten bereits anzeigen.
### AI-THINK-Taktung
```env
BRAIN_AUTO_ENRICH=true
BRAIN_ENRICH_INTERVAL=90s
BRAIN_ENRICH_BATCH_SIZE=3
BRAIN_ENRICH_STEP_DELAY=3s
BRAIN_ENRICH_ANCHORS=48
```
`BRAIN_ENRICH_BATCH_SIZE` bestimmt, wie viele Beziehungen pro autonomem Zyklus nacheinander geprüft werden. `BRAIN_ENRICH_ANCHORS` begrenzt die CPU-seitige Kandidatensuche je Schritt. Die GPU wird nur während echter Qwen- oder Embedding-Aufrufe belastet; zwischen den Zyklen ist eine geringe oder null GPU-Auslastung normal.
## Minimale optionale Integrationen
Die Patches unter `integrations/` senden echte Suchanfragen und Treffer an `POST /api/events`:
@@ -125,7 +138,8 @@ Ist `BRAIN_ACTIVITY_URL` leer, ist die Integration vollständig deaktiviert. Das
| `POST` | `/api/query` | sichtbare Wissensanfrage |
| `POST` | `/api/events` | optionale Agent-/KB-Telemetrie |
| `POST` | `/api/reindex` | Scan und Embedding-Abgleich |
| `POST` | `/api/enrich` | genau einen AI-THINK-Schritt ausführen |
| `POST` | `/api/enrich` | einen AI-THINK-Schritt synchron ausführen |
| `POST` | `/api/enrich?async=1` | einen vollständigen sequenziellen AI-THINK-Zyklus einplanen |
Mit `BRAIN_API_KEY` werden alle POST-Endpunkte über `Authorization: Bearer …` oder `X-Brain-Key` geschützt. Die Webansicht ruft keine Query-POSTs mehr auf. Der API-Key schützt weiterhin Integrationen, Reindex, Enrichment und externe Query-Aufrufe; produktiv sollte der Dienst lokal oder hinter einem authentifizierenden Reverse Proxy betrieben werden.
File diff suppressed because one or more lines are too long
+3
View File
@@ -115,6 +115,9 @@ services:
BRAIN_AUTO_ENRICH: ${BRAIN_AUTO_ENRICH:-true}
BRAIN_SCAN_INTERVAL: ${BRAIN_SCAN_INTERVAL:-20s}
BRAIN_ENRICH_INTERVAL: ${BRAIN_ENRICH_INTERVAL:-90s}
BRAIN_ENRICH_BATCH_SIZE: ${BRAIN_ENRICH_BATCH_SIZE:-3}
BRAIN_ENRICH_STEP_DELAY: ${BRAIN_ENRICH_STEP_DELAY:-3s}
BRAIN_ENRICH_ANCHORS: ${BRAIN_ENRICH_ANCHORS:-48}
BRAIN_RESEARCH_ENABLED: ${BRAIN_RESEARCH_ENABLED:-false}
SEARXNG_URL: ${SEARXNG_URL:-}
volumes:
+3
View File
@@ -17,6 +17,9 @@ services:
BRAIN_AUTO_ENRICH: ${BRAIN_AUTO_ENRICH:-true}
BRAIN_SCAN_INTERVAL: ${BRAIN_SCAN_INTERVAL:-20s}
BRAIN_ENRICH_INTERVAL: ${BRAIN_ENRICH_INTERVAL:-90s}
BRAIN_ENRICH_BATCH_SIZE: ${BRAIN_ENRICH_BATCH_SIZE:-3}
BRAIN_ENRICH_STEP_DELAY: ${BRAIN_ENRICH_STEP_DELAY:-3s}
BRAIN_ENRICH_ANCHORS: ${BRAIN_ENRICH_ANCHORS:-48}
BRAIN_SIMILARITY_THRESHOLD: ${BRAIN_SIMILARITY_THRESHOLD:-0.68}
BRAIN_RELATION_THRESHOLD: ${BRAIN_RELATION_THRESHOLD:-0.72}
BRAIN_RESEARCH_ENABLED: ${BRAIN_RESEARCH_ENABLED:-false}
+15
View File
@@ -21,6 +21,9 @@ type Config struct {
SearXNGURL string
ScanInterval time.Duration
EnrichInterval time.Duration
EnrichStepDelay time.Duration
EnrichBatchSize int
EnrichAnchors int
SimilarityThreshold float64
RelationThreshold float64
TopK int
@@ -48,6 +51,9 @@ func Load() (Config, error) {
SearXNGURL: strings.TrimRight(strings.TrimSpace(os.Getenv("SEARXNG_URL")), "/"),
ScanInterval: duration("BRAIN_SCAN_INTERVAL", 20*time.Second),
EnrichInterval: duration("BRAIN_ENRICH_INTERVAL", 90*time.Second),
EnrichStepDelay: duration("BRAIN_ENRICH_STEP_DELAY", 3*time.Second),
EnrichBatchSize: integer("BRAIN_ENRICH_BATCH_SIZE", 3),
EnrichAnchors: integer("BRAIN_ENRICH_ANCHORS", 48),
SimilarityThreshold: number("BRAIN_SIMILARITY_THRESHOLD", 0.68),
RelationThreshold: number("BRAIN_RELATION_THRESHOLD", 0.72),
TopK: integer("BRAIN_TOP_K", 8),
@@ -62,6 +68,15 @@ func Load() (Config, error) {
if cfg.EnrichInterval < 10*time.Second {
return Config{}, fmt.Errorf("BRAIN_ENRICH_INTERVAL must be at least 10s")
}
if cfg.EnrichStepDelay < 0 {
return Config{}, fmt.Errorf("BRAIN_ENRICH_STEP_DELAY must not be negative")
}
if cfg.EnrichBatchSize < 1 || cfg.EnrichBatchSize > 25 {
return Config{}, fmt.Errorf("BRAIN_ENRICH_BATCH_SIZE must be between 1 and 25")
}
if cfg.EnrichAnchors < 1 || cfg.EnrichAnchors > 2048 {
return Config{}, fmt.Errorf("BRAIN_ENRICH_ANCHORS must be between 1 and 2048")
}
if cfg.SimilarityThreshold < 0 || cfg.SimilarityThreshold > 1 {
return Config{}, fmt.Errorf("invalid similarity threshold")
}
+256 -46
View File
@@ -5,6 +5,7 @@ import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log/slog"
"math"
@@ -25,21 +26,49 @@ import (
"github.com/local/glpi-neural-brain/internal/research"
)
var ErrNoCandidate = errors.New("no enrichment candidate")
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
mu sync.Mutex
lastScan time.Time
lastEnrich time.Time
ollamaOK bool
Cfg config.Config
Graph *graph.Store
Broker *activity.Broker
Ollama *ollama.Client
Research *research.Client
Scanner *ingest.KnowledgeScanner
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
}
func New(cfg config.Config, g *graph.Store, b *activity.Broker) *Engine {
e := &Engine{Cfg: cfg, Graph: g, Broker: b, Ollama: ollama.New(cfg.OllamaURL, cfg.ChatModel, cfg.EmbeddingModel), Scanner: &ingest.KnowledgeScanner{Graph: g, ProductionDirs: cfg.KnowledgeDirs, StagingDirs: cfg.StagingDirs}}
if cfg.EnrichBatchSize < 1 {
cfg.EnrichBatchSize = 1
}
if cfg.EnrichAnchors < 1 {
cfg.EnrichAnchors = 48
}
e := &Engine{Cfg: cfg, Graph: g, Broker: b, Ollama: ollama.New(cfg.OllamaURL, cfg.ChatModel, cfg.EmbeddingModel), Scanner: &ingest.KnowledgeScanner{Graph: g, ProductionDirs: cfg.KnowledgeDirs, StagingDirs: cfg.StagingDirs}, enrichRequests: make(chan string, 1)}
if cfg.SearXNGURL != "" {
e.Research = research.New(cfg.SearXNGURL)
}
@@ -63,25 +92,164 @@ func (e *Engine) Start(ctx context.Context) {
}
}
}()
go e.enrichmentWorker(ctx)
if e.Cfg.AutoEnrich {
go func() {
timer := time.NewTimer(8 * time.Second)
defer timer.Stop()
for {
select {
case <-ctx.Done():
return
case <-timer.C:
if err := e.EnrichOne(ctx); err != nil {
slog.Warn("automatic enrichment skipped", "error", err)
}
timer.Reset(e.Cfg.EnrichInterval)
}
}
}()
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 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++ {
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()
@@ -114,7 +282,7 @@ func (e *Engine) Scan(ctx context.Context) error {
if pingErr != nil {
slog.Warn("Ollama unavailable; using deterministic local fallback", "error", pingErr)
e.ensureFallbackEmbeddings()
e.ollamaOK = false
e.setOllamaOK(false)
} else {
// Local fallback vectors use 256 dimensions. Once Ollama becomes available,
// discard those placeholders and replace them with real model embeddings.
@@ -122,15 +290,17 @@ func (e *Engine) Scan(ctx context.Context) error {
if err := e.ensureEmbeddings(ctx); err != nil {
slog.Warn("Ollama embeddings failed; using deterministic local fallback", "error", err)
e.ensureFallbackEmbeddings()
e.ollamaOK = false
e.setOllamaOK(false)
} else {
e.ollamaOK = true
e.setOllamaOK(true)
}
}
if err := e.Graph.Persist(); err != nil {
return err
}
e.stateMu.Lock()
e.lastScan = time.Now().UTC()
e.stateMu.Unlock()
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
@@ -227,7 +397,7 @@ func (e *Engine) Query(ctx context.Context, q string) (model.QueryResponse, erro
answer := e.fallbackAnswer(q, hits)
used := append([]string(nil), nodeIDs...)
var uncertainties []string
if e.ollamaOK && len(hits) > 0 {
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
@@ -280,29 +450,51 @@ func (e *Engine) fallbackAnswer(q string, hits []model.Hit) string {
}
func (e *Engine) EnrichOne(ctx context.Context) error {
_, err := e.enrichOne(ctx, "direct")
return err
}
func (e *Engine) enrichOne(ctx context.Context, trigger string) (EnrichOutcome, error) {
e.mu.Lock()
defer e.mu.Unlock()
if !e.ollamaOK {
e.Broker.Publish(model.Activity{Type: "think.paused", Source: "brain", Phase: "waiting", Message: "AI-THINK wartet auf ein erreichbares Ollama/Qwen-Modell", Strength: .25})
return fmt.Errorf("Ollama/Qwen is unavailable; no AI edge or AI-THINK draft was created")
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 := e.Graph.BestPair(e.Cfg.SimilarityThreshold)
a, b, sim, ok, comparisons := e.Graph.NextPair(e.Cfg.SimilarityThreshold, e.Cfg.EnrichAnchors)
if !ok {
return nil
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
}
e.lastEnrich = time.Now().UTC()
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{"semantic_similarity": sim, "source_label": a.Label, "target_label": b.Label, "model": e.Cfg.ChatModel}})
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})
return fmt.Errorf("relation inference failed: %w", err)
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{"research_query": decision.ResearchQuery, "source_label": a.Label, "target_label": b.Label}})
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)
@@ -327,20 +519,26 @@ func (e *Engine) EnrichOne(ctx context.Context) error {
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)},
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 err
return outcome, err
}
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 im Staging erzeugt", Strength: 1, Metadata: map[string]any{"path": path, "relation_type": safeRelation(decision.RelationType), "confidence": decision.Confidence, "semantic_similarity": sim, "research_result_count": len(researchResults), "title": decision.Title}})
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 im Staging erzeugt", 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}})
} else {
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{"relation_type": safeRelation(decision.RelationType), "confidence": decision.Confidence, "semantic_similarity": sim, "explanation": decision.Explanation}})
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 e.Graph.Persist()
if err := e.Graph.Persist(); err != nil {
return outcome, err
}
return outcome, nil
}
func (e *Engine) addResearch(a, b model.Node, results []model.ResearchResult) {
@@ -392,7 +590,19 @@ func (e *Engine) writeAIThink(a, b model.Node, d model.RelationDecision, results
}
func (e *Engine) Status() map[string]any {
s := e.Graph.Snapshot()
return map[string]any{"ok": true, "nodes": len(s.Nodes), "edges": len(s.Edges), "version": s.Version, "last_scan": e.lastScan, "last_enrich": e.lastEnrich, "ollama_ok": e.ollamaOK, "auto_enrich": e.Cfg.AutoEnrich, "research_enabled": e.Cfg.ResearchEnabled, "chat_model": e.Cfg.ChatModel, "embedding_model": e.Cfg.EmbeddingModel}
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,
}
e.stateMu.RUnlock()
return status
}
func relationContextWithResearch(a, b model.Node, sim float64, results []model.ResearchResult) string {
+18
View File
@@ -95,3 +95,21 @@ func TestEnrichWritesAIThinkOnlyAfterStructuredQwenDecision(t *testing.T) {
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"])
}
}
+77 -6
View File
@@ -17,12 +17,13 @@ import (
)
type Store struct {
mu sync.RWMutex
nodes map[string]model.Node
edges map[string]model.Edge
vectors map[string][]float64
version uint64
path string
mu sync.RWMutex
nodes map[string]model.Node
edges map[string]model.Edge
vectors map[string][]float64
version uint64
path string
pairCursor int
}
type diskState struct {
@@ -306,6 +307,76 @@ func (s *Store) Similar(query []float64, limit int) []model.Hit {
}
return hits
}
// NextPair searches a bounded rotating window of anchor nodes instead of
// comparing the complete graph on every AI-THINK cycle. This keeps candidate
// selection responsive even for tens of thousands of knowledge nodes while the
// rotating cursor eventually visits the complete corpus.
func (s *Store) NextPair(min float64, anchorLimit int) (model.Node, model.Node, float64, bool, int) {
s.mu.Lock()
defer s.mu.Unlock()
nodes := make([]model.Node, 0, len(s.nodes))
for _, n := range s.nodes {
if n.Kind != "knowledge" && n.Kind != "ai-think" {
continue
}
if v, ok := s.vectors[n.ID]; ok && len(v) > 0 {
nodes = append(nodes, n)
}
}
if len(nodes) < 2 {
return model.Node{}, model.Node{}, 0, false, 0
}
sort.Slice(nodes, func(i, j int) bool { return nodes[i].ID < nodes[j].ID })
if anchorLimit <= 0 || anchorLimit > len(nodes) {
anchorLimit = len(nodes)
}
blocked := make(map[string]struct{}, len(s.edges))
for _, e := range s.edges {
blocked[pairKey(e.Source, e.Target)] = struct{}{}
}
start := s.pairCursor % len(nodes)
best := -1.0
var a, b model.Node
comparisons := 0
for step := 0; step < anchorLimit; step++ {
i := (start + step) % len(nodes)
left := nodes[i]
lv := s.vectors[left.ID]
for j := 0; j < len(nodes); j++ {
if i == j {
continue
}
right := nodes[j]
if _, exists := blocked[pairKey(left.ID, right.ID)]; exists {
continue
}
rv := s.vectors[right.ID]
if len(lv) != len(rv) {
continue
}
comparisons++
score := cosine(lv, rv)
if score >= min && score > best {
best = score
a, b = left, right
}
}
}
s.pairCursor = (start + anchorLimit) % len(nodes)
return a, b, best, best >= 0, comparisons
}
func pairKey(a, b string) string {
if a > b {
a, b = b, a
}
return a + "\x00" + b
}
func (s *Store) BestPair(min float64) (model.Node, model.Node, float64, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
+24
View File
@@ -45,3 +45,27 @@ func TestRejectedEdgePreventsPairReprocessingButIsHidden(t *testing.T) {
t.Fatalf("rejected edge should not be rendered, got %d", got)
}
}
func TestNextPairUsesBoundedRotatingAnchors(t *testing.T) {
s, _ := Open(t.TempDir())
for _, id := range []string{"a", "b", "c", "d"} {
s.UpsertNode(model.Node{ID: id, Kind: "knowledge", Label: id, Origin: "knowledge-production"})
}
s.SetVector("a", []float64{1, 0})
s.SetVector("b", []float64{.99, .01})
s.SetVector("c", []float64{0, 1})
s.SetVector("d", []float64{.01, .99})
a, b, _, ok, comparisons := s.NextPair(.8, 1)
if !ok || comparisons == 0 {
t.Fatalf("expected bounded candidate search, ok=%v comparisons=%d", ok, comparisons)
}
s.UpsertEdge(model.Edge{Source: a.ID, Target: b.ID, Type: "related_to", Origin: "ai-inference", Status: "rejected"})
_, _, _, _, comparisons = s.NextPair(.8, 1)
if comparisons == 0 {
t.Fatal("rotating anchor did not advance after rejected pair")
}
if _, _, _, ok, comparisons = s.NextPair(.8, 1); !ok || comparisons == 0 {
t.Fatalf("rotating anchor did not reach the next semantic region, ok=%v comparisons=%d", ok, comparisons)
}
}
+8
View File
@@ -114,6 +114,14 @@ func (s *Server) handleEnrich(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 401, map[string]string{"error": "unauthorized"})
return
}
if r.URL.Query().Get("async") == "1" {
if !s.Engine.RequestEnrich("manual") {
writeJSON(w, http.StatusConflict, map[string]any{"error": "AI-THINK is already running or queued", "status": s.Engine.Status()})
return
}
writeJSON(w, http.StatusAccepted, map[string]any{"ok": true, "queued": true, "status": s.Engine.Status()})
return
}
ctx, cancel := contextTimeout(r, 10*time.Minute)
defer cancel()
if err := s.Engine.EnrichOne(ctx); err != nil {
+4
View File
@@ -25,3 +25,7 @@ html,body{margin:0;width:100%;height:100%;overflow:hidden;background:radial-grad
.legend{grid-template-columns:1fr 1fr}.legend-title{grid-column:1/-1;color:#8ba4b7;font-size:8px;font-weight:800;letter-spacing:.18em;border-bottom:1px solid var(--line);padding-bottom:5px;margin-bottom:1px}
.activity-item .region{margin-top:6px;color:#d8f8ff;font-size:9px;letter-spacing:.04em}.activity-item.think .region{color:#ffe2b8}.activity-item.research .region{color:#c8ffe8}
@media(max-width:1220px){.topbar{grid-template-columns:1fr auto}.mode-status{display:none}.metrics{grid-column:2}}
/* AI-THINK-Steuerung und echter Backend-Status */
.autonomy-status{display:flex;align-items:center;gap:8px;margin:-1px 0 10px;padding:8px 9px;border:1px solid rgba(82,231,255,.12);border-radius:10px;background:rgba(82,231,255,.035);color:#8fa9ba;font-size:10px;line-height:1.35}.autonomy-status i{flex:0 0 auto;width:7px;height:7px;border-radius:50%;background:var(--cyan);box-shadow:0 0 10px var(--cyan)}.autonomy-status.running{border-color:rgba(255,180,82,.28);background:rgba(255,180,82,.06);color:#ffd9a7}.autonomy-status.running i{background:var(--amber);box-shadow:0 0 12px var(--amber);animation:pulse .72s infinite}.autonomy-status.offline{border-color:rgba(255,95,136,.22);background:rgba(255,95,136,.05);color:#ffb0c5}.autonomy-status.offline i{background:var(--red);box-shadow:0 0 10px var(--red)}.autonomy-status.waiting{color:#a8bdca}.autonomy-status.waiting i{background:var(--blue);box-shadow:0 0 10px var(--blue)}
.dock .think-action{display:flex;align-items:center;gap:7px;border-color:rgba(255,180,82,.22);background:rgba(255,180,82,.07);color:var(--amber)}.dock .think-action small{font-size:8px;letter-spacing:.11em;opacity:.68}.dock .think-action:hover{background:rgba(255,180,82,.14);box-shadow:0 0 22px rgba(255,180,82,.08)}.dock .think-action.running{border-color:rgba(255,180,82,.48);background:rgba(255,180,82,.13);box-shadow:0 0 24px rgba(255,180,82,.11)}.dock .think-action:disabled{cursor:wait;opacity:.72}
+128 -23
View File
@@ -15,7 +15,7 @@
thinking: {label: 'AI-THINK', detail: 'verknüpft und bewertet', className: 'thinking', color: [255, 180, 82], duration: 10500},
researching: {label: 'RESEARCH', detail: 'klärt Unsicherheiten', className: 'researching', color: [93, 255, 189], duration: 11500},
processing: {label: 'PROCESSING', detail: 'Agent- und Wissenssuche', className: 'processing', color: [183, 117, 255], duration: 7800},
learning: {label: 'LEARNING', detail: 'Graph wird neu geordnet', className: 'learning', color: [75, 123, 255], duration: 6500}
learning: {label: 'LEARNING', detail: 'indexiert bei stabiler Ansicht', className: 'learning', color: [75, 123, 255], duration: 6500}
};
const state = {
@@ -27,7 +27,7 @@
lastLogFingerprint: new Map(), recentImportant: [], clusterActive: new Map(),
mode: 'living', modeUntil: 0, activityEnergy: 0, targetEnergy: 0, focusClusterKey: '', focusNodeID: '',
cameraTargetYaw: null, cameraTargetPitch: null, zoomTarget: 1.02, nextAmbientAt: performance.now() + 1800,
ambientCursor: 0, bursts: []
ambientCursor: 0, bursts: [], brainStatus: null
};
function resize() {
@@ -54,6 +54,7 @@
try {
const snap = await api('/api/graph');
const old = state.nodeById;
const oldClusters = state.clusterByKey;
state.nodes = snap.nodes.map(n => ({...n, glow: old.get(n.id)?.glow || 0, screen: null, clusterKey: '', clusterColor: old.get(n.id)?.clusterColor || ''}));
state.edges = snap.edges;
state.nodeById = new Map(state.nodes.map(n => [n.id, n]));
@@ -65,7 +66,7 @@
state.adjacency.get(e.source).push(e);
state.adjacency.get(e.target).push(e);
}
buildLayout();
buildLayout(old, oldClusters);
$('nodeCount').textContent = state.nodes.length.toLocaleString('de-DE');
$('edgeCount').textContent = state.edges.length.toLocaleString('de-DE');
} catch {
@@ -78,6 +79,60 @@
$('systemState').style.color = ok ? 'var(--green)' : 'var(--red)';
}
async function loadStatus() {
try {
const status = await api('/api/status');
state.brainStatus = status;
renderAutonomyStatus(status);
} catch {
renderAutonomyStatus({ollama_ok: false, auto_enrich: false, enrich_error: 'Status nicht erreichbar'});
}
}
function renderAutonomyStatus(status) {
const panel = $('autonomyStatus');
const button = $('enrichNow');
if (!panel || !button) return;
const queued = status.enrich_result === 'queued' && !status.enrich_running;
const running = Boolean(status.enrich_running || queued);
let text = '';
let cls = 'waiting';
if (status.enrich_running) {
text = `AI-THINK läuft · ${status.enrich_trigger === 'manual' ? 'manuell' : 'automatisch'} · Batch ${status.enrich_batch_size || 1}`;
cls = 'running';
} else if (queued) {
text = 'AI-THINK ist eingeplant und wartet auf den sequenziellen Worker.';
cls = 'running';
} else if (!status.ollama_ok) {
text = 'Ollama/Qwen derzeit nicht bestätigt · ein manueller Start versucht die Verbindung erneut.';
cls = 'offline';
} else if (!status.auto_enrich) {
text = 'Automatik ist deaktiviert · AI-THINK kann manuell gestartet werden.';
cls = 'waiting';
} else if (status.enrich_result === 'no_candidate') {
text = `Im zuletzt geprüften Graphbereich kein Kandidat über dem Schwellwert · nächster Versuch ${nextRunText(status.next_enrich)}.`;
cls = 'waiting';
} else {
text = `Autonom aktiv · ${status.enrich_batch_size || 1} Prüfungen pro Zyklus · nächster Lauf ${nextRunText(status.next_enrich)}.`;
cls = 'waiting';
}
panel.className = `autonomy-status ${cls}`;
const span = panel.querySelector('span');
if (span) span.textContent = text;
button.disabled = running;
button.classList.toggle('running', running);
const small = button.querySelector('small');
if (small) small.textContent = status.enrich_running ? 'LÄUFT' : queued ? 'WARTET' : 'STARTEN';
}
function nextRunText(value) {
const when = new Date(value || 0).getTime();
if (!Number.isFinite(when) || when < Date.now() - 1000) return 'bald';
const seconds = Math.max(0, Math.round((when - Date.now()) / 1000));
if (seconds < 60) return `in ${seconds}s`;
return `in ${Math.floor(seconds / 60)}m ${seconds % 60}s`;
}
function hashString(value) {
let h = 2166136261;
const s = String(value || '');
@@ -180,7 +235,7 @@
return out;
}
function buildLayout() {
function buildLayout(previousNodes = new Map(), previousClusters = new Map()) {
const degree = new Map(state.nodes.map(n => [n.id, (state.adjacency.get(n.id) || []).length]));
const labels = new Map();
const locked = new Set();
@@ -220,7 +275,8 @@
n.clusterKey = key;
let cluster = clusters.get(key);
if (!cluster) {
cluster = {key, label: key, nodes: [], size: 0, mass: 0, color: clusterColorFor(key), x: 0, y: 0, z: 0, side: 'left', radius: 0.12, links: new Map()};
const previous = previousClusters.get(key);
cluster = {key, label: key, nodes: [], size: 0, mass: 0, color: clusterColorFor(key), x: previous?.x || 0, y: previous?.y || 0, z: previous?.z || 0, side: previous?.side || 'left', radius: 0.12, links: new Map(), hasPrevious: Boolean(previous)};
clusters.set(key, cluster);
}
cluster.nodes.push(n);
@@ -247,9 +303,14 @@
const left = [], right = [];
let leftMass = 0, rightMass = 0;
for (const cluster of clusterList) {
const preferLeft = (hashString(cluster.key) % 2) === 0;
const chooseLeft = Math.abs(leftMass - rightMass) > cluster.mass * 0.35 ? leftMass <= rightMass : preferLeft;
cluster.side = chooseLeft ? 'left' : 'right';
let chooseLeft;
if (cluster.hasPrevious) {
chooseLeft = cluster.side === 'left';
} else {
const preferLeft = (hashString(cluster.key) % 2) === 0;
chooseLeft = Math.abs(leftMass - rightMass) > cluster.mass * 0.35 ? leftMass <= rightMass : preferLeft;
cluster.side = chooseLeft ? 'left' : 'right';
}
if (chooseLeft) { left.push(cluster); leftMass += cluster.mass; }
else { right.push(cluster); rightMass += cluster.mass; }
cluster.radius = Math.max(0.08, Math.min(0.24, 0.08 + Math.sqrt(cluster.size) / 75));
@@ -258,7 +319,7 @@
seedHemis(left, -1);
seedHemis(right, 1);
relaxClusters(clusterList);
placeNodes(clusterList, degree);
placeNodes(clusterList, degree, previousNodes);
state.clusters = clusterList;
state.clusterByKey = new Map(clusterList.map(cluster => [cluster.key, cluster]));
}
@@ -267,6 +328,7 @@
const total = Math.max(1, list.length);
for (let i = 0; i < list.length; i++) {
const cluster = list[i];
if (cluster.hasPrevious) continue;
const t = (i + 0.6) / total;
const ring = Math.sqrt(t);
const angle = i * GOLDEN;
@@ -312,15 +374,16 @@
fx.x += (centerX - cluster.x) * 0.012;
fx.y += (-0.02 - cluster.y) * 0.01;
fx.z += (0 - cluster.z) * 0.008;
cluster.x += Math.max(-0.03, Math.min(0.03, fx.x));
cluster.y += Math.max(-0.03, Math.min(0.03, fx.y));
cluster.z += Math.max(-0.03, Math.min(0.03, fx.z));
const stability = cluster.hasPrevious ? 0.18 : 1;
cluster.x += Math.max(-0.03, Math.min(0.03, fx.x)) * stability;
cluster.y += Math.max(-0.03, Math.min(0.03, fx.y)) * stability;
cluster.z += Math.max(-0.03, Math.min(0.03, fx.z)) * stability;
Object.assign(cluster, clampBrain(cluster, cluster.side));
}
}
}
function placeNodes(clusterList, degree) {
function placeNodes(clusterList, degree, previousNodes = new Map()) {
for (const cluster of clusterList) {
cluster.nodes.sort((a, b) => (degree.get(b.id) || 0) - (degree.get(a.id) || 0) || a.id.localeCompare(b.id));
const total = Math.max(1, cluster.nodes.length);
@@ -339,9 +402,11 @@
let oz = Math.cos(angleB) * shell * (0.66 + pseudo(node.id, 4) * 0.24);
ox += (cluster.side === 'left' ? -1 : 1) * (0.015 + (1 - spread) * 0.018);
const pos = clampBrain({x: cluster.x + ox, y: cluster.y + oy, z: cluster.z + oz}, cluster.side);
node.x = pos.x;
node.y = pos.y;
node.z = pos.z;
const previous = previousNodes.get(node.id);
const preserve = previous?.clusterKey === cluster.key ? 0.90 : 0;
node.x = previous ? previous.x * preserve + pos.x * (1 - preserve) : pos.x;
node.y = previous ? previous.y * preserve + pos.y * (1 - preserve) : pos.y;
node.z = previous ? previous.z * preserve + pos.z * (1 - preserve) : pos.z;
}
}
}
@@ -383,8 +448,14 @@
const now = performance.now();
state.mode = mode;
state.modeUntil = cfg.duration ? Math.max(state.modeUntil, now + cfg.duration) : 0;
state.targetEnergy = mode === 'living' ? 0.08 : Math.min(1.35, 0.62 + strength * 0.58);
state.zoomTarget = mode === 'living' ? 1.02 : mode === 'thinking' ? 1.13 : mode === 'researching' ? 1.10 : 1.08;
state.targetEnergy = mode === 'living' ? 0.08 : mode === 'learning' ? Math.min(0.72, 0.36 + strength * 0.22) : Math.min(1.35, 0.62 + strength * 0.58);
state.zoomTarget = mode === 'living' || mode === 'learning' ? 1.02 : mode === 'thinking' ? 1.13 : mode === 'researching' ? 1.10 : 1.08;
if (mode === 'learning') {
state.focusClusterKey = '';
state.focusNodeID = '';
state.cameraTargetYaw = null;
state.cameraTargetPitch = null;
}
const badge = $('visualMode');
if (badge) {
badge.className = `mode-status ${cfg.className}`;
@@ -393,7 +464,7 @@
if (b) b.textContent = cfg.label;
if (small) small.textContent = cfg.detail;
}
if (evt?.node_ids?.length) {
if (mode !== 'learning' && evt?.node_ids?.length) {
const focus = state.nodeById.get(evt.node_ids[0]);
if (focus) focusCamera(focus);
}
@@ -423,7 +494,7 @@
state.yaw += (state.cameraTargetYaw - state.yaw) * Math.min(1, dt * 0.68);
state.pitch += (state.cameraTargetPitch - state.pitch) * Math.min(1, dt * 0.68);
} else if (state.autoRotate && !state.dragging) {
const speed = state.mode === 'living' ? 0.026 : 0.009;
const speed = state.mode === 'living' ? 0.026 : state.mode === 'learning' ? 0 : 0.009;
state.yaw += dt * speed;
}
for (const [key, value] of state.clusterActive) {
@@ -474,8 +545,9 @@
let x = node.x, y = node.y, z = node.z;
if (cluster) {
const clusterEnergy = state.clusterActive.get(cluster.key) || 0;
const ambient = 1 + Math.sin(now * 0.00072 + hashString(cluster.key) * 0.00001) * 0.012;
const expansion = ambient + clusterEnergy * 0.055 + (cluster.key === state.focusClusterKey ? state.activityEnergy * 0.028 : 0);
const learning = state.mode === 'learning';
const ambient = 1 + Math.sin(now * 0.00072 + hashString(cluster.key) * 0.00001) * (learning ? 0.0025 : 0.012);
const expansion = ambient + clusterEnergy * (learning ? 0.006 : 0.055) + (cluster.key === state.focusClusterKey && !learning ? state.activityEnergy * 0.028 : 0);
x = cluster.x + (node.x - cluster.x) * expansion;
y = cluster.y + (node.y - cluster.y) * expansion;
z = cluster.z + (node.z - cluster.z) * expansion;
@@ -829,11 +901,12 @@
}
addLog(evt);
if (evt.type === 'graph.updated') loadGraph();
if (evt.type?.startsWith('think.')) loadStatus();
}
function shouldLog(evt) {
if (!evt || evt.type === 'brain.idle' || evt.type === 'node.activated' || evt.type === 'edges.traversed') return false;
const important = new Set(['scan.started', 'graph.updated', 'embedding.batch', 'query.started', 'query.completed', 'think.started', 'think.created', 'think.rejected', 'think.failed', 'think.paused', 'research.started', 'agent.run']);
const important = new Set(['scan.started', 'graph.updated', 'embedding.batch', 'query.started', 'query.completed', 'think.queued', 'think.cycle.started', 'think.cycle.completed', 'think.cycle.failed', 'think.no_candidate', 'think.started', 'think.created', 'think.rejected', 'think.failed', 'think.paused', 'research.started', 'agent.run']);
if (!important.has(evt.type) && !(evt.source === 'agent' || evt.source === 'knowledgebase' || evt.source === 'external' || evt.query)) return false;
const fingerprint = `${evt.type}|${evt.message || ''}|${evt.query || ''}|${evt.source || ''}`;
const last = state.lastLogFingerprint.get(fingerprint) || 0;
@@ -855,6 +928,11 @@
'embedding.batch': 'Embeddings',
'query.started': evt.source === 'knowledgebase' ? 'Knowledgebase-Suche' : evt.source === 'agent' ? 'Agent-Suche' : 'Suchanfrage',
'query.completed': 'Suche abgeschlossen',
'think.queued': 'AI-THINK eingeplant',
'think.cycle.started': 'Autonomer AI-THINK-Zyklus',
'think.cycle.completed': 'AI-THINK-Zyklus beendet',
'think.cycle.failed': 'AI-THINK-Zyklus fehlgeschlagen',
'think.no_candidate': 'Keine neue Beziehung',
'think.started': 'AI-THINK prüft Zusammenhang',
'think.created': 'AI-THINK erstellt Beitrag',
'think.rejected': 'AI-THINK verwirft Edge',
@@ -875,6 +953,14 @@
if (evt.metadata?.confidence !== undefined) meta.push(`${Math.round(Number(evt.metadata.confidence) * 100)}% Konfidenz`);
if (evt.metadata?.relation_type) meta.push(String(evt.metadata.relation_type));
if (evt.metadata?.research_result_count) meta.push(`${Number(evt.metadata.research_result_count)} Webquellen`);
if (evt.metadata?.trigger) meta.push(evt.metadata.trigger === 'manual' ? 'manuell' : 'automatisch');
if (evt.metadata?.batch_size) meta.push(`${Number(evt.metadata.batch_size)} Schritte`);
if (evt.metadata?.checked !== undefined) meta.push(`${Number(evt.metadata.checked)} geprüft`);
if (evt.metadata?.created !== undefined) meta.push(`${Number(evt.metadata.created)} erstellt`);
if (evt.metadata?.rejected !== undefined) meta.push(`${Number(evt.metadata.rejected)} verworfen`);
if (evt.metadata?.comparisons) meta.push(`${Number(evt.metadata.comparisons).toLocaleString('de-DE')} Vergleiche`);
if (evt.metadata?.candidate_comparisons) meta.push(`${Number(evt.metadata.candidate_comparisons).toLocaleString('de-DE')} Vergleiche`);
if (evt.metadata?.threshold !== undefined) meta.push(`Schwelle ${Math.round(Number(evt.metadata.threshold) * 100)}%`);
if ((evt.node_ids || []).length) meta.push(`${evt.node_ids.length} aktive Knoten`);
if ((evt.edge_ids || []).length) meta.push(`${evt.edge_ids.length} aktive Kanten`);
if (evt.metadata?.ticket_id) meta.push(`Ticket ${evt.metadata.ticket_id}`);
@@ -935,6 +1021,23 @@
});
$('clearLog').addEventListener('click', () => {$('activityLog').innerHTML = '';});
$('enrichNow').addEventListener('click', async e => {
const button = e.currentTarget;
button.disabled = true;
button.classList.add('running');
const small = button.querySelector('small');
if (small) small.textContent = 'STARTET';
try {
await api('/api/enrich?async=1', {method: 'POST', body: '{}'});
await loadStatus();
} catch (err) {
addLog({type: 'think.failed', source: 'ui', phase: 'manual', message: `Manueller AI-THINK-Start fehlgeschlagen: ${err.message}`, timestamp: new Date().toISOString(), metadata: {trigger: 'manual'}});
button.disabled = false;
button.classList.remove('running');
if (small) small.textContent = 'STARTEN';
}
});
canvas.addEventListener('pointerdown', e => {
state.dragging = true;
state.moved = false;
@@ -994,5 +1097,7 @@
}
loadGraph();
loadStatus();
setInterval(loadGraph, 30000);
setInterval(loadStatus, 3000);
})();
+2
View File
@@ -39,6 +39,7 @@
</div>
</div>
<div class="panel-subtitle">Zeigt nur relevante Hirnaktivität, Anreicherungen, Recherchen und Suchläufe.</div>
<div id="autonomyStatus" class="autonomy-status" aria-live="polite"><i></i><span>AI-THINK-Status wird geladen …</span></div>
<div id="activityLog" class="activity-log"></div>
</aside>
@@ -48,6 +49,7 @@
<button id="toggleRotate" class="active">Autopilot</button>
<button id="toggleCortex" class="active">Cortex</button>
<button id="resetView">Zentrieren</button>
<button id="enrichNow" class="think-action" title="Einen autonomen AI-THINK-Zyklus sofort starten"><span>AI-THINK</span><small>STARTEN</small></button>
</nav>
<div class="legend glass" aria-label="Legende">
+2
View File
File diff suppressed because one or more lines are too long
@@ -0,0 +1,48 @@
{
"ai_think": {
"confidence": 0.9561,
"explanation": "Beide Wissenseinträge behandeln das Thema 'SSH Key Compromise Response' und teilen ähnliche Kategorien sowie Struktur und Inhalt. Sie decken ähnliche Aspekte wie defensive Prüfung, Detection, Härtung und forensische Hinweise ab. Die Unterschiede liegen im Fokus: Ein Artikel betont die Vorbereitung als Runbook, der andere die Durchführung im Vorfall. Die Überschneidung der Inhalte und Struktur deutet auf eine belastbare Beziehung hin. Es fehlen jedoch externe Fakten, um die Beziehung vollständig zu bestätigen.",
"generated_at": "2026-08-04T01:55:42.550194Z",
"needs_research": true,
"relation_type": "depends_on",
"research_evidence": null,
"research_query": "Wie sind die konkreten Unterschiede und Überschneidungen zwischen der Vorbereitung als Runbook und der Durchführung im Vorfall bei SSH Key Compromise Response? Gibt es standardisierte Verfahren oder best practices, die beide Ansätze verbinden?",
"source_node_ids": [
"009fcae8818d0bb5815b5b2d",
"6325a17f6d3e8abd80123a96"
],
"source_nodes": [
"KB-SEC-HB-02071",
"KB-SEC-HB-02072"
],
"status": "staging"
},
"answer": "Interne AI-THINK-Arbeitsnotiz. Vor produktiver Nutzung im Editor prüfen, korrigieren und freigeben.",
"auto_reply": false,
"categories": [
"AI-Staging",
"AI-THINK",
"IT-Security",
"Incident Response \u0026 Resilience",
"Security-Handbuch"
],
"communication_style": "formal",
"id": "KB-AI-THINK-20260804-23F7935C75",
"keywords": [
"Defensive Prüfung",
"Detection",
"Härtung",
"Incident Response \u0026 Resilience",
"Runbook",
"SSH Key Compromise Response",
"Vorfall",
"als Runbook vorbereiten",
"im Vorfall sicher durchführen"
],
"language": "de-DE",
"min_score": 0.78,
"source": "Neural Brain / qwen3:8b (AI-THINK)",
"source_uri": "brain://edge/23F7935C75",
"text": "Beide Wissenseinträge sind eng verwandt und decken ähnliche Themen ab, wobei der Fokus auf unterschiedliche Aspekte der SSH Key Compromise Response liegt. Die Beziehung ist belastbar, aber weitere externe Fakten sind erforderlich, um die genaue Natur der Beziehung zu bestätigen.",
"title": "SSH Key Compromise Response Vorbereitung vs. Durchführung"
}
@@ -0,0 +1,48 @@
{
"ai_think": {
"confidence": 0.9671,
"explanation": "Beide Wissenseinträge behandeln das Thema 'Water Leak Detection' im Kontext der IT-Security und Physical \u0026 Facility Security. Sie teilen ähnliche Kategorien, Ziele und Struktur. Beide erwähnen defensive Prüfung, Detection, Härtung und forensisch nutzbare Hinweise. Die Inhaltsspalten sind identisch, nur der Schwerpunkt der Artikel unterscheidet sich leicht („Vorfälle erkennen und nachvollziehbar untersuchen“ vs. „präventiv absichern“). Es gibt keine widersprüchlichen Informationen, und die Beziehung ist belastbar. Es sind keine externen Fakten erforderlich.",
"generated_at": "2026-08-04T01:57:02.5055851Z",
"needs_research": false,
"relation_type": "same_topic",
"research_evidence": null,
"research_query": "",
"source_node_ids": [
"01cc92c2849b2aedb28b089d",
"906b0dba2e7f61f5cc7236b3"
],
"source_nodes": [
"KB-SEC-HB-04069",
"KB-SEC-HB-04068"
],
"status": "staging"
},
"answer": "Interne AI-THINK-Arbeitsnotiz. Vor produktiver Nutzung im Editor prüfen, korrigieren und freigeben.",
"auto_reply": false,
"categories": [
"AI-Staging",
"AI-THINK",
"IT-Security",
"Physical \u0026 Facility Security",
"Security-Handbuch"
],
"communication_style": "formal",
"id": "KB-AI-THINK-20260804-351DC82359",
"keywords": [
"Defensive Prüfung",
"Detection",
"Härtung",
"IT-Security",
"Physical \u0026 Facility Security",
"Vorfälle erkennen und nachvollziehbar untersuchen",
"Water Leak Detection",
"forensisch",
"präventiv absichern"
],
"language": "de-DE",
"min_score": 0.78,
"source": "Neural Brain / qwen3:8b (AI-THINK)",
"source_uri": "brain://edge/351DC82359",
"text": "Beide Wissenseinträge beschäftigen sich mit der Risikobewertung und Sicherheitsmaßnahmen im Zusammenhang mit Water Leak Detection. Sie teilen ähnliche Struktur, Ziele und Inhaltsspalten, wobei sich der Fokus leicht unterscheidet. Die Beziehung ist belastbar, ohne externe Fakten zu benötigen.",
"title": "Belastbare Beziehung zu Water Leak Detection in IT-Security und Physical \u0026 Facility Security"
}
@@ -0,0 +1,48 @@
{
"ai_think": {
"confidence": 0.9727,
"explanation": "Beide Wissenseinträge behandeln das Thema 'BGP Prefix Filtering' und teilen ähnliche Kategorien sowie Struktur und Inhalt. Beide erwähnen defensive Bewertung, Härtung, Detection und forensisch nutzbare Hinweise. Die Ziele und Einordnung sind ähnlich, und die Abschnitte 'Defensive Prüfung / Detection' und 'Härtung' sind identisch. Es fehlen externe Fakten, um eine belastbare Beziehung zu bestätigen.",
"generated_at": "2026-08-04T01:57:24.3904379Z",
"needs_research": true,
"relation_type": "same_topic",
"research_evidence": null,
"research_query": "Gibt es externe Quellen, die die Beziehung zwischen den beiden Wissenseinträgen bestätigen oder widersprechen?",
"source_node_ids": [
"032d0b67b37ec1a74caff93a",
"ab7ddbbf9925bad5983e52f4"
],
"source_nodes": [
"KB-SEC-HB-00717",
"KB-SEC-HB-00715"
],
"status": "staging"
},
"answer": "Interne AI-THINK-Arbeitsnotiz. Vor produktiver Nutzung im Editor prüfen, korrigieren und freigeben.",
"auto_reply": false,
"categories": [
"AI-Staging",
"AI-THINK",
"IT-Security",
"Network Security",
"Security-Handbuch"
],
"communication_style": "formal",
"id": "KB-AI-THINK-20260804-9E792DED89",
"keywords": [
"BGP Prefix Filtering",
"Defensive Prüfung",
"Detection",
"Härtung",
"IT-Security",
"Network Security",
"Security-Handbuch",
"bei Sicherheitsvorfällen untersuchen",
"sicher entwerfen und härten"
],
"language": "de-DE",
"min_score": 0.78,
"source": "Neural Brain / qwen3:8b (AI-THINK)",
"source_uri": "brain://edge/9E792DED89",
"text": "Die beiden Wissenseinträge sind stark verwandt und behandeln das gleiche Thema, jedoch mit unterschiedlichen Schwerpunkten (Sicherheitsvorfälle untersuchen vs. sicher entwerfen und härten). Sie teilen jedoch eine große Übereinstimmung in Struktur, Inhalt und Schlüsselbegriffen. Eine belastbare Beziehung kann nicht ohne externe Quellen bestätigt werden.",
"title": "BGP Prefix Filtering bei Sicherheitsvorfällen untersuchen vs. BGP Prefix Filtering sicher entwerfen und härten"
}
@@ -0,0 +1,51 @@
{
"ai_think": {
"confidence": 0.9794,
"explanation": "Beide Wissenseinträge behandeln ähnliche Themen und Schwerpunkte, insbesondere den Bereich 'im Vorfall erkennen, eindämmen und wiederherstellen' im Kontext von Ransomware. Sie teilen sich ähnliche Kategorien und Inhaltsaspekte wie defensive Bewertung, Härtung, Detection und forensisch nutzbare Hinweise. Die Struktur und die genannten Schwerpunkte (z. B. Baseline, Abweichungen, Kontextkorrelation) sind identisch. Es fehlen jedoch externe Fakten, um eine belastbare Beziehung zwischen den beiden Einträgen zu bestätigen.",
"generated_at": "2026-08-04T01:55:54.0676298Z",
"needs_research": true,
"relation_type": "same_topic",
"research_evidence": null,
"research_query": "Gibt es eine klare Unterscheidung zwischen 'Ransomware Prevention' und 'Ransomware Detection' in der Praxis? Wie werden diese Konzepte in Sicherheitsdokumentationen unterschieden?",
"source_node_ids": [
"014516dfe205478447f304d3",
"4e619b8c2b06e4ef9d0a0bc9"
],
"source_nodes": [
"KB-SEC-HB-03825",
"KB-SEC-HB-03827"
],
"status": "staging"
},
"answer": "Interne AI-THINK-Arbeitsnotiz. Vor produktiver Nutzung im Editor prüfen, korrigieren und freigeben.",
"auto_reply": false,
"categories": [
"AI-Staging",
"AI-THINK",
"IT-Security",
"Ransomware \u0026 Backup Resilience",
"Security-Handbuch"
],
"communication_style": "formal",
"id": "KB-AI-THINK-20260804-AAC5103271",
"keywords": [
"Asset-Kontext",
"Change-Kontext",
"Detection",
"Härtung",
"Prevention",
"Ransomware",
"Ransomware \u0026 Backup Resilience",
"Ransomware Detection",
"Ransomware Prevention",
"forensisch",
"im Vorfall erkennen",
"im Vorfall erkennen, eindämmen und wiederherstellen"
],
"language": "de-DE",
"min_score": 0.78,
"source": "Neural Brain / qwen3:8b (AI-THINK)",
"source_uri": "brain://edge/AAC5103271",
"text": "Beide Einträge beschäftigen sich mit der Erkennung, Eindämmung und Wiederherstellung bei Ransomware-Vorfällen. Sie teilen sich ähnliche Methoden und Schwerpunkte, jedoch fehlen externe Fakten, um eine belastbare Beziehung zwischen den beiden zu etablieren. Weitere Forschung ist erforderlich, um die genaue Beziehung und Unterschiede zu klären.",
"title": "Ransomware Prevention vs. Ransomware Detection Beziehung und Unterschiede"
}
@@ -0,0 +1,49 @@
{
"ai_think": {
"confidence": 0.9704,
"explanation": "Beide Wissenseinträge behandeln das Thema 'Authoritative DNS' und teilen ähnliche Kategorien sowie zentrale Aspekte wie defensive Prüfung, Härtung und Detection. Beide betonen die Bedeutung von Baselines, Kontextualisierung von Abweichungen und die Dokumentation von Scope und Abhängigkeiten. Die Struktur und Inhalt der beiden Einträge sind stark übereinstimmend, was eine belastbare Beziehung nahelegt. Es fehlen jedoch externe Fakten, um die Beziehung vollständig zu bestätigen.",
"generated_at": "2026-08-04T01:55:32.9165505Z",
"needs_research": true,
"relation_type": "same_topic",
"research_evidence": null,
"research_query": "Vergleich der Sicherheitspraktiken in Authoritative DNS-Implementierungen zwischen beiden Artikeln",
"source_node_ids": [
"004755f9df7ab623cd7f594a",
"d2815fca2cf6297f981bec8b"
],
"source_nodes": [
"KB-SEC-HB-00694",
"KB-SEC-HB-00696"
],
"status": "staging"
},
"answer": "Interne AI-THINK-Arbeitsnotiz. Vor produktiver Nutzung im Editor prüfen, korrigieren und freigeben.",
"auto_reply": false,
"categories": [
"AI-Staging",
"AI-THINK",
"IT-Security",
"Network Security",
"Security-Handbuch"
],
"communication_style": "formal",
"id": "KB-AI-THINK-20260804-D68E9151FA",
"keywords": [
"Asset-Kontext",
"Authoritative DNS",
"Baseline",
"Detection",
"Härtung",
"Identitätskontext",
"Network Security",
"bei Sicherheitsvorfällen untersuchen",
"defensive Prüfung",
"sicher entwerfen und härten"
],
"language": "de-DE",
"min_score": 0.78,
"source": "Neural Brain / qwen3:8b (AI-THINK)",
"source_uri": "brain://edge/D68E9151FA",
"text": "Beide Wissenseinträge beschäftigen sich mit der Sicherheitsaspekte von Authoritative DNS, insbesondere mit der Dokumentation von Scope, der Härtung und der Detection bei Sicherheitsvorfällen. Sie teilen ähnliche Praktiken und Methoden, was eine belastbare Beziehung nahelegt, jedoch fehlen externe Fakten zur vollständigen Bestätigung der Beziehung.",
"title": "Belastbare Beziehung zwischen zwei Wissenseinträgen zu Authoritative DNS"
}
@@ -0,0 +1,50 @@
{
"ai_think": {
"confidence": 0.9718,
"explanation": "Beide Wissenseinträge behandeln das Thema 'Triple Extortion Risiko' und teilen ähnliche Kategorien sowie Inhaltsaspekte wie defensive Bewertung, Härtung, Detection und forensisch nutzbare Hinweise. Beide betonen die Bedeutung der Dokumentation von Scope, betroffenen Assets, Datenkritikalität, Exposition und betrieblichen Abhängigkeiten. Sie erwähnen ähnliche Vorläuferphänomene wie Credential-Missbrauch, laterale Bewegung, ungewöhnliche Dateioperationen, Backup-/Snapshot-Manipulation und Exfiltration. Die Botschaften sind sehr ähnlich, wobei der erste Fokus auf 'im Vorfall erkennen, eindämmen und wiederherstellen' und der zweite auf 'präventiv und resilient gestalten' liegt. Es fehlen externe Fakten, um die Belastbarkeit der Beziehung zu bestätigen.",
"generated_at": "2026-08-04T01:57:14.8361002Z",
"needs_research": true,
"relation_type": "same_topic",
"research_evidence": null,
"research_query": "Wie unterscheiden sich die Ansätze zur Behandlung von Triple Extortion Risiko in präventiver vs. reaktiver Sicherheitsstrategie?",
"source_node_ids": [
"02cda3d176e51ac4dd1c498d",
"65b02ffa0be5498e843fd005"
],
"source_nodes": [
"KB-SEC-HB-03849",
"KB-SEC-HB-03848"
],
"status": "staging"
},
"answer": "Interne AI-THINK-Arbeitsnotiz. Vor produktiver Nutzung im Editor prüfen, korrigieren und freigeben.",
"auto_reply": false,
"categories": [
"AI-Staging",
"AI-THINK",
"IT-Security",
"Ransomware \u0026 Backup Resilience",
"Security-Handbuch"
],
"communication_style": "formal",
"id": "KB-AI-THINK-20260804-D7B51962A8",
"keywords": [
"Backup Resilience",
"Defensive Prüfung",
"Detection",
"Forensik",
"Härtung",
"IT-Security",
"Ransomware",
"Ransomware \u0026 Backup Resilience",
"Triple Extortion Risiko",
"im Vorfall erkennen, eindämmen und wiederherstellen",
"präventiv und resilient gestalten"
],
"language": "de-DE",
"min_score": 0.78,
"source": "Neural Brain / qwen3:8b (AI-THINK)",
"source_uri": "brain://edge/D7B51962A8",
"text": "Beide Wissenseinträge beschäftigen sich mit demselben Risikotopic, 'Triple Extortion Risiko', und teilen ähnliche Sicherheitsaspekte. Sie sind jedoch in ihrer Fokussierung unterschiedlich: der eine auf reaktive Maßnahmen (erkennen, eindämmen, wiederherstellen), der andere auf präventive und resiliente Strategien. Die Beziehung ist belastbar, aber weitere externe Fakten sind erforderlich, um die genaue Art der Beziehung zu bestätigen.",
"title": "Belastbare Beziehung zwischen Wissenseinträgen zu Triple Extortion Risiko"
}