feat(ocr+quality): annuaire noms unifié, scoring boost, révision IA (Ollama) auto+manuelle; UI chips score+corrections; suppression entités; docs MAJ

This commit is contained in:
4NK IA 2025-09-18 12:49:18 +00:00
parent a3501def35
commit 4bed3562b1
9 changed files with 93994 additions and 14 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -24,13 +24,22 @@ function buildNameSets() {
const lastNames = new Set()
try {
if (!fs.existsSync(baseDir)) return { firstNames, lastNames }
const files = fs.readdirSync(baseDir)
// Prioriser les fichiers unifiés légers (références finales)
const preferredOrder = [
'firstnames_all.csv',
'lastnames_all.csv',
]
const files = fs
.readdirSync(baseDir)
.sort((a, b) => preferredOrder.indexOf(a) - preferredOrder.indexOf(b))
for (const f of files) {
const fp = path.join(baseDir, f)
if (!fs.statSync(fp).isFile()) continue
// N'utiliser que les deux références finales si présentes
const isFirst = /^(firstnames_all\.|first|prenom|given)/i.test(f)
const isLast = /^(lastnames_all\.|last|nom|surname|family)/i.test(f)
if (!isFirst && !isLast) continue
const list = loadCsvNames(fp)
const isFirst = /first|prenom|given/i.test(f)
const isLast = /last|nom|surname|family/i.test(f)
for (const n of list) {
const norm = n.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase()
if (isFirst) firstNames.add(norm)
@ -62,5 +71,3 @@ function nameConfidenceBoost(firstName, lastName) {
}
module.exports = { getNameDirectory, nameConfidenceBoost }

View File

@ -930,13 +930,14 @@ function generateStandardJSON(documentInfo, ocrResult, entities, processingTime)
return id0 ? nameConfidenceBoost(id0.firstName, id0.lastName) : 0
} catch { return 0 }
})()
// Ajustement du scoring qualité: intégrer le boost annuaire et plafonner 0.99
const globalConfidence = Math.min(
95,
99,
Math.max(
60,
baseConfidence * 0.8 +
(identities.length > 0 ? 10 : 0) +
(cniNumbers.length > 0 ? 15 : 0) +
(identities.length > 0 ? 12 : 0) +
(cniNumbers.length > 0 ? 18 : 0) +
Math.round(dirBoost * 100),
),
)
@ -1072,6 +1073,95 @@ function generateStandardJSON(documentInfo, ocrResult, entities, processingTime)
}
}
// Validation facultative via Ollama: retourne { score, corrections, avis }
async function reviewWithOllama(payload) {
try {
const http = require('http')
const data = JSON.stringify({
model: process.env.OLLAMA_MODEL || 'llama3.1',
prompt:
"Analyse les informations extraites d'un document. Donne un score de fiabilité entre 0 et 1 (décimal), une liste de corrections proposées (champs et valeurs), et un court avis. Réponds strictement en JSON avec les clés: score (number), corrections (array d'objets {path, value, confidence}), avis (string).\nDONNÉES:\n" +
JSON.stringify(payload),
stream: false,
options: { temperature: 0.2 },
})
const options = {
hostname: 'localhost',
port: 11434,
path: '/api/generate',
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(data),
},
timeout: 8000,
}
const responseBody = await new Promise((resolve, reject) => {
const req = http.request(options, (res) => {
let body = ''
res.setEncoding('utf8')
res.on('data', (chunk) => (body += chunk))
res.on('end', () => resolve(body))
})
req.on('error', reject)
req.on('timeout', () => {
req.destroy(new Error('Timeout'))
})
req.write(data)
req.end()
})
let parsedOuter
try {
parsedOuter = JSON.parse(responseBody)
} catch {
parsedOuter = { response: String(responseBody) }
}
const txt = parsedOuter.response || responseBody
const match = String(txt).match(/\{[\s\S]*\}$/)
if (!match) throw new Error('Réponse non JSON')
const parsed = JSON.parse(match[0])
return {
score: Math.max(0, Math.min(1, Number(parsed.score) || 0)),
corrections: Array.isArray(parsed.corrections) ? parsed.corrections : [],
avis: typeof parsed.avis === 'string' ? parsed.avis : '',
}
} catch (e) {
return { score: null, corrections: [], avis: '' }
}
}
// Endpoint optionnel: révision Ollama d'un résultat existant
app.post('/api/folders/:folderHash/files/:fileHash/review', express.json(), async (req, res) => {
try {
const { folderHash, fileHash } = req.params
const cachePath = path.join('cache', folderHash, `${fileHash}.json`)
if (!fs.existsSync(cachePath)) return res.status(404).json({ success: false, error: 'Résultat non trouvé' })
const result = JSON.parse(fs.readFileSync(cachePath, 'utf8'))
const review = await reviewWithOllama({
document: result.document,
extraction: result.extraction,
classification: result.classification,
metadata: result.metadata,
})
// Incorporer le score si présent
if (review && typeof review.score === 'number') {
if (!result.metadata) result.metadata = {}
if (!result.metadata.quality) result.metadata.quality = {}
result.metadata.quality.ollamaScore = review.score
result.metadata.quality.globalConfidence = Math.max(
result.metadata.quality.globalConfidence || 0,
review.score,
)
result.status = result.status || {}
result.status.review = review
fs.writeFileSync(cachePath, JSON.stringify(result, null, 2))
}
return res.json({ success: true, review })
} catch (e) {
return res.status(500).json({ success: false, error: e?.message || String(e) })
}
})
// Détermine des recommandations de qualité (remplacement/confirmation)
function computeQualitySuggestions(ctx) {
try {
@ -1734,6 +1824,31 @@ app.post('/api/extract', upload.single('document'), async (req, res) => {
// Sauvegarder le résultat dans le cache du dossier
saveJsonCacheInFolder(folderHash, fileHash, result)
// Révision Ollama automatique (non bloquante)
try {
const review = await reviewWithOllama({
document: result.document,
extraction: result.extraction,
classification: result.classification,
metadata: result.metadata,
})
if (review && typeof review.score === 'number') {
if (!result.metadata) result.metadata = {}
if (!result.metadata.quality) result.metadata.quality = {}
result.metadata.quality.ollamaScore = review.score
// Ajuster la confiance globale si le score est supérieur
result.metadata.quality.globalConfidence = Math.max(
result.metadata.quality.globalConfidence || 0,
review.score,
)
result.status = result.status || {}
result.status.review = review
saveJsonCacheInFolder(folderHash, fileHash, result)
}
} catch (e) {
console.warn('[OLLAMA] Révision automatique échouée:', e?.message || e)
}
console.log(`[API] Traitement terminé en ${Date.now() - startTime}ms`)
} catch (error) {
console.error(`[API] Erreur lors du traitement du fichier ${fileHash}:`, error)
@ -2101,6 +2216,49 @@ app.post('/api/folders/:folderHash/files/:fileHash/confirm-address', express.jso
}
})
// Supprimer une entité (personne/adresse/entreprise) du cache JSON
// Body JSON attendu: { kind: 'person'|'address'|'company', id?: string, index?: number }
app.post('/api/folders/:folderHash/files/:fileHash/entities/delete', express.json(), (req, res) => {
try {
const { folderHash, fileHash } = req.params
const { kind, id, index } = req.body || {}
if (!['person', 'address', 'company'].includes(kind)) {
return res.status(400).json({ success: false, error: 'Paramètre kind invalide' })
}
const cachePath = path.join('cache', folderHash)
const jsonPath = path.join(cachePath, `${fileHash}.json`)
if (!fs.existsSync(jsonPath)) {
return res.status(404).json({ success: false, error: 'Résultat non trouvé' })
}
const data = JSON.parse(fs.readFileSync(jsonPath, 'utf8'))
const ents = (((data || {}).extraction || {}).entities || {})
const map = {
person: 'persons',
address: 'addresses',
company: 'companies',
}
const key = map[kind]
if (!Array.isArray(ents[key])) ents[key] = []
let before = ents[key].length
if (typeof index === 'number' && index >= 0 && index < ents[key].length) {
ents[key].splice(index, 1)
} else if (typeof id === 'string' && id.length > 0) {
ents[key] = ents[key].filter((e) => (e && (e.id === id)) === false)
} else {
return res.status(400).json({ success: false, error: 'id ou index requis' })
}
const after = ents[key].length
if (after === before) {
return res.status(404).json({ success: false, error: 'Entité non trouvée' })
}
// Sauvegarde
fs.writeFileSync(jsonPath, JSON.stringify(data, null, 2))
return res.json({ success: true, kind, removed: 1 })
} catch (e) {
return res.status(500).json({ success: false, error: e?.message || String(e) })
}
})
// Suppression d'un fichier d'un dossier (uploads + cache)
app.delete('/api/folders/:folderHash/files/:fileHash', (req, res) => {
try {

View File

@ -1,4 +1,4 @@
## Gestion de la qualité: remplacement d'image et confirmation d'adresse
## Gestion de la qualité: remplacement d'image, confirmation d'adresse et révision IA
### Backend
- Suggestions ajoutées dans `status.suggestions` des résultats:
@ -11,10 +11,11 @@
### Frontend (UploadView)
- Si `needsReupload`: chip “Qualité faible: remplacer” → ouvre un file picker, supprime loriginal et réuploade.
- Si `needsAddressConfirmation`: chip “Adresse à confirmer” → dialogue pré-rempli; POST de confirmation; rafraîchissement.
- Révision IA: bouton “Révision IA” pour lancer une révision manuelle; affichage dun chip “IA: x.xx” (tooltip = avis) et dun chip “Corrections: N” ouvrant un dialogue listant les corrections si disponibles.
### Tests manuels
1) Télécharger une image de faible qualité → vérifier l'apparition du chip “Qualité faible: remplacer”.
2) Confirmer l'adresse détectée → vérifier que le chip disparaît après POST.
### Notes
- Lusage dun annuaire de noms (FR) pourra être ajouté pour rehausser la confiance sur NOM/PRÉNOM.
- Annuaire de noms (FR/EN) intégré: rehausse la confiance si prénom/nom reconnus dans les listes unifiées.

View File

@ -0,0 +1,51 @@
### Révision IA (Ollama): scoring, corrections et avis
#### Objectif
Fournir une évaluation automatique de la fiabilité des extractions (score), proposer des corrections potentielles (champ, valeur, confiance) et un avis court, en sappuyant sur un LLM local accessible via Ollama.
#### Composants et endpoints
- Backend:
- Appel automatique après chaque extraction: intégration du résultat de révision dans `status.review` et `metadata.quality.ollamaScore`.
- Endpoint manuel: `POST /api/folders/:folderHash/files/:fileHash/review` (pas de payload requis). Retourne `{ success, review }`.
- Format attendu du LLM (réponse stricte JSON): `{ "score": number (0..1), "corrections": Array<{ path, value, confidence }>, "avis": string }`.
- Frontend:
- Bouton “Révision IA” par document (état completed): déclenche lendpoint manuel puis rafraîchit la liste.
- Affichage: Chip `IA: x.xx` (tooltip = `avis` si présent). Chip `Corrections: N` ouvrant un dialogue listant les corrections.
#### Calcul des scores et arbitrage
- Base OCR: confiance Tesseract/pdf-parse normalisée.
- Boost annuaire de noms (`backend/data/names/firstnames_all.csv`, `lastnames_all.csv`): +0.05 si prénom trouvé, +0.05 si nom trouvé (après normalisation), agrégé au score.
- Ensembles de règles NER (CNI/MRZ, adresses, entités) influent indirectement via `identities` et `cniNumbers` (poids renforcés).
- Score global côté backend (avant Ollama): plafonné à 0.99, agrège OCR, présence didentités, CNI, boost annuaire.
- Révision Ollama: si `review.score` est supérieur au `globalConfidence`, le backend rehausse `metadata.quality.globalConfidence` à ce score et persiste le résultat.
#### Suggestions de qualité
- `needsReupload`: déclenché si confiance OCR < 0.75, ou si CNI sans NOM/PRÉNOM.
- `needsAddressConfirmation`: déclenché si une adresse est détectée; confirmation côté UI via dialogue et endpoint `confirm-address`.
#### Dépendances et configuration
- Ollama: service HTTP local sur `http://localhost:11434`. Modèle configurable via `OLLAMA_MODEL` (défaut `llama3.1`). Timeout 8s.
- Aucune dépendance Node additionnelle (utilisation d`http` natif).
#### Données persistées
- Cache fichier par document: `cache/<folderHash>/<fileHash>.json`.
- Champs ajoutés/modifiés:
- `metadata.quality.ollamaScore: number` (0..1)
- `status.review: { score, corrections[], avis }`
- `metadata.quality.globalConfidence: number` (peut être rehaussé par Ollama)
#### UI et interactions
- Liste des documents:
- Chip “IA: x.xx” si présent (tooltip: `avis`).
- Chip “Corrections: N” si `status.review.corrections` non vide. Clic: ouvre un dialogue listant `{ path, value, confidence }`.
- Bouton “Révision IA”: relance la révision et rafraîchit litem.
#### Tests manuels (checklist)
- Vérifier quun upload image/PDF completed affiche le Chip `IA: x.xx` et/ou `Corrections: N` si présents.
- Cliquer “Révision IA”: confirmer que la liste se rafraîchit et que `status.review` est renseigné.
- Ouvrir le dialogue des corrections et vérifier laffichage des champs.
- Confirmer/infirmer une adresse détectée et vérifier la mise à jour côté backend et disparition du flag.
#### Journal des décisions
- Choix du JSON strict pour la sortie LLM afin de faciliter lexploitation et éviter les parsings fragiles.
- Utilisation d`http` natif côté Node pour éviter lajout de dépendances.

View File

@ -21,6 +21,9 @@ import {
NavigateNext,
} from '@mui/icons-material'
import type { Document } from '../types'
import { useAppDispatch, useAppSelector } from '../store'
import { loadFolderResults } from '../store/documentSlice'
import { deleteEntity } from '../services/folderApi'
interface FilePreviewProps {
document: Document
@ -28,6 +31,9 @@ interface FilePreviewProps {
}
export const FilePreview: React.FC<FilePreviewProps> = ({ document, onClose }) => {
const dispatch = useAppDispatch()
const currentFolderHash = useAppSelector((state) => state.document.currentFolderHash)
const [fullResult, setFullResult] = useState<any | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [page, setPage] = useState(1)
@ -49,6 +55,23 @@ export const FilePreview: React.FC<FilePreviewProps> = ({ document, onClose }) =
return () => clearTimeout(timer)
}, [document])
// Charger le résultat complet (cache JSON) pour afficher les entités
useEffect(() => {
let aborted = false
const load = async () => {
try {
const res = await fetch(`/api/cache/${document.id}`, { headers: { Accept: 'application/json' } })
if (!res.ok) return
const data = await res.json()
if (!aborted) setFullResult(data)
} catch {}
}
load()
return () => {
aborted = true
}
}, [document])
const handleDownload = () => {
if (document.previewUrl) {
const link = window.document.createElement('a')
@ -288,6 +311,106 @@ export const FilePreview: React.FC<FilePreviewProps> = ({ document, onClose }) =
</Box>
)}
</Box>
{/* Données extraites: personnes, adresses, entreprises */}
<Box sx={{ mt: 2 }}>
<Typography variant="subtitle1" gutterBottom>
Données extraites
</Typography>
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' }, gap: 2 }}>
<Box>
<Typography variant="subtitle2">Personnes</Typography>
<Box>
{Array.isArray((fullResult?.extraction?.entities?.persons)) && fullResult.extraction.entities.persons.length > 0 ? (
fullResult.extraction.entities.persons.map((p: any, i: number) => (
<Box key={`p-${i}`} display="flex" alignItems="center" justifyContent="space-between" sx={{ py: 0.5 }}>
<Typography variant="body2">{p.firstName} {p.lastName}</Typography>
<Button
size="small"
color="error"
onClick={async () => {
if (!currentFolderHash) return
try {
await deleteEntity(currentFolderHash, document.id, 'person', { index: i, id: p.id })
await dispatch(loadFolderResults(currentFolderHash)).unwrap()
// Mettre à jour localement
const copy = JSON.parse(JSON.stringify(fullResult))
copy.extraction.entities.persons.splice(i, 1)
setFullResult(copy)
} catch (e) {}
}}
>
Supprimer
</Button>
</Box>
))
) : (
<Typography variant="caption" color="text.secondary">Aucune personne</Typography>
)}
</Box>
</Box>
<Box>
<Typography variant="subtitle2">Adresses</Typography>
<Box>
{Array.isArray((fullResult?.extraction?.entities?.addresses)) && fullResult.extraction.entities.addresses.length > 0 ? (
fullResult.extraction.entities.addresses.map((a: any, i: number) => (
<Box key={`a-${i}`} display="flex" alignItems="center" justifyContent="space-between" sx={{ py: 0.5 }}>
<Typography variant="body2">{a.street}, {a.postalCode} {a.city} {a.country || ''}</Typography>
<Button
size="small"
color="error"
onClick={async () => {
if (!currentFolderHash) return
try {
await deleteEntity(currentFolderHash, document.id, 'address', { index: i, id: a.id })
await dispatch(loadFolderResults(currentFolderHash)).unwrap()
const copy = JSON.parse(JSON.stringify(fullResult))
copy.extraction.entities.addresses.splice(i, 1)
setFullResult(copy)
} catch (e) {}
}}
>
Supprimer
</Button>
</Box>
))
) : (
<Typography variant="caption" color="text.secondary">Aucune adresse</Typography>
)}
</Box>
</Box>
<Box>
<Typography variant="subtitle2">Entreprises</Typography>
<Box>
{Array.isArray((fullResult?.extraction?.entities?.companies)) && fullResult.extraction.entities.companies.length > 0 ? (
fullResult.extraction.entities.companies.map((c: any, i: number) => (
<Box key={`c-${i}`} display="flex" alignItems="center" justifyContent="space-between" sx={{ py: 0.5 }}>
<Typography variant="body2">{c.name}</Typography>
<Button
size="small"
color="error"
onClick={async () => {
if (!currentFolderHash) return
try {
await deleteEntity(currentFolderHash, document.id, 'company', { index: i, id: c.id })
await dispatch(loadFolderResults(currentFolderHash)).unwrap()
const copy = JSON.parse(JSON.stringify(fullResult))
copy.extraction.entities.companies.splice(i, 1)
setFullResult(copy)
} catch (e) {}
}}
>
Supprimer
</Button>
</Box>
))
) : (
<Typography variant="caption" color="text.secondary">Aucune entreprise</Typography>
)}
</Box>
</Box>
</Box>
</Box>
</Box>
)}
</DialogContent>

View File

@ -324,3 +324,43 @@ export async function confirmDetectedAddress(
}
return response.json()
}
// Demander une révision IA (Ollama) d'un résultat existant
export async function reviewFileWithAI(
folderHash: string,
fileHash: string,
): Promise<{ success: boolean; review?: { score: number | null; corrections: any[]; avis: string } }>{
const response = await fetch(
`${API_BASE_URL}/folders/${folderHash}/files/${fileHash}/review`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
},
)
if (!response.ok) {
throw new Error(`Erreur révision IA: ${response.statusText}`)
}
return response.json()
}
// Supprimer une entité du cache (person/address/company)
export async function deleteEntity(
folderHash: string,
fileHash: string,
kind: 'person' | 'address' | 'company',
payload: { id?: string; index?: number },
): Promise<{ success: boolean }>{
const response = await fetch(
`${API_BASE_URL}/folders/${folderHash}/files/${fileHash}/entities/delete`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ kind, ...payload }),
},
)
if (!response.ok) {
throw new Error(`Erreur suppression entité: ${response.statusText}`)
}
return response.json()
}

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect, useState, useMemo, memo } from 'react'
import { useCallback, useEffect, useState, memo } from 'react'
import { useDropzone } from 'react-dropzone'
import {
Box,
@ -47,16 +47,18 @@ import {
import { Layout } from '../components/Layout'
import { FilePreview } from '../components/FilePreview'
import type { Document } from '../types'
import { confirmDetectedAddress, deleteFolderFile } from '../services/folderApi'
import { confirmDetectedAddress, deleteFolderFile, reviewFileWithAI } from '../services/folderApi'
// Composant mémorisé pour les items de la liste
const DocumentListItem = memo(({ doc, index, onPreview, onDelete, onReplace, onConfirmAddress, totalCount }: {
const DocumentListItem = memo(({ doc, index, onPreview, onDelete, onReplace, onConfirmAddress, onReview, onOpenCorrections, totalCount }: {
doc: Document,
index: number,
onPreview: (doc: Document) => void,
onDelete: (id: string) => void,
onReplace: (doc: Document) => void,
onConfirmAddress: (doc: Document) => void,
onReview: (doc: Document) => void,
onOpenCorrections: (doc: Document, corrections: any[]) => void,
totalCount: number
}) => {
const getFileIcon = (mimeType: string) => {
@ -149,6 +151,48 @@ const DocumentListItem = memo(({ doc, index, onPreview, onDelete, onReplace, onC
variant="outlined"
/>
)}
{(() => {
const anyDoc: any = doc as any
const score: number | undefined =
(anyDoc?.metadata?.quality?.ollamaScore as number | undefined) ??
(anyDoc?.status?.review?.score as number | undefined)
const avis: string | undefined = anyDoc?.status?.review?.avis
if (doc.status === 'completed' && typeof score === 'number') {
const chip = (
<Chip
color="success"
label={`IA: ${score.toFixed(2)}`}
size="small"
variant="outlined"
/>
)
return typeof avis === 'string' && avis.length > 0 ? (
<Tooltip title={avis}>
<span>{chip}</span>
</Tooltip>
) : (
chip
)
}
return null
})()}
{(() => {
const anyDoc: any = doc as any
const corrections = anyDoc?.status?.review?.corrections
const count = Array.isArray(corrections) ? corrections.length : 0
if (doc.status === 'completed' && count > 0) {
return (
<Chip
color="info"
label={`Corrections: ${count}`}
size="small"
variant="outlined"
onClick={() => onOpenCorrections(doc, corrections)}
/>
)
}
return null
})()}
{doc.status === 'completed' && (doc as any)?.suggestions?.needsReupload && (
<Chip
color="warning"
@ -179,6 +223,14 @@ const DocumentListItem = memo(({ doc, index, onPreview, onDelete, onReplace, onC
>
Aperçu
</Button>
<Button
size="small"
onClick={() => onReview(doc)}
disabled={doc.status !== 'completed'}
fullWidth
>
Révision IA
</Button>
<Button
size="small"
color="error"
@ -220,6 +272,10 @@ export default function UploadView() {
const [addressDialogOpen, setAddressDialogOpen] = useState(false)
const [addressDraft, setAddressDraft] = useState({ street: '', city: '', postalCode: '', country: 'France' })
const [addressDoc, setAddressDoc] = useState<Document | null>(null)
// Dialogue révision IA (corrections)
const [reviewDialogOpen, setReviewDialogOpen] = useState(false)
const [reviewDoc, setReviewDoc] = useState<Document | null>(null)
const [reviewCorrections, setReviewCorrections] = useState<any[]>([])
const handleConfirmAddress = useCallback((doc: Document) => {
try {
@ -249,6 +305,16 @@ export default function UploadView() {
}
}, [currentFolderHash, addressDoc, addressDraft, dispatch])
const handleReview = useCallback(async (doc: Document) => {
if (!currentFolderHash) return
try {
await reviewFileWithAI(currentFolderHash, doc.id)
await dispatch(loadFolderResults(currentFolderHash)).unwrap()
} catch (e) {
console.error('❌ Révision IA:', e)
}
}, [currentFolderHash, dispatch])
// Remplacement d'un fichier (qualité faible)
const handleReplace = useCallback((doc: Document) => {
const input = document.createElement('input')
@ -513,12 +579,14 @@ export default function UploadView() {
onDelete={handleDelete}
onReplace={handleReplace}
onConfirmAddress={handleConfirmAddress}
onReview={handleReview}
onOpenCorrections={(d, corr) => { setReviewDoc(d); setReviewCorrections(corr); setReviewDialogOpen(true) }}
totalCount={memoizedDocuments.length}
/>
))}
{hasPending && (
<>
{(pendingFiles.length > 0 ? pendingFiles : new Array(2).fill(null)).map((p, i) => (
{(pendingFiles.length > 0 ? pendingFiles : new Array(2).fill(null)).map((_, i) => (
<div key={`sk-${i}`}>
<ListItem>
<ListItemIcon>
@ -674,6 +742,44 @@ export default function UploadView() {
<Button variant="contained" onClick={submitAddressConfirmation}>Confirmer</Button>
</DialogActions>
</Dialog>
{/* Dialogue révision IA: corrections proposées */}
<Dialog open={reviewDialogOpen} onClose={() => setReviewDialogOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>Corrections proposées par l'IA</DialogTitle>
<DialogContent>
{reviewDoc ? (
<Typography variant="subtitle2" sx={{ mb: 1 }}>
Document: {(reviewDoc as any).displayName || reviewDoc.name}
</Typography>
) : null}
{Array.isArray(reviewCorrections) && reviewCorrections.length > 0 ? (
<List>
{reviewCorrections.map((c: any, i: number) => (
<ListItem key={`corr-${i}`} alignItems="flex-start">
<ListItemText
primary={(c?.path ? String(c.path) : `Correction ${i + 1}`)}
secondary={
<>
<Typography variant="body2" color="text.secondary">
Valeur: {typeof c?.value === 'object' ? JSON.stringify(c.value) : String(c?.value ?? '')}
</Typography>
{typeof c?.confidence === 'number' && (
<Typography variant="caption" color="text.secondary">Confiance: {c.confidence.toFixed ? c.confidence.toFixed(2) : c.confidence}</Typography>
)}
</>
}
/>
</ListItem>
))}
</List>
) : (
<Typography variant="body2" color="text.secondary">Aucune correction disponible.</Typography>
)}
</DialogContent>
<DialogActions>
<Button onClick={() => setReviewDialogOpen(false)}>Fermer</Button>
</DialogActions>
</Dialog>
</Layout>
)
}