use std::sync::Arc;
use async_trait::async_trait;
use klieo_core::llm::ChatRequest;
use klieo_core::response::{parse_structured, KlieoResponse};
use klieo_core::{Error as CoreError, LlmClient, LlmError};
use klieo_runlog::types::RunLog;
use klieo_spec::Critique;
use serde::Deserialize;
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct JudgeCriterion {
pub name: String,
pub description: String,
}
impl JudgeCriterion {
pub fn new(name: impl Into<String>, description: impl Into<String>) -> Self {
Self {
name: name.into(),
description: description.into(),
}
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct CriterionVerdict {
pub criterion: String,
pub critique: Critique,
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct JudgeVerdict {
pub per_criterion: Vec<CriterionVerdict>,
}
impl JudgeVerdict {
pub fn all_pass(&self) -> bool {
self.per_criterion.iter().all(|v| v.critique.pass)
}
pub fn verdict_for(&self, criterion: &str) -> Option<&CriterionVerdict> {
self.per_criterion.iter().find(|v| v.criterion == criterion)
}
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum JudgeError {
#[error("llm-judge request failed: {0}")]
Llm(#[source] LlmError),
#[error("llm-judge response was not valid: {0}")]
Parse(#[source] CoreError),
#[error("llm-judge config error: {0}")]
Config(String),
#[error("llm-judge response missing a verdict for criterion {0:?}")]
MissingCriterion(String),
}
#[async_trait]
pub trait Judge: Send + Sync {
async fn judge(
&self,
run_log: &RunLog,
criteria: &[JudgeCriterion],
) -> Result<JudgeVerdict, JudgeError>;
}
pub struct LlmJudge {
llm: Arc<dyn LlmClient>,
}
impl LlmJudge {
pub fn new(llm: Arc<dyn LlmClient>) -> Self {
Self { llm }
}
}
#[async_trait]
impl Judge for LlmJudge {
async fn judge(
&self,
run_log: &RunLog,
criteria: &[JudgeCriterion],
) -> Result<JudgeVerdict, JudgeError> {
if criteria.is_empty() {
return Err(JudgeError::Config(
"at least one criterion is required".into(),
));
}
let request = build_request(run_log, criteria);
let response = self.llm.complete(request).await.map_err(JudgeError::Llm)?;
let raw: RawJudgeResponse =
parse_structured(&response.message.content).map_err(JudgeError::Parse)?;
to_verdict(raw, criteria)
}
}
const JUDGE_SYSTEM_PROMPT: &str = "You are an evaluation judge for an AI agent's \
recorded run. You will be given the run's steps (tool calls and LLM calls, \
in order) and a list of criteria. For EVERY criterion, decide pass or fail \
and give a one- or two-sentence rationale grounded in the run's actual \
steps. Respond with ONLY JSON matching this shape: \
{\"verdicts\":[{\"criterion\":\"<name>\",\"pass\":<bool>,\"rationale\":\"<text>\"}]}";
fn build_request(run_log: &RunLog, criteria: &[JudgeCriterion]) -> ChatRequest {
let steps_json =
serde_json::to_string_pretty(&run_log.steps).unwrap_or_else(|_| "[]".to_string());
let criteria_list = criteria
.iter()
.map(|c| format!("- {}: {}", c.name, c.description))
.collect::<Vec<_>>()
.join("\n");
let user_prompt = format!(
"Run steps (agent {:?}):\n{steps_json}\n\nCriteria:\n{criteria_list}",
run_log.agent
);
ChatRequest::builder()
.system(JUDGE_SYSTEM_PROMPT)
.user(user_prompt)
.build()
}
#[derive(Debug, Deserialize)]
struct RawCriterionVerdict {
criterion: String,
pass: bool,
rationale: String,
}
#[derive(Debug, Deserialize)]
struct RawJudgeResponse {
verdicts: Vec<RawCriterionVerdict>,
}
impl KlieoResponse for RawJudgeResponse {
fn json_schema() -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"verdicts": {
"type": "array",
"items": {
"type": "object",
"properties": {
"criterion": {"type": "string"},
"pass": {"type": "boolean"},
"rationale": {"type": "string"},
},
"required": ["criterion", "pass", "rationale"],
},
},
},
"required": ["verdicts"],
})
}
}
fn to_verdict(
raw: RawJudgeResponse,
criteria: &[JudgeCriterion],
) -> Result<JudgeVerdict, JudgeError> {
let per_criterion = criteria
.iter()
.map(|c| {
raw.verdicts
.iter()
.find(|v| v.criterion == c.name)
.map(|v| CriterionVerdict {
criterion: c.name.clone(),
critique: if v.pass {
Critique::pass(v.rationale.clone())
} else {
Critique::fail(v.rationale.clone())
},
})
.ok_or_else(|| JudgeError::MissingCriterion(c.name.clone()))
})
.collect::<Result<Vec<_>, _>>()?;
Ok(JudgeVerdict { per_criterion })
}
#[cfg(test)]
mod tests {
use super::*;
fn crit(name: &str) -> JudgeCriterion {
JudgeCriterion::new(name, "desc")
}
#[test]
fn all_pass_true_on_empty_verdict() {
let v = JudgeVerdict {
per_criterion: vec![],
};
assert!(v.all_pass());
}
#[test]
fn all_pass_false_when_any_criterion_fails() {
let v = JudgeVerdict {
per_criterion: vec![
CriterionVerdict {
criterion: "a".into(),
critique: Critique::pass("ok"),
},
CriterionVerdict {
criterion: "b".into(),
critique: Critique::fail("nope"),
},
],
};
assert!(!v.all_pass());
}
#[test]
fn to_verdict_maps_by_name_regardless_of_order() {
let raw = RawJudgeResponse {
verdicts: vec![
RawCriterionVerdict {
criterion: "b".into(),
pass: false,
rationale: "bad".into(),
},
RawCriterionVerdict {
criterion: "a".into(),
pass: true,
rationale: "good".into(),
},
],
};
let verdict = to_verdict(raw, &[crit("a"), crit("b")]).unwrap();
assert_eq!(verdict.verdict_for("a").unwrap().critique.feedback, "good");
assert_eq!(verdict.verdict_for("b").unwrap().critique.feedback, "bad");
}
#[test]
fn to_verdict_errors_on_missing_criterion() {
let raw = RawJudgeResponse { verdicts: vec![] };
let err = to_verdict(raw, &[crit("a")]).unwrap_err();
assert!(matches!(err, JudgeError::MissingCriterion(name) if name == "a"));
}
}