aidaemon 0.9.34

A personal AI agent that runs as a background daemon, accessible via Telegram, Slack, or Discord, with tool use, MCP integration, and persistent memory
//! Proactive suggestion engine for anticipating user needs.
//!
//! This module analyzes patterns, goals, and past experiences to generate
//! contextually relevant suggestions.

use crate::traits::{BehaviorPattern, Episode, Goal, Procedure, UserProfile};
use crate::utils::truncate_str;

/// A suggestion generated by the proactive engine.
#[derive(Debug, Clone)]
pub struct Suggestion {
    #[allow(dead_code)]
    pub text: String,
    #[allow(dead_code)] // Reserved for tracking suggestion provenance
    pub source: SuggestionSource,
    #[allow(dead_code)] // Used internally for sorting
    pub confidence: f32,
}

/// The source of a proactive suggestion.
#[derive(Debug, Clone)]
#[allow(dead_code)] // IDs reserved for feedback/analytics
pub enum SuggestionSource {
    Pattern(i64),   // Behavior pattern ID
    Goal(String),   // Goal ID
    Procedure(i64), // Procedure ID
    Episode(i64),   // Episode ID
}

/// Context for generating suggestions.
pub struct SuggestionContext {
    #[allow(dead_code)] // Reserved for future explicit selection wiring.
    pub last_action: Option<String>,
    #[allow(dead_code)] // Reserved for future explicit selection wiring.
    pub current_topic: Option<String>,
    /// Explicitly selected behavior pattern IDs relevant to the current turn.
    pub relevant_pattern_ids: Vec<i64>,
    /// Explicitly selected goal IDs relevant to the current turn.
    pub relevant_goal_ids: Vec<String>,
    /// Explicitly selected procedure IDs relevant to the current turn.
    pub relevant_procedure_ids: Vec<i64>,
    /// Explicitly selected episode IDs relevant to the current turn.
    pub relevant_episode_ids: Vec<i64>,
    #[allow(dead_code)] // Reserved for time-based suggestions
    pub session_duration_mins: i32,
    #[allow(dead_code)] // Reserved for complexity-based suggestions
    pub tool_call_count: i32,
    #[allow(dead_code)] // Reserved for error-recovery suggestions
    pub has_errors: bool,
    #[allow(dead_code)] // Reserved for future explicit selection wiring.
    pub user_message: String,
}

/// The proactive suggestion engine.
pub struct ProactiveEngine {
    patterns: Vec<BehaviorPattern>,
    goals: Vec<Goal>,
    procedures: Vec<Procedure>,
    recent_episodes: Vec<Episode>,
    profile: UserProfile,
}

impl ProactiveEngine {
    pub fn new(
        patterns: Vec<BehaviorPattern>,
        goals: Vec<Goal>,
        procedures: Vec<Procedure>,
        recent_episodes: Vec<Episode>,
        profile: UserProfile,
    ) -> Self {
        Self {
            patterns,
            goals,
            procedures,
            recent_episodes,
            profile,
        }
    }

    /// Get suggestions based on the current context.
    pub fn get_suggestions(&self, context: &SuggestionContext) -> Vec<Suggestion> {
        // If user doesn't like suggestions, return empty
        if !self.profile.likes_suggestions {
            return vec![];
        }

        let mut suggestions = vec![];

        // 1. Pattern-based suggestions
        suggestions.extend(self.pattern_suggestions(context));

        // 2. Goal-based suggestions
        suggestions.extend(self.goal_suggestions(context));

        // 3. Procedure-based suggestions
        suggestions.extend(self.procedure_suggestions(context));

        // 4. Episode-based suggestions
        suggestions.extend(self.episode_suggestions(context));

        // Sort by confidence and deduplicate
        suggestions.sort_by(|a, b| {
            b.confidence
                .partial_cmp(&a.confidence)
                .unwrap_or(std::cmp::Ordering::Equal)
        });

        // Keep top 3
        suggestions.truncate(3);

        suggestions
    }

    /// Get the top suggestion if above confidence threshold.
    #[allow(dead_code)] // Reserved for single-suggestion mode
    pub fn get_top_suggestion(
        &self,
        context: &SuggestionContext,
        min_confidence: f32,
    ) -> Option<Suggestion> {
        self.get_suggestions(context)
            .into_iter()
            .find(|s| s.confidence >= min_confidence)
    }

    /// Generate suggestions based on behavior patterns.
    fn pattern_suggestions(&self, context: &SuggestionContext) -> Vec<Suggestion> {
        let mut suggestions = vec![];

        for pattern in &self.patterns {
            if pattern.confidence < 0.6 {
                continue;
            }

            let matches = match pattern.pattern_type.as_str() {
                "sequence" | "trigger" => context.relevant_pattern_ids.contains(&pattern.id),
                "habit" => {
                    // Habit patterns rely on observed recurring behavior frequency.
                    pattern.occurrence_count >= 3
                }
                _ => false,
            };

            if matches {
                if let Some(action) = &pattern.action {
                    suggestions.push(Suggestion {
                        text: format!("Based on your usual pattern: {}", action),
                        source: SuggestionSource::Pattern(pattern.id),
                        confidence: pattern.confidence,
                    });
                }
            }
        }

        suggestions
    }

    /// Generate suggestions based on active goals.
    fn goal_suggestions(&self, context: &SuggestionContext) -> Vec<Suggestion> {
        let mut suggestions = vec![];

        for goal in &self.goals {
            if goal.status != "active" {
                continue;
            }

            let relevant = context.relevant_goal_ids.contains(&goal.id);

            if relevant {
                let confidence = match goal.priority.as_str() {
                    "high" => 0.8,
                    "medium" => 0.6,
                    _ => 0.4,
                };

                suggestions.push(Suggestion {
                    text: format!("This relates to your goal: {}", goal.description),
                    source: SuggestionSource::Goal(goal.id.clone()),
                    confidence,
                });
            }
        }

        suggestions
    }

    /// Generate suggestions based on known procedures.
    fn procedure_suggestions(&self, context: &SuggestionContext) -> Vec<Suggestion> {
        let mut suggestions = vec![];

        for procedure in &self.procedures {
            // Only suggest procedures with good track record
            if procedure.success_count <= procedure.failure_count {
                continue;
            }

            let matches = context.relevant_procedure_ids.contains(&procedure.id);

            if matches {
                let success_rate = procedure.success_count as f32
                    / (procedure.success_count + procedure.failure_count) as f32;

                suggestions.push(Suggestion {
                    text: format!("I know how to handle this (procedure: {})", procedure.name),
                    source: SuggestionSource::Procedure(procedure.id),
                    confidence: success_rate * 0.8, // Scale down slightly
                });
            }
        }

        suggestions
    }

    /// Generate suggestions based on similar past episodes.
    fn episode_suggestions(&self, context: &SuggestionContext) -> Vec<Suggestion> {
        let mut suggestions = vec![];

        for episode in &self.recent_episodes {
            let topic_match = context.relevant_episode_ids.contains(&episode.id);

            if topic_match {
                let confidence = episode.importance * 0.6; // Scale by importance

                suggestions.push(Suggestion {
                    text: format!(
                        "We've worked on something similar before: {}",
                        truncate_str(&episode.summary, 50)
                    ),
                    source: SuggestionSource::Episode(episode.id),
                    confidence,
                });
            }
        }

        suggestions
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use chrono::Utc;

    fn make_profile(likes_suggestions: bool) -> UserProfile {
        UserProfile {
            id: 1,
            verbosity_preference: "medium".to_string(),
            explanation_depth: "moderate".to_string(),
            tone_preference: "neutral".to_string(),
            emoji_preference: "none".to_string(),
            typical_session_length: None,
            active_hours: None,
            common_workflows: None,
            asks_before_acting: true,
            prefers_explanations: true,
            likes_suggestions,
            updated_at: Utc::now(),
        }
    }

    #[test]
    fn test_no_suggestions_when_disabled() {
        let engine = ProactiveEngine::new(vec![], vec![], vec![], vec![], make_profile(false));

        let context = SuggestionContext {
            last_action: None,
            current_topic: None,
            relevant_pattern_ids: vec![],
            relevant_goal_ids: vec![],
            relevant_procedure_ids: vec![],
            relevant_episode_ids: vec![],
            session_duration_mins: 5,
            tool_call_count: 0,
            has_errors: false,
            user_message: "test".to_string(),
        };

        let suggestions = engine.get_suggestions(&context);
        assert!(suggestions.is_empty());
    }

    #[test]
    fn test_goal_suggestion() {
        let mut goal = Goal::new_finite("Complete the Rust migration", "test-session");
        goal.priority = "high".to_string();
        let goal_id = goal.id.clone();

        let engine = ProactiveEngine::new(vec![], vec![goal], vec![], vec![], make_profile(true));

        let context = SuggestionContext {
            last_action: None,
            current_topic: None,
            relevant_pattern_ids: vec![],
            relevant_goal_ids: vec![goal_id],
            relevant_procedure_ids: vec![],
            relevant_episode_ids: vec![],
            session_duration_mins: 5,
            tool_call_count: 0,
            has_errors: false,
            user_message: "Anything".to_string(),
        };

        let suggestions = engine.get_suggestions(&context);
        assert!(!suggestions.is_empty());
    }

    #[test]
    fn test_goal_suggestion_does_not_use_keyword_guessing() {
        let mut goal = Goal::new_finite("Complete the Rust migration", "test-session");
        goal.priority = "high".to_string();

        let engine = ProactiveEngine::new(vec![], vec![goal], vec![], vec![], make_profile(true));
        let context = SuggestionContext {
            last_action: None,
            current_topic: Some("rust".to_string()),
            relevant_pattern_ids: vec![],
            relevant_goal_ids: vec![],
            relevant_procedure_ids: vec![],
            relevant_episode_ids: vec![],
            session_duration_mins: 5,
            tool_call_count: 0,
            has_errors: false,
            user_message: "help me with rust".to_string(),
        };

        let suggestions = engine.get_suggestions(&context);
        assert!(suggestions.is_empty());
    }
}