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)
|
.filter((v): v is string => typeof v === 'string' && v.trim().length > 0)
|
||||||
.join(' ');
|
.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);
|
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;
|
let nextSuggestion = item.categorySuggestion ?? null;
|
||||||
|
|
||||||
const isTrustedSuggestion =
|
const isTrustedSuggestion =
|
||||||
@@ -272,6 +293,7 @@ export class ReceiptImportService {
|
|||||||
nextSuggestion != null && nextSuggestion.categoryId === byRule.categoryId;
|
nextSuggestion != null && nextSuggestion.categoryId === byRule.categoryId;
|
||||||
if (!sameAsCurrent && (!isTrustedSuggestion || nextSuggestion == null)) {
|
if (!sameAsCurrent && (!isTrustedSuggestion || nextSuggestion == null)) {
|
||||||
nextSuggestion = byRule;
|
nextSuggestion = byRule;
|
||||||
|
pushTrace(`rule applied -> "${byRule.path}"`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Om regler säger en annan kategori än ett redan "trusted" förslag,
|
// 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}"`,
|
`Rule-override: "${item.rawName}" ändras från "${nextSuggestion?.path}" till "${byRule.path}"`,
|
||||||
);
|
);
|
||||||
nextSuggestion = byRule;
|
nextSuggestion = byRule;
|
||||||
|
pushTrace(`rule override trusted -> "${byRule.path}"`);
|
||||||
}
|
}
|
||||||
} else if (!nextSuggestion && byRule) {
|
} else if (!nextSuggestion && byRule) {
|
||||||
nextSuggestion = byRule;
|
nextSuggestion = byRule;
|
||||||
|
pushTrace(`rule fallback applied -> "${byRule.path}"`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// AI används som fallback när varken matchning eller regler satte kategori
|
// AI används som fallback när varken matchning eller regler satte kategori
|
||||||
if (!nextSuggestion) {
|
if (!nextSuggestion) {
|
||||||
|
pushTrace('ai invoked');
|
||||||
nextSuggestion = await this.aiService.suggestCategory(item.rawName, categories);
|
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
|
const guardedSuggestion = nextSuggestion
|
||||||
? this.applyContradictionGuard(signalText || item.rawName, nextSuggestion, categories)
|
? this.applyContradictionGuard(signalText || item.rawName, nextSuggestion, categories)
|
||||||
: null;
|
: null;
|
||||||
|
if (guardedSuggestion && beforeGuardPath !== guardedSuggestion.path) {
|
||||||
|
pushTrace(`contradiction guard remap "${beforeGuardPath}" -> "${guardedSuggestion.path}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const beforeHardPath = guardedSuggestion?.path;
|
||||||
const finalSuggestion = guardedSuggestion
|
const finalSuggestion = guardedSuggestion
|
||||||
? this.applyHardCategoryOverrides(signalText || item.rawName, guardedSuggestion, categories)
|
? this.applyHardCategoryOverrides(signalText || item.rawName, guardedSuggestion, categories)
|
||||||
: null;
|
: 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(
|
enriched.push(
|
||||||
finalSuggestion
|
finalSuggestion
|
||||||
? { ...item, categorySuggestion: finalSuggestion }
|
? { ...item, categorySuggestion: finalSuggestion }
|
||||||
: item,
|
: 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
|
// Om AI-anrop misslyckas för enskild vara — hoppa över utan att kasta
|
||||||
enriched.push(item);
|
enriched.push(item);
|
||||||
}
|
}
|
||||||
@@ -313,6 +371,22 @@ export class ReceiptImportService {
|
|||||||
return enriched;
|
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(
|
private applyHardCategoryOverrides(
|
||||||
signalText: string,
|
signalText: string,
|
||||||
suggestion: CategorySuggestion,
|
suggestion: CategorySuggestion,
|
||||||
|
|||||||
Reference in New Issue
Block a user