diff --git a/server/invoiceExtractor.test.ts b/server/invoiceExtractor.test.ts new file mode 100644 index 0000000..e29a56f --- /dev/null +++ b/server/invoiceExtractor.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { cleanJsonResponse } from "./invoiceExtractor"; + +describe("cleanJsonResponse", () => { + it("supprime les caractères de contrôle bruts dans une chaîne JSON LLM", () => { + const rawResponse = `{ + "pageCount": 1, + "invoiceCount": 1, + "invoices": [{ + "supplierName": "Manus AI", + "invoiceNumber": "J6LNGDAL-0021", + "extractedText": "Ligne valide\u0000suite du texte" + }] + }`; + + const parsed = JSON.parse(cleanJsonResponse(rawResponse)); + + expect(parsed.invoices[0].invoiceNumber).toBe("J6LNGDAL-0021"); + expect(parsed.invoices[0].extractedText).toBe("Ligne validesuite du texte"); + }); + + it("préserve les retours à la ligne échappés dans les chaînes JSON", () => { + const rawResponse = '{"invoices":[{"extractedText":"Ligne 1\nLigne 2"}] }'; + + const parsed = JSON.parse(cleanJsonResponse(rawResponse)); + + expect(parsed.invoices[0].extractedText).toBe("Ligne 1\nLigne 2"); + }); +}); diff --git a/server/invoiceExtractor.ts b/server/invoiceExtractor.ts index e29b876..c129b43 100644 --- a/server/invoiceExtractor.ts +++ b/server/invoiceExtractor.ts @@ -75,9 +75,54 @@ async function extractTextFromPdf(pdfBuffer: Buffer): Promise { } /** - * Clean JSON response from LLM by removing markdown code blocks + * 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 cleanJsonResponse(content: string): string { +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 @@ -123,7 +168,7 @@ function cleanJsonResponse(content: string): string { cleaned = cleaned.substring(startIdx, endIdx + 1); } - return cleaned; + return normalizeControlCharactersInJsonStrings(cleaned); } /**