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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user