Files
microservice-importer/backend/src/receipt-parsing/receipt-parsing.service.ts
T

208 lines
8.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<ParsedReceiptItemRaw[]> {
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<ParsedReceiptItemRaw[]> {
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<ParsedReceiptItemRaw[]> {
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<ParsedReceiptItemRaw[]> {
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<Response> {
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');
}
}