import { BadRequestException, Injectable, Logger, ServiceUnavailableException, } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { ParsedReceiptItem } from './dto/parsed-receipt-item.dto'; import { SaveReceiptDto } from './dto/save-receipt.dto'; import { SaveReceiptResponse } from './dto/save-receipt.response'; import { AiService, CategorySuggestion } from '../ai/ai.service'; import { CategoriesService } from '../categories/categories.service'; import { normalizeName } from '../common/utils/normalize-name'; import { isIgnoredReceiptAliasName, normalizeReceiptAliasName, validateReceiptAliasName, } from '../common/utils/receipt-alias'; import { FlyerSelectionService } from '../flyer-selection/flyer-selection.service'; const IMPORTER_SERVICE_URL = process.env.IMPORTER_SERVICE_URL || 'http://importer-api:3001'; const RECEIPT_IMPORT_MODEL = 'importer-receipt-ai'; 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 { return isIgnoredReceiptAliasName(value); } 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) || normalized.includes('sidflask') || normalized.includes('pancetta') || normalized.includes('flask') || normalized.includes('karre') || normalized.includes('kotlett') ); } 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) ); } type UnitMappingLite = { productId: number; originalUnit: string; preferredUnit: string; }; type ProductLite = { id: number; name: string; canonicalName: string | null; categoryRef: { id: number; name: string } | null; }; type AliasLite = { receiptName: string; product: ProductLite; }; type MatchingContext = { aliases: AliasLite[]; aliasByReceiptName?: Map; products: ProductLite[]; unitMappings: UnitMappingLite[]; unitMappingByKey?: Map; categories: Awaited>; aiEnabled: boolean; }; type MatchDebug = { steps: string[]; tree: Record; }; @Injectable() export class ReceiptImportService { private readonly logger = new Logger(ReceiptImportService.name); constructor( private readonly prisma: PrismaService, private readonly aiService: AiService, private readonly categoriesService: CategoriesService, private readonly flyerSelectionService: FlyerSelectionService, ) {} async parseReceipt(file: Express.Multer.File, _isPremium = false, userId?: number): Promise { const parseStartedAt = Date.now(); let parseError: string | null = null; let tracePrompt: string | null = null; let traceRawOutput: string | null = null; let traceNormalizedOutput: Record | null = null; // Steg 1: Delegera AI-parsning till microservice-importer let rawItems: ParsedReceiptItem[]; try { const importer = await this.parseReceiptViaImporter(file); rawItems = importer.items; tracePrompt = importer.trace.prompt; traceRawOutput = importer.trace.rawOutput; traceNormalizedOutput = importer.trace.normalizedOutput; } catch (err) { parseError = err instanceof Error ? err.message : String(err); await this.persistReceiptTrace({ userId, model: RECEIPT_IMPORT_MODEL, prompt: tracePrompt, rawOutput: traceRawOutput, normalizedOutput: traceNormalizedOutput, status: 'error', error: parseError, durationMs: Date.now() - parseStartedAt, }); throw err; } // Steg 2 & 3: Unified matching + categorization // Samla context en gång för alla items const context = await this.prepareMatchingContext(userId); // Mappa alla items genom unified matcher const parsedItems = await Promise.all( rawItems.map((item) => this.matchAndEnrichReceiptItem(item, context)), ); await this.persistReceiptTrace({ userId, model: RECEIPT_IMPORT_MODEL, prompt: tracePrompt, rawOutput: traceRawOutput, normalizedOutput: { importer: traceNormalizedOutput, enrichedItems: parsedItems, }, status: parsedItems.length == 0 ? 'error' : 'success', error: parsedItems.length == 0 ? 'Inga kvittorader kunde tolkas av importer-tjänsten.' : null, durationMs: Date.now() - parseStartedAt, }); return parsedItems; } private async prepareMatchingContext(userId?: number): Promise { const prismaAny = this.prisma as any; 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[]); let categories: Awaited>; try { categories = await this.categoriesService.findFlattened(); } catch (err) { this.logger.warn( `prepareMatchingContext: kunde inte ladda kategorier (${String(err)}). Kategori-förslag kan utebli.`, ); categories = []; } if (categories.length === 0) { this.logger.warn( 'prepareMatchingContext: inga kategorier laddade. Regel-baserade kategori-förslag blir tomma.', ); } const [aliases, products, unitMappings] = await Promise.all([ this.prisma.receiptAlias.findMany({ where: aliasFilter, orderBy: [{ isGlobal: 'asc' }, { id: 'asc' }], select: { receiptName: true, product: { select: { id: true, name: true, canonicalName: true, categoryRef: { select: { id: true, name: true } }, }, }, }, }), this.prisma.product.findMany({ where: productFilter, select: { id: true, name: true, canonicalName: true, categoryRef: { select: { id: true, name: true } }, }, }), unitMappingsPromise, ]) as [AliasLite[], ProductLite[], UnitMappingLite[]]; const user = userId ? await this.prisma.user.findUnique({ where: { id: userId }, select: { aiEngineEnabled: true } }) : null; const aliasByReceiptName = new Map(); for (const alias of aliases) { const normalizedReceiptName = normalizeReceiptAliasName(alias.receiptName); if (!normalizedReceiptName) continue; if (!aliasByReceiptName.has(normalizedReceiptName)) { aliasByReceiptName.set(normalizedReceiptName, alias); } } const unitMappingByKey = new Map(); for (const unitMapping of unitMappings) { const key = `${unitMapping.productId}:${unitMapping.originalUnit}`; if (!unitMappingByKey.has(key)) { unitMappingByKey.set(key, unitMapping.preferredUnit); } } return { aliases, aliasByReceiptName, products, unitMappings, unitMappingByKey, categories, aiEnabled: user?.aiEngineEnabled ?? false, }; } private resolvePreferredUnit( productId: number, itemUnit: string | null | undefined, context: Pick, ): string | null | undefined { const normalizedUnit = (itemUnit ?? '').trim().toLowerCase(); return ( context.unitMappingByKey?.get(`${productId}:${normalizedUnit}`) ?? context.unitMappings.find( (um) => um.productId === productId && um.originalUnit === normalizedUnit, )?.preferredUnit ); } private buildSignalText(item: ParsedReceiptItem): string { return [item.rawName, item.matchedProductName, item.suggestedProductName] .filter((v): v is string => typeof v === 'string' && v.trim().length > 0) .join(' '); } 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, }, }); } async saveReceipt(userId: number, dto: SaveReceiptDto): Promise { const response: SaveReceiptResponse = { created: 0, merged: 0, pantryAdded: 0, pantrySkipped: 0, aliasesLearned: 0, unitMappingsLearned: 0, errors: [], }; const prismaAny = this.prisma as any; const successfulRows: Array<{ rawName: string; productId?: number; quantity?: number; unit?: string; }> = []; // Preload existierande pantry-poster för denna användare const userPantry = await this.prisma.pantryItem.findMany({ where: { userId }, select: { productId: true }, }); const pantryProductIds = new Set(userPantry.map((p) => p.productId)); // Preload existierande inventarioposter för denna användare (grupperat efter productId) const userInventory = await this.prisma.inventoryItem.findMany({ where: { userId }, select: { id: true, productId: true, quantity: true, unit: true }, }); const inventoryByProductId = new Map(); for (const item of userInventory) { if (!inventoryByProductId.has(item.productId)) { inventoryByProductId.set(item.productId, item); } } // Kör allt i en transaktion för atomicitet try { await this.prisma.$transaction(async (tx) => { const txAny = tx as any; for (let index = 0; index < dto.items.length; index++) { const item = dto.items[index]; try { // === Steg 1: Bestäm/skapa produkten === let productId: number; if (item.createProductName) { // Skapa ny privat produkt const name = item.createProductName.trim(); const normalizedName = `private:${userId}:${normalizeName(name)}`; const existing = await tx.product.findUnique({ where: { normalizedName }, }); if (existing && existing.isActive) { productId = existing.id; } else if (existing) { const updated = await tx.product.update({ where: { id: existing.id }, data: { isActive: true, deletedAt: null, name, canonicalName: name }, }); productId = updated.id; } else { const created = await tx.product.create({ data: { name, normalizedName, canonicalName: name, isActive: true, isPrivate: true, ownerId: userId, ...(item.categoryId != null ? { categoryId: item.categoryId } : {}), }, }); productId = created.id; } } else if (item.productId) { // Använd befintlig produkt const product = await tx.product.findUnique({ where: { id: item.productId }, }); if (!product) { throw new Error(`Produkten med ID ${item.productId} hittades inte.`); } if (item.categoryId != null && product.categoryId !== item.categoryId) { await tx.product.update({ where: { id: product.id }, data: { categoryId: item.categoryId }, }); } productId = product.id; } else { throw new Error('Antingen productId eller createProductName måste anges.'); } // === Steg 2: Hantera pantry eller inventory === if (item.destination === 'pantry') { if (pantryProductIds.has(productId)) { response.pantrySkipped++; } else { await tx.pantryItem.create({ data: { userId, productId }, }); response.pantryAdded++; pantryProductIds.add(productId); } } else { // inventory const quantity = item.quantity ?? 0; const unit = (item.unit ?? '').trim() || 'st'; const existing = inventoryByProductId.get(productId); if (existing) { // Slå samman await tx.inventoryItem.update({ where: { id: existing.id }, data: { quantity: { increment: new Prisma.Decimal(quantity), }, }, }); response.merged++; } else { // Skapa ny await tx.inventoryItem.create({ data: { userId, productId, quantity: new Prisma.Decimal(quantity), unit, brand: item.brand ?? undefined, origin: item.origin ?? undefined, receiptName: item.rawName, }, }); response.created++; // Uppdatera local cache inventoryByProductId.set(productId, { id: -1, productId, quantity: new Prisma.Decimal(quantity), unit, }); } // === Steg 3: Lär in enhetsmappning om requested === if (item.learnUnitMapping) { const originalUnit = (item.rawName ?? '').trim().toLowerCase(); const preferredUnit = unit.toLowerCase(); if (originalUnit && preferredUnit && originalUnit !== preferredUnit) { await txAny.unitMapping.upsert({ where: { productId_originalUnit_userId: { productId, originalUnit, userId, }, }, update: { preferredUnit, }, create: { productId, userId, originalUnit, preferredUnit, }, }); response.unitMappingsLearned++; } } } successfulRows.push({ rawName: item.rawName, productId, quantity: item.quantity, unit: item.unit, }); // === Steg 4: Lär in alias om requested === if (item.learnAlias) { const normalizedReceiptName = normalizeReceiptAliasName(item.rawName); const aliasValidationError = validateReceiptAliasName(normalizedReceiptName); if (aliasValidationError) { throw new Error(aliasValidationError); } if (normalizedReceiptName) { const isGlobalAlias = item.learnAliasGlobally === true; const aliasOwnerId: number | null = isGlobalAlias ? null : userId || null; await tx.receiptAlias.upsert({ where: { receiptName_ownerId_isGlobal: { receiptName: normalizedReceiptName, ownerId: aliasOwnerId as any, isGlobal: isGlobalAlias, }, }, update: { productId, }, create: { receiptName: normalizedReceiptName, productId, ownerId: aliasOwnerId as any, isGlobal: isGlobalAlias, }, }); response.aliasesLearned++; } } } catch (err) { const errorMsg = err instanceof Error ? err.message : String(err); this.logger.warn( `saveReceipt item [${index}] error: ${errorMsg}`, ); response.errors = response.errors ?? []; response.errors.push({ index, error: errorMsg }); } } }); } catch (err) { this.logger.error(`saveReceipt transaction failed: ${err}`); throw new BadRequestException( `Transaktionfel vid sparande av kvittovaror: ${err instanceof Error ? err.message : String(err)}`, ); } if (successfulRows.length > 0) { const syncPayload = { items: successfulRows, receiptImportBatchId: `receipt-save-${Date.now()}-${userId}`, boughtSource: 'receipt_auto' as const, }; try { const sync = await this.flyerSelectionService.commitReceiptMatches(userId, syncPayload); response.flyerAutoSync = { bought: sync.boughtCount, ambiguous: sync.ambiguousCount, unmatched: sync.unmatchedCount, }; } catch (err) { this.logger.warn(`Flyer auto-sync failed after receipt save (attempt 1): ${String(err)}`); try { const sync = await this.flyerSelectionService.commitReceiptMatches(userId, syncPayload); response.flyerAutoSync = { bought: sync.boughtCount, ambiguous: sync.ambiguousCount, unmatched: sync.unmatchedCount, }; } catch (retryErr) { const message = retryErr instanceof Error ? retryErr.message : String(retryErr); this.logger.warn(`Flyer auto-sync failed after receipt save (attempt 2): ${message}`); response.flyerAutoSync = { bought: 0, ambiguous: 0, unmatched: 0, error: message, }; } } } return response; } private async parseReceiptViaImporter(file: Express.Multer.File): Promise<{ items: ParsedReceiptItem[]; trace: { prompt: string | null; rawOutput: string | null; normalizedOutput: Record | null; }; }> { 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 body = (await response.json()) as | ParsedReceiptItem[] | { items?: ParsedReceiptItem[]; prompt?: unknown; rawOutput?: unknown; normalizedOutput?: Record; }; const normalizedItems = this.extractImporterItems(body) .filter((item) => !isIgnoredReceiptName(item.rawName)); return { items: normalizedItems, trace: { prompt: this.extractImporterPrompt(body), rawOutput: this.extractImporterRawOutput(body), normalizedOutput: this.extractImporterNormalizedOutput(body), }, }; } private extractImporterItems( body: ParsedReceiptItem[] | { items?: ParsedReceiptItem[] }, ): ParsedReceiptItem[] { if (Array.isArray(body)) return body; if (Array.isArray(body.items)) return body.items; return []; } private extractImporterPrompt( body: ParsedReceiptItem[] | { prompt?: unknown }, ): string | null { if (Array.isArray(body)) return null; if (typeof body.prompt !== 'string') return null; const prompt = body.prompt.trim(); return prompt && prompt.length > 0 ? prompt : null; } private extractImporterRawOutput( body: ParsedReceiptItem[] | { rawOutput?: unknown }, ): string | null { if (Array.isArray(body)) return JSON.stringify(body); if (typeof body.rawOutput === 'string' && body.rawOutput.trim().length > 0) { return body.rawOutput; } if (body.rawOutput !== undefined) { try { return JSON.stringify(body.rawOutput); } catch { return String(body.rawOutput); } } return JSON.stringify(body); } private extractImporterNormalizedOutput( body: ParsedReceiptItem[] | { normalizedOutput?: Record; items?: ParsedReceiptItem[] }, ): Record | null { if (Array.isArray(body)) { return { items: body }; } if (body.normalizedOutput && typeof body.normalizedOutput === 'object') { return body.normalizedOutput; } if (Array.isArray(body.items)) { return { items: body.items }; } return null; } private async persistReceiptTrace(params: { userId?: number; model: string; prompt: string | null; rawOutput: string | null; normalizedOutput: Record | null; status: 'success' | 'error'; error: string | null; durationMs: number; }): Promise { try { await this.prisma.aiTrace.create({ data: { source: 'receipt', userId: params.userId, model: params.model, prompt: params.prompt, rawOutput: params.rawOutput, ...(params.normalizedOutput == null ? {} : { normalizedOutput: params.normalizedOutput as Prisma.InputJsonValue, }), status: params.status, error: params.error, durationMs: params.durationMs, }, }); } catch (traceErr) { this.logger.warn( `Kunde inte spara receipt AI-trace: ${traceErr instanceof Error ? traceErr.message : String(traceErr)}`, ); } } // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ // UNIFIED MATCHER: Kombinerar product matching + categorization // // KATEGORI-HIERARKI (fallback-first): // 1. Alias-kopplad produkt (om fanns) // 2. Word-match-kopplad produkt (om fanns) // 3. Regel-baserad (deterministisk, alltid försökt) // 4. AI-kategorisering (BARA som fallback när allt annat misslyckades, och om aktiverat) // // AI kallas ALDRIG om regel-baserad eller produkt-koppling redan satte kategori. // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ private async matchAndEnrichReceiptItem( item: ParsedReceiptItem, context: MatchingContext, ): Promise { if (!item.rawName) return item; const raw = normalizeReceiptAliasName(item.rawName); const debug: MatchDebug = { steps: [], tree: {} }; try { // ┌─ STEG 1: Alias-lookup (certifierad match) ─────────────────────────┐ debug.steps.push('Step 1: Alias lookup'); const aliasMatch = context.aliasByReceiptName?.get(raw) ?? context.aliases.find((a) => a.receiptName === raw); if (aliasMatch) { debug.tree.alias = { found: true, productId: aliasMatch.product.id }; debug.steps.push(` ✓ Alias found → productId ${aliasMatch.product.id}`); const mappedUnit = this.resolvePreferredUnit( aliasMatch.product.id, item.unit, context, ); const aliasResult: ParsedReceiptItem = { ...item, matchedProductId: aliasMatch.product.id, matchedProductName: aliasMatch.product.canonicalName ?? aliasMatch.product.name, unit: mappedUnit ?? item.unit, matchedVia: 'alias' as const, ...(aliasMatch.product.categoryRef ? { categorySuggestion: { categoryId: aliasMatch.product.categoryRef.id, categoryName: aliasMatch.product.categoryRef.name, path: aliasMatch.product.categoryRef.name, confidence: 'high' as const, usedFallback: false, }, } : {}), }; // Kör alltid enrichCategoryForItem för guard-funktioner och hard overrides return await this.enrichCategoryForItem(aliasResult, context, debug); } debug.steps.push(` ✗ No alias match`); debug.tree.alias = { found: false }; // ┌─ STEG 2: Ordet-baserad matchning (förslag) ────────────────────────┐ debug.steps.push('Step 2: Word match'); const wordMatchResult = this.findWordMatchWithScore(raw, context.products); if (wordMatchResult) { debug.tree.wordMatch = { found: true, productId: wordMatchResult.id, score: wordMatchResult.score }; debug.steps.push(` ✓ Word match found → productId ${wordMatchResult.id} (score ${wordMatchResult.score})`); const preferredUnit = this.resolvePreferredUnit(wordMatchResult.id, item.unit, context) ?? item.unit; const result: ParsedReceiptItem = { ...item, suggestedProductId: wordMatchResult.id, suggestedProductName: wordMatchResult.canonicalName ?? wordMatchResult.name, unit: preferredUnit, matchedVia: 'wordmatch' as const, }; // Lägg på kategori från produkt om den finns if (wordMatchResult.categoryRef) { result.categorySuggestion = { categoryId: wordMatchResult.categoryRef.id, categoryName: wordMatchResult.categoryRef.name, path: wordMatchResult.categoryRef.name, confidence: 'medium' as const, usedFallback: false, }; } // Gå vidare till kategorisering för wordmatch return await this.enrichCategoryForItem(result, context, debug); } debug.steps.push(` ✗ No word match`); debug.tree.wordMatch = { found: false }; // ┌─ STEG 3: Regel-baserad kategorisering (no product match) ──────────┐ return await this.enrichCategoryForItem( { ...item, matchedVia: 'none' as const }, context, debug, ); } catch (err) { this.logger.warn(`matchAndEnrichReceiptItem error for "${item.rawName}": ${err}`); return item; } } private async enrichCategoryForItem( item: ParsedReceiptItem, context: Pick, debug: MatchDebug, ): Promise { // Kategori-hierarki: // 1. Om produktmatchning redan satte kategori, börjar vi med den // 2. Försöker regel-baserad kategorisering (HIGH confidence: ersätt, fallback: använd om tom) // 3. AI kallas ENDAST om ingen kategori satts än (nextCategory === null) // 4. Guards och hard overrides tillämpas på slutresultatet debug.steps.push('Step 3: Categorization'); const signalText = this.buildSignalText(item); const signalOrRaw = signalText || item.rawName; let nextCategory = item.categorySuggestion ?? null; // ┌─ STEG 3A: Försök regel-baserad kategorisering ─────────────────────┐ debug.steps.push(' Trying rule-based categorization'); const ruleResult = this.ruleBasedCategorySuggestion(signalOrRaw, context.categories); debug.tree.rule = { found: !!ruleResult, path: ruleResult?.path }; if (ruleResult?.confidence === 'high') { const sameAsExisting = nextCategory && nextCategory.categoryId === ruleResult.categoryId; if (!sameAsExisting) { debug.steps.push(` ✓ Rule-based HIGH: ${ruleResult.path}`); nextCategory = ruleResult; } else { debug.steps.push(` ✓ Rule-based HIGH (same as existing): ${ruleResult.path}`); } } else if (!nextCategory && ruleResult) { debug.steps.push(` ✓ Rule-based fallback: ${ruleResult.path}`); nextCategory = ruleResult; } else { debug.steps.push(` ✗ Rule-based miss or lower priority`); } // ┌─ STEG 3B: AI-kategorisering ENDAST som fallback ────────────────────┐ // AI kallas bara om: // 1) nextCategory är fortfarande NULL (regel-baserad misslyckades/fanns inte) // 2) User har AI aktiverat (context.aiEnabled === true) // AI ersätter ALDRIG redan satta kategorier. if (!nextCategory) { debug.steps.push(' Trying AI categorization (fallback: no category set yet)'); if (context.aiEnabled) { debug.tree.ai = { called: true }; try { nextCategory = await this.aiService.suggestCategory(item.rawName, context.categories); debug.steps.push(` ✓ AI suggestion: ${nextCategory.path}`); } catch (err) { debug.steps.push(` ✗ AI failed: ${err}`); debug.tree.ai = { called: true, error: String(err) }; } } else { debug.steps.push(` ✗ AI disabled for user`); debug.tree.ai = { called: false }; } } else { debug.steps.push(` ⊘ AI skipped (category already set: ${nextCategory.path})`); debug.tree.ai = { called: false, reason: 'category_already_set' }; } // ┌─ Contradiction guard (final sanity check) ────────────────────────┐ if (nextCategory) { debug.steps.push(' Applying contradiction guard'); const beforePath = nextCategory.path; const guardedCategory = this.applyContradictionGuard(signalOrRaw, nextCategory, context.categories); if (guardedCategory && guardedCategory.path !== beforePath) { debug.steps.push(` ⚠️ Guard remapped: ${beforePath} → ${guardedCategory.path}`); nextCategory = guardedCategory; debug.tree.guard = { applied: true, oldPath: beforePath, newPath: guardedCategory.path }; } else { debug.steps.push(` ✓ Guard OK`); } } // ┌─ Hard overrides (special rules for problematic cases) ─────────────┐ if (nextCategory) { debug.steps.push(' Applying hard overrides'); const beforePath = nextCategory.path; const finalCategory = this.applyHardCategoryOverrides(signalOrRaw, nextCategory, context.categories); if (finalCategory && finalCategory.path !== beforePath) { debug.steps.push(` ⚠️ Override applied: ${beforePath} → ${finalCategory.path}`); nextCategory = finalCategory; debug.tree.hardOverride = { applied: true, oldPath: beforePath, newPath: finalCategory.path }; } else { debug.steps.push(` ✓ No hard override needed`); } } if (nextCategory) { debug.steps.push(`✅ FINAL: ${nextCategory.path} (${nextCategory.confidence})`); } else { debug.steps.push(`❌ FINAL: No category assigned`); } if (this.shouldTraceDecision(signalOrRaw)) { this.logger.log(`[ReceiptDecision] ${item.rawName}\n${debug.steps.join('\n')}`); } return nextCategory ? { ...item, categorySuggestion: nextCategory } : item; } // Helper: findWordMatch som returnerar både product OCH score private findWordMatchWithScore( raw: string, products: Array<{ id: number; name: string; canonicalName: string | null; categoryRef: { id: number; name: string } | null; }>, ): (typeof products[0] & { score: number }) | undefined { const rawWords = tokenize(raw); if (rawWords.length === 0) return undefined; const rawWordSet = new Set(rawWords); const rawWordsNorm = rawWords.map(normalizeToken); const rawWordSetNorm = new Set(rawWordsNorm); let best: (typeof products[0] & { 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; } 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) continue; partialStrong += 1; score += 3; } const hasLongPartial = partialStrong >= 1 && productWords.some((pw) => pw.length >= 5); const hasStrongSignal = exactStrong >= 1 || exactAny + partialStrong >= 2 || hasLongPartial; if (!hasStrongSignal) continue; if (score < 8) continue; if (!best || score > best.score) { best = { ...product, score }; } } return best; } 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: Choklad & spreads (Nutella, sjokolade, m.m.) ───────────── const hasChocolateSignal = normalized.includes('nutella') || normalized.includes('chocolate') || normalized.includes('choklad') || normalized.includes('sjokolade') || normalized.includes('kakao') || normalized.includes('spreads'); if (hasChocolateSignal) { const chocolate = findCategory({ name: 'choklad & spreads', includes: 'sötsaker', }); const hit = toSuggestion(chocolate, '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', ); const l2EggHit = toSuggestion(l2Egg, 'high'); if (l2EggHit) return l2EggHit; const l1DairyEgg = categories.find( (c) => c.path.toLowerCase() === 'mejeri, ost & ägg', ); const l1DairyEggHit = toSuggestion(l1DairyEgg, 'high'); if (l1DairyEggHit) return l1DairyEggHit; } // ── 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'), ); const l3TeHit = toSuggestion(l3Te, 'high'); if (l3TeHit) return l3TeHit; const l2TeChoklad = categories.find( (c) => c.name.toLowerCase() === 'te & choklad' && c.path.toLowerCase().startsWith('dryck'), ); const l2TeChokladHit = toSuggestion(l2TeChoklad, 'medium'); if (l2TeChokladHit) return l2TeChokladHit; } // ── 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'), ); const l3KaffebrodHit = toSuggestion(l3Kaffebrod, 'high'); if (l3KaffebrodHit) return l3KaffebrodHit; const l2Kondis = categories.find( (c) => c.name.toLowerCase() === 'kondis & fika' && c.path.toLowerCase().startsWith('bröd & kakor'), ); const l2KondisHit = toSuggestion(l2Kondis, 'medium'); if (l2KondisHit) return l2KondisHit; } // ── 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 > '), ); const l3AllergyCookingHit = toSuggestion(l3AllergyCooking, 'high'); if (l3AllergyCookingHit) return l3AllergyCookingHit; const l2Cooking = categories.find( (c) => c.name.toLowerCase() === 'matlagning' && c.path.toLowerCase() === 'mejeri, ost & ägg > matlagning', ); const l2CookingHit = toSuggestion(l2Cooking, 'medium'); if (l2CookingHit) return l2CookingHit; 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; } }