Skip to main content

gemini_adk_fluent_rs/spec/
decisions.rs

1//! `decisions`: the questions a decision model answers about the
2//! conversation, named by `decided` guards.
3//!
4//! ```json
5//! "decisions": {
6//!   "confirmed": {
7//!     "type": "boolean",
8//!     "instructions": "In their last turn, did the caller agree to the booking that was read back?",
9//!     "criteria": { "true": "the caller said yes in their own words",
10//!                   "false": "they hesitated, changed a detail, or only picked an option" }
11//!   },
12//!   "picked_slot": {
13//!     "type": "choice",
14//!     "instructions": "Which of the offered times did the caller choose?",
15//!     "options_from": "availability.slots",
16//!     "none": "the caller has not chosen one of the offered times",
17//!     "writes": "slot"
18//!   }
19//! },
20//! "conversation": { "stages": [ { "id": "confirm", "commit": { "tool": "book", "when": { "decided": "confirmed" } } } ] }
21//! ```
22//!
23//! Lowered to a [`Decisions`] bank over [`SpecResources::decision_model`](super::SpecResources::decision_model).
24
25use std::collections::{BTreeMap, BTreeSet};
26use std::sync::Arc;
27
28use serde::{Deserialize, Serialize};
29
30use gemini_adk_rs::decision::{BooleanCriteria, Decision, DecisionModel, Decisions, Question};
31use gemini_adk_rs::flow::{Decided, Expect, Guard, Pred};
32
33use super::SessionSpec;
34
35/// One question a decision model answers about the conversation.
36#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
37pub struct DecisionSpec {
38    /// The question and its options.
39    #[serde(flatten)]
40    pub kind: DecisionKind,
41    /// For a choice: the state path (`key` or `key.field`) holding an array
42    /// of options, read at each ask, such as the slots a tool offered. An
43    /// object option is keyed by its `id`, `value` or `name`.
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub options_from: Option<String>,
46    /// For a choice: an extra option, `none_of_these`, with this
47    /// description. A choice always picks something, so "which offered time
48    /// did the caller pick?" needs a way to say they have not.
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub none: Option<String>,
51    /// Boolean: the probability needed for yes (and `1 -` this for no,
52    /// default 0.85). Choice and score: the certainty needed (default 0.6).
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub at_least: Option<f64>,
55    /// Write the decided value into this state key: the chosen option (an
56    /// object option whole), the score, or the boolean. The question is then
57    /// also asked whenever an active guard reads the key, so a stage that
58    /// collects it is filled by the pick.
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub writes: Option<String>,
61}
62
63/// The question types a decision model answers.
64#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
65#[serde(tag = "type", rename_all = "lowercase")]
66pub enum DecisionKind {
67    /// Answered with the probability that it is true.
68    Boolean {
69        /// The question. Ask about "their last turn" for a confirmation or an
70        /// intent, so an earlier answer is not read again.
71        instructions: String,
72        /// What the true and false cases mean.
73        #[serde(default, skip_serializing_if = "Option::is_none")]
74        criteria: Option<BooleanCriteriaSpec>,
75    },
76    /// Answered with one option.
77    Choice {
78        /// The question.
79        instructions: String,
80        /// Option name to description (1 to 255). May be empty with
81        /// `options_from`.
82        #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
83        criteria: BTreeMap<String, String>,
84    },
85    /// Answered with a position on an ordered scale, from 0.
86    Score {
87        /// The question.
88        instructions: String,
89        /// Level descriptions, lowest first (2 to 10).
90        criteria: Vec<String>,
91    },
92}
93
94/// What the true and false cases of a boolean question mean.
95#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
96pub struct BooleanCriteriaSpec {
97    /// When it is true.
98    #[serde(rename = "true")]
99    pub when_true: String,
100    /// When it is false.
101    #[serde(rename = "false")]
102    pub when_false: String,
103}
104
105impl DecisionSpec {
106    /// The question as the decision model takes it.
107    pub fn question(&self) -> Question {
108        match &self.kind {
109            DecisionKind::Boolean {
110                instructions,
111                criteria,
112            } => Question::Boolean {
113                instructions: instructions.clone(),
114                criteria: criteria.as_ref().map(|c| BooleanCriteria {
115                    when_true: c.when_true.clone(),
116                    when_false: c.when_false.clone(),
117                }),
118            },
119            DecisionKind::Choice {
120                instructions,
121                criteria,
122            } => Question::Choice {
123                instructions: instructions.clone(),
124                criteria: criteria.clone(),
125            },
126            DecisionKind::Score {
127                instructions,
128                criteria,
129            } => Question::Score {
130                instructions: instructions.clone(),
131                criteria: criteria.clone(),
132            },
133        }
134    }
135
136    /// The runtime form.
137    pub fn compile(&self) -> Decision {
138        let mut d = Decision::new(self.question());
139        d.options_from.clone_from(&self.options_from);
140        d.none.clone_from(&self.none);
141        d.at_least = self.at_least;
142        d.writes.clone_from(&self.writes);
143        d
144    }
145
146    /// Problems with this question, prefixed with its id.
147    pub fn problems(&self, id: &str) -> Vec<String> {
148        let mut out = Vec::new();
149        let at = |m: &str| format!("decision '{id}': {m}");
150        let mut question = self.question();
151        // Options read from state are filled in at each ask.
152        if let (Some(_), Question::Choice { criteria, .. }) = (&self.options_from, &mut question)
153            && criteria.is_empty()
154        {
155            criteria.insert("_".into(), "_".into());
156        }
157        if let Err(e) = question.validate() {
158            out.push(at(&e));
159        }
160        let choice = matches!(self.kind, DecisionKind::Choice { .. });
161        if self.options_from.is_some() && !choice {
162            out.push(at("options_from applies to choice questions only"));
163        }
164        if self.none.is_some() && !choice {
165            out.push(at("none applies to choice questions only"));
166        }
167        if let Some(t) = self.at_least {
168            let boolean = matches!(self.kind, DecisionKind::Boolean { .. });
169            let ok = if boolean {
170                t > 0.5 && t <= 1.0
171            } else {
172                t > 0.0 && t <= 1.0
173            };
174            if !ok {
175                out.push(at(&format!(
176                    "at_least {t} must be {}",
177                    if boolean {
178                        "above 0.5 and at most 1"
179                    } else {
180                        "above 0 and at most 1"
181                    }
182                )));
183            }
184        }
185        if self.writes.as_deref().is_some_and(|k| k.trim().is_empty()) {
186            out.push(at("writes is empty"));
187        }
188        out
189    }
190
191    /// Whether `expect` fits this question's type, and the option exists
192    /// when the choice's options are fixed.
193    fn accepts(&self, expect: &Expect) -> Result<(), String> {
194        match (&self.kind, expect) {
195            (_, Expect::Yes) => Ok(()),
196            (DecisionKind::Boolean { .. }, Expect::No) => Ok(()),
197            (DecisionKind::Choice { .. }, Expect::No) if self.none.is_some() => Ok(()),
198            (DecisionKind::Choice { criteria, .. }, Expect::Is(option)) => {
199                if self.options_from.is_some() || criteria.contains_key(option) {
200                    Ok(())
201                } else {
202                    Err(format!(
203                        "'{option}' is not one of its options ({})",
204                        criteria.keys().cloned().collect::<Vec<_>>().join(", ")
205                    ))
206                }
207            }
208            (DecisionKind::Score { .. }, Expect::AtLeast(_) | Expect::AtMost(_)) => Ok(()),
209            (DecisionKind::Choice { .. }, Expect::No) => {
210                Err("false means \"none of these\", which needs a `none` option".into())
211            }
212            (kind, expect) => Err(format!(
213                "a {} question cannot be expected to be {expect:?}",
214                match kind {
215                    DecisionKind::Boolean { .. } => "boolean",
216                    DecisionKind::Choice { .. } => "choice",
217                    DecisionKind::Score { .. } => "score",
218                }
219            )),
220        }
221    }
222}
223
224/// Every `decided` atom in a predicate.
225fn atoms(pred: &Pred, out: &mut Vec<Decided>) {
226    match pred {
227        Pred::Decided(d) => out.push(d.clone()),
228        Pred::All(ps) | Pred::Any(ps) => ps.iter().for_each(|p| atoms(p, out)),
229        Pred::Not(p) => atoms(p, out),
230        _ => {}
231    }
232}
233
234fn guard_atoms(guard: &Guard, out: &mut Vec<Decided>) {
235    if let Guard::Spec(p) = guard {
236        atoms(p, out);
237    }
238}
239
240impl SessionSpec {
241    /// Every `decided` atom in the spec, with where it is, and whether that
242    /// place is read outside a flow (phase transitions and patterns), where
243    /// no flow names the question to ask.
244    pub(crate) fn decided_atoms(&self) -> Vec<(String, Decided, bool)> {
245        let mut out = Vec::new();
246        let mut push = |place: String, g: &Guard, standing: bool| {
247            let mut found = Vec::new();
248            guard_atoms(g, &mut found);
249            out.extend(found.into_iter().map(|d| (place.clone(), d, standing)));
250        };
251        if let Some(c) = &self.conversation {
252            let stages = c
253                .stages
254                .iter()
255                .map(|s| ("conversation".to_string(), s))
256                .chain(c.overlays.iter().flat_map(|o| {
257                    o.stages
258                        .iter()
259                        .map(move |s| (format!("digression '{}'", o.name), s))
260                }));
261            for (layer, s) in stages {
262                if let Some(g) = &s.done {
263                    push(format!("{layer} stage '{}' done", s.id), g, false);
264                }
265                if let Some(commit) = &s.commit {
266                    push(
267                        format!("{layer} stage '{}' commit", s.id),
268                        &commit.when,
269                        false,
270                    );
271                }
272                for n in &s.next {
273                    push(format!("{layer} stage '{}' next", s.id), &n.when, false);
274                }
275            }
276            for o in &c.overlays {
277                push(
278                    format!("digression '{}' trigger", o.name),
279                    &o.trigger,
280                    false,
281                );
282            }
283        }
284        if let Some(flow) = &self.flow {
285            for step in &flow.steps {
286                for g in step.gate.iter().chain(step.done.iter()) {
287                    push(format!("flow step '{}'", step.id), g, false);
288                }
289                for e in &step.after {
290                    if let Some(w) = &e.when {
291                        push(format!("flow step '{}' edge", step.id), w, false);
292                    }
293                }
294            }
295            for c in &flow.constraints {
296                if let gemini_adk_rs::flow::Constraint::NeverUntil { tool, until } = c {
297                    push(format!("flow constraint on '{tool}'"), until, false);
298                }
299            }
300        }
301        for p in &self.phases {
302            for t in &p.transitions {
303                push(format!("phase '{}' transition", p.name), &t.when, true);
304            }
305        }
306        for p in &self.patterns {
307            push(format!("pattern '{}'", p.name), &p.when, true);
308        }
309        out
310    }
311
312    /// Problems with the `decisions` bank and the `decided` atoms naming it.
313    pub(crate) fn decision_problems(&self) -> (Vec<String>, Vec<String>) {
314        let mut errors = Vec::new();
315        let mut warnings = Vec::new();
316        for (id, spec) in &self.decisions {
317            errors.extend(spec.problems(id));
318        }
319        let mut named = BTreeSet::new();
320        for (place, atom, _) in self.decided_atoms() {
321            named.insert(atom.question.clone());
322            match self.decisions.get(&atom.question) {
323                None => errors.push(format!(
324                    "{place}: decided '{}' names no question in `decisions`",
325                    atom.question
326                )),
327                Some(spec) => {
328                    if let Err(e) = spec.accepts(&atom.expect) {
329                        errors.push(format!("{place}: decided '{}': {e}", atom.question));
330                    }
331                }
332            }
333        }
334        for (id, spec) in &self.decisions {
335            if !named.contains(id) && spec.writes.is_none() {
336                warnings.push(format!(
337                    "decision '{id}' is never asked: no guard names it and it writes nothing"
338                ));
339            }
340        }
341        (errors, warnings)
342    }
343
344    /// The `decisions` bank as the runtime's [`Decisions`], over `model`.
345    /// Questions read outside a flow (phase transitions, patterns) are
346    /// standing: asked at every caller turn.
347    pub fn compile_decisions(&self, model: Arc<dyn DecisionModel>) -> Decisions {
348        let standing: BTreeSet<String> = self
349            .decided_atoms()
350            .into_iter()
351            .filter(|(_, _, standing)| *standing)
352            .map(|(_, d, _)| d.question)
353            .collect();
354        self.decisions
355            .iter()
356            .fold(Decisions::new(model), |d, (id, spec)| {
357                d.question(id.clone(), spec.compile())
358            })
359            .standing(standing)
360    }
361}
362
363#[cfg(test)]
364mod tests {
365    use super::*;
366    use serde_json::json;
367
368    fn spec(decisions: serde_json::Value, stages: serde_json::Value) -> SessionSpec {
369        SessionSpec::from_value(json!({
370            "name": "t",
371            "instruction": "Book tables.",
372            "tools": [{ "name": "book" }, { "name": "transfer" }],
373            "decisions": decisions,
374            "conversation": { "name": "c", "stages": stages,
375                "overlays": [{ "name": "handoff", "trigger": { "decided": "wants_person" },
376                               "stages": [{ "id": "transfer", "allow": ["transfer"],
377                                            "done": { "called_ok": "transfer" } }],
378                               "resume": "terminate" }] }
379        }))
380        .expect("parses")
381    }
382
383    fn bank() -> serde_json::Value {
384        json!({
385            "confirmed": { "type": "boolean", "instructions": "Agreed?",
386                           "criteria": { "true": "said yes", "false": "anything else" } },
387            "wants_person": { "type": "boolean", "instructions": "Asked for a person?" },
388            "next_step": { "type": "choice", "instructions": "Next?",
389                           "criteria": { "book": "confirmed", "stay": "not yet" } },
390            "picked": { "type": "choice", "instructions": "Which time?",
391                        "options_from": "availability.options", "none": "not picked",
392                        "writes": "slot" },
393            "anger": { "type": "score", "instructions": "How angry?",
394                       "criteria": ["calm", "upset", "angry"] }
395        })
396    }
397
398    fn stages() -> serde_json::Value {
399        json!([
400            { "id": "collect", "collect": ["slot"] },
401            { "id": "confirm", "after": ["collect"],
402              "commit": { "tool": "book", "when": { "decided": "confirmed" } },
403              "next": [{ "to": "end", "when": { "decided": { "next_step": "book" } } }] },
404            { "id": "end", "after": ["confirm"], "terminal": true }
405        ])
406    }
407
408    #[test]
409    fn a_bank_and_its_atoms_validate() {
410        let s = spec(bank(), stages());
411        let (errors, warnings) = s.decision_problems();
412        assert!(errors.is_empty(), "{errors:?}");
413        assert_eq!(
414            warnings,
415            ["decision 'anger' is never asked: no guard names it and it writes nothing"]
416        );
417        let back = serde_json::to_value(&s).unwrap();
418        assert_eq!(back["decisions"]["picked"]["writes"], "slot");
419        assert_eq!(
420            back["conversation"]["stages"][1]["commit"]["when"],
421            json!({ "decided": "confirmed" })
422        );
423    }
424
425    #[test]
426    fn atoms_are_checked_against_the_bank() {
427        let s = spec(
428            bank(),
429            json!([
430                { "id": "a", "done": { "decided": "missing" } },
431                { "id": "b", "after": ["a"], "done": { "decided": { "next_step": "fly" } } },
432                { "id": "c", "after": ["b"], "done": { "decided": { "anger": "high" } } },
433                { "id": "d", "after": ["c"], "done": { "decided": { "next_step": false } } },
434                { "id": "e", "after": ["d"], "terminal": true, "done": { "decided": { "anger": { "at_least": 1 } } } }
435            ]),
436        );
437        let (errors, _) = s.decision_problems();
438        let joined = errors.join("\n");
439        assert!(
440            joined.contains("decided 'missing' names no question"),
441            "{joined}"
442        );
443        assert!(
444            joined.contains("'fly' is not one of its options"),
445            "{joined}"
446        );
447        assert!(
448            joined.contains("a score question cannot be expected"),
449            "{joined}"
450        );
451        assert!(joined.contains("needs a `none` option"), "{joined}");
452        assert_eq!(errors.len(), 4, "{joined}");
453    }
454
455    #[test]
456    fn question_problems_are_reported() {
457        let mut b = bank();
458        b["anger"]["criteria"] = json!(["only one"]);
459        b["confirmed"]["at_least"] = json!(0.4);
460        b["wants_person"]["options_from"] = json!("x");
461        let (errors, _) = spec(b, stages()).decision_problems();
462        let joined = errors.join("\n");
463        assert!(
464            joined.contains("score question takes 2 to 10 levels"),
465            "{joined}"
466        );
467        assert!(
468            joined.contains("at_least 0.4 must be above 0.5"),
469            "{joined}"
470        );
471        assert!(
472            joined.contains("options_from applies to choice"),
473            "{joined}"
474        );
475    }
476
477    #[test]
478    fn questions_read_outside_a_flow_are_standing() {
479        let s = SessionSpec::from_value(json!({
480            "name": "t",
481            "instruction": "x",
482            "decisions": { "angry": { "type": "boolean", "instructions": "Angry?" } },
483            "phases": [{ "name": "main",
484                         "transitions": [{ "to": "calm", "when": { "decided": "angry" } }] },
485                       { "name": "calm" }],
486            "initial_phase": "main"
487        }))
488        .unwrap();
489        let model = Arc::new(gemini_adk_rs::decision::MockDecisionModel::new(|_| {
490            Ok(Default::default())
491        }));
492        let d = s.compile_decisions(model);
493        let scope = gemini_adk_rs::flow::DecisionScope::default();
494        assert_eq!(d.select(&scope), ["angry".to_string()].into());
495    }
496}