From ec24f49836c2eba0572a7659c0b57f2a0b29f3f6 Mon Sep 17 00:00:00 2001 From: Nils-Johan Gynther Date: Sat, 2 May 2026 17:44:01 +0200 Subject: [PATCH] =?UTF-8?q?feat(categories):=20add=20new=20categories=20fo?= =?UTF-8?q?r=20Kondis=20&=20fika=20and=20Kaffebr=C3=B6d,=20and=20Te=20&=20?= =?UTF-8?q?choklad?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../migration.sql | 20 ++++++ .../migration.sql | 20 ++++++ backend/src/ai/ai.service.ts | 18 +++++ .../receipt-import/receipt-import.service.ts | 71 ++++++++++++++++++- db/seeds/seed_all.sql | 13 ++++ 5 files changed, 139 insertions(+), 3 deletions(-) create mode 100644 backend/prisma/migrations/20260502150000_add_kondis_fika_kaffebrod/migration.sql create mode 100644 backend/prisma/migrations/20260502151000_add_te_choklad_te/migration.sql diff --git a/backend/prisma/migrations/20260502150000_add_kondis_fika_kaffebrod/migration.sql b/backend/prisma/migrations/20260502150000_add_kondis_fika_kaffebrod/migration.sql new file mode 100644 index 00000000..182d2a74 --- /dev/null +++ b/backend/prisma/migrations/20260502150000_add_kondis_fika_kaffebrod/migration.sql @@ -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 + ); diff --git a/backend/prisma/migrations/20260502151000_add_te_choklad_te/migration.sql b/backend/prisma/migrations/20260502151000_add_te_choklad_te/migration.sql new file mode 100644 index 00000000..37336620 --- /dev/null +++ b/backend/prisma/migrations/20260502151000_add_te_choklad_te/migration.sql @@ -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 + ); diff --git a/backend/src/ai/ai.service.ts b/backend/src/ai/ai.service.ts index 470e21ea..ec4494a3 100644 --- a/backend/src/ai/ai.service.ts +++ b/backend/src/ai/ai.service.ts @@ -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, diff --git a/backend/src/receipt-import/receipt-import.service.ts b/backend/src/receipt-import/receipt-import.service.ts index 75fcc7ea..8a124a29 100644 --- a/backend/src/receipt-import/receipt-import.service.ts +++ b/backend/src/receipt-import/receipt-import.service.ts @@ -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) || diff --git a/db/seeds/seed_all.sql b/db/seeds/seed_all.sql index c3e5f901..00e865c4 100644 --- a/db/seeds/seed_all.sql +++ b/db/seeds/seed_all.sql @@ -42,11 +42,13 @@ INSERT INTO `Category` (`name`, `parentId`) SELECT 'Bröd', id FRO INSERT INTO `Category` (`name`, `parentId`) SELECT 'Fastfoodbröd', id FROM `Category` WHERE name = 'Bröd & Kakor' AND parentId IS NULL; INSERT INTO `Category` (`name`, `parentId`) SELECT 'Kex & Kakor', id FROM `Category` WHERE name = 'Bröd & Kakor' AND parentId IS NULL; INSERT INTO `Category` (`name`, `parentId`) SELECT 'Knäckebröd & Skorpor', id FROM `Category` WHERE name = 'Bröd & Kakor' AND parentId IS NULL; +INSERT INTO `Category` (`name`, `parentId`) SELECT 'Kondis & fika', id FROM `Category` WHERE name = 'Bröd & Kakor' AND parentId IS NULL; -- ── NIVÅ 2: under Dryck ───────────────────────────────────── INSERT INTO `Category` (`name`, `parentId`) SELECT 'Öl & cider', id FROM `Category` WHERE name = 'Dryck' AND parentId IS NULL; INSERT INTO `Category` (`name`, `parentId`) SELECT 'Läsk och Energidryck', id FROM `Category` WHERE name = 'Dryck' AND parentId IS NULL; INSERT INTO `Category` (`name`, `parentId`) SELECT 'Juice, fruktdryck & smoothie', id FROM `Category` WHERE name = 'Dryck' AND parentId IS NULL; +INSERT INTO `Category` (`name`, `parentId`) SELECT 'Te & choklad', id FROM `Category` WHERE name = 'Dryck' AND parentId IS NULL; -- ── NIVÅ 2: under Färdigmat ───────────────────────────────── INSERT INTO `Category` (`name`, `parentId`) SELECT 'Såser, grytbaser & övriga smaksättare', id FROM `Category` WHERE name = 'Färdigmat' AND parentId IS NULL; @@ -121,6 +123,12 @@ INSERT INTO `Category` (`name`, `parentId`) JOIN `Category` c2 ON c2.parentId = c1.id AND c2.name = 'Bröd' WHERE c1.name = 'Bröd & Kakor' AND c1.parentId IS NULL; +-- ── NIVÅ 3: under Bröd & Kakor > 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; + -- ── NIVÅ 3: under Bröd & Kakor > Fastfoodbröd ───────────────── INSERT INTO `Category` (`name`, `parentId`) SELECT 'Hamburgerbröd', c2.id FROM `Category` c1 @@ -147,6 +155,11 @@ INSERT INTO `Category` (`name`, `parentId`) SELECT 'Kyld juice & nektar', c2.id FROM `Category` c1 JOIN `Category` c2 ON c2.parentId = c1.id AND c2.name = 'Juice, fruktdryck & smoothie' WHERE c1.name = 'Dryck' AND c1.parentId IS NULL; +-- ── NIVÅ 3: under Dryck > 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; -- ── NIVÅ 3: under Färdigmat > Såser, grytbaser... ─────────── INSERT INTO `Category` (`name`, `parentId`) SELECT 'Dressing & övriga smaksättare', c2.id FROM `Category` c1