import { BadRequestException, Injectable, Logger, ServiceUnavailableException, } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { ParsedReceiptItem } from './dto/parsed-receipt-item.dto'; import { AiService, CategorySuggestion } from '../ai/ai.service'; import { CategoriesService } from '../categories/categories.service'; const IMPORTER_SERVICE_URL = process.env.IMPORTER_SERVICE_URL || 'http://importer-api:3001'; const WEAK_DESCRIPTORS = new Set([ 'rokt', 'rökt', 'kokt', 'grillad', 'stekt', 'skivad', 'strimlad', 'fryst', 'farsk', 'färsk', ]); function tokenize(value: string): string[] { return value .toLowerCase() .split(/[^a-z0-9åäö]+/) .filter((w) => w.length >= 3); } export function isIgnoredReceiptName(value: string | null | undefined): boolean { const normalized = (value ?? '').trim().toLowerCase(); if (!normalized) return false; if (/^rabatt\b/.test(normalized)) return true; if (/^summa\b/.test(normalized)) return true; if (/^moms\b/.test(normalized)) return true; if (/^pant\b/.test(normalized)) return true; if (/^att\s+betala\b/.test(normalized)) return true; if (/^totalt\b/.test(normalized)) return true; if (/^kort\b/.test(normalized)) return true; if (/^kontant\b/.test(normalized)) return true; if (/^willys\s+plus\s*[:\-]?\b/.test(normalized)) return true; return false; } function normalizeToken(s: string): string { return s.replace(/å/g, 'a').replace(/ä/g, 'a').replace(/ö/g, 'o').replace(/é/g, 'e').replace(/è/g, 'e'); } function normalizeForRules(value: string): string { return value .toLowerCase() .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') .replace(/[^a-z0-9]+/g, ' ') .trim(); } function hasPorkLikeSignal(normalized: string): boolean { return ( normalized.includes('bacon') || normalized.includes('bacn') || normalized.includes('baco') || /\bbac[a-z0-9]{1,5}\b/.test(normalized) || /\bsidflask\b/.test(normalized) || /\bpancetta\b/.test(normalized) || /\bflask\b/.test(normalized) || /\bflaskfile\b/.test(normalized) || /\bkarr[eé]\b/.test(normalized) || /\bkotlett\b/.test(normalized) ); } function hasBreadLikeSignal(normalized: string): boolean { return ( /\brostbrod\b/.test(normalized) || /\brost\s*n\s*toast\b/.test(normalized) || /\broast\s*n\s*toast\b/.test(normalized) || /\btoastbrod\b/.test(normalized) || /\bformbrod\b/.test(normalized) || /\blantbrod\b/.test(normalized) || /\bfullkornsbrod\b/.test(normalized) || /\bfranska\b/.test(normalized) || /\blimpa\b/.test(normalized) || /\bbrod\b/.test(normalized) || /\btoast\b/.test(normalized) ); } function inferPackageDebugFromRawName(rawName: string): { packageCount: number; packQuantity: number | null; packUnit: string | null; } { const normalized = rawName.toLowerCase(); // e.g. "3x120g", "2 x 1.5l" const multiPack = /(\d+)\s*[x×]\s*(\d+(?:[\.,]\d+)?)\s*(ml|cl|dl|l|g|kg)\b/i.exec(normalized); if (multiPack) { const count = Number.parseInt(multiPack[1], 10); const qty = Number.parseFloat(multiPack[2].replace(',', '.')); const unit = multiPack[3].toLowerCase(); return { packageCount: Number.isFinite(count) && count > 0 ? count : 1, packQuantity: Number.isFinite(qty) ? qty : null, packUnit: unit, }; } // e.g. "5dl", "1,5l" const singlePack = /(\d+(?:[\.,]\d+)?)\s*(ml|cl|dl|l|g|kg)\b/i.exec(normalized.replace(/([\d.,]+)(ml|cl|dl|l|g|kg)\b/i, '$1 $2')); if (singlePack) { const qty = Number.parseFloat(singlePack[1].replace(',', '.')); const unit = singlePack[2].toLowerCase(); return { packageCount: 1, packQuantity: Number.isFinite(qty) ? qty : null, packUnit: unit, }; } return { packageCount: 1, packQuantity: null, packUnit: null, }; } @Injectable() export class ReceiptImportService { private readonly logger = new Logger(ReceiptImportService.name); private cachedCategories: any[] = []; constructor( private readonly prisma: PrismaService, private readonly aiService: AiService, private readonly categoriesService: CategoriesService, ) { this.loadCategories(); } async loadCategories() { this.cachedCategories = await this.prisma.category.findMany({ include: { children: true }, }); } async parseReceipt(file: Express.Multer.File, _isPremium = false, userId?: number): Promise { // Steg 1: Delegera AI-parsning till microservice-importer const rawItems = await this.parseReceiptViaImporter(file); // Steg 2: Matchning mot produktdatabas (kräver DB — stannar i recipe-app) const matched = await this.matchProducts(rawItems, userId); // Steg 3: Regel + AI-kategorisering för alla användare return this.enrichWithAiCategories(matched, userId); } async upsertUnitMapping( userId: number, productId: number, originalUnit: string, preferredUnit: string, ) { const prismaAny = this.prisma as any; const normalizedOriginalUnit = originalUnit.trim().toLowerCase(); const normalizedPreferredUnit = preferredUnit.trim().toLowerCase(); if (!normalizedOriginalUnit || !normalizedPreferredUnit) { throw new BadRequestException('Enheter måste vara ifyllda.'); } // Ingen inlärning behövs om enheten redan är samma. if (normalizedOriginalUnit === normalizedPreferredUnit) { return { skipped: true }; } return prismaAny.unitMapping.upsert({ where: { productId_originalUnit_userId: { productId, originalUnit: normalizedOriginalUnit, userId, }, }, update: { preferredUnit: normalizedPreferredUnit, }, create: { productId, userId, originalUnit: normalizedOriginalUnit, preferredUnit: normalizedPreferredUnit, }, }); } private async parseReceiptViaImporter(file: Express.Multer.File): Promise { const form = new FormData(); form.append( 'file', new Blob([new Uint8Array(file.buffer)], { type: file.mimetype }), file.originalname, ); let response: Response; try { response = await fetch(`${IMPORTER_SERVICE_URL}/api/receipt-import/parse`, { method: 'POST', body: form, }); } catch (err) { this.logger.error(`Kunde inte nå importer-api för kvittoparsning: ${err}`); throw new ServiceUnavailableException( 'Import-tjänsten är inte tillgänglig. Försök igen senare.', ); } if (!response.ok) { let message = `Importer svarade ${response.status}`; try { const body = (await response.json()) as { message?: string }; if (body.message) message = body.message; } catch { // ignorera parse-fel } if (response.status === 503 || response.status === 429) { throw new ServiceUnavailableException(message); } throw new BadRequestException(message); } const items = (await response.json()) as ParsedReceiptItem[]; return items.filter((item) => !isIgnoredReceiptName(item.rawName)); } private async matchProducts( items: ParsedReceiptItem[], userId?: number, ): Promise { type UnitMappingLite = { productId: number; originalUnit: string; preferredUnit: string }; type AliasLite = { receiptName: string; product: { id: number; name: string; canonicalName: string | null; categoryRef: { id: number; name: string } | null; }; }; const prismaAny = this.prisma as any; // Hämta alias och produkter parallellt — filtrera på userId om angivet const productFilter = userId ? { isActive: true, ownerId: userId } : { isActive: true }; const aliasFilter = userId ? { OR: [ { ownerId: userId, isGlobal: false }, { isGlobal: true }, ], } : { isGlobal: true }; const unitMappingsPromise = userId && prismaAny.unitMapping?.findMany ? (prismaAny.unitMapping.findMany({ where: { userId }, select: { productId: true, originalUnit: true, preferredUnit: true }, }) as Promise) : Promise.resolve([] as UnitMappingLite[]); const [aliases, products, unitMappings] = await Promise.all([ this.prisma.receiptAlias.findMany({ where: aliasFilter, orderBy: [ { isGlobal: 'asc' }, { id: 'asc' }, ], select: { receiptName: true, productId: true, product: { select: { id: true, name: true, canonicalName: true, categoryId: true, categoryRef: { select: { id: true, name: true } } } } }, }), this.prisma.product.findMany({ where: productFilter, select: { id: true, name: true, canonicalName: true, categoryId: true, categoryRef: { select: { id: true, name: true } } }, }), unitMappingsPromise, ]) as [AliasLite[], { id: number; name: string; canonicalName: string | null; categoryId: number | null; categoryRef: { id: number; name: string } | null }[], UnitMappingLite[]]; return items.map((item) => { const raw = (item.rawName ?? '').toLowerCase().trim(); if (!raw) return item; // 1. Alias-match (säker, användaren behöver inte bekräfta) const alias = aliases.find((a: AliasLite) => a.receiptName === raw); if (alias) { const mappedUnit = unitMappings.find( (um) => um.productId === alias.product.id && um.originalUnit === (item.unit ?? '').trim().toLowerCase(), )?.preferredUnit; const cat = alias.product.categoryRef; return { ...item, matchedProductId: alias.product.id, matchedProductName: alias.product.canonicalName ?? alias.product.name, unit: mappedUnit ?? item.unit, ...(cat ? { categorySuggestion: { categoryId: cat.id, categoryName: cat.name, path: cat.name, confidence: 'high' as const, usedFallback: false } } : {}), }; } // 2. Ordbaserad matchning (förslag, kräver bekräftelse) const suggestion = this.findWordMatch(raw, products); if (!suggestion) { return { ...item }; } // Kontrollera om det finns en enhetsmappning för produkten och användaren const unitMapping = unitMappings.find( (um) => um.productId === suggestion.id && um.originalUnit === (item.unit ?? '').trim().toLowerCase(), ); const preferredUnit = unitMapping ? unitMapping.preferredUnit : item.unit; const cat = suggestion.categoryRef; return { ...item, suggestedProductId: suggestion.id, suggestedProductName: suggestion.canonicalName ?? suggestion.name, unit: preferredUnit, ...(cat ? { categorySuggestion: { categoryId: cat.id, categoryName: cat.name, path: cat.name, confidence: 'medium' as const, usedFallback: false } } : {}), }; }); } private findWordMatch( raw: string, products: { id: number; name: string; canonicalName: string | null; categoryId: number | null; categoryRef: { id: number; name: string } | null }[], ): { id: number; name: string; canonicalName: string | null; categoryId: number | null; categoryRef: { id: number; name: string } | null } | undefined { // Dela upp kvittonamnet i ord (min 3 tecken) const rawWords = tokenize(raw); if (rawWords.length === 0) return undefined; const rawWordSet = new Set(rawWords); // Normaliserade versioner (utan diakritik) för att hantera t.ex. gradde == grädde const rawWordsNorm = rawWords.map(normalizeToken); const rawWordSetNorm = new Set(rawWordsNorm); let best: | { product: { id: number; name: string; canonicalName: string | null; categoryId: number | null; categoryRef: { id: number; name: string } | null }; score: number } | undefined; for (const product of products) { const productWords = tokenize(product.canonicalName ?? product.name); if (productWords.length === 0) continue; let score = 0; let exactStrong = 0; let exactAny = 0; let partialStrong = 0; const phrase = (product.canonicalName ?? product.name).toLowerCase(); if (raw.includes(phrase)) { score += 5; } for (const pw of productWords) { const isWeak = WEAK_DESCRIPTORS.has(pw); const pwNorm = normalizeToken(pw); if (rawWordSet.has(pw) || rawWordSetNorm.has(pwNorm)) { exactAny += 1; if (isWeak) { score += 1; } else { exactStrong += 1; score += 8; } continue; } // Delmatchning tillåts bara för ord med minst 4 tecken. if (pw.length < 4) continue; const hasPartial = rawWords.some((rw) => rw.includes(pw) || pw.includes(rw)) || rawWordsNorm.some((rw) => rw.includes(pwNorm) || pwNorm.includes(rw)); if (!hasPartial) continue; if (isWeak) { // Deskriptiva ord (t.ex. rökt) ska inte driva förslag ensamma. continue; } partialStrong += 1; score += 3; } // Kräv antingen minst ett starkt exakt ord, eller flera samverkande signaler. // Undantag: ett enstaka starkt partiellt ord (>=5 tecken) räcker, t.ex. vispgrädde → grädde. const hasLongPartial = partialStrong >= 1 && productWords.some((pw) => pw.length >= 5); const hasStrongSignal = exactStrong >= 1 || exactAny + partialStrong >= 2 || hasLongPartial; if (!hasStrongSignal) continue; // Tröskel för att undvika svaga enkelträffar. if (score < 8) continue; if (!best || score > best.score) { best = { product, score }; } } return best?.product; } private async enrichWithAiCategories(items: ParsedReceiptItem[], userId?: number): Promise { let categories: Awaited>; try { categories = await this.categoriesService.findFlattened(); } catch { return items; // Om kategoritjänsten är otillgänglig, returnera utan AI-förslag } const user = userId ? await this.prisma.user.findUnique({ where: { id: userId }, select: { aiEngineEnabled: true }, }) : null; const enriched: ParsedReceiptItem[] = []; for (const item of items) { if (!item.rawName) { enriched.push(item); continue; } try { const signalText = [ item.rawName, item.matchedProductName, item.suggestedProductName, ] .filter((v): v is string => typeof v === 'string' && v.trim().length > 0) .join(' '); const trace: string[] = []; const traceEnabled = this.shouldTraceDecision(signalText || item.rawName); const pushTrace = (msg: string) => { if (traceEnabled) trace.push(msg); }; const pkg = inferPackageDebugFromRawName(item.rawName); pushTrace( `start raw="${item.rawName}" signal="${signalText || item.rawName}" parsedQuantity=${item.quantity ?? 'null'} parsedUnit=${item.unit ?? 'null'} packageCount=${pkg.packageCount} packQuantity=${pkg.packQuantity ?? 'null'} packUnit=${pkg.packUnit ?? 'null'}`, ); pushTrace( `match matchedProductId=${item.matchedProductId ?? 'null'} suggestedProductId=${item.suggestedProductId ?? 'null'}`, ); if (item.categorySuggestion) { pushTrace( `incoming category="${item.categorySuggestion.path}" confidence=${item.categorySuggestion.confidence} fallback=${item.categorySuggestion.usedFallback}`, ); } const byRule = this.ruleBasedCategorySuggestion(signalText || item.rawName, categories); if (byRule) { pushTrace(`rule hit -> "${byRule.path}" (${byRule.confidence})`); } else { pushTrace('rule miss'); } let nextSuggestion = item.categorySuggestion ?? null; const isTrustedSuggestion = nextSuggestion?.confidence === 'high' && !nextSuggestion.usedFallback; // Regel med stark signal får överstyra svaga förslag (och även felaktiga matchningsförslag) if (byRule?.confidence === 'high') { const sameAsCurrent = nextSuggestion != null && nextSuggestion.categoryId === byRule.categoryId; if (sameAsCurrent && nextSuggestion && nextSuggestion.confidence !== 'high') { nextSuggestion = { ...nextSuggestion, confidence: 'high' }; pushTrace(`rule applied -> "${byRule.path}" (confidence upgraded to high)`); } else if (!sameAsCurrent && (!isTrustedSuggestion || nextSuggestion == null)) { nextSuggestion = byRule; pushTrace(`rule applied -> "${byRule.path}"`); } // Om regler säger en annan kategori än ett redan "trusted" förslag, // låt regeln vinna för att bryta felaktiga historiska produktkopplingar. if (!sameAsCurrent && isTrustedSuggestion) { this.logger.log( `Rule-override: "${item.rawName}" ändras från "${nextSuggestion?.path}" till "${byRule.path}"`, ); nextSuggestion = byRule; pushTrace(`rule override trusted -> "${byRule.path}"`); } } else if (!nextSuggestion && byRule) { nextSuggestion = byRule; pushTrace(`rule fallback applied -> "${byRule.path}"`); } // AI används som fallback när varken matchning eller regler satte kategori if (!nextSuggestion) { if (user?.aiEngineEnabled) { pushTrace('ai invoked'); nextSuggestion = await this.aiService.suggestCategory(item.rawName, categories); pushTrace(`ai result -> "${nextSuggestion.path}" (${nextSuggestion.confidence})`); } else { pushTrace('ai skipped, feature disabled'); } } else { pushTrace(`ai skipped, current -> "${nextSuggestion.path}"`); } const beforeGuardPath = nextSuggestion?.path; const guardedSuggestion = nextSuggestion ? this.applyContradictionGuard(signalText || item.rawName, nextSuggestion, categories) : null; if (guardedSuggestion && beforeGuardPath !== guardedSuggestion.path) { pushTrace(`contradiction guard remap "${beforeGuardPath}" -> "${guardedSuggestion.path}"`); } const beforeHardPath = guardedSuggestion?.path; const finalSuggestion = guardedSuggestion ? this.applyHardCategoryOverrides(signalText || item.rawName, guardedSuggestion, categories) : null; if (finalSuggestion && beforeHardPath !== finalSuggestion.path) { pushTrace(`hard override remap "${beforeHardPath}" -> "${finalSuggestion.path}"`); } if (finalSuggestion) { pushTrace(`final -> "${finalSuggestion.path}" (${finalSuggestion.confidence})`); } else { pushTrace('final -> no categorySuggestion'); } if (traceEnabled) { this.logger.log(`[ReceiptDecision] ${trace.join(' | ')}`); } enriched.push( finalSuggestion ? { ...item, categorySuggestion: finalSuggestion } : item, ); } catch (err) { const traceSignalText = [ item.rawName, item.matchedProductName, item.suggestedProductName, ] .filter((v): v is string => typeof v === 'string' && v.trim().length > 0) .join(' '); if (this.shouldTraceDecision(traceSignalText || item.rawName)) { this.logger.warn( `[ReceiptDecision] error raw="${item.rawName}" signal="${traceSignalText || item.rawName}" err=${String(err)}`, ); } // Om AI-anrop misslyckas för enskild vara — hoppa över utan att kasta enriched.push(item); } } return enriched; } private shouldTraceDecision(signalText: string): boolean { const envFlag = (process.env.RECEIPT_TRACE_DECISIONS ?? '').toLowerCase(); if (envFlag === '1' || envFlag === 'true' || envFlag === 'yes') { return true; } const normalized = normalizeForRules(signalText); return hasPorkLikeSignal(normalized); } private resolvePorkCategory( categories: Awaited>, ) { return ( categories.find( (c) => c.name.toLowerCase() === 'fläsk' && c.path.toLowerCase().startsWith('kött, chark & fågel > kött > '), ) || categories.find( (c) => c.name.toLowerCase() === 'kött' && c.path.toLowerCase() === 'kött, chark & fågel > kött', ) || categories.find((c) => c.path.toLowerCase() === 'kött, chark & fågel') ); } private resolveBreadCategory( categories: Awaited>, ) { return ( categories.find( (c) => c.name.toLowerCase() === 'rostbröd' && c.path.toLowerCase().startsWith('bröd & kakor > bröd > '), ) || categories.find( (c) => c.name.toLowerCase() === 'bröd' && c.path.toLowerCase() === 'bröd & kakor > bröd', ) || categories.find((c) => c.path.toLowerCase() === 'bröd & kakor') ); } private applyHardCategoryOverrides( signalText: string, suggestion: CategorySuggestion, categories: Awaited>, ): CategorySuggestion { const normalized = normalizeForRules(signalText); const hasBaconLikeSignal = hasPorkLikeSignal(normalized); if (!hasBaconLikeSignal) return suggestion; const l3Pork = this.resolvePorkCategory(categories); if (!l3Pork) { this.logger.warn(`Hard-override: pork signal hittad men ingen köttkategori kunde hittas för "${signalText}"`); return suggestion; } if (suggestion.categoryId === l3Pork.id) return suggestion; this.logger.log( `Hard-override: "${signalText}" remappas från "${suggestion.path}" till "${l3Pork.path}"`, ); return { categoryId: l3Pork.id, categoryName: l3Pork.name, path: l3Pork.path, confidence: 'high', usedFallback: true, }; } private ruleBasedCategorySuggestion( rawName: string, categories: Awaited>, ): CategorySuggestion | null { const normalized = normalizeForRules(rawName); const findCategory = (opts: { name: string; startsWith?: string; includes?: string; }) => categories.find((c) => { const cName = c.name.toLowerCase(); const cPath = c.path.toLowerCase(); if (cName !== opts.name.toLowerCase()) return false; if (opts.startsWith && !cPath.startsWith(opts.startsWith.toLowerCase())) return false; if (opts.includes && !cPath.includes(opts.includes.toLowerCase())) return false; return true; }); const toSuggestion = ( cat: { id: number; name: string; path: string } | undefined, confidence: 'high' | 'medium' = 'high', ): CategorySuggestion | null => { if (!cat) return null; return { categoryId: cat.id, categoryName: cat.name, path: cat.path, confidence, usedFallback: false, }; }; // ── Regel: Kött/chark (bacon/fläsk m.m.) ──────────────────────────── const hasPorkSignal = hasPorkLikeSignal(normalized); const hasToastBreadSignal = hasBreadLikeSignal(normalized); if (hasToastBreadSignal) { const bread = this.resolveBreadCategory(categories); const hit = toSuggestion(bread, 'high'); if (hit) return hit; } if (hasPorkSignal) { const l3Pork = this.resolvePorkCategory(categories); const hit = toSuggestion(l3Pork, 'high'); if (hit) return hit; } // ── Regel: Korvfamiljen ───────────────────────────────────────────── const hasSausageSignal = /\bkorv\b/.test(normalized) || /\bfalukorv\b/.test(normalized) || /\bchorizo\b/.test(normalized) || /\bbratwurst\b/.test(normalized) || /\bwienerkorv\b/.test(normalized) || /\bgrillkorv\b/.test(normalized) || /\bprinskorv\b/.test(normalized) || /\bolkorv\b/.test(normalized); if (hasSausageSignal) { const isVegetarian = /\bvegetarisk\b/.test(normalized) || /\bvegansk\b/.test(normalized) || /\bvego\b/.test(normalized); if (isVegetarian) { const vegSausage = findCategory({ name: 'vegetarisk korv', startsWith: 'kött, chark & fågel > korv > ', }); const hit = toSuggestion(vegSausage, 'high'); if (hit) return hit; } if (/\bolkorv\b/.test(normalized)) { const beerSausage = findCategory({ name: 'ölkorv', startsWith: 'kött, chark & fågel > korv > ', }); const hit = toSuggestion(beerSausage, 'high'); if (hit) return hit; } const sausageGeneral = findCategory({ name: 'grill, kok- & kryddkorv', startsWith: 'kött, chark & fågel > korv > ', }); const hit = toSuggestion(sausageGeneral, 'high'); if (hit) return hit; } // ── Regel: Fågel (färsk/fryst) ────────────────────────────────────── const hasPoultrySignal = /\bkyckling\b/.test(normalized) || /\bkalkon\b/.test(normalized) || /\bdrumsticks?\b/.test(normalized) || /\bling?file\b/.test(normalized) || /\blarfile\b/.test(normalized) || /\bkycklinglar\b/.test(normalized); if (hasPoultrySignal) { const isFrozen = /\bfryst\b/.test(normalized) || /\bdjupfryst\b/.test(normalized); if (isFrozen) { const frozenPoultry = findCategory({ name: 'fryst fågel', startsWith: 'kött, chark & fågel > fågel > ', }); const hit = toSuggestion(frozenPoultry, 'high'); if (hit) return hit; } const freshPoultry = findCategory({ name: 'färsk fågel', startsWith: 'kött, chark & fågel > fågel > ', }); const hit = toSuggestion(freshPoultry, 'high'); if (hit) return hit; } // ── Regel: Pålägg (korv/salami vs skivat) ─────────────────────────── const hasColdCutSignal = /\bpalagg\b/.test(normalized) || /\bpalegg\b/.test(normalized) || /\bskivad\b/.test(normalized) || /\bsalami\b/.test(normalized) || /\bmedvurst\b/.test(normalized) || /\bpastrami\b/.test(normalized) || /\bskinka\b/.test(normalized); if (hasColdCutSignal) { const hasSlicedSausageSignal = /\bsalami\b/.test(normalized) || /\bmedvurst\b/.test(normalized) || /\bpastrami\b/.test(normalized); if (hasSlicedSausageSignal) { const salamiColdCut = findCategory({ name: 'korv & salami', startsWith: 'kött, chark & fågel > pålägg > ', }); const hit = toSuggestion(salamiColdCut, 'high'); if (hit) return hit; } const slicedColdCut = findCategory({ name: 'skivat pålägg', startsWith: 'kött, chark & fågel > pålägg > ', }); const hit = toSuggestion(slicedColdCut, 'high'); if (hit) return hit; } // ── Regel: Färs ────────────────────────────────────────────────────── const hasMinceSignal = /\bfars\b/.test(normalized) || /\bfarse\b/.test(normalized) || /\bmince\b/.test(normalized) || /\bköttfärs\b/.test(rawName.toLowerCase()); if (hasMinceSignal && !hasPoultrySignal) { const mince = findCategory({ name: 'köttfärs', startsWith: 'kött, chark & fågel > kött > ', }); const hit = toSuggestion(mince, 'high'); if (hit) return hit; } // ── Regel: Nöt/kalv (hela detaljer) ───────────────────────────────── const hasBeefVealSignal = /\bnot\b/.test(normalized) || /\bkalv\b/.test(normalized) || /\bbiff\b/.test(normalized) || /\bentrecote\b/.test(normalized) || /\brostbiff\b/.test(normalized) || /\bryggbiff\b/.test(normalized); if (hasBeefVealSignal && !hasMinceSignal) { const beefVeal = findCategory({ name: 'nöt & kalv', startsWith: 'kött, chark & fågel > kött > ', }); const hit = toSuggestion(beefVeal, 'high'); if (hit) return hit; } // ── Regel: Pasta (inkl. italienska formatnamn) ───────────────────── const hasPastaSignal = /\bmezze\b/.test(normalized) || /\bmaniche\b/.test(normalized) || /\bpenne\b/.test(normalized) || /\brigatoni\b/.test(normalized) || /\bfusilli\b/.test(normalized) || /\bspaghetti\b/.test(normalized) || /\btagliatelle\b/.test(normalized) || /\bmakaron\w*\b/.test(normalized) || /\bgnocchi\b/.test(normalized) || /\blasagne\b/.test(normalized) || /\bpasta\b/.test(normalized); if (hasPastaSignal) { const freshPasta = findCategory({ name: 'färsk pasta', startsWith: 'skafferi > pasta, ris & matgryn > ', }); if (/\bfarsk\b/.test(normalized) || /\bfresh\b/.test(normalized)) { const freshHit = toSuggestion(freshPasta, 'high'); if (freshHit) return freshHit; } const pasta = findCategory({ name: 'pasta', startsWith: 'skafferi > pasta, ris & matgryn > ', }); const pastaHit = toSuggestion(pasta, 'high'); if (pastaHit) return pastaHit; } // ── Regel: Grädde/matlagningsgrädde (icke-allergi) ───────────────── const hasCreamSignal = /\bvispgradde\b/.test(normalized) || /\bmatlagningsgradde\b/.test(normalized) || /\bgradde\b/.test(normalized) || /\bcreme\s+fraiche\b/.test(normalized) || /\bgraddfil\b/.test(normalized); const hasPlantOrAllergySignal = /\blaktosfri\b/.test(normalized) || /\bvegetabilisk\b/.test(normalized) || /\bhavre\b/.test(normalized) || /\bsoja\b/.test(normalized) || /\brisdryck\b/.test(normalized) || /\bplant\b/.test(normalized); if (hasCreamSignal && !hasPlantOrAllergySignal) { const l3Cream = findCategory({ name: 'grädde', startsWith: 'mejeri, ost & ägg > matlagning > ', }); const l3Hit = toSuggestion(l3Cream, 'high'); if (l3Hit) return l3Hit; const l2CookingDairy = findCategory({ name: 'matlagning', startsWith: 'mejeri, ost & ägg > ', }); const hit = toSuggestion(l2CookingDairy, 'high'); if (hit) return hit; } // ── Regel: Vanlig mjölk (inte laktosfri/allergi) ─────────────────── const hasMilkSignal = /\bmjolk\b/.test(normalized) || /\bstandardmjolk\b/.test(normalized) || /\bstandmjolk\b/.test(normalized) || /\besl\b/.test(normalized); const hasLactoseFreeSignal = /\blaktosfri\b/.test(normalized) || /\blactose\s*free\b/.test(normalized); if (hasMilkSignal && !hasPlantOrAllergySignal && !hasLactoseFreeSignal) { const l3StandardMilk = findCategory({ name: 'standardmjölk', startsWith: 'mejeri, ost & ägg > mjölk > ', }); const hit = toSuggestion(l3StandardMilk, 'high'); if (hit) return hit; const l2Milk = findCategory({ name: 'mjölk', startsWith: 'mejeri, ost & ägg > ', }); const fallbackHit = toSuggestion(l2Milk, 'high'); if (fallbackHit) return fallbackHit; } // ── Regel: Ägg ────────────────────────────────────────────────────── const hasEggSignal = /\bagg\b/.test(normalized) || /\begg\b/.test(normalized) || /\binne\b/.test(normalized) || /\b24p\b/.test(normalized); if (hasEggSignal) { const l2Egg = categories.find( (c) => c.name.toLowerCase() === 'ägg' && c.path.toLowerCase() === 'mejeri, ost & ägg > ägg', ); if (l2Egg) { return { categoryId: l2Egg.id, categoryName: l2Egg.name, path: l2Egg.path, confidence: 'high', usedFallback: false, }; } const l1DairyEgg = categories.find( (c) => c.path.toLowerCase() === 'mejeri, ost & ägg', ); if (l1DairyEgg) { return { categoryId: l1DairyEgg.id, categoryName: l1DairyEgg.name, path: l1DairyEgg.path, confidence: 'high', usedFallback: false, }; } } // ── Regel: Juice/fruktdryck/smoothie ─────────────────────────────── const hasJuiceSignal = /\bjuice\b/.test(normalized) || /\bnektar\b/.test(normalized) || /\bfruktdryck\b/.test(normalized) || /\bsmoothie\b/.test(normalized) || /\bmultivitamin\b/.test(normalized); if (hasJuiceSignal) { const l3ColdJuice = findCategory({ name: 'kyld juice & nektar', startsWith: 'dryck > juice, fruktdryck & smoothie > ', }); const l3Hit = toSuggestion(l3ColdJuice, 'high'); if (l3Hit) return l3Hit; const l2Juice = findCategory({ name: 'juice, fruktdryck & smoothie', startsWith: 'dryck > ', }); const l2Hit = toSuggestion(l2Juice, 'high'); if (l2Hit) return l2Hit; } // ── Regel: Te ──────────────────────────────────────────────────────── const isTea = /\bte\b/.test(normalized) || /\btea\b/.test(normalized) || /\bchai\b/.test(normalized) || /\btepa(se|k|r)?\b/.test(normalized); if (isTea) { const l3Te = categories.find( (c) => c.name.toLowerCase() === 'te' && c.path.toLowerCase().includes('te & choklad'), ); if (l3Te) { return { categoryId: l3Te.id, categoryName: l3Te.name, path: l3Te.path, confidence: 'high', usedFallback: false }; } const l2TeChoklad = categories.find( (c) => c.name.toLowerCase() === 'te & choklad' && c.path.toLowerCase().startsWith('dryck'), ); if (l2TeChoklad) { return { categoryId: l2TeChoklad.id, categoryName: l2TeChoklad.name, path: l2TeChoklad.path, confidence: 'medium', usedFallback: false }; } } // ── Regel: Kaffebröd ───────────────────────────────────────────────── const isKaffebrod = /\bkaffebrod\b/.test(normalized) || /\bwienerbrod\b/.test(normalized) || /\bdonut\b/.test(normalized) || /\bmunk\b/.test(normalized) || /\bcroissant\b/.test(normalized) || /\bkanelbulle\b/.test(normalized) || /\bbakelse\b/.test(normalized) || /\bsemla\b/.test(normalized) || /\bdammsugare\b/.test(normalized) || /\bkladdkaka\b/.test(normalized) || /\bmuffin\b/.test(normalized) || /\bcupcake\b/.test(normalized) || /\bchokladboll\b/.test(normalized); if (isKaffebrod) { const l3Kaffebrod = categories.find( (c) => c.name.toLowerCase() === 'kaffebröd' && c.path.toLowerCase().includes('kondis & fika'), ); if (l3Kaffebrod) { return { categoryId: l3Kaffebrod.id, categoryName: l3Kaffebrod.name, path: l3Kaffebrod.path, confidence: 'high', usedFallback: false }; } const l2Kondis = categories.find( (c) => c.name.toLowerCase() === 'kondis & fika' && c.path.toLowerCase().startsWith('bröd & kakor'), ); if (l2Kondis) { return { categoryId: l2Kondis.id, categoryName: l2Kondis.name, path: l2Kondis.path, confidence: 'medium', usedFallback: false }; } } // ── Regel: Godis/chokladkakor ────────────────────────────────────── const isChocolateBar = /\bsnickers\b/.test(normalized) || /\bmars\b/.test(normalized) || /\btwix\b/.test(normalized) || /\bbounty\b/.test(normalized) || /\bkitkat\b/.test(normalized) || /\bdajm\b/.test(normalized) || /\bjapp\b/.test(normalized); if (isChocolateBar) { const l3ChocolateBars = findCategory({ name: 'chokladkakor & rullar', startsWith: 'glass, godis & snacks > choklad > ', }); const hit = toSuggestion(l3ChocolateBars, 'high'); if (hit) return hit; } const isCandyBagLike = /\bnappar\b/.test(normalized) || /\bgodispas\w*\b/.test(normalized); if (isCandyBagLike) { const l3CandyBag = findCategory({ name: 'godispåsar', startsWith: 'glass, godis & snacks > godis > ', }); const hit = toSuggestion(l3CandyBag, 'high'); if (hit) return hit; } // ── Regel: Potatis (färsk) ───────────────────────────────────────── const hasPotatoSignal = /\bpotatis\b/.test(normalized); const hasFrozenPotatoSignal = /\bfryst\b/.test(normalized) || /\bdjupfryst\b/.test(normalized) || /\bpommes\b/.test(normalized) || /\bstrips?\b/.test(normalized); if (hasPotatoSignal && !hasFrozenPotatoSignal) { const l3Potato = findCategory({ name: 'potatis', startsWith: 'frukt & grönt > potatis & rotsaker > ', }); const l3Hit = toSuggestion(l3Potato, 'high'); if (l3Hit) return l3Hit; } // ── Regel: Laktosfri/växtbaserad mejeri ────────────────────────────── const isCookingBase = /\bmatlagningsbas\b/.test(normalized) || /\bmatlagnings\b/.test(normalized) || /\bplant\s+cream\b/.test(normalized) || /\bcreme\s+fraiche\b/.test(normalized) || /\bgradde\b/.test(normalized) || /\bvispgradde\b/.test(normalized); const isPlantOrAllergy = /\blaktosfri\b/.test(normalized) || /\bvegetabilisk\b/.test(normalized) || /\bhavre\b/.test(normalized) || /\bsoja\b/.test(normalized) || /\brisdryck\b/.test(normalized) || /\bplant\b/.test(normalized); if (!isCookingBase || !isPlantOrAllergy) return null; const l3AllergyCooking = categories.find( (c) => c.name.toLowerCase() === 'allergi matlagning' && c.path.toLowerCase().startsWith('matlagning > '), ); if (l3AllergyCooking) { return { categoryId: l3AllergyCooking.id, categoryName: l3AllergyCooking.name, path: l3AllergyCooking.path, confidence: 'high', usedFallback: false, }; } const l2Cooking = categories.find( (c) => c.name.toLowerCase() === 'matlagning' && c.path.toLowerCase() === 'mejeri, ost & ägg > matlagning', ); if (l2Cooking) { return { categoryId: l2Cooking.id, categoryName: l2Cooking.name, path: l2Cooking.path, confidence: 'medium', usedFallback: false, }; } return null; } private applyContradictionGuard( rawName: string, suggestion: CategorySuggestion, categories: Awaited>, ): CategorySuggestion { const normalized = normalizeForRules(rawName); const hasPorkSignal = hasPorkLikeSignal(normalized); if (hasPorkSignal) { const aiPath = suggestion.path.toLowerCase(); const isClearlyWrongBranch = aiPath.includes('köttbullar & färsprodukter') || aiPath.includes('köttfärs'); if (!isClearlyWrongBranch) return suggestion; const l3Pork = this.resolvePorkCategory(categories); if (!l3Pork) return suggestion; this.logger.log( `AI contradiction-guard: "${rawName}" remappas från "${suggestion.path}" till "${l3Pork.path}"`, ); return { categoryId: l3Pork.id, categoryName: l3Pork.name, path: l3Pork.path, confidence: 'high', usedFallback: true, }; } const hasMilkSignal = /\bmjolk\b/.test(normalized) || /\bstandardmjolk\b/.test(normalized) || /\bstandmjolk\b/.test(normalized) || /\besl\b/.test(normalized); const hasLactoseFreeSignal = /\blaktosfri\b/.test(normalized) || /\blactose\s*free\b/.test(normalized); if (hasMilkSignal && !hasLactoseFreeSignal) { const isWrongLactoseFreeBranch = suggestion.path.toLowerCase().includes('allergi mejeri > laktosfri mjölk'); if (isWrongLactoseFreeBranch) { const l3StandardMilk = categories.find( (c) => c.name.toLowerCase() === 'standardmjölk' && c.path.toLowerCase().startsWith('mejeri, ost & ägg > mjölk > '), ); if (l3StandardMilk) { this.logger.log( `AI contradiction-guard: "${rawName}" remappas från "${suggestion.path}" till "${l3StandardMilk.path}"`, ); return { categoryId: l3StandardMilk.id, categoryName: l3StandardMilk.name, path: l3StandardMilk.path, confidence: 'high', usedFallback: true, }; } } } const hasEggSignal = /\bagg\b/.test(normalized) || /\begg\b/.test(normalized) || /\binne\b/.test(normalized) || /\b24p\b/.test(normalized); if (hasEggSignal && suggestion.path.toLowerCase().includes('allergi mejeri')) { const l2Egg = categories.find( (c) => c.name.toLowerCase() === 'ägg' && c.path.toLowerCase() === 'mejeri, ost & ägg > ägg', ); if (l2Egg) { this.logger.log( `AI contradiction-guard: "${rawName}" remappas från "${suggestion.path}" till "${l2Egg.path}"`, ); return { categoryId: l2Egg.id, categoryName: l2Egg.name, path: l2Egg.path, confidence: 'high', usedFallback: true, }; } const l1DairyEgg = categories.find( (c) => c.path.toLowerCase() === 'mejeri, ost & ägg', ); if (l1DairyEgg) { this.logger.log( `AI contradiction-guard: "${rawName}" remappas från "${suggestion.path}" till "${l1DairyEgg.path}"`, ); return { categoryId: l1DairyEgg.id, categoryName: l1DairyEgg.name, path: l1DairyEgg.path, confidence: 'high', usedFallback: true, }; } } const hasCreamSignal = /\bvispgradde\b/.test(normalized) || /\bmatlagningsgradde\b/.test(normalized) || /\bgradde\b/.test(normalized) || /\bcreme\s+fraiche\b/.test(normalized) || /\bgraddfil\b/.test(normalized); const hasPlantOrAllergySignal = /\blaktosfri\b/.test(normalized) || /\bvegetabilisk\b/.test(normalized) || /\bhavre\b/.test(normalized) || /\bsoja\b/.test(normalized) || /\brisdryck\b/.test(normalized) || /\bplant\b/.test(normalized); if (hasCreamSignal && !hasPlantOrAllergySignal) { const aiPath = suggestion.path.toLowerCase(); const isOutsideDairy = !aiPath.startsWith('mejeri, ost & ägg > matlagning'); if (!isOutsideDairy) return suggestion; const l2CookingDairy = categories.find( (c) => c.name.toLowerCase() === 'matlagning' && c.path.toLowerCase() === 'mejeri, ost & ägg > matlagning', ); if (!l2CookingDairy) return suggestion; const l3Cream = categories.find( (c) => c.name.toLowerCase() === 'grädde' && c.path.toLowerCase().startsWith('mejeri, ost & ägg > matlagning > '), ); if (l3Cream) { this.logger.log( `AI contradiction-guard: "${rawName}" remappas från "${suggestion.path}" till "${l3Cream.path}"`, ); return { categoryId: l3Cream.id, categoryName: l3Cream.name, path: l3Cream.path, confidence: 'high', usedFallback: true, }; } this.logger.log( `AI contradiction-guard: "${rawName}" remappas från "${suggestion.path}" till "${l2CookingDairy.path}"`, ); return { categoryId: l2CookingDairy.id, categoryName: l2CookingDairy.name, path: l2CookingDairy.path, confidence: 'high', usedFallback: true, }; } const hasToastBreadSignal = hasBreadLikeSignal(normalized); if (hasToastBreadSignal) { const aiPath = suggestion.path.toLowerCase(); const isOutsideBread = !aiPath.startsWith('bröd & kakor > bröd'); if (!isOutsideBread) return suggestion; const bread = this.resolveBreadCategory(categories); if (!bread) return suggestion; this.logger.log( `AI contradiction-guard: "${rawName}" remappas från "${suggestion.path}" till "${bread.path}"`, ); return { categoryId: bread.id, categoryName: bread.name, path: bread.path, confidence: 'high', usedFallback: true, }; } return suggestion; } }