Skip to main content

ai_agents_eval/
judge.rs

1use std::sync::Arc;
2
3use ai_agents_core::{ChatMessage, LLMProvider};
4use ai_agents_llm::LLMRegistry;
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7
8use crate::{EvalError, Result};
9
10/// Semantic judge assertion declared in an eval suite.
11#[derive(Debug, Clone, Deserialize, Serialize)]
12#[serde(deny_unknown_fields)]
13pub struct JudgeAssertion {
14    /// Optional LLM alias or provider used for judge calls.
15    #[serde(default)]
16    pub llm: Option<String>,
17    /// Minimum overall score required to pass.
18    #[serde(default = "default_threshold")]
19    pub pass_threshold: f32,
20    /// Criteria used by the judge prompt.
21    #[serde(default)]
22    pub criteria: Vec<JudgeCriterion>,
23}
24
25/// Text or weighted object form for judge criteria.
26#[derive(Debug, Clone, Deserialize, Serialize)]
27#[serde(untagged, deny_unknown_fields)]
28pub enum JudgeCriterion {
29    Text(String),
30    Object {
31        name: String,
32        description: String,
33        #[serde(default = "default_weight")]
34        weight: f32,
35    },
36}
37
38/// Default behavior for LLM judge evaluation.
39#[derive(Debug, Clone, Serialize, Deserialize)]
40#[serde(deny_unknown_fields)]
41pub struct JudgeConfig {
42    /// Whether this feature is enabled.
43    #[serde(default = "default_true")]
44    pub enabled: bool,
45    /// Optional LLM alias or provider used for judge calls.
46    #[serde(default)]
47    pub llm: Option<String>,
48    /// Criteria used when assertions omit criteria.
49    #[serde(default)]
50    pub default_criteria: Vec<JudgeCriterion>,
51    /// Minimum overall score required to pass.
52    #[serde(default = "default_threshold")]
53    pub pass_threshold: f32,
54    /// Whether judge responses must be strict JSON.
55    #[serde(default = "default_true")]
56    pub require_json: bool,
57}
58
59impl Default for JudgeConfig {
60    fn default() -> Self {
61        Self {
62            enabled: true,
63            llm: None,
64            default_criteria: Vec::new(),
65            pass_threshold: default_threshold(),
66            require_json: true,
67        }
68    }
69}
70
71/// Parsed JSON result returned by an LLM judge.
72#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct JudgeResult {
74    /// Scores for individual criteria.
75    pub criteria_scores: Vec<CriterionScore>,
76    /// Aggregated score used for pass or fail.
77    pub overall_score: f32,
78    /// Brief feedback returned by the judge.
79    pub overall_feedback: String,
80    /// Passed count or boolean result.
81    pub passed: bool,
82    /// Optional raw judge response for debugging.
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub raw_response: Option<String>,
85}
86
87/// Score for one criterion inside a judge result.
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct CriterionScore {
90    /// Human-readable name or criterion name.
91    pub name: String,
92    /// Numeric score assigned by the judge.
93    pub score: f32,
94    /// Brief explanation for the score.
95    #[serde(default)]
96    pub explanation: String,
97}
98
99/// Context passed to the judge prompt for one evaluation.
100pub struct JudgeInput<'a> {
101    /// Assistant response text or redacted output value.
102    pub response: &'a str,
103    /// Optional user input for judge prompt context.
104    pub user_input: Option<&'a str>,
105    /// Optional scenario ID for judge prompt context.
106    pub scenario_id: Option<&'a str>,
107    /// Optional language label for filtering, metrics, and judge context.
108    pub language: Option<&'a str>,
109}
110
111/// Resolves judge LLM aliases from the runtime registry.
112pub struct JudgeResolver {
113    /// Runtime LLM registry used to resolve aliases.
114    registry: Arc<LLMRegistry>,
115    /// Configuration used by this component.
116    config: JudgeConfig,
117}
118
119impl JudgeResolver {
120    pub fn new(registry: Arc<LLMRegistry>, config: JudgeConfig) -> Self {
121        Self { registry, config }
122    }
123
124    pub fn resolve(&self, alias: Option<&str>) -> Result<LLMJudge> {
125        let llm = if let Some(alias) = alias {
126            self.registry
127                .get(alias)
128                .map_err(|error| EvalError::Judge(error.to_string()))?
129        } else {
130            self.registry
131                .router()
132                .or_else(|_| self.registry.default())
133                .map_err(|error| EvalError::Judge(error.to_string()))?
134        };
135        Ok(LLMJudge::new(llm, self.config.clone()))
136    }
137}
138
139/// Wrapper that asks an LLM to score semantic response quality.
140pub struct LLMJudge {
141    /// Optional LLM alias or provider used for judge calls.
142    llm: Arc<dyn LLMProvider>,
143    /// Configuration used by this component.
144    config: JudgeConfig,
145}
146
147impl LLMJudge {
148    pub fn new(llm: Arc<dyn LLMProvider>, config: JudgeConfig) -> Self {
149        Self { llm, config }
150    }
151
152    pub async fn evaluate(
153        &self,
154        response: &str,
155        assertion: &JudgeAssertion,
156    ) -> Result<JudgeResult> {
157        self.evaluate_input(
158            JudgeInput {
159                response,
160                user_input: None,
161                scenario_id: None,
162                language: None,
163            },
164            assertion,
165        )
166        .await
167    }
168
169    pub async fn evaluate_input(
170        &self,
171        input: JudgeInput<'_>,
172        assertion: &JudgeAssertion,
173    ) -> Result<JudgeResult> {
174        let criteria = if assertion.criteria.is_empty() {
175            self.config.default_criteria.clone()
176        } else {
177            assertion.criteria.clone()
178        };
179        if criteria.is_empty() {
180            return Err(EvalError::Judge("judge assertion has no criteria".into()));
181        }
182        let threshold = assertion.pass_threshold;
183        let prompt = build_prompt(input, &criteria, threshold);
184        let llm_response = self
185            .llm
186            .complete(&[ChatMessage::user(&prompt)], None)
187            .await
188            .map_err(|error| EvalError::Judge(error.to_string()))?;
189        let value = extract_json(&llm_response.content)
190            .ok_or_else(|| EvalError::Judge("judge did not return JSON".into()))?;
191        let mut result: JudgeResult = serde_json::from_value(value)
192            .map_err(|error| EvalError::Judge(format!("invalid judge JSON: {}", error)))?;
193        result.passed = result.overall_score >= threshold;
194        if !self.config.require_json {
195            result.raw_response = Some(llm_response.content);
196        }
197        Ok(result)
198    }
199}
200
201fn build_prompt(input: JudgeInput<'_>, criteria: &[JudgeCriterion], threshold: f32) -> String {
202    let criteria_text = criteria
203        .iter()
204        .enumerate()
205        .map(|(idx, criterion)| match criterion {
206            JudgeCriterion::Text(text) => format!("{}. {} (weight 1.0)", idx + 1, text),
207            JudgeCriterion::Object {
208                name,
209                description,
210                weight,
211            } => {
212                format!("{}. {}: {} (weight {})", idx + 1, name, description, weight)
213            }
214        })
215        .collect::<Vec<_>>()
216        .join("\n");
217    let user_input = input.user_input.unwrap_or("");
218    let scenario_id = input.scenario_id.unwrap_or("");
219    let language = input.language.unwrap_or("");
220    let response = input.response;
221    format!(
222        r#"Evaluate the assistant response against the criteria.
223Evaluate semantic meaning across languages. Do not require exact wording unless a criterion says so.
224Return strict JSON only with this shape:
225{{"criteria_scores":[{{"name":"criterion","score":0.0,"explanation":"brief"}}],"overall_score":0.0,"overall_feedback":"brief","passed":false}}
226Pass threshold: {threshold}
227
228Scenario ID: {scenario_id}
229Language: {language}
230User input: {user_input}
231
232Criteria:
233{criteria_text}
234
235Assistant response:
236{response}"#
237    )
238}
239
240fn extract_json(text: &str) -> Option<Value> {
241    if let Ok(value) = serde_json::from_str(text.trim()) {
242        return Some(value);
243    }
244    let start = text.find('{')?;
245    let end = text.rfind('}')?;
246    serde_json::from_str(&text[start..=end]).ok()
247}
248
249fn default_threshold() -> f32 {
250    0.75
251}
252
253fn default_weight() -> f32 {
254    1.0
255}
256
257fn default_true() -> bool {
258    true
259}