diff --git a/NEXT_STEPS.md b/NEXT_STEPS.md index 9b42782c..cf23e1d6 100644 --- a/NEXT_STEPS.md +++ b/NEXT_STEPS.md @@ -88,6 +88,10 @@ 5. Lokalisera `receipt_import_tab.dart` och `swipeable_inventory_tile.dart`. 6. Smoke-test på testdomän och avstämning. 7. Planera och påbörja avancerad AI-integration och EAN-skanning. +8. AI-kategorisering: kandidatbegränsning före AI (narrowing till relevant gren/kandidatlista istället för hela kategoriträdet). +9. AI-kategorisering: egen confidence-score i backend (regel/lexikal signal + AI-signal), inte enbart modellens confidence. +10. AI-kategorisering: logga användarkorrigeringar och bygg feedbackloop för nya regler/synonymer. +11. AI-kategorisering: utvärdera alternativ modell för kategorisering med A/B-test på historiska kvittorader. ## Beslut 2026-05-02 - Aliasstrategi för kvittoimport diff --git a/backend/src/receipt-import/receipt-import.service.ts b/backend/src/receipt-import/receipt-import.service.ts index cc51263d..7bfd25bf 100644 --- a/backend/src/receipt-import/receipt-import.service.ts +++ b/backend/src/receipt-import/receipt-import.service.ts @@ -261,7 +261,12 @@ export class ReceiptImportService { } const suggestion = await this.aiService.suggestCategory(item.rawName, categories); - enriched.set(item.rawName, { ...item, categorySuggestion: suggestion }); + const guardedSuggestion = this.applyContradictionGuard( + item.rawName, + suggestion, + categories, + ); + enriched.set(item.rawName, { ...item, categorySuggestion: guardedSuggestion }); } catch { // Om AI-anrop misslyckas för enskild vara — hoppa över utan att kasta enriched.set(item.rawName, item); @@ -276,6 +281,188 @@ export class ReceiptImportService { categories: Awaited>, ): 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 = + /\bbacon\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); + + if (hasPorkSignal) { + const l3Pork = findCategory({ + name: 'fläsk', + startsWith: 'kött, chark & fågel > kött > ', + }); + 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: Te ──────────────────────────────────────────────────────── const isTea = @@ -380,4 +567,46 @@ export class ReceiptImportService { return null; } + + private applyContradictionGuard( + rawName: string, + suggestion: CategorySuggestion, + categories: Awaited>, + ): CategorySuggestion { + const normalized = normalizeForRules(rawName); + const hasPorkSignal = + /\bbacon\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); + + if (!hasPorkSignal) return suggestion; + + const aiPath = suggestion.path.toLowerCase(); + const isClearlyWrongBranch = + aiPath.includes('köttbullar & färsprodukter') || aiPath.includes('köttfärs'); + + if (!isClearlyWrongBranch) return suggestion; + + const l3Pork = categories.find( + (c) => + c.name.toLowerCase() === 'fläsk' && + c.path.toLowerCase().startsWith('kött, chark & fågel > kött > '), + ); + 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, + }; + } }