Free-form model text is difficult to validate and store. This tutorial uses the OpenAI Responses API and a Pydantic model to turn facilities notes into typed Python records, then checks schema failures, semantic mistakes, and batch results.
Free-form model text is easy to read but difficult to use in a program. A sentence such as “the archive room is closed because water is leaking” does not give your code a dependable field for urgency, issue type, or service impact. Saving that sentence in a database only moves the parsing problem to a later step.
To get structured JSON from the OpenAI API in Python, define the required fields in a Pydantic model and pass that class to client.responses.parse(..., text_format=YourModel). Read the validated object from response.output_parsed, but handle refusals and incomplete responses before using it. Then check rules that a JSON schema cannot judge, so predictable types are not mistaken for automatically correct values.
We will apply that method to 15 fictional facilities-maintenance notes. Each note becomes a record with an issue area, urgency, service-impact flag, and next action. The scenario is small enough to inspect by eye, but the same boundary works for document intake, catalog enrichment, and other text-extraction tasks.
You need Python 3.10 or later and basic knowledge of classes and dictionaries. A schema is a description of the fields and types that data must contain. Pydantic turns Python type hints into a schema and validates values against it.
Create a virtual environment so this lesson’s packages do not conflict with packages from another project. On macOS or Linux, run:
python -m venv .venv
source .venv/bin/activate
python -m pip install openai==2.37.0 pydantic==2.13.4 matplotlib==3.11.0On Windows PowerShell, activate the environment with .venv\Scripts\Activate.ps1 instead. The executed build used Python 3.13.2 with the package versions shown above.
A live request also needs an OpenAI API project, API access, and an API key. Put the key in an environment variable rather than in your Python file:
export OPENAI_API_KEY="your-key-here"In PowerShell, use $env:OPENAI_API_KEY="your-key-here". Do not print the key or commit it to version control. API availability, supported models, and charges can change, so check the official Structured Outputs guide before a production rollout.
The lesson data is an original synthetic maintenance-note dataset released under CC0 1.0. All identifiers, faults, rooms, and actions are fictional. The CSV also contains expected teaching labels so you can test the surrounding Python code without making paid calls.
Here are the first two rows in compact form:
report_id issue_area urgency blocks_service raw_note
R001 electrical low False The east reading-room lamp flickers...
R002 water high True Water is dripping through the archive-room ceiling...The expected columns are useful for tests. They are not proof that a live model will extract every note correctly.
Structured output adds a contract between a model response and the rest of your program:
free-form note -> model + schema -> typed Pydantic object -> application checksThe schema controls shape. It can require urgency, restrict it to low, medium, or high, and reject an unexpected key. It cannot decide whether a leaking pipe truly deserves high urgency. That is a meaning question, also called a semantic question, and it still needs evaluation or a business rule.
This difference is important:
OpenAI’s Python guide uses client.responses.parse() with a Pydantic class and reads the result from response.output_parsed. The guide also shows that a safety refusal appears as a separate response item rather than as data that follows your schema. Your code therefore needs explicit paths for refusals, incomplete responses, and missing parsed output.
Structured output is different from function calling. Use structured output when the model should return a typed answer. Use function calling when the model should request that your program run a tool. See Function Calling in Python for that separate request-execute-result loop.
Start with two enums. An enum, short for enumeration, limits a value to a fixed list. Fixed values prevent variations such as urgent, very urgent, and high-priority from becoming three accidental categories.
from enum import Enum
from pydantic import BaseModel, ConfigDict, Field
class IssueArea(str, Enum):
ELECTRICAL = "electrical"
WATER = "water"
ACCESS = "access"
TEMPERATURE = "temperature"
OTHER = "other"
class Urgency(str, Enum):
LOW = "low"
MEDIUM = "medium"
HIGH = "high"Now describe one complete maintenance record. Every field has a clear type, and each text constraint is small enough to explain.
class MaintenanceRecord(BaseModel):
model_config = ConfigDict(extra="forbid")
report_id: str = Field(pattern=r"^R\d{3}$")
issue_area: IssueArea
urgency: Urgency
blocks_service: bool
next_action: str = Field(min_length=8, max_length=100)report_id must contain R followed by three digits. blocks_service must be a Boolean value: True or False. extra="forbid" rejects keys that are not declared. The action-length limits prevent an empty response and keep the value concise enough for a small work queue.
Pydantic can show the generated JSON Schema. Inspect three parts before sending it anywhere:
schema = MaintenanceRecord.model_json_schema()
print(schema["required"])
print(schema["$defs"]["Urgency"]["enum"])
print(schema["additionalProperties"])['report_id', 'issue_area', 'urgency', 'blocks_service', 'next_action']
['low', 'medium', 'high']
FalseAll five fields are required. The urgency list is closed, and extra properties are disabled. Those details match key Structured Outputs requirements: object fields are required, and objects do not accept unspecified properties. If a value may be missing, use a required nullable field such as detail: str | None instead of silently inventing text.
Create the official client after the key is available in the environment. The client reads OPENAI_API_KEY automatically.
from openai import OpenAI
client = OpenAI()Next, put the API call in one function. Keeping the external API call in one place makes it easier to find, test, and update.
def extract_record(client, note: str) -> MaintenanceRecord:
response = client.responses.parse(
model="gpt-5.6",
input=[
{
"role": "system",
"content": (
"Convert one facilities note into the requested record. "
"Use only facts stated in the note."
),
},
{"role": "user", "content": note},
],
text_format=MaintenanceRecord,
)
if response.status == "incomplete":
raise RuntimeError(
f"Incomplete response: {response.incomplete_details}"
)
for output in response.output:
if output.type != "message":
continue
for item in output.content:
if item.type == "refusal":
raise RuntimeError(f"Model refusal: {item.refusal}")
if response.output_parsed is None:
raise RuntimeError("No parsed record returned.")
return response.output_parsedtext_format=MaintenanceRecord lets the SDK generate the JSON Schema and parse the returned value into the same class. The function stops before using the result if the response is incomplete or contains a refusal. On success, output_parsed is a MaintenanceRecord, not an untyped JSON string that you must pass through json.loads().
The model name above follows the current official documentation example. If your project does not have access to that model, select a Structured Outputs-compatible model available to your project and record the model name in your tests. Do not replace the model silently in production because extraction behavior can change.
Use the first note for a small test before processing the CSV:
note = (
"R001: The east reading-room lamp flickers, but the room remains open. "
"Replace the lamp during this week's routine visit."
)
record = extract_record(client, note)
print(record.model_dump_json(indent=2))The reproducible build used a deterministic offline client with the same responses.parse() interface, so it made no API request. It also exercised the incomplete-response, refusal, and missing-output paths. The first note produced this verified object:
{
"report_id": "R001",
"issue_area": "electrical",
"urgency": "low",
"blocks_service": false,
"next_action": "Replace the lamp during the routine visit."
}Read this from the types outward. The ID follows the required pattern. issue_area and urgency are allowed enum values. blocks_service is a Boolean, not the string "false". The action also passes its length limits.
A live model may phrase next_action differently while still passing the schema. If exact wording matters, use a closed enum or a separate normalization rule instead of relying on prose.
Once one row works, load the CSV and process each note independently. A failure in R008 should not remove the successful result for R007.
import csv
records = []
failures = []
with open("maintenance_notes.csv", encoding="utf-8", newline="") as handle:
for row in csv.DictReader(handle):
try:
records.append(extract_record(client, row["raw_note"]))
except Exception as exc:
failures.append({"report_id": row["report_id"], "error": str(exc)})
print(f"processed={len(records)} failed={len(failures)}")For a real service, catch the specific SDK and validation exceptions that your application knows how to handle. The broad Exception above keeps the first inspection loop short, but it should not hide a programming error in long-running production code.
The offline verification run executed all 15 notes and printed:
processed=15 failed=0
issue_area_counts: {'electrical': 4, 'water': 3, 'access': 3, 'temperature': 3, 'other': 2}
urgency_counts: {'low': 5, 'high': 6, 'medium': 4}These counts confirm that the file, loop, schema, and summary code ran together. They do not measure OpenAI model accuracy because the offline client returned the dataset’s expected teaching labels. For deterministic tests around an API boundary, see Test an LLM Pipeline Without API Calls.
The left chart shows that electrical notes are the largest issue group in this small dataset. The right chart shows six high-urgency records. These are descriptive counts from fictional data, not evidence about real facility operations or model performance.
Pydantic can reject a badly shaped value before it reaches a database. This payload has an invalid ID, an unknown urgency, and an extra key:
from pydantic import ValidationError
bad_payload = {
"report_id": "ticket-9",
"issue_area": "electrical",
"urgency": "urgent",
"blocks_service": False,
"next_action": "Check it.",
"unplanned_field": "not allowed",
}
try:
MaintenanceRecord.model_validate(bad_payload)
except ValidationError as exc:
print(exc.error_count(), "validation errors")3 validation errorsThe exact errors correspond to the ID pattern, the urgency enum, and the forbidden extra field. This is a useful boundary test because all three failures are structural and should be handled by code.
Now consider a record that changes R001 from low to high. It still follows the schema:
wrong_but_valid = record.model_copy(update={"urgency": Urgency.HIGH})
accepted = MaintenanceRecord.model_validate(wrong_but_valid.model_dump())
print("schema_accepts_semantic_mismatch:", accepted.urgency is Urgency.HIGH)schema_accepts_semantic_mismatch: TrueNothing in the schema knows that a flickering lamp in an open room should be low urgency. Test semantic quality with labelled examples, explicit business rules, and human review where mistakes have material consequences. A schema makes evaluation easier because every prediction has the same fields; it does not replace evaluation.
No parsed record is available. Check response.status for an incomplete response and inspect message content for a refusal, as extract_record() does. Keep the full response available for diagnosis, then decide whether to retry, revise the input, or send the record for review. Do not convert a refusal into an empty record.
A field is optional in Python but invalid for the API schema. Structured Outputs requires fields to be present. When “unknown” is a real state, define the field as nullable, for example location: str | None, and require the model to return either text or null.
The JSON is valid but the category is wrong. Schema adherence and extraction accuracy are separate measurements. Keep a labelled evaluation set that is different from examples used to design the prompt.
The schema is too broad. A plain str accepts many values. Prefer an enum when your program has a fixed vocabulary, and add clear field descriptions when categories are easy to confuse.
The batch stops on one row. Catch expected per-row failures, record the report ID and error type, and continue. Set a failure-rate limit so a broken release does not appear healthy merely because some rows succeeded.
The key is placed in source code. Remove it, rotate the exposed key, and load the replacement from an environment variable or secret manager. Never include the key in logs or screenshots.
Sensitive text is sent without review. Decide which data may leave your system before calling an external API. Remove unnecessary personal information, apply retention rules, and follow the requirements for your organization and region.
Structured JSON starts with a narrow typed contract. Define a Pydantic model, pass it to responses.parse(), and use output_parsed as the validated object your Python code consumes.
Keep three checks separate: API completion, schema validity, and semantic correctness. The first tells you whether a response arrived, the second tells you whether its shape is safe to process, and the third tells you whether its meaning is useful. Once those boundaries are explicit, you can add batch retries, labelled evaluations, and storage without rebuilding the extraction logic.