Skip to main content

turnframe_runtime/narrate/
outcome.rs

1//! What a turn did, gathered by code into the one document the acknowledgement is
2//! written from, with the single thing to ask for next.
3
4use serde::Serialize;
5use turnframe_core::case::{CaseKey, CaseRef};
6use turnframe_core::event::OperationalReceipt;
7use turnframe_core::flow::ErasedWorkflowView;
8use turnframe_core::interaction::Interaction;
9use turnframe_core::locale::{Locale, LocalizedText};
10use turnframe_core::response::{CaseLabel, Expectation, NarratableFact};
11
12/// The turn as the acknowledgement may state it.
13#[derive(Debug, Clone, Default, PartialEq, Serialize)]
14pub(crate) struct TurnOutcome {
15    /// What was done, each as its receipt says it.
16    #[serde(skip_serializing_if = "Vec::is_empty")]
17    pub done: Vec<String>,
18    /// What was not done, each with its reason.
19    #[serde(skip_serializing_if = "Vec::is_empty")]
20    pub not_done: Vec<String>,
21    /// What the user said the assistant got wrong.
22    #[serde(skip_serializing_if = "Vec::is_empty")]
23    pub disputes: Vec<String>,
24    /// Workflows the turn started without writing anything yet.
25    #[serde(skip_serializing_if = "Vec::is_empty")]
26    pub starting: Vec<String>,
27    /// The one thing to ask for next.
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub ask: Option<Ask>,
30    /// The card on screen, when there is one.
31    #[serde(skip_serializing_if = "Option::is_none")]
32    pub card: Option<String>,
33    /// What the user may do next, when the record needs nothing more.
34    #[serde(skip_serializing_if = "Vec::is_empty")]
35    pub next: Vec<String>,
36}
37
38impl TurnOutcome {
39    /// Whether there is nothing to say: then no acknowledgement is written at all.
40    pub fn is_silent(&self) -> bool {
41        self.done.is_empty()
42            && self.not_done.is_empty()
43            && self.disputes.is_empty()
44            && self.starting.is_empty()
45            && self.ask.is_none()
46            && self.card.is_none()
47            && self.next.is_empty()
48    }
49
50    /// Whether all the reply has to say is what it asks next.
51    pub fn only_asks(&self) -> bool {
52        self.ask.is_some()
53            && self.done.is_empty()
54            && self.not_done.is_empty()
55            && self.disputes.is_empty()
56            && self.starting.is_empty()
57            && self.card.is_none()
58            && self.next.is_empty()
59    }
60}
61
62/// The one thing the reply asks for, chosen by code.
63#[derive(Debug, Clone, PartialEq, Serialize)]
64pub(crate) struct Ask {
65    /// The record it is about, by its label.
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub record: Option<String>,
68    /// What to ask for: the argument's words or the obligation's sentence.
69    pub what: String,
70    /// Why the value is needed again, when the domain refused the one given.
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub because: Option<String>,
73    /// The question the reply asks when no model writes one, and the model rewords.
74    pub question: String,
75    /// What the next turn expects, recorded once the reply is out.
76    #[serde(skip)]
77    pub expectation: Option<Expectation>,
78}
79
80/// Copy for the questions code writes when no acknowledgement does.
81#[derive(Debug, Clone, PartialEq, Eq)]
82#[non_exhaustive]
83pub struct AskCopy {
84    /// A missing value; `{what}` is replaced by its label.
85    pub value: LocalizedText,
86    /// A value the domain refused; `{what}` is its label, `{because}` the reason.
87    pub refused_value: LocalizedText,
88    /// An open obligation the workflow gave no sentence; `{what}` is its name.
89    pub obligation: LocalizedText,
90    /// A receipt the user contested; `{what}` is the receipt as it was shown.
91    pub contested: LocalizedText,
92}
93
94impl AskCopy {
95    /// The built-in copy: English, with Italian.
96    #[must_use]
97    pub fn standard() -> Self {
98        crate::copy::ServerCopy::translated(Self::english(), "it", ITALIAN)
99    }
100
101    /// English alone.
102    #[must_use]
103    pub fn english() -> Self {
104        Self {
105            value: LocalizedText::new("What should the {what} be?"),
106            refused_value: LocalizedText::new("{because} What should the {what} be instead?"),
107            obligation: LocalizedText::new("Still needed: {what}."),
108            contested: LocalizedText::new("{what} What should it be instead?"),
109        }
110    }
111}
112
113impl Default for AskCopy {
114    fn default() -> Self {
115        Self::standard()
116    }
117}
118
119crate::copy::server_copy!(AskCopy, [value, refused_value, obligation, contested]);
120
121/// The built-in Italian of [`AskCopy`], by field.
122const ITALIAN: &[(&str, &str)] = &[
123    ("value", "Che cosa metto come {what}?"),
124    (
125        "refused_value",
126        "{because} Che cosa metto come {what}, invece?",
127    ),
128    ("obligation", "Manca ancora: {what}."),
129    ("contested", "{what} Come dovrebbe essere, invece?"),
130];
131
132fn fill(template: &LocalizedText, locale: &Locale, pairs: &[(&str, &str)]) -> String {
133    let mut text = template.resolve(locale).to_owned();
134    for (key, value) in pairs {
135        text = text.replace(&format!("{{{key}}}"), value);
136    }
137    text.trim().to_owned()
138}
139
140/// What an outcome is gathered from.
141pub(crate) struct Material<'a> {
142    pub receipts: &'a [OperationalReceipt],
143    pub facts: &'a [NarratableFact],
144    pub interactions: &'a [Interaction],
145    pub views: &'a [ErasedWorkflowView],
146    /// The cases an act of this turn reached, in act order.
147    pub touched: &'a [CaseKey],
148    /// Other cases in view, asked about once the touched ones need nothing.
149    pub beside: &'a [CaseKey],
150    pub labels: &'a [CaseLabel],
151    pub disputes: &'a [String],
152    pub started: &'a [turnframe_core::ids::WorkflowKey],
153    pub contested: &'a [String],
154    /// What each case lets the user do next once it owes nothing, by case.
155    pub next_steps: &'a [(CaseKey, Vec<LocalizedText>)],
156    pub locale: &'a Locale,
157    pub copy: &'a AskCopy,
158}
159
160impl Material<'_> {
161    fn label(&self, case_ref: &CaseRef) -> Option<String> {
162        self.labels
163            .iter()
164            .find(|label| label.case_ref.key() == case_ref.key())
165            .map(|label| label.label.clone())
166    }
167
168    /// A receipt the user contested, else the first value an act is waiting for, else
169    /// the first open obligation of a record the turn touched. A card on a record the turn
170    /// reached is what comes next, so no obligation is asked beside it.
171    fn ask(&self) -> Option<Ask> {
172        if let Some(contested) = self.contested.first() {
173            return Some(Ask {
174                record: None,
175                what: contested.clone(),
176                because: None,
177                question: fill(&self.copy.contested, self.locale, &[("what", contested)]),
178                expectation: None,
179            });
180        }
181        let waiting = self.facts.iter().find_map(|fact| match fact {
182            NarratableFact::ValueNeeded {
183                case_ref,
184                arguments,
185                reason,
186                ..
187            } => Some((case_ref.clone(), arguments.join(", "), reason.clone())),
188            _ => None,
189        });
190        if let Some((case_ref, what, because)) = waiting {
191            let question = match &because {
192                Some(because) => fill(
193                    &self.copy.refused_value,
194                    self.locale,
195                    &[("what", &what), ("because", because)],
196                ),
197                None => fill(&self.copy.value, self.locale, &[("what", &what)]),
198            };
199            return Some(Ask {
200                record: case_ref.as_ref().and_then(|case_ref| self.label(case_ref)),
201                what,
202                because,
203                question,
204                // The waiting act is recorded as its own expectation already.
205                expectation: None,
206            });
207        }
208        let reached = |case_ref: &CaseRef| self.touched.contains(&case_ref.key());
209        let card_next = self
210            .interactions
211            .iter()
212            .any(|card| card.blocking && reached(&card.case_ref))
213            || self
214                .views
215                .iter()
216                .any(|view| view.blocking_interaction.is_some() && reached(&view.case_ref));
217        if card_next {
218            return None;
219        }
220        let (view, obligation) = self.touched.iter().chain(self.beside).find_map(|key| {
221            let view = self.views.iter().find(|view| view.case_ref.key() == *key)?;
222            Some((view, view.obligations.first()?))
223        })?;
224        // The workflow's sentence is the question; without one, its name is read out.
225        let (what, question) = match &obligation.sentence {
226            Some(sentence) => {
227                let sentence = sentence.resolve(self.locale).to_owned();
228                (sentence.clone(), sentence)
229            }
230            None => {
231                let what = obligation_words(&obligation.value);
232                let question = fill(&self.copy.obligation, self.locale, &[("what", &what)]);
233                (what, question)
234            }
235        };
236        // An obligation whose workflow names the act that answers it is awaited as that
237        // act, so a bare answer completes it.
238        let expectation = match &obligation.act {
239            Some(act) => Expectation::AwaitingOperation {
240                case_ref: view.case_ref.clone(),
241                obligation: what.clone(),
242                act: act.clone(),
243            },
244            None => Expectation::AwaitingObligation {
245                case_ref: view.case_ref.clone(),
246                obligation: what.clone(),
247            },
248        };
249        Some(Ask {
250            record: self.label(&view.case_ref),
251            question,
252            expectation: Some(expectation),
253            what,
254            because: None,
255        })
256    }
257
258    /// Gathers the outcome.
259    pub fn outcome(&self) -> TurnOutcome {
260        let locale = self.locale;
261        let done = self
262            .receipts
263            .iter()
264            .map(|receipt| {
265                format!(
266                    "{}: {}",
267                    receipt.title.resolve(locale),
268                    receipt.body.resolve(locale)
269                )
270            })
271            .collect();
272        let not_done = self.facts.iter().filter_map(not_done).collect();
273        let card = self.interactions.first().map(|card| {
274            let options: Vec<&str> = card
275                .payload
276                .options
277                .iter()
278                .map(|option| option.label.resolve(locale))
279                .collect();
280            let about = self
281                .touched
282                .contains(&card.case_ref.key())
283                .then(|| self.label(&card.case_ref))
284                .flatten()
285                .map(|label| format!("{label}: "))
286                .unwrap_or_default();
287            format!(
288                "{about}{} ({})",
289                card.payload.title.resolve(locale),
290                options.join(" / ")
291            )
292        });
293        let ask = self.ask();
294        let next = if ask.is_none() && card.is_none() {
295            self.touched
296                .iter()
297                .find_map(|key| {
298                    self.next_steps
299                        .iter()
300                        .find(|(case, steps)| case == key && !steps.is_empty())
301                })
302                .map(|(_, steps)| {
303                    steps
304                        .iter()
305                        .map(|step| step.resolve(locale).to_owned())
306                        .collect()
307                })
308                .unwrap_or_default()
309        } else {
310            Vec::new()
311        };
312        TurnOutcome {
313            done,
314            not_done,
315            disputes: self.disputes.to_vec(),
316            starting: self.started.iter().map(ToString::to_string).collect(),
317            ask,
318            card,
319            next,
320        }
321    }
322}
323
324/// An obligation's name in words: a string, or an object's one tag, with its
325/// underscores read as spaces.
326fn obligation_words(value: &serde_json::Value) -> String {
327    let name = match value {
328        serde_json::Value::String(name) => Some(name.as_str()),
329        serde_json::Value::Object(map) => map.keys().next().map(String::as_str),
330        _ => None,
331    };
332    name.map_or_else(|| value.to_string(), |name| name.replace('_', " "))
333}
334
335/// What a fact says was not done, and why, as a sentence the reply may restate.
336fn not_done(fact: &NarratableFact) -> Option<String> {
337    Some(match fact {
338        NarratableFact::ActRefused {
339            explanation, code, ..
340        } => {
341            if explanation.is_empty() {
342                format!("Refused ({code}).")
343            } else {
344                explanation.clone()
345            }
346        }
347        NarratableFact::ActChangedNothing { explanation, .. } if !explanation.is_empty() => {
348            explanation.clone()
349        }
350        NarratableFact::ActChangedNothing { operation, .. } => format!(
351            "Nothing changed: {} was already so.",
352            operation.as_deref().unwrap_or("what was asked")
353        ),
354        NarratableFact::InstructionDeclined {
355            question,
356            option_label,
357            ..
358        } => format!("The user answered «{question}» with «{option_label}»."),
359        NarratableFact::ActHeld { operation, because } => {
360            format!("{operation} waits: «{because}» was not understood.")
361        }
362        NarratableFact::NotUnderstood { words } => format!("Not understood: «{words}»."),
363        NarratableFact::WorkflowUnavailable { reason, .. } => reason.clone(),
364        NarratableFact::AttachmentNotShown {
365            filename, reason, ..
366        } => format!(
367            "The file {} was not read: {reason}",
368            filename.as_deref().unwrap_or("attached")
369        ),
370        _ => return None,
371    })
372}
373
374#[cfg(test)]
375mod tests {
376    use super::*;
377    use turnframe_core::ids::CaseRevision;
378
379    fn material<'a>(
380        facts: &'a [NarratableFact],
381        copy: &'a AskCopy,
382        locale: &'a Locale,
383    ) -> Material<'a> {
384        Material {
385            receipts: &[],
386            facts,
387            interactions: &[],
388            views: &[],
389            touched: &[],
390            beside: &[],
391            labels: &[],
392            disputes: &[],
393            started: &[],
394            contested: &[],
395            next_steps: &[],
396            locale,
397            copy,
398        }
399    }
400
401    #[test]
402    fn a_record_that_needs_nothing_more_offers_its_next_steps() {
403        let view = |obligations| ErasedWorkflowView {
404            case_ref: CaseRef::new("sample", "s-1", CaseRevision(1)),
405            workflow_version: turnframe_core::ids::WorkflowVersion::from("1"),
406            phase: serde_json::json!("collecting"),
407            phase_ownership: turnframe_core::flow::PhaseOwnership::User,
408            obligations,
409            blocking_interaction: None,
410            notices: Vec::new(),
411            outcome: None,
412            state: Vec::new(),
413        };
414        let owed = turnframe_core::flow::ErasedObligation {
415            id: turnframe_core::flow::ObligationId("\"a\"".to_owned()),
416            value: serde_json::json!("a"),
417            sentence: Some(LocalizedText::new("What is A?")),
418            act: None,
419        };
420        let copy = AskCopy::english();
421        let locale = Locale::from("en-GB");
422        let touched = [CaseRef::new("sample", "s-1", CaseRevision(1)).key()];
423        let next_steps = [(
424            touched[0].clone(),
425            vec![
426                LocalizedText::new("Add another item."),
427                LocalizedText::new("Send it."),
428            ],
429        )];
430
431        let complete = [view(Vec::new())];
432        let mut material = material(&[], &copy, &locale);
433        material.views = &complete;
434        material.touched = &touched;
435        material.next_steps = &next_steps;
436        let outcome = material.outcome();
437        assert_eq!(outcome.ask, None);
438        assert_eq!(outcome.next, vec!["Add another item.", "Send it."]);
439
440        let open = [view(vec![owed])];
441        material.views = &open;
442        let outcome = material.outcome();
443        assert!(outcome.ask.is_some());
444        assert!(outcome.next.is_empty(), "what is owed comes first");
445    }
446
447    #[test]
448    fn a_waiting_value_is_the_ask_and_code_writes_its_question() {
449        let facts = [NarratableFact::ValueNeeded {
450            case_ref: Some(CaseRef::new("trip", "trip-1", CaseRevision(1))),
451            operation: "trip.set_name".to_owned(),
452            arguments: vec!["subject".to_owned()],
453            reason: None,
454        }];
455        let copy = AskCopy::english();
456        let locale = Locale::from("en-GB");
457        let outcome = material(&facts, &copy, &locale).outcome();
458        let ask = outcome.ask.expect("an ask");
459        assert_eq!(ask.what, "subject");
460        assert_eq!(ask.question, "What should the subject be?");
461        assert!(
462            outcome.not_done.is_empty(),
463            "a missing value is asked for, not reported"
464        );
465    }
466
467    #[test]
468    fn an_obligation_without_a_sentence_is_never_asked_by_its_code() {
469        let case_ref = CaseRef::new("trip", "trip-2", CaseRevision(1));
470        let view = ErasedWorkflowView {
471            case_ref: case_ref.clone(),
472            workflow_version: turnframe_core::ids::WorkflowVersion::from("1"),
473            phase: serde_json::json!("collecting"),
474            phase_ownership: turnframe_core::flow::PhaseOwnership::User,
475            obligations: vec![turnframe_core::flow::ErasedObligation {
476                id: turnframe_core::flow::ObligationId("\"select_traveler\"".to_owned()),
477                value: serde_json::json!("select_traveler"),
478                sentence: None,
479                act: None,
480            }],
481            blocking_interaction: None,
482            notices: Vec::new(),
483            outcome: None,
484            state: Vec::new(),
485        };
486        let copy = AskCopy::english();
487        let locale = Locale::from("en-GB");
488        let views = [view];
489        let touched = [case_ref.key()];
490        let mut material = material(&[], &copy, &locale);
491        material.views = &views;
492        material.touched = &touched;
493        let ask = material
494            .outcome()
495            .ask
496            .expect("the open obligation is asked for");
497        assert_eq!(ask.question, "Still needed: select traveler.");
498    }
499
500    #[test]
501    fn a_card_on_screen_is_what_comes_next() {
502        let obligation = |case_id: &str, requirement| ErasedWorkflowView {
503            case_ref: CaseRef::new("sample", case_id, CaseRevision(1)),
504            workflow_version: turnframe_core::ids::WorkflowVersion::from("1"),
505            phase: serde_json::json!("collecting"),
506            phase_ownership: turnframe_core::flow::PhaseOwnership::User,
507            obligations: vec![turnframe_core::flow::ErasedObligation {
508                id: turnframe_core::flow::ObligationId("\"a\"".to_owned()),
509                value: serde_json::json!("a"),
510                sentence: Some(LocalizedText::new("What is A?")),
511                act: None,
512            }],
513            blocking_interaction: requirement,
514            notices: Vec::new(),
515            outcome: None,
516            state: Vec::new(),
517        };
518        let carded = obligation(
519            "s-1",
520            Some(turnframe_core::flow::InteractionRequirement::blocking(
521                "confirm",
522                turnframe_core::interaction::InteractionKind::Boolean,
523            )),
524        );
525        let other = obligation("s-2", None);
526        let touched = [carded.case_ref.key()];
527        let beside = [other.case_ref.key()];
528        let views = [carded, other];
529        let copy = AskCopy::english();
530        let locale = Locale::from("en-GB");
531        let mut material = material(&[], &copy, &locale);
532        material.views = &views;
533        material.touched = &touched;
534        material.beside = &beside;
535        assert_eq!(material.outcome().ask, None);
536    }
537
538    #[test]
539    fn nothing_to_say_is_silence() {
540        let copy = AskCopy::english();
541        let locale = Locale::from("en-GB");
542        assert!(material(&[], &copy, &locale).outcome().is_silent());
543    }
544}