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:
parent
a3501def35
commit
4bed3562b1
26552
backend/data/names/firstnames_all.csv
Normal file
26552
backend/data/names/firstnames_all.csv
Normal file
File diff suppressed because it is too large
Load Diff
66942
backend/data/names/lastnames_all.csv
Normal file
66942
backend/data/names/lastnames_all.csv
Normal file
File diff suppressed because it is too large
Load Diff
@ -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 }
|
||||
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 l’original 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 d’un chip “IA: x.xx” (tooltip = avis) et d’un 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
|
||||
- L’usage d’un 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.
|
||||
|
||||
51
docs/revision_ia_ollama.md
Normal file
51
docs/revision_ia_ollama.md
Normal 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 s’appuyant 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 l’endpoint 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 d’identité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 l’item.
|
||||
|
||||
#### Tests manuels (checklist)
|
||||
- Vérifier qu’un 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 l’affichage 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 l’exploitation et éviter les parsings fragiles.
|
||||
- Utilisation d’`http` natif côté Node pour éviter l’ajout de dépendances.
|
||||
@ -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>
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user