Cluster-Work
This commit is contained in:
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user