use crate::agent::Agent;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Assertion {
pub id: String,
pub check: String,
pub expected: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GradeResult {
pub assertion_id: String,
pub passed: bool,
pub confidence: f64,
pub evidence: String,
pub reasoning: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GradingReport {
pub case_id: String,
pub results: Vec<GradeResult>,
pub pass_rate: f64,
pub overall_assessment: String,
}
pub struct LlmGrader {
grader_prompt: String,
}
impl Default for LlmGrader {
fn default() -> Self {
Self::new()
}
}
impl LlmGrader {
pub fn new() -> Self {
Self {
grader_prompt: concat!(
"You are an expert evaluator. You will receive:\n",
"1. A TASK that an agent was asked to complete\n",
"2. The agent's OUTPUT\n",
"3. A list of ASSERTIONS to check\n\n",
"For each assertion, determine:\n",
"- PASSED: true/false\n",
"- CONFIDENCE: 0.0-1.0\n",
"- EVIDENCE: quote the specific part of the output that proves or disproves\n",
"- REASONING: explain your judgment\n\n",
"Output format (JSON):\n",
"{\n",
" \"results\": [\n",
" {\"assertion_id\": \"...\", \"passed\": bool, \"confidence\": 0.0-1.0,\n",
" \"evidence\": \"...\", \"reasoning\": \"...\"}\n",
" ],\n",
" \"overall_assessment\": \"brief summary\"\n",
"}\n\n",
"Be strict but fair. If evidence is ambiguous, mark as not passed."
)
.to_string(),
}
}
pub async fn grade(
&self,
agent: &dyn Agent,
task: &str,
output: &str,
assertions: &[Assertion],
) -> GradingReport {
self.grade_with_trajectory(agent, task, output, assertions, None)
.await
}
pub async fn grade_with_trajectory(
&self,
agent: &dyn Agent,
task: &str,
output: &str,
assertions: &[Assertion],
trajectory_summary: Option<&str>,
) -> GradingReport {
let assertions_text: String = assertions
.iter()
.map(|a| {
format!(
"- [{}] Check: {}\n Expected: {}",
a.id, a.check, a.expected
)
})
.collect::<Vec<_>>()
.join("\n");
let mut sections = vec![self.grader_prompt.clone(), format!("--- TASK ---\n{task}")];
if let Some(traj) = trajectory_summary {
sections.push(format!("--- TRAJECTORY ---\n{traj}"));
}
sections.push(format!("--- OUTPUT ---\n{}", truncate(output, 8000)));
sections.push(format!("--- ASSERTIONS ---\n{assertions_text}"));
sections.push("Provide your grading in the JSON format specified.".to_string());
let prompt = sections.join("\n\n");
let response = agent.execute(&prompt).await;
let raw = response.unwrap_or_else(|e| format!("{{\"error\": \"{e}\"}}"));
Self::parse_grading_response(&raw, assertions)
}
fn parse_grading_response(raw: &str, assertions: &[Assertion]) -> GradingReport {
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(raw) {
let results: Vec<GradeResult> = parsed["results"]
.as_array()
.map(|arr| {
arr.iter()
.map(|v| GradeResult {
assertion_id: v["assertion_id"].as_str().unwrap_or("?").into(),
passed: v["passed"].as_bool().unwrap_or(false),
confidence: v["confidence"].as_f64().unwrap_or(0.0),
evidence: v["evidence"].as_str().unwrap_or("").into(),
reasoning: v["reasoning"].as_str().unwrap_or("").into(),
})
.collect()
})
.unwrap_or_default();
let pass_count = results.iter().filter(|r| r.passed).count();
let pass_rate = if assertions.is_empty() {
1.0
} else {
pass_count as f64 / assertions.len() as f64
};
GradingReport {
case_id: String::new(),
results,
pass_rate,
overall_assessment: parsed["overall_assessment"].as_str().unwrap_or("").into(),
}
} else {
let results: Vec<GradeResult> = assertions
.iter()
.map(|a| {
let passed = raw.to_lowercase().contains("passed") && raw.contains(&a.id);
GradeResult {
assertion_id: a.id.clone(),
passed,
confidence: 0.5,
evidence: raw.chars().take(200).collect(),
reasoning: String::new(),
}
})
.collect();
let pass_count = results.iter().filter(|r| r.passed).count();
let pass_rate = if assertions.is_empty() {
1.0
} else {
pass_count as f64 / assertions.len() as f64
};
GradingReport {
case_id: String::new(),
results,
pass_rate,
overall_assessment: "Fallback parsing applied".into(),
}
}
}
}
fn truncate(s: &str, max: usize) -> &str {
if s.len() <= max { s } else { &s[..max] }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_valid_response() {
let raw = r#"{"results":[{"assertion_id":"a1","passed":true,"confidence":0.9,"evidence":"found","reasoning":"clear"}],"overall_assessment":"good"}"#;
let assertions = vec![Assertion {
id: "a1".into(),
check: "check".into(),
expected: "yes".into(),
}];
let report = LlmGrader::parse_grading_response(raw, &assertions);
assert_eq!(report.pass_rate, 1.0);
assert_eq!(report.results.len(), 1);
assert!(report.results[0].passed);
}
}