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;