1use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9use systemprompt_identifiers::{
10 AnalyticsChangeId, AnalyticsFactId, DeviceId, ManagedResourceId, NativeSessionId,
11 ResourceInvocationId, ResourceRevisionId, UserId,
12};
13
14use super::EvaluatorClient;
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
17#[serde(rename_all = "snake_case")]
18pub enum AnalyticsFactKind {
19 Invocation,
20 Request,
21 Assessment,
22 ResourceAssociation,
23}
24
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
26#[serde(deny_unknown_fields)]
27pub struct AnalyticsFactKey {
28 pub kind: AnalyticsFactKind,
29 pub source: String,
30 pub id: AnalyticsFactId,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
34#[serde(tag = "status", rename_all = "snake_case")]
35pub enum InvocationConsumerIdentity {
36 HistoricalUnknown,
37 Authenticated {
38 consumer_id: UserId,
39 device_id: DeviceId,
40 host: EvaluatorClient,
41 session_id: NativeSessionId,
42 },
43}
44
45#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
46#[serde(tag = "status", rename_all = "snake_case")]
47pub enum InvocationResourceAttribution {
48 Unknown,
49 Verified {
50 resource_id: ManagedResourceId,
51 revision_id: ResourceRevisionId,
52 },
53}
54
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
56#[serde(deny_unknown_fields)]
57pub struct NormalizedInvocationFact {
58 pub invocation_id: ResourceInvocationId,
59 pub occurred_at: DateTime<Utc>,
60 pub consumer: InvocationConsumerIdentity,
61 pub attribution: InvocationResourceAttribution,
62 pub succeeded: bool,
63 pub latency_micros: Option<u64>,
64}
65
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
67#[serde(tag = "status", rename_all = "snake_case")]
68pub enum RecordedSpend {
69 Known {
70 currency: String,
71 amount_micros: u64,
72 },
73 UnknownPricing,
74}
75
76#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
77#[serde(deny_unknown_fields)]
78pub struct NormalizedRequestFact {
79 pub request_key: AnalyticsFactKey,
80 pub occurred_at: DateTime<Utc>,
81 pub consumer: InvocationConsumerIdentity,
82 pub succeeded: bool,
83 pub spend: RecordedSpend,
84 pub input_tokens: Option<u64>,
85 pub output_tokens: Option<u64>,
86 pub latency_micros: Option<u64>,
87}
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
90#[serde(tag = "status", rename_all = "snake_case")]
91pub enum AssessmentOutcome {
92 Scored { score_millionths: i64 },
93 Failed,
94 Unavailable,
95}
96
97#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
98#[serde(deny_unknown_fields)]
99pub struct AssessmentConversationKey {
100 pub source: String,
101 pub id: AnalyticsFactId,
102}
103
104#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
105#[serde(deny_unknown_fields)]
106pub struct NormalizedAssessmentFact {
107 pub conversation_key: AssessmentConversationKey,
108 pub assessment_key: AnalyticsFactKey,
109 pub invocation_key: AnalyticsFactKey,
110 pub occurred_at: DateTime<Utc>,
111 pub outcome: AssessmentOutcome,
112}
113
114#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
115#[serde(deny_unknown_fields)]
116pub struct NormalizedResourceAssociationFact {
117 pub association_key: AnalyticsFactKey,
118 pub invocation_key: AnalyticsFactKey,
119 pub request_key: AnalyticsFactKey,
120 pub occurred_at: DateTime<Utc>,
121 pub attribution: InvocationResourceAttribution,
122}
123
124#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
125#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
126pub enum NormalizedAnalyticsFact {
127 Invocation(NormalizedInvocationFact),
128 Request(NormalizedRequestFact),
129 Assessment(NormalizedAssessmentFact),
130 ResourceAssociation(NormalizedResourceAssociationFact),
131}
132
133impl NormalizedAnalyticsFact {
134 pub const fn kind(&self) -> AnalyticsFactKind {
135 match self {
136 Self::Invocation(_) => AnalyticsFactKind::Invocation,
137 Self::Request(_) => AnalyticsFactKind::Request,
138 Self::Assessment(_) => AnalyticsFactKind::Assessment,
139 Self::ResourceAssociation(_) => AnalyticsFactKind::ResourceAssociation,
140 }
141 }
142}
143
144#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
145#[serde(tag = "operation", rename_all = "snake_case")]
146#[expect(
147 clippy::large_enum_variant,
148 reason = "wire contract: a tombstone carries nothing by definition and the fact is matched by value"
149)]
150pub enum AnalyticsChangeOperation {
151 Replace { fact: NormalizedAnalyticsFact },
152 Tombstone,
153}
154
155#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
156#[serde(deny_unknown_fields)]
157pub struct AnalyticsChange {
158 pub change_id: AnalyticsChangeId,
159 pub key: AnalyticsFactKey,
160 pub revision: u64,
161 pub occurred_at: DateTime<Utc>,
162 pub recorded_at: DateTime<Utc>,
163 pub operation: AnalyticsChangeOperation,
164}
165
166impl AnalyticsChange {
167 pub fn validate(&self) -> Result<(), super::FeedbackContractError> {
168 if self.revision == 0 || self.key.source.is_empty() || self.key.source.len() > 128 {
169 return Err(super::FeedbackContractError::Bounds);
170 }
171 if let AnalyticsChangeOperation::Replace { fact } = &self.operation
172 && fact.kind() != self.key.kind
173 {
174 return Err(super::FeedbackContractError::IncompleteManifest);
175 }
176 Ok(())
177 }
178}