feat(receipt-import): expand deterministic category rules and AI contradiction guards
This commit is contained in:
@@ -261,7 +261,12 @@ export class ReceiptImportService {
|
||||
}
|
||||
|
||||
const suggestion = await this.aiService.suggestCategory(item.rawName, categories);
|
||||
enriched.set(item.rawName, { ...item, categorySuggestion: suggestion });
|
||||
const guardedSuggestion = this.applyContradictionGuard(
|
||||
item.rawName,
|
||||
suggestion,
|
||||
categories,
|
||||
);
|
||||
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);
|
||||
@@ -276,6 +281,188 @@ export class ReceiptImportService {
|
||||
categories: Awaited<ReturnType<CategoriesService['findFlattened']>>,
|
||||
): CategorySuggestion | null {
|
||||
const normalized = normalizeForRules(rawName);
|
||||
const findCategory = (opts: {
|
||||
name: string;
|
||||
startsWith?: string;
|
||||
includes?: string;
|
||||
}) =>
|
||||
categories.find((c) => {
|
||||
const cName = c.name.toLowerCase();
|
||||
const cPath = c.path.toLowerCase();
|
||||
if (cName !== opts.name.toLowerCase()) return false;
|
||||
if (opts.startsWith && !cPath.startsWith(opts.startsWith.toLowerCase())) return false;
|
||||
if (opts.includes && !cPath.includes(opts.includes.toLowerCase())) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const toSuggestion = (
|
||||
cat: { id: number; name: string; path: string } | undefined,
|
||||
confidence: 'high' | 'medium' = 'high',
|
||||
): CategorySuggestion | null => {
|
||||
if (!cat) return null;
|
||||
return {
|
||||
categoryId: cat.id,
|
||||
categoryName: cat.name,
|
||||
path: cat.path,
|
||||
confidence,
|
||||
usedFallback: false,
|
||||
};
|
||||
};
|
||||
|
||||
// ── Regel: Kött/chark (bacon/fläsk m.m.) ────────────────────────────
|
||||
const hasPorkSignal =
|
||||
/\bbacon\b/.test(normalized) ||
|
||||
/\bsidflask\b/.test(normalized) ||
|
||||
/\bpancetta\b/.test(normalized) ||
|
||||
/\bflask\b/.test(normalized) ||
|
||||
/\bflaskfile\b/.test(normalized) ||
|
||||
/\bkarr[eé]\b/.test(normalized) ||
|
||||
/\bkotlett\b/.test(normalized);
|
||||
|
||||
if (hasPorkSignal) {
|
||||
const l3Pork = findCategory({
|
||||
name: 'fläsk',
|
||||
startsWith: 'kött, chark & fågel > kött > ',
|
||||
});
|
||||
const hit = toSuggestion(l3Pork, 'high');
|
||||
if (hit) return hit;
|
||||
}
|
||||
|
||||
// ── Regel: Korvfamiljen ─────────────────────────────────────────────
|
||||
const hasSausageSignal =
|
||||
/\bkorv\b/.test(normalized) ||
|
||||
/\bfalukorv\b/.test(normalized) ||
|
||||
/\bchorizo\b/.test(normalized) ||
|
||||
/\bbratwurst\b/.test(normalized) ||
|
||||
/\bwienerkorv\b/.test(normalized) ||
|
||||
/\bgrillkorv\b/.test(normalized) ||
|
||||
/\bprinskorv\b/.test(normalized) ||
|
||||
/\bolkorv\b/.test(normalized);
|
||||
|
||||
if (hasSausageSignal) {
|
||||
const isVegetarian =
|
||||
/\bvegetarisk\b/.test(normalized) ||
|
||||
/\bvegansk\b/.test(normalized) ||
|
||||
/\bvego\b/.test(normalized);
|
||||
if (isVegetarian) {
|
||||
const vegSausage = findCategory({
|
||||
name: 'vegetarisk korv',
|
||||
startsWith: 'kött, chark & fågel > korv > ',
|
||||
});
|
||||
const hit = toSuggestion(vegSausage, 'high');
|
||||
if (hit) return hit;
|
||||
}
|
||||
|
||||
if (/\bolkorv\b/.test(normalized)) {
|
||||
const beerSausage = findCategory({
|
||||
name: 'ölkorv',
|
||||
startsWith: 'kött, chark & fågel > korv > ',
|
||||
});
|
||||
const hit = toSuggestion(beerSausage, 'high');
|
||||
if (hit) return hit;
|
||||
}
|
||||
|
||||
const sausageGeneral = findCategory({
|
||||
name: 'grill, kok- & kryddkorv',
|
||||
startsWith: 'kött, chark & fågel > korv > ',
|
||||
});
|
||||
const hit = toSuggestion(sausageGeneral, 'high');
|
||||
if (hit) return hit;
|
||||
}
|
||||
|
||||
// ── Regel: Fågel (färsk/fryst) ──────────────────────────────────────
|
||||
const hasPoultrySignal =
|
||||
/\bkyckling\b/.test(normalized) ||
|
||||
/\bkalkon\b/.test(normalized) ||
|
||||
/\bdrumsticks?\b/.test(normalized) ||
|
||||
/\bling?file\b/.test(normalized) ||
|
||||
/\blarfile\b/.test(normalized) ||
|
||||
/\bkycklinglar\b/.test(normalized);
|
||||
|
||||
if (hasPoultrySignal) {
|
||||
const isFrozen = /\bfryst\b/.test(normalized) || /\bdjupfryst\b/.test(normalized);
|
||||
if (isFrozen) {
|
||||
const frozenPoultry = findCategory({
|
||||
name: 'fryst fågel',
|
||||
startsWith: 'kött, chark & fågel > fågel > ',
|
||||
});
|
||||
const hit = toSuggestion(frozenPoultry, 'high');
|
||||
if (hit) return hit;
|
||||
}
|
||||
|
||||
const freshPoultry = findCategory({
|
||||
name: 'färsk fågel',
|
||||
startsWith: 'kött, chark & fågel > fågel > ',
|
||||
});
|
||||
const hit = toSuggestion(freshPoultry, 'high');
|
||||
if (hit) return hit;
|
||||
}
|
||||
|
||||
// ── Regel: Pålägg (korv/salami vs skivat) ───────────────────────────
|
||||
const hasColdCutSignal =
|
||||
/\bpalagg\b/.test(normalized) ||
|
||||
/\bpalegg\b/.test(normalized) ||
|
||||
/\bskivad\b/.test(normalized) ||
|
||||
/\bsalami\b/.test(normalized) ||
|
||||
/\bmedvurst\b/.test(normalized) ||
|
||||
/\bpastrami\b/.test(normalized) ||
|
||||
/\bskinka\b/.test(normalized);
|
||||
|
||||
if (hasColdCutSignal) {
|
||||
const hasSlicedSausageSignal =
|
||||
/\bsalami\b/.test(normalized) ||
|
||||
/\bmedvurst\b/.test(normalized) ||
|
||||
/\bpastrami\b/.test(normalized);
|
||||
if (hasSlicedSausageSignal) {
|
||||
const salamiColdCut = findCategory({
|
||||
name: 'korv & salami',
|
||||
startsWith: 'kött, chark & fågel > pålägg > ',
|
||||
});
|
||||
const hit = toSuggestion(salamiColdCut, 'high');
|
||||
if (hit) return hit;
|
||||
}
|
||||
|
||||
const slicedColdCut = findCategory({
|
||||
name: 'skivat pålägg',
|
||||
startsWith: 'kött, chark & fågel > pålägg > ',
|
||||
});
|
||||
const hit = toSuggestion(slicedColdCut, 'high');
|
||||
if (hit) return hit;
|
||||
}
|
||||
|
||||
// ── Regel: Färs ──────────────────────────────────────────────────────
|
||||
const hasMinceSignal =
|
||||
/\bfars\b/.test(normalized) ||
|
||||
/\bfarse\b/.test(normalized) ||
|
||||
/\bmince\b/.test(normalized) ||
|
||||
/\bköttfärs\b/.test(rawName.toLowerCase());
|
||||
|
||||
if (hasMinceSignal && !hasPoultrySignal) {
|
||||
const mince = findCategory({
|
||||
name: 'köttfärs',
|
||||
startsWith: 'kött, chark & fågel > kött > ',
|
||||
});
|
||||
const hit = toSuggestion(mince, 'high');
|
||||
if (hit) return hit;
|
||||
}
|
||||
|
||||
// ── Regel: Nöt/kalv (hela detaljer) ─────────────────────────────────
|
||||
const hasBeefVealSignal =
|
||||
/\bnot\b/.test(normalized) ||
|
||||
/\bkalv\b/.test(normalized) ||
|
||||
/\bbiff\b/.test(normalized) ||
|
||||
/\bentrecote\b/.test(normalized) ||
|
||||
/\brostbiff\b/.test(normalized) ||
|
||||
/\bryggbiff\b/.test(normalized);
|
||||
|
||||
if (hasBeefVealSignal && !hasMinceSignal) {
|
||||
const beefVeal = findCategory({
|
||||
name: 'nöt & kalv',
|
||||
startsWith: 'kött, chark & fågel > kött > ',
|
||||
});
|
||||
const hit = toSuggestion(beefVeal, 'high');
|
||||
if (hit) return hit;
|
||||
}
|
||||
|
||||
// ── Regel: Te ────────────────────────────────────────────────────────
|
||||
const isTea =
|
||||
@@ -380,4 +567,46 @@ export class ReceiptImportService {
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private applyContradictionGuard(
|
||||
rawName: string,
|
||||
suggestion: CategorySuggestion,
|
||||
categories: Awaited<ReturnType<CategoriesService['findFlattened']>>,
|
||||
): CategorySuggestion {
|
||||
const normalized = normalizeForRules(rawName);
|
||||
const hasPorkSignal =
|
||||
/\bbacon\b/.test(normalized) ||
|
||||
/\bsidflask\b/.test(normalized) ||
|
||||
/\bpancetta\b/.test(normalized) ||
|
||||
/\bflask\b/.test(normalized) ||
|
||||
/\bflaskfile\b/.test(normalized) ||
|
||||
/\bkarr[eé]\b/.test(normalized) ||
|
||||
/\bkotlett\b/.test(normalized);
|
||||
|
||||
if (!hasPorkSignal) return suggestion;
|
||||
|
||||
const aiPath = suggestion.path.toLowerCase();
|
||||
const isClearlyWrongBranch =
|
||||
aiPath.includes('köttbullar & färsprodukter') || aiPath.includes('köttfärs');
|
||||
|
||||
if (!isClearlyWrongBranch) return suggestion;
|
||||
|
||||
const l3Pork = categories.find(
|
||||
(c) =>
|
||||
c.name.toLowerCase() === 'fläsk' &&
|
||||
c.path.toLowerCase().startsWith('kött, chark & fågel > kött > '),
|
||||
);
|
||||
if (!l3Pork) return suggestion;
|
||||
|
||||
this.logger.log(
|
||||
`AI contradiction-guard: "${rawName}" remappas från "${suggestion.path}" till "${l3Pork.path}"`,
|
||||
);
|
||||
return {
|
||||
categoryId: l3Pork.id,
|
||||
categoryName: l3Pork.name,
|
||||
path: l3Pork.path,
|
||||
confidence: 'high',
|
||||
usedFallback: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user