Skip to main content

wabot_testing/
llm_judge.rs

1//! Grade a conversation with a real LLM. Port of
2//! `wabot-ts/src/testing/LlmJudge.ts`.
3//!
4//! ```ignore
5//! let judge = LlmJudge::new(adapter, vec![ModelRef::new("openai", "gpt-5")]);
6//! judge
7//!     .assert(harness.history(), "the bot gave the tracking number and stayed polite")
8//!     .await?;
9//! ```
10//!
11//! ## What this is for, and what it isn't
12//!
13//! Every other harness here asserts something exact — this reply,
14//! that tool call. Some properties aren't exact: *did it stay on
15//! topic*, *did it refuse without being rude*, *did it avoid
16//! promising a refund*. Those are the ones that regress silently,
17//! because nobody writes a brittle string match for them.
18//!
19//! It is **not** a unit test. It calls a paid API, it is slow, and
20//! the same input can grade differently twice. Keep judged tests few,
21//! behind an env var, and out of the loop developers run on every
22//! save.
23//!
24//! ## The verdict comes back as a tool call, not as prose
25//!
26//! Asking a model for "PASS or FAIL" means parsing free-form text —
27//! and a model that answers "PASS (with reservations)" quietly
28//! becomes a failure, or worse, a pass. A forced tool call gives a
29//! typed `pass: bool` the provider itself validated, and it works the
30//! same across all six adapters because tool calling is the one thing
31//! they all speak.
32//!
33//! A judge that answers with text anyway is an **error**, not a
34//! failure: those are different things, and reporting "the criteria
35//! were not met" when the judge never rendered a verdict would be a
36//! lie about your code.
37
38use std::sync::Arc;
39
40use serde::Deserialize;
41use thiserror::Error;
42use wabot_core::validation::Validate;
43use wabot_feature_chat_bot::{
44    ChatAdapter, ChatAdapterRequest, ChatItem, ChatMessage, ModelRef, ToolDefinition, ToolParameter,
45};
46use wabot_feature_tool::schema_from_model_info;
47
48/// The verdict's shape — the same `#[derive(Validate)]` path every
49/// other tool schema comes from, so the judge is described to the
50/// provider exactly as an application's tools are.
51#[derive(Debug, Deserialize, wabot_macros::Validate)]
52struct VerdictArgs {
53    #[description("true if the transcript satisfies the criteria")]
54    pass: bool,
55    #[description("short explanation of the verdict")]
56    reasoning: String,
57}
58
59const VERDICT_TOOL: &str = "submitVerdict";
60
61const JUDGE_SYSTEM_PROMPT: &str = "\
62You are a strict QA judge for chatbot conversations.
63You will receive a chat transcript and evaluation criteria.
64Evaluate whether the transcript satisfies ALL the criteria.
65You MUST report your verdict by calling the submitVerdict tool exactly once.
66Never reply with plain text.";
67
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct Verdict {
70    pub pass: bool,
71    pub reasoning: String,
72}
73
74#[derive(Debug, Error)]
75pub enum JudgeError {
76    #[error("the judge model did not call {VERDICT_TOOL}. It said: {said}")]
77    NoVerdict { said: String },
78    #[error("the judge called {VERDICT_TOOL} with arguments that don't match: {detail}")]
79    BadVerdict { detail: String },
80    #[error("the judge's provider failed: {0}")]
81    Adapter(String),
82    /// The criteria were not met — the judge worked, your code
83    /// didn't. Carries the reasoning so a failing test says *why*.
84    #[error("criteria not satisfied: {criteria}\n{reasoning}")]
85    Failed { criteria: String, reasoning: String },
86}
87
88/// What to grade: the items a harness recorded, or text you rendered
89/// yourself.
90pub enum Transcript {
91    Items(Vec<ChatItem>),
92    Text(String),
93}
94
95impl From<Vec<ChatItem>> for Transcript {
96    fn from(items: Vec<ChatItem>) -> Self {
97        Self::Items(items)
98    }
99}
100
101impl From<&[ChatItem]> for Transcript {
102    fn from(items: &[ChatItem]) -> Self {
103        Self::Items(items.to_vec())
104    }
105}
106
107impl From<String> for Transcript {
108    fn from(text: String) -> Self {
109        Self::Text(text)
110    }
111}
112
113impl From<&str> for Transcript {
114    fn from(text: &str) -> Self {
115        Self::Text(text.to_string())
116    }
117}
118
119impl Transcript {
120    fn render(self) -> String {
121        match self {
122            Transcript::Text(text) => text,
123            Transcript::Items(items) => render_transcript(&items),
124        }
125    }
126}
127
128/// One line per item, in a shape a model reads without instructions.
129///
130/// Tool calls are included with their arguments and result: half of
131/// what is worth judging is whether the bot *looked something up*
132/// before answering, and a transcript of prose alone can't show that.
133pub fn render_transcript(items: &[ChatItem]) -> String {
134    items
135        .iter()
136        .map(|item| match item {
137            ChatItem::HumanMessage { human_message } => {
138                format!("HUMAN: {}", describe_message(human_message))
139            }
140            ChatItem::BotMessage { bot_message } => {
141                format!("BOT: {}", describe_message(bot_message))
142            }
143            ChatItem::FunctionCall { function_call } => format!(
144                "TOOL CALL: {}({}) -> {}",
145                function_call.name,
146                function_call.arguments.as_deref().unwrap_or("{}"),
147                function_call.result.as_deref().unwrap_or("(no result)")
148            ),
149        })
150        .collect::<Vec<_>>()
151        .join("\n")
152}
153
154fn describe_message(message: &ChatMessage) -> String {
155    let mut parts = Vec::new();
156    if let Some(text) = message.text.as_deref() {
157        if !text.is_empty() {
158            parts.push(text.to_string());
159        }
160    }
161    // Attachments are named but not sent: the judge grades the
162    // conversation, and shipping image bytes to it would cost tokens
163    // for something it was not asked about.
164    if let Some(images) = message.images.as_ref().filter(|i| !i.is_empty()) {
165        parts.push(format!("[{} image(s)]", images.len()));
166    }
167    if let Some(documents) = message.documents.as_ref().filter(|d| !d.is_empty()) {
168        parts.push(format!("[{} document(s)]", documents.len()));
169    }
170    parts.join(" ")
171}
172
173/// Grades a conversation with a real model.
174pub struct LlmJudge {
175    adapter: Arc<dyn ChatAdapter>,
176    models: Vec<ModelRef>,
177}
178
179impl LlmJudge {
180    pub fn new(adapter: Arc<dyn ChatAdapter>, models: Vec<ModelRef>) -> Self {
181        Self { adapter, models }
182    }
183
184    /// The verdict, whatever it is.
185    pub async fn evaluate(
186        &self,
187        transcript: impl Into<Transcript>,
188        criteria: &str,
189    ) -> Result<Verdict, JudgeError> {
190        let transcript = transcript.into().render();
191
192        let response = self
193            .adapter
194            .next_items(ChatAdapterRequest {
195                models: self.models.clone(),
196                system_prompt: JUDGE_SYSTEM_PROMPT.to_string(),
197                tools: vec![verdict_tool()],
198                prev_items: vec![ChatItem::HumanMessage {
199                    human_message: ChatMessage::text(format!(
200                        "## Criteria\n{criteria}\n\n## Transcript\n{transcript}\n\n\
201                         Evaluate now and call {VERDICT_TOOL}."
202                    )),
203                }],
204            })
205            .await
206            .map_err(|error| JudgeError::Adapter(error.to_string()))?;
207
208        let call = response.next_items.iter().find_map(|item| match item {
209            ChatItem::FunctionCall { function_call } if function_call.name == VERDICT_TOOL => {
210                Some(function_call)
211            }
212            _ => None,
213        });
214
215        let Some(call) = call else {
216            let said: Vec<String> = response
217                .next_items
218                .iter()
219                .filter_map(|item| match item {
220                    ChatItem::BotMessage { bot_message } => bot_message.text.clone(),
221                    _ => None,
222                })
223                .collect();
224            return Err(JudgeError::NoVerdict {
225                said: if said.is_empty() {
226                    "(nothing)".to_string()
227                } else {
228                    said.join(" | ")
229                },
230            });
231        };
232
233        let arguments = call.arguments.as_deref().unwrap_or("{}");
234        let args: VerdictArgs =
235            serde_json::from_str(arguments).map_err(|error| JudgeError::BadVerdict {
236                detail: format!("{error} — got {arguments}"),
237            })?;
238
239        Ok(Verdict {
240            pass: args.pass,
241            reasoning: args.reasoning,
242        })
243    }
244
245    /// Like [`evaluate`], but a failing verdict is an `Err` carrying
246    /// the judge's reasoning — which is what makes a failing test
247    /// readable.
248    ///
249    /// [`evaluate`]: Self::evaluate
250    pub async fn assert(
251        &self,
252        transcript: impl Into<Transcript>,
253        criteria: &str,
254    ) -> Result<Verdict, JudgeError> {
255        let verdict = self.evaluate(transcript, criteria).await?;
256        if !verdict.pass {
257            return Err(JudgeError::Failed {
258                criteria: criteria.to_string(),
259                reasoning: verdict.reasoning,
260            });
261        }
262        Ok(verdict)
263    }
264}
265
266/// The tool the judge must call.
267///
268/// Built from the same `schema_from_model_info` path an application's
269/// tools go through, so the judge is described to the provider
270/// exactly as they are — no second way of declaring a tool that could
271/// behave differently.
272///
273/// There is no body: the *call* is the answer, and it is read out of
274/// the response rather than dispatched.
275pub fn verdict_tool() -> ToolDefinition {
276    let schema = schema_from_model_info(
277        VERDICT_TOOL,
278        "Submit your evaluation verdict. You MUST always call this tool exactly once; \
279         never answer with plain text.",
280        "english",
281        <VerdictArgs as Validate>::model_info(),
282    );
283    ToolDefinition {
284        name: schema.name,
285        description: schema.description,
286        language: schema.language,
287        parameters: schema
288            .parameters
289            .into_iter()
290            .map(|parameter| ToolParameter {
291                name: parameter.name,
292                r#type: parameter.r#type,
293                description: parameter.description,
294                required: parameter.required,
295            })
296            .collect(),
297    }
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303    use wabot_feature_chat_bot::FunctionCall;
304
305    fn human(text: &str) -> ChatItem {
306        ChatItem::HumanMessage {
307            human_message: ChatMessage::text(text),
308        }
309    }
310
311    fn bot(text: &str) -> ChatItem {
312        ChatItem::BotMessage {
313            bot_message: ChatMessage::text(text),
314        }
315    }
316
317    #[test]
318    fn a_transcript_shows_prose_and_tool_calls() {
319        let rendered = render_transcript(&[
320            human("where is my order?"),
321            ChatItem::FunctionCall {
322                function_call: FunctionCall {
323                    id: "1".into(),
324                    name: "read_order".into(),
325                    arguments: Some("{\"id\":7}".into()),
326                    result: Some("{\"status\":\"shipped\"}".into()),
327                    signature: None,
328                },
329            },
330            bot("It shipped yesterday."),
331        ]);
332
333        assert_eq!(
334            rendered,
335            "HUMAN: where is my order?\n\
336             TOOL CALL: read_order({\"id\":7}) -> {\"status\":\"shipped\"}\n\
337             BOT: It shipped yesterday."
338        );
339    }
340
341    /// A call with no arguments recorded still renders, because "the
342    /// bot called this and got nothing back" is exactly the kind of
343    /// thing a judge is asked about.
344    #[test]
345    fn a_call_with_nothing_recorded_still_renders() {
346        let rendered = render_transcript(&[ChatItem::FunctionCall {
347            function_call: FunctionCall {
348                id: "1".into(),
349                name: "lookup".into(),
350                arguments: None,
351                result: None,
352                signature: None,
353            },
354        }]);
355        assert_eq!(rendered, "TOOL CALL: lookup({}) -> (no result)");
356    }
357
358    #[test]
359    fn the_verdict_tool_asks_for_a_boolean_and_a_reason() {
360        let schema = verdict_tool();
361        assert_eq!(schema.name, "submitVerdict");
362
363        let pass = schema
364            .parameters
365            .iter()
366            .find(|parameter| parameter.name == "pass")
367            .expect("pass");
368        assert_eq!(pass.r#type, "boolean", "typed, not parsed out of prose");
369        assert!(pass.required);
370
371        assert!(schema
372            .parameters
373            .iter()
374            .any(|parameter| parameter.name == "reasoning"));
375    }
376}