Monté un lector de facturas con IA 100% local (Ollama + n8n) — y el truco fue enseñarle a NO adivinar

Hola a todos :waving_hand:

No soy programador profesional, pero soy autodidacta y algo me defiendo. Llevo un negocio y me pasaba lo de siempre: cada mes, un montón de facturas en PDF y yo copiando a mano proveedor, fecha, concepto e importe a una hoja. Aburrido y con errores.

Me puse con n8n para automatizarlo, pero tenía dos condiciones que me importaban mucho:

  • Que las facturas no salieran de mi ordenador. Son datos de proveedores y de mi negocio, no quería subirlos a ninguna API en la nube.
  • Que no me costara una suscripción mensual por cada llamada a un modelo.

Así que lo monté con Ollama corriendo en local (qwen2.5-coder:7b) en vez de una IA de pago. Y funciona en un portátil normalito: un Ryzen 7 con 16 GB de RAM, sin gráfica profesional, ~12 segundos por factura y 0 € de coste por proceso.

El flujo es simple, 7 nodos:

  1. Un disparador (en la demo, manual; en real yo uso un Gmail Trigger con la etiqueta “Facturas”).
  2. Un nodo Code con 3 facturas de ejemplo embebidas, para que el flujo se pueda probar sin montar nada.
  3. Un HTTP Request a Ollama pidiéndole el JSON con proveedor, fecha, concepto, total.
  4. Un nodo que clasifica.
  5. Un resumen.
  6. Aviso por Telegram al móvil.
  7. Un hueco reservado para volcar a Google Sheets.

Lo que más me costó, y lo que más me ha servido: al principio la IA se inventaba datos cuando la factura estaba borrosa o escaneada regular. Un desastre para contabilidad. La solución fue pedirle en el prompt que devolviera también un campo confianza de 0 a 1, y montar una regla sencilla:

si confianza ≥ 0.8 y hay proveedor y total → OK si no → REVISAR

Con eso, las facturas dudosas no se cuelan: se marcan en “amarillo” y las miro yo 10 segundos. Prefiero eso a un error de 300 € pasando en silencio. El prompt lleva temperature: 0 y format: json para que no divague.

Os dejo el workflow completo aquí abajo (en el desplegable), por si a alguien le sirve o quiere destrozarlo y mejorarlo. Es de copiar y pegar directo en n8n (Ctrl+V en el lienzo) — trae las 3 facturas de ejemplo dentro, así lo pruebas sin montar nada.

Un par de cosas que aprendí montándolo, por si os ahorran el rato:

  • Si n8n corre en Docker, el nodo de Ollama tiene que apuntar a http://host.docker.internal:11434, no a localhost — ahí perdí un buen rato con el “connection refused”.
  • La primera factura tarda bastante más que el resto (en mi portátil, cerca de un minuto) porque Ollama carga el modelo en memoria la primera vez. A partir de la segunda ya va a ~8-10 segundos. Si vas a procesar un lote, la primera es el peaje y luego vuela.
  • Si todo te sale “REVISAR”, el umbral de confianza está demasiado alto; bájalo a 0,6 y prueba.
  • Si el PDF es un escaneo sin capa de texto, la IA recibe vacío: hay que meterle un OCR antes.

Dos preguntas para quien haya hecho algo parecido, que ando dándole vueltas:

  • ¿Cómo gestionáis vosotros el caso “REVISAR”? ¿lo mandáis a una segunda pasada con otro modelo, o directamente a revisión humana como yo?
  • Para producción, ¿tiráis de Gmail Trigger como yo o preferís una carpeta vigilada / webhook?

Cualquier crítica al montaje es bienvenida, que soy más de barra de bar que de código y seguro que se puede afinar :slightly_smiling_face:

[details=“Workflow completo - copialo y pegalo directo en n8n (Ctrl+V en el lienzo)”]

{

"id": "FacturasAuto0001",

"name": "facturas-auto (v3: IA local + Telegram)",

"nodes": [

{

"name": "Inicio manual (piloto)",

"type": "n8n-nodes-base.manualTrigger",

"typeVersion": 1,

"position": [

0,

0

],

"parameters": {}

},

{

"name": "Facturas de prueba",

"type": "n8n-nodes-base.code",

"typeVersion": 2,

"position": [

200,

0

],

"parameters": {

"jsCode": "// PILOTO: 3 facturas embebidas. PRODUCCION: sustituir este nodo por Gmail Trigger (etiqueta Facturas).\nconst facturas = [\n 'ACME SUMINISTROS S.L.\\nProveedor: Acme Suministros SL\\nFecha: 02/07/2026\\nConcepto: Material de oficina - pedido 4471\\nTotal: 148,50',\n 'HOSTING CLARO SA DE CV\\nProveedor: Hosting Claro\\nFecha: 05/07/2026\\nConcepto: Servidor VPS mensualidad julio\\nTotal: 29,00',\n '[escaneo de baja calidad]\\nProv__dor: (ilegible)\\nFecha: 06/07/2026\\nConc.: reparacion equipo\\nImporte total 3?0,00'\n];\nreturn facturas.map(t => ({ json: { texto: t } }));"

}

},

{

"name": "Extraer con IA local (Ollama)",

"type": "n8n-nodes-base.httpRequest",

"typeVersion": 4.2,

"position": [

400,

0

],

"parameters": {

"method": "POST",

"url": "http://host.docker.internal:11434/api/generate",

"sendBody": true,

"specifyBody": "json",

"jsonBody": "={{ JSON.stringify({ model: 'qwen2.5-coder:7b', stream: false, format: 'json', options: { temperature: 0 }, prompt: 'Extrae de esta factura los campos en JSON exacto: proveedor (string), fecha (string), concepto (string), total (string, solo el numero), confianza (0 a 1 segun lo legible que sea). Si un campo es ilegible usa \\\"\\\" y baja la confianza.\\nFACTURA:\\n' + $json.texto }) }}",

"options": {

"timeout": 300000

}

}

},

{

"name": "Clasificar",

"type": "n8n-nodes-base.code",

"typeVersion": 2,

"position": [

600,

0

],

"parameters": {

"jsCode": "return $input.all().map(item => {\n let d = {};\n try { d = JSON.parse(item.json.response); } catch (e) { d = { confianza: 0 }; }\n const estado = (d.confianza ?? 0) >= 0.8 && d.proveedor && d.total ? 'OK' : 'REVISAR';\n return { json: { proveedor: d.proveedor || 'DESCONOCIDO', fecha: d.fecha || '', concepto: d.concepto || '', total: d.total || '', confianza: d.confianza ?? 0, estado } };\n});"

}

},

{

"name": "Resumen",

"type": "n8n-nodes-base.code",

"typeVersion": 2,

"position": [

800,

0

],

"parameters": {

"jsCode": "const items = $input.all();\nconst ok = items.filter(i => i.json.estado === 'OK').length;\nconst lineas = items.map(i => `- ${i.json.proveedor}: ${i.json.total || '?'} EUR [${i.json.estado}]`).join('\\n');\nreturn [{ json: { text: `\\u{1F4C4} Facturas procesadas con IA local: ${items.length} (${ok} OK, ${items.length - ok} a revisar)\\n${lineas}` } }];"

}

},

{

"name": "Aviso Telegram",

"type": "n8n-nodes-base.telegram",

"typeVersion": 1.2,

"position": [

1000,

0

],

"parameters": {

"chatId": "PON_AQUI_TU_CHAT_ID",

"text": "={{ $json.text }}",

"additionalFields": {}

},

"credentials": {

"telegramApi": {

"id": "telegramBotAvisos",

"name": "Telegram (bot avisos)"

}

}

},

{

"name": "Registro (Sheets: pendiente credencial)",

"type": "n8n-nodes-base.noOp",

"typeVersion": 1,

"position": [

1000,

160

],

"parameters": {}

}

],

"connections": {

"Inicio manual (piloto)": {

"main": [

[

{

"node": "Facturas de prueba",

"type": "main",

"index": 0

}

]

]

},

"Facturas de prueba": {

"main": [

[

{

"node": "Extraer con IA local (Ollama)",

"type": "main",

"index": 0

}

]

]

},

"Extraer con IA local (Ollama)": {

"main": [

[

{

"node": "Clasificar",

"type": "main",

"index": 0

}

]

]

},

"Clasificar": {

"main": [

[

{

"node": "Resumen",

"type": "main",

"index": 0

},

{

"node": "Registro (Sheets: pendiente credencial)",

"type": "main",

"index": 0

}

]

]

},

"Resumen": {

"main": [

[

{

"node": "Aviso Telegram",

"type": "main",

"index": 0

}

]

]

}

},

"settings": {

"executionOrder": "v1"

},

"active": false

}

[/details]