1use std::collections::BTreeMap;
5
6use turnframe_core::operation::{Money, ValueShape};
7use turnframe_core::understanding::{
8 ArgumentValue, Excerpt, MessageRef, RecordValue, UnderstoodArgument,
9};
10use turnframe_tasks::StructuralError;
11
12use crate::input::UnderstandingInput;
13use crate::tasks::extract::{BY_NAME, CURRENT, ExtractInput, Extraction, Given};
14use crate::tasks::{check_one_of, not_one_of, out_of_range};
15use crate::words::{Span, Words};
16
17#[derive(Debug, Clone, Default, PartialEq, Eq)]
19pub struct Extracted {
20 pub arguments: BTreeMap<String, UnderstoodArgument>,
22 pub not_given: Vec<String>,
24 pub elsewhere: Vec<(String, Span)>,
27}
28
29pub fn convert(
37 turn: &UnderstandingInput,
38 input: &ExtractInput<'_>,
39 output: &Extraction,
40) -> Result<Extracted, StructuralError> {
41 let extracted = converted(turn, input, output)?;
42 shares_no_words(input, &extracted)?;
43 Ok(extracted)
44}
45
46fn chosen(input: &ExtractInput<'_>, given: &Given) -> bool {
49 let Given::Record { record, .. } = given else {
50 return false;
51 };
52 input
53 .record_choices
54 .values()
55 .flat_map(|choices| choices.iter())
56 .any(|choice| &choice.handle == record)
57}
58
59fn elsewhere(input: &ExtractInput<'_>, span: Span) -> bool {
62 let within = |outer: Span| outer.from <= span.from && span.to <= outer.to;
63 if within(input.words) || input.continues.is_some_and(within) {
64 return false;
65 }
66 let mut whole = input.words;
69 for kin in &input.kin {
70 if kin.to.saturating_add(1) == whole.from {
71 whole = Span::new(kin.from, whole.to);
72 } else if whole.to.saturating_add(1) == kin.from {
73 whole = Span::new(whole.from, kin.to);
74 }
75 }
76 if within(whole) && span.from <= input.words.to && input.words.from <= span.to {
77 return false;
78 }
79 let reach = Span::new(span.from.min(input.words.from), span.to.max(input.words.to));
80 input
81 .others
82 .iter()
83 .any(|other| other.from <= reach.to && reach.from <= other.to)
84}
85
86fn shares_no_words(input: &ExtractInput<'_>, extracted: &Extracted) -> Result<(), StructuralError> {
90 let deduced = |name: &str| {
91 input.arguments.iter().any(|argument| {
92 argument.name == name
93 && matches!(
94 argument.source,
95 turnframe_core::operation::ArgumentSource::Inferred
96 )
97 })
98 };
99 let pointed: Vec<(&String, &Excerpt)> = extracted
100 .arguments
101 .iter()
102 .filter_map(|(name, argument)| argument.excerpt.as_ref().map(|excerpt| (name, excerpt)))
103 .collect();
104 for (at, (name, excerpt)) in pointed.iter().enumerate() {
105 for (other, theirs) in &pointed[at + 1..] {
106 if deduced(name) != deduced(other) {
107 continue;
108 }
109 let shared = excerpt.message == theirs.message
110 && excerpt.words.first <= theirs.words.last
111 && theirs.words.first <= excerpt.words.last;
112 if shared {
113 return Err(StructuralError::new(
114 "shared_words",
115 format!(
116 "`{name}` and `{other}` point at the same words: each value takes words \
117 of its own, and a value the message only implies points at the words \
118 that imply it"
119 ),
120 ));
121 }
122 }
123 }
124 Ok(())
125}
126
127fn converted(
128 turn: &UnderstandingInput,
129 input: &ExtractInput<'_>,
130 output: &Extraction,
131) -> Result<Extracted, StructuralError> {
132 let asked: Vec<String> = input.arguments.iter().map(|a| a.name.clone()).collect();
133 if let Some(unknown) = output.arguments.keys().find(|name| !asked.contains(name)) {
134 return Err(not_one_of("argument", unknown, &asked));
135 }
136 let messages = input.messages(turn);
137 let mut extracted = Extracted::default();
138 for argument in &input.arguments {
139 let name = &argument.name;
140 let given = output.arguments.get(name).ok_or_else(|| {
141 StructuralError::new("missing_argument", format!("`arguments.{name}` is missing"))
142 })?;
143 let Some((message, span)) = given.pointer() else {
144 extracted.not_given.push(name.clone());
145 continue;
146 };
147 check_one_of(&format!("{name}.message"), message, &messages)?;
148 let (message, span) = match given {
151 Given::Words { text, .. } if message != CURRENT && !text.trim().is_empty() => turn
152 .message
153 .narrow(input.words, text)
154 .map_or((message, span), |here| (CURRENT, here)),
155 _ => (message, span),
156 };
157 let (reference, words) = message_words(turn, message)
158 .ok_or_else(|| not_one_of(&format!("{name}.message"), message, &messages))?;
159 let span = match given {
160 Given::Words { text, .. } if !text.trim().is_empty() => {
161 narrow(name, words, span, text)?
162 }
163 _ => span,
164 };
165 let range = words
166 .range(span)
167 .map_err(|_| out_of_range(name, span, words))?;
168 let by_name = matches!(given, Given::Record { record, .. } if record == BY_NAME);
172 if message == CURRENT && elsewhere(input, span) && !chosen(input, given) && !by_name {
173 extracted.not_given.push(name.clone());
174 extracted.elsewhere.push((name.clone(), span));
175 continue;
176 }
177 let value = value_of(turn, input, name, &argument.shape, given, words, span)?;
178 if !matches!(
179 argument.source,
180 turnframe_core::operation::ArgumentSource::Inferred
181 ) {
182 says_its_number(name, given, words, span)?;
183 }
184 extracted.arguments.insert(
185 name.clone(),
186 UnderstoodArgument {
187 value,
188 excerpt: Some(Excerpt {
189 message: reference,
190 words: range,
191 }),
192 },
193 );
194 }
195 Ok(extracted)
196}
197
198fn says_its_number(
201 name: &str,
202 given: &Given,
203 words: &Words,
204 span: Span,
205) -> Result<(), StructuralError> {
206 let stated = match given {
207 Given::Money { amount, .. } => amount.replace(',', ".").trim().parse::<f64>().ok(),
208 Given::Value { value, .. } => value.as_f64(),
209 _ => None,
210 };
211 let (Some(stated), Ok(said)) = (stated, words.slice(span)) else {
212 return Ok(());
213 };
214 let numbers = numbers_in(said);
215 if numbers.is_empty() || numbers.iter().any(|n| (n - stated).abs() < 1e-9) {
216 return Ok(());
217 }
218 let (from, to) = span.shown();
219 Err(StructuralError::new(
220 "number_not_in_words",
221 format!(
222 "`arguments.{name}` is {stated}, which words {from} to {to} («{said}») do not say; \
223 point at the words that say it"
224 ),
225 ))
226}
227
228fn numbers_in(text: &str) -> Vec<f64> {
231 let mut values = Vec::new();
232 let tokens = text
233 .split(|c: char| !(c.is_ascii_digit() || c == ',' || c == '.'))
234 .map(|token| token.trim_matches(|c: char| c == ',' || c == '.'))
235 .filter(|token| token.starts_with(|c: char| c.is_ascii_digit()));
236 for token in tokens {
237 let commas = token.contains(',');
238 let stops = token.contains('.');
239 let readings: Vec<String> = match (commas, stops) {
240 (false, false) => vec![token.to_owned()],
241 (true, false) => vec![token.replace(',', "."), token.replace(',', "")],
242 (false, true) => vec![token.to_owned(), token.replace('.', "")],
243 (true, true) => {
244 let last = token.rfind([',', '.']).unwrap_or(0);
245 let (whole, part) = token.split_at(last);
246 vec![format!("{}.{}", whole.replace([',', '.'], ""), &part[1..])]
247 }
248 };
249 values.extend(
250 readings
251 .iter()
252 .filter_map(|reading| reading.parse::<f64>().ok()),
253 );
254 }
255 values
256}
257
258fn narrow(name: &str, words: &Words, span: Span, copied: &str) -> Result<Span, StructuralError> {
262 let pointed = words
263 .slice(span)
264 .map_err(|_| out_of_range(name, span, words))?;
265 words
266 .narrow(span, copied)
267 .or_else(|| words.only_place(copied))
268 .ok_or_else(|| {
269 let (from, to) = span.shown();
270 StructuralError::new(
271 "text_not_pointed_at",
272 format!(
273 "`arguments.{name}.text` is «{copied}», which is not among words {from} to {to} \
274 («{pointed}»); copy the value's words exactly, and point at the words that hold them"
275 ),
276 )
277 })
278}
279
280fn message_words<'a>(turn: &'a UnderstandingInput, name: &str) -> Option<(MessageRef, &'a Words)> {
281 if name == CURRENT {
282 return Some((MessageRef::Current, &turn.message));
283 }
284 let index = name
285 .strip_prefix('m')?
286 .parse::<usize>()
287 .ok()?
288 .checked_sub(1)?;
289 let message = turn.transcript.get(index)?;
290 Some((MessageRef::Earlier { index }, &message.words))
291}
292
293fn wrong_kind(name: &str, shape: &ValueShape) -> StructuralError {
294 let expected = match shape {
295 ValueShape::Text { written: false } => "words",
296 ValueShape::Text { written: true } => "written",
297 ValueShape::Date { .. } => "date",
298 ValueShape::Money => "money",
299 ValueShape::Record { .. } => "record",
300 _ => "value",
301 };
302 StructuralError::new(
303 "wrong_kind",
304 format!("`arguments.{name}` takes kind {expected} or not_given"),
305 )
306}
307
308fn unquoted(text: &str) -> &str {
310 const PAIRS: [(char, char); 5] = [('"', '"'), ('\'', '\''), ('«', '»'), ('“', '”'), ('‘', '’')];
311 PAIRS
312 .iter()
313 .find_map(|(open, close)| {
314 let inner = text.strip_prefix(*open)?.strip_suffix(*close)?;
315 (!inner.trim().is_empty()).then(|| inner.trim())
316 })
317 .unwrap_or(text)
318}
319
320fn value_of(
321 turn: &UnderstandingInput,
322 input: &ExtractInput<'_>,
323 name: &str,
324 shape: &ValueShape,
325 given: &Given,
326 words: &Words,
327 span: Span,
328) -> Result<ArgumentValue, StructuralError> {
329 let json = ArgumentValue::Json;
330 match (shape, given) {
331 (ValueShape::Text { written: false }, Given::Words { text: copied, .. }) => {
332 let text = words
333 .slice(span)
334 .map_err(|_| out_of_range(name, span, words))?;
335 let joined = text.trim_end_matches([',', ';', ':']);
339 let ends_sentence = joined.len() == text.len();
340 let mut text = joined;
341 let ends = ['.', '!', '?'];
342 if ends_sentence && !copied.trim().is_empty() && !copied.trim_end().ends_with(ends) {
343 text = text.trim_end_matches(ends);
344 }
345 let text = unquoted(text);
346 Ok(json(serde_json::Value::from(text)))
347 }
348 (ValueShape::Text { written: true }, Given::Written { text, .. }) => {
349 if text.trim().is_empty() {
350 return Err(StructuralError::new(
351 "empty_text",
352 format!("`arguments.{name}.text` is empty; give not_given instead"),
353 ));
354 }
355 Ok(json(serde_json::Value::from(text.trim())))
356 }
357 (ValueShape::Enum { values }, Given::Value { value, .. }) => {
358 let text = value.as_str().unwrap_or_default();
359 check_one_of(&format!("{name}.value"), text, values)?;
360 Ok(json(value.clone()))
361 }
362 (ValueShape::Integer, Given::Value { value, .. }) if value.is_i64() || value.is_u64() => {
363 Ok(json(value.clone()))
364 }
365 (ValueShape::Number, Given::Value { value, .. }) if value.is_number() => {
366 Ok(json(value.clone()))
367 }
368 (ValueShape::Bool, Given::Value { value, .. }) if value.is_boolean() => {
369 Ok(json(value.clone()))
370 }
371 (ValueShape::Structured, Given::Value { value, .. }) => Ok(json(value.clone())),
372 (ValueShape::Date { direction }, Given::Date { date, .. }) => {
373 let day = date.evaluate(turn.today, *direction).map_err(|error| {
374 StructuralError::new(
375 "no_such_date",
376 format!("`arguments.{name}.date`: {error}; give the date the user said"),
377 )
378 })?;
379 Ok(json(serde_json::Value::from(day.to_string())))
380 }
381 (
382 ValueShape::Money,
383 Given::Money {
384 amount, currency, ..
385 },
386 ) => {
387 let money = Money::parse(amount, currency).map_err(|error| {
388 StructuralError::new("not_money", format!("`arguments.{name}`: {error}"))
389 })?;
390 Ok(json(serde_json::to_value(money).unwrap_or_default()))
391 }
392 (
393 ValueShape::Record { workflow },
394 Given::Record {
395 record,
396 name: named,
397 ..
398 },
399 ) if record == BY_NAME => {
400 if named.trim().is_empty() {
401 return Err(StructuralError::new(
402 "missing_name",
403 format!("`arguments.{name}.name` must be the record's name when it is by_name"),
404 ));
405 }
406 let words = |text: &str| {
408 text.split_whitespace()
409 .map(str::to_lowercase)
410 .collect::<Vec<_>>()
411 };
412 let listed: Vec<&RecordValue> = input
413 .record_choices
414 .get(name)
415 .into_iter()
416 .flatten()
417 .map(|choice| &choice.value)
418 .filter(|value| match value {
419 RecordValue::Record { token } => turn
420 .record(token)
421 .is_some_and(|(_, record)| words(&record.label) == words(named)),
422 _ => false,
423 })
424 .collect();
425 if let [record] = listed.as_slice() {
426 return Ok(ArgumentValue::Record((*record).clone()));
427 }
428 Ok(ArgumentValue::Record(RecordValue::Named {
429 workflow: workflow.clone(),
430 named: named.trim().to_owned(),
431 }))
432 }
433 (ValueShape::Record { .. }, Given::Record { record, .. }) => {
434 let choices = input
435 .record_choices
436 .get(name)
437 .map(Vec::as_slice)
438 .unwrap_or(&[]);
439 let handles: Vec<String> = choices.iter().map(|c| c.handle.clone()).collect();
440 let choice = choices
441 .iter()
442 .find(|choice| &choice.handle == record)
443 .ok_or_else(|| not_one_of(&format!("{name}.record"), record, &handles))?;
444 Ok(ArgumentValue::Record(choice.value.clone()))
445 }
446 _ => Err(wrong_kind(name, shape)),
447 }
448}