Tutorial
Python Structured JSON Extraction with OpenAI
Build a Python command-line tool that loads travel-journal files, requests JSON-shaped extraction from an OpenAI chat model, validates every response with Pydantic, and writes a reusable report.
What You Will Build
This tutorial builds travel_journal_analyzer, a small but production-minded Python application. It accepts a text, Markdown, CSV file, or a directory containing those formats. It converts each source into a consistent JournalEntry, sends the entry to an OpenAI chat model, validates the returned JSON locally, and writes one JSON report for downstream software.
The report has a deliberately narrow purpose: identify cities mentioned in an entry, explicitly named restaurants, dishes, ratings where stated, sentiment, practical tips, and a concise summary. The application does not treat a valid JSON shape as proof that a claim is true. Its prompt instructs the model to extract facts only from the supplied entry, while local validation checks the data contract before results are exported.
Reliable structured extraction combines two boundaries. The first is the requested JSON schema: it defines the fields an application expects. The second is local validation with Pydantic: it rejects malformed values, such as a rating outside a five-point range. Research on structured generation describes the importance of reliable, typed output for applications that need predictable data rather than unconstrained prose. In practical Python work, these boundaries make testing and maintenance substantially easier.
Prerequisites and Setup
- Python 3.10 or later.
- An OpenAI API key and access to a chat model configured through an environment variable.
- Basic familiarity with a terminal, files, and Python functions.
Create a project and isolated virtual environment:
mkdir travel-journal-analyzer
cd travel-journal-analyzer
python -m venv .venv
# macOS or Linux
source .venv/bin/activate
# Windows PowerShell
# .venv\Scripts\Activate.ps1
python -m pip install --upgrade pip
python -m pip install "openai>=1.0.0" "pydantic>=2.7.0" "python-dotenv>=1.0.1" "pytest>=8.0.0"
mkdir data output testsCreate .env. Keep this file out of source control. API keys belong in environment variables locally and in a deployment secret manager in production.
OPENAI_API_KEY=your-api-key
OPENAI_MODEL=your-chat-model
MAX_ENTRY_CHARACTERS=12000
REQUEST_TIMEOUT_SECONDS=45Create .gitignore:
.env
.venv/
__pycache__/
.pytest_cache/
output/
*.pycThis tutorial uses the modern client-based OpenAI Python pattern: from openai import OpenAI, then client.chat.completions.create(...). Do not use legacy module-level completion calls.
Step 1: Define the Data Contract
Create models.py. Nullable fields represent facts that the source did not provide. That is preferable to inventing a city, cuisine, or rating.
from __future__ import annotations
from typing import Literal
from pydantic import BaseModel, Field, field_validator
class JournalEntry(BaseModel):
source_name: str = Field(min_length=1, max_length=255)
entry_id: str = Field(min_length=1, max_length=100)
text: str = Field(min_length=1)
@field_validator("text")
@classmethod
def validate_text(cls, value: str) -> str:
cleaned = value.strip()
if not cleaned:
raise ValueError("Journal entry text cannot be blank.")
return cleaned
class RestaurantFinding(BaseModel):
name: str = Field(min_length=1, max_length=200)
city: str | None = Field(default=None, max_length=120)
country: str | None = Field(default=None, max_length=120)
cuisine: str | None = Field(default=None, max_length=120)
dishes: list[str] = Field(default_factory=list)
rating_out_of_five: float | None = Field(default=None, ge=0, le=5)
sentiment: Literal["positive", "neutral", "negative"]
recommendation_reason: str = Field(min_length=1, max_length=600)
class JournalAnalysis(BaseModel):
entry_id: str = Field(min_length=1, max_length=100)
cities_mentioned: list[str] = Field(default_factory=list)
restaurants: list[RestaurantFinding] = Field(default_factory=list)
travel_tips: list[str] = Field(default_factory=list)
concise_summary: str = Field(min_length=1, max_length=1000)
class AnalysisReport(BaseModel):
generated_at_utc: str
model: str
total_entries: int = Field(ge=0)
successful_analyses: int = Field(ge=0)
failed_entries: list[str] = Field(default_factory=list)
analyses: list[JournalAnalysis] = Field(default_factory=list)The models are not merely documentation. JournalAnalysis.model_validate_json() turns the model response into a validated object. A response with an invalid sentiment label or a six-point rating fails before it reaches a database, spreadsheet, or customer-facing interface.
Step 2: Load Text and CSV Inputs
Create journal_loader.py. Plain-text and Markdown files create one entry each. A CSV file requires a text column and creates one entry for every non-empty row. The character limit prevents unexpectedly large requests.
from __future__ import annotationsimport csv
from pathlib import Path
from models import JournalEntrySUPPORTED_SUFFIXES = {".txt", ".md", ".csv"}def load_journal_entries(path_value: str, max_characters: int) -> list[JournalEntry]:
path = Path(path_value).expanduser().resolve()
if not path.exists():
raise FileNotFoundError(f"Input path does not exist: {path}")if path.is_dir():
entries: list[JournalEntry] = []
for child in sorted(path.iterdir()):
if child.is_file() and child.suffix.lower() in SUPPORTED_SUFFIXES:
entries.extend(load_journal_entries(str(child), max_characters))
if not entries:
raise ValueError("Directory contains no supported input files.")
return entriesif path.suffix.lower() in {".txt", ".md"}:
text = path.read_text(encoding="utf-8").strip()
_check_length(text, path.name, max_characters)
return [JournalEntry(source_name=path.name, entry_id=path.stem, text=text)]if path.suffix.lower() != ".csv":
raise ValueError("Use a .txt, .md, .csv file, or directory.")entries = []
with path.open("r", encoding="utf-8-sig", newline="") as handle:
reader = csv.DictReader(handle)
if not reader.fieldnames or "text" not in reader.fieldnames:
raise ValueError("CSV must contain a column named 'text'.")
for row_number, row in enumerate(reader, start=2):
...Continue Reading
Log in for free to read the rest of this article and access exclusive AI tools.
Log in / Register