feat(front): afficher nom du dossier et nom lisible des documents; dialog création (nom+description)\nfeat(backend): meta dossier (name, description); MRZ CNI robuste; routes meta/cache/reprocess\nchore: spinner chargement extraction; retirer navigation\nci: docker_tag=dev-test

This commit is contained in:
4NK IA 2025-09-17 13:04:43 +00:00
parent 883f49e2e2
commit fa50a0c2e6
17 changed files with 608 additions and 145 deletions

2
.env
View File

@ -7,6 +7,8 @@ VITE_APP_VERSION=0.1.0
VITE_USE_RULE_NER=true
VITE_LLM_CLASSIFY_ONLY=true
VITE_BACKEND_URL=http://localhost:3001
# Configuration des services externes (optionnel)
VITE_CADASTRE_API_URL=https://apicarto.ign.fr/api/cadastre
VITE_GEORISQUES_API_URL=https://www.georisques.gouv.fr/api

View File

@ -2,11 +2,13 @@
VITE_API_URL=http://localhost:18000
# Configuration pour le développement
VITE_APP_NAME=4NK IA Lecoffre.io
VITE_APP_NAME=IA Lecoffre.io
VITE_APP_VERSION=0.1.0
VITE_USE_RULE_NER=true
VITE_LLM_CLASSIFY_ONLY=true
VITE_BACKEND_URL=http://localhost:3001
# Configuration des services externes (optionnel)
VITE_CADASTRE_API_URL=https://apicarto.ign.fr/api/cadastre
VITE_GEORISQUES_API_URL=https://www.georisques.gouv.fr/api

1
backend.pid Normal file
View File

@ -0,0 +1 @@

Binary file not shown.

View File

@ -92,6 +92,38 @@ function createFolderStructure(folderHash) {
return { folderPath, cachePath }
}
// Écrit un fichier de métadonnées du dossier (uploads/<hash>/folder.json)
function writeFolderMeta(folderHash, name, description) {
try {
const { folderPath } = createFolderStructure(folderHash)
const metaPath = path.join(folderPath, 'folder.json')
const meta = {
folderHash,
name: name || null,
description: description || null,
updatedAt: new Date().toISOString(),
}
fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2))
return true
} catch (e) {
console.warn('[FOLDER] Impossible d\'écrire folder.json:', e?.message || e)
return false
}
}
// Lit le fichier de métadonnées du dossier
function readFolderMeta(folderHash) {
try {
const metaPath = path.join('uploads', folderHash, 'folder.json')
if (!fs.existsSync(metaPath)) return null
const raw = fs.readFileSync(metaPath, 'utf8')
return JSON.parse(raw)
} catch (e) {
console.warn('[FOLDER] Impossible de lire folder.json:', e?.message || e)
return null
}
}
// Fonction pour sauvegarder le cache JSON dans un dossier spécifique
function saveJsonCacheInFolder(folderHash, fileHash, result) {
const { cachePath } = createFolderStructure(folderHash)
@ -817,6 +849,7 @@ function generateStandardJSON(documentInfo, ocrResult, entities, processingTime)
document: {
id: documentId,
fileName: documentInfo.originalname,
displayName: documentInfo.displayName || documentInfo.originalname,
fileSize: documentInfo.size,
mimeType: documentInfo.mimetype,
uploadTimestamp: timestamp,
@ -1067,6 +1100,76 @@ function extractEntitiesFromText(text) {
documentType: 'Document',
}
// Extraction spécifique CNI (MRZ et libellés FR)
try {
const t = correctedText.replace(/\u200B|\u200E|\u200F/g, '')
// MRZ de CNI (deux ou trois lignes, séparateur << : NOM<<PRENOMS)
// Scanner le texte complet (sans filtrage par lignes) et normaliser
const mrzText = t
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toUpperCase()
const mrzRegex = /([A-Z]{2,})<<([A-Z<]{2,})/g
let match
while ((match = mrzRegex.exec(mrzText)) !== null) {
const rawSurname = match[1].replace(/</g, ' ').trim()
// Certains MRZ concatènent plusieurs prénoms séparés par <<
const rawGiven = match[2].split('<<')[0].replace(/</g, ' ').trim()
if (rawSurname.length >= 2 && rawGiven.length >= 2) {
entities.identities.push({
id: `identity-${(Array.isArray(entities.identities)?entities.identities:[]).length}`,
type: 'person',
firstName: capitalize(rawGiven.split(/\s+/)[0]),
lastName: rawSurname.toUpperCase(),
confidence: 0.99,
source: 'mrz',
})
break
}
}
// Repli: si pas d'identité MRZ extraite, tenter reconstruction NOM/PRÉNOM séparés
if (!(Array.isArray(entities.identities) && entities.identities.some((i)=> (i.source||'').toLowerCase()==='mrz'))) {
// Chercher NOM après IDFRA (ex: IDFRACANTU<<<<...)
const mSurname = mrzText.match(/IDFRA\s*([A-Z]{2,})</)
// Chercher PRENOM en premier avant << (ex: NICOLAS<<FRANCS)
const mGiven = mrzText.match(/\b([A-Z]{2,})<<[A-Z<]{2,}\b/)
const last = mSurname?.[1]?.trim()
const first = mGiven?.[1]?.trim()
if (last && last.length >= 2 && first && first.length >= 2) {
entities.identities.push({
id: `identity-${(Array.isArray(entities.identities)?entities.identities:[]).length}`,
type: 'person',
firstName: capitalize(first.toLowerCase()),
lastName: last.toUpperCase(),
confidence: 0.97,
source: 'mrz-heuristic',
})
}
}
// Libellés français typiques de CNI
// NOM : XXXX PRENOM(S) : YYYYY
const labelName = t.match(/\bNOM\s*[:\-]?\s*([A-ZÀ-ÖØ-Þ\-\s]{2,})/i)
const labelGiven = t.match(/\bPRÉ?NOM\S*\s*[:\-]?\s*([A-Za-zÀ-ÖØ-öø-ÿ'\-\s]{2,})/i)
if (labelName || labelGiven) {
const last = (labelName?.[1] || '').replace(/[^A-Za-zÀ-ÖØ-Þ'\-\s]/g, '').trim()
const first = (labelGiven?.[1] || '').replace(/[^A-Za-zÀ-ÖØ-öø-ÿ'\-\s]/g, '').trim()
if (last || first) {
entities.identities.push({
id: `identity-${(Array.isArray(entities.identities)?entities.identities:[]).length}`,
type: 'person',
firstName: first ? capitalize(first.split(/\s+/)[0]) : '',
lastName: last ? last.toUpperCase() : '',
confidence: 0.9,
source: 'label',
})
}
}
} catch (e) {
console.warn('[NER] Erreur parsing CNI:', e?.message || e)
}
// Extraction des noms avec patterns généraux
const namePatterns = [
// Patterns pour documents officiels
@ -1229,6 +1332,50 @@ function extractEntitiesFromText(text) {
entities.documentType = 'Contrat'
}
// Post-traitement des identités: privilégier MRZ, filtrer les faux positifs
try {
const wordsBlacklist = new Set([
'ME', 'DE', 'DU', 'DES', 'LA', 'LE', 'LES', 'ET', 'OU', 'EL', 'DEL', 'D', 'M', 'MR', 'MME',
'FACTURE', 'CONDITIONS', 'PAIEMENT', 'SIGNATURE', 'ADDRESS', 'ADRESSE', 'TEL', 'TÉL', 'EMAIL',
])
const isValidName = (first, last) => {
const a = (first || '').replace(/[^A-Za-zÀ-ÖØ-öø-ÿ'\-\s]/g, '').trim()
const b = (last || '').replace(/[^A-Za-zÀ-ÖØ-öø-ÿ'\-\s]/g, '').trim()
if (!a && !b) return false
if (a && (a.length < 2 || wordsBlacklist.has(a.toUpperCase()))) return false
if (b && b.length < 2) return false
return true
}
// Séparer MRZ vs autres
const mrz = []
const others = []
for (const id of (Array.isArray(entities.identities) ? entities.identities : [])) {
if (!isValidName(id.firstName, id.lastName)) continue
if ((id.source || '').toLowerCase() === 'mrz') mrz.push(id)
else others.push(id)
}
// Dédupliquer par (first,last)
const dedup = (arr) => {
const seen = new Set()
const out = []
for (const it of arr) {
const key = `${(it.firstName || '').toLowerCase()}::${(it.lastName || '').toLowerCase()}`
if (seen.has(key)) continue
seen.add(key)
out.push(it)
}
return out
}
let finalIds = dedup(mrz).concat(dedup(others))
// Si une identité MRZ existe, limiter à 1-2 meilleures (éviter bruit)
if (mrz.length > 0) {
finalIds = dedup(mrz).slice(0, 2)
}
entities.identities = finalIds
} catch (e) {
console.warn('[NER] Post-processing identities error:', e?.message || e)
}
console.log(`[NER] Extraction terminée:`)
console.log(` - Identités: ${(Array.isArray(entities.identities)?entities.identities:[]).length}`)
console.log(` - Sociétés: ${(Array.isArray(entities.companies)?entities.companies:[]).length}`)
@ -1242,6 +1389,11 @@ function extractEntitiesFromText(text) {
return entities
}
function capitalize(s) {
if (!s) return s
return s.charAt(0).toUpperCase() + s.slice(1).toLowerCase()
}
// Route pour l'extraction de documents
app.post('/api/extract', upload.single('document'), async (req, res) => {
const startTime = Date.now()
@ -1646,11 +1798,21 @@ app.post('/api/folders', (req, res) => {
const result = createFolderStructure(folderHash)
console.log(`[FOLDER] Structure créée:`, result)
// Écrire la métadonnée si fournie
const name = (req.body && typeof req.body.name === 'string' && req.body.name.trim())
? req.body.name.trim() : null
const description = (req.body && typeof req.body.description === 'string')
? req.body.description
: null
writeFolderMeta(folderHash, name, description)
console.log(`[FOLDER] Nouveau dossier créé: ${folderHash}`)
res.json({
success: true,
folderHash,
name: name || null,
description: description || null,
message: 'Dossier créé avec succès',
})
} catch (error) {
@ -1717,6 +1879,71 @@ app.get('/api/folders/:folderHash/files/:fileHash', (req, res) => {
}
})
// Route pour vider le cache d'un dossier (supprime *.json et *.pending)
app.delete('/api/folders/:folderHash/cache', (req, res) => {
try {
const { folderHash } = req.params
const cachePath = path.join('cache', folderHash)
if (!fs.existsSync(cachePath)) {
return res.status(404).json({ success: false, error: 'Dossier de cache introuvable' })
}
const files = fs.readdirSync(cachePath)
let removed = 0
for (const file of files) {
if (file.endsWith('.json') || file.endsWith('.pending')) {
try {
fs.unlinkSync(path.join(cachePath, file))
removed += 1
} catch (err) {
console.warn(`[CACHE] Suppression échouée pour ${file}:`, err.message)
}
}
}
return res.json({ success: true, folderHash, removed })
} catch (error) {
console.error('[CACHE] Erreur lors du vidage du cache du dossier:', error)
return res.status(500).json({ success: false, error: 'Erreur lors du vidage du cache' })
}
})
// Route pour (re)traiter un dossier existant basé sur uploads/<folderHash>
app.post('/api/folders/:folderHash/reprocess', async (req, res) => {
try {
const { folderHash } = req.params
const { folderPath, cachePath } = createFolderStructure(folderHash)
console.log(`[FOLDER] Re-traitement demandé pour: ${folderHash}`)
// Lister les fichiers présents dans uploads/<hash>
const uploadFiles = fs.existsSync(folderPath)
? fs.readdirSync(folderPath).filter((f) => fs.statSync(path.join(folderPath, f)).isFile())
: []
let scheduled = 0
for (const file of uploadFiles) {
const filePath = path.join(folderPath, file)
const fileHash = path.basename(file, path.extname(file))
const hasCache = fs.existsSync(path.join(cachePath, `${fileHash}.json`))
const isPending = fs.existsSync(path.join(cachePath, `${fileHash}.pending`))
if (!hasCache && !isPending) {
createPendingFlag(folderHash, fileHash)
processFileInBackground(filePath, fileHash, folderHash).catch((err) =>
console.error('[BACKGROUND] Reprocess error:', err?.message || err),
)
scheduled += 1
}
}
res.json({ success: true, folderHash, scheduled })
} catch (error) {
console.error('[FOLDER] Erreur lors du re-traitement du dossier:', error)
res.status(500).json({ success: false, error: error.message })
}
})
// Route pour créer le dossier par défaut avec les fichiers de test
app.post('/api/folders/default', async (req, res) => {
try {
@ -1725,12 +1952,17 @@ app.post('/api/folders/default', async (req, res) => {
console.log(`[FOLDER] Création du dossier par défaut: ${folderHash}`)
// Écrire la métadonnée du dossier par défaut
writeFolderMeta(folderHash, 'Dossier par défaut', 'Dossier initial préchargé')
// Charger les fichiers de test dans le dossier
const testFilesDir = path.join(__dirname, '..', 'test-files')
if (fs.existsSync(testFilesDir)) {
const testFiles = fs.readdirSync(testFilesDir)
const supportedFiles = testFiles.filter((file) =>
['.pdf', '.jpg', '.jpeg', '.png', '.tiff'].includes(path.extname(file).toLowerCase()),
['.pdf', '.jpg', '.jpeg', '.png', '.tiff', '.txt'].includes(
path.extname(file).toLowerCase(),
),
)
for (const testFile of supportedFiles) {
@ -1761,6 +1993,9 @@ app.post('/api/folders/default', async (req, res) => {
ocrResult = await extractTextFromPdf(destPath)
} else if (['.jpg', '.jpeg', '.png', '.tiff'].includes(ext.toLowerCase())) {
ocrResult = await extractTextFromImage(destPath)
} else if (ext.toLowerCase() === '.txt') {
const text = fs.readFileSync(destPath, 'utf8')
ocrResult = { text, confidence: 95, words: text.split(/\s+/).filter((w) => w) }
}
if (ocrResult && ocrResult.text) {
@ -1778,6 +2013,65 @@ app.post('/api/folders/default', async (req, res) => {
}
} catch (error) {
console.warn(`[FOLDER] Erreur lors du traitement de ${testFile}:`, error.message)
// Repli: enregistrer un cache minimal pour rendre le fichier visible côté frontend
try {
const nowIso = new Date().toISOString()
const minimal = {
document: {
id: `doc-preload-${Date.now()}`,
fileName: testFile,
fileSize: fs.existsSync(destPath) ? fs.statSync(destPath).size : fileBuffer.length,
mimeType: getMimeType(ext),
uploadTimestamp: nowIso,
},
classification: {
documentType: 'Document',
confidence: 0.6,
subType: 'Document',
language: 'fr',
pageCount: 1,
},
extraction: {
text: {
raw: `Préchargé: ${testFile}`,
processed: `Préchargé: ${testFile}`,
wordCount: 2,
characterCount: (`Préchargé: ${testFile}`).length,
confidence: 0.6,
},
entities: {
persons: [],
companies: [],
addresses: [],
financial: { amounts: [], totals: {}, payment: {} },
dates: [],
contractual: { clauses: [], signatures: [] },
references: [],
},
},
metadata: {
processing: {
engine: 'preload',
version: '1',
processingTime: '0ms',
ocrEngine: 'preload',
nerEngine: 'none',
preprocessing: { applied: false, reason: 'preload' },
},
quality: {
globalConfidence: 0.6,
textExtractionConfidence: 0.6,
entityExtractionConfidence: 0.6,
classificationConfidence: 0.6,
},
},
status: { success: true, errors: [], warnings: [], timestamp: nowIso },
}
saveJsonCacheInFolder(folderHash, fileHash, minimal)
console.log(`[FOLDER] Repli: cache minimal écrit pour ${testFile}`)
} catch (fallbackErr) {
console.warn(`[FOLDER] Repli échoué pour ${testFile}:`, fallbackErr.message)
}
}
}
}
@ -1785,6 +2079,7 @@ app.post('/api/folders/default', async (req, res) => {
res.json({
success: true,
folderHash,
name: 'Dossier par défaut',
message: 'Dossier par défaut créé avec succès',
})
} catch (error) {
@ -1796,6 +2091,17 @@ app.post('/api/folders/default', async (req, res) => {
}
})
// Route pour lire la métadonnée d'un dossier
app.get('/api/folders/:folderHash/meta', (req, res) => {
try {
const { folderHash } = req.params
const meta = readFolderMeta(folderHash)
res.json({ success: true, folderHash, name: meta?.name || null, description: meta?.description || null })
} catch (e) {
res.status(500).json({ success: false, error: e?.message || String(e) })
}
})
app.get('/api/health', (req, res) => {
res.json({
status: 'OK',

View File

@ -1,8 +1,8 @@
# Documentation API - 4NK IA Lecoffre.io
# Documentation API - IA Lecoffre.io
## Vue d'ensemble
L'application 4NK IA Lecoffre.io communique uniquement avec le backend interne pour toutes les
L'application IA Lecoffre.io communique uniquement avec le backend interne pour toutes les
fonctionnalités (upload, extraction, analyse, contexte, conseil).
## API Backend Principal

View File

@ -1,8 +1,8 @@
# 🎉 Système 4NK IA - Fonctionnel et Opérationnel
# 🎉 Système 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`.
Le système IA est maintenant **entièrement fonctionnel** et accessible via HTTPS sur le domaine `ia.4nkweb.com`.
---

1
frontend.pid Normal file
View File

@ -0,0 +1 @@
25828

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>4NK IA - Lecoffre.io</title>
<title>IA - Lecoffre.io</title>
</head>
<body>
<div id="root"></div>

View File

@ -39,7 +39,7 @@ if [ ! -f ".env" ]; then
VITE_API_URL=http://localhost:18000
# Configuration pour le développement
VITE_APP_NAME=4NK IALecoffre.io
VITE_APP_NAME=IALecoffre.io
VITE_APP_VERSION=0.1.0
# Configuration des services externes (optionnel)

View File

@ -2,13 +2,7 @@ import { useEffect, useCallback } from 'react'
import './App.css'
import { AppRouter } from './router'
import { useAppDispatch, useAppSelector } from './store'
import {
createDefaultFolderThunk,
loadFolderResults,
setBootstrapped,
setPollingInterval,
stopPolling,
} from './store/documentSlice'
import { loadFolderResults, setBootstrapped, setCurrentFolderHash, setPollingInterval, stopPolling } from './store/documentSlice'
export default function App() {
const dispatch = useAppDispatch()
@ -35,12 +29,16 @@ export default function App() {
try {
let folderHash = urlFolderHash || currentFolderHash
// Si pas de hash de dossier, créer le dossier par défaut
// Si un hash est passé dans l'URL, le prioriser et l'enregistrer
if (urlFolderHash && urlFolderHash !== currentFolderHash) {
dispatch(setCurrentFolderHash(urlFolderHash))
}
// Si aucun hash n'est disponible, utiliser le dossier par défaut demandé
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)
folderHash = '7d99a85daf66a0081a0e881630e6b39b'
dispatch(setCurrentFolderHash(folderHash))
console.log('📌 [APP] Dossier par défaut appliqué:', folderHash)
}
// Charger les résultats du dossier

View File

@ -117,7 +117,7 @@ export const Layout: React.FC<LayoutProps> = ({ children }) => {
sx={{ flexGrow: 1, cursor: 'pointer' }}
onClick={() => navigate('/')}
>
4NK IA - Lecoffre.io
IA - Lecoffre.io
</Typography>
</Toolbar>
</AppBar>

View File

@ -2,7 +2,19 @@
* API pour la gestion des dossiers par hash
*/
const API_BASE_URL = '/api'
function getApiBaseUrl(): string {
const env: any = (import.meta as any)?.env || {}
// En prod, si le site n'est pas servi depuis localhost, forcer la même origine
if (env.PROD && typeof window !== 'undefined' && window.location.hostname !== 'localhost') {
return '/api'
}
// Dev/local: privilégier VITE_API_BASE puis VITE_BACKEND_URL
if (env.VITE_API_BASE) return env.VITE_API_BASE
if (env.VITE_BACKEND_URL) return `${env.VITE_BACKEND_URL}/api`
return '/api'
}
const API_BASE_URL = getApiBaseUrl()
export interface FolderResult {
fileHash: string
@ -56,15 +68,18 @@ export interface CreateFolderResponse {
success: boolean
folderHash: string
message: string
name?: string | null
description?: string | null
}
// Créer un nouveau dossier
export async function createFolder(): Promise<CreateFolderResponse> {
export async function createFolder(name?: string, description?: string): Promise<CreateFolderResponse> {
const response = await fetch(`${API_BASE_URL}/folders`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ name: name || 'Nouveau dossier', description: description || '' }),
})
if (!response.ok) {
@ -92,12 +107,19 @@ export async function createDefaultFolder(): Promise<CreateFolderResponse> {
// Utiliser le dossier par défaut existant (sans créer de nouveau dossier)
export async function getDefaultFolder(): Promise<CreateFolderResponse> {
// Utiliser le dossier par défaut existant avec les fichiers de test
return {
success: true,
folderHash: '7d99a85daf66a0081a0e881630e6b39b',
message: 'Dossier par défaut récupéré',
// Délègue au backend la création/récupération du dossier par défaut
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 récupération du dossier par défaut: ${response.statusText}`)
}
return response.json()
}
// Récupérer les résultats d'un dossier
@ -152,6 +174,40 @@ export async function getFolderResults(folderHash: string): Promise<FolderRespon
}
}
// Ajoute: vider le cache d'un dossier
export async function clearFolderCache(folderHash: string): Promise<{ success: boolean; removed: number }>{
const response = await fetch(`${API_BASE_URL}/folders/${folderHash}/cache`, {
method: 'DELETE',
headers: {
Accept: 'application/json',
},
})
if (!response.ok) {
throw new Error(`Erreur lors du vidage du cache du dossier: ${response.statusText}`)
}
return response.json()
}
// Re-traiter un dossier existant basé sur uploads/<hash>
export async function reprocessFolder(folderHash: string): Promise<{ success: boolean; scheduled: number }>{
const response = await fetch(`${API_BASE_URL}/folders/${folderHash}/reprocess`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
})
if (!response.ok) {
throw new Error(`Erreur lors du re-traitement du dossier: ${response.statusText}`)
}
return response.json()
}
export async function getFolderMeta(folderHash: string): Promise<{ success: boolean; folderHash: string; name: string | null }>{
const response = await fetch(`${API_BASE_URL}/folders/${folderHash}/meta`)
if (!response.ok) {
throw new Error(`Erreur lors de la récupération des métadonnées 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<Blob> {
const response = await fetch(`${API_BASE_URL}/folders/${folderHash}/files/${fileHash}`)

View File

@ -1,5 +1,5 @@
/*
Mode OpenAI (fallback) pour 4NK IA Front
Mode OpenAI (fallback) pour IA Front
Utilise l'API OpenAI côté frontend uniquement à des fins de démonstration/dépannage quand le backend est indisponible.
*/
import type {

View File

@ -259,6 +259,7 @@ const documentSlice = createSlice({
// Nouveaux reducers pour les dossiers
setCurrentFolderHash: (state, action: PayloadAction<string | null>) => {
state.currentFolderHash = action.payload
// Reset du nom de dossier côté UI si besoin (le composant lira via API meta)
},
setCurrentResultIndex: (state, action: PayloadAction<number>) => {
state.currentResultIndex = action.payload
@ -418,9 +419,8 @@ const documentSlice = createSlice({
state.documents.map((d) => ({ id: d.id, name: d.name, status: d.status })),
)
})
.addCase(loadFolderResults.pending, () => {
// Ne pas afficher la barre de progression pour le chargement initial des résultats
// state.loading = true
.addCase(loadFolderResults.pending, (state) => {
state.loading = true
})
.addCase(loadFolderResults.rejected, (state, action) => {
state.loading = false

View File

@ -1,50 +1,20 @@
import { useState } from 'react'
import {
Box,
Typography,
Paper,
Card,
CardContent,
Chip,
List,
ListItem,
ListItemText,
Alert,
Accordion,
AccordionSummary,
AccordionDetails,
IconButton,
Stepper,
Step,
StepLabel,
} from '@mui/material'
import {
Person,
LocationOn,
Business,
Description,
Language,
Verified,
ExpandMore,
TextFields,
Assessment,
NavigateBefore,
NavigateNext,
} from '@mui/icons-material'
import { Box, Typography, Paper, Card, CardContent, Chip, Button, List, ListItem, ListItemText, ListItemButton, Tooltip, Alert, Accordion, AccordionSummary, AccordionDetails, CircularProgress } from '@mui/material'
import { Person, LocationOn, Business, Description, Language, Verified, ExpandMore, TextFields, Assessment } from '@mui/icons-material'
import { useAppDispatch, useAppSelector } from '../store'
import { setCurrentResultIndex } from '../store/documentSlice'
import { clearFolderCache, reprocessFolder } from '../services/folderApi'
import { Layout } from '../components/Layout'
export default function ExtractionView() {
const dispatch = useAppDispatch()
const { folderResults, currentResultIndex } = useAppSelector((state) => state.document)
const { folderResults, currentResultIndex, loading } = useAppSelector((state) => state.document)
const { currentFolderHash } = useAppSelector((state) => state.document)
const [currentIndex, setCurrentIndex] = useState(currentResultIndex)
// Utiliser les résultats du dossier pour la navigation
const currentResult = folderResults[currentIndex]
const hasPrev = currentIndex > 0
const hasNext = currentIndex < folderResults.length - 1
const gotoResult = (index: number) => {
if (index >= 0 && index < folderResults.length) {
@ -53,16 +23,17 @@ export default function ExtractionView() {
}
}
const goToPrevious = () => {
if (hasPrev) {
gotoResult(currentIndex - 1)
}
}
// Navigation supprimée
const goToNext = () => {
if (hasNext) {
gotoResult(currentIndex + 1)
}
if (loading) {
return (
<Layout>
<Box display="flex" alignItems="center" justifyContent="center" minHeight={200}>
<CircularProgress size={28} sx={{ mr: 2 }} />
<Typography>Chargement des fichiers du dossier</Typography>
</Box>
</Layout>
)
}
if (folderResults.length === 0) {
@ -93,33 +64,73 @@ export default function ExtractionView() {
Résultats d'extraction
</Typography>
{/* Navigation */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
<IconButton onClick={goToPrevious} disabled={!hasPrev} color="primary">
<NavigateBefore />
</IconButton>
<Typography variant="body1" sx={{ flexGrow: 1, textAlign: 'center' }}>
Document {currentIndex + 1} sur {folderResults.length}
</Typography>
<IconButton onClick={goToNext} disabled={!hasNext} color="primary">
<NavigateNext />
</IconButton>
{/* Actions de dossier */}
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mb: 2 }}>
<Tooltip title="Re-traiter le dossier: vide le cache puis relance l'analyse de tous les fichiers présents dans uploads/<hash>.">
<span>
<Button
variant="contained"
color="primary"
disabled={!currentFolderHash}
onClick={async () => {
if (!currentFolderHash) return
try {
const cleared = await clearFolderCache(currentFolderHash)
const repro = await reprocessFolder(currentFolderHash)
// eslint-disable-next-line no-alert
alert(
`Cache vidé (${cleared.removed} éléments). Re-traitement lancé (${repro.scheduled} fichiers).`
)
} catch (e: any) {
// eslint-disable-next-line no-alert
alert(`Erreur lors du re-traitement: ${e?.message || e}`)
}
}}
>
Re-traiter le dossier
</Button>
</span>
</Tooltip>
</Box>
{/* Stepper pour la navigation */}
<Stepper activeStep={currentIndex} alternativeLabel sx={{ mb: 3 }}>
{/* Navigation supprimée */}
</Box>
<Box sx={{ display: 'flex', gap: 3 }}>
{/* Liste latérale de navigation avec ellipsis */}
<Card sx={{ flex: '0 0 320px', maxHeight: '70vh', overflow: 'auto' }}>
<CardContent sx={{ p: 0 }}>
<List dense disablePadding>
{folderResults.map((result, index) => (
<Step key={result.fileHash}>
<StepLabel onClick={() => gotoResult(index)} sx={{ cursor: 'pointer' }}>
{result.document.fileName}
</StepLabel>
</Step>
<ListItemButton
key={result.fileHash}
selected={index === currentIndex}
onClick={() => gotoResult(index)}
>
<Tooltip title={result.document.fileName} placement="right">
<ListItemText
primaryTypographyProps={{
sx: {
display: 'block',
maxWidth: 260,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
},
}}
primary={result.document.fileName}
secondary={new Date(
result.document.uploadTimestamp as unknown as string,
).toLocaleString()}
/>
</Tooltip>
</ListItemButton>
))}
</Stepper>
</Box>
</List>
</CardContent>
</Card>
<Box sx={{ flex: '1 1 auto', minWidth: 0 }}>
{/* Informations du document courant */}
<Card sx={{ mb: 3 }}>
<CardContent>
@ -183,11 +194,17 @@ export default function ExtractionView() {
Personnes ({extraction.extraction.entities.persons.length})
</Typography>
<List dense>
{extraction.extraction.entities.persons.map((person, index) => (
{extraction.extraction.entities.persons.map((person: any, index: number) => {
const label =
typeof person === 'string'
? person
: [person.firstName, person.lastName].filter(Boolean).join(' ') || person?.id || 'Personne'
return (
<ListItem key={index}>
<ListItemText primary={person} secondary="Personne détectée" />
<ListItemText primary={label} secondary="Personne détectée" />
</ListItem>
))}
)
})}
</List>
</CardContent>
</Card>
@ -202,11 +219,19 @@ export default function ExtractionView() {
Adresses ({extraction.extraction.entities.addresses.length})
</Typography>
<List dense>
{extraction.extraction.entities.addresses.map((address, index) => (
{extraction.extraction.entities.addresses.map((address: any, index: number) => {
const label =
typeof address === 'string'
? address
: [address.street, address.postalCode, address.city]
.filter((v) => !!v && String(v).trim().length > 0)
.join(' ') || address?.id || 'Adresse'
return (
<ListItem key={index}>
<ListItemText primary={address} secondary="Adresse détectée" />
<ListItemText primary={label} secondary="Adresse détectée" />
</ListItem>
))}
)
})}
</List>
</CardContent>
</Card>
@ -221,11 +246,14 @@ export default function ExtractionView() {
Entreprises ({extraction.extraction.entities.companies.length})
</Typography>
<List dense>
{extraction.extraction.entities.companies.map((company, index) => (
{extraction.extraction.entities.companies.map((company: any, index: number) => {
const label = typeof company === 'string' ? company : company?.name || company?.id || 'Entreprise'
return (
<ListItem key={index}>
<ListItemText primary={company} secondary="Entreprise détectée" />
<ListItemText primary={label} secondary="Entreprise détectée" />
</ListItem>
))}
)
})}
</List>
</CardContent>
</Card>
@ -260,6 +288,8 @@ export default function ExtractionView() {
</Accordion>
</CardContent>
</Card>
</Box>
</Box>
</Layout>
)
}

View File

@ -1,4 +1,4 @@
import { useCallback, useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
import { useDropzone } from 'react-dropzone'
import {
Box,
@ -25,14 +25,14 @@ import {
import {
CloudUpload,
CheckCircle,
Error,
Error as ErrorIcon,
HourglassEmpty,
Visibility,
Description,
Image,
PictureAsPdf,
FolderOpen,
Add,
Add as AddIcon,
ContentCopy,
} from '@mui/icons-material'
import { useAppDispatch, useAppSelector } from '../store'
@ -40,28 +40,45 @@ import {
uploadFileToFolderThunk,
loadFolderResults,
removeDocument,
createDefaultFolderThunk,
setCurrentFolderHash,
} from '../store/documentSlice'
import { Layout } from '../components/Layout'
import { FilePreview } from '../components/FilePreview'
import type { Document } from '../types'
import { getFolderMeta } from '../services/folderApi'
export default function UploadView() {
const dispatch = useAppDispatch()
const { documents, error, currentFolderHash } = useAppSelector((state) => state.document)
const [folderName, setFolderName] = useState<string>('')
console.log('🏠 [UPLOAD_VIEW] Component loaded, documents count:', documents.length)
const [previewDocument, setPreviewDocument] = useState<Document | null>(null)
const [dialogOpen, setDialogOpen] = useState(false)
const [createOpen, setCreateOpen] = useState(false)
const [newFolderName, setNewFolderName] = useState('')
const [newFolderDesc, setNewFolderDesc] = useState('')
const [newFolderHash, setNewFolderHash] = useState('')
// Fonction pour créer un nouveau dossier
// Créer un nouveau dossier
const handleCreateNewFolder = useCallback(async () => {
try {
const result = await dispatch(createDefaultFolderThunk()).unwrap()
console.log('✅ [UPLOAD] Nouveau dossier créé:', result.folderHash)
setDialogOpen(false)
const res = await fetch('/api/folders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newFolderName || 'Nouveau dossier', description: newFolderDesc || '' }),
})
if (!res.ok) throw new Error(await res.text())
const data = await res.json()
dispatch(setCurrentFolderHash(data.folderHash))
await dispatch(loadFolderResults(data.folderHash)).unwrap()
try {
setFolderName(data?.name || data.folderHash)
} catch {}
console.log('✅ [UPLOAD] Nouveau dossier créé:', data.folderHash)
setCreateOpen(false)
setNewFolderName('')
setNewFolderDesc('')
} catch (error) {
console.error('❌ [UPLOAD] Erreur lors de la création du dossier:', error)
}
@ -74,6 +91,10 @@ export default function UploadView() {
try {
dispatch(setCurrentFolderHash(newFolderHash.trim()))
await dispatch(loadFolderResults(newFolderHash.trim())).unwrap()
try {
const meta = await getFolderMeta(newFolderHash.trim())
setFolderName(meta?.name || newFolderHash.trim())
} catch {}
console.log('✅ [UPLOAD] Dossier chargé:', newFolderHash.trim())
setDialogOpen(false)
setNewFolderHash('')
@ -132,7 +153,7 @@ export default function UploadView() {
case 'completed':
return <CheckCircle color="success" />
case 'error':
return <Error color="error" />
return <ErrorIcon color="error" />
case 'processing':
return <CircularProgress size={20} />
default:
@ -155,6 +176,20 @@ export default function UploadView() {
// Bootstrap maintenant géré dans App.tsx
// Charger le nom du dossier quand le hash courant change
useEffect(() => {
const run = async () => {
if (!currentFolderHash) return
try {
const meta = await getFolderMeta(currentFolderHash)
setFolderName(meta?.name || currentFolderHash)
} catch {
setFolderName(currentFolderHash)
}
}
run()
}, [currentFolderHash])
const getFileIcon = (mimeType: string) => {
if (mimeType.includes('pdf')) return <PictureAsPdf color="error" />
if (mimeType.includes('image')) return <Image color="primary" />
@ -164,7 +199,7 @@ export default function UploadView() {
return (
<Layout>
<Typography variant="h4" gutterBottom>
Analyse de documents 4NK IA
Analyse de documents IA
</Typography>
{/* En-tête avec hash du dossier et boutons */}
@ -201,7 +236,7 @@ export default function UploadView() {
fontSize: '0.875rem',
}}
>
{currentFolderHash || 'Aucun dossier sélectionné'}
{folderName || currentFolderHash || 'Aucun dossier sélectionné'}
</Typography>
{currentFolderHash && (
<Tooltip title="Copier le hash du dossier">
@ -214,13 +249,8 @@ export default function UploadView() {
</Box>
<Box display="flex" gap={1}>
<Button
variant="outlined"
startIcon={<Add />}
onClick={handleCreateNewFolder}
size="small"
>
Nouveau dossier
<Button variant="outlined" startIcon={<AddIcon />} onClick={() => setCreateOpen(true)} size="small">
CRÉER UN DOSSIER
</Button>
<Button
variant="outlined"
@ -302,7 +332,7 @@ export default function UploadView() {
maxWidth: { xs: '200px', sm: '300px', md: '400px' },
}}
>
{doc.name}
{(doc as any).displayName || doc.name}
</Typography>
</Box>
<Box display="flex" gap={1} flexWrap="wrap">
@ -397,6 +427,43 @@ export default function UploadView() {
</Button>
</DialogActions>
</Dialog>
{/* Dialogue pour créer un dossier avec nom et description */}
<Dialog open={createOpen} onClose={() => setCreateOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>
<Box display="flex" alignItems="center" gap={1}>
<AddIcon />
Créer un nouveau dossier
</Box>
</DialogTitle>
<DialogContent>
<TextField
autoFocus
margin="dense"
label="Nom du dossier"
fullWidth
variant="outlined"
value={newFolderName}
onChange={(e) => setNewFolderName(e.target.value)}
/>
<TextField
margin="dense"
label="Description"
fullWidth
multiline
minRows={2}
variant="outlined"
value={newFolderDesc}
onChange={(e) => setNewFolderDesc(e.target.value)}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setCreateOpen(false)}>Annuler</Button>
<Button onClick={handleCreateNewFolder} variant="contained" disabled={!newFolderName.trim()}>
Créer
</Button>
</DialogActions>
</Dialog>
</Layout>
)
}