feat(receipt-import): expand deterministic category rules and AI contradiction guards
This commit is contained in:
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user