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}