#[derive(Deserialize)]
pub struct MemoryAnalyticsQuery {
#[serde(default = "default_hours")]
pub hours: Option<i64>,
#[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 {
p.trim_end_matches('h')
.parse::<i64>()
.unwrap_or(24)
} else {
self.hours.unwrap_or(24)
}
}
}
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
};
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!({}));
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);
}
}