fix: compacter extraction pour Santino rapide
This commit is contained in:
@@ -171,6 +171,75 @@ export function cleanJsonResponse(content: string): string {
|
|||||||
return normalizeControlCharactersInJsonStrings(cleaned);
|
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
|
* Extract invoice data using Mistral AI
|
||||||
* Hybrid approach: OCR to extract text, then LLM to parse and extract structured data
|
* 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`;
|
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 = buildInvoiceExtractionPrompt({
|
||||||
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.
|
pageCount,
|
||||||
|
qualityScoreInstruction,
|
||||||
Pour chaque facture trouvée, extrais les informations suivantes:
|
subscriptionInstruction,
|
||||||
- supplierName: Nom du fournisseur/vendeur (émetteur de la facture)
|
keywordsHint,
|
||||||
- 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.`;
|
|
||||||
|
|
||||||
// Call Mistral LLM with extracted text
|
// Call Mistral LLM with extracted text
|
||||||
const fullPrompt = `${prompt}\n\nTexte extrait du PDF:\n${pdfText}`;
|
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 || "");
|
: JSON.stringify(response.choices[0]?.message?.content || "");
|
||||||
const processingTimeMs = Date.now() - startTime;
|
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
|
// Clean and parse response
|
||||||
const cleanedResponse = cleanJsonResponse(rawResponse);
|
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);
|
result = JSON.parse(cleanedResponse);
|
||||||
} catch (parseError) {
|
} catch (parseError) {
|
||||||
console.error("[Mistral] Failed to parse JSON:", 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
|
// Log error to database
|
||||||
await createLlmLog({
|
await createLlmLog({
|
||||||
@@ -352,11 +397,7 @@ Si une information n'est pas trouvée, utilise null. Ne retourne AUCUN texte en
|
|||||||
processingTimeMs,
|
processingTimeMs,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Convert date strings to Date objects
|
result = attachLocallyExtractedText(result as any, pdfText);
|
||||||
result.invoices = result.invoices.map((inv) => ({
|
|
||||||
...inv,
|
|
||||||
invoiceDate: inv.invoiceDate ? new Date(inv.invoiceDate) : null,
|
|
||||||
}));
|
|
||||||
|
|
||||||
console.log(`[Mistral] Successfully extracted ${result.invoiceCount} invoice(s)`);
|
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