1use 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#[derive(Debug, Clone, Copy)]
22pub struct Verify<'a> {
23 turn: &'a UnderstandingInput,
24}
25
26impl<'a> Verify<'a> {
27 #[must_use]
29 pub const fn new(turn: &'a UnderstandingInput) -> Self {
30 Self { turn }
31 }
32}
33
34#[derive(Debug, Clone)]
36pub struct VerifyInput<'a> {
37 pub label: &'static str,
39 pub words: Span,
41 pub meaning: String,
43 pub record: String,
45 pub arguments: &'a BTreeMap<String, UnderstoodArgument>,
47 pub labels: BTreeMap<String, String>,
49 pub record_labels: BTreeMap<String, String>,
51 pub meanings: BTreeMap<String, String>,
53 pub occurrence: Option<(String, usize, usize)>,
55 pub note: Option<String>,
57 pub continues: Option<Span>,
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
63#[serde(rename_all = "snake_case")]
64pub enum ArgumentVerdict {
65 Stated,
67 NotStated,
69 Different,
71 Incomplete,
73 TooMuch,
75}
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
79#[serde(rename_all = "snake_case")]
80pub enum Overall {
81 Confirmed,
83 NotRequested,
85 WrongRecord,
87}
88
89#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
91pub struct Verdict {
92 pub reason: String,
94 pub arguments: BTreeMap<String, ArgumentVerdict>,
96 pub overall: Overall,
98}
99
100impl Verdict {
101 #[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 #[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 #[must_use]
123 pub fn feedback(&self) -> String {
124 self.feedback_given(|_| false)
125 }
126
127 #[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
154fn 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
181fn 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 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 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 let correction = input.label == "Correction";
322 let said = |span: Span| render::quoted(words.slice(span).unwrap_or_default());
323 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 (_, 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
391fn 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
402fn 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 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 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}