init
This commit is contained in:
31
.env
Normal file
31
.env
Normal file
@@ -0,0 +1,31 @@
|
||||
# HTTP
|
||||
BRAIN_LISTEN_ADDR=:8091
|
||||
BRAIN_DATA_DIR=./data
|
||||
BRAIN_API_KEY=
|
||||
|
||||
# Read-only production knowledge; comma-separated paths are supported.
|
||||
BRAIN_KNOWLEDGE_DIRS=./knowledge
|
||||
|
||||
# Writable staging used only for AI-THINK drafts. The Agent does not mount this path.
|
||||
BRAIN_STAGING_DIRS=../glpi-ai-knowledgebase/staging
|
||||
|
||||
# Optional read-only audit streams. Multiple files may be comma-separated.
|
||||
BRAIN_AGENT_RUNS_FILES=../glpi-ai-agent/data/runs.jsonl
|
||||
|
||||
# Local models
|
||||
OLLAMA_URL=http://localhost:11434
|
||||
OLLAMA_CHAT_MODEL=qwen3:8b
|
||||
OLLAMA_EMBEDDING_MODEL=embeddinggemma
|
||||
|
||||
# Sequential enrichment
|
||||
BRAIN_AUTO_ENRICH=true
|
||||
BRAIN_SCAN_INTERVAL=20s
|
||||
BRAIN_ENRICH_INTERVAL=90s
|
||||
BRAIN_SIMILARITY_THRESHOLD=0.68
|
||||
BRAIN_RELATION_THRESHOLD=0.72
|
||||
BRAIN_TOP_K=8
|
||||
BRAIN_MAX_CONTEXT_CHARS=16000
|
||||
|
||||
# Optional controlled web research through your own SearXNG instance.
|
||||
BRAIN_RESEARCH_ENABLED=false
|
||||
SEARXNG_URL=
|
||||
31
.env.example
Normal file
31
.env.example
Normal file
@@ -0,0 +1,31 @@
|
||||
# HTTP
|
||||
BRAIN_LISTEN_ADDR=:8090
|
||||
BRAIN_DATA_DIR=./data
|
||||
BRAIN_API_KEY=
|
||||
|
||||
# Read-only production knowledge; comma-separated paths are supported.
|
||||
BRAIN_KNOWLEDGE_DIRS=../glpi-ai-agent/knowledge
|
||||
|
||||
# Writable staging used only for AI-THINK drafts. The Agent does not mount this path.
|
||||
BRAIN_STAGING_DIRS=../glpi-ai-knowledgebase/staging
|
||||
|
||||
# Optional read-only audit streams. Multiple files may be comma-separated.
|
||||
BRAIN_AGENT_RUNS_FILES=../glpi-ai-agent/data/runs.jsonl
|
||||
|
||||
# Local models
|
||||
OLLAMA_URL=http://localhost:11434
|
||||
OLLAMA_CHAT_MODEL=qwen3:8b
|
||||
OLLAMA_EMBEDDING_MODEL=embeddinggemma
|
||||
|
||||
# Sequential enrichment
|
||||
BRAIN_AUTO_ENRICH=true
|
||||
BRAIN_SCAN_INTERVAL=20s
|
||||
BRAIN_ENRICH_INTERVAL=90s
|
||||
BRAIN_SIMILARITY_THRESHOLD=0.68
|
||||
BRAIN_RELATION_THRESHOLD=0.72
|
||||
BRAIN_TOP_K=8
|
||||
BRAIN_MAX_CONTEXT_CHARS=16000
|
||||
|
||||
# Optional controlled web research through your own SearXNG instance.
|
||||
BRAIN_RESEARCH_ENABLED=false
|
||||
SEARXNG_URL=
|
||||
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
knowledge
|
||||
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"git.ignoreLimitWarning": true
|
||||
}
|
||||
25
ARCHITECTURE.md
Normal file
25
ARCHITECTURE.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# Architektur und Vertrauensgrenzen
|
||||
|
||||
```text
|
||||
Agent knowledge/ ──ro──┐
|
||||
Agent runs.jsonl ──ro──┼──► Ingest ─► Graph + Vectors ─► Retrieval/Inference ─► SSE/Web UI
|
||||
KB staging/ ──────rw───┘ │
|
||||
├──► AI Edge (staging)
|
||||
├──► optional SearXNG evidence
|
||||
└──► AI-THINK JSON in staging
|
||||
```
|
||||
|
||||
## Edge-Klassen
|
||||
|
||||
- `categorized_as`, `mentions`, `derived_from`: deterministisch aus JSON.
|
||||
- `research_evidence`: aus einer explizit gestarteten Recherche, weiterhin `staging`.
|
||||
- `related_to`, `depends_on`, `supports`, `contradicts`, `extends`, `same_topic`, `caused_by`: Qwen-Inferenz mit Confidence und Evidence.
|
||||
- `rejected`: intern gespeicherte Prüfung ohne sichtbare Beziehung; verhindert Endlosschleifen.
|
||||
|
||||
## Ereignismodell
|
||||
|
||||
`Activity` ist von der Graph-Persistenz getrennt. Eine Anfrage verändert deshalb nicht automatisch die Wissensbasis. Erst der sequenzielle Enrichment-Schritt darf eine neue Edge oder einen AI-THINK-Entwurf anlegen.
|
||||
|
||||
## Datenhoheit
|
||||
|
||||
Produktive JSON-Dateien werden niemals verändert. Der einzige schreibende Fremdpfad ist das explizit konfigurierte Staging-Verzeichnis. Alle Dateien werden atomar über Temporärdatei und Rename geschrieben.
|
||||
15
Dockerfile
Normal file
15
Dockerfile
Normal file
@@ -0,0 +1,15 @@
|
||||
FROM golang:1.23-alpine AS build
|
||||
WORKDIR /src
|
||||
COPY go.mod ./
|
||||
COPY cmd ./cmd
|
||||
COPY internal ./internal
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/neural-brain ./cmd/brain
|
||||
|
||||
FROM alpine:3.21
|
||||
RUN addgroup -S brain && adduser -S -G brain brain
|
||||
WORKDIR /app
|
||||
COPY --from=build /out/neural-brain /usr/local/bin/neural-brain
|
||||
RUN mkdir -p /app/data /sources/knowledge /sources/staging /sources/agent-data && chown -R brain:brain /app /sources
|
||||
USER brain
|
||||
EXPOSE 8090
|
||||
ENTRYPOINT ["neural-brain"]
|
||||
12
Makefile
Normal file
12
Makefile
Normal file
@@ -0,0 +1,12 @@
|
||||
.PHONY: run test build fmt
|
||||
run:
|
||||
go run ./cmd/brain
|
||||
|
||||
test:
|
||||
go test ./...
|
||||
|
||||
build:
|
||||
go build -o bin/neural-brain ./cmd/brain
|
||||
|
||||
fmt:
|
||||
gofmt -w cmd internal
|
||||
137
README.md
137
README.md
@@ -1,2 +1,137 @@
|
||||
# glpi-neural-brain
|
||||
# Neural Knowledge Brain
|
||||
|
||||
Eigenständiger Go-Dienst für deine beiden GLPI-Projekte. Er liest die produktive Wissensbasis und Agent-Audits **read-only**, erzeugt einen dynamischen Wissensgraphen und rendert dessen Aktivität als fullscreen „Gehirn“. Neue KI-Synthesen werden ausschließlich als **AI-THINK** in das vorhandene Knowledgebase-Staging geschrieben.
|
||||
|
||||
## Was bereits implementiert ist
|
||||
|
||||
- Fullscreen Canvas-Rendering in Gehirnform, ohne Frontend-Framework oder externe CDN-Abhängigkeit.
|
||||
- Echtzeitaktivierung über Server-Sent Events: Nodes glühen, Edges leuchten, Partikel laufen entlang verwendeter Verbindungen.
|
||||
- Direkter Ingest der vorhandenen Knowledge-JSONs einschließlich Kategorien, Keywords, Quellen und Staging-Status.
|
||||
- Read-only Tailing von `runs.jsonl` des Agents; neue Agent-Läufe erscheinen als Hirnaktivität.
|
||||
- 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.
|
||||
- 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`.
|
||||
|
||||
|
||||
## Automatische Visualzustände
|
||||
|
||||
Die Fullscreen-Ansicht wechselt selbstständig zwischen drei Darstellungsstufen:
|
||||
|
||||
- **LIVING:** ruhige Eigenaktivität mit langsamer Atmung semantischer Cortex-Areale, vereinzelten internen Impulsen und sanfter Kamerabewegung. Diese Mikroaktivität wird nicht als wichtiges Feed-Ereignis protokolliert.
|
||||
- **ACTIVATION:** Agent-Suchen, Knowledgebase-Suchen und Graph-Updates fokussieren automatisch den betroffenen Wissensbereich. Aktive Regionen dehnen sich leicht aus; relevante Edges transportieren Partikel.
|
||||
- **AI-THINK / RESEARCH:** Beziehungsanalyse und Recherche erhalten einen stärkeren visuellen Modus mit fokussierter Kamera, konzentrischen Wellen, Synapsen-Bursts und statusabhängigen Farben.
|
||||
|
||||
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.
|
||||
|
||||
## Schutz der Basisprojekte
|
||||
|
||||
Das Brain bekommt nur:
|
||||
|
||||
- `knowledge/` **read-only**
|
||||
- `data/runs.jsonl` **read-only**
|
||||
- `staging/` **read-write**
|
||||
|
||||
Der Agent erhält weiterhin keinen Zugriff auf das Staging. Im integrierten Compose-Stack erhält auch `kb-search` nur ein leeres, flüchtiges Staging; ausschließlich der Prüf-Editor und das Brain sehen die echten Entwürfe. Die bestehende Knowledgebase nimmt AI-THINK erst nach deiner Freigabe in den produktiven Bestand. Damit kann das Brain die Entwürfe bereits darstellen und beim Denken berücksichtigen, während Agent und produktive Suche sie noch nicht sehen.
|
||||
|
||||
## Schnellstart nativ
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Pfade in .env anpassen
|
||||
ollama pull qwen3:8b
|
||||
ollama pull embeddinggemma
|
||||
set -a; . ./.env; set +a
|
||||
go run ./cmd/brain
|
||||
```
|
||||
|
||||
Windows PowerShell:
|
||||
|
||||
```powershell
|
||||
Copy-Item .env.example .env
|
||||
# Variablen aus .env setzen oder direkt in der Sitzung definieren
|
||||
go run ./cmd/brain
|
||||
```
|
||||
|
||||
Oberfläche: `http://localhost:8090`
|
||||
|
||||
Ohne Ollama startet die Visualisierung trotzdem. Retrieval verwendet dann einen deterministischen lokalen Fallback; Qwen-Synthesen und belastbare AI-Inferenz benötigen Ollama.
|
||||
|
||||
## Docker
|
||||
|
||||
Passe in `docker-compose.yml` die drei Host-Pfade an und starte:
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Auf Linux ist `host.docker.internal` über `extra_hosts` eingebunden. Alternativ kann das Brain in dasselbe Docker-Netz wie Ollama aufgenommen und `OLLAMA_URL=http://ollama:11434` gesetzt werden.
|
||||
|
||||
## Ablauf einer sichtbaren Anfrage
|
||||
|
||||
1. Die Anfrage erzeugt eine Wahrnehmungswelle.
|
||||
2. EmbeddingGemma bewertet passende Knowledge- und AI-THINK-Nodes.
|
||||
3. Treffer leuchten nacheinander auf.
|
||||
4. Vorhandene Verbindungen werden durchlaufen und mit Partikeln dargestellt.
|
||||
5. Qwen3:8b erhält ausschließlich den ausgewählten Kontext.
|
||||
6. Die final verwendeten Nodes und Edges pulsieren bei der Antwortsynthese.
|
||||
|
||||
## Automatische Anreicherung
|
||||
|
||||
Der Enrichment-Loop arbeitet bewusst seriell:
|
||||
|
||||
1. ähnlichstes noch ungeprüftes Wissenspaar auswählen;
|
||||
2. Qwen-Beziehungsanalyse mit festem JSON-Schema;
|
||||
3. Edge als `staging` oder intern als `rejected` speichern;
|
||||
4. bei Unklarheit optional SearXNG-Recherche durchführen;
|
||||
5. externe Quellen als eigene Nodes mit Evidence-Edges anlegen;
|
||||
6. AI-THINK-JSON atomar in `BRAIN_STAGING_DIRS` schreiben;
|
||||
7. beim nächsten Scan den neuen Beitrag als sichtbaren und durchsuchbaren Staging-Node aufnehmen.
|
||||
|
||||
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.
|
||||
|
||||
## Minimale optionale Integrationen
|
||||
|
||||
Die Patches unter `integrations/` senden echte Suchanfragen und Treffer an `POST /api/events`:
|
||||
|
||||
```bash
|
||||
# im jeweiligen Projekt-Root
|
||||
git apply /pfad/glpi-neural-brain/integrations/agent/glpi-ai-agent-neural-brain.patch
|
||||
git apply /pfad/glpi-neural-brain/integrations/knowledgebase/glpi-ai-knowledgebase-neural-brain.patch
|
||||
```
|
||||
|
||||
Danach optional setzen:
|
||||
|
||||
```env
|
||||
BRAIN_ACTIVITY_URL=http://brain:8090/api/events
|
||||
BRAIN_ACTIVITY_API_KEY=
|
||||
```
|
||||
|
||||
Ist `BRAIN_ACTIVITY_URL` leer, ist die Integration vollständig deaktiviert. Das Senden ist asynchron, fail-open, auf drei Sekunden begrenzt und kann weder Ticketverarbeitung noch KB-Suche blockieren. Der Agent funktioniert zusätzlich auch ohne Patch: Das Brain beobachtet weiterhin sein `runs.jsonl`.
|
||||
|
||||
## HTTP-Endpunkte
|
||||
|
||||
| Methode | Pfad | Zweck |
|
||||
|---|---|---|
|
||||
| `GET` | `/api/status` | Zustand, Modelle und Zähler |
|
||||
| `GET` | `/api/graph` | kompletter aktueller Graph |
|
||||
| `GET` | `/api/analysis` | Komponenten, Hubs, AI-Edges, Widersprüche und unverknüpftes Wissen |
|
||||
| `GET` | `/api/stream` | SSE-Aktivitätsstrom |
|
||||
| `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 |
|
||||
|
||||
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.
|
||||
|
||||
## Grenzen des Prototyps
|
||||
|
||||
- Der Graphspeicher ist eine atomar geschriebene JSON-Datei und für einen einzelnen Brain-Prozess ausgelegt. Für sehr große Bestände wäre eine spätere Migration auf einen spezialisierten Graph-/Vektorspeicher sinnvoll.
|
||||
- Webrecherche ist absichtlich nur über eine explizit konfigurierte SearXNG-Instanz aktiv.
|
||||
- KI-Edges bleiben Hypothesen. Erst eine Freigabe des AI-THINK-Beitrags macht daraus produktives Knowledge; die Edge selbst trägt weiterhin ihre KI-Herkunft.
|
||||
- Das System löst Widersprüche nicht stillschweigend auf. `contradicts` ist ein eigener Edge-Typ und bleibt sichtbar.
|
||||
|
||||
34
SHA256SUMS.txt
Normal file
34
SHA256SUMS.txt
Normal file
@@ -0,0 +1,34 @@
|
||||
6e7da366f9f451117f3d80acce5c94e551b7838eaa063ee887aeea51ab148197 ./.env.example
|
||||
9f81f9f707d706c92059977b7c5a2ec68ed15c39dcd6d2d57aadee1cbb1e3bb7 ./ARCHITECTURE.md
|
||||
99171b262752a66278da5a4a8b2b48e83fbdb1eb9cdca3efa944d260d6e6bc01 ./Dockerfile
|
||||
adcd3c9ba3bdf366afcc4e15a25423e068dd761e5d5d2d6f8cb20a3686302045 ./Makefile
|
||||
23f3748f6adef25034c32832272a5c0d03700aed0619742e6a8c450b84f95a07 ./README.md
|
||||
cf53db59fb1b3057019d54166bfbb0c95fbf84cb5032cad3ebea30af3aab69b3 ./cmd/brain/main.go
|
||||
f854ec4e9b1a5190a294529b58759c0e8535a1715bf12bb24f5b588af9b98b72 ./data/graph-state.json
|
||||
2404066ac6c893852a3f2bba00b33883b7e0eeb542448caf733fff4862715ed0 ./deployment/README.md
|
||||
d2c5ea4134a3df1ffa9c6409603f2fcf7bc30d16e3ac66e46328d059de5b5eff ./deployment/docker-compose.full.yml
|
||||
b8c9c01bdf731530252bbc109fd898d3c76b6e44ec6d589ffd0c87a60111ea04 ./docker-compose.yml
|
||||
9126f8bca1144abfc77747063b2c8f31acf12839a75e060b98369e10a00061ca ./go.mod
|
||||
bf239391e61040b00d2f49d805b0d49a44bd3679f3c53849dfbc5e3b5a3fcc7a ./integrations/agent/README.md
|
||||
a0105475dc054977223fac36618b8cd8137c55be1d11fddcd24e9a4d3074c170 ./integrations/agent/glpi-ai-agent-neural-brain.patch
|
||||
73ab7c49600cdaa4795e76c50e66ce919dec171b3a07f2d16ff6f45ce5f73365 ./integrations/knowledgebase/README.md
|
||||
36666043ebf4139e13610e5fcd0b0c6f45e4e6a53fed33ec47d03a66878e7b08 ./integrations/knowledgebase/glpi-ai-knowledgebase-neural-brain.patch
|
||||
50d05fa2a183f5f3eaab0545cb48d3abb64be62eb5d84dc2c6d99c7125f7344b ./internal/activity/broker.go
|
||||
92f34f0f6535daffbbd405c5c0957b5367189420dc5b3ecbc505435534d8a464 ./internal/config/config.go
|
||||
862b2eb57019d87f2221698341faf180820062fb309bdc6047129f25e902ed74 ./internal/engine/engine.go
|
||||
9fa32035e4a606e69597329f78cbe6cadcaa47eec50d77299841ef049725c6a6 ./internal/engine/engine_test.go
|
||||
0bf06e95cfafc65862f873a648240b192e158b814ffdd24c30d353ceb1042f82 ./internal/graph/store.go
|
||||
2a7a62411c1a6eb70440ac13ce6bc9eef10914ea62ebd270bfc6e8444c036250 ./internal/graph/store_test.go
|
||||
4476351d388d11c6becd78b4c918fc8d47dfd70500d8b3e2ddf81f7ff61a2cea ./internal/ingest/agent.go
|
||||
fe0a813efe140fdc8961015cb2a493928c97e456a80c2d8cf2ddbe7c6e335018 ./internal/ingest/knowledge.go
|
||||
5ed96aec4c3d2599cb9af3f6e951533271dc2b9a4826667482da814f9b450da1 ./internal/ingest/knowledge_test.go
|
||||
fb1af93afeb4ebc89cad95badeaee9a0cc3a236b9761e15a41a7b4b2c7192d00 ./internal/model/model.go
|
||||
2ea6284f6aa2c23c6f6583ba66647ae8f48432527746b99898bc567c1be64a52 ./internal/ollama/client.go
|
||||
2eb686cfc9016b9b0cf15e5c342f693ec9b60c454bc4814c1c9583f6d5b5b806 ./internal/research/searxng.go
|
||||
6346f7b213aa3fb36bc9f43134bba75e0b368c9a005cc1aef8d265f553ff8cef ./internal/research/searxng_test.go
|
||||
0f54fd249ef02616378b3630951eee4526f69593e54911058271c9eeb076874b ./internal/web/server.go
|
||||
b5b46e461473e21c5bb5e1cd014c6cd1a1db4c0b734a52d93a47ea4bb73f9e7d ./internal/web/static/app.css
|
||||
28c3461026cfdac5e0c4b08c92ede18c3ae0427ba4d9ad1193daf60c9974f9f3 ./internal/web/static/app.js
|
||||
acbc97be2fa1d495270751a4a7b2ef0a58a9c7301e82a3cac901cda0f78f7600 ./internal/web/static/index.html
|
||||
c81571f39dfa36c540c3f9b55ac05db90ffc85dd8ccf0da5fecd454a7f65b19d ./neural-brain
|
||||
83aded814b6225395935e61fe957963c3c470f368fc9089f505b6de23e959115 ./preview.png
|
||||
54
cmd/brain/main.go
Normal file
54
cmd/brain/main.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/local/glpi-neural-brain/internal/activity"
|
||||
"github.com/local/glpi-neural-brain/internal/config"
|
||||
"github.com/local/glpi-neural-brain/internal/engine"
|
||||
"github.com/local/glpi-neural-brain/internal/graph"
|
||||
"github.com/local/glpi-neural-brain/internal/ingest"
|
||||
webui "github.com/local/glpi-neural-brain/internal/web"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
slog.Error("configuration invalid", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})))
|
||||
g, err := graph.Open(cfg.DataDir)
|
||||
if err != nil {
|
||||
slog.Error("graph open failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
broker := activity.New(120)
|
||||
eng := engine.New(cfg, g, broker)
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer cancel()
|
||||
eng.Start(ctx)
|
||||
watcher := ingest.NewAgentWatcher(cfg.AgentRunsFiles, g, broker)
|
||||
watcher.Start(ctx)
|
||||
ui := &webui.Server{Engine: eng, Graph: g, Broker: broker, APIKey: cfg.APIKey}
|
||||
srv := &http.Server{Addr: cfg.ListenAddr, Handler: ui.Handler(), ReadHeaderTimeout: 10 * time.Second, ReadTimeout: 30 * time.Second, WriteTimeout: 10 * time.Minute, IdleTimeout: 90 * time.Second}
|
||||
go func() {
|
||||
slog.Info("neural brain listening", "addr", cfg.ListenAddr, "knowledge_dirs", cfg.KnowledgeDirs, "staging_dirs", cfg.StagingDirs)
|
||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
slog.Error("server failed", "error", err)
|
||||
cancel()
|
||||
}
|
||||
}()
|
||||
<-ctx.Done()
|
||||
shutdown, c := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer c()
|
||||
_ = g.Persist()
|
||||
_ = srv.Shutdown(shutdown)
|
||||
}
|
||||
1
data/graph-state.json
Normal file
1
data/graph-state.json
Normal file
File diff suppressed because one or more lines are too long
24
deployment/README.md
Normal file
24
deployment/README.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# Gemeinsamer Stack
|
||||
|
||||
`docker-compose.full.yml` ist auf deine beiden gelieferten Projekte zugeschnitten. Standardmäßig erwartet die Datei drei Geschwisterordner. Pfade und Ports lassen sich über Umgebungsvariablen überschreiben.
|
||||
|
||||
Vor dem Start:
|
||||
|
||||
1. beide optionalen Telemetrie-Patches anwenden, falls echte Suchanfragen live erscheinen sollen;
|
||||
2. Agent-`.env` mit GLPI-Zugangsdaten bereitstellen;
|
||||
3. Knowledge-, Staging- und Backup-Pfade prüfen;
|
||||
4. Stack starten und Modelle laden.
|
||||
|
||||
```bash
|
||||
docker compose -f deployment/docker-compose.full.yml up -d --build ollama
|
||||
docker compose -f deployment/docker-compose.full.yml exec ollama ollama pull qwen3:8b
|
||||
docker compose -f deployment/docker-compose.full.yml exec ollama ollama pull embeddinggemma
|
||||
docker compose -f deployment/docker-compose.full.yml up -d --build
|
||||
```
|
||||
|
||||
Ports: Agent `8080`, Editor `8081`, Suche `8082`, Brain `8090`.
|
||||
|
||||
|
||||
## Staging-Isolation
|
||||
|
||||
Der Editor und das Brain teilen sich das echte Knowledgebase-Staging. `kb-search` erhält absichtlich nur ein leeres, flüchtiges Verzeichnis unter `/tmp/empty-staging`; der Agent mountet das Staging überhaupt nicht. AI-THINK-Drafts sind dadurch im Brain und im Prüf-Editor sichtbar, aber weder für Agent-Antworten noch für die produktive Knowledgebase-Suche verfügbar. Erst die bestehende Promotion im Editor kopiert einen freigegebenen Entwurf in die produktive Knowledge-Basis.
|
||||
138
deployment/docker-compose.full.yml
Normal file
138
deployment/docker-compose.full.yml
Normal file
@@ -0,0 +1,138 @@
|
||||
# Integrated example for sibling project folders:
|
||||
# ./glpi-ai-agent
|
||||
# ./glpi-ai-knowledgebase
|
||||
# ./glpi-neural-brain
|
||||
# Run from glpi-neural-brain:
|
||||
# docker compose -f deployment/docker-compose.full.yml up -d --build
|
||||
services:
|
||||
agent-data-init:
|
||||
build:
|
||||
context: ${AGENT_PROJECT_PATH:-../../glpi-ai-agent}
|
||||
target: data-init
|
||||
restart: "no"
|
||||
user: "0:0"
|
||||
volumes:
|
||||
- agent-data:/app/data
|
||||
security_opt: [no-new-privileges:true]
|
||||
cap_drop: [ALL]
|
||||
cap_add: [CHOWN, FOWNER]
|
||||
|
||||
agent:
|
||||
build: ${AGENT_PROJECT_PATH:-../../glpi-ai-agent}
|
||||
restart: unless-stopped
|
||||
env_file: ${AGENT_ENV_FILE:-../../glpi-ai-agent/.env}
|
||||
environment:
|
||||
DATA_DIR: /app/data
|
||||
KNOWLEDGE_DIR: /app/knowledge
|
||||
OLLAMA_URL: http://ollama:11434
|
||||
BRAIN_ACTIVITY_URL: http://brain:8090/api/events
|
||||
BRAIN_ACTIVITY_API_KEY: ${BRAIN_API_KEY:-}
|
||||
ports:
|
||||
- "127.0.0.1:${AGENT_PORT:-8080}:8080"
|
||||
volumes:
|
||||
- agent-data:/app/data
|
||||
- ${KNOWLEDGE_HOST_PATH:-../../glpi-ai-agent/knowledge}:/app/knowledge:ro
|
||||
depends_on:
|
||||
agent-data-init:
|
||||
condition: service_completed_successfully
|
||||
ollama:
|
||||
condition: service_started
|
||||
security_opt: [no-new-privileges:true]
|
||||
cap_drop: [ALL]
|
||||
read_only: true
|
||||
tmpfs: [/tmp:size=64m,mode=1777]
|
||||
|
||||
kb-editor:
|
||||
build: ${KB_PROJECT_PATH:-../../glpi-ai-knowledgebase}
|
||||
image: kb-helpdesk:neural-stack
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${KB_EDITOR_PORT:-8081}:8080"
|
||||
environment:
|
||||
APP_MODE: editor
|
||||
APP_TITLE: KB Administration
|
||||
APP_SUBTITLE: Wissen und AI-THINK prüfen
|
||||
DATA_DIR: /data/knowledge
|
||||
BACKUP_DIR: /data/backups
|
||||
STAGING_DIR: /data/staging
|
||||
LISTEN_ADDR: :8080
|
||||
BASIC_AUTH_USER: ${EDITOR_AUTH_USER:-}
|
||||
BASIC_AUTH_PASSWORD: ${EDITOR_AUTH_PASSWORD:-}
|
||||
BRAIN_ACTIVITY_URL: http://brain:8090/api/events
|
||||
BRAIN_ACTIVITY_API_KEY: ${BRAIN_API_KEY:-}
|
||||
volumes:
|
||||
- ${KNOWLEDGE_HOST_PATH:-../../glpi-ai-agent/knowledge}:/data/knowledge:rw
|
||||
- ${KB_BACKUP_HOST_PATH:-../../glpi-ai-knowledgebase/backups}:/data/backups:rw
|
||||
- ${KB_STAGING_HOST_PATH:-../../glpi-ai-knowledgebase/staging}:/data/staging:rw
|
||||
read_only: true
|
||||
tmpfs: [/tmp:size=32m]
|
||||
security_opt: [no-new-privileges:true]
|
||||
cap_drop: [ALL]
|
||||
|
||||
kb-search:
|
||||
image: kb-helpdesk:neural-stack
|
||||
depends_on: [kb-editor]
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${KB_SEARCH_PORT:-8082}:8080"
|
||||
environment:
|
||||
APP_MODE: google
|
||||
APP_TITLE: IT Helpdesk Wissen
|
||||
APP_SUBTITLE: Interne Lösungsdatenbank
|
||||
AUTO_RELOAD_INTERVAL: 60s
|
||||
DATA_DIR: /data/knowledge
|
||||
# Deliberately isolated: production search never sees Brain AI-THINK drafts.
|
||||
STAGING_DIR: /tmp/empty-staging
|
||||
LISTEN_ADDR: :8080
|
||||
OLLAMA_BASE_URL: http://ollama:11434
|
||||
AI_FALLBACK_ENABLED: ${AI_FALLBACK_ENABLED:-false}
|
||||
OLLAMA_MODEL: ${OLLAMA_CHAT_MODEL:-qwen3:8b}
|
||||
BRAIN_ACTIVITY_URL: http://brain:8090/api/events
|
||||
BRAIN_ACTIVITY_API_KEY: ${BRAIN_API_KEY:-}
|
||||
volumes:
|
||||
- ${KNOWLEDGE_HOST_PATH:-../../glpi-ai-agent/knowledge}:/data/knowledge:ro
|
||||
read_only: true
|
||||
tmpfs: [/tmp:size=32m]
|
||||
security_opt: [no-new-privileges:true]
|
||||
cap_drop: [ALL]
|
||||
|
||||
brain:
|
||||
build:
|
||||
context: ..
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${BRAIN_PORT:-8090}:8090"
|
||||
environment:
|
||||
BRAIN_LISTEN_ADDR: :8090
|
||||
BRAIN_DATA_DIR: /app/data
|
||||
BRAIN_KNOWLEDGE_DIRS: /sources/knowledge
|
||||
BRAIN_STAGING_DIRS: /sources/staging
|
||||
BRAIN_AGENT_RUNS_FILES: /sources/agent-data/runs.jsonl
|
||||
BRAIN_API_KEY: ${BRAIN_API_KEY:-}
|
||||
OLLAMA_URL: http://ollama:11434
|
||||
OLLAMA_CHAT_MODEL: ${OLLAMA_CHAT_MODEL:-qwen3:8b}
|
||||
OLLAMA_EMBEDDING_MODEL: ${OLLAMA_EMBEDDING_MODEL:-embeddinggemma}
|
||||
BRAIN_AUTO_ENRICH: ${BRAIN_AUTO_ENRICH:-true}
|
||||
BRAIN_SCAN_INTERVAL: ${BRAIN_SCAN_INTERVAL:-20s}
|
||||
BRAIN_ENRICH_INTERVAL: ${BRAIN_ENRICH_INTERVAL:-90s}
|
||||
BRAIN_RESEARCH_ENABLED: ${BRAIN_RESEARCH_ENABLED:-false}
|
||||
SEARXNG_URL: ${SEARXNG_URL:-}
|
||||
volumes:
|
||||
- brain-data:/app/data
|
||||
- ${KNOWLEDGE_HOST_PATH:-../../glpi-ai-agent/knowledge}:/sources/knowledge:ro
|
||||
- ${KB_STAGING_HOST_PATH:-../../glpi-ai-knowledgebase/staging}:/sources/staging:rw
|
||||
- agent-data:/sources/agent-data:ro
|
||||
depends_on:
|
||||
ollama:
|
||||
condition: service_started
|
||||
|
||||
ollama:
|
||||
image: ollama/ollama:latest
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ollama-data:/root/.ollama
|
||||
|
||||
volumes:
|
||||
agent-data:
|
||||
brain-data:
|
||||
ollama-data:
|
||||
35
docker-compose.yml
Normal file
35
docker-compose.yml
Normal file
@@ -0,0 +1,35 @@
|
||||
services:
|
||||
brain:
|
||||
build: .
|
||||
container_name: glpi-neural-brain
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8090:8090"
|
||||
environment:
|
||||
BRAIN_LISTEN_ADDR: ":8090"
|
||||
BRAIN_DATA_DIR: /app/data
|
||||
BRAIN_KNOWLEDGE_DIRS: /sources/knowledge
|
||||
BRAIN_STAGING_DIRS: /sources/staging
|
||||
BRAIN_AGENT_RUNS_FILES: /sources/agent-data/runs.jsonl
|
||||
OLLAMA_URL: ${OLLAMA_URL:-http://host.docker.internal:11434}
|
||||
OLLAMA_CHAT_MODEL: ${OLLAMA_CHAT_MODEL:-qwen3:8b}
|
||||
OLLAMA_EMBEDDING_MODEL: ${OLLAMA_EMBEDDING_MODEL:-embeddinggemma}
|
||||
BRAIN_AUTO_ENRICH: ${BRAIN_AUTO_ENRICH:-true}
|
||||
BRAIN_SCAN_INTERVAL: ${BRAIN_SCAN_INTERVAL:-20s}
|
||||
BRAIN_ENRICH_INTERVAL: ${BRAIN_ENRICH_INTERVAL:-90s}
|
||||
BRAIN_SIMILARITY_THRESHOLD: ${BRAIN_SIMILARITY_THRESHOLD:-0.68}
|
||||
BRAIN_RELATION_THRESHOLD: ${BRAIN_RELATION_THRESHOLD:-0.72}
|
||||
BRAIN_RESEARCH_ENABLED: ${BRAIN_RESEARCH_ENABLED:-false}
|
||||
SEARXNG_URL: ${SEARXNG_URL:-}
|
||||
BRAIN_API_KEY: ${BRAIN_API_KEY:-}
|
||||
volumes:
|
||||
- brain-data:/app/data
|
||||
# Adjust the three host paths to your actual project directories.
|
||||
- ../glpi-ai-agent/knowledge:/sources/knowledge:ro
|
||||
- ../glpi-ai-knowledgebase/staging:/sources/staging:rw
|
||||
- ../glpi-ai-agent/data:/sources/agent-data:ro
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
|
||||
volumes:
|
||||
brain-data:
|
||||
15
integrations/agent/README.md
Normal file
15
integrations/agent/README.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# Agent-Integration
|
||||
|
||||
Der Patch instrumentiert ausschließlich `knowledge.Store.Search`. Er übermittelt Suchtext, Knowledge-IDs, Scores, Trefferzahl und Dauer an das Brain. Keine GLPI-Zugangsdaten, Ticketaktionen oder freien LLM-Antworten werden gesendet.
|
||||
|
||||
```bash
|
||||
git apply glpi-ai-agent-neural-brain.patch
|
||||
go test ./internal/brainactivity ./internal/knowledge
|
||||
```
|
||||
|
||||
Aktivierung:
|
||||
|
||||
```env
|
||||
BRAIN_ACTIVITY_URL=http://glpi-neural-brain:8090/api/events
|
||||
BRAIN_ACTIVITY_API_KEY=
|
||||
```
|
||||
128
integrations/agent/glpi-ai-agent-neural-brain.patch
Normal file
128
integrations/agent/glpi-ai-agent-neural-brain.patch
Normal file
@@ -0,0 +1,128 @@
|
||||
diff --git a/internal/brainactivity/client.go b/internal/brainactivity/client.go
|
||||
new file mode 100644
|
||||
index 0000000..fb16e0a
|
||||
--- /dev/null
|
||||
+++ b/internal/brainactivity/client.go
|
||||
@@ -0,0 +1,90 @@
|
||||
+package brainactivity
|
||||
+
|
||||
+import (
|
||||
+ "bytes"
|
||||
+ "encoding/json"
|
||||
+ "net/http"
|
||||
+ "os"
|
||||
+ "strings"
|
||||
+ "sync"
|
||||
+ "time"
|
||||
+)
|
||||
+
|
||||
+type Hit struct {
|
||||
+ ID string `json:"id"`
|
||||
+ Score float64 `json:"score,omitempty"`
|
||||
+}
|
||||
+
|
||||
+type event struct {
|
||||
+ Type string `json:"type"`
|
||||
+ Source string `json:"source"`
|
||||
+ Query string `json:"query,omitempty"`
|
||||
+ Message string `json:"message,omitempty"`
|
||||
+ Hits []Hit `json:"hits,omitempty"`
|
||||
+ Metadata map[string]any `json:"metadata,omitempty"`
|
||||
+}
|
||||
+
|
||||
+var sender = newSender()
|
||||
+
|
||||
+type asyncSender struct {
|
||||
+ once sync.Once
|
||||
+ url string
|
||||
+ key string
|
||||
+ ch chan event
|
||||
+ http *http.Client
|
||||
+}
|
||||
+
|
||||
+func newSender() *asyncSender {
|
||||
+ return &asyncSender{ch: make(chan event, 128), http: &http.Client{Timeout: 3 * time.Second}}
|
||||
+}
|
||||
+
|
||||
+// EmitSearch is fail-open and has no effect unless BRAIN_ACTIVITY_URL is set.
|
||||
+// It never blocks the ticket-processing path and silently drops telemetry when
|
||||
+// the optional visualization is unavailable or the local queue is full.
|
||||
+func EmitSearch(source, query string, hits []Hit, duration time.Duration) {
|
||||
+ sender.once.Do(sender.start)
|
||||
+ if sender.url == "" {
|
||||
+ return
|
||||
+ }
|
||||
+ query = strings.TrimSpace(query)
|
||||
+ if len([]rune(query)) > 4000 {
|
||||
+ query = string([]rune(query)[:4000])
|
||||
+ }
|
||||
+ e := event{
|
||||
+ Type: "knowledge.search", Source: source, Query: query,
|
||||
+ Message: "Wissenssuche aus " + source,
|
||||
+ Hits: hits, Metadata: map[string]any{"duration_ms": duration.Milliseconds(), "result_count": len(hits)},
|
||||
+ }
|
||||
+ select {
|
||||
+ case sender.ch <- e:
|
||||
+ default:
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+func (s *asyncSender) start() {
|
||||
+ s.url = strings.TrimSpace(os.Getenv("BRAIN_ACTIVITY_URL"))
|
||||
+ s.key = strings.TrimSpace(os.Getenv("BRAIN_ACTIVITY_API_KEY"))
|
||||
+ if s.url == "" {
|
||||
+ return
|
||||
+ }
|
||||
+ go func() {
|
||||
+ for e := range s.ch {
|
||||
+ b, err := json.Marshal(e)
|
||||
+ if err != nil {
|
||||
+ continue
|
||||
+ }
|
||||
+ req, err := http.NewRequest(http.MethodPost, s.url, bytes.NewReader(b))
|
||||
+ if err != nil {
|
||||
+ continue
|
||||
+ }
|
||||
+ req.Header.Set("Content-Type", "application/json")
|
||||
+ if s.key != "" {
|
||||
+ req.Header.Set("Authorization", "Bearer "+s.key)
|
||||
+ }
|
||||
+ resp, err := s.http.Do(req)
|
||||
+ if err == nil {
|
||||
+ _ = resp.Body.Close()
|
||||
+ }
|
||||
+ }
|
||||
+ }()
|
||||
+}
|
||||
diff --git a/internal/knowledge/store.go b/internal/knowledge/store.go
|
||||
index 5c762c8..0c186a1 100644
|
||||
--- a/internal/knowledge/store.go
|
||||
+++ b/internal/knowledge/store.go
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
+ "github.com/example/glpi-ai-agent/internal/brainactivity"
|
||||
"github.com/example/glpi-ai-agent/internal/model"
|
||||
)
|
||||
|
||||
@@ -1073,6 +1074,7 @@ func safeID(v string) bool {
|
||||
// are scored separately. Missing metadata does not lower a document's score:
|
||||
// the weights of available components are normalized dynamically.
|
||||
func (s *Store) Search(ctx context.Context, text string, topK int, categorySets ...[]model.Category) ([]model.KnowledgeHit, error) {
|
||||
+ startedAt := time.Now()
|
||||
if s == nil {
|
||||
return nil, fmt.Errorf("knowledge store is not initialized")
|
||||
}
|
||||
@@ -1191,6 +1193,11 @@ func (s *Store) Search(ctx context.Context, text string, topK int, categorySets
|
||||
if topK > 0 && len(hits) > topK {
|
||||
hits = hits[:topK]
|
||||
}
|
||||
+ activityHits := make([]brainactivity.Hit, 0, len(hits))
|
||||
+ for _, hit := range hits {
|
||||
+ activityHits = append(activityHits, brainactivity.Hit{ID: hit.Doc.ID, Score: hit.Score})
|
||||
+ }
|
||||
+ brainactivity.EmitSearch("agent", text, activityHits, time.Since(startedAt))
|
||||
return hits, nil
|
||||
}
|
||||
|
||||
15
integrations/knowledgebase/README.md
Normal file
15
integrations/knowledgebase/README.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# Knowledgebase-Integration
|
||||
|
||||
Der Patch instrumentiert ausschließlich `GET /api/search`. Die Antwort und das Ranking bleiben unverändert. Das Telemetrie-Senden ist asynchron und fail-open.
|
||||
|
||||
```bash
|
||||
git apply glpi-ai-knowledgebase-neural-brain.patch
|
||||
go test ./internal/brainactivity ./cmd/server ./internal/store
|
||||
```
|
||||
|
||||
Aktivierung:
|
||||
|
||||
```env
|
||||
BRAIN_ACTIVITY_URL=http://glpi-neural-brain:8090/api/events
|
||||
BRAIN_ACTIVITY_API_KEY=
|
||||
```
|
||||
@@ -0,0 +1,128 @@
|
||||
diff --git a/cmd/server/app.go b/cmd/server/app.go
|
||||
index 158e10b..a85413d 100644
|
||||
--- a/cmd/server/app.go
|
||||
+++ b/cmd/server/app.go
|
||||
@@ -11,8 +11,10 @@ import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
+ "time"
|
||||
|
||||
"kb-editor/internal/aifallback"
|
||||
+ "kb-editor/internal/brainactivity"
|
||||
"kb-editor/internal/staging"
|
||||
"kb-editor/internal/store"
|
||||
)
|
||||
@@ -131,8 +133,15 @@ func (a *app) handleList(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (a *app) handleSearch(w http.ResponseWriter, r *http.Request) {
|
||||
+ startedAt := time.Now()
|
||||
q := queryFromURL(r)
|
||||
- writeJSON(w, http.StatusOK, a.store.Search(q))
|
||||
+ result := a.store.Search(q)
|
||||
+ hits := make([]brainactivity.Hit, 0, len(result.Items))
|
||||
+ for _, hit := range result.Items {
|
||||
+ hits = append(hits, brainactivity.Hit{ID: hit.ID, Score: float64(hit.Score) / 100})
|
||||
+ }
|
||||
+ brainactivity.EmitSearch("knowledgebase", q.Q, hits, time.Since(startedAt))
|
||||
+ writeJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (a *app) handleFacets(w http.ResponseWriter, r *http.Request) {
|
||||
diff --git a/internal/brainactivity/client.go b/internal/brainactivity/client.go
|
||||
new file mode 100644
|
||||
index 0000000..fb16e0a
|
||||
--- /dev/null
|
||||
+++ b/internal/brainactivity/client.go
|
||||
@@ -0,0 +1,90 @@
|
||||
+package brainactivity
|
||||
+
|
||||
+import (
|
||||
+ "bytes"
|
||||
+ "encoding/json"
|
||||
+ "net/http"
|
||||
+ "os"
|
||||
+ "strings"
|
||||
+ "sync"
|
||||
+ "time"
|
||||
+)
|
||||
+
|
||||
+type Hit struct {
|
||||
+ ID string `json:"id"`
|
||||
+ Score float64 `json:"score,omitempty"`
|
||||
+}
|
||||
+
|
||||
+type event struct {
|
||||
+ Type string `json:"type"`
|
||||
+ Source string `json:"source"`
|
||||
+ Query string `json:"query,omitempty"`
|
||||
+ Message string `json:"message,omitempty"`
|
||||
+ Hits []Hit `json:"hits,omitempty"`
|
||||
+ Metadata map[string]any `json:"metadata,omitempty"`
|
||||
+}
|
||||
+
|
||||
+var sender = newSender()
|
||||
+
|
||||
+type asyncSender struct {
|
||||
+ once sync.Once
|
||||
+ url string
|
||||
+ key string
|
||||
+ ch chan event
|
||||
+ http *http.Client
|
||||
+}
|
||||
+
|
||||
+func newSender() *asyncSender {
|
||||
+ return &asyncSender{ch: make(chan event, 128), http: &http.Client{Timeout: 3 * time.Second}}
|
||||
+}
|
||||
+
|
||||
+// EmitSearch is fail-open and has no effect unless BRAIN_ACTIVITY_URL is set.
|
||||
+// It never blocks the ticket-processing path and silently drops telemetry when
|
||||
+// the optional visualization is unavailable or the local queue is full.
|
||||
+func EmitSearch(source, query string, hits []Hit, duration time.Duration) {
|
||||
+ sender.once.Do(sender.start)
|
||||
+ if sender.url == "" {
|
||||
+ return
|
||||
+ }
|
||||
+ query = strings.TrimSpace(query)
|
||||
+ if len([]rune(query)) > 4000 {
|
||||
+ query = string([]rune(query)[:4000])
|
||||
+ }
|
||||
+ e := event{
|
||||
+ Type: "knowledge.search", Source: source, Query: query,
|
||||
+ Message: "Wissenssuche aus " + source,
|
||||
+ Hits: hits, Metadata: map[string]any{"duration_ms": duration.Milliseconds(), "result_count": len(hits)},
|
||||
+ }
|
||||
+ select {
|
||||
+ case sender.ch <- e:
|
||||
+ default:
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+func (s *asyncSender) start() {
|
||||
+ s.url = strings.TrimSpace(os.Getenv("BRAIN_ACTIVITY_URL"))
|
||||
+ s.key = strings.TrimSpace(os.Getenv("BRAIN_ACTIVITY_API_KEY"))
|
||||
+ if s.url == "" {
|
||||
+ return
|
||||
+ }
|
||||
+ go func() {
|
||||
+ for e := range s.ch {
|
||||
+ b, err := json.Marshal(e)
|
||||
+ if err != nil {
|
||||
+ continue
|
||||
+ }
|
||||
+ req, err := http.NewRequest(http.MethodPost, s.url, bytes.NewReader(b))
|
||||
+ if err != nil {
|
||||
+ continue
|
||||
+ }
|
||||
+ req.Header.Set("Content-Type", "application/json")
|
||||
+ if s.key != "" {
|
||||
+ req.Header.Set("Authorization", "Bearer "+s.key)
|
||||
+ }
|
||||
+ resp, err := s.http.Do(req)
|
||||
+ if err == nil {
|
||||
+ _ = resp.Body.Close()
|
||||
+ }
|
||||
+ }
|
||||
+ }()
|
||||
+}
|
||||
90
internal/activity/broker.go
Normal file
90
internal/activity/broker.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package activity
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/local/glpi-neural-brain/internal/model"
|
||||
)
|
||||
|
||||
type Broker struct {
|
||||
mu sync.RWMutex
|
||||
next int
|
||||
subs map[int]chan model.Activity
|
||||
recent []model.Activity
|
||||
maxRecent int
|
||||
}
|
||||
|
||||
func New(maxRecent int) *Broker {
|
||||
if maxRecent < 10 {
|
||||
maxRecent = 100
|
||||
}
|
||||
return &Broker{subs: map[int]chan model.Activity{}, maxRecent: maxRecent}
|
||||
}
|
||||
func (b *Broker) Publish(a model.Activity) {
|
||||
if a.Timestamp.IsZero() {
|
||||
a.Timestamp = time.Now().UTC()
|
||||
}
|
||||
if a.ID == "" {
|
||||
a.ID = fmt.Sprintf("evt-%d", a.Timestamp.UnixNano())
|
||||
}
|
||||
b.mu.Lock()
|
||||
b.recent = append(b.recent, a)
|
||||
if len(b.recent) > b.maxRecent {
|
||||
b.recent = append([]model.Activity(nil), b.recent[len(b.recent)-b.maxRecent:]...)
|
||||
}
|
||||
for _, ch := range b.subs {
|
||||
select {
|
||||
case ch <- a:
|
||||
default:
|
||||
}
|
||||
}
|
||||
b.mu.Unlock()
|
||||
}
|
||||
func (b *Broker) Recent() []model.Activity {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
return append([]model.Activity(nil), b.recent...)
|
||||
}
|
||||
func (b *Broker) ServeSSE(w http.ResponseWriter, r *http.Request) {
|
||||
fl, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
http.Error(w, "streaming unsupported", 500)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
b.mu.Lock()
|
||||
id := b.next
|
||||
b.next++
|
||||
ch := make(chan model.Activity, 64)
|
||||
b.subs[id] = ch
|
||||
recent := append([]model.Activity(nil), b.recent...)
|
||||
b.mu.Unlock()
|
||||
defer func() { b.mu.Lock(); delete(b.subs, id); close(ch); b.mu.Unlock() }()
|
||||
enc := func(a model.Activity) {
|
||||
data, _ := json.Marshal(a)
|
||||
fmt.Fprintf(w, "event: activity\ndata: %s\n\n", data)
|
||||
fl.Flush()
|
||||
}
|
||||
for _, a := range recent {
|
||||
enc(a)
|
||||
}
|
||||
ticker := time.NewTicker(20 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
case a := <-ch:
|
||||
enc(a)
|
||||
case <-ticker.C:
|
||||
fmt.Fprint(w, ": ping\n\n")
|
||||
fl.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
140
internal/config/config.go
Normal file
140
internal/config/config.go
Normal file
@@ -0,0 +1,140 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
ListenAddr string
|
||||
DataDir string
|
||||
KnowledgeDirs []string
|
||||
StagingDirs []string
|
||||
AgentRunsFiles []string
|
||||
OllamaURL string
|
||||
ChatModel string
|
||||
EmbeddingModel string
|
||||
SearXNGURL string
|
||||
ScanInterval time.Duration
|
||||
EnrichInterval time.Duration
|
||||
SimilarityThreshold float64
|
||||
RelationThreshold float64
|
||||
TopK int
|
||||
MaxContextChars int
|
||||
AutoEnrich bool
|
||||
ResearchEnabled bool
|
||||
APIKey string
|
||||
}
|
||||
|
||||
func Load() (Config, error) {
|
||||
dataDir := env("BRAIN_DATA_DIR", "./data")
|
||||
abs, err := filepath.Abs(dataDir)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg := Config{
|
||||
ListenAddr: env("BRAIN_LISTEN_ADDR", ":8090"),
|
||||
DataDir: abs,
|
||||
KnowledgeDirs: paths("BRAIN_KNOWLEDGE_DIRS"),
|
||||
StagingDirs: paths("BRAIN_STAGING_DIRS"),
|
||||
AgentRunsFiles: paths("BRAIN_AGENT_RUNS_FILES"),
|
||||
OllamaURL: strings.TrimRight(env("OLLAMA_URL", "http://localhost:11434"), "/"),
|
||||
ChatModel: env("OLLAMA_CHAT_MODEL", "qwen3:8b"),
|
||||
EmbeddingModel: env("OLLAMA_EMBEDDING_MODEL", "embeddinggemma"),
|
||||
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),
|
||||
SimilarityThreshold: number("BRAIN_SIMILARITY_THRESHOLD", 0.68),
|
||||
RelationThreshold: number("BRAIN_RELATION_THRESHOLD", 0.72),
|
||||
TopK: integer("BRAIN_TOP_K", 8),
|
||||
MaxContextChars: integer("BRAIN_MAX_CONTEXT_CHARS", 16000),
|
||||
AutoEnrich: boolean("BRAIN_AUTO_ENRICH", true),
|
||||
ResearchEnabled: boolean("BRAIN_RESEARCH_ENABLED", false),
|
||||
APIKey: strings.TrimSpace(os.Getenv("BRAIN_API_KEY")),
|
||||
}
|
||||
if cfg.ScanInterval < 2*time.Second {
|
||||
return Config{}, fmt.Errorf("BRAIN_SCAN_INTERVAL must be at least 2s")
|
||||
}
|
||||
if cfg.EnrichInterval < 10*time.Second {
|
||||
return Config{}, fmt.Errorf("BRAIN_ENRICH_INTERVAL must be at least 10s")
|
||||
}
|
||||
if cfg.SimilarityThreshold < 0 || cfg.SimilarityThreshold > 1 {
|
||||
return Config{}, fmt.Errorf("invalid similarity threshold")
|
||||
}
|
||||
if cfg.RelationThreshold < 0 || cfg.RelationThreshold > 1 {
|
||||
return Config{}, fmt.Errorf("invalid relation threshold")
|
||||
}
|
||||
if cfg.ResearchEnabled && cfg.SearXNGURL == "" {
|
||||
return Config{}, fmt.Errorf("BRAIN_RESEARCH_ENABLED requires SEARXNG_URL")
|
||||
}
|
||||
if err := os.MkdirAll(cfg.DataDir, 0o750); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func env(k, d string) string {
|
||||
if v := strings.TrimSpace(os.Getenv(k)); v != "" {
|
||||
return v
|
||||
}
|
||||
return d
|
||||
}
|
||||
func paths(k string) []string {
|
||||
var out []string
|
||||
for _, v := range strings.Split(os.Getenv(k), ",") {
|
||||
if v = strings.TrimSpace(v); v != "" {
|
||||
if a, e := filepath.Abs(v); e == nil {
|
||||
out = append(out, a)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
func duration(k string, d time.Duration) time.Duration {
|
||||
v := strings.TrimSpace(os.Getenv(k))
|
||||
if v == "" {
|
||||
return d
|
||||
}
|
||||
x, e := time.ParseDuration(v)
|
||||
if e != nil {
|
||||
return d
|
||||
}
|
||||
return x
|
||||
}
|
||||
func number(k string, d float64) float64 {
|
||||
v := strings.TrimSpace(os.Getenv(k))
|
||||
if v == "" {
|
||||
return d
|
||||
}
|
||||
x, e := strconv.ParseFloat(v, 64)
|
||||
if e != nil {
|
||||
return d
|
||||
}
|
||||
return x
|
||||
}
|
||||
func integer(k string, d int) int {
|
||||
v := strings.TrimSpace(os.Getenv(k))
|
||||
if v == "" {
|
||||
return d
|
||||
}
|
||||
x, e := strconv.Atoi(v)
|
||||
if e != nil {
|
||||
return d
|
||||
}
|
||||
return x
|
||||
}
|
||||
func boolean(k string, d bool) bool {
|
||||
v := strings.TrimSpace(os.Getenv(k))
|
||||
if v == "" {
|
||||
return d
|
||||
}
|
||||
x, e := strconv.ParseBool(v)
|
||||
if e != nil {
|
||||
return d
|
||||
}
|
||||
return x
|
||||
}
|
||||
487
internal/engine/engine.go
Normal file
487
internal/engine/engine.go
Normal file
@@ -0,0 +1,487 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"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/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/research"
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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.SearXNGURL != "" {
|
||||
e.Research = research.New(cfg.SearXNGURL)
|
||||
}
|
||||
return e
|
||||
}
|
||||
func (e *Engine) Start(ctx context.Context) {
|
||||
go func() {
|
||||
if err := e.Scan(ctx); err != nil {
|
||||
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 err := e.Scan(ctx); err != nil {
|
||||
slog.Error("brain scan failed", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
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.idle(ctx)
|
||||
}
|
||||
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 {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
e.Broker.Publish(model.Activity{Type: "scan.started", Source: "brain", Phase: "ingest", Message: "Wissensräume werden synchronisiert", Strength: .45})
|
||||
count, err := e.Scanner.Scan()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
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.ollamaOK = 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.ollamaOK = false
|
||||
} else {
|
||||
e.ollamaOK = true
|
||||
}
|
||||
}
|
||||
if err := e.Graph.Persist(); err != nil {
|
||||
return err
|
||||
}
|
||||
e.lastScan = time.Now().UTC()
|
||||
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.NodesForEmbedding()
|
||||
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.NodesForEmbedding() {
|
||||
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.ollamaOK && 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 {
|
||||
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")
|
||||
}
|
||||
a, b, sim, ok := e.Graph.BestPair(e.Cfg.SimilarityThreshold)
|
||||
if !ok {
|
||||
return 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}})
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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}})
|
||||
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)},
|
||||
}
|
||||
e.Graph.UpsertEdge(edge)
|
||||
edge.ID = graph.EdgeID(edge.Source, edge.Target, edge.Type, edge.Origin)
|
||||
if status == "staging" {
|
||||
path, err := e.writeAIThink(a, b, decision, researchResults)
|
||||
if err != nil {
|
||||
return 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}})
|
||||
} 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}})
|
||||
}
|
||||
return e.Graph.Persist()
|
||||
}
|
||||
|
||||
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]
|
||||
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||
return "", err
|
||||
}
|
||||
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 _, err := os.Stat(path); err == nil {
|
||||
return path, nil
|
||||
}
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, append(bts, '\n'), 0o640); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.Rename(tmp, path); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
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}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
97
internal/engine/engine_test.go
Normal file
97
internal/engine/engine_test.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/local/glpi-neural-brain/internal/activity"
|
||||
"github.com/local/glpi-neural-brain/internal/config"
|
||||
"github.com/local/glpi-neural-brain/internal/graph"
|
||||
)
|
||||
|
||||
func TestEnrichWritesAIThinkOnlyAfterStructuredQwenDecision(t *testing.T) {
|
||||
mock := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case "/api/tags":
|
||||
_, _ = w.Write([]byte(`{"models":[]}`))
|
||||
case "/api/embed":
|
||||
var req struct {
|
||||
Input []string `json:"input"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
vectors := make([][]float64, len(req.Input))
|
||||
for i := range vectors {
|
||||
vectors[i] = []float64{1, float64(i) * .05, 0}
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"embeddings": vectors})
|
||||
case "/api/chat":
|
||||
content := `{"related":true,"relation_type":"supports","confidence":0.91,"explanation":"Beide Einträge behandeln denselben VPN-Störungsablauf.","needs_research":false,"research_query":"","title":"VPN-Gateway und Remotezugriff","synthesis":"Der Gateway-Fehler ist ein konkreter Teilbereich des Remotezugriffs.","keywords":["VPN","Gateway"]}`
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"message": map[string]any{"content": content}})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer mock.Close()
|
||||
|
||||
root := t.TempDir()
|
||||
knowledge := filepath.Join(root, "knowledge")
|
||||
staging := filepath.Join(root, "staging")
|
||||
data := filepath.Join(root, "data")
|
||||
for _, d := range []string{knowledge, staging, data} {
|
||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
write := func(name, body string) {
|
||||
if err := os.WriteFile(filepath.Join(knowledge, name), []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
write("vpn.json", `{"id":"KB-VPN","title":"VPN Gateway","text":"Gateway nicht erreichbar","categories":["Netzwerk"],"keywords":["VPN","Gateway"],"source":"internal-kb"}`)
|
||||
write("remote.json", `{"id":"KB-REMOTE","title":"Remotezugriff und VPN","text":"Remotezugriff über VPN und Gateway","categories":["Netzwerk"],"keywords":["VPN","Remotezugriff"],"source":"internal-kb"}`)
|
||||
|
||||
g, err := graph.Open(data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := config.Config{DataDir: data, KnowledgeDirs: []string{knowledge}, StagingDirs: []string{staging}, OllamaURL: mock.URL, ChatModel: "qwen3:8b", EmbeddingModel: "embeddinggemma", ScanInterval: time.Minute, EnrichInterval: time.Minute, SimilarityThreshold: .5, RelationThreshold: .7, TopK: 5, MaxContextChars: 8000}
|
||||
e := New(cfg, g, activity.New(20))
|
||||
if err := e.Scan(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !e.ollamaOK {
|
||||
t.Fatal("mock Ollama should be healthy")
|
||||
}
|
||||
if err := e.EnrichOne(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
files, err := filepath.Glob(filepath.Join(staging, "*.json"))
|
||||
if err != nil || len(files) != 1 {
|
||||
t.Fatalf("expected one AI-THINK file, files=%v err=%v", files, err)
|
||||
}
|
||||
var doc map[string]any
|
||||
b, _ := os.ReadFile(files[0])
|
||||
if err := json.Unmarshal(b, &doc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if doc["auto_reply"] != false {
|
||||
t.Fatalf("AI-THINK must be auto_reply=false: %#v", doc["auto_reply"])
|
||||
}
|
||||
cats, _ := doc["categories"].([]any)
|
||||
found := false
|
||||
for _, c := range cats {
|
||||
if c == "AI-THINK" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("AI-THINK category missing: %#v", cats)
|
||||
}
|
||||
}
|
||||
501
internal/graph/store.go
Normal file
501
internal/graph/store.go
Normal file
@@ -0,0 +1,501 @@
|
||||
package graph
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/local/glpi-neural-brain/internal/model"
|
||||
)
|
||||
|
||||
type Store struct {
|
||||
mu sync.RWMutex
|
||||
nodes map[string]model.Node
|
||||
edges map[string]model.Edge
|
||||
vectors map[string][]float64
|
||||
version uint64
|
||||
path string
|
||||
}
|
||||
|
||||
type diskState struct {
|
||||
Version uint64 `json:"version"`
|
||||
Nodes []model.Node `json:"nodes"`
|
||||
Edges []model.Edge `json:"edges"`
|
||||
Vectors map[string][]float64 `json:"vectors,omitempty"`
|
||||
}
|
||||
|
||||
func Open(dir string) (*Store, error) {
|
||||
s := &Store{nodes: map[string]model.Node{}, edges: map[string]model.Edge{}, vectors: map[string][]float64{}, path: filepath.Join(dir, "graph-state.json")}
|
||||
b, err := os.ReadFile(s.path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return s, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var d diskState
|
||||
if err = json.Unmarshal(b, &d); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.version = d.Version
|
||||
for _, n := range d.Nodes {
|
||||
s.nodes[n.ID] = n
|
||||
}
|
||||
for _, e := range d.Edges {
|
||||
s.edges[e.ID] = e
|
||||
}
|
||||
if d.Vectors != nil {
|
||||
s.vectors = d.Vectors
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
func ID(parts ...string) string {
|
||||
h := sha256.Sum256([]byte(strings.Join(parts, "\x00")))
|
||||
return hex.EncodeToString(h[:12])
|
||||
}
|
||||
func EdgeID(source, target, typ, origin string) string {
|
||||
return ID("edge", source, target, typ, origin)
|
||||
}
|
||||
func (s *Store) UpsertNode(n model.Node) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if n.UpdatedAt.IsZero() {
|
||||
n.UpdatedAt = time.Now().UTC()
|
||||
}
|
||||
if n.Weight == 0 {
|
||||
n.Weight = 1
|
||||
}
|
||||
if n.X == 0 && n.Y == 0 && n.Z == 0 {
|
||||
n.X, n.Y, n.Z = position(n.ID, n.Categories)
|
||||
}
|
||||
s.nodes[n.ID] = n
|
||||
s.version++
|
||||
}
|
||||
func (s *Store) UpsertEdge(e model.Edge) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
now := time.Now().UTC()
|
||||
if e.ID == "" {
|
||||
e.ID = EdgeID(e.Source, e.Target, e.Type, e.Origin)
|
||||
}
|
||||
if e.CreatedAt.IsZero() {
|
||||
if old, ok := s.edges[e.ID]; ok {
|
||||
e.CreatedAt = old.CreatedAt
|
||||
} else {
|
||||
e.CreatedAt = now
|
||||
}
|
||||
}
|
||||
e.UpdatedAt = now
|
||||
if e.Weight == 0 {
|
||||
e.Weight = 1
|
||||
}
|
||||
s.edges[e.ID] = e
|
||||
s.version++
|
||||
}
|
||||
func (s *Store) HasEdgeBetween(a, b string) bool {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
for _, e := range s.edges {
|
||||
if (e.Source == a && e.Target == b) || (e.Source == b && e.Target == a) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
func (s *Store) GetNode(id string) (model.Node, bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
n, ok := s.nodes[id]
|
||||
return n, ok
|
||||
}
|
||||
func (s *Store) LookupExternal(id string) (model.Node, bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
for _, n := range s.nodes {
|
||||
if strings.EqualFold(n.ExternalID, id) {
|
||||
return n, true
|
||||
}
|
||||
}
|
||||
return model.Node{}, false
|
||||
}
|
||||
func (s *Store) SetVector(id string, v []float64) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.vectors[id] = append([]float64(nil), v...)
|
||||
}
|
||||
func (s *Store) Vector(id string) ([]float64, bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
v, ok := s.vectors[id]
|
||||
return append([]float64(nil), v...), ok
|
||||
}
|
||||
|
||||
func (s *Store) ClearVectorsByDimension(dim int) int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
removed := 0
|
||||
for id, v := range s.vectors {
|
||||
if len(v) == dim {
|
||||
delete(s.vectors, id)
|
||||
removed++
|
||||
}
|
||||
}
|
||||
if removed > 0 {
|
||||
s.version++
|
||||
}
|
||||
return removed
|
||||
}
|
||||
|
||||
func (s *Store) NodesForEmbedding() []model.Node {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := []model.Node{}
|
||||
for _, n := range s.nodes {
|
||||
if n.Kind != "knowledge" && n.Kind != "ai-think" && n.Kind != "external" {
|
||||
continue
|
||||
}
|
||||
if _, ok := s.vectors[n.ID]; !ok {
|
||||
out = append(out, n)
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
|
||||
return out
|
||||
}
|
||||
func (s *Store) KnowledgeNodes() []model.Node {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := []model.Node{}
|
||||
for _, n := range s.nodes {
|
||||
if n.Kind == "knowledge" || n.Kind == "ai-think" {
|
||||
out = append(out, n)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
func (s *Store) ReplaceOrigins(origins []string, nodes []model.Node, edges []model.Edge) {
|
||||
set := map[string]bool{}
|
||||
for _, o := range origins {
|
||||
set[o] = true
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
oldVectors := make(map[string][]float64)
|
||||
oldFingerprints := make(map[string]string)
|
||||
for id, n := range s.nodes {
|
||||
if set[n.Origin] {
|
||||
if v, ok := s.vectors[id]; ok {
|
||||
oldVectors[id] = append([]float64(nil), v...)
|
||||
oldFingerprints[id] = n.Label + "\x00" + n.Summary + "\x00" + strings.Join(n.Categories, "\x00") + "\x00" + strings.Join(n.Keywords, "\x00")
|
||||
}
|
||||
delete(s.nodes, id)
|
||||
delete(s.vectors, id)
|
||||
}
|
||||
}
|
||||
for id, e := range s.edges {
|
||||
if set[e.Origin] {
|
||||
delete(s.edges, id)
|
||||
}
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
for _, n := range nodes {
|
||||
if n.UpdatedAt.IsZero() {
|
||||
n.UpdatedAt = now
|
||||
}
|
||||
if n.Weight == 0 {
|
||||
n.Weight = 1
|
||||
}
|
||||
if n.X == 0 && n.Y == 0 && n.Z == 0 {
|
||||
n.X, n.Y, n.Z = position(n.ID, n.Categories)
|
||||
}
|
||||
s.nodes[n.ID] = n
|
||||
fingerprint := n.Label + "\x00" + n.Summary + "\x00" + strings.Join(n.Categories, "\x00") + "\x00" + strings.Join(n.Keywords, "\x00")
|
||||
if fingerprint == oldFingerprints[n.ID] {
|
||||
if v, ok := oldVectors[n.ID]; ok {
|
||||
s.vectors[n.ID] = append([]float64(nil), v...)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, e := range edges {
|
||||
if e.ID == "" {
|
||||
e.ID = EdgeID(e.Source, e.Target, e.Type, e.Origin)
|
||||
}
|
||||
if e.CreatedAt.IsZero() {
|
||||
e.CreatedAt = now
|
||||
}
|
||||
e.UpdatedAt = now
|
||||
if e.Weight == 0 {
|
||||
e.Weight = 1
|
||||
}
|
||||
s.edges[e.ID] = e
|
||||
}
|
||||
for id, e := range s.edges {
|
||||
if _, ok := s.nodes[e.Source]; !ok {
|
||||
delete(s.edges, id)
|
||||
continue
|
||||
}
|
||||
if _, ok := s.nodes[e.Target]; !ok {
|
||||
delete(s.edges, id)
|
||||
}
|
||||
}
|
||||
s.version++
|
||||
}
|
||||
func (s *Store) Snapshot() model.Snapshot {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
n := make([]model.Node, 0, len(s.nodes))
|
||||
e := make([]model.Edge, 0, len(s.edges))
|
||||
for _, x := range s.nodes {
|
||||
n = append(n, x)
|
||||
}
|
||||
for _, x := range s.edges {
|
||||
if x.Status == "rejected" {
|
||||
continue
|
||||
}
|
||||
e = append(e, x)
|
||||
}
|
||||
sort.Slice(n, func(i, j int) bool { return n[i].ID < n[j].ID })
|
||||
sort.Slice(e, func(i, j int) bool { return e[i].ID < e[j].ID })
|
||||
return model.Snapshot{Version: s.version, Nodes: n, Edges: e, UpdatedAt: time.Now().UTC()}
|
||||
}
|
||||
func (s *Store) Persist() error {
|
||||
s.mu.RLock()
|
||||
d := diskState{Version: s.version, Vectors: map[string][]float64{}}
|
||||
for _, n := range s.nodes {
|
||||
d.Nodes = append(d.Nodes, n)
|
||||
}
|
||||
for _, e := range s.edges {
|
||||
d.Edges = append(d.Edges, e)
|
||||
}
|
||||
for k, v := range s.vectors {
|
||||
d.Vectors[k] = v
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
b, err := json.Marshal(d)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := s.path + ".tmp"
|
||||
if err = os.WriteFile(tmp, b, 0o640); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, s.path)
|
||||
}
|
||||
func (s *Store) Similar(query []float64, limit int) []model.Hit {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
hits := []model.Hit{}
|
||||
for id, v := range s.vectors {
|
||||
n, ok := s.nodes[id]
|
||||
if !ok || (n.Kind != "knowledge" && n.Kind != "ai-think" && n.Kind != "external") {
|
||||
continue
|
||||
}
|
||||
score := cosine(query, v)
|
||||
hits = append(hits, model.Hit{NodeID: id, Label: n.Label, Score: score, Kind: n.Kind, Status: n.Status})
|
||||
}
|
||||
sort.Slice(hits, func(i, j int) bool { return hits[i].Score > hits[j].Score })
|
||||
if limit > 0 && len(hits) > limit {
|
||||
hits = hits[:limit]
|
||||
}
|
||||
return hits
|
||||
}
|
||||
func (s *Store) BestPair(min float64) (model.Node, model.Node, float64, bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
nodes := []model.Node{}
|
||||
for _, n := range s.nodes {
|
||||
if n.Kind == "knowledge" || n.Kind == "ai-think" {
|
||||
if _, ok := s.vectors[n.ID]; ok {
|
||||
nodes = append(nodes, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
best := -1.0
|
||||
var a, b model.Node
|
||||
for i := 0; i < len(nodes); i++ {
|
||||
for j := i + 1; j < len(nodes); j++ {
|
||||
if edgeBetweenLocked(s.edges, nodes[i].ID, nodes[j].ID) {
|
||||
continue
|
||||
}
|
||||
score := cosine(s.vectors[nodes[i].ID], s.vectors[nodes[j].ID])
|
||||
if score >= min && score > best {
|
||||
best = score
|
||||
a = nodes[i]
|
||||
b = nodes[j]
|
||||
}
|
||||
}
|
||||
}
|
||||
return a, b, best, best >= 0
|
||||
}
|
||||
func (s *Store) ConnectingEdges(ids []string) []string {
|
||||
set := map[string]bool{}
|
||||
for _, id := range ids {
|
||||
set[id] = true
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
var out []string
|
||||
for id, e := range s.edges {
|
||||
if set[e.Source] && set[e.Target] {
|
||||
out = append(out, id)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
func edgeBetweenLocked(edges map[string]model.Edge, a, b string) bool {
|
||||
for _, e := range edges {
|
||||
if (e.Source == a && e.Target == b) || (e.Source == b && e.Target == a) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
func cosine(a, b []float64) float64 {
|
||||
if len(a) == 0 || len(a) != len(b) {
|
||||
return 0
|
||||
}
|
||||
var dot, aa, bb float64
|
||||
for i := range a {
|
||||
dot += a[i] * b[i]
|
||||
aa += a[i] * a[i]
|
||||
bb += b[i] * b[i]
|
||||
}
|
||||
if aa == 0 || bb == 0 {
|
||||
return 0
|
||||
}
|
||||
return dot / (math.Sqrt(aa) * math.Sqrt(bb))
|
||||
}
|
||||
func position(id string, cats []string) (float64, float64, float64) {
|
||||
seed := sha256.Sum256([]byte(id + "\x00" + strings.Join(cats, "|")))
|
||||
u := func(i int) float64 { return float64(int(seed[i%len(seed)])) / 255 }
|
||||
side := -1.0
|
||||
if seed[0]%2 == 0 {
|
||||
side = 1
|
||||
}
|
||||
biasY, biasZ := 0.0, 0.0
|
||||
if len(cats) > 0 {
|
||||
h := sha256.Sum256([]byte(cats[0]))
|
||||
biasY = (float64(h[0])/255 - .5) * .9
|
||||
biasZ = (float64(h[1])/255 - .5) * .65
|
||||
}
|
||||
for i := 0; i < 16; i++ {
|
||||
x := side * (0.08 + u(1+i)*0.72)
|
||||
y := biasY*.32 + (u(2+i)-.5)*1.18
|
||||
z := biasZ*.28 + (u(3+i)-.5)*.94
|
||||
if insideBrainShape(x, y, z) {
|
||||
return x, y, z
|
||||
}
|
||||
}
|
||||
return side * .34, biasY * .22, biasZ * .2
|
||||
}
|
||||
|
||||
func insideBrainShape(x, y, z float64) bool {
|
||||
if math.Abs(x) < .045 && y > -.58 && y < .42 {
|
||||
return false
|
||||
}
|
||||
if y < -.76 || y > .82 {
|
||||
return false
|
||||
}
|
||||
taperY := y + math.Abs(z)*.10 - math.Max(0, math.Abs(x)-.58)*.18
|
||||
lx := (x + .35) / .58
|
||||
rx := (x - .35) / .58
|
||||
ny := taperY / .76
|
||||
nz := z / .58
|
||||
left := lx*lx+ny*ny+nz*nz <= 1
|
||||
right := rx*rx+ny*ny+nz*nz <= 1
|
||||
return left || right
|
||||
}
|
||||
|
||||
func (s *Store) Analyze() model.GraphAnalysis {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
analysis := model.GraphAnalysis{NodeCount: len(s.nodes)}
|
||||
degree := make(map[string]int, len(s.nodes))
|
||||
knowledgeLinked := make(map[string]bool)
|
||||
parent := make(map[string]string, len(s.nodes))
|
||||
for id, n := range s.nodes {
|
||||
parent[id] = id
|
||||
if n.Status == "staging" {
|
||||
analysis.StagingNodes++
|
||||
}
|
||||
if n.Kind == "ai-think" {
|
||||
analysis.AIThinkNodes++
|
||||
}
|
||||
if n.Kind == "external" {
|
||||
analysis.ExternalNodes++
|
||||
}
|
||||
}
|
||||
var find func(string) string
|
||||
find = func(x string) string {
|
||||
p := parent[x]
|
||||
if p != x {
|
||||
parent[x] = find(p)
|
||||
}
|
||||
return parent[x]
|
||||
}
|
||||
union := func(a, b string) {
|
||||
ra, rb := find(a), find(b)
|
||||
if ra != rb {
|
||||
parent[rb] = ra
|
||||
}
|
||||
}
|
||||
for _, e := range s.edges {
|
||||
if e.Status == "rejected" {
|
||||
continue
|
||||
}
|
||||
if _, ok := s.nodes[e.Source]; !ok {
|
||||
continue
|
||||
}
|
||||
if _, ok := s.nodes[e.Target]; !ok {
|
||||
continue
|
||||
}
|
||||
analysis.EdgeCount++
|
||||
degree[e.Source]++
|
||||
degree[e.Target]++
|
||||
union(e.Source, e.Target)
|
||||
if e.Origin == "ai-inference" {
|
||||
analysis.AIEdges++
|
||||
}
|
||||
if e.Type == "contradicts" {
|
||||
analysis.Contradictions++
|
||||
}
|
||||
a, b := s.nodes[e.Source], s.nodes[e.Target]
|
||||
if (a.Kind == "knowledge" || a.Kind == "ai-think") && (b.Kind == "knowledge" || b.Kind == "ai-think" || b.Kind == "external") {
|
||||
knowledgeLinked[a.ID] = true
|
||||
if b.Kind != "external" {
|
||||
knowledgeLinked[b.ID] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
roots := map[string]bool{}
|
||||
for id, n := range s.nodes {
|
||||
roots[find(id)] = true
|
||||
if (n.Kind == "knowledge" || n.Kind == "ai-think") && !knowledgeLinked[id] {
|
||||
analysis.KnowledgeOrphans++
|
||||
}
|
||||
}
|
||||
analysis.Components = len(roots)
|
||||
hubs := make([]model.Hub, 0, len(degree))
|
||||
for id, d := range degree {
|
||||
n := s.nodes[id]
|
||||
hubs = append(hubs, model.Hub{NodeID: id, Label: n.Label, Kind: n.Kind, Degree: d})
|
||||
}
|
||||
sort.Slice(hubs, func(i, j int) bool {
|
||||
if hubs[i].Degree == hubs[j].Degree {
|
||||
return hubs[i].Label < hubs[j].Label
|
||||
}
|
||||
return hubs[i].Degree > hubs[j].Degree
|
||||
})
|
||||
if len(hubs) > 8 {
|
||||
hubs = hubs[:8]
|
||||
}
|
||||
analysis.TopHubs = hubs
|
||||
return analysis
|
||||
}
|
||||
47
internal/graph/store_test.go
Normal file
47
internal/graph/store_test.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package graph
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/local/glpi-neural-brain/internal/model"
|
||||
)
|
||||
|
||||
func TestReplaceOriginsPreservesUnchangedVector(t *testing.T) {
|
||||
s, err := Open(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
n := model.Node{ID: "n1", Kind: "knowledge", Label: "VPN", Summary: "Gateway", Origin: "knowledge-production"}
|
||||
s.ReplaceOrigins([]string{"knowledge-production"}, []model.Node{n}, nil)
|
||||
s.SetVector("n1", []float64{1, 2, 3})
|
||||
s.ReplaceOrigins([]string{"knowledge-production"}, []model.Node{n}, nil)
|
||||
v, ok := s.Vector("n1")
|
||||
if !ok || len(v) != 3 {
|
||||
t.Fatalf("vector was not preserved: %v %v", ok, v)
|
||||
}
|
||||
n.Summary = "changed"
|
||||
s.ReplaceOrigins([]string{"knowledge-production"}, []model.Node{n}, nil)
|
||||
if _, ok := s.Vector("n1"); ok {
|
||||
t.Fatal("changed document retained stale vector")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectedEdgePreventsPairReprocessingButIsHidden(t *testing.T) {
|
||||
s, _ := Open(t.TempDir())
|
||||
a := model.Node{ID: "a", Kind: "knowledge", Label: "A", Origin: "knowledge-production"}
|
||||
b := model.Node{ID: "b", Kind: "knowledge", Label: "B", Origin: "knowledge-production"}
|
||||
s.UpsertNode(a)
|
||||
s.UpsertNode(b)
|
||||
s.SetVector("a", []float64{1, 0})
|
||||
s.SetVector("b", []float64{.9, .1})
|
||||
if _, _, _, ok := s.BestPair(.5); !ok {
|
||||
t.Fatal("expected candidate pair")
|
||||
}
|
||||
s.UpsertEdge(model.Edge{Source: "a", Target: "b", Type: "related_to", Origin: "ai-inference", Status: "rejected"})
|
||||
if _, _, _, ok := s.BestPair(.5); ok {
|
||||
t.Fatal("rejected pair was selected again")
|
||||
}
|
||||
if got := len(s.Snapshot().Edges); got != 0 {
|
||||
t.Fatalf("rejected edge should not be rendered, got %d", got)
|
||||
}
|
||||
}
|
||||
147
internal/ingest/agent.go
Normal file
147
internal/ingest/agent.go
Normal file
@@ -0,0 +1,147 @@
|
||||
package ingest
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/local/glpi-neural-brain/internal/activity"
|
||||
"github.com/local/glpi-neural-brain/internal/graph"
|
||||
"github.com/local/glpi-neural-brain/internal/model"
|
||||
)
|
||||
|
||||
type AgentWatcher struct {
|
||||
Files []string
|
||||
Graph *graph.Store
|
||||
Broker *activity.Broker
|
||||
mu sync.Mutex
|
||||
offsets map[string]int64
|
||||
}
|
||||
|
||||
func NewAgentWatcher(files []string, g *graph.Store, b *activity.Broker) *AgentWatcher {
|
||||
return &AgentWatcher{Files: files, Graph: g, Broker: b, offsets: map[string]int64{}}
|
||||
}
|
||||
func (w *AgentWatcher) Start(ctx context.Context) {
|
||||
go func() {
|
||||
ticker := time.NewTicker(time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
w.poll()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
func (w *AgentWatcher) poll() {
|
||||
for _, path := range w.Files {
|
||||
_ = w.readNew(path)
|
||||
}
|
||||
}
|
||||
func (w *AgentWatcher) readNew(path string) error {
|
||||
f, err := os.Open(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
w.mu.Lock()
|
||||
off := w.offsets[path]
|
||||
w.mu.Unlock()
|
||||
st, err := f.Stat()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if st.Size() < off {
|
||||
off = 0
|
||||
}
|
||||
if _, err = f.Seek(off, io.SeekStart); err != nil {
|
||||
return err
|
||||
}
|
||||
sc := bufio.NewScanner(f)
|
||||
buf := make([]byte, 64*1024)
|
||||
sc.Buffer(buf, 8<<20)
|
||||
for sc.Scan() {
|
||||
line := append([]byte(nil), sc.Bytes()...)
|
||||
off += int64(len(sc.Bytes()) + 1)
|
||||
w.process(line)
|
||||
}
|
||||
if err := sc.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
w.mu.Lock()
|
||||
w.offsets[path] = off
|
||||
w.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
func (w *AgentWatcher) process(line []byte) {
|
||||
var run map[string]any
|
||||
if json.Unmarshal(line, &run) != nil {
|
||||
return
|
||||
}
|
||||
runID := str(run["run_id"])
|
||||
ticket := str(run["ticket_id"])
|
||||
trigger := str(run["trigger"])
|
||||
outcome := str(run["outcome"])
|
||||
analyses, _ := run["analyses"].([]any)
|
||||
nodeSet := map[string]bool{}
|
||||
var phases []string
|
||||
for _, raw := range analyses {
|
||||
a, _ := raw.(map[string]any)
|
||||
if a == nil {
|
||||
continue
|
||||
}
|
||||
phases = append(phases, str(a["analysis_type"]))
|
||||
collectExternalIDs(a, func(id string) {
|
||||
if n, ok := w.Graph.LookupExternal(id); ok {
|
||||
nodeSet[n.ID] = true
|
||||
}
|
||||
})
|
||||
}
|
||||
var nodeIDs []string
|
||||
for id := range nodeSet {
|
||||
nodeIDs = append(nodeIDs, id)
|
||||
}
|
||||
edgeIDs := w.Graph.ConnectingEdges(nodeIDs)
|
||||
msg := fmt.Sprintf("Agent-Lauf %s · Ticket %s · %s", runID, ticket, outcome)
|
||||
if len(phases) > 0 {
|
||||
msg += " · " + strings.Join(phases, " → ")
|
||||
}
|
||||
w.Broker.Publish(model.Activity{Type: "agent.run", Source: "agent", Phase: trigger, Message: msg, NodeIDs: nodeIDs, EdgeIDs: edgeIDs, Strength: .9, Metadata: map[string]any{"run_id": runID, "ticket_id": ticket, "outcome": outcome}})
|
||||
}
|
||||
func collectExternalIDs(v any, fn func(string)) {
|
||||
switch x := v.(type) {
|
||||
case map[string]any:
|
||||
for k, v := range x {
|
||||
lk := strings.ToLower(k)
|
||||
if strings.Contains(lk, "knowledge_id") || lk == "id" {
|
||||
s := str(v)
|
||||
if len(s) > 2 {
|
||||
fn(s)
|
||||
}
|
||||
}
|
||||
collectExternalIDs(v, fn)
|
||||
}
|
||||
case []any:
|
||||
for _, v := range x {
|
||||
collectExternalIDs(v, fn)
|
||||
}
|
||||
}
|
||||
}
|
||||
func str(v any) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(fmt.Sprint(v))
|
||||
}
|
||||
237
internal/ingest/knowledge.go
Normal file
237
internal/ingest/knowledge.go
Normal file
@@ -0,0 +1,237 @@
|
||||
package ingest
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/local/glpi-neural-brain/internal/graph"
|
||||
"github.com/local/glpi-neural-brain/internal/model"
|
||||
)
|
||||
|
||||
type KnowledgeScanner struct {
|
||||
Graph *graph.Store
|
||||
ProductionDirs []string
|
||||
StagingDirs []string
|
||||
}
|
||||
|
||||
func (s *KnowledgeScanner) Scan() (int, error) {
|
||||
if s.Graph == nil {
|
||||
return 0, fmt.Errorf("graph store is nil")
|
||||
}
|
||||
var nodes []model.Node
|
||||
var edges []model.Edge
|
||||
seen := map[string]int{}
|
||||
for _, root := range s.StagingDirs {
|
||||
n, e, err := scanDir(root, "knowledge-staging", "staging")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for _, x := range n {
|
||||
if _, ok := seen[x.ID]; !ok {
|
||||
seen[x.ID] = len(nodes)
|
||||
nodes = append(nodes, x)
|
||||
}
|
||||
}
|
||||
edges = append(edges, e...)
|
||||
}
|
||||
// Production wins if the same document ID exists in both scopes.
|
||||
for _, root := range s.ProductionDirs {
|
||||
n, e, err := scanDir(root, "knowledge-production", "production")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for _, x := range n {
|
||||
if idx, ok := seen[x.ID]; ok {
|
||||
nodes[idx] = x
|
||||
} else {
|
||||
seen[x.ID] = len(nodes)
|
||||
nodes = append(nodes, x)
|
||||
}
|
||||
}
|
||||
edges = append(edges, e...)
|
||||
}
|
||||
s.Graph.ReplaceOrigins([]string{"knowledge-production", "knowledge-staging", "knowledge-taxonomy"}, nodes, edges)
|
||||
return len(nodes), nil
|
||||
}
|
||||
|
||||
func scanDir(root, origin, status string) ([]model.Node, []model.Edge, error) {
|
||||
if strings.TrimSpace(root) == "" {
|
||||
return nil, nil, nil
|
||||
}
|
||||
if _, err := os.Stat(root); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil, nil
|
||||
}
|
||||
return nil, nil, err
|
||||
}
|
||||
var docs []model.Node
|
||||
categoryNodes := map[string]model.Node{}
|
||||
keywordNodes := map[string]model.Node{}
|
||||
sourceNodes := map[string]model.Node{}
|
||||
var edges []model.Edge
|
||||
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if d.IsDir() {
|
||||
base := strings.ToLower(d.Name())
|
||||
if strings.HasPrefix(base, ".") && path != root {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !strings.EqualFold(filepath.Ext(d.Name()), ".json") {
|
||||
return nil
|
||||
}
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var doc map[string]any
|
||||
if err := json.Unmarshal(b, &doc); err != nil {
|
||||
return fmt.Errorf("decode %s: %w", path, err)
|
||||
}
|
||||
externalID := firstString(doc, "id", "key")
|
||||
if externalID == "" {
|
||||
rel, _ := filepath.Rel(root, path)
|
||||
externalID = filepath.ToSlash(rel)
|
||||
}
|
||||
label := firstString(doc, "title", "name")
|
||||
if label == "" {
|
||||
label = externalID
|
||||
}
|
||||
categories := stringSlice(doc["categories"])
|
||||
keywords := stringSlice(doc["keywords"])
|
||||
source := firstString(doc, "source")
|
||||
uri := firstString(doc, "source_uri", "uri")
|
||||
text := joinNonEmpty(firstString(doc, "text", "problem", "description"), firstString(doc, "answer", "solution"))
|
||||
aiThink := containsFold(categories, "AI-THINK") || strings.Contains(strings.ToLower(source), "ai-think")
|
||||
kind := "knowledge"
|
||||
if aiThink {
|
||||
kind = "ai-think"
|
||||
}
|
||||
nodeID := graph.ID("knowledge", externalID)
|
||||
rel, _ := filepath.Rel(root, path)
|
||||
n := model.Node{
|
||||
ID: nodeID, Kind: kind, Label: label, Summary: clamp(text, 900), Status: status, Origin: origin,
|
||||
ExternalID: externalID, URI: uri, Categories: categories, Keywords: keywords, Weight: 1.3,
|
||||
Metadata: map[string]any{"path": filepath.ToSlash(rel), "source": source, "auto_reply": doc["auto_reply"], "min_score": doc["min_score"]},
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
}
|
||||
docs = append(docs, n)
|
||||
for _, cat := range categories {
|
||||
cat = strings.TrimSpace(cat)
|
||||
if cat == "" {
|
||||
continue
|
||||
}
|
||||
cid := graph.ID("category", strings.ToLower(cat))
|
||||
if _, ok := categoryNodes[cid]; !ok {
|
||||
categoryNodes[cid] = model.Node{ID: cid, Kind: "category", Label: cat, Origin: "knowledge-taxonomy", ExternalID: cat, Weight: 0.75, UpdatedAt: time.Now().UTC()}
|
||||
}
|
||||
edges = append(edges, model.Edge{Source: nodeID, Target: cid, Type: "categorized_as", Origin: origin, Status: "verified", Confidence: 1, Weight: .55})
|
||||
}
|
||||
for _, kw := range keywords {
|
||||
kw = strings.TrimSpace(kw)
|
||||
if kw == "" || len([]rune(kw)) < 3 {
|
||||
continue
|
||||
}
|
||||
kid := graph.ID("keyword", strings.ToLower(kw))
|
||||
if _, ok := keywordNodes[kid]; !ok {
|
||||
keywordNodes[kid] = model.Node{ID: kid, Kind: "concept", Label: kw, Origin: "knowledge-taxonomy", ExternalID: kw, Weight: .55, UpdatedAt: time.Now().UTC()}
|
||||
}
|
||||
edges = append(edges, model.Edge{Source: nodeID, Target: kid, Type: "mentions", Origin: origin, Status: "verified", Confidence: 1, Weight: .28})
|
||||
}
|
||||
if source != "" {
|
||||
sid := graph.ID("source", strings.ToLower(source))
|
||||
if _, ok := sourceNodes[sid]; !ok {
|
||||
sourceNodes[sid] = model.Node{ID: sid, Kind: "source", Label: source, Origin: "knowledge-taxonomy", ExternalID: source, Weight: .6, UpdatedAt: time.Now().UTC()}
|
||||
}
|
||||
edges = append(edges, model.Edge{Source: nodeID, Target: sid, Type: "derived_from", Origin: origin, Status: "verified", Confidence: 1, Weight: .35})
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
for _, m := range []map[string]model.Node{categoryNodes, keywordNodes, sourceNodes} {
|
||||
for _, n := range m {
|
||||
docs = append(docs, n)
|
||||
}
|
||||
}
|
||||
sort.Slice(docs, func(i, j int) bool { return docs[i].ID < docs[j].ID })
|
||||
return docs, edges, nil
|
||||
}
|
||||
|
||||
func firstString(m map[string]any, keys ...string) string {
|
||||
for _, k := range keys {
|
||||
if v, ok := m[k]; ok {
|
||||
if s := strings.TrimSpace(fmt.Sprint(v)); s != "" && s != "<nil>" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
func stringSlice(v any) []string {
|
||||
var out []string
|
||||
switch x := v.(type) {
|
||||
case []any:
|
||||
for _, e := range x {
|
||||
if s := strings.TrimSpace(fmt.Sprint(e)); s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
case []string:
|
||||
out = append(out, x...)
|
||||
case string:
|
||||
for _, s := range strings.Split(x, ",") {
|
||||
if s = strings.TrimSpace(s); s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
return unique(out)
|
||||
}
|
||||
func unique(in []string) []string {
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, s := range in {
|
||||
k := strings.ToLower(strings.TrimSpace(s))
|
||||
if k == "" || seen[k] {
|
||||
continue
|
||||
}
|
||||
seen[k] = true
|
||||
out = append(out, strings.TrimSpace(s))
|
||||
}
|
||||
return out
|
||||
}
|
||||
func containsFold(in []string, want string) bool {
|
||||
for _, s := range in {
|
||||
if strings.EqualFold(strings.TrimSpace(s), want) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
func joinNonEmpty(parts ...string) string {
|
||||
var out []string
|
||||
for _, p := range parts {
|
||||
if p = strings.TrimSpace(p); p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return strings.Join(out, "\n\n")
|
||||
}
|
||||
func clamp(s string, n int) string {
|
||||
r := []rune(strings.TrimSpace(s))
|
||||
if len(r) <= n {
|
||||
return string(r)
|
||||
}
|
||||
return string(r[:n]) + "…"
|
||||
}
|
||||
45
internal/ingest/knowledge_test.go
Normal file
45
internal/ingest/knowledge_test.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package ingest
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/local/glpi-neural-brain/internal/graph"
|
||||
)
|
||||
|
||||
func TestKnowledgeScannerIncludesAIThinkStaging(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
prod := filepath.Join(root, "knowledge")
|
||||
stage := filepath.Join(root, "staging")
|
||||
if err := os.MkdirAll(prod, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(stage, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(prod, "vpn.json"), []byte(`{"id":"KB-VPN","title":"VPN","text":"Gateway","categories":["Netzwerk"],"keywords":["VPN"],"source":"internal-kb"}`), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(stage, "think.json"), []byte(`{"id":"KB-AI-1","title":"VPN Zusammenhang","text":"Synthese","categories":["AI-THINK"],"keywords":["VPN"],"source":"Neural Brain"}`), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g, _ := graph.Open(filepath.Join(root, "data"))
|
||||
s := KnowledgeScanner{Graph: g, ProductionDirs: []string{prod}, StagingDirs: []string{stage}}
|
||||
if _, err := s.Scan(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
snap := g.Snapshot()
|
||||
var prodOK, thinkOK bool
|
||||
for _, n := range snap.Nodes {
|
||||
if n.ExternalID == "KB-VPN" && n.Status == "production" {
|
||||
prodOK = true
|
||||
}
|
||||
if n.ExternalID == "KB-AI-1" && n.Kind == "ai-think" && n.Status == "staging" {
|
||||
thinkOK = true
|
||||
}
|
||||
}
|
||||
if !prodOK || !thinkOK {
|
||||
t.Fatalf("missing nodes: prod=%v think=%v", prodOK, thinkOK)
|
||||
}
|
||||
}
|
||||
144
internal/model/model.go
Normal file
144
internal/model/model.go
Normal file
@@ -0,0 +1,144 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
type Node struct {
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
Label string `json:"label"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Origin string `json:"origin"`
|
||||
ExternalID string `json:"external_id,omitempty"`
|
||||
URI string `json:"uri,omitempty"`
|
||||
Categories []string `json:"categories,omitempty"`
|
||||
Keywords []string `json:"keywords,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
Weight float64 `json:"weight,omitempty"`
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
Z float64 `json:"z"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type Evidence struct {
|
||||
NodeID string `json:"node_id,omitempty"`
|
||||
URI string `json:"uri,omitempty"`
|
||||
Excerpt string `json:"excerpt,omitempty"`
|
||||
}
|
||||
|
||||
type Edge struct {
|
||||
ID string `json:"id"`
|
||||
Source string `json:"source"`
|
||||
Target string `json:"target"`
|
||||
Type string `json:"type"`
|
||||
Origin string `json:"origin"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Confidence float64 `json:"confidence,omitempty"`
|
||||
Weight float64 `json:"weight,omitempty"`
|
||||
Explanation string `json:"explanation,omitempty"`
|
||||
Evidence []Evidence `json:"evidence,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type Hub struct {
|
||||
NodeID string `json:"node_id"`
|
||||
Label string `json:"label"`
|
||||
Kind string `json:"kind"`
|
||||
Degree int `json:"degree"`
|
||||
}
|
||||
|
||||
type GraphAnalysis struct {
|
||||
NodeCount int `json:"node_count"`
|
||||
EdgeCount int `json:"edge_count"`
|
||||
Components int `json:"components"`
|
||||
KnowledgeOrphans int `json:"knowledge_orphans"`
|
||||
StagingNodes int `json:"staging_nodes"`
|
||||
AIThinkNodes int `json:"ai_think_nodes"`
|
||||
ExternalNodes int `json:"external_nodes"`
|
||||
AIEdges int `json:"ai_edges"`
|
||||
Contradictions int `json:"contradictions"`
|
||||
TopHubs []Hub `json:"top_hubs"`
|
||||
}
|
||||
|
||||
type Snapshot struct {
|
||||
Version uint64 `json:"version"`
|
||||
Nodes []Node `json:"nodes"`
|
||||
Edges []Edge `json:"edges"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type Hit struct {
|
||||
NodeID string `json:"node_id"`
|
||||
Label string `json:"label"`
|
||||
Score float64 `json:"score"`
|
||||
Kind string `json:"kind"`
|
||||
Status string `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
type Activity struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Source string `json:"source"`
|
||||
Phase string `json:"phase,omitempty"`
|
||||
Query string `json:"query,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
NodeIDs []string `json:"node_ids,omitempty"`
|
||||
EdgeIDs []string `json:"edge_ids,omitempty"`
|
||||
Strength float64 `json:"strength,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
}
|
||||
|
||||
type ExternalEvent struct {
|
||||
Type string `json:"type"`
|
||||
Source string `json:"source"`
|
||||
Query string `json:"query,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Hits []ExternalHit `json:"hits,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
type ExternalHit struct {
|
||||
ID string `json:"id"`
|
||||
Score float64 `json:"score,omitempty"`
|
||||
}
|
||||
|
||||
type QueryRequest struct {
|
||||
Query string `json:"query"`
|
||||
}
|
||||
|
||||
type QueryResponse struct {
|
||||
Query string `json:"query"`
|
||||
Answer string `json:"answer"`
|
||||
Hits []Hit `json:"hits"`
|
||||
UsedNodeIDs []string `json:"used_node_ids,omitempty"`
|
||||
Uncertainties []string `json:"uncertainties,omitempty"`
|
||||
DurationMS int64 `json:"duration_ms"`
|
||||
}
|
||||
|
||||
type RelationDecision struct {
|
||||
Related bool `json:"related"`
|
||||
RelationType string `json:"relation_type"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
Explanation string `json:"explanation"`
|
||||
NeedsResearch bool `json:"needs_research"`
|
||||
ResearchQuery string `json:"research_query"`
|
||||
Title string `json:"title"`
|
||||
Synthesis string `json:"synthesis"`
|
||||
Keywords []string `json:"keywords"`
|
||||
}
|
||||
|
||||
type AnswerDecision struct {
|
||||
Answer string `json:"answer"`
|
||||
UsedNodeIDs []string `json:"used_node_ids"`
|
||||
Uncertainties []string `json:"uncertainties"`
|
||||
}
|
||||
|
||||
type ResearchResult struct {
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
94
internal/ollama/client.go
Normal file
94
internal/ollama/client.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package ollama
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
BaseURL, ChatModel, EmbeddingModel string
|
||||
HTTP *http.Client
|
||||
}
|
||||
|
||||
func New(base, chat, embed string) *Client {
|
||||
return &Client{BaseURL: strings.TrimRight(base, "/"), ChatModel: chat, EmbeddingModel: embed, HTTP: &http.Client{Timeout: 8 * time.Minute}}
|
||||
}
|
||||
func (c *Client) Embed(ctx context.Context, texts []string) ([][]float64, error) {
|
||||
body := map[string]any{"model": c.EmbeddingModel, "input": texts, "truncate": true}
|
||||
var out struct {
|
||||
Embeddings [][]float64 `json:"embeddings"`
|
||||
}
|
||||
if err := c.post(ctx, "/api/embed", body, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(out.Embeddings) != len(texts) {
|
||||
return nil, fmt.Errorf("ollama returned %d embeddings for %d inputs", len(out.Embeddings), len(texts))
|
||||
}
|
||||
return out.Embeddings, nil
|
||||
}
|
||||
func (c *Client) ChatJSON(ctx context.Context, system, user string, schema any, target any) error {
|
||||
body := map[string]any{"model": c.ChatModel, "messages": []map[string]string{{"role": "system", "content": system}, {"role": "user", "content": user}}, "stream": false, "think": false, "format": schema, "options": map[string]any{"temperature": 0}}
|
||||
var env struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"message"`
|
||||
}
|
||||
if err := c.post(ctx, "/api/chat", body, &env); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(env.Message.Content) == "" {
|
||||
return fmt.Errorf("empty Ollama response")
|
||||
}
|
||||
if err := json.Unmarshal([]byte(env.Message.Content), target); err != nil {
|
||||
return fmt.Errorf("decode structured response: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (c *Client) Ping(ctx context.Context) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+"/api/tags", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := c.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode/100 != 2 {
|
||||
return fmt.Errorf("ollama HTTP %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (c *Client) post(ctx context.Context, path string, in, out any) error {
|
||||
b, err := json.Marshal(in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+path, bytes.NewReader(b))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := c.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, 16<<20))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.StatusCode/100 != 2 {
|
||||
return fmt.Errorf("ollama %s HTTP %d: %s", path, resp.StatusCode, strings.TrimSpace(string(data)))
|
||||
}
|
||||
if err := json.Unmarshal(data, out); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
61
internal/research/searxng.go
Normal file
61
internal/research/searxng.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package research
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/local/glpi-neural-brain/internal/model"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
BaseURL string
|
||||
HTTP *http.Client
|
||||
}
|
||||
|
||||
func New(base string) *Client {
|
||||
return &Client{BaseURL: strings.TrimRight(base, "/"), HTTP: &http.Client{Timeout: 45 * time.Second}}
|
||||
}
|
||||
func (c *Client) Search(ctx context.Context, q string, limit int) ([]model.ResearchResult, error) {
|
||||
if c == nil || c.BaseURL == "" {
|
||||
return nil, nil
|
||||
}
|
||||
u := c.BaseURL + "/search?format=json&language=de-DE&q=" + url.QueryEscape(q)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := c.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode/100 != 2 {
|
||||
return nil, fmt.Errorf("searxng HTTP %d", resp.StatusCode)
|
||||
}
|
||||
var env struct {
|
||||
Results []struct {
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
Content string `json:"content"`
|
||||
} `json:"results"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := []model.ResearchResult{}
|
||||
for _, r := range env.Results {
|
||||
if strings.TrimSpace(r.URL) == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, model.ResearchResult{Title: r.Title, URL: r.URL, Content: r.Content})
|
||||
if limit > 0 && len(out) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
23
internal/research/searxng_test.go
Normal file
23
internal/research/searxng_test.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package research
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSearchParsesSearXNG(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"results":[{"title":"Vendor note","url":"https://example.test/a","content":"Relevant evidence"}]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
got, err := New(srv.URL).Search(context.Background(), "vpn", 3)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 1 || got[0].URL != "https://example.test/a" || got[0].Content != "Relevant evidence" {
|
||||
t.Fatalf("unexpected result: %#v", got)
|
||||
}
|
||||
}
|
||||
149
internal/web/server.go
Normal file
149
internal/web/server.go
Normal file
@@ -0,0 +1,149 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/local/glpi-neural-brain/internal/activity"
|
||||
"github.com/local/glpi-neural-brain/internal/engine"
|
||||
"github.com/local/glpi-neural-brain/internal/graph"
|
||||
"github.com/local/glpi-neural-brain/internal/model"
|
||||
)
|
||||
|
||||
//go:embed static/*
|
||||
var assets embed.FS
|
||||
|
||||
type Server struct {
|
||||
Engine *engine.Engine
|
||||
Graph *graph.Store
|
||||
Broker *activity.Broker
|
||||
APIKey string
|
||||
}
|
||||
|
||||
func (s *Server) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /api/status", s.handleStatus)
|
||||
mux.HandleFunc("GET /api/graph", s.handleGraph)
|
||||
mux.HandleFunc("GET /api/analysis", s.handleAnalysis)
|
||||
mux.HandleFunc("GET /api/stream", s.Broker.ServeSSE)
|
||||
mux.HandleFunc("POST /api/query", s.handleQuery)
|
||||
mux.HandleFunc("POST /api/events", s.handleEvent)
|
||||
mux.HandleFunc("POST /api/reindex", s.handleReindex)
|
||||
mux.HandleFunc("POST /api/enrich", s.handleEnrich)
|
||||
sub, _ := fs.Sub(assets, "static")
|
||||
mux.Handle("GET /", http.FileServer(http.FS(sub)))
|
||||
return s.headers(mux)
|
||||
}
|
||||
func (s *Server) headers(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("Referrer-Policy", "no-referrer")
|
||||
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
|
||||
w.Header().Set("Content-Security-Policy", "default-src 'self'; style-src 'self'; script-src 'self'; connect-src 'self'; img-src 'self' data:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, 200, s.Engine.Status())
|
||||
}
|
||||
func (s *Server) handleGraph(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, 200, s.Graph.Snapshot())
|
||||
}
|
||||
func (s *Server) handleAnalysis(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, 200, s.Graph.Analyze())
|
||||
}
|
||||
func (s *Server) handleQuery(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.authorized(r) {
|
||||
writeJSON(w, 401, map[string]string{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
var req model.QueryRequest
|
||||
if err := decode(r, &req); err != nil {
|
||||
writeJSON(w, 400, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
ctx, cancel := contextTimeout(r, 8*time.Minute)
|
||||
defer cancel()
|
||||
out, err := s.Engine.Query(ctx, req.Query)
|
||||
if err != nil {
|
||||
writeJSON(w, 400, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, out)
|
||||
}
|
||||
func (s *Server) handleEvent(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.authorized(r) {
|
||||
writeJSON(w, 401, map[string]string{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
var in model.ExternalEvent
|
||||
if err := decode(r, &in); err != nil {
|
||||
writeJSON(w, 400, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
var ids []string
|
||||
for _, h := range in.Hits {
|
||||
if n, ok := s.Graph.LookupExternal(h.ID); ok {
|
||||
ids = append(ids, n.ID)
|
||||
}
|
||||
}
|
||||
s.Broker.Publish(model.Activity{Type: nonempty(in.Type, "external.event"), Source: nonempty(in.Source, "external"), Phase: "external", Query: in.Query, Message: in.Message, NodeIDs: ids, EdgeIDs: s.Graph.ConnectingEdges(ids), Strength: .9, Metadata: in.Metadata})
|
||||
writeJSON(w, 202, map[string]any{"ok": true, "resolved_nodes": len(ids)})
|
||||
}
|
||||
func (s *Server) handleReindex(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.authorized(r) {
|
||||
writeJSON(w, 401, map[string]string{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
ctx, cancel := contextTimeout(r, 10*time.Minute)
|
||||
defer cancel()
|
||||
if err := s.Engine.Scan(ctx); err != nil {
|
||||
writeJSON(w, 500, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, s.Engine.Status())
|
||||
}
|
||||
func (s *Server) handleEnrich(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.authorized(r) {
|
||||
writeJSON(w, 401, map[string]string{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
ctx, cancel := contextTimeout(r, 10*time.Minute)
|
||||
defer cancel()
|
||||
if err := s.Engine.EnrichOne(ctx); err != nil {
|
||||
writeJSON(w, 500, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, s.Engine.Status())
|
||||
}
|
||||
func (s *Server) authorized(r *http.Request) bool {
|
||||
if s.APIKey == "" {
|
||||
return true
|
||||
}
|
||||
v := strings.TrimSpace(r.Header.Get("Authorization"))
|
||||
return v == "Bearer "+s.APIKey || r.Header.Get("X-Brain-Key") == s.APIKey
|
||||
}
|
||||
func decode(r *http.Request, v any) error {
|
||||
return json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(v)
|
||||
}
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
func nonempty(a, b string) string {
|
||||
if strings.TrimSpace(a) != "" {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func contextTimeout(r *http.Request, d time.Duration) (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(r.Context(), d)
|
||||
}
|
||||
27
internal/web/static/app.css
Normal file
27
internal/web/static/app.css
Normal file
@@ -0,0 +1,27 @@
|
||||
:root{color-scheme:dark;--bg:#02050b;--panel:rgba(5,12,24,.66);--line:rgba(133,200,255,.16);--text:#eaf7ff;--muted:#7792a8;--cyan:#52e7ff;--blue:#4b7bff;--violet:#b775ff;--amber:#ffb452;--green:#5dffbd;--red:#ff5f88}
|
||||
*{box-sizing:border-box}
|
||||
html,body{margin:0;width:100%;height:100%;overflow:hidden;background:radial-gradient(circle at 50% 44%,#07192d 0,#02050b 58%,#010207 100%);font-family:Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,sans-serif;color:var(--text)}
|
||||
#brain{position:fixed;inset:0;width:100%;height:100%;cursor:grab}#brain:active{cursor:grabbing}
|
||||
.vignette{pointer-events:none;position:fixed;inset:0;background:radial-gradient(circle at center,transparent 48%,rgba(0,0,0,.74) 100%),linear-gradient(rgba(82,231,255,.016) 1px,transparent 1px);background-size:auto,100% 4px;mix-blend-mode:screen}
|
||||
.glass{border:1px solid var(--line);background:linear-gradient(145deg,rgba(9,22,40,.8),rgba(3,8,17,.58));backdrop-filter:blur(18px);box-shadow:inset 0 1px rgba(255,255,255,.035),0 18px 60px rgba(0,0,0,.28)}
|
||||
.topbar{position:fixed;z-index:4;left:18px;right:18px;top:18px;height:64px;border-radius:18px;display:flex;align-items:center;justify-content:space-between;padding:0 20px}
|
||||
.brand{display:flex;gap:13px;align-items:center}.brand strong{display:block;letter-spacing:.18em;font-size:13px}.brand small{display:block;color:var(--muted);letter-spacing:.08em;font-size:10px;margin-top:4px}
|
||||
.mark{position:relative;width:34px;height:34px;display:block}.mark:before,.mark:after,.mark i{content:"";position:absolute;border:1px solid var(--cyan);border-radius:50%;box-shadow:0 0 14px rgba(82,231,255,.7)}.mark:before{width:8px;height:8px;left:13px;top:13px;background:var(--cyan)}.mark:after{width:28px;height:28px;left:2px;top:2px;opacity:.35}.mark i{width:4px;height:4px}.mark i:nth-child(1){left:1px;top:15px}.mark i:nth-child(2){right:1px;top:5px}.mark i:nth-child(3){right:3px;bottom:3px}
|
||||
.metrics{display:flex;gap:18px;align-items:center;font-size:11px;color:var(--muted);letter-spacing:.06em}.metrics b{font-size:15px;color:var(--text);font-variant-numeric:tabular-nums}.state{border-left:1px solid var(--line);padding-left:18px;color:var(--green)}.state i{display:inline-block;width:7px;height:7px;border-radius:50%;background:currentColor;box-shadow:0 0 12px currentColor;margin-right:6px;animation:pulse 1.6s infinite}@keyframes pulse{50%{opacity:.35;transform:scale(.72)}}
|
||||
.activity{position:fixed;z-index:4;left:18px;top:98px;bottom:18px;width:340px;border-radius:18px;padding:14px;display:flex;flex-direction:column}
|
||||
.panel-title{height:34px;display:flex;align-items:center;justify-content:space-between;color:#9fb6c8;font-size:10px;font-weight:800;letter-spacing:.19em;border-bottom:1px solid var(--line);margin-bottom:10px}
|
||||
.panel-actions{display:flex;align-items:center;gap:8px}.panel-title button{border:0;background:transparent;padding:2px 7px;font-size:18px;color:var(--muted);cursor:pointer}.chip{padding:4px 7px;border-radius:99px;background:rgba(82,231,255,.08);color:var(--cyan);letter-spacing:.08em}
|
||||
.panel-subtitle{font-size:11px;line-height:1.45;color:#7f99ad;margin-bottom:10px}
|
||||
.activity-log{flex:1;overflow:hidden;display:flex;flex-direction:column-reverse;justify-content:flex-start;gap:8px}
|
||||
.activity-item{border-left:2px solid var(--cyan);padding:9px 10px;background:rgba(255,255,255,.028);border-radius:0 12px 12px 0;animation:enter .25s ease}.activity-item.agent{border-color:var(--violet)}.activity-item.think{border-color:var(--amber)}.activity-item.research{border-color:var(--green)}.activity-item.graph{border-color:var(--blue)}.activity-item b{font-size:10px;letter-spacing:.1em;text-transform:uppercase;display:block;padding-right:54px}.activity-item time{float:right;color:#526a7e;font-size:9px}.activity-item p{margin:5px 0 0;color:#d8e6f1;font-size:11px;line-height:1.42}.activity-item .meta{display:flex;flex-wrap:wrap;gap:5px;margin-top:6px}.activity-item .meta span{font-size:9px;padding:3px 6px;border-radius:999px;border:1px solid rgba(82,231,255,.12);background:rgba(82,231,255,.05);color:#98b3c6}.activity-item .query{margin-top:6px;font-size:10px;color:#8db8c7;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}.activity-item.think .meta span{border-color:rgba(255,180,82,.18);background:rgba(255,180,82,.07);color:#ffd7a0}.activity-item.research .meta span{border-color:rgba(93,255,189,.18);background:rgba(93,255,189,.07);color:#baffde}.activity-item.agent .meta span{border-color:rgba(183,117,255,.18);background:rgba(183,117,255,.07);color:#d8bbff}@keyframes enter{from{opacity:0;transform:translateX(-8px)}}
|
||||
.dock{position:fixed;z-index:5;bottom:18px;left:50%;transform:translateX(-50%);display:flex;gap:5px;border-radius:15px;padding:6px}.dock button{border:1px solid transparent;background:transparent;color:#6e8799;border-radius:11px;padding:9px 11px;font-weight:700;letter-spacing:.08em;font-size:10px;cursor:pointer}.dock button.active{color:var(--cyan);border-color:rgba(82,231,255,.18);background:rgba(82,231,255,.07)}.dock button:hover{background:rgba(82,231,255,.08)}
|
||||
.legend{position:fixed;z-index:4;right:18px;bottom:18px;border-radius:16px;padding:10px 12px;display:grid;grid-template-columns:1fr 1fr;gap:6px 12px;font-size:10px;color:var(--muted)}.legend i{display:inline-block;width:6px;height:6px;border-radius:50%;margin-right:5px;box-shadow:0 0 8px currentColor}.legend .prod{background:var(--cyan)}.legend .think{background:var(--amber)}.legend .stage{background:var(--violet)}.legend .external{background:var(--green)}
|
||||
.tooltip{position:fixed;z-index:10;pointer-events:none;background:rgba(2,8,16,.94);border:1px solid rgba(82,231,255,.25);border-radius:10px;padding:8px 10px;font-size:10px;color:#dcecf5;max-width:250px;box-shadow:0 0 30px rgba(0,0,0,.4)}.tooltip strong{display:block;font-size:11px;color:#eef9ff;margin-bottom:3px}.tooltip small{display:block;color:#84a0b3;line-height:1.4}.hidden{display:none!important}
|
||||
@media(max-width:1100px){.activity{width:300px}.metrics span:not(.state){display:none}}
|
||||
@media(max-width:780px){.activity{left:10px;right:10px;width:auto;top:auto;bottom:72px;height:42vh}.topbar{left:10px;right:10px;top:10px}.legend{display:none}.dock{bottom:10px;max-width:calc(100vw - 20px);overflow:auto}.metrics span:not(.state){display:none}}
|
||||
|
||||
/* automatische Visualzustände */
|
||||
.topbar{display:grid;grid-template-columns:minmax(260px,1fr) auto minmax(300px,1fr);gap:18px}.metrics{justify-self:end}.mode-status{justify-self:center;display:flex;align-items:center;gap:9px;min-width:174px;padding:7px 12px;border:1px solid rgba(82,231,255,.16);border-radius:12px;background:rgba(82,231,255,.045);transition:border-color .35s,background .35s,box-shadow .35s}.mode-status>i{width:9px;height:9px;border-radius:50%;background:var(--cyan);box-shadow:0 0 16px var(--cyan);animation:modeBreath 2.8s ease-in-out infinite}.mode-status b{display:block;font-size:10px;letter-spacing:.16em;color:#dffaff}.mode-status small{display:block;margin-top:2px;font-size:9px;letter-spacing:.05em;color:#6f8da2}.mode-status.thinking{border-color:rgba(255,180,82,.34);background:rgba(255,180,82,.08);box-shadow:0 0 30px rgba(255,180,82,.08)}.mode-status.thinking>i{background:var(--amber);box-shadow:0 0 18px var(--amber);animation-duration:.72s}.mode-status.researching{border-color:rgba(93,255,189,.34);background:rgba(93,255,189,.08);box-shadow:0 0 30px rgba(93,255,189,.08)}.mode-status.researching>i{background:var(--green);box-shadow:0 0 18px var(--green);animation-duration:.82s}.mode-status.processing{border-color:rgba(183,117,255,.34);background:rgba(183,117,255,.08);box-shadow:0 0 30px rgba(183,117,255,.08)}.mode-status.processing>i{background:var(--violet);box-shadow:0 0 18px var(--violet);animation-duration:.9s}.mode-status.learning{border-color:rgba(75,123,255,.34);background:rgba(75,123,255,.08)}.mode-status.learning>i{background:var(--blue);box-shadow:0 0 18px var(--blue);animation-duration:1.1s}@keyframes modeBreath{50%{opacity:.42;transform:scale(.72)}}
|
||||
.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}}
|
||||
998
internal/web/static/app.js
Normal file
998
internal/web/static/app.js
Normal file
@@ -0,0 +1,998 @@
|
||||
(() => {
|
||||
'use strict';
|
||||
|
||||
const canvas = document.getElementById('brain');
|
||||
const ctx = canvas.getContext('2d', {alpha: false});
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const GOLDEN = Math.PI * (3 - Math.sqrt(5));
|
||||
const CORTEX_PALETTE = [
|
||||
[82, 231, 255], [145, 126, 255], [255, 105, 178], [93, 255, 189],
|
||||
[255, 180, 82], [75, 143, 255], [196, 117, 255], [255, 102, 92],
|
||||
[135, 231, 105], [78, 207, 224], [235, 215, 98], [151, 205, 255]
|
||||
];
|
||||
const MODE_CONFIG = {
|
||||
living: {label: 'LIVING', detail: 'ruhige Eigenaktivität', className: 'living', color: [82, 231, 255], duration: 0},
|
||||
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}
|
||||
};
|
||||
|
||||
const state = {
|
||||
nodes: [], edges: [], clusters: [], clusterByKey: new Map(), nodeById: new Map(), edgeById: new Map(), adjacency: new Map(),
|
||||
active: new Map(), edgeActive: new Map(), particles: [], waves: [], pulses: 0,
|
||||
yaw: 0.18, pitch: -0.12, zoom: 1.02, autoRotate: true, labels: true, edgesVisible: true, cortexVisible: true,
|
||||
dragging: false, moved: false, lastX: 0, lastY: 0, hover: null, selected: null, projected: [],
|
||||
width: innerWidth, height: innerHeight, dpr: Math.min(devicePixelRatio || 1, 2), last: performance.now(),
|
||||
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: []
|
||||
};
|
||||
|
||||
function resize() {
|
||||
state.width = innerWidth;
|
||||
state.height = innerHeight;
|
||||
state.dpr = Math.min(devicePixelRatio || 1, 2);
|
||||
canvas.width = Math.floor(state.width * state.dpr);
|
||||
canvas.height = Math.floor(state.height * state.dpr);
|
||||
canvas.style.width = state.width + 'px';
|
||||
canvas.style.height = state.height + 'px';
|
||||
ctx.setTransform(state.dpr, 0, 0, state.dpr, 0, 0);
|
||||
}
|
||||
addEventListener('resize', resize);
|
||||
resize();
|
||||
|
||||
async function api(path, options = {}) {
|
||||
const res = await fetch(path, {headers: {'Content-Type': 'application/json', ...(options.headers || {})}, ...options});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
async function loadGraph() {
|
||||
try {
|
||||
const snap = await api('/api/graph');
|
||||
const old = state.nodeById;
|
||||
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]));
|
||||
state.edgeById = new Map(state.edges.map(e => [e.id, e]));
|
||||
state.adjacency = new Map();
|
||||
for (const e of state.edges) {
|
||||
if (!state.adjacency.has(e.source)) state.adjacency.set(e.source, []);
|
||||
if (!state.adjacency.has(e.target)) state.adjacency.set(e.target, []);
|
||||
state.adjacency.get(e.source).push(e);
|
||||
state.adjacency.get(e.target).push(e);
|
||||
}
|
||||
buildLayout();
|
||||
$('nodeCount').textContent = state.nodes.length.toLocaleString('de-DE');
|
||||
$('edgeCount').textContent = state.edges.length.toLocaleString('de-DE');
|
||||
} catch {
|
||||
setSystem('offline', false);
|
||||
}
|
||||
}
|
||||
|
||||
function setSystem(text, ok = true) {
|
||||
$('systemState').lastChild.textContent = ' ' + text;
|
||||
$('systemState').style.color = ok ? 'var(--green)' : 'var(--red)';
|
||||
}
|
||||
|
||||
function hashString(value) {
|
||||
let h = 2166136261;
|
||||
const s = String(value || '');
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
h ^= s.charCodeAt(i);
|
||||
h = Math.imul(h, 16777619);
|
||||
}
|
||||
return h >>> 0;
|
||||
}
|
||||
|
||||
function fract(v) {
|
||||
return v - Math.floor(v);
|
||||
}
|
||||
|
||||
function pseudo(value, offset = 0) {
|
||||
const h = hashString(value + ':' + offset);
|
||||
return fract(Math.sin(h * 0.00000137 + offset * 12.345) * 43758.5453123);
|
||||
}
|
||||
|
||||
function hsvToRgb(h, s, v) {
|
||||
const c = v * s;
|
||||
const hh = (h % 360) / 60;
|
||||
const x = c * (1 - Math.abs(hh % 2 - 1));
|
||||
let r = 0, g = 0, b = 0;
|
||||
if (hh >= 0 && hh < 1) [r, g, b] = [c, x, 0];
|
||||
else if (hh < 2) [r, g, b] = [x, c, 0];
|
||||
else if (hh < 3) [r, g, b] = [0, c, x];
|
||||
else if (hh < 4) [r, g, b] = [0, x, c];
|
||||
else if (hh < 5) [r, g, b] = [x, 0, c];
|
||||
else [r, g, b] = [c, 0, x];
|
||||
const m = v - c;
|
||||
return [Math.round((r + m) * 255), Math.round((g + m) * 255), Math.round((b + m) * 255)];
|
||||
}
|
||||
|
||||
function explicitClusterKey(node) {
|
||||
const cats = (node.categories || []).filter(Boolean);
|
||||
const preferred = cats.find(c => !/^(AI-THINK|AI-Staging|Staging|Produktiv)$/i.test(c));
|
||||
if (preferred) return preferred;
|
||||
if (node.kind === 'category' && node.label) return node.label;
|
||||
if (node.kind === 'ai-think') return 'AI-THINK';
|
||||
return '';
|
||||
}
|
||||
|
||||
function categoryKey(node) {
|
||||
const explicit = explicitClusterKey(node);
|
||||
if (explicit) return explicit;
|
||||
if (node.kind === 'external') return 'Recherche';
|
||||
if (node.kind === 'source') return 'Quellen';
|
||||
if (node.kind === 'concept') return 'Konzepte';
|
||||
return `${node.kind || 'node'}:${node.status || 'live'}`;
|
||||
}
|
||||
|
||||
function clusterColorFor(key) {
|
||||
const hue = hashString(key) % 360;
|
||||
return hsvToRgb(hue, 0.48, 1);
|
||||
}
|
||||
|
||||
function nodeColor(node, alpha = 1) {
|
||||
let c = node.clusterColor || [82, 231, 255];
|
||||
if (node.kind === 'ai-think' || (node.categories || []).some(x => String(x).toUpperCase() === 'AI-THINK')) c = [255, 180, 82];
|
||||
else if (node.status === 'staging') c = [183, 117, 255];
|
||||
else if (node.kind === 'external') c = [93, 255, 189];
|
||||
else if (node.kind === 'category') c = [75, 123, 255];
|
||||
else if (node.kind === 'source') c = [255, 95, 136];
|
||||
return `rgba(${c[0]},${c[1]},${c[2]},${alpha})`;
|
||||
}
|
||||
|
||||
function clusterFill(cluster, alpha = 1) {
|
||||
const c = cluster.color || [82, 231, 255];
|
||||
return `rgba(${c[0]},${c[1]},${c[2]},${alpha})`;
|
||||
}
|
||||
|
||||
function insideBrain(x, y, z) {
|
||||
const fissure = Math.abs(x) < 0.045 && y > -0.58 && y < 0.42;
|
||||
if (fissure) return false;
|
||||
const taperY = y + Math.abs(z) * 0.10 - Math.max(0, Math.abs(x) - 0.58) * 0.18;
|
||||
const lx = (x + 0.35) / 0.58;
|
||||
const rx = (x - 0.35) / 0.58;
|
||||
const ny = taperY / 0.76;
|
||||
const nz = z / 0.58;
|
||||
const left = lx * lx + ny * ny + nz * nz <= 1;
|
||||
const right = rx * rx + ny * ny + nz * nz <= 1;
|
||||
const stemCut = y < -0.76 || y > 0.82;
|
||||
return !stemCut && (left || right);
|
||||
}
|
||||
|
||||
function clampBrain(point, sideHint) {
|
||||
const out = {x: point.x, y: point.y, z: point.z};
|
||||
const centerX = sideHint === 'left' ? -0.36 : sideHint === 'right' ? 0.36 : (out.x < 0 ? -0.36 : 0.36);
|
||||
for (let i = 0; i < 18 && !insideBrain(out.x, out.y, out.z); i++) {
|
||||
out.x = centerX + (out.x - centerX) * 0.88;
|
||||
out.y *= 0.93;
|
||||
out.z *= 0.93;
|
||||
}
|
||||
if (sideHint === 'left' && out.x > -0.06) out.x = -0.06 - Math.abs(out.x) * 0.1;
|
||||
if (sideHint === 'right' && out.x < 0.06) out.x = 0.06 + Math.abs(out.x) * 0.1;
|
||||
out.x = Math.max(-0.95, Math.min(0.95, out.x));
|
||||
out.y = Math.max(-0.9, Math.min(0.9, out.y));
|
||||
out.z = Math.max(-0.72, Math.min(0.72, out.z));
|
||||
return out;
|
||||
}
|
||||
|
||||
function buildLayout() {
|
||||
const degree = new Map(state.nodes.map(n => [n.id, (state.adjacency.get(n.id) || []).length]));
|
||||
const labels = new Map();
|
||||
const locked = new Set();
|
||||
for (const node of state.nodes) {
|
||||
const explicit = explicitClusterKey(node);
|
||||
if (explicit) {
|
||||
labels.set(node.id, explicit);
|
||||
locked.add(node.id);
|
||||
}
|
||||
}
|
||||
for (let round = 0; round < 4; round++) {
|
||||
const pending = [];
|
||||
for (const node of state.nodes) {
|
||||
if (locked.has(node.id)) continue;
|
||||
const votes = new Map();
|
||||
for (const edge of state.adjacency.get(node.id) || []) {
|
||||
const otherID = edge.source === node.id ? edge.target : edge.source;
|
||||
const label = labels.get(otherID);
|
||||
if (!label) continue;
|
||||
const trust = edge.origin === 'ai-inference' ? 0.72 : edge.type === 'categorized_as' ? 1.35 : 1;
|
||||
const score = Math.max(0.05, edge.weight || 1) * trust;
|
||||
votes.set(label, (votes.get(label) || 0) + score);
|
||||
}
|
||||
let best = '', bestScore = 0;
|
||||
for (const [label, score] of votes) {
|
||||
if (score > bestScore) { best = label; bestScore = score; }
|
||||
}
|
||||
if (best) pending.push([node.id, best]);
|
||||
}
|
||||
for (const [id, label] of pending) labels.set(id, label);
|
||||
if (!pending.length) break;
|
||||
}
|
||||
|
||||
const clusters = new Map();
|
||||
for (const n of state.nodes) {
|
||||
const key = labels.get(n.id) || categoryKey(n);
|
||||
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()};
|
||||
clusters.set(key, cluster);
|
||||
}
|
||||
cluster.nodes.push(n);
|
||||
cluster.size++;
|
||||
cluster.mass += 1 + Math.min(10, Math.sqrt(degree.get(n.id) || 0));
|
||||
}
|
||||
|
||||
for (const e of state.edges) {
|
||||
const a = state.nodeById.get(e.source);
|
||||
const b = state.nodeById.get(e.target);
|
||||
if (!a || !b || a.clusterKey === b.clusterKey) continue;
|
||||
const c1 = clusters.get(a.clusterKey);
|
||||
const c2 = clusters.get(b.clusterKey);
|
||||
c1.links.set(c2.key, (c1.links.get(c2.key) || 0) + (e.weight || 1));
|
||||
c2.links.set(c1.key, (c2.links.get(c1.key) || 0) + (e.weight || 1));
|
||||
}
|
||||
|
||||
const clusterList = Array.from(clusters.values()).sort((a, b) => b.mass - a.mass || a.key.localeCompare(b.key));
|
||||
clusterList.forEach((cluster, index) => {
|
||||
cluster.color = CORTEX_PALETTE[index % CORTEX_PALETTE.length];
|
||||
cluster.importance = index < 12 ? 1 : Math.max(0.25, 1 - index / Math.max(1, clusterList.length));
|
||||
cluster.screen = null;
|
||||
});
|
||||
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';
|
||||
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));
|
||||
}
|
||||
|
||||
seedHemis(left, -1);
|
||||
seedHemis(right, 1);
|
||||
relaxClusters(clusterList);
|
||||
placeNodes(clusterList, degree);
|
||||
state.clusters = clusterList;
|
||||
state.clusterByKey = new Map(clusterList.map(cluster => [cluster.key, cluster]));
|
||||
}
|
||||
|
||||
function seedHemis(list, sign) {
|
||||
const total = Math.max(1, list.length);
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
const cluster = list[i];
|
||||
const t = (i + 0.6) / total;
|
||||
const ring = Math.sqrt(t);
|
||||
const angle = i * GOLDEN;
|
||||
const x = sign * (0.18 + 0.26 * (0.25 + ring * 0.75));
|
||||
const y = Math.cos(angle) * 0.56 * ring;
|
||||
const z = Math.sin(angle) * 0.46 * ring;
|
||||
Object.assign(cluster, clampBrain({x, y, z}, sign < 0 ? 'left' : 'right'));
|
||||
}
|
||||
}
|
||||
|
||||
function relaxClusters(clusterList) {
|
||||
for (let iter = 0; iter < 70; iter++) {
|
||||
const force = clusterList.map(() => ({x: 0, y: 0, z: 0}));
|
||||
for (let i = 0; i < clusterList.length; i++) {
|
||||
const a = clusterList[i];
|
||||
for (let j = i + 1; j < clusterList.length; j++) {
|
||||
const b = clusterList[j];
|
||||
let dx = b.x - a.x, dy = b.y - a.y, dz = b.z - a.z;
|
||||
const dist = Math.max(0.001, Math.hypot(dx, dy, dz));
|
||||
const ux = dx / dist, uy = dy / dist, uz = dz / dist;
|
||||
const minDist = a.radius + b.radius + 0.06;
|
||||
const repulsion = 0.0016 * Math.sqrt(a.mass * b.mass) / (dist * dist);
|
||||
force[i].x -= ux * repulsion; force[i].y -= uy * repulsion; force[i].z -= uz * repulsion;
|
||||
force[j].x += ux * repulsion; force[j].y += uy * repulsion; force[j].z += uz * repulsion;
|
||||
if (dist < minDist) {
|
||||
const push = (minDist - dist) * 0.022;
|
||||
force[i].x -= ux * push; force[i].y -= uy * push; force[i].z -= uz * push;
|
||||
force[j].x += ux * push; force[j].y += uy * push; force[j].z += uz * push;
|
||||
}
|
||||
const linkWeight = (a.links.get(b.key) || 0);
|
||||
if (linkWeight > 0) {
|
||||
const target = a.side === b.side ? 0.24 + Math.max(a.radius, b.radius) * 0.35 : 0.38 + Math.max(a.radius, b.radius) * 0.25;
|
||||
const spring = (dist - target) * 0.0022 * Math.log1p(linkWeight);
|
||||
force[i].x += ux * spring; force[i].y += uy * spring; force[i].z += uz * spring;
|
||||
force[j].x -= ux * spring; force[j].y -= uy * spring; force[j].z -= uz * spring;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < clusterList.length; i++) {
|
||||
const cluster = clusterList[i];
|
||||
const fx = force[i];
|
||||
const centerX = cluster.side === 'left' ? -0.34 : 0.34;
|
||||
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));
|
||||
Object.assign(cluster, clampBrain(cluster, cluster.side));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function placeNodes(clusterList, degree) {
|
||||
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);
|
||||
for (let i = 0; i < cluster.nodes.length; i++) {
|
||||
const node = cluster.nodes[i];
|
||||
node.clusterColor = cluster.color;
|
||||
node.cluster = cluster;
|
||||
const rank = i / total;
|
||||
const spread = Math.pow(rank, 0.58);
|
||||
const angleA = i * GOLDEN + pseudo(node.id, 1) * Math.PI * 2;
|
||||
const angleB = Math.acos(1 - 2 * pseudo(node.id, 2));
|
||||
const shell = cluster.radius * (0.18 + 0.92 * spread);
|
||||
const density = 0.75 + pseudo(node.id, 3) * 0.5;
|
||||
let ox = Math.cos(angleA) * Math.sin(angleB) * shell * density;
|
||||
let oy = Math.sin(angleA) * Math.sin(angleB) * shell * density * 0.95;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function rotatePoint(n) {
|
||||
const cy = Math.cos(state.yaw), sy = Math.sin(state.yaw);
|
||||
const cp = Math.cos(state.pitch), sp = Math.sin(state.pitch);
|
||||
const x1 = n.x * cy - n.z * sy;
|
||||
const z1 = n.x * sy + n.z * cy;
|
||||
const y1 = n.y * cp - z1 * sp;
|
||||
const z2 = n.y * sp + z1 * cp;
|
||||
return {x: x1, y: y1, z: z2};
|
||||
}
|
||||
|
||||
function project(n) {
|
||||
const r = rotatePoint(n);
|
||||
const panelOffset = state.width > 1000 ? 110 : state.width > 780 ? 60 : 0;
|
||||
const centerX = state.width / 2 + panelOffset;
|
||||
const centerY = state.height / 2 - 4;
|
||||
const usableW = Math.max(360, state.width - (state.width > 900 ? 470 : 40));
|
||||
const scale = Math.min(usableW * 0.42, state.height * 0.45) * state.zoom;
|
||||
const perspective = 2.9 / (3.3 - r.z * 0.42);
|
||||
return {x: centerX + r.x * scale * perspective, y: centerY + r.y * scale * perspective, z: r.z, p: perspective};
|
||||
}
|
||||
|
||||
|
||||
function rgba(color, alpha) {
|
||||
return `rgba(${color[0]},${color[1]},${color[2]},${alpha})`;
|
||||
}
|
||||
|
||||
function nearestAngle(current, target) {
|
||||
let delta = (target - current + Math.PI) % (Math.PI * 2) - Math.PI;
|
||||
if (delta < -Math.PI) delta += Math.PI * 2;
|
||||
return current + delta;
|
||||
}
|
||||
|
||||
function setVisualMode(mode, evt = null, strength = 1) {
|
||||
const cfg = MODE_CONFIG[mode] || MODE_CONFIG.living;
|
||||
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;
|
||||
const badge = $('visualMode');
|
||||
if (badge) {
|
||||
badge.className = `mode-status ${cfg.className}`;
|
||||
const b = badge.querySelector('b');
|
||||
const small = badge.querySelector('small');
|
||||
if (b) b.textContent = cfg.label;
|
||||
if (small) small.textContent = cfg.detail;
|
||||
}
|
||||
if (evt?.node_ids?.length) {
|
||||
const focus = state.nodeById.get(evt.node_ids[0]);
|
||||
if (focus) focusCamera(focus);
|
||||
}
|
||||
}
|
||||
|
||||
function focusCamera(node) {
|
||||
state.focusNodeID = node.id;
|
||||
state.focusClusterKey = node.clusterKey || '';
|
||||
const radial = Math.max(0.05, Math.hypot(node.x, node.z));
|
||||
state.cameraTargetYaw = nearestAngle(state.yaw, Math.atan2(node.x, node.z));
|
||||
state.cameraTargetPitch = Math.max(-0.62, Math.min(0.62, Math.atan2(node.y, radial)));
|
||||
}
|
||||
|
||||
function updateVisualState(now, dt) {
|
||||
if (state.mode !== 'living' && now > state.modeUntil) {
|
||||
state.mode = 'living';
|
||||
state.focusClusterKey = '';
|
||||
state.focusNodeID = '';
|
||||
state.cameraTargetYaw = null;
|
||||
state.cameraTargetPitch = null;
|
||||
setVisualMode('living');
|
||||
}
|
||||
state.activityEnergy += (state.targetEnergy - state.activityEnergy) * Math.min(1, dt * 3.1);
|
||||
if (state.mode === 'living') state.targetEnergy = 0.06 + Math.sin(now * 0.00043) * 0.025;
|
||||
state.zoom += (state.zoomTarget - state.zoom) * Math.min(1, dt * 2.4);
|
||||
if (!state.dragging && state.cameraTargetYaw !== null && state.mode !== 'living') {
|
||||
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;
|
||||
state.yaw += dt * speed;
|
||||
}
|
||||
for (const [key, value] of state.clusterActive) {
|
||||
const next = value - dt * (state.mode === 'living' ? 0.16 : 0.23);
|
||||
if (next <= 0) state.clusterActive.delete(key); else state.clusterActive.set(key, next);
|
||||
}
|
||||
autonomousLivingPulse(now);
|
||||
}
|
||||
|
||||
function autonomousLivingPulse(now) {
|
||||
if (state.mode !== 'living' || now < state.nextAmbientAt || !state.clusters.length) return;
|
||||
const candidates = state.clusters.slice(0, Math.min(18, state.clusters.length));
|
||||
const cluster = candidates[state.ambientCursor % candidates.length];
|
||||
state.ambientCursor++;
|
||||
state.nextAmbientAt = now + 2400 + pseudo(cluster.key, state.ambientCursor) * 3600;
|
||||
const count = Math.min(cluster.nodes.length, 3 + Math.floor(pseudo(cluster.key, state.ambientCursor + 1) * 4));
|
||||
state.clusterActive.set(cluster.key, Math.max(state.clusterActive.get(cluster.key) || 0, 0.34));
|
||||
for (let i = 0; i < count; i++) {
|
||||
const node = cluster.nodes[(state.ambientCursor * 7 + i * 13) % cluster.nodes.length];
|
||||
state.active.set(node.id, Math.max(state.active.get(node.id) || 0, 0.2 + pseudo(node.id, i) * 0.15));
|
||||
const edges = (state.adjacency.get(node.id) || []).filter(edge => {
|
||||
const otherID = edge.source === node.id ? edge.target : edge.source;
|
||||
return state.nodeById.get(otherID)?.clusterKey === cluster.key;
|
||||
});
|
||||
if (edges.length) {
|
||||
const edge = edges[(state.ambientCursor + i) % edges.length];
|
||||
state.edgeActive.set(edge.id, Math.max(state.edgeActive.get(edge.id) || 0, 0.18));
|
||||
state.particles.push({edgeId: edge.id, t: -0.03 * i, speed: 0.18 + pseudo(edge.id, i) * 0.22, color: clusterFill(cluster, 0.65), size: 0.62});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function eventMode(evt) {
|
||||
if (!evt || evt.type === 'brain.idle') return 'living';
|
||||
if (evt.type?.includes('research')) return 'researching';
|
||||
if (evt.type?.includes('think')) return 'thinking';
|
||||
if (evt.source === 'agent' || evt.source === 'knowledgebase' || evt.type?.includes('query')) return 'processing';
|
||||
if (evt.type === 'graph.updated' || evt.type === 'scan.started' || evt.type === 'embedding.batch') return 'learning';
|
||||
return 'processing';
|
||||
}
|
||||
|
||||
function modeParticleColor(mode) {
|
||||
return rgba((MODE_CONFIG[mode] || MODE_CONFIG.living).color, 0.92);
|
||||
}
|
||||
|
||||
function projectAnimatedNode(node, now) {
|
||||
const cluster = node.cluster;
|
||||
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);
|
||||
x = cluster.x + (node.x - cluster.x) * expansion;
|
||||
y = cluster.y + (node.y - cluster.y) * expansion;
|
||||
z = cluster.z + (node.z - cluster.z) * expansion;
|
||||
}
|
||||
const cy = Math.cos(state.yaw), sy = Math.sin(state.yaw);
|
||||
const cp = Math.cos(state.pitch), sp = Math.sin(state.pitch);
|
||||
const x1 = x * cy - z * sy;
|
||||
const z1 = x * sy + z * cy;
|
||||
const y1 = y * cp - z1 * sp;
|
||||
const z2 = y * sp + z1 * cp;
|
||||
const panelOffset = state.width > 1000 ? 110 : state.width > 780 ? 60 : 0;
|
||||
const centerX = state.width / 2 + panelOffset;
|
||||
const centerY = state.height / 2 - 4;
|
||||
const usableW = Math.max(360, state.width - (state.width > 900 ? 470 : 40));
|
||||
const scale = Math.min(usableW * 0.42, state.height * 0.45) * state.zoom;
|
||||
const perspective = 2.9 / (3.3 - z2 * 0.42);
|
||||
return {x: centerX + x1 * scale * perspective, y: centerY + y1 * scale * perspective, z: z2, p: perspective};
|
||||
}
|
||||
|
||||
function drawBackground(now) {
|
||||
const g = ctx.createRadialGradient(state.width * 0.56, state.height * 0.48, 24, state.width * 0.56, state.height * 0.48, Math.max(state.width, state.height) * 0.8);
|
||||
g.addColorStop(0, '#071827');
|
||||
g.addColorStop(0.52, '#020711');
|
||||
g.addColorStop(1, '#010207');
|
||||
ctx.fillStyle = g;
|
||||
ctx.fillRect(0, 0, state.width, state.height);
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 0.16;
|
||||
for (let i = 0; i < 90; i++) {
|
||||
const x = (i * 193.7 + now * 0.002 * (i % 3 + 1)) % state.width;
|
||||
const y = (i * 97.3) % state.height;
|
||||
ctx.fillStyle = i % 9 === 0 ? '#52e7ff' : '#5d7890';
|
||||
const size = i % 11 === 0 ? 1.5 : 0.8;
|
||||
ctx.fillRect(x, y, size, size);
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function renderActivityAura(now) {
|
||||
const cfg = MODE_CONFIG[state.mode] || MODE_CONFIG.living;
|
||||
const panelOffset = state.width > 1000 ? 110 : state.width > 780 ? 60 : 0;
|
||||
const cx = state.width / 2 + panelOffset;
|
||||
const cy = state.height / 2 - 4;
|
||||
const radius = Math.min(state.height * 0.48, Math.max(280, state.width * 0.34));
|
||||
const energy = Math.max(0.035, state.activityEnergy);
|
||||
ctx.save();
|
||||
ctx.globalCompositeOperation = 'screen';
|
||||
const aura = ctx.createRadialGradient(cx, cy, radius * 0.08, cx, cy, radius * 1.18);
|
||||
aura.addColorStop(0, rgba(cfg.color, 0.025 + energy * 0.038));
|
||||
aura.addColorStop(0.52, rgba(cfg.color, 0.012 + energy * 0.025));
|
||||
aura.addColorStop(1, rgba(cfg.color, 0));
|
||||
ctx.fillStyle = aura;
|
||||
ctx.fillRect(cx - radius * 1.25, cy - radius * 1.25, radius * 2.5, radius * 2.5);
|
||||
if (state.mode !== 'living') {
|
||||
const beat = 0.5 + 0.5 * Math.sin(now * 0.0075);
|
||||
ctx.strokeStyle = rgba(cfg.color, 0.035 + energy * 0.07 * beat);
|
||||
ctx.lineWidth = 1 + energy * 1.5;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, radius * (0.58 + beat * 0.025), 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function renderClusterClouds(now) {
|
||||
ctx.save();
|
||||
ctx.globalCompositeOperation = 'screen';
|
||||
for (const cluster of state.clusters) {
|
||||
const p = project(cluster);
|
||||
cluster.screen = p;
|
||||
const active = state.clusterActive.get(cluster.key) || 0;
|
||||
const focus = cluster.key === state.focusClusterKey ? state.activityEnergy : 0;
|
||||
const breathe = 1 + Math.sin(now * 0.00072 + hashString(cluster.key) * 0.00001) * 0.035;
|
||||
const radius = Math.max(44, cluster.radius * 230 * p.p * breathe * (1 + active * 0.08 + focus * 0.1));
|
||||
const alpha = 0.045 + cluster.importance * 0.026 + active * 0.08 + focus * 0.09;
|
||||
const g = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, radius);
|
||||
g.addColorStop(0, clusterFill(cluster, Math.min(0.25, alpha * 1.8)));
|
||||
g.addColorStop(0.32, clusterFill(cluster, Math.min(0.13, alpha)));
|
||||
g.addColorStop(0.78, clusterFill(cluster, 0.012 + focus * 0.025));
|
||||
g.addColorStop(1, clusterFill(cluster, 0));
|
||||
ctx.fillStyle = g;
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(p.x, p.y, radius, radius * (0.68 + Math.max(-0.12, Math.min(0.12, p.z * 0.16))), p.z * 0.18, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
if (state.cortexVisible && (cluster.importance > 0.62 || active > 0.15 || focus > 0.1)) {
|
||||
ctx.setLineDash([3, 7]);
|
||||
ctx.strokeStyle = clusterFill(cluster, 0.055 + active * 0.13 + focus * 0.16);
|
||||
ctx.lineWidth = 0.65 + focus * 1.1;
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(p.x, p.y, radius * 0.86, radius * 0.58, p.z * 0.18, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function renderCortexLabels() {
|
||||
if (!state.cortexVisible || state.width < 850) return;
|
||||
const visible = state.clusters
|
||||
.filter(cluster => cluster.screen && (cluster.importance > 0.52 || cluster.key === state.focusClusterKey))
|
||||
.sort((a, b) => (b.key === state.focusClusterKey) - (a.key === state.focusClusterKey) || b.mass - a.mass)
|
||||
.slice(0, state.width > 1450 ? 12 : 8);
|
||||
const occupied = [];
|
||||
ctx.save();
|
||||
ctx.font = '600 10px Inter, system-ui';
|
||||
ctx.textBaseline = 'middle';
|
||||
for (const cluster of visible) {
|
||||
const p = cluster.screen;
|
||||
const label = cluster.label.length > 28 ? cluster.label.slice(0, 26) + '…' : cluster.label;
|
||||
const count = cluster.size.toLocaleString('de-DE');
|
||||
const text = `${label} · ${count}`;
|
||||
const w = ctx.measureText(text).width + 16;
|
||||
let x = p.x + (cluster.side === 'left' ? -w - 16 : 16);
|
||||
let y = p.y - 8;
|
||||
x = Math.max(372, Math.min(state.width - w - 24, x));
|
||||
y = Math.max(100, Math.min(state.height - 78, y));
|
||||
if (occupied.some(box => Math.abs(box.x - x) < (box.w + w) * 0.48 && Math.abs(box.y - y) < 22)) continue;
|
||||
occupied.push({x, y, w});
|
||||
const focus = cluster.key === state.focusClusterKey;
|
||||
ctx.strokeStyle = clusterFill(cluster, focus ? 0.75 : 0.28);
|
||||
ctx.lineWidth = focus ? 1.25 : 0.6;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(p.x, p.y);
|
||||
ctx.lineTo(cluster.side === 'left' ? x + w : x, y + 8);
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = focus ? 'rgba(3,10,18,.94)' : 'rgba(3,10,18,.78)';
|
||||
ctx.fillRect(x, y, w, 17);
|
||||
ctx.strokeStyle = clusterFill(cluster, focus ? 0.65 : 0.18);
|
||||
ctx.strokeRect(x, y, w, 17);
|
||||
ctx.fillStyle = clusterFill(cluster, focus ? 1 : 0.82);
|
||||
ctx.fillText(text, x + 8, y + 8.5);
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function renderEdges() {
|
||||
if (!state.edgesVisible) return;
|
||||
ctx.save();
|
||||
ctx.globalCompositeOperation = 'screen';
|
||||
for (const e of state.edges) {
|
||||
const a = state.nodeById.get(e.source), b = state.nodeById.get(e.target);
|
||||
if (!a?.screen || !b?.screen) continue;
|
||||
const active = state.edgeActive.get(e.id) || 0;
|
||||
const base = active > 0.01 ? 0.08 + active * 0.7 : e.origin === 'ai-inference' ? 0.03 : e.type === 'categorized_as' ? 0.02 : 0.009;
|
||||
const alpha = Math.min(0.85, base);
|
||||
if (alpha < 0.012) continue;
|
||||
const mx = (a.screen.x + b.screen.x) / 2;
|
||||
const my = (a.screen.y + b.screen.y) / 2 - Math.abs(a.screen.x - b.screen.x) * 0.035;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(a.screen.x, a.screen.y);
|
||||
ctx.quadraticCurveTo(mx, my, b.screen.x, b.screen.y);
|
||||
ctx.strokeStyle = e.origin === 'ai-inference' ? `rgba(255,180,82,${alpha})` : `rgba(82,181,255,${alpha})`;
|
||||
ctx.lineWidth = 0.25 + active * 1.7 + (e.confidence || 0) * 0.2;
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function renderParticles(dt) {
|
||||
ctx.save();
|
||||
ctx.globalCompositeOperation = 'lighter';
|
||||
for (let i = state.particles.length - 1; i >= 0; i--) {
|
||||
const p = state.particles[i];
|
||||
p.t += dt * p.speed;
|
||||
const e = state.edgeById.get(p.edgeId), a = e && state.nodeById.get(e.source), b = e && state.nodeById.get(e.target);
|
||||
if (!e || !a?.screen || !b?.screen || p.t > 1.08) {
|
||||
state.particles.splice(i, 1);
|
||||
continue;
|
||||
}
|
||||
const q = Math.min(1, p.t);
|
||||
const ease = q * q * (3 - 2 * q);
|
||||
const x = a.screen.x + (b.screen.x - a.screen.x) * ease;
|
||||
const y = a.screen.y + (b.screen.y - a.screen.y) * ease - Math.sin(q * Math.PI) * 18;
|
||||
const size = 8 * (p.size || 1) * (0.82 + state.activityEnergy * 0.28);
|
||||
const grad = ctx.createRadialGradient(x, y, 0, x, y, size);
|
||||
grad.addColorStop(0, 'rgba(255,255,255,.95)');
|
||||
grad.addColorStop(0.22, p.color);
|
||||
grad.addColorStop(1, 'rgba(82,231,255,0)');
|
||||
ctx.fillStyle = grad;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, size, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function renderNodes(now, dt) {
|
||||
state.projected = [];
|
||||
for (const n of state.nodes) {
|
||||
n.screen = projectAnimatedNode(n, now);
|
||||
state.projected.push(n);
|
||||
}
|
||||
state.projected.sort((a, b) => a.screen.z - b.screen.z);
|
||||
|
||||
ctx.save();
|
||||
ctx.globalCompositeOperation = 'lighter';
|
||||
for (const n of state.projected) {
|
||||
let act = state.active.get(n.id) || 0;
|
||||
act = Math.max(0, act - dt * 0.44);
|
||||
if (act > 0) state.active.set(n.id, act); else state.active.delete(n.id);
|
||||
const hover = state.hover?.id === n.id;
|
||||
const selected = state.selected?.id === n.id;
|
||||
const degree = Math.min(18, (state.adjacency.get(n.id) || []).length);
|
||||
const baseWeight = Math.max(0.25, Math.min(2.5, (n.weight || 1) * 0.85 + Math.sqrt(degree) * 0.05));
|
||||
const breathe = 0.5 + 0.5 * Math.sin(now * 0.0014 + n.x * 7 + n.y * 9);
|
||||
const clusterEnergy = state.clusterActive.get(n.clusterKey) || 0;
|
||||
const focused = n.clusterKey === state.focusClusterKey ? state.activityEnergy : 0;
|
||||
const r = (1.05 + baseWeight * 0.7 + n.screen.p * 0.55) * (1 + act * 0.48 + clusterEnergy * 0.06 + focused * 0.025) + (hover || selected ? 1.6 : 0);
|
||||
const idleDim = state.mode === 'living' ? 0 : 0.025;
|
||||
const alpha = Math.min(1, 0.17 - idleDim + n.screen.p * 0.1 + Math.min(0.22, degree * 0.006) + act * 0.46 + clusterEnergy * 0.08 + focused * 0.04 + (hover || selected ? 0.18 : 0));
|
||||
if (act > 0.05 || hover || selected || n.kind === 'ai-think') {
|
||||
const halo = ctx.createRadialGradient(n.screen.x, n.screen.y, 0, n.screen.x, n.screen.y, r * (3 + act * 4));
|
||||
halo.addColorStop(0, nodeColor(n, 0.48 + act * 0.35));
|
||||
halo.addColorStop(0.24, nodeColor(n, 0.14 + act * 0.2));
|
||||
halo.addColorStop(1, nodeColor(n, 0));
|
||||
ctx.fillStyle = halo;
|
||||
ctx.beginPath();
|
||||
ctx.arc(n.screen.x, n.screen.y, r * (3 + act * 4), 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.fillStyle = nodeColor(n, alpha);
|
||||
ctx.beginPath();
|
||||
ctx.arc(n.screen.x, n.screen.y, r * (0.84 + breathe * 0.1), 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
if (n.kind === 'ai-think') {
|
||||
ctx.strokeStyle = nodeColor(n, 0.55);
|
||||
ctx.lineWidth = 0.7;
|
||||
ctx.beginPath();
|
||||
ctx.arc(n.screen.x, n.screen.y, r * 2 + breathe * 1.8, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
ctx.restore();
|
||||
|
||||
if (state.labels) {
|
||||
ctx.save();
|
||||
ctx.font = '10px Inter, system-ui';
|
||||
ctx.textBaseline = 'middle';
|
||||
for (const n of state.projected) {
|
||||
const act = state.active.get(n.id) || 0;
|
||||
if (!(act > 0.45 || state.hover?.id === n.id || state.selected?.id === n.id || (n.kind === 'ai-think' && n.screen.p > 1.02))) continue;
|
||||
const label = n.label.length > 36 ? n.label.slice(0, 34) + '…' : n.label;
|
||||
const w = ctx.measureText(label).width + 12;
|
||||
ctx.fillStyle = 'rgba(2,7,14,.82)';
|
||||
ctx.fillRect(n.screen.x + 8, n.screen.y - 8, w, 16);
|
||||
ctx.fillStyle = nodeColor(n, 0.95);
|
||||
ctx.fillText(label, n.screen.x + 14, n.screen.y);
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
function renderWaves(dt) {
|
||||
ctx.save();
|
||||
ctx.globalCompositeOperation = 'screen';
|
||||
for (let i = state.waves.length - 1; i >= 0; i--) {
|
||||
const w = state.waves[i];
|
||||
w.life -= dt * (w.decay || 1);
|
||||
w.r += dt * (w.speed || 130);
|
||||
if (w.life <= 0) {
|
||||
state.waves.splice(i, 1);
|
||||
continue;
|
||||
}
|
||||
const color = w.color || MODE_CONFIG.living.color;
|
||||
ctx.strokeStyle = rgba(color, w.life * (w.alpha || 0.22));
|
||||
ctx.lineWidth = (w.width || 1.3) * (0.8 + state.activityEnergy * 0.45);
|
||||
if (w.dashed) ctx.setLineDash([4, 8]);
|
||||
ctx.beginPath();
|
||||
ctx.arc(w.x, w.y, w.r, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
for (let i = state.bursts.length - 1; i >= 0; i--) {
|
||||
const burst = state.bursts[i];
|
||||
burst.life -= dt * 0.72;
|
||||
if (burst.life <= 0) {
|
||||
state.bursts.splice(i, 1);
|
||||
continue;
|
||||
}
|
||||
const rayCount = burst.rays || 16;
|
||||
const length = (1 - burst.life) * (burst.length || 115) + 18;
|
||||
ctx.strokeStyle = rgba(burst.color, burst.life * 0.18);
|
||||
ctx.lineWidth = 0.7;
|
||||
for (let ray = 0; ray < rayCount; ray++) {
|
||||
const angle = ray / rayCount * Math.PI * 2 + burst.spin;
|
||||
const inner = 9 + (1 - burst.life) * 13;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(burst.x + Math.cos(angle) * inner, burst.y + Math.sin(angle) * inner);
|
||||
ctx.lineTo(burst.x + Math.cos(angle) * length, burst.y + Math.sin(angle) * length);
|
||||
ctx.stroke();
|
||||
}
|
||||
burst.spin += dt * 0.35;
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function frame(now) {
|
||||
const dt = Math.min(0.05, (now - state.last) / 1000);
|
||||
state.last = now;
|
||||
updateVisualState(now, dt);
|
||||
drawBackground(now);
|
||||
renderActivityAura(now);
|
||||
renderClusterClouds(now);
|
||||
renderEdges();
|
||||
renderParticles(dt);
|
||||
renderNodes(now, dt);
|
||||
renderWaves(dt);
|
||||
renderCortexLabels();
|
||||
for (const [id, value] of state.edgeActive) {
|
||||
const next = value - dt * (state.mode === 'living' ? 0.34 : 0.5);
|
||||
if (next <= 0) state.edgeActive.delete(id); else state.edgeActive.set(id, next);
|
||||
}
|
||||
requestAnimationFrame(frame);
|
||||
}
|
||||
requestAnimationFrame(frame);
|
||||
|
||||
function activate(evt) {
|
||||
const strength = Math.max(0.15, Math.min(1.4, evt.strength || 0.6));
|
||||
const mode = eventMode(evt);
|
||||
const substep = evt.type === 'node.activated' || evt.type === 'edges.traversed';
|
||||
if (evt.type !== 'brain.idle' && !substep) setVisualMode(mode, evt, strength);
|
||||
if (substep && !state.focusClusterKey && evt.node_ids?.length) {
|
||||
const focus = state.nodeById.get(evt.node_ids[0]);
|
||||
if (focus) focusCamera(focus);
|
||||
}
|
||||
const modeColor = (MODE_CONFIG[mode] || MODE_CONFIG.living).color;
|
||||
for (const id of evt.node_ids || []) {
|
||||
const node = state.nodeById.get(id);
|
||||
state.active.set(id, Math.max(state.active.get(id) || 0, strength));
|
||||
if (node?.clusterKey) state.clusterActive.set(node.clusterKey, Math.max(state.clusterActive.get(node.clusterKey) || 0, strength));
|
||||
}
|
||||
for (const id of evt.edge_ids || []) {
|
||||
state.edgeActive.set(id, Math.max(state.edgeActive.get(id) || 0, strength));
|
||||
const count = evt.type === 'brain.idle' ? 1 : Math.ceil(3 + strength * (mode === 'thinking' ? 7 : 5));
|
||||
for (let i = 0; i < count; i++) {
|
||||
state.particles.push({edgeId: id, t: -i * 0.065, speed: 0.32 + Math.random() * (mode === 'thinking' ? 0.9 : 0.62), color: modeParticleColor(mode), size: mode === 'thinking' ? 1.12 : mode === 'researching' ? 0.95 : 0.86});
|
||||
}
|
||||
}
|
||||
if (evt.type !== 'brain.idle') {
|
||||
state.pulses++;
|
||||
$('pulseCount').textContent = state.pulses.toLocaleString('de-DE');
|
||||
const focus = evt.node_ids?.[0] ? state.nodeById.get(evt.node_ids[0]) : null;
|
||||
const x = focus?.screen?.x ?? state.width / 2;
|
||||
const y = focus?.screen?.y ?? state.height / 2;
|
||||
const waveCount = mode === 'thinking' ? 3 : mode === 'researching' ? 2 : 1;
|
||||
for (let i = 0; i < waveCount; i++) {
|
||||
state.waves.push({x, y, r: 18 + i * 17, life: 1 - i * 0.08, color: modeColor, speed: 118 + i * 35, width: mode === 'thinking' ? 1.8 : 1.25, alpha: mode === 'thinking' ? 0.3 : 0.24, dashed: mode === 'researching' && i === 1});
|
||||
}
|
||||
if (mode === 'thinking' || mode === 'researching') state.bursts.push({x, y, life: 1, color: modeColor, rays: mode === 'thinking' ? 22 : 16, length: mode === 'thinking' ? 150 : 115, spin: pseudo(evt.id || evt.type, 9) * Math.PI});
|
||||
}
|
||||
addLog(evt);
|
||||
if (evt.type === 'graph.updated') loadGraph();
|
||||
}
|
||||
|
||||
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']);
|
||||
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;
|
||||
const now = Date.now();
|
||||
const cooldown = evt.type === 'embedding.batch' ? 45000 : evt.type === 'graph.updated' ? 15000 : 4000;
|
||||
if (now - last < cooldown) return false;
|
||||
state.lastLogFingerprint.set(fingerprint, now);
|
||||
return true;
|
||||
}
|
||||
|
||||
function formatEvent(evt) {
|
||||
const time = new Date(evt.timestamp || Date.now()).toLocaleTimeString('de-DE', {hour: '2-digit', minute: '2-digit', second: '2-digit'});
|
||||
const nodeObjects = (evt.node_ids || []).map(id => state.nodeById.get(id)).filter(Boolean);
|
||||
const nodes = nodeObjects.map(node => node.label);
|
||||
const regions = [...new Set(nodeObjects.map(node => node.clusterKey).filter(Boolean))].slice(0, 3);
|
||||
const titleMap = {
|
||||
'scan.started': 'Synchronisation',
|
||||
'graph.updated': 'Graph aktualisiert',
|
||||
'embedding.batch': 'Embeddings',
|
||||
'query.started': evt.source === 'knowledgebase' ? 'Knowledgebase-Suche' : evt.source === 'agent' ? 'Agent-Suche' : 'Suchanfrage',
|
||||
'query.completed': 'Suche abgeschlossen',
|
||||
'think.started': 'AI-THINK prüft Zusammenhang',
|
||||
'think.created': 'AI-THINK erstellt Beitrag',
|
||||
'think.rejected': 'AI-THINK verwirft Edge',
|
||||
'think.failed': 'AI-THINK Fehler',
|
||||
'think.paused': 'AI-THINK pausiert',
|
||||
'research.started': 'Recherche gestartet',
|
||||
'agent.run': 'Agent-Lauf'
|
||||
};
|
||||
const title = titleMap[evt.type] || evt.phase || evt.source || 'Aktivität';
|
||||
const meta = [];
|
||||
if (evt.metadata?.nodes) meta.push(`${Number(evt.metadata.nodes).toLocaleString('de-DE')} Nodes`);
|
||||
if (evt.metadata?.edges) meta.push(`${Number(evt.metadata.edges).toLocaleString('de-DE')} Edges`);
|
||||
if (evt.metadata?.duration_ms) meta.push(`${Number(evt.metadata.duration_ms).toLocaleString('de-DE')} ms`);
|
||||
if (evt.metadata?.hit_count) meta.push(`${Number(evt.metadata.hit_count).toLocaleString('de-DE')} Treffer`);
|
||||
if (evt.metadata?.used_nodes) meta.push(`${Number(evt.metadata.used_nodes).toLocaleString('de-DE')} Quellen`);
|
||||
if (evt.metadata?.batch_count) meta.push(`Batch ${Number(evt.metadata.batch_count).toLocaleString('de-DE')}`);
|
||||
if (evt.metadata?.semantic_similarity !== undefined) meta.push(`${Math.round(Number(evt.metadata.semantic_similarity) * 100)}% Nähe`);
|
||||
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.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}`);
|
||||
if (evt.metadata?.outcome) meta.push(String(evt.metadata.outcome));
|
||||
if (evt.metadata?.path) meta.push(String(evt.metadata.path).split('/').slice(-2).join('/'));
|
||||
const eventQuery = evt.query || evt.metadata?.research_query || '';
|
||||
if (eventQuery) meta.push('Query');
|
||||
let message = evt.message || eventQuery || evt.type;
|
||||
if ((evt.type === 'think.started' || evt.type === 'think.created' || evt.type === 'think.rejected' || evt.type === 'research.started') && nodes.length >= 2) {
|
||||
message = `${message} · ${nodes.slice(0, 2).join(' ↔ ')}`;
|
||||
} else if (evt.type === 'query.started' && evt.query) {
|
||||
message = `${evt.source === 'agent' ? 'Agent' : evt.source === 'knowledgebase' ? 'Knowledgebase' : 'Brain'} verarbeitet eine Anfrage.`;
|
||||
}
|
||||
return {time, title, message, meta, query: eventQuery, regions, cls: evt.type?.includes('think') ? 'think' : evt.type?.includes('research') ? 'research' : evt.type === 'graph.updated' || evt.type === 'scan.started' ? 'graph' : evt.source === 'agent' ? 'agent' : ''};
|
||||
}
|
||||
|
||||
function addLog(evt) {
|
||||
if (!shouldLog(evt)) return;
|
||||
const out = formatEvent(evt);
|
||||
const item = document.createElement('div');
|
||||
item.className = 'activity-item ' + out.cls;
|
||||
item.innerHTML = `<b>${escapeHTML(out.title)}</b><time>${out.time}</time><p>${escapeHTML(out.message)}</p>${out.regions.length ? `<div class="region">Cortex: ${out.regions.map(escapeHTML).join(' · ')}</div>` : ''}${out.meta.length ? `<div class="meta">${out.meta.map(v => `<span>${escapeHTML(v)}</span>`).join('')}</div>` : ''}${out.query ? `<div class="query">${escapeHTML(out.query)}</div>` : ''}`;
|
||||
const log = $('activityLog');
|
||||
log.prepend(item);
|
||||
while (log.children.length > 18) log.removeChild(log.lastChild);
|
||||
}
|
||||
|
||||
const stream = new EventSource('/api/stream');
|
||||
stream.addEventListener('activity', e => {
|
||||
try {
|
||||
activate(JSON.parse(e.data));
|
||||
setSystem('lebt', true);
|
||||
} catch {}
|
||||
});
|
||||
stream.onerror = () => setSystem('verbindet', false);
|
||||
stream.onopen = () => setSystem('lebt', true);
|
||||
|
||||
$('toggleLabels').addEventListener('click', e => {
|
||||
state.labels = !state.labels;
|
||||
e.currentTarget.classList.toggle('active', state.labels);
|
||||
});
|
||||
$('toggleEdges').addEventListener('click', e => {
|
||||
state.edgesVisible = !state.edgesVisible;
|
||||
e.currentTarget.classList.toggle('active', state.edgesVisible);
|
||||
});
|
||||
$('toggleRotate').addEventListener('click', e => {
|
||||
state.autoRotate = !state.autoRotate;
|
||||
e.currentTarget.classList.toggle('active', state.autoRotate);
|
||||
});
|
||||
$('toggleCortex').addEventListener('click', e => {
|
||||
state.cortexVisible = !state.cortexVisible;
|
||||
e.currentTarget.classList.toggle('active', state.cortexVisible);
|
||||
});
|
||||
$('resetView').addEventListener('click', () => {
|
||||
state.yaw = 0.18;
|
||||
state.pitch = -0.12;
|
||||
state.zoom = 1.02;
|
||||
});
|
||||
$('clearLog').addEventListener('click', () => {$('activityLog').innerHTML = '';});
|
||||
|
||||
canvas.addEventListener('pointerdown', e => {
|
||||
state.dragging = true;
|
||||
state.moved = false;
|
||||
state.lastX = e.clientX;
|
||||
state.lastY = e.clientY;
|
||||
canvas.setPointerCapture(e.pointerId);
|
||||
});
|
||||
|
||||
canvas.addEventListener('pointermove', e => {
|
||||
if (state.dragging) {
|
||||
const dx = e.clientX - state.lastX;
|
||||
const dy = e.clientY - state.lastY;
|
||||
if (Math.abs(dx) > 1 || Math.abs(dy) > 1) state.moved = true;
|
||||
state.yaw += dx * 0.006;
|
||||
state.pitch = Math.max(-1, Math.min(1, state.pitch + dy * 0.005));
|
||||
state.lastX = e.clientX;
|
||||
state.lastY = e.clientY;
|
||||
return;
|
||||
}
|
||||
let best = null, dist = 14;
|
||||
for (const n of state.projected) {
|
||||
if (!n.screen) continue;
|
||||
const d = Math.hypot(n.screen.x - e.clientX, n.screen.y - e.clientY);
|
||||
if (d < dist) {
|
||||
dist = d;
|
||||
best = n;
|
||||
}
|
||||
}
|
||||
state.hover = best;
|
||||
const tip = $('tooltip');
|
||||
if (best) {
|
||||
const degree = (state.adjacency.get(best.id) || []).length;
|
||||
tip.classList.remove('hidden');
|
||||
tip.innerHTML = `<strong>${escapeHTML(best.label)}</strong><small>${escapeHTML((best.categories || []).slice(0, 3).join(' · ') || best.kind || 'Knoten')}<br>${degree} Verbindungen · ${escapeHTML(best.status || 'aktiv')}</small>`;
|
||||
tip.style.left = Math.min(state.width - 260, e.clientX + 14) + 'px';
|
||||
tip.style.top = Math.min(state.height - 70, e.clientY + 14) + 'px';
|
||||
} else {
|
||||
tip.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
canvas.addEventListener('pointerup', e => {
|
||||
if (!state.moved && state.hover) {
|
||||
state.selected = state.hover;
|
||||
state.active.set(state.hover.id, 1.2);
|
||||
}
|
||||
state.dragging = false;
|
||||
});
|
||||
canvas.addEventListener('pointercancel', () => state.dragging = false);
|
||||
canvas.addEventListener('wheel', e => {
|
||||
e.preventDefault();
|
||||
state.zoom = Math.max(0.55, Math.min(2.4, state.zoom * Math.exp(-e.deltaY * 0.001)));
|
||||
}, {passive: false});
|
||||
|
||||
function escapeHTML(v) {
|
||||
return String(v ?? '').replace(/[&<>'"]/g, c => ({'&': '&', '<': '<', '>': '>', "'": ''', '"': '"'}[c]));
|
||||
}
|
||||
|
||||
loadGraph();
|
||||
setInterval(loadGraph, 30000);
|
||||
})();
|
||||
64
internal/web/static/index.html
Normal file
64
internal/web/static/index.html
Normal file
@@ -0,0 +1,64 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
||||
<title>Neural Knowledge Brain</title>
|
||||
<link rel="stylesheet" href="/app.css">
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="brain" aria-label="Animierter Wissensgraph in 3D-Gehirnform"></canvas>
|
||||
<div class="vignette"></div>
|
||||
|
||||
<header class="topbar glass">
|
||||
<div class="brand">
|
||||
<span class="mark"><i></i><i></i><i></i></span>
|
||||
<div>
|
||||
<strong>NEURAL KNOWLEDGE BRAIN</strong>
|
||||
<small>Autonome Hirnaktivität · Agent · Knowledgebase · AI-THINK</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mode-status living" id="visualMode" aria-live="polite">
|
||||
<i></i>
|
||||
<div><b>LIVING</b><small>ruhige Eigenaktivität</small></div>
|
||||
</div>
|
||||
<div class="metrics">
|
||||
<span><b id="nodeCount">0</b> Nodes</span>
|
||||
<span><b id="edgeCount">0</b> Edges</span>
|
||||
<span><b id="pulseCount">0</b> Impulse</span>
|
||||
<span class="state" id="systemState"><i></i> verbindet</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<aside class="activity glass">
|
||||
<div class="panel-title">
|
||||
<span>AKTIVITÄTSFEED</span>
|
||||
<div class="panel-actions">
|
||||
<span class="chip">wichtig</span>
|
||||
<button id="clearLog" title="Aktivitätsprotokoll leeren">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-subtitle">Zeigt nur relevante Hirnaktivität, Anreicherungen, Recherchen und Suchläufe.</div>
|
||||
<div id="activityLog" class="activity-log"></div>
|
||||
</aside>
|
||||
|
||||
<nav class="dock glass" aria-label="Ansichtssteuerung">
|
||||
<button id="toggleLabels" class="active">Labels</button>
|
||||
<button id="toggleEdges" class="active">Synapsen</button>
|
||||
<button id="toggleRotate" class="active">Autopilot</button>
|
||||
<button id="toggleCortex" class="active">Cortex</button>
|
||||
<button id="resetView">Zentrieren</button>
|
||||
</nav>
|
||||
|
||||
<div class="legend glass" aria-label="Legende">
|
||||
<span class="legend-title">STATUS</span>
|
||||
<span><i class="prod"></i>Produktiv</span>
|
||||
<span><i class="think"></i>AI-THINK</span>
|
||||
<span><i class="stage"></i>Staging</span>
|
||||
<span><i class="external"></i>Recherche</span>
|
||||
</div>
|
||||
|
||||
<div id="tooltip" class="tooltip hidden"></div>
|
||||
<script src="/app.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
BIN
neural-brain
Normal file
BIN
neural-brain
Normal file
Binary file not shown.
BIN
preview.png
Normal file
BIN
preview.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 846 KiB |
45
run.ps1
Normal file
45
run.ps1
Normal file
@@ -0,0 +1,45 @@
|
||||
param(
|
||||
[switch]$NoEnv
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
Set-StrictMode -Version Latest
|
||||
|
||||
$ProjectRoot = $PSScriptRoot
|
||||
Set-Location $ProjectRoot
|
||||
|
||||
function Import-DotEnv {
|
||||
param([Parameter(Mandatory = $true)][string]$Path)
|
||||
|
||||
Get-Content -LiteralPath $Path | ForEach-Object {
|
||||
$line = $_.Trim()
|
||||
if (-not $line -or $line.StartsWith("#")) { return }
|
||||
|
||||
$parts = $line.Split("=", 2)
|
||||
if ($parts.Count -ne 2) { return }
|
||||
|
||||
$name = $parts[0].Trim()
|
||||
$value = $parts[1].Trim()
|
||||
if (-not $name) { return }
|
||||
|
||||
if (($value.StartsWith('"') -and $value.EndsWith('"')) -or
|
||||
($value.StartsWith("'") -and $value.EndsWith("'"))) {
|
||||
$value = $value.Substring(1, $value.Length - 2)
|
||||
}
|
||||
|
||||
[Environment]::SetEnvironmentVariable($name, $value, "Process")
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $NoEnv) {
|
||||
$envFile = Join-Path $ProjectRoot ".env"
|
||||
if (Test-Path -LiteralPath $envFile) {
|
||||
Import-DotEnv -Path $envFile
|
||||
}
|
||||
else {
|
||||
Write-Warning ".env nicht gefunden. Es werden vorhandene Umgebungsvariablen und Programm-Defaults verwendet."
|
||||
}
|
||||
}
|
||||
|
||||
go run ./cmd/brain
|
||||
exit $LASTEXITCODE
|
||||
Reference in New Issue
Block a user