chore(receipt-import): add decision-path tracing for category pipeline
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user