1use super::metrics::TopicPerformance;
5use crate::storage::strategy::StrategyReportRow;
6
7#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
9pub struct Recommendation {
10 pub category: String,
12 pub priority: String,
14 pub title: String,
16 pub description: String,
18}
19
20#[derive(Debug, Clone)]
22pub struct WeekMetrics {
23 pub replies_sent: i64,
24 pub tweets_posted: i64,
25 pub threads_posted: i64,
26 pub target_replies: i64,
27 pub follower_delta: i64,
28 pub avg_reply_score: f64,
29 pub avg_tweet_score: f64,
30 pub reply_acceptance_rate: f64,
31 pub top_topics: Vec<TopicPerformance>,
32 pub bottom_topics: Vec<TopicPerformance>,
33 pub distinct_topic_count: i64,
34 pub max_replies_per_week: i64,
36 pub max_tweets_per_week: i64,
37}
38
39pub fn generate(
41 metrics: &WeekMetrics,
42 previous: Option<&StrategyReportRow>,
43) -> Vec<Recommendation> {
44 let mut recs = Vec::new();
45
46 let overall_avg = overall_average(metrics);
48
49 for topic in &metrics.top_topics {
51 if topic.post_count >= 3 && overall_avg > 0.0 && topic.avg_score > overall_avg * 1.5 {
52 recs.push(Recommendation {
53 category: "promote".to_string(),
54 priority: "high".to_string(),
55 title: format!("Double down on \"{}\"", topic.topic),
56 description: format!(
57 "This topic averages {:.1} — {:.0}% above your overall average. \
58 Consider posting more about it.",
59 topic.avg_score,
60 (topic.avg_score / overall_avg - 1.0) * 100.0
61 ),
62 });
63 }
64 }
65
66 for topic in &metrics.bottom_topics {
68 if topic.post_count >= 3 && overall_avg > 0.0 && topic.avg_score < overall_avg * 0.5 {
69 recs.push(Recommendation {
70 category: "kill".to_string(),
71 priority: "medium".to_string(),
72 title: format!("Reconsider \"{}\"", topic.topic),
73 description: format!(
74 "This topic averages {:.1} — {:.0}% below your overall average across {} posts. \
75 Consider dropping or reworking it.",
76 topic.avg_score,
77 (1.0 - topic.avg_score / overall_avg) * 100.0,
78 topic.post_count
79 ),
80 });
81 }
82 }
83
84 let total_output = metrics.replies_sent + metrics.tweets_posted + metrics.threads_posted;
86 let total_capacity = metrics.max_replies_per_week + metrics.max_tweets_per_week;
87 if total_capacity > 0 && total_output > 0 && total_output < total_capacity / 2 {
88 recs.push(Recommendation {
89 category: "alert".to_string(),
90 priority: "low".to_string(),
91 title: "Low output volume".to_string(),
92 description: format!(
93 "You posted {total_output} items this week — under 50% of your configured \
94 capacity ({total_capacity}). Check if the engine is running or if discovery \
95 needs tuning."
96 ),
97 });
98 }
99
100 if metrics.follower_delta <= 0 && total_output > 0 {
102 recs.push(Recommendation {
103 category: "alert".to_string(),
104 priority: "high".to_string(),
105 title: "Follower growth stalled".to_string(),
106 description: format!(
107 "You posted {total_output} items but followers {} by {}. \
108 Review content quality and targeting.",
109 if metrics.follower_delta < 0 {
110 "dropped"
111 } else {
112 "stayed flat"
113 },
114 metrics.follower_delta.unsigned_abs()
115 ),
116 });
117 }
118
119 if metrics.reply_acceptance_rate < 0.10 && metrics.replies_sent > 0 {
121 recs.push(Recommendation {
122 category: "experiment".to_string(),
123 priority: "medium".to_string(),
124 title: "Low reply acceptance rate".to_string(),
125 description: format!(
126 "Only {:.0}% of your replies received a response. Try different reply \
127 archetypes or target higher-engagement conversations.",
128 metrics.reply_acceptance_rate * 100.0
129 ),
130 });
131 }
132
133 if metrics.reply_acceptance_rate > 0.30 && metrics.replies_sent > 0 {
135 recs.push(Recommendation {
136 category: "celebrate".to_string(),
137 priority: "low".to_string(),
138 title: "Strong reply engagement".to_string(),
139 description: format!(
140 "{:.0}% of your replies received responses — well above average. \
141 Keep doing what you're doing.",
142 metrics.reply_acceptance_rate * 100.0
143 ),
144 });
145 }
146
147 if let Some(prev) = previous {
149 let prev_avg = (prev.avg_reply_score + prev.avg_tweet_score) / 2.0;
150 if prev_avg > 0.0 {
151 let pct_change = (overall_avg - prev_avg) / prev_avg;
152 if pct_change < -0.20 {
153 recs.push(Recommendation {
154 category: "alert".to_string(),
155 priority: "high".to_string(),
156 title: "Engagement score regression".to_string(),
157 description: format!(
158 "Average score dropped {:.0}% vs last week ({:.1} → {:.1}). \
159 Review recent content for quality issues.",
160 pct_change.abs() * 100.0,
161 prev_avg,
162 overall_avg
163 ),
164 });
165 }
166 }
167 }
168
169 if metrics.distinct_topic_count <= 2 && metrics.tweets_posted > 0 {
171 recs.push(Recommendation {
172 category: "experiment".to_string(),
173 priority: "low".to_string(),
174 title: "Low topic diversity".to_string(),
175 description: format!(
176 "You only posted about {} distinct topic(s) this week. \
177 Try branching into adjacent topics to reach a wider audience.",
178 metrics.distinct_topic_count
179 ),
180 });
181 }
182
183 recs.sort_by(|a, b| priority_rank(&a.priority).cmp(&priority_rank(&b.priority)));
185
186 recs
187}
188
189fn overall_average(metrics: &WeekMetrics) -> f64 {
190 let scores = [metrics.avg_reply_score, metrics.avg_tweet_score];
191 let non_zero: Vec<f64> = scores.iter().copied().filter(|s| *s > 0.0).collect();
192 if non_zero.is_empty() {
193 return 0.0;
194 }
195 non_zero.iter().sum::<f64>() / non_zero.len() as f64
196}
197
198fn priority_rank(priority: &str) -> u8 {
199 match priority {
200 "high" => 0,
201 "medium" => 1,
202 "low" => 2,
203 _ => 3,
204 }
205}
206
207#[cfg(test)]
208mod tests {
209 use super::*;
210
211 fn base_metrics() -> WeekMetrics {
212 WeekMetrics {
213 replies_sent: 40,
214 tweets_posted: 15,
215 threads_posted: 1,
216 target_replies: 5,
217 follower_delta: 50,
218 avg_reply_score: 60.0,
219 avg_tweet_score: 70.0,
220 reply_acceptance_rate: 0.20,
221 top_topics: vec![],
222 bottom_topics: vec![],
223 distinct_topic_count: 5,
224 max_replies_per_week: 70,
225 max_tweets_per_week: 21,
226 }
227 }
228
229 #[test]
230 fn no_recommendations_for_healthy_metrics() {
231 let metrics = base_metrics();
232 let recs = generate(&metrics, None);
233 assert!(recs.is_empty(), "expected no recs, got: {recs:?}");
234 }
235
236 #[test]
237 fn promote_winners() {
238 let mut metrics = base_metrics();
239 metrics.top_topics = vec![TopicPerformance {
240 topic: "rust".to_string(),
241 format: String::new(),
242 avg_score: 120.0, post_count: 5,
244 }];
245 let recs = generate(&metrics, None);
246 assert!(recs.iter().any(|r| r.category == "promote"));
247 }
248
249 #[test]
250 fn kill_losers() {
251 let mut metrics = base_metrics();
252 metrics.bottom_topics = vec![TopicPerformance {
253 topic: "crypto".to_string(),
254 format: String::new(),
255 avg_score: 10.0, post_count: 5,
257 }];
258 let recs = generate(&metrics, None);
259 assert!(recs.iter().any(|r| r.category == "kill"));
260 }
261
262 #[test]
263 fn follower_stall_alert() {
264 let mut metrics = base_metrics();
265 metrics.follower_delta = 0;
266 let recs = generate(&metrics, None);
267 assert!(recs
268 .iter()
269 .any(|r| r.category == "alert" && r.title.contains("stalled")));
270 }
271
272 #[test]
273 fn low_reply_quality() {
274 let mut metrics = base_metrics();
275 metrics.reply_acceptance_rate = 0.05;
276 let recs = generate(&metrics, None);
277 assert!(recs.iter().any(|r| r.category == "experiment"));
278 }
279
280 #[test]
281 fn high_reply_quality_celebrate() {
282 let mut metrics = base_metrics();
283 metrics.reply_acceptance_rate = 0.40;
284 let recs = generate(&metrics, None);
285 assert!(recs.iter().any(|r| r.category == "celebrate"));
286 }
287
288 #[test]
289 fn wow_regression() {
290 let metrics = base_metrics();
291 let prev = StrategyReportRow {
292 id: 1,
293 week_start: "2026-02-17".to_string(),
294 week_end: "2026-02-23".to_string(),
295 replies_sent: 20,
296 tweets_posted: 10,
297 threads_posted: 1,
298 target_replies: 5,
299 follower_start: 950,
300 follower_end: 1000,
301 follower_delta: 50,
302 avg_reply_score: 100.0,
303 avg_tweet_score: 100.0, reply_acceptance_rate: 0.25,
305 estimated_follow_conversion: 0.01,
306 top_topics_json: "[]".to_string(),
307 bottom_topics_json: "[]".to_string(),
308 top_content_json: "[]".to_string(),
309 recommendations_json: "[]".to_string(),
310 created_at: String::new(),
311 };
312 let recs = generate(&metrics, Some(&prev));
314 assert!(recs
315 .iter()
316 .any(|r| r.category == "alert" && r.title.contains("regression")));
317 }
318
319 #[test]
320 fn low_topic_diversity() {
321 let mut metrics = base_metrics();
322 metrics.distinct_topic_count = 1;
323 let recs = generate(&metrics, None);
324 assert!(recs
325 .iter()
326 .any(|r| r.category == "experiment" && r.title.contains("diversity")));
327 }
328
329 #[test]
330 fn recommendations_sorted_by_priority() {
331 let mut metrics = base_metrics();
332 metrics.follower_delta = 0; metrics.reply_acceptance_rate = 0.05; metrics.distinct_topic_count = 1; let recs = generate(&metrics, None);
336 assert_eq!(recs[0].priority, "high");
338 }
339}