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}`;
|
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;
|
||||||
|
};
|
||||||
@@ -24,22 +24,23 @@ import { buildDisplayNameDetailed } from '../import-common/import-display-name.u
|
|||||||
import { extractImportSignals } from '../import-common/import-signals.util';
|
import { extractImportSignals } from '../import-common/import-signals.util';
|
||||||
import { ImportedItemSignals } from '../import-common/import-item.types';
|
import { ImportedItemSignals } from '../import-common/import-item.types';
|
||||||
|
|
||||||
type FlyerParseItem = {
|
type FlyerParseItem = {
|
||||||
rawName: string;
|
rawName: string;
|
||||||
normalizedName: string;
|
normalizedName: string;
|
||||||
brand: string | null;
|
brand: string | null;
|
||||||
category: string | null;
|
category: string | null;
|
||||||
price: number | null;
|
price: number | null;
|
||||||
priceUnit: string | null;
|
priceUnit: string | null;
|
||||||
comparisonPrice: number | null;
|
comparisonPrice: number | null;
|
||||||
comparisonUnit: string | null;
|
comparisonUnit: string | null;
|
||||||
weight: string | null;
|
weight: string | null;
|
||||||
bundleWeight: string | null;
|
bundleWeight: string | null;
|
||||||
isBundle: boolean;
|
isBundle: boolean;
|
||||||
bundleItems: string[];
|
bundleItems: string[];
|
||||||
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(
|
||||||
|
|||||||
Reference in New Issue
Block a user