Skip to main content

jev_repl/
session.rs

1//! The thing you are building in the REPL: a `state`, some named questions, a model.
2
3use serde::Serialize;
4use serde_json::Value;
5use typesafe::{Choice, Noul, Question, Questions, Score};
6
7/// A question under construction, kept in the order it was added (answers come back in that order).
8pub type Entry = (String, Question);
9
10/// One turn of a conversation held as the state: who spoke, and what they said.
11///
12/// A conversation is not a new field on the wire — it is the `state`, shaped as an array. The
13/// questions stay fixed and the state grows, which is the whole point: the same rubric, re-read
14/// after every reply, so a noul can be watched moving rather than sampled once.
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct Turn {
17    /// The speaker, when the line names one.
18    pub who: Option<String>,
19    /// What was said.
20    pub said: String,
21}
22
23/// Keys a turn's speaker may arrive under, so a transcript from elsewhere still reads as one.
24const WHO_KEYS: [&str; 4] = ["who", "role", "speaker", "from"];
25/// Keys a turn's text may arrive under, for the same reason.
26const SAID_KEYS: [&str; 4] = ["said", "text", "content", "message"];
27
28/// Read a state as a conversation, or `None` when it is not one.
29///
30/// Only an array whose every element carries some text counts, so a string state, a row of
31/// numbers or an object of fields is never mistaken for a thread and quietly reshaped. The key
32/// names are read loosely because a transcript pasted in from a chat API is still a transcript.
33pub 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
51/// Turns as they go on the wire: `who` only when there is one, so nothing empty is paid for.
52pub 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
68/// `customer: The payout failed again` — one turn on one line.
69pub 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/// Everything the next `:ask` will send.
77#[derive(Debug, Default, Clone)]
78pub struct Session {
79    /// The text or JSON the model reasons about.
80    pub state: Value,
81    /// Named questions, in insertion order.
82    pub questions: Vec<Entry>,
83    /// Per-session model override; `None` means the client default.
84    pub model: Option<String>,
85}
86
87impl Session {
88    pub fn new() -> Self {
89        Self {
90            state: Value::String(String::new()),
91            questions: Vec::new(),
92            model: None,
93        }
94    }
95
96    pub fn state_is_empty(&self) -> bool {
97        is_empty_value(&self.state)
98    }
99
100    /// One-line preview of the state for the side panel.
101    pub fn state_preview(&self) -> String {
102        if let Some(turns) = self.turns()
103            && let Some(last) = turns.last()
104        {
105            let plural = if turns.len() == 1 { "" } else { "s" };
106            return format!("{} turn{plural} · {}", turns.len(), turn_text(last));
107        }
108        match &self.state {
109            Value::String(s) => s.clone(),
110            other => other.to_string(),
111        }
112    }
113
114    /// The state read as a conversation, or `None` when it is something else.
115    pub fn turns(&self) -> Option<Vec<Turn>> {
116        turns_of(&self.state)
117    }
118
119    /// Append a turn to the state.
120    ///
121    /// An empty state starts a thread and a thread grows by one. A state that is plain text
122    /// becomes the first turn, because that is how a session usually begins — one message, then
123    /// the reply to it. Any other JSON is refused rather than reshaped: whatever it is, it is not
124    /// a conversation, and guessing at one would lose it.
125    pub fn add_turn(&mut self, turn: Turn) -> Result<Vec<Turn>, String> {
126        let Some(mut turns) = self.turns().or_else(|| self.seed_turns()) else {
127            return Err(
128                "The state is JSON that is not a conversation, so there is no thread to add to."
129                    .to_owned(),
130            );
131        };
132        turns.push(turn);
133        self.state = turns_to_json(&turns);
134        Ok(turns)
135    }
136
137    /// Take the last turn back. The last one of all leaves the state empty again.
138    pub fn drop_turn(&mut self) -> Option<Turn> {
139        let mut turns = self.turns()?;
140        let last = turns.pop()?;
141        self.state = if turns.is_empty() {
142            Value::String(String::new())
143        } else {
144            turns_to_json(&turns)
145        };
146        Some(last)
147    }
148
149    /// What a thread starts from: nothing, or the text that was already there.
150    fn seed_turns(&self) -> Option<Vec<Turn>> {
151        if self.state_is_empty() {
152            return Some(Vec::new());
153        }
154        match &self.state {
155            Value::String(s) => Some(vec![Turn {
156                who: None,
157                said: s.clone(),
158            }]),
159            _ => None,
160        }
161    }
162
163    /// Add a question, or replace one of the same name in place.
164    pub fn insert(&mut self, name: String, question: Question) -> bool {
165        if let Some(slot) = self.questions.iter_mut().find(|(n, _)| *n == name) {
166            slot.1 = question;
167            true
168        } else {
169            self.questions.push((name, question));
170            false
171        }
172    }
173
174    pub fn remove(&mut self, name: &str) -> bool {
175        let before = self.questions.len();
176        self.questions.retain(|(n, _)| n != name);
177        self.questions.len() != before
178    }
179
180    pub fn to_questions(&self) -> Questions {
181        self.questions.iter().cloned().collect()
182    }
183
184    /// The exact JSON body the SDK will POST to `/v1/systemone`.
185    ///
186    /// Serialized through a struct (not a `Value`) so field and question order survive, which is
187    /// the whole point of showing it.
188    pub fn request_json(&self, model: &str) -> String {
189        self.body_json(model, true)
190    }
191
192    /// The same body [`Session::request_json`] shows, with the whitespace taken out: what a cache
193    /// key hashes.
194    pub fn request_json_compact(&self, model: &str) -> String {
195        self.body_json(model, false)
196    }
197
198    fn body_json(&self, model: &str, pretty: bool) -> String {
199        #[derive(Serialize)]
200        struct Body<'a> {
201            state: &'a Value,
202            model: &'a str,
203            questions: &'a Questions,
204        }
205        let questions = self.to_questions();
206        let body = Body {
207            state: &self.state,
208            model,
209            questions: &questions,
210        };
211        if pretty {
212            serde_json::to_string_pretty(&body)
213        } else {
214            serde_json::to_string(&body)
215        }
216        .unwrap_or_else(|e| format!("<unencodable: {e}>"))
217    }
218}
219
220/// Whether a value is empty enough that there is nothing to judge.
221///
222/// A case's state is arbitrary JSON and is held to the same bar as a session's, so the rule lives
223/// here rather than inside [`Session::state_is_empty`].
224pub fn is_empty_value(v: &Value) -> bool {
225    match v {
226        Value::Null => true,
227        Value::String(s) => s.trim().is_empty(),
228        Value::Array(a) => a.is_empty(),
229        Value::Object(o) => o.is_empty(),
230        _ => false,
231    }
232}
233
234/// `name instructions [| yes: ...] [| no: ...]`
235pub fn parse_noul(args: &str) -> Result<Entry, String> {
236    let (name, rest) = split_name(args, ":noul is_urgent The message conveys urgency")?;
237    let mut parts = rest.split('|').map(str::trim);
238    let instructions = parts.next().unwrap_or("");
239    if instructions.is_empty() {
240        return Err(
241            "A noul needs instructions: :noul is_urgent The message conveys urgency".into(),
242        );
243    }
244    let mut q = Noul::new(value(instructions));
245    for part in parts {
246        let (tag, text) = part
247            .split_once(':')
248            .map(|(t, v)| (t.trim().to_ascii_lowercase(), v.trim()))
249            .ok_or_else(|| format!("Expected `yes: …` or `no: …`, got {part:?}."))?;
250        match tag.as_str() {
251            "yes" | "true" => q = q.when_true(value(text)),
252            "no" | "false" => q = q.when_false(value(text)),
253            _ => return Err(format!("Unknown criterion {tag:?}; use `yes:` or `no:`.")),
254        }
255    }
256    Ok((name, q.into()))
257}
258
259/// `name instructions | label=description | bare_label | …`
260pub fn parse_choice(args: &str) -> Result<Entry, String> {
261    let (name, rest) = split_name(
262        args,
263        ":choice department Which team handles this | billing=Payments | technical=Bugs",
264    )?;
265    let mut parts = rest.split('|').map(str::trim);
266    let instructions = parts.next().unwrap_or("");
267    if instructions.is_empty() {
268        return Err("A choice needs instructions before the first `|`.".into());
269    }
270    let mut q = Choice::new(value(instructions));
271    let mut count = 0;
272    for part in parts.filter(|p| !p.is_empty()) {
273        q = match part.split_once('=') {
274            Some((label, desc)) => q.option(label.trim(), value(desc.trim())),
275            None => q.label(part),
276        };
277        count += 1;
278    }
279    if count < 2 {
280        return Err(
281            "A choice needs at least two options: … | billing=Payments | technical=Bugs".into(),
282        );
283    }
284    Ok((name, q.into()))
285}
286
287/// `name instructions | level | level | …`
288pub fn parse_score(args: &str) -> Result<Entry, String> {
289    let (name, rest) = split_name(
290        args,
291        ":score frustration How frustrated they are | Calm | Annoyed | Furious",
292    )?;
293    let mut parts = rest.split('|').map(str::trim);
294    let instructions = parts.next().unwrap_or("");
295    if instructions.is_empty() {
296        return Err("A score needs instructions before the first `|`.".into());
297    }
298    let levels: Vec<Value> = parts.filter(|p| !p.is_empty()).map(value).collect();
299    if levels.len() < 2 {
300        return Err(
301            "A score needs at least two ordered levels: … | Calm | Annoyed | Furious".into(),
302        );
303    }
304    Ok((name, Score::new(value(instructions), levels).into()))
305}
306
307/// `name {json}` — a hand-built question object, like `Question::Raw`.
308pub fn parse_raw(args: &str) -> Result<Entry, String> {
309    let (name, rest) = split_name(
310        args,
311        r#":raw tone {"type": "noul", "instructions": "Polite?"}"#,
312    )?;
313    let v: Value = serde_json::from_str(&rest).map_err(|e| format!("Not valid JSON: {e}"))?;
314    Ok((name, Question::Raw(v)))
315}
316
317/// `who: what they said`, or just what they said.
318///
319/// The speaker is the first word and only when that word ends in a colon, so a line typed without
320/// one keeps all of its words instead of donating the first to a speaker nobody named.
321pub fn parse_turn(args: &str) -> Result<Turn, String> {
322    let text = args.trim();
323    let example = ":turn customer: The payout failed again";
324    if text.is_empty() {
325        return Err(format!("A turn needs something said. Try: {example}"));
326    }
327    let (head, rest) = match text.split_once(char::is_whitespace) {
328        Some((head, rest)) => (head, rest.trim()),
329        None => (text, ""),
330    };
331    let Some(who) = head.strip_suffix(':').filter(|w| !w.is_empty()) else {
332        return Ok(Turn {
333            who: None,
334            said: text.to_owned(),
335        });
336    };
337    if rest.is_empty() {
338        return Err(format!("Nothing said after {head:?}. Try: {example}"));
339    }
340    Ok(Turn {
341        who: Some(who.to_owned()),
342        said: rest.to_owned(),
343    })
344}
345
346fn split_name(args: &str, example: &str) -> Result<(String, String), String> {
347    let args = args.trim();
348    let (name, rest) = args
349        .split_once(char::is_whitespace)
350        .ok_or_else(|| format!("Missing name or body. Try: {example}"))?;
351    if name.is_empty() {
352        return Err(format!("Missing a question name. Try: {example}"));
353    }
354    Ok((name.to_owned(), rest.trim().to_owned()))
355}
356
357/// Instructions, descriptions and levels accept any JSON, so `{…}`/`[…]` is parsed as such and
358/// anything else is sent as a plain string.
359pub fn value(text: &str) -> Value {
360    let t = text.trim();
361    if (t.starts_with('{') || t.starts_with('['))
362        && let Ok(v) = serde_json::from_str::<Value>(t)
363    {
364        return v;
365    }
366    Value::String(t.to_owned())
367}
368
369/// Rebuild a session from a saved request body (`:open`), keeping typed questions where the
370/// `type` is one this SDK models.
371pub fn from_body(text: &str) -> Result<Session, String> {
372    let body: Value = serde_json::from_str(text).map_err(|e| format!("Not valid JSON: {e}"))?;
373    let obj = body.as_object().ok_or("Expected a JSON object.")?;
374    let questions = obj
375        .get("questions")
376        .and_then(Value::as_object)
377        .ok_or("Expected a `questions` object.")?;
378    Ok(Session {
379        state: obj.get("state").cloned().unwrap_or(Value::Null),
380        model: obj.get("model").and_then(Value::as_str).map(str::to_owned),
381        questions: questions
382            .iter()
383            .map(|(name, q)| (name.clone(), question_from_json(q)))
384            .collect(),
385    })
386}
387
388/// Map a wire question back to a typed one; anything unfamiliar stays [`Question::Raw`].
389pub fn question_from_json(v: &Value) -> Question {
390    let instructions = v.get("instructions").cloned();
391    let criteria = v.get("criteria");
392    match v.get("type").and_then(Value::as_str) {
393        Some("noul") => {
394            let mut q = match instructions {
395                Some(i) => Noul::new(i),
396                None => Noul::default(),
397            };
398            if let Some(yes) = criteria.and_then(|c| c.get("true")) {
399                q = q.when_true(yes.clone());
400            }
401            if let Some(no) = criteria.and_then(|c| c.get("false")) {
402                q = q.when_false(no.clone());
403            }
404            q.into()
405        }
406        Some("choice") => {
407            let mut q = Choice::new(instructions.unwrap_or(Value::Null));
408            if let Some(map) = criteria.and_then(Value::as_object) {
409                for (label, desc) in map {
410                    q = match desc {
411                        Value::Null => q.label(label.clone()),
412                        d => q.option(label.clone(), d.clone()),
413                    };
414                }
415            }
416            q.into()
417        }
418        Some("score") => {
419            let levels = criteria
420                .and_then(Value::as_array)
421                .cloned()
422                .unwrap_or_default();
423            Score::new(instructions.unwrap_or(Value::Null), levels).into()
424        }
425        _ => Question::Raw(v.clone()),
426    }
427}