Update 4
All checks were successful
release-tag / release-image (push) Successful in 1m42s

This commit is contained in:
2026-08-04 14:23:48 +02:00
parent e94fb23f0c
commit 5387563e4e
14 changed files with 883 additions and 165 deletions

View File

@@ -6,10 +6,11 @@ GLPI Knowledge Base ─────ro──┼──► Ingest ─► In-Memory
Agent runs.jsonl ─────────ro──┘ │
├──► Ollama Pool
├──► Relation Thinking
├──► Knowledge Synthesis / Research
├──► Knowledge Consolidation / Research / Synthesis
├──► SSE / Hierarchical LOD UI
└──► Batched Persistence
├── staging drafts
├── article metadata sidecars
├── GLPI cache
└── graph-state.json
```
@@ -22,7 +23,7 @@ Agent runs.jsonl ─────────ro──┘
- `rejected`: intern gespeicherte Prüfung ohne sichtbare Beziehung.
## Zweistufiges Thinking
## Mehrstufiges Thinking
```text
Kandidatenpaar
@@ -31,11 +32,19 @@ Kandidatenpaar
└── staging edge
└── Quellenverbund (38 Quellen)
└── Artikelplanung: create / update / merge / skip
└── Knowledge Synthesis
── strukturierter AI-Synthesis-Entwurf
└── fachliche Wissenskonsolidierung
── belegte Fakten und Arbeitsschritte
├── Widersprüche und Wissenslücken
└── sequenzielle Recherche
└── erneute Konsolidierung
└── reine Artikelgenerierung
├── KB-Staging-Datei
├── Metadaten-Sidecar
├── Embedding
└── Quellen- und Recherche-Edges
```
Relation Thinking schreibt keine Artikel. Knowledge Synthesis startet nur, wenn genügend produktive Quellen, ein ausreichender Produktionsanteil und eine zulässige Generierungstiefe vorliegen. Die Artikelerstellung verwendet vollständige lokale beziehungsweise im GLPI-Cache gehaltene Quelltexte. Der Entwurf wird sofort als In-Memory-Node sichtbar und erst über die gebündelte Persistenz auf die Festplatte geschrieben.
Relation Thinking schreibt keine Artikel. Die sichtbare KB-Datei enthält ausschließlich das reguläre KB-Schema. Interne Planung, Bewertung, Confidence, Quellen-IDs und Rechercheprotokolle werden getrennt unter `BRAIN_DATA_DIR/article-metadata/` gespeichert. Der Entwurf wird sofort als In-Memory-Node sichtbar, eingebettet und mit `synthesized_from` beziehungsweise `grounded_by` verknüpft; die Festplattenschreibvorgänge bleiben gebündelt.
## Schreibmodell

View File

@@ -0,0 +1,21 @@
# Grounded Knowledge Synthesis Fix
## Behoben
- Interne Planungs- und Qualitätsbewertungen werden nicht mehr in der KB-Staging-Datei gespeichert.
- Das frühere `ai_think`-Metadatenobjekt wurde aus dem sichtbaren KB-Dokument entfernt.
- Interne Metadaten liegen nun separat unter `BRAIN_DATA_DIR/article-metadata/`.
- Vor der Artikelerstellung entsteht eine quellengebundene fachliche Wissensbasis.
- Ungeklärte Punkte erzeugen bis zu mehrere sequenzielle Rechercheanfragen.
- Nach der Recherche wird das Wissen erneut konsolidiert.
- Bei verbleibenden wesentlichen Lücken wird kein Meta- oder Platzhalterartikel geschrieben.
- Der Fachautor erhält keine Planungsbegründung als Artikelinhalt.
- Recherchebelege werden in der Qualitätsprüfung berücksichtigt.
- Neu erstellte Artikel werden sofort eingebettet und mit Quellen sowie Recherchebelegen verknüpft.
## Neue Konfiguration
```env
BRAIN_ARTICLE_MAX_RESEARCH_QUERIES=3
BRAIN_ARTICLE_RESEARCH_RESULTS=4
```

View File

@@ -1,5 +1,7 @@
# Knowledge Synthesis Upgrade
> Hinweis: Die spätere Korrektur `CHANGELOG-GROUNDED-KNOWLEDGE-SYNTHESIS.md` trennt interne Bewertungen vollständig vom sichtbaren KB-Dokument und ergänzt Wissenskonsolidierung sowie Recherche.
- Relationserkennung und Artikelerstellung sind getrennte Modellschritte.
- Eine positive Zweier-Relation erzeugt nur noch eine Graph-Edge, keinen technischen Meta-Artikel.
- Artikelplanung entscheidet `create`, `update`, `merge` oder `skip`.

View File

@@ -1,6 +1,6 @@
# Relation Thinking und Knowledge Synthesis
# Relation Thinking und Grounded Knowledge Synthesis
Das Brain trennt die autonome Arbeit jetzt in zwei unabhängige fachliche Schritte. Beide werden durch den vorhandenen **THINKING**-Schalter und den Thinking-Kategoriefilter gesteuert.
Das Brain trennt seine autonome Arbeit in klar voneinander isolierte Schritte. Interne Bewertungen werden nicht mehr als Knowledgebase-Inhalt gespeichert.
## 1. Relation Thinking
@@ -11,66 +11,102 @@ Relation Thinking beantwortet ausschließlich Graphfragen:
- Wie hoch ist die Konfidenz?
- Fehlt Evidenz für die Relationsentscheidung?
Ein positiver Entscheid erzeugt nur eine nachvollziehbare Edge mit Evidenz, Konfidenz und Herkunft. Aus einem bloßen Zweiervergleich wird **kein** Artikel mehr geschrieben.
Ein positiver Entscheid erzeugt eine nachvollziehbare Edge. Aus einem Zweiervergleich wird noch kein Artikel geschrieben.
## 2. Knowledge Synthesis
## 2. Quellenverbund
Nach einer bestätigten Relation wird geprüft, ob um das Themenpaar ein ausreichend starker Quellenverbund existiert. Standardregeln:
Nach einer bestätigten Relation sucht das Brain einen größeren Themenverbund. Standardregeln:
- mindestens drei produktive Quellen;
- höchstens acht Quellen pro Entwurf;
- höchstens acht Quellen;
- mindestens 70 Prozent Produktionswissen;
- keine reine AI-THINK-Quellenkette;
- maximale AI-Generierungstiefe zwei;
- vollständige Quelltexte werden bevorzugt, nicht nur die gekürzten Graph-Summaries;
- ein vorhandener gleichwertiger Staging-Entwurf verhindert Dubletten.
- begrenzte AI-Generierungstiefe;
- vollständige lokale und GLPI-KB-Inhalte statt nur kurzer Graph-Summaries.
Der erste Qwen-Aufruf plant eine der Aktionen:
Danach wird intern zwischen `create`, `update`, `merge` und `skip` entschieden. Diese Bewertung bleibt interne Metadaten und erscheint nicht im Artikel.
- `create`: neuen Wissensartikel anlegen;
- `update`: einen vorhandenen produktiven Artikel als Staging-Entwurf verbessern;
- `merge`: mehrere produktive Beiträge in einen Zielartikel konsolidieren;
- `skip`: kein belastbarer Mehrwert.
## 3. Fachliche Wissenskonsolidierung
Erst danach erzeugt ein zweiter, separat strukturierter Modellaufruf den eigentlichen Helpdesk-Artikel.
Vor der Texterzeugung erstellt Qwen eine interne, quellengebundene Wissensbasis:
## Artikelstruktur
- Geltungsbereich;
- belegte Fakten;
- Symptome;
- Voraussetzungen;
- konkrete Lösungsschritte;
- Validierung;
- Fehlerbehandlung;
- Widersprüche;
- fehlende Informationen;
- gezielte Rechercheanfragen.
Der Staging-Entwurf befüllt die Knowledgebase-Felder fachlich korrekt:
Jede fachliche Aussage enthält interne Quellenreferenzen. Diese Konsolidierung wird niemals als KB-Artikel gespeichert.
- `text`: Problem, Symptome, Geltungsbereich und Abgrenzung;
- `answer`: konkrete Lösungsschritte;
- zusätzliche Abschnitte: Voraussetzungen, Ergebnisprüfung und Fehlerbehandlung.
## 4. Recherche ungeklärter Punkte
Der feste Prüfhinweis steht nicht mehr als vermeintliche Lösung im Feld `answer`. Er liegt ausschließlich in `ai_think.review_notice`.
Wesentliche Lücken und ungelöste Widersprüche werden sequenziell über die konfigurierte SearXNG-Instanz recherchiert. Danach wird die Wissensbasis erneut konsolidiert.
Beispiel:
Bleiben wesentliche Lücken bestehen, wird kein Bewertungs-, Platzhalter- oder Meta-Artikel geschrieben.
```env
BRAIN_RESEARCH_ENABLED=true
SEARXNG_URL=http://searxng:8080
BRAIN_ARTICLE_MAX_RESEARCH_QUERIES=3
BRAIN_ARTICLE_RESEARCH_RESULTS=4
```
## 5. Reine Artikelgenerierung
Erst eine vollständige Wissensbasis wird an den Fachautor-Aufruf übergeben. Der sichtbare Artikel enthält ausschließlich:
- `title`: fachlicher Titel;
- `text`: Problem, Geltungsbereich und Symptome;
- `answer`: Lösungsschritte, Voraussetzungen, Ergebnisprüfung und Fehlerbehandlung;
- fachliche Kategorien und Keywords.
Unzulässig sind Formulierungen über Quellenbewertung, Mehrwert, Relation, Ähnlichkeit, Graph, Nodes, Edges, KI, Qwen, Prompt, Confidence, Staging oder den Erzeugungsprozess. Solche Entwürfe werden automatisch neu geschrieben oder verworfen.
## 6. Sauberes Knowledgebase-Dokument
Die Staging-Datei enthält nur das bekannte KB-Schema:
```json
{
"id": "KB-AI-THINK-ARTICLE-20260804-...",
"title": "VPN-Gateway-Störung systematisch beheben",
"text": "Dieser Artikel gilt für ...",
"answer": "1. Prüfen Sie ...\n\n## Ergebnis prüfen\n- Der VPN-Tunnel ...",
"text": "Der VPN-Client kann keinen Tunnel zum Gateway aufbauen ...",
"answer": "1. Prüfen Sie ...\n\n## Ergebnis prüfen\n- Der VPN-Tunnel wird aufgebaut ...",
"auto_reply": false,
"min_score": 0.82,
"categories": ["AI-THINK", "AI-Staging", "AI-Synthesis", "VPN"],
"ai_think": {
"subtype": "knowledge_synthesis",
"action": "create",
"source_node_ids": ["..."],
"productive_source_count": 4,
"ai_source_count": 1,
"production_ratio": 0.8,
"generation_depth": 1,
"confidence": 0.91,
"review_notice": "Vor produktiver Nutzung im Editor prüfen, korrigieren und freigeben."
}
"keywords": ["VPN", "Gateway"],
"source": "Neural Brain / qwen3:8b (Knowledge Synthesis)",
"source_uri": "brain://article/...",
"language": "de-DE",
"communication_style": "formal"
}
```
## Volle Quelleninhalte
Planung, Qualitätsbewertung, Confidence, Quellen-IDs, Widersprüche und Recherchebelege werden separat unter folgendem Pfad gespeichert:
Lokale Knowledge-JSONs werden für die Artikelsynthese erneut read-only von ihrem bekannten Pfad gelesen. Für GLPI-KB-Beiträge hält der GLPI-Syncer den vollständigen bereinigten Artikeltext in seinem gebündelt gespeicherten Cache. Der Browser erhält weiterhin nur den normalen Graph-Snapshot; die Volltexte sind kein zusätzliches UI-Payload.
```text
BRAIN_DATA_DIR/article-metadata/<artikel-id>.json
```
Dadurch kann der KB-Editor interne Bewertungen nicht mehr als sichtbaren Artikelinhalt behandeln.
## 7. Lernen und Verknüpfen
Nach erfolgreicher Erstellung wird der Artikel sofort:
1. als AI-Synthesis-Node in den In-Memory-Graph aufgenommen;
2. über `synthesized_from` mit allen internen Quellen verbunden;
3. über `grounded_by` mit verwendeten Recherchebelegen verbunden;
4. über `proposes_update` oder `proposes_merge` mit einem Zielartikel verbunden;
5. mit EmbeddingGemma eingebettet und für weitere semantische Verknüpfungen verfügbar gemacht.
Ist Learning im Webinterface deaktiviert, bleibt der Artikel sichtbar und verknüpft, wird aber erst nach erneuter Aktivierung des Learning-Modus eingebettet.
## Qualitätsgrenzen
@@ -83,14 +119,8 @@ BRAIN_ARTICLE_MAX_GENERATION_DEPTH=2
BRAIN_ARTICLE_MIN_CONFIDENCE=0.74
BRAIN_ARTICLE_MIN_TEXT_CHARS=180
BRAIN_ARTICLE_MIN_ANSWER_CHARS=420
BRAIN_ARTICLE_MAX_RESEARCH_QUERIES=3
BRAIN_ARTICLE_RESEARCH_RESULTS=4
```
Ein Entwurf, der diese Regeln nicht erfüllt, wird nicht geschrieben. Die Relation bleibt trotzdem im Graphen bestehen.
## Recherche
Wenn der Plan notwendige Informationslücken erkennt und `BRAIN_RESEARCH_ENABLED=true` gesetzt ist, kann vor dem Artikelentwurf kontrolliert über die konfigurierte SearXNG-Instanz recherchiert werden. Scheitert eine als notwendig deklarierte Recherche, wird kein Artikel erfunden.
## Staging und Isolation
Neue Artikel werden weiterhin ausschließlich in den ersten Pfad aus `BRAIN_STAGING_DIRS` geschrieben, tragen `auto_reply: false` und bleiben für Agent sowie produktive Knowledgebase-Suche unsichtbar, bis sie im vorhandenen Editor freigegeben werden. Der In-Memory-Graph erhält den neuen AI-THINK-Node sofort, damit Visualisierung und weitere Analyse nicht auf den nächsten Festplatten-Flush warten müssen.
Ein Entwurf, der diese Regeln nicht erfüllt, wird nicht geschrieben. Bereits erkannte belastbare Relationen bleiben dennoch im Graphen bestehen.

View File

@@ -13,7 +13,7 @@ Eigenständiger Go-Dienst für Agent, lokale Knowledgebase, GLPI-Knowledgebase u
- Read-only Tailing der Agent-`runs.jsonl` und optionale Suchtelemetrie aus Agent und KB.
- Ollama-Pool mit mehreren unabhängigen Instanzen, Routing, Healthchecks, Cooldown und Failover.
- Embeddings über `embeddinggemma`, Beziehungsanalyse über `qwen3:8b`.
- Zweistufiger autonomer Worker: Relation Thinking für Graph-Edges und Knowledge Synthesis für echte strukturierte KB-Entwürfe.
- Mehrstufiger autonomer Worker: Relation Thinking, quellengebundene Wissenskonsolidierung, Recherche offener Punkte und reine Knowledge-Synthesis für vollständige KB-Artikel.
- Gebündelte Festplattenpersistenz: Graph, GLPI-KB-Cache und AI-THINK-Dateien werden standardmäßig nur alle fünf Minuten sequenziell geschrieben.
## Vertrauens- und Schreibgrenzen
@@ -25,7 +25,14 @@ Schreibbar sind nur:
- das eigene `BRAIN_DATA_DIR` für Graphzustand und GLPI-KB-Cache;
- der erste Pfad aus `BRAIN_STAGING_DIRS` für AI-THINK-Entwürfe.
AI-THINK bleibt `auto_reply: false`, trägt die Kategorien `AI-THINK` und `AI-Staging` und wird erst nach einer expliziten Freigabe im bestehenden Editor produktiv. Artikelentwürfe erhalten zusätzlich `AI-Synthesis`; ihr Feld `answer` enthält die tatsächliche Lösung statt eines Prüfhinweises.
AI-THINK bleibt `auto_reply: false`, trägt die Kategorien `AI-THINK` und `AI-Staging` und wird erst nach einer expliziten Freigabe im bestehenden Editor produktiv. Artikelentwürfe erhalten zusätzlich `AI-Synthesis`; `text` und `answer` enthalten ausschließlich den fachlichen Artikel. Planung, Bewertung, Confidence, Quellen-IDs und Rechercheprotokolle werden separat unter `BRAIN_DATA_DIR/article-metadata/` gespeichert.
## Grounded Knowledge Synthesis
Verwandtes Wissen wird nicht direkt als Bewertungsbericht gespeichert. Das Brain konsolidiert zunächst belegte Fakten, Lösungsschritte, Widersprüche und offene Punkte. Wesentliche Unklarheiten werden über SearXNG recherchiert; anschließend wird ausschließlich der fachliche KB-Inhalt erzeugt. Der neue Artikel wird sofort eingebettet und über `synthesized_from` sowie `grounded_by` mit seinen Quellen verbunden.
Details: [`KNOWLEDGE-SYNTHESIS.md`](KNOWLEDGE-SYNTHESIS.md).
## Schnellstart

View File

@@ -1,33 +1,34 @@
8f3cb64f13d249e7a2a92d2879eead4f1a95e0807a3a569fe6c9022a78f29396 ./.env.example
606565a8a3b98a2985cb2bdc93f9c64d307a9288e58a8a55a041000cd68aae78 ./ARCHITECTURE.md
33629e5d0420fa9011643291b5a71bc543cd318e4f5fd01c12ac5978710e3589 ./.env.example
65faec43717b4df28bfb23d3a04211fbde4cfff165749dc40f37af3f3c774d4d ./ARCHITECTURE.md
91f75b87bd95480fd40f191b551fe2d5f9f64e590e7c5442c19ee6166a362e6e ./CHANGELOG-GLPI-POOL-PERSISTENCE.md
e2f1f0400999cb59b8be09bc2743e06e0a0b2ecdc44a583af7c9a08b70d8509e ./CHANGELOG-GPU-NODE-LIMIT.md
7c8116836000f0ac2951afd3b5a8855380967cf3d7333b1d7608545b91b7a946 ./CHANGELOG-KNOWLEDGE-SYNTHESIS.md
a317376127be1e47b9ec9f0781fcfebdcbf7fccfaeb7840d9241f9586048fadb ./CHANGELOG-GROUNDED-KNOWLEDGE-SYNTHESIS.md
e167a9d64f63c3933ad40c5078684bc019db303043c34ea3965f3b089f3d32c7 ./CHANGELOG-KNOWLEDGE-SYNTHESIS.md
87f89a81e1124b18e092a4ea946cb037295cb9e884a46b392286272dc8134dd4 ./CHANGELOG-RUNTIME-HONEYCOMB.md
99171b262752a66278da5a4a8b2b48e83fbdb1eb9cdca3efa944d260d6e6bc01 ./Dockerfile
5534536965bf0479455f97324c242160202650ca1256f1ba0420b4ad67125e49 ./GLPI-KB.md
809f8489aeabbdffbe35f3f8e93cf422439adfe93cf2fc9af2fb7c2f2d83749a ./KNOWLEDGE-SYNTHESIS.md
a090708c29db08973f712eb9ebf07dff2aae4e3e64f8954bbdaa83848b3ffcc0 ./KNOWLEDGE-SYNTHESIS.md
adcd3c9ba3bdf366afcc4e15a25423e068dd761e5d5d2d6f8cb20a3686302045 ./Makefile
381d7d6ac9e3c2e63c9ecdaa42ed4c73058f5d78e57c7532bb75a9663c919530 ./OLLAMA-POOL.md
10c14f4c08b9b84699cc4cf0dac3e6faf6c0ffd7e74b428463f14c628d0b533d ./PERSISTENCE.md
3a4138ceb0c998828b1d12ba3b642768b167c998db20ccd618a1dd74d5f0df5b ./README.md
4561debb97c555f57005430781de10de8a2a56a0d364060f70f2f4ebc0b0ab01 ./README.md
73171929c19afc9c0f280a50bee1c6becfbfe1a1a4107540ff80d5e4f885ba9a ./RUNTIME-CONTROLS-HONEYCOMB.md
cfc2393e8300b792cec30c57b9dc7670b19dcf5abb190dddaa75f32b77cb1fc7 ./cmd/brain/main.go
418310e2660f34890c4be97d6e4a0873cce57fe5b8eb6bfdff25bcd2d5faa7bf ./data/graph-state.json
21b51d0e1b7ed07c20f7f3a5da76dedab8df44a94a51724b67b0c3411599fe15 ./deployment/README.md
f9ddc9f66156d6c4895a731c76047fd8a4ed897c6a5bc34630112ed6f857a646 ./deployment/docker-compose.full.yml
73f2824a517044157fb59e8fc5854d6604511cbf6cffe810beb8830456ab89fa ./docker-compose.yml
139d3dc6939848f8d8e827e5a483da7f7794a47dd3c858a964eaa7f811987985 ./deployment/docker-compose.full.yml
5d97d77e2f0c302fb7fedfe8c7ff2c72a30f196590fdcab498ab0f4369de0071 ./docker-compose.yml
9126f8bca1144abfc77747063b2c8f31acf12839a75e060b98369e10a00061ca ./go.mod
bf239391e61040b00d2f49d805b0d49a44bd3679f3c53849dfbc5e3b5a3fcc7a ./integrations/agent/README.md
a0105475dc054977223fac36618b8cd8137c55be1d11fddcd24e9a4d3074c170 ./integrations/agent/glpi-ai-agent-neural-brain.patch
73ab7c49600cdaa4795e76c50e66ce919dec171b3a07f2d16ff6f45ce5f73365 ./integrations/knowledgebase/README.md
36666043ebf4139e13610e5fcd0b0c6f45e4e6a53fed33ec47d03a66878e7b08 ./integrations/knowledgebase/glpi-ai-knowledgebase-neural-brain.patch
50d05fa2a183f5f3eaab0545cb48d3abb64be62eb5d84dc2c6d99c7125f7344b ./internal/activity/broker.go
ddbae88b0a810620ca950dbaef47df63c7f5e298eb983f8ddf76d9b1eaeb3716 ./internal/config/config.go
45ca13d5dc65b5858359d1187147c8a892ae6ba8355e699c05d7e44212096c37 ./internal/config/config.go
d5defa74dff6b09869f59c3e40ecc083f18bb9d81a0541956a857c4d0c26c360 ./internal/config/config_test.go
b432846e506d69990d8d8d4f711141972619a901ef57b965c3cf21f8d9279ced ./internal/engine/article.go
115ceadebc1395a0b2b8379a4eb6f5555656330b12dc9fb88c4dc1761dcf7ca6 ./internal/engine/engine.go
ee96b363b124d268216219cffcac73bbc1f393a9838dd9731ef5e56cfd4271d4 ./internal/engine/engine_test.go
bfbb007982ddde321f6abe7bf82e6d3e7dc60304445a4649573e24989b9e922b ./internal/engine/article.go
dc07c6fd00d55982fb98533d0719984d7e4b01b0a06123c93183522eaaa06393 ./internal/engine/engine.go
2c4d29463b4d7225d4d7e89386a01fb91254ef584e2e2cb5dc80f2baf3d21c01 ./internal/engine/engine_test.go
872b9a5d6cc0e610a50f64ae8d66ed53bf07da718da22f91c11cf43a487fb425 ./internal/engine/runtime.go
b82980a646a92751bdd27a866ba1ffc6d34a3ba81d537f7b6e5a78e1432ee6fa ./internal/glpi/client.go
525102be56bc51ce8a08655b1b2bb53b67f4a1828903585fd664ed6a5133f617 ./internal/glpi/client_test.go
@@ -38,7 +39,7 @@ b82980a646a92751bdd27a866ba1ffc6d34a3ba81d537f7b6e5a78e1432ee6fa ./internal/glp
ff44d56d56b9e301fbcf0f028d1bae6f0b851665f4fee65222097f1a2450f24a ./internal/ingest/glpikb_test.go
257a4beba480dab7d9b79c1f32496f6b4f648c4c05170f51a77220dbfd21fe96 ./internal/ingest/knowledge.go
a13910fb417484d56e78ae856b71fb66c63987d9abf3b513190bc52697321a18 ./internal/ingest/knowledge_test.go
738e9079804fd39c353db54e531cfa6ec0be0a96d9cc2a19b79f813641d90a28 ./internal/model/model.go
835f6913829df7d55560d62dfe39dc01d0448dba63dae808249e32bd130b51d7 ./internal/model/model.go
db7a99b832bc41c61584cd726d5fc7f4f250197d716d58bc27e715923f7f5426 ./internal/ollama/client.go
61089700aa5912b58bff526b71c2a1bda74077b802f9ff69a0d7dc89c1e3e3a1 ./internal/ollama/client_test.go
e61a9a426b96851b409ac357bdc53853324b5c5da7568ff6f89fe9cc09f83a73 ./internal/persist/coordinator.go
@@ -50,5 +51,5 @@ d33318b43388f134358cf40f5b0f130eb10edcca06011955ea0544897b4e4ad1 ./internal/per
a9e586d77223a60bb8345c21de5381699eb8dbdfc215cbb5de975ee0d62b15fa ./internal/web/static/app.css
33f4f973878985ee086425f560a95d8269a7989da36f6c35c0ff2d8aa00c709f ./internal/web/static/app.js
7e99be904fea61b19bfd5560c93eec5dc5eee75252d1410ca1661cc4a87181dd ./internal/web/static/index.html
81c0b69517037de987b37611ea7569ee88aada911dbc3565ba0aef8e0bb83ba2 ./neural-brain
eed8aecdf49ea5efecbb16b73483a2c3d2a527a079698519f9bb3b1c9667872c ./neural-brain
83aded814b6225395935e61fe957963c3c470f368fc9089f505b6de23e959115 ./preview.png

View File

@@ -151,6 +151,8 @@ services:
BRAIN_ARTICLE_MIN_CONFIDENCE: ${BRAIN_ARTICLE_MIN_CONFIDENCE:-0.74}
BRAIN_ARTICLE_MIN_TEXT_CHARS: ${BRAIN_ARTICLE_MIN_TEXT_CHARS:-180}
BRAIN_ARTICLE_MIN_ANSWER_CHARS: ${BRAIN_ARTICLE_MIN_ANSWER_CHARS:-420}
BRAIN_ARTICLE_MAX_RESEARCH_QUERIES: ${BRAIN_ARTICLE_MAX_RESEARCH_QUERIES:-3}
BRAIN_ARTICLE_RESEARCH_RESULTS: ${BRAIN_ARTICLE_RESEARCH_RESULTS:-4}
BRAIN_TOP_K: ${BRAIN_TOP_K:-8}
BRAIN_MAX_CONTEXT_CHARS: ${BRAIN_MAX_CONTEXT_CHARS:-16000}
BRAIN_RESEARCH_ENABLED: ${BRAIN_RESEARCH_ENABLED:-false}

View File

@@ -50,6 +50,8 @@ services:
BRAIN_ARTICLE_MIN_CONFIDENCE: ${BRAIN_ARTICLE_MIN_CONFIDENCE:-0.74}
BRAIN_ARTICLE_MIN_TEXT_CHARS: ${BRAIN_ARTICLE_MIN_TEXT_CHARS:-180}
BRAIN_ARTICLE_MIN_ANSWER_CHARS: ${BRAIN_ARTICLE_MIN_ANSWER_CHARS:-420}
BRAIN_ARTICLE_MAX_RESEARCH_QUERIES: ${BRAIN_ARTICLE_MAX_RESEARCH_QUERIES:-3}
BRAIN_ARTICLE_RESEARCH_RESULTS: ${BRAIN_ARTICLE_RESEARCH_RESULTS:-4}
BRAIN_TOP_K: ${BRAIN_TOP_K:-8}
BRAIN_MAX_CONTEXT_CHARS: ${BRAIN_MAX_CONTEXT_CHARS:-16000}
BRAIN_RESEARCH_ENABLED: ${BRAIN_RESEARCH_ENABLED:-false}

View File

@@ -48,6 +48,8 @@ type Config struct {
ArticleMinConfidence float64
ArticleMinTextChars int
ArticleMinAnswerChars int
ArticleMaxResearchQueries int
ArticleResearchResults int
TopK int
MaxContextChars int
AutoEnrich bool
@@ -130,6 +132,8 @@ func Load() (Config, error) {
ArticleMinConfidence: number("BRAIN_ARTICLE_MIN_CONFIDENCE", 0.74),
ArticleMinTextChars: integer("BRAIN_ARTICLE_MIN_TEXT_CHARS", 180),
ArticleMinAnswerChars: integer("BRAIN_ARTICLE_MIN_ANSWER_CHARS", 420),
ArticleMaxResearchQueries: integer("BRAIN_ARTICLE_MAX_RESEARCH_QUERIES", 3),
ArticleResearchResults: integer("BRAIN_ARTICLE_RESEARCH_RESULTS", 4),
TopK: integer("BRAIN_TOP_K", 8),
MaxContextChars: integer("BRAIN_MAX_CONTEXT_CHARS", 16000),
AutoEnrich: boolean("BRAIN_AUTO_ENRICH", true),
@@ -203,6 +207,12 @@ func Load() (Config, error) {
if cfg.ArticleMinAnswerChars < 100 || cfg.ArticleMinAnswerChars > 50000 {
return Config{}, fmt.Errorf("BRAIN_ARTICLE_MIN_ANSWER_CHARS must be between 100 and 50000")
}
if cfg.ArticleMaxResearchQueries < 1 || cfg.ArticleMaxResearchQueries > 10 {
return Config{}, fmt.Errorf("BRAIN_ARTICLE_MAX_RESEARCH_QUERIES must be between 1 and 10")
}
if cfg.ArticleResearchResults < 1 || cfg.ArticleResearchResults > 10 {
return Config{}, fmt.Errorf("BRAIN_ARTICLE_RESEARCH_RESULTS must be between 1 and 10")
}
if cfg.ResearchEnabled && cfg.SearXNGURL == "" {
return Config{}, fmt.Errorf("BRAIN_RESEARCH_ENABLED requires SEARXNG_URL")
}

View File

@@ -40,12 +40,7 @@ func (e *Engine) synthesizeKnowledgeArticle(ctx context.Context, trigger string,
sources := e.selectArticleSources(seeds)
productionCount, aiCount, productionRatio, maxDepth := articleSourceStats(sources)
if productionCount < e.Cfg.ArticleMinSources {
e.Broker.Publish(model.Activity{
Type: "article.skipped", Source: "brain", Phase: "source-selection", NodeIDs: nodeIDsFromArticleSources(sources),
Message: "Für einen belastbaren Wissensartikel sind noch nicht genug produktive Quellen verbunden",
Strength: .3,
Metadata: map[string]any{"trigger": trigger, "reason": "insufficient_production_sources", "productive_sources": productionCount, "ai_sources": aiCount, "required_sources": e.Cfg.ArticleMinSources, "production_ratio": productionRatio},
})
e.Broker.Publish(model.Activity{Type: "article.skipped", Source: "brain", Phase: "source-selection", NodeIDs: nodeIDsFromArticleSources(sources), Message: "Für einen belastbaren Wissensartikel sind noch nicht genug produktive Quellen verbunden", Strength: .3, Metadata: map[string]any{"trigger": trigger, "reason": "insufficient_production_sources", "productive_sources": productionCount, "ai_sources": aiCount, "required_sources": e.Cfg.ArticleMinSources, "production_ratio": productionRatio}})
return articleSynthesisOutcome{Skipped: true, Reason: "insufficient_production_sources"}, nil
}
if productionRatio < e.Cfg.ArticleMinProductionRatio {
@@ -56,11 +51,7 @@ func (e *Engine) synthesizeKnowledgeArticle(ctx context.Context, trigger string,
return articleSynthesisOutcome{Skipped: true, Reason: "generation_depth_limit"}, nil
}
e.Broker.Publish(model.Activity{
Type: "article.plan.started", Source: "brain", Phase: "knowledge-planning", NodeIDs: nodeIDsFromArticleSources(sources),
Message: fmt.Sprintf("%d Quellen werden auf einen echten Wissensmehrwert geprüft", len(sources)), Strength: .84,
Metadata: map[string]any{"trigger": trigger, "productive_sources": productionCount, "ai_sources": aiCount, "production_ratio": productionRatio, "generation_depth": generationDepth, "model": e.Cfg.ChatModel},
})
e.Broker.Publish(model.Activity{Type: "article.plan.started", Source: "brain", Phase: "knowledge-planning", NodeIDs: nodeIDsFromArticleSources(sources), Message: fmt.Sprintf("%d Quellen werden auf einen echten Wissensmehrwert geprüft", len(sources)), Strength: .84, Metadata: map[string]any{"trigger": trigger, "productive_sources": productionCount, "ai_sources": aiCount, "production_ratio": productionRatio, "generation_depth": generationDepth, "model": e.Cfg.ChatModel}})
var plan model.ArticlePlanDecision
if err := e.Ollama.ChatJSON(ctx, articlePlanSystemPrompt(), e.articlePlanContext(sources, relation, initialResearch), articlePlanSchema(), &plan); err != nil {
@@ -83,11 +74,9 @@ func (e *Engine) synthesizeKnowledgeArticle(ctx context.Context, trigger string,
return articleSynthesisOutcome{Skipped: true, Reason: nonempty(plan.Reason, "model_skip"), Action: plan.Action}, nil
}
if (plan.Action == "update" || plan.Action == "merge") && !validProductionTarget(plan.TargetArticleID, selected) {
e.Broker.Publish(model.Activity{Type: "article.plan.skipped", Source: "brain", Phase: "knowledge-planning", NodeIDs: plan.SourceNodeIDs, Message: "Der vorgeschlagene Zielartikel ist keine produktive Quelle des geprüften Themenverbunds", Strength: .34, Metadata: map[string]any{"trigger": trigger, "action": plan.Action, "target_article_id": plan.TargetArticleID}})
return articleSynthesisOutcome{Skipped: true, Reason: "invalid_target_article", Action: plan.Action}, nil
}
if e.hasEquivalentArticleDraft(selected, plan) {
e.Broker.Publish(model.Activity{Type: "article.plan.skipped", Source: "brain", Phase: "quality-gate", NodeIDs: plan.SourceNodeIDs, Message: "Für diesen Themenverbund liegt bereits ein gleichwertiger AI-THINK-Entwurf im Staging", Strength: .3, Metadata: map[string]any{"trigger": trigger, "action": plan.Action, "target_article_id": plan.TargetArticleID, "reason": "equivalent_staging_draft"}})
return articleSynthesisOutcome{Skipped: true, Reason: "equivalent_staging_draft", Action: plan.Action}, nil
}
@@ -95,15 +84,21 @@ func (e *Engine) synthesizeKnowledgeArticle(ctx context.Context, trigger string,
if len(initialResearch) > 0 {
e.addResearchToSources(selected, initialResearch)
}
if plan.NeedsResearch {
if !e.Cfg.ResearchEnabled || e.Research == nil || strings.TrimSpace(plan.ResearchQuery) == "" {
e.Broker.Publish(model.Activity{Type: "article.plan.skipped", Source: "brain", Phase: "knowledge-research", NodeIDs: plan.SourceNodeIDs, Message: "Der Artikel benötigt zusätzliche Evidenz, aber die kontrollierte Recherche ist nicht verfügbar", Strength: .34, Metadata: map[string]any{"trigger": trigger, "reason": "required_research_unavailable", "research_enabled": e.Cfg.ResearchEnabled, "research_query": plan.ResearchQuery, "missing_information": plan.MissingInformation}})
e.Broker.Publish(model.Activity{Type: "article.consolidation.started", Source: "brain", Phase: "knowledge-consolidation", NodeIDs: plan.SourceNodeIDs, Message: "Verwandtes Wissen wird zu einer belegten fachlichen Wissensbasis zusammengeführt", Strength: .88, Metadata: map[string]any{"trigger": trigger, "source_count": len(selected)}})
brief, err := e.buildKnowledgeBrief(ctx, selected, researchResults)
if err != nil {
return articleSynthesisOutcome{}, fmt.Errorf("knowledge consolidation failed: %w", err)
}
queries := collectArticleResearchQueries(plan, brief, e.Cfg.ArticleMaxResearchQueries)
if len(queries) > 0 {
if !e.Cfg.ResearchEnabled || e.Research == nil {
e.Broker.Publish(model.Activity{Type: "article.plan.skipped", Source: "brain", Phase: "knowledge-research", NodeIDs: plan.SourceNodeIDs, Message: "Offene fachliche Punkte benötigen Recherche, aber die Recherche ist nicht verfügbar", Strength: .34, Metadata: map[string]any{"trigger": trigger, "reason": "required_research_unavailable", "research_queries": queries, "missing_information": brief.MissingInformation}})
return articleSynthesisOutcome{Skipped: true, Reason: "required_research_unavailable", Action: plan.Action}, nil
}
e.Broker.Publish(model.Activity{Type: "article.research.started", Source: "brain", Phase: "knowledge-research", NodeIDs: plan.SourceNodeIDs, Message: "Für den geplanten Wissensartikel werden offene Punkte recherchiert", Strength: .9, Metadata: map[string]any{"trigger": trigger, "research_query": plan.ResearchQuery, "missing_information": plan.MissingInformation}})
results, err := e.Research.Search(ctx, plan.ResearchQuery, 5)
if err != nil {
e.Broker.Publish(model.Activity{Type: "article.research.failed", Source: "brain", Phase: "knowledge-research", NodeIDs: plan.SourceNodeIDs, Message: "Die ergänzende Artikelrecherche ist fehlgeschlagen; es wird nichts erfunden", Strength: .35, Metadata: map[string]any{"trigger": trigger, "error": err.Error()}})
results, researchErr := e.researchKnowledgeGaps(ctx, trigger, plan.SourceNodeIDs, queries)
if researchErr != nil {
return articleSynthesisOutcome{Skipped: true, Reason: "required_research_failed", Action: plan.Action}, nil
}
if len(results) == 0 {
@@ -111,37 +106,55 @@ func (e *Engine) synthesizeKnowledgeArticle(ctx context.Context, trigger string,
}
researchResults = append(researchResults, results...)
e.addResearchToSources(selected, results)
brief, err = e.buildKnowledgeBrief(ctx, selected, researchResults)
if err != nil {
return articleSynthesisOutcome{}, fmt.Errorf("knowledge consolidation after research failed: %w", err)
}
}
e.Broker.Publish(model.Activity{Type: "article.draft.started", Source: "brain", Phase: "knowledge-synthesis", NodeIDs: plan.SourceNodeIDs, Message: fmt.Sprintf("Qwen erstellt einen strukturierten KB-Entwurf · Aktion %s", strings.ToUpper(plan.Action)), Strength: .95, Metadata: map[string]any{"trigger": trigger, "action": plan.Action, "target_article_id": plan.TargetArticleID, "source_count": len(selected), "research_result_count": len(researchResults), "generation_depth": generationDepth}})
if !brief.ReadyForArticle || len(collectBriefResearchQueries(brief, 1)) > 0 {
e.Broker.Publish(model.Activity{Type: "article.plan.skipped", Source: "brain", Phase: "knowledge-consolidation", NodeIDs: plan.SourceNodeIDs, Message: "Die Wissensbasis enthält weiterhin ungelöste fachliche Lücken; es wird kein Bewertungs- oder Platzhalterartikel geschrieben", Strength: .38, Metadata: map[string]any{"trigger": trigger, "missing_information": brief.MissingInformation, "contradictions": brief.Contradictions, "ready_for_article": brief.ReadyForArticle}})
return articleSynthesisOutcome{Skipped: true, Reason: "knowledge_not_ready", Action: plan.Action}, nil
}
var draft model.KnowledgeArticleDraft
if err := e.Ollama.ChatJSON(ctx, articleDraftSystemPrompt(), e.articleDraftContext(selected, plan, researchResults), articleDraftSchema(), &draft); err != nil {
return articleSynthesisOutcome{}, fmt.Errorf("article drafting failed: %w", err)
e.Broker.Publish(model.Activity{Type: "article.draft.started", Source: "brain", Phase: "knowledge-synthesis", NodeIDs: plan.SourceNodeIDs, Message: "Qwen verfasst aus der konsolidierten Wissensbasis einen vollständigen KB-Artikel", Strength: .95, Metadata: map[string]any{"trigger": trigger, "action": plan.Action, "target_article_id": plan.TargetArticleID, "source_count": len(selected), "research_result_count": len(researchResults), "generation_depth": generationDepth}})
content, rewritten, err := e.generateArticleContent(ctx, selected, plan, brief, researchResults)
if err != nil {
return articleSynthesisOutcome{}, err
}
draft.SourceNodeIDs = validIDs(draft.SourceNodeIDs, plan.SourceNodeIDs)
if len(draft.SourceNodeIDs) < e.Cfg.ArticleMinSources {
draft.SourceNodeIDs = append([]string(nil), plan.SourceNodeIDs...)
}
selected = filterArticleSources(selected, draft.SourceNodeIDs)
draft := articleContentToDraft(content, plan.SourceNodeIDs)
productionCount, aiCount, productionRatio, maxDepth = articleSourceStats(selected)
generationDepth = maxDepth + 1
quality, err := e.reviewArticleContent(ctx, draft, selected, researchResults)
if err != nil {
return articleSynthesisOutcome{}, fmt.Errorf("article quality review failed: %w", err)
}
draft.Confidence = quality.Confidence
if !quality.Accepted || quality.MetaContentDetected || len(quality.UnsupportedClaims) > 0 {
reason := strings.Join(unique(append(append([]string(nil), quality.Issues...), quality.UnsupportedClaims...)), "; ")
if reason == "" {
reason = "content quality review rejected the generated article"
}
e.Broker.Publish(model.Activity{Type: "article.draft.rejected", Source: "brain", Phase: "quality-gate", NodeIDs: draft.SourceNodeIDs, Message: "Der erzeugte Inhalt wurde als Bewertung, Meta-Text oder unbelegt erkannt", Strength: .42, Metadata: map[string]any{"trigger": trigger, "action": plan.Action, "error": reason, "confidence": quality.Confidence, "meta_content_detected": quality.MetaContentDetected, "unsupported_claims": quality.UnsupportedClaims, "rewritten": rewritten}})
return articleSynthesisOutcome{Skipped: true, Reason: "quality_gate: " + reason, Action: plan.Action, Title: draft.Title}, nil
}
if err := e.validateArticleDraft(draft, selected, productionRatio, generationDepth); err != nil {
e.Broker.Publish(model.Activity{Type: "article.draft.rejected", Source: "brain", Phase: "quality-gate", NodeIDs: draft.SourceNodeIDs, Message: "Der erzeugte Artikelentwurf hat die Qualitätsregeln nicht erfüllt", Strength: .42, Metadata: map[string]any{"trigger": trigger, "action": plan.Action, "error": err.Error(), "confidence": draft.Confidence, "productive_sources": productionCount, "ai_sources": aiCount, "production_ratio": productionRatio, "generation_depth": generationDepth}})
return articleSynthesisOutcome{Skipped: true, Reason: "quality_gate: " + err.Error(), Action: plan.Action, Title: draft.Title}, nil
}
path, articleID, created, err := e.writeKnowledgeArticleDraft(selected, plan, draft, researchResults, productionCount, aiCount, productionRatio, generationDepth)
path, articleID, created, err := e.writeKnowledgeArticleDraft(selected, plan, brief, draft, researchResults, productionCount, aiCount, productionRatio, generationDepth)
if err != nil {
return articleSynthesisOutcome{}, err
}
if !created {
e.Broker.Publish(model.Activity{Type: "article.duplicate", Source: "brain", Phase: "quality-gate", NodeIDs: draft.SourceNodeIDs, Message: "Ein identischer AI-THINK-Artikelentwurf ist bereits vorhanden oder vorgemerkt", Strength: .28, Metadata: map[string]any{"trigger": trigger, "action": plan.Action, "path": path, "title": draft.Title}})
return articleSynthesisOutcome{Skipped: true, Reason: "duplicate", Action: plan.Action, Path: path, Title: draft.Title}, nil
}
e.addRuntimeArticleNode(articleID, selected, plan, draft, productionCount, aiCount, productionRatio, generationDepth)
e.Broker.Publish(model.Activity{Type: "article.created", Source: "brain", Phase: "staging", NodeIDs: append([]string{graph.ID("knowledge", articleID)}, draft.SourceNodeIDs...), Message: fmt.Sprintf("Neuer strukturierter KB-Entwurf im Staging · %s", draft.Title), Strength: 1, Metadata: map[string]any{"trigger": trigger, "action": plan.Action, "target_article_id": plan.TargetArticleID, "path": path, "title": draft.Title, "confidence": draft.Confidence, "productive_sources": productionCount, "ai_sources": aiCount, "production_ratio": productionRatio, "generation_depth": generationDepth, "research_result_count": len(researchResults), "write_pending": true}})
e.addRuntimeArticleNode(articleID, selected, plan, draft, researchResults, productionCount, aiCount, productionRatio, generationDepth)
e.learnRuntimeArticle(ctx, articleID)
e.Broker.Publish(model.Activity{Type: "article.created", Source: "brain", Phase: "staging", NodeIDs: append([]string{graph.ID("knowledge", articleID)}, draft.SourceNodeIDs...), Message: fmt.Sprintf("Konsolidierter KB-Artikel wurde erstellt, gelernt und mit seinen Quellen verknüpft · %s", draft.Title), Strength: 1, Metadata: map[string]any{"trigger": trigger, "action": plan.Action, "target_article_id": plan.TargetArticleID, "path": path, "title": draft.Title, "confidence": draft.Confidence, "productive_sources": productionCount, "ai_sources": aiCount, "production_ratio": productionRatio, "generation_depth": generationDepth, "research_result_count": len(researchResults), "write_pending": true}})
return articleSynthesisOutcome{Created: true, Path: path, Action: plan.Action, Title: draft.Title}, nil
}
@@ -302,20 +315,197 @@ func (e *Engine) articlePlanContext(sources []articleSource, relation model.Rela
return b.String()
}
func (e *Engine) articleDraftContext(sources []articleSource, plan model.ArticlePlanDecision, researchResults []model.ResearchResult) string {
func (e *Engine) buildKnowledgeBrief(ctx context.Context, sources []articleSource, researchResults []model.ResearchResult) (model.KnowledgeBrief, error) {
var brief model.KnowledgeBrief
if err := e.Ollama.ChatJSON(ctx, knowledgeBriefSystemPrompt(), e.knowledgeBriefContext(sources, researchResults), knowledgeBriefSchema(), &brief); err != nil {
return model.KnowledgeBrief{}, err
}
return normalizeKnowledgeBrief(brief), nil
}
func (e *Engine) knowledgeBriefContext(sources []articleSource, researchResults []model.ResearchResult) string {
var b strings.Builder
fmt.Fprintf(&b, "AKTION: %s\nZIELARTIKEL_ID: %s\nARTIKELTYP: %s\nBEGRÜNDUNG: %s\nERWARTETER_MEHRWERT: %s\nFEHLENDE_INFORMATIONEN: %s\nWIDERSPRÜCHE: %s\n\n", plan.Action, plan.TargetArticleID, plan.ArticleType, plan.Reason, plan.ExpectedValue, strings.Join(plan.MissingInformation, " | "), strings.Join(plan.Contradictions, " | "))
b.WriteString("QUELLEN:\n")
b.WriteString("AUFGABE: Führe das fachliche Wissen der folgenden Beiträge zusammen. Extrahiere nur Aussagen, die durch mindestens eine angegebene Referenz belegt sind. Widersprüche und fehlende Informationen bleiben getrennt vom später sichtbaren Artikel.\n\nQUELLEN:\n")
appendArticleSources(&b, sources, e.Cfg.MaxContextChars)
if len(researchResults) > 0 {
b.WriteString("\nRECHERCHEERGEBNISSE (nur als zusätzliche Evidenz, nicht als garantierte Wahrheit):\n")
b.WriteString("\nRECHERCHEBELEGE:\n")
for i, result := range researchResults {
fmt.Fprintf(&b, "\nR%d: %s\nURL: %s\nAUSZUG: %s\n", i+1, result.Title, result.URL, clamp(result.Content, 1000))
fmt.Fprintf(&b, "\nREF: R%d\nTITEL: %s\nURL: %s\nINHALT:\n%s\n", i+1, result.Title, result.URL, clamp(result.Content, 1800))
}
}
return b.String()
}
func knowledgeBriefSystemPrompt() string {
return `Du konsolidierst deutschsprachiges Helpdesk-Wissen zu einer fachlichen Wissensbasis. Diese Ausgabe ist intern und wird niemals als Artikel gespeichert.
Regeln:
- Führe inhaltlich gleiche Aussagen zusammen.
- Jede fachliche Aussage erhält source_refs mit SOURCE_NODE_ID oder Recherche-Referenzen R1, R2 usw.
- Trenne Problem, Geltungsbereich, Symptome, Voraussetzungen, Lösungsschritte, Validierung und Fehlerbehandlung.
- Schreibe solution_steps nur, wenn konkrete ausführbare Handlungen belegt sind.
- Markiere Widersprüche ausdrücklich. Löse sie nur, wenn ein eindeutiger Beleg vorliegt.
- Erzeuge präzise research_queries für wesentliche ungeklärte Punkte.
- ready_for_article ist nur true, wenn ein vollständiger, nutzbarer Artikel ohne erfundene Fakten geschrieben werden kann und keine wesentliche Recherche mehr offen ist.
- Schreibe keine Bewertung des Mehrwerts und keine Beschreibung des KI-Prozesses.
Gib ausschließlich JSON nach Schema zurück.`
}
func knowledgeBriefSchema() map[string]any {
statement := map[string]any{"type": "object", "properties": map[string]any{
"text": map[string]any{"type": "string"},
"source_refs": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
}, "required": []string{"text", "source_refs"}}
conflict := map[string]any{"type": "object", "properties": map[string]any{
"topic": map[string]any{"type": "string"},
"statements": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
"source_refs": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
"resolution": map[string]any{"type": "string"},
"needs_research": map[string]any{"type": "boolean"},
"research_query": map[string]any{"type": "string"},
}, "required": []string{"topic", "statements", "source_refs", "resolution", "needs_research", "research_query"}}
return map[string]any{"type": "object", "properties": map[string]any{
"topic": map[string]any{"type": "string"},
"purpose": map[string]any{"type": "string"},
"scope": map[string]any{"type": "array", "items": statement},
"facts": map[string]any{"type": "array", "items": statement},
"symptoms": map[string]any{"type": "array", "items": statement},
"prerequisites": map[string]any{"type": "array", "items": statement},
"solution_steps": map[string]any{"type": "array", "items": statement},
"validation_steps": map[string]any{"type": "array", "items": statement},
"troubleshooting": map[string]any{"type": "array", "items": statement},
"contradictions": map[string]any{"type": "array", "items": conflict},
"missing_information": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
"research_queries": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
"ready_for_article": map[string]any{"type": "boolean"},
}, "required": []string{"topic", "purpose", "scope", "facts", "symptoms", "prerequisites", "solution_steps", "validation_steps", "troubleshooting", "contradictions", "missing_information", "research_queries", "ready_for_article"}}
}
func normalizeKnowledgeBrief(brief model.KnowledgeBrief) model.KnowledgeBrief {
brief.Topic = strings.TrimSpace(brief.Topic)
brief.Purpose = strings.TrimSpace(brief.Purpose)
brief.Scope = cleanGroundedStatements(brief.Scope)
brief.Facts = cleanGroundedStatements(brief.Facts)
brief.Symptoms = cleanGroundedStatements(brief.Symptoms)
brief.Prerequisites = cleanGroundedStatements(brief.Prerequisites)
brief.SolutionSteps = cleanGroundedStatements(brief.SolutionSteps)
brief.ValidationSteps = cleanGroundedStatements(brief.ValidationSteps)
brief.Troubleshooting = cleanGroundedStatements(brief.Troubleshooting)
brief.MissingInformation = unique(brief.MissingInformation)
brief.ResearchQueries = unique(brief.ResearchQueries)
unresolvedConflict := false
for i := range brief.Contradictions {
brief.Contradictions[i].Topic = strings.TrimSpace(brief.Contradictions[i].Topic)
brief.Contradictions[i].Statements = unique(brief.Contradictions[i].Statements)
brief.Contradictions[i].SourceRefs = unique(brief.Contradictions[i].SourceRefs)
brief.Contradictions[i].Resolution = strings.TrimSpace(brief.Contradictions[i].Resolution)
brief.Contradictions[i].ResearchQuery = strings.TrimSpace(brief.Contradictions[i].ResearchQuery)
if brief.Contradictions[i].NeedsResearch || brief.Contradictions[i].Resolution == "" {
unresolvedConflict = true
}
}
if len(brief.MissingInformation) > 0 || unresolvedConflict {
brief.ReadyForArticle = false
}
return brief
}
func cleanGroundedStatements(values []model.GroundedStatement) []model.GroundedStatement {
out := make([]model.GroundedStatement, 0, len(values))
seen := map[string]bool{}
for _, value := range values {
value.Text = strings.TrimSpace(value.Text)
value.SourceRefs = unique(value.SourceRefs)
key := strings.ToLower(value.Text)
if value.Text == "" || seen[key] {
continue
}
seen[key] = true
out = append(out, value)
}
return out
}
func collectArticleResearchQueries(plan model.ArticlePlanDecision, brief model.KnowledgeBrief, limit int) []string {
queries := collectBriefResearchQueries(brief, limit)
if plan.NeedsResearch && strings.TrimSpace(plan.ResearchQuery) != "" {
queries = append([]string{strings.TrimSpace(plan.ResearchQuery)}, queries...)
}
queries = unique(queries)
if limit > 0 && len(queries) > limit {
queries = queries[:limit]
}
return queries
}
func collectBriefResearchQueries(brief model.KnowledgeBrief, limit int) []string {
queries := append([]string(nil), brief.ResearchQueries...)
for _, conflict := range brief.Contradictions {
if conflict.NeedsResearch && strings.TrimSpace(conflict.ResearchQuery) != "" {
queries = append(queries, strings.TrimSpace(conflict.ResearchQuery))
}
}
queries = unique(queries)
if limit > 0 && len(queries) > limit {
queries = queries[:limit]
}
return queries
}
func (e *Engine) researchKnowledgeGaps(ctx context.Context, trigger string, nodeIDs, queries []string) ([]model.ResearchResult, error) {
var out []model.ResearchResult
seen := map[string]bool{}
for _, query := range queries {
query = strings.TrimSpace(query)
if query == "" {
continue
}
e.Broker.Publish(model.Activity{Type: "article.research.started", Source: "brain", Phase: "knowledge-research", NodeIDs: nodeIDs, Message: "Ein ungeklärter fachlicher Punkt wird recherchiert", Strength: .9, Metadata: map[string]any{"trigger": trigger, "research_query": query}})
resultLimit := e.Cfg.ArticleResearchResults
if resultLimit < 1 {
resultLimit = 4
}
results, err := e.Research.Search(ctx, query, resultLimit)
if err != nil {
e.Broker.Publish(model.Activity{Type: "article.research.failed", Source: "brain", Phase: "knowledge-research", NodeIDs: nodeIDs, Message: "Die ergänzende Artikelrecherche ist fehlgeschlagen", Strength: .35, Metadata: map[string]any{"trigger": trigger, "research_query": query, "error": err.Error()}})
return nil, err
}
for _, result := range results {
key := strings.TrimSpace(result.URL)
if key == "" {
key = result.Title + "\x00" + result.Content
}
if seen[key] {
continue
}
seen[key] = true
out = append(out, result)
}
}
return out, nil
}
func (e *Engine) articleDraftContext(sources []articleSource, plan model.ArticlePlanDecision, brief model.KnowledgeBrief, researchResults []model.ResearchResult) string {
var b strings.Builder
b.WriteString("SCHREIBAUFTRAG: Verfasse einen vollständigen, direkt nutzbaren deutschsprachigen Helpdesk-Wissensartikel.\n")
if plan.Action == "update" || plan.Action == "merge" {
b.WriteString("Der Text muss als vollständiger eigenständiger Artikel formuliert sein und nicht als Änderungshinweis.\n")
}
briefJSON, _ := json.MarshalIndent(brief, "", " ")
b.WriteString("\nKONSOLIDIERTE FACHLICHE WISSENSBASIS:\n")
b.Write(briefJSON)
b.WriteString("\n\nORIGINALBELEGE ZUR FAKTENPRÜFUNG:\n")
appendArticleSources(&b, sources, e.Cfg.MaxContextChars)
if len(researchResults) > 0 {
b.WriteString("\nERGÄNZENDE RECHERCHEBELEGE:\n")
for i, result := range researchResults {
fmt.Fprintf(&b, "\nR%d: %s\nURL: %s\nINHALT: %s\n", i+1, result.Title, result.URL, clamp(result.Content, 1400))
}
}
b.WriteString("\nDie sichtbaren Artikelfelder dürfen ausschließlich fachlichen Inhalt enthalten. Interne Planung, Bewertung, Quellen- oder Prozesssprache gehört nicht in den Artikel.\n")
return b.String()
}
func appendArticleSources(b *strings.Builder, sources []articleSource, maxChars int) {
if maxChars < 4000 {
maxChars = 16000
@@ -350,17 +540,27 @@ Erfinde keine Fakten. Bevorzuge konkrete Problemlösung gegenüber technischer M
}
func articleDraftSystemPrompt() string {
return `Du schreibst einen hochwertigen deutschsprachigen Helpdesk-KB-Entwurf aus den bereitgestellten Quellen. Der Text ist für Anwender und Support-Mitarbeiter, nicht für Graph- oder KI-Entwickler.
return `Du bist ausschließlich der Fachautor eines deutschsprachigen Helpdesk-Wissensartikels. Du führst keine Bewertung und keine Quellenanalyse im Ausgabedokument durch.
Regeln:
Deine Ausgabe enthält nur den später sichtbaren Artikelinhalt:
- title: sachlicher Artikeltitel ohne KI- oder Entwurfshinweis.
- problem_description: konkrete Beschreibung des Problems oder Anwendungsfalls.
- scope: Geltungsbereich und sachliche Abgrenzung.
- symptoms: beobachtbare Symptome oder Ausgangssituationen.
- prerequisites: belegte Voraussetzungen.
- solution_steps: konkrete, ausführbare Schritte in sinnvoller Reihenfolge. Jeder Eintrag ist genau ein Arbeitsschritt.
- validation_steps: konkrete Prüfungen des Ergebnisses.
- troubleshooting: belegte Maßnahmen bei Abweichungen.
- categories und keywords: fachliche Einordnung.
- open_questions: nur fachlich offene Punkte, die vor Freigabe geklärt werden müssen.
Strikte Regeln:
- Erfinde keine Fakten, Befehle, Pfade, Versionen oder Ursachen.
- text beschreibt Problem, Symptome, Geltungsbereich und Abgrenzung.
- answer enthält eine konkrete, nachvollziehbare und möglichst nummerierte Lösung.
- prerequisites, validation und troubleshooting enthalten nur belegbare Punkte.
- Widersprüche werden nicht still aufgelöst; verbleibende Unsicherheiten kommen in open_questions.
- Keine Formulierungen über "semantische Nähe", "Nodes", "Edges", "KI", "Qwen" oder den Denkprozess im Artikeltext.
- source_node_ids dürfen nur IDs aus dem Kontext enthalten.
- Der Entwurf muss einen klaren Mehrwert gegenüber bloßem Zusammenfassen haben.
- Schreibe keine Bewertung der Quellen und keine Begründung, warum ein Artikel erstellt wird.
- Schreibe nichts über Quellenverbund, Mehrwert, Relation, Ähnlichkeit, Nodes, Edges, Graph, KI, Qwen, Modell, Prompt, Confidence, Staging oder Denkprozess.
- Verwende keine Formulierungen wie "die Quellen zeigen", "die Analyse ergibt", "die Inhalte ergänzen sich" oder "der Artikel sollte".
- Formuliere unmittelbar als fertigen Support-Artikel.
- Wenn konkrete Lösungsschritte nicht aus den Quellen ableitbar sind, lasse solution_steps leer; erfinde keinen Ersatztext.
Gib ausschließlich JSON nach Schema zurück.`
}
@@ -381,18 +581,224 @@ func articlePlanSchema() map[string]any {
func articleDraftSchema() map[string]any {
return map[string]any{"type": "object", "properties": map[string]any{
"title": map[string]any{"type": "string"},
"text": map[string]any{"type": "string"},
"answer": map[string]any{"type": "string"},
"prerequisites": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
"validation": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
"troubleshooting": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
"categories": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
"keywords": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
"source_node_ids": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
"confidence": map[string]any{"type": "number", "minimum": 0, "maximum": 1},
"open_questions": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
}, "required": []string{"title", "text", "answer", "prerequisites", "validation", "troubleshooting", "categories", "keywords", "source_node_ids", "confidence", "open_questions"}}
"title": map[string]any{"type": "string"},
"problem_description": map[string]any{"type": "string"},
"scope": map[string]any{"type": "string"},
"symptoms": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
"prerequisites": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
"solution_steps": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
"validation_steps": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
"troubleshooting": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
"categories": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
"keywords": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
"open_questions": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
}, "required": []string{"title", "problem_description", "scope", "symptoms", "prerequisites", "solution_steps", "validation_steps", "troubleshooting", "categories", "keywords", "open_questions"}}
}
func (e *Engine) generateArticleContent(ctx context.Context, sources []articleSource, plan model.ArticlePlanDecision, brief model.KnowledgeBrief, researchResults []model.ResearchResult) (model.KnowledgeArticleContent, bool, error) {
var content model.KnowledgeArticleContent
if err := e.Ollama.ChatJSON(ctx, articleDraftSystemPrompt(), e.articleDraftContext(sources, plan, brief, researchResults), articleDraftSchema(), &content); err != nil {
return model.KnowledgeArticleContent{}, false, fmt.Errorf("article content generation failed: %w", err)
}
content = normalizeArticleContent(content)
if !containsArticleMetaContent(content) {
return content, false, nil
}
e.Broker.Publish(model.Activity{Type: "article.rewrite.started", Source: "brain", Phase: "knowledge-synthesis", NodeIDs: plan.SourceNodeIDs, Message: "Meta-Bewertung im Entwurf erkannt · der Inhalt wird aus der Wissensbasis als reiner Fachartikel neu geschrieben", Strength: .72, Metadata: map[string]any{"action": plan.Action, "target_article_id": plan.TargetArticleID}})
badJSON, _ := json.MarshalIndent(content, "", " ")
var rewritten model.KnowledgeArticleContent
if err := e.Ollama.ChatJSON(ctx, articleRewriteSystemPrompt(), e.articleRewriteContext(sources, brief, researchResults, string(badJSON)), articleDraftSchema(), &rewritten); err != nil {
return model.KnowledgeArticleContent{}, true, fmt.Errorf("article content rewrite failed: %w", err)
}
rewritten = normalizeArticleContent(rewritten)
if containsArticleMetaContent(rewritten) {
return model.KnowledgeArticleContent{}, true, fmt.Errorf("article rewrite still contains planning or assessment language")
}
return rewritten, true, nil
}
func (e *Engine) reviewArticleContent(ctx context.Context, draft model.KnowledgeArticleDraft, sources []articleSource, researchResults []model.ResearchResult) (model.ArticleQualityDecision, error) {
var decision model.ArticleQualityDecision
if err := e.Ollama.ChatJSON(ctx, articleQualitySystemPrompt(), e.articleQualityContext(draft, sources, researchResults), articleQualitySchema(), &decision); err != nil {
return model.ArticleQualityDecision{}, err
}
if containsDraftMetaContent(draft) {
decision.Accepted = false
decision.MetaContentDetected = true
decision.Issues = append(decision.Issues, "Der sichtbare Artikel enthält Bewertungs- oder Prozesssprache.")
}
decision.Issues = unique(decision.Issues)
decision.UnsupportedClaims = unique(decision.UnsupportedClaims)
return decision, nil
}
func (e *Engine) articleRewriteContext(sources []articleSource, brief model.KnowledgeBrief, researchResults []model.ResearchResult, rejected string) string {
var b strings.Builder
b.WriteString("SCHREIBAUFTRAG: Formuliere einen vollständigen, direkt nutzbaren Helpdesk-Wissensartikel.\n")
b.WriteString("Der folgende Entwurf wurde wegen Bewertungs- oder Prozesssprache verworfen. Übernimm daraus keine Meta-Aussagen:\n--- VERWORFENER ENTWURF ---\n")
b.WriteString(clamp(rejected, 5000))
briefJSON, _ := json.MarshalIndent(brief, "", " ")
b.WriteString("\n--- ENDE VERWORFENER ENTWURF ---\n\nKONSOLIDIERTE FACHLICHE WISSENSBASIS:\n")
b.Write(briefJSON)
b.WriteString("\n\nORIGINALBELEGE:\n")
appendArticleSources(&b, sources, e.Cfg.MaxContextChars)
if len(researchResults) > 0 {
b.WriteString("\nRECHERCHEBELEGE:\n")
for i, result := range researchResults {
fmt.Fprintf(&b, "\nR%d: %s\nURL: %s\nINHALT: %s\n", i+1, result.Title, result.URL, clamp(result.Content, 1200))
}
}
return b.String()
}
func (e *Engine) articleQualityContext(draft model.KnowledgeArticleDraft, sources []articleSource, researchResults []model.ResearchResult) string {
var b strings.Builder
b.WriteString("ZU PRÜFENDER SICHTBARER KB-ARTIKEL:\n\nTITEL:\n")
b.WriteString(draft.Title)
b.WriteString("\n\nPROBLEM / BESCHREIBUNG:\n")
b.WriteString(draft.Text)
b.WriteString("\n\nLÖSUNG / ANTWORT:\n")
b.WriteString(formatArticleAnswer(draft))
b.WriteString("\n\nINTERNE BELEGQUELLEN:\n")
appendArticleSources(&b, sources, e.Cfg.MaxContextChars)
if len(researchResults) > 0 {
b.WriteString("\nRECHERCHEBELEGE:\n")
for i, result := range researchResults {
fmt.Fprintf(&b, "\nR%d: %s\nURL: %s\nINHALT: %s\n", i+1, result.Title, result.URL, clamp(result.Content, 1200))
}
}
return b.String()
}
func articleRewriteSystemPrompt() string {
return `Du bist der Fachautor eines deutschsprachigen Helpdesk-Wissensartikels. Ein vorheriger Entwurf wurde verworfen, weil er eine Bewertung, Quellenanalyse oder Beschreibung des KI-Prozesses statt des eigentlichen Ergebnisses enthielt.
Schreibe den Artikel vollständig neu und ausschließlich als sichtbaren Fachinhalt. Verwende nur belegte Informationen aus den Quellen. Entferne jede Aussage über Quellen, Relation, Ähnlichkeit, Mehrwert, Bewertung, Analyse, Graph, Nodes, Edges, KI, Qwen, Modell, Prompt, Confidence, Staging oder Entwurf. Keine Vorrede und kein Fazit über die Erstellung. Gib ausschließlich JSON nach dem vorgegebenen Inhaltsschema zurück.`
}
func articleQualitySystemPrompt() string {
return `Du bist die Qualitätskontrolle einer deutschsprachigen Helpdesk-Wissensdatenbank. Du bewertest einen bereits erzeugten Artikel gegen seine Belegquellen. Deine Bewertung wird niemals als Artikeltext gespeichert.
Setze accepted nur dann auf true, wenn:
- der sichtbare Text ein fertiger fachlicher Helpdesk-Artikel ist,
- Problem und Lösung konkret und für Anwender oder Support nutzbar sind,
- keine Bewertung der Quellen oder Beschreibung des Erzeugungsprozesses enthalten ist,
- keine Aussagen über Relation, Ähnlichkeit, Nodes, Edges, Graph, KI, Qwen, Modell, Prompt, Confidence, Staging oder Entwurf vorkommen,
- alle konkreten Behauptungen und Schritte durch die Quellen belegbar sind,
- die Lösung nicht nur erklärt, dass Quellen zusammenpassen.
meta_content_detected ist true, sobald sichtbarer Inhalt eine Quellenbewertung, Planungsbegründung oder Prozessbeschreibung enthält. unsupported_claims enthält konkrete unbelegte Aussagen. issues enthält kurze Qualitätsmängel. Gib ausschließlich JSON nach Schema zurück.`
}
func articleQualitySchema() map[string]any {
return map[string]any{"type": "object", "properties": map[string]any{
"accepted": map[string]any{"type": "boolean"},
"confidence": map[string]any{"type": "number", "minimum": 0, "maximum": 1},
"meta_content_detected": map[string]any{"type": "boolean"},
"unsupported_claims": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
"issues": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
}, "required": []string{"accepted", "confidence", "meta_content_detected", "unsupported_claims", "issues"}}
}
func normalizeArticleContent(content model.KnowledgeArticleContent) model.KnowledgeArticleContent {
content.Title = strings.TrimSpace(content.Title)
content.ProblemDescription = strings.TrimSpace(content.ProblemDescription)
content.Scope = strings.TrimSpace(content.Scope)
content.Symptoms = cleanArticleItems(content.Symptoms)
content.Prerequisites = cleanArticleItems(content.Prerequisites)
content.SolutionSteps = cleanArticleItems(content.SolutionSteps)
content.ValidationSteps = cleanArticleItems(content.ValidationSteps)
content.Troubleshooting = cleanArticleItems(content.Troubleshooting)
content.Categories = unique(content.Categories)
content.Keywords = unique(content.Keywords)
content.OpenQuestions = cleanArticleItems(content.OpenQuestions)
return content
}
func cleanArticleItems(items []string) []string {
out := make([]string, 0, len(items))
for _, item := range items {
item = strings.TrimSpace(item)
item = strings.TrimLeft(item, "0123456789.-) \t")
if item != "" {
out = append(out, item)
}
}
return unique(out)
}
func articleContentToDraft(content model.KnowledgeArticleContent, sourceIDs []string) model.KnowledgeArticleDraft {
return model.KnowledgeArticleDraft{
Title: content.Title,
Text: formatArticleProblem(content),
Answer: formatNumberedSteps(content.SolutionSteps),
Prerequisites: content.Prerequisites,
Validation: content.ValidationSteps,
Troubleshooting: content.Troubleshooting,
Categories: content.Categories,
Keywords: content.Keywords,
SourceNodeIDs: append([]string(nil), sourceIDs...),
OpenQuestions: content.OpenQuestions,
}
}
func formatArticleProblem(content model.KnowledgeArticleContent) string {
var b strings.Builder
b.WriteString(strings.TrimSpace(content.ProblemDescription))
if strings.TrimSpace(content.Scope) != "" {
b.WriteString("\n\n## Geltungsbereich\n")
b.WriteString(strings.TrimSpace(content.Scope))
}
appendListSection(&b, "Symptome", content.Symptoms)
return strings.TrimSpace(b.String())
}
func formatNumberedSteps(steps []string) string {
clean := cleanArticleItems(steps)
var b strings.Builder
for i, step := range clean {
if i > 0 {
b.WriteByte('\n')
}
fmt.Fprintf(&b, "%d. %s", i+1, step)
}
return b.String()
}
func containsArticleMetaContent(content model.KnowledgeArticleContent) bool {
parts := []string{content.Title, content.ProblemDescription, content.Scope}
parts = append(parts, content.Symptoms...)
parts = append(parts, content.Prerequisites...)
parts = append(parts, content.SolutionSteps...)
parts = append(parts, content.ValidationSteps...)
parts = append(parts, content.Troubleshooting...)
return containsMetaLanguage(strings.Join(parts, "\n"))
}
func containsDraftMetaContent(draft model.KnowledgeArticleDraft) bool {
return containsMetaLanguage(strings.Join([]string{draft.Title, draft.Text, draft.Answer, strings.Join(draft.Prerequisites, "\n"), strings.Join(draft.Validation, "\n"), strings.Join(draft.Troubleshooting, "\n")}, "\n"))
}
func containsMetaLanguage(value string) bool {
lower := strings.ToLower(value)
phrases := []string{
"die bereitgestellten quellen", "die vorliegenden quellen", "die quellen zeigen", "die quellen ergänzen", "aus den quellen",
"der quellenverbund", "diese quellen", "die beziehung zwischen", "semantische nähe", "semantische ähnlichkeit",
"die analyse ergibt", "die analyse zeigt", "die bewertung", "erwarteter mehrwert", "der mehrwert",
"source_node", "node_id", "nodes", "edges", "wissensgraph", "graphenansicht", "qwen", "ollama-modell",
"als ki", "ki-generiert", "ki erstellt", "prompt", "confidence", "staging-entwurf", "dieser entwurf",
"der artikel sollte", "es sollte ein artikel", "es empfiehlt sich, einen artikel", "relationstyp", "relationsbewertung",
"die wissensbasis zeigt", "die konsolidierung zeigt", "die zusammenführung zeigt", "die zusammenführung der quellen",
"auf basis der quellen", "basierend auf den quellen", "basierend auf den bereitgestellten informationen", "die quellenlage",
"der themenverbund", "die relation", "die bewertung ergab", "im rahmen der analyse", "dieser artikel fasst die quellen",
}
for _, phrase := range phrases {
if strings.Contains(lower, phrase) {
return true
}
}
return false
}
func (e *Engine) validateArticleDraft(draft model.KnowledgeArticleDraft, sources []articleSource, productionRatio float64, generationDepth int) error {
@@ -421,7 +827,7 @@ func (e *Engine) validateArticleDraft(draft model.KnowledgeArticleDraft, sources
return nil
}
func (e *Engine) writeKnowledgeArticleDraft(sources []articleSource, plan model.ArticlePlanDecision, draft model.KnowledgeArticleDraft, researchResults []model.ResearchResult, productionCount, aiCount int, productionRatio float64, generationDepth int) (string, string, bool, error) {
func (e *Engine) writeKnowledgeArticleDraft(sources []articleSource, plan model.ArticlePlanDecision, brief model.KnowledgeBrief, draft model.KnowledgeArticleDraft, researchResults []model.ResearchResult, productionCount, aiCount int, productionRatio float64, generationDepth int) (string, string, bool, error) {
if len(e.Cfg.StagingDirs) == 0 {
return "", "", false, fmt.Errorf("no BRAIN_STAGING_DIRS configured")
}
@@ -452,35 +858,15 @@ func (e *Engine) writeKnowledgeArticleDraft(sources []articleSource, plan model.
}
keywords = limitStrings(unique(keywords), 30)
answer := formatArticleAnswer(draft)
var evidence []map[string]any
for _, result := range researchResults {
evidence = append(evidence, map[string]any{"title": result.Title, "url": result.URL, "excerpt": clamp(result.Content, 500)})
}
targetExternalID := ""
if plan.TargetArticleID != "" {
for _, source := range sources {
if source.Node.ID == plan.TargetArticleID {
targetExternalID = source.Node.ExternalID
break
}
}
}
// The KB document intentionally contains only the public KB schema. Planning,
// model assessment, confidence and provenance are stored in a separate Brain
// sidecar so the editor can never mistake an internal evaluation for article text.
doc := map[string]any{
"id": articleID, "title": strings.TrimSpace(draft.Title), "text": strings.TrimSpace(draft.Text), "answer": answer,
"auto_reply": false, "min_score": 0.82, "categories": categories, "keywords": keywords,
"source": "Neural Brain / " + e.Cfg.ChatModel + " (AI-THINK Knowledge Synthesis)",
"source": "Neural Brain / " + e.Cfg.ChatModel + " (Knowledge Synthesis)",
"source_uri": "brain://article/" + short, "language": "de-DE", "communication_style": "formal",
"ai_think": map[string]any{
"status": "staging", "subtype": "knowledge_synthesis", "generated_at": now, "action": plan.Action,
"target_node_id": plan.TargetArticleID, "target_article_id": targetExternalID, "reason": plan.Reason,
"expected_value": plan.ExpectedValue, "article_type": plan.ArticleType,
"source_nodes": externalIDsFromArticleSources(sources), "source_node_ids": sourceIDs,
"productive_source_count": productionCount, "ai_source_count": aiCount, "production_ratio": productionRatio,
"generation_depth": generationDepth, "confidence": draft.Confidence,
"missing_information": plan.MissingInformation, "contradictions": plan.Contradictions,
"open_questions": draft.OpenQuestions, "research_query": plan.ResearchQuery, "research_evidence": evidence,
"review_notice": "Vor produktiver Nutzung im Editor prüfen, korrigieren und freigeben.",
},
}
bytes, err := json.MarshalIndent(doc, "", " ")
if err != nil {
@@ -490,10 +876,41 @@ func (e *Engine) writeKnowledgeArticleDraft(sources []articleSource, plan model.
if err != nil {
return "", "", false, err
}
targetExternalID := ""
if plan.TargetArticleID != "" {
for _, source := range sources {
if source.Node.ID == plan.TargetArticleID {
targetExternalID = source.Node.ExternalID
break
}
}
}
var evidence []map[string]any
for _, result := range researchResults {
evidence = append(evidence, map[string]any{"title": result.Title, "url": result.URL, "excerpt": clamp(result.Content, 500)})
}
meta := map[string]any{
"article_id": articleID, "article_path": queued, "generated_at": now, "status": "staging", "subtype": "knowledge_synthesis",
"action": plan.Action, "target_node_id": plan.TargetArticleID, "target_article_id": targetExternalID,
"planning": map[string]any{"reason": plan.Reason, "expected_value": plan.ExpectedValue, "article_type": plan.ArticleType, "missing_information": plan.MissingInformation, "contradictions": plan.Contradictions},
"source_nodes": externalIDsFromArticleSources(sources), "source_node_ids": sourceIDs,
"productive_source_count": productionCount, "ai_source_count": aiCount, "production_ratio": productionRatio,
"generation_depth": generationDepth, "confidence": draft.Confidence, "open_questions": draft.OpenQuestions,
"knowledge_brief": brief, "research_query": plan.ResearchQuery, "research_evidence": evidence,
}
metaBytes, err := json.MarshalIndent(meta, "", " ")
if err != nil {
return "", "", false, err
}
metaPath := filepath.Join(e.Cfg.DataDir, "article-metadata", strings.ToLower(articleID)+".json")
if _, err := e.Persistence.QueueFile(metaPath, append(metaBytes, '\n'), 0o640); err != nil {
return "", "", false, err
}
return queued, articleID, true, nil
}
func (e *Engine) addRuntimeArticleNode(articleID string, sources []articleSource, plan model.ArticlePlanDecision, draft model.KnowledgeArticleDraft, productionCount, aiCount int, productionRatio float64, generationDepth int) {
func (e *Engine) addRuntimeArticleNode(articleID string, sources []articleSource, plan model.ArticlePlanDecision, draft model.KnowledgeArticleDraft, researchResults []model.ResearchResult, productionCount, aiCount int, productionRatio float64, generationDepth int) {
nodeID := graph.ID("knowledge", articleID)
now := time.Now().UTC()
node := model.Node{
@@ -509,6 +926,35 @@ func (e *Engine) addRuntimeArticleNode(articleID string, sources []articleSource
if plan.TargetArticleID != "" {
e.Graph.UpsertEdge(model.Edge{Source: nodeID, Target: plan.TargetArticleID, Type: "proposes_" + plan.Action, Origin: "knowledge-staging", Status: "staging", Confidence: draft.Confidence, Weight: .8, Explanation: plan.Reason})
}
for _, result := range researchResults {
researchID := graph.ID("external", result.URL)
if _, ok := e.Graph.GetNode(researchID); ok {
e.Graph.UpsertEdge(model.Edge{Source: nodeID, Target: researchID, Type: "grounded_by", Origin: "knowledge-staging", Status: "staging", Confidence: draft.Confidence, Weight: .55, Explanation: "Recherchebeleg für den konsolidierten Wissensartikel"})
}
}
}
func (e *Engine) learnRuntimeArticle(ctx context.Context, articleID string) {
if !e.LearningEnabled() {
return
}
nodeID := graph.ID("knowledge", articleID)
node, ok := e.Graph.GetNode(nodeID)
if !ok || !matchesCategories(node, e.learningCategories()) {
return
}
text := embeddingText(node)
if strings.TrimSpace(text) == "" {
return
}
vecs, err := e.Ollama.Embed(ctx, []string{text})
if err != nil || len(vecs) != 1 || len(vecs[0]) == 0 {
e.Graph.SetVector(nodeID, hashEmbedding(text, 256))
e.Broker.Publish(model.Activity{Type: "article.learned", Source: "brain", Phase: "embedding", NodeIDs: []string{nodeID}, Message: "Der neue KB-Artikel wurde mit einem lokalen Fallback-Vektor in den Wissensgraphen aufgenommen", Strength: .46, Metadata: map[string]any{"article_id": articleID, "fallback": true}})
return
}
e.Graph.SetVector(nodeID, vecs[0])
e.Broker.Publish(model.Activity{Type: "article.learned", Source: "ollama", Phase: "embedding", NodeIDs: []string{nodeID}, Message: "Der neue KB-Artikel wurde eingebettet und ist sofort für Verknüpfungen verfügbar", Strength: .62, Metadata: map[string]any{"article_id": articleID, "model": e.Cfg.EmbeddingModel, "dimensions": len(vecs[0])}})
}
func (e *Engine) addResearchToSources(sources []articleSource, results []model.ResearchResult) {

View File

@@ -698,6 +698,7 @@ func (e *Engine) Status() map[string]any {
"articles_skipped": e.articlesSkipped, "article_synthesis_enabled": e.Cfg.ArticleSynthesisEnabled,
"article_min_sources": e.Cfg.ArticleMinSources, "article_max_sources": e.Cfg.ArticleMaxSources,
"article_min_production_ratio": e.Cfg.ArticleMinProductionRatio, "article_max_generation_depth": e.Cfg.ArticleMaxGenerationDepth,
"article_max_research_queries": e.Cfg.ArticleMaxResearchQueries, "article_research_results": e.Cfg.ArticleResearchResults,
"enrich_interval": e.Cfg.EnrichInterval.String(),
"enrich_batch_size": e.Cfg.EnrichBatchSize, "enrich_anchors": e.Cfg.EnrichAnchors,
"research_enabled": e.Cfg.ResearchEnabled, "chat_model": e.Cfg.ChatModel, "embedding_model": e.Cfg.EmbeddingModel,

View File

@@ -42,8 +42,12 @@ func TestEnrichCreatesStructuredKnowledgeArticleFromThreeProductionSources(t *te
content = `{"related":true,"relation_type":"supports","confidence":0.91,"explanation":"Die drei produktiven Quellen beschreiben zusammen Diagnose, Ursache und Behebung derselben VPN-Störung.","needs_research":false,"research_query":"","topic_label":"VPN-Gateway-Störung","keywords":["VPN","Gateway"]}`
case 2:
content = `{"action":"create","target_article_id":"","reason":"Die Quellen ergänzen sich zu einer vollständigen Anleitung, die noch nicht als einzelner Artikel existiert.","expected_value":"Ein durchgängiger Diagnose- und Lösungsablauf für den Support.","article_type":"troubleshooting","source_node_ids":[],"missing_information":[],"contradictions":[],"needs_research":false,"research_query":""}`
case 3:
content = `{"topic":"VPN-Gateway-Störung","purpose":"VPN-Verbindungsprobleme diagnostizieren und beheben","scope":[{"text":"Gilt für Remotezugriffe über einen VPN-Client.","source_refs":["KB-VPN"]}],"facts":[{"text":"Ein nicht erreichbares Gateway kann den Tunnelaufbau verhindern.","source_refs":["KB-VPN"]}],"symptoms":[{"text":"Der Client meldet ein nicht erreichbares Gateway oder einen Timeout.","source_refs":["KB-VPN","KB-REMOTE"]}],"prerequisites":[{"text":"Die aktuelle Fehlermeldung und der Zeitpunkt des Fehlers liegen vor.","source_refs":["KB-VPN-ESC"]}],"solution_steps":[{"text":"Prüfen Sie die Internetverbindung des betroffenen Rechners.","source_refs":["KB-VPN"]},{"text":"Kontrollieren Sie die konfigurierte Gateway-Adresse und deren Erreichbarkeit.","source_refs":["KB-VPN"]},{"text":"Starten Sie den VPN-Client neu und prüfen Sie den Tunnelaufbau erneut.","source_refs":["KB-REMOTE"]},{"text":"Eskalieren Sie einen anhaltenden Gateway-Ausfall mit Zeitstempel und Fehlermeldung an das Netzwerkteam.","source_refs":["KB-VPN-ESC"]}],"validation_steps":[{"text":"Der VPN-Tunnel wird aufgebaut und interne Ressourcen sind erreichbar.","source_refs":["KB-REMOTE"]}],"troubleshooting":[{"text":"Bei weiterhin nicht erreichbarem Gateway den dokumentierten Eskalationsweg verwenden.","source_refs":["KB-VPN-ESC"]}],"contradictions":[],"missing_information":[],"research_queries":[],"ready_for_article":true}`
case 4:
content = `{"title":"VPN-Gateway-Störung systematisch beheben","problem_description":"Der VPN-Client kann keinen Tunnel zum zentralen Gateway aufbauen. Betroffene Benutzer sehen typischerweise einen Timeout oder die Meldung, dass das Gateway nicht erreichbar ist. Die folgenden Schritte grenzen lokale Verbindungsprobleme von einer zentralen Gateway-Störung ab.","scope":"Die Anleitung gilt für Remotezugriffe über den dokumentierten VPN-Client und das zugehörige zentrale Gateway.","symptoms":["Der Verbindungsaufbau endet mit einem Timeout.","Das konfigurierte Gateway ist nicht erreichbar."],"prerequisites":["Die aktuelle Fehlermeldung und der Zeitpunkt des Fehlers liegen vor.","Zugriff auf die VPN-Client-Konfiguration ist vorhanden."],"solution_steps":["Prüfen Sie, ob der betroffene Rechner eine funktionierende Internetverbindung besitzt.","Kontrollieren Sie die konfigurierte Gateway-Adresse und testen Sie deren Erreichbarkeit.","Starten Sie den VPN-Client neu und führen Sie den Verbindungsversuch erneut aus.","Bleibt das Gateway nicht erreichbar, übergeben Sie Zeitstempel, Fehlermeldung und betroffene Benutzer an das Netzwerkteam."],"validation_steps":["Der VPN-Tunnel wird aufgebaut.","Eine interne Zielressource ist erreichbar."],"troubleshooting":["Bei weiterhin nicht erreichbarem Gateway den dokumentierten Eskalationsweg verwenden."],"categories":["Netzwerk","VPN"],"keywords":["VPN","Gateway","Remotezugriff"],"open_questions":[]}`
default:
content = `{"title":"VPN-Gateway-Störung systematisch beheben","text":"Dieser Artikel gilt für Remotezugriffe, bei denen der VPN-Client keine Verbindung zum Gateway herstellen kann. Typische Symptome sind Zeitüberschreitungen, ein nicht erreichbares Gateway oder ein Verbindungsabbruch unmittelbar nach dem Start. Die Anleitung grenzt lokale Clientfehler von einer Störung des zentralen Gateways ab.","answer":"1. Prüfen Sie zuerst, ob der betroffene Rechner eine funktionierende Internetverbindung besitzt. 2. Kontrollieren Sie, ob der konfigurierte Gateway-Name erreichbar ist. 3. Vergleichen Sie die Fehlermeldung mit dem dokumentierten Gateway-Ausfallbild. 4. Starten Sie den VPN-Client neu und führen Sie den Verbindungsversuch erneut aus. 5. Bleibt das Gateway nicht erreichbar, eskalieren Sie den Fall mit Zeitstempel und Fehlermeldung an das Netzwerkteam.","prerequisites":["Zugriff auf die VPN-Client-Konfiguration","Aktuelle Fehlermeldung des Benutzers"],"validation":["Der VPN-Tunnel wird aufgebaut","Die interne Zielressource ist erreichbar"],"troubleshooting":["Bei weiterhin nicht erreichbarem Gateway den dokumentierten Eskalationsweg verwenden"],"categories":["Netzwerk","VPN"],"keywords":["VPN","Gateway","Remotezugriff"],"source_node_ids":[],"confidence":0.92,"open_questions":[]}`
content = `{"accepted":true,"confidence":0.92,"meta_content_detected":false,"unsupported_claims":[],"issues":[]}`
}
_ = json.NewEncoder(w).Encode(map[string]any{"message": map[string]any{"content": content}})
default:
@@ -88,8 +92,8 @@ func TestEnrichCreatesStructuredKnowledgeArticleFromThreeProductionSources(t *te
if err := e.Flush(context.Background()); err != nil {
t.Fatal(err)
}
if chatCalls != 3 {
t.Fatalf("expected relation, plan and draft calls, got %d", chatCalls)
if chatCalls != 5 {
t.Fatalf("expected relation, plan, consolidation, draft and quality calls, got %d", chatCalls)
}
files, err := filepath.Glob(filepath.Join(staging, "*.json"))
if err != nil || len(files) != 1 {
@@ -106,12 +110,18 @@ func TestEnrichCreatesStructuredKnowledgeArticleFromThreeProductionSources(t *te
if got := doc["answer"].(string); len(got) < 180 || got == "Interne AI-THINK-Arbeitsnotiz. Vor produktiver Nutzung im Editor prüfen, korrigieren und freigeben." {
t.Fatalf("expected a real solution article, got %q", got)
}
ai, ok := doc["ai_think"].(map[string]any)
if !ok || ai["subtype"] != "knowledge_synthesis" || ai["action"] != "create" {
t.Fatalf("unexpected AI-THINK metadata: %#v", doc["ai_think"])
if _, exists := doc["ai_think"]; exists {
t.Fatalf("internal assessment metadata must not be written into the KB article: %#v", doc["ai_think"])
}
if ai["productive_source_count"].(float64) < 3 {
t.Fatalf("expected at least three productive sources: %#v", ai)
visible := strings.ToLower(doc["text"].(string) + "\n" + doc["answer"].(string))
for _, forbidden := range []string{"die quellen", "mehrwert", "bewertung", "source_node", "relation"} {
if strings.Contains(visible, forbidden) {
t.Fatalf("visible article contains internal assessment language %q: %s", forbidden, visible)
}
}
metaFiles, err := filepath.Glob(filepath.Join(data, "article-metadata", "*.json"))
if err != nil || len(metaFiles) != 1 {
t.Fatalf("expected one separate article metadata sidecar, files=%v err=%v", metaFiles, err)
}
}
@@ -168,6 +178,117 @@ func TestEnrichRelationWithOnlyTwoSourcesDoesNotCreateArticle(t *testing.T) {
}
}
func TestSynthesisResearchesUnclearKnowledgeThenLearnsAndLinksArticle(t *testing.T) {
var chatCalls int
ollama := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/tags":
_, _ = w.Write([]byte(`{"models":[{"name":"qwen3:8b","digest":"chat-digest"},{"name":"embeddinggemma:latest","digest":"embed-digest"}]}`))
case "/api/embed":
var req struct {
Input []string `json:"input"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
vectors := make([][]float64, len(req.Input))
for i := range vectors {
vectors[i] = []float64{1, .1 + float64(i)*.01, .2}
}
_ = json.NewEncoder(w).Encode(map[string]any{"embeddings": vectors})
case "/api/chat":
chatCalls++
content := ""
switch chatCalls {
case 1:
content = `{"related":true,"relation_type":"same_topic","confidence":0.9,"explanation":"Die Beiträge behandeln denselben VPN-Fehler.","needs_research":false,"research_query":"","topic_label":"VPN Gateway","keywords":["VPN"]}`
case 2:
content = `{"action":"create","target_article_id":"","reason":"Die produktiven Beiträge lassen sich konsolidieren.","expected_value":"Ein vollständiger Diagnoseablauf.","article_type":"troubleshooting","source_node_ids":[],"missing_information":[],"contradictions":[],"needs_research":false,"research_query":""}`
case 3:
content = `{"topic":"VPN Gateway","purpose":"VPN-Fehler beheben","scope":[],"facts":[],"symptoms":[{"text":"Der VPN-Tunnel wird nicht aufgebaut.","source_refs":["A"]}],"prerequisites":[],"solution_steps":[{"text":"Internetverbindung prüfen.","source_refs":["A"]}],"validation_steps":[],"troubleshooting":[],"contradictions":[{"topic":"Offizieller Gateway-Prüfweg","statements":["Die internen Beiträge nennen keinen verlässlichen Gateway-Test."],"source_refs":["A","B","C"],"resolution":"","needs_research":true,"research_query":"Hersteller VPN Gateway Erreichbarkeit Diagnose"}],"missing_information":["Offizieller Gateway-Prüfweg"],"research_queries":["Hersteller VPN Gateway Erreichbarkeit Diagnose"],"ready_for_article":false}`
case 4:
content = `{"topic":"VPN Gateway","purpose":"VPN-Fehler beheben","scope":[{"text":"Gilt für den dokumentierten VPN-Client.","source_refs":["A"]}],"facts":[{"text":"Der Hersteller empfiehlt, die Gateway-Adresse und die Netzwerkverbindung zu prüfen.","source_refs":["R1"]}],"symptoms":[{"text":"Der VPN-Tunnel wird nicht aufgebaut.","source_refs":["A"]}],"prerequisites":[{"text":"Fehlermeldung und Zeitpunkt liegen vor.","source_refs":["C"]}],"solution_steps":[{"text":"Prüfen Sie die Internetverbindung.","source_refs":["A"]},{"text":"Prüfen Sie die konfigurierte Gateway-Adresse und deren Erreichbarkeit.","source_refs":["R1"]},{"text":"Starten Sie den VPN-Client neu.","source_refs":["B"]}],"validation_steps":[{"text":"Der VPN-Tunnel wird aufgebaut.","source_refs":["B"]}],"troubleshooting":[{"text":"Bei anhaltendem Fehler mit Zeitstempel und Meldung eskalieren.","source_refs":["C"]}],"contradictions":[],"missing_information":[],"research_queries":[],"ready_for_article":true}`
case 5:
content = `{"title":"VPN-Gateway-Verbindung diagnostizieren","problem_description":"Der VPN-Client kann keine Verbindung zum konfigurierten Gateway aufbauen. Der Tunnel bleibt getrennt und der Remotezugriff ist nicht möglich.","scope":"Die Anleitung gilt für den dokumentierten VPN-Client und das konfigurierte zentrale Gateway.","symptoms":["Der VPN-Tunnel wird nicht aufgebaut.","Der Client meldet ein nicht erreichbares Gateway."],"prerequisites":["Fehlermeldung und Zeitpunkt des Fehlers liegen vor."],"solution_steps":["Prüfen Sie die Internetverbindung des Rechners.","Kontrollieren Sie die konfigurierte Gateway-Adresse und testen Sie deren Erreichbarkeit.","Starten Sie den VPN-Client neu und wiederholen Sie den Verbindungsaufbau.","Eskalieren Sie einen anhaltenden Fehler mit Zeitstempel und Fehlermeldung."],"validation_steps":["Der VPN-Tunnel wird aufgebaut.","Eine interne Ressource ist erreichbar."],"troubleshooting":["Prüfen Sie bei erneutem Fehler die erfasste Meldung und den dokumentierten Eskalationsweg."],"categories":["Netzwerk","VPN"],"keywords":["VPN","Gateway"],"open_questions":[]}`
default:
content = `{"accepted":true,"confidence":0.93,"meta_content_detected":false,"unsupported_claims":[],"issues":[]}`
}
_ = json.NewEncoder(w).Encode(map[string]any{"message": map[string]any{"content": content}})
default:
http.NotFound(w, r)
}
}))
defer ollama.Close()
searx := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"results":[{"title":"Hersteller-Dokumentation","url":"https://vendor.example/vpn-gateway","content":"Prüfen Sie die konfigurierte Gateway-Adresse und die Netzwerkverbindung. Starten Sie danach den Client neu."}]}`))
}))
defer searx.Close()
root := t.TempDir()
knowledge, staging, data := filepath.Join(root, "knowledge"), filepath.Join(root, "staging"), filepath.Join(root, "data")
for _, dir := range []string{knowledge, staging, data} {
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
}
for name, body := range map[string]string{
"a.json": `{"id":"A","title":"VPN nicht verbunden","text":"Der Tunnel wird nicht aufgebaut.","answer":"Internetverbindung prüfen.","categories":["VPN"]}`,
"b.json": `{"id":"B","title":"VPN Client neu starten","text":"Der Client bleibt getrennt.","answer":"Client neu starten und Tunnel erneut prüfen.","categories":["VPN"]}`,
"c.json": `{"id":"C","title":"VPN Eskalation","text":"Anhaltende Fehler müssen eskaliert werden.","answer":"Zeitstempel und Fehlermeldung erfassen.","categories":["VPN"]}`,
} {
if err := os.WriteFile(filepath.Join(knowledge, name), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
g, err := graph.Open(data)
if err != nil {
t.Fatal(err)
}
cfg := config.Config{DataDir: data, KnowledgeDirs: []string{knowledge}, StagingDirs: []string{staging}, OllamaURL: ollama.URL, ChatModel: "qwen3:8b", EmbeddingModel: "embeddinggemma", SearXNGURL: searx.URL, ResearchEnabled: true, SimilarityThreshold: .5, RelationThreshold: .7, ArticleSynthesisEnabled: true, ArticleMinSources: 3, ArticleMaxSources: 6, ArticleMinProductionRatio: .7, ArticleMaxGenerationDepth: 2, ArticleMinConfidence: .7, ArticleMinTextChars: 80, ArticleMinAnswerChars: 180, ArticleMaxResearchQueries: 3, ArticleResearchResults: 4, MaxContextChars: 16000}
e := New(cfg, g, activity.New(100))
if err := e.Scan(context.Background()); err != nil {
t.Fatal(err)
}
if err := e.EnrichOne(context.Background()); err != nil {
t.Fatal(err)
}
if err := e.Flush(context.Background()); err != nil {
t.Fatal(err)
}
files, _ := filepath.Glob(filepath.Join(staging, "*.json"))
if len(files) != 1 {
t.Fatalf("expected researched article, files=%v", files)
}
var doc map[string]any
dataBytes, _ := os.ReadFile(files[0])
if err := json.Unmarshal(dataBytes, &doc); err != nil {
t.Fatal(err)
}
if _, exists := doc["ai_think"]; exists {
t.Fatal("assessment metadata leaked into article")
}
articleID := doc["id"].(string)
articleNodeID := graph.ID("knowledge", articleID)
if vector, ok := g.Vector(articleNodeID); !ok || len(vector) == 0 {
t.Fatal("new article was not learned immediately")
}
researchID := graph.ID("external", "https://vendor.example/vpn-gateway")
linked := false
for _, edge := range g.Snapshot().Edges {
if edge.Source == articleNodeID && edge.Target == researchID && edge.Type == "grounded_by" {
linked = true
break
}
}
if !linked {
t.Fatal("new article is not linked to its research evidence")
}
if chatCalls != 6 {
t.Fatalf("expected relation, plan, two consolidations, article and quality calls, got %d", chatCalls)
}
}
func TestRequestEnrichDoesNotQueueDuplicateCycle(t *testing.T) {
g, err := graph.Open(t.TempDir())
if err != nil {
@@ -250,7 +371,7 @@ func TestWriteKnowledgeArticleDraftPreservesUpdateTarget(t *testing.T) {
}
plan := model.ArticlePlanDecision{Action: "update", TargetArticleID: target.ID, Reason: "Der bestehende Artikel benötigt Diagnose und Validierung.", ExpectedValue: "Vollständiger Ablauf", ArticleType: "troubleshooting", SourceNodeIDs: nodeIDsFromArticleSources(sources)}
draft := model.KnowledgeArticleDraft{Title: "VPN-Artikel vollständig diagnostizieren", Text: strings.Repeat("Problem und Geltungsbereich. ", 5), Answer: strings.Repeat("1. Konkreten Prüfschritt ausführen. ", 8), Validation: []string{"VPN-Tunnel ist aktiv"}, Categories: []string{"VPN"}, Keywords: []string{"VPN"}, SourceNodeIDs: nodeIDsFromArticleSources(sources), Confidence: .9}
path, _, created, err := e.writeKnowledgeArticleDraft(sources, plan, draft, nil, 3, 0, 1, 1)
path, _, created, err := e.writeKnowledgeArticleDraft(sources, plan, model.KnowledgeBrief{Topic: "VPN", ReadyForArticle: true}, draft, nil, 3, 0, 1, 1)
if err != nil {
t.Fatal(err)
}
@@ -268,9 +389,23 @@ func TestWriteKnowledgeArticleDraftPreservesUpdateTarget(t *testing.T) {
if err := json.Unmarshal(dataBytes, &doc); err != nil {
t.Fatal(err)
}
ai := doc["ai_think"].(map[string]any)
if ai["action"] != "update" || ai["target_article_id"] != "KB-TARGET" || ai["target_node_id"] != target.ID {
t.Fatalf("update target metadata was not preserved: %#v", ai)
if _, exists := doc["ai_think"]; exists {
t.Fatalf("internal metadata leaked into KB document: %#v", doc["ai_think"])
}
metaFiles, err := filepath.Glob(filepath.Join(data, "article-metadata", "*.json"))
if err != nil || len(metaFiles) != 1 {
t.Fatalf("expected separate update metadata sidecar, files=%v err=%v", metaFiles, err)
}
metaBytes, err := os.ReadFile(metaFiles[0])
if err != nil {
t.Fatal(err)
}
var meta map[string]any
if err := json.Unmarshal(metaBytes, &meta); err != nil {
t.Fatal(err)
}
if meta["action"] != "update" || meta["target_article_id"] != "KB-TARGET" || meta["target_node_id"] != target.ID {
t.Fatalf("update target metadata was not preserved in sidecar: %#v", meta)
}
if !strings.Contains(doc["answer"].(string), "## Ergebnis prüfen") {
t.Fatalf("validation section missing from answer: %q", doc["answer"])

View File

@@ -143,6 +143,58 @@ type ArticlePlanDecision struct {
ResearchQuery string `json:"research_query"`
}
type GroundedStatement struct {
Text string `json:"text"`
SourceRefs []string `json:"source_refs"`
}
type KnowledgeConflict struct {
Topic string `json:"topic"`
Statements []string `json:"statements"`
SourceRefs []string `json:"source_refs"`
Resolution string `json:"resolution"`
NeedsResearch bool `json:"needs_research"`
ResearchQuery string `json:"research_query"`
}
type KnowledgeBrief struct {
Topic string `json:"topic"`
Purpose string `json:"purpose"`
Scope []GroundedStatement `json:"scope"`
Facts []GroundedStatement `json:"facts"`
Symptoms []GroundedStatement `json:"symptoms"`
Prerequisites []GroundedStatement `json:"prerequisites"`
SolutionSteps []GroundedStatement `json:"solution_steps"`
ValidationSteps []GroundedStatement `json:"validation_steps"`
Troubleshooting []GroundedStatement `json:"troubleshooting"`
Contradictions []KnowledgeConflict `json:"contradictions"`
MissingInformation []string `json:"missing_information"`
ResearchQueries []string `json:"research_queries"`
ReadyForArticle bool `json:"ready_for_article"`
}
type KnowledgeArticleContent struct {
Title string `json:"title"`
ProblemDescription string `json:"problem_description"`
Scope string `json:"scope"`
Symptoms []string `json:"symptoms"`
Prerequisites []string `json:"prerequisites"`
SolutionSteps []string `json:"solution_steps"`
ValidationSteps []string `json:"validation_steps"`
Troubleshooting []string `json:"troubleshooting"`
Categories []string `json:"categories"`
Keywords []string `json:"keywords"`
OpenQuestions []string `json:"open_questions"`
}
type ArticleQualityDecision struct {
Accepted bool `json:"accepted"`
Confidence float64 `json:"confidence"`
MetaContentDetected bool `json:"meta_content_detected"`
UnsupportedClaims []string `json:"unsupported_claims"`
Issues []string `json:"issues"`
}
type KnowledgeArticleDraft struct {
Title string `json:"title"`
Text string `json:"text"`

Binary file not shown.