1use serde::Serialize;
4use serde_json::Value;
5use typesafe::{Choice, Noul, Question, Questions, Score};
6
7pub type Entry = (String, Question);
9
10#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct Turn {
17 pub who: Option<String>,
19 pub said: String,
21}
22
23const WHO_KEYS: [&str; 4] = ["who", "role", "speaker", "from"];
25const SAID_KEYS: [&str; 4] = ["said", "text", "content", "message"];
27
28pub fn turns_of(state: &Value) -> Option<Vec<Turn>> {
34 let items = state.as_array().filter(|a| !a.is_empty())?;
35 let mut turns = Vec::with_capacity(items.len());
36 for item in items {
37 let object = item.as_object()?;
38 let pick = |keys: [&str; 4]| {
39 keys.into_iter()
40 .find_map(|k| object.get(k).and_then(Value::as_str))
41 };
42 let said = pick(SAID_KEYS)?;
43 turns.push(Turn {
44 who: pick(WHO_KEYS).filter(|w| !w.is_empty()).map(str::to_owned),
45 said: said.to_owned(),
46 });
47 }
48 Some(turns)
49}
50
51pub fn turns_to_json(turns: &[Turn]) -> Value {
53 Value::Array(
54 turns
55 .iter()
56 .map(|t| {
57 let mut map = serde_json::Map::new();
58 if let Some(who) = &t.who {
59 map.insert("who".to_owned(), Value::String(who.clone()));
60 }
61 map.insert("said".to_owned(), Value::String(t.said.clone()));
62 Value::Object(map)
63 })
64 .collect(),
65 )
66}
67
68pub fn turn_text(turn: &Turn) -> String {
70 match &turn.who {
71 Some(who) => format!("{who}: {}", turn.said),
72 None => turn.said.clone(),
73 }
74}
75
76#[derive(Debug, Default, Clone)]
78pub struct Session {
79 pub state: Value,
81 pub questions: Vec<Entry>,
83 pub model: Option<String>,
85 pub bars: Vec<(String, f64)>,
89}
90
91impl Session {
92 pub fn new() -> Self {
93 Self {
94 state: Value::String(String::new()),
95 questions: Vec::new(),
96 model: None,
97 bars: Vec::new(),
98 }
99 }
100
101 pub fn bar(&self, name: &str) -> Option<f64> {
103 self.bars
104 .iter()
105 .find(|(n, _)| n == name)
106 .map(|(_, bar)| *bar)
107 }
108
109 pub fn set_bar(&mut self, name: &str, bar: f64) {
111 match self.bars.iter_mut().find(|(n, _)| n == name) {
112 Some(slot) => slot.1 = bar,
113 None => self.bars.push((name.to_owned(), bar)),
114 }
115 }
116
117 pub fn clear_bar(&mut self, name: &str) {
119 self.bars.retain(|(n, _)| n != name);
120 }
121
122 pub fn threshold_of(&self, name: &str, fallback: f64) -> f64 {
126 let is_noul = self
127 .questions
128 .iter()
129 .any(|(n, q)| n == name && matches!(q, Question::Noul(_)));
130 match self.bar(name) {
131 Some(bar) if is_noul => bar,
132 _ => fallback,
133 }
134 }
135
136 pub fn state_is_empty(&self) -> bool {
137 is_empty_value(&self.state)
138 }
139
140 pub fn state_preview(&self) -> String {
142 if let Some(turns) = self.turns()
143 && let Some(last) = turns.last()
144 {
145 let plural = if turns.len() == 1 { "" } else { "s" };
146 return format!("{} turn{plural} · {}", turns.len(), turn_text(last));
147 }
148 match &self.state {
149 Value::String(s) => s.clone(),
150 other => other.to_string(),
151 }
152 }
153
154 pub fn turns(&self) -> Option<Vec<Turn>> {
156 turns_of(&self.state)
157 }
158
159 pub fn add_turn(&mut self, turn: Turn) -> Result<Vec<Turn>, String> {
166 let Some(mut turns) = self.turns().or_else(|| self.seed_turns()) else {
167 return Err(
168 "The state is JSON that is not a conversation, so there is no thread to add to."
169 .to_owned(),
170 );
171 };
172 turns.push(turn);
173 self.state = turns_to_json(&turns);
174 Ok(turns)
175 }
176
177 pub fn drop_turn(&mut self) -> Option<Turn> {
179 let mut turns = self.turns()?;
180 let last = turns.pop()?;
181 self.state = if turns.is_empty() {
182 Value::String(String::new())
183 } else {
184 turns_to_json(&turns)
185 };
186 Some(last)
187 }
188
189 fn seed_turns(&self) -> Option<Vec<Turn>> {
191 if self.state_is_empty() {
192 return Some(Vec::new());
193 }
194 match &self.state {
195 Value::String(s) => Some(vec![Turn {
196 who: None,
197 said: s.clone(),
198 }]),
199 _ => None,
200 }
201 }
202
203 pub fn insert(&mut self, name: String, question: Question) -> bool {
205 if let Some(slot) = self.questions.iter_mut().find(|(n, _)| *n == name) {
206 if std::mem::discriminant(&slot.1) != std::mem::discriminant(&question) {
208 self.bars.retain(|(n, _)| *n != name);
209 }
210 slot.1 = question;
211 true
212 } else {
213 self.questions.push((name, question));
214 false
215 }
216 }
217
218 pub fn remove(&mut self, name: &str) -> bool {
219 let before = self.questions.len();
220 self.questions.retain(|(n, _)| n != name);
221 self.clear_bar(name);
222 self.questions.len() != before
223 }
224
225 pub fn to_questions(&self) -> Questions {
226 self.questions.iter().cloned().collect()
227 }
228
229 pub fn request_json(&self, model: &str) -> String {
234 self.body_json(model, true)
235 }
236
237 pub fn request_json_compact(&self, model: &str) -> String {
240 self.body_json(model, false)
241 }
242
243 fn body_json(&self, model: &str, pretty: bool) -> String {
244 #[derive(Serialize)]
245 struct Body<'a> {
246 state: &'a Value,
247 model: &'a str,
248 questions: &'a Questions,
249 }
250 let questions = self.to_questions();
251 let body = Body {
252 state: &self.state,
253 model,
254 questions: &questions,
255 };
256 if pretty {
257 serde_json::to_string_pretty(&body)
258 } else {
259 serde_json::to_string(&body)
260 }
261 .unwrap_or_else(|e| format!("<unencodable: {e}>"))
262 }
263}
264
265pub fn is_empty_value(v: &Value) -> bool {
270 match v {
271 Value::Null => true,
272 Value::String(s) => s.trim().is_empty(),
273 Value::Array(a) => a.is_empty(),
274 Value::Object(o) => o.is_empty(),
275 _ => false,
276 }
277}
278
279pub fn parse_noul(args: &str) -> Result<Entry, String> {
281 let (name, rest) = split_name(args, ":noul is_urgent The message conveys urgency")?;
282 let mut parts = rest.split('|').map(str::trim);
283 let instructions = parts.next().unwrap_or("");
284 if instructions.is_empty() {
285 return Err(
286 "A noul needs instructions: :noul is_urgent The message conveys urgency".into(),
287 );
288 }
289 let mut q = Noul::new(value(instructions));
290 for part in parts {
291 let (tag, text) = part
292 .split_once(':')
293 .map(|(t, v)| (t.trim().to_ascii_lowercase(), v.trim()))
294 .ok_or_else(|| format!("Expected `yes: …` or `no: …`, got {part:?}."))?;
295 match tag.as_str() {
296 "yes" | "true" => q = q.when_true(value(text)),
297 "no" | "false" => q = q.when_false(value(text)),
298 _ => return Err(format!("Unknown criterion {tag:?}; use `yes:` or `no:`.")),
299 }
300 }
301 Ok((name, q.into()))
302}
303
304pub fn parse_choice(args: &str) -> Result<Entry, String> {
306 let (name, rest) = split_name(
307 args,
308 ":choice department Which team handles this | billing=Payments | technical=Bugs",
309 )?;
310 let mut parts = rest.split('|').map(str::trim);
311 let instructions = parts.next().unwrap_or("");
312 if instructions.is_empty() {
313 return Err("A choice needs instructions before the first `|`.".into());
314 }
315 let mut q = Choice::new(value(instructions));
316 let mut count = 0;
317 for part in parts.filter(|p| !p.is_empty()) {
318 q = match part.split_once('=') {
319 Some((label, desc)) => q.option(label.trim(), value(desc.trim())),
320 None => q.label(part),
321 };
322 count += 1;
323 }
324 if count < 2 {
325 return Err(
326 "A choice needs at least two options: … | billing=Payments | technical=Bugs".into(),
327 );
328 }
329 Ok((name, q.into()))
330}
331
332pub fn parse_score(args: &str) -> Result<Entry, String> {
334 let (name, rest) = split_name(
335 args,
336 ":score frustration How frustrated they are | Calm | Annoyed | Furious",
337 )?;
338 let mut parts = rest.split('|').map(str::trim);
339 let instructions = parts.next().unwrap_or("");
340 if instructions.is_empty() {
341 return Err("A score needs instructions before the first `|`.".into());
342 }
343 let levels: Vec<Value> = parts.filter(|p| !p.is_empty()).map(value).collect();
344 if levels.len() < 2 {
345 return Err(
346 "A score needs at least two ordered levels: … | Calm | Annoyed | Furious".into(),
347 );
348 }
349 Ok((name, Score::new(value(instructions), levels).into()))
350}
351
352pub fn parse_raw(args: &str) -> Result<Entry, String> {
354 let (name, rest) = split_name(
355 args,
356 r#":raw tone {"type": "noul", "instructions": "Polite?"}"#,
357 )?;
358 let v: Value = serde_json::from_str(&rest).map_err(|e| format!("Not valid JSON: {e}"))?;
359 Ok((name, Question::Raw(v)))
360}
361
362pub fn parse_turn(args: &str) -> Result<Turn, String> {
367 let text = args.trim();
368 let example = ":turn customer: The payout failed again";
369 if text.is_empty() {
370 return Err(format!("A turn needs something said. Try: {example}"));
371 }
372 let (head, rest) = match text.split_once(char::is_whitespace) {
373 Some((head, rest)) => (head, rest.trim()),
374 None => (text, ""),
375 };
376 let Some(who) = head.strip_suffix(':').filter(|w| !w.is_empty()) else {
377 return Ok(Turn {
378 who: None,
379 said: text.to_owned(),
380 });
381 };
382 if rest.is_empty() {
383 return Err(format!("Nothing said after {head:?}. Try: {example}"));
384 }
385 Ok(Turn {
386 who: Some(who.to_owned()),
387 said: rest.to_owned(),
388 })
389}
390
391fn split_name(args: &str, example: &str) -> Result<(String, String), String> {
392 let args = args.trim();
393 let (name, rest) = args
394 .split_once(char::is_whitespace)
395 .ok_or_else(|| format!("Missing name or body. Try: {example}"))?;
396 if name.is_empty() {
397 return Err(format!("Missing a question name. Try: {example}"));
398 }
399 Ok((name.to_owned(), rest.trim().to_owned()))
400}
401
402pub fn value(text: &str) -> Value {
405 let t = text.trim();
406 if (t.starts_with('{') || t.starts_with('['))
407 && let Ok(v) = serde_json::from_str::<Value>(t)
408 {
409 return v;
410 }
411 Value::String(t.to_owned())
412}
413
414pub fn from_body(text: &str) -> Result<Session, String> {
417 let body: Value = serde_json::from_str(text).map_err(|e| format!("Not valid JSON: {e}"))?;
418 let obj = body.as_object().ok_or("Expected a JSON object.")?;
419 let questions = obj
420 .get("questions")
421 .and_then(Value::as_object)
422 .ok_or("Expected a `questions` object.")?;
423 Ok(Session {
424 state: obj.get("state").cloned().unwrap_or(Value::Null),
425 model: obj.get("model").and_then(Value::as_str).map(str::to_owned),
426 questions: questions
427 .iter()
428 .map(|(name, q)| (name.clone(), question_from_json(q)))
429 .collect(),
430 bars: Vec::new(),
432 })
433}
434
435pub fn question_from_json(v: &Value) -> Question {
437 let instructions = v.get("instructions").cloned();
438 let criteria = v.get("criteria");
439 match v.get("type").and_then(Value::as_str) {
440 Some("noul") => {
441 let mut q = match instructions {
442 Some(i) => Noul::new(i),
443 None => Noul::default(),
444 };
445 if let Some(yes) = criteria.and_then(|c| c.get("true")) {
446 q = q.when_true(yes.clone());
447 }
448 if let Some(no) = criteria.and_then(|c| c.get("false")) {
449 q = q.when_false(no.clone());
450 }
451 q.into()
452 }
453 Some("choice") => {
454 let mut q = Choice::new(instructions.unwrap_or(Value::Null));
455 if let Some(map) = criteria.and_then(Value::as_object) {
456 for (label, desc) in map {
457 q = match desc {
458 Value::Null => q.label(label.clone()),
459 d => q.option(label.clone(), d.clone()),
460 };
461 }
462 }
463 q.into()
464 }
465 Some("score") => {
466 let levels = criteria
467 .and_then(Value::as_array)
468 .cloned()
469 .unwrap_or_default();
470 Score::new(instructions.unwrap_or(Value::Null), levels).into()
471 }
472 _ => Question::Raw(v.clone()),
473 }
474}