d9f992ca9a
- 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
214 lines
5.9 KiB
TypeScript
214 lines
5.9 KiB
TypeScript
import { AiTraceService } from './ai-trace.service';
|
|
|
|
describe('AiTraceService receipt masking', () => {
|
|
const prismaMock = {
|
|
aiTrace: {
|
|
findFirst: jest.fn(),
|
|
findMany: jest.fn(),
|
|
},
|
|
flyerSession: {
|
|
findMany: jest.fn(),
|
|
findUnique: jest.fn(),
|
|
},
|
|
};
|
|
|
|
const service = new AiTraceService(prismaMock as any);
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
});
|
|
|
|
it('masks sensitive data in receipt prompt and rawOutput', async () => {
|
|
prismaMock.aiTrace.findFirst.mockResolvedValue({
|
|
id: 42,
|
|
source: 'receipt',
|
|
status: 'success',
|
|
createdAt: new Date('2026-05-21T10:00:00.000Z'),
|
|
userId: 7,
|
|
sessionId: null,
|
|
model: 'importer-receipt-ai',
|
|
durationMs: 240,
|
|
error: null,
|
|
prompt: 'Kund email anna@example.com och telefon 070-123 45 67',
|
|
rawOutput: JSON.stringify({
|
|
personnummer: '850101-1234',
|
|
email: 'anna@example.com',
|
|
nested: {
|
|
namn: 'Anna Andersson',
|
|
phone: '+46701234567',
|
|
},
|
|
}),
|
|
normalizedOutput: {
|
|
items: [
|
|
{
|
|
rawName: 'Mjolk',
|
|
customerEmail: 'anna@example.com',
|
|
},
|
|
],
|
|
},
|
|
user: {
|
|
username: 'admin',
|
|
email: 'admin@example.com',
|
|
},
|
|
});
|
|
|
|
const result = await service.getTraceById('receipt-42');
|
|
|
|
expect(result.prompt).not.toContain('anna@example.com');
|
|
expect(result.prompt).not.toContain('070-123 45 67');
|
|
expect(result.prompt).toContain('[MASKED]');
|
|
|
|
expect(result.rawOutput).not.toContain('850101-1234');
|
|
expect(result.rawOutput).not.toContain('anna@example.com');
|
|
expect(result.rawOutput).not.toContain('Anna Andersson');
|
|
expect(result.rawOutput).toContain('[MASKED]');
|
|
|
|
expect(result.normalizedOutput).toEqual({
|
|
items: [
|
|
{
|
|
rawName: 'Mjolk',
|
|
customerEmail: '[MASKED]',
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
it('filters flyer list by errors in database query', async () => {
|
|
prismaMock.flyerSession.findMany.mockResolvedValue([]);
|
|
|
|
await service.listTraces({
|
|
source: 'flyer',
|
|
limit: 20,
|
|
onlyErrors: true,
|
|
});
|
|
|
|
expect(prismaMock.flyerSession.findMany).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: expect.objectContaining({
|
|
items: { none: {} },
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('returns flyer prompt/rawOutput and trace counters from aiTrace supplement', async () => {
|
|
prismaMock.flyerSession.findUnique.mockResolvedValue({
|
|
id: 101,
|
|
userId: 7,
|
|
createdAt: new Date('2026-05-21T12:00:00.000Z'),
|
|
sourceFileName: 'willys.pdf',
|
|
sourceMimeType: 'application/pdf',
|
|
sourceFileSize: 12345,
|
|
user: { username: 'admin', email: 'admin@example.com' },
|
|
items: [
|
|
{
|
|
id: 1,
|
|
rawName: 'Tomat',
|
|
normalizedName: 'tomat',
|
|
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: ['low_confidence'],
|
|
matchedProductId: null,
|
|
matchedProductName: null,
|
|
matchedVia: 'none',
|
|
matchConfidence: null,
|
|
matchReasons: [],
|
|
},
|
|
],
|
|
});
|
|
|
|
prismaMock.aiTrace.findMany.mockResolvedValue([
|
|
{
|
|
sessionId: 101,
|
|
prompt: 'Flyer prompt med email kund@example.com',
|
|
rawOutput: '{"ok":true}',
|
|
normalizedOutput: { retryCount: 2, chunkCount: 4 },
|
|
},
|
|
]);
|
|
|
|
const result = await service.getTraceById('flyer-101');
|
|
|
|
expect(result.prompt).toContain('[MASKED]');
|
|
expect(result.rawOutput).toContain('{"ok":true}');
|
|
expect(result.retryCount).toBe(2);
|
|
expect(result.chunkCount).toBe(4);
|
|
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']),
|
|
);
|
|
});
|
|
});
|