Skip to main content

tuitbot_core/scoring/
engine.rs

1//! Scoring engine — combines all signals into a unified tweet score.
2
3use chrono::{DateTime, Utc};
4
5use crate::config::ScoringConfig;
6
7use super::signals;
8use super::{TweetData, TweetScore};
9
10/// Scoring engine that combines all signals into a unified score.
11pub struct ScoringEngine {
12    pub(super) config: ScoringConfig,
13    pub(super) keywords: Vec<String>,
14}
15
16impl ScoringEngine {
17    /// Create a new scoring engine with the given config and keywords.
18    ///
19    /// Keywords should be the combined list of `product_keywords` and
20    /// `competitor_keywords` from the business profile.
21    pub fn new(config: ScoringConfig, keywords: Vec<String>) -> Self {
22        Self { config, keywords }
23    }
24
25    /// Score a tweet using all six signals.
26    ///
27    /// Uses the current time for recency scoring.
28    pub fn score_tweet(&self, tweet: &TweetData) -> TweetScore {
29        self.score_tweet_at(tweet, Utc::now())
30    }
31
32    /// Score a tweet using all six signals with a specific time reference.
33    ///
34    /// Accepts `now` for deterministic testing.
35    pub fn score_tweet_at(&self, tweet: &TweetData, now: DateTime<Utc>) -> TweetScore {
36        let keyword_relevance = signals::keyword_relevance(
37            &tweet.text,
38            &self.keywords,
39            self.config.keyword_relevance_max,
40        );
41
42        let follower = signals::targeted_follower_score(
43            tweet.author_followers,
44            self.config.follower_count_max,
45        );
46
47        let recency = signals::recency_score_at(&tweet.created_at, self.config.recency_max, now);
48
49        let engagement = signals::engagement_rate(
50            tweet.likes,
51            tweet.retweets,
52            tweet.replies,
53            tweet.author_followers,
54            self.config.engagement_rate_max,
55        );
56
57        let reply_count = signals::reply_count_score(tweet.replies, self.config.reply_count_max);
58
59        let content_type = signals::content_type_score(
60            tweet.has_media,
61            tweet.is_quote_tweet,
62            self.config.content_type_max,
63        );
64
65        let total =
66            (keyword_relevance + follower + recency + engagement + reply_count + content_type)
67                .clamp(0.0, 100.0);
68        let meets_threshold = total >= self.config.threshold as f32;
69
70        tracing::debug!(
71            author = %tweet.author_username,
72            total = format!("{:.0}", total),
73            keyword = format!("{:.0}", keyword_relevance),
74            follower = format!("{:.0}", follower),
75            recency = format!("{:.0}", recency),
76            engagement = format!("{:.0}", engagement),
77            reply = format!("{:.0}", reply_count),
78            content = format!("{:.0}", content_type),
79            meets = meets_threshold,
80            "Scored tweet",
81        );
82
83        TweetScore {
84            total,
85            keyword_relevance,
86            follower,
87            recency,
88            engagement,
89            reply_count,
90            content_type,
91            meets_threshold,
92        }
93    }
94
95    /// Return the configured keywords.
96    pub fn keywords(&self) -> &[String] {
97        &self.keywords
98    }
99
100    /// Return the scoring configuration.
101    pub fn config(&self) -> &ScoringConfig {
102        &self.config
103    }
104}