187d0283a5
- 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
159 lines
4.1 KiB
TypeScript
159 lines
4.1 KiB
TypeScript
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;
|
|
}
|
|
}
|