use crate::config::Config;
use crate::error::StorageError;
use crate::scoring::find_matched_keywords;
use crate::storage::DbPool;
use serde::Serialize;
use super::author;
#[derive(Debug, Clone, Serialize)]
pub struct EngagementRecommendation {
pub recommended_action: String,
pub confidence: f64,
pub contributing_factors: Vec<ContributingFactor>,
pub policy_considerations: Vec<PolicyConsideration>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ContributingFactor {
pub factor: String,
pub signal: String,
pub weight: f64,
pub detail: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct PolicyConsideration {
pub policy: String,
pub status: String,
pub detail: String,
}
pub async fn recommend_engagement(
pool: &DbPool,
author_username: &str,
tweet_text: &str,
campaign_objective: Option<&str>,
config: &Config,
) -> Result<EngagementRecommendation, StorageError> {
let ctx = author::get_author_context(pool, author_username, config).await?;
let replies_today_total = crate::storage::replies::count_replies_today(pool).await?;
let mut factors = Vec::new();
let mut blocked = false;
let keywords = config.business.draft_context_keywords();
let matched = find_matched_keywords(tweet_text, &keywords);
let relevance_score = if matched.is_empty() {
factors.push(ContributingFactor {
factor: "keyword_relevance".into(),
signal: "negative".into(),
weight: 30.0,
detail: "No configured keyword matches in tweet text".into(),
});
10.0
} else {
let score = (matched.len() as f64 * 30.0).min(100.0);
factors.push(ContributingFactor {
factor: "keyword_relevance".into(),
signal: "positive".into(),
weight: 30.0,
detail: format!("Matched {} keywords: {}", matched.len(), matched.join(", ")),
});
score
};
let relationship_score = evaluate_relationship(&ctx, &mut factors);
let max_per_author = config.limits.max_replies_per_author_per_day as i64;
let frequency_score = if ctx.interaction_summary.replies_today >= max_per_author {
blocked = true;
factors.push(ContributingFactor {
factor: "author_frequency".into(),
signal: "negative".into(),
weight: 15.0,
detail: format!(
"At per-author daily limit ({}/{})",
ctx.interaction_summary.replies_today, max_per_author
),
});
0.0
} else if ctx.interaction_summary.replies_today > 0 {
factors.push(ContributingFactor {
factor: "author_frequency".into(),
signal: "neutral".into(),
weight: 15.0,
detail: format!(
"Replied {} time(s) today (limit: {})",
ctx.interaction_summary.replies_today, max_per_author
),
});
40.0
} else {
factors.push(ContributingFactor {
factor: "author_frequency".into(),
signal: "positive".into(),
weight: 15.0,
detail: "No replies to this author today".into(),
});
100.0
};
let max_per_day = config.limits.max_replies_per_day as i64;
let capacity_score =
evaluate_capacity(replies_today_total, max_per_day, &mut factors, &mut blocked);
let alignment_score = evaluate_campaign(tweet_text, campaign_objective, &mut factors);
let weighted_total = (relevance_score * 30.0
+ relationship_score * 20.0
+ frequency_score * 15.0
+ capacity_score * 15.0
+ alignment_score * 20.0)
/ 100.0;
let (action, confidence) = decide_action(weighted_total, blocked);
let policies = build_policy_considerations(
config,
replies_today_total,
max_per_day,
ctx.interaction_summary.replies_today,
max_per_author,
);
Ok(EngagementRecommendation {
recommended_action: action,
confidence,
contributing_factors: factors,
policy_considerations: policies,
})
}
fn evaluate_relationship(
ctx: &author::AuthorContext,
factors: &mut Vec<ContributingFactor>,
) -> f64 {
if ctx.interaction_summary.total_replies_sent > 0 {
if ctx.response_metrics.response_rate > 0.2 {
factors.push(ContributingFactor {
factor: "author_relationship".into(),
signal: "positive".into(),
weight: 20.0,
detail: format!(
"Good engagement history ({:.0}% response rate over {} interactions)",
ctx.response_metrics.response_rate * 100.0,
ctx.response_metrics.replies_measured
),
});
90.0
} else if ctx.response_metrics.response_rate > 0.0 {
factors.push(ContributingFactor {
factor: "author_relationship".into(),
signal: "neutral".into(),
weight: 20.0,
detail: format!(
"Some engagement history ({:.0}% response rate)",
ctx.response_metrics.response_rate * 100.0
),
});
60.0
} else {
factors.push(ContributingFactor {
factor: "author_relationship".into(),
signal: "negative".into(),
weight: 20.0,
detail: "Previous interactions received no engagement".into(),
});
30.0
}
} else {
factors.push(ContributingFactor {
factor: "author_relationship".into(),
signal: "neutral".into(),
weight: 20.0,
detail: "No prior interaction — fresh engagement opportunity".into(),
});
50.0
}
}
fn evaluate_capacity(
replies_today: i64,
max_per_day: i64,
factors: &mut Vec<ContributingFactor>,
blocked: &mut bool,
) -> f64 {
if replies_today >= max_per_day {
*blocked = true;
factors.push(ContributingFactor {
factor: "daily_capacity".into(),
signal: "negative".into(),
weight: 15.0,
detail: format!("Daily limit reached ({}/{})", replies_today, max_per_day),
});
0.0
} else {
let utilization = replies_today as f64 / max_per_day.max(1) as f64;
if utilization > 0.8 {
factors.push(ContributingFactor {
factor: "daily_capacity".into(),
signal: "negative".into(),
weight: 15.0,
detail: format!(
"Nearing daily limit ({}/{}, {:.0}% used)",
replies_today,
max_per_day,
utilization * 100.0
),
});
30.0
} else {
factors.push(ContributingFactor {
factor: "daily_capacity".into(),
signal: "positive".into(),
weight: 15.0,
detail: format!(
"Capacity available ({}/{}, {:.0}% used)",
replies_today,
max_per_day,
utilization * 100.0
),
});
100.0
}
}
}
fn evaluate_campaign(
tweet_text: &str,
campaign_objective: Option<&str>,
factors: &mut Vec<ContributingFactor>,
) -> f64 {
let Some(objective) = campaign_objective.filter(|o| !o.is_empty()) else {
factors.push(ContributingFactor {
factor: "campaign_alignment".into(),
signal: "neutral".into(),
weight: 20.0,
detail: "No campaign objective specified".into(),
});
return 50.0;
};
let tweet_lower = tweet_text.to_lowercase();
let objective_words: Vec<&str> = objective
.split_whitespace()
.filter(|w| w.len() > 3)
.collect();
let matching: Vec<&&str> = objective_words
.iter()
.filter(|w| tweet_lower.contains(&w.to_lowercase()))
.collect();
if matching.len() >= 3 {
factors.push(ContributingFactor {
factor: "campaign_alignment".into(),
signal: "positive".into(),
weight: 20.0,
detail: format!(
"Strong alignment — {} objective terms found in tweet",
matching.len()
),
});
90.0
} else if !matching.is_empty() {
factors.push(ContributingFactor {
factor: "campaign_alignment".into(),
signal: "neutral".into(),
weight: 20.0,
detail: format!(
"Partial alignment — {} objective term(s) found in tweet",
matching.len()
),
});
60.0
} else {
factors.push(ContributingFactor {
factor: "campaign_alignment".into(),
signal: "negative".into(),
weight: 20.0,
detail: "No objective terms found in tweet text".into(),
});
20.0
}
}
fn decide_action(weighted_total: f64, blocked: bool) -> (String, f64) {
if blocked {
return ("skip".to_string(), 0.95);
}
if weighted_total >= 65.0 {
let confidence = (0.5 + (weighted_total - 65.0) / 70.0).clamp(0.6, 0.95);
("reply".to_string(), confidence)
} else if weighted_total >= 40.0 {
let confidence = (0.4 + (weighted_total - 40.0) / 62.5).clamp(0.4, 0.8);
("observe".to_string(), confidence)
} else {
let confidence = (0.5 + (40.0 - weighted_total) / 80.0).clamp(0.5, 0.95);
("skip".to_string(), confidence)
}
}
fn build_policy_considerations(
config: &Config,
replies_today: i64,
max_per_day: i64,
replies_to_author: i64,
max_per_author: i64,
) -> Vec<PolicyConsideration> {
let mut policies = Vec::new();
if config.effective_approval_mode() {
policies.push(PolicyConsideration {
policy: "approval_mode".into(),
status: "warning".into(),
detail: "Approval mode active — replies require manual review".into(),
});
}
if replies_today >= max_per_day {
policies.push(PolicyConsideration {
policy: "daily_rate_limit".into(),
status: "blocked".into(),
detail: format!("Daily limit reached ({}/{})", replies_today, max_per_day),
});
} else if replies_today as f64 > max_per_day as f64 * 0.8 {
policies.push(PolicyConsideration {
policy: "daily_rate_limit".into(),
status: "warning".into(),
detail: format!(
"Approaching daily limit ({}/{})",
replies_today, max_per_day
),
});
}
if replies_to_author >= max_per_author {
policies.push(PolicyConsideration {
policy: "per_author_limit".into(),
status: "blocked".into(),
detail: format!(
"Per-author limit reached ({}/{})",
replies_to_author, max_per_author
),
});
}
policies
}
#[cfg(test)]
mod tests {
use super::*;
use crate::storage::init_test_db;
#[test]
fn decide_action_blocked() {
let (action, confidence) = decide_action(100.0, true);
assert_eq!(action, "skip");
assert!((confidence - 0.95).abs() < 0.01);
}
#[test]
fn decide_action_high_score_reply() {
let (action, confidence) = decide_action(80.0, false);
assert_eq!(action, "reply");
assert!(confidence >= 0.6);
assert!(confidence <= 0.95);
}
#[test]
fn decide_action_medium_score_observe() {
let (action, confidence) = decide_action(50.0, false);
assert_eq!(action, "observe");
assert!(confidence >= 0.4);
assert!(confidence <= 0.8);
}
#[test]
fn decide_action_low_score_skip() {
let (action, confidence) = decide_action(20.0, false);
assert_eq!(action, "skip");
assert!(confidence >= 0.5);
}
#[test]
fn decide_action_boundary_65() {
let (action, _) = decide_action(65.0, false);
assert_eq!(action, "reply");
}
#[test]
fn decide_action_boundary_40() {
let (action, _) = decide_action(40.0, false);
assert_eq!(action, "observe");
}
#[test]
fn decide_action_boundary_39() {
let (action, _) = decide_action(39.0, false);
assert_eq!(action, "skip");
}
#[test]
fn campaign_no_objective() {
let mut factors = Vec::new();
let score = evaluate_campaign("Some tweet about rust programming", None, &mut factors);
assert_eq!(score, 50.0);
assert_eq!(factors.len(), 1);
assert_eq!(factors[0].signal, "neutral");
}
#[test]
fn campaign_empty_objective() {
let mut factors = Vec::new();
let score = evaluate_campaign("Some tweet", Some(""), &mut factors);
assert_eq!(score, 50.0);
}
#[test]
fn campaign_strong_alignment() {
let mut factors = Vec::new();
let score = evaluate_campaign(
"Rust async programming with tokio runtime is great",
Some("Rust async programming tokio runtime"),
&mut factors,
);
assert_eq!(score, 90.0);
assert_eq!(factors.last().unwrap().signal, "positive");
}
#[test]
fn campaign_partial_alignment() {
let mut factors = Vec::new();
let score = evaluate_campaign(
"I love rust programming today",
Some("Rust programming excellence"),
&mut factors,
);
assert!(score >= 60.0);
assert_eq!(factors.last().unwrap().signal, "neutral");
}
#[test]
fn campaign_no_alignment() {
let mut factors = Vec::new();
let score = evaluate_campaign(
"The weather is nice today",
Some("Rust programming async tokio"),
&mut factors,
);
assert_eq!(score, 20.0);
assert_eq!(factors.last().unwrap().signal, "negative");
}
#[test]
fn capacity_at_limit() {
let mut factors = Vec::new();
let mut blocked = false;
let score = evaluate_capacity(50, 50, &mut factors, &mut blocked);
assert_eq!(score, 0.0);
assert!(blocked);
}
#[test]
fn capacity_over_80_percent() {
let mut factors = Vec::new();
let mut blocked = false;
let score = evaluate_capacity(42, 50, &mut factors, &mut blocked);
assert_eq!(score, 30.0);
assert!(!blocked);
}
#[test]
fn capacity_under_80_percent() {
let mut factors = Vec::new();
let mut blocked = false;
let score = evaluate_capacity(10, 50, &mut factors, &mut blocked);
assert_eq!(score, 100.0);
assert!(!blocked);
}
#[test]
fn capacity_empty() {
let mut factors = Vec::new();
let mut blocked = false;
let score = evaluate_capacity(0, 50, &mut factors, &mut blocked);
assert_eq!(score, 100.0);
assert!(!blocked);
}
#[test]
fn relationship_no_prior_interaction() {
let mut factors = Vec::new();
let ctx = author::AuthorContext {
author_username: "test".to_string(),
author_id: None,
interaction_summary: author::InteractionSummary {
total_replies_sent: 0,
replies_today: 0,
first_interaction: None,
last_interaction: None,
distinct_days_active: 0,
},
conversation_history: vec![],
topic_affinity: vec![],
risk_signals: vec![],
response_metrics: author::ResponseMetrics {
replies_with_engagement: 0,
replies_measured: 0,
response_rate: 0.0,
avg_performance_score: 0.0,
},
};
let score = evaluate_relationship(&ctx, &mut factors);
assert_eq!(score, 50.0);
}
#[test]
fn relationship_good_engagement() {
let mut factors = Vec::new();
let ctx = author::AuthorContext {
author_username: "test".to_string(),
author_id: None,
interaction_summary: author::InteractionSummary {
total_replies_sent: 5,
replies_today: 1,
first_interaction: Some("2026-01-01".to_string()),
last_interaction: Some("2026-03-01".to_string()),
distinct_days_active: 3,
},
conversation_history: vec![],
topic_affinity: vec![],
risk_signals: vec![],
response_metrics: author::ResponseMetrics {
replies_with_engagement: 3,
replies_measured: 5,
response_rate: 0.6,
avg_performance_score: 70.0,
},
};
let score = evaluate_relationship(&ctx, &mut factors);
assert_eq!(score, 90.0);
}
#[test]
fn relationship_no_engagement() {
let mut factors = Vec::new();
let ctx = author::AuthorContext {
author_username: "test".to_string(),
author_id: None,
interaction_summary: author::InteractionSummary {
total_replies_sent: 3,
replies_today: 0,
first_interaction: Some("2026-01-01".to_string()),
last_interaction: Some("2026-02-01".to_string()),
distinct_days_active: 2,
},
conversation_history: vec![],
topic_affinity: vec![],
risk_signals: vec![],
response_metrics: author::ResponseMetrics {
replies_with_engagement: 0,
replies_measured: 3,
response_rate: 0.0,
avg_performance_score: 10.0,
},
};
let score = evaluate_relationship(&ctx, &mut factors);
assert_eq!(score, 30.0);
}
#[test]
fn policy_empty_when_ok() {
let config = Config::default();
let policies = build_policy_considerations(&config, 0, 50, 0, 5);
assert!(policies
.iter()
.all(|p| p.policy != "daily_rate_limit" && p.policy != "per_author_limit"));
}
#[test]
fn policy_daily_limit_blocked() {
let config = Config::default();
let policies = build_policy_considerations(&config, 50, 50, 0, 5);
assert!(policies
.iter()
.any(|p| p.policy == "daily_rate_limit" && p.status == "blocked"));
}
#[test]
fn policy_daily_limit_warning() {
let config = Config::default();
let policies = build_policy_considerations(&config, 42, 50, 0, 5);
assert!(policies
.iter()
.any(|p| p.policy == "daily_rate_limit" && p.status == "warning"));
}
#[test]
fn policy_per_author_blocked() {
let config = Config::default();
let policies = build_policy_considerations(&config, 0, 50, 5, 5);
assert!(policies
.iter()
.any(|p| p.policy == "per_author_limit" && p.status == "blocked"));
}
#[tokio::test]
async fn recommend_engagement_fresh_author() {
let pool = init_test_db().await.expect("init db");
let config = Config::default();
let rec =
recommend_engagement(&pool, "nobody", "Check out this rust crate!", None, &config)
.await
.expect("recommend");
assert!(!rec.recommended_action.is_empty());
assert!(rec.confidence > 0.0);
assert!(!rec.contributing_factors.is_empty());
}
#[tokio::test]
async fn recommend_engagement_with_campaign() {
let pool = init_test_db().await.expect("init db");
let config = Config::default();
let rec = recommend_engagement(
&pool,
"nobody",
"Rust async tokio runtime performance",
Some("Build awareness for Rust async programming tools"),
&config,
)
.await
.expect("recommend");
assert!(!rec.recommended_action.is_empty());
assert!(rec
.contributing_factors
.iter()
.any(|f| f.factor == "campaign_alignment"));
}
}