2025-09-15 13:37:53 +02:00

210 lines
8.2 KiB
TypeScript

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