refactor(ai): enhance AI trace integration and OCR normalization
- Add FlyerTraceSupplement type for AI trace metadata - Implement getFlyerTraceSupplements method to fetch trace supplements - Update AiTraceService to include prompt/rawOutput and counters in flyer traces - Add persistFlyerTrace method to FlyerImportService for trace persistence - Enhance AiFlyerParserService to return structured trace data with prompts and retries - Update FlyerNormalizerService with OCR typo fixes for cheese variants and spröd bakad firre - Improve Flutter admin panel with selectable text, warnings display, and tooltips - Add comprehensive tests for AI trace supplements and normalization rules
This commit is contained in:
@@ -300,10 +300,13 @@ class _AdminAiPanelState extends ConsumerState<AdminAiPanel> {
|
||||
title: Text(item.fileName ?? item.id),
|
||||
subtitle: Text(
|
||||
'${_formatDateTime(item.createdAt)} • ${item.userLabel}'),
|
||||
trailing: Chip(
|
||||
label: Text(item.status.label),
|
||||
labelStyle: TextStyle(
|
||||
color: _statusColor(item.status, theme.colorScheme)),
|
||||
trailing: Tooltip(
|
||||
message: _statusTooltipText(item),
|
||||
child: Chip(
|
||||
label: Text(item.status.label),
|
||||
labelStyle: TextStyle(
|
||||
color: _statusColor(item.status, theme.colorScheme)),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -349,6 +352,14 @@ class _AdminAiPanelState extends ConsumerState<AdminAiPanel> {
|
||||
return ListView(
|
||||
children: [
|
||||
_TraceMetaCard(detail: detail, formatDateTime: _formatDateTime),
|
||||
if (detail.warnings.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
_WarningsCard(
|
||||
warnings: detail.warnings,
|
||||
onCopyWarning: (warning) => _copyText(warning, 'Varning'),
|
||||
onCopyAll: () => _copyText(detail.warnings.join('\n'), 'Varningar'),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
_PromptCard(
|
||||
prompt: prompt,
|
||||
@@ -375,6 +386,20 @@ class _AdminAiPanelState extends ConsumerState<AdminAiPanel> {
|
||||
_cachedOutputPrettyJson = next;
|
||||
return next;
|
||||
}
|
||||
|
||||
String _statusTooltipText(AdminAiTraceListItem item) {
|
||||
final parts = <String>[];
|
||||
if (item.status == AdminAiTraceStatus.warning && item.warningsCount > 0) {
|
||||
parts.add('${item.warningsCount} varning(ar). Välj raden för detaljer och kopiering.');
|
||||
}
|
||||
if (item.error != null && item.error!.trim().isNotEmpty) {
|
||||
parts.add(item.error!.trim());
|
||||
}
|
||||
if (parts.isEmpty) {
|
||||
return 'Inga ytterligare detaljer.';
|
||||
}
|
||||
return parts.join('\n');
|
||||
}
|
||||
}
|
||||
|
||||
class _TraceMetaCard extends StatelessWidget {
|
||||
@@ -466,15 +491,13 @@ class _PromptCard extends StatelessWidget {
|
||||
.withValues(alpha: 0.35),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
hasPrompt
|
||||
? value
|
||||
: 'Prompt är inte tillgänglig i denna fas för vald källa.',
|
||||
maxLines: expanded ? null : 10,
|
||||
overflow:
|
||||
expanded ? TextOverflow.visible : TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodySmall
|
||||
?.copyWith(fontFamily: 'monospace'),
|
||||
child: SelectionArea(
|
||||
child: SelectableText(
|
||||
hasPrompt ? value : 'Prompt saknas för detta spår.',
|
||||
maxLines: expanded ? null : 10,
|
||||
style: theme.textTheme.bodySmall
|
||||
?.copyWith(fontFamily: 'monospace'),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -484,15 +507,27 @@ class _PromptCard extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _OutputJsonCard extends StatelessWidget {
|
||||
class _OutputJsonCard extends StatefulWidget {
|
||||
final String jsonText;
|
||||
final VoidCallback onCopy;
|
||||
|
||||
const _OutputJsonCard({required this.jsonText, required this.onCopy});
|
||||
|
||||
@override
|
||||
State<_OutputJsonCard> createState() => _OutputJsonCardState();
|
||||
}
|
||||
|
||||
class _OutputJsonCardState extends State<_OutputJsonCard> {
|
||||
static const int _maxPreviewChars = 12000;
|
||||
bool _expanded = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final shouldTruncate = widget.jsonText.length > _maxPreviewChars;
|
||||
final visibleText =
|
||||
!_expanded && shouldTruncate ? '${widget.jsonText.substring(0, _maxPreviewChars)}\n…' : widget.jsonText;
|
||||
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
@@ -506,11 +541,20 @@ class _OutputJsonCard extends StatelessWidget {
|
||||
style: theme.textTheme.titleMedium)),
|
||||
IconButton(
|
||||
tooltip: 'Kopiera JSON',
|
||||
onPressed: onCopy,
|
||||
onPressed: widget.onCopy,
|
||||
icon: const Icon(Icons.copy_all),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (shouldTruncate)
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton.icon(
|
||||
onPressed: () => setState(() => _expanded = !_expanded),
|
||||
icon: Icon(_expanded ? Icons.unfold_less : Icons.unfold_more),
|
||||
label: Text(_expanded ? 'Visa mindre' : 'Visa hela outputen'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
@@ -520,12 +564,70 @@ class _OutputJsonCard extends StatelessWidget {
|
||||
.withValues(alpha: 0.35),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Text(
|
||||
jsonText,
|
||||
style: theme.textTheme.bodySmall
|
||||
?.copyWith(fontFamily: 'monospace'),
|
||||
child: SelectionArea(
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: SelectableText(
|
||||
visibleText,
|
||||
style: theme.textTheme.bodySmall
|
||||
?.copyWith(fontFamily: 'monospace'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _WarningsCard extends StatelessWidget {
|
||||
final List<String> warnings;
|
||||
final void Function(String warning) onCopyWarning;
|
||||
final VoidCallback onCopyAll;
|
||||
|
||||
const _WarningsCard({
|
||||
required this.warnings,
|
||||
required this.onCopyWarning,
|
||||
required this.onCopyAll,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Varningar (${warnings.length})',
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Kopiera alla varningar',
|
||||
onPressed: onCopyAll,
|
||||
icon: const Icon(Icons.copy_all),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
...warnings.map(
|
||||
(warning) => ListTile(
|
||||
dense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.warning_amber_rounded, size: 18),
|
||||
title: SelectableText(warning),
|
||||
trailing: IconButton(
|
||||
tooltip: 'Kopiera varning',
|
||||
onPressed: () => onCopyWarning(warning),
|
||||
icon: const Icon(Icons.copy, size: 18),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user