1use serde::{Deserialize, Serialize};
2
3use crate::receipt_query::ReceiptReadContext;
4
5pub const MAX_ANALYTICS_GROUP_LIMIT: usize = 200;
7
8#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
10#[serde(rename_all = "snake_case")]
11pub enum AnalyticsTimeBucket {
12 Hour,
13 Day,
14}
15
16impl AnalyticsTimeBucket {
17 #[must_use]
18 pub fn width_secs(self) -> u64 {
19 match self {
20 Self::Hour => 3_600,
21 Self::Day => 86_400,
22 }
23 }
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
28#[serde(rename_all = "camelCase")]
29pub struct ReceiptAnalyticsQuery {
30 #[serde(default, skip_serializing_if = "Option::is_none")]
31 pub capability_id: Option<String>,
32 #[serde(default, skip_serializing_if = "Option::is_none")]
33 pub agent_subject: Option<String>,
34 #[serde(default, skip_serializing_if = "Option::is_none")]
35 pub tool_server: Option<String>,
36 #[serde(default, skip_serializing_if = "Option::is_none")]
37 pub tool_name: Option<String>,
38 #[serde(default, skip_serializing_if = "Option::is_none")]
39 pub since: Option<u64>,
40 #[serde(default, skip_serializing_if = "Option::is_none")]
41 pub until: Option<u64>,
42 #[serde(default, skip_serializing_if = "Option::is_none")]
43 pub group_limit: Option<usize>,
44 #[serde(default, skip_serializing_if = "Option::is_none")]
45 pub time_bucket: Option<AnalyticsTimeBucket>,
46 #[serde(skip)]
48 pub read_context: Option<ReceiptReadContext>,
49}
50
51impl Default for ReceiptAnalyticsQuery {
52 fn default() -> Self {
53 Self {
54 capability_id: None,
55 agent_subject: None,
56 tool_server: None,
57 tool_name: None,
58 since: None,
59 until: None,
60 group_limit: Some(50),
61 time_bucket: Some(AnalyticsTimeBucket::Day),
62 read_context: None,
63 }
64 }
65}
66
67#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
69#[serde(rename_all = "camelCase")]
70pub struct ReceiptAnalyticsMetrics {
71 pub total_receipts: u64,
72 pub allow_count: u64,
73 pub deny_count: u64,
74 pub cancelled_count: u64,
75 pub incomplete_count: u64,
76 pub total_cost_charged: u64,
77 pub total_attempted_cost: u64,
78 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub reliability_score: Option<f64>,
80 #[serde(default, skip_serializing_if = "Option::is_none")]
81 pub compliance_rate: Option<f64>,
82 #[serde(default, skip_serializing_if = "Option::is_none")]
83 pub budget_utilization_rate: Option<f64>,
84}
85
86impl ReceiptAnalyticsMetrics {
87 #[must_use]
88 pub fn from_raw(
89 total_receipts: u64,
90 allow_count: u64,
91 deny_count: u64,
92 cancelled_count: u64,
93 incomplete_count: u64,
94 total_cost_charged: u64,
95 total_attempted_cost: u64,
96 ) -> Self {
97 let terminal_total = allow_count
98 .saturating_add(cancelled_count)
99 .saturating_add(incomplete_count);
100 let attempted_total = total_cost_charged.saturating_add(total_attempted_cost);
101
102 Self {
103 total_receipts,
104 allow_count,
105 deny_count,
106 cancelled_count,
107 incomplete_count,
108 total_cost_charged,
109 total_attempted_cost,
110 reliability_score: ratio_option(allow_count, terminal_total),
111 compliance_rate: ratio_option(
112 total_receipts.saturating_sub(deny_count),
113 total_receipts,
114 ),
115 budget_utilization_rate: ratio_option(total_cost_charged, attempted_total),
116 }
117 }
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
121#[serde(rename_all = "camelCase")]
122pub struct AgentAnalyticsRow {
123 pub subject_key: String,
124 pub metrics: ReceiptAnalyticsMetrics,
125}
126
127#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
128#[serde(rename_all = "camelCase")]
129pub struct ToolAnalyticsRow {
130 pub tool_server: String,
131 pub tool_name: String,
132 pub metrics: ReceiptAnalyticsMetrics,
133}
134
135#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
136#[serde(rename_all = "camelCase")]
137pub struct TimeAnalyticsRow {
138 pub bucket_start: u64,
139 pub bucket_end: u64,
140 pub metrics: ReceiptAnalyticsMetrics,
141}
142
143#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
144#[serde(rename_all = "camelCase")]
145pub struct ReceiptAnalyticsResponse {
146 pub summary: ReceiptAnalyticsMetrics,
147 pub by_agent: Vec<AgentAnalyticsRow>,
148 pub by_tool: Vec<ToolAnalyticsRow>,
149 pub by_time: Vec<TimeAnalyticsRow>,
150}
151
152fn ratio_option(numerator: u64, denominator: u64) -> Option<f64> {
153 if denominator == 0 {
154 None
155 } else {
156 Some(numerator as f64 / denominator as f64)
157 }
158}