feat(ai): enhance AI trace warnings and reason codes system
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 4m21s
Test Suite / flutter-quality (push) Failing after 1m38s

- Added structured warning system with `AdminAiWarning` type in backend and Flutter
- Implemented detailed reason descriptors with `FlyerReasonDescriptor` for parse and match operations
- Added `legacyWarnings` field to maintain backward compatibility
- Enhanced AI trace service to collect and format warnings with item-level context
- Updated flyer import services to include detailed reason descriptions in responses
- Added Swedish diacritic preservation for cheese variants (Prästost, Herrgårdsost, Grevéost)
- Implemented UTF-8 content validation for AI responses
- Added new reason code definitions in `reason-codes.ts`
- Updated Flutter UI to display structured warnings with severity indicators
- Added error report generation and copy functionality in admin panel
- Added comprehensive test coverage for new warning system and cheese normalization

BREAKING CHANGE: AI trace warnings are now structured objects instead of simple strings
This commit is contained in:
Nils-Johan Gynther
2026-05-23 21:11:46 +02:00
parent 0fb507f247
commit d9f992ca9a
18 changed files with 1308 additions and 81 deletions
@@ -1,4 +1,6 @@
class FlyerImportItem {
import 'flyer_reason_descriptor.dart';
class FlyerImportItem {
final int? flyerItemId;
final String rawName;
final String normalizedName;
@@ -12,11 +14,14 @@ class FlyerImportItem {
final double? comparisonPrice;
final String? comparisonUnit;
final double? parseConfidence;
final List<String> parseReasons;
final int? matchedProductId;
final String? matchedProductName;
final String? matchedVia;
final double? matchConfidence;
final List<String> parseReasons;
final List<FlyerReasonDescriptor> parseReasonsDetailed;
final int? matchedProductId;
final String? matchedProductName;
final String? matchedVia;
final double? matchConfidence;
final List<String> matchReasons;
final List<FlyerReasonDescriptor> matchReasonsDetailed;
FlyerImportItem({
required this.flyerItemId,
@@ -31,13 +36,16 @@ class FlyerImportItem {
this.offerLimitText,
this.comparisonPrice,
this.comparisonUnit,
this.parseConfidence,
this.parseReasons = const [],
this.matchedProductId,
this.matchedProductName,
this.matchedVia,
this.matchConfidence,
});
this.parseConfidence,
this.parseReasons = const [],
this.parseReasonsDetailed = const [],
this.matchedProductId,
this.matchedProductName,
this.matchedVia,
this.matchConfidence,
this.matchReasons = const [],
this.matchReasonsDetailed = const [],
});
factory FlyerImportItem.fromJson(Map<String, dynamic> json) {
return FlyerImportItem(
@@ -53,12 +61,25 @@ class FlyerImportItem {
offerLimitText: json['offerLimitText'] as String?,
comparisonPrice: (json['comparisonPrice'] as num?)?.toDouble(),
comparisonUnit: json['comparisonUnit'] as String?,
parseConfidence: (json['parseConfidence'] as num?)?.toDouble(),
parseReasons: (json['parseReasons'] as List?)?.map((e) => e.toString()).toList() ?? const [],
matchedProductId: (json['matchedProductId'] as num?)?.toInt(),
matchedProductName: json['matchedProductName'] as String?,
matchedVia: json['matchedVia'] as String?,
matchConfidence: (json['matchConfidence'] as num?)?.toDouble(),
parseConfidence: (json['parseConfidence'] as num?)?.toDouble(),
parseReasons: (json['parseReasons'] as List?)?.map((e) => e.toString()).toList() ?? const [],
parseReasonsDetailed:
(json['parseReasonsDetailed'] as List?)
?.whereType<Map>()
.map((e) => FlyerReasonDescriptor.fromJson(Map<String, dynamic>.from(e)))
.toList() ??
const [],
matchedProductId: (json['matchedProductId'] as num?)?.toInt(),
matchedProductName: json['matchedProductName'] as String?,
matchedVia: json['matchedVia'] as String?,
matchConfidence: (json['matchConfidence'] as num?)?.toDouble(),
matchReasons: (json['matchReasons'] as List?)?.map((e) => e.toString()).toList() ?? const [],
matchReasonsDetailed:
(json['matchReasonsDetailed'] as List?)
?.whereType<Map>()
.map((e) => FlyerReasonDescriptor.fromJson(Map<String, dynamic>.from(e)))
.toList() ??
const [],
);
}
@@ -78,10 +99,15 @@ class FlyerImportItem {
'comparisonUnit': comparisonUnit,
'parseConfidence': parseConfidence,
'parseReasons': parseReasons,
'parseReasonsDetailed':
parseReasonsDetailed.map((reason) => reason.toJson()).toList(),
'matchedProductId': matchedProductId,
'matchedProductName': matchedProductName,
'matchedVia': matchedVia,
'matchConfidence': matchConfidence,
'matchReasons': matchReasons,
'matchReasonsDetailed':
matchReasonsDetailed.map((reason) => reason.toJson()).toList(),
};
}
@@ -105,10 +131,13 @@ class FlyerImportItem {
comparisonUnit: comparisonUnit,
parseConfidence: parseConfidence,
parseReasons: parseReasons,
parseReasonsDetailed: parseReasonsDetailed,
matchedProductId: matchedProductId,
matchedProductName: matchedProductName,
matchedVia: matchedVia,
matchConfidence: matchConfidence,
matchReasons: matchReasons,
matchReasonsDetailed: matchReasonsDetailed,
);
}
}
@@ -0,0 +1,39 @@
class FlyerReasonDescriptor {
final String code;
final String kind;
final String title;
final String message;
final String severity;
final String? location;
const FlyerReasonDescriptor({
required this.code,
required this.kind,
required this.title,
required this.message,
required this.severity,
required this.location,
});
factory FlyerReasonDescriptor.fromJson(Map<String, dynamic> json) {
return FlyerReasonDescriptor(
code: (json['code'] ?? '').toString(),
kind: (json['kind'] ?? '').toString(),
title: (json['title'] ?? '').toString(),
message: (json['message'] ?? '').toString(),
severity: (json['severity'] ?? '').toString(),
location: json['location']?.toString(),
);
}
Map<String, dynamic> toJson() {
return {
'code': code,
'kind': kind,
'title': title,
'message': message,
'severity': severity,
'location': location,
};
}
}
@@ -1,6 +1,6 @@
import 'package:file_picker/file_picker.dart';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
@@ -14,6 +14,7 @@ import '../../shopping_list/data/shopping_list_providers.dart';
import '../data/flyer_import_session.dart';
import '../data/import_providers.dart';
import '../domain/flyer_import_item.dart';
import '../domain/flyer_reason_descriptor.dart';
import '../domain/flyer_import_result.dart';
import 'error_dialog.dart';
@@ -32,6 +33,7 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
List<AdminCategoryNode> _categoryTree = const [];
FlyerImportResult? _result;
final Map<int, bool> _selected = {};
final Map<int, bool> _expandedReasonRows = {};
@override
void initState() {
@@ -90,6 +92,7 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
_selected
..clear()
..addAll(selected);
_expandedReasonRows.clear();
});
await _loadRestoredSourceIfNeeded(serverResult, token);
notifier.setImportedResult(
@@ -117,6 +120,7 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
_selected
..clear()
..addAll(selected);
_expandedReasonRows.clear();
});
await _loadRestoredSourceIfNeeded(latest, token);
notifier.setImportedResult(
@@ -130,6 +134,7 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
_selected
..clear()
..addAll(session.selected);
_expandedReasonRows.clear();
});
}
} catch (_) {
@@ -141,6 +146,7 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
_selected
..clear()
..addAll(session.selected);
_expandedReasonRows.clear();
});
}
}
@@ -196,6 +202,7 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
_selected
..clear()
..addAll(selected);
_expandedReasonRows.clear();
});
ref.read(flyerImportSessionProvider.notifier).setImportedResult(
result: parsed,
@@ -494,6 +501,39 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
);
}
Future<void> _copyText(String value, String label) async {
await Clipboard.setData(ClipboardData(text: value));
if (!mounted) return;
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text('$label kopierad')));
}
IconData _reasonSeverityIcon(String severity) {
switch (severity) {
case 'error':
return Icons.error_outline;
case 'warning':
return Icons.warning_amber_rounded;
default:
return Icons.info_outline;
}
}
Color _reasonSeverityColor(String severity) {
switch (severity) {
case 'error':
return Colors.red.shade700;
case 'warning':
return Colors.orange.shade700;
default:
return Colors.blue.shade700;
}
}
int _countWarningReasons(List<FlyerReasonDescriptor> reasons) {
return reasons.where((reason) => reason.severity == 'warning' || reason.severity == 'error').length;
}
Widget _buildWarningsPanel(ThemeData theme) {
final warnings = _result?.warnings ?? const <String>[];
if (warnings.isEmpty) return const SizedBox.shrink();
@@ -526,10 +566,24 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
const SizedBox(height: 8),
...warnings.map((warning) => Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(
'$warning',
style: theme.textTheme.bodySmall?.copyWith(
color: Colors.amber.shade900,
child: InkWell(
onTap: () => _copyText(warning, 'Varning'),
borderRadius: BorderRadius.circular(4),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
children: [
Expanded(
child: Text(
'$warning',
style: theme.textTheme.bodySmall?.copyWith(
color: Colors.amber.shade900,
),
),
),
Icon(Icons.copy, size: 14, color: Colors.amber.shade800),
],
),
),
),
)),
@@ -672,6 +726,12 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
final sanitizedOfferText = item.offerText == null
? ''
: _removeLimitTextFromOfferText(item.offerText!, limitText);
final detailedReasons = [
...item.parseReasonsDetailed,
...item.matchReasonsDetailed,
];
final warningCount = _countWarningReasons(detailedReasons);
final reasonExpanded = _expandedReasonRows[index] == true;
return CheckboxListTile(
value: _selected[index] ?? false,
@@ -720,6 +780,100 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
if (sanitizedOfferText.isNotEmpty) Text(sanitizedOfferText),
if (item.matchedProductName != null)
Text('Match: ${item.matchedProductName}'),
if (detailedReasons.isNotEmpty) ...[
const SizedBox(height: 6),
InkWell(
onTap: () => setState(
() => _expandedReasonRows[index] = !reasonExpanded,
),
child: Row(
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: warningCount > 0
? Colors.orange.shade50
: Colors.blue.shade50,
borderRadius: BorderRadius.circular(999),
border: Border.all(
color: warningCount > 0
? Colors.orange.shade200
: Colors.blue.shade200,
),
),
child: Text(
warningCount > 0
? '$warningCount varningar'
: '${detailedReasons.length} info',
style: theme.textTheme.labelSmall?.copyWith(
color: warningCount > 0
? Colors.orange.shade900
: Colors.blue.shade900,
fontWeight: FontWeight.w700,
),
),
),
const SizedBox(width: 8),
Icon(
reasonExpanded
? Icons.expand_less
: Icons.expand_more,
size: 18,
),
],
),
),
if (reasonExpanded)
...detailedReasons.map(
(reason) => Padding(
padding: const EdgeInsets.only(top: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding:
const EdgeInsets.only(top: 2, right: 6),
child: Icon(
_reasonSeverityIcon(reason.severity),
size: 16,
color: _reasonSeverityColor(
reason.severity,
),
),
),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
reason.title,
style: theme.textTheme.bodySmall
?.copyWith(
fontWeight: FontWeight.w700,
),
),
Text(
reason.message,
style: theme.textTheme.bodySmall,
),
if ((reason.location ?? '').isNotEmpty)
Text(
reason.location!,
style: theme.textTheme.bodySmall
?.copyWith(
color: theme.colorScheme.primary,
),
),
],
),
),
],
),
),
),
],
],
),
controlAffinity: ListTileControlAffinity.leading,