368 lines
14 KiB
TypeScript

/*
Mode OpenAI (fallback) pour 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'
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'
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 = ''
}
}
// Flags de mode
const useRuleNer = import.meta.env.VITE_USE_RULE_NER === 'true'
const classifyOnly = import.meta.env.VITE_LLM_CLASSIFY_ONLY === 'true'
const disableLLM = import.meta.env.VITE_DISABLE_LLM === 'true'
console.log('🔧 [CONFIG] Mode sans LLM activé:', {
useRuleNer,
classifyOnly,
disableLLM,
hasOpenAIKey: !!OPENAI_API_KEY,
})
// 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) {
console.log("🚀 [OCR] Début de l'extraction OCR locale...")
console.log('📄 [OCR] Texte à traiter:', localText.substring(0, 200) + '...')
// Simuler la progression OCR de manière asynchrone pour éviter les boucles
if (hooks?.onOcrProgress) {
setTimeout(() => {
hooks.onOcrProgress?.(0.3)
console.log('⏳ [OCR] Progression: 30%')
}, 100)
setTimeout(() => {
hooks.onOcrProgress?.(0.7)
console.log('⏳ [OCR] Progression: 70%')
}, 200)
setTimeout(() => {
hooks.onOcrProgress?.(1)
console.log('✅ [OCR] Progression: 100% - Extraction terminée')
}, 300)
}
console.log("🔍 [NER] Début de l'extraction par règles...")
let res = runRuleNER(documentId, localText)
console.log('📊 [NER] Résultats extraits:', {
documentType: res.documentType,
identitiesCount: res.identities.length,
addressesCount: res.addresses.length,
confidence: res.confidence,
})
if (classifyOnly && OPENAI_API_KEY && localText && !disableLLM) {
console.log('🤖 [LLM] Classification LLM demandée...')
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',
]
console.log('✅ [LLM] Classification LLM terminée:', parsed.documentType)
}
hooks?.onLlmProgress?.(1)
} catch (error) {
console.warn('⚠️ [LLM] Échec de la classification LLM:', error)
hooks?.onLlmProgress?.(1)
}
} else {
console.log('🚫 [LLM] LLM désactivé - Mode 100% local')
// Mode sans LLM : simuler la progression LLM de manière asynchrone
if (hooks?.onLlmProgress) {
setTimeout(() => {
hooks.onLlmProgress?.(0.5)
console.log('⏳ [LLM] Progression: 50%')
}, 400)
setTimeout(() => {
hooks.onLlmProgress?.(1)
console.log('✅ [LLM] Progression: 100%')
}, 500)
}
}
console.log('🎉 [FINAL] Extraction complète terminée:', res)
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 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 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: safeIdentities,
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> => {
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
}