Skip to main content

tuitbot_core/scoring/
mod.rs

1//! Tweet scoring engine for reply-worthiness evaluation.
2//!
3//! Combines six independent signals (keyword relevance, follower score,
4//! recency, engagement rate, reply count, content type) into a total score
5//! (0-100) with a configurable threshold for the REPLY/SKIP verdict.
6//!
7//! All scoring is purely heuristic — no LLM calls.
8
9pub mod signals;
10
11mod engine;
12mod weights;
13
14pub use engine::ScoringEngine;
15pub use weights::{
16    find_matched_keywords, format_follower_count, format_tweet_age, format_tweet_age_at,
17    truncate_text,
18};
19
20use crate::config::ScoringConfig;
21
22/// Input data for scoring a tweet.
23///
24/// This struct decouples the scoring engine from specific API types,
25/// allowing the engine to be used with any data source.
26#[derive(Debug, Clone)]
27pub struct TweetData {
28    /// The tweet text content.
29    pub text: String,
30    /// ISO-8601 timestamp of when the tweet was created.
31    pub created_at: String,
32    /// Number of likes on the tweet.
33    pub likes: u64,
34    /// Number of retweets.
35    pub retweets: u64,
36    /// Number of replies.
37    pub replies: u64,
38    /// Author's username (for display).
39    pub author_username: String,
40    /// Author's follower count.
41    pub author_followers: u64,
42    /// Whether the tweet has attached media (images, video, etc.).
43    #[allow(dead_code)]
44    pub has_media: bool,
45    /// Whether the tweet is a quote tweet.
46    #[allow(dead_code)]
47    pub is_quote_tweet: bool,
48}
49
50/// Per-signal score breakdown for a tweet.
51#[derive(Debug, Clone)]
52pub struct TweetScore {
53    /// Total score (0-100), clamped.
54    pub total: f32,
55    /// Keyword relevance signal score.
56    pub keyword_relevance: f32,
57    /// Author follower count signal score.
58    pub follower: f32,
59    /// Tweet recency signal score.
60    pub recency: f32,
61    /// Engagement rate signal score.
62    pub engagement: f32,
63    /// Reply count signal score (fewer replies = higher).
64    pub reply_count: f32,
65    /// Content type signal score (text-only = max).
66    pub content_type: f32,
67    /// Whether the total score meets the configured threshold.
68    pub meets_threshold: bool,
69}
70
71impl TweetScore {
72    /// Format a human-readable breakdown of the score.
73    ///
74    /// Shows the total score, per-signal breakdown with context,
75    /// and the REPLY/SKIP verdict.
76    pub fn format_breakdown(
77        &self,
78        config: &ScoringConfig,
79        tweet: &TweetData,
80        matched_keywords: &[String],
81    ) -> String {
82        let truncated = truncate_text(&tweet.text, 50);
83        let formatted_followers = format_follower_count(tweet.author_followers);
84        let age = format_tweet_age(&tweet.created_at);
85        let matched_list = if matched_keywords.is_empty() {
86            "none".to_string()
87        } else {
88            matched_keywords.join(", ")
89        };
90
91        let total_engagement = tweet.likes + tweet.retweets + tweet.replies;
92        let followers_for_rate = tweet.author_followers.max(1) as f64;
93        let rate_pct = (total_engagement as f64 / followers_for_rate) * 100.0;
94
95        let verdict = if self.meets_threshold {
96            "REPLY"
97        } else {
98            "SKIP"
99        };
100
101        let reply_count_display = tweet.replies;
102
103        format!(
104            "Tweet: \"{}\" by @{} ({} followers)\n\
105             Score: {:.0}/100\n\
106             \x20 Keyword relevance:  {:.0}/{}  (matched: {})\n\
107             \x20 Author reach:       {:.0}/{}  ({} followers, bell curve)\n\
108             \x20 Recency:            {:.0}/{}  (posted {} ago)\n\
109             \x20 Engagement rate:    {:.0}/{}  ({:.1}% engagement vs 1.5% baseline)\n\
110             \x20 Reply count:        {:.0}/{}  ({} existing replies)\n\
111             \x20 Content type:       {:.0}/{}  ({})\n\
112             Verdict: {} (threshold: {})",
113            truncated,
114            tweet.author_username,
115            formatted_followers,
116            self.total,
117            self.keyword_relevance,
118            config.keyword_relevance_max as u32,
119            matched_list,
120            self.follower,
121            config.follower_count_max as u32,
122            formatted_followers,
123            self.recency,
124            config.recency_max as u32,
125            age,
126            self.engagement,
127            config.engagement_rate_max as u32,
128            rate_pct,
129            self.reply_count,
130            config.reply_count_max as u32,
131            reply_count_display,
132            self.content_type,
133            config.content_type_max as u32,
134            if tweet.has_media || tweet.is_quote_tweet {
135                "media/quote"
136            } else {
137                "text-only"
138            },
139            verdict,
140            config.threshold,
141        )
142    }
143}
144
145impl std::fmt::Display for TweetScore {
146    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
147        write!(
148            f,
149            "Score: {:.0}/100 [kw:{:.0} fol:{:.0} rec:{:.0} eng:{:.0} rep:{:.0} ct:{:.0}] {}",
150            self.total,
151            self.keyword_relevance,
152            self.follower,
153            self.recency,
154            self.engagement,
155            self.reply_count,
156            self.content_type,
157            if self.meets_threshold {
158                "REPLY"
159            } else {
160                "SKIP"
161            }
162        )
163    }
164}
165
166#[cfg(test)]
167mod signals_tests;
168#[cfg(test)]
169mod tests;