turnframe_understand/tasks/
route.rs1use 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
18pub const NONE: &str = "none";
20
21pub const START: &str = "start:";
23
24const BUILT_IN: &str = include_str!("../../prompts/understand/route.md");
25
26#[derive(Debug, Clone, Copy)]
28pub struct Route<'a> {
29 turn: &'a UnderstandingInput,
30 again: bool,
31}
32
33impl<'a> Route<'a> {
34 #[must_use]
36 pub const fn new(turn: &'a UnderstandingInput) -> Self {
37 Self { turn, again: false }
38 }
39
40 #[must_use]
43 pub const fn again(turn: &'a UnderstandingInput) -> Self {
44 Self { turn, again: true }
45 }
46}
47
48#[derive(Debug, Clone)]
50pub struct RouteInput<'a> {
51 pub label: &'static str,
53 pub words: Span,
55 pub workflows: Vec<&'a WorkflowBrief>,
57 pub note: Option<String>,
59 pub others: Vec<Span>,
61}
62
63impl RouteInput<'_> {
64 #[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 #[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 #[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
108pub struct Routing {
109 pub operations: Vec<String>,
111}
112
113impl<'a> ModelTask for Route<'a> {
114 type Input = RouteInput<'a>;
115 type Output = Routing;
116
117 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 let corrected = (input.label == "Correction")
170 .then(|| render::receipts(self.turn))
171 .flatten();
172 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}