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