Some checks failed
BotServer CI / build (push) Failing after 7m5s
479 lines
14 KiB
Rust
479 lines
14 KiB
Rust
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)),
|
|
}
|
|
}
|