Files
recipe-app/backend/src/receipt-import/receipt-import.service.ts
T

1586 lines
56 KiB
TypeScript

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';
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 {
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<string, AliasLite>;
products: ProductLite[];
unitMappings: UnitMappingLite[];
unitMappingByKey?: Map<string, string>;
categories: Awaited<ReturnType<CategoriesService['findFlattened']>>;
aiEnabled: boolean;
};
type MatchDebug = {
steps: string[];
tree: Record<string, unknown>;
};
@Injectable()
export class ReceiptImportService {
private readonly logger = new Logger(ReceiptImportService.name);
constructor(
private readonly prisma: PrismaService,
private readonly aiService: AiService,
private readonly categoriesService: CategoriesService,
) {}
async parseReceipt(file: Express.Multer.File, _isPremium = false, userId?: number): Promise<ParsedReceiptItem[]> {
// Steg 1: Delegera AI-parsning till microservice-importer
const rawItems = await this.parseReceiptViaImporter(file);
// 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
return Promise.all(
rawItems.map((item) =>
this.matchAndEnrichReceiptItem(item, context),
),
);
}
private async prepareMatchingContext(userId?: number): Promise<MatchingContext> {
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<UnitMappingLite[]>)
: Promise.resolve([] as UnitMappingLite[]);
let categories: Awaited<ReturnType<CategoriesService['findFlattened']>>;
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<string, AliasLite>();
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<string, string>();
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<MatchingContext, 'unitMappings' | 'unitMappingByKey'>,
): 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<SaveReceiptResponse> {
const response: SaveReceiptResponse = {
created: 0,
merged: 0,
pantryAdded: 0,
pantrySkipped: 0,
aliasesLearned: 0,
unitMappingsLearned: 0,
errors: [],
};
const prismaAny = this.prisma as any;
// 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<number, typeof userInventory[0]>();
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.`);
}
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++;
}
}
}
// === 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)}`,
);
}
return response;
}
private async parseReceiptViaImporter(file: Express.Multer.File): Promise<ParsedReceiptItem[]> {
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));
}
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 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<ParsedReceiptItem> {
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<MatchingContext, 'categories' | 'aiEnabled'>,
debug: MatchDebug,
): Promise<ParsedReceiptItem> {
// 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<ReturnType<CategoriesService['findFlattened']>>,
) {
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<ReturnType<CategoriesService['findFlattened']>>,
) {
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<ReturnType<CategoriesService['findFlattened']>>,
): 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<ReturnType<CategoriesService['findFlattened']>>,
): 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<ReturnType<CategoriesService['findFlattened']>>,
): 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;
}
}