tuitbot_core/scoring/
mod.rs1pub 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#[derive(Debug, Clone)]
27pub struct TweetData {
28 pub text: String,
30 pub created_at: String,
32 pub likes: u64,
34 pub retweets: u64,
36 pub replies: u64,
38 pub author_username: String,
40 pub author_followers: u64,
42 #[allow(dead_code)]
44 pub has_media: bool,
45 #[allow(dead_code)]
47 pub is_quote_tweet: bool,
48}
49
50#[derive(Debug, Clone)]
52pub struct TweetScore {
53 pub total: f32,
55 pub keyword_relevance: f32,
57 pub follower: f32,
59 pub recency: f32,
61 pub engagement: f32,
63 pub reply_count: f32,
65 pub content_type: f32,
67 pub meets_threshold: bool,
69}
70
71impl TweetScore {
72 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;