Files
glpi-neuroforge-mega/patches/v1.1.0-to-v1.2.0.diff
T
jbergner a6bc71fb3a
release-tag / Resolve release metadata (push) Successful in 30s
release-tag / Build knowledge (push) Failing after 4m51s
release-tag / Build control (push) Failing after 5m0s
release-tag / Build agent (push) Failing after 5m0s
release-tag / Build agent-data-init (push) Failing after 5m5s
release-tag / Build neuroforge-worker (push) Failing after 5m7s
release-tag / Build neuroforge (push) Failing after 5m9s
Init
2026-08-26 18:34:41 +02:00

2150 lines
116 KiB
Diff
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
diff -ruN '--exclude=MANIFEST.sha256' '--exclude=patches' '--exclude=*.zip' /mnt/data/glpi-neuroforge-mega-v1.1.0/.env.example ./.env.example
--- /mnt/data/glpi-neuroforge-mega-v1.1.0/.env.example 2026-08-25 16:06:06.000000000 +0000
+++ ./.env.example 2026-08-25 19:19:15.237283985 +0000
@@ -64,3 +64,34 @@
KNOWLEDGE_HOST_PORT=8081
NEUROFORGE_HOST_PORT=8090
OLLAMA_HOST_PORT=11434
+
+# -----------------------------
+# Controlled learning / research
+# -----------------------------
+# Enforces: no automatic learning from raw chat input or assistant output;
+# explicit validated outcomes and research evidence keep distinct provenance.
+NEUROFORGE_CONTROLLED_LEARNING=true
+
+# Ticket -> AI proposal -> technician accept/correct -> NeuroForge learn.
+OUTCOME_LEARNING_ENABLED=true
+# false = technician sees an error when NeuroForge cannot persist the validated outcome.
+# The local outcome audit is still retained with sync_status=failed.
+OUTCOME_LEARNING_FAIL_OPEN=false
+OUTCOME_LEARNING_MAX_OUTCOMES=2000
+
+# Research is opt-in. Starting the SearXNG profile alone does not enable learning.
+NEUROFORGE_RESEARCH_ENABLED=false
+NEUROFORGE_SEARXNG_ENABLED=false
+NEUROFORGE_SEARXNG_URL=http://searxng:8080
+NEUROFORGE_RESEARCH_GOAL_ENABLED=true
+# Separate switch for scheduled self-directed goal cycles.
+NEUROFORGE_AUTONOMY_ENABLED=false
+NEUROFORGE_AUTONOMY_INTERVAL_MINUTES=30
+NEUROFORGE_RESEARCH_MAX_QUERIES=2
+NEUROFORGE_RESEARCH_MAX_PAGES=4
+
+# Required only when the optional `research` compose profile is started.
+# Pin this to a version/digest in production if reproducible images are required.
+SEARXNG_IMAGE=docker.io/searxng/searxng:latest
+SEARXNG_SECRET=CHANGE_ME_SEARXNG_LONG_RANDOM_SECRET
+SEARXNG_HOST_PORT=8888
diff -ruN '--exclude=MANIFEST.sha256' '--exclude=patches' '--exclude=*.zip' /mnt/data/glpi-neuroforge-mega-v1.1.0/Makefile ./Makefile
--- /mnt/data/glpi-neuroforge-mega-v1.1.0/Makefile 2026-08-25 15:59:02.000000000 +0000
+++ ./Makefile 2026-08-25 19:13:14.014255166 +0000
@@ -1,6 +1,6 @@
SHELL := /bin/sh
-.PHONY: test vet build up down logs status ps
+.PHONY: test vet build up research-up down logs status ps
test:
cd platform/neuroforge && go test ./...
@@ -20,6 +20,9 @@
up:
docker compose up -d --build
+research-up:
+ ./scripts/research-up.sh
+
down:
docker compose down
diff -ruN '--exclude=MANIFEST.sha256' '--exclude=patches' '--exclude=*.zip' /mnt/data/glpi-neuroforge-mega-v1.1.0/README.md ./README.md
--- /mnt/data/glpi-neuroforge-mega-v1.1.0/README.md 2026-08-25 18:59:12.576471455 +0000
+++ ./README.md 2026-08-25 19:16:33.910735909 +0000
@@ -1,4 +1,4 @@
-# GLPI NeuroForge Mega
+# GLPI NeuroForge Mega v1.2.0
Ein kontrolliertes Monorepo aus **GLPI AI Agent**, **GLPI AI Knowledgebase** und **NeuroForge + SQAR**. Ziel ist nicht ein untrennbarer Monolith, sondern eine gemeinsame Plattform mit klaren Zuständigkeiten, getrennten Credentials und nachvollziehbaren Failure-Modi.
@@ -53,6 +53,39 @@
Details: [`docs/MIGRATION-CUTOVER.md`](docs/MIGRATION-CUTOVER.md).
+## Kontrolliertes Lernen: erst Outcome, dann Wissen
+
+Im produktionsnahen Standard (`NEUROFORGE_CONTROLLED_LEARNING=true`) werden rohe Chat-Eingaben und KI-Antworten **nicht automatisch** zu vertrauenswürdigem Langzeitwissen. Der Helpdesk-Lernpfad ist explizit menschlich gegated:
+
+```text
+Ticket -> KI-Vorschlag -> Techniker bestätigt/korrigiert -> auditiertes Outcome -> NeuroForge lernt
+```
+
+Im Agent-Dashboard kann ein Antwortvorschlag als **„KI-Antwort bestätigen“** oder **„KI-Antwort korrigieren“** validiert werden. Jede Entscheidung wird lokal in `ticket-outcomes.json` mit Sync-Status gespeichert. Eine spätere Korrektur überschreibt die frühere Entscheidung nicht, sondern erzeugt eine neue Revision mit `supersedes_id`. Nur `accepted` und `corrected` dürfen den App-Key-geschützten NeuroForge-Endpunkt `/api/v1/integrations/outcomes` verwenden; NeuroForge weist die vertrauenswürdige Provenance serverseitig zu.
+
+Vor der Hochstufung verifiziert der Agent außerdem, dass sich der GLPI-Ticketzustand seit dem analysierten Run nicht geändert hat. Ein veralteter Run darf nicht als Trusted Outcome gelernt werden.
+
+Standardmäßig ist `OUTCOME_LEARNING_FAIL_OPEN=false`: Kann das bestätigte Outcome nicht nach NeuroForge synchronisiert werden, sieht der Techniker einen Fehler. Der lokale Audit-Eintrag bleibt mit `sync_status=failed` für einen kontrollierten Retry erhalten.
+
+Details: [`docs/CONTROLLED-AUTONOMY.md`](docs/CONTROLLED-AUTONOMY.md).
+
+## Optionales SearXNG / kontrollierte Autonomie
+
+SearXNG ist ein echtes, aber **optionales** Compose-Profil. Der normale Stack startet es nicht. Research und zeitgesteuerte Autonomie besitzen getrennte Schalter:
+
+```bash
+# .env: echten SEARXNG_SECRET setzen
+./scripts/research-up.sh
+```
+
+`research-up.sh` startet SearXNG sowie NeuroForge mit Research/SearXNG aktiviert. `NEUROFORGE_AUTONOMY_ENABLED` bleibt davon unberührt und ist standardmäßig `false`. Damit sind drei Betriebsstufen möglich:
+
+1. Research aus – keine Webrecherche.
+2. Research an, Autonomy aus – Recherche kann explizit/manuell angestoßen werden.
+3. Research an, Autonomy an – fällige Research-Goals dürfen zyklisch selbst recherchieren.
+
+Web-Evidence erhält bewusst niedrigere Source-Trust-Werte als menschlich bestätigte GLPI-Outcomes. Unabhängige Quellen können bestehende Evidence über die vorhandene Corroboration-Logik stärken; produktive KB-Promotion bleibt trotzdem menschlich kontrolliert.
+
## Research → Staging
Die Knowledgebase stellt einen getrennt authentifizierten Eingang bereit:
@@ -109,6 +142,6 @@
Die importierten GLPI-Projekte wurden im Mega-Repo auf Go 1.23 normalisiert. Die komplette Testbasis läuft damit in der bereitgestellten Umgebung. Die ursprünglichen Quellarchive bleiben davon unberührt.
-## Noch bewusst nicht autonom
+## Bewusst begrenzte Autonomie
-NeuroForge Research veröffentlicht **nicht selbstständig** in die produktive Knowledgebase. Der technische Draft-Ingress ist vorhanden, aber der Übergang von einem konkreten Research-Run zu einem KB-Draft soll über einen expliziten Workflow/Job erfolgen. Das ist eine Governance-Entscheidung, kein fehlender Schreibweg.
+Auch bei aktivierter Research-Autonomie veröffentlicht NeuroForge **nicht selbstständig** in die produktive Knowledgebase. Der technische Draft-Ingress ist vorhanden, aber der Übergang von einem konkreten Research-Run zu einem KB-Draft soll über einen expliziten Workflow/Job erfolgen. Das ist eine Governance-Entscheidung, kein fehlender Schreibweg.
diff -ruN '--exclude=MANIFEST.sha256' '--exclude=patches' '--exclude=*.zip' /mnt/data/glpi-neuroforge-mega-v1.1.0/RELEASE-NOTES-v1.2.0.md ./RELEASE-NOTES-v1.2.0.md
--- /mnt/data/glpi-neuroforge-mega-v1.1.0/RELEASE-NOTES-v1.2.0.md 1970-01-01 00:00:00.000000000 +0000
+++ ./RELEASE-NOTES-v1.2.0.md 2026-08-25 19:16:33.911460019 +0000
@@ -0,0 +1,45 @@
+# Release Notes v1.2.0 — Controlled Autonomy
+
+## Schwerpunkt
+
+v1.2.0 macht aus „autonom lernfähig“ ein kontrolliert autonomes Betriebsmodell. Rohes Chat-/Modellverhalten wird im Mega-Stack nicht mehr automatisch zu vertrauenswürdigem Langzeitwissen. Helpdesk-Lernen folgt stattdessen dem Ablauf **Ticket → KI-Vorschlag → Techniker bestätigt/korrigiert → Outcome → Learn**.
+
+## Neu
+
+- optionaler SearXNG-Service als Compose-Profil `research`
+- gehärtete private SearXNG-Konfiguration unter `deploy/searxng/settings.yml`
+- `scripts/research-up.sh` für bewusstes Research-Enabling
+- separate Schalter für Research/SearXNG und zeitgesteuerte Autonomy
+- `NEUROFORGE_CONTROLLED_LEARNING=true` als konservativer Mega-Stack-Standard
+- kein automatisches Lernen von Chat-Inputs oder Assistant-Antworten im Controlled Mode
+- neue App-Key-geschützte API `POST /api/v1/integrations/outcomes`
+- serverseitig gesetzte Provenance `glpi.outcome.accepted|corrected`
+- Agent-Audit `ticket-outcomes.json` mit `pending|learned|failed`
+- Stale-Run-Schutz: Trusted Outcome nur, wenn der GLPI-Ticketzustand noch zum analysierten Run passt
+- unveränderliche Outcome-Revisionskette via `supersedes_id`
+- idempotente Wiederholung bereits gelernter menschlicher Entscheidungen
+- UI-Aktionen **KI-Antwort bestätigen** und **KI-Antwort korrigieren** im Run-Drawer
+- Control Center zeigt Controlled Learning, Outcome Learning, Research/SearXNG und Autonomy read-only an
+- `SEARXNG_SECRET` im Secret-Generator
+
+## Vertrauensmodell
+
+- Web Search: 0.45
+- Web Page/Document: 0.60
+- human accepted outcome: 1.00
+- human corrected outcome: 1.00
+
+Web-Evidence bleibt source-backed, deduplizierbar und korroborierbar. Sie wird nicht mit einem menschlich bestätigten Helpdesk-Outcome gleichgesetzt.
+
+## Bewusste Grenzen
+
+- SearXNG ist standardmäßig aus.
+- Research ist standardmäßig aus.
+- Autonomy ist standardmäßig aus.
+- Research darf nicht direkt in die Produktions-KB schreiben.
+- Das Control Center bleibt read-only.
+- GLPI-Aktionen bleiben beim policy-gated Agenten.
+
+## Upgrade
+
+Siehe `docs/MIGRATION-v1.1.0-to-v1.2.0.md` und `docs/CONTROLLED-AUTONOMY.md`.
diff -ruN '--exclude=MANIFEST.sha256' '--exclude=patches' '--exclude=*.zip' /mnt/data/glpi-neuroforge-mega-v1.1.0/VERSION ./VERSION
--- /mnt/data/glpi-neuroforge-mega-v1.1.0/VERSION 2026-08-25 18:37:16.026769139 +0000
+++ ./VERSION 2026-08-25 18:57:43.995247001 +0000
@@ -1 +1 @@
-1.1.0
+1.2.0
diff -ruN '--exclude=MANIFEST.sha256' '--exclude=patches' '--exclude=*.zip' /mnt/data/glpi-neuroforge-mega-v1.1.0/deploy/searxng/settings.yml ./deploy/searxng/settings.yml
--- /mnt/data/glpi-neuroforge-mega-v1.1.0/deploy/searxng/settings.yml 1970-01-01 00:00:00.000000000 +0000
+++ ./deploy/searxng/settings.yml 2026-08-25 19:03:31.940921490 +0000
@@ -0,0 +1,26 @@
+# Private SearXNG instance for NeuroForge research.
+# SEARXNG_SECRET and SEARXNG_BASE_URL override the corresponding server values.
+use_default_settings: true
+
+general:
+ debug: false
+ instance_name: "NeuroForge Research Search"
+
+search:
+ safe_search: 1
+ formats:
+ - html
+ - json
+
+server:
+ secret_key: "overridden-by-SEARXNG_SECRET"
+ limiter: false
+ public_instance: false
+ image_proxy: false
+ default_http_headers:
+ X-Robots-Tag: "noindex, nofollow"
+ Referrer-Policy: "no-referrer"
+
+outgoing:
+ request_timeout: 5.0
+ max_request_timeout: 15.0
diff -ruN '--exclude=MANIFEST.sha256' '--exclude=patches' '--exclude=*.zip' /mnt/data/glpi-neuroforge-mega-v1.1.0/docker-compose.yml ./docker-compose.yml
--- /mnt/data/glpi-neuroforge-mega-v1.1.0/docker-compose.yml 2026-08-25 16:06:06.000000000 +0000
+++ ./docker-compose.yml 2026-08-25 19:19:15.237095886 +0000
@@ -11,6 +11,26 @@
security_opt:
- no-new-privileges:true
+ searxng:
+ image: ${SEARXNG_IMAGE:-docker.io/searxng/searxng:latest}
+ profiles: ["research"]
+ restart: unless-stopped
+ environment:
+ SEARXNG_SECRET: ${SEARXNG_SECRET}
+ SEARXNG_BASE_URL: http://searxng:8080/
+ FORCE_OWNERSHIP: "false"
+ volumes:
+ - ./deploy/searxng/settings.yml:/etc/searxng/settings.yml:ro
+ - searxng-cache:/var/cache/searxng
+ ports:
+ - "127.0.0.1:${SEARXNG_HOST_PORT:-8888}:8080"
+ read_only: true
+ tmpfs:
+ - /tmp:size=64m,mode=1777
+ security_opt:
+ - no-new-privileges:true
+ cap_drop: ["ALL"]
+
neuroforge:
build:
context: ./platform/neuroforge
@@ -27,6 +47,15 @@
NEUROFORGE_OLLAMA_URL: http://ollama:11434
NEUROFORGE_OLLAMA_CHAT_MODEL: ${OLLAMA_MODEL:-gemma3}
NEUROFORGE_OLLAMA_EMBEDDING_MODEL: ${OLLAMA_EMBEDDING_MODEL:-embeddinggemma}
+ NEUROFORGE_CONTROLLED_LEARNING: ${NEUROFORGE_CONTROLLED_LEARNING:-true}
+ NEUROFORGE_RESEARCH_ENABLED: ${NEUROFORGE_RESEARCH_ENABLED:-false}
+ NEUROFORGE_SEARXNG_ENABLED: ${NEUROFORGE_SEARXNG_ENABLED:-false}
+ NEUROFORGE_SEARXNG_URL: ${NEUROFORGE_SEARXNG_URL:-http://searxng:8080}
+ NEUROFORGE_RESEARCH_GOAL_ENABLED: ${NEUROFORGE_RESEARCH_GOAL_ENABLED:-true}
+ NEUROFORGE_AUTONOMY_ENABLED: ${NEUROFORGE_AUTONOMY_ENABLED:-false}
+ NEUROFORGE_AUTONOMY_INTERVAL_MINUTES: ${NEUROFORGE_AUTONOMY_INTERVAL_MINUTES:-30}
+ NEUROFORGE_RESEARCH_MAX_QUERIES: ${NEUROFORGE_RESEARCH_MAX_QUERIES:-2}
+ NEUROFORGE_RESEARCH_MAX_PAGES: ${NEUROFORGE_RESEARCH_MAX_PAGES:-4}
ports:
- "127.0.0.1:${NEUROFORGE_HOST_PORT:-8090}:8080"
volumes:
@@ -92,6 +121,9 @@
NEUROFORGE_FAIL_OPEN: ${NEUROFORGE_FAIL_OPEN:-true}
BRAIN_ACTIVITY_URL: http://neuroforge:8080/api/v1/integrations/events
BRAIN_ACTIVITY_API_KEY: ${NEUROFORGE_APP_API_KEY}
+ OUTCOME_LEARNING_ENABLED: ${OUTCOME_LEARNING_ENABLED:-true}
+ OUTCOME_LEARNING_FAIL_OPEN: ${OUTCOME_LEARNING_FAIL_OPEN:-false}
+ OUTCOME_LEARNING_MAX_OUTCOMES: ${OUTCOME_LEARNING_MAX_OUTCOMES:-2000}
ports:
- "127.0.0.1:${AGENT_HOST_PORT:-8080}:8080"
volumes:
@@ -158,6 +190,11 @@
KNOWLEDGE_VECTOR_BACKEND: ${KNOWLEDGE_VECTOR_BACKEND:-dual}
NEUROFORGE_SEARCH_K: ${NEUROFORGE_SEARCH_K:-128}
NEUROFORGE_FAIL_OPEN: ${NEUROFORGE_FAIL_OPEN:-true}
+ NEUROFORGE_CONTROLLED_LEARNING: ${NEUROFORGE_CONTROLLED_LEARNING:-true}
+ OUTCOME_LEARNING_ENABLED: ${OUTCOME_LEARNING_ENABLED:-true}
+ NEUROFORGE_RESEARCH_ENABLED: ${NEUROFORGE_RESEARCH_ENABLED:-false}
+ NEUROFORGE_SEARXNG_ENABLED: ${NEUROFORGE_SEARXNG_ENABLED:-false}
+ NEUROFORGE_AUTONOMY_ENABLED: ${NEUROFORGE_AUTONOMY_ENABLED:-false}
PUBLIC_AGENT_URL: http://localhost:${AGENT_HOST_PORT:-8080}
PUBLIC_KNOWLEDGE_URL: http://localhost:${KNOWLEDGE_HOST_PORT:-8081}
PUBLIC_NEUROFORGE_URL: http://localhost:${NEUROFORGE_HOST_PORT:-8090}/admin
@@ -181,3 +218,4 @@
neuroforge-data:
agent-data:
ollama-data:
+ searxng-cache:
diff -ruN '--exclude=MANIFEST.sha256' '--exclude=patches' '--exclude=*.zip' /mnt/data/glpi-neuroforge-mega-v1.1.0/docs/ARCHITECTURE.md ./docs/ARCHITECTURE.md
--- /mnt/data/glpi-neuroforge-mega-v1.1.0/docs/ARCHITECTURE.md 2026-08-25 18:59:13.092472558 +0000
+++ ./docs/ARCHITECTURE.md 2026-08-25 19:14:47.048287348 +0000
@@ -20,6 +20,7 @@
│ HNSW / Disk-PQ │ │ read-only │
│ NFVJ2 + SQAR │ └──────────────┘
│ memory / research │
+ │ validated outcomes │
└─────────┬───────────┘
│ draft proposal only
▼
@@ -131,3 +132,45 @@
```
Diese Events sind Telemetrie/Audit, keine Policy-Eingaben. Beispiele sind `knowledge.search` sowie Synchronisationsereignisse.
+
+
+## Kontrolliertes Lernmodell (v1.2.0)
+
+### Human Outcome Gate
+
+```text
+Ticket -> AI proposal -> technician accept/correct
+ |
+ v
+ immutable local audit
+ | App Key
+ v
+ /api/v1/integrations/outcomes
+ |
+ v
+ trusted semantic outcome memory
+```
+
+Der Agent bestimmt nicht selbst die vertrauenswürdige Provenance. NeuroForge akzeptiert über diesen Pfad ausschließlich `accepted` und `corrected` und setzt `glpi.outcome.*` serverseitig. Eine spätere Korrektur wird als neue Outcome-Version mit `supersedes_id` geführt.
+
+### Optionaler Research-Layer
+
+```text
+ [compose profile: research]
+ SearXNG
+ |
+ v
+Goal/manual research -> Search -> Fetch -> Evidence
+ |
+ v
+ provenance + dedup +
+ independent corroboration
+ |
+ v
+ NeuroForge Memory
+ |
+ v
+ KB staging only
+```
+
+Research-Infrastruktur und zeitgesteuerte Autonomie sind getrennt. `NEUROFORGE_AUTONOMY_ENABLED=false` verhindert selbstlaufende Goal-Cycles auch dann, wenn SearXNG und manuelles Research aktiv sind.
diff -ruN '--exclude=MANIFEST.sha256' '--exclude=patches' '--exclude=*.zip' /mnt/data/glpi-neuroforge-mega-v1.1.0/docs/CONTROL-CENTER.md ./docs/CONTROL-CENTER.md
--- /mnt/data/glpi-neuroforge-mega-v1.1.0/docs/CONTROL-CENTER.md 2026-08-25 18:59:12.636471584 +0000
+++ ./docs/CONTROL-CENTER.md 2026-08-25 19:14:47.047919399 +0000
@@ -18,3 +18,15 @@
## Erweiterungsregel
Falls zentrale Aktionen später direkt im Control Center benötigt werden, sollten sie als einzelne delegierte Operationen mit eigenem Scope, Audit-Eintrag und expliziter Bestätigung implementiert werden. Die Admin-Credentials der Zielsysteme sollen nicht pauschal im Control Center hinterlegt werden.
+
+
+## v1.2.0: Controlled-Autonomy-Status
+
+Das Control Center zeigt zusätzlich die effektiven Stack-Schalter für:
+
+- Controlled Learning
+- Outcome Learning
+- Research/SearXNG
+- Autonomy
+
+Diese Anzeigen sind bewusst nur Beobachtung. Das Aktivieren von Research oder Autonomy erfolgt über Betreiberkonfiguration/Compose bzw. NeuroForge-Admin, nicht über einen globalen Super-Admin-Schalter im Control Center.
diff -ruN '--exclude=MANIFEST.sha256' '--exclude=patches' '--exclude=*.zip' /mnt/data/glpi-neuroforge-mega-v1.1.0/docs/CONTROL-MATRIX.md ./docs/CONTROL-MATRIX.md
--- /mnt/data/glpi-neuroforge-mega-v1.1.0/docs/CONTROL-MATRIX.md 2026-08-25 18:36:09.218643097 +0000
+++ ./docs/CONTROL-MATRIX.md 2026-08-25 19:14:47.047439932 +0000
@@ -14,6 +14,10 @@
| NeuroForge Secrets lesen/rotieren | nein | nein | nein | ja | nein |
| Systemstatus lesen | eigene Readiness | eigene Health | Stats mit App Key | ja | aggregiert read-only |
| Obsidian-Export | Live-Sicht inkl. GLPI-Relations | kanonische KB | nein | nein | verlinkt Ziel-UI |
+| Human Outcome erfassen | ja, authentifizierter Techniker | nein | empfängt nur validated outcome | sichtbar/admin | Status read-only |
+| Trusted Outcome-Source setzen | nein | nein | **serverseitig fest** | ja | nein |
+| SearXNG Research | nein | nein | Research Engine via SearXNG | konfigurierbar | Status read-only |
+| Autonomy aktivieren | nein | nein | nein | Betreiber/Admin bzw. Env | Status read-only |
## Credentials
@@ -24,6 +28,7 @@
- `KB_INTEGRATION_TOKEN`: ausschließlich maschineller Staging-Ingress.
- `BASIC_AUTH_USER/PASSWORD`: Knowledgebase-Editor.
- `WEB_USERNAME/PASSWORD`: Agent-Webzugang.
+- `SEARXNG_SECRET`: nur optionaler SearXNG-Container/Betreiber.
- GLPI-Credentials: ausschließlich Agent.
## Failure-Policy
@@ -35,6 +40,15 @@
| `neuroforge` + fail-open | Fehler wird geloggt | lokale/lexikalische Evidenz soweit verfügbar |
| `neuroforge` + fail-closed | Fehler wird propagiert | semantischer Schritt blockiert kontrolliert |
+## Outcome-Learning Failure-Policy
+
+| Einstellung | NeuroForge-Sync nach Technikerentscheidung | Verhalten |
+|---|---|---|
+| `OUTCOME_LEARNING_ENABLED=false` | nicht ausgeführt | kein Outcome-Learning |
+| enabled + `FAIL_OPEN=false` | Fehler | lokaler Audit bleibt `failed`, UI meldet Fehler |
+| enabled + `FAIL_OPEN=true` | Fehler | lokaler Audit bleibt `failed`, Workflow darf fortfahren |
+| enabled + Sync OK | Erfolg | Audit `learned` + NeuroForge Memory-ID |
+
## Nicht lernende Kontrollinformationen
Folgende Informationen bleiben absichtlich außerhalb des NeuroForge-Learnings:
diff -ruN '--exclude=MANIFEST.sha256' '--exclude=patches' '--exclude=*.zip' /mnt/data/glpi-neuroforge-mega-v1.1.0/docs/CONTROLLED-AUTONOMY.md ./docs/CONTROLLED-AUTONOMY.md
--- /mnt/data/glpi-neuroforge-mega-v1.1.0/docs/CONTROLLED-AUTONOMY.md 1970-01-01 00:00:00.000000000 +0000
+++ ./docs/CONTROLLED-AUTONOMY.md 2026-08-25 19:19:15.237666158 +0000
@@ -0,0 +1,123 @@
+# Kontrollierte Autonomie und Outcome-gated Learning
+
+Stand: v1.2.0
+
+## Ziel
+
+NeuroForge soll recherchieren und lernen können, ohne KI-Ausgaben automatisch mit bestätigtem Betriebswissen gleichzusetzen. Der Release trennt deshalb drei Vertrauensklassen:
+
+| Klasse | Beispiele | Standard-Trust | Freigabe |
+|---|---|---:|---|
+| Rohes Modell-/Chat-Signal | `chat.input`, `chat.response` | 0.25 / 0.20 im Controlled Mode | kein automatisches Langzeitlernen |
+| Quellengebundene Research-Evidence | `web.search`, `web.page` | 0.45 / 0.60 | Provenance + Dedup + unabhängige Corroboration |
+| Menschlich validiertes Helpdesk-Outcome | `glpi.outcome.accepted`, `glpi.outcome.corrected` | 1.00 | explizite Technikeraktion |
+
+Die Werte sind eine Ranking-/Learning-Policy, keine Behauptung absoluter Wahrheit. Auch menschlich bestätigtes Wissen bleibt mit Ticket, Run, Actor und Outcome-ID nachvollziehbar.
+
+## Helpdesk-Lernpfad
+
+```text
+GLPI Ticket
+ |
+ v
+Agent analysiert + erzeugt Antwortvorschlag
+ |
+ v
+Techniker prüft
+ |--------------------|
+ v v
+bestätigt korrigiert
+ | |
+ +---------+----------+
+ v
+ lokales Outcome-Audit
+ |
+ v
+ POST /api/v1/integrations/outcomes
+ |
+ v
+ NeuroForge Semantic Memory
+```
+
+Vor dem Persistieren liest der Agent bei aktuellen Runs den Ticketzustand erneut aus GLPI und vergleicht ihn mit `SourceVersion`. Hat sich der entscheidungsrelevante Ticketzustand geändert, wird die Validierung blockiert und ein neuer Agent-Run verlangt.
+
+Nur `accepted` und `corrected` sind zulässig. Der Client kann die vertrauenswürdige Source nicht frei setzen; NeuroForge erzeugt serverseitig `glpi.outcome.accepted` bzw. `glpi.outcome.corrected`.
+
+### Audit und Revisionen
+
+`services/agent` speichert Entscheidungen in `DATA_DIR/ticket-outcomes.json`:
+
+- `pending`: lokal erfasst, Sync noch offen
+- `learned`: NeuroForge hat eine Memory-ID bestätigt
+- `failed`: Entscheidung bleibt erhalten, Remote-Sync ist fehlgeschlagen
+- `supersedes_id`: verweist bei einer späteren Korrektur/Neubewertung auf den vorigen Outcome
+
+Eine exakt wiederholte Entscheidung ist idempotent. Bereits erfolgreich gelernte Outcomes werden nicht ein zweites Mal an NeuroForge gesendet. Ein `failed`-Outcome kann dagegen bewusst erneut synchronisiert werden.
+
+`OUTCOME_LEARNING_FAIL_OPEN=false` ist der kontrollierte Standard: Ein Remote-Fehler wird dem Techniker sichtbar zurückgegeben. `true` ist nur sinnvoll, wenn lokale Audit-Erfassung wichtiger ist als sofortige zentrale Konsistenz.
+
+## Controlled Learning
+
+`NEUROFORGE_CONTROLLED_LEARNING=true` setzt beim Serverstart eine konservative Policy:
+
+- `learn_chat_inputs=false`
+- `learn_chat_responses=false`
+- `allow_explicit_learn=true`
+- `allow_imports=false`
+- `learn_goal_cycles=false`
+- höhere Mindestanforderungen für semantische Konsolidierung
+- niedriger Trust für Web-/Chat-Signale
+- maximaler Trust für explizite GLPI-Outcomes
+
+Damit ist „das Modell hat es gesagt“ kein Lernsignal. Lernen braucht entweder einen expliziten, kontrollierten API-Pfad oder quellengebundene Evidence.
+
+## Research und SearXNG
+
+SearXNG ist im Root-Compose als Profil `research` definiert und wird im normalen `docker compose up` nicht gestartet.
+
+### Research manuell freischalten
+
+1. In `.env` einen zufälligen `SEARXNG_SECRET` setzen. Für reproduzierbare Produktion `SEARXNG_IMAGE` auf einen freigegebenen Tag oder Digest pinnen.
+2. Research starten:
+
+```bash
+./scripts/research-up.sh
+```
+
+Das Script aktiviert für diesen Compose-Aufruf:
+
+```text
+NEUROFORGE_RESEARCH_ENABLED=true
+NEUROFORGE_SEARXNG_ENABLED=true
+```
+
+Es aktiviert **nicht** automatisch `NEUROFORGE_AUTONOMY_ENABLED`.
+
+### Autonomie bewusst separat aktivieren
+
+Für zeitgesteuerte, selbstinitiierte Goal-Cycles zusätzlich in `.env`:
+
+```text
+NEUROFORGE_AUTONOMY_ENABLED=true
+```
+
+Ein Goal muss zusätzlich `auto_run`/Research erlauben. Damit sind die infrastrukturelle Suchfähigkeit, manuelles Research und zyklische Autonomie getrennt kontrollierbar.
+
+## Research-Vertrauen
+
+Research-Inhalte werden als untrusted external data behandelt. NeuroForge hält Source-URI, Source-ID, Hash, Retrieval-Zeitpunkt und Evidence-Quellen fest. Ähnliche Evidence aus einer weiteren unabhängigen Source erhöht `EvidenceCount` und Confidence über die Corroboration-Logik, statt einen einzelnen Treffer sofort auf Trust 1.0 zu setzen.
+
+Research darf außerdem nicht direkt produktive Knowledge-Artikel veröffentlichen. Der vorhandene Maschinenpfad endet beim token-geschützten KB-Staging; `auto_reply=false` wird dort serverseitig erzwungen. Promotion bleibt eine menschliche Entscheidung.
+
+## Empfohlener Produktionsmodus
+
+```text
+NEUROFORGE_CONTROLLED_LEARNING=true
+OUTCOME_LEARNING_ENABLED=true
+OUTCOME_LEARNING_FAIL_OPEN=false
+NEUROFORGE_RESEARCH_ENABLED=false
+NEUROFORGE_SEARXNG_ENABLED=false
+NEUROFORGE_AUTONOMY_ENABLED=false
+```
+
+Research anschließend gezielt aktivieren, beobachten und erst danach – falls gewünscht – Autonomy einschalten.
diff -ruN '--exclude=MANIFEST.sha256' '--exclude=patches' '--exclude=*.zip' /mnt/data/glpi-neuroforge-mega-v1.1.0/docs/IMPLEMENTED.md ./docs/IMPLEMENTED.md
--- /mnt/data/glpi-neuroforge-mega-v1.1.0/docs/IMPLEMENTED.md 2026-08-25 16:07:19.000000000 +0000
+++ ./docs/IMPLEMENTED.md 2026-08-25 19:18:11.456671151 +0000
@@ -20,6 +20,12 @@
- Research-Drafts können nicht produktiv schreiben und erzwingen `auto_reply=false`
- getrennte Secrets für Admin, App, Worker, Metrics und KB-Staging-Integration
- Tests für Namespace-Isolation, Lifecycle, Fail-open/fail-closed und Staging-Governance
+- optionaler SearXNG-Service als Compose-Profil `research`
+- Controlled-Learning-Bootstrap ohne automatisches Chat-Input/Assistant-Output-Lernen
+- Human-Outcome-Learning (`accepted`/`corrected`) über separaten App-Key-Endpunkt
+- lokales Outcome-Audit mit `pending|learned|failed`, Retry und unveränderlicher Revisionskette
+- Stale-Run-Schutz gegen Lernen aus überholten GLPI-Ticketzuständen
+- getrennte Schalter für Research/SearXNG und zeitgesteuerte Autonomie
## Bewusst nicht automatisiert
diff -ruN '--exclude=MANIFEST.sha256' '--exclude=patches' '--exclude=*.zip' /mnt/data/glpi-neuroforge-mega-v1.1.0/docs/MIGRATION-MANIFEST.md ./docs/MIGRATION-MANIFEST.md
--- /mnt/data/glpi-neuroforge-mega-v1.1.0/docs/MIGRATION-MANIFEST.md 2026-08-25 18:37:16.027064187 +0000
+++ ./docs/MIGRATION-MANIFEST.md 2026-08-25 19:19:43.741936055 +0000
@@ -55,3 +55,31 @@
- Neuer CLI-Helfer: `scripts/export-obsidian.sh`.
- Control-Center-Sicherheitsgrenze und delegierte Interaktionswege sind in `docs/CONTROL-CENTER.md` dokumentiert.
- Repräsentativer Export-Snapshot liegt unter `exports/knowledge-obsidian-snapshot.zip`.
+
+## Version 1.2.0 – Controlled Autonomy
+
+### NeuroForge
+
+- `internal/httpapi/outcomes.go` – schmaler App-Key-Pfad für menschlich validierte Ticket-Outcomes
+- `internal/brain/brain.go` – interne, nicht vom JSON-Client spoofbare Trusted-Provenance-Felder
+- `cmd/server/main.go` – Controlled-Learning- und Research-Bootstrap per Environment
+- `deploy/learning-policy.example.json` – konservative Source-Trust-/Learning-Policy
+
+### GLPI AI Agent
+
+- `internal/learning/outcomes.go` – persistentes Outcome-Audit, Sync-Status, Revisionen und NeuroForge-Sink
+- `internal/agent/agent.go` – Stale-Run-Prüfung und Outcome-gated Learning
+- `internal/web/server.go` / Dashboard – Bestätigen/Korrigieren und Audit-Sicht
+- neue Outcome-Learning-Konfiguration mit explizitem fail-open/fail-closed
+
+### Mega Platform
+
+- `searxng` als optionaler Compose-Profilservice `research`
+- `deploy/searxng/settings.yml`
+- `scripts/research-up.sh`
+- separate Research-, SearXNG- und Autonomy-Schalter
+- Control Center zeigt diese Betriebsmodi read-only
+- `docs/CONTROLLED-AUTONOMY.md`
+- `docs/MIGRATION-v1.1.0-to-v1.2.0.md`
+- `RELEASE-NOTES-v1.2.0.md`
+- `patches/v1.1.0-to-v1.2.0.diff`
diff -ruN '--exclude=MANIFEST.sha256' '--exclude=patches' '--exclude=*.zip' /mnt/data/glpi-neuroforge-mega-v1.1.0/docs/MIGRATION-v1.1.0-to-v1.2.0.md ./docs/MIGRATION-v1.1.0-to-v1.2.0.md
--- /mnt/data/glpi-neuroforge-mega-v1.1.0/docs/MIGRATION-v1.1.0-to-v1.2.0.md 1970-01-01 00:00:00.000000000 +0000
+++ ./docs/MIGRATION-v1.1.0-to-v1.2.0.md 2026-08-25 19:14:29.925280510 +0000
@@ -0,0 +1,44 @@
+# Migration v1.1.0 → v1.2.0
+
+## 1. Neue Secrets übernehmen
+
+```bash
+./scripts/generate-secrets.sh
+```
+
+Zusätzlich wird `SEARXNG_SECRET` ausgegeben. SearXNG ist optional; der Secret wird erst für das `research`-Profil benötigt.
+
+## 2. Controlled Learning prüfen
+
+Empfohlen:
+
+```text
+NEUROFORGE_CONTROLLED_LEARNING=true
+OUTCOME_LEARNING_ENABLED=true
+OUTCOME_LEARNING_FAIL_OPEN=false
+```
+
+Bestehende NeuroForge-Daten werden nicht gelöscht. Der Modus ändert, welche neuen Signale automatisch gelernt werden.
+
+## 3. Human Outcome Flow verwenden
+
+Neue Agent-Runs speichern den für Learning benötigten Ticket-/Reply-Snapshot. Alte Runs aus v1.1.0 können deshalb bewusst nicht nachträglich als validiertes Outcome gelernt werden, wenn dieser Snapshot fehlt.
+
+Im Agent-Dashboard den Run öffnen und **KI-Antwort bestätigen** bzw. **KI-Antwort korrigieren** verwenden.
+
+## 4. Research optional starten
+
+```bash
+./scripts/research-up.sh
+```
+
+Das startet das Compose-Profil `research` und aktiviert SearXNG/Research für den NeuroForge-Start. Zyklische Autonomie bleibt separat deaktiviert, solange `NEUROFORGE_AUTONOMY_ENABLED=false` ist.
+
+## 5. Rollback
+
+- SearXNG stoppen: `docker compose --profile research stop searxng`
+- Research deaktivieren: `NEUROFORGE_RESEARCH_ENABLED=false`, `NEUROFORGE_SEARXNG_ENABLED=false`
+- Autonomy deaktivieren: `NEUROFORGE_AUTONOMY_ENABLED=false`
+- Outcome Learning deaktivieren: `OUTCOME_LEARNING_ENABLED=false`
+
+Das lokale Outcome-Audit und bereits gelernte Memories werden dadurch nicht gelöscht.
diff -ruN '--exclude=MANIFEST.sha256' '--exclude=patches' '--exclude=*.zip' /mnt/data/glpi-neuroforge-mega-v1.1.0/docs/OPERATIONS.md ./docs/OPERATIONS.md
--- /mnt/data/glpi-neuroforge-mega-v1.1.0/docs/OPERATIONS.md 2026-08-25 18:36:09.218732381 +0000
+++ ./docs/OPERATIONS.md 2026-08-25 19:18:11.456856250 +0000
@@ -7,6 +7,8 @@
make vet
make build
make up
+# optional: Research/SearXNG ohne Autonomy
+make research-up
make ps
make status
make logs
@@ -81,3 +83,20 @@
## SQAR
SQAR ist ausschließlich im NeuroForge Vector Journal aktiviert. Nicht komprimiert werden operative Audit-/Policy-Dateien oder zufällig zugreifbare Memory-Segmente. Der Codec wählt nur dann die SQAR-Variante, wenn sie gegenüber der Roh-/DEFLATE-Darstellung tatsächlich kleiner ist.
+
+
+## Controlled Learning / Human Outcomes
+
+Im Standard ist `NEUROFORGE_CONTROLLED_LEARNING=true`. Rohe Chat-/Assistant-Inhalte werden damit nicht automatisch als Langzeitwissen gelernt. Ein Agent-Run kann im Dashboard explizit bestätigt oder korrigiert werden. Das Outcome wird unter `DATA_DIR/ticket-outcomes.json` auditiert und erst dann über den App-Key-Pfad an NeuroForge übertragen.
+
+Bei `OUTCOME_LEARNING_FAIL_OPEN=false` ist ein NeuroForge-Syncfehler für den Techniker sichtbar. Der lokale Outcome-Eintrag bleibt erhalten und kann durch Wiederholen derselben Entscheidung retryt werden. Änderungen am GLPI-Ticket seit dem analysierten Run blockieren die Validierung.
+
+## Optionales SearXNG / Research
+
+Der Basisstack startet SearXNG nicht. Für Research zuerst einen echten `SEARXNG_SECRET` in `.env` setzen und dann:
+
+```bash
+./scripts/research-up.sh
+```
+
+Das startet das Compose-Profil `research` und schaltet Research/SearXNG für NeuroForge ein. `NEUROFORGE_AUTONOMY_ENABLED` bleibt separat und standardmäßig `false`. Details: [`CONTROLLED-AUTONOMY.md`](CONTROLLED-AUTONOMY.md).
diff -ruN '--exclude=MANIFEST.sha256' '--exclude=patches' '--exclude=*.zip' /mnt/data/glpi-neuroforge-mega-v1.1.0/docs/VALIDATION.md ./docs/VALIDATION.md
--- /mnt/data/glpi-neuroforge-mega-v1.1.0/docs/VALIDATION.md 2026-08-25 18:59:12.676471669 +0000
+++ ./docs/VALIDATION.md 2026-08-25 19:17:56.193755539 +0000
@@ -1,99 +1,113 @@
# Validierung
-Stand: 25.08.2026
+Stand: 25.08.2026 — Release v1.2.0
## Umfang
- 4 Go-Module im gemeinsamen `go.work`
-- 43.890 Go-Codezeilen inklusive Tests
-- 257 `Test...`-Testfunktionen
+- 150 Go-Dateien
+- 44.761 Go-Codezeilen inklusive Tests
+- 263 `Test...`-Testfunktionen
- 103 produktive Knowledge-JSON-Dateien im gemeinsamen `knowledge/`
+- 8 Compose-Services inklusive optionalem `searxng`-Profilservice
-## Erfolgreich ausgeführt
+## Vollständige Modulprüfung
+
+`./scripts/validate.sh` wurde erfolgreich ausgeführt:
```text
-./scripts/validate.sh
- - go test ./... platform/neuroforge OK
- - go vet ./... platform/neuroforge OK
- - go test ./... services/agent OK
- - go vet ./... services/agent OK
- - go test ./... services/knowledge OK
- - go vet ./... services/knowledge OK
- - go test ./... services/control OK
- - go vet ./... services/control OK
+platform/neuroforge go test ./... OK
+platform/neuroforge go vet ./... OK
+services/agent go test ./... OK
+services/agent go vet ./... OK
+services/knowledge go test ./... OK
+services/knowledge go vet ./... OK
+services/control go test ./... OK
+services/control go vet ./... OK
+scripts/*.sh sh -n OK
```
-Race-Checks:
+Alle vier Module wurden danach zusätzlich mit `go build ./...` gebaut: **OK**.
+
+## Race-Checks
```text
services/agent:
- go test -race ./internal/glpi ./internal/glpikb ./internal/knowledge ./internal/obsidian OK
+ go test -race ./internal/learning ./internal/agent ./internal/web ./internal/knowledge OK
platform/neuroforge:
- go test -race ./internal/httpapi ./internal/store OK
+ go test -race ./internal/httpapi ./internal/store ./internal/brain OK
services/knowledge:
- go test -race ./cmd/server ./internal/obsidian ./internal/staging OK
-```
-
-Builds:
-
-```text
-go build ./... platform/neuroforge OK
-go build ./... services/agent OK
-go build ./... services/knowledge OK
-go build ./... services/control OK
+ go test -race ./cmd/server ./internal/staging ./internal/store ./internal/obsidian OK
```
-Zusätzliche Prüfungen:
+## Controlled-Autonomy-spezifische Prüfungen
-- Docker-Compose-YAML statisch geparst: **OK**
-- gemeinsame Knowledgebase im echten KB-Server gestartet: **OK**
-- 103 Knowledge-Dateien geladen: **OK**
-- `/api/health`: **OK**
-- Integration-Draft landet nur in Staging: **OK**
-- Integration-Draft erzwingt `auto_reply=false`: **OK**
-- Editor-Basic-Auth bleibt für normale Editor-APIs aktiv: **OK**
-- Research-/Integration-Client benötigt keine Editor-Credentials: **OK**
-- NeuroForge Integration API verlangt App-Key: **OK**
-- Namespace-Isolation bei semantischer Suche: **OK**
-- Knowledge Upsert/Update/Delete Lifecycle: **OK**
-- Agent fail-open/fail-closed Verhalten: **OK**
-- Obsidian-Export der realen 103 kanonischen Knowledge-Dateien: **OK**
-- erzeugter Vault: **209 Dateien**, ZIP-Integrität **OK**
-- Manifest: 103 Knowledge-Dokumente, 102 Kategorien, 102 explizite Kanten im kanonischen Snapshot
-- GLPI `KnowbaseItem_Item` OpenAPI-Discovery/Parsing: **OK (Testfixture)**
-- GLPI-KB-Sync übernimmt `linked_items`: **OK (Testfixture)**
-- Agent-Live-Obsidian-Export mit GLPI-Entity-Wikilinks: **OK**
-- Compose-YAML statisch geparst: **OK (7 Services)**
-- Shell-Syntax aller `scripts/*.sh`: **OK**
+Automatisierte Tests und statische Prüfungen decken insbesondere ab:
-## Nicht ausführbar in dieser Umgebung
+- App-Key-geschützter `POST /api/v1/integrations/outcomes`: **OK**
+- nur `accepted`/`corrected` als validierte Outcome-Entscheidung: **OK**
+- serverseitig gesetzte Provenance `glpi.outcome.accepted|corrected`: **OK**
+- Human Outcome wird lokal auditiert und mit NeuroForge Memory-ID synchronisiert: **OK**
+- Korrektur erzeugt neue unveränderliche Revision via `supersedes_id`: **OK**
+- exakt wiederholte, bereits gelernte Entscheidung ist idempotent: **OK**
+- fehlgeschlagene Sync-Entscheidung bleibt lokal als `failed` erhalten und ist retry-fähig: **OK**
+- veralteter Agent-Run wird bei verändertem GLPI-Ticketzustand nicht als Trusted Outcome gelernt: **OK**
+- Controlled Learning deaktiviert automatisches Chat-Input/Assistant-Output-Learning beim Mega-Stack-Bootstrap: Code/Vet **OK**
+- Web-Research und Human Outcomes besitzen getrennte Source-Provenance/Trust-Stufen: **OK**
+- SearXNG-Service liegt ausschließlich im Compose-Profil `research`: YAML-Prüfung **OK**
+- SearXNG-Settings aktivieren JSON-Suchergebnisse: YAML-Prüfung **OK**
+- `research-up.sh` startet exakt `--profile research ... searxng neuroforge neuroforge-worker`: Fake-Docker-Smoke-Test **OK**
+- `research-up.sh` aktiviert Research/SearXNG, nicht automatisch Autonomy: **OK**
+- Control-Center-JavaScript und Agent-Dashboard-JavaScript mit `node --check`: **OK**
+
+## Bereits erhaltene Plattform-Funktionen
+
+Die bestehenden Tests decken weiterhin unter anderem ab:
+
+- Namespace-Isolation der NeuroForge Knowledge API
+- Knowledge Upsert/Update/Delete und Vector-Journal-Lifecycle
+- Agent `local|dual|neuroforge` und fail-open/fail-closed
+- GLPI Polling/Webhook/Followup/Kategorie/Priorität/Eskalation
+- GLPI-KB-Sync und `KnowbaseItem_Item`-Parsing über Testfixtures
+- Obsidian-Export mit Frontmatter, Wikilinks und Graphdaten
+- KB-Staging-Ingress ohne produktive Schreibrechte und mit erzwungenem `auto_reply=false`
+
+## Statische Compose-Prüfung
+
+Da Docker in der Prüfungsumgebung nicht installiert ist, konnte kein `docker compose config` oder echter Containerstart ausgeführt werden. Die YAML-Dateien wurden stattdessen programmgesteuert geparst und strukturell geprüft:
+
+- Root-Compose parsebar: **OK**
+- 8 Services erkannt: **OK**
+- `searxng.profiles == ["research"]`: **OK**
+- `deploy/searxng/settings.yml` parsebar: **OK**
+- JSON-Format für SearXNG-Suche vorhanden: **OK**
+
+## Nicht als getestet behauptet
+
+In dieser Umgebung wurden **nicht** ausgeführt:
+
+- echter `docker compose up`
+- echter SearXNG-Container gegen das Internet
+- Live-Research gegen öffentliche Webseiten
+- Live-Zugriff auf die Betreiber-GLPI-Instanz
-Docker ist in der bereitgestellten Prüfungsumgebung nicht installiert. Deshalb wurde **kein echter Container-Runtime-Test mit `docker compose up`** behauptet oder durchgeführt. Die Compose-Datei wurde statisch validiert; alle darin gebauten Go-Komponenten wurden separat erfolgreich gebaut und getestet.
+Das Projekt enthält bewusst keine produktiven GLPI-Credentials oder sonstigen Betreiber-Secrets.
-Vor Produktion ist daher noch ein Host-/CI-Smoke-Test sinnvoll:
+## Empfohlener Host-/CI-Smoke-Test
```bash
+cp .env.example .env
+# echte Secrets + GLPI-Credentials setzen
+
docker compose config
docker compose up -d --build
./scripts/status.sh
-```
-
-## Sicherheitsrelevante Testfälle
-Neu hinzugefügte Tests prüfen insbesondere:
-
-- App-Key-Pflicht der NeuroForge Knowledge API
-- keine Treffer über Namespace-Grenzen
-- batchweisen Replace/Delete-Lifecycle
-- Remote-Semantik als Evidenz statt Policy
-- lokales Fallback nur entsprechend `NEUROFORGE_FAIL_OPEN`
-- fail-closed propagiert Backend-Fehler
-- Research-Ingress kann Produktions-KB nicht schreiben
-- Research-Ingress kann `auto_reply` nicht aktivieren
-- Basic-Auth-Bypass gilt nur für Health und den separat token-authentifizierten Staging-Ingress
-
-## Externe GLPI-Live-Prüfung
+# optional: Research-Infrastruktur
+./scripts/research-up.sh
+curl -fsS 'http://127.0.0.1:8888/search?q=neuroforge&format=json' >/dev/null
+```
-Die GLPI-Integration ist im Code, in den vorhandenen Agent-Tests und in den neuen `KnowbaseItem_Item`-Testfixtures validiert. Ein Zugriff auf eine reale GLPI-Installation wurde nicht durchgeführt, da dem Projektarchiv bewusst keine produktiven GLPI-Credentials beiliegen. Der Live-Cutover muss deshalb mit den Betreiber-Credentials gegen die Zielinstanz geprüft werden.
+Autonomy anschließend nur bewusst und separat über `NEUROFORGE_AUTONOMY_ENABLED=true` einschalten.
diff -ruN '--exclude=MANIFEST.sha256' '--exclude=patches' '--exclude=*.zip' /mnt/data/glpi-neuroforge-mega-v1.1.0/mega-project.json ./mega-project.json
--- /mnt/data/glpi-neuroforge-mega-v1.1.0/mega-project.json 2026-08-25 18:37:16.026596262 +0000
+++ ./mega-project.json 2026-08-25 19:18:37.193442216 +0000
@@ -26,5 +26,28 @@
"schema": "Wiki/Schema.md",
"glpi_relations": "KnowbaseItem_Item when exposed by GLPI OpenAPI"
},
- "version": "1.1.0"
+ "version": "1.2.0",
+ "controlled_learning": {
+ "raw_chat_auto_learning": false,
+ "validated_outcomes": [
+ "accepted",
+ "corrected"
+ ],
+ "outcome_endpoint": "/api/v1/integrations/outcomes",
+ "research_evidence_trust": {
+ "web.search": 0.45,
+ "web.page": 0.6
+ },
+ "human_outcome_trust": 1.0,
+ "stale_run_guard": true,
+ "immutable_outcome_revisions": true,
+ "failed_sync_retry": true
+ },
+ "optional_research": {
+ "compose_profile": "research",
+ "service": "searxng",
+ "autonomy_default": false,
+ "research_default": false,
+ "separate_autonomy_switch": true
+ }
}
diff -ruN '--exclude=MANIFEST.sha256' '--exclude=patches' '--exclude=*.zip' /mnt/data/glpi-neuroforge-mega-v1.1.0/platform/neuroforge/cmd/server/main.go ./platform/neuroforge/cmd/server/main.go
--- /mnt/data/glpi-neuroforge-mega-v1.1.0/platform/neuroforge/cmd/server/main.go 2026-08-25 15:58:16.000000000 +0000
+++ ./platform/neuroforge/cmd/server/main.go 2026-08-25 19:00:19.000605695 +0000
@@ -10,6 +10,8 @@
"os"
"os/signal"
"path/filepath"
+ "strconv"
+ "strings"
"syscall"
"time"
@@ -21,6 +23,30 @@
"neuroforge/internal/store"
)
+func envBool(name string) (bool, bool) {
+ raw, ok := os.LookupEnv(name)
+ if !ok {
+ return false, false
+ }
+ v, err := strconv.ParseBool(strings.TrimSpace(raw))
+ if err != nil {
+ return false, false
+ }
+ return v, true
+}
+
+func envInt(name string) (int, bool) {
+ raw, ok := os.LookupEnv(name)
+ if !ok {
+ return 0, false
+ }
+ v, err := strconv.Atoi(strings.TrimSpace(raw))
+ if err != nil {
+ return 0, false
+ }
+ return v, true
+}
+
func main() {
if err := run(); err != nil {
log.Printf("fatal: %v", err)
@@ -84,6 +110,71 @@
}
}
+ // Controlled-learning and research bootstrap for the mega-project. These
+ // values are only applied when the corresponding environment variable is
+ // explicitly present, preserving persisted admin settings otherwise.
+ if controlled, ok := envBool("NEUROFORGE_CONTROLLED_LEARNING"); ok && controlled {
+ cfg := s.Config()
+ cfg.Brain.AutoLearn = true
+ cfg.Brain.LearningPolicy.Enabled = true
+ cfg.Brain.LearningPolicy.LearnChatInputs = false
+ cfg.Brain.LearningPolicy.LearnChatResponses = false
+ cfg.Brain.LearningPolicy.AllowExplicitLearn = true
+ cfg.Brain.LearningPolicy.AllowImports = false
+ cfg.Brain.LearningPolicy.LearnGoalCycles = false
+ if cfg.Brain.LearningPolicy.MinConfidence < 0.35 {
+ cfg.Brain.LearningPolicy.MinConfidence = 0.35
+ }
+ if cfg.Brain.LearningPolicy.SemanticMinConfirmations < 3 {
+ cfg.Brain.LearningPolicy.SemanticMinConfirmations = 3
+ }
+ if cfg.Brain.LearningPolicy.SemanticMinConfidence < 0.65 {
+ cfg.Brain.LearningPolicy.SemanticMinConfidence = 0.65
+ }
+ if cfg.Brain.LearningPolicy.SourceTrust == nil {
+ cfg.Brain.LearningPolicy.SourceTrust = map[string]float64{}
+ }
+ cfg.Brain.LearningPolicy.SourceTrust["chat.input"] = 0.25
+ cfg.Brain.LearningPolicy.SourceTrust["chat.response"] = 0.20
+ cfg.Brain.LearningPolicy.SourceTrust["web.search"] = 0.45
+ cfg.Brain.LearningPolicy.SourceTrust["web.page"] = 0.60
+ cfg.Brain.LearningPolicy.SourceTrust["glpi.outcome.accepted"] = 1.0
+ cfg.Brain.LearningPolicy.SourceTrust["glpi.outcome.corrected"] = 1.0
+ if err := s.UpdateConfig(cfg); err != nil {
+ return fmt.Errorf("apply controlled learning bootstrap: %w", err)
+ }
+ }
+ if _, hasResearch := os.LookupEnv("NEUROFORGE_RESEARCH_ENABLED"); hasResearch {
+ cfg := s.Config()
+ if v, ok := envBool("NEUROFORGE_RESEARCH_ENABLED"); ok {
+ cfg.Research.Enabled = v
+ }
+ if v, ok := envBool("NEUROFORGE_SEARXNG_ENABLED"); ok {
+ cfg.Research.SearXNG.Enabled = v
+ }
+ if v := strings.TrimSpace(os.Getenv("NEUROFORGE_SEARXNG_URL")); v != "" {
+ cfg.Research.SearXNG.BaseURL = v
+ }
+ if v, ok := envBool("NEUROFORGE_AUTONOMY_ENABLED"); ok {
+ cfg.Autonomy.Enabled = v
+ }
+ if v, ok := envBool("NEUROFORGE_RESEARCH_GOAL_ENABLED"); ok {
+ cfg.Research.Goal.Enabled = v
+ }
+ if v, ok := envInt("NEUROFORGE_AUTONOMY_INTERVAL_MINUTES"); ok && v > 0 {
+ cfg.Autonomy.IntervalMinutes = v
+ }
+ if v, ok := envInt("NEUROFORGE_RESEARCH_MAX_QUERIES"); ok && v > 0 {
+ cfg.Research.Goal.MaxQueriesPerCycle = v
+ }
+ if v, ok := envInt("NEUROFORGE_RESEARCH_MAX_PAGES"); ok && v >= 0 {
+ cfg.Research.Goal.MaxPagesPerCycle = v
+ }
+ if err := s.UpdateConfig(cfg); err != nil {
+ return fmt.Errorf("apply research environment bootstrap: %w", err)
+ }
+ }
+
r := provider.NewRouter(s)
c := cost.New(s)
b := brain.New(s, r, c)
diff -ruN '--exclude=MANIFEST.sha256' '--exclude=patches' '--exclude=*.zip' /mnt/data/glpi-neuroforge-mega-v1.1.0/platform/neuroforge/deploy/learning-policy.example.json ./platform/neuroforge/deploy/learning-policy.example.json
--- /mnt/data/glpi-neuroforge-mega-v1.1.0/platform/neuroforge/deploy/learning-policy.example.json 2026-08-17 19:56:33.000000000 +0000
+++ ./platform/neuroforge/deploy/learning-policy.example.json 2026-08-25 19:07:16.778776324 +0000
@@ -2,10 +2,10 @@
"auto_learn": true,
"policy": {
"enabled": true,
- "learn_chat_inputs": true,
- "learn_chat_responses": true,
+ "learn_chat_inputs": false,
+ "learn_chat_responses": false,
"allow_explicit_learn": true,
- "allow_imports": true,
+ "allow_imports": false,
"learn_goal_cycles": false,
"min_confidence": 0.35,
"duplicate_similarity": 0.985,
@@ -15,11 +15,15 @@
"negative_archive_threshold": -0.75,
"max_memory_text_chars": 50000,
"source_trust": {
- "chat.input": 1.0,
- "chat.response": 0.9,
- "api.learn": 1.0,
- "api.import": 0.7,
- "goal-cycle": 0.85,
+ "chat.input": 0.25,
+ "chat.response": 0.20,
+ "api.learn": 0.80,
+ "api.import": 0.50,
+ "web.search": 0.45,
+ "web.page": 0.60,
+ "goal-cycle": 0.50,
+ "glpi.outcome.accepted": 1.0,
+ "glpi.outcome.corrected": 1.0,
"consolidation": 1.0
}
}
diff -ruN '--exclude=MANIFEST.sha256' '--exclude=patches' '--exclude=*.zip' /mnt/data/glpi-neuroforge-mega-v1.1.0/platform/neuroforge/internal/brain/brain.go ./platform/neuroforge/internal/brain/brain.go
--- /mnt/data/glpi-neuroforge-mega-v1.1.0/platform/neuroforge/internal/brain/brain.go 2026-08-17 21:07:54.000000000 +0000
+++ ./platform/neuroforge/internal/brain/brain.go 2026-08-25 18:59:34.202307901 +0000
@@ -342,6 +342,15 @@
Confidence float64 `json:"confidence,omitempty"`
TruthKey string `json:"truth_key,omitempty"`
Version int64 `json:"version,omitempty"`
+
+ // Internal provenance overrides. They are deliberately excluded from JSON so
+ // the generic /learn API cannot spoof a trusted source. Dedicated integration
+ // handlers may set them.
+ Source string `json:"-"`
+ Actor string `json:"-"`
+ SourceID string `json:"-"`
+ SourceURI string `json:"-"`
+ Note string `json:"-"`
}
func (e *Engine) Learn(ctx context.Context, r LearnRequest) (*core.Memory, error) {
@@ -362,7 +371,15 @@
if r.Salience == 0 {
r.Salience = 1
}
- r.Confidence = policyConfidence(lp, "api.learn", r.Confidence)
+ source := strings.TrimSpace(r.Source)
+ if source == "" {
+ source = "api.learn"
+ }
+ actor := strings.TrimSpace(r.Actor)
+ if actor == "" {
+ actor = r.Kind
+ }
+ r.Confidence = policyConfidence(lp, source, r.Confidence)
if r.Confidence < lp.MinConfidence {
return nil, fmt.Errorf("confidence %.3f is below learning policy minimum %.3f", r.Confidence, lp.MinConfidence)
}
@@ -377,14 +394,18 @@
return nil, err
}
if dup, sim := e.duplicateMemory(emb.Vector, r.MemoryType, r.Kind, lp.DuplicateSimilarity); dup != nil && r.TruthKey == "" {
- _ = e.store.AddKnowledgeEvent(core.KnowledgeEvent{Type: "memory.duplicate_suppressed", MemoryID: dup.ID, Summary: "Explicit learn matched existing memory", Reason: fmt.Sprintf("similarity %.4f >= %.4f", sim, lp.DuplicateSimilarity), Actor: "learning-policy", Metadata: map[string]string{"source": "api.learn"}})
+ _ = e.store.AddKnowledgeEvent(core.KnowledgeEvent{Type: "memory.duplicate_suppressed", MemoryID: dup.ID, Summary: "Explicit learn matched existing memory", Reason: fmt.Sprintf("similarity %.4f >= %.4f", sim, lp.DuplicateSimilarity), Actor: "learning-policy", Metadata: map[string]string{"source": source}})
return dup, nil
}
- m := &core.Memory{Kind: r.Kind, MemoryType: r.MemoryType, Text: r.Text, Vector: emb.Vector, SessionID: r.SessionID, Tags: r.Tags, Salience: r.Salience, Confidence: r.Confidence, TruthKey: r.TruthKey, Version: r.Version, Provenance: core.MemoryProvenance{Source: "api.learn", Actor: r.Kind, EmbeddingProvider: emb.Provider, EmbeddingModel: emb.Model, EmbeddingNodeID: emb.NodeID}}
+ m := &core.Memory{Kind: r.Kind, MemoryType: r.MemoryType, Text: r.Text, Vector: emb.Vector, SessionID: r.SessionID, Tags: r.Tags, Salience: r.Salience, Confidence: r.Confidence, TruthKey: r.TruthKey, Version: r.Version, Provenance: core.MemoryProvenance{Source: source, Actor: actor, SourceID: strings.TrimSpace(r.SourceID), SourceURI: strings.TrimSpace(r.SourceURI), Note: strings.TrimSpace(r.Note), EmbeddingProvider: emb.Provider, EmbeddingModel: emb.Model, EmbeddingNodeID: emb.NodeID}}
if err := e.addMemory(ctx, m); err != nil {
return nil, err
}
- _ = e.store.AddKnowledgeEvent(core.KnowledgeEvent{Type: "memory.learned", MemoryID: m.ID, Summary: "Knowledge explicitly learned through API", Reason: "POST /api/v1/learn permitted by learning policy", Actor: r.Kind, Metadata: map[string]string{"memory_type": r.MemoryType, "truth_key": r.TruthKey}})
+ reason := "POST /api/v1/learn permitted by learning policy"
+ if source != "api.learn" {
+ reason = "trusted integration outcome permitted by learning policy"
+ }
+ _ = e.store.AddKnowledgeEvent(core.KnowledgeEvent{Type: "memory.learned", MemoryID: m.ID, Summary: "Knowledge explicitly learned", Reason: reason, Actor: actor, Metadata: map[string]string{"memory_type": r.MemoryType, "truth_key": r.TruthKey, "source": source, "source_id": r.SourceID}})
if cfg.Brain.ExternalRelinkWorker {
_, _ = e.enqueueRelink(m)
} else {
diff -ruN '--exclude=MANIFEST.sha256' '--exclude=patches' '--exclude=*.zip' /mnt/data/glpi-neuroforge-mega-v1.1.0/platform/neuroforge/internal/httpapi/httpapi.go ./platform/neuroforge/internal/httpapi/httpapi.go
--- /mnt/data/glpi-neuroforge-mega-v1.1.0/platform/neuroforge/internal/httpapi/httpapi.go 2026-08-25 15:50:39.000000000 +0000
+++ ./platform/neuroforge/internal/httpapi/httpapi.go 2026-08-25 19:00:00.204630097 +0000
@@ -83,6 +83,7 @@
s.mux.Handle("DELETE /api/v1/integrations/knowledge/{namespace}/{document_id}", s.appAuth(http.HandlerFunc(s.integrationKnowledgeDelete)))
s.mux.Handle("POST /api/v1/integrations/knowledge/search", s.appAuth(http.HandlerFunc(s.integrationKnowledgeSearch)))
s.mux.Handle("POST /api/v1/integrations/events", s.appAuth(http.HandlerFunc(s.integrationEvent)))
+ s.mux.Handle("POST /api/v1/integrations/outcomes", s.appAuth(http.HandlerFunc(s.integrationValidatedOutcome)))
s.mux.Handle("POST /internal/v1/cluster/request-vote", s.clusterAuth(http.HandlerFunc(s.clusterRequestVote)))
s.mux.Handle("POST /internal/v1/cluster/heartbeat", s.clusterAuth(http.HandlerFunc(s.clusterHeartbeat)))
diff -ruN '--exclude=MANIFEST.sha256' '--exclude=patches' '--exclude=*.zip' /mnt/data/glpi-neuroforge-mega-v1.1.0/platform/neuroforge/internal/httpapi/outcomes.go ./platform/neuroforge/internal/httpapi/outcomes.go
--- /mnt/data/glpi-neuroforge-mega-v1.1.0/platform/neuroforge/internal/httpapi/outcomes.go 1970-01-01 00:00:00.000000000 +0000
+++ ./platform/neuroforge/internal/httpapi/outcomes.go 2026-08-25 19:13:45.223089817 +0000
@@ -0,0 +1,131 @@
+package httpapi
+
+import (
+ "errors"
+ "fmt"
+ "net/http"
+ "strconv"
+ "strings"
+
+ "neuroforge/internal/brain"
+ "neuroforge/internal/core"
+)
+
+type validatedOutcomeRequest struct {
+ OutcomeID string `json:"outcome_id"`
+ RunID string `json:"run_id"`
+ TicketID int64 `json:"ticket_id"`
+ Decision string `json:"decision"` // accepted | corrected
+ TicketInput string `json:"ticket_input"`
+ ProposedReply string `json:"proposed_reply,omitempty"`
+ ConfirmedReply string `json:"confirmed_reply"`
+ CategoryID int64 `json:"category_id,omitempty"`
+ CategoryName string `json:"category_name,omitempty"`
+ KnowledgeID string `json:"knowledge_id,omitempty"`
+ SupersedesID string `json:"supersedes_id,omitempty"`
+ Actor string `json:"actor"`
+ Note string `json:"note,omitempty"`
+}
+
+// integrationValidatedOutcome is deliberately narrower than /api/v1/learn.
+// Only a human-confirmed or human-corrected operational outcome can enter this
+// path, and trusted provenance is assigned server-side rather than accepted
+// from the caller.
+func (s *Server) integrationValidatedOutcome(w http.ResponseWriter, r *http.Request) {
+ var q validatedOutcomeRequest
+ if err := decode(r, &q); err != nil {
+ s.err(w, http.StatusBadRequest, err)
+ return
+ }
+ q.OutcomeID = strings.TrimSpace(q.OutcomeID)
+ q.RunID = strings.TrimSpace(q.RunID)
+ q.Decision = strings.ToLower(strings.TrimSpace(q.Decision))
+ q.TicketInput = strings.TrimSpace(q.TicketInput)
+ q.ProposedReply = strings.TrimSpace(q.ProposedReply)
+ q.ConfirmedReply = strings.TrimSpace(q.ConfirmedReply)
+ q.CategoryName = strings.TrimSpace(q.CategoryName)
+ q.KnowledgeID = strings.TrimSpace(q.KnowledgeID)
+ q.SupersedesID = strings.TrimSpace(q.SupersedesID)
+ q.Actor = strings.TrimSpace(q.Actor)
+ q.Note = strings.TrimSpace(q.Note)
+
+ if q.OutcomeID == "" || q.RunID == "" || q.TicketID <= 0 || q.TicketInput == "" || q.ConfirmedReply == "" || q.Actor == "" {
+ s.err(w, http.StatusBadRequest, errors.New("outcome_id, run_id, ticket_id, ticket_input, confirmed_reply and actor are required"))
+ return
+ }
+ if q.Decision != "accepted" && q.Decision != "corrected" {
+ s.err(w, http.StatusBadRequest, errors.New("decision must be accepted or corrected"))
+ return
+ }
+ if q.Decision == "accepted" && q.ProposedReply == "" {
+ s.err(w, http.StatusBadRequest, errors.New("accepted outcomes require proposed_reply"))
+ return
+ }
+ if len([]rune(q.TicketInput)) > 12000 || len([]rune(q.ConfirmedReply)) > 12000 || len([]rune(q.Note)) > 4000 {
+ s.err(w, http.StatusRequestEntityTooLarge, errors.New("validated outcome exceeds size limits"))
+ return
+ }
+
+ source := "glpi.outcome." + q.Decision
+ confidence := 0.99
+ if q.Decision == "corrected" {
+ confidence = 1.0
+ }
+ var text strings.Builder
+ text.WriteString("GLPI helpdesk outcome verified by a technician.\n\nProblem:\n")
+ text.WriteString(q.TicketInput)
+ text.WriteString("\n\nVerified solution:\n")
+ text.WriteString(q.ConfirmedReply)
+ if q.CategoryName != "" || q.CategoryID > 0 {
+ text.WriteString("\n\nCategory: ")
+ if q.CategoryName != "" {
+ text.WriteString(q.CategoryName)
+ }
+ if q.CategoryID > 0 {
+ text.WriteString(" (#")
+ text.WriteString(strconv.FormatInt(q.CategoryID, 10))
+ text.WriteString(")")
+ }
+ }
+ if q.Decision == "corrected" && q.ProposedReply != "" && q.ProposedReply != q.ConfirmedReply {
+ text.WriteString("\n\nSuperseded AI proposal (do not treat as verified):\n")
+ text.WriteString(q.ProposedReply)
+ }
+
+ tags := []string{"integration:glpi", "validated:human", "outcome:" + q.Decision, "ticket:" + strconv.FormatInt(q.TicketID, 10), "run:" + q.RunID}
+ if q.CategoryID > 0 {
+ tags = append(tags, "category:"+strconv.FormatInt(q.CategoryID, 10))
+ }
+ if q.KnowledgeID != "" {
+ tags = append(tags, "knowledge:"+q.KnowledgeID)
+ }
+ if q.SupersedesID != "" {
+ tags = append(tags, "supersedes-outcome:"+q.SupersedesID)
+ }
+
+ m, err := s.brain.Learn(r.Context(), brain.LearnRequest{
+ Text: text.String(),
+ Kind: "validated_outcome",
+ MemoryType: core.MemorySemantic,
+ Tags: tags,
+ Salience: 1.2,
+ Confidence: confidence,
+ Source: source,
+ Actor: q.Actor,
+ SourceID: q.OutcomeID,
+ SourceURI: fmt.Sprintf("glpi://Ticket/%d#run=%s", q.TicketID, q.RunID),
+ Note: q.Note,
+ })
+ if err != nil {
+ s.err(w, http.StatusBadGateway, err)
+ return
+ }
+ _ = s.store.AddKnowledgeEvent(core.KnowledgeEvent{
+ Type: "integration.outcome_validated", MemoryID: m.ID,
+ Summary: "Human-confirmed GLPI ticket outcome learned",
+ Reason: "technician explicitly accepted or corrected the AI proposal",
+ Actor: q.Actor,
+ Metadata: map[string]string{"source": source, "outcome_id": q.OutcomeID, "run_id": q.RunID, "ticket_id": strconv.FormatInt(q.TicketID, 10), "decision": q.Decision, "knowledge_id": q.KnowledgeID, "supersedes_outcome_id": q.SupersedesID},
+ })
+ s.json(w, http.StatusCreated, map[string]any{"memory": m, "outcome_id": q.OutcomeID, "decision": q.Decision, "source": source})
+}
diff -ruN '--exclude=MANIFEST.sha256' '--exclude=patches' '--exclude=*.zip' /mnt/data/glpi-neuroforge-mega-v1.1.0/platform/neuroforge/internal/httpapi/outcomes_test.go ./platform/neuroforge/internal/httpapi/outcomes_test.go
--- /mnt/data/glpi-neuroforge-mega-v1.1.0/platform/neuroforge/internal/httpapi/outcomes_test.go 1970-01-01 00:00:00.000000000 +0000
+++ ./platform/neuroforge/internal/httpapi/outcomes_test.go 2026-08-25 19:05:43.029092951 +0000
@@ -0,0 +1,71 @@
+package httpapi
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+func TestValidatedOutcomeLearnsTrustedProvenance(t *testing.T) {
+ fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path == "/api/embed" {
+ _ = json.NewEncoder(w).Encode(map[string]any{"embeddings": [][]float32{{1, 0, 0}}})
+ return
+ }
+ http.NotFound(w, r)
+ }))
+ defer fake.Close()
+
+ s, _ := newMetricsTestServer(t)
+ cfg := s.store.Config()
+ cfg.Ollama[0].BaseURL = fake.URL
+ cfg.Brain.ExternalRelinkWorker = false
+ cfg.Brain.LearningPolicy.SourceTrust["glpi.outcome.accepted"] = 1
+ cfg.Brain.LearningPolicy.SourceTrust["glpi.outcome.corrected"] = 1
+ cfg.Brain.LearningPolicy.LearnChatResponses = false
+ if err := s.store.UpdateConfig(cfg); err != nil {
+ t.Fatal(err)
+ }
+ sec := s.store.Secrets()
+
+ body := `{"outcome_id":"out-1","run_id":"run-1","ticket_id":42,"decision":"accepted","ticket_input":"VPN verbindet nicht","proposed_reply":"VPN Client neu starten","confirmed_reply":"VPN Client neu starten","category_id":5,"category_name":"VPN","knowledge_id":"kb-vpn","actor":"tech-a"}`
+ req := httptest.NewRequest(http.MethodPost, "/api/v1/integrations/outcomes", strings.NewReader(body))
+ req.Header.Set("Authorization", "Bearer "+sec.AppAPIKey)
+ req.Header.Set("Content-Type", "application/json")
+ rr := httptest.NewRecorder()
+ s.Handler().ServeHTTP(rr, req)
+ if rr.Code != http.StatusCreated {
+ t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
+ }
+ var out struct {
+ Memory struct {
+ ID string `json:"id"`
+ Provenance struct {
+ Source string `json:"source"`
+ Actor string `json:"actor"`
+ SourceID string `json:"source_id"`
+ } `json:"provenance"`
+ } `json:"memory"`
+ }
+ if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil {
+ t.Fatal(err)
+ }
+ if out.Memory.ID == "" || out.Memory.Provenance.Source != "glpi.outcome.accepted" || out.Memory.Provenance.Actor != "tech-a" || out.Memory.Provenance.SourceID != "out-1" {
+ t.Fatalf("unexpected outcome memory: %#v body=%s", out, rr.Body.String())
+ }
+}
+
+func TestValidatedOutcomeRejectsUnconfirmedDecision(t *testing.T) {
+ s, _ := newMetricsTestServer(t)
+ sec := s.store.Secrets()
+ req := httptest.NewRequest(http.MethodPost, "/api/v1/integrations/outcomes", strings.NewReader(`{"outcome_id":"o","run_id":"r","ticket_id":1,"decision":"rejected","ticket_input":"x","confirmed_reply":"y","actor":"tech"}`))
+ req.Header.Set("Authorization", "Bearer "+sec.AppAPIKey)
+ req.Header.Set("Content-Type", "application/json")
+ rr := httptest.NewRecorder()
+ s.Handler().ServeHTTP(rr, req)
+ if rr.Code != http.StatusBadRequest {
+ t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
+ }
+}
diff -ruN '--exclude=MANIFEST.sha256' '--exclude=patches' '--exclude=*.zip' /mnt/data/glpi-neuroforge-mega-v1.1.0/scripts/generate-secrets.sh ./scripts/generate-secrets.sh
--- /mnt/data/glpi-neuroforge-mega-v1.1.0/scripts/generate-secrets.sh 2026-08-25 16:06:06.000000000 +0000
+++ ./scripts/generate-secrets.sh 2026-08-25 19:13:14.014332798 +0000
@@ -7,4 +7,5 @@
NEUROFORGE_WORKER_TOKEN=$(gen)
NEUROFORGE_METRICS_TOKEN=$(gen)
KB_INTEGRATION_TOKEN=$(gen)
+SEARXNG_SECRET=$(gen)
OUT
diff -ruN '--exclude=MANIFEST.sha256' '--exclude=patches' '--exclude=*.zip' /mnt/data/glpi-neuroforge-mega-v1.1.0/scripts/research-up.sh ./scripts/research-up.sh
--- /mnt/data/glpi-neuroforge-mega-v1.1.0/scripts/research-up.sh 1970-01-01 00:00:00.000000000 +0000
+++ ./scripts/research-up.sh 2026-08-25 19:13:33.157101615 +0000
@@ -0,0 +1,19 @@
+#!/usr/bin/env sh
+set -eu
+ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
+secret=${SEARXNG_SECRET:-}
+if [ -z "$secret" ] && [ -f "$ROOT/.env" ]; then
+ secret=$(awk -F= '$1=="SEARXNG_SECRET" {sub(/^[^=]*=/, ""); print; exit}' "$ROOT/.env")
+fi
+case "$secret" in
+ ""|CHANGE_ME*)
+ echo "Set a real SEARXNG_SECRET in $ROOT/.env (or export it) before enabling research." >&2
+ exit 1
+ ;;
+esac
+export SEARXNG_SECRET=$secret
+cd "$ROOT"
+NEUROFORGE_RESEARCH_ENABLED=true \
+NEUROFORGE_SEARXNG_ENABLED=true \
+docker compose --profile research up -d searxng neuroforge neuroforge-worker
+printf '%s\n' 'SearXNG + NeuroForge research are running. Autonomy remains controlled by NEUROFORGE_AUTONOMY_ENABLED.'
diff -ruN '--exclude=MANIFEST.sha256' '--exclude=patches' '--exclude=*.zip' /mnt/data/glpi-neuroforge-mega-v1.1.0/scripts/validate.sh ./scripts/validate.sh
--- /mnt/data/glpi-neuroforge-mega-v1.1.0/scripts/validate.sh 2026-08-25 15:59:02.000000000 +0000
+++ ./scripts/validate.sh 2026-08-25 19:13:14.014069585 +0000
@@ -7,8 +7,14 @@
echo "==> go vet $mod"
(cd "$ROOT/$mod" && go vet ./...)
done
+echo "==> shell syntax"
+for script in "$ROOT"/scripts/*.sh; do
+ sh -n "$script"
+done
if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then
- echo "==> docker compose config"
+ echo "==> docker compose config (base)"
(cd "$ROOT" && docker compose --env-file .env.example config >/dev/null)
+ echo "==> docker compose config (research profile)"
+ (cd "$ROOT" && docker compose --env-file .env.example --profile research config >/dev/null)
fi
echo "validation OK"
diff -ruN '--exclude=MANIFEST.sha256' '--exclude=patches' '--exclude=*.zip' /mnt/data/glpi-neuroforge-mega-v1.1.0/services/agent/cmd/agent/main.go ./services/agent/cmd/agent/main.go
--- /mnt/data/glpi-neuroforge-mega-v1.1.0/services/agent/cmd/agent/main.go 2026-08-25 15:53:37.000000000 +0000
+++ ./services/agent/cmd/agent/main.go 2026-08-25 19:01:47.825565990 +0000
@@ -130,6 +130,20 @@
}
contextCollector := contextdata.New(cfg, g, kuma)
svc := agent.New(cfg, g, o, k, l, st, q, m, contextCollector)
+ if cfg.OutcomeLearningEnabled {
+ outcomeStore, outcomeErr := learning.OpenOutcomes(cfg.DataDir, cfg.OutcomeLearningMaxOutcomes)
+ if outcomeErr != nil {
+ slog.Error("ticket outcome store initialization failed", "error", outcomeErr)
+ os.Exit(1)
+ }
+ outcomeSink, outcomeErr := learning.NewNeuroForgeOutcomeSink(cfg.NeuroForgeURL, cfg.NeuroForgeAPIKey, cfg.NeuroForgeTimeout)
+ if outcomeErr != nil {
+ slog.Error("NeuroForge outcome learning configuration failed", "error", outcomeErr)
+ os.Exit(1)
+ }
+ svc.SetOutcomeLearning(outcomeStore, outcomeSink)
+ slog.Info("outcome-gated learning enabled", "fail_open", cfg.OutcomeLearningFailOpen, "max_outcomes", cfg.OutcomeLearningMaxOutcomes)
+ }
web, err := webui.New(cfg, m, st, q, k, svc, o)
if err != nil {
slog.Error("web UI initialization failed", "error", err)
diff -ruN '--exclude=MANIFEST.sha256' '--exclude=patches' '--exclude=*.zip' /mnt/data/glpi-neuroforge-mega-v1.1.0/services/agent/internal/agent/agent.go ./services/agent/internal/agent/agent.go
--- /mnt/data/glpi-neuroforge-mega-v1.1.0/services/agent/internal/agent/agent.go 2026-08-25 18:31:41.024092729 +0000
+++ ./services/agent/internal/agent/agent.go 2026-08-25 19:16:08.787533068 +0000
@@ -48,6 +48,8 @@
ai AI
knowledge *knowledge.Store
learning *learning.Store
+ outcomes *learning.OutcomeStore
+ outcomeSink learning.OutcomeSink
state *state.Store
q *queue.Queue
metrics *metrics.Metrics
@@ -63,6 +65,11 @@
func New(cfg config.Config, g GLPI, ai AI, k *knowledge.Store, l *learning.Store, s *state.Store, q *queue.Queue, m *metrics.Metrics, contextCollector ContextCollector) *Service {
return &Service{cfg: cfg, glpi: g, ai: ai, knowledge: k, learning: l, state: s, q: q, metrics: m, context: contextCollector, policy: NewPolicy(cfg.AutoCategory, cfg.AutoReply, cfg.CategoryConfidence, cfg.ReplyConfidence, cfg.KnowledgeMinScore, cfg.KnowledgeRetrievalFloor, cfg.KnowledgeEvidenceRetrievalWeight, cfg.KnowledgeEvidenceAIWeight, cfg.KnowledgeEvidenceCategoryWeight, cfg.KnowledgeAllowedSources, cfg.KnowledgeAutoReplySources, cfg.CommunicationLanguage, cfg.CommunicationStyle, cfg.CommunicationSalutation, cfg.CommunicationClosing, cfg.CommunicationSignature, cfg.AIContentLabelEnabled, cfg.ContextBlockReplyOnError, cfg.ContextBlockReplyOnIncident, cfg.ContextRelevanceMinScore)}
}
+
+func (s *Service) SetOutcomeLearning(store *learning.OutcomeStore, sink learning.OutcomeSink) {
+ s.outcomes = store
+ s.outcomeSink = sink
+}
func (s *Service) Queue() *queue.Queue { return s.q }
func (s *Service) Start(ctx context.Context) {
go s.healthLoop(ctx)
@@ -195,6 +202,9 @@
}
run.TicketName = t.Name
run.SourceVersion = sourceVersion(t)
+ if s.cfg.OutcomeLearningEnabled {
+ run.LearningTicketText = compactLearningText(stripHTML(t.Content), 4000)
+ }
run.CategoryBefore = t.CategoryID
run.ExecutionChecks = append(run.ExecutionChecks, model.RuleCheck{Code: "execution_ticket_loaded", Group: "eligibility", Label: "Ticket konnte geladen werden", Status: "pass", Actual: "ja", Expected: "ja"})
alreadySeen := s.state.Seen(t.ID, run.SourceVersion)
@@ -575,6 +585,9 @@
run.AIKnowledgeID = result.ReplyKnowledgeID
run.ReplyDecision = result.ReplyDecision
run.ReplyProposed = result.Reply
+ if result.Reply {
+ run.ReplyProposedText = compactLearningText(stripHTML(result.ReplyText), 12000)
+ }
run.KnowledgeID = result.KnowledgeID
if result.KnowledgeThreshold > 0 {
run.KnowledgeThreshold = result.KnowledgeThreshold
@@ -1255,6 +1268,96 @@
return s.learning.Count()
}
+func (s *Service) RecordTicketOutcome(ctx context.Context, runID, decision, correctedReply, note, actor string) (learning.TicketOutcome, error) {
+ if !s.cfg.OutcomeLearningEnabled || s.outcomes == nil || s.outcomeSink == nil {
+ return learning.TicketOutcome{}, fmt.Errorf("outcome learning is disabled")
+ }
+ run, ok := s.state.FindRun(strings.TrimSpace(runID))
+ if !ok {
+ return learning.TicketOutcome{}, fmt.Errorf("run not found")
+ }
+ // High-trust learning must refer to the same ticket state the AI actually
+ // evaluated. If GLPI changed after the run, require a fresh run before a
+ // technician can promote its answer into trusted knowledge.
+ if strings.TrimSpace(run.SourceVersion) != "" && s.glpi != nil {
+ fresh, err := s.glpi.GetTicket(ctx, run.TicketID)
+ if err != nil {
+ return learning.TicketOutcome{}, fmt.Errorf("verify current ticket before outcome learning: %w", err)
+ }
+ if sourceVersion(fresh) != run.SourceVersion {
+ return learning.TicketOutcome{}, fmt.Errorf("ticket changed since this run; process the current ticket state before validating the AI outcome")
+ }
+ }
+ if !run.ReplyProposed || strings.TrimSpace(run.ReplyProposedText) == "" {
+ return learning.TicketOutcome{}, fmt.Errorf("run has no reply proposal to validate")
+ }
+ decision = strings.ToLower(strings.TrimSpace(decision))
+ confirmed := strings.TrimSpace(correctedReply)
+ if decision == "accepted" {
+ confirmed = strings.TrimSpace(run.ReplyProposedText)
+ } else if decision == "corrected" {
+ if confirmed == "" {
+ return learning.TicketOutcome{}, fmt.Errorf("corrected outcome requires corrected_reply")
+ }
+ } else {
+ return learning.TicketOutcome{}, fmt.Errorf("decision must be accepted or corrected")
+ }
+ input := strings.TrimSpace(run.LearningTicketText)
+ if input == "" {
+ return learning.TicketOutcome{}, fmt.Errorf("run predates outcome-gated learning and has no learning input snapshot")
+ }
+ categoryID := run.ReplyBasisCategoryID
+ categoryName := strings.TrimSpace(run.ReplyBasisCategoryName)
+ if categoryID <= 0 {
+ categoryID = run.AIRecommendedCategoryID
+ categoryName = strings.TrimSpace(run.AIRecommendedCategoryName)
+ }
+ if categoryID <= 0 {
+ categoryID = run.CategoryBefore
+ categoryName = strings.TrimSpace(run.CategoryBeforeName)
+ }
+ knowledgeID := strings.TrimSpace(run.AIKnowledgeID)
+ if knowledgeID == "" {
+ knowledgeID = strings.TrimSpace(run.KnowledgeID)
+ }
+ x := learning.TicketOutcome{RunID: run.RunID, TicketID: run.TicketID, Decision: decision, TicketInput: input, ProposedReply: strings.TrimSpace(run.ReplyProposedText), ConfirmedReply: compactLearningText(stripHTML(confirmed), 12000), CategoryID: categoryID, CategoryName: categoryName, KnowledgeID: knowledgeID, Actor: strings.TrimSpace(actor), Note: compactLearningText(note, 4000), SyncStatus: "pending"}
+ if x.Actor == "" {
+ x.Actor = "technician"
+ }
+ stored, err := s.outcomes.Add(x)
+ if err != nil {
+ return learning.TicketOutcome{}, err
+ }
+ // Exact repeated confirmations are idempotent. If the same human
+ // decision has already been learned, return the existing audit record
+ // without sending a duplicate trusted memory to NeuroForge. Failed
+ // records intentionally continue below so they can be retried.
+ if stored.SyncStatus == "learned" && strings.TrimSpace(stored.NeuroForgeID) != "" {
+ return stored, nil
+ }
+ memoryID, syncErr := s.outcomeSink.LearnOutcome(ctx, stored)
+ if syncErr != nil {
+ failed, _ := s.outcomes.UpdateSync(stored.ID, "failed", "", syncErr.Error())
+ if s.cfg.OutcomeLearningFailOpen {
+ slog.Warn("validated ticket outcome persisted but NeuroForge learning failed", "run_id", run.RunID, "ticket_id", run.TicketID, "error", syncErr)
+ return failed, nil
+ }
+ return failed, fmt.Errorf("validated outcome persisted, but NeuroForge learning failed: %w", syncErr)
+ }
+ learned, err := s.outcomes.UpdateSync(stored.ID, "learned", memoryID, "")
+ if err != nil {
+ return stored, err
+ }
+ return learned, nil
+}
+
+func (s *Service) TicketOutcomes() []learning.TicketOutcome {
+ if s.outcomes == nil {
+ return nil
+ }
+ return s.outcomes.List()
+}
+
func appendUnique(in []string, v string) []string {
v = strings.TrimSpace(v)
if v == "" {
diff -ruN '--exclude=MANIFEST.sha256' '--exclude=patches' '--exclude=*.zip' /mnt/data/glpi-neuroforge-mega-v1.1.0/services/agent/internal/agent/outcome_learning_test.go ./services/agent/internal/agent/outcome_learning_test.go
--- /mnt/data/glpi-neuroforge-mega-v1.1.0/services/agent/internal/agent/outcome_learning_test.go 1970-01-01 00:00:00.000000000 +0000
+++ ./services/agent/internal/agent/outcome_learning_test.go 2026-08-25 19:16:22.085568271 +0000
@@ -0,0 +1,96 @@
+package agent
+
+import (
+ "context"
+ "testing"
+
+ "github.com/example/glpi-ai-agent/internal/config"
+ "github.com/example/glpi-ai-agent/internal/learning"
+ "github.com/example/glpi-ai-agent/internal/model"
+ "github.com/example/glpi-ai-agent/internal/state"
+)
+
+type fakeOutcomeSink struct {
+ got learning.TicketOutcome
+ id string
+ err error
+ calls int
+}
+
+func (f *fakeOutcomeSink) LearnOutcome(_ context.Context, x learning.TicketOutcome) (string, error) {
+ f.got = x
+ f.calls++
+ return f.id, f.err
+}
+
+func TestRecordTicketOutcomeAcceptedAndCorrected(t *testing.T) {
+ st, err := state.Open(t.TempDir(), 20)
+ if err != nil {
+ t.Fatal(err)
+ }
+ run := model.RunRecord{RunID: "run-1", TicketID: 42, ReplyProposed: true, ReplyProposedText: "Bitte VPN neu starten.", LearningTicketText: "VPN verbindet nicht.", ReplyBasisCategoryID: 5, ReplyBasisCategoryName: "VPN", AIKnowledgeID: "kb-vpn"}
+ if err := st.Append(run); err != nil {
+ t.Fatal(err)
+ }
+ os, err := learning.OpenOutcomes(t.TempDir(), 20)
+ if err != nil {
+ t.Fatal(err)
+ }
+ sink := &fakeOutcomeSink{id: "mem-1"}
+ svc := &Service{cfg: config.Config{OutcomeLearningEnabled: true}, state: st, outcomes: os, outcomeSink: sink}
+
+ got, err := svc.RecordTicketOutcome(context.Background(), "run-1", "accepted", "", "checked", "tech")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.SyncStatus != "learned" || got.NeuroForgeID != "mem-1" || got.ConfirmedReply != "Bitte VPN neu starten." || sink.got.KnowledgeID != "kb-vpn" {
+ t.Fatalf("unexpected accepted outcome %#v sink=%#v", got, sink.got)
+ }
+
+ sink.id = "mem-2"
+ got, err = svc.RecordTicketOutcome(context.Background(), "run-1", "corrected", "VPN-Profil neu importieren.", "technician correction", "tech")
+ if err != nil {
+ t.Fatal(err)
+ }
+ items := os.List()
+ if got.Decision != "corrected" || got.ConfirmedReply != "VPN-Profil neu importieren." || got.NeuroForgeID != "mem-2" || len(items) != 2 || got.SupersedesID == "" {
+ t.Fatalf("unexpected corrected outcome %#v list=%#v", got, items)
+ }
+ // Repeating the same correction must not create or learn a duplicate.
+ beforeCalls := sink.calls
+ retry, err := svc.RecordTicketOutcome(context.Background(), "run-1", "corrected", "VPN-Profil neu importieren.", "technician correction", "tech")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if retry.ID != got.ID || sink.calls != beforeCalls || len(os.List()) != 2 {
+ t.Fatalf("expected idempotent retry: retry=%#v calls=%d list=%#v", retry, sink.calls, os.List())
+ }
+}
+
+func TestRecordTicketOutcomeRejectsStaleTicketState(t *testing.T) {
+ st, err := state.Open(t.TempDir(), 20)
+ if err != nil {
+ t.Fatal(err)
+ }
+ original := model.Ticket{ID: 42, Name: "VPN", Content: "VPN verbindet nicht.", DateMod: "v1", StatusID: 1}
+ run := model.RunRecord{RunID: "run-stale", TicketID: 42, SourceVersion: sourceVersion(original), ReplyProposed: true, ReplyProposedText: "VPN neu starten.", LearningTicketText: "VPN verbindet nicht."}
+ if err := st.Append(run); err != nil {
+ t.Fatal(err)
+ }
+ os, err := learning.OpenOutcomes(t.TempDir(), 20)
+ if err != nil {
+ t.Fatal(err)
+ }
+ g := &fakeGLPI{ticket: original}
+ g.ticket.Content = "Ticket wurde zwischenzeitlich aktualisiert."
+ sink := &fakeOutcomeSink{id: "mem-stale"}
+ svc := &Service{cfg: config.Config{OutcomeLearningEnabled: true}, glpi: g, state: st, outcomes: os, outcomeSink: sink}
+
+ _, err = svc.RecordTicketOutcome(context.Background(), "run-stale", "accepted", "", "", "tech")
+ if err == nil {
+ t.Fatal("expected stale ticket state to block trusted outcome learning")
+ }
+ if sink.calls != 0 || len(os.List()) != 0 {
+ t.Fatalf("stale run must not be persisted or learned: calls=%d outcomes=%#v", sink.calls, os.List())
+ }
+}
diff -ruN '--exclude=MANIFEST.sha256' '--exclude=patches' '--exclude=*.zip' /mnt/data/glpi-neuroforge-mega-v1.1.0/services/agent/internal/config/config.go ./services/agent/internal/config/config.go
--- /mnt/data/glpi-neuroforge-mega-v1.1.0/services/agent/internal/config/config.go 2026-08-25 16:02:10.000000000 +0000
+++ ./services/agent/internal/config/config.go 2026-08-25 19:01:08.060689991 +0000
@@ -116,6 +116,9 @@
LearningEnabled bool
LearningMaxExamples int
LearningExamplesPerCategory int
+ OutcomeLearningEnabled bool
+ OutcomeLearningFailOpen bool
+ OutcomeLearningMaxOutcomes int
CommunicationLanguage string
CommunicationStyle string
@@ -304,6 +307,9 @@
LearningEnabled: envBool("LEARNING_ENABLED", true),
LearningMaxExamples: envInt("LEARNING_MAX_EXAMPLES", 500),
LearningExamplesPerCategory: envInt("LEARNING_EXAMPLES_PER_CATEGORY", 5),
+ OutcomeLearningEnabled: envBool("OUTCOME_LEARNING_ENABLED", true),
+ OutcomeLearningFailOpen: envBool("OUTCOME_LEARNING_FAIL_OPEN", false),
+ OutcomeLearningMaxOutcomes: envInt("OUTCOME_LEARNING_MAX_OUTCOMES", 2000),
CommunicationLanguage: env("COMMUNICATION_LANGUAGE", "de-DE"),
CommunicationStyle: strings.ToLower(env("COMMUNICATION_STYLE", "formal")),
CommunicationSalutation: env("COMMUNICATION_SALUTATION", "Guten Tag,"),
@@ -647,6 +653,9 @@
if c.LearningExamplesPerCategory < 1 || c.LearningExamplesPerCategory > 20 {
return errors.New("LEARNING_EXAMPLES_PER_CATEGORY must be between 1 and 20")
}
+ if c.OutcomeLearningMaxOutcomes < 1 || c.OutcomeLearningMaxOutcomes > 50000 {
+ return errors.New("OUTCOME_LEARNING_MAX_OUTCOMES must be between 1 and 50000")
+ }
}
if len(c.GLPIAllowedStatusIDs) == 0 {
return errors.New("GLPI_ALLOWED_STATUS_IDS must contain at least one positive status id")
diff -ruN '--exclude=MANIFEST.sha256' '--exclude=patches' '--exclude=*.zip' /mnt/data/glpi-neuroforge-mega-v1.1.0/services/agent/internal/learning/outcomes.go ./services/agent/internal/learning/outcomes.go
--- /mnt/data/glpi-neuroforge-mega-v1.1.0/services/agent/internal/learning/outcomes.go 1970-01-01 00:00:00.000000000 +0000
+++ ./services/agent/internal/learning/outcomes.go 2026-08-25 19:13:45.223377526 +0000
@@ -0,0 +1,211 @@
+package learning
+
+import (
+ "bytes"
+ "context"
+ "crypto/rand"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+ "sync"
+ "time"
+)
+
+type TicketOutcome struct {
+ ID string `json:"id"`
+ RunID string `json:"run_id"`
+ TicketID int64 `json:"ticket_id"`
+ Decision string `json:"decision"` // accepted | corrected
+ TicketInput string `json:"ticket_input"`
+ ProposedReply string `json:"proposed_reply,omitempty"`
+ ConfirmedReply string `json:"confirmed_reply"`
+ CategoryID int64 `json:"category_id,omitempty"`
+ CategoryName string `json:"category_name,omitempty"`
+ KnowledgeID string `json:"knowledge_id,omitempty"`
+ Actor string `json:"actor"`
+ Note string `json:"note,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
+ NeuroForgeID string `json:"neuroforge_memory_id,omitempty"`
+ SyncStatus string `json:"sync_status"` // pending | learned | failed
+ SyncError string `json:"sync_error,omitempty"`
+ SupersedesID string `json:"supersedes_id,omitempty"`
+}
+
+type OutcomeStore struct {
+ mu sync.RWMutex
+ path string
+ max int
+ items []TicketOutcome
+}
+
+func OpenOutcomes(dataDir string, max int) (*OutcomeStore, error) {
+ if max < 1 {
+ max = 2000
+ }
+ s := &OutcomeStore{path: filepath.Join(dataDir, "ticket-outcomes.json"), max: max}
+ if b, err := os.ReadFile(s.path); err == nil {
+ if err := json.Unmarshal(b, &s.items); err != nil {
+ return nil, fmt.Errorf("parse ticket outcomes: %w", err)
+ }
+ } else if !os.IsNotExist(err) {
+ return nil, err
+ }
+ if len(s.items) > s.max {
+ s.items = s.items[len(s.items)-s.max:]
+ }
+ return s, nil
+}
+
+func (s *OutcomeStore) Add(x TicketOutcome) (TicketOutcome, error) {
+ x.RunID = strings.TrimSpace(x.RunID)
+ x.Decision = strings.ToLower(strings.TrimSpace(x.Decision))
+ x.TicketInput = strings.TrimSpace(x.TicketInput)
+ x.ProposedReply = strings.TrimSpace(x.ProposedReply)
+ x.ConfirmedReply = strings.TrimSpace(x.ConfirmedReply)
+ x.CategoryName = strings.TrimSpace(x.CategoryName)
+ x.KnowledgeID = strings.TrimSpace(x.KnowledgeID)
+ x.Actor = strings.TrimSpace(x.Actor)
+ x.Note = strings.TrimSpace(x.Note)
+ if x.RunID == "" || x.TicketID <= 0 || x.TicketInput == "" || x.ConfirmedReply == "" || x.Actor == "" {
+ return x, fmt.Errorf("run, ticket, input, confirmed reply and actor are required")
+ }
+ if x.Decision != "accepted" && x.Decision != "corrected" {
+ return x, fmt.Errorf("decision must be accepted or corrected")
+ }
+ if x.Decision == "accepted" && x.ProposedReply == "" {
+ return x, fmt.Errorf("accepted outcome requires proposed reply")
+ }
+ if x.ID == "" {
+ x.ID = outcomeID()
+ }
+ if x.CreatedAt.IsZero() {
+ x.CreatedAt = time.Now().UTC()
+ }
+ if x.SyncStatus == "" {
+ x.SyncStatus = "pending"
+ }
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ for i := len(s.items) - 1; i >= 0; i-- {
+ prev := s.items[i]
+ if prev.RunID != x.RunID {
+ continue
+ }
+ // Repeating the exact same human decision is idempotent. This also
+ // lets a previously failed NeuroForge sync be retried without
+ // manufacturing a second human decision.
+ if prev.Decision == x.Decision && strings.TrimSpace(prev.ConfirmedReply) == x.ConfirmedReply {
+ return prev, nil
+ }
+ // A later correction/confirmation is a new immutable audit record.
+ // Preserve the previous decision and make the revision chain explicit.
+ x.SupersedesID = prev.ID
+ break
+ }
+ s.items = append(s.items, x)
+ if len(s.items) > s.max {
+ s.items = s.items[len(s.items)-s.max:]
+ }
+ return x, s.saveLocked()
+}
+
+func (s *OutcomeStore) UpdateSync(id, status, memoryID, syncErr string) (TicketOutcome, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ for i := range s.items {
+ if s.items[i].ID != id {
+ continue
+ }
+ s.items[i].SyncStatus = status
+ s.items[i].NeuroForgeID = memoryID
+ s.items[i].SyncError = syncErr
+ return s.items[i], s.saveLocked()
+ }
+ return TicketOutcome{}, os.ErrNotExist
+}
+
+func (s *OutcomeStore) List() []TicketOutcome {
+ if s == nil {
+ return nil
+ }
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ out := append([]TicketOutcome(nil), s.items...)
+ sort.SliceStable(out, func(i, j int) bool { return out[i].CreatedAt.After(out[j].CreatedAt) })
+ return out
+}
+func (s *OutcomeStore) saveLocked() error {
+ b, err := json.MarshalIndent(s.items, "", " ")
+ if err != nil {
+ return err
+ }
+ tmp := s.path + ".tmp"
+ if err := os.WriteFile(tmp, b, 0o640); err != nil {
+ return err
+ }
+ return os.Rename(tmp, s.path)
+}
+func outcomeID() string { b := make([]byte, 12); _, _ = rand.Read(b); return hex.EncodeToString(b) }
+
+type OutcomeSink interface {
+ LearnOutcome(context.Context, TicketOutcome) (string, error)
+}
+
+type NeuroForgeOutcomeSink struct {
+ baseURL, apiKey string
+ http *http.Client
+}
+
+func NewNeuroForgeOutcomeSink(baseURL, apiKey string, timeout time.Duration) (*NeuroForgeOutcomeSink, error) {
+ raw := strings.TrimRight(strings.TrimSpace(baseURL), "/")
+ u, err := url.Parse(raw)
+ if err != nil || u.Scheme == "" || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") {
+ return nil, fmt.Errorf("invalid neuroforge URL %q", raw)
+ }
+ if timeout <= 0 {
+ timeout = 15 * time.Second
+ }
+ return &NeuroForgeOutcomeSink{baseURL: raw, apiKey: strings.TrimSpace(apiKey), http: &http.Client{Timeout: timeout}}, nil
+}
+func (c *NeuroForgeOutcomeSink) LearnOutcome(ctx context.Context, x TicketOutcome) (string, error) {
+ body, err := json.Marshal(map[string]any{"outcome_id": x.ID, "run_id": x.RunID, "ticket_id": x.TicketID, "decision": x.Decision, "ticket_input": x.TicketInput, "proposed_reply": x.ProposedReply, "confirmed_reply": x.ConfirmedReply, "category_id": x.CategoryID, "category_name": x.CategoryName, "knowledge_id": x.KnowledgeID, "supersedes_id": x.SupersedesID, "actor": x.Actor, "note": x.Note})
+ if err != nil {
+ return "", err
+ }
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/api/v1/integrations/outcomes", bytes.NewReader(body))
+ if err != nil {
+ return "", err
+ }
+ req.Header.Set("Content-Type", "application/json")
+ if c.apiKey != "" {
+ req.Header.Set("Authorization", "Bearer "+c.apiKey)
+ }
+ resp, err := c.http.Do(req)
+ if err != nil {
+ return "", err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode/100 != 2 {
+ b, _ := io.ReadAll(io.LimitReader(resp.Body, 8192))
+ return "", fmt.Errorf("neuroforge outcome learning failed: %s: %s", resp.Status, strings.TrimSpace(string(b)))
+ }
+ var out struct {
+ Memory struct {
+ ID string `json:"id"`
+ } `json:"memory"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
+ return "", err
+ }
+ if out.Memory.ID == "" {
+ return "", fmt.Errorf("neuroforge returned no memory id")
+ }
+ return out.Memory.ID, nil
+}
diff -ruN '--exclude=MANIFEST.sha256' '--exclude=patches' '--exclude=*.zip' /mnt/data/glpi-neuroforge-mega-v1.1.0/services/agent/internal/learning/outcomes_test.go ./services/agent/internal/learning/outcomes_test.go
--- /mnt/data/glpi-neuroforge-mega-v1.1.0/services/agent/internal/learning/outcomes_test.go 1970-01-01 00:00:00.000000000 +0000
+++ ./services/agent/internal/learning/outcomes_test.go 2026-08-25 19:09:57.953094102 +0000
@@ -0,0 +1,75 @@
+package learning
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+)
+
+func TestOutcomeStorePreservesRevisionHistoryAndPersistsSync(t *testing.T) {
+ s, err := OpenOutcomes(t.TempDir(), 10)
+ if err != nil {
+ t.Fatal(err)
+ }
+ a, err := s.Add(TicketOutcome{RunID: "r1", TicketID: 1, Decision: "accepted", TicketInput: "problem", ProposedReply: "fix", ConfirmedReply: "fix", Actor: "tech"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ b, err := s.Add(TicketOutcome{RunID: "r1", TicketID: 1, Decision: "corrected", TicketInput: "problem", ProposedReply: "fix", ConfirmedReply: "better", Actor: "tech"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(s.List()) != 2 || a.ID == b.ID || b.SupersedesID != a.ID {
+ t.Fatalf("expected immutable revision history: %#v", s.List())
+ }
+ retry, err := s.Add(TicketOutcome{RunID: "r1", TicketID: 1, Decision: "corrected", TicketInput: "problem", ProposedReply: "fix", ConfirmedReply: "better", Actor: "tech"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if retry.ID != b.ID || len(s.List()) != 2 {
+ t.Fatalf("exact repeated human decision must be idempotent: retry=%#v list=%#v", retry, s.List())
+ }
+ got, err := s.UpdateSync(b.ID, "learned", "mem-1", "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.SyncStatus != "learned" || got.NeuroForgeID != "mem-1" {
+ t.Fatalf("unexpected sync: %#v", got)
+ }
+}
+
+func TestNeuroForgeOutcomeSink(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/api/v1/integrations/outcomes" {
+ http.NotFound(w, r)
+ return
+ }
+ if r.Header.Get("Authorization") != "Bearer secret" {
+ http.Error(w, "unauthorized", 401)
+ return
+ }
+ var v map[string]any
+ if err := json.NewDecoder(r.Body).Decode(&v); err != nil {
+ t.Fatal(err)
+ }
+ if v["decision"] != "accepted" {
+ t.Fatalf("unexpected payload %#v", v)
+ }
+ _ = json.NewEncoder(w).Encode(map[string]any{"memory": map[string]any{"id": "mem-7"}})
+ }))
+ defer srv.Close()
+ sink, err := NewNeuroForgeOutcomeSink(srv.URL, "secret", time.Second)
+ if err != nil {
+ t.Fatal(err)
+ }
+ id, err := sink.LearnOutcome(context.Background(), TicketOutcome{ID: "o", RunID: "r", TicketID: 1, Decision: "accepted", TicketInput: "p", ProposedReply: "a", ConfirmedReply: "a", Actor: "tech"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if id != "mem-7" {
+ t.Fatalf("id=%q", id)
+ }
+}
diff -ruN '--exclude=MANIFEST.sha256' '--exclude=patches' '--exclude=*.zip' /mnt/data/glpi-neuroforge-mega-v1.1.0/services/agent/internal/model/model.go ./services/agent/internal/model/model.go
--- /mnt/data/glpi-neuroforge-mega-v1.1.0/services/agent/internal/model/model.go 2026-08-25 18:19:03.554284889 +0000
+++ ./services/agent/internal/model/model.go 2026-08-25 19:01:07.791696816 +0000
@@ -618,6 +618,8 @@
AIKnowledgeID string `json:"ai_knowledge_id,omitempty"`
ReplyDecision string `json:"reply_decision,omitempty"`
ReplyProposed bool `json:"reply_proposed"`
+ ReplyProposedText string `json:"reply_proposed_text,omitempty"`
+ LearningTicketText string `json:"learning_ticket_text,omitempty"`
ReplyWritten bool `json:"reply_written"`
KnowledgeID string `json:"knowledge_id,omitempty"`
KnowledgeTopID string `json:"knowledge_top_id,omitempty"`
diff -ruN '--exclude=MANIFEST.sha256' '--exclude=patches' '--exclude=*.zip' /mnt/data/glpi-neuroforge-mega-v1.1.0/services/agent/internal/web/server.go ./services/agent/internal/web/server.go
--- /mnt/data/glpi-neuroforge-mega-v1.1.0/services/agent/internal/web/server.go 2026-08-25 18:20:01.518776121 +0000
+++ ./services/agent/internal/web/server.go 2026-08-25 19:06:33.970451716 +0000
@@ -19,6 +19,7 @@
"github.com/example/glpi-ai-agent/internal/config"
knowledgepkg "github.com/example/glpi-ai-agent/internal/knowledge"
+ "github.com/example/glpi-ai-agent/internal/learning"
"github.com/example/glpi-ai-agent/internal/metrics"
"github.com/example/glpi-ai-agent/internal/model"
"github.com/example/glpi-ai-agent/internal/obsidian"
@@ -49,6 +50,8 @@
LearningExamples() []model.LearningExample
DeleteLearning(string) error
LearningCount() int
+ RecordTicketOutcome(context.Context, string, string, string, string, string) (learning.TicketOutcome, error)
+ TicketOutcomes() []learning.TicketOutcome
}
type DiagnosticsManager interface {
@@ -112,6 +115,8 @@
mux.Handle("GET /api/learning", s.auth(http.HandlerFunc(s.learningList)))
mux.Handle("POST /api/learning", s.auth(s.mutation(http.HandlerFunc(s.learningAdd))))
mux.Handle("DELETE /api/learning/{id}", s.auth(s.mutation(http.HandlerFunc(s.learningDelete))))
+ mux.Handle("GET /api/outcomes", s.auth(http.HandlerFunc(s.outcomeList)))
+ mux.Handle("POST /api/outcomes", s.auth(s.mutation(http.HandlerFunc(s.outcomeAdd))))
mux.Handle("POST /api/tickets/{id}/reprocess", s.auth(s.mutation(http.HandlerFunc(s.reprocessTicket))))
mux.HandleFunc("POST /webhook/glpi", s.webhook)
return securityHeaders(requestLog(mux))
@@ -427,6 +432,7 @@
"context_enabled": s.cfg.ContextEnabled, "context_fetches": s.metrics.ContextFetches.Load(), "context_errors": s.metrics.ContextErrors.Load(),
"change_calendar_enabled": s.cfg.ChangeCalendarEnabled, "major_incidents_enabled": s.cfg.MajorIncidentsEnabled, "user_device_context_enabled": s.cfg.UserDeviceContextEnabled,
"knowledge_edit_enabled": s.cfg.KnowledgeWebEditEnabled, "learning_enabled": s.cfg.LearningEnabled, "learning_examples": s.feedback.LearningCount(),
+ "outcome_learning_enabled": s.cfg.OutcomeLearningEnabled, "outcome_learning_fail_open": s.cfg.OutcomeLearningFailOpen, "validated_outcomes": len(s.feedback.TicketOutcomes()),
"glpi_kb_enabled": s.cfg.GLPIKBEnabled, "glpi_kb_ok": kbOK, "glpi_kb_documents": kbDocs, "glpi_kb_last_sync": kbLastSync, "glpi_kb_last_error": kbLastErr, "glpi_kb_source": s.cfg.GLPIKBSource, "glpi_kb_sync_interval": s.cfg.GLPIKBSyncInterval.String(), "glpi_kb_auto_reply_approved": glpiKBAutoReplyApproved, "glpi_kb_auto_reply_blocked": glpiKBAutoReplyBlocked, "glpi_kb_auto_reply_decisions": glpiKBAutoReplyDecisions,
"uptime_kuma_enabled": s.cfg.UptimeKumaEnabled, "uptime_kuma_mode": s.cfg.UptimeKumaMode, "uptime_kuma_status_pages": s.cfg.UptimeKumaStatusPages, "context_fail_closed": s.cfg.ContextBlockReplyOnError, "context_incident_block": s.cfg.ContextBlockReplyOnIncident,
"context_status_reply_enabled": s.cfg.ContextStatusReplyEnabled, "context_status_reply_min_relevance": s.cfg.ContextStatusReplyMinRelevance, "context_status_reply_min_ai_confidence": s.cfg.ContextStatusReplyMinAIConfidence, "context_status_reply_min_final_score": s.cfg.ContextStatusReplyMinFinalScore, "context_incident_reply_text_configured": strings.TrimSpace(s.cfg.ContextIncidentReplyText) != "", "context_maintenance_reply_text_configured": strings.TrimSpace(s.cfg.ContextMaintenanceReplyText) != "",
@@ -652,6 +658,36 @@
}
w.WriteHeader(http.StatusNoContent)
}
+func (s *Server) outcomeList(w http.ResponseWriter, r *http.Request) {
+ respondJSON(w, s.feedback.TicketOutcomes())
+}
+func (s *Server) outcomeAdd(w http.ResponseWriter, r *http.Request) {
+ if !s.cfg.OutcomeLearningEnabled {
+ http.Error(w, "outcome learning disabled", http.StatusForbidden)
+ return
+ }
+ var in struct {
+ RunID string `json:"run_id"`
+ Decision string `json:"decision"`
+ CorrectedReply string `json:"corrected_reply,omitempty"`
+ Note string `json:"note,omitempty"`
+ }
+ if err := json.NewDecoder(io.LimitReader(r.Body, 64<<10)).Decode(&in); err != nil {
+ http.Error(w, "invalid outcome feedback", http.StatusBadRequest)
+ return
+ }
+ actor := strings.TrimSpace(s.cfg.WebUsername)
+ if actor == "" {
+ actor = "authenticated-technician"
+ }
+ x, err := s.feedback.RecordTicketOutcome(r.Context(), in.RunID, in.Decision, in.CorrectedReply, in.Note, actor)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusUnprocessableEntity)
+ return
+ }
+ respondJSON(w, x)
+}
+
func (s *Server) mutation(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("X-Requested-With") != "GLPI-AI-Agent" {
diff -ruN '--exclude=MANIFEST.sha256' '--exclude=patches' '--exclude=*.zip' /mnt/data/glpi-neuroforge-mega-v1.1.0/services/agent/internal/web/server_test.go ./services/agent/internal/web/server_test.go
--- /mnt/data/glpi-neuroforge-mega-v1.1.0/services/agent/internal/web/server_test.go 2026-08-05 17:17:23.000000000 +0000
+++ ./services/agent/internal/web/server_test.go 2026-08-25 19:03:01.329955146 +0000
@@ -15,6 +15,7 @@
"github.com/example/glpi-ai-agent/internal/config"
"github.com/example/glpi-ai-agent/internal/knowledge"
+ "github.com/example/glpi-ai-agent/internal/learning"
"github.com/example/glpi-ai-agent/internal/metrics"
"github.com/example/glpi-ai-agent/internal/model"
"github.com/example/glpi-ai-agent/internal/queue"
@@ -59,6 +60,10 @@
func (f fakeFeedback) LearningExamples() []model.LearningExample { return nil }
func (f fakeFeedback) DeleteLearning(string) error { return os.ErrNotExist }
func (f fakeFeedback) LearningCount() int { return 0 }
+func (f fakeFeedback) RecordTicketOutcome(context.Context, string, string, string, string, string) (learning.TicketOutcome, error) {
+ return learning.TicketOutcome{}, nil
+}
+func (f fakeFeedback) TicketOutcomes() []learning.TicketOutcome { return nil }
func newKnowledgeTestServer(t *testing.T) (http.Handler, *knowledge.Store) {
t.Helper()
diff -ruN '--exclude=MANIFEST.sha256' '--exclude=patches' '--exclude=*.zip' /mnt/data/glpi-neuroforge-mega-v1.1.0/services/agent/internal/web/templates/dashboard.html ./services/agent/internal/web/templates/dashboard.html
--- /mnt/data/glpi-neuroforge-mega-v1.1.0/services/agent/internal/web/templates/dashboard.html 2026-08-25 18:23:14.811739258 +0000
+++ ./services/agent/internal/web/templates/dashboard.html 2026-08-25 19:02:44.110896273 +0000
@@ -107,7 +107,7 @@
const pct=v=>`${Math.round(Number(v||0)*100)} %`; const fmtNum=v=>new Intl.NumberFormat('de-DE').format(Number(v||0));
const fmtDate=v=>{if(!v)return '–';const d=new Date(v);return Number.isNaN(d.getTime())?'–':d.toLocaleString('de-DE')};
const clamp=v=>Math.max(0,Math.min(1,Number(v||0))); const scoreClass=v=>Number(v)>=.8?'good':Number(v)>=.6?'warn':'bad';
-let statusData={},runsData=[],categories=[],kbDocs=[],learningRows=[],currentRun=null,currentKbId='',currentLearnRun='',kbCategorySelection=new Set();
+let statusData={},runsData=[],categories=[],kbDocs=[],learningRows=[],outcomeRows=[],currentRun=null,currentKbId='',currentLearnRun='',kbCategorySelection=new Set();
const viewMeta={overview:['Übersicht','Gesundheit, Verarbeitung und die wichtigsten Stellschrauben auf einen Blick.'],runs:['Verarbeitungen','Audit-Trail mit KI-, Policy-, RAG- und Kontextdetails.'],knowledge:['Knowledge Base','Interne und synchronisierte Wissensquellen verwalten und kontrollieren.'],learning:['Bestätigtes Lernen','Menschlich bestätigte Beispiele, die die Kategorisierung schrittweise verbessern.'],settings:['Effektive Konfiguration','Alle nicht-geheimen Laufzeitwerte zum Tuning und Troubleshooting.']};
function toast(text,kind='good'){const n=document.createElement('div');n.className=`toast ${kind}`;n.textContent=text;$('#toasts').appendChild(n);setTimeout(()=>n.remove(),5000)}
async function api(url,opt={}){const headers={...(opt.headers||{}),'X-Requested-With':'GLPI-AI-Agent'};if(opt.body)headers['Content-Type']='application/json';const r=await fetch(url,{...opt,headers});if(!r.ok){const t=(await r.text()).trim();throw new Error(t||`HTTP ${r.status}`)}if(r.status===204)return null;return r.json()}
@@ -136,7 +136,7 @@
<div class="detail-box full"><div class="detail-heading">Top-Knowledge-Kandidat</div>${x.knowledge_top_id?`${progress('Retrieval / Ranking',x.knowledge_score)}${x.knowledge_evidence_score?progress('Finale Evidenz',x.knowledge_evidence_score):''}${progress('Semantik (raw)',x.knowledge_semantic_score)}${progress('Titel',x.knowledge_title_score)}${progress('Lexikalisch',x.knowledge_lexical_score)}${progress('Keywords',x.knowledge_keyword_score)}${progress('Kategorie/Lernen',x.knowledge_category_score)}<div class="muted small">${esc(x.knowledge_top_title)} · ${esc(x.knowledge_top_id)} · Retrieval-Floor ${esc(pct(x.knowledge_retrieval_floor||0))} · Evidenz erforderlich ${esc(pct(x.knowledge_threshold))}${x.knowledge_category_aligned?' · Kategorie exakt zugeordnet':''}</div>`:'<div class="muted">Kein Treffer.</div>'}</div>
<div class="detail-box full"><div class="detail-heading">KI-Begründung</div><div class="reason-text">${esc(x.ai_reason||x.reason||'–')}</div></div><div class="detail-box full"><div class="detail-heading">Policy</div><div class="reason-text">${esc(x.policy_reason||'–')}</div>${x.error?`<div class="reason-text" style="color:#ffb5b5;margin-top:9px"><strong>Fehler:</strong> ${esc(x.error)}</div>`:''}</div>
<div class="detail-box full"><div class="detail-heading">Knowledge-Ranking</div><div class="muted small" style="margin-bottom:10px">An KI gesendet: ${esc(x.knowledge_llm_candidates||0)} · Kandidaten-Cutoff ${esc(pct(x.knowledge_candidate_cutoff||0))} · Max. Abstand ${esc(pct(x.knowledge_candidate_max_gap||0))} · Audit Top K ${esc(x.knowledge_audit_top_k||0)}</div>${candidates}</div><div class="detail-box full"><div class="detail-heading">Kontextquellen</div>${contexts}${(x.context_warnings||[]).length?`<div class="notice warn" style="margin-top:10px">${x.context_warnings.map(esc).join('<br>')}</div>`:''}</div>
- <div class="detail-box full"><div class="detail-heading">Lernen</div>${categories.length?`<button class="btn small primary" data-learn-run="${esc(x.run_id)}" data-learn-cat="${esc(x.ai_recommended_category_id||0)}">Kategorie bestätigen / korrigieren</button>`:'<span class="muted">Kategorien nicht geladen.</span>'}</div>
+ <div class="detail-box full"><div class="detail-heading">Bestätigtes Lernen</div>${categories.length?`<button class="btn small" data-learn-run="${esc(x.run_id)}" data-learn-cat="${esc(x.ai_recommended_category_id||0)}">Kategorie bestätigen / korrigieren</button>`:'<span class="muted">Kategorien nicht geladen.</span>'}${x.reply_proposed&&x.reply_proposed_text?` <button class="btn small primary" data-outcome-accept="${esc(x.run_id)}">KI-Antwort bestätigen</button> <button class="btn small" data-outcome-correct="${esc(x.run_id)}">KI-Antwort korrigieren</button>${(()=>{const o=outcomeRows.find(v=>v.run_id===x.run_id);return o?` <span class="badge ${o.sync_status==='learned'?'good':o.sync_status==='failed'?'bad':'warn'}">Outcome: ${esc(o.decision)} · ${esc(o.sync_status)}</span>`:''})()}`:' <span class="muted small">Keine lernfähige Antwort vorgeschlagen.</span>'}<div class="muted small" style="margin-top:8px">Erst die explizite Bestätigung oder Korrektur durch einen Techniker wird als verifiziertes NeuroForge-Wissen gespeichert.</div></div>
<div class="detail-box full"><details><summary class="small muted" style="cursor:pointer">Audit-JSON anzeigen</summary><pre class="raw">${esc(JSON.stringify(x,null,2))}</pre></details></div></div>`;openRunDrawer()}
function openRunDrawer(){ $('#runBackdrop').classList.add('show');$('#runDrawer').classList.add('show') } function closeRunDrawer(){ $('#runBackdrop').classList.remove('show');$('#runDrawer').classList.remove('show');currentRun=null }
function kbStatsData(){const managed=kbDocs.filter(x=>x.managed).length,glpi=kbDocs.filter(x=>x.source==='glpi-kb').length,auto=kbDocs.filter(x=>x.auto_reply).length;return [['Gesamt',kbDocs.length,'geladene Artikel'],['Web-verwaltet',managed,'editierbar'],['GLPI-KB',glpi,'read-only synchronisiert'],['Auto-Reply',auto,'grundsätzlich freigegeben']]}
@@ -159,15 +159,17 @@
function closeLearn(){$('#learnBackdrop').classList.remove('show');currentLearnRun=''}
async function saveLearning(){const b=$('#saveLearnBtn');b.disabled=true;try{await api('/api/learning',{method:'POST',body:JSON.stringify({run_id:currentLearnRun,category_id:Number($('#learnCategory').value)})});toast('Kategorie als Lernbeispiel gespeichert.');closeLearn();await loadLearning();await loadRuns()}catch(e){toast(e.message,'error')}finally{b.disabled=false}}
async function deleteLearning(id){if(!confirm('Lernbeispiel wirklich löschen?'))return;try{await api(`/api/learning/${encodeURIComponent(id)}`,{method:'DELETE'});toast('Lernbeispiel gelöscht.');await loadLearning()}catch(e){toast(e.message,'error')}}
+async function recordOutcome(runID,decision){const r=runsData.find(x=>x.run_id===runID);if(!r)return;let corrected='';if(decision==='corrected'){corrected=prompt('Korrigierte, fachlich bestätigte Antwort:',r.reply_proposed_text||'')||'';if(!corrected.trim())return}const note=prompt('Optionaler Prüfvermerk / Grund (kann leer bleiben):','')||'';if(!confirm(decision==='accepted'?'Diese KI-Antwort als technisch bestätigt lernen?':'Diese korrigierte Antwort als technisch bestätigt lernen?'))return;try{await api('/api/outcomes',{method:'POST',body:JSON.stringify({run_id:runID,decision,corrected_reply:corrected,note})});toast('Bestätigtes Ticket-Outcome wurde an NeuroForge übergeben.');await loadOutcomes();await loadRuns();const fresh=runsData.find(x=>x.run_id===runID);if(fresh)renderRunDrawer(fresh)}catch(e){toast(e.message,'error')}}
async function reprocessTicket(){const id=Number($('#reprocessTicketID').value||0);if(!Number.isInteger(id)||id<=0){toast('Bitte eine gültige Ticket-ID eingeben.','error');return}const live=!statusData.dry_run;if(live&&!confirm(`Ticket #${id} im LIVE-Modus neu analysieren? Konfigurierte Auto-Aktionen können ausgeführt werden.`))return;const b=$('#reprocessTicketBtn');b.disabled=true;try{await api(`/api/tickets/${id}/reprocess`,{method:'POST',body:'{}'});toast(`Ticket #${id} wurde zur Neuanalyse eingereiht.`);setTimeout(()=>refreshAll(),800)}catch(e){toast(e.message,'error')}finally{b.disabled=false}}
async function loadStatus(){statusData=await api('/api/status');renderStatusChrome();renderOverview();renderConfig();renderSourceOptions()}
async function loadRuns(){const limit=Number($('#runLimit').value||50);runsData=await api(`/api/runs?limit=${limit}`);renderRuns();renderOverview()}
async function loadKnowledge(){kbDocs=await api('/api/knowledge');renderSourceOptions();renderKB();renderOverview()}
async function loadLearning(){learningRows=await api('/api/learning');renderLearning();renderOverview()}
+async function loadOutcomes(){outcomeRows=await api('/api/outcomes')}
async function loadCategories(){categories=await api('/api/categories');renderCategoryPicker($('#kbCategorySearch').value||'')}
-async function refreshAll(showToast=false){$('#refreshBtn').disabled=true;try{await Promise.all([loadStatus(),loadRuns(),loadKnowledge(),loadLearning(),loadCategories()]);renderSourceOptions();renderOverview();renderRuns();renderKB();renderLearning();renderConfig();if(showToast)toast('Dashboard aktualisiert.')}catch(e){toast(e.message,'error')}finally{$('#refreshBtn').disabled=false;$('#lastRefresh').textContent=new Date().toLocaleTimeString('de-DE')}}
+async function refreshAll(showToast=false){$('#refreshBtn').disabled=true;try{await Promise.all([loadStatus(),loadRuns(),loadKnowledge(),loadLearning(),loadOutcomes(),loadCategories()]);renderSourceOptions();renderOverview();renderRuns();renderKB();renderLearning();renderConfig();if(showToast)toast('Dashboard aktualisiert.')}catch(e){toast(e.message,'error')}finally{$('#refreshBtn').disabled=false;$('#lastRefresh').textContent=new Date().toLocaleTimeString('de-DE')}}
-$('#nav').addEventListener('click',e=>{const b=e.target.closest('[data-view]');if(b)setView(b.dataset.view)});document.addEventListener('click',e=>{const g=e.target.closest('[data-goto]');if(g)setView(g.dataset.goto);const row=e.target.closest('[data-run-id]');if(row){const x=runsData.find(r=>r.run_id===row.dataset.runId);if(x)renderRunDrawer(x)}const ed=e.target.closest('[data-kb-edit]');if(ed)editKB(ed.dataset.kbEdit);const del=e.target.closest('[data-kb-delete]');if(del)deleteKB(del.dataset.kbDelete);const ld=e.target.closest('[data-learning-delete]');if(ld)deleteLearning(ld.dataset.learningDelete);const lr=e.target.closest('[data-learn-run]');if(lr)openLearn(lr.dataset.learnRun,Number(lr.dataset.learnCat||0))});
+$('#nav').addEventListener('click',e=>{const b=e.target.closest('[data-view]');if(b)setView(b.dataset.view)});document.addEventListener('click',e=>{const g=e.target.closest('[data-goto]');if(g)setView(g.dataset.goto);const row=e.target.closest('[data-run-id]');if(row){const x=runsData.find(r=>r.run_id===row.dataset.runId);if(x)renderRunDrawer(x)}const ed=e.target.closest('[data-kb-edit]');if(ed)editKB(ed.dataset.kbEdit);const del=e.target.closest('[data-kb-delete]');if(del)deleteKB(del.dataset.kbDelete);const ld=e.target.closest('[data-learning-delete]');if(ld)deleteLearning(ld.dataset.learningDelete);const lr=e.target.closest('[data-learn-run]');if(lr)openLearn(lr.dataset.learnRun,Number(lr.dataset.learnCat||0));const oa=e.target.closest('[data-outcome-accept]');if(oa)recordOutcome(oa.dataset.outcomeAccept,'accepted');const oc=e.target.closest('[data-outcome-correct]');if(oc)recordOutcome(oc.dataset.outcomeCorrect,'corrected')});
$('#refreshBtn').addEventListener('click',()=>refreshAll(true));$('#reprocessTicketBtn').addEventListener('click',reprocessTicket);$('#reprocessTicketID').addEventListener('keydown',e=>{if(e.key==='Enter')reprocessTicket()});$('#runSearch').addEventListener('input',renderRuns);$('#runOutcome').addEventListener('change',renderRuns);$('#runLimit').addEventListener('change',loadRuns);$('#kbSearch').addEventListener('input',renderKB);$('#kbSourceFilter').addEventListener('change',renderKB);$('#kbManageFilter').addEventListener('change',renderKB);$('#learningSearch').addEventListener('input',renderLearning);$('#newKbBtn').addEventListener('click',()=>{clearKbForm();openKbModal()});$('#closeKbModal').addEventListener('click',closeKbModal);$('#cancelKbBtn').addEventListener('click',closeKbModal);$('#kbModalBackdrop').addEventListener('click',e=>{if(e.target===e.currentTarget)closeKbModal()});$('#kbForm').addEventListener('submit',saveKB);$('#kbCategorySearch').addEventListener('input',e=>renderCategoryPicker(e.target.value));$('#kbCategoryList').addEventListener('change',e=>{if(e.target.type==='checkbox'){const id=Number(e.target.value);e.target.checked?kbCategorySelection.add(id):kbCategorySelection.delete(id)}});$('#kbText').addEventListener('input',updateCounts);$('#kbAnswer').addEventListener('input',updateCounts);$('#runBackdrop').addEventListener('click',closeRunDrawer);$('#closeRunDrawer').addEventListener('click',closeRunDrawer);$('#closeLearnModal').addEventListener('click',closeLearn);$('#cancelLearnBtn').addEventListener('click',closeLearn);$('#saveLearnBtn').addEventListener('click',saveLearning);$('#learnBackdrop').addEventListener('click',e=>{if(e.target===e.currentTarget)closeLearn()});document.addEventListener('keydown',e=>{if(e.key==='Escape'){closeRunDrawer();closeKbModal();closeLearn()}});
setView(location.hash.replace('#','')||'overview');refreshAll();setInterval(async()=>{try{await Promise.all([loadStatus(),loadRuns()])}catch{}},10000);
</script>
diff -ruN '--exclude=MANIFEST.sha256' '--exclude=patches' '--exclude=*.zip' /mnt/data/glpi-neuroforge-mega-v1.1.0/services/control/index.html ./services/control/index.html
--- /mnt/data/glpi-neuroforge-mega-v1.1.0/services/control/index.html 2026-08-25 15:57:41.000000000 +0000
+++ ./services/control/index.html 2026-08-25 19:06:48.463471065 +0000
@@ -1,10 +1,10 @@
<!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>GLPI NeuroForge Control Center</title><style>
-:root{color-scheme:dark;font-family:Inter,system-ui,sans-serif;background:#0c1118;color:#e8eef7}body{max-width:1180px;margin:0 auto;padding:32px 20px}h1{font-size:30px;margin:0 0 6px}p{color:#aab7c7}.bar,.grid{display:grid;gap:14px}.bar{grid-template-columns:repeat(4,minmax(0,1fr));margin:24px 0}.grid{grid-template-columns:repeat(3,minmax(0,1fr))}.card{background:#121a24;border:1px solid #263244;border-radius:14px;padding:18px}.label{font-size:12px;text-transform:uppercase;color:#8fa1b7;letter-spacing:.08em}.value{font-size:20px;font-weight:700;margin-top:7px}.ok{color:#8de5ac}.bad{color:#ff9f9f}.muted{color:#92a1b4}.service h2{margin:0 0 8px;font-size:18px}.service a{color:#8fc5ff}.detail{white-space:pre-wrap;font:12px ui-monospace,monospace;background:#0b1016;padding:10px;border-radius:9px;max-height:220px;overflow:auto}.footer{margin-top:24px;font-size:13px}@media(max-width:850px){.bar,.grid{grid-template-columns:1fr}}</style></head><body>
+:root{color-scheme:dark;font-family:Inter,system-ui,sans-serif;background:#0c1118;color:#e8eef7}body{max-width:1180px;margin:0 auto;padding:32px 20px}h1{font-size:30px;margin:0 0 6px}p{color:#aab7c7}.bar,.grid{display:grid;gap:14px}.bar{grid-template-columns:repeat(auto-fit,minmax(180px,1fr));margin:24px 0}.grid{grid-template-columns:repeat(3,minmax(0,1fr))}.card{background:#121a24;border:1px solid #263244;border-radius:14px;padding:18px}.label{font-size:12px;text-transform:uppercase;color:#8fa1b7;letter-spacing:.08em}.value{font-size:20px;font-weight:700;margin-top:7px}.ok{color:#8de5ac}.bad{color:#ff9f9f}.muted{color:#92a1b4}.service h2{margin:0 0 8px;font-size:18px}.service a{color:#8fc5ff}.detail{white-space:pre-wrap;font:12px ui-monospace,monospace;background:#0b1016;padding:10px;border-radius:9px;max-height:220px;overflow:auto}.footer{margin-top:24px;font-size:13px}@media(max-width:850px){.bar,.grid{grid-template-columns:1fr}}</style></head><body>
<h1>GLPI × NeuroForge Control Center</h1><p>Read-only Betriebsübersicht. Entscheidungen und GLPI-Schreibregeln bleiben im Agenten; NeuroForge liefert Gedächtnis, Vektorindex und Audit-Events.</p>
-<div class="bar"><div class="card"><div class="label">Vector Backend</div><div id="mode" class="value">…</div></div><div class="card"><div class="label">Search K</div><div id="searchk" class="value">…</div></div><div class="card"><div class="label">Fail Policy</div><div id="fail" class="value">…</div></div><div class="card"><div class="label">Control Plane</div><div class="value">Read-only</div></div></div>
+<div class="bar"><div class="card"><div class="label">Vector Backend</div><div id="mode" class="value">…</div></div><div class="card"><div class="label">Search K</div><div id="searchk" class="value">…</div></div><div class="card"><div class="label">Fail Policy</div><div id="fail" class="value">…</div></div><div class="card"><div class="label">Controlled Learning</div><div id="controlled" class="value">…</div></div><div class="card"><div class="label">Outcome Learning</div><div id="outcome" class="value">…</div></div><div class="card"><div class="label">Research / SearXNG</div><div id="research" class="value">…</div></div><div class="card"><div class="label">Autonomy</div><div id="autonomy" class="value">…</div></div><div class="card"><div class="label">Control Plane</div><div class="value">Read-only</div></div></div>
<div id="services" class="grid"></div><div class="footer muted">Status wird alle 10 Sekunden aktualisiert. Keine Secrets werden angezeigt.</div>
<script>
const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
-async function load(){try{const [c,r]=await Promise.all([fetch('/api/config',{cache:'no-store'}).then(x=>x.json()),fetch('/api/status',{cache:'no-store'}).then(x=>x.json())]);mode.textContent=c.vector_backend;searchk.textContent=c.neuroforge_search_k;fail.textContent=c.neuroforge_fail_open==='true'?'fail-open':'fail-closed';services.innerHTML=(r.services||[]).map(s=>`<div class="card service"><h2>${esc(s.name)}</h2><div class="${s.ok?'ok':'bad'}"><b>${s.ok?'ONLINE':'PROBLEM'}</b> · HTTP ${s.status||'–'} · ${s.latency_ms} ms</div><p><a href="${esc(s.public_url)}" target="_blank" rel="noopener">Oberfläche öffnen</a></p><div class="detail">${esc(JSON.stringify(s.detail||s.error||{},null,2))}</div></div>`).join('')}catch(e){services.innerHTML=`<div class="card bad">Control-Status nicht verfügbar: ${esc(e.message)}</div>`}}
+async function load(){try{const [c,r]=await Promise.all([fetch('/api/config',{cache:'no-store'}).then(x=>x.json()),fetch('/api/status',{cache:'no-store'}).then(x=>x.json())]);mode.textContent=c.vector_backend;searchk.textContent=c.neuroforge_search_k;fail.textContent=c.neuroforge_fail_open==='true'?'fail-open':'fail-closed';controlled.textContent=c.controlled_learning==='true'?'ON':'OFF';outcome.textContent=c.outcome_learning==='true'?'ON':'OFF';research.textContent=(c.research_enabled==='true'?'ON':'OFF')+' / '+(c.searxng_enabled==='true'?'SearXNG':'kein SearXNG');autonomy.textContent=c.autonomy_enabled==='true'?'ON':'OFF';services.innerHTML=(r.services||[]).map(s=>`<div class="card service"><h2>${esc(s.name)}</h2><div class="${s.ok?'ok':'bad'}"><b>${s.ok?'ONLINE':'PROBLEM'}</b> · HTTP ${s.status||'–'} · ${s.latency_ms} ms</div><p><a href="${esc(s.public_url)}" target="_blank" rel="noopener">Oberfläche öffnen</a></p><div class="detail">${esc(JSON.stringify(s.detail||s.error||{},null,2))}</div></div>`).join('')}catch(e){services.innerHTML=`<div class="card bad">Control-Status nicht verfügbar: ${esc(e.message)}</div>`}}
load();setInterval(load,10000);
</script></body></html>
diff -ruN '--exclude=MANIFEST.sha256' '--exclude=patches' '--exclude=*.zip' /mnt/data/glpi-neuroforge-mega-v1.1.0/services/control/main.go ./services/control/main.go
--- /mnt/data/glpi-neuroforge-mega-v1.1.0/services/control/main.go 2026-08-25 15:57:42.000000000 +0000
+++ ./services/control/main.go 2026-08-25 19:06:34.198238052 +0000
@@ -35,11 +35,16 @@
}
type server struct {
- http *http.Client
- targets []target
- vectorMode string
- neuroforgeSearchK string
- failOpen string
+ http *http.Client
+ targets []target
+ vectorMode string
+ neuroforgeSearchK string
+ failOpen string
+ controlledLearning string
+ outcomeLearning string
+ researchEnabled string
+ searxngEnabled string
+ autonomyEnabled string
}
func env(k, d string) string {
@@ -50,7 +55,7 @@
}
func main() {
- s := &server{http: &http.Client{Timeout: 4 * time.Second}, vectorMode: env("KNOWLEDGE_VECTOR_BACKEND", "dual"), neuroforgeSearchK: env("NEUROFORGE_SEARCH_K", "128"), failOpen: env("NEUROFORGE_FAIL_OPEN", "true")}
+ s := &server{http: &http.Client{Timeout: 4 * time.Second}, vectorMode: env("KNOWLEDGE_VECTOR_BACKEND", "dual"), neuroforgeSearchK: env("NEUROFORGE_SEARCH_K", "128"), failOpen: env("NEUROFORGE_FAIL_OPEN", "true"), controlledLearning: env("NEUROFORGE_CONTROLLED_LEARNING", "true"), outcomeLearning: env("OUTCOME_LEARNING_ENABLED", "true"), researchEnabled: env("NEUROFORGE_RESEARCH_ENABLED", "false"), searxngEnabled: env("NEUROFORGE_SEARXNG_ENABLED", "false"), autonomyEnabled: env("NEUROFORGE_AUTONOMY_ENABLED", "false")}
nfKey := strings.TrimSpace(os.Getenv("NEUROFORGE_API_KEY"))
if nfKey != "" {
nfKey = "Bearer " + nfKey
@@ -91,7 +96,7 @@
}
func (s *server) handleConfig(w http.ResponseWriter, r *http.Request) {
- writeJSON(w, 200, map[string]any{"vector_backend": s.vectorMode, "neuroforge_search_k": s.neuroforgeSearchK, "neuroforge_fail_open": s.failOpen, "control_plane": "read-only", "policy_owner": "glpi-agent"})
+ writeJSON(w, 200, map[string]any{"vector_backend": s.vectorMode, "neuroforge_search_k": s.neuroforgeSearchK, "neuroforge_fail_open": s.failOpen, "controlled_learning": s.controlledLearning, "outcome_learning": s.outcomeLearning, "research_enabled": s.researchEnabled, "searxng_enabled": s.searxngEnabled, "autonomy_enabled": s.autonomyEnabled, "control_plane": "read-only", "policy_owner": "glpi-agent"})
}
func (s *server) handleStatus(w http.ResponseWriter, r *http.Request) {