feat(ai): enhance AI trace warnings with product context
- 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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user