1use 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
37pub struct DecisionSpec {
38 #[serde(flatten)]
40 pub kind: DecisionKind,
41 #[serde(default, skip_serializing_if = "Option::is_none")]
45 pub options_from: Option<String>,
46 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub none: Option<String>,
51 #[serde(default, skip_serializing_if = "Option::is_none")]
54 pub at_least: Option<f64>,
55 #[serde(default, skip_serializing_if = "Option::is_none")]
60 pub writes: Option<String>,
61}
62
63#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
65#[serde(tag = "type", rename_all = "lowercase")]
66pub enum DecisionKind {
67 Boolean {
69 instructions: String,
72 #[serde(default, skip_serializing_if = "Option::is_none")]
74 criteria: Option<BooleanCriteriaSpec>,
75 },
76 Choice {
78 instructions: String,
80 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
83 criteria: BTreeMap<String, String>,
84 },
85 Score {
87 instructions: String,
89 criteria: Vec<String>,
91 },
92}
93
94#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
96pub struct BooleanCriteriaSpec {
97 #[serde(rename = "true")]
99 pub when_true: String,
100 #[serde(rename = "false")]
102 pub when_false: String,
103}
104
105impl DecisionSpec {
106 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 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 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 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 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
224fn 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 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 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 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}