Ejemplo mínimo y reproducible de cómo usar LlamaExtract (LlamaCloud) para sacar datos estructurados de facturas en PDF, con un esquema modelado sobre el formato legal español (NIF/CIF, base imponible, desglose de IVA por tipo, retención de IRPF…).
Más allá del "hola mundo", el ejemplo muestra cómo hacerlo robusto: escalado automático de calidad cuando un PDF no se lee bien, e inyección de contexto por fichero para casos peculiares (facturas con páginas extra, datos anonimizados, etc.).
No es un "resumen": es generación restringida. El esquema no solo valida, sino que
forma parte del prompt — LlamaCloud lo convierte a JSON Schema y obliga al LLM a
rellenar esa estructura exacta. El flujo (extract_facturas.py)
tiene tres etapas explícitas:
PDF ──(1) PARSE──► texto+layout ──(2) EXTRACT (LLM)──► JSON ──(3) VALIDATE──► Pydantic
files.create() extract.run() model_validate()
- Parse —
client.files.create(file=pdf, purpose="extract")sube el PDF y el servicio lo lee (OCR/visión). - Extract —
client.extract.run(...)pasa tu JSON Schema al LLM, que devuelve un JSON que lo cumple. Aquí cadaField(description=...)deschema.pyactúa como instrucción de qué buscar (NIF/CIF, base imponible, IRPF…). - Validate —
FacturaEspanola.model_validate(...)valida tipos y normaliza el JSON crudo a un objeto Pydantic.
Por eso devuelve null en vez de inventar: si no encuentra el IRPF, el esquema lo
permite y el modelo lo respeta.
La "calidad" no es un único ajuste. Hay dos controles, uno por fase:
| Config | Fase | Qué controla | Valores |
|---|---|---|---|
parse_tier |
(1) Parse | Cómo se lee el PDF (OCR/layout). agentic lee mejor tablas y escaneos difíciles. |
cost_effective, agentic |
tier |
(2) Extract | El LLM que rellena el esquema. | cost_effective, agentic |
cost_effective es rápido y barato; agentic es más lento y caro, pero entiende
documentos complejos. Empezamos barato y subimos solo cuando hace falta (ver abajo).
Por cada factura, el script decide cuánto contexto dar y cuánta potencia gastar:
┌─ ¿el nombre casa una regla? (FILE_RULES)
│ sí → añade contexto al system_prompt
factura.pdf ───────┤ y/o limita target_pages
│
└─► PARSE (1 vez) ─► EXTRACT [cost_effective] ─► VALIDATE
│
¿salió vacía (sin nº ni total)?
│ sí │ no
▼ ▼
EXTRACT [agentic+parse agentic] ✅ OK
│
VALIDATE ─► ✅ OK / ⚠ fallo
Claves del diseño:
- Escalado solo ante extracción vacía. No subimos el tier ante cualquier error.
Hay fallos (p.ej. el modelo devolvió un campo
nullque el esquema rechazaba) que no se arreglan con más cómputo, sino corrigiendo el contrato de datos. Solo escalamos cuando el resultado es basura (ni número de factura ni total) — señal de que el PDF no se pudo leer, que es justo lo queagenticmejora. - El PDF se sube una sola vez. Los reintentos reutilizan
file_input=uploaded.id, así escalar solo paga la fase de extract, no re-subir el documento. - Se vuelca SIEMPRE el JSON crudo (
output/<factura>.raw.json) antes de validar. Si algo falla, se diagnostica sin volver a llamar a la API (y gastar créditos).
El LLM acepta un system_prompt: instrucciones en lenguaje natural que se leen junto
al esquema. Además del prompt base (formato de importes, reverse charge, ignorar páginas
de condiciones…), se puede añadir contexto específico según el nombre del fichero, y
ajustar las páginas a procesar. Las reglas viven en FILE_RULES:
FILE_RULES = [
{ # Naturgy trae páginas de detalle de consumo que estorban
"match": ["naturgy"],
"note": "...quédate solo con el resumen de las primeras páginas...",
"target_pages": "1,2", # nos saltamos el detalle posterior
},
{ # O2/Telefónica anonimiza datos con asteriscos
"match": ["o2", "telefon", "om7vafj"],
"note": "...los asteriscos (B7***7191) son esperados, NO un error: cópialos tal cual...",
},
]match: la regla se aplica si cualquiera de esas subcadenas está en el nombre del fichero (case-insensitive).note: se añade alsystem_promptbase (no lo sustituye).target_pages/tier: overrides opcionales de páginas y de tier de arranque.
Añadir un emisor nuevo es una entrada más en la lista — cero cambios en la lógica.
- Una API key de LlamaCloud (https://cloud.llamaindex.ai → API Keys, empieza por
llx-). uvpara gestionar el entorno.
cp .env.example .env # y pon tu key en LLAMA_PARSE_KEY
uv sync # instala dependenciasEl SDK lee la variable
LLAMA_CLOUD_API_KEY. Como en este proyecto la key está enLLAMA_PARSE_KEY, el script la mapea automáticamente al arrancar.
# Facturas de ejemplo por defecto
uv run extract_facturas.py
# Todas las de invoices/
uv run extract_facturas.py --all
# Una factura concreta
uv run extract_facturas.py invoices/factura-simple-iva21.pdf
# Forzar tier alto desde el principio (sin esperar al escalado)
uv run extract_facturas.py --tier agentic invoices/factura_o2.pdf
# Limitar páginas manualmente (una regla por fichero puede sobreescribirlo)
uv run extract_facturas.py --target-pages 1 "invoices/Petalo Uber Mayo.pdf"
# Desactivar el reintento automático a agentic
uv run extract_facturas.py --all --no-escalateFlags principales: --all, --tier {cost_effective,agentic}, --no-escalate,
--target-pages, --system-prompt, --out. Cada factura genera en output/ un JSON
con la forma { "metadata": {...}, "factura": {...} } (ver más abajo), más un
<factura>.raw.json con la respuesta cruda del modelo antes de validar.
Reporta el progreso en tiempo real: documento [n/total], paso (k/total_pasos), qué
reglas se inyectaron y si hubo escalado. Ejemplo real (O2, que escala):
Las fases lógicas son siempre 3 (Parse · Extract · Validate); el escalado se muestra como un reintento dentro de la fase Extract, no como una fase nueva:
▶ [2/2] invoices/factura_o2.pdf
↳ contexto inyectado por regla: o2
[2/2] (paso 1/3) Parse (subir+OCR)…
[2/2] (paso 2/3) Extract [cost_effective]…
↑ extracción vacía, escalando tier…
↳ reintento 2: Extract [agentic + parse agentic]…
[2/2] (paso 3/3) Validate OK
Nº factura : OM7VAFJ001****
Emisor : Telefónica de España, S.A.U. (A-82018474)
TOTAL : 43.0 EUR
ⓘ job ext-wsi9nykka75…apw · tier agentic · 2 pág · ~66 cr (~€0.0759) ⚠ ESCALADO
→ JSON : output/factura_o2.json
Y al final del lote, un agregado de coste para no leer microcéntimos uno a uno:
============================================================
Listo: 9 OK, 0 con fallo. JSON en ./output/
Coste estimado total: ~178 cr · ~$0.2225 · ~€0.2047
(9 facturas · media ~19.8 cr/factura · 1 escaladas)
Estimación con tarifas por página; el consumo real está en el dashboard (Usage).
Cada output/<factura>.json envuelve los datos en dos bloques:
El metadata da trazabilidad (qué job, qué tier, qué reglas) y coste; el bloque
factura es el objeto Pydantic validado. Nota: el SDK no devuelve un parse job id
(pjb-…) porque extract.run() parsea internamente dentro del mismo job ext-….
Por cada PDF se escriben dos ficheros en output/. Son las dos caras de la etapa
VALIDATE (la entrada y la salida), no duplicados:
| Fichero | Qué es | Estructura | Cuándo se escribe |
|---|---|---|---|
<factura>.raw.json |
La respuesta cruda del modelo (job.extract_result), antes de validar |
Plana (campos de la factura, tal cual). Puede traer null donde el esquema espera lista, tipos sin normalizar, etc. |
Siempre, aunque la validación falle después |
<factura>.json |
El resultado final validado | { "metadata": {...}, "factura": {...} }. El bloque factura ya pasó por Pydantic (null → [], str → float, defaults) y se le añade metadata (job ids, tier, coste) |
Solo si la validación tiene éxito |
Ejemplo real: en Petalo Uber Mayo, el modelo devolvió lineas: null (sin tabla de
líneas). El .raw.json lo conserva como null; el .json lo muestra ya coercionado a
[] por el validador de schema.py.
Para qué sirve cada uno:
.jsones el dato que consume tu aplicación (validado, tipado, con metadata)..raw.jsones para depurar sin coste: si una factura falla la validación, el.jsonno se escribe, pero el.raw.jsonsí — así ves qué devolvió el modelo y por qué Pydantic lo rechazó, sin re-llamar a la API (sin gastar créditos). En producción normalmente se descartan; aquí se conservan por ser un ejemplo didáctico.
La API no devuelve los créditos consumidos (no hay endpoint de usage en el SDK);
el consumo real vive en el dashboard, localizable por extract_job_id. El script estima
el coste con el modelo de tarifas por página = parse + extract:
| Fase | cost_effective |
agentic |
|---|---|---|
| Parse | 3 cr/pág | 10 cr/pág |
| Extract | 5 cr/pág | 15 cr/pág |
| Total por página | 8 cr | 25 cr |
A $1,25 / 1.000 créditos (~€0,00115/crédito). Implicaciones:
- Una factura de 1 página en barato cuesta ~8 cr (~€0,009). Multiplica por páginas: una de 4 páginas son ~32 cr.
- Escalar es caro y asimétrico: pagas el intento barato fallido y el
agentic. Una de 4 páginas que escala salta de 32 a 132 cr (~4×). target_pagesahorra de verdad: Naturgy tiene 4 páginas pero la regla la limita a 2 → la mitad de coste, sin perder datos de la factura.- El agregado final suma todos los intentos (también los de facturas que fallaron, porque se cobran igual) y reporta la media y cuántas escalaron.
Ajusta las tarifas en las constantes PARSE_CREDITS_PER_PAGE / EXTRACT_CREDITS_PER_PAGE
de extract_facturas.py si cambian en tu cuenta o región.
Probado con dos facturas de muestra públicas:
| Campo | Valor extraído |
|---|---|
| Nº factura | 98765 |
| Fecha | 24-11-2012 |
| Emisor / NIF | DISTRIBUCIONES FICTICIAS… / X0000000T |
| Receptor / NIF | PRUEBASED GARCIA PROBERO / 77777777B |
| Líneas | 2 (900,00 € + 126,00 €) |
| Base imponible | 1026,00 |
| IVA 21% | 215,46 |
| Total | 1241,46 |
| Forma de pago | CONTADO |
| Campo | Valor extraído |
|---|---|
| Nº factura | 21180613010076890 |
| Fecha | 13 de junio de 2018 |
| Emisor / CIF | IBERDROLA CLIENTES, S.A.U. / A-95758389 |
| Receptor / CIF | EXODO RENTAL S.L. / B86918002 |
| Base imponible | 66,90 (= 66,02 energía + 0,88 servicios) |
| IVA 21% | 14,05 |
| Total | 80,95 EUR |
| Forma de pago | DOMICILIACION BANCARIA |
Aciertos destacables: reconstruyó la base imponible sumando líneas, calculó la
cuota de IVA, y dejó en null los campos que no aplican (IRPF en ambas, cantidad/precio
unitario en la eléctrica) en lugar de inventarlos.
invoices/ contiene un surtido de facturas reales de distintos emisores
(suministros, SaaS, transporte, telecom…) para probar casos variados: IVA al 21%/10%,
inversión del sujeto pasivo (IVA 0 en facturas intracomunitarias), datos anonimizados y
documentos de varias páginas. Los datos personales de esas facturas no se documentan
aquí ni deberían subirse a un repositorio público.
Usa el SDK actual llama-cloud>=1.0 (from llama_cloud import LlamaCloud).
El esquema Pydantic se pasa al servicio como JSON Schema vía
FacturaEspanola.model_json_schema().
{ "metadata": { "extract_job_id": "ext-…", // localizable en el dashboard (Usage) "file_id": "…", "project_id": "…", "tier": "agentic", "parse_tier": "agentic", "target_pages": null, "paginas_facturadas": 2, "reglas_aplicadas": ["o2"], "escalado": true, "num_intentos": 2, "intentos": [ // CADA intento, incluido el barato que falló {"extract_job_id": "ext-…", "tier": "cost_effective", "resultado": "vacío", "creditos_estimados": 16}, {"extract_job_id": "ext-…", "tier": "agentic", "parse_tier": "agentic", "resultado": "ok", "creditos_estimados": 50} ], "coste_estimado": {"creditos": 66, "usd": 0.0825, "eur": 0.0759, "nota": "…dashboard…"}, "parsing_nota": "extract.run() parsea internamente; no expone un parse job id (pjb-…)." }, "factura": { "numero_factura": "…", "emisor": {...}, "desglose_iva": [...], "total_factura": 43.0, ... } }