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