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    /// Each question's decision bar, by name: a noul's threshold, or the confidence a choice or a
86    /// score has to reach before it is acted on. It lives on the page and never goes on the wire —
87    /// it is what the caller does with the answer, not part of the question.
88    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    /// The bar written for a question, if the page gives it one.
102    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    /// Write down a question's bar, replacing the one it had.
110    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    /// Forget a question's bar.
118    pub fn clear_bar(&mut self, name: &str) {
119        self.bars.retain(|(n, _)| n != name);
120    }
121
122    /// The threshold a noul is read at: its own `@threshold` when the page has one, `fallback` — the
123    /// session-wide `:threshold` or `--threshold` — when it does not. The question's own bar wins
124    /// because it is the more specific of the two: someone wrote it down for this question.
125    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    /// One-line preview of the state for the side panel.
141    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    /// The state read as a conversation, or `None` when it is something else.
155    pub fn turns(&self) -> Option<Vec<Turn>> {
156        turns_of(&self.state)
157    }
158
159    /// Append a turn to the state.
160    ///
161    /// An empty state starts a thread and a thread grows by one. A state that is plain text
162    /// becomes the first turn, because that is how a session usually begins — one message, then
163    /// the reply to it. Any other JSON is refused rather than reshaped: whatever it is, it is not
164    /// a conversation, and guessing at one would lose it.
165    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    /// Take the last turn back. The last one of all leaves the state empty again.
178    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    /// What a thread starts from: nothing, or the text that was already there.
190    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    /// Add a question, or replace one of the same name in place.
204    pub fn insert(&mut self, name: String, question: Question) -> bool {
205        if let Some(slot) = self.questions.iter_mut().find(|(n, _)| *n == name) {
206            // A threshold means nothing to a choice, nor a confidence bar to a noul.
207            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    /// The exact JSON body the SDK will POST to `/v1/systemone`.
230    ///
231    /// Serialized through a struct (not a `Value`) so field and question order survive, which is
232    /// the whole point of showing it.
233    pub fn request_json(&self, model: &str) -> String {
234        self.body_json(model, true)
235    }
236
237    /// The same body [`Session::request_json`] shows, with the whitespace taken out: what a cache
238    /// key hashes.
239    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
265/// Whether a value is empty enough that there is nothing to judge.
266///
267/// A case's state is arbitrary JSON and is held to the same bar as a session's, so the rule lives
268/// here rather than inside [`Session::state_is_empty`].
269pub 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
279/// `name instructions [| yes: ...] [| no: ...]`
280pub 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
304/// `name instructions | label=description | bare_label | …`
305pub 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
332/// `name instructions | level | level | …`
333pub 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
352/// `name {json}` — a hand-built question object, like `Question::Raw`.
353pub 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
362/// `who: what they said`, or just what they said.
363///
364/// The speaker is the first word and only when that word ends in a colon, so a line typed without
365/// one keeps all of its words instead of donating the first to a speaker nobody named.
366pub 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
402/// Instructions, descriptions and levels accept any JSON, so `{…}`/`[…]` is parsed as such and
403/// anything else is sent as a plain string.
404pub 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
414/// Rebuild a session from a saved request body (`:open`), keeping typed questions where the
415/// `type` is one this SDK models.
416pub 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        // A request body has nowhere to hold a bar.
431        bars: Vec::new(),
432    })
433}
434
435/// Map a wire question back to a typed one; anything unfamiliar stays [`Question::Raw`].
436pub 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}