Artículos / LLMs & Agents

[ LLMs & Agents ]PythonLLMsPydanticsalida estructurada

Extracción de datos con LLMs, Python y Pydantic

Extrae facturas y ofertas de trabajo con Python, Pydantic e Instructor. Valida tipos, revisa errores y mide la calidad de cada campo.

25 de junio de 2026·7 min de lectura
Extracción de datos con LLMs, Python y Pydantic

Una factura en texto necesita convertirse en campos que tu aplicación pueda procesar: proveedor, fecha, moneda e importes. Este tutorial usa Python, Pydantic e Instructor para definir esos campos y validar la respuesta de un LLM.

La validación comprueba la estructura y las reglas que escribas. No demuestra que el importe o la fecha coincidan con el documento: esa comprobación requiere evidencia del texto y ejemplos revisados.

El Problema de la Extracción Ingenua

El primer intento obvio es pedirle a un LLM que "extraiga la fecha de la factura y el monto total" y parsear su respuesta. Esto falla de tres formas:

  1. El LLM devuelve nombres de campo ligeramente diferentes cada vez
  2. Las fechas llegan en formatos inconsistentes (ene 5, 2025-01-05, 05/01/25)
  3. No hay validación de esquema: un campo puede ser string cuando esperabas un float

La solución es forzar al LLM a devolver JSON que coincida con un esquema Pydantic, y validarlo antes de que entre en tu pipeline.

Instalación

Usaremos la librería instructor, que parchea cualquier cliente compatible con OpenAI para devolver objetos Pydantic validados:

bash
pip install instructor openai pydantic
python
import instructor
from openai import OpenAI

client = instructor.from_openai(OpenAI())

Si prefieres usar Claude de Anthropic:

bash
pip install instructor anthropic
python
import instructor
import anthropic

client = instructor.from_anthropic(anthropic.Anthropic())

Los ejemplos siguientes usan el cliente de OpenAI. Para Anthropic, adapta el modelo, el método de creación y los parámetros según la integración de Instructor.

Ejemplo 1: Extrayendo Facturas

python
from pydantic import BaseModel, Field
from typing import Optional
from datetime import date
from decimal import Decimal

class LineaDetalle(BaseModel):
    descripcion: str
    cantidad: int
    precio_unitario: Decimal
    total: Decimal

class Factura(BaseModel):
    numero_factura: str
    nombre_proveedor: str
    fecha_emision: date
    fecha_vencimiento: Optional[date] = None
    lineas: list[LineaDetalle]
    subtotal: Decimal
    tasa_impuesto: Optional[float] = Field(None, ge=0, le=1)
    total: Decimal
    moneda: str = 'COP'

TEXTO_FACTURA = """
FACTURA #FAC-2025-0847
Proveedor: Soluciones TI Andinas S.A.S.
Fecha: 12 de marzo de 2025
Vence: 11 de abril de 2025

Servicios:
- Desarrollo de Pipeline de Datos (40 hrs a $150.000/hr): $6.000.000
- Configuración de Infraestructura Cloud (1 unidad a $800.000): $800.000
- Soporte y Documentación (8 hrs a $100.000/hr): $800.000

Subtotal: $7.600.000
IVA (19%): $1.444.000
TOTAL A PAGAR: $9.044.000
"""

factura = client.chat.completions.create(
    model='gpt-4o-mini',
    response_model=Factura,
    messages=[
        {
            'role': 'user',
            'content': f'Extrae todos los datos de factura del siguiente texto:\n\n{TEXTO_FACTURA}',
        }
    ],
)

print(f"Factura: {factura.numero_factura}")
print(f"Proveedor: {factura.nombre_proveedor}")
print(f"Fecha: {factura.fecha_emision}")   # datetime.date(2025, 3, 12): siempre un objeto date
print(f"Total: {factura.total}")            # Decimal('9044000'): siempre Decimal
print(f"Líneas: {len(factura.lineas)}")
for linea in factura.lineas:
    print(f"  - {linea.descripcion}: ${linea.total:,}")

Pydantic valida cada campo: fecha_emision es siempre un objeto Python date, total es siempre un Decimal, y si el LLM alucina un campo que no coincide con el esquema, Instructor reintenta automáticamente.

Ejemplo 2: Ofertas de Trabajo

python
from enum import Enum
from typing import Optional

class NivelExperiencia(str, Enum):
    JUNIOR    = 'junior'
    MID       = 'mid'
    SENIOR    = 'senior'
    LEAD      = 'lead'
    PRINCIPAL = 'principal'

class RangoSalario(BaseModel):
    min_cop: Optional[int] = None
    max_cop: Optional[int] = None
    es_divulgado: bool

class OfertaTrabajo(BaseModel):
    titulo: str
    empresa: str
    ubicacion: str
    modalidad: str                        # 'remoto', 'híbrido', 'presencial'
    nivel_experiencia: NivelExperiencia
    habilidades_requeridas: list[str]
    habilidades_deseadas: list[str]
    salario: RangoSalario
    responsabilidades: list[str] = Field(max_length=8)

TEXTO_OFERTA = """
Ingeniero Senior de ML: Startup de IA (Remoto / Medellín)

Buscamos un Ingeniero Senior de Machine Learning para unirse a nuestro equipo.
Serás responsable del ciclo completo de ML: recolección de datos, entrenamiento de modelos,
evaluación y despliegue.

Lo que necesitamos:
  5+ años de experiencia en ML/Ciencia de Datos
  Python avanzado (scikit-learn, PyTorch o TensorFlow)
  Experiencia en producción con servicio de modelos (FastAPI, Docker, Kubernetes)
  Familiaridad con MLflow u otras herramientas de seguimiento de experimentos

Deseable:
  Experiencia con fine-tuning de LLMs
  Rust (nuestro motor de inferencia está en Rust)

Responsabilidades:
  Diseñar y entrenar modelos para nuestro sistema de recomendación
  Configurar y mantener nuestra infraestructura de ML en AWS
  Colaborar con producto para definir features impulsadas por ML
  Mentorear a ingenieros junior

Compensación: COP 15.000.000 a COP 22.000.000/mes + equity.
"""

oferta = client.chat.completions.create(
    model='gpt-4o-mini',
    response_model=OfertaTrabajo,
    messages=[{'role': 'user', 'content': f'Extrae los datos de la oferta de trabajo:\n\n{TEXTO_OFERTA}'}],
)

print(f"Cargo:      {oferta.titulo} ({oferta.nivel_experiencia.value})")
print(f"Empresa:    {oferta.empresa} | {oferta.modalidad}")
print(f"Salario:    COP {oferta.salario.min_cop:,} a COP {oferta.salario.max_cop:,}/mes")
print(f"Requerido:  {', '.join(oferta.habilidades_requeridas)}")

Ejemplo 3: Procesamiento en Batch con Estadísticas de Validación

En producción procesas cientos de documentos, normalmente como un paso dentro de un pipeline orquestado (cubro ese montaje en orquesta pipelines de ML con Prefect). A esa escala necesitas conocer tu tasa de éxito de extracción:

python
from pydantic import ValidationError

class ResultadoExtraccion(BaseModel):
    exitoso: bool
    datos: Optional[Factura] = None
    error: Optional[str] = None
    preview_texto: str

def extraer_factura_seguro(texto: str) -> ResultadoExtraccion:
    try:
        factura = client.chat.completions.create(
            model='gpt-4o-mini',
            response_model=Factura,
            max_retries=2,
            messages=[{
                'role': 'user',
                'content': f'Extrae los datos de la factura:\n\n{texto}',
            }],
        )
        return ResultadoExtraccion(exitoso=True, datos=factura, preview_texto=texto[:100])
    except Exception as e:
        return ResultadoExtraccion(exitoso=False, error=str(e), preview_texto=texto[:100])

# Procesar un batch
textos_facturas = [TEXTO_FACTURA, "Esto no es una factura", TEXTO_FACTURA]

resultados = [extraer_factura_seguro(t) for t in textos_facturas]

exitosos = [r for r in resultados if r.exitoso]
fallidos  = [r for r in resultados if not r.exitoso]

print(f"Tasa de éxito: {len(exitosos)}/{len(resultados)} ({len(exitosos)/len(resultados)*100:.0f}%)")
for f in fallidos:
    print(f"  Falló: {f.error}")

Manejo de Estructuras Anidadas Complejas

Para documentos con estructuras condicionales o complejas, agrega lógica de validación directamente en Pydantic:

python
from pydantic import field_validator, model_validator
from datetime import datetime

class Factura(BaseModel):
    # ... campos como antes ...

    @field_validator('fecha_emision', 'fecha_vencimiento', mode='before')
    @classmethod
    def parsear_fecha_flexible(cls, v):
        if v is None:
            return v
        if isinstance(v, date):
            return v
        # Manejar formatos de fecha comunes de LLMs
        for fmt in ('%Y-%m-%d', '%d de %B de %Y', '%d/%m/%Y', '%B %d, %Y'):
            try:
                return datetime.strptime(str(v), fmt).date()
            except ValueError:
                continue
        raise ValueError(f"No se puede parsear la fecha: {v}")

    @model_validator(mode='after')
    def validar_totales(self) -> 'Factura':
        computado = sum(linea.total for linea in self.lineas)
        if abs(computado - self.subtotal) > Decimal('1.00'):
            raise ValueError(
                f"La suma de líneas ({computado}) no coincide con el subtotal ({self.subtotal})"
            )
        return self

El model_validator detecta casos donde el LLM extrae líneas individuales cuya suma no coincide con el subtotal declarado: una fuente común de errores en datos reales de facturas.

Medir costo y calidad antes de automatizar

Calcula el costo con los tokens de entrada y salida de cada intento y las tarifas vigentes del proveedor. Incluye reintentos, OCR, almacenamiento y revisión humana. El costo por documento depende de su longitud, del modelo y del porcentaje de fallos; una tarifa fija sin esas condiciones no sirve para presupuestar.

Para comparar modelos, prepara documentos con campos revisados y mide aciertos por campo, valores omitidos, valores inventados y proporción enviada a revisión. Mantén el mismo conjunto de evaluación cuando cambies el prompt o el esquema. Versiona también el modelo y las dependencias.

Qué hacer cuando falta información

Distingue un campo ausente de un cero real o una cadena vacía. Si el documento no contiene una fecha, permite un valor ausente en el esquema y conserva el motivo. No le pidas al modelo que complete datos por plausibilidad. Para importes, compara moneda, unidades y totales con el texto original antes de escribir en el sistema de destino.

Los ejemplos muestran patrones de integración, no una garantía de precisión ni un pipeline listo para cualquier factura. Las llamadas necesitan una clave del proveedor y generan consumo. Prueba primero documentos sintéticos o con los datos sensibles eliminados.

Para consultar una colección documental, continúa con RAG con LangChain. Para ejecutar herramientas sobre datos ya extraídos, consulta agentes con LangGraph.

Referencias

¿Vas a construir una plataforma de IA o de datos?

Si necesitas adaptar este enfoque a tu sistema, cuéntame qué datos tienes y qué decisión técnica necesitas resolver.

Ver todos los proyectos