Structured outputs (JSON mode) resolvió el problema obvio: hacer que el LLM devuelva JSON válido en lugar de texto libre con JSON mezclado. Pero en producción, los casos de uso son más complejos: schemas anidados, arrays de longitud variable, campos opcionales, validación de dominio, y extracción de múltiples entidades de un mismo texto.
Más allá del JSON básico
Extracción con Pydantic y validación de dominio
from pydantic import BaseModel, field_validator, EmailStr
from anthropic import Anthropic
import json
client = Anthropic()
class ContactInfo(BaseModel):
nombre: str
email: EmailStr | None = None
telefono: str | None = None
empresa: str | None = None
@field_validator("telefono")
@classmethod
def validate_phone(cls, v):
if v is not None:
# Normalizar formato de teléfono
digits = "".join(filter(str.isdigit, v))
if len(digits) < 9:
return None # teléfono inválido, lo descartamos
return v
class EmailAnalysis(BaseModel):
remitente: ContactInfo
asunto: str
tipo: str # "soporte", "ventas", "queja", "informacion"
urgencia: int # 1-5
puntos_clave: list[str]
accion_requerida: bool
def analyze_email(email_text: str) -> EmailAnalysis:
response = client.messages.create(
model="claude-haiku-4-5-20251001",
max_tokens=1024,
messages=[{
"role": "user",
"content": f"""Analiza este email y extrae la información en JSON.
Email:
{email_text}
Devuelve SOLO JSON válido con esta estructura:
{{
"remitente": {{
"nombre": "...",
"email": "...",
"telefono": "...",
"empresa": "..."
}},
"asunto": "...",
"tipo": "soporte|ventas|queja|informacion",
"urgencia": 1-5,
"puntos_clave": ["...", "..."],
"accion_requerida": true/false
}}
Usa null para campos que no están disponibles."""
}]
)
# Parsear y validar con Pydantic
data = json.loads(response.content[0].text)
return EmailAnalysis(**data)
Extracción de múltiples entidades
Para textos que contienen múltiples entidades del mismo tipo:
class Producto(BaseModel):
nombre: str
precio: float | None
cantidad: int | None
sku: str | None
class PedidoExtraido(BaseModel):
numero_pedido: str | None
fecha: str | None
cliente: str | None
productos: list[Producto]
total: float | None
def extract_order(text: str) -> PedidoExtraido:
response = client.messages.create(
model="claude-haiku-4-5-20251001",
max_tokens=2048,
messages=[{
"role": "user",
"content": f"""Extrae todos los datos del pedido de este texto.
{text}
JSON con estructura:
{{
"numero_pedido": "...",
"fecha": "YYYY-MM-DD o null",
"cliente": "...",
"productos": [
{{"nombre": "...", "precio": 0.0, "cantidad": 0, "sku": "..."}},
...
],
"total": 0.0
}}"""
}]
)
data = json.loads(response.content[0].text)
return PedidoExtraido(**data)
Manejo robusto de errores de parsing
import re
from typing import TypeVar, Type
T = TypeVar("T", bound=BaseModel)
def safe_extract(text: str, schema: Type[T], max_retries: int = 2) -> T | None:
for attempt in range(max_retries + 1):
try:
# Intentar extraer JSON del texto (a veces el modelo añade texto extra)
json_match = re.search(r'\{.*\}', text, re.DOTALL)
if not json_match:
raise ValueError("No JSON encontrado en la respuesta")
data = json.loads(json_match.group())
return schema(**data)
except (json.JSONDecodeError, ValueError) as e:
if attempt < max_retries:
# Reintentar con un prompt de corrección
text = fix_json_with_llm(text, str(e))
else:
return None
def fix_json_with_llm(broken_text: str, error: str) -> str:
response = client.messages.create(
model="claude-haiku-4-5-20251001",
max_tokens=512,
messages=[{
"role": "user",
"content": f"""El siguiente texto contiene JSON inválido. Error: {error}
Texto:
{broken_text}
Devuelve SOLO el JSON corregido y válido, sin explicaciones."""
}]
)
return response.content[0].text
Structured outputs con OpenAI (modo estricto)
OpenAI ofrece structured outputs en modo estricto que garantiza que el output coincide con el schema:
from openai import OpenAI
from pydantic import BaseModel
openai_client = OpenAI()
class CalificacionResena(BaseModel):
sentimiento: str # "positivo", "negativo", "neutro"
puntuacion: int # 1-5
aspectos_positivos: list[str]
aspectos_negativos: list[str]
resumen: str
def classify_review(review: str) -> CalificacionResena:
completion = openai_client.beta.chat.completions.parse(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": "Analiza reseñas de productos y extrae información estructurada."},
{"role": "user", "content": review}
],
response_format=CalificacionResena,
)
return completion.choices[0].message.parsed
El modo estricto de OpenAI garantiza JSON válido que cumple el schema. El tradeoff: el schema debe ser más simple (no todos los tipos de Pydantic están soportados).
El pipeline de extracción en escala
Para procesar miles de documentos:
import asyncio
from anthropic import AsyncAnthropic
async_client = AsyncAnthropic()
async def extract_batch(texts: list[str], schema: Type[T]) -> list[T | None]:
semaphore = asyncio.Semaphore(10) # máximo 10 requests concurrentes
async def extract_one(text: str) -> T | None:
async with semaphore:
response = await async_client.messages.create(
model="claude-haiku-4-5-20251001",
max_tokens=1024,
messages=[{"role": "user", "content": f"Extrae en JSON: {text}"}]
)
return safe_extract(response.content[0].text, schema)
return await asyncio.gather(*[extract_one(text) for text in texts])
# Uso
results = asyncio.run(extract_batch(documentos, MiSchema))
exitosos = [r for r in results if r is not None]
print(f"Extraídos: {len(exitosos)}/{len(documentos)}")
Patrones validados en pipelines de extracción de datos en producción procesando miles de documentos diarios.