This commit is contained in:
Nicolas Cantu 2025-09-15 13:37:53 +02:00
parent ae8e647cf0
commit 0f0a26ed46
23 changed files with 1670 additions and 836 deletions

6
.env
View File

@ -2,7 +2,7 @@
VITE_API_URL=http://localhost:18000
# Configuration pour le développement
VITE_APP_NAME=4NK IA Front Notarial
VITE_APP_NAME=4NK IA Lecoffre.io
VITE_APP_VERSION=0.1.0
# Configuration des services externes (optionnel)
@ -11,3 +11,7 @@ VITE_GEORISQUES_API_URL=https://www.georisques.gouv.fr/api
VITE_GEOFONCIER_API_URL=https://api2.geofoncier.fr
VITE_BODACC_API_URL=https://bodacc-datadila.opendatasoft.com/api
VITE_INFOGREFFE_API_URL=https://entreprise.api.gouv.fr
VITE_OPENAI_API_KEY=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
VITE_USE_OPENAI=true
VITE_OPENAI_BASE_URL=https://api.openai.com/v1
VITE_OPENAI_MODEL=gpt-4o-mini

View File

@ -2,7 +2,7 @@
VITE_API_URL=http://localhost:8000
# Configuration pour le développement
VITE_APP_NAME=4NK IA Front Notarial
VITE_APP_NAME=4NK IA Lecoffre.io
VITE_APP_VERSION=0.1.0
# Configuration des services externes (optionnel)

View File

@ -3,7 +3,7 @@ name: Build & Push Docker Image
on:
push:
branches:
- '**'
- 'release'
jobs:
docker:

1
.gitignore vendored
View File

@ -22,3 +22,4 @@ dist-ssr
*.njsproj
*.sln
*.sw?
test-files/

View File

@ -1,4 +1,4 @@
# 4NK IA Front Notarial
# Lecoffre.io
Application front-end pour l'analyse intelligente de documents notariaux avec IA.
@ -75,6 +75,10 @@ Créer un fichier `.env` :
```env
VITE_API_URL=http://localhost:8000
VITE_USE_OPENAI=false
VITE_OPENAI_API_KEY=
VITE_OPENAI_BASE_URL=https://api.openai.com/v1
VITE_OPENAI_MODEL=gpt-4o-mini
VITE_CADASTRE_API_URL=https://api.cadastre.gouv.fr
VITE_GEORISQUES_API_URL=https://www.georisques.gouv.fr/api
VITE_GEOFONCIER_API_URL=https://api.geofoncier.fr

View File

@ -15,41 +15,41 @@
}
</style>
</head>
<body>
<div class='wrapper'>
<div class='pad1'>
<h1><a href="../../index.html">All files</a> / <a href="index.html">src/components</a> Layout.tsx</h1>
<div class='clearfix'>
<div class='fl pad1y space-right2'>
<span class="strong">0% </span>
<span class="quiet">Statements</span>
<span class='fraction'>0/26</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">0% </span>
<span class="quiet">Branches</span>
<span class='fraction'>0/1</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">0% </span>
<span class="quiet">Functions</span>
<span class='fraction'>0/1</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">0% </span>
<span class="quiet">Lines</span>
<span class='fraction'>0/26</span>
</div>
</div>
<p class="quiet">
Press <em>n</em> or <em>j</em> to go to the next uncovered block, <em>b</em>, <em>p</em> or <em>k</em> for the previous block.
@ -158,7 +158,7 @@ interface LayoutProps {
<span class="cstat-no" title="statement not covered" > sx={{ flexGrow: 1, cursor: 'pointer' }}</span>
<span class="cstat-no" title="statement not covered" > onClick={() =&gt; navigate('/')}</span>
<span class="cstat-no" title="statement not covered" > &gt;</span>
4NK IA - Front Notarial
4NK IA - Lecoffre.io
<span class="cstat-no" title="statement not covered" > &lt;/Typography&gt;</span>
<span class="cstat-no" title="statement not covered" > &lt;/Toolbar&gt;</span>
<span class="cstat-no" title="statement not covered" > &lt;/AppBar&gt;</span>
@ -190,4 +190,3 @@ interface LayoutProps {
<script src="../../block-navigation.js"></script>
</body>
</html>

View File

@ -1,8 +1,8 @@
# Documentation API - 4NK IA Front Notarial
# Documentation API - 4NK IA Lecoffre.io
## Vue d'ensemble
L'application 4NK IA Front Notarial communique uniquement avec le backend interne pour toutes les
L'application 4NK IA Lecoffre.io communique uniquement avec le backend interne pour toutes les
fonctionnalités (upload, extraction, analyse, contexte, conseil).
## API Backend Principal
@ -100,8 +100,22 @@ uniquement. Aucun appel direct côté front.
```env
VITE_API_URL=http://localhost:8000
VITE_USE_OPENAI=false
VITE_OPENAI_API_KEY=
VITE_OPENAI_BASE_URL=https://api.openai.com/v1
VITE_OPENAI_MODEL=gpt-4o-mini
```
## Mode OpenAI (fallback)
Quand `VITE_USE_OPENAI=true`, le frontend bascule sur un mode de secours basé sur OpenAI:
- Upload: simulé côté client (le fichier nest pas envoyé à OpenAI)
- Extraction/Analyse/Conseil/Contexte: appels `chat.completions` sur `VITE_OPENAI_MODEL`
- Détection de type: heuristique simple côté client
Ce mode est utile pour démo/diagnostic quand le backend nest pas disponible.
### Configuration Axios
```typescript

View File

@ -1,8 +1,8 @@
# Architecture de l'application 4NK IA Front Notarial
# Architecture de l'application 4NK IA Lecoffre.io
## Vue d'ensemble
L'application 4NK IA Front Notarial est une interface web moderne construite avec React et TypeScript,
L'application 4NK IA Lecoffre.io est une interface web moderne construite avec React et TypeScript,
conçue pour l'analyse intelligente de documents notariaux.
## Stack technique

View File

@ -1,8 +1,8 @@
# Guide de déploiement - 4NK IA Front Notarial
# Guide de déploiement - Lecoffre.io
## Vue d'ensemble
Ce guide couvre le déploiement de l'application 4NK IA Front Notarial dans différents environnements.
Ce guide couvre le déploiement de l'application 4NK IALecoffre.io dans différents environnements.
## Notes de version 0.1.2

View File

@ -1,8 +1,8 @@
# Guide de tests - 4NK IA Front Notarial
# Guide de tests - 4NK IA Lecoffre.io
## Vue d'ensemble
Ce guide couvre la stratégie de tests pour l'application 4NK IA Front Notarial, incluant les tests unitaires,
Ce guide couvre la stratégie de tests pour l'application 4NK IA Lecoffre.io, incluant les tests unitaires,
d'intégration et end-to-end.
## Stack de test
@ -87,7 +87,7 @@ describe('Layout', () => {
it('should render the application title', () => {
renderWithProviders(<Layout><div>Test content</div></Layout>)
expect(screen.getByText('4NK IA - Front Notarial')).toBeInTheDocument()
expect(screen.getByText('4NK IA - Lecoffre.io')).toBeInTheDocument()
})
it('should render navigation tabs', () => {

View File

@ -1,4 +1,4 @@
# Spécifications fonctionnelles du front notarial
# Spécifications fonctionnelles duLecoffre.io
on veut crer un front pour les notaires et leurs assistants afin de :
les notaires vont l'utiliser dans le cadre de leur processus métiers et des types d'actes.
@ -47,9 +47,9 @@ Retrait gonflement des argiles Opérations sur les retrait de gonflement des arg
Opérations sur le Détail des risques SSP
Sites et sols pollués (SSP) TIM
Opérations sur les Transmissions d'Informations au Maire (TIM)
TRI Opérations sur les Territoires à Risques importants d'Inondation (TRI)
TRI Opérations sur les Territoires <20> Risques importants d'Inondation (TRI)
TRI - Zonage réglementaire
Opérations sur les Territoires à Risques importants d'Inondation (TRI)
Opérations sur les Territoires <20> Risques importants d'Inondation (TRI)
Zonage Sismique
Opérations sur le risque sismique
Géoportail urba
@ -65,7 +65,7 @@ Vigilances DOW
JONES[https://www.dowjones.com/business-intelligence/risk/products/data-feeds-apis/](https://www.dowjones.com/business-intelligence/risk/products/data-feeds-apis/)
Infogreffe
[https://entreprise.api.gouv.fr/catalogue/infogreffe/rcs/extrait#parameters_details](https://entreprise.api.gouv.fr/catalogue/infogreffe/rcs/extrait#parameters_details)
RBE (Ã coupler avec infogreffe ci-dessus)
RBE (<EFBFBD> coupler avec infogreffe ci-dessus)
[https://www.data.gouv.fr/dataservices/api-registre-des-beneficiaires-effectifs-rbe/](https://www.data.gouv.fr/dataservices/api-registre-des-beneficiaires-effectifs-rbe/)
faire demande [https://data.inpi.fr/content/editorial/acces_BE](https://data.inpi.fr/content/editorial/acces_BE)
joindre le PDF suivant complété :
@ -134,9 +134,9 @@ Retrait gonflement des argiles Opérations sur les retrait de gonflement des arg
Opérations sur le Détail des risques SSP
Sites et sols pollués (SSP) TIM
Opérations sur les Transmissions d'Informations au Maire (TIM)
TRI Opérations sur les Territoires à Risques importants d'Inondation (TRI)
TRI Opérations sur les Territoires <20> Risques importants d'Inondation (TRI)
TRI - Zonage réglementaire
Opérations sur les Territoires à Risques importants d'Inondation (TRI)
Opérations sur les Territoires <20> Risques importants d'Inondation (TRI)
Zonage Sismique
Opérations sur le risque sismique
Géoportail urba
@ -152,11 +152,11 @@ Vigilances DOW
JONES[https://www.dowjones.com/business-intelligence/risk/products/data-feeds-apis/](https://www.dowjones.com/business-intelligence/risk/products/data-feeds-apis/)
Infogreffe
[https://entreprise.api.gouv.fr/catalogue/infogreffe/rcs/extrait#parameters_details](https://entreprise.api.gouv.fr/catalogue/infogreffe/rcs/extrait#parameters_details)
RBE (Ã coupler avec infogreffe ci-dessus)
RBE (<EFBFBD> coupler avec infogreffe ci-dessus)
[https://www.data.gouv.fr/dataservices/api-registre-des-beneficiaires-effectifs-rbe/](https://www.data.gouv.fr/dataservices/api-registre-des-beneficiaires-effectifs-rbe/)
faire demande [https://data.inpi.fr/content/editorial/acces_BE](https://data.inpi.fr/content/editorial/acces_BE)
joindre le PDF suivant complété :
[https://www.inpi.fr/sites/default/files/2025-01/Formulaire_demande_acces_BE.pdfÂ](https://www.inpi.fr/sites/default/files/2025-01/Formulaire_demande_acces_BE.pdfÂ)
[https://www.inpi.fr/sites/default/files/2025-01/Formulaire_demande_acces_BE.pdf<EFBFBD>](https://www.inpi.fr/sites/default/files/2025-01/Formulaire_demande_acces_BE.pdf<64>)
6) donner un score de vraissemblance sur le document 7) donner une avis de synthèse sur le document
on veut crer un front pour les notaires et leurs assistants afin de :
les notaires vont l'utiliser dans le cadre de leur processus métiers et des types d'actes.
@ -205,9 +205,9 @@ Retrait gonflement des argiles Opérations sur les retrait de gonflement des arg
Opérations sur le Détail des risques SSP
Sites et sols pollués (SSP) TIM
Opérations sur les Transmissions d'Informations au Maire (TIM)
TRI Opérations sur les Territoires à Risques importants d'Inondation (TRI)
TRI Opérations sur les Territoires <20> Risques importants d'Inondation (TRI)
TRI - Zonage réglementaire
Opérations sur les Territoires à Risques importants d'Inondation (TRI)
Opérations sur les Territoires <20> Risques importants d'Inondation (TRI)
Zonage Sismique
Opérations sur le risque sismique
Géoportail urba
@ -223,7 +223,7 @@ Vigilances DOW
JONES[https://www.dowjones.com/business-intelligence/risk/products/data-feeds-apis/](https://www.dowjones.com/business-intelligence/risk/products/data-feeds-apis/)
Infogreffe
[https://entreprise.api.gouv.fr/catalogue/infogreffe/rcs/extrait#parameters_details](https://entreprise.api.gouv.fr/catalogue/infogreffe/rcs/extrait#parameters_details)
RBE (Ã coupler avec infogreffe ci-dessus)
RBE (<EFBFBD> coupler avec infogreffe ci-dessus)
[https://www.data.gouv.fr/dataservices/api-registre-des-beneficiaires-effectifs-rbe/](https://www.data.gouv.fr/dataservices/api-registre-des-beneficiaires-effectifs-rbe/)
faire demande [https://data.inpi.fr/content/editorial/acces_BE](https://data.inpi.fr/content/editorial/acces_BE)
joindre le PDF suivant complété :

1604
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -50,7 +50,9 @@
"jsdom": "^26.1.0",
"markdownlint": "^0.38.0",
"markdownlint-cli": "^0.45.0",
"pdfjs-dist": "^4.8.69",
"prettier": "^3.6.2",
"tesseract.js": "^5.1.0",
"typescript": "~5.8.3",
"typescript-eslint": "^8.39.1",
"vite": "^7.1.2",

View File

@ -39,7 +39,7 @@ if [ ! -f ".env" ]; then
VITE_API_URL=http://localhost:18000
# Configuration pour le développement
VITE_APP_NAME=4NK IA Front Notarial
VITE_APP_NAME=4NK IALecoffre.io
VITE_APP_VERSION=0.1.0
# Configuration des services externes (optionnel)

View File

@ -60,20 +60,94 @@ export const FilePreview: React.FC<FilePreviewProps> = ({ document, onClose }) =
}
const isPDF = document.mimeType.includes('pdf') || document.name.toLowerCase().endsWith('.pdf')
const isImage =
document.mimeType.startsWith('image/') ||
['.png', '.jpg', '.jpeg', '.gif', '.webp'].some((ext) => document.name.toLowerCase().endsWith(ext))
if (!isPDF) {
if (!isPDF && isImage) {
return (
<Paper sx={{ p: 3, mt: 2 }}>
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
<Typography variant="h6">{document.name}</Typography>
<IconButton onClick={onClose} title="Fermer">
<Close />
</IconButton>
</Box>
<Alert severity="info">
Aperçu non disponible pour ce type de fichier ({document.functionalType || document.mimeType})
</Alert>
</Paper>
<Dialog open onClose={onClose} maxWidth="lg" fullWidth>
<DialogTitle>
<Box display="flex" justifyContent="space-between" alignItems="center">
<Typography variant="h6">{document.name}</Typography>
<IconButton onClick={onClose} title="Fermer">
<Close />
</IconButton>
</Box>
</DialogTitle>
<DialogContent dividers>
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
<Box />
<Box display="flex" alignItems="center" gap={1}>
<Button
variant="outlined"
size="small"
startIcon={<ZoomOut />}
onClick={() => setScale((prev) => Math.max(prev - 0.2, 0.2))}
>
Zoom -
</Button>
<Typography variant="body2">{Math.round(scale * 100)}%</Typography>
<Button
variant="outlined"
size="small"
startIcon={<ZoomIn />}
onClick={() => setScale((prev) => Math.min(prev + 0.2, 4))}
>
Zoom +
</Button>
</Box>
</Box>
<Box
sx={{
border: '1px solid',
borderColor: 'grey.300',
borderRadius: 1,
overflow: 'auto',
maxHeight: '70vh',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'grey.50',
}}
>
{document.previewUrl ? (
<img
src={document.previewUrl}
alt={document.name}
style={{
maxWidth: `${100 * scale}%`,
maxHeight: `${100 * scale}%`,
objectFit: 'contain',
}}
onLoad={() => setLoading(false)}
onError={() => {
setError('Erreur de chargement de l\'image')
setLoading(false)
}}
/>
) : (
<Box textAlign="center" p={4}>
<Typography variant="h6" gutterBottom>
Aperçu image
</Typography>
<Typography variant="body2" color="text.secondary">
Le fichier a é uploadé avec succès.
</Typography>
<Typography variant="body2" color="text.secondary">
Taille: {(document.size / 1024 / 1024).toFixed(2)} MB
</Typography>
</Box>
)}
</Box>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Fermer</Button>
<Button variant="contained" startIcon={<Download />} onClick={handleDownload} disabled={!document.previewUrl}>
Télécharger
</Button>
</DialogActions>
</Dialog>
)
}

View File

@ -21,7 +21,7 @@ export const Layout: React.FC<LayoutProps> = ({ children }) => {
sx={{ flexGrow: 1, cursor: 'pointer' }}
onClick={() => navigate('/')}
>
4NK IA - Front Notarial
4NK IA - Lecoffre.io
</Typography>
</Toolbar>
</AppBar>

View File

@ -1,7 +1,18 @@
import axios from 'axios'
import { openaiDocumentApi, openaiExternalApi } from './openai'
import type { Document, ExtractionResult, AnalysisResult, ContextResult, ConseilResult } from '../types'
const BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'
const USE_OPENAI = import.meta.env.VITE_USE_OPENAI === 'true'
// Debug non-invasif en dev pour vérifier la lecture du .env
if (import.meta.env.DEV) {
const maskedKey = (import.meta.env.VITE_OPENAI_API_KEY || '')
.toString()
.replace(/.(?=.{4})/g, '*')
// eslint-disable-next-line no-console
console.info('[ENV] VITE_API_URL=', BASE_URL, 'VITE_USE_AI=', USE_OPENAI, 'VITE_AI_API_KEY=', maskedKey)
}
export const apiClient = axios.create({
baseURL: BASE_URL,
@ -21,11 +32,10 @@ apiClient.interceptors.response.use(
export const documentApi = {
// Téléversement de document
upload: async (file: File): Promise<Document> => {
if (USE_OPENAI) return openaiDocumentApi.upload(file)
const formData = new FormData()
formData.append('file', file)
const { data } = await apiClient.post('/api/notary/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
const { data } = await apiClient.post('/api/notary/upload', formData)
// L'API retourne {message, document_id, status}
// On doit mapper vers le format Document attendu
@ -44,6 +54,11 @@ export const documentApi = {
// Extraction des données
extract: async (documentId: string): Promise<ExtractionResult> => {
if (USE_OPENAI) {
// En mode OpenAI, nous navons pas le fichier original côté service.
// Le texte a été approximé à lupload. On tente néanmoins lextraction textuelle côté OpenAI sans fichier.
return openaiDocumentApi.extract(documentId)
}
const { data } = await apiClient.get(`/api/notary/documents/${documentId}`)
// Mapper les données de l'API vers le format ExtractionResult
@ -98,29 +113,31 @@ export const documentApi = {
// Analyse du document
analyze: async (documentId: string): Promise<AnalysisResult> => {
if (USE_OPENAI) return openaiDocumentApi.analyze(documentId)
const { data } = await apiClient.get<AnalysisResult>(`/api/documents/${documentId}/analyze`)
return data
},
// Données contextuelles
getContext: async (documentId: string): Promise<ContextResult> => {
if (USE_OPENAI) return openaiDocumentApi.getContext(documentId)
const { data } = await apiClient.get<ContextResult>(`/api/documents/${documentId}/context`)
return data
},
// Conseil LLM
getConseil: async (documentId: string): Promise<ConseilResult> => {
if (USE_OPENAI) return openaiDocumentApi.getConseil(documentId)
const { data } = await apiClient.get<ConseilResult>(`/api/documents/${documentId}/conseil`)
return data
},
// Détection du type de document
detectType: async (file: File): Promise<{ type: string; confidence: number }> => {
if (USE_OPENAI) return openaiDocumentApi.detectType(file)
const formData = new FormData()
formData.append('file', file)
const { data } = await apiClient.post('/api/ocr/detect', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
const { data } = await apiClient.post('/api/ocr/detect', formData)
return data
},
}
@ -129,30 +146,35 @@ export const documentApi = {
export const externalApi = {
// Cadastre via backend
cadastre: async (address: string) => {
if (USE_OPENAI) return openaiExternalApi.cadastre(address)
const { data } = await apiClient.get('/api/context/cadastre', { params: { q: address } })
return data
},
// Géorisques via backend
georisques: async (coordinates: { lat: number; lng: number }) => {
if (USE_OPENAI) return openaiExternalApi.georisques(coordinates)
const { data } = await apiClient.get('/api/context/georisques', { params: coordinates })
return data
},
// Géofoncier via backend
geofoncier: async (address: string) => {
if (USE_OPENAI) return openaiExternalApi.geofoncier(address)
const { data } = await apiClient.get('/api/context/geofoncier', { params: { address } })
return data
},
// BODACC via backend
bodacc: async (companyName: string) => {
if (USE_OPENAI) return openaiExternalApi.bodacc(companyName)
const { data } = await apiClient.get('/api/context/bodacc', { params: { q: companyName } })
return data
},
// Infogreffe via backend
infogreffe: async (siren: string) => {
if (USE_OPENAI) return openaiExternalApi.infogreffe(siren)
const { data } = await apiClient.get('/api/context/infogreffe', { params: { siren } })
return data
},

141
src/services/fileExtract.ts Normal file
View File

@ -0,0 +1,141 @@
// Chargements dynamiques locaux (pdfjs-dist/tesseract.js)
let _pdfjsLib: any | null = null
async function getPdfJs() {
if (_pdfjsLib) return _pdfjsLib
const pdfjsLib: any = await import('pdfjs-dist')
try {
// Utilise un worker module réel pour éviter le fake worker
const workerUrl = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url)
// @ts-expect-error - API v4
pdfjsLib.GlobalWorkerOptions.workerPort = new Worker(workerUrl, { type: 'module' })
} catch {
// ignore si worker introuvable
}
_pdfjsLib = pdfjsLib
return _pdfjsLib
}
export async function extractTextFromFile(file: File): Promise<string> {
const mime = file.type || ''
if (mime.includes('pdf') || file.name.toLowerCase().endsWith('.pdf')) {
return extractFromPdf(file)
}
if (mime.startsWith('image/') || ['.png', '.jpg', '.jpeg'].some((ext) => file.name.toLowerCase().endsWith(ext))) {
return extractFromImage(file)
}
// Fallback: lecture texte brut
try {
return await file.text()
} catch {
return ''
}
}
async function extractFromPdf(file: File): Promise<string> {
const pdfjsLib = await getPdfJs().catch(() => null)
if (!pdfjsLib) return ''
const arrayBuffer = await file.arrayBuffer()
const pdf = await pdfjsLib.getDocument({ data: new Uint8Array(arrayBuffer) }).promise
const texts: string[] = []
const numPages = Math.min(pdf.numPages, 50)
for (let i = 1; i <= numPages; i += 1) {
const page = await pdf.getPage(i)
const content = await page.getTextContent()
const pageText = content.items.map((it: any) => (it.str ? it.str : '')).join(' ')
if (pageText.trim()) texts.push(pageText)
}
return texts.join('\n')
}
async function extractFromImage(file: File): Promise<string> {
const { createWorker } = await import('tesseract.js')
// Pré-redimensionne l'image si trop petite (largeur minimale 300px)
const imgBitmap = await createImageBitmap(file)
let source: Blob = file
// Normalisation pour CNI: contraste, gris, upscaling plus agressif
const minWidth = /recto|verso|cni|carte/i.test(file.name) ? 1200 : 300
if (imgBitmap.width < minWidth) {
const scale = minWidth / Math.max(1, imgBitmap.width)
const canvas = document.createElement('canvas')
canvas.width = Math.max(300, Math.floor(imgBitmap.width * scale))
canvas.height = Math.floor(imgBitmap.height * scale)
const ctx = canvas.getContext('2d')!
ctx.imageSmoothingEnabled = true
ctx.imageSmoothingQuality = 'high'
ctx.drawImage(imgBitmap, 0, 0, canvas.width, canvas.height)
// Conversion en niveaux de gris + amélioration du contraste
const imgData = ctx.getImageData(0, 0, canvas.width, canvas.height)
const data = imgData.data
for (let i = 0; i < data.length; i += 4) {
const r = data[i], g = data[i + 1], b = data[i + 2]
// luma
let y = 0.299 * r + 0.587 * g + 0.114 * b
// contraste simple
y = Math.max(0, Math.min(255, (y - 128) * 1.2 + 128))
data[i] = data[i + 1] = data[i + 2] = y
}
ctx.putImageData(imgData, 0, 0)
source = await new Promise<Blob>((resolve) => canvas.toBlob((b) => resolve(b || file))!)
}
const worker = await createWorker()
try {
// Configure le logger après création pour éviter DataCloneError
// eslint-disable-next-line no-console
worker.setLogger?.((m: any) => {
if (m?.progress != null) console.info('[OCR]', Math.round(m.progress * 100) + '%')
})
await worker.load()
await worker.loadLanguage('fra+eng')
await worker.initialize('fra+eng')
// Essaie plusieurs PSM et orientations (0/90/180/270) et garde le meilleur résultat
const rotations = [0, 90, 180, 270]
const psmModes = ['6', '7', '11'] // 6: block, 7: single line, 11: sparse text
let bestText = ''
let bestScore = -1
for (const psm of psmModes) {
await worker.setParameters({ tessedit_pageseg_mode: psm })
for (const deg of rotations) {
const rotatedBlob = await rotateBlob(source, deg)
const { data } = await worker.recognize(rotatedBlob)
const text = data.text || ''
const len = text.replace(/\s+/g, ' ').trim().length
const score = (data.confidence || 0) * Math.log(len + 1)
if (score > bestScore) {
bestScore = score
bestText = text
}
// Court-circuit si très bon
if (data.confidence >= 85 && len > 40) break
}
}
return bestText
} finally {
await worker.terminate()
}
}
async function rotateBlob(blob: Blob, deg: number): Promise<Blob> {
if (deg % 360 === 0) return blob
const bmp = await createImageBitmap(blob)
const rad = (deg * Math.PI) / 180
const sin = Math.abs(Math.sin(rad))
const cos = Math.abs(Math.cos(rad))
const w = bmp.width
const h = bmp.height
const newW = Math.floor(w * cos + h * sin)
const newH = Math.floor(w * sin + h * cos)
const canvas = document.createElement('canvas')
canvas.width = newW
canvas.height = newH
const ctx = canvas.getContext('2d')!
ctx.imageSmoothingEnabled = true
ctx.imageSmoothingQuality = 'high'
ctx.translate(newW / 2, newH / 2)
ctx.rotate(rad)
ctx.drawImage(bmp, -w / 2, -h / 2)
return await new Promise<Blob>((resolve) => canvas.toBlob((b) => resolve(b || blob))!)
}

209
src/services/openai.ts Normal file
View File

@ -0,0 +1,209 @@
/*
Mode OpenAI (fallback) pour 4NK IA Front
Utilise l'API OpenAI côté frontend uniquement à des fins de démonstration/dépannage quand le backend est indisponible.
*/
import type {
Document,
ExtractionResult,
AnalysisResult,
ContextResult,
ConseilResult,
} from '../types'
import { extractTextFromFile } from './fileExtract'
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_CHAT_MODEL = import.meta.env.VITE_OPENAI_MODEL || 'gpt-4o-mini'
async function callOpenAIChat(messages: Array<{ role: 'system' | 'user' | 'assistant'; content: string }>): Promise<string> {
if (!OPENAI_API_KEY) {
throw new Error('Clé API OpenAI manquante (VITE_AI_API_KEY)')
}
// Log minimal masqué
if (import.meta.env.DEV) {
// eslint-disable-next-line no-console
console.info('[LLM] Request chat.completions (mode AI distante activé)')
}
const response = await fetch(`${OPENAI_BASE_URL}/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${OPENAI_API_KEY}`,
},
body: JSON.stringify({
model: OPENAI_CHAT_MODEL,
messages,
temperature: 0.2,
}),
})
if (!response.ok) {
if (import.meta.env.DEV) {
// eslint-disable-next-line no-console
console.warn('[LLM] Response error', response.status)
}
const text = await response.text()
throw new Error(`OpenAI error ${response.status}: ${text}`)
}
const data = await response.json()
if (import.meta.env.DEV) {
// eslint-disable-next-line no-console
console.info('[LLM] Response received')
}
return data.choices?.[0]?.message?.content || ''
}
type ProgressHooks = { onOcrProgress?: (p: number) => void; onLlmProgress?: (p: number) => void }
export const openaiDocumentApi = {
upload: async (file: File): Promise<Document> => {
const fileUrl = URL.createObjectURL(file)
return {
id: `openai-upload-${Date.now()}`,
name: file.name,
mimeType: file.type || 'application/octet-stream',
functionalType: undefined,
size: file.size,
uploadDate: new Date(),
status: 'completed',
previewUrl: fileUrl,
}
},
extract: async (documentId: string, file?: File, hooks?: ProgressHooks): Promise<ExtractionResult> => {
let localText = ''
if (file) {
try {
hooks?.onOcrProgress?.(0)
localText = await extractTextFromFile(file)
hooks?.onOcrProgress?.(1)
} catch {
localText = ''
}
}
hooks?.onLlmProgress?.(0)
const content = await callOpenAIChat([
{
role: 'system',
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.',
},
{
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":["..."]}`,
},
])
// Essaye d'analyser le JSON, sinon fallback heuristique
try {
const parsed = JSON.parse(content)
hooks?.onLlmProgress?.(1)
return {
documentId,
text: localText || '',
language: parsed.language || 'fr',
documentType: parsed.documentType || 'Document',
identities: parsed.identities || [],
addresses: parsed.addresses || [],
properties: parsed.properties || [],
contracts: parsed.contracts || [],
signatures: parsed.signatures || [],
confidence: Math.round((typeof parsed.confidence === 'number' ? parsed.confidence : 0.7) * 100) / 100,
confidenceReasons: parsed.confidenceReasons || [],
}
} catch {
hooks?.onLlmProgress?.(1)
const lowered = (localText || '').toLowerCase()
let documentType = 'Document'
const reasons: string[] = []
if (/carte\s+nationale\s+d'identité|cni|national id/.test(lowered)) {
documentType = 'CNI'
reasons.push('Mots-clés CNI détectés')
} else if (/facture|invoice|amount|tva|siren/.test(lowered)) {
documentType = 'Facture'
reasons.push('Mots-clés facture détectés')
} else if (/attestation|certificat/.test(lowered)) {
documentType = 'Attestation'
reasons.push('Mots-clés attestation détectés')
}
return {
documentId,
text: localText || 'Contenu résumé non disponible.',
language: 'fr',
documentType,
identities: [],
addresses: [],
properties: [],
contracts: [],
signatures: [],
confidence: 0.7,
confidenceReasons: reasons,
}
}
},
analyze: async (documentId: string): Promise<AnalysisResult> => {
const result = await callOpenAIChat([
{ role: 'system', content: 'Tu fournis une analyse brève et des risques potentiels.' },
{ role: 'user', content: `Analyse le document ${documentId} et fournis un résumé des risques.` },
])
const isCNI = /cni|carte\s+nationale\s+d'identité/i.test(result || '')
const number = (result || '').match(/[A-Z0-9]{12,}/)?.[0] || ''
const formatValid = /^[A-Z0-9]{12,}$/.test(number)
const checksumValid = pseudoChecksum(number)
const numberValid = formatValid && checksumValid
return {
documentId,
documentType: isCNI ? 'CNI' : 'Document',
isCNI,
verificationResult: isCNI
? { numberValid, formatValid, checksumValid }
: undefined,
credibilityScore: isCNI ? (numberValid ? 0.8 : 0.6) : 0.6,
summary: result || 'Analyse indisponible.',
recommendations: [],
confidenceReasons: isCNI
? [
formatValid ? 'Format du numéro plausible' : 'Format du numéro invalide',
checksumValid ? 'Checksum plausible' : 'Checksum invalide',
]
: ['Analyse préliminaire via modèle'],
}
},
getContext: async (documentId: string): Promise<ContextResult> => {
const ctx = await callOpenAIChat([
{ role: 'system', content: 'Tu proposes des pistes de contexte externes utiles.' },
{ role: 'user', content: `Indique le contexte potentiel utile pour le document ${documentId}.` },
])
return { documentId, lastUpdated: new Date(), georisquesData: {}, cadastreData: {} }
},
getConseil: async (documentId: string): Promise<ConseilResult> => {
const conseil = await callOpenAIChat([
{ role: 'system', content: 'Tu fournis des conseils opérationnels courts et concrets.' },
{ role: 'user', content: `Donne 3 conseils actionnables pour le document ${documentId}.` },
])
return { documentId, analysis: conseil || '', recommendations: conseil ? [conseil] : [], risks: [], nextSteps: [], generatedAt: new Date() }
},
detectType: async (_file: File): Promise<{ type: string; confidence: number }> => {
return { type: 'Document', confidence: 0.6 }
},
}
export const openaiExternalApi = {
cadastre: async (_address: string) => ({ note: 'Mode OpenAI: contexte non connecté' }),
georisques: async (_coordinates: { lat: number; lng: number }) => ({ note: 'Mode OpenAI: contexte non connecté' }),
geofoncier: async (_address: string) => ({ note: 'Mode OpenAI: contexte non connecté' }),
bodacc: async (_companyName: string) => ({ note: 'Mode OpenAI: contexte non connecté' }),
infogreffe: async (_siren: string) => ({ note: 'Mode OpenAI: contexte non connecté' }),
}
function pseudoChecksum(input: string): boolean {
if (!input) return false
// checksum simple: somme des codes char modulo 10 doit être pair
const sum = Array.from(input).reduce((acc, ch) => acc + ch.charCodeAt(0), 0)
return sum % 10 % 2 === 0
}

View File

@ -2,27 +2,32 @@ import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'
import type { PayloadAction } from '@reduxjs/toolkit'
import type { Document, ExtractionResult, AnalysisResult, ContextResult, ConseilResult } from '../types'
import { documentApi } from '../services/api'
import { openaiDocumentApi } from '../services/openai'
interface DocumentState {
documents: Document[]
currentDocument: Document | null
extractionResult: ExtractionResult | null
extractionById: Record<string, ExtractionResult>
analysisResult: AnalysisResult | null
contextResult: ContextResult | null
conseilResult: ConseilResult | null
loading: boolean
error: string | null
progressById: Record<string, { ocr: number; llm: number }>
}
const initialState: DocumentState = {
documents: [],
currentDocument: null,
extractionResult: null,
extractionById: {},
analysisResult: null,
contextResult: null,
conseilResult: null,
loading: false,
error: null,
progressById: {},
}
export const uploadDocument = createAsyncThunk(
@ -34,7 +39,31 @@ export const uploadDocument = createAsyncThunk(
export const extractDocument = createAsyncThunk(
'document/extract',
async (documentId: string) => {
async (documentId: string, thunkAPI) => {
const useOpenAI = import.meta.env.VITE_USE_OPENAI === 'true'
if (useOpenAI) {
const state = thunkAPI.getState() as { document: DocumentState }
const doc = state.document.documents.find((d) => d.id === documentId)
if (doc?.previewUrl) {
try {
const res = await fetch(doc.previewUrl)
const blob = await res.blob()
const file = new File([blob], doc.name, { type: doc.mimeType })
return await openaiDocumentApi.extract(documentId, file, {
onOcrProgress: (p: number) => (thunkAPI.dispatch as any)(setOcrProgress({ id: documentId, progress: p })),
onLlmProgress: (p: number) => (thunkAPI.dispatch as any)(setLlmProgress({ id: documentId, progress: p })),
})
} catch {
// fallback sans fichier
return await openaiDocumentApi.extract(documentId, undefined, {
onLlmProgress: (p: number) => (thunkAPI.dispatch as any)(setLlmProgress({ id: documentId, progress: p })),
})
}
}
return await openaiDocumentApi.extract(documentId, undefined, {
onLlmProgress: (p: number) => (thunkAPI.dispatch as any)(setLlmProgress({ id: documentId, progress: p })),
})
}
return await documentApi.extract(documentId)
}
)
@ -69,10 +98,45 @@ const documentSlice = createSlice({
},
clearResults: (state) => {
state.extractionResult = null
// Ne pas effacer extractionById pour conserver les résultats par document
state.analysisResult = null
state.contextResult = null
state.conseilResult = null
},
addDocuments: (state, action: PayloadAction<Document[]>) => {
const incoming = action.payload
// Évite les doublons par (name,size) pour les bootstraps répétés en dev
const seenKey = new Set(state.documents.map((d) => `${d.name}::${d.size}`))
const merged = [...state.documents]
incoming.forEach((d) => {
const key = `${d.name}::${d.size}`
if (!seenKey.has(key)) {
seenKey.add(key)
merged.push(d)
}
})
state.documents = merged
},
removeDocument: (state, action: PayloadAction<string>) => {
const idToRemove = action.payload
state.documents = state.documents.filter((d) => d.id !== idToRemove)
if (state.currentDocument && state.currentDocument.id === idToRemove) {
state.currentDocument = null
state.extractionResult = null
state.analysisResult = null
state.contextResult = null
state.conseilResult = null
}
delete state.progressById[idToRemove]
},
setOcrProgress: (state, action: PayloadAction<{ id: string; progress: number }>) => {
const { id, progress } = action.payload
state.progressById[id] = { ocr: Math.max(0, Math.min(100, Math.round(progress * 100))), llm: state.progressById[id]?.llm || 0 }
},
setLlmProgress: (state, action: PayloadAction<{ id: string; progress: number }>) => {
const { id, progress } = action.payload
state.progressById[id] = { ocr: state.progressById[id]?.ocr || 0, llm: Math.max(0, Math.min(100, Math.round(progress * 100))) }
},
},
extraReducers: (builder) => {
builder
@ -89,8 +153,18 @@ const documentSlice = createSlice({
state.loading = false
state.error = action.error.message || 'Erreur lors du téléversement'
})
.addCase(extractDocument.pending, (state) => {
state.loading = true
state.error = null
})
.addCase(extractDocument.fulfilled, (state, action) => {
state.loading = false
state.extractionResult = action.payload
state.extractionById[action.payload.documentId] = action.payload
})
.addCase(extractDocument.rejected, (state, action) => {
state.loading = false
state.error = action.error.message || 'Erreur lors de l\'extraction'
})
.addCase(analyzeDocument.fulfilled, (state, action) => {
state.analysisResult = action.payload
@ -104,5 +178,5 @@ const documentSlice = createSlice({
},
})
export const { setCurrentDocument, clearResults } = documentSlice.actions
export const { setCurrentDocument, clearResults, addDocuments, removeDocument, setOcrProgress, setLlmProgress } = documentSlice.actions
export const documentReducer = documentSlice.reducer

View File

@ -60,6 +60,7 @@ export interface ExtractionResult {
contracts: Contract[]
signatures: string[]
confidence: number
confidenceReasons?: string[]
}
export interface AnalysisResult {
@ -75,6 +76,7 @@ export interface AnalysisResult {
credibilityScore: number
summary: string
recommendations: string[]
confidenceReasons?: string[]
}
export interface ContextResult {

View File

@ -11,6 +11,8 @@ import {
ListItemText,
Alert,
CircularProgress,
Button,
Tooltip,
} from '@mui/material'
import {
Person,
@ -21,20 +23,29 @@ import {
Verified,
} from '@mui/icons-material'
import { useAppDispatch, useAppSelector } from '../store'
import { extractDocument } from '../store/documentSlice'
import { extractDocument, setCurrentDocument, clearResults } from '../store/documentSlice'
import { Layout } from '../components/Layout'
export default function ExtractionView() {
const dispatch = useAppDispatch()
const { currentDocument, extractionResult, loading } = useAppSelector(
(state) => state.document
)
const { currentDocument, extractionResult, extractionById, loading, documents, progressById } = useAppSelector((state) => state.document)
useEffect(() => {
if (currentDocument && !extractionResult) {
dispatch(extractDocument(currentDocument.id))
}
}, [currentDocument, extractionResult, dispatch])
if (!currentDocument) return
const cached = extractionById[currentDocument.id]
if (!cached) dispatch(extractDocument(currentDocument.id))
}, [currentDocument, extractionById, dispatch])
const currentIndex = currentDocument ? Math.max(0, documents.findIndex(d => d.id === currentDocument.id)) : -1
const hasPrev = currentIndex > 0
const hasNext = currentIndex >= 0 && currentIndex < documents.length - 1
const gotoDoc = (index: number) => {
const doc = documents[index]
if (!doc) return
dispatch(setCurrentDocument(doc))
// Laisser l'effet décider si une nouvelle extraction est nécessaire
}
if (!currentDocument) {
return (
@ -49,9 +60,9 @@ export default function ExtractionView() {
if (loading) {
return (
<Layout>
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 4 }}>
<CircularProgress />
<Typography sx={{ ml: 2 }}>Extraction en cours...</Typography>
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 4, alignItems: 'center', gap: 2 }}>
<CircularProgress size={24} />
<Typography>Extraction en cours...</Typography>
</Box>
</Layout>
)
@ -73,6 +84,26 @@ export default function ExtractionView() {
Extraction des données
</Typography>
{/* Navigation entre documents */}
{documents.length > 0 && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<Button size="small" variant="outlined" disabled={!hasPrev} onClick={() => gotoDoc(currentIndex - 1)}>
Précédent
</Button>
<Typography variant="body2">
{currentIndex + 1} / {documents.length}
</Typography>
<Button size="small" variant="outlined" disabled={!hasNext} onClick={() => gotoDoc(currentIndex + 1)}>
Suivant
</Button>
{currentDocument && (
<Typography variant="body2" sx={{ ml: 2 }} color="text.secondary">
Document: {currentDocument.name}
</Typography>
)}
</Box>
)}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
{/* Informations générales */}
<Paper sx={{ p: 2 }}>
@ -82,23 +113,94 @@ export default function ExtractionView() {
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
<Chip
icon={<Language />}
label={`Langue: ${extractionResult.language}`}
label={`Langue: ${ (extractionById[currentDocument!.id] || extractionResult)!.language }`}
color="primary"
variant="outlined"
/>
<Chip
icon={<Description />}
label={`Type: ${extractionResult.documentType}`}
label={`Type: ${ (extractionById[currentDocument!.id] || extractionResult)!.documentType }`}
color="secondary"
variant="outlined"
/>
<Chip
icon={<Verified />}
label={`Confiance: ${(extractionResult.confidence * 100).toFixed(1)}%`}
color={extractionResult.confidence > 0.8 ? 'success' : 'warning'}
variant="outlined"
/>
<Tooltip
arrow
title={
(() => { const r = (extractionById[currentDocument!.id] || extractionResult)!; return (r.confidenceReasons && r.confidenceReasons.length > 0)
? r.confidenceReasons.join(' • ')
: `Évaluation automatique basée sur le contenu et le type (${r.documentType}).` })()
}
>
<Chip
icon={<Verified />}
label={`Confiance: ${(() => { const r = (extractionById[currentDocument!.id] || extractionResult)!; return Math.round(r.confidence * 100)})()}%`}
color={(() => { const r = (extractionById[currentDocument!.id] || extractionResult)!; return r.confidence > 0.8 ? 'success' : 'warning' })()}
variant="outlined"
/>
</Tooltip>
</Box>
{/* Progression OCR/LLM si en cours pour ce document */}
{currentDocument && progressById[currentDocument.id] && loading && (
<Box display="flex" alignItems="center" gap={2} sx={{ mt: 1 }}>
<Box sx={{ width: 140 }}>
<Typography variant="caption">OCR</Typography>
<Box sx={{ height: 6, bgcolor: 'grey.300', borderRadius: 1 }}>
<Box sx={{ width: `${progressById[currentDocument.id].ocr}%`, height: '100%', bgcolor: 'primary.main', borderRadius: 1 }} />
</Box>
</Box>
<Box sx={{ width: 140 }}>
<Typography variant="caption">LLM</Typography>
<Box sx={{ height: 6, bgcolor: 'grey.300', borderRadius: 1 }}>
<Box sx={{ width: `${progressById[currentDocument.id].llm}%`, height: '100%', bgcolor: 'info.main', borderRadius: 1 }} />
</Box>
</Box>
</Box>
)}
{/* Aperçu rapide du document */}
{currentDocument && (
<Box sx={{ mt: 2 }}>
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
Aperçu du document
</Typography>
{(() => {
const isPDF = currentDocument.mimeType.includes('pdf') || currentDocument.name.toLowerCase().endsWith('.pdf')
const isImage =
currentDocument.mimeType.startsWith('image/') ||
['.png', '.jpg', '.jpeg', '.gif', '.webp'].some((ext) => currentDocument.name.toLowerCase().endsWith(ext))
if (isImage && currentDocument.previewUrl) {
return (
<Box sx={{
border: '1px solid', borderColor: 'grey.300', borderRadius: 1, p: 1,
display: 'inline-block', maxWidth: '100%'
}}>
<img
src={currentDocument.previewUrl}
alt={currentDocument.name}
style={{ maxWidth: 320, maxHeight: 240, objectFit: 'contain' }}
/>
</Box>
)
}
if (isPDF && currentDocument.previewUrl) {
return (
<Box sx={{
border: '1px solid', borderColor: 'grey.300', borderRadius: 1,
overflow: 'hidden', width: 360, height: 240
}}>
<iframe
src={`${currentDocument.previewUrl}#toolbar=0&navpanes=0&scrollbar=0&page=1&view=FitH`}
width="100%"
height="100%"
style={{ border: 'none' }}
title={`Aperçu rapide de ${currentDocument.name}`}
/>
</Box>
)
}
return null
})()}
</Box>
)}
</Paper>
<Box sx={{ display: 'flex', gap: 3, flexWrap: 'wrap' }}>
@ -256,11 +358,16 @@ export default function ExtractionView() {
Signatures détectées ({extractionResult.signatures?.length || 0})
</Typography>
<List dense>
{(extractionResult.signatures || []).map((signature, index) => (
<ListItem key={index}>
<ListItemText primary={signature} />
</ListItem>
))}
{(extractionResult.signatures || []).map((signature: any, index: number) => {
const label = typeof signature === 'string'
? signature
: signature?.name || signature?.title || signature?.date || JSON.stringify(signature)
return (
<ListItem key={index}>
<ListItemText primary={label} />
</ListItem>
)
})}
</List>
</CardContent>
</Card>

View File

@ -1,4 +1,4 @@
import { useCallback, useState } from 'react'
import { useCallback, useState, useEffect } from 'react'
import { useDropzone } from 'react-dropzone'
import { Box, Typography, Paper, CircularProgress, Alert, Button, Chip, Grid } from '@mui/material'
import {
@ -9,23 +9,32 @@ import {
Visibility,
} from '@mui/icons-material'
import { useAppDispatch, useAppSelector } from '../store'
import { uploadDocument } from '../store/documentSlice'
import { uploadDocument, removeDocument, addDocuments, setCurrentDocument } from '../store/documentSlice'
import { Layout } from '../components/Layout'
import { FilePreview } from '../components/FilePreview'
import type { Document } from '../types'
export default function UploadView() {
const dispatch = useAppDispatch()
const { documents, error } = useAppSelector((state) => state.document)
const { documents, error, progressById, extractionById } = useAppSelector((state) => state.document)
const [previewDocument, setPreviewDocument] = useState<Document | null>(null)
const [bootstrapped, setBootstrapped] = useState(false)
const onDrop = useCallback(
(acceptedFiles: File[]) => {
acceptedFiles.forEach((file) => {
dispatch(uploadDocument(file))
.unwrap()
.then(async (doc) => {
if (!extractionById[doc.id]) {
const { extractDocument } = await import('../store/documentSlice')
dispatch(extractDocument(doc.id))
}
})
.catch(() => {})
})
},
[dispatch]
[dispatch, extractionById]
)
const { getRootProps, getInputProps, isDragActive } = useDropzone({
@ -66,6 +75,49 @@ export default function UploadView() {
}
}
// Bootstrap: charger les fichiers de test par défaut (en dev uniquement)
useEffect(() => {
if (bootstrapped || !import.meta.env.DEV) return
const testFiles = ['attestation.png', 'id_recto.jpg', 'id_verso.jpg', 'facture_4NK_08-2025_04.pdf']
const load = async () => {
const created: Document[] = []
for (const name of testFiles) {
try {
const resp = await fetch(`/test-files/${name}`)
if (!resp.ok) continue
const blob = await resp.blob()
const file = new File([blob], name, { type: blob.type })
// simule upload local
const previewUrl = URL.createObjectURL(file)
created.push({
id: `boot-${name}-${Date.now()}`,
name,
mimeType: blob.type || 'application/octet-stream',
functionalType: undefined,
size: blob.size,
uploadDate: new Date(),
status: 'completed',
previewUrl,
})
} catch {
// ignore
}
}
if (created.length) {
dispatch(addDocuments(created))
// Définir le document courant
dispatch(setCurrentDocument(created[0]))
// Déclencher l'extraction pour afficher les barres de progression dans la liste
const { extractDocument } = await import('../store/documentSlice')
created.forEach((d) => {
if (!extractionById[d.id]) dispatch(extractDocument(d.id))
})
setBootstrapped(true)
}
}
load()
}, [dispatch, bootstrapped])
return (
<Layout>
<Typography variant="h4" gutterBottom>
@ -131,10 +183,17 @@ export default function UploadView() {
>
Aperçu
</Button>
<Button
size="small"
color="error"
onClick={() => dispatch(removeDocument(doc.id))}
>
Supprimer
</Button>
</Box>
</Box>
<Box display="flex" gap={1} flexWrap="wrap">
<Box display="flex" gap={1} flexWrap="wrap" alignItems="center">
<Chip
label={doc.functionalType || doc.mimeType}
size="small"
@ -150,6 +209,22 @@ export default function UploadView() {
size="small"
variant="outlined"
/>
{progressById[doc.id] && (
<Box display="flex" alignItems="center" gap={1} sx={{ ml: 1, minWidth: 160 }}>
<Box sx={{ width: 70 }}>
<Typography variant="caption">OCR</Typography>
<Box sx={{ height: 6, bgcolor: 'grey.300', borderRadius: 1 }}>
<Box sx={{ width: `${progressById[doc.id].ocr}%`, height: '100%', bgcolor: 'primary.main', borderRadius: 1 }} />
</Box>
</Box>
<Box sx={{ width: 70 }}>
<Typography variant="caption">LLM</Typography>
<Box sx={{ height: 6, bgcolor: 'grey.300', borderRadius: 1 }}>
<Box sx={{ width: `${progressById[doc.id].llm}%`, height: '100%', bgcolor: 'info.main', borderRadius: 1 }} />
</Box>
</Box>
</Box>
)}
</Box>
</Paper>
</Grid>