From 3b0be885e2fdf000f64014583b869f7ae2ba4152 Mon Sep 17 00:00:00 2001 From: Manus Date: Fri, 2 Oct 2026 21:36:27 +0000 Subject: [PATCH] fix: compacter extraction pour Santino rapide --- server/invoiceExtractor.ts | 131 ++++++++++++++++-------- server/santinoCompactExtraction.test.ts | 31 ++++++ 2 files changed, 117 insertions(+), 45 deletions(-) create mode 100644 server/santinoCompactExtraction.test.ts diff --git a/server/invoiceExtractor.ts b/server/invoiceExtractor.ts index c129b43..fed729e 100644 --- a/server/invoiceExtractor.ts +++ b/server/invoiceExtractor.ts @@ -171,6 +171,75 @@ export function cleanJsonResponse(content: string): string { 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": , + "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 & { invoices: Array> }, + 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 @@ -245,44 +314,12 @@ export async function extractInvoicesWithMistral( subscriptionInstruction = `- isSubscription: true UNIQUEMENT si la facture contient l'un de ces mots-clés exacts: ${customKeywords.subscription}, false sinon`; } - // Prepare prompt for LLM - const prompt = `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} -- extractedText: Texte complet extrait de la facture (tout le texte visible sur les pages de cette facture) -${subscriptionInstruction}${keywordsHint} - -Réponds UNIQUEMENT avec un objet JSON valide au format suivant: -{ - "pageCount": ${pageCount}, - "invoiceCount": , - "invoices": [ - { - "supplierName": "...", - "invoiceNumber": "...", - "invoiceDate": "YYYY-MM-DD", - "deliveryNoteNumber": "...", - "orderNumber": "...", - "totalAmount": 123.45, - "recipientName": "...", - "pageRange": "1-2", - "qualityScore": 85, - "extractedText": "Texte complet de la facture...", - "isSubscription": false - } - ] -} - -Si une information n'est pas trouvée, utilise null. Ne retourne AUCUN texte en dehors du JSON.`; + const prompt = buildInvoiceExtractionPrompt({ + pageCount, + qualityScoreInstruction, + subscriptionInstruction, + keywordsHint, + }); // Call Mistral LLM with extracted text const fullPrompt = `${prompt}\n\nTexte extrait du PDF:\n${pdfText}`; @@ -304,7 +341,11 @@ Si une information n'est pas trouvée, utilise null. Ne retourne AUCUN texte en : JSON.stringify(response.choices[0]?.message?.content || ""); const processingTimeMs = Date.now() - startTime; - console.log("[Mistral] Raw response received:", rawResponse.substring(0, 200)); + 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); @@ -314,7 +355,11 @@ Si une information n'est pas trouvée, utilise null. Ne retourne AUCUN texte en result = JSON.parse(cleanedResponse); } catch (parseError) { console.error("[Mistral] Failed to parse JSON:", parseError); - console.error("[Mistral] Cleaned response:", cleanedResponse); + console.error("[Mistral] Invalid response shape", { + length: cleanedResponse.length, + startsWithJson: cleanedResponse.startsWith("{"), + endsWithJson: cleanedResponse.endsWith("}"), + }); // Log error to database await createLlmLog({ @@ -352,11 +397,7 @@ Si une information n'est pas trouvée, utilise null. Ne retourne AUCUN texte en processingTimeMs, }); - // Convert date strings to Date objects - result.invoices = result.invoices.map((inv) => ({ - ...inv, - invoiceDate: inv.invoiceDate ? new Date(inv.invoiceDate) : null, - })); + result = attachLocallyExtractedText(result as any, pdfText); console.log(`[Mistral] Successfully extracted ${result.invoiceCount} invoice(s)`); diff --git a/server/santinoCompactExtraction.test.ts b/server/santinoCompactExtraction.test.ts new file mode 100644 index 0000000..72683e5 --- /dev/null +++ b/server/santinoCompactExtraction.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { + attachLocallyExtractedText, + buildInvoiceExtractionPrompt, +} from "./invoiceExtractor"; + +describe("contrat compact pour les moteurs à sortie limitée", () => { + it("ne demande pas au modèle de recopier le texte complet du PDF", () => { + const prompt = buildInvoiceExtractionPrompt({ + pageCount: 1, + qualityScoreInstruction: "- qualityScore: score", + subscriptionInstruction: "- isSubscription: false", + keywordsHint: "", + }); + + expect(prompt).not.toContain("- extractedText:"); + expect(prompt).toContain("Ne retourne jamais le texte complet du PDF"); + }); + + it("conserve le texte extrait localement après le JSON métier", () => { + const text = "texte extrait localement"; + const result = attachLocallyExtractedText({ + pageCount: 1, + invoiceCount: 1, + invoices: [{ invoiceDate: "2026-08-28", supplierName: "Fournisseur" }], + } as any, text); + + expect(result.invoices[0].extractedText).toBe(text); + expect(result.invoices[0].invoiceDate).toEqual(new Date("2026-08-28")); + }); +});