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
@@ -12,12 +12,13 @@ describe('ReceiptImportService parseReceipt flow', () => {
cat(51, 'Godis', 'Glass, godis & snacks > Godis'),
];
const prismaMock = {
receiptAlias: { findMany: jest.fn() },
product: { findMany: jest.fn() },
unitMapping: { findMany: jest.fn() },
user: { findUnique: jest.fn() },
};
const prismaMock = {
aiTrace: { create: jest.fn() },
receiptAlias: { findMany: jest.fn() },
product: { findMany: jest.fn() },
unitMapping: { findMany: jest.fn() },
user: { findUnique: jest.fn() },
};
const aiServiceMock = {
suggestCategory: jest.fn(),
@@ -80,14 +81,21 @@ describe('ReceiptImportService parseReceipt flow', () => {
confidence: 'low',
});
jest
.spyOn(service as any, 'parseReceiptViaImporter')
.mockResolvedValue([
{ rawName: 'MIXAD VARA', quantity: 1, unit: 'st' },
{ rawName: 'GLOBAL CHOKLAD', quantity: 1, unit: 'st' },
{ rawName: 'SPECIALPRODUKT 1st', quantity: 1, unit: 'st' },
{ rawName: 'helt okänd vara', quantity: 1, unit: 'st' },
]);
jest
.spyOn(service as any, 'parseReceiptViaImporter')
.mockResolvedValue({
items: [
{ rawName: 'MIXAD VARA', quantity: 1, unit: 'st' },
{ rawName: 'GLOBAL CHOKLAD', quantity: 1, unit: 'st' },
{ rawName: 'SPECIALPRODUKT 1st', quantity: 1, unit: 'st' },
{ rawName: 'helt okänd vara', quantity: 1, unit: 'st' },
],
trace: {
prompt: 'test prompt',
rawOutput: '{"items":[]}',
normalizedOutput: { items: [] },
},
});
const file = {
buffer: Buffer.from('dummy'),
@@ -19,8 +19,10 @@ import {
} from '../common/utils/receipt-alias';
import { FlyerSelectionService } from '../flyer-selection/flyer-selection.service';
const IMPORTER_SERVICE_URL =
process.env.IMPORTER_SERVICE_URL || 'http://importer-api:3001';
const IMPORTER_SERVICE_URL =
process.env.IMPORTER_SERVICE_URL || 'http://importer-api:3001';
const RECEIPT_IMPORT_MODEL = 'importer-receipt-ai';
const WEAK_DESCRIPTORS = new Set([
'rokt',
@@ -133,21 +135,63 @@ export class ReceiptImportService {
private readonly flyerSelectionService: FlyerSelectionService,
) {}
async parseReceipt(file: Express.Multer.File, _isPremium = false, userId?: number): Promise<ParsedReceiptItem[]> {
// Steg 1: Delegera AI-parsning till microservice-importer
const rawItems = await this.parseReceiptViaImporter(file);
async parseReceipt(file: Express.Multer.File, _isPremium = false, userId?: number): Promise<ParsedReceiptItem[]> {
const parseStartedAt = Date.now();
let parseError: string | null = null;
let tracePrompt: string | null = null;
let traceRawOutput: string | null = null;
let traceNormalizedOutput: Record<string, unknown> | null = null;
// Steg 1: Delegera AI-parsning till microservice-importer
let rawItems: ParsedReceiptItem[];
try {
const importer = await this.parseReceiptViaImporter(file);
rawItems = importer.items;
tracePrompt = importer.trace.prompt;
traceRawOutput = importer.trace.rawOutput;
traceNormalizedOutput = importer.trace.normalizedOutput;
} catch (err) {
parseError = err instanceof Error ? err.message : String(err);
await this.persistReceiptTrace({
userId,
model: RECEIPT_IMPORT_MODEL,
prompt: tracePrompt,
rawOutput: traceRawOutput,
normalizedOutput: traceNormalizedOutput,
status: 'error',
error: parseError,
durationMs: Date.now() - parseStartedAt,
});
throw err;
}
// Steg 2 & 3: Unified matching + categorization
// Samla context en gång för alla items
const context = await this.prepareMatchingContext(userId);
// Mappa alla items genom unified matcher
return Promise.all(
rawItems.map((item) =>
this.matchAndEnrichReceiptItem(item, context),
),
);
}
const parsedItems = await Promise.all(
rawItems.map((item) => this.matchAndEnrichReceiptItem(item, context)),
);
await this.persistReceiptTrace({
userId,
model: RECEIPT_IMPORT_MODEL,
prompt: tracePrompt,
rawOutput: traceRawOutput,
normalizedOutput: {
importer: traceNormalizedOutput,
enrichedItems: parsedItems,
},
status: parsedItems.length == 0 ? 'error' : 'success',
error: parsedItems.length == 0
? 'Inga kvittorader kunde tolkas av importer-tjänsten.'
: null,
durationMs: Date.now() - parseStartedAt,
});
return parsedItems;
}
private async prepareMatchingContext(userId?: number): Promise<MatchingContext> {
const prismaAny = this.prisma as any;
@@ -573,7 +617,14 @@ export class ReceiptImportService {
return response;
}
private async parseReceiptViaImporter(file: Express.Multer.File): Promise<ParsedReceiptItem[]> {
private async parseReceiptViaImporter(file: Express.Multer.File): Promise<{
items: ParsedReceiptItem[];
trace: {
prompt: string | null;
rawOutput: string | null;
normalizedOutput: Record<string, unknown> | null;
};
}> {
const form = new FormData();
form.append(
'file',
@@ -608,9 +659,112 @@ export class ReceiptImportService {
throw new BadRequestException(message);
}
const items = (await response.json()) as ParsedReceiptItem[];
return items.filter((item) => !isIgnoredReceiptName(item.rawName));
}
const body = (await response.json()) as
| ParsedReceiptItem[]
| {
items?: ParsedReceiptItem[];
prompt?: unknown;
rawOutput?: unknown;
normalizedOutput?: Record<string, unknown>;
};
const normalizedItems = this.extractImporterItems(body)
.filter((item) => !isIgnoredReceiptName(item.rawName));
return {
items: normalizedItems,
trace: {
prompt: this.extractImporterPrompt(body),
rawOutput: this.extractImporterRawOutput(body),
normalizedOutput: this.extractImporterNormalizedOutput(body),
},
};
}
private extractImporterItems(
body: ParsedReceiptItem[] | { items?: ParsedReceiptItem[] },
): ParsedReceiptItem[] {
if (Array.isArray(body)) return body;
if (Array.isArray(body.items)) return body.items;
return [];
}
private extractImporterPrompt(
body: ParsedReceiptItem[] | { prompt?: unknown },
): string | null {
if (Array.isArray(body)) return null;
if (typeof body.prompt !== 'string') return null;
const prompt = body.prompt.trim();
return prompt && prompt.length > 0 ? prompt : null;
}
private extractImporterRawOutput(
body: ParsedReceiptItem[] | { rawOutput?: unknown },
): string | null {
if (Array.isArray(body)) return JSON.stringify(body);
if (typeof body.rawOutput === 'string' && body.rawOutput.trim().length > 0) {
return body.rawOutput;
}
if (body.rawOutput !== undefined) {
try {
return JSON.stringify(body.rawOutput);
} catch {
return String(body.rawOutput);
}
}
return JSON.stringify(body);
}
private extractImporterNormalizedOutput(
body: ParsedReceiptItem[] | { normalizedOutput?: Record<string, unknown>; items?: ParsedReceiptItem[] },
): Record<string, unknown> | null {
if (Array.isArray(body)) {
return { items: body };
}
if (body.normalizedOutput && typeof body.normalizedOutput === 'object') {
return body.normalizedOutput;
}
if (Array.isArray(body.items)) {
return { items: body.items };
}
return null;
}
private async persistReceiptTrace(params: {
userId?: number;
model: string;
prompt: string | null;
rawOutput: string | null;
normalizedOutput: Record<string, unknown> | null;
status: 'success' | 'error';
error: string | null;
durationMs: number;
}): Promise<void> {
try {
await this.prisma.aiTrace.create({
data: {
source: 'receipt',
userId: params.userId,
model: params.model,
prompt: params.prompt,
rawOutput: params.rawOutput,
...(params.normalizedOutput == null
? {}
: {
normalizedOutput:
params.normalizedOutput as Prisma.InputJsonValue,
}),
status: params.status,
error: params.error,
durationMs: params.durationMs,
},
});
} catch (traceErr) {
this.logger.warn(
`Kunde inte spara receipt AI-trace: ${traceErr instanceof Error ? traceErr.message : String(traceErr)}`,
);
}
}
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// UNIFIED MATCHER: Kombinerar product matching + categorization