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,5 +1,53 @@
|
||||
import 'admin_ai_trace.dart';
|
||||
|
||||
class AdminAiWarning {
|
||||
final String code;
|
||||
final String kind;
|
||||
final String title;
|
||||
final String message;
|
||||
final String severity;
|
||||
final String? location;
|
||||
final int? itemIndex;
|
||||
|
||||
const AdminAiWarning({
|
||||
required this.code,
|
||||
required this.kind,
|
||||
required this.title,
|
||||
required this.message,
|
||||
required this.severity,
|
||||
required this.location,
|
||||
required this.itemIndex,
|
||||
});
|
||||
|
||||
factory AdminAiWarning.fromJson(Map<String, dynamic> json) {
|
||||
return AdminAiWarning(
|
||||
code: (json['code'] ?? '').toString(),
|
||||
kind: (json['kind'] ?? '').toString(),
|
||||
title: (json['title'] ?? '').toString(),
|
||||
message: (json['message'] ?? '').toString(),
|
||||
severity: (json['severity'] ?? '').toString(),
|
||||
location: json['location']?.toString(),
|
||||
itemIndex: (json['itemIndex'] as num?)?.toInt(),
|
||||
);
|
||||
}
|
||||
|
||||
factory AdminAiWarning.fromLegacy(String value) {
|
||||
final trimmed = value.trim();
|
||||
final parts = trimmed.split(':');
|
||||
final kind = parts.isNotEmpty ? parts.first : 'parse';
|
||||
final code = parts.length > 1 ? parts.sublist(1).join(':') : trimmed;
|
||||
return AdminAiWarning(
|
||||
code: code,
|
||||
kind: kind,
|
||||
title: trimmed,
|
||||
message: trimmed,
|
||||
severity: 'warning',
|
||||
location: null,
|
||||
itemIndex: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AdminAiTraceDetail {
|
||||
final String id;
|
||||
final AdminAiTraceSource source;
|
||||
@@ -13,7 +61,8 @@ class AdminAiTraceDetail {
|
||||
final int? durationMs;
|
||||
final int? retryCount;
|
||||
final int? chunkCount;
|
||||
final List<String> warnings;
|
||||
final List<AdminAiWarning> warnings;
|
||||
final List<String> legacyWarnings;
|
||||
final String? error;
|
||||
final String? prompt;
|
||||
final String? rawOutput;
|
||||
@@ -34,6 +83,7 @@ class AdminAiTraceDetail {
|
||||
required this.retryCount,
|
||||
required this.chunkCount,
|
||||
required this.warnings,
|
||||
required this.legacyWarnings,
|
||||
required this.error,
|
||||
required this.prompt,
|
||||
required this.rawOutput,
|
||||
@@ -43,6 +93,7 @@ class AdminAiTraceDetail {
|
||||
|
||||
factory AdminAiTraceDetail.fromJson(Map<String, dynamic> json) {
|
||||
final warningsRaw = (json['warnings'] as List<dynamic>?) ?? const [];
|
||||
final legacyWarningsRaw = (json['legacyWarnings'] as List<dynamic>?) ?? const [];
|
||||
final normalizedOutputMap = json['normalizedOutput'] is Map
|
||||
? Map<String, dynamic>.from(json['normalizedOutput'] as Map)
|
||||
: null;
|
||||
@@ -64,7 +115,15 @@ class AdminAiTraceDetail {
|
||||
durationMs: (json['durationMs'] as num?)?.toInt(),
|
||||
retryCount: (json['retryCount'] as num?)?.toInt(),
|
||||
chunkCount: (json['chunkCount'] as num?)?.toInt(),
|
||||
warnings: warningsRaw.map((entry) => entry.toString()).toList(),
|
||||
warnings: warningsRaw
|
||||
.map((entry) {
|
||||
if (entry is Map) {
|
||||
return AdminAiWarning.fromJson(Map<String, dynamic>.from(entry));
|
||||
}
|
||||
return AdminAiWarning.fromLegacy(entry.toString());
|
||||
})
|
||||
.toList(),
|
||||
legacyWarnings: legacyWarningsRaw.map((entry) => entry.toString()).toList(),
|
||||
error: json['error']?.toString(),
|
||||
prompt: json['prompt']?.toString(),
|
||||
rawOutput: json['rawOutput']?.toString(),
|
||||
|
||||
@@ -137,6 +137,41 @@ class _AdminAiPanelState extends ConsumerState<AdminAiPanel> {
|
||||
return const JsonEncoder.withIndent(' ').convert(data);
|
||||
}
|
||||
|
||||
String _formatWarningLine(AdminAiWarning warning) {
|
||||
final rowSuffix = warning.itemIndex == null ? '' : ' (rad ${warning.itemIndex})';
|
||||
return '[${warning.severity}] ${warning.title}$rowSuffix: ${warning.message}';
|
||||
}
|
||||
|
||||
String _buildErrorReport({
|
||||
required AdminAiTraceDetail detail,
|
||||
required String prettyOutput,
|
||||
}) {
|
||||
final warningCount = detail.warnings.length;
|
||||
final buffer = StringBuffer()
|
||||
..writeln('[AI-trace ${detail.id}]')
|
||||
..writeln('Modell: ${detail.model ?? 'okänd'}')
|
||||
..writeln('Status: ${detail.status.name} ($warningCount varningar)')
|
||||
..writeln('Tid: ${detail.createdAt.toIso8601String()}')
|
||||
..writeln();
|
||||
|
||||
if (detail.warnings.isNotEmpty) {
|
||||
buffer.writeln('Varningar:');
|
||||
for (final warning in detail.warnings) {
|
||||
buffer.writeln('- ${_formatWarningLine(warning)}');
|
||||
}
|
||||
buffer.writeln();
|
||||
}
|
||||
|
||||
buffer
|
||||
..writeln('Prompt:')
|
||||
..writeln((detail.prompt ?? '').trim().isEmpty ? '[saknas]' : detail.prompt!.trim())
|
||||
..writeln()
|
||||
..writeln('Raw output:')
|
||||
..writeln((detail.rawOutput ?? '').trim().isEmpty ? prettyOutput : detail.rawOutput!.trim());
|
||||
|
||||
return buffer.toString().trimRight();
|
||||
}
|
||||
|
||||
Color _statusColor(AdminAiTraceStatus status, ColorScheme scheme) {
|
||||
return switch (status) {
|
||||
AdminAiTraceStatus.success => Colors.green.shade700,
|
||||
@@ -348,16 +383,30 @@ class _AdminAiPanelState extends ConsumerState<AdminAiPanel> {
|
||||
? const <String, dynamic>{}
|
||||
: {'rawOutput': detail.rawOutput});
|
||||
final prettyOutput = _prettyOutputFor(detail.id, outputJson);
|
||||
final errorReport = _buildErrorReport(detail: detail, prettyOutput: prettyOutput);
|
||||
|
||||
return ListView(
|
||||
children: [
|
||||
_TraceMetaCard(detail: detail, formatDateTime: _formatDateTime),
|
||||
const SizedBox(height: 8),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () => _copyText(errorReport, 'Felrapport'),
|
||||
icon: const Icon(Icons.bug_report_outlined),
|
||||
label: const Text('Kopiera felrapport'),
|
||||
),
|
||||
),
|
||||
if (detail.warnings.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
_WarningsCard(
|
||||
warnings: detail.warnings,
|
||||
onCopyWarning: (warning) => _copyText(warning, 'Varning'),
|
||||
onCopyAll: () => _copyText(detail.warnings.join('\n'), 'Varningar'),
|
||||
onCopyWarning: (warning) =>
|
||||
_copyText(_formatWarningLine(warning), 'Varning'),
|
||||
onCopyAll: () => _copyText(
|
||||
detail.warnings.map(_formatWarningLine).join('\n'),
|
||||
'Varningar',
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
@@ -583,8 +632,8 @@ class _OutputJsonCardState extends State<_OutputJsonCard> {
|
||||
}
|
||||
|
||||
class _WarningsCard extends StatelessWidget {
|
||||
final List<String> warnings;
|
||||
final void Function(String warning) onCopyWarning;
|
||||
final List<AdminAiWarning> warnings;
|
||||
final void Function(AdminAiWarning warning) onCopyWarning;
|
||||
final VoidCallback onCopyAll;
|
||||
|
||||
const _WarningsCard({
|
||||
@@ -622,8 +671,27 @@ class _WarningsCard extends StatelessWidget {
|
||||
(warning) => ListTile(
|
||||
dense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.warning_amber_rounded, size: 18),
|
||||
title: SelectableText(warning),
|
||||
leading: Icon(_severityIcon(warning), size: 18, color: _severityColor(warning, theme)),
|
||||
title: Text(
|
||||
warning.title,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(warning.message),
|
||||
if ((warning.location ?? '').trim().isNotEmpty)
|
||||
Text(
|
||||
warning.location!,
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
if (warning.itemIndex != null)
|
||||
Text(
|
||||
'Rad: ${warning.itemIndex}',
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: IconButton(
|
||||
tooltip: 'Kopiera varning',
|
||||
onPressed: () => onCopyWarning(warning),
|
||||
@@ -636,4 +704,26 @@ class _WarningsCard extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
IconData _severityIcon(AdminAiWarning warning) {
|
||||
switch (warning.severity) {
|
||||
case 'error':
|
||||
return Icons.error_outline;
|
||||
case 'warning':
|
||||
return Icons.warning_amber_rounded;
|
||||
default:
|
||||
return Icons.info_outline;
|
||||
}
|
||||
}
|
||||
|
||||
Color _severityColor(AdminAiWarning warning, ThemeData theme) {
|
||||
switch (warning.severity) {
|
||||
case 'error':
|
||||
return theme.colorScheme.error;
|
||||
case 'warning':
|
||||
return Colors.orange.shade700;
|
||||
default:
|
||||
return theme.colorScheme.primary;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -86,7 +86,28 @@ void main() {
|
||||
durationMs: 1880,
|
||||
retryCount: 1,
|
||||
chunkCount: 3,
|
||||
warnings: const ['parse:low_confidence', 'match:no_match'],
|
||||
warnings: const [
|
||||
AdminAiWarning(
|
||||
code: 'low_confidence',
|
||||
kind: 'parse',
|
||||
title: 'Låg parsningskvalitet',
|
||||
message: 'Modellens säkerhet är låg, granska raden manuellt.',
|
||||
severity: 'warning',
|
||||
location: 'Steg: AI-parser',
|
||||
itemIndex: 5,
|
||||
),
|
||||
AdminAiWarning(
|
||||
code: 'no_match',
|
||||
kind: 'match',
|
||||
title: 'Ingen produktmatchning',
|
||||
message:
|
||||
'Vi kunde inte hitta någon befintlig produkt som matchar texten på flyern.',
|
||||
severity: 'warning',
|
||||
location: 'Steg: matchning mot dina produkter',
|
||||
itemIndex: 7,
|
||||
),
|
||||
],
|
||||
legacyWarnings: const ['parse:low_confidence', 'match:no_match'],
|
||||
error: null,
|
||||
prompt: 'Prompttext exempel',
|
||||
rawOutput: veryLargeOutput,
|
||||
@@ -198,8 +219,8 @@ void main() {
|
||||
|
||||
expect(find.text('Sammanfattning'), findsOneWidget);
|
||||
expect(find.text('Varningar (2)'), findsOneWidget);
|
||||
expect(find.text('parse:low_confidence'), findsOneWidget);
|
||||
expect(find.text('match:no_match'), findsOneWidget);
|
||||
expect(find.text('Låg parsningskvalitet'), findsOneWidget);
|
||||
expect(find.text('Ingen produktmatchning'), findsOneWidget);
|
||||
final detailScroll = find.byType(Scrollable).last;
|
||||
await tester.scrollUntilVisible(
|
||||
find.text('Model Output'),
|
||||
@@ -219,9 +240,11 @@ void main() {
|
||||
final copyPrompt = find.byTooltip('Kopiera');
|
||||
final copyOutput = find.byTooltip('Kopiera JSON');
|
||||
final copyWarnings = find.byTooltip('Kopiera alla varningar');
|
||||
final copyErrorReport = find.text('Kopiera felrapport');
|
||||
expect(copyPrompt, findsOneWidget);
|
||||
expect(copyOutput, findsOneWidget);
|
||||
expect(copyWarnings, findsOneWidget);
|
||||
expect(copyErrorReport, findsOneWidget);
|
||||
|
||||
await tester.tap(copyPrompt);
|
||||
await tester.pumpAndSettle();
|
||||
@@ -235,6 +258,10 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.takeException(), isNull);
|
||||
|
||||
await tester.tap(copyErrorReport);
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.takeException(), isNull);
|
||||
|
||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user