feat(ai): enhance AI trace warnings and reason codes system
- 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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user