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