Skip to main content

typesafe/
rubric.rs

1//! Rubrics as types: a struct describes the questions, and the answers come back into it.
2//!
3//! [`Rubric`] is implemented by `#[derive(Rubric)]` (feature `derive`) on a struct with one field
4//! per question. The field name is the question name, the attribute is its type, and the field
5//! type is what the answer decodes into, so a misspelled name or a wrong answer type is a compile
6//! error instead of a `None` at runtime:
7//!
8//! ```
9//! # #[cfg(feature = "derive")] {
10//! use typesafe::{ChoiceOf, NoulAnswer, Rubric, RubricChoice, ScoreAnswer};
11//!
12//! #[derive(Rubric)]
13//! struct Triage {
14//!     #[noul("The message conveys urgency", yes = "A deadline or ASAP", no = "Routine")]
15//!     is_urgent: NoulAnswer,
16//!     #[choice("Which team should handle this")]
17//!     department: ChoiceOf<Department>,
18//!     #[score("How frustrated", levels = ["Calm", "Frustrated but civil", "Very angry"])]
19//!     frustration: ScoreAnswer,
20//! }
21//!
22//! #[derive(Debug, PartialEq, RubricChoice)]
23//! enum Department {
24//!     #[option("Payment or subscription issues")]
25//!     Billing,
26//!     /// Bugs or integration problems
27//!     Technical,
28//! }
29//!
30//! let questions = Triage::questions(); // what `Client::ask::<Triage>` sends
31//! assert_eq!(questions.len(), 3);
32//! # }
33//! ```
34//!
35//! `client.ask::<Triage>(state).await?` sends the questions and returns a `Triage`; see
36//! [`Client::ask`]. Without the derive, the same traits can be implemented by hand, and
37//! `Rubric::from_response` decodes any [`SystemOneResponse`] you already have.
38//!
39//! # Attributes
40//!
41//! On the struct's fields — exactly one of the first three per field:
42//!
43//! | Attribute | Field type | Asks |
44//! | --- | --- | --- |
45//! | `#[noul("…", yes = "…", no = "…")]` | [`NoulAnswer`], or `f64` for the probability | a [`Noul`]; `yes`/`no` are optional |
46//! | `#[choice("…")]` | a `RubricChoice` enum, [`ChoiceOf<E>`], [`ChoiceAnswer`] or `String` | a [`Choice`] |
47//! | `#[score("…", levels = ["…", …])]` | [`ScoreAnswer`], or `f64` for the score | a [`Score`], levels lowest first |
48//! | `#[rubric(rename = "…")]` | | a question name other than the field's |
49//!
50//! The enum types bring their options with them. [`ChoiceAnswer`] and `String` do not, so they
51//! take theirs as `#[choice("…", labels = ["a", "b"])]`. Instructions left out of an attribute are
52//! read from the field's doc comment.
53//!
54//! On an enum's variants, for `#[derive(RubricChoice)]`: the label is the variant name in
55//! snake_case (`NeedsHuman` → `needs_human`) unless `#[rubric(rename = "…")]` says otherwise, and
56//! the description is `#[option("…")]` or else the doc comment; a variant with neither is an
57//! undescribed option. The derive also implements `FromStr`, so
58//! `res.choice("department").unwrap().parse::<Department>()` keeps working.
59//!
60//! # Errors
61//!
62//! A response that does not fit the struct — an answer missing, of another type, or a label the
63//! enum does not have — is an [`Error::ResponseValidation`] whose `field_path` names it
64//! (`answers.department` or `answers.department.choice`).
65
66use std::fmt;
67use std::future::IntoFuture;
68use std::marker::PhantomData;
69use std::ops::Deref;
70use std::time::Duration;
71
72use http::header::{HeaderName, HeaderValue};
73use serde::Serialize;
74use serde_json::Value;
75
76use crate::client::{BoxFuture, Client, SystemOneRequest};
77use crate::error::{Error, ResponseValidationError, Result};
78use crate::question::{Choice, Questions};
79use crate::response::{ChoiceAnswer, NoulAnswer, ScoreAnswer, SystemOneResponse};
80use crate::retry::RetryPolicy;
81
82#[cfg(doc)]
83use crate::question::{Noul, Score};
84
85/// A struct whose fields are questions and whose values are their answers. Derive it with
86/// `#[derive(Rubric)]` (feature `derive`); see the [module docs](self).
87pub trait Rubric: Sized {
88    /// The questions to send, one per field, in field order.
89    fn questions() -> Questions;
90
91    /// Read the answers back out of a response to [`Rubric::questions`].
92    fn from_response(response: &SystemOneResponse) -> Result<Self>;
93}
94
95/// An enum whose variants are the options of a [`Choice`]. Derive it with
96/// `#[derive(RubricChoice)]` (feature `derive`), which also implements [`ChoiceField`] and
97/// `FromStr` for the enum.
98pub trait RubricChoice: Sized {
99    /// Every option as `(label, description)`, in the order they are offered.
100    const OPTIONS: &'static [(&'static str, Option<&'static str>)];
101
102    /// The variant for a label, if there is one.
103    fn from_label(label: &str) -> Option<Self>;
104
105    /// The label this variant is sent and answered as.
106    fn label(&self) -> &'static str;
107
108    /// A [`Choice`] offering every option.
109    fn choice(instructions: impl Into<Value>) -> Choice {
110        Self::OPTIONS
111            .iter()
112            .fold(
113                Choice::new(instructions),
114                |c, (label, description)| match description {
115                    Some(d) => c.option(*label, *d),
116                    None => c.label(*label),
117                },
118            )
119    }
120
121    /// [`RubricChoice::from_label`], with an error that lists the labels there are.
122    fn parse_label(label: &str) -> std::result::Result<Self, UnknownLabel> {
123        Self::from_label(label).ok_or_else(|| UnknownLabel {
124            label: label.to_owned(),
125            expected: Self::OPTIONS.iter().map(|(l, _)| *l).collect(),
126        })
127    }
128}
129
130/// A label the options do not include.
131#[derive(Debug, Clone, PartialEq, Eq)]
132#[non_exhaustive]
133pub struct UnknownLabel {
134    /// The label that came back.
135    pub label: String,
136    /// The labels that were offered.
137    pub expected: Vec<&'static str>,
138}
139
140impl fmt::Display for UnknownLabel {
141    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
142        write!(f, "unknown label {:?}; expected one of ", self.label)?;
143        for (i, l) in self.expected.iter().enumerate() {
144            if i > 0 {
145                f.write_str(", ")?;
146            }
147            write!(f, "{l:?}")?;
148        }
149        Ok(())
150    }
151}
152
153impl std::error::Error for UnknownLabel {}
154
155/// A choice decoded into your enum, with the distribution it was picked from.
156///
157/// Derefs to the enum, so `match *answer { Department::Billing => … }` works.
158#[derive(Debug, Clone, PartialEq)]
159#[non_exhaustive]
160pub struct ChoiceOf<T> {
161    /// The selected option.
162    pub value: T,
163    /// The answer it came from: per-label probabilities and confidence.
164    pub answer: ChoiceAnswer,
165}
166
167impl<T> ChoiceOf<T> {
168    /// Certainty derived from the distribution, 0 to 1.
169    pub fn confidence(&self) -> f64 {
170        self.answer.confidence
171    }
172
173    /// The selected option.
174    pub fn into_inner(self) -> T {
175        self.value
176    }
177}
178
179impl<T: RubricChoice> ChoiceOf<T> {
180    /// The probability the model gave an option.
181    pub fn probability(&self, option: &T) -> Option<f64> {
182        self.answer.probability(option.label())
183    }
184}
185
186impl<T> Deref for ChoiceOf<T> {
187    type Target = T;
188
189    fn deref(&self) -> &T {
190        &self.value
191    }
192}
193
194/// A field type a `#[noul]` answer decodes into.
195pub trait NoulField {
196    /// Convert the answer.
197    fn from_noul(answer: &NoulAnswer) -> Self;
198}
199
200impl NoulField for NoulAnswer {
201    fn from_noul(answer: &NoulAnswer) -> Self {
202        answer.clone()
203    }
204}
205
206/// The probability of "yes".
207impl NoulField for f64 {
208    fn from_noul(answer: &NoulAnswer) -> Self {
209        answer.noul
210    }
211}
212
213/// A field type a `#[score]` answer decodes into.
214pub trait ScoreField {
215    /// Convert the answer.
216    fn from_score(answer: &ScoreAnswer) -> Self;
217}
218
219impl ScoreField for ScoreAnswer {
220    fn from_score(answer: &ScoreAnswer) -> Self {
221        answer.clone()
222    }
223}
224
225/// The probability-weighted level.
226impl ScoreField for f64 {
227    fn from_score(answer: &ScoreAnswer) -> Self {
228        answer.score
229    }
230}
231
232/// A field type a `#[choice]` asks with and decodes into. `#[derive(RubricChoice)]` implements
233/// it for the enum; it is implemented here for [`ChoiceOf`], [`ChoiceAnswer`] and `String`.
234pub trait ChoiceField: Sized {
235    /// The question, with whatever options the type knows (none, for a plain label).
236    fn question(instructions: Value) -> Choice;
237
238    /// Convert the answer.
239    fn from_choice(answer: &ChoiceAnswer) -> std::result::Result<Self, UnknownLabel>;
240}
241
242impl<T: RubricChoice> ChoiceField for ChoiceOf<T> {
243    fn question(instructions: Value) -> Choice {
244        T::choice(instructions)
245    }
246
247    fn from_choice(answer: &ChoiceAnswer) -> std::result::Result<Self, UnknownLabel> {
248        Ok(ChoiceOf {
249            value: T::parse_label(&answer.choice)?,
250            answer: answer.clone(),
251        })
252    }
253}
254
255impl ChoiceField for ChoiceAnswer {
256    fn question(instructions: Value) -> Choice {
257        Choice::new(instructions)
258    }
259
260    fn from_choice(answer: &ChoiceAnswer) -> std::result::Result<Self, UnknownLabel> {
261        Ok(answer.clone())
262    }
263}
264
265/// The selected label.
266impl ChoiceField for String {
267    fn question(instructions: Value) -> Choice {
268        Choice::new(instructions)
269    }
270
271    fn from_choice(answer: &ChoiceAnswer) -> std::result::Result<Self, UnknownLabel> {
272        Ok(answer.choice.clone())
273    }
274}
275
276/// What the derived code calls. Not part of the API.
277#[doc(hidden)]
278pub mod __private {
279    use super::*;
280    use crate::response::Answer;
281
282    fn mismatch(response: &SystemOneResponse, field_path: String, detail: String) -> Error {
283        Error::ResponseValidation(Box::new(ResponseValidationError {
284            status: response.meta.status,
285            field_path,
286            detail,
287            body: Some(response.raw.clone()),
288            headers: response.meta.headers.clone(),
289            endpoint: None,
290        }))
291    }
292
293    fn answer<'r>(response: &'r SystemOneResponse, name: &str, kind: &str) -> Result<&'r Answer> {
294        let answer = response.answers.get(name).ok_or_else(|| {
295            let detail = if response.raw["answers"].get(name).is_some() {
296                format!("the answer is of a type this SDK does not know; expected a {kind}")
297            } else {
298                format!("no answer; expected a {kind}")
299            };
300            mismatch(response, format!("answers.{name}"), detail)
301        })?;
302        if answer.kind() != kind {
303            return Err(mismatch(
304                response,
305                format!("answers.{name}"),
306                format!("expected a {kind} answer, got a {}", answer.kind()),
307            ));
308        }
309        Ok(answer)
310    }
311
312    pub fn noul<'r>(response: &'r SystemOneResponse, name: &str) -> Result<&'r NoulAnswer> {
313        match answer(response, name, "noul")? {
314            Answer::Noul(a) => Ok(a),
315            _ => unreachable!("kind checked"),
316        }
317    }
318
319    pub fn score<'r>(response: &'r SystemOneResponse, name: &str) -> Result<&'r ScoreAnswer> {
320        match answer(response, name, "score")? {
321            Answer::Score(a) => Ok(a),
322            _ => unreachable!("kind checked"),
323        }
324    }
325
326    pub fn choice<T: ChoiceField>(response: &SystemOneResponse, name: &str) -> Result<T> {
327        let Answer::Choice(a) = answer(response, name, "choice")? else {
328            unreachable!("kind checked")
329        };
330        T::from_choice(a)
331            .map_err(|e| mismatch(response, format!("answers.{name}.choice"), e.to_string()))
332    }
333}
334
335impl Client {
336    /// Ask the questions of a [`Rubric`] about `state` and decode the answers into it.
337    ///
338    /// ```no_run
339    /// # #[cfg(feature = "derive")] {
340    /// use typesafe::{Client, NoulAnswer, Rubric};
341    ///
342    /// #[derive(Rubric)]
343    /// struct Urgency {
344    ///     #[noul("The message conveys urgency")]
345    ///     is_urgent: NoulAnswer,
346    /// }
347    ///
348    /// # async fn run() -> typesafe::Result<()> {
349    /// let client = Client::from_env()?;
350    /// let answer: Urgency = client.ask("The payout failed again.").await?;
351    /// println!("{}", answer.is_urgent.is_yes(0.8));
352    /// # Ok(()) }
353    /// # }
354    /// ```
355    pub fn ask<R: Rubric>(&self, state: impl Serialize) -> AskRequest<R> {
356        AskRequest {
357            req: self.system_one(state, R::questions()),
358            rubric: PhantomData,
359        }
360    }
361}
362
363/// A pending [`Client::ask`]: the same per-call options as a [`SystemOneRequest`], and `.await`
364/// gives the rubric instead of the response.
365#[must_use = "requests do nothing until awaited"]
366#[derive(Debug)]
367pub struct AskRequest<R> {
368    req: SystemOneRequest,
369    rubric: PhantomData<fn() -> R>,
370}
371
372impl<R: Rubric> AskRequest<R> {
373    /// Override the retry policy for this call.
374    pub fn retry(mut self, policy: RetryPolicy) -> Self {
375        self.req = self.req.retry(policy);
376        self
377    }
378
379    /// Override the per-attempt timeout for this call.
380    pub fn timeout(mut self, timeout: Duration) -> Self {
381        self.req = self.req.timeout(timeout);
382        self
383    }
384
385    /// Add a header for this call (protected headers still win).
386    pub fn header(mut self, name: HeaderName, value: HeaderValue) -> Self {
387        self.req = self.req.header(name, value);
388        self
389    }
390
391    /// Override the model for this call.
392    pub fn model(mut self, model: impl Into<String>) -> Self {
393        self.req = self.req.model(model);
394        self
395    }
396
397    /// Add a top-level body field; see [`SystemOneRequest::extra_body`].
398    pub fn extra_body(mut self, key: impl Into<String>, value: impl Into<Value>) -> Self {
399        self.req = self.req.extra_body(key, value);
400        self
401    }
402
403    /// Send the request and decode the answers.
404    pub async fn send(self) -> Result<R> {
405        R::from_response(&self.req.send().await?)
406    }
407}
408
409impl<R: Rubric + 'static> IntoFuture for AskRequest<R> {
410    type Output = Result<R>;
411    type IntoFuture = BoxFuture<'static, Self::Output>;
412
413    fn into_future(self) -> Self::IntoFuture {
414        Box::pin(self.send())
415    }
416}
417
418/// Mistakes the derive refuses to compile, each checked as a `compile_fail` doctest.
419///
420/// An answer read as the wrong type:
421/// ```compile_fail
422/// #[derive(typesafe::Rubric)]
423/// struct R {
424///     #[noul("Urgent?")]
425///     is_urgent: typesafe::ScoreAnswer,
426/// }
427/// ```
428/// A choice whose type has no options to offer:
429/// ```compile_fail
430/// #[derive(typesafe::Rubric)]
431/// struct R {
432///     #[choice("Which team")]
433///     team: u32,
434/// }
435/// ```
436/// A score without levels:
437/// ```compile_fail
438/// #[derive(typesafe::Rubric)]
439/// struct R {
440///     #[score("How angry")]
441///     anger: typesafe::ScoreAnswer,
442/// }
443/// ```
444/// A score with one level, or with more than the API's ten:
445/// ```compile_fail
446/// #[derive(typesafe::Rubric)]
447/// struct R {
448///     #[score("How angry", levels = ["Furious"])]
449///     anger: typesafe::ScoreAnswer,
450/// }
451/// ```
452/// ```compile_fail
453/// #[derive(typesafe::Rubric)]
454/// struct R {
455///     #[score("How angry", levels = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10"])]
456///     anger: typesafe::ScoreAnswer,
457/// }
458/// ```
459/// A field that is not a question:
460/// ```compile_fail
461/// #[derive(typesafe::Rubric)]
462/// struct R {
463///     #[noul("Urgent?")]
464///     is_urgent: typesafe::NoulAnswer,
465///     note: String,
466/// }
467/// ```
468/// Two fields asking under one name:
469/// ```compile_fail
470/// #[derive(typesafe::Rubric)]
471/// struct R {
472///     #[noul("Urgent?")]
473///     is_urgent: typesafe::NoulAnswer,
474///     #[noul("Really urgent?")]
475///     #[rubric(rename = "is_urgent")]
476///     very: typesafe::NoulAnswer,
477/// }
478/// ```
479/// A key the attribute does not take:
480/// ```compile_fail
481/// #[derive(typesafe::Rubric)]
482/// struct R {
483///     #[noul("Urgent?", levels = ["a", "b"])]
484///     is_urgent: typesafe::NoulAnswer,
485/// }
486/// ```
487/// An option that carries data:
488/// ```compile_fail
489/// #[derive(typesafe::RubricChoice)]
490/// enum Team {
491///     Billing,
492///     Other(String),
493/// }
494/// ```
495/// And the same shapes, spelled correctly, compile:
496/// ```
497/// #[derive(typesafe::Rubric)]
498/// struct R {
499///     #[noul("Urgent?", yes = "A deadline")]
500///     is_urgent: typesafe::NoulAnswer,
501///     #[choice("Which team")]
502///     team: Team,
503///     #[score("How angry", levels = ["Calm", "Angry"])]
504///     anger: typesafe::ScoreAnswer,
505///     #[score("How bad", levels = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"])]
506///     severity: typesafe::ScoreAnswer,
507/// }
508/// #[derive(typesafe::RubricChoice)]
509/// enum Team {
510///     Billing,
511///     Other,
512/// }
513/// ```
514#[cfg(all(doctest, feature = "derive"))]
515pub struct DeriveCompileFail;