Skip to main content

typesafe_systemone/
request.rs

1use std::collections::BTreeMap;
2
3use serde::Serialize;
4use serde_json::{Map, Value};
5
6use crate::answer::SystemOneResponse;
7use crate::client::Client;
8use crate::error::{Error, Result};
9use crate::question::{NONE_OF_THE_ABOVE, Question};
10
11enum State {
12    Empty,
13    Whole(Value),
14    Fields(Map<String, Value>),
15}
16
17/// A `POST /v1/systemone` call under construction. Created by [`Client::system_one`].
18///
19/// Set the state once with [`state`](Self::state) (any `Serialize`, typically your own
20/// struct) or build it field by field with [`field`](Self::field), add questions, then
21/// [`send`](Self::send). Errors in the inputs (unserializable value, fields added to a
22/// non-object state, no state, no questions) surface from `send`, so the chain stays clean.
23#[must_use = "a request does nothing until `.send().await`"]
24pub struct SystemOneRequest<'a> {
25    client: &'a Client,
26    model: Option<String>,
27    state: State,
28    questions: BTreeMap<String, Question>,
29    error: Option<Error>,
30}
31
32impl<'a> SystemOneRequest<'a> {
33    pub(crate) fn new(client: &'a Client) -> Self {
34        Self {
35            client,
36            model: None,
37            state: State::Empty,
38            questions: BTreeMap::new(),
39            error: None,
40        }
41    }
42
43    /// Model for this call only. Defaults to the client's model.
44    pub fn model(mut self, model: impl Into<String>) -> Self {
45        self.model = Some(model.into());
46        self
47    }
48
49    /// The whole state: a string, or any `Serialize` value (your own struct, a `Vec`, a
50    /// `serde_json::Value`). Replaces anything set before.
51    pub fn state(mut self, state: impl Serialize) -> Self {
52        match serde_json::to_value(state) {
53            Ok(v) => self.state = State::Whole(v),
54            Err(e) => self.fail(Error::RequestSerialization(e)),
55        }
56        self
57    }
58
59    /// One named field of an object state. Call it once per field; a later
60    /// [`state`](Self::state) replaces them all. Fails at `send` if the state was already
61    /// set to something that is not an object.
62    pub fn field(mut self, name: impl Into<String>, value: impl Serialize) -> Self {
63        let value = match serde_json::to_value(value) {
64            Ok(v) => v,
65            Err(e) => {
66                self.fail(Error::RequestSerialization(e));
67                return self;
68            }
69        };
70        let name = name.into();
71        self.state = match std::mem::replace(&mut self.state, State::Empty) {
72            State::Empty => State::Fields(Map::from_iter([(name, value)])),
73            State::Fields(mut map) => {
74                map.insert(name, value);
75                State::Fields(map)
76            }
77            State::Whole(Value::Object(mut map)) => {
78                map.insert(name, value);
79                State::Fields(map)
80            }
81            other @ State::Whole(_) => {
82                self.fail(Error::InvalidRequest(format!(
83                    "cannot add field `{name}`: state is not an object"
84                )));
85                other
86            }
87        };
88        self
89    }
90
91    /// Any question under `id`. Answers come back under the same id.
92    pub fn question(mut self, id: impl Into<String>, question: Question) -> Self {
93        let id = id.into();
94        if self.questions.insert(id.clone(), question).is_some() {
95            self.fail(Error::InvalidRequest(format!("duplicate question id `{id}`")));
96        }
97        self
98    }
99
100    /// Yes/no question.
101    pub fn noul(self, id: impl Into<String>, instructions: impl Into<Value>) -> Self {
102        self.question(id, Question::noul(instructions))
103    }
104
105    /// Yes/no question with descriptions of what yes and no mean.
106    pub fn noul_with_criteria(
107        self,
108        id: impl Into<String>,
109        instructions: impl Into<Value>,
110        yes: impl Into<String>,
111        no: impl Into<String>,
112    ) -> Self {
113        self.question(id, Question::noul_with_criteria(instructions, yes, no))
114    }
115
116    /// Pick one option; build the option set in the closure.
117    pub fn choice(
118        mut self,
119        id: impl Into<String>,
120        instructions: impl Into<Value>,
121        options: impl FnOnce(ChoiceBuilder) -> ChoiceBuilder,
122    ) -> Self {
123        let id = id.into();
124        let built = options(ChoiceBuilder::default());
125        if built.options.is_empty() {
126            self.fail(Error::InvalidRequest(format!("choice `{id}` has no options")));
127            return self;
128        }
129        self.question(
130            id,
131            Question::Choice {
132                instructions: instructions.into(),
133                criteria: built.options,
134            },
135        )
136    }
137
138    /// Rate along `levels`, lowest first. At least two.
139    pub fn score<L: Into<String>>(
140        mut self,
141        id: impl Into<String>,
142        instructions: impl Into<Value>,
143        levels: impl IntoIterator<Item = L>,
144    ) -> Self {
145        let id = id.into();
146        let levels: Vec<String> = levels.into_iter().map(Into::into).collect();
147        if levels.len() < 2 {
148            self.fail(Error::InvalidRequest(format!("score `{id}` needs at least two levels")));
149            return self;
150        }
151        self.question(
152            id,
153            Question::Score {
154                instructions: instructions.into(),
155                criteria: levels,
156            },
157        )
158    }
159
160    /// Send the request.
161    pub async fn send(self) -> Result<SystemOneResponse> {
162        if let Some(e) = self.error {
163            return Err(e);
164        }
165        let state = match self.state {
166            State::Empty => {
167                return Err(Error::InvalidRequest(
168                    "no state: call `.state(..)` or `.field(..)`".into(),
169                ));
170            }
171            State::Whole(v) => v,
172            State::Fields(map) => Value::Object(map),
173        };
174        if self.questions.is_empty() {
175            return Err(Error::InvalidRequest("no questions".into()));
176        }
177        match self.model {
178            Some(model) => self.client.evaluate_with_model(&model, state, self.questions).await,
179            None => self.client.evaluate(state, self.questions).await,
180        }
181    }
182
183    fn fail(&mut self, e: Error) {
184        if self.error.is_none() {
185            self.error = Some(e);
186        }
187    }
188}
189
190/// Option set of a Choice, built inside [`SystemOneRequest::choice`].
191#[derive(Default)]
192pub struct ChoiceBuilder {
193    options: BTreeMap<String, Option<String>>,
194}
195
196impl ChoiceBuilder {
197    /// An option with a rubric description.
198    pub fn option(mut self, name: impl Into<String>, description: impl Into<String>) -> Self {
199        self.options.insert(name.into(), Some(description.into()));
200        self
201    }
202
203    /// An option that needs no description.
204    pub fn option_plain(mut self, name: impl Into<String>) -> Self {
205        self.options.insert(name.into(), None);
206        self
207    }
208
209    /// Many undescribed options at once.
210    pub fn options_plain<K: Into<String>>(mut self, names: impl IntoIterator<Item = K>) -> Self {
211        self.options.extend(names.into_iter().map(|n| (n.into(), None)));
212        self
213    }
214
215    /// Many described options at once.
216    pub fn options<K, V>(mut self, pairs: impl IntoIterator<Item = (K, V)>) -> Self
217    where
218        K: Into<String>,
219        V: Into<String>,
220    {
221        self.options
222            .extend(pairs.into_iter().map(|(k, v)| (k.into(), Some(v.into()))));
223        self
224    }
225
226    /// The abstain option (`none_of_the_above`). A Choice always picks *something*; this is
227    /// how the model says nothing in the list fits.
228    pub fn none_of_the_above(self, description: impl Into<String>) -> Self {
229        self.option(NONE_OF_THE_ABOVE, description)
230    }
231}