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    pub fn ranked(&self) -> Vec<(&str, f64)> {
28        let mut ranked: Vec<(&str, f64)> = self.probabilities.iter().map(|(k, v)| (k.as_str(), *v)).collect();
29        ranked.sort_by(|a, b| b.1.total_cmp(&a.1));
30        ranked
31    }
32}
33
34/// Answer to a [`Question::Score`](crate::Question::Score).
35#[derive(Clone, Debug, PartialEq, Deserialize)]
36pub struct ScoreAnswer {
37    /// Probability-weighted position across the levels; may fall between levels.
38    pub score: f64,
39    /// Level index (as a string key) → level description.
40    pub legend: HashMap<String, String>,
41    /// Level index (as a string key) → probability; sums to 1.
42    pub probabilities: HashMap<String, f64>,
43    /// Certainty derived from the shape of `probabilities`, from 0 to 1.
44    pub confidence: f64,
45}
46
47/// One answer, tagged with the type of the question that produced it.
48#[derive(Clone, Debug, PartialEq, Deserialize)]
49#[serde(tag = "type", rename_all = "lowercase")]
50#[non_exhaustive]
51pub enum Answer {
52    Noul(NoulAnswer),
53    Choice(ChoiceAnswer),
54    Score(ScoreAnswer),
55}
56
57impl Answer {
58    /// The yes-probability, if this answers a Noul question.
59    pub fn as_noul(&self) -> Option<f64> {
60        match self {
61            Self::Noul(a) => Some(a.noul),
62            _ => None,
63        }
64    }
65
66    /// The choice answer, if this answers a Choice question.
67    pub fn as_choice(&self) -> Option<&ChoiceAnswer> {
68        match self {
69            Self::Choice(a) => Some(a),
70            _ => None,
71        }
72    }
73
74    /// The score answer, if this answers a Score question.
75    pub fn as_score(&self) -> Option<&ScoreAnswer> {
76        match self {
77            Self::Score(a) => Some(a),
78            _ => None,
79        }
80    }
81}
82
83/// Token usage for one request. Output tokens are not billed.
84#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize)]
85pub struct Usage {
86    pub input_tokens: u64,
87    pub output_tokens: u64,
88}
89
90/// Response from `POST /v1/systemone`.
91#[derive(Clone, Debug, PartialEq, Deserialize)]
92pub struct SystemOneResponse {
93    /// The versioned model id that answered, e.g. `jev-1.13.0`.
94    pub model: String,
95    /// One answer per question, under the same keys.
96    pub answers: HashMap<String, Answer>,
97    pub usage: Usage,
98}
99
100impl SystemOneResponse {
101    /// The yes-probability of the Noul question `id`.
102    pub fn noul(&self, id: &str) -> Result<f64> {
103        self.answers
104            .get(id)
105            .and_then(Answer::as_noul)
106            .ok_or_else(|| missing(id, "noul"))
107    }
108
109    /// The answer to the Choice question `id`.
110    pub fn choice(&self, id: &str) -> Result<&ChoiceAnswer> {
111        self.answers
112            .get(id)
113            .and_then(Answer::as_choice)
114            .ok_or_else(|| missing(id, "choice"))
115    }
116
117    /// The answer to the Score question `id`.
118    pub fn score(&self, id: &str) -> Result<&ScoreAnswer> {
119        self.answers
120            .get(id)
121            .and_then(Answer::as_score)
122            .ok_or_else(|| missing(id, "score"))
123    }
124}
125
126fn missing(id: &str, expected: &'static str) -> Error {
127    Error::MissingAnswer {
128        id: id.to_string(),
129        expected,
130    }
131}
132
133/// One entry from `GET /v1/models`.
134#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
135pub struct ModelInfo {
136    /// Model id or alias accepted by the `model` field.
137    pub name: String,
138    pub description: String,
139    pub release_date: String,
140}
141
142#[derive(Deserialize)]
143pub(crate) struct ModelsResponse {
144    pub(crate) models: Vec<ModelInfo>,
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150    use serde_json::json;
151
152    #[test]
153    fn parses_all_three_answer_types() {
154        let body = json!({
155            "model": "jev-1.13.0",
156            "answers": {
157                "is_urgent": {"type": "noul", "noul": 0.92},
158                "department": {"type": "choice", "choice": "technical",
159                                "probabilities": {"billing": 0.08, "technical": 0.85, "sales": 0.07}, "confidence": 0.82},
160                "frustration": {"type": "score", "score": 1.6,
161                                 "legend": {"0": "Calm", "1": "Frustrated", "2": "Very angry"},
162                                 "probabilities": {"0": 0.05, "1": 0.3, "2": 0.65}, "confidence": 0.78}
163            },
164            "usage": {"input_tokens": 312, "output_tokens": 48}
165        });
166        let resp: SystemOneResponse = serde_json::from_value(body).unwrap();
167        assert_eq!(resp.answers["is_urgent"].as_noul(), Some(0.92));
168        let dept = resp.answers["department"].as_choice().unwrap();
169        assert_eq!(dept.choice, "technical");
170        assert_eq!(dept.ranked()[0], ("technical", 0.85));
171        let score = resp.answers["frustration"].as_score().unwrap();
172        assert_eq!(score.legend["2"], "Very angry");
173        assert_eq!(resp.usage.input_tokens, 312);
174        assert!(resp.answers["is_urgent"].as_choice().is_none());
175        assert_eq!(resp.noul("is_urgent").unwrap(), 0.92);
176        assert_eq!(resp.choice("department").unwrap().choice, "technical");
177        assert!(matches!(
178            resp.choice("is_urgent"),
179            Err(Error::MissingAnswer { expected: "choice", .. })
180        ));
181        assert!(matches!(resp.score("nope"), Err(Error::MissingAnswer { .. })));
182    }
183}