use jsonschema::Validator;
use serde_json::{Map, Value, json};
use crate::{config::EvalDeclaration, tools::schema::quoted};
mod structured;
pub(super) use structured::Structured;
pub fn validate_result(eval: &EvalDeclaration, value: &Value) -> Result<ValidatedResult, String> {
let schema = VerdictSchema::new(eval.pass_schema(), eval.fail_schema())?;
schema
.validate(value)
.map_err(|error| schema.explain(error, value))?;
Ok(ValidatedResult::from_validated(value.clone()))
}
pub(super) const MAX_RESPONSE_BYTES: usize = 1024 * 1024;
const MAX_RESULT_CHARS: usize = 256_000;
const MAX_SCHEMA_BYTES: usize = 8 * 1024 * 1024;
const MAX_DIAGNOSTICS: usize = 5;
const MAX_DIAGNOSTIC_CHARS: usize = 200;
const RESERVED: &[&str] = &[
"verdict",
"reference",
"reusedFrom",
"executionProvenance",
"attemptId",
"provider",
"model",
"stdout",
"stderr",
"durationMs",
"exitCode",
];
pub(crate) fn validate_schema(schema: &Map<String, Value>) -> Result<(), String> {
let value = Value::Object(schema.clone());
if schema.get("type").is_some_and(|kind| kind != "object") {
return Err("Response schema type must be object when declared.".into());
}
if serde_json::to_vec(&value).expect("schema is JSON").len() > MAX_SCHEMA_BYTES {
return Err("Response schema exceeds 8 MiB.".into());
}
if ["allOf", "anyOf", "oneOf", "not"]
.iter()
.any(|key| schema.contains_key(*key))
{
return Err(
"Response schemas cannot use top-level composition; compose inside owner fields."
.into(),
);
}
if schema
.get("additionalProperties")
.is_some_and(|v| v != false)
{
return Err("Response schemas must forbid undeclared top-level fields.".into());
}
for field in RESERVED {
if value["properties"].get(*field).is_some()
|| value["required"]
.as_array()
.is_some_and(|required| required.iter().any(|name| name.as_str() == Some(field)))
{
return Err(
"Response schemas cannot declare or require reserved result fields.".into(),
);
}
}
jsonschema::validator_for(&value)
.map(|_| ())
.map_err(|_| "Invalid response JSON Schema.".into())
}
pub(crate) struct VerdictSchema {
green: Validator,
red: Validator,
pub schema: Value,
}
impl VerdictSchema {
pub fn new(
pass: Option<&Map<String, Value>>,
fail: Option<&Map<String, Value>>,
) -> Result<Self, String> {
let green = branch("GREEN", pass)?;
let red = branch("RED", fail)?;
Ok(Self {
green: jsonschema::validator_for(&green)
.map_err(|_| "Invalid GREEN response JSON Schema.".to_owned())?,
red: jsonschema::validator_for(&red)
.map_err(|_| "Invalid RED response JSON Schema.".to_owned())?,
schema: json!({"GREEN":green,"RED":red}),
})
}
#[cfg(test)]
pub fn parse(&self, text: &str) -> Result<ValidatedResult, &'static str> {
self.parse_model(text, None)
}
pub(super) fn parse_model(
&self,
text: &str,
structured: Option<&Structured>,
) -> Result<ValidatedResult, &'static str> {
if text.len() > MAX_RESPONSE_BYTES {
return Err("over_size: final JSON exceeds 1 MiB");
}
if text.trim().is_empty() {
return Err("empty: return one JSON object");
}
let value: Value = serde_json::from_str(text).map_err(
|_| "not_json: return exactly one JSON object, without prose or code fences",
)?;
let value = match structured {
Some(structured) => structured.decode(value)?,
None => value,
};
self.validate(&value)?;
Ok(ValidatedResult::from_validated(value))
}
pub(super) fn repair_detail(
&self,
text: &str,
error: &str,
structured: Option<&Structured>,
) -> String {
let value = (text.len() <= MAX_RESPONSE_BYTES)
.then(|| serde_json::from_str::<Value>(text).ok())
.flatten()
.and_then(|value| match structured {
Some(structured) => structured.decode(value).ok(),
None => Some(value),
});
match value {
Some(value) => self.explain(error, &value),
None => error.into(),
}
}
fn branch(&self, value: &Value) -> Option<(&Validator, &Value)> {
match value.get("verdict").and_then(Value::as_str) {
Some("GREEN") => Some((&self.green, &self.schema["GREEN"])),
Some("RED") => Some((&self.red, &self.schema["RED"])),
_ => None,
}
}
fn explain(&self, error: &str, value: &Value) -> String {
let Some((validator, branch)) = self.branch(value) else {
return error.into();
};
let mut paths: Vec<_> = validator
.iter_errors(value)
.map(|error| {
format!(
"- instancePath {}: {}",
quoted(&bounded(error.instance_path().to_string())),
bounded(error.to_string())
)
})
.take(MAX_DIAGNOSTICS)
.collect();
if paths.is_empty() {
let fields = value.as_object().into_iter().flatten();
paths = fields
.filter(|(key, _)| branch["properties"].get(key.as_str()).is_none())
.map(|(key, _)| {
format!("- field {} is not declared.", quoted(&bounded(key.clone())))
})
.take(MAX_DIAGNOSTICS)
.collect();
}
std::iter::once(error.to_owned())
.chain(paths)
.collect::<Vec<_>>()
.join("\n")
}
fn validate(&self, value: &Value) -> Result<(), &'static str> {
let (validator, branch) = self
.branch(value)
.ok_or("schema_mismatch: verdict must be GREEN or RED")?;
if !value.as_object().is_some_and(|object| {
object
.keys()
.all(|key| branch["properties"].get(key).is_some())
}) || !validator.is_valid(value)
{
return Err("schema_mismatch: result must match the selected verdict's owner schema");
}
if serde_json::to_string(value)
.expect("result is JSON")
.encode_utf16()
.count()
> MAX_RESULT_CHARS
{
return Err("over_size: normalized result exceeds 256000 characters");
}
Ok(())
}
}
fn bounded(mut text: String) -> String {
if text.chars().count() > MAX_DIAGNOSTIC_CHARS {
text = text.chars().take(MAX_DIAGNOSTIC_CHARS).collect::<String>() + "… (truncated)";
}
text
}
fn branch(verdict: &str, owner: Option<&Map<String, Value>>) -> Result<Value, String> {
if let Some(owner) = owner {
validate_schema(owner)?;
}
let mut schema = owner.cloned().unwrap_or_default();
let properties = schema.entry("properties").or_insert_with(|| json!({}));
properties
.as_object_mut()
.expect("validated properties")
.insert("verdict".into(), json!({"type":"string","const":verdict}));
schema
.entry("required")
.or_insert_with(|| json!([]))
.as_array_mut()
.expect("validated required")
.push(json!("verdict"));
schema.insert("type".into(), json!("object"));
schema.insert("additionalProperties".into(), json!(false));
Ok(Value::Object(schema))
}
#[cfg(test)]
mod tests;
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct ValidatedResult {
pub verdict: crate::runtime::Verdict,
#[serde(flatten)]
pub fields: Map<String, Value>,
}
impl ValidatedResult {
fn from_validated(value: Value) -> Self {
let mut fields = value
.as_object()
.expect("validated result is an object")
.clone();
let verdict = serde_json::from_value(fields.remove("verdict").expect("validated verdict"))
.expect("schema validated the verdict");
Self { verdict, fields }
}
pub fn into_json(self) -> Value {
serde_json::to_value(self).expect("validated result is JSON")
}
pub fn to_json(&self) -> Value {
serde_json::to_value(self).expect("validated result is JSON")
}
}