Skip to main content

turnframe_eval/simulate/
converse.rs

1//! One conversation held with the runtime, turn by turn, until the goal or the limit.
2
3use futures::StreamExt as _;
4use turnframe_core::ids::TurnId;
5use turnframe_core::interaction::Interaction;
6use turnframe_core::locale::Locale;
7use turnframe_core::response::{AssistantTurn, Expectation, ResponseBlock};
8use turnframe_core::turn::{InteractionResponse, TurnInput};
9use turnframe_runtime::orchestrator::error_code;
10use turnframe_store::interaction::InteractionReader;
11
12use super::goal::Goal;
13use super::score::{Conversation, Ending, Exchange, SimulationReport, score};
14use super::user::{CardOnScreen, Screen, SimulatedUser, UserMove};
15use crate::assertions::check;
16use crate::observation::Observation;
17use crate::runner::{EvalHarness, PreparedRun, SampleIndex};
18
19/// The id of turn `number` of a conversation whose first turn is `first`.
20#[must_use]
21pub fn turn_at(first: TurnId, number: u32) -> TurnId {
22    let offset = u128::from(number) << 64;
23    TurnId::from(uuid::Uuid::from_u128(
24        first.as_uuid().as_u128().wrapping_add(offset),
25    ))
26}
27
28/// Runs goals against a harness, each manner of each goal `samples` times.
29#[derive(Debug, Clone, Copy)]
30pub struct Simulation {
31    samples: u32,
32    concurrency: usize,
33}
34
35impl Default for Simulation {
36    fn default() -> Self {
37        Self {
38            samples: 1,
39            concurrency: 1,
40        }
41    }
42}
43
44impl Simulation {
45    /// One sample of each, one at a time.
46    #[must_use]
47    pub fn new() -> Self {
48        Self::default()
49    }
50
51    /// Holds each conversation `samples` times.
52    #[must_use]
53    pub const fn with_samples(mut self, samples: u32) -> Self {
54        self.samples = samples;
55        self
56    }
57
58    /// Holds at most `concurrency` conversations at once; the report's order is kept.
59    #[must_use]
60    pub const fn with_concurrency(mut self, concurrency: usize) -> Self {
61        self.concurrency = concurrency;
62        self
63    }
64
65    /// Holds every conversation and reports them.
66    pub async fn run(
67        &self,
68        goals: &[Goal],
69        harness: &dyn EvalHarness,
70        user: &dyn SimulatedUser,
71    ) -> SimulationReport {
72        let runs = goals.iter().flat_map(|goal| {
73            goal.manners.iter().flat_map(move |manner| {
74                (0..self.samples.max(1)).map(move |sample| (goal, manner, SampleIndex(sample)))
75            })
76        });
77        let conversations = futures::stream::iter(
78            runs.map(|(goal, manner, sample)| converse(goal, manner, harness, user, sample)),
79        )
80        .buffered(self.concurrency.max(1))
81        .collect()
82        .await;
83        SimulationReport { conversations }
84    }
85}
86
87/// Holds one conversation: `user` pursues `goal` in `manner` until done or the limit.
88pub async fn converse(
89    goal: &Goal,
90    manner: &str,
91    harness: &dyn EvalHarness,
92    user: &dyn SimulatedUser,
93    sample: SampleIndex,
94) -> Conversation {
95    let mut conversation = Conversation {
96        goal: goal.id.clone(),
97        manner: manner.to_owned(),
98        sample: sample.number(),
99        exchanges: Vec::new(),
100        ended: Ending::TurnLimit,
101        score: score(&[], false),
102    };
103    let prepared = match harness.prepare(&goal.item(), sample).await {
104        Ok(prepared) => prepared,
105        Err(error) => {
106            conversation.ended = Ending::Unprepared {
107                message: error.to_string(),
108            };
109            return conversation;
110        }
111    };
112    let locale = goal.locale.clone().unwrap_or_else(|| Locale::from("en"));
113    let mut read: Vec<(UserMove, String)> = Vec::new();
114    let mut ran: Vec<TurnId> = Vec::new();
115    let mut offers: Vec<String> = Vec::new();
116    for number in 0..goal.max_turns {
117        let open = open_cards(&prepared).await;
118        let cards: Vec<CardOnScreen> = open.iter().map(|card| on_screen(card, &locale)).collect();
119        let screen = Screen {
120            want: &goal.want,
121            manner,
122            exchanges: &read,
123            cards: &cards,
124            offers: &offers,
125            turns_left: goal.max_turns - number,
126        };
127        let said = match user.next(&screen).await {
128            Ok(UserMove::Done) => {
129                conversation.ended = Ending::Done;
130                break;
131            }
132            Ok(said) => said,
133            Err(message) => {
134                conversation.ended = Ending::UserFailed { message };
135                break;
136            }
137        };
138        let (text, response) = match &said {
139            UserMove::Say { text } => (Some(text.clone()), None),
140            UserMove::Press { option } => match pressed(&open, option, &locale) {
141                Some(response) => (None, Some(response)),
142                // No button is called that: nothing is sent, and the person sees so.
143                None => {
144                    let note = format!("(no card on screen has an option «{option}»)");
145                    read.push((said.clone(), note));
146                    continue;
147                }
148            },
149            UserMove::Done => (None, None),
150        };
151        let turn_id = turn_at(prepared.turn_id, number);
152        let outcome = prepared
153            .orchestrator
154            .handle_turn(input(&prepared, text, response, turn_id, &locale))
155            .await;
156        let observed = Observation::collect(
157            prepared.orchestrator.stores(),
158            prepared.workflows.as_ref(),
159            &prepared.actor.account_id,
160            turn_id,
161            &goal.setup.cases,
162            outcome.as_ref().map_err(error_code),
163        )
164        .await;
165        ran.push(turn_id);
166        let blocking_open = open_cards(&prepared).await.iter().any(|card| card.blocking);
167        offers = outcome.as_ref().map_or_else(
168            |_| Vec::new(),
169            |turn| {
170                turn.offers
171                    .iter()
172                    .map(|offer| offer.words.clone())
173                    .collect()
174            },
175        );
176        let exchange = match &outcome {
177            Ok(turn) => answered(turn, &observed, &locale, blocking_open),
178            Err(error) => Exchange {
179                failed: Some(error_code(error)),
180                ..Exchange::default()
181            },
182        };
183        read.push((said.clone(), exchange.reply.clone()));
184        conversation.exchanges.push(Exchange {
185            said: Some(said),
186            ..exchange
187        });
188    }
189    let reached = reached(goal, &prepared, &ran).await;
190    conversation.score = score(&conversation.exchanges, reached);
191    conversation
192}
193
194/// Whether the goal's state holds now, read from the stores.
195async fn reached(goal: &Goal, prepared: &PreparedRun, ran: &[TurnId]) -> bool {
196    let (last, earlier) = match ran.split_last() {
197        Some((last, earlier)) => (*last, earlier),
198        None => (prepared.turn_id, &[][..]),
199    };
200    let mut observed = Observation::collect(
201        prepared.orchestrator.stores(),
202        prepared.workflows.as_ref(),
203        &prepared.actor.account_id,
204        last,
205        &goal.setup.cases,
206        Err(String::new()),
207    )
208    .await
209    .with_conversation_cases(
210        prepared.orchestrator.stores(),
211        prepared.workflows.as_ref(),
212        &prepared.actor.account_id,
213        earlier,
214    )
215    .await;
216    // Only the state is asked about, whatever became of the last turn.
217    observed.error_code = None;
218    check(&goal.reached.expectations(), &observed).is_empty()
219}
220
221/// The cards open in the conversation, blocking ones first.
222async fn open_cards(prepared: &PreparedRun) -> Vec<Interaction> {
223    let store = prepared.orchestrator.stores().interactions();
224    let mut open = InteractionReader::list_open_for_conversation(
225        store.as_ref(),
226        &prepared.actor.account_id,
227        &prepared.conversation_id,
228    )
229    .await
230    .unwrap_or_default();
231    open.sort_by_key(|card| !card.blocking);
232    open
233}
234
235fn on_screen(card: &Interaction, locale: &Locale) -> CardOnScreen {
236    CardOnScreen {
237        title: card.payload.title.resolve(locale).to_owned(),
238        options: card
239            .payload
240            .options
241            .iter()
242            .map(|option| {
243                (
244                    option.id.as_str().to_owned(),
245                    option.label.resolve(locale).to_owned(),
246                )
247            })
248            .collect(),
249    }
250}
251
252/// The answer to the card option a press names, by its id or its label.
253fn pressed(open: &[Interaction], option: &str, locale: &Locale) -> Option<InteractionResponse> {
254    open.iter().find_map(|card| {
255        let chosen = card.payload.options.iter().find(|offered| {
256            offered.id.as_str().eq_ignore_ascii_case(option)
257                || offered.label.resolve(locale).eq_ignore_ascii_case(option)
258        })?;
259        Some(InteractionResponse {
260            interaction_id: card.id,
261            option_id: chosen.id.clone(),
262            expected_case_revision: card.case_ref.expected_revision,
263            freeform_input: None,
264        })
265    })
266}
267
268fn input(
269    prepared: &PreparedRun,
270    text: Option<String>,
271    interaction_response: Option<InteractionResponse>,
272    turn_id: TurnId,
273    locale: &Locale,
274) -> TurnInput {
275    TurnInput {
276        turn_id,
277        conversation_id: prepared.conversation_id,
278        actor: prepared.actor.clone(),
279        text,
280        interaction_response,
281        attachments: Vec::new(),
282        origin: None,
283        locale: locale.clone(),
284        effort: None,
285    }
286}
287
288/// What a turn that answered showed, and what code reads of it.
289fn answered(
290    turn: &AssistantTurn,
291    observed: &Observation,
292    locale: &Locale,
293    blocking_open: bool,
294) -> Exchange {
295    // The reply carries its answers: their blocks are read only when there is no reply.
296    let replied = turn
297        .blocks
298        .iter()
299        .any(|block| matches!(block, ResponseBlock::Transition(_)));
300    let mut shown = Vec::new();
301    let mut prose = String::new();
302    let mut card = false;
303    for block in &turn.blocks {
304        match block {
305            ResponseBlock::Transition(transition) => {
306                prose.clone_from(&transition.text);
307                shown.push(transition.text.clone());
308            }
309            ResponseBlock::Answer(answer) if !replied => shown.push(answer.text.clone()),
310            ResponseBlock::Receipt(receipt) => shown.push(format!(
311                "[{}: {}]",
312                receipt.receipt.title.resolve(locale),
313                receipt.receipt.body.resolve(locale)
314            )),
315            ResponseBlock::Notice(notice) => {
316                shown.push(format!("[{}]", notice.text.resolve(locale)));
317            }
318            ResponseBlock::Interaction(_) => card = true,
319            _ => {}
320        }
321    }
322    let asks_a_question = prose
323        .trim_end()
324        .trim_end_matches(['"', '»', ')', '*', '_'])
325        .ends_with('?');
326    // An ask the turn recorded is a way forward however it is worded: «Still needed: X.».
327    let asks: Vec<String> = turn.expectations.iter().filter_map(asked).collect();
328    Exchange {
329        said: None,
330        reply: shown.join("\n"),
331        failed: None,
332        way_forward: asks_a_question
333            || !asks.is_empty()
334            || card
335            || blocking_open
336            || !turn.offers.is_empty(),
337        asks,
338        offers: turn
339            .offers
340            .iter()
341            .map(|offer| offer.operation.as_str().to_owned())
342            .collect(),
343        refused: observed
344            .acts
345            .iter()
346            .filter(|act| act.outcome.as_deref() == Some("rejected"))
347            .filter_map(|act| act.operation.clone())
348            .collect(),
349        not_understood: observed
350            .understanding
351            .as_ref()
352            .map_or(0, |understanding| understanding.not_understood.len()),
353    }
354}
355
356/// What an expectation asked, as its record and what it waits on.
357fn asked(expectation: &Expectation) -> Option<String> {
358    Some(match expectation {
359        Expectation::AwaitingObligation {
360            case_ref,
361            obligation,
362        }
363        | Expectation::AwaitingOperation {
364            case_ref,
365            obligation,
366            ..
367        } => format!("{}/{}: {obligation}", case_ref.workflow, case_ref.case_id),
368        Expectation::AwaitingValue {
369            act,
370            case_ref,
371            missing,
372        } => format!(
373            "{}: {} {}",
374            case_ref.as_ref().map_or_else(String::new, |case| format!(
375                "{}/{}",
376                case.workflow, case.case_id
377            )),
378            act.operation().map_or("", |operation| operation.as_str()),
379            missing.join(",")
380        ),
381        _ => return None,
382    })
383}