package viral import ( "fmt" "math" "sort" "strings" "haixun-backend/internal/library/clock" "haixun-backend/internal/library/placement" libthreads "haixun-backend/internal/library/threadsapi" missionentity "haixun-backend/internal/model/copy_mission/domain/entity" ) const ( RefAccountMinBestEngagement = 50 RefAccountMinBestLikes = 18 RefAccountMinTotalEngagement = 80 RefVerifiedMinBestEngagement = 35 RefVerifiedMinBestLikes = 10 ) // ReferenceRankWeights controls the weighted sort key applied to ranked // reference authors. Defaults preserve historical precedence (verified first, // then follower count, then total engagement, then best single-post // engagement) and introduce topic relevance as a tie-breaker multiplier. type ReferenceRankWeights struct { VerifiedW int FollowerW int TotalEngagementW int BestEngagementW int TopicRelevanceW int } // DefaultReferenceRankWeights returns the canonical weights used by // BuildReferenceAccountsFromScan. Callers may pass a customised copy via // ReferenceAccountInput in a future iteration; Phase 1 keeps it internal. func DefaultReferenceRankWeights() ReferenceRankWeights { return ReferenceRankWeights{ VerifiedW: 4, FollowerW: 2, TotalEngagementW: 1, BestEngagementW: 1, TopicRelevanceW: 2, } } // rankScore converts aggregated author signals into a weighted integer sort // key. Follower count is log-scaled so 10M-follower mega accounts do not // dominate niche candidates with high topic relevance. func (w ReferenceRankWeights) rankScore(item referenceAuthorAgg) int { score := 0 if item.verified { score += w.VerifiedW * 1000 } followerBucket := 0 switch { case item.followerCount >= 1_000_000: followerBucket = 4 case item.followerCount >= 100_000: followerBucket = 3 case item.followerCount >= 10_000: followerBucket = 2 case item.followerCount >= 1_000: followerBucket = 1 } score += w.FollowerW * followerBucket * 100 score += w.TotalEngagementW * item.totalEngagement score += w.BestEngagementW * item.bestEngagement score += w.TopicRelevanceW * item.topicHits * 50 return score } type ReferenceAccountInput struct { SeedQuery string Label string Posts []placement.ScanCandidate Limit int ExcludedUsernames []string } type referenceAuthorAgg struct { username string verified bool followerCount int totalEngagement int bestEngagement int bestLikes int bestReplies int postCount int topicHits int sampleText string sampleSearchTag string samplePermalink string } // BuildReferenceAccountsFromScan lists authors from patrol posts that match the // mission topic and pass quality gates. Verified/follower are optional bonuses; // if strict gates yield nothing, falls back to baseline viral engagement. func BuildReferenceAccountsFromScan(in ReferenceAccountInput) []missionentity.SimilarAccount { out := buildReferenceAccountsFromScan(in, true) if len(out) > 0 { return out } return buildReferenceAccountsFromScan(in, false) } func buildReferenceAccountsFromScan(in ReferenceAccountInput, strictQuality bool) []missionentity.SimilarAccount { limit := in.Limit if limit <= 0 { limit = MaxSimilarAccounts } byUser := map[string]referenceAuthorAgg{} terms := normalisedTopicTerms(in.SeedQuery, in.Label) weights := DefaultReferenceRankWeights() now := clock.NowUnixNano() topicDenom := math.Max(1, float64(len(terms))) for _, post := range in.Posts { user := strings.TrimSpace(post.Author) if user == "" || !isValidUsername(user) { continue } if isExcluded(in.ExcludedUsernames, user) { continue } hits := topicTopicHits(post, terms) if hits == 0 { continue } if strictQuality { if !PassesMissionQualityCandidate( post.Text, post.LikeCount, post.ReplyCount, post.EngagementScore, post.AuthorVerified, post.FollowerCount, nil, ) { continue } } else if !PassesViralCandidate( post.Text, post.LikeCount, post.ReplyCount, post.EngagementScore, nil, ) { continue } key := strings.ToLower(user) prev := byUser[key] prev.username = user if post.AuthorVerified { prev.verified = true } if post.FollowerCount > prev.followerCount { prev.followerCount = post.FollowerCount } prev.postCount++ prev.totalEngagement += post.EngagementScore if hits > prev.topicHits { prev.topicHits = hits } if post.EngagementScore > prev.bestEngagement { prev.bestEngagement = post.EngagementScore prev.bestLikes = post.LikeCount prev.bestReplies = post.ReplyCount text := strings.TrimSpace(post.Text) if len([]rune(text)) > 80 { text = string([]rune(text)[:80]) } prev.sampleText = text prev.sampleSearchTag = strings.TrimSpace(post.SearchTag) prev.samplePermalink = strings.TrimSpace(post.Permalink) } byUser[key] = prev } ranked := make([]referenceAuthorAgg, 0, len(byUser)) for _, item := range byUser { if qualifiesReferenceAuthor(item, strictQuality) { ranked = append(ranked, item) } } sort.Slice(ranked, func(i, j int) bool { si := weights.rankScore(ranked[i]) sj := weights.rankScore(ranked[j]) if si != sj { return si > sj } // stable historical tie-breakers (verified first, follower, // total engagement, best engagement) preserved for determinism. if ranked[i].verified != ranked[j].verified { return ranked[i].verified } if ranked[i].followerCount != ranked[j].followerCount { return ranked[i].followerCount > ranked[j].followerCount } if ranked[i].totalEngagement != ranked[j].totalEngagement { return ranked[i].totalEngagement > ranked[j].totalEngagement } return ranked[i].bestEngagement > ranked[j].bestEngagement }) if len(ranked) > limit { ranked = ranked[:limit] } out := make([]missionentity.SimilarAccount, 0, len(ranked)) for _, item := range ranked { conf := "medium" if item.verified { conf = "high" } else if item.bestEngagement >= HotEngagementScore || item.totalEngagement >= 120 { conf = "high" } out = append(out, missionentity.SimilarAccount{ Username: item.username, Reason: formatReferenceReason(item), Source: "scan", MatchedSource: []string{"scan"}, Confidence: conf, Status: missionentity.SimilarAccountStatusRecommended, TopicRelevance: float64(item.topicHits) / topicDenom, LastSeenAt: now, ProfileURL: libthreads.ProfileURLFromPermalink(item.samplePermalink, item.username), AuthorVerified: item.verified, FollowerCount: item.followerCount, EngagementScore: item.bestEngagement, LikeCount: item.bestLikes, ReplyCount: item.bestReplies, PostCount: item.postCount, }) } return out } func qualifiesReferenceAuthor(item referenceAuthorAgg, strictQuality bool) bool { if item.postCount == 0 { return false } if item.verified { return item.bestLikes >= RefVerifiedMinBestLikes && (item.bestEngagement >= RefVerifiedMinBestEngagement || item.totalEngagement >= 60) } if strictQuality { if item.bestLikes < RefAccountMinBestLikes { return false } return item.bestEngagement >= RefAccountMinBestEngagement || item.totalEngagement >= RefAccountMinTotalEngagement } return item.bestLikes >= 8 && item.bestEngagement >= MinEngagementScore } func postTopicRelevant(post placement.ScanCandidate, seed, label string) bool { return topicTopicHits(post, normalisedTopicTerms(seed, label)) > 0 } // topicTopicHits counts how many normalised topic terms appear (case-folded // substring match against the post's text and search tag). Returning a hit // count (instead of a boolean) lets the ranking weight reward posts that are // relevant across multiple seed/label tokens — a coarse but dependency-free // CJK-friendly proxy for topic similarity. func topicTopicHits(post placement.ScanCandidate, terms []string) int { if len(terms) == 0 { text := strings.TrimSpace(post.Text) tag := strings.TrimSpace(post.SearchTag) if text == "" && tag == "" { return 0 } return 1 } text := strings.ToLower(strings.TrimSpace(post.Text)) tag := strings.ToLower(strings.TrimSpace(post.SearchTag)) hits := 0 for _, term := range terms { if term == "" { continue } if strings.Contains(text, term) || strings.Contains(tag, term) { hits++ } } return hits } // normalisedTopicTerms lowercases and de-spaces the seed-query and label while // also exposing coarse tokens split on whitespace and punctuation. It remains // dependency-free and CJK-friendly, but avoids matching only a long exact phrase. func normalisedTopicTerms(seed, label string) []string { out := []string{} terms := missionTopicMatchTerms(seed, label, nil) seen := map[string]struct{}{} for _, term := range append(append([]string{}, terms.Anchors...), terms.Terms...) { if term == "" { continue } if _, ok := seen[term]; ok { continue } seen[term] = struct{}{} out = append(out, term) } return out } func formatReferenceReason(item referenceAuthorAgg) string { if item.sampleText != "" { return item.sampleText } if item.sampleSearchTag != "" { return fmt.Sprintf("標籤「%s」高互動作者", item.sampleSearchTag) } return "本次海巡高互動作者" }