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
}
}
+103 -70
View File
@@ -3,13 +3,10 @@ package engine
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log/slog"
"math"
"os"
"path/filepath"
"sort"
"strings"
@@ -35,11 +32,14 @@ var (
)
type EnrichOutcome struct {
Result string
Candidate bool
Created bool
Rejected bool
Comparisons int
Result string
Candidate bool
Created bool
RelationCreated bool
ArticleCreated bool
ArticleSkipped bool
Rejected bool
Comparisons int
}
type Engine struct {
@@ -52,24 +52,27 @@ type Engine struct {
GLPIKB *ingest.GLPIKBSyncer
Persistence *persist.Coordinator
mu sync.Mutex
stateMu sync.RWMutex
lastScan time.Time
lastEnrich time.Time
lastAttempt time.Time
nextEnrich time.Time
ollamaOK bool
enrichRunning bool
enrichTrigger string
enrichResult string
enrichError string
enrichCycles uint64
enrichCreated uint64
enrichRejected uint64
enrichRequests chan string
runtimeMu sync.RWMutex
runtime RuntimeSettings
runtimePath string
mu sync.Mutex
stateMu sync.RWMutex
lastScan time.Time
lastEnrich time.Time
lastAttempt time.Time
nextEnrich time.Time
ollamaOK bool
enrichRunning bool
enrichTrigger string
enrichResult string
enrichError string
enrichCycles uint64
enrichCreated uint64
enrichRejected uint64
relationsCreated uint64
articlesCreated uint64
articlesSkipped uint64
enrichRequests chan string
runtimeMu sync.RWMutex
runtime RuntimeSettings
runtimePath string
}
func New(cfg config.Config, g *graph.Store, b *activity.Broker) *Engine {
@@ -84,6 +87,27 @@ func New(cfg config.Config, g *graph.Store, b *activity.Broker) *Engine {
if cfg.EnrichAnchors < 1 {
cfg.EnrichAnchors = 48
}
if cfg.ArticleMinSources < 2 {
cfg.ArticleMinSources = 3
}
if cfg.ArticleMaxSources < cfg.ArticleMinSources {
cfg.ArticleMaxSources = 8
}
if cfg.ArticleMinProductionRatio == 0 {
cfg.ArticleMinProductionRatio = .70
}
if cfg.ArticleMaxGenerationDepth < 1 {
cfg.ArticleMaxGenerationDepth = 2
}
if cfg.ArticleMinConfidence == 0 {
cfg.ArticleMinConfidence = .74
}
if cfg.ArticleMinTextChars < 1 {
cfg.ArticleMinTextChars = 180
}
if cfg.ArticleMinAnswerChars < 1 {
cfg.ArticleMinAnswerChars = 420
}
ollamaURLs := append([]string(nil), cfg.OllamaURLs...)
if len(ollamaURLs) == 0 && strings.TrimSpace(cfg.OllamaURL) != "" {
ollamaURLs = []string{cfg.OllamaURL}
@@ -235,6 +259,7 @@ func (e *Engine) runEnrichmentCycle(ctx context.Context, trigger string) {
e.Broker.Publish(model.Activity{Type: "think.cycle.started", Source: "brain", Phase: "autonomous", Message: fmt.Sprintf("Autonomer AI-THINK-Zyklus startet · bis zu %d sequenzielle Prüfungen", e.Cfg.EnrichBatchSize), Strength: .72, Metadata: map[string]any{"trigger": trigger, "batch_size": e.Cfg.EnrichBatchSize, "anchors": e.Cfg.EnrichAnchors}})
created, rejected, checked := 0, 0, 0
relationsCreated, articlesCreated, articlesSkipped := 0, 0, 0
result := "completed"
var cycleErr error
for step := 0; step < e.Cfg.EnrichBatchSize; step++ {
@@ -258,6 +283,15 @@ func (e *Engine) runEnrichmentCycle(ctx context.Context, trigger string) {
if outcome.Created {
created++
}
if outcome.RelationCreated {
relationsCreated++
}
if outcome.ArticleCreated {
articlesCreated++
}
if outcome.ArticleSkipped {
articlesSkipped++
}
if outcome.Rejected {
rejected++
}
@@ -282,15 +316,18 @@ func (e *Engine) runEnrichmentCycle(ctx context.Context, trigger string) {
}
e.enrichCreated += uint64(created)
e.enrichRejected += uint64(rejected)
e.relationsCreated += uint64(relationsCreated)
e.articlesCreated += uint64(articlesCreated)
e.articlesSkipped += uint64(articlesSkipped)
e.stateMu.Unlock()
metadata := map[string]any{"trigger": trigger, "checked": checked, "created": created, "rejected": rejected, "duration_ms": time.Since(started).Milliseconds(), "result": result}
metadata := map[string]any{"trigger": trigger, "checked": checked, "created": created, "relations_created": relationsCreated, "articles_created": articlesCreated, "articles_skipped": articlesSkipped, "rejected": rejected, "duration_ms": time.Since(started).Milliseconds(), "result": result}
if cycleErr != nil {
e.Broker.Publish(model.Activity{Type: "think.cycle.failed", Source: "brain", Phase: "autonomous", Message: "AI-THINK-Zyklus wurde mit Fehler beendet", Strength: .45, Metadata: metadata})
slog.Warn("enrichment cycle failed", "trigger", trigger, "error", cycleErr)
return
}
message := fmt.Sprintf("AI-THINK-Zyklus abgeschlossen · %d geprüft · %d erstellt · %d verworfen", checked, created, rejected)
message := fmt.Sprintf("AI-THINK-Zyklus abgeschlossen · %d Relationen · %d Artikel · %d verworfen", relationsCreated, articlesCreated, rejected)
if result == "no_candidate" {
message = "AI-THINK hat im aktuell geprüften Graphbereich keinen Kandidaten oberhalb des Schwellwerts gefunden"
}
@@ -525,8 +562,26 @@ func (e *Engine) EnrichOne(ctx context.Context) error {
if !e.ThinkingEnabled() {
return ErrThinkingDisabled
}
_, err := e.enrichOne(ctx, "direct")
return err
outcome, err := e.enrichOne(ctx, "direct")
if err != nil {
return err
}
e.stateMu.Lock()
if outcome.RelationCreated {
e.relationsCreated++
e.enrichCreated++
}
if outcome.ArticleCreated {
e.articlesCreated++
}
if outcome.ArticleSkipped {
e.articlesSkipped++
}
if outcome.Rejected {
e.enrichRejected++
}
e.stateMu.Unlock()
return nil
}
func (e *Engine) enrichOne(ctx context.Context, trigger string) (EnrichOutcome, error) {
@@ -547,7 +602,7 @@ func (e *Engine) enrichOne(ctx context.Context, trigger string) (EnrichOutcome,
e.setOllamaOK(true)
}
a, b, sim, ok, comparisons := e.Graph.NextPairFiltered(e.Cfg.SimilarityThreshold, e.Cfg.EnrichAnchors, e.thinkingCategories())
a, b, sim, ok, comparisons := e.Graph.NextPairFilteredDepth(e.Cfg.SimilarityThreshold, e.Cfg.EnrichAnchors, e.thinkingCategories(), e.Cfg.ArticleMaxGenerationDepth)
if !ok {
e.stateMu.Lock()
e.lastAttempt = time.Now().UTC()
@@ -563,7 +618,7 @@ func (e *Engine) enrichOne(ctx context.Context, trigger string) (EnrichOutcome,
e.stateMu.Unlock()
e.Broker.Publish(model.Activity{Type: "think.started", Source: "brain", Phase: "association", NodeIDs: []string{a.ID, b.ID}, Message: fmt.Sprintf("Verwandtschaft wird geprüft · %.0f%% semantische Nähe", sim*100), Strength: .88, Metadata: map[string]any{"trigger": trigger, "semantic_similarity": sim, "source_label": a.Label, "target_label": b.Label, "model": e.Cfg.ChatModel, "candidate_comparisons": comparisons}})
system := "Analysiere zwei interne Wissenseinträge. Erfinde keine Fakten. Entscheide, ob eine belastbare Beziehung besteht. Wenn externe Fakten fehlen, setze needs_research=true. Gib ausschließlich JSON nach Schema zurück."
system := "Du führst ausschließlich eine Relationserkennung für einen Wissensgraphen durch. Analysiere zwei interne Wissenseinträge, erfinde keine Fakten und entscheide, ob eine belastbare Beziehung besteht. Schreibe keinen Artikel und keine technische Synthese. Wenn externe Fakten zur Relationsentscheidung fehlen, setze needs_research=true. Gib ausschließlich JSON nach Schema zurück."
var decision model.RelationDecision
if err := e.Ollama.ChatJSON(ctx, system, relationContext(a, b, sim), relationSchema(), &decision); err != nil {
e.Broker.Publish(model.Activity{Type: "think.failed", Source: "brain", Phase: "inference", NodeIDs: []string{a.ID, b.ID}, Message: "Qwen-Beziehungsanalyse ist fehlgeschlagen; es wurde nichts gespeichert", Strength: .35, Metadata: map[string]any{"trigger": trigger, "error": err.Error(), "model": e.Cfg.ChatModel}})
@@ -603,12 +658,17 @@ func (e *Engine) enrichOne(ctx context.Context, trigger string) (EnrichOutcome,
edge.ID = graph.EdgeID(edge.Source, edge.Target, edge.Type, edge.Origin)
outcome := EnrichOutcome{Result: status, Candidate: true, Comparisons: comparisons}
if status == "staging" {
path, err := e.writeAIThink(a, b, decision, researchResults)
if err != nil {
return outcome, err
}
outcome.Created = true
e.Broker.Publish(model.Activity{Type: "think.created", Source: "brain", Phase: "staging", NodeIDs: []string{a.ID, b.ID}, EdgeIDs: []string{edge.ID}, Message: "Neuer AI-THINK-Beitrag wurde erzeugt und für den gebündelten Staging-Schreibvorgang vorgemerkt", Strength: 1, Metadata: map[string]any{"trigger": trigger, "path": path, "relation_type": safeRelation(decision.RelationType), "confidence": decision.Confidence, "semantic_similarity": sim, "research_result_count": len(researchResults), "title": decision.Title, "write_pending": true}})
outcome.RelationCreated = true
e.Broker.Publish(model.Activity{Type: "think.relation.created", Source: "brain", Phase: "relation", NodeIDs: []string{a.ID, b.ID}, EdgeIDs: []string{edge.ID}, Message: "Belastbare Wissensrelation wurde als überprüfbare Graph-Edge übernommen", Strength: .86, Metadata: map[string]any{"trigger": trigger, "relation_type": safeRelation(decision.RelationType), "confidence": decision.Confidence, "semantic_similarity": sim, "research_result_count": len(researchResults), "topic_label": decision.TopicLabel}})
articleOutcome, err := e.synthesizeKnowledgeArticle(ctx, trigger, []model.Node{a, b}, decision, researchResults)
if err != nil {
e.Broker.Publish(model.Activity{Type: "article.failed", Source: "brain", Phase: "knowledge-synthesis", NodeIDs: []string{a.ID, b.ID}, EdgeIDs: []string{edge.ID}, Message: "Die Relation bleibt erhalten, aber die Artikelsynthese ist fehlgeschlagen", Strength: .4, Metadata: map[string]any{"trigger": trigger, "error": err.Error()}})
outcome.ArticleSkipped = true
} else {
outcome.ArticleCreated = articleOutcome.Created
outcome.ArticleSkipped = articleOutcome.Skipped
}
} else {
outcome.Rejected = true
e.Broker.Publish(model.Activity{Type: "think.rejected", Source: "brain", Phase: "validation", NodeIDs: []string{a.ID, b.ID}, Message: "Ähnlichkeit geprüft, aber nicht als belastbare Edge übernommen", Strength: .42, Metadata: map[string]any{"trigger": trigger, "relation_type": safeRelation(decision.RelationType), "confidence": decision.Confidence, "semantic_similarity": sim, "explanation": decision.Explanation}})
@@ -625,37 +685,6 @@ func (e *Engine) addResearch(a, b model.Node, results []model.ResearchResult) {
e.Graph.UpsertEdge(model.Edge{Source: id, Target: b.ID, Type: "research_evidence", Origin: "research", Status: "staging", Confidence: .55, Weight: .4})
}
}
func (e *Engine) writeAIThink(a, b model.Node, d model.RelationDecision, results []model.ResearchResult) (string, error) {
if len(e.Cfg.StagingDirs) == 0 {
return "", fmt.Errorf("no BRAIN_STAGING_DIRS configured")
}
dir := e.Cfg.StagingDirs[0]
pair := a.ID + "\x00" + b.ID
sum := sha256.Sum256([]byte(pair))
short := strings.ToUpper(hex.EncodeToString(sum[:5]))
now := time.Now().UTC()
id := fmt.Sprintf("KB-AI-THINK-%s-%s", now.Format("20060102"), short)
cats := unique(append([]string{"AI-THINK", "AI-Staging"}, common(a.Categories, b.Categories)...))
keywords := unique(append(append([]string{}, d.Keywords...), first(a.Keywords, 4)...))
keywords = unique(append(keywords, first(b.Keywords, 4)...))
var evidence []map[string]any
for _, r := range results {
evidence = append(evidence, map[string]any{"title": r.Title, "url": r.URL, "excerpt": clamp(r.Content, 300)})
}
doc := map[string]any{"id": id, "title": nonempty(d.Title, "Zusammenhang: "+a.Label+" ↔ "+b.Label), "text": nonempty(d.Synthesis, d.Explanation), "answer": "Interne AI-THINK-Arbeitsnotiz. Vor produktiver Nutzung im Editor prüfen, korrigieren und freigeben.", "auto_reply": false, "min_score": 0.78, "categories": cats, "keywords": keywords, "source": "Neural Brain / " + e.Cfg.ChatModel + " (AI-THINK)", "source_uri": "brain://edge/" + short, "language": "de-DE", "communication_style": "formal", "ai_think": map[string]any{"status": "staging", "generated_at": now, "source_nodes": []string{a.ExternalID, b.ExternalID}, "source_node_ids": []string{a.ID, b.ID}, "relation_type": safeRelation(d.RelationType), "confidence": d.Confidence, "explanation": d.Explanation, "needs_research": d.NeedsResearch, "research_query": d.ResearchQuery, "research_evidence": evidence}}
bts, err := json.MarshalIndent(doc, "", " ")
if err != nil {
return "", err
}
path := filepath.Join(dir, strings.ToLower(id)+".json")
if e.Persistence.Pending(path) {
return path, nil
}
if _, err := os.Stat(path); err == nil {
return path, nil
}
return e.Persistence.QueueFile(path, append(bts, '\n'), 0o640)
}
func (e *Engine) Status() map[string]any {
s := e.Graph.Snapshot()
e.stateMu.RLock()
@@ -665,7 +694,11 @@ func (e *Engine) Status() map[string]any {
"next_enrich": e.nextEnrich, "ollama_ok": e.ollamaOK, "auto_enrich": e.Cfg.AutoEnrich,
"enrich_running": e.enrichRunning, "enrich_trigger": e.enrichTrigger, "enrich_result": e.enrichResult,
"enrich_error": e.enrichError, "enrich_cycles": e.enrichCycles, "enrich_created": e.enrichCreated,
"enrich_rejected": e.enrichRejected, "enrich_interval": e.Cfg.EnrichInterval.String(),
"enrich_rejected": e.enrichRejected, "relations_created": e.relationsCreated, "articles_created": e.articlesCreated,
"articles_skipped": e.articlesSkipped, "article_synthesis_enabled": e.Cfg.ArticleSynthesisEnabled,
"article_min_sources": e.Cfg.ArticleMinSources, "article_max_sources": e.Cfg.ArticleMaxSources,
"article_min_production_ratio": e.Cfg.ArticleMinProductionRatio, "article_max_generation_depth": e.Cfg.ArticleMaxGenerationDepth,
"enrich_interval": e.Cfg.EnrichInterval.String(),
"enrich_batch_size": e.Cfg.EnrichBatchSize, "enrich_anchors": e.Cfg.EnrichAnchors,
"research_enabled": e.Cfg.ResearchEnabled, "chat_model": e.Cfg.ChatModel, "embedding_model": e.Cfg.EmbeddingModel,
"ollama_pool": e.Ollama.PoolStatus(), "persistence": e.Persistence.Status(),
@@ -708,7 +741,7 @@ func relationContext(a, b model.Node, sim float64) string {
return fmt.Sprintf("SEMANTISCHE_NÄHE: %.4f\n\nA\nID: %s\nTitel: %s\nKategorien: %s\nInhalt: %s\n\nB\nID: %s\nTitel: %s\nKategorien: %s\nInhalt: %s", sim, a.ID, a.Label, strings.Join(a.Categories, ", "), a.Summary, b.ID, b.Label, strings.Join(b.Categories, ", "), b.Summary)
}
func relationSchema() map[string]any {
return map[string]any{"type": "object", "properties": map[string]any{"related": map[string]any{"type": "boolean"}, "relation_type": map[string]any{"type": "string", "enum": []string{"related_to", "depends_on", "supports", "contradicts", "extends", "same_topic", "caused_by"}}, "confidence": map[string]any{"type": "number", "minimum": 0, "maximum": 1}, "explanation": map[string]any{"type": "string"}, "needs_research": map[string]any{"type": "boolean"}, "research_query": map[string]any{"type": "string"}, "title": map[string]any{"type": "string"}, "synthesis": map[string]any{"type": "string"}, "keywords": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}}, "required": []string{"related", "relation_type", "confidence", "explanation", "needs_research", "research_query", "title", "synthesis", "keywords"}}
return map[string]any{"type": "object", "properties": map[string]any{"related": map[string]any{"type": "boolean"}, "relation_type": map[string]any{"type": "string", "enum": []string{"related_to", "depends_on", "supports", "contradicts", "extends", "same_topic", "caused_by"}}, "confidence": map[string]any{"type": "number", "minimum": 0, "maximum": 1}, "explanation": map[string]any{"type": "string"}, "needs_research": map[string]any{"type": "boolean"}, "research_query": map[string]any{"type": "string"}, "topic_label": map[string]any{"type": "string"}, "keywords": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}}, "required": []string{"related", "relation_type", "confidence", "explanation", "needs_research", "research_query", "topic_label", "keywords"}}
}
func answerSchema() map[string]any {
return map[string]any{"type": "object", "properties": map[string]any{"answer": map[string]any{"type": "string"}, "used_node_ids": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, "uncertainties": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}}, "required": []string{"answer", "used_node_ids", "uncertainties"}}
+171 -17
View File
@@ -7,15 +7,18 @@ import (
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/local/glpi-neural-brain/internal/activity"
"github.com/local/glpi-neural-brain/internal/config"
"github.com/local/glpi-neural-brain/internal/graph"
"github.com/local/glpi-neural-brain/internal/model"
)
func TestEnrichWritesAIThinkOnlyAfterStructuredQwenDecision(t *testing.T) {
func TestEnrichCreatesStructuredKnowledgeArticleFromThreeProductionSources(t *testing.T) {
var chatCalls int
mock := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
@@ -28,11 +31,20 @@ func TestEnrichWritesAIThinkOnlyAfterStructuredQwenDecision(t *testing.T) {
_ = json.NewDecoder(r.Body).Decode(&req)
vectors := make([][]float64, len(req.Input))
for i := range vectors {
vectors[i] = []float64{1, float64(i) * .05, 0}
vectors[i] = []float64{1, float64(i) * .03, 0}
}
_ = json.NewEncoder(w).Encode(map[string]any{"embeddings": vectors})
case "/api/chat":
content := `{"related":true,"relation_type":"supports","confidence":0.91,"explanation":"Beide Einträge behandeln denselben VPN-Störungsablauf.","needs_research":false,"research_query":"","title":"VPN-Gateway und Remotezugriff","synthesis":"Der Gateway-Fehler ist ein konkreter Teilbereich des Remotezugriffs.","keywords":["VPN","Gateway"]}`
chatCalls++
var content string
switch chatCalls {
case 1:
content = `{"related":true,"relation_type":"supports","confidence":0.91,"explanation":"Die drei produktiven Quellen beschreiben zusammen Diagnose, Ursache und Behebung derselben VPN-Störung.","needs_research":false,"research_query":"","topic_label":"VPN-Gateway-Störung","keywords":["VPN","Gateway"]}`
case 2:
content = `{"action":"create","target_article_id":"","reason":"Die Quellen ergänzen sich zu einer vollständigen Anleitung, die noch nicht als einzelner Artikel existiert.","expected_value":"Ein durchgängiger Diagnose- und Lösungsablauf für den Support.","article_type":"troubleshooting","source_node_ids":[],"missing_information":[],"contradictions":[],"needs_research":false,"research_query":""}`
default:
content = `{"title":"VPN-Gateway-Störung systematisch beheben","text":"Dieser Artikel gilt für Remotezugriffe, bei denen der VPN-Client keine Verbindung zum Gateway herstellen kann. Typische Symptome sind Zeitüberschreitungen, ein nicht erreichbares Gateway oder ein Verbindungsabbruch unmittelbar nach dem Start. Die Anleitung grenzt lokale Clientfehler von einer Störung des zentralen Gateways ab.","answer":"1. Prüfen Sie zuerst, ob der betroffene Rechner eine funktionierende Internetverbindung besitzt. 2. Kontrollieren Sie, ob der konfigurierte Gateway-Name erreichbar ist. 3. Vergleichen Sie die Fehlermeldung mit dem dokumentierten Gateway-Ausfallbild. 4. Starten Sie den VPN-Client neu und führen Sie den Verbindungsversuch erneut aus. 5. Bleibt das Gateway nicht erreichbar, eskalieren Sie den Fall mit Zeitstempel und Fehlermeldung an das Netzwerkteam.","prerequisites":["Zugriff auf die VPN-Client-Konfiguration","Aktuelle Fehlermeldung des Benutzers"],"validation":["Der VPN-Tunnel wird aufgebaut","Die interne Zielressource ist erreichbar"],"troubleshooting":["Bei weiterhin nicht erreichbarem Gateway den dokumentierten Eskalationsweg verwenden"],"categories":["Netzwerk","VPN"],"keywords":["VPN","Gateway","Remotezugriff"],"source_node_ids":[],"confidence":0.92,"open_questions":[]}`
}
_ = json.NewEncoder(w).Encode(map[string]any{"message": map[string]any{"content": content}})
default:
http.NotFound(w, r)
@@ -54,15 +66,16 @@ func TestEnrichWritesAIThinkOnlyAfterStructuredQwenDecision(t *testing.T) {
t.Fatal(err)
}
}
write("vpn.json", `{"id":"KB-VPN","title":"VPN Gateway","text":"Gateway nicht erreichbar","categories":["Netzwerk"],"keywords":["VPN","Gateway"],"source":"internal-kb"}`)
write("remote.json", `{"id":"KB-REMOTE","title":"Remotezugriff und VPN","text":"Remotezugriff über VPN und Gateway","categories":["Netzwerk"],"keywords":["VPN","Remotezugriff"],"source":"internal-kb"}`)
write("vpn.json", `{"id":"KB-VPN","title":"VPN Gateway nicht erreichbar","text":"Der Client meldet, dass das Gateway nicht erreichbar ist.","answer":"Internetverbindung und Gateway-Adresse prüfen.","categories":["Netzwerk","VPN"],"keywords":["VPN","Gateway"],"source":"internal-kb"}`)
write("remote.json", `{"id":"KB-REMOTE","title":"Remotezugriff über VPN diagnostizieren","text":"Remotezugriff schlägt mit einem Timeout fehl.","answer":"Fehlermeldung erfassen, Client neu starten und Tunnel prüfen.","categories":["Netzwerk","VPN"],"keywords":["VPN","Remotezugriff"],"source":"internal-kb"}`)
write("escalation.json", `{"id":"KB-VPN-ESC","title":"VPN-Störungen eskalieren","text":"Anhaltende Gateway-Ausfälle benötigen eine strukturierte Eskalation.","answer":"Zeitstempel, Fehlermeldung und betroffene Benutzer an das Netzwerkteam übergeben.","categories":["Netzwerk","VPN"],"keywords":["VPN","Eskalation"],"source":"internal-kb"}`)
g, err := graph.Open(data)
if err != nil {
t.Fatal(err)
}
cfg := config.Config{DataDir: data, KnowledgeDirs: []string{knowledge}, StagingDirs: []string{staging}, OllamaURL: mock.URL, ChatModel: "qwen3:8b", EmbeddingModel: "embeddinggemma", ScanInterval: time.Minute, EnrichInterval: time.Minute, SimilarityThreshold: .5, RelationThreshold: .7, TopK: 5, MaxContextChars: 8000}
e := New(cfg, g, activity.New(20))
cfg := config.Config{DataDir: data, KnowledgeDirs: []string{knowledge}, StagingDirs: []string{staging}, OllamaURL: mock.URL, ChatModel: "qwen3:8b", EmbeddingModel: "embeddinggemma", ScanInterval: time.Minute, EnrichInterval: time.Minute, SimilarityThreshold: .5, RelationThreshold: .7, ArticleSynthesisEnabled: true, ArticleMinSources: 3, ArticleMaxSources: 6, ArticleMinProductionRatio: .7, ArticleMaxGenerationDepth: 2, ArticleMinConfidence: .7, ArticleMinTextChars: 80, ArticleMinAnswerChars: 180, TopK: 5, MaxContextChars: 16000}
e := New(cfg, g, activity.New(50))
if err := e.Scan(context.Background()); err != nil {
t.Fatal(err)
}
@@ -75,9 +88,12 @@ func TestEnrichWritesAIThinkOnlyAfterStructuredQwenDecision(t *testing.T) {
if err := e.Flush(context.Background()); err != nil {
t.Fatal(err)
}
if chatCalls != 3 {
t.Fatalf("expected relation, plan and draft calls, got %d", chatCalls)
}
files, err := filepath.Glob(filepath.Join(staging, "*.json"))
if err != nil || len(files) != 1 {
t.Fatalf("expected one AI-THINK file, files=%v err=%v", files, err)
t.Fatalf("expected one structured AI-THINK article, files=%v err=%v", files, err)
}
var doc map[string]any
b, _ := os.ReadFile(files[0])
@@ -87,15 +103,68 @@ func TestEnrichWritesAIThinkOnlyAfterStructuredQwenDecision(t *testing.T) {
if doc["auto_reply"] != false {
t.Fatalf("AI-THINK must be auto_reply=false: %#v", doc["auto_reply"])
}
cats, _ := doc["categories"].([]any)
found := false
for _, c := range cats {
if c == "AI-THINK" {
found = true
if got := doc["answer"].(string); len(got) < 180 || got == "Interne AI-THINK-Arbeitsnotiz. Vor produktiver Nutzung im Editor prüfen, korrigieren und freigeben." {
t.Fatalf("expected a real solution article, got %q", got)
}
ai, ok := doc["ai_think"].(map[string]any)
if !ok || ai["subtype"] != "knowledge_synthesis" || ai["action"] != "create" {
t.Fatalf("unexpected AI-THINK metadata: %#v", doc["ai_think"])
}
if ai["productive_source_count"].(float64) < 3 {
t.Fatalf("expected at least three productive sources: %#v", ai)
}
}
func TestEnrichRelationWithOnlyTwoSourcesDoesNotCreateArticle(t *testing.T) {
var chatCalls int
mock := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/tags":
_, _ = w.Write([]byte(`{"models":[{"name":"qwen3:8b","digest":"chat-digest"},{"name":"embeddinggemma:latest","digest":"embed-digest"}]}`))
case "/api/embed":
var req struct {
Input []string `json:"input"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
vectors := make([][]float64, len(req.Input))
for i := range vectors {
vectors[i] = []float64{1, float64(i) * .02}
}
_ = json.NewEncoder(w).Encode(map[string]any{"embeddings": vectors})
case "/api/chat":
chatCalls++
content := `{"related":true,"relation_type":"same_topic","confidence":0.9,"explanation":"Beide Quellen behandeln dasselbe Gateway-Thema.","needs_research":false,"research_query":"","topic_label":"VPN","keywords":["VPN"]}`
_ = json.NewEncoder(w).Encode(map[string]any{"message": map[string]any{"content": content}})
}
}))
defer mock.Close()
root := t.TempDir()
knowledge, staging, data := filepath.Join(root, "knowledge"), filepath.Join(root, "staging"), filepath.Join(root, "data")
for _, dir := range []string{knowledge, staging, data} {
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
}
if !found {
t.Fatalf("AI-THINK category missing: %#v", cats)
_ = os.WriteFile(filepath.Join(knowledge, "a.json"), []byte(`{"id":"A","title":"VPN A","text":"Gateway","categories":["VPN"]}`), 0o644)
_ = os.WriteFile(filepath.Join(knowledge, "b.json"), []byte(`{"id":"B","title":"VPN B","text":"Gateway","categories":["VPN"]}`), 0o644)
g, _ := graph.Open(data)
e := New(config.Config{DataDir: data, KnowledgeDirs: []string{knowledge}, StagingDirs: []string{staging}, OllamaURL: mock.URL, ChatModel: "qwen3:8b", EmbeddingModel: "embeddinggemma", SimilarityThreshold: .5, RelationThreshold: .7, ArticleSynthesisEnabled: true, ArticleMinSources: 3, ArticleMaxSources: 8, ArticleMinProductionRatio: .7, ArticleMaxGenerationDepth: 2, ArticleMinConfidence: .7, ArticleMinTextChars: 80, ArticleMinAnswerChars: 180, MaxContextChars: 8000}, g, activity.New(20))
if err := e.Scan(context.Background()); err != nil {
t.Fatal(err)
}
if err := e.EnrichOne(context.Background()); err != nil {
t.Fatal(err)
}
if chatCalls != 1 {
t.Fatalf("article planning must not run with only two productive sources; calls=%d", chatCalls)
}
files, _ := filepath.Glob(filepath.Join(staging, "*.json"))
if len(files) != 0 {
t.Fatalf("unexpected article draft: %v", files)
}
if len(g.Snapshot().Edges) == 0 {
t.Fatal("relation edge should still be created")
}
}
@@ -132,11 +201,12 @@ func TestRuntimeSettingsDisableThinkingAndPersist(t *testing.T) {
DisplayCategories: []string{"GLPI KB"},
ThinkingCategories: []string{"Netzwerk"},
ViewMode: "honeycomb",
MaxDisplayNodes: 4321,
})
if err != nil {
t.Fatal(err)
}
if updated.LearningEnabled || updated.ThinkingEnabled || updated.ViewMode != "honeycomb" {
if updated.LearningEnabled || updated.ThinkingEnabled || updated.ViewMode != "honeycomb" || updated.MaxDisplayNodes != 4321 {
t.Fatalf("unexpected runtime settings: %+v", updated)
}
if e.RequestEnrich("manual") {
@@ -155,7 +225,91 @@ func TestRuntimeSettingsDisableThinkingAndPersist(t *testing.T) {
}
e2 := New(cfg, g2, activity.New(20))
loaded := e2.RuntimeSettings()
if loaded.LearningEnabled || loaded.ThinkingEnabled || loaded.ViewMode != "honeycomb" || len(loaded.DisplayCategories) != 1 {
if loaded.LearningEnabled || loaded.ThinkingEnabled || loaded.ViewMode != "honeycomb" || loaded.MaxDisplayNodes != 4321 || len(loaded.DisplayCategories) != 1 {
t.Fatalf("runtime settings were not restored: %+v", loaded)
}
}
func TestWriteKnowledgeArticleDraftPreservesUpdateTarget(t *testing.T) {
root := t.TempDir()
staging := filepath.Join(root, "staging")
data := filepath.Join(root, "data")
if err := os.MkdirAll(staging, 0o755); err != nil {
t.Fatal(err)
}
g, err := graph.Open(data)
if err != nil {
t.Fatal(err)
}
e := New(config.Config{DataDir: data, StagingDirs: []string{staging}, ChatModel: "qwen3:8b", ArticleMinSources: 3, ArticleMaxSources: 8, ArticleMinProductionRatio: .7, ArticleMaxGenerationDepth: 2, ArticleMinConfidence: .7, ArticleMinTextChars: 50, ArticleMinAnswerChars: 100}, g, activity.New(20))
target := model.Node{ID: graph.ID("knowledge", "KB-TARGET"), Kind: "knowledge", Status: "production", ExternalID: "KB-TARGET", Label: "Bestehender VPN-Artikel", Categories: []string{"VPN"}}
sources := []articleSource{
{Node: target, Content: "Bestehender Artikel", Depth: 0},
{Node: model.Node{ID: graph.ID("knowledge", "KB-SOURCE-2"), Kind: "knowledge", Status: "production", ExternalID: "KB-SOURCE-2", Label: "Diagnose", Categories: []string{"VPN"}}, Content: "Diagnosequelle"},
{Node: model.Node{ID: graph.ID("knowledge", "KB-SOURCE-3"), Kind: "knowledge", Status: "production", ExternalID: "KB-SOURCE-3", Label: "Prüfung", Categories: []string{"VPN"}}, Content: "Prüfquelle"},
}
plan := model.ArticlePlanDecision{Action: "update", TargetArticleID: target.ID, Reason: "Der bestehende Artikel benötigt Diagnose und Validierung.", ExpectedValue: "Vollständiger Ablauf", ArticleType: "troubleshooting", SourceNodeIDs: nodeIDsFromArticleSources(sources)}
draft := model.KnowledgeArticleDraft{Title: "VPN-Artikel vollständig diagnostizieren", Text: strings.Repeat("Problem und Geltungsbereich. ", 5), Answer: strings.Repeat("1. Konkreten Prüfschritt ausführen. ", 8), Validation: []string{"VPN-Tunnel ist aktiv"}, Categories: []string{"VPN"}, Keywords: []string{"VPN"}, SourceNodeIDs: nodeIDsFromArticleSources(sources), Confidence: .9}
path, _, created, err := e.writeKnowledgeArticleDraft(sources, plan, draft, nil, 3, 0, 1, 1)
if err != nil {
t.Fatal(err)
}
if !created {
t.Fatal("expected update draft to be queued")
}
if err := e.Flush(context.Background()); err != nil {
t.Fatal(err)
}
dataBytes, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
var doc map[string]any
if err := json.Unmarshal(dataBytes, &doc); err != nil {
t.Fatal(err)
}
ai := doc["ai_think"].(map[string]any)
if ai["action"] != "update" || ai["target_article_id"] != "KB-TARGET" || ai["target_node_id"] != target.ID {
t.Fatalf("update target metadata was not preserved: %#v", ai)
}
if !strings.Contains(doc["answer"].(string), "## Ergebnis prüfen") {
t.Fatalf("validation section missing from answer: %q", doc["answer"])
}
}
func TestSourceContentReloadsFullLocalKnowledgeDocument(t *testing.T) {
root := t.TempDir()
knowledge := filepath.Join(root, "knowledge")
data := filepath.Join(root, "data")
if err := os.MkdirAll(knowledge, 0o755); err != nil {
t.Fatal(err)
}
longProblem := strings.Repeat("Ausführliche Problembeschreibung. ", 80)
longAnswer := strings.Repeat("Konkreter Lösungsschritt. ", 80)
body, _ := json.Marshal(map[string]any{"id": "KB-LONG", "title": "Langer Artikel", "text": longProblem, "answer": longAnswer, "categories": []string{"Test"}})
if err := os.WriteFile(filepath.Join(knowledge, "long.json"), body, 0o644); err != nil {
t.Fatal(err)
}
g, err := graph.Open(data)
if err != nil {
t.Fatal(err)
}
e := New(config.Config{DataDir: data, KnowledgeDirs: []string{knowledge}, ArticleMinSources: 3, ArticleMaxSources: 8, ArticleMinProductionRatio: .7, ArticleMaxGenerationDepth: 2}, g, activity.New(20))
if _, err := e.Scanner.Scan(); err != nil {
t.Fatal(err)
}
var node model.Node
for _, candidate := range g.Snapshot().Nodes {
if candidate.ExternalID == "KB-LONG" {
node = candidate
break
}
}
if node.ID == "" {
t.Fatal("knowledge node not found")
}
full := e.sourceContent(node)
if !strings.Contains(full, "PROBLEM / BESCHREIBUNG:") || !strings.Contains(full, "LÖSUNG / ANTWORT:") || len(full) <= len(node.Summary) {
t.Fatalf("expected full source content, summary=%d full=%d", len(node.Summary), len(full))
}
}
+13
View File
@@ -2,6 +2,7 @@ package engine
import (
"encoding/json"
"fmt"
"os"
"sort"
"strings"
@@ -18,6 +19,7 @@ type RuntimeSettings struct {
DisplayCategories []string `json:"display_categories"`
ThinkingCategories []string `json:"thinking_categories"`
ViewMode string `json:"view_mode"`
MaxDisplayNodes int `json:"max_display_nodes"`
}
type CategoryInfo struct {
@@ -33,6 +35,7 @@ func (e *Engine) defaultRuntimeSettings() RuntimeSettings {
DisplayCategories: append([]string(nil), e.Cfg.DisplayCategories...),
ThinkingCategories: append([]string(nil), e.Cfg.ThinkingCategories...),
ViewMode: e.Cfg.DefaultView,
MaxDisplayNodes: e.Cfg.MaxDisplayNodes,
})
}
@@ -47,6 +50,12 @@ func normalizeRuntimeSettings(in RuntimeSettings) RuntimeSettings {
if in.ViewMode != "neural" && in.ViewMode != "honeycomb" {
in.ViewMode = "neural"
}
if in.MaxDisplayNodes < 0 {
in.MaxDisplayNodes = 0
}
if in.MaxDisplayNodes > 500000 {
in.MaxDisplayNodes = 500000
}
return in
}
@@ -96,6 +105,9 @@ func (e *Engine) RuntimeSettings() RuntimeSettings {
}
func (e *Engine) SetRuntimeSettings(settings RuntimeSettings) (RuntimeSettings, error) {
if settings.MaxDisplayNodes < 0 || settings.MaxDisplayNodes > 500000 {
return e.RuntimeSettings(), fmt.Errorf("max_display_nodes must be between 0 and 500000")
}
settings = normalizeRuntimeSettings(settings)
e.runtimeMu.Lock()
previous := e.runtime
@@ -136,6 +148,7 @@ func (e *Engine) SetRuntimeSettings(settings RuntimeSettings) (RuntimeSettings,
"display_categories": len(settings.DisplayCategories),
"thinking_categories": len(settings.ThinkingCategories),
"view_mode": settings.ViewMode,
"max_display_nodes": settings.MaxDisplayNodes,
},
})
return settings, nil