Skip to main content

turnframe_eval/simulate/
user.rs

1//! The person a conversation is held with: what they see, and what they do next.
2
3use std::collections::VecDeque;
4use std::fmt::Write as _;
5use std::sync::{Arc, Mutex};
6
7use async_trait::async_trait;
8use serde::{Deserialize, Serialize};
9use turnframe_provider::provider::ModelProvider;
10use turnframe_provider::purpose::ModelPurpose;
11use turnframe_provider::request::{Message, ModelRequest, OutputSpec};
12use turnframe_provider::structured::{SchemaCache, parse_structured};
13
14/// What the person does next.
15#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(tag = "move", rename_all = "snake_case")]
17pub enum UserMove {
18    /// Types a message.
19    Say {
20        /// The message.
21        text: String,
22    },
23    /// Presses an option of a card on screen, by the option's id.
24    Press {
25        /// The option.
26        option: String,
27    },
28    /// Stops: the goal is reached as far as they can tell, or they give up.
29    Done,
30}
31
32impl UserMove {
33    /// A message.
34    #[must_use]
35    pub fn say(text: impl Into<String>) -> Self {
36        Self::Say { text: text.into() }
37    }
38
39    /// A press of `option`.
40    #[must_use]
41    pub fn press(option: impl Into<String>) -> Self {
42        Self::Press {
43            option: option.into(),
44        }
45    }
46}
47
48impl std::fmt::Display for UserMove {
49    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50        match self {
51            Self::Say { text } => f.write_str(text),
52            Self::Press { option } => write!(f, "[presses {option}]"),
53            Self::Done => f.write_str("[done]"),
54        }
55    }
56}
57
58/// A card on screen, with the options the person may press.
59#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
60pub struct CardOnScreen {
61    /// Its title.
62    pub title: String,
63    /// Its options, as `(id, label)`.
64    pub options: Vec<(String, String)>,
65}
66
67/// What the person has in front of them when they decide.
68#[derive(Debug, Clone, Copy)]
69pub struct Screen<'a> {
70    /// What they want.
71    pub want: &'a str,
72    /// How they talk.
73    pub manner: &'a str,
74    /// The conversation so far: what they did, and the reply they read.
75    pub exchanges: &'a [(UserMove, String)],
76    /// The cards open on screen.
77    pub cards: &'a [CardOnScreen],
78    /// The next steps the last reply offered, which a surface shows beside it.
79    pub offers: &'a [String],
80    /// How many turns they will still take.
81    pub turns_left: u32,
82}
83
84/// Someone who holds a conversation.
85#[async_trait]
86pub trait SimulatedUser: Send + Sync {
87    /// The next move, or why none could be had.
88    ///
89    /// # Errors
90    ///
91    /// A message when the person could not decide: a model that failed or answered badly.
92    async fn next(&self, screen: &Screen<'_>) -> Result<UserMove, String>;
93}
94
95/// A person whose moves are written in advance, for tests: done once they run out.
96#[derive(Debug, Default)]
97pub struct ScriptedUser {
98    moves: Mutex<VecDeque<UserMove>>,
99}
100
101impl ScriptedUser {
102    /// A person who makes `moves`, in order.
103    #[must_use]
104    pub fn new(moves: impl IntoIterator<Item = UserMove>) -> Self {
105        Self {
106            moves: Mutex::new(moves.into_iter().collect()),
107        }
108    }
109}
110
111#[async_trait]
112impl SimulatedUser for ScriptedUser {
113    async fn next(&self, _screen: &Screen<'_>) -> Result<UserMove, String> {
114        let mut moves = self
115            .moves
116            .lock()
117            .map_err(|_| "the script was poisoned".to_owned())?;
118        Ok(moves.pop_front().unwrap_or(UserMove::Done))
119    }
120}
121
122const SYSTEM: &str = "You play a person using a text assistant, to test it. You have a goal \
123and a manner of talking. Each time, read the conversation so far and what is on screen, and \
124give your next move:\n\
125- say: the next message you type, in your manner, as that person would type it. Never say \
126you are testing or playing.\n\
127- press: an option of a card on screen, by its id, when a card asks you to choose. Only a \
128card's options can be pressed.\n\
129- done: when the assistant has told you your goal is reached, or when you would give up.\n\
130Keep to your goal: ask for nothing it does not name. When the assistant asks something your \
131goal answers, answer it; when it asks something your goal does not say, answer briefly as a \
132reasonable person would. When it misunderstands you, say so as that person would.";
133
134/// What the simulator answers, before it is a move.
135#[derive(Debug, Deserialize)]
136struct Decided {
137    #[serde(rename = "move")]
138    kind: String,
139    text: String,
140    option: String,
141}
142
143/// A person played by a model.
144pub struct ModelUser {
145    provider: Arc<dyn ModelProvider>,
146    temperature: Option<f32>,
147    schemas: SchemaCache,
148}
149
150impl std::fmt::Debug for ModelUser {
151    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
152        f.debug_struct("ModelUser")
153            .field("provider", &self.provider.provider_key())
154            .field("model", &self.provider.model_key())
155            .finish_non_exhaustive()
156    }
157}
158
159impl ModelUser {
160    /// A person played by `provider`, which should not be the one under test.
161    #[must_use]
162    pub fn new(provider: Arc<dyn ModelProvider>) -> Self {
163        Self {
164            provider,
165            temperature: None,
166            schemas: SchemaCache::new(),
167        }
168    }
169
170    /// Plays at `temperature`, for a model that takes one.
171    #[must_use]
172    pub const fn with_temperature(mut self, temperature: f32) -> Self {
173        self.temperature = Some(temperature);
174        self
175    }
176
177    fn schema() -> serde_json::Value {
178        serde_json::json!({
179            "type": "object",
180            "properties": {
181                "thinking": {"type": "string"},
182                "move": {"type": "string", "enum": ["say", "press", "done"]},
183                "text": {"type": "string"},
184                "option": {"type": "string"}
185            },
186            "required": ["thinking", "move", "text", "option"],
187            "additionalProperties": false
188        })
189    }
190
191    fn prompt(screen: &Screen<'_>) -> String {
192        let mut out = format!(
193            "Your goal: {}\nYour manner: {}\n\nConversation so far:",
194            screen.want, screen.manner
195        );
196        if screen.exchanges.is_empty() {
197            out.push_str("\n(nothing yet: you speak first)");
198        }
199        for (said, reply) in screen.exchanges {
200            let _ = write!(out, "\n- you: {said}\n- assistant: {reply}");
201        }
202        if !screen.cards.is_empty() {
203            out.push_str("\n\nCards on screen:");
204            for card in screen.cards {
205                let options: Vec<String> = card
206                    .options
207                    .iter()
208                    .map(|(id, label)| format!("{id} «{label}»"))
209                    .collect();
210                let _ = write!(out, "\n- «{}»: {}", card.title, options.join(", "));
211            }
212        }
213        if !screen.offers.is_empty() {
214            let _ = write!(
215                out,
216                "\n\nSuggested next steps on screen (type what you want; they are not buttons):\n- {}",
217                screen.offers.join("\n- ")
218            );
219        }
220        let _ = write!(
221            out,
222            "\n\nTurns you will still take: {}.\n\nAnswer with your thinking in a sentence, \
223             the move, the text for say (empty otherwise) and the option id for press (empty \
224             otherwise).",
225            screen.turns_left
226        );
227        out
228    }
229}
230
231#[async_trait]
232impl SimulatedUser for ModelUser {
233    async fn next(&self, screen: &Screen<'_>) -> Result<UserMove, String> {
234        let schema = Self::schema();
235        let compiled = self
236            .schemas
237            .compile(&schema)
238            .map_err(|error| error.to_string())?;
239        let mut request = ModelRequest::new(ModelPurpose::OfflineEvaluate)
240            .with_system(SYSTEM.to_owned())
241            .with_message(Message::user(Self::prompt(screen)));
242        request.output = OutputSpec::json("turnframe_simulated_user", schema);
243        request.temperature = self.temperature;
244        let response = self
245            .provider
246            .generate(request)
247            .await
248            .map_err(|error| error.to_string())?;
249        let decided: Decided =
250            parse_structured(&response, &compiled).map_err(|error| error.to_string())?;
251        match decided.kind.as_str() {
252            "say" if !decided.text.trim().is_empty() => Ok(UserMove::say(decided.text.trim())),
253            "press" if !decided.option.trim().is_empty() => {
254                Ok(UserMove::press(decided.option.trim()))
255            }
256            "done" => Ok(UserMove::Done),
257            other => Err(format!("the simulator answered {other} with nothing to do")),
258        }
259    }
260}