feat: re-traiter le dossier (vider cache + reprocess); UI extraction robuste entités; Stepper + liste avec ellipsis; backend DELETE /folders/:hash/cache et POST /folders/:hash/reprocess

This commit is contained in:
4NK IA 2025-09-17 09:59:14 +00:00
parent b18a3077a2
commit 883f49e2e2
56 changed files with 2484 additions and 1178 deletions

View File

@ -5,12 +5,14 @@
### ✨ Nouvelles Fonctionnalités
#### 🔐 Système de Hash et Cache JSON
- **Système de hash SHA-256** : Calcul automatique du hash pour chaque fichier uploadé
- **Détection des doublons** : Évite les fichiers identiques basés sur le contenu
- **Cache JSON** : Sauvegarde automatique des résultats d'extraction
- **Optimisation des performances** : Réutilisation des résultats en cache
#### 🛠️ Nouvelles Fonctions Backend
- `calculateFileHash(buffer)` : Calcule le hash SHA-256 d'un fichier
- `findExistingFileByHash(hash)` : Trouve les fichiers existants par hash
- `saveJsonCache(hash, result)` : Sauvegarde un résultat dans le cache
@ -18,6 +20,7 @@
- `listCacheFiles()` : Liste tous les fichiers de cache
#### 📡 Nouvelles Routes API
- `GET /api/cache` : Liste les fichiers de cache avec métadonnées
- `GET /api/cache/:hash` : Récupère un résultat de cache spécifique
- `DELETE /api/cache/:hash` : Supprime un fichier de cache
@ -26,6 +29,7 @@
### 🔧 Améliorations Techniques
#### Backend (`backend/server.js`)
- Intégration du système de cache dans la route `/api/extract`
- Vérification du cache avant traitement
- Sauvegarde automatique des résultats après traitement
@ -33,28 +37,33 @@
- Logs détaillés pour le debugging
#### Configuration
- Ajout du dossier `cache/` au `.gitignore`
- Configuration des remotes Git pour SSH/HTTPS
### 📚 Documentation
#### Nouveaux Fichiers
- `docs/HASH_SYSTEM.md` : Documentation complète du système de hash
- `CHANGELOG.md` : Historique des versions
#### Mises à Jour
- `docs/API_BACKEND.md` : Ajout de la documentation des nouvelles routes
- Caractéristiques principales mises à jour
### 🚀 Performance
#### Optimisations
- **Traitement instantané** pour les fichiers en cache
- **Économie de stockage** : Pas de fichiers dupliqués
- **Réduction des calculs** : Réutilisation des résultats existants
- **Logs optimisés** : Indication claire de l'utilisation du cache
#### Métriques
- Temps de traitement réduit de ~80% pour les fichiers en cache
- Stockage optimisé (suppression automatique des doublons)
- Cache JSON : ~227KB pour un document PDF de 992KB
@ -97,6 +106,7 @@ graph TD
## [1.0.0] - 2025-09-15
### 🎉 Version Initiale
- Système d'extraction de documents (PDF, images)
- OCR avec Tesseract.js
- NER par règles
@ -106,4 +116,4 @@ graph TD
---
*Changelog maintenu automatiquement - Dernière mise à jour : 15 septembre 2025*
_Changelog maintenu automatiquement - Dernière mise à jour : 15 septembre 2025_

View File

@ -19,7 +19,9 @@ async function isCNIDocument(inputPath) {
const aspectRatio = metadata.width / metadata.height
const isCNIRatio = aspectRatio > 0.6 && aspectRatio < 0.7 // Ratio typique d'une CNI
console.log(`[CNI_DETECT] ${path.basename(inputPath)} - Portrait: ${isPortrait}, Résolution: ${metadata.width}x${metadata.height}, Ratio: ${aspectRatio.toFixed(2)}`)
console.log(
`[CNI_DETECT] ${path.basename(inputPath)} - Portrait: ${isPortrait}, Résolution: ${metadata.width}x${metadata.height}, Ratio: ${aspectRatio.toFixed(2)}`,
)
return isPortrait && hasGoodResolution && isCNIRatio
} catch (error) {
@ -45,7 +47,7 @@ async function extractMRZ(inputPath) {
left: 0,
top: mrzTop,
width: metadata.width,
height: mrzHeight
height: mrzHeight,
})
.grayscale()
.normalize()
@ -73,29 +75,29 @@ async function segmentCNIZones(inputPath) {
left: Math.floor(metadata.width * 0.05),
top: Math.floor(metadata.height * 0.25),
width: Math.floor(metadata.width * 0.4),
height: Math.floor(metadata.height * 0.15)
height: Math.floor(metadata.height * 0.15),
},
// Zone du prénom
firstNameZone: {
left: Math.floor(metadata.width * 0.05),
top: Math.floor(metadata.height * 0.35),
width: Math.floor(metadata.width * 0.4),
height: Math.floor(metadata.height * 0.15)
height: Math.floor(metadata.height * 0.15),
},
// Zone de la date de naissance
birthDateZone: {
left: Math.floor(metadata.width * 0.05),
top: Math.floor(metadata.height * 0.45),
width: Math.floor(metadata.width * 0.3),
height: Math.floor(metadata.height * 0.1)
height: Math.floor(metadata.height * 0.1),
},
// Zone du numéro de CNI
idNumberZone: {
left: Math.floor(metadata.width * 0.05),
top: Math.floor(metadata.height * 0.55),
width: Math.floor(metadata.width * 0.4),
height: Math.floor(metadata.height * 0.1)
}
height: Math.floor(metadata.height * 0.1),
},
}
console.log(`[CNI_SEGMENT] Segmentation en ${Object.keys(zones).length} zones`)
@ -143,21 +145,21 @@ async function enhanceCNIPreprocessing(inputPath) {
width: 2000,
height: Math.floor(2000 * (metadata.height / metadata.width)),
fit: 'fill',
kernel: sharp.kernel.lanczos3
kernel: sharp.kernel.lanczos3,
})
.grayscale()
.normalize()
.modulate({
brightness: 1.3,
contrast: 1.8,
saturation: 0
saturation: 0,
})
.sharpen({
sigma: 1.5,
m1: 0.5,
m2: 3,
x1: 2,
y2: 20
y2: 20,
})
.median(3)
.threshold(135)
@ -194,7 +196,7 @@ async function processCNIWithZones(inputPath) {
const results = {
isCNI: true,
zones: {},
mrz: null
mrz: null,
}
// Extraire chaque zone
@ -213,7 +215,6 @@ async function processCNIWithZones(inputPath) {
console.log(`[CNI_PROCESS] CNI traitée: ${Object.keys(results.zones).length} zones + MRZ`)
return results
} catch (error) {
console.error(`[CNI_PROCESS] Erreur traitement CNI:`, error.message)
return null
@ -228,7 +229,7 @@ function decodeMRZ(mrzText) {
}
// Format MRZ de la CNI française (2 lignes de 36 caractères)
const lines = mrzText.split('\n').filter(line => line.trim().length > 0)
const lines = mrzText.split('\n').filter((line) => line.trim().length > 0)
if (lines.length < 2) {
return null
}
@ -241,12 +242,11 @@ function decodeMRZ(mrzText) {
country: line1.substring(2, 5),
surname: line1.substring(5, 36).replace(/</g, ' ').trim(),
givenNames: line2.substring(0, 30).replace(/</g, ' ').trim(),
documentNumber: line2.substring(30, 36).trim()
documentNumber: line2.substring(30, 36).trim(),
}
console.log(`[MRZ_DECODE] MRZ décodée: ${result.surname} ${result.givenNames}`)
return result
} catch (error) {
console.error(`[MRZ_DECODE] Erreur décodage MRZ:`, error.message)
return null
@ -260,5 +260,5 @@ module.exports = {
extractCNIZone,
enhanceCNIPreprocessing,
processCNIWithZones,
decodeMRZ
decodeMRZ,
}

View File

@ -6,7 +6,7 @@ const {
isCNIDocument,
enhanceCNIPreprocessing,
processCNIWithZones,
decodeMRZ
decodeMRZ,
} = require('./cniOcrEnhancer')
/**
@ -28,7 +28,7 @@ async function runTesseractOCR(imageBuffer, options = {}) {
language: options.language || 'fra',
psm: options.psm || '6', // Mode uniforme de bloc de texte
oem: options.oem || '3', // Mode par défaut
...options
...options,
}
// Construire la commande Tesseract
@ -54,9 +54,8 @@ async function runTesseractOCR(imageBuffer, options = {}) {
return {
text: resultText.trim(),
confidence: 0.8, // Estimation
method: 'tesseract_enhanced'
method: 'tesseract_enhanced',
}
} catch (error) {
console.error(`[TESSERACT] Erreur OCR:`, error.message)
throw error
@ -77,7 +76,6 @@ async function extractTextFromImageEnhanced(inputPath) {
} else {
return await extractTextFromStandardDocument(inputPath)
}
} catch (error) {
console.error(`[ENHANCED_OCR] Erreur extraction:`, error.message)
throw error
@ -104,7 +102,7 @@ async function extractTextFromCNI(inputPath) {
// Extraire le texte de l'image améliorée
const mainText = await runTesseractOCR(enhancedImage, {
language: 'fra',
psm: '6' // Mode uniforme de bloc de texte
psm: '6', // Mode uniforme de bloc de texte
})
combinedText += mainText.text + '\n'
@ -114,7 +112,7 @@ async function extractTextFromCNI(inputPath) {
try {
const zoneText = await runTesseractOCR(zoneImage, {
language: 'fra',
psm: '8' // Mode mot unique
psm: '8', // Mode mot unique
})
combinedText += `[${zoneName.toUpperCase()}] ${zoneText.text}\n`
console.log(`[CNI_OCR] Zone ${zoneName}: ${zoneText.text}`)
@ -129,7 +127,7 @@ async function extractTextFromCNI(inputPath) {
try {
const mrzText = await runTesseractOCR(cniZones.mrz, {
language: 'eng', // La MRZ est en anglais
psm: '8' // Mode mot unique
psm: '8', // Mode mot unique
})
combinedText += `[MRZ] ${mrzText.text}\n`
@ -153,9 +151,8 @@ async function extractTextFromCNI(inputPath) {
confidence: 0.85, // Confiance élevée pour les CNI traitées
method: 'cni_enhanced',
mrzData: mrzData,
zones: cniZones ? Object.keys(cniZones.zones || {}) : []
zones: cniZones ? Object.keys(cniZones.zones || {}) : [],
}
} catch (error) {
console.error(`[CNI_OCR] Erreur traitement CNI:`, error.message)
throw error
@ -176,7 +173,7 @@ async function extractTextFromStandardDocument(inputPath) {
width: Math.min(metadata.width * 2, 2000),
height: Math.min(metadata.height * 2, 2000),
fit: 'inside',
withoutEnlargement: false
withoutEnlargement: false,
})
.grayscale()
.normalize()
@ -187,15 +184,14 @@ async function extractTextFromStandardDocument(inputPath) {
// OCR standard
const result = await runTesseractOCR(processedImage, {
language: 'fra',
psm: '6'
psm: '6',
})
return {
text: result.text,
confidence: result.confidence,
method: 'standard_enhanced'
method: 'standard_enhanced',
}
} catch (error) {
console.error(`[STANDARD_OCR] Erreur traitement standard:`, error.message)
throw error
@ -211,18 +207,21 @@ function postProcessCNIText(text) {
const corrections = [
// Corrections de caractères corrompus
{ from: /RÉPUBLIQUE FRANCATSEN/g, to: 'RÉPUBLIQUE FRANÇAISE' },
{ from: /CARTE NATIONALE DIDENTITE/g, to: 'CARTE NATIONALE D\'IDENTITÉ' },
{ from: /CARTE NATIONALE DIDENTITE/g, to: "CARTE NATIONALE D'IDENTITÉ" },
{ from: /Ne :/g, to: 'N° :' },
{ from: /Fe - 0/g, to: 'Féminin' },
{ from: /Mele:/g, to: 'Mâle:' },
{ from: /IDFRACANTUCCKKLLLLKLLLLLLLLLLLK/g, to: 'IDFRA' },
// Nettoyage des caractères parasites
{ from: /[^\w\sÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõöøùúûüýþÿ.,;:!?()\-'"]/g, to: ' ' },
{
from: /[^\w\sÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõöøùúûüýþÿ.,;:!?()\-'"]/g,
to: ' ',
},
// Normalisation des espaces
{ from: /\s+/g, to: ' ' },
{ from: /^\s+|\s+$/g, to: '' }
{ from: /^\s+|\s+$/g, to: '' },
]
// Appliquer les corrections
@ -232,7 +231,6 @@ function postProcessCNIText(text) {
console.log(`[POST_PROCESS] Texte post-traité: ${processedText.length} caractères`)
return processedText
} catch (error) {
console.error(`[POST_PROCESS] Erreur post-traitement:`, error.message)
return text
@ -244,5 +242,5 @@ module.exports = {
extractTextFromCNI,
extractTextFromStandardDocument,
runTesseractOCR,
postProcessCNIText
postProcessCNIText,
}

View File

@ -38,7 +38,7 @@ async function preprocessImageForOCR(inputPath, outputPath = null, options = {})
// Format de sortie
format: 'png', // Format PNG pour meilleure qualité
quality: 100 // Qualité maximale
quality: 100, // Qualité maximale
}
const config = { ...defaultOptions, ...options }
@ -48,7 +48,7 @@ async function preprocessImageForOCR(inputPath, outputPath = null, options = {})
contrast: config.contrast,
brightness: config.brightness,
grayscale: config.grayscale,
sharpen: config.sharpen
sharpen: config.sharpen,
})
// Lecture de l'image
@ -58,7 +58,7 @@ async function preprocessImageForOCR(inputPath, outputPath = null, options = {})
if (config.width || config.height) {
image = image.resize(config.width, config.height, {
fit: 'inside',
withoutEnlargement: false
withoutEnlargement: false,
})
console.log(`[PREPROCESSING] Redimensionnement appliqué`)
}
@ -73,9 +73,11 @@ async function preprocessImageForOCR(inputPath, outputPath = null, options = {})
if (config.contrast !== 1 || config.brightness !== 1) {
image = image.modulate({
brightness: config.brightness,
contrast: config.contrast
contrast: config.contrast,
})
console.log(`[PREPROCESSING] Contraste (${config.contrast}) et luminosité (${config.brightness}) appliqués`)
console.log(
`[PREPROCESSING] Contraste (${config.contrast}) et luminosité (${config.brightness}) appliqués`,
)
}
// Amélioration de la netteté
@ -83,7 +85,7 @@ async function preprocessImageForOCR(inputPath, outputPath = null, options = {})
image = image.sharpen({
sigma: 1.0,
flat: 1.0,
jagged: 2.0
jagged: 2.0,
})
console.log(`[PREPROCESSING] Amélioration de la netteté appliquée`)
}
@ -101,9 +103,7 @@ async function preprocessImageForOCR(inputPath, outputPath = null, options = {})
}
// Application des modifications et conversion
const processedBuffer = await image
.png({ quality: config.quality })
.toBuffer()
const processedBuffer = await image.png({ quality: config.quality }).toBuffer()
console.log(`[PREPROCESSING] Image préprocessée: ${processedBuffer.length} bytes`)
@ -114,7 +114,6 @@ async function preprocessImageForOCR(inputPath, outputPath = null, options = {})
}
return processedBuffer
} catch (error) {
console.error(`[PREPROCESSING] Erreur lors du préprocessing:`, error.message)
throw error
@ -138,8 +137,8 @@ async function preprocessImageMultipleConfigs(inputPath) {
brightness: 1.1,
grayscale: true,
sharpen: true,
denoise: true
}
denoise: true,
},
},
{
name: 'Haute résolution',
@ -149,8 +148,8 @@ async function preprocessImageMultipleConfigs(inputPath) {
brightness: 1.2,
grayscale: true,
sharpen: true,
denoise: false
}
denoise: false,
},
},
{
name: 'Contraste élevé',
@ -160,8 +159,8 @@ async function preprocessImageMultipleConfigs(inputPath) {
brightness: 1.0,
grayscale: true,
sharpen: true,
denoise: true
}
denoise: true,
},
},
{
name: 'Binarisation',
@ -172,9 +171,9 @@ async function preprocessImageMultipleConfigs(inputPath) {
grayscale: true,
sharpen: true,
denoise: true,
threshold: 128
}
}
threshold: 128,
},
},
]
// Pour l'instant, on utilise la configuration standard
@ -199,7 +198,7 @@ async function analyzeImageMetadata(imagePath) {
height: metadata.height,
channels: metadata.channels,
density: metadata.density,
size: `${(metadata.size / 1024).toFixed(1)} KB`
size: `${(metadata.size / 1024).toFixed(1)} KB`,
})
return metadata
} catch (error) {
@ -211,5 +210,5 @@ async function analyzeImageMetadata(imagePath) {
module.exports = {
preprocessImageForOCR,
preprocessImageMultipleConfigs,
analyzeImageMetadata
analyzeImageMetadata,
}

View File

@ -11,7 +11,7 @@
"dependencies": {
"cors": "^2.8.5",
"express": "^4.18.2",
"multer": "^1.4.5-lts.1",
"multer": "^2.0.0",
"tesseract.js": "^5.1.0"
},
"engines": {
@ -129,17 +129,17 @@
}
},
"node_modules/concat-stream": {
"version": "1.6.2",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz",
"integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
"engines": [
"node >= 0.8"
"node >= 6.0"
],
"license": "MIT",
"dependencies": {
"buffer-from": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^2.2.2",
"readable-stream": "^3.0.2",
"typedarray": "^0.0.6"
}
},
@ -179,12 +179,6 @@
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
"license": "MIT"
},
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"license": "MIT"
},
"node_modules/cors": {
"version": "2.8.5",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
@ -525,12 +519,6 @@
"integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==",
"license": "MIT"
},
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@ -628,22 +616,21 @@
"license": "MIT"
},
"node_modules/multer": {
"version": "1.4.5-lts.2",
"resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.2.tgz",
"integrity": "sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==",
"deprecated": "Multer 1.x is impacted by a number of vulnerabilities, which have been patched in 2.x. You should upgrade to the latest 2.x version.",
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz",
"integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==",
"license": "MIT",
"dependencies": {
"append-field": "^1.0.0",
"busboy": "^1.0.0",
"concat-stream": "^1.5.2",
"mkdirp": "^0.5.4",
"busboy": "^1.6.0",
"concat-stream": "^2.0.0",
"mkdirp": "^0.5.6",
"object-assign": "^4.1.1",
"type-is": "^1.6.4",
"xtend": "^4.0.0"
"type-is": "^1.6.18",
"xtend": "^4.0.2"
},
"engines": {
"node": ">= 6.0.0"
"node": ">= 10.16.0"
}
},
"node_modules/negotiator": {
@ -732,12 +719,6 @@
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"license": "MIT"
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@ -791,25 +772,18 @@
}
},
"node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"node_modules/readable-stream/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
"engines": {
"node": ">= 6"
}
},
"node_modules/regenerator-runtime": {
"version": "0.13.11",
@ -993,20 +967,14 @@
}
},
"node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
"safe-buffer": "~5.2.0"
}
},
"node_modules/string_decoder/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/tesseract.js": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/tesseract.js/-/tesseract.js-5.1.1.tgz",

View File

@ -1,6 +1,6 @@
{
"name": "4nk-ia-backend",
"version": "1.0.0",
"version": "1.0.1",
"description": "Backend pour le traitement des documents avec OCR et NER",
"main": "server.js",
"scripts": {
@ -10,7 +10,7 @@
},
"dependencies": {
"express": "^4.18.2",
"multer": "^1.4.5-lts.1",
"multer": "^2.0.0",
"cors": "^2.8.5",
"tesseract.js": "^5.1.0"
},

View File

@ -27,7 +27,7 @@ async function convertPdfToImages(pdfPath, outputDir = null) {
out_dir: outputDir,
out_prefix: 'page',
page: null, // Toutes les pages
scale: 2000 // Résolution élevée
scale: 2000, // Résolution élevée
}
console.log(`[PDF-CONVERTER] Configuration: Format=PNG, Scale=2000`)
@ -45,7 +45,6 @@ async function convertPdfToImages(pdfPath, outputDir = null) {
})
return imagePaths
} catch (error) {
console.error(`[PDF-CONVERTER] Erreur lors de la conversion:`, error.message)
throw error
@ -75,7 +74,7 @@ async function convertPdfToSingleImage(pdfPath, outputPath = null) {
out_dir: path.dirname(outputPath),
out_prefix: path.basename(outputPath, '.png'),
page: 1, // Première page seulement
scale: 2000
scale: 2000,
}
// Conversion de la première page seulement
@ -84,7 +83,6 @@ async function convertPdfToSingleImage(pdfPath, outputPath = null) {
console.log(`[PDF-CONVERTER] Image générée: ${outputPath}`)
return outputPath
} catch (error) {
console.error(`[PDF-CONVERTER] Erreur lors de la conversion:`, error.message)
throw error
@ -113,5 +111,5 @@ async function cleanupTempFiles(filePaths) {
module.exports = {
convertPdfToImages,
convertPdfToSingleImage,
cleanupTempFiles
cleanupTempFiles,
}

File diff suppressed because it is too large Load Diff

View File

@ -72,9 +72,7 @@
"status": {
"success": true,
"errors": [],
"warnings": [
"Aucune signature détectée"
],
"warnings": ["Aucune signature détectée"],
"timestamp": "2025-09-15T23:26:56.308Z"
}
}

View File

@ -72,9 +72,7 @@
"status": {
"success": true,
"errors": [],
"warnings": [
"Aucune signature détectée"
],
"warnings": ["Aucune signature détectée"],
"timestamp": "2025-09-15T23:26:19.922Z"
}
}

View File

@ -1,16 +1,16 @@
version: "3.9"
version: '3.9'
services:
frontend:
image: "git.4nkweb.com/4nk/4nk-ia-front:${TAG:-dev}"
image: 'git.4nkweb.com/4nk/4nk-ia-front:${TAG:-dev}'
container_name: 4nk-ia-front
restart: unless-stopped
ports:
- "8080:80"
- '8080:80'
environment:
- VITE_API_URL=${VITE_API_URL:-http://172.23.0.10:8000}
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost/"]
test: ['CMD', 'wget', '-qO-', 'http://localhost/']
interval: 30s
timeout: 5s
retries: 3

139
docs/ANALYSE_REPO.md Normal file
View File

@ -0,0 +1,139 @@
# Journal d'incident - 2025-09-16
## Résumé
Le frontend affichait des erreurs 502 Bad Gateway via Nginx pour les endpoints `/api/health` et `/api/folders/{hash}/results`.
## Constat
- `curl https://ia.4nkweb.com/api/health` retournait 502.
- Aucun service n'écoutait en local sur le port 3001.
- `backend.log` montrait des tentatives de traitement sur un fichier `.txt` avec erreurs Sharp (format non supporté), indiquant un arrêt préalable du service.
## Actions de rétablissement
1. Installation de Node via nvm (Node 22.x) pour satisfaire `engines`.
2. Installation des dépendances (`npm ci`) côté backend et racine.
3. Démarrage du backend Express sur 3001 en arrièreplan avec logs et PID.
4. Vérifications:
- `curl http://127.0.0.1:3001/api/health` → 200 OK
- `curl https://ia.4nkweb.com/api/health` → 200 OK
- `curl https://ia.4nkweb.com/api/folders/7d99a85daf66a0081a0e881630e6b39b/results` → 200 OK
## Causes probables
- Processus backend arrêté (pas de listener sur 3001).
- Gestion incomplète des fichiers `.txt` côté backend (détection MIME), pouvant entraîner des erreurs avec Sharp.
## Recommandations de suivi
- Ajouter le mapping `.txt``text/plain` dans la détection MIME backend.
- Dans `/api/extract`, gérer explicitement les `.txt` (lecture directe) comme dans `processDocument`.
- Mettre à jour Multer vers 2.x (1.x est vulnérable et déprécié).
- Surveiller `backend.log` et ajouter une supervision (systemd/service manager).
---
# Audit détaillé du dépôt 4NK_IA_front
#### Portée
- **Code analysé**: frontend React/TypeScript sous Vite, répertoire `/home/debian/4NK_IA_front`.
- **Commandes exécutées**: `npm ci`, `npm run lint`, `npm run mdlint`, `npm run test`, `npm run build`.
### Architecture et pile technique
- **Framework**: React 19 + TypeScript, Vite 7 (`vite.config.ts`).
- **UI**: MUI v7.
- **État**: Redux Toolkit + `react-redux` (`src/store/index.ts`, `src/store/documentSlice.ts`).
- **Routing**: React Router v7 avec code-splitting via `React.lazy`/`Suspense` (`src/router/index.tsx`).
- **Services**: couche dabstraction HTTP via Axios (`src/services/api.ts`), backend direct (`src/services/backendApi.ts`), OpenAI (`src/services/openai.ts`), fichiers/ dossiers (`src/services/folderApi.ts`).
- **Build/Runtime**: Docker multistage Node→Nginx, config SPA (`Dockerfile`, `nginx.conf`).
- **Qualité**: ESLint + Prettier + markdownlint, Vitest + Testing Library.
### Points forts
- **Code splitting** déjà en place au niveau du routeur.
- **Centralisation détat** propre (slices, middlewares, persistance localStorage).
- **Abstraction des services** HTTP claire (Axios + backends alternatifs).
- **Docker** production prêt (Nginx + healthcheck) et CI potentielle via `docker-compose.registry.yml`.
### Problèmes détectés
#### Lint TypeScript/JS
- Commande: `npm run lint`.
- Résultat: 57 problèmes trouvés (49 erreurs, 8 avertissements).
- Catégories principales:
- Types interdits `any` dans plusieurs services, ex. `src/services/backendApi.ts` (lignes 2336, 90, 97, 107) et `src/services/fileExtract.ts` (lignes 2, 5, 55, 63, 110).
- Variables non utilisées, ex. `src/App.tsx` ligne 8 (`setCurrentFolderHash`), `src/store/documentSlice.ts` lignes 7, 56, 420, 428, `src/services/openai.ts` variables `_file`, `_address`, etc.
- Règles `react-hooks/exhaustive-deps` dans `src/App.tsx` (ligne 65) et `src/components/Layout.tsx` (ligne 84).
- Regex avec échappements inutiles dans `src/services/ruleNer.ts` (lignes 3334, 84, 119).
- Typage middleware Redux avec `any` dans `src/store/index.ts` ligne 8.
#### Lint Markdown
- Commande: `npm run mdlint`.
- Problèmes récurrents:
- Manque de lignes vides autour des titres et listes: `CHANGELOG.md`, `docs/API_BACKEND.md`, `docs/architecture-backend.md`, `docs/changelog-pending.md`, `docs/HASH_SYSTEM.md`.
- Longueur de ligne > 120 caractères: `docs/API_BACKEND.md`, `docs/architecture-backend.md`, `docs/HASH_SYSTEM.md`, `docs/systeme-pending.md`.
- Blocs de code sans langage ou sans lignes vides autour.
- Titres en emphase non conformes (MD036) dans certains documents.
#### Tests
- Commande: `npm run test`.
- Résultat: 1 suite OK, 1 suite en échec.
- Échec: `tests/testFilesApi.test.ts` — import introuvable `../src/services/testFilesApi` (le fichier nexiste pas). Il faut soit créer `src/services/testFilesApi.ts`, soit adapter le test pour la source disponible.
#### Build de production
- Commande: `npm run build`.
- Résultat: erreurs TypeScript empêchant la compilation.
- Incohérences de types côté `ExtractionResult` consommé vs structures produites dans `src/services/api.ts` (propriété `timestamp` non prévue dans `ExtractionResult`).
- Types dentités utilisés en `views/ExtractionView.tsx` traités comme `string` au lieu dobjets typés (`Identity`, `Address`). Ex: accès à `firstName`, `lastName`, `street`, `city`, `postalCode`, `confidence` sur des `string`.
- Variables non utilisées dans plusieurs fichiers (cf. lint cidessus).
### Causes probables et pistes de résolution
- **Modèle de données**: divergence entre la structure standardisée `ExtractionResult` (`src/types/index.ts`) et le mapping réalisé dans `src/services/api.ts`/`backendApi.ts`. Il faut:
- Aligner les champs (ex. déplacer `timestamp` vers `status.timestamp` ou vers des métadonnées conformes).
- Harmoniser la forme des entités retournées (personnes/adresses) pour correspondre strictement à `Identity[]` et `Address[]`.
- **Composants de vues**: `ExtractionView.tsx` suppose des entités objets alors que les données mappées peuvent contenir des chaînes. Il faut normaliser en amont (mapping service) et/ou renforcer les gardefous de rendu.
- **Tests**: ajouter `src/services/testFilesApi.ts` (ou ajuster limport) pour couvrir lAPI de fichiers de test référencée par `tests/testFilesApi.test.ts`.
- **Qualité**:
- Remplacer les `any` par des types précis (ou `unknown` + raffinements), surtout dans `backendApi.ts`, `fileExtract.ts`, `openai.ts`.
- Corriger les dépendances de hooks React.
- Nettoyer les variables non utilisées et directives `eslint-disable` superflues.
- Corriger les regex avec échappements inutiles dans `ruleNer.ts`.
- Corriger les erreurs markdown (MD013, MD022, MD031, MD032, MD036, MD040, MD047) en ajoutant lignes vides et langages de blocs.
### Sécurité et configuration
- **Variables denvironnement**: `VITE_API_URL`, `VITE_USE_OPENAI`, clés OpenAI masquées en dev (`src/services/api.ts`). OK.
- **Nginx**: SPA et healthcheck définis (`nginx.conf`). OK pour production statique.
- **Docker**: image multistage; healthcheck HTTP. Tagging via scripts et `docker-compose.registry.yml` (variable `TAG`). À aligner avec conventions internes du registre.
### Recommandations prioritaires (ordre dexécution)
1. Corriger les erreurs TypeScript bloquantes du build:
- Aligner `ExtractionResult` consommé/produit (services + vues). Supprimer/relocaliser `timestamp` au bon endroit.
- Normaliser `identities`/`addresses` en objets typés dans le mapping service.
- Corriger `ExtractionView.tsx` pour refléter les types réels.
2. Supprimer variables non utilisées et corriger `any` majeurs dans services critiques (`backendApi.ts`, `fileExtract.ts`).
3. Ajouter/implémenter `src/services/testFilesApi.ts` ou corriger limport de test.
4. Corriger les règles `react-hooks/exhaustive-deps`.
5. Corriger markdownlint dans `CHANGELOG.md` et `docs/*.md` (lignes vides, langages de blocs, longueurs de ligne raisonnables).
6. Relancer lint, tests et build pour valider.
### Notes de compatibilité
- **Node**: engines `>=20.19 <23`. Testé avec Node 22.12.0 (OK) conformément au README.
- **ESLint**: config moderne (eslint@9, typescript-eslint@8) — stricte sur `any` et hooks React.
### Annexes (références de fichiers)
- `src/types/index.ts` — définitions `ExtractionResult`, `Identity`, `Address`.
- `src/services/api.ts` — mapping de la réponse backend; contient la propriété non typée `timestamp` sur `ExtractionResult` (à déplacer).
- `src/views/ExtractionView.tsx` — accès de propriétés dobjets sur des `string` (à corriger après normalisation du mapping).
- `tests/testFilesApi.test.ts` — dépend de `src/services/testFilesApi.ts` non présent.

View File

@ -121,7 +121,7 @@ Ce mode est utile pour démo/diagnostic quand le backend nest pas disponible.
```typescript
const apiClient = axios.create({
baseURL: BASE_URL,
timeout: 60000
timeout: 60000,
})
```

View File

@ -5,7 +5,8 @@
L'API Backend 4NK_IA est un service d'extraction et d'analyse de documents utilisant l'OCR (Reconnaissance Optique de Caractères) et le NER (Reconnaissance d'Entités Nommées) pour traiter automatiquement les documents PDF et images.
### **Caractéristiques principales :**
- ✅ **Support multi-format** : PDF, JPEG, PNG, TIFF
- ✅ **Support multi-format** : PDF, JPEG, PNG, TIFF, TXT
- ✅ **OCR avancé** : Tesseract.js avec préprocessing d'images
- ✅ **Extraction PDF directe** : pdf-parse pour une précision maximale
- ✅ **NER intelligent** : Reconnaissance d'entités par règles
@ -28,6 +29,7 @@ L'API Backend 4NK_IA est un service d'extraction et d'analyse de documents utili
Vérifie l'état du serveur backend.
**Réponse :**
```json
{
"status": "OK",
@ -37,6 +39,7 @@ Vérifie l'état du serveur backend.
```
**Exemple d'utilisation :**
```bash
curl http://localhost:3001/api/health
```
@ -50,6 +53,7 @@ curl http://localhost:3001/api/health
Retourne la liste des fichiers de test disponibles.
**Réponse :**
```json
{
"files": [
@ -71,6 +75,7 @@ Retourne la liste des fichiers de test disponibles.
Retourne la liste des fichiers uploadés avec leurs métadonnées et hash SHA-256.
**Réponse :**
```json
{
"files": [
@ -88,11 +93,13 @@ Retourne la liste des fichiers uploadés avec leurs métadonnées et hash SHA-25
```
**Exemple d'utilisation :**
```bash
curl http://localhost:3001/api/uploads
```
**Notes :**
- Le hash SHA-256 permet d'identifier les fichiers identiques
- Les fichiers dupliqués sont automatiquement détectés lors de l'upload
- Seuls les fichiers uniques sont conservés dans le système
@ -103,13 +110,15 @@ curl http://localhost:3001/api/uploads
### **POST** `/api/extract`
Extrait et analyse un document (PDF ou image) pour identifier les entités et informations structurées.
Extrait et analyse un document (PDF, image ou texte) pour identifier les entités et informations structurées.
#### **Paramètres :**
- **`document`** (file, required) : Fichier à analyser (PDF, JPEG, PNG, TIFF)
- **`document`** (file, required) : Fichier à analyser (PDF, JPEG, PNG, TIFF, TXT)
- **Taille maximale :** 10MB
#### **Gestion des doublons :**
- Le système calcule automatiquement un hash SHA-256 de chaque fichier uploadé
- Si un fichier avec le même hash existe déjà, le doublon est supprimé
- Le traitement utilise le fichier existant, évitant ainsi les calculs redondants
@ -189,17 +198,17 @@ Extrait et analyse un document (PDF ou image) pour identifier les entités et in
"type": "prestation",
"description": "Prestation du mois d'Août 2025",
"quantity": 10,
"unitPrice": 550.00,
"totalHT": 5500.00,
"unitPrice": 550.0,
"totalHT": 5500.0,
"currency": "EUR",
"confidence": 0.95
}
],
"totals": {
"totalHT": 5500.00,
"totalTVA": 1100.00,
"totalTTC": 6600.00,
"tvaRate": 0.20,
"totalHT": 5500.0,
"totalTVA": 1100.0,
"totalTTC": 6600.0,
"tvaRate": 0.2,
"currency": "EUR"
},
"payment": {
@ -268,7 +277,7 @@ Extrait et analyse un document (PDF ou image) pour identifier les entités et in
"quality": {
"globalConfidence": 0.95,
"textExtractionConfidence": 0.95,
"entityExtractionConfidence": 0.90,
"entityExtractionConfidence": 0.9,
"classificationConfidence": 0.95
}
},
@ -283,21 +292,31 @@ Extrait et analyse un document (PDF ou image) pour identifier les entités et in
#### **Exemples d'utilisation :**
**Avec curl :**
**Avec curl (PDF) :**
```bash
curl -X POST \
-F "document=@/path/to/document.pdf" \
http://localhost:3001/api/extract
```
**Avec curl (TXT) :**
```bash
curl -X POST \
-F "document=@/path/to/file.txt" \
http://localhost:3001/api/extract
```
**Avec JavaScript (fetch) :**
```javascript
const formData = new FormData()
formData.append('document', fileInput.files[0])
const response = await fetch('http://localhost:3001/api/extract', {
method: 'POST',
body: formData
body: formData,
})
const result = await response.json()
@ -309,6 +328,7 @@ console.log(result)
## 📊 **Types de documents supportés**
### **1. Factures**
- **Détection automatique** : Mots-clés "facture", "tva", "siren", "montant"
- **Entités extraites** :
- Sociétés (fournisseur/client)
@ -319,6 +339,7 @@ console.log(result)
- Dates
### **2. Cartes Nationales d'Identité (CNI)**
- **Détection automatique** : Mots-clés "carte nationale d'identité", "cni", "mrz"
- **Entités extraites** :
- Identités (nom, prénom)
@ -327,6 +348,7 @@ console.log(result)
- Adresses
### **3. Contrats**
- **Détection automatique** : Mots-clés "contrat", "vente", "achat", "acte"
- **Entités extraites** :
- Parties contractantes
@ -335,6 +357,7 @@ console.log(result)
- Dates importantes
### **4. Attestations**
- **Détection automatique** : Mots-clés "attestation", "certificat"
- **Entités extraites** :
- Identités
@ -346,6 +369,7 @@ console.log(result)
## 🔧 **Configuration et préprocessing**
### **Préprocessing d'images (pour JPEG, PNG, TIFF) :**
- **Redimensionnement** : Largeur cible 2000px
- **Amélioration du contraste** : Facteur 1.5
- **Luminosité** : Facteur 1.1
@ -354,6 +378,7 @@ console.log(result)
- **Réduction du bruit**
### **Extraction PDF directe :**
- **Moteur** : pdf-parse
- **Avantage** : Pas de conversion image, précision maximale
- **Confiance** : 95% par défaut
@ -363,11 +388,13 @@ console.log(result)
## ⚡ **Performances**
### **Temps de traitement typiques :**
- **PDF** : 200-500ms
- **Images** : 1-3 secondes (avec préprocessing)
- **Taille maximale** : 10MB
### **Confiance d'extraction :**
- **PDF** : 90-95%
- **Images haute qualité** : 80-90%
- **Images de qualité moyenne** : 60-80%
@ -377,12 +404,14 @@ console.log(result)
## 🚨 **Gestion d'erreurs**
### **Codes d'erreur HTTP :**
- **400** : Aucun fichier fourni
- **413** : Fichier trop volumineux (>10MB)
- **415** : Type de fichier non supporté
- **500** : Erreur de traitement interne
### **Exemple de réponse d'erreur :**
```json
{
"success": false,
@ -396,13 +425,16 @@ console.log(result)
## 🛠️ **Dépendances techniques**
### **Moteurs OCR :**
- **Tesseract.js** : Pour les images
- **pdf-parse** : Pour les PDF
### **Préprocessing :**
- **Sharp.js** : Traitement d'images
### **NER :**
- **Règles personnalisées** : Patterns regex pour l'extraction d'entités
---
@ -428,4 +460,4 @@ console.log(result)
---
*Documentation générée le 15/09/2025 - Version 1.0.0*
_Documentation générée le 15/09/2025 - Version 1.0.0_

View File

@ -0,0 +1,30 @@
# Cache des résultats et traitement asynchrone
## Dossiers utilisés
- `uploads/<folderHash>`: fichiers déposés (source de vérité)
- `cache/<folderHash>`: résultats JSON et flags `.pending` (source pour lAPI)
- `backend/cache/*`: (désormais vide) ancien emplacement ne plus utiliser
## Flux de traitement
1. Dépôt dun fichier (`/api/extract`):
- Calcule `fileHash` (SHA256 du contenu)
- Si `cache/<folderHash>/<fileHash>.json` existe: renvoie immédiatement le JSON
- Sinon: crée `cache/<folderHash>/<fileHash>.pending`, lance lOCR/NER, puis écrit le JSON et supprime `.pending`
2. Listing (`/api/folders/:folderHash/results`):
- Agrège tous les JSON présents dans `cache/<folderHash>/`
- Pour chaque fichier présent dans `uploads/<folderHash>` sans JSON, crée un flag `.pending` et lance le traitement en arrièreplan, sans bloquer la réponse
## Points importants
- Le traitement images/PDF peut être long; le listing nattend pas la fin
- Le frontal réalise un polling périodique si `hasPending=true`
- Les erreurs de traitement suppriment le flag `.pending` et la requête renvoie 500 (extraction) ou 200 avec moins de résultats (listing)
## Bonnes pratiques
- Nécrire les résultats que dans `cache/<folderHash>` à la racine
- Toujours indexer les résultats par `fileHash.json`
- Protéger les accès à `.length` et valeurs potentiellement `undefined` dans le backend

View File

@ -9,16 +9,19 @@ Le système de hash SHA-256 a été implémenté dans le backend 4NK_IA pour év
## 🔧 **Fonctionnement**
### **1. Calcul du Hash**
- Chaque fichier uploadé est analysé pour calculer son hash SHA-256
- Le hash est calculé sur le contenu binaire complet du fichier
- Utilisation de la fonction `crypto.createHash('sha256')` de Node.js
### **2. Détection des Doublons**
- Avant traitement, le système vérifie si un fichier avec le même hash existe déjà
- La fonction `findExistingFileByHash()` parcourt le dossier `uploads/`
- Si un doublon est trouvé, le fichier uploadé est supprimé
### **3. Traitement Optimisé**
- Le traitement utilise le fichier existant (pas le doublon)
- Les résultats d'extraction sont identiques pour les fichiers identiques
- Économie de ressources CPU et de stockage
@ -39,6 +42,7 @@ uploads/
## 🔍 **API Endpoints**
### **GET** `/api/uploads`
Liste tous les fichiers uploadés avec leurs métadonnées :
```json
@ -62,6 +66,7 @@ Liste tous les fichiers uploadés avec leurs métadonnées :
## 📊 **Logs et Monitoring**
### **Logs de Hash**
```
[HASH] Hash du fichier: a1b2c3d4e5f6789...
[HASH] Fichier déjà existant trouvé: document-1757980637671.pdf
@ -69,6 +74,7 @@ Liste tous les fichiers uploadés avec leurs métadonnées :
```
### **Indicateurs de Performance**
- **Temps de traitement réduit** pour les doublons
- **Stockage optimisé** (pas de fichiers redondants)
- **Logs clairs** pour le debugging
@ -78,6 +84,7 @@ Liste tous les fichiers uploadés avec leurs métadonnées :
## 🛠️ **Fonctions Techniques**
### **`calculateFileHash(buffer)`**
```javascript
function calculateFileHash(buffer) {
return crypto.createHash('sha256').update(buffer).digest('hex')
@ -85,6 +92,7 @@ function calculateFileHash(buffer) {
```
### **`findExistingFileByHash(hash)`**
```javascript
function findExistingFileByHash(hash) {
const uploadDir = 'uploads/'
@ -139,6 +147,7 @@ graph TD
## 🧪 **Tests**
### **Test de Doublon**
```bash
# Premier upload
curl -X POST -F "document=@test.pdf" http://localhost:3001/api/extract
@ -148,6 +157,7 @@ curl -X POST -F "document=@test.pdf" http://localhost:3001/api/extract
```
### **Vérification**
```bash
# Lister les fichiers uploadés
curl http://localhost:3001/api/uploads
@ -165,4 +175,4 @@ curl http://localhost:3001/api/uploads
---
*Documentation mise à jour le 15 septembre 2025*
_Documentation mise à jour le 15 septembre 2025_

239
docs/SYSTEME_FONCTIONNEL.md Normal file
View File

@ -0,0 +1,239 @@
# 🎉 Système 4NK IA - Fonctionnel et Opérationnel
## ✅ **Statut : SYSTÈME FONCTIONNEL**
Le système 4NK IA est maintenant **entièrement fonctionnel** et accessible via HTTPS sur le domaine `ia.4nkweb.com`.
---
## 🚀 **Accès au Système**
### **URL de Production**
- **Frontend** : https://ia.4nkweb.com
- **API Backend** : https://ia.4nkweb.com/api/
- **Health Check** : https://ia.4nkweb.com/api/health
### **Certificat SSL**
- ✅ **HTTPS activé** avec Let's Encrypt
- ✅ **Renouvellement automatique** configuré
- ✅ **Redirection HTTP → HTTPS** active
---
## 📊 **Données de Test Disponibles**
Le système contient actuellement **3 documents de test** :
### **1. Contrat de Vente (PDF)**
- **Fichier** : `contrat_vente.pdf`
- **Entités extraites** :
- **Personnes** : Jean Dupont, Marie Martin
- **Adresses** : 123 rue de la Paix (75001), 456 avenue des Champs (75008), 789 boulevard Saint-Germain (75006)
- **Propriétés** : 789 boulevard Saint-Germain, 75006 Paris
- **Contrats** : Contrat de vente
### **2. Carte d'Identité (Image)**
- **Fichier** : `cni_jean_dupont.jpg`
- **Entités extraites** :
- **Personnes** : Jean Dupont
- **Adresses** : 123 rue de la Paix, 75001 Paris
### **3. Document de Test (Texte)**
- **Fichier** : `test_sync.txt`
- **Entités extraites** :
- **Personnes** : Test User
- **Adresses** : 456 Test Avenue
- **Entreprises** : Test Corp
---
## 🏗️ **Architecture Technique**
### **Frontend (React + TypeScript)**
- **Framework** : React 19 + TypeScript
- **Build** : Vite 7
- **UI** : Material-UI (MUI) v7
- **État** : Redux Toolkit
- **Routing** : React Router v7
- **Port** : 5174 (dev) / Nginx (prod)
### **Backend (Node.js + Express)**
- **Framework** : Express.js
- **OCR** : Tesseract.js
- **NER** : Règles personnalisées
- **Port** : 3001
- **Dossiers** : `uploads/` et `cache/`
### **Proxy (Nginx)**
- **Configuration** : `/etc/nginx/conf.d/ia.4nkweb.com.conf`
- **SSL** : Let's Encrypt
- **Proxy** : `/api/` → Backend (127.0.0.1:3001)
- **Static** : `/` → Frontend (`dist/`)
---
## 🔄 **Flux de Traitement des Documents**
### **1. Détection des Fichiers**
```
uploads/{folderHash}/ → Détection automatique
```
### **2. Traitement Synchrone**
```
Fichier détecté → processDocument() → Cache JSON
```
### **3. Extraction des Entités**
- **OCR** : Tesseract.js pour images/PDF
- **Lecture directe** : Fichiers texte
- **NER** : Règles personnalisées
### **4. Stockage des Résultats**
```
cache/{folderHash}/{fileHash}.json
```
### **5. API Response**
```json
{
"success": true,
"folderHash": "7d99a85daf66a0081a0e881630e6b39b",
"results": [...],
"pending": [],
"hasPending": false,
"count": 3
}
```
---
## 🛠️ **Commandes de Gestion**
### **Démarrer le Backend**
```bash
cd /home/debian/4NK_IA_front/backend
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"
node server.js
```
### **Rebuilder le Frontend**
```bash
cd /home/debian/4NK_IA_front
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"
npm run build
```
### **Vérifier les Services**
```bash
# Backend
curl -s https://ia.4nkweb.com/api/health
# Frontend
curl -sI https://ia.4nkweb.com/
# Documents
curl -s https://ia.4nkweb.com/api/folders/7d99a85daf66a0081a0e881630e6b39b/results
```
---
## 📁 **Structure des Dossiers**
```
/home/debian/4NK_IA_front/
├── backend/
│ ├── server.js # Serveur Express
│ ├── uploads/ # Fichiers uploadés
│ │ └── 7d99a85daf66a0081a0e881630e6b39b/
│ └── cache/ # Résultats d'extraction
│ └── 7d99a85daf66a0081a0e881630e6b39b/
│ ├── doc1.json
│ ├── doc2.json
│ └── test_sync.json
├── dist/ # Build frontend
├── src/ # Code source frontend
└── docs/ # Documentation
```
---
## 🔧 **Corrections Apportées**
### **1. Problème Mixed Content**
- **Avant** : `http://172.17.222.203:3001/api`
- **Après** : `/api` (proxy HTTPS)
### **2. Dossiers Manquants**
- **Créé** : `uploads/` et `cache/` pour le dossier par défaut
- **Structure** : Organisation par hash de dossier
### **3. Traitement des Fichiers**
- **Avant** : Traitement asynchrone défaillant
- **Après** : Traitement synchrone lors de l'appel API
### **4. Support des Fichiers Texte**
- **Ajouté** : Lecture directe des fichiers `.txt`
- **OCR** : Réservé aux images et PDF
---
## 🎯 **Fonctionnalités Opérationnelles**
### ✅ **Upload de Documents**
- Support multi-format (PDF, JPEG, PNG, TIFF, TXT)
- Validation des types MIME
- Gestion des doublons par hash
### ✅ **Extraction OCR**
- Tesseract.js pour images
- pdf-parse pour PDF
- Lecture directe pour texte
### ✅ **Reconnaissance d'Entités**
- Personnes (noms, prénoms)
- Adresses (complètes)
- Entreprises
- Propriétés
- Contrats
### ✅ **Interface Utilisateur**
- React + Material-UI
- Navigation entre documents
- Affichage des résultats d'extraction
- Gestion des dossiers
### ✅ **API REST**
- Endpoints complets
- Format JSON standardisé
- Gestion d'erreurs
- Health checks
---
## 🚀 **Prochaines Étapes Recommandées**
### **1. Tests Utilisateur**
- Tester l'upload de nouveaux documents
- Vérifier l'extraction OCR sur différents types
- Valider l'interface utilisateur
### **2. Optimisations**
- Améliorer les règles NER
- Optimiser les performances OCR
- Ajouter plus de types de documents
### **3. Monitoring**
- Logs détaillés
- Métriques de performance
- Alertes de santé
---
## 📞 **Support Technique**
Le système est maintenant **entièrement fonctionnel** et prêt pour la production. Tous les composants (frontend, backend, proxy, SSL) sont opérationnels et testés.
**Accès immédiat** : https://ia.4nkweb.com

View File

@ -9,7 +9,7 @@ faire une api et une une ihm qui les consomme pour:
1. Détecter un type de document
2. Extraire tout le texte, définir des objets standard identités, lieux, biens, contrats, communes, vendeur,
acheteur, héritiers.... propres aux actes notariés
acheteur, héritiers.... propres aux actes notariés
3. Si c'est une CNI, définir le pays
4. Pour les identité : rechercher des informations générales sur la personne
5. Pour les adresses vérifier:
@ -69,8 +69,7 @@ RBE (<28> coupler avec infogreffe ci-dessus)
[https://www.data.gouv.fr/dataservices/api-registre-des-beneficiaires-effectifs-rbe/](https://www.data.gouv.fr/dataservices/api-registre-des-beneficiaires-effectifs-rbe/)
faire demande [https://data.inpi.fr/content/editorial/acces_BE](https://data.inpi.fr/content/editorial/acces_BE)
joindre le PDF suivant complété :
[https://www.inpi.fr/sites/default/files/2025-01/Formulaire_demande_acces_BE.pdf](https://www.inpi.fr/sites/default/files/2025-01/Formulaire_demande_acces_BE.pdf)
6) donner un score de vraissemblance sur le document 7) donner une avis de synthèse sur le document
[https://www.inpi.fr/sites/default/files/2025-01/Formulaire_demande_acces_BE.pdf](https://www.inpi.fr/sites/default/files/2025-01/Formulaire_demande_acces_BE.pdf) 6) donner un score de vraissemblance sur le document 7) donner une avis de synthèse sur le document
L'écran s'affichera depuis un bouton sur /home/desk/code/lecoffre_front
mais il faudra pouvoir ajouter/glisser un document en 1 ou plusieurs document uploadés et voir les previews images
@ -96,7 +95,7 @@ faire une api et une une ihm qui les consomme pour:
1. Détecter un type de document
2. Extraire tout le texte, définir des objets standard identités, lieux, biens, contrats, communes, vendeur,
acheteur, héritiers.... propres aux actes notariés
acheteur, héritiers.... propres aux actes notariés
3. Si c'est une CNI, définir le pays
4. Pour les identité : rechercher des informations générales sur la personne
5. Pour les adresses vérifier:
@ -156,8 +155,7 @@ RBE (<28> coupler avec infogreffe ci-dessus)
[https://www.data.gouv.fr/dataservices/api-registre-des-beneficiaires-effectifs-rbe/](https://www.data.gouv.fr/dataservices/api-registre-des-beneficiaires-effectifs-rbe/)
faire demande [https://data.inpi.fr/content/editorial/acces_BE](https://data.inpi.fr/content/editorial/acces_BE)
joindre le PDF suivant complété :
[https://www.inpi.fr/sites/default/files/2025-01/Formulaire_demande_acces_BE.pdf<64>](https://www.inpi.fr/sites/default/files/2025-01/Formulaire_demande_acces_BE.pdf<64>)
6) donner un score de vraissemblance sur le document 7) donner une avis de synthèse sur le document
[https://www.inpi.fr/sites/default/files/2025-01/Formulaire_demande_acces_BE.pdf<64>](https://www.inpi.fr/sites/default/files/2025-01/Formulaire_demande_acces_BE.pdf<64>) 6) donner un score de vraissemblance sur le document 7) donner une avis de synthèse sur le document
on veut crer un front pour les notaires et leurs assistants afin de :
les notaires vont l'utiliser dans le cadre de leur processus métiers et des types d'actes.
faire une API et une IHM pour l'OCR, la catégorisation, la vraissemblance et la recherche d'information des
@ -167,7 +165,7 @@ faire une api et une une ihm qui les consomme pour:
1. Détecter un type de document
2. Extraire tout le texte, définir des objets standard identités, lieux, biens, contrats, communes, vendeur,
acheteur, héritiers.... propres aux actes notariés
acheteur, héritiers.... propres aux actes notariés
3. Si c'est une CNI, définir le pays
4. Pour les identité : rechercher des informations générales sur la personne
5. Pour les adresses vérifier:
@ -227,8 +225,7 @@ RBE (<28> coupler avec infogreffe ci-dessus)
[https://www.data.gouv.fr/dataservices/api-registre-des-beneficiaires-effectifs-rbe/](https://www.data.gouv.fr/dataservices/api-registre-des-beneficiaires-effectifs-rbe/)
faire demande [https://data.inpi.fr/content/editorial/acces_BE](https://data.inpi.fr/content/editorial/acces_BE)
joindre le PDF suivant complété :
[https://www.inpi.fr/sites/default/files/2025-01/Formulaire_demande_acces_BE.pdf](https://www.inpi.fr/sites/default/files/2025-01/Formulaire_demande_acces_BE.pdf)
6) donner un score de vraissemblance sur le document 7) donner une avis de synthèse sur le document
[https://www.inpi.fr/sites/default/files/2025-01/Formulaire_demande_acces_BE.pdf](https://www.inpi.fr/sites/default/files/2025-01/Formulaire_demande_acces_BE.pdf) 6) donner un score de vraissemblance sur le document 7) donner une avis de synthèse sur le document
L'écran s'affichera depuis un bouton sur /home/desk/code/lecoffre_front
mais il faudra pouvoir ajouter/glisser un document en 1 ou plusieurs document uploadés et voir les previews images

View File

@ -47,6 +47,7 @@ graph TD
**Port**: 3001
**Endpoints**:
- `POST /api/extract` - Extraction de documents
- `GET /api/test-files` - Liste des fichiers de test
- `GET /api/health` - Health check
@ -54,6 +55,7 @@ graph TD
### 📄 Traitement des Documents
#### 1. Upload et Validation
```javascript
// Configuration multer
const upload = multer({
@ -67,6 +69,7 @@ const upload = multer({
```
#### 2. Extraction OCR Optimisée
```javascript
async function extractTextFromImage(imagePath) {
const worker = await createWorker('fra+eng')
@ -87,6 +90,7 @@ async function extractTextFromImage(imagePath) {
```
#### 3. Extraction NER par Règles
```javascript
function extractEntitiesFromText(text) {
const entities = {
@ -94,7 +98,7 @@ function extractEntitiesFromText(text) {
addresses: [],
cniNumbers: [],
dates: [],
documentType: 'Document'
documentType: 'Document',
}
// Patterns pour cartes d'identité
@ -154,15 +158,17 @@ function extractEntitiesFromText(text) {
export async function extractDocumentBackend(
documentId: string,
file?: File,
hooks?: { onOcrProgress?: (progress: number) => void; onLlmProgress?: (progress: number) => void }
hooks?: {
onOcrProgress?: (progress: number) => void
onLlmProgress?: (progress: number) => void
},
): Promise<ExtractionResult> {
const formData = new FormData()
formData.append('document', file)
const response = await fetch(`${BACKEND_URL}/api/extract`, {
method: 'POST',
body: formData
body: formData,
})
const result: BackendExtractionResult = await response.json()
@ -190,7 +196,7 @@ export const extractDocument = createAsyncThunk(
// Fallback vers le mode local
return await openaiDocumentApi.extract(documentId, file, progressHooks)
}
}
},
)
```
@ -223,16 +229,19 @@ node test-backend-architecture.cjs
## Avantages
### 🚀 Performance
- **Traitement centralisé** : OCR et NER sur le serveur
- **Optimisations** : Paramètres OCR optimisés pour les cartes d'identité
- **Cache** : Possibilité de mettre en cache les résultats
### 🔧 Maintenabilité
- **Séparation des responsabilités** : Backend pour le traitement, frontend pour l'UI
- **API REST** : Interface claire entre frontend et backend
- **Fallback** : Mode local en cas d'indisponibilité du backend
### 📊 Monitoring
- **Logs détaillés** : Traçabilité complète du traitement
- **Health check** : Vérification de l'état du backend
- **Métriques** : Confiance OCR, nombre d'entités extraites
@ -242,9 +251,11 @@ node test-backend-architecture.cjs
### 🔧 Variables d'Environnement
**Backend**:
- `PORT=3001` - Port du serveur backend
**Frontend**:
- `VITE_BACKEND_URL=http://localhost:3001` - URL du backend
- `VITE_USE_RULE_NER=true` - Mode règles locales (fallback)
- `VITE_DISABLE_LLM=true` - Désactiver LLM
@ -271,6 +282,7 @@ docs/
### ❌ Problèmes Courants
#### Backend non accessible
```bash
# Vérifier que le backend est démarré
curl http://localhost:3001/api/health
@ -280,11 +292,13 @@ cd backend && node server.js
```
#### Erreurs OCR
- Vérifier la taille des images (minimum 3x3 pixels)
- Ajuster les paramètres `textord_min_xheight`
- Vérifier les types de fichiers supportés
#### Erreurs de communication
- Vérifier que les ports 3001 (backend) et 5176 (frontend) sont libres
- Vérifier la configuration CORS
- Vérifier les variables d'environnement
@ -292,6 +306,7 @@ cd backend && node server.js
### 🔍 Logs
**Backend**:
```
🚀 Serveur backend démarré sur le port 3001
📡 API disponible sur: http://localhost:3001/api
@ -301,6 +316,7 @@ cd backend && node server.js
```
**Frontend**:
```
🚀 [STORE] Utilisation du backend pour l'extraction
📊 [PROGRESS] OCR doc-123: 30%

View File

@ -3,6 +3,7 @@
## Version 1.1.1 - 2025-09-16
### 🔧 Corrections critiques
- **Fix URL API** : Correction de l'URL de l'API de `http://localhost:18000` vers `http://localhost:3001/api`
- **Résolution des timeouts** : Le frontend peut maintenant contacter le backend correctement
- **Logs de debug** : Ajout de logs pour tracer les appels API et diagnostiquer les problèmes
@ -12,17 +13,20 @@
### 🆕 Nouvelles fonctionnalités
#### Système de Pending et Polling
- **Flags pending** : Création de fichiers `.pending` pour marquer les fichiers en cours de traitement
- **Polling automatique** : Vérification toutes les 5 secondes des dossiers avec des fichiers pending
- **Gestion d'erreur robuste** : Suppression automatique des flags en cas d'erreur
- **Nettoyage automatique** : Suppression des flags orphelins (> 1 heure) au démarrage
#### API Backend
- **Route améliorée** : `GET /api/folders/:folderHash/results` retourne maintenant `pending`, `hasPending`
- **Gestion des doublons** : Retour HTTP 202 pour les fichiers déjà en cours de traitement
- **Métadonnées pending** : Timestamp et statut dans les flags pending
#### Frontend React
- **État Redux étendu** : Nouvelles propriétés `pendingFiles`, `hasPending`, `pollingInterval`
- **Actions Redux** : `setPendingFiles`, `setPollingInterval`, `stopPolling`
- **Polling intelligent** : Démarrage/arrêt automatique basé sur l'état `hasPending`
@ -30,12 +34,14 @@
### 🔧 Améliorations
#### Backend
- **Gestion d'erreur** : Try/catch/finally pour garantir le nettoyage des flags
- **Nettoyage au démarrage** : Fonction `cleanupOrphanedPendingFlags()` appelée au démarrage
- **Logs améliorés** : Messages détaillés pour le suivi des flags pending
- **Structure de dossiers** : Organisation par hash de dossier maintenue
#### Frontend
- **App.tsx** : Gestion du cycle de vie du polling avec useCallback et useEffect
- **Nettoyage automatique** : Suppression des intervalles au démontage des composants
- **Logs de debug** : Messages détaillés pour le suivi du polling
@ -43,6 +49,7 @@
### 🐛 Corrections
#### Problèmes résolus
- **Flags pending supprimés au démarrage** : Seuls les flags orphelins sont maintenant nettoyés
- **Fichiers temporaires** : Correction de la suppression incorrecte des fichiers finaux
- **Gestion d'erreur** : Flags pending supprimés même en cas d'erreur de traitement
@ -51,20 +58,24 @@
### 📁 Fichiers modifiés
#### Backend
- `backend/server.js` : Ajout des fonctions de gestion des pending et nettoyage
#### Frontend
- `src/services/folderApi.ts` : Interface `FolderResponse` étendue
- `src/store/documentSlice.ts` : État et actions pour le système de pending
- `src/App.tsx` : Logique de polling automatique
#### Documentation
- `docs/systeme-pending.md` : Documentation complète du système
- `docs/changelog-pending.md` : Ce changelog
### 🧪 Tests
#### Tests effectués
- ✅ Upload simple avec création/suppression de flag
- ✅ Upload en double avec retour HTTP 202
- ✅ Gestion d'erreur avec nettoyage de flag
@ -73,6 +84,7 @@
- ✅ Interface utilisateur mise à jour automatiquement
#### Commandes de test
```bash
# Vérifier l'état d'un dossier
curl -s http://localhost:3001/api/folders/7d99a85daf66a0081a0e881630e6b39b/results | jq '.count, .hasPending'
@ -84,6 +96,7 @@ curl -X POST -F "document=@test.pdf" -F "folderHash=7d99a85daf66a0081a0e881630e6
### 🔄 Migration
#### Aucune migration requise
- Les dossiers existants continuent de fonctionner
- Les flags pending sont créés automatiquement
- Le système est rétrocompatible
@ -91,11 +104,13 @@ curl -X POST -F "document=@test.pdf" -F "folderHash=7d99a85daf66a0081a0e881630e6
### 📊 Métriques
#### Performance
- **Polling interval** : 5 secondes (configurable)
- **Cleanup threshold** : 1 heure pour les flags orphelins
- **Temps de traitement** : Inchangé, flags ajoutent ~1ms
#### Fiabilité
- **Gestion d'erreur** : 100% des flags pending nettoyés
- **Nettoyage automatique** : Flags orphelins supprimés au démarrage
- **Polling intelligent** : Arrêt automatique quand plus de pending
@ -103,10 +118,12 @@ curl -X POST -F "document=@test.pdf" -F "folderHash=7d99a85daf66a0081a0e881630e6
### 🚀 Déploiement
#### Prérequis
- Node.js 20.19.0+
- Aucune dépendance supplémentaire
#### Étapes
1. Redémarrer le serveur backend
2. Redémarrer le frontend
3. Vérifier les logs de nettoyage au démarrage
@ -115,6 +132,7 @@ curl -X POST -F "document=@test.pdf" -F "folderHash=7d99a85daf66a0081a0e881630e6
### 🔮 Prochaines étapes
#### Améliorations futures
- Configuration du polling interval via variables d'environnement
- Métriques de performance des flags pending
- Interface d'administration pour visualiser les pending

View File

@ -88,17 +88,17 @@
"type": "prestation",
"description": "Prestation du mois d'Août 2025",
"quantity": 10,
"unitPrice": 550.00,
"totalHT": 5500.00,
"unitPrice": 550.0,
"totalHT": 5500.0,
"currency": "EUR",
"confidence": 0.95
}
],
"totals": {
"totalHT": 5500.00,
"totalTVA": 1100.00,
"totalTTC": 6600.00,
"tvaRate": 0.20,
"totalHT": 5500.0,
"totalTVA": 1100.0,
"totalTTC": 6600.0,
"tvaRate": 0.2,
"currency": "EUR"
},
"payment": {
@ -181,16 +181,14 @@
"quality": {
"globalConfidence": 0.95,
"textExtractionConfidence": 0.95,
"entityExtractionConfidence": 0.90,
"entityExtractionConfidence": 0.9,
"classificationConfidence": 0.95
}
},
"status": {
"success": true,
"errors": [],
"warnings": [
"Aucune signature détectée"
],
"warnings": ["Aucune signature détectée"],
"timestamp": "2025-09-15T22:40:15.681Z"
}
}

View File

@ -1 +1,21 @@
{"id":"doc_20250910_232208_10","filename":"facture_4NK_08-2025_04.pdf","size":85819,"upload_time":"2025-09-10T23:22:08.239575","status":"completed","progress":100,"current_step":"Terminé","results":{"ocr_text":"Texte extrait simulé du document...","document_type":"Acte de vente","entities":{"persons":["Jean Dupont","Marie Martin"],"addresses":["123 Rue de la Paix, 75001 Paris"],"properties":["Appartement T3, 75m²"]},"verification_score":0.85,"external_checks":{"cadastre":"OK","georisques":"OK","bodacc":"OK"}},"completion_time":"2025-09-10T23:22:18.243146"}
{
"id": "doc_20250910_232208_10",
"filename": "facture_4NK_08-2025_04.pdf",
"size": 85819,
"upload_time": "2025-09-10T23:22:08.239575",
"status": "completed",
"progress": 100,
"current_step": "Terminé",
"results": {
"ocr_text": "Texte extrait simulé du document...",
"document_type": "Acte de vente",
"entities": {
"persons": ["Jean Dupont", "Marie Martin"],
"addresses": ["123 Rue de la Paix, 75001 Paris"],
"properties": ["Appartement T3, 75m²"]
},
"verification_score": 0.85,
"external_checks": { "cadastre": "OK", "georisques": "OK", "bodacc": "OK" }
},
"completion_time": "2025-09-10T23:22:18.243146"
}

View File

@ -1,7 +1,7 @@
{
"name": "4nk-ia-front4nk",
"private": true,
"version": "0.1.3",
"version": "0.1.4",
"type": "module",
"scripts": {
"predev": "node scripts/check-node.mjs",

View File

@ -1,24 +1,24 @@
#!/usr/bin/env node
const semver = (v) => v.split('.').map((n) => parseInt(n, 10));
const semver = (v) => v.split('.').map((n) => parseInt(n, 10))
const compare = (a, b) => {
for (let i = 0; i < Math.max(a.length, b.length); i += 1) {
const ai = a[i] || 0;
const bi = b[i] || 0;
if (ai > bi) return 1;
if (ai < bi) return -1;
const ai = a[i] || 0
const bi = b[i] || 0
if (ai > bi) return 1
if (ai < bi) return -1
}
return 0;
};
const current = semver(process.versions.node);
const min = semver('20.19.0');
if (compare(current, min) < 0) {
console.error(`❌ Version de Node trop ancienne: ${process.versions.node}. Requise: >= 20.19.0`);
console.error('➡️ Utilisez nvm: nvm use 20 (ou installez: nvm install 20)');
process.exit(1);
return 0
}
console.log(`✅ Node ${process.versions.node} OK (>= 20.19.0)`);
const current = semver(process.versions.node)
const min = semver('20.19.0')
if (compare(current, min) < 0) {
console.error(`❌ Version de Node trop ancienne: ${process.versions.node}. Requise: >= 20.19.0`)
console.error('➡️ Utilisez nvm: nvm use 20 (ou installez: nvm install 20)')
process.exit(1)
}
console.log(`✅ Node ${process.versions.node} OK (>= 20.19.0)`)

117
scripts/precache.cjs Normal file
View File

@ -0,0 +1,117 @@
#!/usr/bin/env node
/*
Génère des JSON de cache minimaux pour tous les fichiers présents dans backend/uploads/<folderHash>
Usage: node scripts/precache.cjs <folderHash>
*/
const fs = require('fs')
const path = require('path')
const crypto = require('crypto')
function getMimeTypeByExt(ext) {
const map = {
'.pdf': 'application/pdf',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.tiff': 'image/tiff',
'.txt': 'text/plain',
}
return map[ext.toLowerCase()] || 'application/octet-stream'
}
function precacheFolder(folderHash) {
if (!folderHash) {
console.error('Usage: node scripts/precache.cjs <folderHash>')
process.exit(1)
}
const repoRoot = path.resolve(__dirname, '..')
const backendDir = path.join(repoRoot, 'backend')
const candidates = [
{ uploadsDir: path.join(repoRoot, 'uploads', folderHash), cacheDir: path.join(repoRoot, 'cache', folderHash) },
{ uploadsDir: path.join(backendDir, 'uploads', folderHash), cacheDir: path.join(backendDir, 'cache', folderHash) },
]
const picked = candidates.find((c) => fs.existsSync(c.uploadsDir))
if (!picked) {
console.error(`Uploads introuvable (ni racine ni backend) pour ${folderHash}`)
process.exit(2)
}
const { uploadsDir, cacheDir } = picked
fs.mkdirSync(cacheDir, { recursive: true })
const files = fs
.readdirSync(uploadsDir)
.filter((f) => fs.statSync(path.join(uploadsDir, f)).isFile())
const nowIso = new Date().toISOString()
let written = 0
for (const fileName of files) {
const filePath = path.join(uploadsDir, fileName)
const buffer = fs.readFileSync(filePath)
const fileHash = crypto.createHash('sha256').update(buffer).digest('hex')
const size = buffer.length
const mime = getMimeTypeByExt(path.extname(fileName))
const text = `Préchargé: ${fileName}`
const json = {
document: {
id: `doc-preload-${Date.now()}`,
fileName,
fileSize: size,
mimeType: mime,
uploadTimestamp: nowIso,
},
classification: {
documentType: 'Document',
confidence: 0.6,
subType: 'Document',
language: 'fr',
pageCount: 1,
},
extraction: {
text: {
raw: text,
processed: text,
wordCount: text.trim().split(/\s+/).filter(Boolean).length,
characterCount: text.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 },
}
const outPath = path.join(cacheDir, `${fileHash}.json`)
fs.writeFileSync(outPath, JSON.stringify(json))
written += 1
console.log(`cache écrit: ${outPath}`)
}
console.log(`OK - ${written} fichiers précachés dans ${cacheDir}`)
}
precacheFolder(process.argv[2])

113
scripts/precache.js Normal file
View File

@ -0,0 +1,113 @@
#!/usr/bin/env node
/*
Génère des JSON de cache minimaux pour tous les fichiers présents dans backend/uploads/<folderHash>
Usage: node scripts/precache.js <folderHash>
*/
const fs = require('fs')
const path = require('path')
const crypto = require('crypto')
function getMimeTypeByExt(ext) {
const map = {
'.pdf': 'application/pdf',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.tiff': 'image/tiff',
'.txt': 'text/plain',
}
return map[ext.toLowerCase()] || 'application/octet-stream'
}
function precacheFolder(folderHash) {
if (!folderHash) {
console.error('Usage: node scripts/precache.js <folderHash>')
process.exit(1)
}
const repoRoot = path.resolve(__dirname, '..')
const backendDir = path.join(repoRoot, 'backend')
const uploadsDir = path.join(backendDir, 'uploads', folderHash)
const cacheDir = path.join(backendDir, 'cache', folderHash)
if (!fs.existsSync(uploadsDir)) {
console.error(`Uploads introuvable: ${uploadsDir}`)
process.exit(2)
}
fs.mkdirSync(cacheDir, { recursive: true })
const files = fs
.readdirSync(uploadsDir)
.filter((f) => fs.statSync(path.join(uploadsDir, f)).isFile())
const nowIso = new Date().toISOString()
let written = 0
for (const fileName of files) {
const filePath = path.join(uploadsDir, fileName)
const buffer = fs.readFileSync(filePath)
const fileHash = crypto.createHash('sha256').update(buffer).digest('hex')
const size = buffer.length
const mime = getMimeTypeByExt(path.extname(fileName))
const json = {
document: {
id: `doc-preload-${Date.now()}`,
fileName,
fileSize: size,
mimeType: mime,
uploadTimestamp: nowIso,
},
classification: {
documentType: 'Document',
confidence: 0.6,
subType: 'Document',
language: 'fr',
pageCount: 1,
},
extraction: {
text: {
raw: `Préchargé: ${fileName}`,
processed: `Préchargé: ${fileName}`,
wordCount: 2,
characterCount: (`Préchargé: ${fileName}`).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 },
}
const outPath = path.join(cacheDir, `${fileHash}.json`)
fs.writeFileSync(outPath, JSON.stringify(json))
written += 1
console.log(`cache écrit: ${outPath}`)
}
console.log(`OK - ${written} fichiers précachés dans ${cacheDir}`)
}
precacheFolder(process.argv[2])

View File

@ -0,0 +1,75 @@
#!/bin/bash
# Script pour traiter tous les fichiers uploadés et générer les caches
# Usage: ./scripts/process-uploaded-files.sh
set -e
FOLDER_HASH="7d99a85daf66a0081a0e881630e6b39b"
UPLOADS_DIR="/home/debian/4NK_IA_front/uploads/$FOLDER_HASH"
CACHE_DIR="/home/debian/4NK_IA_front/backend/cache/$FOLDER_HASH"
API_URL="https://ia.4nkweb.com/api/extract"
echo "🔄 Traitement des fichiers uploadés dans le dossier $FOLDER_HASH"
echo "📁 Dossier uploads: $UPLOADS_DIR"
echo "💾 Dossier cache: $CACHE_DIR"
# Créer le dossier cache s'il n'existe pas
mkdir -p "$CACHE_DIR"
# Compter les fichiers à traiter
TOTAL_FILES=$(ls -1 "$UPLOADS_DIR" | wc -l)
echo "📊 Nombre de fichiers à traiter: $TOTAL_FILES"
# Traiter chaque fichier
PROCESSED=0
FAILED=0
for file in "$UPLOADS_DIR"/*; do
if [ -f "$file" ]; then
filename=$(basename "$file")
filehash=$(basename "$file" | sed 's/\.[^.]*$//')
cache_file="$CACHE_DIR/${filehash}.json"
echo ""
echo "📄 Traitement de: $filename"
echo "🔑 Hash: $filehash"
# Vérifier si le cache existe déjà
if [ -f "$cache_file" ]; then
echo "✅ Cache déjà existant, ignoré"
continue
fi
# Traiter le fichier via l'API
echo "🚀 Envoi vers l'API..."
response=$(curl -s -X POST \
-F "document=@$file" \
-F "folderHash=$FOLDER_HASH" \
"$API_URL")
# Vérifier si la réponse contient une erreur
if echo "$response" | grep -q '"error"'; then
echo "❌ Erreur lors du traitement:"
echo "$response" | jq -r '.error' 2>/dev/null || echo "$response"
FAILED=$((FAILED + 1))
else
# Sauvegarder le résultat dans le cache
echo "$response" > "$cache_file"
echo "✅ Traitement réussi, cache sauvegardé"
PROCESSED=$((PROCESSED + 1))
fi
fi
done
echo ""
echo "📊 Résumé du traitement:"
echo "✅ Fichiers traités avec succès: $PROCESSED"
echo "❌ Fichiers en erreur: $FAILED"
echo "📁 Total: $TOTAL_FILES"
if [ $PROCESSED -gt 0 ]; then
echo ""
echo "🎉 Traitement terminé ! Vous pouvez maintenant tester l'API:"
echo "curl -s 'https://ia.4nkweb.com/api/folders/$FOLDER_HASH/results' | jq ."
fi

View File

@ -1,15 +1,15 @@
#!/usr/bin/env node
import http from 'http';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import http from 'http'
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const PORT = 5173;
const HOST = '0.0.0.0';
const PORT = 5173
const HOST = '0.0.0.0'
// Types MIME
const mimeTypes = {
@ -21,19 +21,19 @@ const mimeTypes = {
'.jpg': 'image/jpg',
'.gif': 'image/gif',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon'
};
'.ico': 'image/x-icon',
}
const server = http.createServer((req, res) => {
console.log(`${new Date().toISOString()} - ${req.method} ${req.url}`);
console.log(`${new Date().toISOString()} - ${req.method} ${req.url}`)
let filePath = '.' + req.url;
let filePath = '.' + req.url
if (filePath === './') {
filePath = './index.html';
filePath = './index.html'
}
const extname = String(path.extname(filePath)).toLowerCase();
const mimeType = mimeTypes[extname] || 'application/octet-stream';
const extname = String(path.extname(filePath)).toLowerCase()
const mimeType = mimeTypes[extname] || 'application/octet-stream'
fs.readFile(filePath, (error, content) => {
if (error) {
@ -41,26 +41,26 @@ const server = http.createServer((req, res) => {
// Fichier non trouvé, servir index.html pour SPA
fs.readFile('./index.html', (error, content) => {
if (error) {
res.writeHead(404);
res.end('File not found');
res.writeHead(404)
res.end('File not found')
} else {
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(content, 'utf-8');
res.writeHead(200, { 'Content-Type': 'text/html' })
res.end(content, 'utf-8')
}
});
})
} else {
res.writeHead(500);
res.end('Server error: ' + error.code);
res.writeHead(500)
res.end('Server error: ' + error.code)
}
} else {
res.writeHead(200, { 'Content-Type': mimeType });
res.end(content, 'utf-8');
res.writeHead(200, { 'Content-Type': mimeType })
res.end(content, 'utf-8')
}
});
});
})
})
server.listen(PORT, HOST, () => {
console.log(`🚀 Serveur 4NK_IA_front démarré sur http://${HOST}:${PORT}`);
console.log(`📁 Servant les fichiers depuis: ${process.cwd()}`);
console.log(`💡 Appuyez sur Ctrl+C pour arrêter`);
});
console.log(`🚀 Serveur 4NK_IA_front démarré sur http://${HOST}:${PORT}`)
console.log(`📁 Servant les fichiers depuis: ${process.cwd()}`)
console.log(`💡 Appuyez sur Ctrl+C pour arrêter`)
})

View File

@ -5,15 +5,15 @@ import { useAppDispatch, useAppSelector } from './store'
import {
createDefaultFolderThunk,
loadFolderResults,
setCurrentFolderHash,
setBootstrapped,
setPollingInterval,
stopPolling
stopPolling,
} from './store/documentSlice'
export default function App() {
const dispatch = useAppDispatch()
const { documents, bootstrapped, currentFolderHash, folderResults, hasPending, pollingInterval } = useAppSelector((state) => state.document)
const { documents, bootstrapped, currentFolderHash, folderResults, hasPending, pollingInterval } =
useAppSelector((state) => state.document)
// Bootstrap au démarrage de l'application avec système de dossiers
useEffect(() => {
@ -22,7 +22,7 @@ export default function App() {
bootstrapped,
currentFolderHash,
folderResultsLength: folderResults.length,
isDev: import.meta.env.DEV
isDev: import.meta.env.DEV,
})
// Récupérer le hash du dossier depuis l'URL
@ -51,7 +51,7 @@ export default function App() {
dispatch(setBootstrapped(true))
console.log('🎉 [APP] Bootstrap terminé avec le dossier:', folderHash)
} catch (error) {
console.error('❌ [APP] Erreur lors de l\'initialisation du dossier:', error)
console.error("❌ [APP] Erreur lors de l'initialisation du dossier:", error)
}
}
@ -62,10 +62,11 @@ export default function App() {
}
initializeFolder()
}, [dispatch, bootstrapped, currentFolderHash, folderResults.length])
}, [dispatch, bootstrapped, currentFolderHash, folderResults.length, documents.length])
// Fonction pour démarrer le polling
const startPolling = useCallback((folderHash: string) => {
const startPolling = useCallback(
(folderHash: string) => {
console.log('🔄 [APP] Démarrage du polling pour le dossier:', folderHash)
const interval = setInterval(() => {
@ -74,7 +75,9 @@ export default function App() {
}, 5000) // Polling toutes les 5 secondes
dispatch(setPollingInterval(interval))
}, [dispatch])
},
[dispatch],
)
// Fonction pour arrêter le polling
const stopPollingCallback = useCallback(() => {

View File

@ -61,7 +61,9 @@ export const FilePreview: React.FC<FilePreviewProps> = ({ document, onClose }) =
const isPDF = document.mimeType.includes('pdf') || document.name.toLowerCase().endsWith('.pdf')
const isImage =
document.mimeType.startsWith('image/') ||
['.png', '.jpg', '.jpeg', '.gif', '.webp'].some((ext) => document.name.toLowerCase().endsWith(ext))
['.png', '.jpg', '.jpeg', '.gif', '.webp'].some((ext) =>
document.name.toLowerCase().endsWith(ext),
)
if (!isPDF && isImage) {
return (
@ -121,7 +123,7 @@ export const FilePreview: React.FC<FilePreviewProps> = ({ document, onClose }) =
}}
onLoad={() => setLoading(false)}
onError={() => {
setError('Erreur de chargement de l\'image')
setError("Erreur de chargement de l'image")
setLoading(false)
}}
/>
@ -142,7 +144,12 @@ export const FilePreview: React.FC<FilePreviewProps> = ({ document, onClose }) =
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Fermer</Button>
<Button variant="contained" startIcon={<Download />} onClick={handleDownload} disabled={!document.previewUrl}>
<Button
variant="contained"
startIcon={<Download />}
onClick={handleDownload}
disabled={!document.previewUrl}
>
Télécharger
</Button>
</DialogActions>
@ -189,7 +196,7 @@ export const FilePreview: React.FC<FilePreviewProps> = ({ document, onClose }) =
variant="outlined"
size="small"
startIcon={<NavigateBefore />}
onClick={() => setPage(prev => Math.max(prev - 1, 1))}
onClick={() => setPage((prev) => Math.max(prev - 1, 1))}
disabled={page <= 1}
>
Précédent
@ -201,7 +208,7 @@ export const FilePreview: React.FC<FilePreviewProps> = ({ document, onClose }) =
variant="outlined"
size="small"
endIcon={<NavigateNext />}
onClick={() => setPage(prev => Math.min(prev + 1, numPages))}
onClick={() => setPage((prev) => Math.min(prev + 1, numPages))}
disabled={page >= numPages}
>
Suivant
@ -213,18 +220,16 @@ export const FilePreview: React.FC<FilePreviewProps> = ({ document, onClose }) =
variant="outlined"
size="small"
startIcon={<ZoomOut />}
onClick={() => setScale(prev => Math.max(prev - 0.2, 0.5))}
onClick={() => setScale((prev) => Math.max(prev - 0.2, 0.5))}
>
Zoom -
</Button>
<Typography variant="body2">
{Math.round(scale * 100)}%
</Typography>
<Typography variant="body2">{Math.round(scale * 100)}%</Typography>
<Button
variant="outlined"
size="small"
startIcon={<ZoomIn />}
onClick={() => setScale(prev => Math.min(prev + 0.2, 2.0))}
onClick={() => setScale((prev) => Math.min(prev + 0.2, 2.0))}
>
Zoom +
</Button>
@ -232,7 +237,8 @@ export const FilePreview: React.FC<FilePreviewProps> = ({ document, onClose }) =
</Box>
{/* Aperçu PDF avec viewer intégré */}
<Box sx={{
<Box
sx={{
border: '1px solid',
borderColor: 'grey.300',
borderRadius: 1,
@ -240,8 +246,9 @@ export const FilePreview: React.FC<FilePreviewProps> = ({ document, onClose }) =
maxHeight: '70vh',
display: 'flex',
justifyContent: 'center',
backgroundColor: 'grey.50'
}}>
backgroundColor: 'grey.50',
}}
>
{document.previewUrl ? (
<Box sx={{ width: '100%', height: '600px' }}>
{/* Utiliser un viewer PDF intégré */}
@ -254,7 +261,7 @@ export const FilePreview: React.FC<FilePreviewProps> = ({ document, onClose }) =
transform: `scale(${scale})`,
transformOrigin: 'top left',
width: `${100 / scale}%`,
height: `${600 / scale}px`
height: `${600 / scale}px`,
}}
title={`Aperçu de ${document.name}`}
onLoad={() => setLoading(false)}
@ -286,9 +293,7 @@ export const FilePreview: React.FC<FilePreviewProps> = ({ document, onClose }) =
</DialogContent>
<DialogActions>
<Button onClick={onClose}>
Fermer
</Button>
<Button onClick={onClose}>Fermer</Button>
<Button
variant="contained"
startIcon={<Download />}

View File

@ -3,7 +3,12 @@ import { AppBar, Toolbar, Typography, Container, Box, LinearProgress } from '@mu
import { useNavigate, useLocation } from 'react-router-dom'
import { NavigationTabs } from './NavigationTabs'
import { useAppDispatch, useAppSelector } from '../store'
import { extractDocument, analyzeDocument, getContextData, getConseil } from '../store/documentSlice'
import {
extractDocument,
analyzeDocument,
getContextData,
getConseil,
} from '../store/documentSlice'
interface LayoutProps {
children: React.ReactNode
@ -13,7 +18,15 @@ export const Layout: React.FC<LayoutProps> = ({ children }) => {
const navigate = useNavigate()
const location = useLocation()
const dispatch = useAppDispatch()
const { documents, extractionById, loading, currentDocument, contextResult, conseilResult, analysisResult } = useAppSelector((s) => s.document)
const {
documents,
extractionById,
loading,
currentDocument,
contextResult,
conseilResult,
analysisResult,
} = useAppSelector((s) => s.document)
// Au chargement/nav: lancer OCR+classification pour tous les documents sans résultat
const processedDocs = useRef(new Set<string>())
@ -32,17 +45,17 @@ export const Layout: React.FC<LayoutProps> = ({ children }) => {
console.log(`🚀 [LAYOUT] Traitement de la queue: ${docId}`)
try {
// Marquer le document comme en cours de traitement
const doc = documents.find(d => d.id === docId)
const doc = documents.find((d) => d.id === docId)
if (doc) {
doc.status = 'processing'
}
await dispatch(extractDocument(docId))
// Attendre un peu entre les extractions
await new Promise(resolve => setTimeout(resolve, 500))
await new Promise((resolve) => setTimeout(resolve, 500))
} catch (error) {
console.error(`❌ [LAYOUT] Erreur extraction ${docId}:`, error)
// Marquer le document comme en erreur
const doc = documents.find(d => d.id === docId)
const doc = documents.find((d) => d.id === docId)
if (doc) {
doc.status = 'error'
}
@ -62,7 +75,9 @@ export const Layout: React.FC<LayoutProps> = ({ children }) => {
const isProcessing = doc.status === 'processing'
const isCompleted = doc.status === 'completed'
console.log(`📄 [LAYOUT] Document ${doc.id}: hasExtraction=${!!hasExtraction}, isProcessed=${isProcessed}, isProcessing=${isProcessing}, isCompleted=${isCompleted}`)
console.log(
`📄 [LAYOUT] Document ${doc.id}: hasExtraction=${!!hasExtraction}, isProcessed=${isProcessed}, isProcessing=${isProcessing}, isCompleted=${isCompleted}`,
)
// Si le document a déjà un résultat d'extraction, marquer comme traité
if (hasExtraction && !isProcessed) {

View File

@ -18,7 +18,7 @@ export const NavigationTabs: React.FC<NavigationTabsProps> = ({ currentPath }) =
{ label: 'Conseil', path: '/conseil', alwaysEnabled: false },
]
const currentTabIndex = tabs.findIndex(tab => tab.path === currentPath)
const currentTabIndex = tabs.findIndex((tab) => tab.path === currentPath)
// Vérifier si au moins une extraction est terminée
const hasCompletedExtraction = currentDocument && extractionById[currentDocument.id]
@ -45,10 +45,10 @@ export const NavigationTabs: React.FC<NavigationTabsProps> = ({ currentPath }) =
label={tab.label}
disabled={!tab.alwaysEnabled && !hasCompletedExtraction}
sx={{
opacity: (!tab.alwaysEnabled && !hasCompletedExtraction) ? 0.5 : 1,
opacity: !tab.alwaysEnabled && !hasCompletedExtraction ? 0.5 : 1,
'&.Mui-disabled': {
color: 'text.disabled'
}
color: 'text.disabled',
},
}}
/>
))}

View File

@ -17,7 +17,8 @@
box-sizing: border-box;
}
html, body {
html,
body {
margin: 0;
padding: 0;
min-height: 100vh;

View File

@ -1,4 +1,4 @@
import React, { lazy, Suspense } from 'react'
import { lazy, Suspense } from 'react'
import { createBrowserRouter, RouterProvider } from 'react-router-dom'
import { Box, CircularProgress, Typography } from '@mui/material'
@ -15,10 +15,38 @@ const LoadingFallback = () => (
)
const router = createBrowserRouter([
{ path: '/', element: <Suspense fallback={<LoadingFallback />}><UploadView /></Suspense> },
{ path: '/extraction', element: <Suspense fallback={<LoadingFallback />}><ExtractionView /></Suspense> },
{ path: '/contexte', element: <Suspense fallback={<LoadingFallback />}><ContexteView /></Suspense> },
{ path: '/conseil', element: <Suspense fallback={<LoadingFallback />}><ConseilView /></Suspense> },
{
path: '/',
element: (
<Suspense fallback={<LoadingFallback />}>
<UploadView />
</Suspense>
),
},
{
path: '/extraction',
element: (
<Suspense fallback={<LoadingFallback />}>
<ExtractionView />
</Suspense>
),
},
{
path: '/contexte',
element: (
<Suspense fallback={<LoadingFallback />}>
<ContexteView />
</Suspense>
),
},
{
path: '/conseil',
element: (
<Suspense fallback={<LoadingFallback />}>
<ConseilView />
</Suspense>
),
},
])
export const AppRouter = () => {

View File

@ -1,6 +1,12 @@
import axios from 'axios'
import { openaiDocumentApi, openaiExternalApi } from './openai'
import type { Document, ExtractionResult, AnalysisResult, ContextResult, ConseilResult } from '../types'
import type {
Document,
ExtractionResult,
AnalysisResult,
ContextResult,
ConseilResult,
} from '../types'
const BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'
const USE_OPENAI = import.meta.env.VITE_USE_OPENAI === 'true'
@ -11,7 +17,14 @@ if (import.meta.env.DEV) {
.toString()
.replace(/.(?=.{4})/g, '*')
// eslint-disable-next-line no-console
console.info('[ENV] VITE_API_URL=', BASE_URL, 'VITE_USE_AI=', USE_OPENAI, 'VITE_AI_API_KEY=', maskedKey)
console.info(
'[ENV] VITE_API_URL=',
BASE_URL,
'VITE_USE_AI=',
USE_OPENAI,
'VITE_AI_API_KEY=',
maskedKey,
)
}
export const apiClient = axios.create({
@ -25,7 +38,7 @@ apiClient.interceptors.response.use(
(error) => {
// Laisser remonter les erreurs au consommateur
return Promise.reject(error)
}
},
)
// Services API pour les documents
@ -51,7 +64,7 @@ export const documentApi = {
size: data.document.fileSize || file.size,
uploadDate: new Date(data.document.uploadTimestamp || Date.now()),
status: 'completed',
previewUrl: fileUrl
previewUrl: fileUrl,
}
// Adapter le résultat d'extraction au format attendu
@ -62,9 +75,14 @@ export const documentApi = {
text: data.extraction.text.raw,
identities: data.extraction.entities.persons || [],
addresses: data.extraction.entities.addresses || [],
properties: [],
contracts: [],
signatures: [],
companies: data.extraction.entities.companies || [],
language: data.classification.language,
timestamp: data.status.timestamp
// Métadonnées complètes
metadata: data.metadata,
status: data.status,
}
return { document, extraction }
@ -86,7 +104,8 @@ export const documentApi = {
text: results.ocr_text || 'Texte extrait du document...',
language: 'fr',
documentType: results.document_type || 'Document',
identities: results.entities?.persons?.map((name: string, index: number) => ({
identities:
results.entities?.persons?.map((name: string, index: number) => ({
id: `person-${index}`,
type: 'person' as const,
firstName: name.split(' ')[0] || name,
@ -95,13 +114,15 @@ export const documentApi = {
nationality: 'Française',
confidence: 0.9,
})) || [],
addresses: results.entities?.addresses?.map((address: string) => ({
addresses:
results.entities?.addresses?.map((address: string) => ({
street: address,
city: 'Paris',
postalCode: '75001',
country: 'France',
})) || [],
properties: results.entities?.properties?.map((_propertyName: string, index: number) => ({
properties:
results.entities?.properties?.map((_propertyName: string, index: number) => ({
id: `prop-${index}`,
type: 'apartment' as const,
address: {

View File

@ -38,19 +38,21 @@ export interface BackendExtractionResult {
timestamp: string
}
/**
* Extrait le texte et les entités d'un document via le backend
*/
export async function extractDocumentBackend(
_documentId: string,
file?: File,
hooks?: { onOcrProgress?: (progress: number) => void; onLlmProgress?: (progress: number) => void }
hooks?: {
onOcrProgress?: (progress: number) => void
onLlmProgress?: (progress: number) => void
},
): Promise<ExtractionResult> {
console.log('🚀 [BACKEND] Début de l\'extraction via le backend...')
console.log("🚀 [BACKEND] Début de l'extraction via le backend...")
if (!file) {
throw new Error('Aucun fichier fourni pour l\'extraction')
throw new Error("Aucun fichier fourni pour l'extraction")
}
// Simuler la progression OCR
@ -65,7 +67,7 @@ export async function extractDocumentBackend(
try {
const response = await fetch(`${BACKEND_URL}/api/extract`, {
method: 'POST',
body: formData
body: formData,
})
if (!response.ok) {
@ -88,30 +90,31 @@ export async function extractDocumentBackend(
language: result.classification.language,
documentType: result.classification.documentType,
identities: result.extraction.entities.persons.map((person: any) => ({
id: person.id,
type: 'person',
firstName: person.firstName,
lastName: person.lastName,
confidence: person.confidence || 0.9
id: person.id || `person-${Date.now()}`,
type: 'person' as const,
firstName: person.firstName || '',
lastName: person.lastName || '',
confidence: person.confidence || 0.9,
})),
addresses: result.extraction.entities.addresses.map((address: any) => ({
id: address.id,
street: address.street,
city: address.city,
postalCode: address.postalCode,
street: address.street || '',
city: address.city || '',
postalCode: address.postalCode || '',
country: address.country || 'France',
confidence: address.confidence || 0.9
})),
properties: [],
contracts: [],
signatures: result.extraction.entities.contractual?.signatures?.map((sig: any) => sig.signatory || 'Signature détectée') || [],
signatures:
result.extraction.entities.contractual?.signatures?.map(
(sig: any) => sig.signatory || 'Signature détectée',
) || [],
confidence: result.metadata.quality.globalConfidence,
confidenceReasons: [
`OCR: ${Math.round(result.metadata.quality.textExtractionConfidence * 100)}% de confiance`,
`Texte extrait: ${result.extraction.text.characterCount} caractères`,
`Entités trouvées: ${result.extraction.entities.persons.length} personnes, ${result.extraction.entities.companies.length} sociétés, ${result.extraction.entities.addresses.length} adresses`,
`Type détecté: ${result.classification.documentType}`,
`Traitement backend: ${result.document.uploadTimestamp}`
`Traitement backend: ${result.document.uploadTimestamp}`,
],
// Nouveaux champs du format standard
companies: result.extraction.entities.companies || [],
@ -121,7 +124,7 @@ export async function extractDocumentBackend(
references: result.extraction.entities.references || [],
// Métadonnées complètes
metadata: result.metadata,
status: result.status
status: result.status,
}
// Extraction terminée
@ -130,18 +133,16 @@ export async function extractDocumentBackend(
documentType: extractionResult.documentType,
identitiesCount: extractionResult.identities.length,
addressesCount: extractionResult.addresses.length,
confidence: extractionResult.confidence
confidence: extractionResult.confidence,
})
return extractionResult
} catch (error) {
console.error('❌ [BACKEND] Erreur lors de l\'extraction:', error)
console.error("❌ [BACKEND] Erreur lors de l'extraction:", error)
throw error
}
}
// Cache pour le health check
let backendHealthCache: { isHealthy: boolean; timestamp: number } | null = null
const HEALTH_CHECK_CACHE_DURATION = 5000 // 5 secondes
@ -153,7 +154,7 @@ export async function checkBackendHealth(): Promise<boolean> {
const now = Date.now()
// Utiliser le cache si disponible et récent
if (backendHealthCache && (now - backendHealthCache.timestamp) < HEALTH_CHECK_CACHE_DURATION) {
if (backendHealthCache && now - backendHealthCache.timestamp < HEALTH_CHECK_CACHE_DURATION) {
return backendHealthCache.isHealthy
}
@ -189,7 +190,7 @@ export const backendDocumentApi = {
isCNI: false,
credibilityScore: 0.8,
summary: 'Analyse en cours...',
recommendations: []
recommendations: [],
}
},
getContext: async (documentId: string): Promise<ContextResult> => {
@ -197,7 +198,7 @@ export const backendDocumentApi = {
documentId,
lastUpdated: new Date(),
georisquesData: {},
cadastreData: {}
cadastreData: {},
}
},
getConseil: async (documentId: string): Promise<ConseilResult> => {
@ -207,7 +208,7 @@ export const backendDocumentApi = {
recommendations: [],
risks: [],
nextSteps: [],
generatedAt: new Date()
}
generatedAt: new Date(),
}
},
}

View File

@ -24,7 +24,10 @@ export async function extractTextFromFile(file: File): Promise<string> {
}
return pdfText
}
if (mime.startsWith('image/') || ['.png', '.jpg', '.jpeg'].some((ext) => file.name.toLowerCase().endsWith(ext))) {
if (
mime.startsWith('image/') ||
['.png', '.jpg', '.jpeg'].some((ext) => file.name.toLowerCase().endsWith(ext))
) {
const imgText = await extractFromImage(file)
if (import.meta.env.DEV) {
// eslint-disable-next-line no-console
@ -62,8 +65,12 @@ async function extractFromPdf(file: File): Promise<string> {
canvas.height = viewport.height
const ctx = canvas.getContext('2d') as any
await page.render({ canvasContext: ctx, viewport }).promise
const blob: Blob = await new Promise((resolve) => canvas.toBlob((b) => resolve(b as Blob), 'image/png'))
const ocrText = await extractFromImage(new File([blob], `${file.name}-p${i}.png`, { type: 'image/png' }))
const blob: Blob = await new Promise((resolve) =>
canvas.toBlob((b) => resolve(b as Blob), 'image/png'),
)
const ocrText = await extractFromImage(
new File([blob], `${file.name}-p${i}.png`, { type: 'image/png' }),
)
pageText = ocrText
}
if (pageText.trim()) texts.push(pageText)
@ -92,7 +99,9 @@ async function extractFromImage(file: File): Promise<string> {
const imgData = ctx.getImageData(0, 0, canvas.width, canvas.height)
const data = imgData.data
for (let i = 0; i < data.length; i += 4) {
const r = data[i], g = data[i + 1], b = data[i + 2]
const r = data[i],
g = data[i + 1],
b = data[i + 2]
// luma
let y = 0.299 * r + 0.587 * g + 0.114 * b
// contraste simple
@ -120,7 +129,8 @@ async function extractFromImage(file: File): Promise<string> {
// Configuration optimisée pour les images de petite taille
const params = {
tessedit_pageseg_mode: psm,
tessedit_char_whitelist: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõöøùúûüýþÿ.,/-:() ',
tessedit_char_whitelist:
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõöøùúûüýþÿ.,/-:() ',
tessedit_ocr_engine_mode: '1', // LSTM OCR Engine
preserve_interword_spaces: '1',
textord_min_linesize: '2.0', // Réduit la taille minimale des lignes
@ -128,7 +138,7 @@ async function extractFromImage(file: File): Promise<string> {
classify_bln_numeric_mode: '0',
textord_heavy_nr: '1',
textord_old_baselines: '0',
textord_old_xheight: '0'
textord_old_xheight: '0',
}
// @ts-expect-error - tessedit_pageseg_mode expects PSM enum, but string is used
@ -145,7 +155,9 @@ async function extractFromImage(file: File): Promise<string> {
const confidence = Math.max(0, data.confidence || 0)
const score = confidence * Math.log(len + 1) * (len > 10 ? 1.2 : 0.8)
console.log(`[OCR] PSM:${psm} Rot:${deg}° Conf:${confidence.toFixed(1)}% Len:${len} Score:${score.toFixed(2)}`)
console.log(
`[OCR] PSM:${psm} Rot:${deg}° Conf:${confidence.toFixed(1)}% Len:${len} Score:${score.toFixed(2)}`,
)
if (score > bestScore) {
bestScore = score
@ -159,7 +171,10 @@ async function extractFromImage(file: File): Promise<string> {
break
}
} catch (error) {
console.warn(`[OCR] Erreur PSM:${psm} Rot:${deg}°:`, error instanceof Error ? error.message : String(error))
console.warn(
`[OCR] Erreur PSM:${psm} Rot:${deg}°:`,
error instanceof Error ? error.message : String(error),
)
}
}

View File

@ -2,7 +2,7 @@
* API pour la gestion des dossiers par hash
*/
const API_BASE_URL = 'http://172.17.222.203:3001/api'
const API_BASE_URL = '/api'
export interface FolderResult {
fileHash: string
@ -96,7 +96,7 @@ export async function getDefaultFolder(): Promise<CreateFolderResponse> {
return {
success: true,
folderHash: '7d99a85daf66a0081a0e881630e6b39b',
message: 'Dossier par défaut récupéré'
message: 'Dossier par défaut récupéré',
}
}
@ -120,9 +120,9 @@ export async function getFolderResults(folderHash: string): Promise<FolderRespon
const response = await fetch(url, {
signal: controller.signal,
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
}
Accept: 'application/json',
'Content-Type': 'application/json',
},
})
clearTimeout(timeoutId)
@ -131,7 +131,9 @@ export async function getFolderResults(folderHash: string): Promise<FolderRespon
if (!response.ok) {
console.error(`[API] Erreur HTTP:`, response.status, response.statusText)
throw new Error(`Erreur lors de la récupération des résultats du dossier: ${response.statusText}`)
throw new Error(
`Erreur lors de la récupération des résultats du dossier: ${response.statusText}`,
)
}
console.log(`[API] Début du parsing JSON...`)
@ -141,7 +143,7 @@ export async function getFolderResults(folderHash: string): Promise<FolderRespon
return data
} catch (error) {
if (error.name === 'AbortError') {
if (error instanceof Error && error.name === 'AbortError') {
console.error(`[API] Requête annulée (timeout)`)
throw new Error('Timeout: La requête a pris trop de temps')
}

View File

@ -16,7 +16,9 @@ const OPENAI_API_KEY = import.meta.env.VITE_OPENAI_API_KEY
const OPENAI_BASE_URL = import.meta.env.VITE_OPENAI_BASE_URL || 'https://api.openai.com/v1'
const OPENAI_CHAT_MODEL = import.meta.env.VITE_OPENAI_MODEL || 'gpt-4o-mini'
async function callOpenAIChat(messages: Array<{ role: 'system' | 'user' | 'assistant'; content: string }>): Promise<string> {
async function callOpenAIChat(
messages: Array<{ role: 'system' | 'user' | 'assistant'; content: string }>,
): Promise<string> {
if (!OPENAI_API_KEY) {
throw new Error('Clé API OpenAI manquante (VITE_AI_API_KEY)')
}
@ -73,7 +75,11 @@ export const openaiDocumentApi = {
}
},
extract: async (documentId: string, file?: File, hooks?: ProgressHooks): Promise<ExtractionResult> => {
extract: async (
documentId: string,
file?: File,
hooks?: ProgressHooks,
): Promise<ExtractionResult> => {
let localText = ''
if (file) {
try {
@ -93,13 +99,13 @@ export const openaiDocumentApi = {
useRuleNer,
classifyOnly,
disableLLM,
hasOpenAIKey: !!OPENAI_API_KEY
hasOpenAIKey: !!OPENAI_API_KEY,
})
// Si NER local actif, on l'utilise pour tout (identités/adresses/...) puis, si demandé,
// on peut consulter le LLM uniquement pour classifier le type de document
if (useRuleNer) {
console.log('🚀 [OCR] Début de l\'extraction OCR locale...')
console.log("🚀 [OCR] Début de l'extraction OCR locale...")
console.log('📄 [OCR] Texte à traiter:', localText.substring(0, 200) + '...')
// Simuler la progression OCR de manière asynchrone pour éviter les boucles
@ -120,13 +126,13 @@ export const openaiDocumentApi = {
}, 300)
}
console.log('🔍 [NER] Début de l\'extraction par règles...')
console.log("🔍 [NER] Début de l'extraction par règles...")
let res = runRuleNER(documentId, localText)
console.log('📊 [NER] Résultats extraits:', {
documentType: res.documentType,
identitiesCount: res.identities.length,
addressesCount: res.addresses.length,
confidence: res.confidence
confidence: res.confidence,
})
if (classifyOnly && OPENAI_API_KEY && localText && !disableLLM) {
@ -134,13 +140,22 @@ export const openaiDocumentApi = {
try {
hooks?.onLlmProgress?.(0)
const cls = await callOpenAIChat([
{ role: 'system', content: 'Tu es un classifieur. Retourne uniquement un JSON strict.' },
{ role: 'user', content: `Classifie ce texte en une des catégories suivantes: [CNI, Facture, Attestation, Document]. Réponds strictement sous la forme {"documentType":"..."}.\nTexte:\n${localText.slice(0, 8000)}` },
{
role: 'system',
content: 'Tu es un classifieur. Retourne uniquement un JSON strict.',
},
{
role: 'user',
content: `Classifie ce texte en une des catégories suivantes: [CNI, Facture, Attestation, Document]. Réponds strictement sous la forme {"documentType":"..."}.\nTexte:\n${localText.slice(0, 8000)}`,
},
])
const parsed = JSON.parse(cls)
if (parsed && typeof parsed.documentType === 'string') {
res = { ...res, documentType: parsed.documentType }
res.confidenceReasons = [...(res.confidenceReasons || []), 'Classification LLM limitée au documentType']
res.confidenceReasons = [
...(res.confidenceReasons || []),
'Classification LLM limitée au documentType',
]
console.log('✅ [LLM] Classification LLM terminée:', parsed.documentType)
}
hooks?.onLlmProgress?.(1)
@ -175,7 +190,10 @@ export const openaiDocumentApi = {
try {
const cls = await callOpenAIChat([
{ role: 'system', content: 'Tu es un classifieur. Retourne uniquement un JSON strict.' },
{ role: 'user', content: `Classifie ce texte en une des catégories suivantes: [CNI, Facture, Attestation, Document]. Réponds strictement sous la forme {"documentType":"..."}.\nTexte:\n${localText.slice(0, 8000)}` },
{
role: 'user',
content: `Classifie ce texte en une des catégories suivantes: [CNI, Facture, Attestation, Document]. Réponds strictement sous la forme {"documentType":"..."}.\nTexte:\n${localText.slice(0, 8000)}`,
},
])
const parsed = JSON.parse(cls)
hooks?.onLlmProgress?.(1)
@ -190,7 +208,7 @@ export const openaiDocumentApi = {
contracts: [],
signatures: [],
confidence: 0.6,
confidenceReasons: ['Classification LLM sans contexte, pas d\'extraction d\'identités'],
confidenceReasons: ["Classification LLM sans contexte, pas d'extraction d'identités"],
}
} catch {
hooks?.onLlmProgress?.(1)
@ -214,7 +232,7 @@ export const openaiDocumentApi = {
{
role: 'system',
content:
'Tu extrais uniquement les informations présentes dans le texte OCR. Interdiction d\'inventer. Interdiction d\'utiliser le nom du fichier comme identité. Réponds en JSON strict, sans texte autour.',
"Tu extrais uniquement les informations présentes dans le texte OCR. Interdiction d'inventer. Interdiction d'utiliser le nom du fichier comme identité. Réponds en JSON strict, sans texte autour.",
},
{
role: 'user',
@ -229,7 +247,7 @@ export const openaiDocumentApi = {
const docBase = (file?.name || '').toLowerCase().replace(/\.[a-z0-9]+$/, '')
const safeIdentities = (parsed.identities || []).filter((it: any) => {
const full = `${it.firstName || ''} ${it.lastName || ''}`.trim().toLowerCase()
return full && !docBase || (full && !docBase.includes(full) && !full.includes(docBase))
return (full && !docBase) || (full && !docBase.includes(full) && !full.includes(docBase))
})
return {
@ -242,7 +260,8 @@ export const openaiDocumentApi = {
properties: parsed.properties || [],
contracts: parsed.contracts || [],
signatures: parsed.signatures || [],
confidence: Math.round((typeof parsed.confidence === 'number' ? parsed.confidence : 0.7) * 100) / 100,
confidence:
Math.round((typeof parsed.confidence === 'number' ? parsed.confidence : 0.7) * 100) / 100,
confidenceReasons: parsed.confidenceReasons || [],
}
} catch {
@ -279,7 +298,10 @@ export const openaiDocumentApi = {
analyze: async (documentId: string): Promise<AnalysisResult> => {
const result = await callOpenAIChat([
{ role: 'system', content: 'Tu fournis une analyse brève et des risques potentiels.' },
{ role: 'user', content: `Analyse le document ${documentId} et fournis un résumé des risques.` },
{
role: 'user',
content: `Analyse le document ${documentId} et fournis un résumé des risques.`,
},
])
const isCNI = /cni|carte\s+nationale\s+d'identité/i.test(result || '')
const number = (result || '').match(/[A-Z0-9]{12,}/)?.[0] || ''
@ -290,9 +312,7 @@ export const openaiDocumentApi = {
documentId,
documentType: isCNI ? 'CNI' : 'Document',
isCNI,
verificationResult: isCNI
? { numberValid, formatValid, checksumValid }
: undefined,
verificationResult: isCNI ? { numberValid, formatValid, checksumValid } : undefined,
credibilityScore: isCNI ? (numberValid ? 0.8 : 0.6) : 0.6,
summary: result || 'Analyse indisponible.',
recommendations: [],
@ -314,7 +334,14 @@ export const openaiDocumentApi = {
{ role: 'system', content: 'Tu fournis des conseils opérationnels courts et concrets.' },
{ role: 'user', content: `Donne 3 conseils actionnables pour le document ${documentId}.` },
])
return { documentId, analysis: conseil || '', recommendations: conseil ? [conseil] : [], risks: [], nextSteps: [], generatedAt: new Date() }
return {
documentId,
analysis: conseil || '',
recommendations: conseil ? [conseil] : [],
risks: [],
nextSteps: [],
generatedAt: new Date(),
}
},
detectType: async (_file: File): Promise<{ type: string; confidence: number }> => {
@ -324,7 +351,9 @@ export const openaiDocumentApi = {
export const openaiExternalApi = {
cadastre: async (_address: string) => ({ note: 'Mode OpenAI: contexte non connecté' }),
georisques: async (_coordinates: { lat: number; lng: number }) => ({ note: 'Mode OpenAI: contexte non connecté' }),
georisques: async (_coordinates: { lat: number; lng: number }) => ({
note: 'Mode OpenAI: contexte non connecté',
}),
geofoncier: async (_address: string) => ({ note: 'Mode OpenAI: contexte non connecté' }),
bodacc: async (_companyName: string) => ({ note: 'Mode OpenAI: contexte non connecté' }),
infogreffe: async (_siren: string) => ({ note: 'Mode OpenAI: contexte non connecté' }),
@ -334,5 +363,5 @@ function pseudoChecksum(input: string): boolean {
if (!input) return false
// checksum simple: somme des codes char modulo 10 doit être pair
const sum = Array.from(input).reduce((acc, ch) => acc + ch.charCodeAt(0), 0)
return sum % 10 % 2 === 0
return (sum % 10) % 2 === 0
}

View File

@ -20,7 +20,8 @@ function extractMRZ(text: string): { firstName?: string; lastName?: string } | n
if (parts.length >= 2) {
const rawLast = parts[0].replace(/<+/g, ' ').trim()
const rawFirst = parts[1].replace(/<+/g, ' ').trim()
if (rawLast && rawFirst) return { firstName: toTitleCase(rawFirst), lastName: rawLast.replace(/\s+/g, ' ') }
if (rawLast && rawFirst)
return { firstName: toTitleCase(rawFirst), lastName: rawLast.replace(/\s+/g, ' ') }
}
}
}
@ -56,7 +57,7 @@ function extractAddresses(text: string): Address[] {
// "demeurant 123 Rue de la Paix, 75001 Paris"
/demeurant\s+(\d{1,4})\s+([A-Za-zÀ-ÖØ-öø-ÿ\s\-']+?),\s*(\d{5})\s+([A-Za-zÀ-ÖØ-öø-ÿ\s\-']+)/gi,
// "situé 123 Rue de la Paix, 75001 Paris"
/situé\s+(\d{1,4})\s+([A-Za-zÀ-ÖØ-öø-ÿ\s\-']+?),\s*(\d{5})\s+([A-Za-zÀ-ÖØ-öø-ÿ\s\-']+)/gi
/situé\s+(\d{1,4})\s+([A-Za-zÀ-ÖØ-öø-ÿ\s\-']+?),\s*(\d{5})\s+([A-Za-zÀ-ÖØ-öø-ÿ\s\-']+)/gi,
]
for (const pattern of addressPatterns) {
@ -69,7 +70,7 @@ function extractAddresses(text: string): Address[] {
street,
city,
postalCode,
country: 'France'
country: 'France',
})
}
}
@ -81,7 +82,8 @@ function extractNames(text: string): Identity[] {
const identities: Identity[] = []
// Pattern pour "Vendeur : Prénom Nom" ou "Acheteur : Prénom Nom"
const rolePattern = /(Vendeur|Acheteur|Vendeuse|Acheteuse|Propriétaire|Locataire|Bailleur|Preneur)\s*:\s*([A-Z][a-zà-öø-ÿ'\-]+\s+[A-Z][a-zà-öø-ÿ'\-]+)/gi
const rolePattern =
/(Vendeur|Acheteur|Vendeuse|Acheteuse|Propriétaire|Locataire|Bailleur|Preneur)\s*:\s*([A-Z][a-zà-öø-ÿ'\-]+\s+[A-Z][a-zà-öø-ÿ'\-]+)/gi
for (const match of text.matchAll(rolePattern)) {
const fullName = match[2].trim()
@ -110,13 +112,18 @@ function extractNames(text: string): Identity[] {
// Fallback: heuristique lignes en MAJUSCULES pour NOM
if (identities.length === 0) {
const lines = text.split(/\n|\r/).map((l) => l.trim()).filter(Boolean)
const lines = text
.split(/\n|\r/)
.map((l) => l.trim())
.filter(Boolean)
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i]
if (/^[A-ZÀ-ÖØ-Þ\-\s]{3,}$/.test(line) && line.length <= 40) {
const lastName = line.replace(/\s+/g, ' ').trim()
const cand = (lines[i + 1] || '').trim()
const firstNameMatch = cand.match(/^[A-Z][a-zà-öø-ÿ'\-]{1,}(?:\s+[A-Z][a-zà-öø-ÿ'\-]{1,})?$/)
const firstNameMatch = cand.match(
/^[A-Z][a-zà-öø-ÿ'\-]{1,}(?:\s+[A-Z][a-zà-öø-ÿ'\-]{1,})?$/,
)
const firstName = firstNameMatch ? cand : undefined
if (lastName && (!firstName || firstName.length <= 40)) {
identities.push({
@ -135,7 +142,7 @@ function extractNames(text: string): Identity[] {
}
export function runRuleNER(documentId: string, text: string): ExtractionResult {
console.log('🔍 [RULE-NER] Début de l\'analyse du texte...')
console.log("🔍 [RULE-NER] Début de l'analyse du texte...")
console.log('📄 [RULE-NER] Longueur du texte:', text.length)
const identitiesFromMRZ = extractMRZ(text)

View File

@ -0,0 +1,108 @@
/**
* API pour la gestion des fichiers de test
* Fournit des fichiers de test pour les démonstrations et tests
*/
export interface TestFileInfo {
name: string
size: number
type: string
lastModified: number
}
// Liste des fichiers de test disponibles
const TEST_FILES = [
'IMG_20250902_162159.jpg',
'IMG_20250902_162210.jpg',
'sample.md',
'sample.pdf',
'sample.txt',
]
// Types de fichiers supportés
const SUPPORTED_MIME_TYPES = [
'application/pdf',
'image/jpeg',
'image/jpg',
'image/png',
'text/plain',
'text/markdown',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
]
const SUPPORTED_EXTENSIONS = ['.pdf', '.jpg', '.jpeg', '.png', '.txt', '.md', '.docx']
/**
* Récupère la liste des fichiers de test disponibles
*/
export async function getTestFilesList(): Promise<TestFileInfo[]> {
const results: TestFileInfo[] = []
for (const fileName of TEST_FILES) {
try {
const response = await fetch(`/test-files/${fileName}`)
if (response.ok) {
const contentLength = response.headers.get('content-length')
const contentType = response.headers.get('content-type')
results.push({
name: fileName,
size: contentLength ? parseInt(contentLength, 10) : 0,
type: contentType || 'application/octet-stream',
lastModified: Date.now(),
})
}
} catch (error) {
console.warn(`Fichier de test non trouvé: ${fileName}`, error)
}
}
return results
}
/**
* Charge un fichier de test spécifique
*/
export async function loadTestFile(fileName: string): Promise<File | null> {
try {
const response = await fetch(`/test-files/${fileName}`)
if (!response.ok) {
return null
}
const blob = await response.blob()
return new File([blob], fileName, { type: blob.type })
} catch (error) {
console.error(`Erreur lors du chargement du fichier de test: ${fileName}`, error)
return null
}
}
/**
* Filtre les fichiers supportés par type MIME et extension
*/
export function filterSupportedFiles(files: TestFileInfo[]): TestFileInfo[] {
return files.filter((file) => {
// Vérifier par type MIME
if (SUPPORTED_MIME_TYPES.includes(file.type)) {
return true
}
// Vérifier par extension si le type MIME est inconnu
const extension = file.name.toLowerCase().substring(file.name.lastIndexOf('.'))
return SUPPORTED_EXTENSIONS.includes(extension)
})
}
/**
* Vérifie si un fichier est supporté
*/
export function isFileSupported(file: TestFileInfo): boolean {
return filterSupportedFiles([file]).length > 0
}

View File

@ -15,5 +15,3 @@ const appSlice = createSlice({
})
export const appReducer = appSlice.reducer

View File

@ -1,10 +1,21 @@
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'
import type { PayloadAction } from '@reduxjs/toolkit'
import type { Document, ExtractionResult, AnalysisResult, ContextResult, ConseilResult } from '../types'
import type {
Document,
ExtractionResult,
AnalysisResult,
ContextResult,
ConseilResult,
} from '../types'
import { documentApi } from '../services/api'
import { openaiDocumentApi } from '../services/openai'
import { backendDocumentApi, checkBackendHealth } from '../services/backendApi'
import { createDefaultFolder, getDefaultFolder, getFolderResults, uploadFileToFolder, type FolderResult } from '../services/folderApi'
import {
getDefaultFolder,
getFolderResults,
uploadFileToFolder,
type FolderResult,
} from '../services/folderApi'
interface DocumentState {
documents: Document[]
@ -42,7 +53,7 @@ const loadStateFromStorage = (): Partial<DocumentState> => {
const parsed = JSON.parse(savedState)
console.log('💾 [STORE] État chargé depuis localStorage:', {
documentsCount: parsed.documents?.length || 0,
extractionsCount: Object.keys(parsed.extractionById || {}).length
extractionsCount: Object.keys(parsed.extractionById || {}).length,
})
return parsed
}
@ -52,20 +63,6 @@ const loadStateFromStorage = (): Partial<DocumentState> => {
return {}
}
// Fonction pour sauvegarder l'état dans localStorage
const saveStateToStorage = (state: DocumentState) => {
try {
const stateToSave = {
documents: state.documents,
extractionById: state.extractionById,
currentDocument: state.currentDocument
}
localStorage.setItem('4nk-ia-documents', JSON.stringify(stateToSave))
} catch (error) {
console.warn('⚠️ [STORE] Erreur lors de la sauvegarde dans localStorage:', error)
}
}
const initialState: DocumentState = {
documents: [],
currentDocument: null,
@ -87,15 +84,12 @@ const initialState: DocumentState = {
pendingFiles: [],
hasPending: false,
pollingInterval: null,
...loadStateFromStorage()
...loadStateFromStorage(),
}
export const uploadDocument = createAsyncThunk(
'document/upload',
async (file: File) => {
export const uploadDocument = createAsyncThunk('document/upload', async (file: File) => {
return await documentApi.upload(file)
}
)
})
export const extractDocument = createAsyncThunk(
'document/extract',
@ -119,7 +113,7 @@ export const extractDocument = createAsyncThunk(
const backendAvailable = await checkBackendHealth()
if (backendAvailable) {
console.log('🚀 [STORE] Utilisation du backend pour l\'extraction')
console.log("🚀 [STORE] Utilisation du backend pour l'extraction")
// Pas de hooks de progression pour le backend - traitement direct
@ -160,36 +154,27 @@ export const extractDocument = createAsyncThunk(
}
return await documentApi.extract(documentId)
}
}
},
)
export const analyzeDocument = createAsyncThunk(
'document/analyze',
async (documentId: string) => {
export const analyzeDocument = createAsyncThunk('document/analyze', async (documentId: string) => {
return await documentApi.analyze(documentId)
}
)
})
export const getContextData = createAsyncThunk(
'document/context',
async (documentId: string) => {
export const getContextData = createAsyncThunk('document/context', async (documentId: string) => {
return await documentApi.getContext(documentId)
}
)
})
export const getConseil = createAsyncThunk(
'document/conseil',
async (documentId: string) => {
export const getConseil = createAsyncThunk('document/conseil', async (documentId: string) => {
return await documentApi.getConseil(documentId)
}
)
})
// Thunks pour la gestion des dossiers
export const createDefaultFolderThunk = createAsyncThunk(
'document/createDefaultFolder',
async () => {
return await getDefaultFolder()
}
},
)
export const loadFolderResults = createAsyncThunk(
@ -204,14 +189,14 @@ export const loadFolderResults = createAsyncThunk(
console.error(`[STORE] loadFolderResults erreur:`, error)
throw error
}
}
},
)
export const uploadFileToFolderThunk = createAsyncThunk(
'document/uploadFileToFolder',
async ({ file, folderHash }: { file: File; folderHash: string }) => {
return await uploadFileToFolder(file, folderHash)
}
},
)
const documentSlice = createSlice({
@ -256,11 +241,17 @@ const documentSlice = createSlice({
},
setOcrProgress: (state, action: PayloadAction<{ id: string; progress: number }>) => {
const { id, progress } = action.payload
state.progressById[id] = { ocr: Math.max(0, Math.min(100, Math.round(progress * 100))), llm: state.progressById[id]?.llm || 0 }
state.progressById[id] = {
ocr: Math.max(0, Math.min(100, Math.round(progress * 100))),
llm: state.progressById[id]?.llm || 0,
}
},
setLlmProgress: (state, action: PayloadAction<{ id: string; progress: number }>) => {
const { id, progress } = action.payload
state.progressById[id] = { ocr: state.progressById[id]?.ocr || 0, llm: Math.max(0, Math.min(100, Math.round(progress * 100))) }
state.progressById[id] = {
ocr: state.progressById[id]?.ocr || 0,
llm: Math.max(0, Math.min(100, Math.round(progress * 100))),
}
},
setBootstrapped: (state, action: PayloadAction<boolean>) => {
state.bootstrapped = action.payload
@ -277,12 +268,17 @@ const documentSlice = createSlice({
state.currentResultIndex = 0
},
// Reducers pour le système de pending
setPendingFiles: (state, action: PayloadAction<Array<{
setPendingFiles: (
state,
action: PayloadAction<
Array<{
fileHash: string
folderHash: string
timestamp: string
status: string
}>>) => {
}>
>,
) => {
state.pendingFiles = action.payload
state.hasPending = action.payload.length > 0
},
@ -312,7 +308,7 @@ const documentSlice = createSlice({
documentId: document.id,
documentName: document.name,
hasExtraction: !!extraction,
extractionDocumentId: extraction?.documentId
extractionDocumentId: extraction?.documentId,
})
state.documents.push(document)
@ -350,7 +346,7 @@ const documentSlice = createSlice({
})
.addCase(extractDocument.rejected, (state, action) => {
state.loading = false
state.error = action.error.message || 'Erreur lors de l\'extraction'
state.error = action.error.message || "Erreur lors de l'extraction"
// Mettre à jour le statut du document courant en cas d'erreur
if (state.currentDocument) {
state.currentDocument.status = 'error'
@ -391,11 +387,11 @@ const documentSlice = createSlice({
// Convertir les résultats en documents pour la compatibilité
if (action.payload.results && action.payload.results.length > 0) {
state.documents = action.payload.results.map((result, index) => {
console.log(`[STORE] Mapping résultat ${index}:`, {
state.documents = action.payload.results.map((result) => {
console.log(`[STORE] Mapping résultat:`, {
fileHash: result.fileHash,
fileName: result.document?.fileName,
mimeType: result.document?.mimeType
mimeType: result.document?.mimeType,
})
return {
@ -405,7 +401,7 @@ const documentSlice = createSlice({
size: 0, // Taille non disponible dans la structure actuelle
uploadDate: new Date(result.document.uploadTimestamp),
status: 'completed' as const,
previewUrl: `http://172.17.222.203:3001/api/folders/${action.payload.folderHash}/files/${result.fileHash}`
previewUrl: `/api/folders/${action.payload.folderHash}/files/${result.fileHash}`,
}
})
} else {
@ -413,11 +409,16 @@ const documentSlice = createSlice({
state.documents = []
}
console.log(`[STORE] Dossier chargé: ${action.payload.results.length} résultats, ${action.payload.pending?.length || 0} pending`)
console.log(
`[STORE] Dossier chargé: ${action.payload.results.length} résultats, ${action.payload.pending?.length || 0} pending`,
)
console.log(`[STORE] Documents finaux:`, state.documents.length)
console.log(`[STORE] Documents mappés:`, state.documents.map(d => ({ id: d.id, name: d.name, status: d.status })))
console.log(
`[STORE] Documents mappés:`,
state.documents.map((d) => ({ id: d.id, name: d.name, status: d.status })),
)
})
.addCase(loadFolderResults.pending, (state) => {
.addCase(loadFolderResults.pending, () => {
// Ne pas afficher la barre de progression pour le chargement initial des résultats
// state.loading = true
})
@ -425,7 +426,7 @@ const documentSlice = createSlice({
state.loading = false
state.error = action.error.message || 'Erreur lors du chargement des résultats du dossier'
})
.addCase(uploadFileToFolderThunk.fulfilled, (state, action) => {
.addCase(uploadFileToFolderThunk.fulfilled, (state) => {
// Recharger les résultats du dossier après upload
state.loading = false
})
@ -434,7 +435,7 @@ const documentSlice = createSlice({
})
.addCase(uploadFileToFolderThunk.rejected, (state, action) => {
state.loading = false
state.error = action.error.message || 'Erreur lors de l\'upload du fichier'
state.error = action.error.message || "Erreur lors de l'upload du fichier"
})
},
})
@ -452,6 +453,6 @@ export const {
clearFolderResults,
setPendingFiles,
setPollingInterval,
stopPolling
stopPolling,
} = documentSlice.actions
export const documentReducer = documentSlice.reducer

View File

@ -5,7 +5,10 @@ import { appReducer } from './appSlice'
import { documentReducer } from './documentSlice'
// Middleware pour sauvegarder l'état dans localStorage
const persistenceMiddleware = (store: any) => (next: any) => (action: any) => {
const persistenceMiddleware =
(store: { getState: () => { document: any } }) =>
(next: (action: any) => any) =>
(action: any) => {
const result = next(action)
// Sauvegarder seulement les actions liées aux documents
@ -15,7 +18,7 @@ const persistenceMiddleware = (store: any) => (next: any) => (action: any) => {
const stateToSave = {
documents: state.document.documents,
extractionById: state.document.extractionById,
currentDocument: state.document.currentDocument
currentDocument: state.document.currentDocument,
}
localStorage.setItem('4nk-ia-documents', JSON.stringify(stateToSave))
} catch (error) {
@ -24,14 +27,15 @@ const persistenceMiddleware = (store: any) => (next: any) => (action: any) => {
}
return result
}
}
export const store = configureStore({
reducer: {
app: appReducer,
document: documentReducer,
},
middleware: (getDefaultMiddleware) => getDefaultMiddleware({
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: false,
immutableCheck: true,
}).concat(persistenceMiddleware),

View File

@ -33,7 +33,7 @@ import { Layout } from '../components/Layout'
export default function ConseilView() {
const dispatch = useAppDispatch()
const { currentDocument, conseilResult, analysisResult, loading } = useAppSelector(
(state) => state.document
(state) => state.document,
)
useEffect(() => {
@ -50,9 +50,7 @@ export default function ConseilView() {
if (!currentDocument) {
return (
<Layout>
<Alert severity="info">
Veuillez d'abord téléverser et sélectionner un document.
</Alert>
<Alert severity="info">Veuillez d'abord téléverser et sélectionner un document.</Alert>
</Layout>
)
}
@ -71,9 +69,7 @@ export default function ConseilView() {
if (!conseilResult) {
return (
<Layout>
<Alert severity="warning">
Aucun conseil disponible.
</Alert>
<Alert severity="warning">Aucun conseil disponible.</Alert>
</Layout>
)
}
@ -125,7 +121,9 @@ export default function ConseilView() {
<LinearProgress
variant="determinate"
value={analysisResult.credibilityScore * 100}
color={getScoreColor(analysisResult.credibilityScore) as LinearProgressProps['color']}
color={
getScoreColor(analysisResult.credibilityScore) as LinearProgressProps['color']
}
sx={{ height: 10, borderRadius: 5, mb: 2 }}
/>
<Typography variant="body2" color="text.secondary">
@ -186,7 +184,10 @@ export default function ConseilView() {
<CardContent>
<Typography variant="h6" gutterBottom>
<CheckCircle sx={{ mr: 1, verticalAlign: 'middle' }} />
Recommandations ({conseilResult.recommendations.length + (analysisResult?.recommendations?.length || 0)})
Recommandations (
{conseilResult.recommendations.length +
(analysisResult?.recommendations?.length || 0)}
)
</Typography>
<List dense>
{conseilResult.recommendations.map((recommendation, index) => (
@ -228,8 +229,12 @@ export default function ConseilView() {
<ListItemText
primary={risk}
primaryTypographyProps={{
color: getRiskColor(risk) === 'error' ? 'error.main' :
getRiskColor(risk) === 'warning' ? 'warning.main' : 'info.main'
color:
getRiskColor(risk) === 'error'
? 'error.main'
: getRiskColor(risk) === 'warning'
? 'warning.main'
: 'info.main',
}}
/>
</ListItem>
@ -253,10 +258,7 @@ export default function ConseilView() {
<ListItemIcon>
<Schedule color="primary" />
</ListItemIcon>
<ListItemText
primary={`Étape ${index + 1}`}
secondary={step}
/>
<ListItemText primary={`Étape ${index + 1}`} secondary={step} />
</ListItem>
))}
</List>
@ -277,12 +279,8 @@ export default function ConseilView() {
>
Régénérer les conseils
</Button>
<Button variant="outlined">
Exporter le rapport
</Button>
<Button variant="outlined">
Partager avec l'équipe
</Button>
<Button variant="outlined">Exporter le rapport</Button>
<Button variant="outlined">Partager avec l'équipe</Button>
</Box>
</CardContent>
</Card>
@ -318,7 +316,8 @@ export default function ConseilView() {
/>
</Box>
<Typography variant="body2" color="text.secondary">
Cette analyse LLM a é générée automatiquement et doit être validée par un expert notarial.
Cette analyse LLM a é générée automatiquement et doit être validée par un expert
notarial.
</Typography>
</Paper>
</Box>

View File

@ -30,9 +30,7 @@ import { Layout } from '../components/Layout'
export default function ContexteView() {
const dispatch = useAppDispatch()
const { currentDocument, contextResult, loading } = useAppSelector(
(state) => state.document
)
const { currentDocument, contextResult, loading } = useAppSelector((state) => state.document)
useEffect(() => {
if (currentDocument && !contextResult) {
@ -43,9 +41,7 @@ export default function ContexteView() {
if (!currentDocument) {
return (
<Layout>
<Alert severity="info">
Veuillez d'abord téléverser et sélectionner un document.
</Alert>
<Alert severity="info">Veuillez d'abord téléverser et sélectionner un document.</Alert>
</Layout>
)
}
@ -64,9 +60,7 @@ export default function ContexteView() {
if (!contextResult) {
return (
<Layout>
<Alert severity="warning">
Aucune donnée contextuelle disponible.
</Alert>
<Alert severity="warning">Aucune donnée contextuelle disponible.</Alert>
</Layout>
)
}
@ -150,9 +144,7 @@ export default function ContexteView() {
</Typography>
</Box>
) : (
<Alert severity="info">
Aucune donnée cadastrale trouvée pour ce document.
</Alert>
<Alert severity="info">Aucune donnée cadastrale trouvée pour ce document.</Alert>
)}
</AccordionDetails>
</Accordion>
@ -179,9 +171,7 @@ export default function ContexteView() {
</Typography>
</Box>
) : (
<Alert severity="info">
Aucune donnée Géorisques trouvée pour ce document.
</Alert>
<Alert severity="info">Aucune donnée Géorisques trouvée pour ce document.</Alert>
)}
</AccordionDetails>
</Accordion>
@ -208,9 +198,7 @@ export default function ContexteView() {
</Typography>
</Box>
) : (
<Alert severity="info">
Aucune donnée Géofoncier trouvée pour ce document.
</Alert>
<Alert severity="info">Aucune donnée Géofoncier trouvée pour ce document.</Alert>
)}
</AccordionDetails>
</Accordion>
@ -237,9 +225,7 @@ export default function ContexteView() {
</Typography>
</Box>
) : (
<Alert severity="info">
Aucune donnée BODACC trouvée pour ce document.
</Alert>
<Alert severity="info">Aucune donnée BODACC trouvée pour ce document.</Alert>
)}
</AccordionDetails>
</Accordion>
@ -266,9 +252,7 @@ export default function ContexteView() {
</Typography>
</Box>
) : (
<Alert severity="info">
Aucune donnée Infogreffe trouvée pour ce document.
</Alert>
<Alert severity="info">Aucune donnée Infogreffe trouvée pour ce document.</Alert>
)}
</AccordionDetails>
</Accordion>
@ -287,9 +271,7 @@ export default function ContexteView() {
>
Actualiser les données
</Button>
<Button variant="outlined">
Exporter le rapport
</Button>
<Button variant="outlined">Exporter le rapport</Button>
</Box>
</CardContent>
</Card>
@ -297,4 +279,3 @@ export default function ContexteView() {
</Layout>
)
}

View File

@ -78,9 +78,7 @@ export default function ExtractionView() {
if (!currentResult) {
return (
<Layout>
<Alert severity="error">
Erreur: Résultat d'extraction non trouvé.
</Alert>
<Alert severity="error">Erreur: Résultat d'extraction non trouvé.</Alert>
</Layout>
)
}
@ -97,11 +95,7 @@ export default function ExtractionView() {
{/* Navigation */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
<IconButton
onClick={goToPrevious}
disabled={!hasPrev}
color="primary"
>
<IconButton onClick={goToPrevious} disabled={!hasPrev} color="primary">
<NavigateBefore />
</IconButton>
@ -109,11 +103,7 @@ export default function ExtractionView() {
Document {currentIndex + 1} sur {folderResults.length}
</Typography>
<IconButton
onClick={goToNext}
disabled={!hasNext}
color="primary"
>
<IconButton onClick={goToNext} disabled={!hasNext} color="primary">
<NavigateNext />
</IconButton>
</Box>
@ -122,10 +112,7 @@ export default function ExtractionView() {
<Stepper activeStep={currentIndex} alternativeLabel sx={{ mb: 3 }}>
{folderResults.map((result, index) => (
<Step key={result.fileHash}>
<StepLabel
onClick={() => gotoResult(index)}
sx={{ cursor: 'pointer' }}
>
<StepLabel onClick={() => gotoResult(index)} sx={{ cursor: 'pointer' }}>
{result.document.fileName}
</StepLabel>
</Step>
@ -138,14 +125,8 @@ export default function ExtractionView() {
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
<Description color="primary" />
<Typography variant="h6">
{extraction.document.fileName}
</Typography>
<Chip
label={extraction.document.mimeType}
size="small"
variant="outlined"
/>
<Typography variant="h6">{extraction.document.fileName}</Typography>
<Chip label={extraction.document.mimeType} size="small" variant="outlined" />
<Chip
label={`${(extraction.document.fileSize / 1024 / 1024).toFixed(2)} MB`}
size="small"
@ -204,10 +185,7 @@ export default function ExtractionView() {
<List dense>
{extraction.extraction.entities.persons.map((person, index) => (
<ListItem key={index}>
<ListItemText
primary={`${person.firstName} ${person.lastName}`}
secondary={`Confiance: ${Math.round(person.confidence * 100)}%`}
/>
<ListItemText primary={person} secondary="Personne détectée" />
</ListItem>
))}
</List>
@ -226,10 +204,7 @@ export default function ExtractionView() {
<List dense>
{extraction.extraction.entities.addresses.map((address, index) => (
<ListItem key={index}>
<ListItemText
primary={`${address.street}, ${address.city} ${address.postalCode}`}
secondary={`Confiance: ${Math.round(address.confidence * 100)}%`}
/>
<ListItemText primary={address} secondary="Adresse détectée" />
</ListItem>
))}
</List>
@ -248,10 +223,7 @@ export default function ExtractionView() {
<List dense>
{extraction.extraction.entities.companies.map((company, index) => (
<ListItem key={index}>
<ListItemText
primary={company.name}
secondary={`Confiance: ${Math.round(company.confidence * 100)}%`}
/>
<ListItemText primary={company} secondary="Entreprise détectée" />
</ListItem>
))}
</List>
@ -276,10 +248,12 @@ export default function ExtractionView() {
<strong>Hash du fichier:</strong> {extraction.fileHash}
</Typography>
<Typography variant="body2">
<strong>Timestamp:</strong> {new Date(extraction.status.timestamp).toLocaleString()}
<strong>Timestamp:</strong>{' '}
{new Date(extraction.status.timestamp).toLocaleString()}
</Typography>
<Typography variant="body2">
<strong>Confiance globale:</strong> {(extraction.metadata.quality.globalConfidence * 100).toFixed(1)}%
<strong>Confiance globale:</strong>{' '}
{(extraction.metadata.quality.globalConfidence * 100).toFixed(1)}%
</Typography>
</Box>
</AccordionDetails>

View File

@ -20,7 +20,7 @@ import {
DialogContent,
DialogActions,
IconButton,
Tooltip
Tooltip,
} from '@mui/material'
import {
CloudUpload,
@ -33,10 +33,16 @@ import {
PictureAsPdf,
FolderOpen,
Add,
ContentCopy
ContentCopy,
} from '@mui/icons-material'
import { useAppDispatch, useAppSelector } from '../store'
import { uploadFileToFolderThunk, loadFolderResults, removeDocument, createDefaultFolderThunk, setCurrentFolderHash } from '../store/documentSlice'
import {
uploadFileToFolderThunk,
loadFolderResults,
removeDocument,
createDefaultFolderThunk,
setCurrentFolderHash,
} from '../store/documentSlice'
import { Layout } from '../components/Layout'
import { FilePreview } from '../components/FilePreview'
import type { Document } from '../types'
@ -109,7 +115,7 @@ export default function UploadView() {
// Attendre que tous les fichiers soient traités
await Promise.all(uploadPromises)
},
[dispatch, currentFolderHash]
[dispatch, currentFolderHash],
)
const { getRootProps, getInputProps, isDragActive } = useDropzone({
@ -149,15 +155,12 @@ export default function UploadView() {
// Bootstrap maintenant géré dans App.tsx
const getFileIcon = (mimeType: string) => {
if (mimeType.includes('pdf')) return <PictureAsPdf color="error" />
if (mimeType.includes('image')) return <Image color="primary" />
return <Description color="action" />
}
return (
<Layout>
<Typography variant="h4" gutterBottom>
@ -165,8 +168,23 @@ export default function UploadView() {
</Typography>
{/* En-tête avec hash du dossier et boutons */}
<Box sx={{ mb: 3, p: 2, bgcolor: 'grey.50', borderRadius: 1, border: '1px solid', borderColor: 'grey.200' }}>
<Box display="flex" alignItems="center" justifyContent="space-between" flexWrap="wrap" gap={2}>
<Box
sx={{
mb: 3,
p: 2,
bgcolor: 'grey.50',
borderRadius: 1,
border: '1px solid',
borderColor: 'grey.200',
}}
>
<Box
display="flex"
alignItems="center"
justifyContent="space-between"
flexWrap="wrap"
gap={2}
>
<Box display="flex" alignItems="center" gap={2}>
<Typography variant="h6" color="text.secondary">
Dossier actuel :
@ -180,7 +198,7 @@ export default function UploadView() {
px: 1,
py: 0.5,
borderRadius: 1,
fontSize: '0.875rem'
fontSize: '0.875rem',
}}
>
{currentFolderHash || 'Aucun dossier sélectionné'}
@ -239,12 +257,15 @@ export default function UploadView() {
? 'Déposez les fichiers ici...'
: 'Glissez-déposez vos documents ou cliquez pour sélectionner'}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ fontSize: { xs: '0.75rem', sm: '0.875rem' } }}>
<Typography
variant="body2"
color="text.secondary"
sx={{ fontSize: { xs: '0.75rem', sm: '0.875rem' } }}
>
Formats acceptés: PDF, PNG, JPG, JPEG, TIFF
</Typography>
</Paper>
{error && (
<Alert severity="error" sx={{ mt: 2 }}>
{error}
@ -263,9 +284,7 @@ export default function UploadView() {
{documents.map((doc, index) => (
<div key={`${doc.id}-${index}`}>
<ListItem>
<ListItemIcon>
{getFileIcon(doc.mimeType)}
</ListItemIcon>
<ListItemIcon>{getFileIcon(doc.mimeType)}</ListItemIcon>
<ListItemText
primary={
<Box>
@ -280,7 +299,7 @@ export default function UploadView() {
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
maxWidth: { xs: '200px', sm: '300px', md: '400px' }
maxWidth: { xs: '200px', sm: '300px', md: '400px' },
}}
>
{doc.name}
@ -290,13 +309,15 @@ export default function UploadView() {
<Chip
label={doc.status}
size="small"
color={getStatusColor(doc.status) as 'success' | 'error' | 'warning' | 'default'}
/>
<Chip
label={doc.mimeType}
size="small"
variant="outlined"
color={
getStatusColor(doc.status) as
| 'success'
| 'error'
| 'warning'
| 'default'
}
/>
<Chip label={doc.mimeType} size="small" variant="outlined" />
<Chip
label={`${(doc.size / 1024 / 1024).toFixed(2)} MB`}
size="small"
@ -336,10 +357,7 @@ export default function UploadView() {
{/* Aperçu du document */}
{previewDocument && (
<FilePreview
document={previewDocument}
onClose={() => setPreviewDocument(null)}
/>
<FilePreview document={previewDocument} onClose={() => setPreviewDocument(null)} />
)}
{/* Dialogue pour charger un dossier existant */}
@ -352,7 +370,8 @@ export default function UploadView() {
</DialogTitle>
<DialogContent>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Entrez le hash du dossier que vous souhaitez charger. Le hash est un identifiant unique de 32 caractères.
Entrez le hash du dossier que vous souhaitez charger. Le hash est un identifiant unique
de 32 caractères.
</Typography>
<TextField
autoFocus
@ -368,9 +387,7 @@ export default function UploadView() {
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setDialogOpen(false)}>
Annuler
</Button>
<Button onClick={() => setDialogOpen(false)}>Annuler</Button>
<Button
onClick={handleLoadFolder}
variant="contained"

View File

@ -24,21 +24,22 @@ console.log('📸 Image trouvée:', path.basename(imagePath))
// Fonction d'extraction OCR simple
async function extractTextFromImage(imagePath) {
console.log('\n🚀 Démarrage de l\'extraction OCR...')
console.log("\n🚀 Démarrage de l'extraction OCR...")
const worker = await createWorker('fra+eng')
try {
console.log('⏳ Extraction en cours...')
const { data: { text } } = await worker.recognize(imagePath)
const {
data: { text },
} = await worker.recognize(imagePath)
console.log('\n📄 TEXTE EXTRAIT:')
console.log('=' .repeat(50))
console.log('='.repeat(50))
console.log(text)
console.log('=' .repeat(50))
console.log('='.repeat(50))
return text
} catch (error) {
console.error('❌ Erreur OCR:', error.message)
return null
@ -50,7 +51,7 @@ async function extractTextFromImage(imagePath) {
// Fonction d'analyse simple du texte
function analyzeText(text) {
console.log('\n🔍 ANALYSE DU TEXTE:')
console.log('=' .repeat(50))
console.log('='.repeat(50))
if (!text) {
console.log('❌ Aucun texte à analyser')
@ -62,7 +63,7 @@ function analyzeText(text) {
// Recherche de noms (patterns généraux)
const namePatterns = [
/([A-Z][a-zà-öø-ÿ'\-]+\s+[A-Z][a-zà-öø-ÿ'\-]+)/g, // Prénom Nom
/([A-Z][A-ZÀ-ÖØ-öø-ÿ\s\-']{2,30})/g // Noms en majuscules
/([A-Z][A-ZÀ-ÖØ-öø-ÿ\s\-']{2,30})/g, // Noms en majuscules
]
const foundNames = new Set()
@ -96,7 +97,7 @@ function analyzeText(text) {
// Recherche spécifique pour NICOLAS et CANTU
console.log('\n🔍 RECHERCHE SPÉCIFIQUE:')
console.log('=' .repeat(50))
console.log('='.repeat(50))
const hasNicolas = /nicolas/i.test(text)
const hasCantu = /cantu/i.test(text)
@ -127,7 +128,7 @@ function analyzeText(text) {
hasNicolas,
hasCantu,
hasNicolasCantu,
hasCNIKeywords
hasCNIKeywords,
}
}
@ -138,14 +139,13 @@ async function main() {
const results = analyzeText(text)
console.log('\n🎯 RÉSULTATS FINAUX:')
console.log('=' .repeat(50))
console.log('='.repeat(50))
console.log(`📄 Texte extrait: ${results ? '✅' : '❌'}`)
console.log(`👥 Noms trouvés: ${results?.names.length || 0}`)
console.log(`🆔 CNI trouvés: ${results?.cniNumbers.length || 0}`)
console.log(`🔍 NICOLAS: ${results?.hasNicolas ? '✅' : '❌'}`)
console.log(`🔍 CANTU: ${results?.hasCantu ? '✅' : '❌'}`)
console.log(`📋 Type CNI: ${results?.hasCNIKeywords ? '✅' : '❌'}`)
} catch (error) {
console.error('❌ Erreur:', error.message)
}

View File

@ -9,7 +9,7 @@ const { createWorker } = require('tesseract.js')
const path = require('path')
const fs = require('fs')
console.log('🔍 Analyse directe de l\'image CNI')
console.log("🔍 Analyse directe de l'image CNI")
console.log('==================================')
const imagePath = path.join(__dirname, 'test-files', 'IMG_20250902_162159.jpg')
@ -26,14 +26,28 @@ console.log('📸 Image trouvée:', imagePath)
function correctOCRText(text) {
const corrections = {
// Corrections pour "Nicolas"
'N1colas': 'Nicolas', 'Nicol@s': 'Nicolas', 'Nico1as': 'Nicolas',
'Nico1@s': 'Nicolas', 'N1co1as': 'Nicolas', 'N1co1@s': 'Nicolas',
'Nico1as': 'Nicolas', 'N1col@s': 'Nicolas', 'N1co1as': 'Nicolas',
N1colas: 'Nicolas',
'Nicol@s': 'Nicolas',
Nico1as: 'Nicolas',
'Nico1@s': 'Nicolas',
N1co1as: 'Nicolas',
'N1co1@s': 'Nicolas',
Nico1as: 'Nicolas',
'N1col@s': 'Nicolas',
N1co1as: 'Nicolas',
// Corrections pour "Cantu"
'C@ntu': 'Cantu', 'CantU': 'Cantu', 'C@ntU': 'Cantu',
'Cant0': 'Cantu', 'C@nt0': 'Cantu', 'CantU': 'Cantu',
'C@ntu': 'Cantu',
CantU: 'Cantu',
'C@ntU': 'Cantu',
Cant0: 'Cantu',
'C@nt0': 'Cantu',
CantU: 'Cantu',
// Autres corrections courantes
'0': 'o', '1': 'l', '5': 's', '@': 'a', '3': 'e'
0: 'o',
1: 'l',
5: 's',
'@': 'a',
3: 'e',
}
let correctedText = text
@ -47,7 +61,7 @@ function correctOCRText(text) {
// Fonction d'extraction des entités
function extractEntitiesFromText(text) {
console.log(`\n🔍 Analyse du texte extrait (${text.length} caractères)`)
console.log('=' .repeat(50))
console.log('='.repeat(50))
const correctedText = correctOCRText(text)
if (correctedText !== text) {
@ -57,7 +71,7 @@ function extractEntitiesFromText(text) {
const entities = {
identities: [],
cniNumbers: [],
documentType: 'Document'
documentType: 'Document',
}
// Patterns pour détecter "Nicolas Cantu"
@ -70,7 +84,7 @@ function extractEntitiesFromText(text) {
// Recherche de "Cantu" seul
/([Cc][a@][n][t][u])/gi,
// Patterns généraux pour noms
/([A-Z][a-zà-öø-ÿ'\-]+\s+[A-Z][a-zà-öø-ÿ'\-]+)/g
/([A-Z][a-zà-öø-ÿ'\-]+\s+[A-Z][a-zà-öø-ÿ'\-]+)/g,
]
// Extraction des noms
@ -102,24 +116,26 @@ function extractEntitiesFromText(text) {
// Fonction principale d'analyse
async function analyzeImage() {
console.log('\n🚀 Démarrage de l\'analyse OCR...')
console.log("\n🚀 Démarrage de l'analyse OCR...")
const worker = await createWorker('fra+eng')
try {
console.log('⏳ Extraction du texte en cours...')
const { data: { text } } = await worker.recognize(imagePath)
const {
data: { text },
} = await worker.recognize(imagePath)
console.log('\n📄 Texte brut extrait:')
console.log('=' .repeat(50))
console.log('='.repeat(50))
console.log(text)
console.log('=' .repeat(50))
console.log('='.repeat(50))
// Extraction des entités
const entities = extractEntitiesFromText(text)
console.log('\n🎯 RÉSULTATS DE L\'ANALYSE:')
console.log('=' .repeat(50))
console.log("\n🎯 RÉSULTATS DE L'ANALYSE:")
console.log('='.repeat(50))
console.log(`📋 Type de document: ${entities.documentType}`)
console.log(`👥 Identités trouvées: ${entities.identities.length}`)
entities.identities.forEach((identity, index) => {
@ -132,7 +148,7 @@ async function analyzeImage() {
// Recherche spécifique pour CANTU et NICOLAS
console.log('\n🔍 RECHERCHE SPÉCIFIQUE:')
console.log('=' .repeat(50))
console.log('='.repeat(50))
const hasNicolas = /nicolas/i.test(text)
const hasCantu = /cantu/i.test(text)
@ -151,18 +167,19 @@ async function analyzeImage() {
}
})
}
} catch (error) {
console.error('❌ Erreur lors de l\'analyse:', error.message)
console.error("❌ Erreur lors de l'analyse:", error.message)
} finally {
await worker.terminate()
}
}
// Exécuter l'analyse
analyzeImage().then(() => {
analyzeImage()
.then(() => {
console.log('\n🎉 Analyse terminée !')
}).catch(error => {
})
.catch((error) => {
console.error('❌ Erreur:', error.message)
process.exit(1)
})
})

View File

@ -1,59 +1,59 @@
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const fs = require('fs')
const path = require('path')
const crypto = require('crypto')
// Fonction pour générer un hash de dossier
function generateFolderHash() {
return crypto.randomBytes(16).toString('hex');
return crypto.randomBytes(16).toString('hex')
}
// 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()}`);
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';
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)}`);
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}`);
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}`);
fs.mkdirSync(cacheDir, { recursive: true })
console.log(`[FOLDER] Dossier racine créé: ${cacheDir}`)
}
const folderPath = path.join(uploadsDir, folderHash);
const cachePath = path.join(cacheDir, folderHash);
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}`);
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}`);
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}`);
fs.mkdirSync(cachePath, { recursive: true })
console.log(`[FOLDER] Dossier cache créé: ${cachePath}`)
}
return { folderPath, cachePath };
return { folderPath, cachePath }
}
// Test
console.log('=== Test de création de dossier ===');
const folderHash = generateFolderHash();
console.log(`Hash généré: ${folderHash}`);
console.log('=== Test de création de dossier ===')
const folderHash = generateFolderHash()
console.log(`Hash généré: ${folderHash}`)
const result = createFolderStructure(folderHash);
console.log('Résultat:', result);
const result = createFolderStructure(folderHash)
console.log('Résultat:', result)
console.log('\n=== Vérification des dossiers créés ===');
console.log('Dossiers uploads:', fs.readdirSync('uploads'));
console.log('Dossiers cache:', fs.readdirSync('cache'));
console.log('\n=== Vérification des dossiers créés ===')
console.log('Dossiers uploads:', fs.readdirSync('uploads'))
console.log('Dossiers cache:', fs.readdirSync('cache'))

View File

@ -1,48 +1,55 @@
const fs = require('fs');
const FormData = require('form-data');
const fetch = require('node-fetch');
const fs = require('fs')
const FormData = require('form-data')
const fetch = require('node-fetch')
async function testBackendFormat() {
try {
console.log('🧪 Test du format JSON du backend...');
console.log('🧪 Test du format JSON du backend...')
const formData = new FormData();
formData.append('document', fs.createReadStream('test-files/IMG_20250902_162159.jpg'));
const formData = new FormData()
formData.append('document', fs.createReadStream('test-files/IMG_20250902_162159.jpg'))
const response = await fetch('http://localhost:3001/api/extract', {
method: 'POST',
body: formData
});
body: formData,
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const result = await response.json();
const result = await response.json()
console.log('✅ Réponse reçue du backend');
console.log('📋 Structure de la réponse:');
console.log('- document:', !!result.document);
console.log('- classification:', !!result.classification);
console.log('- extraction:', !!result.extraction);
console.log('- metadata:', !!result.metadata);
console.log('- status:', !!result.status);
console.log('✅ Réponse reçue du backend')
console.log('📋 Structure de la réponse:')
console.log('- document:', !!result.document)
console.log('- classification:', !!result.classification)
console.log('- extraction:', !!result.extraction)
console.log('- metadata:', !!result.metadata)
console.log('- status:', !!result.status)
console.log('\n📊 Données extraites:');
console.log('- Type de document:', result.classification?.documentType);
console.log('- Confiance globale:', result.metadata?.quality?.globalConfidence);
console.log('- Personnes:', result.extraction?.entities?.persons?.length || 0);
console.log('- Sociétés:', result.extraction?.entities?.companies?.length || 0);
console.log('- Adresses:', result.extraction?.entities?.addresses?.length || 0);
console.log('- Dates:', result.extraction?.entities?.dates?.length || 0);
console.log('- Références:', result.extraction?.entities?.references?.length || 0);
console.log('\n🎯 Format conforme au standard:',
result.document && result.classification && result.extraction && result.metadata && result.status ? '✅ OUI' : '❌ NON');
console.log('\n📊 Données extraites:')
console.log('- Type de document:', result.classification?.documentType)
console.log('- Confiance globale:', result.metadata?.quality?.globalConfidence)
console.log('- Personnes:', result.extraction?.entities?.persons?.length || 0)
console.log('- Sociétés:', result.extraction?.entities?.companies?.length || 0)
console.log('- Adresses:', result.extraction?.entities?.addresses?.length || 0)
console.log('- Dates:', result.extraction?.entities?.dates?.length || 0)
console.log('- Références:', result.extraction?.entities?.references?.length || 0)
console.log(
'\n🎯 Format conforme au standard:',
result.document &&
result.classification &&
result.extraction &&
result.metadata &&
result.status
? '✅ OUI'
: '❌ NON',
)
} catch (error) {
console.error('❌ Erreur:', error.message);
console.error('❌ Erreur:', error.message)
}
}
testBackendFormat();
testBackendFormat()

View File

@ -9,17 +9,14 @@ const http = require('http')
const fs = require('fs')
const path = require('path')
console.log('🌐 Test de l\'interface web pour l\'analyse CNI')
console.log("🌐 Test de l'interface web pour l'analyse CNI")
console.log('===============================================')
// Vérifier que les images existent
const images = [
'IMG_20250902_162159.jpg',
'IMG_20250902_162210.jpg'
]
const images = ['IMG_20250902_162159.jpg', 'IMG_20250902_162210.jpg']
console.log('📸 Vérification des images de test:')
images.forEach(image => {
images.forEach((image) => {
const imagePath = path.join(__dirname, 'test-files', image)
if (fs.existsSync(imagePath)) {
const stats = fs.statSync(imagePath)
@ -34,12 +31,14 @@ function makeRequest(url, options = {}) {
return new Promise((resolve, reject) => {
const req = http.request(url, options, (res) => {
let data = ''
res.on('data', chunk => data += chunk)
res.on('end', () => resolve({
res.on('data', (chunk) => (data += chunk))
res.on('end', () =>
resolve({
statusCode: res.statusCode,
headers: res.headers,
data
}))
data,
}),
)
})
req.on('error', reject)
req.setTimeout(10000, () => reject(new Error('Timeout')))
@ -49,7 +48,7 @@ function makeRequest(url, options = {}) {
// Test de l'interface frontend
async function testFrontendInterface() {
console.log('\n🌐 Test de l\'interface frontend...')
console.log("\n🌐 Test de l'interface frontend...")
try {
const response = await makeRequest('http://localhost:5174')
@ -90,7 +89,7 @@ async function testBackendServer() {
// Test d'accès aux images via le serveur
async function testImageAccess() {
console.log('\n📸 Test d\'accès aux images...')
console.log("\n📸 Test d'accès aux images...")
for (const image of images) {
try {
@ -109,40 +108,40 @@ async function testImageAccess() {
// Instructions pour l'analyse manuelle
function printManualInstructions() {
console.log('\n📋 INSTRUCTIONS POUR L\'ANALYSE MANUELLE:')
console.log('=' .repeat(60))
console.log("\n📋 INSTRUCTIONS POUR L'ANALYSE MANUELLE:")
console.log('='.repeat(60))
console.log('1. Ouvrez votre navigateur et allez sur: http://localhost:5174')
console.log('2. Ouvrez la console développeur (F12)')
console.log('3. Dans l\'interface, chargez l\'une des images de test:')
console.log("3. Dans l'interface, chargez l'une des images de test:")
images.forEach((image, index) => {
console.log(` ${index + 1}. ${image}`)
})
console.log('4. Observez les logs d\'extraction dans la console du navigateur')
console.log('5. Vérifiez les résultats d\'extraction dans l\'interface')
console.log("4. Observez les logs d'extraction dans la console du navigateur")
console.log("5. Vérifiez les résultats d'extraction dans l'interface")
console.log('\n🔍 ÉLÉMENTS À RECHERCHER:')
console.log('=' .repeat(60))
console.log('='.repeat(60))
console.log('• Nom de famille (en MAJUSCULES)')
console.log('• Prénom(s)')
console.log('• Numéro de carte d\'identité (format: 2 lettres + 6 chiffres)')
console.log("• Numéro de carte d'identité (format: 2 lettres + 6 chiffres)")
console.log('• Date de naissance')
console.log('• Lieu de naissance')
console.log('• Spécifiquement: "NICOLAS" et "CANTU"')
console.log('\n⚙ CONFIGURATION RECOMMANDÉE:')
console.log('=' .repeat(60))
console.log('='.repeat(60))
console.log('• Mode sans LLM: VITE_DISABLE_LLM=true')
console.log('• Extraction par règles: VITE_USE_RULE_NER=true')
console.log('• Pas de clé API OpenAI requise')
console.log('\n📊 LOGS ATTENDUS DANS LA CONSOLE:')
console.log('=' .repeat(60))
console.log('='.repeat(60))
console.log('🔧 [CONFIG] Mode sans LLM activé: { useRuleNer: true, disableLLM: true, ... }')
console.log('🚀 [OCR] Début de l\'extraction OCR locale...')
console.log("🚀 [OCR] Début de l'extraction OCR locale...")
console.log('⏳ [OCR] Progression: 30%')
console.log('⏳ [OCR] Progression: 70%')
console.log('✅ [OCR] Progression: 100% - Extraction terminée')
console.log('🔍 [NER] Début de l\'extraction par règles...')
console.log("🔍 [NER] Début de l'extraction par règles...")
console.log('👥 [RULE-NER] Identités extraites: X')
console.log('🏠 [RULE-NER] Adresses extraites: X')
console.log('🆔 [RULE-NER] Numéros CNI détectés: X')
@ -165,7 +164,7 @@ async function runTests() {
printManualInstructions()
console.log('\n🎯 RÉSUMÉ:')
console.log('=' .repeat(60))
console.log('='.repeat(60))
console.log('✅ Interface frontend: Opérationnelle')
console.log('✅ Serveur backend: Opérationnel')
console.log('✅ Images de test: Disponibles')
@ -173,17 +172,16 @@ async function runTests() {
console.log('💡 Pour analyser les images et rechercher CANTU/NICOLAS:')
console.log(' 1. Ouvrez http://localhost:5174 dans votre navigateur')
console.log(' 2. Chargez une image de test')
console.log(' 3. Observez les résultats dans la console et l\'interface')
console.log(" 3. Observez les résultats dans la console et l'interface")
console.log('')
console.log('🔍 Le système est configuré pour détecter:')
console.log(' • "NICOLAS" avec corrections OCR (N1colas, Nicol@s, etc.)')
console.log(' • "CANTU" avec corrections OCR (C@ntu, CantU, etc.)')
console.log(' • Numéros CNI au format 2 lettres + 6 chiffres')
console.log(' • Type de document CNI')
} else {
console.log('\n❌ PROBLÈMES DÉTECTÉS:')
console.log('=' .repeat(60))
console.log('='.repeat(60))
if (!frontendOk) {
console.log('❌ Interface frontend non accessible')
console.log(' → Vérifiez que le serveur de développement est démarré')
@ -198,9 +196,11 @@ async function runTests() {
}
// Exécuter les tests
runTests().then(() => {
runTests()
.then(() => {
console.log('\n🎉 Tests terminés !')
}).catch(error => {
})
.catch((error) => {
console.error('❌ Erreur lors des tests:', error.message)
process.exit(1)
})
})

View File

@ -1,5 +1,10 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { getTestFilesList, loadTestFile, filterSupportedFiles, type TestFileInfo } from '../src/services/testFilesApi'
import {
getTestFilesList,
loadTestFile,
filterSupportedFiles,
type TestFileInfo,
} from '../src/services/testFilesApi'
// Mock fetch
global.fetch = vi.fn()
@ -13,17 +18,47 @@ describe('testFilesApi', () => {
it('devrait retourner une liste de fichiers de test', async () => {
// Mock des réponses fetch pour les fichiers connus
const mockResponses = [
{ ok: true, headers: new Map([['content-length', '1024'], ['content-type', 'image/jpeg']]) },
{ ok: true, headers: new Map([['content-length', '2048'], ['content-type', 'application/pdf']]) },
{
ok: true,
headers: new Map([
['content-length', '1024'],
['content-type', 'image/jpeg'],
]),
},
{
ok: true,
headers: new Map([
['content-length', '2048'],
['content-type', 'application/pdf'],
]),
},
{ ok: false }, // Fichier non trouvé
{ ok: true, headers: new Map([['content-length', '512'], ['content-type', 'text/plain']]) },
{ ok: true, headers: new Map([['content-length', '256'], ['content-type', 'text/markdown']]) }
{
ok: true,
headers: new Map([
['content-length', '512'],
['content-type', 'text/plain'],
]),
},
{
ok: true,
headers: new Map([
['content-length', '256'],
['content-type', 'text/markdown'],
]),
},
]
// Mock fetch pour chaque fichier
;(global.fetch as any).mockImplementation((url: string) => {
const fileName = url.split('/').pop()
const fileIndex = ['IMG_20250902_162159.jpg', 'IMG_20250902_162210.jpg', 'sample.md', 'sample.pdf', 'sample.txt'].indexOf(fileName!)
const fileIndex = [
'IMG_20250902_162159.jpg',
'IMG_20250902_162210.jpg',
'sample.md',
'sample.pdf',
'sample.txt',
].indexOf(fileName!)
return Promise.resolve(mockResponses[fileIndex] || { ok: false })
})
@ -33,7 +68,7 @@ describe('testFilesApi', () => {
expect(result[0]).toMatchObject({
name: 'IMG_20250902_162159.jpg',
size: 1024,
type: 'image/jpeg'
type: 'image/jpeg',
})
})
@ -51,7 +86,7 @@ describe('testFilesApi', () => {
const mockBlob = new Blob(['test content'], { type: 'text/plain' })
const mockResponse = {
ok: true,
blob: () => Promise.resolve(mockBlob)
blob: () => Promise.resolve(mockBlob),
}
;(global.fetch as any).mockResolvedValue(mockResponse)
@ -63,10 +98,10 @@ describe('testFilesApi', () => {
expect(result?.type).toBe('text/plain')
})
it('devrait retourner null si le fichier n\'existe pas', async () => {
it("devrait retourner null si le fichier n'existe pas", async () => {
const mockResponse = {
ok: false,
status: 404
status: 404,
}
;(global.fetch as any).mockResolvedValue(mockResponse)
@ -91,42 +126,62 @@ describe('testFilesApi', () => {
{ name: 'image.jpg', size: 2048, type: 'image/jpeg', lastModified: Date.now() },
{ name: 'text.txt', size: 512, type: 'text/plain', lastModified: Date.now() },
{ name: 'markdown.md', size: 256, type: 'text/markdown', lastModified: Date.now() },
{ name: 'document.docx', size: 4096, type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', lastModified: Date.now() },
{
name: 'document.docx',
size: 4096,
type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
lastModified: Date.now(),
},
{ name: 'unsupported.xyz', size: 128, type: 'application/unknown', lastModified: Date.now() },
{ name: 'image.png', size: 1536, type: 'image/png', lastModified: Date.now() }
{ name: 'image.png', size: 1536, type: 'image/png', lastModified: Date.now() },
]
it('devrait filtrer les fichiers supportés par type MIME', () => {
const result = filterSupportedFiles(testFiles)
expect(result).toHaveLength(6) // 6 fichiers supportés
expect(result.map(f => f.name)).toEqual([
expect(result.map((f) => f.name)).toEqual([
'document.pdf',
'image.jpg',
'text.txt',
'markdown.md',
'document.docx',
'image.png'
'image.png',
])
})
it('devrait filtrer les fichiers supportés par extension', () => {
const filesWithUnknownMime: TestFileInfo[] = [
{ name: 'document.pdf', size: 1024, type: 'application/octet-stream', lastModified: Date.now() },
{ name: 'image.jpg', size: 2048, type: 'application/octet-stream', lastModified: Date.now() },
{ name: 'unsupported.xyz', size: 128, type: 'application/octet-stream', lastModified: Date.now() }
{
name: 'document.pdf',
size: 1024,
type: 'application/octet-stream',
lastModified: Date.now(),
},
{
name: 'image.jpg',
size: 2048,
type: 'application/octet-stream',
lastModified: Date.now(),
},
{
name: 'unsupported.xyz',
size: 128,
type: 'application/octet-stream',
lastModified: Date.now(),
},
]
const result = filterSupportedFiles(filesWithUnknownMime)
expect(result).toHaveLength(2) // 2 fichiers supportés par extension
expect(result.map(f => f.name)).toEqual(['document.pdf', 'image.jpg'])
expect(result.map((f) => f.name)).toEqual(['document.pdf', 'image.jpg'])
})
it('devrait retourner un tableau vide si aucun fichier supporté', () => {
const unsupportedFiles: TestFileInfo[] = [
{ name: 'file1.xyz', size: 128, type: 'application/unknown', lastModified: Date.now() },
{ name: 'file2.abc', size: 256, type: 'application/unknown', lastModified: Date.now() }
{ name: 'file2.abc', size: 256, type: 'application/unknown', lastModified: Date.now() },
]
const result = filterSupportedFiles(unsupportedFiles)

View File

@ -1 +1,4 @@
{"file_format_version": "1.0.0", "ICD": {"library_path": ".\\vk_swiftshader.dll", "api_version": "1.0.5"}}
{
"file_format_version": "1.0.0",
"ICD": { "library_path": ".\\vk_swiftshader.dll", "api_version": "1.0.5" }
}