UNPKG

@aidalinfo/pdf-processor

Version:

Powerful PDF data extraction library powered by AI vision models. Transform PDFs into structured, validated data using TypeScript, Zod, and AI providers like Scaleway and Ollama.

1,312 lines (1,281 loc) 53.7 kB
import { createRequire } from "node:module"; var __create = Object.create; var __getProtoOf = Object.getPrototypeOf; var __defProp = Object.defineProperty; var __getOwnPropNames = Object.getOwnPropertyNames; var __hasOwnProp = Object.prototype.hasOwnProperty; var __toESM = (mod, isNodeMode, target) => { target = mod != null ? __create(__getProtoOf(mod)) : {}; const to = isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target; for (let key of __getOwnPropNames(mod)) if (!__hasOwnProp.call(to, key)) __defProp(to, key, { get: () => mod[key], enumerable: true }); return to; }; var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true, configurable: true, set: (newValue) => all[name] = () => newValue }); }; var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res); var __require = /* @__PURE__ */ createRequire(import.meta.url); // src/utils/logger.ts import pino from "pino"; function createModuleLogger(module) { return logger.child({ module }); } var isDevelopment, pinoConfig, logger; var init_logger = __esm(() => { isDevelopment = process.env.EK_NODE_ENV === "development" || process.env.EK_NODE_ENV !== "production"; pinoConfig = { level: process.env.EK_LOG_LEVEL || (isDevelopment ? "debug" : "info"), ...isDevelopment ? { transport: { target: "pino-pretty", options: { colorize: true, translateTime: "SYS:HH:MM:ss.l", ignore: "pid,hostname", singleLine: true } } } : { formatters: { level: (label) => { return { level: label.toUpperCase() }; } }, timestamp: pino.stdTimeFunctions.isoTime } }; logger = pino(pinoConfig); }); // src/core/file-processor.ts var exports_file_processor = {}; __export(exports_file_processor, { extractImagesFromPDF: () => extractImagesFromPDF, createTempDir: () => createTempDir, cleanupTempDir: () => cleanupTempDir, cleanupAllTempDirs: () => cleanupAllTempDirs }); import fs from "fs/promises"; import path from "path"; import sharp from "sharp"; import os from "os"; import { existsSync } from "fs"; import * as gs from "ghostscript-node"; async function extractImagesFromPDF(pdfPath, outputDir, options) { try { if (!existsSync(pdfPath)) { throw new Error(`PDF file does not exist: ${pdfPath}`); } const pdfBuffer = await fs.readFile(pdfPath); const isValid = await gs.isValidPDF(pdfBuffer); if (!isValid) { throw new Error("The PDF file is not valid"); } const imageBuffers = await gs.renderPDFPagesToPNG(pdfBuffer, undefined, undefined, options.dpi || 300); logger2.info({ pageCount: imageBuffers.length }, "\uD83D\uDCC4 PDF contient pages"); const saveStart = Date.now(); logger2.debug({ imageCount: imageBuffers.length }, "\uD83D\uDCBE Sauvegarde des images sur disque..."); const imagePaths = new Array(imageBuffers.length); await Promise.all(imageBuffers.map(async (buffer, i) => { const imagePath = path.join(outputDir, `page-${i + 1}.png`); await fs.writeFile(imagePath, buffer); imagePaths[i] = imagePath; })); const saveTime = Date.now() - saveStart; logger2.debug({ saveTime }, "Sauvegarde terminée"); logger2.info({ imageCount: imagePaths.length }, "\uD83D\uDDBC️ Images extraites pour Vision LLM"); return imagePaths; } catch (error) { logger2.error({ error }, "❌ Erreur lors de l'extraction des images"); throw new Error(`❌ Échec de l'extraction d'images: ${error.message}`); } } async function createTempDir() { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "pdf-processor-")); tempDirs.add(tempDir); return tempDir; } async function cleanupTempDir(tempDir) { try { if (existsSync(tempDir)) { await fs.rm(tempDir, { recursive: true, force: true }); tempDirs.delete(tempDir); } } catch (error) { logger2.warn({ error, tempDir }, "⚠️ Could not clean up temp directory"); } } async function cleanupAllTempDirs() { const cleanupPromises = Array.from(tempDirs).map(cleanupTempDir); await Promise.allSettled(cleanupPromises); } var logger2, tempDirs; var init_file_processor = __esm(() => { init_logger(); logger2 = createModuleLogger("file-processor"); tempDirs = new Set; process.on("beforeExit", async () => { await cleanupAllTempDirs(); }); }); // src/api/server.ts init_logger(); // src/core/vision/processor.ts init_logger(); import { z as z5 } from "zod"; import path5 from "path"; // src/core/vision/image-optimization.ts init_logger(); import fs2 from "fs/promises"; import path2 from "path"; // src/core/types.ts var DEFAULT_MODELS = { scaleway: "mistral-small-3.1-24b-instruct-2503", ollama: "llava:13b", mistral: "pixtral-12b-latest", custom: "" }; // src/core/vision/image-optimization.ts var logger3 = createModuleLogger("image-optimization"); class ImageOptimizer { async processDirect(filePath, options) { const tempDir = await this.createTempDir(); try { const { extractImagesFromPDF: extractImagesFromPDF2 } = await Promise.resolve().then(() => (init_file_processor(), exports_file_processor)); const rawImages = await extractImagesFromPDF2(filePath, tempDir, { provider: options.provider, dpi: options.dpi || 300, extractImages: true }); if (rawImages.length === 0) { throw new Error("Aucune image extraite du PDF"); } logger3.debug({ imageCount: rawImages.length }, "\uD83D\uDDBC️ Images extraites (mode direct)"); const optimizedImages = await this.optimizeDirectly(rawImages, options); const totalOriginalSize = optimizedImages.reduce((sum, img) => sum + img.originalSizeBytes, 0); const totalOptimizedSize = optimizedImages.reduce((sum, img) => sum + img.optimizedSizeBytes, 0); const averageCompressionRatio = optimizedImages.reduce((sum, img) => sum + img.compressionRatio, 0) / optimizedImages.length; return { optimizedImages, optimizationMetrics: { originalSizeMB: totalOriginalSize / 1048576, optimizedSizeMB: totalOptimizedSize / 1048576, compressionRatio: averageCompressionRatio } }; } finally { await this.cleanupTempDir(tempDir); } } async optimizeDirectly(imagePaths, options) { const sharp2 = (await import("sharp")).default; const fs3 = await import("fs/promises"); const { existsSync: existsSync2 } = await import("fs"); const results = []; for (const imagePath of imagePaths) { if (!existsSync2(imagePath)) { throw new Error(`Image not found: ${imagePath}`); } const originalStats = await fs3.stat(imagePath); const originalSizeBytes = originalStats.size; let pipeline = sharp2(imagePath); const metadata = await pipeline.metadata(); const optimizations = []; if (options.cropSize && options.cropSize > 0 && options.cropSize < 100) { const cropPercent = options.cropSize / 100; const cropWidth = Math.floor((metadata.width || 0) * cropPercent); const cropHeight = Math.floor((metadata.height || 0) * cropPercent); if (cropWidth > 100 && cropHeight > 100) { const left = Math.floor(((metadata.width || 0) - cropWidth) / 2); const top = Math.floor(((metadata.height || 0) - cropHeight) / 2); pipeline = pipeline.extract({ left, top, width: cropWidth, height: cropHeight }); optimizations.push(`crop-${options.cropSize}%`); } } const { maxPixels, maxDimension } = this.getMaxResolutionForProvider(options.provider, options.model); const currentPixels = (metadata.width || 0) * (metadata.height || 0); if (currentPixels > maxPixels || metadata.width && metadata.width > maxDimension || metadata.height && metadata.height > maxDimension) { const aspectRatio = (metadata.width || 1) / (metadata.height || 1); let newWidth = Math.sqrt(maxPixels * aspectRatio); let newHeight = Math.sqrt(maxPixels / aspectRatio); if (newWidth > maxDimension) { newWidth = maxDimension; newHeight = newWidth / aspectRatio; } if (newHeight > maxDimension) { newHeight = maxDimension; newWidth = newHeight * aspectRatio; } newWidth = Math.round(newWidth); newHeight = Math.round(newHeight); pipeline = pipeline.resize(newWidth, newHeight, { kernel: sharp2.kernel.lanczos3, withoutEnlargement: true }); optimizations.push(`resize-${newWidth}x${newHeight}-pixels`); } if (options.enhanceContrast !== false) { pipeline = pipeline.modulate({ brightness: 1.05, saturation: 1.1 }).sharpen({ sigma: 0.8, m1: 1, m2: 2, x1: 2, y2: 10, y3: 20 }); optimizations.push("contrast-enhanced"); } const targetQuality = options.targetQuality || 95; pipeline = pipeline.jpeg({ quality: targetQuality, progressive: true, mozjpeg: true }); optimizations.push(`jpeg-q${targetQuality}`); const optimizedBuffer = await pipeline.toBuffer(); const base64 = optimizedBuffer.toString("base64"); results.push({ base64, optimizedSizeBytes: optimizedBuffer.length, originalSizeBytes, compressionRatio: optimizedBuffer.length / originalSizeBytes, optimizations }); } logger3.debug({ imageCount: results.length }, "✅ Images optimisées (mode direct)"); return results; } async createTempDir() { const tempDir = path2.join(process.env.EK_TMPDIR || "/tmp", `ai-vision-${Date.now()}`); await fs2.mkdir(tempDir, { recursive: true }); return tempDir; } async cleanupTempDir(tempDir) { try { await fs2.rm(tempDir, { recursive: true, force: true }); } catch (error) { logger3.warn({ error, tempDir }, "⚠️ Erreur nettoyage répertoire temporaire"); } } getMaxResolutionForProvider(provider, model) { const modelName = model || DEFAULT_MODELS[provider] || ""; if (modelName.toLowerCase().includes("pixtral")) { return { maxPixels: 1048576, maxDimension: 1024 }; } else if (modelName.toLowerCase().includes("mistral")) { return { maxPixels: 2371600, maxDimension: 1540 }; } return { maxPixels: 4194304, maxDimension: 2048 }; } } // src/core/vision/worker-manager.ts init_logger(); import path4 from "path"; // src/core/workers/worker-pool-manager.ts init_logger(); import path3 from "path"; import crypto from "crypto"; var logger4 = createModuleLogger("worker-pool-manager"); class WorkerPool { workers = []; availableWorkers = []; pendingTasks = []; activeTasks = new Map; config; constructor(config) { this.config = config; this.initializeWorkers(); } initializeWorkers() { logger4.info({ maxWorkers: this.config.maxWorkers, script: path3.basename(this.config.workerScript) }, "\uD83C\uDFED Initialisation pool"); for (let i = 0;i < this.config.maxWorkers; i++) { const worker = new Worker(this.config.workerScript); worker.onmessage = (event) => { this.handleWorkerMessage(worker, event.data); }; worker.onerror = (error) => { this.handleWorkerError(worker, error); }; this.workers.push(worker); this.availableWorkers.push(worker); } } async executeTask(data, timeout) { return new Promise((resolve, reject) => { const taskId = crypto.randomUUID(); const task = { taskId, data: { ...data, taskId }, resolve, reject, timeout: timeout || this.config.taskTimeout }; this.pendingTasks.push(task); this.processPendingTasks(); }); } processPendingTasks() { while (this.pendingTasks.length > 0 && this.availableWorkers.length > 0) { const task = this.pendingTasks.shift(); const worker = this.availableWorkers.shift(); this.activeTasks.set(task.taskId, task); setTimeout(() => { if (this.activeTasks.has(task.taskId)) { this.handleTaskTimeout(task); } }, task.timeout); worker.postMessage(task.data); } } handleWorkerMessage(worker, result) { const taskId = result.taskId; const task = this.activeTasks.get(taskId); if (!task) { logger4.warn({ taskId }, "⚠️ Réponse worker pour tâche inconnue"); return; } this.activeTasks.delete(taskId); this.availableWorkers.push(worker); if (result.success) { task.resolve(result); } else { task.reject(new Error(result.error || "Worker task failed")); } this.processPendingTasks(); } handleWorkerError(worker, error) { logger4.error({ error }, "❌ Erreur worker"); for (const [taskId, task] of this.activeTasks.entries()) { task.reject(new Error(`Worker error: ${error.message}`)); this.activeTasks.delete(taskId); } this.workers = this.workers.filter((w) => w !== worker); this.availableWorkers = this.availableWorkers.filter((w) => w !== worker); this.createReplacementWorker(); } handleTaskTimeout(task) { this.activeTasks.delete(task.taskId); task.reject(new Error(`Task timeout after ${task.timeout}ms`)); } createReplacementWorker() { try { const worker = new Worker(this.config.workerScript); worker.onmessage = (event) => { this.handleWorkerMessage(worker, event.data); }; worker.onerror = (error) => { this.handleWorkerError(worker, error); }; this.workers.push(worker); this.availableWorkers.push(worker); logger4.info("\uD83D\uDD04 Worker de remplacement créé"); } catch (error) { logger4.error({ error }, "❌ Impossible de créer un worker de remplacement"); } } getStats() { return { totalWorkers: this.workers.length, availableWorkers: this.availableWorkers.length, activeTasks: this.activeTasks.size, pendingTasks: this.pendingTasks.length, utilization: (this.workers.length - this.availableWorkers.length) / this.workers.length * 100 }; } async shutdown() { logger4.info("\uD83D\uDD3B Arrêt du pool de workers..."); for (const [taskId, task] of this.activeTasks.entries()) { task.reject(new Error("Worker pool shutdown")); } this.activeTasks.clear(); this.pendingTasks.forEach((task) => { task.reject(new Error("Worker pool shutdown")); }); this.pendingTasks.length = 0; await Promise.all(this.workers.map((worker) => worker.terminate())); this.workers.length = 0; this.availableWorkers.length = 0; } } class WorkerPoolManager { pools = new Map; getPool(name, config) { if (!this.pools.has(name)) { if (!config) { throw new Error(`Pool '${name}' n'existe pas et aucune configuration fournie`); } this.pools.set(name, new WorkerPool(config)); } return this.pools.get(name); } getGlobalStats() { const stats = new Map; for (const [name, pool] of this.pools.entries()) { stats.set(name, pool.getStats()); } return Object.fromEntries(stats); } async shutdownAll() { logger4.info("\uD83D\uDD3B Arrêt de tous les pools de workers..."); await Promise.all(Array.from(this.pools.values()).map((pool) => pool.shutdown())); this.pools.clear(); } } var workerPoolManager = new WorkerPoolManager; process.on("SIGINT", async () => { await workerPoolManager.shutdownAll(); process.exit(0); }); process.on("SIGTERM", async () => { await workerPoolManager.shutdownAll(); process.exit(0); }); // src/core/vision/worker-manager.ts var __dirname = "/home/killian/Documents/dev/extract-kit/packages/pdf-processor/src/core/vision"; var logger5 = createModuleLogger("worker-manager"); class WorkerManager { useWorkers; constructor() { this.useWorkers = process.env.EK_ENABLE_WORKERS === "true" || process.env.EK_ENABLE_WORKERS === "1"; if (this.useWorkers) { this.initializeWorkerPools(); } else { logger5.info("⚡ Mode direct activé (workers désactivés) - Set EK_ENABLE_WORKERS=true pour activer les workers"); } } isEnabled() { return this.useWorkers; } initializeWorkerPools() { workerPoolManager.getPool("pdf-extraction", { maxWorkers: parseInt(process.env.EK_PDF_WORKERS || "2"), workerScript: path4.join(__dirname, "../workers/pdf-extraction-worker.ts"), taskTimeout: parseInt(process.env.EK_PDF_WORKER_TIMEOUT || "60000") }); workerPoolManager.getPool("vision-optimization", { maxWorkers: parseInt(process.env.EK_VISION_WORKERS || "3"), workerScript: path4.join(__dirname, "../workers/vision-optimization-worker.ts"), taskTimeout: parseInt(process.env.EK_VISION_WORKER_TIMEOUT || "30000") }); const totalWorkers = parseInt(process.env.EK_PDF_WORKERS || "2") + parseInt(process.env.EK_VISION_WORKERS || "3"); logger5.info({ pdfWorkers: process.env.EK_PDF_WORKERS || "2", visionWorkers: process.env.EK_VISION_WORKERS || "3", totalWorkers }, "\uD83C\uDFED Workers activés"); } async extractImages(filePath, options) { const tempDir = await this.createTempDir(); const pdfPool = workerPoolManager.getPool("pdf-extraction"); logger5.debug({ file: path4.basename(filePath) }, "\uD83D\uDCC4 Worker PDF: Extraction"); const task = { taskId: "", pdfPath: filePath, outputDir: tempDir, dpi: options.dpi || 300 }; const result = await pdfPool.executeTask(task); if (!result.success || !result.imagePaths) { throw new Error(`PDF extraction failed: ${result.error}`); } logger5.info({ pageCount: result.pageCount, processingTime: result.processingTime }, "✅ Worker PDF: Pages extraites"); return { imagePaths: result.imagePaths, pageCount: result.pageCount || result.imagePaths.length }; } async optimizeImages(imagePaths, options) { const visionPool = workerPoolManager.getPool("vision-optimization"); logger5.debug({ imageCount: imagePaths.length }, "\uD83C\uDFA8 Worker Vision: Optimisation images"); const task = { taskId: "", imagePaths, options: { provider: options.provider, cropSize: options.cropSize, enhanceContrast: options.enhanceContrast !== false, preserveColor: true, targetQuality: options.targetQuality || 95 } }; const result = await visionPool.executeTask(task); if (!result.success || !result.images) { throw new Error(`Vision optimization failed: ${result.error}`); } logger5.info({ imageCount: result.images.length, processingTime: result.processingTime, originalSizeMB: (result.totalOriginalSize / 1024 / 1024).toFixed(1), optimizedSizeMB: (result.totalOptimizedSize / 1024 / 1024).toFixed(1) }, "✅ Worker Vision: Images optimisées"); return result; } async createTempDir() { const fs3 = await import("fs/promises"); const tempDir = path4.join(process.env.EK_TMPDIR || "/tmp", `ai-vision-${Date.now()}`); await fs3.mkdir(tempDir, { recursive: true }); return tempDir; } } // src/core/vision/schema-selector.ts init_logger(); // src/core/schemas/base.ts import { z } from "zod"; var AddressSchema = z.object({ street: z.string().nullable().optional(), city: z.string().nullable().optional(), postal_code: z.string().nullable().optional(), country: z.string().nullable().optional() }); var ContactInfoSchema = z.object({ name: z.string().nullable().optional(), company_name: z.string().nullable().optional(), address: AddressSchema.nullable().optional(), phone: z.string().nullable().optional(), email: z.string().email().nullable().or(z.literal(null)).optional(), website: z.string().nullable().optional(), vat_number: z.string().nullable().optional(), tax_id: z.string().nullable().optional() }); var DocumentInfoSchema = z.object({ document_type: z.string().nullable().optional(), language: z.string().nullable().optional(), currency: z.string().nullable().optional(), total_pages: z.number().int().nullable().optional() }); var PaymentInfoSchema = z.object({ payment_terms: z.string().nullable().optional(), payment_method: z.string().nullable().optional(), payment_due_date: z.string().nullable().optional(), bank_details: z.string().nullable().optional(), iban: z.string().nullable().optional(), swift_code: z.string().nullable().optional() }); // src/core/schemas/invoice.ts import { z as z2 } from "zod"; var InvoiceLineItemSchema = z2.object({ item_number: z2.string().nullable().optional(), description: z2.string().nullable().optional(), quantity: z2.number().nullable().optional(), unit: z2.string().nullable().optional(), unit_price: z2.number().nullable().optional(), discount: z2.number().nullable().optional(), tax_rate: z2.number().nullable().optional(), tax_amount: z2.number().nullable().optional(), line_total: z2.number().nullable().optional(), quantite: z2.number().nullable().optional(), prix_unitaire: z2.number().nullable().optional(), montant_ht: z2.number().nullable().optional(), montant_ttc: z2.number().nullable().optional(), taux_tva: z2.number().nullable().optional(), montant_tva: z2.number().nullable().optional(), currency: z2.string().nullable().optional() }); var FinancialTotalsSchema = z2.object({ subtotal: z2.number().nullable().optional(), discount_total: z2.number().nullable().optional(), tax_total: z2.number().nullable().optional(), shipping_cost: z2.number().nullable().optional(), total_amount: z2.number().nullable().optional(), amount_paid: z2.number().nullable().optional(), balance_due: z2.number().nullable().optional(), total_ht: z2.number().nullable().optional(), total_tva: z2.number().nullable().optional(), total_ttc: z2.number().nullable().optional(), montant_ht: z2.number().nullable().optional(), montant_tva: z2.number().nullable().optional(), montant_ttc: z2.number().nullable().optional(), currency: z2.string().nullable().optional() }); var InvoiceDetailsSchema = z2.object({ invoice_number: z2.string().nullable().optional(), invoice_date: z2.string().nullable().optional(), due_date: z2.string().nullable().optional(), purchase_order: z2.string().nullable().optional(), reference_number: z2.string().nullable().optional() }); var ComprehensiveInvoiceSchema = z2.object({ document_info: DocumentInfoSchema.optional(), invoice_details: InvoiceDetailsSchema.optional(), seller_info: ContactInfoSchema.optional(), buyer_info: ContactInfoSchema.optional(), line_items: z2.array(InvoiceLineItemSchema).optional(), financial_totals: FinancialTotalsSchema.optional(), payment_info: PaymentInfoSchema.optional(), pages: z2.array(z2.object({ page: z2.number(), page_tables: z2.array(z2.object({ billed_services: z2.array(InvoiceLineItemSchema).optional(), totals: FinancialTotalsSchema.optional(), sections_detaillees: z2.record(z2.object({ items: z2.record(z2.object({ quantite: z2.number().nullable().optional(), prix_unitaire: z2.number().nullable().optional(), montant_ht: z2.number().nullable().optional(), taux_tva: z2.number().nullable().optional(), montant_tva: z2.number().nullable().optional(), montant_ttc: z2.number().nullable().optional() })), sous_total: FinancialTotalsSchema.optional() })).optional(), total: FinancialTotalsSchema.optional(), reference: z2.string().optional(), exercice: z2.string().nullable().optional(), montant_ttc: z2.number().nullable().optional(), currency: z2.string().nullable().optional(), raw_data: z2.record(z2.any()).optional() })) })).optional(), extraction_metadata: z2.object({ confidence_score: z2.number().min(0).max(1).nullable(), fields_found: z2.number().int().nullable(), fields_empty: z2.number().int().nullable(), processing_notes: z2.array(z2.string()).optional() }).optional() }); var BasicReceiptSchema = z2.object({ merchant_name: z2.string().nullable().optional(), transaction_date: z2.string().nullable().optional(), total_amount: z2.number().nullable().optional(), payment_method: z2.string().nullable().optional(), currency: z2.string().nullable().optional(), items: z2.array(z2.object({ name: z2.string().nullable().optional(), price: z2.number().nullable().optional() })).optional() }); // src/core/schemas/tables.ts import { z as z3 } from "zod"; var TableRowSchema = z3.array(z3.union([z3.string(), z3.number(), z3.null()])); var DetectedTableSchema = z3.object({ table_name: z3.string().nullable().optional(), table_type: z3.string().nullable().optional(), headers: z3.array(z3.string()).optional(), rows: z3.array(TableRowSchema).optional(), summary: z3.string().nullable().optional() }); var TablesOnlySchema = z3.object({ detected_tables: z3.array(DetectedTableSchema).optional(), extraction_metadata: z3.object({ tables_found: z3.number().int().nullable().optional(), confidence_score: z3.number().min(0).max(1).nullable().optional() }).optional() }); // src/core/schemas/factory.ts import { z as z4 } from "zod"; class SchemaFactory { static createFromJSON(jsonSchema) { try { const parsed = JSON.parse(jsonSchema); return this.convertJSONSchemaToZod(parsed); } catch (error) { throw new Error(`Invalid JSON schema: ${error.message}`); } } static convertJSONSchemaToZod(jsonSchema) { if (jsonSchema.type === "object" && jsonSchema.properties) { const shape = {}; for (const [key, prop] of Object.entries(jsonSchema.properties)) { shape[key] = this.convertPropertyToZod(prop); } return z4.object(shape); } return z4.any(); } static convertPropertyToZod(prop) { if (Array.isArray(prop.type)) { if (prop.type.includes("null")) { const nonNullType = prop.type.find((t) => t !== "null"); return this.getZodTypeForString(nonNullType).nullable(); } } if (prop.type === "array" && prop.items) { const itemSchema = this.convertPropertyToZod(prop.items); return z4.array(itemSchema); } if (prop.type === "object" && prop.properties) { return this.convertJSONSchemaToZod(prop); } return this.getZodTypeForString(prop.type); } static getZodTypeForString(type) { switch (type) { case "string": return z4.string(); case "number": return z4.number(); case "integer": return z4.number().int(); case "boolean": return z4.boolean(); case "array": return z4.array(z4.any()); case "object": return z4.object({}); default: return z4.any(); } } static getSchemaForDocumentType(documentType) { switch (documentType.toLowerCase()) { case "invoice": return ComprehensiveInvoiceSchema; case "receipt": return BasicReceiptSchema; case "tables": return TablesOnlySchema; case "basic": case "simple": return ComprehensiveInvoiceSchema; default: return ComprehensiveInvoiceSchema; } } } // src/core/vision/schema-selector.ts var logger6 = createModuleLogger("schema-selector"); class SchemaSelector { selectSchema(options) { if (options.customSchema) { logger6.debug("Utilisation schéma personnalisé"); return { schema: options.customSchema, schemaName: "custom" }; } if (options.tablesOnly) { logger6.debug("Mode tableaux uniquement activé"); return { schema: TablesOnlySchema, schemaName: "tables-only" }; } if (options.documentType) { logger6.debug({ documentType: options.documentType }, "Sélection par type de document"); const schema = SchemaFactory.getSchemaForDocumentType(options.documentType); return { schema, schemaName: options.documentType }; } if (options.query && options.query !== "*" && this.isValidJSON(options.query)) { try { logger6.debug("Conversion JSON Schema → Zod"); const schema = SchemaFactory.createFromJSON(options.query); return { schema, schemaName: "custom-json" }; } catch (error) { logger6.warn("⚠️ Échec conversion JSON → Zod, utilisation schéma complet"); } } logger6.debug("Utilisation schéma complet par défaut"); return { schema: ComprehensiveInvoiceSchema, schemaName: "comprehensive" }; } isValidJSON(str) { try { JSON.parse(str); return true; } catch { return false; } } } // src/core/vision/ai-generator.ts init_logger(); import { generateObject } from "ai"; import { createOpenAI } from "@ai-sdk/openai"; var logger7 = createModuleLogger("ai-generator"); class AIGenerator { async generate(images, schema, options) { const provider = options.provider || "scaleway"; const modelName = options.model || options.pdfProcessor?.providers?.[provider]?.model || DEFAULT_MODELS[provider]; const model = this.getModelInstance(provider, options.model, options.pdfProcessor); const prompt = this.buildPromptForSchema(schema, options); const modelToUse = options.model || DEFAULT_MODELS[provider]; logger7.debug({ provider, model: modelToUse }, "\uD83C\uDFAF Génération avec AI"); const imageMessages = this.formatImagesForProvider(images, provider); logger7.debug({ provider, model: modelName, imageCount: images.length, schemaKeys: Object.keys(schema.shape || {}), promptLength: prompt.length, systemPromptLength: this.getSystemPrompt(provider).length, maxRetries: options.maxRetries || 2 }, "\uD83C\uDFAF Début génération AI avec tous les détails"); try { const result = await generateObject({ model, schema, messages: [ { role: "system", content: this.getSystemPrompt(provider) }, { role: "user", content: [ { type: "text", text: prompt }, ...imageMessages ] } ], maxRetries: options.maxRetries || 2 }); logger7.debug("✅ Données structurées générées et validées par Zod"); return { ...result, modelUsed: modelName }; } catch (error) { console.log("❌ Erreur lors de la génération AI", error); logger7.error({ error: error.message || error.toString(), stack: error.stack, name: error.name, provider, model: modelName, cause: error.cause }, "❌ Erreur génération AI"); throw error; } } getModelInstance(provider, model, config) { const providerConfig = config?.providers?.[provider]; const modelToUse = model || providerConfig?.model || DEFAULT_MODELS[provider]; logger7.debug({ provider, model: modelToUse, hasCustomConfig: !!providerConfig }, "\uD83E\uDD16 Configuration modèle"); switch (provider) { case "scaleway": const apiKey = providerConfig?.apiKey || process.env.EK_AI_API_KEY; const baseURL = providerConfig?.baseURL || process.env.EK_AI_BASE_URL || "https://api.scaleway.ai/v1"; if (!apiKey) { throw new Error("Scaleway API key requis: fournissez-le via pdfProcessor.providers.scaleway.apiKey ou EK_AI_API_KEY"); } const scalewayClient = createOpenAI({ apiKey, baseURL }); return scalewayClient.chat(modelToUse); case "mistral": const mistralApiKey = providerConfig?.apiKey || process.env.EK_MISTRAL_API_KEY; const mistralBaseURL = providerConfig?.baseURL; if (!mistralApiKey) { throw new Error("Mistral API key requis: fournissez-le via pdfProcessor.providers.mistral.apiKey ou EK_MISTRAL_API_KEY"); } const mistralConfig = { apiKey: mistralApiKey, baseURL: "https://api.mistral.ai/v1" }; if (mistralBaseURL) { mistralConfig.baseURL = mistralBaseURL; } const mistralClient = createOpenAI(mistralConfig); return mistralClient.chat(modelToUse); case "ollama": const ollamaConfig = { baseURL: "http://localhost:11434/api" }; if (providerConfig?.baseURL) { ollamaConfig.baseURL = providerConfig.baseURL; } const ollama = createOpenAI(ollamaConfig); return ollama.chat(modelToUse); case "custom": const customApiKey = providerConfig?.apiKey || process.env.CUSTOM_API_KEY; const customBaseURL = providerConfig?.baseURL; if (!customApiKey) { throw new Error("Custom API key requis: fournissez-le via pdfProcessor.providers.custom.apiKey ou CUSTOM_API_KEY"); } if (!customBaseURL) { throw new Error("Custom baseURL requis: fournissez-le via pdfProcessor.providers.custom.baseURL"); } const customClient = createOpenAI({ apiKey: customApiKey, baseURL: customBaseURL }); return customClient.chat(modelToUse); default: throw new Error(`Provider non supporté: ${provider}. Seuls 'scaleway', 'mistral', 'ollama' et 'custom' sont supportés.`); } } buildPromptForSchema(schema, options) { const basePrompt = `Extract structured data from this document following the provided schema exactly.`; if (options.tablesOnly) { return `${basePrompt} TASK: Focus exclusively on extracting tables and tabular data. - Identify all tables in the document - Extract headers and all data rows completely - Preserve data types (numbers as numbers) - Use null for empty cells - Include table context/summary if visible`; } if (options.documentType === "invoice") { return `${basePrompt} TASK: Extract comprehensive invoice information. - Invoice details (number, date, amounts) - Seller and buyer information - Line items with quantities and prices - Financial totals and tax information - Payment terms and banking details - Use null for missing fields - never guess`; } return `${basePrompt} TASK: Perform comprehensive document data extraction. - Extract all visible information systematically - Follow the schema structure exactly - Use appropriate data types (numbers as numbers, not strings) - Use null for fields that are not visible or unclear - Maintain high precision - only extract clearly visible data `; } formatImagesForProvider(images, provider) { return images.map((img) => ({ type: "image", image: `data:image/jpeg;base64,${img.base64}` })); } getSystemPrompt(provider) { return `You are a professional document data extraction specialist. You excel at: - Extracting structured data from invoices, receipts, and business documents - Following schemas with absolute precision - Using appropriate data types (numbers as numbers, not strings) - Returning null for missing or unclear fields - never guess - Maintaining high accuracy with complex document layouts - Generate valid JSON only - no trailing commas, no empty objects with trailing commas - If an object/array is empty, write it as {} or [] without trailing commas Extract data exactly as requested in the schema.`; } } // src/core/vision/processor.ts var logger8 = createModuleLogger("vision-processor"); class AIVisionProcessor { workerManager; imageOptimizer; schemaSelector; aiGenerator; constructor() { this.workerManager = new WorkerManager; this.imageOptimizer = new ImageOptimizer; this.schemaSelector = new SchemaSelector; this.aiGenerator = new AIGenerator; } async process(filePath, options) { const startTime = Date.now(); const provider = options.provider || "scaleway"; logger8.info({ file: path5.basename(filePath), provider, model: options.model }, "\uD83E\uDD16 AI Vision démarrage"); try { const { optimizedImages, optimizationMetrics, pageCount } = await this.processImages(filePath, options); const { schema, schemaName } = this.schemaSelector.selectSchema(options); const result = await this.aiGenerator.generate(optimizedImages, schema, options); const processingTime = Date.now() - startTime; logger8.info({ processingTime, schemaName }, "✅ AI Vision terminé"); return { data: result.object, metadata: { pageCount, processingTime, provider, model: result.modelUsed, schemaUsed: schemaName, optimizationMetrics }, validation: { success: true } }; } catch (error) { logger8.error({ error: error.message || error.toString(), stack: error.stack, name: error.name, cause: error.cause }, "❌ Erreur AI Vision"); if (error instanceof z5.ZodError) { return { data: null, metadata: { pageCount: 0, processingTime: Date.now() - startTime, provider, model: "unknown", schemaUsed: "unknown", optimizationMetrics: { originalSizeMB: 0, optimizedSizeMB: 0, compressionRatio: 1 } }, validation: { success: false, errors: error } }; } logger8.error({ error: error.message || error.toString(), stack: error.stack, name: error.name, status: error.status, statusText: error.statusText, url: error.url, response: error.response, cause: error.cause }, "❌ AI Vision processing failed - Détails complets"); throw new Error(`AI Vision processing failed: ${error.message}`); } } async processImages(filePath, options) { if (this.workerManager.isEnabled()) { logger8.debug("\uD83C\uDFED Mode Workers activé"); const extractResult = await this.workerManager.extractImages(filePath, options); const optimizationResult = await this.workerManager.optimizeImages(extractResult.imagePaths, options); return { optimizedImages: optimizationResult.images, optimizationMetrics: { originalSizeMB: (optimizationResult.totalOriginalSize || 0) / (1024 * 1024), optimizedSizeMB: (optimizationResult.totalOptimizedSize || 0) / (1024 * 1024), compressionRatio: optimizationResult.averageCompressionRatio || 1 }, pageCount: extractResult.pageCount }; } else { logger8.debug("⚡ Mode Direct activé"); const directResult = await this.imageOptimizer.processDirect(filePath, options); return { optimizedImages: directResult.optimizedImages, optimizationMetrics: directResult.optimizationMetrics, pageCount: directResult.optimizedImages.length }; } } } var aiVisionProcessor = new AIVisionProcessor; // src/core/vision/index.ts async function extractWithAI(filePath, schema, options = {}) { const result = await aiVisionProcessor.process(filePath, { provider: options.provider || "scaleway", model: options.model, customSchema: schema, enhanceContrast: options.enhanceContrast !== false, targetQuality: options.targetQuality || 95, ...options }); if (!result.validation.success) { throw new Error(`Validation failed: ${result.validation.errors?.message}`); } return result.data; } async function extractInvoice(filePath, options = {}) { return extractWithAI(filePath, ComprehensiveInvoiceSchema, { documentType: "invoice", ...options }); } async function extractTables(filePath, options = {}) { return extractWithAI(filePath, TablesOnlySchema, { tablesOnly: true, ...options }); } // src/api/handlers.ts init_logger(); // src/api/validation.ts import { z as z6 } from "zod"; var ExtractRequestSchema = z6.object({ provider: z6.enum(["scaleway", "ollama"]).optional().default("scaleway"), model: z6.string().optional(), query: z6.string().optional().default("*"), cropSize: z6.number().min(10).max(100).optional(), tablesOnly: z6.boolean().optional().default(false), documentType: z6.enum(["invoice", "receipt", "basic", "custom"]).optional(), enhanceContrast: z6.boolean().optional().default(true), targetQuality: z6.number().min(70).max(100).optional().default(95), debug: z6.boolean().optional().default(false) }); function validateExtractRequest(body) { try { const validated = ExtractRequestSchema.parse(body); return { valid: true, data: validated }; } catch (error) { return { valid: false, error: error.message }; } } // src/api/utils.ts init_logger(); import fs3 from "fs/promises"; import path6 from "path"; var logger9 = createModuleLogger("api-utils"); async function createTempFile(content, extension = "pdf") { const tempFileName = path6.join("/tmp", `vision_${Date.now()}.${extension}`); await Bun.write(tempFileName, content); logger9.debug({ filePath: tempFileName }, "\uD83D\uDCC1 Fichier temporaire créé"); return { filePath: tempFileName, cleanup: async () => { try { await fs3.unlink(tempFileName); logger9.debug({ filePath: tempFileName }, "\uD83E\uDDF9 Nettoyage fichier temporaire"); } catch (error) { logger9.warn({ error, filePath: tempFileName }, "⚠️ Erreur nettoyage fichier temporaire"); } } }; } function createCorsHeaders(origins = ["*"]) { return { "Access-Control-Allow-Origin": origins.join(", "), "Access-Control-Allow-Methods": "POST, GET, OPTIONS", "Access-Control-Allow-Headers": "Content-Type" }; } // src/api/handlers.ts var logger10 = createModuleLogger("api-handlers"); async function handleExtractRequest(req, corsHeaders) { try { logger10.info("\uD83C\uDFAF Requête Vision LLM reçue"); const contentType = req.headers.get("Content-Type") || ""; if (!contentType.includes("multipart/form-data")) { return new Response(JSON.stringify({ success: false, error: "Content-Type doit être multipart/form-data" }), { status: 400, headers: { "Content-Type": "application/json", ...corsHeaders } }); } const formData = await req.formData(); const pdfFile = formData.get("file"); if (!pdfFile || !(pdfFile instanceof File)) { return new Response(JSON.stringify({ success: false, error: "Fichier PDF manquant ou invalide" }), { status: 400, headers: { "Content-Type": "application/json", ...corsHeaders } }); } const requestBody = { provider: formData.get("provider")?.toString(), model: formData.get("model")?.toString(), query: formData.get("query")?.toString(), cropSize: formData.get("cropSize") ? parseInt(formData.get("cropSize").toString()) : undefined, tablesOnly: formData.get("tablesOnly")?.toString() === "true", documentType: formData.get("documentType")?.toString(), enhanceContrast: formData.get("enhanceContrast")?.toString() !== "false", targetQuality: formData.get("targetQuality") ? parseInt(formData.get("targetQuality").toString()) : undefined, debug: formData.get("debug")?.toString() === "true" }; const validation = validateExtractRequest(requestBody); if (!validation.valid) { return new Response(JSON.stringify({ success: false, error: `Paramètres invalides: ${validation.error}` }), { status: 400, headers: { "Content-Type": "application/json", ...corsHeaders } }); } const options = validation.data; logger10.info({ provider: options.provider, model: options.model || "default", query: options.query }, "\uD83D\uDD27 Configuration requête"); const { filePath, cleanup } = await createTempFile(await pdfFile.arrayBuffer()); try { const result = await aiVisionProcessor.process(filePath, { provider: options.provider, model: options.model, query: options.query, cropSize: options.cropSize, tablesOnly: options.tablesOnly, documentType: options.documentType, enhanceContrast: options.enhanceContrast, targetQuality: options.targetQuality, dpi: 300 }); logger10.info({ processingTime: result.metadata.processingTime, pages: result.metadata.pageCount }, "✅ Extraction réussie"); return new Response(JSON.stringify({ success: true, ...result }, null, options.debug ? 2 : 0), { headers: { "Content-Type": "application/json", ...corsHeaders } }); } finally { await cleanup(); } } catch (error) { logger10.error({ error }, "❌ Erreur Vision API"); return new Response(JSON.stringify({ success: false, error: `Erreur serveur: ${error.message}` }), { status: 500, headers: { "Content-Type": "application/json", ...corsHeaders } }); } } async function handleInvoiceRequest(req, corsHeaders) { try { const formData = await req.formData(); const pdfFile = formData.get("file"); if (!pdfFile || !(pdfFile instanceof File)) { return new Response(JSON.stringify({ success: false, error: "Fichier PDF requis" }), { status: 400, headers: { "Content-Type": "application/json", ...corsHeaders } }); } const { filePath, cleanup } = await createTempFile(await pdfFile.arrayBuffer()); try { const invoiceData = await extractInvoice(filePath, { provider: formData.get("provider")?.toString() || "scaleway", model: formData.get("model")?.toString(), cropSize: formData.get("cropSize") ? parseInt(formData.get("cropSize").toString()) : undefined }); return new Response(JSON.stringify({ success: true, invoice: invoiceData }), { headers: { "Content-Type": "application/json", ...corsHeaders } }); } finally { await cleanup(); } } catch (error) { return new Response(JSON.stringify({ success: false, error: error.message }), { status: 500, headers: { "Content-Type": "application/json", ...corsHeaders } }); } } async function handleTablesRequest(req, corsHeaders) { try { const formData = await req.formData(); const pdfFile = formData.get("file"); if (!pdfFile || !(pdfFile instanceof File)) { return new Response(JSON.stringify({ success: false, error: "Fichier PDF requis" }), { status: 400, headers: { "Content-Type": "application/json", ...corsHeaders } }); } const { filePath, cleanup } = await createTempFile(await pdfFile.arrayBuffer()); try { const tablesData = await extractTables(filePath, { provider: formData.get("provider")?.toString() || "scaleway", model: formData.get("model")?.toString() }); return new Response(JSON.stringify({ success: true, tables: tablesData }), { headers: { "Content-Type": "application/json", ...corsHeaders } }); } finally { await cleanup(); } } catch (error) { return new Response(JSON.stringify({ success: false, error: error.message }), { status: 500, headers: { "Content-Type": "application/json", ...corsHeaders } }); } } // src/api/server.ts var serve = null; try { if (typeof Bun !== "undefined") { const bunModule = await import("bun"); serve = bunModule.serve; } } catch (error) { console.warn("Bun runtime not detected. Server functionality requires Bun runtime."); } var logger11 = createModuleLogger("api-server"); function createVisionAPI(config = {}) { if (!serve) { throw new Error("Server functionality requires Bun runtime. Install and run with Bun: https://bun.sh"); } const serverConfig = { port: config.port || process.env.PORT ? parseInt(process.env.PORT) : 3001, cors: config.cors !== false, corsOrigins: config.corsOrigins || ["*"] }; const corsHeaders = serverConfig.cors ? createCorsHeaders(serverConfig.corsOrigins) : {}; const server = serve({ port: serverConfig.port, async fetch(req) { const url = new URL(req.url); if (req.method === "OPTIONS") { return new Response(null, { headers: corsHeaders }); } if (url.pathname === "/health") { return new Response(JSON.stringify({ status: "ok", service: "vision-llm-api", features: ["sharp-optimization", "zod-validation", "ai-sdk"], providers: ["scaleway", "ollama"] }), { headers: { "Content-Type": "application/json", ...corsHeaders } }); } if (url.pathname === "/api/v1/vision/extract" && req.method === "POST") { return await handleExtractRequest(req, corsHeaders); } if (url.pathname === "/api/v1/vision/invoice" && req.method === "POST") { return await handleInvoiceRequest(req, corsHeaders); } if (url.pathname === "/api/v1/vision/tables" && req.method === "POST") { return await handleTablesRequest(req, corsHeaders); } return new Response(JSON.stringify({ success: false, error: "Route non trouvée", availableEndpoints: [ "POST /api/v1/vision/extract - Extraction configurable", "POST /api/v1/vision/invoice - Extraction facture rapide", "POST /api/v1/vision/tables - Extraction tableaux rapide", "GET /health - Status" ] }), { status: 404, headers: { "Content-Type": "application/json", ...corsHeaders } }); } }); logger11.info({ port: server.port }, "\uD83D\uDE80 Vision LLM API démarré"); logger11.info("Routes disponibles:"); logger11.info("- GET /health: Status du service"); logger11.info("- POST /api/v1/vision/extract: Extraction configurable avec Zod + AI SDK"); logger11.info("- POST /api/v1/vision/invoice: Extraction facture rapide"); logger11.info("- POST /api/v1/vision/tables: Extraction tableaux rapide"); logger11.info("✨ Optimisations: Sharp Vision LLM + Zod validation + AI SDK gene