diff --git a/backend/src/receipt-import/receipt-import.service.ts b/backend/src/receipt-import/receipt-import.service.ts index 993a430e..c19eb569 100644 --- a/backend/src/receipt-import/receipt-import.service.ts +++ b/backend/src/receipt-import/receipt-import.service.ts @@ -260,7 +260,28 @@ export class ReceiptImportService { .filter((v): v is string => typeof v === 'string' && v.trim().length > 0) .join(' '); + const trace: string[] = []; + const traceEnabled = this.shouldTraceDecision(signalText || item.rawName); + const pushTrace = (msg: string) => { + if (traceEnabled) trace.push(msg); + }; + + pushTrace(`start raw="${item.rawName}" signal="${signalText || item.rawName}"`); + pushTrace( + `match matchedProductId=${item.matchedProductId ?? 'null'} suggestedProductId=${item.suggestedProductId ?? 'null'}`, + ); + if (item.categorySuggestion) { + pushTrace( + `incoming category="${item.categorySuggestion.path}" confidence=${item.categorySuggestion.confidence} fallback=${item.categorySuggestion.usedFallback}`, + ); + } + const byRule = this.ruleBasedCategorySuggestion(signalText || item.rawName, categories); + if (byRule) { + pushTrace(`rule hit -> "${byRule.path}" (${byRule.confidence})`); + } else { + pushTrace('rule miss'); + } let nextSuggestion = item.categorySuggestion ?? null; const isTrustedSuggestion = @@ -272,6 +293,7 @@ export class ReceiptImportService { nextSuggestion != null && nextSuggestion.categoryId === byRule.categoryId; if (!sameAsCurrent && (!isTrustedSuggestion || nextSuggestion == null)) { nextSuggestion = byRule; + pushTrace(`rule applied -> "${byRule.path}"`); } // Om regler säger en annan kategori än ett redan "trusted" förslag, @@ -281,30 +303,66 @@ export class ReceiptImportService { `Rule-override: "${item.rawName}" ändras från "${nextSuggestion?.path}" till "${byRule.path}"`, ); nextSuggestion = byRule; + pushTrace(`rule override trusted -> "${byRule.path}"`); } } else if (!nextSuggestion && byRule) { nextSuggestion = byRule; + pushTrace(`rule fallback applied -> "${byRule.path}"`); } // AI används som fallback när varken matchning eller regler satte kategori if (!nextSuggestion) { + pushTrace('ai invoked'); nextSuggestion = await this.aiService.suggestCategory(item.rawName, categories); + pushTrace(`ai result -> "${nextSuggestion.path}" (${nextSuggestion.confidence})`); + } else { + pushTrace(`ai skipped, current -> "${nextSuggestion.path}"`); } + const beforeGuardPath = nextSuggestion?.path; const guardedSuggestion = nextSuggestion ? this.applyContradictionGuard(signalText || item.rawName, nextSuggestion, categories) : null; + if (guardedSuggestion && beforeGuardPath !== guardedSuggestion.path) { + pushTrace(`contradiction guard remap "${beforeGuardPath}" -> "${guardedSuggestion.path}"`); + } + const beforeHardPath = guardedSuggestion?.path; const finalSuggestion = guardedSuggestion ? this.applyHardCategoryOverrides(signalText || item.rawName, guardedSuggestion, categories) : null; + if (finalSuggestion && beforeHardPath !== finalSuggestion.path) { + pushTrace(`hard override remap "${beforeHardPath}" -> "${finalSuggestion.path}"`); + } + + if (finalSuggestion) { + pushTrace(`final -> "${finalSuggestion.path}" (${finalSuggestion.confidence})`); + } else { + pushTrace('final -> no categorySuggestion'); + } + + if (traceEnabled) { + this.logger.log(`[ReceiptDecision] ${trace.join(' | ')}`); + } enriched.push( finalSuggestion ? { ...item, categorySuggestion: finalSuggestion } : item, ); - } catch { + } catch (err) { + const traceSignalText = [ + item.rawName, + item.matchedProductName, + item.suggestedProductName, + ] + .filter((v): v is string => typeof v === 'string' && v.trim().length > 0) + .join(' '); + if (this.shouldTraceDecision(traceSignalText || item.rawName)) { + this.logger.warn( + `[ReceiptDecision] error raw="${item.rawName}" signal="${traceSignalText || item.rawName}" err=${String(err)}`, + ); + } // Om AI-anrop misslyckas för enskild vara — hoppa över utan att kasta enriched.push(item); } @@ -313,6 +371,22 @@ export class ReceiptImportService { return enriched; } + private shouldTraceDecision(signalText: string): boolean { + const envFlag = (process.env.RECEIPT_TRACE_DECISIONS ?? '').toLowerCase(); + if (envFlag === '1' || envFlag === 'true' || envFlag === 'yes') { + return true; + } + + const normalized = normalizeForRules(signalText); + return ( + /\bbacon\b/.test(normalized) || + /\bbacn\b/.test(normalized) || + /\bbaco\b/.test(normalized) || + /\bsidflask\b/.test(normalized) || + /\bpancetta\b/.test(normalized) + ); + } + private applyHardCategoryOverrides( signalText: string, suggestion: CategorySuggestion,