Skip to main content

ironflow_core/decision/
request.rs

1//! Request types for a [`DecisionProvider`](super::DecisionProvider): the state
2//! to evaluate and the map of typed questions to ask about it.
3
4use std::collections::BTreeMap;
5use std::fmt;
6
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9
10/// A model route for a [`DecisionRequest`], e.g. the System One `jev-latest` alias.
11///
12/// A thin newtype over the route string: it keeps model identifiers distinct from
13/// arbitrary strings at the type level while still accepting any custom route a
14/// backend exposes. Serializes transparently as the bare string, so the wire
15/// format is unchanged.
16///
17/// # Examples
18///
19/// ```
20/// use ironflow_core::decision::DecisionModel;
21///
22/// assert_eq!(DecisionModel::default().as_str(), "jev-latest");
23/// assert_eq!(DecisionModel::from("jev-2").as_str(), "jev-2");
24/// ```
25#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
26#[serde(transparent)]
27pub struct DecisionModel(String);
28
29impl DecisionModel {
30    /// The default early-access route (`jev-latest`).
31    pub const LATEST: &'static str = "jev-latest";
32
33    /// Wrap a model route.
34    ///
35    /// # Examples
36    ///
37    /// ```
38    /// use ironflow_core::decision::DecisionModel;
39    ///
40    /// let model = DecisionModel::new("jev-2");
41    /// assert_eq!(model.as_str(), "jev-2");
42    /// ```
43    pub fn new(route: impl Into<String>) -> Self {
44        Self(route.into())
45    }
46
47    /// The route as a string slice.
48    ///
49    /// # Examples
50    ///
51    /// ```
52    /// use ironflow_core::decision::DecisionModel;
53    ///
54    /// assert_eq!(DecisionModel::from("jev-latest").as_str(), "jev-latest");
55    /// ```
56    pub fn as_str(&self) -> &str {
57        &self.0
58    }
59}
60
61impl Default for DecisionModel {
62    fn default() -> Self {
63        Self(Self::LATEST.to_string())
64    }
65}
66
67impl From<&str> for DecisionModel {
68    fn from(route: &str) -> Self {
69        Self(route.to_string())
70    }
71}
72
73impl From<String> for DecisionModel {
74    fn from(route: String) -> Self {
75        Self(route)
76    }
77}
78
79impl fmt::Display for DecisionModel {
80    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
81        f.write_str(&self.0)
82    }
83}
84
85impl AsRef<str> for DecisionModel {
86    fn as_ref(&self) -> &str {
87        &self.0
88    }
89}
90
91impl PartialEq<str> for DecisionModel {
92    fn eq(&self, other: &str) -> bool {
93        self.0 == other
94    }
95}
96
97impl PartialEq<&str> for DecisionModel {
98    fn eq(&self, other: &&str) -> bool {
99        self.0 == *other
100    }
101}
102
103/// Optional natural-language criteria for a [`DecisionQuestion::Noul`] question.
104///
105/// Both sides are optional: an empty [`NoulCriteria`] asks the model to decide
106/// with the instructions alone.
107///
108/// # Examples
109///
110/// ```
111/// use ironflow_core::decision::NoulCriteria;
112///
113/// let criteria = NoulCriteria {
114///     if_true: Some("Explicitly time-sensitive".to_string()),
115///     if_false: Some("No urgency expressed".to_string()),
116/// };
117/// assert!(!criteria.is_empty());
118/// assert!(NoulCriteria::default().is_empty());
119/// ```
120#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
121pub struct NoulCriteria {
122    /// Description of what a "yes" (true) looks like.
123    #[serde(rename = "true", default, skip_serializing_if = "Option::is_none")]
124    pub if_true: Option<String>,
125    /// Description of what a "no" (false) looks like.
126    #[serde(rename = "false", default, skip_serializing_if = "Option::is_none")]
127    pub if_false: Option<String>,
128}
129
130impl NoulCriteria {
131    /// Whether both criteria are unset.
132    ///
133    /// # Examples
134    ///
135    /// ```
136    /// use ironflow_core::decision::NoulCriteria;
137    ///
138    /// assert!(NoulCriteria::default().is_empty());
139    /// ```
140    pub fn is_empty(&self) -> bool {
141        self.if_true.is_none() && self.if_false.is_none()
142    }
143}
144
145/// A single typed question in a [`DecisionRequest`].
146///
147/// The three variants mirror the System One question types. `instructions` is a
148/// free-form [`Value`] (string, object, or array) describing what to evaluate.
149///
150/// # Examples
151///
152/// ```
153/// use ironflow_core::decision::DecisionQuestion;
154/// use serde_json::json;
155///
156/// let q = DecisionQuestion::Score {
157///     instructions: json!("How frustrated is the customer?"),
158///     criteria: vec!["Calm".into(), "Frustrated".into(), "Very angry".into()],
159/// };
160/// let wire = serde_json::to_value(&q).unwrap();
161/// assert_eq!(wire["type"], "score");
162/// ```
163#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
164#[serde(tag = "type", rename_all = "snake_case")]
165pub enum DecisionQuestion {
166    /// A yes/no question. The answer is the probability that the answer is "yes".
167    Noul {
168        /// What to evaluate (string, object, or array).
169        instructions: Value,
170        /// Optional descriptions of the true/false sides.
171        #[serde(default, skip_serializing_if = "NoulCriteria::is_empty")]
172        criteria: NoulCriteria,
173    },
174    /// A selection among named options. `criteria` maps each option to an
175    /// optional description.
176    Choice {
177        /// What to evaluate (string, object, or array).
178        instructions: Value,
179        /// Options: name -> optional description.
180        criteria: BTreeMap<String, Option<String>>,
181    },
182    /// A rating against ordered, descriptive levels (index 0..N-1).
183    Score {
184        /// What to evaluate (string, object, or array).
185        instructions: Value,
186        /// Ordered level descriptions.
187        criteria: Vec<String>,
188    },
189}
190
191/// A request to a [`DecisionProvider`](super::DecisionProvider): a state plus a
192/// map of typed questions.
193///
194/// Answers come back under the same keys used in [`questions`](Self::questions).
195///
196/// # Examples
197///
198/// ```
199/// use ironflow_core::decision::{DecisionRequest, DecisionQuestion};
200/// use std::collections::BTreeMap;
201/// use serde_json::json;
202///
203/// let mut questions = BTreeMap::new();
204/// questions.insert(
205///     "department".to_string(),
206///     DecisionQuestion::Choice {
207///         instructions: json!("Which team should handle this?"),
208///         criteria: BTreeMap::from([
209///             ("billing".to_string(), Some("Payments".to_string())),
210///             ("technical".to_string(), Some("Bugs".to_string())),
211///         ]),
212///     },
213/// );
214/// let request = DecisionRequest { state: json!("outage"), model: "jev-latest".into(), questions };
215/// assert_eq!(request.questions.len(), 1);
216/// ```
217#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
218pub struct DecisionRequest {
219    /// The content to evaluate: a string, or structured JSON.
220    pub state: Value,
221    /// Model route (e.g. [`DecisionModel::LATEST`]).
222    pub model: DecisionModel,
223    /// Typed questions keyed by a name the caller chooses.
224    pub questions: BTreeMap<String, DecisionQuestion>,
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230    use serde_json::json;
231
232    #[test]
233    fn noul_criteria_empty() {
234        assert!(NoulCriteria::default().is_empty());
235        assert!(
236            !NoulCriteria {
237                if_true: Some("x".into()),
238                if_false: None,
239            }
240            .is_empty()
241        );
242    }
243
244    #[test]
245    fn request_serializes_to_wire_format() {
246        let mut questions = BTreeMap::new();
247        questions.insert(
248            "is_urgent".to_string(),
249            DecisionQuestion::Noul {
250                instructions: json!("Does this convey urgency?"),
251                criteria: NoulCriteria {
252                    if_true: Some("time-sensitive".to_string()),
253                    if_false: None,
254                },
255            },
256        );
257        let request = DecisionRequest {
258            state: json!("outage"),
259            model: "jev-latest".into(),
260            questions,
261        };
262        let wire = serde_json::to_value(&request).unwrap();
263        assert_eq!(wire["questions"]["is_urgent"]["type"], "noul");
264        assert_eq!(
265            wire["questions"]["is_urgent"]["criteria"]["true"],
266            "time-sensitive"
267        );
268        assert!(
269            wire["questions"]["is_urgent"]["criteria"]
270                .get("false")
271                .is_none()
272        );
273    }
274
275    #[test]
276    fn choice_and_score_roundtrip() {
277        let q = DecisionQuestion::Choice {
278            instructions: json!("team?"),
279            criteria: BTreeMap::from([("billing".to_string(), Some("pay".to_string()))]),
280        };
281        let back: DecisionQuestion =
282            serde_json::from_value(serde_json::to_value(&q).unwrap()).unwrap();
283        assert_eq!(q, back);
284
285        let q = DecisionQuestion::Score {
286            instructions: json!("mood?"),
287            criteria: vec!["Calm".into(), "Angry".into()],
288        };
289        let back: DecisionQuestion =
290            serde_json::from_value(serde_json::to_value(&q).unwrap()).unwrap();
291        assert_eq!(q, back);
292    }
293}