Skip to main content

jev_repl/
headless.rs

1//! jev with no terminal in the way: a page in, an answer page out.
2//!
3//! The REPL is the place to shape a request; once it is shaped, the same session is something a
4//! script wants — in a pipe, in a Makefile, in CI. Everything here is the pure half of that: text
5//! in, text out, no terminal and no process, so a test can drive it without a PTY.
6//!
7//! ```
8//! # use jev_repl::headless;
9//! let session = headless::load("A payout failed.\n---\nis_urgent? Conveys urgency").unwrap();
10//! println!("{}", headless::request_text(&session, "jev-latest"));
11//! ```
12
13use ratatui::text::Line;
14use serde_json::Value;
15use typesafe::{Answer, Question, SystemOneResponse, Usage};
16
17use crate::cost::Rates;
18use crate::format::{answer_lines, cost_lines};
19use crate::session::Session;
20use crate::{codegen, cost, mock, session, sketch};
21
22/// The subcommands that run without a terminal, and what each one prints.
23pub const COMMANDS: &[(&str, &str)] = &[
24    ("run", "send the request and print the answers"),
25    (
26        "eval",
27        "run a page over a file of labelled cases and score the answers",
28    ),
29    ("json", "the exact request body this session POSTs"),
30    (
31        "cost",
32        "what a call costs, per question and on both sides of the wire",
33    ),
34    ("rust", "the session as a program against typesafe-ai-sdk"),
35    ("check", "parse the input and report what is wrong with it"),
36];
37
38/// Whether `word` names a subcommand, so `jev <word>` is not mistaken for a flag or a path.
39pub fn is_command(word: &str) -> bool {
40    COMMANDS.iter().any(|(name, _)| *name == word)
41}
42
43/// One question's answer, or `None` when nothing answered it.
44pub type Answered = (String, Option<Answer>);
45
46/// Read a session from a sketch page or a request body.
47///
48/// Which one it is comes from the text, not the file name: stdin has no extension, and a here-doc
49/// piped in should behave the same as the file it was copied from. A leading `{` is a request
50/// body; anything else is a page.
51pub fn load(text: &str) -> Result<Session, String> {
52    if text.trim().is_empty() {
53        return Err("Nothing to read: the input is empty.".to_owned());
54    }
55    if text.trim_start().starts_with('{') {
56        return session::from_body(text);
57    }
58    let page = sketch::parse(text);
59    match page.problems.first() {
60        Some(problem) => Err(format!("line {}: {}", problem.line + 1, problem.message)),
61        None => Ok(page.to_session()),
62    }
63}
64
65/// What `jev check` says about a page: every problem, not just the first.
66pub fn check_text(text: &str) -> Result<String, String> {
67    if text.trim().is_empty() {
68        return Err("Nothing to read: the input is empty.".to_owned());
69    }
70    if text.trim_start().starts_with('{') {
71        return session::from_body(text).map(|s| describe(&s));
72    }
73    let page = sketch::parse(text);
74    if !page.ok() {
75        return Err(page
76            .problems
77            .iter()
78            .map(|p| format!("line {}: {}", p.line + 1, p.message))
79            .collect::<Vec<_>>()
80            .join("\n"));
81    }
82    Ok(describe(&page.to_session()))
83}
84
85/// `3 questions: is_urgent (noul), department (choice)` — enough to see the parse landed right.
86fn describe(session: &Session) -> String {
87    let n = session.questions.len();
88    let kinds = session
89        .questions
90        .iter()
91        .map(|(name, q)| {
92            let kind = kind_of(q);
93            let directive = match q {
94                Question::Noul(_) => Some("@threshold"),
95                Question::Choice(_) | Question::Score(_) => Some("@confidence"),
96                _ => None,
97            };
98            match (session.bar(name), directive) {
99                (Some(bar), Some(directive)) => format!("{name} ({kind}, {directive} {bar})"),
100                _ => format!("{name} ({kind})"),
101            }
102        })
103        .collect::<Vec<_>>()
104        .join(", ");
105    let plural = if n == 1 { "" } else { "s" };
106    let head = if kinds.is_empty() {
107        format!("{n} question{plural}")
108    } else {
109        format!("{n} question{plural}: {kinds}")
110    };
111    if session.state_is_empty() {
112        return format!("{head}\nno state — pass --state <text> before sending");
113    }
114    match session.turns() {
115        Some(turns) => {
116            let plural = if turns.len() == 1 { "" } else { "s" };
117            format!(
118                "{head}\nthe state is a conversation of {} turn{plural}",
119                turns.len()
120            )
121        }
122        None => head,
123    }
124}
125
126/// The wire `type` of a question, for the one-line summary `check` prints.
127fn kind_of(question: &Question) -> String {
128    match question {
129        Question::Noul(_) => "noul".to_owned(),
130        Question::Choice(_) => "choice".to_owned(),
131        Question::Score(_) => "score".to_owned(),
132        Question::Raw(value) => value
133            .get("type")
134            .and_then(Value::as_str)
135            .unwrap_or("raw")
136            .to_owned(),
137        // `Question` is non-exhaustive: a shape this build does not know still has a name.
138        _ => "question".to_owned(),
139    }
140}
141
142/// Why this session cannot be sent yet, if it cannot.
143pub fn sendable(session: &Session) -> Option<String> {
144    if session.questions.is_empty() {
145        return Some("No questions: the request would ask nothing.".to_owned());
146    }
147    if session.state_is_empty() {
148        return Some("No state: pass --state <text>, or put one above the `---`.".to_owned());
149    }
150    None
151}
152
153/// The exact body the SDK would POST.
154pub fn request_text(session: &Session, model: &str) -> String {
155    format!("{}\n", session.request_json(model))
156}
157
158/// Simulated answers, the same deterministic ones the REPL shows offline.
159pub fn mock_answers(session: &Session) -> Vec<Answered> {
160    session
161        .questions
162        .iter()
163        .map(|(name, q)| {
164            let json = serde_json::to_value(q).unwrap_or(Value::Null);
165            (name.clone(), mock::answer(&session.state, name, &json))
166        })
167        .collect()
168}
169
170/// The answers a live response carries, lined up with the questions that were asked.
171pub fn live_answers(session: &Session, response: &SystemOneResponse) -> Vec<Answered> {
172    session
173        .questions
174        .iter()
175        .map(|(name, _)| (name.clone(), response.answers.get(name).cloned()))
176        .collect()
177}
178
179/// The answers a cached body carries, lined up with the questions that were asked.
180///
181/// The wire body is all the cache keeps, and `SystemOneResponse` cannot be rebuilt outside the SDK
182/// crate, so this is [`live_answers`] for a response that arrived from disk instead of the
183/// network. `None` means the file is not a System One body, which is a cache miss and not an error.
184pub fn cached_answers(session: &Session, body: &Value) -> Option<(Vec<Answered>, Option<Usage>)> {
185    let object = body.as_object()?;
186    object.get("model")?.as_str()?;
187    let mut decoded: Vec<(String, Answer)> = Vec::new();
188    for (name, value) in object.get("answers")?.as_object()? {
189        let answer = match value.get("type")?.as_str()? {
190            "noul" => Answer::Noul(serde_json::from_value(value.clone()).ok()?),
191            "choice" => Answer::Choice(serde_json::from_value(value.clone()).ok()?),
192            "score" => Answer::Score(serde_json::from_value(value.clone()).ok()?),
193            // An answer this build does not model is skipped, the way the SDK's decoder skips it.
194            _ => continue,
195        };
196        decoded.push((name.clone(), answer));
197    }
198    let answers = session
199        .questions
200        .iter()
201        .map(|(name, _)| {
202            let answer = decoded
203                .iter()
204                .find(|(n, _)| n == name)
205                .map(|(_, a)| a.clone());
206            (name.clone(), answer)
207        })
208        .collect();
209    let usage = object
210        .get("usage")
211        .and_then(|u| serde_json::from_value::<Usage>(u.clone()).ok());
212    Some((answers, usage))
213}
214
215/// The answer page: the same bars and labels the REPL draws, minus the colour.
216pub fn answers_text(answers: &[Answered], threshold: f64) -> String {
217    page_text(answers, |_| threshold)
218}
219
220/// The answer page for a session, where a noul that carries its own `@threshold` is read at that
221/// instead of `threshold` — what `jev run` and `jev_ask` print.
222pub fn session_answers_text(answers: &[Answered], threshold: f64, session: &Session) -> String {
223    page_text(answers, |name| session.threshold_of(name, threshold))
224}
225
226fn page_text(answers: &[Answered], threshold_of: impl Fn(&str) -> f64) -> String {
227    let mut out = String::new();
228    for (name, answer) in answers {
229        match answer {
230            Some(a) => out.push_str(&plain(answer_lines(name, a, threshold_of(name)))),
231            None => out.push_str(&format!(
232                "  {name}: no answer came back for this question.\n"
233            )),
234        }
235    }
236    out
237}
238
239/// The raw body, for `--json`: what arrived live, or the shape a mock answer would have arrived in.
240pub fn answers_json(answers: &[Answered], model: &str, raw: Option<&Value>) -> String {
241    match raw {
242        Some(value) => format!(
243            "{}\n",
244            serde_json::to_string_pretty(value).unwrap_or_default()
245        ),
246        None => format!("{}\n", mock::body(answers, model)),
247    }
248}
249
250/// The token table, priced when rates were supplied.
251pub fn cost_text(session: &Session, model: &str, rates: Option<Rates>) -> String {
252    let estimate = cost::estimate(session, model);
253    let hint = "--price 0.20/1.00 prices it: dollars per million tokens, input then output";
254    plain(cost_lines(
255        &estimate,
256        rates,
257        hint,
258        cost::thread(session, model).as_ref(),
259    ))
260}
261
262/// The one-line footer under an answer page: tokens, and money when the rates are known.
263pub fn usage_text(
264    session: &Session,
265    model: &str,
266    rates: Option<Rates>,
267    usage: Option<&Usage>,
268) -> String {
269    if let Some(usage) = usage
270        && let (Some(input), Some(output)) = (usage.input_tokens, usage.output_tokens)
271    {
272        let money = match rates.and_then(|r| cost::price_usage(usage, r)) {
273            Some(cost) => format!(" · {}", cost::usd(cost.total)),
274            None => String::new(),
275        };
276        return format!("  {input} in / {output} out tokens{money}\n");
277    }
278    let estimate = cost::estimate(session, model);
279    let money = match rates {
280        Some(rates) => format!(
281            " · {}",
282            cost::usd(cost::price_estimate(&estimate, rates).total)
283        ),
284        None => String::new(),
285    };
286    format!(
287        "  ≈ {} in / {} out tokens{money} — estimated, nothing was counted\n",
288        estimate.input_tokens, estimate.output_tokens
289    )
290}
291
292/// The session as code, for `jev rust`.
293pub fn code_text(session: &Session, model: &str, threshold: f64) -> String {
294    let code = codegen::rust(session, model, threshold);
295    if code.ends_with('\n') {
296        code
297    } else {
298        format!("{code}\n")
299    }
300}
301
302/// Styled lines as the plain text a pipe wants.
303fn plain(lines: Vec<Line<'static>>) -> String {
304    let mut out = String::new();
305    for line in lines {
306        for span in &line.spans {
307            out.push_str(span.content.as_ref());
308        }
309        out.push('\n');
310    }
311    out
312}