Trail content
9 minEnglish narration, Portuguese subtitlesPodcast (RSS)
Chapters
  1. 0:00Opening
  2. 1:06The idea
  3. 1:25Before and after
  4. 2:34Why it works
  5. 3:28A challenge
  6. 4:23Safe mechanics
  7. 6:08Risks
  8. 7:26Beyond extraction
  9. 7:57Wrap-up
Transcript

Opening

A logistics company's contract team asked the model: list the main data from this file. And the answer came back as a friendly little paragraph.

The contract is with a carrier called Aurora. Total amount, twelve thousand four hundred dollars. Starts in January, automatic renewal, twelve months. A regular expression pulled out the total. And it worked for weeks.

Then one day the model wrote the amount in words. Twelve thousand four hundred dollars, spelled out.

And for the start date, just January.

The system stored the total as empty, and the start date as empty. Nobody noticed until finance reconciled the next month.

And the parser also let through things that only looked right. A contract ending in one year and starting in the next went in, because the text had both dates, and the regular expression grabbed both. In this lesson, you'll do the refactoring that ends this, safely, with a measurement before and after.

The idea

The solution has three moves.

Define a schema for what this step outputs.

Ask the model to answer in that schema, using the API's structured output feature. Not by asking in the prompt.

And validate the result in code, before it moves on.

Before and after

Before, the format is a convention nobody wrote down. The model writes text, a regular expression guesses, and whatever it finds goes into the system. Including nothing.

After, the format is a contract, checked at both ends. Same extraction call, now with a schema attached.

The API returns an object that follows the schema. Right types, required fields present.

Code checks the rules a schema can't express, like dates that make sense together.

And only valid data reaches the destination. Failures go to review, with the reason attached.

Here's the same contract, now as an object. The tax ID, digits only, because the schema asked for that. The total as an integer, in cents. Dates in a fixed format. Automatic renewal, true or false.

No twelve thousand in words. And if the file has no end date, that field comes back empty, explicitly. Not as a phrase like open ended term that the old parser never expected.

Why it works

With free text, the format is an implicit promise. The model usually writes it one way, and your code counts on that. With structured output, the format becomes part of the call. The API constrains what the model can generate, token by token, so the answer always closes into valid data with the required fields.

The schema also improves extraction. Clear field names, types and descriptions work as instructions. Total in cents, as an integer, says much more than the total amount. The model doesn't have to decide whether to write dollars, cents, or the number in words.

And the error moves. Instead of an empty field that shows up weeks later, the failure appears right away, in the step that failed. Reviewers get the object and the reason. Not a paragraph to reread.

A challenge

Here's one for you. Your schema has a required penalty field, an integer. But half the contracts in the folder have no penalty clause at all. The eval shows a penalty filled in on those cases anyway, almost always zero, or a number copied from another field. What do you do?

Make it optional, and validate in code whatever comes back filled in. A required field on a document that sometimes lacks the data pushes the model to invent it.

Telling the prompt to use zero when there's no penalty makes zero mean two things. A zero penalty, and a missing penalty. The next step can't tell them apart. And dropping the field throws away the half of the contracts where legal really needs it. Absence is modeled with optional, not by deleting the field.

Safe mechanics

Now the mechanics, in an order that keeps you safe.

Step zero is measuring. Build an eval from real documents, with the expected value for each field. Run the current version, and record accuracy per field and the empty field rate. A field that already comes back empty twenty percent of the time, because the regular expression failed, is the first one to benefit.

Then list what the next step really uses. If the system only stores the tax ID, the total, the dates and the renewal, those are your fields. Summaries, adjectives and main obligations go away.

Write the schema with strong types. Integers for cents, real dates, closed lists. And a short description on each field. That description is the field's prompt.

And decide honestly what's optional. Don't let the model invent an end date for a contract with no end.

Then swap the call for the structured version, and delete the old parser. While both exist, someone will just fix the regular expression one more time.

Add business validation in code. Line items that add up to the total. An end date after the start date. Tax ID check digits.

Send failures to review, with the object and the reason, instead of saving partial data.

And measure again. Accuracy per field should hold or rise. Empty fields caused by parsing should drop to zero. Reading errors still exist, like the model reading twelve thousand four hundred as one thousand two hundred forty. But now they show up in validation, not weeks later.

Risks

A few risks to watch. A schema that forces invention. We just saw it with the penalty.

A schema that's too big. Dozens of nested fields, and quality drops. The model spreads its attention and starts filling in by guesswork, usually in the fields in the middle. If the document has two parts, like the cover and the annexes, split it into two calls, each with what the next step actually uses.

Trusting only the schema. Valid data can still be wrong. A total of one cent is a perfectly legal integer, and an absurd contract. Sum the line items in code, and send it to review when the numbers don't match.

And some rules the provider's structured mode won't accept, like certain patterns. That's fine. Ask for a plain string, and validate the fourteen digits in code, right after the call.

And the most common mistake. Swapping the regular expression for structured output, and leaving the total typed as a string. The model is still free to return the amount with a currency symbol, as a plain number, or in words. You just moved the parser into a field with a nice name. A strong type is what closes the door.

Beyond extraction

This refactoring isn't only for files. Picture two agents sharing a block of text, a kind of process scratchpad. One writes: customer ok, proceed. The other reads that as a credit approval.

Same cure. The scratchpad becomes an object, with a field like credit status, from a closed list of values, validated before the next agent reads it. Prose in the middle becomes a contract, and the second agent stops interpreting okay.

Wrap-up

So here's the idea to take with you. The parser that guesses is not a bug you fix one more time. It's a contract nobody wrote. Measure, write the contract as a schema with strong types, validate what a schema can't express, and delete the parser for good.

In the next lesson, we look at the same extraction from another angle: the model doing math and rules that code would get right every time. LLM doing deterministic work. See you there.

RefactoringOrganizing data

Replace Free Text with Structured Output

Trocar Texto Livre por Saída Estruturada

Replace Free Text with Structured Output is an AI refactoring for LLM systems, in the Organizing data group. Define a schema for the step's output and validate it before moving on.

Element
15/35
Level
Beginner
Read
4 min
Updated
On this page 8
  1. 01Problem
  2. 02Solution
  3. 03Before and after
  4. 04Why it works
  5. 05Safe mechanics
  6. 06Risks
  7. 07Challenge
  8. 08Check your understanding
  9. ··Quick answers

Problem

One step hands loose text to the next, which has to guess where each piece of data is. This is the Free-Text Handoff smell, and also Shared Scratchpad when several agents write prose to the same place.

A logistics company’s contract team asked the model: “List the main data from this PDF”. The answer came back like this:

The contract is with Transportadora Aurora Ltda (CNPJ 12.345.678/0001-90).
Total amount: R$ 12,400.00. Starts on 03/01/2025, automatic renewal.
Term: 12 months.

CNPJ is Brazil’s company tax id (14 digits). A regex extracted the total. It worked for weeks. The day the model wrote “twelve thousand four hundred reais” and “start: January 2025”, the ERP stored total = None and start_date = None. Nobody noticed until finance reconciled the next month.

The parser also accepted what looked right. A contract ending in 2024 and starting in 2025 went through, because the text had both dates and the regex grabbed both. The inconsistency only showed up when legal tried to terminate.

Solution

Define a schema for the step’s output, ask the model to answer in that schema using the API’s structured output feature, and validate the result before moving on.

Before and after

-summary = llm("List the main data from the contract.", pdf_text)
-match = re.search(r"Total:\s*R\$\s*([\d\.,]+)", summary)
-total = parse_brl(match.group(1)) if match else None
+class Contract(BaseModel):
+    supplier_cnpj: str = Field(pattern=r"^\d{14}$")
+    total_cents: int
+    start_date: date
+    end_date: date | None
+    auto_renewal: bool
+
+contract = llm_parse("Extract the contract data.", pdf_text, schema=Contract)
+if contract.end_date and contract.end_date < contract.start_date:
+    raise InvalidExtraction("end before start")
+erp.save(contract)

Before: the format is a convention nobody wrote down.

flowchart LR
  A(["1. Extract data"]):::llm --> T["2. Free text"]:::tool
  T --> R["3. Regex"]:::tool
  R --> E[("4. ERP")]:::store

After: the format is a contract, checked at both ends.

flowchart LR
  A(["1. Extract data"]):::llm --> T["2. Object in the schema"]:::tool
  T --> R["3. Business validation"]:::tool
  R --> E[("4. ERP")]:::store
  R -->|"failed"| Q((("Review"))):::human
  1. The same extraction call, now with a schema attached.
  2. The API returns an object that follows the schema: right types, required fields present.
  3. Code checks rules the schema cannot express, such as coherent dates.
  4. Only valid data reaches the destination. Failures go to review, with the reason.

The same contract, two returns

With free text, the next step still has to guess. With the schema, the object arrives ready:

{
  "supplier_cnpj": "12345678000190",
  "total_cents": 1240000,
  "start_date": "2025-01-03",
  "end_date": "2026-01-02",
  "auto_renewal": true
}

There is no “R$ 12,400.00” and no “twelve thousand”. The CNPJ came as digits only, because the schema asked for that. If the PDF has no end date, end_date is null, not a phrase like “open-ended term” that the old parser did not expect.

Why it works

With free text, the format is an implicit promise: the model usually writes it one way, and code counts on that. With structured output, the format becomes part of the call. The API constrains what the model can generate (constrained decoding: the next token is only valid if it continues JSON that can still close against the schema). The JSON is always valid and always has the fields the schema requires.

The schema also improves extraction. Clear field names, types, and descriptions work as instructions: total_cents: int says more than “the total amount”. The model does not have to decide whether to write reais, cents, or “twelve thousand”.

The error moves. Instead of a None that shows up in the ERP weeks later, the failure appears right away, in the step that failed. An inverted date never gets saved. A punctuated CNPJ fails the pattern. Reviewers get the object and the reason, not a paragraph to reread.

The destination pattern is Structured Output. This refactoring is the path to it, starting from a parser that already exists and already breaks.

Check +20 XP on first tryYour team's parser looks for 'Total: R$' in the text the model returns. Last week the model wrote 'Global amount: 12,400 reais' and the ERP stored empty. Which change attacks the cause, not the symptom?

Safe mechanics

  1. Measure. Build an eval from real documents with the expected value per field. Run the current version and record accuracy per field and the empty-field rate. A field that already comes back empty 20% of the time (because the regex failed) is the first to benefit.
  2. List what the next step really uses. If the ERP only stores CNPJ, total, dates, and renewal, those are the fields. Summary snippets, adjectives, and “main obligations” go.
  3. Write the schema with strong types (integer cents, date, enum) and a short description on each field. The description is the field’s prompt.
  4. Decide what is optional. A field that may be missing from the document becomes optional and explicit. Do not let the model invent an end_date for an open-ended contract.
  5. Swap the call for the structured output version and delete the old parser. While both exist, someone will “just fix the regex one more time”.
  6. Add business validation in code: line items summing to the total, end date after start date, CNPJ check digits.
  7. Send failures to review with the object and the reason, instead of saving partial data.
  8. Measure again. Accuracy per field should hold or rise, and empty fields caused by parsing errors should drop to zero. Reading errors (the PDF says 12,400 and the model extracts 1,240) still exist: they now show up in validation, not in the regex.

Risks

  • A schema that forces invention. A required field that does not exist in the document pushes the model to make up a value. Use optional, or an explicit null with an end_date_reason field.
  • A schema that is too big. Dozens of nested fields lower quality: the model spreads attention and starts filling by guesswork. Split into two calls if the document has two parts (cover and annexes, for example).
  • Trusting only the schema. Valid JSON can hold a wrong value. total_cents: 1 is a legal integer and an absurd contract. Business validation is still needed.
  • Unsupported schema features. Some constraints (certain formats, numeric limits, elaborate regex) may not be accepted by the provider’s structured mode. Validate those rules in code, after the object arrives.

Common mistake: a string that “looks” structured

Swapping the regex for llm_parse and leaving total: str does not fix it. The model is still free to return "R$ 12,400.00", "12400", or "twelve thousand". You only moved the parser into a nicely named field. A strong type is what closes the door.

Check +20 XP on first tryThe schema has a required penalty_cents: int. Half the contracts in the folder have no penalty clause. The eval shows a penalty filled in on those cases, almost always 0 or a value copied from another field. What do you do?

Challenge

This schema will produce bad data. Find two problems.

class Invoice(BaseModel):
    total: str
    date: str
    customer: str
    discount: float  # required
Show solution

total and date as strings bring back the format problem (“R$ 1,200.00”, “03/12/26”). A required discount forces the model to invent a discount when the invoice has none.

class Invoice(BaseModel):
    total_cents: int = Field(description="Invoice total in cents")
    issue_date: date
    customer_cnpj: str = Field(description="The 14 digits only")
    discount_cents: int | None = Field(description="Null if the invoice has no discount")

Check your understanding

Check +20 XP on first tryExtraction returns valid JSON in the schema, with end_date after start_date. The extracted total is 124,000 cents; the PDF line items sum to 1,240,000 cents. The schema let it through. What is the next step?

Check +20 XP on first tryYou have a schema with 48 nested fields for an 8-page contract. Accuracy on the middle fields dropped after the swap. What do you do first?

Check +20 XP on first tryTwo agents share a text block called 'process scratchpad'. One writes 'customer ok, proceed'; the other reads that as a credit approval. Does this refactoring help?

Check +20 XP on first tryThe provider rejects the CNPJ pattern in structured mode. Which design still keeps a punctuated CNPJ out of the ERP?

Quick answers

What is Replace Free Text with Structured Output?

Replace Free Text with Structured Output is an AI refactoring for LLM systems, in the Organizing data group. Define a schema for the step's output and validate it before moving on.

What problem does Replace Free Text with Structured Output solve?

It treats Free-Text Handoff and Shared Scratchpad.

Which pattern does Replace Free Text with Structured Output lead to?

The Structured Output design pattern.

Go further

Relations

Target patterns 1
Shows up in scenarios 1
Part of the tracks 1
Search