feat(ai): add AI trace tracking and admin panel
- 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:
@@ -3,6 +3,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:recipe_flutter/features/admin/data/admin_repository.dart';
|
||||
import 'package:recipe_flutter/features/admin/domain/admin_ai_categorize_result.dart';
|
||||
import 'package:recipe_flutter/features/admin/domain/admin_ai_trace.dart';
|
||||
import 'package:recipe_flutter/features/admin/domain/admin_ai_trace_detail.dart';
|
||||
import 'package:recipe_flutter/features/admin/domain/admin_category_node.dart';
|
||||
import 'package:recipe_flutter/features/admin/domain/admin_inventory_item.dart';
|
||||
import 'package:recipe_flutter/features/admin/domain/admin_pantry_item.dart';
|
||||
@@ -20,99 +22,187 @@ class TestAdminRepositoryWrapper implements AdminRepository {
|
||||
TestAdminRepositoryWrapper(this._fakeRepo);
|
||||
|
||||
@override
|
||||
Future<List<ReceiptAlias>> listReceiptAliases() => _fakeRepo.listReceiptAliases();
|
||||
Future<List<ReceiptAlias>> listReceiptAliases() =>
|
||||
_fakeRepo.listReceiptAliases();
|
||||
|
||||
@override
|
||||
Future<List<AdminProduct>> listGlobalProducts() => _fakeRepo.listGlobalProducts();
|
||||
Future<List<AdminProduct>> listGlobalProducts() =>
|
||||
_fakeRepo.listGlobalProducts();
|
||||
|
||||
@override
|
||||
Future<void> updateReceiptAlias(int id, {String? receiptName, int? productId, bool? isGlobal}) => _fakeRepo.updateReceiptAlias(id, receiptName: receiptName, productId: productId, isGlobal: isGlobal);
|
||||
Future<void> updateReceiptAlias(int id,
|
||||
{String? receiptName, int? productId, bool? isGlobal}) =>
|
||||
_fakeRepo.updateReceiptAlias(id,
|
||||
receiptName: receiptName, productId: productId, isGlobal: isGlobal);
|
||||
|
||||
// Stub implementations for other required methods
|
||||
@override
|
||||
Future<List<AdminAiCategorizeResult>> aiCategorizeBulk({List<int>? productIds}) => throw UnimplementedError();
|
||||
Future<List<AdminAiCategorizeResult>> aiCategorizeBulk(
|
||||
{List<int>? productIds}) =>
|
||||
throw UnimplementedError();
|
||||
@override
|
||||
Future<int> bulkSetCategory(List<int> ids, {required int? categoryId}) => throw UnimplementedError();
|
||||
Future<int> bulkSetCategory(List<int> ids, {required int? categoryId}) =>
|
||||
throw UnimplementedError();
|
||||
@override
|
||||
Future<AdminInventoryItem> createAdminInventory({int? userId, required int productId, required double quantity, required String unit, String? location, String? brand, String? receiptName, String? suitableFor, String? comment}) => throw UnimplementedError();
|
||||
Future<AdminInventoryItem> createAdminInventory(
|
||||
{int? userId,
|
||||
required int productId,
|
||||
required double quantity,
|
||||
required String unit,
|
||||
String? location,
|
||||
String? brand,
|
||||
String? receiptName,
|
||||
String? suitableFor,
|
||||
String? comment}) =>
|
||||
throw UnimplementedError();
|
||||
@override
|
||||
Future<AdminPantryItem> createAdminPantry({int? userId, required int productId, String? location}) => throw UnimplementedError();
|
||||
Future<AdminPantryItem> createAdminPantry(
|
||||
{int? userId, required int productId, String? location}) =>
|
||||
throw UnimplementedError();
|
||||
@override
|
||||
Future<Map<String, dynamic>> createProduct(String name, {int? categoryId}) => throw UnimplementedError();
|
||||
Future<Map<String, dynamic>> createProduct(String name, {int? categoryId}) =>
|
||||
throw UnimplementedError();
|
||||
@override
|
||||
Future<UserAdmin> createUser({required String username, required String email, required String password, String role = 'user'}) => throw UnimplementedError();
|
||||
Future<UserAdmin> createUser(
|
||||
{required String username,
|
||||
required String email,
|
||||
required String password,
|
||||
String role = 'user'}) =>
|
||||
throw UnimplementedError();
|
||||
@override
|
||||
Future<void> deleteUser(int userId) => throw UnimplementedError();
|
||||
@override
|
||||
Future<List<AdminInventoryItem>> listAdminInventory({int? userId, String? sort}) => throw UnimplementedError();
|
||||
Future<List<AdminInventoryItem>> listAdminInventory(
|
||||
{int? userId, String? sort}) =>
|
||||
throw UnimplementedError();
|
||||
@override
|
||||
Future<List<AdminPantryItem>> listAdminPantry({int? userId}) => throw UnimplementedError();
|
||||
Future<List<AdminPantryItem>> listAdminPantry({int? userId}) =>
|
||||
throw UnimplementedError();
|
||||
@override
|
||||
Future<List<AiModelInfo>> listAiModels() => throw UnimplementedError();
|
||||
@override
|
||||
Future<List<AdminCategoryNode>> listCategoryTree() => throw UnimplementedError();
|
||||
Future<AdminAiTraceDetail> getAiTraceById(String traceId) =>
|
||||
throw UnimplementedError();
|
||||
@override
|
||||
Future<List<AdminProduct>> listDeletedProducts() => throw UnimplementedError();
|
||||
Future<AdminAiTraceListResponse> listAiTraces(
|
||||
{required AdminAiTraceSource source,
|
||||
int limit = 25,
|
||||
String? cursor,
|
||||
String? period,
|
||||
bool onlyErrors = false}) =>
|
||||
throw UnimplementedError();
|
||||
@override
|
||||
Future<List<PendingProduct>> listPendingProducts() => throw UnimplementedError();
|
||||
Future<List<AdminCategoryNode>> listCategoryTree() =>
|
||||
throw UnimplementedError();
|
||||
@override
|
||||
Future<List<PendingProduct>> listPrivateProducts() => throw UnimplementedError();
|
||||
Future<List<AdminProduct>> listDeletedProducts() =>
|
||||
throw UnimplementedError();
|
||||
@override
|
||||
Future<List<PendingProduct>> listPendingProducts() =>
|
||||
throw UnimplementedError();
|
||||
@override
|
||||
Future<List<PendingProduct>> listPrivateProducts() =>
|
||||
throw UnimplementedError();
|
||||
@override
|
||||
Future<List<AdminProduct>> listProducts() => throw UnimplementedError();
|
||||
@override
|
||||
Future<List<AdminProduct>> listSelectableProductsForAdmin({bool forceRefresh = false}) => throw UnimplementedError();
|
||||
Future<List<AdminProduct>> listSelectableProductsForAdmin(
|
||||
{bool forceRefresh = false}) =>
|
||||
throw UnimplementedError();
|
||||
@override
|
||||
Future<List<UserAdmin>> listUsers() => throw UnimplementedError();
|
||||
@override
|
||||
Future<void> mergeAdminInventory({required int sourceInventoryId, required int targetInventoryId}) => throw UnimplementedError();
|
||||
Future<void> mergeAdminInventory(
|
||||
{required int sourceInventoryId, required int targetInventoryId}) =>
|
||||
throw UnimplementedError();
|
||||
@override
|
||||
Future<void> mergeProducts({required int sourceProductId, required int targetProductId}) => throw UnimplementedError();
|
||||
Future<void> mergeProducts(
|
||||
{required int sourceProductId, required int targetProductId}) =>
|
||||
throw UnimplementedError();
|
||||
@override
|
||||
Future<void> mergeProductsPrivate({required int sourceProductId, required int targetProductId}) => throw UnimplementedError();
|
||||
Future<void> mergeProductsPrivate(
|
||||
{required int sourceProductId, required int targetProductId}) =>
|
||||
throw UnimplementedError();
|
||||
@override
|
||||
Future<void> moveAdminInventoryToPantry(int inventoryId) => throw UnimplementedError();
|
||||
Future<void> moveAdminInventoryToPantry(int inventoryId) =>
|
||||
throw UnimplementedError();
|
||||
@override
|
||||
Future<void> moveAdminPantryToInventory(int pantryItemId, Map<String, dynamic> body) => throw UnimplementedError();
|
||||
Future<void> moveAdminPantryToInventory(
|
||||
int pantryItemId, Map<String, dynamic> body) =>
|
||||
throw UnimplementedError();
|
||||
@override
|
||||
Future<Map<String, dynamic>> previewAdminInventoryMerge({required int sourceInventoryId, required int targetInventoryId}) => throw UnimplementedError();
|
||||
Future<Map<String, dynamic>> previewAdminInventoryMerge(
|
||||
{required int sourceInventoryId, required int targetInventoryId}) =>
|
||||
throw UnimplementedError();
|
||||
@override
|
||||
Future<Map<String, dynamic>> previewMerge({required int sourceProductId, required int targetProductId}) => throw UnimplementedError();
|
||||
Future<Map<String, dynamic>> previewMerge(
|
||||
{required int sourceProductId, required int targetProductId}) =>
|
||||
throw UnimplementedError();
|
||||
@override
|
||||
Future<AdminProduct> promotePrivateProduct(int productId) => throw UnimplementedError();
|
||||
Future<AdminProduct> promotePrivateProduct(int productId) =>
|
||||
throw UnimplementedError();
|
||||
@override
|
||||
Future<void> removeAdminInventory(int inventoryId) => throw UnimplementedError();
|
||||
Future<void> removeAdminInventory(int inventoryId) =>
|
||||
throw UnimplementedError();
|
||||
@override
|
||||
Future<void> removeAdminPantryItem(int pantryItemId) => throw UnimplementedError();
|
||||
Future<void> removeAdminPantryItem(int pantryItemId) =>
|
||||
throw UnimplementedError();
|
||||
@override
|
||||
Future<void> removeProduct(int productId) => throw UnimplementedError();
|
||||
@override
|
||||
Future<void> removeReceiptAlias(int id) => throw UnimplementedError();
|
||||
@override
|
||||
Future<Map<String, dynamic>> resetPassword(int userId) => throw UnimplementedError();
|
||||
Future<Map<String, dynamic>> resetPassword(int userId) =>
|
||||
throw UnimplementedError();
|
||||
@override
|
||||
Future<void> restoreProduct(int productId) => throw UnimplementedError();
|
||||
@override
|
||||
Future<UserAdmin> setPremium(int userId, {required bool isPremium}) => throw UnimplementedError();
|
||||
Future<UserAdmin> setPremium(int userId, {required bool isPremium}) =>
|
||||
throw UnimplementedError();
|
||||
@override
|
||||
Future<void> setProductCategory(int productId, {required int? categoryId}) => throw UnimplementedError();
|
||||
Future<void> setProductCategory(int productId, {required int? categoryId}) =>
|
||||
throw UnimplementedError();
|
||||
@override
|
||||
Future<void> setProductStatus(int productId, String status) => throw UnimplementedError();
|
||||
Future<void> setProductStatus(int productId, String status) =>
|
||||
throw UnimplementedError();
|
||||
@override
|
||||
Future<UserAdmin> setRecipeSharing(int userId, {required bool canShareRecipes}) => throw UnimplementedError();
|
||||
Future<UserAdmin> setRecipeSharing(int userId,
|
||||
{required bool canShareRecipes}) =>
|
||||
throw UnimplementedError();
|
||||
@override
|
||||
Future<UserAdmin> setRole(int userId, String newRole) => throw UnimplementedError();
|
||||
Future<UserAdmin> setRole(int userId, String newRole) =>
|
||||
throw UnimplementedError();
|
||||
@override
|
||||
Future<AdminInventoryItem> updateAdminInventory(int inventoryId, {int? productId, double? quantity, String? unit, String? location, String? brand, String? receiptName, String? suitableFor, String? comment}) => throw UnimplementedError();
|
||||
Future<AdminInventoryItem> updateAdminInventory(int inventoryId,
|
||||
{int? productId,
|
||||
double? quantity,
|
||||
String? unit,
|
||||
String? location,
|
||||
String? brand,
|
||||
String? receiptName,
|
||||
String? suitableFor,
|
||||
String? comment}) =>
|
||||
throw UnimplementedError();
|
||||
@override
|
||||
Future<AdminPantryItem> updateAdminPantry(int pantryItemId, {int? productId, String? location}) => throw UnimplementedError();
|
||||
Future<AdminPantryItem> updateAdminPantry(int pantryItemId,
|
||||
{int? productId, String? location}) =>
|
||||
throw UnimplementedError();
|
||||
@override
|
||||
Future<void> updateCanonicalName(int productId, String canonicalName) => throw UnimplementedError();
|
||||
Future<void> updateCanonicalName(int productId, String canonicalName) =>
|
||||
throw UnimplementedError();
|
||||
@override
|
||||
Future<void> updateCanonicalNamePrivate(int productId, String canonicalName) => throw UnimplementedError();
|
||||
Future<void> updateCanonicalNamePrivate(
|
||||
int productId, String canonicalName) =>
|
||||
throw UnimplementedError();
|
||||
@override
|
||||
Future<void> updateEmail(int userId, String email) => throw UnimplementedError();
|
||||
Future<void> updateEmail(int userId, String email) =>
|
||||
throw UnimplementedError();
|
||||
@override
|
||||
Future<void> upsertReceiptAlias({required String receiptName, required int productId, bool isGlobal = false}) => throw UnimplementedError();
|
||||
Future<void> upsertReceiptAlias(
|
||||
{required String receiptName,
|
||||
required int productId,
|
||||
bool isGlobal = false}) =>
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
// Simple fake that only implements the methods we need
|
||||
@@ -128,7 +218,8 @@ class FakeAdminRepository {
|
||||
return _products;
|
||||
}
|
||||
|
||||
Future<void> updateReceiptAlias(int id, {String? receiptName, int? productId, bool? isGlobal}) async {
|
||||
Future<void> updateReceiptAlias(int id,
|
||||
{String? receiptName, int? productId, bool? isGlobal}) async {
|
||||
// Find and update alias
|
||||
final index = _aliases.indexWhere((a) => a.id == id);
|
||||
if (index >= 0) {
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:recipe_flutter/core/ui/app_shell.dart';
|
||||
import 'package:recipe_flutter/features/admin/data/admin_repository.dart';
|
||||
import 'package:recipe_flutter/features/admin/domain/admin_ai_trace.dart';
|
||||
import 'package:recipe_flutter/features/admin/domain/admin_ai_trace_detail.dart';
|
||||
import 'package:recipe_flutter/features/admin/presentation/admin_ai_panel.dart';
|
||||
import 'package:recipe_flutter/features/admin/presentation/admin_screen.dart';
|
||||
import 'package:recipe_flutter/features/auth/data/auth_providers.dart';
|
||||
|
||||
class _FakeAdminRepository implements AdminRepository {
|
||||
final AdminAiTraceListResponse flyerList;
|
||||
final AdminAiTraceListResponse receiptList;
|
||||
final Map<String, AdminAiTraceDetail> details;
|
||||
|
||||
_FakeAdminRepository({
|
||||
required this.flyerList,
|
||||
required this.receiptList,
|
||||
required this.details,
|
||||
});
|
||||
|
||||
@override
|
||||
Future<AdminAiTraceListResponse> listAiTraces({
|
||||
required AdminAiTraceSource source,
|
||||
int limit = 25,
|
||||
String? cursor,
|
||||
String? period,
|
||||
bool onlyErrors = false,
|
||||
}) async {
|
||||
return source == AdminAiTraceSource.flyer ? flyerList : receiptList;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<AdminAiTraceDetail> getAiTraceById(String traceId) async {
|
||||
return details[traceId] ?? details.values.first;
|
||||
}
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
Widget _buildPanelApp(AdminRepository repo) {
|
||||
return ProviderScope(
|
||||
overrides: [
|
||||
adminRepositoryProvider.overrideWithValue(repo),
|
||||
],
|
||||
child: const MaterialApp(
|
||||
home: Scaffold(
|
||||
body: AdminAiPanel(embedded: true),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
final flyerItem = AdminAiTraceListItem(
|
||||
id: 'flyer-101',
|
||||
source: AdminAiTraceSource.flyer,
|
||||
status: AdminAiTraceStatus.warning,
|
||||
createdAt: DateTime.parse('2026-05-20T12:34:56.000Z'),
|
||||
userId: 7,
|
||||
userLabel: 'admin',
|
||||
sessionId: 101,
|
||||
fileName: 'willys-v20.pdf',
|
||||
model: 'ministral-8b-2512',
|
||||
durationMs: 1880,
|
||||
warningsCount: 2,
|
||||
hasPrompt: true,
|
||||
hasOutput: true,
|
||||
error: null,
|
||||
);
|
||||
|
||||
final flyerDetail = AdminAiTraceDetail(
|
||||
id: 'flyer-101',
|
||||
source: AdminAiTraceSource.flyer,
|
||||
status: AdminAiTraceStatus.warning,
|
||||
createdAt: DateTime.parse('2026-05-20T12:34:56.000Z'),
|
||||
userId: 7,
|
||||
userLabel: 'admin',
|
||||
sessionId: 101,
|
||||
fileName: 'willys-v20.pdf',
|
||||
model: 'ministral-8b-2512',
|
||||
durationMs: 1880,
|
||||
retryCount: 1,
|
||||
chunkCount: 3,
|
||||
warnings: const ['parse:low_confidence'],
|
||||
error: null,
|
||||
prompt: 'Prompttext exempel',
|
||||
rawOutput: '{"ok":true}',
|
||||
normalizedOutput: const {
|
||||
'sessionId': 101,
|
||||
'items': [
|
||||
{'rawName': 'Tomat'}
|
||||
],
|
||||
},
|
||||
summary: const {'itemCount': 1},
|
||||
);
|
||||
|
||||
group('Admin AI tab and panel', () {
|
||||
testWidgets('Admin main route query tab=ai renders AI panel',
|
||||
(tester) async {
|
||||
final fakeRepo = _FakeAdminRepository(
|
||||
flyerList:
|
||||
AdminAiTraceListResponse(items: [flyerItem], nextCursor: null),
|
||||
receiptList:
|
||||
const AdminAiTraceListResponse(items: [], nextCursor: null),
|
||||
details: {'flyer-101': flyerDetail},
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
adminRepositoryProvider.overrideWithValue(fakeRepo),
|
||||
],
|
||||
child: const MaterialApp(
|
||||
home: Scaffold(
|
||||
body: AdminScreen(initialTab: AdminViewTab.ai),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Kvitto'), findsOneWidget);
|
||||
expect(find.text('Flyer'), findsOneWidget);
|
||||
expect(find.text('willys-v20.pdf'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('AppShell shows AI top chip and navigates with query',
|
||||
(tester) async {
|
||||
String? navigatedTo;
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
isAdminProvider.overrideWithValue(true),
|
||||
],
|
||||
child: MaterialApp(
|
||||
home: AppShell(
|
||||
location: '/admin?tab=ai',
|
||||
onNavigateToPath: (path) => navigatedTo = path,
|
||||
child: const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('AI'), findsOneWidget);
|
||||
await tester.tap(find.text('Databas'));
|
||||
await tester.pump();
|
||||
|
||||
expect(navigatedTo, '/admin?tab=database');
|
||||
});
|
||||
|
||||
testWidgets('Source switching toggles between Flyer and Kvitto views',
|
||||
(tester) async {
|
||||
final fakeRepo = _FakeAdminRepository(
|
||||
flyerList:
|
||||
AdminAiTraceListResponse(items: [flyerItem], nextCursor: null),
|
||||
receiptList:
|
||||
const AdminAiTraceListResponse(items: [], nextCursor: null),
|
||||
details: {'flyer-101': flyerDetail},
|
||||
);
|
||||
|
||||
await tester.pumpWidget(_buildPanelApp(fakeRepo));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('willys-v20.pdf'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Kvitto'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Receipt trace-data saknas i recipe-api i denna fas.'),
|
||||
findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Flyer'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('willys-v20.pdf'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('Prompt and output render and copy actions show snackbars',
|
||||
(tester) async {
|
||||
await tester.binding.setSurfaceSize(const Size(1400, 1200));
|
||||
final fakeRepo = _FakeAdminRepository(
|
||||
flyerList:
|
||||
AdminAiTraceListResponse(items: [flyerItem], nextCursor: null),
|
||||
receiptList:
|
||||
const AdminAiTraceListResponse(items: [], nextCursor: null),
|
||||
details: {'flyer-101': flyerDetail},
|
||||
);
|
||||
|
||||
await tester.pumpWidget(_buildPanelApp(fakeRepo));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
|
||||
await tester.tap(find.byType(ListTile).first);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
|
||||
expect(find.text('Sammanfattning'), findsOneWidget);
|
||||
final detailScroll = find.byType(Scrollable).last;
|
||||
await tester.scrollUntilVisible(
|
||||
find.text('Model Output'),
|
||||
200,
|
||||
scrollable: detailScroll,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Model Output'), findsOneWidget);
|
||||
expect(find.textContaining('"sessionId": 101'), findsOneWidget);
|
||||
|
||||
final copyPrompt = find.byTooltip('Kopiera');
|
||||
final copyOutput = find.byTooltip('Kopiera JSON');
|
||||
expect(copyPrompt, findsOneWidget);
|
||||
expect(copyOutput, findsOneWidget);
|
||||
|
||||
await tester.tap(copyPrompt);
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.takeException(), isNull);
|
||||
|
||||
await tester.tap(copyOutput);
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.takeException(), isNull);
|
||||
|
||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user