4NK IA 8033afd748 docs: rendre générique la documentation et tests OCR
- docs/ocr_cni_adresse.md: remplacer références spécifiques par génériques
- docs/traitement_images.md: généraliser la description des cas cibles
- tests/ocr_cni_adresse.md: tests génériques pour identités
- test-web-interface.cjs: interface générique pour détection d\identités
2025-09-18 20:15:55 +00:00

2866 lines
102 KiB
JavaScript

#!/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 { extractMetierEntities } = require('./entityExtraction')
const pdf = require('pdf-parse')
// Collecteurs d'enrichissement
const { searchBodaccGelAvoirs, generateBodaccSummary } = require('./collectors/bodaccCollector')
const { searchCompanyInfo, generateCompanySummary } = require('./collectors/inforgreffeCollector')
const { searchRBEBeneficiaires, searchPersonneEntreprises } = require('./collectors/rbeCollector')
const { searchGeofoncierInfo } = require('./collectors/geofoncierCollector')
const { generatePersonPdf, generateCompanyPdf, generateAddressPdf } = require('./collectors/pdfGenerator')
const { collectAddressData } = require('./collectors/addressCollector')
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/<hash>/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)
// Extraction des entités métier spécialisées
const metierEntities = extractMetierEntities(ocrResult.text)
console.log(`[METIER] Entités métier extraites:`, {
biens: metierEntities.biensImmobiliers.length,
clauses: metierEntities.clauses.length,
signatures: metierEntities.signatures.length,
heritiers: metierEntities.heritiers.length,
type: metierEntities.documentType
})
// 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, metierEntities)
} 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, metierEntities = null) {
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,
})),
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,
})),
},
},
},
metadata: {
processing: {
engine: '4NK_IA_Backend',
version: '1.0.1',
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<<PRENOMS)
// Normaliser (suppression accents) et mettre en majuscules
const mrzText = t
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toUpperCase()
// Recherche stricte NOM<<PRENOMS (MRZ)
const mrzRegex = /\b([A-Z]{2,})(?:<+)([A-Z<]{2,})\b/g
let match
while ((match = mrzRegex.exec(mrzText)) !== null) {
const rawSurname = match[1].replace(/</g, ' ').trim()
// Certains MRZ concatènent plusieurs prénoms séparés par <<
const rawGiven = match[2].split('<<')[0].replace(/</g, ' ').trim()
if (rawSurname.length >= 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 NOM<<<<...)
const mSurname = mrzText.match(/IDFRA\s*([A-Z]{2,})</)
// Chercher PRENOM avant << (ex: PRENOM<<...)
const mGiven = mrzText.match(/\b([A-Z]{2,})<<[A-Z<]{2,}\b/)
const last = mSurname?.[1]?.trim()
const first = mGiven?.[1]?.trim()
if (last && last.length >= 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',
})
}
}
// Recherche générique pour les patterns de noms corrompus
if (!(Array.isArray(entities.identities) && entities.identities.length > 0)) {
// Patterns génériques pour détecter les noms corrompus
const namePatterns = [
// Pattern: NOM[<]*PRENOM
/([A-Z]{2,})[<]+([A-Z]{2,})/gi,
// Pattern: NOM avec caractères parasites
/([A-Z]{2,})CCKKLLLLK/gi,
// Pattern: NOM avec erreurs OCR courantes
/([A-Z]{2,})[0-9]([A-Z]{2,})/gi,
]
for (const pattern of namePatterns) {
const match = correctedText.match(pattern)
if (match) {
// Extraire le nom et prénom du pattern
let lastName = ''
let firstName = ''
if (match.length >= 3) {
// Pattern avec séparateur
lastName = match[1] || ''
firstName = match[2] || ''
} else if (match.length >= 2) {
// Pattern simple, essayer de séparer
const fullName = match[1] || match[0]
const parts = fullName.split(/[<0-9]+/)
if (parts.length >= 2) {
lastName = parts[0] || ''
firstName = parts[1] || ''
}
}
if (lastName && firstName && lastName.length >= 2 && firstName.length >= 2) {
entities.identities.push({
id: `identity-${(Array.isArray(entities.identities)?entities.identities:[]).length}`,
type: 'person',
firstName: firstName.trim(),
lastName: lastName.trim(),
confidence: 0.85,
source: 'pattern-detection',
})
break
}
}
}
}
// 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)
// Extraction des entités métier spécialisées
const metierEntities = extractMetierEntities(ocrResult.text)
console.log(`[METIER] Entités métier extraites:`, {
biens: metierEntities.biensImmobiliers.length,
clauses: metierEntities.clauses.length,
signatures: metierEntities.signatures.length,
heritiers: metierEntities.heritiers.length,
type: metierEntities.documentType
})
// 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, metierEntities)
} 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/<folderHash>
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/<hash>
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.1',
metrics,
})
})
// Enrichissement asynchrone des entités avec collecteurs réels
// Démarre une collecte et enregistre un statut côté cache
app.post('/api/folders/:folderHash/files/:fileHash/enrich/:kind', async (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))
res.json({ success: true, message: 'Enrichissement démarré' })
// Enrichissement asynchrone selon le type
setTimeout(async () => {
try {
let result = null
let pdfGenerated = false
if (kind === 'person') {
// Recherche Bodacc pour les personnes
const cacheFile = path.join(cachePath, `${fileHash}.json`)
if (fs.existsSync(cacheFile)) {
const cacheData = JSON.parse(fs.readFileSync(cacheFile, 'utf8'))
const persons = cacheData.entities?.persons || []
for (const person of persons) {
if (person.lastName) {
console.log(`[Enrich] Recherche Bodacc pour: ${person.firstName} ${person.lastName}`)
result = await searchBodaccGelAvoirs(person.lastName, person.firstName)
if (result.success) {
const summary = generateBodaccSummary(result.results, person.lastName, person.firstName)
result.summary = summary
// Génération du PDF
try {
await generatePersonPdf(person, result, pdfPath)
pdfGenerated = true
} catch (pdfError) {
console.warn(`[Enrich] Erreur génération PDF personne:`, pdfError.message)
}
}
break // Traiter seulement la première personne trouvée
}
}
}
} else if (kind === 'company') {
// Recherche Inforgreffe + RBE pour les entreprises
const cacheFile = path.join(cachePath, `${fileHash}.json`)
if (fs.existsSync(cacheFile)) {
const cacheData = JSON.parse(fs.readFileSync(cacheFile, 'utf8'))
const companies = cacheData.entities?.companies || []
for (const company of companies) {
if (company.name) {
console.log(`[Enrich] Recherche Inforgreffe + RBE pour: ${company.name}`)
// Recherche Inforgreffe
const inforgreffeResult = await searchCompanyInfo(company.name, company.siren)
// Recherche RBE si SIREN disponible
let rbeResult = null
if (inforgreffeResult.success && inforgreffeResult.company?.siren) {
console.log(`[Enrich] Recherche RBE pour SIREN: ${inforgreffeResult.company.siren}`)
rbeResult = await searchRBEBeneficiaires(inforgreffeResult.company.siren)
}
// Fusion des résultats
result = {
success: inforgreffeResult.success || (rbeResult && rbeResult.success),
company: company,
inforgreffe: inforgreffeResult,
rbe: rbeResult,
summary: inforgreffeResult.success ? generateCompanySummary(inforgreffeResult.company, company.name) : null,
timestamp: new Date().toISOString()
}
if (result.success) {
// Génération du PDF
try {
await generateCompanyPdf(company, result, pdfPath)
pdfGenerated = true
} catch (pdfError) {
console.warn(`[Enrich] Erreur génération PDF entreprise:`, pdfError.message)
}
}
break // Traiter seulement la première entreprise trouvée
}
}
}
} else if (kind === 'address') {
// Géocodage réel via BAN + GéoFoncier
const cacheFile = path.join(cachePath, `${fileHash}.json`)
let addressData = { street: '', city: '', postalCode: '', country: 'France' }
if (fs.existsSync(cacheFile)) {
const cacheData = JSON.parse(fs.readFileSync(cacheFile, 'utf8'))
const addresses = cacheData.entities?.addresses || []
if (addresses.length > 0) {
addressData = {
street: addresses[0].street || '',
city: addresses[0].city || '',
postalCode: addresses[0].postalCode || '',
country: addresses[0].country || 'France',
}
}
}
// Collecte BAN + GéoRisque + Cadastre
const addressResult = await collectAddressData(addressData)
// Collecte GéoFoncier en parallèle
const geofoncierResult = await searchGeofoncierInfo(addressData)
// Fusion des résultats
result = {
success: addressResult.success || geofoncierResult.success,
address: addressData,
ban: addressResult,
geofoncier: geofoncierResult,
timestamp: new Date().toISOString()
}
// Génération du PDF avec données géocodées
try {
await generateAddressPdf(addressData, result, pdfPath)
pdfGenerated = true
} catch (pdfError) {
console.warn(`[Enrich] Erreur génération PDF adresse:`, pdfError.message)
}
}
// Mise à jour du statut final
const done = {
kind,
state: result?.success ? 'done' : 'error',
startedAt: status.startedAt,
finishedAt: new Date().toISOString(),
message: result?.success ? 'Collecte terminée' : 'Erreur de collecte',
sources: result?.sources ? Object.keys(result.sources) :
(kind === 'person') ? ['bodacc_gel_avoirs'] :
(kind === 'company') ? ['kbis_inforgreffe', 'societe_com'] :
['cadastre', 'georisque', 'geofoncier'],
data: result,
pdfGenerated
}
fs.writeFileSync(statusPath, JSON.stringify(done, null, 2))
console.log(`[Enrich] Terminé pour ${kind}:`, done.state)
} catch (error) {
console.error(`[Enrich] Erreur enrichissement ${kind}:`, error.message)
const errorStatus = {
kind,
state: 'error',
startedAt: status.startedAt,
finishedAt: new Date().toISOString(),
message: `Erreur: ${error.message}`,
sources: [],
error: error.message
}
fs.writeFileSync(statusPath, JSON.stringify(errorStatus, null, 2))
}
}, 2000) // Délai de 2 secondes pour laisser le temps aux collecteurs
} 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