ironflow_engine/config/decision.rs
1//! [`DecisionConfig`] -- configuration for a typed machine-decision step.
2//!
3//! Builds a [`DecisionRequest`] for a [`DecisionProvider`](ironflow_core::decision::DecisionProvider)
4//! and carries the escalation threshold used to route low-confidence answers to a
5//! human approval gate.
6
7use std::collections::BTreeMap;
8
9use ironflow_core::decision::{DecisionModel, DecisionQuestion, DecisionRequest, NoulCriteria};
10use serde::{Deserialize, Serialize};
11use serde_json::Value;
12
13/// The default model route for the System One decision backend.
14pub const DEFAULT_DECISION_MODEL: &str = DecisionModel::LATEST;
15
16/// Configuration for a [`decision`](crate::context::WorkflowContext::decision) step.
17///
18/// # Examples
19///
20/// ```
21/// use ironflow_engine::config::DecisionConfig;
22///
23/// let config = DecisionConfig::new("Payouts have been failing for 3 days")
24/// .noul("is_urgent", "Does this convey urgency?")
25/// .choice("department", "Which team?", &["billing", "technical", "sales"])
26/// .score("frustration", "How frustrated is the customer?", &["Calm", "Frustrated", "Very angry"])
27/// .escalate_below(0.7);
28///
29/// assert_eq!(config.questions.len(), 3);
30/// assert_eq!(config.escalate_below, Some(0.7));
31/// ```
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct DecisionConfig {
34 /// The state (content) to evaluate.
35 pub state: Value,
36 /// Model route (defaults to [`DEFAULT_DECISION_MODEL`]).
37 #[serde(default)]
38 pub model: DecisionModel,
39 /// Typed questions keyed by name.
40 #[serde(default)]
41 pub questions: BTreeMap<String, DecisionQuestion>,
42 /// Escalate to a human approval gate when any answer's confidence falls
43 /// below this threshold. `None` never escalates.
44 #[serde(default, skip_serializing_if = "Option::is_none")]
45 pub escalate_below: Option<f64>,
46}
47
48impl DecisionConfig {
49 /// Create a config for the given state (any serializable value).
50 ///
51 /// # Examples
52 ///
53 /// ```
54 /// use ironflow_engine::config::DecisionConfig;
55 /// use serde_json::json;
56 ///
57 /// let config = DecisionConfig::new(json!({ "ticket": "outage", "priority": 1 }));
58 /// assert_eq!(config.model, "jev-latest");
59 /// ```
60 pub fn new(state: impl Serialize) -> Self {
61 Self {
62 state: serde_json::to_value(state).unwrap_or(Value::Null),
63 model: DecisionModel::default(),
64 questions: BTreeMap::new(),
65 escalate_below: None,
66 }
67 }
68
69 /// Override the model route.
70 ///
71 /// # Examples
72 ///
73 /// ```
74 /// use ironflow_engine::config::DecisionConfig;
75 ///
76 /// let config = DecisionConfig::new("x").model("jev-2");
77 /// assert_eq!(config.model, "jev-2");
78 /// ```
79 pub fn model(mut self, model: impl Into<DecisionModel>) -> Self {
80 self.model = model.into();
81 self
82 }
83
84 /// Add a yes/no question. The answer is the probability of "yes".
85 ///
86 /// # Examples
87 ///
88 /// ```
89 /// use ironflow_engine::config::DecisionConfig;
90 ///
91 /// let config = DecisionConfig::new("x").noul("urgent", "Is this urgent?");
92 /// assert!(config.questions.contains_key("urgent"));
93 /// ```
94 pub fn noul(self, name: &str, instructions: impl Serialize) -> Self {
95 self.noul_with(name, instructions, NoulCriteria::default())
96 }
97
98 /// Add a yes/no question with explicit true/false criteria.
99 ///
100 /// # Examples
101 ///
102 /// ```
103 /// use ironflow_engine::config::DecisionConfig;
104 /// use ironflow_core::decision::NoulCriteria;
105 ///
106 /// let config = DecisionConfig::new("x").noul_with(
107 /// "urgent",
108 /// "Is this urgent?",
109 /// NoulCriteria { if_true: Some("time-sensitive".into()), if_false: None },
110 /// );
111 /// assert!(config.questions.contains_key("urgent"));
112 /// ```
113 pub fn noul_with(
114 mut self,
115 name: &str,
116 instructions: impl Serialize,
117 criteria: NoulCriteria,
118 ) -> Self {
119 self.questions.insert(
120 name.to_string(),
121 DecisionQuestion::Noul {
122 instructions: to_value(instructions),
123 criteria,
124 },
125 );
126 self
127 }
128
129 /// Add a selection among named options (no per-option descriptions).
130 ///
131 /// # Examples
132 ///
133 /// ```
134 /// use ironflow_engine::config::DecisionConfig;
135 ///
136 /// let config = DecisionConfig::new("x").choice("team", "Which team?", &["billing", "tech"]);
137 /// assert!(config.questions.contains_key("team"));
138 /// ```
139 pub fn choice(self, name: &str, instructions: impl Serialize, options: &[&str]) -> Self {
140 let described: Vec<(&str, Option<&str>)> = options.iter().map(|o| (*o, None)).collect();
141 self.choice_described(name, instructions, &described)
142 }
143
144 /// Add a selection among named options, each with an optional description.
145 ///
146 /// # Examples
147 ///
148 /// ```
149 /// use ironflow_engine::config::DecisionConfig;
150 ///
151 /// let config = DecisionConfig::new("x").choice_described(
152 /// "team",
153 /// "Which team?",
154 /// &[("billing", Some("payments")), ("tech", Some("bugs"))],
155 /// );
156 /// assert!(config.questions.contains_key("team"));
157 /// ```
158 pub fn choice_described(
159 mut self,
160 name: &str,
161 instructions: impl Serialize,
162 options: &[(&str, Option<&str>)],
163 ) -> Self {
164 let criteria = options
165 .iter()
166 .map(|(label, desc)| (label.to_string(), desc.map(str::to_string)))
167 .collect();
168 self.questions.insert(
169 name.to_string(),
170 DecisionQuestion::Choice {
171 instructions: to_value(instructions),
172 criteria,
173 },
174 );
175 self
176 }
177
178 /// Add a rating against ordered levels (index 0..N-1).
179 ///
180 /// # Examples
181 ///
182 /// ```
183 /// use ironflow_engine::config::DecisionConfig;
184 ///
185 /// let config = DecisionConfig::new("x").score("mood", "How angry?", &["Calm", "Angry"]);
186 /// assert!(config.questions.contains_key("mood"));
187 /// ```
188 pub fn score(mut self, name: &str, instructions: impl Serialize, levels: &[&str]) -> Self {
189 self.questions.insert(
190 name.to_string(),
191 DecisionQuestion::Score {
192 instructions: to_value(instructions),
193 criteria: levels.iter().map(|l| l.to_string()).collect(),
194 },
195 );
196 self
197 }
198
199 /// Set the confidence threshold below which the run escalates to a human.
200 ///
201 /// # Examples
202 ///
203 /// ```
204 /// use ironflow_engine::config::DecisionConfig;
205 ///
206 /// let config = DecisionConfig::new("x").escalate_below(0.8);
207 /// assert_eq!(config.escalate_below, Some(0.8));
208 /// ```
209 pub fn escalate_below(mut self, threshold: f64) -> Self {
210 self.escalate_below = Some(threshold);
211 self
212 }
213
214 /// Build the [`DecisionRequest`] sent to the provider.
215 pub fn to_request(&self) -> DecisionRequest {
216 DecisionRequest {
217 state: self.state.clone(),
218 model: self.model.clone(),
219 questions: self.questions.clone(),
220 }
221 }
222}
223
224fn to_value(value: impl Serialize) -> Value {
225 serde_json::to_value(value).unwrap_or(Value::Null)
226}
227
228#[cfg(test)]
229mod tests {
230 use super::*;
231
232 #[test]
233 fn builder_assembles_questions() {
234 let config = DecisionConfig::new("state")
235 .noul("a", "yes/no?")
236 .choice("b", "pick", &["x", "y"])
237 .score("c", "rate", &["low", "high"])
238 .escalate_below(0.6);
239 assert_eq!(config.questions.len(), 3);
240 assert_eq!(config.escalate_below, Some(0.6));
241 assert_eq!(config.model, "jev-latest");
242 }
243
244 #[test]
245 fn to_request_carries_state_and_questions() {
246 let config = DecisionConfig::new("hello").noul("a", "?");
247 let request = config.to_request();
248 assert_eq!(request.state, serde_json::json!("hello"));
249 assert_eq!(request.questions.len(), 1);
250 }
251
252 #[test]
253 fn decision_config_serde_roundtrip() {
254 let config = DecisionConfig::new("s")
255 .noul("a", "?")
256 .choice("b", "?", &["x"])
257 .escalate_below(0.5);
258 let json = serde_json::to_string(&config).unwrap();
259 let back: DecisionConfig = serde_json::from_str(&json).unwrap();
260 assert_eq!(back.questions.len(), 2);
261 assert_eq!(back.escalate_below, Some(0.5));
262 }
263
264 #[test]
265 fn model_defaults_when_missing_in_json() {
266 let config: DecisionConfig = serde_json::from_str(r#"{"state":"s"}"#).unwrap();
267 assert_eq!(config.model, "jev-latest");
268 assert!(config.questions.is_empty());
269 }
270}