refactor(ai): enhance AI trace integration and OCR normalization
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 3m54s
Test Suite / flutter-quality (push) Failing after 1m29s

- Add FlyerTraceSupplement type for AI trace metadata
- Implement getFlyerTraceSupplements method to fetch trace supplements
- Update AiTraceService to include prompt/rawOutput and counters in flyer traces
- Add persistFlyerTrace method to FlyerImportService for trace persistence
- Enhance AiFlyerParserService to return structured trace data with prompts and retries
- Update FlyerNormalizerService with OCR typo fixes for cheese variants and spröd bakad firre
- Improve Flutter admin panel with selectable text, warnings display, and tooltips
- Add comprehensive tests for AI trace supplements and normalization rules
This commit is contained in:
Nils-Johan Gynther
2026-05-21 19:11:54 +02:00
parent 67a7590525
commit 026323b72a
9 changed files with 681 additions and 67 deletions
@@ -15,7 +15,7 @@ import {
FlyerImportResponse,
} from './dto/flyer-import.response';
import { TextExtractorService } from './services/text-extractor.service';
import { AiFlyerParserService } from './services/ai-flyer-parser.service';
import { AiFlyerParserService } from './services/ai-flyer-parser.service';
import { FlyerNormalizerService } from './services/flyer-normalizer.service';
type FlyerParseItem = {
@@ -41,6 +41,12 @@ type FlyerParseResponse = {
parserVersion: 'v1';
items: FlyerParseItem[];
warnings: string[];
trace: {
prompt: string | null;
rawOutput: string | null;
chunkCount: number | null;
retryCount: number | null;
};
};
type ExtractedOfferSignals = {
@@ -71,7 +77,8 @@ export class FlyerImportService {
) {}
async parseAndMatch(file: Express.Multer.File, userId: number): Promise<FlyerImportResponse> {
const parsed = await this.parseViaInternal(file);
const startedAt = Date.now();
const parsed = await this.parseViaInternal(file);
const [products, aliases] = await Promise.all([
this.prisma.product.findMany({
@@ -137,6 +144,24 @@ export class FlyerImportService {
});
const persistedItems = await this.persistSessionWithItems(userId, parsed.retailer, items, file);
await this.persistFlyerTrace({
userId,
sessionId: persistedItems.sessionId,
model: 'ministral-8b-2512',
prompt: parsed.trace.prompt,
rawOutput: parsed.trace.rawOutput,
normalizedOutput: {
sessionId: persistedItems.sessionId,
warnings: parsed.warnings,
itemCount: persistedItems.items.length,
chunkCount: parsed.trace.chunkCount,
retryCount: parsed.trace.retryCount,
},
status: persistedItems.items.length === 0 ? 'error' : parsed.warnings.length > 0 ? 'warning' : 'success',
error: persistedItems.items.length === 0 ? 'Inga produkter kunde extraheras från flyern.' : null,
durationMs: Date.now() - startedAt,
});
return {
sessionId: persistedItems.sessionId,
@@ -603,10 +628,10 @@ export class FlyerImportService {
);
// 2. Skicka till Mistral Tiny
const aiItems = await this.aiParser.parseWithAI(text);
// 3. Normalisera resultatet
const normalizedItems = this.normalizer.normalize(aiItems);
const aiParseResult = await this.aiParser.parseWithAI(text);
// 3. Normalisera resultatet
const normalizedItems = this.normalizer.normalize(aiParseResult.items);
// 4. Konvertera till intern FlyerParseItem-format
const items: FlyerParseItem[] = normalizedItems.map((item) => ({
@@ -632,12 +657,18 @@ export class FlyerImportService {
warnings.push('Inga produkter kunde extraheras från flyern.');
}
return {
retailer: 'willys',
parserVersion: 'v1',
items,
warnings,
};
return {
retailer: 'willys',
parserVersion: 'v1',
items,
warnings,
trace: {
prompt: aiParseResult.trace.prompt,
rawOutput: aiParseResult.trace.rawOutput,
chunkCount: aiParseResult.trace.chunkCount,
retryCount: aiParseResult.trace.retryCount,
},
};
} catch (err) {
if (err instanceof BadRequestException) {
throw err;
@@ -652,6 +683,41 @@ export class FlyerImportService {
}
}
private async persistFlyerTrace(params: {
userId: number;
sessionId: number;
model: string;
prompt: string | null;
rawOutput: string | null;
normalizedOutput: Record<string, unknown> | null;
status: 'success' | 'warning' | 'error';
error: string | null;
durationMs: number | null;
}): Promise<void> {
try {
await this.prisma.aiTrace.create({
data: {
source: 'flyer',
userId: params.userId,
sessionId: params.sessionId,
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 (err) {
this.logger.warn(
`Kunde inte spara flyer AI-trace: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
private toFlyerImportItem(item: {
id: number;
rawName: string;