use crate::infer::{InferBackend, InferRequest};
use cuttlefish_core::graph::AcceptCheck;
use cuttlefish_core::spec::ModelRef;
use std::sync::Arc;
const JUDGE_MAX_TOKENS: u32 = 256;
#[derive(Debug)]
pub enum JudgeVerdict {
Accepted,
Rejected(String),
Unusable(String),
}
pub struct CompiledChecks {
schemas: Vec<(std::path::PathBuf, jsonschema::Validator)>,
judges: Vec<(Option<ModelRef>, String)>,
}
impl CompiledChecks {
pub fn compile(checks: &[AcceptCheck]) -> anyhow::Result<Self> {
let mut schemas = Vec::new();
let mut judges = Vec::new();
for check in checks {
match check {
AcceptCheck::Schema(path) => {
let text = std::fs::read_to_string(path).map_err(|e| {
anyhow::anyhow!("reading accept schema {}: {e}", path.display())
})?;
let value: serde_json::Value = serde_json::from_str(&text).map_err(|e| {
anyhow::anyhow!("accept schema {} is not valid JSON: {e}", path.display())
})?;
let validator = jsonschema::validator_for(&value).map_err(|e| {
anyhow::anyhow!(
"accept schema {} is not a valid JSON Schema: {e}",
path.display()
)
})?;
schemas.push((path.clone(), validator));
}
AcceptCheck::Judge { model, prompt } => {
judges.push((model.clone(), prompt.clone()))
}
}
}
Ok(Self { schemas, judges })
}
pub fn is_empty(&self) -> bool {
self.schemas.is_empty() && self.judges.is_empty()
}
pub fn check_schemas(&self, value: &serde_json::Value) -> Result<(), String> {
for (path, validator) in &self.schemas {
let violations: Vec<String> = validator
.iter_errors(value)
.map(|e| format!("{}: {e}", e.instance_path()))
.collect();
if !violations.is_empty() {
return Err(format!(
"does not conform to {}:\n{}",
path.display(),
violations.join("\n")
));
}
}
Ok(())
}
pub async fn run_judges(
&self,
input: &serde_json::Value,
output: &serde_json::Value,
default: &Arc<dyn InferBackend>,
alternates: &crate::runner::Alternates,
) -> JudgeVerdict {
for (model, prompt) in &self.judges {
let backend = match model {
None => default,
Some(m) => match alternates.get(m) {
Some(b) => b,
None => {
return JudgeVerdict::Unusable(format!(
"judge names model `{m}`, which was not resolved at startup"
))
}
},
};
let full = judge_prompt(prompt, input, output);
let mut sink = |_: &str| true;
let reply = match backend
.infer(
InferRequest {
prompt: &full,
max_tokens: JUDGE_MAX_TOKENS,
images: &[],
},
&mut sink,
)
.await
{
Ok(r) => r.text,
Err(e) => return JudgeVerdict::Unusable(format!("judge inference failed: {e}")),
};
match parse_verdict(&reply) {
Ok(true) => continue,
Ok(false) => {
return JudgeVerdict::Rejected(
verdict_reason(&reply).unwrap_or_else(|| "no reason given".to_string()),
)
}
Err(why) => return JudgeVerdict::Unusable(why),
}
}
JudgeVerdict::Accepted
}
}
impl std::fmt::Debug for CompiledChecks {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CompiledChecks")
.field("schemas", &self.schemas.len())
.field("judges", &self.judges.len())
.finish()
}
}
fn judge_prompt(prompt: &str, input: &serde_json::Value, output: &serde_json::Value) -> String {
format!(
"{prompt}\n\n\
--- INPUT ---\n{input}\n\n\
--- OUTPUT UNDER REVIEW ---\n{output}\n\n\
--- END ---\n\
Reply with JSON only: {{\"accept\": true|false, \"reason\": \"...\"}}"
)
}
fn parse_verdict(reply: &str) -> Result<bool, String> {
let value = extract_json(reply)
.ok_or_else(|| format!("judge reply contained no JSON object: {reply:?}"))?;
value
.get("accept")
.and_then(|v| v.as_bool())
.ok_or_else(|| format!("judge reply has no boolean `accept` field: {reply:?}"))
}
fn verdict_reason(reply: &str) -> Option<String> {
extract_json(reply)?
.get("reason")
.and_then(|v| v.as_str())
.map(str::to_string)
}
fn extract_json(reply: &str) -> Option<serde_json::Value> {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(reply.trim()) {
return Some(v);
}
let start = reply.find('{')?;
let end = reply.rfind('}')?;
if end <= start {
return None;
}
serde_json::from_str(&reply[start..=end]).ok()
}