feat(ai): add AI trace tracking and admin panel
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 12m45s
Test Suite / flutter-quality (push) Failing after 7m24s

- Add AiTrace model to Prisma schema with relations to User
- Implement AiTraceService with CRUD operations for AI traces
- Add new admin panel for AI traces with filtering and detail views
- Integrate trace persistence in receipt import flow
- Add API endpoints for listing and retrieving AI traces
- Update Flutter admin UI with new AI tab and navigation
- Add new domain models for AI traces and details
- Add migration for AiTrace table creation

BREAKING CHANGE: None
This commit is contained in:
Nils-Johan Gynther
2026-05-21 17:33:21 +02:00
parent c3520b5ad4
commit 67a7590525
21 changed files with 2477 additions and 509 deletions
+93
View File
@@ -0,0 +1,93 @@
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: {} },
}),
}),
);
});
});