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:
@@ -0,0 +1,135 @@
|
||||
# Plan: Separat Admin-AI huvudtabb + AI-insyn för Kvitto/Flyer
|
||||
|
||||
## Mål
|
||||
- Flytta ut `AI` från `Admin > Databas` till en egen huvudtabb i admin (samma nivå som `Users` och `Database`).
|
||||
- På nya `AI`-fliken visa två underflikar:
|
||||
- `Kvitto` (AI-funktioner för receipt-import)
|
||||
- `Flyer` (AI-funktioner för flyer-import)
|
||||
- Ge admin insyn i:
|
||||
- Prompt (vad modellen fick)
|
||||
- Output (vad modellen returnerade)
|
||||
- Per import/sessionshistorik
|
||||
|
||||
## Nuvarande läge (verifierat)
|
||||
- Huvudtabbar i admin definieras i:
|
||||
- `flutter/lib/features/admin/presentation/admin_screen.dart`
|
||||
- `flutter/lib/core/ui/app_shell.dart`
|
||||
- `AI` ligger idag som intern databas-tab i:
|
||||
- `flutter/lib/features/admin/presentation/admin_database_panel.dart`
|
||||
- Nuvarande `AdminAiPanel` visar bara modellinfo:
|
||||
- `flutter/lib/features/admin/presentation/admin_ai_panel.dart`
|
||||
- Flyer har sessions-API och sparar parsat resultat, men ingen API-yta för prompt/output:
|
||||
- `backend/src/flyer-import/flyer-import.controller.ts`
|
||||
- `backend/src/flyer-import/dto/flyer-import.response.ts`
|
||||
- Receipt-import har ingen sessionhistorik i recipe-api och ingen prompt/output i svar:
|
||||
- `backend/src/receipt-import/receipt-import.controller.ts`
|
||||
- `backend/src/receipt-import/dto/parsed-receipt-item.dto.ts`
|
||||
|
||||
## UX-förslag (enkelt och snyggt)
|
||||
- Ny huvudflik `AI` i admin med tydlig struktur:
|
||||
- Topp: två chips/segmenterade knappar `Kvitto` och `Flyer`
|
||||
- Innehåll: 2-kolumnslayout på desktop, enkel stack på mobil
|
||||
- Vänster: lista med senaste importer (tid, användare, fil, status)
|
||||
- Höger: detaljer för vald import
|
||||
- Detaljvyn har tre kort:
|
||||
1. **Prompt** (monospace, copy-knapp, expand/collapse)
|
||||
2. **Model Output** (formatterad JSON, copy-knapp)
|
||||
3. **Sammanfattning** (modell, duration, chunk/retry, warnings)
|
||||
- Filter högst upp:
|
||||
- Källa: Kvitto/Flyer
|
||||
- Period: senaste 24h / 7d / 30d
|
||||
- Endast fel
|
||||
- Default-beteende:
|
||||
- Välj senaste import automatiskt
|
||||
- Visa läs-only data (ingen redigering av prompt i denna iteration)
|
||||
|
||||
## Föreslagen implementation
|
||||
|
||||
### 1) Flutter: ny Admin-huvudtabb AI
|
||||
1. Utöka enum och query-hantering:
|
||||
- `AdminViewTab` får `ai`
|
||||
- query-stöd: `?tab=ai`
|
||||
2. Uppdatera admin-title chips i `AppShell`:
|
||||
- Lägg till `AI` bredvid `Användare` och `Databas`
|
||||
3. Rendra ny panel i `AdminScreen`:
|
||||
- `AdminAiPanel` blir panel för huvudtabben
|
||||
4. Ta bort AI från `AdminDatabasePanel` interna tabs
|
||||
|
||||
Berörda filer:
|
||||
- `flutter/lib/features/admin/presentation/admin_screen.dart`
|
||||
- `flutter/lib/core/ui/app_shell.dart`
|
||||
- `flutter/lib/features/admin/presentation/admin_database_panel.dart`
|
||||
|
||||
### 2) Flutter: bygg om `AdminAiPanel` till AI-observability
|
||||
1. Inför underflikar `Kvitto` / `Flyer`
|
||||
2. Lägg till vänster lista + höger detalj
|
||||
3. Lägg till komponenter:
|
||||
- `PromptCard` (text/copy)
|
||||
- `OutputJsonCard` (pretty JSON/copy)
|
||||
- `TraceMetaCard` (modell, tid, status)
|
||||
4. Lägg till `adminRepository`-metoder för att hämta traces
|
||||
|
||||
Berörda filer:
|
||||
- `flutter/lib/features/admin/presentation/admin_ai_panel.dart`
|
||||
- `flutter/lib/features/admin/data/admin_repository.dart`
|
||||
- `flutter/lib/core/api/api_paths.dart`
|
||||
- ev. nya domänmodeller under `flutter/lib/features/admin/domain/`
|
||||
|
||||
### 3) Backend: exponera AI trace-data (admin-only)
|
||||
1. Lägg till admin-endpoints för trace-lista + detalj
|
||||
2. Returnera prompt/output samt metadata
|
||||
3. Begränsa åtkomst till admin
|
||||
|
||||
Föreslagen API-yta:
|
||||
- `GET /ai/traces?source=receipt|flyer&limit=...&cursor=...`
|
||||
- `GET /ai/traces/:id`
|
||||
|
||||
### 4) Datalagring för prompt/output
|
||||
För stabil UX behövs persistens, inte bara loggar.
|
||||
|
||||
Föreslagen modell:
|
||||
- Ny Prisma-tabell `AiTrace`
|
||||
- `id`, `source` (`receipt`/`flyer`), `userId`, `sessionId?`, `model`, `prompt`, `rawOutput`, `normalizedOutput`, `status`, `error`, `durationMs`, `createdAt`
|
||||
|
||||
Integration:
|
||||
- Flyer: skapa trace i `AiFlyerParserService` vid varje AI-anrop/chunk (sammanfatta till en sessionsrad eller flera child-rader)
|
||||
- Receipt: recipe-api behöver trace från importer-service eller egen instrumentation av prompt/output
|
||||
|
||||
## Viktig teknisk avgränsning (receipt)
|
||||
- I nuvarande repo byggs flyer-prompt i `recipe-api` och är enkel att visa.
|
||||
- Receipt-AI sker i importer-flöde; prompt/output finns sannolikt inte i recipe-api idag.
|
||||
- Därför två realistiska steg:
|
||||
1. **Steg 1 (snabbt):** full trace-visning för Flyer + modellinfo för Kvitto
|
||||
2. **Steg 2:** utöka receipt/importer så prompt/output skickas eller lagras som trace och visas i samma UI
|
||||
|
||||
## Säkerhet
|
||||
- Endast admin får läsa traces.
|
||||
- Prompt/output kan innehålla känslig text från uppladdade filer:
|
||||
- visa som read-only
|
||||
- möjlighet till maskning av persondata i senare steg
|
||||
- paginering + kort retention (t.ex. 30 dagar) rekommenderas
|
||||
|
||||
## Testplan
|
||||
- Flutter widget-test:
|
||||
- Ny huvudtabb `AI` syns och route `?tab=ai` fungerar
|
||||
- Underflikar `Kvitto`/`Flyer` växlar korrekt
|
||||
- Prompt/output renderas och copy-knappar fungerar
|
||||
- Backend tester:
|
||||
- admin-only behörighet på trace-endpoints
|
||||
- lista/detalj svarar korrekt
|
||||
- trace skapas för flyer parse-flöde
|
||||
- Regression:
|
||||
- Admin `Users`/`Database` fungerar oförändrat
|
||||
|
||||
## Genomförandeordning
|
||||
1. Flytta AI till huvudtabb i Flutter (utan backend-ändring först)
|
||||
2. Bygg ny AI-panelstruktur med underflikar
|
||||
3. Inför backend trace-endpoints + Prisma-migration
|
||||
4. Koppla Flyer trace end-to-end
|
||||
5. Koppla Receipt trace (beroende på importer-instrumentation)
|
||||
6. Sluttest + docs
|
||||
|
||||
## Rekommenderat beslut inför implementation
|
||||
- Implementera i två faser för låg risk:
|
||||
- Fas A: Ny UX + full Flyer-insyn direkt
|
||||
- Fas B: Receipt prompt/output när importer-trace är tillgänglig
|
||||
@@ -0,0 +1,24 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE `AiTrace` (
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`source` VARCHAR(191) NOT NULL,
|
||||
`userId` INTEGER NULL,
|
||||
`sessionId` INTEGER NULL,
|
||||
`model` VARCHAR(191) NULL,
|
||||
`prompt` LONGTEXT NULL,
|
||||
`rawOutput` LONGTEXT NULL,
|
||||
`normalizedOutput` JSON NULL,
|
||||
`status` VARCHAR(191) NOT NULL,
|
||||
`error` TEXT NULL,
|
||||
`durationMs` INTEGER NULL,
|
||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updatedAt` DATETIME(3) NOT NULL,
|
||||
|
||||
INDEX `AiTrace_source_createdAt_idx`(`source`, `createdAt`),
|
||||
INDEX `AiTrace_userId_createdAt_idx`(`userId`, `createdAt`),
|
||||
INDEX `AiTrace_status_createdAt_idx`(`status`, `createdAt`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `AiTrace` ADD CONSTRAINT `AiTrace_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -33,6 +33,7 @@ model User {
|
||||
flyerSessions FlyerSession[]
|
||||
flyerSelections FlyerSelection[]
|
||||
shoppingListItems ShoppingListItem[]
|
||||
aiTraces AiTrace[]
|
||||
}
|
||||
|
||||
model Product {
|
||||
@@ -388,3 +389,25 @@ model ShoppingListItem {
|
||||
@@index([productId, unit, status])
|
||||
@@index([categoryId])
|
||||
}
|
||||
|
||||
model AiTrace {
|
||||
id Int @id @default(autoincrement())
|
||||
source String
|
||||
userId Int?
|
||||
sessionId Int?
|
||||
model String?
|
||||
prompt String? @db.LongText
|
||||
rawOutput String? @db.LongText
|
||||
normalizedOutput Json?
|
||||
status String
|
||||
error String? @db.Text
|
||||
durationMs Int?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([source, createdAt])
|
||||
@@index([userId, createdAt])
|
||||
@@index([status, createdAt])
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { AiTraceService } from './ai-trace.service';
|
||||
|
||||
describe('AiTraceService receipt masking', () => {
|
||||
const prismaMock = {
|
||||
aiTrace: {
|
||||
findFirst: jest.fn(),
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
flyerSession: {
|
||||
findMany: jest.fn(),
|
||||
findUnique: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
const service = new AiTraceService(prismaMock as any);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('masks sensitive data in receipt prompt and rawOutput', async () => {
|
||||
prismaMock.aiTrace.findFirst.mockResolvedValue({
|
||||
id: 42,
|
||||
source: 'receipt',
|
||||
status: 'success',
|
||||
createdAt: new Date('2026-05-21T10:00:00.000Z'),
|
||||
userId: 7,
|
||||
sessionId: null,
|
||||
model: 'importer-receipt-ai',
|
||||
durationMs: 240,
|
||||
error: null,
|
||||
prompt: 'Kund email anna@example.com och telefon 070-123 45 67',
|
||||
rawOutput: JSON.stringify({
|
||||
personnummer: '850101-1234',
|
||||
email: 'anna@example.com',
|
||||
nested: {
|
||||
namn: 'Anna Andersson',
|
||||
phone: '+46701234567',
|
||||
},
|
||||
}),
|
||||
normalizedOutput: {
|
||||
items: [
|
||||
{
|
||||
rawName: 'Mjolk',
|
||||
customerEmail: 'anna@example.com',
|
||||
},
|
||||
],
|
||||
},
|
||||
user: {
|
||||
username: 'admin',
|
||||
email: 'admin@example.com',
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.getTraceById('receipt-42');
|
||||
|
||||
expect(result.prompt).not.toContain('anna@example.com');
|
||||
expect(result.prompt).not.toContain('070-123 45 67');
|
||||
expect(result.prompt).toContain('[MASKED]');
|
||||
|
||||
expect(result.rawOutput).not.toContain('850101-1234');
|
||||
expect(result.rawOutput).not.toContain('anna@example.com');
|
||||
expect(result.rawOutput).not.toContain('Anna Andersson');
|
||||
expect(result.rawOutput).toContain('[MASKED]');
|
||||
|
||||
expect(result.normalizedOutput).toEqual({
|
||||
items: [
|
||||
{
|
||||
rawName: 'Mjolk',
|
||||
customerEmail: '[MASKED]',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('filters flyer list by errors in database query', async () => {
|
||||
prismaMock.flyerSession.findMany.mockResolvedValue([]);
|
||||
|
||||
await service.listTraces({
|
||||
source: 'flyer',
|
||||
limit: 20,
|
||||
onlyErrors: true,
|
||||
});
|
||||
|
||||
expect(prismaMock.flyerSession.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
items: { none: {} },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,492 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
export type AiTraceSource = 'receipt' | 'flyer';
|
||||
|
||||
export type AiTraceStatus = 'success' | 'warning' | 'error';
|
||||
|
||||
const AI_TRACE_MASK_FIELDS = ['personnummer', 'telefon', 'email', 'address', 'namn'];
|
||||
const SWEDISH_PERSONAL_ID_REGEX = /\b(\d{2})?(\d{6})[-+ ]?(\d{4})\b/g;
|
||||
const EMAIL_REGEX = /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi;
|
||||
const PHONE_REGEX = /\b(?:\+46|0)\s?\d(?:[\d\s-]{6,}\d)\b/g;
|
||||
|
||||
export type AiTraceListItem = {
|
||||
id: string;
|
||||
source: AiTraceSource;
|
||||
status: AiTraceStatus;
|
||||
createdAt: string;
|
||||
userId: number;
|
||||
userLabel: string;
|
||||
sessionId: number | null;
|
||||
fileName: string | null;
|
||||
model: string | null;
|
||||
durationMs: number | null;
|
||||
warningsCount: number;
|
||||
hasPrompt: boolean;
|
||||
hasOutput: boolean;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
export type AiTraceListResponse = {
|
||||
items: AiTraceListItem[];
|
||||
nextCursor: string | null;
|
||||
};
|
||||
|
||||
export type AiTraceDetail = {
|
||||
id: string;
|
||||
source: AiTraceSource;
|
||||
status: AiTraceStatus;
|
||||
createdAt: string;
|
||||
userId: number;
|
||||
userLabel: string;
|
||||
sessionId: number | null;
|
||||
fileName: string | null;
|
||||
model: string | null;
|
||||
durationMs: number | null;
|
||||
retryCount: number | null;
|
||||
chunkCount: number | null;
|
||||
warnings: string[];
|
||||
error: string | null;
|
||||
prompt: string | null;
|
||||
rawOutput: string | null;
|
||||
normalizedOutput: Record<string, unknown> | null;
|
||||
summary: Record<string, unknown>;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class AiTraceService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async listTraces(params: {
|
||||
source: AiTraceSource;
|
||||
limit: number;
|
||||
cursor?: string;
|
||||
period?: '24h' | '7d' | '30d';
|
||||
onlyErrors?: boolean;
|
||||
}): Promise<AiTraceListResponse> {
|
||||
if (params.source === 'receipt') {
|
||||
return this.listReceiptTraces(params);
|
||||
}
|
||||
|
||||
const take = Math.max(1, Math.min(params.limit || 20, 100));
|
||||
const cursorId = this.parseCursor(params.cursor);
|
||||
const periodStart = this.periodStart(params.period);
|
||||
|
||||
const sessions = await this.prisma.flyerSession.findMany({
|
||||
where: {
|
||||
...(periodStart ? { createdAt: { gte: periodStart } } : {}),
|
||||
...(cursorId ? { id: { lt: cursorId } } : {}),
|
||||
...(params.onlyErrors ? { items: { none: {} } } : {}),
|
||||
},
|
||||
orderBy: { id: 'desc' },
|
||||
take: take + 1,
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
createdAt: true,
|
||||
sourceFileName: true,
|
||||
user: { select: { username: true, email: true } },
|
||||
items: {
|
||||
select: {
|
||||
id: true,
|
||||
parseReasons: true,
|
||||
matchReasons: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const hasMore = sessions.length > take;
|
||||
const page = hasMore ? sessions.slice(0, take) : sessions;
|
||||
|
||||
const items: AiTraceListItem[] = page.map((session) => {
|
||||
const warningsCount = session.items.reduce((sum, item) => {
|
||||
const parseWarnings = Array.isArray(item.parseReasons) ? item.parseReasons.length : 0;
|
||||
const matchWarnings = Array.isArray(item.matchReasons) ? item.matchReasons.length : 0;
|
||||
return sum + parseWarnings + matchWarnings;
|
||||
}, 0);
|
||||
const status = this.statusFromSession(session.items.length, warningsCount);
|
||||
return {
|
||||
id: this.flyerTraceId(session.id),
|
||||
source: 'flyer',
|
||||
status,
|
||||
createdAt: session.createdAt.toISOString(),
|
||||
userId: session.userId,
|
||||
userLabel: this.userLabel(session.user?.username, session.user?.email, session.userId),
|
||||
sessionId: session.id,
|
||||
fileName: session.sourceFileName,
|
||||
model: 'ministral-8b-2512',
|
||||
durationMs: null,
|
||||
warningsCount,
|
||||
hasPrompt: false,
|
||||
hasOutput: session.items.length > 0,
|
||||
error: status === 'error' ? 'Inga produkter kunde extraheras från flyern.' : null,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
items,
|
||||
nextCursor: hasMore ? String(page[page.length - 1]?.id ?? '') : null,
|
||||
};
|
||||
}
|
||||
|
||||
async getTraceById(id: string): Promise<AiTraceDetail> {
|
||||
const parsed = this.parseTraceId(id);
|
||||
if (parsed.source === 'receipt') {
|
||||
return this.getReceiptTraceById(parsed.numericId);
|
||||
}
|
||||
|
||||
const session = await this.prisma.flyerSession.findUnique({
|
||||
where: { id: parsed.numericId },
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
createdAt: true,
|
||||
sourceFileName: true,
|
||||
sourceMimeType: true,
|
||||
sourceFileSize: true,
|
||||
user: { select: { username: true, email: true } },
|
||||
items: {
|
||||
orderBy: { id: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
rawName: true,
|
||||
normalizedName: true,
|
||||
brand: true,
|
||||
categoryHint: true,
|
||||
categoryId: true,
|
||||
price: true,
|
||||
priceUnit: true,
|
||||
comparisonPrice: true,
|
||||
comparisonUnit: true,
|
||||
weight: true,
|
||||
bundleWeight: true,
|
||||
isBundle: true,
|
||||
bundleItems: true,
|
||||
offerText: true,
|
||||
parseConfidence: true,
|
||||
parseReasons: true,
|
||||
matchedProductId: true,
|
||||
matchedProductName: true,
|
||||
matchedVia: true,
|
||||
matchConfidence: true,
|
||||
matchReasons: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
throw new NotFoundException('AI-trace hittades inte.');
|
||||
}
|
||||
|
||||
const warnings = this.collectWarnings(session.items);
|
||||
const status = this.statusFromSession(session.items.length, warnings.length);
|
||||
|
||||
const normalizedOutput = {
|
||||
sessionId: session.id,
|
||||
source: 'flyer',
|
||||
sourceFileName: session.sourceFileName,
|
||||
sourceMimeType: session.sourceMimeType,
|
||||
sourceFileSize: session.sourceFileSize,
|
||||
itemCount: session.items.length,
|
||||
items: session.items.map((item) => ({
|
||||
id: item.id,
|
||||
rawName: item.rawName,
|
||||
normalizedName: item.normalizedName,
|
||||
brand: item.brand,
|
||||
categoryHint: item.categoryHint,
|
||||
categoryId: item.categoryId,
|
||||
price: item.price != null ? Number(item.price) : null,
|
||||
priceUnit: item.priceUnit,
|
||||
comparisonPrice: item.comparisonPrice != null ? Number(item.comparisonPrice) : null,
|
||||
comparisonUnit: item.comparisonUnit,
|
||||
weight: item.weight,
|
||||
bundleWeight: item.bundleWeight,
|
||||
isBundle: item.isBundle,
|
||||
bundleItems: Array.isArray(item.bundleItems) ? item.bundleItems : [],
|
||||
offerText: item.offerText,
|
||||
parseConfidence: item.parseConfidence,
|
||||
parseReasons: Array.isArray(item.parseReasons) ? item.parseReasons : [],
|
||||
matchedProductId: item.matchedProductId,
|
||||
matchedProductName: item.matchedProductName,
|
||||
matchedVia: item.matchedVia,
|
||||
matchConfidence: item.matchConfidence,
|
||||
matchReasons: Array.isArray(item.matchReasons) ? item.matchReasons : [],
|
||||
})),
|
||||
warnings,
|
||||
} as Record<string, unknown>;
|
||||
|
||||
return {
|
||||
id: this.flyerTraceId(session.id),
|
||||
source: 'flyer',
|
||||
status,
|
||||
createdAt: session.createdAt.toISOString(),
|
||||
userId: session.userId,
|
||||
userLabel: this.userLabel(session.user?.username, session.user?.email, session.userId),
|
||||
sessionId: session.id,
|
||||
fileName: session.sourceFileName,
|
||||
model: 'ministral-8b-2512',
|
||||
durationMs: null,
|
||||
retryCount: null,
|
||||
chunkCount: null,
|
||||
warnings,
|
||||
error: session.items.length === 0 ? 'Inga produkter kunde extraheras från flyern.' : null,
|
||||
prompt: null,
|
||||
rawOutput: JSON.stringify(this.maskSensitiveData(normalizedOutput)),
|
||||
normalizedOutput: this.maskSensitiveData(normalizedOutput),
|
||||
summary: {
|
||||
source: 'flyer',
|
||||
sessionId: session.id,
|
||||
itemCount: session.items.length,
|
||||
warningsCount: warnings.length,
|
||||
promptAvailable: false,
|
||||
outputAvailable: true,
|
||||
retentionHintDays: 30,
|
||||
maskedFields: AI_TRACE_MASK_FIELDS,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private statusFromSession(itemCount: number, warningsCount: number): AiTraceStatus {
|
||||
if (itemCount <= 0) return 'error';
|
||||
if (warningsCount > 0) return 'warning';
|
||||
return 'success';
|
||||
}
|
||||
|
||||
private maskSensitiveData(data: Record<string, unknown>): Record<string, unknown> {
|
||||
const clone = JSON.parse(JSON.stringify(data)) as Record<string, unknown>;
|
||||
return this.maskDeep(clone) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
private maskDeep(value: unknown): unknown {
|
||||
if (typeof value === 'string') {
|
||||
return this.maskSensitiveText(value);
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((entry) => this.maskDeep(entry));
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [key, nested] of Object.entries(value as Record<string, unknown>)) {
|
||||
const lowerKey = key.toLowerCase();
|
||||
if (AI_TRACE_MASK_FIELDS.some((field) => lowerKey.includes(field))) {
|
||||
out[key] = '[MASKED]';
|
||||
continue;
|
||||
}
|
||||
out[key] = this.maskDeep(nested);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private maskSensitiveText(value: string): string {
|
||||
return value
|
||||
.replace(EMAIL_REGEX, '[MASKED]')
|
||||
.replace(SWEDISH_PERSONAL_ID_REGEX, '[MASKED]')
|
||||
.replace(PHONE_REGEX, '[MASKED]');
|
||||
}
|
||||
|
||||
private maskRawOutput(rawOutput: string | null | undefined): string | null {
|
||||
if (typeof rawOutput !== 'string' || rawOutput.trim().length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(rawOutput);
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
const masked = this.maskDeep(parsed);
|
||||
return JSON.stringify(masked);
|
||||
}
|
||||
if (typeof parsed === 'string') {
|
||||
return this.maskSensitiveText(parsed);
|
||||
}
|
||||
return this.maskSensitiveText(String(parsed));
|
||||
} catch {
|
||||
return this.maskSensitiveText(rawOutput);
|
||||
}
|
||||
}
|
||||
|
||||
private parseCursor(cursor?: string): number | null {
|
||||
if (!cursor) return null;
|
||||
const value = Number.parseInt(cursor, 10);
|
||||
return Number.isFinite(value) && value > 0 ? value : null;
|
||||
}
|
||||
|
||||
private periodStart(period?: '24h' | '7d' | '30d'): Date | null {
|
||||
if (!period) return null;
|
||||
const now = Date.now();
|
||||
const map: Record<string, number> = {
|
||||
'24h': 24 * 60 * 60 * 1000,
|
||||
'7d': 7 * 24 * 60 * 60 * 1000,
|
||||
'30d': 30 * 24 * 60 * 60 * 1000,
|
||||
};
|
||||
const duration = map[period];
|
||||
if (!duration) return null;
|
||||
return new Date(now - duration);
|
||||
}
|
||||
|
||||
private parseTraceId(id: string): { source: AiTraceSource; numericId: number } {
|
||||
const trimmed = id.trim();
|
||||
if (trimmed.startsWith('flyer-')) {
|
||||
const value = Number.parseInt(trimmed.replace('flyer-', ''), 10);
|
||||
if (Number.isFinite(value) && value > 0) {
|
||||
return { source: 'flyer', numericId: value };
|
||||
}
|
||||
}
|
||||
if (trimmed.startsWith('receipt-')) {
|
||||
const value = Number.parseInt(trimmed.replace('receipt-', ''), 10);
|
||||
if (Number.isFinite(value) && value > 0) {
|
||||
return { source: 'receipt', numericId: value };
|
||||
}
|
||||
}
|
||||
throw new NotFoundException('AI-trace hittades inte.');
|
||||
}
|
||||
|
||||
private flyerTraceId(sessionId: number): string {
|
||||
return `flyer-${sessionId}`;
|
||||
}
|
||||
|
||||
private userLabel(username: string | null | undefined, email: string | null | undefined, userId: number): string {
|
||||
if (username && username.trim().length > 0) return username.trim();
|
||||
if (email && email.trim().length > 0) return email.trim();
|
||||
return `user:${userId}`;
|
||||
}
|
||||
|
||||
private collectWarnings(items: Array<{ parseReasons: unknown; matchReasons: unknown }>): string[] {
|
||||
const warnings = new Set<string>();
|
||||
for (const item of items) {
|
||||
if (Array.isArray(item.parseReasons)) {
|
||||
for (const reason of item.parseReasons) {
|
||||
const text = String(reason ?? '').trim();
|
||||
if (text.length > 0) warnings.add(`parse:${text}`);
|
||||
}
|
||||
}
|
||||
if (Array.isArray(item.matchReasons)) {
|
||||
for (const reason of item.matchReasons) {
|
||||
const text = String(reason ?? '').trim();
|
||||
if (text.length > 0) warnings.add(`match:${text}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return Array.from(warnings);
|
||||
}
|
||||
|
||||
private async listReceiptTraces(params: {
|
||||
source: AiTraceSource;
|
||||
limit: number;
|
||||
cursor?: string;
|
||||
period?: '24h' | '7d' | '30d';
|
||||
onlyErrors?: boolean;
|
||||
}): Promise<AiTraceListResponse> {
|
||||
const take = Math.max(1, Math.min(params.limit || 20, 100));
|
||||
const cursorId = this.parseCursor(params.cursor);
|
||||
const periodStart = this.periodStart(params.period);
|
||||
|
||||
const rows = await this.prisma.aiTrace.findMany({
|
||||
where: {
|
||||
source: 'receipt',
|
||||
...(periodStart ? { createdAt: { gte: periodStart } } : {}),
|
||||
...(cursorId ? { id: { lt: cursorId } } : {}),
|
||||
...(params.onlyErrors ? { status: 'error' } : {}),
|
||||
},
|
||||
orderBy: { id: 'desc' },
|
||||
take: take + 1,
|
||||
select: {
|
||||
id: true,
|
||||
source: true,
|
||||
status: true,
|
||||
createdAt: true,
|
||||
userId: true,
|
||||
sessionId: true,
|
||||
model: true,
|
||||
durationMs: true,
|
||||
error: true,
|
||||
prompt: true,
|
||||
rawOutput: true,
|
||||
user: { select: { username: true, email: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const hasMore = rows.length > take;
|
||||
const page = hasMore ? rows.slice(0, take) : rows;
|
||||
|
||||
return {
|
||||
items: page.map((row) => ({
|
||||
id: `receipt-${row.id}`,
|
||||
source: 'receipt',
|
||||
status: row.status === 'error' ? 'error' : row.status === 'warning' ? 'warning' : 'success',
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
userId: row.userId ?? 0,
|
||||
userLabel: this.userLabel(row.user?.username, row.user?.email, row.userId ?? 0),
|
||||
sessionId: row.sessionId,
|
||||
fileName: null,
|
||||
model: row.model,
|
||||
durationMs: row.durationMs,
|
||||
warningsCount: 0,
|
||||
hasPrompt: !!row.prompt,
|
||||
hasOutput: !!row.rawOutput,
|
||||
error: row.error,
|
||||
})),
|
||||
nextCursor: hasMore ? String(page[page.length - 1]?.id ?? '') : null,
|
||||
};
|
||||
}
|
||||
|
||||
private async getReceiptTraceById(traceId: number): Promise<AiTraceDetail> {
|
||||
const row = await this.prisma.aiTrace.findFirst({
|
||||
where: { id: traceId, source: 'receipt' },
|
||||
select: {
|
||||
id: true,
|
||||
source: true,
|
||||
status: true,
|
||||
createdAt: true,
|
||||
userId: true,
|
||||
sessionId: true,
|
||||
model: true,
|
||||
durationMs: true,
|
||||
error: true,
|
||||
prompt: true,
|
||||
rawOutput: true,
|
||||
normalizedOutput: true,
|
||||
user: { select: { username: true, email: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!row) {
|
||||
throw new NotFoundException('AI-trace hittades inte.');
|
||||
}
|
||||
|
||||
const normalizedOutput = row.normalizedOutput && typeof row.normalizedOutput === 'object'
|
||||
? this.maskSensitiveData(row.normalizedOutput as Record<string, unknown>)
|
||||
: null;
|
||||
|
||||
return {
|
||||
id: `receipt-${row.id}`,
|
||||
source: 'receipt',
|
||||
status: row.status === 'error' ? 'error' : row.status === 'warning' ? 'warning' : 'success',
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
userId: row.userId ?? 0,
|
||||
userLabel: this.userLabel(row.user?.username, row.user?.email, row.userId ?? 0),
|
||||
sessionId: row.sessionId,
|
||||
fileName: null,
|
||||
model: row.model,
|
||||
durationMs: row.durationMs,
|
||||
retryCount: null,
|
||||
chunkCount: null,
|
||||
warnings: [],
|
||||
error: row.error,
|
||||
prompt: row.prompt ? this.maskSensitiveText(row.prompt) : null,
|
||||
rawOutput: this.maskRawOutput(row.rawOutput),
|
||||
normalizedOutput,
|
||||
summary: {
|
||||
source: 'receipt',
|
||||
traceId: row.id,
|
||||
promptAvailable: !!row.prompt,
|
||||
outputAvailable: !!row.rawOutput || normalizedOutput != null,
|
||||
retentionHintDays: 30,
|
||||
maskedFields: AI_TRACE_MASK_FIELDS,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { Public } from '../auth/decorators/public.decorator';
|
||||
import { AI_CATEGORIZATION_MODEL } from './ai.service';
|
||||
import { Controller, Get, Param, ParseIntPipe, Query } from '@nestjs/common';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { Public } from '../auth/decorators/public.decorator';
|
||||
import { AI_CATEGORIZATION_MODEL } from './ai.service';
|
||||
import { AiTraceService } from './ai-trace.service';
|
||||
import { ListAiTracesQueryDto } from './dto/list-ai-traces.query.dto';
|
||||
|
||||
const RECEIPT_IMPORT_MODEL = 'mistral-small-2603';
|
||||
|
||||
@@ -15,10 +18,12 @@ export interface AiModelInfo {
|
||||
}
|
||||
|
||||
@Controller('ai')
|
||||
export class AiController {
|
||||
@Get('models')
|
||||
@Public()
|
||||
getModels(): AiModelInfo[] {
|
||||
export class AiController {
|
||||
constructor(private readonly aiTraceService: AiTraceService) {}
|
||||
|
||||
@Get('models')
|
||||
@Public()
|
||||
getModels(): AiModelInfo[] {
|
||||
return [
|
||||
{
|
||||
id: 'receipt-pdf',
|
||||
@@ -64,7 +69,31 @@ export class AiController {
|
||||
path: '/admin/products',
|
||||
trigger: 'Manuell — knappen "✨ AI-kategorisera okategoriserade"',
|
||||
access: 'Admin',
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@Roles('admin')
|
||||
@Get('traces')
|
||||
listTraces(@Query() query: ListAiTracesQueryDto) {
|
||||
return this.aiTraceService.listTraces({
|
||||
source: query.source ?? 'flyer',
|
||||
limit: query.limit ?? 20,
|
||||
cursor: query.cursor,
|
||||
period: query.period,
|
||||
onlyErrors: query.onlyErrors ?? false,
|
||||
});
|
||||
}
|
||||
|
||||
@Roles('admin')
|
||||
@Get('traces/:id')
|
||||
getTraceById(@Param('id') id: string) {
|
||||
return this.aiTraceService.getTraceById(id);
|
||||
}
|
||||
|
||||
@Roles('admin')
|
||||
@Get('receipt/traces/:id')
|
||||
getReceiptTraceById(@Param('id', ParseIntPipe) id: number) {
|
||||
return this.aiTraceService.getTraceById(`receipt-${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
+13
-10
@@ -1,10 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AiService } from './ai.service';
|
||||
import { AiController } from './ai.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [AiController],
|
||||
providers: [AiService],
|
||||
exports: [AiService],
|
||||
})
|
||||
export class AiModule {}
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AiService } from './ai.service';
|
||||
import { AiController } from './ai.controller';
|
||||
import { AiTraceService } from './ai-trace.service';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [AiController],
|
||||
providers: [AiService, AiTraceService],
|
||||
exports: [AiService],
|
||||
})
|
||||
export class AiModule {}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsBoolean, IsIn, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||
|
||||
export class ListAiTracesQueryDto {
|
||||
@IsOptional()
|
||||
@IsIn(['receipt', 'flyer'])
|
||||
source?: 'receipt' | 'flyer';
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => {
|
||||
if (value === undefined || value === null || value === '') return undefined;
|
||||
const parsed = Number.parseInt(String(value), 10);
|
||||
return Number.isFinite(parsed) ? parsed : value;
|
||||
})
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
limit?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
cursor?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['24h', '7d', '30d'])
|
||||
period?: '24h' | '7d' | '30d';
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => {
|
||||
if (typeof value === 'boolean') return value;
|
||||
const normalized = String(value ?? '').trim().toLowerCase();
|
||||
if (!normalized) return undefined;
|
||||
return ['1', 'true', 'yes', 'on'].includes(normalized);
|
||||
})
|
||||
@IsBoolean()
|
||||
onlyErrors?: boolean;
|
||||
}
|
||||
@@ -12,12 +12,13 @@ describe('ReceiptImportService parseReceipt flow', () => {
|
||||
cat(51, 'Godis', 'Glass, godis & snacks > Godis'),
|
||||
];
|
||||
|
||||
const prismaMock = {
|
||||
receiptAlias: { findMany: jest.fn() },
|
||||
product: { findMany: jest.fn() },
|
||||
unitMapping: { findMany: jest.fn() },
|
||||
user: { findUnique: jest.fn() },
|
||||
};
|
||||
const prismaMock = {
|
||||
aiTrace: { create: jest.fn() },
|
||||
receiptAlias: { findMany: jest.fn() },
|
||||
product: { findMany: jest.fn() },
|
||||
unitMapping: { findMany: jest.fn() },
|
||||
user: { findUnique: jest.fn() },
|
||||
};
|
||||
|
||||
const aiServiceMock = {
|
||||
suggestCategory: jest.fn(),
|
||||
@@ -80,14 +81,21 @@ describe('ReceiptImportService parseReceipt flow', () => {
|
||||
confidence: 'low',
|
||||
});
|
||||
|
||||
jest
|
||||
.spyOn(service as any, 'parseReceiptViaImporter')
|
||||
.mockResolvedValue([
|
||||
{ rawName: 'MIXAD VARA', quantity: 1, unit: 'st' },
|
||||
{ rawName: 'GLOBAL CHOKLAD', quantity: 1, unit: 'st' },
|
||||
{ rawName: 'SPECIALPRODUKT 1st', quantity: 1, unit: 'st' },
|
||||
{ rawName: 'helt okänd vara', quantity: 1, unit: 'st' },
|
||||
]);
|
||||
jest
|
||||
.spyOn(service as any, 'parseReceiptViaImporter')
|
||||
.mockResolvedValue({
|
||||
items: [
|
||||
{ rawName: 'MIXAD VARA', quantity: 1, unit: 'st' },
|
||||
{ rawName: 'GLOBAL CHOKLAD', quantity: 1, unit: 'st' },
|
||||
{ rawName: 'SPECIALPRODUKT 1st', quantity: 1, unit: 'st' },
|
||||
{ rawName: 'helt okänd vara', quantity: 1, unit: 'st' },
|
||||
],
|
||||
trace: {
|
||||
prompt: 'test prompt',
|
||||
rawOutput: '{"items":[]}',
|
||||
normalizedOutput: { items: [] },
|
||||
},
|
||||
});
|
||||
|
||||
const file = {
|
||||
buffer: Buffer.from('dummy'),
|
||||
|
||||
@@ -19,8 +19,10 @@ import {
|
||||
} from '../common/utils/receipt-alias';
|
||||
import { FlyerSelectionService } from '../flyer-selection/flyer-selection.service';
|
||||
|
||||
const IMPORTER_SERVICE_URL =
|
||||
process.env.IMPORTER_SERVICE_URL || 'http://importer-api:3001';
|
||||
const IMPORTER_SERVICE_URL =
|
||||
process.env.IMPORTER_SERVICE_URL || 'http://importer-api:3001';
|
||||
|
||||
const RECEIPT_IMPORT_MODEL = 'importer-receipt-ai';
|
||||
|
||||
const WEAK_DESCRIPTORS = new Set([
|
||||
'rokt',
|
||||
@@ -133,21 +135,63 @@ export class ReceiptImportService {
|
||||
private readonly flyerSelectionService: FlyerSelectionService,
|
||||
) {}
|
||||
|
||||
async parseReceipt(file: Express.Multer.File, _isPremium = false, userId?: number): Promise<ParsedReceiptItem[]> {
|
||||
// Steg 1: Delegera AI-parsning till microservice-importer
|
||||
const rawItems = await this.parseReceiptViaImporter(file);
|
||||
async parseReceipt(file: Express.Multer.File, _isPremium = false, userId?: number): Promise<ParsedReceiptItem[]> {
|
||||
const parseStartedAt = Date.now();
|
||||
let parseError: string | null = null;
|
||||
let tracePrompt: string | null = null;
|
||||
let traceRawOutput: string | null = null;
|
||||
let traceNormalizedOutput: Record<string, unknown> | null = null;
|
||||
|
||||
// Steg 1: Delegera AI-parsning till microservice-importer
|
||||
let rawItems: ParsedReceiptItem[];
|
||||
try {
|
||||
const importer = await this.parseReceiptViaImporter(file);
|
||||
rawItems = importer.items;
|
||||
tracePrompt = importer.trace.prompt;
|
||||
traceRawOutput = importer.trace.rawOutput;
|
||||
traceNormalizedOutput = importer.trace.normalizedOutput;
|
||||
} catch (err) {
|
||||
parseError = err instanceof Error ? err.message : String(err);
|
||||
await this.persistReceiptTrace({
|
||||
userId,
|
||||
model: RECEIPT_IMPORT_MODEL,
|
||||
prompt: tracePrompt,
|
||||
rawOutput: traceRawOutput,
|
||||
normalizedOutput: traceNormalizedOutput,
|
||||
status: 'error',
|
||||
error: parseError,
|
||||
durationMs: Date.now() - parseStartedAt,
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Steg 2 & 3: Unified matching + categorization
|
||||
// Samla context en gång för alla items
|
||||
const context = await this.prepareMatchingContext(userId);
|
||||
|
||||
// Mappa alla items genom unified matcher
|
||||
return Promise.all(
|
||||
rawItems.map((item) =>
|
||||
this.matchAndEnrichReceiptItem(item, context),
|
||||
),
|
||||
);
|
||||
}
|
||||
const parsedItems = await Promise.all(
|
||||
rawItems.map((item) => this.matchAndEnrichReceiptItem(item, context)),
|
||||
);
|
||||
|
||||
await this.persistReceiptTrace({
|
||||
userId,
|
||||
model: RECEIPT_IMPORT_MODEL,
|
||||
prompt: tracePrompt,
|
||||
rawOutput: traceRawOutput,
|
||||
normalizedOutput: {
|
||||
importer: traceNormalizedOutput,
|
||||
enrichedItems: parsedItems,
|
||||
},
|
||||
status: parsedItems.length == 0 ? 'error' : 'success',
|
||||
error: parsedItems.length == 0
|
||||
? 'Inga kvittorader kunde tolkas av importer-tjänsten.'
|
||||
: null,
|
||||
durationMs: Date.now() - parseStartedAt,
|
||||
});
|
||||
|
||||
return parsedItems;
|
||||
}
|
||||
|
||||
private async prepareMatchingContext(userId?: number): Promise<MatchingContext> {
|
||||
const prismaAny = this.prisma as any;
|
||||
@@ -573,7 +617,14 @@ export class ReceiptImportService {
|
||||
return response;
|
||||
}
|
||||
|
||||
private async parseReceiptViaImporter(file: Express.Multer.File): Promise<ParsedReceiptItem[]> {
|
||||
private async parseReceiptViaImporter(file: Express.Multer.File): Promise<{
|
||||
items: ParsedReceiptItem[];
|
||||
trace: {
|
||||
prompt: string | null;
|
||||
rawOutput: string | null;
|
||||
normalizedOutput: Record<string, unknown> | null;
|
||||
};
|
||||
}> {
|
||||
const form = new FormData();
|
||||
form.append(
|
||||
'file',
|
||||
@@ -608,9 +659,112 @@ export class ReceiptImportService {
|
||||
throw new BadRequestException(message);
|
||||
}
|
||||
|
||||
const items = (await response.json()) as ParsedReceiptItem[];
|
||||
return items.filter((item) => !isIgnoredReceiptName(item.rawName));
|
||||
}
|
||||
const body = (await response.json()) as
|
||||
| ParsedReceiptItem[]
|
||||
| {
|
||||
items?: ParsedReceiptItem[];
|
||||
prompt?: unknown;
|
||||
rawOutput?: unknown;
|
||||
normalizedOutput?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const normalizedItems = this.extractImporterItems(body)
|
||||
.filter((item) => !isIgnoredReceiptName(item.rawName));
|
||||
|
||||
return {
|
||||
items: normalizedItems,
|
||||
trace: {
|
||||
prompt: this.extractImporterPrompt(body),
|
||||
rawOutput: this.extractImporterRawOutput(body),
|
||||
normalizedOutput: this.extractImporterNormalizedOutput(body),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private extractImporterItems(
|
||||
body: ParsedReceiptItem[] | { items?: ParsedReceiptItem[] },
|
||||
): ParsedReceiptItem[] {
|
||||
if (Array.isArray(body)) return body;
|
||||
if (Array.isArray(body.items)) return body.items;
|
||||
return [];
|
||||
}
|
||||
|
||||
private extractImporterPrompt(
|
||||
body: ParsedReceiptItem[] | { prompt?: unknown },
|
||||
): string | null {
|
||||
if (Array.isArray(body)) return null;
|
||||
if (typeof body.prompt !== 'string') return null;
|
||||
const prompt = body.prompt.trim();
|
||||
return prompt && prompt.length > 0 ? prompt : null;
|
||||
}
|
||||
|
||||
private extractImporterRawOutput(
|
||||
body: ParsedReceiptItem[] | { rawOutput?: unknown },
|
||||
): string | null {
|
||||
if (Array.isArray(body)) return JSON.stringify(body);
|
||||
if (typeof body.rawOutput === 'string' && body.rawOutput.trim().length > 0) {
|
||||
return body.rawOutput;
|
||||
}
|
||||
if (body.rawOutput !== undefined) {
|
||||
try {
|
||||
return JSON.stringify(body.rawOutput);
|
||||
} catch {
|
||||
return String(body.rawOutput);
|
||||
}
|
||||
}
|
||||
return JSON.stringify(body);
|
||||
}
|
||||
|
||||
private extractImporterNormalizedOutput(
|
||||
body: ParsedReceiptItem[] | { normalizedOutput?: Record<string, unknown>; items?: ParsedReceiptItem[] },
|
||||
): Record<string, unknown> | null {
|
||||
if (Array.isArray(body)) {
|
||||
return { items: body };
|
||||
}
|
||||
if (body.normalizedOutput && typeof body.normalizedOutput === 'object') {
|
||||
return body.normalizedOutput;
|
||||
}
|
||||
if (Array.isArray(body.items)) {
|
||||
return { items: body.items };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private async persistReceiptTrace(params: {
|
||||
userId?: number;
|
||||
model: string;
|
||||
prompt: string | null;
|
||||
rawOutput: string | null;
|
||||
normalizedOutput: Record<string, unknown> | null;
|
||||
status: 'success' | 'error';
|
||||
error: string | null;
|
||||
durationMs: number;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
await this.prisma.aiTrace.create({
|
||||
data: {
|
||||
source: 'receipt',
|
||||
userId: params.userId,
|
||||
model: params.model,
|
||||
prompt: params.prompt,
|
||||
rawOutput: params.rawOutput,
|
||||
...(params.normalizedOutput == null
|
||||
? {}
|
||||
: {
|
||||
normalizedOutput:
|
||||
params.normalizedOutput as Prisma.InputJsonValue,
|
||||
}),
|
||||
status: params.status,
|
||||
error: params.error,
|
||||
durationMs: params.durationMs,
|
||||
},
|
||||
});
|
||||
} catch (traceErr) {
|
||||
this.logger.warn(
|
||||
`Kunde inte spara receipt AI-trace: ${traceErr instanceof Error ? traceErr.message : String(traceErr)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
// UNIFIED MATCHER: Kombinerar product matching + categorization
|
||||
|
||||
+133
-148
@@ -1,148 +1,133 @@
|
||||
Du är en senior utvecklare och säkerhetsexpert. Analysera alla commit-kandidater i detta fullstack-projekt (backend: NestJS + Prisma, frontend: Next.js/Flutter, databas: MariaDB).
|
||||
|
||||
Syfte:
|
||||
- Detta är en pre-commit quality gate som ska användas innan commit.
|
||||
- Ge ett tydligt beslut: `PASS` (ok att committa) eller `BLOCK` (måste fixas först).
|
||||
- Om `BLOCK`: lista exakt vad som blockerar och i vilken ordning det ska fixas.
|
||||
|
||||
Arbetsordning för filurval:
|
||||
1. Primärt: analysera alla staged filer.
|
||||
2. Om inga staged filer finns: analysera commit-kandidater i working tree (modified + untracked).
|
||||
3. Exkludera alltid irrelevanta filer: node_modules, .git, build/cache-artifacts, binärfiler, genererade filer som inte ska committas.
|
||||
|
||||
Inled rapporten med en kort Scope-sektion som anger:
|
||||
- Vilken urvalsregel som användes (staged eller commit-kandidater).
|
||||
- Exakt vilka filer som analyserades.
|
||||
- Vilka filer som exkluderades och varför.
|
||||
|
||||
Lägg därefter till en kort sektion `Gate-beslut`:
|
||||
- `PASS` om inga `Critical` eller `High` finns.
|
||||
- `BLOCK` om minst en `Critical` eller `High` finns.
|
||||
- Vid `BLOCK`, ge en kort checklista med konkreta fixar.
|
||||
|
||||
Ge en detaljerad rapport enligt följande struktur:
|
||||
|
||||
---
|
||||
### **1. Allmän kodkvalitet**
|
||||
|
||||
- **Läsbarhet/underhållbarhet** (kan blockera om allvarligt):
|
||||
- Finns det bristande namngivning (variabler, funktioner, klasser)?
|
||||
- Saknas kommentarer för komplex logik?
|
||||
- Kan modulariseringen förbättras (t.ex. splitta stora funktioner/klasser)?
|
||||
- Följs TypeScript-bäst-praxis (t.ex. starka typer, interfaces, SOLID-principer)?
|
||||
|
||||
---
|
||||
### **1b. Performance-optimeringar** (INFORMATIONAL)
|
||||
|
||||
Dessa rapporteras men blockerar inte commit. Kan adresseras i senare iteration:
|
||||
|
||||
- **Algoritm-effektivitet**:
|
||||
- Finns det O(n²) eller värre algoritmer som kan vara O(n)?
|
||||
- Finns onödig kod (död kod, duplicerad logik)?
|
||||
|
||||
- **Resurser**:
|
||||
- Kan minne eller CPU-användning reduceras (t.ex. undvika djupa kopior, använda streams)?
|
||||
- Kan loopar eller databaserfrågor (Prisma) optimeras (t.ex. med caching, batch-behandling)?
|
||||
- Finns N+1-frågor eller ineffektiva `include/select`-mönster?
|
||||
|
||||
**Severity**: `Low` eller `Medium` beroende på påverkan. Blockerar aldrig commit.
|
||||
|
||||
---
|
||||
### **2. Säkerhetsanalys**
|
||||
- **Sårbarheter**:
|
||||
- Finns det risk för SQL-injection (Prisma), XSS, CSRF, eller insecure deserialization?
|
||||
- Används osäkra bibliotek (t.ex. föråldrade versioner av `axios`, `lodash`, `express`)?
|
||||
- Finns det hårdkodade lösenord, API-nycklar eller tokens?
|
||||
- Saknas input-validering (t.ex. för filupp laddningar, användarinmatning)?
|
||||
|
||||
- **Autentisering/auktorisation**:
|
||||
- Finns det brister i JWT-hantering (t.ex. svaga algoritmer, saknade `exp`-fält)?
|
||||
- Används HTTP istället för HTTPS?
|
||||
- Saknas rate limiting för känsliga endpoints?
|
||||
|
||||
- **Datahantering**:
|
||||
- Lagras känslig data (t.ex. lösenord) i klartext?
|
||||
- Finns det loggning av känslig data?
|
||||
- Används säkra krypteringsmetoder (t.ex. AES-256, bcrypt)?
|
||||
|
||||
---
|
||||
### **2b. Backend-specifik kontroll (NestJS + Prisma)**
|
||||
- **API-kontrakt och validering**:
|
||||
- Kontrollera DTO-validering (`class-validator`) på indata till controllers.
|
||||
- Kontrollera att controllers inte accepterar osanerad payload direkt till service/Prisma.
|
||||
- Kontrollera att felhantering använder korrekta HTTP-statuskoder (inte generiska 500/400 i onödan).
|
||||
|
||||
- **Auktorisation och scope**:
|
||||
- Kontrollera att user-scope upprätthålls i queries/mutationer (ingen IDOR).
|
||||
- Kontrollera att admin-endpoints skyddas med rätt guards/roller.
|
||||
- Kontrollera att privata resurser inte kan nås via andras ID.
|
||||
|
||||
- **Prisma och dataintegritet**:
|
||||
- Kontrollera att `where`-villkor inkluderar rätt scope (t.ex. `userId`) där det krävs.
|
||||
- Kontrollera transaction-användning vid multipla skrivoperationer.
|
||||
- Kontrollera risk för N+1-frågor och föreslå `include/select`-optimering där relevant.
|
||||
|
||||
- **Drift och robusthet**:
|
||||
- Kontrollera rate limiting/throttling på känsliga endpoints.
|
||||
- Kontrollera att loggar inte exponerar tokens, lösenord eller fulla stacktraces i produktion.
|
||||
- Kontrollera timeout/retry-strategi vid anrop till externa tjänster.
|
||||
|
||||
---
|
||||
### **3. Sammanfattning**
|
||||
- **Topp 6 kritiska åtgärder** (prioriterade efter risk/vinst).
|
||||
- **Uppskattad tid** för att implementera förslagen.
|
||||
- **Rekommenderade verktyg** för automatiserade kontroller (t.ex. `ESLint`, `Prisma Lint`, `OWASP Dependency-Check`).
|
||||
|
||||
---
|
||||
### **Klassificering av fynd (Severity)**
|
||||
|
||||
**BLOCKING** (hindrar commit):
|
||||
- `Critical`: Säkerhetshål, scope-brister (IDOR), SQL-injection, XSS, eller data-loss risk.
|
||||
- `High`: Allvarlig korrektness-fel, felaktig autentisering/auktorisation, eller felaktig felhantering som påverkar produktion.
|
||||
|
||||
**INFORMATIONAL** (rapporteras, men blockerar inte):
|
||||
- `Medium`: Code-quality, läsbarhet, testluckor, eller mindre performance-optimeringar.
|
||||
- `Low`: Stilfrågor, dokumentation, eller nice-to-have refactor.
|
||||
|
||||
**Regel**: Gate-beslut = `PASS` om inga `Critical` eller `High` finns. `BLOCK` annars.
|
||||
|
||||
---
|
||||
### **Regler för analysen**
|
||||
- Var **specifik**: Ge **kod-exempel** för varje förslag.
|
||||
- Var **praktisk**: Fokusera på **realistiska förbättringar** som kan implementeras nu.
|
||||
- Var **kritisk**: Peka ut **allvarliga risker** (t.ex. säkerhetshål) först.
|
||||
- Använd **severity** per fynd enligt klassificering ovan: `Critical`, `High`, `Medium`, `Low`.
|
||||
- För varje fynd: ange fil, kort riskbeskrivning, varför det är ett problem, severity, och konkret åtgärd.
|
||||
- **Separa fynd efter severity**: Listet först alla `Critical`/`High` (blocking), sedan `Medium`/`Low` (informational).
|
||||
- Om inga allvarliga risker hittas: skriv det explicit och lyft kvarvarande risker/testluckor.
|
||||
- Ignorera filer som inte är relevanta (t.ex. node_modules, .git, binärfiler).
|
||||
- Prioritera körbarhet: föreslagna åtgärder ska kunna göras i denna kodbas utan större arkitekturprojekt.
|
||||
- Undvik generiska råd. Allt ska vara kopplat till faktisk kod i scope.
|
||||
- När både frontend och backend finns i scope: dela upp fynd per delsystem.
|
||||
- Om endast backendfiler finns i scope: lägg huvudfokus på sektion **2b** och prioritera säkerhet/scope före stilfrågor.
|
||||
|
||||
---
|
||||
### **Outputformat (obligatoriskt)**
|
||||
1. `Scope`
|
||||
2. `Gate-beslut` (`PASS` eller `BLOCK`)
|
||||
3. `1. Allmän kodkvalitet` (blocking issues)
|
||||
4. `1b. Performance-optimeringar` (informational)
|
||||
5. `2. Säkerhetsanalys` (blocking issues)
|
||||
6. `2b. Backend-specifik kontroll` (blocking + informational)
|
||||
7. `3. Sammanfattning` (topprioriteringar, tidskattning)
|
||||
|
||||
Om inga relevanta filer hittas:
|
||||
- Skriv `Inget att analysera` och varför (t.ex. tom staged + tom working tree).
|
||||
- Föreslå nästa konkreta steg (t.ex. stagea filer och kör prompten igen).
|
||||
|
||||
---
|
||||
### **Kontext för projektet**
|
||||
- **Backend**: NestJS + Prisma + MariaDB (Docker-container).
|
||||
- **Frontend**: Next.js + TypeScript + Flutter (kan förekomma i samma repo).
|
||||
- **Mål**: Förbereda för produktion, minska teknisk skuld, säkra känslig data.
|
||||
|
||||
---
|
||||
### **CI-koppling**
|
||||
- Denna prompt är främst ett lokalt pre-commit-steg.
|
||||
- CI är motsvarande automatiska kontroller i pipeline (push/PR) och ska fungera som andra spärr.
|
||||
- Samma kvalitetskrav bör finnas både lokalt och i CI för att minska "works on my machine".
|
||||
Du är en senior utvecklare och säkerhetsexpert. Analysera commit-kandidater i detta fullstack-projekt (backend: NestJS + Prisma, frontend: Next.js/Flutter, databas: MariaDB).
|
||||
|
||||
Syfte:
|
||||
- Detta är en pre-commit quality gate innan commit.
|
||||
- Leverera ett tydligt gate-beslut: `PASS`, `PASS_WITH_WARNINGS` eller `BLOCK`.
|
||||
- Vid `BLOCK`: lista exakta blockerare och fixordning.
|
||||
|
||||
---
|
||||
## 0. Deterministiska gate-regler (källa till sanning)
|
||||
|
||||
### 0.1 Filurval (delta-first)
|
||||
1. Primärt: analysera alla staged filer.
|
||||
2. Om inga staged filer finns: analysera commit-kandidater i working tree (modified + untracked).
|
||||
3. Exkludera alltid: `node_modules`, `.git`, build/cache-artifacts, binärfiler, genererade filer som inte ska committas.
|
||||
4. Fokusera blockerande bedömning på förändrad kod (delta). Legacy-problem i opåverkade delar rapporteras som teknisk skuld (ej blockerande i denna gate).
|
||||
|
||||
### 0.2 Severity och beslut
|
||||
- **Critical**: säkerhetshål/scope-brist med hög impact (t.ex. IDOR, auth bypass, PII-läckage, injection).
|
||||
- **High**: allvarlig korrektness-/driftsrisk i produktion.
|
||||
- **Medium/Low**: informativa förbättringar (blockerar inte).
|
||||
|
||||
**Beslutslogik (deterministisk):**
|
||||
- `BLOCK` om minst 1 `Critical`.
|
||||
- `BLOCK` om 2 eller fler `High`.
|
||||
- `PASS_WITH_WARNINGS` om exakt 1 `High` utan `Critical`.
|
||||
- `PASS` om inga `Critical`/`High`.
|
||||
|
||||
### 0.3 Evidenskrav för blockerande fynd
|
||||
Varje `Critical`/`High` måste ha:
|
||||
- `Evidence`: `code`, `test`, eller `runtime`.
|
||||
- Fil + radreferens.
|
||||
- Konkreta fixsteg.
|
||||
|
||||
Fynd med endast antagande märks `Needs verification` och får inte ensamt orsaka `BLOCK`, om inte risken är uppenbart kritisk.
|
||||
|
||||
### 0.4 Stop-early-regel (effektivitet)
|
||||
- Vid första tydliga `Critical`: sätt preliminärt `BLOCK`, identifiera max 3 ytterligare blockerare, avsluta sedan djupanalys.
|
||||
|
||||
### 0.5 Rapportbudget
|
||||
- Rapportera max 5 informativa fynd (`Medium/Low`), prioriterade efter högst nytta/lägst kostnad.
|
||||
|
||||
---
|
||||
## 1. Analysfokus
|
||||
|
||||
### 1.1 Allmän kodkvalitet
|
||||
- Läsbarhet/underhållbarhet: namngivning, modularisering, komplexitet.
|
||||
- TypeScript/Flutter best practices.
|
||||
- Kommentarer för icke-obvious logik.
|
||||
|
||||
### 1.2 Performance-optimeringar (informational)
|
||||
- Algoritmisk effektivitet.
|
||||
- Onödiga kopior/serialiseringar.
|
||||
- Databasfrågor, N+1-risk, ineffektiva `include/select`.
|
||||
|
||||
### 1.3 Säkerhetsanalys
|
||||
- Injection/XSS/CSRF/insecure deserialization.
|
||||
- Inputvalidering och filuppladdningar.
|
||||
- Secrets i kod/loggar.
|
||||
- Känslig data i klartext eller otillräckligt maskerad.
|
||||
|
||||
### 1.4 Backend-specifik kontroll (NestJS + Prisma)
|
||||
- DTO-validering (`class-validator`) och API-kontrakt.
|
||||
- Auktorisation/scope (IDOR-skydd, admin-guards).
|
||||
- Prisma-scope i `where`, transaktioner vid multipla writes.
|
||||
- Timeout/retry/rate limiting och robust felhantering.
|
||||
|
||||
---
|
||||
## 2. Krav på varje fynd
|
||||
Använd följande mall:
|
||||
- **Severity**: `Critical|High|Medium|Low`
|
||||
- **Evidence**: `code|test|runtime|Needs verification`
|
||||
- **Delsystem**: `backend|frontend|db|infra`
|
||||
- **Fil**: `<path:line>`
|
||||
- **Risk**: kort riskbeskrivning
|
||||
- **Varför**: varför detta är ett problem
|
||||
- **Åtgärd**: konkret, realistisk fix
|
||||
- **Verifiering**: kommando/test för att bekräfta fix
|
||||
|
||||
Blocking-fynd (`Critical/High`) listas först, därefter informational (`Medium/Low`).
|
||||
|
||||
---
|
||||
## 3. Obligatoriskt outputformat
|
||||
Returnera exakt i denna ordning:
|
||||
|
||||
1. `Scope`
|
||||
- Urvalsregel: `staged` eller `working-tree`
|
||||
- Analyserade filer (exakt lista)
|
||||
- Exkluderade filer (med orsak)
|
||||
|
||||
2. `Gate-beslut`
|
||||
- `PASS|PASS_WITH_WARNINGS|BLOCK`
|
||||
- Antal per severity: `Critical`, `High`, `Medium`, `Low`
|
||||
- Kort motivering
|
||||
|
||||
3. `Blocking Findings (Critical/High)`
|
||||
- Om inga finns: skriv `Inga blockerande fynd`.
|
||||
|
||||
4. `Informational Findings (Medium/Low)`
|
||||
- Max 5 fynd.
|
||||
|
||||
5. `Fixplan (vid BLOCK eller PASS_WITH_WARNINGS)`
|
||||
- Numrerad ordning, konkreta steg.
|
||||
|
||||
6. `Sammanfattning`
|
||||
- Topp 3 åtgärder efter risk/vinst
|
||||
- Tidsestimat
|
||||
- Rekommenderade automatiserade kontroller
|
||||
|
||||
---
|
||||
## 4. Konsistenskontroller (måste uppfyllas)
|
||||
- Om `Gate-beslut=PASS` får inga `Critical/High` listas.
|
||||
- Om `Gate-beslut=BLOCK` måste `Fixplan` innehålla minst 1 konkret blockerande åtgärd.
|
||||
- Om `PASS_WITH_WARNINGS` används måste exakt 1 `High` finnas och 0 `Critical`.
|
||||
|
||||
---
|
||||
## 5. Fallback: inget att analysera
|
||||
Om inga relevanta filer hittas:
|
||||
- Skriv `Inget att analysera` och varför (t.ex. tom staged + tom working tree).
|
||||
- Ge nästa konkreta steg:
|
||||
- `git add <filer>`
|
||||
- `git diff --cached --name-only`
|
||||
- Kör analysen igen.
|
||||
|
||||
---
|
||||
## 6. Kontext för projektet
|
||||
- Backend: NestJS + Prisma + MariaDB (Docker).
|
||||
- Frontend: Next.js + TypeScript + Flutter.
|
||||
- Mål: produktion, låg teknisk skuld, säkrad hantering av känslig data.
|
||||
|
||||
---
|
||||
## 7. CI-koppling
|
||||
- Detta är lokalt pre-commit-steg.
|
||||
- Samma kvalitetskrav bör speglas i CI (push/PR) för att minska miljöskillnader.
|
||||
|
||||
@@ -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)}';
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import '../domain/admin_category_node.dart';
|
||||
import '../domain/admin_pantry_item.dart';
|
||||
import '../domain/admin_inventory_item.dart';
|
||||
import '../domain/admin_product.dart';
|
||||
import '../domain/admin_ai_trace.dart';
|
||||
import '../domain/admin_ai_trace_detail.dart';
|
||||
import '../domain/ai_model_info.dart';
|
||||
import '../domain/pending_product.dart';
|
||||
import '../domain/receipt_alias.dart';
|
||||
@@ -145,7 +147,8 @@ class AdminRepository {
|
||||
(data['data'] as List<dynamic>?) ??
|
||||
const [];
|
||||
if (raw.isEmpty && data.isNotEmpty) {
|
||||
debugPrint('[AdminRepository] Unexpected API wrapper shape: ${data.keys}');
|
||||
debugPrint(
|
||||
'[AdminRepository] Unexpected API wrapper shape: ${data.keys}');
|
||||
}
|
||||
} else {
|
||||
raw = const [];
|
||||
@@ -172,7 +175,8 @@ class AdminRepository {
|
||||
Future<UserAdmin> setRecipeSharing(int userId,
|
||||
{required bool canShareRecipes}) =>
|
||||
_patch(UserApiPaths.setRecipeSharing(userId),
|
||||
body: {'canShareRecipes': canShareRecipes}, parse: UserAdmin.fromJson);
|
||||
body: {'canShareRecipes': canShareRecipes},
|
||||
parse: UserAdmin.fromJson);
|
||||
|
||||
Future<void> updateEmail(int userId, String email) =>
|
||||
_patchVoid(UserApiPaths.updateEmail(userId), {'email': email});
|
||||
@@ -194,7 +198,8 @@ class AdminRepository {
|
||||
parse: (d) => UserAdmin.fromJson(d as Map<String, dynamic>),
|
||||
);
|
||||
|
||||
Future<void> deleteUser(int userId) => _deleteVoid(UserApiPaths.delete(userId));
|
||||
Future<void> deleteUser(int userId) =>
|
||||
_deleteVoid(UserApiPaths.delete(userId));
|
||||
|
||||
/// Returns `{ temporaryPassword, to, subject, body }`.
|
||||
Future<Map<String, dynamic>> resetPassword(int userId) =>
|
||||
@@ -203,7 +208,8 @@ class AdminRepository {
|
||||
// ── Produkter ──────────────────────────────────────────────────────────────
|
||||
|
||||
Future<List<AdminProduct>> listProducts() =>
|
||||
_getList(ProductApiPaths.list, AdminProduct.fromJson, requiresAuth: false);
|
||||
_getList(ProductApiPaths.list, AdminProduct.fromJson,
|
||||
requiresAuth: false);
|
||||
|
||||
@Deprecated('Use listProducts(). Kept for temporary compatibility.')
|
||||
Future<List<AdminProduct>> listGlobalProducts() => listProducts();
|
||||
@@ -249,7 +255,8 @@ class AdminRepository {
|
||||
|
||||
final list = merged.values.toList();
|
||||
list.sort(
|
||||
(a, b) => a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase()),
|
||||
(a, b) =>
|
||||
a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase()),
|
||||
);
|
||||
_selectableProductsCache = List<AdminProduct>.from(list);
|
||||
_selectableProductsCacheAt = now;
|
||||
@@ -263,7 +270,8 @@ class AdminRepository {
|
||||
_getList(ProductApiPaths.pending, PendingProduct.fromJson);
|
||||
|
||||
Future<void> setProductStatus(int productId, String status) =>
|
||||
_patchVoid(ProductApiPaths.setStatus(productId), {'status': status}).then((_) {
|
||||
_patchVoid(ProductApiPaths.setStatus(productId), {'status': status})
|
||||
.then((_) {
|
||||
_invalidateSelectableProductsCache();
|
||||
});
|
||||
|
||||
@@ -271,14 +279,16 @@ class AdminRepository {
|
||||
_post<AdminProduct>(
|
||||
ProductApiPaths.promotePrivate(productId),
|
||||
body: null,
|
||||
parse: (d) => AdminProduct.fromJson(Map<String, dynamic>.from(d as Map)),
|
||||
parse: (d) =>
|
||||
AdminProduct.fromJson(Map<String, dynamic>.from(d as Map)),
|
||||
).then((value) {
|
||||
_invalidateSelectableProductsCache();
|
||||
return value;
|
||||
});
|
||||
|
||||
Future<void> setProductCategory(int productId, {required int? categoryId}) =>
|
||||
_patchVoid(ProductApiPaths.update(productId), {'categoryId': categoryId}).then((_) {
|
||||
_patchVoid(ProductApiPaths.update(productId), {'categoryId': categoryId})
|
||||
.then((_) {
|
||||
_invalidateSelectableProductsCache();
|
||||
});
|
||||
|
||||
@@ -301,7 +311,8 @@ class AdminRepository {
|
||||
_invalidateSelectableProductsCache();
|
||||
});
|
||||
|
||||
Future<void> updateCanonicalNamePrivate(int productId, String canonicalName) =>
|
||||
Future<void> updateCanonicalNamePrivate(
|
||||
int productId, String canonicalName) =>
|
||||
_patchVoid(
|
||||
ProductApiPaths.canonicalNamePrivate(productId),
|
||||
{'canonicalName': canonicalName.trim()},
|
||||
@@ -336,7 +347,8 @@ class AdminRepository {
|
||||
|
||||
int _parseUpdatedCount(dynamic data) {
|
||||
if (data is! Map) {
|
||||
debugPrint('[AdminRepository] bulkSetCategory unexpected response type: ${data.runtimeType}');
|
||||
debugPrint(
|
||||
'[AdminRepository] bulkSetCategory unexpected response type: ${data.runtimeType}');
|
||||
return 0;
|
||||
}
|
||||
final map = Map<String, dynamic>.from(data);
|
||||
@@ -391,8 +403,7 @@ class AdminRepository {
|
||||
|
||||
// ── Kategorier ─────────────────────────────────────────────────────────────
|
||||
|
||||
Future<List<AdminCategoryNode>> listCategoryTree() =>
|
||||
_getList(
|
||||
Future<List<AdminCategoryNode>> listCategoryTree() => _getList(
|
||||
CategoryApiPaths.tree,
|
||||
AdminCategoryNode.fromJson,
|
||||
requiresAuth: false,
|
||||
@@ -404,6 +415,26 @@ class AdminRepository {
|
||||
Future<List<AiModelInfo>> listAiModels() =>
|
||||
_getList(AiApiPaths.models, AiModelInfo.fromJson);
|
||||
|
||||
Future<AdminAiTraceListResponse> listAiTraces({
|
||||
required AdminAiTraceSource source,
|
||||
int limit = 25,
|
||||
String? cursor,
|
||||
String? period,
|
||||
bool onlyErrors = false,
|
||||
}) =>
|
||||
_getMap(
|
||||
AiApiPaths.traces(
|
||||
source: source.apiValue,
|
||||
limit: limit,
|
||||
cursor: cursor,
|
||||
period: period,
|
||||
onlyErrors: onlyErrors,
|
||||
),
|
||||
).then(AdminAiTraceListResponse.fromJson);
|
||||
|
||||
Future<AdminAiTraceDetail> getAiTraceById(String traceId) =>
|
||||
_getMap(AiApiPaths.traceById(traceId)).then(AdminAiTraceDetail.fromJson);
|
||||
|
||||
// ── Kvittoalias (admin/global fallback) ───────────────────────────────────
|
||||
|
||||
Future<List<ReceiptAlias>> listReceiptAliases() =>
|
||||
@@ -543,7 +574,8 @@ class AdminRepository {
|
||||
if (location != null && location.trim().isNotEmpty)
|
||||
'location': location.trim(),
|
||||
},
|
||||
parse: (d) => AdminPantryItem.fromJson(Map<String, dynamic>.from(d as Map)),
|
||||
parse: (d) =>
|
||||
AdminPantryItem.fromJson(Map<String, dynamic>.from(d as Map)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -582,6 +614,7 @@ class AdminRepository {
|
||||
required int targetInventoryId,
|
||||
}) =>
|
||||
_getMap(
|
||||
AdminInventoryApiPaths.mergePreview(sourceInventoryId, targetInventoryId),
|
||||
AdminInventoryApiPaths.mergePreview(
|
||||
sourceInventoryId, targetInventoryId),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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