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