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:
@@ -0,0 +1,24 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE `AiTrace` (
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`source` VARCHAR(191) NOT NULL,
|
||||
`userId` INTEGER NULL,
|
||||
`sessionId` INTEGER NULL,
|
||||
`model` VARCHAR(191) NULL,
|
||||
`prompt` LONGTEXT NULL,
|
||||
`rawOutput` LONGTEXT NULL,
|
||||
`normalizedOutput` JSON NULL,
|
||||
`status` VARCHAR(191) NOT NULL,
|
||||
`error` TEXT NULL,
|
||||
`durationMs` INTEGER NULL,
|
||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updatedAt` DATETIME(3) NOT NULL,
|
||||
|
||||
INDEX `AiTrace_source_createdAt_idx`(`source`, `createdAt`),
|
||||
INDEX `AiTrace_userId_createdAt_idx`(`userId`, `createdAt`),
|
||||
INDEX `AiTrace_status_createdAt_idx`(`status`, `createdAt`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `AiTrace` ADD CONSTRAINT `AiTrace_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -33,6 +33,7 @@ model User {
|
||||
flyerSessions FlyerSession[]
|
||||
flyerSelections FlyerSelection[]
|
||||
shoppingListItems ShoppingListItem[]
|
||||
aiTraces AiTrace[]
|
||||
}
|
||||
|
||||
model Product {
|
||||
@@ -388,3 +389,25 @@ model ShoppingListItem {
|
||||
@@index([productId, unit, status])
|
||||
@@index([categoryId])
|
||||
}
|
||||
|
||||
model AiTrace {
|
||||
id Int @id @default(autoincrement())
|
||||
source String
|
||||
userId Int?
|
||||
sessionId Int?
|
||||
model String?
|
||||
prompt String? @db.LongText
|
||||
rawOutput String? @db.LongText
|
||||
normalizedOutput Json?
|
||||
status String
|
||||
error String? @db.Text
|
||||
durationMs Int?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([source, createdAt])
|
||||
@@index([userId, createdAt])
|
||||
@@index([status, createdAt])
|
||||
}
|
||||
|
||||
@@ -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: {} },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user