Lab 01 · Ticket classifier
Turn raw support tickets into typed Pydantic records with with_structured_output, and classify a whole batch at once without flooding the API.
What you will build
Northwind receives thousands of support tickets a day. Before any agent touches one, a model turns it into a typed record: category, urgency, sentiment, the order id if there is one, and a one-sentence summary.
The whole lab is one Pydantic schema, one with_structured_output call and one batch call that classifies six tickets with at most three requests in flight.
What you will learn
- How
with_structured_outputmakes a model return a validated Pydantic object instead of text - Why field descriptions are the per-field prompt the model reads
- How
ge,leandmax_lengthboth shape the JSON schema and validate the answer - How a
field_validatorcleans up the answers models really give ("N/A","none") - How
batchwithmax_concurrencyruns many calls in parallel without a burst of 429 errors
Check your prerequisites
You need three things on your machine and three values from your instructor.
- Python 3.11, 3.12 or 3.13. Check with the command below. On Windows, install it from python.org and tick Add python.exe to PATH.
- A code editor. VS Code with the Python extension works well.
- A terminal. PowerShell on Windows, Terminal on macOS or Linux.
python --versionpython3 --versionFrom your instructor you get an Azure OpenAI endpoint, an API key and a deployment name. Keep the key private: never paste it into code or share it in chat.
Create the project folder and virtual environment
Create a new folder for this lab, open it in a terminal, and create a virtual environment. A virtual environment is a private copy of Python for this project, so the libraries you install here never clash with other projects.
mkdir C:\labs\lab-01-ticket-classifier
cd C:\labs\lab-01-ticket-classifier
python -m venv .venv
.venv\Scripts\Activate.ps1mkdir -p ~/labs/lab-01-ticket-classifier
cd ~/labs/lab-01-ticket-classifier
python3 -m venv .venv
source .venv/bin/activateKeep lab folders on a short path such as C:\labs. Deep folders, for example inside OneDrive or Desktop, can exceed the Windows path limit. Some libraries then fail to load with The filename or extension is too long. OneDrive syncing can also lock files the labs write.
When activation works, your prompt starts with (.venv). Activate it again every time you open a new terminal for this lab. Open the folder in VS Code with code . if you like.
PowerShell says running scripts is disabled on this system? Run Set-ExecutionPolicy -Scope CurrentUser RemoteSigned, answer Y, and activate again.
This is what your folder will look like at the end of the lab:
Install the libraries
Create a file named requirements.txt in your lab folder with the content below. The versions are pinned to the ones this lab was tested with.
langchain==1.4.2
langchain-core==1.6.4
langchain-openai==1.6.2
python-dotenv==1.2.3
pydantic==2.13.5Pydantic is already a dependency of LangChain. It is listed here because your code imports it directly.
Then install everything into your virtual environment. It takes a minute or two.
python -m pip install --upgrade pip
pip install -r requirements.txtpython -m pip install --upgrade pip
pip install -r requirements.txtAdd your Azure OpenAI settings
Create a file named .env (the name starts with a dot) in your lab folder. Paste the template and replace the three placeholders with the values from your instructor.
# Azure OpenAI settings. Your instructor gives you these three values.
AZURE_OPENAI_ENDPOINT=https://<your-resource>.openai.azure.com/openai/v1
AZURE_OPENAI_API_KEY=<paste-your-key-here>
AZURE_OPENAI_DEPLOYMENT=<your-chat-deployment-name>
# Only used when your endpoint does NOT end in /openai/v1.
OPENAI_API_VERSION=2025-04-01-preview- AZURE_OPENAI_ENDPOINT is your resource URL. If it ends in
/openai/v1, keep it that way. If it is justhttps://<resource>.openai.azure.com/, paste that; the helper in the next step handles both. - AZURE_OPENAI_API_KEY is your secret key.
- AZURE_OPENAI_DEPLOYMENT is the deployment name, for example
gpt-5-mini. On Azure you call a deployment, not a model name.
Never commit .env to Git or share it. If you use Git, add a .gitignore file containing .env and .venv/.
Create the Azure OpenAI helper
Every lab talks to Azure OpenAI through one small helper file. Create model_setup.py and add the blocks below one after another. You write it once per lab folder; the lab code then only needs from model_setup import get_model.
Block 1Imports, .env loading and a console fix
load_dotenv() reads your .env file and puts every line into environment variables, so your key never appears in code.
The loop below it switches the console to UTF-8. Windows terminals default to an older encoding and crash when a model prints curly quotes or dashes.
"""Azure OpenAI model setup shared by every lab.
Import it in lab code as: from model_setup import get_model, get_embeddings
"""
import asyncio
import os
import sys
from dotenv import load_dotenv
from langchain_core.embeddings import Embeddings
from langchain_core.language_models import BaseChatModel
# Read AZURE_OPENAI_* values from the .env file next to your lab code.
load_dotenv()
# Windows consoles default to cp1252 and crash on the curly quotes models love to print.
for _stream in (sys.stdout, sys.stderr):
if hasattr(_stream, "reconfigure"):
_stream.reconfigure(encoding="utf-8", errors="replace")Block 2Read settings safely
_setting() fetches a value from the environment and stops with a friendly message if it is missing, instead of a long stack trace.
_uses_v1_api() checks the shape of your endpoint. Azure offers two:
https://<resource>.openai.azure.com/openai/v1is the newer OpenAI-compatible API.https://<resource>.openai.azure.com/is the classic API that needs anapi_version.
def _setting(name: str) -> str:
"""Return a required setting from .env, or stop with a readable message."""
value = os.getenv(name, "").strip()
if not value:
sys.exit(f"Missing {name} in your .env file. Open .env and fill it in.")
return value
def _uses_v1_api(endpoint: str) -> bool:
"""True for the OpenAI-compatible Azure endpoint that ends in /openai/v1."""
return endpoint.rstrip("/").endswith("/openai/v1")Block 3Build the chat model
get_model() returns a LangChain chat model connected to your deployment.
- With a
/openai/v1endpoint it usesChatOpenAIwithbase_url. On Azure the deployment name goes inmodel. - With a classic endpoint it uses
AzureChatOpenAIwithazure_deploymentandapi_version.
Either way the lab code just calls get_model() and never cares which one it got.
def get_model(deployment: str | None = None, **kwargs) -> BaseChatModel:
"""Return a chat model for your Azure OpenAI deployment."""
endpoint = _setting("AZURE_OPENAI_ENDPOINT")
api_key = _setting("AZURE_OPENAI_API_KEY")
name = deployment or _setting("AZURE_OPENAI_DEPLOYMENT")
if _uses_v1_api(endpoint):
from langchain_openai import ChatOpenAI
return ChatOpenAI(model=name, base_url=endpoint, api_key=api_key, **kwargs)
from langchain_openai import AzureChatOpenAI
return AzureChatOpenAI(
azure_deployment=name,
azure_endpoint=endpoint,
api_key=api_key,
api_version=os.getenv("OPENAI_API_VERSION", "2025-04-01-preview"),
**kwargs,
)Block 4Build the embeddings model
get_embeddings() does the same for embeddings, which turn text into vectors for search. Only the retrieval labs use it, but keeping one helper for every lab is simpler.
def get_embeddings(deployment: str | None = None) -> Embeddings:
"""Return an embeddings model for your Azure OpenAI embeddings deployment."""
endpoint = _setting("AZURE_OPENAI_ENDPOINT")
api_key = _setting("AZURE_OPENAI_API_KEY")
name = deployment or _setting("AZURE_OPENAI_EMBEDDING_DEPLOYMENT")
if _uses_v1_api(endpoint):
from langchain_openai import OpenAIEmbeddings
return OpenAIEmbeddings(model=name, base_url=endpoint, api_key=api_key)
from langchain_openai import AzureOpenAIEmbeddings
return AzureOpenAIEmbeddings(
azure_deployment=name,
azure_endpoint=endpoint,
api_key=api_key,
api_version=os.getenv("OPENAI_API_VERSION", "2025-04-01-preview"),
)Block 5A Windows fix for async code
use_stable_event_loop() matters only for the async MCP lab. The default Windows event loop can crash on exit after async network sessions close. On macOS and Linux it does nothing.
def use_stable_event_loop() -> None:
"""On Windows, use the Selector event loop so async MCP code exits cleanly."""
if sys.platform == "win32":
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())Check that the helper can reach your deployment with a one-line test:
python -c "from model_setup import get_model; print(get_model().invoke('Say hello in 5 words').text)"python -c "from model_setup import get_model; print(get_model().invoke('Say hello in 5 words').text)"You should see a short greeting. If you see an error instead, check the troubleshooting step at the end.
Write the ticket classifier
Structured output is what turns a model from a chatbot into a component. A TicketClassification object can be routed, stored in a database or checked in a test. A paragraph of text can only be shown to a human.
You describe the record you want as a Pydantic class. LangChain turns that class into a JSON schema, sends it with the request, and parses the answer back into an instance of your class.
Create an empty file named ticket_classifier.py in your lab folder. Then copy each block below into it, in order, one after another.
Block 1Imports
SystemMessageandHumanMessagebuild the conversation sent for each ticket.BaseModel,Fieldandfield_validatorcome from Pydantic and define the record.ValidationErroris what Pydantic raises when data breaks the schema rules.BaseChatModelandRunnableare only used as type hints.
"""Lab 01: classify a batch of Northwind support tickets into typed records.
Run: python ticket_classifier.py
"""
from enum import Enum
from langchain.messages import HumanMessage, SystemMessage
from langchain_core.language_models import BaseChatModel
from langchain_core.runnables import Runnable
from pydantic import BaseModel, Field, ValidationError, field_validator
from model_setup import get_modelBlock 2The category enum
A str Enum limits the model to six labels. In JSON each member is a plain string such as "billing", but in your code it is a typed value you can compare with Category.BILLING.
Keep the list short. Models choose badly from thirty labels, and other gives them a safe answer when nothing fits.
class Category(str, Enum):
"""Northwind support taxonomy. Keep it small: models pick badly from thirty labels."""
BILLING = "billing"
SHIPPING = "shipping"
TECHNICAL = "technical"
ACCOUNT = "account"
RETURNS = "returns"
OTHER = "other"Block 3The sentiment enum
Same pattern for the customer's tone. A support team can use it to put angry customers in front of a human first.
class Sentiment(str, Enum):
"""How the customer sounds. It decides whether a human reads the reply first."""
ANGRY = "angry"
FRUSTRATED = "frustrated"
NEUTRAL = "neutral"
POSITIVE = "positive"Block 4The record schema and its validator
Each Field(description=...) becomes part of the JSON schema the model sees, so the description is the instruction for that field. If the model keeps picking the wrong value, improve the description before you touch the system prompt.
ge=1, le=5limitsurgencyto 1 to 5, andmax_length=200caps the summary. These rules are sent to the model and checked on the answer.order_idis optional (str | None, defaultNone).- The
field_validatorruns before normal validation (mode="before"). Models often answer"N/A"or"none"instead of a real JSONnull, so the validator turns those intoNoneand upper-cases real ids.
class TicketClassification(BaseModel):
"""One support ticket as a typed record. Every description is a per-field prompt."""
category: Category = Field(description="The single best-fitting support category.")
urgency: int = Field(ge=1, le=5, description="1 = can wait a week, 5 = outage or money lost.")
sentiment: Sentiment = Field(description="The emotional tone of the customer.")
order_id: str | None = Field(
default=None, description="Order id like 'A-100', not an invoice number. Null if absent."
)
summary: str = Field(max_length=200, description="One sentence on what the customer needs.")
@field_validator("order_id", mode="before")
@classmethod
def blank_to_none(cls, value: str | None) -> str | None:
"""Models answer 'none' or 'N/A' far more often than a real JSON null."""
if value is None or value.strip().upper() in {"", "NONE", "NULL", "N/A"}:
return None
return value.strip().upper()Block 5The system prompt
The field descriptions already explain each field, so the system prompt only holds policy: never invent an order id, and keep urgency 5 for real emergencies.
SYSTEM_PROMPT = (
"You are the triage engine for Northwind support. Classify exactly one ticket into the "
"schema. Use only facts present in the ticket: never invent an order id. Urgency 5 is "
"reserved for outages, security issues and money already lost."
)Block 6Six sample tickets
A small batch that covers every category. Notice that the first ticket has an invoice number, not an order id. The field description tells the model not to confuse the two.
TICKETS = [
"My card was charged twice for invoice INV-9912. Fix this today!",
"Where is order A-100? It was supposed to arrive on Tuesday.",
"The export API returns 500 for every request since this morning.",
"Thanks for the quick replacement on A-118, works perfectly now.",
"I would like to return order A-205, the size is wrong.",
"Cannot log in, the password reset email never arrives.",
]Block 7Check the schema without a model
This function builds records by hand to show the rules working, with no API call:
" a-100 "is cleaned to"A-100"."N/A"becomesNone.urgency=9raises aValidationError, because the field only allows 1 to 5.
The same checks run on every answer the model sends back.
def check_schema() -> None:
"""Show the schema rules working locally, without calling the model."""
print("Schema checks (no model call):")
record = TicketClassification(
category="shipping", urgency=2, sentiment="neutral", order_id=" a-100 ", summary="Test."
)
print(f" order_id ' a-100 ' -> {record.order_id!r}")
record = TicketClassification(
category="shipping", urgency=2, sentiment="neutral", order_id="N/A", summary="Test."
)
print(f" order_id 'N/A' -> {record.order_id!r}")
try:
TicketClassification(category="billing", urgency=9, sentiment="angry", summary="Test.")
except ValidationError as error:
print(f" urgency=9 -> ValidationError: {error.errors()[0]['msg']}")
print()Block 8Build the classifier
model.with_structured_output(TicketClassification) returns a new runnable. You call it like the model, but it returns a TicketClassification object instead of a message.
The model is a parameter so a test can pass in a fake one.
Why no temperature=0? For classification you normally want temperature=0, so the same ticket gets the same label tomorrow. Reasoning deployments such as gpt-5-mini do not support a custom temperature and reject the request, so this lab leaves it out. With a non-reasoning model such as gpt-4.1-mini, use get_model(temperature=0).
def build_classifier(model: BaseChatModel | None = None) -> Runnable:
"""Wrap the chat model so every answer comes back as a TicketClassification."""
model = model or get_model()
return model.with_structured_output(TicketClassification)Block 9Classify the batch
check_schema()runs the offline checks first.- Each ticket becomes its own conversation: the system prompt plus the ticket.
classifier.batch(prompts, config={"max_concurrency": 3})sends one request per ticket with at most three in flight at a time. Results come back in input order, so they line up withTICKETS.
Without max_concurrency, batch fires every request at once. With a large queue that is how you get a storm of 429 Too Many Requests errors.
if __name__ == "__main__":
check_schema()
classifier = build_classifier()
prompts = [[SystemMessage(SYSTEM_PROMPT), HumanMessage(ticket)] for ticket in TICKETS]
# One call per ticket, at most three in flight, results in input order.
print("Classified tickets:")
for facts in classifier.batch(prompts, config={"max_concurrency": 3}):
print(
f"{facts.category.value:10} u{facts.urgency} {facts.sentiment.value:10} "
f"order={facts.order_id or '-':6} {facts.summary}"
)Run the classifier
python ticket_classifier.pypython3 ticket_classifier.pyYour output should look similar to this. Model answers vary a little from run to run, so the wording will not match exactly.
Schema checks (no model call):
order_id ' a-100 ' -> 'A-100'
order_id 'N/A' -> None
urgency=9 -> ValidationError: Input should be less than or equal to 5
Classified tickets:
billing u5 angry order=- Customer reports their card was charged twice for invoice INV-9912 and requests it be fixed today.
shipping u3 frustrated order=A-100 Customer asks for the location/status of order A-100, which was supposed to arrive on Tuesday but has not.
technical u5 neutral order=- The export API returns HTTP 500 for every request since this morning; please investigate and restore the service.
returns u1 positive order=A-118 Customer confirms the replacement for order A-118 works perfectly and thanks support.
returns u2 neutral order=A-205 Customer wants to return order A-205 because the item is the wrong size.
account u3 frustrated order=- User cannot log in because the password reset email never arrives; they need assistance to receive the reset email or regain account access.The first three lines come from check_schema() and need no model.
Then you get six classified lines, one per ticket, in the same order as TICKETS. Each line shows the category, the urgency (u1 to u5), the sentiment, the order id and the summary. The first ticket shows order=-: the model did not treat the invoice number as an order id. The exact urgency and sentiment values can vary a little from run to run.
Check your work
You are done when
- The schema checks print
'A-100',Noneand aValidationErrorforurgency=9. - Six classified tickets are printed, in the same order as
TICKETS. - The first ticket has no order id (INV-9912 is an invoice, not an order).
- Every field in
TicketClassificationhas a non-emptydescription.
Troubleshooting
Unsupported value: 'temperature' does not support 0
Your deployment is a reasoning model (gpt-5 family, o-series). Remove temperature=0 from get_model(...). Only non-reasoning models such as gpt-4.1 accept it.
The model keeps choosing other
Improve the enum values or the field description first. The model picks labels from the schema, so a vague description leads to vague labels.
A ValidationError stops the whole batch
The model returned a value that breaks the schema, for example a summary longer than 200 characters. Run again, or see the first Go further idea to log errors instead of raising them.
401 Unauthorized or 'Access denied due to invalid subscription key'
The key in .env is wrong or has spaces around it. Copy it again from your instructor, with no quotes.
404 'DeploymentNotFound' or 'The API deployment for this resource does not exist'
AZURE_OPENAI_DEPLOYMENT must be the deployment name exactly as your instructor gave it, and the endpoint must be the same resource.
429 Too Many Requests
The shared deployment is busy. Wait 30 seconds and run again.
ModuleNotFoundError: No module named 'model_setup' (or another package)
Run the command from inside your lab folder, with the virtual environment activated. Reinstall with pip install -r requirements.txt if a library is missing.
'python' is not recognized, or the wrong Python runs
Activate the virtual environment first. On Windows you can also try py -3.11 instead of python when creating the venv.
Go further
- Use
with_structured_output(TicketClassification, include_raw=True)and printparsing_errorandraw.usage_metadataper ticket. A malformed answer then becomes a logged metric instead of a crash in the middle of a batch. - Add
rate_limiter=InMemoryRateLimiter(requests_per_second=2)(fromlangchain_core.rate_limiters) toget_model(...)and watch the run time change while the concurrency stays the same. - Replace the Pydantic class with a
TypedDictand see what you lose (validators, defaults, enum objects) and what you gain (a plain dict).
Stuck?
Download the finished project and compare it with yours. It contains every file from this guide except .env and .venv, so you still add your own settings.