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
239pub(crate) use cortexkit_model_batch::admission::bounded;
240
241// ---- images ---------------------------------------------------------------
242
243// The item image shape, its validation and its limits are the shared
244// model-batch rules (`cortexkit-model-batch`, its `RULES.md` ยง13),
245// re-exported here at their `classifier/v1` paths. `check_image` decodes
246// the base64, so malformed base64 and a `data:` URL without `;base64` are
247// refused before any provider call.
248pub use cortexkit_model_batch::image::{
249    check_image, content_types, Image, InlineImage, IMAGES_MAX_TOTAL_BYTES, IMAGE_MAX_BYTES,
250    IMAGE_MAX_PIXELS,
251};
252
253#[cfg(test)]
254mod tests {
255    use serde_json::json;
256
257    use super::*;
258    use crate::errors::INVALID_PARAMS;
259
260    fn options(n: usize) -> IndexMap<String, Value> {
261        (0..n).map(|i| (format!("o{i}"), json!("x"))).collect()
262    }
263
264    fn levels(n: usize) -> Vec<Value> {
265        (0..n).map(|i| json!(format!("level {i}"))).collect()
266    }
267
268    #[test]
269    fn choice_options_and_score_levels_are_bounded() {
270        assert!(check_question("q", &Question::choice("i", options(2))).is_ok());
271        assert!(check_question("q", &Question::choice("i", options(255))).is_ok());
272        for n in [1, 256] {
273            let refusal = check_question("q", &Question::choice("i", options(n))).unwrap_err();
274            assert_eq!(refusal.code, INVALID_PARAMS);
275            assert_eq!(refusal.field(), Some("questions.q.criteria"));
276        }
277        assert!(check_question("q", &Question::score("i", levels(2))).is_ok());
278        assert!(check_question("q", &Question::score("i", levels(10))).is_ok());
279        for (n, limit) in [(1, 2), (11, 10)] {
280            let refusal = check_question("q", &Question::score("i", levels(n))).unwrap_err();
281            assert_eq!(refusal.field(), Some("questions.q.criteria"));
282            assert_eq!(refusal.detail.limit, Some(limit));
283        }
284    }
285
286    #[test]
287    fn ids_types_and_instructions_are_refused_by_field() {
288        let refusal = check_question("Bad", &Question::noul("i")).unwrap_err();
289        assert_eq!(refusal.field(), Some("questions.Bad"));
290        let refusal = check_question("q", &Question::noul("")).unwrap_err();
291        assert_eq!(refusal.field(), Some("questions.q.instructions"));
292        let refusal =
293            check_question("q", &Question::new(QuestionType::parse("rank"), "i")).unwrap_err();
294        assert_eq!(refusal.field(), Some("questions.q.type"));
295        // The type is refused before the instructions, which mean nothing
296        // for a type the runner does not know.
297        let refusal =
298            check_question("q", &Question::new(QuestionType::parse("rank"), "")).unwrap_err();
299        assert_eq!(refusal.field(), Some("questions.q.type"));
300        assert!(check_question("q", &Question::noul(json!({"ask": "is it?"}))).is_ok());
301        assert!(is_question_id(&"a".repeat(64)));
302        assert!(!is_question_id(&"a".repeat(65)));
303    }
304
305    #[test]
306    fn noul_criteria_is_true_and_false() {
307        let good: IndexMap<String, Value> = [
308            ("true".to_owned(), json!("yes")),
309            ("false".to_owned(), json!("no")),
310        ]
311        .into();
312        let question = Question::noul("i").with_criteria(Criteria::Options(good));
313        assert!(check_question("q", &question).is_ok());
314        let bad: IndexMap<String, Value> = [("yes".to_owned(), json!("y"))].into();
315        let question = Question::noul("i").with_criteria(Criteria::Options(bad));
316        assert_eq!(
317            check_question("q", &question).unwrap_err().field(),
318            Some("questions.q.criteria")
319        );
320    }
321
322    #[test]
323    fn question_type_decodes_open() {
324        let question: Question =
325            serde_json::from_str(r#"{"type":"rank","instructions":"i"}"#).unwrap();
326        assert_eq!(question.kind, QuestionType::Other("rank".into()));
327        assert_eq!(
328            serde_json::to_string(&question).unwrap(),
329            r#"{"type":"rank","instructions":"i"}"#
330        );
331    }
332
333    #[test]
334    fn remote_urls_and_unknown_types_are_refused() {
335        assert!(check_image(&Image::DataUrl("https://example.com/a.png".into())).is_err());
336        assert!(check_image(&Image::DataUrl("data:image/gif;base64,AAAA".into())).is_err());
337        assert!(check_image(&Image::DataUrl("data:image/png;base64,AAAA".into())).is_ok());
338        assert!(check_image(&Image::Inline(InlineImage::new("image/webp", "AAAA"))).is_ok());
339        assert!(check_image(&Image::Inline(InlineImage::new("image/bmp", "AAAA"))).is_err());
340        assert!(check_image(&Image::Inline(InlineImage::new("image/png", "A A="))).is_err());
341        assert!(check_image(&Image::DataUrl("data:image/png,AAAA".into())).is_err());
342    }
343}