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
@@ -0,0 +1,107 @@
enum AdminAiTraceSource { receipt, flyer }
enum AdminAiTraceStatus { success, warning, error }
extension AdminAiTraceSourceX on AdminAiTraceSource {
String get apiValue =>
this == AdminAiTraceSource.receipt ? 'receipt' : 'flyer';
String get label => this == AdminAiTraceSource.receipt ? 'Kvitto' : 'Flyer';
static AdminAiTraceSource fromApi(String? value) {
if (value == 'receipt') return AdminAiTraceSource.receipt;
return AdminAiTraceSource.flyer;
}
}
extension AdminAiTraceStatusX on AdminAiTraceStatus {
String get label => switch (this) {
AdminAiTraceStatus.success => 'OK',
AdminAiTraceStatus.warning => 'Varning',
AdminAiTraceStatus.error => 'Fel',
};
static AdminAiTraceStatus fromApi(String? value) {
return switch (value) {
'error' => AdminAiTraceStatus.error,
'warning' => AdminAiTraceStatus.warning,
_ => AdminAiTraceStatus.success,
};
}
}
class AdminAiTraceListItem {
final String id;
final AdminAiTraceSource source;
final AdminAiTraceStatus status;
final DateTime createdAt;
final int userId;
final String userLabel;
final int? sessionId;
final String? fileName;
final String? model;
final int? durationMs;
final int warningsCount;
final bool hasPrompt;
final bool hasOutput;
final String? error;
const AdminAiTraceListItem({
required this.id,
required this.source,
required this.status,
required this.createdAt,
required this.userId,
required this.userLabel,
required this.sessionId,
required this.fileName,
required this.model,
required this.durationMs,
required this.warningsCount,
required this.hasPrompt,
required this.hasOutput,
required this.error,
});
factory AdminAiTraceListItem.fromJson(Map<String, dynamic> json) {
return AdminAiTraceListItem(
id: (json['id'] ?? '').toString(),
source: AdminAiTraceSourceX.fromApi(json['source']?.toString()),
status: AdminAiTraceStatusX.fromApi(json['status']?.toString()),
createdAt: DateTime.tryParse((json['createdAt'] ?? '').toString()) ??
DateTime.fromMillisecondsSinceEpoch(0),
userId: (json['userId'] as num?)?.toInt() ?? 0,
userLabel: (json['userLabel'] ?? '').toString(),
sessionId: (json['sessionId'] as num?)?.toInt(),
fileName: json['fileName']?.toString(),
model: json['model']?.toString(),
durationMs: (json['durationMs'] as num?)?.toInt(),
warningsCount: (json['warningsCount'] as num?)?.toInt() ?? 0,
hasPrompt: json['hasPrompt'] == true,
hasOutput: json['hasOutput'] == true,
error: json['error']?.toString(),
);
}
}
class AdminAiTraceListResponse {
final List<AdminAiTraceListItem> items;
final String? nextCursor;
const AdminAiTraceListResponse({
required this.items,
required this.nextCursor,
});
factory AdminAiTraceListResponse.fromJson(Map<String, dynamic> json) {
final rawItems = (json['items'] as List<dynamic>?) ?? const [];
return AdminAiTraceListResponse(
items: rawItems
.whereType<Map>()
.map((entry) =>
AdminAiTraceListItem.fromJson(Map<String, dynamic>.from(entry)))
.toList(),
nextCursor: json['nextCursor']?.toString(),
);
}
}
@@ -0,0 +1,75 @@
import 'admin_ai_trace.dart';
class AdminAiTraceDetail {
final String id;
final AdminAiTraceSource source;
final AdminAiTraceStatus status;
final DateTime createdAt;
final int userId;
final String userLabel;
final int? sessionId;
final String? fileName;
final String? model;
final int? durationMs;
final int? retryCount;
final int? chunkCount;
final List<String> warnings;
final String? error;
final String? prompt;
final String? rawOutput;
final Map<String, dynamic>? normalizedOutput;
final Map<String, dynamic> summary;
const AdminAiTraceDetail({
required this.id,
required this.source,
required this.status,
required this.createdAt,
required this.userId,
required this.userLabel,
required this.sessionId,
required this.fileName,
required this.model,
required this.durationMs,
required this.retryCount,
required this.chunkCount,
required this.warnings,
required this.error,
required this.prompt,
required this.rawOutput,
required this.normalizedOutput,
required this.summary,
});
factory AdminAiTraceDetail.fromJson(Map<String, dynamic> json) {
final warningsRaw = (json['warnings'] as List<dynamic>?) ?? const [];
final normalizedOutputMap = json['normalizedOutput'] is Map
? Map<String, dynamic>.from(json['normalizedOutput'] as Map)
: null;
final summaryMap = json['summary'] is Map
? Map<String, dynamic>.from(json['summary'] as Map)
: const <String, dynamic>{};
return AdminAiTraceDetail(
id: (json['id'] ?? '').toString(),
source: AdminAiTraceSourceX.fromApi(json['source']?.toString()),
status: AdminAiTraceStatusX.fromApi(json['status']?.toString()),
createdAt: DateTime.tryParse((json['createdAt'] ?? '').toString()) ??
DateTime.fromMillisecondsSinceEpoch(0),
userId: (json['userId'] as num?)?.toInt() ?? 0,
userLabel: (json['userLabel'] ?? '').toString(),
sessionId: (json['sessionId'] as num?)?.toInt(),
fileName: json['fileName']?.toString(),
model: json['model']?.toString(),
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(),
error: json['error']?.toString(),
prompt: json['prompt']?.toString(),
rawOutput: json['rawOutput']?.toString(),
normalizedOutput: normalizedOutputMap,
summary: summaryMap,
);
}
}