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: {} },
}),
}),
);
});
});
+492
View File
@@ -0,0 +1,492 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
export type AiTraceSource = 'receipt' | 'flyer';
export type AiTraceStatus = 'success' | 'warning' | 'error';
const AI_TRACE_MASK_FIELDS = ['personnummer', 'telefon', 'email', 'address', 'namn'];
const SWEDISH_PERSONAL_ID_REGEX = /\b(\d{2})?(\d{6})[-+ ]?(\d{4})\b/g;
const EMAIL_REGEX = /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi;
const PHONE_REGEX = /\b(?:\+46|0)\s?\d(?:[\d\s-]{6,}\d)\b/g;
export type AiTraceListItem = {
id: string;
source: AiTraceSource;
status: AiTraceStatus;
createdAt: string;
userId: number;
userLabel: string;
sessionId: number | null;
fileName: string | null;
model: string | null;
durationMs: number | null;
warningsCount: number;
hasPrompt: boolean;
hasOutput: boolean;
error: string | null;
};
export type AiTraceListResponse = {
items: AiTraceListItem[];
nextCursor: string | null;
};
export type AiTraceDetail = {
id: string;
source: AiTraceSource;
status: AiTraceStatus;
createdAt: string;
userId: number;
userLabel: string;
sessionId: number | null;
fileName: string | null;
model: string | null;
durationMs: number | null;
retryCount: number | null;
chunkCount: number | null;
warnings: string[];
error: string | null;
prompt: string | null;
rawOutput: string | null;
normalizedOutput: Record<string, unknown> | null;
summary: Record<string, unknown>;
};
@Injectable()
export class AiTraceService {
constructor(private readonly prisma: PrismaService) {}
async listTraces(params: {
source: AiTraceSource;
limit: number;
cursor?: string;
period?: '24h' | '7d' | '30d';
onlyErrors?: boolean;
}): Promise<AiTraceListResponse> {
if (params.source === 'receipt') {
return this.listReceiptTraces(params);
}
const take = Math.max(1, Math.min(params.limit || 20, 100));
const cursorId = this.parseCursor(params.cursor);
const periodStart = this.periodStart(params.period);
const sessions = await this.prisma.flyerSession.findMany({
where: {
...(periodStart ? { createdAt: { gte: periodStart } } : {}),
...(cursorId ? { id: { lt: cursorId } } : {}),
...(params.onlyErrors ? { items: { none: {} } } : {}),
},
orderBy: { id: 'desc' },
take: take + 1,
select: {
id: true,
userId: true,
createdAt: true,
sourceFileName: true,
user: { select: { username: true, email: true } },
items: {
select: {
id: true,
parseReasons: true,
matchReasons: true,
},
},
},
});
const hasMore = sessions.length > take;
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 status = this.statusFromSession(session.items.length, warningsCount);
return {
id: this.flyerTraceId(session.id),
source: 'flyer',
status,
createdAt: session.createdAt.toISOString(),
userId: session.userId,
userLabel: this.userLabel(session.user?.username, session.user?.email, session.userId),
sessionId: session.id,
fileName: session.sourceFileName,
model: 'ministral-8b-2512',
durationMs: null,
warningsCount,
hasPrompt: false,
hasOutput: session.items.length > 0,
error: status === 'error' ? 'Inga produkter kunde extraheras från flyern.' : null,
};
});
return {
items,
nextCursor: hasMore ? String(page[page.length - 1]?.id ?? '') : null,
};
}
async getTraceById(id: string): Promise<AiTraceDetail> {
const parsed = this.parseTraceId(id);
if (parsed.source === 'receipt') {
return this.getReceiptTraceById(parsed.numericId);
}
const session = await this.prisma.flyerSession.findUnique({
where: { id: parsed.numericId },
select: {
id: true,
userId: true,
createdAt: true,
sourceFileName: true,
sourceMimeType: true,
sourceFileSize: true,
user: { select: { username: true, email: true } },
items: {
orderBy: { id: 'asc' },
select: {
id: true,
rawName: true,
normalizedName: true,
brand: true,
categoryHint: true,
categoryId: true,
price: true,
priceUnit: true,
comparisonPrice: true,
comparisonUnit: true,
weight: true,
bundleWeight: true,
isBundle: true,
bundleItems: true,
offerText: true,
parseConfidence: true,
parseReasons: true,
matchedProductId: true,
matchedProductName: true,
matchedVia: true,
matchConfidence: true,
matchReasons: true,
},
},
},
});
if (!session) {
throw new NotFoundException('AI-trace hittades inte.');
}
const warnings = this.collectWarnings(session.items);
const status = this.statusFromSession(session.items.length, warnings.length);
const normalizedOutput = {
sessionId: session.id,
source: 'flyer',
sourceFileName: session.sourceFileName,
sourceMimeType: session.sourceMimeType,
sourceFileSize: session.sourceFileSize,
itemCount: session.items.length,
items: session.items.map((item) => ({
id: item.id,
rawName: item.rawName,
normalizedName: item.normalizedName,
brand: item.brand,
categoryHint: item.categoryHint,
categoryId: item.categoryId,
price: item.price != null ? Number(item.price) : null,
priceUnit: item.priceUnit,
comparisonPrice: item.comparisonPrice != null ? Number(item.comparisonPrice) : null,
comparisonUnit: item.comparisonUnit,
weight: item.weight,
bundleWeight: item.bundleWeight,
isBundle: item.isBundle,
bundleItems: Array.isArray(item.bundleItems) ? item.bundleItems : [],
offerText: item.offerText,
parseConfidence: item.parseConfidence,
parseReasons: Array.isArray(item.parseReasons) ? item.parseReasons : [],
matchedProductId: item.matchedProductId,
matchedProductName: item.matchedProductName,
matchedVia: item.matchedVia,
matchConfidence: item.matchConfidence,
matchReasons: Array.isArray(item.matchReasons) ? item.matchReasons : [],
})),
warnings,
} as Record<string, unknown>;
return {
id: this.flyerTraceId(session.id),
source: 'flyer',
status,
createdAt: session.createdAt.toISOString(),
userId: session.userId,
userLabel: this.userLabel(session.user?.username, session.user?.email, session.userId),
sessionId: session.id,
fileName: session.sourceFileName,
model: 'ministral-8b-2512',
durationMs: null,
retryCount: null,
chunkCount: null,
warnings,
error: session.items.length === 0 ? 'Inga produkter kunde extraheras från flyern.' : null,
prompt: null,
rawOutput: JSON.stringify(this.maskSensitiveData(normalizedOutput)),
normalizedOutput: this.maskSensitiveData(normalizedOutput),
summary: {
source: 'flyer',
sessionId: session.id,
itemCount: session.items.length,
warningsCount: warnings.length,
promptAvailable: false,
outputAvailable: true,
retentionHintDays: 30,
maskedFields: AI_TRACE_MASK_FIELDS,
},
};
}
private statusFromSession(itemCount: number, warningsCount: number): AiTraceStatus {
if (itemCount <= 0) return 'error';
if (warningsCount > 0) return 'warning';
return 'success';
}
private maskSensitiveData(data: Record<string, unknown>): Record<string, unknown> {
const clone = JSON.parse(JSON.stringify(data)) as Record<string, unknown>;
return this.maskDeep(clone) as Record<string, unknown>;
}
private maskDeep(value: unknown): unknown {
if (typeof value === 'string') {
return this.maskSensitiveText(value);
}
if (Array.isArray(value)) {
return value.map((entry) => this.maskDeep(entry));
}
if (value && typeof value === 'object') {
const out: Record<string, unknown> = {};
for (const [key, nested] of Object.entries(value as Record<string, unknown>)) {
const lowerKey = key.toLowerCase();
if (AI_TRACE_MASK_FIELDS.some((field) => lowerKey.includes(field))) {
out[key] = '[MASKED]';
continue;
}
out[key] = this.maskDeep(nested);
}
return out;
}
return value;
}
private maskSensitiveText(value: string): string {
return value
.replace(EMAIL_REGEX, '[MASKED]')
.replace(SWEDISH_PERSONAL_ID_REGEX, '[MASKED]')
.replace(PHONE_REGEX, '[MASKED]');
}
private maskRawOutput(rawOutput: string | null | undefined): string | null {
if (typeof rawOutput !== 'string' || rawOutput.trim().length === 0) {
return null;
}
try {
const parsed = JSON.parse(rawOutput);
if (parsed && typeof parsed === 'object') {
const masked = this.maskDeep(parsed);
return JSON.stringify(masked);
}
if (typeof parsed === 'string') {
return this.maskSensitiveText(parsed);
}
return this.maskSensitiveText(String(parsed));
} catch {
return this.maskSensitiveText(rawOutput);
}
}
private parseCursor(cursor?: string): number | null {
if (!cursor) return null;
const value = Number.parseInt(cursor, 10);
return Number.isFinite(value) && value > 0 ? value : null;
}
private periodStart(period?: '24h' | '7d' | '30d'): Date | null {
if (!period) return null;
const now = Date.now();
const map: Record<string, number> = {
'24h': 24 * 60 * 60 * 1000,
'7d': 7 * 24 * 60 * 60 * 1000,
'30d': 30 * 24 * 60 * 60 * 1000,
};
const duration = map[period];
if (!duration) return null;
return new Date(now - duration);
}
private parseTraceId(id: string): { source: AiTraceSource; numericId: number } {
const trimmed = id.trim();
if (trimmed.startsWith('flyer-')) {
const value = Number.parseInt(trimmed.replace('flyer-', ''), 10);
if (Number.isFinite(value) && value > 0) {
return { source: 'flyer', numericId: value };
}
}
if (trimmed.startsWith('receipt-')) {
const value = Number.parseInt(trimmed.replace('receipt-', ''), 10);
if (Number.isFinite(value) && value > 0) {
return { source: 'receipt', numericId: value };
}
}
throw new NotFoundException('AI-trace hittades inte.');
}
private flyerTraceId(sessionId: number): string {
return `flyer-${sessionId}`;
}
private userLabel(username: string | null | undefined, email: string | null | undefined, userId: number): string {
if (username && username.trim().length > 0) return username.trim();
if (email && email.trim().length > 0) return email.trim();
return `user:${userId}`;
}
private collectWarnings(items: Array<{ parseReasons: unknown; matchReasons: unknown }>): string[] {
const warnings = new Set<string>();
for (const item of items) {
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 (Array.isArray(item.matchReasons)) {
for (const reason of item.matchReasons) {
const text = String(reason ?? '').trim();
if (text.length > 0) warnings.add(`match:${text}`);
}
}
}
return Array.from(warnings);
}
private async listReceiptTraces(params: {
source: AiTraceSource;
limit: number;
cursor?: string;
period?: '24h' | '7d' | '30d';
onlyErrors?: boolean;
}): Promise<AiTraceListResponse> {
const take = Math.max(1, Math.min(params.limit || 20, 100));
const cursorId = this.parseCursor(params.cursor);
const periodStart = this.periodStart(params.period);
const rows = await this.prisma.aiTrace.findMany({
where: {
source: 'receipt',
...(periodStart ? { createdAt: { gte: periodStart } } : {}),
...(cursorId ? { id: { lt: cursorId } } : {}),
...(params.onlyErrors ? { status: 'error' } : {}),
},
orderBy: { id: 'desc' },
take: take + 1,
select: {
id: true,
source: true,
status: true,
createdAt: true,
userId: true,
sessionId: true,
model: true,
durationMs: true,
error: true,
prompt: true,
rawOutput: true,
user: { select: { username: true, email: true } },
},
});
const hasMore = rows.length > take;
const page = hasMore ? rows.slice(0, take) : rows;
return {
items: page.map((row) => ({
id: `receipt-${row.id}`,
source: 'receipt',
status: row.status === 'error' ? 'error' : row.status === 'warning' ? 'warning' : 'success',
createdAt: row.createdAt.toISOString(),
userId: row.userId ?? 0,
userLabel: this.userLabel(row.user?.username, row.user?.email, row.userId ?? 0),
sessionId: row.sessionId,
fileName: null,
model: row.model,
durationMs: row.durationMs,
warningsCount: 0,
hasPrompt: !!row.prompt,
hasOutput: !!row.rawOutput,
error: row.error,
})),
nextCursor: hasMore ? String(page[page.length - 1]?.id ?? '') : null,
};
}
private async getReceiptTraceById(traceId: number): Promise<AiTraceDetail> {
const row = await this.prisma.aiTrace.findFirst({
where: { id: traceId, source: 'receipt' },
select: {
id: true,
source: true,
status: true,
createdAt: true,
userId: true,
sessionId: true,
model: true,
durationMs: true,
error: true,
prompt: true,
rawOutput: true,
normalizedOutput: true,
user: { select: { username: true, email: true } },
},
});
if (!row) {
throw new NotFoundException('AI-trace hittades inte.');
}
const normalizedOutput = row.normalizedOutput && typeof row.normalizedOutput === 'object'
? this.maskSensitiveData(row.normalizedOutput as Record<string, unknown>)
: null;
return {
id: `receipt-${row.id}`,
source: 'receipt',
status: row.status === 'error' ? 'error' : row.status === 'warning' ? 'warning' : 'success',
createdAt: row.createdAt.toISOString(),
userId: row.userId ?? 0,
userLabel: this.userLabel(row.user?.username, row.user?.email, row.userId ?? 0),
sessionId: row.sessionId,
fileName: null,
model: row.model,
durationMs: row.durationMs,
retryCount: null,
chunkCount: null,
warnings: [],
error: row.error,
prompt: row.prompt ? this.maskSensitiveText(row.prompt) : null,
rawOutput: this.maskRawOutput(row.rawOutput),
normalizedOutput,
summary: {
source: 'receipt',
traceId: row.id,
promptAvailable: !!row.prompt,
outputAvailable: !!row.rawOutput || normalizedOutput != null,
retentionHintDays: 30,
maskedFields: AI_TRACE_MASK_FIELDS,
},
};
}
}
+40 -11
View File
@@ -1,6 +1,9 @@
import { Controller, Get } from '@nestjs/common';
import { Public } from '../auth/decorators/public.decorator';
import { AI_CATEGORIZATION_MODEL } from './ai.service';
import { Controller, Get, Param, ParseIntPipe, Query } from '@nestjs/common';
import { Roles } from '../auth/decorators/roles.decorator';
import { Public } from '../auth/decorators/public.decorator';
import { AI_CATEGORIZATION_MODEL } from './ai.service';
import { AiTraceService } from './ai-trace.service';
import { ListAiTracesQueryDto } from './dto/list-ai-traces.query.dto';
const RECEIPT_IMPORT_MODEL = 'mistral-small-2603';
@@ -15,10 +18,12 @@ export interface AiModelInfo {
}
@Controller('ai')
export class AiController {
@Get('models')
@Public()
getModels(): AiModelInfo[] {
export class AiController {
constructor(private readonly aiTraceService: AiTraceService) {}
@Get('models')
@Public()
getModels(): AiModelInfo[] {
return [
{
id: 'receipt-pdf',
@@ -64,7 +69,31 @@ export class AiController {
path: '/admin/products',
trigger: 'Manuell — knappen "✨ AI-kategorisera okategoriserade"',
access: 'Admin',
},
];
}
}
},
];
}
@Roles('admin')
@Get('traces')
listTraces(@Query() query: ListAiTracesQueryDto) {
return this.aiTraceService.listTraces({
source: query.source ?? 'flyer',
limit: query.limit ?? 20,
cursor: query.cursor,
period: query.period,
onlyErrors: query.onlyErrors ?? false,
});
}
@Roles('admin')
@Get('traces/:id')
getTraceById(@Param('id') id: string) {
return this.aiTraceService.getTraceById(id);
}
@Roles('admin')
@Get('receipt/traces/:id')
getReceiptTraceById(@Param('id', ParseIntPipe) id: number) {
return this.aiTraceService.getTraceById(`receipt-${id}`);
}
}
+13 -10
View File
@@ -1,10 +1,13 @@
import { Module } from '@nestjs/common';
import { AiService } from './ai.service';
import { AiController } from './ai.controller';
@Module({
controllers: [AiController],
providers: [AiService],
exports: [AiService],
})
export class AiModule {}
import { Module } from '@nestjs/common';
import { AiService } from './ai.service';
import { AiController } from './ai.controller';
import { AiTraceService } from './ai-trace.service';
import { PrismaModule } from '../prisma/prisma.module';
@Module({
imports: [PrismaModule],
controllers: [AiController],
providers: [AiService, AiTraceService],
exports: [AiService],
})
export class AiModule {}
@@ -0,0 +1,37 @@
import { Transform } from 'class-transformer';
import { IsBoolean, IsIn, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
export class ListAiTracesQueryDto {
@IsOptional()
@IsIn(['receipt', 'flyer'])
source?: 'receipt' | 'flyer';
@IsOptional()
@Transform(({ value }) => {
if (value === undefined || value === null || value === '') return undefined;
const parsed = Number.parseInt(String(value), 10);
return Number.isFinite(parsed) ? parsed : value;
})
@IsInt()
@Min(1)
@Max(100)
limit?: number;
@IsOptional()
@IsString()
cursor?: string;
@IsOptional()
@IsIn(['24h', '7d', '30d'])
period?: '24h' | '7d' | '30d';
@IsOptional()
@Transform(({ value }) => {
if (typeof value === 'boolean') return value;
const normalized = String(value ?? '').trim().toLowerCase();
if (!normalized) return undefined;
return ['1', 'true', 'yes', 'on'].includes(normalized);
})
@IsBoolean()
onlyErrors?: boolean;
}
@@ -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