This commit is contained in:
Nicolas Cantu 2025-09-15 16:17:46 +02:00
parent 0f0a26ed46
commit 5b2a5782be
10 changed files with 318 additions and 44 deletions

2
.env
View File

@ -4,6 +4,8 @@ VITE_API_URL=http://localhost:18000
# Configuration pour le développement
VITE_APP_NAME=4NK IA Lecoffre.io
VITE_APP_VERSION=0.1.0
VITE_USE_RULE_NER=true
VITE_LLM_CLASSIFY_ONLY=true
# Configuration des services externes (optionnel)
VITE_CADASTRE_API_URL=https://apicarto.ign.fr/api/cadastre

View File

@ -79,6 +79,7 @@ VITE_USE_OPENAI=false
VITE_OPENAI_API_KEY=
VITE_OPENAI_BASE_URL=https://api.openai.com/v1
VITE_OPENAI_MODEL=gpt-4o-mini
VITE_USE_RULE_NER=true
VITE_CADASTRE_API_URL=https://api.cadastre.gouv.fr
VITE_GEORISQUES_API_URL=https://www.georisques.gouv.fr/api
VITE_GEOFONCIER_API_URL=https://api.geofoncier.fr

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
<title>4NK IA - Lecoffre.io</title>
</head>
<body>
<div id="root"></div>

View File

@ -1,7 +1,9 @@
import React from 'react'
import { AppBar, Toolbar, Typography, Container, Box } from '@mui/material'
import React, { useEffect } from 'react'
import { AppBar, Toolbar, Typography, Container, Box, LinearProgress } from '@mui/material'
import { useNavigate, useLocation } from 'react-router-dom'
import { NavigationTabs } from './NavigationTabs'
import { useAppDispatch, useAppSelector } from '../store'
import { extractDocument, analyzeDocument, getContextData, getConseil } from '../store/documentSlice'
interface LayoutProps {
children: React.ReactNode
@ -10,6 +12,24 @@ interface LayoutProps {
export const Layout: React.FC<LayoutProps> = ({ children }) => {
const navigate = useNavigate()
const location = useLocation()
const dispatch = useAppDispatch()
const { documents, extractionById, loading, currentDocument, contextResult, conseilResult, analysisResult } = useAppSelector((s) => s.document)
// Au chargement/nav: lancer OCR+classification pour tous les documents sans résultat
useEffect(() => {
documents.forEach((doc) => {
if (!extractionById[doc.id]) dispatch(extractDocument(doc.id))
})
}, [documents, extractionById, dispatch])
// Déclencher contexte et conseil globaux une fois qu'un document courant existe
useEffect(() => {
if (currentDocument) {
if (!analysisResult) dispatch(analyzeDocument(currentDocument.id))
if (!contextResult) dispatch(getContextData(currentDocument.id))
if (!conseilResult) dispatch(getConseil(currentDocument.id))
}
}, [currentDocument, analysisResult, contextResult, conseilResult, dispatch])
return (
<Box sx={{ flexGrow: 1 }}>
@ -28,6 +48,12 @@ export const Layout: React.FC<LayoutProps> = ({ children }) => {
<NavigationTabs currentPath={location.pathname} />
{loading && (
<Box sx={{ px: 2, pt: 1 }}>
<LinearProgress />
</Box>
)}
<Container maxWidth="xl" sx={{ mt: 3, mb: 3 }}>
{children}
</Container>

View File

@ -18,10 +18,20 @@ async function getPdfJs() {
export async function extractTextFromFile(file: File): Promise<string> {
const mime = file.type || ''
if (mime.includes('pdf') || file.name.toLowerCase().endsWith('.pdf')) {
return extractFromPdf(file)
const pdfText = await extractFromPdf(file)
if (import.meta.env.DEV) {
// eslint-disable-next-line no-console
console.info('[OCR][PDF]', file.name, 'len=', pdfText.length, 'peek=', pdfText.slice(0, 200))
}
return pdfText
}
if (mime.startsWith('image/') || ['.png', '.jpg', '.jpeg'].some((ext) => file.name.toLowerCase().endsWith(ext))) {
return extractFromImage(file)
const imgText = await extractFromImage(file)
if (import.meta.env.DEV) {
// eslint-disable-next-line no-console
console.info('[OCR][IMG]', file.name, 'len=', imgText.length, 'peek=', imgText.slice(0, 200))
}
return imgText
}
// Fallback: lecture texte brut
try {
@ -40,8 +50,23 @@ async function extractFromPdf(file: File): Promise<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(' ')
const content = await page.getTextContent().catch(() => null)
let pageText = ''
if (content) {
pageText = content.items.map((it: any) => (it.str ? it.str : '')).join(' ')
}
// Fallback OCR si pas de texte exploitable
if (!pageText || pageText.replace(/\s+/g, '').length < 30) {
const viewport = page.getViewport({ scale: 2 })
const canvas = document.createElement('canvas')
canvas.width = viewport.width
canvas.height = viewport.height
const ctx = canvas.getContext('2d') as any
await page.render({ canvasContext: ctx, viewport }).promise
const blob: Blob = await new Promise((resolve) => canvas.toBlob((b) => resolve(b as Blob), 'image/png'))
const ocrText = await extractFromImage(new File([blob], `${file.name}-p${i}.png`, { type: 'image/png' }))
pageText = ocrText
}
if (pageText.trim()) texts.push(pageText)
}
return texts.join('\n')

View File

@ -10,6 +10,7 @@ import type {
ConseilResult,
} from '../types'
import { extractTextFromFile } from './fileExtract'
import { runRuleNER } from './ruleNer'
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'
@ -83,28 +84,105 @@ export const openaiDocumentApi = {
localText = ''
}
}
// Flags de mode
const useRuleNer = import.meta.env.VITE_USE_RULE_NER === 'true'
const classifyOnly = import.meta.env.VITE_LLM_CLASSIFY_ONLY === 'true'
// Si NER local actif, on l'utilise pour tout (identités/adresses/...) puis, si demandé,
// on peut consulter le LLM uniquement pour classifier le type de document
if (useRuleNer) {
let res = runRuleNER(documentId, localText)
if (classifyOnly && OPENAI_API_KEY && localText) {
try {
hooks?.onLlmProgress?.(0)
const cls = await callOpenAIChat([
{ role: 'system', content: 'Tu es un classifieur. Retourne uniquement un JSON strict.' },
{ role: 'user', content: `Classifie ce texte en une des catégories suivantes: [CNI, Facture, Attestation, Document]. Réponds strictement sous la forme {"documentType":"..."}.\nTexte:\n${localText.slice(0, 8000)}` },
])
const parsed = JSON.parse(cls)
if (parsed && typeof parsed.documentType === 'string') {
res = { ...res, documentType: parsed.documentType }
res.confidenceReasons = [...(res.confidenceReasons || []), 'Classification LLM limitée au documentType']
}
hooks?.onLlmProgress?.(1)
} catch {
// ignore échec de classification
hooks?.onLlmProgress?.(1)
}
}
return res
}
hooks?.onLlmProgress?.(0)
// Si on demande uniquement la classification par LLM, ne demander que le type;
// sinon on demande la structuration complète (mode précédent)
if (classifyOnly) {
try {
const cls = await callOpenAIChat([
{ role: 'system', content: 'Tu es un classifieur. Retourne uniquement un JSON strict.' },
{ role: 'user', content: `Classifie ce texte en une des catégories suivantes: [CNI, Facture, Attestation, Document]. Réponds strictement sous la forme {"documentType":"..."}.\nTexte:\n${localText.slice(0, 8000)}` },
])
const parsed = JSON.parse(cls)
hooks?.onLlmProgress?.(1)
return {
documentId,
text: localText || '',
language: 'fr',
documentType: (parsed && parsed.documentType) || 'Document',
identities: [],
addresses: [],
properties: [],
contracts: [],
signatures: [],
confidence: 0.6,
confidenceReasons: ['Classification LLM sans contexte, pas d\'extraction d\'identités'],
}
} catch {
hooks?.onLlmProgress?.(1)
return {
documentId,
text: localText || '',
language: 'fr',
documentType: 'Document',
identities: [],
addresses: [],
properties: [],
contracts: [],
signatures: [],
confidence: 0.6,
confidenceReasons: ['Classification LLM échouée, valeur par défaut'],
}
}
}
const content = await callOpenAIChat([
{
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.',
'Tu extrais uniquement les informations présentes dans le texte OCR. Interdiction d\'inventer. Interdiction d\'utiliser le nom du fichier comme identité. Réponds en JSON strict, sans texte autour.',
},
{
role: 'user',
content: `Document ID: ${documentId}. Texte: ${localText.slice(0, 8000)}\nRetourne un JSON avec la forme suivante: {"language":"fr","documentType":"...","identities":[{"id":"id-1","type":"person","firstName":"...","lastName":"...","confidence":0.9}],"addresses":[{"street":"...","city":"...","postalCode":"...","country":"..."}],"properties":[{"id":"prop-1","type":"apartment","address":{"street":"...","city":"...","postalCode":"...","country":"..."},"surface":75}],"contracts":[{"id":"contract-1","type":"sale","parties":[],"amount":0,"date":"YYYY-MM-DD","clauses":["..."]}],"signatures":[],"confidence":0.7,"confidenceReasons":["..."]}`,
content: `Document ID: ${documentId}. Texte OCR (tronqué): ${localText.slice(0, 8000)}\nRègles: 1) ne pas inventer, 2) si incertitude, laisser vide, 3) ne JAMAIS utiliser le nom du fichier comme identité. Schéma JSON: {"language":"fr","documentType":"...","identities":[{"id":"id-1","type":"person","firstName":"...","lastName":"...","confidence":0.9}],"addresses":[{"street":"...","city":"...","postalCode":"...","country":"..."}],"properties":[{"id":"prop-1","type":"apartment","address":{"street":"...","city":"...","postalCode":"...","country":"..."},"surface":75}],"contracts":[{"id":"contract-1","type":"sale","parties":[],"amount":0,"date":"YYYY-MM-DD","clauses":["..."]}],"signatures":[],"confidence":0.7,"confidenceReasons":["sources présentes dans le texte"]}`,
},
])
// Essaye d'analyser le JSON, sinon fallback heuristique
try {
const parsed = JSON.parse(content)
hooks?.onLlmProgress?.(1)
// Post-traitement: filtrage des identités qui ressemblent au nom de fichier
const docBase = (file?.name || '').toLowerCase().replace(/\.[a-z0-9]+$/, '')
const safeIdentities = (parsed.identities || []).filter((it: any) => {
const full = `${it.firstName || ''} ${it.lastName || ''}`.trim().toLowerCase()
return full && !docBase || (full && !docBase.includes(full) && !full.includes(docBase))
})
return {
documentId,
text: localText || '',
language: parsed.language || 'fr',
documentType: parsed.documentType || 'Document',
identities: parsed.identities || [],
identities: safeIdentities,
addresses: parsed.addresses || [],
properties: parsed.properties || [],
contracts: parsed.contracts || [],

140
src/services/ruleNer.ts Normal file
View File

@ -0,0 +1,140 @@
import type { ExtractionResult, Identity, Address, Property, Contract } from '../types'
function toTitleCase(input: string): string {
return input
.toLowerCase()
.split(/\s+/)
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(' ')
}
function extractMRZ(text: string): { firstName?: string; lastName?: string } | null {
// Cherche MRZ (deux lignes, < comme séparateur). Stricte A-Z0-9<
const lines = text.split(/\n|\r/).map((l) => l.trim().toUpperCase())
for (let i = 0; i < lines.length - 1; i += 1) {
const a = lines[i].replace(/[^A-Z0-9<]/g, '')
const b = lines[i + 1].replace(/[^A-Z0-9<]/g, '')
if (a.includes('<<') || b.includes('<<')) {
const target = a.length >= b.length ? a : b
const parts = target.split('<<')
if (parts.length >= 2) {
const rawLast = parts[0].replace(/<+/g, ' ').trim()
const rawFirst = parts[1].replace(/<+/g, ' ').trim()
if (rawLast && rawFirst) return { firstName: toTitleCase(rawFirst), lastName: rawLast.replace(/\s+/g, ' ') }
}
}
}
return null
}
function extractDates(text: string): string[] {
const results = new Set<string>()
const patterns = [
/(\b\d{2}[\/\-]\d{2}[\/\-]\d{4}\b)/g, // JJ/MM/AAAA ou JJ-MM-AAAA
/(\b\d{4}[\/\-]\d{2}[\/\-]\d{2}\b)/g, // AAAA/MM/JJ
]
for (const re of patterns) {
for (const m of text.matchAll(re)) results.add(m[1])
}
return Array.from(results)
}
function extractCniNumbers(text: string): string[] {
const results = new Set<string>()
const re = /\b[A-Z0-9]{12,15}\b/g
for (const m of text.toUpperCase().matchAll(re)) results.add(m[0])
return Array.from(results)
}
function extractAddresses(text: string): Address[] {
const items: Address[] = []
const typeVoie = '(rue|avenue|av\.?|bd\.?|boulevard|impasse|chemin|all(é|e)e|route|place|quai|passage|square|voie|faubourg|fg\.?|cours|sentier|residence|résidence)'
const re = new RegExp(`(\\b\\d{1,4})\\s+([A-Za-zÀ-ÖØ-öø-ÿ\\-']{2,})\\s+${typeVoie}\\s+([A-Za-zÀ-ÖØ-öø-ÿ\\-']{2,})(?:\\s+|,)+(\\b\\d{5}\\b)\\s+([A-Za-zÀ-ÖØ-öø-ÿ\\-']{2,})`, 'gi')
for (const m of text.matchAll(re)) {
const street = `${m[1]} ${toTitleCase(`${m[2]} ${m[3]} ${m[4]}`)}`.trim()
const postalCode = m[5]
const city = toTitleCase(m[6])
items.push({ street, city, postalCode, country: 'France' })
}
return items
}
function extractNames(text: string): Identity[] {
const identities: Identity[] = []
// Heuristique: lignes en MAJUSCULES pour NOM; prénoms capitalisés à proximité
const lines = text.split(/\n|\r/).map((l) => l.trim()).filter(Boolean)
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i]
if (/^[A-ZÀ-ÖØ-Þ\-\s]{3,}$/.test(line) && line.length <= 40) {
const lastName = line.replace(/\s+/g, ' ').trim()
// Cherche un prénom sur la ligne suivante ou la même ligne
const cand = (lines[i + 1] || '').trim()
const firstNameMatch = cand.match(/^[A-Z][a-zà-öø-ÿ'\-]{1,}(?:\s+[A-Z][a-zà-öø-ÿ'\-]{1,})?$/)
const firstName = firstNameMatch ? cand : undefined
if (lastName && (!firstName || firstName.length <= 40)) {
identities.push({
id: `id-${i}`,
type: 'person',
firstName: firstName ? toTitleCase(firstName) : undefined,
lastName,
confidence: firstName ? 0.85 : 0.7,
})
}
}
}
return identities
}
export function runRuleNER(documentId: string, text: string): ExtractionResult {
const identitiesFromMRZ = extractMRZ(text)
const identities = identitiesFromMRZ
? [
{
id: 'mrz-1',
type: 'person',
firstName: identitiesFromMRZ.firstName,
lastName: identitiesFromMRZ.lastName!,
confidence: 0.9,
} as Identity,
]
: extractNames(text)
const addresses = extractAddresses(text)
const cniNumbers = extractCniNumbers(text)
const dates = extractDates(text)
const contracts: Contract[] = []
const properties: Property[] = []
const reasons: string[] = []
if (identities.length) reasons.push('Identités détectées par règles')
if (addresses.length) reasons.push('Adresse(s) détectée(s) par motifs')
if (cniNumbers.length) reasons.push('Numéro CNI plausible détecté')
if (dates.length) reasons.push('Dates détectées')
let documentType = 'Document'
if (/carte\s+nationale\s+d'identité|cni|mrz|identite/i.test(text)) documentType = 'CNI'
else if (/facture|tva|siren|montant/i.test(text)) documentType = 'Facture'
else if (/attestation|certificat/i.test(text)) documentType = 'Attestation'
// Confiance: base 0.6 + bonus par signal
let confidence = 0.6
if (identities.length) confidence += 0.15
if (cniNumbers.length) confidence += 0.15
if (addresses.length) confidence += 0.05
confidence = Math.max(0, Math.min(1, confidence))
return {
documentId,
text,
language: 'fr',
documentType,
identities,
addresses,
properties,
contracts,
signatures: [],
confidence,
confidenceReasons: reasons,
}
}

View File

@ -9,6 +9,7 @@ interface DocumentState {
currentDocument: Document | null
extractionResult: ExtractionResult | null
extractionById: Record<string, ExtractionResult>
fileById: Record<string, File>
analysisResult: AnalysisResult | null
contextResult: ContextResult | null
conseilResult: ConseilResult | null
@ -22,6 +23,7 @@ const initialState: DocumentState = {
currentDocument: null,
extractionResult: null,
extractionById: {},
fileById: {},
analysisResult: null,
contextResult: null,
conseilResult: null,
@ -148,6 +150,11 @@ const documentSlice = createSlice({
state.loading = false
state.documents.push(action.payload)
state.currentDocument = action.payload
// Capture le File depuis l'URL blob si disponible
if (action.payload.previewUrl?.startsWith('blob:')) {
// On ne peut pas récupérer l'objet File initial ici sans passer par onDrop;
// il est reconstruit lors de l'extraction via fetch blob.
}
})
.addCase(uploadDocument.rejected, (state, action) => {
state.loading = false

View File

@ -24,20 +24,19 @@ import {
} from '@mui/icons-material'
import type { ChipProps, LinearProgressProps } from '@mui/material'
import { useAppDispatch, useAppSelector } from '../store'
import { analyzeDocument } from '../store/documentSlice'
import { analyzeDocument, getConseil, getContextData } from '../store/documentSlice'
import { Layout } from '../components/Layout'
export default function AnalyseView() {
const dispatch = useAppDispatch()
const { currentDocument, analysisResult, loading } = useAppSelector(
(state) => state.document
)
const { currentDocument, analysisResult, loading, conseilResult, contextResult } = useAppSelector((state) => state.document)
useEffect(() => {
if (currentDocument && !analysisResult) {
dispatch(analyzeDocument(currentDocument.id))
}
}, [currentDocument, analysisResult, dispatch])
if (!currentDocument) return
if (!analysisResult) dispatch(analyzeDocument(currentDocument.id))
if (!conseilResult) dispatch(getConseil(currentDocument.id))
if (!contextResult) dispatch(getContextData(currentDocument.id))
}, [currentDocument, analysisResult, conseilResult, contextResult, dispatch])
if (!currentDocument) {
return (
@ -97,16 +96,10 @@ export default function AnalyseView() {
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap', alignItems: 'center' }}>
<Chip
icon={<Assessment />}
label={`Score de vraisemblance: ${(analysisResult.credibilityScore * 100).toFixed(1)}%`}
label={`Avancement: ${Math.round(analysisResult.credibilityScore * 100)}%`}
color={getScoreColor(analysisResult.credibilityScore)}
variant="filled"
/>
<Chip
icon={<Info />}
label={`Type: ${analysisResult.documentType}`}
color="primary"
variant="outlined"
/>
{analysisResult.isCNI && (
<Chip
icon={<Flag />}

View File

@ -68,7 +68,9 @@ export default function ExtractionView() {
)
}
if (!extractionResult) {
const activeResult = currentDocument ? (extractionById[currentDocument.id] || extractionResult) : extractionResult
if (!activeResult) {
return (
<Layout>
<Alert severity="warning">
@ -113,28 +115,28 @@ export default function ExtractionView() {
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
<Chip
icon={<Language />}
label={`Langue: ${ (extractionById[currentDocument!.id] || extractionResult)!.language }`}
label={`Langue: ${ activeResult.language }`}
color="primary"
variant="outlined"
/>
<Chip
icon={<Description />}
label={`Type: ${ (extractionById[currentDocument!.id] || extractionResult)!.documentType }`}
label={`Type: ${ activeResult.documentType }`}
color="secondary"
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}).` })()
(activeResult.confidenceReasons && activeResult.confidenceReasons.length > 0)
? activeResult.confidenceReasons.join(' • ')
: `Évaluation automatique basée sur le contenu et le type (${activeResult.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' })()}
label={`Confiance: ${Math.round(activeResult.confidence * 100)}%`}
color={activeResult.confidence > 0.8 ? 'success' : 'warning'}
variant="outlined"
/>
</Tooltip>
@ -210,10 +212,10 @@ export default function ExtractionView() {
<CardContent>
<Typography variant="h6" gutterBottom>
<Person sx={{ mr: 1, verticalAlign: 'middle' }} />
Identités ({extractionResult.identities?.length || 0})
Identités ({activeResult.identities?.length || 0})
</Typography>
<List dense>
{(extractionResult.identities || []).map((identity, index) => (
{(activeResult.identities || []).map((identity, index) => (
<ListItem key={index}>
<ListItemText
primary={
@ -256,10 +258,10 @@ export default function ExtractionView() {
<CardContent>
<Typography variant="h6" gutterBottom>
<LocationOn sx={{ mr: 1, verticalAlign: 'middle' }} />
Adresses ({extractionResult.addresses?.length || 0})
Adresses ({activeResult.addresses?.length || 0})
</Typography>
<List dense>
{(extractionResult.addresses || []).map((address, index) => (
{(activeResult.addresses || []).map((address, index) => (
<ListItem key={index}>
<ListItemText
primary={`${address.street}, ${address.city}`}
@ -280,10 +282,10 @@ export default function ExtractionView() {
<CardContent>
<Typography variant="h6" gutterBottom>
<Home sx={{ mr: 1, verticalAlign: 'middle' }} />
Biens ({extractionResult.properties?.length || 0})
Biens ({activeResult.properties?.length || 0})
</Typography>
<List dense>
{(extractionResult.properties || []).map((property, index) => (
{(activeResult.properties || []).map((property, index) => (
<ListItem key={index}>
<ListItemText
primary={`${property.type} - ${property.address.city}`}
@ -319,10 +321,10 @@ export default function ExtractionView() {
<CardContent>
<Typography variant="h6" gutterBottom>
<Description sx={{ mr: 1, verticalAlign: 'middle' }} />
Contrats ({extractionResult.contracts?.length || 0})
Contrats ({activeResult.contracts?.length || 0})
</Typography>
<List dense>
{(extractionResult.contracts || []).map((contract, index) => (
{(activeResult.contracts || []).map((contract, index) => (
<ListItem key={index}>
<ListItemText
primary={`${contract.type} - ${contract.amount ? `${contract.amount}` : 'Montant non spécifié'}`}
@ -355,10 +357,10 @@ export default function ExtractionView() {
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
Signatures détectées ({extractionResult.signatures?.length || 0})
Signatures détectées ({activeResult.signatures?.length || 0})
</Typography>
<List dense>
{(extractionResult.signatures || []).map((signature: any, index: number) => {
{(activeResult.signatures || []).map((signature: any, index: number) => {
const label = typeof signature === 'string'
? signature
: signature?.name || signature?.title || signature?.date || JSON.stringify(signature)
@ -387,7 +389,7 @@ export default function ExtractionView() {
}}
>
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap' }}>
{extractionResult.text}
{activeResult.text}
</Typography>
</Paper>
</CardContent>