/* 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 { 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 => { 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 => { 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 => { 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 => { 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 => { 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 }