Files
recipe-app/backend/src/receipt-import/receipt-import.service.ts
T
Nils-Johan Gynther a68a0ca86f
Test Suite / test (24.15.0) (push) Has been cancelled
feat: add unit mapping functionality
- Added new API path for unit mappings in `api_paths.dart`.
- Implemented `upsertUnitMapping` method in `ImportRepository` to handle unit mapping creation.
- Updated `ReceiptImportTab` to learn and save unit mappings during receipt import.
- Created DTO for unit mapping with validation in `create-unit-mapping.dto.ts`.
- Added SQL migration for `UnitMapping` table creation with necessary constraints.
2026-05-07 10:00:42 +02:00

1328 lines
47 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<ParsedReceiptItem[]> {
// 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<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));
}
private async matchProducts(
items: ParsedReceiptItem[],
userId?: number,
): Promise<ParsedReceiptItem[]> {
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<UnitMappingLite[]>)
: 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<ParsedReceiptItem[]> {
let categories: Awaited<ReturnType<CategoriesService['findFlattened']>>;
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<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: 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<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;
}
}