1use std::collections::BTreeMap;
8use std::fmt::Write as _;
9
10use serde::{Deserialize, Serialize};
11use serde_json::{Value, json};
12use turnframe_core::ids::OperationKey;
13use turnframe_core::operation::{ArgumentSpec, DateExpr, OperationSpec, ValueShape};
14use turnframe_core::understanding::RecordValue;
15use turnframe_provider::request::Message;
16use turnframe_tasks::{ModelTask, StructuralError, TaskKind};
17
18use crate::input::{RecordBrief, UnderstandingInput, WorkflowBrief};
19use crate::render;
20use crate::schema::{any_of, date_expression, index, object, one_of, variant};
21use crate::values;
22use crate::words::Span;
23
24pub const CURRENT: &str = "current";
26
27pub const BY_NAME: &str = "by_name";
29
30const BUILT_IN: &str = include_str!("../../prompts/understand/extract.md");
31
32#[derive(Debug, Clone, Copy)]
34pub struct Extract<'a> {
35 turn: &'a UnderstandingInput,
36}
37
38impl<'a> Extract<'a> {
39 #[must_use]
41 pub const fn new(turn: &'a UnderstandingInput) -> Self {
42 Self { turn }
43 }
44
45 #[must_use]
47 pub const fn turn(&self) -> &'a UnderstandingInput {
48 self.turn
49 }
50}
51
52#[derive(Debug, Clone, Copy)]
54pub enum RecordContext<'a> {
55 Existing(&'a RecordBrief),
57 New,
59 SameTurn,
61 Nothing,
63}
64
65#[derive(Debug, Clone)]
67pub struct RecordChoice {
68 pub handle: String,
70 pub value: RecordValue,
72 pub label: String,
74}
75
76#[derive(Debug, Clone)]
78pub struct ExtractInput<'a> {
79 pub label: &'static str,
81 pub words: Span,
83 pub spec: &'a OperationSpec,
85 pub workflow: &'a WorkflowBrief,
87 pub record: RecordContext<'a>,
89 pub arguments: Vec<&'a ArgumentSpec>,
91 pub record_choices: BTreeMap<String, Vec<RecordChoice>>,
93 pub continues: Option<Span>,
95 pub others: Vec<Span>,
97 pub kin: Vec<Span>,
100 pub also: Vec<OperationKey>,
102 pub transcript: usize,
104 pub note: Option<String>,
106 pub occurrence: Option<(usize, usize)>,
108 pub corrected: BTreeMap<String, chrono::NaiveDate>,
110}
111
112impl ExtractInput<'_> {
113 #[must_use]
115 pub fn messages(&self, turn: &UnderstandingInput) -> Vec<String> {
116 let mut names = vec![CURRENT.to_owned()];
117 names.extend(render::shown_messages(turn, self.transcript));
118 names
119 }
120}
121
122#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
124pub struct Extraction {
125 pub arguments: BTreeMap<String, Given>,
127}
128
129#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
131#[serde(tag = "kind", rename_all = "snake_case")]
132#[allow(missing_docs)]
133pub enum Given {
134 NotGiven,
136 Words {
139 text: String,
140 message: String,
141 from: usize,
142 to: usize,
143 },
144 Written {
146 message: String,
147 from: usize,
148 to: usize,
149 text: String,
150 },
151 Value {
153 message: String,
154 from: usize,
155 to: usize,
156 value: Value,
157 },
158 Date {
160 message: String,
161 from: usize,
162 to: usize,
163 date: DateExpr,
164 },
165 Money {
167 message: String,
168 from: usize,
169 to: usize,
170 amount: String,
171 currency: String,
172 },
173 Record {
175 message: String,
176 from: usize,
177 to: usize,
178 record: String,
179 #[serde(default)]
180 name: String,
181 },
182}
183
184impl Given {
185 #[must_use]
187 pub fn pointer(&self) -> Option<(&str, Span)> {
188 match self {
189 Self::NotGiven => None,
190 Self::Words {
191 message, from, to, ..
192 }
193 | Self::Written {
194 message, from, to, ..
195 }
196 | Self::Value {
197 message, from, to, ..
198 }
199 | Self::Date {
200 message, from, to, ..
201 }
202 | Self::Money {
203 message, from, to, ..
204 }
205 | Self::Record {
206 message, from, to, ..
207 } => Some((message, Span::from_shown(*from, *to))),
208 }
209 }
210}
211
212fn given_kind(shape: &ValueShape) -> &'static str {
214 match shape {
215 ValueShape::Text { written: false } => "words",
216 ValueShape::Text { written: true } => "written",
217 ValueShape::Date { .. } => "date",
218 ValueShape::Money => "money",
219 ValueShape::Record { .. } => "record",
220 _ => "value",
221 }
222}
223
224fn argument_schema(
225 argument: &ArgumentSpec,
226 spec: &OperationSpec,
227 messages: &[String],
228 records: Option<&Vec<RecordChoice>>,
229) -> Value {
230 let pointer = || {
231 vec![
232 ("message", one_of(messages.iter().cloned())),
233 ("from", index()),
234 ("to", index()),
235 ]
236 };
237 let mut fields = pointer();
238 let kind = given_kind(&argument.shape);
239 match &argument.shape {
240 ValueShape::Text { written: false } => fields.insert(
241 0,
242 (
243 "text",
244 json!({
245 "type": "string",
246 "description": "The value's own words, copied exactly as the user wrote them, without the punctuation that ends their sentence."
247 }),
248 ),
249 ),
250 ValueShape::Text { written: true } => fields.push(("text", json!({ "type": "string" }))),
251 ValueShape::Enum { values } => fields.push(("value", one_of(values.iter().cloned()))),
252 ValueShape::Integer => fields.push(("value", json!({ "type": "integer" }))),
253 ValueShape::Number => fields.push(("value", json!({ "type": "number" }))),
254 ValueShape::Bool => fields.push(("value", json!({ "type": "boolean" }))),
255 ValueShape::Date { .. } => fields.push(("date", date_expression())),
256 ValueShape::Money => {
257 fields.push(("amount", json!({ "type": "string" })));
258 fields.push(("currency", json!({ "type": "string" })));
259 }
260 ValueShape::Record { .. } => {
261 let mut handles: Vec<String> = records
262 .map(|choices| choices.iter().map(|c| c.handle.clone()).collect())
263 .unwrap_or_default();
264 handles.push(BY_NAME.to_owned());
265 fields.insert(
268 0,
269 (
270 "name",
271 json!({
272 "type": "string",
273 "description": "The words naming the record, copied exactly as the user wrote them, without the punctuation that ends their sentence."
274 }),
275 ),
276 );
277 fields.push(("record", one_of(handles)));
278 }
279 _ => fields.push(("value", structured(spec, &argument.name))),
280 }
281 any_of(vec![variant("not_given", vec![]), variant(kind, fields)])
282}
283
284fn structured(spec: &OperationSpec, name: &str) -> Value {
286 let root = spec.arguments_schema.as_value();
287 let property = root
288 .pointer(&format!("/properties/{name}"))
289 .cloned()
290 .unwrap_or_else(|| json!({}));
291 let defs = root.get("$defs").cloned();
292 inline(property, defs.as_ref(), 0)
293}
294
295fn inline(value: Value, defs: Option<&Value>, depth: usize) -> Value {
296 match value {
297 Value::Object(map) if depth < 16 => {
298 if let Some(Value::String(reference)) = map.get("$ref") {
299 let name = reference.rsplit('/').next().unwrap_or_default();
300 let target = defs.and_then(|d| d.get(name)).cloned().unwrap_or(json!({}));
301 return inline(target, defs, depth + 1);
302 }
303 Value::Object(
304 map.into_iter()
305 .map(|(key, child)| (key, inline(child, defs, depth + 1)))
306 .collect(),
307 )
308 }
309 Value::Array(items) => Value::Array(
310 items
311 .into_iter()
312 .map(|item| inline(item, defs, depth + 1))
313 .collect(),
314 ),
315 other => other,
316 }
317}
318
319impl<'a> ModelTask for Extract<'a> {
320 type Input = ExtractInput<'a>;
321 type Output = Extraction;
322
323 fn kind(&self) -> TaskKind {
324 TaskKind::Extract
325 }
326
327 fn prompt_name(&self) -> &str {
328 "understand.extract"
329 }
330
331 fn instructions(&self) -> &str {
332 BUILT_IN
333 }
334
335 fn schema(&self, input: &ExtractInput<'a>) -> Value {
336 let messages = input.messages(self.turn);
337 let properties = input
338 .arguments
339 .iter()
340 .map(|argument| {
341 (
342 argument.name.as_str(),
343 argument_schema(
344 argument,
345 input.spec,
346 &messages,
347 input.record_choices.get(&argument.name),
348 ),
349 )
350 })
351 .collect();
352 object(vec![("arguments", object(properties))])
353 }
354
355 fn render(&self, input: &ExtractInput<'a>) -> Vec<Message> {
356 let turn = self.turn;
357 let words = &turn.message;
358 let record = match input.record {
359 RecordContext::Existing(record) => {
360 Some(format!("Record: {}", render::record_line(record, false)))
361 }
362 RecordContext::New => Some(format!("Record: a new {} record", input.workflow.key)),
363 RecordContext::SameTurn => Some(format!(
364 "Record: the {} record this message creates",
365 input.workflow.key
366 )),
367 RecordContext::Nothing => None,
368 };
369 let mut choices = String::new();
370 for (name, listed) in &input.record_choices {
371 if !choices.is_empty() {
372 choices.push_str("\n\n");
373 }
374 if listed.is_empty() {
375 let _ = write!(
376 choices,
377 "No record is listed for {name}: one the user names is {BY_NAME}, with its name."
378 );
379 continue;
380 }
381 let _ = write!(choices, "Records {name} may name:");
382 for choice in listed {
383 let _ = write!(choices, "\n- {}: {}", choice.handle, choice.label);
384 }
385 let _ = write!(
386 choices,
387 "\n- {BY_NAME}: a record the user names that is not listed"
388 );
389 }
390 let asked: Vec<&str> = input.arguments.iter().map(|a| a.name.as_str()).collect();
391 let offered = input
392 .spec
393 .arguments
394 .iter()
395 .filter(|argument| render::model_given(&argument.source))
396 .count();
397 let only = (asked.len() < offered).then(|| format!("Give only: {}.", asked.join(", ")));
398 vec![Message::user(render::sections([
399 Some(format!(
400 "Operation: {}",
401 render::operation_line(input.spec, self.turn)
402 )),
403 input
404 .spec
405 .guidance
406 .as_ref()
407 .map(|g| format!("Guidance: {g}")),
408 render::arguments(input.spec, turn),
409 only,
410 render::examples(input.spec),
411 render::glossary(input.workflow),
412 record,
413 (!choices.is_empty()).then_some(choices),
414 Some(format!("Today: {}", turn.today.format("%A %-d %B %Y"))),
415 render::transcript(turn, input.transcript),
416 Some(render::titled_message(
417 &format!("Message ({CURRENT})"),
418 words,
419 )),
420 Some(render::unit(input.label, words, input.words)),
421 input
422 .continues
423 .map(|span| render::unit("It continues", words, span)),
424 (!input.also.is_empty()).then(|| {
425 let keys: Vec<&str> = input.also.iter().map(OperationKey::as_str).collect();
426 format!(
427 "This request also asks for {}: the words of its values are that \
428 operation's.",
429 keys.join(", ")
430 )
431 }),
432 (!input.others.is_empty()).then(|| {
433 let spans: Vec<String> = input
434 .others
435 .iter()
436 .map(|span| {
437 let (from, to) = span.shown();
438 format!("words {from} to {to}")
439 })
440 .collect();
441 format!(
442 "Other parts of the message, each read on its own, give none of this \
443 part's values: {}.",
444 spans.join(", ")
445 )
446 }),
447 input.occurrence.map(|(number, of)| {
448 format!(
449 "This request asks for {} {of} times: give only the values of occurrence \
450 {number} of {of}, counting in the order the message says them.",
451 input.spec.key
452 )
453 }),
454 input
455 .note
456 .as_ref()
457 .map(|note| format!("A check of the whole message found: {note}")),
458 ]))]
459 }
460
461 fn check(&self, input: &ExtractInput<'a>, output: &Extraction) -> Result<(), StructuralError> {
462 values::convert(self.turn, input, output).map(|_| ())
463 }
464}
465
466#[cfg(test)]
467mod tests {
468 use super::*;
469
470 #[test]
471 fn a_note_from_the_whole_turn_check_is_shown() {
472 let turn = UnderstandingInput::new("name Lisbon", "en-GB", chrono::NaiveDate::MIN);
473 let spec = OperationSpec::new("trip.set_name").summary("Name the trip.");
474 let workflow = WorkflowBrief::new("trip");
475 let input = ExtractInput {
476 label: "Request",
477 words: Span::new(0, 1),
478 spec: &spec,
479 workflow: &workflow,
480 record: RecordContext::Nothing,
481 arguments: Vec::new(),
482 record_choices: BTreeMap::new(),
483 continues: None,
484 others: Vec::new(),
485 kin: Vec::new(),
486 also: Vec::new(),
487 transcript: 0,
488 note: Some("the value of value is in «rent»".to_owned()),
489 occurrence: None,
490 corrected: std::collections::BTreeMap::new(),
491 };
492 let rendered = format!("{:?}", Extract::new(&turn).render(&input));
493 assert!(
494 rendered
495 .contains("A check of the whole message found: the value of value is in «rent»"),
496 "{rendered}"
497 );
498 }
499
500 #[test]
501 fn the_words_other_parts_hold_are_named() {
502 let turn =
503 UnderstandingInput::new("name Lisbon, fly tomorrow", "en-GB", chrono::NaiveDate::MIN);
504 let spec = OperationSpec::new("trip.set_name").summary("Name the trip.");
505 let workflow = WorkflowBrief::new("trip");
506 let input = ExtractInput {
507 label: "Request",
508 words: Span::new(0, 1),
509 spec: &spec,
510 workflow: &workflow,
511 record: RecordContext::Nothing,
512 arguments: Vec::new(),
513 record_choices: BTreeMap::new(),
514 continues: None,
515 others: vec![Span::new(2, 3)],
516 kin: Vec::new(),
517 also: Vec::new(),
518 transcript: 0,
519 note: None,
520 occurrence: None,
521 corrected: std::collections::BTreeMap::new(),
522 };
523 let rendered = format!("{:?}", Extract::new(&turn).render(&input));
524 assert!(
525 rendered.contains(
526 "Other parts of the message, each read on its own, give none of this part's \
527 values: words 3 to 4."
528 ),
529 "{rendered}"
530 );
531 }
532
533 #[test]
534 fn the_other_operations_of_the_request_are_named() {
535 let turn = UnderstandingInput::new(
536 "name Lisbon and fly tomorrow",
537 "en-GB",
538 chrono::NaiveDate::MIN,
539 );
540 let spec = OperationSpec::new("trip.set_name").summary("Name the trip.");
541 let workflow = WorkflowBrief::new("trip");
542 let input = ExtractInput {
543 label: "Request",
544 words: Span::new(0, 4),
545 spec: &spec,
546 workflow: &workflow,
547 record: RecordContext::Nothing,
548 arguments: Vec::new(),
549 record_choices: BTreeMap::new(),
550 continues: None,
551 others: Vec::new(),
552 kin: Vec::new(),
553 also: vec!["trip.set_travel_date".into()],
554 transcript: 0,
555 note: None,
556 occurrence: None,
557 corrected: std::collections::BTreeMap::new(),
558 };
559 let rendered = format!("{:?}", Extract::new(&turn).render(&input));
560 assert!(
561 rendered.contains(
562 "This request also asks for trip.set_travel_date: the words of its values are \
563 that operation's."
564 ),
565 "{rendered}"
566 );
567 }
568
569 #[test]
570 fn a_given_value_deserializes_by_its_kind() {
571 let given: Given = serde_json::from_value(json!({
572 "kind": "date", "message": "current", "from": 4, "to": 4,
573 "date": {"kind": "relative", "unit": "day", "amount": 1}
574 }))
575 .unwrap();
576 assert_eq!(
577 given.pointer(),
578 Some(("current", Span::new(3, 3))),
579 "a model counts words from 1"
580 );
581 let absent: Given = serde_json::from_value(json!({"kind": "not_given"})).unwrap();
582 assert_eq!(absent, Given::NotGiven);
583 }
584}