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    ///
93    /// # Errors
94    ///
95    /// [`Error::ResponseValidation`], with `field_path` naming the question, if an answer is
96    /// missing, of another type, or a label the field's type does not have.
97    fn from_response(response: &SystemOneResponse) -> Result<Self>;
98}
99
100/// An enum whose variants are the options of a [`Choice`]. Derive it with
101/// `#[derive(RubricChoice)]` (feature `derive`), which also implements [`ChoiceField`] and
102/// `FromStr` for the enum.
103pub trait RubricChoice: Sized {
104    /// Every option as `(label, description)`, in the order they are offered.
105    const OPTIONS: &'static [(&'static str, Option<&'static str>)];
106
107    /// The variant for a label, if there is one.
108    fn from_label(label: &str) -> Option<Self>;
109
110    /// The label this variant is sent and answered as.
111    fn label(&self) -> &'static str;
112
113    /// A [`Choice`] offering every option.
114    fn choice(instructions: impl Into<Value>) -> Choice {
115        Self::OPTIONS
116            .iter()
117            .fold(
118                Choice::new(instructions),
119                |c, (label, description)| match description {
120                    Some(d) => c.option(*label, *d),
121                    None => c.label(*label),
122                },
123            )
124    }
125
126    /// [`RubricChoice::from_label`], with an error that lists the labels there are.
127    ///
128    /// # Errors
129    ///
130    /// [`UnknownLabel`] if no option has this label.
131    fn parse_label(label: &str) -> std::result::Result<Self, UnknownLabel> {
132        Self::from_label(label).ok_or_else(|| UnknownLabel {
133            label: label.to_owned(),
134            expected: Self::OPTIONS.iter().map(|(l, _)| *l).collect(),
135        })
136    }
137}
138
139/// A label the options do not include.
140#[derive(Debug, Clone, PartialEq, Eq)]
141#[non_exhaustive]
142pub struct UnknownLabel {
143    /// The label that came back.
144    pub label: String,
145    /// The labels that were offered.
146    pub expected: Vec<&'static str>,
147}
148
149impl fmt::Display for UnknownLabel {
150    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
151        write!(f, "unknown label {:?}; expected one of ", self.label)?;
152        for (i, l) in self.expected.iter().enumerate() {
153            if i > 0 {
154                f.write_str(", ")?;
155            }
156            write!(f, "{l:?}")?;
157        }
158        Ok(())
159    }
160}
161
162impl std::error::Error for UnknownLabel {}
163
164/// A choice decoded into your enum, with the distribution it was picked from.
165///
166/// Derefs to the enum, so `match *answer { Department::Billing => … }` works.
167#[derive(Debug, Clone, PartialEq)]
168#[non_exhaustive]
169pub struct ChoiceOf<T> {
170    /// The selected option.
171    pub value: T,
172    /// The answer it came from: per-label probabilities and confidence.
173    pub answer: ChoiceAnswer,
174}
175
176impl<T> ChoiceOf<T> {
177    /// Certainty derived from the distribution, 0 to 1.
178    pub fn confidence(&self) -> f64 {
179        self.answer.confidence
180    }
181
182    /// The selected option.
183    pub fn into_inner(self) -> T {
184        self.value
185    }
186}
187
188impl<T: RubricChoice> ChoiceOf<T> {
189    /// The probability the model gave an option.
190    pub fn probability(&self, option: &T) -> Option<f64> {
191        self.answer.probability(option.label())
192    }
193}
194
195impl<T> Deref for ChoiceOf<T> {
196    type Target = T;
197
198    fn deref(&self) -> &T {
199        &self.value
200    }
201}
202
203/// A field type a `#[noul]` answer decodes into.
204pub trait NoulField {
205    /// Convert the answer.
206    fn from_noul(answer: &NoulAnswer) -> Self;
207}
208
209impl NoulField for NoulAnswer {
210    fn from_noul(answer: &NoulAnswer) -> Self {
211        answer.clone()
212    }
213}
214
215/// The probability of "yes".
216impl NoulField for f64 {
217    fn from_noul(answer: &NoulAnswer) -> Self {
218        answer.noul
219    }
220}
221
222/// A field type a `#[score]` answer decodes into.
223pub trait ScoreField {
224    /// Convert the answer.
225    fn from_score(answer: &ScoreAnswer) -> Self;
226}
227
228impl ScoreField for ScoreAnswer {
229    fn from_score(answer: &ScoreAnswer) -> Self {
230        answer.clone()
231    }
232}
233
234/// The probability-weighted level.
235impl ScoreField for f64 {
236    fn from_score(answer: &ScoreAnswer) -> Self {
237        answer.score
238    }
239}
240
241/// A field type a `#[choice]` asks with and decodes into. `#[derive(RubricChoice)]` implements
242/// it for the enum; it is implemented here for [`ChoiceOf`], [`ChoiceAnswer`] and `String`.
243pub trait ChoiceField: Sized {
244    /// The question, with whatever options the type knows (none, for a plain label).
245    fn question(instructions: Value) -> Choice;
246
247    /// Convert the answer.
248    ///
249    /// # Errors
250    ///
251    /// [`UnknownLabel`] if the chosen label is not one the type has.
252    fn from_choice(answer: &ChoiceAnswer) -> std::result::Result<Self, UnknownLabel>;
253}
254
255impl<T: RubricChoice> ChoiceField for ChoiceOf<T> {
256    fn question(instructions: Value) -> Choice {
257        T::choice(instructions)
258    }
259
260    fn from_choice(answer: &ChoiceAnswer) -> std::result::Result<Self, UnknownLabel> {
261        Ok(ChoiceOf {
262            value: T::parse_label(&answer.choice)?,
263            answer: answer.clone(),
264        })
265    }
266}
267
268impl ChoiceField for ChoiceAnswer {
269    fn question(instructions: Value) -> Choice {
270        Choice::new(instructions)
271    }
272
273    fn from_choice(answer: &ChoiceAnswer) -> std::result::Result<Self, UnknownLabel> {
274        Ok(answer.clone())
275    }
276}
277
278/// The selected label.
279impl ChoiceField for String {
280    fn question(instructions: Value) -> Choice {
281        Choice::new(instructions)
282    }
283
284    fn from_choice(answer: &ChoiceAnswer) -> std::result::Result<Self, UnknownLabel> {
285        Ok(answer.choice.clone())
286    }
287}
288
289/// What the derived code calls. Not part of the API.
290#[doc(hidden)]
291pub mod __private {
292    use super::*;
293    use crate::response::{Answer, AnswerKind};
294
295    fn mismatch(response: &SystemOneResponse, field_path: String, detail: String) -> Error {
296        Error::ResponseValidation(Box::new(ResponseValidationError {
297            status: response.meta.status,
298            field_path,
299            detail,
300            body: Some(response.raw.clone()),
301            headers: response.meta.headers.clone(),
302            endpoint: None,
303        }))
304    }
305
306    fn answer<'r>(
307        response: &'r SystemOneResponse,
308        name: &str,
309        kind: AnswerKind,
310    ) -> Result<&'r Answer> {
311        let answer = response.answers.get(name).ok_or_else(|| {
312            let detail = if response.raw["answers"].get(name).is_some() {
313                format!("the answer is of a type this SDK does not know; expected a {kind}")
314            } else {
315                format!("no answer; expected a {kind}")
316            };
317            mismatch(response, format!("answers.{name}"), detail)
318        })?;
319        if answer.kind() != kind {
320            return Err(mismatch(
321                response,
322                format!("answers.{name}"),
323                format!("expected a {kind} answer, got a {}", answer.kind()),
324            ));
325        }
326        Ok(answer)
327    }
328
329    pub fn noul<'r>(response: &'r SystemOneResponse, name: &str) -> Result<&'r NoulAnswer> {
330        match answer(response, name, AnswerKind::Noul)? {
331            Answer::Noul(a) => Ok(a),
332            _ => unreachable!("kind checked"),
333        }
334    }
335
336    pub fn score<'r>(response: &'r SystemOneResponse, name: &str) -> Result<&'r ScoreAnswer> {
337        match answer(response, name, AnswerKind::Score)? {
338            Answer::Score(a) => Ok(a),
339            _ => unreachable!("kind checked"),
340        }
341    }
342
343    pub fn choice<T: ChoiceField>(response: &SystemOneResponse, name: &str) -> Result<T> {
344        let Answer::Choice(a) = answer(response, name, AnswerKind::Choice)? else {
345            unreachable!("kind checked")
346        };
347        T::from_choice(a)
348            .map_err(|e| mismatch(response, format!("answers.{name}.choice"), e.to_string()))
349    }
350}
351
352impl Client {
353    /// Ask the questions of a [`Rubric`] about `state` and decode the answers into it.
354    ///
355    /// ```no_run
356    /// # #[cfg(feature = "derive")] {
357    /// use typesafe::{Client, NoulAnswer, Rubric};
358    ///
359    /// #[derive(Rubric)]
360    /// struct Urgency {
361    ///     #[noul("The message conveys urgency")]
362    ///     is_urgent: NoulAnswer,
363    /// }
364    ///
365    /// # async fn run() -> typesafe::Result<()> {
366    /// let client = Client::from_env()?;
367    /// let answer: Urgency = client.ask("The payout failed again.").await?;
368    /// println!("{}", answer.is_urgent.is_yes(0.8));
369    /// # Ok(()) }
370    /// # }
371    /// ```
372    pub fn ask<R: Rubric>(&self, state: impl Serialize) -> AskRequest<R> {
373        AskRequest {
374            req: self.system_one(state, R::questions()),
375            rubric: PhantomData,
376        }
377    }
378}
379
380/// A pending [`Client::ask`]: the same per-call options as a [`SystemOneRequest`], and `.await`
381/// gives the rubric instead of the response.
382#[must_use = "requests do nothing until awaited"]
383#[derive(Debug)]
384pub struct AskRequest<R> {
385    req: SystemOneRequest,
386    rubric: PhantomData<fn() -> R>,
387}
388
389impl<R: Rubric> AskRequest<R> {
390    /// Override the retry policy for this call.
391    pub fn retry(mut self, policy: RetryPolicy) -> Self {
392        self.req = self.req.retry(policy);
393        self
394    }
395
396    /// Override the per-attempt timeout for this call.
397    pub fn timeout(mut self, timeout: Duration) -> Self {
398        self.req = self.req.timeout(timeout);
399        self
400    }
401
402    /// Add a header for this call (protected headers still win).
403    pub fn header(mut self, name: HeaderName, value: HeaderValue) -> Self {
404        self.req = self.req.header(name, value);
405        self
406    }
407
408    /// Override the model for this call.
409    pub fn model(mut self, model: impl Into<String>) -> Self {
410        self.req = self.req.model(model);
411        self
412    }
413
414    /// Add a top-level body field; see [`SystemOneRequest::extra_body`].
415    pub fn extra_body(mut self, key: impl Into<String>, value: impl Into<Value>) -> Self {
416        self.req = self.req.extra_body(key, value);
417        self
418    }
419
420    /// Send the request and decode the answers.
421    ///
422    /// # Errors
423    ///
424    /// Those of [`SystemOneRequest::send`], plus those of [`Rubric::from_response`].
425    pub async fn send(self) -> Result<R> {
426        R::from_response(&self.req.send().await?)
427    }
428}
429
430impl<R: Rubric + 'static> IntoFuture for AskRequest<R> {
431    type Output = Result<R>;
432    type IntoFuture = BoxFuture<'static, Self::Output>;
433
434    fn into_future(self) -> Self::IntoFuture {
435        Box::pin(self.send())
436    }
437}
438
439/// Mistakes the derive refuses to compile, each checked as a `compile_fail` doctest.
440///
441/// An answer read as the wrong type:
442/// ```compile_fail
443/// #[derive(typesafe::Rubric)]
444/// struct R {
445///     #[noul("Urgent?")]
446///     is_urgent: typesafe::ScoreAnswer,
447/// }
448/// ```
449/// A choice whose type has no options to offer:
450/// ```compile_fail
451/// #[derive(typesafe::Rubric)]
452/// struct R {
453///     #[choice("Which team")]
454///     team: u32,
455/// }
456/// ```
457/// A score without levels:
458/// ```compile_fail
459/// #[derive(typesafe::Rubric)]
460/// struct R {
461///     #[score("How angry")]
462///     anger: typesafe::ScoreAnswer,
463/// }
464/// ```
465/// A score with one level, or with more than the API's ten:
466/// ```compile_fail
467/// #[derive(typesafe::Rubric)]
468/// struct R {
469///     #[score("How angry", levels = ["Furious"])]
470///     anger: typesafe::ScoreAnswer,
471/// }
472/// ```
473/// ```compile_fail
474/// #[derive(typesafe::Rubric)]
475/// struct R {
476///     #[score("How angry", levels = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10"])]
477///     anger: typesafe::ScoreAnswer,
478/// }
479/// ```
480/// A field that is not a question:
481/// ```compile_fail
482/// #[derive(typesafe::Rubric)]
483/// struct R {
484///     #[noul("Urgent?")]
485///     is_urgent: typesafe::NoulAnswer,
486///     note: String,
487/// }
488/// ```
489/// Two fields asking under one name:
490/// ```compile_fail
491/// #[derive(typesafe::Rubric)]
492/// struct R {
493///     #[noul("Urgent?")]
494///     is_urgent: typesafe::NoulAnswer,
495///     #[noul("Really urgent?")]
496///     #[rubric(rename = "is_urgent")]
497///     very: typesafe::NoulAnswer,
498/// }
499/// ```
500/// A key the attribute does not take:
501/// ```compile_fail
502/// #[derive(typesafe::Rubric)]
503/// struct R {
504///     #[noul("Urgent?", levels = ["a", "b"])]
505///     is_urgent: typesafe::NoulAnswer,
506/// }
507/// ```
508/// An option that carries data:
509/// ```compile_fail
510/// #[derive(typesafe::RubricChoice)]
511/// enum Team {
512///     Billing,
513///     Other(String),
514/// }
515/// ```
516/// And the same shapes, spelled correctly, compile:
517/// ```
518/// #[derive(typesafe::Rubric)]
519/// struct R {
520///     #[noul("Urgent?", yes = "A deadline")]
521///     is_urgent: typesafe::NoulAnswer,
522///     #[choice("Which team")]
523///     team: Team,
524///     #[score("How angry", levels = ["Calm", "Angry"])]
525///     anger: typesafe::ScoreAnswer,
526///     #[score("How bad", levels = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"])]
527///     severity: typesafe::ScoreAnswer,
528/// }
529/// #[derive(typesafe::RubricChoice)]
530/// enum Team {
531///     Billing,
532///     Other,
533/// }
534/// ```
535#[cfg(all(doctest, feature = "derive"))]
536pub struct DeriveCompileFail;