Skip to main content

turnframe_test/providers/
understanding.rs

1//! What a turn was understood to say, written by a test: a builder that turns quotes into
2//! word ranges, and a [`TurnUnderstander`] that returns the understandings it was given.
3
4use std::collections::{BTreeMap, VecDeque};
5use std::sync::{Mutex, PoisonError};
6
7use async_trait::async_trait;
8use turnframe_core::ids::{OperationKey, OptionId, TargetToken, WorkflowKey};
9use turnframe_core::plan::AnswerBasis;
10use turnframe_core::understanding::{
11    ActAction, ActId, ActStatus, ActTarget, ArgumentValue, CardAnswer, ConstraintKind, Dispute,
12    Excerpt, FoundBy, MessageRef, NotUnderstood, NotUnderstoodReason, QuestionTopic, RecordValue,
13    Superseded, TurnConstraint, Understanding, UnderstoodAct, UnderstoodArgument,
14    UnderstoodQuestion, Unit, UnitId, UnitKind, Unreadable, WordRange,
15};
16use turnframe_tasks::TaskScope;
17use turnframe_understand::{ActChecker, StepSink, TurnUnderstander, UnderstandingInput, Words};
18
19/// A quote that does not occur in the text the understanding is about.
20#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
21#[error("«{quote}» does not occur in «{text}»")]
22pub struct QuoteNotFound {
23    /// The quote.
24    pub quote: String,
25    /// The text.
26    pub text: String,
27}
28
29/// Builds an [`Understanding`] of one message from quotes of it.
30///
31/// ```
32/// use turnframe_test::providers::UnderstandingBuilder;
33///
34/// let understanding = UnderstandingBuilder::of("set the name to Lisbon")
35///     .apply("trip.set_name", "tok_1", serde_json::json!({"value": "Lisbon"}), "set the name to Lisbon")
36///     .build()
37///     .unwrap();
38/// assert_eq!(understanding.acts[0].arguments["value"].excerpt.unwrap().words.first, 4);
39/// ```
40#[derive(Debug, Clone)]
41pub struct UnderstandingBuilder {
42    words: Words,
43    understanding: Understanding,
44    error: Option<QuoteNotFound>,
45}
46
47impl UnderstandingBuilder {
48    /// Starts an understanding of `text`.
49    #[must_use]
50    pub fn of(text: impl AsRef<str>) -> Self {
51        Self {
52            words: Words::split(text.as_ref()),
53            understanding: Understanding::default(),
54            error: None,
55        }
56    }
57
58    /// The message, as given.
59    #[must_use]
60    pub fn text(&self) -> &str {
61        self.words.text()
62    }
63
64    fn range(&mut self, quote: &str) -> Option<WordRange> {
65        let found = self.locate(quote);
66        if found.is_none() && self.error.is_none() {
67            self.error = Some(QuoteNotFound {
68                quote: quote.to_owned(),
69                text: self.words.text().to_owned(),
70            });
71        }
72        found
73    }
74
75    fn locate(&self, quote: &str) -> Option<WordRange> {
76        let start = self.words.text().find(quote)?;
77        let end = start + quote.len();
78        let words = self.words.words();
79        let first = words.iter().position(|w| w.end > start)?;
80        let last = words.iter().rposition(|w| w.start < end)?;
81        Some(WordRange {
82            first,
83            last,
84            start: words[first].start,
85            end: words[last].end,
86        })
87    }
88
89    fn unit(&mut self, kind: UnitKind, words: WordRange, workflow: Option<WorkflowKey>) -> UnitId {
90        let id = UnitId(u16::try_from(self.understanding.units.len() + 1).unwrap_or(u16::MAX));
91        self.understanding.units.push(Unit {
92            id,
93            kind,
94            words,
95            workflow,
96            found_by: FoundBy::Segment,
97        });
98        id
99    }
100
101    fn arguments(
102        &self,
103        arguments: &serde_json::Value,
104        words: WordRange,
105    ) -> BTreeMap<String, UnderstoodArgument> {
106        let Some(object) = arguments.as_object() else {
107            return BTreeMap::new();
108        };
109        object
110            .iter()
111            .map(|(name, value)| {
112                let located = value.as_str().and_then(|text| self.locate(text));
113                let argument = UnderstoodArgument {
114                    value: ArgumentValue::Json(value.clone()),
115                    excerpt: Some(Excerpt {
116                        message: MessageRef::Current,
117                        words: located.unwrap_or(words),
118                    }),
119                };
120                (name.clone(), argument)
121            })
122            .collect()
123    }
124
125    fn act(
126        mut self,
127        action: ActAction,
128        target: ActTarget,
129        arguments: &serde_json::Value,
130        quote: &str,
131    ) -> Self {
132        let Some(words) = self.range(quote) else {
133            return self;
134        };
135        let workflow = match &action {
136            ActAction::Start { workflow } => Some(workflow.clone()),
137            ActAction::Apply { operation } => operation
138                .as_str()
139                .split_once('.')
140                .map(|(workflow, _)| WorkflowKey::from(workflow)),
141        };
142        let unit = self.unit(UnitKind::Request, words, workflow);
143        let mut depends_on = Vec::new();
144        if let ActTarget::SameTurn { act } = &target {
145            depends_on.push(*act);
146        }
147        let arguments = self.arguments(arguments, words);
148        self.understanding.acts.push(UnderstoodAct {
149            id: ActId::new(unit, 1),
150            action,
151            target,
152            arguments,
153            words,
154            depends_on,
155            status: ActStatus::Ready,
156        });
157        self
158    }
159
160    /// An act applying `operation` to the record `target` names, asked for in `quote`.
161    #[must_use]
162    pub fn apply(
163        self,
164        operation: impl Into<OperationKey>,
165        target: impl Into<TargetToken>,
166        arguments: serde_json::Value,
167        quote: &str,
168    ) -> Self {
169        let target = ActTarget::Record {
170            token: target.into(),
171        };
172        self.apply_to(operation, target, arguments, quote)
173    }
174
175    /// An act applying `operation` to any target.
176    #[must_use]
177    pub fn apply_to(
178        self,
179        operation: impl Into<OperationKey>,
180        target: ActTarget,
181        arguments: serde_json::Value,
182        quote: &str,
183    ) -> Self {
184        let action = ActAction::Apply {
185            operation: operation.into(),
186        };
187        self.act(action, target, &arguments, quote)
188    }
189
190    /// An act applying `operation` to a record of `workflow` the user named in
191    /// `named` that is not in view, for the directory to look up.
192    #[must_use]
193    pub fn apply_to_unlisted(
194        mut self,
195        operation: impl Into<OperationKey>,
196        workflow: impl Into<WorkflowKey>,
197        named: &str,
198        arguments: serde_json::Value,
199        quote: &str,
200    ) -> Self {
201        let words = self.range(named);
202        let target = ActTarget::NotListed {
203            workflow: workflow.into(),
204            words,
205        };
206        self.apply_to(operation, target, arguments, quote)
207    }
208
209    /// An act applying `operation` to a record of `workflow` it creates.
210    #[must_use]
211    pub fn open(
212        self,
213        operation: impl Into<OperationKey>,
214        workflow: impl Into<WorkflowKey>,
215        arguments: serde_json::Value,
216        quote: &str,
217    ) -> Self {
218        let target = ActTarget::New {
219            workflow: workflow.into(),
220        };
221        self.apply_to(operation, target, arguments, quote)
222    }
223
224    /// An act starting a new case of `workflow`.
225    #[must_use]
226    pub fn start(self, workflow: impl Into<WorkflowKey>, quote: &str) -> Self {
227        let workflow = workflow.into();
228        let target = ActTarget::New {
229            workflow: workflow.clone(),
230        };
231        self.act(
232            ActAction::Start { workflow },
233            target,
234            &serde_json::Value::Null,
235            quote,
236        )
237    }
238
239    /// Gives the last act an argument naming a record. A record an earlier act of the
240    /// turn creates makes that act a prerequisite.
241    #[must_use]
242    pub fn with_record(mut self, name: &str, record: RecordValue) -> Self {
243        if let Some(act) = self.understanding.acts.last_mut() {
244            if let RecordValue::SameTurn { act: earlier } = &record
245                && !act.depends_on.contains(earlier)
246            {
247                act.depends_on.push(*earlier);
248            }
249            act.arguments.insert(
250                name.to_owned(),
251                UnderstoodArgument {
252                    value: ArgumentValue::Record(record),
253                    excerpt: None,
254                },
255            );
256        }
257        self
258    }
259
260    /// The id of the act added last.
261    #[must_use]
262    pub fn last_act(&self) -> Option<ActId> {
263        self.understanding.acts.last().map(|act| act.id)
264    }
265
266    /// Replaces the last act's status.
267    #[must_use]
268    pub fn with_status(mut self, status: ActStatus) -> Self {
269        if let Some(act) = self.understanding.acts.last_mut() {
270            act.status = status;
271        }
272        self
273    }
274
275    /// Marks the last act as asking for `arguments`.
276    #[must_use]
277    pub fn needing(self, arguments: &[&str]) -> Self {
278        self.with_status(ActStatus::NeedsValue {
279            arguments: arguments.iter().map(|name| (*name).to_owned()).collect(),
280            reason: None,
281        })
282    }
283
284    /// Marks the last act as superseded by a later unit, removing it from the acts.
285    #[must_use]
286    pub fn superseded_by_next(mut self) -> Self {
287        if let Some(act) = self.understanding.acts.pop() {
288            let by = UnitId(act.id.unit.0 + 1);
289            self.understanding.superseded.push(Superseded {
290                act: act.id,
291                action: act.action,
292                by,
293            });
294        }
295        self
296    }
297
298    /// A question answered against the current committed state.
299    #[must_use]
300    pub fn ask(self, quote: &str) -> Self {
301        self.ask_about(AnswerBasis::CurrentCommittedState, None, &[], quote)
302    }
303
304    /// A question with its basis, record and subjects; one on general domain knowledge is
305    /// about the domain, the rest about a record, until [`Self::about`] says otherwise.
306    #[must_use]
307    pub fn ask_about(
308        mut self,
309        basis: AnswerBasis,
310        record: Option<TargetToken>,
311        subjects: &[&str],
312        quote: &str,
313    ) -> Self {
314        let Some(words) = self.range(quote) else {
315            return self;
316        };
317        let unit = self.unit(UnitKind::Question, words, None);
318        self.understanding.questions.push(UnderstoodQuestion {
319            unit,
320            words,
321            workflow: None,
322            record,
323            subjects: subjects.iter().map(|s| (*s).to_owned()).collect(),
324            basis,
325            topic: match basis {
326                AnswerBasis::GeneralDomainKnowledge => QuestionTopic::Knowledge,
327                _ => QuestionTopic::default(),
328            },
329            continues_previous: false,
330        });
331        self
332    }
333
334    /// Marks the last question as following up the assistant's last message.
335    #[must_use]
336    pub fn following_up(mut self) -> Self {
337        if let Some(question) = self.understanding.questions.last_mut() {
338            question.continues_previous = true;
339        }
340        self
341    }
342
343    /// Sets what kind of thing the last question asks.
344    #[must_use]
345    pub fn about(mut self, topic: QuestionTopic) -> Self {
346        if let Some(question) = self.understanding.questions.last_mut() {
347            question.topic = topic;
348        }
349        self
350    }
351
352    /// A turn-wide constraint stated in `quote`.
353    #[must_use]
354    pub fn constrain(mut self, kind: ConstraintKind, quote: &str) -> Self {
355        let Some(words) = self.range(quote) else {
356            return self;
357        };
358        let unit = self.unit(UnitKind::Constraint, words, None);
359        self.understanding
360            .constraints
361            .push(TurnConstraint { unit, kind, words });
362        self
363    }
364
365    /// A typed answer to the card on screen.
366    #[must_use]
367    pub fn answer_card(mut self, option: impl Into<OptionId>, quote: &str) -> Self {
368        let Some(words) = self.range(quote) else {
369            return self;
370        };
371        let unit = self.unit(UnitKind::CardAnswer, words, None);
372        self.understanding.card_answer = Some(CardAnswer {
373            unit,
374            option: option.into(),
375            words,
376        });
377        self
378    }
379
380    /// A dispute of the receipt shown under `receipt`, when it names one.
381    #[must_use]
382    pub fn dispute(mut self, receipt: Option<&str>, quote: &str) -> Self {
383        let Some(words) = self.range(quote) else {
384            return self;
385        };
386        let unit = self.unit(UnitKind::Dispute, words, None);
387        self.understanding.disputes.push(Dispute {
388            unit,
389            words,
390            receipt: receipt.map(str::to_owned),
391        });
392        self
393    }
394
395    /// Words that produced nothing to act on.
396    #[must_use]
397    pub fn not_understood(mut self, reason: NotUnderstoodReason, quote: &str) -> Self {
398        let Some(words) = self.range(quote) else {
399            return self;
400        };
401        let unit = self.unit(UnitKind::Request, words, None);
402        self.understanding.not_understood.push(NotUnderstood {
403            unit,
404            words,
405            reason,
406        });
407        self
408    }
409
410    /// Greetings, thanks, words that ask for nothing.
411    #[must_use]
412    pub fn chitchat(mut self, quote: &str) -> Self {
413        if let Some(words) = self.range(quote) {
414            self.unit(UnitKind::Chitchat, words, None);
415        }
416        self
417    }
418
419    /// Finishes the understanding.
420    ///
421    /// # Errors
422    ///
423    /// [`QuoteNotFound`] for the first quote that is not in the text.
424    pub fn build(self) -> Result<Understanding, QuoteNotFound> {
425        match self.error {
426            Some(error) => Err(error),
427            None => Ok(self.understanding),
428        }
429    }
430}
431
432/// A [`TurnUnderstander`] that returns the understandings queued, in order, and keeps
433/// what each turn showed it.
434///
435/// A turn with nothing queued is unreadable, with the code `unscripted`, so an extra
436/// turn shows up as a turn that did nothing instead of an improvised reading.
437#[derive(Debug, Default)]
438pub struct ScriptedUnderstanding {
439    queue: Mutex<VecDeque<Understanding>>,
440    seen: Mutex<Vec<UnderstandingInput>>,
441}
442
443impl ScriptedUnderstanding {
444    /// Nothing queued.
445    #[must_use]
446    pub fn new() -> Self {
447        Self::default()
448    }
449
450    /// Queues `understanding` for the next turn.
451    #[must_use]
452    pub fn then(self, understanding: Understanding) -> Self {
453        self.push(understanding);
454        self
455    }
456
457    /// Queues `understanding` for the next turn, after construction.
458    pub fn push(&self, understanding: Understanding) {
459        self.queue
460            .lock()
461            .unwrap_or_else(PoisonError::into_inner)
462            .push_back(understanding);
463    }
464
465    /// What each turn showed understanding, in order.
466    #[must_use]
467    pub fn seen(&self) -> Vec<UnderstandingInput> {
468        self.seen
469            .lock()
470            .unwrap_or_else(PoisonError::into_inner)
471            .clone()
472    }
473
474    /// Understandings queued and not yet used.
475    #[must_use]
476    pub fn remaining(&self) -> usize {
477        self.queue
478            .lock()
479            .unwrap_or_else(PoisonError::into_inner)
480            .len()
481    }
482}
483
484#[async_trait]
485impl TurnUnderstander for ScriptedUnderstanding {
486    async fn understand(
487        &self,
488        _scope: &TaskScope,
489        turn: &UnderstandingInput,
490        _steps: &dyn StepSink,
491        _checker: &dyn ActChecker,
492    ) -> Understanding {
493        self.seen
494            .lock()
495            .unwrap_or_else(PoisonError::into_inner)
496            .push(turn.clone());
497        self.queue
498            .lock()
499            .unwrap_or_else(PoisonError::into_inner)
500            .pop_front()
501            .unwrap_or_else(|| {
502                Understanding::unreadable(Unreadable::Segmentation {
503                    code: "unscripted".to_owned(),
504                })
505            })
506    }
507}
508
509#[cfg(test)]
510mod tests {
511    use super::*;
512
513    #[test]
514    fn quotes_become_word_ranges_and_values_point_at_their_words() {
515        let understanding = UnderstandingBuilder::of("please set the name to Lisbon")
516            .apply(
517                "trip.set_name",
518                "t_1",
519                serde_json::json!({"value": "Lisbon"}),
520                "set the name to Lisbon",
521            )
522            .build()
523            .unwrap();
524        let act = &understanding.acts[0];
525        assert_eq!((act.words.first, act.words.last), (1, 5));
526        let excerpt = act.arguments["value"].excerpt.unwrap();
527        assert_eq!((excerpt.words.first, excerpt.words.last), (5, 5));
528        assert_eq!(understanding.units[0].workflow, Some("trip".into()));
529    }
530
531    #[test]
532    fn a_quote_that_is_not_in_the_text_fails_the_build() {
533        let error = UnderstandingBuilder::of("hello")
534            .ask("what is the total?")
535            .build()
536            .unwrap_err();
537        assert_eq!(error.quote, "what is the total?");
538    }
539}