generalbots/botserver/src/attendance/llm_assist_handlers.rs
Rodrigo Rodriguez (Pragmatismo) 037db5c381 feat: Major workspace reorganization and documentation update
- Add comprehensive documentation in botbook/ with 12 chapters
- Add botapp/ Tauri desktop application
- Add botdevice/ IoT device support
- Add botlib/ shared library crate
- Add botmodels/ Python ML models service
- Add botplugin/ browser extension
- Add botserver/ reorganized server code
- Add bottemplates/ bot templates
- Add bottest/ integration tests
- Add botui/ web UI server
- Add CI/CD workflows in .forgejo/workflows/
- Add AGENTS.md and PROD.md documentation
- Add dependency management scripts (DEPENDENCIES.sh/ps1)
- Remove legacy src/ structure and migrations
- Clean up temporary and backup files
2026-04-19 08:14:25 -03:00

564 lines
17 KiB
Rust

use super::llm_assist_types::*;
use super::llm_assist_config::get_bot_system_prompt;
use super::llm_assist_helpers::*;
use crate::core::config::ConfigManager;
use crate::core::shared::state::AppState;
use axum::{
extract::{Path, State},
http::StatusCode,
response::IntoResponse,
Json,
};
use log::{error, info};
use std::sync::Arc;
use uuid::Uuid;
pub async fn generate_tips(
State(state): State<Arc<AppState>>,
Json(request): Json<TipRequest>,
) -> (StatusCode, Json<TipResponse>) {
info!("Generating tips for session {}", request.session_id);
let session_result = get_session(&state, request.session_id).await;
let session = match session_result {
Ok(s) => s,
Err(e) => {
return (
StatusCode::NOT_FOUND,
Json(TipResponse {
success: false,
tips: vec![],
error: Some(e),
}),
)
}
};
let work_path = std::env::var("WORK_PATH").unwrap_or_else(|_| "./work".to_string());
let config = crate::attendance::llm_assist_config::LlmAssistConfig::from_config(session.bot_id, &work_path);
if !config.tips_enabled {
return (
StatusCode::OK,
Json(TipResponse {
success: true,
tips: vec![],
error: Some("Tips feature is disabled".to_string()),
}),
);
}
let history_context = request
.history
.iter()
.map(|m| format!("{}: {}", m.role, m.content))
.collect::<Vec<_>>()
.join("\n");
let bot_prompt = get_bot_system_prompt(session.bot_id, &work_path);
let system_prompt = format!(
r#"You are an AI assistant helping a human customer service attendant.
The bot they are replacing has this personality: {}
Your job is to provide helpful tips to the attendant based on the customer's message.
Analyze the customer message and provide 2-4 actionable tips. For each tip, classify it as:
- intent: What the customer wants
- action: Suggested action for attendant
- warning: Sentiment or escalation concern
- knowledge: Relevant info they should know
- history: Insight from conversation history
- general: General helpful advice
Respond in JSON format:
{{
"tips": [
{{"type": "intent", "content": "...", "confidence": 0.9, "priority": 1}},
{{"type": "action", "content": "...", "confidence": 0.8, "priority": 2}}
]
}}"#,
bot_prompt
);
let user_prompt = format!(
r#"Conversation history:
{}
Latest customer message: "{}"
Provide tips for the attendant."#,
history_context, request.customer_message
);
match execute_llm_with_context(&state, session.bot_id, &system_prompt, &user_prompt).await {
Ok(response) => {
let tips = parse_tips_response(&response);
(
StatusCode::OK,
Json(TipResponse {
success: true,
tips,
error: None,
}),
)
}
Err(e) => {
error!("LLM error generating tips: {}", e);
(
StatusCode::OK,
Json(TipResponse {
success: true,
tips: generate_fallback_tips(&request.customer_message),
error: Some(format!("LLM unavailable, using fallback: {}", e)),
}),
)
}
}
}
pub async fn polish_message(
State(state): State<Arc<AppState>>,
Json(request): Json<PolishRequest>,
) -> (StatusCode, Json<PolishResponse>) {
info!("Polishing message for session {}", request.session_id);
let session_result = get_session(&state, request.session_id).await;
let session = match session_result {
Ok(s) => s,
Err(e) => {
return (
StatusCode::NOT_FOUND,
Json(PolishResponse {
success: false,
original: request.message.clone(),
polished: request.message.clone(),
changes: vec![],
error: Some(e),
}),
)
}
};
let work_path = std::env::var("WORK_PATH").unwrap_or_else(|_| "./work".to_string());
let config = crate::attendance::llm_assist_config::LlmAssistConfig::from_config(session.bot_id, &work_path);
if !config.polish_enabled {
return (
StatusCode::OK,
Json(PolishResponse {
success: true,
original: request.message.clone(),
polished: request.message.clone(),
changes: vec![],
error: Some("Polish feature is disabled".to_string()),
}),
);
}
let bot_prompt = get_bot_system_prompt(session.bot_id, &work_path);
let system_prompt = format!(
r#"You are a professional editor helping a customer service attendant.
The service has this tone: {}
Your job is to polish the attendant's message to be more {} while:
1. Fixing grammar and spelling errors
2. Improving clarity and flow
3. Maintaining the original meaning
4. Keeping it natural (not robotic)
Respond in JSON format:
{{
"polished": "The improved message",
"changes": ["Changed X to Y", "Fixed grammar in..."]
}}"#,
bot_prompt, request.tone
);
let user_prompt = format!(
r#"Polish this message with a {} tone:
"{}"#,
request.tone, request.message
);
match execute_llm_with_context(&state, session.bot_id, &system_prompt, &user_prompt).await {
Ok(response) => {
let (polished, changes) = parse_polish_response(&response, &request.message);
(
StatusCode::OK,
Json(PolishResponse {
success: true,
original: request.message.clone(),
polished,
changes,
error: None,
}),
)
}
Err(e) => {
error!("LLM error polishing message: {}", e);
(
StatusCode::OK,
Json(PolishResponse {
success: false,
original: request.message.clone(),
polished: request.message.clone(),
changes: vec![],
error: Some(format!("LLM error: {}", e)),
}),
)
}
}
}
pub async fn generate_smart_replies(
State(state): State<Arc<AppState>>,
Json(request): Json<SmartRepliesRequest>,
) -> (StatusCode, Json<SmartRepliesResponse>) {
info!(
"Generating smart replies for session {}",
request.session_id
);
let session_result = get_session(&state, request.session_id).await;
let session = match session_result {
Ok(s) => s,
Err(e) => {
return (
StatusCode::NOT_FOUND,
Json(SmartRepliesResponse {
success: false,
replies: vec![],
error: Some(e),
}),
)
}
};
let work_path = std::env::var("WORK_PATH").unwrap_or_else(|_| "./work".to_string());
let config = crate::attendance::llm_assist_config::LlmAssistConfig::from_config(session.bot_id, &work_path);
if !config.smart_replies_enabled {
return (
StatusCode::OK,
Json(SmartRepliesResponse {
success: true,
replies: vec![],
error: Some("Smart replies feature is disabled".to_string()),
}),
);
}
let history_context = request
.history
.iter()
.map(|m| format!("{}: {}", m.role, m.content))
.collect::<Vec<_>>()
.join("\n");
let bot_prompt = get_bot_system_prompt(session.bot_id, &work_path);
let system_prompt = format!(
r#"You are an AI assistant helping a customer service attendant craft responses.
The service has this personality: {}
Generate exactly 3 reply suggestions that:
1. Are contextually appropriate
2. Sound natural and human (not robotic)
3. Vary in approach (one empathetic, one solution-focused, one follow_up)
4. Are ready to send (no placeholders like [name])
Respond in JSON format:
{{
"replies": [
{{"text": "...", "tone": "empathetic", "confidence": 0.9, "category": "answer"}},
{{"text": "...", "tone": "professional", "confidence": 0.85, "category": "solution"}},
{{"text": "...", "tone": "friendly", "confidence": 0.8, "category": "follow_up"}}
]
}}"#,
bot_prompt
);
let user_prompt = format!(
r"Conversation:
{}
Generate 3 reply options for the attendant.",
history_context
);
match execute_llm_with_context(&state, session.bot_id, &system_prompt, &user_prompt).await {
Ok(response) => {
let replies = parse_smart_replies_response(&response);
(
StatusCode::OK,
Json(SmartRepliesResponse {
success: true,
replies,
error: None,
}),
)
}
Err(e) => {
error!("LLM error generating smart replies: {}", e);
(
StatusCode::OK,
Json(SmartRepliesResponse {
success: true,
replies: generate_fallback_replies(),
error: Some(format!("LLM unavailable, using fallback: {}", e)),
}),
)
}
}
}
pub async fn generate_summary(
State(state): State<Arc<AppState>>,
Path(session_id): Path<Uuid>,
) -> (StatusCode, Json<SummaryResponse>) {
info!("Generating summary for session {}", session_id);
let session_result = get_session(&state, session_id).await;
let session = match session_result {
Ok(s) => s,
Err(e) => {
return (
StatusCode::NOT_FOUND,
Json(SummaryResponse {
success: false,
summary: ConversationSummary::default(),
error: Some(e),
}),
)
}
};
let work_path = std::env::var("WORK_PATH").unwrap_or_else(|_| "./work".to_string());
let config = crate::attendance::llm_assist_config::LlmAssistConfig::from_config(session.bot_id, &work_path);
if !config.auto_summary_enabled {
return (
StatusCode::OK,
Json(SummaryResponse {
success: true,
summary: ConversationSummary::default(),
error: Some("Auto-summary feature is disabled".to_string()),
}),
);
}
let history = load_conversation_history(&state, session_id).await;
if history.is_empty() {
return (
StatusCode::OK,
Json(SummaryResponse {
success: true,
summary: ConversationSummary {
brief: "No messages in conversation yet".to_string(),
..Default::default()
},
error: None,
}),
);
}
let history_text = history
.iter()
.map(|m| format!("{}: {}", m.role, m.content))
.collect::<Vec<_>>()
.join("\n");
let bot_prompt = get_bot_system_prompt(session.bot_id, &work_path);
let system_prompt = format!(
r#"You are an AI assistant helping a customer service attendant understand a conversation.
The bot/service personality is: {}
Analyze the conversation and provide a comprehensive summary.
Respond in JSON format:
{{
"brief": "One sentence summary",
"key_points": ["Point 1", "Point 2"],
"customer_needs": ["Need 1", "Need 2"],
"unresolved_issues": ["Issue 1"],
"sentiment_trend": "improving/stable/declining",
"recommended_action": "What the attendant should do next"
}}"#,
bot_prompt
);
let user_prompt = format!(
r"Summarize this conversation:
{}",
history_text
);
match execute_llm_with_context(&state, session.bot_id, &system_prompt, &user_prompt).await {
Ok(response) => {
let mut summary = parse_summary_response(&response);
summary.message_count = history.len() as i32;
if let (Some(first_ts), Some(last_ts)) = (
history.first().and_then(|m| m.timestamp.as_ref()),
history.last().and_then(|m| m.timestamp.as_ref()),
) {
if let (Ok(first), Ok(last)) = (
chrono::DateTime::parse_from_rfc3339(first_ts),
chrono::DateTime::parse_from_rfc3339(last_ts),
) {
summary.duration_minutes = (last - first).num_minutes() as i32;
}
}
(
StatusCode::OK,
Json(SummaryResponse {
success: true,
summary,
error: None,
}),
)
}
Err(e) => {
error!("LLM error generating summary: {}", e);
(
StatusCode::OK,
Json(SummaryResponse {
success: false,
summary: ConversationSummary {
brief: format!("Conversation with {} messages", history.len()),
message_count: history.len() as i32,
..Default::default()
},
error: Some(format!("LLM error: {}", e)),
}),
)
}
}
}
pub async fn analyze_sentiment(
State(state): State<Arc<AppState>>,
Json(request): Json<SentimentRequest>,
) -> impl IntoResponse {
info!("Analyzing sentiment for session {}", request.session_id);
let session_result = get_session(&state, request.session_id).await;
let session = match session_result {
Ok(s) => s,
Err(e) => {
return (
StatusCode::NOT_FOUND,
Json(SentimentResponse {
success: false,
sentiment: SentimentAnalysis::default(),
error: Some(e),
}),
)
}
};
let work_path = std::env::var("WORK_PATH").unwrap_or_else(|_| "./work".to_string());
let config = crate::attendance::llm_assist_config::LlmAssistConfig::from_config(session.bot_id, &work_path);
if !config.sentiment_enabled {
let sentiment = analyze_sentiment_keywords(&request.message);
return (
StatusCode::OK,
Json(SentimentResponse {
success: true,
sentiment,
error: Some("LLM sentiment disabled, using keyword analysis".to_string()),
}),
);
}
let history_context = request
.history
.iter()
.take(5)
.map(|m| format!("{}: {}", m.role, m.content))
.collect::<Vec<_>>()
.join("\n");
let system_prompt = r#"You are a sentiment analysis expert. Analyze the customer's emotional state.
Consider:
1. Overall sentiment (positive/neutral/negative)
2. Specific emotions present
3. Risk of escalation
4. Urgency level
Respond in JSON format:
{
"overall": "positive|neutral|negative",
"score": 0.5,
"emotions": [{"name": "frustration", "intensity": 0.7}],
"escalation_risk": "low|medium|high",
"urgency": "low|normal|high|urgent",
"emoji": "😐"
}"#;
let user_prompt = format!(
r#"Recent conversation:
{}
Current message to analyze: "{}"
Analyze the customer's sentiment."#,
history_context, request.message
);
match execute_llm_with_context(&state, session.bot_id, system_prompt, &user_prompt).await {
Ok(response) => {
let sentiment = parse_sentiment_response(&response);
(
StatusCode::OK,
Json(SentimentResponse {
success: true,
sentiment,
error: None,
}),
)
}
Err(e) => {
error!("LLM error analyzing sentiment: {}", e);
let sentiment = analyze_sentiment_keywords(&request.message);
(
StatusCode::OK,
Json(SentimentResponse {
success: true,
sentiment,
error: Some(format!("LLM unavailable, using fallback: {}", e)),
}),
)
}
}
}
pub async fn get_llm_config(
State(_state): State<Arc<AppState>>,
Path(bot_id): Path<Uuid>,
) -> impl IntoResponse {
let work_path = std::env::var("WORK_PATH").unwrap_or_else(|_| "./work".to_string());
let config = crate::attendance::llm_assist_config::LlmAssistConfig::from_config(bot_id, &work_path);
(
StatusCode::OK,
Json(serde_json::json!({
"tips_enabled": config.tips_enabled,
"polish_enabled": config.polish_enabled,
"smart_replies_enabled": config.smart_replies_enabled,
"auto_summary_enabled": config.auto_summary_enabled,
"sentiment_enabled": config.sentiment_enabled,
"any_enabled": config.any_enabled()
})),
)
}