BugFix
All checks were successful
release-tag / release-image (push) Successful in 2m24s

This commit is contained in:
2026-08-05 11:58:25 +02:00
parent 31d3efb8a2
commit b3bd3d5ffd
25 changed files with 679 additions and 719 deletions

View File

@@ -0,0 +1,21 @@
# Changelog: Source-only Filter
## Geändert
- Kategorie-Filter vollständig aus Konfiguration, Runtime-API und WebUI entfernt.
- Administrative ENV/WebUI-Schnittmengen entfernt.
- Lernen, Anzeige und Thinking filtern ausschließlich auf den exakten KB-Wert `source`.
- Vergleich ist case-sensitiv; `origin`, URI, Kategorie und Anzeigename sind keine Fallbacks.
- `GLPI_KB_SOURCE` wird immer als auswählbarer Wert bereitgestellt, auch mit Trefferzahl 0.
- `/api/categories` entfernt; `/api/sources` ist die einzige Filteroptions-API.
- Source-Filter werden mit `source_filter_version: 1` versioniert.
- Alte Kategorie- und unversionierte Source-Auswahlen werden bei der Migration ignoriert.
- Neue SearXNG-Volltextbelege erhalten den Hostnamen als explizites Source-Feld.
- Anzeige zieht keine anders bezogenen Wissens-Nodes mehr über semantische Kanten herein.
- Dokumentation, Compose-Dateien und `.env.example` auf die vereinfachte Semantik umgestellt.
## Kompatibilität
- `graph.db` bleibt verwendbar.
- `runtime-settings.json` behält Schalter, Ansicht, Node-Limit und ECO-Wert.
- Die drei neuen Source-Auswahlen müssen einmal im Web gespeichert werden oder stammen initial aus `BRAIN_*_SOURCES`.

View File

@@ -7,7 +7,7 @@ Eigenständiger Go-Dienst für Agent, lokale Knowledgebase, GLPI-Knowledgebase u
- Fullscreen-Canvas mit 3D-Hirnform, semantischen Cortex-Regionen und hierarchischem Level-of-Detail.
- Drei Visualisierungen: Neural-Brain, gleichmäßige 3D-Honeycomb-Wabe und semantische Constellation-Ansicht mit Themensternen.
- Persistierbarer **ECO**-Modus für schwächere Systeme sowie weiche Node-, Edge- und Ansichtsübergänge ohne sichtbaren Komplett-Reload.
- Laufzeitsteuerung für Learning und Thinking sowie getrennte Kategorie-Filter für Lernen, Anzeige und AI-THINK.
- Laufzeitsteuerung für Learning und Thinking sowie drei einfache, exakte `source`-Filter für Lernen, Anzeige und AI-THINK.
- Echtzeitaktivierung über Server-Sent Events: Nodes glühen, aggregierte Edges leuchten und Partikel folgen tatsächlichen Wissenspfaden.
- Ingest lokaler produktiver Knowledge-JSONs sowie separater AI-THINK-Staging-Dateien.
- Optionaler read-only Ingest sichtbarer Beiträge aus der GLPI-Knowledgebase.
@@ -138,30 +138,35 @@ Mehr Details: [`PERSISTENCE.md`](PERSISTENCE.md) und [`SQLITE-STORAGE.md`](SQLIT
Die untere Steuerleiste enthält direkte Schalter für **LEARNING**, **THINKING**, **NEURAL**, **HONEYCOMB**, **CONSTELLATION** und **ECO**. Sind Learning und Thinking deaktiviert, bleibt das System im Living-Modus; eingehende Agent- oder KB-Anfragen können weiterhin die tatsächlich verwendeten Notes aktivieren.
Über **FILTER** lassen sich für **Lernen**, **Anzeige** und **Thinking** jeweils Kategorien und Quellen auswählen.
Über **FILTER** werden ausschließlich die exakten Werte des JSON-Feldes `source` ausgewählt. Es gibt keine Kategoriefilter, keine technische `origin`-Aliasauflösung und keine Schnittmenge aus Environment und WebUI mehr.
- **Lernen** steuert neue Embeddings **und** die Wissensknoten, die bei Abfragen verwendet werden dürfen.
- **Anzeige** rendert nur passende Wissens-Nodes; lediglich direkt verbundene Kategorie-, Quellen- und Konzept-Nodes werden ergänzend angezeigt.
- **Thinking** begrenzt Relationskandidaten, Artikelquellen, wiederverwendete Recherchebelege und neu akzeptierte Webquellen.
- **Lernen**: Nur Nodes mit exakt passendem `source` erhalten neue Embeddings und dürfen bei semantischen Abfragen verwendet werden.
- **Anzeige**: Nur Nodes mit exakt passendem `source` werden gerendert; direkt verbundene Kategorie-, Quellen- und Konzept-Nodes bleiben als visuelle Taxonomie sichtbar.
- **Thinking**: Nur Nodes und explizit mit einer Quelle versehene Webbelege mit exakt passendem `source` dürfen neue Relationen und Artikel speisen.
Kategorie und Quelle werden mit **UND** kombiniert; mehrere Werte innerhalb einer Liste verwenden **ODER**. Leere WebUI-Listen bedeuten „alle administrativ erlaubten Werte“.
Die Environment-Listen sind feste Obergrenzen. Die WebUI kann sie nur weiter einschränken:
Mehrere ausgewählte Quellen verwenden ODER-Semantik. Eine leere Liste bedeutet „alle Quellen“. Der Vergleich ist nach dem Entfernen äußerer Leerzeichen **case-sensitiv und exakt**:
```text
wirksamer Filter = Environment ∩ WebUI
source: "internal-category" passt zu internal-category
source: "internal-category" passt nicht zu Internal-Category
source: "GLPI Knowledge Base" passt nicht zu glpi-kb
```
Der GLPI-Quellenname stammt ausschließlich aus:
```env
GLPI_KB_SOURCE=GLPI Knowledge Base
```
Dieser Wert wird in der WebUI immer als Auswahl angeboten, auch wenn GLPI noch nicht synchronisiert wurde oder aktuell 0 Beiträge vorhanden sind.
Die folgenden Environment-Variablen sind nur Startwerte, wenn noch keine neue versionierte `runtime-settings.json` existiert. Danach ist die WebUI-Auswahl die einzige aktive Konfiguration; es gibt keine zusätzliche Environment-Obergrenze:
```env
BRAIN_LEARNING_ENABLED=true
BRAIN_THINKING_ENABLED=true
# Administrative Kategoriegrenzen; leer oder * = unbegrenzt
BRAIN_LEARNING_CATEGORIES=
BRAIN_DISPLAY_CATEGORIES=
BRAIN_THINKING_CATEGORIES=
# Administrative Quellengrenzen; z. B. GLPI Knowledge Base, internal-kb, docs.example.org
# Exakte Werte des KB-JSON-Feldes source; leer = alle
BRAIN_LEARNING_SOURCES=
BRAIN_DISPLAY_SOURCES=
BRAIN_THINKING_SOURCES=
@@ -171,7 +176,7 @@ BRAIN_MAX_DISPLAY_NODES=0 # 0 = unbegrenzt; alternativ z. B. 5000
BRAIN_LOW_POWER_MODE=false
```
Die WebUI zeigt die wirksame Schnittmenge an und kennzeichnet Optionen, die durch das Environment ausgeschlossen sind. Gespeichert werden nur die veränderbaren WebUI-Auswahlen in `runtime-settings.json`.
Alte `learning_categories`, `display_categories`, `thinking_categories` und frühere unversionierte Source-Filter werden beim ersten Start dieser Version bewusst ignoriert. Schalter, Ansicht und Performancewerte bleiben erhalten. Beim nächsten Speichern schreibt die WebUI `source_filter_version: 1`. `graph.db` muss dafür nicht gelöscht werden.
Die Ansichten erfüllen unterschiedliche Aufgaben:
@@ -183,7 +188,7 @@ Der **ECO**-Schalter begrenzt die Ausgabe auf ungefähr 30 FPS, reduziert die De
Graphupdates verändern vorhandene Node-Objekte und Positionen in-place. Neue Elemente blenden ein, entfernte Elemente blenden aus, Ansichtswechsel morphen zwischen den Koordinatenräumen. Unveränderte Snapshots werden übersprungen und reine Edgeupdates lösen keinen vollständigen Layout-Neuaufbau aus.
Mehr Details: [`VISUALIZATION-PERFORMANCE.md`](VISUALIZATION-PERFORMANCE.md) und [`RUNTIME-CONTROLS-HONEYCOMB.md`](RUNTIME-CONTROLS-HONEYCOMB.md).
Mehr Details: [`SOURCE-ONLY-FILTERS.md`](SOURCE-ONLY-FILTERS.md), [`VISUALIZATION-PERFORMANCE.md`](VISUALIZATION-PERFORMANCE.md) und [`RUNTIME-CONTROLS-HONEYCOMB.md`](RUNTIME-CONTROLS-HONEYCOMB.md).
## Autonome Anreicherung
@@ -228,9 +233,9 @@ 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/runtime-settings` | aktuelle Learning-, Thinking-, Filter- und View-Einstellungen |
| `GET` | `/api/runtime-settings` | aktuelle Learning-, Thinking-, exakte Source- und View-Einstellungen |
| `PUT` | `/api/runtime-settings` | Laufzeiteinstellungen ändern und gebündelt persistieren |
| `GET` | `/api/categories` | verfügbare Note-Kategorien mit Anzahl |
| `GET` | `/api/sources` | exakte `source`-Werte mit Anzahl; enthält immer `GLPI_KB_SOURCE` |
| `GET` | `/api/stream` | SSE-Aktivitätsstrom |
| `POST` | `/api/query` | programmatische Wissensanfrage; in der Fullscreen-UI verborgen |
| `POST` | `/api/events` | optionale Agent-/KB-Telemetrie |

View File

@@ -1,121 +1,167 @@
# Runtime Controls, Ansichten und ECO
# Runtime Controls, Ansichten, Source-Filter und ECO
Die Fullscreen-Oberfläche kann die aktiven Hintergrundprozesse und die Darstellung ohne Neustart umschalten. Die Einstellungen gelten sofort im Arbeitsspeicher und werden über die gebündelte Persistenz in `BRAIN_DATA_DIR/runtime-settings.json` geschrieben.
Die Fullscreen-Oberfläche kann Hintergrundprozesse und Darstellung ohne Neustart umschalten. Änderungen gelten sofort im Arbeitsspeicher und werden gebündelt in `BRAIN_DATA_DIR/runtime-settings.json` persistiert.
## Living-only
Die Schalter **LEARNING** und **THINKING** befinden sich direkt in der unteren Steuerleiste.
- **Learning aus**: geplante lokale Scans, Reindex, GLPI-KB-Synchronisation und neue Embeddings werden pausiert. Der bereits vorhandene In-Memory-Graph bleibt vollständig nutzbar.
- **Thinking aus**: automatische und manuelle AI-THINK-Zyklen, neue KI-Edges und die dazugehörige Recherche werden pausiert.
- Sind beide Schalter aus, bleibt die Visualisierung im **LIVING**-Modus. Eingehende Agent- oder Knowledgebase-Anfragen dürfen weiterhin die tatsächlich verwendeten Notes aufleuchten lassen; sie starten dadurch keinen autonomen Lern- oder Thinking-Zyklus.
- **Learning aus**: geplante lokale Scans, Reindex, GLPI-KB-Synchronisation und neue Embeddings werden pausiert. Der vorhandene In-Memory-Graph bleibt nutzbar.
- **Thinking aus**: automatische und manuelle AI-THINK-Zyklen, neue KI-Edges, Artikelsynthese und zugehörige Recherche werden pausiert.
- Sind beide Schalter aus, bleibt die Visualisierung im **LIVING**-Modus. Eingehende Agent- oder Knowledgebase-Anfragen dürfen weiterhin tatsächlich verwendete Notes aktivieren, starten aber keinen autonomen Zyklus.
Ein bereits laufender Ollama-Aufruf wird nicht hart abgebrochen. Die Deaktivierung verhindert neue Schritte und neue Zyklen.
Ein bereits laufender Ollama-Aufruf wird nicht hart abgebrochen. Die Deaktivierung verhindert nachfolgende Schritte und neue Zyklen.
## Honeycomb-Ansicht
Mit **HONEYCOMB** wird die semantische Graphansicht durch eine gleichmäßig besetzte dreidimensionale Wabenstruktur ersetzt.
- Gerendert werden nur Notes: `knowledge`, `ai-think` und `external`.
- Gerendert werden nur `knowledge`, `ai-think` und `external`.
- Die Punkte liegen auf einem dicht gepackten 3D-Gitter mit einheitlichem Abstand.
- Das Gitter wird auf die mathematische 3D-Grenze der beiden Gehirnhälften beschnitten.
- Der Abstand wird per binärer Suche automatisch so gewählt, dass alle sichtbaren Notes in die Gehirnform passen.
- Der Abstand wird automatisch so gewählt, dass alle sichtbaren Notes in die Gehirnform passen.
- Edges, Partikelpfade, Cortex-Flächen und LOD-Supernodes werden in dieser Ansicht nicht gerendert.
- Wird eine Note durch Agent, Knowledgebase, Retrieval oder AI-THINK referenziert, leuchtet genau ihr Wabenpunkt auf.
- Wird eine Note durch Agent, Knowledgebase, Retrieval oder AI-THINK referenziert, leuchtet ihr Wabenpunkt auf.
Die Honeycomb-Ansicht verändert weder den Graphen noch die gespeicherten Node-Positionen. Sie ist eine reine Renderprojektion und kann jederzeit zurück auf **NEURAL** geschaltet werden. Ihre Slotbelegung bleibt bei Graphupdates stabil; neue Notes belegen freie Plätze, statt die komplette Wabe neu zu sortieren.
Die Slotbelegung bleibt bei Graphupdates stabil. Neue Notes belegen freie Plätze, statt die komplette Wabe neu zu sortieren.
## Constellation-Ansicht
Mit **CONSTELLATION** werden semantische Themenbereiche als ruhige Zentren mit umlaufenden Wissenssternen dargestellt. Aggregierte Verbindungen zwischen den Themen machen Wissensbrücken sichtbar, ohne jede Einzelkante zu rendern. Aktivierte Notes und Rechercheereignisse leuchten weiterhin auf.
Mit **CONSTELLATION** werden semantische Themenbereiche als ruhige Zentren mit umlaufenden Wissenssternen dargestellt. Aggregierte Verbindungen machen Wissensbrücken sichtbar, ohne jede Einzelkante zu rendern. Aktivierte Notes und Rechercheereignisse bleiben sichtbar.
## ECO-Modus
Der persistierbare Schalter **ECO** optimiert die Darstellung für schwächere Systeme, ohne die Kernanimationen zu entfernen. Er begrenzt die Ausgabe auf ungefähr 30 FPS, reduziert die Device-Pixel-Ratio sowie Budgets für inaktive Edges, Partikel, Wolken, Sterne und Quellenpunkte. Aktive Nodes, Rechercheanimationen, Fade-Übergänge und Themenzentren bleiben sichtbar.
Der persistierbare Schalter **ECO** optimiert die Darstellung für schwächere Systeme, ohne die Kernanimationen zu entfernen. Er begrenzt die Ausgabe auf ungefähr 30 FPS, reduziert Device-Pixel-Ratio sowie Budgets für inaktive Edges, Partikel, Wolken, Sterne und Quellenpunkte. Aktive Nodes, Rechercheanimationen, Fade-Übergänge und Themenzentren bleiben erhalten.
Der ECO-Modus ist unabhängig vom GPU-Node-Limit: ECO reduziert die Kosten pro Frame, das Node-Limit reduziert die Anzahl aufgebauter Elemente.
## GPU-Limit für schwächere Systeme
`max_display_nodes` begrenzt die Anzahl der Nodes, die im Browser für Neural, Honeycomb und Constellation aufgebaut werden. `0` bedeutet unbegrenzt. Die Begrenzung wird nach dem Anzeige-Kategoriefilter angewendet. Das vollständige Wissen bleibt im Backend und im geladenen Graph-Snapshot erhalten.
`max_display_nodes` begrenzt die Anzahl der Nodes, die im Browser für Neural, Honeycomb und Constellation aufgebaut werden. `0` bedeutet unbegrenzt. Die Begrenzung wird nach dem Anzeige-Source-Filter angewendet. Das vollständige Wissen bleibt im Backend und im geladenen Graph-Snapshot erhalten.
Die Auswahl ist stabil und bevorzugt aktive Notes, AI-THINK/Staging-Inhalte, Hubs und eine Grundabdeckung der vorhandenen Themenbereiche. Wird eine aktuell nicht dargestellte Note von Agent, Knowledgebase oder AI-THINK angefordert, ersetzt sie temporär einen inaktiven Node. Nach der Aktivitätsphase greift das Limit wieder auf die stabile Grundauswahl zurück.
Die Auswahl ist stabil und bevorzugt aktive Notes, AI-THINK-/Staging-Inhalte, Hubs und eine Grundabdeckung der vorhandenen Themenbereiche. Wird eine aktuell nicht dargestellte Note von Agent, Knowledgebase oder AI-THINK angefordert, ersetzt sie temporär einen inaktiven Node. Nach der Aktivitätsphase greift wieder die stabile Grundauswahl.
Im Web befindet sich die Einstellung unter **FILTER → GPU-Limit**. Voreinstellungen für 1.000, 5.000, 10.000 und 25.000 Nodes sind enthalten.
Startwert per Environment:
```env
BRAIN_MAX_DISPLAY_NODES=5000
```
Zulässig sind Werte von `0` bis `500000`. Die Einstellung reduziert die Render-, Layout- und Edge-Last im Browser. Der vollständige Graph wird weiterhin übertragen, damit bei einer Anfrage gezielt ausgeblendete Notes eingeblendet werden können.
Zulässig sind Werte von `0` bis `500000`.
## Kategorie-Filter
## Exakte Source-Filter
Über **FILTER** öffnet sich das Control-Panel. Eine leere Liste bedeutet jeweils „alle Kategorien“. Mehrere ausgewählte Kategorien werden als ODER-Verknüpfung behandelt.
Das frühere Kategorie-/Source-Doppelfiltersystem wurde vollständig entfernt. Gefiltert wird nur nach dem exakten Wert des top-level JSON-Feldes `source` eines KB-Dokuments.
Beispiel:
```json
{
"id": "KB-AD-001",
"title": "Active Directory prüfen",
"source": "internal-category"
}
```
Dazu passt ausschließlich:
```text
internal-category
```
Nicht passend sind `Internal-Category`, `glpi-kb`, ein technischer `origin`-Wert oder eine Kategorie des Artikels. Vor dem Vergleich werden lediglich äußere Leerzeichen entfernt; Groß-/Kleinschreibung bleibt relevant.
Für gelernte Webbelege wird als explizite Quelle der Hostname gesetzt, zum Beispiel `learn.microsoft.com`. Nodes ohne `source` erscheinen bei einer konkreten Auswahl nicht als eigener synthetischer Filterwert; sie bleiben nur bei „Alle“ sichtbar beziehungsweise nutzbar.
### Lernen
`learning_categories` begrenzt, welche neuen oder geänderten Notes Embeddings erhalten und damit für neue semantische Verarbeitung vorbereitet werden. Der Quell-Ingest bleibt read-only und darf die Datenbasis weiterhin vollständig erfassen; ausgeschlossen werden nur neue Embedding-Schritte.
`learning_sources` steuert:
- welche neuen oder geänderten Nodes eingebettet werden;
- welche vorhandenen Vektoren bei semantischen Abfragen berücksichtigt werden.
Bereits gespeicherte Vektoren anderer Quellen bleiben in `graph.db`, sind unter dem aktuellen Filter aber inaktiv. Eine spätere Freigabe erfordert daher kein erneutes Embedding.
### Anzeige
`display_categories` begrenzt die im Browser gerenderten Notes. Direkt verbundene Kategorie-, Quellen- und Taxonomie-Nodes werden in der Neural-Ansicht mitgeführt. In Honeycomb und Constellation werden ausschließlich passende Notes gerendert.
`display_sources` begrenzt die sichtbaren Wissens-Nodes. Wissens-Nodes mit einem anderen `source`-Wert werden nicht über semantische Kanten hereingezogen. In der Neural-Ansicht dürfen direkt verbundene `category`, `source` und `concept`-Taxonomieknoten als visueller Kontext mitgeführt werden. Honeycomb zeigt weiterhin nur Notes.
Der vollständige Graph bleibt serverseitig erhalten. Der Filter ist keine Lösch- oder Zugriffsregel.
Der Anzeige-Filter ist eine Rendersteuerung und keine Zugriffskontrolle. `/api/graph` liefert weiterhin den vollständigen Graphen.
### Thinking
`thinking_categories` und `thinking_sources` begrenzen beide Nodes eines Kandidatenpaares, die Artikelquellenauswahl und externe Belege. Bereits vorhandene Edges und AI-THINK-Beiträge bleiben gespeichert, werden außerhalb des aktuellen Filters aber nicht für neue Thinking-Schritte verwendet.
`thinking_sources` begrenzt:
`learning_categories` und `learning_sources` gelten sowohl für neue Embeddings als auch für semantische Abfragen. Bereits gespeicherte Vektoren außerhalb des Filters werden nicht gelöscht, sondern bleiben inaktiv und können nach einer späteren Erweiterung ohne Neuberechnung wiederverwendet werden.
- beide Nodes eines Relationskandidaten;
- Quellen eines Themenverbunds;
- Knowledge-Synthesis-Quellen;
- wiederverwendete Research-Evidence;
- neu akzeptierte SearXNG-Volltextbelege.
Die virtuellen Werte `__uncategorized__` und `__unsourced__` erscheinen im Web als **Ohne Kategorie** beziehungsweise **Ohne Quelle**.
Mehrere ausgewählte Quellen verwenden ODER-Semantik. Eine leere Liste bedeutet in allen drei Bereichen **alle Source-Werte**.
## Konfiguration
## GLPI-Source
Startwerte können über Umgebungsvariablen gesetzt werden:
Der genaue Source-Wert aller GLPI-KB-Nodes wird durch folgende Variable festgelegt:
```env
GLPI_KB_SOURCE=GLPI Knowledge Base
```
Genau dieser Wert wird in `metadata.source` der GLPI-Nodes gespeichert und steht in der WebUI immer zur Auswahl auch vor dem ersten GLPI-Sync, bei deaktiviertem GLPI-Ingest oder bei aktuell 0 Beiträgen.
Der technische Origin `glpi-kb` ist absichtlich kein Alias und wird nicht als Treffer behandelt.
## Konfiguration und Persistenz
```env
BRAIN_LEARNING_ENABLED=true
BRAIN_THINKING_ENABLED=true
BRAIN_LEARNING_CATEGORIES=
BRAIN_DISPLAY_CATEGORIES=
BRAIN_THINKING_CATEGORIES=
# Exakte Werte des JSON-Feldes source; kommagetrennt, leer = alle
BRAIN_LEARNING_SOURCES=
BRAIN_DISPLAY_SOURCES=
BRAIN_THINKING_SOURCES=
BRAIN_DEFAULT_VIEW=neural
BRAIN_MAX_DISPLAY_NODES=0
BRAIN_LOW_POWER_MODE=false
```
Zulässige Werte für `BRAIN_DEFAULT_VIEW` sind `neural`, `honeycomb` und `constellation`.
Die drei Environment-Listen sind ausschließlich Startwerte. Sobald die neue WebUI-Konfiguration gespeichert wurde, ist `runtime-settings.json` maßgeblich. Es gibt keine zusätzliche ENV/WebUI-Schnittmenge.
Die Environment-Kategorie- und Quellenlisten bleiben immer administrative Obergrenzen. `runtime-settings.json` speichert nur zusätzliche WebUI-Einschränkungen sowie Schalter, Ansicht und Performancewerte. Fehlende Felder in älteren Dateien werden feldweise mit den aktuellen Defaults ergänzt.
Die neue Datei kennzeichnet die Semantik mit:
```json
{
"source_filter_version": 1
}
```
Alte Kategorie-Felder sowie unversionierte Source-Auswahlen werden bei der Umstellung bewusst ignoriert. Learning-/Thinking-Schalter, Ansicht, Node-Limit und ECO-Wert werden weiterhin feldweise übernommen. `graph.db` muss nicht gelöscht werden.
## API
```http
GET /api/runtime-settings
PUT /api/runtime-settings
GET /api/categories
GET /api/sources
```
`GET /api/sources` enthält die exakt im Graph gefundenen Source-Werte mit Anzahl und zusätzlich immer den aktuell konfigurierten Wert aus `GLPI_KB_SOURCE`.
Beispiel:
```bash
curl -X PUT http://localhost:8090/api/runtime-settings \
-H 'Content-Type: application/json' \
-d '{
"learning_enabled": false,
"thinking_enabled": false,
"learning_categories": [],
"display_categories": ["GLPI"],
"thinking_categories": ["GLPI", "Ollama"],
"source_filter_version": 1,
"learning_enabled": true,
"thinking_enabled": true,
"learning_sources": ["internal-category", "GLPI Knowledge Base"],
"display_sources": ["GLPI Knowledge Base"],
"thinking_sources": ["internal-category", "learn.microsoft.com"],
"view_mode": "constellation",
"max_display_nodes": 5000,
"low_power_mode": true

View File

@@ -1,7 +1,6 @@
8f8861c8f91c787238b019804f05f5ce1a33878480c8de3239de97c98f492f21 ./.env.example
478e5158150ff119d661f1d9dcd02565ac9181db77b666289b860d45ed92b896 ./.env.example
8955cfbeff229e73f0cad664863ff21711c270a4233c3225680c89aa6268a901 ./ARCHITECTURE.md
2bc149241c2d25f755e4a0470dc517f646527ee3e98d3a6c2e7798639bd70866 ./CHANGELOG-CONSTELLATION-ECO-TRANSITIONS.md
176f089da30ddea637d7a4c81ef45dfa889e0b36a9180a17145ef0931b523a69 ./CHANGELOG-FILTER-SCOPES-SOURCES.md
213ac897cd415bbeb9764a847843b9eff366983cf25bb3cbce4efa4c04b9e5f5 ./CHANGELOG-GLPI-POOL-PERSISTENCE.md
e2f1f0400999cb59b8be09bc2743e06e0a0b2ecdc44a583af7c9a08b70d8509e ./CHANGELOG-GPU-NODE-LIMIT.md
a317376127be1e47b9ec9f0781fcfebdcbf7fccfaeb7840d9241f9586048fadb ./CHANGELOG-GROUNDED-KNOWLEDGE-SYNTHESIS.md
@@ -10,31 +9,32 @@ e167a9d64f63c3933ad40c5078684bc019db303043c34ea3965f3b089f3d32c7 ./CHANGELOG-KN
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
a1aed7c198bc1ffc7af4a8f69ccf137541e4887ce5d59a0d67f2be7216a37dcd ./FILTER-SCOPES-SOURCES.md
5534536965bf0479455f97324c242160202650ca1256f1ba0420b4ad67125e49 ./GLPI-KB.md
c546524a3add0b1beb8d1ca675fd602e2ea7c7bddfc04759519c21a02667eb61 ./ITERATIVE-GROUNDED-RESEARCH.md
e3ae87108607ca494668a9974c467a5c529b9599bf88d3d2a79ddc15b64e4a38 ./KNOWLEDGE-SYNTHESIS.md
696d2da2338cd8190b9614707e4059d78ce291e7334f273633aad815c3b6a6df ./Makefile
381d7d6ac9e3c2e63c9ecdaa42ed4c73058f5d78e57c7532bb75a9663c919530 ./OLLAMA-POOL.md
2c0062941ef3edbd40d46b823934a7d0a3a9da7581b83d0b9360e8aaa7694b1b ./PERSISTENCE.md
1d5ef2ee13147d582d0a1fccea15884912a3b7742d3c847460bc3f660eb108f9 ./README.md
24ebc1a996f9792a9b38ba1f5dca55b217d61378af9fe9a751c1478c31fb9ea1 ./RUNTIME-CONTROLS-HONEYCOMB.md
81b96acacdb0754eec1c2e5e1d73440e6110d9589444c38d9daa9c2ab0a30996 ./README.md
2838cd19ac2bfa35bebbef2541f631b99221b5997bbb6dbc27146a66a3a1ad34 ./RUNTIME-CONTROLS-HONEYCOMB.md
c3da43b33e550901d55789f2ee526c2e50f61ee028a3f59e0a40e77e1057fde7 ./SEARXNG-VISUALIZATION.md
c69419c0327425186cfb84f25746feff226ce813cf467b3f252e729517455047 ./SOURCE-ONLY-FILTERS.md
ae7bc1f1959071f79b76ca8a4ba103064ec0d5c5752af346175731ef4f636d9d ./SQLITE-STORAGE.md
e05449bab6250e585a6c0ac0735008cd533baf07dbf7149ddae609ae252d4425 ./VALIDATION-CONSTELLATION-ECO-TRANSITIONS.md
3830269b6584e63b4d5cd3627ac5c713c0aedc80e189b35e920d1228de25ff5e ./VALIDATION-FILTER-SCOPES-SOURCES.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
70deb54b2c756cd53bd5606b9a1abc0bab8ad9194e96b6d86df79cfa8cac2903 ./deployment/docker-compose.full.yml
d7902167be92bd1d2fc2872cc69814e17f569e605ddb817cbd36b12768d2d818 ./docker-compose.yml
69bbfd1f37ef763f47bef05e41b964412ad099e84ca2cd8b250ff6c0d653021f ./deployment/docker-compose.full.yml
150a861ec5984f59edb235659e1614b6d102c038717b4e88d6770332958c06be ./docker-compose.yml
1542b064707bef0c4488f558f77c02db5f4cfc8d4da228f615ebdc3ee9ace8fd ./go.mod
864c3376212497b070feca13d26cfc28e96876078ce7a0b5b0c0470e2dd4fbf8 ./go.sum
bf239391e61040b00d2f49d805b0d49a44bd3679f3c53849dfbc5e3b5a3fcc7a ./integrations/agent/README.md
@@ -42,29 +42,29 @@ a0105475dc054977223fac36618b8cd8137c55be1d11fddcd24e9a4d3074c170 ./integrations
73ab7c49600cdaa4795e76c50e66ce919dec171b3a07f2d16ff6f45ce5f73365 ./integrations/knowledgebase/README.md
36666043ebf4139e13610e5fcd0b0c6f45e4e6a53fed33ec47d03a66878e7b08 ./integrations/knowledgebase/glpi-ai-knowledgebase-neural-brain.patch
50d05fa2a183f5f3eaab0545cb48d3abb64be62eb5d84dc2c6d99c7125f7344b ./internal/activity/broker.go
3b23bf7119463350fc1a8454bc8e7d3713b6ee03457618634d154912c41de787 ./internal/config/config.go
c619fb8ae9c68d43ce3db2588b41de3db4407c1402220499fa55d34755fd374e ./internal/config/config_test.go
0560ae79ab5b100de402d6c25026fbacdd36d1d4cf9a17f464eaa4586a61aa52 ./internal/engine/article.go
df61afedf192e2ae716cab5473e17fde83426e28e8d8443aff4a0ec9375c452e ./internal/config/config.go
8d0c9e0691d4c8aaf4542b12f1b7331acf054990034e38c912b492afe98c3159 ./internal/config/config_test.go
60f32f6dbc77ce319344422805185b30264cf4d3dc87e8f36daeafefcba31f0f ./internal/engine/article.go
d5d18a26010fe5f308c05b79e3a3d7501c8490bb33098357cde4373affd7e60d ./internal/engine/article_format_test.go
0563c5455889f0747c1c2dd3ec5e7b639f7102d2f8966b1ed863e401ee537450 ./internal/engine/article_research.go
2e439337f3e6c8372e5ccef9c23b605ad8c18310f642643244a2f7e9f0747a3c ./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
94ce4c2cab802e0bc6870c58eb95bf47d699acbe233a0851ddc289520a238e41 ./internal/engine/engine.go
ecc5960311c88b4fb179dec3c2fa7662130f2163c14a258b886e682ce93eb933 ./internal/engine/engine_test.go
f5ff9fc694f0b85bcc58a4374b7e053cd63a9081f497bbc5d52101a610c32359 ./internal/engine/engine.go
24e022c1e572b42752fed780ff57f2c857d0600460b7806f7664f3c8c7dbb811 ./internal/engine/engine_test.go
e3595489273b2e5f6b8686d7912e1d56c968fb8d105eea58973c25496e1cf412 ./internal/engine/research_diagnostics.go
0eb3f00e2ab73d2dc6a4cbc1a4038533a4bb20fbbbb6190abd39feff6b34b8e1 ./internal/engine/research_diagnostics_test.go
716d42138db9eb64480c1bbaf4cb3b70bce1edf72c17c3d85a8d84f866fd8700 ./internal/engine/research_events.go
62de2df4f031f46921f2cb7a27d29e9dbb9e8ad344a6ea78c213e98f77ff377d ./internal/engine/runtime.go
459a86b2fcbb3be6233310dd3bb6f0c3700d661251eb582f9f50f2d13f3dfcc3 ./internal/engine/runtime_filter_test.go
5da8f0045fe6c7e855bb0089878934468c33e590f44e4249749d6460f13285b5 ./internal/engine/runtime.go
8dc076eb6c4c21a46e93dcdf33159bc4bf4a6876433a4df0fbeef0b08daca378 ./internal/engine/runtime_filter_test.go
b82980a646a92751bdd27a866ba1ffc6d34a3ba81d537f7b6e5a78e1432ee6fa ./internal/glpi/client.go
525102be56bc51ce8a08655b1b2bb53b67f4a1828903585fd664ed6a5133f617 ./internal/glpi/client_test.go
7f5028a6cb24ede68316dbe1708b5482fed44bbc3aee3347d1d5d615c9b7a826 ./internal/graph/filter.go
ee3668d71652630a3e5778f9840618e5e1a599a654abf7c679a4b735e34d2491 ./internal/graph/filter_test.go
5e1b064bda200cf6f14278093f7a48132b175c73973706322623b049b8083737 ./internal/graph/filter.go
c14883e3db65a61672d0171c98a9c2d5ccff3fa6ac74e7236c16819d27ddf503 ./internal/graph/filter_test.go
6b3df883bb30e4d969a5aa7e4cb74ca98119ec1155f6b566555d06c270842c93 ./internal/graph/sqlite_backend.go
744d306b9c7151543e91421575775ff6eb1b7e0af187ba7d92d8a298c51704de ./internal/graph/sqlite_backend_test.go
abe9818db121f96ea0f73437182df13ed8a51259c21dcdbab199032b5a62c3c6 ./internal/graph/store.go
11daaf15e175818ddd62d70ac415d3493d6a473a2dbb1b65a59be33a249242d3 ./internal/graph/store_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
@@ -79,9 +79,9 @@ e7138877303bcc07c429323c4792fed112d8c16d35fd44222fe144ab0b69344f ./internal/res
6a9f269783a7c41d5b63f9bd5022f415ed1b5572041ca5ccae1512d6dc2a0b80 ./internal/research/fetch_test.go
a46b953c22fb2aef112b11ba65c61bda1e1582903e39489c2d081d17519ebd9d ./internal/research/searxng.go
2f82e80ad91590f419b1bbb19647b5191596e97c4d765e8736c1867ad899d457 ./internal/research/searxng_test.go
53a6808587288a1396bc069518814e361c08b88034cf2715e6d14663bed424b3 ./internal/web/server.go
680fa1cc3c15fce43920f75e4592370609a0e6cc2b9ebf2a208f700612ad3d43 ./internal/web/server_test.go
48e7fed4f954232ced9cd05f90a78bcb8793df582ab21faaf3cd243c9cb7b7ea ./internal/web/static/app.css
8bc0391f849b77e6a08da13d0696f1bd122ba07aa3c58600911d922cf7871c10 ./internal/web/static/app.js
a9d35b1de4a98f76e4851fc2e0c083f62c262468e9c357fd59851daa2c84d9c7 ./internal/web/static/index.html
500aa5830effdb248b08bf5e25d58920ab9a5bdb49ca8b18265f02435e2ccaf7 ./internal/web/server.go
e601277536f59c6064e643726a44cebaf67b2a8aad54039f45ff75dddd9b4a5d ./internal/web/server_test.go
5a1c81aa6e77e1408f3cfa86f9ad2c3dc6345b7d451a02e5c9524d0917bdc682 ./internal/web/static/app.css
ecc55c4fad6d1b470ae5d546798339e1d1a63d47c1b21a311340b1458dee9e7e ./internal/web/static/app.js
4446cd59b91abb9e81b74ff66b1fb1aee63f891c36829c192c83308313653f94 ./internal/web/static/index.html
83aded814b6225395935e61fe957963c3c470f368fc9089f505b6de23e959115 ./preview.png

111
SOURCE-ONLY-FILTERS.md Normal file
View File

@@ -0,0 +1,111 @@
# Source-only Filter
## Ziel
Das bisherige Doppelfiltersystem aus Kategorien, Quellen, administrativen Environment-Grenzen und WebUI-Schnittmengen wurde entfernt. Das Brain verwendet für Lernen, Anzeige und Thinking nur noch den exakten Wert des top-level Feldes `source` in den KB-JSON-Dateien.
```json
{
"id": "KB-VPN-001",
"title": "VPN-Verbindung prüfen",
"source": "internal-category"
}
```
Die Auswahl `internal-category` trifft dieses Dokument. `Internal-Category`, `glpi-kb`, Kategorien, Dateipfade, URI und `origin` treffen es nicht.
## Vergleichsregeln
- äußere Leerzeichen werden entfernt;
- danach wird case-sensitiv und bytegenau verglichen;
- mehrere Werte innerhalb eines Filters sind ODER-verknüpft;
- eine leere Liste bedeutet alle Quellen;
- Nodes ohne `source` erzeugen keine künstliche Filteroption und werden nur bei leerem Filter („Alle“) berücksichtigt;
- es gibt keine Kategorie-/Quellen-UND-Verknüpfung;
- es gibt keine ENV/WebUI-Schnittmenge;
- technische Origins sind keine Aliase.
## Drei unabhängige Einsatzbereiche
### Lernen
`learning_sources` entscheidet, welche Wissens-Nodes neue Embeddings erhalten und welche vorhandenen Vektoren bei `/api/query` verwendet werden. Vektoren anderer Quellen bleiben gespeichert, sind unter dem aktuellen Filter jedoch inaktiv.
### Anzeige
`display_sources` entscheidet, welche `knowledge`, `ai-think` und `external` Nodes im Browser sichtbar sind. Semantische Kanten dürfen keine anders bezogenen Wissens-Nodes nachziehen. Direkt verknüpfte Taxonomieknoten können in der Neural-Ansicht als Kontext sichtbar bleiben.
### Thinking
`thinking_sources` begrenzt Relationskandidaten, Themenverbünde, Artikelquellen, wiederverwendete Research-Evidence und neu akzeptierte SearXNG-Belege.
## GLPI
GLPI-KB-Nodes erhalten als exakten Source-Wert:
```env
GLPI_KB_SOURCE=GLPI Knowledge Base
```
Dieser Wert wird immer über `GET /api/sources` angeboten, auch wenn noch kein GLPI-Beitrag geladen wurde. Der technische Origin `glpi-kb` wird absichtlich nicht als Alias akzeptiert.
## Webbelege
Akzeptierte Volltextbelege aus SearXNG erhalten als explizites `source` den normalisierten Hostnamen, zum Beispiel:
```text
learn.microsoft.com
docs.aws.amazon.com
nvlpubs.nist.gov
```
Dadurch können Webbelege gezielt im Thinking-Filter zugelassen oder ausgeschlossen werden.
## Runtime-Datei und Migration
Die neue Semantik wird in `runtime-settings.json` markiert:
```json
{
"source_filter_version": 1,
"learning_sources": ["internal-category"],
"display_sources": ["GLPI Knowledge Base"],
"thinking_sources": ["internal-category", "learn.microsoft.com"]
}
```
Unversionierte Source-Auswahlen und frühere Kategorie-Felder werden beim ersten Start dieser Version ignoriert. Dadurch werden keine alten, mehrdeutigen Filter stillschweigend übernommen. Learning-/Thinking-Schalter, Ansicht, GPU-Limit und ECO-Einstellung bleiben erhalten.
Die Environment-Listen sind nur Initialwerte, solange noch keine versionierte Runtime-Auswahl vorhanden ist:
```env
BRAIN_LEARNING_SOURCES=
BRAIN_DISPLAY_SOURCES=
BRAIN_THINKING_SOURCES=
```
Nach dem Speichern im Web ist `runtime-settings.json` allein maßgeblich.
## API
```http
GET /api/runtime-settings
PUT /api/runtime-settings
GET /api/sources
```
Beispielantwort von `/api/sources`:
```json
{
"sources": [
{"name": "internal-category", "count": 126},
{"name": "GLPI Knowledge Base", "count": 0},
{"name": "learn.microsoft.com", "count": 7}
]
}
```
## Kein Datenreset erforderlich
`graph.db` muss nicht gelöscht werden. Bereits vorhandene Nodes und Embeddings werden anhand ihres expliziten `metadata.source` ausgewählt. Alte Research-Nodes ohne Source werden nur bei leerem Filter („Alle“) berücksichtigt; neu gelernte Webbelege erhalten automatisch den Hostnamen.

View File

@@ -0,0 +1,47 @@
# Validierung: Source-only Filter
## Geprüfte Semantik
- exakter, case-sensitiver Treffer auf `metadata.source`;
- kein Treffer über `origin=glpi-kb`;
- kein Fallback auf URI, Kategorie, Label oder Taxonomieknoten;
- ODER-Semantik für mehrere Source-Werte;
- leere Liste bedeutet uneingeschränkt;
- keine synthetische Option für Nodes ohne Source;
- Learning-Auswahl für Embedding und semantisches Retrieval;
- Thinking-Auswahl für Relationspaare und Artikelquellen;
- Anzeige ohne semantisches Nachziehen anders bezogener Wissens-Nodes;
- versionierte Runtime-Persistenz;
- bewusster Reset alter unversionierter Filter;
- `GLPI_KB_SOURCE` wird mit Trefferzahl 0 in der Source-Auswahl bereitgestellt;
- Webbelege erhalten den Hostnamen als explizite Source.
## Erfolgreich ausgeführt
```text
node --check internal/web/static/app.js
Chromium-CDP-DOM-Test mit simulierten Runtime-, Source- und Graph-APIs
YAML-Parsing von docker-compose.yml
YAML-Parsing von deployment/docker-compose.full.yml
go test ./internal/config
go test ./internal/graph -run Source-only-In-Memory-Tests
go test ./internal/engine -run Runtime-/Source-Unit-Tests
go test ./internal/config ./internal/research ./internal/ollama ./internal/glpi
go test ./... -run '^$' (Typkompilierung aller Pakete)
go vet ./...
git diff --check
```
Der Browser-Test bestätigte: nur der exakt passende `internal-category`-Node wird gerendert, `Internal-Category` bleibt getrennt, der konfigurierte GLPI-Wert erscheint mit Anzahl 0 und es existieren keine Kategorie-Filterelemente mehr.
Die Source-Unit-Tests prüfen insbesondere den exakten GLPI-Namen, die Ablehnung des technischen Origins `glpi-kb`, den konfigurierten GLPI-Wert mit Anzahl 0 sowie Learning-, Retrieval- und Thinking-Grenzen.
## Einschränkung der lokalen Umgebung
Der echte Treiber `modernc.org/sqlite` war nicht im lokalen Go-Modulcache vorhanden und konnte wegen gesperrter externer Modulauflösung nicht geladen werden. Für Typkompilierung und SQLite-unabhängige Unit-Tests wurde deshalb ausschließlich während der Prüfung ein lokaler Compile-Stub verwendet; `go.mod`, `go.sum` und der ausgelieferte Produktivcode verwenden unverändert:
```text
modernc.org/sqlite v1.37.1
```
SQLite-abhängige Integrations- und Webservertests müssen auf dem Buildsystem mit normalem Modulzugriff durch `go test ./...` ausgeführt werden. Es wurde kein Stub in das Projekt oder das ZIP übernommen.

View File

@@ -132,9 +132,6 @@ services:
BRAIN_PERSIST_INTERVAL: ${BRAIN_PERSIST_INTERVAL:-5m}
BRAIN_LEARNING_ENABLED: ${BRAIN_LEARNING_ENABLED:-true}
BRAIN_THINKING_ENABLED: ${BRAIN_THINKING_ENABLED:-true}
BRAIN_LEARNING_CATEGORIES: ${BRAIN_LEARNING_CATEGORIES:-}
BRAIN_DISPLAY_CATEGORIES: ${BRAIN_DISPLAY_CATEGORIES:-}
BRAIN_THINKING_CATEGORIES: ${BRAIN_THINKING_CATEGORIES:-}
BRAIN_LEARNING_SOURCES: ${BRAIN_LEARNING_SOURCES:-}
BRAIN_DISPLAY_SOURCES: ${BRAIN_DISPLAY_SOURCES:-}
BRAIN_THINKING_SOURCES: ${BRAIN_THINKING_SOURCES:-}

View File

@@ -31,9 +31,6 @@ services:
BRAIN_PERSIST_INTERVAL: ${BRAIN_PERSIST_INTERVAL:-5m}
BRAIN_LEARNING_ENABLED: ${BRAIN_LEARNING_ENABLED:-true}
BRAIN_THINKING_ENABLED: ${BRAIN_THINKING_ENABLED:-true}
BRAIN_LEARNING_CATEGORIES: ${BRAIN_LEARNING_CATEGORIES:-}
BRAIN_DISPLAY_CATEGORIES: ${BRAIN_DISPLAY_CATEGORIES:-}
BRAIN_THINKING_CATEGORIES: ${BRAIN_THINKING_CATEGORIES:-}
BRAIN_LEARNING_SOURCES: ${BRAIN_LEARNING_SOURCES:-}
BRAIN_DISPLAY_SOURCES: ${BRAIN_DISPLAY_SOURCES:-}
BRAIN_THINKING_SOURCES: ${BRAIN_THINKING_SOURCES:-}

View File

@@ -65,9 +65,6 @@ type Config struct {
APIKey string
LearningEnabled bool
ThinkingEnabled bool
LearningCategories []string
DisplayCategories []string
ThinkingCategories []string
LearningSources []string
DisplaySources []string
ThinkingSources []string
@@ -161,9 +158,6 @@ func Load() (Config, error) {
APIKey: strings.TrimSpace(os.Getenv("BRAIN_API_KEY")),
LearningEnabled: boolean("BRAIN_LEARNING_ENABLED", true),
ThinkingEnabled: boolean("BRAIN_THINKING_ENABLED", true),
LearningCategories: stringList("BRAIN_LEARNING_CATEGORIES"),
DisplayCategories: stringList("BRAIN_DISPLAY_CATEGORIES"),
ThinkingCategories: stringList("BRAIN_THINKING_CATEGORIES"),
LearningSources: stringList("BRAIN_LEARNING_SOURCES"),
DisplaySources: stringList("BRAIN_DISPLAY_SOURCES"),
ThinkingSources: stringList("BRAIN_THINKING_SOURCES"),

View File

@@ -42,9 +42,6 @@ func TestLoadRuntimeControlDefaultsAndFilters(t *testing.T) {
t.Setenv("BRAIN_DATA_DIR", t.TempDir())
t.Setenv("BRAIN_LEARNING_ENABLED", "false")
t.Setenv("BRAIN_THINKING_ENABLED", "false")
t.Setenv("BRAIN_LEARNING_CATEGORIES", "Netzwerk,GLPI KB")
t.Setenv("BRAIN_DISPLAY_CATEGORIES", "GLPI KB")
t.Setenv("BRAIN_THINKING_CATEGORIES", "Netzwerk")
t.Setenv("BRAIN_LEARNING_SOURCES", "GLPI Knowledge Base,internal-kb")
t.Setenv("BRAIN_DISPLAY_SOURCES", "GLPI Knowledge Base")
t.Setenv("BRAIN_THINKING_SOURCES", "internal-kb")
@@ -58,9 +55,6 @@ func TestLoadRuntimeControlDefaultsAndFilters(t *testing.T) {
if cfg.LearningEnabled || cfg.ThinkingEnabled || cfg.DefaultView != "constellation" || cfg.MaxDisplayNodes != 12000 || !cfg.LowPowerMode {
t.Fatalf("unexpected runtime defaults: %+v", cfg)
}
if len(cfg.LearningCategories) != 2 || len(cfg.DisplayCategories) != 1 || len(cfg.ThinkingCategories) != 1 {
t.Fatalf("unexpected category defaults: %+v", cfg)
}
if len(cfg.LearningSources) != 2 || len(cfg.DisplaySources) != 1 || len(cfg.ThinkingSources) != 1 {
t.Fatalf("unexpected source defaults: %+v", cfg)
}

View File

@@ -1198,7 +1198,7 @@ func researchResultNode(id string, result model.ResearchResult, evidencePath, co
content = result.Snippet
}
weight := .8 + result.Relevance*.35 + result.SourceQualityScore*.25
metadata := map[string]any{"query": result.Query, "language": result.Language, "round": result.Round, "fetched": result.Fetched, "content_type": result.ContentType, "relevant": result.Relevant, "relevance": result.Relevance, "source_quality": result.SourceQuality, "source_quality_score": result.SourceQualityScore, "actionable": result.Actionable, "covered_gap_ids": result.CoveredGapIDs, "assessment_reason": result.AssessmentReason}
metadata := map[string]any{"source": graph.SourceFromURL(result.URL), "query": result.Query, "language": result.Language, "round": result.Round, "fetched": result.Fetched, "content_type": result.ContentType, "relevant": result.Relevant, "relevance": result.Relevance, "source_quality": result.SourceQuality, "source_quality_score": result.SourceQualityScore, "actionable": result.Actionable, "covered_gap_ids": result.CoveredGapIDs, "assessment_reason": result.AssessmentReason}
if evidencePath != "" {
metadata["evidence_path"] = evidencePath
metadata["evidence_schema"] = researchEvidenceSchemaVersion
@@ -1213,7 +1213,7 @@ func (e *Engine) filterResearchEvidenceForThinking(results []model.ResearchResul
filter := e.effectiveThinkingFilter()
out := make([]model.ResearchResult, 0, len(results))
for _, result := range results {
node := model.Node{Kind: "external", Origin: "research", URI: result.URL, ExternalID: result.URL, Categories: categories}
node := model.Node{Kind: "external", Origin: "research", URI: result.URL, ExternalID: result.URL, Categories: categories, Metadata: map[string]any{"source": graph.SourceFromURL(result.URL)}}
if filter.Matches(node) {
out = append(out, result)
}
@@ -1444,10 +1444,6 @@ func categoryAffinity(node model.Node, seeds []model.Node) float64 {
return float64(matches)
}
func matchesCategories(node model.Node, filters []string) bool {
return (graph.NodeFilter{Categories: filters}).Matches(node)
}
func metadataString(metadata map[string]any, key string) string {
if metadata == nil {
return ""

View File

@@ -12,6 +12,7 @@ import (
"time"
"unicode"
"github.com/local/glpi-neural-brain/internal/graph"
"github.com/local/glpi-neural-brain/internal/model"
"github.com/local/glpi-neural-brain/internal/research"
)
@@ -495,7 +496,7 @@ func (e *Engine) executeArticleResearchQuery(ctx context.Context, trigger string
if question.ExpectActionable && !assessment.Actionable {
acceptedByGate = false
}
researchNode := model.Node{Kind: "external", Origin: "research", URI: item.URL, ExternalID: item.URL, Categories: researchCategories}
researchNode := model.Node{Kind: "external", Origin: "research", URI: item.URL, ExternalID: item.URL, Categories: researchCategories, Metadata: map[string]any{"source": graph.SourceFromURL(item.URL)}}
if !thinkingFilter.Matches(researchNode) {
acceptedByGate = false
if strings.TrimSpace(item.AssessmentReason) == "" {

View File

@@ -78,6 +78,9 @@ type Engine struct {
}
func New(cfg config.Config, g *graph.Store, b *activity.Broker) *Engine {
if strings.TrimSpace(cfg.GLPIKBSource) == "" {
cfg.GLPIKBSource = "GLPI Knowledge Base"
}
if !cfg.RuntimeDefaultsConfigured {
cfg.LearningEnabled = true
cfg.ThinkingEnabled = true
@@ -746,7 +749,7 @@ func (e *Engine) addResearch(a, b model.Node, results []model.ResearchResult) re
refs := researchGraphRefs{}
for _, r := range results {
id := graph.ID("external", r.URL)
n := model.Node{ID: id, Kind: "external", Label: r.Title, Summary: clamp(r.Content, 700), Status: "research", Origin: "research", ExternalID: r.URL, URI: r.URL, Categories: unique(append(append([]string{}, a.Categories...), b.Categories...)), Weight: .8, Metadata: map[string]any{"query_pair": []string{a.ID, b.ID}}, UpdatedAt: time.Now().UTC()}
n := model.Node{ID: id, Kind: "external", Label: r.Title, Summary: clamp(r.Content, 700), Status: "research", Origin: "research", ExternalID: r.URL, URI: r.URL, Categories: unique(append(append([]string{}, a.Categories...), b.Categories...)), Weight: .8, Metadata: map[string]any{"source": graph.SourceFromURL(r.URL), "query_pair": []string{a.ID, b.ID}}, UpdatedAt: time.Now().UTC()}
e.Graph.UpsertNode(n)
refs.NodeIDs = append(refs.NodeIDs, id)
for _, targetID := range []string{a.ID, b.ID} {

View File

@@ -296,7 +296,14 @@ func TestSynthesisResearchesUnclearKnowledgeThenLearnsAndLinksArticle(t *testing
t.Fatal("accepted web evidence was not learned immediately")
}
researchNode, ok := g.GetNode(researchID)
if !ok || !matchesCategories(researchNode, []string{"VPN"}) {
hasVPN := false
for _, category := range researchNode.Categories {
if strings.EqualFold(strings.TrimSpace(category), "VPN") {
hasVPN = true
break
}
}
if !ok || !hasVPN {
t.Fatalf("accepted web evidence did not inherit source categories: %+v", researchNode)
}
evidenceFiles, err := filepath.Glob(filepath.Join(data, "research-evidence", "*.json"))
@@ -387,17 +394,14 @@ func TestRuntimeSettingsDisableThinkingAndPersist(t *testing.T) {
cfg := config.Config{DataDir: data, RuntimeDefaultsConfigured: true, LearningEnabled: true, ThinkingEnabled: true, DefaultView: "neural", PersistInterval: time.Minute}
e := New(cfg, g, activity.New(20))
updated, err := e.SetRuntimeSettings(RuntimeSettings{
LearningEnabled: false,
ThinkingEnabled: false,
LearningCategories: []string{"Netzwerk"},
DisplayCategories: []string{"GLPI KB"},
ThinkingCategories: []string{"Netzwerk"},
LearningSources: []string{"internal-kb"},
DisplaySources: []string{"GLPI Knowledge Base"},
ThinkingSources: []string{"internal-kb"},
ViewMode: "constellation",
MaxDisplayNodes: 4321,
LowPowerMode: true,
LearningEnabled: false,
ThinkingEnabled: false,
LearningSources: []string{"internal-kb"},
DisplaySources: []string{"GLPI Knowledge Base"},
ThinkingSources: []string{"internal-kb"},
ViewMode: "constellation",
MaxDisplayNodes: 4321,
LowPowerMode: true,
})
if err != nil {
t.Fatal(err)
@@ -421,7 +425,7 @@ func TestRuntimeSettingsDisableThinkingAndPersist(t *testing.T) {
}
e2 := New(cfg, g2, activity.New(20))
loaded := e2.RuntimeSettings()
if loaded.LearningEnabled || loaded.ThinkingEnabled || loaded.ViewMode != "constellation" || loaded.MaxDisplayNodes != 4321 || !loaded.LowPowerMode || len(loaded.DisplayCategories) != 1 || len(loaded.DisplaySources) != 1 {
if loaded.LearningEnabled || loaded.ThinkingEnabled || loaded.ViewMode != "constellation" || loaded.MaxDisplayNodes != 4321 || !loaded.LowPowerMode || len(loaded.DisplaySources) != 1 {
t.Fatalf("runtime settings were not restored: %+v", loaded)
}
}

View File

@@ -11,68 +11,52 @@ import (
"github.com/local/glpi-neural-brain/internal/model"
)
const uncategorizedFilter = graph.UncategorizedFilter
const unsourcedFilter = graph.UnsourcedFilter
type RuntimeSettings struct {
LearningEnabled bool `json:"learning_enabled"`
ThinkingEnabled bool `json:"thinking_enabled"`
LearningCategories []string `json:"learning_categories"`
DisplayCategories []string `json:"display_categories"`
ThinkingCategories []string `json:"thinking_categories"`
LearningSources []string `json:"learning_sources"`
DisplaySources []string `json:"display_sources"`
ThinkingSources []string `json:"thinking_sources"`
ViewMode string `json:"view_mode"`
MaxDisplayNodes int `json:"max_display_nodes"`
LowPowerMode bool `json:"low_power_mode"`
}
type RuntimeFilterInfo struct {
Categories []string `json:"categories"`
Sources []string `json:"sources"`
CategoriesRestricted bool `json:"categories_restricted"`
SourcesRestricted bool `json:"sources_restricted"`
MatchesNone bool `json:"matches_none"`
SourceFilterVersion int `json:"source_filter_version"`
LearningEnabled bool `json:"learning_enabled"`
ThinkingEnabled bool `json:"thinking_enabled"`
LearningSources []string `json:"learning_sources"`
DisplaySources []string `json:"display_sources"`
ThinkingSources []string `json:"thinking_sources"`
ViewMode string `json:"view_mode"`
MaxDisplayNodes int `json:"max_display_nodes"`
LowPowerMode bool `json:"low_power_mode"`
}
type RuntimeSettingsView struct {
RuntimeSettings
EffectiveLearning RuntimeFilterInfo `json:"effective_learning"`
EffectiveDisplay RuntimeFilterInfo `json:"effective_display"`
EffectiveThinking RuntimeFilterInfo `json:"effective_thinking"`
AdminLearning RuntimeFilterInfo `json:"admin_learning"`
AdminDisplay RuntimeFilterInfo `json:"admin_display"`
AdminThinking RuntimeFilterInfo `json:"admin_thinking"`
GLPIKBSource string `json:"glpi_kb_source"`
}
type CategoryInfo struct {
type SourceInfo struct {
Name string `json:"name"`
Count int `json:"count"`
}
type SourceInfo = CategoryInfo
func (e *Engine) defaultRuntimeSettings() RuntimeSettings {
// Category/source values in Config are administrative ceilings. The WebUI
// starts at "all allowed" (empty selection), not by copying the ceiling into
// mutable runtime state.
// Environment source lists are startup defaults only. Once a corresponding
// field exists in runtime-settings.json, that persisted WebUI value is the
// single authoritative selection. There is no ENV/WebUI intersection.
return normalizeRuntimeSettings(RuntimeSettings{
LearningEnabled: e.Cfg.LearningEnabled,
ThinkingEnabled: e.Cfg.ThinkingEnabled,
ViewMode: e.Cfg.DefaultView,
MaxDisplayNodes: e.Cfg.MaxDisplayNodes,
LowPowerMode: e.Cfg.LowPowerMode,
SourceFilterVersion: 1,
LearningEnabled: e.Cfg.LearningEnabled,
ThinkingEnabled: e.Cfg.ThinkingEnabled,
LearningSources: append([]string(nil), e.Cfg.LearningSources...),
DisplaySources: append([]string(nil), e.Cfg.DisplaySources...),
ThinkingSources: append([]string(nil), e.Cfg.ThinkingSources...),
ViewMode: e.Cfg.DefaultView,
MaxDisplayNodes: e.Cfg.MaxDisplayNodes,
LowPowerMode: e.Cfg.LowPowerMode,
})
}
func normalizeRuntimeSettings(in RuntimeSettings) RuntimeSettings {
in.LearningCategories = normalizeValues(in.LearningCategories)
in.DisplayCategories = normalizeValues(in.DisplayCategories)
in.ThinkingCategories = normalizeValues(in.ThinkingCategories)
in.LearningSources = normalizeValues(in.LearningSources)
in.DisplaySources = normalizeValues(in.DisplaySources)
in.ThinkingSources = normalizeValues(in.ThinkingSources)
if in.SourceFilterVersion != 1 {
in.SourceFilterVersion = 1
}
in.LearningSources = normalizeSources(in.LearningSources)
in.DisplaySources = normalizeSources(in.DisplaySources)
in.ThinkingSources = normalizeSources(in.ThinkingSources)
in.ViewMode = strings.ToLower(strings.TrimSpace(in.ViewMode))
if in.ViewMode == "" {
in.ViewMode = "neural"
@@ -89,31 +73,35 @@ func normalizeRuntimeSettings(in RuntimeSettings) RuntimeSettings {
return in
}
func normalizeValues(values []string) []string {
seen := map[string]string{}
// normalizeSources preserves the exact source spelling used by KB files. Only
// surrounding whitespace and byte-identical duplicates are removed.
func normalizeSources(values []string) []string {
seen := map[string]struct{}{}
out := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
key := strings.ToLower(value)
if _, exists := seen[key]; !exists {
seen[key] = value
if _, exists := seen[value]; exists {
continue
}
}
out := make([]string, 0, len(seen))
for _, value := range seen {
seen[value] = struct{}{}
out = append(out, value)
}
sort.Slice(out, func(i, j int) bool { return strings.ToLower(out[i]) < strings.ToLower(out[j]) })
sort.Slice(out, func(i, j int) bool {
li, lj := strings.ToLower(out[i]), strings.ToLower(out[j])
if li == lj {
return out[i] < out[j]
}
return li < lj
})
return out
}
func normalizeCategories(values []string) []string { return normalizeValues(values) }
// loadRuntimeSettings merges individual persisted fields. Older files that do
// not contain newly introduced fields inherit defaults instead of silently
// zeroing unrelated settings.
// loadRuntimeSettings merges individual persisted fields. Legacy category fields
// and unversioned source selections are intentionally ignored so the previous
// ambiguous filter system cannot silently survive this source-only migration.
func (e *Engine) loadRuntimeSettings() {
settings := e.defaultRuntimeSettings()
if strings.TrimSpace(e.runtimePath) != "" {
@@ -139,12 +127,14 @@ func mergeRuntimeSettingsJSON(settings *RuntimeSettings, data []byte) {
}
decode("learning_enabled", &settings.LearningEnabled)
decode("thinking_enabled", &settings.ThinkingEnabled)
decode("learning_categories", &settings.LearningCategories)
decode("display_categories", &settings.DisplayCategories)
decode("thinking_categories", &settings.ThinkingCategories)
decode("learning_sources", &settings.LearningSources)
decode("display_sources", &settings.DisplaySources)
decode("thinking_sources", &settings.ThinkingSources)
var version int
decode("source_filter_version", &version)
if version == 1 {
settings.SourceFilterVersion = 1
decode("learning_sources", &settings.LearningSources)
decode("display_sources", &settings.DisplaySources)
decode("thinking_sources", &settings.ThinkingSources)
}
decode("view_mode", &settings.ViewMode)
decode("max_display_nodes", &settings.MaxDisplayNodes)
decode("low_power_mode", &settings.LowPowerMode)
@@ -154,9 +144,6 @@ func (e *Engine) RuntimeSettings() RuntimeSettings {
e.runtimeMu.RLock()
settings := e.runtime
e.runtimeMu.RUnlock()
settings.LearningCategories = append([]string{}, settings.LearningCategories...)
settings.DisplayCategories = append([]string{}, settings.DisplayCategories...)
settings.ThinkingCategories = append([]string{}, settings.ThinkingCategories...)
settings.LearningSources = append([]string{}, settings.LearningSources...)
settings.DisplaySources = append([]string{}, settings.DisplaySources...)
settings.ThinkingSources = append([]string{}, settings.ThinkingSources...)
@@ -164,49 +151,7 @@ func (e *Engine) RuntimeSettings() RuntimeSettings {
}
func (e *Engine) RuntimeSettingsView() RuntimeSettingsView {
settings := e.RuntimeSettings()
return RuntimeSettingsView{
RuntimeSettings: settings,
EffectiveLearning: filterInfo(e.effectiveLearningFilter()),
EffectiveDisplay: filterInfo(e.effectiveDisplayFilter()),
EffectiveThinking: filterInfo(e.effectiveThinkingFilter()),
AdminLearning: adminFilterInfo(e.Cfg.LearningCategories, e.Cfg.LearningSources),
AdminDisplay: adminFilterInfo(e.Cfg.DisplayCategories, e.Cfg.DisplaySources),
AdminThinking: adminFilterInfo(e.Cfg.ThinkingCategories, e.Cfg.ThinkingSources),
}
}
func filterInfo(filter graph.NodeFilter) RuntimeFilterInfo {
return RuntimeFilterInfo{
Categories: append([]string{}, filter.Categories...),
Sources: append([]string{}, filter.Sources...),
CategoriesRestricted: dimensionRestricted(filter.Categories) || filter.MatchNone,
SourcesRestricted: dimensionRestricted(filter.Sources) || filter.MatchNone,
MatchesNone: filter.MatchNone,
}
}
func adminFilterInfo(categories, sources []string) RuntimeFilterInfo {
categories = normalizeValues(categories)
sources = normalizeValues(sources)
return RuntimeFilterInfo{
Categories: categories,
Sources: sources,
CategoriesRestricted: dimensionRestricted(categories),
SourcesRestricted: dimensionRestricted(sources),
}
}
func dimensionRestricted(values []string) bool {
if len(values) == 0 {
return false
}
for _, value := range values {
if strings.TrimSpace(value) == "*" {
return false
}
}
return true
return RuntimeSettingsView{RuntimeSettings: e.RuntimeSettings(), GLPIKBSource: strings.TrimSpace(e.Cfg.GLPIKBSource)}
}
func (e *Engine) SetRuntimeSettings(settings RuntimeSettings) (RuntimeSettings, error) {
@@ -240,28 +185,22 @@ func (e *Engine) SetRuntimeSettings(settings RuntimeSettings) (RuntimeSettings,
}
}
view := e.RuntimeSettingsView()
e.Broker.Publish(model.Activity{
Type: "runtime.settings.updated",
Source: "ui",
Phase: "control",
Message: "Lern-, Anzeige-, Quellen- und Thinking-Einstellungen wurden aktualisiert",
Message: "Laufzeitmodi und exakte KB-Quellenfilter wurden aktualisiert",
Strength: .32,
Metadata: map[string]any{
"learning_enabled": settings.LearningEnabled,
"thinking_enabled": settings.ThinkingEnabled,
"learning_categories": len(settings.LearningCategories),
"display_categories": len(settings.DisplayCategories),
"thinking_categories": len(settings.ThinkingCategories),
"learning_sources": len(settings.LearningSources),
"display_sources": len(settings.DisplaySources),
"thinking_sources": len(settings.ThinkingSources),
"learning_filter_matches_none": view.EffectiveLearning.MatchesNone,
"display_filter_matches_none": view.EffectiveDisplay.MatchesNone,
"thinking_filter_matches_none": view.EffectiveThinking.MatchesNone,
"view_mode": settings.ViewMode,
"max_display_nodes": settings.MaxDisplayNodes,
"low_power_mode": settings.LowPowerMode,
"source_filter_version": settings.SourceFilterVersion,
"learning_enabled": settings.LearningEnabled,
"thinking_enabled": settings.ThinkingEnabled,
"learning_sources": len(settings.LearningSources),
"display_sources": len(settings.DisplaySources),
"thinking_sources": len(settings.ThinkingSources),
"view_mode": settings.ViewMode,
"max_display_nodes": settings.MaxDisplayNodes,
"low_power_mode": settings.LowPowerMode,
},
})
return settings, nil
@@ -282,136 +221,53 @@ func (e *Engine) ThinkingEnabled() bool {
}
func (e *Engine) effectiveLearningFilter() graph.NodeFilter {
settings := e.RuntimeSettings()
return effectiveNodeFilter(e.Cfg.LearningCategories, settings.LearningCategories, e.Cfg.LearningSources, settings.LearningSources)
return graph.NodeFilter{Sources: e.RuntimeSettings().LearningSources}
}
func (e *Engine) effectiveDisplayFilter() graph.NodeFilter {
settings := e.RuntimeSettings()
return effectiveNodeFilter(e.Cfg.DisplayCategories, settings.DisplayCategories, e.Cfg.DisplaySources, settings.DisplaySources)
return graph.NodeFilter{Sources: e.RuntimeSettings().DisplaySources}
}
func (e *Engine) effectiveThinkingFilter() graph.NodeFilter {
settings := e.RuntimeSettings()
return effectiveNodeFilter(e.Cfg.ThinkingCategories, settings.ThinkingCategories, e.Cfg.ThinkingSources, settings.ThinkingSources)
}
func effectiveNodeFilter(adminCategories, runtimeCategories, adminSources, runtimeSources []string) graph.NodeFilter {
categories, categoryNone := intersectFilterValues(adminCategories, runtimeCategories)
sources, sourceNone := intersectFilterValues(adminSources, runtimeSources)
return graph.NodeFilter{Categories: categories, Sources: sources, MatchNone: categoryNone || sourceNone}
}
func intersectFilterValues(admin, runtime []string) ([]string, bool) {
admin = normalizeValues(admin)
runtime = normalizeValues(runtime)
adminRestricted := dimensionRestricted(admin)
runtimeRestricted := dimensionRestricted(runtime)
if !adminRestricted && !runtimeRestricted {
return nil, false
}
if adminRestricted && !runtimeRestricted {
return admin, false
}
if !adminRestricted && runtimeRestricted {
return runtime, false
}
allowed := make(map[string]string, len(admin))
for _, value := range admin {
allowed[strings.ToLower(strings.TrimSpace(value))] = value
}
intersection := make([]string, 0)
for _, value := range runtime {
if canonical, ok := allowed[strings.ToLower(strings.TrimSpace(value))]; ok {
intersection = append(intersection, canonical)
}
}
intersection = normalizeValues(intersection)
return intersection, len(intersection) == 0
}
// Compatibility helpers retained for tests and internal callers that only need
// the category projection. New code should use the scoped filters above.
func (e *Engine) learningCategories() []string { return e.effectiveLearningFilter().Categories }
func (e *Engine) thinkingCategories() []string { return e.effectiveThinkingFilter().Categories }
func (e *Engine) Categories() []CategoryInfo {
snapshot := e.Graph.Snapshot()
counts := map[string]int{}
names := map[string]string{}
uncategorized := 0
for _, node := range snapshot.Nodes {
if node.Kind != "knowledge" && node.Kind != "ai-think" && node.Kind != "external" {
continue
}
if len(node.Categories) == 0 {
uncategorized++
continue
}
seen := map[string]bool{}
for _, category := range node.Categories {
category = strings.TrimSpace(category)
if category == "" {
continue
}
key := strings.ToLower(category)
if seen[key] {
continue
}
seen[key] = true
if _, ok := names[key]; !ok {
names[key] = category
}
counts[key]++
}
}
out := make([]CategoryInfo, 0, len(counts)+1)
for key, count := range counts {
out = append(out, CategoryInfo{Name: names[key], Count: count})
}
if uncategorized > 0 {
out = append(out, CategoryInfo{Name: uncategorizedFilter, Count: uncategorized})
}
sortFilterInfo(out)
return out
return graph.NodeFilter{Sources: e.RuntimeSettings().ThinkingSources}
}
// Sources lists exact source values seen on knowledge-bearing nodes. The GLPI
// source configured through GLPI_KB_SOURCE is always present, even before the
// first GLPI sync or while GLPI ingestion is disabled.
func (e *Engine) Sources() []SourceInfo {
snapshot := e.Graph.Snapshot()
return sourceInfos(e.Graph.Snapshot(), e.Cfg.GLPIKBSource)
}
func sourceInfos(snapshot model.Snapshot, configuredGLPI string) []SourceInfo {
counts := map[string]int{}
names := map[string]string{}
unsourced := 0
glpiSource := strings.TrimSpace(configuredGLPI)
if glpiSource != "" {
counts[glpiSource] = 0
}
for _, node := range snapshot.Nodes {
if node.Kind != "knowledge" && node.Kind != "ai-think" && node.Kind != "external" {
continue
}
source := strings.TrimSpace(graph.NodeSource(node))
if source == "" {
unsourced++
continue
}
key := strings.ToLower(source)
if _, ok := names[key]; !ok {
names[key] = source
counts[source]++
}
result := make([]SourceInfo, 0, len(counts))
for name, count := range counts {
result = append(result, SourceInfo{Name: name, Count: count})
}
sort.Slice(result, func(i, j int) bool {
if result[i].Count == result[j].Count {
li, lj := strings.ToLower(result[i].Name), strings.ToLower(result[j].Name)
if li == lj {
return result[i].Name < result[j].Name
}
return li < lj
}
counts[key]++
}
result := make([]SourceInfo, 0, len(counts)+1)
for key, count := range counts {
result = append(result, SourceInfo{Name: names[key], Count: count})
}
if unsourced > 0 {
result = append(result, SourceInfo{Name: unsourcedFilter, Count: unsourced})
}
sortFilterInfo(result)
return result[i].Count > result[j].Count
})
return result
}
func sortFilterInfo(out []CategoryInfo) {
sort.Slice(out, func(i, j int) bool {
if out[i].Count == out[j].Count {
return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name)
}
return out[i].Count > out[j].Count
})
}

View File

@@ -3,43 +3,68 @@ package engine
import (
"testing"
"github.com/local/glpi-neural-brain/internal/config"
"github.com/local/glpi-neural-brain/internal/model"
)
func TestEffectiveFilterIsEnvironmentIntersectionRuntime(t *testing.T) {
filter := effectiveNodeFilter(
[]string{"IT-Security", "Netzwerk"}, []string{"Netzwerk", "Backup"},
[]string{"GLPI Knowledge Base", "internal-kb"}, []string{"internal-kb"},
)
if filter.MatchNone {
t.Fatal("intersection should not be empty")
func TestRuntimeUsesSingleExactSourceSelection(t *testing.T) {
e := &Engine{Cfg: config.Config{LearningSources: []string{"ENV-only"}}}
e.runtime = RuntimeSettings{SourceFilterVersion: 1, LearningSources: []string{"internal-category"}}
filter := e.effectiveLearningFilter()
if len(filter.Sources) != 1 || filter.Sources[0] != "internal-category" {
t.Fatalf("runtime source should be authoritative without ENV intersection: %+v", filter)
}
if len(filter.Categories) != 1 || filter.Categories[0] != "Netzwerk" {
t.Fatalf("unexpected category intersection: %#v", filter.Categories)
}
if len(filter.Sources) != 1 || filter.Sources[0] != "internal-kb" {
t.Fatalf("unexpected source intersection: %#v", filter.Sources)
matching := model.Node{Metadata: map[string]any{"source": "internal-category"}}
wrongCase := model.Node{Metadata: map[string]any{"source": "Internal-Category"}}
if !filter.Matches(matching) || filter.Matches(wrongCase) {
t.Fatal("source matching is not exact")
}
}
func TestEffectiveFilterDisjointSelectionMatchesNothing(t *testing.T) {
filter := effectiveNodeFilter([]string{"IT-Security"}, []string{"Backup"}, nil, nil)
if !filter.MatchNone {
t.Fatalf("disjoint administrative/runtime filters must match none: %+v", filter)
}
node := model.Node{Kind: "knowledge", Categories: []string{"IT-Security"}, Metadata: map[string]any{"source": "internal-kb"}}
if filter.Matches(node) {
t.Fatal("empty intersection must not fall back to all")
}
}
func TestRuntimeJSONFieldMergePreservesDefaults(t *testing.T) {
settings := RuntimeSettings{LearningEnabled: true, ThinkingEnabled: true, ViewMode: "constellation", MaxDisplayNodes: 5000, LowPowerMode: true}
mergeRuntimeSettingsJSON(&settings, []byte(`{"display_categories":["Cloud"],"display_sources":["internal-kb"]}`))
func TestLegacyRuntimeFilterFieldsAreReset(t *testing.T) {
settings := RuntimeSettings{SourceFilterVersion: 1, LearningEnabled: true, ThinkingEnabled: true, LearningSources: []string{"ENV-default"}, DisplaySources: []string{"ENV-display"}, ViewMode: "constellation", MaxDisplayNodes: 5000, LowPowerMode: true}
mergeRuntimeSettingsJSON(&settings, []byte(`{"display_categories":["Cloud"],"display_sources":["glpi-kb"]}`))
if !settings.LearningEnabled || !settings.ThinkingEnabled || settings.ViewMode != "constellation" || settings.MaxDisplayNodes != 5000 || !settings.LowPowerMode {
t.Fatalf("missing JSON fields overwrote defaults: %+v", settings)
t.Fatalf("legacy JSON overwrote unrelated defaults: %+v", settings)
}
if len(settings.DisplayCategories) != 1 || len(settings.DisplaySources) != 1 {
t.Fatalf("present JSON fields were not merged: %+v", settings)
if len(settings.LearningSources) != 1 || settings.LearningSources[0] != "ENV-default" || len(settings.DisplaySources) != 1 || settings.DisplaySources[0] != "ENV-display" {
t.Fatalf("legacy source filters should be ignored during migration: %+v", settings)
}
}
func TestVersionedRuntimeSourceFieldsAreLoaded(t *testing.T) {
settings := RuntimeSettings{SourceFilterVersion: 1, LearningSources: []string{"ENV-default"}}
mergeRuntimeSettingsJSON(&settings, []byte(`{"source_filter_version":1,"learning_sources":["internal-category"],"display_sources":["GLPI Knowledge Base"],"thinking_sources":["docs.example.org"]}`))
if len(settings.LearningSources) != 1 || settings.LearningSources[0] != "internal-category" || len(settings.DisplaySources) != 1 || settings.DisplaySources[0] != "GLPI Knowledge Base" || len(settings.ThinkingSources) != 1 || settings.ThinkingSources[0] != "docs.example.org" {
t.Fatalf("versioned source fields were not loaded: %+v", settings)
}
}
func TestSourceInfosUseExactMetadataAndAlwaysIncludeConfiguredGLPI(t *testing.T) {
snapshot := model.Snapshot{Nodes: []model.Node{
{ID: "a", Kind: "knowledge", Origin: "glpi-kb", Metadata: map[string]any{"source": "GLPI Custom"}},
{ID: "b", Kind: "knowledge", Origin: "internal-category", Metadata: map[string]any{"source": "internal-category"}},
{ID: "c", Kind: "knowledge", Origin: "internal-category"},
{ID: "taxonomy", Kind: "source", Label: "must-not-count"},
}}
infos := sourceInfos(snapshot, "Configured GLPI")
counts := map[string]int{}
for _, info := range infos {
counts[info.Name] = info.Count
}
if counts["Configured GLPI"] != 0 {
t.Fatalf("configured GLPI source should be present with zero count: %+v", infos)
}
if counts["GLPI Custom"] != 1 || counts["internal-category"] != 1 {
t.Fatalf("source values were not counted exactly: %+v", infos)
}
if _, exists := counts["glpi-kb"]; exists {
t.Fatalf("technical origin leaked into source options: %+v", infos)
}
if _, exists := counts["must-not-count"]; exists {
t.Fatalf("taxonomy node leaked into source options: %+v", infos)
}
if _, exists := counts[""]; exists {
t.Fatalf("nodes without source must not become a synthetic filter option: %+v", infos)
}
}

View File

@@ -8,96 +8,47 @@ import (
"github.com/local/glpi-neural-brain/internal/model"
)
const (
UncategorizedFilter = "__uncategorized__"
UnsourcedFilter = "__unsourced__"
)
// NodeFilter limits knowledge-bearing nodes by category and logical source.
// Categories and Sources are AND-combined; values inside each dimension use
// OR semantics. Empty dimensions and "*" mean unrestricted. MatchNone is used
// for an empty administrative/runtime intersection and deliberately matches no
// node.
// NodeFilter limits knowledge-bearing nodes by the exact value stored in the
// KB document's source field. Values are compared case-sensitively after
// trimming surrounding whitespace. An empty Sources list means unrestricted.
// No category, origin, URI or taxonomy fallback participates in matching.
type NodeFilter struct {
Categories []string
Sources []string
MatchNone bool
Sources []string
}
func (f NodeFilter) Matches(node model.Node) bool {
if f.MatchNone {
return false
}
return matchesDimension(node.Categories, f.Categories, UncategorizedFilter) &&
matchesDimension(nodeSources(node), f.Sources, UnsourcedFilter)
}
func matchesDimension(values, filters []string, emptyToken string) bool {
if len(filters) == 0 {
if len(f.Sources) == 0 {
return true
}
wanted := make(map[string]struct{}, len(filters))
for _, filter := range filters {
filter = strings.ToLower(strings.TrimSpace(filter))
if filter != "" {
wanted[filter] = struct{}{}
source := strings.TrimSpace(NodeSource(node))
for _, wanted := range f.Sources {
wanted = strings.TrimSpace(wanted)
if wanted == "" {
continue
}
}
if len(wanted) == 0 {
return true
}
if _, ok := wanted["*"]; ok {
return true
}
clean := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value != "" {
clean = append(clean, value)
}
}
if len(clean) == 0 {
_, ok := wanted[emptyToken]
return ok
}
for _, value := range clean {
if _, ok := wanted[strings.ToLower(value)]; ok {
if source == wanted {
return true
}
}
return false
}
// NodeSource returns the logical source shown to users. Explicit source
// metadata wins. Research nodes fall back to their host name; all other nodes
// fall back to their technical origin.
// NodeSource returns only the explicit source metadata copied from the KB
// file's top-level source field (or assigned explicitly to generated research
// evidence). Technical origins such as glpi-kb are deliberately ignored.
func NodeSource(node model.Node) string {
values := nodeSources(node)
if len(values) == 0 {
return ""
}
return values[0]
return metadataText(node.Metadata, "source")
}
func nodeSources(node model.Node) []string {
if source := metadataText(node.Metadata, "source"); source != "" {
return []string{source}
// SourceFromURL derives the explicit source value used for learned web
// evidence. It is kept separate from NodeSource so ordinary nodes never fall
// back to their URI implicitly.
func SourceFromURL(raw string) string {
parsed, err := url.Parse(strings.TrimSpace(raw))
if err != nil {
return ""
}
if node.Kind == "source" && strings.TrimSpace(node.Label) != "" {
return []string{strings.TrimSpace(node.Label)}
}
if node.Kind == "external" {
for _, raw := range []string{node.URI, node.ExternalID} {
parsed, err := url.Parse(strings.TrimSpace(raw))
if err == nil && strings.TrimSpace(parsed.Hostname()) != "" {
return []string{strings.ToLower(strings.TrimSpace(parsed.Hostname()))}
}
}
}
if strings.TrimSpace(node.Origin) != "" {
return []string{strings.TrimSpace(node.Origin)}
}
return nil
return strings.ToLower(strings.TrimSpace(parsed.Hostname()))
}
func metadataText(metadata map[string]any, key string) string {

View File

@@ -6,38 +6,41 @@ import (
"github.com/local/glpi-neural-brain/internal/model"
)
func TestNodeFilterCombinesCategoryAndSource(t *testing.T) {
func TestNodeFilterMatchesExactSourceOnly(t *testing.T) {
node := model.Node{
Kind: "knowledge",
Categories: []string{"IT-Security", "Cloud"},
Origin: "knowledge-production",
Origin: "glpi-kb",
Metadata: map[string]any{"source": "GLPI Knowledge Base"},
}
if !(NodeFilter{Categories: []string{"Cloud"}, Sources: []string{"glpi knowledge base"}}).Matches(node) {
t.Fatal("matching category and source should be accepted case-insensitively")
if !(NodeFilter{Sources: []string{"GLPI Knowledge Base"}}).Matches(node) {
t.Fatal("exact source should match")
}
if (NodeFilter{Categories: []string{"Cloud"}, Sources: []string{"internal-kb"}}).Matches(node) {
t.Fatal("source mismatch must reject even when category matches")
if (NodeFilter{Sources: []string{"glpi knowledge base"}}).Matches(node) {
t.Fatal("source matching must be case-sensitive and exact")
}
if (NodeFilter{Categories: []string{"Backup"}, Sources: []string{"GLPI Knowledge Base"}}).Matches(node) {
t.Fatal("category mismatch must reject even when source matches")
if (NodeFilter{Sources: []string{"glpi-kb"}}).Matches(node) {
t.Fatal("technical origin must not be treated as source")
}
if (NodeFilter{MatchNone: true}).Matches(node) {
t.Fatal("MatchNone must reject every node")
if !(NodeFilter{}).Matches(node) {
t.Fatal("empty source list must be unrestricted")
}
}
func TestNodeFilterVirtualEmptyValuesAndResearchHost(t *testing.T) {
unsourced := model.Node{Kind: "knowledge"}
if !(NodeFilter{Categories: []string{UncategorizedFilter}, Sources: []string{UnsourcedFilter}}).Matches(unsourced) {
t.Fatal("virtual empty category/source filters should match")
func TestNodeFilterUnsourcedAndNoFallback(t *testing.T) {
unsourced := model.Node{Kind: "knowledge", Origin: "internal-category", URI: "https://example.test/a"}
if NodeSource(unsourced) != "" {
t.Fatalf("origin or URI leaked into source: %q", NodeSource(unsourced))
}
research := model.Node{Kind: "external", Origin: "research", URI: "https://docs.example.org/guide", Categories: []string{"Cloud"}}
if (NodeFilter{Sources: []string{"internal-category"}}).Matches(unsourced) {
t.Fatal("node without source must not match a concrete source")
}
research := model.Node{Kind: "external", Origin: "research", URI: "https://docs.example.org/guide", Metadata: map[string]any{"source": SourceFromURL("https://docs.example.org/guide")}}
if got := NodeSource(research); got != "docs.example.org" {
t.Fatalf("unexpected research source %q", got)
t.Fatalf("unexpected explicit research source %q", got)
}
if !(NodeFilter{Sources: []string{"DOCS.EXAMPLE.ORG"}}).Matches(research) {
t.Fatal("research host should be source-filterable")
if !(NodeFilter{Sources: []string{"docs.example.org"}}).Matches(research) {
t.Fatal("explicit research source should be filterable")
}
}
@@ -45,7 +48,7 @@ func TestScopedEmbeddingRetrievalAndThinking(t *testing.T) {
s := &Store{
nodes: map[string]model.Node{
"a": {ID: "a", Kind: "knowledge", Label: "A", Categories: []string{"Cloud"}, Metadata: map[string]any{"source": "internal-kb"}},
"b": {ID: "b", Kind: "knowledge", Label: "B", Categories: []string{"Cloud"}, Metadata: map[string]any{"source": "internal-kb"}},
"b": {ID: "b", Kind: "knowledge", Label: "B", Categories: []string{"Backup"}, Metadata: map[string]any{"source": "internal-kb"}},
"c": {ID: "c", Kind: "knowledge", Label: "C", Categories: []string{"Cloud"}, Metadata: map[string]any{"source": "GLPI Knowledge Base"}},
},
edges: map[string]model.Edge{},
@@ -57,14 +60,14 @@ func TestScopedEmbeddingRetrievalAndThinking(t *testing.T) {
deletedEdges: map[string]uint64{},
deletedVectors: map[string]uint64{},
}
filter := NodeFilter{Categories: []string{"Cloud"}, Sources: []string{"internal-kb"}}
filter := NodeFilter{Sources: []string{"internal-kb"}}
hits := s.SimilarFiltered([]float64{1, 0}, 10, filter)
if len(hits) != 2 {
t.Fatalf("retrieval escaped source filter: %+v", hits)
t.Fatalf("retrieval escaped exact source filter: %+v", hits)
}
a, b, _, ok, _ := s.NextPairScopedDepth(.5, 8, filter, 0)
if !ok || NodeSource(a) != "internal-kb" || NodeSource(b) != "internal-kb" {
t.Fatalf("thinking escaped scoped filter: ok=%v a=%+v b=%+v", ok, a, b)
t.Fatalf("thinking escaped source filter: ok=%v a=%+v b=%+v", ok, a, b)
}
if pending := s.NodesForEmbeddingScoped(NodeFilter{Sources: []string{"GLPI Knowledge Base"}}); len(pending) != 0 {
t.Fatalf("nodes with existing vectors should not be pending: %+v", pending)

View File

@@ -235,8 +235,8 @@ func (s *Store) NodesForEmbedding() []model.Node {
return s.NodesForEmbeddingScoped(NodeFilter{})
}
func (s *Store) NodesForEmbeddingFiltered(categories []string) []model.Node {
return s.NodesForEmbeddingScoped(NodeFilter{Categories: categories})
func (s *Store) NodesForEmbeddingFiltered(sources []string) []model.Node {
return s.NodesForEmbeddingScoped(NodeFilter{Sources: sources})
}
func (s *Store) NodesForEmbeddingScoped(filter NodeFilter) []model.Node {
@@ -547,12 +547,12 @@ func (s *Store) NextPair(min float64, anchorLimit int) (model.Node, model.Node,
return s.NextPairFiltered(min, anchorLimit, nil)
}
func (s *Store) NextPairFiltered(min float64, anchorLimit int, categories []string) (model.Node, model.Node, float64, bool, int) {
return s.NextPairFilteredDepth(min, anchorLimit, categories, 0)
func (s *Store) NextPairFiltered(min float64, anchorLimit int, sources []string) (model.Node, model.Node, float64, bool, int) {
return s.NextPairFilteredDepth(min, anchorLimit, sources, 0)
}
func (s *Store) NextPairFilteredDepth(min float64, anchorLimit int, categories []string, maxAIDepth int) (model.Node, model.Node, float64, bool, int) {
return s.NextPairScopedDepth(min, anchorLimit, NodeFilter{Categories: categories}, maxAIDepth)
func (s *Store) NextPairFilteredDepth(min float64, anchorLimit int, sources []string, maxAIDepth int) (model.Node, model.Node, float64, bool, int) {
return s.NextPairScopedDepth(min, anchorLimit, NodeFilter{Sources: sources}, maxAIDepth)
}
func (s *Store) NextPairScopedDepth(min float64, anchorLimit int, filter NodeFilter, maxAIDepth int) (model.Node, model.Node, float64, bool, int) {

View File

@@ -73,34 +73,30 @@ func TestNextPairUsesBoundedRotatingAnchors(t *testing.T) {
}
}
func TestCategoryFiltersLimitEmbeddingAndThinkingCandidates(t *testing.T) {
func TestSourceFiltersLimitEmbeddingAndThinkingCandidates(t *testing.T) {
s, err := Open(t.TempDir())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = s.Close() })
nodes := []model.Node{
{ID: "net-a", Kind: "knowledge", Label: "Net A", Categories: []string{"Netzwerk"}, Origin: "test"},
{ID: "net-b", Kind: "knowledge", Label: "Net B", Categories: []string{"Netzwerk"}, Origin: "test"},
{ID: "app-a", Kind: "knowledge", Label: "App A", Categories: []string{"Applikation"}, Origin: "test"},
{ID: "internal-a", Kind: "knowledge", Label: "Internal A", Categories: []string{"Netzwerk"}, Origin: "test", Metadata: map[string]any{"source": "internal-category"}},
{ID: "internal-b", Kind: "knowledge", Label: "Internal B", Categories: []string{"Applikation"}, Origin: "test", Metadata: map[string]any{"source": "internal-category"}},
{ID: "glpi-a", Kind: "knowledge", Label: "GLPI A", Categories: []string{"Netzwerk"}, Origin: "glpi-kb", Metadata: map[string]any{"source": "GLPI Knowledge Base"}},
{ID: "none", Kind: "knowledge", Label: "Ohne", Origin: "test"},
}
for _, node := range nodes {
s.UpsertNode(node)
}
pending := s.NodesForEmbeddingFiltered([]string{"Netzwerk"})
pending := s.NodesForEmbeddingFiltered([]string{"internal-category"})
if len(pending) != 2 {
t.Fatalf("expected two network embeddings, got %d", len(pending))
}
uncategorized := s.NodesForEmbeddingFiltered([]string{"__uncategorized__"})
if len(uncategorized) != 1 || uncategorized[0].ID != "none" {
t.Fatalf("unexpected uncategorized nodes: %#v", uncategorized)
t.Fatalf("expected two internal source embeddings, got %d", len(pending))
}
for _, node := range nodes {
s.SetVector(node.ID, []float64{1, .01})
}
a, b, _, ok, _ := s.NextPairFiltered(.5, 8, []string{"Netzwerk"})
if !ok || a.Categories[0] != "Netzwerk" || b.Categories[0] != "Netzwerk" {
t.Fatalf("thinking filter returned wrong pair: ok=%v a=%+v b=%+v", ok, a, b)
a, b, _, ok, _ := s.NextPairFiltered(.5, 8, []string{"internal-category"})
if !ok || NodeSource(a) != "internal-category" || NodeSource(b) != "internal-category" {
t.Fatalf("thinking source filter returned wrong pair: ok=%v a=%+v b=%+v", ok, a, b)
}
}

View File

@@ -36,7 +36,6 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("GET /api/analysis", s.handleAnalysis)
mux.HandleFunc("GET /api/runtime-settings", s.handleGetRuntimeSettings)
mux.HandleFunc("PUT /api/runtime-settings", s.handleSetRuntimeSettings)
mux.HandleFunc("GET /api/categories", s.handleCategories)
mux.HandleFunc("GET /api/sources", s.handleSources)
mux.HandleFunc("GET /api/research/status", s.handleResearchStatus)
mux.HandleFunc("POST /api/research/test", s.handleResearchTest)
@@ -93,10 +92,6 @@ func (s *Server) handleSetRuntimeSettings(w http.ResponseWriter, r *http.Request
writeJSON(w, http.StatusOK, s.Engine.RuntimeSettingsView())
}
func (s *Server) handleCategories(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"categories": s.Engine.Categories()})
}
func (s *Server) handleSources(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"sources": s.Engine.Sources()})
}

View File

@@ -16,7 +16,7 @@ import (
"github.com/local/glpi-neural-brain/internal/research"
)
func TestRuntimeSettingsAndCategoriesAPI(t *testing.T) {
func TestRuntimeSettingsAndSourcesAPI(t *testing.T) {
data := t.TempDir()
g, err := graph.Open(data)
if err != nil {
@@ -34,10 +34,11 @@ func TestRuntimeSettingsAndCategoriesAPI(t *testing.T) {
LearningEnabled: true,
ThinkingEnabled: true,
DefaultView: "neural",
GLPIKBSource: "Configured GLPI Source",
}, g, broker)
h := (&Server{Engine: eng, Graph: g, Broker: broker}).Handler()
body := `{"learning_enabled":false,"thinking_enabled":false,"learning_categories":["GLPI"],"display_categories":["Ollama"],"thinking_categories":["GLPI"],"learning_sources":["GLPI Knowledge Base"],"display_sources":["internal-kb"],"thinking_sources":["GLPI Knowledge Base"],"view_mode":"constellation","max_display_nodes":1500,"low_power_mode":true}`
body := `{"learning_enabled":false,"thinking_enabled":false,"learning_sources":["GLPI Knowledge Base"],"display_sources":["internal-kb"],"thinking_sources":["GLPI Knowledge Base"],"view_mode":"constellation","max_display_nodes":1500,"low_power_mode":true}`
req := httptest.NewRequest(http.MethodPut, "/api/runtime-settings", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
@@ -53,27 +54,6 @@ func TestRuntimeSettingsAndCategoriesAPI(t *testing.T) {
t.Fatalf("unexpected settings: %+v", settings)
}
res = httptest.NewRecorder()
h.ServeHTTP(res, httptest.NewRequest(http.MethodGet, "/api/categories", nil))
if res.Code != http.StatusOK {
t.Fatalf("GET categories returned %d", res.Code)
}
var categories struct {
Categories []engine.CategoryInfo `json:"categories"`
}
if err := json.NewDecoder(res.Body).Decode(&categories); err != nil {
t.Fatal(err)
}
foundUncategorized := false
for _, category := range categories.Categories {
if category.Name == "__uncategorized__" && category.Count == 1 {
foundUncategorized = true
}
}
if !foundUncategorized {
t.Fatalf("uncategorized virtual category missing: %+v", categories.Categories)
}
res = httptest.NewRecorder()
h.ServeHTTP(res, httptest.NewRequest(http.MethodGet, "/api/sources", nil))
if res.Code != http.StatusOK {
@@ -85,8 +65,18 @@ func TestRuntimeSettingsAndCategoriesAPI(t *testing.T) {
if err := json.NewDecoder(res.Body).Decode(&sources); err != nil {
t.Fatal(err)
}
if len(sources.Sources) < 2 {
t.Fatalf("source options missing: %+v", sources.Sources)
foundConfiguredGLPI := false
foundInternal := false
for _, source := range sources.Sources {
if source.Name == "Configured GLPI Source" && source.Count == 0 {
foundConfiguredGLPI = true
}
if source.Name == "internal-kb" && source.Count == 1 {
foundInternal = true
}
}
if !foundConfiguredGLPI || !foundInternal {
t.Fatalf("exact source options or configured GLPI source missing: %+v", sources.Sources)
}
res = httptest.NewRecorder()

View File

@@ -31,7 +31,7 @@ html,body{margin:0;width:100%;height:100%;overflow:hidden;background:radial-grad
.dock .think-action{display:flex;align-items:center;gap:7px;border-color:rgba(255,180,82,.22);background:rgba(255,180,82,.07);color:var(--amber)}.dock .think-action small{font-size:8px;letter-spacing:.11em;opacity:.68}.dock .think-action:hover{background:rgba(255,180,82,.14);box-shadow:0 0 22px rgba(255,180,82,.08)}.dock .think-action.running{border-color:rgba(255,180,82,.48);background:rgba(255,180,82,.13);box-shadow:0 0 24px rgba(255,180,82,.11)}.dock .think-action:disabled{cursor:wait;opacity:.72}
.metrics span[title]{cursor:help}.dock #toggleLOD.active{color:var(--green);border-color:rgba(93,255,189,.22);background:rgba(93,255,189,.065)}
/* Laufzeitsteuerung, Kategorie-Filter und Honeycomb-Ansicht */
/* Laufzeitsteuerung, source-Filter und Visualisierungen */
.dock{max-width:calc(100vw - 390px);overflow-x:auto;scrollbar-width:none}.dock::-webkit-scrollbar{display:none}.dock-separator{width:1px;min-width:1px;background:var(--line);margin:5px 2px}.dock button:disabled{opacity:.32;cursor:not-allowed}.dock #toggleLearning.active{color:var(--blue);border-color:rgba(75,123,255,.25);background:rgba(75,123,255,.08)}.dock #toggleThinking.active{color:var(--amber);border-color:rgba(255,180,82,.25);background:rgba(255,180,82,.08)}.dock #viewHoneycomb.active,.dock #viewNeural.active{color:var(--green);border-color:rgba(93,255,189,.24);background:rgba(93,255,189,.07)}.dock #openSettings{color:#a7bdcc}
body.honeycomb-view .legend{opacity:.58}body.honeycomb-view .mode-status small:after{content:" · Honeycomb"}
.settings-backdrop{position:fixed;inset:0;z-index:20;background:rgba(0,3,9,.55);backdrop-filter:blur(3px)}
@@ -41,7 +41,7 @@ body.honeycomb-view .legend{opacity:.58}body.honeycomb-view .mode-status small:a
.switch-row{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:9px 10px;margin:7px 0;border:1px solid rgba(133,200,255,.1);border-radius:12px;background:rgba(255,255,255,.022);cursor:pointer}.switch-row b{display:block;font-size:11px}.switch-row small{display:block;margin-top:3px;color:#718b9e;font-size:9px;line-height:1.35}.switch-row input{appearance:none;width:38px;height:21px;border-radius:999px;background:rgba(119,146,168,.25);position:relative;cursor:pointer;flex:0 0 auto;outline:1px solid rgba(255,255,255,.06)}.switch-row input:after{content:"";position:absolute;width:15px;height:15px;left:3px;top:3px;border-radius:50%;background:#8196a7;transition:transform .2s,background .2s,box-shadow .2s}.switch-row input:checked{background:rgba(82,231,255,.19)}.switch-row input:checked:after{transform:translateX(17px);background:var(--cyan);box-shadow:0 0 10px rgba(82,231,255,.7)}
.view-selector{display:grid;grid-template-columns:repeat(3,1fr);gap:6px;margin-top:10px}.view-selector button{border:1px solid rgba(133,200,255,.12);background:rgba(255,255,255,.025);color:#738da1;border-radius:10px;padding:9px;font-size:10px;font-weight:700;letter-spacing:.1em;cursor:pointer}.view-selector button.active{color:var(--green);border-color:rgba(93,255,189,.24);background:rgba(93,255,189,.07)}
.category-settings{flex:1;min-height:0;overflow:auto;padding-right:5px}.filter-search{display:block;margin-bottom:12px}.filter-search span{display:block;font-size:9px;color:#6d879a;margin-bottom:5px}.filter-search input{width:100%;border:1px solid rgba(133,200,255,.14);background:rgba(2,8,17,.6);color:var(--text);border-radius:10px;padding:9px 10px;outline:0}.filter-search input:focus{border-color:rgba(82,231,255,.38);box-shadow:0 0 0 3px rgba(82,231,255,.06)}
.filter-block{margin:12px 0 16px}.filter-heading{display:flex;align-items:center;justify-content:space-between;margin-bottom:7px}.filter-heading b{display:block;font-size:10px;color:#c8d9e4}.filter-heading small{display:block;margin-top:2px;color:#607b90;font-size:9px}.filter-heading button{border:1px solid rgba(82,231,255,.13);background:rgba(82,231,255,.04);color:#87a8bc;border-radius:8px;padding:5px 8px;font-size:8px;letter-spacing:.08em;cursor:pointer}.category-list{display:flex;flex-wrap:wrap;gap:5px;max-height:128px;overflow:auto;padding:1px}.category-option{position:relative}.category-option input{position:absolute;opacity:0;pointer-events:none}.category-option span{display:block;border:1px solid rgba(133,200,255,.12);background:rgba(255,255,255,.022);color:#7893a6;border-radius:999px;padding:5px 8px;font-size:9px;cursor:pointer;white-space:nowrap}.category-option input:checked+span{color:#dffaff;border-color:rgba(82,231,255,.32);background:rgba(82,231,255,.11);box-shadow:0 0 12px rgba(82,231,255,.05)}.category-option em{font-style:normal;opacity:.58;margin-left:4px}
.filter-block{margin:12px 0 16px}.filter-heading{display:flex;align-items:center;justify-content:space-between;margin-bottom:7px}.filter-heading b{display:block;font-size:10px;color:#c8d9e4}.filter-heading small{display:block;margin-top:2px;color:#607b90;font-size:9px}.filter-heading button{border:1px solid rgba(82,231,255,.13);background:rgba(82,231,255,.04);color:#87a8bc;border-radius:8px;padding:5px 8px;font-size:8px;letter-spacing:.08em;cursor:pointer}.source-list{display:flex;flex-wrap:wrap;gap:5px;max-height:128px;overflow:auto;padding:1px}.source-option{position:relative}.source-option input{position:absolute;opacity:0;pointer-events:none}.source-option span{display:block;border:1px solid rgba(133,200,255,.12);background:rgba(255,255,255,.022);color:#7893a6;border-radius:999px;padding:5px 8px;font-size:9px;cursor:pointer;white-space:nowrap}.source-option input:checked+span{color:#dffaff;border-color:rgba(82,231,255,.32);background:rgba(82,231,255,.11);box-shadow:0 0 12px rgba(82,231,255,.05)}.source-option em{font-style:normal;opacity:.58;margin-left:4px}
.settings-footer{display:flex;align-items:center;justify-content:space-between;gap:12px;padding-top:13px}.settings-footer span{font-size:9px;color:#7f9aac;line-height:1.35}.settings-footer button{border:1px solid rgba(82,231,255,.3);background:rgba(82,231,255,.1);color:var(--cyan);border-radius:11px;padding:10px 13px;font-size:9px;font-weight:800;letter-spacing:.13em;cursor:pointer}.settings-footer button:disabled{opacity:.5;cursor:wait}
@media(max-width:1100px){.dock{max-width:calc(100vw - 40px)}.settings-panel{right:10px;top:10px;bottom:10px}}
@media(max-width:780px){.settings-panel{left:10px;width:auto}.dock{max-width:calc(100vw - 20px)}.dock-separator{display:none}}
@@ -65,4 +65,4 @@ body.low-power .glass{backdrop-filter:blur(12px)}
.eco-switch input:checked{background:rgba(255,180,82,.2)}
.eco-switch input:checked:after{background:var(--amber);box-shadow:0 0 10px rgba(255,180,82,.72)}
@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}.category-option.disabled{opacity:.38}.category-option.disabled span{cursor:not-allowed;border-style:dashed}.category-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}
.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}

View File

@@ -51,15 +51,15 @@
lodOpenUntil: new Map(), lodHotUntil: new Map(), lodDirty: true, lodLastBuild: 0, lodNextExpiry: 0, lodZoomBand: 2,
renderNodes: [], renderEdges: [], renderIdleEdges: [], renderNodeById: new Map(), renderEdgeById: new Map(), visibleForNode: new Map(),
edgeRenderMap: new Map(), renderActive: new Map(), renderEdgeActive: new Map(), renderStats: {nodes: 0, edges: 0, hiddenNodes: 0, hiddenEdges: 0},
fullSnapshot: null, fullNodeById: new Map(), runtimeSettings: {learning_enabled: true, thinking_enabled: true, learning_categories: [], display_categories: [], thinking_categories: [], learning_sources: [], display_sources: [], thinking_sources: [], effective_learning: {categories: [], sources: [], matches_none: false}, effective_display: {categories: [], sources: [], matches_none: false}, effective_thinking: {categories: [], sources: [], matches_none: false}, admin_learning: {categories: [], sources: []}, admin_display: {categories: [], sources: []}, admin_thinking: {categories: [], sources: []}, view_mode: 'neural', max_display_nodes: 0, low_power_mode: false},
availableCategories: [], availableSources: [], viewMode: 'neural', honeycombNodes: [], honeycombSpacing: 0, honeySlotByID: new Map(), honeyPointPool: [], honeyFreeSlots: [],
fullSnapshot: null, fullNodeById: new Map(), runtimeSettings: {source_filter_version: 1, learning_enabled: true, thinking_enabled: true, learning_sources: [], display_sources: [], thinking_sources: [], glpi_kb_source: '', view_mode: 'neural', max_display_nodes: 0, low_power_mode: false},
availableSources: [], viewMode: 'neural', honeycombNodes: [], honeycombSpacing: 0, honeySlotByID: new Map(), honeyPointPool: [], honeyFreeSlots: [],
constellationNodes: [], constellationLinks: [], settingsOpen: false, settingsDraft: null,
forcedDisplayUntil: new Map(), nextDisplayLimitExpiry: 0, graphVersion: null, displaySignature: '', displayLimitStats: {limit: 0, eligible: 0, shown: 0},
researchAnimations: new Map(), researchSequence: 0,
lowPowerMode: false, performanceProfile: PERFORMANCE_CONFIG.normal, lastPaint: 0, fpsWindowStarted: performance.now(), fpsFrames: 0, fps: 0,
backgroundCanvas: document.createElement('canvas'), backgroundKey: '', cameraFrame: null, projectedSortAt: 0,
retiringRenderNodes: [], retiringRenderEdges: [], retiringRenderNodeById: new Map(), viewGhostNodes: [],
topologyNodeIDs: new Set(), topologyEdgeIDs: new Set(), graphLoadPromise: null, graphLoadQueued: false, graphLoadTimer: 0
topologyNodeIDs: new Set(), topologyEdgeIDs: new Set(), graphLoadPromise: null, graphLoadQueued: false, graphLoadTimer: 0, sourceOptionsTimer: 0
};
function currentPerformanceProfile() {
@@ -141,46 +141,30 @@
return data;
}
function normalizedFilterValues(values) {
return [...new Set((values || []).map(value => String(value).trim().toLowerCase()).filter(Boolean))];
function normalizedSourceValues(values) {
return [...new Set((values || []).map(value => String(value).trim()).filter(Boolean))];
}
// Source filtering intentionally uses only the explicit metadata.source value
// copied from a KB file (or assigned to learned web evidence). No origin,
// category, URI or display-label fallback is used.
function nodeSource(node) {
const explicit = node?.metadata?.source;
if (explicit !== undefined && explicit !== null) {
const value = String(explicit).trim();
if (value && value.toLowerCase() !== '<nil>' && value.toLowerCase() !== 'null') return value;
}
if (node?.kind === 'source' && String(node.label || '').trim()) return String(node.label).trim();
if (node?.kind === 'external') {
for (const raw of [node.uri, node.external_id]) {
try {
const host = new URL(String(raw || '')).hostname.trim().toLowerCase();
if (host) return host;
} catch {}
}
}
return String(node?.origin || '').trim();
if (explicit === undefined || explicit === null) return '';
const value = String(explicit).trim();
if (!value || value.toLowerCase() === '<nil>' || value.toLowerCase() === 'null') return '';
return value;
}
function filterDimensionMatches(values, filters, emptyToken) {
const wanted = new Set(normalizedFilterValues(filters));
if (!wanted.size || wanted.has('*')) return true;
const clean = (values || []).map(value => String(value).trim().toLowerCase()).filter(Boolean);
if (!clean.length) return wanted.has(emptyToken);
return clean.some(value => wanted.has(value));
function sourceFilterMatches(node, sources) {
const wanted = normalizedSourceValues(sources);
if (!wanted.length) return true;
const source = nodeSource(node);
return wanted.some(value => source === value);
}
function nodeFilterMatches(node, filter) {
if (filter?.matches_none) return false;
return filterDimensionMatches(node.categories || [], filter?.categories || [], '__uncategorized__') &&
filterDimensionMatches(nodeSource(node) ? [nodeSource(node)] : [], filter?.sources || [], '__unsourced__');
}
function effectiveRuntimeFilter(key, settings = state.runtimeSettings) {
const effective = settings?.[`effective_${key}`];
if (effective) return effective;
return {categories: settings?.[`${key}_categories`] || [], sources: settings?.[`${key}_sources`] || [], matches_none: false};
function runtimeSources(key, settings = state.runtimeSettings) {
return normalizedSourceValues(settings?.[`${key}_sources`] || []);
}
function activeForcedDisplayIDs(now = Date.now()) {
@@ -265,19 +249,18 @@
}
function filteredSnapshot(snapshot) {
const filter = effectiveRuntimeFilter('display');
const restricted = Boolean(filter?.matches_none || (filter?.categories || []).length || (filter?.sources || []).length);
const sources = runtimeSources('display');
let filtered = snapshot;
if (restricted) {
if (sources.length) {
const visible = new Set();
const noteKinds = new Set(['knowledge', 'ai-think', 'external']);
const taxonomyKinds = new Set(['category', 'source', 'concept']);
const nodeMap = new Map(snapshot.nodes.map(node => [node.id, node]));
for (const node of snapshot.nodes) {
if (noteKinds.has(node.kind) && nodeFilterMatches(node, filter)) visible.add(node.id);
if (noteKinds.has(node.kind) && sourceFilterMatches(node, sources)) visible.add(node.id);
}
// Add only the taxonomy directly attached to an already matching note.
// Never pull another knowledge/external note through a semantic edge.
// Taxonomy is visual context only. Another knowledge-bearing node is
// never pulled through an edge when its exact source does not match.
for (const edge of snapshot.edges) {
const source = nodeMap.get(edge.source);
const target = nodeMap.get(edge.target);
@@ -294,8 +277,7 @@
}
function displaySignatureFor(settings = state.runtimeSettings) {
const filter = effectiveRuntimeFilter('display', settings);
return {categories: filter.categories || [], sources: filter.sources || [], matchesNone: Boolean(filter.matches_none), limit: Number(settings.max_display_nodes || 0)};
return {sources: runtimeSources('display', settings), limit: Number(settings.max_display_nodes || 0)};
}
function currentDisplaySignature() {
@@ -303,16 +285,28 @@
return JSON.stringify({...displaySignatureFor(), forced});
}
async function loadSourceOptions() {
const payload = await api('/api/sources');
state.availableSources = payload.sources || [];
renderSourceFilters($('sourceSearch')?.value || '');
return state.availableSources;
}
function scheduleSourceOptionsLoad(delay = 260) {
clearTimeout(state.sourceOptionsTimer);
state.sourceOptionsTimer = setTimeout(() => {
state.sourceOptionsTimer = 0;
loadSourceOptions().catch(() => {});
}, delay);
}
async function loadRuntimeConfiguration() {
try {
const [settings, categories, sources] = await Promise.all([api('/api/runtime-settings'), api('/api/categories'), api('/api/sources')]);
const [settings] = await Promise.all([api('/api/runtime-settings'), loadSourceOptions()]);
state.runtimeSettings = {...state.runtimeSettings, ...settings};
state.availableCategories = categories.categories || [];
state.availableSources = sources.sources || [];
state.viewMode = ['neural', 'honeycomb', 'constellation'].includes(state.runtimeSettings.view_mode) ? state.runtimeSettings.view_mode : 'neural';
applyPerformanceMode(Boolean(state.runtimeSettings.low_power_mode), true);
syncRuntimeControls();
renderCategoryFilters();
renderSourceFilters();
renderFilterScopeSummary();
} catch {
@@ -347,7 +341,7 @@
if (!hint) return;
const limit = Math.max(0, Math.trunc(Number(value) || 0));
if (!limit) {
hint.textContent = 'Unbegrenzt · Kategorie-Filter und LOD bestimmen die Renderlast.';
hint.textContent = 'Unbegrenzt · Quellenfilter und LOD bestimmen die Renderlast.';
return;
}
const stats = state.displayLimitStats || {};
@@ -355,113 +349,72 @@
hint.textContent = `Maximal ${limit.toLocaleString('de-DE')} Nodes · aktuell ${Math.min(limit, eligible).toLocaleString('de-DE')} von ${eligible.toLocaleString('de-DE')} auswählbar.`;
}
function categoryLabel(name) {
return name === '__uncategorized__' ? 'Ohne Kategorie' : name;
}
function sourceLabel(name) {
return name === '__unsourced__' ? 'Ohne Quelle' : name;
return String(name || '');
}
function filterSet(key, dimension) {
const source = state.settingsDraft || state.runtimeSettings;
return new Set((source[`${key}_${dimension}`] || []).map(value => String(value).toLowerCase()));
}
function adminAllows(key, dimension, value) {
const admin = state.runtimeSettings?.[`admin_${key}`] || {};
const restricted = Boolean(admin[`${dimension}_restricted`]);
if (!restricted) return true;
const allowed = new Set(normalizedFilterValues(admin[dimension] || []));
return allowed.has(String(value).trim().toLowerCase());
}
function renderFilterOptions({dimension, search = '', options, targets, labeler, dataAttribute}) {
const query = String(search || '').trim().toLowerCase();
for (const [key, target] of Object.entries(targets)) {
if (!target) continue;
const selected = filterSet(key, dimension);
target.innerHTML = '';
const visibleOptions = options.filter(option => !query || labeler(option.name).toLowerCase().includes(query));
for (const option of visibleOptions) {
const allowed = adminAllows(key, dimension, option.name);
const label = document.createElement('label');
label.className = `category-option${allowed ? '' : ' disabled'}`;
const checked = selected.has(String(option.name).toLowerCase());
const disabled = allowed ? '' : ' disabled';
const notice = allowed ? '' : '<small>durch ENV ausgeschlossen</small>';
label.innerHTML = `<input type="checkbox" ${dataAttribute}="${key}" value="${escapeHTML(option.name)}" ${checked ? 'checked' : ''}${disabled}><span>${escapeHTML(labeler(option.name))}<em>${Number(option.count || 0).toLocaleString('de-DE')}</em>${notice}</span>`;
target.appendChild(label);
}
if (!visibleOptions.length) target.innerHTML = `<span class="empty-filter">Keine passende ${dimension === 'categories' ? 'Kategorie' : 'Quelle'}</span>`;
}
}
function renderCategoryFilters(search = '') {
renderFilterOptions({
dimension: 'categories', search, options: state.availableCategories,
targets: {learning: $('learningCategoryList'), display: $('displayCategoryList'), thinking: $('thinkingCategoryList')},
labeler: categoryLabel, dataAttribute: 'data-category-filter'
});
function sourceSet(key) {
const settings = state.settingsDraft || state.runtimeSettings;
return new Set(normalizedSourceValues(settings[`${key}_sources`] || []));
}
function renderSourceFilters(search = '') {
renderFilterOptions({
dimension: 'sources', search, options: state.availableSources,
targets: {learning: $('learningSourceList'), display: $('displaySourceList'), thinking: $('thinkingSourceList')},
labeler: sourceLabel, dataAttribute: 'data-source-filter'
});
}
function intersectClientValues(adminValues, runtimeValues, restrictedFlag) {
const runtime = normalizedFilterValues(runtimeValues);
if (!restrictedFlag) return {values: runtime, restricted: runtime.length > 0, none: false};
const admin = normalizedFilterValues(adminValues);
if (!runtime.length) return {values: admin, restricted: true, none: false};
const allowed = new Set(admin);
const values = runtime.filter(value => allowed.has(value));
return {values, restricted: true, none: values.length === 0};
}
function previewEffectiveFilter(key) {
const query = String(search || '').trim().toLowerCase();
const settings = state.settingsDraft || state.runtimeSettings;
const admin = state.runtimeSettings?.[`admin_${key}`] || {};
const categories = intersectClientValues(admin.categories || [], settings?.[`${key}_categories`] || [], Boolean(admin.categories_restricted));
const sources = intersectClientValues(admin.sources || [], settings?.[`${key}_sources`] || [], Boolean(admin.sources_restricted));
return {categories: categories.values, sources: sources.values, matches_none: categories.none || sources.none};
const optionMap = new Map((state.availableSources || []).map(option => [String(option.name), {...option}]));
const configuredGLPI = String(state.runtimeSettings.glpi_kb_source || '').trim();
if (configuredGLPI && !optionMap.has(configuredGLPI)) optionMap.set(configuredGLPI, {name: configuredGLPI, count: 0});
for (const key of ['learning', 'display', 'thinking']) {
for (const source of runtimeSources(key, settings)) {
if (!optionMap.has(source)) optionMap.set(source, {name: source, count: 0});
}
}
const options = [...optionMap.values()].filter(option => !query || sourceLabel(option.name).toLowerCase().includes(query));
const targets = {learning: $('learningSourceList'), display: $('displaySourceList'), thinking: $('thinkingSourceList')};
for (const [key, target] of Object.entries(targets)) {
if (!target) continue;
const selected = sourceSet(key);
target.innerHTML = '';
for (const option of options) {
const label = document.createElement('label');
label.className = 'source-option';
const checked = selected.has(String(option.name));
const configured = option.name === state.runtimeSettings.glpi_kb_source && Number(option.count || 0) === 0 ? '<small>GLPI_KB_SOURCE</small>' : '';
label.innerHTML = `<input type="checkbox" data-source-filter="${key}" value="${escapeHTML(option.name)}" ${checked ? 'checked' : ''}><span>${escapeHTML(sourceLabel(option.name))}<em>${Number(option.count || 0).toLocaleString('de-DE')}</em>${configured}</span>`;
target.appendChild(label);
}
if (!options.length) target.innerHTML = '<span class="empty-filter">Keine passende source gefunden</span>';
}
}
function compactFilterValues(values, labeler) {
if (!values?.length) return 'alle';
const labels = values.slice(0, 3).map(labeler);
function compactSourceValues(values) {
if (!values?.length) return 'alle source-Werte';
const labels = values.slice(0, 3).map(sourceLabel);
return labels.join(', ') + (values.length > 3 ? ` +${values.length - 3}` : '');
}
function renderFilterScopeSummary() {
const target = $('filterScopeSummary');
if (!target) return;
const parts = [];
let hasEmpty = false;
for (const [key, label] of [['learning', 'Lernen'], ['display', 'Anzeige'], ['thinking', 'Thinking']]) {
const effective = previewEffectiveFilter(key);
hasEmpty ||= effective.matches_none;
parts.push(`${label}: ${effective.matches_none ? 'keine Übereinstimmung' : `${compactFilterValues(effective.categories, categoryLabel)} · ${compactFilterValues(effective.sources, sourceLabel)}`}`);
}
target.textContent = `Wirksam (ENV ∩ WebUI) — ${parts.join(' | ')}`;
target.classList.toggle('warn', hasEmpty);
target.classList.toggle('ok', !hasEmpty);
const settings = state.settingsDraft || state.runtimeSettings;
const parts = [
`Lernen: ${compactSourceValues(runtimeSources('learning', settings))}`,
`Anzeige: ${compactSourceValues(runtimeSources('display', settings))}`,
`Thinking: ${compactSourceValues(runtimeSources('thinking', settings))}`
];
target.textContent = `Exakter KB-source-Treffer — ${parts.join(' | ')}`;
target.classList.remove('warn');
target.classList.add('ok');
}
async function persistRuntimeSettings(settings = state.runtimeSettings) {
const normalized = {
source_filter_version: 1,
learning_enabled: Boolean(settings.learning_enabled),
thinking_enabled: Boolean(settings.thinking_enabled),
learning_categories: [...(settings.learning_categories || [])],
display_categories: [...(settings.display_categories || [])],
thinking_categories: [...(settings.thinking_categories || [])],
learning_sources: [...(settings.learning_sources || [])],
display_sources: [...(settings.display_sources || [])],
thinking_sources: [...(settings.thinking_sources || [])],
learning_sources: normalizedSourceValues(settings.learning_sources),
display_sources: normalizedSourceValues(settings.display_sources),
thinking_sources: normalizedSourceValues(settings.thinking_sources),
view_mode: ['neural', 'honeycomb', 'constellation'].includes(settings.view_mode) ? settings.view_mode : 'neural',
max_display_nodes: Math.max(0, Math.min(500000, Math.trunc(Number(settings.max_display_nodes) || 0))),
low_power_mode: Boolean(settings.low_power_mode)
@@ -488,7 +441,6 @@
$('settingsPanel')?.classList.remove('hidden');
$('settingsBackdrop')?.classList.remove('hidden');
syncRuntimeControls();
renderCategoryFilters($('categorySearch')?.value || '');
renderSourceFilters($('sourceSearch')?.value || '');
renderFilterScopeSummary();
}
@@ -2760,7 +2712,10 @@
}
}
addLog(evt);
if (evt.type === 'graph.updated') scheduleGraphLoad(180);
if (evt.type === 'graph.updated') {
scheduleGraphLoad(180);
scheduleSourceOptionsLoad(320);
}
if (evt.type?.startsWith('think.') || evt.type?.startsWith('article.') || evt.type?.startsWith('research.')) loadStatus();
}
@@ -2999,7 +2954,6 @@
$('openSettings').addEventListener('click', openSettingsPanel);
$('closeSettings').addEventListener('click', closeSettingsPanel);
$('settingsBackdrop').addEventListener('click', closeSettingsPanel);
$('categorySearch').addEventListener('input', e => renderCategoryFilters(e.currentTarget.value));
$('sourceSearch').addEventListener('input', e => renderSourceFilters(e.currentTarget.value));
$('settingsLearning').addEventListener('change', e => { if (state.settingsDraft) state.settingsDraft.learning_enabled = e.currentTarget.checked; });
$('settingsThinking').addEventListener('change', e => { if (state.settingsDraft) state.settingsDraft.thinking_enabled = e.currentTarget.checked; });
@@ -3019,23 +2973,15 @@
$('settingsViewHoneycomb').addEventListener('click', () => selectSettingsView('honeycomb'));
$('settingsViewConstellation').addEventListener('click', () => selectSettingsView('constellation'));
$('settingsPanel').addEventListener('change', e => {
const input = e.target.closest('[data-category-filter], [data-source-filter]');
if (!input || !state.settingsDraft || input.disabled) return;
const dimension = input.dataset.categoryFilter !== undefined ? 'categories' : 'sources';
const key = input.dataset.categoryFilter ?? input.dataset.sourceFilter;
const property = `${key}_${dimension}`;
const selected = new Map((state.settingsDraft[property] || []).map(value => [String(value).toLowerCase(), value]));
const normalized = String(input.value).toLowerCase();
if (input.checked) selected.set(normalized, input.value); else selected.delete(normalized);
state.settingsDraft[property] = [...selected.values()];
const input = e.target.closest('[data-source-filter]');
if (!input || !state.settingsDraft) return;
const key = input.dataset.sourceFilter;
const property = `${key}_sources`;
const selected = new Set(normalizedSourceValues(state.settingsDraft[property] || []));
if (input.checked) selected.add(input.value); else selected.delete(input.value);
state.settingsDraft[property] = [...selected];
renderFilterScopeSummary();
});
document.querySelectorAll('[data-clear-filter]').forEach(button => button.addEventListener('click', () => {
if (!state.settingsDraft) return;
state.settingsDraft[`${button.dataset.clearFilter}_categories`] = [];
renderCategoryFilters($('categorySearch').value);
renderFilterScopeSummary();
}));
document.querySelectorAll('[data-clear-source-filter]').forEach(button => button.addEventListener('click', () => {
if (!state.settingsDraft) return;
state.settingsDraft[`${button.dataset.clearSourceFilter}_sources`] = [];

View File

@@ -60,7 +60,7 @@
<button id="toggleLOD" class="active" title="Hierarchische dynamische Verdichtung">LOD</button>
<button id="toggleEco" title="Optimierter Renderpfad für schwächere Systeme">ECO</button>
<button id="resetView">Zentrieren</button>
<button id="openSettings" title="Kategorie-Filter und Laufzeitsteuerung">FILTER</button>
<button id="openSettings" title="Exakte KB-source-Filter und Laufzeitsteuerung">FILTER</button>
<button id="enrichNow" class="think-action" title="Einen autonomen AI-THINK-Zyklus sofort starten"><span>AI-THINK</span><small>STARTEN</small></button>
</nav>
@@ -75,7 +75,7 @@
<div id="settingsBackdrop" class="settings-backdrop hidden"></div>
<aside id="settingsPanel" class="settings-panel glass hidden" aria-label="Brain-Einstellungen">
<div class="settings-header">
<div><strong>BRAIN CONTROL</strong><small>Laufzeitmodi und Kategorie-Filter</small></div>
<div><strong>BRAIN CONTROL</strong><small>Laufzeitmodi und exakte KB-source-Filter</small></div>
<button id="closeSettings" title="Schließen">×</button>
</div>
@@ -114,7 +114,7 @@
<button type="button" data-node-limit="25000">25.000</button>
<button type="button" data-node-limit="0">Unbegrenzt</button>
</div>
<p id="displayLimitHint" class="setting-hint">Unbegrenzt · Kategorie-Filter und LOD bestimmen die Renderlast.</p>
<p id="displayLimitHint" class="setting-hint">Unbegrenzt · Quellenfilter und LOD bestimmen die Renderlast.</p>
</section>
<section class="settings-section research-settings">
@@ -130,40 +130,22 @@
</div>
</section>
<section class="settings-section category-settings">
<div class="settings-section-title"><h2>Kategorie-Filter</h2><span>Leer = alle erlaubten Kategorien</span></div>
<p id="filterScopeSummary" class="setting-hint filter-scope-summary">Environment-Grenzen werden geladen …</p>
<label class="filter-search"><span>Kategorien durchsuchen</span><input id="categorySearch" type="search" placeholder="z. B. GLPI, Netzwerk, Ollama"></label>
<div class="filter-block">
<div class="filter-heading"><div><b>Lernen</b><small>Nur diese Kategorien werden neu eingebettet.</small></div><button type="button" data-clear-filter="learning">Alle</button></div>
<div id="learningCategoryList" class="category-list"></div>
</div>
<div class="filter-block">
<div class="filter-heading"><div><b>Anzeige</b><small>Nur passende Notes und deren Taxonomie werden gerendert.</small></div><button type="button" data-clear-filter="display">Alle</button></div>
<div id="displayCategoryList" class="category-list"></div>
</div>
<div class="filter-block">
<div class="filter-heading"><div><b>Thinking</b><small>Nur diese Kategorien werden für neue AI-Edges geprüft.</small></div><button type="button" data-clear-filter="thinking">Alle</button></div>
<div id="thinkingCategoryList" class="category-list"></div>
</div>
</section>
<section class="settings-section source-settings">
<div class="settings-section-title"><h2>Quellen-Filter</h2><span>Quelle und Kategorie gelten gemeinsam</span></div>
<label class="filter-search"><span>Quellen durchsuchen</span><input id="sourceSearch" type="search" placeholder="z. B. GLPI Knowledge Base, internal-kb, docs.example.org"></label>
<div class="settings-section-title"><h2>KB-source-Filter</h2><span>exakter Treffer auf dem source-Feld</span></div>
<p id="filterScopeSummary" class="setting-hint filter-scope-summary">Quellen werden geladen …</p>
<label class="filter-search"><span>source-Werte durchsuchen</span><input id="sourceSearch" type="search" placeholder="z. B. internal-category oder GLPI Knowledge Base"></label>
<div class="filter-block">
<div class="filter-heading"><div><b>Lernen</b><small>Nur Wissen aus diesen Quellen wird neu eingebettet und bei Abfragen verwendet.</small></div><button type="button" data-clear-source-filter="learning">Alle</button></div>
<div id="learningSourceList" class="category-list"></div>
<div class="filter-heading"><div><b>Lernen</b><small>Nur KB-Dokumente mit exakt diesem source-Wert werden eingebettet und bei Abfragen verwendet.</small></div><button type="button" data-clear-source-filter="learning">Alle</button></div>
<div id="learningSourceList" class="source-list"></div>
</div>
<div class="filter-block">
<div class="filter-heading"><div><b>Anzeige</b><small>Nur passende Quellen werden gerendert; Taxonomie bleibt sichtbar.</small></div><button type="button" data-clear-source-filter="display">Alle</button></div>
<div id="displaySourceList" class="category-list"></div>
<div class="filter-heading"><div><b>Anzeige</b><small>Nur Dokumente mit exakt passendem source-Wert werden gerendert; direkte Taxonomie bleibt sichtbar.</small></div><button type="button" data-clear-source-filter="display">Alle</button></div>
<div id="displaySourceList" class="source-list"></div>
</div>
<div class="filter-block">
<div class="filter-heading"><div><b>Thinking</b><small>Nur diese Quellen dürfen neue Relationen und Artikel speisen.</small></div><button type="button" data-clear-source-filter="thinking">Alle</button></div>
<div id="thinkingSourceList" class="category-list"></div>
<div class="filter-heading"><div><b>Thinking</b><small>Nur Dokumente und Webbelege mit exakt passendem source-Wert speisen neue Relationen und Artikel.</small></div><button type="button" data-clear-source-filter="thinking">Alle</button></div>
<div id="thinkingSourceList" class="source-list"></div>
</div>
</section>