Skip to main content

ccboard_core/analytics/
insights.rs

1//! Actionable insights generation
2//!
3//! Rule-based recommendations to optimize costs and productivity.
4
5use super::forecasting::{ForecastData, TrendDirection};
6use super::patterns::UsagePatterns;
7use super::trends::TrendsData;
8
9/// Alert types for budget and anomaly detection
10#[derive(Debug, Clone)]
11pub enum Alert {
12    /// Budget warning (current cost approaching budget)
13    BudgetWarning { current: f64, budget: f64, pct: f64 },
14    /// Usage spike (day with tokens > 2x average)
15    UsageSpike { day: String, tokens: u64, avg: u64 },
16    /// Projected budget overage
17    ProjectedOverage {
18        forecast: f64,
19        budget: f64,
20        overage: f64,
21    },
22}
23
24/// Generate actionable insights
25///
26/// Uses rule-based thresholds to identify optimization opportunities:
27/// - Peak hours >30% → batch work suggestion
28/// - Opus >20% → review necessity
29/// - Cost imbalance (token vs cost ratio >1.5x) → expensive model warning
30/// - Cost trend >+20% with confidence >0.5 → budget alert
31/// - Weekend usage <10% → weekday optimization
32/// - Low confidence (<0.5) → unreliable forecast warning
33///
34/// # Performance
35/// Target: <10ms
36pub fn generate_insights(
37    _trends: &TrendsData,
38    patterns: &UsagePatterns,
39    forecast: &ForecastData,
40) -> Vec<String> {
41    let mut insights = Vec::new();
42
43    // 1. Peak hours insight (>30% of sessions)
44    let total_sessions: usize = patterns.hourly_distribution.iter().sum();
45    if !patterns.peak_hours.is_empty() && total_sessions > 0 {
46        let peak_count: usize = patterns
47            .peak_hours
48            .iter()
49            .map(|&h| patterns.hourly_distribution[h as usize])
50            .sum();
51
52        if peak_count > total_sessions * 3 / 10 {
53            insights.push(format!(
54                "Peak hours: {:02}h-{:02}h ({:.0}% of sessions). Consider batching work.",
55                patterns.peak_hours.first().unwrap_or(&0),
56                patterns.peak_hours.last().unwrap_or(&23),
57                peak_count as f64 / total_sessions as f64 * 100.0
58            ));
59        }
60    }
61
62    // 2. Expensive model warning (Opus >20% usage)
63    if let Some(&opus_pct) = patterns.model_distribution.get("opus") {
64        if opus_pct > 0.2 {
65            insights.push(format!(
66                "Opus usage: {:.0}% tokens. Costs 3x more than Sonnet. Review necessity.",
67                opus_pct * 100.0
68            ));
69        }
70    }
71
72    // 3. Cost imbalance (tokens vs cost distribution mismatch)
73    for (model, &token_pct) in &patterns.model_distribution {
74        if let Some(&cost_pct) = patterns.model_cost_distribution.get(model) {
75            let cost_premium = cost_pct / token_pct;
76            if cost_premium > 1.5 && cost_pct > 0.2 {
77                insights.push(format!(
78                    "{}: {:.0}% tokens but {:.0}% cost. Cost premium: {:.1}x.",
79                    model,
80                    token_pct * 100.0,
81                    cost_pct * 100.0,
82                    cost_premium
83                ));
84            }
85        }
86    }
87
88    // 4. Cost trend warning (>20% increase)
89    if let TrendDirection::Up(pct) = forecast.trend_direction {
90        if pct > 20.0 && forecast.confidence > 0.5 {
91            insights.push(format!(
92                "Cost trend: +{:.0}% over period. Monthly estimate: ${:.2} (confidence: {:.0}%).",
93                pct,
94                forecast.monthly_cost_estimate,
95                forecast.confidence * 100.0
96            ));
97        }
98    }
99
100    // 5. Weekend optimization (usage <10%)
101    let weekday_sum: usize = patterns.weekday_distribution[0..5].iter().sum();
102    let weekend_sum: usize = patterns.weekday_distribution[5..7].iter().sum();
103    let total = weekday_sum + weekend_sum;
104
105    if total > 0 {
106        let weekend_pct = weekend_sum as f64 / total as f64;
107        if weekend_pct < 0.1 {
108            insights.push(format!(
109                "Weekend usage: {:.0}%. Consider weekday-focused workflows.",
110                weekend_pct * 100.0
111            ));
112        }
113    }
114
115    // 6. Low confidence warning
116    if forecast.confidence < 0.5 {
117        insights.push(format!(
118            "Forecast confidence low ({:.0}%). Predictions may be unreliable.",
119            forecast.confidence * 100.0
120        ));
121    }
122
123    insights
124}
125
126/// Generate budget and anomaly alerts
127///
128/// Detects:
129/// - Budget warnings (current cost > threshold%)
130/// - Projected overages (forecast > budget)
131/// - Usage spikes (daily tokens > 2x average)
132///
133/// # Arguments
134/// - `trends`: Time series data for spike detection
135/// - `forecast`: Forecast data for budget projections
136/// - `monthly_budget`: Optional monthly budget in USD
137/// - `alert_threshold_pct`: Alert threshold (default 80%)
138pub fn generate_budget_alerts(
139    trends: &TrendsData,
140    forecast: &ForecastData,
141    monthly_budget: Option<f64>,
142    alert_threshold_pct: f64,
143) -> Vec<Alert> {
144    let mut alerts = Vec::new();
145
146    if let Some(budget) = monthly_budget {
147        // 1. Current budget warning
148        let current_cost = forecast.monthly_cost_estimate;
149        let pct = (current_cost / budget * 100.0).min(100.0);
150
151        if pct >= alert_threshold_pct {
152            alerts.push(Alert::BudgetWarning {
153                current: current_cost,
154                budget,
155                pct,
156            });
157        }
158
159        // 2. Projected overage
160        if forecast.unavailable_reason.is_none() {
161            let projected = forecast.next_30_days_cost;
162            if projected > budget {
163                alerts.push(Alert::ProjectedOverage {
164                    forecast: projected,
165                    budget,
166                    overage: projected - budget,
167                });
168            }
169        }
170    }
171
172    // 3. Usage spikes (tokens > 2x average)
173    if !trends.daily_tokens.is_empty() {
174        let avg_tokens: u64 =
175            trends.daily_tokens.iter().sum::<u64>() / trends.daily_tokens.len() as u64;
176
177        for (i, &tokens) in trends.daily_tokens.iter().enumerate() {
178            if tokens > avg_tokens * 2 {
179                if let Some(day) = trends.dates.get(i) {
180                    alerts.push(Alert::UsageSpike {
181                        day: day.clone(),
182                        tokens,
183                        avg: avg_tokens,
184                    });
185                }
186            }
187        }
188    }
189
190    alerts
191}