Skip to main content

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}