feat(ai): enhance AI trace warnings with product context
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 2m31s
Test Suite / flutter-quality (push) Failing after 1m12s

- Added `productName` field to `AdminAiWarning` to include product context in warnings
- Updated `collectWarnings` to extract and include `rawName` as `productName` in AI trace warnings
- Added `signals` field to `FlyerParseItem` type for detailed product signals
- Enhanced Flutter admin panel to display product names in AI trace warnings
- Added new `AdminAiTraceResponse` DTO for AI trace data structure
This commit is contained in:
Nils-Johan Gynther
2026-05-24 20:55:14 +02:00
parent 7713eb2fa7
commit ca1eed5061
5 changed files with 117 additions and 93 deletions
+6 -3
View File
@@ -457,7 +457,7 @@ export class AiTraceService {
return `user:${userId}`; return `user:${userId}`;
} }
private collectWarnings(items: Array<{ parseReasons: unknown; matchReasons: unknown; itemIndex?: number }>): { private collectWarnings(items: Array<{ parseReasons: unknown; matchReasons: unknown; itemIndex?: number; rawName?: string }>): {
warnings: AdminAiWarning[]; warnings: AdminAiWarning[];
legacyWarnings: string[]; legacyWarnings: string[];
} { } {
@@ -467,6 +467,7 @@ export class AiTraceService {
for (const item of items) { for (const item of items) {
const itemIndex = item.itemIndex != null ? item.itemIndex + 1 : undefined; const itemIndex = item.itemIndex != null ? item.itemIndex + 1 : undefined;
const productName = item.rawName?.trim() || 'okänt';
if (Array.isArray(item.parseReasons)) { if (Array.isArray(item.parseReasons)) {
for (const reason of item.parseReasons) { for (const reason of item.parseReasons) {
@@ -475,7 +476,8 @@ export class AiTraceService {
const warning: AdminAiWarning = { const warning: AdminAiWarning = {
...describeParseReason(text), ...describeParseReason(text),
itemIndex, itemIndex,
}; productName,
} as AdminAiWarning;
const key = `${warning.kind}:${text}:${warning.itemIndex ?? 0}`; const key = `${warning.kind}:${text}:${warning.itemIndex ?? 0}`;
if (dedupe.has(key)) continue; if (dedupe.has(key)) continue;
dedupe.add(key); dedupe.add(key);
@@ -491,7 +493,8 @@ export class AiTraceService {
const warning: AdminAiWarning = { const warning: AdminAiWarning = {
...describeMatchReason(text), ...describeMatchReason(text),
itemIndex, itemIndex,
}; productName,
} as AdminAiWarning;
const key = `${warning.kind}:${text}:${warning.itemIndex ?? 0}`; const key = `${warning.kind}:${text}:${warning.itemIndex ?? 0}`;
if (dedupe.has(key)) continue; if (dedupe.has(key)) continue;
dedupe.add(key); dedupe.add(key);
@@ -0,0 +1,6 @@
import { FlyerReasonDescriptor } from '../../flyer-import/services/reason-codes';
export type AdminAiWarning = FlyerReasonDescriptor & {
itemIndex?: number;
productName?: string;
};
@@ -40,6 +40,7 @@ type FlyerParseItem = {
offerText: string | null; offerText: string | null;
confidence: number; confidence: number;
reasonCodes: string[]; reasonCodes: string[];
signals?: ImportedItemSignals | null;
}; };
type FlyerParseResponse = { type FlyerParseResponse = {
@@ -129,56 +130,59 @@ export class FlyerImportService {
offerText: item.offerText, offerText: item.offerText,
}); });
const match = this.matchItem(item, signalData.normalizedMatchName, signalData.signals, products, aliasToProduct, productById); const match = this.matchItem(item, signalData.normalizedMatchName, signalData.signals, products, aliasToProduct, productById);
const signals = this.extractOfferSignals(item.offerText); const signals = this.extractOfferSignals(item.offerText);
const price = item.price ?? signals.price; const price = item.price ?? signals.price;
const priceUnit = this.normalizeUnit(item.priceUnit) ?? signals.priceUnit; const priceUnit = this.normalizeUnit(item.priceUnit) ?? signals.priceUnit;
const comparisonPrice = item.comparisonPrice ?? signals.comparisonPrice; const comparisonPrice = item.comparisonPrice ?? signals.comparisonPrice;
const comparisonUnit = this.normalizeUnit(item.comparisonUnit) ?? signals.comparisonUnit; const comparisonUnit = this.normalizeUnit(item.comparisonUnit) ?? signals.comparisonUnit;
const offerLimitText = this.extractOfferLimitText(item.offerText); const offerLimitText = this.extractOfferLimitText(item.offerText);
const displayNameDetailed = buildDisplayNameDetailed({ const displayNameDetailed = buildDisplayNameDetailed({
rawName: item.rawName, rawName: item.rawName,
isBundle: item.isBundle, isBundle: item.isBundle,
bundleItems: this.sanitizeBundleItems(item.bundleItems), bundleItems: this.sanitizeBundleItems(item.bundleItems),
}); });
const categoryId = this.categoryResolver.resolveForFlyer({ const categoryId = this.categoryResolver.resolveForFlyer({
categories, categories,
signalText: [item.rawName, item.brand ?? '', item.offerText ?? ''].join(' ').trim(), signalText: [item.rawName, item.brand ?? '', item.offerText ?? ''].join(' ').trim(),
categoryHint: item.category, categoryHint: item.category,
matchedProductCategoryId: match.product?.categoryId ?? null, matchedProductCategoryId: match.product?.categoryId ?? null,
matchConfidence: match.confidence, matchConfidence: match.confidence,
}); });
return { const origin = item.signals?.originCountries?.[0] || null;
flyerItemId: null, const brand = item.brand && item.brand.trim() !== origin ? item.brand : null;
rawName: item.rawName,
normalizedName: signalData.normalizedMatchName || item.normalizedName, return {
brand: item.brand, flyerItemId: null,
category: item.category, rawName: item.rawName,
categoryId, normalizedName: signalData.normalizedMatchName || item.normalizedName,
price, brand,
priceUnit, category: item.category,
comparisonPrice, categoryId,
comparisonUnit, price,
weight: item.weight, priceUnit,
bundleWeight: item.bundleWeight, comparisonPrice,
isBundle: item.isBundle, comparisonUnit,
bundleItems: this.sanitizeBundleItems(item.bundleItems), weight: item.weight,
displayNameDetailed, bundleWeight: item.bundleWeight,
signals: signalData.signals, isBundle: item.isBundle,
offerText: item.offerText, bundleItems: this.sanitizeBundleItems(item.bundleItems),
isOffer: this.isOfferItem(item, signals.hasCampaignPattern), displayNameDetailed,
offerLimitText, signals: signalData.signals,
parseConfidence: item.confidence, offerText: item.offerText,
parseReasons: item.reasonCodes, isOffer: this.isOfferItem(item, signals.hasCampaignPattern),
parseReasonsDetailed: this.describeParseReasons(item.reasonCodes), offerLimitText,
matchedProductId: match.product?.id ?? null, parseConfidence: item.confidence,
matchedProductName: match.product?.name ?? null, parseReasons: item.reasonCodes,
matchedVia: match.via, parseReasonsDetailed: this.describeParseReasons(item.reasonCodes),
matchConfidence: match.confidence, matchedProductId: match.product?.id ?? null,
matchReasons: match.reasons, matchedProductName: match.product?.name ?? null,
matchReasonsDetailed: this.describeMatchReasons(match.reasons), matchedVia: match.via,
}; matchConfidence: match.confidence,
matchReasons: match.reasons,
matchReasonsDetailed: this.describeMatchReasons(match.reasons),
};
}); });
this.logImportMetrics(items); this.logImportMetrics(items);
@@ -716,24 +720,25 @@ export class FlyerImportService {
// 3. Normalisera resultatet // 3. Normalisera resultatet
const normalizedItems = this.normalizer.normalize(aiParseResult.items); 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) => ({
rawName: item.rawName, rawName: item.rawName,
normalizedName: item.normalizedName, normalizedName: item.normalizedName,
brand: item.brand, brand: item.brand,
category: item.categoryHint, category: item.categoryHint,
price: item.price, price: item.price,
priceUnit: item.priceUnit, priceUnit: item.priceUnit,
comparisonPrice: item.comparisonPrice, comparisonPrice: item.comparisonPrice,
comparisonUnit: item.comparisonUnit, comparisonUnit: item.comparisonUnit,
weight: item.weight, weight: item.weight,
bundleWeight: item.bundleWeight, bundleWeight: item.bundleWeight,
isBundle: item.isBundle, isBundle: item.isBundle,
bundleItems: item.bundleItems, bundleItems: item.bundleItems,
offerText: item.offerText, offerText: item.offerText,
confidence: item.parseConfidence, confidence: item.parseConfidence,
reasonCodes: item.parseReasons, reasonCodes: item.parseReasons,
})); signals: null,
}));
const warnings: string[] = []; const warnings: string[] = [];
if (items.length === 0) { if (items.length === 0) {
@@ -8,6 +8,7 @@ class AdminAiWarning {
final String severity; final String severity;
final String? location; final String? location;
final int? itemIndex; final int? itemIndex;
final String? productName;
const AdminAiWarning({ const AdminAiWarning({
required this.code, required this.code,
@@ -17,6 +18,7 @@ class AdminAiWarning {
required this.severity, required this.severity,
required this.location, required this.location,
required this.itemIndex, required this.itemIndex,
required this.productName,
}); });
factory AdminAiWarning.fromJson(Map<String, dynamic> json) { factory AdminAiWarning.fromJson(Map<String, dynamic> json) {
@@ -28,6 +30,7 @@ class AdminAiWarning {
severity: (json['severity'] ?? '').toString(), severity: (json['severity'] ?? '').toString(),
location: json['location']?.toString(), location: json['location']?.toString(),
itemIndex: (json['itemIndex'] as num?)?.toInt(), itemIndex: (json['itemIndex'] as num?)?.toInt(),
productName: json['productName']?.toString(),
); );
} }
@@ -44,6 +47,7 @@ class AdminAiWarning {
severity: 'warning', severity: 'warning',
location: null, location: null,
itemIndex: null, itemIndex: null,
productName: null,
); );
} }
} }
@@ -139,7 +139,8 @@ class _AdminAiPanelState extends ConsumerState<AdminAiPanel> {
String _formatWarningLine(AdminAiWarning warning) { String _formatWarningLine(AdminAiWarning warning) {
final rowSuffix = warning.itemIndex == null ? '' : ' (rad ${warning.itemIndex})'; final rowSuffix = warning.itemIndex == null ? '' : ' (rad ${warning.itemIndex})';
return '[${warning.severity}] ${warning.title}$rowSuffix: ${warning.message}'; final productSuffix = warning.productName != null ? ' (${warning.productName})' : '';
return '[${warning.severity}] ${warning.title}$rowSuffix$productSuffix: ${warning.message}';
} }
String _buildErrorReport({ String _buildErrorReport({
@@ -685,11 +686,16 @@ class _WarningsCard extends StatelessWidget {
warning.location!, warning.location!,
style: theme.textTheme.bodySmall, style: theme.textTheme.bodySmall,
), ),
if (warning.itemIndex != null) if (warning.itemIndex != null)
Text( Text(
'Rad: ${warning.itemIndex}', 'Rad: ${warning.itemIndex}',
style: theme.textTheme.bodySmall, style: theme.textTheme.bodySmall,
), ),
if (warning.productName != null)
Text(
'Produkt: ${warning.productName}',
style: theme.textTheme.bodySmall,
),
], ],
), ),
trailing: IconButton( trailing: IconButton(