feat(receipt-import): run rules and AI for all users with trust-aware overrides
This commit is contained in:
@@ -55,19 +55,16 @@ 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[]> {
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
@@ -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) {
|
||||||
try {
|
if (!item.rawName) {
|
||||||
const byRule = this.ruleBasedCategorySuggestion(item.rawName, categories);
|
enriched.push(item);
|
||||||
if (byRule) {
|
|
||||||
enriched.set(item.rawName, { ...item, categorySuggestion: byRule });
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const suggestion = await this.aiService.suggestCategory(item.rawName, categories);
|
try {
|
||||||
const guardedSuggestion = this.applyContradictionGuard(
|
const byRule = this.ruleBasedCategorySuggestion(item.rawName, categories);
|
||||||
item.rawName,
|
let nextSuggestion = item.categorySuggestion ?? null;
|
||||||
suggestion,
|
|
||||||
categories,
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
} 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(
|
||||||
|
|||||||
Reference in New Issue
Block a user