This commit is contained in:
@@ -105,7 +105,6 @@ func (e *Engine) synthesizeKnowledgeArticle(ctx context.Context, trigger string,
|
||||
return articleSynthesisOutcome{Skipped: true, Reason: "required_research_empty", Action: plan.Action}, nil
|
||||
}
|
||||
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)
|
||||
@@ -460,16 +459,30 @@ func (e *Engine) researchKnowledgeGaps(ctx context.Context, trigger string, node
|
||||
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}})
|
||||
researchID := newResearchRunID("article-research", query)
|
||||
started := time.Now()
|
||||
startMetadata := map[string]any{"trigger": trigger, "research_id": researchID, "research_query": query, "animation_min_ms": 2000}
|
||||
e.Broker.Publish(model.Activity{Type: "article.research.started", Source: "searxng", Phase: "knowledge-research", NodeIDs: nodeIDs, Message: "Ein ungeklärter fachlicher Punkt wird mit SearXNG recherchiert", Strength: .9, Metadata: startMetadata})
|
||||
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()}})
|
||||
e.Broker.Publish(model.Activity{Type: "article.research.failed", Source: "searxng", Phase: "knowledge-research", NodeIDs: nodeIDs, Message: "Die ergänzende Artikelrecherche ist fehlgeschlagen", Strength: .35, Metadata: mergeResearchMetadata(startMetadata, map[string]any{"error": err.Error(), "duration_ms": time.Since(started).Milliseconds()})})
|
||||
return nil, err
|
||||
}
|
||||
resultMetadata := researchEventMetadata(trigger, researchID, query, results, time.Since(started))
|
||||
message := fmt.Sprintf("SearXNG hat %d Quellen für den offenen Wissenspunkt geliefert", len(results))
|
||||
if len(results) == 0 {
|
||||
message = "SearXNG hat für den offenen Wissenspunkt keine verwertbare Quelle geliefert"
|
||||
}
|
||||
e.Broker.Publish(model.Activity{Type: "article.research.results", Source: "searxng", Phase: "knowledge-research-results", NodeIDs: nodeIDs, Message: message, Strength: .94, Metadata: resultMetadata})
|
||||
if len(results) > 0 {
|
||||
refs := e.addResearchToNodeIDs(nodeIDs, results)
|
||||
ingestMetadata := mergeResearchMetadata(resultMetadata, map[string]any{"result_node_ids": refs.NodeIDs, "result_edge_ids": refs.EdgeIDs, "source_node_ids": nodeIDs})
|
||||
e.Broker.Publish(model.Activity{Type: "article.research.ingested", Source: "searxng", Phase: "knowledge-research-ingest", NodeIDs: append(append([]string{}, nodeIDs...), refs.NodeIDs...), EdgeIDs: refs.EdgeIDs, Message: fmt.Sprintf("%d recherchierte Quellen wurden als neue Forschungs-Nodes verknüpft", len(refs.NodeIDs)), Strength: 1, Metadata: ingestMetadata})
|
||||
}
|
||||
for _, result := range results {
|
||||
key := strings.TrimSpace(result.URL)
|
||||
if key == "" {
|
||||
@@ -957,14 +970,34 @@ func (e *Engine) learnRuntimeArticle(ctx context.Context, articleID string) {
|
||||
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) {
|
||||
func (e *Engine) addResearchToNodeIDs(nodeIDs []string, results []model.ResearchResult) researchGraphRefs {
|
||||
refs := researchGraphRefs{}
|
||||
for _, result := range results {
|
||||
id := graph.ID("external", result.URL)
|
||||
e.Graph.UpsertNode(model.Node{ID: id, Kind: "external", Label: result.Title, Summary: clamp(result.Content, 900), Status: "research", Origin: "research", ExternalID: result.URL, URI: result.URL, Weight: .8, UpdatedAt: time.Now().UTC()})
|
||||
for _, source := range sources {
|
||||
e.Graph.UpsertEdge(model.Edge{Source: id, Target: source.Node.ID, Type: "research_evidence", Origin: "research", Status: "staging", Confidence: .55, Weight: .4})
|
||||
refs.NodeIDs = append(refs.NodeIDs, id)
|
||||
for _, targetID := range nodeIDs {
|
||||
edge := model.Edge{Source: id, Target: targetID, Type: "research_evidence", Origin: "research", Status: "staging", Confidence: .55, Weight: .4}
|
||||
e.Graph.UpsertEdge(edge)
|
||||
refs.EdgeIDs = append(refs.EdgeIDs, graph.EdgeID(edge.Source, edge.Target, edge.Type, edge.Origin))
|
||||
}
|
||||
}
|
||||
return uniqueResearchRefs(refs)
|
||||
}
|
||||
|
||||
func (e *Engine) addResearchToSources(sources []articleSource, results []model.ResearchResult) researchGraphRefs {
|
||||
refs := researchGraphRefs{}
|
||||
for _, result := range results {
|
||||
id := graph.ID("external", result.URL)
|
||||
e.Graph.UpsertNode(model.Node{ID: id, Kind: "external", Label: result.Title, Summary: clamp(result.Content, 900), Status: "research", Origin: "research", ExternalID: result.URL, URI: result.URL, Weight: .8, UpdatedAt: time.Now().UTC()})
|
||||
refs.NodeIDs = append(refs.NodeIDs, id)
|
||||
for _, source := range sources {
|
||||
edge := model.Edge{Source: id, Target: source.Node.ID, Type: "research_evidence", Origin: "research", Status: "staging", Confidence: .55, Weight: .4}
|
||||
e.Graph.UpsertEdge(edge)
|
||||
refs.EdgeIDs = append(refs.EdgeIDs, graph.EdgeID(edge.Source, edge.Target, edge.Type, edge.Origin))
|
||||
}
|
||||
}
|
||||
return uniqueResearchRefs(refs)
|
||||
}
|
||||
|
||||
func formatArticleAnswer(draft model.KnowledgeArticleDraft) string {
|
||||
|
||||
+33
-13
@@ -627,19 +627,33 @@ func (e *Engine) enrichOne(ctx context.Context, trigger string) (EnrichOutcome,
|
||||
|
||||
var researchResults []model.ResearchResult
|
||||
if decision.NeedsResearch && e.Cfg.ResearchEnabled && e.Research != nil && strings.TrimSpace(decision.ResearchQuery) != "" {
|
||||
e.Broker.Publish(model.Activity{Type: "research.started", Source: "brain", Phase: "research", NodeIDs: []string{a.ID, b.ID}, Message: "Unklarheit erkannt · kontrollierte Webrecherche startet", Strength: .9, Metadata: map[string]any{"trigger": trigger, "research_query": decision.ResearchQuery, "source_label": a.Label, "target_label": b.Label}})
|
||||
researchID := newResearchRunID("relation-research", decision.ResearchQuery)
|
||||
researchStarted := time.Now()
|
||||
startMetadata := map[string]any{"trigger": trigger, "research_id": researchID, "research_query": decision.ResearchQuery, "source_label": a.Label, "target_label": b.Label, "animation_min_ms": 2000}
|
||||
e.Broker.Publish(model.Activity{Type: "research.started", Source: "searxng", Phase: "research", NodeIDs: []string{a.ID, b.ID}, Message: "Unklarheit erkannt · SearXNG durchsucht externe Quellen", Strength: .9, Metadata: startMetadata})
|
||||
results, err := e.Research.Search(ctx, decision.ResearchQuery, 4)
|
||||
if err != nil {
|
||||
slog.Warn("research failed", "error", err)
|
||||
} else if len(results) > 0 {
|
||||
researchResults = results
|
||||
e.addResearch(a, b, results)
|
||||
var reviewed model.RelationDecision
|
||||
reviewSystem := "Bewerte die Beziehung erneut anhand der zwei internen Wissenseinträge und der beigefügten Web-Suchergebnisse. Suchtreffer sind Hinweise, keine garantierten Fakten. Erfinde nichts, kennzeichne verbleibende Unsicherheit und gib ausschließlich JSON nach Schema zurück."
|
||||
if err := e.Ollama.ChatJSON(ctx, reviewSystem, relationContextWithResearch(a, b, sim, results), relationSchema(), &reviewed); err != nil {
|
||||
slog.Warn("research review failed; keeping pre-research decision", "error", err)
|
||||
} else {
|
||||
decision = reviewed
|
||||
e.Broker.Publish(model.Activity{Type: "research.failed", Source: "searxng", Phase: "research", NodeIDs: []string{a.ID, b.ID}, Message: "SearXNG-Recherche ist fehlgeschlagen", Strength: .35, Metadata: mergeResearchMetadata(startMetadata, map[string]any{"error": err.Error(), "duration_ms": time.Since(researchStarted).Milliseconds()})})
|
||||
} else {
|
||||
resultMetadata := researchEventMetadata(trigger, researchID, decision.ResearchQuery, results, time.Since(researchStarted))
|
||||
message := fmt.Sprintf("SearXNG hat %d verwertbare Webquellen geliefert", len(results))
|
||||
if len(results) == 0 {
|
||||
message = "SearXNG hat keine verwertbaren Webquellen geliefert"
|
||||
}
|
||||
e.Broker.Publish(model.Activity{Type: "research.results", Source: "searxng", Phase: "research-results", NodeIDs: []string{a.ID, b.ID}, Message: message, Strength: .92, Metadata: resultMetadata})
|
||||
if len(results) > 0 {
|
||||
researchResults = results
|
||||
refs := e.addResearch(a, b, results)
|
||||
ingestMetadata := mergeResearchMetadata(resultMetadata, map[string]any{"result_node_ids": refs.NodeIDs, "result_edge_ids": refs.EdgeIDs})
|
||||
e.Broker.Publish(model.Activity{Type: "research.ingested", Source: "searxng", Phase: "research-ingest", NodeIDs: append([]string{a.ID, b.ID}, refs.NodeIDs...), EdgeIDs: refs.EdgeIDs, Message: fmt.Sprintf("%d Webquellen wurden als neue Forschungs-Nodes in den Graphen übernommen", len(refs.NodeIDs)), Strength: 1, Metadata: ingestMetadata})
|
||||
var reviewed model.RelationDecision
|
||||
reviewSystem := "Bewerte die Beziehung erneut anhand der zwei internen Wissenseinträge und der beigefügten Web-Suchergebnisse. Suchtreffer sind Hinweise, keine garantierten Fakten. Erfinde nichts, kennzeichne verbleibende Unsicherheit und gib ausschließlich JSON nach Schema zurück."
|
||||
if err := e.Ollama.ChatJSON(ctx, reviewSystem, relationContextWithResearch(a, b, sim, results), relationSchema(), &reviewed); err != nil {
|
||||
slog.Warn("research review failed; keeping pre-research decision", "error", err)
|
||||
} else {
|
||||
decision = reviewed
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -676,14 +690,20 @@ func (e *Engine) enrichOne(ctx context.Context, trigger string) (EnrichOutcome,
|
||||
return outcome, nil
|
||||
}
|
||||
|
||||
func (e *Engine) addResearch(a, b model.Node, results []model.ResearchResult) {
|
||||
func (e *Engine) addResearch(a, b model.Node, results []model.ResearchResult) researchGraphRefs {
|
||||
refs := researchGraphRefs{}
|
||||
for _, r := range results {
|
||||
id := graph.ID("external", r.URL)
|
||||
n := model.Node{ID: id, Kind: "external", Label: r.Title, Summary: clamp(r.Content, 700), Status: "research", Origin: "research", ExternalID: r.URL, URI: r.URL, Weight: .8, Metadata: map[string]any{"query_pair": []string{a.ID, b.ID}}, UpdatedAt: time.Now().UTC()}
|
||||
e.Graph.UpsertNode(n)
|
||||
e.Graph.UpsertEdge(model.Edge{Source: id, Target: a.ID, Type: "research_evidence", Origin: "research", Status: "staging", Confidence: .55, Weight: .4})
|
||||
e.Graph.UpsertEdge(model.Edge{Source: id, Target: b.ID, Type: "research_evidence", Origin: "research", Status: "staging", Confidence: .55, Weight: .4})
|
||||
refs.NodeIDs = append(refs.NodeIDs, id)
|
||||
for _, targetID := range []string{a.ID, b.ID} {
|
||||
edge := model.Edge{Source: id, Target: targetID, Type: "research_evidence", Origin: "research", Status: "staging", Confidence: .55, Weight: .4}
|
||||
e.Graph.UpsertEdge(edge)
|
||||
refs.EdgeIDs = append(refs.EdgeIDs, graph.EdgeID(edge.Source, edge.Target, edge.Type, edge.Origin))
|
||||
}
|
||||
}
|
||||
return uniqueResearchRefs(refs)
|
||||
}
|
||||
func (e *Engine) Status() map[string]any {
|
||||
s := e.Graph.Snapshot()
|
||||
|
||||
@@ -246,7 +246,8 @@ func TestSynthesisResearchesUnclearKnowledgeThenLearnsAndLinksArticle(t *testing
|
||||
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))
|
||||
broker := activity.New(100)
|
||||
e := New(cfg, g, broker)
|
||||
if err := e.Scan(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -287,6 +288,29 @@ func TestSynthesisResearchesUnclearKnowledgeThenLearnsAndLinksArticle(t *testing
|
||||
if chatCalls != 6 {
|
||||
t.Fatalf("expected relation, plan, two consolidations, article and quality calls, got %d", chatCalls)
|
||||
}
|
||||
var resultEvent, ingestEvent *model.Activity
|
||||
for _, event := range broker.Recent() {
|
||||
event := event
|
||||
switch event.Type {
|
||||
case "article.research.results":
|
||||
resultEvent = &event
|
||||
case "article.research.ingested":
|
||||
ingestEvent = &event
|
||||
}
|
||||
}
|
||||
if resultEvent == nil || ingestEvent == nil {
|
||||
t.Fatalf("expected visible SearXNG result and ingest events, results=%v ingest=%v", resultEvent != nil, ingestEvent != nil)
|
||||
}
|
||||
if count, _ := resultEvent.Metadata["result_count"].(int); count != 1 {
|
||||
t.Fatalf("unexpected result count metadata: %#v", resultEvent.Metadata["result_count"])
|
||||
}
|
||||
resultNodeIDs, _ := ingestEvent.Metadata["result_node_ids"].([]string)
|
||||
if len(resultNodeIDs) != 1 || resultNodeIDs[0] != researchID {
|
||||
t.Fatalf("unexpected ingested research nodes: %#v", ingestEvent.Metadata["result_node_ids"])
|
||||
}
|
||||
if ingestEvent.Metadata["animation_min_ms"] != 2000 {
|
||||
t.Fatalf("research animation minimum missing: %#v", ingestEvent.Metadata["animation_min_ms"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestEnrichDoesNotQueueDuplicateCycle(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/local/glpi-neural-brain/internal/graph"
|
||||
"github.com/local/glpi-neural-brain/internal/model"
|
||||
)
|
||||
|
||||
type researchGraphRefs struct {
|
||||
NodeIDs []string
|
||||
EdgeIDs []string
|
||||
}
|
||||
|
||||
func newResearchRunID(prefix, query string) string {
|
||||
return graph.ID(prefix, strings.TrimSpace(query), fmt.Sprintf("%d", time.Now().UnixNano()))
|
||||
}
|
||||
|
||||
func researchEventMetadata(trigger, researchID, query string, results []model.ResearchResult, elapsed time.Duration) map[string]any {
|
||||
titles := make([]string, 0, min(5, len(results)))
|
||||
domains := make([]string, 0, min(5, len(results)))
|
||||
urls := make([]string, 0, min(5, len(results)))
|
||||
seenDomains := map[string]bool{}
|
||||
for _, result := range results {
|
||||
if title := strings.TrimSpace(result.Title); title != "" && len(titles) < 5 {
|
||||
titles = append(titles, title)
|
||||
}
|
||||
if rawURL := strings.TrimSpace(result.URL); rawURL != "" {
|
||||
if len(urls) < 5 {
|
||||
urls = append(urls, rawURL)
|
||||
}
|
||||
if parsed, err := url.Parse(rawURL); err == nil {
|
||||
domain := strings.TrimPrefix(strings.ToLower(parsed.Hostname()), "www.")
|
||||
if domain != "" && !seenDomains[domain] && len(domains) < 5 {
|
||||
seenDomains[domain] = true
|
||||
domains = append(domains, domain)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Strings(domains)
|
||||
return map[string]any{
|
||||
"trigger": trigger,
|
||||
"research_id": researchID,
|
||||
"research_query": query,
|
||||
"result_count": len(results),
|
||||
"result_titles": titles,
|
||||
"result_domains": domains,
|
||||
"result_urls": urls,
|
||||
"duration_ms": elapsed.Milliseconds(),
|
||||
"animation_min_ms": 2000,
|
||||
}
|
||||
}
|
||||
|
||||
func mergeResearchMetadata(base map[string]any, extra map[string]any) map[string]any {
|
||||
out := make(map[string]any, len(base)+len(extra))
|
||||
for key, value := range base {
|
||||
out[key] = value
|
||||
}
|
||||
for key, value := range extra {
|
||||
out[key] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func uniqueResearchRefs(refs researchGraphRefs) researchGraphRefs {
|
||||
refs.NodeIDs = unique(refs.NodeIDs)
|
||||
refs.EdgeIDs = unique(refs.EdgeIDs)
|
||||
return refs
|
||||
}
|
||||
@@ -47,3 +47,6 @@ body.honeycomb-view .legend{opacity:.58}body.honeycomb-view .mode-status small:a
|
||||
@media(max-width:780px){.settings-panel{left:10px;width:auto}.dock{max-width:calc(100vw - 20px)}.dock-separator{display:none}}
|
||||
|
||||
.performance-settings{flex:0 0 auto}.number-setting{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:10px;margin:7px 0;border:1px solid rgba(133,200,255,.1);border-radius:12px;background:rgba(255,255,255,.022)}.number-setting b{display:block;font-size:11px}.number-setting small{display:block;margin-top:3px;max-width:250px;color:#718b9e;font-size:9px;line-height:1.4}.number-setting input{width:112px;flex:0 0 auto;border:1px solid rgba(82,231,255,.2);background:rgba(2,8,17,.72);color:var(--text);border-radius:10px;padding:9px 8px;text-align:right;font-variant-numeric:tabular-nums;outline:0}.number-setting input:focus{border-color:rgba(82,231,255,.48);box-shadow:0 0 0 3px rgba(82,231,255,.07)}.node-limit-presets{display:flex;flex-wrap:wrap;gap:5px;margin-top:8px}.node-limit-presets button{border:1px solid rgba(133,200,255,.12);background:rgba(255,255,255,.025);color:#7893a6;border-radius:999px;padding:5px 8px;font-size:8px;cursor:pointer}.node-limit-presets button:hover{color:var(--cyan);border-color:rgba(82,231,255,.3);background:rgba(82,231,255,.08)}.setting-hint{margin:8px 2px 0;color:#6f899d;font-size:9px;line-height:1.4}
|
||||
.activity-item .sources{display:grid;gap:4px;margin-top:7px;padding-top:6px;border-top:1px solid rgba(93,255,189,.1)}
|
||||
.activity-item .sources span{display:flex;align-items:flex-start;gap:6px;color:#b7d9ca;font-size:9px;line-height:1.35}
|
||||
.activity-item .sources i{display:inline-flex;align-items:center;justify-content:center;flex:0 0 15px;height:15px;border-radius:50%;border:1px solid rgba(93,255,189,.24);background:rgba(93,255,189,.07);color:#86ffd0;font-style:normal;font-size:8px}
|
||||
|
||||
+268
-4
@@ -43,7 +43,8 @@
|
||||
edgeRenderMap: new Map(), renderActive: new Map(), renderEdgeActive: new Map(), renderStats: {nodes: 0, edges: 0, hiddenNodes: 0, hiddenEdges: 0},
|
||||
fullSnapshot: null, fullNodeById: new Map(), runtimeSettings: {learning_enabled: true, thinking_enabled: true, learning_categories: [], display_categories: [], thinking_categories: [], view_mode: 'neural', max_display_nodes: 0},
|
||||
availableCategories: [], viewMode: 'neural', honeycombNodes: [], honeycombSpacing: 0, settingsOpen: false, settingsDraft: null,
|
||||
forcedDisplayUntil: new Map(), nextDisplayLimitExpiry: 0, graphVersion: null, displaySignature: '', displayLimitStats: {limit: 0, eligible: 0, shown: 0}
|
||||
forcedDisplayUntil: new Map(), nextDisplayLimitExpiry: 0, graphVersion: null, displaySignature: '', displayLimitStats: {limit: 0, eligible: 0, shown: 0},
|
||||
researchAnimations: new Map(), researchSequence: 0
|
||||
};
|
||||
|
||||
function resize() {
|
||||
@@ -1236,6 +1237,259 @@
|
||||
return rgba((MODE_CONFIG[mode] || MODE_CONFIG.living).color, 0.92);
|
||||
}
|
||||
|
||||
function researchEventID(evt) {
|
||||
return String(evt?.metadata?.research_id || `${evt?.type || 'research'}:${evt?.metadata?.research_query || evt?.query || ++state.researchSequence}`);
|
||||
}
|
||||
|
||||
function researchResultIDs(evt) {
|
||||
const explicit = Array.isArray(evt?.metadata?.result_node_ids) ? evt.metadata.result_node_ids : [];
|
||||
if (explicit.length) return explicit.map(String);
|
||||
return (evt?.node_ids || []).filter(id => state.fullNodeById.get(id)?.kind === 'external' || state.nodeById.get(id)?.kind === 'external');
|
||||
}
|
||||
|
||||
function researchSourceIDs(evt) {
|
||||
const explicit = Array.isArray(evt?.metadata?.source_node_ids) ? evt.metadata.source_node_ids : [];
|
||||
if (explicit.length) return explicit.map(String);
|
||||
const resultIDs = new Set(researchResultIDs(evt));
|
||||
return (evt?.node_ids || []).filter(id => !resultIDs.has(id));
|
||||
}
|
||||
|
||||
function brainViewportCenter() {
|
||||
const panelOffset = state.width > 1000 ? 110 : state.width > 780 ? 60 : 0;
|
||||
return {x: state.width / 2 + panelOffset, y: state.height / 2 - 4};
|
||||
}
|
||||
|
||||
function visibleScreenForNode(id) {
|
||||
const visibleID = state.visibleForNode.get(id);
|
||||
const renderNode = visibleID ? state.renderNodeById.get(visibleID) : null;
|
||||
if (renderNode?.screen) return renderNode.screen;
|
||||
const node = state.nodeById.get(id);
|
||||
if (node?.screen) return node.screen;
|
||||
return null;
|
||||
}
|
||||
|
||||
function averageScreen(ids, fallback) {
|
||||
let x = 0, y = 0, count = 0;
|
||||
for (const id of ids || []) {
|
||||
const point = visibleScreenForNode(id);
|
||||
if (!point) continue;
|
||||
x += point.x;
|
||||
y += point.y;
|
||||
count++;
|
||||
}
|
||||
return count ? {x: x / count, y: y / count} : fallback;
|
||||
}
|
||||
|
||||
function researchSourcePoints(animation, count) {
|
||||
const desired = Math.max(3, Math.min(7, count || 5));
|
||||
if (animation.sourcePoints?.length === desired) return animation.sourcePoints;
|
||||
const center = brainViewportCenter();
|
||||
const rx = Math.min(state.width * 0.34, state.height * 0.43);
|
||||
const ry = Math.min(state.height * 0.39, state.width * 0.28);
|
||||
const seed = hashString(animation.id);
|
||||
animation.sourcePoints = Array.from({length: desired}, (_, index) => {
|
||||
const angle = (index / desired) * Math.PI * 2 + (seed % 1000) / 1000 * Math.PI * 2;
|
||||
return {
|
||||
x: center.x + Math.cos(angle) * rx * (0.9 + pseudo(animation.id, index + 40) * 0.18),
|
||||
y: center.y + Math.sin(angle) * ry * (0.88 + pseudo(animation.id, index + 70) * 0.2),
|
||||
phase: pseudo(animation.id, index + 100) * Math.PI * 2
|
||||
};
|
||||
});
|
||||
return animation.sourcePoints;
|
||||
}
|
||||
|
||||
function handleResearchAnimation(evt) {
|
||||
if (!evt?.type?.includes('research')) return;
|
||||
const timestamp = Date.parse(evt.timestamp || '') || Date.now();
|
||||
const id = researchEventID(evt);
|
||||
let animation = state.researchAnimations.get(id);
|
||||
if (!animation && Date.now() - timestamp > 30000) return;
|
||||
const now = performance.now();
|
||||
if (!animation) {
|
||||
animation = {
|
||||
id,
|
||||
stage: 'searching',
|
||||
startedAt: now,
|
||||
updatedAt: now,
|
||||
until: Number.POSITIVE_INFINITY,
|
||||
query: String(evt.metadata?.research_query || evt.query || ''),
|
||||
sourceNodeIDs: researchSourceIDs(evt),
|
||||
resultNodeIDs: [],
|
||||
resultCount: 0,
|
||||
titles: [],
|
||||
domains: [],
|
||||
sourcePoints: [],
|
||||
fallbackAnchor: brainViewportCenter()
|
||||
};
|
||||
state.researchAnimations.set(id, animation);
|
||||
}
|
||||
animation.updatedAt = now;
|
||||
animation.query = String(evt.metadata?.research_query || animation.query || '');
|
||||
const sourceIDs = researchSourceIDs(evt);
|
||||
if (sourceIDs.length) animation.sourceNodeIDs = [...new Set([...(animation.sourceNodeIDs || []), ...sourceIDs])];
|
||||
const minMS = Math.max(2000, Number(evt.metadata?.animation_min_ms || 2000));
|
||||
if (evt.type.endsWith('.started')) {
|
||||
animation.stage = 'searching';
|
||||
animation.until = Number.POSITIVE_INFINITY;
|
||||
} else if (evt.type.endsWith('.results')) {
|
||||
animation.resultCount = Number(evt.metadata?.result_count || 0);
|
||||
animation.titles = Array.isArray(evt.metadata?.result_titles) ? evt.metadata.result_titles.map(String).slice(0, 5) : [];
|
||||
animation.domains = Array.isArray(evt.metadata?.result_domains) ? evt.metadata.result_domains.map(String).slice(0, 5) : [];
|
||||
animation.stage = animation.resultCount > 0 ? 'results' : 'empty';
|
||||
animation.resultsAt = now;
|
||||
animation.until = now + minMS;
|
||||
researchSourcePoints(animation, animation.resultCount || 4);
|
||||
} else if (evt.type.endsWith('.ingested')) {
|
||||
const resultIDs = researchResultIDs(evt);
|
||||
animation.resultNodeIDs = [...new Set([...(animation.resultNodeIDs || []), ...resultIDs])];
|
||||
animation.resultCount = Number(evt.metadata?.result_count || animation.resultCount || resultIDs.length);
|
||||
animation.titles = Array.isArray(evt.metadata?.result_titles) ? evt.metadata.result_titles.map(String).slice(0, 5) : animation.titles;
|
||||
animation.domains = Array.isArray(evt.metadata?.result_domains) ? evt.metadata.result_domains.map(String).slice(0, 5) : animation.domains;
|
||||
animation.stage = 'ingested';
|
||||
animation.ingestedAt = now;
|
||||
animation.until = Number.POSITIVE_INFINITY;
|
||||
researchSourcePoints(animation, animation.resultCount || resultIDs.length || 4);
|
||||
refreshResearchNodes(animation, minMS);
|
||||
} else if (evt.type.endsWith('.failed')) {
|
||||
animation.stage = 'failed';
|
||||
animation.error = String(evt.metadata?.error || 'Recherche fehlgeschlagen');
|
||||
animation.until = now + minMS;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshResearchNodes(animation, minMS) {
|
||||
try {
|
||||
await loadGraph();
|
||||
const ids = animation.resultNodeIDs || [];
|
||||
forceDisplayNodes(ids, Math.max(6000, minMS + 4000));
|
||||
if (state.fullSnapshot && ids.some(id => !state.nodeById.has(id))) applyGraphSnapshot(state.fullSnapshot, true);
|
||||
for (const id of ids) {
|
||||
state.active.set(id, Math.max(state.active.get(id) || 0, 1.15));
|
||||
const visibleID = state.visibleForNode.get(id);
|
||||
if (visibleID) state.renderActive.set(visibleID, Math.max(state.renderActive.get(visibleID) || 0, 1.15));
|
||||
}
|
||||
animation.stage = 'ingested';
|
||||
animation.ingestedAt = performance.now();
|
||||
animation.until = animation.ingestedAt + Math.max(2000, minMS);
|
||||
} catch {
|
||||
animation.until = Math.max(animation.until || 0, performance.now() + Math.max(2000, minMS));
|
||||
}
|
||||
}
|
||||
|
||||
function drawResearchHex(x, y, radius, color, alpha, rotation = 0) {
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const angle = rotation + i / 6 * Math.PI * 2;
|
||||
const px = x + Math.cos(angle) * radius;
|
||||
const py = y + Math.sin(angle) * radius;
|
||||
if (i === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py);
|
||||
}
|
||||
ctx.closePath();
|
||||
ctx.strokeStyle = rgba(color, alpha);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
function renderResearchAnimations(now) {
|
||||
if (!state.researchAnimations.size) return;
|
||||
const green = MODE_CONFIG.researching.color;
|
||||
ctx.save();
|
||||
ctx.globalCompositeOperation = 'screen';
|
||||
for (const [id, animation] of state.researchAnimations) {
|
||||
if (Number.isFinite(animation.until) && now > animation.until) {
|
||||
state.researchAnimations.delete(id);
|
||||
continue;
|
||||
}
|
||||
const anchor = averageScreen(animation.sourceNodeIDs, animation.fallbackAnchor || brainViewportCenter());
|
||||
animation.fallbackAnchor = anchor;
|
||||
const elapsed = Math.max(0, (now - animation.startedAt) / 1000);
|
||||
const pulse = 0.5 + 0.5 * Math.sin(elapsed * 6.2);
|
||||
const searching = animation.stage === 'searching';
|
||||
const failed = animation.stage === 'failed';
|
||||
const empty = animation.stage === 'empty';
|
||||
const color = failed || empty ? [255, 180, 82] : green;
|
||||
const ringAlpha = searching ? 0.34 : 0.22;
|
||||
ctx.lineWidth = 1.1;
|
||||
ctx.setLineDash([4, 8]);
|
||||
for (let ring = 0; ring < 3; ring++) {
|
||||
const radius = 24 + ring * 13 + pulse * 4;
|
||||
ctx.strokeStyle = rgba(color, ringAlpha - ring * 0.055);
|
||||
ctx.beginPath();
|
||||
ctx.arc(anchor.x, anchor.y, radius, elapsed * (0.7 + ring * 0.18), elapsed * (0.7 + ring * 0.18) + Math.PI * (1.15 + ring * 0.12));
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.setLineDash([]);
|
||||
const sweepAngle = elapsed * 2.7;
|
||||
ctx.strokeStyle = rgba(color, 0.35 + pulse * 0.18);
|
||||
ctx.lineWidth = 0.9;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(anchor.x, anchor.y);
|
||||
ctx.lineTo(anchor.x + Math.cos(sweepAngle) * 58, anchor.y + Math.sin(sweepAngle) * 58);
|
||||
ctx.stroke();
|
||||
|
||||
const sourceCount = searching ? 5 : Math.max(1, animation.resultCount || animation.resultNodeIDs?.length || 1);
|
||||
const sourcePoints = researchSourcePoints(animation, sourceCount);
|
||||
const resultTargets = (animation.resultNodeIDs || []).map(visibleScreenForNode).filter(Boolean);
|
||||
for (let index = 0; index < sourcePoints.length; index++) {
|
||||
const source = sourcePoints[index];
|
||||
const target = resultTargets[index % Math.max(1, resultTargets.length)] || anchor;
|
||||
const sourcePulse = 0.5 + 0.5 * Math.sin(elapsed * 5 + source.phase);
|
||||
ctx.strokeStyle = rgba(color, searching ? 0.055 + sourcePulse * 0.04 : 0.12 + sourcePulse * 0.06);
|
||||
ctx.lineWidth = 0.65;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(source.x, source.y);
|
||||
const mx = (source.x + target.x) / 2 + Math.sin(source.phase) * 24;
|
||||
const my = (source.y + target.y) / 2 - 22;
|
||||
ctx.quadraticCurveTo(mx, my, target.x, target.y);
|
||||
ctx.stroke();
|
||||
drawResearchHex(source.x, source.y, 4.2 + sourcePulse * 1.7, color, 0.28 + sourcePulse * 0.34, elapsed * 0.35 + source.phase);
|
||||
if (!searching && !failed && !empty) {
|
||||
const phase = ((now - (animation.resultsAt || animation.startedAt)) / 760 + index * 0.17) % 1;
|
||||
const eased = phase * phase * (3 - 2 * phase);
|
||||
const packetX = source.x + (target.x - source.x) * eased;
|
||||
const packetY = source.y + (target.y - source.y) * eased - Math.sin(phase * Math.PI) * 26;
|
||||
const glow = ctx.createRadialGradient(packetX, packetY, 0, packetX, packetY, 9);
|
||||
glow.addColorStop(0, 'rgba(255,255,255,.96)');
|
||||
glow.addColorStop(0.22, rgba(color, 0.85));
|
||||
glow.addColorStop(1, rgba(color, 0));
|
||||
ctx.fillStyle = glow;
|
||||
ctx.beginPath();
|
||||
ctx.arc(packetX, packetY, 9, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
|
||||
if (animation.stage === 'ingested') {
|
||||
const settle = Math.max(0, Math.min(1, (now - animation.ingestedAt) / 2000));
|
||||
for (let index = 0; index < resultTargets.length; index++) {
|
||||
const target = resultTargets[index];
|
||||
const radius = 12 + (1 - settle) * 22 + Math.sin(elapsed * 8 + index) * 2;
|
||||
ctx.lineWidth = 1.2;
|
||||
drawResearchHex(target.x, target.y, radius, green, 0.5 * (1 - settle * 0.45), elapsed * 0.5 + index);
|
||||
ctx.strokeStyle = rgba(green, 0.24 * (1 - settle));
|
||||
ctx.beginPath();
|
||||
ctx.arc(target.x, target.y, radius * 1.45, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
ctx.save();
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
const label = searching ? 'SEARXNG · QUELLENSUCHE' : failed ? 'SEARXNG · FEHLER' : empty ? 'SEARXNG · 0 QUELLEN' : animation.stage === 'ingested' ? `SEARXNG · ${animation.resultCount} QUELLEN VERKNÜPFT` : `SEARXNG · ${animation.resultCount} QUELLEN`;
|
||||
ctx.font = '700 9px Inter, system-ui';
|
||||
const width = ctx.measureText(label).width + 16;
|
||||
ctx.fillStyle = 'rgba(2,10,16,.88)';
|
||||
ctx.fillRect(anchor.x - width / 2, anchor.y + 49, width, 18);
|
||||
ctx.strokeStyle = rgba(color, 0.35);
|
||||
ctx.strokeRect(anchor.x - width / 2, anchor.y + 49, width, 18);
|
||||
ctx.fillStyle = rgba(color, 0.95);
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(label, anchor.x, anchor.y + 58);
|
||||
ctx.restore();
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function projectAnimatedNode(node, now) {
|
||||
const honeycomb = state.viewMode === 'honeycomb' && Number.isFinite(node.honeyX);
|
||||
const cluster = honeycomb ? null : node.cluster;
|
||||
@@ -1630,6 +1884,7 @@
|
||||
renderParticles(dt);
|
||||
}
|
||||
renderNodes(now, dt);
|
||||
renderResearchAnimations(now);
|
||||
if (state.viewMode === 'neural') {
|
||||
renderWaves(dt);
|
||||
renderCortexLabels();
|
||||
@@ -1650,6 +1905,7 @@
|
||||
requestAnimationFrame(frame);
|
||||
|
||||
function activate(evt) {
|
||||
handleResearchAnimation(evt);
|
||||
const requestedDuration = evt?.type?.includes('think') || evt?.type?.includes('research') ? 45000 : 32000;
|
||||
forceDisplayNodes(evt?.node_ids || [], requestedDuration);
|
||||
const strength = Math.max(0.15, Math.min(1.4, evt.strength || 0.6));
|
||||
@@ -1707,7 +1963,7 @@
|
||||
|
||||
function shouldLog(evt) {
|
||||
if (!evt || evt.type === 'brain.idle' || evt.type === 'node.activated' || evt.type === 'edges.traversed') return false;
|
||||
const important = new Set(['scan.started', 'graph.updated', 'embedding.batch', 'query.started', 'query.completed', 'think.queued', 'think.cycle.started', 'think.cycle.completed', 'think.cycle.failed', 'think.no_candidate', 'think.started', 'think.relation.created', 'think.rejected', 'think.failed', 'think.paused', 'research.started', 'article.plan.started', 'article.plan.skipped', 'article.research.started', 'article.research.failed', 'article.draft.started', 'article.draft.rejected', 'article.created', 'article.duplicate', 'article.skipped', 'article.failed', 'agent.run', 'glpi.kb.synced', 'glpi.kb.failed', 'persistence.flushed', 'persistence.failed']);
|
||||
const important = new Set(['scan.started', 'graph.updated', 'embedding.batch', 'query.started', 'query.completed', 'think.queued', 'think.cycle.started', 'think.cycle.completed', 'think.cycle.failed', 'think.no_candidate', 'think.started', 'think.relation.created', 'think.rejected', 'think.failed', 'think.paused', 'research.started', 'research.results', 'research.ingested', 'research.failed', 'article.plan.started', 'article.plan.skipped', 'article.research.started', 'article.research.results', 'article.research.ingested', 'article.research.failed', 'article.draft.started', 'article.draft.rejected', 'article.created', 'article.duplicate', 'article.skipped', 'article.failed', 'agent.run', 'glpi.kb.synced', 'glpi.kb.failed', 'persistence.flushed', 'persistence.failed']);
|
||||
if (!important.has(evt.type) && !(evt.source === 'agent' || evt.source === 'knowledgebase' || evt.source === 'external' || evt.query)) return false;
|
||||
const fingerprint = `${evt.type}|${evt.message || ''}|${evt.query || ''}|${evt.source || ''}`;
|
||||
const last = state.lastLogFingerprint.get(fingerprint) || 0;
|
||||
@@ -1740,9 +1996,14 @@
|
||||
'think.failed': 'AI-THINK Fehler',
|
||||
'think.paused': 'AI-THINK pausiert',
|
||||
'research.started': 'Relationsrecherche gestartet',
|
||||
'research.results': 'SearXNG-Ergebnisse empfangen',
|
||||
'research.ingested': 'Webquellen im Graph verknüpft',
|
||||
'research.failed': 'Relationsrecherche fehlgeschlagen',
|
||||
'article.plan.started': 'Artikelmehrwert wird geprüft',
|
||||
'article.plan.skipped': 'Artikelsynthese übersprungen',
|
||||
'article.research.started': 'Artikelrecherche gestartet',
|
||||
'article.research.results': 'Artikelquellen gefunden',
|
||||
'article.research.ingested': 'Artikelquellen verknüpft',
|
||||
'article.research.failed': 'Artikelrecherche fehlgeschlagen',
|
||||
'article.draft.started': 'KB-Entwurf wird geschrieben',
|
||||
'article.draft.rejected': 'KB-Entwurf abgelehnt',
|
||||
@@ -1771,6 +2032,8 @@
|
||||
if (evt.metadata?.confidence !== undefined) meta.push(`${Math.round(Number(evt.metadata.confidence) * 100)}% Konfidenz`);
|
||||
if (evt.metadata?.relation_type) meta.push(String(evt.metadata.relation_type));
|
||||
if (evt.metadata?.research_result_count) meta.push(`${Number(evt.metadata.research_result_count)} Webquellen`);
|
||||
if (evt.metadata?.result_count !== undefined) meta.push(`${Number(evt.metadata.result_count)} SearXNG-Treffer`);
|
||||
if (Array.isArray(evt.metadata?.result_domains) && evt.metadata.result_domains.length) meta.push(evt.metadata.result_domains.slice(0, 3).join(' · '));
|
||||
if (evt.metadata?.trigger) meta.push(evt.metadata.trigger === 'manual' ? 'manuell' : 'automatisch');
|
||||
if (evt.metadata?.batch_size) meta.push(`${Number(evt.metadata.batch_size)} Schritte`);
|
||||
if (evt.metadata?.checked !== undefined) meta.push(`${Number(evt.metadata.checked)} geprüft`);
|
||||
@@ -1800,7 +2063,8 @@
|
||||
} else if (evt.type === 'query.started' && evt.query) {
|
||||
message = `${evt.source === 'agent' ? 'Agent' : evt.source === 'knowledgebase' ? 'Knowledgebase' : 'Brain'} verarbeitet eine Anfrage.`;
|
||||
}
|
||||
return {time, title, message, meta, query: eventQuery, regions, cls: evt.type?.includes('think') || evt.type?.startsWith('article.') ? (evt.type?.includes('research') ? 'research' : 'think') : evt.type?.includes('research') ? 'research' : evt.type === 'graph.updated' || evt.type === 'scan.started' || evt.type?.startsWith('glpi.kb') || evt.type?.startsWith('persistence.') ? 'graph' : evt.source === 'agent' ? 'agent' : ''};
|
||||
const sources = Array.isArray(evt.metadata?.result_titles) ? evt.metadata.result_titles.map(String).slice(0, 4) : [];
|
||||
return {time, title, message, meta, query: eventQuery, regions, sources, cls: evt.type?.includes('think') || evt.type?.startsWith('article.') ? (evt.type?.includes('research') ? 'research' : 'think') : evt.type?.includes('research') ? 'research' : evt.type === 'graph.updated' || evt.type === 'scan.started' || evt.type?.startsWith('glpi.kb') || evt.type?.startsWith('persistence.') ? 'graph' : evt.source === 'agent' ? 'agent' : ''};
|
||||
}
|
||||
|
||||
function addLog(evt) {
|
||||
@@ -1808,7 +2072,7 @@
|
||||
const out = formatEvent(evt);
|
||||
const item = document.createElement('div');
|
||||
item.className = 'activity-item ' + out.cls;
|
||||
item.innerHTML = `<b>${escapeHTML(out.title)}</b><time>${out.time}</time><p>${escapeHTML(out.message)}</p>${out.regions.length ? `<div class="region">Cortex: ${out.regions.map(escapeHTML).join(' · ')}</div>` : ''}${out.meta.length ? `<div class="meta">${out.meta.map(v => `<span>${escapeHTML(v)}</span>`).join('')}</div>` : ''}${out.query ? `<div class="query">${escapeHTML(out.query)}</div>` : ''}`;
|
||||
item.innerHTML = `<b>${escapeHTML(out.title)}</b><time>${out.time}</time><p>${escapeHTML(out.message)}</p>${out.regions.length ? `<div class="region">Cortex: ${out.regions.map(escapeHTML).join(' · ')}</div>` : ''}${out.meta.length ? `<div class="meta">${out.meta.map(v => `<span>${escapeHTML(v)}</span>`).join('')}</div>` : ''}${out.sources?.length ? `<div class="sources">${out.sources.map((source, index) => `<span><i>${index + 1}</i>${escapeHTML(source)}</span>`).join('')}</div>` : ''}${out.query ? `<div class="query">${escapeHTML(out.query)}</div>` : ''}`;
|
||||
const log = $('activityLog');
|
||||
log.prepend(item);
|
||||
while (log.children.length > 18) log.removeChild(log.lastChild);
|
||||
|
||||
Reference in New Issue
Block a user