1use indexmap::IndexMap;
7use serde::{Deserialize, Serialize};
8use serde_json::{Number, Value};
9
10use crate::{errors::ItemError, question::QuestionType, request::ClassifyRequest};
11
12#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
28#[non_exhaustive]
29pub struct Answer {
30 #[serde(rename = "type")]
31 pub kind: QuestionType,
32 #[serde(default, skip_serializing_if = "Option::is_none")]
33 pub noul: Option<Number>,
34 #[serde(default, skip_serializing_if = "Option::is_none")]
35 pub choice: Option<String>,
36 #[serde(default, skip_serializing_if = "Option::is_none")]
37 pub score: Option<Number>,
38 #[serde(default, skip_serializing_if = "Option::is_none")]
39 pub legend: Option<IndexMap<String, Value>>,
40 #[serde(default, skip_serializing_if = "Option::is_none")]
41 pub probabilities: Option<IndexMap<String, Number>>,
42 #[serde(default, skip_serializing_if = "Option::is_none")]
43 pub confidence: Option<Number>,
44 #[serde(flatten)]
47 pub extra: IndexMap<String, Value>,
48}
49
50impl Answer {
51 fn empty(kind: QuestionType) -> Self {
52 Self {
53 kind,
54 noul: None,
55 choice: None,
56 score: None,
57 legend: None,
58 probabilities: None,
59 confidence: None,
60 extra: IndexMap::new(),
61 }
62 }
63
64 pub fn noul(noul: Number) -> Self {
66 Self {
67 noul: Some(noul),
68 ..Self::empty(QuestionType::Noul)
69 }
70 }
71
72 pub fn choice(
74 choice: impl Into<String>,
75 probabilities: IndexMap<String, Number>,
76 confidence: Number,
77 ) -> Self {
78 Self {
79 choice: Some(choice.into()),
80 probabilities: Some(probabilities),
81 confidence: Some(confidence),
82 ..Self::empty(QuestionType::Choice)
83 }
84 }
85
86 pub fn score(
88 score: Number,
89 legend: IndexMap<String, Value>,
90 probabilities: IndexMap<String, Number>,
91 confidence: Number,
92 ) -> Self {
93 Self {
94 score: Some(score),
95 legend: Some(legend),
96 probabilities: Some(probabilities),
97 confidence: Some(confidence),
98 ..Self::empty(QuestionType::Score)
99 }
100 }
101}
102
103#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
105#[non_exhaustive]
106pub struct Usage {
107 #[serde(default, skip_serializing_if = "Option::is_none")]
108 pub input_tokens: Option<u64>,
109 #[serde(default, skip_serializing_if = "Option::is_none")]
110 pub output_tokens: Option<u64>,
111}
112
113impl Usage {
114 pub fn new() -> Self {
115 Self::default()
116 }
117
118 pub fn with_input_tokens(mut self, tokens: u64) -> Self {
119 self.input_tokens = Some(tokens);
120 self
121 }
122
123 pub fn with_output_tokens(mut self, tokens: u64) -> Self {
124 self.output_tokens = Some(tokens);
125 self
126 }
127
128 pub fn is_empty(&self) -> bool {
130 self.input_tokens.is_none() && self.output_tokens.is_none()
131 }
132}
133
134pub fn sum_usage<'a>(items: impl IntoIterator<Item = &'a ItemResult>) -> Usage {
137 let add = cortexkit_model_batch::absent::add_reported;
139 items
140 .into_iter()
141 .filter_map(|item| item.usage)
142 .fold(Usage::default(), |total, usage| Usage {
143 input_tokens: add(total.input_tokens, usage.input_tokens),
144 output_tokens: add(total.output_tokens, usage.output_tokens),
145 })
146}
147
148#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
151#[non_exhaustive]
152pub struct ItemResult {
153 pub index: u64,
155 #[serde(default, skip_serializing_if = "Option::is_none")]
156 pub answers: Option<IndexMap<String, Answer>>,
157 #[serde(default, skip_serializing_if = "Option::is_none")]
158 pub error: Option<ItemError>,
159 #[serde(default, skip_serializing_if = "Option::is_none")]
162 pub usage: Option<Usage>,
163}
164
165impl ItemResult {
166 pub fn answered(index: u64, answers: IndexMap<String, Answer>) -> Self {
167 Self {
168 index,
169 answers: Some(answers),
170 error: None,
171 usage: None,
172 }
173 }
174
175 pub fn failed(index: u64, error: ItemError) -> Self {
176 Self {
177 index,
178 answers: None,
179 error: Some(error),
180 usage: None,
181 }
182 }
183
184 pub fn with_usage(mut self, usage: Usage) -> Self {
185 self.usage = Some(usage);
186 self
187 }
188}
189
190#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
192#[non_exhaustive]
193pub struct ClassifyReply {
194 pub batch_id: String,
195 pub model: String,
196 pub items: Vec<ItemResult>,
198 #[serde(default)]
200 pub usage: Usage,
201 #[serde(default, skip_serializing_if = "Option::is_none")]
204 pub cost_usd: Option<Number>,
205}
206
207impl ClassifyReply {
208 pub fn new(
210 batch_id: impl Into<String>,
211 model: impl Into<String>,
212 items: Vec<ItemResult>,
213 ) -> Self {
214 let usage = sum_usage(&items);
215 Self {
216 batch_id: batch_id.into(),
217 model: model.into(),
218 items,
219 usage,
220 cost_usd: None,
221 }
222 }
223
224 pub fn with_usage(mut self, usage: Usage) -> Self {
225 self.usage = usage;
226 self
227 }
228
229 pub fn with_cost_usd(mut self, cost_usd: Number) -> Self {
230 self.cost_usd = Some(cost_usd);
231 self
232 }
233}
234
235pub fn check_reply(reply: &ClassifyReply, request: &ClassifyRequest) -> Result<(), String> {
241 if reply.batch_id != request.batch_id {
242 return Err(format!(
243 "batch_id {} answers request {}",
244 reply.batch_id, request.batch_id
245 ));
246 }
247 if reply.model != request.model {
248 return Err(format!(
249 "model {} answers a request for {}",
250 reply.model, request.model
251 ));
252 }
253 if reply.items.len() != request.items.len() {
254 return Err(format!(
255 "{} items answer {} request items",
256 reply.items.len(),
257 request.items.len()
258 ));
259 }
260 for (position, item) in reply.items.iter().enumerate() {
261 if item.index != position as u64 {
262 return Err(format!(
263 "item at position {position} has index {}",
264 item.index
265 ));
266 }
267 match (&item.answers, &item.error) {
268 (Some(answers), None) => {
269 let asked: Vec<&String> = request.questions.keys().collect();
270 let got: Vec<&String> = answers.keys().collect();
271 let mut asked_sorted = asked.clone();
272 let mut got_sorted = got.clone();
273 asked_sorted.sort();
274 got_sorted.sort();
275 if asked_sorted != got_sorted {
276 return Err(format!(
277 "item {position} answers {got:?}, the request asked {asked:?}"
278 ));
279 }
280 for (id, answer) in answers {
281 let kind = &request.questions[id].kind;
282 if &answer.kind != kind {
283 return Err(format!(
284 "item {position} answers {id} as {}, asked as {kind}",
285 answer.kind
286 ));
287 }
288 }
289 }
290 (None, Some(_)) => {}
291 (Some(_), Some(_)) => {
292 return Err(format!("item {position} has both answers and error"))
293 }
294 (None, None) => return Err(format!("item {position} has neither answers nor error")),
295 }
296 }
297 Ok(())
298}
299
300#[cfg(test)]
301mod tests {
302 use serde_json::json;
303
304 use super::*;
305 use crate::{errors::ItemError, question::Question, request::Item};
306
307 #[test]
308 fn unreported_usage_stays_absent_in_the_sum() {
309 let items = vec![
310 ItemResult::answered(0, IndexMap::new()),
311 ItemResult::answered(1, IndexMap::new()).with_usage(Usage::new().with_input_tokens(5)),
312 ItemResult::failed(2, ItemError::for_code("invalid_item", "m")),
313 ];
314 let usage = sum_usage(&items);
315 assert_eq!(usage.input_tokens, Some(5));
316 assert_eq!(usage.output_tokens, None);
317 assert!(sum_usage(&items[..1]).is_empty());
318 let reply = ClassifyReply::new("b", "m", items[..1].to_vec());
319 assert_eq!(
320 serde_json::to_string(&reply).unwrap(),
321 r#"{"batch_id":"b","model":"m","items":[{"index":0,"answers":{}}],"usage":{}}"#
322 );
323 }
324
325 #[test]
326 fn answers_keep_integers_order_and_unknown_members() {
327 let text = r#"{"type":"choice","choice":"b","probabilities":{"b":1,"a":0.0},"confidence":0.5,"rationale":"x"}"#;
328 let answer: Answer = serde_json::from_str(text).unwrap();
329 assert_eq!(answer.extra["rationale"], json!("x"));
330 assert_eq!(serde_json::to_string(&answer).unwrap(), text);
331 }
332
333 #[test]
334 fn a_reply_out_of_order_or_with_both_members_is_refused() {
335 let request = ClassifyRequest::new(
336 "b",
337 "m",
338 [("q".to_owned(), Question::noul("i"))].into(),
339 vec![Item::new("a"), Item::new("b")],
340 );
341 let answers: IndexMap<String, Answer> =
342 [("q".to_owned(), Answer::noul(Number::from_f64(0.5).unwrap()))].into();
343 let good = ClassifyReply::new(
344 "b",
345 "m",
346 vec![
347 ItemResult::answered(0, answers.clone()),
348 ItemResult::failed(1, ItemError::for_code("rate_limited", "m")),
349 ],
350 );
351 assert_eq!(check_reply(&good, &request), Ok(()));
352 let mut swapped = good.clone();
353 swapped.items.swap(0, 1);
354 assert!(check_reply(&swapped, &request)
355 .unwrap_err()
356 .contains("index"));
357 let mut both = good;
358 both.items[1].answers = Some(answers);
359 assert!(check_reply(&both, &request).unwrap_err().contains("both"));
360 }
361}