Step 1: Describe the shape of an order
Brightline's suppliers confirm orders by email, each in its own layout
(open the emails/ folder). The stock system needs the same fields from
every one: supplier, order reference, currency, delivery date and the
lines ordered. In the Files and JSON lab you checked each field by hand
with if statements. With more fields that gets long and easy to get
wrong. Pydantic lets you declare the shape once and checks it for
you.
A Pydantic model is a class whose attributes are fields with types:
class Line(BaseModel):
quantity: int = Field(gt=0) # an integer greater than 0
Line.model_validate(some_dict) returns a Line if the data fits, and
raises a ValidationError listing every problem if it does not. The
same types will become the JSON schema you show the model in Step 3.
1. Fill in Line: isbn: str, title: str with Field(min_length=1),
quantity: int with Field(gt=0), unit_price: float with Field(ge=0).
2. Fill in BookOrder:
supplier: str = Field(min_length=1)order_ref: str = Field(pattern=r"^[A-Z]{2}-\d{5}$"): two capitals, a hyphen, five digitscurrency: Literal["GBP", "EUR", "USD"]: only those three valuesdelivery_date: Optional[date] = None: a date, orNonewhen the email has nonelines: list[Line] = Field(min_length=1): at least oneLine
3. Run. Four dictionaries go through BookOrder.model_validate. Read
the errors: each names the field and the rule. And notice the last one:
the quantity "4" arrives as text and is accepted as the number 4.
Pydantic converts obvious cases by default (strict=True turns that off).
orders.py, the file you edit89 lines
"""Supplier emails in, validated BookOrder objects out."""
import json
import re
from datetime import date
from pathlib import Path
from typing import Literal, Optional
from openai import OpenAI
from pydantic import BaseModel, Field, ValidationError, computed_field, field_validator
CLIENT = OpenAI()
MODEL = "meta/llama-3.1-8b-instruct"
# ---------- Step 1 + 2: the shape of an order, and the rules it must obey ----------
class Line(BaseModel):
# TODO (Step 1): four fields with types and constraints:
# isbn: str
# title: str, at least 1 character -> Field(min_length=1)
# quantity: int, greater than 0 -> Field(gt=0)
# unit_price: float, 0 or more -> Field(ge=0)
pass
class BookOrder(BaseModel):
# TODO (Step 1):
# supplier: str, at least 1 character
# order_ref: str matching two capital letters, a hyphen, five digits -> Field(pattern=r"^[A-Z]{2}-\d{5}$")
# currency: one of "GBP", "EUR", "USD" -> Literal["GBP", "EUR", "USD"]
# delivery_date: a date, or None when unknown (default None) -> Optional[date] = None
# lines: a list of Line with at least one item -> list[Line] = Field(min_length=1)
pass
# ---------- Step 3: ask for JSON that matches the schema ----------
SYSTEM = ("You extract purchase orders from supplier emails. Reply with one JSON object that matches this JSON "
"schema, and nothing else. Use null for a delivery date the email does not give. Copy ISBNs, "
"references and prices exactly as written.\n\nSchema:\n")
def json_block(text):
"""The outermost {...} in a reply (models sometimes wrap JSON in fences or chatter)."""
start, end = text.find("{"), text.rfind("}")
if start == -1 or end < start:
raise ValueError(f"no JSON object in the reply: {text[:80]!r}")
return text[start:end + 1]
def extraction_messages(email):
"""The two messages that start an extraction: the system prompt with the schema, then the email."""
return [
{"role": "system", "content": SYSTEM + json.dumps(BookOrder.model_json_schema())},
{"role": "user", "content": email},
]
def extract(email, client=None):
"""One attempt: return a validated BookOrder (raises ValidationError / ValueError if the reply is bad)."""
client = client or CLIENT
raise NotImplementedError("extract() arrives in Step 3")
# ---------- Step 4: when validation fails, show the model the errors ----------
def extract_with_retry(email, attempts=3, client=None):
"""Return (order, attempts_used). After a failed attempt, send the model its own reply and the
validation errors, and ask for corrected JSON. Raise the last error if every attempt fails."""
client = client or CLIENT
raise NotImplementedError("extract_with_retry() arrives in Step 4")
def describe_error(err):
"""One readable line for any extraction error."""
if isinstance(err, ValidationError):
return "; ".join(f"{'.'.join(map(str, e['loc']))}: {e['msg']}" for e in err.errors())
return str(err)
# ---------- Step 5: a valid object can still be wrong ----------
def ungrounded(order, email):
"""Values in the order that do not appear in the email: each ISBN (digits only) and the order_ref.
Return a list of short problem descriptions; an empty list means everything was found."""
raise NotImplementedError("ungrounded() arrives in Step 5")
def process(folder, out_path, client=None):
"""Extract every email in folder; write a JSON list of {"file", "ok", "attempts", "order" | "error"}."""
raise NotImplementedError("process() arrives in Step 5")emails/01-harbour.txtemails/02-pelican.txtemails/03-atlas.txtemails/04-harbour.txtemails/05-pelican.txtemails/06-atlas.txtemails/07-harbour.txtemails/08-harbour.txttry_it.py