+533
-87
@@ -40,12 +40,7 @@ func (e *Engine) synthesizeKnowledgeArticle(ctx context.Context, trigger string,
|
||||
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},
|
||||
})
|
||||
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 {
|
||||
@@ -56,11 +51,7 @@ func (e *Engine) synthesizeKnowledgeArticle(ctx context.Context, trigger string,
|
||||
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},
|
||||
})
|
||||
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 {
|
||||
@@ -83,11 +74,9 @@ func (e *Engine) synthesizeKnowledgeArticle(ctx context.Context, trigger string,
|
||||
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
|
||||
}
|
||||
|
||||
@@ -95,15 +84,21 @@ func (e *Engine) synthesizeKnowledgeArticle(ctx context.Context, trigger string,
|
||||
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}})
|
||||
|
||||
e.Broker.Publish(model.Activity{Type: "article.consolidation.started", Source: "brain", Phase: "knowledge-consolidation", NodeIDs: plan.SourceNodeIDs, Message: "Verwandtes Wissen wird zu einer belegten fachlichen Wissensbasis zusammengeführt", Strength: .88, Metadata: map[string]any{"trigger": trigger, "source_count": len(selected)}})
|
||||
brief, err := e.buildKnowledgeBrief(ctx, selected, researchResults)
|
||||
if err != nil {
|
||||
return articleSynthesisOutcome{}, fmt.Errorf("knowledge consolidation failed: %w", err)
|
||||
}
|
||||
|
||||
queries := collectArticleResearchQueries(plan, brief, e.Cfg.ArticleMaxResearchQueries)
|
||||
if len(queries) > 0 {
|
||||
if !e.Cfg.ResearchEnabled || e.Research == nil {
|
||||
e.Broker.Publish(model.Activity{Type: "article.plan.skipped", Source: "brain", Phase: "knowledge-research", NodeIDs: plan.SourceNodeIDs, Message: "Offene fachliche Punkte benötigen Recherche, aber die Recherche ist nicht verfügbar", Strength: .34, Metadata: map[string]any{"trigger": trigger, "reason": "required_research_unavailable", "research_queries": queries, "missing_information": brief.MissingInformation}})
|
||||
return articleSynthesisOutcome{Skipped: true, Reason: "required_research_unavailable", Action: plan.Action}, nil
|
||||
}
|
||||
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()}})
|
||||
results, researchErr := e.researchKnowledgeGaps(ctx, trigger, plan.SourceNodeIDs, queries)
|
||||
if researchErr != nil {
|
||||
return articleSynthesisOutcome{Skipped: true, Reason: "required_research_failed", Action: plan.Action}, nil
|
||||
}
|
||||
if len(results) == 0 {
|
||||
@@ -111,37 +106,55 @@ func (e *Engine) synthesizeKnowledgeArticle(ctx context.Context, trigger string,
|
||||
}
|
||||
researchResults = append(researchResults, results...)
|
||||
e.addResearchToSources(selected, results)
|
||||
brief, err = e.buildKnowledgeBrief(ctx, selected, researchResults)
|
||||
if err != nil {
|
||||
return articleSynthesisOutcome{}, fmt.Errorf("knowledge consolidation after research failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
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}})
|
||||
if !brief.ReadyForArticle || len(collectBriefResearchQueries(brief, 1)) > 0 {
|
||||
e.Broker.Publish(model.Activity{Type: "article.plan.skipped", Source: "brain", Phase: "knowledge-consolidation", NodeIDs: plan.SourceNodeIDs, Message: "Die Wissensbasis enthält weiterhin ungelöste fachliche Lücken; es wird kein Bewertungs- oder Platzhalterartikel geschrieben", Strength: .38, Metadata: map[string]any{"trigger": trigger, "missing_information": brief.MissingInformation, "contradictions": brief.Contradictions, "ready_for_article": brief.ReadyForArticle}})
|
||||
return articleSynthesisOutcome{Skipped: true, Reason: "knowledge_not_ready", Action: plan.Action}, nil
|
||||
}
|
||||
|
||||
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)
|
||||
e.Broker.Publish(model.Activity{Type: "article.draft.started", Source: "brain", Phase: "knowledge-synthesis", NodeIDs: plan.SourceNodeIDs, Message: "Qwen verfasst aus der konsolidierten Wissensbasis einen vollständigen KB-Artikel", Strength: .95, Metadata: map[string]any{"trigger": trigger, "action": plan.Action, "target_article_id": plan.TargetArticleID, "source_count": len(selected), "research_result_count": len(researchResults), "generation_depth": generationDepth}})
|
||||
|
||||
content, rewritten, err := e.generateArticleContent(ctx, selected, plan, brief, researchResults)
|
||||
if err != nil {
|
||||
return articleSynthesisOutcome{}, err
|
||||
}
|
||||
draft.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)
|
||||
draft := articleContentToDraft(content, plan.SourceNodeIDs)
|
||||
productionCount, aiCount, productionRatio, maxDepth = articleSourceStats(selected)
|
||||
generationDepth = maxDepth + 1
|
||||
|
||||
quality, err := e.reviewArticleContent(ctx, draft, selected, researchResults)
|
||||
if err != nil {
|
||||
return articleSynthesisOutcome{}, fmt.Errorf("article quality review failed: %w", err)
|
||||
}
|
||||
draft.Confidence = quality.Confidence
|
||||
if !quality.Accepted || quality.MetaContentDetected || len(quality.UnsupportedClaims) > 0 {
|
||||
reason := strings.Join(unique(append(append([]string(nil), quality.Issues...), quality.UnsupportedClaims...)), "; ")
|
||||
if reason == "" {
|
||||
reason = "content quality review rejected the generated article"
|
||||
}
|
||||
e.Broker.Publish(model.Activity{Type: "article.draft.rejected", Source: "brain", Phase: "quality-gate", NodeIDs: draft.SourceNodeIDs, Message: "Der erzeugte Inhalt wurde als Bewertung, Meta-Text oder unbelegt erkannt", Strength: .42, Metadata: map[string]any{"trigger": trigger, "action": plan.Action, "error": reason, "confidence": quality.Confidence, "meta_content_detected": quality.MetaContentDetected, "unsupported_claims": quality.UnsupportedClaims, "rewritten": rewritten}})
|
||||
return articleSynthesisOutcome{Skipped: true, Reason: "quality_gate: " + reason, Action: plan.Action, Title: draft.Title}, nil
|
||||
}
|
||||
if err := e.validateArticleDraft(draft, selected, productionRatio, generationDepth); err != nil {
|
||||
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)
|
||||
path, articleID, created, err := e.writeKnowledgeArticleDraft(selected, plan, brief, 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}})
|
||||
e.addRuntimeArticleNode(articleID, selected, plan, draft, researchResults, productionCount, aiCount, productionRatio, generationDepth)
|
||||
e.learnRuntimeArticle(ctx, articleID)
|
||||
e.Broker.Publish(model.Activity{Type: "article.created", Source: "brain", Phase: "staging", NodeIDs: append([]string{graph.ID("knowledge", articleID)}, draft.SourceNodeIDs...), Message: fmt.Sprintf("Konsolidierter KB-Artikel wurde erstellt, gelernt und mit seinen Quellen verknüpft · %s", draft.Title), Strength: 1, Metadata: map[string]any{"trigger": trigger, "action": plan.Action, "target_article_id": plan.TargetArticleID, "path": path, "title": draft.Title, "confidence": draft.Confidence, "productive_sources": productionCount, "ai_sources": aiCount, "production_ratio": productionRatio, "generation_depth": generationDepth, "research_result_count": len(researchResults), "write_pending": true}})
|
||||
return articleSynthesisOutcome{Created: true, Path: path, Action: plan.Action, Title: draft.Title}, nil
|
||||
}
|
||||
|
||||
@@ -302,20 +315,197 @@ func (e *Engine) articlePlanContext(sources []articleSource, relation model.Rela
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (e *Engine) articleDraftContext(sources []articleSource, plan model.ArticlePlanDecision, researchResults []model.ResearchResult) string {
|
||||
func (e *Engine) buildKnowledgeBrief(ctx context.Context, sources []articleSource, researchResults []model.ResearchResult) (model.KnowledgeBrief, error) {
|
||||
var brief model.KnowledgeBrief
|
||||
if err := e.Ollama.ChatJSON(ctx, knowledgeBriefSystemPrompt(), e.knowledgeBriefContext(sources, researchResults), knowledgeBriefSchema(), &brief); err != nil {
|
||||
return model.KnowledgeBrief{}, err
|
||||
}
|
||||
return normalizeKnowledgeBrief(brief), nil
|
||||
}
|
||||
|
||||
func (e *Engine) knowledgeBriefContext(sources []articleSource, researchResults []model.ResearchResult) string {
|
||||
var b strings.Builder
|
||||
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")
|
||||
b.WriteString("AUFGABE: Führe das fachliche Wissen der folgenden Beiträge zusammen. Extrahiere nur Aussagen, die durch mindestens eine angegebene Referenz belegt sind. Widersprüche und fehlende Informationen bleiben getrennt vom später sichtbaren Artikel.\n\nQUELLEN:\n")
|
||||
appendArticleSources(&b, sources, e.Cfg.MaxContextChars)
|
||||
if len(researchResults) > 0 {
|
||||
b.WriteString("\nRECHERCHEERGEBNISSE (nur als zusätzliche Evidenz, nicht als garantierte Wahrheit):\n")
|
||||
b.WriteString("\nRECHERCHEBELEGE:\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))
|
||||
fmt.Fprintf(&b, "\nREF: R%d\nTITEL: %s\nURL: %s\nINHALT:\n%s\n", i+1, result.Title, result.URL, clamp(result.Content, 1800))
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func knowledgeBriefSystemPrompt() string {
|
||||
return `Du konsolidierst deutschsprachiges Helpdesk-Wissen zu einer fachlichen Wissensbasis. Diese Ausgabe ist intern und wird niemals als Artikel gespeichert.
|
||||
|
||||
Regeln:
|
||||
- Führe inhaltlich gleiche Aussagen zusammen.
|
||||
- Jede fachliche Aussage erhält source_refs mit SOURCE_NODE_ID oder Recherche-Referenzen R1, R2 usw.
|
||||
- Trenne Problem, Geltungsbereich, Symptome, Voraussetzungen, Lösungsschritte, Validierung und Fehlerbehandlung.
|
||||
- Schreibe solution_steps nur, wenn konkrete ausführbare Handlungen belegt sind.
|
||||
- Markiere Widersprüche ausdrücklich. Löse sie nur, wenn ein eindeutiger Beleg vorliegt.
|
||||
- Erzeuge präzise research_queries für wesentliche ungeklärte Punkte.
|
||||
- ready_for_article ist nur true, wenn ein vollständiger, nutzbarer Artikel ohne erfundene Fakten geschrieben werden kann und keine wesentliche Recherche mehr offen ist.
|
||||
- Schreibe keine Bewertung des Mehrwerts und keine Beschreibung des KI-Prozesses.
|
||||
Gib ausschließlich JSON nach Schema zurück.`
|
||||
}
|
||||
|
||||
func knowledgeBriefSchema() map[string]any {
|
||||
statement := map[string]any{"type": "object", "properties": map[string]any{
|
||||
"text": map[string]any{"type": "string"},
|
||||
"source_refs": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
|
||||
}, "required": []string{"text", "source_refs"}}
|
||||
conflict := map[string]any{"type": "object", "properties": map[string]any{
|
||||
"topic": map[string]any{"type": "string"},
|
||||
"statements": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
|
||||
"source_refs": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
|
||||
"resolution": map[string]any{"type": "string"},
|
||||
"needs_research": map[string]any{"type": "boolean"},
|
||||
"research_query": map[string]any{"type": "string"},
|
||||
}, "required": []string{"topic", "statements", "source_refs", "resolution", "needs_research", "research_query"}}
|
||||
return map[string]any{"type": "object", "properties": map[string]any{
|
||||
"topic": map[string]any{"type": "string"},
|
||||
"purpose": map[string]any{"type": "string"},
|
||||
"scope": map[string]any{"type": "array", "items": statement},
|
||||
"facts": map[string]any{"type": "array", "items": statement},
|
||||
"symptoms": map[string]any{"type": "array", "items": statement},
|
||||
"prerequisites": map[string]any{"type": "array", "items": statement},
|
||||
"solution_steps": map[string]any{"type": "array", "items": statement},
|
||||
"validation_steps": map[string]any{"type": "array", "items": statement},
|
||||
"troubleshooting": map[string]any{"type": "array", "items": statement},
|
||||
"contradictions": map[string]any{"type": "array", "items": conflict},
|
||||
"missing_information": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
|
||||
"research_queries": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
|
||||
"ready_for_article": map[string]any{"type": "boolean"},
|
||||
}, "required": []string{"topic", "purpose", "scope", "facts", "symptoms", "prerequisites", "solution_steps", "validation_steps", "troubleshooting", "contradictions", "missing_information", "research_queries", "ready_for_article"}}
|
||||
}
|
||||
|
||||
func normalizeKnowledgeBrief(brief model.KnowledgeBrief) model.KnowledgeBrief {
|
||||
brief.Topic = strings.TrimSpace(brief.Topic)
|
||||
brief.Purpose = strings.TrimSpace(brief.Purpose)
|
||||
brief.Scope = cleanGroundedStatements(brief.Scope)
|
||||
brief.Facts = cleanGroundedStatements(brief.Facts)
|
||||
brief.Symptoms = cleanGroundedStatements(brief.Symptoms)
|
||||
brief.Prerequisites = cleanGroundedStatements(brief.Prerequisites)
|
||||
brief.SolutionSteps = cleanGroundedStatements(brief.SolutionSteps)
|
||||
brief.ValidationSteps = cleanGroundedStatements(brief.ValidationSteps)
|
||||
brief.Troubleshooting = cleanGroundedStatements(brief.Troubleshooting)
|
||||
brief.MissingInformation = unique(brief.MissingInformation)
|
||||
brief.ResearchQueries = unique(brief.ResearchQueries)
|
||||
unresolvedConflict := false
|
||||
for i := range brief.Contradictions {
|
||||
brief.Contradictions[i].Topic = strings.TrimSpace(brief.Contradictions[i].Topic)
|
||||
brief.Contradictions[i].Statements = unique(brief.Contradictions[i].Statements)
|
||||
brief.Contradictions[i].SourceRefs = unique(brief.Contradictions[i].SourceRefs)
|
||||
brief.Contradictions[i].Resolution = strings.TrimSpace(brief.Contradictions[i].Resolution)
|
||||
brief.Contradictions[i].ResearchQuery = strings.TrimSpace(brief.Contradictions[i].ResearchQuery)
|
||||
if brief.Contradictions[i].NeedsResearch || brief.Contradictions[i].Resolution == "" {
|
||||
unresolvedConflict = true
|
||||
}
|
||||
}
|
||||
if len(brief.MissingInformation) > 0 || unresolvedConflict {
|
||||
brief.ReadyForArticle = false
|
||||
}
|
||||
return brief
|
||||
}
|
||||
|
||||
func cleanGroundedStatements(values []model.GroundedStatement) []model.GroundedStatement {
|
||||
out := make([]model.GroundedStatement, 0, len(values))
|
||||
seen := map[string]bool{}
|
||||
for _, value := range values {
|
||||
value.Text = strings.TrimSpace(value.Text)
|
||||
value.SourceRefs = unique(value.SourceRefs)
|
||||
key := strings.ToLower(value.Text)
|
||||
if value.Text == "" || seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
out = append(out, value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func collectArticleResearchQueries(plan model.ArticlePlanDecision, brief model.KnowledgeBrief, limit int) []string {
|
||||
queries := collectBriefResearchQueries(brief, limit)
|
||||
if plan.NeedsResearch && strings.TrimSpace(plan.ResearchQuery) != "" {
|
||||
queries = append([]string{strings.TrimSpace(plan.ResearchQuery)}, queries...)
|
||||
}
|
||||
queries = unique(queries)
|
||||
if limit > 0 && len(queries) > limit {
|
||||
queries = queries[:limit]
|
||||
}
|
||||
return queries
|
||||
}
|
||||
|
||||
func collectBriefResearchQueries(brief model.KnowledgeBrief, limit int) []string {
|
||||
queries := append([]string(nil), brief.ResearchQueries...)
|
||||
for _, conflict := range brief.Contradictions {
|
||||
if conflict.NeedsResearch && strings.TrimSpace(conflict.ResearchQuery) != "" {
|
||||
queries = append(queries, strings.TrimSpace(conflict.ResearchQuery))
|
||||
}
|
||||
}
|
||||
queries = unique(queries)
|
||||
if limit > 0 && len(queries) > limit {
|
||||
queries = queries[:limit]
|
||||
}
|
||||
return queries
|
||||
}
|
||||
|
||||
func (e *Engine) researchKnowledgeGaps(ctx context.Context, trigger string, nodeIDs, queries []string) ([]model.ResearchResult, error) {
|
||||
var out []model.ResearchResult
|
||||
seen := map[string]bool{}
|
||||
for _, query := range queries {
|
||||
query = strings.TrimSpace(query)
|
||||
if query == "" {
|
||||
continue
|
||||
}
|
||||
e.Broker.Publish(model.Activity{Type: "article.research.started", Source: "brain", Phase: "knowledge-research", NodeIDs: nodeIDs, Message: "Ein ungeklärter fachlicher Punkt wird recherchiert", Strength: .9, Metadata: map[string]any{"trigger": trigger, "research_query": query}})
|
||||
resultLimit := e.Cfg.ArticleResearchResults
|
||||
if resultLimit < 1 {
|
||||
resultLimit = 4
|
||||
}
|
||||
results, err := e.Research.Search(ctx, query, resultLimit)
|
||||
if err != nil {
|
||||
e.Broker.Publish(model.Activity{Type: "article.research.failed", Source: "brain", Phase: "knowledge-research", NodeIDs: nodeIDs, Message: "Die ergänzende Artikelrecherche ist fehlgeschlagen", Strength: .35, Metadata: map[string]any{"trigger": trigger, "research_query": query, "error": err.Error()}})
|
||||
return nil, err
|
||||
}
|
||||
for _, result := range results {
|
||||
key := strings.TrimSpace(result.URL)
|
||||
if key == "" {
|
||||
key = result.Title + "\x00" + result.Content
|
||||
}
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
out = append(out, result)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (e *Engine) articleDraftContext(sources []articleSource, plan model.ArticlePlanDecision, brief model.KnowledgeBrief, researchResults []model.ResearchResult) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("SCHREIBAUFTRAG: Verfasse einen vollständigen, direkt nutzbaren deutschsprachigen Helpdesk-Wissensartikel.\n")
|
||||
if plan.Action == "update" || plan.Action == "merge" {
|
||||
b.WriteString("Der Text muss als vollständiger eigenständiger Artikel formuliert sein und nicht als Änderungshinweis.\n")
|
||||
}
|
||||
briefJSON, _ := json.MarshalIndent(brief, "", " ")
|
||||
b.WriteString("\nKONSOLIDIERTE FACHLICHE WISSENSBASIS:\n")
|
||||
b.Write(briefJSON)
|
||||
b.WriteString("\n\nORIGINALBELEGE ZUR FAKTENPRÜFUNG:\n")
|
||||
appendArticleSources(&b, sources, e.Cfg.MaxContextChars)
|
||||
if len(researchResults) > 0 {
|
||||
b.WriteString("\nERGÄNZENDE RECHERCHEBELEGE:\n")
|
||||
for i, result := range researchResults {
|
||||
fmt.Fprintf(&b, "\nR%d: %s\nURL: %s\nINHALT: %s\n", i+1, result.Title, result.URL, clamp(result.Content, 1400))
|
||||
}
|
||||
}
|
||||
b.WriteString("\nDie sichtbaren Artikelfelder dürfen ausschließlich fachlichen Inhalt enthalten. Interne Planung, Bewertung, Quellen- oder Prozesssprache gehört nicht in den Artikel.\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func appendArticleSources(b *strings.Builder, sources []articleSource, maxChars int) {
|
||||
if maxChars < 4000 {
|
||||
maxChars = 16000
|
||||
@@ -350,17 +540,27 @@ Erfinde keine Fakten. Bevorzuge konkrete Problemlösung gegenüber technischer M
|
||||
}
|
||||
|
||||
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.
|
||||
return `Du bist ausschließlich der Fachautor eines deutschsprachigen Helpdesk-Wissensartikels. Du führst keine Bewertung und keine Quellenanalyse im Ausgabedokument durch.
|
||||
|
||||
Regeln:
|
||||
Deine Ausgabe enthält nur den später sichtbaren Artikelinhalt:
|
||||
- title: sachlicher Artikeltitel ohne KI- oder Entwurfshinweis.
|
||||
- problem_description: konkrete Beschreibung des Problems oder Anwendungsfalls.
|
||||
- scope: Geltungsbereich und sachliche Abgrenzung.
|
||||
- symptoms: beobachtbare Symptome oder Ausgangssituationen.
|
||||
- prerequisites: belegte Voraussetzungen.
|
||||
- solution_steps: konkrete, ausführbare Schritte in sinnvoller Reihenfolge. Jeder Eintrag ist genau ein Arbeitsschritt.
|
||||
- validation_steps: konkrete Prüfungen des Ergebnisses.
|
||||
- troubleshooting: belegte Maßnahmen bei Abweichungen.
|
||||
- categories und keywords: fachliche Einordnung.
|
||||
- open_questions: nur fachlich offene Punkte, die vor Freigabe geklärt werden müssen.
|
||||
|
||||
Strikte Regeln:
|
||||
- Erfinde keine Fakten, Befehle, Pfade, Versionen oder Ursachen.
|
||||
- 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.
|
||||
- Schreibe keine Bewertung der Quellen und keine Begründung, warum ein Artikel erstellt wird.
|
||||
- Schreibe nichts über Quellenverbund, Mehrwert, Relation, Ähnlichkeit, Nodes, Edges, Graph, KI, Qwen, Modell, Prompt, Confidence, Staging oder Denkprozess.
|
||||
- Verwende keine Formulierungen wie "die Quellen zeigen", "die Analyse ergibt", "die Inhalte ergänzen sich" oder "der Artikel sollte".
|
||||
- Formuliere unmittelbar als fertigen Support-Artikel.
|
||||
- Wenn konkrete Lösungsschritte nicht aus den Quellen ableitbar sind, lasse solution_steps leer; erfinde keinen Ersatztext.
|
||||
Gib ausschließlich JSON nach Schema zurück.`
|
||||
}
|
||||
|
||||
@@ -381,18 +581,224 @@ func articlePlanSchema() map[string]any {
|
||||
|
||||
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"}}
|
||||
"title": map[string]any{"type": "string"},
|
||||
"problem_description": map[string]any{"type": "string"},
|
||||
"scope": map[string]any{"type": "string"},
|
||||
"symptoms": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
|
||||
"prerequisites": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
|
||||
"solution_steps": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
|
||||
"validation_steps": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
|
||||
"troubleshooting": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
|
||||
"categories": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
|
||||
"keywords": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
|
||||
"open_questions": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
|
||||
}, "required": []string{"title", "problem_description", "scope", "symptoms", "prerequisites", "solution_steps", "validation_steps", "troubleshooting", "categories", "keywords", "open_questions"}}
|
||||
}
|
||||
|
||||
func (e *Engine) generateArticleContent(ctx context.Context, sources []articleSource, plan model.ArticlePlanDecision, brief model.KnowledgeBrief, researchResults []model.ResearchResult) (model.KnowledgeArticleContent, bool, error) {
|
||||
var content model.KnowledgeArticleContent
|
||||
if err := e.Ollama.ChatJSON(ctx, articleDraftSystemPrompt(), e.articleDraftContext(sources, plan, brief, researchResults), articleDraftSchema(), &content); err != nil {
|
||||
return model.KnowledgeArticleContent{}, false, fmt.Errorf("article content generation failed: %w", err)
|
||||
}
|
||||
content = normalizeArticleContent(content)
|
||||
if !containsArticleMetaContent(content) {
|
||||
return content, false, nil
|
||||
}
|
||||
|
||||
e.Broker.Publish(model.Activity{Type: "article.rewrite.started", Source: "brain", Phase: "knowledge-synthesis", NodeIDs: plan.SourceNodeIDs, Message: "Meta-Bewertung im Entwurf erkannt · der Inhalt wird aus der Wissensbasis als reiner Fachartikel neu geschrieben", Strength: .72, Metadata: map[string]any{"action": plan.Action, "target_article_id": plan.TargetArticleID}})
|
||||
badJSON, _ := json.MarshalIndent(content, "", " ")
|
||||
var rewritten model.KnowledgeArticleContent
|
||||
if err := e.Ollama.ChatJSON(ctx, articleRewriteSystemPrompt(), e.articleRewriteContext(sources, brief, researchResults, string(badJSON)), articleDraftSchema(), &rewritten); err != nil {
|
||||
return model.KnowledgeArticleContent{}, true, fmt.Errorf("article content rewrite failed: %w", err)
|
||||
}
|
||||
rewritten = normalizeArticleContent(rewritten)
|
||||
if containsArticleMetaContent(rewritten) {
|
||||
return model.KnowledgeArticleContent{}, true, fmt.Errorf("article rewrite still contains planning or assessment language")
|
||||
}
|
||||
return rewritten, true, nil
|
||||
}
|
||||
|
||||
func (e *Engine) reviewArticleContent(ctx context.Context, draft model.KnowledgeArticleDraft, sources []articleSource, researchResults []model.ResearchResult) (model.ArticleQualityDecision, error) {
|
||||
var decision model.ArticleQualityDecision
|
||||
if err := e.Ollama.ChatJSON(ctx, articleQualitySystemPrompt(), e.articleQualityContext(draft, sources, researchResults), articleQualitySchema(), &decision); err != nil {
|
||||
return model.ArticleQualityDecision{}, err
|
||||
}
|
||||
if containsDraftMetaContent(draft) {
|
||||
decision.Accepted = false
|
||||
decision.MetaContentDetected = true
|
||||
decision.Issues = append(decision.Issues, "Der sichtbare Artikel enthält Bewertungs- oder Prozesssprache.")
|
||||
}
|
||||
decision.Issues = unique(decision.Issues)
|
||||
decision.UnsupportedClaims = unique(decision.UnsupportedClaims)
|
||||
return decision, nil
|
||||
}
|
||||
|
||||
func (e *Engine) articleRewriteContext(sources []articleSource, brief model.KnowledgeBrief, researchResults []model.ResearchResult, rejected string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("SCHREIBAUFTRAG: Formuliere einen vollständigen, direkt nutzbaren Helpdesk-Wissensartikel.\n")
|
||||
b.WriteString("Der folgende Entwurf wurde wegen Bewertungs- oder Prozesssprache verworfen. Übernimm daraus keine Meta-Aussagen:\n--- VERWORFENER ENTWURF ---\n")
|
||||
b.WriteString(clamp(rejected, 5000))
|
||||
briefJSON, _ := json.MarshalIndent(brief, "", " ")
|
||||
b.WriteString("\n--- ENDE VERWORFENER ENTWURF ---\n\nKONSOLIDIERTE FACHLICHE WISSENSBASIS:\n")
|
||||
b.Write(briefJSON)
|
||||
b.WriteString("\n\nORIGINALBELEGE:\n")
|
||||
appendArticleSources(&b, sources, e.Cfg.MaxContextChars)
|
||||
if len(researchResults) > 0 {
|
||||
b.WriteString("\nRECHERCHEBELEGE:\n")
|
||||
for i, result := range researchResults {
|
||||
fmt.Fprintf(&b, "\nR%d: %s\nURL: %s\nINHALT: %s\n", i+1, result.Title, result.URL, clamp(result.Content, 1200))
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (e *Engine) articleQualityContext(draft model.KnowledgeArticleDraft, sources []articleSource, researchResults []model.ResearchResult) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("ZU PRÜFENDER SICHTBARER KB-ARTIKEL:\n\nTITEL:\n")
|
||||
b.WriteString(draft.Title)
|
||||
b.WriteString("\n\nPROBLEM / BESCHREIBUNG:\n")
|
||||
b.WriteString(draft.Text)
|
||||
b.WriteString("\n\nLÖSUNG / ANTWORT:\n")
|
||||
b.WriteString(formatArticleAnswer(draft))
|
||||
b.WriteString("\n\nINTERNE BELEGQUELLEN:\n")
|
||||
appendArticleSources(&b, sources, e.Cfg.MaxContextChars)
|
||||
if len(researchResults) > 0 {
|
||||
b.WriteString("\nRECHERCHEBELEGE:\n")
|
||||
for i, result := range researchResults {
|
||||
fmt.Fprintf(&b, "\nR%d: %s\nURL: %s\nINHALT: %s\n", i+1, result.Title, result.URL, clamp(result.Content, 1200))
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func articleRewriteSystemPrompt() string {
|
||||
return `Du bist der Fachautor eines deutschsprachigen Helpdesk-Wissensartikels. Ein vorheriger Entwurf wurde verworfen, weil er eine Bewertung, Quellenanalyse oder Beschreibung des KI-Prozesses statt des eigentlichen Ergebnisses enthielt.
|
||||
|
||||
Schreibe den Artikel vollständig neu und ausschließlich als sichtbaren Fachinhalt. Verwende nur belegte Informationen aus den Quellen. Entferne jede Aussage über Quellen, Relation, Ähnlichkeit, Mehrwert, Bewertung, Analyse, Graph, Nodes, Edges, KI, Qwen, Modell, Prompt, Confidence, Staging oder Entwurf. Keine Vorrede und kein Fazit über die Erstellung. Gib ausschließlich JSON nach dem vorgegebenen Inhaltsschema zurück.`
|
||||
}
|
||||
|
||||
func articleQualitySystemPrompt() string {
|
||||
return `Du bist die Qualitätskontrolle einer deutschsprachigen Helpdesk-Wissensdatenbank. Du bewertest einen bereits erzeugten Artikel gegen seine Belegquellen. Deine Bewertung wird niemals als Artikeltext gespeichert.
|
||||
|
||||
Setze accepted nur dann auf true, wenn:
|
||||
- der sichtbare Text ein fertiger fachlicher Helpdesk-Artikel ist,
|
||||
- Problem und Lösung konkret und für Anwender oder Support nutzbar sind,
|
||||
- keine Bewertung der Quellen oder Beschreibung des Erzeugungsprozesses enthalten ist,
|
||||
- keine Aussagen über Relation, Ähnlichkeit, Nodes, Edges, Graph, KI, Qwen, Modell, Prompt, Confidence, Staging oder Entwurf vorkommen,
|
||||
- alle konkreten Behauptungen und Schritte durch die Quellen belegbar sind,
|
||||
- die Lösung nicht nur erklärt, dass Quellen zusammenpassen.
|
||||
|
||||
meta_content_detected ist true, sobald sichtbarer Inhalt eine Quellenbewertung, Planungsbegründung oder Prozessbeschreibung enthält. unsupported_claims enthält konkrete unbelegte Aussagen. issues enthält kurze Qualitätsmängel. Gib ausschließlich JSON nach Schema zurück.`
|
||||
}
|
||||
|
||||
func articleQualitySchema() map[string]any {
|
||||
return map[string]any{"type": "object", "properties": map[string]any{
|
||||
"accepted": map[string]any{"type": "boolean"},
|
||||
"confidence": map[string]any{"type": "number", "minimum": 0, "maximum": 1},
|
||||
"meta_content_detected": map[string]any{"type": "boolean"},
|
||||
"unsupported_claims": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
|
||||
"issues": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
|
||||
}, "required": []string{"accepted", "confidence", "meta_content_detected", "unsupported_claims", "issues"}}
|
||||
}
|
||||
|
||||
func normalizeArticleContent(content model.KnowledgeArticleContent) model.KnowledgeArticleContent {
|
||||
content.Title = strings.TrimSpace(content.Title)
|
||||
content.ProblemDescription = strings.TrimSpace(content.ProblemDescription)
|
||||
content.Scope = strings.TrimSpace(content.Scope)
|
||||
content.Symptoms = cleanArticleItems(content.Symptoms)
|
||||
content.Prerequisites = cleanArticleItems(content.Prerequisites)
|
||||
content.SolutionSteps = cleanArticleItems(content.SolutionSteps)
|
||||
content.ValidationSteps = cleanArticleItems(content.ValidationSteps)
|
||||
content.Troubleshooting = cleanArticleItems(content.Troubleshooting)
|
||||
content.Categories = unique(content.Categories)
|
||||
content.Keywords = unique(content.Keywords)
|
||||
content.OpenQuestions = cleanArticleItems(content.OpenQuestions)
|
||||
return content
|
||||
}
|
||||
|
||||
func cleanArticleItems(items []string) []string {
|
||||
out := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
item = strings.TrimSpace(item)
|
||||
item = strings.TrimLeft(item, "0123456789.-) \t")
|
||||
if item != "" {
|
||||
out = append(out, item)
|
||||
}
|
||||
}
|
||||
return unique(out)
|
||||
}
|
||||
|
||||
func articleContentToDraft(content model.KnowledgeArticleContent, sourceIDs []string) model.KnowledgeArticleDraft {
|
||||
return model.KnowledgeArticleDraft{
|
||||
Title: content.Title,
|
||||
Text: formatArticleProblem(content),
|
||||
Answer: formatNumberedSteps(content.SolutionSteps),
|
||||
Prerequisites: content.Prerequisites,
|
||||
Validation: content.ValidationSteps,
|
||||
Troubleshooting: content.Troubleshooting,
|
||||
Categories: content.Categories,
|
||||
Keywords: content.Keywords,
|
||||
SourceNodeIDs: append([]string(nil), sourceIDs...),
|
||||
OpenQuestions: content.OpenQuestions,
|
||||
}
|
||||
}
|
||||
|
||||
func formatArticleProblem(content model.KnowledgeArticleContent) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(strings.TrimSpace(content.ProblemDescription))
|
||||
if strings.TrimSpace(content.Scope) != "" {
|
||||
b.WriteString("\n\n## Geltungsbereich\n")
|
||||
b.WriteString(strings.TrimSpace(content.Scope))
|
||||
}
|
||||
appendListSection(&b, "Symptome", content.Symptoms)
|
||||
return strings.TrimSpace(b.String())
|
||||
}
|
||||
|
||||
func formatNumberedSteps(steps []string) string {
|
||||
clean := cleanArticleItems(steps)
|
||||
var b strings.Builder
|
||||
for i, step := range clean {
|
||||
if i > 0 {
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
fmt.Fprintf(&b, "%d. %s", i+1, step)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func containsArticleMetaContent(content model.KnowledgeArticleContent) bool {
|
||||
parts := []string{content.Title, content.ProblemDescription, content.Scope}
|
||||
parts = append(parts, content.Symptoms...)
|
||||
parts = append(parts, content.Prerequisites...)
|
||||
parts = append(parts, content.SolutionSteps...)
|
||||
parts = append(parts, content.ValidationSteps...)
|
||||
parts = append(parts, content.Troubleshooting...)
|
||||
return containsMetaLanguage(strings.Join(parts, "\n"))
|
||||
}
|
||||
|
||||
func containsDraftMetaContent(draft model.KnowledgeArticleDraft) bool {
|
||||
return containsMetaLanguage(strings.Join([]string{draft.Title, draft.Text, draft.Answer, strings.Join(draft.Prerequisites, "\n"), strings.Join(draft.Validation, "\n"), strings.Join(draft.Troubleshooting, "\n")}, "\n"))
|
||||
}
|
||||
|
||||
func containsMetaLanguage(value string) bool {
|
||||
lower := strings.ToLower(value)
|
||||
phrases := []string{
|
||||
"die bereitgestellten quellen", "die vorliegenden quellen", "die quellen zeigen", "die quellen ergänzen", "aus den quellen",
|
||||
"der quellenverbund", "diese quellen", "die beziehung zwischen", "semantische nähe", "semantische ähnlichkeit",
|
||||
"die analyse ergibt", "die analyse zeigt", "die bewertung", "erwarteter mehrwert", "der mehrwert",
|
||||
"source_node", "node_id", "nodes", "edges", "wissensgraph", "graphenansicht", "qwen", "ollama-modell",
|
||||
"als ki", "ki-generiert", "ki erstellt", "prompt", "confidence", "staging-entwurf", "dieser entwurf",
|
||||
"der artikel sollte", "es sollte ein artikel", "es empfiehlt sich, einen artikel", "relationstyp", "relationsbewertung",
|
||||
"die wissensbasis zeigt", "die konsolidierung zeigt", "die zusammenführung zeigt", "die zusammenführung der quellen",
|
||||
"auf basis der quellen", "basierend auf den quellen", "basierend auf den bereitgestellten informationen", "die quellenlage",
|
||||
"der themenverbund", "die relation", "die bewertung ergab", "im rahmen der analyse", "dieser artikel fasst die quellen",
|
||||
}
|
||||
for _, phrase := range phrases {
|
||||
if strings.Contains(lower, phrase) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (e *Engine) validateArticleDraft(draft model.KnowledgeArticleDraft, sources []articleSource, productionRatio float64, generationDepth int) error {
|
||||
@@ -421,7 +827,7 @@ func (e *Engine) validateArticleDraft(draft model.KnowledgeArticleDraft, sources
|
||||
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) {
|
||||
func (e *Engine) writeKnowledgeArticleDraft(sources []articleSource, plan model.ArticlePlanDecision, brief model.KnowledgeBrief, draft model.KnowledgeArticleDraft, researchResults []model.ResearchResult, productionCount, aiCount int, productionRatio float64, generationDepth int) (string, string, bool, error) {
|
||||
if len(e.Cfg.StagingDirs) == 0 {
|
||||
return "", "", false, fmt.Errorf("no BRAIN_STAGING_DIRS configured")
|
||||
}
|
||||
@@ -452,35 +858,15 @@ func (e *Engine) writeKnowledgeArticleDraft(sources []articleSource, plan model.
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The KB document intentionally contains only the public KB schema. Planning,
|
||||
// model assessment, confidence and provenance are stored in a separate Brain
|
||||
// sidecar so the editor can never mistake an internal evaluation for article text.
|
||||
doc := map[string]any{
|
||||
"id": articleID, "title": strings.TrimSpace(draft.Title), "text": strings.TrimSpace(draft.Text), "answer": answer,
|
||||
"auto_reply": false, "min_score": 0.82, "categories": categories, "keywords": keywords,
|
||||
"source": "Neural Brain / " + e.Cfg.ChatModel + " (AI-THINK Knowledge Synthesis)",
|
||||
"source": "Neural Brain / " + e.Cfg.ChatModel + " (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 {
|
||||
@@ -490,10 +876,41 @@ func (e *Engine) writeKnowledgeArticleDraft(sources []articleSource, plan model.
|
||||
if err != nil {
|
||||
return "", "", false, err
|
||||
}
|
||||
|
||||
targetExternalID := ""
|
||||
if plan.TargetArticleID != "" {
|
||||
for _, source := range sources {
|
||||
if source.Node.ID == plan.TargetArticleID {
|
||||
targetExternalID = source.Node.ExternalID
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
var evidence []map[string]any
|
||||
for _, result := range researchResults {
|
||||
evidence = append(evidence, map[string]any{"title": result.Title, "url": result.URL, "excerpt": clamp(result.Content, 500)})
|
||||
}
|
||||
meta := map[string]any{
|
||||
"article_id": articleID, "article_path": queued, "generated_at": now, "status": "staging", "subtype": "knowledge_synthesis",
|
||||
"action": plan.Action, "target_node_id": plan.TargetArticleID, "target_article_id": targetExternalID,
|
||||
"planning": map[string]any{"reason": plan.Reason, "expected_value": plan.ExpectedValue, "article_type": plan.ArticleType, "missing_information": plan.MissingInformation, "contradictions": plan.Contradictions},
|
||||
"source_nodes": externalIDsFromArticleSources(sources), "source_node_ids": sourceIDs,
|
||||
"productive_source_count": productionCount, "ai_source_count": aiCount, "production_ratio": productionRatio,
|
||||
"generation_depth": generationDepth, "confidence": draft.Confidence, "open_questions": draft.OpenQuestions,
|
||||
"knowledge_brief": brief, "research_query": plan.ResearchQuery, "research_evidence": evidence,
|
||||
}
|
||||
metaBytes, err := json.MarshalIndent(meta, "", " ")
|
||||
if err != nil {
|
||||
return "", "", false, err
|
||||
}
|
||||
metaPath := filepath.Join(e.Cfg.DataDir, "article-metadata", strings.ToLower(articleID)+".json")
|
||||
if _, err := e.Persistence.QueueFile(metaPath, append(metaBytes, '\n'), 0o640); err != nil {
|
||||
return "", "", false, err
|
||||
}
|
||||
return queued, articleID, true, nil
|
||||
}
|
||||
|
||||
func (e *Engine) addRuntimeArticleNode(articleID string, sources []articleSource, plan model.ArticlePlanDecision, draft model.KnowledgeArticleDraft, productionCount, aiCount int, productionRatio float64, generationDepth int) {
|
||||
func (e *Engine) addRuntimeArticleNode(articleID string, sources []articleSource, plan model.ArticlePlanDecision, draft model.KnowledgeArticleDraft, researchResults []model.ResearchResult, productionCount, aiCount int, productionRatio float64, generationDepth int) {
|
||||
nodeID := graph.ID("knowledge", articleID)
|
||||
now := time.Now().UTC()
|
||||
node := model.Node{
|
||||
@@ -509,6 +926,35 @@ func (e *Engine) addRuntimeArticleNode(articleID string, sources []articleSource
|
||||
if plan.TargetArticleID != "" {
|
||||
e.Graph.UpsertEdge(model.Edge{Source: nodeID, Target: plan.TargetArticleID, Type: "proposes_" + plan.Action, Origin: "knowledge-staging", Status: "staging", Confidence: draft.Confidence, Weight: .8, Explanation: plan.Reason})
|
||||
}
|
||||
for _, result := range researchResults {
|
||||
researchID := graph.ID("external", result.URL)
|
||||
if _, ok := e.Graph.GetNode(researchID); ok {
|
||||
e.Graph.UpsertEdge(model.Edge{Source: nodeID, Target: researchID, Type: "grounded_by", Origin: "knowledge-staging", Status: "staging", Confidence: draft.Confidence, Weight: .55, Explanation: "Recherchebeleg für den konsolidierten Wissensartikel"})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Engine) learnRuntimeArticle(ctx context.Context, articleID string) {
|
||||
if !e.LearningEnabled() {
|
||||
return
|
||||
}
|
||||
nodeID := graph.ID("knowledge", articleID)
|
||||
node, ok := e.Graph.GetNode(nodeID)
|
||||
if !ok || !matchesCategories(node, e.learningCategories()) {
|
||||
return
|
||||
}
|
||||
text := embeddingText(node)
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return
|
||||
}
|
||||
vecs, err := e.Ollama.Embed(ctx, []string{text})
|
||||
if err != nil || len(vecs) != 1 || len(vecs[0]) == 0 {
|
||||
e.Graph.SetVector(nodeID, hashEmbedding(text, 256))
|
||||
e.Broker.Publish(model.Activity{Type: "article.learned", Source: "brain", Phase: "embedding", NodeIDs: []string{nodeID}, Message: "Der neue KB-Artikel wurde mit einem lokalen Fallback-Vektor in den Wissensgraphen aufgenommen", Strength: .46, Metadata: map[string]any{"article_id": articleID, "fallback": true}})
|
||||
return
|
||||
}
|
||||
e.Graph.SetVector(nodeID, vecs[0])
|
||||
e.Broker.Publish(model.Activity{Type: "article.learned", Source: "ollama", Phase: "embedding", NodeIDs: []string{nodeID}, Message: "Der neue KB-Artikel wurde eingebettet und ist sofort für Verknüpfungen verfügbar", Strength: .62, Metadata: map[string]any{"article_id": articleID, "model": e.Cfg.EmbeddingModel, "dimensions": len(vecs[0])}})
|
||||
}
|
||||
|
||||
func (e *Engine) addResearchToSources(sources []articleSource, results []model.ResearchResult) {
|
||||
|
||||
@@ -698,6 +698,7 @@ func (e *Engine) Status() map[string]any {
|
||||
"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,
|
||||
"article_max_research_queries": e.Cfg.ArticleMaxResearchQueries, "article_research_results": e.Cfg.ArticleResearchResults,
|
||||
"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,
|
||||
|
||||
+147
-12
@@ -42,8 +42,12 @@ func TestEnrichCreatesStructuredKnowledgeArticleFromThreeProductionSources(t *te
|
||||
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":""}`
|
||||
case 3:
|
||||
content = `{"topic":"VPN-Gateway-Störung","purpose":"VPN-Verbindungsprobleme diagnostizieren und beheben","scope":[{"text":"Gilt für Remotezugriffe über einen VPN-Client.","source_refs":["KB-VPN"]}],"facts":[{"text":"Ein nicht erreichbares Gateway kann den Tunnelaufbau verhindern.","source_refs":["KB-VPN"]}],"symptoms":[{"text":"Der Client meldet ein nicht erreichbares Gateway oder einen Timeout.","source_refs":["KB-VPN","KB-REMOTE"]}],"prerequisites":[{"text":"Die aktuelle Fehlermeldung und der Zeitpunkt des Fehlers liegen vor.","source_refs":["KB-VPN-ESC"]}],"solution_steps":[{"text":"Prüfen Sie die Internetverbindung des betroffenen Rechners.","source_refs":["KB-VPN"]},{"text":"Kontrollieren Sie die konfigurierte Gateway-Adresse und deren Erreichbarkeit.","source_refs":["KB-VPN"]},{"text":"Starten Sie den VPN-Client neu und prüfen Sie den Tunnelaufbau erneut.","source_refs":["KB-REMOTE"]},{"text":"Eskalieren Sie einen anhaltenden Gateway-Ausfall mit Zeitstempel und Fehlermeldung an das Netzwerkteam.","source_refs":["KB-VPN-ESC"]}],"validation_steps":[{"text":"Der VPN-Tunnel wird aufgebaut und interne Ressourcen sind erreichbar.","source_refs":["KB-REMOTE"]}],"troubleshooting":[{"text":"Bei weiterhin nicht erreichbarem Gateway den dokumentierten Eskalationsweg verwenden.","source_refs":["KB-VPN-ESC"]}],"contradictions":[],"missing_information":[],"research_queries":[],"ready_for_article":true}`
|
||||
case 4:
|
||||
content = `{"title":"VPN-Gateway-Störung systematisch beheben","problem_description":"Der VPN-Client kann keinen Tunnel zum zentralen Gateway aufbauen. Betroffene Benutzer sehen typischerweise einen Timeout oder die Meldung, dass das Gateway nicht erreichbar ist. Die folgenden Schritte grenzen lokale Verbindungsprobleme von einer zentralen Gateway-Störung ab.","scope":"Die Anleitung gilt für Remotezugriffe über den dokumentierten VPN-Client und das zugehörige zentrale Gateway.","symptoms":["Der Verbindungsaufbau endet mit einem Timeout.","Das konfigurierte Gateway ist nicht erreichbar."],"prerequisites":["Die aktuelle Fehlermeldung und der Zeitpunkt des Fehlers liegen vor.","Zugriff auf die VPN-Client-Konfiguration ist vorhanden."],"solution_steps":["Prüfen Sie, ob der betroffene Rechner eine funktionierende Internetverbindung besitzt.","Kontrollieren Sie die konfigurierte Gateway-Adresse und testen Sie deren Erreichbarkeit.","Starten Sie den VPN-Client neu und führen Sie den Verbindungsversuch erneut aus.","Bleibt das Gateway nicht erreichbar, übergeben Sie Zeitstempel, Fehlermeldung und betroffene Benutzer an das Netzwerkteam."],"validation_steps":["Der VPN-Tunnel wird aufgebaut.","Eine interne Zielressource ist erreichbar."],"troubleshooting":["Bei weiterhin nicht erreichbarem Gateway den dokumentierten Eskalationsweg verwenden."],"categories":["Netzwerk","VPN"],"keywords":["VPN","Gateway","Remotezugriff"],"open_questions":[]}`
|
||||
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":[]}`
|
||||
content = `{"accepted":true,"confidence":0.92,"meta_content_detected":false,"unsupported_claims":[],"issues":[]}`
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"message": map[string]any{"content": content}})
|
||||
default:
|
||||
@@ -88,8 +92,8 @@ func TestEnrichCreatesStructuredKnowledgeArticleFromThreeProductionSources(t *te
|
||||
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)
|
||||
if chatCalls != 5 {
|
||||
t.Fatalf("expected relation, plan, consolidation, draft and quality calls, got %d", chatCalls)
|
||||
}
|
||||
files, err := filepath.Glob(filepath.Join(staging, "*.json"))
|
||||
if err != nil || len(files) != 1 {
|
||||
@@ -106,12 +110,18 @@ func TestEnrichCreatesStructuredKnowledgeArticleFromThreeProductionSources(t *te
|
||||
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 _, exists := doc["ai_think"]; exists {
|
||||
t.Fatalf("internal assessment metadata must not be written into the KB article: %#v", doc["ai_think"])
|
||||
}
|
||||
if ai["productive_source_count"].(float64) < 3 {
|
||||
t.Fatalf("expected at least three productive sources: %#v", ai)
|
||||
visible := strings.ToLower(doc["text"].(string) + "\n" + doc["answer"].(string))
|
||||
for _, forbidden := range []string{"die quellen", "mehrwert", "bewertung", "source_node", "relation"} {
|
||||
if strings.Contains(visible, forbidden) {
|
||||
t.Fatalf("visible article contains internal assessment language %q: %s", forbidden, visible)
|
||||
}
|
||||
}
|
||||
metaFiles, err := filepath.Glob(filepath.Join(data, "article-metadata", "*.json"))
|
||||
if err != nil || len(metaFiles) != 1 {
|
||||
t.Fatalf("expected one separate article metadata sidecar, files=%v err=%v", metaFiles, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,6 +178,117 @@ func TestEnrichRelationWithOnlyTwoSourcesDoesNotCreateArticle(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSynthesisResearchesUnclearKnowledgeThenLearnsAndLinksArticle(t *testing.T) {
|
||||
var chatCalls int
|
||||
ollama := 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, .1 + float64(i)*.01, .2}
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"embeddings": vectors})
|
||||
case "/api/chat":
|
||||
chatCalls++
|
||||
content := ""
|
||||
switch chatCalls {
|
||||
case 1:
|
||||
content = `{"related":true,"relation_type":"same_topic","confidence":0.9,"explanation":"Die Beiträge behandeln denselben VPN-Fehler.","needs_research":false,"research_query":"","topic_label":"VPN Gateway","keywords":["VPN"]}`
|
||||
case 2:
|
||||
content = `{"action":"create","target_article_id":"","reason":"Die produktiven Beiträge lassen sich konsolidieren.","expected_value":"Ein vollständiger Diagnoseablauf.","article_type":"troubleshooting","source_node_ids":[],"missing_information":[],"contradictions":[],"needs_research":false,"research_query":""}`
|
||||
case 3:
|
||||
content = `{"topic":"VPN Gateway","purpose":"VPN-Fehler beheben","scope":[],"facts":[],"symptoms":[{"text":"Der VPN-Tunnel wird nicht aufgebaut.","source_refs":["A"]}],"prerequisites":[],"solution_steps":[{"text":"Internetverbindung prüfen.","source_refs":["A"]}],"validation_steps":[],"troubleshooting":[],"contradictions":[{"topic":"Offizieller Gateway-Prüfweg","statements":["Die internen Beiträge nennen keinen verlässlichen Gateway-Test."],"source_refs":["A","B","C"],"resolution":"","needs_research":true,"research_query":"Hersteller VPN Gateway Erreichbarkeit Diagnose"}],"missing_information":["Offizieller Gateway-Prüfweg"],"research_queries":["Hersteller VPN Gateway Erreichbarkeit Diagnose"],"ready_for_article":false}`
|
||||
case 4:
|
||||
content = `{"topic":"VPN Gateway","purpose":"VPN-Fehler beheben","scope":[{"text":"Gilt für den dokumentierten VPN-Client.","source_refs":["A"]}],"facts":[{"text":"Der Hersteller empfiehlt, die Gateway-Adresse und die Netzwerkverbindung zu prüfen.","source_refs":["R1"]}],"symptoms":[{"text":"Der VPN-Tunnel wird nicht aufgebaut.","source_refs":["A"]}],"prerequisites":[{"text":"Fehlermeldung und Zeitpunkt liegen vor.","source_refs":["C"]}],"solution_steps":[{"text":"Prüfen Sie die Internetverbindung.","source_refs":["A"]},{"text":"Prüfen Sie die konfigurierte Gateway-Adresse und deren Erreichbarkeit.","source_refs":["R1"]},{"text":"Starten Sie den VPN-Client neu.","source_refs":["B"]}],"validation_steps":[{"text":"Der VPN-Tunnel wird aufgebaut.","source_refs":["B"]}],"troubleshooting":[{"text":"Bei anhaltendem Fehler mit Zeitstempel und Meldung eskalieren.","source_refs":["C"]}],"contradictions":[],"missing_information":[],"research_queries":[],"ready_for_article":true}`
|
||||
case 5:
|
||||
content = `{"title":"VPN-Gateway-Verbindung diagnostizieren","problem_description":"Der VPN-Client kann keine Verbindung zum konfigurierten Gateway aufbauen. Der Tunnel bleibt getrennt und der Remotezugriff ist nicht möglich.","scope":"Die Anleitung gilt für den dokumentierten VPN-Client und das konfigurierte zentrale Gateway.","symptoms":["Der VPN-Tunnel wird nicht aufgebaut.","Der Client meldet ein nicht erreichbares Gateway."],"prerequisites":["Fehlermeldung und Zeitpunkt des Fehlers liegen vor."],"solution_steps":["Prüfen Sie die Internetverbindung des Rechners.","Kontrollieren Sie die konfigurierte Gateway-Adresse und testen Sie deren Erreichbarkeit.","Starten Sie den VPN-Client neu und wiederholen Sie den Verbindungsaufbau.","Eskalieren Sie einen anhaltenden Fehler mit Zeitstempel und Fehlermeldung."],"validation_steps":["Der VPN-Tunnel wird aufgebaut.","Eine interne Ressource ist erreichbar."],"troubleshooting":["Prüfen Sie bei erneutem Fehler die erfasste Meldung und den dokumentierten Eskalationsweg."],"categories":["Netzwerk","VPN"],"keywords":["VPN","Gateway"],"open_questions":[]}`
|
||||
default:
|
||||
content = `{"accepted":true,"confidence":0.93,"meta_content_detected":false,"unsupported_claims":[],"issues":[]}`
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"message": map[string]any{"content": content}})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer ollama.Close()
|
||||
|
||||
searx := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"results":[{"title":"Hersteller-Dokumentation","url":"https://vendor.example/vpn-gateway","content":"Prüfen Sie die konfigurierte Gateway-Adresse und die Netzwerkverbindung. Starten Sie danach den Client neu."}]}`))
|
||||
}))
|
||||
defer searx.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)
|
||||
}
|
||||
}
|
||||
for name, body := range map[string]string{
|
||||
"a.json": `{"id":"A","title":"VPN nicht verbunden","text":"Der Tunnel wird nicht aufgebaut.","answer":"Internetverbindung prüfen.","categories":["VPN"]}`,
|
||||
"b.json": `{"id":"B","title":"VPN Client neu starten","text":"Der Client bleibt getrennt.","answer":"Client neu starten und Tunnel erneut prüfen.","categories":["VPN"]}`,
|
||||
"c.json": `{"id":"C","title":"VPN Eskalation","text":"Anhaltende Fehler müssen eskaliert werden.","answer":"Zeitstempel und Fehlermeldung erfassen.","categories":["VPN"]}`,
|
||||
} {
|
||||
if err := os.WriteFile(filepath.Join(knowledge, name), []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
g, err := graph.Open(data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := config.Config{DataDir: data, KnowledgeDirs: []string{knowledge}, StagingDirs: []string{staging}, OllamaURL: ollama.URL, ChatModel: "qwen3:8b", EmbeddingModel: "embeddinggemma", SearXNGURL: searx.URL, ResearchEnabled: true, SimilarityThreshold: .5, RelationThreshold: .7, ArticleSynthesisEnabled: true, ArticleMinSources: 3, ArticleMaxSources: 6, ArticleMinProductionRatio: .7, ArticleMaxGenerationDepth: 2, ArticleMinConfidence: .7, ArticleMinTextChars: 80, ArticleMinAnswerChars: 180, ArticleMaxResearchQueries: 3, ArticleResearchResults: 4, MaxContextChars: 16000}
|
||||
e := New(cfg, g, activity.New(100))
|
||||
if err := e.Scan(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := e.EnrichOne(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := e.Flush(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
files, _ := filepath.Glob(filepath.Join(staging, "*.json"))
|
||||
if len(files) != 1 {
|
||||
t.Fatalf("expected researched article, files=%v", files)
|
||||
}
|
||||
var doc map[string]any
|
||||
dataBytes, _ := os.ReadFile(files[0])
|
||||
if err := json.Unmarshal(dataBytes, &doc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, exists := doc["ai_think"]; exists {
|
||||
t.Fatal("assessment metadata leaked into article")
|
||||
}
|
||||
articleID := doc["id"].(string)
|
||||
articleNodeID := graph.ID("knowledge", articleID)
|
||||
if vector, ok := g.Vector(articleNodeID); !ok || len(vector) == 0 {
|
||||
t.Fatal("new article was not learned immediately")
|
||||
}
|
||||
researchID := graph.ID("external", "https://vendor.example/vpn-gateway")
|
||||
linked := false
|
||||
for _, edge := range g.Snapshot().Edges {
|
||||
if edge.Source == articleNodeID && edge.Target == researchID && edge.Type == "grounded_by" {
|
||||
linked = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !linked {
|
||||
t.Fatal("new article is not linked to its research evidence")
|
||||
}
|
||||
if chatCalls != 6 {
|
||||
t.Fatalf("expected relation, plan, two consolidations, article and quality calls, got %d", chatCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestEnrichDoesNotQueueDuplicateCycle(t *testing.T) {
|
||||
g, err := graph.Open(t.TempDir())
|
||||
if err != nil {
|
||||
@@ -250,7 +371,7 @@ func TestWriteKnowledgeArticleDraftPreservesUpdateTarget(t *testing.T) {
|
||||
}
|
||||
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)
|
||||
path, _, created, err := e.writeKnowledgeArticleDraft(sources, plan, model.KnowledgeBrief{Topic: "VPN", ReadyForArticle: true}, draft, nil, 3, 0, 1, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -268,9 +389,23 @@ func TestWriteKnowledgeArticleDraftPreservesUpdateTarget(t *testing.T) {
|
||||
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 _, exists := doc["ai_think"]; exists {
|
||||
t.Fatalf("internal metadata leaked into KB document: %#v", doc["ai_think"])
|
||||
}
|
||||
metaFiles, err := filepath.Glob(filepath.Join(data, "article-metadata", "*.json"))
|
||||
if err != nil || len(metaFiles) != 1 {
|
||||
t.Fatalf("expected separate update metadata sidecar, files=%v err=%v", metaFiles, err)
|
||||
}
|
||||
metaBytes, err := os.ReadFile(metaFiles[0])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var meta map[string]any
|
||||
if err := json.Unmarshal(metaBytes, &meta); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if meta["action"] != "update" || meta["target_article_id"] != "KB-TARGET" || meta["target_node_id"] != target.ID {
|
||||
t.Fatalf("update target metadata was not preserved in sidecar: %#v", meta)
|
||||
}
|
||||
if !strings.Contains(doc["answer"].(string), "## Ergebnis prüfen") {
|
||||
t.Fatalf("validation section missing from answer: %q", doc["answer"])
|
||||
|
||||
Reference in New Issue
Block a user