feat(categories): add new categories for Kondis & fika and Kaffebröd, and Te & choklad

This commit is contained in:
Nils-Johan Gynther
2026-05-02 17:44:01 +02:00
parent 1604751b65
commit ec24f49836
5 changed files with 139 additions and 3 deletions
@@ -0,0 +1,20 @@
-- Lägg till L2: Kondis & fika under Bröd & Kakor
INSERT INTO `Category` (`name`, `parentId`)
SELECT 'Kondis & fika', c1.id
FROM `Category` c1
WHERE c1.name = 'Bröd & Kakor' AND c1.parentId IS NULL
AND NOT EXISTS (
SELECT 1 FROM `Category` c2
WHERE c2.name = 'Kondis & fika' AND c2.parentId = c1.id
);
-- Lägg till L3: Kaffebröd under Kondis & fika
INSERT INTO `Category` (`name`, `parentId`)
SELECT 'Kaffebröd', c2.id
FROM `Category` c1
JOIN `Category` c2 ON c2.parentId = c1.id AND c2.name = 'Kondis & fika'
WHERE c1.name = 'Bröd & Kakor' AND c1.parentId IS NULL
AND NOT EXISTS (
SELECT 1 FROM `Category` c3
WHERE c3.name = 'Kaffebröd' AND c3.parentId = c2.id
);
@@ -0,0 +1,20 @@
-- Lägg till L2: Te & choklad under Dryck
INSERT INTO `Category` (`name`, `parentId`)
SELECT 'Te & choklad', c1.id
FROM `Category` c1
WHERE c1.name = 'Dryck' AND c1.parentId IS NULL
AND NOT EXISTS (
SELECT 1 FROM `Category` c2
WHERE c2.name = 'Te & choklad' AND c2.parentId = c1.id
);
-- Lägg till L3: Te under Te & choklad
INSERT INTO `Category` (`name`, `parentId`)
SELECT 'Te', c2.id
FROM `Category` c1
JOIN `Category` c2 ON c2.parentId = c1.id AND c2.name = 'Te & choklad'
WHERE c1.name = 'Dryck' AND c1.parentId IS NULL
AND NOT EXISTS (
SELECT 1 FROM `Category` c3
WHERE c3.name = 'Te' AND c3.parentId = c2.id
);
+18
View File
@@ -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) ||