241 lines
8.8 KiB
Rust
241 lines
8.8 KiB
Rust
// SPDX-License-Identifier: LicenseRef-Commercial
|
|
// Session Export & Reporting — generate Markdown reports from agent sessions.
|
|
|
|
use serde::Serialize;
|
|
|
|
#[derive(Debug, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct SessionReport {
|
|
pub project_id: String,
|
|
pub session_id: String,
|
|
pub markdown: String,
|
|
pub cost_usd: f64,
|
|
pub turn_count: i64,
|
|
pub tool_call_count: i64,
|
|
pub duration_minutes: f64,
|
|
pub model: String,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct ProjectSummaryReport {
|
|
pub project_id: String,
|
|
pub markdown: String,
|
|
pub total_sessions: i64,
|
|
pub total_cost_usd: f64,
|
|
pub period_days: i64,
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn pro_export_session(project_id: String, session_id: String) -> Result<SessionReport, String> {
|
|
let conn = super::open_sessions_db()?;
|
|
|
|
// Get metric for this session
|
|
let mut stmt = conn.prepare(
|
|
"SELECT start_time, end_time, peak_tokens, turn_count, tool_call_count, cost_usd, model, status, error_message
|
|
FROM session_metrics WHERE project_id = ?1 AND session_id = ?2"
|
|
).map_err(|e| format!("Query failed: {e}"))?;
|
|
|
|
let (start, end, tokens, turns, tools, cost, model, status, error): (i64, i64, i64, i64, i64, f64, Option<String>, String, Option<String>) =
|
|
stmt.query_row(rusqlite::params![project_id, session_id], |row| {
|
|
Ok((row.get("start_time")?, row.get("end_time")?, row.get("peak_tokens")?, row.get("turn_count")?,
|
|
row.get("tool_call_count")?, row.get("cost_usd")?, row.get("model")?, row.get("status")?, row.get("error_message")?))
|
|
}).map_err(|e| format!("Session not found: {e}"))?;
|
|
|
|
let model_name = model.clone().unwrap_or_else(|| "unknown".into());
|
|
let duration_min = (end - start) as f64 / 60.0;
|
|
let start_str = epoch_to_iso(start);
|
|
let end_str = epoch_to_iso(end);
|
|
|
|
// Get messages for this session
|
|
let mut msg_stmt = conn.prepare(
|
|
"SELECT message_type, content, created_at
|
|
FROM agent_messages WHERE session_id = ?1 ORDER BY created_at ASC"
|
|
).map_err(|e| format!("Messages query failed: {e}"))?;
|
|
|
|
let messages: Vec<(String, String, i64)> = msg_stmt
|
|
.query_map(rusqlite::params![session_id], |row| {
|
|
Ok((row.get("message_type")?, row.get("content")?, row.get("created_at")?))
|
|
})
|
|
.map_err(|e| format!("Messages query failed: {e}"))?
|
|
.collect::<Result<Vec<_>, _>>()
|
|
.map_err(|e| format!("Row read failed: {e}"))?;
|
|
|
|
let mut md = String::new();
|
|
md.push_str(&format!("# Session Report: {session_id}\n\n"));
|
|
md.push_str(&format!("**Project:** {project_id} \n"));
|
|
md.push_str(&format!("**Model:** {model_name} \n"));
|
|
md.push_str(&format!("**Status:** {status} \n"));
|
|
md.push_str(&format!("**Duration:** {duration_min:.1} min \n"));
|
|
md.push_str(&format!("**Start:** {start_str} \n"));
|
|
md.push_str(&format!("**End:** {end_str} \n\n"));
|
|
md.push_str("## Metrics\n\n");
|
|
md.push_str(&format!("| Metric | Value |\n|--------|-------|\n"));
|
|
md.push_str(&format!("| Cost | ${cost:.4} |\n"));
|
|
md.push_str(&format!("| Peak Tokens | {tokens} |\n"));
|
|
md.push_str(&format!("| Turns | {turns} |\n"));
|
|
md.push_str(&format!("| Tool Calls | {tools} |\n\n"));
|
|
|
|
if let Some(err) = &error {
|
|
md.push_str(&format!("## Error\n\n```\n{err}\n```\n\n"));
|
|
}
|
|
|
|
if !messages.is_empty() {
|
|
md.push_str("## Conversation\n\n");
|
|
for (msg_type, content, ts) in &messages {
|
|
let time = epoch_to_time(*ts);
|
|
let prefix = match msg_type.as_str() {
|
|
"user" | "human" => "**User**",
|
|
"assistant" => "**Assistant**",
|
|
"tool_call" => "**Tool Call**",
|
|
"tool_result" => "**Tool Result**",
|
|
_ => msg_type.as_str(),
|
|
};
|
|
// Truncate long content (safe UTF-8 boundary)
|
|
let display = if content.len() > 500 {
|
|
let truncated: String = content.chars().take(500).collect();
|
|
format!("{}... *(truncated, {} chars)*", truncated, content.len())
|
|
} else {
|
|
content.clone()
|
|
};
|
|
md.push_str(&format!("### [{time}] {prefix}\n\n{display}\n\n"));
|
|
}
|
|
}
|
|
|
|
Ok(SessionReport {
|
|
project_id,
|
|
session_id,
|
|
markdown: md,
|
|
cost_usd: cost,
|
|
turn_count: turns,
|
|
tool_call_count: tools,
|
|
duration_minutes: duration_min,
|
|
model: model_name,
|
|
})
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn pro_export_project_summary(project_id: String, days: Option<i64>) -> Result<ProjectSummaryReport, String> {
|
|
let conn = super::open_sessions_db()?;
|
|
let period = days.unwrap_or(30);
|
|
let cutoff = super::analytics::now_epoch() - (period * 86400);
|
|
|
|
let mut stmt = conn.prepare(
|
|
"SELECT session_id, start_time, end_time, cost_usd, turn_count, tool_call_count, model, status
|
|
FROM session_metrics WHERE project_id = ?1 AND end_time >= ?2 ORDER BY end_time DESC"
|
|
).map_err(|e| format!("Query failed: {e}"))?;
|
|
|
|
let sessions: Vec<(String, i64, i64, f64, i64, i64, Option<String>, String)> = stmt
|
|
.query_map(rusqlite::params![project_id, cutoff], |row| {
|
|
Ok((row.get("session_id")?, row.get("start_time")?, row.get("end_time")?, row.get("cost_usd")?,
|
|
row.get("turn_count")?, row.get("tool_call_count")?, row.get("model")?, row.get("status")?))
|
|
})
|
|
.map_err(|e| format!("Query failed: {e}"))?
|
|
.collect::<Result<Vec<_>, _>>()
|
|
.map_err(|e| format!("Row read failed: {e}"))?;
|
|
|
|
let total_cost: f64 = sessions.iter().map(|s| s.3).sum();
|
|
let total_sessions = sessions.len() as i64;
|
|
|
|
let mut md = String::new();
|
|
md.push_str(&format!("# Project Summary: {project_id}\n\n"));
|
|
md.push_str(&format!("**Period:** last {period} days \n"));
|
|
md.push_str(&format!("**Total Sessions:** {total_sessions} \n"));
|
|
md.push_str(&format!("**Total Cost:** ${total_cost:.4} \n\n"));
|
|
|
|
if !sessions.is_empty() {
|
|
md.push_str("## Sessions\n\n");
|
|
md.push_str("| Date | Session | Model | Duration | Cost | Turns | Tools | Status |\n");
|
|
md.push_str("|------|---------|-------|----------|------|-------|-------|--------|\n");
|
|
for (sid, start, end, cost, turns, tools, model, status) in &sessions {
|
|
let date = epoch_to_date(*start);
|
|
let dur = (*end - *start) as f64 / 60.0;
|
|
let m = model.as_deref().unwrap_or("?");
|
|
let short_sid = if sid.len() > 8 { &sid[..8] } else { sid };
|
|
md.push_str(&format!("| {date} | {short_sid}.. | {m} | {dur:.0}m | ${cost:.4} | {turns} | {tools} | {status} |\n"));
|
|
}
|
|
}
|
|
|
|
Ok(ProjectSummaryReport {
|
|
project_id,
|
|
markdown: md,
|
|
total_sessions,
|
|
total_cost_usd: total_cost,
|
|
period_days: period,
|
|
})
|
|
}
|
|
|
|
fn epoch_to_iso(epoch: i64) -> String {
|
|
let secs = epoch;
|
|
let time = secs % 86400;
|
|
let h = time / 3600;
|
|
let m = (time % 3600) / 60;
|
|
// Simple date from epoch (2000-01-01 = day 10957 from 1970)
|
|
format!("{}T{:02}:{:02}Z", epoch_to_date(secs), h, m)
|
|
}
|
|
|
|
fn epoch_to_time(epoch: i64) -> String {
|
|
let time = epoch % 86400;
|
|
format!("{:02}:{:02}", time / 3600, (time % 3600) / 60)
|
|
}
|
|
|
|
pub(crate) fn epoch_to_date(epoch: i64) -> String {
|
|
// Days since 1970-01-01
|
|
let mut days = epoch / 86400;
|
|
let mut year = 1970i64;
|
|
loop {
|
|
let days_in_year = if is_leap(year) { 366 } else { 365 };
|
|
if days < days_in_year { break; }
|
|
days -= days_in_year;
|
|
year += 1;
|
|
}
|
|
let months = if is_leap(year) {
|
|
[31,29,31,30,31,30,31,31,30,31,30,31]
|
|
} else {
|
|
[31,28,31,30,31,30,31,31,30,31,30,31]
|
|
};
|
|
let mut month = 0usize;
|
|
for (i, &d) in months.iter().enumerate() {
|
|
if days < d { month = i; break; }
|
|
days -= d;
|
|
}
|
|
format!("{year:04}-{:02}-{:02}", month + 1, days + 1)
|
|
}
|
|
|
|
fn is_leap(y: i64) -> bool {
|
|
(y % 4 == 0 && y % 100 != 0) || y % 400 == 0
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_epoch_to_date() {
|
|
assert_eq!(epoch_to_date(0), "1970-01-01");
|
|
assert_eq!(epoch_to_date(1710000000), "2024-03-09");
|
|
}
|
|
|
|
#[test]
|
|
fn test_epoch_to_iso() {
|
|
let iso = epoch_to_iso(1710000000);
|
|
assert!(iso.starts_with("2024-"));
|
|
assert!(iso.ends_with('Z'));
|
|
}
|
|
|
|
#[test]
|
|
fn test_session_report_struct() {
|
|
let r = SessionReport {
|
|
project_id: "test".into(),
|
|
session_id: "abc".into(),
|
|
markdown: "# Report".into(),
|
|
cost_usd: 0.5,
|
|
turn_count: 10,
|
|
tool_call_count: 5,
|
|
duration_minutes: 15.0,
|
|
model: "opus".into(),
|
|
};
|
|
let json = serde_json::to_string(&r).unwrap();
|
|
assert!(json.contains("durationMinutes"));
|
|
}
|
|
}
|