Skip to main content

jev_repl/
codegen.rs

1//! Turn the current session into a program written against this SDK.
2
3use serde_json::Value;
4
5use crate::evaluate;
6use crate::session::Session;
7
8pub fn rust(session: &Session, model: &str, threshold: f64) -> String {
9    let questions: Vec<(String, Value)> = session
10        .questions
11        .iter()
12        .filter_map(|(name, q)| Some((name.clone(), serde_json::to_value(q).ok()?)))
13        .collect();
14
15    let mut out = String::new();
16    out.push_str("#[tokio::main]\nasync fn main() -> typesafe::Result<()> {\n");
17    out.push_str("    let client = Client::from_env()?; // TYPESAFE_API_KEY\n\n");
18    out.push_str("    let res = client\n        .system_one(\n");
19    out.push_str(&format!("            {},\n", state_literal(&session.state)));
20    out.push_str("            Questions::new()\n");
21    for (name, q) in &questions {
22        out.push_str(&format!(
23            "                .with({:?}, {})\n",
24            name,
25            builder(q, 20)
26        ));
27    }
28    out.push_str("        )\n");
29    out.push_str(&format!("        .model({model:?})\n"));
30    out.push_str("        .await?;\n\n");
31
32    if questions.is_empty() {
33        out.push_str("    // add questions in the REPL and run :rust again\n");
34    }
35    for (name, q) in &questions {
36        out.push_str(&reader(name, q, session.bar(name), threshold));
37    }
38    out.push_str("\n    Ok(())\n}\n");
39
40    // Imports are worked out from what the body actually used, `json!` included.
41    let mut imports = vec!["Client", "Questions"];
42    for (_, q) in &questions {
43        match kind(q) {
44            "noul" => imports.push("Noul"),
45            "choice" => imports.push("Choice"),
46            "score" => imports.push("Score"),
47            _ => {}
48        }
49    }
50    if out.contains("json!(") {
51        imports.push("json");
52    }
53    imports.sort_unstable();
54    imports.dedup();
55
56    format!(
57        "// Cargo.toml: typesafe-ai-sdk = \"0.1\"\nuse typesafe::{{{}}};\n\n{out}",
58        imports.join(", ")
59    )
60}
61
62fn kind(q: &Value) -> &str {
63    q.get("type").and_then(Value::as_str).unwrap_or("raw")
64}
65
66fn builder(q: &Value, indent: usize) -> String {
67    let pad = " ".repeat(indent + 4);
68    let instructions = q.get("instructions").map(literal).unwrap_or_default();
69    match kind(q) {
70        "noul" => {
71            let mut s = format!("Noul::new({instructions})");
72            let criteria = q.get("criteria");
73            if let Some(v) = criteria.and_then(|c| c.get("true")) {
74                s.push_str(&format!("\n{pad}.when_true({})", literal(v)));
75            }
76            if let Some(v) = criteria.and_then(|c| c.get("false")) {
77                s.push_str(&format!("\n{pad}.when_false({})", literal(v)));
78            }
79            s
80        }
81        "choice" => {
82            let mut s = format!("Choice::new({instructions})");
83            if let Some(criteria) = q.get("criteria").and_then(Value::as_object) {
84                for (label, desc) in criteria {
85                    s.push_str(&match desc {
86                        Value::Null => format!("\n{pad}.label({label:?})"),
87                        v => format!("\n{pad}.option({label:?}, {})", literal(v)),
88                    });
89                }
90            }
91            s
92        }
93        "score" => {
94            let levels: Vec<String> = q
95                .get("criteria")
96                .and_then(Value::as_array)
97                .map(|a| a.iter().map(literal).collect())
98                .unwrap_or_default();
99            format!(
100                "Score::new(\n{pad}{instructions},\n{pad}[{}],\n{})",
101                levels.join(", "),
102                " ".repeat(indent)
103            )
104        }
105        _ => format!("json!({q})"),
106    }
107}
108
109/// What a choice is gated at when the page names no bar: the README's rule of thumb.
110const CHOICE_GATE: f64 = 0.6;
111
112/// A threshold as code: two decimals, the way it has always been printed, unless that would change
113/// it — a bar someone wrote as `0.625` is `0.625` in the program too.
114fn threshold_literal(t: f64) -> String {
115    let fixed = evaluate::two(t);
116    if fixed.parse::<f64>().ok() == Some(t) {
117        fixed
118    } else {
119        format!("{t}")
120    }
121}
122
123/// A float literal Rust reads as an `f64`: `0.7` stays `0.7`, but `1` has to be `1.0`.
124fn float_literal(n: f64) -> String {
125    let text = format!("{n}");
126    if text.contains(['.', 'e']) {
127        text
128    } else {
129        format!("{text}.0")
130    }
131}
132
133/// How one answer is read back: a noul at its threshold, a choice (and a score with a bar) gated
134/// on confidence. `bar` is the page's `@threshold` or `@confidence` for this question.
135fn reader(name: &str, q: &Value, bar: Option<f64>, threshold: f64) -> String {
136    match kind(q) {
137        "noul" => {
138            let cut = threshold_literal(bar.unwrap_or(threshold));
139            format!(
140                "    let {name} = res.noul({name:?}).expect(\"asked\");\n\
141                 \x20   println!(\"{name}: {{:.2}} → {{}}\", {name}.noul, {name}.is_yes({cut}));\n"
142            )
143        }
144        "choice" => {
145            let gate = float_literal(bar.unwrap_or(CHOICE_GATE));
146            format!(
147                "    let {name} = res.choice({name:?}).expect(\"asked\");\n\
148                 \x20   if {name}.confidence >= {gate} {{\n\
149                 \x20       println!(\"{name}: {{}}\", {name}.choice);\n\
150                 \x20   }} else {{\n\
151                 \x20       println!(\"{name}: unsure ({{:.2}}), send to a human\", {name}.confidence);\n\
152                 \x20   }}\n"
153            )
154        }
155        // A score with a bar is gated like a choice: act above it, hand the rest to a person.
156        "score" => match bar {
157            Some(bar) => {
158                let gate = float_literal(bar);
159                format!(
160                    "    let {name} = res.score({name:?}).expect(\"asked\");\n\
161                     \x20   if {name}.confidence >= {gate} {{\n\
162                     \x20       println!(\"{name}: {{:.2}} of {{}} (confidence {{:.2}})\", {name}.score, {name}.legend.len() - 1, {name}.confidence);\n\
163                     \x20   }} else {{\n\
164                     \x20       println!(\"{name}: unsure ({{:.2}}), send to a human\", {name}.confidence);\n\
165                     \x20   }}\n"
166                )
167            }
168            None => format!(
169                "    let {name} = res.score({name:?}).expect(\"asked\");\n\
170                 \x20   println!(\"{name}: {{:.2}} of {{}} (confidence {{:.2}})\", {name}.score, {name}.legend.len() - 1, {name}.confidence);\n"
171            ),
172        },
173        _ => format!("    // {name}: a raw question — read it from res.raw\n"),
174    }
175}
176
177fn state_literal(state: &Value) -> String {
178    match state {
179        Value::String(s) => format!("{s:?}"),
180        other => format!("json!({other})"),
181    }
182}
183
184fn literal(v: &Value) -> String {
185    match v {
186        Value::String(s) => format!("{s:?}"),
187        other => format!("json!({other})"),
188    }
189}