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}`;
}
private collectWarnings(items: Array<{ parseReasons: unknown; matchReasons: unknown; itemIndex?: number }>): {
private collectWarnings(items: Array<{ parseReasons: unknown; matchReasons: unknown; itemIndex?: number; rawName?: string }>): {
warnings: AdminAiWarning[];
legacyWarnings: string[];
} {
@@ -467,6 +467,7 @@ export class AiTraceService {
for (const item of items) {
const itemIndex = item.itemIndex != null ? item.itemIndex + 1 : undefined;
const productName = item.rawName?.trim() || 'okänt';
if (Array.isArray(item.parseReasons)) {
for (const reason of item.parseReasons) {
@@ -475,7 +476,8 @@ export class AiTraceService {
const warning: AdminAiWarning = {
...describeParseReason(text),
itemIndex,
};
productName,
} as AdminAiWarning;
const key = `${warning.kind}:${text}:${warning.itemIndex ?? 0}`;
if (dedupe.has(key)) continue;
dedupe.add(key);
@@ -491,7 +493,8 @@ export class AiTraceService {
const warning: AdminAiWarning = {
...describeMatchReason(text),
itemIndex,
};
productName,
} as AdminAiWarning;
const key = `${warning.kind}:${text}:${warning.itemIndex ?? 0}`;
if (dedupe.has(key)) continue;
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;
confidence: number;
reasonCodes: string[];
signals?: ImportedItemSignals | null;
};
type FlyerParseResponse = {
@@ -129,56 +130,59 @@ export class FlyerImportService {
offerText: item.offerText,
});
const match = this.matchItem(item, signalData.normalizedMatchName, signalData.signals, products, aliasToProduct, productById);
const signals = this.extractOfferSignals(item.offerText);
const price = item.price ?? signals.price;
const priceUnit = this.normalizeUnit(item.priceUnit) ?? signals.priceUnit;
const comparisonPrice = item.comparisonPrice ?? signals.comparisonPrice;
const comparisonUnit = this.normalizeUnit(item.comparisonUnit) ?? signals.comparisonUnit;
const offerLimitText = this.extractOfferLimitText(item.offerText);
const displayNameDetailed = buildDisplayNameDetailed({
rawName: item.rawName,
isBundle: item.isBundle,
bundleItems: this.sanitizeBundleItems(item.bundleItems),
});
const categoryId = this.categoryResolver.resolveForFlyer({
categories,
signalText: [item.rawName, item.brand ?? '', item.offerText ?? ''].join(' ').trim(),
categoryHint: item.category,
matchedProductCategoryId: match.product?.categoryId ?? null,
matchConfidence: match.confidence,
});
const match = this.matchItem(item, signalData.normalizedMatchName, signalData.signals, products, aliasToProduct, productById);
const signals = this.extractOfferSignals(item.offerText);
const price = item.price ?? signals.price;
const priceUnit = this.normalizeUnit(item.priceUnit) ?? signals.priceUnit;
const comparisonPrice = item.comparisonPrice ?? signals.comparisonPrice;
const comparisonUnit = this.normalizeUnit(item.comparisonUnit) ?? signals.comparisonUnit;
const offerLimitText = this.extractOfferLimitText(item.offerText);
const displayNameDetailed = buildDisplayNameDetailed({
rawName: item.rawName,
isBundle: item.isBundle,
bundleItems: this.sanitizeBundleItems(item.bundleItems),
});
const categoryId = this.categoryResolver.resolveForFlyer({
categories,
signalText: [item.rawName, item.brand ?? '', item.offerText ?? ''].join(' ').trim(),
categoryHint: item.category,
matchedProductCategoryId: match.product?.categoryId ?? null,
matchConfidence: match.confidence,
});
return {
flyerItemId: null,
rawName: item.rawName,
normalizedName: signalData.normalizedMatchName || item.normalizedName,
brand: item.brand,
category: item.category,
categoryId,
price,
priceUnit,
comparisonPrice,
comparisonUnit,
weight: item.weight,
bundleWeight: item.bundleWeight,
isBundle: item.isBundle,
bundleItems: this.sanitizeBundleItems(item.bundleItems),
displayNameDetailed,
signals: signalData.signals,
offerText: item.offerText,
isOffer: this.isOfferItem(item, signals.hasCampaignPattern),
offerLimitText,
parseConfidence: item.confidence,
parseReasons: item.reasonCodes,
parseReasonsDetailed: this.describeParseReasons(item.reasonCodes),
matchedProductId: match.product?.id ?? null,
matchedProductName: match.product?.name ?? null,
matchedVia: match.via,
matchConfidence: match.confidence,
matchReasons: match.reasons,
matchReasonsDetailed: this.describeMatchReasons(match.reasons),
};
const origin = item.signals?.originCountries?.[0] || null;
const brand = item.brand && item.brand.trim() !== origin ? item.brand : null;
return {
flyerItemId: null,
rawName: item.rawName,
normalizedName: signalData.normalizedMatchName || item.normalizedName,
brand,
category: item.category,
categoryId,
price,
priceUnit,
comparisonPrice,
comparisonUnit,
weight: item.weight,
bundleWeight: item.bundleWeight,
isBundle: item.isBundle,
bundleItems: this.sanitizeBundleItems(item.bundleItems),
displayNameDetailed,
signals: signalData.signals,
offerText: item.offerText,
isOffer: this.isOfferItem(item, signals.hasCampaignPattern),
offerLimitText,
parseConfidence: item.confidence,
parseReasons: item.reasonCodes,
parseReasonsDetailed: this.describeParseReasons(item.reasonCodes),
matchedProductId: match.product?.id ?? null,
matchedProductName: match.product?.name ?? null,
matchedVia: match.via,
matchConfidence: match.confidence,
matchReasons: match.reasons,
matchReasonsDetailed: this.describeMatchReasons(match.reasons),
};
});
this.logImportMetrics(items);
@@ -716,24 +720,25 @@ export class FlyerImportService {
// 3. Normalisera resultatet
const normalizedItems = this.normalizer.normalize(aiParseResult.items);
// 4. Konvertera till intern FlyerParseItem-format
const items: FlyerParseItem[] = normalizedItems.map((item) => ({
rawName: item.rawName,
normalizedName: item.normalizedName,
brand: item.brand,
category: item.categoryHint,
price: item.price,
priceUnit: item.priceUnit,
comparisonPrice: item.comparisonPrice,
comparisonUnit: item.comparisonUnit,
weight: item.weight,
bundleWeight: item.bundleWeight,
isBundle: item.isBundle,
bundleItems: item.bundleItems,
offerText: item.offerText,
confidence: item.parseConfidence,
reasonCodes: item.parseReasons,
}));
// 4. Konvertera till intern FlyerParseItem-format
const items: FlyerParseItem[] = normalizedItems.map((item) => ({
rawName: item.rawName,
normalizedName: item.normalizedName,
brand: item.brand,
category: item.categoryHint,
price: item.price,
priceUnit: item.priceUnit,
comparisonPrice: item.comparisonPrice,
comparisonUnit: item.comparisonUnit,
weight: item.weight,
bundleWeight: item.bundleWeight,
isBundle: item.isBundle,
bundleItems: item.bundleItems,
offerText: item.offerText,
confidence: item.parseConfidence,
reasonCodes: item.parseReasons,
signals: null,
}));
const warnings: string[] = [];
if (items.length === 0) {
@@ -8,6 +8,7 @@ class AdminAiWarning {
final String severity;
final String? location;
final int? itemIndex;
final String? productName;
const AdminAiWarning({
required this.code,
@@ -17,6 +18,7 @@ class AdminAiWarning {
required this.severity,
required this.location,
required this.itemIndex,
required this.productName,
});
factory AdminAiWarning.fromJson(Map<String, dynamic> json) {
@@ -28,6 +30,7 @@ class AdminAiWarning {
severity: (json['severity'] ?? '').toString(),
location: json['location']?.toString(),
itemIndex: (json['itemIndex'] as num?)?.toInt(),
productName: json['productName']?.toString(),
);
}
@@ -44,6 +47,7 @@ class AdminAiWarning {
severity: 'warning',
location: null,
itemIndex: null,
productName: null,
);
}
}
@@ -139,7 +139,8 @@ class _AdminAiPanelState extends ConsumerState<AdminAiPanel> {
String _formatWarningLine(AdminAiWarning warning) {
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({
@@ -685,11 +686,16 @@ class _WarningsCard extends StatelessWidget {
warning.location!,
style: theme.textTheme.bodySmall,
),
if (warning.itemIndex != null)
Text(
'Rad: ${warning.itemIndex}',
style: theme.textTheme.bodySmall,
),
if (warning.itemIndex != null)
Text(
'Rad: ${warning.itemIndex}',
style: theme.textTheme.bodySmall,
),
if (warning.productName != null)
Text(
'Produkt: ${warning.productName}',
style: theme.textTheme.bodySmall,
),
],
),
trailing: IconButton(