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. The questions come from a struct deriving
6//! [`DecisionAnswers`], see [`crate::decision`].
7
8use std::collections::BTreeMap;
9use std::fmt;
10use std::marker::PhantomData;
11
12use ironflow_core::decision::{DecisionModel, DecisionQuestion, DecisionRequest};
13use serde::{Deserialize, Serialize};
14use serde_json::Value;
15
16use crate::decision::DecisionAnswers;
17
18/// The default model route for the System One decision backend.
19pub const DEFAULT_DECISION_MODEL: &str = DecisionModel::LATEST;
20
21/// Marker of a [`DecisionConfig`] whose questions are not set yet: call
22/// [`DecisionConfig::answers`] before handing it to a step.
23#[derive(Debug, Clone, Copy)]
24pub struct NoAnswers;
25
26/// Configuration for a [`decision`](crate::context::WorkflowContext::decision) step.
27///
28/// `T` is the struct the answers are read into. [`new`](DecisionConfig::new)
29/// starts without questions; [`answers`](DecisionConfig::answers) sets them
30/// from `T`.
31///
32/// # Examples
33///
34/// ```
35/// use ironflow_engine::config::DecisionConfig;
36/// use ironflow_engine::decision::{DecisionAnswers, DecisionChoice};
37///
38/// #[derive(DecisionChoice)]
39/// enum Team {
40///     Billing,
41///     Technical,
42/// }
43///
44/// #[derive(DecisionAnswers)]
45/// struct Triage {
46///     #[noul("Does this convey urgency?")]
47///     is_urgent: f64,
48///     #[choice("Which team?")]
49///     team: Team,
50/// }
51///
52/// let config = DecisionConfig::new("Payouts have been failing for 3 days")
53///     .answers::<Triage>()
54///     .escalate_below(0.7);
55///
56/// assert_eq!(config.questions.len(), 2);
57/// assert_eq!(config.escalate_below, Some(0.7));
58/// ```
59#[derive(Serialize, Deserialize)]
60#[serde(bound = "")]
61pub struct DecisionConfig<T = NoAnswers> {
62    /// The state (content) to evaluate.
63    pub state: Value,
64    /// Model route (defaults to [`DEFAULT_DECISION_MODEL`]).
65    #[serde(default)]
66    pub model: DecisionModel,
67    /// Typed questions keyed by name.
68    #[serde(default)]
69    pub questions: BTreeMap<String, DecisionQuestion>,
70    /// Escalate to a human approval gate when any answer's confidence falls
71    /// below this threshold. `None` never escalates.
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub escalate_below: Option<f64>,
74    #[serde(skip)]
75    answers: PhantomData<fn() -> T>,
76}
77
78// Written by hand: a derive would require `T: Debug` / `T: Clone` for a type
79// that is never stored.
80impl<T> fmt::Debug for DecisionConfig<T> {
81    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
82        f.debug_struct("DecisionConfig")
83            .field("state", &self.state)
84            .field("model", &self.model)
85            .field("questions", &self.questions)
86            .field("escalate_below", &self.escalate_below)
87            .finish()
88    }
89}
90
91impl<T> Clone for DecisionConfig<T> {
92    fn clone(&self) -> Self {
93        Self {
94            state: self.state.clone(),
95            model: self.model.clone(),
96            questions: self.questions.clone(),
97            escalate_below: self.escalate_below,
98            answers: PhantomData,
99        }
100    }
101}
102
103impl DecisionConfig {
104    /// Create a config for the given state (any serializable value).
105    ///
106    /// # Examples
107    ///
108    /// ```
109    /// use ironflow_engine::config::DecisionConfig;
110    /// use serde_json::json;
111    ///
112    /// let config = DecisionConfig::new(json!({ "ticket": "outage", "priority": 1 }));
113    /// assert_eq!(config.model, "jev-latest");
114    /// assert!(config.questions.is_empty());
115    /// ```
116    pub fn new(state: impl Serialize) -> Self {
117        Self {
118            state: serde_json::to_value(state).unwrap_or(Value::Null),
119            model: DecisionModel::default(),
120            questions: BTreeMap::new(),
121            escalate_below: None,
122            answers: PhantomData,
123        }
124    }
125
126    /// Ask the questions declared by `T` and read the answers into a `T`.
127    ///
128    /// # Examples
129    ///
130    /// ```
131    /// use ironflow_engine::config::DecisionConfig;
132    /// use ironflow_engine::decision::DecisionAnswers;
133    ///
134    /// #[derive(DecisionAnswers)]
135    /// struct Urgency {
136    ///     #[noul("Is this urgent?")]
137    ///     urgent: f64,
138    /// }
139    ///
140    /// let config = DecisionConfig::new("x").answers::<Urgency>();
141    /// assert!(config.questions.contains_key("urgent"));
142    /// ```
143    pub fn answers<T: DecisionAnswers>(self) -> DecisionConfig<T> {
144        DecisionConfig {
145            state: self.state,
146            model: self.model,
147            questions: T::questions(),
148            escalate_below: self.escalate_below,
149            answers: PhantomData,
150        }
151    }
152}
153
154impl<T> DecisionConfig<T> {
155    /// Override the model route.
156    ///
157    /// # Examples
158    ///
159    /// ```
160    /// use ironflow_engine::config::DecisionConfig;
161    ///
162    /// let config = DecisionConfig::new("x").model("jev-2");
163    /// assert_eq!(config.model, "jev-2");
164    /// ```
165    pub fn model(mut self, model: impl Into<DecisionModel>) -> Self {
166        self.model = model.into();
167        self
168    }
169
170    /// Set the confidence threshold below which the run escalates to a human.
171    ///
172    /// # Examples
173    ///
174    /// ```
175    /// use ironflow_engine::config::DecisionConfig;
176    ///
177    /// let config = DecisionConfig::new("x").escalate_below(0.8);
178    /// assert_eq!(config.escalate_below, Some(0.8));
179    /// ```
180    pub fn escalate_below(mut self, threshold: f64) -> Self {
181        self.escalate_below = Some(threshold);
182        self
183    }
184
185    /// Build the [`DecisionRequest`] sent to the provider.
186    ///
187    /// # Examples
188    ///
189    /// ```
190    /// use ironflow_engine::config::DecisionConfig;
191    ///
192    /// let request = DecisionConfig::new("hello").to_request();
193    /// assert_eq!(request.state, serde_json::json!("hello"));
194    /// ```
195    pub fn to_request(&self) -> DecisionRequest {
196        DecisionRequest {
197            state: self.state.clone(),
198            model: self.model.clone(),
199            questions: self.questions.clone(),
200        }
201    }
202
203    /// The same config, without the answer type. This is the form stored as
204    /// the step input.
205    pub(crate) fn erase(self) -> DecisionConfig {
206        DecisionConfig {
207            state: self.state,
208            model: self.model,
209            questions: self.questions,
210            escalate_below: self.escalate_below,
211            answers: PhantomData,
212        }
213    }
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219    use crate::decision::{DecisionAnswers, DecisionChoice};
220
221    #[derive(DecisionChoice)]
222    enum Pick {
223        X,
224        Y,
225    }
226
227    #[derive(DecisionAnswers)]
228    #[allow(dead_code)]
229    struct Probe {
230        #[noul("yes/no?")]
231        a: f64,
232        #[choice("pick")]
233        b: Pick,
234        #[score("rate", levels = ["low", "high"])]
235        c: f64,
236    }
237
238    #[test]
239    fn answers_assembles_the_questions_of_the_type() {
240        let config = DecisionConfig::new("state")
241            .escalate_below(0.6)
242            .answers::<Probe>();
243        assert_eq!(config.questions.len(), 3);
244        assert_eq!(config.escalate_below, Some(0.6));
245        assert_eq!(config.model, "jev-latest");
246    }
247
248    #[test]
249    fn builders_keep_working_after_answers() {
250        let config = DecisionConfig::new("state")
251            .answers::<Probe>()
252            .model("jev-2")
253            .escalate_below(0.5);
254        assert_eq!(config.model, "jev-2");
255        assert_eq!(config.escalate_below, Some(0.5));
256    }
257
258    #[test]
259    fn to_request_carries_state_and_questions() {
260        let request = DecisionConfig::new("hello").answers::<Probe>().to_request();
261        assert_eq!(request.state, serde_json::json!("hello"));
262        assert_eq!(request.questions.len(), 3);
263    }
264
265    #[test]
266    fn erase_keeps_everything_but_the_type() {
267        let typed = DecisionConfig::new("s")
268            .answers::<Probe>()
269            .escalate_below(0.4);
270        let questions = typed.questions.clone();
271        let erased = typed.erase();
272        assert_eq!(erased.questions, questions);
273        assert_eq!(erased.escalate_below, Some(0.4));
274    }
275
276    #[test]
277    fn decision_config_serde_roundtrip() {
278        let config = DecisionConfig::new("s")
279            .answers::<Probe>()
280            .escalate_below(0.5);
281        let json = serde_json::to_string(&config).unwrap();
282        assert!(!json.contains("answers"));
283        let back: DecisionConfig = serde_json::from_str(&json).unwrap();
284        assert_eq!(back.questions.len(), 3);
285        assert_eq!(back.escalate_below, Some(0.5));
286    }
287
288    #[test]
289    fn model_defaults_when_missing_in_json() {
290        let config: DecisionConfig = serde_json::from_str(r#"{"state":"s"}"#).unwrap();
291        assert_eq!(config.model, "jev-latest");
292        assert!(config.questions.is_empty());
293    }
294}