1use std::collections::HashMap;
2
3use chrono::{Datelike, Timelike};
4
5use crate::data::models::{GlobalDataQuality, SessionData};
6use crate::pricing::calculator::PricingCalculator;
7
8use super::{
9 AggregatedTokens, CacheSavings, CostByCategory, OverviewResult, SessionSummary,
10 SubscriptionValue, TurnCostBreakdown, TurnDetail,
11};
12
13pub fn analyze_overview(
14 sessions: &[SessionData],
15 quality: GlobalDataQuality,
16 calc: &PricingCalculator,
17 subscription_price: Option<f64>,
18) -> OverviewResult {
19 let mut tokens_by_model: HashMap<String, AggregatedTokens> = HashMap::new();
20 let mut cost_by_model: HashMap<String, f64> = HashMap::new();
21 let mut total_cost = 0.0;
22 let mut hourly_distribution = [0usize; 24];
23 let mut weekday_hour_matrix = [[0usize; 24]; 7];
24 let mut total_turns = 0usize;
25 let mut total_agent_turns = 0usize;
26 let mut cost_by_category = CostByCategory::default();
27 let mut tool_count_map: HashMap<String, usize> = HashMap::new();
28
29 for session in sessions {
30 for turn in session.all_responses() {
31 process_turn(
32 turn,
33 calc,
34 &mut tokens_by_model,
35 &mut cost_by_model,
36 &mut total_cost,
37 &mut hourly_distribution,
38 &mut weekday_hour_matrix,
39 &mut cost_by_category,
40 );
41 total_turns += 1;
42 if turn.is_agent { total_agent_turns += 1; }
43
44 for name in &turn.tool_names {
46 *tool_count_map.entry(name.clone()).or_insert(0) += 1;
47 }
48 }
49 }
50
51 let mut tool_counts: Vec<(String, usize)> = tool_count_map.into_iter().collect();
52 tool_counts.sort_by(|a, b| b.1.cmp(&a.1));
53
54 let mut total_output_tokens: u64 = 0;
56 let mut total_context_tokens: u64 = 0;
57 for agg in tokens_by_model.values() {
58 total_output_tokens += agg.output_tokens;
59 total_context_tokens += agg.context_tokens();
60 }
61
62 let total_cache_read: u64 = tokens_by_model.values().map(|a| a.cache_read_tokens).sum();
64 let avg_cache_hit_rate = if total_context_tokens > 0 {
65 (total_cache_read as f64 / total_context_tokens as f64) * 100.0
66 } else {
67 0.0
68 };
69
70 let mut session_summaries: Vec<SessionSummary> = sessions
72 .iter()
73 .map(|s| build_session_summary(s, calc))
74 .collect();
75
76 for (idx, session) in sessions.iter().enumerate() {
78 let details = build_turn_details(session, calc);
79 session_summaries[idx].turn_details = Some(details);
80 }
81
82 let cache_savings = {
85 let mut total_saved = 0.0f64;
86 let mut without_cache = 0.0f64;
87 let mut with_cache = 0.0f64;
88 let mut by_model: Vec<(String, f64)> = Vec::new();
89
90 for (model, tokens) in &tokens_by_model {
91 if let Some((price, _)) = calc.get_price(model) {
92 let cache_read_mtok = tokens.cache_read_tokens as f64 / 1_000_000.0;
93 let hypothetical = cache_read_mtok * price.base_input;
94 let actual = cache_read_mtok * price.cache_read;
95 let saved = hypothetical - actual;
96 without_cache += hypothetical;
97 with_cache += actual;
98 total_saved += saved;
99 if saved > 0.01 {
100 by_model.push((model.clone(), saved));
101 }
102 }
103 }
104 by_model.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
105
106 let savings_pct = if without_cache > 0.0 {
107 total_saved / without_cache * 100.0
108 } else {
109 0.0
110 };
111
112 CacheSavings {
113 total_saved,
114 without_cache_cost: without_cache,
115 with_cache_cost: with_cache,
116 savings_pct,
117 by_model,
118 }
119 };
120
121 let subscription_value = subscription_price.map(|monthly_price| {
122 let value_multiplier = if total_cost > 0.0 {
123 total_cost / monthly_price
124 } else {
125 0.0
126 };
127 SubscriptionValue {
128 monthly_price,
129 api_equivalent: total_cost,
130 value_multiplier,
131 }
132 });
133
134 OverviewResult {
135 total_sessions: sessions.len(),
136 total_turns,
137 total_agent_turns,
138 tokens_by_model,
139 cost_by_model,
140 total_cost,
141 hourly_distribution,
142 quality,
143 subscription_value,
144 weekday_hour_matrix,
145 tool_counts,
146 cost_by_category,
147 session_summaries,
148 total_output_tokens,
149 total_context_tokens,
150 avg_cache_hit_rate,
151 cache_savings,
152 }
153}
154
155#[allow(clippy::too_many_arguments)]
156fn process_turn(
157 turn: &crate::data::models::ValidatedTurn,
158 calc: &PricingCalculator,
159 tokens_by_model: &mut HashMap<String, AggregatedTokens>,
160 cost_by_model: &mut HashMap<String, f64>,
161 total_cost: &mut f64,
162 hourly_distribution: &mut [usize; 24],
163 weekday_hour_matrix: &mut [[usize; 24]; 7],
164 cost_by_category: &mut CostByCategory,
165) {
166 tokens_by_model
168 .entry(turn.model.clone())
169 .or_default()
170 .add_usage(&turn.usage);
171
172 let cost = calc.calculate_turn_cost(&turn.model, &turn.usage);
174 *cost_by_model.entry(turn.model.clone()).or_insert(0.0) += cost.total;
175 *total_cost += cost.total;
176
177 cost_by_category.input_cost += cost.input_cost;
179 cost_by_category.output_cost += cost.output_cost;
180 cost_by_category.cache_write_5m_cost += cost.cache_write_5m_cost;
181 cost_by_category.cache_write_1h_cost += cost.cache_write_1h_cost;
182 cost_by_category.cache_read_cost += cost.cache_read_cost;
183
184 let hour = turn.timestamp.hour() as usize;
186 hourly_distribution[hour] += 1;
187
188 let weekday = turn.timestamp.weekday().num_days_from_monday() as usize; weekday_hour_matrix[weekday][hour] += 1;
191}
192
193fn build_session_summary(session: &SessionData, calc: &PricingCalculator) -> SessionSummary {
195 let session_id = if session.session_id.len() > 8 {
196 session.session_id[..8].to_string()
197 } else {
198 session.session_id.clone()
199 };
200
201 let project_display_name = session
202 .project
203 .as_deref()
204 .map(crate::analysis::project::project_display_name)
205 .unwrap_or_else(|| "(unknown)".to_string());
206
207 let all_turns = session.all_responses();
208 let turn_count = all_turns.len();
209
210 let duration_minutes = match (session.first_timestamp, session.last_timestamp) {
212 (Some(first), Some(last)) => (last - first).num_seconds() as f64 / 60.0,
213 _ => 0.0,
214 };
215
216 let mut model_counts: HashMap<&str, usize> = HashMap::new();
218 let mut output_tokens: u64 = 0;
219 let mut context_tokens: u64 = 0;
220 let mut max_context: u64 = 0;
221 let mut total_cache_read: u64 = 0;
222 let mut total_context: u64 = 0;
223 let mut total_5m: u64 = 0;
224 let mut total_1h: u64 = 0;
225 let mut compaction_count: usize = 0;
226 let mut agent_turn_count: usize = 0;
227 let mut tool_use_count: usize = 0;
228 let mut total_cost: f64 = 0.0;
229 let mut prev_context_size: Option<u64> = None;
230 let mut tool_map: HashMap<String, usize> = HashMap::new();
231
232 for turn in &all_turns {
233 *model_counts.entry(&turn.model).or_insert(0) += 1;
234
235 let input = turn.usage.input_tokens.unwrap_or(0);
236 let cache_create = turn.usage.cache_creation_input_tokens.unwrap_or(0);
237 let cache_read = turn.usage.cache_read_input_tokens.unwrap_or(0);
238 let out = turn.usage.output_tokens.unwrap_or(0);
239
240 output_tokens += out;
241 let ctx = input + cache_create + cache_read;
242 context_tokens += ctx;
243 total_context += ctx;
244 total_cache_read += cache_read;
245
246 if ctx > max_context {
247 max_context = ctx;
248 }
249
250 if let Some(ref detail) = turn.usage.cache_creation {
252 total_5m += detail.ephemeral_5m_input_tokens.unwrap_or(0);
253 total_1h += detail.ephemeral_1h_input_tokens.unwrap_or(0);
254 }
255
256 if let Some(prev) = prev_context_size {
258 if prev > 0 && (ctx as f64) < (prev as f64 * 0.9) {
259 compaction_count += 1;
260 }
261 }
262 prev_context_size = Some(ctx);
263
264 if turn.is_agent {
266 agent_turn_count += 1;
267 }
268
269 if turn.stop_reason.as_deref() == Some("tool_use") {
271 tool_use_count += 1;
272 }
273 for name in &turn.tool_names {
274 *tool_map.entry(name.clone()).or_insert(0) += 1;
275 }
276
277 let cost = calc.calculate_turn_cost(&turn.model, &turn.usage);
279 total_cost += cost.total;
280 }
281
282 let model = model_counts
284 .into_iter()
285 .max_by_key(|(_, count)| *count)
286 .map(|(m, _)| m.to_string())
287 .unwrap_or_default();
288
289 let cache_hit_rate = if total_context > 0 {
291 (total_cache_read as f64 / total_context as f64) * 100.0
292 } else {
293 0.0
294 };
295
296 let total_cache_write = total_5m + total_1h;
298 let cache_write_5m_pct = if total_cache_write > 0 {
299 (total_5m as f64 / total_cache_write as f64) * 100.0
300 } else {
301 0.0
302 };
303
304 SessionSummary {
305 session_id,
306 project_display_name,
307 first_timestamp: session.first_timestamp,
308 duration_minutes,
309 model,
310 turn_count,
311 agent_turn_count,
312 output_tokens,
313 context_tokens,
314 max_context,
315 cache_hit_rate,
316 cache_write_5m_pct,
317 compaction_count,
318 cost: total_cost,
319 tool_use_count,
320 top_tools: {
321 let mut tools: Vec<(String, usize)> = tool_map.into_iter().collect();
322 tools.sort_by(|a, b| b.1.cmp(&a.1));
323 tools.truncate(5);
324 tools
325 },
326 turn_details: None,
327 }
328}
329
330fn build_turn_details(session: &SessionData, calc: &PricingCalculator) -> Vec<TurnDetail> {
332 let all_turns = session.all_responses();
333
334 let mut details = Vec::new();
335 let mut prev_context_size: Option<u64> = None;
336
337 for (i, turn) in all_turns.iter().enumerate() {
338 let input = turn.usage.input_tokens.unwrap_or(0);
339 let output = turn.usage.output_tokens.unwrap_or(0);
340 let cache_create = turn.usage.cache_creation_input_tokens.unwrap_or(0);
341 let cache_read = turn.usage.cache_read_input_tokens.unwrap_or(0);
342
343 let (cache_write_5m, cache_write_1h) = if let Some(ref detail) = turn.usage.cache_creation {
344 (
345 detail.ephemeral_5m_input_tokens.unwrap_or(0),
346 detail.ephemeral_1h_input_tokens.unwrap_or(0),
347 )
348 } else {
349 (0, 0)
350 };
351
352 let context_size = input + cache_create + cache_read;
353 let cache_hit_rate = if context_size > 0 {
354 (cache_read as f64 / context_size as f64) * 100.0
355 } else {
356 0.0
357 };
358
359 let is_compaction = match prev_context_size {
360 Some(prev) => prev > 0 && (context_size as f64) < (prev as f64 * 0.9),
361 None => false,
362 };
363 let context_delta = match prev_context_size {
364 Some(prev) => context_size as i64 - prev as i64,
365 None => 0,
366 };
367 prev_context_size = Some(context_size);
368
369 let pricing_cost = calc.calculate_turn_cost(&turn.model, &turn.usage);
370
371 let cost_breakdown = TurnCostBreakdown {
372 input_cost: pricing_cost.input_cost,
373 output_cost: pricing_cost.output_cost,
374 cache_write_5m_cost: pricing_cost.cache_write_5m_cost,
375 cache_write_1h_cost: pricing_cost.cache_write_1h_cost,
376 cache_read_cost: pricing_cost.cache_read_cost,
377 total: pricing_cost.total,
378 };
379
380 details.push(TurnDetail {
381 turn_number: i + 1,
382 timestamp: turn.timestamp,
383 model: turn.model.clone(),
384 input_tokens: input,
385 output_tokens: output,
386 cache_write_5m_tokens: cache_write_5m,
387 cache_write_1h_tokens: cache_write_1h,
388 cache_read_tokens: cache_read,
389 context_size,
390 cache_hit_rate,
391 cost: pricing_cost.total,
392 cost_breakdown,
393 stop_reason: turn.stop_reason.clone(),
394 is_agent: turn.is_agent,
395 is_compaction,
396 context_delta,
397 user_text: turn.user_text.clone(),
398 assistant_text: turn.assistant_text.clone(),
399 tool_names: turn.tool_names.clone(),
400 });
401 }
402
403 details
404}