feat(receipt-import): expand deterministic category rules and AI contradiction guards

This commit is contained in:
Nils-Johan Gynther
2026-05-02 20:28:40 +02:00
parent 38613e0cf3
commit d823143611
2 changed files with 234 additions and 1 deletions
+4
View File
@@ -88,6 +88,10 @@
5. Lokalisera `receipt_import_tab.dart` och `swipeable_inventory_tile.dart`. 5. Lokalisera `receipt_import_tab.dart` och `swipeable_inventory_tile.dart`.
6. Smoke-test på testdomän och avstämning. 6. Smoke-test på testdomän och avstämning.
7. Planera och påbörja avancerad AI-integration och EAN-skanning. 7. Planera och påbörja avancerad AI-integration och EAN-skanning.
8. AI-kategorisering: kandidatbegränsning före AI (narrowing till relevant gren/kandidatlista istället för hela kategoriträdet).
9. AI-kategorisering: egen confidence-score i backend (regel/lexikal signal + AI-signal), inte enbart modellens confidence.
10. AI-kategorisering: logga användarkorrigeringar och bygg feedbackloop för nya regler/synonymer.
11. AI-kategorisering: utvärdera alternativ modell för kategorisering med A/B-test på historiska kvittorader.
## Beslut 2026-05-02 - Aliasstrategi för kvittoimport ## Beslut 2026-05-02 - Aliasstrategi för kvittoimport
@@ -261,7 +261,12 @@ export class ReceiptImportService {
} }
const suggestion = await this.aiService.suggestCategory(item.rawName, categories); 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 { } 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.set(item.rawName, item);
@@ -276,6 +281,188 @@ export class ReceiptImportService {
categories: Awaited<ReturnType<CategoriesService['findFlattened']>>, categories: Awaited<ReturnType<CategoriesService['findFlattened']>>,
): CategorySuggestion | null { ): CategorySuggestion | null {
const normalized = normalizeForRules(rawName); 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 ──────────────────────────────────────────────────────── // ── Regel: Te ────────────────────────────────────────────────────────
const isTea = const isTea =
@@ -380,4 +567,46 @@ export class ReceiptImportService {
return null; 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,
};
}
} }