Update 2+3
release-tag / release-image (push) Successful in 1m36s

This commit is contained in:
2026-08-04 12:07:45 +02:00
parent 5cb00c45d5
commit e94fb23f0c
27 changed files with 1816 additions and 201 deletions
+815
View File
@@ -0,0 +1,815 @@
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) {
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
}
researchResults := append([]model.ResearchResult(nil), initialResearch...)
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}})
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()}})
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)
}
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}})
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)
}
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)
productionCount, aiCount, productionRatio, maxDepth = articleSourceStats(selected)
generationDepth = maxDepth + 1
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)
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}})
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) articleDraftContext(sources []articleSource, plan model.ArticlePlanDecision, 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")
appendArticleSources(&b, sources, e.Cfg.MaxContextChars)
if len(researchResults) > 0 {
b.WriteString("\nRECHERCHEERGEBNISSE (nur als zusätzliche Evidenz, nicht als garantierte Wahrheit):\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))
}
}
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 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.
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.
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"},
"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"}}
}
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, 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)
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
}
}
}
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_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 {
return "", "", false, err
}
queued, err := e.Persistence.QueueFile(path, append(bytes, '\n'), 0o640)
if 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) {
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})
}
}
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
}
}