feat(flyer-import): integrate AI-based flyer parsing with image support
- 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:
@@ -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>;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user