1use 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
22pub 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
38pub fn is_command(word: &str) -> bool {
40 COMMANDS.iter().any(|(name, _)| *name == word)
41}
42
43pub type Answered = (String, Option<Answer>);
45
46pub 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
65pub 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
85fn 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
126fn 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".to_owned(),
139 }
140}
141
142pub 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
153pub fn request_text(session: &Session, model: &str) -> String {
155 format!("{}\n", session.request_json(model))
156}
157
158pub 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
170pub 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
179pub 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 _ => 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
215pub fn answers_text(answers: &[Answered], threshold: f64) -> String {
217 page_text(answers, |_| threshold)
218}
219
220pub 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
239pub 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
250pub 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
262pub 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
292pub 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
302fn 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}