1use crate::types::message::{Content, CoreMessage, Role};
20
21#[derive(Debug, Clone)]
27pub struct Criterion {
28 pub text: String,
29 pub required: bool,
31 pub weight: f32,
33}
34
35impl Criterion {
36 pub fn required(text: impl Into<String>) -> Self {
37 Self {
38 text: text.into(),
39 required: true,
40 weight: 1.0,
41 }
42 }
43
44 pub fn optional(text: impl Into<String>) -> Self {
45 Self {
46 text: text.into(),
47 required: false,
48 weight: 1.0,
49 }
50 }
51
52 pub fn with_weight(mut self, w: f32) -> Self {
53 self.weight = w;
54 self
55 }
56}
57
58impl From<String> for Criterion {
59 fn from(s: String) -> Self {
60 Self::required(s)
61 }
62}
63
64impl From<&str> for Criterion {
65 fn from(s: &str) -> Self {
66 Self::required(s)
67 }
68}
69
70#[derive(Debug, Clone)]
76pub struct CriterionResult {
77 pub criterion: String,
78 pub passed: bool,
79 pub score: f32,
81 pub feedback: String,
82}
83
84#[derive(Debug, Clone)]
86pub struct SkillCandidate {
87 pub name: String,
88 pub description: String,
89 pub when_to_use: Option<String>,
90 pub content: String,
92}
93
94#[derive(Debug, Clone)]
96pub struct EvalResult {
97 pub passed: bool,
98 pub overall_score: f32,
100 pub feedback: String,
102 pub details: Vec<CriterionResult>,
104 pub skill_candidate: Option<SkillCandidate>,
105}
106
107pub fn build_eval_messages(
115 goal: &str,
116 criteria: &[Criterion],
117 result: &str,
118 attempt: u32,
119 extract_skill_on_pass: bool,
120) -> Vec<CoreMessage> {
121 let criteria_text = if criteria.is_empty() {
122 "No explicit criteria — use general quality judgement.".to_string()
123 } else {
124 criteria
125 .iter()
126 .enumerate()
127 .map(|(i, c)| {
128 let tag = if c.required {
129 "[required]"
130 } else {
131 "[optional]"
132 };
133 let weight = if (c.weight - 1.0).abs() > 0.01 {
134 format!(" weight={:.1}", c.weight)
135 } else {
136 String::new()
137 };
138 format!("{}. {}{}{}", i + 1, tag, weight, c.text)
139 })
140 .collect::<Vec<_>>()
141 .join("\n")
142 };
143
144 let details_schema = r#"[{"criterion":"...","passed":bool,"score":0.0-1.0,"feedback":"..."}]"#;
145
146 let skill_instruction = if extract_skill_on_pass {
147 "\nIf passed=true and the approach is reusable, add a \"skill\" field:\
148\n{\"name\":\"snake_case\",\"description\":\"one sentence\",\"when_to_use\":\"optional hint\",\"content\":\"markdown body (no frontmatter)\"}"
149 } else {
150 ""
151 };
152
153 let system = CoreMessage {
154 role: Role::System,
155 content: Content::Text(format!(
156 "You are an impartial evaluator. Assess whether the agent's output meets the goal and criteria.\n\
157 [required] criteria must ALL pass for overall passed=true.\n\
158 [optional] criteria contribute to overall_score but do not block passing.\n\
159 Respond with JSON only:\n\
160 {{\"passed\":bool,\"overall_score\":0.0-1.0,\"feedback\":\"concise summary\",\
161 \"details\":{details_schema}{skill_instruction}}}"
162 )),
163 tool_calls: vec![],
164 };
165
166 let user = CoreMessage {
167 role: Role::User,
168 content: Content::Text(format!(
169 "## Goal\n{goal}\n\n## Criteria\n{criteria_text}\n\n## Agent Output (attempt {attempt})\n{result}"
170 )),
171 tool_calls: vec![],
172 };
173
174 vec![system, user]
175}
176
177pub fn verdict_output_schema(extract_skill_on_pass: bool) -> serde_json::Value {
185 let mut properties = serde_json::json!({
186 "passed": { "type": "boolean", "description": "true iff all [required] criteria pass" },
187 "overall_score": { "type": "number", "minimum": 0.0, "maximum": 1.0 },
188 "feedback": { "type": "string", "description": "concise summary; on fail, what to fix next attempt" },
189 "details": {
190 "type": "array",
191 "items": {
192 "type": "object",
193 "required": ["criterion", "passed", "score", "feedback"],
194 "properties": {
195 "criterion": { "type": "string" },
196 "passed": { "type": "boolean" },
197 "score": { "type": "number", "minimum": 0.0, "maximum": 1.0 },
198 "feedback": { "type": "string" }
199 }
200 }
201 }
202 });
203 if extract_skill_on_pass {
204 properties["skill"] = serde_json::json!({
205 "type": "object",
206 "description": "optional reusable skill distilled from a passing run",
207 "required": ["name", "description", "content"],
208 "properties": {
209 "name": { "type": "string", "description": "snake_case" },
210 "description": { "type": "string" },
211 "when_to_use": { "type": "string" },
212 "content": { "type": "string", "description": "markdown body, no frontmatter" }
213 }
214 });
215 }
216 serde_json::json!({
217 "type": "object",
218 "required": ["passed", "overall_score", "feedback"],
219 "properties": properties
220 })
221}
222
223pub fn parse_verdict(content: &str) -> EvalResult {
230 let json_str = extract_json(content);
231 let v: serde_json::Value = serde_json::from_str(json_str).unwrap_or(serde_json::Value::Null);
232
233 let passed = v.get("passed").and_then(|x| x.as_bool()).unwrap_or(false);
234 let overall_score = v
235 .get("overall_score")
236 .and_then(|x| x.as_f64())
237 .map(|f| f as f32)
238 .unwrap_or(if passed { 1.0 } else { 0.0 });
239 let feedback = v
240 .get("feedback")
241 .and_then(|x| x.as_str())
242 .unwrap_or("No feedback provided.")
243 .to_string();
244
245 let details = v
246 .get("details")
247 .and_then(|d| d.as_array())
248 .map(|arr| {
249 arr.iter()
250 .filter_map(|item| {
251 let criterion = item.get("criterion")?.as_str()?.to_string();
252 let item_passed = item
253 .get("passed")
254 .and_then(|x| x.as_bool())
255 .unwrap_or(false);
256 let score = item
257 .get("score")
258 .and_then(|x| x.as_f64())
259 .map(|f| f as f32)
260 .unwrap_or(if item_passed { 1.0 } else { 0.0 });
261 let item_feedback = item
262 .get("feedback")
263 .and_then(|x| x.as_str())
264 .unwrap_or("")
265 .to_string();
266 Some(CriterionResult {
267 criterion,
268 passed: item_passed,
269 score,
270 feedback: item_feedback,
271 })
272 })
273 .collect()
274 })
275 .unwrap_or_default();
276
277 let skill_candidate = v.get("skill").and_then(|s| {
278 let name = s.get("name")?.as_str()?.to_string();
279 let description = s.get("description")?.as_str()?.to_string();
280 let content = s.get("content")?.as_str()?.to_string();
281 if name.is_empty() {
282 return None;
283 }
284 let when_to_use = s
285 .get("when_to_use")
286 .and_then(|x| x.as_str())
287 .filter(|x| !x.is_empty())
288 .map(|x| x.to_string());
289 Some(SkillCandidate {
290 name,
291 description,
292 when_to_use,
293 content,
294 })
295 });
296
297 EvalResult {
298 passed,
299 overall_score,
300 feedback,
301 details,
302 skill_candidate,
303 }
304}
305
306fn extract_json(s: &str) -> &str {
307 if let Some(start) = s.find('{') {
309 if let Some(end) = s.rfind('}') {
310 if start <= end {
311 return &s[start..=end];
312 }
313 }
314 }
315 s
316}
317
318#[cfg(test)]
323mod tests {
324 use super::*;
325
326 #[test]
327 fn build_eval_messages_carries_goal_and_criteria() {
328 let msgs = build_eval_messages(
329 "Write a function",
330 &[Criterion::required("Must handle errors")],
331 "fn foo() {}",
332 1,
333 true,
334 );
335 assert_eq!(msgs.len(), 2);
336 assert!(matches!(msgs[0].role, Role::System));
337 let Content::Text(user) = &msgs[1].content else {
338 panic!("expected text")
339 };
340 assert!(user.contains("Write a function"));
341 assert!(user.contains("[required]Must handle errors"));
342 assert!(user.contains("attempt 1"));
343 let Content::Text(system) = &msgs[0].content else {
345 panic!("expected text")
346 };
347 assert!(system.contains("\"skill\""));
348 }
349
350 #[test]
351 fn build_eval_messages_omits_skill_instruction_when_disabled() {
352 let msgs = build_eval_messages("g", &[], "r", 1, false);
353 let Content::Text(system) = &msgs[0].content else {
354 panic!("expected text")
355 };
356 assert!(!system.contains("\"name\":\"snake_case\""));
357 }
358
359 #[test]
360 fn parse_verdict_survives_close_brace_before_open_brace() {
361 let result = parse_verdict("score 8/10}\n\nverdict: {");
364 assert!(!result.passed);
365 assert_eq!(result.overall_score, 0.0);
366 assert!(result.details.is_empty());
367 }
368
369 #[test]
370 fn parse_verdict_failed_no_skill() {
371 let result = parse_verdict(
372 r#"{"passed":false,"overall_score":0.2,"feedback":"Missing error handling","details":[{"criterion":"Must handle errors","passed":false,"score":0.2,"feedback":"No error handling found"}]}"#,
373 );
374 assert!(!result.passed);
375 assert_eq!(result.feedback, "Missing error handling");
376 assert_eq!(result.details.len(), 1);
377 assert!(!result.details[0].passed);
378 assert!(result.skill_candidate.is_none());
379 }
380
381 #[test]
382 fn parse_verdict_passed_with_skill_and_details() {
383 let json = r#"{"passed":true,"overall_score":0.95,"feedback":"All criteria met","details":[{"criterion":"Must handle errors","passed":true,"score":1.0,"feedback":"Good error handling"}],"skill":{"name":"robust_api_call","description":"How to call APIs with retries","content":"Robust API Call - Always retry on 5xx."}}"#;
384 let result = parse_verdict(json);
385 assert!(result.passed);
386 assert!(result.overall_score > 0.9);
387 assert_eq!(result.details.len(), 1);
388 assert!(result.details[0].passed);
389 let skill = result.skill_candidate.unwrap();
390 assert_eq!(skill.name, "robust_api_call");
391 assert!(skill.content.contains("retry"));
392 }
393
394 #[test]
395 fn parse_verdict_strips_markdown_fences() {
396 let result = parse_verdict("```json\n{\"passed\":true,\"feedback\":\"good\"}\n```");
397 assert!(result.passed);
398 }
399
400 #[test]
401 fn criterion_from_string_is_required() {
402 let c = Criterion::from("some check");
403 assert!(c.required);
404 assert!((c.weight - 1.0).abs() < 0.001);
405 }
406
407 #[test]
408 fn optional_criterion_with_weight() {
409 let c = Criterion::optional("bonus check").with_weight(0.5);
410 assert!(!c.required);
411 assert!((c.weight - 0.5).abs() < 0.001);
412 }
413
414 #[test]
415 fn verdict_output_schema_shape() {
416 let schema = verdict_output_schema(true);
417 assert_eq!(schema["type"], "object");
418 assert!(schema["properties"]["passed"].is_object());
419 assert!(schema["properties"]["overall_score"].is_object());
420 assert!(schema["properties"]["details"].is_object());
421 assert!(schema["properties"]["skill"].is_object());
422 let no_skill = verdict_output_schema(false);
424 assert!(no_skill["properties"]["skill"].is_null());
425 }
426}