feat(receipt-import): run rules and AI for all users with trust-aware overrides

This commit is contained in:
Nils-Johan Gynther
2026-05-02 20:48:47 +02:00
parent 6733a50cfb
commit a88d6e2452
@@ -55,18 +55,15 @@ export class ReceiptImportService {
private readonly categoriesService: CategoriesService, private readonly categoriesService: CategoriesService,
) {} ) {}
async parseReceipt(file: Express.Multer.File, isPremium = false, userId?: number): Promise<ParsedReceiptItem[]> { async parseReceipt(file: Express.Multer.File, _isPremium = false, userId?: number): Promise<ParsedReceiptItem[]> {
// Steg 1: Delegera AI-parsning till microservice-importer // Steg 1: Delegera AI-parsning till microservice-importer
const rawItems = await this.parseReceiptViaImporter(file); const rawItems = await this.parseReceiptViaImporter(file);
// Steg 2: Matchning mot produktdatabas (kräver DB — stannar i recipe-app) // Steg 2: Matchning mot produktdatabas (kräver DB — stannar i recipe-app)
const matched = await this.matchProducts(rawItems, userId); const matched = await this.matchProducts(rawItems, userId);
// Steg 3: AI-kategorisering för premium-användare // Steg 3: Regel + AI-kategorisering för alla användare
if (isPremium) { return this.enrichWithAiCategories(matched);
return this.enrichWithAiCategories(matched);
}
return matched;
} }
private async parseReceiptViaImporter(file: Express.Multer.File): Promise<ParsedReceiptItem[]> { private async parseReceiptViaImporter(file: Express.Multer.File): Promise<ParsedReceiptItem[]> {
@@ -240,10 +237,6 @@ export class ReceiptImportService {
} }
private async enrichWithAiCategories(items: ParsedReceiptItem[]): Promise<ParsedReceiptItem[]> { private async enrichWithAiCategories(items: ParsedReceiptItem[]): Promise<ParsedReceiptItem[]> {
// 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<ReturnType<CategoriesService['findFlattened']>>; let categories: Awaited<ReturnType<CategoriesService['findFlattened']>>;
try { try {
categories = await this.categoriesService.findFlattened(); 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 return items; // Om kategoritjänsten är otillgänglig, returnera utan AI-förslag
} }
const enriched = new Map<string, ParsedReceiptItem>(); const enriched: ParsedReceiptItem[] = [];
for (const item of unmatched) { for (const item of items) {
if (!item.rawName) {
enriched.push(item);
continue;
}
try { try {
const byRule = this.ruleBasedCategorySuggestion(item.rawName, categories); const byRule = this.ruleBasedCategorySuggestion(item.rawName, categories);
if (byRule) { let nextSuggestion = item.categorySuggestion ?? null;
enriched.set(item.rawName, { ...item, categorySuggestion: byRule });
continue; 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); // AI används som fallback när varken matchning eller regler satte kategori
const guardedSuggestion = this.applyContradictionGuard( if (!nextSuggestion) {
item.rawName, nextSuggestion = await this.aiService.suggestCategory(item.rawName, categories);
suggestion, }
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 { } catch {
// Om AI-anrop misslyckas för enskild vara — hoppa över utan att kasta // 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( private ruleBasedCategorySuggestion(