- Fix MinIO bucket name sanitization (replace spaces with hyphens) - Write apps to MinIO path: botname.gbapp/appname/files - Serve apps directly from MinIO via /apps/:app_name route - Add WebSocket reconnection on HTMX page load - Remove sync_app_to_site_root (drive monitor handles sync)
1128 lines
39 KiB
Rust
1128 lines
39 KiB
Rust
use crate::auto_task::app_generator::AppGenerator;
|
|
use crate::auto_task::intent_compiler::IntentCompiler;
|
|
use crate::core::config::ConfigManager;
|
|
use crate::shared::models::UserSession;
|
|
use crate::shared::state::AppState;
|
|
use chrono::{DateTime, Utc};
|
|
use diesel::prelude::*;
|
|
use diesel::sql_query;
|
|
use diesel::sql_types::{Text, Uuid as DieselUuid};
|
|
use log::{error, info, trace, warn};
|
|
use serde::{Deserialize, Serialize};
|
|
use std::sync::Arc;
|
|
use uuid::Uuid;
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
|
pub enum IntentType {
|
|
AppCreate,
|
|
Todo,
|
|
Monitor,
|
|
Action,
|
|
Schedule,
|
|
Goal,
|
|
Tool,
|
|
Unknown,
|
|
}
|
|
|
|
impl std::fmt::Display for IntentType {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
Self::AppCreate => write!(f, "APP_CREATE"),
|
|
Self::Todo => write!(f, "TODO"),
|
|
Self::Monitor => write!(f, "MONITOR"),
|
|
Self::Action => write!(f, "ACTION"),
|
|
Self::Schedule => write!(f, "SCHEDULE"),
|
|
Self::Goal => write!(f, "GOAL"),
|
|
Self::Tool => write!(f, "TOOL"),
|
|
Self::Unknown => write!(f, "UNKNOWN"),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<&str> for IntentType {
|
|
fn from(s: &str) -> Self {
|
|
match s.to_uppercase().as_str() {
|
|
"APP_CREATE" | "APP" | "APPLICATION" | "CREATE_APP" => Self::AppCreate,
|
|
"TODO" | "TASK" | "REMINDER" => Self::Todo,
|
|
"MONITOR" | "WATCH" | "ALERT" | "ON_CHANGE" => Self::Monitor,
|
|
"ACTION" | "EXECUTE" | "DO" | "RUN" => Self::Action,
|
|
"SCHEDULE" | "SCHEDULED" | "DAILY" | "WEEKLY" | "MONTHLY" | "CRON" => Self::Schedule,
|
|
"GOAL" | "OBJECTIVE" | "TARGET" | "ACHIEVE" => Self::Goal,
|
|
"TOOL" | "COMMAND" | "TRIGGER" | "WHEN_I_SAY" => Self::Tool,
|
|
_ => Self::Unknown,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ClassifiedIntent {
|
|
pub id: String,
|
|
pub original_text: String,
|
|
pub intent_type: IntentType,
|
|
pub confidence: f64,
|
|
pub entities: ClassifiedEntities,
|
|
pub suggested_name: Option<String>,
|
|
pub requires_clarification: bool,
|
|
pub clarification_question: Option<String>,
|
|
pub alternative_types: Vec<AlternativeClassification>,
|
|
pub classified_at: DateTime<Utc>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
pub struct ClassifiedEntities {
|
|
pub subject: Option<String>,
|
|
pub action: Option<String>,
|
|
pub domain: Option<String>,
|
|
pub time_spec: Option<TimeSpec>,
|
|
pub condition: Option<String>,
|
|
pub recipient: Option<String>,
|
|
pub features: Vec<String>,
|
|
pub tables: Vec<String>,
|
|
pub trigger_phrases: Vec<String>,
|
|
pub target_value: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct TimeSpec {
|
|
pub schedule_type: ScheduleType,
|
|
pub time: Option<String>,
|
|
pub day: Option<String>,
|
|
pub interval: Option<String>,
|
|
pub cron_expression: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub enum ScheduleType {
|
|
Once,
|
|
Daily,
|
|
Weekly,
|
|
Monthly,
|
|
Interval,
|
|
Cron,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct AlternativeClassification {
|
|
pub intent_type: IntentType,
|
|
pub confidence: f64,
|
|
pub reason: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct IntentResult {
|
|
pub success: bool,
|
|
pub intent_type: IntentType,
|
|
pub message: String,
|
|
pub created_resources: Vec<CreatedResource>,
|
|
pub app_url: Option<String>,
|
|
pub task_id: Option<String>,
|
|
pub schedule_id: Option<String>,
|
|
pub tool_triggers: Vec<String>,
|
|
pub next_steps: Vec<String>,
|
|
pub error: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct CreatedResource {
|
|
pub resource_type: String,
|
|
pub name: String,
|
|
pub path: Option<String>,
|
|
}
|
|
|
|
pub struct IntentClassifier {
|
|
state: Arc<AppState>,
|
|
intent_compiler: IntentCompiler,
|
|
}
|
|
|
|
impl IntentClassifier {
|
|
pub fn new(state: Arc<AppState>) -> Self {
|
|
Self {
|
|
state: state.clone(),
|
|
intent_compiler: IntentCompiler::new(state),
|
|
}
|
|
}
|
|
|
|
/// Classify an intent and determine which handler should process it
|
|
pub async fn classify(
|
|
&self,
|
|
intent: &str,
|
|
session: &UserSession,
|
|
) -> Result<ClassifiedIntent, Box<dyn std::error::Error + Send + Sync>> {
|
|
info!(
|
|
"Classifying intent for session {}: {}",
|
|
session.id,
|
|
&intent[..intent.len().min(100)]
|
|
);
|
|
|
|
// Use LLM to classify the intent
|
|
let classification = self.classify_with_llm(intent, session.bot_id).await?;
|
|
|
|
// Store classification for analytics
|
|
self.store_classification(&classification, session)?;
|
|
|
|
Ok(classification)
|
|
}
|
|
|
|
/// Classify and then process the intent through the appropriate handler
|
|
pub async fn classify_and_process(
|
|
&self,
|
|
intent: &str,
|
|
session: &UserSession,
|
|
) -> Result<IntentResult, Box<dyn std::error::Error + Send + Sync>> {
|
|
self.classify_and_process_with_task_id(intent, session, None).await
|
|
}
|
|
|
|
/// Classify and then process the intent through the appropriate handler with task tracking
|
|
pub async fn classify_and_process_with_task_id(
|
|
&self,
|
|
intent: &str,
|
|
session: &UserSession,
|
|
task_id: Option<String>,
|
|
) -> Result<IntentResult, Box<dyn std::error::Error + Send + Sync>> {
|
|
let classification = self.classify(intent, session).await?;
|
|
|
|
self.process_classified_intent_with_task_id(&classification, session, task_id)
|
|
.await
|
|
}
|
|
|
|
/// Process a classified intent through the appropriate handler
|
|
pub async fn process_classified_intent(
|
|
&self,
|
|
classification: &ClassifiedIntent,
|
|
session: &UserSession,
|
|
) -> Result<IntentResult, Box<dyn std::error::Error + Send + Sync>> {
|
|
self.process_classified_intent_with_task_id(classification, session, None).await
|
|
}
|
|
|
|
/// Process a classified intent through the appropriate handler with task tracking
|
|
pub async fn process_classified_intent_with_task_id(
|
|
&self,
|
|
classification: &ClassifiedIntent,
|
|
session: &UserSession,
|
|
task_id: Option<String>,
|
|
) -> Result<IntentResult, Box<dyn std::error::Error + Send + Sync>> {
|
|
info!(
|
|
"Processing {} intent: {}",
|
|
classification.intent_type,
|
|
&classification.original_text[..classification.original_text.len().min(50)]
|
|
);
|
|
|
|
match classification.intent_type {
|
|
IntentType::AppCreate => self.handle_app_create(classification, session, task_id).await,
|
|
IntentType::Todo => self.handle_todo(classification, session),
|
|
IntentType::Monitor => self.handle_monitor(classification, session),
|
|
IntentType::Action => self.handle_action(classification, session).await,
|
|
IntentType::Schedule => self.handle_schedule(classification, session),
|
|
IntentType::Goal => self.handle_goal(classification, session),
|
|
IntentType::Tool => self.handle_tool(classification, session),
|
|
IntentType::Unknown => Self::handle_unknown(classification),
|
|
}
|
|
}
|
|
|
|
/// Use LLM to classify the intent
|
|
async fn classify_with_llm(
|
|
&self,
|
|
intent: &str,
|
|
bot_id: Uuid,
|
|
) -> Result<ClassifiedIntent, Box<dyn std::error::Error + Send + Sync>> {
|
|
let prompt = format!(
|
|
r#"Classify this user request into one of these intent types:
|
|
|
|
USER REQUEST: "{intent}"
|
|
|
|
INTENT TYPES:
|
|
- APP_CREATE: Create a full application, utility, calculator, tool, system, etc.
|
|
Keywords: "create", "build", "make", "calculator", "app", "system", "CRM", "tool"
|
|
IMPORTANT: If user wants to CREATE anything (app, calculator, converter, timer, etc), classify as APP_CREATE
|
|
|
|
- TODO: Simple task or reminder
|
|
Keywords: "call", "remind me", "don't forget", "tomorrow", "later"
|
|
|
|
- MONITOR: Watch for changes and alert
|
|
Keywords: "alert when", "notify if", "watch", "monitor", "track changes"
|
|
|
|
- ACTION: Execute something immediately
|
|
Keywords: "send email", "delete", "update all", "export", "do now"
|
|
|
|
- SCHEDULE: Create recurring automation
|
|
Keywords: "every day", "daily at", "weekly", "monthly", "at 9am"
|
|
|
|
- GOAL: Long-term objective to achieve
|
|
Keywords: "increase", "improve", "achieve", "reach target", "grow by"
|
|
|
|
- TOOL: Create a voice/chat command
|
|
Keywords: "when I say", "create command", "shortcut for", "trigger"
|
|
|
|
YOLO MODE: NEVER ask for clarification. Always make a decision and proceed.
|
|
For APP_CREATE: Just build whatever makes sense. A "calculator" = basic calculator app. A "CRM" = customer management app. Be creative and decisive.
|
|
|
|
Respond with JSON only:
|
|
{{
|
|
"intent_type": "APP_CREATE|TODO|MONITOR|ACTION|SCHEDULE|GOAL|TOOL|UNKNOWN",
|
|
"confidence": 0.0-1.0,
|
|
"subject": "main subject or null",
|
|
"action": "main action verb or null",
|
|
"domain": "industry/domain or null",
|
|
"time_spec": {{"type": "ONCE|DAILY|WEEKLY|MONTHLY", "time": "9:00", "day": "monday"}} or null,
|
|
"condition": "trigger condition or null",
|
|
"recipient": "notification recipient or null",
|
|
"features": ["feature1", "feature2"],
|
|
"tables": ["table1", "table2"],
|
|
"trigger_phrases": ["phrase1", "phrase2"],
|
|
"target_value": "metric target or null",
|
|
"suggested_name": "short name for the resource",
|
|
"requires_clarification": false,
|
|
"clarification_question": null,
|
|
"alternatives": []
|
|
}}"#
|
|
);
|
|
|
|
info!("[INTENT_CLASSIFIER] Starting LLM call for classification, prompt_len={} chars", prompt.len());
|
|
let start = std::time::Instant::now();
|
|
|
|
let response = self.call_llm(&prompt, bot_id).await?;
|
|
let elapsed = start.elapsed();
|
|
info!("[INTENT_CLASSIFIER] LLM classification completed in {:?}, response_len={} chars", elapsed, response.len());
|
|
trace!("LLM classification response: {}", &response[..response.len().min(500)]);
|
|
Self::parse_classification_response(&response, intent)
|
|
}
|
|
|
|
fn parse_classification_response(
|
|
response: &str,
|
|
original_intent: &str,
|
|
) -> Result<ClassifiedIntent, Box<dyn std::error::Error + Send + Sync>> {
|
|
#[derive(Deserialize)]
|
|
struct LlmResponse {
|
|
intent_type: String,
|
|
confidence: f64,
|
|
subject: Option<String>,
|
|
action: Option<String>,
|
|
domain: Option<String>,
|
|
time_spec: Option<TimeSpecResponse>,
|
|
condition: Option<String>,
|
|
recipient: Option<String>,
|
|
features: Option<Vec<String>>,
|
|
tables: Option<Vec<String>>,
|
|
trigger_phrases: Option<Vec<String>>,
|
|
target_value: Option<String>,
|
|
suggested_name: Option<String>,
|
|
requires_clarification: Option<bool>,
|
|
clarification_question: Option<String>,
|
|
alternatives: Option<Vec<AlternativeResponse>>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct TimeSpecResponse {
|
|
#[serde(rename = "type")]
|
|
schedule_type: Option<String>,
|
|
time: Option<String>,
|
|
day: Option<String>,
|
|
interval: Option<String>,
|
|
cron_expression: Option<String>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct AlternativeResponse {
|
|
#[serde(rename = "type")]
|
|
intent_type: String,
|
|
confidence: f64,
|
|
reason: String,
|
|
}
|
|
|
|
// Clean response - remove markdown code blocks if present
|
|
let cleaned = response
|
|
.trim()
|
|
.trim_start_matches("```json")
|
|
.trim_start_matches("```JSON")
|
|
.trim_start_matches("```")
|
|
.trim_end_matches("```")
|
|
.trim();
|
|
|
|
trace!("Cleaned classification response: {}", &cleaned[..cleaned.len().min(300)]);
|
|
|
|
// Try to parse, fall back to heuristic classification
|
|
let parsed: Result<LlmResponse, _> = serde_json::from_str(cleaned);
|
|
|
|
match parsed {
|
|
Ok(resp) => {
|
|
let intent_type = IntentType::from(resp.intent_type.as_str());
|
|
|
|
let time_spec = resp.time_spec.map(|ts| TimeSpec {
|
|
schedule_type: match ts.schedule_type.as_deref() {
|
|
Some("DAILY") => ScheduleType::Daily,
|
|
Some("WEEKLY") => ScheduleType::Weekly,
|
|
Some("MONTHLY") => ScheduleType::Monthly,
|
|
Some("INTERVAL") => ScheduleType::Interval,
|
|
Some("CRON") => ScheduleType::Cron,
|
|
_ => ScheduleType::Once,
|
|
},
|
|
time: ts.time,
|
|
day: ts.day,
|
|
interval: ts.interval,
|
|
cron_expression: ts.cron_expression,
|
|
});
|
|
|
|
let alternatives = resp
|
|
.alternatives
|
|
.unwrap_or_default()
|
|
.into_iter()
|
|
.map(|a| AlternativeClassification {
|
|
intent_type: IntentType::from(a.intent_type.as_str()),
|
|
confidence: a.confidence,
|
|
reason: a.reason,
|
|
})
|
|
.collect();
|
|
|
|
Ok(ClassifiedIntent {
|
|
id: Uuid::new_v4().to_string(),
|
|
original_text: original_intent.to_string(),
|
|
intent_type,
|
|
confidence: resp.confidence,
|
|
entities: ClassifiedEntities {
|
|
subject: resp.subject,
|
|
action: resp.action,
|
|
domain: resp.domain,
|
|
time_spec,
|
|
condition: resp.condition,
|
|
recipient: resp.recipient,
|
|
features: resp.features.unwrap_or_default(),
|
|
tables: resp.tables.unwrap_or_default(),
|
|
trigger_phrases: resp.trigger_phrases.unwrap_or_default(),
|
|
target_value: resp.target_value,
|
|
},
|
|
suggested_name: resp.suggested_name,
|
|
requires_clarification: resp.requires_clarification.unwrap_or(false),
|
|
clarification_question: resp.clarification_question,
|
|
alternative_types: alternatives,
|
|
classified_at: Utc::now(),
|
|
})
|
|
}
|
|
Err(e) => {
|
|
warn!("Failed to parse LLM response, using heuristic: {e}");
|
|
trace!("Raw response that failed to parse: {}", &response[..response.len().min(200)]);
|
|
Self::classify_heuristic(original_intent)
|
|
}
|
|
}
|
|
}
|
|
|
|
fn classify_heuristic(
|
|
intent: &str,
|
|
) -> Result<ClassifiedIntent, Box<dyn std::error::Error + Send + Sync>> {
|
|
let lower = intent.to_lowercase();
|
|
|
|
let (intent_type, confidence) = if lower.contains("create app")
|
|
|| lower.contains("build app")
|
|
|| lower.contains("make app")
|
|
|| lower.contains("crm")
|
|
|| lower.contains("management system")
|
|
|| lower.contains("inventory")
|
|
|| lower.contains("booking")
|
|
|| lower.contains("calculator")
|
|
|| lower.contains("website")
|
|
|| lower.contains("webpage")
|
|
|| lower.contains("web page")
|
|
|| lower.contains("landing page")
|
|
|| lower.contains("dashboard")
|
|
|| lower.contains("form")
|
|
|| lower.contains("todo list")
|
|
|| lower.contains("todo app")
|
|
|| lower.contains("chat")
|
|
|| lower.contains("blog")
|
|
|| lower.contains("portfolio")
|
|
|| lower.contains("store")
|
|
|| lower.contains("shop")
|
|
|| lower.contains("e-commerce")
|
|
|| lower.contains("ecommerce")
|
|
|| (lower.contains("create") && lower.contains("html"))
|
|
|| (lower.contains("make") && lower.contains("html"))
|
|
|| (lower.contains("build") && lower.contains("html"))
|
|
|| (lower.contains("create a") && (lower.contains("simple") || lower.contains("basic")))
|
|
|| (lower.contains("make a") && (lower.contains("simple") || lower.contains("basic")))
|
|
|| (lower.contains("build a") && (lower.contains("simple") || lower.contains("basic")))
|
|
|| lower.contains("criar")
|
|
|| lower.contains("fazer")
|
|
|| lower.contains("construir")
|
|
{
|
|
(IntentType::AppCreate, 0.75)
|
|
} else if lower.contains("remind")
|
|
|| lower.contains("call ")
|
|
|| lower.contains("tomorrow")
|
|
|| lower.contains("don't forget")
|
|
{
|
|
(IntentType::Todo, 0.70)
|
|
} else if lower.contains("alert when")
|
|
|| lower.contains("notify if")
|
|
|| lower.contains("watch for")
|
|
|| lower.contains("monitor")
|
|
{
|
|
(IntentType::Monitor, 0.70)
|
|
} else if lower.contains("send email")
|
|
|| lower.contains("delete all")
|
|
|| lower.contains("update all")
|
|
|| lower.contains("export")
|
|
{
|
|
(IntentType::Action, 0.65)
|
|
} else if lower.contains("every day")
|
|
|| lower.contains("daily")
|
|
|| lower.contains("weekly")
|
|
|| lower.contains("at 9")
|
|
|| lower.contains("at 8")
|
|
{
|
|
(IntentType::Schedule, 0.70)
|
|
} else if lower.contains("increase")
|
|
|| lower.contains("improve")
|
|
|| lower.contains("achieve")
|
|
|| lower.contains("grow by")
|
|
{
|
|
(IntentType::Goal, 0.60)
|
|
} else if lower.contains("when i say")
|
|
|| lower.contains("create command")
|
|
|| lower.contains("shortcut")
|
|
{
|
|
(IntentType::Tool, 0.70)
|
|
} else {
|
|
(IntentType::Unknown, 0.30)
|
|
};
|
|
|
|
Ok(ClassifiedIntent {
|
|
id: Uuid::new_v4().to_string(),
|
|
original_text: intent.to_string(),
|
|
intent_type,
|
|
confidence,
|
|
entities: ClassifiedEntities::default(),
|
|
suggested_name: None,
|
|
requires_clarification: intent_type == IntentType::Unknown,
|
|
clarification_question: if intent_type == IntentType::Unknown {
|
|
Some("Could you please clarify what you'd like me to do?".to_string())
|
|
} else {
|
|
None
|
|
},
|
|
alternative_types: Vec::new(),
|
|
classified_at: Utc::now(),
|
|
})
|
|
}
|
|
|
|
async fn handle_app_create(
|
|
&self,
|
|
classification: &ClassifiedIntent,
|
|
session: &UserSession,
|
|
task_id: Option<String>,
|
|
) -> Result<IntentResult, Box<dyn std::error::Error + Send + Sync>> {
|
|
info!("Handling APP_CREATE intent");
|
|
|
|
let mut app_generator = if let Some(tid) = task_id {
|
|
AppGenerator::with_task_id(self.state.clone(), tid)
|
|
} else {
|
|
AppGenerator::new(self.state.clone())
|
|
};
|
|
|
|
match app_generator
|
|
.generate_app(&classification.original_text, session)
|
|
.await
|
|
{
|
|
Ok(app) => {
|
|
let mut resources = Vec::new();
|
|
|
|
// Track created tables
|
|
for table in &app.tables {
|
|
resources.push(CreatedResource {
|
|
resource_type: "table".to_string(),
|
|
name: table.name.clone(),
|
|
path: Some("tables.bas".to_string()),
|
|
});
|
|
}
|
|
|
|
for page in &app.pages {
|
|
resources.push(CreatedResource {
|
|
resource_type: "page".to_string(),
|
|
name: page.filename.clone(),
|
|
path: Some(page.filename.clone()),
|
|
});
|
|
}
|
|
|
|
for tool in &app.tools {
|
|
resources.push(CreatedResource {
|
|
resource_type: "tool".to_string(),
|
|
name: tool.filename.clone(),
|
|
path: Some(tool.filename.clone()),
|
|
});
|
|
}
|
|
|
|
let app_url = format!("/apps/{}", app.name.to_lowercase().replace(' ', "-"));
|
|
|
|
Ok(IntentResult {
|
|
success: true,
|
|
intent_type: IntentType::AppCreate,
|
|
message: format!(
|
|
"Done:\n{}\nApp available at {}",
|
|
resources
|
|
.iter()
|
|
.filter(|r| r.resource_type == "table")
|
|
.map(|r| format!("{} table created", r.name))
|
|
.collect::<Vec<_>>()
|
|
.join("\n"),
|
|
app_url
|
|
),
|
|
created_resources: resources,
|
|
app_url: Some(app_url),
|
|
task_id: None,
|
|
schedule_id: None,
|
|
tool_triggers: Vec::new(),
|
|
next_steps: vec![
|
|
"Open the app to start using it".to_string(),
|
|
"Use Designer to customize the app".to_string(),
|
|
],
|
|
error: None,
|
|
})
|
|
}
|
|
Err(e) => {
|
|
error!("Failed to generate app: {e}");
|
|
Ok(IntentResult {
|
|
success: false,
|
|
intent_type: IntentType::AppCreate,
|
|
message: "Failed to create the application".to_string(),
|
|
created_resources: Vec::new(),
|
|
app_url: None,
|
|
task_id: None,
|
|
schedule_id: None,
|
|
tool_triggers: Vec::new(),
|
|
next_steps: vec!["Try again with more details".to_string()],
|
|
error: Some(e.to_string()),
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
fn handle_todo(
|
|
&self,
|
|
classification: &ClassifiedIntent,
|
|
session: &UserSession,
|
|
) -> Result<IntentResult, Box<dyn std::error::Error + Send + Sync>> {
|
|
info!("Handling TODO intent");
|
|
|
|
let task_id = Uuid::new_v4();
|
|
let title = classification
|
|
.suggested_name
|
|
.clone()
|
|
.unwrap_or_else(|| classification.original_text.clone());
|
|
|
|
let mut conn = self.state.conn.get()?;
|
|
|
|
// Insert into tasks table (no bot_id column in tasks table)
|
|
sql_query(
|
|
"INSERT INTO tasks (id, title, description, status, priority, created_at)
|
|
VALUES ($1, $2, $3, 'pending', 'normal', NOW())",
|
|
)
|
|
.bind::<DieselUuid, _>(task_id)
|
|
.bind::<Text, _>(&title)
|
|
.bind::<Text, _>(&classification.original_text)
|
|
.execute(&mut conn)?;
|
|
|
|
Ok(IntentResult {
|
|
success: true,
|
|
intent_type: IntentType::Todo,
|
|
message: format!("Task saved: {title}"),
|
|
created_resources: vec![CreatedResource {
|
|
resource_type: "task".to_string(),
|
|
name: title,
|
|
path: None,
|
|
}],
|
|
app_url: None,
|
|
task_id: Some(task_id.to_string()),
|
|
schedule_id: None,
|
|
tool_triggers: Vec::new(),
|
|
next_steps: vec!["View tasks in your task list".to_string()],
|
|
error: None,
|
|
})
|
|
}
|
|
|
|
fn handle_monitor(
|
|
&self,
|
|
classification: &ClassifiedIntent,
|
|
session: &UserSession,
|
|
) -> Result<IntentResult, Box<dyn std::error::Error + Send + Sync>> {
|
|
info!("Handling MONITOR intent");
|
|
|
|
let subject = classification
|
|
.entities
|
|
.subject
|
|
.clone()
|
|
.unwrap_or_else(|| "data".to_string());
|
|
let condition = classification
|
|
.entities
|
|
.condition
|
|
.clone()
|
|
.unwrap_or_else(|| "changes".to_string());
|
|
|
|
// Generate ON CHANGE handler BASIC code
|
|
let handler_name = format!("monitor_{}.bas", subject.to_lowercase().replace(' ', "_"));
|
|
|
|
let basic_code = format!(
|
|
r#"' Monitor: {subject}
|
|
' Condition: {condition}
|
|
' Created: {}
|
|
|
|
ON CHANGE "{subject}"
|
|
current_value = GET "{subject}"
|
|
IF {condition} THEN
|
|
TALK "Alert: {subject} has changed"
|
|
' Add notification logic here
|
|
END IF
|
|
END ON
|
|
"#,
|
|
Utc::now().format("%Y-%m-%d %H:%M")
|
|
);
|
|
|
|
// Save to .gbdialog/events/
|
|
let event_path = format!(".gbdialog/events/{handler_name}");
|
|
self.save_basic_file(session.bot_id, &event_path, &basic_code)?;
|
|
|
|
Ok(IntentResult {
|
|
success: true,
|
|
intent_type: IntentType::Monitor,
|
|
message: format!("Monitor created for: {subject}"),
|
|
created_resources: vec![CreatedResource {
|
|
resource_type: "event".to_string(),
|
|
name: handler_name,
|
|
path: Some(event_path),
|
|
}],
|
|
app_url: None,
|
|
task_id: None,
|
|
schedule_id: None,
|
|
tool_triggers: Vec::new(),
|
|
next_steps: vec![format!("You'll be notified when {subject} {condition}")],
|
|
error: None,
|
|
})
|
|
}
|
|
|
|
async fn handle_action(
|
|
&self,
|
|
classification: &ClassifiedIntent,
|
|
session: &UserSession,
|
|
) -> Result<IntentResult, Box<dyn std::error::Error + Send + Sync>> {
|
|
info!("Handling ACTION intent");
|
|
|
|
// Compile the intent into an execution plan
|
|
let compiled = self
|
|
.intent_compiler
|
|
.compile(&classification.original_text, session)
|
|
.await?;
|
|
|
|
// For immediate actions, we'd execute the plan
|
|
// For safety, high-risk actions require approval
|
|
if compiled.risk_assessment.requires_human_review {
|
|
return Ok(IntentResult {
|
|
success: false,
|
|
intent_type: IntentType::Action,
|
|
message: format!(
|
|
"This action requires approval:\n{}",
|
|
compiled.risk_assessment.review_reason.unwrap_or_default()
|
|
),
|
|
created_resources: Vec::new(),
|
|
app_url: None,
|
|
task_id: Some(compiled.id),
|
|
schedule_id: None,
|
|
tool_triggers: Vec::new(),
|
|
next_steps: vec!["Approve the action to proceed".to_string()],
|
|
error: None,
|
|
});
|
|
}
|
|
|
|
// Execute low-risk actions immediately
|
|
// In production, this would run the BASIC program
|
|
Ok(IntentResult {
|
|
success: true,
|
|
intent_type: IntentType::Action,
|
|
message: format!(
|
|
"Executing: {}\nSteps: {}",
|
|
compiled.plan.name,
|
|
compiled.plan.steps.len()
|
|
),
|
|
created_resources: Vec::new(),
|
|
app_url: None,
|
|
task_id: Some(compiled.id),
|
|
schedule_id: None,
|
|
tool_triggers: Vec::new(),
|
|
next_steps: vec!["Action is being executed".to_string()],
|
|
error: None,
|
|
})
|
|
}
|
|
|
|
fn handle_schedule(
|
|
&self,
|
|
classification: &ClassifiedIntent,
|
|
session: &UserSession,
|
|
) -> Result<IntentResult, Box<dyn std::error::Error + Send + Sync>> {
|
|
info!("Handling SCHEDULE intent");
|
|
|
|
let schedule_name = classification
|
|
.suggested_name
|
|
.clone()
|
|
.unwrap_or_else(|| "scheduled-task".to_string())
|
|
.to_lowercase()
|
|
.replace(' ', "-");
|
|
|
|
let time_spec = classification
|
|
.entities
|
|
.time_spec
|
|
.as_ref()
|
|
.map(|ts| {
|
|
format!(
|
|
"{} at {}",
|
|
match ts.schedule_type {
|
|
ScheduleType::Daily => "Every day",
|
|
ScheduleType::Weekly => "Every week",
|
|
ScheduleType::Monthly => "Every month",
|
|
_ => "Once",
|
|
},
|
|
ts.time.as_deref().unwrap_or("9:00 AM")
|
|
)
|
|
})
|
|
.unwrap_or_else(|| "Every day at 9:00 AM".to_string());
|
|
|
|
// Generate scheduler BASIC code
|
|
let scheduler_file = format!("{schedule_name}.bas");
|
|
let basic_code = format!(
|
|
r#"' Scheduler: {schedule_name}
|
|
' Schedule: {time_spec}
|
|
' Created: {}
|
|
|
|
SET SCHEDULE "{time_spec}"
|
|
' Task logic from: {}
|
|
TALK "Running scheduled task: {schedule_name}"
|
|
' Add your automation logic here
|
|
END SCHEDULE
|
|
"#,
|
|
Utc::now().format("%Y-%m-%d %H:%M"),
|
|
classification.original_text
|
|
);
|
|
|
|
// Save to .gbdialog/schedulers/
|
|
let scheduler_path = format!(".gbdialog/schedulers/{scheduler_file}");
|
|
self.save_basic_file(session.bot_id, &scheduler_path, &basic_code)?;
|
|
|
|
let schedule_id = Uuid::new_v4();
|
|
|
|
Ok(IntentResult {
|
|
success: true,
|
|
intent_type: IntentType::Schedule,
|
|
message: format!("Scheduler created: {scheduler_file}\nSchedule: {time_spec}"),
|
|
created_resources: vec![CreatedResource {
|
|
resource_type: "scheduler".to_string(),
|
|
name: scheduler_file,
|
|
path: Some(scheduler_path),
|
|
}],
|
|
app_url: None,
|
|
task_id: None,
|
|
schedule_id: Some(schedule_id.to_string()),
|
|
tool_triggers: Vec::new(),
|
|
next_steps: vec![format!("The task will run {time_spec}")],
|
|
error: None,
|
|
})
|
|
}
|
|
|
|
fn handle_goal(
|
|
&self,
|
|
classification: &ClassifiedIntent,
|
|
session: &UserSession,
|
|
) -> Result<IntentResult, Box<dyn std::error::Error + Send + Sync>> {
|
|
info!("Handling GOAL intent");
|
|
|
|
let goal_name = classification
|
|
.suggested_name
|
|
.clone()
|
|
.unwrap_or_else(|| "goal".to_string());
|
|
let target = classification
|
|
.entities
|
|
.target_value
|
|
.clone()
|
|
.unwrap_or_else(|| "unspecified".to_string());
|
|
|
|
let _goal_id = Uuid::new_v4();
|
|
|
|
// Goals are more complex - they create a monitoring + action loop
|
|
let basic_code = format!(
|
|
r#"' Goal: {goal_name}
|
|
' Target: {target}
|
|
' Created: {}
|
|
|
|
' This goal runs as an autonomous loop
|
|
SET GOAL "{goal_name}"
|
|
TARGET = "{target}"
|
|
|
|
' Check current metrics
|
|
current = GET_METRIC "{goal_name}"
|
|
|
|
' LLM analyzes progress and suggests actions
|
|
analysis = LLM "Analyze progress toward {target}. Current: " + current
|
|
|
|
' Execute suggested improvements
|
|
IF analysis.has_action THEN
|
|
EXECUTE analysis.action
|
|
END IF
|
|
|
|
' Report progress
|
|
TALK "Goal progress: " + current + " / " + TARGET
|
|
END GOAL
|
|
"#,
|
|
Utc::now().format("%Y-%m-%d %H:%M")
|
|
);
|
|
|
|
// Save to .gbdialog/goals/
|
|
let goal_file = format!("{}.bas", goal_name.to_lowercase().replace(' ', "-"));
|
|
let goal_path = format!(".gbdialog/goals/{goal_file}");
|
|
self.save_basic_file(session.bot_id, &goal_path, &basic_code)?;
|
|
|
|
Ok(IntentResult {
|
|
success: true,
|
|
intent_type: IntentType::Goal,
|
|
message: format!("Goal created: {goal_name}\nTarget: {target}"),
|
|
created_resources: vec![CreatedResource {
|
|
resource_type: "goal".to_string(),
|
|
name: goal_name,
|
|
path: Some(goal_path),
|
|
}],
|
|
app_url: None,
|
|
task_id: None,
|
|
schedule_id: None,
|
|
tool_triggers: Vec::new(),
|
|
next_steps: vec![
|
|
"The system will work toward this goal autonomously".to_string(),
|
|
"Check progress in the Goals dashboard".to_string(),
|
|
],
|
|
error: None,
|
|
})
|
|
}
|
|
|
|
fn handle_tool(
|
|
&self,
|
|
classification: &ClassifiedIntent,
|
|
session: &UserSession,
|
|
) -> Result<IntentResult, Box<dyn std::error::Error + Send + Sync>> {
|
|
info!("Handling TOOL intent");
|
|
|
|
let tool_name = classification
|
|
.suggested_name
|
|
.clone()
|
|
.unwrap_or_else(|| "custom-command".to_string())
|
|
.to_lowercase()
|
|
.replace(' ', "-");
|
|
|
|
let triggers = if classification.entities.trigger_phrases.is_empty() {
|
|
vec![tool_name.clone()]
|
|
} else {
|
|
classification.entities.trigger_phrases.clone()
|
|
};
|
|
|
|
let triggers_str = triggers
|
|
.iter()
|
|
.map(|t| format!("\"{}\"", t))
|
|
.collect::<Vec<_>>()
|
|
.join(", ");
|
|
|
|
// Generate tool BASIC code
|
|
let tool_file = format!("{tool_name}.bas");
|
|
let basic_code = format!(
|
|
r#"' Tool: {tool_name}
|
|
' Triggers: {triggers_str}
|
|
' Created: {}
|
|
|
|
TRIGGER {triggers_str}
|
|
' Command logic from: {}
|
|
TALK "Running command: {tool_name}"
|
|
' Add your command logic here
|
|
END TRIGGER
|
|
"#,
|
|
Utc::now().format("%Y-%m-%d %H:%M"),
|
|
classification.original_text
|
|
);
|
|
|
|
// Save to .gbdialog/tools/
|
|
let tool_path = format!(".gbdialog/tools/{tool_file}");
|
|
self.save_basic_file(session.bot_id, &tool_path, &basic_code)?;
|
|
|
|
Ok(IntentResult {
|
|
success: true,
|
|
intent_type: IntentType::Tool,
|
|
message: format!(
|
|
"Command created: {tool_file}\nTriggers: {}",
|
|
triggers.join(", ")
|
|
),
|
|
created_resources: vec![CreatedResource {
|
|
resource_type: "tool".to_string(),
|
|
name: tool_file,
|
|
path: Some(tool_path),
|
|
}],
|
|
app_url: None,
|
|
task_id: None,
|
|
schedule_id: None,
|
|
tool_triggers: triggers,
|
|
next_steps: vec!["Say any of the trigger phrases to use the command".to_string()],
|
|
error: None,
|
|
})
|
|
}
|
|
|
|
fn handle_unknown(
|
|
classification: &ClassifiedIntent,
|
|
) -> Result<IntentResult, Box<dyn std::error::Error + Send + Sync>> {
|
|
info!("Handling UNKNOWN intent - requesting clarification");
|
|
|
|
let suggestions = if classification.alternative_types.is_empty() {
|
|
"- Create an app\n- Add a task\n- Set up monitoring\n- Schedule automation".to_string()
|
|
} else {
|
|
classification
|
|
.alternative_types
|
|
.iter()
|
|
.map(|a| format!("- {}: {}", a.intent_type, a.reason))
|
|
.collect::<Vec<_>>()
|
|
.join("\n")
|
|
};
|
|
|
|
Ok(IntentResult {
|
|
success: false,
|
|
intent_type: IntentType::Unknown,
|
|
message: format!(
|
|
"I'm not sure what you'd like me to do. Could you clarify?\n\nPossible interpretations:\n{}",
|
|
suggestions
|
|
),
|
|
created_resources: Vec::new(),
|
|
app_url: None,
|
|
task_id: None,
|
|
schedule_id: None,
|
|
tool_triggers: Vec::new(),
|
|
next_steps: vec!["Provide more details about what you want".to_string()],
|
|
error: None,
|
|
})
|
|
}
|
|
|
|
// =========================================================================
|
|
// HELPER METHODS
|
|
// =========================================================================
|
|
|
|
/// Call LLM for classification
|
|
async fn call_llm(
|
|
&self,
|
|
prompt: &str,
|
|
bot_id: Uuid,
|
|
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
|
|
trace!("Calling LLM for intent classification");
|
|
|
|
#[cfg(feature = "llm")]
|
|
{
|
|
// Get model and key from bot configuration
|
|
let config_manager = ConfigManager::new(self.state.conn.clone());
|
|
let model = config_manager
|
|
.get_config(&bot_id, "llm-model", None)
|
|
.unwrap_or_else(|_| {
|
|
config_manager
|
|
.get_config(&Uuid::nil(), "llm-model", None)
|
|
.unwrap_or_else(|_| "gpt-4".to_string())
|
|
});
|
|
let key = config_manager
|
|
.get_config(&bot_id, "llm-key", None)
|
|
.unwrap_or_else(|_| {
|
|
config_manager
|
|
.get_config(&Uuid::nil(), "llm-key", None)
|
|
.unwrap_or_default()
|
|
});
|
|
|
|
let llm_config = serde_json::json!({
|
|
"temperature": 0.3,
|
|
"max_tokens": 1000
|
|
});
|
|
let response = self
|
|
.state
|
|
.llm_provider
|
|
.generate(prompt, &llm_config, &model, &key)
|
|
.await?;
|
|
return Ok(response);
|
|
}
|
|
|
|
#[cfg(not(feature = "llm"))]
|
|
{
|
|
warn!("LLM feature not enabled, using heuristic classification");
|
|
Ok("{}".to_string())
|
|
}
|
|
}
|
|
|
|
/// Save a BASIC file to the bot's directory
|
|
fn save_basic_file(
|
|
&self,
|
|
bot_id: Uuid,
|
|
path: &str,
|
|
content: &str,
|
|
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|
let site_path = self
|
|
.state
|
|
.config
|
|
.as_ref()
|
|
.map(|c| c.site_path.clone())
|
|
.unwrap_or_else(|| "./botserver-stack/sites".to_string());
|
|
|
|
let full_path = format!("{}/{}.gbai/{}", site_path, bot_id, path);
|
|
|
|
// Create directory if needed
|
|
if let Some(dir) = std::path::Path::new(&full_path).parent() {
|
|
if !dir.exists() {
|
|
std::fs::create_dir_all(dir)?;
|
|
}
|
|
}
|
|
|
|
std::fs::write(&full_path, content)?;
|
|
info!("Saved BASIC file: {full_path}");
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Store classification for analytics
|
|
fn store_classification(
|
|
&self,
|
|
classification: &ClassifiedIntent,
|
|
session: &UserSession,
|
|
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|
let mut conn = self.state.conn.get()?;
|
|
|
|
sql_query(
|
|
"INSERT INTO intent_classifications
|
|
(id, bot_id, session_id, original_text, intent_type, confidence, entities, created_at)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
|
|
ON CONFLICT DO NOTHING",
|
|
)
|
|
.bind::<DieselUuid, _>(Uuid::parse_str(&classification.id)?)
|
|
.bind::<DieselUuid, _>(session.bot_id)
|
|
.bind::<DieselUuid, _>(session.id)
|
|
.bind::<Text, _>(&classification.original_text)
|
|
.bind::<Text, _>(&classification.intent_type.to_string())
|
|
.bind::<diesel::sql_types::Float8, _>(classification.confidence)
|
|
.bind::<Text, _>(serde_json::to_string(&classification.entities)?)
|
|
.execute(&mut conn)
|
|
.ok(); // Ignore errors - analytics shouldn't break the flow
|
|
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_intent_type_from_str() {
|
|
assert_eq!(IntentType::from("APP_CREATE"), IntentType::AppCreate);
|
|
assert_eq!(IntentType::from("app"), IntentType::AppCreate);
|
|
assert_eq!(IntentType::from("TODO"), IntentType::Todo);
|
|
assert_eq!(IntentType::from("reminder"), IntentType::Todo);
|
|
assert_eq!(IntentType::from("MONITOR"), IntentType::Monitor);
|
|
assert_eq!(IntentType::from("SCHEDULE"), IntentType::Schedule);
|
|
assert_eq!(IntentType::from("daily"), IntentType::Schedule);
|
|
assert_eq!(IntentType::from("unknown_value"), IntentType::Unknown);
|
|
}
|
|
|
|
#[test]
|
|
fn test_intent_type_display() {
|
|
assert_eq!(IntentType::AppCreate.to_string(), "APP_CREATE");
|
|
assert_eq!(IntentType::Todo.to_string(), "TODO");
|
|
assert_eq!(IntentType::Monitor.to_string(), "MONITOR");
|
|
}
|
|
}
|