This commit is contained in:
@@ -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 (3–8 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
|
||||
|
||||
|
||||
21
CHANGELOG-GROUNDED-KNOWLEDGE-SYNTHESIS.md
Normal file
21
CHANGELOG-GROUNDED-KNOWLEDGE-SYNTHESIS.md
Normal 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
|
||||
```
|
||||
@@ -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`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
11
README.md
11
README.md
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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"`
|
||||
|
||||
BIN
neural-brain
BIN
neural-brain
Binary file not shown.
Reference in New Issue
Block a user