design
This commit is contained in:
parent
ae8e647cf0
commit
0f0a26ed46
6
.env
6
.env
@ -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
|
||||
@ -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)
|
||||
|
||||
@ -3,7 +3,7 @@ name: Build & Push Docker Image
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- '**'
|
||||
- 'release'
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -22,3 +22,4 @@ dist-ssr
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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={() => navigate('/')}</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" > </Toolbar></span>
|
||||
<span class="cstat-no" title="statement not covered" > </AppBar></span>
|
||||
@ -190,4 +190,3 @@ interface LayoutProps {
|
||||
<script src="../../block-navigation.js"></script>
|
||||
</body>
|
||||
</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
|
||||
|
||||
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 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
|
||||
|
||||
```typescript
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
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 :
|
||||
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
1604
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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",
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 é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' }}
|
||||
onClick={() => navigate('/')}
|
||||
>
|
||||
4NK IA - Front Notarial
|
||||
4NK IA - Lecoffre.io
|
||||
</Typography>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
|
||||
@ -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 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}`)
|
||||
|
||||
// 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
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 { 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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user