1use 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#[derive(Debug, Clone, Default, PartialEq, Eq)]
20pub struct Extracted {
21 pub arguments: BTreeMap<String, UnderstoodArgument>,
23 pub not_given: Vec<String>,
25 pub elsewhere: Vec<(String, Span)>,
28 pub aside: BTreeMap<String, UnderstoodArgument>,
30}
31
32pub 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
49fn 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
62fn 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 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
89fn 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 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 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
210fn 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
240fn 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
270fn 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
320pub(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
334fn 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
343fn 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
352fn 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 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 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 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 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 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}