Skip to main content

ironflow_engine/
decision.rs

1//! Typed decisions: questions declared as a struct, answers read back into it.
2//!
3//! [`DecisionAnswers`] turns a struct into the questions of a
4//! [`decision`](crate::context::WorkflowContext::decision) step, one question
5//! per field, and reads the provider's answers back into it.
6//! [`DecisionChoice`] lists the options of a choice question from the unit
7//! variants of an enum. Both are derived:
8//!
9//! ```no_run
10//! use ironflow_engine::config::DecisionConfig;
11//! use ironflow_engine::context::WorkflowContext;
12//! use ironflow_engine::decision::{DecisionAnswers, DecisionChoice};
13//! use ironflow_engine::error::EngineError;
14//!
15//! #[derive(Debug, DecisionChoice)]
16//! enum Team {
17//!     #[choice(description = "Payments and invoices")]
18//!     Billing,
19//!     Technical,
20//!     Sales,
21//! }
22//!
23//! #[derive(Debug, DecisionAnswers)]
24//! struct Triage {
25//!     #[noul("Does this convey urgency?")]
26//!     is_urgent: f64,
27//!     #[choice("Which team should handle this?")]
28//!     team: Team,
29//!     #[score("How frustrated is the customer?", levels = ["Calm", "Frustrated", "Very angry"])]
30//!     mood: f64,
31//! }
32//!
33//! # async fn example(ctx: &mut WorkflowContext, ticket: &str) -> Result<(), EngineError> {
34//! let triage = ctx
35//!     .decision("triage", DecisionConfig::new(ticket).answers::<Triage>().escalate_below(0.7))
36//!     .await?;
37//! match triage.team {
38//!     Team::Billing => { /* .. */ }
39//!     Team::Technical | Team::Sales => { /* .. */ }
40//! }
41//! # Ok(())
42//! # }
43//! ```
44//!
45//! # Field attributes of `#[derive(DecisionAnswers)]`
46//!
47//! Every field is one question, named after the field, with exactly one of:
48//!
49//! | Attribute | Field type | Answer |
50//! |-----------|------------|--------|
51//! | `#[noul("..")]`, optionally `if_true = ".."`, `if_false = ".."` | `f64` | Probability of "yes", in `[0, 1]` |
52//! | `#[choice("..")]` | an enum deriving [`DecisionChoice`] | The option picked |
53//! | `#[score("..", levels = ["..", ".."])]` | `f64` | Probability-weighted level index |
54//!
55//! The first argument is the instruction sent to the model. A field without a
56//! question does not compile:
57//!
58//! ```compile_fail
59//! use ironflow_engine::decision::DecisionAnswers;
60//!
61//! #[derive(DecisionAnswers)]
62//! struct Triage {
63//!     is_urgent: f64,
64//! }
65//! ```
66//!
67//! nor does a score without levels:
68//!
69//! ```compile_fail
70//! use ironflow_engine::decision::DecisionAnswers;
71//!
72//! #[derive(DecisionAnswers)]
73//! struct Triage {
74//!     #[score("How frustrated?")]
75//!     mood: f64,
76//! }
77//! ```
78//!
79//! nor a field whose type does not fit its question:
80//!
81//! ```compile_fail,E0308
82//! use ironflow_engine::decision::DecisionAnswers;
83//!
84//! #[derive(DecisionAnswers)]
85//! struct Triage {
86//!     #[noul("Does this convey urgency?")]
87//!     is_urgent: String,
88//! }
89//! ```
90//!
91//! # Variant attributes of `#[derive(DecisionChoice)]`
92//!
93//! An option is labelled with its variant name in `snake_case` (`OnCall` is
94//! `on_call`). `#[choice(rename = "..")]` overrides the label,
95//! `#[choice(description = "..")]` tells the model what the option means. Doc
96//! comments are never sent to the model. Options are unit variants:
97//!
98//! ```compile_fail
99//! use ironflow_engine::decision::DecisionChoice;
100//!
101//! #[derive(DecisionChoice)]
102//! enum Team {
103//!     Billing,
104//!     Other(String),
105//! }
106//! ```
107
108use std::collections::BTreeMap;
109
110use ironflow_core::decision::{DecisionOutput, DecisionQuestion};
111use ironflow_core::error::DecisionError;
112
113pub use ironflow_engine_macros::{DecisionAnswers, DecisionChoice};
114
115/// A struct whose fields are the questions of a decision step and whose
116/// values are the answers.
117///
118/// Derive it, see the [module documentation](self).
119///
120/// # Examples
121///
122/// ```
123/// use ironflow_engine::decision::DecisionAnswers;
124///
125/// #[derive(DecisionAnswers)]
126/// struct Review {
127///     #[noul("Is the change safe to ship?")]
128///     safe: f64,
129/// }
130///
131/// assert!(Review::questions().contains_key("safe"));
132/// ```
133pub trait DecisionAnswers: Sized {
134    /// The questions, keyed by field name.
135    fn questions() -> BTreeMap<String, DecisionQuestion>;
136
137    /// Read the provider's answers back into the struct.
138    ///
139    /// # Errors
140    ///
141    /// Returns [`DecisionError::NotFound`] when an answer is missing,
142    /// [`DecisionError::TypeMismatch`] when it is of another kind than its
143    /// question, and [`DecisionError::UnknownChoice`] when a choice is not one
144    /// of the options.
145    fn from_output(output: &DecisionOutput) -> Result<Self, DecisionError>;
146}
147
148/// An enum whose unit variants are the options of a choice question.
149///
150/// Derive it, see the [module documentation](self).
151///
152/// # Examples
153///
154/// ```
155/// use ironflow_engine::decision::DecisionChoice;
156///
157/// #[derive(Debug, PartialEq, DecisionChoice)]
158/// enum Severity {
159///     Low,
160///     #[choice(description = "Customers are affected")]
161///     High,
162/// }
163///
164/// assert_eq!(Severity::options(), vec![("low", None), ("high", Some("Customers are affected"))]);
165/// assert_eq!(Severity::from_label("high"), Some(Severity::High));
166/// assert_eq!(Severity::Low.label(), "low");
167/// ```
168pub trait DecisionChoice: Sized {
169    /// Every option label with its optional description, in declaration order.
170    fn options() -> Vec<(&'static str, Option<&'static str>)>;
171
172    /// The variant labelled `label`, if any.
173    fn from_label(label: &str) -> Option<Self>;
174
175    /// The label of this variant.
176    fn label(&self) -> &'static str;
177}
178
179/// Support code for the derives. Not a public API.
180#[doc(hidden)]
181pub mod __private {
182    use std::collections::BTreeMap;
183
184    use ironflow_core::decision::NoulCriteria;
185    use serde_json::Value;
186
187    pub use ironflow_core::decision::{DecisionOutput, DecisionQuestion};
188    pub use ironflow_core::error::DecisionError;
189
190    use super::DecisionChoice;
191
192    /// Questions keyed by name.
193    pub type Questions = BTreeMap<String, DecisionQuestion>;
194
195    pub fn noul(
196        instructions: &str,
197        if_true: Option<&str>,
198        if_false: Option<&str>,
199    ) -> DecisionQuestion {
200        DecisionQuestion::Noul {
201            instructions: Value::String(instructions.to_string()),
202            criteria: NoulCriteria {
203                if_true: if_true.map(str::to_string),
204                if_false: if_false.map(str::to_string),
205            },
206        }
207    }
208
209    pub fn choice<C: DecisionChoice>(instructions: &str) -> DecisionQuestion {
210        DecisionQuestion::Choice {
211            instructions: Value::String(instructions.to_string()),
212            criteria: C::options()
213                .into_iter()
214                .map(|(label, description)| (label.to_string(), description.map(str::to_string)))
215                .collect(),
216        }
217    }
218
219    pub fn score(instructions: &str, levels: &[&str]) -> DecisionQuestion {
220        DecisionQuestion::Score {
221            instructions: Value::String(instructions.to_string()),
222            criteria: levels.iter().map(|level| level.to_string()).collect(),
223        }
224    }
225
226    pub fn read_noul(output: &DecisionOutput, name: &str) -> Result<f64, DecisionError> {
227        output.noul(name)
228    }
229
230    pub fn read_choice<C: DecisionChoice>(
231        output: &DecisionOutput,
232        name: &str,
233    ) -> Result<C, DecisionError> {
234        let picked = &output.choice(name)?.choice;
235        C::from_label(picked).ok_or_else(|| DecisionError::UnknownChoice {
236            name: name.to_string(),
237            choice: picked.clone(),
238        })
239    }
240
241    pub fn read_score(output: &DecisionOutput, name: &str) -> Result<f64, DecisionError> {
242        Ok(output.score(name)?.score)
243    }
244}