design
This commit is contained in:
parent
0f0a26ed46
commit
5b2a5782be
2
.env
2
.env
@ -4,6 +4,8 @@ VITE_API_URL=http://localhost:18000
|
|||||||
# Configuration pour le développement
|
# Configuration pour le développement
|
||||||
VITE_APP_NAME=4NK IA Lecoffre.io
|
VITE_APP_NAME=4NK IA Lecoffre.io
|
||||||
VITE_APP_VERSION=0.1.0
|
VITE_APP_VERSION=0.1.0
|
||||||
|
VITE_USE_RULE_NER=true
|
||||||
|
VITE_LLM_CLASSIFY_ONLY=true
|
||||||
|
|
||||||
# Configuration des services externes (optionnel)
|
# Configuration des services externes (optionnel)
|
||||||
VITE_CADASTRE_API_URL=https://apicarto.ign.fr/api/cadastre
|
VITE_CADASTRE_API_URL=https://apicarto.ign.fr/api/cadastre
|
||||||
|
|||||||
@ -79,6 +79,7 @@ VITE_USE_OPENAI=false
|
|||||||
VITE_OPENAI_API_KEY=
|
VITE_OPENAI_API_KEY=
|
||||||
VITE_OPENAI_BASE_URL=https://api.openai.com/v1
|
VITE_OPENAI_BASE_URL=https://api.openai.com/v1
|
||||||
VITE_OPENAI_MODEL=gpt-4o-mini
|
VITE_OPENAI_MODEL=gpt-4o-mini
|
||||||
|
VITE_USE_RULE_NER=true
|
||||||
VITE_CADASTRE_API_URL=https://api.cadastre.gouv.fr
|
VITE_CADASTRE_API_URL=https://api.cadastre.gouv.fr
|
||||||
VITE_GEORISQUES_API_URL=https://www.georisques.gouv.fr/api
|
VITE_GEORISQUES_API_URL=https://www.georisques.gouv.fr/api
|
||||||
VITE_GEOFONCIER_API_URL=https://api.geofoncier.fr
|
VITE_GEOFONCIER_API_URL=https://api.geofoncier.fr
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Vite + React + TS</title>
|
<title>4NK IA - Lecoffre.io</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@ -1,7 +1,9 @@
|
|||||||
import React from 'react'
|
import React, { useEffect } from 'react'
|
||||||
import { AppBar, Toolbar, Typography, Container, Box } from '@mui/material'
|
import { AppBar, Toolbar, Typography, Container, Box, LinearProgress } from '@mui/material'
|
||||||
import { useNavigate, useLocation } from 'react-router-dom'
|
import { useNavigate, useLocation } from 'react-router-dom'
|
||||||
import { NavigationTabs } from './NavigationTabs'
|
import { NavigationTabs } from './NavigationTabs'
|
||||||
|
import { useAppDispatch, useAppSelector } from '../store'
|
||||||
|
import { extractDocument, analyzeDocument, getContextData, getConseil } from '../store/documentSlice'
|
||||||
|
|
||||||
interface LayoutProps {
|
interface LayoutProps {
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
@ -10,6 +12,24 @@ interface LayoutProps {
|
|||||||
export const Layout: React.FC<LayoutProps> = ({ children }) => {
|
export const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
|
const dispatch = useAppDispatch()
|
||||||
|
const { documents, extractionById, loading, currentDocument, contextResult, conseilResult, analysisResult } = useAppSelector((s) => s.document)
|
||||||
|
|
||||||
|
// Au chargement/nav: lancer OCR+classification pour tous les documents sans résultat
|
||||||
|
useEffect(() => {
|
||||||
|
documents.forEach((doc) => {
|
||||||
|
if (!extractionById[doc.id]) dispatch(extractDocument(doc.id))
|
||||||
|
})
|
||||||
|
}, [documents, extractionById, dispatch])
|
||||||
|
|
||||||
|
// Déclencher contexte et conseil globaux une fois qu'un document courant existe
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentDocument) {
|
||||||
|
if (!analysisResult) dispatch(analyzeDocument(currentDocument.id))
|
||||||
|
if (!contextResult) dispatch(getContextData(currentDocument.id))
|
||||||
|
if (!conseilResult) dispatch(getConseil(currentDocument.id))
|
||||||
|
}
|
||||||
|
}, [currentDocument, analysisResult, contextResult, conseilResult, dispatch])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ flexGrow: 1 }}>
|
<Box sx={{ flexGrow: 1 }}>
|
||||||
@ -28,6 +48,12 @@ export const Layout: React.FC<LayoutProps> = ({ children }) => {
|
|||||||
|
|
||||||
<NavigationTabs currentPath={location.pathname} />
|
<NavigationTabs currentPath={location.pathname} />
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<Box sx={{ px: 2, pt: 1 }}>
|
||||||
|
<LinearProgress />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
<Container maxWidth="xl" sx={{ mt: 3, mb: 3 }}>
|
<Container maxWidth="xl" sx={{ mt: 3, mb: 3 }}>
|
||||||
{children}
|
{children}
|
||||||
</Container>
|
</Container>
|
||||||
|
|||||||
@ -18,10 +18,20 @@ async function getPdfJs() {
|
|||||||
export async function extractTextFromFile(file: File): Promise<string> {
|
export async function extractTextFromFile(file: File): Promise<string> {
|
||||||
const mime = file.type || ''
|
const mime = file.type || ''
|
||||||
if (mime.includes('pdf') || file.name.toLowerCase().endsWith('.pdf')) {
|
if (mime.includes('pdf') || file.name.toLowerCase().endsWith('.pdf')) {
|
||||||
return extractFromPdf(file)
|
const pdfText = await extractFromPdf(file)
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.info('[OCR][PDF]', file.name, 'len=', pdfText.length, 'peek=', pdfText.slice(0, 200))
|
||||||
|
}
|
||||||
|
return pdfText
|
||||||
}
|
}
|
||||||
if (mime.startsWith('image/') || ['.png', '.jpg', '.jpeg'].some((ext) => file.name.toLowerCase().endsWith(ext))) {
|
if (mime.startsWith('image/') || ['.png', '.jpg', '.jpeg'].some((ext) => file.name.toLowerCase().endsWith(ext))) {
|
||||||
return extractFromImage(file)
|
const imgText = await extractFromImage(file)
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.info('[OCR][IMG]', file.name, 'len=', imgText.length, 'peek=', imgText.slice(0, 200))
|
||||||
|
}
|
||||||
|
return imgText
|
||||||
}
|
}
|
||||||
// Fallback: lecture texte brut
|
// Fallback: lecture texte brut
|
||||||
try {
|
try {
|
||||||
@ -40,8 +50,23 @@ async function extractFromPdf(file: File): Promise<string> {
|
|||||||
const numPages = Math.min(pdf.numPages, 50)
|
const numPages = Math.min(pdf.numPages, 50)
|
||||||
for (let i = 1; i <= numPages; i += 1) {
|
for (let i = 1; i <= numPages; i += 1) {
|
||||||
const page = await pdf.getPage(i)
|
const page = await pdf.getPage(i)
|
||||||
const content = await page.getTextContent()
|
const content = await page.getTextContent().catch(() => null)
|
||||||
const pageText = content.items.map((it: any) => (it.str ? it.str : '')).join(' ')
|
let pageText = ''
|
||||||
|
if (content) {
|
||||||
|
pageText = content.items.map((it: any) => (it.str ? it.str : '')).join(' ')
|
||||||
|
}
|
||||||
|
// Fallback OCR si pas de texte exploitable
|
||||||
|
if (!pageText || pageText.replace(/\s+/g, '').length < 30) {
|
||||||
|
const viewport = page.getViewport({ scale: 2 })
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
canvas.width = viewport.width
|
||||||
|
canvas.height = viewport.height
|
||||||
|
const ctx = canvas.getContext('2d') as any
|
||||||
|
await page.render({ canvasContext: ctx, viewport }).promise
|
||||||
|
const blob: Blob = await new Promise((resolve) => canvas.toBlob((b) => resolve(b as Blob), 'image/png'))
|
||||||
|
const ocrText = await extractFromImage(new File([blob], `${file.name}-p${i}.png`, { type: 'image/png' }))
|
||||||
|
pageText = ocrText
|
||||||
|
}
|
||||||
if (pageText.trim()) texts.push(pageText)
|
if (pageText.trim()) texts.push(pageText)
|
||||||
}
|
}
|
||||||
return texts.join('\n')
|
return texts.join('\n')
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import type {
|
|||||||
ConseilResult,
|
ConseilResult,
|
||||||
} from '../types'
|
} from '../types'
|
||||||
import { extractTextFromFile } from './fileExtract'
|
import { extractTextFromFile } from './fileExtract'
|
||||||
|
import { runRuleNER } from './ruleNer'
|
||||||
|
|
||||||
const OPENAI_API_KEY = import.meta.env.VITE_OPENAI_API_KEY
|
const OPENAI_API_KEY = import.meta.env.VITE_OPENAI_API_KEY
|
||||||
const OPENAI_BASE_URL = import.meta.env.VITE_OPENAI_BASE_URL || 'https://api.openai.com/v1'
|
const OPENAI_BASE_URL = import.meta.env.VITE_OPENAI_BASE_URL || 'https://api.openai.com/v1'
|
||||||
@ -83,28 +84,105 @@ export const openaiDocumentApi = {
|
|||||||
localText = ''
|
localText = ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Flags de mode
|
||||||
|
const useRuleNer = import.meta.env.VITE_USE_RULE_NER === 'true'
|
||||||
|
const classifyOnly = import.meta.env.VITE_LLM_CLASSIFY_ONLY === 'true'
|
||||||
|
|
||||||
|
// Si NER local actif, on l'utilise pour tout (identités/adresses/...) puis, si demandé,
|
||||||
|
// on peut consulter le LLM uniquement pour classifier le type de document
|
||||||
|
if (useRuleNer) {
|
||||||
|
let res = runRuleNER(documentId, localText)
|
||||||
|
if (classifyOnly && OPENAI_API_KEY && localText) {
|
||||||
|
try {
|
||||||
hooks?.onLlmProgress?.(0)
|
hooks?.onLlmProgress?.(0)
|
||||||
|
const cls = await callOpenAIChat([
|
||||||
|
{ role: 'system', content: 'Tu es un classifieur. Retourne uniquement un JSON strict.' },
|
||||||
|
{ role: 'user', content: `Classifie ce texte en une des catégories suivantes: [CNI, Facture, Attestation, Document]. Réponds strictement sous la forme {"documentType":"..."}.\nTexte:\n${localText.slice(0, 8000)}` },
|
||||||
|
])
|
||||||
|
const parsed = JSON.parse(cls)
|
||||||
|
if (parsed && typeof parsed.documentType === 'string') {
|
||||||
|
res = { ...res, documentType: parsed.documentType }
|
||||||
|
res.confidenceReasons = [...(res.confidenceReasons || []), 'Classification LLM limitée au documentType']
|
||||||
|
}
|
||||||
|
hooks?.onLlmProgress?.(1)
|
||||||
|
} catch {
|
||||||
|
// ignore échec de classification
|
||||||
|
hooks?.onLlmProgress?.(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
hooks?.onLlmProgress?.(0)
|
||||||
|
// Si on demande uniquement la classification par LLM, ne demander que le type;
|
||||||
|
// sinon on demande la structuration complète (mode précédent)
|
||||||
|
if (classifyOnly) {
|
||||||
|
try {
|
||||||
|
const cls = await callOpenAIChat([
|
||||||
|
{ role: 'system', content: 'Tu es un classifieur. Retourne uniquement un JSON strict.' },
|
||||||
|
{ role: 'user', content: `Classifie ce texte en une des catégories suivantes: [CNI, Facture, Attestation, Document]. Réponds strictement sous la forme {"documentType":"..."}.\nTexte:\n${localText.slice(0, 8000)}` },
|
||||||
|
])
|
||||||
|
const parsed = JSON.parse(cls)
|
||||||
|
hooks?.onLlmProgress?.(1)
|
||||||
|
return {
|
||||||
|
documentId,
|
||||||
|
text: localText || '',
|
||||||
|
language: 'fr',
|
||||||
|
documentType: (parsed && parsed.documentType) || 'Document',
|
||||||
|
identities: [],
|
||||||
|
addresses: [],
|
||||||
|
properties: [],
|
||||||
|
contracts: [],
|
||||||
|
signatures: [],
|
||||||
|
confidence: 0.6,
|
||||||
|
confidenceReasons: ['Classification LLM sans contexte, pas d\'extraction d\'identités'],
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
hooks?.onLlmProgress?.(1)
|
||||||
|
return {
|
||||||
|
documentId,
|
||||||
|
text: localText || '',
|
||||||
|
language: 'fr',
|
||||||
|
documentType: 'Document',
|
||||||
|
identities: [],
|
||||||
|
addresses: [],
|
||||||
|
properties: [],
|
||||||
|
contracts: [],
|
||||||
|
signatures: [],
|
||||||
|
confidence: 0.6,
|
||||||
|
confidenceReasons: ['Classification LLM échouée, valeur par défaut'],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const content = await callOpenAIChat([
|
const content = await callOpenAIChat([
|
||||||
{
|
{
|
||||||
role: 'system',
|
role: 'system',
|
||||||
content:
|
content:
|
||||||
'Tu es un assistant qui extrait des informations structurées (identités, adresses, biens, contrats) à partir de documents. Réponds en JSON strict, sans texte autour.',
|
'Tu extrais uniquement les informations présentes dans le texte OCR. Interdiction d\'inventer. Interdiction d\'utiliser le nom du fichier comme identité. Réponds en JSON strict, sans texte autour.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
role: 'user',
|
role: 'user',
|
||||||
content: `Document ID: ${documentId}. Texte: ${localText.slice(0, 8000)}\nRetourne un JSON avec la forme suivante: {"language":"fr","documentType":"...","identities":[{"id":"id-1","type":"person","firstName":"...","lastName":"...","confidence":0.9}],"addresses":[{"street":"...","city":"...","postalCode":"...","country":"..."}],"properties":[{"id":"prop-1","type":"apartment","address":{"street":"...","city":"...","postalCode":"...","country":"..."},"surface":75}],"contracts":[{"id":"contract-1","type":"sale","parties":[],"amount":0,"date":"YYYY-MM-DD","clauses":["..."]}],"signatures":[],"confidence":0.7,"confidenceReasons":["..."]}`,
|
content: `Document ID: ${documentId}. Texte OCR (tronqué): ${localText.slice(0, 8000)}\nRègles: 1) ne pas inventer, 2) si incertitude, laisser vide, 3) ne JAMAIS utiliser le nom du fichier comme identité. Schéma JSON: {"language":"fr","documentType":"...","identities":[{"id":"id-1","type":"person","firstName":"...","lastName":"...","confidence":0.9}],"addresses":[{"street":"...","city":"...","postalCode":"...","country":"..."}],"properties":[{"id":"prop-1","type":"apartment","address":{"street":"...","city":"...","postalCode":"...","country":"..."},"surface":75}],"contracts":[{"id":"contract-1","type":"sale","parties":[],"amount":0,"date":"YYYY-MM-DD","clauses":["..."]}],"signatures":[],"confidence":0.7,"confidenceReasons":["sources présentes dans le texte"]}`,
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
// Essaye d'analyser le JSON, sinon fallback heuristique
|
// Essaye d'analyser le JSON, sinon fallback heuristique
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(content)
|
const parsed = JSON.parse(content)
|
||||||
hooks?.onLlmProgress?.(1)
|
hooks?.onLlmProgress?.(1)
|
||||||
|
// Post-traitement: filtrage des identités qui ressemblent au nom de fichier
|
||||||
|
const docBase = (file?.name || '').toLowerCase().replace(/\.[a-z0-9]+$/, '')
|
||||||
|
const safeIdentities = (parsed.identities || []).filter((it: any) => {
|
||||||
|
const full = `${it.firstName || ''} ${it.lastName || ''}`.trim().toLowerCase()
|
||||||
|
return full && !docBase || (full && !docBase.includes(full) && !full.includes(docBase))
|
||||||
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
documentId,
|
documentId,
|
||||||
text: localText || '',
|
text: localText || '',
|
||||||
language: parsed.language || 'fr',
|
language: parsed.language || 'fr',
|
||||||
documentType: parsed.documentType || 'Document',
|
documentType: parsed.documentType || 'Document',
|
||||||
identities: parsed.identities || [],
|
identities: safeIdentities,
|
||||||
addresses: parsed.addresses || [],
|
addresses: parsed.addresses || [],
|
||||||
properties: parsed.properties || [],
|
properties: parsed.properties || [],
|
||||||
contracts: parsed.contracts || [],
|
contracts: parsed.contracts || [],
|
||||||
|
|||||||
140
src/services/ruleNer.ts
Normal file
140
src/services/ruleNer.ts
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
import type { ExtractionResult, Identity, Address, Property, Contract } from '../types'
|
||||||
|
|
||||||
|
function toTitleCase(input: string): string {
|
||||||
|
return input
|
||||||
|
.toLowerCase()
|
||||||
|
.split(/\s+/)
|
||||||
|
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
||||||
|
.join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractMRZ(text: string): { firstName?: string; lastName?: string } | null {
|
||||||
|
// Cherche MRZ (deux lignes, < comme séparateur). Stricte A-Z0-9<
|
||||||
|
const lines = text.split(/\n|\r/).map((l) => l.trim().toUpperCase())
|
||||||
|
for (let i = 0; i < lines.length - 1; i += 1) {
|
||||||
|
const a = lines[i].replace(/[^A-Z0-9<]/g, '')
|
||||||
|
const b = lines[i + 1].replace(/[^A-Z0-9<]/g, '')
|
||||||
|
if (a.includes('<<') || b.includes('<<')) {
|
||||||
|
const target = a.length >= b.length ? a : b
|
||||||
|
const parts = target.split('<<')
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
const rawLast = parts[0].replace(/<+/g, ' ').trim()
|
||||||
|
const rawFirst = parts[1].replace(/<+/g, ' ').trim()
|
||||||
|
if (rawLast && rawFirst) return { firstName: toTitleCase(rawFirst), lastName: rawLast.replace(/\s+/g, ' ') }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractDates(text: string): string[] {
|
||||||
|
const results = new Set<string>()
|
||||||
|
const patterns = [
|
||||||
|
/(\b\d{2}[\/\-]\d{2}[\/\-]\d{4}\b)/g, // JJ/MM/AAAA ou JJ-MM-AAAA
|
||||||
|
/(\b\d{4}[\/\-]\d{2}[\/\-]\d{2}\b)/g, // AAAA/MM/JJ
|
||||||
|
]
|
||||||
|
for (const re of patterns) {
|
||||||
|
for (const m of text.matchAll(re)) results.add(m[1])
|
||||||
|
}
|
||||||
|
return Array.from(results)
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractCniNumbers(text: string): string[] {
|
||||||
|
const results = new Set<string>()
|
||||||
|
const re = /\b[A-Z0-9]{12,15}\b/g
|
||||||
|
for (const m of text.toUpperCase().matchAll(re)) results.add(m[0])
|
||||||
|
return Array.from(results)
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractAddresses(text: string): Address[] {
|
||||||
|
const items: Address[] = []
|
||||||
|
const typeVoie = '(rue|avenue|av\.?|bd\.?|boulevard|impasse|chemin|all(é|e)e|route|place|quai|passage|square|voie|faubourg|fg\.?|cours|sentier|residence|résidence)'
|
||||||
|
const re = new RegExp(`(\\b\\d{1,4})\\s+([A-Za-zÀ-ÖØ-öø-ÿ\\-']{2,})\\s+${typeVoie}\\s+([A-Za-zÀ-ÖØ-öø-ÿ\\-']{2,})(?:\\s+|,)+(\\b\\d{5}\\b)\\s+([A-Za-zÀ-ÖØ-öø-ÿ\\-']{2,})`, 'gi')
|
||||||
|
for (const m of text.matchAll(re)) {
|
||||||
|
const street = `${m[1]} ${toTitleCase(`${m[2]} ${m[3]} ${m[4]}`)}`.trim()
|
||||||
|
const postalCode = m[5]
|
||||||
|
const city = toTitleCase(m[6])
|
||||||
|
items.push({ street, city, postalCode, country: 'France' })
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractNames(text: string): Identity[] {
|
||||||
|
const identities: Identity[] = []
|
||||||
|
// Heuristique: lignes en MAJUSCULES pour NOM; prénoms capitalisés à proximité
|
||||||
|
const lines = text.split(/\n|\r/).map((l) => l.trim()).filter(Boolean)
|
||||||
|
for (let i = 0; i < lines.length; i += 1) {
|
||||||
|
const line = lines[i]
|
||||||
|
if (/^[A-ZÀ-ÖØ-Þ\-\s]{3,}$/.test(line) && line.length <= 40) {
|
||||||
|
const lastName = line.replace(/\s+/g, ' ').trim()
|
||||||
|
// Cherche un prénom sur la ligne suivante ou la même ligne
|
||||||
|
const cand = (lines[i + 1] || '').trim()
|
||||||
|
const firstNameMatch = cand.match(/^[A-Z][a-zà-öø-ÿ'\-]{1,}(?:\s+[A-Z][a-zà-öø-ÿ'\-]{1,})?$/)
|
||||||
|
const firstName = firstNameMatch ? cand : undefined
|
||||||
|
if (lastName && (!firstName || firstName.length <= 40)) {
|
||||||
|
identities.push({
|
||||||
|
id: `id-${i}`,
|
||||||
|
type: 'person',
|
||||||
|
firstName: firstName ? toTitleCase(firstName) : undefined,
|
||||||
|
lastName,
|
||||||
|
confidence: firstName ? 0.85 : 0.7,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return identities
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runRuleNER(documentId: string, text: string): ExtractionResult {
|
||||||
|
const identitiesFromMRZ = extractMRZ(text)
|
||||||
|
const identities = identitiesFromMRZ
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
id: 'mrz-1',
|
||||||
|
type: 'person',
|
||||||
|
firstName: identitiesFromMRZ.firstName,
|
||||||
|
lastName: identitiesFromMRZ.lastName!,
|
||||||
|
confidence: 0.9,
|
||||||
|
} as Identity,
|
||||||
|
]
|
||||||
|
: extractNames(text)
|
||||||
|
|
||||||
|
const addresses = extractAddresses(text)
|
||||||
|
const cniNumbers = extractCniNumbers(text)
|
||||||
|
const dates = extractDates(text)
|
||||||
|
|
||||||
|
const contracts: Contract[] = []
|
||||||
|
const properties: Property[] = []
|
||||||
|
|
||||||
|
const reasons: string[] = []
|
||||||
|
if (identities.length) reasons.push('Identités détectées par règles')
|
||||||
|
if (addresses.length) reasons.push('Adresse(s) détectée(s) par motifs')
|
||||||
|
if (cniNumbers.length) reasons.push('Numéro CNI plausible détecté')
|
||||||
|
if (dates.length) reasons.push('Dates détectées')
|
||||||
|
|
||||||
|
let documentType = 'Document'
|
||||||
|
if (/carte\s+nationale\s+d'identité|cni|mrz|identite/i.test(text)) documentType = 'CNI'
|
||||||
|
else if (/facture|tva|siren|montant/i.test(text)) documentType = 'Facture'
|
||||||
|
else if (/attestation|certificat/i.test(text)) documentType = 'Attestation'
|
||||||
|
|
||||||
|
// Confiance: base 0.6 + bonus par signal
|
||||||
|
let confidence = 0.6
|
||||||
|
if (identities.length) confidence += 0.15
|
||||||
|
if (cniNumbers.length) confidence += 0.15
|
||||||
|
if (addresses.length) confidence += 0.05
|
||||||
|
confidence = Math.max(0, Math.min(1, confidence))
|
||||||
|
|
||||||
|
return {
|
||||||
|
documentId,
|
||||||
|
text,
|
||||||
|
language: 'fr',
|
||||||
|
documentType,
|
||||||
|
identities,
|
||||||
|
addresses,
|
||||||
|
properties,
|
||||||
|
contracts,
|
||||||
|
signatures: [],
|
||||||
|
confidence,
|
||||||
|
confidenceReasons: reasons,
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -9,6 +9,7 @@ interface DocumentState {
|
|||||||
currentDocument: Document | null
|
currentDocument: Document | null
|
||||||
extractionResult: ExtractionResult | null
|
extractionResult: ExtractionResult | null
|
||||||
extractionById: Record<string, ExtractionResult>
|
extractionById: Record<string, ExtractionResult>
|
||||||
|
fileById: Record<string, File>
|
||||||
analysisResult: AnalysisResult | null
|
analysisResult: AnalysisResult | null
|
||||||
contextResult: ContextResult | null
|
contextResult: ContextResult | null
|
||||||
conseilResult: ConseilResult | null
|
conseilResult: ConseilResult | null
|
||||||
@ -22,6 +23,7 @@ const initialState: DocumentState = {
|
|||||||
currentDocument: null,
|
currentDocument: null,
|
||||||
extractionResult: null,
|
extractionResult: null,
|
||||||
extractionById: {},
|
extractionById: {},
|
||||||
|
fileById: {},
|
||||||
analysisResult: null,
|
analysisResult: null,
|
||||||
contextResult: null,
|
contextResult: null,
|
||||||
conseilResult: null,
|
conseilResult: null,
|
||||||
@ -148,6 +150,11 @@ const documentSlice = createSlice({
|
|||||||
state.loading = false
|
state.loading = false
|
||||||
state.documents.push(action.payload)
|
state.documents.push(action.payload)
|
||||||
state.currentDocument = action.payload
|
state.currentDocument = action.payload
|
||||||
|
// Capture le File depuis l'URL blob si disponible
|
||||||
|
if (action.payload.previewUrl?.startsWith('blob:')) {
|
||||||
|
// On ne peut pas récupérer l'objet File initial ici sans passer par onDrop;
|
||||||
|
// il est reconstruit lors de l'extraction via fetch blob.
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.addCase(uploadDocument.rejected, (state, action) => {
|
.addCase(uploadDocument.rejected, (state, action) => {
|
||||||
state.loading = false
|
state.loading = false
|
||||||
|
|||||||
@ -24,20 +24,19 @@ import {
|
|||||||
} from '@mui/icons-material'
|
} from '@mui/icons-material'
|
||||||
import type { ChipProps, LinearProgressProps } from '@mui/material'
|
import type { ChipProps, LinearProgressProps } from '@mui/material'
|
||||||
import { useAppDispatch, useAppSelector } from '../store'
|
import { useAppDispatch, useAppSelector } from '../store'
|
||||||
import { analyzeDocument } from '../store/documentSlice'
|
import { analyzeDocument, getConseil, getContextData } from '../store/documentSlice'
|
||||||
import { Layout } from '../components/Layout'
|
import { Layout } from '../components/Layout'
|
||||||
|
|
||||||
export default function AnalyseView() {
|
export default function AnalyseView() {
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch()
|
||||||
const { currentDocument, analysisResult, loading } = useAppSelector(
|
const { currentDocument, analysisResult, loading, conseilResult, contextResult } = useAppSelector((state) => state.document)
|
||||||
(state) => state.document
|
|
||||||
)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentDocument && !analysisResult) {
|
if (!currentDocument) return
|
||||||
dispatch(analyzeDocument(currentDocument.id))
|
if (!analysisResult) dispatch(analyzeDocument(currentDocument.id))
|
||||||
}
|
if (!conseilResult) dispatch(getConseil(currentDocument.id))
|
||||||
}, [currentDocument, analysisResult, dispatch])
|
if (!contextResult) dispatch(getContextData(currentDocument.id))
|
||||||
|
}, [currentDocument, analysisResult, conseilResult, contextResult, dispatch])
|
||||||
|
|
||||||
if (!currentDocument) {
|
if (!currentDocument) {
|
||||||
return (
|
return (
|
||||||
@ -97,16 +96,10 @@ export default function AnalyseView() {
|
|||||||
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap', alignItems: 'center' }}>
|
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||||
<Chip
|
<Chip
|
||||||
icon={<Assessment />}
|
icon={<Assessment />}
|
||||||
label={`Score de vraisemblance: ${(analysisResult.credibilityScore * 100).toFixed(1)}%`}
|
label={`Avancement: ${Math.round(analysisResult.credibilityScore * 100)}%`}
|
||||||
color={getScoreColor(analysisResult.credibilityScore)}
|
color={getScoreColor(analysisResult.credibilityScore)}
|
||||||
variant="filled"
|
variant="filled"
|
||||||
/>
|
/>
|
||||||
<Chip
|
|
||||||
icon={<Info />}
|
|
||||||
label={`Type: ${analysisResult.documentType}`}
|
|
||||||
color="primary"
|
|
||||||
variant="outlined"
|
|
||||||
/>
|
|
||||||
{analysisResult.isCNI && (
|
{analysisResult.isCNI && (
|
||||||
<Chip
|
<Chip
|
||||||
icon={<Flag />}
|
icon={<Flag />}
|
||||||
|
|||||||
@ -68,7 +68,9 @@ export default function ExtractionView() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!extractionResult) {
|
const activeResult = currentDocument ? (extractionById[currentDocument.id] || extractionResult) : extractionResult
|
||||||
|
|
||||||
|
if (!activeResult) {
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
<Alert severity="warning">
|
<Alert severity="warning">
|
||||||
@ -113,28 +115,28 @@ export default function ExtractionView() {
|
|||||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||||
<Chip
|
<Chip
|
||||||
icon={<Language />}
|
icon={<Language />}
|
||||||
label={`Langue: ${ (extractionById[currentDocument!.id] || extractionResult)!.language }`}
|
label={`Langue: ${ activeResult.language }`}
|
||||||
color="primary"
|
color="primary"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
/>
|
/>
|
||||||
<Chip
|
<Chip
|
||||||
icon={<Description />}
|
icon={<Description />}
|
||||||
label={`Type: ${ (extractionById[currentDocument!.id] || extractionResult)!.documentType }`}
|
label={`Type: ${ activeResult.documentType }`}
|
||||||
color="secondary"
|
color="secondary"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
/>
|
/>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
arrow
|
arrow
|
||||||
title={
|
title={
|
||||||
(() => { const r = (extractionById[currentDocument!.id] || extractionResult)!; return (r.confidenceReasons && r.confidenceReasons.length > 0)
|
(activeResult.confidenceReasons && activeResult.confidenceReasons.length > 0)
|
||||||
? r.confidenceReasons.join(' • ')
|
? activeResult.confidenceReasons.join(' • ')
|
||||||
: `Évaluation automatique basée sur le contenu et le type (${r.documentType}).` })()
|
: `Évaluation automatique basée sur le contenu et le type (${activeResult.documentType}).`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Chip
|
<Chip
|
||||||
icon={<Verified />}
|
icon={<Verified />}
|
||||||
label={`Confiance: ${(() => { const r = (extractionById[currentDocument!.id] || extractionResult)!; return Math.round(r.confidence * 100)})()}%`}
|
label={`Confiance: ${Math.round(activeResult.confidence * 100)}%`}
|
||||||
color={(() => { const r = (extractionById[currentDocument!.id] || extractionResult)!; return r.confidence > 0.8 ? 'success' : 'warning' })()}
|
color={activeResult.confidence > 0.8 ? 'success' : 'warning'}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@ -210,10 +212,10 @@ export default function ExtractionView() {
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
<Typography variant="h6" gutterBottom>
|
<Typography variant="h6" gutterBottom>
|
||||||
<Person sx={{ mr: 1, verticalAlign: 'middle' }} />
|
<Person sx={{ mr: 1, verticalAlign: 'middle' }} />
|
||||||
Identités ({extractionResult.identities?.length || 0})
|
Identités ({activeResult.identities?.length || 0})
|
||||||
</Typography>
|
</Typography>
|
||||||
<List dense>
|
<List dense>
|
||||||
{(extractionResult.identities || []).map((identity, index) => (
|
{(activeResult.identities || []).map((identity, index) => (
|
||||||
<ListItem key={index}>
|
<ListItem key={index}>
|
||||||
<ListItemText
|
<ListItemText
|
||||||
primary={
|
primary={
|
||||||
@ -256,10 +258,10 @@ export default function ExtractionView() {
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
<Typography variant="h6" gutterBottom>
|
<Typography variant="h6" gutterBottom>
|
||||||
<LocationOn sx={{ mr: 1, verticalAlign: 'middle' }} />
|
<LocationOn sx={{ mr: 1, verticalAlign: 'middle' }} />
|
||||||
Adresses ({extractionResult.addresses?.length || 0})
|
Adresses ({activeResult.addresses?.length || 0})
|
||||||
</Typography>
|
</Typography>
|
||||||
<List dense>
|
<List dense>
|
||||||
{(extractionResult.addresses || []).map((address, index) => (
|
{(activeResult.addresses || []).map((address, index) => (
|
||||||
<ListItem key={index}>
|
<ListItem key={index}>
|
||||||
<ListItemText
|
<ListItemText
|
||||||
primary={`${address.street}, ${address.city}`}
|
primary={`${address.street}, ${address.city}`}
|
||||||
@ -280,10 +282,10 @@ export default function ExtractionView() {
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
<Typography variant="h6" gutterBottom>
|
<Typography variant="h6" gutterBottom>
|
||||||
<Home sx={{ mr: 1, verticalAlign: 'middle' }} />
|
<Home sx={{ mr: 1, verticalAlign: 'middle' }} />
|
||||||
Biens ({extractionResult.properties?.length || 0})
|
Biens ({activeResult.properties?.length || 0})
|
||||||
</Typography>
|
</Typography>
|
||||||
<List dense>
|
<List dense>
|
||||||
{(extractionResult.properties || []).map((property, index) => (
|
{(activeResult.properties || []).map((property, index) => (
|
||||||
<ListItem key={index}>
|
<ListItem key={index}>
|
||||||
<ListItemText
|
<ListItemText
|
||||||
primary={`${property.type} - ${property.address.city}`}
|
primary={`${property.type} - ${property.address.city}`}
|
||||||
@ -319,10 +321,10 @@ export default function ExtractionView() {
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
<Typography variant="h6" gutterBottom>
|
<Typography variant="h6" gutterBottom>
|
||||||
<Description sx={{ mr: 1, verticalAlign: 'middle' }} />
|
<Description sx={{ mr: 1, verticalAlign: 'middle' }} />
|
||||||
Contrats ({extractionResult.contracts?.length || 0})
|
Contrats ({activeResult.contracts?.length || 0})
|
||||||
</Typography>
|
</Typography>
|
||||||
<List dense>
|
<List dense>
|
||||||
{(extractionResult.contracts || []).map((contract, index) => (
|
{(activeResult.contracts || []).map((contract, index) => (
|
||||||
<ListItem key={index}>
|
<ListItem key={index}>
|
||||||
<ListItemText
|
<ListItemText
|
||||||
primary={`${contract.type} - ${contract.amount ? `${contract.amount}€` : 'Montant non spécifié'}`}
|
primary={`${contract.type} - ${contract.amount ? `${contract.amount}€` : 'Montant non spécifié'}`}
|
||||||
@ -355,10 +357,10 @@ export default function ExtractionView() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Typography variant="h6" gutterBottom>
|
<Typography variant="h6" gutterBottom>
|
||||||
Signatures détectées ({extractionResult.signatures?.length || 0})
|
Signatures détectées ({activeResult.signatures?.length || 0})
|
||||||
</Typography>
|
</Typography>
|
||||||
<List dense>
|
<List dense>
|
||||||
{(extractionResult.signatures || []).map((signature: any, index: number) => {
|
{(activeResult.signatures || []).map((signature: any, index: number) => {
|
||||||
const label = typeof signature === 'string'
|
const label = typeof signature === 'string'
|
||||||
? signature
|
? signature
|
||||||
: signature?.name || signature?.title || signature?.date || JSON.stringify(signature)
|
: signature?.name || signature?.title || signature?.date || JSON.stringify(signature)
|
||||||
@ -387,7 +389,7 @@ export default function ExtractionView() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap' }}>
|
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap' }}>
|
||||||
{extractionResult.text}
|
{activeResult.text}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Paper>
|
</Paper>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user