thread-master/backend/internal/library/viral/reference_accounts.go

307 lines
9.1 KiB
Go
Raw Normal View History

2026-06-26 08:37:04 +00:00
package viral
import (
"fmt"
2026-06-28 08:28:42 +00:00
"math"
2026-06-26 08:37:04 +00:00
"sort"
"strings"
2026-06-28 08:28:42 +00:00
"haixun-backend/internal/library/clock"
2026-06-26 08:37:04 +00:00
"haixun-backend/internal/library/placement"
2026-06-28 08:28:42 +00:00
libthreads "haixun-backend/internal/library/threadsapi"
2026-06-26 08:37:04 +00:00
missionentity "haixun-backend/internal/model/copy_mission/domain/entity"
)
const (
RefAccountMinBestEngagement = 50
RefAccountMinBestLikes = 18
RefAccountMinTotalEngagement = 80
RefVerifiedMinBestEngagement = 35
RefVerifiedMinBestLikes = 10
)
2026-06-28 08:28:42 +00:00
// 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
}
2026-06-26 08:37:04 +00:00
type ReferenceAccountInput struct {
2026-06-28 08:28:42 +00:00
SeedQuery string
Label string
Posts []placement.ScanCandidate
Limit int
ExcludedUsernames []string
2026-06-26 08:37:04 +00:00
}
type referenceAuthorAgg struct {
username string
verified bool
followerCount int
totalEngagement int
bestEngagement int
bestLikes int
bestReplies int
postCount int
2026-06-28 08:28:42 +00:00
topicHits int
2026-06-26 08:37:04 +00:00
sampleText string
sampleSearchTag string
2026-06-28 08:28:42 +00:00
samplePermalink string
2026-06-26 08:37:04 +00:00
}
// 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{}
2026-06-28 08:28:42 +00:00
terms := normalisedTopicTerms(in.SeedQuery, in.Label)
weights := DefaultReferenceRankWeights()
now := clock.NowUnixNano()
topicDenom := math.Max(1, float64(len(terms)))
2026-06-26 08:37:04 +00:00
for _, post := range in.Posts {
user := strings.TrimSpace(post.Author)
if user == "" || !isValidUsername(user) {
continue
}
2026-06-28 08:28:42 +00:00
if isExcluded(in.ExcludedUsernames, user) {
continue
}
hits := topicTopicHits(post, terms)
if hits == 0 {
2026-06-26 08:37:04 +00:00
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
2026-06-28 08:28:42 +00:00
if hits > prev.topicHits {
prev.topicHits = hits
}
2026-06-26 08:37:04 +00:00
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)
2026-06-28 08:28:42 +00:00
prev.samplePermalink = strings.TrimSpace(post.Permalink)
2026-06-26 08:37:04 +00:00
}
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 {
2026-06-28 08:28:42 +00:00
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.
2026-06-26 08:37:04 +00:00
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",
2026-06-28 08:28:42 +00:00
MatchedSource: []string{"scan"},
2026-06-26 08:37:04 +00:00
Confidence: conf,
2026-06-28 08:28:42 +00:00
Status: missionentity.SimilarAccountStatusRecommended,
TopicRelevance: float64(item.topicHits) / topicDenom,
LastSeenAt: now,
ProfileURL: libthreads.ProfileURLFromPermalink(item.samplePermalink, item.username),
2026-06-26 08:37:04 +00:00
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 {
2026-06-28 08:28:42 +00:00
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 {
2026-06-26 08:37:04 +00:00
if len(terms) == 0 {
2026-06-28 08:28:42 +00:00
text := strings.TrimSpace(post.Text)
tag := strings.TrimSpace(post.SearchTag)
if text == "" && tag == "" {
return 0
}
return 1
2026-06-26 08:37:04 +00:00
}
2026-06-28 08:28:42 +00:00
text := strings.ToLower(strings.TrimSpace(post.Text))
tag := strings.ToLower(strings.TrimSpace(post.SearchTag))
hits := 0
2026-06-26 08:37:04 +00:00
for _, term := range terms {
2026-06-28 08:28:42 +00:00
if term == "" {
continue
}
2026-06-26 08:37:04 +00:00
if strings.Contains(text, term) || strings.Contains(tag, term) {
2026-06-28 08:28:42 +00:00
hits++
2026-06-26 08:37:04 +00:00
}
}
2026-06-28 08:28:42 +00:00
return hits
2026-06-26 08:37:04 +00:00
}
2026-06-28 08:28:42 +00:00
// 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 {
2026-06-26 08:37:04 +00:00
out := []string{}
2026-06-28 08:28:42 +00:00
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)
2026-06-26 08:37:04 +00:00
}
return out
}
func formatReferenceReason(item referenceAuthorAgg) string {
if item.sampleText != "" {
return item.sampleText
}
if item.sampleSearchTag != "" {
return fmt.Sprintf("標籤「%s」高互動作者", item.sampleSearchTag)
}
return "本次海巡高互動作者"
}