1use std::collections::HashMap;
2
3use chrono::{Datelike, Local, Timelike};
4
5use crate::data::models::{GlobalDataQuality, SessionData};
6use crate::pricing::calculator::PricingCalculator;
7
8use super::{
9 AggregatedTokens, CacheSavings, CostByCategory, OverviewResult, SessionSummary,
10 SubscriptionValue,
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 {
43 total_agent_turns += 1;
44 }
45
46 for name in &turn.tool_names {
48 *tool_count_map.entry(name.clone()).or_insert(0) += 1;
49 }
50 }
51 }
52
53 let mut tool_counts: Vec<(String, usize)> = tool_count_map.into_iter().collect();
54 tool_counts.sort_by(|a, b| b.1.cmp(&a.1));
55
56 let mut total_output_tokens: u64 = 0;
58 let mut total_context_tokens: u64 = 0;
59 for agg in tokens_by_model.values() {
60 total_output_tokens += agg.output_tokens;
61 total_context_tokens += agg.context_tokens();
62 }
63
64 let total_cache_read: u64 = tokens_by_model.values().map(|a| a.cache_read_tokens).sum();
66 let avg_cache_hit_rate = if total_context_tokens > 0 {
67 (total_cache_read as f64 / total_context_tokens as f64) * 100.0
68 } else {
69 0.0
70 };
71
72 let session_summaries: Vec<SessionSummary> = sessions
74 .iter()
75 .map(|s| build_session_summary(s, calc))
76 .collect();
77
78 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 let output_ratio = if total_context_tokens > 0 {
136 total_output_tokens as f64 / total_context_tokens as f64 * 100.0
137 } else {
138 0.0
139 };
140 let cost_per_turn = if total_turns > 0 {
141 total_cost / total_turns as f64
142 } else {
143 0.0
144 };
145 let tokens_per_output_turn = if total_turns > 0 {
146 total_output_tokens / total_turns as u64
147 } else {
148 0
149 };
150
151 OverviewResult {
152 total_sessions: sessions.len(),
153 total_turns,
154 total_agent_turns,
155 tokens_by_model,
156 cost_by_model,
157 total_cost,
158 hourly_distribution,
159 quality,
160 subscription_value,
161 weekday_hour_matrix,
162 tool_counts,
163 cost_by_category,
164 session_summaries,
165 total_output_tokens,
166 total_context_tokens,
167 avg_cache_hit_rate,
168 cache_savings,
169 output_ratio,
170 cost_per_turn,
171 tokens_per_output_turn,
172 }
173}
174
175#[allow(clippy::too_many_arguments)]
176fn process_turn(
177 turn: &crate::data::models::ValidatedTurn,
178 calc: &PricingCalculator,
179 tokens_by_model: &mut HashMap<String, AggregatedTokens>,
180 cost_by_model: &mut HashMap<String, f64>,
181 total_cost: &mut f64,
182 hourly_distribution: &mut [usize; 24],
183 weekday_hour_matrix: &mut [[usize; 24]; 7],
184 cost_by_category: &mut CostByCategory,
185) {
186 tokens_by_model
188 .entry(turn.model.clone())
189 .or_default()
190 .add_usage(&turn.usage);
191
192 let cost = calc.calculate_turn_cost(&turn.model, &turn.usage);
194 *cost_by_model.entry(turn.model.clone()).or_insert(0.0) += cost.total;
195 *total_cost += cost.total;
196
197 cost_by_category.input_cost += cost.input_cost;
199 cost_by_category.output_cost += cost.output_cost;
200 cost_by_category.cache_write_5m_cost += cost.cache_write_5m_cost;
201 cost_by_category.cache_write_1h_cost += cost.cache_write_1h_cost;
202 cost_by_category.cache_read_cost += cost.cache_read_cost;
203
204 let local_ts = turn.timestamp.with_timezone(&Local);
206 let hour = local_ts.hour() as usize;
207 hourly_distribution[hour] += 1;
208
209 let weekday = local_ts.weekday().num_days_from_monday() as usize; weekday_hour_matrix[weekday][hour] += 1;
212}
213
214fn build_session_summary(session: &SessionData, calc: &PricingCalculator) -> SessionSummary {
216 let session_id = if session.session_id.len() > 8 {
217 session.session_id[..8].to_string()
218 } else {
219 session.session_id.clone()
220 };
221
222 let project_display_name = session
223 .project
224 .as_deref()
225 .map(crate::analysis::project::project_display_name)
226 .unwrap_or_else(|| "(unknown)".to_string());
227
228 let all_turns = session.all_responses();
229 let turn_count = all_turns.len();
230
231 let duration_minutes = match (session.first_timestamp, session.last_timestamp) {
233 (Some(first), Some(last)) => (last - first).num_seconds() as f64 / 60.0,
234 _ => 0.0,
235 };
236
237 let mut model_counts: HashMap<&str, usize> = HashMap::new();
239 let mut output_tokens: u64 = 0;
240 let mut context_tokens: u64 = 0;
241 let mut max_context: u64 = 0;
242 let mut total_cache_read: u64 = 0;
243 let mut total_context: u64 = 0;
244 let mut total_5m: u64 = 0;
245 let mut total_1h: u64 = 0;
246 let mut compaction_count: usize = 0;
247 let mut agent_turn_count: usize = 0;
248 let mut tool_use_count: usize = 0;
249 let mut total_cost: f64 = 0.0;
250 let mut prev_context_size: Option<u64> = None;
251 let mut tool_map: HashMap<String, usize> = HashMap::new();
252
253 for turn in &all_turns {
254 *model_counts.entry(&turn.model).or_insert(0) += 1;
255
256 let input = turn.usage.input_tokens.unwrap_or(0);
257 let cache_create = turn.usage.cache_creation_input_tokens.unwrap_or(0);
258 let cache_read = turn.usage.cache_read_input_tokens.unwrap_or(0);
259 let out = turn.usage.output_tokens.unwrap_or(0);
260
261 output_tokens += out;
262 let ctx = input + cache_create + cache_read;
263 context_tokens += ctx;
264 total_context += ctx;
265 total_cache_read += cache_read;
266
267 if ctx > max_context {
268 max_context = ctx;
269 }
270
271 if let Some(ref detail) = turn.usage.cache_creation {
273 total_5m += detail.ephemeral_5m_input_tokens.unwrap_or(0);
274 total_1h += detail.ephemeral_1h_input_tokens.unwrap_or(0);
275 }
276
277 if let Some(prev) = prev_context_size {
279 if prev > 0 && (ctx as f64) < (prev as f64 * 0.9) {
280 compaction_count += 1;
281 }
282 }
283 prev_context_size = Some(ctx);
284
285 if turn.is_agent {
287 agent_turn_count += 1;
288 }
289
290 if turn.stop_reason.as_deref() == Some("tool_use") {
292 tool_use_count += 1;
293 }
294 for name in &turn.tool_names {
295 *tool_map.entry(name.clone()).or_insert(0) += 1;
296 }
297
298 let cost = calc.calculate_turn_cost(&turn.model, &turn.usage);
300 total_cost += cost.total;
301 }
302
303 let model = model_counts
305 .into_iter()
306 .max_by_key(|(_, count)| *count)
307 .map(|(m, _)| m.to_string())
308 .unwrap_or_default();
309
310 let cache_hit_rate = if total_context > 0 {
312 (total_cache_read as f64 / total_context as f64) * 100.0
313 } else {
314 0.0
315 };
316
317 let total_cache_write = total_5m + total_1h;
319 let cache_write_5m_pct = if total_cache_write > 0 {
320 (total_5m as f64 / total_cache_write as f64) * 100.0
321 } else {
322 0.0
323 };
324
325 let output_ratio = if context_tokens > 0 {
326 output_tokens as f64 / context_tokens as f64 * 100.0
327 } else {
328 0.0
329 };
330 let cost_per_turn = if turn_count > 0 {
331 total_cost / turn_count as f64
332 } else {
333 0.0
334 };
335
336 SessionSummary {
337 session_id,
338 project_display_name,
339 first_timestamp: session.first_timestamp,
340 duration_minutes,
341 model,
342 turn_count,
343 agent_turn_count,
344 output_tokens,
345 context_tokens,
346 max_context,
347 cache_hit_rate,
348 cache_write_5m_pct,
349 compaction_count,
350 cost: total_cost,
351 tool_use_count,
352 top_tools: {
353 let mut tools: Vec<(String, usize)> = tool_map.into_iter().collect();
354 tools.sort_by(|a, b| b.1.cmp(&a.1));
355 tools.truncate(5);
356 tools
357 },
358 turn_details: None,
359 output_ratio,
360 cost_per_turn,
361 }
362}