Files
glpi-neural-brain/internal/engine/article_batch.go
2026-08-07 21:55:54 +02:00

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
}