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
+72 -39
View File
@@ -2,7 +2,7 @@ class AuthApiPaths {
static const login = '/auth/login';
}
class ProductApiPaths {
class ProductApiPaths {
static const list = '/products';
static const mine = '/products/mine';
static const createPrivate = '/products/private';
@@ -13,14 +13,15 @@ class ProductApiPaths {
static const deleted = '/products/deleted';
static const merge = '/products/merge';
static const mergePrivate = '/products/private/merge';
static String updateMineCategory(int id) => '/products/mine/$id/category';
static const backfillMineCategories = '/products/mine/backfill-categories';
static String updateMineCategory(int id) => '/products/mine/$id/category';
static const backfillMineCategories = '/products/mine/backfill-categories';
static String mergePreview(int sourceProductId, int targetProductId) =>
'/products/merge-preview?sourceProductId=$sourceProductId&targetProductId=$targetProductId';
static String setStatus(int id) => '/products/$id/status';
static String update(int id) => '/products/$id';
static String canonicalName(int id) => '/products/$id/canonical-name';
static String canonicalNamePrivate(int id) => '/products/private/$id/canonical-name';
static String canonicalNamePrivate(int id) =>
'/products/private/$id/canonical-name';
static String remove(int id) => '/products/$id';
static String restore(int id) => '/products/$id/restore';
static const bulkUpdate = '/products/bulk-update';
@@ -28,37 +29,63 @@ class ProductApiPaths {
class AiApiPaths {
static const models = '/ai/models';
static String traces({
required String source,
int? limit,
String? cursor,
String? period,
bool onlyErrors = false,
}) {
final params = <String, String>{'source': source};
if (limit != null) params['limit'] = '$limit';
if (cursor != null && cursor.isNotEmpty) params['cursor'] = cursor;
if (period != null && period.isNotEmpty) params['period'] = period;
if (onlyErrors) params['onlyErrors'] = 'true';
final query = params.entries
.map((e) =>
'${Uri.encodeQueryComponent(e.key)}=${Uri.encodeQueryComponent(e.value)}')
.join('&');
return '/ai/traces?$query';
}
static String traceById(String traceId) =>
'/ai/traces/${Uri.encodeComponent(traceId)}';
}
class CategoryApiPaths {
static const tree = '/categories/tree';
}
class ReceiptImportApiPaths {
static const refreshCategories = '/receipt-import/refresh-categories';
static const unitMappings = '/receipt-import/unit-mappings';
}
class FlyerImportApiPaths {
static const parse = '/flyer-import/parse';
static const latestSession = '/flyer-import/sessions/latest';
static String bySession(int sessionId) => '/flyer-import/sessions/$sessionId';
static String sourceBySession(int sessionId) => '/flyer-import/sessions/$sessionId/source';
static String patchItem(int sessionId, int itemId) => '/flyer-import/sessions/$sessionId/items/$itemId';
}
class FlyerSelectionApiPaths {
static String bySession(int sessionId) => '/flyer-sessions/$sessionId/selections';
static String bulkBySession(int sessionId) => '/flyer-sessions/$sessionId/selections/bulk';
static String planToShoppingListBySession(int sessionId) =>
'/flyer-sessions/$sessionId/selections/plan-to-shopping-list';
static const open = '/flyer-selections/open';
}
class ShoppingListApiPaths {
static const items = '/shopping-list/items';
static String updateStatus(int itemId) => '/shopping-list/items/$itemId/status';
}
class ReceiptImportApiPaths {
static const refreshCategories = '/receipt-import/refresh-categories';
static const unitMappings = '/receipt-import/unit-mappings';
}
class FlyerImportApiPaths {
static const parse = '/flyer-import/parse';
static const latestSession = '/flyer-import/sessions/latest';
static String bySession(int sessionId) => '/flyer-import/sessions/$sessionId';
static String sourceBySession(int sessionId) =>
'/flyer-import/sessions/$sessionId/source';
static String patchItem(int sessionId, int itemId) =>
'/flyer-import/sessions/$sessionId/items/$itemId';
}
class FlyerSelectionApiPaths {
static String bySession(int sessionId) =>
'/flyer-sessions/$sessionId/selections';
static String bulkBySession(int sessionId) =>
'/flyer-sessions/$sessionId/selections/bulk';
static String planToShoppingListBySession(int sessionId) =>
'/flyer-sessions/$sessionId/selections/plan-to-shopping-list';
static const open = '/flyer-selections/open';
}
class ShoppingListApiPaths {
static const items = '/shopping-list/items';
static String updateStatus(int itemId) =>
'/shopping-list/items/$itemId/status';
}
class HelpTextApiPaths {
static String byKey(String key) => '/help-texts/${Uri.encodeComponent(key)}';
@@ -77,7 +104,8 @@ class RecipeApiPaths {
static String remove(int id) => '/recipes/$id';
static String setVisibility(int id) => '/recipes/$id/visibility';
static String share(int id) => '/recipes/$id/share';
static String unshare(int id, String username) => '/recipes/$id/share/${Uri.encodeComponent(username)}';
static String unshare(int id, String username) =>
'/recipes/$id/share/${Uri.encodeComponent(username)}';
static String inventoryPreview(int id) => '/recipes/$id/inventory-preview';
static String analysis(int id) => '/recipes/$id/analysis';
static String rematch(int id) => '/recipes/$id/rematch';
@@ -92,9 +120,11 @@ class InventoryApiPaths {
static String update(int id) => '/inventory/$id';
static String remove(int id) => '/inventory/$id';
static String moveToPantry(int id) => '/inventory/$id/move-to-pantry';
static String moveToPantryAdmin(int id) => '/inventory/admin/$id/move-to-pantry';
static String moveToPantryAdmin(int id) =>
'/inventory/admin/$id/move-to-pantry';
static String consume(int id) => '/inventory/$id/consume';
static String consumptionHistory(int id) => '/inventory/$id/consumption-history';
static String consumptionHistory(int id) =>
'/inventory/$id/consumption-history';
}
class AdminInventoryApiPaths {
@@ -105,10 +135,12 @@ class AdminInventoryApiPaths {
if (sort != null && sort.isNotEmpty) params['sort'] = sort;
if (params.isEmpty) return list;
final query = params.entries
.map((e) => '${Uri.encodeQueryComponent(e.key)}=${Uri.encodeQueryComponent(e.value)}')
.map((e) =>
'${Uri.encodeQueryComponent(e.key)}=${Uri.encodeQueryComponent(e.value)}')
.join('&');
return '$list?$query';
}
static String update(int id) => '/inventory/admin/$id';
static String remove(int id) => '/inventory/admin/$id';
static String moveToPantry(int id) => '/inventory/admin/$id/move-to-pantry';
@@ -121,7 +153,8 @@ class PantryApiPaths {
static const list = '/pantry';
static String remove(int id) => '/pantry/$id';
static String moveToInventory(int id) => '/pantry/$id/move-to-inventory';
static String moveToInventoryAdmin(int id) => '/pantry/admin/$id/move-to-inventory';
static String moveToInventoryAdmin(int id) =>
'/pantry/admin/$id/move-to-inventory';
static const adminList = '/pantry/admin';
static const adminCreate = '/pantry/admin';
static String adminUpdate(int id) => '/pantry/admin/$id';
@@ -143,14 +176,14 @@ class MealPlanApiPaths {
static const list = '/meal-plan';
static String listByRange(String from, String to) =>
'$list?from=${Uri.encodeQueryComponent(from)}&to=${Uri.encodeQueryComponent(to)}';
'$list?from=${Uri.encodeQueryComponent(from)}&to=${Uri.encodeQueryComponent(to)}';
static String shoppingList(String from, String to) =>
'$list/shopping-list?from=${Uri.encodeQueryComponent(from)}&to=${Uri.encodeQueryComponent(to)}';
'$list/shopping-list?from=${Uri.encodeQueryComponent(from)}&to=${Uri.encodeQueryComponent(to)}';
static String inventoryCompare(String from, String to) =>
'$list/inventory-compare?from=${Uri.encodeQueryComponent(from)}&to=${Uri.encodeQueryComponent(to)}';
'$list/inventory-compare?from=${Uri.encodeQueryComponent(from)}&to=${Uri.encodeQueryComponent(to)}';
static String removeByDate(String date) =>
'$list/${Uri.encodeComponent(date)}';
}
'$list/${Uri.encodeComponent(date)}';
}
+40 -32
View File
@@ -49,19 +49,19 @@ class AppShell extends ConsumerWidget {
icon: Icons.storefront_outlined,
label: 'Baslager',
),
_AppDestination(
path: '/import',
title: 'Importera',
icon: Icons.upload_file_outlined,
label: 'Importera',
),
_AppDestination(
path: '/inkopslista',
title: 'Inköpslista',
icon: Icons.shopping_cart_outlined,
label: 'Inköpslista',
),
];
_AppDestination(
path: '/import',
title: 'Importera',
icon: Icons.upload_file_outlined,
label: 'Importera',
),
_AppDestination(
path: '/inkopslista',
title: 'Inköpslista',
icon: Icons.shopping_cart_outlined,
label: 'Inköpslista',
),
];
List<_AppDestination> _destinations() => _baseDestinations;
@@ -101,8 +101,8 @@ class AppShell extends ConsumerWidget {
}
}
final isRecipesRoute = location.startsWith('/recipes') &&
!location.startsWith('/recipes/');
final isRecipesRoute =
location.startsWith('/recipes') && !location.startsWith('/recipes/');
final isImportRoute = location == '/import';
final isAdminRoute = location.startsWith('/admin');
final adminTab = AdminViewTabX.fromQuery(
@@ -133,6 +133,12 @@ class AppShell extends ConsumerWidget {
selected: adminTab == AdminViewTab.database,
onSelected: (_) => navigateToAdminTab(AdminViewTab.database),
),
const SizedBox(width: 8),
ChoiceChip(
label: const Text('AI'),
selected: adminTab == AdminViewTab.ai,
onSelected: (_) => navigateToAdminTab(AdminViewTab.ai),
),
],
),
);
@@ -140,7 +146,8 @@ class AppShell extends ConsumerWidget {
Widget shell = Scaffold(
appBar: AppBar(
title: isAdminRoute ? buildAdminTitle() : Text(selectedDestination.title),
title:
isAdminRoute ? buildAdminTitle() : Text(selectedDestination.title),
bottom: isImportRoute
? const TabBar(
tabs: [
@@ -148,17 +155,17 @@ class AppShell extends ConsumerWidget {
icon: Icon(Icons.restaurant_menu_outlined),
text: 'Recept',
),
Tab(
icon: Icon(Icons.receipt_long_outlined),
text: 'Kvitto',
),
Tab(
icon: Icon(Icons.local_offer_outlined),
text: 'Flyer',
),
],
)
: null,
Tab(
icon: Icon(Icons.receipt_long_outlined),
text: 'Kvitto',
),
Tab(
icon: Icon(Icons.local_offer_outlined),
text: 'Flyer',
),
],
)
: null,
actions: [
if (isRecipesRoute)
Consumer(
@@ -184,8 +191,9 @@ class AppShell extends ConsumerWidget {
PopupMenuButton<int>(
icon: const Icon(Icons.grid_view),
tooltip: 'Välj antal kolumner',
onSelected: (columns) =>
ref.read(recipesViewProvider.notifier).setColumns(columns),
onSelected: (columns) => ref
.read(recipesViewProvider.notifier)
.setColumns(columns),
itemBuilder: (context) => const [
PopupMenuItem(value: 2, child: Text('2 kolumner')),
PopupMenuItem(value: 4, child: Text('4 kolumner')),
@@ -288,9 +296,9 @@ class AppShell extends ConsumerWidget {
),
);
if (isImportRoute) {
shell = DefaultTabController(length: 3, child: shell);
}
if (isImportRoute) {
shell = DefaultTabController(length: 3, child: shell);
}
return shell;
}