1//! What lmjtfy asks Jev, and the record each call leaves for the page. 2//! 3//! There are two requests. The first asks for facts about the input, as many 4//! as the rules want at once ([`rules::Fact`]): can Jev judge it, what kind 5//! of question is it, is it several, does it need a scale written for it, is 6//! it fit to show publicly, and the answer if it is a yes-or-no question, a 7//! how-much one, or a how-likely one. The second asks the questions the LLM wrote ([`llm::Draft`]), 8//! all in one request. Every question is asked about the same state, the 9//! visitor's input. 10//! 11//! A call is prepared before it is sent, so the page can show the request 12//! while Jev is still answering it, and [`send`] returns the response. 13//! Nothing here does I/O: it runs on whatever `Transport` and `Runtime` the 14//! `Client` was built with. 15#![forbid(unsafe_code)] 16 17use std::time::Duration; 18 19use jev_client::{Client, Observer, Runtime, Transport}; 20use jev_protocol::{ 21 Choice, ChoiceAnswer, Json, Key, ModelId, Noul, NoulAnswer, ProtocolError, Question, Questions, Score, 22 Response, ScoreAnswer, Usage, request_bytes, worst_case_dollars, 23}; 24use llm::Draft; 25use rules::{Fact, Kind, Value}; 26use serde::{Deserialize, Serialize}; 27 28/// The longest input sent on. A search box, not a document. 29pub const MAX_INPUT_CHARS: usize = 500; 30 31/// At or above this, a `Noul` reads as yes. 32pub const THRESHOLD: f64 = 0.5; 33 34/// Whether a `Noul`'s probability reads as yes. 35pub fn is_yes(p_yes: f64) -> bool { 36 p_yes >= THRESHOLD 37} 38 39/// A `Noul` is sure when the likelier answer has at least this much. Below 40/// it the page ends the answer with a question mark instead of a full stop 41/// (the user, 2026-10-02: "is AI a bit stupid?" came back 0.55, "he's not 42/// that confident"). 43pub const SURE: f64 = 0.7; 44 45/// Whether a `Noul`'s probability is far enough from even to state flatly. 46pub fn is_sure(p_yes: f64) -> bool { 47 p_yes.max(1.0 - p_yes) >= SURE 48} 49 50/// A probability in a word or two, for a how-likely answer: the number is 51/// the answer, and this is how to read it. 52pub fn likelihood(p: f64) -> &'static str { 53 match p { 54 p if p < 0.1 => "very unlikely", 55 p if p < 0.4 => "unlikely", 56 p if p <= 0.6 => "a toss-up", 57 p if p <= 0.9 => "likely", 58 _ => "very likely", 59 } 60} 61 62/// The visitor's text as Jev sees it, trimmed and capped. 63pub fn clean(input: &str) -> String { 64 input.trim().chars().take(MAX_INPUT_CHARS).collect() 65} 66 67/// The labels of the `kind` question's options, and the [`Kind`] each means. 68const KINDS: [(&str, Kind, &str); 4] = [ 69 ("yes_or_no", Kind::Noul, "It asks whether something is so. A yes or a no answers it."), 70 ( 71 "pick_one", 72 Kind::Choice, 73 "It asks which, who or what: the best or right one among several possibilities. Naming one answers it.", 74 ), 75 ( 76 "how_much", 77 Kind::Score, 78 "It asks for a degree, an amount or a rating. A position on a scale answers it.", 79 ), 80 ( 81 "how_likely", 82 Kind::Chance, 83 "It asks for the chance, the odds or the probability of something. A percentage answers it.", 84 ), 85]; 86 87/// Jev's runner-up reading is taken too when it has at least this much: Jev 88/// is split, and both readings are answered (the user, 2026-10-02: at 90% 89/// do that one; at 40 / 40 / 20, do the top two). 90pub const ALSO: f64 = 0.3; 91 92/// Jev's probability that the input is this kind of question. 93pub fn kind_probability(answer: &ChoiceAnswer, kind: Kind) -> Option<f64> { 94 let (label, ..) = KINDS.iter().find(|(_, known, _)| *known == kind)?; 95 answer.probabilities.iter().find(|(option, _)| option == label).map(|(_, p)| *p) 96} 97 98/// The kinds the input is read as, likeliest first: the top one, and the 99/// runner-up if Jev gave it at least [`ALSO`]. 100pub fn readings(answer: &ChoiceAnswer) -> Vec<Kind> { 101 let mut ranked: Vec<(Kind, f64)> = 102 Kind::ALL.into_iter().filter_map(|kind| Some((kind, kind_probability(answer, kind)?))).collect(); 103 ranked.sort_by(|a, b| b.1.total_cmp(&a.1)); 104 ranked.iter().enumerate().filter(|(rank, (_, p))| *rank == 0 || (*rank == 1 && *p >= ALSO)).map(|(_, (kind, _))| *kind).collect() 105} 106 107/// The id of the question that teaches a fact. The three readings are one 108/// question, answered with a probability for each. 109pub fn question_id(fact: Fact) -> &'static str { 110 match fact { 111 Fact::Reads(_) => "kind", 112 fact => fact.name(), 113 } 114} 115 116/// The scale a how-much question is answered on when nobody writes one for 117/// it, lowest first. Fixed, so Jev can be asked without an LLM (the user, 118/// 2026-10-02, knowing it gives up levels written for the question). 119pub const DEGREES: [&str; 5] = ["Not at all", "Slightly", "Moderately", "Very", "Extremely"]; 120 121/// The question that teaches a fact. Only facts whose source is Jev have one. 122fn fact_question(fact: Fact) -> Result<Question, ProtocolError> { 123 Ok(match fact { 124 Fact::Answerable => Question::Noul( 125 Noul::new(Json::text("Is the input a question you can answer with your types?")) 126 .yes_means(Json::text( 127 "The input asks something that can be judged: a yes-or-no question, a question with a best \ 128 answer among options, or a question of degree.", 129 )) 130 .no_means(Json::text( 131 "The input is anything else: a greeting, a statement, a command, or a request to write, \ 132 explain, summarise or do something.", 133 )), 134 ), 135 Fact::Reads(_) => Question::Choice(Choice::new( 136 Json::text("What kind of question is the input?"), 137 KINDS.iter().map(|(label, _, means)| ((*label).to_owned(), Some(Json::text(means)))), 138 )?), 139 Fact::Several => Question::Noul( 140 Noul::new(Json::text("Does the input ask more than one separate question?")) 141 .yes_means(Json::text("It asks two or more separate questions, each needing its own answer.")) 142 .no_means(Json::text("It asks one question, even if that question mentions several things.")), 143 ), 144 Fact::Yes => Question::Noul( 145 Noul::new(Json::text("Answer the question in the input.")) 146 .yes_means(Json::text("The answer to the question in the input is yes.")) 147 .no_means(Json::text("The answer to the question in the input is no.")), 148 ), 149 Fact::Degree => Question::Score(Score::new( 150 Json::text("Answer the question in the input as a degree: how much, how good, how strong, how likely."), 151 DEGREES.iter().map(|level| Json::text(level)), 152 )?), 153 Fact::Scale => Question::Noul( 154 Noul::new(Json::text("Would the input be answered badly on a general scale from 'Not at all' to 'Extremely'?")) 155 .yes_means(Json::text( 156 "Yes. What it asks about has its own units, ranges, grades or named levels, and a good \ 157 answer would use them.", 158 )) 159 .no_means(Json::text( 160 "No. A general scale answers it well enough, or it is not a how-much question at all.", 161 )), 162 ), 163 Fact::Chance => Question::Noul( 164 Noul::new(Json::text("Is the thing the input asks the chances of so?")) 165 .yes_means(Json::text("It is so, or it will happen.")) 166 .no_means(Json::text("It is not so, or it will not happen.")), 167 ), 168 Fact::Fit => Question::Noul( 169 Noul::new(Json::text("Is the input fit to show to strangers on a public page?")) 170 .yes_means(Json::text("It is harmless to show anyone.")) 171 .no_means(Json::text( 172 "It has a slur, harassment, a threat, sexual content, or a private person's name or \ 173 personal details.", 174 )), 175 ), 176 Fact::Drafted | Fact::Judged => { 177 return Err(ProtocolError::Invalid(format!("{} is not a fact Jev is asked for", fact.name()))); 178 } 179 }) 180} 181 182fn drafted_question(draft: &Draft) -> Result<Question, ProtocolError> { 183 Ok(match draft { 184 Draft::Noul { instructions, yes_means, no_means } => Question::Noul( 185 Noul::new(Json::text(instructions)).yes_means(Json::text(yes_means)).no_means(Json::text(no_means)), 186 ), 187 Draft::Choice { instructions, options } => Question::Choice(Choice::new( 188 Json::text(instructions), 189 options.iter().map(|option| (option.label.clone(), Some(Json::text(&option.description)))), 190 )?), 191 Draft::Score { instructions, levels } => { 192 Question::Score(Score::new(Json::text(instructions), levels.iter().map(|level| Json::text(level)))?) 193 } 194 }) 195} 196 197/// Whether Jev's own protocol takes a question the LLM wrote. One that it 198/// refuses is left out of the request, and the page says why. 199pub fn check(draft: &Draft) -> Result<(), ProtocolError> { 200 drafted_question(draft).map(|_| ()) 201} 202 203/// The answer's key, typed by the question it belongs to. 204#[derive(Clone, Copy)] 205enum Asked { 206 Noul(Key<NoulAnswer>), 207 Choice(Key<ChoiceAnswer>), 208 Score(Key<ScoreAnswer>), 209} 210 211/// One question in a request. 212pub struct Part { 213 /// The question's id in the request and on the page. 214 pub id: String, 215 /// The Jev type asked: `noul`, `choice` or `score`. 216 pub kind: &'static str, 217 asked: Asked, 218} 219 220/// A call that is ready to send: one request, one or more questions. 221pub struct Prepared { 222 /// What the call is for, on the page: `facts` or `answers`. 223 pub id: &'static str, 224 /// The questions, in the order they are asked. 225 pub parts: Vec<Part>, 226 /// The request body exactly as it will be sent. 227 pub request: String, 228 /// The most this call can cost, every retry billed 229 /// (`jev_protocol::worst_case_dollars`): what a budget holds before it 230 /// is sent. 231 pub worst_case_dollars: f64, 232 state: Json, 233 questions: Questions, 234} 235 236fn prepare( 237 model: &ModelId, 238 id: &'static str, 239 input: &str, 240 asked: impl IntoIterator<Item = (String, Question)>, 241) -> Result<Prepared, ProtocolError> { 242 let state = Json::canonical(&serde_json::json!({ "input": input }).to_string())?; 243 let mut questions = Questions::new(); 244 let mut parts = Vec::new(); 245 for (id, question) in asked { 246 let (kind, asked) = match question { 247 Question::Noul(noul) => ("noul", Asked::Noul(questions.noul(&id, noul)?)), 248 Question::Choice(choice) => ("choice", Asked::Choice(questions.choice(&id, choice)?)), 249 Question::Score(score) => ("score", Asked::Score(questions.score(&id, score)?)), 250 }; 251 parts.push(Part { id, kind, asked }); 252 } 253 let request = String::from_utf8(request_bytes(model, &state, &questions)?) 254 .map_err(|e| ProtocolError::Invalid(e.to_string()))?; 255 let worst_case_dollars = worst_case_dollars(model, &state, &questions); 256 Ok(Prepared { id, parts, request, worst_case_dollars, state, questions }) 257} 258 259/// What to ask Jev about an input, in a form that can be sent to the 260/// archive: it prepares the same request from this as the Worker did. 261#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] 262pub enum Wanted { 263 /// The questions that teach these facts, in one request. 264 Facts(Vec<Fact>), 265 /// The questions the LLM wrote, in one request, each with its id. 266 Drafted(Vec<(String, Draft)>), 267} 268 269/// The request for `wanted` about `input` (already [`clean`]). 270pub fn wanted(model: &ModelId, input: &str, wanted: &Wanted) -> Result<Prepared, ProtocolError> { 271 match wanted { 272 Wanted::Facts(facts) => { 273 // Several facts can share a question; it is asked once. 274 let mut asked: Vec<(String, Question)> = Vec::new(); 275 for fact in facts { 276 let id = question_id(*fact); 277 if asked.iter().all(|(known, _)| known != id) { 278 asked.push((id.to_owned(), fact_question(*fact)?)); 279 } 280 } 281 prepare(model, "facts", input, asked) 282 } 283 Wanted::Drafted(drafts) => { 284 let asked: Result<Vec<_>, _> = 285 drafts.iter().map(|(id, draft)| Ok((id.clone(), drafted_question(draft)?))).collect(); 286 prepare(model, "answers", input, asked?) 287 } 288 } 289} 290 291/// What the answer to a fact's question ([`question_id`]) makes the fact. 292/// `None` when the answer is not of the type that question is. 293pub fn learned(fact: Fact, judged: &Judged) -> Option<Value> { 294 match (fact, judged) { 295 (Fact::Answerable | Fact::Several | Fact::Scale | Fact::Fit, Judged::Noul(p_yes)) => { 296 Some(Value::Bool(is_yes(*p_yes))) 297 } 298 (Fact::Yes | Fact::Chance, Judged::Noul(_)) | (Fact::Degree, Judged::Score(_)) => Some(Value::Given), 299 (Fact::Reads(kind), Judged::Choice(answer)) => Some(Value::Bool(readings(answer).contains(&kind))), 300 _ => None, 301 } 302} 303 304/// Jev's answer, by type. 305#[derive(Clone, Debug, PartialEq)] 306pub enum Judged { 307 /// Probability that the answer is yes. 308 Noul(f64), 309 Choice(ChoiceAnswer), 310 Score(ScoreAnswer), 311} 312 313/// Whether this ask put the request on the wire. 314#[derive(Clone, Copy, Debug, PartialEq)] 315pub enum Sent { 316 /// It did, just now. 317 Now, 318 /// It did not: the same request was answered at `at_ms` (Unix 319 /// milliseconds), and this is that response. 320 Before { at_ms: f64 }, 321} 322 323/// What came back. 324#[derive(Debug)] 325pub enum Outcome { 326 Answered { 327 /// One answer per question, in the order they were asked. 328 judged: Vec<Judged>, 329 sent: Sent, 330 /// The response body exactly as it arrived. 331 body: String, 332 request_id: Option<String>, 333 attempts: u32, 334 usage: Usage, 335 /// The whole round trip, retries and waits included. 336 took: Duration, 337 }, 338 Failed { 339 error: String, 340 request_id: Option<String>, 341 took: Duration, 342 /// What a budget should count the failure as: nothing when it 343 /// certainly cost nothing, the call's worst case otherwise. 344 dollars: f64, 345 }, 346} 347 348impl Outcome { 349 /// What the call cost, or may have. 350 pub fn dollars(&self) -> f64 { 351 match self { 352 Outcome::Answered { sent: Sent::Before { .. }, .. } => 0.0, 353 Outcome::Answered { usage, .. } => usage.dollars(), 354 Outcome::Failed { dollars, .. } => *dollars, 355 } 356 } 357} 358 359impl Prepared { 360 /// The state and the questions, for a client that takes them and not the 361 /// body: `jev-http`'s, which the eval asks through. 362 pub fn asking(&self) -> (&Json, &Questions) { 363 (&self.state, &self.questions) 364 } 365} 366 367/// The answers in a response, one per question of `prepared`, in order. 368pub fn judged(prepared: &Prepared, response: &Response) -> Vec<Judged> { 369 prepared 370 .parts 371 .iter() 372 .map(|part| match part.asked { 373 Asked::Noul(key) => Judged::Noul(response.get(key).noul), 374 Asked::Choice(key) => Judged::Choice(response.get(key).clone()), 375 Asked::Score(key) => Judged::Score(response.get(key).clone()), 376 }) 377 .collect() 378} 379 380/// Sends a prepared call. `runtime` is the clock the round trip is timed on. 381pub async fn send<T: Transport, R: Runtime, O: Observer>( 382 client: &Client<T, R, O>, 383 runtime: &impl Runtime, 384 prepared: &Prepared, 385) -> Outcome { 386 let started = runtime.now(); 387 let asked = client.ask(&prepared.state, &prepared.questions).await; 388 let took = runtime.now() - started; 389 match asked { 390 Ok(answered) => Outcome::Answered { 391 judged: judged(prepared, &answered.response), 392 sent: Sent::Now, 393 body: String::from_utf8_lossy(&answered.body).into_owned(), 394 request_id: answered.request_id, 395 attempts: answered.attempts, 396 usage: answered.response.usage(), 397 took, 398 }, 399 Err(error) => Outcome::Failed { 400 request_id: error.request_id().map(str::to_owned), 401 dollars: if error.nothing_billed() { 0.0 } else { prepared.worst_case_dollars }, 402 error: error.to_string(), 403 took, 404 }, 405 } 406} 407 408/// A response body as it arrived for a call, with what the call recorded 409/// beside it. 410pub struct Kept<'a> { 411 pub body: &'a str, 412 pub request_id: Option<String>, 413 pub attempts: u32, 414 pub took: Duration, 415 pub sent: Sent, 416} 417 418/// Reads a kept response as the answer to `prepared`: the same check of the 419/// body against the questions that `send` makes, with nothing sent. A body 420/// that does not answer these questions is a failure, not an answer. 421pub fn read(model: &ModelId, prepared: &Prepared, kept: Kept<'_>) -> Outcome { 422 match Response::parse(model, &prepared.questions, kept.body.as_bytes()) { 423 Ok(response) => Outcome::Answered { 424 judged: judged(prepared, &response), 425 sent: kept.sent, 426 body: kept.body.to_owned(), 427 request_id: kept.request_id, 428 attempts: kept.attempts, 429 usage: response.usage(), 430 took: kept.took, 431 }, 432 Err(error) => Outcome::Failed { 433 error: format!("the kept response does not answer this request: {error}"), 434 request_id: kept.request_id, 435 took: kept.took, 436 dollars: 0.0, 437 }, 438 } 439} 440 441#[cfg(test)] 442mod tests;