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#[derive(Debug, Clone, Deserialize, Serialize)]
12#[serde(deny_unknown_fields)]
13pub struct JudgeAssertion {
14 #[serde(default)]
16 pub llm: Option<String>,
17 #[serde(default = "default_threshold")]
19 pub pass_threshold: f32,
20 #[serde(default)]
22 pub criteria: Vec<JudgeCriterion>,
23}
24
25#[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#[derive(Debug, Clone, Serialize, Deserialize)]
40#[serde(deny_unknown_fields)]
41pub struct JudgeConfig {
42 #[serde(default = "default_true")]
44 pub enabled: bool,
45 #[serde(default)]
47 pub llm: Option<String>,
48 #[serde(default)]
50 pub default_criteria: Vec<JudgeCriterion>,
51 #[serde(default = "default_threshold")]
53 pub pass_threshold: f32,
54 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct JudgeResult {
74 pub criteria_scores: Vec<CriterionScore>,
76 pub overall_score: f32,
78 pub overall_feedback: String,
80 pub passed: bool,
82 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub raw_response: Option<String>,
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct CriterionScore {
90 pub name: String,
92 pub score: f32,
94 #[serde(default)]
96 pub explanation: String,
97}
98
99pub struct JudgeInput<'a> {
101 pub response: &'a str,
103 pub user_input: Option<&'a str>,
105 pub scenario_id: Option<&'a str>,
107 pub language: Option<&'a str>,
109}
110
111pub struct JudgeResolver {
113 registry: Arc<LLMRegistry>,
115 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
139pub struct LLMJudge {
141 llm: Arc<dyn LLMProvider>,
143 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}