feat(ai): enhance AI trace warnings and reason codes system
- Added structured warning system with `AdminAiWarning` type in backend and Flutter - Implemented detailed reason descriptors with `FlyerReasonDescriptor` for parse and match operations - Added `legacyWarnings` field to maintain backward compatibility - Enhanced AI trace service to collect and format warnings with item-level context - Updated flyer import services to include detailed reason descriptions in responses - Added Swedish diacritic preservation for cheese variants (Prästost, Herrgårdsost, Grevéost) - Implemented UTF-8 content validation for AI responses - Added new reason code definitions in `reason-codes.ts` - Updated Flutter UI to display structured warnings with severity indicators - Added error report generation and copy functionality in admin panel - Added comprehensive test coverage for new warning system and cheese normalization BREAKING CHANGE: AI trace warnings are now structured objects instead of simple strings
This commit is contained in:
@@ -143,6 +143,71 @@ describe('AiTraceService receipt masking', () => {
|
||||
expect(result.rawOutput).toContain('{"ok":true}');
|
||||
expect(result.retryCount).toBe(2);
|
||||
expect(result.chunkCount).toBe(4);
|
||||
expect(result.warnings).toContain('parse:low_confidence');
|
||||
expect(result.warnings).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
kind: 'parse',
|
||||
code: 'low_confidence',
|
||||
title: 'Låg parsningskvalitet',
|
||||
severity: 'warning',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(result.legacyWarnings).toContain('parse:low_confidence');
|
||||
});
|
||||
|
||||
it('keeps multiple token_overlap warnings for same row', async () => {
|
||||
prismaMock.flyerSession.findUnique.mockResolvedValue({
|
||||
id: 202,
|
||||
userId: 9,
|
||||
createdAt: new Date('2026-05-23T09:00:00.000Z'),
|
||||
sourceFileName: 'willys-v21.pdf',
|
||||
sourceMimeType: 'application/pdf',
|
||||
sourceFileSize: 2222,
|
||||
user: { username: 'admin', email: 'admin@example.com' },
|
||||
items: [
|
||||
{
|
||||
id: 11,
|
||||
rawName: 'Tomatmix',
|
||||
normalizedName: 'tomatmix',
|
||||
brand: null,
|
||||
categoryHint: 'Grönsaker',
|
||||
categoryId: null,
|
||||
price: null,
|
||||
priceUnit: null,
|
||||
comparisonPrice: null,
|
||||
comparisonUnit: null,
|
||||
weight: null,
|
||||
bundleWeight: null,
|
||||
isBundle: false,
|
||||
bundleItems: [],
|
||||
offerText: null,
|
||||
parseConfidence: 0.9,
|
||||
parseReasons: [],
|
||||
matchedProductId: null,
|
||||
matchedProductName: null,
|
||||
matchedVia: 'token',
|
||||
matchConfidence: 0.7,
|
||||
matchReasons: ['token_overlap:0.42', 'token_overlap:0.73'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
prismaMock.aiTrace.findMany.mockResolvedValue([
|
||||
{
|
||||
sessionId: 202,
|
||||
prompt: 'prompt',
|
||||
rawOutput: '{"ok":true}',
|
||||
normalizedOutput: { retryCount: 0, chunkCount: 1 },
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await service.getTraceById('flyer-202');
|
||||
|
||||
const tokenWarnings = result.warnings.filter((warning) => warning.code === 'token_overlap');
|
||||
expect(tokenWarnings).toHaveLength(2);
|
||||
expect(result.legacyWarnings).toEqual(
|
||||
expect.arrayContaining(['match:token_overlap:0.42', 'match:token_overlap:0.73']),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import {
|
||||
describeMatchReason,
|
||||
describeParseReason,
|
||||
FlyerReasonDescriptor,
|
||||
} from '../flyer-import/services/reason-codes';
|
||||
|
||||
export type AiTraceSource = 'receipt' | 'flyer';
|
||||
|
||||
@@ -45,7 +50,8 @@ export type AiTraceDetail = {
|
||||
durationMs: number | null;
|
||||
retryCount: number | null;
|
||||
chunkCount: number | null;
|
||||
warnings: string[];
|
||||
warnings: AdminAiWarning[];
|
||||
legacyWarnings: string[];
|
||||
error: string | null;
|
||||
prompt: string | null;
|
||||
rawOutput: string | null;
|
||||
@@ -53,6 +59,10 @@ export type AiTraceDetail = {
|
||||
summary: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type AdminAiWarning = FlyerReasonDescriptor & {
|
||||
itemIndex?: number;
|
||||
};
|
||||
|
||||
type FlyerTraceSupplement = {
|
||||
prompt: string | null;
|
||||
rawOutput: string | null;
|
||||
@@ -107,11 +117,14 @@ export class AiTraceService {
|
||||
const page = hasMore ? sessions.slice(0, take) : sessions;
|
||||
|
||||
const items: AiTraceListItem[] = page.map((session) => {
|
||||
const warningsCount = session.items.reduce((sum, item) => {
|
||||
const parseWarnings = Array.isArray(item.parseReasons) ? item.parseReasons.length : 0;
|
||||
const matchWarnings = Array.isArray(item.matchReasons) ? item.matchReasons.length : 0;
|
||||
return sum + parseWarnings + matchWarnings;
|
||||
}, 0);
|
||||
const warningSet = this.collectWarnings(
|
||||
session.items.map((item, itemIndex) => ({
|
||||
parseReasons: item.parseReasons,
|
||||
matchReasons: item.matchReasons,
|
||||
itemIndex,
|
||||
})),
|
||||
);
|
||||
const warningsCount = this.countActionableWarnings(warningSet.warnings);
|
||||
const status = this.statusFromSession(session.items.length, warningsCount);
|
||||
return {
|
||||
id: this.flyerTraceId(session.id),
|
||||
@@ -205,8 +218,18 @@ export class AiTraceService {
|
||||
throw new NotFoundException('AI-trace hittades inte.');
|
||||
}
|
||||
|
||||
const warnings = this.collectWarnings(session.items);
|
||||
const status = this.statusFromSession(session.items.length, warnings.length);
|
||||
const warningSet = this.collectWarnings(
|
||||
session.items.map((item, itemIndex) => ({
|
||||
parseReasons: item.parseReasons,
|
||||
matchReasons: item.matchReasons,
|
||||
itemIndex,
|
||||
})),
|
||||
);
|
||||
const warnings = warningSet.warnings;
|
||||
const status = this.statusFromSession(
|
||||
session.items.length,
|
||||
this.countActionableWarnings(warnings),
|
||||
);
|
||||
const supplement = await this.getFlyerTraceSupplementBySessionId(session.id);
|
||||
|
||||
const normalizedOutput = {
|
||||
@@ -241,6 +264,7 @@ export class AiTraceService {
|
||||
matchReasons: Array.isArray(item.matchReasons) ? item.matchReasons : [],
|
||||
})),
|
||||
warnings,
|
||||
legacyWarnings: warningSet.legacyWarnings,
|
||||
} as Record<string, unknown>;
|
||||
|
||||
return {
|
||||
@@ -257,6 +281,7 @@ export class AiTraceService {
|
||||
retryCount: supplement.retryCount,
|
||||
chunkCount: supplement.chunkCount,
|
||||
warnings,
|
||||
legacyWarnings: warningSet.legacyWarnings,
|
||||
error: session.items.length === 0 ? 'Inga produkter kunde extraheras från flyern.' : null,
|
||||
prompt: supplement.prompt,
|
||||
rawOutput:
|
||||
@@ -266,7 +291,7 @@ export class AiTraceService {
|
||||
source: 'flyer',
|
||||
sessionId: session.id,
|
||||
itemCount: session.items.length,
|
||||
warningsCount: warnings.length,
|
||||
warningsCount: this.countActionableWarnings(warnings),
|
||||
promptAvailable: !!supplement.prompt,
|
||||
outputAvailable: true,
|
||||
retentionHintDays: 30,
|
||||
@@ -432,23 +457,58 @@ export class AiTraceService {
|
||||
return `user:${userId}`;
|
||||
}
|
||||
|
||||
private collectWarnings(items: Array<{ parseReasons: unknown; matchReasons: unknown }>): string[] {
|
||||
const warnings = new Set<string>();
|
||||
private collectWarnings(items: Array<{ parseReasons: unknown; matchReasons: unknown; itemIndex?: number }>): {
|
||||
warnings: AdminAiWarning[];
|
||||
legacyWarnings: string[];
|
||||
} {
|
||||
const warnings: AdminAiWarning[] = [];
|
||||
const legacyWarnings = new Set<string>();
|
||||
const dedupe = new Set<string>();
|
||||
|
||||
for (const item of items) {
|
||||
const itemIndex = item.itemIndex != null ? item.itemIndex + 1 : undefined;
|
||||
|
||||
if (Array.isArray(item.parseReasons)) {
|
||||
for (const reason of item.parseReasons) {
|
||||
const text = String(reason ?? '').trim();
|
||||
if (text.length > 0) warnings.add(`parse:${text}`);
|
||||
if (!text) continue;
|
||||
const warning: AdminAiWarning = {
|
||||
...describeParseReason(text),
|
||||
itemIndex,
|
||||
};
|
||||
const key = `${warning.kind}:${text}:${warning.itemIndex ?? 0}`;
|
||||
if (dedupe.has(key)) continue;
|
||||
dedupe.add(key);
|
||||
warnings.push(warning);
|
||||
legacyWarnings.add(`parse:${text}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(item.matchReasons)) {
|
||||
for (const reason of item.matchReasons) {
|
||||
const text = String(reason ?? '').trim();
|
||||
if (text.length > 0) warnings.add(`match:${text}`);
|
||||
if (!text) continue;
|
||||
const warning: AdminAiWarning = {
|
||||
...describeMatchReason(text),
|
||||
itemIndex,
|
||||
};
|
||||
const key = `${warning.kind}:${text}:${warning.itemIndex ?? 0}`;
|
||||
if (dedupe.has(key)) continue;
|
||||
dedupe.add(key);
|
||||
warnings.push(warning);
|
||||
legacyWarnings.add(`match:${text}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return Array.from(warnings);
|
||||
|
||||
return {
|
||||
warnings,
|
||||
legacyWarnings: Array.from(legacyWarnings),
|
||||
};
|
||||
}
|
||||
|
||||
private countActionableWarnings(warnings: AdminAiWarning[]): number {
|
||||
return warnings.filter((warning) => warning.severity !== 'info').length;
|
||||
}
|
||||
|
||||
private async listReceiptTraces(params: {
|
||||
@@ -553,6 +613,7 @@ export class AiTraceService {
|
||||
retryCount: null,
|
||||
chunkCount: null,
|
||||
warnings: [],
|
||||
legacyWarnings: [],
|
||||
error: row.error,
|
||||
prompt: row.prompt ? this.maskSensitiveText(row.prompt) : null,
|
||||
rawOutput: this.maskRawOutput(row.rawOutput),
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
export type FlyerImportMatchVia = 'alias' | 'exact' | 'token' | 'none';
|
||||
export type FlyerImportMatchVia = 'alias' | 'exact' | 'token' | 'none';
|
||||
|
||||
export type FlyerReasonDescriptor = {
|
||||
code: string;
|
||||
kind: 'parse' | 'match';
|
||||
title: string;
|
||||
message: string;
|
||||
severity: 'info' | 'warning' | 'error';
|
||||
location: string | null;
|
||||
};
|
||||
|
||||
export type FlyerImportItem = {
|
||||
flyerItemId: number | null;
|
||||
@@ -18,14 +27,16 @@ export type FlyerImportItem = {
|
||||
offerText: string | null;
|
||||
isOffer: boolean;
|
||||
offerLimitText: string | null;
|
||||
parseConfidence: number;
|
||||
parseReasons: string[];
|
||||
matchedProductId: number | null;
|
||||
matchedProductName: string | null;
|
||||
matchedVia: FlyerImportMatchVia;
|
||||
matchConfidence: number;
|
||||
matchReasons: string[];
|
||||
};
|
||||
parseConfidence: number;
|
||||
parseReasons: string[];
|
||||
parseReasonsDetailed: FlyerReasonDescriptor[];
|
||||
matchedProductId: number | null;
|
||||
matchedProductName: string | null;
|
||||
matchedVia: FlyerImportMatchVia;
|
||||
matchConfidence: number;
|
||||
matchReasons: string[];
|
||||
matchReasonsDetailed: FlyerReasonDescriptor[];
|
||||
};
|
||||
|
||||
export type FlyerImportResponse = {
|
||||
sessionId: number | null;
|
||||
|
||||
@@ -97,6 +97,8 @@ describe('FlyerImportService', () => {
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.items[0].flyerItemId).toBe(99);
|
||||
expect(result.items[0].matchedVia).toBe('exact');
|
||||
expect(result.items[0].parseReasonsDetailed[0].title).toBe('AI-tolkad rad');
|
||||
expect(result.items[0].matchReasonsDetailed[0].title).toBe('Exakt normaliserad matchning');
|
||||
expect(result.sourceAvailable).toBe(false);
|
||||
});
|
||||
|
||||
|
||||
@@ -9,14 +9,15 @@ import {
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { normalizeName } from '../common/utils/normalize-name';
|
||||
import {
|
||||
FlyerImportItem,
|
||||
FlyerImportMatchVia,
|
||||
FlyerImportResponse,
|
||||
} from './dto/flyer-import.response';
|
||||
import {
|
||||
FlyerImportItem,
|
||||
FlyerImportMatchVia,
|
||||
FlyerImportResponse,
|
||||
} from './dto/flyer-import.response';
|
||||
import { TextExtractorService } from './services/text-extractor.service';
|
||||
import { AiFlyerParserService } from './services/ai-flyer-parser.service';
|
||||
import { FlyerNormalizerService } from './services/flyer-normalizer.service';
|
||||
import { FlyerNormalizerService } from './services/flyer-normalizer.service';
|
||||
import { describeMatchReason, describeParseReason } from './services/reason-codes';
|
||||
|
||||
type FlyerParseItem = {
|
||||
rawName: string;
|
||||
@@ -135,13 +136,15 @@ export class FlyerImportService {
|
||||
offerLimitText,
|
||||
parseConfidence: item.confidence,
|
||||
parseReasons: item.reasonCodes,
|
||||
matchedProductId: match.product?.id ?? null,
|
||||
matchedProductName: match.product?.name ?? null,
|
||||
matchedVia: match.via,
|
||||
matchConfidence: match.confidence,
|
||||
matchReasons: match.reasons,
|
||||
};
|
||||
});
|
||||
parseReasonsDetailed: this.describeParseReasons(item.reasonCodes),
|
||||
matchedProductId: match.product?.id ?? null,
|
||||
matchedProductName: match.product?.name ?? null,
|
||||
matchedVia: match.via,
|
||||
matchConfidence: match.confidence,
|
||||
matchReasons: match.reasons,
|
||||
matchReasonsDetailed: this.describeMatchReasons(match.reasons),
|
||||
};
|
||||
});
|
||||
|
||||
const persistedItems = await this.persistSessionWithItems(userId, parsed.retailer, items, file);
|
||||
|
||||
@@ -790,14 +793,24 @@ export class FlyerImportService {
|
||||
offerLimitText,
|
||||
parseConfidence: item.parseConfidence,
|
||||
parseReasons: toStringArray(item.parseReasons),
|
||||
parseReasonsDetailed: this.describeParseReasons(toStringArray(item.parseReasons)),
|
||||
matchedProductId: item.matchedProductId,
|
||||
matchedProductName: item.matchedProductName,
|
||||
matchedVia: normalizedMatchVia,
|
||||
matchConfidence: item.matchConfidence ?? 0,
|
||||
matchReasons: toStringArray(item.matchReasons),
|
||||
matchReasonsDetailed: this.describeMatchReasons(toStringArray(item.matchReasons)),
|
||||
};
|
||||
}
|
||||
|
||||
private describeParseReasons(codes: string[]) {
|
||||
return codes.map((code) => describeParseReason(code));
|
||||
}
|
||||
|
||||
private describeMatchReasons(codes: string[]) {
|
||||
return codes.map((code) => describeMatchReason(code));
|
||||
}
|
||||
|
||||
private buildCategoryPath(categoryRef?: {
|
||||
name: string;
|
||||
parent?: {
|
||||
|
||||
@@ -4,6 +4,15 @@ import { AiFlyerParserService } from './ai-flyer-parser.service';
|
||||
describe('AiFlyerParserService dedupe', () => {
|
||||
const service = Object.create(AiFlyerParserService.prototype) as AiFlyerParserService;
|
||||
|
||||
it('buildPrompt enforces Swedish diacritics for cheese variants', () => {
|
||||
const prompt = (service as any).buildPrompt('PRAST, HERRGARD, GREVE', 3000) as string;
|
||||
|
||||
expect(prompt).toContain('Behåll svenska diakritiska tecken (ä, å, ö, é)');
|
||||
expect(prompt).toContain('Prästost');
|
||||
expect(prompt).toContain('Herrgårdsost');
|
||||
expect(prompt).toContain('Grevéost');
|
||||
});
|
||||
|
||||
it('dedupes same product with minor offer text differences', () => {
|
||||
const items = [
|
||||
{
|
||||
|
||||
@@ -214,10 +214,11 @@ Regler:
|
||||
6) Om en rubrik/lista innehaller flera kommaseparerade namn och efterfoljande rad/rader innehaller gemensam brand, vikt, pris eller kampanjvillkor: expandera till separata objekt (en per namn) och arv all gemensam metadata.
|
||||
7) Tillämpa samma split-regel generellt for liknande tillbud (inte bara ost), nar listan tydligt representerar produktvarianter/smaker/sorter.
|
||||
8) Splitta INTE om listan snarare ar ingredienser, avdelningar, eller otydlig marknadsforing utan tydlig produktvariant.
|
||||
9) Specialregel ost: namn som PRAST/HERRGARD/GREVE ska normaliseras till Prastost/Herrgardsost/Greveost.
|
||||
9) Specialregel ost: namn som PRAST/HERRGARD/GREVE ska normaliseras till Prästost/Herrgårdsost/Grevéost.
|
||||
10) Om texten innehaller "ARLA KO" ska brand vara exakt "Arla Ko".
|
||||
11) For ovan ostsorter ska category vara "Hardost".
|
||||
12) Returnera aldrig extra nycklar, text, markdown eller forklaringar utanfor JSON-arrayen.
|
||||
12) Behåll svenska diakritiska tecken (ä, å, ö, é) i produktnamn. Returnera "Prästost", "Herrgårdsost", "Grevéost" - inte ASCII-versioner.
|
||||
13) Returnera aldrig extra nycklar, text, markdown eller forklaringar utanfor JSON-arrayen.
|
||||
|
||||
Exempel bundle utdata:
|
||||
[
|
||||
@@ -258,7 +259,7 @@ Input-idé: "PRAST, HERRGARD, GREVE" + "ARLA KO" + gemensam vikt/pris.
|
||||
Output-idé:
|
||||
[
|
||||
{
|
||||
"name": "Prastost",
|
||||
"name": "Prästost",
|
||||
"brand": "Arla Ko",
|
||||
"category": "Hardost",
|
||||
"isBundle": false,
|
||||
@@ -271,7 +272,7 @@ Output-idé:
|
||||
"offer": ["Max 3 forp/hushall"]
|
||||
},
|
||||
{
|
||||
"name": "Herrgardsost",
|
||||
"name": "Herrgårdsost",
|
||||
"brand": "Arla Ko",
|
||||
"category": "Hardost",
|
||||
"isBundle": false,
|
||||
@@ -358,7 +359,7 @@ ${truncatedText}`;
|
||||
private normalizeName(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-zåäö0-9\s]/g, '')
|
||||
.replace(/[^a-zåäöé0-9\s]/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
@@ -427,7 +428,7 @@ ${truncatedText}`;
|
||||
'Mistral-anrop timeout',
|
||||
);
|
||||
|
||||
const content = response.choices?.[0]?.message?.content;
|
||||
const content = this.ensureUtf8Content(response.choices?.[0]?.message?.content);
|
||||
if (!content) {
|
||||
throw new BadRequestException('Tomt svar från AI-modellen.');
|
||||
}
|
||||
@@ -531,6 +532,40 @@ ${truncatedText}`;
|
||||
return hasCampaignMarkers ? normalized : '';
|
||||
}
|
||||
|
||||
private ensureUtf8Content(content: unknown): string {
|
||||
const asString = this.flattenContent(content);
|
||||
if (!asString) return '';
|
||||
|
||||
const utf8 = Buffer.from(asString, 'utf8').toString('utf8');
|
||||
if (this.debugEnabled && (asString.includes('\uFFFD') || utf8.includes('\uFFFD'))) {
|
||||
const hex = Buffer.from(asString, 'utf8').toString('hex').slice(0, 256);
|
||||
this.logger.debug(`Potential encoding issue in AI response (hex preview): ${hex}`);
|
||||
}
|
||||
return utf8;
|
||||
}
|
||||
|
||||
private flattenContent(content: unknown): string {
|
||||
if (typeof content === 'string') {
|
||||
return content;
|
||||
}
|
||||
if (Array.isArray(content)) {
|
||||
return content
|
||||
.map((part) => {
|
||||
if (typeof part === 'string') return part;
|
||||
if (part && typeof part === 'object' && 'text' in part) {
|
||||
const text = (part as { text?: unknown }).text;
|
||||
return typeof text === 'string' ? text : '';
|
||||
}
|
||||
return '';
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
if (content == null) {
|
||||
return '';
|
||||
}
|
||||
return String(content);
|
||||
}
|
||||
|
||||
private readPositiveIntEnv(key: string, fallback: number): number {
|
||||
const raw = process.env[key];
|
||||
if (!raw) return fallback;
|
||||
|
||||
@@ -120,12 +120,28 @@ describe('FlyerNormalizerService', () => {
|
||||
const result = service.normalize(items);
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result.map((item) => item.rawName)).toEqual(['Prästost', 'Herrgårdsost', 'Greveost']);
|
||||
expect(result.map((item) => item.rawName)).toEqual(['Prästost', 'Herrgårdsost', 'Grevéost']);
|
||||
expect(result.every((item) => item.brand === 'Arla Ko')).toBe(true);
|
||||
expect(result.every((item) => item.categoryHint === 'Hårdost')).toBe(true);
|
||||
expect(result[0].parseReasons).toContain('split_cheese_variants');
|
||||
});
|
||||
|
||||
it('normalizes PRAST token to Prästost', () => {
|
||||
const items = [{ rawName: 'PRAST, GREVE', brand: 'ARLA KO' }];
|
||||
|
||||
const result = service.normalize(items);
|
||||
|
||||
expect(result.map((item) => item.rawName)).toContain('Prästost');
|
||||
});
|
||||
|
||||
it('normalizes GREVE token to Grevéost', () => {
|
||||
const items = [{ rawName: 'GREVE, PRAST', brand: 'ARLA KO' }];
|
||||
|
||||
const result = service.normalize(items);
|
||||
|
||||
expect(result.map((item) => item.rawName)).toContain('Grevéost');
|
||||
});
|
||||
|
||||
it('keeps single cheese item unsplit but normalizes brand/category', () => {
|
||||
const items = [
|
||||
{
|
||||
@@ -184,5 +200,20 @@ describe('FlyerNormalizerService', () => {
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].rawName).toContain('Herrgårdsost');
|
||||
});
|
||||
|
||||
it('fixes greveost typo in cheese context and preserves é', () => {
|
||||
const items = [
|
||||
{
|
||||
rawName: 'Greveost skivad',
|
||||
brand: 'Arla Ko',
|
||||
},
|
||||
];
|
||||
|
||||
const result = service.normalize(items);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].rawName).toContain('Grevéost');
|
||||
expect(result[0].normalizedName).toContain('grevéost');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,7 +26,7 @@ export class FlyerNormalizerService {
|
||||
private readonly CHEESE_VARIANT_TO_NAME: Record<string, string> = {
|
||||
prast: 'Prästost',
|
||||
herrgard: 'Herrgårdsost',
|
||||
greve: 'Greveost',
|
||||
greve: 'Grevéost',
|
||||
};
|
||||
|
||||
private readonly UNIT_MAPPING: Record<string, string> = {
|
||||
@@ -140,7 +140,7 @@ export class FlyerNormalizerService {
|
||||
private normalizeName(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-zåäö0-9\s]/g, '')
|
||||
.replace(/[^a-zåäöé0-9\s]/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
@@ -256,6 +256,7 @@ export class FlyerNormalizerService {
|
||||
|
||||
if (/ost\b|hårdost/i.test(value)) {
|
||||
corrected = corrected.replace(/\bherg{1,2}årds?ost\b/gi, (match) => (match[0] === 'H' ? 'Herrgårdsost' : 'herrgårdsost'));
|
||||
corrected = corrected.replace(/\bgreveost\b/gi, (match) => (match[0] === 'G' ? 'Grevéost' : 'grevéost'));
|
||||
}
|
||||
|
||||
return corrected;
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describeMatchReason, describeParseReason } from './reason-codes';
|
||||
|
||||
describe('reason-codes', () => {
|
||||
it('describes known parse reasons in Swedish', () => {
|
||||
expect(describeParseReason('ai_parsed')).toMatchObject({
|
||||
kind: 'parse',
|
||||
code: 'ai_parsed',
|
||||
severity: 'info',
|
||||
title: 'AI-tolkad rad',
|
||||
});
|
||||
|
||||
expect(describeParseReason('split_cheese_variants')).toMatchObject({
|
||||
kind: 'parse',
|
||||
code: 'split_cheese_variants',
|
||||
severity: 'info',
|
||||
});
|
||||
|
||||
expect(describeParseReason('normalized')).toMatchObject({
|
||||
kind: 'parse',
|
||||
code: 'normalized',
|
||||
severity: 'info',
|
||||
});
|
||||
|
||||
expect(describeParseReason('low_confidence')).toMatchObject({
|
||||
kind: 'parse',
|
||||
code: 'low_confidence',
|
||||
severity: 'warning',
|
||||
title: 'Låg parsningskvalitet',
|
||||
});
|
||||
});
|
||||
|
||||
it('describes known match reasons in Swedish', () => {
|
||||
expect(describeMatchReason('no_match')).toMatchObject({
|
||||
kind: 'match',
|
||||
code: 'no_match',
|
||||
severity: 'warning',
|
||||
title: 'Ingen produktmatchning',
|
||||
});
|
||||
|
||||
expect(describeMatchReason('alias_exact')).toMatchObject({
|
||||
kind: 'match',
|
||||
code: 'alias_exact',
|
||||
severity: 'info',
|
||||
});
|
||||
|
||||
expect(describeMatchReason('normalized_exact')).toMatchObject({
|
||||
kind: 'match',
|
||||
code: 'normalized_exact',
|
||||
severity: 'info',
|
||||
});
|
||||
|
||||
expect(describeMatchReason('token_overlap:0.72')).toMatchObject({
|
||||
kind: 'match',
|
||||
code: 'token_overlap',
|
||||
severity: 'info',
|
||||
title: 'Tokenmatchning',
|
||||
});
|
||||
|
||||
expect(describeMatchReason('alias_points_to_missing_product')).toMatchObject({
|
||||
kind: 'match',
|
||||
code: 'alias_points_to_missing_product',
|
||||
severity: 'error',
|
||||
});
|
||||
|
||||
expect(describeMatchReason('empty_name')).toMatchObject({
|
||||
kind: 'match',
|
||||
code: 'empty_name',
|
||||
severity: 'error',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
export type ReasonKind = 'parse' | 'match';
|
||||
export type ReasonSeverity = 'info' | 'warning' | 'error';
|
||||
|
||||
export type ParseReasonCode =
|
||||
| 'ai_parsed'
|
||||
| 'split_cheese_variants'
|
||||
| 'normalized'
|
||||
| 'low_confidence';
|
||||
|
||||
export type MatchReasonCode =
|
||||
| 'no_match'
|
||||
| 'alias_exact'
|
||||
| 'normalized_exact'
|
||||
| 'token_overlap'
|
||||
| 'alias_points_to_missing_product'
|
||||
| 'empty_name';
|
||||
|
||||
export type FlyerReasonDescriptor = {
|
||||
code: string;
|
||||
kind: ReasonKind;
|
||||
title: string;
|
||||
message: string;
|
||||
severity: ReasonSeverity;
|
||||
location: string | null;
|
||||
};
|
||||
|
||||
export type DescribeReasonContext = {
|
||||
location?: string | null;
|
||||
itemIndex?: number;
|
||||
lang?: 'sv';
|
||||
};
|
||||
|
||||
const PARSE_DEFAULT_LOCATION = 'Steg: AI-parser';
|
||||
const MATCH_DEFAULT_LOCATION = 'Steg: matchning mot dina produkter';
|
||||
|
||||
export function describeParseReason(
|
||||
rawCode: string,
|
||||
context?: DescribeReasonContext,
|
||||
): FlyerReasonDescriptor {
|
||||
const code = normalizeCode(rawCode);
|
||||
const location = context?.location ?? PARSE_DEFAULT_LOCATION;
|
||||
|
||||
switch (code) {
|
||||
case 'ai_parsed':
|
||||
return {
|
||||
code,
|
||||
kind: 'parse',
|
||||
title: 'AI-tolkad rad',
|
||||
message: 'Raden tolkades av AI utan att en deterministisk regel matchade.',
|
||||
severity: 'info',
|
||||
location,
|
||||
};
|
||||
case 'split_cheese_variants':
|
||||
return {
|
||||
code,
|
||||
kind: 'parse',
|
||||
title: 'Variant-split',
|
||||
message: 'Gruppannonsen expanderades till individuella ostvarianter.',
|
||||
severity: 'info',
|
||||
location,
|
||||
};
|
||||
case 'normalized':
|
||||
return {
|
||||
code,
|
||||
kind: 'parse',
|
||||
title: 'Normaliserad rad',
|
||||
message: 'Produkttexten normaliserades för bättre matchning.',
|
||||
severity: 'info',
|
||||
location,
|
||||
};
|
||||
case 'low_confidence':
|
||||
return {
|
||||
code,
|
||||
kind: 'parse',
|
||||
title: 'Låg parsningskvalitet',
|
||||
message: 'Modellens säkerhet är låg, granska raden manuellt.',
|
||||
severity: 'warning',
|
||||
location,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
code,
|
||||
kind: 'parse',
|
||||
title: 'Okänd parserorsak',
|
||||
message: `En okänd parserorsak rapporterades: ${rawCode}`,
|
||||
severity: 'warning',
|
||||
location,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function describeMatchReason(
|
||||
rawCode: string,
|
||||
context?: DescribeReasonContext,
|
||||
): FlyerReasonDescriptor {
|
||||
const location = context?.location ?? MATCH_DEFAULT_LOCATION;
|
||||
const code = normalizeCode(rawCode);
|
||||
|
||||
switch (code) {
|
||||
case 'no_match':
|
||||
return {
|
||||
code,
|
||||
kind: 'match',
|
||||
title: 'Ingen produktmatchning',
|
||||
message:
|
||||
'Vi kunde inte hitta någon befintlig produkt som matchar texten på flyern.',
|
||||
severity: 'warning',
|
||||
location,
|
||||
};
|
||||
case 'alias_exact':
|
||||
return {
|
||||
code,
|
||||
kind: 'match',
|
||||
title: 'Aliasmatchning',
|
||||
message: 'Raden matchades exakt via ett registrerat alias.',
|
||||
severity: 'info',
|
||||
location,
|
||||
};
|
||||
case 'normalized_exact':
|
||||
return {
|
||||
code,
|
||||
kind: 'match',
|
||||
title: 'Exakt normaliserad matchning',
|
||||
message: 'Raden matchades exakt efter normalisering av produktnamnet.',
|
||||
severity: 'info',
|
||||
location,
|
||||
};
|
||||
case 'token_overlap': {
|
||||
const overlap = parseTokenOverlap(rawCode);
|
||||
const overlapSuffix = overlap == null ? '' : ` (överlapp: ${Math.round(overlap * 100)}%)`;
|
||||
return {
|
||||
code,
|
||||
kind: 'match',
|
||||
title: 'Tokenmatchning',
|
||||
message: `Raden matchades med tokenöverlapp mot en befintlig produkt${overlapSuffix}.`,
|
||||
severity: 'info',
|
||||
location,
|
||||
};
|
||||
}
|
||||
case 'alias_points_to_missing_product':
|
||||
return {
|
||||
code,
|
||||
kind: 'match',
|
||||
title: 'Trasig alias-koppling',
|
||||
message: 'Ett alias pekar på en produkt som inte längre finns.',
|
||||
severity: 'error',
|
||||
location,
|
||||
};
|
||||
case 'empty_name':
|
||||
return {
|
||||
code,
|
||||
kind: 'match',
|
||||
title: 'Tomt produktnamn',
|
||||
message: 'Raden saknar tolkbart produktnamn.',
|
||||
severity: 'error',
|
||||
location,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
code,
|
||||
kind: 'match',
|
||||
title: 'Okänd matchorsak',
|
||||
message: `En okänd matchorsak rapporterades: ${rawCode}`,
|
||||
severity: 'warning',
|
||||
location,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeCode(rawCode: string): string {
|
||||
const trimmed = String(rawCode ?? '').trim();
|
||||
if (trimmed.startsWith('token_overlap:')) {
|
||||
return 'token_overlap';
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function parseTokenOverlap(rawCode: string): number | null {
|
||||
const match = String(rawCode).trim().match(/^token_overlap:(\d+(?:\.\d+)?)$/);
|
||||
if (!match) return null;
|
||||
const parsed = Number.parseFloat(match[1]);
|
||||
if (!Number.isFinite(parsed)) return null;
|
||||
return Math.max(0, Math.min(1, parsed));
|
||||
}
|
||||
Reference in New Issue
Block a user