Skip to main content

systemprompt_models/feedback/
analytics.rs

1//! Normalised invocation facts: the fact key, consumer and resource
2//! attribution, and the spend a consumer recorded for one skill invocation.
3//!
4//! Copyright (c) systemprompt.io — Business Source License 1.1.
5//! See <https://systemprompt.io> for licensing details.
6
7use 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}