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_IMPORT_MODEL = 'mistral-small-2603'; const MAX_RETRIES = 3; 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 följande fält: - "rawName": varans namn som det står på kvittot (sträng) - "quantity": antal eller mängd som ett tal (t.ex. 1, 2, 0.5) - "unit": enhet — välj ett av: "st", "kg", "g", "l", "dl", "cl", "ml", "förp", "pak", "burk", "flaska" - "price": pris i SEK som ett tal, eller null - "brand": märke eller leverantör om det tydligt framgår av varunamnet (t.ex. "Arla", "ICA", "Oatly"), annars null - "origin": ursprungsland om det framgår av varunamnet (t.ex. "Brasilien", "Sverige", "Italien"), annars null Returnera BARA JSON-arrayen utan markdown-formatering.`; const TEXT_PROMPT = (text: string) => `Du är en kvittoläsare. Nedan följer rå text från ett kvitto. Analysera texten och returnera ENDAST en JSON-array med alla köpta varor. Varje vara ska ha följande fält: - "rawName": varans namn som det står på kvittot (sträng) - "quantity": antal eller mängd som ett tal (t.ex. 1, 2, 0.5) - "unit": enhet — välj ett av: "st", "kg", "g", "l", "dl", "cl", "ml", "förp", "pak", "burk", "flaska" - "price": pris i SEK som ett tal, eller null - "brand": märke eller leverantör om det tydligt framgår av varunamnet (t.ex. "Arla", "ICA", "Oatly"), annars null - "origin": ursprungsland om det framgår av varunamnet (t.ex. "Brasilien", "Sverige", "Italien"), annars null Returnera BARA JSON-arrayen utan markdown-formatering. Kvittotext: ${text}`; export interface ParsedReceiptItemRaw { rawName: string; quantity: number; unit: string; price?: number | null; brand?: string | null; origin?: string | 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); } 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((r) => setTimeout(r, attempt * 2000)); continue; } throw new ServiceUnavailableException('Mistral API: Tjänsten tillfälligt otillgänglig'); } return response; } throw new ServiceUnavailableException('Kunde inte nå Mistral API efter flera försök'); } private async parseReceiptFromImage( buffer: Buffer, mimeType: string, apiKey: string, ): Promise { const effectiveMime = mimeType === 'application/octet-stream' ? 'image/jpeg' : mimeType; const base64 = buffer.toString('base64'); const response = await this.callMistralWithRetry( { model: RECEIPT_IMPORT_MODEL, messages: [ { role: 'user', content: [ { type: 'image_url', image_url: { url: `data:${effectiveMime};base64,${base64}` }, }, { type: 'text', text: IMAGE_PROMPT }, ], }, ], max_tokens: 2000, temperature: 0.1, }, apiKey, 'bild', ); return this.extractItemsFromMistralResponse(response, 'bild'); } private async parseReceiptFromPdf( buffer: Buffer, apiKey: string, ): Promise { let pdfText: string; try { const parsed = await pdfParse(buffer); pdfText = parsed.text?.trim(); } catch { throw new BadRequestException('Kunde inte läsa PDF-filen. Kontrollera att filen inte är skadad.'); } if (!pdfText || pdfText.length < 20) { throw new BadRequestException( 'PDF-filen verkar inte innehålla läsbar text. Prova att fotografera kvittot istället.', ); } this.logger.log(`PDF-text extraherad (${pdfText.length} tecken)`); const response = await this.callMistralWithRetry( { model: RECEIPT_IMPORT_MODEL, messages: [{ role: 'user', content: TEXT_PROMPT(pdfText) }], max_tokens: 2000, temperature: 0.1, }, apiKey, 'PDF', ); return this.extractItemsFromMistralResponse(response, 'PDF'); } private async extractItemsFromMistralResponse( response: Response, source: string, ): Promise { if (!response.ok) { const err = await response.text(); this.logger.error(`Mistral API svarade ${response.status} (${source}): ${err}`); const hint = response.status === 401 ? 'Ogiltig API-nyckel (401)' : response.status === 429 ? 'För många förfrågningar (429)' : `HTTP ${response.status}`; throw new ServiceUnavailableException(`Mistral API returnerade ett fel: ${hint}`); } const data = (await response.json()) as { choices: { message: { content: string } }[]; }; const content = data.choices?.[0]?.message?.content ?? '[]'; try { const clean = content.replace(/```(?:json)?/gi, '').trim(); const items = JSON.parse(clean); if (!Array.isArray(items)) throw new Error('Inte en array'); return items as ParsedReceiptItemRaw[]; } catch { this.logger.error(`Kunde inte parsa Mistral-svar (${source}):`, content); throw new BadRequestException( `Kvittot kunde inte tolkas. Försök med en tydligare ${source === 'PDF' ? 'PDF' : 'bild'}.`, ); } } }