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}