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 field that is not a question:
445/// ```compile_fail
446/// #[derive(typesafe::Rubric)]
447/// struct R {
448/// #[noul("Urgent?")]
449/// is_urgent: typesafe::NoulAnswer,
450/// note: String,
451/// }
452/// ```
453/// Two fields asking under one name:
454/// ```compile_fail
455/// #[derive(typesafe::Rubric)]
456/// struct R {
457/// #[noul("Urgent?")]
458/// is_urgent: typesafe::NoulAnswer,
459/// #[noul("Really urgent?")]
460/// #[rubric(rename = "is_urgent")]
461/// very: typesafe::NoulAnswer,
462/// }
463/// ```
464/// A key the attribute does not take:
465/// ```compile_fail
466/// #[derive(typesafe::Rubric)]
467/// struct R {
468/// #[noul("Urgent?", levels = ["a", "b"])]
469/// is_urgent: typesafe::NoulAnswer,
470/// }
471/// ```
472/// An option that carries data:
473/// ```compile_fail
474/// #[derive(typesafe::RubricChoice)]
475/// enum Team {
476/// Billing,
477/// Other(String),
478/// }
479/// ```
480/// And the same shapes, spelled correctly, compile:
481/// ```
482/// #[derive(typesafe::Rubric)]
483/// struct R {
484/// #[noul("Urgent?", yes = "A deadline")]
485/// is_urgent: typesafe::NoulAnswer,
486/// #[choice("Which team")]
487/// team: Team,
488/// #[score("How angry", levels = ["Calm", "Angry"])]
489/// anger: typesafe::ScoreAnswer,
490/// }
491/// #[derive(typesafe::RubricChoice)]
492/// enum Team {
493/// Billing,
494/// Other,
495/// }
496/// ```
497#[cfg(all(doctest, feature = "derive"))]
498pub struct DeriveCompileFail;