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 } // Article batching is deliberately stricter than semantic candidate search. // Knowledge articles share a lot of security boilerplate (hardening, detection, // forensics, incident response), so generic terms/categories must never be // sufficient to merge distinct topics such as Water Leak Detection, BGP // Prefix Filtering and Triple Extortion into one author context. topicA := articleCandidateTopicTerms(a) topicB := articleCandidateTopicTerms(b) if len(topicA) == 0 || len(topicB) == 0 { return false } intersection := boolSetIntersectionSize(topicA, topicB) if intersection == 0 { return false } jaccard := boolSetJaccard(topicA, topicB) minSize := len(topicA) if len(topicB) < minSize { minSize = len(topicB) } containment := float64(intersection) / float64(minSize) if jaccard >= .50 || containment >= .67 { return true } // A shared seed is useful only when that seed is a real topical anchor for // both relations. Merely sharing a broad/generic node must not override the // topic guard. This keeps legitimate multi-perspective articles together // while preventing one hub-like source from joining unrelated subjects. shared := map[string]model.Node{} for _, seed := range a.Seeds { shared[seed.ID] = seed } for _, seed := range b.Seeds { anchor, ok := shared[seed.ID] if !ok { continue } anchorTerms := articleTopicTermsFromText(anchor.Label) if boolSetIntersectionSize(anchorTerms, topicA) > 0 && boolSetIntersectionSize(anchorTerms, topicB) > 0 { return true } } return false } var articleTopicStopwords = map[string]bool{ "security": true, "sicherheit": true, "sicher": true, "sichere": true, "sicherheitsmassnahmen": true, "sicherheitsmaßnahmen": true, "hardening": true, "haertung": true, "härtung": true, "haerten": true, "härten": true, "gestalten": true, "praeventiv": true, "präventiv": true, "resilient": true, "ueberwachen": true, "überwachen": true, "erkennen": true, "untersuchen": true, "forensisch": true, "forensik": true, "incident": true, "vorfall": true, "vorfaelle": true, "vorfälle": true, "eindaemmen": true, "eindämmen": true, "wiederherstellen": true, "risiko": true, "risiken": true, "schutz": true, "massnahmen": true, "maßnahmen": true, "konfiguration": true, "und": true, "oder": true, "der": true, "die": true, "das": true, "den": true, "des": true, "von": true, "bei": true, "mit": true, "fuer": true, "für": true, } func articleCandidateTopicTerms(value *pendingArticleCandidate) map[string]bool { if value == nil { return nil } parts := make([]string, 0, 1+len(value.Seeds)) if topic := strings.TrimSpace(value.Relation.TopicLabel); topic != "" { parts = append(parts, topic) } if len(parts) == 0 { for _, seed := range value.Seeds { label := strings.TrimSpace(seed.Label) if idx := strings.Index(label, " – "); idx > 0 { label = label[:idx] } else if idx := strings.Index(label, " - "); idx > 0 { label = label[:idx] } parts = append(parts, label) } } return articleTopicTermsFromText(strings.Join(parts, " ")) } func articleTopicTermsFromText(text string) map[string]bool { // Source titles in the KB commonly use a stable topical prefix followed by // a templated perspective such as " – sicher planen und durchführen". The // suffix must not participate in topic coherence: otherwise two unrelated // articles can look related merely because they share the same template. text = articleTopicCore(text) terms := researchTerms(text) for term := range terms { if articleTopicStopwords[term] || len([]rune(term)) < 2 { delete(terms, term) } } return terms } func articleTopicCore(text string) string { text = strings.TrimSpace(text) for _, separator := range []string{" – ", " - "} { if idx := strings.Index(text, separator); idx > 0 { return strings.TrimSpace(text[:idx]) } } return text } func articleTopicSetsBelongTogether(a, b map[string]bool) bool { if len(a) == 0 || len(b) == 0 { return false } intersection := boolSetIntersectionSize(a, b) if intersection == 0 { return false } minSize := len(a) if len(b) < minSize { minSize = len(b) } containment := float64(intersection) / float64(minSize) return boolSetJaccard(a, b) >= .50 || containment >= .67 } func boolSetIntersectionSize(a, b map[string]bool) int { n := 0 for key := range a { if b[key] { n++ } } return n } 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 } topicLabels := make([]string, 0, len(cluster)) for _, candidate := range cluster { if candidate != nil && strings.TrimSpace(candidate.Relation.TopicLabel) != "" { topicLabels = append(topicLabels, strings.TrimSpace(candidate.Relation.TopicLabel)) } } 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", "topic_guard": "strict-v2", "topic_labels": unique(topicLabels)}}) outcome, err := e.synthesizeKnowledgeArticle(ctx, trigger, seeds, relation, researchResults) if err != nil { skipped++ // synthesizeKnowledgeArticle owns the terminal article.failed event and // its native run_id. Do not publish a second orphan terminal here. continue } if outcome.Created { created++ } if outcome.Skipped { skipped++ } } return created, skipped }