App de una sola página (Next.js) para subir imágenes o PDFs, verlos en una vista previa y extraer sus datos con un modelo de visión: clasifica el documento como factura, recibo o contrato, valida los campos con Zod y deja corregir a mano lo que esté mal, con el documento a la izquierda y el formulario a la derecha. Al confirmar, los datos pasan a tablas de PostgreSQL y el texto del documento se indexa como embeddings en pgvector; un chat responde preguntas sobre lo guardado citando el documento de origen, con SQL cuando la pregunta es de cálculo y con búsqueda semántica cuando va del contenido.
Los archivos y el JSON extraído se guardan en PostgreSQL con la extensión pgvector, lista para añadir embeddings más adelante.
En producción: https://extracto.seenode.app
- Node.js 20+
- Docker
cp .env.example .env.local # DATABASE_URL + OPENROUTER_API_KEY
docker compose up -d # PostgreSQL + pgvector en localhost:5435
npm install
npm run dev # http://localhost:3000El esquema (db/init/01-schema.sql) se aplica automáticamente la primera vez que
se crea el volumen de la base de datos.
| Ruta | Descripción |
|---|---|
src/app/page.tsx |
Única página: subida, vista previa y datos |
src/app/datos-form.tsx |
Formulario editable con los campos inválidos en rojo |
src/app/chat.tsx |
Chat con citas clicables que abren el documento citado |
src/app/api/upload |
POST recibe el archivo y lo guarda en documents |
src/app/api/extract |
POST {id} envía el archivo al modelo de visión y guarda el JSON |
src/app/api/documents/[id] |
PATCH guarda el borrador con las correcciones |
src/app/api/confirm |
POST {id} valida, escribe en las tablas e indexa el texto |
src/app/api/registros |
GET lista de documentos confirmados |
src/app/api/chat |
POST {pregunta, historial} busca por similitud y responde citando |
src/lib/busqueda.ts |
Consulta de vecinos más próximos y armado del contexto |
src/lib/planificador.ts |
Decide SQL o embeddings y escribe la consulta |
src/lib/sql-seguro.ts |
Revisa y ejecuta el SQL generado en modo sólo lectura |
src/lib/embeddings.ts |
Troceado del texto y llamada a la API de embeddings |
src/lib/schemas/ |
Esquemas Zod: forma laxa para el modelo y reglas por tipo |
src/app/api/documents |
GET lista los últimos 50 archivos |
src/app/api/files/[id] |
GET devuelve el binario para la vista previa |
src/lib/db.ts |
Pool de pg reutilizado entre recargas en desarrollo |
POST /api/extract manda el archivo a OpenRouter y fuerza la respuesta contra el
esquema de src/lib/extraction-schema.ts (structured outputs, strict: true); el
resultado se valida otra vez con Zod antes de guardarlo.
| Variable | Por defecto | Para qué |
|---|---|---|
OPENROUTER_API_KEY |
— | Obligatoria |
OPENROUTER_MODEL |
deepseek/deepseek-v4.1-flash |
Modelo de visión |
OPENROUTER_PDF_ENGINE |
mistral-ocr |
Motor con el que OpenRouter convierte los PDFs (mistral-ocr lee escaneos; native y cloudflare-ai son las otras opciones) |
OPENROUTER_EMBEDDING_MODEL |
openai/text-embedding-3-small |
Debe dar 1536 dimensiones, las de la columna vector(1536) |
Las imágenes viajan como image_url en base64; los PDFs como parte file con el
plugin file-parser, así que también funcionan los PDFs escaneados. La petición lleva
provider: { require_parameters: true } para que OpenRouter no la enrute a un
proveedor que ignore el esquema y devuelva una respuesta vacía.
Campos extraídos: tipo de documento y confianza, emisor y receptor (nombre, NIF/CIF,
dirección), número, fechas, moneda, subtotal/impuestos/total, método de pago, líneas
de detalle y, para contratos, objeto, vigencia, importe, ley aplicable y cláusulas
destacadas. Lo que no aparece en el documento vuelve como null, y las dudas quedan
listadas en notas.
src/lib/schemas/tipos.ts define un esquema Zod por tipo de documento; cada uno
decide qué es obligatorio:
| Factura | Recibo | Contrato | Otro | |
|---|---|---|---|---|
| Emisor (nombre) | ✓ | ✓ | ✓ | — |
| Receptor (nombre) | ✓ | — | ✓ | — |
| Número | ✓ | — | — | — |
| Fecha de emisión | ✓ | ✓ | — | — |
| Moneda y total | ✓ | ✓ | — | — |
| Base y impuestos | ✓ | — | — | — |
| Al menos una línea | ✓ | — | — | — |
| Objeto e inicio de vigencia | — | — | ✓ | — |
Además se comprueban las reglas que cruzan campos:
- Fechas: formato
AAAA-MM-DDy que la fecha exista (2026-02-31se rechaza). - Orden: el vencimiento no puede preceder a la emisión, ni el fin de vigencia al inicio.
- Aritmética:
subtotal + impuestos = total, con dos céntimos de tolerancia por redondeo; el mensaje dice la cuenta que sí sale.
Estas reglas cruzadas se evalúan aparte del safeParse porque Zod se salta sus
refinements en cuanto falla un campo, y en el formulario interesa ver todos los
problemas a la vez.
Nada de esto bloquea el guardado: un documento con problemas se guarda igual, con los campos marcados en rojo, porque suelen ser correcciones en curso.
Está desplegado en Seenode (Frankfurt) desde la rama master
de este repositorio:
| Recurso | Detalle |
|---|---|
| App web | Node 24, paquete Basic (0.25 CPU / 512 MB) |
| Base de datos | PostgreSQL 16.14 gestionado, paquete Basic (1 GB), con pgvector 0.8.6 |
- Build:
npm ci --include=dev && npm run build. El--include=deves obligatorio: conNODE_ENV=production,npm cise saltaría Tailwind y PostCSS, que hacen falta para compilar. - Arranque:
node scripts/migrate.mjs && npx next start. El script aplicadb/init/*.sql(todo idempotente) y deja escrito en el log qué versión de pgvector encontró; en local ese trabajo lo hace el entrypoint de Docker. - Variables:
DATABASE_URLla inyecta Seenode al enlazar la base;OPENROUTER_API_KEYva como secreto;DATABASE_SSL=onfuerza TLS, porque el Postgres gestionado rechaza las conexiones sin cifrar.
La entrega continua (autoDeploy) está activada en la aplicación, pero sólo entra en
funcionamiento cuando la GitHub App de Seenode está instalada sobre el repositorio:
es ella quien crea el webhook de push. Sin esa instalación el flag no hace nada y hay
que lanzar el despliegue a mano.
Cuando esté activa, cada push a master dispara build y despliegue, así que conviene
que npm test y npm run build pasen antes de empujar: esa rama sirve producción.
npm test # una pasada
npm run test:watchtests/ cubre los esquemas (cada tipo, fechas, cuadre, mensajes) y las tres rutas de
API con la base de datos y el modelo simulados: subida correcta de PDF e imagen,
tipo no permitido, archivo ausente, exceso de 20 MB, extracción completa, extracción
incompleta (se guarda y devuelve los campos que fallan), respuesta del modelo que no
es JSON, que no cumple el esquema, que llega vacía (se reintenta) o que se corta por
longitud, 401 de OpenRouter, documento inexistente, guardado de correcciones,
troceado y embeddings, y la confirmación completa: transacción con cabecera, líneas,
detalle de contrato y chunks, rechazo si hay campos inválidos, y ROLLBACK si algo
falla a mitad. El chat tiene los suyos: respuesta con cita, fragmentos numerados en el
prompt, fuentes filtradas a las citadas, umbral de similitud, base vacía, historial
recortado y saneado, y los errores del proveedor. El enrutado a SQL tiene los suyos:
consulta ejecutada y citada, transacción de sólo lectura con timeout, rechazo de
INSERT/UPDATE/DELETE/DROP/TRUNCATE, de sentencias encadenadas, de comentarios, del
catálogo del sistema y de tablas ajenas, y la caída a búsqueda semántica cuando la
consulta se rechaza o falla.
Mientras se revisa, la extracción vive como jsonb en documents.extraction; es un
borrador y se guarda tenga los problemas que tenga. Confirmar y guardar exige que
la validación esté limpia y entonces, en una única transacción:
- escribe la cabecera en
registrosy el detalle enregistro_lineas/registro_contratos; - trocea el texto del documento (~800 caracteres con solape) y guarda cada fragmento
con su vector en
documento_chunks.
Los embeddings se piden antes de abrir la transacción, para no dejarla esperando por la red, y confirmar dos veces reemplaza lo anterior en lugar de duplicarlo.
Buscar por significado es entonces una consulta normal:
SELECT r.tipo_documento, r.numero_documento, c.texto
FROM documento_chunks c
JOIN registros r ON r.document_id = c.document_id
ORDER BY c.embedding <=> $1 -- $1 = embedding de la consulta
LIMIT 5;Cada pregunta pasa primero por un planificador que elige el camino:
| Pregunta | Camino |
|---|---|
| "¿Cuánto suman las facturas?" · "¿Cuántos documentos hay de cada tipo?" · "¿Qué facturó X en 2026?" | SQL sobre las tablas |
| "¿Qué dice el contrato sobre la fianza?" · "¿Qué incluye el mantenimiento?" | Embeddings sobre el texto |
El planificador devuelve la consulta con structured output, y antes de tocar la base
pasa por revisarConsulta: una sola sentencia, sin comentarios ni punto y coma, que
empiece por SELECT o WITH, sin verbos de escritura, sin catálogo del sistema ni
funciones peligrosas, y sólo sobre las cuatro tablas del dominio (las CTE declaradas
en la propia consulta también valen).
La ejecución añade la defensa de verdad: BEGIN READ ONLY, statement_timeout de
5 s y las filas envueltas en un LIMIT 100. Aunque el modelo escribiera un DELETE,
Postgres lo rechazaría.
Del resultado se extraen los uuid de documento (por eso se pide document_id o
array_agg(document_id) en el SELECT), se convierten en las fuentes numeradas y el
modelo redacta la respuesta citándolas. En la interfaz, la consulta ejecutada queda
disponible en un desplegable bajo la respuesta.
Si la consulta se rechaza o falla en Postgres, la pregunta cae al camino semántico en vez de dejar al usuario sin respuesta.
POST /api/chat embebe la pregunta, recupera los seis fragmentos más parecidos
(descartando los que bajan de 0,18 de similitud coseno) y se los pasa al modelo
numerados, con instrucción de responder sólo con lo que aparezca ahí y citar cada dato
como [1]. La respuesta vuelve con la lista de fuentes que realmente cita; en la
interfaz, cada [n] es un botón que abre ese documento en la vista previa.
Tres decisiones que importan:
- Sin fragmentos relevantes no se llama al modelo: responde que no encuentra nada, en vez de improvisar sobre contexto vacío.
- Sólo documentos confirmados: la consulta hace
JOINconregistros, así que los borradores no contaminan las respuestas. - Sin tokens de razonamiento (
reasoning: { enabled: false }): el modelo gastaba la mitad del presupuesto razonando y la respuesta se cortaba a media frase.
El historial se reenvía recortado a los seis últimos turnos, y se filtran los mensajes
que no sean user/assistant.
| Tabla | Contenido |
|---|---|
documents |
Archivo original (bytea), su tipo y el borrador extraction (jsonb) |
registros |
Una fila por documento confirmado: partes, fechas, importes |
registro_lineas |
Conceptos de facturas y recibos |
registro_contratos |
Objeto, vigencia, ley aplicable y cláusulas |
documento_chunks |
Fragmentos de texto y su vector(1536), con índice HNSW |
El esquema se aplica solo (db/init/*.sql) la primera vez que se crea el volumen.
PNG, JPG, WebP, GIF y PDF, hasta 20 MB por archivo.