Structured Output (JSON Extraction)
Ask an LLM for a Person and get back %{name: "Alice", age: 30, email: "alice@..."}, typed and validated, every time.
ReqLLM.generate_object/3 takes a schema and forces the model to return JSON that matches it. No “regex the markdown out of the response”. No retries because the model wrapped the JSON in a Markdown code fence. The provider does the structured-output dance and ReqLLM hands you a map.
This is what you reach for when you’re extracting data from emails, normalising user-submitted free text, or replacing a brittle regex-based parser.
defmodule MyApp.AI.Extractors.PersonExtractor do
@moduledoc "Extract structured Person data from free-form text."
@model "openai:gpt-4o-mini"
@schema [
name: [type: :string, required: true, doc: "Full name"],
age: [type: :integer, doc: "Age in years, if mentioned"],
email: [type: :string, doc: "Email address, if present"],
role: [type: :string, doc: "Job title or role, if mentioned"]
]
def extract(text) do
ReqLLM.put_key(:openai_api_key, System.get_env("OPENAI_API_KEY"))
messages = [
%{role: "system", content: "Extract person details from the text. Use null for missing fields."},
%{role: "user", content: text}
]
case ReqLLM.generate_object(@model, messages, @schema) do
{:ok, response} ->
{:ok, ReqLLM.Response.object(response)}
{:error, error} ->
{:error, inspect(error)}
end
end
end
iex> MyApp.AI.Extractors.PersonExtractor.extract("""
...> Met Sarah Chen at the Phoenix conf in Lisbon. She's 34, runs
...> infra at Fly.io, reach her at sarah@fly.io.
...> """)
{:ok, %{name: "Sarah Chen", age: 34, role: "Infrastructure lead", email: "sarah@fly.io"}}
defmodule MyApp.AI.Extractors.InvoiceExtractor do
@moduledoc "Nested schemas: pull line items out of a PDF transcript."
@model "anthropic:claude-sonnet-4-6"
@line_item_schema [
description: [type: :string, required: true],
quantity: [type: :integer, required: true],
unit_price_cents: [type: :integer, required: true]
]
@schema [
vendor: [type: :string, required: true],
invoice_number: [type: :string, required: true],
issued_on: [type: :string, doc: "ISO 8601 date"],
line_items: [type: {:list, {:map, @line_item_schema}}, required: true],
total_cents: [type: :integer, required: true]
]
def extract(invoice_text) do
ReqLLM.put_key(:anthropic_api_key, System.get_env("ANTHROPIC_API_KEY"))
ReqLLM.generate_object(@model, invoice_text, @schema)
end
end
def deps do
[
{:req, "~> 0.5"},
{:req_llm, "~> 1.0"}
]
end
When to reach for this
- Replacing a regex-based parser that breaks every time someone phrases things differently.
- Extracting fields from inbound emails, scanned PDFs, or user-submitted free text.
-
Normalising data before it hits an Ecto changeset. Pair with
validate_required/2on the way in. - Provider-specific tip: OpenAI's structured output is rock-solid. Anthropic does it well via tool calling. Gemini's JSON mode is improving fast.