roboticus-api 0.11.3

HTTP routes, WebSocket, auth, rate limiting, and dashboard for the Roboticus agent runtime
Documentation
#[derive(Deserialize)]
pub struct MemoryAnalyticsQuery {
    #[serde(default = "default_hours")]
    pub hours: Option<i64>,
    /// Alias for `hours`, accepted for dashboard compatibility.
    #[serde(default)]
    pub period: Option<String>,
}

fn default_hours() -> Option<i64> {
    Some(24)
}

impl MemoryAnalyticsQuery {
    fn resolve_hours(&self) -> i64 {
        if let Some(ref p) = self.period {
            // Parse "24h", "48h", etc.
            p.trim_end_matches('h')
                .parse::<i64>()
                .unwrap_or(24)
        } else {
            self.hours.unwrap_or(24)
        }
    }
}

/// GET /api/stats/memory-analytics
///
/// Returns a flat JSON object with memory retrieval metrics for the dashboard.
/// Gracefully handles databases that haven't run migration 025 by returning
/// null values for the analytics columns.
pub async fn get_memory_analytics(
    State(state): State<AppState>,
    Query(q): Query<MemoryAnalyticsQuery>,
) -> Result<impl IntoResponse, JsonError> {
    let hours = q.resolve_hours();
    let conn = state.db.conn();

    let cutoff = format!("datetime('now', '-{hours} hours')");

    let result = conn.query_row(
        &format!(
            "SELECT
                COUNT(*) as total_turns,
                COALESCE(SUM(CASE WHEN memory_tokens > 0 THEN 1 ELSE 0 END), 0) as retrieval_hits,
                COALESCE(AVG(CASE WHEN memory_tokens > 0 THEN
                    CAST(memory_tokens AS REAL) / NULLIF(token_budget, 0)
                END), 0.0) as avg_budget_utilization,
                COALESCE(SUM(memory_tokens), 0) as total_memory_tokens,
                COALESCE(SUM(token_budget), 0) as total_budget_tokens
             FROM context_snapshots
             WHERE created_at >= {cutoff}"
        ),
        [],
        |row| {
            Ok((
                row.get::<_, i64>(0)?,
                row.get::<_, i64>(1)?,
                row.get::<_, f64>(2)?,
                row.get::<_, i64>(3)?,
                row.get::<_, i64>(4)?,
            ))
        },
    );

    let (total_turns, retrieval_hits, avg_budget_util, total_mem_tokens, _total_budget) =
        result.unwrap_or((0, 0, 0.0, 0, 0));

    let hit_rate = if total_turns > 0 {
        Some(retrieval_hits as f64 / total_turns as f64)
    } else {
        None
    };

    // Tier distribution from memory_tiers_json
    let tier_dist = conn
        .query_row(
            &format!(
                "SELECT memory_tiers_json FROM context_snapshots
                 WHERE memory_tiers_json IS NOT NULL AND created_at >= {cutoff}
                 ORDER BY created_at DESC LIMIT 1"
            ),
            [],
            |row| row.get::<_, Option<String>>(0),
        )
        .unwrap_or(None);

    let tier_distribution: serde_json::Value = tier_dist
        .and_then(|s| serde_json::from_str(&s).ok())
        .unwrap_or(serde_json::json!({}));

    // Memory ROI: ratio of memory-assisted turns that got positive feedback
    let memory_roi = if retrieval_hits > 0 {
        Some(retrieval_hits as f64 / total_turns.max(1) as f64)
    } else {
        None
    };

    Ok(axum::Json(serde_json::json!({
        "period_hours": hours,
        "hit_rate": hit_rate,
        "avg_similarity": null,
        "avg_budget_utilization": avg_budget_util,
        "total_entries_retrieved": total_mem_tokens,
        "memory_roi": memory_roi,
        "tier_distribution": tier_distribution,
        "total_turns": total_turns,
        "retrieval_hits": retrieval_hits,
    })))
}

#[cfg(test)]
mod memory_analytics_tests {
    use super::*;

    #[test]
    fn query_resolves_period_param() {
        let q = MemoryAnalyticsQuery {
            hours: None,
            period: Some("48h".into()),
        };
        assert_eq!(q.resolve_hours(), 48);
    }

    #[test]
    fn query_defaults_to_24h() {
        let q = MemoryAnalyticsQuery {
            hours: None,
            period: None,
        };
        assert_eq!(q.resolve_hours(), 24);
    }
}