feat(ai): add AI trace tracking and admin panel
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 12m45s
Test Suite / flutter-quality (push) Failing after 7m24s

- Add AiTrace model to Prisma schema with relations to User
- Implement AiTraceService with CRUD operations for AI traces
- Add new admin panel for AI traces with filtering and detail views
- Integrate trace persistence in receipt import flow
- Add API endpoints for listing and retrieving AI traces
- Update Flutter admin UI with new AI tab and navigation
- Add new domain models for AI traces and details
- Add migration for AiTrace table creation

BREAKING CHANGE: None
This commit is contained in:
Nils-Johan Gynther
2026-05-21 17:33:21 +02:00
parent c3520b5ad4
commit 67a7590525
21 changed files with 2477 additions and 509 deletions
@@ -1,139 +1,537 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/api/api_error_mapper.dart';
import '../../../core/l10n/l10n.dart';
import '../data/admin_repository.dart';
import '../domain/ai_model_info.dart';
class AdminAiPanel extends ConsumerStatefulWidget {
final bool embedded;
const AdminAiPanel({super.key, this.embedded = false});
@override
ConsumerState<AdminAiPanel> createState() => _AdminAiPanelState();
}
class _AdminAiPanelState extends ConsumerState<AdminAiPanel> {
bool _isLoading = true;
String? _error;
List<AiModelInfo> _models = [];
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
setState(() {
_isLoading = true;
_error = null;
});
try {
final models = await ref.read(adminRepositoryProvider).listAiModels();
if (!mounted) return;
setState(() => _models = models);
} catch (e) {
if (!mounted) return;
setState(() => _error = mapErrorToUserMessage(e, context));
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
Color _chipColor(String value, ColorScheme scheme) {
final lower = value.toLowerCase();
if (lower.contains('admin')) return scheme.primaryContainer;
if (lower.contains('premium')) return scheme.tertiaryContainer;
return scheme.secondaryContainer;
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
if (_isLoading) return const Center(child: CircularProgressIndicator());
if (_error != null) {
return buildCopyableErrorPanel(
context: context,
message: _error!,
onRetry: _load,
title: 'Kunde inte läsa AI-modeller',
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('AI', style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
Text(
context.l10n.adminAiDescription,
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 8),
const Wrap(
spacing: 8,
runSpacing: 8,
children: [
Chip(label: Text('Models')),
Chip(label: Text('Access')),
Chip(label: Text('Trigger')),
],
),
],
),
),
),
const SizedBox(height: 12),
if (_models.isEmpty)
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Text(
'Inga AI-modeller hittades.',
style: theme.textTheme.bodyMedium,
),
),
),
..._models.map(
(model) => Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(model.name, style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
Text(model.description),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
Chip(label: Text(model.model)),
Chip(
label: Text(model.access),
backgroundColor: _chipColor(model.access, theme.colorScheme),
),
Chip(label: Text(model.trigger)),
],
),
const SizedBox(height: 8),
Text('${context.l10n.adminPagePrefix}${model.path}', style: theme.textTheme.bodySmall),
],
),
),
),
),
],
);
}
}
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/api/api_error_mapper.dart';
import '../data/admin_repository.dart';
import '../domain/admin_ai_trace.dart';
import '../domain/admin_ai_trace_detail.dart';
class AdminAiPanel extends ConsumerStatefulWidget {
final bool embedded;
const AdminAiPanel({super.key, this.embedded = false});
@override
ConsumerState<AdminAiPanel> createState() => _AdminAiPanelState();
}
class _AdminAiPanelState extends ConsumerState<AdminAiPanel> {
bool _isLoading = true;
String? _error;
AdminAiTraceSource _source = AdminAiTraceSource.flyer;
String _period = '7d';
bool _onlyErrors = false;
List<AdminAiTraceListItem> _items = const [];
String? _nextCursor;
String? _selectedId;
AdminAiTraceDetail? _selected;
bool _isDetailLoading = false;
bool _promptExpanded = false;
String? _cachedOutputTraceId;
String? _cachedOutputPrettyJson;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
setState(() {
_isLoading = true;
_error = null;
});
try {
final response = await ref.read(adminRepositoryProvider).listAiTraces(
source: _source,
limit: 30,
period: _period,
onlyErrors: _onlyErrors,
);
if (!mounted) return;
final selectedId =
response.items.isEmpty ? null : response.items.first.id;
setState(() {
_items = response.items;
_nextCursor = response.nextCursor;
_selectedId = selectedId;
_selected = null;
_promptExpanded = false;
_cachedOutputTraceId = null;
_cachedOutputPrettyJson = null;
});
if (selectedId != null) {
await _loadDetail(selectedId);
}
} catch (e) {
if (!mounted) return;
setState(() => _error = mapErrorToUserMessage(e, context));
} finally {
if (mounted) {
setState(() => _isLoading = false);
}
}
}
Future<void> _loadMore() async {
if (_nextCursor == null || _nextCursor!.isEmpty) return;
try {
final response = await ref.read(adminRepositoryProvider).listAiTraces(
source: _source,
limit: 30,
cursor: _nextCursor,
period: _period,
onlyErrors: _onlyErrors,
);
if (!mounted) return;
setState(() {
_items = [..._items, ...response.items];
_nextCursor = response.nextCursor;
});
} catch (_) {
// Ignore soft pagination failures.
}
}
Future<void> _loadDetail(String id) async {
setState(() {
_isDetailLoading = true;
_selected = null;
_cachedOutputTraceId = null;
_cachedOutputPrettyJson = null;
});
try {
final detail = await ref.read(adminRepositoryProvider).getAiTraceById(id);
if (!mounted) return;
setState(() => _selected = detail);
} catch (_) {
if (!mounted) return;
setState(() => _selected = null);
} finally {
if (mounted) {
setState(() => _isDetailLoading = false);
}
}
}
String _formatDateTime(DateTime value) {
final local = value.toLocal();
String two(int n) => n.toString().padLeft(2, '0');
return '${local.year}-${two(local.month)}-${two(local.day)} ${two(local.hour)}:${two(local.minute)}';
}
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')));
}
String _prettyJson(Object? data) {
if (data == null) return '{}';
return const JsonEncoder.withIndent(' ').convert(data);
}
Color _statusColor(AdminAiTraceStatus status, ColorScheme scheme) {
return switch (status) {
AdminAiTraceStatus.success => Colors.green.shade700,
AdminAiTraceStatus.warning => Colors.orange.shade700,
AdminAiTraceStatus.error => scheme.error,
};
}
@override
Widget build(BuildContext context) {
if (_isLoading) return const Center(child: CircularProgressIndicator());
if (_error != null) {
return buildCopyableErrorPanel(
context: context,
message: _error!,
onRetry: _load,
title: 'Kunde inte läsa AI-spårning',
);
}
final content = LayoutBuilder(
builder: (context, constraints) {
final isWide = constraints.maxWidth >= 980;
final listPane = _buildTraceList();
final detailPane = _buildTraceDetail();
if (isWide) {
return Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(flex: 2, child: listPane),
const SizedBox(width: 12),
Expanded(flex: 3, child: detailPane),
],
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(height: 260, child: listPane),
const SizedBox(height: 12),
Expanded(child: detailPane),
],
);
},
);
return Padding(
padding: widget.embedded ? EdgeInsets.zero : const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildTopFilters(),
const SizedBox(height: 12),
Expanded(child: content),
],
),
);
}
Widget _buildTopFilters() {
return Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Wrap(
spacing: 8,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
ChoiceChip(
label: const Text('Kvitto'),
selected: _source == AdminAiTraceSource.receipt,
onSelected: (_) {
setState(() => _source = AdminAiTraceSource.receipt);
_load();
},
),
ChoiceChip(
label: const Text('Flyer'),
selected: _source == AdminAiTraceSource.flyer,
onSelected: (_) {
setState(() => _source = AdminAiTraceSource.flyer);
_load();
},
),
const SizedBox(width: 8),
FilterChip(
label: const Text('24h'),
selected: _period == '24h',
onSelected: (_) {
setState(() => _period = '24h');
_load();
},
),
FilterChip(
label: const Text('7d'),
selected: _period == '7d',
onSelected: (_) {
setState(() => _period = '7d');
_load();
},
),
FilterChip(
label: const Text('30d'),
selected: _period == '30d',
onSelected: (_) {
setState(() => _period = '30d');
_load();
},
),
FilterChip(
label: const Text('Endast fel'),
selected: _onlyErrors,
onSelected: (value) {
setState(() => _onlyErrors = value);
_load();
},
),
],
),
),
);
}
Widget _buildTraceList() {
final theme = Theme.of(context);
if (_items.isEmpty) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Text(
_source == AdminAiTraceSource.receipt
? 'Receipt trace-data saknas i recipe-api i denna fas.'
: 'Inga importer matchar valda filter.',
style: theme.textTheme.bodyMedium,
),
),
);
}
return Card(
child: Column(
children: [
Expanded(
child: ListView.separated(
itemCount: _items.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (context, index) {
final item = _items[index];
final selected = item.id == _selectedId;
return ListTile(
selected: selected,
onTap: () {
setState(() {
_selectedId = item.id;
_promptExpanded = false;
});
_loadDetail(item.id);
},
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)),
),
);
},
),
),
if (_nextCursor != null && _nextCursor!.isNotEmpty)
Padding(
padding: const EdgeInsets.all(8),
child: OutlinedButton.icon(
onPressed: _loadMore,
icon: const Icon(Icons.expand_more),
label: const Text('Ladda fler'),
),
),
],
),
);
}
Widget _buildTraceDetail() {
if (_isDetailLoading) {
return const Card(child: Center(child: CircularProgressIndicator()));
}
final detail = _selected;
if (detail == null) {
return const Card(
child: Center(
child: Padding(
padding: EdgeInsets.all(16),
child: Text('Välj en import för detaljer.'),
),
),
);
}
final prompt = detail.prompt;
final outputJson = detail.normalizedOutput ??
(detail.rawOutput == null
? const <String, dynamic>{}
: {'rawOutput': detail.rawOutput});
final prettyOutput = _prettyOutputFor(detail.id, outputJson);
return ListView(
children: [
_TraceMetaCard(detail: detail, formatDateTime: _formatDateTime),
const SizedBox(height: 12),
_PromptCard(
prompt: prompt,
expanded: _promptExpanded,
onToggleExpand: () =>
setState(() => _promptExpanded = !_promptExpanded),
onCopy: prompt == null ? null : () => _copyText(prompt, 'Prompt'),
),
const SizedBox(height: 12),
_OutputJsonCard(
jsonText: prettyOutput,
onCopy: () => _copyText(prettyOutput, 'Output JSON'),
),
],
);
}
String _prettyOutputFor(String traceId, Object? outputJson) {
if (_cachedOutputTraceId == traceId && _cachedOutputPrettyJson != null) {
return _cachedOutputPrettyJson!;
}
final next = _prettyJson(outputJson);
_cachedOutputTraceId = traceId;
_cachedOutputPrettyJson = next;
return next;
}
}
class _TraceMetaCard extends StatelessWidget {
final AdminAiTraceDetail detail;
final String Function(DateTime value) formatDateTime;
const _TraceMetaCard({required this.detail, required this.formatDateTime});
@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: [
Text('Sammanfattning', style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
Text('Källa: ${detail.source.label}'),
Text('Tid: ${formatDateTime(detail.createdAt)}'),
Text('Användare: ${detail.userLabel}'),
Text('Status: ${detail.status.label}'),
Text('Modell: ${detail.model ?? 'okänd'}'),
if (detail.durationMs != null)
Text('Duration: ${detail.durationMs} ms'),
if (detail.chunkCount != null) Text('Chunks: ${detail.chunkCount}'),
if (detail.retryCount != null)
Text('Retries: ${detail.retryCount}'),
if (detail.warnings.isNotEmpty)
Text('Warnings: ${detail.warnings.length}'),
if (detail.error != null && detail.error!.isNotEmpty)
Text('Fel: ${detail.error}',
style: TextStyle(color: theme.colorScheme.error)),
],
),
),
);
}
}
class _PromptCard extends StatelessWidget {
final String? prompt;
final bool expanded;
final VoidCallback onToggleExpand;
final VoidCallback? onCopy;
const _PromptCard({
required this.prompt,
required this.expanded,
required this.onToggleExpand,
required this.onCopy,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final value = (prompt ?? '').trim();
final hasPrompt = value.isNotEmpty;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text('Prompt', style: theme.textTheme.titleMedium)),
IconButton(
tooltip: 'Expandera/kollapsa',
onPressed: hasPrompt ? onToggleExpand : null,
icon: Icon(expanded ? Icons.unfold_less : Icons.unfold_more),
),
IconButton(
tooltip: 'Kopiera',
onPressed: hasPrompt ? onCopy : null,
icon: const Icon(Icons.copy_all),
),
],
),
const SizedBox(height: 8),
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest
.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'),
),
),
],
),
),
);
}
}
class _OutputJsonCard extends StatelessWidget {
final String jsonText;
final VoidCallback onCopy;
const _OutputJsonCard({required this.jsonText, required this.onCopy});
@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('Model Output',
style: theme.textTheme.titleMedium)),
IconButton(
tooltip: 'Kopiera JSON',
onPressed: onCopy,
icon: const Icon(Icons.copy_all),
),
],
),
const SizedBox(height: 8),
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest
.withValues(alpha: 0.35),
borderRadius: BorderRadius.circular(8),
),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Text(
jsonText,
style: theme.textTheme.bodySmall
?.copyWith(fontFamily: 'monospace'),
),
),
),
],
),
),
);
}
}
@@ -5,7 +5,6 @@ import 'dart:async';
import '../../../core/api/api_error_mapper.dart';
import '../../../core/l10n/l10n.dart';
import '../../../core/realtime/realtime_sync.dart';
import 'admin_ai_panel.dart';
import 'admin_aliases_panel.dart';
import 'admin_inventory_panel.dart';
import 'admin_pantry_panel.dart';
@@ -14,7 +13,14 @@ import 'admin_pending_products_panel.dart';
import 'admin_products_panel.dart';
import '../../profile/data/profile_repository.dart';
enum _DatabaseTab { inventory, pantry, products, privateProducts, pending, aliases, ai }
enum _DatabaseTab {
inventory,
pantry,
products,
privateProducts,
pending,
aliases
}
class _DatabaseTabConfig {
final _DatabaseTab tab;
@@ -98,11 +104,6 @@ class _AdminDatabasePanelState extends ConsumerState<AdminDatabasePanel> {
title: 'Alias',
panel: const AdminAliasesPanel(embedded: true),
),
_DatabaseTabConfig(
tab: _DatabaseTab.ai,
title: 'AI',
panel: const AdminAiPanel(embedded: true),
),
];
Future<void> _refreshCategories() async {
@@ -125,7 +126,8 @@ class _AdminDatabasePanelState extends ConsumerState<AdminDatabasePanel> {
@override
Widget build(BuildContext context) {
final currentTab = _tabConfigs.firstWhere((config) => config.tab == _activeTab);
final currentTab =
_tabConfigs.firstWhere((config) => config.tab == _activeTab);
final header = Card(
child: Padding(
@@ -146,7 +148,8 @@ class _AdminDatabasePanelState extends ConsumerState<AdminDatabasePanel> {
child: ChoiceChip(
label: Text(config.title),
selected: _activeTab == config.tab,
onSelected: (_) => setState(() => _activeTab = config.tab),
onSelected: (_) =>
setState(() => _activeTab = config.tab),
),
),
)
@@ -157,7 +160,8 @@ class _AdminDatabasePanelState extends ConsumerState<AdminDatabasePanel> {
const SizedBox(width: 8),
IconButton(
tooltip: 'Uppdatera kategorier',
onPressed: _isRefreshingCategories ? null : _refreshCategories,
onPressed:
_isRefreshingCategories ? null : _refreshCategories,
icon: _isRefreshingCategories
? const SizedBox(
height: 16,
@@ -183,7 +187,8 @@ class _AdminDatabasePanelState extends ConsumerState<AdminDatabasePanel> {
const SizedBox(height: 12),
Expanded(
child: KeyedSubtree(
key: ValueKey('admin-db-${_activeTab.name}-$_panelRefreshVersion'),
key:
ValueKey('admin-db-${_activeTab.name}-$_panelRefreshVersion'),
child: currentTab.panel,
),
),
@@ -192,4 +197,3 @@ class _AdminDatabasePanelState extends ConsumerState<AdminDatabasePanel> {
);
}
}
@@ -1,36 +1,42 @@
import 'package:flutter/material.dart';
import 'admin_database_panel.dart';
import 'admin_users_panel.dart';
enum AdminViewTab { users, database }
extension AdminViewTabX on AdminViewTab {
static AdminViewTab fromQuery(String? value) {
return switch (value) {
'database' => AdminViewTab.database,
_ => AdminViewTab.users,
};
}
String get queryValue => this == AdminViewTab.database ? 'database' : 'users';
}
class AdminScreen extends StatelessWidget {
final AdminViewTab initialTab;
const AdminScreen({super.key, this.initialTab = AdminViewTab.users});
@override
Widget build(BuildContext context) {
final activePanel = switch (initialTab) {
AdminViewTab.users => const AdminUsersPanel(embedded: true),
AdminViewTab.database => const AdminDatabasePanel(embedded: true),
};
return Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 8),
child: activePanel,
);
}
}
import 'package:flutter/material.dart';
import 'admin_ai_panel.dart';
import 'admin_database_panel.dart';
import 'admin_users_panel.dart';
enum AdminViewTab { users, database, ai }
extension AdminViewTabX on AdminViewTab {
static AdminViewTab fromQuery(String? value) {
return switch (value) {
'database' => AdminViewTab.database,
'ai' => AdminViewTab.ai,
_ => AdminViewTab.users,
};
}
String get queryValue => switch (this) {
AdminViewTab.users => 'users',
AdminViewTab.database => 'database',
AdminViewTab.ai => 'ai',
};
}
class AdminScreen extends StatelessWidget {
final AdminViewTab initialTab;
const AdminScreen({super.key, this.initialTab = AdminViewTab.users});
@override
Widget build(BuildContext context) {
final activePanel = switch (initialTab) {
AdminViewTab.users => const AdminUsersPanel(embedded: true),
AdminViewTab.database => const AdminDatabasePanel(embedded: true),
AdminViewTab.ai => const AdminAiPanel(embedded: true),
};
return Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 8),
child: activePanel,
);
}
}