1use http::{HeaderMap, StatusCode};
2use indexmap::IndexMap;
3use serde::Deserialize;
4
5#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
7pub struct Usage {
8 pub input_tokens: u64,
10 pub output_tokens: u64,
12}
13
14#[derive(Clone, Debug, Default)]
16pub struct ResponseMeta {
17 pub request_id: Option<String>,
19 pub status: Option<StatusCode>,
21 pub headers: HeaderMap,
23 pub attempts: u32,
25}
26
27#[derive(Clone, Debug, PartialEq, Deserialize)]
29#[non_exhaustive]
30#[serde(tag = "type", rename_all = "lowercase")]
31pub enum Answer {
32 Noul {
34 noul: f64,
36 },
37 Choice {
39 choice: String,
41 probabilities: IndexMap<String, f64>,
43 confidence: f64,
45 },
46 Score {
48 score: f64,
50 legend: IndexMap<String, serde_json::Value>,
52 #[serde(default)]
54 probabilities: Option<IndexMap<String, f64>>,
55 confidence: f64,
57 },
58 #[serde(other)]
60 Unknown,
61}
62
63#[derive(Clone, Copy, Debug, PartialEq)]
65pub struct NoulView {
66 pub noul: f64,
68}
69
70#[derive(Clone, Copy, Debug, PartialEq)]
72pub struct ChoiceView<'a> {
73 pub choice: &'a str,
75 pub probabilities: &'a IndexMap<String, f64>,
77 pub confidence: f64,
79}
80
81#[derive(Clone, Copy, Debug, PartialEq)]
83pub struct ScoreView<'a> {
84 pub score: f64,
86 pub legend: &'a IndexMap<String, serde_json::Value>,
88 pub probabilities: Option<&'a IndexMap<String, f64>>,
90 pub confidence: f64,
92}
93
94#[derive(Clone, Debug, Deserialize)]
99pub struct SystemOneResponse {
100 pub model: String,
102 pub answers: IndexMap<String, Answer>,
104 #[serde(default)]
106 pub usage: Option<Usage>,
107 #[serde(skip)]
109 pub meta: ResponseMeta,
110}
111
112impl SystemOneResponse {
113 pub fn nouls(&self) -> impl Iterator<Item = (&str, NoulView)> + '_ {
115 self.answers
116 .iter()
117 .filter_map(|(key, answer)| match answer {
118 Answer::Noul { noul } => Some((key.as_str(), NoulView { noul: *noul })),
119 _ => None,
120 })
121 }
122
123 pub fn choices(&self) -> impl Iterator<Item = (&str, ChoiceView<'_>)> + '_ {
125 self.answers
126 .iter()
127 .filter_map(|(key, answer)| match answer {
128 Answer::Choice {
129 choice,
130 probabilities,
131 confidence,
132 } => Some((
133 key.as_str(),
134 ChoiceView {
135 choice,
136 probabilities,
137 confidence: *confidence,
138 },
139 )),
140 _ => None,
141 })
142 }
143
144 pub fn scores(&self) -> impl Iterator<Item = (&str, ScoreView<'_>)> + '_ {
146 self.answers
147 .iter()
148 .filter_map(|(key, answer)| match answer {
149 Answer::Score {
150 score,
151 legend,
152 probabilities,
153 confidence,
154 } => Some((
155 key.as_str(),
156 ScoreView {
157 score: *score,
158 legend,
159 probabilities: probabilities.as_ref(),
160 confidence: *confidence,
161 },
162 )),
163 _ => None,
164 })
165 }
166
167 #[must_use]
169 pub fn noul(&self, key: &str) -> Option<f64> {
170 match self.answers.get(key) {
171 Some(Answer::Noul { noul }) => Some(*noul),
172 _ => None,
173 }
174 }
175
176 #[must_use]
178 pub fn choice(&self, key: &str) -> Option<ChoiceView<'_>> {
179 match self.answers.get(key) {
180 Some(Answer::Choice {
181 choice,
182 probabilities,
183 confidence,
184 }) => Some(ChoiceView {
185 choice,
186 probabilities,
187 confidence: *confidence,
188 }),
189 _ => None,
190 }
191 }
192
193 #[must_use]
195 pub fn score(&self, key: &str) -> Option<ScoreView<'_>> {
196 match self.answers.get(key) {
197 Some(Answer::Score {
198 score,
199 legend,
200 probabilities,
201 confidence,
202 }) => Some(ScoreView {
203 score: *score,
204 legend,
205 probabilities: probabilities.as_ref(),
206 confidence: *confidence,
207 }),
208 _ => None,
209 }
210 }
211
212 #[must_use]
214 pub fn answer(&self, key: &str) -> Option<&Answer> {
215 self.answers.get(key)
216 }
217}
218
219#[cfg(test)]
220mod tests {
221 use super::*;
222 use serde_json::json;
223
224 #[test]
225 fn deserializes_noul_choice_score() {
226 let body = json!({
227 "model": "jev-latest",
228 "answers": {
229 "urgent": { "type": "noul", "noul": 0.92 },
230 "dept": {
231 "type": "choice",
232 "choice": "technical",
233 "probabilities": { "billing": 0.08, "technical": 0.85, "sales": 0.07 },
234 "confidence": 0.82
235 },
236 "frustration": {
237 "type": "score",
238 "score": 1.6,
239 "legend": { "0": "Calm", "1": "Frustrated", "2": "Very angry" },
240 "probabilities": { "0": 0.05, "1": 0.3, "2": 0.65 },
241 "confidence": 0.78
242 }
243 },
244 "usage": { "input_tokens": 312, "output_tokens": 48 }
245 });
246 let resp: SystemOneResponse = serde_json::from_value(body).unwrap();
247 assert_eq!(resp.noul("urgent"), Some(0.92));
248 assert_eq!(resp.choice("dept").unwrap().choice, "technical");
249 assert_eq!(resp.score("frustration").unwrap().score, 1.6);
250 assert_eq!(resp.usage.as_ref().unwrap().input_tokens, 312);
251 assert_eq!(resp.nouls().count(), 1);
252 assert_eq!(resp.choices().count(), 1);
253 assert_eq!(resp.scores().count(), 1);
254 }
255
256 #[test]
257 fn unknown_answer_type_does_not_fail() {
258 let body = json!({
259 "model": "jev-latest",
260 "answers": {
261 "future": { "type": "spectrum", "value": 1 },
262 "urgent": { "type": "noul", "noul": 0.5 }
263 }
264 });
265 let resp: SystemOneResponse = serde_json::from_value(body).unwrap();
266 assert!(matches!(resp.answer("future"), Some(Answer::Unknown)));
267 assert_eq!(resp.noul("urgent"), Some(0.5));
268 }
269
270 #[test]
271 fn extra_fields_on_known_answers_are_ignored() {
272 let body = json!({
273 "model": "jev-latest",
274 "answers": {
275 "urgent": { "type": "noul", "noul": 0.1, "extra": true }
276 },
277 "bonus": 1
278 });
279 let resp: SystemOneResponse = serde_json::from_value(body).unwrap();
280 assert_eq!(resp.noul("urgent"), Some(0.1));
281 }
282
283 #[test]
284 fn score_without_probabilities_is_ok() {
285 let body = json!({
286 "model": "jev-latest",
287 "answers": {
288 "s": {
289 "type": "score",
290 "score": 0.0,
291 "legend": { "0": "a", "1": "b" },
292 "confidence": 0.5
293 }
294 }
295 });
296 let resp: SystemOneResponse = serde_json::from_value(body).unwrap();
297 assert!(resp.score("s").unwrap().probabilities.is_none());
298 }
299}