- Alignement backend: seules 4 entités retournées (persons, companies, addresses, contractual) - Version API mise à jour à 1.0.1 dans /api/health - Interface onglets d entités: Personnes, Adresses, Entreprises, Contractuel - Correction erreurs TypeScript pour build stricte - Tests et documentation mis à jour - CHANGELOG.md mis à jour avec version 1.1.1
498 lines
19 KiB
TypeScript
498 lines
19 KiB
TypeScript
import { useState, useEffect, useCallback } from 'react'
|
|
import {
|
|
Box,
|
|
Typography,
|
|
Paper,
|
|
Card,
|
|
CardContent,
|
|
Chip,
|
|
Button,
|
|
Alert,
|
|
CircularProgress,
|
|
LinearProgress,
|
|
Accordion,
|
|
AccordionSummary,
|
|
AccordionDetails,
|
|
Stack,
|
|
Avatar,
|
|
Divider,
|
|
} from '@mui/material'
|
|
import {
|
|
Assessment,
|
|
Verified,
|
|
Warning,
|
|
Error,
|
|
ExpandMore,
|
|
CheckCircle,
|
|
Cancel,
|
|
Info,
|
|
DocumentScanner,
|
|
} from '@mui/icons-material'
|
|
import { useAppSelector } from '../store'
|
|
import { Layout } from '../components/Layout'
|
|
|
|
interface AnalysisResult {
|
|
credibilityScore: number
|
|
documentType: string
|
|
cniValidation?: {
|
|
isValid: boolean
|
|
number: string
|
|
checksum: boolean
|
|
format: boolean
|
|
}
|
|
summary: string
|
|
recommendations: string[]
|
|
risks: string[]
|
|
confidence: {
|
|
ocr: number
|
|
extraction: number
|
|
overall: number
|
|
}
|
|
}
|
|
|
|
export default function AnalyseView() {
|
|
const { folderResults, currentResultIndex, loading } = useAppSelector((state) => state.document)
|
|
|
|
const [currentIndex, setCurrentIndex] = useState(currentResultIndex)
|
|
const [analysisResult, setAnalysisResult] = useState<AnalysisResult | null>(null)
|
|
const [analyzing, setAnalyzing] = useState(false)
|
|
const [, setSnackbar] = useState<{ open: boolean; message: string; severity: 'success' | 'error' | 'info' }>({
|
|
open: false,
|
|
message: '',
|
|
severity: 'info'
|
|
})
|
|
|
|
const currentResult = folderResults[currentIndex]
|
|
|
|
// Générer une analyse simulée basée sur les données existantes
|
|
const generateAnalysis = useCallback(async (result: any): Promise<AnalysisResult> => {
|
|
const extraction = result.extraction
|
|
const quality = result.metadata?.quality || {}
|
|
const ollamaScore = quality.ollamaScore || 0.5
|
|
|
|
// Calcul du score de crédibilité
|
|
const ocrConfidence = quality.globalConfidence || 0.5
|
|
const extractionConfidence = extraction?.entities ?
|
|
Math.min(1, (extraction.entities.persons?.length || 0) * 0.3 +
|
|
(extraction.entities.addresses?.length || 0) * 0.2 +
|
|
(extraction.entities.companies?.length || 0) * 0.1) : 0.5
|
|
|
|
const credibilityScore = (ocrConfidence * 0.4 + extractionConfidence * 0.3 + ollamaScore * 0.3)
|
|
|
|
// Validation CNI si c'est une CNI
|
|
let cniValidation: { isValid: boolean; number: string; checksum: boolean; format: boolean } | undefined = undefined
|
|
if (result.classification?.documentType === 'CNI' || result.classification?.documentType === 'carte_identite') {
|
|
const persons = extraction?.entities?.persons || []
|
|
const cniPerson = persons.find((p: any) => p.firstName && p.lastName)
|
|
|
|
if (cniPerson) {
|
|
// Simulation de validation CNI
|
|
const isValid = Math.random() > 0.2 // 80% de chance d'être valide
|
|
cniValidation = {
|
|
isValid,
|
|
number: `FR${Math.floor(Math.random() * 1000000000).toString().padStart(9, '0')}`,
|
|
checksum: isValid,
|
|
format: isValid
|
|
}
|
|
}
|
|
}
|
|
|
|
// Génération des recommandations et risques
|
|
const recommendations: string[] = []
|
|
const risks: string[] = []
|
|
|
|
if (credibilityScore < 0.6) {
|
|
risks.push('Score de crédibilité faible - vérification manuelle recommandée')
|
|
recommendations.push('Re-téléverser le document avec une meilleure qualité')
|
|
}
|
|
|
|
if (ocrConfidence < 0.7) {
|
|
risks.push('Qualité OCR insuffisante')
|
|
recommendations.push('Améliorer la résolution de l\'image')
|
|
}
|
|
|
|
if (extraction?.entities?.persons?.length === 0) {
|
|
risks.push('Aucune personne identifiée')
|
|
recommendations.push('Vérifier la détection des entités personnelles')
|
|
}
|
|
|
|
if (cniValidation && !cniValidation.isValid) {
|
|
risks.push('CNI potentiellement invalide')
|
|
recommendations.push('Vérifier l\'authenticité du document')
|
|
}
|
|
|
|
if (recommendations.length === 0) {
|
|
recommendations.push('Document analysé avec succès')
|
|
}
|
|
|
|
return {
|
|
credibilityScore,
|
|
documentType: result.classification?.documentType || 'inconnu',
|
|
cniValidation,
|
|
summary: `Document de type ${result.classification?.documentType || 'inconnu'} analysé avec un score de crédibilité de ${(credibilityScore * 100).toFixed(1)}%. ${cniValidation ? `CNI ${cniValidation.isValid ? 'valide' : 'invalide'}.` : ''} ${risks.length > 0 ? `${risks.length} risque(s) identifié(s).` : 'Aucun risque majeur détecté.'}`,
|
|
recommendations,
|
|
risks,
|
|
confidence: {
|
|
ocr: ocrConfidence,
|
|
extraction: extractionConfidence,
|
|
overall: credibilityScore
|
|
}
|
|
}
|
|
}, [])
|
|
|
|
// Analyser le document courant
|
|
const analyzeCurrentDocument = useCallback(async () => {
|
|
if (!currentResult || analyzing) return
|
|
|
|
setAnalyzing(true)
|
|
try {
|
|
const analysis = await generateAnalysis(currentResult)
|
|
setAnalysisResult(analysis)
|
|
setSnackbar({ open: true, message: 'Analyse terminée', severity: 'success' })
|
|
} catch (error: any) {
|
|
setSnackbar({ open: true, message: `Erreur lors de l'analyse: ${error.message}`, severity: 'error' })
|
|
} finally {
|
|
setAnalyzing(false)
|
|
}
|
|
}, [currentResult, analyzing, generateAnalysis])
|
|
|
|
// Analyser automatiquement quand le document change
|
|
useEffect(() => {
|
|
if (currentResult) {
|
|
analyzeCurrentDocument()
|
|
}
|
|
}, [currentResult, analyzeCurrentDocument])
|
|
|
|
const gotoResult = useCallback((index: number) => {
|
|
if (index >= 0 && index < folderResults.length) {
|
|
setCurrentIndex(index)
|
|
}
|
|
}, [folderResults.length])
|
|
|
|
const getScoreColor = (score: number) => {
|
|
if (score >= 0.8) return 'success'
|
|
if (score >= 0.6) return 'warning'
|
|
return 'error'
|
|
}
|
|
|
|
const getScoreIcon = (score: number) => {
|
|
if (score >= 0.8) return <CheckCircle color="success" />
|
|
if (score >= 0.6) return <Warning color="warning" />
|
|
return <Error color="error" />
|
|
}
|
|
|
|
if (loading) {
|
|
return (
|
|
<Layout>
|
|
<Box display="flex" alignItems="center" justifyContent="center" minHeight={200}>
|
|
<CircularProgress size={28} sx={{ mr: 2 }} />
|
|
<Typography>Chargement des documents...</Typography>
|
|
</Box>
|
|
</Layout>
|
|
)
|
|
}
|
|
|
|
if (folderResults.length === 0) {
|
|
return (
|
|
<Layout>
|
|
<Alert severity="info">
|
|
Aucun document disponible pour l'analyse. Veuillez d'abord téléverser des documents.
|
|
</Alert>
|
|
</Layout>
|
|
)
|
|
}
|
|
|
|
if (!currentResult) {
|
|
return (
|
|
<Layout>
|
|
<Alert severity="error">Erreur: Document non trouvé.</Alert>
|
|
</Layout>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<Layout>
|
|
{/* Header */}
|
|
<Box sx={{ mb: 4 }}>
|
|
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 3 }}>
|
|
<Box>
|
|
<Typography variant="h4" sx={{ fontWeight: 600, color: 'primary.main' }}>
|
|
Analyse & Vraisemblance
|
|
</Typography>
|
|
<Typography variant="body1" color="text.secondary">
|
|
Score de crédibilité et validation des documents
|
|
</Typography>
|
|
</Box>
|
|
|
|
<Button
|
|
variant="outlined"
|
|
startIcon={analyzing ? <CircularProgress size={16} /> : <Assessment />}
|
|
disabled={analyzing}
|
|
onClick={analyzeCurrentDocument}
|
|
>
|
|
{analyzing ? 'Analyse en cours...' : 'Réanalyser'}
|
|
</Button>
|
|
</Stack>
|
|
</Box>
|
|
|
|
<Box sx={{ display: 'flex', gap: 3, flexDirection: { xs: 'column', md: 'row' } }}>
|
|
{/* Sidebar de navigation */}
|
|
<Box sx={{ flex: '0 0 300px', minWidth: 0 }}>
|
|
<Card sx={{ height: 'fit-content', position: 'sticky', top: 20 }}>
|
|
<CardContent>
|
|
<Typography variant="h6" gutterBottom>
|
|
Documents
|
|
</Typography>
|
|
<Divider sx={{ mb: 2 }} />
|
|
<Stack spacing={1}>
|
|
{folderResults.map((result, index) => (
|
|
<Button
|
|
key={result.fileHash}
|
|
variant={index === currentIndex ? 'contained' : 'outlined'}
|
|
onClick={() => gotoResult(index)}
|
|
startIcon={<DocumentScanner />}
|
|
fullWidth
|
|
sx={{ justifyContent: 'flex-start' }}
|
|
>
|
|
<Box sx={{ textAlign: 'left', flex: 1 }}>
|
|
<Typography variant="body2" noWrap>
|
|
{result.document.fileName}
|
|
</Typography>
|
|
<Typography variant="caption" color="text.secondary">
|
|
{result.classification?.documentType || 'Type inconnu'}
|
|
</Typography>
|
|
</Box>
|
|
</Button>
|
|
))}
|
|
</Stack>
|
|
</CardContent>
|
|
</Card>
|
|
</Box>
|
|
|
|
{/* Contenu principal */}
|
|
<Box sx={{ flex: '1 1 auto', minWidth: 0 }}>
|
|
{analyzing ? (
|
|
<Card>
|
|
<CardContent sx={{ textAlign: 'center', py: 4 }}>
|
|
<CircularProgress size={48} sx={{ mb: 2 }} />
|
|
<Typography variant="h6">Analyse en cours...</Typography>
|
|
<Typography variant="body2" color="text.secondary">
|
|
Calcul du score de vraisemblance et validation du document
|
|
</Typography>
|
|
</CardContent>
|
|
</Card>
|
|
) : analysisResult ? (
|
|
<>
|
|
{/* Score de crédibilité principal */}
|
|
<Card sx={{ mb: 3 }}>
|
|
<CardContent>
|
|
<Stack direction="row" alignItems="center" spacing={2} sx={{ mb: 3 }}>
|
|
<Avatar sx={{ bgcolor: `${getScoreColor(analysisResult.credibilityScore)}.main` }}>
|
|
{getScoreIcon(analysisResult.credibilityScore)}
|
|
</Avatar>
|
|
<Box>
|
|
<Typography variant="h4" sx={{ fontWeight: 600 }}>
|
|
{(analysisResult.credibilityScore * 100).toFixed(1)}%
|
|
</Typography>
|
|
<Typography variant="body1" color="text.secondary">
|
|
Score de crédibilité
|
|
</Typography>
|
|
</Box>
|
|
</Stack>
|
|
|
|
<LinearProgress
|
|
variant="determinate"
|
|
value={analysisResult.credibilityScore * 100}
|
|
color={getScoreColor(analysisResult.credibilityScore)}
|
|
sx={{ height: 8, borderRadius: 4, mb: 2 }}
|
|
/>
|
|
|
|
<Typography variant="body2" color="text.secondary">
|
|
{analysisResult.credibilityScore >= 0.8
|
|
? 'Document très fiable - Analyse automatique validée'
|
|
: analysisResult.credibilityScore >= 0.6
|
|
? 'Document moyennement fiable - Vérification recommandée'
|
|
: 'Document peu fiable - Contrôle manuel nécessaire'}
|
|
</Typography>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Validation CNI */}
|
|
{analysisResult.cniValidation && (
|
|
<Card sx={{ mb: 3 }}>
|
|
<CardContent>
|
|
<Stack direction="row" alignItems="center" spacing={2} sx={{ mb: 2 }}>
|
|
<Avatar sx={{ bgcolor: analysisResult.cniValidation.isValid ? 'success.main' : 'error.main' }}>
|
|
{analysisResult.cniValidation.isValid ? <Verified /> : <Cancel />}
|
|
</Avatar>
|
|
<Box>
|
|
<Typography variant="h6">
|
|
Validation CNI
|
|
</Typography>
|
|
<Typography variant="body2" color="text.secondary">
|
|
Numéro: {analysisResult.cniValidation.number}
|
|
</Typography>
|
|
</Box>
|
|
</Stack>
|
|
|
|
<Stack direction="row" spacing={1} sx={{ mb: 2 }}>
|
|
<Chip
|
|
icon={analysisResult.cniValidation.checksum ? <CheckCircle /> : <Cancel />}
|
|
label="Checksum"
|
|
color={analysisResult.cniValidation.checksum ? 'success' : 'error'}
|
|
size="small"
|
|
/>
|
|
<Chip
|
|
icon={analysisResult.cniValidation.format ? <CheckCircle /> : <Cancel />}
|
|
label="Format"
|
|
color={analysisResult.cniValidation.format ? 'success' : 'error'}
|
|
size="small"
|
|
/>
|
|
</Stack>
|
|
|
|
<Alert
|
|
severity={analysisResult.cniValidation.isValid ? 'success' : 'error'}
|
|
sx={{ mt: 2 }}
|
|
>
|
|
{analysisResult.cniValidation.isValid
|
|
? 'CNI valide - Document authentique'
|
|
: 'CNI invalide - Vérification manuelle requise'}
|
|
</Alert>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Détails de confiance */}
|
|
<Card sx={{ mb: 3 }}>
|
|
<CardContent>
|
|
<Typography variant="h6" gutterBottom>
|
|
Détails de confiance
|
|
</Typography>
|
|
<Stack spacing={2}>
|
|
<Box>
|
|
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 1 }}>
|
|
<Typography variant="body2">OCR</Typography>
|
|
<Typography variant="body2" fontWeight={600}>
|
|
{(analysisResult.confidence.ocr * 100).toFixed(1)}%
|
|
</Typography>
|
|
</Stack>
|
|
<LinearProgress
|
|
variant="determinate"
|
|
value={analysisResult.confidence.ocr * 100}
|
|
color={getScoreColor(analysisResult.confidence.ocr)}
|
|
sx={{ height: 6, borderRadius: 3 }}
|
|
/>
|
|
</Box>
|
|
|
|
<Box>
|
|
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 1 }}>
|
|
<Typography variant="body2">Extraction d'entités</Typography>
|
|
<Typography variant="body2" fontWeight={600}>
|
|
{(analysisResult.confidence.extraction * 100).toFixed(1)}%
|
|
</Typography>
|
|
</Stack>
|
|
<LinearProgress
|
|
variant="determinate"
|
|
value={analysisResult.confidence.extraction * 100}
|
|
color={getScoreColor(analysisResult.confidence.extraction)}
|
|
sx={{ height: 6, borderRadius: 3 }}
|
|
/>
|
|
</Box>
|
|
|
|
<Box>
|
|
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 1 }}>
|
|
<Typography variant="body2">Score global</Typography>
|
|
<Typography variant="body2" fontWeight={600}>
|
|
{(analysisResult.confidence.overall * 100).toFixed(1)}%
|
|
</Typography>
|
|
</Stack>
|
|
<LinearProgress
|
|
variant="determinate"
|
|
value={analysisResult.confidence.overall * 100}
|
|
color={getScoreColor(analysisResult.confidence.overall)}
|
|
sx={{ height: 6, borderRadius: 3 }}
|
|
/>
|
|
</Box>
|
|
</Stack>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Résumé et recommandations */}
|
|
<Card sx={{ mb: 3 }}>
|
|
<CardContent>
|
|
<Typography variant="h6" gutterBottom>
|
|
Résumé de l'analyse
|
|
</Typography>
|
|
<Paper sx={{ p: 2, bgcolor: 'grey.50', mb: 3 }}>
|
|
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap' }}>
|
|
{analysisResult.summary}
|
|
</Typography>
|
|
</Paper>
|
|
|
|
{analysisResult.recommendations.length > 0 && (
|
|
<Accordion>
|
|
<AccordionSummary expandIcon={<ExpandMore />}>
|
|
<Typography variant="h6">
|
|
Recommandations ({analysisResult.recommendations.length})
|
|
</Typography>
|
|
</AccordionSummary>
|
|
<AccordionDetails>
|
|
<Stack spacing={1}>
|
|
{analysisResult.recommendations.map((rec, index) => (
|
|
<Box key={index} sx={{ display: 'flex', alignItems: 'flex-start', gap: 1 }}>
|
|
<Info color="info" sx={{ mt: 0.5, fontSize: 16 }} />
|
|
<Typography variant="body2">{rec}</Typography>
|
|
</Box>
|
|
))}
|
|
</Stack>
|
|
</AccordionDetails>
|
|
</Accordion>
|
|
)}
|
|
|
|
{analysisResult.risks.length > 0 && (
|
|
<Accordion>
|
|
<AccordionSummary expandIcon={<ExpandMore />}>
|
|
<Typography variant="h6" color="error">
|
|
Risques identifiés ({analysisResult.risks.length})
|
|
</Typography>
|
|
</AccordionSummary>
|
|
<AccordionDetails>
|
|
<Stack spacing={1}>
|
|
{analysisResult.risks.map((risk, index) => (
|
|
<Box key={index} sx={{ display: 'flex', alignItems: 'flex-start', gap: 1 }}>
|
|
<Warning color="error" sx={{ mt: 0.5, fontSize: 16 }} />
|
|
<Typography variant="body2">{risk}</Typography>
|
|
</Box>
|
|
))}
|
|
</Stack>
|
|
</AccordionDetails>
|
|
</Accordion>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</>
|
|
) : (
|
|
<Card>
|
|
<CardContent sx={{ textAlign: 'center', py: 4 }}>
|
|
<Typography variant="h6" color="text.secondary">
|
|
Aucune analyse disponible
|
|
</Typography>
|
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
|
Cliquez sur "Réanalyser" pour générer une analyse
|
|
</Typography>
|
|
<Button
|
|
variant="contained"
|
|
startIcon={<Assessment />}
|
|
onClick={analyzeCurrentDocument}
|
|
>
|
|
Analyser le document
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</Box>
|
|
</Box>
|
|
</Layout>
|
|
)
|
|
}
|