botserver/src/marketing/ai.rs

480 lines
14 KiB
Rust
Raw Normal View History

2026-03-15 15:50:02 -03:00
use axum::{
extract::State,
http::StatusCode,
Json,
};
use diesel::prelude::*;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use uuid::Uuid;
use crate::core::config::ConfigManager;
use crate::core::shared::schema::crm_contacts;
use crate::core::shared::state::AppState;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContentGenerationRequest {
pub channel: String,
pub goal: String,
pub audience_description: String,
pub template_variables: Option<serde_json::Value>,
pub tone: Option<String>,
pub length: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContentGenerationResult {
pub subject: Option<String>,
pub body: String,
pub headline: Option<String>,
pub cta: Option<String>,
pub suggested_images: Vec<String>,
pub variations: Vec<ContentVariation>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContentVariation {
pub name: String,
pub body: String,
pub tone: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PersonalizationRequest {
pub template: String,
pub contact_id: Uuid,
pub context: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PersonalizationResult {
pub personalized_content: String,
pub variables_used: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ABTestRequest {
pub campaign_id: Uuid,
pub variations: Vec<ABTestVariation>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ABTestVariation {
pub name: String,
pub subject: Option<String>,
pub body: String,
pub weight: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ABTestResult {
pub variation_id: String,
pub opens: i64,
pub clicks: i64,
pub open_rate: f64,
pub click_rate: f64,
pub winner: bool,
}
const DEFAULT_TONE: &str = "professional";
const DEFAULT_LENGTH: &str = "medium";
fn build_generation_prompt(req: &ContentGenerationRequest) -> String {
let tone = req.tone.as_deref().unwrap_or(DEFAULT_TONE);
let length = req.length.as_deref().unwrap_or(DEFAULT_LENGTH);
format!(
r#"You are a marketing expert. Create {} length marketing content for {} channel.
Goal: {}
Audience: {}
Tone: {}
Style: Clear, compelling, action-oriented
Generate:
1. A compelling subject line (if email)
2. Main body content ({} characters max)
3. A call-to-action
4. 2 alternative variations with different tones
Respond in JSON format:
{{
"subject": "...",
"body": "...",
"cta": "...",
"variations": [
{{"name": "friendly", "body": "...", "tone": "friendly"}},
{{"name": "urgent", "body": "...", "tone": "urgent"}}
]
}}"#,
length, req.channel, req.goal, req.audience_description, tone, length
)
}
fn build_personalization_prompt(contact: &ContactInfo, template: &str, context: &serde_json::Value) -> String {
let context_str = if context.is_null() {
String::new()
} else {
format!("\nAdditional context: {}", context)
};
let first_name = contact.first_name.as_deref().unwrap_or("there");
let last_name = contact.last_name.as_deref().unwrap_or("");
let email = contact.email.as_deref().unwrap_or("");
let phone = contact.phone.as_deref().unwrap_or("");
let company = contact.company.as_deref().unwrap_or("");
format!(
r#"Personalize the following marketing message for this contact:
Contact Name: {} {}
Email: {}
Phone: {}
Company: {}{}
Original Template:
{}
Rewrite the template, replacing placeholders with the contact's actual information.
Keep the same structure and tone but make it feel personally addressed."#,
first_name,
last_name,
email,
phone,
company,
context_str,
template
)
}
#[derive(Debug, Clone)]
struct ContactInfo {
first_name: Option<String>,
last_name: Option<String>,
email: Option<String>,
phone: Option<String>,
company: Option<String>,
}
async fn get_llm_config(state: &Arc<AppState>, bot_id: Uuid) -> Result<(String, String, String), String> {
let config = ConfigManager::new(state.conn.clone());
let llm_url = config
.get_config(&bot_id, "llm-url", Some("http://localhost:8081"))
.unwrap_or_else(|_| "http://localhost:8081".to_string());
let llm_model = config
.get_config(&bot_id, "llm-model", None)
.unwrap_or_default();
let llm_key = config
.get_config(&bot_id, "llm-key", None)
.unwrap_or_default();
Ok((llm_url, llm_model, llm_key))
}
pub async fn generate_campaign_content(
state: &Arc<AppState>,
bot_id: Uuid,
req: ContentGenerationRequest,
) -> Result<ContentGenerationResult, String> {
let (_, llm_model, llm_key) = get_llm_config(state, bot_id).await?;
let prompt = build_generation_prompt(&req);
let config = serde_json::json!({
"temperature": 0.7,
"max_tokens": 2000,
});
let llm_provider = &state.llm_provider;
let response = llm_provider
.generate(&prompt, &config, &llm_model, &llm_key)
.await
.map_err(|e| format!("LLM generation failed: {}", e))?;
parse_llm_response(&response)
}
fn parse_llm_response(response: &str) -> Result<ContentGenerationResult, String> {
let json_start = response.find('{').or_else(|| response.find('['));
let json_end = response.rfind('}').or_else(|| response.rfind(']'));
if let (Some(start), Some(end)) = (json_start, json_end) {
let json_str = &response[start..=end];
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(json_str) {
let subject = parsed.get("subject").and_then(|s| s.as_str()).map(String::from);
let body = parsed.get("body").and_then(|b| b.as_str()).unwrap_or("").to_string();
let cta = parsed.get("cta").and_then(|c| c.as_str()).map(String::from);
let mut variations = Vec::new();
if let Some(vars) = parsed.get("variations").and_then(|v| v.as_array()) {
for v in vars {
variations.push(ContentVariation {
name: v.get("name").and_then(|n| n.as_str()).unwrap_or("").to_string(),
body: v.get("body").and_then(|b| b.as_str()).unwrap_or("").to_string(),
tone: v.get("tone").and_then(|t| t.as_str()).unwrap_or("").to_string(),
});
}
}
return Ok(ContentGenerationResult {
subject,
body,
headline: None,
cta,
suggested_images: vec![],
variations,
});
}
}
Ok(ContentGenerationResult {
subject: Some(response.lines().next().unwrap_or("").to_string()),
body: response.to_string(),
headline: None,
cta: Some("Learn More".to_string()),
suggested_images: vec![],
variations: vec![],
})
}
pub async fn personalize_content(
state: &Arc<AppState>,
bot_id: Uuid,
req: PersonalizationRequest,
) -> Result<PersonalizationResult, String> {
let mut conn = state.conn.get().map_err(|e| format!("DB error: {}", e))?;
let contact = crm_contacts::table
.filter(crm_contacts::id.eq(req.contact_id))
.filter(crm_contacts::bot_id.eq(bot_id))
.select((
crm_contacts::first_name,
crm_contacts::last_name,
crm_contacts::email,
crm_contacts::phone,
crm_contacts::company,
))
.first::<(Option<String>, Option<String>, Option<String>, Option<String>, Option<String>)>(&mut conn)
.map_err(|_| "Contact not found")?;
let contact_info = ContactInfo {
first_name: contact.0,
last_name: contact.1,
email: contact.2,
phone: contact.3,
company: contact.4,
};
let context = req.context.unwrap_or(serde_json::Value::Null);
let prompt = build_personalization_prompt(&contact_info, &req.template, &context);
let (_, llm_model, llm_key) = get_llm_config(state, bot_id).await?;
let config = serde_json::json!({
"temperature": 0.5,
"max_tokens": 1000,
});
let llm_provider = &state.llm_provider;
let response = llm_provider
.generate(&prompt, &config, &llm_model, &llm_key)
.await
.map_err(|e| format!("LLM personalization failed: {}", e))?;
let variables = extract_variables(&req.template);
Ok(PersonalizationResult {
personalized_content: response,
variables_used: variables,
})
}
fn extract_variables(template: &str) -> Vec<String> {
let mut vars = Vec::new();
let mut in_brace = false;
let mut current = String::new();
for c in template.chars() {
match c {
'{' => {
in_brace = true;
current.clear();
}
'}' if in_brace => {
in_brace = false;
if !current.is_empty() {
vars.push(current.clone());
}
current.clear();
}
_ if in_brace => current.push(c),
_ => {}
}
}
vars
}
pub async fn generate_ab_test_variations(
state: &Arc<AppState>,
bot_id: Uuid,
req: ABTestRequest,
) -> Result<Vec<ABTestResult>, String> {
let mut results = Vec::new();
for (i, variation) in req.variations.iter().enumerate() {
let prompt = format!(
r#"Evaluate this marketing variation:
Name: {}
Subject: {}
Body: {}
Provide a JSON response:
{{
"opens": <estimated opens 0-100>,
"clicks": <estimated clicks 0-100>,
"open_rate": <percentage>,
"click_rate": <percentage>
}}"#,
variation.name,
variation.subject.as_deref().unwrap_or("N/A"),
variation.body
);
let config = serde_json::json!({
"temperature": 0.3,
"max_tokens": 200,
});
let llm_provider = &state.llm_provider;
let (_, llm_model, llm_key) = get_llm_config(state, bot_id).await?;
let response = llm_provider
.generate(&prompt, &config, &llm_model, &llm_key)
.await
.unwrap_or_default();
let parsed: serde_json::Value = serde_json::from_str(&response).unwrap_or(serde_json::json!({
"opens": 50,
"clicks": 10,
"open_rate": 50.0,
"click_rate": 10.0
}));
results.push(ABTestResult {
variation_id: format!("variation_{}", i),
opens: parsed.get("opens").and_then(|v| v.as_i64()).unwrap_or(50),
clicks: parsed.get("clicks").and_then(|v| v.as_i64()).unwrap_or(10),
open_rate: parsed.get("open_rate").and_then(|v| v.as_f64()).unwrap_or(50.0),
click_rate: parsed.get("click_rate").and_then(|v| v.as_f64()).unwrap_or(10.0),
winner: false,
});
}
if let Some(winner) = results.iter().max_by(|a, b| a.open_rate.partial_cmp(&b.open_rate).unwrap()) {
let winner_id = winner.variation_id.clone();
for r in &mut results {
r.winner = r.variation_id == winner_id;
}
}
Ok(results)
}
pub async fn generate_template_content(
state: &Arc<AppState>,
template_id: Uuid,
) -> Result<ContentGenerationResult, String> {
let mut conn = state.conn.get().map_err(|e| format!("DB error: {}", e))?;
#[derive(QueryableByName)]
struct TemplateRow {
#[diesel(sql_type = diesel::sql_types::Uuid)]
bot_id: Uuid,
#[diesel(sql_type = diesel::sql_types::Text)]
channel: String,
#[diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::Text>)]
subject: Option<String>,
}
let template = diesel::sql_query("SELECT bot_id, channel, subject FROM marketing_templates WHERE id = $1")
.bind::<diesel::sql_types::Uuid, _>(template_id)
.get_result::<TemplateRow>(&mut conn)
.map_err(|_| "Template not found")?;
let req = ContentGenerationRequest {
channel: template.channel,
goal: template.subject.unwrap_or_default(),
audience_description: "General audience".to_string(),
template_variables: None,
tone: None,
length: None,
};
let bot_id = template.bot_id;
generate_campaign_content(state, bot_id, req).await
}
#[derive(Debug, Deserialize)]
pub struct GenerateContentRequest {
pub channel: String,
pub goal: String,
pub audience_description: String,
pub template_variables: Option<serde_json::Value>,
pub tone: Option<String>,
pub length: Option<String>,
}
pub async fn generate_content_api(
State(state): State<Arc<AppState>>,
Json(req): Json<GenerateContentRequest>,
) -> Result<Json<ContentGenerationResult>, (StatusCode, String)> {
let bot_id = Uuid::nil();
let internal_req = ContentGenerationRequest {
channel: req.channel,
goal: req.goal,
audience_description: req.audience_description,
template_variables: req.template_variables,
tone: req.tone,
length: req.length,
};
match generate_campaign_content(&state, bot_id, internal_req).await {
Ok(result) => Ok(Json(result)),
Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, e)),
}
}
#[derive(Debug, Deserialize)]
pub struct PersonalizeRequest {
pub template: String,
pub contact_id: Uuid,
pub context: Option<serde_json::Value>,
}
pub async fn personalize_api(
State(state): State<Arc<AppState>>,
Json(req): Json<PersonalizeRequest>,
) -> Result<Json<PersonalizationResult>, (StatusCode, String)> {
let bot_id = Uuid::nil();
let internal_req = PersonalizationRequest {
template: req.template,
contact_id: req.contact_id,
context: req.context,
};
match personalize_content(&state, bot_id, internal_req).await {
Ok(result) => Ok(Json(result)),
Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, e)),
}
}