#!/usr/bin/env node /** * Serveur backend pour le traitement des documents * Gère l'OCR, l'extraction NER et renvoie du JSON au frontend */ const express = require('express') const multer = require('multer') const cors = require('cors') const path = require('path') const fs = require('fs') const crypto = require('crypto') const { createWorker } = require('tesseract.js') const { preprocessImageForOCR, analyzeImageMetadata } = require('./imagePreprocessing') const { nameConfidenceBoost } = require('./nameDirectory') const pdf = require('pdf-parse') const app = express() const PORT = process.env.PORT || 3001 // Middleware app.use(cors()) app.use(express.json()) app.use(express.static('public')) // Middleware de logging pour debug app.use((req, res, next) => { console.log(`[DEBUG] ${new Date().toISOString()} - ${req.method} ${req.url}`) console.log(`[DEBUG] Headers:`, req.headers) next() }) // Collecte simple de métriques runtime function collectMetrics() { try { const cacheDir = 'cache' let pending = 0 let results = 0 if (fs.existsSync(cacheDir)) { for (const folder of fs.readdirSync(cacheDir)) { const folderPath = path.join(cacheDir, folder) if (!fs.statSync(folderPath).isDirectory()) continue for (const f of fs.readdirSync(folderPath)) { if (f.endsWith('.pending')) pending += 1 if (f.endsWith('.json')) results += 1 } } } return { pending, results } } catch (e) { return { pending: 0, results: 0 } } } // Fonction pour calculer le hash d'un fichier function calculateFileHash(buffer) { return crypto.createHash('sha256').update(buffer).digest('hex') } // Fonction pour générer un hash de dossier function generateFolderHash() { return crypto.randomBytes(16).toString('hex') } // Fonction pour obtenir le type MIME à partir de l'extension function getMimeType(ext) { const mimeTypes = { '.pdf': 'application/pdf', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.tiff': 'image/tiff', '.txt': 'text/plain', } return mimeTypes[ext.toLowerCase()] || 'application/octet-stream' } // Fonction pour créer la structure de dossiers function createFolderStructure(folderHash) { console.log(`[FOLDER] Création de la structure pour le hash: ${folderHash}`) console.log(`[FOLDER] Répertoire de travail: ${process.cwd()}`) // Créer les dossiers racines s'ils n'existent pas const uploadsDir = 'uploads' const cacheDir = 'cache' console.log(`[FOLDER] Vérification de l'existence de ${uploadsDir}: ${fs.existsSync(uploadsDir)}`) console.log(`[FOLDER] Vérification de l'existence de ${cacheDir}: ${fs.existsSync(cacheDir)}`) if (!fs.existsSync(uploadsDir)) { fs.mkdirSync(uploadsDir, { recursive: true }) console.log(`[FOLDER] Dossier racine créé: ${uploadsDir}`) } if (!fs.existsSync(cacheDir)) { fs.mkdirSync(cacheDir, { recursive: true }) console.log(`[FOLDER] Dossier racine créé: ${cacheDir}`) } const folderPath = path.join(uploadsDir, folderHash) const cachePath = path.join(cacheDir, folderHash) console.log(`[FOLDER] Chemin du dossier uploads: ${folderPath}`) console.log(`[FOLDER] Chemin du dossier cache: ${cachePath}`) if (!fs.existsSync(folderPath)) { fs.mkdirSync(folderPath, { recursive: true }) console.log(`[FOLDER] Dossier uploads créé: ${folderPath}`) } if (!fs.existsSync(cachePath)) { fs.mkdirSync(cachePath, { recursive: true }) console.log(`[FOLDER] Dossier cache créé: ${cachePath}`) } return { folderPath, cachePath } } // Écrit un fichier de métadonnées du dossier (uploads//folder.json) function writeFolderMeta(folderHash, name, description) { try { const { folderPath } = createFolderStructure(folderHash) const metaPath = path.join(folderPath, 'folder.json') const meta = { folderHash, name: name || null, description: description || null, updatedAt: new Date().toISOString(), } fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2)) return true } catch (e) { console.warn('[FOLDER] Impossible d\'écrire folder.json:', e?.message || e) return false } } // Lit le fichier de métadonnées du dossier function readFolderMeta(folderHash) { try { const metaPath = path.join('uploads', folderHash, 'folder.json') if (!fs.existsSync(metaPath)) return null const raw = fs.readFileSync(metaPath, 'utf8') return JSON.parse(raw) } catch (e) { console.warn('[FOLDER] Impossible de lire folder.json:', e?.message || e) return null } } // Fonction pour sauvegarder le cache JSON dans un dossier spécifique function saveJsonCacheInFolder(folderHash, fileHash, result) { const { cachePath } = createFolderStructure(folderHash) const cacheFile = path.join(cachePath, `${fileHash}.json`) const tempFile = path.join(cachePath, `${fileHash}.json.tmp`) try { // Écriture atomique: écrire dans un fichier temporaire puis renommer fs.writeFileSync(tempFile, JSON.stringify(result, null, 2)) fs.renameSync(tempFile, cacheFile) console.log(`[CACHE] Résultat sauvegardé dans le dossier ${folderHash}: ${fileHash}`) // Supprimer le flag pending si il existe const pendingFile = path.join(cachePath, `${fileHash}.pending`) if (fs.existsSync(pendingFile)) { fs.unlinkSync(pendingFile) console.log(`[CACHE] Flag pending supprimé pour ${fileHash}`) } return true } catch (error) { console.error(`[CACHE] Erreur lors de la sauvegarde dans le dossier ${folderHash}:`, error) try { if (fs.existsSync(tempFile)) fs.unlinkSync(tempFile) } catch {} return false } } // Fonction pour créer un flag pending function createPendingFlag(folderHash, fileHash) { const { cachePath } = createFolderStructure(folderHash) const pendingFile = path.join(cachePath, `${fileHash}.pending`) try { const pendingData = { fileHash, folderHash, timestamp: new Date().toISOString(), status: 'processing', } fs.writeFileSync(pendingFile, JSON.stringify(pendingData, null, 2)) console.log(`[CACHE] Flag pending créé pour ${fileHash} dans le dossier ${folderHash}`) return true } catch (error) { console.error(`[CACHE] Erreur lors de la création du flag pending:`, error) return false } } // Fonction pour vérifier si un fichier est en cours de traitement function isFilePending(folderHash, fileHash) { const cachePath = path.join('cache', folderHash) const pendingFile = path.join(cachePath, `${fileHash}.pending`) return fs.existsSync(pendingFile) } // Fonction pour nettoyer les flags pending orphelins (plus de 1 heure) function cleanupOrphanedPendingFlags() { console.log('[CLEANUP] Nettoyage des flags pending orphelins...') const cacheDir = 'cache' if (!fs.existsSync(cacheDir)) { return } const folders = fs.readdirSync(cacheDir) let cleanedCount = 0 for (const folder of folders) { const folderPath = path.join(cacheDir, folder) if (!fs.statSync(folderPath).isDirectory()) continue const files = fs.readdirSync(folderPath) for (const file of files) { if (file.endsWith('.pending')) { const pendingFile = path.join(folderPath, file) try { const stats = fs.statSync(pendingFile) const age = Date.now() - stats.mtime.getTime() const oneHour = 60 * 60 * 1000 // 1 heure en millisecondes if (age > oneHour) { fs.unlinkSync(pendingFile) cleanedCount++ console.log(`[CLEANUP] Flag pending orphelin supprimé: ${file}`) } } catch (error) { console.error(`[CLEANUP] Erreur lors du nettoyage de ${file}:`, error.message) } } } } if (cleanedCount > 0) { console.log(`[CLEANUP] ${cleanedCount} flags pending orphelins supprimés`) } else { console.log('[CLEANUP] Aucun flag pending orphelin trouvé') } } // Fonction pour récupérer le cache JSON depuis un dossier spécifique function getJsonCacheFromFolder(folderHash, fileHash) { const cachePath = path.join('cache', folderHash) const cacheFile = path.join(cachePath, `${fileHash}.json`) if (fs.existsSync(cacheFile)) { try { const data = fs.readFileSync(cacheFile, 'utf8') const result = JSON.parse(data) console.log(`[CACHE] Résultat récupéré depuis le dossier ${folderHash}: ${fileHash}`) return result } catch (error) { console.error(`[CACHE] Erreur lors de la lecture depuis le dossier ${folderHash}:`, error) return null } } return null } // Fonction pour obtenir le type MIME à partir de l'extension function getMimeTypeFromExtension(extension) { const mimeTypes = { '.pdf': 'application/pdf', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.gif': 'image/gif', '.bmp': 'image/bmp', '.tiff': 'image/tiff', '.webp': 'image/webp', '.txt': 'text/plain', } return mimeTypes[extension.toLowerCase()] || 'application/octet-stream' } // Fonction pour lister tous les résultats d'un dossier async function listFolderResults(folderHash) { const cachePath = path.join('cache', folderHash) const uploadsPath = path.join('uploads', folderHash) const results = [] const pending = [] let hasPending = false // Traiter les fichiers en cache (avec résultats d'extraction) if (fs.existsSync(cachePath)) { const cacheFiles = fs.readdirSync(cachePath) for (const file of cacheFiles) { if (file.endsWith('.json')) { const fileHash = path.basename(file, '.json') const result = getJsonCacheFromFolder(folderHash, fileHash) if (result) { results.push({ fileHash, ...result, }) } } else if (file.endsWith('.pending')) { const fileHash = path.basename(file, '.pending') try { const pendingData = JSON.parse(fs.readFileSync(path.join(cachePath, file), 'utf8')) pending.push(pendingData) hasPending = true console.log(`[CACHE] Fichier en cours de traitement détecté: ${fileHash}`) } catch (error) { console.error(`[CACHE] Erreur lors de la lecture du flag pending ${file}:`, error) } } } } // Traiter les fichiers en uploads (sans résultats d'extraction) if (fs.existsSync(uploadsPath)) { console.log(`[FOLDER] Dossier uploads trouvé: ${uploadsPath}`) const uploadFiles = fs.readdirSync(uploadsPath) console.log(`[FOLDER] Fichiers trouvés dans uploads: ${uploadFiles.length}`) console.log(`[FOLDER] Liste des fichiers: ${uploadFiles.join(', ')}`) for (const file of uploadFiles) { console.log(`[FOLDER] Traitement du fichier: ${file}`) // Ignorer les fichiers de métadonnées et fichiers système if ( file === 'folder.json' || file.endsWith('.meta') || file === '.DS_Store' || file === 'Thumbs.db' || file.startsWith('._') ) { console.log(`[FOLDER] Fichier de métadonnées ignoré: ${file}`) continue } // Extraire le hash du nom de fichier (format: hash.extension) const fileHash = path.basename(file, path.extname(file)) console.log(`[FOLDER] Hash extrait: ${fileHash}`) // Vérifier si ce fichier n'a pas déjà un résultat en cache const hasCacheResult = results.some((result) => result.fileHash === fileHash) // Vérifier si ce fichier n'est pas déjà en pending const isAlreadyPending = pending.some((p) => p.fileHash === fileHash) console.log(`[FOLDER] hasCacheResult: ${hasCacheResult}, isAlreadyPending: ${isAlreadyPending}`) if (!hasCacheResult && !isAlreadyPending) { // Ne pas bloquer la réponse: marquer en pending et lancer en arrière-plan const filePath = path.join(uploadsPath, file) console.log( `[FOLDER] Fichier non traité détecté, mise en traitement asynchrone: ${file}`, ) // Créer un flag pending et enregistrer l'état createPendingFlag(folderHash, fileHash) pending.push({ fileHash, folderHash, timestamp: new Date().toISOString(), status: 'processing', }) hasPending = true // Lancer le traitement en arrière-plan (sans await) processFileInBackground(filePath, fileHash, folderHash).catch((err) => console.error('[BACKGROUND] Erreur (non bloquante):', err?.message || err), ) } else { console.log(`[FOLDER] Fichier ${file} ignoré (déjà traité ou en cours)`) } } } else { console.log(`[FOLDER] Dossier uploads non trouvé: ${uploadsPath}`) } return { results, pending, hasPending } } // Nettoyage automatique du cache d'un dossier: supprime les JSON invalides // et les résultats orphelins dont le fichier source n'existe plus function pruneFolderCache(folderHash) { try { const { folderPath, cachePath } = createFolderStructure(folderHash) if (!fs.existsSync(cachePath)) return 0 const existingUploads = new Set( (fs.existsSync(folderPath) ? fs.readdirSync(folderPath).filter((f) => fs.statSync(path.join(folderPath, f)).isFile()) : []) .map((f) => path.basename(f, path.extname(f))) ) let removed = 0 for (const file of fs.readdirSync(cachePath)) { if (!file.endsWith('.json')) continue const jsonPath = path.join(cachePath, file) const fileHash = path.basename(file, '.json') try { const raw = fs.readFileSync(jsonPath, 'utf8') JSON.parse(raw) // valider if (!existingUploads.has(fileHash)) { fs.unlinkSync(jsonPath) removed += 1 } } catch (e) { try { fs.unlinkSync(jsonPath) removed += 1 } catch {} } } return removed } catch (e) { console.warn('[CACHE] prune error:', e?.message || e) return 0 } } // Fonction pour traiter un document (extraction de la logique de /api/extract) async function processDocument(filePath, fileHash) { const startTime = Date.now() try { console.log(`[PROCESS] Début du traitement: ${filePath}`) // Obtenir les informations du fichier const stats = fs.statSync(filePath) const ext = path.extname(filePath) const mimeType = getMimeTypeFromExtension(ext) console.log(`[PROCESS] Fichier: ${path.basename(filePath)}`) console.log(`[PROCESS] Extension: ${ext}`) console.log(`[PROCESS] Type MIME: ${mimeType}`) console.log(`[PROCESS] Taille: ${stats.size} bytes`) // Créer un objet file similaire à celui de multer const file = { path: filePath, originalname: path.basename(filePath), mimetype: mimeType, size: stats.size, } let ocrResult let result // Si c'est un fichier texte, lire directement le contenu if (mimeType === 'text/plain') { console.log(`[PROCESS] Lecture du fichier texte...`) try { const text = fs.readFileSync(filePath, 'utf8') ocrResult = { text, confidence: 1.0 } console.log(`[PROCESS] Texte lu: ${text.length} caractères`) } catch (error) { console.error(`[PROCESS] Erreur lors de la lecture du fichier texte:`, error.message) throw new Error(`Erreur lors de la lecture du fichier texte: ${error.message}`) } } else if (mimeType === 'application/pdf') { console.log(`[PROCESS] Extraction de texte depuis PDF...`) try { ocrResult = await extractTextFromPdf(filePath) console.log(`[PROCESS] Texte extrait du PDF: ${ocrResult.text.length} caractères`) } catch (error) { console.error(`[PROCESS] Erreur lors de l'extraction PDF:`, error.message) throw new Error(`Erreur lors de l'extraction PDF: ${error.message}`) } } else if (mimeType.startsWith('image/')) { // Pour les images, utiliser l'OCR amélioré avec détection CNI console.log(`[PROCESS] Traitement d'une image avec OCR...`) try { const { extractTextFromImageEnhanced } = require('./enhancedOcr') ocrResult = await extractTextFromImageEnhanced(filePath) console.log(`[PROCESS] OCR terminé: ${ocrResult.text.length} caractères`) } catch (error) { console.error(`[PROCESS] Erreur lors de l'OCR:`, error.message) throw new Error(`Erreur lors de l'OCR: ${error.message}`) } } else { console.error(`[PROCESS] Type de fichier non supporté: ${mimeType}`) throw new Error(`Type de fichier non supporté: ${mimeType}`) } // Extraction NER const entities = extractEntitiesFromText(ocrResult.text) // Mesure du temps de traitement const processingTime = Date.now() - startTime // Génération du format JSON standard (avec repli sûr) try { result = generateStandardJSON(file, ocrResult, entities, processingTime) } catch (genErr) { console.error('[PROCESS] Erreur generateStandardJSON, application d\'un repli:', genErr) const safeText = typeof ocrResult.text === 'string' ? ocrResult.text : '' const safeMime = file.mimetype || getMimeType(path.extname(filePath)) const fallbackProcessing = { engine: '4NK_IA_Backend', version: '1.0.0', processingTime: `${processingTime}ms`, ocrEngine: safeMime === 'application/pdf' ? 'pdf-parse' : 'tesseract.js', nerEngine: 'rule-based', preprocessing: { applied: safeMime !== 'application/pdf', reason: safeMime === 'application/pdf' ? 'PDF direct text extraction' : 'Image preprocessing applied', }, } result = { document: { id: `doc-${Date.now()}`, fileName: file.originalname || path.basename(filePath), fileSize: file.size || (fs.existsSync(filePath) ? fs.statSync(filePath).size : 0), mimeType: safeMime, uploadTimestamp: new Date().toISOString(), }, classification: { documentType: 'Document', confidence: 0.6, subType: getDocumentSubType('Document', safeText), language: 'fr', pageCount: 1, }, extraction: { text: { raw: safeText, processed: correctOCRText(safeText), wordCount: safeText.trim().split(/\s+/).filter(Boolean).length, characterCount: safeText.length, confidence: (typeof ocrResult.confidence === 'number' ? ocrResult.confidence : 0) / 100, }, entities: { persons: [], companies: [], addresses: [], financial: extractFinancialInfo(safeText, 'Document'), dates: [], contractual: { clauses: [], signatures: [] }, references: extractReferences(safeText, 'Document'), }, }, metadata: { processing: fallbackProcessing, quality: { globalConfidence: 0.6, textExtractionConfidence: (typeof ocrResult.confidence === 'number' ? ocrResult.confidence : 0) / 100, entityExtractionConfidence: 0.6, classificationConfidence: 0.6, }, }, status: { success: true, errors: ['fallback: generateStandardJSON error'], warnings: [], timestamp: new Date().toISOString() }, } } console.log(`[PROCESS] Traitement terminé en ${processingTime}ms`) return result } catch (error) { console.error(`[PROCESS] Erreur lors du traitement:`, error) throw error } } // Fonction pour traiter un fichier en arrière-plan async function processFileInBackground(filePath, fileHash, folderHash) { try { console.log(`[BACKGROUND] Début du traitement en arrière-plan: ${filePath}`) console.log(`[BACKGROUND] fileHash: ${fileHash}, folderHash: ${folderHash}`) // Vérifier que le fichier existe if (!fs.existsSync(filePath)) { throw new Error(`Fichier non trouvé: ${filePath}`) } // Traiter le document console.log(`[BACKGROUND] Appel de processDocument...`) const result = await processDocument(filePath, fileHash) console.log(`[BACKGROUND] processDocument terminé, résultat:`, result ? 'OK' : 'NULL') // Sauvegarder le résultat dans le cache du dossier console.log(`[BACKGROUND] Sauvegarde du résultat dans le cache...`) const success = saveJsonCacheInFolder(folderHash, fileHash, result) console.log(`[BACKGROUND] Sauvegarde: ${success ? 'OK' : 'ÉCHEC'}`) if (success) { // Supprimer le flag pending console.log(`[BACKGROUND] Suppression du flag pending...`) removePendingFlag(folderHash, fileHash) console.log(`[BACKGROUND] Traitement terminé avec succès: ${fileHash}`) } else { console.error(`[BACKGROUND] Erreur lors de la sauvegarde du résultat: ${fileHash}`) } } catch (error) { console.error(`[BACKGROUND] Erreur lors du traitement en arrière-plan:`, error) console.error(`[BACKGROUND] Stack trace:`, error.stack) // Supprimer le flag pending même en cas d'erreur removePendingFlag(folderHash, fileHash) } } // Fonction pour supprimer un flag pending function removePendingFlag(folderHash, fileHash) { try { const cachePath = path.join('cache', folderHash) const pendingFile = path.join(cachePath, `${fileHash}.pending`) if (fs.existsSync(pendingFile)) { fs.unlinkSync(pendingFile) console.log(`[PENDING] Flag pending supprimé: ${fileHash}`) } } catch (error) { console.error(`[PENDING] Erreur lors de la suppression du flag pending:`, error) } } // Fonction pour vérifier si un fichier existe déjà par hash dans un dossier function findExistingFileByHash(hash, folderHash) { const folderPath = path.join('uploads', folderHash) if (!fs.existsSync(folderPath)) return null const files = fs.readdirSync(folderPath) for (const file of files) { // Vérifier si le nom de fichier commence par le hash if (file.startsWith(hash)) { const filePath = path.join(folderPath, file) return { path: filePath, name: file } } } return null } // Fonction pour sauvegarder le cache JSON function saveJsonCache(hash, result) { const cacheDir = 'cache/' if (!fs.existsSync(cacheDir)) { fs.mkdirSync(cacheDir, { recursive: true }) } const cacheFile = path.join(cacheDir, `${hash}.json`) try { fs.writeFileSync(cacheFile, JSON.stringify(result, null, 2)) console.log(`[CACHE] Résultat sauvegardé: ${hash.substring(0, 16)}...`) return true } catch (error) { console.error(`[CACHE] Erreur lors de la sauvegarde:`, error.message) return false } } // Fonction pour récupérer le cache JSON function getJsonCache(hash) { const cacheFile = path.join('cache/', `${hash}.json`) try { if (fs.existsSync(cacheFile)) { const cachedData = fs.readFileSync(cacheFile, 'utf8') const result = JSON.parse(cachedData) console.log(`[CACHE] Résultat récupéré: ${hash.substring(0, 16)}...`) return result } } catch (error) { console.warn(`[CACHE] Erreur lors de la lecture du cache:`, error.message) } return null } // Fonction pour lister les fichiers de cache function listCacheFiles() { const cacheDir = 'cache/' if (!fs.existsSync(cacheDir)) return [] const files = fs.readdirSync(cacheDir) return files .map((file) => { const filePath = path.join(cacheDir, file) try { const stats = fs.statSync(filePath) const hash = path.basename(file, '.json') return { hash: hash, fileName: file, size: stats.size, createdDate: stats.birthtime, modifiedDate: stats.mtime, } } catch (error) { console.warn(`[CACHE] Erreur lors de la lecture de ${file}:`, error.message) return null } }) .filter((file) => file !== null) } // Configuration multer pour l'upload de fichiers avec hash comme nom const storage = multer.diskStorage({ destination: (req, file, cb) => { const uploadDir = 'uploads/' if (!fs.existsSync(uploadDir)) { fs.mkdirSync(uploadDir, { recursive: true }) } cb(null, uploadDir) }, filename: (req, file, cb) => { // Utiliser un nom temporaire, le hash sera calculé après const timestamp = Date.now() const ext = path.extname(file.originalname) cb(null, `temp-${timestamp}${ext}`) }, }) const upload = multer({ storage, limits: { fileSize: 100 * 1024 * 1024 }, // 100MB max fileFilter: (req, file, cb) => { const allowedTypes = [ 'image/jpeg', 'image/png', 'image/tiff', 'application/pdf', 'text/plain', ] if (allowedTypes.includes(file.mimetype)) { cb(null, true) } else { cb(new Error('Type de fichier non supporté'), false) } }, }) // Fonction d'extraction de texte depuis un PDF async function extractTextFromPdf(pdfPath) { console.log(`[PDF] Début de l'extraction de texte pour: ${path.basename(pdfPath)}`) try { const dataBuffer = fs.readFileSync(pdfPath) const data = await pdf(dataBuffer) console.log(`[PDF] Texte extrait: ${data.text.length} caractères`) console.log(`[PDF] Nombre de pages: ${data.numpages}`) return { text: data.text, confidence: 95, // PDF text extraction est très fiable words: data.text.split(/\s+/).filter((word) => word.length > 0), } } catch (error) { console.error(`[PDF] Erreur lors de l'extraction:`, error.message) throw error } } // Fonction d'extraction OCR optimisée avec préprocessing async function extractTextFromImage(imagePath) { console.log(`[OCR] Début de l'extraction pour: ${imagePath}`) // Analyse des métadonnées de l'image const metadata = await analyzeImageMetadata(imagePath) // Préprocessing de l'image pour améliorer l'OCR console.log(`[OCR] Préprocessing de l'image...`) const preprocessedBuffer = await preprocessImageForOCR(imagePath, null, { width: 2000, contrast: 1.5, brightness: 1.1, grayscale: true, sharpen: true, denoise: true, }) // Sauvegarde temporaire de l'image préprocessée const tempPath = imagePath.replace(/\.[^/.]+$/, '_preprocessed.png') await fs.promises.writeFile(tempPath, preprocessedBuffer) console.log(`[OCR] Image préprocessée sauvegardée: ${tempPath}`) const worker = await createWorker('fra+eng') try { // Stratégie multi-modes pour améliorer la détection const strategies = [ { name: 'Mode Standard', params: { tessedit_pageseg_mode: '6', tessedit_char_whitelist: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõöøùúûüýþÿ.,/-:() ', tessedit_ocr_engine_mode: '1', preserve_interword_spaces: '1', textord_min_linesize: '2.0', textord_min_xheight: '6', }, }, { name: 'Mode OCRB/MRZ', params: { tessedit_pageseg_mode: '6', tessedit_char_whitelist: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789<', tessedit_ocr_engine_mode: '1', preserve_interword_spaces: '1', }, }, { name: 'Mode Fine', params: { tessedit_pageseg_mode: '8', // Mot unique tessedit_char_whitelist: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõöøùúûüýþÿ.,/-:() ', tessedit_ocr_engine_mode: '1', textord_min_linesize: '1.0', textord_min_xheight: '4', textord_heavy_nr: '0', }, }, { name: 'Mode Ligne', params: { tessedit_pageseg_mode: '13', // Ligne brute de texte tessedit_char_whitelist: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõöøùúûüýþÿ.,/-:() ', tessedit_ocr_engine_mode: '1', textord_min_linesize: '1.5', textord_min_xheight: '5', }, }, ] let bestResult = { text: '', confidence: 0, words: [], strategy: 'none' } for (const strategy of strategies) { try { console.log(`[OCR] Test de la stratégie: ${strategy.name}`) await worker.setParameters(strategy.params) const { data } = await worker.recognize(tempPath) console.log(`[OCR] ${strategy.name} - Confiance: ${data.confidence}%`) if (data.confidence > bestResult.confidence) { bestResult = { text: data.text, confidence: data.confidence, words: data.words || [], strategy: strategy.name, } } } catch (error) { console.log(`[OCR] Erreur avec ${strategy.name}: ${error.message}`) } } console.log( `[OCR] Meilleur résultat (${bestResult.strategy}) - Confiance: ${bestResult.confidence}%`, ) console.log( `[OCR] Texte extrait (${bestResult.text.length} caractères): ${bestResult.text.substring(0, 200)}...`, ) return { text: bestResult.text, confidence: bestResult.confidence, words: bestResult.words, } } finally { await worker.terminate() // Nettoyage du fichier temporaire try { if (fs.existsSync(tempPath)) { await fs.promises.unlink(tempPath) console.log(`[OCR] Fichier temporaire supprimé: ${tempPath}`) } } catch (error) { console.warn(`[OCR] Erreur lors de la suppression du fichier temporaire: ${error.message}`) } } } // Fonction de correction de texte pour améliorer la détection function correctOCRText(text) { // Corrections courantes pour les erreurs OCR const corrections = { // Corrections générales courantes seulement 0: 'o', 1: 'l', 5: 's', '@': 'a', 3: 'e', } let correctedText = text for (const [wrong, correct] of Object.entries(corrections)) { correctedText = correctedText.replace(new RegExp(wrong, 'gi'), correct) } return correctedText } // Fonction pour générer le format JSON standard function generateStandardJSON(documentInfo, ocrResult, entities, processingTime) { const timestamp = new Date().toISOString() const documentId = `doc-${Date.now()}` // Classification du document const safeEntities = { identities: Array.isArray(entities?.identities) ? entities.identities : [], companies: Array.isArray(entities?.companies) ? entities.companies : [], addresses: Array.isArray(entities?.addresses) ? entities.addresses : [], cniNumbers: Array.isArray(entities?.cniNumbers) ? entities.cniNumbers : [], dates: Array.isArray(entities?.dates) ? entities.dates : [], contractClauses: Array.isArray(entities?.contractClauses) ? entities.contractClauses : [], signatures: Array.isArray(entities?.signatures) ? entities.signatures : [], documentType: entities?.documentType || 'Document', } const { identities, cniNumbers } = safeEntities const documentType = safeEntities.documentType const subType = getDocumentSubType(documentType, ocrResult.text) // Extraction des informations financières pour les factures const financial = extractFinancialInfo(ocrResult.text, documentType) // Extraction des références const references = extractReferences(ocrResult.text, documentType) // Calcul de la confiance globale const baseConfidence = typeof ocrResult.confidence === 'number' ? ocrResult.confidence : 0 const dirBoost = (() => { try { const id0 = (Array.isArray(identities) && identities.length > 0) ? identities[0] : null return id0 ? nameConfidenceBoost(id0.firstName, id0.lastName) : 0 } catch { return 0 } })() // Ajustement du scoring qualité: intégrer le boost annuaire et plafonner 0.99 const globalConfidence = Math.min( 99, Math.max( 60, baseConfidence * 0.8 + (identities.length > 0 ? 12 : 0) + (cniNumbers.length > 0 ? 18 : 0) + Math.round(dirBoost * 100), ), ) return { document: { id: documentId, fileName: documentInfo.originalname, displayName: documentInfo.displayName || documentInfo.originalname, fileSize: documentInfo.size, mimeType: documentInfo.mimetype, uploadTimestamp: timestamp, }, classification: { documentType: documentType, confidence: globalConfidence / 100, subType: subType, language: 'fr', pageCount: 1, }, extraction: { text: { raw: typeof ocrResult.text === 'string' ? ocrResult.text : '', processed: correctOCRText(typeof ocrResult.text === 'string' ? ocrResult.text : ''), wordCount: Array.isArray(ocrResult.words) ? ocrResult.words.length : ((typeof ocrResult.text === 'string' ? ocrResult.text : '') .trim() .split(/\s+/) .filter(Boolean).length), characterCount: (typeof ocrResult.text === 'string' ? ocrResult.text : '').length, confidence: (typeof ocrResult.confidence === 'number' ? ocrResult.confidence : 0) / 100, }, entities: { persons: safeEntities.identities.map((identity) => ({ id: identity.id, type: 'person', firstName: identity.firstName, lastName: identity.lastName, role: identity.role || null, email: identity.email || null, phone: identity.phone || null, confidence: identity.confidence, source: identity.source, })), companies: safeEntities.companies.map((company) => ({ id: company.id, name: company.name, legalForm: company.legalForm || null, siret: company.siret || null, rcs: company.rcs || null, tva: company.tva || null, capital: company.capital || null, role: company.role || null, confidence: company.confidence, source: company.source, })), addresses: safeEntities.addresses.map((address) => ({ id: address.id, type: address.type || 'general', street: address.street, city: address.city, postalCode: address.postalCode, country: address.country, company: address.company || null, confidence: address.confidence, source: address.source, })), financial: financial, dates: safeEntities.dates.map((date) => ({ id: date.id, type: date.type || 'general', value: date.date || date.value, formatted: formatDate(date.date || date.value), confidence: date.confidence, source: date.source, })), contractual: { clauses: safeEntities.contractClauses.map((clause) => ({ id: clause.id, type: clause.type, content: clause.text, confidence: clause.confidence, })), signatures: safeEntities.signatures.map((signature) => ({ id: signature.id, type: signature.type || 'électronique', present: signature.present || false, signatory: signature.signatory || null, date: signature.date || null, confidence: signature.confidence, })), }, references: references, }, }, metadata: { processing: { engine: '4NK_IA_Backend', version: '1.0.0', processingTime: `${processingTime}ms`, ocrEngine: documentInfo.mimetype === 'application/pdf' ? 'pdf-parse' : 'tesseract.js', nerEngine: 'rule-based', preprocessing: { applied: documentInfo.mimetype !== 'application/pdf', reason: documentInfo.mimetype === 'application/pdf' ? 'PDF direct text extraction' : 'Image preprocessing applied', }, }, quality: { globalConfidence: globalConfidence / 100, textExtractionConfidence: (typeof ocrResult.confidence === 'number' ? ocrResult.confidence : 0) / 100, entityExtractionConfidence: 0.9, classificationConfidence: globalConfidence / 100, }, }, status: { success: true, errors: [], warnings: (Array.isArray(safeEntities.signatures) ? safeEntities.signatures.length : 0) === 0 ? ['Aucune signature détectée'] : [], timestamp: timestamp, suggestions: computeQualitySuggestions({ documentType, confidence: (typeof ocrResult.confidence === 'number' ? ocrResult.confidence : 0) / 100, identities: safeEntities.identities, addresses: safeEntities.addresses, }), }, } } // Validation facultative via Ollama: retourne { score, corrections, avis } async function reviewWithOllama(payload) { try { const http = require('http') const startAt = Date.now() const data = JSON.stringify({ model: process.env.OLLAMA_MODEL || 'llama3.1', prompt: "Analyse les informations extraites d'un document. Donne un score de fiabilité entre 0 et 1 (décimal), une liste de corrections proposées (champs et valeurs), et un court avis. Réponds strictement en JSON avec les clés: score (number), corrections (array d'objets {path, value, confidence}), avis (string).\nDONNÉES:\n" + JSON.stringify(payload), stream: false, options: { temperature: 0.2 }, }) const options = { hostname: 'localhost', port: 11434, path: '/api/generate', method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), }, timeout: 15000, } const responseBody = await new Promise((resolve, reject) => { const req = http.request(options, (res) => { let body = '' res.setEncoding('utf8') res.on('data', (chunk) => (body += chunk)) res.on('end', () => resolve(body)) }) req.on('error', reject) req.on('timeout', () => { req.destroy(new Error('Timeout')) }) req.write(data) req.end() }) let parsedOuter try { parsedOuter = JSON.parse(responseBody) } catch { parsedOuter = { response: String(responseBody) } } const txt = parsedOuter.response || responseBody const match = String(txt).match(/\{[\s\S]*\}$/) if (!match) throw new Error('Réponse non JSON') const parsed = JSON.parse(match[0]) const durationMs = Date.now() - startAt // Respecter un délai minimal si souhaité const minDelay = Math.max(0, Number(process.env.OLLAMA_MIN_REVIEW_MS || 0)) if (minDelay > 0 && durationMs < minDelay) { await new Promise((r) => setTimeout(r, minDelay - durationMs)) } return { score: Math.max(0, Math.min(1, Number(parsed.score) || 0)), corrections: Array.isArray(parsed.corrections) ? parsed.corrections : [], avis: typeof parsed.avis === 'string' ? parsed.avis : '', durationMs: Math.max(durationMs, minDelay || 0), } } catch (e) { return { score: null, corrections: [], avis: '', durationMs: null } } } // Endpoint optionnel: révision Ollama d'un résultat existant app.post('/api/folders/:folderHash/files/:fileHash/review', express.json(), async (req, res) => { try { const { folderHash, fileHash } = req.params const cachePath = path.join('cache', folderHash, `${fileHash}.json`) if (!fs.existsSync(cachePath)) return res.status(404).json({ success: false, error: 'Résultat non trouvé' }) const result = JSON.parse(fs.readFileSync(cachePath, 'utf8')) const review = await reviewWithOllama({ document: result.document, extraction: result.extraction, classification: result.classification, metadata: result.metadata, }) // Incorporer le score si présent if (review && typeof review.score === 'number') { if (!result.metadata) result.metadata = {} if (!result.metadata.quality) result.metadata.quality = {} result.metadata.quality.ollamaScore = review.score result.metadata.quality.globalConfidence = Math.max( result.metadata.quality.globalConfidence || 0, review.score, ) result.status = result.status || {} result.status.review = review fs.writeFileSync(cachePath, JSON.stringify(result, null, 2)) } return res.json({ success: true, review }) } catch (e) { return res.status(500).json({ success: false, error: e?.message || String(e) }) } }) // Détermine des recommandations de qualité (remplacement/confirmation) function computeQualitySuggestions(ctx) { try { const suggestions = { needsReupload: false, reasons: [], needsAddressConfirmation: false, detectedAddress: null, } // Critères de re-upload: faible confiance OCR OU CNI sans NOM/PRÉNOM if (typeof ctx.confidence === 'number' && ctx.confidence < 0.75) { suggestions.needsReupload = true suggestions.reasons.push('Confiance OCR faible') } if ((ctx.documentType || '').toUpperCase() === 'CNI') { const numIds = Array.isArray(ctx.identities) ? ctx.identities.length : 0 if (numIds === 0) { suggestions.needsReupload = true suggestions.reasons.push('Nom/Prénom non reconnus') } } // Confirmation d'adresse si détectée if (Array.isArray(ctx.addresses) && ctx.addresses.length > 0) { suggestions.needsAddressConfirmation = true suggestions.detectedAddress = ctx.addresses[0] } return suggestions } catch { return { needsReupload: false, reasons: [], needsAddressConfirmation: false, detectedAddress: null } } } // Fonction pour déterminer le sous-type de document function getDocumentSubType(documentType, text) { if (documentType === 'Facture') { if (/prestation|service/i.test(text)) return 'Facture de prestation' if (/vente|achat/i.test(text)) return 'Facture de vente' return 'Facture' } if (documentType === 'CNI') return "Carte Nationale d'Identité" if (documentType === 'Contrat') { if (/vente|achat/i.test(text)) return 'Contrat de vente' if (/location|bail/i.test(text)) return 'Contrat de location' return 'Contrat' } return documentType } // Fonction pour extraire les informations financières function extractFinancialInfo(text, documentType) { if (documentType !== 'Facture') { return { amounts: [], totals: {}, payment: {} } } const amounts = [] const totals = {} const payment = {} // Extraction des montants const amountPatterns = [ /(\d+(?:[.,]\d{2})?)\s*€/g, /Total\s+H\.T\.\s*[:\-]?\s*(\d+(?:[.,]\d{2})?)\s*€/gi, /Total\s+T\.T\.C\.\s*[:\-]?\s*(\d+(?:[.,]\d{2})?)\s*€/gi, /T\.V\.A\.\s*[:\-]?\s*(\d+(?:[.,]\d{2})?)\s*€/gi, ] amountPatterns.forEach((pattern) => { for (const match of text.matchAll(pattern)) { const amount = parseFloat(match[1].replace(',', '.')) if (amount > 0) { amounts.push({ id: `amount-${amounts.length}`, type: 'montant', value: amount, currency: 'EUR', confidence: 0.9, }) } } }) // Extraction des conditions de paiement const paymentPattern = /paiement\s+se\s+fera\s+\(maximum\)\s+(\d+)\s+jours/gi const paymentMatch = paymentPattern.exec(text) if (paymentMatch) { payment.terms = `${paymentMatch[1]} jours après émission` } return { amounts, totals, payment } } // Fonction pour extraire les références function extractReferences(text, documentType) { const references = [] if (documentType === 'Facture') { const facturePattern = /Facture\s+N°\s*[:\-]?\s*([A-Z0-9_-]+)/gi for (const match of text.matchAll(facturePattern)) { references.push({ id: `ref-${references.length}`, type: 'facture', number: match[1], confidence: 0.95, }) } } return references } // Fonction pour formater les dates function formatDate(dateStr) { if (!dateStr) return null // Format DD-MM-YY vers YYYY-MM-DD const match = dateStr.match(/(\d{2})-(\w+)-(\d{2})/) if (match) { const months = { janvier: '01', février: '02', mars: '03', avril: '04', mai: '05', juin: '06', juillet: '07', août: '08', septembre: '09', octobre: '10', novembre: '11', décembre: '12', } const month = months[match[2].toLowerCase()] if (month) { const year = '20' + match[3] return `${year}-${month}-${match[1].padStart(2, '0')}` } } return dateStr } // Fonction d'extraction NER par règles function extractEntitiesFromText(text) { console.log(`[NER] Début de l'extraction d'entités pour ${text.length} caractères`) // Correction du texte OCR const correctedText = correctOCRText(text) if (correctedText !== text) { console.log(`[NER] Texte corrigé: ${correctedText.substring(0, 100)}...`) } const entities = { identities: [], companies: [], addresses: [], cniNumbers: [], dates: [], contractClauses: [], signatures: [], documentType: 'Document', } // Extraction spécifique CNI (MRZ et libellés FR) try { const t = correctedText.replace(/\u200B|\u200E|\u200F/g, '') // MRZ de CNI (deux ou trois lignes, séparateur << : NOM<= 2 && rawGiven.length >= 2) { entities.identities.push({ id: `identity-${(Array.isArray(entities.identities)?entities.identities:[]).length}`, type: 'person', firstName: capitalize(rawGiven.split(/\s+/)[0]), lastName: rawSurname.toUpperCase(), confidence: 0.99, source: 'mrz', }) break } } // Repli: si pas d'identité MRZ extraite, tenter reconstruction NOM/PRÉNOM séparés if (!(Array.isArray(entities.identities) && entities.identities.some((i)=> (i.source||'').toLowerCase()==='mrz'))) { // Chercher NOM après IDFRA (ex: IDFRA CANTU<<<<...) const mSurname = mrzText.match(/IDFRA\s*([A-Z]{2,})= 2 && first && first.length >= 2) { entities.identities.push({ id: `identity-${(Array.isArray(entities.identities)?entities.identities:[]).length}`, type: 'person', firstName: capitalize(first.toLowerCase()), lastName: last.toUpperCase(), confidence: 0.97, source: 'mrz-heuristic', }) } } // Libellés français typiques de CNI // NOM : XXXX PRENOM(S) : YYYYY const labelName = t.match(/\bNOM\s*[:\-]?\s*([A-ZÀ-ÖØ-Þ\-\s]{2,})/i) const labelGiven = t.match(/\bPR[ÉE]?NOM\S*\s*[:\-]?\s*([A-Za-zÀ-ÖØ-öø-ÿ'\-\s]{2,})/i) if (labelName || labelGiven) { const last = (labelName?.[1] || '').replace(/[^A-Za-zÀ-ÖØ-Þ'\-\s]/g, '').trim() const first = (labelGiven?.[1] || '').replace(/[^A-Za-zÀ-ÖØ-öø-ÿ'\-\s]/g, '').trim() if (last || first) { entities.identities.push({ id: `identity-${(Array.isArray(entities.identities)?entities.identities:[]).length}`, type: 'person', firstName: first ? capitalize(first.split(/\s+/)[0]) : '', lastName: last ? last.toUpperCase() : '', confidence: 0.9, source: 'label', }) } } } catch (e) { console.warn('[NER] Erreur parsing CNI:', e?.message || e) } // Extraction des noms avec patterns généraux const namePatterns = [ // Patterns pour documents officiels /(Vendeur|Acheteur|Vendeuse|Acheteuse|Propriétaire|Locataire|Bailleur|Preneur)\s*:\s*([A-Z][a-zà-öø-ÿ'\-]+\s+[A-Z][a-zà-öø-ÿ'\-]+)/gi, // Lignes en MAJUSCULES (noms complets) /^([A-Z][A-ZÀ-ÖØ-öø-ÿ\s\-']{2,30})$/gm, // Noms avec prénom + nom /([A-Z][a-zà-öø-ÿ'\-]+\s+[A-Z][a-zà-öø-ÿ'\-]+)/g, ] namePatterns.forEach((pattern) => { for (const match of correctedText.matchAll(pattern)) { const fullName = match[2] || match[1] || match[0] if (fullName && fullName.length > 3) { const nameParts = fullName.trim().split(/\s+/) if (nameParts.length >= 2) { entities.identities.push({ id: `identity-${(Array.isArray(entities.identities)?entities.identities:[]).length}`, type: 'person', firstName: nameParts[0], lastName: nameParts.slice(1).join(' '), confidence: 0.9, source: 'rule-based', }) } } } }) // Extraction des sociétés const companyPatterns = [ /(S\.A\.R\.L\.|SAS|SASU|EURL|SNC|SCI|SARL|SA|SAS|SASU|EURL|SNC|SCI|S\.A\.|S\.A\.R\.L\.|S\.A\.S\.|S\.A\.S\.U\.|E\.U\.R\.L\.|S\.N\.C\.|S\.C\.I\.)/gi, /([A-Z][A-Za-zÀ-ÖØ-öø-ÿ\s\-']{3,50})\s+(S\.A\.R\.L\.|SAS|SASU|EURL|SNC|SCI|SARL|SA)/gi, /(Entreprise|Société|Compagnie|Groupe|Corporation|Corp\.|Inc\.|Ltd\.|LLC)/gi, ] companyPatterns.forEach((pattern) => { for (const match of text.matchAll(pattern)) { const companyName = match[1] || match[0] if (companyName && companyName.length > 3) { entities.companies.push({ id: `company-${(Array.isArray(entities.companies)?entities.companies:[]).length}`, name: companyName.trim(), type: 'company', confidence: 0.8, source: 'rule-based', }) } } }) // Extraction des adresses const addressPatterns = [ // 10 rue Exemple, 75001 Paris /(\d{1,4})\s+([A-Za-zÀ-ÖØ-öø-ÿ\s\-']+?),\s*(\d{5})\s+([A-Za-zÀ-ÖØ-öø-ÿ\-\s]{2,})(?:\b(France)\b)?/gi, // demeurant 10 rue Exemple, 75001 Paris /demeurant\s+(\d{1,4})\s+([A-Za-zÀ-ÖØ-öø-ÿ\s\-']+?),\s*(\d{5})\s+([A-Za-zÀ-ÖØ-öø-ÿ\-\s]{2,})(?:\b(France)\b)?/gi, // Adresse: 10 rue Exemple, 75001 Paris /(Adresse|Siège|Adresse de facturation)\s*:\s*(\d{1,4}\s+[A-Za-zÀ-ÖØ-öø-ÿ\s\-']+,\s*\d{5}\s+[A-Za-zÀ-ÖØ-öø-ÿ\-\s]{2,})(?:\b(France)\b)?/gi, ] addressPatterns.forEach((pattern) => { for (const match of text.matchAll(pattern)) { const street = match[2] || match[1] const postalCode = (match[3] || '').replace(/\s+/g, '') const city = (match[4] || '').replace(/\s+France$/i, '').trim() const country = (match[5] || 'France').trim() entities.addresses.push({ id: `address-${(Array.isArray(entities.addresses)?entities.addresses:[]).length}`, street: street ? `${street}`.trim() : '', city: city, postalCode: postalCode, country: country || 'France', confidence: 0.9, source: 'rule-based', }) } }) // Extraction des numéros de carte d'identité const cniPattern = /([A-Z]{2}\d{6})/g for (const match of text.matchAll(cniPattern)) { entities.cniNumbers.push({ id: `cni-${(Array.isArray(entities.cniNumbers)?entities.cniNumbers:[]).length}`, number: match[1], confidence: 0.95, source: 'rule-based', }) } // Extraction des dates const datePatterns = [/(\d{2}\/\d{2}\/\d{4})/g, /(né|née)\s+le\s+(\d{2}\/\d{2}\/\d{4})/gi] datePatterns.forEach((pattern) => { for (const match of text.matchAll(pattern)) { const date = match[2] || match[1] entities.dates.push({ id: `date-${(Array.isArray(entities.dates)?entities.dates:[]).length}`, date: date, type: match[1]?.toLowerCase().includes('né') ? 'birth' : 'general', confidence: 0.9, source: 'rule-based', }) } }) // Extraction des clauses contractuelles const clausePatterns = [ /(Article\s+\d+[:\-]?\s*[^\.]+\.)/gi, /(Clause\s+\d+[:\-]?\s*[^\.]+\.)/gi, /(Conditions\s+générales[^\.]+\.)/gi, /(Modalités\s+de\s+[^\.]+\.)/gi, /(Obligations\s+du\s+[^\.]+\.)/gi, /(Responsabilités[^\.]+\.)/gi, ] clausePatterns.forEach((pattern) => { for (const match of text.matchAll(pattern)) { const clause = match[1] || match[0] if (clause && clause.length > 10) { entities.contractClauses.push({ id: `clause-${(Array.isArray(entities.contractClauses)?entities.contractClauses:[]).length}`, text: clause.trim(), type: 'contractual', confidence: 0.8, source: 'rule-based', }) } } }) // Extraction des signatures const signaturePatterns = [ /(Signé\s+le\s+\d{1,2}[\/\-]\d{1,2}[\/\-]\d{2,4})/gi, /(Signature\s+de\s+[A-Z][a-zà-öø-ÿ'\-]+\s+[A-Z][a-zà-öø-ÿ'\-]+)/gi, /(Par\s+[A-Z][a-zà-öø-ÿ'\-]+\s+[A-Z][a-zà-öø-ÿ'\-]+)/gi, /(Fait\s+et\s+signé\s+[^\.]+\.)/gi, ] signaturePatterns.forEach((pattern) => { for (const match of text.matchAll(pattern)) { const signature = match[1] || match[0] if (signature && signature.length > 5) { entities.signatures.push({ id: `signature-${(Array.isArray(entities.signatures)?entities.signatures:[]).length}`, text: signature.trim(), type: 'signature', confidence: 0.8, source: 'rule-based', }) } } }) // Classification du type de document if (/carte\s+nationale\s+d'identité|cni|mrz|identite/i.test(text)) { entities.documentType = 'CNI' } else if (/facture|tva|siren|montant|facturation/i.test(text)) { entities.documentType = 'Facture' } else if (/attestation|certificat/i.test(text)) { entities.documentType = 'Attestation' } else if (/contrat|vente|achat|acte/i.test(text)) { entities.documentType = 'Contrat' } // Post-traitement des identités: privilégier MRZ, filtrer les faux positifs try { const wordsBlacklist = new Set([ 'ME', 'DE', 'DU', 'DES', 'LA', 'LE', 'LES', 'ET', 'OU', 'EL', 'DEL', 'D', 'M', 'MR', 'MME', 'FACTURE', 'CONDITIONS', 'PAIEMENT', 'SIGNATURE', 'ADDRESS', 'ADRESSE', 'TEL', 'TÉL', 'EMAIL', ]) const isValidName = (first, last) => { const a = (first || '').replace(/[^A-Za-zÀ-ÖØ-öø-ÿ'\-\s]/g, '').trim() const b = (last || '').replace(/[^A-Za-zÀ-ÖØ-öø-ÿ'\-\s]/g, '').trim() if (!a && !b) return false if (a && (a.length < 2 || wordsBlacklist.has(a.toUpperCase()))) return false if (b && b.length < 2) return false return true } // Séparer MRZ vs autres const mrz = [] const others = [] for (const id of (Array.isArray(entities.identities) ? entities.identities : [])) { if (!isValidName(id.firstName, id.lastName)) continue if ((id.source || '').toLowerCase() === 'mrz') mrz.push(id) else others.push(id) } // Dédupliquer par (first,last) const dedup = (arr) => { const seen = new Set() const out = [] for (const it of arr) { const key = `${(it.firstName || '').toLowerCase()}::${(it.lastName || '').toLowerCase()}` if (seen.has(key)) continue seen.add(key) out.push(it) } return out } let finalIds = dedup(mrz).concat(dedup(others)) // Si une identité MRZ existe, limiter à 1-2 meilleures (éviter bruit) if (mrz.length > 0) { finalIds = dedup(mrz).slice(0, 2) } entities.identities = finalIds } catch (e) { console.warn('[NER] Post-processing identities error:', e?.message || e) } console.log(`[NER] Extraction terminée:`) console.log(` - Identités: ${(Array.isArray(entities.identities)?entities.identities:[]).length}`) console.log(` - Sociétés: ${(Array.isArray(entities.companies)?entities.companies:[]).length}`) console.log(` - Adresses: ${(Array.isArray(entities.addresses)?entities.addresses:[]).length}`) console.log(` - Numéros CNI: ${(Array.isArray(entities.cniNumbers)?entities.cniNumbers:[]).length}`) console.log(` - Dates: ${(Array.isArray(entities.dates)?entities.dates:[]).length}`) console.log(` - Clauses contractuelles: ${(Array.isArray(entities.contractClauses)?entities.contractClauses:[]).length}`) console.log(` - Signatures: ${(Array.isArray(entities.signatures)?entities.signatures:[]).length}`) console.log(` - Type: ${entities.documentType}`) return entities } function capitalize(s) { if (!s) return s return s.charAt(0).toUpperCase() + s.slice(1).toLowerCase() } // Route pour l'extraction de documents app.post('/api/extract', upload.single('document'), async (req, res) => { const startTime = Date.now() try { if (!req.file) { return res.status(400).json({ error: 'Aucun fichier fourni' }) } // Récupérer le hash du dossier depuis les paramètres de requête const folderHash = req.body.folderHash || req.query.folderHash if (!folderHash) { return res.status(400).json({ error: 'Hash du dossier requis' }) } console.log( `[API] Traitement du fichier: ${req.file.originalname} dans le dossier: ${folderHash}`, ) // Calculer le hash du fichier uploadé const fileBuffer = fs.readFileSync(req.file.path) const fileHash = calculateFileHash(fileBuffer) console.log(`[HASH] Hash du fichier: ${fileHash.substring(0, 16)}...`) // Vérifier d'abord le cache JSON dans le dossier const cachedResult = getJsonCacheFromFolder(folderHash, fileHash) if (cachedResult) { console.log(`[CACHE] Utilisation du résultat en cache du dossier ${folderHash}`) // Supprimer le fichier temporaire fs.unlinkSync(req.file.path) // Retourner le résultat en cache return res.json(cachedResult) } // Vérifier si le fichier est déjà en cours de traitement if (isFilePending(folderHash, fileHash)) { console.log(`[CACHE] Fichier déjà en cours de traitement: ${fileHash}`) fs.unlinkSync(req.file.path) return res.status(202).json({ success: false, status: 'pending', message: 'Fichier en cours de traitement', fileHash, }) } // Créer un flag pending pour ce fichier createPendingFlag(folderHash, fileHash) // Vérifier si un fichier avec le même hash existe déjà dans le dossier const existingFile = findExistingFileByHash(fileHash, folderHash) let isDuplicate = false let duplicatePath = null if (existingFile) { console.log( `[HASH] Fichier déjà existant trouvé dans le dossier ${folderHash}: ${existingFile.name}`, ) isDuplicate = true // Sauvegarder le chemin du doublon pour suppression ultérieure duplicatePath = req.file.path // Utiliser le fichier existant pour le traitement req.file.path = existingFile.path req.file.originalname = existingFile.name } else { console.log(`[HASH] Nouveau fichier, renommage avec hash dans le dossier ${folderHash}`) // Créer la structure du dossier si elle n'existe pas const { folderPath } = createFolderStructure(folderHash) // Renommer le fichier avec son hash + extension dans le dossier const ext = path.extname(req.file.originalname) const newFileName = `${fileHash}${ext}` const newFilePath = path.join(folderPath, newFileName) // Renommer le fichier fs.renameSync(req.file.path, newFilePath) // Mettre à jour les informations du fichier req.file.path = newFilePath req.file.filename = newFileName console.log(`[HASH] Fichier renommé: ${newFileName}`) } let ocrResult let result try { // Si c'est un PDF, extraire le texte directement if (req.file.mimetype === 'application/pdf') { console.log(`[API] Extraction de texte depuis PDF...`) try { ocrResult = await extractTextFromPdf(req.file.path) console.log(`[API] Texte extrait du PDF: ${ocrResult.text.length} caractères`) } catch (error) { console.error(`[API] Erreur lors de l'extraction PDF:`, error.message) throw new Error(`Erreur lors de l'extraction PDF: ${error.message}`) } } else if (req.file.mimetype === 'text/plain') { // Lecture directe pour les fichiers texte console.log(`[API] Lecture de texte depuis fichier .txt...`) try { const text = fs.readFileSync(req.file.path, 'utf8') ocrResult = { text, confidence: 95, words: text.split(/\s+/).filter((w) => w.length > 0), } console.log(`[API] Texte lu: ${text.length} caractères`) } catch (error) { console.error(`[API] Erreur lecture .txt:`, error.message) throw new Error(`Erreur lors de la lecture du fichier texte: ${error.message}`) } } else { // Pour les images, utiliser l'OCR amélioré avec détection CNI const { extractTextFromImageEnhanced } = require('./enhancedOcr') ocrResult = await extractTextFromImageEnhanced(req.file.path) } // Extraction NER const entities = extractEntitiesFromText(ocrResult.text) // Mesure du temps de traitement const processingTime = Date.now() - startTime // Génération du format JSON standard (avec repli sûr) try { result = generateStandardJSON(req.file, ocrResult, entities, processingTime) } catch (genErr) { console.error('[API] Erreur generateStandardJSON, application d\'un repli:', genErr) const safeText = typeof ocrResult.text === 'string' ? ocrResult.text : '' const safeMime = req.file.mimetype || getMimeType(path.extname(req.file.path)) const fallbackProcessing = { engine: '4NK_IA_Backend', version: '1.0.0', processingTime: `${processingTime}ms`, ocrEngine: safeMime === 'application/pdf' ? 'pdf-parse' : 'tesseract.js', nerEngine: 'rule-based', preprocessing: { applied: safeMime !== 'application/pdf', reason: safeMime === 'application/pdf' ? 'PDF direct text extraction' : 'Image preprocessing applied', }, } result = { document: { id: `doc-${Date.now()}`, fileName: req.file.originalname || path.basename(req.file.path), fileSize: req.file.size || (fs.existsSync(req.file.path) ? fs.statSync(req.file.path).size : 0), mimeType: safeMime, uploadTimestamp: new Date().toISOString(), }, classification: { documentType: 'Document', confidence: 0.6, subType: getDocumentSubType('Document', safeText), language: 'fr', pageCount: 1, }, extraction: { text: { raw: safeText, processed: correctOCRText(safeText), wordCount: safeText.trim().split(/\s+/).filter(Boolean).length, characterCount: safeText.length, confidence: (typeof ocrResult.confidence === 'number' ? ocrResult.confidence : 0) / 100, }, entities: { persons: [], companies: [], addresses: [], financial: extractFinancialInfo(safeText, 'Document'), dates: [], contractual: { clauses: [], signatures: [] }, references: extractReferences(safeText, 'Document'), }, }, metadata: { processing: fallbackProcessing, quality: { globalConfidence: 0.6, textExtractionConfidence: (typeof ocrResult.confidence === 'number' ? ocrResult.confidence : 0) / 100, entityExtractionConfidence: 0.6, classificationConfidence: 0.6, }, }, status: { success: true, errors: ['fallback: generateStandardJSON error'], warnings: [], timestamp: new Date().toISOString() }, } } // Sauvegarder le résultat dans le cache du dossier saveJsonCacheInFolder(folderHash, fileHash, result) // Révision Ollama automatique (non bloquante) try { const review = await reviewWithOllama({ document: result.document, extraction: result.extraction, classification: result.classification, metadata: result.metadata, }) if (review && typeof review.score === 'number') { if (!result.metadata) result.metadata = {} if (!result.metadata.quality) result.metadata.quality = {} result.metadata.quality.ollamaScore = review.score // Ajuster la confiance globale si le score est supérieur result.metadata.quality.globalConfidence = Math.max( result.metadata.quality.globalConfidence || 0, review.score, ) result.status = result.status || {} result.status.review = review saveJsonCacheInFolder(folderHash, fileHash, result) } } catch (e) { console.warn('[OLLAMA] Révision automatique échouée:', e?.message || e) } console.log(`[API] Traitement terminé en ${Date.now() - startTime}ms`) } catch (error) { console.error(`[API] Erreur lors du traitement du fichier ${fileHash}:`, error) // Supprimer le flag pending en cas d'erreur const { cachePath } = createFolderStructure(folderHash) const pendingFile = path.join(cachePath, `${fileHash}.pending`) if (fs.existsSync(pendingFile)) { fs.unlinkSync(pendingFile) console.log(`[CACHE] Flag pending supprimé après erreur pour ${fileHash}`) } return res.status(500).json({ success: false, error: 'Erreur lors du traitement du document', details: error.message, }) } finally { // Nettoyage du fichier temporaire if (isDuplicate) { // Supprimer le doublon uploadé fs.unlinkSync(duplicatePath) } // Note: Ne pas supprimer req.file.path car c'est le fichier final dans le dossier } console.log( `[API] Traitement terminé avec succès - Confiance: ${Math.round(result.metadata.quality.globalConfidence * 100)}%`, ) res.json(result) } catch (error) { console.error('[API] Erreur lors du traitement:', error) // Nettoyage en cas d'erreur if (req.file && fs.existsSync(req.file.path)) { fs.unlinkSync(req.file.path) } res.status(500).json({ success: false, error: 'Erreur lors du traitement du document', details: error.message, }) } }) // Route pour lister les fichiers de test app.get('/api/test-files', (req, res) => { try { const testFilesDir = path.join(__dirname, '..', 'test-files') const files = fs .readdirSync(testFilesDir) .filter((file) => { const ext = path.extname(file).toLowerCase() return ['.jpg', '.jpeg', '.png', '.pdf', '.tiff'].includes(ext) }) .map((file) => { const filePath = path.join(testFilesDir, file) const stats = fs.statSync(filePath) return { name: file, size: stats.size, type: path.extname(file).toLowerCase(), lastModified: stats.mtime, } }) res.json({ success: true, files }) } catch (error) { res.status(500).json({ success: false, error: error.message }) } }) // Route pour servir un fichier de test individuel app.get('/api/test-files/:filename', (req, res) => { try { const filename = req.params.filename const testFilesDir = path.join(__dirname, '..', 'test-files') const filePath = path.join(testFilesDir, filename) // Vérifier que le fichier existe et est dans le bon répertoire if (!fs.existsSync(filePath)) { return res.status(404).json({ success: false, error: 'Fichier non trouvé' }) } // Vérifier que le fichier est bien dans le répertoire test-files (sécurité) const resolvedPath = path.resolve(filePath) const resolvedTestDir = path.resolve(testFilesDir) if (!resolvedPath.startsWith(resolvedTestDir)) { return res.status(403).json({ success: false, error: 'Accès non autorisé' }) } // Servir le fichier res.sendFile(filePath) } catch (error) { res.status(500).json({ success: false, error: error.message }) } }) // Route de santé // Route pour lister les fichiers uploadés avec leurs hash app.get('/api/uploads', (req, res) => { try { const uploadDir = 'uploads/' if (!fs.existsSync(uploadDir)) { return res.json({ files: [] }) } const files = fs.readdirSync(uploadDir) const fileList = files .map((file) => { const filePath = path.join(uploadDir, file) try { const stats = fs.statSync(filePath) // Extraire le hash du nom de fichier (format: hash.extension) const ext = path.extname(file) const hash = path.basename(file, ext) return { name: file, size: stats.size, hash: hash, uploadDate: stats.birthtime, modifiedDate: stats.mtime, } } catch (error) { console.warn(`[API] Erreur lors de la lecture de ${file}:`, error.message) return null } }) .filter((file) => file !== null) res.json({ files: fileList, count: fileList.length, totalSize: fileList.reduce((sum, file) => sum + file.size, 0), }) } catch (error) { console.error('[API] Erreur lors de la liste des fichiers:', error) res.status(500).json({ error: 'Erreur lors de la récupération des fichiers' }) } }) // Route pour lister les fichiers de cache JSON app.get('/api/cache', (req, res) => { try { const cacheFiles = listCacheFiles() res.json({ files: cacheFiles, count: cacheFiles.length, totalSize: cacheFiles.reduce((sum, file) => sum + file.size, 0), }) } catch (error) { console.error('[API] Erreur lors de la liste du cache:', error) res.status(500).json({ error: 'Erreur lors de la récupération du cache' }) } }) // Route pour récupérer un résultat de cache spécifique app.get('/api/cache/:hash', (req, res) => { try { const { hash } = req.params const cachedResult = getJsonCache(hash) if (cachedResult) { res.json(cachedResult) } else { res.status(404).json({ error: 'Résultat non trouvé dans le cache' }) } } catch (error) { console.error('[API] Erreur lors de la récupération du cache:', error) res.status(500).json({ error: 'Erreur lors de la récupération du cache' }) } }) // Route pour supprimer un fichier de cache app.delete('/api/cache/:hash', (req, res) => { try { const { hash } = req.params const cacheFile = path.join('cache/', `${hash}.json`) if (fs.existsSync(cacheFile)) { fs.unlinkSync(cacheFile) console.log(`[CACHE] Fichier supprimé: ${hash.substring(0, 16)}...`) res.json({ message: 'Fichier de cache supprimé avec succès' }) } else { res.status(404).json({ error: 'Fichier de cache non trouvé' }) } } catch (error) { console.error('[API] Erreur lors de la suppression du cache:', error) res.status(500).json({ error: 'Erreur lors de la suppression du cache' }) } }) // Route pour créer un nouveau dossier app.post('/api/folders', (req, res) => { try { console.log("[FOLDER] Début de la création d'un nouveau dossier") const folderHash = generateFolderHash() console.log(`[FOLDER] Hash généré: ${folderHash}`) const result = createFolderStructure(folderHash) console.log(`[FOLDER] Structure créée:`, result) // Écrire la métadonnée si fournie const name = (req.body && typeof req.body.name === 'string' && req.body.name.trim()) ? req.body.name.trim() : null const description = (req.body && typeof req.body.description === 'string') ? req.body.description : null writeFolderMeta(folderHash, name, description) console.log(`[FOLDER] Nouveau dossier créé: ${folderHash}`) res.json({ success: true, folderHash, name: name || null, description: description || null, message: 'Dossier créé avec succès', }) } catch (error) { console.error('[FOLDER] Erreur lors de la création du dossier:', error) res.status(500).json({ success: false, error: error.message, }) } }) // Route pour récupérer les résultats d'un dossier app.get('/api/folders/:folderHash/results', async (req, res) => { try { const { folderHash } = req.params // Nettoyage automatique du cache avant génération de la réponse const pruned = pruneFolderCache(folderHash) const folderData = await listFolderResults(folderHash) const meta = readFolderMeta(folderHash) // ETag basé sur le hash des contenus const etagPayload = JSON.stringify({ name: meta?.name || null, results: folderData.results.map((r) => r.fileHash), pending: folderData.pending.map((p) => p.fileHash), count: folderData.results.length, pruned, }) const etag = crypto.createHash('md5').update(etagPayload).digest('hex') res.setHeader('ETag', etag) const ifNoneMatch = req.headers['if-none-match'] if (ifNoneMatch && ifNoneMatch === etag) { return res.status(304).end() } console.log( `[FOLDER] Résultats récupérés pour le dossier ${folderHash}: ${folderData.results.length} fichiers, ${folderData.pending.length} en cours`, ) res.json({ success: true, folderHash, folderName: meta?.name || null, results: folderData.results, pending: folderData.pending, hasPending: folderData.hasPending, count: folderData.results.length, pruned, }) } catch (error) { console.error('[FOLDER] Erreur lors de la récupération des résultats:', error) res.status(500).json({ success: false, error: error.message, }) } }) // Route pour récupérer les métadonnées d'un dossier (nom, description) app.get('/api/folders/:folderHash/meta', (req, res) => { try { const { folderHash } = req.params const meta = readFolderMeta(folderHash) if (!meta) { return res.status(404).json({ success: false, folderHash, name: null }) } return res.json({ success: true, folderHash, name: meta?.name || null, description: meta?.description || null }) } catch (error) { console.error('[FOLDER] Erreur meta:', error) return res.status(500).json({ success: false, error: error.message }) } }) // Route pour récupérer un fichier original depuis un dossier app.get('/api/folders/:folderHash/files/:fileHash', (req, res) => { try { const { folderHash, fileHash } = req.params const folderPath = path.join('uploads', folderHash) if (!fs.existsSync(folderPath)) { return res.status(404).json({ success: false, error: 'Dossier non trouvé' }) } const files = fs.readdirSync(folderPath) const targetFile = files.find((file) => file.startsWith(fileHash)) if (!targetFile) { return res.status(404).json({ success: false, error: 'Fichier non trouvé' }) } const filePath = path.join(folderPath, targetFile) res.sendFile(path.resolve(filePath)) } catch (error) { console.error('[FOLDER] Erreur lors de la récupération du fichier:', error) res.status(500).json({ success: false, error: error.message, }) } }) // Confirmer (ou corriger) l'adresse détectée pour un fichier app.post('/api/folders/:folderHash/files/:fileHash/confirm-address', express.json(), (req, res) => { try { const { folderHash, fileHash } = req.params const { address, confirmed } = req.body || {} const cachePath = path.join('cache', folderHash) const jsonPath = path.join(cachePath, `${fileHash}.json`) if (!fs.existsSync(jsonPath)) { return res.status(404).json({ success: false, error: 'Résultat non trouvé' }) } const raw = fs.readFileSync(jsonPath, 'utf8') const data = JSON.parse(raw) // Mettre à jour la première adresse et marquer la confirmation if (confirmed) { if (!data.extraction) data.extraction = {} if (!data.extraction.entities) data.extraction.entities = {} if (!Array.isArray(data.extraction.entities.addresses)) data.extraction.entities.addresses = [] if (address && typeof address === 'object') { data.extraction.entities.addresses[0] = { id: data.extraction.entities.addresses[0]?.id || `address-0`, type: 'general', street: address.street || '', city: address.city || '', postalCode: address.postalCode || '', country: address.country || 'France', confidence: 1.0, source: 'confirmed', } } if (!data.metadata) data.metadata = {} if (!data.metadata.confirmations) data.metadata.confirmations = {} data.metadata.confirmations.addressConfirmed = true if (data.status && data.status.suggestions) { data.status.suggestions.needsAddressConfirmation = false } fs.writeFileSync(jsonPath, JSON.stringify(data, null, 2)) return res.json({ success: true }) } return res.status(400).json({ success: false, error: 'Paramètre confirmed manquant' }) } catch (e) { return res.status(500).json({ success: false, error: e?.message || String(e) }) } }) // Supprimer une entité (personne/adresse/entreprise) du cache JSON // Body JSON attendu: { kind: 'person'|'address'|'company', id?: string, index?: number } app.post('/api/folders/:folderHash/files/:fileHash/entities/delete', express.json(), (req, res) => { try { const { folderHash, fileHash } = req.params const { kind, id, index } = req.body || {} if (!['person', 'address', 'company'].includes(kind)) { return res.status(400).json({ success: false, error: 'Paramètre kind invalide' }) } const cachePath = path.join('cache', folderHash) const jsonPath = path.join(cachePath, `${fileHash}.json`) if (!fs.existsSync(jsonPath)) { return res.status(404).json({ success: false, error: 'Résultat non trouvé' }) } const data = JSON.parse(fs.readFileSync(jsonPath, 'utf8')) const ents = (((data || {}).extraction || {}).entities || {}) const map = { person: 'persons', address: 'addresses', company: 'companies', } const key = map[kind] if (!Array.isArray(ents[key])) ents[key] = [] let before = ents[key].length if (typeof index === 'number' && index >= 0 && index < ents[key].length) { ents[key].splice(index, 1) } else if (typeof id === 'string' && id.length > 0) { ents[key] = ents[key].filter((e) => (e && (e.id === id)) === false) } else { return res.status(400).json({ success: false, error: 'id ou index requis' }) } const after = ents[key].length if (after === before) { return res.status(404).json({ success: false, error: 'Entité non trouvée' }) } // Sauvegarde fs.writeFileSync(jsonPath, JSON.stringify(data, null, 2)) return res.json({ success: true, kind, removed: 1 }) } catch (e) { return res.status(500).json({ success: false, error: e?.message || String(e) }) } }) // Mettre à jour une entité (personne/adresse/entreprise) dans le cache JSON // Body: { kind: 'person'|'address'|'company', id?: string, index?: number, patch: object } app.post('/api/folders/:folderHash/files/:fileHash/entities/update', express.json(), (req, res) => { try { const { folderHash, fileHash } = req.params const { kind, id, index, patch } = req.body || {} if (!['person', 'address', 'company'].includes(kind)) { return res.status(400).json({ success: false, error: 'Paramètre kind invalide' }) } if (!patch || typeof patch !== 'object') { return res.status(400).json({ success: false, error: 'Patch invalide' }) } const jsonPath = path.join('cache', folderHash, `${fileHash}.json`) if (!fs.existsSync(jsonPath)) { return res.status(404).json({ success: false, error: 'Résultat non trouvé' }) } const data = JSON.parse(fs.readFileSync(jsonPath, 'utf8')) const ents = (((data || {}).extraction || {}).entities || {}) const map = { person: 'persons', address: 'addresses', company: 'companies' } const key = map[kind] if (!Array.isArray(ents[key])) ents[key] = [] let targetIndex = -1 if (typeof index === 'number' && index >= 0 && index < ents[key].length) { targetIndex = index } else if (typeof id === 'string' && id.length > 0) { targetIndex = ents[key].findIndex((e) => e && e.id === id) } if (targetIndex < 0) { return res.status(404).json({ success: false, error: 'Entité non trouvée' }) } const before = ents[key][targetIndex] || {} const updated = { ...before, ...patch } ents[key][targetIndex] = updated // Sauvegarder fs.writeFileSync(jsonPath, JSON.stringify(data, null, 2)) return res.json({ success: true, kind, index: targetIndex, entity: updated }) } catch (e) { return res.status(500).json({ success: false, error: e?.message || String(e) }) } }) // Suppression d'un fichier d'un dossier (uploads + cache) app.delete('/api/folders/:folderHash/files/:fileHash', (req, res) => { try { const { folderHash, fileHash } = req.params const folderPath = path.join('uploads', folderHash) const cachePath = path.join('cache', folderHash) if (!fs.existsSync(folderPath)) { return res.status(404).json({ success: false, error: 'Dossier non trouvé' }) } const files = fs.readdirSync(folderPath) const targetFile = files.find((file) => file.startsWith(fileHash)) if (!targetFile) { return res.status(404).json({ success: false, error: 'Fichier non trouvé' }) } // Supprimer le fichier original fs.unlinkSync(path.join(folderPath, targetFile)) // Supprimer le JSON de cache et le flag pending éventuel try { fs.unlinkSync(path.join(cachePath, `${fileHash}.json`)) } catch {} try { fs.unlinkSync(path.join(cachePath, `${fileHash}.pending`)) } catch {} return res.json({ success: true, folderHash, fileHash }) } catch (error) { console.error('[FOLDER] Erreur suppression fichier:', error) return res.status(500).json({ success: false, error: error.message }) } }) // Route pour vider le cache d'un dossier (supprime *.json et *.pending) app.delete('/api/folders/:folderHash/cache', (req, res) => { try { const { folderHash } = req.params const cachePath = path.join('cache', folderHash) if (!fs.existsSync(cachePath)) { return res.status(404).json({ success: false, error: 'Dossier de cache introuvable' }) } const files = fs.readdirSync(cachePath) let removed = 0 for (const file of files) { if (file.endsWith('.json') || file.endsWith('.pending')) { try { fs.unlinkSync(path.join(cachePath, file)) removed += 1 } catch (err) { console.warn(`[CACHE] Suppression échouée pour ${file}:`, err.message) } } } return res.json({ success: true, folderHash, removed }) } catch (error) { console.error('[CACHE] Erreur lors du vidage du cache du dossier:', error) return res.status(500).json({ success: false, error: 'Erreur lors du vidage du cache' }) } }) // Route pour (re)traiter un dossier existant basé sur uploads/ app.post('/api/folders/:folderHash/reprocess', async (req, res) => { try { const { folderHash } = req.params const { folderPath, cachePath } = createFolderStructure(folderHash) console.log(`[FOLDER] Re-traitement demandé pour: ${folderHash}`) // Lister les fichiers présents dans uploads/ const uploadFiles = fs.existsSync(folderPath) ? fs.readdirSync(folderPath).filter((f) => fs.statSync(path.join(folderPath, f)).isFile()) : [] let scheduled = 0 for (const file of uploadFiles) { const filePath = path.join(folderPath, file) const fileHash = path.basename(file, path.extname(file)) const hasCache = fs.existsSync(path.join(cachePath, `${fileHash}.json`)) const isPending = fs.existsSync(path.join(cachePath, `${fileHash}.pending`)) if (!hasCache && !isPending) { createPendingFlag(folderHash, fileHash) processFileInBackground(filePath, fileHash, folderHash).catch((err) => console.error('[BACKGROUND] Reprocess error:', err?.message || err), ) scheduled += 1 } } res.json({ success: true, folderHash, scheduled }) } catch (error) { console.error('[FOLDER] Erreur lors du re-traitement du dossier:', error) res.status(500).json({ success: false, error: error.message }) } }) // Route pour créer le dossier par défaut avec les fichiers de test app.post('/api/folders/default', async (req, res) => { try { const folderHash = generateFolderHash() const { folderPath, cachePath } = createFolderStructure(folderHash) console.log(`[FOLDER] Création du dossier par défaut: ${folderHash}`) // Écrire la métadonnée du dossier par défaut writeFolderMeta(folderHash, 'Dossier par défaut', 'Dossier initial préchargé') // Charger les fichiers de test dans le dossier const testFilesDir = path.join(__dirname, '..', 'test-files') if (fs.existsSync(testFilesDir)) { const testFiles = fs.readdirSync(testFilesDir) const supportedFiles = testFiles.filter((file) => ['.pdf', '.jpg', '.jpeg', '.png', '.tiff', '.txt'].includes( path.extname(file).toLowerCase(), ), ) for (const testFile of supportedFiles) { const sourcePath = path.join(testFilesDir, testFile) const fileBuffer = fs.readFileSync(sourcePath) const fileHash = calculateFileHash(fileBuffer) const ext = path.extname(testFile) const newFileName = `${fileHash}${ext}` const destPath = path.join(folderPath, newFileName) // Copier le fichier fs.copyFileSync(sourcePath, destPath) // Traiter le fichier et sauvegarder le résultat try { console.log(`[FOLDER] Traitement de ${testFile}...`) // Simuler un objet req.file pour la logique existante const mockFile = { path: destPath, originalname: testFile, mimetype: getMimeType(ext), } // Extraction de texte selon le type de fichier let ocrResult = null if (ext.toLowerCase() === '.pdf') { ocrResult = await extractTextFromPdf(destPath) } else if (['.jpg', '.jpeg', '.png', '.tiff'].includes(ext.toLowerCase())) { ocrResult = await extractTextFromImage(destPath) } else if (ext.toLowerCase() === '.txt') { const text = fs.readFileSync(destPath, 'utf8') ocrResult = { text, confidence: 95, words: text.split(/\s+/).filter((w) => w) } } if (ocrResult && ocrResult.text) { // Extraction des entités const entities = extractEntitiesFromText(ocrResult.text) // Génération du résultat au format standard const result = generateStandardJSON(mockFile, ocrResult, entities, 0) // Sauvegarde dans le cache du dossier saveJsonCacheInFolder(folderHash, fileHash, result) console.log(`[FOLDER] Fichier de test traité: ${testFile} -> ${fileHash}`) } else { console.warn(`[FOLDER] Aucun texte extrait de ${testFile}`) } } catch (error) { console.warn(`[FOLDER] Erreur lors du traitement de ${testFile}:`, error.message) // Repli: enregistrer un cache minimal pour rendre le fichier visible côté frontend try { const nowIso = new Date().toISOString() const minimal = { document: { id: `doc-preload-${Date.now()}`, fileName: testFile, fileSize: fs.existsSync(destPath) ? fs.statSync(destPath).size : fileBuffer.length, mimeType: getMimeType(ext), uploadTimestamp: nowIso, }, classification: { documentType: 'Document', confidence: 0.6, subType: 'Document', language: 'fr', pageCount: 1, }, extraction: { text: { raw: `Préchargé: ${testFile}`, processed: `Préchargé: ${testFile}`, wordCount: 2, characterCount: (`Préchargé: ${testFile}`).length, confidence: 0.6, }, entities: { persons: [], companies: [], addresses: [], financial: { amounts: [], totals: {}, payment: {} }, dates: [], contractual: { clauses: [], signatures: [] }, references: [], }, }, metadata: { processing: { engine: 'preload', version: '1', processingTime: '0ms', ocrEngine: 'preload', nerEngine: 'none', preprocessing: { applied: false, reason: 'preload' }, }, quality: { globalConfidence: 0.6, textExtractionConfidence: 0.6, entityExtractionConfidence: 0.6, classificationConfidence: 0.6, }, }, status: { success: true, errors: [], warnings: [], timestamp: nowIso }, } saveJsonCacheInFolder(folderHash, fileHash, minimal) console.log(`[FOLDER] Repli: cache minimal écrit pour ${testFile}`) } catch (fallbackErr) { console.warn(`[FOLDER] Repli échoué pour ${testFile}:`, fallbackErr.message) } } } } res.json({ success: true, folderHash, name: 'Dossier par défaut', message: 'Dossier par défaut créé avec succès', }) } catch (error) { console.error('[FOLDER] Erreur lors de la création du dossier par défaut:', error) res.status(500).json({ success: false, error: error.message, }) } }) // Route pour lire la métadonnée d'un dossier app.get('/api/folders/:folderHash/meta', (req, res) => { try { const { folderHash } = req.params const meta = readFolderMeta(folderHash) res.json({ success: true, folderHash, name: meta?.name || null, description: meta?.description || null }) } catch (e) { res.status(500).json({ success: false, error: e?.message || String(e) }) } }) app.get('/api/health', (req, res) => { const metrics = collectMetrics() res.json({ status: 'OK', timestamp: new Date().toISOString(), version: '1.0.0', metrics, }) }) // Enrichissement asynchrone des entités (squelette) // Démarre une collecte et enregistre un statut côté cache app.post('/api/folders/:folderHash/files/:fileHash/enrich/:kind', (req, res) => { try { const { folderHash, fileHash, kind } = req.params if (!['person', 'address', 'company'].includes(kind)) { return res.status(400).json({ success: false, error: 'Kind invalide' }) } const cachePath = path.join('cache', folderHash) if (!fs.existsSync(cachePath)) fs.mkdirSync(cachePath, { recursive: true }) const statusPath = path.join(cachePath, `${fileHash}.enrich.${kind}.json`) const pdfPath = path.join(cachePath, `${fileHash}.enrich.${kind}.pdf`) // Statut initial const status = { kind, state: 'running', startedAt: new Date().toISOString(), finishedAt: null, message: 'Collecte lancée', sources: [], } fs.writeFileSync(statusPath, JSON.stringify(status, null, 2)) // Simuler une collecte asynchrone courte setTimeout(() => { try { const done = { ...status, state: 'done', finishedAt: new Date().toISOString(), message: 'Collecte terminée', sources: (kind === 'person') ? ['bodacc_gel_avoirs'] : (kind === 'company') ? ['kbis_inforgreffe', 'societe_com'] : ['cadastre', 'georisque', 'geofoncier'], } fs.writeFileSync(statusPath, JSON.stringify(done, null, 2)) // Générer un PDF minimal (texte) pour preuve de concept try { const content = `Dossier d'enrichissement\nKind: ${kind}\nFichier: ${fileHash}\nSources: ${done.sources.join(', ')}\nDate: ${new Date().toISOString()}\n` fs.writeFileSync(pdfPath, content) } catch {} } catch {} }, 1500) return res.json({ success: true }) } catch (e) { return res.status(500).json({ success: false, error: e?.message || String(e) }) } }) // Lire le statut d'enrichissement app.get('/api/folders/:folderHash/files/:fileHash/enrich/:kind/status', (req, res) => { try { const { folderHash, fileHash, kind } = req.params const statusPath = path.join('cache', folderHash, `${fileHash}.enrich.${kind}.json`) if (!fs.existsSync(statusPath)) return res.json({ success: true, state: 'idle' }) const data = JSON.parse(fs.readFileSync(statusPath, 'utf8')) return res.json({ success: true, ...data }) } catch (e) { return res.status(500).json({ success: false, error: e?.message || String(e) }) } }) // Télécharger le PDF d'enrichissement app.get('/api/folders/:folderHash/files/:fileHash/enrich/:kind/pdf', (req, res) => { try { const { folderHash, fileHash, kind } = req.params const pdfPath = path.join('cache', folderHash, `${fileHash}.enrich.${kind}.pdf`) if (!fs.existsSync(pdfPath)) return res.status(404).json({ success: false }) return res.sendFile(path.resolve(pdfPath)) } catch (e) { return res.status(500).json({ success: false, error: e?.message || String(e) }) } }) // Démarrage du serveur app.listen(PORT, () => { console.log(`🚀 Serveur backend démarré sur le port ${PORT}`) console.log(`📡 API disponible sur: http://localhost:${PORT}/api`) console.log(`🏥 Health check: http://localhost:${PORT}/api/health`) console.log(`📁 Test files: http://localhost:${PORT}/api/test-files`) console.log(`📂 Répertoire de travail: ${process.cwd()}`) // Nettoyer les flags pending orphelins au démarrage cleanupOrphanedPendingFlags() }) module.exports = app