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
@@ -0,0 +1,158 @@
import { Injectable, Logger } from '@nestjs/common';
export interface NormalizedFlyerItem {
rawName: string;
normalizedName: string;
categoryHint: string | null;
price: number | null;
priceUnit: string | null;
comparisonPrice: number | null;
comparisonUnit: string | null;
offerText: string | null;
parseConfidence: number;
parseReasons: string[];
}
@Injectable()
export class FlyerNormalizerService {
private readonly logger = new Logger(FlyerNormalizerService.name);
private readonly UNIT_MAPPING: Record<string, string> = {
// Längd
mm: 'mm',
cm: 'cm',
m: 'm',
// Vikt
mg: 'mg',
g: 'g',
hg: 'hg',
kg: 'kg',
ton: 'ton',
// Volym
ml: 'ml',
cl: 'cl',
dl: 'dl',
l: 'l',
// Övrigt
st: 'st',
styck: 'st',
stycke: 'st',
pkt: 'pkt',
paket: 'pkt',
fp: 'pkt',
förp: 'pkt',
förpackning: 'pkt',
};
/**
* Normaliserar en AI-parsad produktlista.
*/
normalize(items: any[]): NormalizedFlyerItem[] {
if (!Array.isArray(items)) {
this.logger.warn('normalize() received non-array, returning empty list');
return [];
}
return items
.map((item, idx) => this.normalizeItem(item, idx))
.filter((item): item is NormalizedFlyerItem => item !== null);
}
private normalizeItem(item: any, index: number): NormalizedFlyerItem | null {
if (!item || typeof item !== 'object') {
this.logger.warn(`Item ${index} is not an object, skipping`);
return null;
}
const rawName = this.extractString(item.rawName) || this.extractString(item.name);
if (!rawName) {
this.logger.warn(`Item ${index} has no name, skipping`);
return null;
}
const normalizedName = this.extractString(item.normalizedName) || this.normalizeName(rawName);
return {
rawName,
normalizedName,
categoryHint: this.normalizeCategory(this.extractString(item.category)),
price: this.extractPrice(item.price),
priceUnit: this.normalizeUnit(this.extractString(item.unit)),
comparisonPrice: this.extractPrice(item.comparisonPrice),
comparisonUnit: this.normalizeUnit(this.extractString(item.comparisonUnit)),
offerText: this.normalizeOfferText(item.offer),
parseConfidence: item.confidence ?? 0.85,
parseReasons: Array.isArray(item.reasonCodes)
? item.reasonCodes.map(String)
: ['normalized'],
};
}
private extractString(val: any): string | null {
if (typeof val === 'string') return val.trim() || null;
return null;
}
private extractPrice(val: any): number | null {
if (typeof val === 'number') return val;
if (typeof val === 'string') {
const num = parseFloat(val.replace(/,/g, '.'));
return isFinite(num) ? num : null;
}
return null;
}
private normalizeName(name: string): string {
return name
.toLowerCase()
.replace(/[^a-zåäö0-9\s]/g, '')
.replace(/\s+/g, ' ')
.trim();
}
private normalizeUnit(unit: string | null): string | null {
if (!unit) return null;
const cleaned = unit.trim().toLowerCase().replace(/\./g, '');
return this.UNIT_MAPPING[cleaned] ?? null;
}
private normalizeCategory(category: string | null): string | null {
if (!category) return null;
const normalized = category.trim().toLowerCase();
// Mappning av tänkta kategorivärdena från AI
const categoryMap: Record<string, string> = {
fisk: 'Fisk',
kött: 'Kött',
mejeri: 'Mejeri',
grönsaker: 'Grönsaker',
frukt: 'Frukt',
dryck: 'Dryck',
frukt_grönsaker: 'Frukt & Grönsaker',
fastfood: 'Fastfood',
bröd: 'Bröd',
fryst: 'Fryst',
godis: 'Godis',
pasta: 'Pasta',
};
return categoryMap[normalized] ?? null;
}
private normalizeOfferText(offer: any): string | null {
if (!offer) return null;
if (typeof offer === 'string') {
return offer.trim() || null;
}
if (Array.isArray(offer)) {
const joined = offer.map(String).filter(s => s.trim()).join(' ');
return joined || null;
}
return null;
}
}