180 lines
5.2 KiB
Go
180 lines
5.2 KiB
Go
package engine
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/local/glpi-neural-brain/internal/model"
|
|
)
|
|
|
|
func clusterPendingArticleCandidates(values []*pendingArticleCandidate) [][]*pendingArticleCandidate {
|
|
clusters := make([][]*pendingArticleCandidate, 0)
|
|
for _, value := range values {
|
|
if value == nil || len(value.Seeds) == 0 {
|
|
continue
|
|
}
|
|
placed := false
|
|
for i := range clusters {
|
|
for _, existing := range clusters[i] {
|
|
if articleCandidatesBelongTogether(existing, value) {
|
|
clusters[i] = append(clusters[i], value)
|
|
placed = true
|
|
break
|
|
}
|
|
}
|
|
if placed {
|
|
break
|
|
}
|
|
}
|
|
if !placed {
|
|
clusters = append(clusters, []*pendingArticleCandidate{value})
|
|
}
|
|
}
|
|
return clusters
|
|
}
|
|
|
|
func articleCandidatesBelongTogether(a, b *pendingArticleCandidate) bool {
|
|
if a == nil || b == nil {
|
|
return false
|
|
}
|
|
ids := map[string]bool{}
|
|
for _, seed := range a.Seeds {
|
|
ids[seed.ID] = true
|
|
}
|
|
for _, seed := range b.Seeds {
|
|
if ids[seed.ID] {
|
|
return true
|
|
}
|
|
}
|
|
textA := articleCandidateTerms(a)
|
|
textB := articleCandidateTerms(b)
|
|
termScore := boolSetJaccard(textA, textB)
|
|
if termScore >= .42 {
|
|
return true
|
|
}
|
|
catA := map[string]bool{}
|
|
catB := map[string]bool{}
|
|
for _, seed := range a.Seeds {
|
|
for _, cat := range seed.Categories {
|
|
catA[strings.ToLower(strings.TrimSpace(cat))] = true
|
|
}
|
|
}
|
|
for _, seed := range b.Seeds {
|
|
for _, cat := range seed.Categories {
|
|
catB[strings.ToLower(strings.TrimSpace(cat))] = true
|
|
}
|
|
}
|
|
return termScore >= .18 && boolSetJaccard(catA, catB) >= .50
|
|
}
|
|
|
|
func articleCandidateTerms(value *pendingArticleCandidate) map[string]bool {
|
|
parts := []string{value.Relation.TopicLabel}
|
|
parts = append(parts, value.Relation.Keywords...)
|
|
for _, seed := range value.Seeds {
|
|
parts = append(parts, seed.Label)
|
|
}
|
|
return researchTerms(strings.Join(parts, " "))
|
|
}
|
|
|
|
func boolSetJaccard(a, b map[string]bool) float64 {
|
|
if len(a) == 0 || len(b) == 0 {
|
|
return 0
|
|
}
|
|
intersection := 0
|
|
union := map[string]bool{}
|
|
for key := range a {
|
|
union[key] = true
|
|
if b[key] {
|
|
intersection++
|
|
}
|
|
}
|
|
for key := range b {
|
|
union[key] = true
|
|
}
|
|
if len(union) == 0 {
|
|
return 0
|
|
}
|
|
return float64(intersection) / float64(len(union))
|
|
}
|
|
|
|
func combinePendingArticleCluster(cluster []*pendingArticleCandidate) ([]model.Node, model.RelationDecision, []model.ResearchResult) {
|
|
seedByID := map[string]model.Node{}
|
|
keywords := []string{}
|
|
topics := []string{}
|
|
relationTypes := map[string]int{}
|
|
confidenceSum := 0.0
|
|
count := 0
|
|
researchResults := []model.ResearchResult{}
|
|
for _, item := range cluster {
|
|
if item == nil {
|
|
continue
|
|
}
|
|
for _, seed := range item.Seeds {
|
|
seedByID[seed.ID] = seed
|
|
}
|
|
keywords = append(keywords, item.Relation.Keywords...)
|
|
if topic := strings.TrimSpace(item.Relation.TopicLabel); topic != "" {
|
|
topics = append(topics, topic)
|
|
}
|
|
if rel := safeRelation(item.Relation.RelationType); rel != "" {
|
|
relationTypes[rel]++
|
|
}
|
|
confidenceSum += item.Relation.Confidence
|
|
count++
|
|
researchResults = append(researchResults, item.Research...)
|
|
}
|
|
seeds := make([]model.Node, 0, len(seedByID))
|
|
for _, seed := range seedByID {
|
|
seeds = append(seeds, seed)
|
|
}
|
|
topic := ""
|
|
if len(topics) > 0 {
|
|
topic = topics[0]
|
|
}
|
|
relationType := "same_topic"
|
|
maxCount := 0
|
|
for rel, n := range relationTypes {
|
|
if n > maxCount {
|
|
relationType, maxCount = rel, n
|
|
}
|
|
}
|
|
confidence := .8
|
|
if count > 0 {
|
|
confidence = confidenceSum / float64(count)
|
|
}
|
|
decision := model.RelationDecision{
|
|
Related: true,
|
|
RelationType: relationType,
|
|
Confidence: confidence,
|
|
TopicLabel: topic,
|
|
Keywords: unique(keywords),
|
|
Explanation: fmt.Sprintf("%d thematisch kompatible neue Relationen wurden für einen gemeinsamen Artikelauftrag gebündelt.", count),
|
|
}
|
|
return seeds, decision, uniqueResearchEvidence(researchResults)
|
|
}
|
|
|
|
func (e *Engine) synthesizePendingArticleClusters(ctx context.Context, trigger string, pending []*pendingArticleCandidate) (created, skipped int) {
|
|
clusters := clusterPendingArticleCandidates(pending)
|
|
for index, cluster := range clusters {
|
|
seeds, relation, researchResults := combinePendingArticleCluster(cluster)
|
|
if len(seeds) == 0 {
|
|
continue
|
|
}
|
|
e.Broker.Publish(model.Activity{Type: "article.cluster.started", Source: "brain", Phase: "knowledge-planning", NodeIDs: nodeIDsFromNodes(seeds), Message: fmt.Sprintf("%d Relation(en) werden als gemeinsamer Artikelauftrag verarbeitet", len(cluster)), Strength: .62, Metadata: map[string]any{"trigger": trigger, "cluster_index": index + 1, "relation_count": len(cluster), "seed_count": len(seeds), "processing_mode": "clustered"}})
|
|
outcome, err := e.synthesizeKnowledgeArticle(ctx, trigger, seeds, relation, researchResults)
|
|
if err != nil {
|
|
skipped++
|
|
e.Broker.Publish(model.Activity{Type: "article.failed", Source: "brain", Phase: "knowledge-synthesis", NodeIDs: nodeIDsFromNodes(seeds), Message: "Gebündelte Artikelsynthese ist fehlgeschlagen; die bereits erzeugten Relationen bleiben erhalten", Strength: .4, Metadata: map[string]any{"trigger": trigger, "cluster_index": index + 1, "relation_count": len(cluster), "error": err.Error()}})
|
|
continue
|
|
}
|
|
if outcome.Created {
|
|
created++
|
|
}
|
|
if outcome.Skipped {
|
|
skipped++
|
|
}
|
|
}
|
|
return created, skipped
|
|
}
|