Skip to main content

cortexkit_role_classifier/
question.rs

1//! The providers' shared question shape, and the images an item may carry.
2//!
3//! Both starting providers (Cloudflare Workers AI `@cf/cloudflare/clef` and
4//! TypeSafe `jev-latest`) take the same question object, and a runner
5//! forwards each question to the provider as the caller wrote it. The field
6//! names are the providers' own; see `CONTRACT.md` for the sources.
7
8use std::fmt;
9
10use indexmap::IndexMap;
11use serde::{Deserialize, Deserializer, Serialize, Serializer};
12use serde_json::Value;
13
14use crate::errors::Refusal;
15
16/// Question type names.
17pub mod types {
18    /// A yes/no question; the answer is the probability that the answer is
19    /// yes, from 0 (no) to 1 (yes).
20    pub const NOUL: &str = "noul";
21    /// Pick one of the named options.
22    pub const CHOICE: &str = "choice";
23    /// Place the item on an ordered scale of levels, lowest first.
24    pub const SCORE: &str = "score";
25}
26
27/// Fewest options a `choice` question may have.
28pub const CHOICE_MIN_OPTIONS: usize = 2;
29/// Most options a `choice` question may have.
30pub const CHOICE_MAX_OPTIONS: usize = 255;
31/// Fewest levels a `score` question may have.
32pub const SCORE_MIN_LEVELS: usize = 2;
33/// Most levels a `score` question may have.
34pub const SCORE_MAX_LEVELS: usize = 10;
35/// Longest question id, in bytes. Ids are caller-chosen, `[a-z0-9_-]`.
36pub const QUESTION_ID_MAX_LEN: usize = 64;
37/// The two keys of a `noul` question's optional criteria.
38pub const NOUL_CRITERIA_KEYS: [&str; 2] = ["true", "false"];
39
40/// A question type. Decodes open: a value other than [`types`] is kept in
41/// [`QuestionType::Other`]. The open variant is for decoding a reply, where
42/// a provider's new type passes through as given; in a request,
43/// [`check_question`] refuses it `invalid_params` naming
44/// `questions.<id>.type`, because a runner can neither validate nor map a
45/// type it does not know.
46#[derive(Clone, Debug, PartialEq, Eq, Hash)]
47pub enum QuestionType {
48    Noul,
49    Choice,
50    Score,
51    Other(String),
52}
53
54impl QuestionType {
55    pub fn parse(name: &str) -> Self {
56        match name {
57            types::NOUL => Self::Noul,
58            types::CHOICE => Self::Choice,
59            types::SCORE => Self::Score,
60            other => Self::Other(other.to_owned()),
61        }
62    }
63
64    pub fn as_str(&self) -> &str {
65        match self {
66            Self::Noul => types::NOUL,
67            Self::Choice => types::CHOICE,
68            Self::Score => types::SCORE,
69            Self::Other(other) => other,
70        }
71    }
72}
73
74impl fmt::Display for QuestionType {
75    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
76        f.write_str(self.as_str())
77    }
78}
79
80impl Serialize for QuestionType {
81    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
82        serializer.serialize_str(self.as_str())
83    }
84}
85
86impl<'de> Deserialize<'de> for QuestionType {
87    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
88        Ok(Self::parse(&String::deserialize(deserializer)?))
89    }
90}
91
92/// A question's `criteria`, as the providers document it per type:
93///
94/// - `score`: an array of level descriptions, lowest first, indexed from 0
95///   ([`Criteria::Levels`]). The request has no levels or legend field; the
96///   answer's `legend` is keyed by the level index.
97/// - `choice`: an object from option id to its description, a string,
98///   object, array or null ([`Criteria::Options`]).
99/// - `noul`: optionally `{"true": string, "false": string}`, also an
100///   [`Criteria::Options`].
101///
102/// The options keep their order, so a decoded question re-encodes as sent.
103#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
104#[serde(untagged)]
105pub enum Criteria {
106    Levels(Vec<Value>),
107    Options(IndexMap<String, Value>),
108}
109
110/// One question: `{type, instructions, criteria?}`.
111///
112/// Non-exhaustive so later optional members are additive: use
113/// [`Question::noul`], [`Question::choice`] or [`Question::score`] and the
114/// `with_*` setters, or decode one.
115#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
116#[non_exhaustive]
117pub struct Question {
118    #[serde(rename = "type")]
119    pub kind: QuestionType,
120    /// A non-empty string, or an object or array.
121    pub instructions: Value,
122    #[serde(default, skip_serializing_if = "Option::is_none")]
123    pub criteria: Option<Criteria>,
124}
125
126impl Question {
127    pub fn new(kind: QuestionType, instructions: impl Into<Value>) -> Self {
128        Self {
129            kind,
130            instructions: instructions.into(),
131            criteria: None,
132        }
133    }
134
135    /// A `noul` question with no criteria.
136    pub fn noul(instructions: impl Into<Value>) -> Self {
137        Self::new(QuestionType::Noul, instructions)
138    }
139
140    /// A `choice` question over `options`, in order.
141    pub fn choice(instructions: impl Into<Value>, options: IndexMap<String, Value>) -> Self {
142        Self::new(QuestionType::Choice, instructions).with_criteria(Criteria::Options(options))
143    }
144
145    /// A `score` question over `levels`, lowest first.
146    pub fn score(instructions: impl Into<Value>, levels: Vec<Value>) -> Self {
147        Self::new(QuestionType::Score, instructions).with_criteria(Criteria::Levels(levels))
148    }
149
150    pub fn with_criteria(mut self, criteria: Criteria) -> Self {
151        self.criteria = Some(criteria);
152        self
153    }
154}
155
156/// Whether `id` is a valid question id: 1 to [`QUESTION_ID_MAX_LEN`]
157/// characters of `[a-z0-9_-]`.
158pub fn is_question_id(id: &str) -> bool {
159    !id.is_empty()
160        && id.len() <= QUESTION_ID_MAX_LEN
161        && id
162            .bytes()
163            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_' || b == b'-')
164}
165
166/// Check one question against the role's limits. A refusal names
167/// `questions.<id>` (the id), `questions.<id>.type` (a type this crate does
168/// not name, checked before anything else about the question, since the
169/// rest depends on the type), `questions.<id>.instructions` or
170/// `questions.<id>.criteria`.
171pub fn check_question(id: &str, question: &Question) -> Result<(), Refusal> {
172    let at = |member: &str| format!("questions.{id}.{member}");
173    if !is_question_id(id) {
174        return Err(Refusal::invalid_params(
175            format!("questions.{id}"),
176            "a question id is 1-64 characters of [a-z0-9_-]",
177        ));
178    }
179    let unknown_type = |name: &str| {
180        Refusal::invalid_params(
181            at("type"),
182            format!("unknown question type {name}; expected noul, choice or score"),
183        )
184    };
185    if let QuestionType::Other(name) = &question.kind {
186        return Err(unknown_type(name));
187    }
188    let instructions_ok = match &question.instructions {
189        Value::String(text) => !text.is_empty(),
190        Value::Object(_) | Value::Array(_) => true,
191        _ => false,
192    };
193    if !instructions_ok {
194        return Err(Refusal::invalid_params(
195            at("instructions"),
196            "instructions is a non-empty string, an object or an array",
197        ));
198    }
199    match (&question.kind, &question.criteria) {
200        (QuestionType::Noul, None) => Ok(()),
201        (QuestionType::Noul, Some(Criteria::Options(options)))
202            if options.len() == NOUL_CRITERIA_KEYS.len()
203                && NOUL_CRITERIA_KEYS
204                    .iter()
205                    .all(|key| options.get(*key).is_some_and(Value::is_string)) =>
206        {
207            Ok(())
208        }
209        (QuestionType::Noul, Some(_)) => Err(Refusal::invalid_params(
210            at("criteria"),
211            "noul criteria, when present, is {\"true\": string, \"false\": string}",
212        )),
213        (QuestionType::Choice, Some(Criteria::Options(options))) => bounded(
214            options.len(),
215            CHOICE_MIN_OPTIONS,
216            CHOICE_MAX_OPTIONS,
217            &at("criteria"),
218            "choice options",
219        ),
220        (QuestionType::Choice, _) => Err(Refusal::invalid_params(
221            at("criteria"),
222            "choice criteria is an object from option id to description",
223        )),
224        (QuestionType::Score, Some(Criteria::Levels(levels))) => bounded(
225            levels.len(),
226            SCORE_MIN_LEVELS,
227            SCORE_MAX_LEVELS,
228            &at("criteria"),
229            "score levels",
230        ),
231        (QuestionType::Score, _) => Err(Refusal::invalid_params(
232            at("criteria"),
233            "score criteria is an array of level descriptions, lowest first",
234        )),
235        (QuestionType::Other(name), _) => Err(unknown_type(name)),
236    }
237}
238
239/// `count` within `min..=max`, or an over-limit refusal naming `field` and
240/// the bound it broke.
241pub(crate) fn bounded(
242    count: usize,
243    min: usize,
244    max: usize,
245    field: &str,
246    what: &str,
247) -> Result<(), Refusal> {
248    if count < min {
249        Err(Refusal::over_limit(
250            field,
251            min as u64,
252            format!("{count} {what}; at least {min}"),
253        ))
254    } else if count > max {
255        Err(Refusal::over_limit(
256            field,
257            max as u64,
258            format!("{count} {what}; at most {max}"),
259        ))
260    } else {
261        Ok(())
262    }
263}
264
265// ---- images ---------------------------------------------------------------
266
267/// Image content types a provider accepts in the object form.
268pub mod content_types {
269    pub const PNG: &str = "image/png";
270    pub const JPEG: &str = "image/jpeg";
271    pub const WEBP: &str = "image/webp";
272    pub const ALL: &[&str] = &[PNG, JPEG, WEBP];
273}
274
275/// Largest image, in decoded bytes (4 MiB).
276pub const IMAGE_MAX_BYTES: usize = 4 * 1024 * 1024;
277/// Largest total of an item's images, in decoded bytes (8 MiB).
278pub const IMAGES_MAX_TOTAL_BYTES: usize = 8 * 1024 * 1024;
279/// Largest image, in pixels (16 megapixels). The role crate does not decode
280/// images, so this is a runner's check, not [`check_image`]'s.
281pub const IMAGE_MAX_PIXELS: u64 = 16_000_000;
282
283/// An image in its object form: `{content_type, base64}`.
284#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
285pub struct InlineImage {
286    /// One of [`content_types::ALL`].
287    pub content_type: String,
288    pub base64: String,
289}
290
291impl InlineImage {
292    pub fn new(content_type: impl Into<String>, base64: impl Into<String>) -> Self {
293        Self {
294            content_type: content_type.into(),
295            base64: base64.into(),
296        }
297    }
298}
299
300/// An image: a `data:` URL string, or `{content_type, base64}`. Remote URLs
301/// are never accepted.
302#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
303#[serde(untagged)]
304pub enum Image {
305    DataUrl(String),
306    Inline(InlineImage),
307}
308
309impl Image {
310    /// The image's size in decoded bytes, estimated from its base64 length.
311    pub fn approx_bytes(&self) -> usize {
312        let payload = match self {
313            Self::DataUrl(url) => url.split_once(',').map_or("", |(_, data)| data),
314            Self::Inline(inline) => &inline.base64,
315        };
316        payload.trim_end_matches('=').len() * 3 / 4
317    }
318}
319
320/// Check one image's form and size. The error says why it is refused.
321pub fn check_image(image: &Image) -> Result<(), String> {
322    match image {
323        Image::DataUrl(url) => {
324            let Some(rest) = url.strip_prefix("data:") else {
325                return Err("an image string must be a data: URL; remote URLs are refused".into());
326            };
327            let Some((media, _)) = rest.split_once(',') else {
328                return Err("a data: URL has no payload".into());
329            };
330            let content_type = media.split(';').next().unwrap_or("");
331            if !content_types::ALL.contains(&content_type) {
332                return Err(format!("unsupported image content type {content_type}"));
333            }
334        }
335        Image::Inline(inline) => {
336            if !content_types::ALL.contains(&inline.content_type.as_str()) {
337                return Err(format!(
338                    "unsupported image content type {}",
339                    inline.content_type
340                ));
341            }
342        }
343    }
344    if image.approx_bytes() > IMAGE_MAX_BYTES {
345        return Err(format!("an image is at most {IMAGE_MAX_BYTES} bytes"));
346    }
347    Ok(())
348}
349
350#[cfg(test)]
351mod tests {
352    use serde_json::json;
353
354    use super::*;
355    use crate::errors::INVALID_PARAMS;
356
357    fn options(n: usize) -> IndexMap<String, Value> {
358        (0..n).map(|i| (format!("o{i}"), json!("x"))).collect()
359    }
360
361    fn levels(n: usize) -> Vec<Value> {
362        (0..n).map(|i| json!(format!("level {i}"))).collect()
363    }
364
365    #[test]
366    fn choice_options_and_score_levels_are_bounded() {
367        assert!(check_question("q", &Question::choice("i", options(2))).is_ok());
368        assert!(check_question("q", &Question::choice("i", options(255))).is_ok());
369        for n in [1, 256] {
370            let refusal = check_question("q", &Question::choice("i", options(n))).unwrap_err();
371            assert_eq!(refusal.code, INVALID_PARAMS);
372            assert_eq!(refusal.field(), Some("questions.q.criteria"));
373        }
374        assert!(check_question("q", &Question::score("i", levels(2))).is_ok());
375        assert!(check_question("q", &Question::score("i", levels(10))).is_ok());
376        for (n, limit) in [(1, 2), (11, 10)] {
377            let refusal = check_question("q", &Question::score("i", levels(n))).unwrap_err();
378            assert_eq!(refusal.field(), Some("questions.q.criteria"));
379            assert_eq!(refusal.detail.limit, Some(limit));
380        }
381    }
382
383    #[test]
384    fn ids_types_and_instructions_are_refused_by_field() {
385        let refusal = check_question("Bad", &Question::noul("i")).unwrap_err();
386        assert_eq!(refusal.field(), Some("questions.Bad"));
387        let refusal = check_question("q", &Question::noul("")).unwrap_err();
388        assert_eq!(refusal.field(), Some("questions.q.instructions"));
389        let refusal =
390            check_question("q", &Question::new(QuestionType::parse("rank"), "i")).unwrap_err();
391        assert_eq!(refusal.field(), Some("questions.q.type"));
392        // The type is refused before the instructions, which mean nothing
393        // for a type the runner does not know.
394        let refusal =
395            check_question("q", &Question::new(QuestionType::parse("rank"), "")).unwrap_err();
396        assert_eq!(refusal.field(), Some("questions.q.type"));
397        assert!(check_question("q", &Question::noul(json!({"ask": "is it?"}))).is_ok());
398        assert!(is_question_id(&"a".repeat(64)));
399        assert!(!is_question_id(&"a".repeat(65)));
400    }
401
402    #[test]
403    fn noul_criteria_is_true_and_false() {
404        let good: IndexMap<String, Value> = [
405            ("true".to_owned(), json!("yes")),
406            ("false".to_owned(), json!("no")),
407        ]
408        .into();
409        let question = Question::noul("i").with_criteria(Criteria::Options(good));
410        assert!(check_question("q", &question).is_ok());
411        let bad: IndexMap<String, Value> = [("yes".to_owned(), json!("y"))].into();
412        let question = Question::noul("i").with_criteria(Criteria::Options(bad));
413        assert_eq!(
414            check_question("q", &question).unwrap_err().field(),
415            Some("questions.q.criteria")
416        );
417    }
418
419    #[test]
420    fn question_type_decodes_open() {
421        let question: Question =
422            serde_json::from_str(r#"{"type":"rank","instructions":"i"}"#).unwrap();
423        assert_eq!(question.kind, QuestionType::Other("rank".into()));
424        assert_eq!(
425            serde_json::to_string(&question).unwrap(),
426            r#"{"type":"rank","instructions":"i"}"#
427        );
428    }
429
430    #[test]
431    fn remote_urls_and_unknown_types_are_refused() {
432        assert!(check_image(&Image::DataUrl("https://example.com/a.png".into())).is_err());
433        assert!(check_image(&Image::DataUrl("data:image/gif;base64,AAAA".into())).is_err());
434        assert!(check_image(&Image::DataUrl("data:image/png;base64,AAAA".into())).is_ok());
435        assert!(check_image(&Image::Inline(InlineImage::new("image/webp", "AAAA"))).is_ok());
436        assert!(check_image(&Image::Inline(InlineImage::new("image/bmp", "AAAA"))).is_err());
437    }
438}