diff --git a/backend/eng.traineddata b/backend/eng.traineddata deleted file mode 100644 index 6d11002..0000000 Binary files a/backend/eng.traineddata and /dev/null differ diff --git a/backend/server.js b/backend/server.js index 9cdf88e..7df059e 100644 --- a/backend/server.js +++ b/backend/server.js @@ -28,22 +28,97 @@ function calculateFileHash(buffer) { return crypto.createHash('sha256').update(buffer).digest('hex') } -// Fonction pour vérifier si un fichier existe déjà par hash -function findExistingFileByHash(hash) { - const uploadDir = 'uploads/' - if (!fs.existsSync(uploadDir)) return null +// Fonction pour générer un hash de dossier +function generateFolderHash() { + return crypto.randomBytes(16).toString('hex') +} - const files = fs.readdirSync(uploadDir) - for (const file of files) { - const filePath = path.join(uploadDir, file) +// Fonction pour créer la structure de dossiers +function createFolderStructure(folderHash) { + const folderPath = path.join('uploads', folderHash) + const cachePath = path.join('cache', folderHash) + + if (!fs.existsSync(folderPath)) { + fs.mkdirSync(folderPath, { recursive: true }) + } + if (!fs.existsSync(cachePath)) { + fs.mkdirSync(cachePath, { recursive: true }) + } + + return { folderPath, cachePath } +} + +// Fonction pour sauvegarder le cache JSON dans un dossier spécifique +function saveJsonCacheInFolder(folderHash, fileHash, result) { + const { cachePath } = createFolderStructure(folderHash) + const cacheFile = path.join(cachePath, `${fileHash}.json`) + + try { + fs.writeFileSync(cacheFile, JSON.stringify(result, null, 2)) + console.log(`[CACHE] Résultat sauvegardé dans le dossier ${folderHash}: ${fileHash}`) + return true + } catch (error) { + console.error(`[CACHE] Erreur lors de la sauvegarde dans le dossier ${folderHash}:`, error) + return false + } +} + +// Fonction pour récupérer le cache JSON depuis un dossier spécifique +function getJsonCacheFromFolder(folderHash, fileHash) { + const cachePath = path.join('cache', folderHash) + const cacheFile = path.join(cachePath, `${fileHash}.json`) + + if (fs.existsSync(cacheFile)) { try { - const fileBuffer = fs.readFileSync(filePath) - const fileHash = calculateFileHash(fileBuffer) - if (fileHash === hash) { - return { path: filePath, name: file } - } + const data = fs.readFileSync(cacheFile, 'utf8') + const result = JSON.parse(data) + console.log(`[CACHE] Résultat récupéré depuis le dossier ${folderHash}: ${fileHash}`) + return result } catch (error) { - console.warn(`[HASH] Erreur lors de la lecture de ${file}:`, error.message) + console.error(`[CACHE] Erreur lors de la lecture depuis le dossier ${folderHash}:`, error) + return null + } + } + return null +} + +// Fonction pour lister tous les résultats d'un dossier +function listFolderResults(folderHash) { + const cachePath = path.join('cache', folderHash) + if (!fs.existsSync(cachePath)) { + return [] + } + + const files = fs.readdirSync(cachePath) + const results = [] + + for (const file of files) { + if (file.endsWith('.json')) { + const fileHash = path.basename(file, '.json') + const result = getJsonCacheFromFolder(folderHash, fileHash) + if (result) { + results.push({ + fileHash, + ...result + }) + } + } + } + + return results +} + +// Fonction pour vérifier si un fichier existe déjà par hash dans un dossier +function findExistingFileByHash(hash, folderHash) { + const folderPath = path.join('uploads', folderHash) + if (!fs.existsSync(folderPath)) return null + + const files = fs.readdirSync(folderPath) + for (const file of files) { + // Vérifier si le nom de fichier commence par le hash + if (file.startsWith(hash)) { + const filePath = path.join(folderPath, file) + return { path: filePath, name: file } } } return null @@ -108,7 +183,7 @@ function listCacheFiles() { }).filter(file => file !== null) } -// Configuration multer pour l'upload de fichiers avec gestion des doublons +// Configuration multer pour l'upload de fichiers avec hash comme nom const storage = multer.diskStorage({ destination: (req, file, cb) => { const uploadDir = 'uploads/' @@ -118,11 +193,10 @@ const storage = multer.diskStorage({ cb(null, uploadDir) }, filename: (req, file, cb) => { - // Utiliser le nom original avec timestamp pour éviter les conflits + // Utiliser un nom temporaire, le hash sera calculé après const timestamp = Date.now() const ext = path.extname(file.originalname) - const name = path.basename(file.originalname, ext) - cb(null, `${name}-${timestamp}${ext}`) + cb(null, `temp-${timestamp}${ext}`) } }) @@ -728,17 +802,23 @@ app.post('/api/extract', upload.single('document'), async (req, res) => { return res.status(400).json({ error: 'Aucun fichier fourni' }) } - console.log(`[API] Traitement du fichier: ${req.file.originalname}`) + // Récupérer le hash du dossier depuis les paramètres de requête + const folderHash = req.body.folderHash || req.query.folderHash + if (!folderHash) { + return res.status(400).json({ error: 'Hash du dossier requis' }) + } + + console.log(`[API] Traitement du fichier: ${req.file.originalname} dans le dossier: ${folderHash}`) // Calculer le hash du fichier uploadé const fileBuffer = fs.readFileSync(req.file.path) const fileHash = calculateFileHash(fileBuffer) console.log(`[HASH] Hash du fichier: ${fileHash.substring(0, 16)}...`) - // Vérifier d'abord le cache JSON - const cachedResult = getJsonCache(fileHash) + // Vérifier d'abord le cache JSON dans le dossier + const cachedResult = getJsonCacheFromFolder(folderHash, fileHash) if (cachedResult) { - console.log(`[CACHE] Utilisation du résultat en cache`) + console.log(`[CACHE] Utilisation du résultat en cache du dossier ${folderHash}`) // Supprimer le fichier temporaire fs.unlinkSync(req.file.path) @@ -747,13 +827,13 @@ app.post('/api/extract', upload.single('document'), async (req, res) => { return res.json(cachedResult) } - // Vérifier si un fichier avec le même hash existe déjà - const existingFile = findExistingFileByHash(fileHash) + // Vérifier si un fichier avec le même hash existe déjà dans le dossier + const existingFile = findExistingFileByHash(fileHash, folderHash) let isDuplicate = false let duplicatePath = null if (existingFile) { - console.log(`[HASH] Fichier déjà existant trouvé: ${existingFile.name}`) + console.log(`[HASH] Fichier déjà existant trouvé dans le dossier ${folderHash}: ${existingFile.name}`) isDuplicate = true // Sauvegarder le chemin du doublon pour suppression ultérieure @@ -763,7 +843,24 @@ app.post('/api/extract', upload.single('document'), async (req, res) => { req.file.path = existingFile.path req.file.originalname = existingFile.name } else { - console.log(`[HASH] Nouveau fichier, traitement normal`) + console.log(`[HASH] Nouveau fichier, renommage avec hash dans le dossier ${folderHash}`) + + // Créer la structure du dossier si elle n'existe pas + const { folderPath } = createFolderStructure(folderHash) + + // Renommer le fichier avec son hash + extension dans le dossier + const ext = path.extname(req.file.originalname) + const newFileName = `${fileHash}${ext}` + const newFilePath = path.join(folderPath, newFileName) + + // Renommer le fichier + fs.renameSync(req.file.path, newFilePath) + + // Mettre à jour les informations du fichier + req.file.path = newFilePath + req.file.filename = newFileName + + console.log(`[HASH] Fichier renommé: ${newFileName}`) } let ocrResult @@ -796,8 +893,8 @@ app.post('/api/extract', upload.single('document'), async (req, res) => { // Génération du format JSON standard const result = generateStandardJSON(req.file, ocrResult, entities, processingTime) - // Sauvegarder le résultat dans le cache - saveJsonCache(fileHash, result) + // Sauvegarder le résultat dans le cache du dossier + saveJsonCacheInFolder(folderHash, fileHash, result) // Nettoyage du fichier temporaire if (isDuplicate) { @@ -854,6 +951,32 @@ app.get('/api/test-files', (req, res) => { } }) +// Route pour servir un fichier de test individuel +app.get('/api/test-files/:filename', (req, res) => { + try { + const filename = req.params.filename + const testFilesDir = path.join(__dirname, '..', 'test-files') + const filePath = path.join(testFilesDir, filename) + + // Vérifier que le fichier existe et est dans le bon répertoire + if (!fs.existsSync(filePath)) { + return res.status(404).json({ success: false, error: 'Fichier non trouvé' }) + } + + // Vérifier que le fichier est bien dans le répertoire test-files (sécurité) + const resolvedPath = path.resolve(filePath) + const resolvedTestDir = path.resolve(testFilesDir) + if (!resolvedPath.startsWith(resolvedTestDir)) { + return res.status(403).json({ success: false, error: 'Accès non autorisé' }) + } + + // Servir le fichier + res.sendFile(filePath) + } catch (error) { + res.status(500).json({ success: false, error: error.message }) + } +}) + // Route de santé // Route pour lister les fichiers uploadés avec leurs hash app.get('/api/uploads', (req, res) => { @@ -868,8 +991,10 @@ app.get('/api/uploads', (req, res) => { const filePath = path.join(uploadDir, file) try { const stats = fs.statSync(filePath) - const fileBuffer = fs.readFileSync(filePath) - const hash = calculateFileHash(fileBuffer) + + // Extraire le hash du nom de fichier (format: hash.extension) + const ext = path.extname(file) + const hash = path.basename(file, ext) return { name: file, @@ -947,6 +1072,131 @@ app.delete('/api/cache/:hash', (req, res) => { } }) +// Route pour créer un nouveau dossier +app.post('/api/folders', (req, res) => { + try { + const folderHash = generateFolderHash() + createFolderStructure(folderHash) + + console.log(`[FOLDER] Nouveau dossier créé: ${folderHash}`) + + res.json({ + success: true, + folderHash, + message: 'Dossier créé avec succès' + }) + } catch (error) { + console.error('[FOLDER] Erreur lors de la création du dossier:', error) + res.status(500).json({ + success: false, + error: error.message + }) + } +}) + +// Route pour récupérer les résultats d'un dossier +app.get('/api/folders/:folderHash/results', (req, res) => { + try { + const { folderHash } = req.params + const results = listFolderResults(folderHash) + + console.log(`[FOLDER] Résultats récupérés pour le dossier ${folderHash}: ${results.length} fichiers`) + + res.json({ + success: true, + folderHash, + results, + count: results.length + }) + } catch (error) { + console.error('[FOLDER] Erreur lors de la récupération des résultats:', error) + res.status(500).json({ + success: false, + error: error.message + }) + } +}) + +// Route pour récupérer un fichier original depuis un dossier +app.get('/api/folders/:folderHash/files/:fileHash', (req, res) => { + try { + const { folderHash, fileHash } = req.params + const folderPath = path.join('uploads', folderHash) + + if (!fs.existsSync(folderPath)) { + return res.status(404).json({ success: false, error: 'Dossier non trouvé' }) + } + + const files = fs.readdirSync(folderPath) + const targetFile = files.find(file => file.startsWith(fileHash)) + + if (!targetFile) { + return res.status(404).json({ success: false, error: 'Fichier non trouvé' }) + } + + const filePath = path.join(folderPath, targetFile) + res.sendFile(path.resolve(filePath)) + } catch (error) { + console.error('[FOLDER] Erreur lors de la récupération du fichier:', error) + res.status(500).json({ + success: false, + error: error.message + }) + } +}) + +// Route pour créer le dossier par défaut avec les fichiers de test +app.post('/api/folders/default', async (req, res) => { + try { + const folderHash = generateFolderHash() + const { folderPath, cachePath } = createFolderStructure(folderHash) + + console.log(`[FOLDER] Création du dossier par défaut: ${folderHash}`) + + // Charger les fichiers de test dans le dossier + const testFilesDir = path.join(__dirname, '..', 'test-files') + if (fs.existsSync(testFilesDir)) { + const testFiles = fs.readdirSync(testFilesDir) + const supportedFiles = testFiles.filter(file => + ['.pdf', '.jpg', '.jpeg', '.png', '.tiff'].includes(path.extname(file).toLowerCase()) + ) + + for (const testFile of supportedFiles) { + const sourcePath = path.join(testFilesDir, testFile) + const fileBuffer = fs.readFileSync(sourcePath) + const fileHash = calculateFileHash(fileBuffer) + const ext = path.extname(testFile) + const newFileName = `${fileHash}${ext}` + const destPath = path.join(folderPath, newFileName) + + // Copier le fichier + fs.copyFileSync(sourcePath, destPath) + + // Traiter le fichier et sauvegarder le résultat + try { + const result = await processDocument(fileBuffer, newFileName, testFile) + saveJsonCacheInFolder(folderHash, fileHash, result) + console.log(`[FOLDER] Fichier de test traité: ${testFile} -> ${fileHash}`) + } catch (error) { + console.warn(`[FOLDER] Erreur lors du traitement de ${testFile}:`, error.message) + } + } + } + + res.json({ + success: true, + folderHash, + message: 'Dossier par défaut créé avec succès' + }) + } catch (error) { + console.error('[FOLDER] Erreur lors de la création du dossier par défaut:', error) + res.status(500).json({ + success: false, + error: error.message + }) + } +}) + app.get('/api/health', (req, res) => { res.json({ status: 'OK', diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..1a01a6d --- /dev/null +++ b/docs/API.md @@ -0,0 +1,139 @@ +# Documentation API - 4NK IA Lecoffre.io + +## Vue d'ensemble + +L'application 4NK IA Lecoffre.io communique uniquement avec le backend interne pour toutes les +fonctionnalités (upload, extraction, analyse, contexte, conseil). + +## API Backend Principal + +### Base URL + +```text +http://localhost:8000 (développement) +``` + +### Endpoints + +#### Upload de document + +```http +POST /api/notary/upload +Content-Type: multipart/form-data + +Body: FormData avec le fichier +``` + +Réponse attendue (champs utilisés par le front) : + +```json +{ + "document_id": "doc_123456", + "mime_type": "application/pdf", + "functional_type": "CNI" +} +``` + +Mappage front en `Document` : + +```json +{ + "id": "doc_123456", + "name": "acte_vente.pdf", + "mimeType": "application/pdf", + "functionalType": "CNI", + "size": 1024000, + "uploadDate": "", + "status": "completed", + "previewUrl": "blob:..." +} +``` + +#### Extraction de données + +```http +GET /api/notary/documents/{documentId} +``` + +#### Analyse du document + +```http +GET /api/documents/{documentId}/analyze +``` + +#### Données contextuelles + +```http +GET /api/documents/{documentId}/context +``` + +#### Conseil IA + +```http +GET /api/documents/{documentId}/conseil +``` + +## APIs Externes + +Les APIs externes (Cadastre, Géorisques, Géofoncier, BODACC, Infogreffe) sont appelées côté backend +uniquement. Aucun appel direct côté front. + +## Gestion d'erreur + +### Codes d'erreur HTTP + +- 200 : Succès +- 400 : Requête malformée +- 404 : Ressource non trouvée +- 405 : Méthode non autorisée +- 500 : Erreur serveur interne + +### Erreurs de connexion + +- ERR_NETWORK : Erreur de réseau +- ERR_CONNECTION_REFUSED : Connexion refusée +- ERR_TIMEOUT : Timeout de la requête + +## Configuration + +### Variables d'environnement + +```env +VITE_API_URL=http://localhost:8000 +VITE_USE_OPENAI=false +VITE_OPENAI_API_KEY= +VITE_OPENAI_BASE_URL=https://api.openai.com/v1 +VITE_OPENAI_MODEL=gpt-4o-mini +``` + +## Mode OpenAI (fallback) + +Quand `VITE_USE_OPENAI=true`, le frontend bascule sur un mode de secours basé sur OpenAI: + +- Upload: simulé côté client (le fichier n’est pas envoyé à OpenAI) +- Extraction/Analyse/Conseil/Contexte: appels `chat.completions` sur `VITE_OPENAI_MODEL` +- Détection de type: heuristique simple côté client + +Ce mode est utile pour démo/diagnostic quand le backend n’est pas disponible. + +### Configuration Axios + +```typescript +const apiClient = axios.create({ + baseURL: BASE_URL, + timeout: 60000 +}) +``` + +## Authentification + +### Headers requis + +```http +Authorization: Bearer {token} +Content-Type: application/json +``` + +## Rate Limiting + +- Limites gérées par le backend diff --git a/docs/architecture-backend.md b/docs/architecture-backend.md new file mode 100644 index 0000000..317840b --- /dev/null +++ b/docs/architecture-backend.md @@ -0,0 +1,322 @@ +# Architecture Backend pour le Traitement des Documents + +## Vue d'ensemble + +L'application utilise maintenant une architecture backend qui traite les données (OCR, NER) et renvoie du JSON au frontend. Cette approche améliore les performances et centralise le traitement des documents. + +## Architecture + +### 🏗️ Structure + +``` +4NK_IA_front/ +├── backend/ # Serveur backend Express +│ ├── server.js # Serveur principal +│ ├── package.json # Dépendances backend +│ └── uploads/ # Fichiers temporaires +├── src/ # Frontend React +│ ├── services/ +│ │ ├── backendApi.ts # API backend +│ │ ├── openai.ts # Fallback local +│ │ └── ruleNer.ts # Règles NER +│ └── store/ +│ └── documentSlice.ts # Redux avec backend +└── test-files/ # Fichiers de test +``` + +### 🔄 Flux de Données + +```mermaid +graph TD + A[Frontend React] --> B[Backend Express] + B --> C[Tesseract.js OCR] + B --> D[Règles NER] + C --> E[Texte extrait] + D --> F[Entités extraites] + E --> G[JSON Response] + F --> G + G --> A +``` + +## Backend (Express.js) + +### 🚀 Serveur Principal + +**Fichier**: `backend/server.js` + +**Port**: 3001 + +**Endpoints**: +- `POST /api/extract` - Extraction de documents +- `GET /api/test-files` - Liste des fichiers de test +- `GET /api/health` - Health check + +### 📄 Traitement des Documents + +#### 1. Upload et Validation +```javascript +// Configuration multer +const upload = multer({ + storage: multer.diskStorage({...}), + limits: { fileSize: 10 * 1024 * 1024 }, // 10MB max + fileFilter: (req, file, cb) => { + const allowedTypes = ['image/jpeg', 'image/png', 'image/tiff', 'application/pdf'] + // Validation des types de fichiers + } +}) +``` + +#### 2. Extraction OCR Optimisée +```javascript +async function extractTextFromImage(imagePath) { + const worker = await createWorker('fra+eng') + + // Configuration optimisée pour cartes d'identité + const params = { + tessedit_pageseg_mode: '6', + tessedit_char_whitelist: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ...', + tessedit_ocr_engine_mode: '1', // LSTM + textord_min_xheight: '6', // Petits textes + // ... autres paramètres + } + + await worker.setParameters(params) + const { data } = await worker.recognize(imagePath) + return { text: data.text, confidence: data.confidence } +} +``` + +#### 3. Extraction NER par Règles +```javascript +function extractEntitiesFromText(text) { + const entities = { + identities: [], + addresses: [], + cniNumbers: [], + dates: [], + documentType: 'Document' + } + + // Patterns pour cartes d'identité + const namePatterns = [ + /(Vendeur|Acheteur|...)\s*:\s*([A-Z][a-zà-öø-ÿ'\-]+\s+[A-Z][a-zà-öø-ÿ'\-]+)/gi, + /^([A-Z][A-ZÀ-ÖØ-öø-ÿ\s\-']{2,30})$/gm, + // ... autres patterns + ] + + // Extraction des entités... + return entities +} +``` + +### 📊 Réponse JSON + +```json +{ + "success": true, + "documentId": "doc-1234567890", + "fileName": "IMG_20250902_162159.jpg", + "fileSize": 1077961, + "mimeType": "image/jpeg", + "processing": { + "ocr": { + "text": "Texte extrait par OCR...", + "confidence": 85.5, + "wordCount": 25 + }, + "ner": { + "identities": [...], + "addresses": [...], + "cniNumbers": [...], + "dates": [...], + "documentType": "CNI" + }, + "globalConfidence": 87.2 + }, + "extractedData": { + "documentType": "CNI", + "identities": [...], + "addresses": [...], + "cniNumbers": [...], + "dates": [...] + }, + "timestamp": "2025-09-15T23:30:00.000Z" +} +``` + +## Frontend (React) + +### 🔌 Service Backend + +**Fichier**: `src/services/backendApi.ts` + +```typescript +export async function extractDocumentBackend( + documentId: string, + file?: File, + hooks?: { onOcrProgress?: (progress: number) => void; onLlmProgress?: (progress: number) => void } +): Promise { + + const formData = new FormData() + formData.append('document', file) + + const response = await fetch(`${BACKEND_URL}/api/extract`, { + method: 'POST', + body: formData + }) + + const result: BackendExtractionResult = await response.json() + + // Conversion vers le format frontend + return convertBackendToFrontend(result) +} +``` + +### 🔄 Redux Store + +**Fichier**: `src/store/documentSlice.ts` + +```typescript +export const extractDocument = createAsyncThunk( + 'document/extract', + async (documentId: string, thunkAPI) => { + // Vérifier si le backend est disponible + const backendAvailable = await checkBackendHealth() + + if (backendAvailable) { + // Utiliser le backend + return await backendDocumentApi.extract(documentId, file, progressHooks) + } else { + // Fallback vers le mode local + return await openaiDocumentApi.extract(documentId, file, progressHooks) + } + } +) +``` + +## Démarrage + +### 🚀 Backend + +```bash +# Option 1: Script automatique +./start-backend.sh + +# Option 2: Manuel +cd backend +npm install +node server.js +``` + +### 🌐 Frontend + +```bash +npm run dev +``` + +### 🧪 Test de l'Architecture + +```bash +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 + +## Configuration + +### 🔧 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 + +### 📁 Structure des Fichiers + +``` +backend/ +├── server.js # Serveur Express +├── package.json # Dépendances +└── uploads/ # Fichiers temporaires (auto-créé) + +src/services/ +├── backendApi.ts # API backend +├── openai.ts # Fallback local +└── ruleNer.ts # Règles NER + +docs/ +└── architecture-backend.md # Cette documentation +``` + +## Dépannage + +### ❌ Problèmes Courants + +#### Backend non accessible +```bash +# Vérifier que le backend est démarré +curl http://localhost:3001/api/health + +# Vérifier les logs +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 + +### 🔍 Logs + +**Backend**: +``` +🚀 Serveur backend démarré sur le port 3001 +📡 API disponible sur: http://localhost:3001/api +[OCR] Début de l'extraction pour: uploads/document-123.jpg +[OCR] Extraction terminée - Confiance: 85.5% +[NER] Extraction terminée: 2 identités, 1 adresse, 1 CNI +``` + +**Frontend**: +``` +🚀 [STORE] Utilisation du backend pour l'extraction +📊 [PROGRESS] OCR doc-123: 30% +📊 [PROGRESS] NER doc-123: 50% +🎉 [BACKEND] Extraction terminée avec succès +``` + +## Évolutions Futures + +### 🔮 Améliorations Possibles + +1. **Base de données** : Stockage des résultats d'extraction +2. **Cache Redis** : Mise en cache des résultats OCR +3. **Queue système** : Traitement asynchrone des gros volumes +4. **API GraphQL** : Interface plus flexible +5. **Microservices** : Séparation OCR et NER +6. **Docker** : Containerisation pour le déploiement +7. **Monitoring** : Métriques et alertes +8. **Tests automatisés** : Suite de tests complète diff --git a/package-lock.json b/package-lock.json index b20adb2..e59fb16 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,7 +21,8 @@ "react-dom": "^19.1.1", "react-dropzone": "^14.3.8", "react-redux": "^9.2.0", - "react-router-dom": "^7.8.2", + "react-router-dom": "^7.9.1", + "router-dom": "^3.0.3", "sharp": "^0.34.3" }, "devDependencies": { @@ -4542,6 +4543,12 @@ "node": ">= 14" } }, + "node_modules/hydro-js": { + "version": "1.8.13", + "resolved": "https://registry.npmjs.org/hydro-js/-/hydro-js-1.8.13.tgz", + "integrity": "sha512-zgPCJCdJkCeEZL+NK9t0ojPCwKp2EEmuqTVkTBmmL3Vuu5+0+gCTV4uG16u23mS5HRVksQ18e/cqAFU7mILWGg==", + "license": "MIT" + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -6158,6 +6165,12 @@ "node": "20 || >=22" } }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "license": "MIT" + }, "node_modules/path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", @@ -6571,6 +6584,7 @@ "version": "7.9.1", "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.9.1.tgz", "integrity": "sha512-U9WBQssBE9B1vmRjo9qTM7YRzfZ3lUxESIZnsf4VjR/lXYz9MHjvOxHzr/aUm4efpktbVOrF09rL/y4VHa8RMw==", + "license": "MIT", "dependencies": { "react-router": "7.9.1" }, @@ -6726,6 +6740,16 @@ "fsevents": "~2.3.2" } }, + "node_modules/router-dom": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/router-dom/-/router-dom-3.0.3.tgz", + "integrity": "sha512-1xCnUy53CNrgJopNhOwzN6i34gRNIjhADAtPJvbISwOe/7zaQQ9tvcA/HuNbLk4PAHQcXgcyf0H3XZi84mYBAQ==", + "license": "MIT", + "dependencies": { + "hydro-js": "^1.5.22", + "path-to-regexp": "6.3.0" + } + }, "node_modules/rrweb-cssom": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", diff --git a/package.json b/package.json index d974ffe..71aaca4 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,8 @@ "react-dom": "^19.1.1", "react-dropzone": "^14.3.8", "react-redux": "^9.2.0", - "react-router-dom": "^7.8.2", + "react-router-dom": "^7.9.1", + "router-dom": "^3.0.3", "sharp": "^0.34.3" }, "devDependencies": { diff --git a/src/App.tsx b/src/App.tsx index b55d672..436f02d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,66 @@ +import { useEffect } from 'react' import './App.css' import { AppRouter } from './router' +import { useAppDispatch, useAppSelector } from './store' +import { + createDefaultFolderThunk, + loadFolderResults, + setCurrentFolderHash, + setBootstrapped +} from './store/documentSlice' export default function App() { + const dispatch = useAppDispatch() + const { documents, bootstrapped, currentFolderHash, folderResults } = useAppSelector((state) => state.document) + + // Bootstrap au démarrage de l'application avec système de dossiers + useEffect(() => { + console.log('🔍 [APP] useEffect déclenché:', { + documentsLength: documents.length, + bootstrapped, + currentFolderHash, + folderResultsLength: folderResults.length, + isDev: import.meta.env.DEV + }) + + // Récupérer le hash du dossier depuis l'URL + const urlParams = new URLSearchParams(window.location.search) + const urlFolderHash = urlParams.get('hash') + + console.log('🔍 [APP] Hash du dossier depuis URL:', urlFolderHash) + + const initializeFolder = async () => { + try { + let folderHash = urlFolderHash || currentFolderHash + + // Si pas de hash de dossier, créer le dossier par défaut + if (!folderHash) { + console.log('🚀 [APP] Création du dossier par défaut...') + const result = await dispatch(createDefaultFolderThunk()).unwrap() + folderHash = result.folderHash + console.log('✅ [APP] Dossier par défaut créé:', folderHash) + } + + // Charger les résultats du dossier + console.log('📁 [APP] Chargement des résultats du dossier:', folderHash) + await dispatch(loadFolderResults(folderHash)).unwrap() + + // Marquer le bootstrap comme terminé + 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) + } + } + + // Ne pas refaire le bootstrap si déjà fait + if (bootstrapped && folderResults.length > 0) { + console.log('⏭️ [APP] Bootstrap déjà effectué, dossier:', currentFolderHash) + return + } + + initializeFolder() + }, [dispatch, bootstrapped, currentFolderHash, folderResults.length]) + return } diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 9d53bec..0429f43 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -31,11 +31,21 @@ export const Layout: React.FC = ({ children }) => { if (docId) { 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) + if (doc) { + doc.status = 'processing' + } await dispatch(extractDocument(docId)) // Attendre un peu entre les extractions 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) + if (doc) { + doc.status = 'error' + } } } } @@ -54,7 +64,15 @@ export const Layout: React.FC = ({ children }) => { console.log(`📄 [LAYOUT] Document ${doc.id}: hasExtraction=${!!hasExtraction}, isProcessed=${isProcessed}, isProcessing=${isProcessing}, isCompleted=${isCompleted}`) - if (!hasExtraction && !isProcessed && !isProcessing && !isCompleted) { + // Si le document a déjà un résultat d'extraction, marquer comme traité + if (hasExtraction && !isProcessed) { + console.log(`✅ [LAYOUT] Document ${doc.id} a déjà un résultat, marqué comme traité`) + processedDocs.current.add(doc.id) + // Mettre à jour le statut du document + if (doc.status !== 'completed') { + doc.status = 'completed' + } + } else if (!hasExtraction && !isProcessed && !isProcessing && !isCompleted) { console.log(`🚀 [LAYOUT] Ajout à la queue: ${doc.id}`) processedDocs.current.add(doc.id) extractionQueue.current.push(doc.id) @@ -63,7 +81,7 @@ export const Layout: React.FC = ({ children }) => { // Traiter la queue processExtractionQueue() - }, [documents, dispatch]) // Retiré extractionById des dépendances + }, [documents, dispatch, extractionById]) // Remettre extractionById dans les dépendances // Déclencher contexte et conseil globaux une fois qu'un document courant existe useEffect(() => { diff --git a/src/components/NavigationTabs.tsx b/src/components/NavigationTabs.tsx index 0df78cc..b47a8c5 100644 --- a/src/components/NavigationTabs.tsx +++ b/src/components/NavigationTabs.tsx @@ -1,6 +1,7 @@ import React from 'react' import { Tabs, Tab, Box } from '@mui/material' import { useNavigate } from 'react-router-dom' +import { useAppSelector } from '../store' interface NavigationTabsProps { currentPath: string @@ -8,19 +9,25 @@ interface NavigationTabsProps { export const NavigationTabs: React.FC = ({ currentPath }) => { const navigate = useNavigate() + const { currentDocument, extractionById } = useAppSelector((state) => state.document) const tabs = [ - { label: 'Téléversement', path: '/' }, - { label: 'Extraction', path: '/extraction' }, - { label: 'Analyse', path: '/analyse' }, - { label: 'Contexte', path: '/contexte' }, - { label: 'Conseil', path: '/conseil' }, + { label: 'Téléversement', path: '/', alwaysEnabled: true }, + { label: 'Extraction', path: '/extraction', alwaysEnabled: true }, + { label: 'Contexte', path: '/contexte', alwaysEnabled: false }, + { label: 'Conseil', path: '/conseil', alwaysEnabled: false }, ] const currentTabIndex = tabs.findIndex(tab => tab.path === currentPath) + // Vérifier si au moins une extraction est terminée + const hasCompletedExtraction = currentDocument && extractionById[currentDocument.id] + const handleTabChange = (_event: React.SyntheticEvent, newValue: number) => { - navigate(tabs[newValue].path) + const tab = tabs[newValue] + if (tab.alwaysEnabled || hasCompletedExtraction) { + navigate(tab.path) + } } return ( @@ -33,7 +40,17 @@ export const NavigationTabs: React.FC = ({ currentPath }) = scrollButtons="auto" > {tabs.map((tab, index) => ( - + ))} diff --git a/src/router/index.tsx b/src/router/index.tsx index 7f3de58..a8b3b8c 100644 --- a/src/router/index.tsx +++ b/src/router/index.tsx @@ -4,7 +4,6 @@ import { Box, CircularProgress, Typography } from '@mui/material' const UploadView = lazy(() => import('../views/UploadView')) const ExtractionView = lazy(() => import('../views/ExtractionView')) -const AnalyseView = lazy(() => import('../views/AnalyseView')) const ContexteView = lazy(() => import('../views/ContexteView')) const ConseilView = lazy(() => import('../views/ConseilView')) @@ -18,7 +17,6 @@ const LoadingFallback = () => ( const router = createBrowserRouter([ { path: '/', element: }> }, { path: '/extraction', element: }> }, - { path: '/analyse', element: }> }, { path: '/contexte', element: }> }, { path: '/conseil', element: }> }, ]) diff --git a/src/services/api.ts b/src/services/api.ts index ecfdb3b..3cc6eb8 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -30,26 +30,44 @@ apiClient.interceptors.response.use( // Services API pour les documents export const documentApi = { - // Téléversement de document - upload: async (file: File): Promise => { - if (USE_OPENAI) return openaiDocumentApi.upload(file) + // Téléversement de document avec extraction + upload: async (file: File): Promise<{ document: Document; extraction: ExtractionResult }> => { + if (USE_OPENAI) { + const doc = await openaiDocumentApi.upload(file) + return { document: doc, extraction: null as any } + } const formData = new FormData() - formData.append('file', file) - const { data } = await apiClient.post('/api/notary/upload', formData) + formData.append('document', file) + const { data } = await apiClient.post('/api/extract', formData) - // L'API retourne {message, document_id, status} + // L'API retourne le résultat d'extraction complet // On doit mapper vers le format Document attendu const fileUrl = URL.createObjectURL(file) - return { - id: data.document_id || data.id || 'upload-' + Date.now(), - name: file.name, - mimeType: data.mime_type || data.mimeType || file.type || 'application/pdf', - functionalType: data.functional_type || data.functionalType || undefined, - size: file.size, - uploadDate: new Date(), + const document: Document = { + id: data.document.id || 'upload-' + Date.now(), + name: data.document.fileName || file.name, + mimeType: data.document.mimeType || file.type || 'application/pdf', + functionalType: undefined, + size: data.document.fileSize || file.size, + uploadDate: new Date(data.document.uploadTimestamp || Date.now()), status: 'completed', previewUrl: fileUrl } + + // Adapter le résultat d'extraction au format attendu + const extraction: ExtractionResult = { + documentId: document.id, + documentType: data.classification.documentType, + confidence: data.metadata.quality.globalConfidence, + text: data.extraction.text.raw, + identities: data.extraction.entities.persons || [], + addresses: data.extraction.entities.addresses || [], + companies: data.extraction.entities.companies || [], + language: data.classification.language, + timestamp: data.status.timestamp + } + + return { document, extraction } }, // Extraction des données diff --git a/src/services/folderApi.ts b/src/services/folderApi.ts new file mode 100644 index 0000000..fee0af7 --- /dev/null +++ b/src/services/folderApi.ts @@ -0,0 +1,124 @@ +/** + * API pour la gestion des dossiers par hash + */ + +const API_BASE_URL = 'http://localhost:3001/api' + +export interface FolderResult { + fileHash: string + document: { + id: string + fileName: string + mimeType: string + fileSize: number + uploadTimestamp: number + } + classification: { + documentType: string + language: string + } + extraction: { + text: { + raw: string + processed: string + } + entities: { + persons: string[] + addresses: string[] + companies: string[] + } + } + metadata: { + quality: { + globalConfidence: number + } + } + status: { + timestamp: number + } +} + +export interface FolderResponse { + success: boolean + folderHash: string + results: FolderResult[] + count: number +} + +export interface CreateFolderResponse { + success: boolean + folderHash: string + message: string +} + +// Créer un nouveau dossier +export async function createFolder(): Promise { + const response = await fetch(`${API_BASE_URL}/folders`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }) + + if (!response.ok) { + throw new Error(`Erreur lors de la création du dossier: ${response.statusText}`) + } + + return response.json() +} + +// Créer le dossier par défaut avec les fichiers de test +export async function createDefaultFolder(): Promise { + const response = await fetch(`${API_BASE_URL}/folders/default`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }) + + if (!response.ok) { + throw new Error(`Erreur lors de la création du dossier par défaut: ${response.statusText}`) + } + + return response.json() +} + +// Récupérer les résultats d'un dossier +export async function getFolderResults(folderHash: string): Promise { + const response = await fetch(`${API_BASE_URL}/folders/${folderHash}/results`) + + if (!response.ok) { + throw new Error(`Erreur lors de la récupération des résultats du dossier: ${response.statusText}`) + } + + return response.json() +} + +// Récupérer un fichier original depuis un dossier +export async function getFolderFile(folderHash: string, fileHash: string): Promise { + const response = await fetch(`${API_BASE_URL}/folders/${folderHash}/files/${fileHash}`) + + if (!response.ok) { + throw new Error(`Erreur lors de la récupération du fichier: ${response.statusText}`) + } + + return response.blob() +} + +// Uploader un fichier dans un dossier +export async function uploadFileToFolder(file: File, folderHash: string): Promise { + const formData = new FormData() + formData.append('document', file) + formData.append('folderHash', folderHash) + + const response = await fetch(`${API_BASE_URL}/extract`, { + method: 'POST', + body: formData, + }) + + if (!response.ok) { + throw new Error(`Erreur lors de l'upload: ${response.statusText}`) + } + + return response.json() +} diff --git a/src/services/testFilesApi.ts b/src/services/testFilesApi.ts index e070a1b..071f879 100644 --- a/src/services/testFilesApi.ts +++ b/src/services/testFilesApi.ts @@ -14,39 +14,47 @@ export interface TestFileInfo { */ export async function getTestFilesList(): Promise { try { - // En mode développement, on peut utiliser une API pour lister les fichiers - // Pour l'instant, on utilise une approche simple avec les fichiers connus - const knownFiles = [ - 'IMG_20250902_162159.jpg', - 'IMG_20250902_162210.jpg', - 'sample.md', - 'sample.pdf', - 'sample.txt' - ] - - const files: TestFileInfo[] = [] - - for (const fileName of knownFiles) { - try { - const response = await fetch(`/test-files/${fileName}`, { method: 'HEAD' }) - if (response.ok) { - const contentLength = response.headers.get('content-length') - const contentType = response.headers.get('content-type') - const lastModified = response.headers.get('last-modified') - - files.push({ - name: fileName, - size: contentLength ? parseInt(contentLength, 10) : 0, - type: contentType || 'application/octet-stream', - lastModified: lastModified ? new Date(lastModified).getTime() : Date.now() - }) - } - } catch (error) { - console.warn(`Impossible de vérifier le fichier ${fileName}:`, error) - } + // Utiliser l'API du backend pour récupérer la liste des fichiers + const response = await fetch('http://localhost:3001/api/test-files') + if (!response.ok) { + throw new Error(`Erreur API: ${response.status}`) } - return files + const data = await response.json() + if (!data.success) { + throw new Error('API retourne success: false') + } + + // Convertir le format du backend vers le format attendu + return data.files.map((file: any) => { + let mimeType = 'application/octet-stream' + + // Convertir l'extension en type MIME + switch (file.type) { + case '.jpg': + case '.jpeg': + mimeType = 'image/jpeg' + break + case '.png': + mimeType = 'image/png' + break + case '.pdf': + mimeType = 'application/pdf' + break + case '.tiff': + mimeType = 'image/tiff' + break + default: + mimeType = 'application/octet-stream' + } + + return { + name: file.name, + size: file.size, + type: mimeType, + lastModified: new Date(file.lastModified).getTime() + } + }) } catch (error) { console.error('Erreur lors de la récupération de la liste des fichiers de test:', error) return [] @@ -58,7 +66,7 @@ export async function getTestFilesList(): Promise { */ export async function loadTestFile(fileName: string): Promise { try { - const response = await fetch(`/test-files/${fileName}`) + const response = await fetch(`http://localhost:3001/api/test-files/${fileName}`) if (!response.ok) { throw new Error(`Fichier non trouvé: ${fileName}`) } diff --git a/src/store/appSlice.ts b/src/store/appSlice.ts index cd25a36..7fffdc8 100644 --- a/src/store/appSlice.ts +++ b/src/store/appSlice.ts @@ -15,3 +15,5 @@ const appSlice = createSlice({ }) export const appReducer = appSlice.reducer + + diff --git a/src/store/documentSlice.ts b/src/store/documentSlice.ts index c421c1e..9fc6762 100644 --- a/src/store/documentSlice.ts +++ b/src/store/documentSlice.ts @@ -4,6 +4,7 @@ import type { Document, ExtractionResult, AnalysisResult, ContextResult, Conseil import { documentApi } from '../services/api' import { openaiDocumentApi } from '../services/openai' import { backendDocumentApi, checkBackendHealth } from '../services/backendApi' +import { createDefaultFolder, getFolderResults, uploadFileToFolder, type FolderResult } from '../services/folderApi' interface DocumentState { documents: Document[] @@ -17,6 +18,43 @@ interface DocumentState { loading: boolean error: string | null progressById: Record + bootstrapped: boolean // Flag pour indiquer si le bootstrap a été effectué + // Nouvelles propriétés pour les dossiers + currentFolderHash: string | null + folderResults: FolderResult[] + currentResultIndex: number +} + +// Fonction pour charger l'état depuis localStorage +const loadStateFromStorage = (): Partial => { + try { + const savedState = localStorage.getItem('4nk-ia-documents') + if (savedState) { + const parsed = JSON.parse(savedState) + console.log('💾 [STORE] État chargé depuis localStorage:', { + documentsCount: parsed.documents?.length || 0, + extractionsCount: Object.keys(parsed.extractionById || {}).length + }) + return parsed + } + } catch (error) { + console.warn('⚠️ [STORE] Erreur lors du chargement depuis localStorage:', error) + } + 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 = { @@ -31,6 +69,12 @@ const initialState: DocumentState = { loading: false, error: null, progressById: {}, + bootstrapped: false, + // Nouvelles propriétés pour les dossiers + currentFolderHash: null, + folderResults: [], + currentResultIndex: 0, + ...loadStateFromStorage() } export const uploadDocument = createAsyncThunk( @@ -127,6 +171,28 @@ export const getConseil = createAsyncThunk( } ) +// Thunks pour la gestion des dossiers +export const createDefaultFolderThunk = createAsyncThunk( + 'document/createDefaultFolder', + async () => { + return await createDefaultFolder() + } +) + +export const loadFolderResults = createAsyncThunk( + 'document/loadFolderResults', + async (folderHash: string) => { + return await getFolderResults(folderHash) + } +) + +export const uploadFileToFolderThunk = createAsyncThunk( + 'document/uploadFileToFolder', + async ({ file, folderHash }: { file: File; folderHash: string }) => { + return await uploadFileToFolder(file, folderHash) + } +) + const documentSlice = createSlice({ name: 'document', initialState, @@ -175,6 +241,20 @@ const documentSlice = createSlice({ 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))) } }, + setBootstrapped: (state, action: PayloadAction) => { + state.bootstrapped = action.payload + }, + // Nouveaux reducers pour les dossiers + setCurrentFolderHash: (state, action: PayloadAction) => { + state.currentFolderHash = action.payload + }, + setCurrentResultIndex: (state, action: PayloadAction) => { + state.currentResultIndex = action.payload + }, + clearFolderResults: (state) => { + state.folderResults = [] + state.currentResultIndex = 0 + }, }, extraReducers: (builder) => { builder @@ -184,10 +264,27 @@ const documentSlice = createSlice({ }) .addCase(uploadDocument.fulfilled, (state, action) => { state.loading = false - state.documents.push(action.payload) - state.currentDocument = action.payload + const { document, extraction } = action.payload + + console.log('📤 [STORE] Upload fulfilled:', { + documentId: document.id, + documentName: document.name, + hasExtraction: !!extraction, + extractionDocumentId: extraction?.documentId + }) + + state.documents.push(document) + state.currentDocument = document + + // Stocker le résultat d'extraction si disponible + if (extraction) { + state.extractionResult = extraction + state.extractionById[document.id] = extraction + console.log('✅ [STORE] Extraction stored for document:', document.id) + } + // Capture le File depuis l'URL blob si disponible - if (action.payload.previewUrl?.startsWith('blob:')) { + if (document.previewUrl?.startsWith('blob:')) { // On ne peut pas récupérer l'objet File initial ici sans passer par onDrop; // il est reconstruit lors de l'extraction via fetch blob. } @@ -200,14 +297,22 @@ const documentSlice = createSlice({ state.loading = true state.error = null }) - .addCase(extractDocument.fulfilled, (state, action) => { - state.loading = false - state.extractionResult = action.payload - state.extractionById[action.payload.documentId] = action.payload - }) + .addCase(extractDocument.fulfilled, (state, action) => { + state.loading = false + state.extractionResult = action.payload + state.extractionById[action.payload.documentId] = action.payload + // Mettre à jour le statut du document courant + if (state.currentDocument && state.currentDocument.id === action.payload.documentId) { + state.currentDocument.status = 'completed' + } + }) .addCase(extractDocument.rejected, (state, action) => { state.loading = false 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' + } }) .addCase(analyzeDocument.fulfilled, (state, action) => { state.analysisResult = action.payload @@ -218,8 +323,64 @@ const documentSlice = createSlice({ .addCase(getConseil.fulfilled, (state, action) => { state.conseilResult = action.payload }) + // ExtraReducers pour les dossiers + .addCase(createDefaultFolderThunk.fulfilled, (state, action) => { + state.currentFolderHash = action.payload.folderHash + state.loading = false + }) + .addCase(createDefaultFolderThunk.pending, (state) => { + state.loading = true + }) + .addCase(createDefaultFolderThunk.rejected, (state, action) => { + state.loading = false + state.error = action.error.message || 'Erreur lors de la création du dossier par défaut' + }) + .addCase(loadFolderResults.fulfilled, (state, action) => { + state.folderResults = action.payload.results + state.currentFolderHash = action.payload.folderHash + state.loading = false + // Convertir les résultats en documents pour la compatibilité + state.documents = action.payload.results.map((result, index) => ({ + id: result.fileHash, + name: result.document.fileName, + mimeType: result.document.mimeType, + size: result.document.fileSize, + uploadDate: new Date(result.document.uploadTimestamp), + status: 'completed' as const, + previewUrl: `blob:folder-${result.fileHash}` + })) + }) + .addCase(loadFolderResults.pending, (state) => { + state.loading = true + }) + .addCase(loadFolderResults.rejected, (state, action) => { + state.loading = false + state.error = action.error.message || 'Erreur lors du chargement des résultats du dossier' + }) + .addCase(uploadFileToFolderThunk.fulfilled, (state, action) => { + // Recharger les résultats du dossier après upload + state.loading = false + }) + .addCase(uploadFileToFolderThunk.pending, (state) => { + state.loading = true + }) + .addCase(uploadFileToFolderThunk.rejected, (state, action) => { + state.loading = false + state.error = action.error.message || 'Erreur lors de l\'upload du fichier' + }) }, }) -export const { setCurrentDocument, clearResults, addDocuments, removeDocument, setOcrProgress, setLlmProgress } = documentSlice.actions +export const { + setCurrentDocument, + clearResults, + addDocuments, + removeDocument, + setOcrProgress, + setLlmProgress, + setBootstrapped, + setCurrentFolderHash, + setCurrentResultIndex, + clearFolderResults +} = documentSlice.actions export const documentReducer = documentSlice.reducer diff --git a/src/store/index.ts b/src/store/index.ts index 102780d..90fb6ae 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -4,6 +4,28 @@ import type { TypedUseSelectorHook } from 'react-redux' import { appReducer } from './appSlice' import { documentReducer } from './documentSlice' +// Middleware pour sauvegarder l'état dans localStorage +const persistenceMiddleware = (store: any) => (next: any) => (action: any) => { + const result = next(action) + + // Sauvegarder seulement les actions liées aux documents + if (action.type.startsWith('document/')) { + const state = store.getState() + try { + const stateToSave = { + documents: state.document.documents, + extractionById: state.document.extractionById, + currentDocument: state.document.currentDocument + } + localStorage.setItem('4nk-ia-documents', JSON.stringify(stateToSave)) + } catch (error) { + console.warn('⚠️ [STORE] Erreur lors de la sauvegarde:', error) + } + } + + return result +} + export const store = configureStore({ reducer: { app: appReducer, @@ -12,7 +34,7 @@ export const store = configureStore({ middleware: (getDefaultMiddleware) => getDefaultMiddleware({ serializableCheck: false, immutableCheck: true, - }), + }).concat(persistenceMiddleware), devTools: true, }) diff --git a/src/views/AnalyseView.tsx b/src/views/AnalyseView.tsx deleted file mode 100644 index 8d76650..0000000 --- a/src/views/AnalyseView.tsx +++ /dev/null @@ -1,248 +0,0 @@ -import { useEffect } from 'react' -import { - Box, - Typography, - Paper, - Card, - CardContent, - Chip, - List, - ListItem, - ListItemText, - ListItemIcon, - Alert, - LinearProgress, -} from '@mui/material' -import { - CheckCircle, - Error, - Warning, - Flag, - Security, - Assessment, - Info, -} from '@mui/icons-material' -import type { ChipProps, LinearProgressProps } from '@mui/material' -import { useAppDispatch, useAppSelector } from '../store' -import { analyzeDocument, getConseil, getContextData } from '../store/documentSlice' -import { Layout } from '../components/Layout' - -export default function AnalyseView() { - const dispatch = useAppDispatch() - const { currentDocument, analysisResult, loading, conseilResult, contextResult } = useAppSelector((state) => state.document) - - useEffect(() => { - if (!currentDocument) return - if (!analysisResult) dispatch(analyzeDocument(currentDocument.id)) - if (!conseilResult) dispatch(getConseil(currentDocument.id)) - if (!contextResult) dispatch(getContextData(currentDocument.id)) - }, [currentDocument, analysisResult, conseilResult, contextResult, dispatch]) - - if (!currentDocument) { - return ( - - - Veuillez d'abord téléverser et sélectionner un document. - - - ) - } - - if (loading) { - return ( - - - - Analyse en cours... - - - ) - } - - if (!analysisResult) { - return ( - - - Aucun résultat d'analyse disponible. - - - ) - } - - const getScoreColor = (score: number): ChipProps['color'] => { - if (score >= 0.8) return 'success' - if (score >= 0.6) return 'warning' - return 'error' - } - - const getScoreIcon = (score: number) => { - if (score >= 0.8) return - if (score >= 0.6) return - return - } - - return ( - - - Analyse du document - - - - {/* Résumé général */} - - - Résumé de l'analyse - - - } - label={`Avancement: ${Math.round(analysisResult.credibilityScore * 100)}%`} - color={getScoreColor(analysisResult.credibilityScore)} - variant="filled" - /> - {analysisResult.isCNI && ( - } - label={`Pays: ${analysisResult.country}`} - color="secondary" - variant="outlined" - /> - )} - - - - {/* Cas CNI */} - {analysisResult.isCNI && ( - - - - - Vérification CNI - - {analysisResult.verificationResult && ( - - - - {analysisResult.verificationResult.numberValid ? ( - - ) : ( - - )} - - - - - - {analysisResult.verificationResult.formatValid ? ( - - ) : ( - - )} - - - - - - {analysisResult.verificationResult.checksumValid ? ( - - ) : ( - - )} - - - - - )} - - - )} - - - {/* Score de vraisemblance */} - - - - - Score de vraisemblance - - - {getScoreIcon(analysisResult.credibilityScore)} - - {(analysisResult.credibilityScore * 100).toFixed(1)}% - - - - - {analysisResult.credibilityScore >= 0.8 - ? 'Document très fiable' - : analysisResult.credibilityScore >= 0.6 - ? 'Document moyennement fiable' - : 'Document peu fiable - vérification recommandée'} - - - - - - {/* Synthèse */} - - - - - Synthèse - - - {analysisResult.summary} - - - - - - - {/* Recommandations */} - - - - Recommandations - - - {analysisResult.recommendations.map((recommendation, index) => ( - - - - - - - ))} - - - - - - ) -} \ No newline at end of file diff --git a/src/views/ConseilView.tsx b/src/views/ConseilView.tsx index dbd33f9..5bf856b 100644 --- a/src/views/ConseilView.tsx +++ b/src/views/ConseilView.tsx @@ -13,6 +13,7 @@ import { Chip, Button, CircularProgress, + LinearProgress, } from '@mui/material' import { Lightbulb, @@ -21,23 +22,30 @@ import { TrendingUp, Schedule, Psychology, + Assessment, + Error, } from '@mui/icons-material' -import type { SvgIconProps } from '@mui/material' +import type { SvgIconProps, ChipProps, LinearProgressProps } from '@mui/material' import { useAppDispatch, useAppSelector } from '../store' -import { getConseil } from '../store/documentSlice' +import { getConseil, analyzeDocument } from '../store/documentSlice' import { Layout } from '../components/Layout' export default function ConseilView() { const dispatch = useAppDispatch() - const { currentDocument, conseilResult, loading } = useAppSelector( + const { currentDocument, conseilResult, analysisResult, loading } = useAppSelector( (state) => state.document ) useEffect(() => { - if (currentDocument && !conseilResult) { - dispatch(getConseil(currentDocument.id)) + if (currentDocument) { + if (!conseilResult) { + dispatch(getConseil(currentDocument.id)) + } + if (!analysisResult) { + dispatch(analyzeDocument(currentDocument.id)) + } } - }, [currentDocument, conseilResult, dispatch]) + }, [currentDocument, conseilResult, analysisResult, dispatch]) if (!currentDocument) { return ( @@ -80,6 +88,18 @@ export default function ConseilView() { return 'info' } + const getScoreColor = (score: number): ChipProps['color'] => { + if (score >= 0.8) return 'success' + if (score >= 0.6) return 'warning' + return 'error' + } + + const getScoreIcon = (score: number) => { + if (score >= 0.8) return + if (score >= 0.6) return + return + } + return ( @@ -88,6 +108,52 @@ export default function ConseilView() { + {/* Score de vraisemblance */} + {analysisResult && ( + + + + + Score de vraisemblance + + + {getScoreIcon(analysisResult.credibilityScore)} + + {(analysisResult.credibilityScore * 100).toFixed(1)}% + + + + + {analysisResult.credibilityScore >= 0.8 + ? 'Document très fiable' + : analysisResult.credibilityScore >= 0.6 + ? 'Document moyennement fiable' + : 'Document peu fiable - vérification recommandée'} + + {analysisResult.summary && ( + + + {analysisResult.summary} + + + )} + + + )} + {/* Analyse LLM */} @@ -120,7 +186,7 @@ export default function ConseilView() { - Recommandations ({conseilResult.recommendations.length}) + Recommandations ({conseilResult.recommendations.length + (analysisResult?.recommendations?.length || 0)}) {conseilResult.recommendations.map((recommendation, index) => ( @@ -131,6 +197,15 @@ export default function ConseilView() { ))} + {/* Ajouter les recommandations d'analyse si disponibles */} + {analysisResult?.recommendations?.map((recommendation, index) => ( + + + + + + + ))} @@ -218,8 +293,16 @@ export default function ConseilView() { Résumé exécutif + {analysisResult && ( + } + label={`Score: ${(analysisResult.credibilityScore * 100).toFixed(1)}%`} + color={getScoreColor(analysisResult.credibilityScore)} + variant="filled" + /> + )} diff --git a/src/views/ContexteView.tsx b/src/views/ContexteView.tsx index 7144dc6..0488f08 100644 --- a/src/views/ContexteView.tsx +++ b/src/views/ContexteView.tsx @@ -296,4 +296,5 @@ export default function ContexteView() { ) -} \ No newline at end of file +} + diff --git a/src/views/ExtractionView.tsx b/src/views/ExtractionView.tsx index 02660eb..7a435e6 100644 --- a/src/views/ExtractionView.tsx +++ b/src/views/ExtractionView.tsx @@ -1,4 +1,4 @@ -import { useEffect } from 'react' +import { useState } from 'react' import { Box, Typography, @@ -10,12 +10,13 @@ import { ListItem, ListItemText, Alert, - CircularProgress, - Button, - Tooltip, Accordion, AccordionSummary, AccordionDetails, + IconButton, + Stepper, + Step, + StepLabel, } from '@mui/material' import { Person, @@ -25,817 +26,257 @@ import { Language, Verified, ExpandMore, - AttachMoney, - CalendarToday, - Gavel, - Edit, TextFields, Assessment, + NavigateBefore, + NavigateNext, } from '@mui/icons-material' import { useAppDispatch, useAppSelector } from '../store' -import { extractDocument, setCurrentDocument } from '../store/documentSlice' +import { setCurrentResultIndex } from '../store/documentSlice' import { Layout } from '../components/Layout' export default function ExtractionView() { const dispatch = useAppDispatch() - const { currentDocument, extractionResult, extractionById, documents } = useAppSelector((state) => state.document) + const { folderResults, currentResultIndex } = useAppSelector((state) => state.document) + + const [currentIndex, setCurrentIndex] = useState(currentResultIndex) - useEffect(() => { - if (!currentDocument) return - const cached = extractionById[currentDocument.id] - const isExtracting = currentDocument.status === 'processing' - - // Ne relancer l'extraction que si elle n'existe pas ET qu'elle n'est pas en cours - if (!cached && !isExtracting) { - console.log(`🔄 [EXTRACTION] Relancement extraction pour ${currentDocument.id}`) - dispatch(extractDocument(currentDocument.id)) - } else if (cached) { - console.log(`✅ [EXTRACTION] Résultat existant trouvé pour ${currentDocument.id}`) - } else if (isExtracting) { - console.log(`⏳ [EXTRACTION] Extraction en cours pour ${currentDocument.id}`) - } - }, [currentDocument, extractionById, dispatch]) - - const currentIndex = currentDocument ? Math.max(0, documents.findIndex(d => d.id === currentDocument.id)) : -1 + // Utiliser les résultats du dossier pour la navigation + const currentResult = folderResults[currentIndex] const hasPrev = currentIndex > 0 - const hasNext = currentIndex >= 0 && currentIndex < documents.length - 1 + const hasNext = currentIndex < folderResults.length - 1 - const gotoDoc = (index: number) => { - const doc = documents[index] - if (!doc) return - dispatch(setCurrentDocument(doc)) - // Laisser l'effet décider si une nouvelle extraction est nécessaire + const gotoResult = (index: number) => { + if (index >= 0 && index < folderResults.length) { + setCurrentIndex(index) + dispatch(setCurrentResultIndex(index)) + } } - if (!currentDocument) { + const goToPrevious = () => { + if (hasPrev) { + gotoResult(currentIndex - 1) + } + } + + const goToNext = () => { + if (hasNext) { + gotoResult(currentIndex + 1) + } + } + + if (folderResults.length === 0) { return ( - Veuillez d'abord téléverser et sélectionner un document. + Aucun résultat d'extraction disponible. Veuillez d'abord téléverser des documents. ) } - // Vérifier l'état d'extraction du document courant - const isExtracting = currentDocument.status === 'processing' - const hasExtractionResult = extractionById[currentDocument.id] - - // Si extraction en cours, afficher un loader avec les étapes grisées - if (isExtracting && !hasExtractionResult) { + if (!currentResult) { return ( - - - - Extraction en cours... - - - {/* Afficher les étapes grisées */} - - - Résultats d'extraction - {currentDocument.name} - - - L'extraction est en cours. Les résultats seront affichés une fois terminée. - - - {/* Étapes grisées */} - - - - - Texte extrait - - En cours d'extraction... - - - - - - Identités - - En cours d'analyse... - - - - - - Adresses - - En cours d'analyse... - - - - - - Sociétés - - En cours d'analyse... - - - - + + Erreur: Résultat d'extraction non trouvé. + ) } - const activeResult = currentDocument ? (extractionById[currentDocument.id] || extractionResult) : extractionResult - - if (!activeResult) { - return ( - - - - Aucun résultat d'extraction disponible pour ce document. - - - - - ) - } - - // Adapter le résultat pour le nouveau format JSON standard - const getStandardResult = (result: any) => { - // Si c'est déjà le nouveau format, on le retourne tel quel - if (result.extraction && result.classification) { - return result - } - - // Sinon, on adapte l'ancien format - return { - document: { - id: result.documentId, - fileName: currentDocument?.name || 'Document', - fileSize: currentDocument?.size || 0, - mimeType: currentDocument?.mimeType || 'application/octet-stream', - uploadTimestamp: new Date().toISOString() - }, - classification: { - documentType: result.documentType || 'Document', - confidence: result.confidence || 0.8, - subType: result.documentType || 'Document', - language: result.language || 'fr', - pageCount: 1 - }, - extraction: { - text: { - raw: result.text || '', - processed: result.text || '', - wordCount: result.text ? result.text.split(/\s+/).length : 0, - characterCount: result.text ? result.text.length : 0, - confidence: result.confidence || 0.8 - }, - entities: { - persons: result.identities?.filter((id: any) => id.type === 'person') || [], - companies: result.identities?.filter((id: any) => id.type === 'company') || [], - addresses: result.addresses || [], - financial: { amounts: [], totals: {}, payment: {} }, - dates: [], - contractual: { clauses: [], signatures: [] }, - references: [] - } - }, - metadata: { - processing: { - engine: '4NK_IA_Backend', - version: '1.0.0', - processingTime: '0ms', - ocrEngine: 'tesseract.js', - nerEngine: 'rule-based', - preprocessing: { applied: true, reason: 'Image preprocessing applied' } - }, - quality: { - globalConfidence: result.confidence || 0.8, - textExtractionConfidence: result.confidence || 0.8, - entityExtractionConfidence: 0.90, - classificationConfidence: result.confidence || 0.8 - } - }, - status: { - success: true, - errors: [], - warnings: [], - timestamp: new Date().toISOString() - } - } - } - - const standardResult = getStandardResult(activeResult) + // Utiliser le résultat d'extraction du dossier + const extraction = currentResult return ( - - Extraction des données - - - {/* Navigation entre documents */} - {documents.length > 0 && ( - - - - {currentIndex + 1} / {documents.length} + + + Résultats d'extraction + + + {/* Navigation */} + + + + + + + Document {currentIndex + 1} sur {folderResults.length} - - {currentDocument && ( - - Document: {currentDocument.name} - - )} + + + + - )} - - {/* Informations générales */} - + {/* Stepper pour la navigation */} + + {folderResults.map((result, index) => ( + + gotoResult(index)} + sx={{ cursor: 'pointer' }} + > + {result.document.fileName} + + + ))} + + + + {/* Informations du document courant */} + + + + + + {extraction.document.fileName} + + + + + + + } + label={`Langue: ${extraction.classification.language}`} + color="info" + variant="outlined" + /> + } + label={`Type: ${extraction.classification.documentType}`} + color="success" + variant="outlined" + /> + } + label={`Confiance: ${(extraction.metadata.quality.globalConfidence * 100).toFixed(1)}%`} + color={extraction.metadata.quality.globalConfidence > 0.8 ? 'success' : 'warning'} + variant="outlined" + /> + + + + + {/* Texte extrait */} + + - Informations générales + + Texte extrait - - - - } - label={`Langue: ${standardResult.classification.language}`} - color="primary" - variant="outlined" - /> - } - label={`Type: ${standardResult.classification.documentType}`} - color="secondary" - variant="outlined" - /> - {standardResult.classification.subType && ( - - )} - - } - label={`Confiance: ${Math.round(standardResult.metadata.quality.globalConfidence * 100)}%`} - color={standardResult.metadata.quality.globalConfidence > 0.8 ? 'success' : 'warning'} - variant="outlined" - /> - - - - {/* Métadonnées de traitement */} - - } - label={`Moteur: ${standardResult.metadata.processing.engine}`} - size="small" - variant="outlined" - /> - - - - - - - {/* Aperçu du document */} - {currentDocument && ( - - - Aperçu du document - - {(() => { - const isPDF = currentDocument.mimeType.includes('pdf') || currentDocument.name.toLowerCase().endsWith('.pdf') - const isImage = - currentDocument.mimeType.startsWith('image/') || - ['.png', '.jpg', '.jpeg', '.gif', '.webp'].some((ext) => currentDocument.name.toLowerCase().endsWith(ext)) - if (isImage && currentDocument.previewUrl) { - return ( - - {currentDocument.name} - - ) - } - if (isPDF && currentDocument.previewUrl) { - return ( - -