4NK_IA_front/src/views/ExtractionView.tsx
Nicolas Cantu a5a0421b32 backend
2025-09-16 01:04:57 +02:00

769 lines
31 KiB
TypeScript

import { useEffect } from 'react'
import {
Box,
Typography,
Paper,
Card,
CardContent,
Chip,
List,
ListItem,
ListItemText,
Alert,
CircularProgress,
Button,
Tooltip,
Accordion,
AccordionSummary,
AccordionDetails,
} from '@mui/material'
import {
Person,
LocationOn,
Business,
Description,
Language,
Verified,
ExpandMore,
AttachMoney,
CalendarToday,
Gavel,
Edit,
TextFields,
Assessment,
} from '@mui/icons-material'
import { useAppDispatch, useAppSelector } from '../store'
import { extractDocument, setCurrentDocument } from '../store/documentSlice'
import { Layout } from '../components/Layout'
export default function ExtractionView() {
const dispatch = useAppDispatch()
const { currentDocument, extractionResult, extractionById, loading, documents } = useAppSelector((state) => state.document)
useEffect(() => {
if (!currentDocument) return
const cached = extractionById[currentDocument.id]
if (!cached) dispatch(extractDocument(currentDocument.id))
}, [currentDocument, extractionById, dispatch])
const currentIndex = currentDocument ? Math.max(0, documents.findIndex(d => d.id === currentDocument.id)) : -1
const hasPrev = currentIndex > 0
const hasNext = currentIndex >= 0 && currentIndex < documents.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
}
if (!currentDocument) {
return (
<Layout>
<Alert severity="info">
Veuillez d'abord téléverser et sélectionner un document.
</Alert>
</Layout>
)
}
if (loading) {
return (
<Layout>
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 4, alignItems: 'center', gap: 2 }}>
<CircularProgress size={24} />
<Typography>Extraction en cours...</Typography>
</Box>
</Layout>
)
}
const activeResult = currentDocument ? (extractionById[currentDocument.id] || extractionResult) : extractionResult
if (!activeResult) {
return (
<Layout>
<Alert severity="warning">
Aucun résultat d'extraction disponible.
</Alert>
</Layout>
)
}
// 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)
return (
<Layout>
<Typography variant="h4" gutterBottom>
Extraction des données
</Typography>
{/* Navigation entre documents */}
{documents.length > 0 && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<Button size="small" variant="outlined" disabled={!hasPrev} onClick={() => gotoDoc(currentIndex - 1)}>
Précédent
</Button>
<Typography variant="body2">
{currentIndex + 1} / {documents.length}
</Typography>
<Button size="small" variant="outlined" disabled={!hasNext} onClick={() => gotoDoc(currentIndex + 1)}>
Suivant
</Button>
{currentDocument && (
<Typography variant="body2" sx={{ ml: 2 }} color="text.secondary">
Document: {currentDocument.name}
</Typography>
)}
</Box>
)}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
{/* Informations générales */}
<Paper sx={{ p: 2 }}>
<Typography variant="h6" gutterBottom>
Informations générales
</Typography>
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
<Box sx={{ flex: '1 1 300px' }}>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', mb: 2 }}>
<Chip
icon={<Language />}
label={`Langue: ${standardResult.classification.language}`}
color="primary"
variant="outlined"
/>
<Chip
icon={<Description />}
label={`Type: ${standardResult.classification.documentType}`}
color="secondary"
variant="outlined"
/>
{standardResult.classification.subType && (
<Chip
label={`Sous-type: ${standardResult.classification.subType}`}
color="info"
variant="outlined"
/>
)}
<Tooltip
arrow
title={`Confiance globale: ${Math.round(standardResult.metadata.quality.globalConfidence * 100)}%`}
>
<Chip
icon={<Verified />}
label={`Confiance: ${Math.round(standardResult.metadata.quality.globalConfidence * 100)}%`}
color={standardResult.metadata.quality.globalConfidence > 0.8 ? 'success' : 'warning'}
variant="outlined"
/>
</Tooltip>
</Box>
{/* Métadonnées de traitement */}
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
<Chip
icon={<Assessment />}
label={`Moteur: ${standardResult.metadata.processing.engine}`}
size="small"
variant="outlined"
/>
<Chip
label={`Temps: ${standardResult.metadata.processing.processingTime}`}
size="small"
variant="outlined"
/>
<Chip
label={`OCR: ${standardResult.metadata.processing.ocrEngine}`}
size="small"
variant="outlined"
/>
</Box>
</Box>
<Box sx={{ flex: '1 1 300px' }}>
{/* Aperçu du document */}
{currentDocument && (
<Box>
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
Aperçu du document
</Typography>
{(() => {
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 (
<Box sx={{
border: '1px solid', borderColor: 'grey.300', borderRadius: 1, p: 1,
display: 'inline-block', maxWidth: '100%'
}}>
<img
src={currentDocument.previewUrl}
alt={currentDocument.name}
style={{ maxWidth: 200, maxHeight: 150, objectFit: 'contain' }}
/>
</Box>
)
}
if (isPDF && currentDocument.previewUrl) {
return (
<Box sx={{
border: '1px solid', borderColor: 'grey.300', borderRadius: 1,
overflow: 'hidden', width: 200, height: 150
}}>
<iframe
src={`${currentDocument.previewUrl}#toolbar=0&navpanes=0&scrollbar=0&page=1&view=FitH`}
width="100%"
height="100%"
style={{ border: 'none' }}
title={`Aperçu rapide de ${currentDocument.name}`}
/>
</Box>
)
}
return (
<Box sx={{
border: '1px solid', borderColor: 'grey.300', borderRadius: 1, p: 2,
display: 'flex', alignItems: 'center', justifyContent: 'center',
width: 200, height: 150, bgcolor: 'grey.50'
}}>
<Description color="action" />
</Box>
)
})()}
</Box>
)}
</Box>
</Box>
</Paper>
{/* Entités extraites */}
<Box sx={{ display: 'flex', gap: 3, flexWrap: 'wrap' }}>
{/* Personnes */}
<Box sx={{ flex: '1 1 300px' }}>
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
<Person sx={{ mr: 1, verticalAlign: 'middle' }} />
Personnes ({standardResult.extraction.entities.persons?.length || 0})
</Typography>
<List dense>
{(standardResult.extraction.entities.persons || []).map((person: any, index: number) => (
<ListItem key={index}>
<ListItemText
primary={`${person.firstName || ''} ${person.lastName || ''}`.trim()}
secondaryTypographyProps={{ component: 'span' }}
secondary={
<Box component="span">
{person.role && (
<Typography variant="caption" display="block" component="span">
Rôle: {person.role}
</Typography>
)}
{person.email && (
<Typography variant="caption" display="block" component="span">
Email: {person.email}
</Typography>
)}
{person.phone && (
<Typography variant="caption" display="block" component="span">
Téléphone: {person.phone}
</Typography>
)}
<Typography variant="caption" display="block" component="span">
Confiance: {(person.confidence * 100).toFixed(1)}%
</Typography>
</Box>
}
/>
</ListItem>
))}
</List>
</CardContent>
</Card>
</Box>
{/* Sociétés */}
<Box sx={{ flex: '1 1 300px' }}>
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
<Business sx={{ mr: 1, verticalAlign: 'middle' }} />
Sociétés ({standardResult.extraction.entities.companies?.length || 0})
</Typography>
<List dense>
{(standardResult.extraction.entities.companies || []).map((company: any, index: number) => (
<ListItem key={index}>
<ListItemText
primary={company.name}
secondaryTypographyProps={{ component: 'span' }}
secondary={
<Box component="span">
{company.legalForm && (
<Typography variant="caption" display="block" component="span">
Forme: {company.legalForm}
</Typography>
)}
{company.siret && (
<Typography variant="caption" display="block" component="span">
SIRET: {company.siret}
</Typography>
)}
{company.tva && (
<Typography variant="caption" display="block" component="span">
TVA: {company.tva}
</Typography>
)}
{company.role && (
<Typography variant="caption" display="block" component="span">
Rôle: {company.role}
</Typography>
)}
<Typography variant="caption" display="block" component="span">
Confiance: {(company.confidence * 100).toFixed(1)}%
</Typography>
</Box>
}
/>
</ListItem>
))}
</List>
</CardContent>
</Card>
</Box>
{/* Adresses */}
<Box sx={{ flex: '1 1 300px' }}>
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
<LocationOn sx={{ mr: 1, verticalAlign: 'middle' }} />
Adresses ({standardResult.extraction.entities.addresses?.length || 0})
</Typography>
<List dense>
{(standardResult.extraction.entities.addresses || []).map((address: any, index: number) => (
<ListItem key={index}>
<ListItemText
primary={`${address.street}, ${address.city}`}
secondaryTypographyProps={{ component: 'span' }}
secondary={
<Box component="span">
<Typography variant="caption" display="block" component="span">
{address.postalCode} {address.country}
</Typography>
{address.type && (
<Typography variant="caption" display="block" component="span">
Type: {address.type}
</Typography>
)}
{address.company && (
<Typography variant="caption" display="block" component="span">
Société: {address.company}
</Typography>
)}
<Typography variant="caption" display="block" component="span">
Confiance: {(address.confidence * 100).toFixed(1)}%
</Typography>
</Box>
}
/>
</ListItem>
))}
</List>
</CardContent>
</Card>
</Box>
{/* Informations financières */}
<Box sx={{ flex: '1 1 300px' }}>
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
<AttachMoney sx={{ mr: 1, verticalAlign: 'middle' }} />
Informations financières
</Typography>
{standardResult.extraction.entities.financial?.amounts?.length > 0 ? (
<List dense>
{standardResult.extraction.entities.financial.amounts.map((amount: any, index: number) => (
<ListItem key={index}>
<ListItemText
primary={`${amount.value} ${amount.currency}`}
secondary={
<Box component="span">
<Typography variant="caption" display="block" component="span">
Type: {amount.type}
</Typography>
{amount.description && (
<Typography variant="caption" display="block" component="span">
{amount.description}
</Typography>
)}
<Typography variant="caption" display="block" component="span">
Confiance: {(amount.confidence * 100).toFixed(1)}%
</Typography>
</Box>
}
/>
</ListItem>
))}
</List>
) : (
<Typography variant="body2" color="text.secondary">
Aucune information financière détectée
</Typography>
)}
</CardContent>
</Card>
</Box>
</Box>
{/* Sections supplémentaires */}
<Box sx={{ display: 'flex', gap: 3, flexWrap: 'wrap' }}>
{/* Dates */}
<Box sx={{ flex: '1 1 300px' }}>
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
<CalendarToday sx={{ mr: 1, verticalAlign: 'middle' }} />
Dates ({standardResult.extraction.entities.dates?.length || 0})
</Typography>
{standardResult.extraction.entities.dates?.length > 0 ? (
<List dense>
{standardResult.extraction.entities.dates.map((date: any, index: number) => (
<ListItem key={index}>
<ListItemText
primary={date.value || date.formatted}
secondary={
<Box component="span">
<Typography variant="caption" display="block" component="span">
Type: {date.type}
</Typography>
{date.formatted && (
<Typography variant="caption" display="block" component="span">
Formaté: {date.formatted}
</Typography>
)}
<Typography variant="caption" display="block" component="span">
Confiance: {(date.confidence * 100).toFixed(1)}%
</Typography>
</Box>
}
/>
</ListItem>
))}
</List>
) : (
<Typography variant="body2" color="text.secondary">
Aucune date détectée
</Typography>
)}
</CardContent>
</Card>
</Box>
{/* Références */}
<Box sx={{ flex: '1 1 300px' }}>
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
<Description sx={{ mr: 1, verticalAlign: 'middle' }} />
Références ({standardResult.extraction.entities.references?.length || 0})
</Typography>
{standardResult.extraction.entities.references?.length > 0 ? (
<List dense>
{standardResult.extraction.entities.references.map((ref: any, index: number) => (
<ListItem key={index}>
<ListItemText
primary={ref.number || ref.value}
secondary={
<Box component="span">
<Typography variant="caption" display="block" component="span">
Type: {ref.type}
</Typography>
<Typography variant="caption" display="block" component="span">
Confiance: {(ref.confidence * 100).toFixed(1)}%
</Typography>
</Box>
}
/>
</ListItem>
))}
</List>
) : (
<Typography variant="body2" color="text.secondary">
Aucune référence détectée
</Typography>
)}
</CardContent>
</Card>
</Box>
</Box>
{/* Clauses contractuelles et signatures */}
<Box sx={{ display: 'flex', gap: 3, flexWrap: 'wrap' }}>
{/* Clauses contractuelles */}
<Box sx={{ flex: '1 1 300px' }}>
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
<Gavel sx={{ mr: 1, verticalAlign: 'middle' }} />
Clauses contractuelles ({standardResult.extraction.entities.contractual?.clauses?.length || 0})
</Typography>
{standardResult.extraction.entities.contractual?.clauses?.length > 0 ? (
<List dense>
{standardResult.extraction.entities.contractual.clauses.map((clause: any, index: number) => (
<ListItem key={index}>
<ListItemText
primary={clause.content}
secondary={
<Box component="span">
<Typography variant="caption" display="block" component="span">
Type: {clause.type}
</Typography>
<Typography variant="caption" display="block" component="span">
Confiance: {(clause.confidence * 100).toFixed(1)}%
</Typography>
</Box>
}
/>
</ListItem>
))}
</List>
) : (
<Typography variant="body2" color="text.secondary">
Aucune clause contractuelle détectée
</Typography>
)}
</CardContent>
</Card>
</Box>
{/* Signatures */}
<Box sx={{ flex: '1 1 300px' }}>
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
<Edit sx={{ mr: 1, verticalAlign: 'middle' }} />
Signatures ({standardResult.extraction.entities.contractual?.signatures?.length || 0})
</Typography>
{standardResult.extraction.entities.contractual?.signatures?.length > 0 ? (
<List dense>
{standardResult.extraction.entities.contractual.signatures.map((signature: any, index: number) => (
<ListItem key={index}>
<ListItemText
primary={signature.signatory || 'Signature détectée'}
secondary={
<Box component="span">
<Typography variant="caption" display="block" component="span">
Type: {signature.type}
</Typography>
<Typography variant="caption" display="block" component="span">
Présente: {signature.present ? 'Oui' : 'Non'}
</Typography>
{signature.date && (
<Typography variant="caption" display="block" component="span">
Date: {signature.date}
</Typography>
)}
<Typography variant="caption" display="block" component="span">
Confiance: {(signature.confidence * 100).toFixed(1)}%
</Typography>
</Box>
}
/>
</ListItem>
))}
</List>
) : (
<Typography variant="body2" color="text.secondary">
Aucune signature détectée
</Typography>
)}
</CardContent>
</Card>
</Box>
</Box>
{/* Texte extrait */}
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
<TextFields sx={{ mr: 1, verticalAlign: 'middle' }} />
Texte extrait
</Typography>
<Box sx={{ mb: 2 }}>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
<Chip
label={`${standardResult.extraction.text.wordCount} mots`}
size="small"
variant="outlined"
/>
<Chip
label={`${standardResult.extraction.text.characterCount} caractères`}
size="small"
variant="outlined"
/>
<Chip
label={`Confiance: ${Math.round(standardResult.extraction.text.confidence * 100)}%`}
size="small"
color={standardResult.extraction.text.confidence > 0.8 ? 'success' : 'warning'}
variant="outlined"
/>
</Box>
</Box>
<Accordion>
<AccordionSummary expandIcon={<ExpandMore />}>
<Typography variant="subtitle1">Texte brut</Typography>
</AccordionSummary>
<AccordionDetails>
<Paper
sx={{
p: 2,
bgcolor: 'grey.50',
maxHeight: 300,
overflow: 'auto',
}}
>
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap' }}>
{standardResult.extraction.text.raw}
</Typography>
</Paper>
</AccordionDetails>
</Accordion>
{standardResult.extraction.text.processed !== standardResult.extraction.text.raw && (
<Accordion>
<AccordionSummary expandIcon={<ExpandMore />}>
<Typography variant="subtitle1">Texte traité</Typography>
</AccordionSummary>
<AccordionDetails>
<Paper
sx={{
p: 2,
bgcolor: 'grey.50',
maxHeight: 300,
overflow: 'auto',
}}
>
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap' }}>
{standardResult.extraction.text.processed}
</Typography>
</Paper>
</AccordionDetails>
</Accordion>
)}
</CardContent>
</Card>
{/* Statut et métadonnées */}
{standardResult.status && (
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
Statut du traitement
</Typography>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', mb: 2 }}>
<Chip
label={standardResult.status.success ? 'Succès' : 'Échec'}
color={standardResult.status.success ? 'success' : 'error'}
variant="outlined"
/>
<Chip
label={`Traité le: ${new Date(standardResult.status.timestamp).toLocaleString()}`}
size="small"
variant="outlined"
/>
</Box>
{standardResult.status.warnings?.length > 0 && (
<Alert severity="warning" sx={{ mb: 1 }}>
<Typography variant="subtitle2">Avertissements:</Typography>
<ul>
{standardResult.status.warnings.map((warning: string, index: number) => (
<li key={index}>{warning}</li>
))}
</ul>
</Alert>
)}
{standardResult.status.errors?.length > 0 && (
<Alert severity="error">
<Typography variant="subtitle2">Erreurs:</Typography>
<ul>
{standardResult.status.errors.map((error: string, index: number) => (
<li key={index}>{error}</li>
))}
</ul>
</Alert>
)}
</CardContent>
</Card>
)}
</Box>
</Layout>
)
}