Skip to main content

turnframe_understand/tasks/
extract.rs

1//! `extract`: each argument's value as the user stated it, or `not_given`.
2//!
3//! A value is given in its argument's shape and always points at the words that state
4//! it. Code turns it into the operation's value: words become text, a date expression a
5//! date, an amount money, a handle a record ([`crate::values`]).
6
7use 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
24/// The message name of the user's current message.
25pub const CURRENT: &str = "current";
26
27/// The record handle for a record the user names that is not listed.
28pub const BY_NAME: &str = "by_name";
29
30const BUILT_IN: &str = include_str!("../../prompts/understand/extract.md");
31
32/// The extraction task, over one turn.
33#[derive(Debug, Clone, Copy)]
34pub struct Extract<'a> {
35    turn: &'a UnderstandingInput,
36}
37
38impl<'a> Extract<'a> {
39    /// The task for `turn`.
40    #[must_use]
41    pub const fn new(turn: &'a UnderstandingInput) -> Self {
42        Self { turn }
43    }
44
45    /// The turn it runs over.
46    #[must_use]
47    pub const fn turn(&self) -> &'a UnderstandingInput {
48        self.turn
49    }
50}
51
52/// The record an act applies to, as extraction shows it.
53#[derive(Debug, Clone, Copy)]
54pub enum RecordContext<'a> {
55    /// A record in view.
56    Existing(&'a RecordBrief),
57    /// A record the act creates.
58    New,
59    /// A record an earlier act of this message creates.
60    SameTurn,
61    /// No record.
62    Nothing,
63}
64
65/// A record an argument may name, with the handle it is listed under.
66#[derive(Debug, Clone)]
67pub struct RecordChoice {
68    /// The handle.
69    pub handle: String,
70    /// What it resolves to.
71    pub value: RecordValue,
72    /// How it is shown.
73    pub label: String,
74}
75
76/// One act's arguments to extract, and their context.
77#[derive(Debug, Clone)]
78pub struct ExtractInput<'a> {
79    /// How the unit is shown.
80    pub label: &'static str,
81    /// Its words.
82    pub words: Span,
83    /// The operation.
84    pub spec: &'a OperationSpec,
85    /// Its workflow.
86    pub workflow: &'a WorkflowBrief,
87    /// The record it applies to.
88    pub record: RecordContext<'a>,
89    /// The arguments asked for, in declaration order.
90    pub arguments: Vec<&'a ArgumentSpec>,
91    /// The records each record-valued argument may name.
92    pub record_choices: BTreeMap<String, Vec<RecordChoice>>,
93    /// Words of this message the unit continues, such as the request a correction changes.
94    pub continues: Option<Span>,
95    /// Words of this message the other parts hold.
96    pub others: Vec<Span>,
97    /// Words of the other parts asking for this same operation: a value the segmentation
98    /// cut in two runs on into a neighbouring one.
99    pub kin: Vec<Span>,
100    /// The other operations the same request asks for.
101    pub also: Vec<OperationKey>,
102    /// How many earlier messages are shown.
103    pub transcript: usize,
104    /// What the whole-turn check found, when this call reads the act again.
105    pub note: Option<String>,
106    /// Which of the unit's acts of this operation to read, and how many it asks for.
107    pub occurrence: Option<(usize, usize)>,
108    /// The dates a correction changes, by argument: one it gives without a year takes theirs.
109    pub corrected: BTreeMap<String, chrono::NaiveDate>,
110}
111
112impl ExtractInput<'_> {
113    /// The names a value's `message` may take.
114    #[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/// Every argument's value, by name.
123#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
124pub struct Extraction {
125    /// The values.
126    pub arguments: BTreeMap<String, Given>,
127}
128
129/// One argument's value as the model gave it.
130#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
131#[serde(tag = "kind", rename_all = "snake_case")]
132#[allow(missing_docs)]
133pub enum Given {
134    /// The user did not state it.
135    NotGiven,
136    /// The user's words are the value. `text` copies them and narrows the pointer to
137    /// them; left empty, the pointer stands.
138    Words {
139        text: String,
140        message: String,
141        from: usize,
142        to: usize,
143    },
144    /// Text the model wrote from the words pointed at.
145    Written {
146        message: String,
147        from: usize,
148        to: usize,
149        text: String,
150    },
151    /// A value of a closed set, a number or a flag.
152    Value {
153        message: String,
154        from: usize,
155        to: usize,
156        value: Value,
157    },
158    /// A date as the user said it.
159    Date {
160        message: String,
161        from: usize,
162        to: usize,
163        date: DateExpr,
164    },
165    /// An amount of money.
166    Money {
167        message: String,
168        from: usize,
169        to: usize,
170        amount: String,
171        currency: String,
172    },
173    /// A record, by its handle; one not listed, by its name as written.
174    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    /// The message and words a given value points at.
186    #[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
212/// The kind of value an argument of this shape is given as.
213fn 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            // The name comes first, as a text value's words do: copying it anchors the
266            // choice, where a bare handle list reads as «nothing here» when it is short.
267            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
284/// The argument's own schema from the arguments type, with no references left.
285fn 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}