From a88d6e2452c62f0cbbfad9412e356ff1387aaa54 Mon Sep 17 00:00:00 2001 From: Nils-Johan Gynther Date: Sat, 2 May 2026 20:48:47 +0200 Subject: [PATCH] feat(receipt-import): run rules and AI for all users with trust-aware overrides --- .../receipt-import/receipt-import.service.ts | 71 +++++++++++++------ 1 file changed, 48 insertions(+), 23 deletions(-) diff --git a/backend/src/receipt-import/receipt-import.service.ts b/backend/src/receipt-import/receipt-import.service.ts index 7e6066c7..6ca0fd96 100644 --- a/backend/src/receipt-import/receipt-import.service.ts +++ b/backend/src/receipt-import/receipt-import.service.ts @@ -55,18 +55,15 @@ export class ReceiptImportService { private readonly categoriesService: CategoriesService, ) {} - async parseReceipt(file: Express.Multer.File, isPremium = false, userId?: number): Promise { + async parseReceipt(file: Express.Multer.File, _isPremium = false, userId?: number): Promise { // 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: AI-kategorisering för premium-användare - if (isPremium) { - return this.enrichWithAiCategories(matched); - } - return matched; + // Steg 3: Regel + AI-kategorisering för alla användare + return this.enrichWithAiCategories(matched); } private async parseReceiptViaImporter(file: Express.Multer.File): Promise { @@ -240,10 +237,6 @@ export class ReceiptImportService { } private async enrichWithAiCategories(items: ParsedReceiptItem[]): Promise { - // Kör regler/AI för alla items som saknar categorySuggestion och har ett rawName - const unmatched = items.filter((i) => !i.categorySuggestion && i.rawName); - if (unmatched.length === 0) return items; - let categories: Awaited>; try { categories = await this.categoriesService.findFlattened(); @@ -251,29 +244,61 @@ export class ReceiptImportService { return items; // Om kategoritjänsten är otillgänglig, returnera utan AI-förslag } - const enriched = new Map(); - for (const item of unmatched) { + const enriched: ParsedReceiptItem[] = []; + for (const item of items) { + if (!item.rawName) { + enriched.push(item); + continue; + } + try { const byRule = this.ruleBasedCategorySuggestion(item.rawName, categories); - if (byRule) { - enriched.set(item.rawName, { ...item, categorySuggestion: byRule }); - continue; + 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 && (!isTrustedSuggestion || nextSuggestion == null)) { + nextSuggestion = byRule; + } + + // 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; + } + } else if (!nextSuggestion && byRule) { + nextSuggestion = byRule; } - const suggestion = await this.aiService.suggestCategory(item.rawName, categories); - const guardedSuggestion = this.applyContradictionGuard( - item.rawName, - suggestion, - categories, + // AI används som fallback när varken matchning eller regler satte kategori + if (!nextSuggestion) { + nextSuggestion = await this.aiService.suggestCategory(item.rawName, categories); + } + + const guardedSuggestion = nextSuggestion + ? this.applyContradictionGuard(item.rawName, nextSuggestion, categories) + : null; + + enriched.push( + guardedSuggestion + ? { ...item, categorySuggestion: guardedSuggestion } + : item, ); - 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); + enriched.push(item); } } - return items.map((item) => enriched.get(item.rawName) ?? item); + return enriched; } private ruleBasedCategorySuggestion(