Academy Tutorial · Beginner · 20 minutes

Schema-check one output and fail closed

Twenty minutes to put the first guardrail from the course into code: a JSON schema, a validator that reads the stop reason first, one repair, then a closed door.

Level
Beginner
Time
About 20 minutes
You need
Python 3.11 or later, the jsonschema package, any model API that returns a stop reason
Published
You will have
  • A schema that forces the reasoning field before the verdict
  • A check() that rejects truncated replies before it parses them
  • One repair attempt, then a raised error nobody can ignore
  • Five fixture cases you can keep as your first evals

Lesson 13 of the course says to treat model output like untrusted user input. This tutorial does the smallest version of that in code. Nothing here depends on a provider: call_model is whatever function returns the reply text and the stop reason from the API you already use.

What you will have

A review step that returns a verdict of approve, reject or ask, with the model’s reasoning written before the verdict, validated on every call, repaired once when it fails, and refused when it fails twice. The refusal is the point. A step that cannot produce a valid verdict must not write one.

1. Decide the shape

Write the schema before the prompt. The property order below is the order you want the model to write in, and the prompt will say so; the schema cannot enforce order, only presence and shape. The if and then clauses make a question mandatory when the verdict is ask.

REPLY = {
    "type": "object",
    "properties": {
        "reasoning": {"type": "string", "minLength": 1},
        "verdict": {"type": "string", "enum": ["approve", "reject", "ask"]},
        "question": {"type": "string", "minLength": 1},
    },
    "required": ["reasoning", "verdict"],
    "additionalProperties": False,
    "if": {"properties": {"verdict": {"const": "ask"}}},
    "then": {"required": ["question"]},
}

Three rules are doing the work. additionalProperties: false stops the model from inventing fields your code will silently ignore. enum makes the verdict a closed set. minLength: 1 on the reasoning stops an empty string from passing.

2. Validate, and read the stop reason first

A reply cut at the output limit is not an error in the API. It comes back with a stop reason such as length or max_tokens, and the JSON ends mid-field. Check the stop reason before you parse, so a truncated reply is refused as truncated and not reported as a syntax error you will spend an hour chasing.

import json
from jsonschema import Draft202012Validator

validator = Draft202012Validator(REPLY)

# Providers name the normal ending differently. Put yours here.
CLEAN_STOPS = {"end_turn", "stop"}


def check(raw: str, stop_reason: str) -> dict:
    """Return the parsed reply, or raise ValueError with a message the model can act on."""
    if stop_reason not in CLEAN_STOPS:
        raise ValueError(f"reply was cut short: stop_reason={stop_reason}")
    try:
        data = json.loads(raw)
    except json.JSONDecodeError as e:
        raise ValueError(f"not a JSON object: {e.msg} at character {e.pos}") from None
    errors = sorted(validator.iter_errors(data), key=lambda e: list(e.path))
    if errors:
        where = lambda e: "/".join(str(p) for p in e.path) or "$"
        raise ValueError("; ".join(f"{where(e)}: {e.message}" for e in errors))
    return data


def reasoning_first(raw: str) -> bool:
    """The schema cannot check order. This reads the raw text, which can."""
    r, v = raw.find('"reasoning"'), raw.find('"verdict"')
    return r != -1 and v != -1 and r < v

The error messages are written for the model, not for a log. Step 3 sends them back.

3. Repair once, then fail closed

One repair is cheap and fixes most format slips. A second failure means the task or the prompt is wrong, and looping will not fix that. Raise, and let the caller decide what a refused verdict means, which for anything that writes is: no write.

def review(messages: list[dict], call_model) -> dict:
    """call_model(messages) -> (raw_text, stop_reason). Raises ValueError if no valid reply arrives in two tries."""
    raw, stop = call_model(messages)
    try:
        data = check(raw, stop)
    except ValueError as first:
        repair = messages + [
            {"role": "assistant", "content": raw},
            {"role": "user", "content": f"Your reply failed validation: {first}. Reply again with only the JSON object, reasoning first."},
        ]
        raw, stop = call_model(repair)
        data = check(raw, stop)  # a second ValueError leaves this function, and nothing downstream runs
    if not reasoning_first(raw):
        raise ValueError("verdict was written before the reasoning")
    return data

Two things to notice. The repair message quotes the exact validation error, which is the one piece of information the model did not have. And the order check runs on the raw text of the reply that passed, so a valid object written verdict-first is still refused.

4. Five cases to keep

These are your first evals. Keep them in the test suite and add one every time production shows you a new way to be wrong.

CASES = [
    # raw reply, stop reason, should it pass
    ('{"reasoning": "Drawing matches the brief.", "verdict": "approve"}', "end_turn", True),
    ('{"reasoning": "Span unclear.", "verdict": "ask", "question": "Which span, 25 m or 27 m?"}', "end_turn", True),
    ('{"reasoning": "Span unclear.", "verdict": "ask"}', "end_turn", False),          # ask without a question
    ('{"reasoning": "Looks fine", "verdict": "approve"', "length", False),           # cut at the output limit
    ('Sure, here is the JSON: {"reasoning": "ok", "verdict": "approve"}', "end_turn", False),  # prose around it
]

for raw, stop, should_pass in CASES:
    try:
        check(raw, stop)
        passed = True
    except ValueError:
        passed = False
    assert passed == should_pass, (raw, stop)
print("5 cases hold")

Run it with python -m pytest or plain python. It takes no model call, so it runs in CI on every prompt change for free.

Where it goes wrong

A team adds a regex that strips the prose around the JSON in case five, so the case passes. The next model version writes clean JSON and the regex eats the first brace. Delete the repair layer and keep the case failing on prose, then fix the prompt: “Reply with only the JSON object.”

Next

Lesson 14 turns cases like these into a suite that gates releases. The next tutorial will put a trace ID on the call so a refused verdict shows up beside the reply that caused it.