Structured Output and Tool Calling · Contracts · lesson 1 of 6
Why free text breaks pipelines
about 14 minutes · free · runs in your browser
Step 1 of 2
The answer is right and the program is broken
Ask a model to classify something and it will happily reply "Based on the review, the
sentiment appears to be positive." A person reads that and nods. label == "positive"
is False, and the row goes into your database as unclassified.
The failure is not the model's. It is a missing contract: nobody said what the answer had to look like, so it looked like English.
Two halves fix it, and both are needed:
- Ask for the shape. "Reply with exactly one word: positive, negative or neutral."
- Check what came back. Never assume the instruction was followed — a model under a long prompt drifts, and a drifted answer that goes unchecked is a silent bad row.
ALLOWED = {"positive", "negative", "neutral"}
label = reply.strip().lower()
if label not in ALLOWED:
raise ValueError("unexpected label: " + repr(reply))
Raising here feels harsh and is the point. A pipeline that stores "Based on the review..." in a label column fails silently and is discovered by a chart that looks odd
three weeks later.
Your turn: write strict_label(reply) that normalises a reply and returns the label,
or raises ValueError when it is not one of the three.
You start from this, and edit it in the browser:
ALLOWED = {"positive", "negative", "neutral"}
def strict_label(reply):
"""Return the normalised label, or raise ValueError."""
return reply
Step 2 of 2
Asking for JSON
One word is the simplest contract. The next one up is JSON, and the instruction has to say so plainly — the word "JSON" in the prompt is what switches most models into that mode.
RULE = "Reply with JSON only. No preamble, no code fence."
Then parse it. json.loads raises ValueError (JSONDecodeError is a subclass) on
anything that is not JSON, and that exception is the most useful signal in the whole
pipeline: it fires at the boundary, on the request that broke, rather than three functions
downstream where a KeyError tells you nothing about why.
Your turn: write ask_json(question) that asks for JSON and returns the parsed
dictionary. Let a parse failure raise — repairing it is the next lesson, and code that
swallows the error now will make that lesson impossible.
You start from this, and edit it in the browser:
import json
import fake_llm
RULE = "Reply with JSON only. No preamble, no code fence."
def ask_json(question):
"""Ask for JSON and return the parsed dictionary."""
return {}