chore(receipt-import): add decision-path tracing for category pipeline

This commit is contained in:
Nils-Johan Gynther
2026-05-02 22:58:45 +02:00
parent 60056b94bf
commit 4345547cbf
@@ -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,