feat(receipt-import): run rules and AI for all users with trust-aware overrides
This commit is contained in:
@@ -55,18 +55,15 @@ export class ReceiptImportService {
|
||||
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
|
||||
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<ParsedReceiptItem[]> {
|
||||
@@ -240,10 +237,6 @@ export class ReceiptImportService {
|
||||
}
|
||||
|
||||
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']>>;
|
||||
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<string, ParsedReceiptItem>();
|
||||
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(
|
||||
|
||||
Reference in New Issue
Block a user