feat(ai): add AI trace tracking and admin panel
- 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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user