1use 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#[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 #[error("criteria not satisfied: {criteria}\n{reasoning}")]
85 Failed { criteria: String, reasoning: String },
86}
87
88pub 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
128pub 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 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
173pub 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 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 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
266pub 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 #[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}