Skip to content

Data Extraction

View as Markdown

This example extracts structured data from unstructured text, using the schema to validate what comes back.

You receive data in various unstructured formats (emails, forms, documents) and need to:

  1. Extract specific fields reliably
  2. Normalize data to a consistent format
  3. Validate extracted values
  4. Handle missing or ambiguous data gracefully
import { Nua, z } from 'nuabase';
import { findLocalAgent } from 'nuabase/local-agent';
const nua = Nua.direct({ localAgent: await findLocalAgent() });
// Define a strict address schema
const AddressSchema = z.object({
street: z.string(),
apartment: z.string().optional(),
city: z.string(),
state: z.string().length(2),
zipCode: z.string().regex(/^\d{5}(-\d{4})?$/),
});
// Unstructured address inputs
const rawAddresses = [
"Please ship to 123 Main Street, Apt 4B, Springfield, Illinois 62701",
"My address is 456 Oak Ave Chicago IL",
"Send it to: 789 Pine Road, Suite 100, New York, NY 10001-1234",
"deliver to 321 Elm St., Los Angeles California 90210",
];
async function extractAddresses() {
for (const raw of rawAddresses) {
const result = await nua.get(
`Extract the mailing address from the text. Normalize the state to a 2-letter code. If zip code is missing, use "00000" as placeholder.`,
{
input: raw,
output: { name: 'address', schema: AddressSchema }
}
);
console.log(`Input: "${raw}"`);
if (result.success) {
const addr = result.data;
console.log(` → ${addr.street}${addr.apartment ? `, ${addr.apartment}` : ''}`);
console.log(` ${addr.city}, ${addr.state} ${addr.zipCode}`);
} else {
console.log(` → Error: ${result.error}`);
}
console.log();
}
}
extractAddresses();

From a run on Claude Code (claude-code:claude-opus-5-5), with the default logger’s [INFO] lines left out:

Input: "Please ship to 123 Main Street, Apt 4B, Springfield, Illinois 62701"
→ 123 Main Street, Apt 4B
Springfield, IL 62701
Input: "My address is 456 Oak Ave Chicago IL"
→ 456 Oak Ave
Chicago, IL 00000
Input: "Send it to: 789 Pine Road, Suite 100, New York, NY 10001-1234"
→ 789 Pine Road, Suite 100
New York, NY 10001-1234
Input: "deliver to 321 Elm St., Los Angeles California 90210"
→ 321 Elm St.
Los Angeles, CA 90210
const ContactSchema = z.object({
name: z.object({
first: z.string(),
last: z.string(),
title: z.string().optional(),
}),
email: z.string().optional(),
phone: z.string().optional(),
company: z.string().optional(),
role: z.string().optional(),
});
const emailSignatures = [
`Best regards,
Dr. Jane Smith
Chief Technology Officer
Acme Corporation
jane.smith@acme.com
(555) 123-4567`,
`Thanks,
Mike Johnson | Senior Developer
mike@startup.io`,
`Cheers,
Lisa Park
lisa.park@agency.co | 555.987.6543
Creative Director at BigAgency`,
];
async function extractContacts() {
const contacts = [];
for (const signature of emailSignatures) {
const result = await nua.get(
"Extract contact information from this email signature",
{
input: signature,
output: { name: 'contact', schema: ContactSchema }
}
);
if (result.success) {
contacts.push(result.data);
}
}
console.log('Extracted Contacts:');
console.log(JSON.stringify(contacts, null, 2));
}
extractContacts();

Output, from the same run:

Extracted Contacts:
[
{
"name": {
"first": "Jane",
"last": "Smith",
"title": "Dr."
},
"email": "jane.smith@acme.com",
"phone": "(555) 123-4567",
"company": "Acme Corporation",
"role": "Chief Technology Officer"
},
{
"name": {
"first": "Mike",
"last": "Johnson"
},
"email": "mike@startup.io",
"role": "Senior Developer"
},
{
"name": {
"first": "Lisa",
"last": "Park"
},
"email": "lisa.park@agency.co",
"phone": "555.987.6543",
"company": "BigAgency",
"role": "Creative Director"
}
]

Optional fields with nothing to fill them, such as Mike’s phone and company, are left out rather than filled with empty strings.

const LineItemSchema = z.object({
description: z.string(),
quantity: z.number().positive(),
unitPrice: z.number().nonnegative(),
total: z.number().nonnegative(),
});
const InvoiceSchema = z.object({
invoiceNumber: z.string(),
date: z.string(),
vendor: z.object({
name: z.string(),
address: z.string().optional(),
}),
lineItems: z.array(LineItemSchema),
subtotal: z.number(),
tax: z.number().optional(),
total: z.number(),
});
const invoiceText = `
INVOICE #INV-2024-0042
Date: January 15, 2024
From: TechSupplies Inc.
123 Business Park, Suite 400
San Francisco, CA 94102
Items:
- Wireless Mouse (x10) @ $25.00 each = $250.00
- USB-C Hub (x5) @ $45.00 each = $225.00
- Monitor Stand (x3) @ $89.99 each = $269.97
Subtotal: $744.97
Tax (8.5%): $63.32
Total Due: $808.29
`;
async function extractInvoice() {
const result = await nua.get(
"Extract all invoice data from this document",
{
input: invoiceText,
output: { name: 'invoice', schema: InvoiceSchema }
}
);
if (result.success) {
const inv = result.data;
console.log(`Invoice: ${inv.invoiceNumber}`);
console.log(`From: ${inv.vendor.name}`);
console.log(`Date: ${inv.date}`);
console.log(`\nLine Items:`);
for (const item of inv.lineItems) {
console.log(` ${item.description}: ${item.quantity} x $${item.unitPrice} = $${item.total}`);
}
console.log(`\nSubtotal: $${inv.subtotal}`);
console.log(`Tax: $${inv.tax}`);
console.log(`Total: $${inv.total}`);
}
}
extractInvoice();

Output:

Invoice: INV-2024-0042
From: TechSupplies Inc.
Date: January 15, 2024
Line Items:
Wireless Mouse: 10 x $25 = $250
USB-C Hub: 5 x $45 = $225
Monitor Stand: 3 x $89.99 = $269.97
Subtotal: $744.97
Tax: $63.32
Total: $808.29

date is a free-form z.string(), so it comes back as written in the invoice. Use z.iso.date() and say so in the prompt if you need 2024-01-15.

When extracting from multiple documents:

const documents = [
{ id: 'doc-1', content: 'Invoice #001...' },
{ id: 'doc-2', content: 'Invoice #002...' },
{ id: 'doc-3', content: 'Invoice #003...' },
];
const result = await nua.list("Extract invoice data from each document", {
input: documents,
primaryKey: 'id',
output: { name: 'invoice', schema: InvoiceSchema }
});
if (result.success) {
for (const row of result.data) {
console.log(`${row.id}: Invoice ${row.invoice.invoiceNumber} - $${row.invoice.total}`);
}
}

Make fields optional when the source may not contain them, and check what came back:

const FlexibleContactSchema = z.object({
name: z.string(),
email: z.string().optional(),
phone: z.string().optional(),
});
const result = await nua.get('Extract contact info', {
input: signature,
output: { name: 'contact', schema: FlexibleContactSchema },
});
if (result.success && !result.data.email && !result.data.phone) {
console.log('Warning: no contact method found');
}

To require a cross-field rule such as “either email or phone”, add a .refine(). An answer that breaks it is retried, and if every attempt breaks it, the call fails and reason says why:

const ContactWithMethod = FlexibleContactSchema.refine(
(c) => c.email || c.phone,
{ message: 'Either email or phone is required' },
);
const result = await nua.get('Extract contact info. Include an email or a phone number.', {
input: signature,
output: { name: 'contact', schema: ContactWithMethod },
});
if (!result.success) {
console.log(`Flag for review: ${result.reason}`);
}

Use a .refine() when an answer without the rule is wrong. When the source text may simply lack the data, leave the fields optional and check what came back, as above.

  1. Use enums and numbers where the value is constrained, rather than free-form strings
  2. Make fields optional when data might not be present
  3. Add formats and regex patterns for formatted data: z.email() for email addresses, .regex() for phone numbers and zip codes
  4. Express cross-field rules with .refine(), and mention them in the prompt so the model knows them
  5. Normalize during extraction: ask the LLM to convert formats, such as state names to 2-letter codes