use anyhow::{Context, Result};
pub struct ResultContract {
schema_text: String,
schema: serde_json::Value,
validator: jsonschema::Validator,
}
impl ResultContract {
pub fn from_flag(flag: &str) -> Result<Self> {
let text = match flag.strip_prefix('@') {
Some(path) => std::fs::read_to_string(path)
.with_context(|| format!("reading result schema from {path}"))?,
None => flag.to_string(),
};
let schema: serde_json::Value =
serde_json::from_str(&text).context("result schema is not valid JSON")?;
let validator = jsonschema::validator_for(&schema)
.map_err(|e| anyhow::anyhow!("result schema is not a valid JSON Schema: {e}"))?;
Ok(Self {
schema_text: serde_json::to_string_pretty(&schema).unwrap_or(text),
schema,
validator,
})
}
pub fn schema(&self) -> &serde_json::Value {
&self.schema
}
pub fn instruction(&self) -> String {
format!(
"\n\nWhen you are done, end your reply with your final result as a single \
JSON object inside a ```json fenced code block (the last such block in \
your reply is taken as the result). It must match this JSON Schema:\n\
```json\n{}\n```",
self.schema_text
)
}
pub fn repair_prompt(&self, problem: &str) -> String {
format!(
"Your previous reply did not contain a valid result ({problem}). Reply with \
ONLY a ```json fenced code block containing the corrected result object, \
matching the schema you were given."
)
}
pub fn check(&self, reply: &str) -> std::result::Result<serde_json::Value, String> {
let candidate = extract_json(reply).ok_or("no JSON result found in the reply")?;
let value: serde_json::Value = serde_json::from_str(&candidate)
.map_err(|e| format!("result block is not valid JSON: {e}"))?;
let errors: Vec<String> = self
.validator
.iter_errors(&value)
.map(|e| format!("{}: {e}", e.instance_path()))
.collect();
if errors.is_empty() {
Ok(value)
} else {
Err(format!("schema violations: {}", errors.join("; ")))
}
}
}
fn extract_json(reply: &str) -> Option<String> {
let mut last: Option<String> = None;
let mut in_block: Option<String> = None;
for line in reply.lines() {
let trimmed = line.trim();
match &mut in_block {
None => {
let fence = trimmed.strip_prefix("```");
if let Some(info) = fence
&& matches!(info.trim(), "json" | "JSON")
{
in_block = Some(String::new());
}
}
Some(buf) => {
if trimmed.starts_with("```") {
last = in_block.take();
} else {
buf.push_str(line);
buf.push('\n');
}
}
}
}
if last.is_some() {
return last;
}
let trimmed = reply.trim();
(trimmed.starts_with('{') && trimmed.ends_with('}')).then(|| trimmed.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
fn contract() -> ResultContract {
ResultContract::from_flag(
r#"{"type":"object","required":["ok"],"properties":{"ok":{"type":"boolean"}}}"#,
)
.expect("valid schema")
}
#[test]
fn from_flag_rejects_bad_json_and_bad_schema() {
assert!(ResultContract::from_flag("not json").is_err());
assert!(
ResultContract::from_flag(r#"{"type":"no-such-type"}"#).is_err(),
"an invalid schema must fail up front, not at check time"
);
}
#[test]
fn instruction_carries_the_schema() {
let c = contract();
let inst = c.instruction();
assert!(inst.contains("```json"), "fenced block requested");
assert!(inst.contains("\"ok\""), "schema text rides the prompt");
}
#[test]
fn check_takes_the_last_json_block_and_validates() {
let c = contract();
let reply =
"Working…\n```json\n{\"ok\": \"draft\"}\n```\nFinal:\n```json\n{\"ok\": true}\n```\n";
assert_eq!(
c.check(reply).expect("valid"),
serde_json::json!({"ok": true}),
"the LAST block wins"
);
}
#[test]
fn check_accepts_bare_json_reply() {
let c = contract();
assert!(c.check("{\"ok\": false}").is_ok());
}
#[test]
fn check_reports_missing_invalid_and_violating_results() {
let c = contract();
assert!(c.check("no json here").unwrap_err().contains("no JSON"));
assert!(
c.check("```json\n{broken\n```")
.unwrap_err()
.contains("not valid JSON")
);
let err = c.check("```json\n{\"ok\": 3}\n```").unwrap_err();
assert!(err.contains("schema violations"), "{err}");
}
#[test]
fn repair_prompt_names_the_problem() {
let c = contract();
let p = c.repair_prompt("no JSON result found in the reply");
assert!(p.contains("no JSON result found"));
}
}