@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user