fix: compacter extraction pour Santino rapide

This commit is contained in:
Manus
2026-10-02 21:36:27 +00:00
parent f9434d3a79
commit 3b0be885e2
2 changed files with 117 additions and 45 deletions

View File

@@ -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": <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
@@ -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": <nombre de factures détectées>,
"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)`);

View File

@@ -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"));
});
});