design
This commit is contained in:
parent
ae8e647cf0
commit
0f0a26ed46
6
.env
6
.env
@ -2,7 +2,7 @@
|
|||||||
VITE_API_URL=http://localhost:18000
|
VITE_API_URL=http://localhost:18000
|
||||||
|
|
||||||
# Configuration pour le développement
|
# 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
|
VITE_APP_VERSION=0.1.0
|
||||||
|
|
||||||
# Configuration des services externes (optionnel)
|
# 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_GEOFONCIER_API_URL=https://api2.geofoncier.fr
|
||||||
VITE_BODACC_API_URL=https://bodacc-datadila.opendatasoft.com/api
|
VITE_BODACC_API_URL=https://bodacc-datadila.opendatasoft.com/api
|
||||||
VITE_INFOGREFFE_API_URL=https://entreprise.api.gouv.fr
|
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
|
||||||
@ -2,7 +2,7 @@
|
|||||||
VITE_API_URL=http://localhost:8000
|
VITE_API_URL=http://localhost:8000
|
||||||
|
|
||||||
# Configuration pour le développement
|
# 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
|
VITE_APP_VERSION=0.1.0
|
||||||
|
|
||||||
# Configuration des services externes (optionnel)
|
# Configuration des services externes (optionnel)
|
||||||
|
|||||||
@ -3,7 +3,7 @@ name: Build & Push Docker Image
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- '**'
|
- 'release'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
docker:
|
docker:
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -22,3 +22,4 @@ dist-ssr
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
test-files/
|
||||||
@ -1,4 +1,4 @@
|
|||||||
# 4NK IA Front Notarial
|
# Lecoffre.io
|
||||||
|
|
||||||
Application front-end pour l'analyse intelligente de documents notariaux avec IA.
|
Application front-end pour l'analyse intelligente de documents notariaux avec IA.
|
||||||
|
|
||||||
@ -75,6 +75,10 @@ Créer un fichier `.env` :
|
|||||||
|
|
||||||
```env
|
```env
|
||||||
VITE_API_URL=http://localhost:8000
|
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_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
|
||||||
|
|||||||
@ -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" > sx={{ flexGrow: 1, cursor: 'pointer' }}</span>
|
||||||
<span class="cstat-no" title="statement not covered" > onClick={() => navigate('/')}</span>
|
<span class="cstat-no" title="statement not covered" > onClick={() => navigate('/')}</span>
|
||||||
<span class="cstat-no" title="statement not covered" > ></span>
|
<span class="cstat-no" title="statement not covered" > ></span>
|
||||||
4NK IA - Front Notarial
|
4NK IA - Lecoffre.io
|
||||||
<span class="cstat-no" title="statement not covered" > </Typography></span>
|
<span class="cstat-no" title="statement not covered" > </Typography></span>
|
||||||
<span class="cstat-no" title="statement not covered" > </Toolbar></span>
|
<span class="cstat-no" title="statement not covered" > </Toolbar></span>
|
||||||
<span class="cstat-no" title="statement not covered" > </AppBar></span>
|
<span class="cstat-no" title="statement not covered" > </AppBar></span>
|
||||||
@ -190,4 +190,3 @@ interface LayoutProps {
|
|||||||
<script src="../../block-navigation.js"></script>
|
<script src="../../block-navigation.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
18
docs/API.md
18
docs/API.md
@ -1,8 +1,8 @@
|
|||||||
# Documentation API - 4NK IA Front Notarial
|
# Documentation API - 4NK IA Lecoffre.io
|
||||||
|
|
||||||
## Vue d'ensemble
|
## 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).
|
fonctionnalités (upload, extraction, analyse, contexte, conseil).
|
||||||
|
|
||||||
## API Backend Principal
|
## API Backend Principal
|
||||||
@ -100,8 +100,22 @@ uniquement. Aucun appel direct côté front.
|
|||||||
|
|
||||||
```env
|
```env
|
||||||
VITE_API_URL=http://localhost:8000
|
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 n’est 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 n’est pas disponible.
|
||||||
|
|
||||||
### Configuration Axios
|
### Configuration Axios
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
# Architecture de l'application 4NK IA Front Notarial
|
# Architecture de l'application 4NK IA Lecoffre.io
|
||||||
|
|
||||||
## Vue d'ensemble
|
## 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.
|
conçue pour l'analyse intelligente de documents notariaux.
|
||||||
|
|
||||||
## Stack technique
|
## Stack technique
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
# Guide de déploiement - 4NK IA Front Notarial
|
# Guide de déploiement - Lecoffre.io
|
||||||
|
|
||||||
## Vue d'ensemble
|
## 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
|
## Notes de version 0.1.2
|
||||||
|
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
# Guide de tests - 4NK IA Front Notarial
|
# Guide de tests - 4NK IA Lecoffre.io
|
||||||
|
|
||||||
## Vue d'ensemble
|
## 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.
|
d'intégration et end-to-end.
|
||||||
|
|
||||||
## Stack de test
|
## Stack de test
|
||||||
@ -87,7 +87,7 @@ describe('Layout', () => {
|
|||||||
it('should render the application title', () => {
|
it('should render the application title', () => {
|
||||||
renderWithProviders(<Layout><div>Test content</div></Layout>)
|
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', () => {
|
it('should render navigation tabs', () => {
|
||||||
|
|||||||
22
docs/TODO.md
22
docs/TODO.md
@ -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 :
|
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.
|
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
|
Opérations sur le Détail des risques SSP
|
||||||
Sites et sols pollués (SSP) TIM
|
Sites et sols pollués (SSP) TIM
|
||||||
Opérations sur les Transmissions d'Informations au Maire (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
|
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
|
Zonage Sismique
|
||||||
Opérations sur le risque sismique
|
Opérations sur le risque sismique
|
||||||
Géoportail urba
|
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/)
|
JONES[https://www.dowjones.com/business-intelligence/risk/products/data-feeds-apis/](https://www.dowjones.com/business-intelligence/risk/products/data-feeds-apis/)
|
||||||
Infogreffe
|
Infogreffe
|
||||||
[https://entreprise.api.gouv.fr/catalogue/infogreffe/rcs/extrait#parameters_details](https://entreprise.api.gouv.fr/catalogue/infogreffe/rcs/extrait#parameters_details)
|
[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/)
|
[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)
|
faire demande [https://data.inpi.fr/content/editorial/acces_BE](https://data.inpi.fr/content/editorial/acces_BE)
|
||||||
joindre le PDF suivant complété :
|
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
|
Opérations sur le Détail des risques SSP
|
||||||
Sites et sols pollués (SSP) TIM
|
Sites et sols pollués (SSP) TIM
|
||||||
Opérations sur les Transmissions d'Informations au Maire (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
|
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
|
Zonage Sismique
|
||||||
Opérations sur le risque sismique
|
Opérations sur le risque sismique
|
||||||
Géoportail urba
|
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/)
|
JONES[https://www.dowjones.com/business-intelligence/risk/products/data-feeds-apis/](https://www.dowjones.com/business-intelligence/risk/products/data-feeds-apis/)
|
||||||
Infogreffe
|
Infogreffe
|
||||||
[https://entreprise.api.gouv.fr/catalogue/infogreffe/rcs/extrait#parameters_details](https://entreprise.api.gouv.fr/catalogue/infogreffe/rcs/extrait#parameters_details)
|
[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/)
|
[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)
|
faire demande [https://data.inpi.fr/content/editorial/acces_BE](https://data.inpi.fr/content/editorial/acces_BE)
|
||||||
joindre le PDF suivant complété :
|
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
|
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 :
|
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.
|
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
|
Opérations sur le Détail des risques SSP
|
||||||
Sites et sols pollués (SSP) TIM
|
Sites et sols pollués (SSP) TIM
|
||||||
Opérations sur les Transmissions d'Informations au Maire (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
|
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
|
Zonage Sismique
|
||||||
Opérations sur le risque sismique
|
Opérations sur le risque sismique
|
||||||
Géoportail urba
|
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/)
|
JONES[https://www.dowjones.com/business-intelligence/risk/products/data-feeds-apis/](https://www.dowjones.com/business-intelligence/risk/products/data-feeds-apis/)
|
||||||
Infogreffe
|
Infogreffe
|
||||||
[https://entreprise.api.gouv.fr/catalogue/infogreffe/rcs/extrait#parameters_details](https://entreprise.api.gouv.fr/catalogue/infogreffe/rcs/extrait#parameters_details)
|
[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/)
|
[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)
|
faire demande [https://data.inpi.fr/content/editorial/acces_BE](https://data.inpi.fr/content/editorial/acces_BE)
|
||||||
joindre le PDF suivant complété :
|
joindre le PDF suivant complété :
|
||||||
|
|||||||
1604
package-lock.json
generated
1604
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -50,7 +50,9 @@
|
|||||||
"jsdom": "^26.1.0",
|
"jsdom": "^26.1.0",
|
||||||
"markdownlint": "^0.38.0",
|
"markdownlint": "^0.38.0",
|
||||||
"markdownlint-cli": "^0.45.0",
|
"markdownlint-cli": "^0.45.0",
|
||||||
|
"pdfjs-dist": "^4.8.69",
|
||||||
"prettier": "^3.6.2",
|
"prettier": "^3.6.2",
|
||||||
|
"tesseract.js": "^5.1.0",
|
||||||
"typescript": "~5.8.3",
|
"typescript": "~5.8.3",
|
||||||
"typescript-eslint": "^8.39.1",
|
"typescript-eslint": "^8.39.1",
|
||||||
"vite": "^7.1.2",
|
"vite": "^7.1.2",
|
||||||
|
|||||||
@ -39,7 +39,7 @@ if [ ! -f ".env" ]; then
|
|||||||
VITE_API_URL=http://localhost:18000
|
VITE_API_URL=http://localhost:18000
|
||||||
|
|
||||||
# Configuration pour le développement
|
# Configuration pour le développement
|
||||||
VITE_APP_NAME=4NK IA Front Notarial
|
VITE_APP_NAME=4NK IALecoffre.io
|
||||||
VITE_APP_VERSION=0.1.0
|
VITE_APP_VERSION=0.1.0
|
||||||
|
|
||||||
# Configuration des services externes (optionnel)
|
# Configuration des services externes (optionnel)
|
||||||
|
|||||||
@ -60,20 +60,94 @@ export const FilePreview: React.FC<FilePreviewProps> = ({ document, onClose }) =
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isPDF = document.mimeType.includes('pdf') || document.name.toLowerCase().endsWith('.pdf')
|
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 (
|
return (
|
||||||
<Paper sx={{ p: 3, mt: 2 }}>
|
<Dialog open onClose={onClose} maxWidth="lg" fullWidth>
|
||||||
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
|
<DialogTitle>
|
||||||
|
<Box display="flex" justifyContent="space-between" alignItems="center">
|
||||||
<Typography variant="h6">{document.name}</Typography>
|
<Typography variant="h6">{document.name}</Typography>
|
||||||
<IconButton onClick={onClose} title="Fermer">
|
<IconButton onClick={onClose} title="Fermer">
|
||||||
<Close />
|
<Close />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Box>
|
</Box>
|
||||||
<Alert severity="info">
|
</DialogTitle>
|
||||||
Aperçu non disponible pour ce type de fichier ({document.functionalType || document.mimeType})
|
<DialogContent dividers>
|
||||||
</Alert>
|
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
|
||||||
</Paper>
|
<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 été 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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -21,7 +21,7 @@ export const Layout: React.FC<LayoutProps> = ({ children }) => {
|
|||||||
sx={{ flexGrow: 1, cursor: 'pointer' }}
|
sx={{ flexGrow: 1, cursor: 'pointer' }}
|
||||||
onClick={() => navigate('/')}
|
onClick={() => navigate('/')}
|
||||||
>
|
>
|
||||||
4NK IA - Front Notarial
|
4NK IA - Lecoffre.io
|
||||||
</Typography>
|
</Typography>
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
</AppBar>
|
</AppBar>
|
||||||
|
|||||||
@ -1,7 +1,18 @@
|
|||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
import { openaiDocumentApi, openaiExternalApi } from './openai'
|
||||||
import type { Document, ExtractionResult, AnalysisResult, ContextResult, ConseilResult } from '../types'
|
import type { Document, ExtractionResult, AnalysisResult, ContextResult, ConseilResult } from '../types'
|
||||||
|
|
||||||
const BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'
|
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({
|
export const apiClient = axios.create({
|
||||||
baseURL: BASE_URL,
|
baseURL: BASE_URL,
|
||||||
@ -21,11 +32,10 @@ apiClient.interceptors.response.use(
|
|||||||
export const documentApi = {
|
export const documentApi = {
|
||||||
// Téléversement de document
|
// Téléversement de document
|
||||||
upload: async (file: File): Promise<Document> => {
|
upload: async (file: File): Promise<Document> => {
|
||||||
|
if (USE_OPENAI) return openaiDocumentApi.upload(file)
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.append('file', file)
|
formData.append('file', file)
|
||||||
const { data } = await apiClient.post('/api/notary/upload', formData, {
|
const { data } = await apiClient.post('/api/notary/upload', formData)
|
||||||
headers: { 'Content-Type': 'multipart/form-data' },
|
|
||||||
})
|
|
||||||
|
|
||||||
// L'API retourne {message, document_id, status}
|
// L'API retourne {message, document_id, status}
|
||||||
// On doit mapper vers le format Document attendu
|
// On doit mapper vers le format Document attendu
|
||||||
@ -44,6 +54,11 @@ export const documentApi = {
|
|||||||
|
|
||||||
// Extraction des données
|
// Extraction des données
|
||||||
extract: async (documentId: string): Promise<ExtractionResult> => {
|
extract: async (documentId: string): Promise<ExtractionResult> => {
|
||||||
|
if (USE_OPENAI) {
|
||||||
|
// En mode OpenAI, nous n’avons pas le fichier original côté service.
|
||||||
|
// Le texte a été approximé à l’upload. On tente néanmoins l’extraction textuelle côté OpenAI sans fichier.
|
||||||
|
return openaiDocumentApi.extract(documentId)
|
||||||
|
}
|
||||||
const { data } = await apiClient.get(`/api/notary/documents/${documentId}`)
|
const { data } = await apiClient.get(`/api/notary/documents/${documentId}`)
|
||||||
|
|
||||||
// Mapper les données de l'API vers le format ExtractionResult
|
// Mapper les données de l'API vers le format ExtractionResult
|
||||||
@ -98,29 +113,31 @@ export const documentApi = {
|
|||||||
|
|
||||||
// Analyse du document
|
// Analyse du document
|
||||||
analyze: async (documentId: string): Promise<AnalysisResult> => {
|
analyze: async (documentId: string): Promise<AnalysisResult> => {
|
||||||
|
if (USE_OPENAI) return openaiDocumentApi.analyze(documentId)
|
||||||
const { data } = await apiClient.get<AnalysisResult>(`/api/documents/${documentId}/analyze`)
|
const { data } = await apiClient.get<AnalysisResult>(`/api/documents/${documentId}/analyze`)
|
||||||
return data
|
return data
|
||||||
},
|
},
|
||||||
|
|
||||||
// Données contextuelles
|
// Données contextuelles
|
||||||
getContext: async (documentId: string): Promise<ContextResult> => {
|
getContext: async (documentId: string): Promise<ContextResult> => {
|
||||||
|
if (USE_OPENAI) return openaiDocumentApi.getContext(documentId)
|
||||||
const { data } = await apiClient.get<ContextResult>(`/api/documents/${documentId}/context`)
|
const { data } = await apiClient.get<ContextResult>(`/api/documents/${documentId}/context`)
|
||||||
return data
|
return data
|
||||||
},
|
},
|
||||||
|
|
||||||
// Conseil LLM
|
// Conseil LLM
|
||||||
getConseil: async (documentId: string): Promise<ConseilResult> => {
|
getConseil: async (documentId: string): Promise<ConseilResult> => {
|
||||||
|
if (USE_OPENAI) return openaiDocumentApi.getConseil(documentId)
|
||||||
const { data } = await apiClient.get<ConseilResult>(`/api/documents/${documentId}/conseil`)
|
const { data } = await apiClient.get<ConseilResult>(`/api/documents/${documentId}/conseil`)
|
||||||
return data
|
return data
|
||||||
},
|
},
|
||||||
|
|
||||||
// Détection du type de document
|
// Détection du type de document
|
||||||
detectType: async (file: File): Promise<{ type: string; confidence: number }> => {
|
detectType: async (file: File): Promise<{ type: string; confidence: number }> => {
|
||||||
|
if (USE_OPENAI) return openaiDocumentApi.detectType(file)
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.append('file', file)
|
formData.append('file', file)
|
||||||
const { data } = await apiClient.post('/api/ocr/detect', formData, {
|
const { data } = await apiClient.post('/api/ocr/detect', formData)
|
||||||
headers: { 'Content-Type': 'multipart/form-data' },
|
|
||||||
})
|
|
||||||
return data
|
return data
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@ -129,30 +146,35 @@ export const documentApi = {
|
|||||||
export const externalApi = {
|
export const externalApi = {
|
||||||
// Cadastre via backend
|
// Cadastre via backend
|
||||||
cadastre: async (address: string) => {
|
cadastre: async (address: string) => {
|
||||||
|
if (USE_OPENAI) return openaiExternalApi.cadastre(address)
|
||||||
const { data } = await apiClient.get('/api/context/cadastre', { params: { q: address } })
|
const { data } = await apiClient.get('/api/context/cadastre', { params: { q: address } })
|
||||||
return data
|
return data
|
||||||
},
|
},
|
||||||
|
|
||||||
// Géorisques via backend
|
// Géorisques via backend
|
||||||
georisques: async (coordinates: { lat: number; lng: number }) => {
|
georisques: async (coordinates: { lat: number; lng: number }) => {
|
||||||
|
if (USE_OPENAI) return openaiExternalApi.georisques(coordinates)
|
||||||
const { data } = await apiClient.get('/api/context/georisques', { params: coordinates })
|
const { data } = await apiClient.get('/api/context/georisques', { params: coordinates })
|
||||||
return data
|
return data
|
||||||
},
|
},
|
||||||
|
|
||||||
// Géofoncier via backend
|
// Géofoncier via backend
|
||||||
geofoncier: async (address: string) => {
|
geofoncier: async (address: string) => {
|
||||||
|
if (USE_OPENAI) return openaiExternalApi.geofoncier(address)
|
||||||
const { data } = await apiClient.get('/api/context/geofoncier', { params: { address } })
|
const { data } = await apiClient.get('/api/context/geofoncier', { params: { address } })
|
||||||
return data
|
return data
|
||||||
},
|
},
|
||||||
|
|
||||||
// BODACC via backend
|
// BODACC via backend
|
||||||
bodacc: async (companyName: string) => {
|
bodacc: async (companyName: string) => {
|
||||||
|
if (USE_OPENAI) return openaiExternalApi.bodacc(companyName)
|
||||||
const { data } = await apiClient.get('/api/context/bodacc', { params: { q: companyName } })
|
const { data } = await apiClient.get('/api/context/bodacc', { params: { q: companyName } })
|
||||||
return data
|
return data
|
||||||
},
|
},
|
||||||
|
|
||||||
// Infogreffe via backend
|
// Infogreffe via backend
|
||||||
infogreffe: async (siren: string) => {
|
infogreffe: async (siren: string) => {
|
||||||
|
if (USE_OPENAI) return openaiExternalApi.infogreffe(siren)
|
||||||
const { data } = await apiClient.get('/api/context/infogreffe', { params: { siren } })
|
const { data } = await apiClient.get('/api/context/infogreffe', { params: { siren } })
|
||||||
return data
|
return data
|
||||||
},
|
},
|
||||||
|
|||||||
141
src/services/fileExtract.ts
Normal file
141
src/services/fileExtract.ts
Normal 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
209
src/services/openai.ts
Normal 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
|
||||||
|
}
|
||||||
@ -2,27 +2,32 @@ import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'
|
|||||||
import type { PayloadAction } from '@reduxjs/toolkit'
|
import type { PayloadAction } from '@reduxjs/toolkit'
|
||||||
import type { Document, ExtractionResult, AnalysisResult, ContextResult, ConseilResult } from '../types'
|
import type { Document, ExtractionResult, AnalysisResult, ContextResult, ConseilResult } from '../types'
|
||||||
import { documentApi } from '../services/api'
|
import { documentApi } from '../services/api'
|
||||||
|
import { openaiDocumentApi } from '../services/openai'
|
||||||
|
|
||||||
interface DocumentState {
|
interface DocumentState {
|
||||||
documents: Document[]
|
documents: Document[]
|
||||||
currentDocument: Document | null
|
currentDocument: Document | null
|
||||||
extractionResult: ExtractionResult | null
|
extractionResult: ExtractionResult | null
|
||||||
|
extractionById: Record<string, ExtractionResult>
|
||||||
analysisResult: AnalysisResult | null
|
analysisResult: AnalysisResult | null
|
||||||
contextResult: ContextResult | null
|
contextResult: ContextResult | null
|
||||||
conseilResult: ConseilResult | null
|
conseilResult: ConseilResult | null
|
||||||
loading: boolean
|
loading: boolean
|
||||||
error: string | null
|
error: string | null
|
||||||
|
progressById: Record<string, { ocr: number; llm: number }>
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialState: DocumentState = {
|
const initialState: DocumentState = {
|
||||||
documents: [],
|
documents: [],
|
||||||
currentDocument: null,
|
currentDocument: null,
|
||||||
extractionResult: null,
|
extractionResult: null,
|
||||||
|
extractionById: {},
|
||||||
analysisResult: null,
|
analysisResult: null,
|
||||||
contextResult: null,
|
contextResult: null,
|
||||||
conseilResult: null,
|
conseilResult: null,
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
|
progressById: {},
|
||||||
}
|
}
|
||||||
|
|
||||||
export const uploadDocument = createAsyncThunk(
|
export const uploadDocument = createAsyncThunk(
|
||||||
@ -34,7 +39,31 @@ export const uploadDocument = createAsyncThunk(
|
|||||||
|
|
||||||
export const extractDocument = createAsyncThunk(
|
export const extractDocument = createAsyncThunk(
|
||||||
'document/extract',
|
'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)
|
return await documentApi.extract(documentId)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@ -69,10 +98,45 @@ const documentSlice = createSlice({
|
|||||||
},
|
},
|
||||||
clearResults: (state) => {
|
clearResults: (state) => {
|
||||||
state.extractionResult = null
|
state.extractionResult = null
|
||||||
|
// Ne pas effacer extractionById pour conserver les résultats par document
|
||||||
state.analysisResult = null
|
state.analysisResult = null
|
||||||
state.contextResult = null
|
state.contextResult = null
|
||||||
state.conseilResult = 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) => {
|
extraReducers: (builder) => {
|
||||||
builder
|
builder
|
||||||
@ -89,8 +153,18 @@ const documentSlice = createSlice({
|
|||||||
state.loading = false
|
state.loading = false
|
||||||
state.error = action.error.message || 'Erreur lors du téléversement'
|
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) => {
|
.addCase(extractDocument.fulfilled, (state, action) => {
|
||||||
|
state.loading = false
|
||||||
state.extractionResult = action.payload
|
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) => {
|
.addCase(analyzeDocument.fulfilled, (state, action) => {
|
||||||
state.analysisResult = action.payload
|
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
|
export const documentReducer = documentSlice.reducer
|
||||||
|
|||||||
@ -60,6 +60,7 @@ export interface ExtractionResult {
|
|||||||
contracts: Contract[]
|
contracts: Contract[]
|
||||||
signatures: string[]
|
signatures: string[]
|
||||||
confidence: number
|
confidence: number
|
||||||
|
confidenceReasons?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AnalysisResult {
|
export interface AnalysisResult {
|
||||||
@ -75,6 +76,7 @@ export interface AnalysisResult {
|
|||||||
credibilityScore: number
|
credibilityScore: number
|
||||||
summary: string
|
summary: string
|
||||||
recommendations: string[]
|
recommendations: string[]
|
||||||
|
confidenceReasons?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ContextResult {
|
export interface ContextResult {
|
||||||
|
|||||||
@ -11,6 +11,8 @@ import {
|
|||||||
ListItemText,
|
ListItemText,
|
||||||
Alert,
|
Alert,
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
|
Button,
|
||||||
|
Tooltip,
|
||||||
} from '@mui/material'
|
} from '@mui/material'
|
||||||
import {
|
import {
|
||||||
Person,
|
Person,
|
||||||
@ -21,20 +23,29 @@ import {
|
|||||||
Verified,
|
Verified,
|
||||||
} from '@mui/icons-material'
|
} from '@mui/icons-material'
|
||||||
import { useAppDispatch, useAppSelector } from '../store'
|
import { useAppDispatch, useAppSelector } from '../store'
|
||||||
import { extractDocument } from '../store/documentSlice'
|
import { extractDocument, setCurrentDocument, clearResults } from '../store/documentSlice'
|
||||||
import { Layout } from '../components/Layout'
|
import { Layout } from '../components/Layout'
|
||||||
|
|
||||||
export default function ExtractionView() {
|
export default function ExtractionView() {
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch()
|
||||||
const { currentDocument, extractionResult, loading } = useAppSelector(
|
const { currentDocument, extractionResult, extractionById, loading, documents, progressById } = useAppSelector((state) => state.document)
|
||||||
(state) => state.document
|
|
||||||
)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentDocument && !extractionResult) {
|
if (!currentDocument) return
|
||||||
dispatch(extractDocument(currentDocument.id))
|
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
|
||||||
}
|
}
|
||||||
}, [currentDocument, extractionResult, dispatch])
|
|
||||||
|
|
||||||
if (!currentDocument) {
|
if (!currentDocument) {
|
||||||
return (
|
return (
|
||||||
@ -49,9 +60,9 @@ export default function ExtractionView() {
|
|||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 4 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 4, alignItems: 'center', gap: 2 }}>
|
||||||
<CircularProgress />
|
<CircularProgress size={24} />
|
||||||
<Typography sx={{ ml: 2 }}>Extraction en cours...</Typography>
|
<Typography>Extraction en cours...</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Layout>
|
</Layout>
|
||||||
)
|
)
|
||||||
@ -73,6 +84,26 @@ export default function ExtractionView() {
|
|||||||
Extraction des données
|
Extraction des données
|
||||||
</Typography>
|
</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 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||||
{/* Informations générales */}
|
{/* Informations générales */}
|
||||||
<Paper sx={{ p: 2 }}>
|
<Paper sx={{ p: 2 }}>
|
||||||
@ -82,23 +113,94 @@ 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: ${extractionResult.language}`}
|
label={`Langue: ${ (extractionById[currentDocument!.id] || extractionResult)!.language }`}
|
||||||
color="primary"
|
color="primary"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
/>
|
/>
|
||||||
<Chip
|
<Chip
|
||||||
icon={<Description />}
|
icon={<Description />}
|
||||||
label={`Type: ${extractionResult.documentType}`}
|
label={`Type: ${ (extractionById[currentDocument!.id] || extractionResult)!.documentType }`}
|
||||||
color="secondary"
|
color="secondary"
|
||||||
variant="outlined"
|
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
|
<Chip
|
||||||
icon={<Verified />}
|
icon={<Verified />}
|
||||||
label={`Confiance: ${(extractionResult.confidence * 100).toFixed(1)}%`}
|
label={`Confiance: ${(() => { const r = (extractionById[currentDocument!.id] || extractionResult)!; return Math.round(r.confidence * 100)})()}%`}
|
||||||
color={extractionResult.confidence > 0.8 ? 'success' : 'warning'}
|
color={(() => { const r = (extractionById[currentDocument!.id] || extractionResult)!; return r.confidence > 0.8 ? 'success' : 'warning' })()}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
/>
|
/>
|
||||||
|
</Tooltip>
|
||||||
</Box>
|
</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>
|
</Paper>
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', gap: 3, flexWrap: 'wrap' }}>
|
<Box sx={{ display: 'flex', gap: 3, flexWrap: 'wrap' }}>
|
||||||
@ -256,11 +358,16 @@ export default function ExtractionView() {
|
|||||||
Signatures détectées ({extractionResult.signatures?.length || 0})
|
Signatures détectées ({extractionResult.signatures?.length || 0})
|
||||||
</Typography>
|
</Typography>
|
||||||
<List dense>
|
<List dense>
|
||||||
{(extractionResult.signatures || []).map((signature, index) => (
|
{(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}>
|
<ListItem key={index}>
|
||||||
<ListItemText primary={signature} />
|
<ListItemText primary={label} />
|
||||||
</ListItem>
|
</ListItem>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</List>
|
</List>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useState } from 'react'
|
import { useCallback, useState, useEffect } from 'react'
|
||||||
import { useDropzone } from 'react-dropzone'
|
import { useDropzone } from 'react-dropzone'
|
||||||
import { Box, Typography, Paper, CircularProgress, Alert, Button, Chip, Grid } from '@mui/material'
|
import { Box, Typography, Paper, CircularProgress, Alert, Button, Chip, Grid } from '@mui/material'
|
||||||
import {
|
import {
|
||||||
@ -9,23 +9,32 @@ import {
|
|||||||
Visibility,
|
Visibility,
|
||||||
} from '@mui/icons-material'
|
} from '@mui/icons-material'
|
||||||
import { useAppDispatch, useAppSelector } from '../store'
|
import { useAppDispatch, useAppSelector } from '../store'
|
||||||
import { uploadDocument } from '../store/documentSlice'
|
import { uploadDocument, removeDocument, addDocuments, setCurrentDocument } from '../store/documentSlice'
|
||||||
import { Layout } from '../components/Layout'
|
import { Layout } from '../components/Layout'
|
||||||
import { FilePreview } from '../components/FilePreview'
|
import { FilePreview } from '../components/FilePreview'
|
||||||
import type { Document } from '../types'
|
import type { Document } from '../types'
|
||||||
|
|
||||||
export default function UploadView() {
|
export default function UploadView() {
|
||||||
const dispatch = useAppDispatch()
|
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 [previewDocument, setPreviewDocument] = useState<Document | null>(null)
|
||||||
|
const [bootstrapped, setBootstrapped] = useState(false)
|
||||||
|
|
||||||
const onDrop = useCallback(
|
const onDrop = useCallback(
|
||||||
(acceptedFiles: File[]) => {
|
(acceptedFiles: File[]) => {
|
||||||
acceptedFiles.forEach((file) => {
|
acceptedFiles.forEach((file) => {
|
||||||
dispatch(uploadDocument(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({
|
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 (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
<Typography variant="h4" gutterBottom>
|
<Typography variant="h4" gutterBottom>
|
||||||
@ -131,10 +183,17 @@ export default function UploadView() {
|
|||||||
>
|
>
|
||||||
Aperçu
|
Aperçu
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
onClick={() => dispatch(removeDocument(doc.id))}
|
||||||
|
>
|
||||||
|
Supprimer
|
||||||
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box display="flex" gap={1} flexWrap="wrap">
|
<Box display="flex" gap={1} flexWrap="wrap" alignItems="center">
|
||||||
<Chip
|
<Chip
|
||||||
label={doc.functionalType || doc.mimeType}
|
label={doc.functionalType || doc.mimeType}
|
||||||
size="small"
|
size="small"
|
||||||
@ -150,6 +209,22 @@ export default function UploadView() {
|
|||||||
size="small"
|
size="small"
|
||||||
variant="outlined"
|
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>
|
</Box>
|
||||||
</Paper>
|
</Paper>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user