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/// Everything the next `:ask` will send.
11#[derive(Debug, Default)]
12pub struct Session {
13    /// The text or JSON the model reasons about.
14    pub state: Value,
15    /// Named questions, in insertion order.
16    pub questions: Vec<Entry>,
17    /// Per-session model override; `None` means the client default.
18    pub model: Option<String>,
19}
20
21impl Session {
22    pub fn new() -> Self {
23        Self {
24            state: Value::String(String::new()),
25            questions: Vec::new(),
26            model: None,
27        }
28    }
29
30    pub fn state_is_empty(&self) -> bool {
31        is_empty_value(&self.state)
32    }
33
34    /// One-line preview of the state for the side panel.
35    pub fn state_preview(&self) -> String {
36        match &self.state {
37            Value::String(s) => s.clone(),
38            other => other.to_string(),
39        }
40    }
41
42    /// Add a question, or replace one of the same name in place.
43    pub fn insert(&mut self, name: String, question: Question) -> bool {
44        if let Some(slot) = self.questions.iter_mut().find(|(n, _)| *n == name) {
45            slot.1 = question;
46            true
47        } else {
48            self.questions.push((name, question));
49            false
50        }
51    }
52
53    pub fn remove(&mut self, name: &str) -> bool {
54        let before = self.questions.len();
55        self.questions.retain(|(n, _)| n != name);
56        self.questions.len() != before
57    }
58
59    pub fn to_questions(&self) -> Questions {
60        self.questions.iter().cloned().collect()
61    }
62
63    /// The exact JSON body the SDK will POST to `/v1/systemone`.
64    ///
65    /// Serialized through a struct (not a `Value`) so field and question order survive, which is
66    /// the whole point of showing it.
67    pub fn request_json(&self, model: &str) -> String {
68        self.body_json(model, true)
69    }
70
71    /// The same body [`Session::request_json`] shows, with the whitespace taken out: what a cache
72    /// key hashes.
73    pub fn request_json_compact(&self, model: &str) -> String {
74        self.body_json(model, false)
75    }
76
77    fn body_json(&self, model: &str, pretty: bool) -> String {
78        #[derive(Serialize)]
79        struct Body<'a> {
80            state: &'a Value,
81            model: &'a str,
82            questions: &'a Questions,
83        }
84        let questions = self.to_questions();
85        let body = Body {
86            state: &self.state,
87            model,
88            questions: &questions,
89        };
90        if pretty {
91            serde_json::to_string_pretty(&body)
92        } else {
93            serde_json::to_string(&body)
94        }
95        .unwrap_or_else(|e| format!("<unencodable: {e}>"))
96    }
97}
98
99/// Whether a value is empty enough that there is nothing to judge.
100///
101/// A case's state is arbitrary JSON and is held to the same bar as a session's, so the rule lives
102/// here rather than inside [`Session::state_is_empty`].
103pub fn is_empty_value(v: &Value) -> bool {
104    match v {
105        Value::Null => true,
106        Value::String(s) => s.trim().is_empty(),
107        Value::Array(a) => a.is_empty(),
108        Value::Object(o) => o.is_empty(),
109        _ => false,
110    }
111}
112
113/// `name instructions [| yes: ...] [| no: ...]`
114pub fn parse_noul(args: &str) -> Result<Entry, String> {
115    let (name, rest) = split_name(args, ":noul is_urgent The message conveys urgency")?;
116    let mut parts = rest.split('|').map(str::trim);
117    let instructions = parts.next().unwrap_or("");
118    if instructions.is_empty() {
119        return Err(
120            "A noul needs instructions: :noul is_urgent The message conveys urgency".into(),
121        );
122    }
123    let mut q = Noul::new(value(instructions));
124    for part in parts {
125        let (tag, text) = part
126            .split_once(':')
127            .map(|(t, v)| (t.trim().to_ascii_lowercase(), v.trim()))
128            .ok_or_else(|| format!("Expected `yes: …` or `no: …`, got {part:?}."))?;
129        match tag.as_str() {
130            "yes" | "true" => q = q.when_true(value(text)),
131            "no" | "false" => q = q.when_false(value(text)),
132            _ => return Err(format!("Unknown criterion {tag:?}; use `yes:` or `no:`.")),
133        }
134    }
135    Ok((name, q.into()))
136}
137
138/// `name instructions | label=description | bare_label | …`
139pub fn parse_choice(args: &str) -> Result<Entry, String> {
140    let (name, rest) = split_name(
141        args,
142        ":choice department Which team handles this | billing=Payments | technical=Bugs",
143    )?;
144    let mut parts = rest.split('|').map(str::trim);
145    let instructions = parts.next().unwrap_or("");
146    if instructions.is_empty() {
147        return Err("A choice needs instructions before the first `|`.".into());
148    }
149    let mut q = Choice::new(value(instructions));
150    let mut count = 0;
151    for part in parts.filter(|p| !p.is_empty()) {
152        q = match part.split_once('=') {
153            Some((label, desc)) => q.option(label.trim(), value(desc.trim())),
154            None => q.label(part),
155        };
156        count += 1;
157    }
158    if count < 2 {
159        return Err(
160            "A choice needs at least two options: … | billing=Payments | technical=Bugs".into(),
161        );
162    }
163    Ok((name, q.into()))
164}
165
166/// `name instructions | level | level | …`
167pub fn parse_score(args: &str) -> Result<Entry, String> {
168    let (name, rest) = split_name(
169        args,
170        ":score frustration How frustrated they are | Calm | Annoyed | Furious",
171    )?;
172    let mut parts = rest.split('|').map(str::trim);
173    let instructions = parts.next().unwrap_or("");
174    if instructions.is_empty() {
175        return Err("A score needs instructions before the first `|`.".into());
176    }
177    let levels: Vec<Value> = parts.filter(|p| !p.is_empty()).map(value).collect();
178    if levels.len() < 2 {
179        return Err(
180            "A score needs at least two ordered levels: … | Calm | Annoyed | Furious".into(),
181        );
182    }
183    Ok((name, Score::new(value(instructions), levels).into()))
184}
185
186/// `name {json}` — a hand-built question object, like `Question::Raw`.
187pub fn parse_raw(args: &str) -> Result<Entry, String> {
188    let (name, rest) = split_name(
189        args,
190        r#":raw tone {"type": "noul", "instructions": "Polite?"}"#,
191    )?;
192    let v: Value = serde_json::from_str(&rest).map_err(|e| format!("Not valid JSON: {e}"))?;
193    Ok((name, Question::Raw(v)))
194}
195
196fn split_name(args: &str, example: &str) -> Result<(String, String), String> {
197    let args = args.trim();
198    let (name, rest) = args
199        .split_once(char::is_whitespace)
200        .ok_or_else(|| format!("Missing name or body. Try: {example}"))?;
201    if name.is_empty() {
202        return Err(format!("Missing a question name. Try: {example}"));
203    }
204    Ok((name.to_owned(), rest.trim().to_owned()))
205}
206
207/// Instructions, descriptions and levels accept any JSON, so `{…}`/`[…]` is parsed as such and
208/// anything else is sent as a plain string.
209pub fn value(text: &str) -> Value {
210    let t = text.trim();
211    if (t.starts_with('{') || t.starts_with('['))
212        && let Ok(v) = serde_json::from_str::<Value>(t)
213    {
214        return v;
215    }
216    Value::String(t.to_owned())
217}
218
219/// Rebuild a session from a saved request body (`:open`), keeping typed questions where the
220/// `type` is one this SDK models.
221pub fn from_body(text: &str) -> Result<Session, String> {
222    let body: Value = serde_json::from_str(text).map_err(|e| format!("Not valid JSON: {e}"))?;
223    let obj = body.as_object().ok_or("Expected a JSON object.")?;
224    let questions = obj
225        .get("questions")
226        .and_then(Value::as_object)
227        .ok_or("Expected a `questions` object.")?;
228    Ok(Session {
229        state: obj.get("state").cloned().unwrap_or(Value::Null),
230        model: obj.get("model").and_then(Value::as_str).map(str::to_owned),
231        questions: questions
232            .iter()
233            .map(|(name, q)| (name.clone(), question_from_json(q)))
234            .collect(),
235    })
236}
237
238/// Map a wire question back to a typed one; anything unfamiliar stays [`Question::Raw`].
239pub fn question_from_json(v: &Value) -> Question {
240    let instructions = v.get("instructions").cloned();
241    let criteria = v.get("criteria");
242    match v.get("type").and_then(Value::as_str) {
243        Some("noul") => {
244            let mut q = match instructions {
245                Some(i) => Noul::new(i),
246                None => Noul::default(),
247            };
248            if let Some(yes) = criteria.and_then(|c| c.get("true")) {
249                q = q.when_true(yes.clone());
250            }
251            if let Some(no) = criteria.and_then(|c| c.get("false")) {
252                q = q.when_false(no.clone());
253            }
254            q.into()
255        }
256        Some("choice") => {
257            let mut q = Choice::new(instructions.unwrap_or(Value::Null));
258            if let Some(map) = criteria.and_then(Value::as_object) {
259                for (label, desc) in map {
260                    q = match desc {
261                        Value::Null => q.label(label.clone()),
262                        d => q.option(label.clone(), d.clone()),
263                    };
264                }
265            }
266            q.into()
267        }
268        Some("score") => {
269            let levels = criteria
270                .and_then(Value::as_array)
271                .cloned()
272                .unwrap_or_default();
273            Score::new(instructions.unwrap_or(Value::Null), levels).into()
274        }
275        _ => Question::Raw(v.clone()),
276    }
277}