1use 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(tag = "move", rename_all = "snake_case")]
17pub enum UserMove {
18 Say {
20 text: String,
22 },
23 Press {
25 option: String,
27 },
28 Done,
30}
31
32impl UserMove {
33 #[must_use]
35 pub fn say(text: impl Into<String>) -> Self {
36 Self::Say { text: text.into() }
37 }
38
39 #[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
60pub struct CardOnScreen {
61 pub title: String,
63 pub options: Vec<(String, String)>,
65}
66
67#[derive(Debug, Clone, Copy)]
69pub struct Screen<'a> {
70 pub want: &'a str,
72 pub manner: &'a str,
74 pub exchanges: &'a [(UserMove, String)],
76 pub cards: &'a [CardOnScreen],
78 pub offers: &'a [String],
80 pub turns_left: u32,
82}
83
84#[async_trait]
86pub trait SimulatedUser: Send + Sync {
87 async fn next(&self, screen: &Screen<'_>) -> Result<UserMove, String>;
93}
94
95#[derive(Debug, Default)]
97pub struct ScriptedUser {
98 moves: Mutex<VecDeque<UserMove>>,
99}
100
101impl ScriptedUser {
102 #[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#[derive(Debug, Deserialize)]
136struct Decided {
137 #[serde(rename = "move")]
138 kind: String,
139 text: String,
140 option: String,
141}
142
143pub 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 #[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 #[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}