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
+55
View File
@@ -90,4 +90,59 @@ describe('AiTraceService receipt masking', () => {
}), }),
); );
}); });
it('returns flyer prompt/rawOutput and trace counters from aiTrace supplement', async () => {
prismaMock.flyerSession.findUnique.mockResolvedValue({
id: 101,
userId: 7,
createdAt: new Date('2026-05-21T12:00:00.000Z'),
sourceFileName: 'willys.pdf',
sourceMimeType: 'application/pdf',
sourceFileSize: 12345,
user: { username: 'admin', email: 'admin@example.com' },
items: [
{
id: 1,
rawName: 'Tomat',
normalizedName: 'tomat',
brand: null,
categoryHint: 'Grönsaker',
categoryId: null,
price: null,
priceUnit: null,
comparisonPrice: null,
comparisonUnit: null,
weight: null,
bundleWeight: null,
isBundle: false,
bundleItems: [],
offerText: null,
parseConfidence: 0.9,
parseReasons: ['low_confidence'],
matchedProductId: null,
matchedProductName: null,
matchedVia: 'none',
matchConfidence: null,
matchReasons: [],
},
],
});
prismaMock.aiTrace.findMany.mockResolvedValue([
{
sessionId: 101,
prompt: 'Flyer prompt med email kund@example.com',
rawOutput: '{"ok":true}',
normalizedOutput: { retryCount: 2, chunkCount: 4 },
},
]);
const result = await service.getTraceById('flyer-101');
expect(result.prompt).toContain('[MASKED]');
expect(result.rawOutput).toContain('{"ok":true}');
expect(result.retryCount).toBe(2);
expect(result.chunkCount).toBe(4);
expect(result.warnings).toContain('parse:low_confidence');
});
}); });
+84 -6
View File
@@ -53,6 +53,13 @@ export type AiTraceDetail = {
summary: Record<string, unknown>; summary: Record<string, unknown>;
}; };
type FlyerTraceSupplement = {
prompt: string | null;
rawOutput: string | null;
retryCount: number | null;
chunkCount: number | null;
};
@Injectable() @Injectable()
export class AiTraceService { export class AiTraceService {
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
@@ -124,8 +131,26 @@ export class AiTraceService {
}; };
}); });
const supplements = await this.getFlyerTraceSupplements(page.map((session) => session.id));
const withSupplements = items.map((item) => {
const sessionId = item.sessionId ?? 0;
const supplement = supplements.get(sessionId);
const hasPrompt = item.hasPrompt || !!supplement?.prompt;
const hasOutput = item.hasOutput || !!supplement?.rawOutput;
const error = item.status === 'warning' && item.warningsCount > 0
? `Det finns ${item.warningsCount} varningar i detaljvyn.`
: item.error;
return { return {
items, ...item,
hasPrompt,
hasOutput,
error,
};
});
return {
items: withSupplements,
nextCursor: hasMore ? String(page[page.length - 1]?.id ?? '') : null, nextCursor: hasMore ? String(page[page.length - 1]?.id ?? '') : null,
}; };
} }
@@ -182,6 +207,7 @@ export class AiTraceService {
const warnings = this.collectWarnings(session.items); const warnings = this.collectWarnings(session.items);
const status = this.statusFromSession(session.items.length, warnings.length); const status = this.statusFromSession(session.items.length, warnings.length);
const supplement = await this.getFlyerTraceSupplementBySessionId(session.id);
const normalizedOutput = { const normalizedOutput = {
sessionId: session.id, sessionId: session.id,
@@ -228,19 +254,20 @@ export class AiTraceService {
fileName: session.sourceFileName, fileName: session.sourceFileName,
model: 'ministral-8b-2512', model: 'ministral-8b-2512',
durationMs: null, durationMs: null,
retryCount: null, retryCount: supplement.retryCount,
chunkCount: null, chunkCount: supplement.chunkCount,
warnings, warnings,
error: session.items.length === 0 ? 'Inga produkter kunde extraheras från flyern.' : null, error: session.items.length === 0 ? 'Inga produkter kunde extraheras från flyern.' : null,
prompt: null, prompt: supplement.prompt,
rawOutput: JSON.stringify(this.maskSensitiveData(normalizedOutput)), rawOutput:
this.maskRawOutput(supplement.rawOutput) ?? JSON.stringify(this.maskSensitiveData(normalizedOutput)),
normalizedOutput: this.maskSensitiveData(normalizedOutput), normalizedOutput: this.maskSensitiveData(normalizedOutput),
summary: { summary: {
source: 'flyer', source: 'flyer',
sessionId: session.id, sessionId: session.id,
itemCount: session.items.length, itemCount: session.items.length,
warningsCount: warnings.length, warningsCount: warnings.length,
promptAvailable: false, promptAvailable: !!supplement.prompt,
outputAvailable: true, outputAvailable: true,
retentionHintDays: 30, retentionHintDays: 30,
maskedFields: AI_TRACE_MASK_FIELDS, maskedFields: AI_TRACE_MASK_FIELDS,
@@ -248,6 +275,57 @@ export class AiTraceService {
}; };
} }
private async getFlyerTraceSupplements(sessionIds: number[]): Promise<Map<number, FlyerTraceSupplement>> {
if (sessionIds.length === 0) return new Map<number, FlyerTraceSupplement>();
const rows = await this.prisma.aiTrace.findMany({
where: {
source: 'flyer',
sessionId: { in: sessionIds },
},
orderBy: [{ sessionId: 'desc' }, { createdAt: 'desc' }],
select: {
sessionId: true,
prompt: true,
rawOutput: true,
normalizedOutput: true,
},
});
const out = new Map<number, FlyerTraceSupplement>();
for (const row of rows) {
if (row.sessionId == null || out.has(row.sessionId)) continue;
out.set(row.sessionId, {
prompt: row.prompt ? this.maskSensitiveText(row.prompt) : null,
rawOutput: row.rawOutput,
retryCount: this.extractTraceNumber(row.normalizedOutput, 'retryCount'),
chunkCount: this.extractTraceNumber(row.normalizedOutput, 'chunkCount'),
});
}
return out;
}
private async getFlyerTraceSupplementBySessionId(sessionId: number): Promise<FlyerTraceSupplement> {
const rows = await this.getFlyerTraceSupplements([sessionId]);
return rows.get(sessionId) ?? {
prompt: null,
rawOutput: null,
retryCount: null,
chunkCount: null,
};
}
private extractTraceNumber(value: unknown, key: string): number | null {
if (!value || typeof value !== 'object') return null;
const entry = (value as Record<string, unknown>)[key];
if (typeof entry === 'number' && Number.isFinite(entry)) return entry;
if (typeof entry === 'string') {
const parsed = Number.parseInt(entry, 10);
return Number.isFinite(parsed) ? parsed : null;
}
return null;
}
private statusFromSession(itemCount: number, warningsCount: number): AiTraceStatus { private statusFromSession(itemCount: number, warningsCount: number): AiTraceStatus {
if (itemCount <= 0) return 'error'; if (itemCount <= 0) return 'error';
if (warningsCount > 0) return 'warning'; if (warningsCount > 0) return 'warning';
@@ -41,6 +41,12 @@ type FlyerParseResponse = {
parserVersion: 'v1'; parserVersion: 'v1';
items: FlyerParseItem[]; items: FlyerParseItem[];
warnings: string[]; warnings: string[];
trace: {
prompt: string | null;
rawOutput: string | null;
chunkCount: number | null;
retryCount: number | null;
};
}; };
type ExtractedOfferSignals = { type ExtractedOfferSignals = {
@@ -71,6 +77,7 @@ export class FlyerImportService {
) {} ) {}
async parseAndMatch(file: Express.Multer.File, userId: number): Promise<FlyerImportResponse> { async parseAndMatch(file: Express.Multer.File, userId: number): Promise<FlyerImportResponse> {
const startedAt = Date.now();
const parsed = await this.parseViaInternal(file); const parsed = await this.parseViaInternal(file);
const [products, aliases] = await Promise.all([ const [products, aliases] = await Promise.all([
@@ -138,6 +145,24 @@ export class FlyerImportService {
const persistedItems = await this.persistSessionWithItems(userId, parsed.retailer, items, file); 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 { return {
sessionId: persistedItems.sessionId, sessionId: persistedItems.sessionId,
retailer: parsed.retailer, retailer: parsed.retailer,
@@ -603,10 +628,10 @@ export class FlyerImportService {
); );
// 2. Skicka till Mistral Tiny // 2. Skicka till Mistral Tiny
const aiItems = await this.aiParser.parseWithAI(text); const aiParseResult = await this.aiParser.parseWithAI(text);
// 3. Normalisera resultatet // 3. Normalisera resultatet
const normalizedItems = this.normalizer.normalize(aiItems); const normalizedItems = this.normalizer.normalize(aiParseResult.items);
// 4. Konvertera till intern FlyerParseItem-format // 4. Konvertera till intern FlyerParseItem-format
const items: FlyerParseItem[] = normalizedItems.map((item) => ({ const items: FlyerParseItem[] = normalizedItems.map((item) => ({
@@ -637,6 +662,12 @@ export class FlyerImportService {
parserVersion: 'v1', parserVersion: 'v1',
items, items,
warnings, warnings,
trace: {
prompt: aiParseResult.trace.prompt,
rawOutput: aiParseResult.trace.rawOutput,
chunkCount: aiParseResult.trace.chunkCount,
retryCount: aiParseResult.trace.retryCount,
},
}; };
} catch (err) { } catch (err) {
if (err instanceof BadRequestException) { if (err instanceof BadRequestException) {
@@ -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: { private toFlyerImportItem(item: {
id: number; id: number;
rawName: string; rawName: string;
@@ -0,0 +1,184 @@
import { BadRequestException } from '@nestjs/common';
import { AiFlyerParserService } from './ai-flyer-parser.service';
describe('AiFlyerParserService dedupe', () => {
const service = Object.create(AiFlyerParserService.prototype) as AiFlyerParserService;
it('dedupes same product with minor offer text differences', () => {
const items = [
{
rawName: 'Kvisttomater',
normalizedName: 'kvisttomater',
brand: null,
category: 'Grönsaker',
price: 19.9,
priceUnit: 'kg',
comparisonPrice: null,
comparisonUnit: null,
weight: null,
bundleWeight: null,
isBundle: false,
bundleItems: [],
offerText: 'Max 2 köp/hushåll',
confidence: 0.9,
reasonCodes: ['ai_parsed'],
},
{
rawName: 'KVISTTOMATER',
normalizedName: 'kvisttomater',
brand: null,
category: 'Grönsaker',
price: 19.9,
priceUnit: 'kg',
comparisonPrice: null,
comparisonUnit: null,
weight: null,
bundleWeight: null,
isBundle: false,
bundleItems: [],
offerText: 'Max 2 kop/hushall',
confidence: 0.89,
reasonCodes: ['ai_parsed'],
},
];
const result = (service as any).dedupeItems(items);
expect(result).toHaveLength(1);
expect(result[0].normalizedName).toBe('kvisttomater');
});
it('keeps products with same name but different prices', () => {
const items = [
{
rawName: 'Kvisttomater',
normalizedName: 'kvisttomater',
brand: null,
category: 'Grönsaker',
price: 19.9,
priceUnit: 'kg',
comparisonPrice: null,
comparisonUnit: null,
weight: null,
bundleWeight: null,
isBundle: false,
bundleItems: [],
offerText: null,
confidence: 0.9,
reasonCodes: ['ai_parsed'],
},
{
rawName: 'Kvisttomater',
normalizedName: 'kvisttomater',
brand: null,
category: 'Grönsaker',
price: 24.9,
priceUnit: 'kg',
comparisonPrice: null,
comparisonUnit: null,
weight: null,
bundleWeight: null,
isBundle: false,
bundleItems: [],
offerText: null,
confidence: 0.9,
reasonCodes: ['ai_parsed'],
},
];
const result = (service as any).dedupeItems(items);
expect(result).toHaveLength(2);
});
it('keeps products with same name/price but materially different campaigns', () => {
const items = [
{
rawName: 'Kvisttomater',
normalizedName: 'kvisttomater',
brand: null,
category: 'Grönsaker',
price: 19.9,
priceUnit: 'kg',
comparisonPrice: null,
comparisonUnit: null,
weight: null,
bundleWeight: null,
isBundle: false,
bundleItems: [],
offerText: 'Max 2 köp/hushåll',
confidence: 0.9,
reasonCodes: ['ai_parsed'],
},
{
rawName: 'Kvisttomater',
normalizedName: 'kvisttomater',
brand: null,
category: 'Grönsaker',
price: 19.9,
priceUnit: 'kg',
comparisonPrice: null,
comparisonUnit: null,
weight: null,
bundleWeight: null,
isBundle: false,
bundleItems: [],
offerText: 'Ta 3 betala för 2',
confidence: 0.9,
reasonCodes: ['ai_parsed'],
},
];
const result = (service as any).dedupeItems(items);
expect(result).toHaveLength(2);
});
it('keeps bundle and non-bundle as separate entries', () => {
const items = [
{
rawName: 'Fiskpaket',
normalizedName: 'fiskpaket',
brand: 'Kapten',
category: 'Fisk',
price: 49.9,
priceUnit: 'pkt',
comparisonPrice: 83.17,
comparisonUnit: 'kg',
weight: null,
bundleWeight: '600g',
isBundle: true,
bundleItems: ['A', 'B'],
offerText: null,
confidence: 0.9,
reasonCodes: ['ai_parsed'],
},
{
rawName: 'Fiskpaket',
normalizedName: 'fiskpaket',
brand: 'Kapten',
category: 'Fisk',
price: 49.9,
priceUnit: 'pkt',
comparisonPrice: 83.17,
comparisonUnit: 'kg',
weight: null,
bundleWeight: null,
isBundle: false,
bundleItems: [],
offerText: null,
confidence: 0.9,
reasonCodes: ['ai_parsed'],
},
];
const result = (service as any).dedupeItems(items);
expect(result).toHaveLength(2);
});
it('throws for empty input in parseWithAI', async () => {
await expect((service as any).parseWithAI('')).rejects.toBeInstanceOf(
BadRequestException,
);
});
});
@@ -25,6 +25,13 @@ export interface AiFlyerParseResult {
reasonCodes: string[]; reasonCodes: string[];
} }
export interface AiFlyerParseTrace {
prompt: string | null;
rawOutput: string | null;
chunkCount: number;
retryCount: number;
}
@Injectable() @Injectable()
export class AiFlyerParserService { export class AiFlyerParserService {
private readonly logger = new Logger(AiFlyerParserService.name); private readonly logger = new Logger(AiFlyerParserService.name);
@@ -66,7 +73,7 @@ export class AiFlyerParserService {
* @param text Text från flyern (från pdf-parse eller OCR) * @param text Text från flyern (från pdf-parse eller OCR)
* @returns Array av parsade produkter * @returns Array av parsade produkter
*/ */
async parseWithAI(text: string): Promise<AiFlyerParseResult[]> { async parseWithAI(text: string): Promise<{ items: AiFlyerParseResult[]; trace: AiFlyerParseTrace }> {
if (!text || text.trim().length === 0) { if (!text || text.trim().length === 0) {
throw new BadRequestException('Flyer-texten är tom. Kan inte fortsätta.'); throw new BadRequestException('Flyer-texten är tom. Kan inte fortsätta.');
} }
@@ -95,18 +102,30 @@ export class AiFlyerParserService {
} }
const allItems: AiFlyerParseResult[] = []; const allItems: AiFlyerParseResult[] = [];
const prompts: string[] = [];
const rawResponses: string[] = [];
let retryCount = 0;
for (let i = 0; i < chunks.length; i++) { for (let i = 0; i < chunks.length; i++) {
const chunkItems = await this.parseChunkWithRetry( const chunkResult = await this.parseChunkWithRetry(
client, client,
chunks[i], chunks[i],
i + 1, i + 1,
chunks.length, chunks.length,
debugSession, debugSession,
); );
allItems.push(...chunkItems); allItems.push(...chunkResult.items);
prompts.push(chunkResult.prompt);
rawResponses.push(chunkResult.rawOutput);
retryCount += Math.max(0, chunkResult.attemptsUsed - 1);
} }
const deduped = this.dedupeItems(allItems); const deduped = this.dedupeItems(allItems);
const trace: AiFlyerParseTrace = {
prompt: prompts.length > 0 ? prompts.join('\n\n-----\n\n') : null,
rawOutput: rawResponses.length > 0 ? rawResponses.join('\n\n-----\n\n') : null,
chunkCount: chunks.length,
retryCount,
};
if (debugSession) { if (debugSession) {
await this.writeDebugFile( await this.writeDebugFile(
@@ -116,7 +135,7 @@ export class AiFlyerParserService {
); );
} }
return deduped; return { items: deduped, trace };
} catch (err) { } catch (err) {
if (debugSession) { if (debugSession) {
await this.writeDebugFile( await this.writeDebugFile(
@@ -371,7 +390,12 @@ ${truncatedText}`;
chunkIndex: number, chunkIndex: number,
totalChunks: number, totalChunks: number,
debugSession: { dirPath: string; baseName: string } | null, debugSession: { dirPath: string; baseName: string } | null,
): Promise<AiFlyerParseResult[]> { ): Promise<{
items: AiFlyerParseResult[];
prompt: string;
rawOutput: string;
attemptsUsed: number;
}> {
const textWindows = [3000, 2200, 1600]; const textWindows = [3000, 2200, 1600];
const attempts = Math.max(1, Math.min(this.maxRetries + 1, textWindows.length)); const attempts = Math.max(1, Math.min(this.maxRetries + 1, textWindows.length));
let lastError: unknown = null; let lastError: unknown = null;
@@ -425,7 +449,12 @@ ${truncatedText}`;
throw new BadRequestException('AI returnerade inte en JSON-array.'); throw new BadRequestException('AI returnerade inte en JSON-array.');
} }
return items.map((aiItem, idx) => this.normalizeAiItem(aiItem, idx)); return {
items: items.map((aiItem, idx) => this.normalizeAiItem(aiItem, idx)),
prompt,
rawOutput: String(content),
attemptsUsed: i + 1,
};
} catch (attemptErr) { } catch (attemptErr) {
lastError = attemptErr; lastError = attemptErr;
if (debugSession) { if (debugSession) {
@@ -454,14 +483,24 @@ ${truncatedText}`;
const deduped: AiFlyerParseResult[] = []; const deduped: AiFlyerParseResult[] = [];
for (const item of items) { for (const item of items) {
const normalizedName = item.normalizedName.trim();
const normalizedBrand = (item.brand ?? '').trim().toLowerCase();
const normalizedPrice = item.price == null ? '' : Number(item.price).toFixed(2);
const normalizedPriceUnit = (item.priceUnit ?? '').trim().toLowerCase();
const normalizedComparisonPrice =
item.comparisonPrice == null ? '' : Number(item.comparisonPrice).toFixed(2);
const normalizedComparisonUnit = (item.comparisonUnit ?? '').trim().toLowerCase();
const offerSignature = this.offerSignature(item.offerText);
const key = [ const key = [
item.normalizedName, normalizedName,
item.price ?? '', normalizedBrand,
item.priceUnit ?? '', normalizedPrice,
item.offerText ?? '', normalizedPriceUnit,
normalizedComparisonPrice,
normalizedComparisonUnit,
offerSignature,
item.isBundle ? '1' : '0', item.isBundle ? '1' : '0',
item.bundleWeight ?? '',
JSON.stringify(item.bundleItems ?? []),
].join('|'); ].join('|');
if (seen.has(key)) continue; if (seen.has(key)) continue;
seen.add(key); seen.add(key);
@@ -471,6 +510,27 @@ ${truncatedText}`;
return deduped; return deduped;
} }
private offerSignature(offerText: string | null | undefined): string {
if (!offerText || offerText.trim().length === 0) return '';
const normalized = offerText
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9\s]/g, ' ')
.replace(/\s+/g, ' ')
.trim();
if (!normalized) return '';
const hasCampaignMarkers =
/(max|hogst|begransat|hushall|kund|kop|for|betala|ta)/.test(normalized)
|| /(\d+\s*for\s*\d+)/.test(normalized)
|| /(ta\s*\d+\s*betala\s*for\s*\d+)/.test(normalized);
return hasCampaignMarkers ? normalized : '';
}
private readPositiveIntEnv(key: string, fallback: number): number { private readPositiveIntEnv(key: string, fallback: number): number {
const raw = process.env[key]; const raw = process.env[key];
if (!raw) return fallback; if (!raw) return fallback;
@@ -120,7 +120,7 @@ describe('FlyerNormalizerService', () => {
const result = service.normalize(items); const result = service.normalize(items);
expect(result).toHaveLength(3); expect(result).toHaveLength(3);
expect(result.map((item) => item.rawName)).toEqual(['Prastost', 'Herrgardsost', 'Greveost']); expect(result.map((item) => item.rawName)).toEqual(['Prästost', 'Herrgårdsost', 'Greveost']);
expect(result.every((item) => item.brand === 'Arla Ko')).toBe(true); expect(result.every((item) => item.brand === 'Arla Ko')).toBe(true);
expect(result.every((item) => item.categoryHint === 'Hårdost')).toBe(true); expect(result.every((item) => item.categoryHint === 'Hårdost')).toBe(true);
expect(result[0].parseReasons).toContain('split_cheese_variants'); expect(result[0].parseReasons).toContain('split_cheese_variants');
@@ -141,5 +141,48 @@ describe('FlyerNormalizerService', () => {
expect(result[0].brand).toBe('Arla Ko'); expect(result[0].brand).toBe('Arla Ko');
expect(result[0].categoryHint).toBe('Hårdost'); expect(result[0].categoryHint).toBe('Hårdost');
}); });
it('fixes known OCR typo for spröd', () => {
const items = [
{
rawName: 'Pröd Bakad Firre',
brand: 'Findus',
},
];
const result = service.normalize(items);
expect(result).toHaveLength(1);
expect(result[0].rawName).toBe('Spröd Bakad Firre');
expect(result[0].normalizedName).toBe('spröd bakad firre');
});
it('does not apply spröd typo fix outside known fish context', () => {
const items = [
{
rawName: 'Pröd tvättmedel',
brand: 'Test',
},
];
const result = service.normalize(items);
expect(result).toHaveLength(1);
expect(result[0].rawName).toBe('Pröd tvättmedel');
});
it('fixes herggårdsost only in cheese context', () => {
const items = [
{
rawName: 'Herggårdsost 31%',
brand: 'Arla Ko',
},
];
const result = service.normalize(items);
expect(result).toHaveLength(1);
expect(result[0].rawName).toContain('Herrgårdsost');
});
}); });
}); });
@@ -24,8 +24,8 @@ export class FlyerNormalizerService {
private readonly MAX_BUNDLE_ITEMS = 20; private readonly MAX_BUNDLE_ITEMS = 20;
private readonly MAX_BUNDLE_ITEM_LENGTH = 120; private readonly MAX_BUNDLE_ITEM_LENGTH = 120;
private readonly CHEESE_VARIANT_TO_NAME: Record<string, string> = { private readonly CHEESE_VARIANT_TO_NAME: Record<string, string> = {
prast: 'Prastost', prast: 'Prästost',
herrgard: 'Herrgardsost', herrgard: 'Herrgårdsost',
greve: 'Greveost', greve: 'Greveost',
}; };
@@ -76,11 +76,12 @@ export class FlyerNormalizerService {
return [null]; return [null];
} }
const rawName = this.extractString(item.rawName) || this.extractString(item.name); const rawNameValue = this.extractString(item.rawName) || this.extractString(item.name);
if (!rawName) { if (!rawNameValue) {
this.logger.warn(`Item ${index} has no name, skipping`); this.logger.warn(`Item ${index} has no name, skipping`);
return [null]; return [null];
} }
const rawName = this.fixKnownOcrTypos(rawNameValue);
const normalizedName = this.extractString(item.normalizedName) || this.normalizeName(rawName); const normalizedName = this.extractString(item.normalizedName) || this.normalizeName(rawName);
const normalizedBrand = this.normalizeBrand(this.extractString(item.brand), rawName); const normalizedBrand = this.normalizeBrand(this.extractString(item.brand), rawName);
@@ -245,4 +246,18 @@ export class FlyerNormalizerService {
return null; return null;
} }
private fixKnownOcrTypos(value: string): string {
let corrected = value;
if (/\bbakad\b/i.test(value) && /\bfirre\b/i.test(value)) {
corrected = corrected.replace(/\bpröd\b/gi, (match) => (match[0] === 'P' ? 'Spröd' : 'spröd'));
}
if (/ost\b|hårdost/i.test(value)) {
corrected = corrected.replace(/\bherg{1,2}årds?ost\b/gi, (match) => (match[0] === 'H' ? 'Herrgårdsost' : 'herrgårdsost'));
}
return corrected;
}
} }
@@ -300,11 +300,14 @@ class _AdminAiPanelState extends ConsumerState<AdminAiPanel> {
title: Text(item.fileName ?? item.id), title: Text(item.fileName ?? item.id),
subtitle: Text( subtitle: Text(
'${_formatDateTime(item.createdAt)}${item.userLabel}'), '${_formatDateTime(item.createdAt)}${item.userLabel}'),
trailing: Chip( trailing: Tooltip(
message: _statusTooltipText(item),
child: Chip(
label: Text(item.status.label), label: Text(item.status.label),
labelStyle: TextStyle( labelStyle: TextStyle(
color: _statusColor(item.status, theme.colorScheme)), color: _statusColor(item.status, theme.colorScheme)),
), ),
),
); );
}, },
), ),
@@ -349,6 +352,14 @@ class _AdminAiPanelState extends ConsumerState<AdminAiPanel> {
return ListView( return ListView(
children: [ children: [
_TraceMetaCard(detail: detail, formatDateTime: _formatDateTime), _TraceMetaCard(detail: detail, formatDateTime: _formatDateTime),
if (detail.warnings.isNotEmpty) ...[
const SizedBox(height: 12),
_WarningsCard(
warnings: detail.warnings,
onCopyWarning: (warning) => _copyText(warning, 'Varning'),
onCopyAll: () => _copyText(detail.warnings.join('\n'), 'Varningar'),
),
],
const SizedBox(height: 12), const SizedBox(height: 12),
_PromptCard( _PromptCard(
prompt: prompt, prompt: prompt,
@@ -375,6 +386,20 @@ class _AdminAiPanelState extends ConsumerState<AdminAiPanel> {
_cachedOutputPrettyJson = next; _cachedOutputPrettyJson = next;
return next; return next;
} }
String _statusTooltipText(AdminAiTraceListItem item) {
final parts = <String>[];
if (item.status == AdminAiTraceStatus.warning && item.warningsCount > 0) {
parts.add('${item.warningsCount} varning(ar). Välj raden för detaljer och kopiering.');
}
if (item.error != null && item.error!.trim().isNotEmpty) {
parts.add(item.error!.trim());
}
if (parts.isEmpty) {
return 'Inga ytterligare detaljer.';
}
return parts.join('\n');
}
} }
class _TraceMetaCard extends StatelessWidget { class _TraceMetaCard extends StatelessWidget {
@@ -466,17 +491,15 @@ class _PromptCard extends StatelessWidget {
.withValues(alpha: 0.35), .withValues(alpha: 0.35),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),
child: Text( child: SelectionArea(
hasPrompt child: SelectableText(
? value hasPrompt ? value : 'Prompt saknas för detta spår.',
: 'Prompt är inte tillgänglig i denna fas för vald källa.',
maxLines: expanded ? null : 10, maxLines: expanded ? null : 10,
overflow:
expanded ? TextOverflow.visible : TextOverflow.ellipsis,
style: theme.textTheme.bodySmall style: theme.textTheme.bodySmall
?.copyWith(fontFamily: 'monospace'), ?.copyWith(fontFamily: 'monospace'),
), ),
), ),
),
], ],
), ),
), ),
@@ -484,15 +507,27 @@ class _PromptCard extends StatelessWidget {
} }
} }
class _OutputJsonCard extends StatelessWidget { class _OutputJsonCard extends StatefulWidget {
final String jsonText; final String jsonText;
final VoidCallback onCopy; final VoidCallback onCopy;
const _OutputJsonCard({required this.jsonText, required this.onCopy}); const _OutputJsonCard({required this.jsonText, required this.onCopy});
@override
State<_OutputJsonCard> createState() => _OutputJsonCardState();
}
class _OutputJsonCardState extends State<_OutputJsonCard> {
static const int _maxPreviewChars = 12000;
bool _expanded = false;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final shouldTruncate = widget.jsonText.length > _maxPreviewChars;
final visibleText =
!_expanded && shouldTruncate ? '${widget.jsonText.substring(0, _maxPreviewChars)}\n' : widget.jsonText;
return Card( return Card(
child: Padding( child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
@@ -506,11 +541,20 @@ class _OutputJsonCard extends StatelessWidget {
style: theme.textTheme.titleMedium)), style: theme.textTheme.titleMedium)),
IconButton( IconButton(
tooltip: 'Kopiera JSON', tooltip: 'Kopiera JSON',
onPressed: onCopy, onPressed: widget.onCopy,
icon: const Icon(Icons.copy_all), icon: const Icon(Icons.copy_all),
), ),
], ],
), ),
if (shouldTruncate)
Align(
alignment: Alignment.centerRight,
child: TextButton.icon(
onPressed: () => setState(() => _expanded = !_expanded),
icon: Icon(_expanded ? Icons.unfold_less : Icons.unfold_more),
label: Text(_expanded ? 'Visa mindre' : 'Visa hela outputen'),
),
),
const SizedBox(height: 8), const SizedBox(height: 8),
Container( Container(
width: double.infinity, width: double.infinity,
@@ -520,15 +564,73 @@ class _OutputJsonCard extends StatelessWidget {
.withValues(alpha: 0.35), .withValues(alpha: 0.35),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),
child: SelectionArea(
child: SingleChildScrollView( child: SingleChildScrollView(
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
child: Text( child: SelectableText(
jsonText, visibleText,
style: theme.textTheme.bodySmall style: theme.textTheme.bodySmall
?.copyWith(fontFamily: 'monospace'), ?.copyWith(fontFamily: 'monospace'),
), ),
), ),
), ),
),
],
),
),
);
}
}
class _WarningsCard extends StatelessWidget {
final List<String> warnings;
final void Function(String warning) onCopyWarning;
final VoidCallback onCopyAll;
const _WarningsCard({
required this.warnings,
required this.onCopyWarning,
required this.onCopyAll,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
'Varningar (${warnings.length})',
style: theme.textTheme.titleMedium,
),
),
IconButton(
tooltip: 'Kopiera alla varningar',
onPressed: onCopyAll,
icon: const Icon(Icons.copy_all),
),
],
),
const SizedBox(height: 8),
...warnings.map(
(warning) => ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.warning_amber_rounded, size: 18),
title: SelectableText(warning),
trailing: IconButton(
tooltip: 'Kopiera varning',
onPressed: () => onCopyWarning(warning),
icon: const Icon(Icons.copy, size: 18),
),
),
),
], ],
), ),
), ),
@@ -54,6 +54,8 @@ Widget _buildPanelApp(AdminRepository repo) {
} }
void main() { void main() {
final veryLargeOutput = '{"payload":"${List.filled(13050, 'x').join()}"}';
final flyerItem = AdminAiTraceListItem( final flyerItem = AdminAiTraceListItem(
id: 'flyer-101', id: 'flyer-101',
source: AdminAiTraceSource.flyer, source: AdminAiTraceSource.flyer,
@@ -68,7 +70,7 @@ void main() {
warningsCount: 2, warningsCount: 2,
hasPrompt: true, hasPrompt: true,
hasOutput: true, hasOutput: true,
error: null, error: 'Det finns 2 varningar i detaljvyn.',
); );
final flyerDetail = AdminAiTraceDetail( final flyerDetail = AdminAiTraceDetail(
@@ -84,16 +86,11 @@ void main() {
durationMs: 1880, durationMs: 1880,
retryCount: 1, retryCount: 1,
chunkCount: 3, chunkCount: 3,
warnings: const ['parse:low_confidence'], warnings: const ['parse:low_confidence', 'match:no_match'],
error: null, error: null,
prompt: 'Prompttext exempel', prompt: 'Prompttext exempel',
rawOutput: '{"ok":true}', rawOutput: veryLargeOutput,
normalizedOutput: const { normalizedOutput: null,
'sessionId': 101,
'items': [
{'rawName': 'Tomat'}
],
},
summary: const {'itemCount': 1}, summary: const {'itemCount': 1},
); );
@@ -180,7 +177,7 @@ void main() {
expect(find.text('willys-v20.pdf'), findsOneWidget); expect(find.text('willys-v20.pdf'), findsOneWidget);
}); });
testWidgets('Prompt and output render and copy actions show snackbars', testWidgets('Prompt/output are selectable and warning details are visible',
(tester) async { (tester) async {
await tester.binding.setSurfaceSize(const Size(1400, 1200)); await tester.binding.setSurfaceSize(const Size(1400, 1200));
final fakeRepo = _FakeAdminRepository( final fakeRepo = _FakeAdminRepository(
@@ -200,6 +197,9 @@ void main() {
await tester.pump(const Duration(milliseconds: 500)); await tester.pump(const Duration(milliseconds: 500));
expect(find.text('Sammanfattning'), findsOneWidget); expect(find.text('Sammanfattning'), findsOneWidget);
expect(find.text('Varningar (2)'), findsOneWidget);
expect(find.text('parse:low_confidence'), findsOneWidget);
expect(find.text('match:no_match'), findsOneWidget);
final detailScroll = find.byType(Scrollable).last; final detailScroll = find.byType(Scrollable).last;
await tester.scrollUntilVisible( await tester.scrollUntilVisible(
find.text('Model Output'), find.text('Model Output'),
@@ -209,12 +209,19 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('Model Output'), findsOneWidget); expect(find.text('Model Output'), findsOneWidget);
expect(find.textContaining('"sessionId": 101'), findsOneWidget); expect(find.byType(SelectableText), findsWidgets);
expect(find.text('Visa hela outputen'), findsOneWidget);
await tester.tap(find.text('Visa hela outputen'));
await tester.pumpAndSettle();
expect(find.text('Visa mindre'), findsOneWidget);
final copyPrompt = find.byTooltip('Kopiera'); final copyPrompt = find.byTooltip('Kopiera');
final copyOutput = find.byTooltip('Kopiera JSON'); final copyOutput = find.byTooltip('Kopiera JSON');
final copyWarnings = find.byTooltip('Kopiera alla varningar');
expect(copyPrompt, findsOneWidget); expect(copyPrompt, findsOneWidget);
expect(copyOutput, findsOneWidget); expect(copyOutput, findsOneWidget);
expect(copyWarnings, findsOneWidget);
await tester.tap(copyPrompt); await tester.tap(copyPrompt);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@@ -224,6 +231,10 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull);
await tester.tap(copyWarnings);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
addTearDown(() => tester.binding.setSurfaceSize(null)); addTearDown(() => tester.binding.setSurfaceSize(null));
}); });
}); });