import { BadRequestException, Injectable, Logger, ServiceUnavailableException, } from '@nestjs/common'; import * as pdfParse from 'pdf-parse'; const MISTRAL_API_URL = 'https://api.mistral.ai/v1/chat/completions'; const RECEIPT_VISION_MODEL = 'mistral-small-2603'; // vision — används för bild-input const RECEIPT_TEXT_MODEL = 'mistral-small-latest'; // text — används som AI-fallback för PDF/OCR-text const MAX_RETRIES = 3; const QUANTITY_RULES = ` Regler för quantity och unit: 1. LÖSVIKT (chark, kött, ost, frukt/grönt vägt på kassabandet): quantity=faktisk vikt/volym från kvittot, unit=kg/g/l etc. Exempel: "BLANDFÄRS 20%" köpt 0.997 kg -> quantity=0.997, unit="kg" 2. FÖRPACKAD VARA med storlek i namnet (mejeri, dryck, konserver, flingor): quantity=antal köpta förpackningar, unit="förp". Exempel: "VISPGRÄDDE 5DL" köpt 1 -> quantity=1, unit="förp" 3. MULTIPACK (NxYg/NxYml i namnet): quantity=1, unit="förp". Räkna INTE upp N som quantity. Exempel: "BACON 3X120G" -> quantity=1, unit="förp" 4. FÖRPACKAT INNEHÅLL (bröd, kex, chips): quantity=antal köpta förpackningar, unit="förp". 5. LÖSA STYCKVAROR (enstaka frukt köpt lösvikt per styck): quantity=antal, unit="st". `; const IMAGE_PROMPT = `Du är en kvittoläsare. Analysera detta kvitto och returnera ENDAST en JSON-array med alla köpta varor. Varje vara ska ha: "rawName", "quantity", "unit" (st/kg/g/l/dl/cl/ml/förp/pak/burk/flaska), "price" (SEK eller null), "brand" (eller null), "origin" (eller null). ${QUANTITY_RULES} Returnera BARA JSON-arrayen utan markdown-formatering.`; const buildTextPrompt = (text: string) => `Du är en kvittoläsare. Nedan följer rader från ett kvitto som regelbaserad parsning inte kunde tolka entydigt. Returnera ENDAST en JSON-array för dessa rader. Varje vara ska ha: "rawName" (exakt som angett), "quantity", "unit" (st/kg/g/l/dl/cl/ml/förp/pak/burk/flaska), "price" (SEK eller null), "brand" (eller null), "origin" (eller null). ${QUANTITY_RULES} Returnera BARA JSON-arrayen utan markdown-formatering. Rader att tolka: ${text}`; export interface ParsedReceiptItemRaw { rawName: string; quantity: number; unit: string; price?: number | null; brand?: string | null; origin?: string | null; } // Regelbaserad parsning av en enstaka textrad från kvitto function ruleBasedParseLine(line: string): ParsedReceiptItemRaw | null { const normalized = line.toLowerCase(); // Multipack: "3x120g", "2 x 1.5l" const multiPack = /(\d+)\s*[x×]\s*(\d+(?:[\.,]\d+)?)\s*(ml|cl|dl|l|g|kg)\b/i.exec(normalized); if (multiPack) { return { rawName: line, quantity: 1, unit: 'förp', price: null, brand: null, origin: null }; } // Förpackad vara med volym/vikt i namn: "5dl", "1,5l", "100g" const singlePack = /(\d+(?:[\.,]\d+)?)\s*(ml|cl|dl|l|g|kg)\b/i.exec(normalized); if (singlePack) { const qty = Number.parseFloat(singlePack[1].replace(',', '.')); const unit = singlePack[2].toLowerCase(); // Lösvikt: kg/g utan "x" — returnera faktisk vikt if ((unit === 'kg' || unit === 'g') && !normalized.includes('x')) { return { rawName: line, quantity: qty, unit, price: null, brand: null, origin: null }; } return { rawName: line, quantity: 1, unit: 'förp', price: null, brand: null, origin: null }; } // Kan inte tolkas regelbaserat return null; } @Injectable() export class ReceiptParsingService { private readonly logger = new Logger(ReceiptParsingService.name); async parseReceipt(file: Express.Multer.File): Promise { const apiKey = process.env.MISTRAL_API_KEY; if (!apiKey) { throw new ServiceUnavailableException('MISTRAL_API_KEY är inte konfigurerad'); } const isPdf = file.mimetype === 'application/pdf' || file.mimetype === 'application/octet-stream' || file.originalname?.toLowerCase().endsWith('.pdf'); if (isPdf) { return this.parseReceiptFromPdf(file.buffer, apiKey); } return this.parseReceiptFromImage(file.buffer, file.mimetype, apiKey); } // PDF-flöde: text-extrahering -> regelbaserat -> AI-fallback för komplexa rader private async parseReceiptFromPdf(buffer: Buffer, apiKey: string): Promise { let text: string; try { const data = await pdfParse(buffer); text = data.text; } catch (err) { this.logger.warn(`pdf-parse misslyckades: ${err}`); throw new BadRequestException('PDF-filen kunde inte läsas. Kontrollera att filen inte är skadad.'); } const lines = text .split('\n') .map((l) => l.trim()) .filter((l) => l.length > 2); const resolved: ParsedReceiptItemRaw[] = []; const needsAI: string[] = []; for (const line of lines) { const item = ruleBasedParseLine(line); if (item !== null) { resolved.push(item); } else { needsAI.push(line); } } this.logger.log(`PDF: ${resolved.length} rader lösta regelbaserat, ${needsAI.length} skickas till AI`); if (needsAI.length > 0) { const aiItems = await this.callMistralText(needsAI, apiKey); resolved.push(...aiItems); } return resolved; } // Bild-flöde: Mistral vision (hela bilden) private async parseReceiptFromImage(buffer: Buffer, mimetype: string, apiKey: string): Promise { const base64 = buffer.toString('base64'); const body = { model: RECEIPT_VISION_MODEL, messages: [ { role: 'user', content: [ { type: 'text', text: IMAGE_PROMPT }, { type: 'image_url', image_url: { url: `data:${mimetype};base64,${base64}` } }, ], }, ], }; const response = await this.callMistralWithRetry(body, apiKey, 'image'); return this.parseJsonResponse(await response.json(), 'image'); } // AI-fallback för enskilda textrader (text-modell, billigare än vision) private async callMistralText(lines: string[], apiKey: string): Promise { const body = { model: RECEIPT_TEXT_MODEL, messages: [{ role: 'user', content: buildTextPrompt(lines.join('\n')) }], }; const response = await this.callMistralWithRetry(body, apiKey, 'text-fallback'); return this.parseJsonResponse(await response.json(), 'text-fallback'); } private parseJsonResponse(data: any, source: string): ParsedReceiptItemRaw[] { try { const content: string = data?.choices?.[0]?.message?.content ?? ''; const cleaned = content.replace(/` + '```' + `json|` + '```' + `/g, '').trim(); return JSON.parse(cleaned) as ParsedReceiptItemRaw[]; } catch (err) { this.logger.error(`Kunde inte parsa Mistral-svar (${source}): ${err}`); throw new BadRequestException('AI-svaret kunde inte tolkas. Försök igen.'); } } private async callMistralWithRetry(body: object, apiKey: string, source: string): Promise { for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { const response = await fetch(MISTRAL_API_URL, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}`, }, body: JSON.stringify(body), }); if (response.status === 503 || response.status === 429) { const err = await response.text(); this.logger.warn(`Mistral ${response.status} (${source}, försök ${attempt}/${MAX_RETRIES}): ${err}`); if (attempt < MAX_RETRIES) { await new Promise((resolve) => setTimeout(resolve, 1000 * attempt)); continue; } throw new ServiceUnavailableException('Mistral API är tillfälligt otillgänglig. Försök igen.'); } if (!response.ok) { const err = await response.text(); this.logger.error(`Mistral ${response.status} (${source}): ${err}`); throw new BadRequestException(`Mistral API svarade med fel: ${response.status}`); } return response; } throw new ServiceUnavailableException('Mistral API misslyckades efter max antal försök'); } }