From 87364f918a2ecd4fb879559917b40be81a0dc835 Mon Sep 17 00:00:00 2001 From: groot Date: Thu, 6 Aug 2026 08:53:16 +0200 Subject: [PATCH] Update 10 - Analyse --- ANALYSIS-DASHBOARD.md | 149 +++ CHANGELOG-ANALYSIS-DASHBOARD.md | 30 + README.md | 8 + SHA256SUMS.txt | 197 ++-- VALIDATION-ANALYSIS-DASHBOARD.md | 25 + internal/activity/broker.go | 18 + internal/engine/engine.go | 22 +- internal/graph/analysis_dashboard.go | 1212 +++++++++++++++++++++ internal/graph/analysis_dashboard_test.go | 78 ++ internal/graph/sqlite_backend.go | 69 +- internal/graph/store.go | 103 +- internal/web/server.go | 60 + internal/web/server_test.go | 41 + internal/web/static/analysis.css | 39 + internal/web/static/analysis.html | 202 ++++ internal/web/static/analysis.js | 384 +++++++ internal/web/static/app.css | 1 + internal/web/static/index.html | 1 + 18 files changed, 2537 insertions(+), 102 deletions(-) create mode 100644 ANALYSIS-DASHBOARD.md create mode 100644 CHANGELOG-ANALYSIS-DASHBOARD.md create mode 100644 VALIDATION-ANALYSIS-DASHBOARD.md create mode 100644 internal/graph/analysis_dashboard.go create mode 100644 internal/graph/analysis_dashboard_test.go create mode 100644 internal/web/static/analysis.css create mode 100644 internal/web/static/analysis.html create mode 100644 internal/web/static/analysis.js diff --git a/ANALYSIS-DASHBOARD.md b/ANALYSIS-DASHBOARD.md new file mode 100644 index 0000000..be24a0c --- /dev/null +++ b/ANALYSIS-DASHBOARD.md @@ -0,0 +1,149 @@ +# BRAIN ANALYSIS CENTER + +Das technische Analyse-Dashboard ist bewusst von der animierten Gehirnansicht getrennt. + +- Visualisierung: `http://localhost:8090/` +- Analyse: `http://localhost:8090/analysis.html` +- Kurzpfad: `http://localhost:8090/analysis` + +In der Gehirnansicht führt der neue Button **ANALYSE** direkt zum Dashboard. + +## Zweck + +Das Dashboard beantwortet insbesondere: + +- Hatte der letzte Lauf ein materiell positives Ergebnis? +- Wurden Nodes, Edges oder Embeddings erzeugt, verändert oder gelöscht? +- Welche konkreten Objekt-IDs waren betroffen? +- Wurde lediglich geprüft und anschließend wegen Qualitätsregeln übersprungen? +- Wie viele semantische Vergleiche, SearXNG-Treffer, Volltextabrufe und akzeptierte Belege gab es? +- Wurde ein Artikel erzeugt oder blieb nur neue Research-Evidence zurück? +- Ist der Graph strukturell gesund und wie hoch ist die Vektorabdeckung? +- Sind Ollama, Autonomous Research, Persistenz und SQLite aktuell arbeitsfähig? + +## Persistentes Auditprotokoll + +Die Historie liegt in `BRAIN_DATA_DIR/graph.db`. Die Datenbank wird automatisch von Schemaversion 2 auf 3 erweitert. Ein Löschen oder Neuaufbau des Graphen ist nicht erforderlich. + +Neue Tabellen: + +```text +analysis_events +analysis_points +analysis_changes +``` + +`analysis_events` enthält die ursprünglichen Engine-Aktivitäten mit Query, Meldung, IDs und Metadaten. `analysis_points` speichert den unmittelbar dazu erfassten Graphstand sowie monotone Änderungszähler. `analysis_changes` enthält die konkreten betroffenen Nodes, Edges und Vektoren. + +Die Historie wird 90 Tage aufbewahrt. Die Aufzeichnung beginnt erst mit dieser Version; frühere Läufe lassen sich nicht rückwirkend rekonstruieren. + +## Änderungssemantik + +### Nodes + +- `created`: neue Node-ID wurde aufgenommen. +- `updated`: vorhandene Node wurde mit geänderten Daten ersetzt. +- `deleted`: Node ist aus einer verwalteten Quelle verschwunden. + +### Edges + +- `created`: neue Relation wurde aufgenommen. +- `updated`: Typ, Status, Confidence, Evidenz oder Metadaten einer vorhandenen Relation wurden geändert. +- `deleted`: Relation wurde entfernt, etwa weil die Quelle oder ein Endpunkt nicht mehr existiert. + +### Vektoren + +- `created`: ein Node erhielt erstmals ein Embedding. +- `recalculated`: ein vorhandenes Embedding wurde ersetzt. +- `deleted`: ein Vektor wurde invalidiert oder sein Node entfernt. + +Eine veränderte semantische Nähe wird sichtbar, wenn eine AI-Edge aktualisiert wird. Reine Kandidatenprüfungen ohne Edge-Übernahme bleiben über die Activity-Metadaten wie `semantic_similarity`, `confidence` und `candidate_comparisons` nachvollziehbar. + +## Laufbewertung + +Das Dashboard fasst zusammengehörige Events zu Läufen zusammen: + +- KB-Lernlauf +- AI-THINK-Zyklus +- SearXNG-Recherche +- autonome Rechercheaufgabe +- Wissensabfrage +- Artikelsynthese +- GLPI-Synchronisierung +- Persistenz- und Embedding-Aktionen + +Bewertungen: + +| Bewertung | Bedeutung | +|---|---| +| `positives Ergebnis` | mindestens ein Node, eine Edge oder ein Embedding wurde erzeugt | +| `aktualisiert` | bestehende Objekte wurden geändert oder Vektoren neu berechnet | +| `Bereinigung` | veraltete Objekte wurden entfernt | +| `ohne Übernahme` | Prüfung lief, aber Qualitäts-, Relevanz- oder Quellenregeln verhinderten eine Übernahme | +| `ohne Graphänderung` | Lauf endete regulär, ohne den Graphzustand zu verändern | +| `fehlgeschlagen` | mindestens ein Fehlerereignis gehört zum Lauf | + +## Detailgrenze + +Bei sehr großen Erstimporten können zehntausende Nodes und Vektoren in einem einzigen Event entstehen. Deshalb werden pro Event höchstens 2.000 konkrete Detailzeilen persistiert. Die aggregierten Änderungszähler bleiben vollständig und exakt. Das Dashboard weist sichtbar auf gekürzte Detaildaten hin. + +Die API liefert standardmäßig die neuesten konkreten Änderungen im gewählten Zeitraum. Dadurch bleibt das Dashboard auch bei großen Wissensbasen bedienbar. + +## Performance + +Die Aufzeichnung selbst ist absichtlich leichtgewichtig: + +1. Graphmutationen erhöhen im Speicher konstante Zähler und sammeln begrenzte Detailinformationen. +2. Ein Activity-Event übernimmt den aktuellen Checkpoint in eine asynchrone Queue. +3. Bursts werden bis zu 50 ms beziehungsweise 64 Events gesammelt und gemeinsam in einer SQLite-Transaktion geschrieben. +4. SQLite wird außerhalb des Render- und Engine-Hotpaths beschrieben. +5. Die aufwendige strukturelle Graphanalyse wird erst beim Öffnen des Dashboards berechnet und pro Graphversion gecacht. +6. Die animierte Gehirnansicht lädt die Analysehistorie nicht. + +Das Analyse-Dashboard aktualisiert sich über den bestehenden SSE-Stream, aber zusammengefasst und verzögert, damit Event-Bursts nicht für jedes einzelne Ereignis eine neue Vollanalyse auslösen. + +## HTTP-API + +```text +GET /api/analysis/dashboard?hours=24&limit=400 +GET /api/analysis/export?hours=24 +``` + +`hours` ist auf 1 bis 2.160 Stunden begrenzt. `limit` steuert die maximale Zahl zurückgegebener Läufe und Events. Der Export liefert dieselbe Struktur als formatiertes JSON und benötigt bei gesetztem `BRAIN_API_KEY` eine Autorisierung. + +Wesentliche Antwortbereiche: + +```json +{ + "graph": { + "summary": {}, + "node_sources": {}, + "edge_types": {}, + "vector_coverage": 0.98, + "average_ai_confidence": 0.86, + "average_ai_similarity": 0.91 + }, + "history": { + "runs": [], + "events": [], + "changes": [], + "totals": {}, + "timeline": [], + "change_count": 0 + }, + "system": {} +} +``` + +## Dashboardbereiche + +- Ergebnisbewertung des letzten abgeschlossenen Laufs +- aktuelle Graph-, Vektor-, Thinking-, Research-, Ollama- und Persistenzkennzahlen +- Zeitverlauf von Erzeugungen, Aktualisierungen, Löschungen und Events +- filterbares Laufprotokoll mit vollständigen Unterevents +- Source-, Kind-, Origin- und Edge-Verteilungen +- Confidence- und Similarity-Auswertung +- Embedding- und Research-Auswertung +- neueste Nodes und Edges +- exaktes Änderungsjournal +- technischer Roh-Eventstream mit ursprünglichen Metadaten und Graphcheckpoint diff --git a/CHANGELOG-ANALYSIS-DASHBOARD.md b/CHANGELOG-ANALYSIS-DASHBOARD.md new file mode 100644 index 0000000..1d6a61d --- /dev/null +++ b/CHANGELOG-ANALYSIS-DASHBOARD.md @@ -0,0 +1,30 @@ +# Changelog: Analysis Dashboard + +## Neu + +- separate Analyseoberfläche unter `/analysis.html` +- direkter **ANALYSE**-Link in der Gehirnansicht +- persistente Activity- und Graphcheckpoint-Historie in SQLite +- konkrete Änderungsjournal-Einträge für Nodes, Edges und Vektoren +- automatische Laufgruppierung und verständliche Ergebnisbewertung +- Zeitreihen für Erzeugungen, Aktualisierungen, Löschungen und Eventvolumen +- Source-, Origin-, Kind-, Edge-, Similarity-, Confidence- und Embedding-Auswertung +- SearXNG-, Volltext-, Evidenz- und Artikelergebnisübersicht +- Ollama-, Autonomous-Research- und Persistenzstatus +- JSON-Export der gesamten Analyse +- Schemaerweiterung `graph.db` von Version 2 auf Version 3 + +## Geändert + +- KB-Scans publizieren nun explizite Start-, Fehler- und Abschlussereignisse mit Vorher-/Nachher-Zahlen. +- Der Activity-Broker kann einen nicht blockierenden Audit-Sink bedienen. +- Graphmutationen führen getrennte monotone Zähler für Created, Updated/Recalculated und Deleted. +- Die aufwendige aktuelle Graphanalyse wird pro Graphversion gecacht. + +## Schutzregeln + +- asynchroner SQLite-Auditwriter mit begrenzter Queue und gebündelten Transaktionen +- höchstens 2.000 konkrete Objektänderungen pro Event +- vollständige Aggregatzähler auch bei gekürztem Detailjournal +- 90 Tage Aufbewahrung +- keine Analysehistorie im Hotpath der animierten Gehirnansicht diff --git a/README.md b/README.md index 19df0e3..a082b5f 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ Eigenständiger Go-Dienst für Agent, lokale Knowledgebase, GLPI-Knowledgebase u - Mehrstufiger AI-THINK-Worker: Relation Thinking, quellengebundene Wissenskonsolidierung, Recherche offener Punkte und reine Knowledge-Synthesis für vollständige KB-Artikel. - Persistente **Autonomous-Research-Queue**: selbstständige Wissenslückensuche, externe Trigger, Leerlauf-/Budgetsteuerung und niedrig priorisierte Übergabe an den vorhandenen Ollama-Pool. - Inkrementelle SQLite/WAL-Persistenz über `modernc.org/sqlite`: binäre Float32-Embeddings und standardmäßig alle fünf Minuten gebündelte Row-Updates. +- Separates **BRAIN ANALYSIS CENTER** mit persistenter Laufhistorie, exakten Node-/Edge-/Vektoränderungen, Similarity-/Confidence-Auswertung, Research-Ergebnissen und technischem Roh-Eventstream. ## Vertrauens- und Schreibgrenzen @@ -47,6 +48,10 @@ go run ./cmd/brain Oberfläche: `http://localhost:8090` +Technisches Analyse-Dashboard: `http://localhost:8090/analysis.html` + +Das Analyse-Dashboard ist von der animierten Gehirnansicht getrennt. Es bewertet Läufe als positives Ergebnis, Aktualisierung, Bereinigung, Prüfung ohne Übernahme oder Fehler und zeigt die konkret betroffenen Node-, Edge- und Vektor-IDs. Die Historie wird in `graph.db` aufgezeichnet und beginnt ab Installation dieser Version. Details: [`ANALYSIS-DASHBOARD.md`](ANALYSIS-DASHBOARD.md). + Docker: ```bash @@ -264,6 +269,8 @@ curl -X POST 'http://localhost:8090/api/enrich?async=1' | `GET` | `/api/status` | Gesamtstatus inklusive Ollama-Pool, GLPI-KB und Persistenzqueue | | `GET` | `/api/graph` | vollständiger aktueller In-Memory-Graph | | `GET` | `/api/analysis` | strukturelle Graphanalyse | +| `GET` | `/api/analysis/dashboard` | detaillierte aktuelle Analyse, Laufhistorie, Zeitreihe und konkretes Änderungsjournal | +| `GET` | `/api/analysis/export` | vollständige Analyse als formatiertes JSON exportieren | | `GET` | `/api/runtime-settings` | aktuelle Learning-, Thinking-, exakte Source- und View-Einstellungen | | `PUT` | `/api/runtime-settings` | Laufzeiteinstellungen ändern und gebündelt persistieren | | `GET` | `/api/sources` | exakte `source`-Werte mit Anzahl; enthält immer `GLPI_KB_SOURCE` | @@ -305,6 +312,7 @@ Wichtige Bereiche: go test ./... go vet ./... node --check internal/web/static/app.js +node --check internal/web/static/analysis.js ``` ## Sichtbare SearXNG-Recherche diff --git a/SHA256SUMS.txt b/SHA256SUMS.txt index 6019cc7..2278b14 100644 --- a/SHA256SUMS.txt +++ b/SHA256SUMS.txt @@ -1,95 +1,102 @@ -e888a548fa32246f01bf7d7545359fa856d6bc32651cafdc776d595d9eada34e ./.env.example -8955cfbeff229e73f0cad664863ff21711c270a4233c3225680c89aa6268a901 ./ARCHITECTURE.md -cf3e5275f1eb623da3b6f734d233197e8b47879b8e7cdfd4d373a7ecdd921651 ./AUTONOMOUS-RESEARCH.md -0ed6ff0d82b3b6776970200a021937611d4f3273f6727d296aec16cfac6153b8 ./CHANGELOG-AUTONOMOUS-RESEARCH.md -2bc149241c2d25f755e4a0470dc517f646527ee3e98d3a6c2e7798639bd70866 ./CHANGELOG-CONSTELLATION-ECO-TRANSITIONS.md -933cdaeae7895e31d2281d591a75f23f07e7c41d356638654bac4f5e7a20a069 ./CHANGELOG-FILTER-PANEL-SCROLL.md -213ac897cd415bbeb9764a847843b9eff366983cf25bb3cbce4efa4c04b9e5f5 ./CHANGELOG-GLPI-POOL-PERSISTENCE.md -e2f1f0400999cb59b8be09bc2743e06e0a0b2ecdc44a583af7c9a08b70d8509e ./CHANGELOG-GPU-NODE-LIMIT.md -a317376127be1e47b9ec9f0781fcfebdcbf7fccfaeb7840d9241f9586048fadb ./CHANGELOG-GROUNDED-KNOWLEDGE-SYNTHESIS.md -02a8d3e541967e2d2ef9dd5451f470679e262ad851aca9f03563b322914c3181 ./CHANGELOG-ITERATIVE-GROUNDED-RESEARCH.md -e167a9d64f63c3933ad40c5078684bc019db303043c34ea3965f3b089f3d32c7 ./CHANGELOG-KNOWLEDGE-SYNTHESIS.md -87f89a81e1124b18e092a4ea946cb037295cb9e884a46b392286272dc8134dd4 ./CHANGELOG-RUNTIME-HONEYCOMB.md -2d04e6d385f4b902080a0bcaab510a846a8ae6a76cb757423c50425c8433e7f9 ./CHANGELOG-SEARXNG-DIAGNOSTICS.md -9c760e167a9af2d3d8ca32c5aa4ef6bb4a3153047343a71c4b19ca9aef96ca32 ./CHANGELOG-SEARXNG-VISUALIZATION.md -be9f133ae933bdc0e0a8aa5d176dd3e39488a191337043533379e23d179f3ad2 ./CHANGELOG-SOURCE-ONLY-FILTERS.md -5433a7c2e67ab35fb320bc872e9024fa5f3e765736184e9f878340b8b45407aa ./CHANGELOG-SQLITE-STARTUP-FIX.md -5b9deeab0cd59b3c649fd73f129361a1e773ed3955cded0048b8cb280bb32e88 ./CHANGELOG-SQLITE-STORAGE.md -42c1cacdc1f792773622017c3528dc626674456f478c41aaf44ff0a32e5f87bd ./Dockerfile -5534536965bf0479455f97324c242160202650ca1256f1ba0420b4ad67125e49 ./GLPI-KB.md -c546524a3add0b1beb8d1ca675fd602e2ea7c7bddfc04759519c21a02667eb61 ./ITERATIVE-GROUNDED-RESEARCH.md -e3ae87108607ca494668a9974c467a5c529b9599bf88d3d2a79ddc15b64e4a38 ./KNOWLEDGE-SYNTHESIS.md -696d2da2338cd8190b9614707e4059d78ce291e7334f273633aad815c3b6a6df ./Makefile -381d7d6ac9e3c2e63c9ecdaa42ed4c73058f5d78e57c7532bb75a9663c919530 ./OLLAMA-POOL.md -2c0062941ef3edbd40d46b823934a7d0a3a9da7581b83d0b9360e8aaa7694b1b ./PERSISTENCE.md -4242cf830d14ecca0e2d069c3a54105e66d24ddc9b7631ef80bb14009d9fcfd9 ./README.md -2838cd19ac2bfa35bebbef2541f631b99221b5997bbb6dbc27146a66a3a1ad34 ./RUNTIME-CONTROLS-HONEYCOMB.md -c3da43b33e550901d55789f2ee526c2e50f61ee028a3f59e0a40e77e1057fde7 ./SEARXNG-VISUALIZATION.md -c69419c0327425186cfb84f25746feff226ce813cf467b3f252e729517455047 ./SOURCE-ONLY-FILTERS.md -ae7bc1f1959071f79b76ca8a4ba103064ec0d5c5752af346175731ef4f636d9d ./SQLITE-STORAGE.md -116a87c5e7c4fcfc333bbdf84979d5bab619b8a0936cd9f8838df04c9981bff7 ./VALIDATION-AUTONOMOUS-RESEARCH.md -e05449bab6250e585a6c0ac0735008cd533baf07dbf7149ddae609ae252d4425 ./VALIDATION-CONSTELLATION-ECO-TRANSITIONS.md -e7ab2cddec372db906c7883a6e0861b71999043cfb9b94e527c3b2aee4532035 ./VALIDATION-ITERATIVE-GROUNDED-RESEARCH.md -e08b0eaab827fe03d5a72327e8fdfe9ce4025097e28208714246969e7d059561 ./VALIDATION-SEARXNG-DIAGNOSTICS.md -f46938b5de7e139e1b21868b1bedce6e312d69cd61602b4f8f43512a327dd422 ./VALIDATION-SOURCE-ONLY-FILTERS.md -4cd120496664388717fe422a8c54380708df723fea26b35f81799665dfaf2c1c ./VALIDATION-SQLITE.md -f6699e4cdbaadc720e4b8a22c557d02319325283775a2b8a87ff78ca202e3386 ./VISUALIZATION-PERFORMANCE.md -8970f2abf17bddcd0d84387e8a4975588df2776f03a7a54c5a283470769ca0c8 ./cmd/brain/main.go -e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 ./data/.gitkeep -21b51d0e1b7ed07c20f7f3a5da76dedab8df44a94a51724b67b0c3411599fe15 ./deployment/README.md -ec106bc91cb70f7f41d3ff0369373ef2a9cf3c4da9cf5af5a13af028c828cf37 ./deployment/docker-compose.full.yml -d990e01adebf65f74e40508c99add46e09c9e51f1aa251ed430814a38c2c635e ./docker-compose.yml -1542b064707bef0c4488f558f77c02db5f4cfc8d4da228f615ebdc3ee9ace8fd ./go.mod -864c3376212497b070feca13d26cfc28e96876078ce7a0b5b0c0470e2dd4fbf8 ./go.sum -ed7fa0e09e94aa9e89c93b00303d4ce6f0f20dac626ecb91181c3aabc76bc8ff ./integrations/agent/README.md -9e15349702f876b1fa74e6caa1e97367d8a11b266fa5a1e40152b10c859b23c2 ./integrations/agent/glpi-ai-agent-neural-brain.patch -3c0fc6913501976100521526e1ee8e7988d33fbce3f7b4bab26387d42b0966f5 ./integrations/knowledgebase/README.md -12f8424f863b13f19aab9c2e6c2828c0ad824a55a62fe336e062db534102bc3a ./integrations/knowledgebase/glpi-ai-knowledgebase-neural-brain.patch -50d05fa2a183f5f3eaab0545cb48d3abb64be62eb5d84dc2c6d99c7125f7344b ./internal/activity/broker.go -3cda19c60313fd29c8d5335a79589ce5a454a5621d5d5cfcbf07381ef3db61a9 ./internal/config/config.go -7d05b3e067d453e3dfaffe5cb3335badec1fe54a6db4ef62024abd9f51936c43 ./internal/config/config_test.go -2d948168b80e8d0d59f2890396bb9f3000555d9d0ded89ec424b86fe495856d1 ./internal/engine/article.go -d5d18a26010fe5f308c05b79e3a3d7501c8490bb33098357cde4373affd7e60d ./internal/engine/article_format_test.go -a7945db4987e50c1d09504335a560fc50ef5277a22bda78e8527768b6bbafe78 ./internal/engine/article_research.go -501597b1b9340726e13c200099fdc10dc2a661410c76dce16f250b4dc6233871 ./internal/engine/article_research_cache.go -3ca7037231935329d49b6b80553fbfef206c079a6aed4c2379dadd46d39ebc0d ./internal/engine/article_research_cache_test.go -289d7dca4f0294072521daa229ff2dcae7f159af4631d6567853f54b920506ce ./internal/engine/article_research_test.go -ee0197234e4b6b01c06dfe33c1f22cd73212a8b08aeb81fec98665ff21b5349c ./internal/engine/autonomous_research.go -62a69f1af6fc3842e34f3847a5f868f81202feaaa6429e4e84db8115f5d14ad8 ./internal/engine/autonomous_research_test.go -c4cb62e184adf7561f64f002312ff7ee417d48b0c4f9bed2b6df26a163d50c1b ./internal/engine/engine.go -24e022c1e572b42752fed780ff57f2c857d0600460b7806f7664f3c8c7dbb811 ./internal/engine/engine_test.go -6209e4e52d8e822d0f72ca5ec04612b0a9ba8a9fe55d2a855d703f2e26e534a5 ./internal/engine/research_diagnostics.go -0eb3f00e2ab73d2dc6a4cbc1a4038533a4bb20fbbbb6190abd39feff6b34b8e1 ./internal/engine/research_diagnostics_test.go -716d42138db9eb64480c1bbaf4cb3b70bce1edf72c17c3d85a8d84f866fd8700 ./internal/engine/research_events.go -5654eadda4cfbc6f160cac9d680ad6b2f4d8c12b65b89bf99df037c3348221b2 ./internal/engine/runtime.go -87416a32118e86c42e14a959f3f807463a694d4cb9218640e0664a39bab43991 ./internal/engine/runtime_filter_test.go -b82980a646a92751bdd27a866ba1ffc6d34a3ba81d537f7b6e5a78e1432ee6fa ./internal/glpi/client.go -525102be56bc51ce8a08655b1b2bb53b67f4a1828903585fd664ed6a5133f617 ./internal/glpi/client_test.go -5e1b064bda200cf6f14278093f7a48132b175c73973706322623b049b8083737 ./internal/graph/filter.go -c14883e3db65a61672d0171c98a9c2d5ccff3fa6ac74e7236c16819d27ddf503 ./internal/graph/filter_test.go -682ba4f391580bde1956d8cd01b8752e67d83bbd671f94f8391eaee60f829852 ./internal/graph/research_tasks.go -39fe73bff6287572c66b719e400623426222ec609a535f7fce5240b6da42f7ce ./internal/graph/research_tasks_test.go -097de854f393d45d836abc77b7bb9cdc3853c601a7c368465ac5e732016bfa6e ./internal/graph/sqlite_backend.go -744d306b9c7151543e91421575775ff6eb1b7e0af187ba7d92d8a298c51704de ./internal/graph/sqlite_backend_test.go -3c14438985528cc829e69d7040e5b7d7301991508f66ab1aca67050974d3eddd ./internal/graph/store.go -941e6dda2f2b21cb4836e671d46da0a469eddccc8ed78865663b18b280f6dde5 ./internal/graph/store_test.go -4476351d388d11c6becd78b4c918fc8d47dfd70500d8b3e2ddf81f7ff61a2cea ./internal/ingest/agent.go -05bdcd8f82756af028807a9ba37e64232e2d93b6a803468d0a29665bbdb3067f ./internal/ingest/glpikb.go -ff44d56d56b9e301fbcf0f028d1bae6f0b851665f4fee65222097f1a2450f24a ./internal/ingest/glpikb_test.go -257a4beba480dab7d9b79c1f32496f6b4f648c4c05170f51a77220dbfd21fe96 ./internal/ingest/knowledge.go -a13910fb417484d56e78ae856b71fb66c63987d9abf3b513190bc52697321a18 ./internal/ingest/knowledge_test.go -6185da62c3f526bacf2b4d6bd5617a10d72af2318852679030251ccb0f1a3882 ./internal/model/model.go -5f6969313f1ca42a498d48787bbb56c173f7c272b54014e03af9bd3ac385c3fe ./internal/ollama/client.go -28854e001cfecdd06d94ee5166cfd1af3b2a0281fb5a1a7fb52280f3edf71edc ./internal/ollama/client_test.go -0bc8bb4c698c2c5dbef5c980d3e3fb88f10d35a8d7a230cbc81d218798bc1fe6 ./internal/persist/coordinator.go -46144aa719ff5c3ab787c214d8e32e4b3c6883bed068410e5403a26f6352cd6a ./internal/persist/coordinator_test.go -e7138877303bcc07c429323c4792fed112d8c16d35fd44222fe144ab0b69344f ./internal/research/fetch.go -6a9f269783a7c41d5b63f9bd5022f415ed1b5572041ca5ccae1512d6dc2a0b80 ./internal/research/fetch_test.go -a46b953c22fb2aef112b11ba65c61bda1e1582903e39489c2d081d17519ebd9d ./internal/research/searxng.go -2f82e80ad91590f419b1bbb19647b5191596e97c4d765e8736c1867ad899d457 ./internal/research/searxng_test.go -5271e30b793e2fdc4fa23b92ab8b569776ce35ddda8551fffa407eac44b16535 ./internal/web/server.go -12c30cf7d223e413abc69579eda36971a2d1f69a7e5973005ee31783fba9bf4b ./internal/web/server_test.go -10940493686434eab93e7d12eb686955b367d233fb5d1898b1659e0d7b9f9668 ./internal/web/static/app.css -af82ad4661b453f3caa9a3c705d7b2f6fd86ebc929cd784c10e5f7e0f9d46bce ./internal/web/static/app.js -5ad03db990f0a78342c49cadc7c402f1018a97a9644df73cf8292b043534ed5b ./internal/web/static/index.html -83aded814b6225395935e61fe957963c3c470f368fc9089f505b6de23e959115 ./preview.png +e888a548fa32246f01bf7d7545359fa856d6bc32651cafdc776d595d9eada34e .env.example +8ddf797373e5cb397a6a8ed35b868393846752339ec7010d0148509794500d3c ANALYSIS-DASHBOARD.md +8955cfbeff229e73f0cad664863ff21711c270a4233c3225680c89aa6268a901 ARCHITECTURE.md +cf3e5275f1eb623da3b6f734d233197e8b47879b8e7cdfd4d373a7ecdd921651 AUTONOMOUS-RESEARCH.md +7aed2194baab0fb66c561446e5f1cd7724c1166bfa3a08c9e59157d1878bf994 CHANGELOG-ANALYSIS-DASHBOARD.md +0ed6ff0d82b3b6776970200a021937611d4f3273f6727d296aec16cfac6153b8 CHANGELOG-AUTONOMOUS-RESEARCH.md +2bc149241c2d25f755e4a0470dc517f646527ee3e98d3a6c2e7798639bd70866 CHANGELOG-CONSTELLATION-ECO-TRANSITIONS.md +933cdaeae7895e31d2281d591a75f23f07e7c41d356638654bac4f5e7a20a069 CHANGELOG-FILTER-PANEL-SCROLL.md +213ac897cd415bbeb9764a847843b9eff366983cf25bb3cbce4efa4c04b9e5f5 CHANGELOG-GLPI-POOL-PERSISTENCE.md +e2f1f0400999cb59b8be09bc2743e06e0a0b2ecdc44a583af7c9a08b70d8509e CHANGELOG-GPU-NODE-LIMIT.md +a317376127be1e47b9ec9f0781fcfebdcbf7fccfaeb7840d9241f9586048fadb CHANGELOG-GROUNDED-KNOWLEDGE-SYNTHESIS.md +02a8d3e541967e2d2ef9dd5451f470679e262ad851aca9f03563b322914c3181 CHANGELOG-ITERATIVE-GROUNDED-RESEARCH.md +e167a9d64f63c3933ad40c5078684bc019db303043c34ea3965f3b089f3d32c7 CHANGELOG-KNOWLEDGE-SYNTHESIS.md +87f89a81e1124b18e092a4ea946cb037295cb9e884a46b392286272dc8134dd4 CHANGELOG-RUNTIME-HONEYCOMB.md +2d04e6d385f4b902080a0bcaab510a846a8ae6a76cb757423c50425c8433e7f9 CHANGELOG-SEARXNG-DIAGNOSTICS.md +9c760e167a9af2d3d8ca32c5aa4ef6bb4a3153047343a71c4b19ca9aef96ca32 CHANGELOG-SEARXNG-VISUALIZATION.md +be9f133ae933bdc0e0a8aa5d176dd3e39488a191337043533379e23d179f3ad2 CHANGELOG-SOURCE-ONLY-FILTERS.md +5433a7c2e67ab35fb320bc872e9024fa5f3e765736184e9f878340b8b45407aa CHANGELOG-SQLITE-STARTUP-FIX.md +5b9deeab0cd59b3c649fd73f129361a1e773ed3955cded0048b8cb280bb32e88 CHANGELOG-SQLITE-STORAGE.md +42c1cacdc1f792773622017c3528dc626674456f478c41aaf44ff0a32e5f87bd Dockerfile +5534536965bf0479455f97324c242160202650ca1256f1ba0420b4ad67125e49 GLPI-KB.md +c546524a3add0b1beb8d1ca675fd602e2ea7c7bddfc04759519c21a02667eb61 ITERATIVE-GROUNDED-RESEARCH.md +e3ae87108607ca494668a9974c467a5c529b9599bf88d3d2a79ddc15b64e4a38 KNOWLEDGE-SYNTHESIS.md +696d2da2338cd8190b9614707e4059d78ce291e7334f273633aad815c3b6a6df Makefile +381d7d6ac9e3c2e63c9ecdaa42ed4c73058f5d78e57c7532bb75a9663c919530 OLLAMA-POOL.md +2c0062941ef3edbd40d46b823934a7d0a3a9da7581b83d0b9360e8aaa7694b1b PERSISTENCE.md +5e1965cd2d5f428651ff2fac9874afa38ab6281585d4db330acac1f69c321608 README.md +2838cd19ac2bfa35bebbef2541f631b99221b5997bbb6dbc27146a66a3a1ad34 RUNTIME-CONTROLS-HONEYCOMB.md +c3da43b33e550901d55789f2ee526c2e50f61ee028a3f59e0a40e77e1057fde7 SEARXNG-VISUALIZATION.md +c69419c0327425186cfb84f25746feff226ce813cf467b3f252e729517455047 SOURCE-ONLY-FILTERS.md +ae7bc1f1959071f79b76ca8a4ba103064ec0d5c5752af346175731ef4f636d9d SQLITE-STORAGE.md +b0123b8425993dea7dd3864f930527b6a1a0e965ff29c0e4899620a64cd9451d VALIDATION-ANALYSIS-DASHBOARD.md +116a87c5e7c4fcfc333bbdf84979d5bab619b8a0936cd9f8838df04c9981bff7 VALIDATION-AUTONOMOUS-RESEARCH.md +e05449bab6250e585a6c0ac0735008cd533baf07dbf7149ddae609ae252d4425 VALIDATION-CONSTELLATION-ECO-TRANSITIONS.md +e7ab2cddec372db906c7883a6e0861b71999043cfb9b94e527c3b2aee4532035 VALIDATION-ITERATIVE-GROUNDED-RESEARCH.md +e08b0eaab827fe03d5a72327e8fdfe9ce4025097e28208714246969e7d059561 VALIDATION-SEARXNG-DIAGNOSTICS.md +f46938b5de7e139e1b21868b1bedce6e312d69cd61602b4f8f43512a327dd422 VALIDATION-SOURCE-ONLY-FILTERS.md +4cd120496664388717fe422a8c54380708df723fea26b35f81799665dfaf2c1c VALIDATION-SQLITE.md +f6699e4cdbaadc720e4b8a22c557d02319325283775a2b8a87ff78ca202e3386 VISUALIZATION-PERFORMANCE.md +8970f2abf17bddcd0d84387e8a4975588df2776f03a7a54c5a283470769ca0c8 cmd/brain/main.go +21b51d0e1b7ed07c20f7f3a5da76dedab8df44a94a51724b67b0c3411599fe15 deployment/README.md +ec106bc91cb70f7f41d3ff0369373ef2a9cf3c4da9cf5af5a13af028c828cf37 deployment/docker-compose.full.yml +d990e01adebf65f74e40508c99add46e09c9e51f1aa251ed430814a38c2c635e docker-compose.yml +1542b064707bef0c4488f558f77c02db5f4cfc8d4da228f615ebdc3ee9ace8fd go.mod +864c3376212497b070feca13d26cfc28e96876078ce7a0b5b0c0470e2dd4fbf8 go.sum +ed7fa0e09e94aa9e89c93b00303d4ce6f0f20dac626ecb91181c3aabc76bc8ff integrations/agent/README.md +9e15349702f876b1fa74e6caa1e97367d8a11b266fa5a1e40152b10c859b23c2 integrations/agent/glpi-ai-agent-neural-brain.patch +3c0fc6913501976100521526e1ee8e7988d33fbce3f7b4bab26387d42b0966f5 integrations/knowledgebase/README.md +12f8424f863b13f19aab9c2e6c2828c0ad824a55a62fe336e062db534102bc3a integrations/knowledgebase/glpi-ai-knowledgebase-neural-brain.patch +93d8993e09473559a191c4e01252d6d1fdb214646d65e1271cde018b47d939ed internal/activity/broker.go +3cda19c60313fd29c8d5335a79589ce5a454a5621d5d5cfcbf07381ef3db61a9 internal/config/config.go +7d05b3e067d453e3dfaffe5cb3335badec1fe54a6db4ef62024abd9f51936c43 internal/config/config_test.go +2d948168b80e8d0d59f2890396bb9f3000555d9d0ded89ec424b86fe495856d1 internal/engine/article.go +d5d18a26010fe5f308c05b79e3a3d7501c8490bb33098357cde4373affd7e60d internal/engine/article_format_test.go +a7945db4987e50c1d09504335a560fc50ef5277a22bda78e8527768b6bbafe78 internal/engine/article_research.go +501597b1b9340726e13c200099fdc10dc2a661410c76dce16f250b4dc6233871 internal/engine/article_research_cache.go +3ca7037231935329d49b6b80553fbfef206c079a6aed4c2379dadd46d39ebc0d internal/engine/article_research_cache_test.go +289d7dca4f0294072521daa229ff2dcae7f159af4631d6567853f54b920506ce internal/engine/article_research_test.go +ee0197234e4b6b01c06dfe33c1f22cd73212a8b08aeb81fec98665ff21b5349c internal/engine/autonomous_research.go +62a69f1af6fc3842e34f3847a5f868f81202feaaa6429e4e84db8115f5d14ad8 internal/engine/autonomous_research_test.go +1beab82f85be8ba29a429df1cf0fd9ce6d754031796f99a6c8e1638e20193fb1 internal/engine/engine.go +24e022c1e572b42752fed780ff57f2c857d0600460b7806f7664f3c8c7dbb811 internal/engine/engine_test.go +6209e4e52d8e822d0f72ca5ec04612b0a9ba8a9fe55d2a855d703f2e26e534a5 internal/engine/research_diagnostics.go +0eb3f00e2ab73d2dc6a4cbc1a4038533a4bb20fbbbb6190abd39feff6b34b8e1 internal/engine/research_diagnostics_test.go +716d42138db9eb64480c1bbaf4cb3b70bce1edf72c17c3d85a8d84f866fd8700 internal/engine/research_events.go +5654eadda4cfbc6f160cac9d680ad6b2f4d8c12b65b89bf99df037c3348221b2 internal/engine/runtime.go +87416a32118e86c42e14a959f3f807463a694d4cb9218640e0664a39bab43991 internal/engine/runtime_filter_test.go +b82980a646a92751bdd27a866ba1ffc6d34a3ba81d537f7b6e5a78e1432ee6fa internal/glpi/client.go +525102be56bc51ce8a08655b1b2bb53b67f4a1828903585fd664ed6a5133f617 internal/glpi/client_test.go +2c7c2b531a908b77189640b55cfdc7308726c5a7344d946ec3ea5b8d5439e38d internal/graph/analysis_dashboard.go +577a7704e5018f5a0487f6095509e81b062847a381ed5b00035a36362373aed8 internal/graph/analysis_dashboard_test.go +5e1b064bda200cf6f14278093f7a48132b175c73973706322623b049b8083737 internal/graph/filter.go +c14883e3db65a61672d0171c98a9c2d5ccff3fa6ac74e7236c16819d27ddf503 internal/graph/filter_test.go +682ba4f391580bde1956d8cd01b8752e67d83bbd671f94f8391eaee60f829852 internal/graph/research_tasks.go +39fe73bff6287572c66b719e400623426222ec609a535f7fce5240b6da42f7ce internal/graph/research_tasks_test.go +195c734d1b0608ae3eee14cfa8004299f1e88084d660feb9ddec5c8e6fbfc7f6 internal/graph/sqlite_backend.go +744d306b9c7151543e91421575775ff6eb1b7e0af187ba7d92d8a298c51704de internal/graph/sqlite_backend_test.go +40d3f3fdc40ba3237fb452c087f8071468a97028ce6bd880452af660742c867c internal/graph/store.go +941e6dda2f2b21cb4836e671d46da0a469eddccc8ed78865663b18b280f6dde5 internal/graph/store_test.go +4476351d388d11c6becd78b4c918fc8d47dfd70500d8b3e2ddf81f7ff61a2cea internal/ingest/agent.go +05bdcd8f82756af028807a9ba37e64232e2d93b6a803468d0a29665bbdb3067f internal/ingest/glpikb.go +ff44d56d56b9e301fbcf0f028d1bae6f0b851665f4fee65222097f1a2450f24a internal/ingest/glpikb_test.go +257a4beba480dab7d9b79c1f32496f6b4f648c4c05170f51a77220dbfd21fe96 internal/ingest/knowledge.go +a13910fb417484d56e78ae856b71fb66c63987d9abf3b513190bc52697321a18 internal/ingest/knowledge_test.go +6185da62c3f526bacf2b4d6bd5617a10d72af2318852679030251ccb0f1a3882 internal/model/model.go +5f6969313f1ca42a498d48787bbb56c173f7c272b54014e03af9bd3ac385c3fe internal/ollama/client.go +28854e001cfecdd06d94ee5166cfd1af3b2a0281fb5a1a7fb52280f3edf71edc internal/ollama/client_test.go +0bc8bb4c698c2c5dbef5c980d3e3fb88f10d35a8d7a230cbc81d218798bc1fe6 internal/persist/coordinator.go +46144aa719ff5c3ab787c214d8e32e4b3c6883bed068410e5403a26f6352cd6a internal/persist/coordinator_test.go +e7138877303bcc07c429323c4792fed112d8c16d35fd44222fe144ab0b69344f internal/research/fetch.go +6a9f269783a7c41d5b63f9bd5022f415ed1b5572041ca5ccae1512d6dc2a0b80 internal/research/fetch_test.go +a46b953c22fb2aef112b11ba65c61bda1e1582903e39489c2d081d17519ebd9d internal/research/searxng.go +2f82e80ad91590f419b1bbb19647b5191596e97c4d765e8736c1867ad899d457 internal/research/searxng_test.go +984966a437944530008f4888944a91130604aad8891da3f67a89656bc991d9e4 internal/web/server.go +b5abd1c3591242a7e8835eb38866410558d0e7901c75f5b11b94039ee3747716 internal/web/server_test.go +3e27efdbeaf8aba34864f6d1dd47d101d04c87993d02e35ee08df34affaefe29 internal/web/static/analysis.css +db27a3c62848dbb0f383886c1075d2c0c779363cea3e847793104ca708c1d6f0 internal/web/static/analysis.html +5b6e8d6952c2ffd66b79d86bf32db139f73e37b0d7d02d154804ef111cce19b6 internal/web/static/analysis.js +6a92c00a768cbef68b2565f6e021351833373bacea7c537c8fba61746e917edc internal/web/static/app.css +af82ad4661b453f3caa9a3c705d7b2f6fd86ebc929cd784c10e5f7e0f9d46bce internal/web/static/app.js +a9327a08da143c45cfb8708a8bff5c4bf780644dddc891d0d2aceffb0bb183b1 internal/web/static/index.html +83aded814b6225395935e61fe957963c3c470f368fc9089f505b6de23e959115 preview.png diff --git a/VALIDATION-ANALYSIS-DASHBOARD.md b/VALIDATION-ANALYSIS-DASHBOARD.md new file mode 100644 index 0000000..559f0d1 --- /dev/null +++ b/VALIDATION-ANALYSIS-DASHBOARD.md @@ -0,0 +1,25 @@ +# Validierung: Analysis Dashboard + +Durchgeführt: + +- Go-Typkompilierung aller Pakete mit lokalem Compile-Stub für `modernc.org/sqlite` +- `go vet ./...` +- JavaScript-Syntaxprüfung für `app.js` und `analysis.js` +- HTML-/DOM-Test der Analyseoberfläche in Headless Chromium +- Rendering von Laufbewertung, Kennzahlen, Timeline, Laufprotokoll, Änderungsjournal und Rohereignissen +- Browserprüfung ohne JavaScript- oder Konsolenfehler +- SQLite-Schemaerzeugung mit Python `sqlite3` +- Insert/Select für `analysis_events`, `analysis_points` und `analysis_changes` +- Foreign-Key-Cascade vom Event auf Checkpoint und Detailänderungen +- Platzhalter- und Spaltenprüfung des Analyse-Inserts +- Unit-Test der Laufgruppierung und positiven Ergebnisbewertung +- statische Prüfung der neuen Webrouten und eingebetteten Assets +- Compose-YAML-Prüfung +- ZIP- und Patch-Integritätsprüfung + +Nicht in dieser isolierten Umgebung ausführbar: + +- echter Go-Runtime-Test mit `modernc.org/sqlite`, weil die Go-Modulauflösung nach `proxy.golang.org` blockiert ist +- produktiver Langzeittest mit realer Knowledgebase, Ollama-Pool und SearXNG + +Das ausgelieferte Projekt enthält unverändert den echten Treiber `modernc.org/sqlite v1.37.1`; der Compile-Stub ist nicht Bestandteil des Pakets. diff --git a/internal/activity/broker.go b/internal/activity/broker.go index 7ea7b11..97ad2db 100644 --- a/internal/activity/broker.go +++ b/internal/activity/broker.go @@ -16,6 +16,8 @@ type Broker struct { subs map[int]chan model.Activity recent []model.Activity maxRecent int + sinkMu sync.RWMutex + sink func(model.Activity) } func New(maxRecent int) *Broker { @@ -24,6 +26,16 @@ func New(maxRecent int) *Broker { } return &Broker{subs: map[int]chan model.Activity{}, maxRecent: maxRecent} } + +// SetSink attaches a non-UI activity recorder. The sink is called after the +// in-memory broker lock is released, so recording cannot block SSE delivery or +// deadlock the broker. +func (b *Broker) SetSink(sink func(model.Activity)) { + b.sinkMu.Lock() + b.sink = sink + b.sinkMu.Unlock() +} + func (b *Broker) Publish(a model.Activity) { if a.Timestamp.IsZero() { a.Timestamp = time.Now().UTC() @@ -43,6 +55,12 @@ func (b *Broker) Publish(a model.Activity) { } } b.mu.Unlock() + b.sinkMu.RLock() + sink := b.sink + b.sinkMu.RUnlock() + if sink != nil { + sink(a) + } } func (b *Broker) Recent() []model.Activity { b.mu.RLock() diff --git a/internal/engine/engine.go b/internal/engine/engine.go index 1b0a55b..a364bb3 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -92,6 +92,9 @@ type Engine struct { } func New(cfg config.Config, g *graph.Store, b *activity.Broker) *Engine { + if b != nil && g != nil { + b.SetSink(g.RecordActivity) + } if strings.TrimSpace(cfg.GLPIKBSource) == "" { cfg.GLPIKBSource = "GLPI Knowledge Base" } @@ -227,6 +230,9 @@ func New(cfg config.Config, g *graph.Store, b *activity.Broker) *Engine { client := glpi.New(cfg.GLPIURL, cfg.GLPIAPIVersion, cfg.GLPIClientID, cfg.GLPIClientSecret, cfg.GLPIUsername, cfg.GLPIPassword, cfg.GLPITimeout) e.GLPIKB = ingest.NewGLPIKBSyncer(ingest.GLPIKBConfig{Enabled: true, Path: cfg.GLPIKBPath, Filter: cfg.GLPIKBFilter, Limit: cfg.GLPIKBLimit, SyncInterval: cfg.GLPIKBSyncInterval, Source: cfg.GLPIKBSource, CachePath: filepath.Join(cfg.DataDir, "glpi-kb-cache.json"), ShouldSync: e.LearningEnabled}, client, g, b, persistence) } + if b != nil { + b.Publish(model.Activity{Type: "system.started", Source: "brain", Phase: "startup", Message: "Neural Brain wurde gestartet; das Analyseprotokoll zeichnet Läufe und Graphänderungen auf", Strength: .3, Metadata: map[string]any{"chat_model": cfg.ChatModel, "embedding_model": cfg.EmbeddingModel, "graph_version": g.Version()}}) + } return e } func (e *Engine) Start(ctx context.Context) { @@ -471,9 +477,15 @@ func (e *Engine) Scan(ctx context.Context) error { } e.mu.Lock() defer e.mu.Unlock() + started := time.Now().UTC() + runID := fmt.Sprintf("learning-scan-%d", started.UnixNano()) beforeVersion := e.Graph.Version() + beforeMutations := e.Graph.MutationStats() + beforeNodes, beforeEdges, _ := e.Graph.Counts() + e.Broker.Publish(model.Activity{Type: "learning.scan.started", Source: "brain", Phase: "ingest", Message: "KB-Lernlauf gestartet: Quellen werden verglichen, Änderungen übernommen und Embeddings geprüft", Strength: .52, Metadata: map[string]any{"run_id": runID, "nodes_before": beforeNodes, "edges_before": beforeEdges, "graph_version_before": beforeVersion}}) count, err := e.Scanner.Scan() if err != nil { + e.Broker.Publish(model.Activity{Type: "learning.scan.failed", Source: "brain", Phase: "ingest", Message: "KB-Lernlauf ist beim Einlesen der Wissensquellen fehlgeschlagen", Strength: .35, Metadata: map[string]any{"run_id": runID, "error": err.Error(), "duration_ms": time.Since(started).Milliseconds()}}) return err } pendingEmbeddings := len(e.Graph.NodesForEmbeddingScoped(e.effectiveLearningFilter())) @@ -505,10 +517,16 @@ func (e *Engine) Scan(ctx context.Context) error { e.stateMu.Lock() e.lastScan = time.Now().UTC() e.stateMu.Unlock() + nodes, edges, version := e.Graph.Counts() if e.Graph.Version() != beforeVersion { - nodes, edges, _ := e.Graph.Counts() - e.Broker.Publish(model.Activity{Type: "graph.updated", Source: "brain", Phase: "indexed", Message: fmt.Sprintf("%d Wissenselemente · %d Nodes · %d Edges", count, nodes, edges), Strength: .55, Metadata: map[string]any{"nodes": nodes, "edges": edges, "knowledge_elements": count}}) + e.Broker.Publish(model.Activity{Type: "graph.updated", Source: "brain", Phase: "indexed", Message: fmt.Sprintf("%d Wissenselemente · %d Nodes · %d Edges", count, nodes, edges), Strength: .55, Metadata: map[string]any{"run_id": runID, "nodes": nodes, "edges": edges, "knowledge_elements": count}}) } + delta := e.Graph.MutationStats().Delta(beforeMutations) + result := "unchanged" + if !delta.Empty() { + result = "updated" + } + e.Broker.Publish(model.Activity{Type: "learning.scan.completed", Source: "brain", Phase: "indexed", Message: fmt.Sprintf("KB-Lernlauf abgeschlossen · %d Wissenselemente · %d neue Nodes · %d neue Edges · %d neue/neu berechnete Embeddings", count, delta.NodesCreated, delta.EdgesCreated, delta.VectorsCreated+delta.VectorsUpdated), Strength: .64, Metadata: map[string]any{"run_id": runID, "result": result, "duration_ms": time.Since(started).Milliseconds(), "knowledge_elements": count, "nodes_before": beforeNodes, "nodes_after": nodes, "edges_before": beforeEdges, "edges_after": edges, "graph_version_before": beforeVersion, "graph_version_after": version, "nodes_created": delta.NodesCreated, "nodes_updated": delta.NodesUpdated, "nodes_deleted": delta.NodesDeleted, "edges_created": delta.EdgesCreated, "edges_updated": delta.EdgesUpdated, "edges_deleted": delta.EdgesDeleted, "vectors_created": delta.VectorsCreated, "vectors_updated": delta.VectorsUpdated, "vectors_deleted": delta.VectorsDeleted, "pending_embeddings": len(e.Graph.NodesForEmbeddingScoped(e.effectiveLearningFilter())), "ollama_ok": e.isOllamaOK()}}) return nil } func (e *Engine) ensureEmbeddings(ctx context.Context) error { diff --git a/internal/graph/analysis_dashboard.go b/internal/graph/analysis_dashboard.go new file mode 100644 index 0000000..910382e --- /dev/null +++ b/internal/graph/analysis_dashboard.go @@ -0,0 +1,1212 @@ +package graph + +import ( + "context" + "encoding/json" + "fmt" + "sort" + "strings" + "sync/atomic" + "time" + + "github.com/local/glpi-neural-brain/internal/model" +) + +// MutationStats are monotonic, process-local counters for material changes to +// the live graph. They deliberately distinguish creation, updates and removal +// so the analysis dashboard can explain a run even when the net graph size did +// not change. +type MutationStats struct { + NodesCreated uint64 `json:"nodes_created"` + NodesUpdated uint64 `json:"nodes_updated"` + NodesDeleted uint64 `json:"nodes_deleted"` + EdgesCreated uint64 `json:"edges_created"` + EdgesUpdated uint64 `json:"edges_updated"` + EdgesDeleted uint64 `json:"edges_deleted"` + VectorsCreated uint64 `json:"vectors_created"` + VectorsUpdated uint64 `json:"vectors_updated"` + VectorsDeleted uint64 `json:"vectors_deleted"` +} + +func (m MutationStats) Delta(previous MutationStats) MutationStats { + return MutationStats{ + NodesCreated: safeDelta(m.NodesCreated, previous.NodesCreated), + NodesUpdated: safeDelta(m.NodesUpdated, previous.NodesUpdated), + NodesDeleted: safeDelta(m.NodesDeleted, previous.NodesDeleted), + EdgesCreated: safeDelta(m.EdgesCreated, previous.EdgesCreated), + EdgesUpdated: safeDelta(m.EdgesUpdated, previous.EdgesUpdated), + EdgesDeleted: safeDelta(m.EdgesDeleted, previous.EdgesDeleted), + VectorsCreated: safeDelta(m.VectorsCreated, previous.VectorsCreated), + VectorsUpdated: safeDelta(m.VectorsUpdated, previous.VectorsUpdated), + VectorsDeleted: safeDelta(m.VectorsDeleted, previous.VectorsDeleted), + } +} + +func (m *MutationStats) Add(other MutationStats) { + m.NodesCreated += other.NodesCreated + m.NodesUpdated += other.NodesUpdated + m.NodesDeleted += other.NodesDeleted + m.EdgesCreated += other.EdgesCreated + m.EdgesUpdated += other.EdgesUpdated + m.EdgesDeleted += other.EdgesDeleted + m.VectorsCreated += other.VectorsCreated + m.VectorsUpdated += other.VectorsUpdated + m.VectorsDeleted += other.VectorsDeleted +} + +func (m MutationStats) Empty() bool { + return m.NodesCreated+m.NodesUpdated+m.NodesDeleted+ + m.EdgesCreated+m.EdgesUpdated+m.EdgesDeleted+ + m.VectorsCreated+m.VectorsUpdated+m.VectorsDeleted == 0 +} + +func safeDelta(current, previous uint64) uint64 { + if current < previous { + return current + } + return current - previous +} + +type GraphChange struct { + ID int64 `json:"id,omitempty"` + EventID string `json:"event_id,omitempty"` + Timestamp time.Time `json:"timestamp"` + ProcessID string `json:"process_id"` + GraphVersion uint64 `json:"graph_version"` + EntityKind string `json:"entity_kind"` + Action string `json:"action"` + EntityID string `json:"entity_id"` + Label string `json:"label,omitempty"` + RelationType string `json:"relation_type,omitempty"` + Origin string `json:"origin,omitempty"` + Details map[string]any `json:"details,omitempty"` +} + +type AnalysisPoint struct { + ProcessID string `json:"process_id"` + GraphVersion uint64 `json:"graph_version"` + NodeCount int `json:"node_count"` + EdgeCount int `json:"edge_count"` + VectorCount int `json:"vector_count"` + Mutations MutationStats `json:"mutations"` + Delta MutationStats `json:"delta"` +} + +type AnalysisEventRecord struct { + Activity model.Activity `json:"activity"` + Point AnalysisPoint `json:"point"` + ChangeCount int `json:"change_count"` + ChangesTruncated int `json:"changes_truncated,omitempty"` +} + +type AnalysisTimelineBucket struct { + Start time.Time `json:"start"` + Events int `json:"events"` + Successes int `json:"successes"` + Warnings int `json:"warnings"` + Failures int `json:"failures"` + Comparisons int64 `json:"comparisons"` + SearchResults int64 `json:"search_results"` + Mutations MutationStats `json:"mutations"` +} + +type AnalysisRun struct { + ID string `json:"id"` + Kind string `json:"kind"` + Title string `json:"title"` + Status string `json:"status"` + Verdict string `json:"verdict"` + Explanation string `json:"explanation"` + StartedAt time.Time `json:"started_at"` + CompletedAt time.Time `json:"completed_at,omitempty"` + DurationMS int64 `json:"duration_ms"` + Trigger string `json:"trigger,omitempty"` + Outcome string `json:"outcome,omitempty"` + EventCount int `json:"event_count"` + Mutations MutationStats `json:"mutations"` + NodeIDs []string `json:"node_ids,omitempty"` + EdgeIDs []string `json:"edge_ids,omitempty"` + Metrics map[string]any `json:"metrics,omitempty"` + Events []AnalysisEventRecord `json:"events,omitempty"` +} + +type DetailedGraphAnalysis struct { + Summary model.GraphAnalysis `json:"summary"` + NodeKinds map[string]int `json:"node_kinds"` + NodeStatuses map[string]int `json:"node_statuses"` + NodeOrigins map[string]int `json:"node_origins"` + NodeSources map[string]int `json:"node_sources"` + EdgeTypes map[string]int `json:"edge_types"` + EdgeStatuses map[string]int `json:"edge_statuses"` + EdgeOrigins map[string]int `json:"edge_origins"` + VectorRows int `json:"vector_rows"` + VectorEligibleNodes int `json:"vector_eligible_nodes"` + VectorCoverage float64 `json:"vector_coverage"` + EmbeddingDimensions map[string]int `json:"embedding_dimensions"` + AverageAIConfidence float64 `json:"average_ai_confidence"` + AverageAISimilarity float64 `json:"average_ai_similarity"` + SimilarityEdgeCount int `json:"similarity_edge_count"` + NewestNodes []model.Node `json:"newest_nodes"` + NewestEdges []model.Edge `json:"newest_edges"` + CurrentProcessChange MutationStats `json:"current_process_changes"` +} + +type AnalysisAuditStatus struct { + QueueDepth int `json:"queue_depth"` + QueueCapacity int `json:"queue_capacity"` + DroppedEvents uint64 `json:"dropped_events"` + LastPersistedAt time.Time `json:"last_persisted_at,omitempty"` + LastError string `json:"last_error,omitempty"` +} + +type AnalysisHistory struct { + GeneratedAt time.Time `json:"generated_at"` + Since time.Time `json:"since"` + Events []AnalysisEventRecord `json:"events"` + Runs []AnalysisRun `json:"runs"` + Timeline []AnalysisTimelineBucket `json:"timeline"` + Changes []GraphChange `json:"changes"` + Totals MutationStats `json:"totals"` + EventCounts map[string]int `json:"event_counts"` + StatusCounts map[string]int `json:"status_counts"` + DroppedEvents uint64 `json:"dropped_events"` + DroppedDetailedChanges uint64 `json:"dropped_detailed_changes"` + RawEventCount int `json:"raw_event_count"` + ChangeCount int `json:"change_count"` + Audit AnalysisAuditStatus `json:"audit"` +} + +type analysisRecord struct { + activity model.Activity + point AnalysisPoint + changes []GraphChange + changesTruncated int +} + +var processCounter atomic.Uint64 + +func newProcessID() string { + return fmt.Sprintf("process-%d-%d", time.Now().UTC().UnixNano(), processCounter.Add(1)) +} + +func (s *Store) initAnalysisWriter() { + s.analysisMu.Lock() + defer s.analysisMu.Unlock() + if s.analysisQueue != nil { + return + } + if strings.TrimSpace(s.processID) == "" { + s.processID = newProcessID() + } + s.analysisQueue = make(chan analysisRecord, 4096) + s.analysisWG.Add(1) + go func(queue <-chan analysisRecord) { + defer s.analysisWG.Done() + for { + first, ok := <-queue + if !ok { + return + } + batch := []analysisRecord{first} + timer := time.NewTimer(50 * time.Millisecond) + collect: + for len(batch) < 64 { + select { + case record, open := <-queue: + if !open { + if !timer.Stop() { + <-timer.C + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + err := s.persistAnalysisBatch(ctx, batch) + cancel() + s.recordAnalysisPersistResult(err) + return + } + batch = append(batch, record) + case <-timer.C: + break collect + } + } + if !timer.Stop() { + select { + case <-timer.C: + default: + } + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + err := s.persistAnalysisBatch(ctx, batch) + cancel() + s.recordAnalysisPersistResult(err) + } + }(s.analysisQueue) +} + +func (s *Store) recordAnalysisPersistResult(err error) { + s.analysisMu.Lock() + defer s.analysisMu.Unlock() + if err != nil { + s.analysisLastError = err.Error() + return + } + s.analysisLastError = "" + s.analysisLastPersisted = time.Now().UTC() +} + +// RecordActivity captures a cheap O(1) graph checkpoint synchronously and +// performs the SQLite write asynchronously. The expensive graph analysis is +// only calculated when the dedicated dashboard is opened. +func (s *Store) RecordActivity(activity model.Activity) { + if s == nil || s.db == nil || activity.Type == "brain.idle" { + return + } + if activity.Timestamp.IsZero() { + activity.Timestamp = time.Now().UTC() + } + s.mu.Lock() + nodes := len(s.nodes) + edges := len(s.edges) + point := AnalysisPoint{ + ProcessID: s.processID, + GraphVersion: s.version, + NodeCount: nodes, + EdgeCount: edges, + VectorCount: len(s.vectors), + Mutations: s.mutations, + } + changes := append([]GraphChange(nil), s.analysisPendingChanges...) + changesTruncated := s.analysisPendingTruncated + s.analysisPendingChanges = s.analysisPendingChanges[:0] + s.analysisPendingTruncated = 0 + s.mu.Unlock() + + s.analysisMu.Lock() + if s.analysisQueue == nil { + s.analysisMu.Unlock() + return + } + point.Delta = point.Mutations.Delta(s.analysisLastMutations) + s.analysisLastMutations = point.Mutations + queued := true + select { + case s.analysisQueue <- analysisRecord{activity: activity, point: point, changes: changes, changesTruncated: changesTruncated}: + default: + s.analysisDropped++ + queued = false + } + s.analysisMu.Unlock() + if !queued && len(changes)+changesTruncated > 0 { + s.mu.Lock() + s.analysisChangesDropped += uint64(len(changes) + changesTruncated) + s.mu.Unlock() + } +} + +func (s *Store) closeAnalysisWriter() { + s.analysisMu.Lock() + queue := s.analysisQueue + if queue != nil { + s.analysisQueue = nil + close(queue) + } + s.analysisMu.Unlock() + if queue != nil { + s.analysisWG.Wait() + } +} + +func (s *Store) persistAnalysisRecord(ctx context.Context, record analysisRecord) error { + return s.persistAnalysisBatch(ctx, []analysisRecord{record}) +} + +func (s *Store) persistAnalysisBatch(ctx context.Context, records []analysisRecord) error { + if len(records) == 0 { + return nil + } + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + eventStatement, err := tx.PrepareContext(ctx, `INSERT OR REPLACE INTO analysis_events(id,type,source,phase,query,message,node_ids_json,edge_ids_json,strength,metadata_json,timestamp_ns,process_id) +VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`) + if err != nil { + return err + } + defer eventStatement.Close() + pointStatement, err := tx.PrepareContext(ctx, `INSERT OR REPLACE INTO analysis_points(event_id,timestamp_ns,process_id,graph_version,node_count,edge_count,vector_count, +node_created,node_updated,node_deleted,edge_created,edge_updated,edge_deleted,vector_created,vector_updated,vector_deleted, +delta_node_created,delta_node_updated,delta_node_deleted,delta_edge_created,delta_edge_updated,delta_edge_deleted,delta_vector_created,delta_vector_updated,delta_vector_deleted, +change_count,changes_truncated) +VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`) + if err != nil { + return err + } + defer pointStatement.Close() + changeStatement, err := tx.PrepareContext(ctx, `INSERT INTO analysis_changes(event_id,timestamp_ns,process_id,graph_version,entity_kind,action,entity_id,label,relation_type,origin,details_json) +VALUES(?,?,?,?,?,?,?,?,?,?,?)`) + if err != nil { + return err + } + defer changeStatement.Close() + + for _, record := range records { + nodeIDs, _ := json.Marshal(record.activity.NodeIDs) + edgeIDs, _ := json.Marshal(record.activity.EdgeIDs) + metadata, _ := json.Marshal(record.activity.Metadata) + if _, err := eventStatement.ExecContext(ctx, record.activity.ID, record.activity.Type, record.activity.Source, record.activity.Phase, record.activity.Query, record.activity.Message, string(nodeIDs), string(edgeIDs), record.activity.Strength, string(metadata), record.activity.Timestamp.UnixNano(), record.point.ProcessID); err != nil { + return err + } + m, d := record.point.Mutations, record.point.Delta + if _, err := pointStatement.ExecContext(ctx, record.activity.ID, record.activity.Timestamp.UnixNano(), record.point.ProcessID, record.point.GraphVersion, record.point.NodeCount, record.point.EdgeCount, record.point.VectorCount, + m.NodesCreated, m.NodesUpdated, m.NodesDeleted, m.EdgesCreated, m.EdgesUpdated, m.EdgesDeleted, m.VectorsCreated, m.VectorsUpdated, m.VectorsDeleted, + d.NodesCreated, d.NodesUpdated, d.NodesDeleted, d.EdgesCreated, d.EdgesUpdated, d.EdgesDeleted, d.VectorsCreated, d.VectorsUpdated, d.VectorsDeleted, + len(record.changes), record.changesTruncated); err != nil { + return err + } + for _, change := range record.changes { + details, _ := json.Marshal(change.Details) + timestamp := change.Timestamp + if timestamp.IsZero() { + timestamp = record.activity.Timestamp + } + if _, err := changeStatement.ExecContext(ctx, record.activity.ID, timestamp.UnixNano(), change.ProcessID, change.GraphVersion, change.EntityKind, change.Action, change.EntityID, change.Label, change.RelationType, change.Origin, string(details)); err != nil { + return err + } + } + } + if err := tx.Commit(); err != nil { + return err + } + last := records[len(records)-1] + if last.activity.Timestamp.Unix()%997 == 0 { + cutoff := time.Now().UTC().Add(-90 * 24 * time.Hour).UnixNano() + _, _ = s.db.ExecContext(ctx, `DELETE FROM analysis_events WHERE timestamp_ns 0 { + return s.analysisDetailCache + } + + summary := s.Analyze() + detail := DetailedGraphAnalysis{ + Summary: summary, + NodeKinds: map[string]int{}, + NodeStatuses: map[string]int{}, + NodeOrigins: map[string]int{}, + NodeSources: map[string]int{}, + EdgeTypes: map[string]int{}, + EdgeStatuses: map[string]int{}, + EdgeOrigins: map[string]int{}, + EmbeddingDimensions: map[string]int{}, + } + s.mu.RLock() + for _, node := range s.nodes { + detail.NodeKinds[nonemptyAnalysis(node.Kind, "unknown")]++ + detail.NodeStatuses[nonemptyAnalysis(node.Status, "unspecified")]++ + detail.NodeOrigins[nonemptyAnalysis(node.Origin, "unknown")]++ + source := explicitNodeSource(node) + if source == "" { + source = "(ohne source)" + } + detail.NodeSources[source]++ + if node.Kind == "knowledge" || node.Kind == "ai-think" || node.Kind == "external" { + detail.VectorEligibleNodes++ + } + detail.NewestNodes = insertNewestNode(detail.NewestNodes, node, 12) + } + var confidenceSum, similaritySum float64 + for _, edge := range s.edges { + if edge.Status == "rejected" { + continue + } + detail.EdgeTypes[nonemptyAnalysis(edge.Type, "unknown")]++ + detail.EdgeStatuses[nonemptyAnalysis(edge.Status, "unspecified")]++ + detail.EdgeOrigins[nonemptyAnalysis(edge.Origin, "unknown")]++ + if edge.Origin == "ai-inference" { + confidenceSum += edge.Confidence + if similarity, ok := numericMetadata(edge.Metadata, "semantic_similarity"); ok { + similaritySum += similarity + detail.SimilarityEdgeCount++ + } + } + detail.NewestEdges = insertNewestEdge(detail.NewestEdges, edge, 12) + } + if summary.AIEdges > 0 { + detail.AverageAIConfidence = confidenceSum / float64(summary.AIEdges) + } + if detail.SimilarityEdgeCount > 0 { + detail.AverageAISimilarity = similaritySum / float64(detail.SimilarityEdgeCount) + } + detail.VectorRows = len(s.vectors) + if detail.VectorEligibleNodes > 0 { + detail.VectorCoverage = float64(detail.VectorRows) / float64(detail.VectorEligibleNodes) + } + for _, vector := range s.vectors { + detail.EmbeddingDimensions[fmt.Sprint(len(vector))]++ + } + detail.CurrentProcessChange = s.mutations + cachedVersion := s.version + s.mu.RUnlock() + + s.analysisDetailVersion = cachedVersion + s.analysisDetailCache = detail + return detail +} + +func insertNewestNode(nodes []model.Node, node model.Node, limit int) []model.Node { + index := sort.Search(len(nodes), func(i int) bool { return !nodes[i].UpdatedAt.After(node.UpdatedAt) }) + if index >= limit { + return nodes + } + nodes = append(nodes, model.Node{}) + copy(nodes[index+1:], nodes[index:]) + nodes[index] = node + if len(nodes) > limit { + nodes = nodes[:limit] + } + return nodes +} + +func insertNewestEdge(edges []model.Edge, edge model.Edge, limit int) []model.Edge { + index := sort.Search(len(edges), func(i int) bool { return !edges[i].UpdatedAt.After(edge.UpdatedAt) }) + if index >= limit { + return edges + } + edges = append(edges, model.Edge{}) + copy(edges[index+1:], edges[index:]) + edges[index] = edge + if len(edges) > limit { + edges = edges[:limit] + } + return edges +} + +func explicitNodeSource(node model.Node) string { + if node.Metadata == nil { + return "" + } + value, ok := node.Metadata["source"] + if !ok || value == nil { + return "" + } + text := strings.TrimSpace(fmt.Sprint(value)) + if text == "" || strings.EqualFold(text, "") || strings.EqualFold(text, "null") { + return "" + } + return text +} + +func nonemptyAnalysis(value, fallback string) string { + if strings.TrimSpace(value) == "" { + return fallback + } + return value +} + +func numericMetadata(metadata map[string]any, key string) (float64, bool) { + if metadata == nil { + return 0, false + } + value, ok := metadata[key] + if !ok { + return 0, false + } + switch typed := value.(type) { + case float64: + return typed, true + case float32: + return float64(typed), true + case int: + return float64(typed), true + case int64: + return float64(typed), true + case json.Number: + parsed, err := typed.Float64() + return parsed, err == nil + default: + return 0, false + } +} + +func (s *Store) AnalysisHistory(ctx context.Context, since time.Time, limit int) (AnalysisHistory, error) { + if limit < 20 { + limit = 200 + } + if limit > 1000 { + limit = 1000 + } + if since.IsZero() { + since = time.Now().UTC().Add(-24 * time.Hour) + } + fetchLimit := limit * 8 + if fetchLimit < 2000 { + fetchLimit = 2000 + } + if fetchLimit > 10000 { + fetchLimit = 10000 + } + events, err := s.analysisEvents(ctx, since, fetchLimit) + if err != nil { + return AnalysisHistory{}, err + } + chronological := append([]AnalysisEventRecord(nil), events...) + sort.Slice(chronological, func(i, j int) bool { + return chronological[i].Activity.Timestamp.Before(chronological[j].Activity.Timestamp) + }) + runs := buildAnalysisRuns(chronological) + sort.Slice(runs, func(i, j int) bool { return runs[i].StartedAt.After(runs[j].StartedAt) }) + if len(runs) > limit { + runs = runs[:limit] + } + if len(events) > limit { + events = events[:limit] + } + changeLimit := limit * 10 + if changeLimit < 500 { + changeLimit = 500 + } + if changeLimit > 10000 { + changeLimit = 10000 + } + changes, changeCount, changeErr := s.analysisChanges(ctx, since, changeLimit) + if changeErr != nil { + return AnalysisHistory{}, changeErr + } + totals, rawCount, persistedTruncated, counts, aggregateErr := s.analysisAggregate(ctx, since) + if aggregateErr != nil { + return AnalysisHistory{}, aggregateErr + } + statusCounts := map[string]int{} + for _, event := range chronological { + statusCounts[eventSeverity(event.Activity)]++ + } + timeline := buildTimeline(chronological, since) + s.analysisMu.Lock() + dropped := s.analysisDropped + audit := AnalysisAuditStatus{DroppedEvents: dropped, LastPersistedAt: s.analysisLastPersisted, LastError: s.analysisLastError} + if s.analysisQueue != nil { + audit.QueueDepth = len(s.analysisQueue) + audit.QueueCapacity = cap(s.analysisQueue) + } + s.analysisMu.Unlock() + s.mu.RLock() + droppedChanges := s.analysisChangesDropped + s.mu.RUnlock() + return AnalysisHistory{ + GeneratedAt: time.Now().UTC(), + Since: since.UTC(), + Events: events, + Runs: runs, + Timeline: timeline, + Changes: changes, + Totals: totals, + EventCounts: counts, + StatusCounts: statusCounts, + DroppedEvents: dropped, + DroppedDetailedChanges: uint64(persistedTruncated) + droppedChanges, + RawEventCount: rawCount, + ChangeCount: changeCount, + Audit: audit, + }, nil +} + +func (s *Store) analysisAggregate(ctx context.Context, since time.Time) (MutationStats, int, int, map[string]int, error) { + var totals MutationStats + var count, truncated int + err := s.db.QueryRowContext(ctx, `SELECT COUNT(*), +COALESCE(SUM(delta_node_created),0),COALESCE(SUM(delta_node_updated),0),COALESCE(SUM(delta_node_deleted),0), +COALESCE(SUM(delta_edge_created),0),COALESCE(SUM(delta_edge_updated),0),COALESCE(SUM(delta_edge_deleted),0), +COALESCE(SUM(delta_vector_created),0),COALESCE(SUM(delta_vector_updated),0),COALESCE(SUM(delta_vector_deleted),0), +COALESCE(SUM(changes_truncated),0) +FROM analysis_points WHERE timestamp_ns>=?`, since.UnixNano()).Scan(&count, + &totals.NodesCreated, &totals.NodesUpdated, &totals.NodesDeleted, + &totals.EdgesCreated, &totals.EdgesUpdated, &totals.EdgesDeleted, + &totals.VectorsCreated, &totals.VectorsUpdated, &totals.VectorsDeleted, &truncated) + if err != nil { + return MutationStats{}, 0, 0, nil, err + } + rows, err := s.db.QueryContext(ctx, `SELECT type,COUNT(*) FROM analysis_events WHERE timestamp_ns>=? GROUP BY type`, since.UnixNano()) + if err != nil { + return MutationStats{}, 0, 0, nil, err + } + defer rows.Close() + counts := map[string]int{} + for rows.Next() { + var eventType string + var eventCount int + if err := rows.Scan(&eventType, &eventCount); err != nil { + return MutationStats{}, 0, 0, nil, err + } + counts[eventType] = eventCount + } + return totals, count, truncated, counts, rows.Err() +} + +func (s *Store) analysisEvents(ctx context.Context, since time.Time, limit int) ([]AnalysisEventRecord, error) { + rows, err := s.db.QueryContext(ctx, `SELECT e.id,e.type,e.source,e.phase,e.query,e.message,e.node_ids_json,e.edge_ids_json,e.strength,e.metadata_json,e.timestamp_ns, +p.process_id,p.graph_version,p.node_count,p.edge_count,p.vector_count, +p.node_created,p.node_updated,p.node_deleted,p.edge_created,p.edge_updated,p.edge_deleted,p.vector_created,p.vector_updated,p.vector_deleted, +p.delta_node_created,p.delta_node_updated,p.delta_node_deleted,p.delta_edge_created,p.delta_edge_updated,p.delta_edge_deleted,p.delta_vector_created,p.delta_vector_updated,p.delta_vector_deleted, +p.change_count,p.changes_truncated +FROM analysis_events e JOIN analysis_points p ON p.event_id=e.id +WHERE e.timestamp_ns>=? ORDER BY e.timestamp_ns DESC LIMIT ?`, since.UnixNano(), limit) + if err != nil { + return nil, err + } + defer rows.Close() + out := []AnalysisEventRecord{} + for rows.Next() { + var record AnalysisEventRecord + var nodeIDs, edgeIDs, metadata string + var timestamp int64 + m, d := &record.Point.Mutations, &record.Point.Delta + if err := rows.Scan(&record.Activity.ID, &record.Activity.Type, &record.Activity.Source, &record.Activity.Phase, &record.Activity.Query, &record.Activity.Message, &nodeIDs, &edgeIDs, &record.Activity.Strength, &metadata, ×tamp, + &record.Point.ProcessID, &record.Point.GraphVersion, &record.Point.NodeCount, &record.Point.EdgeCount, &record.Point.VectorCount, + &m.NodesCreated, &m.NodesUpdated, &m.NodesDeleted, &m.EdgesCreated, &m.EdgesUpdated, &m.EdgesDeleted, &m.VectorsCreated, &m.VectorsUpdated, &m.VectorsDeleted, + &d.NodesCreated, &d.NodesUpdated, &d.NodesDeleted, &d.EdgesCreated, &d.EdgesUpdated, &d.EdgesDeleted, &d.VectorsCreated, &d.VectorsUpdated, &d.VectorsDeleted, + &record.ChangeCount, &record.ChangesTruncated); err != nil { + return nil, err + } + record.Activity.Timestamp = time.Unix(0, timestamp).UTC() + _ = decodeJSON(nodeIDs, &record.Activity.NodeIDs) + _ = decodeJSON(edgeIDs, &record.Activity.EdgeIDs) + _ = decodeJSON(metadata, &record.Activity.Metadata) + if record.Activity.Metadata == nil { + record.Activity.Metadata = map[string]any{} + } + out = append(out, record) + } + return out, rows.Err() +} + +func (s *Store) analysisChanges(ctx context.Context, since time.Time, limit int) ([]GraphChange, int, error) { + var total int + if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM analysis_changes WHERE timestamp_ns>=?`, since.UnixNano()).Scan(&total); err != nil { + return nil, 0, err + } + rows, err := s.db.QueryContext(ctx, `SELECT id,event_id,timestamp_ns,process_id,graph_version,entity_kind,action,entity_id,label,relation_type,origin,details_json +FROM analysis_changes WHERE timestamp_ns>=? ORDER BY timestamp_ns DESC,id DESC LIMIT ?`, since.UnixNano(), limit) + if err != nil { + return nil, 0, err + } + defer rows.Close() + out := []GraphChange{} + for rows.Next() { + var change GraphChange + var timestamp int64 + var details string + if err := rows.Scan(&change.ID, &change.EventID, ×tamp, &change.ProcessID, &change.GraphVersion, &change.EntityKind, &change.Action, &change.EntityID, &change.Label, &change.RelationType, &change.Origin, &details); err != nil { + return nil, 0, err + } + change.Timestamp = time.Unix(0, timestamp).UTC() + _ = decodeJSON(details, &change.Details) + if change.Details == nil { + change.Details = map[string]any{} + } + out = append(out, change) + } + return out, total, rows.Err() +} + +func buildTimeline(events []AnalysisEventRecord, since time.Time) []AnalysisTimelineBucket { + duration := time.Since(since) + bucket := time.Hour + if duration <= 3*time.Hour { + bucket = 10 * time.Minute + } else if duration <= 12*time.Hour { + bucket = 30 * time.Minute + } else if duration > 72*time.Hour { + bucket = 6 * time.Hour + } + buckets := map[int64]*AnalysisTimelineBucket{} + for _, event := range events { + start := event.Activity.Timestamp.Truncate(bucket) + key := start.UnixNano() + entry := buckets[key] + if entry == nil { + entry = &AnalysisTimelineBucket{Start: start} + buckets[key] = entry + } + entry.Events++ + entry.Mutations.Add(event.Point.Delta) + switch eventSeverity(event.Activity) { + case "success": + entry.Successes++ + case "warning": + entry.Warnings++ + case "error": + entry.Failures++ + } + entry.Comparisons += int64(metadataNumber(event.Activity.Metadata, "candidate_comparisons", "comparisons")) + entry.SearchResults += int64(metadataNumber(event.Activity.Metadata, "result_count", "research_search_results")) + } + keys := make([]int64, 0, len(buckets)) + for key := range buckets { + keys = append(keys, key) + } + sort.Slice(keys, func(i, j int) bool { return keys[i] < keys[j] }) + out := make([]AnalysisTimelineBucket, 0, len(keys)) + for _, key := range keys { + out = append(out, *buckets[key]) + } + return out +} + +func buildAnalysisRuns(events []AnalysisEventRecord) []AnalysisRun { + active := map[string]*AnalysisRun{} + activeOrder := []string{} + completed := []AnalysisRun{} + standalone := []AnalysisRun{} + + removeActive := func(key string) { + delete(active, key) + for i := len(activeOrder) - 1; i >= 0; i-- { + if activeOrder[i] == key { + activeOrder = append(activeOrder[:i], activeOrder[i+1:]...) + break + } + } + } + for _, event := range events { + kind, key, phase := classifyRunEvent(event.Activity) + if strings.HasPrefix(key, "latest:") { + wanted := strings.TrimPrefix(key, "latest:") + key = "" + for i := len(activeOrder) - 1; i >= 0; i-- { + candidate := active[activeOrder[i]] + if candidate != nil && candidate.Kind == wanted { + key = activeOrder[i] + break + } + } + } + if phase == "start" { + run := newAnalysisRun(kind, key, event) + active[key] = &run + activeOrder = append(activeOrder, key) + continue + } + if key != "" { + if run := active[key]; run != nil { + appendRunEvent(run, event) + if phase == "end" { + finalizeAnalysisRun(run, event.Activity) + completed = append(completed, *run) + removeActive(key) + } + continue + } + } + // Nested article, research and embedding events belong to the most recent + // active primary operation. This reflects how the engine actually runs: + // one AI-THINK cycle and one autonomous task are serialized. + if len(activeOrder) > 0 { + key := activeOrder[len(activeOrder)-1] + if run := active[key]; run != nil { + appendRunEvent(run, event) + continue + } + } + if isMeaningfulStandalone(event.Activity) { + run := newAnalysisRun(kindForStandalone(event.Activity), "standalone:"+event.Activity.ID, event) + finalizeAnalysisRun(&run, event.Activity) + standalone = append(standalone, run) + } + } + for _, key := range activeOrder { + if run := active[key]; run != nil { + run.Status = "running" + run.Verdict = "läuft" + run.Explanation = "Der Lauf ist noch nicht abgeschlossen oder sein Abschluss liegt außerhalb des gewählten Zeitfensters." + completed = append(completed, *run) + } + } + return append(completed, standalone...) +} + +func newAnalysisRun(kind, key string, event AnalysisEventRecord) AnalysisRun { + if kind == "" { + kind = kindForStandalone(event.Activity) + } + run := AnalysisRun{ + ID: key, + Kind: kind, + Title: runTitle(kind, event.Activity), + Status: "running", + Verdict: "läuft", + StartedAt: event.Activity.Timestamp, + Metrics: map[string]any{}, + } + appendRunEvent(&run, event) + return run +} + +func appendRunEvent(run *AnalysisRun, event AnalysisEventRecord) { + run.EventCount++ + run.Mutations.Add(event.Point.Delta) + run.NodeIDs = uniqueStrings(append(run.NodeIDs, event.Activity.NodeIDs...)) + run.EdgeIDs = uniqueStrings(append(run.EdgeIDs, event.Activity.EdgeIDs...)) + if len(run.Events) < 80 { + run.Events = append(run.Events, event) + } + if trigger := metadataString(event.Activity.Metadata, "trigger", "requested_by"); trigger != "" { + run.Trigger = trigger + } + mergeRunMetrics(run.Metrics, event.Activity) + if event.Activity.Timestamp.After(run.CompletedAt) { + run.CompletedAt = event.Activity.Timestamp + } +} + +func finalizeAnalysisRun(run *AnalysisRun, terminal model.Activity) { + run.CompletedAt = terminal.Timestamp + run.DurationMS = run.CompletedAt.Sub(run.StartedAt).Milliseconds() + if duration := int64(metadataNumber(terminal.Metadata, "duration_ms")); duration > 0 { + run.DurationMS = duration + } + run.Outcome = metadataString(terminal.Metadata, "result", "outcome", "reason") + run.Status = eventSeverity(terminal) + outcome := strings.ToLower(strings.TrimSpace(run.Outcome)) + if run.Status != "error" { + switch outcome { + case "no_candidate", "unchanged", "idle", "completed_no_change": + run.Status = "neutral" + case "skipped", "rejected", "no_useful_evidence", "deferred", "disabled": + run.Status = "warning" + } + derived := verdictFromRun(run) + if derived == "success" { + run.Status = "success" + } else if run.Status == "neutral" && derived == "warning" { + run.Status = "warning" + } + } + run.Verdict, run.Explanation = explainRun(run, terminal) +} + +func classifyRunEvent(activity model.Activity) (kind, key, phase string) { + typeName := activity.Type + switch typeName { + case "think.cycle.started": + return "thinking", "thinking:" + activity.ID, "start" + case "think.cycle.completed", "think.cycle.failed": + return "thinking", latestSyntheticKey("thinking"), "end" + case "learning.scan.started": + return "learning", nonemptyAnalysis(metadataString(activity.Metadata, "run_id"), "learning:"+activity.ID), "start" + case "learning.scan.completed", "learning.scan.failed": + return "learning", nonemptyAnalysis(metadataString(activity.Metadata, "run_id"), latestSyntheticKey("learning")), "end" + case "autonomous.research.task.started": + return "autonomous-research", "autonomous:" + nonemptyAnalysis(metadataString(activity.Metadata, "task_id"), activity.ID), "start" + case "autonomous.research.task.completed", "autonomous.research.task.failed", "autonomous.research.task.cancelled": + return "autonomous-research", "autonomous:" + nonemptyAnalysis(metadataString(activity.Metadata, "task_id"), activity.ID), "end" + case "query.started": + return "query", "query:" + activity.ID, "start" + case "query.completed", "query.failed": + return "query", latestSyntheticKey("query"), "end" + case "research.test.started": + return "searxng-test", "research-test:" + activity.ID, "start" + case "research.test.results", "research.test.failed": + return "searxng-test", latestSyntheticKey("searxng-test"), "end" + } + if strings.HasPrefix(typeName, "research.") || strings.HasPrefix(typeName, "article.research.") { + id := metadataString(activity.Metadata, "research_id") + if id != "" { + if strings.HasSuffix(typeName, ".started") { + return "research", "research:" + id, "start" + } + if strings.HasSuffix(typeName, ".failed") || strings.HasSuffix(typeName, ".completed") || strings.HasSuffix(typeName, ".ingested") { + return "research", "research:" + id, "end" + } + return "research", "research:" + id, "update" + } + } + return "", "", "" +} + +// latestSyntheticKey is a marker resolved by buildAnalysisRuns. Terminal events +// without a native run ID are attached to the most recent active run of the +// same kind. +func latestSyntheticKey(kind string) string { return "latest:" + kind } + +func kindForStandalone(activity model.Activity) string { + switch { + case strings.HasPrefix(activity.Type, "glpi.kb."): + return "glpi-sync" + case strings.HasPrefix(activity.Type, "persistence."): + return "persistence" + case strings.HasPrefix(activity.Type, "article."): + return "article" + case strings.HasPrefix(activity.Type, "embedding."): + return "embedding" + case strings.HasPrefix(activity.Type, "autonomous.research.scan."): + return "opportunity-scan" + case activity.Type == "agent.run": + return "agent" + default: + return "activity" + } +} + +func isMeaningfulStandalone(activity model.Activity) bool { + if activity.Type == "brain.idle" || activity.Type == "think.queued" { + return false + } + return strings.Contains(activity.Type, "completed") || strings.Contains(activity.Type, "failed") || strings.Contains(activity.Type, "created") || strings.Contains(activity.Type, "synced") || strings.Contains(activity.Type, "flushed") || activity.Type == "agent.run" || activity.Type == "graph.updated" +} + +func runTitle(kind string, activity model.Activity) string { + switch kind { + case "thinking": + return "AI-THINK-Zyklus" + case "learning": + return "KB-Lernlauf" + case "autonomous-research": + return nonemptyAnalysis(metadataString(activity.Metadata, "topic"), nonemptyAnalysis(activity.Message, "Autonome Recherche")) + case "query": + return nonemptyAnalysis(activity.Query, "Wissensabfrage") + case "research", "searxng-test": + return nonemptyAnalysis(metadataString(activity.Metadata, "research_query", "query"), nonemptyAnalysis(activity.Query, "SearXNG-Recherche")) + case "article": + return nonemptyAnalysis(metadataString(activity.Metadata, "title"), "Artikelsynthese") + case "glpi-sync": + return "GLPI-KB-Synchronisierung" + case "persistence": + return "Persistenz-Flush" + case "embedding": + return "Embedding-Lauf" + case "opportunity-scan": + return "Autonome Wissenslückensuche" + case "agent": + return "Agent-Lauf" + default: + return nonemptyAnalysis(activity.Message, activity.Type) + } +} + +func eventSeverity(activity model.Activity) string { + typeName := strings.ToLower(activity.Type) + message := strings.ToLower(activity.Message) + if strings.Contains(typeName, "failed") || strings.Contains(typeName, "error") || strings.Contains(message, "fehlgeschlagen") { + return "error" + } + if strings.Contains(typeName, "skipped") || strings.Contains(typeName, "rejected") || strings.Contains(typeName, "no_candidate") || strings.Contains(typeName, "paused") || strings.Contains(typeName, "deferred") || strings.Contains(message, "übersprungen") { + return "warning" + } + if strings.Contains(typeName, "completed") || strings.Contains(typeName, "created") || strings.Contains(typeName, "accepted") || strings.Contains(typeName, "ingested") || strings.Contains(typeName, "learned") || strings.Contains(typeName, "synced") || strings.Contains(typeName, "results") || strings.Contains(typeName, "flushed") { + return "success" + } + return "neutral" +} + +func verdictFromRun(run *AnalysisRun) string { + if hasFailureEvent(run.Events) { + return "error" + } + if !run.Mutations.Empty() { + return "success" + } + if hasWarningEvent(run.Events) { + return "warning" + } + return "neutral" +} + +func explainRun(run *AnalysisRun, terminal model.Activity) (string, string) { + status := run.Status + if status == "error" { + return "fehlgeschlagen", nonemptyAnalysis(metadataString(terminal.Metadata, "error"), nonemptyAnalysis(terminal.Message, "Der Lauf wurde mit einem Fehler beendet.")) + } + if run.Mutations.NodesCreated > 0 || run.Mutations.EdgesCreated > 0 || run.Mutations.VectorsCreated > 0 { + parts := []string{} + if run.Mutations.NodesCreated > 0 { + parts = append(parts, fmt.Sprintf("%d neue Nodes", run.Mutations.NodesCreated)) + } + if run.Mutations.EdgesCreated > 0 { + parts = append(parts, fmt.Sprintf("%d neue Edges", run.Mutations.EdgesCreated)) + } + if run.Mutations.VectorsCreated > 0 { + parts = append(parts, fmt.Sprintf("%d neue Embeddings", run.Mutations.VectorsCreated)) + } + if run.Mutations.VectorsUpdated > 0 { + parts = append(parts, fmt.Sprintf("%d neu berechnete Embeddings", run.Mutations.VectorsUpdated)) + } + return "positives Ergebnis", "Der Lauf hat den Wissenszustand materiell erweitert: " + strings.Join(parts, ", ") + "." + } + if run.Mutations.NodesDeleted+run.Mutations.EdgesDeleted+run.Mutations.VectorsDeleted > 0 { + return "Bereinigung", fmt.Sprintf("Der Lauf hat veraltete Daten entfernt: %d Nodes, %d Edges und %d Embeddings.", run.Mutations.NodesDeleted, run.Mutations.EdgesDeleted, run.Mutations.VectorsDeleted) + } + if run.Mutations.NodesUpdated+run.Mutations.EdgesUpdated+run.Mutations.VectorsUpdated > 0 { + return "aktualisiert", fmt.Sprintf("Bestehendes Wissen wurde verändert: %d Nodes, %d Edges und %d Embeddings wurden aktualisiert oder neu berechnet.", run.Mutations.NodesUpdated, run.Mutations.EdgesUpdated, run.Mutations.VectorsUpdated) + } + if status == "warning" { + return "ohne Übernahme", nonemptyAnalysis(terminal.Message, "Der Lauf hat geprüft, aber wegen Qualitäts- oder Relevanzregeln nichts in den Graphen übernommen.") + } + return "ohne Graphänderung", nonemptyAnalysis(terminal.Message, "Der Lauf wurde beendet, hat aber keine Nodes, Edges oder Embeddings verändert.") +} + +func hasFailureEvent(events []AnalysisEventRecord) bool { + for _, event := range events { + if eventSeverity(event.Activity) == "error" { + return true + } + } + return false +} + +func hasWarningEvent(events []AnalysisEventRecord) bool { + for _, event := range events { + if eventSeverity(event.Activity) == "warning" { + return true + } + } + return false +} + +func mergeRunMetrics(metrics map[string]any, activity model.Activity) { + if metrics == nil { + return + } + for _, keys := range [][]string{{"comparisons", "candidate_comparisons"}, {"checked"}, {"relations_created"}, {"articles_created"}, {"articles_skipped"}, {"research_search_results", "result_count"}, {"research_fetched", "pages_fetched"}, {"research_accepted", "evidence_count"}, {"research_rejected"}, {"queries_executed"}, {"batch_count"}, {"duration_ms"}} { + name := keys[0] + value := metadataNumber(activity.Metadata, keys...) + if value == 0 { + continue + } + previous, _ := metrics[name].(float64) + metrics[name] = previous + value + } + if similarity := metadataNumber(activity.Metadata, "semantic_similarity"); similarity > 0 { + metrics["last_semantic_similarity"] = similarity + } + if confidence := metadataNumber(activity.Metadata, "confidence"); confidence > 0 { + metrics["last_confidence"] = confidence + } + if modelName := metadataString(activity.Metadata, "model"); modelName != "" { + metrics["model"] = modelName + } +} + +func metadataNumber(metadata map[string]any, keys ...string) float64 { + for _, key := range keys { + if value, ok := numericMetadata(metadata, key); ok { + return value + } + } + return 0 +} + +func metadataString(metadata map[string]any, keys ...string) string { + for _, key := range keys { + if metadata == nil { + return "" + } + value, ok := metadata[key] + if !ok || value == nil { + continue + } + text := strings.TrimSpace(fmt.Sprint(value)) + if text != "" && !strings.EqualFold(text, "") && !strings.EqualFold(text, "null") { + return text + } + } + return "" +} + +func uniqueStrings(values []string) []string { + seen := map[string]struct{}{} + out := make([]string, 0, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" { + continue + } + if _, exists := seen[value]; exists { + continue + } + seen[value] = struct{}{} + out = append(out, value) + } + return out +} + +func (s *Store) countNodeCreatedLocked() { s.mutations.NodesCreated++ } +func (s *Store) countNodeUpdatedLocked() { s.mutations.NodesUpdated++ } +func (s *Store) countNodeDeletedLocked() { s.mutations.NodesDeleted++ } +func (s *Store) countEdgeCreatedLocked() { s.mutations.EdgesCreated++ } +func (s *Store) countEdgeUpdatedLocked() { s.mutations.EdgesUpdated++ } +func (s *Store) countEdgeDeletedLocked() { s.mutations.EdgesDeleted++ } +func (s *Store) countVectorCreatedLocked() { s.mutations.VectorsCreated++ } +func (s *Store) countVectorUpdatedLocked() { s.mutations.VectorsUpdated++ } +func (s *Store) countVectorDeletedLocked() { s.mutations.VectorsDeleted++ } + +const analysisDetailedChangeLimit = 2000 + +func (s *Store) recordChangeLocked(change GraphChange) { + change.Timestamp = time.Now().UTC() + change.ProcessID = s.processID + change.GraphVersion = s.version + if change.Details == nil { + change.Details = map[string]any{} + } + if len(s.analysisPendingChanges) < analysisDetailedChangeLimit { + s.analysisPendingChanges = append(s.analysisPendingChanges, change) + return + } + s.analysisPendingTruncated++ +} + +func nodeChange(node model.Node, action string) GraphChange { + return GraphChange{EntityKind: "node", Action: action, EntityID: node.ID, Label: node.Label, Origin: node.Origin, Details: map[string]any{"kind": node.Kind, "status": node.Status, "source": explicitNodeSource(node)}} +} + +func nodeUpdateChange(previous, current model.Node) GraphChange { + change := nodeChange(current, "updated") + change.Details["previous_label"] = previous.Label + change.Details["previous_status"] = previous.Status + change.Details["previous_source"] = explicitNodeSource(previous) + return change +} + +func edgeChange(edge model.Edge, action string) GraphChange { + details := map[string]any{"source": edge.Source, "target": edge.Target, "status": edge.Status, "confidence": edge.Confidence} + if similarity, ok := numericMetadata(edge.Metadata, "semantic_similarity"); ok { + details["semantic_similarity"] = similarity + } + return GraphChange{EntityKind: "edge", Action: action, EntityID: edge.ID, RelationType: edge.Type, Origin: edge.Origin, Details: details} +} + +func edgeUpdateChange(previous, current model.Edge) GraphChange { + change := edgeChange(current, "updated") + change.Details["previous_status"] = previous.Status + change.Details["previous_confidence"] = previous.Confidence + if similarity, ok := numericMetadata(previous.Metadata, "semantic_similarity"); ok { + change.Details["previous_semantic_similarity"] = similarity + } + return change +} + +func vectorRecalculatedChange(nodeID string, previousDimensions, dimensions int, label string) GraphChange { + change := vectorChange(nodeID, "recalculated", dimensions, label) + change.Details["previous_dimensions"] = previousDimensions + return change +} + +func vectorChange(nodeID, action string, dimensions int, label string) GraphChange { + return GraphChange{EntityKind: "vector", Action: action, EntityID: nodeID, Label: label, Origin: "embedding", Details: map[string]any{"dimensions": dimensions}} +} diff --git a/internal/graph/analysis_dashboard_test.go b/internal/graph/analysis_dashboard_test.go new file mode 100644 index 0000000..3b6f2b0 --- /dev/null +++ b/internal/graph/analysis_dashboard_test.go @@ -0,0 +1,78 @@ +package graph + +import ( + "context" + "testing" + "time" + + "github.com/local/glpi-neural-brain/internal/activity" + "github.com/local/glpi-neural-brain/internal/model" +) + +func TestMutationStatsAndAnalysisHistory(t *testing.T) { + store, err := Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = store.Close() }) + broker := activity.New(20) + broker.SetSink(store.RecordActivity) + + store.UpsertNode(model.Node{ID: "a", Kind: "knowledge", Label: "A", Origin: "test", Metadata: map[string]any{"source": "internal"}}) + store.UpsertNode(model.Node{ID: "b", Kind: "knowledge", Label: "B", Origin: "test", Metadata: map[string]any{"source": "internal"}}) + store.SetVector("a", []float64{1, 0}) + store.SetVector("b", []float64{.9, .1}) + broker.Publish(model.Activity{Type: "learning.scan.completed", Source: "brain", Message: "done", Metadata: map[string]any{"run_id": "scan-1", "result": "updated"}}) + + deadline := time.Now().Add(3 * time.Second) + for { + history, err := store.AnalysisHistory(context.Background(), time.Now().Add(-time.Hour), 50) + if err != nil { + t.Fatal(err) + } + if len(history.Events) > 0 { + if history.Totals.NodesCreated != 2 || history.Totals.VectorsCreated != 2 { + t.Fatalf("unexpected mutation totals: %+v", history.Totals) + } + if history.ChangeCount != 4 || len(history.Changes) != 4 { + t.Fatalf("expected four detailed changes, count=%d changes=%+v", history.ChangeCount, history.Changes) + } + if history.Events[0].ChangeCount != 4 { + t.Fatalf("expected event to own four changes: %+v", history.Events[0]) + } + break + } + if time.Now().After(deadline) { + t.Fatal("analysis writer did not persist event") + } + time.Sleep(20 * time.Millisecond) + } +} + +func TestBuildAnalysisRunsExplainsPositiveThinkingResult(t *testing.T) { + start := time.Now().UTC().Add(-2 * time.Second) + events := []AnalysisEventRecord{ + {Activity: model.Activity{ID: "s", Type: "think.cycle.started", Timestamp: start, Metadata: map[string]any{"trigger": "manual"}}}, + {Activity: model.Activity{ID: "r", Type: "think.relation.created", Timestamp: start.Add(time.Second), Message: "Relation erstellt"}, Point: AnalysisPoint{Delta: MutationStats{EdgesCreated: 1}}}, + {Activity: model.Activity{ID: "e", Type: "think.cycle.completed", Timestamp: start.Add(1500 * time.Millisecond), Metadata: map[string]any{"duration_ms": 1500, "relations_created": 1}}}, + } + runs := buildAnalysisRuns(events) + if len(runs) != 1 { + t.Fatalf("expected one run, got %d: %+v", len(runs), runs) + } + if runs[0].Status != "success" || runs[0].Mutations.EdgesCreated != 1 || runs[0].Verdict != "positives Ergebnis" { + t.Fatalf("unexpected run: %+v", runs[0]) + } +} + +func TestEdgeUpdateChangeExplainsSimilarityRecalculation(t *testing.T) { + previous := model.Edge{ID: "e", Type: "related", Origin: "ai-inference", Status: "active", Confidence: .72, Metadata: map[string]any{"semantic_similarity": .81}} + current := model.Edge{ID: "e", Type: "related", Origin: "ai-inference", Status: "active", Confidence: .88, Metadata: map[string]any{"semantic_similarity": .93}} + change := edgeUpdateChange(previous, current) + if change.Action != "updated" || change.Details["previous_semantic_similarity"] != .81 || change.Details["semantic_similarity"] != .93 { + t.Fatalf("similarity delta missing: %+v", change) + } + if change.Details["previous_confidence"] != .72 || change.Details["confidence"] != .88 { + t.Fatalf("confidence delta missing: %+v", change) + } +} diff --git a/internal/graph/sqlite_backend.go b/internal/graph/sqlite_backend.go index d8df86e..b920771 100644 --- a/internal/graph/sqlite_backend.go +++ b/internal/graph/sqlite_backend.go @@ -17,7 +17,7 @@ import ( _ "modernc.org/sqlite" ) -const schemaVersion = 2 +const schemaVersion = 3 const nodeUpsertSQL = `INSERT INTO nodes(id,kind,label,summary,status,origin,external_id,uri,categories_json,keywords_json,metadata_json,weight,x,y,z,updated_at_ns) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) @@ -128,6 +128,7 @@ func Open(dir string) (*Store, error) { db.Close() return nil, fmt.Errorf("load sqlite graph state: %w", err) } + s.initAnalysisWriter() return s, nil } @@ -280,6 +281,69 @@ func (s *Store) initSchema(ctx context.Context) error { created_at_ns INTEGER NOT NULL )`, `CREATE INDEX IF NOT EXISTS idx_research_task_attempts_task ON research_task_attempts(task_id, attempt, created_at_ns)`, + `CREATE TABLE IF NOT EXISTS analysis_events ( + id TEXT PRIMARY KEY, + type TEXT NOT NULL, + source TEXT NOT NULL DEFAULT '', + phase TEXT NOT NULL DEFAULT '', + query TEXT NOT NULL DEFAULT '', + message TEXT NOT NULL DEFAULT '', + node_ids_json TEXT NOT NULL DEFAULT '[]', + edge_ids_json TEXT NOT NULL DEFAULT '[]', + strength REAL NOT NULL DEFAULT 0, + metadata_json TEXT NOT NULL DEFAULT '{}', + timestamp_ns INTEGER NOT NULL, + process_id TEXT NOT NULL DEFAULT '' +) WITHOUT ROWID`, + `CREATE INDEX IF NOT EXISTS idx_analysis_events_timestamp ON analysis_events(timestamp_ns DESC)`, + `CREATE INDEX IF NOT EXISTS idx_analysis_events_type_timestamp ON analysis_events(type, timestamp_ns DESC)`, + `CREATE TABLE IF NOT EXISTS analysis_points ( + event_id TEXT PRIMARY KEY REFERENCES analysis_events(id) ON DELETE CASCADE, + timestamp_ns INTEGER NOT NULL, + process_id TEXT NOT NULL DEFAULT '', + graph_version INTEGER NOT NULL DEFAULT 0, + node_count INTEGER NOT NULL DEFAULT 0, + edge_count INTEGER NOT NULL DEFAULT 0, + vector_count INTEGER NOT NULL DEFAULT 0, + node_created INTEGER NOT NULL DEFAULT 0, + node_updated INTEGER NOT NULL DEFAULT 0, + node_deleted INTEGER NOT NULL DEFAULT 0, + edge_created INTEGER NOT NULL DEFAULT 0, + edge_updated INTEGER NOT NULL DEFAULT 0, + edge_deleted INTEGER NOT NULL DEFAULT 0, + vector_created INTEGER NOT NULL DEFAULT 0, + vector_updated INTEGER NOT NULL DEFAULT 0, + vector_deleted INTEGER NOT NULL DEFAULT 0, + delta_node_created INTEGER NOT NULL DEFAULT 0, + delta_node_updated INTEGER NOT NULL DEFAULT 0, + delta_node_deleted INTEGER NOT NULL DEFAULT 0, + delta_edge_created INTEGER NOT NULL DEFAULT 0, + delta_edge_updated INTEGER NOT NULL DEFAULT 0, + delta_edge_deleted INTEGER NOT NULL DEFAULT 0, + delta_vector_created INTEGER NOT NULL DEFAULT 0, + delta_vector_updated INTEGER NOT NULL DEFAULT 0, + delta_vector_deleted INTEGER NOT NULL DEFAULT 0, + change_count INTEGER NOT NULL DEFAULT 0, + changes_truncated INTEGER NOT NULL DEFAULT 0 +) WITHOUT ROWID`, + `CREATE INDEX IF NOT EXISTS idx_analysis_points_timestamp ON analysis_points(timestamp_ns DESC)`, + `CREATE TABLE IF NOT EXISTS analysis_changes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_id TEXT NOT NULL REFERENCES analysis_events(id) ON DELETE CASCADE, + timestamp_ns INTEGER NOT NULL, + process_id TEXT NOT NULL DEFAULT '', + graph_version INTEGER NOT NULL DEFAULT 0, + entity_kind TEXT NOT NULL, + action TEXT NOT NULL, + entity_id TEXT NOT NULL, + label TEXT NOT NULL DEFAULT '', + relation_type TEXT NOT NULL DEFAULT '', + origin TEXT NOT NULL DEFAULT '', + details_json TEXT NOT NULL DEFAULT '{}' +)`, + `CREATE INDEX IF NOT EXISTS idx_analysis_changes_timestamp ON analysis_changes(timestamp_ns DESC)`, + `CREATE INDEX IF NOT EXISTS idx_analysis_changes_event ON analysis_changes(event_id, id)`, + `CREATE INDEX IF NOT EXISTS idx_analysis_changes_entity ON analysis_changes(entity_kind, action, timestamp_ns DESC)`, } for _, statement := range statements { if _, err := s.db.ExecContext(ctx, statement); err != nil { @@ -299,7 +363,7 @@ func (s *Store) initSchema(ctx context.Context) error { return fmt.Errorf("unsupported graph database schema %d (expected at most %d)", current, schemaVersion) } if current < schemaVersion { - if current != 1 { + if current < 1 || current > 2 { return fmt.Errorf("unsupported graph database schema migration %d -> %d", current, schemaVersion) } if _, err := s.db.ExecContext(ctx, `UPDATE graph_meta SET value=? WHERE key='schema_version'`, schemaVersion); err != nil { @@ -692,6 +756,7 @@ func (s *Store) Close() error { if s.db == nil { return nil } + s.closeAnalysisWriter() ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() _, persistErr := s.PersistVersionContext(ctx) diff --git a/internal/graph/store.go b/internal/graph/store.go index 548bd0b..e54804f 100644 --- a/internal/graph/store.go +++ b/internal/graph/store.go @@ -25,6 +25,7 @@ type Store struct { embeddingModel string embeddingDigest string embeddingMetaGeneration uint64 + mutations MutationStats db *sql.DB dbPath string @@ -36,6 +37,21 @@ type Store struct { deletedNodes map[string]uint64 deletedEdges map[string]uint64 deletedVectors map[string]uint64 + + analysisMu sync.Mutex + analysisQueue chan analysisRecord + analysisWG sync.WaitGroup + analysisLastMutations MutationStats + analysisPendingChanges []GraphChange + analysisPendingTruncated int + analysisChangesDropped uint64 + analysisDropped uint64 + analysisLastError string + analysisLastPersisted time.Time + processID string + analysisDetailMu sync.Mutex + analysisDetailVersion uint64 + analysisDetailCache DetailedGraphAnalysis } func ID(parts ...string) string { @@ -106,8 +122,14 @@ func (s *Store) ConfigureEmbeddingIdentity(modelName, digest string) int { s.version++ removed := 0 if modelChanged || digestChanged { - for id := range s.vectors { + for id, vector := range s.vectors { + label := "" + if node, ok := s.nodes[id]; ok { + label = node.Label + } delete(s.vectors, id) + s.countVectorDeletedLocked() + s.recordChangeLocked(vectorChange(id, "deleted", len(vector), label)) s.deletedVectors[id] = s.version delete(s.dirtyVectors, id) removed++ @@ -126,6 +148,7 @@ func (s *Store) ConfigureEmbeddingIdentity(modelName, digest string) int { func (s *Store) UpsertNode(n model.Node) { s.mu.Lock() defer s.mu.Unlock() + old, existed := s.nodes[n.ID] if n.UpdatedAt.IsZero() { n.UpdatedAt = time.Now().UTC() } @@ -136,7 +159,17 @@ func (s *Store) UpsertNode(n model.Node) { n.X, n.Y, n.Z = position(n.ID, n.Categories) } s.nodes[n.ID] = n + if existed { + s.countNodeUpdatedLocked() + } else { + s.countNodeCreatedLocked() + } s.version++ + if existed { + s.recordChangeLocked(nodeUpdateChange(old, n)) + } else { + s.recordChangeLocked(nodeChange(n, "created")) + } s.markNodeDirtyLocked(n.ID) } func (s *Store) UpsertEdge(e model.Edge) { @@ -146,8 +179,9 @@ func (s *Store) UpsertEdge(e model.Edge) { if e.ID == "" { e.ID = EdgeID(e.Source, e.Target, e.Type, e.Origin) } + old, existed := s.edges[e.ID] if e.CreatedAt.IsZero() { - if old, ok := s.edges[e.ID]; ok { + if existed { e.CreatedAt = old.CreatedAt } else { e.CreatedAt = now @@ -158,7 +192,17 @@ func (s *Store) UpsertEdge(e model.Edge) { e.Weight = 1 } s.edges[e.ID] = e + if existed { + s.countEdgeUpdatedLocked() + } else { + s.countEdgeCreatedLocked() + } s.version++ + if existed { + s.recordChangeLocked(edgeUpdateChange(old, e)) + } else { + s.recordChangeLocked(edgeChange(e, "created")) + } s.markEdgeDirtyLocked(e.ID) } func (s *Store) HasEdgeBetween(a, b string) bool { @@ -199,7 +243,21 @@ func (s *Store) SetVector(id string, v []float64) { return } s.vectors[id] = converted + if ok { + s.countVectorUpdatedLocked() + } else { + s.countVectorCreatedLocked() + } s.version++ + label := "" + if node, exists := s.nodes[id]; exists { + label = node.Label + } + if ok { + s.recordChangeLocked(vectorRecalculatedChange(id, len(old), len(converted), label)) + } else { + s.recordChangeLocked(vectorChange(id, "created", len(converted), label)) + } s.markVectorDirtyLocked(id) } func (s *Store) Vector(id string) ([]float64, bool) { @@ -222,9 +280,15 @@ func (s *Store) ClearVectorsByDimension(dim int) int { removed := 0 for id, v := range s.vectors { if len(v) == dim { + label := "" + if node, ok := s.nodes[id]; ok { + label = node.Label + } delete(s.vectors, id) + s.countVectorDeletedLocked() removed++ s.version++ + s.recordChangeLocked(vectorChange(id, "deleted", len(v), label)) s.markVectorDeletedLocked(id) } } @@ -307,12 +371,17 @@ func (s *Store) ReplaceOrigins(origins []string, nodes []model.Node, edges []mod continue } delete(s.nodes, id) + s.countNodeDeletedLocked() if _, hadVector := s.vectors[id]; hadVector { + vector := s.vectors[id] delete(s.vectors, id) + s.countVectorDeletedLocked() s.version++ + s.recordChangeLocked(vectorChange(id, "deleted", len(vector), old.Label)) s.markVectorDeletedLocked(id) } s.version++ + s.recordChangeLocked(nodeChange(old, "deleted")) s.markNodeDeletedLocked(id) } for id, old := range s.edges { @@ -323,7 +392,9 @@ func (s *Store) ReplaceOrigins(origins []string, nodes []model.Node, edges []mod continue } delete(s.edges, id) + s.countEdgeDeletedLocked() s.version++ + s.recordChangeLocked(edgeChange(old, "deleted")) s.markEdgeDeletedLocked(id) } @@ -356,16 +427,28 @@ func (s *Store) ReplaceOrigins(origins []string, nodes []model.Node, edges []mod } s.nodes[id] = incoming + if existed { + s.countNodeUpdatedLocked() + } else { + s.countNodeCreatedLocked() + } s.version++ + if existed { + s.recordChangeLocked(nodeUpdateChange(old, incoming)) + } else { + s.recordChangeLocked(nodeChange(incoming, "created")) + } s.markNodeDirtyLocked(id) // Embeddings depend on text/categories/keywords, not on display // coordinates or unrelated metadata. Only invalidate a vector when its // actual embedding input changed. if existed && oldEmbeddingFingerprint != embeddingFingerprint(incoming) { - if _, hadVector := s.vectors[id]; hadVector { + if vector, hadVector := s.vectors[id]; hadVector { delete(s.vectors, id) + s.countVectorDeletedLocked() s.version++ + s.recordChangeLocked(vectorChange(id, "deleted", len(vector), incoming.Label)) s.markVectorDeletedLocked(id) } } @@ -387,7 +470,17 @@ func (s *Store) ReplaceOrigins(origins []string, nodes []model.Node, edges []mod } incoming.UpdatedAt = now s.edges[id] = incoming + if existed { + s.countEdgeUpdatedLocked() + } else { + s.countEdgeCreatedLocked() + } s.version++ + if existed { + s.recordChangeLocked(edgeUpdateChange(old, incoming)) + } else { + s.recordChangeLocked(edgeChange(incoming, "created")) + } s.markEdgeDirtyLocked(id) } @@ -396,13 +489,17 @@ func (s *Store) ReplaceOrigins(origins []string, nodes []model.Node, edges []mod for id, edge := range s.edges { if _, ok := s.nodes[edge.Source]; !ok { delete(s.edges, id) + s.countEdgeDeletedLocked() s.version++ + s.recordChangeLocked(edgeChange(edge, "deleted")) s.markEdgeDeletedLocked(id) continue } if _, ok := s.nodes[edge.Target]; !ok { delete(s.edges, id) + s.countEdgeDeletedLocked() s.version++ + s.recordChangeLocked(edgeChange(edge, "deleted")) s.markEdgeDeletedLocked(id) } } diff --git a/internal/web/server.go b/internal/web/server.go index 91db6d5..c679e0c 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -35,6 +35,8 @@ func (s *Server) Handler() http.Handler { 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/analysis/dashboard", s.handleAnalysisDashboard) + mux.HandleFunc("GET /api/analysis/export", s.handleAnalysisExport) mux.HandleFunc("GET /api/runtime-settings", s.handleGetRuntimeSettings) mux.HandleFunc("PUT /api/runtime-settings", s.handleSetRuntimeSettings) mux.HandleFunc("GET /api/sources", s.handleSources) @@ -54,6 +56,9 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("POST /api/flush", s.handleFlush) mux.HandleFunc("GET /api/state/export", s.handleStateExport) sub, _ := fs.Sub(assets, "static") + mux.HandleFunc("GET /analysis", func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/analysis.html", http.StatusTemporaryRedirect) + }) mux.Handle("GET /", http.FileServer(http.FS(sub))) return s.headers(mux) } @@ -76,6 +81,61 @@ func (s *Server) handleAnalysis(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, s.Graph.Analyze()) } +func (s *Server) analysisDashboardPayload(r *http.Request) (map[string]any, error) { + hours := 24 + if raw := strings.TrimSpace(r.URL.Query().Get("hours")); raw != "" { + if value, err := strconv.Atoi(raw); err == nil && value >= 1 && value <= 24*90 { + hours = value + } + } + limit := 250 + if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" { + if value, err := strconv.Atoi(raw); err == nil && value >= 20 && value <= 1000 { + limit = value + } + } + ctx, cancel := contextTimeout(r, 60*time.Second) + defer cancel() + history, err := s.Graph.AnalysisHistory(ctx, time.Now().UTC().Add(-time.Duration(hours)*time.Hour), limit) + if err != nil { + return nil, err + } + return map[string]any{ + "generated_at": history.GeneratedAt, + "range_hours": hours, + "graph": s.Graph.DetailedAnalysis(), + "history": history, + "system": s.Engine.Status(), + }, nil +} + +func (s *Server) handleAnalysisDashboard(w http.ResponseWriter, r *http.Request) { + payload, err := s.analysisDashboardPayload(r) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, payload) +} + +func (s *Server) handleAnalysisExport(w http.ResponseWriter, r *http.Request) { + if !s.authorized(r) { + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"}) + return + } + payload, err := s.analysisDashboardPayload(r) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Header().Set("Content-Disposition", `attachment; filename="brain-analysis.json"`) + w.WriteHeader(http.StatusOK) + encoder := json.NewEncoder(w) + encoder.SetIndent("", " ") + _ = encoder.Encode(payload) +} + func (s *Server) handleGetRuntimeSettings(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, s.Engine.RuntimeSettingsView()) } diff --git a/internal/web/server_test.go b/internal/web/server_test.go index 5c58b39..11a0a6d 100644 --- a/internal/web/server_test.go +++ b/internal/web/server_test.go @@ -163,3 +163,44 @@ func TestSettingsPanelContentIsScrollable(t *testing.T) { } } } + +func TestAnalysisDashboardPageAndAPI(t *testing.T) { + data := t.TempDir() + g, err := graph.Open(data) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = g.Close() }) + broker := activity.New(20) + eng := engine.New(config.Config{DataDir: data, RuntimeDefaultsConfigured: true, LearningEnabled: true, ThinkingEnabled: true, PersistInterval: time.Minute}, g, broker) + g.UpsertNode(model.Node{ID: "n1", Kind: "knowledge", Label: "Analyse", Origin: "test", Metadata: map[string]any{"source": "internal"}}) + broker.Publish(model.Activity{Type: "graph.updated", Source: "brain", Message: "Graph aktualisiert", Metadata: map[string]any{"nodes": 1, "edges": 0}}) + h := (&Server{Engine: eng, Graph: g, Broker: broker}).Handler() + + page := httptest.NewRecorder() + h.ServeHTTP(page, httptest.NewRequest(http.MethodGet, "/analysis.html", nil)) + if page.Code != http.StatusOK || !strings.Contains(page.Body.String(), "BRAIN ANALYSIS CENTER") || !strings.Contains(page.Body.String(), "ÄNDERUNGSJOURNAL") { + t.Fatalf("analysis page missing: %d %s", page.Code, page.Body.String()) + } + + deadline := time.Now().Add(3 * time.Second) + for { + res := httptest.NewRecorder() + h.ServeHTTP(res, httptest.NewRequest(http.MethodGet, "/api/analysis/dashboard?hours=24&limit=50", nil)) + if res.Code != http.StatusOK { + t.Fatalf("analysis API returned %d: %s", res.Code, res.Body.String()) + } + var payload map[string]any + if err := json.NewDecoder(res.Body).Decode(&payload); err != nil { + t.Fatal(err) + } + history, _ := payload["history"].(map[string]any) + if count, _ := history["raw_event_count"].(float64); count > 0 { + break + } + if time.Now().After(deadline) { + t.Fatal("analysis event was not visible through API") + } + time.Sleep(20 * time.Millisecond) + } +} diff --git a/internal/web/static/analysis.css b/internal/web/static/analysis.css new file mode 100644 index 0000000..64942e0 --- /dev/null +++ b/internal/web/static/analysis.css @@ -0,0 +1,39 @@ +:root{ + color-scheme:dark; + --bg:#071019; + --panel:#0d1824; + --panel-2:#101f2d; + --border:#24384a; + --text:#e8f3fb; + --muted:#8ca4b7; + --cyan:#54dff5; + --blue:#5e8cff; + --green:#62d39a; + --yellow:#f2c86d; + --red:#ff7d84; + --violet:#b28cff; + --shadow:0 18px 50px rgba(0,0,0,.28); +} +*{box-sizing:border-box} +html{background:var(--bg);scroll-behavior:smooth} +body{margin:0;min-height:100vh;background:radial-gradient(circle at 15% -10%,rgba(55,164,208,.16),transparent 34%),radial-gradient(circle at 90% 5%,rgba(112,78,196,.13),transparent 30%),var(--bg);color:var(--text);font:14px/1.55 Inter,Segoe UI,Arial,sans-serif} +button,input,select{font:inherit} +a{color:inherit;text-decoration:none} +code{font-family:Consolas,Monaco,monospace;color:#c6eaff} +.analysis-topbar{position:sticky;top:0;z-index:20;display:flex;align-items:center;justify-content:space-between;gap:24px;padding:16px 24px;border-bottom:1px solid rgba(103,155,185,.25);background:rgba(6,15,24,.92);backdrop-filter:blur(18px);box-shadow:0 8px 30px rgba(0,0,0,.22)} +.analysis-brand{display:flex;align-items:center;gap:14px;min-width:260px}.analysis-brand strong{display:block;letter-spacing:.12em;font-size:15px}.analysis-brand small{display:block;color:var(--muted);margin-top:2px}.analysis-mark{display:grid;grid-template-columns:repeat(3,6px);gap:3px;align-items:center;height:28px}.analysis-mark i{display:block;width:6px;border-radius:4px;background:linear-gradient(180deg,var(--cyan),var(--blue));box-shadow:0 0 12px rgba(84,223,245,.5)}.analysis-mark i:nth-child(1){height:15px}.analysis-mark i:nth-child(2){height:27px}.analysis-mark i:nth-child(3){height:20px} +.analysis-actions{display:flex;align-items:center;justify-content:flex-end;gap:10px;flex-wrap:wrap}.action,.analysis-actions select{border:1px solid var(--border);background:#102131;color:var(--text);border-radius:9px;padding:8px 12px;cursor:pointer}.action:hover,.analysis-actions select:hover{border-color:#4e7188;background:#142b3e}.action.secondary{background:transparent}.range-control,.auto-control{display:flex;align-items:center;gap:8px;color:var(--muted);font-size:12px}.auto-control{padding:8px 10px;border:1px solid var(--border);border-radius:9px;background:#0e1c29}.auto-control input{accent-color:var(--cyan)} +.connection-banner{position:sticky;top:76px;z-index:19;text-align:center;padding:8px 16px;font-size:12px;border-bottom:1px solid transparent}.connection-banner.loading{background:#172338;color:var(--yellow)}.connection-banner.connected{background:rgba(34,107,76,.72);color:#c6ffe2}.connection-banner.error{background:rgba(133,43,51,.8);color:#ffd8da}.connection-banner.live{background:rgba(35,79,113,.75);color:#d6f4ff} +.analysis-main{max-width:1680px;margin:0 auto;padding:26px 24px 70px}.section{background:linear-gradient(180deg,rgba(16,31,45,.96),rgba(11,24,35,.96));border:1px solid var(--border);border-radius:15px;padding:20px;box-shadow:var(--shadow);margin-bottom:18px}.intro-section{display:flex;align-items:flex-end;justify-content:space-between;gap:30px;background:linear-gradient(135deg,rgba(15,38,53,.98),rgba(17,26,47,.98))}.intro-section h1{margin:3px 0 8px;font-size:30px;line-height:1.15}.intro-section p{margin:0;color:#b7c7d4;max-width:980px}.eyebrow{display:block;color:var(--cyan);font-size:11px;letter-spacing:.16em;font-weight:700}.legend-box{display:grid;grid-template-columns:repeat(2,max-content);gap:8px 16px;padding:12px 15px;border:1px solid var(--border);border-radius:11px;background:rgba(3,12,20,.36);white-space:nowrap}.legend-box span{display:flex;align-items:center;gap:7px;color:#b9cbd8;font-size:12px}.status-dot{width:8px;height:8px;border-radius:50%;display:inline-block}.status-dot.success{background:var(--green);box-shadow:0 0 9px var(--green)}.status-dot.warning{background:var(--yellow)}.status-dot.error{background:var(--red)}.status-dot.neutral{background:#7890a3} +.section-heading{display:flex;align-items:center;justify-content:space-between;gap:18px;margin-bottom:14px}.section-heading h2{margin:2px 0 0;font-size:20px}.section-note{color:var(--muted);font-size:12px}.status-pill{display:inline-flex;align-items:center;border-radius:999px;padding:5px 10px;font-size:11px;font-weight:700;letter-spacing:.04em;border:1px solid}.status-pill.success{color:#bbf5d4;border-color:#34845c;background:rgba(37,120,78,.18)}.status-pill.warning{color:#ffe6a6;border-color:#8d7136;background:rgba(135,101,35,.18)}.status-pill.error{color:#ffd0d3;border-color:#97464d;background:rgba(143,49,59,.2)}.status-pill.neutral,.status-pill.running{color:#c6d7e3;border-color:#4b6374;background:rgba(65,91,109,.18)} +.last-run-section{border-color:#31566d}.last-run-content{display:grid;grid-template-columns:minmax(260px,1.4fr) repeat(3,minmax(145px,.6fr));gap:14px}.last-run-main{padding:16px;border-radius:11px;background:#0a1722;border:1px solid #294052}.last-run-main h3{margin:0 0 4px;font-size:19px}.last-run-main p{color:#afc1ce;margin:7px 0 0}.last-run-main small{color:var(--muted)}.last-run-stat{padding:15px;border:1px solid #263b4b;border-radius:11px;background:rgba(7,17,26,.55)}.last-run-stat span{display:block;color:var(--muted);font-size:11px;text-transform:uppercase;letter-spacing:.08em}.last-run-stat strong{display:block;margin-top:7px;font-size:22px}.last-run-stat small{color:#9cb0bf}.empty-state{color:var(--muted);padding:18px;text-align:center} +.summary-grid{display:grid;grid-template-columns:repeat(4,minmax(190px,1fr));gap:14px;margin-bottom:18px}.summary-card{min-height:126px;padding:17px;border-radius:14px;border:1px solid var(--border);background:linear-gradient(160deg,#122638,#0b1722);box-shadow:var(--shadow)}.summary-card>span{display:block;color:var(--muted);font-size:12px}.summary-card strong{display:block;font-size:24px;margin:12px 0 4px;letter-spacing:-.02em}.summary-card small{display:block;color:#92a8b8;line-height:1.35} +.explain-box{padding:10px 13px;margin:0 0 14px;border-left:3px solid #4d9ec0;background:rgba(30,77,99,.16);color:#abc0ce;font-size:12px;border-radius:0 7px 7px 0}.chart-wrap{height:310px;border:1px solid #223848;border-radius:11px;background:#07131d;padding:8px;overflow:hidden}.chart-wrap canvas{width:100%;height:100%}.chart-legend{display:flex;gap:18px;flex-wrap:wrap;margin-top:10px;color:var(--muted);font-size:11px}.chart-legend span{display:flex;align-items:center;gap:6px}.legend-swatch{display:block;width:18px;height:5px;border-radius:3px}.legend-swatch.create{background:var(--green)}.legend-swatch.update{background:var(--blue)}.legend-swatch.delete{background:var(--red)}.legend-swatch.events{background:var(--yellow)} +.filters-row{display:flex;align-items:flex-end;gap:12px;flex-wrap:wrap;margin-bottom:14px}.filters-row label{display:flex;flex-direction:column;gap:5px;color:var(--muted);font-size:11px}.filters-row label:has(input[type=checkbox]){flex-direction:row;align-items:center;padding:9px 0}.filters-row .grow{flex:1;min-width:240px}.filters-row select,.filters-row input[type=search]{width:100%;min-height:36px;border:1px solid var(--border);border-radius:8px;background:#091722;color:var(--text);padding:7px 10px}.filters-row input[type=checkbox]{accent-color:var(--cyan)} +.table-wrap{overflow:auto;border:1px solid #233949;border-radius:10px}.data-table{width:100%;border-collapse:collapse;min-width:940px}.data-table th{position:sticky;top:0;background:#132536;text-align:left;color:#9db2c1;font-size:10px;text-transform:uppercase;letter-spacing:.08em;padding:10px 12px;border-bottom:1px solid #2c4455}.data-table td{padding:11px 12px;border-bottom:1px solid rgba(57,81,96,.38);vertical-align:top}.data-table tr:last-child td{border-bottom:0}.data-table tbody tr.run-row{cursor:pointer}.data-table tbody tr.run-row:hover{background:rgba(67,133,164,.09)}.run-title{font-weight:650}.run-meta{display:block;color:var(--muted);font-size:11px;margin-top:2px}.delta-tags{display:flex;gap:5px;flex-wrap:wrap}.delta-tag{font-size:10px;padding:2px 6px;border-radius:999px;background:#172a38;color:#c5d5df}.delta-tag.create{color:#bff1d5;background:rgba(52,132,92,.2)}.delta-tag.update{color:#cbd8ff;background:rgba(75,102,179,.23)}.delta-tag.delete{color:#ffd1d4;background:rgba(151,70,77,.22)}.run-detail-row td{padding:0;background:#07131d}.run-detail{padding:15px 18px;display:grid;grid-template-columns:minmax(300px,1fr) minmax(300px,1fr);gap:16px}.run-detail pre,.event-details pre{max-height:320px;overflow:auto;background:#061019;border:1px solid #243a49;border-radius:8px;padding:11px;color:#b9d0df;font:11px/1.5 Consolas,monospace;white-space:pre-wrap;word-break:break-word}.event-mini-list{display:flex;flex-direction:column;gap:7px}.event-mini{padding:8px;border:1px solid #223744;border-radius:7px;background:#0d1b27}.event-mini b{font-size:11px}.event-mini small{display:block;color:var(--muted)} +.two-column{display:grid;grid-template-columns:1fr 1fr;gap:18px}.two-column>.section{min-width:0}.distribution-grid{display:grid;grid-template-columns:1fr;gap:17px}.section h3{font-size:13px;margin:17px 0 8px;color:#bdd0dd}.bar-list{display:flex;flex-direction:column;gap:7px}.bar-row{display:grid;grid-template-columns:minmax(110px,1fr) minmax(110px,2fr) 58px;gap:9px;align-items:center}.bar-label{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#b8cad6;font-size:11px}.bar-track{height:8px;border-radius:999px;background:#0a141d;overflow:hidden}.bar-fill{height:100%;border-radius:999px;background:linear-gradient(90deg,var(--blue),var(--cyan))}.bar-value{text-align:right;color:#d6e6ef;font-variant-numeric:tabular-nums;font-size:11px}.metric-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:10px}.metric-grid.compact>div{padding:12px;border:1px solid #263b49;border-radius:9px;background:#0a1721}.metric-grid span{display:block;color:var(--muted);font-size:10px}.metric-grid strong{display:block;font-size:18px;margin-top:5px}.compact-list,.entity-list{display:flex;flex-direction:column;gap:8px}.compact-item,.entity-item{padding:10px 12px;border:1px solid #243a49;border-radius:9px;background:#0a1721}.compact-item header,.entity-item header{display:flex;justify-content:space-between;gap:12px}.compact-item b,.entity-item b{font-size:12px}.compact-item small,.entity-item small{color:var(--muted)}.compact-item p,.entity-item p{margin:6px 0 0;color:#a9bdca;font-size:11px}.entity-item code{font-size:10px}.entity-tags{display:flex;gap:5px;flex-wrap:wrap;margin-top:7px}.entity-tags span{font-size:9px;border:1px solid #314b5c;border-radius:999px;padding:2px 6px;color:#a9c1d0} +.events-list{display:flex;flex-direction:column;gap:8px}.event-card{border:1px solid #243a49;border-radius:10px;background:#0a1721;overflow:hidden}.event-summary{display:grid;grid-template-columns:105px 190px minmax(260px,1fr) 260px;gap:10px;align-items:center;padding:10px 12px;cursor:pointer}.event-summary:hover{background:rgba(56,116,144,.09)}.event-time{color:var(--muted);font-size:11px}.event-type{font-family:Consolas,monospace;font-size:10px;color:#b9dded}.event-message{font-size:12px}.event-delta{text-align:right}.event-details{display:none;padding:12px;border-top:1px solid #233745;background:#06111a}.event-card.open .event-details{display:grid;grid-template-columns:1fr 1fr;gap:12px}.query-box{padding:8px 10px;border:1px solid #294253;border-radius:7px;background:#0d1c28;color:#c4d9e5;font-size:11px;margin-top:7px} +.analysis-footer{display:flex;justify-content:space-between;gap:20px;padding:18px 24px;border-top:1px solid var(--border);color:var(--muted);font-size:11px;background:#071019} +@media(max-width:1200px){.summary-grid{grid-template-columns:repeat(2,1fr)}.two-column{grid-template-columns:1fr}.last-run-content{grid-template-columns:1fr repeat(3,1fr)}.event-summary{grid-template-columns:90px 170px 1fr}.event-delta{grid-column:2/4;text-align:left}} +@media(max-width:820px){.analysis-topbar{position:relative;flex-direction:column;align-items:flex-start}.connection-banner{top:0}.analysis-actions{justify-content:flex-start}.analysis-main{padding:18px 12px 50px}.intro-section{flex-direction:column;align-items:flex-start}.legend-box{grid-template-columns:1fr}.summary-grid{grid-template-columns:1fr}.last-run-content{grid-template-columns:1fr}.metric-grid{grid-template-columns:repeat(2,1fr)}.run-detail{grid-template-columns:1fr}.event-summary{display:block}.event-summary>*{margin-bottom:5px}.event-delta{text-align:left}.event-card.open .event-details{grid-template-columns:1fr}.analysis-footer{flex-direction:column}.intro-section h1{font-size:24px}} +.notice{margin:0 0 14px;padding:10px 12px;border-radius:8px;font-size:12px;line-height:1.45}.warning-notice{border:1px solid #7b6737;background:rgba(137,104,38,.18);color:#f4dda1}.changes-table{min-width:1180px}.changes-table code{display:block;max-width:390px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#c6e6f4;font:10px/1.45 Consolas,monospace}.change-action{display:inline-flex;border:1px solid;border-radius:999px;padding:3px 7px;font-size:10px;font-weight:700;white-space:nowrap}.change-action.created{color:#bff1d5;border-color:#34845c;background:rgba(52,132,92,.2)}.change-action.updated,.change-action.recalculated{color:#cbd8ff;border-color:#4d67a7;background:rgba(75,102,179,.23)}.change-action.deleted{color:#ffd1d4;border-color:#97464d;background:rgba(151,70,77,.22)}.event-link{max-width:180px;border:0;background:none;color:var(--cyan);font:10px Consolas,monospace;text-align:left;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;cursor:pointer;padding:2px}.event-link:hover{text-decoration:underline}.change-row:hover{background:rgba(67,133,164,.07)} diff --git a/internal/web/static/analysis.html b/internal/web/static/analysis.html new file mode 100644 index 0000000..e28a76b --- /dev/null +++ b/internal/web/static/analysis.html @@ -0,0 +1,202 @@ + + + + + + Neural Brain · Analyse + + + +
+
+ +
+ BRAIN ANALYSIS CENTER + Nachvollziehbare Läufe, Graphänderungen, Embeddings, Recherche und Persistenz +
+
+ +
+ +
Analysedaten werden geladen …
+ +
+
+
+ ERKLÄRBARE HIRNAKTIVITÄT +

Was hat das System tatsächlich verändert?

+

Dieses Dashboard trennt die visuelle Gehirnansicht von der technischen Auswertung. Es zeigt nicht nur Endstände, sondern auch, ob ein Lauf neue Nodes, Edges oder Embeddings erzeugt, bestehende Daten verändert, etwas entfernt oder lediglich geprüft und verworfen hat.

+
+
+ positiv übernommen + geprüft, aber nicht übernommen + fehlgeschlagen + ohne Graphänderung +
+
+ +
+
+
LETZTER ABGESCHLOSSENER LAUF

Ergebnisbewertung

+ noch keine Daten +
+
Es wurde noch kein auswertbarer Lauf aufgezeichnet.
+
+ +
+
Graph aktuell–Nodes · Edges
+
Vektorabdeckung–Embeddings / Wissens-Nodes
+
Änderungen im Zeitraum–Neu · Aktualisiert · Entfernt
+
Graphqualität–Komponenten · Waisen · Widersprüche
+
AI-THINK–Status und letzte Ausführung
+
Autonome Recherche–Queue und Ergebnisse
+
Ollama-Pool–Gesundheit und Warteschlangen
+
Persistenz–SQLite / WAL / offene Änderungen
+
+ +
+
+
ZEITVERLAUF

Graphänderungen und Laufereignisse

+ Zeitfenster wird geladen +
+
Lesart: Balken oberhalb der Mittellinie sind neu erzeugte oder aktualisierte Graphobjekte. Balken unterhalb zeigen Löschungen. Die Linie zeigt die Anzahl technischer Ereignisse. So bleibt sichtbar, wenn ein Lauf viele Prüfungen durchgeführt, aber den Graphen nicht verändert hat.
+
+
NeuAktualisiert / neu berechnetEntferntEreignisse
+
+ +
+
+
LAUFPROTOKOLL

Thinking, Lernen, Recherche und Systemaktionen

+ 0 Läufe +
+
+ + + +
+
+ + + +
ZeitLaufBewertungGraphänderungMesswerteDauer
Läufe werden geladen …
+
+
+ +
+
+
GRAPHBESTAND

Nodes nach Herkunft und Typ

+
Source ist der exakte Wert aus dem KB-Feld source. Origin beschreibt dagegen den technischen Erzeuger des Graphobjekts, etwa KB-Ingest, Recherche oder AI-Inferenz.
+
+

Source

+

Node-Typ

+

Origin

+
+
+ +
+
BEZIEHUNGEN

Edges, Nähe und Konfidenz

+
+
AI-Edges–
+
Ø Relation-Konfidenz–
+
Ø gespeicherte Nähe–
+
Widersprüche–
+
+

Edge-Typen

+

Letzte Näheprüfungen

+
+
+ +
+
+
LEARNING

Embedding-Aktivität

–
+
+
Vektoren aktuell–
+
Neu im Zeitraum–
+
Neu berechnet–
+
Entfernt–
+
+

Dimensionen

+

Letzte Embedding-Ereignisse

+
+ +
+
RESEARCH

SearXNG und Wissensanreicherung

–
+
+
Suchtreffer–
+
Volltexte geladen–
+
Belege akzeptiert–
+
Artikel erstellt–
+
+

Letzte Recherchevorgänge

+
+
+ +
+
+
NEUESTE OBJEKTE

Zuletzt veränderte Nodes

+
+
+
+
NEUESTE OBJEKTE

Zuletzt veränderte Edges

+
+
+
+ +
+
+
ÄNDERUNGSJOURNAL

Exakt betroffene Nodes, Edges und Vektoren

+ 0 Änderungen +
+
Unterschied zu den Summen: Hier stehen die konkreten Objekt-IDs, die zwischen zwei technischen Events erzeugt, aktualisiert, neu berechnet oder gelöscht wurden. Zum Schutz vor extrem großen Erstimporten werden pro Event höchstens 2.000 Detailzeilen gespeichert; die exakten Gesamtzähler bleiben trotzdem erhalten.
+ +
+ + + +
+
+ + + +
ZeitAktionObjektID / BezeichnungRelation / OriginGraph-VersionZugeordnetes Event
Änderungen werden geladen …
+
+
+ +
+
+
ROHDATEN

Technischer Eventstream

+ 0 Ereignisse +
+
Warum Rohdaten? Jeder Eintrag enthält die ursprünglichen Engine-Metadaten sowie den unmittelbar erfassten Graphstand. Damit lässt sich auch bei einem übersprungenen Artikel erkennen, wie viele Vergleiche, Suchtreffer, Downloads oder Qualitätsentscheidungen stattgefunden haben.
+
+ + + +
+
+
+
+ +
+ Noch nicht aktualisiert + Analysehistorie wird in graph.db gespeichert; die coole Gehirnansicht bleibt davon getrennt. +
+ + + + diff --git a/internal/web/static/analysis.js b/internal/web/static/analysis.js new file mode 100644 index 0000000..979bb09 --- /dev/null +++ b/internal/web/static/analysis.js @@ -0,0 +1,384 @@ +(() => { + 'use strict'; + + const $ = id => document.getElementById(id); + const state = {payload: null, loading: false, refreshTimer: 0, live: null, openRun: '', openEvents: new Set()}; + const nf = new Intl.NumberFormat('de-DE'); + const dtf = new Intl.DateTimeFormat('de-DE', {dateStyle: 'short', timeStyle: 'medium'}); + const tf = new Intl.DateTimeFormat('de-DE', {hour: '2-digit', minute: '2-digit', second: '2-digit'}); + + function esc(value) { + return String(value ?? '').replace(/[&<>'"]/g, char => ({'&':'&','<':'<','>':'>',"'":''','"':'"'}[char])); + } + function num(value) { return nf.format(Number(value || 0)); } + function pct(value) { return `${Math.round(Number(value || 0) * 100)} %`; } + function when(value) { + const date = new Date(value); + return Number.isNaN(date.getTime()) ? '–' : dtf.format(date); + } + function clock(value) { + const date = new Date(value); + return Number.isNaN(date.getTime()) ? '–' : tf.format(date); + } + function duration(ms) { + ms = Number(ms || 0); + if (ms < 1000) return `${Math.max(0, Math.round(ms))} ms`; + if (ms < 60000) return `${(ms / 1000).toFixed(ms < 10000 ? 1 : 0)} s`; + const minutes = Math.floor(ms / 60000); + const seconds = Math.round((ms % 60000) / 1000); + if (minutes < 60) return `${minutes} min ${seconds} s`; + return `${Math.floor(minutes / 60)} h ${minutes % 60} min`; + } + function bytes(value) { + let number = Number(value || 0); + const units = ['B','KiB','MiB','GiB','TiB']; + let index = 0; + while (number >= 1024 && index < units.length - 1) { number /= 1024; index++; } + return `${number.toFixed(index ? 1 : 0)} ${units[index]}`; + } + function statusClass(value) { + return ['success','warning','error','running'].includes(value) ? value : 'neutral'; + } + function mutationTotals(m = {}) { + return { + created: Number(m.nodes_created || 0) + Number(m.edges_created || 0) + Number(m.vectors_created || 0), + updated: Number(m.nodes_updated || 0) + Number(m.edges_updated || 0) + Number(m.vectors_updated || 0), + deleted: Number(m.nodes_deleted || 0) + Number(m.edges_deleted || 0) + Number(m.vectors_deleted || 0) + }; + } + function hasMutation(m = {}) { + const t = mutationTotals(m); + return t.created + t.updated + t.deleted > 0; + } + function deltaTags(m = {}) { + const tags = []; + if (Number(m.nodes_created)) tags.push(`+${num(m.nodes_created)} Nodes`); + if (Number(m.edges_created)) tags.push(`+${num(m.edges_created)} Edges`); + if (Number(m.vectors_created)) tags.push(`+${num(m.vectors_created)} Vektoren`); + if (Number(m.nodes_updated)) tags.push(`~${num(m.nodes_updated)} Nodes`); + if (Number(m.edges_updated)) tags.push(`~${num(m.edges_updated)} Edges`); + if (Number(m.vectors_updated)) tags.push(`↻${num(m.vectors_updated)} Vektoren`); + if (Number(m.nodes_deleted)) tags.push(`−${num(m.nodes_deleted)} Nodes`); + if (Number(m.edges_deleted)) tags.push(`−${num(m.edges_deleted)} Edges`); + if (Number(m.vectors_deleted)) tags.push(`−${num(m.vectors_deleted)} Vektoren`); + return tags.length ? tags.join('') : 'keine Graphänderung'; + } + function metadataValue(metadata, ...keys) { + for (const key of keys) { + const value = metadata?.[key]; + if (value !== undefined && value !== null && value !== '') return value; + } + return undefined; + } + function eventSeverity(activity = {}) { + const type = String(activity.type || '').toLowerCase(); + const message = String(activity.message || '').toLowerCase(); + if (type.includes('failed') || type.includes('error') || message.includes('fehlgeschlagen')) return 'error'; + if (type.includes('skipped') || type.includes('rejected') || type.includes('no_candidate') || type.includes('paused') || type.includes('deferred') || message.includes('übersprungen')) return 'warning'; + if (type.includes('completed') || type.includes('created') || type.includes('accepted') || type.includes('ingested') || type.includes('learned') || type.includes('synced') || type.includes('results') || type.includes('flushed')) return 'success'; + return 'neutral'; + } + + async function api(path) { + const response = await fetch(path, {headers: {'Accept':'application/json'}, cache: 'no-store'}); + const data = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`); + return data; + } + + async function loadDashboard(reason = 'manual') { + if (state.loading) return; + state.loading = true; + const banner = $('connectionBanner'); + banner.className = 'connection-banner loading'; + banner.textContent = reason === 'live' ? 'Neue Aktivität erkannt · Analyse wird aktualisiert …' : 'Analysedaten werden geladen …'; + const hours = Number($('rangeHours').value || 24); + $('exportAnalysis').href = `/api/analysis/export?hours=${hours}`; + try { + state.payload = await api(`/api/analysis/dashboard?hours=${hours}&limit=400`); + renderAll(); + banner.className = 'connection-banner connected'; + banner.textContent = `Verbunden · Analyse bis ${clock(state.payload.generated_at)} · ${num(state.payload.history?.raw_event_count)} Ereignisse im Zeitraum`; + } catch (error) { + banner.className = 'connection-banner error'; + banner.textContent = `Analyse konnte nicht geladen werden: ${error.message}`; + } finally { + state.loading = false; + } + } + + function renderAll() { + renderLastRun(); + renderSummary(); + renderTimeline(); + renderRuns(); + renderDistributions(); + renderRelations(); + renderEmbeddings(); + renderResearch(); + renderNewest(); + renderChanges(); + renderEvents(); + $('generatedAt').textContent = `Erzeugt am ${when(state.payload.generated_at)} · Zeitraum ${state.payload.range_hours} h`; + } + + function renderLastRun() { + const runs = state.payload?.history?.runs || []; + const run = runs.find(item => item.status !== 'running') || runs[0]; + const status = $('lastRunStatus'); + if (!run) { + status.className = 'status-pill neutral'; status.textContent = 'noch keine Daten'; + $('lastRunContent').className = 'last-run-content empty-state'; + $('lastRunContent').textContent = 'Es wurde noch kein auswertbarer Lauf aufgezeichnet.'; + return; + } + status.className = `status-pill ${statusClass(run.status)}`; + status.textContent = run.verdict || run.status; + const totals = mutationTotals(run.mutations); + $('lastRunContent').className = 'last-run-content'; + $('lastRunContent').innerHTML = ` +
${esc(run.kind)} · ${when(run.started_at)}${run.trigger ? ` · Trigger: ${esc(run.trigger)}` : ''}

${esc(run.title)}

${esc(run.explanation || run.outcome || 'Keine zusätzliche Erklärung vorhanden.')}

${deltaTags(run.mutations)}
+
Neu${num(totals.created)}Nodes, Edges, Vektoren
+
Aktualisiert${num(totals.updated)}inkl. neu berechneter Vektoren
+
Dauer${duration(run.duration_ms)}${num(run.event_count)} technische Ereignisse
`; + } + + function renderSummary() { + const graph = state.payload.graph || {}; + const summary = graph.summary || {}; + const history = state.payload.history || {}; + const system = state.payload.system || {}; + const totals = mutationTotals(history.totals || {}); + $('summaryGraph').textContent = `${num(summary.node_count)} / ${num(summary.edge_count)}`; + $('summaryGraphSub').textContent = `${num(summary.node_count)} Nodes · ${num(summary.edge_count)} aktive Edges · Version ${num(system.version)}`; + $('summaryCoverage').textContent = pct(graph.vector_coverage); + $('summaryCoverageSub').textContent = `${num(graph.vector_rows)} Embeddings für ${num(graph.vector_eligible_nodes)} wissenshaltige Nodes`; + $('summaryChanges').textContent = `+${num(totals.created)} · ~${num(totals.updated)} · −${num(totals.deleted)}`; + $('summaryChangesSub').textContent = `Neu · aktualisiert/neu berechnet · entfernt in ${state.payload.range_hours} h`; + $('summaryQuality').textContent = `${num(summary.components)} / ${num(summary.knowledge_orphans)} / ${num(summary.contradictions)}`; + $('summaryQualitySub').textContent = 'Komponenten · Wissenswaisen · Widerspruchs-Edges'; + const settings = system.runtime_settings || {}; + $('summaryThinking').textContent = settings.thinking_enabled ? (system.enrich_running ? 'LÄUFT' : 'AKTIV') : 'PAUSIERT'; + $('summaryThinkingSub').textContent = `${num(system.relations_created)} Relationen · ${num(system.articles_created)} Artikel seit Prozessstart`; + const autonomous = system.autonomous_research || {}; + const queue = autonomous.counts || {}; + $('summaryAutonomous').textContent = settings.autonomous_research_enabled ? (autonomous.running ? 'LÄUFT' : 'AKTIV') : 'PAUSIERT'; + $('summaryAutonomousSub').textContent = `${num(queue.queued)} wartend · ${num(queue.completed)} abgeschlossen · ${num(queue.failed)} fehlgeschlagen`; + const pool = system.ollama_pool || {}; + const nodes = pool.nodes || []; + const healthy = nodes.filter(node => node.healthy && node.compatible).length; + $('summaryOllama').textContent = `${healthy}/${nodes.length || 0} gesund`; + $('summaryOllamaSub').textContent = `${num(pool.normal_waiters)} normale · ${num(pool.low_priority_waiters)} niedrige Priorität in Warteschlange`; + const storage = system.graph_storage || {}; + const persistence = system.persistence || {}; + const audit = state.payload.history?.audit || {}; + const persistenceError = persistence.last_error || audit.last_error; + $('summaryPersistence').textContent = persistenceError ? 'FEHLER' : (persistence.graph_dirty ? 'OFFEN' : 'SAUBER'); + $('summaryPersistenceSub').textContent = persistenceError ? `Audit/SQLite: ${persistenceError}` : `${bytes(storage.database_bytes)} DB · ${bytes(storage.wal_bytes)} WAL · ${num(Number(storage.pending_nodes||0)+Number(storage.pending_edges||0)+Number(storage.pending_vectors||0))} offene Upserts · Audit ${num(audit.queue_depth)}/${num(audit.queue_capacity)}`; + } + + function renderTimeline() { + const canvas = $('timelineCanvas'); + const data = state.payload?.history?.timeline || []; + $('timelineHint').textContent = `${data.length} Zeitsegmente · ${state.payload.range_hours} Stunden`; + const rect = canvas.getBoundingClientRect(); + const dpr = Math.min(window.devicePixelRatio || 1, 2); + canvas.width = Math.max(600, Math.floor(rect.width * dpr)); + canvas.height = Math.floor(300 * dpr); + const ctx = canvas.getContext('2d'); + ctx.setTransform(dpr,0,0,dpr,0,0); + const width = canvas.width / dpr, height = canvas.height / dpr; + ctx.clearRect(0,0,width,height); + ctx.fillStyle = '#07131d'; ctx.fillRect(0,0,width,height); + if (!data.length) { ctx.fillStyle='#8ca4b7'; ctx.font='13px Segoe UI'; ctx.fillText('Noch keine historischen Daten für diesen Zeitraum.',20,35); return; } + const pad = {l:48,r:22,t:22,b:38}; + const chartW = width-pad.l-pad.r, chartH = height-pad.t-pad.b, mid = pad.t+chartH*.63; + const values = data.map(bucket => { + const m=bucket.mutations||{}; + return {create:Number(m.nodes_created||0)+Number(m.edges_created||0)+Number(m.vectors_created||0),update:Number(m.nodes_updated||0)+Number(m.edges_updated||0)+Number(m.vectors_updated||0),del:Number(m.nodes_deleted||0)+Number(m.edges_deleted||0)+Number(m.vectors_deleted||0),events:Number(bucket.events||0)}; + }); + const maxUp=Math.max(1,...values.map(v=>v.create+v.update)); + const maxDown=Math.max(1,...values.map(v=>v.del)); + const maxEvents=Math.max(1,...values.map(v=>v.events)); + ctx.strokeStyle='#29404f';ctx.lineWidth=1; + ctx.beginPath();ctx.moveTo(pad.l,mid+.5);ctx.lineTo(width-pad.r,mid+.5);ctx.stroke(); + ctx.font='10px Segoe UI';ctx.fillStyle='#7890a3';ctx.textAlign='right'; + ctx.fillText(num(maxUp),pad.l-8,pad.t+8);ctx.fillText('0',pad.l-8,mid+3);ctx.fillText(`−${num(maxDown)}`,pad.l-8,height-pad.b); + const slot=chartW/data.length, bar=Math.max(2,Math.min(18,slot*.58)); + const eventPoints=[]; + data.forEach((bucket,index)=>{ + const x=pad.l+slot*index+slot/2; const v=values[index]; + const createH=(v.create/maxUp)*(mid-pad.t-8); const updateH=(v.update/maxUp)*(mid-pad.t-8); + ctx.fillStyle='#62d39a';ctx.fillRect(x-bar/2,mid-createH,bar,createH); + ctx.fillStyle='#5e8cff';ctx.fillRect(x-bar/2,mid-createH-updateH,bar,updateH); + const delH=(v.del/maxDown)*(height-pad.b-mid-5);ctx.fillStyle='#ff7d84';ctx.fillRect(x-bar/2,mid+1,bar,delH); + const ey=pad.t+(1-v.events/maxEvents)*(mid-pad.t-10);eventPoints.push([x,ey]); + if(index===0||index===data.length-1||index%Math.max(1,Math.ceil(data.length/6))===0){ctx.fillStyle='#7890a3';ctx.textAlign='center';ctx.fillText(new Date(bucket.start).toLocaleString('de-DE',{day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'}),x,height-16)} + }); + ctx.strokeStyle='#f2c86d';ctx.lineWidth=1.7;ctx.beginPath();eventPoints.forEach(([x,y],i)=>i?ctx.lineTo(x,y):ctx.moveTo(x,y));ctx.stroke(); + ctx.fillStyle='#f2c86d';eventPoints.forEach(([x,y])=>{ctx.beginPath();ctx.arc(x,y,2.2,0,Math.PI*2);ctx.fill()}); + } + + function renderRuns() { + const runs = state.payload?.history?.runs || []; + const kindFilter = $('runKindFilter'); + const existing = new Set([...kindFilter.options].map(option => option.value)); + [...new Set(runs.map(run => run.kind).filter(Boolean))].sort().forEach(kind => { if (!existing.has(kind)) kindFilter.add(new Option(kind,kind)); }); + const kind = kindFilter.value, status = $('runStatusFilter').value, search = $('runSearch').value.trim().toLowerCase(); + const filtered = runs.filter(run => (!kind||run.kind===kind)&&(!status||run.status===status)&&(!search||JSON.stringify(run).toLowerCase().includes(search))); + $('runCount').textContent = `${num(filtered.length)} von ${num(runs.length)} Läufen`; + const body = $('runsBody'); + if (!filtered.length) { body.innerHTML='Keine Läufe entsprechen den Filtern.'; return; } + body.innerHTML = filtered.map(run => { + const metrics = run.metrics || {}; + const metricText = [metrics.comparisons ? `${num(metrics.comparisons)} Vergleiche` : '', metrics.research_search_results ? `${num(metrics.research_search_results)} Treffer` : '', metrics.research_accepted ? `${num(metrics.research_accepted)} Belege` : '', metrics.articles_created ? `${num(metrics.articles_created)} Artikel` : ''].filter(Boolean).join(' · ') || `${num(run.event_count)} Events`; + const open = state.openRun === run.id; + return `${clock(run.started_at)}${new Date(run.started_at).toLocaleDateString('de-DE')}${esc(run.title)}${esc(run.kind)}${run.trigger?` · ${esc(run.trigger)}`:''}${esc(run.verdict||run.status)}${esc(run.outcome||'')}
${deltaTags(run.mutations)}
${esc(metricText)}${run.status==='running'?'läuft':duration(run.duration_ms)}${open ? runDetails(run) : ''}`; + }).join(''); + body.querySelectorAll('.run-row').forEach(row => row.addEventListener('click', () => { state.openRun = state.openRun === row.dataset.runId ? '' : row.dataset.runId; renderRuns(); })); + } + + function runDetails(run) { + const events=(run.events||[]).slice().reverse(); + return `

Erklärung

${esc(run.explanation||'Keine Erklärung vorhanden.')}

${deltaTags(run.mutations)}

Messwerte

${esc(JSON.stringify(run.metrics||{},null,2))}

Betroffene IDs

${esc(JSON.stringify({node_ids:run.node_ids||[],edge_ids:run.edge_ids||[]},null,2))}

Ereignisse dieses Laufs

${events.map(event=>`
${clock(event.activity.timestamp)} · ${esc(event.activity.type)}${esc(event.activity.message||event.activity.phase||'')}
${deltaTags(event.point?.delta)}
${event.change_count?`${num(event.change_count)} konkrete Änderungen${event.changes_truncated?` · ${num(event.changes_truncated)} gekürzt`:''}`:''}
`).join('')}
`; + } + + function renderBarList(id, values, limit = 12) { + const entries = Object.entries(values || {}).sort((a,b)=>b[1]-a[1]).slice(0,limit); + const max = Math.max(1,...entries.map(entry=>Number(entry[1]))); + $(id).innerHTML = entries.length ? entries.map(([label,value])=>`
${esc(label)}${num(value)}
`).join('') : 'Keine Daten'; + } + function renderDistributions() { + const graph=state.payload.graph||{}; + renderBarList('sourceDistribution',graph.node_sources,14); + renderBarList('kindDistribution',graph.node_kinds,14); + renderBarList('originDistribution',graph.node_origins,14); + renderBarList('edgeTypeDistribution',graph.edge_types,14); + } + + function renderRelations() { + const graph=state.payload.graph||{}, summary=graph.summary||{}; + $('relationAIEdges').textContent=num(summary.ai_edges); + $('relationConfidence').textContent=graph.average_ai_confidence ? pct(graph.average_ai_confidence) : '–'; + $('relationSimilarity').textContent=graph.average_ai_similarity ? pct(graph.average_ai_similarity) : '–'; + $('relationContradictions').textContent=num(summary.contradictions); + const observations=(state.payload.history?.events||[]).filter(event=>metadataValue(event.activity?.metadata,'semantic_similarity')!==undefined).slice(0,12); + $('similarityList').innerHTML=observations.length?observations.map(event=>{const m=event.activity.metadata||{};return `
${esc(event.activity.message||event.activity.type)}${pct(Number(m.semantic_similarity))}
${when(event.activity.timestamp)} · ${num(m.candidate_comparisons||m.comparisons||0)} Vergleiche${m.confidence!==undefined?` · ${pct(m.confidence)} Konfidenz`:''}
`}).join(''):'Noch keine Näheprüfungen im Zeitraum.'; + } + + function renderEmbeddings() { + const graph=state.payload.graph||{}, totals=state.payload.history?.totals||{}; + $('embeddingRows').textContent=num(graph.vector_rows); + $('embeddingCreated').textContent=num(totals.vectors_created); + $('embeddingUpdated').textContent=num(totals.vectors_updated); + $('embeddingDeleted').textContent=num(totals.vectors_deleted); + $('embeddingCoverageBadge').className=`status-pill ${graph.vector_coverage>=.95?'success':graph.vector_coverage>=.7?'warning':'error'}`; + $('embeddingCoverageBadge').textContent=pct(graph.vector_coverage); + renderBarList('embeddingDimensions',graph.embedding_dimensions,8); + const events=(state.payload.history?.events||[]).filter(event=>event.activity?.type?.includes('embedding')||event.activity?.type?.includes('.learned')).slice(0,12); + $('embeddingEvents').innerHTML=events.length?events.map(event=>`
${esc(event.activity.message||event.activity.type)}${clock(event.activity.timestamp)}
${esc(event.activity.type)} · ${esc(metadataValue(event.activity.metadata,'model')||'')} ${metadataValue(event.activity.metadata,'dimensions')?`· ${num(metadataValue(event.activity.metadata,'dimensions'))} Dimensionen`:''}
${deltaTags(event.point?.delta)}
${event.change_count?`${num(event.change_count)} konkrete Änderungen${event.changes_truncated?` · ${num(event.changes_truncated)} gekürzt`:''}`:''}
`).join(''):'Keine Embedding-Ereignisse im Zeitraum.'; + } + + function researchMetrics() { + const events=state.payload.history?.events||[]; + const metrics={results:0,fetched:0,accepted:0,rejected:0,articles:0,failures:0}; + events.forEach(event=>{const a=event.activity||{},m=a.metadata||{},type=a.type||'';if(type.includes('research.results'))metrics.results+=Number(m.result_count||m.research_search_results||0);if(type.includes('fetch.completed'))metrics.fetched++;if(type.includes('evidence.accepted'))metrics.accepted++;if(type.includes('evidence.rejected'))metrics.rejected++;if(type==='article.created'||(type==='autonomous.research.task.completed'&&m.article_created))metrics.articles++;if(type.includes('research')&&type.includes('failed'))metrics.failures++;}); + return metrics; + } + function renderResearch() { + const metrics=researchMetrics(); + $('researchResults').textContent=num(metrics.results);$('researchFetched').textContent=num(metrics.fetched);$('researchAccepted').textContent=num(metrics.accepted);$('researchArticles').textContent=num(metrics.articles); + $('researchBadge').className=`status-pill ${metrics.failures?'warning':metrics.accepted||metrics.results?'success':'neutral'}`; + $('researchBadge').textContent=metrics.failures?`${num(metrics.failures)} Fehler`:metrics.accepted?`${num(metrics.accepted)} Belege`:'keine Aktivität'; + const runs=(state.payload.history?.runs||[]).filter(run=>['research','autonomous-research','searxng-test','opportunity-scan'].includes(run.kind)).slice(0,12); + $('researchRuns').innerHTML=runs.length?runs.map(run=>`
${esc(run.title)}${esc(run.verdict||run.status)}
${when(run.started_at)} · ${duration(run.duration_ms)} · ${num(run.event_count)} Events

${esc(run.explanation||run.outcome||'')}

${deltaTags(run.mutations)}
`).join(''):'Keine Rechercheläufe im Zeitraum.'; + } + + function renderNewest() { + const graph=state.payload.graph||{}; + $('newestNodes').innerHTML=(graph.newest_nodes||[]).map(node=>`
${esc(node.label)}${when(node.updated_at)}

${esc(node.summary||'Keine Zusammenfassung')}

${esc(node.kind)}${esc(node.status||'kein Status')}${esc(node.origin)}${node.metadata?.source?`source: ${esc(node.metadata.source)}`:''}
${esc(node.id)}
`).join('')||'Keine Nodes vorhanden.'; + $('newestEdges').innerHTML=(graph.newest_edges||[]).map(edge=>`
${esc(edge.type)}${when(edge.updated_at)}

${esc(edge.explanation||'Keine Erklärung')} ${edge.confidence?`· Konfidenz ${pct(edge.confidence)}`:''}

${esc(edge.status||'kein Status')}${esc(edge.origin)}
${esc(edge.source)} → ${esc(edge.target)}
`).join('')||'Keine Edges vorhanden.'; + } + + function changeActionLabel(action) { + return ({created:'erzeugt',updated:'aktualisiert',recalculated:'neu berechnet',deleted:'entfernt'})[action] || action || 'unbekannt'; + } + + function renderChanges() { + const history = state.payload?.history || {}; + const changes = history.changes || []; + const kind = $('changeKindFilter').value; + const action = $('changeActionFilter').value; + const search = $('changeSearch').value.trim().toLowerCase(); + const filtered = changes.filter(change => (!kind || change.entity_kind === kind) && (!action || change.action === action) && (!search || JSON.stringify(change).toLowerCase().includes(search))); + $('changeCount').textContent = `${num(filtered.length)} angezeigt · ${num(history.change_count)} insgesamt im Zeitraum`; + const dropped = Number(history.dropped_detailed_changes || 0); + const truncatedEvents = (history.events || []).reduce((sum, event) => sum + Number(event.changes_truncated || 0), 0); + const notice = $('changeTruncationNotice'); + if (dropped || truncatedEvents || Number(history.change_count || 0) > changes.length) { + notice.hidden = false; + notice.textContent = `Es werden die neuesten ${num(changes.length)} Detailänderungen geladen. ${num(dropped || truncatedEvents)} weitere Detailzeilen wurden wegen des Schutzlimits nicht gespeichert; die Summen für Nodes, Edges und Vektoren bleiben vollständig.`; + } else { + notice.hidden = true; + } + const body = $('changesBody'); + if (!filtered.length) { + body.innerHTML = 'Keine konkreten Änderungen entsprechen den Filtern.'; + return; + } + body.innerHTML = filtered.map(change => { + const details = change.details || {}; + const relation = change.relation_type || details.kind || ''; + const endpoints = details.source && details.target ? `${details.source} → ${details.target}` : ''; + const subtitle = change.label || endpoints || details.source || ''; + const sourceText = details.source && change.entity_kind === 'node' ? `source: ${details.source}` : ''; + const deltas = []; + if (details.semantic_similarity !== undefined || details.previous_semantic_similarity !== undefined) deltas.push(`Nähe ${details.previous_semantic_similarity!==undefined?pct(details.previous_semantic_similarity):'–'} → ${details.semantic_similarity!==undefined?pct(details.semantic_similarity):'–'}`); + if (details.confidence !== undefined && details.previous_confidence !== undefined) deltas.push(`Konfidenz ${pct(details.previous_confidence)} → ${pct(details.confidence)}`); + if (details.dimensions !== undefined) deltas.push(details.previous_dimensions!==undefined?`Dimensionen ${num(details.previous_dimensions)} → ${num(details.dimensions)}`:`${num(details.dimensions)} Dimensionen`); + const technical = [change.origin, sourceText, ...deltas].filter(Boolean).join(' · '); + return `${when(change.timestamp)}${esc(changeActionLabel(change.action))}${esc(change.entity_kind)}${details.status?`${esc(details.status)}`:''}${esc(change.entity_id)}${subtitle?`${esc(subtitle)}`:''}${esc(relation||'–')}${esc(technical)}${num(change.graph_version)}`; + }).join(''); + body.querySelectorAll('.event-link').forEach(button => button.addEventListener('click', event => { + event.stopPropagation(); + $('eventSearch').value = button.dataset.eventId; + renderEvents(); + document.querySelector('.raw-events-section')?.scrollIntoView({behavior:'smooth',block:'start'}); + })); + } + + function renderEvents() { + const events=state.payload.history?.events||[]; + const typeFilter=$('eventTypeFilter'); const known=new Set([...typeFilter.options].map(option=>option.value)); + [...new Set(events.map(event=>event.activity?.type).filter(Boolean))].sort().forEach(type=>{if(!known.has(type))typeFilter.add(new Option(type,type))}); + const type=typeFilter.value,search=$('eventSearch').value.trim().toLowerCase(),changesOnly=$('changesOnly').checked; + const filtered=events.filter(event=>(!type||event.activity.type===type)&&(!changesOnly||hasMutation(event.point?.delta))&&(!search||JSON.stringify(event).toLowerCase().includes(search))); + $('eventCount').textContent=`${num(filtered.length)} von ${num(events.length)} Ereignissen`; + $('eventsList').innerHTML=filtered.length?filtered.map(event=>{const a=event.activity||{},open=state.openEvents.has(a.id);return `
${when(a.timestamp)} ${esc(a.type)}${esc(a.message||a.query||a.phase||'')}${deltaTags(event.point?.delta)}
Aktivität${a.query?`
${esc(a.query)}
`:''}
${esc(JSON.stringify(a,null,2))}
Graph-Checkpoint unmittelbar nach dem Event
${esc(JSON.stringify({...event.point,change_count:event.change_count,changes_truncated:event.changes_truncated||0},null,2))}
`}).join(''):'
Keine Ereignisse entsprechen den Filtern.
'; + $('eventsList').querySelectorAll('.event-card').forEach(card=>card.querySelector('.event-summary').addEventListener('click',()=>{const id=card.dataset.eventId;state.openEvents.has(id)?state.openEvents.delete(id):state.openEvents.add(id);renderEvents()})); + } + + function scheduleLiveRefresh() { + if (!$('autoRefresh').checked) return; + clearTimeout(state.refreshTimer); + state.refreshTimer=setTimeout(()=>loadDashboard('live'),900); + } + function connectLive() { + if (state.live) state.live.close(); + const source=new EventSource('/api/stream'); state.live=source; + source.addEventListener('activity',scheduleLiveRefresh); + source.onopen=()=>{if(!state.loading){$('connectionBanner').className='connection-banner live';$('connectionBanner').textContent='Live-Eventstream verbunden · wartet auf neue Hirnaktivität';}}; + source.onerror=()=>{if(!state.loading){$('connectionBanner').className='connection-banner error';$('connectionBanner').textContent='Live-Eventstream unterbrochen · Dashboard kann weiterhin manuell aktualisiert werden';}}; + } + + $('refreshNow').addEventListener('click',()=>loadDashboard('manual')); + $('rangeHours').addEventListener('change',()=>loadDashboard('range')); + $('runKindFilter').addEventListener('change',renderRuns);$('runStatusFilter').addEventListener('change',renderRuns);$('runSearch').addEventListener('input',renderRuns); + $('changeKindFilter').addEventListener('change',renderChanges);$('changeActionFilter').addEventListener('change',renderChanges);$('changeSearch').addEventListener('input',renderChanges); + $('eventTypeFilter').addEventListener('change',renderEvents);$('eventSearch').addEventListener('input',renderEvents);$('changesOnly').addEventListener('change',renderEvents); + $('autoRefresh').addEventListener('change',event=>{event.target.checked?connectLive():state.live?.close()}); + window.addEventListener('resize',()=>{if(state.payload)renderTimeline()}); + + loadDashboard('startup'); + connectLive(); +})(); diff --git a/internal/web/static/app.css b/internal/web/static/app.css index 717e232..82ae1e4 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -67,3 +67,4 @@ body.low-power .glass{backdrop-filter:blur(12px)} @media(max-width:620px){.view-selector{grid-template-columns:1fr}.metrics span[title]{display:none}} .filter-scope-summary{margin:2px 0 10px;line-height:1.45}.source-option.disabled{opacity:.38}.source-option.disabled span{cursor:not-allowed;border-style:dashed}.source-option span small{display:block;margin-top:2px;font-size:7px;letter-spacing:.04em;color:#8a6f79}.filter-scope-summary.warn{color:#ffb37f}.filter-scope-summary.ok{color:#7898aa} .autonomous-research-settings{flex:0 0 auto}.autonomous-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;margin:10px 0}.autonomous-grid label{display:block;padding:8px;border:1px solid rgba(133,200,255,.11);border-radius:10px;background:rgba(255,255,255,.02)}.autonomous-grid span{display:block;font-size:8px;color:#7893a6;margin-bottom:5px}.autonomous-grid input{width:100%;border:1px solid rgba(133,200,255,.14);background:rgba(2,8,17,.62);color:var(--text);border-radius:8px;padding:7px 8px;outline:0}.autonomous-actions{flex-wrap:wrap}.autonomous-actions button{flex:1 1 120px}.queue-title{margin-top:14px}.autonomous-queue{display:flex;flex-direction:column;gap:7px;max-height:260px;overflow:auto;padding-right:3px}.autonomous-task{position:relative;padding:9px 10px;border:1px solid rgba(133,200,255,.11);border-radius:11px;background:rgba(255,255,255,.024)}.autonomous-task.running{border-color:rgba(93,255,189,.3);box-shadow:0 0 18px rgba(93,255,189,.07)}.autonomous-task.failed{border-color:rgba(255,105,125,.24)}.autonomous-task.completed{opacity:.72}.autonomous-task-head{display:flex;align-items:flex-start;justify-content:space-between;gap:8px}.autonomous-task b{font-size:10px;color:#d9f3ff;line-height:1.35}.autonomous-task em{font-style:normal;font-size:8px;color:#82e7ff;border:1px solid rgba(82,231,255,.2);border-radius:999px;padding:2px 6px;white-space:nowrap}.autonomous-task p{margin:5px 0 0;color:#7893a6;font-size:8px;line-height:1.4}.autonomous-task-meta{display:flex;gap:5px;flex-wrap:wrap;margin-top:7px}.autonomous-task-meta span{font-size:7px;color:#86a3b7;background:rgba(255,255,255,.035);border-radius:999px;padding:3px 6px}.autonomous-task button{position:absolute;right:8px;bottom:8px;border:0;background:transparent;color:#ff8ba0;font-size:8px;cursor:pointer}.autonomous-task button:hover{color:#ffc0ca}@media(max-width:720px){.autonomous-grid{grid-template-columns:1fr}.autonomous-actions button{flex-basis:100%}} +.dock .analysis-dashboard-link{display:inline-flex;align-items:center;border:1px solid rgba(82,231,255,.2);background:rgba(82,231,255,.055);color:#a9dbe8;border-radius:11px;padding:9px 11px;font-weight:700;letter-spacing:.08em;font-size:10px;white-space:nowrap}.dock .analysis-dashboard-link:hover{background:rgba(82,231,255,.12);color:#e5fbff} diff --git a/internal/web/static/index.html b/internal/web/static/index.html index 0739727..c687b60 100644 --- a/internal/web/static/index.html +++ b/internal/web/static/index.html @@ -61,6 +61,7 @@ + ANALYSE