Recommended resources
The freely available resources this lesson will direct you to.
Welcome back. Last lesson, you asked Ollama for an OfferInsight using a Pydantic-generated schema, then parsed its response into a Python object. That establishes the shape of an insight, but it does not yet make the insight safe to display on a dashboard. An object can have all four required fields and still call a falling metric “up.”
In this lesson, you’ll add an acceptance boundary: model output must pass structural checks and a small set of business checks before downstream code sees it. You’ll run that boundary against controlled responses without spending time on repeated model calls. Allow about 40 minutes.
Decide what the application can verify
Keep the previous example: repeat-purchase rate fell from 28% in January to 22% in February. Your application already knows the metric and the two values from its source data. It need not ask the model whether 22 is lower than 28.
A useful acceptance boundary has two layers:
- Response validation: Is the output JSON with exactly the expected fields, permitted categories, and usable text?
- Comparison validation: Do its metric and direction agree with the trusted figures supplied to the application?
The curated flowchart illustrates why validation belongs between model output and its next consumer. Its “JSON validation” and “data validation” branches are useful conceptual distinctions; in the Pydantic implementation below, malformed JSON and schema violations can both surface as ValidationError. We’ll add a further check for disagreement with the business figures.

Corey Schafer’s Pydantic tutorial shows how a field validator rejects an unacceptable value and how a model validator can compare fields. Watch the selected portions for the return-or-raise pattern. We’ll put our comparison with source figures in a separate function rather than inside OfferInsight: those figures come from outside the model response.
Python Pydantic Tutorial: Complete Data Validation Course (Used by FastAPI)
Watch selected parts of Corey Schafer’s Python Pydantic Tutorial: Complete Data Validation Course (Used by FastAPI) to see custom checks in working code.
In the custom-validators portion, watch field validation for a validator that returns an accepted value or raises ValueError. Then watch whole-model checks for the distinction between checking one field and comparing values. Skip the intervening URL-normalization example.
Strengthen the response contract
In schemas.py, keep OfferInsight but update it as follows. MetricName gives the schema and the acceptance function one shared vocabulary.
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator
MetricName = Literal[
"conversion_rate",
"average_order_value",
"repeat_purchase_rate",
]
class OfferInsight(BaseModel):
model_config = ConfigDict(extra="forbid", strict=True)
metric: MetricName
direction: Literal["up", "down", "flat"]
observation: str = Field(
min_length=1,
max_length=300,
description="One sentence grounded in the supplied business figures",
)
follow_up_question: str = Field(
min_length=1,
max_length=300,
description="One question for an analyst to investigate next",
)
@field_validator("observation", "follow_up_question")
@classmethod
def reject_blank_text(cls, value: str) -> str:
stripped = value.strip()
if not stripped:
raise ValueError("Text must contain a non-space character")
return stripped
This preserves last lesson’s four-field output while tightening its acceptance rules. extra="forbid" rejects an unexpected field; strict=True avoids relying on type coercion; the length limits reject empty or excessively long text. The custom validator catches a case a minimum length alone misses: a string containing only spaces. Returning stripped also means downstream code receives the cleaned text.
Because last lesson’s Ollama call uses OfferInsight.model_json_schema(), these field constraints are included in the schema sent to Ollama. Still validate the returned content locally. A generation request is not an acceptance decision, and the custom whitespace rule must run in your Python application.
Pydantic’s Models documentation distinguishes validating JSON text with model_validate_json() from validating an existing Python object with model_validate(). Read its example and the opening of “Error handling” before implementing the boundary.
Read Pydantic’s explanation of JSON-mode validation and validation errors. These are the two behaviors the acceptance function will rely on.
In “Validating data,” start where the text introduces the three validation modes. Read the modes and example, focusing on model_validate_json() and its rejected value. Then, in “Error handling,” read the opening explanation of ValidationError. You do not need the later discussion of arbitrary class instances.
Build one acceptance function
Create validation.py. It takes raw model content, not an already accepted OfferInsight, so there is one clear place where untrusted output becomes trusted application data.
from math import isfinite
from pydantic import ValidationError
from schemas import MetricName, OfferInsight
class InvalidOfferInsight(ValueError):
"""The model response must not be used downstream."""
def validate_offer_insight(
raw_json: str,
*,
expected_metric: MetricName,
previous_value: float,
current_value: float,
) -> OfferInsight:
"""Accept a response only if it fits the schema and source comparison."""
if not isfinite(previous_value) or not isfinite(current_value):
raise ValueError("Source comparison values must be finite")
try:
insight = OfferInsight.model_validate_json(raw_json, strict=True)
except ValidationError as exc:
# Keep rejected model text out of the application-facing error.
problems = ", ".join(
f"{'.'.join(map(str, item['loc'])) or 'response'}: {item['type']}"
for item in exc.errors(include_input=False)
)
raise InvalidOfferInsight(f"Invalid response: {problems}") from exc
if insight.metric != expected_metric:
raise InvalidOfferInsight("Metric disagrees with source data")
if current_value > previous_value:
expected_direction = "up"
elif current_value < previous_value:
expected_direction = "down"
else:
expected_direction = "flat"
if insight.direction != expected_direction:
raise InvalidOfferInsight("Direction disagrees with source data")
return insight
Notice the order. Pydantic first parses and validates the raw JSON; only then does the function inspect typed fields. A direction of "sideways" fails the schema. A direction of "up" is schema-valid, but fails the comparison when the values are 28 and 22. The two source numbers must come from the application’s business data, not from values the model supplied to justify its own answer.
ValueError for non-finite source values signals a problem with the application’s comparison data. InvalidOfferInsight signals that the model response was withheld. Keeping those failures distinct will make diagnosis easier.
Check the boundary without Ollama
Create validation_demo.py:
import json
from validation import InvalidOfferInsight, validate_offer_insight
good = {
"metric": "repeat_purchase_rate",
"direction": "down",
"observation": "Repeat-purchase rate fell from 28% to 22%.",
"follow_up_question": "Did retention campaigns change in February?",
}
cases = {
"accepted": json.dumps(good),
"not JSON": "Here is your insight: " + json.dumps(good),
"missing field": json.dumps(
{key: value for key, value in good.items() if key != "observation"}
),
"extra field": json.dumps({**good, "confidence": "high"}),
"blank text": json.dumps({**good, "observation": " "}),
"wrong metric": json.dumps({**good, "metric": "conversion_rate"}),
"wrong direction": json.dumps({**good, "direction": "up"}),
}
for label, raw in cases.items():
try:
insight = validate_offer_insight(
raw,
expected_metric="repeat_purchase_rate",
previous_value=28.0,
current_value=22.0,
)
except InvalidOfferInsight as exc:
print(f"{label}: WITHHELD ({exc})")
else:
print(f"{label}: ACCEPTED ({insight.metric}, {insight.direction})")
Run:
uv run --locked python validation_demo.py
Only the first case should be accepted. This run demonstrates the boundary independently of model availability or output variability; it is not yet a test of the full provider call.
Put the boundary before the dashboard
Now change the signature and final return path of last lesson’s generate_offer_insight() in model_client.py. Add the MetricName and validation imports, keep generate_text() and your existing imports, and replace only the structured-output function with this version:
from schemas import MetricName, OfferInsight
from validation import InvalidOfferInsight, validate_offer_insight
def generate_offer_insight(
business_note: str,
settings: Settings,
*,
expected_metric: MetricName,
previous_value: float,
current_value: float,
) -> OfferInsight:
"""Generate an insight, accepting it only after validation."""
if not business_note.strip():
raise ValueError("Business note must not be blank")
if settings.model_backend != "local":
raise NotImplementedError(
"Structured output is configured for local Ollama only"
)
model_name = settings.model_name.removeprefix("ollama_chat/")
if model_name == settings.model_name or not model_name:
raise ValueError(
"Local MODEL_NAME must be ollama_chat/ followed by a model name"
)
prompt = (
"Analyze this demo ecommerce note using only its supplied facts. "
"Do not invent additional figures. Identify the metric and direction, "
"write a brief observation, and suggest one follow-up question.\n\n"
f"Note: {business_note}"
)
client = Client(
host=settings.model_api_base,
timeout=settings.model_timeout_seconds,
)
response = client.chat(
model=model_name,
messages=[{"role": "user", "content": prompt}],
format=OfferInsight.model_json_schema(),
)
if response.message.content is None:
raise InvalidOfferInsight("Model returned no JSON content")
return validate_offer_insight(
response.message.content,
expected_metric=expected_metric,
previous_value=previous_value,
current_value=current_value,
)
The additional arguments intentionally make the caller supply the source comparison. In a real analytics service, derive the note and these arguments from the same metric record. Otherwise, an inconsistency introduced by your own code could cause a good answer to be rejected—or let a misleading observation pass.
Finally, update the relevant part of app.py so its downstream action occurs only after the function returns:
from config import Settings
from model_client import generate_offer_insight
from validation import InvalidOfferInsight
def main() -> None:
previous_rate = 28.0
current_rate = 22.0
note = (
f"For this demo store, repeat-purchase rate fell from "
f"{previous_rate:g}% in January to {current_rate:g}% in February."
)
try:
insight = generate_offer_insight(
note,
Settings(),
expected_metric="repeat_purchase_rate",
previous_value=previous_rate,
current_value=current_rate,
)
except InvalidOfferInsight:
print("Insight withheld: model output failed validation.")
return
# Stand-in for sending an accepted insight to a dashboard or API client.
print(insight.model_dump_json(indent=2))
if __name__ == "__main__":
main()
With Ollama running, you can run uv run --locked python app.py again. The model’s prose may vary, but a contradictory metric or direction must no longer reach the display. Do not replace a rejected result with the raw model text: that would bypass the boundary you just built.
What validation does—and does not—establish
You can now say that an accepted response has the required structure, nonblank text, the expected metric, and a direction consistent with the two supplied values. You cannot yet say every sentence is factually correct. For example, a schema-valid observation might invent a cause for the decline, or quote the wrong month. Those claims require checks against source evidence or evaluation beyond this narrow comparison rule.
This boundary also does not handle an unavailable Ollama server or a timed-out request. The except InvalidOfferInsight clause deliberately does not disguise provider failures as bad model content.
Takeaway
Your application now has a single point where raw LLM output is either accepted as an OfferInsight or withheld. Pydantic handles JSON, fields, and local text constraints; a separate comparison checks the model’s metric and direction against trusted business figures. Keep both layers before any dashboard display, storage, or consequential action.
Next lesson, you’ll address a different failure path: handling model timeouts and transient provider errors gracefully.