1use std::sync::Arc;
2
3use chrono::SecondsFormat;
4
5use crate::internal::storage::db::{
6 call_blocking, Database, ExperienceSummary, LlmModelUsageSummary, LlmUsageSummary,
7 MemoryObservabilitySummary, SkillLearningSummary,
8};
9
10fn fmt_int(v: i64) -> String {
11 let neg = v < 0;
12 let mut n = v.unsigned_abs();
13 let mut parts = Vec::new();
14 while n >= 1000 {
15 parts.push(format!("{:03}", n % 1000));
16 n /= 1000;
17 }
18 let mut out = n.to_string();
19 while let Some(p) = parts.pop() {
20 out.push(',');
21 out.push_str(&p);
22 }
23 if neg {
24 format!("-{out}")
25 } else {
26 out
27 }
28}
29
30fn fmt_summary_line(name: &str, s: &LlmUsageSummary) -> String {
31 format!(
32 "{name:<8} req={:>4} tok={} (in {} / out {})",
33 fmt_int(s.requests),
34 fmt_int(s.total_tokens),
35 fmt_int(s.input_tokens),
36 fmt_int(s.output_tokens)
37 )
38}
39
40fn format_model_rows(rows: &[LlmModelUsageSummary], max_rows: usize) -> Vec<String> {
41 if rows.is_empty() {
42 return vec![" - (no data)".to_string()];
43 }
44
45 rows.iter()
46 .take(max_rows)
47 .enumerate()
48 .map(|(idx, row)| {
49 format!(
50 " {}. {} tok={} req={} in {} / out {}",
51 idx + 1,
52 row.model,
53 fmt_int(row.total_tokens),
54 fmt_int(row.requests),
55 fmt_int(row.input_tokens),
56 fmt_int(row.output_tokens)
57 )
58 })
59 .collect()
60}
61
62fn block_lines(
63 title: &str,
64 all: &LlmUsageSummary,
65 d24: &LlmUsageSummary,
66 d7: &LlmUsageSummary,
67 models_24h: &[LlmModelUsageSummary],
68 models_7d: &[LlmModelUsageSummary],
69) -> Vec<String> {
70 let mut lines = vec![
71 title.to_string(),
72 "".to_string(),
73 format!(" 🧮 {}", fmt_summary_line("All-time", all)),
74 format!(" 🕓 {}", fmt_summary_line("Last 24h", d24)),
75 format!(" 📆 {}", fmt_summary_line("Last 7d", d7)),
76 "".to_string(),
77 " 🤖 Top models (24h)".to_string(),
78 ];
79 lines.extend(format_model_rows(models_24h, 4));
80 lines.push("".to_string());
81 lines.push(" 🤖 Top models (7d)".to_string());
82 lines.extend(format_model_rows(models_7d, 4));
83
84 lines
85}
86
87async fn query_summary(
88 db: Arc<Database>,
89 chat_id: Option<i64>,
90 since: Option<String>,
91) -> Result<LlmUsageSummary, String> {
92 call_blocking(db, move |d| {
93 d.get_llm_usage_summary_since(chat_id, since.as_deref())
94 })
95 .await
96 .map_err(|e| e.to_string())
97}
98
99async fn query_by_model(
100 db: Arc<Database>,
101 chat_id: Option<i64>,
102 since: Option<String>,
103) -> Result<Vec<LlmModelUsageSummary>, String> {
104 call_blocking(db, move |d| {
105 d.get_llm_usage_by_model(chat_id, since.as_deref(), None)
106 })
107 .await
108 .map_err(|e| e.to_string())
109}
110
111async fn query_memory_summary(
112 db: Arc<Database>,
113 chat_id: Option<i64>,
114) -> Result<MemoryObservabilitySummary, String> {
115 call_blocking(db, move |d| d.get_memory_observability_summary(chat_id))
116 .await
117 .map_err(|e| e.to_string())
118}
119
120async fn query_learning_summary(
121 db: Arc<Database>,
122 chat_id: Option<i64>,
123) -> Result<ExperienceSummary, String> {
124 call_blocking(db, move |d| d.get_experience_summary(chat_id))
125 .await
126 .map_err(|e| e.to_string())
127}
128
129async fn query_skill_learning(db: Arc<Database>) -> Result<Vec<SkillLearningSummary>, String> {
130 call_blocking(db, move |d| d.get_skill_learning_summaries())
131 .await
132 .map_err(|e| e.to_string())
133}
134
135pub async fn build_usage_report(db: Arc<Database>, chat_id: i64) -> Result<String, String> {
136 let now = chrono::Utc::now();
137 let since_24h = (now - chrono::Duration::hours(24)).to_rfc3339();
138 let since_7d = (now - chrono::Duration::days(7)).to_rfc3339();
139
140 let chat_all = query_summary(db.clone(), Some(chat_id), None).await?;
141 let chat_24h = query_summary(db.clone(), Some(chat_id), Some(since_24h.clone())).await?;
142 let chat_7d = query_summary(db.clone(), Some(chat_id), Some(since_7d.clone())).await?;
143 let chat_models_24h = query_by_model(db.clone(), Some(chat_id), Some(since_24h)).await?;
144 let chat_models_7d = query_by_model(db.clone(), Some(chat_id), Some(since_7d)).await?;
145
146 let global_all = query_summary(db.clone(), None, None).await?;
147 let global_24h = query_summary(
148 db.clone(),
149 None,
150 Some((now - chrono::Duration::hours(24)).to_rfc3339()),
151 )
152 .await?;
153 let global_7d = query_summary(
154 db.clone(),
155 None,
156 Some((now - chrono::Duration::days(7)).to_rfc3339()),
157 )
158 .await?;
159 let global_models_24h = query_by_model(
160 db.clone(),
161 None,
162 Some((now - chrono::Duration::hours(24)).to_rfc3339()),
163 )
164 .await?;
165 let global_models_7d = query_by_model(
166 db.clone(),
167 None,
168 Some((now - chrono::Duration::days(7)).to_rfc3339()),
169 )
170 .await?;
171 let chat_mem = query_memory_summary(db.clone(), Some(chat_id)).await?;
172 let global_mem = query_memory_summary(db.clone(), None).await?;
173 let chat_learning = query_learning_summary(db.clone(), Some(chat_id)).await?;
174 let global_learning = query_learning_summary(db.clone(), None).await?;
175 let skill_learning = query_skill_learning(db).await?;
176
177 let mut lines = vec![
178 "📊 Token Usage".to_string(),
179 format!(
180 "🕒 Updated: {}",
181 now.to_rfc3339_opts(SecondsFormat::Secs, true)
182 ),
183 "".to_string(),
184 ];
185
186 lines.extend(block_lines(
187 "🔹 This chat",
188 &chat_all,
189 &chat_24h,
190 &chat_7d,
191 &chat_models_24h,
192 &chat_models_7d,
193 ));
194
195 lines.push("".to_string());
196
197 lines.extend(block_lines(
198 "🌍 Global",
199 &global_all,
200 &global_24h,
201 &global_7d,
202 &global_models_24h,
203 &global_models_7d,
204 ));
205
206 lines.push("".to_string());
207 lines.push("🧠 Memory Observability".to_string());
208 lines.push("".to_string());
209 lines.push(format!(
210 " This chat: total={} active={} archived={} avg_conf={:.2} low_conf={}",
211 fmt_int(chat_mem.total),
212 fmt_int(chat_mem.active),
213 fmt_int(chat_mem.archived),
214 chat_mem.avg_confidence,
215 fmt_int(chat_mem.low_confidence)
216 ));
217 lines.push(format!(
218 " Reflector 24h: runs={} +{} ~{} -{}",
219 fmt_int(chat_mem.reflector_runs_24h),
220 fmt_int(chat_mem.reflector_inserted_24h),
221 fmt_int(chat_mem.reflector_updated_24h),
222 fmt_int(chat_mem.reflector_skipped_24h)
223 ));
224 lines.push(format!(
225 " Injection 24h: events={} selected/candidates={}/{}",
226 fmt_int(chat_mem.injection_events_24h),
227 fmt_int(chat_mem.injection_selected_24h),
228 fmt_int(chat_mem.injection_candidates_24h)
229 ));
230 lines.push("".to_string());
231 lines.push(format!(
232 " Global: total={} active={} archived={} avg_conf={:.2} low_conf={}",
233 fmt_int(global_mem.total),
234 fmt_int(global_mem.active),
235 fmt_int(global_mem.archived),
236 global_mem.avg_confidence,
237 fmt_int(global_mem.low_confidence)
238 ));
239 lines.push(format!(
240 " Global reflector 24h: runs={} +{} ~{} -{}",
241 fmt_int(global_mem.reflector_runs_24h),
242 fmt_int(global_mem.reflector_inserted_24h),
243 fmt_int(global_mem.reflector_updated_24h),
244 fmt_int(global_mem.reflector_skipped_24h)
245 ));
246 lines.push(format!(
247 " Global injection 24h: events={} selected/candidates={}/{}",
248 fmt_int(global_mem.injection_events_24h),
249 fmt_int(global_mem.injection_selected_24h),
250 fmt_int(global_mem.injection_candidates_24h)
251 ));
252 lines.push("".to_string());
253 lines.push("Long-Horizon Learning".to_string());
254 lines.push("".to_string());
255 lines.push(format!(
256 " This chat: runs={} completed={} failed/cancelled={} with_evidence={} tokens={}/{} tools={}/{} cost=${:.4}",
257 fmt_int(chat_learning.total_runs),
258 fmt_int(chat_learning.completed_runs),
259 fmt_int(chat_learning.failed_runs),
260 fmt_int(chat_learning.verified_runs),
261 fmt_int(chat_learning.input_tokens),
262 fmt_int(chat_learning.output_tokens),
263 fmt_int(chat_learning.tool_calls),
264 fmt_int(chat_learning.tool_errors),
265 chat_learning.estimated_cost_usd
266 ));
267 lines.push(format!(
268 " Global: runs={} completed={} failed/cancelled={} with_evidence={} tokens={}/{} tools={}/{} cost=${:.4}",
269 fmt_int(global_learning.total_runs),
270 fmt_int(global_learning.completed_runs),
271 fmt_int(global_learning.failed_runs),
272 fmt_int(global_learning.verified_runs),
273 fmt_int(global_learning.input_tokens),
274 fmt_int(global_learning.output_tokens),
275 fmt_int(global_learning.tool_calls),
276 fmt_int(global_learning.tool_errors),
277 global_learning.estimated_cost_usd
278 ));
279 if skill_learning.is_empty() {
280 lines.push(" Skills: no governed versions yet".to_string());
281 } else {
282 lines.push(" Governed skills:".to_string());
283 for skill in skill_learning.iter().take(8) {
284 lines.push(format!(
285 " - {} v{} [{}] outcomes={} pass_rate={:.0}% utility_lb={:.0}%",
286 skill.skill_name,
287 skill.active_version,
288 skill.state,
289 fmt_int(skill.total_outcomes),
290 skill.success_rate * 100.0,
291 skill.utility_lower_bound * 100.0
292 ));
293 }
294 }
295
296 Ok(lines.join("\n"))
297}