fix: compacter extraction pour Santino rapide
This commit is contained in:
@@ -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)`);
|
||||
|
||||
|
||||
31
server/santinoCompactExtraction.test.ts
Normal file
31
server/santinoCompactExtraction.test.ts
Normal 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"));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user