feat: deprecate matchProducts and enrichWithAiCategories methods, update categorization logic with unified matcher
Test Suite / test (24.15.0) (push) Has been cancelled

This commit is contained in:
Nils-Johan Gynther
2026-05-09 15:27:24 +02:00
parent 000a28bea4
commit 4d5c55f459
@@ -541,6 +541,14 @@ export class ReceiptImportService {
return items.filter((item) => !isIgnoredReceiptName(item.rawName)); return items.filter((item) => !isIgnoredReceiptName(item.rawName));
} }
/**
* @deprecated CLEANUP PENDING (Session 2026-05-09)
*
* Ersatt av unified matcher (matchAndEnrichReceiptItem).
* Denna metod körde alias-lookup + word-match separat.
*
* Cleanup: Se enrichWithAiCategories checklist ovan.
*/
private async matchProducts( private async matchProducts(
items: ParsedReceiptItem[], items: ParsedReceiptItem[],
userId?: number, userId?: number,
@@ -723,6 +731,14 @@ export class ReceiptImportService {
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// UNIFIED MATCHER: Kombinerar product matching + categorization // UNIFIED MATCHER: Kombinerar product matching + categorization
//
// KATEGORI-HIERARKI (fallback-first):
// 1. Alias-kopplad produkt (om fanns)
// 2. Word-match-kopplad produkt (om fanns)
// 3. Regel-baserad (deterministisk, alltid försökt)
// 4. AI-kategorisering (BARA som fallback när allt annat misslyckades, och om aktiverat)
//
// AI kallas ALDRIG om regel-baserad eller produkt-koppling redan satte kategori.
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
private async matchAndEnrichReceiptItem( private async matchAndEnrichReceiptItem(
@@ -766,7 +782,7 @@ export class ReceiptImportService {
(um) => um.productId === aliasMatch.product.id && um.originalUnit === (item.unit ?? '').trim().toLowerCase(), (um) => um.productId === aliasMatch.product.id && um.originalUnit === (item.unit ?? '').trim().toLowerCase(),
)?.preferredUnit; )?.preferredUnit;
return { const aliasResult: ParsedReceiptItem = {
...item, ...item,
matchedProductId: aliasMatch.product.id, matchedProductId: aliasMatch.product.id,
matchedProductName: aliasMatch.product.canonicalName ?? aliasMatch.product.name, matchedProductName: aliasMatch.product.canonicalName ?? aliasMatch.product.name,
@@ -784,6 +800,9 @@ export class ReceiptImportService {
} }
: {}), : {}),
}; };
// Kör alltid enrichCategoryForItem för guard-funktioner och hard overrides
return await this.enrichCategoryForItem(aliasResult, context, debug);
} }
debug.steps.push(` ✗ No alias match`); debug.steps.push(` ✗ No alias match`);
debug.tree.alias = { found: false }; debug.tree.alias = { found: false };
@@ -846,6 +865,12 @@ export class ReceiptImportService {
}, },
debug: any, debug: any,
): Promise<ParsedReceiptItem> { ): Promise<ParsedReceiptItem> {
// Kategori-hierarki:
// 1. Om produktmatchning redan satte kategori, börjar vi med den
// 2. Försöker regel-baserad kategorisering (HIGH confidence: ersätt, fallback: använd om tom)
// 3. AI kallas ENDAST om ingen kategori satts än (nextCategory === null)
// 4. Guards och hard overrides tillämpas på slutresultatet
debug.steps.push('Step 3: Categorization'); debug.steps.push('Step 3: Categorization');
const signalText = [item.rawName, item.matchedProductName, item.suggestedProductName] const signalText = [item.rawName, item.matchedProductName, item.suggestedProductName]
@@ -854,7 +879,7 @@ export class ReceiptImportService {
let nextCategory = item.categorySuggestion ?? null; let nextCategory = item.categorySuggestion ?? null;
// ┌─ Försök regel-baserad kategorisering ─────────────────────────────┐ // ┌─ STEG 3A: Försök regel-baserad kategorisering ─────────────────────┐
debug.steps.push(' Trying rule-based categorization'); debug.steps.push(' Trying rule-based categorization');
const ruleResult = this.ruleBasedCategorySuggestion(signalText || item.rawName, context.categories); const ruleResult = this.ruleBasedCategorySuggestion(signalText || item.rawName, context.categories);
debug.tree.rule = { found: !!ruleResult, path: ruleResult?.path }; debug.tree.rule = { found: !!ruleResult, path: ruleResult?.path };
@@ -874,9 +899,13 @@ export class ReceiptImportService {
debug.steps.push(` ✗ Rule-based miss or lower priority`); debug.steps.push(` ✗ Rule-based miss or lower priority`);
} }
// ┌─ AI-kategorisering som fallback ──────────────────────────────────┐ // ┌─ STEG 3B: AI-kategorisering ENDAST som fallback ────────────────────┐
// AI kallas bara om:
// 1) nextCategory är fortfarande NULL (regel-baserad misslyckades/fanns inte)
// 2) User har AI aktiverat (context.aiEnabled === true)
// AI ersätter ALDRIG redan satta kategorier.
if (!nextCategory) { if (!nextCategory) {
debug.steps.push(' Trying AI categorization'); debug.steps.push(' Trying AI categorization (fallback: no category set yet)');
if (context.aiEnabled) { if (context.aiEnabled) {
debug.tree.ai = { called: true }; debug.tree.ai = { called: true };
try { try {
@@ -890,6 +919,9 @@ export class ReceiptImportService {
debug.steps.push(` ✗ AI disabled for user`); debug.steps.push(` ✗ AI disabled for user`);
debug.tree.ai = { called: false }; debug.tree.ai = { called: false };
} }
} else {
debug.steps.push(` ⊘ AI skipped (category already set: ${nextCategory.path})`);
debug.tree.ai = { called: false, reason: 'category_already_set' };
} }
// ┌─ Contradiction guard (final sanity check) ────────────────────────┐ // ┌─ Contradiction guard (final sanity check) ────────────────────────┐
@@ -1008,6 +1040,19 @@ export class ReceiptImportService {
return best; return best;
} }
/**
* @deprecated CLEANUP PENDING (Session 2026-05-09)
*
* Denna metod är ersatt av unified matcher (matchAndEnrichReceiptItem + enrichCategoryForItem).
* Den användes för att köra regel-baserad och AI-kategorisering separat från product-matching.
*
* Cleanup checklist:
* - [ ] Ta bort denna metod
* - [ ] Ta bort matchProducts() metod
* - [ ] Ta bort findWordMatch() (gammal version)
* - [ ] Uppdatera kommentarer
* - [ ] Kör full test suite för regression detection
*/
private async enrichWithAiCategories(items: ParsedReceiptItem[], userId?: number): Promise<ParsedReceiptItem[]> { private async enrichWithAiCategories(items: ParsedReceiptItem[], userId?: number): Promise<ParsedReceiptItem[]> {
let categories: Awaited<ReturnType<CategoriesService['findFlattened']>>; let categories: Awaited<ReturnType<CategoriesService['findFlattened']>>;
try { try {