use super::metrics::TopicPerformance;
use crate::storage::strategy::StrategyReportRow;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct Recommendation {
pub category: String,
pub priority: String,
pub title: String,
pub description: String,
}
#[derive(Debug, Clone)]
pub struct WeekMetrics {
pub replies_sent: i64,
pub tweets_posted: i64,
pub threads_posted: i64,
pub target_replies: i64,
pub follower_delta: i64,
pub avg_reply_score: f64,
pub avg_tweet_score: f64,
pub reply_acceptance_rate: f64,
pub top_topics: Vec<TopicPerformance>,
pub bottom_topics: Vec<TopicPerformance>,
pub distinct_topic_count: i64,
pub max_replies_per_week: i64,
pub max_tweets_per_week: i64,
}
pub fn generate(
metrics: &WeekMetrics,
previous: Option<&StrategyReportRow>,
) -> Vec<Recommendation> {
let mut recs = Vec::new();
let overall_avg = overall_average(metrics);
for topic in &metrics.top_topics {
if topic.post_count >= 3 && overall_avg > 0.0 && topic.avg_score > overall_avg * 1.5 {
recs.push(Recommendation {
category: "promote".to_string(),
priority: "high".to_string(),
title: format!("Double down on \"{}\"", topic.topic),
description: format!(
"This topic averages {:.1} — {:.0}% above your overall average. \
Consider posting more about it.",
topic.avg_score,
(topic.avg_score / overall_avg - 1.0) * 100.0
),
});
}
}
for topic in &metrics.bottom_topics {
if topic.post_count >= 3 && overall_avg > 0.0 && topic.avg_score < overall_avg * 0.5 {
recs.push(Recommendation {
category: "kill".to_string(),
priority: "medium".to_string(),
title: format!("Reconsider \"{}\"", topic.topic),
description: format!(
"This topic averages {:.1} — {:.0}% below your overall average across {} posts. \
Consider dropping or reworking it.",
topic.avg_score,
(1.0 - topic.avg_score / overall_avg) * 100.0,
topic.post_count
),
});
}
}
let total_output = metrics.replies_sent + metrics.tweets_posted + metrics.threads_posted;
let total_capacity = metrics.max_replies_per_week + metrics.max_tweets_per_week;
if total_capacity > 0 && total_output > 0 && total_output < total_capacity / 2 {
recs.push(Recommendation {
category: "alert".to_string(),
priority: "low".to_string(),
title: "Low output volume".to_string(),
description: format!(
"You posted {total_output} items this week — under 50% of your configured \
capacity ({total_capacity}). Check if the engine is running or if discovery \
needs tuning."
),
});
}
if metrics.follower_delta <= 0 && total_output > 0 {
recs.push(Recommendation {
category: "alert".to_string(),
priority: "high".to_string(),
title: "Follower growth stalled".to_string(),
description: format!(
"You posted {total_output} items but followers {} by {}. \
Review content quality and targeting.",
if metrics.follower_delta < 0 {
"dropped"
} else {
"stayed flat"
},
metrics.follower_delta.unsigned_abs()
),
});
}
if metrics.reply_acceptance_rate < 0.10 && metrics.replies_sent > 0 {
recs.push(Recommendation {
category: "experiment".to_string(),
priority: "medium".to_string(),
title: "Low reply acceptance rate".to_string(),
description: format!(
"Only {:.0}% of your replies received a response. Try different reply \
archetypes or target higher-engagement conversations.",
metrics.reply_acceptance_rate * 100.0
),
});
}
if metrics.reply_acceptance_rate > 0.30 && metrics.replies_sent > 0 {
recs.push(Recommendation {
category: "celebrate".to_string(),
priority: "low".to_string(),
title: "Strong reply engagement".to_string(),
description: format!(
"{:.0}% of your replies received responses — well above average. \
Keep doing what you're doing.",
metrics.reply_acceptance_rate * 100.0
),
});
}
if let Some(prev) = previous {
let prev_avg = (prev.avg_reply_score + prev.avg_tweet_score) / 2.0;
if prev_avg > 0.0 {
let pct_change = (overall_avg - prev_avg) / prev_avg;
if pct_change < -0.20 {
recs.push(Recommendation {
category: "alert".to_string(),
priority: "high".to_string(),
title: "Engagement score regression".to_string(),
description: format!(
"Average score dropped {:.0}% vs last week ({:.1} → {:.1}). \
Review recent content for quality issues.",
pct_change.abs() * 100.0,
prev_avg,
overall_avg
),
});
}
}
}
if metrics.distinct_topic_count <= 2 && metrics.tweets_posted > 0 {
recs.push(Recommendation {
category: "experiment".to_string(),
priority: "low".to_string(),
title: "Low topic diversity".to_string(),
description: format!(
"You only posted about {} distinct topic(s) this week. \
Try branching into adjacent topics to reach a wider audience.",
metrics.distinct_topic_count
),
});
}
recs.sort_by(|a, b| priority_rank(&a.priority).cmp(&priority_rank(&b.priority)));
recs
}
fn overall_average(metrics: &WeekMetrics) -> f64 {
let scores = [metrics.avg_reply_score, metrics.avg_tweet_score];
let non_zero: Vec<f64> = scores.iter().copied().filter(|s| *s > 0.0).collect();
if non_zero.is_empty() {
return 0.0;
}
non_zero.iter().sum::<f64>() / non_zero.len() as f64
}
fn priority_rank(priority: &str) -> u8 {
match priority {
"high" => 0,
"medium" => 1,
"low" => 2,
_ => 3,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn base_metrics() -> WeekMetrics {
WeekMetrics {
replies_sent: 40,
tweets_posted: 15,
threads_posted: 1,
target_replies: 5,
follower_delta: 50,
avg_reply_score: 60.0,
avg_tweet_score: 70.0,
reply_acceptance_rate: 0.20,
top_topics: vec![],
bottom_topics: vec![],
distinct_topic_count: 5,
max_replies_per_week: 70,
max_tweets_per_week: 21,
}
}
#[test]
fn no_recommendations_for_healthy_metrics() {
let metrics = base_metrics();
let recs = generate(&metrics, None);
assert!(recs.is_empty(), "expected no recs, got: {recs:?}");
}
#[test]
fn promote_winners() {
let mut metrics = base_metrics();
metrics.top_topics = vec![TopicPerformance {
topic: "rust".to_string(),
format: String::new(),
avg_score: 120.0, post_count: 5,
}];
let recs = generate(&metrics, None);
assert!(recs.iter().any(|r| r.category == "promote"));
}
#[test]
fn kill_losers() {
let mut metrics = base_metrics();
metrics.bottom_topics = vec![TopicPerformance {
topic: "crypto".to_string(),
format: String::new(),
avg_score: 10.0, post_count: 5,
}];
let recs = generate(&metrics, None);
assert!(recs.iter().any(|r| r.category == "kill"));
}
#[test]
fn follower_stall_alert() {
let mut metrics = base_metrics();
metrics.follower_delta = 0;
let recs = generate(&metrics, None);
assert!(recs
.iter()
.any(|r| r.category == "alert" && r.title.contains("stalled")));
}
#[test]
fn low_reply_quality() {
let mut metrics = base_metrics();
metrics.reply_acceptance_rate = 0.05;
let recs = generate(&metrics, None);
assert!(recs.iter().any(|r| r.category == "experiment"));
}
#[test]
fn high_reply_quality_celebrate() {
let mut metrics = base_metrics();
metrics.reply_acceptance_rate = 0.40;
let recs = generate(&metrics, None);
assert!(recs.iter().any(|r| r.category == "celebrate"));
}
#[test]
fn wow_regression() {
let metrics = base_metrics();
let prev = StrategyReportRow {
id: 1,
week_start: "2026-02-17".to_string(),
week_end: "2026-02-23".to_string(),
replies_sent: 20,
tweets_posted: 10,
threads_posted: 1,
target_replies: 5,
follower_start: 950,
follower_end: 1000,
follower_delta: 50,
avg_reply_score: 100.0,
avg_tweet_score: 100.0, reply_acceptance_rate: 0.25,
estimated_follow_conversion: 0.01,
top_topics_json: "[]".to_string(),
bottom_topics_json: "[]".to_string(),
top_content_json: "[]".to_string(),
recommendations_json: "[]".to_string(),
created_at: String::new(),
};
let recs = generate(&metrics, Some(&prev));
assert!(recs
.iter()
.any(|r| r.category == "alert" && r.title.contains("regression")));
}
#[test]
fn low_topic_diversity() {
let mut metrics = base_metrics();
metrics.distinct_topic_count = 1;
let recs = generate(&metrics, None);
assert!(recs
.iter()
.any(|r| r.category == "experiment" && r.title.contains("diversity")));
}
#[test]
fn recommendations_sorted_by_priority() {
let mut metrics = base_metrics();
metrics.follower_delta = 0; metrics.reply_acceptance_rate = 0.05; metrics.distinct_topic_count = 1; let recs = generate(&metrics, None);
assert_eq!(recs[0].priority, "high");
}
}