4NK_IA_front/src/views/AnalyseView.tsx
4NK IA aad52027c1 ci: docker_tag=dev-test
- 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
2025-09-18 20:07:08 +00:00

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>
)
}