feat(categories): add new categories for Kondis & fika and Kaffebröd, and Te & choklad
This commit is contained in:
@@ -120,6 +120,24 @@ Regler:
|
||||
? (parsed.confidence as 'high' | 'medium' | 'low')
|
||||
: 'medium';
|
||||
|
||||
// Guardrail: för låg/medel konfidenspoäng, remmappa till L1-föräldern
|
||||
if (confidence === 'low' || confidence === 'medium') {
|
||||
const l1Name = matchedCategory.path.split(' > ')[0];
|
||||
const l1 = categories.find((c) => c.path === l1Name);
|
||||
if (l1 && l1.id !== matchedCategory.id) {
|
||||
this.logger.log(
|
||||
`AI-guardrail: ${confidence} konfidenspoäng → remappar "${matchedCategory.path}" → L1 "${l1.path}"`,
|
||||
);
|
||||
return {
|
||||
categoryId: l1.id,
|
||||
categoryName: l1.name,
|
||||
path: l1.path,
|
||||
confidence,
|
||||
usedFallback: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
categoryId: matchedCategory.id,
|
||||
categoryName: matchedCategory.name,
|
||||
|
||||
@@ -32,6 +32,10 @@ function tokenize(value: string): string[] {
|
||||
.filter((w) => w.length >= 3);
|
||||
}
|
||||
|
||||
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()
|
||||
@@ -153,6 +157,9 @@ export class ReceiptImportService {
|
||||
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 }; score: number }
|
||||
@@ -174,8 +181,9 @@ export class ReceiptImportService {
|
||||
|
||||
for (const pw of productWords) {
|
||||
const isWeak = WEAK_DESCRIPTORS.has(pw);
|
||||
const pwNorm = normalizeToken(pw);
|
||||
|
||||
if (rawWordSet.has(pw)) {
|
||||
if (rawWordSet.has(pw) || rawWordSetNorm.has(pwNorm)) {
|
||||
exactAny += 1;
|
||||
if (isWeak) {
|
||||
score += 1;
|
||||
@@ -189,7 +197,9 @@ export class ReceiptImportService {
|
||||
// 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));
|
||||
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) {
|
||||
@@ -202,7 +212,9 @@ export class ReceiptImportService {
|
||||
}
|
||||
|
||||
// Kräv antingen minst ett starkt exakt ord, eller flera samverkande signaler.
|
||||
const hasStrongSignal = exactStrong >= 1 || exactAny + partialStrong >= 2;
|
||||
// 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.
|
||||
@@ -253,6 +265,59 @@ export class ReceiptImportService {
|
||||
): CategorySuggestion | null {
|
||||
const normalized = normalizeForRules(rawName);
|
||||
|
||||
// ── 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 =
|
||||
/\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: Laktosfri/växtbaserad mejeri ──────────────────────────────
|
||||
const isCookingBase =
|
||||
/\bmatlagningsbas\b/.test(normalized) ||
|
||||
/\bmatlagnings\b/.test(normalized) ||
|
||||
|
||||
Reference in New Issue
Block a user