feat(flyer-import): integrate AI-based flyer parsing with image support
Test Suite / quick-import-pr-quick (push) Has been skipped
Test Suite / backend-full (push) Successful in 2m31s
Test Suite / flutter-quality (push) Failing after 3m48s
Test Suite / backend-pr-quick (push) Failing after 13m57s

- Add support for PNG, JPEG, and WebP image formats in flyer import
- Replace external importer service with internal AI-based parsing pipeline
- Add new services: TextExtractorService, AiFlyerParserService, FlyerNormalizerService
- Integrate Mistral AI, pdf-parse, and tesseract.js dependencies
- Add quality confidence indicators and warning panels in Flutter UI
- Update package.json with new dependencies and transform ignore patterns
- Add documentation for flyer importer system
- Add Kilo AI planning file for Happy Island project

BREAKING CHANGE: Flyer import now uses internal AI parsing instead of external importer service
This commit is contained in:
Nils-Johan Gynther
2026-05-19 19:57:54 +02:00
parent 0ce1db5471
commit 187d0283a5
14 changed files with 1479 additions and 103 deletions
@@ -1,8 +1,8 @@
import {
BadRequestException,
Injectable,
Logger,
ServiceUnavailableException,
import {
BadRequestException,
Injectable,
Logger,
ServiceUnavailableException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
@@ -12,8 +12,9 @@ import {
FlyerImportMatchVia,
FlyerImportResponse,
} from './dto/flyer-import.response';
const IMPORTER_SERVICE_URL = process.env.IMPORTER_SERVICE_URL || 'http://importer-api:3001';
import { TextExtractorService } from './services/text-extractor.service';
import { AiFlyerParserService } from './services/ai-flyer-parser.service';
import { FlyerNormalizerService } from './services/flyer-normalizer.service';
type FlyerParseItem = {
rawName: string;
@@ -53,10 +54,15 @@ type ProductLite = {
export class FlyerImportService {
private readonly logger = new Logger(FlyerImportService.name);
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly textExtractor: TextExtractorService,
private readonly aiParser: AiFlyerParserService,
private readonly normalizer: FlyerNormalizerService,
) {}
async parseAndMatch(file: Express.Multer.File, userId: number): Promise<FlyerImportResponse> {
const parsed = await this.parseViaImporter(file);
const parsed = await this.parseViaInternal(file);
const [products, aliases] = await Promise.all([
this.prisma.product.findMany({
@@ -371,43 +377,59 @@ export class FlyerImportService {
return allowed.has(cleaned) ? cleaned : cleaned;
}
private async parseViaImporter(file: Express.Multer.File): Promise<FlyerParseResponse> {
const form = new FormData();
form.append(
'file',
new Blob([new Uint8Array(file.buffer)], { type: file.mimetype }),
file.originalname,
);
form.append('retailer', 'willys');
let response: Response;
private async parseViaInternal(file: Express.Multer.File): Promise<FlyerParseResponse> {
try {
response = await fetch(`${IMPORTER_SERVICE_URL}/api/flyer/parse`, {
method: 'POST',
body: form,
});
} catch (err) {
this.logger.error(`Kunde inte nå importer-api för flyer-parse: ${String(err)}`);
throw new ServiceUnavailableException('Importer-tjänsten är inte tillgänglig just nu.');
}
this.logger.debug(`Parsing flyer file: ${file.originalname}`);
if (!response.ok) {
let message = `Importer-tjänsten svarade ${response.status}`;
try {
const body = (await response.json()) as { message?: string };
if (typeof body.message === 'string' && body.message.trim()) {
message = body.message;
}
} catch {
// ignore parse issues
// 1. Extrahera text från PDF/bild
const text = await this.textExtractor.extractText(
file.buffer,
file.mimetype,
file.originalname,
);
// 2. Skicka till Mistral Tiny
const aiItems = await this.aiParser.parseWithAI(text);
// 3. Normalisera resultatet
const normalizedItems = this.normalizer.normalize(aiItems);
// 4. Konvertera till intern FlyerParseItem-format
const items: FlyerParseItem[] = normalizedItems.map((item) => ({
rawName: item.rawName,
normalizedName: item.normalizedName,
category: item.categoryHint,
price: item.price,
priceUnit: item.priceUnit,
comparisonPrice: item.comparisonPrice,
comparisonUnit: item.comparisonUnit,
offerText: item.offerText,
confidence: item.parseConfidence,
reasonCodes: item.parseReasons,
}));
const warnings: string[] = [];
if (items.length === 0) {
warnings.push('Inga produkter kunde extraheras från flyern.');
}
if (response.status >= 400 && response.status < 500) {
throw new BadRequestException(message);
}
throw new ServiceUnavailableException(message);
return {
retailer: 'willys',
parserVersion: 'v1',
items,
warnings,
};
} catch (err) {
if (err instanceof BadRequestException) {
throw err;
}
if (err instanceof ServiceUnavailableException) {
throw err;
}
this.logger.error(`Internal flyer parse failed: ${String(err)}`);
throw new BadRequestException(
`Fel vid tolkning av flyer: ${err instanceof Error ? err.message : String(err)}`,
);
}
return response.json() as Promise<FlyerParseResponse>;
}
}