refactor(ai): enhance AI trace integration and OCR normalization
- 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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user