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:
parent
883f49e2e2
commit
fa50a0c2e6
2
.env
2
.env
@ -7,6 +7,8 @@ VITE_APP_VERSION=0.1.0
|
|||||||
VITE_USE_RULE_NER=true
|
VITE_USE_RULE_NER=true
|
||||||
VITE_LLM_CLASSIFY_ONLY=true
|
VITE_LLM_CLASSIFY_ONLY=true
|
||||||
|
|
||||||
|
VITE_BACKEND_URL=http://localhost:3001
|
||||||
|
|
||||||
# Configuration des services externes (optionnel)
|
# Configuration des services externes (optionnel)
|
||||||
VITE_CADASTRE_API_URL=https://apicarto.ign.fr/api/cadastre
|
VITE_CADASTRE_API_URL=https://apicarto.ign.fr/api/cadastre
|
||||||
VITE_GEORISQUES_API_URL=https://www.georisques.gouv.fr/api
|
VITE_GEORISQUES_API_URL=https://www.georisques.gouv.fr/api
|
||||||
|
|||||||
@ -2,11 +2,13 @@
|
|||||||
VITE_API_URL=http://localhost:18000
|
VITE_API_URL=http://localhost:18000
|
||||||
|
|
||||||
# Configuration pour le développement
|
# 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_APP_VERSION=0.1.0
|
||||||
VITE_USE_RULE_NER=true
|
VITE_USE_RULE_NER=true
|
||||||
VITE_LLM_CLASSIFY_ONLY=true
|
VITE_LLM_CLASSIFY_ONLY=true
|
||||||
|
|
||||||
|
VITE_BACKEND_URL=http://localhost:3001
|
||||||
|
|
||||||
# Configuration des services externes (optionnel)
|
# Configuration des services externes (optionnel)
|
||||||
VITE_CADASTRE_API_URL=https://apicarto.ign.fr/api/cadastre
|
VITE_CADASTRE_API_URL=https://apicarto.ign.fr/api/cadastre
|
||||||
VITE_GEORISQUES_API_URL=https://www.georisques.gouv.fr/api
|
VITE_GEORISQUES_API_URL=https://www.georisques.gouv.fr/api
|
||||||
|
|||||||
1
backend.pid
Normal file
1
backend.pid
Normal file
@ -0,0 +1 @@
|
|||||||
|
|
||||||
BIN
backend/cache-backup-20250917-063644.tar.gz
Normal file
BIN
backend/cache-backup-20250917-063644.tar.gz
Normal file
Binary file not shown.
@ -92,6 +92,38 @@ function createFolderStructure(folderHash) {
|
|||||||
return { folderPath, cachePath }
|
return { folderPath, cachePath }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Écrit un fichier de métadonnées du dossier (uploads/<hash>/folder.json)
|
||||||
|
function writeFolderMeta(folderHash, name, description) {
|
||||||
|
try {
|
||||||
|
const { folderPath } = createFolderStructure(folderHash)
|
||||||
|
const metaPath = path.join(folderPath, 'folder.json')
|
||||||
|
const meta = {
|
||||||
|
folderHash,
|
||||||
|
name: name || null,
|
||||||
|
description: description || null,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2))
|
||||||
|
return true
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[FOLDER] Impossible d\'écrire folder.json:', e?.message || e)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lit le fichier de métadonnées du dossier
|
||||||
|
function readFolderMeta(folderHash) {
|
||||||
|
try {
|
||||||
|
const metaPath = path.join('uploads', folderHash, 'folder.json')
|
||||||
|
if (!fs.existsSync(metaPath)) return null
|
||||||
|
const raw = fs.readFileSync(metaPath, 'utf8')
|
||||||
|
return JSON.parse(raw)
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[FOLDER] Impossible de lire folder.json:', e?.message || e)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Fonction pour sauvegarder le cache JSON dans un dossier spécifique
|
// Fonction pour sauvegarder le cache JSON dans un dossier spécifique
|
||||||
function saveJsonCacheInFolder(folderHash, fileHash, result) {
|
function saveJsonCacheInFolder(folderHash, fileHash, result) {
|
||||||
const { cachePath } = createFolderStructure(folderHash)
|
const { cachePath } = createFolderStructure(folderHash)
|
||||||
@ -817,6 +849,7 @@ function generateStandardJSON(documentInfo, ocrResult, entities, processingTime)
|
|||||||
document: {
|
document: {
|
||||||
id: documentId,
|
id: documentId,
|
||||||
fileName: documentInfo.originalname,
|
fileName: documentInfo.originalname,
|
||||||
|
displayName: documentInfo.displayName || documentInfo.originalname,
|
||||||
fileSize: documentInfo.size,
|
fileSize: documentInfo.size,
|
||||||
mimeType: documentInfo.mimetype,
|
mimeType: documentInfo.mimetype,
|
||||||
uploadTimestamp: timestamp,
|
uploadTimestamp: timestamp,
|
||||||
@ -1067,6 +1100,76 @@ function extractEntitiesFromText(text) {
|
|||||||
documentType: 'Document',
|
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
|
// Extraction des noms avec patterns généraux
|
||||||
const namePatterns = [
|
const namePatterns = [
|
||||||
// Patterns pour documents officiels
|
// Patterns pour documents officiels
|
||||||
@ -1229,6 +1332,50 @@ function extractEntitiesFromText(text) {
|
|||||||
entities.documentType = 'Contrat'
|
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(`[NER] Extraction terminée:`)
|
||||||
console.log(` - Identités: ${(Array.isArray(entities.identities)?entities.identities:[]).length}`)
|
console.log(` - Identités: ${(Array.isArray(entities.identities)?entities.identities:[]).length}`)
|
||||||
console.log(` - Sociétés: ${(Array.isArray(entities.companies)?entities.companies:[]).length}`)
|
console.log(` - Sociétés: ${(Array.isArray(entities.companies)?entities.companies:[]).length}`)
|
||||||
@ -1242,6 +1389,11 @@ function extractEntitiesFromText(text) {
|
|||||||
return entities
|
return entities
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function capitalize(s) {
|
||||||
|
if (!s) return s
|
||||||
|
return s.charAt(0).toUpperCase() + s.slice(1).toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
// Route pour l'extraction de documents
|
// Route pour l'extraction de documents
|
||||||
app.post('/api/extract', upload.single('document'), async (req, res) => {
|
app.post('/api/extract', upload.single('document'), async (req, res) => {
|
||||||
const startTime = Date.now()
|
const startTime = Date.now()
|
||||||
@ -1646,11 +1798,21 @@ app.post('/api/folders', (req, res) => {
|
|||||||
const result = createFolderStructure(folderHash)
|
const result = createFolderStructure(folderHash)
|
||||||
console.log(`[FOLDER] Structure créée:`, result)
|
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}`)
|
console.log(`[FOLDER] Nouveau dossier créé: ${folderHash}`)
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
folderHash,
|
folderHash,
|
||||||
|
name: name || null,
|
||||||
|
description: description || null,
|
||||||
message: 'Dossier créé avec succès',
|
message: 'Dossier créé avec succès',
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} 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
|
// Route pour créer le dossier par défaut avec les fichiers de test
|
||||||
app.post('/api/folders/default', async (req, res) => {
|
app.post('/api/folders/default', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@ -1725,12 +1952,17 @@ app.post('/api/folders/default', async (req, res) => {
|
|||||||
|
|
||||||
console.log(`[FOLDER] Création du dossier par défaut: ${folderHash}`)
|
console.log(`[FOLDER] Création du dossier par défaut: ${folderHash}`)
|
||||||
|
|
||||||
|
// Écrire la métadonnée du dossier par défaut
|
||||||
|
writeFolderMeta(folderHash, 'Dossier par défaut', 'Dossier initial préchargé')
|
||||||
|
|
||||||
// Charger les fichiers de test dans le dossier
|
// Charger les fichiers de test dans le dossier
|
||||||
const testFilesDir = path.join(__dirname, '..', 'test-files')
|
const testFilesDir = path.join(__dirname, '..', 'test-files')
|
||||||
if (fs.existsSync(testFilesDir)) {
|
if (fs.existsSync(testFilesDir)) {
|
||||||
const testFiles = fs.readdirSync(testFilesDir)
|
const testFiles = fs.readdirSync(testFilesDir)
|
||||||
const supportedFiles = testFiles.filter((file) =>
|
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) {
|
for (const testFile of supportedFiles) {
|
||||||
@ -1761,6 +1993,9 @@ app.post('/api/folders/default', async (req, res) => {
|
|||||||
ocrResult = await extractTextFromPdf(destPath)
|
ocrResult = await extractTextFromPdf(destPath)
|
||||||
} else if (['.jpg', '.jpeg', '.png', '.tiff'].includes(ext.toLowerCase())) {
|
} else if (['.jpg', '.jpeg', '.png', '.tiff'].includes(ext.toLowerCase())) {
|
||||||
ocrResult = await extractTextFromImage(destPath)
|
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) {
|
if (ocrResult && ocrResult.text) {
|
||||||
@ -1778,6 +2013,65 @@ app.post('/api/folders/default', async (req, res) => {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(`[FOLDER] Erreur lors du traitement de ${testFile}:`, error.message)
|
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({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
folderHash,
|
folderHash,
|
||||||
|
name: 'Dossier par défaut',
|
||||||
message: 'Dossier par défaut créé avec succès',
|
message: 'Dossier par défaut créé avec succès',
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} 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) => {
|
app.get('/api/health', (req, res) => {
|
||||||
res.json({
|
res.json({
|
||||||
status: 'OK',
|
status: 'OK',
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
# Documentation API - 4NK IA Lecoffre.io
|
# Documentation API - IA Lecoffre.io
|
||||||
|
|
||||||
## Vue d'ensemble
|
## 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).
|
fonctionnalités (upload, extraction, analyse, contexte, conseil).
|
||||||
|
|
||||||
## API Backend Principal
|
## API Backend Principal
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
# 🎉 Système 4NK IA - Fonctionnel et Opérationnel
|
# 🎉 Système IA - Fonctionnel et Opérationnel
|
||||||
|
|
||||||
## ✅ **Statut : SYSTÈME FONCTIONNEL**
|
## ✅ **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
1
frontend.pid
Normal file
@ -0,0 +1 @@
|
|||||||
|
25828
|
||||||
@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>4NK IA - Lecoffre.io</title>
|
<title>IA - Lecoffre.io</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@ -39,7 +39,7 @@ if [ ! -f ".env" ]; then
|
|||||||
VITE_API_URL=http://localhost:18000
|
VITE_API_URL=http://localhost:18000
|
||||||
|
|
||||||
# Configuration pour le développement
|
# Configuration pour le développement
|
||||||
VITE_APP_NAME=4NK IALecoffre.io
|
VITE_APP_NAME=IALecoffre.io
|
||||||
VITE_APP_VERSION=0.1.0
|
VITE_APP_VERSION=0.1.0
|
||||||
|
|
||||||
# Configuration des services externes (optionnel)
|
# Configuration des services externes (optionnel)
|
||||||
|
|||||||
22
src/App.tsx
22
src/App.tsx
@ -2,13 +2,7 @@ import { useEffect, useCallback } from 'react'
|
|||||||
import './App.css'
|
import './App.css'
|
||||||
import { AppRouter } from './router'
|
import { AppRouter } from './router'
|
||||||
import { useAppDispatch, useAppSelector } from './store'
|
import { useAppDispatch, useAppSelector } from './store'
|
||||||
import {
|
import { loadFolderResults, setBootstrapped, setCurrentFolderHash, setPollingInterval, stopPolling } from './store/documentSlice'
|
||||||
createDefaultFolderThunk,
|
|
||||||
loadFolderResults,
|
|
||||||
setBootstrapped,
|
|
||||||
setPollingInterval,
|
|
||||||
stopPolling,
|
|
||||||
} from './store/documentSlice'
|
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch()
|
||||||
@ -35,12 +29,16 @@ export default function App() {
|
|||||||
try {
|
try {
|
||||||
let folderHash = urlFolderHash || currentFolderHash
|
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) {
|
if (!folderHash) {
|
||||||
console.log('🚀 [APP] Création du dossier par défaut...')
|
folderHash = '7d99a85daf66a0081a0e881630e6b39b'
|
||||||
const result = await dispatch(createDefaultFolderThunk()).unwrap()
|
dispatch(setCurrentFolderHash(folderHash))
|
||||||
folderHash = result.folderHash
|
console.log('📌 [APP] Dossier par défaut appliqué:', folderHash)
|
||||||
console.log('✅ [APP] Dossier par défaut créé:', folderHash)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Charger les résultats du dossier
|
// Charger les résultats du dossier
|
||||||
|
|||||||
@ -117,7 +117,7 @@ export const Layout: React.FC<LayoutProps> = ({ children }) => {
|
|||||||
sx={{ flexGrow: 1, cursor: 'pointer' }}
|
sx={{ flexGrow: 1, cursor: 'pointer' }}
|
||||||
onClick={() => navigate('/')}
|
onClick={() => navigate('/')}
|
||||||
>
|
>
|
||||||
4NK IA - Lecoffre.io
|
IA - Lecoffre.io
|
||||||
</Typography>
|
</Typography>
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
</AppBar>
|
</AppBar>
|
||||||
|
|||||||
@ -2,7 +2,19 @@
|
|||||||
* API pour la gestion des dossiers par hash
|
* 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 {
|
export interface FolderResult {
|
||||||
fileHash: string
|
fileHash: string
|
||||||
@ -56,15 +68,18 @@ export interface CreateFolderResponse {
|
|||||||
success: boolean
|
success: boolean
|
||||||
folderHash: string
|
folderHash: string
|
||||||
message: string
|
message: string
|
||||||
|
name?: string | null
|
||||||
|
description?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Créer un nouveau dossier
|
// 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`, {
|
const response = await fetch(`${API_BASE_URL}/folders`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
|
body: JSON.stringify({ name: name || 'Nouveau dossier', description: description || '' }),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!response.ok) {
|
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)
|
// Utiliser le dossier par défaut existant (sans créer de nouveau dossier)
|
||||||
export async function getDefaultFolder(): Promise<CreateFolderResponse> {
|
export async function getDefaultFolder(): Promise<CreateFolderResponse> {
|
||||||
// Utiliser le dossier par défaut existant avec les fichiers de test
|
// Délègue au backend la création/récupération du dossier par défaut
|
||||||
return {
|
const response = await fetch(`${API_BASE_URL}/folders/default`, {
|
||||||
success: true,
|
method: 'POST',
|
||||||
folderHash: '7d99a85daf66a0081a0e881630e6b39b',
|
headers: {
|
||||||
message: 'Dossier par défaut récupéré',
|
'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
|
// 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
|
// Récupérer un fichier original depuis un dossier
|
||||||
export async function getFolderFile(folderHash: string, fileHash: string): Promise<Blob> {
|
export async function getFolderFile(folderHash: string, fileHash: string): Promise<Blob> {
|
||||||
const response = await fetch(`${API_BASE_URL}/folders/${folderHash}/files/${fileHash}`)
|
const response = await fetch(`${API_BASE_URL}/folders/${folderHash}/files/${fileHash}`)
|
||||||
|
|||||||
@ -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.
|
Utilise l'API OpenAI côté frontend uniquement à des fins de démonstration/dépannage quand le backend est indisponible.
|
||||||
*/
|
*/
|
||||||
import type {
|
import type {
|
||||||
|
|||||||
@ -259,6 +259,7 @@ const documentSlice = createSlice({
|
|||||||
// Nouveaux reducers pour les dossiers
|
// Nouveaux reducers pour les dossiers
|
||||||
setCurrentFolderHash: (state, action: PayloadAction<string | null>) => {
|
setCurrentFolderHash: (state, action: PayloadAction<string | null>) => {
|
||||||
state.currentFolderHash = action.payload
|
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>) => {
|
setCurrentResultIndex: (state, action: PayloadAction<number>) => {
|
||||||
state.currentResultIndex = action.payload
|
state.currentResultIndex = action.payload
|
||||||
@ -418,9 +419,8 @@ const documentSlice = createSlice({
|
|||||||
state.documents.map((d) => ({ id: d.id, name: d.name, status: d.status })),
|
state.documents.map((d) => ({ id: d.id, name: d.name, status: d.status })),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.addCase(loadFolderResults.pending, () => {
|
.addCase(loadFolderResults.pending, (state) => {
|
||||||
// Ne pas afficher la barre de progression pour le chargement initial des résultats
|
state.loading = true
|
||||||
// state.loading = true
|
|
||||||
})
|
})
|
||||||
.addCase(loadFolderResults.rejected, (state, action) => {
|
.addCase(loadFolderResults.rejected, (state, action) => {
|
||||||
state.loading = false
|
state.loading = false
|
||||||
|
|||||||
@ -1,50 +1,20 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import {
|
import { Box, Typography, Paper, Card, CardContent, Chip, Button, List, ListItem, ListItemText, ListItemButton, Tooltip, Alert, Accordion, AccordionSummary, AccordionDetails, CircularProgress } from '@mui/material'
|
||||||
Box,
|
import { Person, LocationOn, Business, Description, Language, Verified, ExpandMore, TextFields, Assessment } from '@mui/icons-material'
|
||||||
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 { useAppDispatch, useAppSelector } from '../store'
|
import { useAppDispatch, useAppSelector } from '../store'
|
||||||
import { setCurrentResultIndex } from '../store/documentSlice'
|
import { setCurrentResultIndex } from '../store/documentSlice'
|
||||||
|
import { clearFolderCache, reprocessFolder } from '../services/folderApi'
|
||||||
import { Layout } from '../components/Layout'
|
import { Layout } from '../components/Layout'
|
||||||
|
|
||||||
export default function ExtractionView() {
|
export default function ExtractionView() {
|
||||||
const dispatch = useAppDispatch()
|
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)
|
const [currentIndex, setCurrentIndex] = useState(currentResultIndex)
|
||||||
|
|
||||||
// Utiliser les résultats du dossier pour la navigation
|
// Utiliser les résultats du dossier pour la navigation
|
||||||
const currentResult = folderResults[currentIndex]
|
const currentResult = folderResults[currentIndex]
|
||||||
const hasPrev = currentIndex > 0
|
|
||||||
const hasNext = currentIndex < folderResults.length - 1
|
|
||||||
|
|
||||||
const gotoResult = (index: number) => {
|
const gotoResult = (index: number) => {
|
||||||
if (index >= 0 && index < folderResults.length) {
|
if (index >= 0 && index < folderResults.length) {
|
||||||
@ -53,16 +23,17 @@ export default function ExtractionView() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const goToPrevious = () => {
|
// Navigation supprimée
|
||||||
if (hasPrev) {
|
|
||||||
gotoResult(currentIndex - 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const goToNext = () => {
|
if (loading) {
|
||||||
if (hasNext) {
|
return (
|
||||||
gotoResult(currentIndex + 1)
|
<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) {
|
if (folderResults.length === 0) {
|
||||||
@ -93,35 +64,75 @@ export default function ExtractionView() {
|
|||||||
Résultats d'extraction
|
Résultats d'extraction
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
{/* Navigation */}
|
{/* Actions de dossier */}
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mb: 2 }}>
|
||||||
<IconButton onClick={goToPrevious} disabled={!hasPrev} color="primary">
|
<Tooltip title="Re-traiter le dossier: vide le cache puis relance l'analyse de tous les fichiers présents dans uploads/<hash>.">
|
||||||
<NavigateBefore />
|
<span>
|
||||||
</IconButton>
|
<Button
|
||||||
|
variant="contained"
|
||||||
<Typography variant="body1" sx={{ flexGrow: 1, textAlign: 'center' }}>
|
color="primary"
|
||||||
Document {currentIndex + 1} sur {folderResults.length}
|
disabled={!currentFolderHash}
|
||||||
</Typography>
|
onClick={async () => {
|
||||||
|
if (!currentFolderHash) return
|
||||||
<IconButton onClick={goToNext} disabled={!hasNext} color="primary">
|
try {
|
||||||
<NavigateNext />
|
const cleared = await clearFolderCache(currentFolderHash)
|
||||||
</IconButton>
|
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>
|
</Box>
|
||||||
|
|
||||||
{/* Stepper pour la navigation */}
|
{/* Navigation supprimée */}
|
||||||
<Stepper activeStep={currentIndex} alternativeLabel sx={{ mb: 3 }}>
|
|
||||||
{folderResults.map((result, index) => (
|
|
||||||
<Step key={result.fileHash}>
|
|
||||||
<StepLabel onClick={() => gotoResult(index)} sx={{ cursor: 'pointer' }}>
|
|
||||||
{result.document.fileName}
|
|
||||||
</StepLabel>
|
|
||||||
</Step>
|
|
||||||
))}
|
|
||||||
</Stepper>
|
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Informations du document courant */}
|
<Box sx={{ display: 'flex', gap: 3 }}>
|
||||||
<Card sx={{ mb: 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) => (
|
||||||
|
<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>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Box sx={{ flex: '1 1 auto', minWidth: 0 }}>
|
||||||
|
{/* Informations du document courant */}
|
||||||
|
<Card sx={{ mb: 3 }}>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
|
||||||
<Description color="primary" />
|
<Description color="primary" />
|
||||||
@ -155,10 +166,10 @@ export default function ExtractionView() {
|
|||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Texte extrait */}
|
{/* Texte extrait */}
|
||||||
<Card sx={{ mb: 3 }}>
|
<Card sx={{ mb: 3 }}>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Typography variant="h6" gutterBottom>
|
<Typography variant="h6" gutterBottom>
|
||||||
<TextFields sx={{ mr: 1, verticalAlign: 'middle' }} />
|
<TextFields sx={{ mr: 1, verticalAlign: 'middle' }} />
|
||||||
@ -170,10 +181,10 @@ export default function ExtractionView() {
|
|||||||
</Typography>
|
</Typography>
|
||||||
</Paper>
|
</Paper>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Entités extraites */}
|
{/* Entités extraites */}
|
||||||
<Box sx={{ display: 'flex', gap: 3, flexWrap: 'wrap' }}>
|
<Box sx={{ display: 'flex', gap: 3, flexWrap: 'wrap' }}>
|
||||||
{/* Personnes */}
|
{/* Personnes */}
|
||||||
{extraction.extraction.entities.persons.length > 0 && (
|
{extraction.extraction.entities.persons.length > 0 && (
|
||||||
<Card sx={{ flex: '1 1 300px' }}>
|
<Card sx={{ flex: '1 1 300px' }}>
|
||||||
@ -183,11 +194,17 @@ export default function ExtractionView() {
|
|||||||
Personnes ({extraction.extraction.entities.persons.length})
|
Personnes ({extraction.extraction.entities.persons.length})
|
||||||
</Typography>
|
</Typography>
|
||||||
<List dense>
|
<List dense>
|
||||||
{extraction.extraction.entities.persons.map((person, index) => (
|
{extraction.extraction.entities.persons.map((person: any, index: number) => {
|
||||||
<ListItem key={index}>
|
const label =
|
||||||
<ListItemText primary={person} secondary="Personne détectée" />
|
typeof person === 'string'
|
||||||
</ListItem>
|
? person
|
||||||
))}
|
: [person.firstName, person.lastName].filter(Boolean).join(' ') || person?.id || 'Personne'
|
||||||
|
return (
|
||||||
|
<ListItem key={index}>
|
||||||
|
<ListItemText primary={label} secondary="Personne détectée" />
|
||||||
|
</ListItem>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</List>
|
</List>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@ -202,11 +219,19 @@ export default function ExtractionView() {
|
|||||||
Adresses ({extraction.extraction.entities.addresses.length})
|
Adresses ({extraction.extraction.entities.addresses.length})
|
||||||
</Typography>
|
</Typography>
|
||||||
<List dense>
|
<List dense>
|
||||||
{extraction.extraction.entities.addresses.map((address, index) => (
|
{extraction.extraction.entities.addresses.map((address: any, index: number) => {
|
||||||
<ListItem key={index}>
|
const label =
|
||||||
<ListItemText primary={address} secondary="Adresse détectée" />
|
typeof address === 'string'
|
||||||
</ListItem>
|
? 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={label} secondary="Adresse détectée" />
|
||||||
|
</ListItem>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</List>
|
</List>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@ -221,19 +246,22 @@ export default function ExtractionView() {
|
|||||||
Entreprises ({extraction.extraction.entities.companies.length})
|
Entreprises ({extraction.extraction.entities.companies.length})
|
||||||
</Typography>
|
</Typography>
|
||||||
<List dense>
|
<List dense>
|
||||||
{extraction.extraction.entities.companies.map((company, index) => (
|
{extraction.extraction.entities.companies.map((company: any, index: number) => {
|
||||||
<ListItem key={index}>
|
const label = typeof company === 'string' ? company : company?.name || company?.id || 'Entreprise'
|
||||||
<ListItemText primary={company} secondary="Entreprise détectée" />
|
return (
|
||||||
</ListItem>
|
<ListItem key={index}>
|
||||||
))}
|
<ListItemText primary={label} secondary="Entreprise détectée" />
|
||||||
|
</ListItem>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</List>
|
</List>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Métadonnées détaillées */}
|
{/* Métadonnées détaillées */}
|
||||||
<Card sx={{ mt: 3 }}>
|
<Card sx={{ mt: 3 }}>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Typography variant="h6" gutterBottom>
|
<Typography variant="h6" gutterBottom>
|
||||||
Métadonnées détaillées
|
Métadonnées détaillées
|
||||||
@ -259,7 +287,9 @@ export default function ExtractionView() {
|
|||||||
</AccordionDetails>
|
</AccordionDetails>
|
||||||
</Accordion>
|
</Accordion>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
</Layout>
|
</Layout>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { useDropzone } from 'react-dropzone'
|
import { useDropzone } from 'react-dropzone'
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
@ -25,14 +25,14 @@ import {
|
|||||||
import {
|
import {
|
||||||
CloudUpload,
|
CloudUpload,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
Error,
|
Error as ErrorIcon,
|
||||||
HourglassEmpty,
|
HourglassEmpty,
|
||||||
Visibility,
|
Visibility,
|
||||||
Description,
|
Description,
|
||||||
Image,
|
Image,
|
||||||
PictureAsPdf,
|
PictureAsPdf,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
Add,
|
Add as AddIcon,
|
||||||
ContentCopy,
|
ContentCopy,
|
||||||
} from '@mui/icons-material'
|
} from '@mui/icons-material'
|
||||||
import { useAppDispatch, useAppSelector } from '../store'
|
import { useAppDispatch, useAppSelector } from '../store'
|
||||||
@ -40,28 +40,45 @@ import {
|
|||||||
uploadFileToFolderThunk,
|
uploadFileToFolderThunk,
|
||||||
loadFolderResults,
|
loadFolderResults,
|
||||||
removeDocument,
|
removeDocument,
|
||||||
createDefaultFolderThunk,
|
|
||||||
setCurrentFolderHash,
|
setCurrentFolderHash,
|
||||||
} from '../store/documentSlice'
|
} from '../store/documentSlice'
|
||||||
import { Layout } from '../components/Layout'
|
import { Layout } from '../components/Layout'
|
||||||
import { FilePreview } from '../components/FilePreview'
|
import { FilePreview } from '../components/FilePreview'
|
||||||
import type { Document } from '../types'
|
import type { Document } from '../types'
|
||||||
|
import { getFolderMeta } from '../services/folderApi'
|
||||||
|
|
||||||
export default function UploadView() {
|
export default function UploadView() {
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch()
|
||||||
const { documents, error, currentFolderHash } = useAppSelector((state) => state.document)
|
const { documents, error, currentFolderHash } = useAppSelector((state) => state.document)
|
||||||
|
const [folderName, setFolderName] = useState<string>('')
|
||||||
|
|
||||||
console.log('🏠 [UPLOAD_VIEW] Component loaded, documents count:', documents.length)
|
console.log('🏠 [UPLOAD_VIEW] Component loaded, documents count:', documents.length)
|
||||||
const [previewDocument, setPreviewDocument] = useState<Document | null>(null)
|
const [previewDocument, setPreviewDocument] = useState<Document | null>(null)
|
||||||
const [dialogOpen, setDialogOpen] = useState(false)
|
const [dialogOpen, setDialogOpen] = useState(false)
|
||||||
|
const [createOpen, setCreateOpen] = useState(false)
|
||||||
|
const [newFolderName, setNewFolderName] = useState('')
|
||||||
|
const [newFolderDesc, setNewFolderDesc] = useState('')
|
||||||
const [newFolderHash, setNewFolderHash] = useState('')
|
const [newFolderHash, setNewFolderHash] = useState('')
|
||||||
|
|
||||||
// Fonction pour créer un nouveau dossier
|
// Créer un nouveau dossier
|
||||||
const handleCreateNewFolder = useCallback(async () => {
|
const handleCreateNewFolder = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const result = await dispatch(createDefaultFolderThunk()).unwrap()
|
const res = await fetch('/api/folders', {
|
||||||
console.log('✅ [UPLOAD] Nouveau dossier créé:', result.folderHash)
|
method: 'POST',
|
||||||
setDialogOpen(false)
|
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) {
|
} catch (error) {
|
||||||
console.error('❌ [UPLOAD] Erreur lors de la création du dossier:', error)
|
console.error('❌ [UPLOAD] Erreur lors de la création du dossier:', error)
|
||||||
}
|
}
|
||||||
@ -74,6 +91,10 @@ export default function UploadView() {
|
|||||||
try {
|
try {
|
||||||
dispatch(setCurrentFolderHash(newFolderHash.trim()))
|
dispatch(setCurrentFolderHash(newFolderHash.trim()))
|
||||||
await dispatch(loadFolderResults(newFolderHash.trim())).unwrap()
|
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())
|
console.log('✅ [UPLOAD] Dossier chargé:', newFolderHash.trim())
|
||||||
setDialogOpen(false)
|
setDialogOpen(false)
|
||||||
setNewFolderHash('')
|
setNewFolderHash('')
|
||||||
@ -132,7 +153,7 @@ export default function UploadView() {
|
|||||||
case 'completed':
|
case 'completed':
|
||||||
return <CheckCircle color="success" />
|
return <CheckCircle color="success" />
|
||||||
case 'error':
|
case 'error':
|
||||||
return <Error color="error" />
|
return <ErrorIcon color="error" />
|
||||||
case 'processing':
|
case 'processing':
|
||||||
return <CircularProgress size={20} />
|
return <CircularProgress size={20} />
|
||||||
default:
|
default:
|
||||||
@ -155,6 +176,20 @@ export default function UploadView() {
|
|||||||
|
|
||||||
// Bootstrap maintenant géré dans App.tsx
|
// 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) => {
|
const getFileIcon = (mimeType: string) => {
|
||||||
if (mimeType.includes('pdf')) return <PictureAsPdf color="error" />
|
if (mimeType.includes('pdf')) return <PictureAsPdf color="error" />
|
||||||
if (mimeType.includes('image')) return <Image color="primary" />
|
if (mimeType.includes('image')) return <Image color="primary" />
|
||||||
@ -164,7 +199,7 @@ export default function UploadView() {
|
|||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
<Typography variant="h4" gutterBottom>
|
<Typography variant="h4" gutterBottom>
|
||||||
Analyse de documents 4NK IA
|
Analyse de documents IA
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
{/* En-tête avec hash du dossier et boutons */}
|
{/* En-tête avec hash du dossier et boutons */}
|
||||||
@ -201,7 +236,7 @@ export default function UploadView() {
|
|||||||
fontSize: '0.875rem',
|
fontSize: '0.875rem',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{currentFolderHash || 'Aucun dossier sélectionné'}
|
{folderName || currentFolderHash || 'Aucun dossier sélectionné'}
|
||||||
</Typography>
|
</Typography>
|
||||||
{currentFolderHash && (
|
{currentFolderHash && (
|
||||||
<Tooltip title="Copier le hash du dossier">
|
<Tooltip title="Copier le hash du dossier">
|
||||||
@ -214,13 +249,8 @@ export default function UploadView() {
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box display="flex" gap={1}>
|
<Box display="flex" gap={1}>
|
||||||
<Button
|
<Button variant="outlined" startIcon={<AddIcon />} onClick={() => setCreateOpen(true)} size="small">
|
||||||
variant="outlined"
|
CRÉER UN DOSSIER
|
||||||
startIcon={<Add />}
|
|
||||||
onClick={handleCreateNewFolder}
|
|
||||||
size="small"
|
|
||||||
>
|
|
||||||
Nouveau dossier
|
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
@ -302,7 +332,7 @@ export default function UploadView() {
|
|||||||
maxWidth: { xs: '200px', sm: '300px', md: '400px' },
|
maxWidth: { xs: '200px', sm: '300px', md: '400px' },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{doc.name}
|
{(doc as any).displayName || doc.name}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Box display="flex" gap={1} flexWrap="wrap">
|
<Box display="flex" gap={1} flexWrap="wrap">
|
||||||
@ -397,6 +427,43 @@ export default function UploadView() {
|
|||||||
</Button>
|
</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</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>
|
</Layout>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user