1262 lines
57 KiB
Go
1262 lines
57 KiB
Go
package engine
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"math"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/local/glpi-neural-brain/internal/graph"
|
|
"github.com/local/glpi-neural-brain/internal/model"
|
|
)
|
|
|
|
type articleSource struct {
|
|
Node model.Node
|
|
Content string
|
|
Score float64
|
|
Depth int
|
|
}
|
|
|
|
type articleSynthesisOutcome struct {
|
|
Created bool
|
|
Skipped bool
|
|
Reason string
|
|
Path string
|
|
Action string
|
|
Title string
|
|
}
|
|
|
|
func (e *Engine) synthesizeKnowledgeArticle(ctx context.Context, trigger string, seeds []model.Node, relation model.RelationDecision, initialResearch []model.ResearchResult) (articleSynthesisOutcome, error) {
|
|
if !e.Cfg.ArticleSynthesisEnabled {
|
|
return articleSynthesisOutcome{Skipped: true, Reason: "article_synthesis_disabled"}, nil
|
|
}
|
|
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}})
|
|
return articleSynthesisOutcome{Skipped: true, Reason: "insufficient_production_sources"}, nil
|
|
}
|
|
if productionRatio < e.Cfg.ArticleMinProductionRatio {
|
|
return articleSynthesisOutcome{Skipped: true, Reason: "production_ratio_too_low"}, nil
|
|
}
|
|
generationDepth := maxDepth + 1
|
|
if generationDepth > e.Cfg.ArticleMaxGenerationDepth {
|
|
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}})
|
|
|
|
var plan model.ArticlePlanDecision
|
|
if err := e.Ollama.ChatJSON(ctx, articlePlanSystemPrompt(), e.articlePlanContext(sources, relation, initialResearch), articlePlanSchema(), &plan); err != nil {
|
|
return articleSynthesisOutcome{}, fmt.Errorf("article planning failed: %w", err)
|
|
}
|
|
plan.Action = safeArticleAction(plan.Action)
|
|
allowedIDs := nodeIDsFromArticleSources(sources)
|
|
plan.SourceNodeIDs = validIDs(plan.SourceNodeIDs, allowedIDs)
|
|
if len(plan.SourceNodeIDs) < e.Cfg.ArticleMinSources {
|
|
plan.SourceNodeIDs = allowedIDs
|
|
}
|
|
selected := filterArticleSources(sources, plan.SourceNodeIDs)
|
|
productionCount, aiCount, productionRatio, maxDepth = articleSourceStats(selected)
|
|
generationDepth = maxDepth + 1
|
|
if productionCount < e.Cfg.ArticleMinSources || productionRatio < e.Cfg.ArticleMinProductionRatio || generationDepth > e.Cfg.ArticleMaxGenerationDepth {
|
|
return articleSynthesisOutcome{Skipped: true, Reason: "plan_source_policy_failed", Action: plan.Action}, nil
|
|
}
|
|
if plan.Action == "skip" {
|
|
e.Broker.Publish(model.Activity{Type: "article.plan.skipped", Source: "brain", Phase: "knowledge-planning", NodeIDs: plan.SourceNodeIDs, Message: nonempty(plan.Reason, "Der Quellenverbund erzeugt keinen zusätzlichen Wissensnutzen"), Strength: .36, Metadata: map[string]any{"trigger": trigger, "action": plan.Action, "reason": plan.Reason, "expected_value": plan.ExpectedValue, "missing_information": plan.MissingInformation, "contradictions": plan.Contradictions}})
|
|
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) {
|
|
return articleSynthesisOutcome{Skipped: true, Reason: "invalid_target_article", Action: plan.Action}, nil
|
|
}
|
|
if e.hasEquivalentArticleDraft(selected, plan) {
|
|
return articleSynthesisOutcome{Skipped: true, Reason: "equivalent_staging_draft", Action: plan.Action}, nil
|
|
}
|
|
|
|
researchResults := append([]model.ResearchResult(nil), initialResearch...)
|
|
if len(initialResearch) > 0 {
|
|
e.addResearchToSources(selected, initialResearch)
|
|
}
|
|
|
|
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
|
|
}
|
|
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 {
|
|
return articleSynthesisOutcome{Skipped: true, Reason: "required_research_empty", Action: plan.Action}, nil
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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 := 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 {
|
|
return articleSynthesisOutcome{Skipped: true, Reason: "quality_gate: " + err.Error(), Action: plan.Action, Title: draft.Title}, nil
|
|
}
|
|
|
|
path, articleID, created, err := e.writeKnowledgeArticleDraft(selected, plan, brief, draft, researchResults, productionCount, aiCount, productionRatio, generationDepth)
|
|
if err != nil {
|
|
return articleSynthesisOutcome{}, err
|
|
}
|
|
if !created {
|
|
return articleSynthesisOutcome{Skipped: true, Reason: "duplicate", Action: plan.Action, Path: path, Title: draft.Title}, nil
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
func (e *Engine) selectArticleSources(seeds []model.Node) []articleSource {
|
|
snapshot := e.Graph.Snapshot()
|
|
seedIDs := map[string]bool{}
|
|
var seedVectors [][]float64
|
|
for _, seed := range seeds {
|
|
seedIDs[seed.ID] = true
|
|
if v, ok := e.Graph.Vector(seed.ID); ok && len(v) > 0 {
|
|
seedVectors = append(seedVectors, v)
|
|
}
|
|
}
|
|
centroid := vectorCentroid(seedVectors)
|
|
direct := map[string]float64{}
|
|
for _, edge := range snapshot.Edges {
|
|
if edge.Status == "rejected" || isTaxonomyEdge(edge.Type) {
|
|
continue
|
|
}
|
|
if seedIDs[edge.Source] {
|
|
direct[edge.Target] += math.Max(.2, math.Max(edge.Confidence, edge.Weight))
|
|
}
|
|
if seedIDs[edge.Target] {
|
|
direct[edge.Source] += math.Max(.2, math.Max(edge.Confidence, edge.Weight))
|
|
}
|
|
}
|
|
filters := e.thinkingCategories()
|
|
var production, ai []articleSource
|
|
for _, node := range snapshot.Nodes {
|
|
if node.Kind != "knowledge" && node.Kind != "ai-think" {
|
|
continue
|
|
}
|
|
if !matchesCategories(node, filters) {
|
|
continue
|
|
}
|
|
depth := nodeGenerationDepth(node)
|
|
if node.Kind == "ai-think" && depth >= e.Cfg.ArticleMaxGenerationDepth {
|
|
continue
|
|
}
|
|
isProduction := node.Kind == "knowledge" && node.Status == "production"
|
|
isAI := node.Kind == "ai-think"
|
|
if !isProduction && !isAI {
|
|
continue
|
|
}
|
|
score := direct[node.ID] * 2.3
|
|
if seedIDs[node.ID] {
|
|
score += 8
|
|
}
|
|
if len(centroid) > 0 {
|
|
if v, ok := e.Graph.Vector(node.ID); ok && len(v) == len(centroid) {
|
|
sim := cosineVector(centroid, v)
|
|
if sim < e.Cfg.SimilarityThreshold*.82 && direct[node.ID] == 0 && !seedIDs[node.ID] {
|
|
continue
|
|
}
|
|
score += sim * 3
|
|
}
|
|
}
|
|
score += categoryAffinity(node, seeds) * .45
|
|
content := e.sourceContent(node)
|
|
if strings.TrimSpace(content) == "" {
|
|
content = node.Summary
|
|
}
|
|
source := articleSource{Node: node, Content: content, Score: score, Depth: depth}
|
|
if isProduction {
|
|
production = append(production, source)
|
|
} else {
|
|
ai = append(ai, source)
|
|
}
|
|
}
|
|
sortArticleSources(production)
|
|
sortArticleSources(ai)
|
|
max := e.Cfg.ArticleMaxSources
|
|
if max < 1 {
|
|
max = 8
|
|
}
|
|
out := make([]articleSource, 0, max)
|
|
for _, source := range production {
|
|
if len(out) >= max {
|
|
break
|
|
}
|
|
out = append(out, source)
|
|
}
|
|
for _, source := range ai {
|
|
if len(out) >= max {
|
|
break
|
|
}
|
|
prod, aiCount, _, _ := articleSourceStats(out)
|
|
if float64(aiCount+1)/float64(prod+aiCount+1) > 1-e.Cfg.ArticleMinProductionRatio {
|
|
continue
|
|
}
|
|
out = append(out, source)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (e *Engine) sourceContent(node model.Node) string {
|
|
if node.Origin == "glpi-kb" && e.GLPIKB != nil {
|
|
if value, ok := e.GLPIKB.Content(node.ID); ok && strings.TrimSpace(value) != "" {
|
|
return value
|
|
}
|
|
}
|
|
relPath := metadataString(node.Metadata, "path")
|
|
if relPath == "" {
|
|
return node.Summary
|
|
}
|
|
roots := e.Cfg.KnowledgeDirs
|
|
if node.Origin == "knowledge-staging" {
|
|
roots = e.Cfg.StagingDirs
|
|
}
|
|
for _, root := range roots {
|
|
candidate := filepath.Clean(filepath.Join(root, filepath.FromSlash(relPath)))
|
|
rootClean := filepath.Clean(root)
|
|
rel, err := filepath.Rel(rootClean, candidate)
|
|
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
|
continue
|
|
}
|
|
data, err := os.ReadFile(candidate)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
var doc map[string]any
|
|
if json.Unmarshal(data, &doc) != nil {
|
|
continue
|
|
}
|
|
problem := firstMapString(doc, "text", "problem", "description")
|
|
answer := firstMapString(doc, "answer", "solution")
|
|
var b strings.Builder
|
|
if problem != "" {
|
|
b.WriteString("PROBLEM / BESCHREIBUNG:\n")
|
|
b.WriteString(problem)
|
|
}
|
|
if answer != "" {
|
|
if b.Len() > 0 {
|
|
b.WriteString("\n\n")
|
|
}
|
|
b.WriteString("LÖSUNG / ANTWORT:\n")
|
|
b.WriteString(answer)
|
|
}
|
|
if b.Len() > 0 {
|
|
return b.String()
|
|
}
|
|
}
|
|
return node.Summary
|
|
}
|
|
|
|
func (e *Engine) articlePlanContext(sources []articleSource, relation model.RelationDecision, researchResults []model.ResearchResult) string {
|
|
var b strings.Builder
|
|
fmt.Fprintf(&b, "AUSLÖSENDE_RELATION: %s\nRELATIONSERKLÄRUNG: %s\nTHEMA: %s\n\n", safeRelation(relation.RelationType), relation.Explanation, relation.TopicLabel)
|
|
b.WriteString("ZIEL: Entscheide, ob aus den Quellen ein neuer oder verbesserter Helpdesk-Wissensartikel mit echtem Mehrwert entstehen soll.\n")
|
|
b.WriteString("PRODUKTIVE ARTIKEL dürfen als update/merge-Ziel gewählt werden. Die IDs stehen bei den Quellen.\n\nQUELLEN:\n")
|
|
appendArticleSources(&b, sources, e.Cfg.MaxContextChars)
|
|
if len(researchResults) > 0 {
|
|
b.WriteString("\nBEREITS VORHANDENE RECHERCHEHINWEISE:\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, 800))
|
|
}
|
|
}
|
|
return b.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
|
|
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("\nRECHERCHEBELEGE:\n")
|
|
for i, result := range researchResults {
|
|
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
|
|
}
|
|
remaining := maxChars
|
|
for i, source := range sources {
|
|
if remaining <= 500 {
|
|
break
|
|
}
|
|
contentLimit := remaining / max(1, len(sources)-i)
|
|
if contentLimit > 4000 {
|
|
contentLimit = 4000
|
|
}
|
|
if contentLimit < 700 {
|
|
contentLimit = 700
|
|
}
|
|
part := fmt.Sprintf("\nSOURCE_NODE_ID: %s\nEXTERNAL_ID: %s\nKIND: %s\nSTATUS: %s\nORIGIN: %s\nGENERATION_DEPTH: %d\nTITEL: %s\nKATEGORIEN: %s\nINHALT:\n%s\n", source.Node.ID, source.Node.ExternalID, source.Node.Kind, source.Node.Status, source.Node.Origin, source.Depth, source.Node.Label, strings.Join(source.Node.Categories, ", "), clamp(source.Content, contentLimit))
|
|
b.WriteString(part)
|
|
remaining -= len(part)
|
|
}
|
|
}
|
|
|
|
func articlePlanSystemPrompt() string {
|
|
return `Du planst die Pflege einer deutschsprachigen Helpdesk-Wissensdatenbank. Du erhältst mehrere bereits verwandte Quellen. Entscheide streng zwischen create, update, merge und skip.
|
|
|
|
create: Es gibt noch keinen vollständigen Artikel und die Quellen ergeben gemeinsam einen eigenständigen, nützlichen Lösungsartikel.
|
|
update: Ein vorhandener produktiver Artikel ist das klare Ziel und kann mit belastbaren Informationen verbessert werden.
|
|
merge: Mehrere produktive Artikel überschneiden sich und sollten als Staging-Entwurf in einen angegebenen Zielartikel konsolidiert werden.
|
|
skip: Kein echter Mehrwert, bloße Dublette, zu wenig Lösungssubstanz, zu viele Widersprüche oder unzureichende Quellen.
|
|
|
|
Erfinde keine Fakten. Bevorzuge konkrete Problemlösung gegenüber technischer Meta-Analyse. target_article_id ist bei update/merge zwingend eine SOURCE_NODE_ID einer produktiven Quelle. source_node_ids dürfen nur IDs aus dem Kontext enthalten. Wenn notwendige Fakten fehlen, setze needs_research=true. Gib ausschließlich JSON nach Schema zurück.`
|
|
}
|
|
|
|
func articleDraftSystemPrompt() string {
|
|
return `Du bist ausschließlich der Fachautor eines deutschsprachigen Helpdesk-Wissensartikels. Du führst keine Bewertung und keine Quellenanalyse im Ausgabedokument durch.
|
|
|
|
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.
|
|
- 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.`
|
|
}
|
|
|
|
func articlePlanSchema() map[string]any {
|
|
return map[string]any{"type": "object", "properties": map[string]any{
|
|
"action": map[string]any{"type": "string", "enum": []string{"create", "update", "merge", "skip"}},
|
|
"target_article_id": map[string]any{"type": "string"},
|
|
"reason": map[string]any{"type": "string"},
|
|
"expected_value": map[string]any{"type": "string"},
|
|
"article_type": map[string]any{"type": "string", "enum": []string{"troubleshooting", "how_to", "reference", "concept", "decision_guide"}},
|
|
"source_node_ids": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
|
|
"missing_information": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
|
|
"contradictions": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
|
|
"needs_research": map[string]any{"type": "boolean"},
|
|
"research_query": map[string]any{"type": "string"},
|
|
}, "required": []string{"action", "target_article_id", "reason", "expected_value", "article_type", "source_node_ids", "missing_information", "contradictions", "needs_research", "research_query"}}
|
|
}
|
|
|
|
func articleDraftSchema() map[string]any {
|
|
return map[string]any{"type": "object", "properties": map[string]any{
|
|
"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 {
|
|
if len([]rune(strings.TrimSpace(draft.Title))) < 8 {
|
|
return fmt.Errorf("title is too short")
|
|
}
|
|
if len([]rune(strings.TrimSpace(draft.Text))) < e.Cfg.ArticleMinTextChars {
|
|
return fmt.Errorf("problem description is shorter than %d characters", e.Cfg.ArticleMinTextChars)
|
|
}
|
|
if len([]rune(strings.TrimSpace(draft.Answer))) < e.Cfg.ArticleMinAnswerChars {
|
|
return fmt.Errorf("solution is shorter than %d characters", e.Cfg.ArticleMinAnswerChars)
|
|
}
|
|
if draft.Confidence < e.Cfg.ArticleMinConfidence {
|
|
return fmt.Errorf("confidence %.2f is below %.2f", draft.Confidence, e.Cfg.ArticleMinConfidence)
|
|
}
|
|
production, _, _, _ := articleSourceStats(sources)
|
|
if production < e.Cfg.ArticleMinSources {
|
|
return fmt.Errorf("only %d productive sources", production)
|
|
}
|
|
if productionRatio < e.Cfg.ArticleMinProductionRatio {
|
|
return fmt.Errorf("production ratio %.2f is below %.2f", productionRatio, e.Cfg.ArticleMinProductionRatio)
|
|
}
|
|
if generationDepth > e.Cfg.ArticleMaxGenerationDepth {
|
|
return fmt.Errorf("generation depth %d exceeds %d", generationDepth, e.Cfg.ArticleMaxGenerationDepth)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
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")
|
|
}
|
|
sourceIDs := nodeIDsFromArticleSources(sources)
|
|
sort.Strings(sourceIDs)
|
|
fingerprint := strings.Join(sourceIDs, "\x00") + "\x00" + plan.Action + "\x00" + plan.TargetArticleID
|
|
sum := sha256.Sum256([]byte(fingerprint))
|
|
short := strings.ToUpper(hex.EncodeToString(sum[:6]))
|
|
now := time.Now().UTC()
|
|
articleID := fmt.Sprintf("KB-AI-THINK-ARTICLE-%s-%s", now.Format("20060102"), short)
|
|
path := filepath.Join(e.Cfg.StagingDirs[0], strings.ToLower(articleID)+".json")
|
|
if e.Persistence.Pending(path) {
|
|
return path, articleID, false, nil
|
|
}
|
|
if _, err := os.Stat(path); err == nil {
|
|
return path, articleID, false, nil
|
|
}
|
|
|
|
categories := []string{"AI-THINK", "AI-Staging", "AI-Synthesis"}
|
|
categories = append(categories, draft.Categories...)
|
|
for _, source := range sources {
|
|
categories = append(categories, source.Node.Categories...)
|
|
}
|
|
categories = limitStrings(unique(categories), 18)
|
|
keywords := append([]string(nil), draft.Keywords...)
|
|
for _, source := range sources {
|
|
keywords = append(keywords, source.Node.Keywords...)
|
|
}
|
|
keywords = limitStrings(unique(keywords), 30)
|
|
answer := formatArticleAnswer(draft)
|
|
|
|
// 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 + " (Knowledge Synthesis)",
|
|
"source_uri": "brain://article/" + short, "language": "de-DE", "communication_style": "formal",
|
|
}
|
|
bytes, err := json.MarshalIndent(doc, "", " ")
|
|
if err != nil {
|
|
return "", "", false, err
|
|
}
|
|
queued, err := e.Persistence.QueueFile(path, append(bytes, '\n'), 0o640)
|
|
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, researchResults []model.ResearchResult, productionCount, aiCount int, productionRatio float64, generationDepth int) {
|
|
nodeID := graph.ID("knowledge", articleID)
|
|
now := time.Now().UTC()
|
|
node := model.Node{
|
|
ID: nodeID, Kind: "ai-think", Label: draft.Title, Summary: clamp(strings.TrimSpace(draft.Text)+"\n\n"+formatArticleAnswer(draft), 1400),
|
|
Status: "staging", Origin: "knowledge-staging", ExternalID: articleID, URI: "brain://article/" + articleID,
|
|
Categories: unique(append([]string{"AI-THINK", "AI-Staging", "AI-Synthesis"}, draft.Categories...)), Keywords: unique(draft.Keywords), Weight: 1.45,
|
|
Metadata: map[string]any{"subtype": "knowledge_synthesis", "action": plan.Action, "target_node_id": plan.TargetArticleID, "generation_depth": generationDepth, "confidence": draft.Confidence, "source_node_ids": nodeIDsFromArticleSources(sources), "productive_source_count": productionCount, "ai_source_count": aiCount, "production_ratio": productionRatio}, UpdatedAt: now,
|
|
}
|
|
e.Graph.UpsertNode(node)
|
|
for _, source := range sources {
|
|
e.Graph.UpsertEdge(model.Edge{Source: nodeID, Target: source.Node.ID, Type: "synthesized_from", Origin: "knowledge-staging", Status: "staging", Confidence: draft.Confidence, Weight: .65, Explanation: plan.Reason})
|
|
}
|
|
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) {
|
|
for _, result := range results {
|
|
id := graph.ID("external", result.URL)
|
|
e.Graph.UpsertNode(model.Node{ID: id, Kind: "external", Label: result.Title, Summary: clamp(result.Content, 900), Status: "research", Origin: "research", ExternalID: result.URL, URI: result.URL, Weight: .8, UpdatedAt: time.Now().UTC()})
|
|
for _, source := range sources {
|
|
e.Graph.UpsertEdge(model.Edge{Source: id, Target: source.Node.ID, Type: "research_evidence", Origin: "research", Status: "staging", Confidence: .55, Weight: .4})
|
|
}
|
|
}
|
|
}
|
|
|
|
func formatArticleAnswer(draft model.KnowledgeArticleDraft) string {
|
|
var b strings.Builder
|
|
b.WriteString(strings.TrimSpace(draft.Answer))
|
|
appendListSection(&b, "Voraussetzungen", draft.Prerequisites)
|
|
appendListSection(&b, "Ergebnis prüfen", draft.Validation)
|
|
appendListSection(&b, "Fehlerbehandlung", draft.Troubleshooting)
|
|
return strings.TrimSpace(b.String())
|
|
}
|
|
|
|
func limitStrings(values []string, limit int) []string {
|
|
if limit > 0 && len(values) > limit {
|
|
return append([]string(nil), values[:limit]...)
|
|
}
|
|
return values
|
|
}
|
|
|
|
func appendListSection(b *strings.Builder, title string, items []string) {
|
|
clean := unique(items)
|
|
if len(clean) == 0 {
|
|
return
|
|
}
|
|
b.WriteString("\n\n## ")
|
|
b.WriteString(title)
|
|
for _, item := range clean {
|
|
b.WriteString("\n- ")
|
|
b.WriteString(strings.TrimSpace(item))
|
|
}
|
|
}
|
|
|
|
func articleSourceStats(sources []articleSource) (production, ai int, ratio float64, maxDepth int) {
|
|
for _, source := range sources {
|
|
if source.Node.Kind == "knowledge" && source.Node.Status == "production" {
|
|
production++
|
|
} else if source.Node.Kind == "ai-think" {
|
|
ai++
|
|
}
|
|
if source.Depth > maxDepth {
|
|
maxDepth = source.Depth
|
|
}
|
|
}
|
|
if production+ai > 0 {
|
|
ratio = float64(production) / float64(production+ai)
|
|
}
|
|
return
|
|
}
|
|
|
|
func nodeGenerationDepth(node model.Node) int {
|
|
if node.Kind != "ai-think" {
|
|
return 0
|
|
}
|
|
value, ok := node.Metadata["generation_depth"]
|
|
if !ok {
|
|
return 1
|
|
}
|
|
switch typed := value.(type) {
|
|
case int:
|
|
return typed
|
|
case int64:
|
|
return int(typed)
|
|
case float64:
|
|
return int(typed)
|
|
case json.Number:
|
|
n, _ := typed.Int64()
|
|
return int(n)
|
|
default:
|
|
return 1
|
|
}
|
|
}
|
|
|
|
func (e *Engine) hasEquivalentArticleDraft(sources []articleSource, plan model.ArticlePlanDecision) bool {
|
|
wanted := map[string]bool{}
|
|
for _, source := range sources {
|
|
wanted[source.Node.ID] = true
|
|
}
|
|
for _, node := range e.Graph.Snapshot().Nodes {
|
|
if node.Kind != "ai-think" || metadataString(node.Metadata, "subtype") != "knowledge_synthesis" {
|
|
continue
|
|
}
|
|
if (plan.Action == "update" || plan.Action == "merge") && metadataString(node.Metadata, "target_node_id") == plan.TargetArticleID {
|
|
return true
|
|
}
|
|
existing := metadataStringSlice(node.Metadata, "source_node_ids")
|
|
if len(existing) == 0 {
|
|
continue
|
|
}
|
|
intersection := 0
|
|
union := make(map[string]bool, len(wanted)+len(existing))
|
|
for id := range wanted {
|
|
union[id] = true
|
|
}
|
|
for _, id := range existing {
|
|
if wanted[id] {
|
|
intersection++
|
|
}
|
|
union[id] = true
|
|
}
|
|
if len(union) > 0 && float64(intersection)/float64(len(union)) >= .70 {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func validProductionTarget(id string, sources []articleSource) bool {
|
|
if strings.TrimSpace(id) == "" {
|
|
return false
|
|
}
|
|
for _, source := range sources {
|
|
if source.Node.ID == id && source.Node.Kind == "knowledge" && source.Node.Status == "production" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func safeArticleAction(value string) string {
|
|
switch strings.ToLower(strings.TrimSpace(value)) {
|
|
case "create", "update", "merge", "skip":
|
|
return strings.ToLower(strings.TrimSpace(value))
|
|
default:
|
|
return "skip"
|
|
}
|
|
}
|
|
|
|
func filterArticleSources(sources []articleSource, ids []string) []articleSource {
|
|
wanted := map[string]bool{}
|
|
for _, id := range ids {
|
|
wanted[id] = true
|
|
}
|
|
var out []articleSource
|
|
for _, source := range sources {
|
|
if wanted[source.Node.ID] {
|
|
out = append(out, source)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func nodeIDsFromArticleSources(sources []articleSource) []string {
|
|
out := make([]string, 0, len(sources))
|
|
for _, source := range sources {
|
|
out = append(out, source.Node.ID)
|
|
}
|
|
return unique(out)
|
|
}
|
|
|
|
func externalIDsFromArticleSources(sources []articleSource) []string {
|
|
out := make([]string, 0, len(sources))
|
|
for _, source := range sources {
|
|
out = append(out, nonempty(source.Node.ExternalID, source.Node.ID))
|
|
}
|
|
return unique(out)
|
|
}
|
|
|
|
func sortArticleSources(sources []articleSource) {
|
|
sort.Slice(sources, func(i, j int) bool {
|
|
if sources[i].Score == sources[j].Score {
|
|
return sources[i].Node.ID < sources[j].Node.ID
|
|
}
|
|
return sources[i].Score > sources[j].Score
|
|
})
|
|
}
|
|
|
|
func vectorCentroid(vectors [][]float64) []float64 {
|
|
if len(vectors) == 0 {
|
|
return nil
|
|
}
|
|
dim := len(vectors[0])
|
|
if dim == 0 {
|
|
return nil
|
|
}
|
|
out := make([]float64, dim)
|
|
count := 0
|
|
for _, vector := range vectors {
|
|
if len(vector) != dim {
|
|
continue
|
|
}
|
|
count++
|
|
for i, value := range vector {
|
|
out[i] += value
|
|
}
|
|
}
|
|
if count == 0 {
|
|
return nil
|
|
}
|
|
for i := range out {
|
|
out[i] /= float64(count)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func cosineVector(a, b []float64) float64 {
|
|
if len(a) == 0 || len(a) != len(b) {
|
|
return 0
|
|
}
|
|
var dot, aa, bb float64
|
|
for i := range a {
|
|
dot += a[i] * b[i]
|
|
aa += a[i] * a[i]
|
|
bb += b[i] * b[i]
|
|
}
|
|
if aa == 0 || bb == 0 {
|
|
return 0
|
|
}
|
|
return dot / (math.Sqrt(aa) * math.Sqrt(bb))
|
|
}
|
|
|
|
func categoryAffinity(node model.Node, seeds []model.Node) float64 {
|
|
wanted := map[string]bool{}
|
|
for _, seed := range seeds {
|
|
for _, category := range seed.Categories {
|
|
wanted[strings.ToLower(strings.TrimSpace(category))] = true
|
|
}
|
|
}
|
|
var matches int
|
|
for _, category := range node.Categories {
|
|
if wanted[strings.ToLower(strings.TrimSpace(category))] {
|
|
matches++
|
|
}
|
|
}
|
|
return float64(matches)
|
|
}
|
|
|
|
func matchesCategories(node model.Node, filters []string) bool {
|
|
if len(filters) == 0 {
|
|
return true
|
|
}
|
|
wanted := map[string]bool{}
|
|
for _, filter := range filters {
|
|
wanted[strings.ToLower(strings.TrimSpace(filter))] = true
|
|
}
|
|
if wanted["*"] {
|
|
return true
|
|
}
|
|
if len(node.Categories) == 0 {
|
|
return wanted["__uncategorized__"]
|
|
}
|
|
for _, category := range node.Categories {
|
|
if wanted[strings.ToLower(strings.TrimSpace(category))] {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func metadataString(metadata map[string]any, key string) string {
|
|
if metadata == nil {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(fmt.Sprint(metadata[key]))
|
|
}
|
|
|
|
func metadataStringSlice(metadata map[string]any, key string) []string {
|
|
if metadata == nil {
|
|
return nil
|
|
}
|
|
switch value := metadata[key].(type) {
|
|
case []string:
|
|
return append([]string(nil), value...)
|
|
case []any:
|
|
out := make([]string, 0, len(value))
|
|
for _, item := range value {
|
|
if text := strings.TrimSpace(fmt.Sprint(item)); text != "" {
|
|
out = append(out, text)
|
|
}
|
|
}
|
|
return out
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func firstMapString(m map[string]any, keys ...string) string {
|
|
for _, key := range keys {
|
|
if value, ok := m[key]; ok {
|
|
text := strings.TrimSpace(fmt.Sprint(value))
|
|
if text != "" && text != "<nil>" {
|
|
return text
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func isTaxonomyEdge(edgeType string) bool {
|
|
switch edgeType {
|
|
case "categorized_as", "mentions", "derived_from":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|