Skip to main content

typesafe_systemone/
answer.rs

1use std::collections::HashMap;
2
3use serde::Deserialize;
4
5use crate::error::{Error, Result};
6
7/// Answer to a [`Question::Noul`](crate::Question::Noul).
8#[derive(Clone, Debug, PartialEq, Deserialize)]
9pub struct NoulAnswer {
10    /// Probability of "yes", from 0 to 1.
11    pub noul: f64,
12}
13
14/// Answer to a [`Question::Choice`](crate::Question::Choice).
15#[derive(Clone, Debug, PartialEq, Deserialize)]
16pub struct ChoiceAnswer {
17    /// The highest-probability option.
18    pub choice: String,
19    /// Every option mapped to its probability; sums to 1.
20    pub probabilities: HashMap<String, f64>,
21    /// Certainty derived from the shape of `probabilities`, from 0 to 1.
22    pub confidence: f64,
23}
24
25impl ChoiceAnswer {
26    /// Options ordered by descending probability.
27    #[must_use]
28    pub fn ranked(&self) -> Vec<(&str, f64)> {
29        let mut ranked: Vec<(&str, f64)> = self.probabilities.iter().map(|(k, v)| (k.as_str(), *v)).collect();
30        ranked.sort_by(|a, b| b.1.total_cmp(&a.1));
31        ranked
32    }
33}
34
35/// Answer to a [`Question::Score`](crate::Question::Score).
36#[derive(Clone, Debug, PartialEq, Deserialize)]
37pub struct ScoreAnswer {
38    /// Probability-weighted position across the levels; may fall between levels.
39    pub score: f64,
40    /// Level index (as a string key) → level description.
41    pub legend: HashMap<String, String>,
42    /// Level index (as a string key) → probability; sums to 1.
43    pub probabilities: HashMap<String, f64>,
44    /// Certainty derived from the shape of `probabilities`, from 0 to 1.
45    pub confidence: f64,
46}
47
48/// One answer, tagged with the type of the question that produced it.
49#[derive(Clone, Debug, PartialEq, Deserialize)]
50#[serde(tag = "type", rename_all = "lowercase")]
51#[non_exhaustive]
52pub enum Answer {
53    /// Answer to a Noul question.
54    Noul(NoulAnswer),
55    /// Answer to a Choice question.
56    Choice(ChoiceAnswer),
57    /// Answer to a Score question.
58    Score(ScoreAnswer),
59}
60
61impl Answer {
62    /// The yes-probability, if this answers a Noul question.
63    #[must_use]
64    pub const fn as_noul(&self) -> Option<f64> {
65        match self {
66            Self::Noul(a) => Some(a.noul),
67            _ => None,
68        }
69    }
70
71    /// The choice answer, if this answers a Choice question.
72    #[must_use]
73    pub const fn as_choice(&self) -> Option<&ChoiceAnswer> {
74        match self {
75            Self::Choice(a) => Some(a),
76            _ => None,
77        }
78    }
79
80    /// The score answer, if this answers a Score question.
81    #[must_use]
82    pub const fn as_score(&self) -> Option<&ScoreAnswer> {
83        match self {
84            Self::Score(a) => Some(a),
85            _ => None,
86        }
87    }
88}
89
90/// Token usage for one request. Output tokens are not billed.
91#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize)]
92pub struct Usage {
93    /// Tokens in `state` plus all questions. The billed quantity.
94    pub input_tokens: u64,
95    /// Tokens in the answers. Not billed.
96    pub output_tokens: u64,
97}
98
99/// Response from `POST /v1/systemone`.
100#[derive(Clone, Debug, PartialEq, Deserialize)]
101pub struct SystemOneResponse {
102    /// The versioned model id that answered, e.g. `jev-1.13.0`.
103    pub model: String,
104    /// One answer per question, under the same keys.
105    pub answers: HashMap<String, Answer>,
106    /// Token accounting for this request.
107    pub usage: Usage,
108}
109
110impl SystemOneResponse {
111    /// The yes-probability of the Noul question `id`.
112    ///
113    /// # Errors
114    ///
115    /// [`Error::MissingAnswer`] if there is no answer under `id` or it is not a Noul.
116    pub fn noul(&self, id: &str) -> Result<f64> {
117        self.answers
118            .get(id)
119            .and_then(Answer::as_noul)
120            .ok_or_else(|| missing(id, "noul"))
121    }
122
123    /// The answer to the Choice question `id`.
124    ///
125    /// # Errors
126    ///
127    /// [`Error::MissingAnswer`] if there is no answer under `id` or it is not a Choice.
128    pub fn choice(&self, id: &str) -> Result<&ChoiceAnswer> {
129        self.answers
130            .get(id)
131            .and_then(Answer::as_choice)
132            .ok_or_else(|| missing(id, "choice"))
133    }
134
135    /// The answer to the Score question `id`.
136    ///
137    /// # Errors
138    ///
139    /// [`Error::MissingAnswer`] if there is no answer under `id` or it is not a Score.
140    pub fn score(&self, id: &str) -> Result<&ScoreAnswer> {
141        self.answers
142            .get(id)
143            .and_then(Answer::as_score)
144            .ok_or_else(|| missing(id, "score"))
145    }
146}
147
148fn missing(id: &str, expected: &'static str) -> Error {
149    Error::MissingAnswer {
150        id: id.to_string(),
151        expected,
152    }
153}
154
155/// One entry from `GET /v1/models`.
156#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
157pub struct ModelInfo {
158    /// Model id or alias accepted by the `model` field.
159    pub name: String,
160    /// What the model is for.
161    pub description: String,
162    /// When the model or alias was released, as the API reports it.
163    pub release_date: String,
164}
165
166#[derive(Deserialize)]
167pub(crate) struct ModelsResponse {
168    pub(crate) models: Vec<ModelInfo>,
169}
170
171#[cfg(test)]
172#[allow(
173    clippy::float_cmp,
174    reason = "values are parsed verbatim from JSON literals, exact comparison is intended"
175)]
176mod tests {
177    use super::*;
178    use serde_json::json;
179
180    #[test]
181    fn parses_all_three_answer_types() {
182        let body = json!({
183            "model": "jev-1.13.0",
184            "answers": {
185                "is_urgent": {"type": "noul", "noul": 0.92},
186                "department": {"type": "choice", "choice": "technical",
187                                "probabilities": {"billing": 0.08, "technical": 0.85, "sales": 0.07}, "confidence": 0.82},
188                "frustration": {"type": "score", "score": 1.6,
189                                 "legend": {"0": "Calm", "1": "Frustrated", "2": "Very angry"},
190                                 "probabilities": {"0": 0.05, "1": 0.3, "2": 0.65}, "confidence": 0.78}
191            },
192            "usage": {"input_tokens": 312, "output_tokens": 48}
193        });
194        let resp: SystemOneResponse = serde_json::from_value(body).unwrap();
195        assert_eq!(resp.answers["is_urgent"].as_noul(), Some(0.92));
196        let dept = resp.answers["department"].as_choice().unwrap();
197        assert_eq!(dept.choice, "technical");
198        assert_eq!(dept.ranked()[0], ("technical", 0.85));
199        let score = resp.answers["frustration"].as_score().unwrap();
200        assert_eq!(score.legend["2"], "Very angry");
201        assert_eq!(resp.usage.input_tokens, 312);
202        assert!(resp.answers["is_urgent"].as_choice().is_none());
203        assert_eq!(resp.noul("is_urgent").unwrap(), 0.92);
204        assert_eq!(resp.choice("department").unwrap().choice, "technical");
205        assert!(matches!(
206            resp.choice("is_urgent"),
207            Err(Error::MissingAnswer { expected: "choice", .. })
208        ));
209        assert!(matches!(resp.score("nope"), Err(Error::MissingAnswer { .. })));
210    }
211}