Skip to main content

turnframe_understand/
values.rs

1//! From an extraction to the operation's values: slicing words, evaluating dates and
2//! amounts, resolving handles. Every failure is structural and worded for a repair.
3
4use std::collections::BTreeMap;
5
6use chrono::Datelike as _;
7use turnframe_core::operation::{DateExpr, Money, ValueShape};
8use turnframe_core::understanding::{
9    ArgumentValue, Excerpt, MessageRef, RecordValue, UnderstoodArgument,
10};
11use turnframe_tasks::StructuralError;
12
13use crate::input::UnderstandingInput;
14use crate::tasks::extract::{BY_NAME, CURRENT, ExtractInput, Extraction, Given};
15use crate::tasks::{check_one_of, not_one_of, out_of_range};
16use crate::words::{Span, Words};
17
18/// An extraction turned into values.
19#[derive(Debug, Clone, Default, PartialEq, Eq)]
20pub struct Extracted {
21    /// The arguments given, by name.
22    pub arguments: BTreeMap<String, UnderstoodArgument>,
23    /// The arguments not given, in declaration order.
24    pub not_given: Vec<String>,
25    /// Of those, the ones pointed at in words another part of the message holds, with
26    /// those words: that part's value, which this act may still find elsewhere.
27    pub elsewhere: Vec<(String, Span)>,
28    /// Their values, set aside: this act's again when no act of that part uses the words.
29    pub aside: BTreeMap<String, UnderstoodArgument>,
30}
31
32/// Turns `output` into values, or says what is structurally wrong with it.
33///
34/// # Errors
35///
36/// A [`StructuralError`] for a missing or unknown argument, a value of the wrong kind,
37/// a pointer outside its message, a date that does not exist or an amount that is not
38/// one.
39pub fn convert(
40    turn: &UnderstandingInput,
41    input: &ExtractInput<'_>,
42    output: &Extraction,
43) -> Result<Extracted, StructuralError> {
44    let extracted = converted(turn, input, output)?;
45    shares_no_words(input, &extracted)?;
46    Ok(extracted)
47}
48
49/// Whether `given` chooses one of the records offered: a listed one, or one another act of
50/// this message creates.
51fn chosen(input: &ExtractInput<'_>, given: &Given) -> bool {
52    let Given::Record { record, .. } = given else {
53        return false;
54    };
55    input
56        .record_choices
57        .values()
58        .flat_map(|choices| choices.iter())
59        .any(|choice| &choice.handle == record)
60}
61
62/// Whether `span` belongs to another part of the message: outside the request's own words
63/// and the words it continues, and not reached from them through words no part holds.
64fn elsewhere(input: &ExtractInput<'_>, span: Span) -> bool {
65    let within = |outer: Span| outer.from <= span.from && span.to <= outer.to;
66    if within(input.words) || input.continues.is_some_and(within) {
67        return false;
68    }
69    // A value of this part running on into a neighbour asking for the same operation is one
70    // value the segmentation cut in two.
71    let mut whole = input.words;
72    for kin in &input.kin {
73        if kin.to.saturating_add(1) == whole.from {
74            whole = Span::new(kin.from, whole.to);
75        } else if whole.to.saturating_add(1) == kin.from {
76            whole = Span::new(whole.from, kin.to);
77        }
78    }
79    if within(whole) && span.from <= input.words.to && input.words.from <= span.to {
80        return false;
81    }
82    let reach = Span::new(span.from.min(input.words.from), span.to.max(input.words.to));
83    input
84        .others
85        .iter()
86        .any(|other| other.from <= reach.to && reach.from <= other.to)
87}
88
89/// One word is one value: two arguments pointing at the same words of a message are two
90/// readings of one value, and one of them is wrong. A value that may be deduced points at
91/// the words that imply it, which may be those of the value they state.
92fn shares_no_words(input: &ExtractInput<'_>, extracted: &Extracted) -> Result<(), StructuralError> {
93    let deduced = |name: &str| {
94        input.arguments.iter().any(|argument| {
95            argument.name == name
96                && matches!(
97                    argument.source,
98                    turnframe_core::operation::ArgumentSource::Inferred
99                )
100        })
101    };
102    let pointed: Vec<(&String, &Excerpt)> = extracted
103        .arguments
104        .iter()
105        .filter_map(|(name, argument)| argument.excerpt.as_ref().map(|excerpt| (name, excerpt)))
106        .collect();
107    for (at, (name, excerpt)) in pointed.iter().enumerate() {
108        for (other, theirs) in &pointed[at + 1..] {
109            if deduced(name) != deduced(other) {
110                continue;
111            }
112            let shared = excerpt.message == theirs.message
113                && excerpt.words.first <= theirs.words.last
114                && theirs.words.first <= excerpt.words.last;
115            if shared {
116                return Err(StructuralError::new(
117                    "shared_words",
118                    format!(
119                        "`{name}` and `{other}` point at the same words: each value takes words \
120                         of its own, and a value the message only implies points at the words \
121                         that imply it"
122                    ),
123                ));
124            }
125        }
126    }
127    Ok(())
128}
129
130fn converted(
131    turn: &UnderstandingInput,
132    input: &ExtractInput<'_>,
133    output: &Extraction,
134) -> Result<Extracted, StructuralError> {
135    let asked: Vec<String> = input.arguments.iter().map(|a| a.name.clone()).collect();
136    if let Some(unknown) = output.arguments.keys().find(|name| !asked.contains(name)) {
137        return Err(not_one_of("argument", unknown, &asked));
138    }
139    let messages = input.messages(turn);
140    let mut extracted = Extracted::default();
141    for argument in &input.arguments {
142        let name = &argument.name;
143        let given = output.arguments.get(name).ok_or_else(|| {
144            StructuralError::new("missing_argument", format!("`arguments.{name}` is missing"))
145        })?;
146        let Some((message, span)) = given.pointer() else {
147            extracted.not_given.push(name.clone());
148            continue;
149        };
150        check_one_of(&format!("{name}.message"), message, &messages)?;
151        // Words this part of the message repeats are the value, wherever the reading pointed:
152        // what the user says now outranks an earlier copy that matching reads as the same.
153        let (message, span) = match given {
154            Given::Words { text, .. } if message != CURRENT && !text.trim().is_empty() => turn
155                .message
156                .narrow(input.words, text)
157                .map_or((message, span), |here| (CURRENT, here)),
158            _ => (message, span),
159        };
160        let (reference, words) = message_words(turn, message)
161            .ok_or_else(|| not_one_of(&format!("{name}.message"), message, &messages))?;
162        let span = match given {
163            Given::Words { text, .. } if !text.trim().is_empty() => {
164                narrow(name, words, span, text)?
165            }
166            _ => span,
167        };
168        let range = words
169            .range(span)
170            .map_err(|_| out_of_range(name, span, words))?;
171        // A value in another part of this message is that part's act's, not this one's; a
172        // record offered is chosen by its handle, and one named by its name, wherever its
173        // words are: a record is looked up, never copied.
174        let by_name = matches!(given, Given::Record { record, .. } if record == BY_NAME);
175        if message == CURRENT && elsewhere(input, span) && !chosen(input, given) && !by_name {
176            extracted.not_given.push(name.clone());
177            extracted.elsewhere.push((name.clone(), span));
178            if let Ok(value) = value_of(turn, input, name, &argument.shape, given, words, span) {
179                let excerpt = Some(Excerpt {
180                    message: reference,
181                    words: range,
182                });
183                extracted
184                    .aside
185                    .insert(name.clone(), UnderstoodArgument { value, excerpt });
186            }
187            continue;
188        }
189        let value = value_of(turn, input, name, &argument.shape, given, words, span)?;
190        if !matches!(
191            argument.source,
192            turnframe_core::operation::ArgumentSource::Inferred
193        ) {
194            says_its_number(name, given, words, span)?;
195        }
196        extracted.arguments.insert(
197            name.clone(),
198            UnderstoodArgument {
199                value,
200                excerpt: Some(Excerpt {
201                    message: reference,
202                    words: range,
203                }),
204            },
205        );
206    }
207    Ok(extracted)
208}
209
210/// A stated number is one its words say, when they hold any: «450» pointed at «500 euros» is
211/// a pointer at another number. Words that say it in letters are not read here.
212fn says_its_number(
213    name: &str,
214    given: &Given,
215    words: &Words,
216    span: Span,
217) -> Result<(), StructuralError> {
218    let stated = match given {
219        Given::Money { amount, .. } => amount.replace(',', ".").trim().parse::<f64>().ok(),
220        Given::Value { value, .. } => value.as_f64(),
221        _ => None,
222    };
223    let (Some(stated), Ok(said)) = (stated, words.slice(span)) else {
224        return Ok(());
225    };
226    let numbers = numbers_in(said);
227    if numbers.is_empty() || numbers.iter().any(|n| (n - stated).abs() < 1e-9) {
228        return Ok(());
229    }
230    let (from, to) = span.shown();
231    Err(StructuralError::new(
232        "number_not_in_words",
233        format!(
234            "`arguments.{name}` is {stated}, which words {from} to {to} («{said}») do not say; \
235             point at the words that say it"
236        ),
237    ))
238}
239
240/// Every value the numbers written in digits in `text` may mean, reading a lone comma or
241/// full stop as decimal or thousands mark, and the last of two kinds as the decimal one.
242fn numbers_in(text: &str) -> Vec<f64> {
243    let mut values = Vec::new();
244    let tokens = text
245        .split(|c: char| !(c.is_ascii_digit() || c == ',' || c == '.'))
246        .map(|token| token.trim_matches(|c: char| c == ',' || c == '.'))
247        .filter(|token| token.starts_with(|c: char| c.is_ascii_digit()));
248    for token in tokens {
249        let commas = token.contains(',');
250        let stops = token.contains('.');
251        let readings: Vec<String> = match (commas, stops) {
252            (false, false) => vec![token.to_owned()],
253            (true, false) => vec![token.replace(',', "."), token.replace(',', "")],
254            (false, true) => vec![token.to_owned(), token.replace('.', "")],
255            (true, true) => {
256                let last = token.rfind([',', '.']).unwrap_or(0);
257                let (whole, part) = token.split_at(last);
258                vec![format!("{}.{}", whole.replace([',', '.'], ""), &part[1..])]
259            }
260        };
261        values.extend(
262            readings
263                .iter()
264                .filter_map(|reading| reading.parse::<f64>().ok()),
265        );
266    }
267    values
268}
269
270/// The words of `span` that `copied` repeats: the value alone, without the words that
271/// name its field or join it to the rest. A copy the words pointed at do not hold is where
272/// the message holds it, when it holds it once.
273fn narrow(name: &str, words: &Words, span: Span, copied: &str) -> Result<Span, StructuralError> {
274    let pointed = words
275        .slice(span)
276        .map_err(|_| out_of_range(name, span, words))?;
277    words
278        .narrow(span, copied)
279        .or_else(|| words.only_place(copied))
280        .ok_or_else(|| {
281        let (from, to) = span.shown();
282        StructuralError::new(
283            "text_not_pointed_at",
284            format!(
285                "`arguments.{name}.text` is «{copied}», which is not among words {from} to {to} \
286                 («{pointed}»); copy the value's words exactly, and point at the words that hold them"
287            ),
288        )
289    })
290}
291
292fn message_words<'a>(turn: &'a UnderstandingInput, name: &str) -> Option<(MessageRef, &'a Words)> {
293    if name == CURRENT {
294        return Some((MessageRef::Current, &turn.message));
295    }
296    let index = name
297        .strip_prefix('m')?
298        .parse::<usize>()
299        .ok()?
300        .checked_sub(1)?;
301    let message = turn.transcript.get(index)?;
302    Some((MessageRef::Earlier { index }, &message.words))
303}
304
305fn wrong_kind(name: &str, shape: &ValueShape) -> StructuralError {
306    let expected = match shape {
307        ValueShape::Text { written: false } => "words",
308        ValueShape::Text { written: true } => "written",
309        ValueShape::Date { .. } => "date",
310        ValueShape::Money => "money",
311        ValueShape::Record { .. } => "record",
312        _ => "value",
313    };
314    StructuralError::new(
315        "wrong_kind",
316        format!("`arguments.{name}` takes kind {expected} or not_given"),
317    )
318}
319
320/// Whether `text` says `year`: its four digits, or its last two as a number of their own
321/// that is not the day.
322pub(crate) fn says_year(text: &str, year: i32, day: u32) -> bool {
323    let runs: Vec<&str> = text
324        .split(|c: char| !c.is_ascii_digit())
325        .filter(|run| !run.is_empty())
326        .collect();
327    let full = year.to_string();
328    let short = format!("{:02}", year.rem_euclid(100));
329    let shorts = runs.iter().filter(|run| **run == short).count();
330    let day_is_short = format!("{day:02}") == short || day.to_string() == short;
331    runs.iter().any(|run| *run == full) || shorts > usize::from(day_is_short)
332}
333
334/// Whether `word`, its last stop left out, is letters in short pieces between stops («S.r.l»).
335fn initialism(word: &str) -> bool {
336    let pieces: Vec<&str> = word.split('.').collect();
337    pieces.len() > 1
338        && pieces.iter().all(|piece| {
339            (1..=3).contains(&piece.chars().count()) && piece.chars().all(char::is_alphabetic)
340        })
341}
342
343/// `text` without a lone full stop after its last word: an initialism keeps its own.
344fn without_stop(text: &str) -> &str {
345    let last = text.split_whitespace().last().unwrap_or_default();
346    match last.strip_suffix('.') {
347        Some(word) if !word.ends_with('.') && !initialism(word) => &text[..text.len() - 1],
348        _ => text,
349    }
350}
351
352/// `text` without one pair of quotes around the whole of it, nor the mark ending the
353/// sentence after them, nor the sentence's own punctuation written inside them («"A,"»).
354fn unquoted(text: &str) -> &str {
355    const PAIRS: [(char, char); 5] = [('"', '"'), ('\'', '\''), ('«', '»'), ('“', '”'), ('‘', '’')];
356    let bare = text.trim_end_matches(['.', '!', '?']);
357    PAIRS
358        .iter()
359        .find_map(|(open, close)| {
360            let inner = bare.strip_prefix(*open)?.strip_suffix(*close)?;
361            let inner = without_stop(inner.trim().trim_end_matches([',', ';', ':'])).trim();
362            (!inner.is_empty()).then_some(inner)
363        })
364        .unwrap_or(text)
365}
366
367fn value_of(
368    turn: &UnderstandingInput,
369    input: &ExtractInput<'_>,
370    name: &str,
371    shape: &ValueShape,
372    given: &Given,
373    words: &Words,
374    span: Span,
375) -> Result<ArgumentValue, StructuralError> {
376    let json = ArgumentValue::Json;
377    match (shape, given) {
378        (ValueShape::Text { written: false }, Given::Words { text: copied, .. }) => {
379            let text = words
380                .slice(span)
381                .map_err(|_| out_of_range(name, span, words))?;
382            // The punctuation that joins a value to the next words is not the value, nor
383            // are the quotes that mark it off, nor the mark ending a sentence the copy drops;
384            // a mark the joining punctuation follows ends no sentence.
385            let joined = text.trim_end_matches([',', ';', ':']);
386            let ends_sentence = joined.len() == text.len();
387            let mut text = joined;
388            let ends = ['.', '!', '?'];
389            if ends_sentence && !copied.trim().is_empty() && !copied.trim_end().ends_with(ends) {
390                text = text.trim_end_matches(ends);
391            }
392            // A full stop after the last word ends the sentence: a value reads the same
393            // without it, and wrong with a stray one. An initialism keeps its own («S.r.l.»).
394            if ends_sentence {
395                text = without_stop(text);
396            }
397            let text = unquoted(text);
398            Ok(json(serde_json::Value::from(text)))
399        }
400        (ValueShape::Text { written: true }, Given::Written { text, .. }) => {
401            if text.trim().is_empty() {
402                return Err(StructuralError::new(
403                    "empty_text",
404                    format!("`arguments.{name}.text` is empty; give not_given instead"),
405                ));
406            }
407            Ok(json(serde_json::Value::from(text.trim())))
408        }
409        (ValueShape::Enum { values }, Given::Value { value, .. }) => {
410            let text = value.as_str().unwrap_or_default();
411            check_one_of(&format!("{name}.value"), text, values)?;
412            Ok(json(value.clone()))
413        }
414        (ValueShape::Integer, Given::Value { value, .. }) if value.is_i64() || value.is_u64() => {
415            Ok(json(value.clone()))
416        }
417        (ValueShape::Number, Given::Value { value, .. }) if value.is_number() => {
418            Ok(json(value.clone()))
419        }
420        (ValueShape::Bool, Given::Value { value, .. }) if value.is_boolean() => {
421            Ok(json(value.clone()))
422        }
423        (ValueShape::Structured, Given::Value { value, .. }) => Ok(json(value.clone())),
424        (ValueShape::Date { direction }, Given::Date { date, .. }) => {
425            // A year the words do not say is not the user's: the date has none.
426            let said = words.slice(span).unwrap_or_default();
427            let date = match date {
428                DateExpr::Absolute {
429                    year: Some(year),
430                    month,
431                    day,
432                } if !says_year(said, *year, *day) => &DateExpr::Absolute {
433                    year: None,
434                    month: *month,
435                    day: *day,
436                },
437                other => other,
438            };
439            // A correction keeps the year of the date it changes when it gives none.
440            let date = match (date, input.corrected.get(name)) {
441                (
442                    DateExpr::Absolute {
443                        year: None,
444                        month,
445                        day,
446                    },
447                    Some(before),
448                ) => DateExpr::Absolute {
449                    year: Some(before.year()),
450                    month: *month,
451                    day: *day,
452                },
453                _ => *date,
454            };
455            let day = date.evaluate(turn.today, *direction).map_err(|error| {
456                StructuralError::new(
457                    "no_such_date",
458                    format!("`arguments.{name}.date`: {error}; give the date the user said"),
459                )
460            })?;
461            Ok(json(serde_json::Value::from(day.to_string())))
462        }
463        (
464            ValueShape::Money,
465            Given::Money {
466                amount, currency, ..
467            },
468        ) => {
469            let money = Money::parse(amount, currency).map_err(|error| {
470                StructuralError::new("not_money", format!("`arguments.{name}`: {error}"))
471            })?;
472            Ok(json(serde_json::to_value(money).unwrap_or_default()))
473        }
474        (
475            ValueShape::Record { workflow },
476            Given::Record {
477                record,
478                name: named,
479                ..
480            },
481        ) if record == BY_NAME => {
482            if named.trim().is_empty() {
483                return Err(StructuralError::new(
484                    "missing_name",
485                    format!("`arguments.{name}.name` must be the record's name when it is by_name"),
486                ));
487            }
488            // A name that is the whole name of one record in view is that record.
489            let words = |text: &str| {
490                text.split_whitespace()
491                    .map(str::to_lowercase)
492                    .collect::<Vec<_>>()
493            };
494            let listed: Vec<&RecordValue> = input
495                .record_choices
496                .get(name)
497                .into_iter()
498                .flatten()
499                .map(|choice| &choice.value)
500                .filter(|value| match value {
501                    RecordValue::Record { token } => turn
502                        .record(token)
503                        .is_some_and(|(_, record)| words(&record.label) == words(named)),
504                    _ => false,
505                })
506                .collect();
507            if let [record] = listed.as_slice() {
508                return Ok(ArgumentValue::Record((*record).clone()));
509            }
510            Ok(ArgumentValue::Record(RecordValue::Named {
511                workflow: workflow.clone(),
512                named: named.trim().to_owned(),
513            }))
514        }
515        (ValueShape::Record { .. }, Given::Record { record, .. }) => {
516            let choices = input
517                .record_choices
518                .get(name)
519                .map(Vec::as_slice)
520                .unwrap_or(&[]);
521            let handles: Vec<String> = choices.iter().map(|c| c.handle.clone()).collect();
522            let choice = choices
523                .iter()
524                .find(|choice| &choice.handle == record)
525                .ok_or_else(|| not_one_of(&format!("{name}.record"), record, &handles))?;
526            Ok(ArgumentValue::Record(choice.value.clone()))
527        }
528        _ => Err(wrong_kind(name, shape)),
529    }
530}