a68a0ca86f
Test Suite / test (24.15.0) (push) Has been cancelled
- 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.
1328 lines
47 KiB
TypeScript
1328 lines
47 KiB
TypeScript
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;
|
||
}
|
||
}
|