Update 5 - SearXNG
All checks were successful
release-tag / release-image (push) Successful in 1m38s
All checks were successful
release-tag / release-image (push) Successful in 1m38s
This commit is contained in:
9
CHANGELOG-SEARXNG-VISUALIZATION.md
Normal file
9
CHANGELOG-SEARXNG-VISUALIZATION.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# SearXNG Research Visualization
|
||||
|
||||
- Eigene Events für empfangene und in den Graphen übernommene SearXNG-Ergebnisse ergänzt.
|
||||
- Trefferzahl, Titel, Domains, URLs, Laufzeit und gemeinsame `research_id` in die Event-Metadaten aufgenommen.
|
||||
- Recherchefehler werden auch für Relation Thinking sichtbar gemeldet.
|
||||
- Externe Forschungs-Nodes lösen unmittelbar einen Graph-Reload im Browser aus.
|
||||
- Persistente Scan-, Quellenflug- und Ingest-Animation ergänzt.
|
||||
- Abschlussanimation überlebt Layout-, LOD-, Honeycomb- und Graph-Neuaufbau und läuft danach mindestens zwei Sekunden.
|
||||
- Aktivitätsfeed um SearXNG-Treffer, Domains und Quelltitel erweitert.
|
||||
@@ -231,3 +231,7 @@ go test ./...
|
||||
go vet ./...
|
||||
node --check internal/web/static/app.js
|
||||
```
|
||||
|
||||
## Sichtbare SearXNG-Recherche
|
||||
|
||||
SearXNG-Suchen werden als eigenständige Aktivität visualisiert. Scanringe markieren die laufende Suche, gefundene Quellen erscheinen als externe Quellenpunkte und fließen anschließend in die neu erzeugten Forschungs-Nodes. Die Animation bleibt bei der Aktualisierung des Graphen bestehen und läuft nach dem Einblenden der neuen Nodes mindestens zwei Sekunden weiter. Der Aktivitätsfeed zeigt Trefferzahl, Domains, Quelltitel und Übernahmestatus. Details stehen in `SEARXNG-VISUALIZATION.md`.
|
||||
|
||||
31
SEARXNG-VISUALIZATION.md
Normal file
31
SEARXNG-VISUALIZATION.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# SearXNG-Visualisierung
|
||||
|
||||
Das Neural Brain meldet die Webrecherche jetzt in drei getrennten Schritten an das Frontend:
|
||||
|
||||
1. `research.started` / `article.research.started` – die Quellensuche läuft.
|
||||
2. `research.results` / `article.research.results` – SearXNG hat Treffer geliefert, einschließlich Trefferzahl, Quelltiteln, Domains und Laufzeit.
|
||||
3. `research.ingested` / `article.research.ingested` – die Treffer wurden als Forschungs-Nodes in den In-Memory-Graphen aufgenommen und mit den internen Wissensquellen verknüpft.
|
||||
|
||||
Die drei Ereignisse tragen dieselbe `research_id`. Dadurch bleibt die Animation über den Graph-Reload hinweg erhalten.
|
||||
|
||||
## Animation
|
||||
|
||||
Während der Suche erscheinen grüne Scanringe und externe Quellenpunkte um das Gehirn. Nach Eingang der Treffer fließen Quellenpakete in die betroffenen Wissensregionen. Beim Erzeugen der neuen Forschungs-Nodes werden diese mit sechseckigen Markierungen hervorgehoben.
|
||||
|
||||
Die Abschlussanimation läuft nach dem Reload und der Einblendung der neuen Nodes mindestens zwei Sekunden weiter. Sie ist unabhängig von der normalen Partikel- und LOD-Liste und wird deshalb durch eine Neuerstellung des Rendergraphen nicht entfernt.
|
||||
|
||||
Die Animation wird sowohl in der Neural- als auch in der Honeycomb-Ansicht dargestellt. In Honeycomb werden weiterhin keine regulären Graph-Edges gezeichnet; sichtbar sind nur die zeitlich begrenzten Recherchepfade.
|
||||
|
||||
## Aktivitätsfeed
|
||||
|
||||
Der linke Feed zeigt jetzt:
|
||||
|
||||
- die Suchanfrage,
|
||||
- Anzahl verwertbarer SearXNG-Treffer,
|
||||
- Antwortzeit,
|
||||
- Domains der Treffer,
|
||||
- bis zu vier Quelltitel,
|
||||
- Anzahl der neu verknüpften Forschungs-Nodes und Edges,
|
||||
- Fehler und leere Ergebnismengen.
|
||||
|
||||
Damit ist erkennbar, ob SearXNG tatsächlich Ergebnisse geliefert hat und ob diese in den Graphen übernommen wurden.
|
||||
@@ -5,16 +5,17 @@ e2f1f0400999cb59b8be09bc2743e06e0a0b2ecdc44a583af7c9a08b70d8509e ./CHANGELOG-GP
|
||||
a317376127be1e47b9ec9f0781fcfebdcbf7fccfaeb7840d9241f9586048fadb ./CHANGELOG-GROUNDED-KNOWLEDGE-SYNTHESIS.md
|
||||
e167a9d64f63c3933ad40c5078684bc019db303043c34ea3965f3b089f3d32c7 ./CHANGELOG-KNOWLEDGE-SYNTHESIS.md
|
||||
87f89a81e1124b18e092a4ea946cb037295cb9e884a46b392286272dc8134dd4 ./CHANGELOG-RUNTIME-HONEYCOMB.md
|
||||
9c760e167a9af2d3d8ca32c5aa4ef6bb4a3153047343a71c4b19ca9aef96ca32 ./CHANGELOG-SEARXNG-VISUALIZATION.md
|
||||
99171b262752a66278da5a4a8b2b48e83fbdb1eb9cdca3efa944d260d6e6bc01 ./Dockerfile
|
||||
5534536965bf0479455f97324c242160202650ca1256f1ba0420b4ad67125e49 ./GLPI-KB.md
|
||||
a090708c29db08973f712eb9ebf07dff2aae4e3e64f8954bbdaa83848b3ffcc0 ./KNOWLEDGE-SYNTHESIS.md
|
||||
adcd3c9ba3bdf366afcc4e15a25423e068dd761e5d5d2d6f8cb20a3686302045 ./Makefile
|
||||
381d7d6ac9e3c2e63c9ecdaa42ed4c73058f5d78e57c7532bb75a9663c919530 ./OLLAMA-POOL.md
|
||||
10c14f4c08b9b84699cc4cf0dac3e6faf6c0ffd7e74b428463f14c628d0b533d ./PERSISTENCE.md
|
||||
4561debb97c555f57005430781de10de8a2a56a0d364060f70f2f4ebc0b0ab01 ./README.md
|
||||
b16c58d98f9da5d240ab5189993c5013aa57ac0da88029f40f3f3e572fdd8233 ./README.md
|
||||
73171929c19afc9c0f280a50bee1c6becfbfe1a1a4107540ff80d5e4f885ba9a ./RUNTIME-CONTROLS-HONEYCOMB.md
|
||||
c6fa5f19066c53fd184170bca78dceb8c3fbdfd0c5d6196e0ddbb37ac7bf8eb6 ./SEARXNG-VISUALIZATION.md
|
||||
cfc2393e8300b792cec30c57b9dc7670b19dcf5abb190dddaa75f32b77cb1fc7 ./cmd/brain/main.go
|
||||
418310e2660f34890c4be97d6e4a0873cce57fe5b8eb6bfdff25bcd2d5faa7bf ./data/graph-state.json
|
||||
21b51d0e1b7ed07c20f7f3a5da76dedab8df44a94a51724b67b0c3411599fe15 ./deployment/README.md
|
||||
139d3dc6939848f8d8e827e5a483da7f7794a47dd3c858a964eaa7f811987985 ./deployment/docker-compose.full.yml
|
||||
5d97d77e2f0c302fb7fedfe8c7ff2c72a30f196590fdcab498ab0f4369de0071 ./docker-compose.yml
|
||||
@@ -26,9 +27,10 @@ a0105475dc054977223fac36618b8cd8137c55be1d11fddcd24e9a4d3074c170 ./integrations
|
||||
50d05fa2a183f5f3eaab0545cb48d3abb64be62eb5d84dc2c6d99c7125f7344b ./internal/activity/broker.go
|
||||
45ca13d5dc65b5858359d1187147c8a892ae6ba8355e699c05d7e44212096c37 ./internal/config/config.go
|
||||
d5defa74dff6b09869f59c3e40ecc083f18bb9d81a0541956a857c4d0c26c360 ./internal/config/config_test.go
|
||||
bfbb007982ddde321f6abe7bf82e6d3e7dc60304445a4649573e24989b9e922b ./internal/engine/article.go
|
||||
dc07c6fd00d55982fb98533d0719984d7e4b01b0a06123c93183522eaaa06393 ./internal/engine/engine.go
|
||||
2c4d29463b4d7225d4d7e89386a01fb91254ef584e2e2cb5dc80f2baf3d21c01 ./internal/engine/engine_test.go
|
||||
fec14e27424873edae74b6a6f304612208e0f0d23d3cb598f0e680238b6ba7c1 ./internal/engine/article.go
|
||||
81386f1ce7d3d4d311367e8692429db6ced99173de9a623953aa38f1dd1725e3 ./internal/engine/engine.go
|
||||
6499fa71d3554845a7a12f6a16c38579f457ece368a6f3b152ccebae39362662 ./internal/engine/engine_test.go
|
||||
716d42138db9eb64480c1bbaf4cb3b70bce1edf72c17c3d85a8d84f866fd8700 ./internal/engine/research_events.go
|
||||
872b9a5d6cc0e610a50f64ae8d66ed53bf07da718da22f91c11cf43a487fb425 ./internal/engine/runtime.go
|
||||
b82980a646a92751bdd27a866ba1ffc6d34a3ba81d537f7b6e5a78e1432ee6fa ./internal/glpi/client.go
|
||||
525102be56bc51ce8a08655b1b2bb53b67f4a1828903585fd664ed6a5133f617 ./internal/glpi/client_test.go
|
||||
@@ -48,8 +50,8 @@ d33318b43388f134358cf40f5b0f130eb10edcca06011955ea0544897b4e4ad1 ./internal/per
|
||||
6346f7b213aa3fb36bc9f43134bba75e0b368c9a005cc1aef8d265f553ff8cef ./internal/research/searxng_test.go
|
||||
1c44338935a4faeedd9671c23df235aa99ea55ed6b2e72060a5d5ddc6c7a6464 ./internal/web/server.go
|
||||
4d89321b95eb2af3f594adb4d8230aa85377bebe707d232a6af7f1a1f99523dd ./internal/web/server_test.go
|
||||
a9e586d77223a60bb8345c21de5381699eb8dbdfc215cbb5de975ee0d62b15fa ./internal/web/static/app.css
|
||||
33f4f973878985ee086425f560a95d8269a7989da36f6c35c0ff2d8aa00c709f ./internal/web/static/app.js
|
||||
d6463cc4c15e023eff8488e9541aa2516386ca3d04993868206e7e0885fb2eb0 ./internal/web/static/app.css
|
||||
d860d640c34d2f65c9f3d387a85988e24446e8464ad701326beae3a5a17e3be5 ./internal/web/static/app.js
|
||||
7e99be904fea61b19bfd5560c93eec5dc5eee75252d1410ca1661cc4a87181dd ./internal/web/static/index.html
|
||||
eed8aecdf49ea5efecbb16b73483a2c3d2a527a079698519f9bb3b1c9667872c ./neural-brain
|
||||
0fe63bd83de071d285fe3576f0ac8093a92bc1e61681649a7330680cd0660197 ./neural-brain
|
||||
83aded814b6225395935e61fe957963c3c470f368fc9089f505b6de23e959115 ./preview.png
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
74
internal/engine/research_events.go
Normal file
74
internal/engine/research_events.go
Normal file
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
BIN
neural-brain
BIN
neural-brain
Binary file not shown.
Reference in New Issue
Block a user