Skip to main content

turnframe_understand/tasks/
route.rs

1//! `route`: which operations a request asks for, among those on offer, in the order asked.
2
3use std::fmt::Write as _;
4
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7use turnframe_core::ids::WorkflowKey;
8use turnframe_core::operation::OperationSpec;
9use turnframe_provider::request::Message;
10use turnframe_tasks::{ModelTask, StructuralError, TaskKind};
11
12use crate::input::{UnderstandingInput, WorkflowBrief};
13use crate::render;
14use crate::schema::{array, object, one_of};
15use crate::tasks::check_one_of;
16use crate::words::Span;
17
18/// The answer when no operation on offer does what was asked.
19pub const NONE: &str = "none";
20
21/// Prefixes the answer that starts a new case: `start:trip`.
22pub const START: &str = "start:";
23
24const BUILT_IN: &str = include_str!("../../prompts/understand/route.md");
25
26/// The routing task, over one turn.
27#[derive(Debug, Clone, Copy)]
28pub struct Route<'a> {
29    turn: &'a UnderstandingInput,
30    again: bool,
31}
32
33impl<'a> Route<'a> {
34    /// The task for `turn`.
35    #[must_use]
36    pub const fn new(turn: &'a UnderstandingInput) -> Self {
37        Self { turn, again: false }
38    }
39
40    /// The task routing a part a second time, told why its first reading is in doubt: there,
41    /// readings that find no operation may make a majority, and doing nothing wins the doubt.
42    #[must_use]
43    pub const fn again(turn: &'a UnderstandingInput) -> Self {
44        Self { turn, again: true }
45    }
46}
47
48/// One unit to route and what is on offer to it.
49#[derive(Debug, Clone)]
50pub struct RouteInput<'a> {
51    /// `Request`, `Correction` or `Cancel`, as the unit is shown.
52    pub label: &'static str,
53    /// Its words.
54    pub words: Span,
55    /// The workflows whose operations are on offer.
56    pub workflows: Vec<&'a WorkflowBrief>,
57    /// What a second route is told of the first reading, or nothing.
58    pub note: Option<String>,
59    /// The words of the message's other parts, each routed on its own.
60    pub others: Vec<Span>,
61}
62
63impl RouteInput<'_> {
64    /// The operations on offer: proposable ones, each once.
65    #[must_use]
66    pub fn offered(&self) -> Vec<&OperationSpec> {
67        self.workflows
68            .iter()
69            .flat_map(|workflow| workflow.operations.iter())
70            .filter(|spec| spec.availability.is_proposable())
71            .collect()
72    }
73
74    /// The workflows a new case may be started of, save those offering an operation
75    /// that opens one: that operation carries arguments, a bare start does not.
76    #[must_use]
77    pub fn startable(&self) -> Vec<&WorkflowKey> {
78        let offered = self.offered();
79        self.workflows
80            .iter()
81            .filter(|workflow| workflow.startable)
82            .filter(|workflow| {
83                !workflow
84                    .new_case
85                    .iter()
86                    .any(|key| offered.iter().any(|spec| &spec.key == key))
87            })
88            .map(|workflow| &workflow.key)
89            .collect()
90    }
91
92    /// Every answer allowed.
93    #[must_use]
94    pub fn choices(&self) -> Vec<String> {
95        let mut choices: Vec<String> = self
96            .offered()
97            .iter()
98            .map(|spec| spec.key.to_string())
99            .collect();
100        choices.extend(self.startable().iter().map(|key| format!("{START}{key}")));
101        choices.push(NONE.to_owned());
102        choices
103    }
104}
105
106/// The operations chosen.
107#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
108pub struct Routing {
109    /// Operation keys and `start:<workflow>` in the order asked, or `none` alone.
110    pub operations: Vec<String>,
111}
112
113impl<'a> ModelTask for Route<'a> {
114    type Input = RouteInput<'a>;
115    type Output = Routing;
116
117    /// Readings that find no operation never make a majority, save on a second reading: a
118    /// split with one that finds one is read once more, shown them all.
119    fn agree(&self, left: &Routing, right: &Routing) -> bool {
120        left == right && (self.again || left.operations.iter().all(|operation| operation != NONE))
121    }
122
123    fn kind(&self) -> TaskKind {
124        TaskKind::Route
125    }
126
127    fn prompt_name(&self) -> &str {
128        "understand.route"
129    }
130
131    fn instructions(&self) -> &str {
132        BUILT_IN
133    }
134
135    fn schema(&self, input: &RouteInput<'a>) -> Value {
136        object(vec![("operations", array(one_of(input.choices())))])
137    }
138
139    fn render(&self, input: &RouteInput<'a>) -> Vec<Message> {
140        let mut operations = String::from("Operations:");
141        for spec in input.offered() {
142            let _ = write!(
143                operations,
144                "\n- {}",
145                render::operation_line(spec, self.turn)
146            );
147        }
148        for key in input.startable() {
149            let _ = write!(operations, "\n- {START}{key}: start a new {key} record");
150        }
151        let _ = write!(
152            operations,
153            "\n- {NONE}: no operation listed does what is asked"
154        );
155        let mut records = String::from("Records:");
156        for workflow in &input.workflows {
157            for record in &workflow.records {
158                let _ = write!(records, "\n- {}", render::record_line(record, true));
159                if !record.obligations.is_empty() {
160                    let _ = write!(records, " · still needs: {}", record.obligations.join("; "));
161                }
162                if let Some(briefing) = &record.briefing {
163                    let _ = write!(records, "\n  Guidance: {briefing}");
164                }
165            }
166        }
167        let has_records = input.workflows.iter().any(|w| !w.records.is_empty());
168        // A correction changes something the last turn did: routing sees what that was.
169        let corrected = (input.label == "Correction")
170            .then(|| render::receipts(self.turn))
171            .flatten();
172        // An answer completes what the user said before the question it answers.
173        let completed = (input.label == "Answer")
174            .then(|| render::user_before_last_assistant(self.turn))
175            .flatten();
176        vec![Message::user(render::sections([
177            Some(operations),
178            has_records.then_some(records),
179            corrected,
180            completed,
181            render::last_assistant(self.turn),
182            render::expectation(self.turn),
183            Some(render::message(&self.turn.message)),
184            Some(render::unit(input.label, &self.turn.message, input.words)),
185            (!input.others.is_empty()).then(|| {
186                let parts: Vec<String> = input
187                    .others
188                    .iter()
189                    .map(|span| {
190                        let (from, to) = span.shown();
191                        let said = self.turn.message.slice(*span).unwrap_or_default();
192                        format!("words {from} to {to}, «{said}»")
193                    })
194                    .collect();
195                format!(
196                    "Other parts of the message are routed on their own, and what they ask for \
197                     is not this request's: {}.",
198                    parts.join("; ")
199                )
200            }),
201            input.note.clone(),
202        ]))]
203    }
204
205    fn check(&self, input: &RouteInput<'a>, output: &Routing) -> Result<(), StructuralError> {
206        let choices = input.choices();
207        let operations = &output.operations;
208        for operation in operations {
209            check_one_of("operations", operation, &choices)?;
210        }
211        if operations.is_empty() {
212            return Err(StructuralError::new(
213                "no_operation",
214                "`operations` is empty; list what the request asks for, or none alone",
215            ));
216        }
217        if operations.len() > 1 && operations.iter().any(|operation| operation == NONE) {
218            return Err(StructuralError::new(
219                "none_among_others",
220                "none stands alone: it says that nothing listed does what is asked",
221            ));
222        }
223        Ok(())
224    }
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230    use crate::input::Speaker;
231
232    #[test]
233    fn readings_that_find_no_operation_never_outvote_one_that_finds_one() {
234        let turn = UnderstandingInput::new("can I?", "en-GB", chrono::NaiveDate::MIN);
235        let route = Route::new(&turn);
236        let reading = |ops: &[&str]| Routing {
237            operations: ops.iter().map(|op| (*op).to_owned()).collect(),
238        };
239        assert!(!route.agree(&reading(&[NONE]), &reading(&[NONE])));
240        assert!(route.agree(&reading(&["trip.set_name"]), &reading(&["trip.set_name"])));
241        assert!(!route.agree(&reading(&["trip.set_name"]), &reading(&[NONE])));
242    }
243
244    #[test]
245    fn a_second_reading_that_finds_no_operation_may_make_a_majority() {
246        let turn = UnderstandingInput::new("open A", "en-GB", chrono::NaiveDate::MIN);
247        let route = Route::again(&turn);
248        let reading = |ops: &[&str]| Routing {
249            operations: ops.iter().map(|op| (*op).to_owned()).collect(),
250        };
251        assert!(route.agree(&reading(&[NONE]), &reading(&[NONE])));
252        assert!(!route.agree(&reading(&["trip.open"]), &reading(&[NONE])));
253    }
254
255    fn rendered(label: &'static str) -> String {
256        let turn = UnderstandingInput::new("10 each", "en-GB", chrono::NaiveDate::MIN)
257            .with_earlier(Speaker::User, "I sold 2 hats")
258            .with_earlier(Speaker::Assistant, "At what price?");
259        let input = RouteInput {
260            label,
261            words: Span::new(0, 1),
262            workflows: Vec::new(),
263            note: None,
264            others: Vec::new(),
265        };
266        format!("{:?}", Route::new(&turn).render(&input))
267    }
268
269    #[test]
270    fn the_other_parts_of_the_message_are_named_as_routed_on_their_own() {
271        let turn = UnderstandingInput::new(
272            "register Beta, then add a line",
273            "en-GB",
274            chrono::NaiveDate::MIN,
275        );
276        let input = RouteInput {
277            label: "Request",
278            words: Span::new(3, 5),
279            workflows: Vec::new(),
280            note: None,
281            others: vec![Span::new(0, 1)],
282        };
283        let rendered = format!("{:?}", Route::new(&turn).render(&input));
284        assert!(
285            rendered.contains(
286                "Other parts of the message are routed on their own, and what they ask for is \
287                 not this request's: words 1 to 2, «register Beta,»."
288            ),
289            "{rendered}"
290        );
291    }
292
293    #[test]
294    fn an_answer_is_routed_seeing_what_the_user_said_before_the_question() {
295        let answer = rendered("Answer");
296        let before = answer
297            .find("Last user message: «I sold 2 hats»")
298            .expect(&answer);
299        let asked = answer
300            .find("Last assistant message: «At what price?»")
301            .expect(&answer);
302        assert!(before < asked, "{answer}");
303        assert!(!rendered("Request").contains("Last user message"));
304    }
305
306    #[test]
307    fn an_operation_is_offered_in_the_turns_language() {
308        use turnframe_core::operation::OperationSpec;
309        let spec = OperationSpec::new("trip.set_name")
310            .summary("Name the trip.")
311            .summary_in("it-IT", "Dà un nome al viaggio.");
312        let workflow = WorkflowBrief::new("trip").operation(spec);
313        let turn = UnderstandingInput::new(
314            "posso chiamare il viaggio?",
315            "it-IT",
316            chrono::NaiveDate::MIN,
317        )
318        .with_workflow(workflow.clone());
319        let input = RouteInput {
320            label: "Request",
321            words: Span::new(0, 2),
322            workflows: vec![&workflow],
323            note: None,
324            others: Vec::new(),
325        };
326        let rendered = format!("{:?}", Route::new(&turn).render(&input));
327        assert!(
328            rendered.contains("- trip.set_name: Dà un nome al viaggio."),
329            "{rendered}"
330        );
331    }
332
333    #[test]
334    fn a_record_asked_for_by_a_name_nothing_holds_yet_is_said_to_be_no_record() {
335        use turnframe_core::operation::OperationSpec;
336        use turnframe_core::understanding::{ArgumentValue, RecordValue, UnderstoodArgument};
337        let workflow = WorkflowBrief::new("a").operation(OperationSpec::new("a.set_b"));
338        let named = UnderstoodArgument {
339            value: ArgumentValue::Record(RecordValue::Named {
340                workflow: "b".into(),
341                named: "Beta".to_owned(),
342            }),
343            excerpt: None,
344        };
345        let turn = UnderstandingInput::new("create it", "en-GB", chrono::NaiveDate::MIN)
346            .with_workflow(workflow.clone())
347            .with_expectation(crate::input::Expectation::Values(
348                crate::input::PendingAct {
349                    operation: "a.set_b".into(),
350                    record: None,
351                    given: std::iter::once(("b".to_owned(), named)).collect(),
352                    missing: vec!["b".to_owned()],
353                },
354            ));
355        let input = RouteInput {
356            label: "Request",
357            words: Span::new(0, 1),
358            workflows: vec![&workflow],
359            note: None,
360            others: Vec::new(),
361        };
362        let rendered = format!("{:?}", Route::new(&turn).render(&input));
363        assert!(
364            rendered.contains("The b given, «Beta», is not a b record yet."),
365            "{rendered}"
366        );
367    }
368}