210 lines
8.2 KiB
TypeScript
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
|
|
}
|