Cluster-Work

This commit is contained in:
2026-08-07 21:54:37 +02:00
parent f8bf8c944e
commit dc2af7ca75
761 changed files with 939 additions and 35364 deletions

View File

@@ -107,10 +107,17 @@ func (e *Engine) synthesizeKnowledgeArticle(ctx context.Context, trigger string,
}
e.Broker.Publish(model.Activity{Type: "article.consolidation.started", Source: "brain", Phase: "knowledge-consolidation", NodeIDs: plan.SourceNodeIDs, Message: "Interne Quellen werden als Ausgangsmaterial für Recherche und Artikelsynthese strukturiert", Strength: .84, Metadata: map[string]any{"trigger": trigger, "source_count": len(selected), "synthesis_model": e.Cfg.ArticleSynthesisModel, "review_model": e.Cfg.ArticleReviewModel}})
brief, err := e.buildKnowledgeBrief(ctx, selected, researchResults, plan.ArticleType)
if err != nil {
var brief model.KnowledgeBrief
if e.RuntimeSettings().ProcessingMode == "clustered" {
brief = fallbackSynthesisBrief(plan, selected)
e.Broker.Publish(model.Activity{Type: "article.consolidation.fallback", Source: "brain", Phase: "knowledge-consolidation", NodeIDs: plan.SourceNodeIDs, Message: "Vorstrukturierung war nicht verfügbar · die Synthese arbeitet direkt mit Originalquellen und Recherchematerial weiter", Strength: .48, Metadata: map[string]any{"trigger": trigger, "error": err.Error(), "review_model": e.Cfg.ArticleReviewModel}})
e.Broker.Publish(model.Activity{Type: "article.consolidation.clustered", Source: "brain", Phase: "knowledge-consolidation", NodeIDs: plan.SourceNodeIDs, Message: "Cluster/Fast überspringt die zusätzliche LLM-Vorstrukturierung und arbeitet direkt mit Plan, Originalquellen und Recherchematerial", Strength: .42, Metadata: map[string]any{"trigger": trigger, "processing_mode": "clustered", "saved_model_call": true}})
} else {
var err error
brief, err = e.buildKnowledgeBrief(ctx, selected, researchResults, plan.ArticleType)
if err != nil {
brief = fallbackSynthesisBrief(plan, selected)
e.Broker.Publish(model.Activity{Type: "article.consolidation.fallback", Source: "brain", Phase: "knowledge-consolidation", NodeIDs: plan.SourceNodeIDs, Message: "Vorstrukturierung war nicht verfügbar · die Synthese arbeitet direkt mit Originalquellen und Recherchematerial weiter", Strength: .48, Metadata: map[string]any{"trigger": trigger, "error": err.Error(), "review_model": e.Cfg.ArticleReviewModel}})
}
}
// Generate-then-review pipeline: research collects broad full-text material.
@@ -151,10 +158,11 @@ func (e *Engine) synthesizeKnowledgeArticle(ctx context.Context, trigger string,
productionCount, aiCount, productionRatio, maxDepth = articleSourceStats(selected)
generationDepth = maxDepth + 1
quality, err = e.reviewArticleContent(ctx, draft, plan.ArticleType, selected, researchResults)
if err != nil {
return articleSynthesisOutcome{}, fmt.Errorf("article quality review with %s failed: %w", e.Cfg.ArticleReviewModel, err)
reviewedQuality, reviewErr := e.reviewArticleContent(ctx, draft, plan.ArticleType, selected, researchResults)
if reviewErr != nil {
return articleSynthesisOutcome{}, fmt.Errorf("article quality review with %s failed: %w", e.Cfg.ArticleReviewModel, reviewErr)
}
quality = reviewedQuality
draft.Confidence = quality.Confidence
claimCounts := articleClaimReviewCounts(quality.ClaimReviews)
e.Broker.Publish(model.Activity{Type: "article.review.completed", Source: "brain", Phase: "quality-gate", NodeIDs: draft.SourceNodeIDs, Message: fmt.Sprintf("%s hat den fertigen Entwurf Claim für Claim gegen die Quellen geprüft", e.Cfg.ArticleReviewModel), Strength: .88, Metadata: map[string]any{"trigger": trigger, "accepted": quality.Accepted, "confidence": quality.Confidence, "claim_reviews": len(quality.ClaimReviews), "supported_claims": claimCounts["supported"], "partially_supported_claims": claimCounts["partially_supported"], "unsupported_claims_count": claimCounts["unsupported"], "contradicted_claims": claimCounts["contradicted"], "missing_evidence_queries": quality.MissingEvidenceQueries, "issues": quality.Issues, "review_model": e.Cfg.ArticleReviewModel, "synthesis_model": e.Cfg.ArticleSynthesisModel, "repair_attempt": repairAttempts}})
@@ -215,6 +223,9 @@ func (e *Engine) synthesizeKnowledgeArticle(ctx context.Context, trigger string,
}
func (e *Engine) selectArticleSources(seeds []model.Node) []articleSource {
if e.RuntimeSettings().ProcessingMode == "clustered" {
return e.selectArticleSourcesClustered(seeds)
}
snapshot := e.Graph.Snapshot()
seedIDs := map[string]bool{}
var seedVectors [][]float64
@@ -306,6 +317,106 @@ func (e *Engine) selectArticleSources(seeds []model.Node) []articleSource {
return out
}
func (e *Engine) selectArticleSourcesClustered(seeds []model.Node) []articleSource {
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 := e.Graph.NeighborScores(seedIDs)
candidateIDs := map[string]bool{}
for id := range seedIDs {
candidateIDs[id] = true
}
for id := range direct {
candidateIDs[id] = true
}
stats := graph.ClusterSearchStats{}
if len(centroid) > 0 {
limit := e.Cfg.ClusterArticleCandidates
if limit < e.Cfg.ArticleMaxSources*4 {
limit = e.Cfg.ArticleMaxSources * 4
}
hits, searchStats := e.Graph.SimilarClusteredFiltered(centroid, limit, limit, e.effectiveThinkingFilter(), e.Cfg.ArticleMaxGenerationDepth, e.Cfg.ClusterHashBits, e.Cfg.ClusterHashTables)
stats = searchStats
for _, hit := range hits {
candidateIDs[hit.NodeID] = true
}
}
filter := e.effectiveThinkingFilter()
var production, ai []articleSource
for id := range candidateIDs {
node, ok := e.Graph.GetNode(id)
if !ok || (node.Kind != "knowledge" && node.Kind != "ai-think") || !filter.Matches(node) {
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)
maxSources := e.Cfg.ArticleMaxSources
if maxSources < 1 {
maxSources = 8
}
out := make([]articleSource, 0, maxSources)
for _, source := range production {
if len(out) >= maxSources {
break
}
out = append(out, source)
}
for _, source := range ai {
if len(out) >= maxSources {
break
}
prod, aiCount, _, _ := articleSourceStats(out)
if float64(aiCount+1)/float64(prod+aiCount+1) > 1-e.Cfg.ArticleMinProductionRatio {
continue
}
out = append(out, source)
}
if e.Broker != nil {
e.Broker.Publish(model.Activity{Type: "article.sources.clustered", Source: "brain", Phase: "candidate-search", NodeIDs: nodeIDsFromArticleSources(out), Message: fmt.Sprintf("Cluster/Fast reduzierte die Artikelquellenauswahl auf %d Kandidaten und %d Quellen", len(candidateIDs), len(out)), Strength: .45, Metadata: map[string]any{"processing_mode": "clustered", "candidate_nodes": len(candidateIDs), "selected_sources": len(out), "indexed_nodes": stats.IndexedNodes, "coarse_comparisons": stats.CoarseComparisons, "exact_comparisons": stats.ExactComparisons, "hash_bits": stats.HashBits, "hash_tables": stats.HashTables}})
}
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) != "" {
@@ -920,7 +1031,11 @@ func (e *Engine) generateArticleContent(ctx context.Context, sources []articleSo
func (e *Engine) reviewArticleContent(ctx context.Context, draft model.KnowledgeArticleDraft, articleType string, sources []articleSource, researchResults []model.ResearchResult) (model.ArticleQualityDecision, error) {
var decision model.ArticleQualityDecision
if err := e.Ollama.ChatJSONModel(ctx, e.Cfg.ArticleReviewModel, articleQualitySystemPrompt(e.Cfg.ArticleLanguage), e.articleQualityContext(draft, articleType, sources, researchResults), articleQualitySchema(), &decision); err != nil {
reviewResearch := researchResults
if e.RuntimeSettings().ProcessingMode == "clustered" {
reviewResearch = selectReviewEvidence(researchResults, e.Cfg.ClusterReviewEvidence)
}
if err := e.Ollama.ChatJSONModel(ctx, e.Cfg.ArticleReviewModel, articleQualitySystemPrompt(e.Cfg.ArticleLanguage), e.articleQualityContext(draft, articleType, sources, reviewResearch), articleQualitySchema(), &decision); err != nil {
return model.ArticleQualityDecision{}, err
}
if containsDraftMetaContent(draft) {
@@ -945,6 +1060,63 @@ func (e *Engine) reviewArticleContent(ctx context.Context, draft model.Knowledge
return decision, nil
}
func selectReviewEvidence(results []model.ResearchResult, limit int) []model.ResearchResult {
if limit <= 0 || len(results) <= limit {
return append([]model.ResearchResult(nil), results...)
}
type scored struct {
result model.ResearchResult
score float64
domain string
}
items := make([]scored, 0, len(results))
for _, result := range results {
score := result.Relevance*.48 + result.SourceQualityScore*.42
if result.Actionable {
score += .10
}
if result.Fetched {
score += .04
}
items = append(items, scored{result: result, score: score, domain: graph.SourceFromURL(result.URL)})
}
sort.SliceStable(items, func(i, j int) bool {
if items[i].score == items[j].score {
return items[i].result.URL < items[j].result.URL
}
return items[i].score > items[j].score
})
out := make([]model.ResearchResult, 0, limit)
seenDomain := map[string]bool{}
for _, item := range items {
if len(out) >= limit {
break
}
if item.domain != "" && seenDomain[item.domain] {
continue
}
out = append(out, item.result)
seenDomain[item.domain] = true
}
if len(out) < limit {
seenURL := map[string]bool{}
for _, result := range out {
seenURL[result.URL] = true
}
for _, item := range items {
if len(out) >= limit {
break
}
if seenURL[item.result.URL] {
continue
}
out = append(out, item.result)
seenURL[item.result.URL] = true
}
}
return out
}
func (e *Engine) articleRewriteContext(sources []articleSource, plan model.ArticlePlanDecision, brief model.KnowledgeBrief, researchResults []model.ResearchResult, rejected string) string {
var b strings.Builder
fmt.Fprintf(&b, "SCHREIBAUFTRAG: Formuliere einen vollständigen, direkt nutzbaren Helpdesk-Wissensartikel.\nARTIKELTYP: %s\n", nonempty(plan.ArticleType, "how_to"))
@@ -964,6 +1136,10 @@ func (e *Engine) articleRewriteContext(sources []articleSource, plan model.Artic
func (e *Engine) articleQualityContext(draft model.KnowledgeArticleDraft, articleType string, sources []articleSource, researchResults []model.ResearchResult) string {
var b strings.Builder
contextLimit := e.Cfg.MaxContextChars
if e.RuntimeSettings().ProcessingMode == "clustered" && e.Cfg.ClusterReviewContextChars > 0 && (contextLimit <= 0 || e.Cfg.ClusterReviewContextChars < contextLimit) {
contextLimit = e.Cfg.ClusterReviewContextChars
}
fmt.Fprintf(&b, "ZU PRÜFENDER SICHTBARER KB-ARTIKEL:\nARTIKELTYP: %s\n\nTITEL:\n", nonempty(articleType, "how_to"))
b.WriteString(draft.Title)
b.WriteString("\n\nPROBLEM / BESCHREIBUNG:\n")
@@ -971,10 +1147,10 @@ func (e *Engine) articleQualityContext(draft model.KnowledgeArticleDraft, articl
b.WriteString("\n\nLÖSUNG / ANTWORT:\n")
b.WriteString(formatArticleAnswer(draft, e.Cfg.ArticleLanguage))
b.WriteString("\n\nINTERNE BELEGQUELLEN:\n")
appendArticleSources(&b, sources, e.Cfg.MaxContextChars)
appendArticleSources(&b, sources, contextLimit)
if len(researchResults) > 0 {
b.WriteString("\nVOLLTEXT-RECHERCHEMATERIAL:\n")
appendResearchEvidence(&b, researchResults, e.Cfg.MaxContextChars)
appendResearchEvidence(&b, researchResults, contextLimit)
}
return b.String()
}

View File

@@ -5,6 +5,7 @@ import (
"testing"
"github.com/local/glpi-neural-brain/internal/config"
"github.com/local/glpi-neural-brain/internal/graph"
"github.com/local/glpi-neural-brain/internal/model"
)
@@ -111,3 +112,23 @@ func TestArticleDraftValidationMetadataIsStructured(t *testing.T) {
t.Fatalf("unexpected validation metadata: %#v", metadata)
}
}
func TestSelectReviewEvidenceLimitsAndDiversifies(t *testing.T) {
results := []model.ResearchResult{
{Title: "A1", URL: "https://a.example/1", Relevance: .9, SourceQualityScore: .9, Fetched: true},
{Title: "A2", URL: "https://a.example/2", Relevance: .89, SourceQualityScore: .9, Fetched: true},
{Title: "B", URL: "https://b.example/1", Relevance: .8, SourceQualityScore: .95, Fetched: true},
{Title: "C", URL: "https://c.example/1", Relevance: .7, SourceQualityScore: .8, Fetched: true},
}
selected := selectReviewEvidence(results, 3)
if len(selected) != 3 {
t.Fatalf("expected 3 evidence items, got %d", len(selected))
}
domains := map[string]bool{}
for _, result := range selected {
domains[graph.SourceFromURL(result.URL)] = true
}
if len(domains) != 3 {
t.Fatalf("expected domain diversity, got %+v", selected)
}
}

View File

@@ -494,7 +494,8 @@ func (e *Engine) resolveAutonomousTaskSeeds(ctx context.Context, task model.Rese
if err != nil || len(vecs) == 0 {
return out
}
for _, hit := range e.Graph.SimilarFiltered(vecs[0], e.Cfg.ArticleMaxSources*2, e.effectiveThinkingFilter()) {
hits, _ := e.similarKnowledge(vecs[0], e.Cfg.ArticleMaxSources*2, e.effectiveThinkingFilter(), e.Cfg.ArticleMaxGenerationDepth)
for _, hit := range hits {
if seen[hit.NodeID] {
continue
}

View File

@@ -34,14 +34,17 @@ var (
)
type EnrichOutcome struct {
Result string
Candidate bool
Created bool
RelationCreated bool
ArticleCreated bool
ArticleSkipped bool
Rejected bool
Comparisons int
Result string
Candidate bool
Created bool
RelationCreated bool
ArticleCreated bool
ArticleSkipped bool
Rejected bool
Comparisons int
CoarseComparisons int
IndexedNodes int
CandidatePool int
}
type Engine struct {
@@ -389,9 +392,10 @@ func (e *Engine) runEnrichmentCycle(ctx context.Context, trigger string) {
e.enrichCycles++
e.stateMu.Unlock()
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}})
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, "processing_mode": e.RuntimeSettings().ProcessingMode}})
created, rejected, checked := 0, 0, 0
exactComparisons, coarseComparisonsTotal, candidatePoolTotal := 0, 0, 0
relationsCreated, articlesCreated, articlesSkipped := 0, 0, 0
result := "completed"
var cycleErr error
@@ -413,6 +417,9 @@ func (e *Engine) runEnrichmentCycle(ctx context.Context, trigger string) {
break
}
checked++
exactComparisons += outcome.Comparisons
coarseComparisonsTotal += outcome.CoarseComparisons
candidatePoolTotal += outcome.CandidatePool
if outcome.Created {
created++
}
@@ -454,7 +461,7 @@ func (e *Engine) runEnrichmentCycle(ctx context.Context, trigger string) {
e.articlesSkipped += uint64(articlesSkipped)
e.stateMu.Unlock()
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}
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, "processing_mode": e.RuntimeSettings().ProcessingMode, "exact_comparisons": exactComparisons, "coarse_comparisons": coarseComparisonsTotal, "candidate_pool": candidatePoolTotal}
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)
@@ -636,6 +643,17 @@ func hashEmbedding(s string, dims int) []float64 {
return v
}
func (e *Engine) similarKnowledge(query []float64, limit int, filter graph.NodeFilter, maxAIDepth int) ([]model.Hit, graph.ClusterSearchStats) {
if e.RuntimeSettings().ProcessingMode != "clustered" {
return e.Graph.SimilarFiltered(query, limit, filter), graph.ClusterSearchStats{}
}
candidateLimit := e.Cfg.ClusterCandidatesPerAnchor
if candidateLimit < limit*12 {
candidateLimit = limit * 12
}
return e.Graph.SimilarClusteredFiltered(query, limit, candidateLimit, filter, maxAIDepth, e.Cfg.ClusterHashBits, e.Cfg.ClusterHashTables)
}
func (e *Engine) Query(ctx context.Context, q string) (model.QueryResponse, error) {
e.interactiveInflight.Add(1)
defer e.interactiveInflight.Add(-1)
@@ -649,7 +667,10 @@ func (e *Engine) Query(ctx context.Context, q string) (model.QueryResponse, erro
if err != nil || len(vecs) == 0 {
vecs = [][]float64{hashEmbedding(q, 256)}
}
hits := e.Graph.SimilarFiltered(vecs[0], e.Cfg.TopK, e.effectiveLearningFilter())
hits, retrievalStats := e.similarKnowledge(vecs[0], e.Cfg.TopK, e.effectiveLearningFilter(), 0)
if e.RuntimeSettings().ProcessingMode == "clustered" {
e.Broker.Publish(model.Activity{Type: "query.retrieval.clustered", Source: "brain", Phase: "retrieval", Query: q, Message: fmt.Sprintf("Cluster-Retrieval: %d exakte Cosine-Prüfungen nach %d Hash-Vergleichen", retrievalStats.ExactComparisons, retrievalStats.CoarseComparisons), Strength: .28, Metadata: map[string]any{"processing_mode": "clustered", "indexed_nodes": retrievalStats.IndexedNodes, "coarse_comparisons": retrievalStats.CoarseComparisons, "exact_comparisons": retrievalStats.ExactComparisons, "candidate_pool": retrievalStats.CandidatePool}})
}
nodeIDs := make([]string, 0, len(hits))
for i, h := range hits {
nodeIDs = append(nodeIDs, h.NodeID)
@@ -777,13 +798,27 @@ func (e *Engine) enrichOne(ctx context.Context, trigger string) (EnrichOutcome,
e.setOllamaOK(true)
}
a, b, sim, ok, comparisons := e.Graph.NextPairScopedDepth(e.Cfg.SimilarityThreshold, e.Cfg.EnrichAnchors, e.effectiveThinkingFilter(), e.Cfg.ArticleMaxGenerationDepth)
processingMode := e.RuntimeSettings().ProcessingMode
var a, b model.Node
var sim float64
var ok bool
comparisons, coarseComparisons, indexedNodes, candidatePool := 0, 0, 0, 0
if processingMode == "clustered" {
var stats graph.ClusterSearchStats
a, b, sim, ok, stats = e.Graph.NextPairClusteredScopedDepth(e.Cfg.SimilarityThreshold, e.Cfg.EnrichAnchors, e.effectiveThinkingFilter(), e.Cfg.ArticleMaxGenerationDepth, e.Cfg.ClusterHashBits, e.Cfg.ClusterHashTables, e.Cfg.ClusterCandidatesPerAnchor)
comparisons = stats.ExactComparisons
coarseComparisons = stats.CoarseComparisons
indexedNodes = stats.IndexedNodes
candidatePool = stats.CandidatePool
} else {
a, b, sim, ok, comparisons = e.Graph.NextPairScopedDepth(e.Cfg.SimilarityThreshold, e.Cfg.EnrichAnchors, e.effectiveThinkingFilter(), e.Cfg.ArticleMaxGenerationDepth)
}
if !ok {
e.stateMu.Lock()
e.lastAttempt = time.Now().UTC()
e.stateMu.Unlock()
e.Broker.Publish(model.Activity{Type: "think.no_candidate", Source: "brain", Phase: "candidate-search", Message: "Im aktuell geprüften Graphbereich wurde keine ungeprüfte Beziehung oberhalb des Ähnlichkeitsschwellwerts gefunden", Strength: .28, Metadata: map[string]any{"trigger": trigger, "threshold": e.Cfg.SimilarityThreshold, "anchors": e.Cfg.EnrichAnchors, "comparisons": comparisons}})
return EnrichOutcome{Result: "no_candidate", Comparisons: comparisons}, nil
e.Broker.Publish(model.Activity{Type: "think.no_candidate", Source: "brain", Phase: "candidate-search", Message: "Im aktuell geprüften Graphbereich wurde keine ungeprüfte Beziehung oberhalb des Ähnlichkeitsschwellwerts gefunden", Strength: .28, Metadata: map[string]any{"trigger": trigger, "threshold": e.Cfg.SimilarityThreshold, "anchors": e.Cfg.EnrichAnchors, "comparisons": comparisons, "exact_comparisons": comparisons, "coarse_comparisons": coarseComparisons, "indexed_nodes": indexedNodes, "candidate_pool": candidatePool, "processing_mode": processingMode}})
return EnrichOutcome{Result: "no_candidate", Comparisons: comparisons, CoarseComparisons: coarseComparisons, IndexedNodes: indexedNodes, CandidatePool: candidatePool}, nil
}
now := time.Now().UTC()
@@ -791,13 +826,13 @@ func (e *Engine) enrichOne(ctx context.Context, trigger string) (EnrichOutcome,
e.lastAttempt = now
e.lastEnrich = now
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}})
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, "exact_comparisons": comparisons, "coarse_comparisons": coarseComparisons, "indexed_nodes": indexedNodes, "candidate_pool": candidatePool, "processing_mode": processingMode}})
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}})
return EnrichOutcome{Result: "inference_failed", Candidate: true, Comparisons: comparisons}, fmt.Errorf("relation inference failed: %w", err)
return EnrichOutcome{Result: "inference_failed", Candidate: true, Comparisons: comparisons, CoarseComparisons: coarseComparisons, IndexedNodes: indexedNodes, CandidatePool: candidatePool}, fmt.Errorf("relation inference failed: %w", err)
}
var researchResults []model.ResearchResult
@@ -899,7 +934,7 @@ func (e *Engine) enrichOne(ctx context.Context, trigger string) (EnrichOutcome,
}
e.Graph.UpsertEdge(edge)
edge.ID = graph.EdgeID(edge.Source, edge.Target, edge.Type, edge.Origin)
outcome := EnrichOutcome{Result: status, Candidate: true, Comparisons: comparisons}
outcome := EnrichOutcome{Result: status, Candidate: true, Comparisons: comparisons, CoarseComparisons: coarseComparisons, IndexedNodes: indexedNodes, CandidatePool: candidatePool}
if status == "staging" {
outcome.Created = true
outcome.RelationCreated = true
@@ -956,6 +991,9 @@ func (e *Engine) Status() map[string]any {
"article_language": e.Cfg.ArticleLanguage, "article_synthesis_model": e.Cfg.ArticleSynthesisModel, "article_review_model": e.Cfg.ArticleReviewModel, "article_review_repair_rounds": e.Cfg.ArticleReviewRepairRounds, "article_pipeline": "research_generate_review", "research_dedupe": e.researchDedupeStatus(),
"enrich_interval": e.Cfg.EnrichInterval.String(),
"enrich_batch_size": e.Cfg.EnrichBatchSize, "enrich_anchors": e.Cfg.EnrichAnchors,
"processing_mode": e.RuntimeSettings().ProcessingMode, "cluster_hash_bits": e.Cfg.ClusterHashBits, "cluster_hash_tables": e.Cfg.ClusterHashTables,
"cluster_candidates_per_anchor": e.Cfg.ClusterCandidatesPerAnchor, "cluster_article_candidates": e.Cfg.ClusterArticleCandidates,
"cluster_review_evidence": e.Cfg.ClusterReviewEvidence, "cluster_review_context_chars": e.Cfg.ClusterReviewContextChars,
"research_enabled": e.ResearchEnabledForRuntime(), "chat_model": e.Cfg.ChatModel, "embedding_model": e.Cfg.EmbeddingModel,
"searxng": e.ResearchStatus(),
"ollama_pool": e.Ollama.PoolStatus(), "article_model_status": map[string]any{"synthesis": e.Ollama.ModelStatus(e.Cfg.ArticleSynthesisModel), "review": e.Ollama.ModelStatus(e.Cfg.ArticleReviewModel)}, "persistence": e.Persistence.Status(), "graph_storage": e.Graph.StorageStatus(),

View File

@@ -21,6 +21,7 @@ type RuntimeSettings struct {
ViewMode string `json:"view_mode"`
MaxDisplayNodes int `json:"max_display_nodes"`
LowPowerMode bool `json:"low_power_mode"`
ProcessingMode string `json:"processing_mode"`
AutonomousResearchEnabled bool `json:"autonomous_research_enabled"`
AutonomousResearchIdleOnly bool `json:"autonomous_research_idle_only"`
AutonomousResearchMinPriority float64 `json:"autonomous_research_min_priority"`
@@ -52,6 +53,7 @@ func (e *Engine) defaultRuntimeSettings() RuntimeSettings {
ViewMode: e.Cfg.DefaultView,
MaxDisplayNodes: e.Cfg.MaxDisplayNodes,
LowPowerMode: e.Cfg.LowPowerMode,
ProcessingMode: e.Cfg.ProcessingMode,
AutonomousResearchEnabled: e.Cfg.AutonomousResearchEnabled,
AutonomousResearchIdleOnly: e.Cfg.AutonomousResearchIdleOnly,
AutonomousResearchMinPriority: e.Cfg.AutonomousResearchMinPriority,
@@ -74,6 +76,10 @@ func normalizeRuntimeSettings(in RuntimeSettings) RuntimeSettings {
if in.ViewMode != "neural" && in.ViewMode != "honeycomb" && in.ViewMode != "constellation" {
in.ViewMode = "neural"
}
in.ProcessingMode = strings.ToLower(strings.TrimSpace(in.ProcessingMode))
if in.ProcessingMode != "clustered" {
in.ProcessingMode = "precise"
}
if in.MaxDisplayNodes < 0 {
in.MaxDisplayNodes = 0
}
@@ -166,6 +172,7 @@ func mergeRuntimeSettingsJSON(settings *RuntimeSettings, data []byte) {
decode("view_mode", &settings.ViewMode)
decode("max_display_nodes", &settings.MaxDisplayNodes)
decode("low_power_mode", &settings.LowPowerMode)
decode("processing_mode", &settings.ProcessingMode)
decode("autonomous_research_enabled", &settings.AutonomousResearchEnabled)
decode("autonomous_research_idle_only", &settings.AutonomousResearchIdleOnly)
decode("autonomous_research_min_priority", &settings.AutonomousResearchMinPriority)
@@ -210,6 +217,12 @@ func (e *Engine) SetRuntimeSettings(settings RuntimeSettings) (RuntimeSettings,
if settings.AutonomousResearchTasksPerCycle == 0 {
settings.AutonomousResearchTasksPerCycle = previous.AutonomousResearchTasksPerCycle
}
if settings.ProcessingMode == "" {
settings.ProcessingMode = previous.ProcessingMode
}
if settings.ProcessingMode != "precise" && settings.ProcessingMode != "clustered" {
return e.RuntimeSettings(), fmt.Errorf("processing_mode must be precise or clustered")
}
if settings.MaxDisplayNodes < 0 || settings.MaxDisplayNodes > 500000 {
return e.RuntimeSettings(), fmt.Errorf("max_display_nodes must be between 0 and 500000")
}
@@ -272,6 +285,7 @@ func (e *Engine) SetRuntimeSettings(settings RuntimeSettings) (RuntimeSettings,
"view_mode": settings.ViewMode,
"max_display_nodes": settings.MaxDisplayNodes,
"low_power_mode": settings.LowPowerMode,
"processing_mode": settings.ProcessingMode,
"autonomous_research_enabled": settings.AutonomousResearchEnabled,
"autonomous_research_idle_only": settings.AutonomousResearchIdleOnly,
"autonomous_research_min_priority": settings.AutonomousResearchMinPriority,

View File

@@ -89,3 +89,14 @@ func TestRuntimeJSONPatchPreservesAutonomousFields(t *testing.T) {
t.Fatalf("partial runtime patch was not applied: %+v", settings)
}
}
func TestNormalizeRuntimeSettingsProcessingMode(t *testing.T) {
settings := normalizeRuntimeSettings(RuntimeSettings{ProcessingMode: "clustered", AutonomousResearchMaxTasksPerDay: 1, AutonomousResearchTasksPerCycle: 1})
if settings.ProcessingMode != "clustered" {
t.Fatalf("clustered mode lost: %+v", settings)
}
settings = normalizeRuntimeSettings(RuntimeSettings{ProcessingMode: "unknown", AutonomousResearchMaxTasksPerDay: 1, AutonomousResearchTasksPerCycle: 1})
if settings.ProcessingMode != "precise" {
t.Fatalf("invalid mode should fall back to precise: %+v", settings)
}
}