Files
demat-facturation/server/invoiceExtractor.ts

432 lines
14 KiB
TypeScript

import { invokeLLMWithUserSettings, type UserLlmSettings } from "./_core/llm";
import { PDFDocument } from "pdf-lib";
import { createLlmLog } from "./db";
import PDFParser from "pdf2json";
export interface ExtractedInvoiceData {
supplierName: string | null;
invoiceNumber: string | null;
invoiceDate: Date | null;
deliveryNoteNumber: string | null;
orderNumber: string | null;
totalAmount: number | null;
recipientName: string | null; // Destinataire de la facture
pageRange: string;
qualityScore: number; // 0-100
extractedText: string | null; // Full text extracted from the invoice PDF
isSubscription: boolean; // True if subscription keywords detected
}
export interface MultiInvoiceResult {
pageCount: number;
invoiceCount: number;
invoices: ExtractedInvoiceData[];
}
/**
* Extract text from PDF buffer using pdf2json
*/
async function extractTextFromPdf(pdfBuffer: Buffer): Promise<string> {
return new Promise((resolve, reject) => {
const pdfParser = new (PDFParser as any)(null, 1);
pdfParser.on("pdfParser_dataError", (errData: any) => {
console.error("Error parsing PDF:", errData.parserError);
reject(new Error("Failed to parse PDF"));
});
pdfParser.on("pdfParser_dataReady", (pdfData: any) => {
try {
let text = "";
// Extract text from all pages
if (pdfData.Pages) {
for (const page of pdfData.Pages) {
if (page.Texts) {
for (const textItem of page.Texts) {
if (textItem.R) {
for (const run of textItem.R) {
if (run.T) {
try {
text += decodeURIComponent(run.T) + " ";
} catch (e) {
// If decodeURIComponent fails, use the raw text
text += run.T + " ";
}
}
}
}
}
text += "\n";
}
}
}
console.log("[PDF] Text extracted, length:", text.length, "characters");
resolve(text);
} catch (error) {
console.error("Error extracting text from PDF data:", error);
reject(new Error("Failed to extract text from PDF"));
}
});
pdfParser.parseBuffer(pdfBuffer);
});
}
/**
* Supprime ou échappe les caractères de contrôle bruts uniquement à
* l'intérieur des chaînes JSON. Certains moteurs retournent ponctuellement
* un caractère NUL provenant du texte OCR : JSON.parse le refuse alors que
* le reste de la réponse est exploitable.
*/
function normalizeControlCharactersInJsonStrings(content: string): string {
let normalized = "";
let inString = false;
let escaped = false;
for (const character of content) {
if (escaped) {
normalized += character;
escaped = false;
continue;
}
if (character === "\\") {
normalized += character;
escaped = true;
continue;
}
if (character === '"') {
inString = !inString;
normalized += character;
continue;
}
if (inString && character.charCodeAt(0) < 0x20) {
if (character === "\n") normalized += "\\n";
else if (character === "\r") normalized += "\\r";
else if (character === "\t") normalized += "\\t";
// Les autres contrôles (notamment NUL) n'ont pas de valeur métier.
continue;
}
normalized += character;
}
return normalized;
}
/**
* Clean JSON response from LLM by removing markdown code blocks.
* Exportée pour couvrir les réponses LLM mal formées en régression.
*/
export function cleanJsonResponse(content: string): string {
let cleaned = content.trim();
// Remove markdown code blocks
cleaned = cleaned.replace(/^```(?:json)?\s*/i, "");
cleaned = cleaned.replace(/\s*```$/, "");
cleaned = cleaned.trim();
// Try to extract JSON object or array
const firstBrace = cleaned.indexOf("{");
const firstBracket = cleaned.indexOf("[");
let startIdx = -1;
let startChar = "";
if (firstBrace !== -1 && (firstBracket === -1 || firstBrace < firstBracket)) {
startIdx = firstBrace;
startChar = "{";
} else if (firstBracket !== -1) {
startIdx = firstBracket;
startChar = "[";
}
if (startIdx === -1) {
return cleaned;
}
const endChar = startChar === "{" ? "}" : "]";
let depth = 0;
let endIdx = -1;
for (let i = startIdx; i < cleaned.length; i++) {
if (cleaned[i] === startChar) depth++;
if (cleaned[i] === endChar) {
depth--;
if (depth === 0) {
endIdx = i;
break;
}
}
}
if (endIdx !== -1) {
cleaned = cleaned.substring(startIdx, endIdx + 1);
}
return normalizeControlCharactersInJsonStrings(cleaned);
}
export type InvoiceExtractionPromptInput = {
pageCount: number;
qualityScoreInstruction: string;
subscriptionInstruction: string;
keywordsHint: string;
};
/**
* Construit une demande JSON compacte. Le texte PDF reste en stockage local :
* les moteurs rapides à sortie limitée ne doivent pas le recopier dans leur JSON.
*/
export function buildInvoiceExtractionPrompt({
pageCount,
qualityScoreInstruction,
subscriptionInstruction,
keywordsHint,
}: InvoiceExtractionPromptInput): string {
return `Tu es un expert en extraction de données de factures. Analyse ce document PDF et extrais toutes les factures qu'il contient.
Pour chaque facture trouvée, extrais les informations suivantes:
- supplierName: Nom du fournisseur/vendeur (émetteur de la facture)
- invoiceNumber: Numéro de la facture
- invoiceDate: Date de la facture (format ISO 8601: YYYY-MM-DD)
- deliveryNoteNumber: Numéro du bon de livraison (si présent)
- orderNumber: Numéro de commande client (si présent)
- totalAmount: Montant total TTC (nombre décimal)
- recipientName: Nom du destinataire/client (société ou personne à qui la facture est adressée)
- pageRange: Plage de pages de cette facture (ex: "1-2" ou "5")
${qualityScoreInstruction}
${subscriptionInstruction}${keywordsHint}
Réponds UNIQUEMENT avec un objet JSON valide au format suivant:
{
"pageCount": ${pageCount},
"invoiceCount": <nombre de factures détectées>,
"invoices": [
{
"supplierName": "...",
"invoiceNumber": "...",
"invoiceDate": "YYYY-MM-DD",
"deliveryNoteNumber": "...",
"orderNumber": "...",
"totalAmount": 123.45,
"recipientName": "...",
"pageRange": "1-2",
"qualityScore": 85,
"isSubscription": false
}
]
}
Si une information n'est pas trouvée, utilise null. Ne retourne jamais le texte complet du PDF, ni aucun texte en dehors du JSON.`;
}
/** Attache le texte PDF déjà extrait localement, sans demander au modèle de le recopier. */
export function attachLocallyExtractedText(
result: Omit<MultiInvoiceResult, "invoices"> & { invoices: Array<Record<string, unknown>> },
pdfText: string,
): MultiInvoiceResult {
return {
...result,
invoices: result.invoices.map((invoice) => ({
...invoice,
invoiceDate: invoice.invoiceDate ? new Date(String(invoice.invoiceDate)) : null,
extractedText: pdfText,
})) as ExtractedInvoiceData[],
};
}
/**
* Extract invoice data using Mistral AI
* Hybrid approach: OCR to extract text, then LLM to parse and extract structured data
*/
export async function extractInvoicesWithMistral(
pdfBuffer: Buffer,
userId: number,
sourceFileId: number,
model: string = "mistral-large-latest",
customKeywords?: {
invoiceNumber?: string | null;
deliveryNote?: string | null;
orderNumber?: string | null;
supplier?: string | null;
totalAmount?: string | null;
subscription?: string | null;
recipient?: string | null;
},
aiSettings?: UserLlmSettings
): Promise<MultiInvoiceResult> {
// Load user's field configuration
const { getLlmFieldsConfigByUser } = await import("./db");
const fieldsConfig = await getLlmFieldsConfigByUser(userId);
// Build quality score instruction based on required fields
const requiredFields = fieldsConfig.filter(f => f.isRequired === 1);
const optionalFields = fieldsConfig.filter(f => f.isRequired === 0);
let qualityScoreInstruction = "- qualityScore: Score de qualité de l'extraction de 0 à 100";
if (requiredFields.length > 0) {
const requiredFieldNames = requiredFields.map(f => f.displayName).join(", ");
qualityScoreInstruction += ` (100 = tous les champs obligatoires trouvés: ${requiredFieldNames})`;
}
if (optionalFields.length > 0) {
const optionalFieldNames = optionalFields.map(f => f.displayName).join(", ");
qualityScoreInstruction += `. Champs optionnels (n'affectent pas le score): ${optionalFieldNames}`;
}
const startTime = Date.now();
try {
console.log("[Mistral] Starting invoice extraction...");
// Extract text from PDF
const pdfText = await extractTextFromPdf(pdfBuffer);
// Get PDF page count
const pdfDoc = await PDFDocument.load(pdfBuffer);
const pageCount = pdfDoc.getPageCount();
console.log(`[Mistral] PDF has ${pageCount} pages`);
// Build custom keywords hint
let keywordsHint = "";
if (customKeywords) {
const hints = [];
if (customKeywords.invoiceNumber) hints.push(`Numéro de facture: ${customKeywords.invoiceNumber}`);
if (customKeywords.deliveryNote) hints.push(`Bon de livraison: ${customKeywords.deliveryNote}`);
if (customKeywords.orderNumber) hints.push(`Numéro de commande: ${customKeywords.orderNumber}`);
if (customKeywords.supplier) hints.push(`Fournisseur: ${customKeywords.supplier}`);
if (customKeywords.totalAmount) hints.push(`Montant total: ${customKeywords.totalAmount}`);
if (customKeywords.subscription) hints.push(`Abonnement: ${customKeywords.subscription}`);
if (customKeywords.recipient) hints.push(`Destinataire: ${customKeywords.recipient}`);
if (hints.length > 0) {
keywordsHint = `\n\nMots-clés personnalisés à rechercher:\n${hints.join("\n")}`;
}
}
// Build subscription detection instruction
let subscriptionInstruction = "- isSubscription: false (par défaut)";
if (customKeywords?.subscription && customKeywords.subscription.trim()) {
subscriptionInstruction = `- isSubscription: true UNIQUEMENT si la facture contient l'un de ces mots-clés exacts: ${customKeywords.subscription}, false sinon`;
}
const prompt = buildInvoiceExtractionPrompt({
pageCount,
qualityScoreInstruction,
subscriptionInstruction,
keywordsHint,
});
// Call Mistral LLM with extracted text
const fullPrompt = `${prompt}\n\nTexte extrait du PDF:\n${pdfText}`;
const invocation = await invokeLLMWithUserSettings(
{
messages: [
{
role: "user",
content: fullPrompt,
},
],
},
aiSettings
);
const { response, trace } = invocation;
const rawResponse = typeof response.choices[0]?.message?.content === "string"
? response.choices[0].message.content
: JSON.stringify(response.choices[0]?.message?.content || "");
const processingTimeMs = Date.now() - startTime;
console.log("[Mistral] Raw response received", {
length: rawResponse.length,
startsWithJson: rawResponse.trimStart().startsWith("{"),
endsWithJson: rawResponse.trimEnd().endsWith("}"),
});
// Clean and parse response
const cleanedResponse = cleanJsonResponse(rawResponse);
let result: MultiInvoiceResult;
try {
result = JSON.parse(cleanedResponse);
} catch (parseError) {
console.error("[Mistral] Failed to parse JSON:", parseError);
console.error("[Mistral] Invalid response shape", {
length: cleanedResponse.length,
startsWithJson: cleanedResponse.startsWith("{"),
endsWithJson: cleanedResponse.endsWith("}"),
});
// Log error to database
await createLlmLog({
userId,
sourceFileId,
operation: "extraction",
model,
resolvedProvider: trace.provider,
resolvedModel: trace.model,
transport: trace.transport,
promptSent: prompt,
rawResponse: rawResponse.substring(0, 10000),
cleanedResponse: cleanedResponse.substring(0, 10000),
success: 0,
errorMessage: `JSON parse error: ${parseError}`,
processingTimeMs,
});
throw new Error("Failed to parse LLM response as JSON");
}
// Log successful extraction
await createLlmLog({
userId,
sourceFileId,
operation: "extraction",
model,
resolvedProvider: trace.provider,
resolvedModel: trace.model,
transport: trace.transport,
promptSent: prompt.substring(0, 10000),
rawResponse: rawResponse.substring(0, 10000),
cleanedResponse: cleanedResponse.substring(0, 10000),
success: 1,
processingTimeMs,
});
result = attachLocallyExtractedText(result as any, pdfText);
console.log(`[Mistral] Successfully extracted ${result.invoiceCount} invoice(s)`);
return result;
} catch (error) {
console.error("[Mistral] Extraction failed:", error);
throw error;
}
}
/**
* Generate metadata JSON for an invoice
*/
export function generateMetadataJSON(invoice: ExtractedInvoiceData): string {
return JSON.stringify(
{
supplierName: invoice.supplierName,
invoiceNumber: invoice.invoiceNumber,
invoiceDate: invoice.invoiceDate?.toISOString(),
deliveryNoteNumber: invoice.deliveryNoteNumber,
orderNumber: invoice.orderNumber,
totalAmount: invoice.totalAmount,
recipientName: invoice.recipientName,
pageRange: invoice.pageRange,
qualityScore: invoice.qualityScore,
extractedAt: new Date().toISOString(),
},
null,
2
);
}