use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use serde_json::{Number, Value};
use crate::{errors::ItemError, question::QuestionType, request::ClassifyRequest};
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
#[non_exhaustive]
pub struct Answer {
#[serde(rename = "type")]
pub kind: QuestionType,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub noul: Option<Number>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub choice: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub score: Option<Number>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub legend: Option<IndexMap<String, Value>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub probabilities: Option<IndexMap<String, Number>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub confidence: Option<Number>,
#[serde(flatten)]
pub extra: IndexMap<String, Value>,
}
impl Answer {
fn empty(kind: QuestionType) -> Self {
Self {
kind,
noul: None,
choice: None,
score: None,
legend: None,
probabilities: None,
confidence: None,
extra: IndexMap::new(),
}
}
pub fn noul(noul: Number) -> Self {
Self {
noul: Some(noul),
..Self::empty(QuestionType::Noul)
}
}
pub fn choice(
choice: impl Into<String>,
probabilities: IndexMap<String, Number>,
confidence: Number,
) -> Self {
Self {
choice: Some(choice.into()),
probabilities: Some(probabilities),
confidence: Some(confidence),
..Self::empty(QuestionType::Choice)
}
}
pub fn score(
score: Number,
legend: IndexMap<String, Value>,
probabilities: IndexMap<String, Number>,
confidence: Number,
) -> Self {
Self {
score: Some(score),
legend: Some(legend),
probabilities: Some(probabilities),
confidence: Some(confidence),
..Self::empty(QuestionType::Score)
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
#[non_exhaustive]
pub struct Usage {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input_tokens: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_tokens: Option<u64>,
}
impl Usage {
pub fn new() -> Self {
Self::default()
}
pub fn with_input_tokens(mut self, tokens: u64) -> Self {
self.input_tokens = Some(tokens);
self
}
pub fn with_output_tokens(mut self, tokens: u64) -> Self {
self.output_tokens = Some(tokens);
self
}
pub fn is_empty(&self) -> bool {
self.input_tokens.is_none() && self.output_tokens.is_none()
}
}
pub fn sum_usage<'a>(items: impl IntoIterator<Item = &'a ItemResult>) -> Usage {
let add = |total: Option<u64>, count: Option<u64>| match (total, count) {
(Some(total), Some(count)) => Some(total + count),
(total, None) => total,
(None, count) => count,
};
items
.into_iter()
.filter_map(|item| item.usage)
.fold(Usage::default(), |total, usage| Usage {
input_tokens: add(total.input_tokens, usage.input_tokens),
output_tokens: add(total.output_tokens, usage.output_tokens),
})
}
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
#[non_exhaustive]
pub struct ItemResult {
pub index: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub answers: Option<IndexMap<String, Answer>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<ItemError>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub usage: Option<Usage>,
}
impl ItemResult {
pub fn answered(index: u64, answers: IndexMap<String, Answer>) -> Self {
Self {
index,
answers: Some(answers),
error: None,
usage: None,
}
}
pub fn failed(index: u64, error: ItemError) -> Self {
Self {
index,
answers: None,
error: Some(error),
usage: None,
}
}
pub fn with_usage(mut self, usage: Usage) -> Self {
self.usage = Some(usage);
self
}
}
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
#[non_exhaustive]
pub struct ClassifyReply {
pub batch_id: String,
pub model: String,
pub items: Vec<ItemResult>,
#[serde(default)]
pub usage: Usage,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cost_usd: Option<Number>,
}
impl ClassifyReply {
pub fn new(
batch_id: impl Into<String>,
model: impl Into<String>,
items: Vec<ItemResult>,
) -> Self {
let usage = sum_usage(&items);
Self {
batch_id: batch_id.into(),
model: model.into(),
items,
usage,
cost_usd: None,
}
}
pub fn with_usage(mut self, usage: Usage) -> Self {
self.usage = usage;
self
}
pub fn with_cost_usd(mut self, cost_usd: Number) -> Self {
self.cost_usd = Some(cost_usd);
self
}
}
pub fn check_reply(reply: &ClassifyReply, request: &ClassifyRequest) -> Result<(), String> {
if reply.batch_id != request.batch_id {
return Err(format!(
"batch_id {} answers request {}",
reply.batch_id, request.batch_id
));
}
if reply.model != request.model {
return Err(format!(
"model {} answers a request for {}",
reply.model, request.model
));
}
if reply.items.len() != request.items.len() {
return Err(format!(
"{} items answer {} request items",
reply.items.len(),
request.items.len()
));
}
for (position, item) in reply.items.iter().enumerate() {
if item.index != position as u64 {
return Err(format!(
"item at position {position} has index {}",
item.index
));
}
match (&item.answers, &item.error) {
(Some(answers), None) => {
let asked: Vec<&String> = request.questions.keys().collect();
let got: Vec<&String> = answers.keys().collect();
let mut asked_sorted = asked.clone();
let mut got_sorted = got.clone();
asked_sorted.sort();
got_sorted.sort();
if asked_sorted != got_sorted {
return Err(format!(
"item {position} answers {got:?}, the request asked {asked:?}"
));
}
for (id, answer) in answers {
let kind = &request.questions[id].kind;
if &answer.kind != kind {
return Err(format!(
"item {position} answers {id} as {}, asked as {kind}",
answer.kind
));
}
}
}
(None, Some(_)) => {}
(Some(_), Some(_)) => {
return Err(format!("item {position} has both answers and error"))
}
(None, None) => return Err(format!("item {position} has neither answers nor error")),
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
use crate::{errors::ItemError, question::Question, request::Item};
#[test]
fn unreported_usage_stays_absent_in_the_sum() {
let items = vec![
ItemResult::answered(0, IndexMap::new()),
ItemResult::answered(1, IndexMap::new()).with_usage(Usage::new().with_input_tokens(5)),
ItemResult::failed(2, ItemError::for_code("invalid_item", "m")),
];
let usage = sum_usage(&items);
assert_eq!(usage.input_tokens, Some(5));
assert_eq!(usage.output_tokens, None);
assert!(sum_usage(&items[..1]).is_empty());
let reply = ClassifyReply::new("b", "m", items[..1].to_vec());
assert_eq!(
serde_json::to_string(&reply).unwrap(),
r#"{"batch_id":"b","model":"m","items":[{"index":0,"answers":{}}],"usage":{}}"#
);
}
#[test]
fn answers_keep_integers_order_and_unknown_members() {
let text = r#"{"type":"choice","choice":"b","probabilities":{"b":1,"a":0.0},"confidence":0.5,"rationale":"x"}"#;
let answer: Answer = serde_json::from_str(text).unwrap();
assert_eq!(answer.extra["rationale"], json!("x"));
assert_eq!(serde_json::to_string(&answer).unwrap(), text);
}
#[test]
fn a_reply_out_of_order_or_with_both_members_is_refused() {
let request = ClassifyRequest::new(
"b",
"m",
[("q".to_owned(), Question::noul("i"))].into(),
vec![Item::new("a"), Item::new("b")],
);
let answers: IndexMap<String, Answer> =
[("q".to_owned(), Answer::noul(Number::from_f64(0.5).unwrap()))].into();
let good = ClassifyReply::new(
"b",
"m",
vec![
ItemResult::answered(0, answers.clone()),
ItemResult::failed(1, ItemError::for_code("rate_limited", "m")),
],
);
assert_eq!(check_reply(&good, &request), Ok(()));
let mut swapped = good.clone();
swapped.items.swap(0, 1);
assert!(check_reply(&swapped, &request)
.unwrap_err()
.contains("index"));
let mut both = good;
both.items[1].answers = Some(answers);
assert!(check_reply(&both, &request).unwrap_err().contains("both"));
}
}