Skip to main content

turnframe_understand/tasks/
verify.rs

1//! `verify`: whether what was understood is what the user said. It can only take away.
2
3use std::collections::BTreeMap;
4use std::fmt::Write as _;
5
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8use turnframe_core::understanding::{ArgumentValue, MessageRef, RecordValue, UnderstoodArgument};
9use turnframe_provider::request::Message;
10use turnframe_tasks::{ModelTask, StructuralError, TaskKind};
11
12use crate::input::{Speaker, UnderstandingInput};
13use crate::render;
14use crate::schema::{object, one_of, text};
15use crate::tasks::not_one_of;
16use crate::words::Span;
17
18const BUILT_IN: &str = include_str!("../../prompts/understand/verify.md");
19
20/// The verification task, over one turn.
21#[derive(Debug, Clone, Copy)]
22pub struct Verify<'a> {
23    turn: &'a UnderstandingInput,
24}
25
26impl<'a> Verify<'a> {
27    /// The task for `turn`.
28    #[must_use]
29    pub const fn new(turn: &'a UnderstandingInput) -> Self {
30        Self { turn }
31    }
32}
33
34/// One act as understood, shown for a person to check.
35#[derive(Debug, Clone)]
36pub struct VerifyInput<'a> {
37    /// How the unit is shown.
38    pub label: &'static str,
39    /// Its words.
40    pub words: Span,
41    /// What the operation does, or what starting the workflow means.
42    pub meaning: String,
43    /// The record, as shown.
44    pub record: String,
45    /// The arguments understood, by name.
46    pub arguments: &'a BTreeMap<String, UnderstoodArgument>,
47    /// How each argument is labelled, by name.
48    pub labels: BTreeMap<String, String>,
49    /// How a record-valued argument's record is shown.
50    pub record_labels: BTreeMap<String, String>,
51    /// What a value of a closed set means, by argument, when the operation says.
52    pub meanings: BTreeMap<String, String>,
53    /// The operation, and which of the times the request asks for it this act is.
54    pub occurrence: Option<(String, usize, usize)>,
55    /// What the whole-turn check found, when this call reads the act again.
56    pub note: Option<String>,
57    /// Words of this message the unit continues, such as the request a correction changes.
58    pub continues: Option<Span>,
59}
60
61/// The verdict on each argument.
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
63#[serde(rename_all = "snake_case")]
64pub enum ArgumentVerdict {
65    /// The user gave that value.
66    Stated,
67    /// The user gave no value for it.
68    NotStated,
69    /// The user gave another value.
70    Different,
71    /// The user gave only part of it.
72    Incomplete,
73    /// It takes words that are not part of the value, such as the field's name.
74    TooMuch,
75}
76
77/// The verdict on the act as a whole.
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
79#[serde(rename_all = "snake_case")]
80pub enum Overall {
81    /// The user asked for this operation on this record.
82    Confirmed,
83    /// The user did not ask for it.
84    NotRequested,
85    /// The user meant another record.
86    WrongRecord,
87}
88
89/// What the verifier found.
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
91pub struct Verdict {
92    /// Why, in a sentence; it reaches the repair when the act is not confirmed.
93    pub reason: String,
94    /// Each argument's verdict, by name.
95    pub arguments: BTreeMap<String, ArgumentVerdict>,
96    /// The act's verdict.
97    pub overall: Overall,
98}
99
100impl Verdict {
101    /// Whether everything checked out.
102    #[must_use]
103    pub fn confirmed(&self) -> bool {
104        self.overall == Overall::Confirmed
105            && self
106                .arguments
107                .values()
108                .all(|verdict| *verdict == ArgumentVerdict::Stated)
109    }
110
111    /// The arguments found wanting, by name.
112    #[must_use]
113    pub fn at_fault(&self) -> Vec<String> {
114        self.arguments
115            .iter()
116            .filter(|(_, verdict)| **verdict != ArgumentVerdict::Stated)
117            .map(|(name, _)| name.clone())
118            .collect()
119    }
120
121    /// The feedback a repair of the extraction is given.
122    #[must_use]
123    pub fn feedback(&self) -> String {
124        self.feedback_given(|_| false)
125    }
126
127    /// The feedback, where `refers_back` names the arguments copied from this message in a
128    /// conversation with earlier words: words there may only point at a value said before.
129    #[must_use]
130    pub fn feedback_given(&self, refers_back: impl Fn(&str) -> bool) -> String {
131        let mut out = format!("A check of your answer found: {}", self.reason);
132        for (name, verdict) in &self.arguments {
133            let said = match verdict {
134                ArgumentVerdict::Stated => continue,
135                ArgumentVerdict::NotStated | ArgumentVerdict::Different if refers_back(name) => {
136                    "you copied it from this message, which gives no value of its own there; \
137                     words that only refer back to a value said before give that value: point \
138                     at the value alone in the earlier message that says it, or give not_given \
139                     when none does"
140                }
141                ArgumentVerdict::NotStated => "the user gave no value for it",
142                ArgumentVerdict::Different => "the user gave another value",
143                ArgumentVerdict::Incomplete => "the user gave more of it than you took",
144                ArgumentVerdict::TooMuch => {
145                    "it takes words that are not part of the value; point at the value alone"
146                }
147            };
148            let _ = write!(out, "\n- {name}: {said}");
149        }
150        out
151    }
152}
153
154/// Whether `argument` is words of this message copied as they stand, not a value computed
155/// from them: a date or a choice read from words is never the words themselves.
156fn copied_from_this_message(turn: &UnderstandingInput, argument: &UnderstoodArgument) -> bool {
157    let (Some(excerpt), ArgumentValue::Json(Value::String(text))) =
158        (argument.excerpt, &argument.value)
159    else {
160        return false;
161    };
162    let bare = |text: &str| -> String {
163        text.chars()
164            .filter(|c| c.is_alphanumeric() || c.is_whitespace())
165            .collect::<String>()
166            .split_whitespace()
167            .collect::<Vec<_>>()
168            .join(" ")
169            .to_lowercase()
170    };
171    excerpt.message == MessageRef::Current
172        && turn
173            .message
174            .slice(crate::words::Span::new(
175                excerpt.words.first,
176                excerpt.words.last,
177            ))
178            .is_ok_and(|said| bare(said) == bare(text))
179}
180
181/// The words of `part` no argument of the act takes, quoted run by run, when `argument` takes
182/// words of this message inside the part and leaves some of them out.
183fn left_out(
184    turn: &UnderstandingInput,
185    arguments: &BTreeMap<String, UnderstoodArgument>,
186    argument: &UnderstoodArgument,
187    part: Span,
188) -> Option<String> {
189    let inside = |argument: &UnderstoodArgument| {
190        argument.excerpt.filter(|excerpt| {
191            excerpt.message == MessageRef::Current
192                && excerpt.words.first >= part.from
193                && excerpt.words.last <= part.to
194        })
195    };
196    inside(argument)?;
197    let taken: Vec<(usize, usize)> = arguments
198        .values()
199        .filter_map(inside)
200        .map(|excerpt| (excerpt.words.first, excerpt.words.last))
201        .collect();
202    let free = |word: usize| {
203        !taken
204            .iter()
205            .any(|(first, last)| (*first..=*last).contains(&word))
206    };
207    let mut runs: Vec<(usize, usize)> = Vec::new();
208    for word in (part.from..=part.to).filter(|word| free(*word)) {
209        match runs.last_mut() {
210            Some((_, last)) if *last + 1 == word => *last = word,
211            _ => runs.push((word, word)),
212        }
213    }
214    let quoted: Vec<String> = runs
215        .into_iter()
216        .filter_map(|(from, to)| turn.message.slice(Span::new(from, to)).ok())
217        .map(render::quoted)
218        .collect();
219    (!quoted.is_empty()).then(|| quoted.join(" and "))
220}
221
222impl<'a> ModelTask for Verify<'a> {
223    type Input = VerifyInput<'a>;
224    type Output = Verdict;
225
226    fn kind(&self) -> TaskKind {
227        TaskKind::Verify
228    }
229
230    fn prompt_name(&self) -> &str {
231        "understand.verify"
232    }
233
234    fn instructions(&self) -> &str {
235        BUILT_IN
236    }
237
238    fn schema(&self, input: &VerifyInput<'a>) -> Value {
239        let verdicts = one_of([
240            "stated",
241            "not_stated",
242            "different",
243            "incomplete",
244            "too_much",
245        ]);
246        let arguments = input
247            .arguments
248            .keys()
249            .map(|name| (name.as_str(), verdicts.clone()))
250            .collect();
251        object(vec![
252            ("reason", text("Why, in one sentence.")),
253            ("arguments", object(arguments)),
254            (
255                "overall",
256                one_of(["confirmed", "not_requested", "wrong_record"]),
257            ),
258        ])
259    }
260
261    fn render(&self, input: &VerifyInput<'a>) -> Vec<Message> {
262        let turn = self.turn;
263        let mut understood = String::from("Understood:");
264        if input.arguments.is_empty() {
265            understood.push_str(" no arguments.");
266        }
267        let mut cited = Vec::new();
268        for (name, argument) in input.arguments {
269            let label = input.labels.get(name).map_or(name.as_str(), String::as_str);
270            let shown = render::understood(argument, turn, |record: &RecordValue| {
271                input
272                    .record_labels
273                    .get(name)
274                    .cloned()
275                    .unwrap_or_else(|| format!("{record:?}"))
276            });
277            // The words of its part a copied value leaves out are said, so they are not
278            // taken for part of it.
279            let copied_here = copied_from_this_message(turn, argument);
280            let shown = match left_out(turn, input.arguments, argument, input.words)
281                .filter(|_| copied_here)
282            {
283                Some(left) => match shown.strip_suffix(')') {
284                    Some(open) => format!("{open}; {left} of its part is not in it)"),
285                    None => format!("{shown} ({left} of its part is not in it)"),
286                },
287                None => shown,
288            };
289            let _ = write!(understood, "\n- {name} ({label}): {shown}");
290            if let Some(meaning) = input.meanings.get(name) {
291                let _ = write!(understood, "\n  {meaning}");
292            }
293            // Only a copy from this message can be words pointing at an earlier value; a value
294            // taken from the earlier message is what they point at.
295            if copied_here && turn.transcript.iter().any(|m| m.speaker == Speaker::User) {
296                understood.push_str(
297                    "\n  Copied from this message: words that only refer back to a value said \
298                     before («what I told you before») are not that value.",
299                );
300            }
301            if let Some(excerpt) = argument.excerpt
302                && let MessageRef::Earlier { index } = excerpt.message
303                && !cited.contains(&index)
304            {
305                cited.push(index);
306            }
307        }
308        let earlier = cited
309            .iter()
310            .filter_map(|index| {
311                let message = turn.transcript.get(*index)?;
312                Some(format!(
313                    "Earlier message {}: {}",
314                    render::message_name(*index),
315                    render::quoted(message.words.text())
316                ))
317            })
318            .collect::<Vec<_>>();
319        let words = &turn.message;
320        // A correction answers for what the last turn did, never for the question asked next.
321        let correction = input.label == "Correction";
322        let said = |span: Span| render::quoted(words.slice(span).unwrap_or_default());
323        // A correction of this message follows the request it corrects, as the user said them.
324        let (unit, then) = match (correction, input.continues) {
325            (true, Some(request)) => (
326                format!("Request: {}", said(request)),
327                Some(format!(
328                    "Corrected by: {}, the user's last word on what it changes.",
329                    said(input.words)
330                )),
331            ),
332            // Named as the words the act reads, so they are never taken for its value.
333            (_, continues) => (
334                format!(
335                    "The part of the message this act reads, {}: {}",
336                    with_article(input.label),
337                    said(input.words)
338                ),
339                continues.map(|span| format!("It continues: {}", said(span))),
340            ),
341        };
342        vec![Message::user(render::sections([
343            Some(format!("Operation: {}", input.meaning)),
344            Some(format!("Record: {}", input.record)),
345            Some(understood),
346            Some(format!("Today: {}", turn.today.format("%A %-d %B %Y"))),
347            (!earlier.is_empty()).then(|| earlier.join("\n")),
348            correction.then(|| render::receipts(turn)).flatten(),
349            render::last_assistant(turn),
350            (!correction).then(|| render::expectation(turn)).flatten(),
351            Some(format!(
352                "The user's message: {}",
353                render::quoted(words.text())
354            )),
355            Some(unit),
356            then,
357            input.occurrence.as_ref().map(|(operation, number, of)| {
358                format!(
359                    "This request asks for {operation} {of} times: this act is the {}, and other \
360                     acts read the rest of the request. Judge only whether the user gave these \
361                     values for it.",
362                    ordinal(*number)
363                )
364            }),
365            input
366                .note
367                .as_ref()
368                .map(|note| format!("A check of the whole message found: {note}")),
369        ]))]
370    }
371
372    fn check(&self, input: &VerifyInput<'a>, output: &Verdict) -> Result<(), StructuralError> {
373        let expected: Vec<String> = input.arguments.keys().cloned().collect();
374        if let Some(unknown) = output.arguments.keys().find(|n| !expected.contains(n)) {
375            return Err(not_one_of("argument", unknown, &expected));
376        }
377        if let Some(missing) = expected.iter().find(|n| !output.arguments.contains_key(*n)) {
378            return Err(StructuralError::new(
379                "missing_argument",
380                format!("`arguments.{missing}` is missing"),
381            ));
382        }
383        Ok(())
384    }
385
386    fn agree(&self, left: &Verdict, right: &Verdict) -> bool {
387        left.overall == right.overall && left.arguments == right.arguments
388    }
389}
390
391/// «Answer» as «an answer», «Request» as «a request».
392fn with_article(label: &str) -> String {
393    let lower = label.to_lowercase();
394    let article = if lower.starts_with(['a', 'e', 'i', 'o', 'u']) {
395        "an"
396    } else {
397        "a"
398    };
399    format!("{article} {lower}")
400}
401
402/// `1` as «first», up to «tenth»; past it, «number 11».
403fn ordinal(number: usize) -> String {
404    const WORDS: [&str; 10] = [
405        "first", "second", "third", "fourth", "fifth", "sixth", "seventh", "eighth", "ninth",
406        "tenth",
407    ];
408    number
409        .checked_sub(1)
410        .and_then(|index| WORDS.get(index))
411        .map_or_else(|| format!("number {number}"), |word| (*word).to_owned())
412}
413
414#[cfg(test)]
415mod tests {
416    use super::*;
417
418    #[test]
419    fn the_words_of_its_part_a_copied_value_leaves_out_are_said() {
420        // [0]the [1]A [2]is [3]X [4]Y
421        let turn = UnderstandingInput::new("the A is X Y", "en-GB", chrono::NaiveDate::MIN);
422        let value = UnderstoodArgument {
423            value: ArgumentValue::Json(Value::from("X Y")),
424            excerpt: Some(turnframe_core::understanding::Excerpt {
425                message: MessageRef::Current,
426                words: turn
427                    .message
428                    .range(Span::new(3, 4))
429                    .unwrap_or_else(|_| unreachable!()),
430            }),
431        };
432        let arguments = BTreeMap::from([("value".to_owned(), value)]);
433        let input = VerifyInput {
434            label: "Answer",
435            words: Span::new(0, 4),
436            meaning: "Set A.".to_owned(),
437            record: "R 1".to_owned(),
438            arguments: &arguments,
439            labels: BTreeMap::new(),
440            record_labels: BTreeMap::new(),
441            meanings: BTreeMap::new(),
442            occurrence: None,
443            note: None,
444            continues: None,
445        };
446        let rendered = format!("{:?}", Verify::new(&turn).render(&input));
447        assert!(
448            rendered.contains("«the A is» of its part is not in it"),
449            "{rendered}"
450        );
451    }
452
453    #[test]
454    fn words_another_argument_takes_are_not_said_to_be_left_out() {
455        // [0]add [1]3 [2]hotel [3]nights
456        let turn = UnderstandingInput::new("add 3 hotel nights", "en-GB", chrono::NaiveDate::MIN);
457        let taken = |from: usize, to: usize, value: Value| UnderstoodArgument {
458            value: ArgumentValue::Json(value),
459            excerpt: Some(turnframe_core::understanding::Excerpt {
460                message: MessageRef::Current,
461                words: turn
462                    .message
463                    .range(Span::new(from, to))
464                    .unwrap_or_else(|_| unreachable!()),
465            }),
466        };
467        let arguments = BTreeMap::from([
468            (
469                "description".to_owned(),
470                taken(2, 3, Value::from("hotel nights")),
471            ),
472            ("quantity".to_owned(), taken(1, 1, Value::from(3))),
473        ]);
474        let input = VerifyInput {
475            label: "Request",
476            words: Span::new(0, 3),
477            meaning: "Add an extra.".to_owned(),
478            record: "R 1".to_owned(),
479            arguments: &arguments,
480            labels: BTreeMap::new(),
481            record_labels: BTreeMap::new(),
482            meanings: BTreeMap::new(),
483            occurrence: None,
484            note: None,
485            continues: None,
486        };
487        let rendered = format!("{:?}", Verify::new(&turn).render(&input));
488        assert!(
489            rendered.contains("«add» of its part is not in it"),
490            "{rendered}"
491        );
492    }
493
494    #[test]
495    fn a_note_from_the_whole_turn_check_is_shown() {
496        let turn = UnderstandingInput::new("name Lisbon", "en-GB", chrono::NaiveDate::MIN);
497        let arguments = BTreeMap::new();
498        let input = VerifyInput {
499            label: "Request",
500            words: Span::new(0, 1),
501            meaning: "Name the trip.".to_owned(),
502            record: "Trip 1".to_owned(),
503            arguments: &arguments,
504            labels: BTreeMap::new(),
505            record_labels: BTreeMap::new(),
506            meanings: BTreeMap::new(),
507            occurrence: None,
508            note: Some("the message may not ask for this act".to_owned()),
509            continues: None,
510        };
511        let rendered = format!("{:?}", Verify::new(&turn).render(&input));
512        assert!(
513            rendered.contains(
514                "A check of the whole message found: the message may not ask for this act"
515            ),
516            "{rendered}"
517        );
518    }
519
520    #[test]
521    fn an_answer_is_checked_against_the_question_it_answers() {
522        let turn = UnderstandingInput::new("X", "en-GB", chrono::NaiveDate::MIN)
523            .with_earlier(crate::Speaker::Assistant, "What is A?")
524            .with_expectation(crate::Expectation::Obligation {
525                record: turnframe_core::ids::TargetToken::new("t1"),
526                sentence: "What is A?".to_owned(),
527            });
528        let arguments = BTreeMap::new();
529        let input = VerifyInput {
530            label: "Answer",
531            words: Span::new(1, 1),
532            meaning: "Set A.".to_owned(),
533            record: "Record 1".to_owned(),
534            arguments: &arguments,
535            labels: BTreeMap::new(),
536            record_labels: BTreeMap::new(),
537            meanings: BTreeMap::new(),
538            occurrence: None,
539            note: None,
540            continues: None,
541        };
542        let rendered = format!("{:?}", Verify::new(&turn).render(&input));
543        assert!(
544            rendered.contains("Last assistant message: «What is A?»"),
545            "{rendered}"
546        );
547        assert!(
548            rendered.contains("The assistant asked about: What is A?"),
549            "{rendered}"
550        );
551    }
552
553    #[test]
554    fn a_correction_is_checked_with_the_words_it_continues() {
555        let turn =
556            UnderstandingInput::new("set A to X 2026, no, Y", "en-GB", chrono::NaiveDate::MIN);
557        let arguments = BTreeMap::new();
558        let input = VerifyInput {
559            label: "Correction",
560            words: Span::new(5, 6),
561            meaning: "Set A.".to_owned(),
562            record: "Record 1".to_owned(),
563            arguments: &arguments,
564            labels: BTreeMap::new(),
565            record_labels: BTreeMap::new(),
566            meanings: BTreeMap::new(),
567            occurrence: None,
568            note: None,
569            continues: Some(Span::new(0, 4)),
570        };
571        let rendered = format!("{:?}", Verify::new(&turn).render(&input));
572        let request = rendered
573            .find("Request: «set A to X 2026,»")
574            .expect(&rendered);
575        let corrected = rendered
576            .find("Corrected by: «no, Y», the user's last word on what it changes.")
577            .expect(&rendered);
578        assert!(request < corrected, "{rendered}");
579    }
580
581    #[test]
582    fn a_correction_is_checked_against_what_the_last_turn_did() {
583        let turn = UnderstandingInput::new("no, the other one", "en-GB", chrono::NaiveDate::MIN)
584            .with_receipt(crate::PreviousReceipt::new("r1", "Field A set to X."))
585            .with_expectation(crate::Expectation::Obligation {
586                record: turnframe_core::ids::TargetToken::new("t1"),
587                sentence: "What is B?".to_owned(),
588            });
589        let arguments = BTreeMap::new();
590        let input = VerifyInput {
591            label: "Correction",
592            words: Span::new(1, 4),
593            meaning: "Set A.".to_owned(),
594            record: "Record 1".to_owned(),
595            arguments: &arguments,
596            labels: BTreeMap::new(),
597            record_labels: BTreeMap::new(),
598            meanings: BTreeMap::new(),
599            occurrence: None,
600            note: None,
601            continues: None,
602        };
603        let rendered = format!("{:?}", Verify::new(&turn).render(&input));
604        assert!(rendered.contains("r1: Field A set to X."), "{rendered}");
605        assert!(!rendered.contains("What is B?"), "{rendered}");
606    }
607
608    #[test]
609    fn an_occurrence_is_judged_as_the_one_it_is() {
610        let turn = UnderstandingInput::new("add a bag and a meal", "en-GB", chrono::NaiveDate::MIN);
611        let arguments = BTreeMap::new();
612        let input = VerifyInput {
613            label: "Request",
614            words: Span::new(0, 4),
615            meaning: "trip.add_extra: Add an extra.".to_owned(),
616            record: "Trip 1".to_owned(),
617            arguments: &arguments,
618            labels: BTreeMap::new(),
619            record_labels: BTreeMap::new(),
620            meanings: BTreeMap::new(),
621            occurrence: Some(("trip.add_extra".to_owned(), 2, 2)),
622            note: None,
623            continues: None,
624        };
625        let rendered = format!("{:?}", Verify::new(&turn).render(&input));
626        assert!(
627            rendered.contains(
628                "This request asks for trip.add_extra 2 times: this act is the second, and \
629                 other acts read the rest of the request. Judge only whether the user gave these \
630                 values for it."
631            ),
632            "{rendered}"
633        );
634    }
635
636    #[test]
637    fn a_value_of_a_closed_set_is_shown_with_what_it_means() {
638        let turn =
639            UnderstandingInput::new("none, she never joined", "en-GB", chrono::NaiveDate::MIN);
640        let arguments = BTreeMap::from([(
641            "reason".to_owned(),
642            UnderstoodArgument {
643                value: turnframe_core::understanding::ArgumentValue::Json("not_applicable".into()),
644                excerpt: None,
645            },
646        )]);
647        let input = VerifyInput {
648            label: "Request",
649            words: Span::new(0, 3),
650            meaning: "Decline the number.".to_owned(),
651            record: "Traveler 1".to_owned(),
652            arguments: &arguments,
653            labels: BTreeMap::new(),
654            record_labels: BTreeMap::new(),
655            meanings: BTreeMap::from([(
656                "reason".to_owned(),
657                "not_applicable when there is none to give".to_owned(),
658            )]),
659            occurrence: None,
660            note: None,
661            continues: None,
662        };
663        let rendered = format!("{:?}", Verify::new(&turn).render(&input));
664        assert!(
665            rendered.contains("- reason (reason): «not_applicable», given earlier\\n  not_applicable when there is none to give"),
666            "{rendered}"
667        );
668    }
669}