lmjtfy.git / packages / llm / src / lib.rs
lib.rsannotatedlib.rssource382 lines · 15.4 KB · raw
1//! The LLM that sits between a visitor and Jev: what it is told, the tools it
2//! is given, and what it wrote back.
3//!
4//! Jev cannot write text. It judges a question whose answer space somebody
5//! has already written down. The LLM's whole job is to write that question:
6//! one of three tool calls, one per Jev type. Nothing here does I/O; the
7//! Worker sends [`request`] through the Workers AI binding and the eval sends
8//! it over REST, and both hand the reply to [`parse`].
9#![forbid(unsafe_code)]
10
11use rules::Want;
12use serde::{Deserialize, Serialize};
13use serde_json::{Value, json};
14
15mod models;
16pub use models::{CANDIDATES, FREE_NEURONS_PER_DAY, Model};
17
18/// The most tool calls one input may become. More are dropped, not run.
19pub const MAX_CALLS: usize = 4;
20/// The most options a Choice may list: enough for a real field, few enough to
21/// read as bars on a phone. Jev itself takes up to 255.
22pub const MAX_OPTIONS: usize = 8;
23/// A Score's levels, as Jev takes them.
24pub const SCORE_LEVELS: std::ops::RangeInclusive<usize> = 2..=10;
25/// The reply's token ceiling, and so the worst case a call can cost.
26pub const MAX_TOKENS: u32 = 1200;
27
28const SYSTEM: &str = "\
29You sit between a person and Jev. Jev is a model that cannot write text. It only judges, in three ways:
30- jev_noul: a yes-or-no question, answered with the probability of yes.
31- jev_choice: one of several options that you list, answered with a probability for each.
32- jev_score: a position on a scale whose levels you write, lowest first.
33
34Turn the person's input into the tool call that answers it.
35- Always call a tool. Never answer the question yourself, and write no other text.
36- A yes-or-no question becomes jev_noul. So does \"how likely is X\": ask whether X, and the probability is the answer.
37- \"Which\", \"who\", \"what is the best\" and other questions with a best answer become jev_choice. \
38You choose 2 to 8 real, specific options and give each a one-line description.
39- \"How good\", \"how much\", \"how spicy\", \"rate this\" become jev_score with 3 to 7 levels, lowest first.
40- Use one call. Use more only when the input plainly asks several separate things, and never more than 4.
41- Write `instructions` as one clear question. Jev sees the person's input beside it, and nothing else.";
42
43/// The three tools, in the OpenAI function format Workers AI takes.
44fn tools() -> Value {
45    let text = |description: &str| json!({ "type": "string", "description": description });
46    let tool = |name: &str, description: &str, properties: Value, required: &[&str]| {
47        json!({
48            "type": "function",
49            "function": {
50                "name": name,
51                "description": description,
52                "parameters": { "type": "object", "properties": properties, "required": required },
53            },
54        })
55    };
56    json!([
57        tool(
58            "jev_noul",
59            "Ask Jev a yes-or-no question. Jev answers with the probability that the answer is yes.",
60            json!({
61                "instructions": text("The yes-or-no question."),
62                "yes_means": text("What a yes means, in one sentence."),
63                "no_means": text("What a no means, in one sentence."),
64            }),
65            &["instructions", "yes_means", "no_means"],
66        ),
67        tool(
68            "jev_choice",
69            "Ask Jev to pick one of several options. Jev answers with a probability for every option.",
70            json!({
71                "instructions": text("The question the options answer."),
72                "options": {
73                    "type": "array",
74                    "description": "2 to 8 options. Labels are short and all different.",
75                    "items": {
76                        "type": "object",
77                        "properties": {
78                            "label": text("The option's short name."),
79                            "description": text("One line on what this option is."),
80                        },
81                        "required": ["label", "description"],
82                    },
83                },
84            }),
85            &["instructions", "options"],
86        ),
87        tool(
88            "jev_score",
89            "Ask Jev to place the input on a scale. Jev answers with a score and a probability for every level.",
90            json!({
91                "instructions": text("What is being scored."),
92                "levels": {
93                    "type": "array",
94                    "description": "2 to 10 levels, lowest first. Each is one line saying what that level means.",
95                    "items": { "type": "string" },
96                },
97            }),
98            &["instructions", "levels"],
99        ),
100    ])
101}
102
103/// The tools the LLM may call for `wants`: every one when the input is
104/// several questions (what each one is, is the LLM's to work out), and
105/// otherwise only the kinds the rules settled on. `None` is every tool.
106fn settled(wants: &[Want]) -> Option<Vec<&'static str>> {
107    if wants.is_empty() || wants.contains(&Want::Split) {
108        return None;
109    }
110    Some(
111        wants
112            .iter()
113            .map(|want| match want {
114                Want::Options => "jev_choice",
115                Want::Scale => "jev_score",
116                Want::Split => unreachable!("ruled out above"),
117            })
118            .collect(),
119    )
120}
121
122/// The LLM's instructions when the rules have settled what the input is.
123/// It is told about, and given, only the tools for that: Jev has already
124/// answered the other readings itself, and a second answer to one of them
125/// from here would only disagree with the first (2026-10-02: "what are the
126/// chances that…" got a yes-or-no from Jev and another, with a different
127/// probability, from a `jev_noul` the LLM wrote when a scale was wanted).
128fn settled_system(tools: &[&str]) -> String {
129    let has = |tool: &str| tools.contains(&tool);
130    let mut text = String::from(
131        "You sit between a person and Jev. Jev is a model that cannot write text. It only judges. \
132         For this input, like this:\n",
133    );
134    if has("jev_choice") {
135        text.push_str("- jev_choice: one of several options that you list, answered with a probability for each.\n");
136    }
137    if has("jev_score") {
138        text.push_str("- jev_score: a position on a scale whose levels you write, lowest first.\n");
139    }
140    text.push_str(match (has("jev_choice"), has("jev_score")) {
141        (true, true) => {
142            "\nThe person's input has already been read two ways: as a pick among possibilities, and as a \
143             how-much question. Write exactly two tool calls, one jev_choice and one jev_score.\n"
144        }
145        (true, false) => {
146            "\nThe person's input has already been read as a pick among possibilities. Write exactly one \
147             jev_choice call.\n"
148        }
149        _ => "\nThe person's input has already been read as a how-much question. Write exactly one jev_score call.\n",
150    });
151    text.push_str("- Always call a tool. Never answer the question yourself, and write no other text.\n");
152    if has("jev_choice") {
153        text.push_str("- For jev_choice, choose 2 to 8 real, specific options and give each a one-line description.\n");
154    }
155    if has("jev_score") {
156        text.push_str(
157            "- For jev_score, write 3 to 7 levels, lowest first, that fit what is asked: its own units, ranges \
158             or named grades where it has them, and levels of likelihood where it asks how likely.\n",
159        );
160    }
161    text.push_str("- Write `instructions` as one clear question. Jev sees the person's input beside it, and nothing else.");
162    text
163}
164
165/// Whether a question the LLM wrote is of a kind it was asked for. One that
166/// is not, is not sent: Jev has answered that reading already, or the rules
167/// did not take it.
168pub fn takes(wants: &[Want], draft: &Draft) -> bool {
169    settled(wants).is_none_or(|tools| tools.contains(&draft.tool()))
170}
171
172/// The request body for `input`, as JSON text. The same bytes go to the
173/// binding and to the page's tool call panel. `wants` is what the rules say
174/// the LLM is to write ([`rules::Network::wants`]): sorted, no repeats.
175pub fn request(input: &str, wants: &[Want]) -> String {
176    let (system, tools) = match settled(wants) {
177        Some(names) => {
178            let given: Vec<Value> = tools()
179                .as_array()
180                .into_iter()
181                .flatten()
182                .filter(|tool| names.iter().any(|name| tool["function"]["name"] == *name))
183                .cloned()
184                .collect();
185            (settled_system(&names), Value::Array(given))
186        }
187        None => (SYSTEM.to_owned(), tools()),
188    };
189    json!({
190        "messages": [
191            { "role": "system", "content": system },
192            { "role": "user", "content": input },
193        ],
194        "tools": tools,
195        "max_tokens": MAX_TOKENS,
196        "temperature": 0,
197    })
198    .to_string()
199}
200
201/// A question for Jev as the LLM wrote it, checked for shape.
202#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
203pub enum Draft {
204    Noul { instructions: String, yes_means: String, no_means: String },
205    Choice { instructions: String, options: Vec<Opt> },
206    Score { instructions: String, levels: Vec<String> },
207}
208
209#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
210#[serde(deny_unknown_fields)]
211pub struct Opt {
212    pub label: String,
213    pub description: String,
214}
215
216impl Draft {
217    /// The tool that was called, which is also the Jev type.
218    pub fn tool(&self) -> &'static str {
219        match self {
220            Draft::Noul { .. } => "jev_noul",
221            Draft::Choice { .. } => "jev_choice",
222            Draft::Score { .. } => "jev_score",
223        }
224    }
225
226    pub fn instructions(&self) -> &str {
227        match self {
228            Draft::Noul { instructions, .. }
229            | Draft::Choice { instructions, .. }
230            | Draft::Score { instructions, .. } => instructions,
231        }
232    }
233}
234
235/// One tool call from the reply.
236#[derive(Clone, Debug, PartialEq)]
237pub struct ToolCall {
238    pub name: String,
239    /// The arguments exactly as the model wrote them.
240    pub arguments: String,
241    /// The question they describe, or why they do not describe one.
242    pub draft: Result<Draft, String>,
243}
244
245/// What a reply cost, as the API reported it.
246#[derive(Clone, Copy, Debug, Default, PartialEq)]
247pub struct Usage {
248    pub prompt_tokens: u64,
249    pub completion_tokens: u64,
250    /// Cloudflare's own count, when the reply carries one.
251    pub neurons: Option<f64>,
252}
253
254#[derive(Clone, Debug, PartialEq)]
255pub struct Reply {
256    /// At most [`MAX_CALLS`], in the order the model made them.
257    pub calls: Vec<ToolCall>,
258    /// How many calls the model made past the cap. They were dropped.
259    pub dropped: usize,
260    pub usage: Usage,
261}
262
263/// Reads a chat-completion reply: the binding's own, or REST's, which wraps
264/// it in `result`. An `Err` is a reply with no usable shape at all; a reply
265/// whose calls are malformed is `Ok`, with the reason on each call.
266pub fn parse(body: &str) -> Result<Reply, String> {
267    let root: Value = serde_json::from_str(body).map_err(|e| format!("the reply is not JSON: {e}"))?;
268    let root = root.get("result").unwrap_or(&root);
269    let message = root
270        .pointer("/choices/0/message")
271        .ok_or_else(|| "the reply has no choices[0].message".to_owned())?;
272    let made: &[Value] = message.get("tool_calls").and_then(Value::as_array).map_or(&[], Vec::as_slice);
273    let calls = made.iter().take(MAX_CALLS).map(tool_call).collect();
274    let usage = root.get("usage");
275    let count = |name: &str| usage.and_then(|u| u.get(name)).and_then(Value::as_u64).unwrap_or(0);
276    Ok(Reply {
277        calls,
278        dropped: made.len().saturating_sub(MAX_CALLS),
279        usage: Usage {
280            prompt_tokens: count("prompt_tokens"),
281            completion_tokens: count("completion_tokens"),
282            neurons: usage.and_then(|u| u.get("neurons")).and_then(Value::as_f64),
283        },
284    })
285}
286
287fn tool_call(call: &Value) -> ToolCall {
288    let name = call.pointer("/function/name").and_then(Value::as_str).unwrap_or_default().to_owned();
289    let arguments = match call.pointer("/function/arguments") {
290        Some(Value::String(text)) => text.clone(),
291        Some(other) => other.to_string(),
292        None => String::new(),
293    };
294    let draft = draft(&name, &arguments);
295    ToolCall { name, arguments, draft }
296}
297
298/// The arguments as an object. The format says `arguments` is JSON text of an
299/// object. One model (granite-4.0-h-micro, measured 2026-10-02) encodes that
300/// text a second time, so a string that decodes to a string is decoded once
301/// more. Nothing else is repaired.
302fn object(arguments: &str) -> Result<Value, String> {
303    let mut value: Value =
304        serde_json::from_str(arguments).map_err(|e| format!("the arguments are not JSON: {e}"))?;
305    if let Value::String(inner) = &value {
306        value = serde_json::from_str(inner).map_err(|e| format!("the arguments are not JSON: {e}"))?;
307    }
308    if value.is_object() { Ok(value) } else { Err("the arguments are not an object".to_owned()) }
309}
310
311fn draft(name: &str, arguments: &str) -> Result<Draft, String> {
312    #[derive(Deserialize)]
313    #[serde(deny_unknown_fields)]
314    struct NoulArgs {
315        instructions: String,
316        yes_means: String,
317        no_means: String,
318    }
319    #[derive(Deserialize)]
320    #[serde(deny_unknown_fields)]
321    struct ChoiceArgs {
322        instructions: String,
323        options: Vec<Opt>,
324    }
325    #[derive(Deserialize)]
326    #[serde(deny_unknown_fields)]
327    struct ScoreArgs {
328        instructions: String,
329        levels: Vec<String>,
330    }
331    fn read<T: for<'de> Deserialize<'de>>(value: Value) -> Result<T, String> {
332        serde_json::from_value(value).map_err(|e| e.to_string())
333    }
334    let filled = |what: &str, text: &str| {
335        if text.trim().is_empty() { Err(format!("{what} is empty")) } else { Ok(()) }
336    };
337
338    let value = object(arguments)?;
339    match name {
340        "jev_noul" => {
341            let NoulArgs { instructions, yes_means, no_means } = read(value)?;
342            filled("instructions", &instructions)?;
343            filled("yes_means", &yes_means)?;
344            filled("no_means", &no_means)?;
345            Ok(Draft::Noul { instructions, yes_means, no_means })
346        }
347        "jev_choice" => {
348            let ChoiceArgs { instructions, options } = read(value)?;
349            filled("instructions", &instructions)?;
350            if !(2..=MAX_OPTIONS).contains(&options.len()) {
351                return Err(format!("a choice takes 2 to {MAX_OPTIONS} options, not {}", options.len()));
352            }
353            for (i, option) in options.iter().enumerate() {
354                filled("an option's label", &option.label)?;
355                if options[..i].iter().any(|earlier| earlier.label == option.label) {
356                    return Err(format!("the option {:?} appears twice", option.label));
357                }
358            }
359            Ok(Draft::Choice { instructions, options })
360        }
361        "jev_score" => {
362            let ScoreArgs { instructions, levels } = read(value)?;
363            filled("instructions", &instructions)?;
364            if !SCORE_LEVELS.contains(&levels.len()) {
365                return Err(format!(
366                    "a score takes {} to {} levels, not {}",
367                    SCORE_LEVELS.start(),
368                    SCORE_LEVELS.end(),
369                    levels.len()
370                ));
371            }
372            for level in &levels {
373                filled("a level", level)?;
374            }
375            Ok(Draft::Score { instructions, levels })
376        }
377        other => Err(format!("there is no tool called {other:?}")),
378    }
379}
380
381#[cfg(test)]
382mod tests;