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 }