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}