Files
recipe-app/backend/src/ai/ai-trace.service.spec.ts
T
Nils-Johan Gynther d9f992ca9a
Test Suite / backend-pr-quick (push) Has been skipped
Test Suite / quick-import-pr-quick (push) Has been skipped
Test Suite / backend-full (push) Successful in 4m21s
Test Suite / flutter-quality (push) Failing after 1m38s
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
2026-05-23 21:11:46 +02:00

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']),
);
});
});