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