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[]
|
flyerSessions FlyerSession[]
|
||||||
flyerSelections FlyerSelection[]
|
flyerSelections FlyerSelection[]
|
||||||
shoppingListItems ShoppingListItem[]
|
shoppingListItems ShoppingListItem[]
|
||||||
|
aiTraces AiTrace[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model Product {
|
model Product {
|
||||||
@@ -388,3 +389,25 @@ model ShoppingListItem {
|
|||||||
@@index([productId, unit, status])
|
@@index([productId, unit, status])
|
||||||
@@index([categoryId])
|
@@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 { Controller, Get, Param, ParseIntPipe, Query } from '@nestjs/common';
|
||||||
|
import { Roles } from '../auth/decorators/roles.decorator';
|
||||||
import { Public } from '../auth/decorators/public.decorator';
|
import { Public } from '../auth/decorators/public.decorator';
|
||||||
import { AI_CATEGORIZATION_MODEL } from './ai.service';
|
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';
|
const RECEIPT_IMPORT_MODEL = 'mistral-small-2603';
|
||||||
|
|
||||||
@@ -16,6 +19,8 @@ export interface AiModelInfo {
|
|||||||
|
|
||||||
@Controller('ai')
|
@Controller('ai')
|
||||||
export class AiController {
|
export class AiController {
|
||||||
|
constructor(private readonly aiTraceService: AiTraceService) {}
|
||||||
|
|
||||||
@Get('models')
|
@Get('models')
|
||||||
@Public()
|
@Public()
|
||||||
getModels(): AiModelInfo[] {
|
getModels(): AiModelInfo[] {
|
||||||
@@ -67,4 +72,28 @@ export class AiController {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { AiService } from './ai.service';
|
import { AiService } from './ai.service';
|
||||||
import { AiController } from './ai.controller';
|
import { AiController } from './ai.controller';
|
||||||
|
import { AiTraceService } from './ai-trace.service';
|
||||||
|
import { PrismaModule } from '../prisma/prisma.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [PrismaModule],
|
||||||
controllers: [AiController],
|
controllers: [AiController],
|
||||||
providers: [AiService],
|
providers: [AiService, AiTraceService],
|
||||||
exports: [AiService],
|
exports: [AiService],
|
||||||
})
|
})
|
||||||
export class AiModule {}
|
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;
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ describe('ReceiptImportService parseReceipt flow', () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const prismaMock = {
|
const prismaMock = {
|
||||||
|
aiTrace: { create: jest.fn() },
|
||||||
receiptAlias: { findMany: jest.fn() },
|
receiptAlias: { findMany: jest.fn() },
|
||||||
product: { findMany: jest.fn() },
|
product: { findMany: jest.fn() },
|
||||||
unitMapping: { findMany: jest.fn() },
|
unitMapping: { findMany: jest.fn() },
|
||||||
@@ -82,12 +83,19 @@ describe('ReceiptImportService parseReceipt flow', () => {
|
|||||||
|
|
||||||
jest
|
jest
|
||||||
.spyOn(service as any, 'parseReceiptViaImporter')
|
.spyOn(service as any, 'parseReceiptViaImporter')
|
||||||
.mockResolvedValue([
|
.mockResolvedValue({
|
||||||
{ rawName: 'MIXAD VARA', quantity: 1, unit: 'st' },
|
items: [
|
||||||
{ rawName: 'GLOBAL CHOKLAD', quantity: 1, unit: 'st' },
|
{ rawName: 'MIXAD VARA', quantity: 1, unit: 'st' },
|
||||||
{ rawName: 'SPECIALPRODUKT 1st', quantity: 1, unit: 'st' },
|
{ rawName: 'GLOBAL CHOKLAD', quantity: 1, unit: 'st' },
|
||||||
{ rawName: 'helt okänd vara', 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 = {
|
const file = {
|
||||||
buffer: Buffer.from('dummy'),
|
buffer: Buffer.from('dummy'),
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ import { FlyerSelectionService } from '../flyer-selection/flyer-selection.servic
|
|||||||
const IMPORTER_SERVICE_URL =
|
const IMPORTER_SERVICE_URL =
|
||||||
process.env.IMPORTER_SERVICE_URL || 'http://importer-api:3001';
|
process.env.IMPORTER_SERVICE_URL || 'http://importer-api:3001';
|
||||||
|
|
||||||
|
const RECEIPT_IMPORT_MODEL = 'importer-receipt-ai';
|
||||||
|
|
||||||
const WEAK_DESCRIPTORS = new Set([
|
const WEAK_DESCRIPTORS = new Set([
|
||||||
'rokt',
|
'rokt',
|
||||||
'rökt',
|
'rökt',
|
||||||
@@ -134,19 +136,61 @@ export class ReceiptImportService {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
async parseReceipt(file: Express.Multer.File, _isPremium = false, userId?: number): Promise<ParsedReceiptItem[]> {
|
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
|
// Steg 1: Delegera AI-parsning till microservice-importer
|
||||||
const rawItems = await this.parseReceiptViaImporter(file);
|
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
|
// Steg 2 & 3: Unified matching + categorization
|
||||||
// Samla context en gång för alla items
|
// Samla context en gång för alla items
|
||||||
const context = await this.prepareMatchingContext(userId);
|
const context = await this.prepareMatchingContext(userId);
|
||||||
|
|
||||||
// Mappa alla items genom unified matcher
|
// Mappa alla items genom unified matcher
|
||||||
return Promise.all(
|
const parsedItems = await Promise.all(
|
||||||
rawItems.map((item) =>
|
rawItems.map((item) => this.matchAndEnrichReceiptItem(item, context)),
|
||||||
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> {
|
private async prepareMatchingContext(userId?: number): Promise<MatchingContext> {
|
||||||
@@ -573,7 +617,14 @@ export class ReceiptImportService {
|
|||||||
return response;
|
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();
|
const form = new FormData();
|
||||||
form.append(
|
form.append(
|
||||||
'file',
|
'file',
|
||||||
@@ -608,8 +659,111 @@ export class ReceiptImportService {
|
|||||||
throw new BadRequestException(message);
|
throw new BadRequestException(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
const items = (await response.json()) as ParsedReceiptItem[];
|
const body = (await response.json()) as
|
||||||
return items.filter((item) => !isIgnoredReceiptName(item.rawName));
|
| 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)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
|||||||
+108
-123
@@ -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).
|
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:
|
Syfte:
|
||||||
- Detta är en pre-commit quality gate som ska användas innan commit.
|
- Detta är en pre-commit quality gate innan commit.
|
||||||
- Ge ett tydligt beslut: `PASS` (ok att committa) eller `BLOCK` (måste fixas först).
|
- Leverera ett tydligt gate-beslut: `PASS`, `PASS_WITH_WARNINGS` eller `BLOCK`.
|
||||||
- Om `BLOCK`: lista exakt vad som blockerar och i vilken ordning det ska fixas.
|
- Vid `BLOCK`: lista exakta blockerare och fixordning.
|
||||||
|
|
||||||
Arbetsordning för filurval:
|
---
|
||||||
|
## 0. Deterministiska gate-regler (källa till sanning)
|
||||||
|
|
||||||
|
### 0.1 Filurval (delta-first)
|
||||||
1. Primärt: analysera alla staged filer.
|
1. Primärt: analysera alla staged filer.
|
||||||
2. Om inga staged filer finns: analysera commit-kandidater i working tree (modified + untracked).
|
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.
|
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).
|
||||||
|
|
||||||
Inled rapporten med en kort Scope-sektion som anger:
|
### 0.2 Severity och beslut
|
||||||
- Vilken urvalsregel som användes (staged eller commit-kandidater).
|
- **Critical**: säkerhetshål/scope-brist med hög impact (t.ex. IDOR, auth bypass, PII-läckage, injection).
|
||||||
- Exakt vilka filer som analyserades.
|
- **High**: allvarlig korrektness-/driftsrisk i produktion.
|
||||||
- Vilka filer som exkluderades och varför.
|
- **Medium/Low**: informativa förbättringar (blockerar inte).
|
||||||
|
|
||||||
Lägg därefter till en kort sektion `Gate-beslut`:
|
**Beslutslogik (deterministisk):**
|
||||||
- `PASS` om inga `Critical` eller `High` finns.
|
- `BLOCK` om minst 1 `Critical`.
|
||||||
- `BLOCK` om minst en `Critical` eller `High` finns.
|
- `BLOCK` om 2 eller fler `High`.
|
||||||
- Vid `BLOCK`, ge en kort checklista med konkreta fixar.
|
- `PASS_WITH_WARNINGS` om exakt 1 `High` utan `Critical`.
|
||||||
|
- `PASS` om inga `Critical`/`High`.
|
||||||
|
|
||||||
Ge en detaljerad rapport enligt följande struktur:
|
### 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. Allmän kodkvalitet**
|
## 1. Analysfokus
|
||||||
|
|
||||||
- **Läsbarhet/underhållbarhet** (kan blockera om allvarligt):
|
### 1.1 Allmän kodkvalitet
|
||||||
- Finns det bristande namngivning (variabler, funktioner, klasser)?
|
- Läsbarhet/underhållbarhet: namngivning, modularisering, komplexitet.
|
||||||
- Saknas kommentarer för komplex logik?
|
- TypeScript/Flutter best practices.
|
||||||
- Kan modulariseringen förbättras (t.ex. splitta stora funktioner/klasser)?
|
- Kommentarer för icke-obvious logik.
|
||||||
- Följs TypeScript-bäst-praxis (t.ex. starka typer, interfaces, SOLID-principer)?
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
### **1b. Performance-optimeringar** (INFORMATIONAL)
|
## 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
|
||||||
|
|
||||||
Dessa rapporteras men blockerar inte commit. Kan adresseras i senare iteration:
|
Blocking-fynd (`Critical/High`) listas först, därefter informational (`Medium/Low`).
|
||||||
|
|
||||||
- **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**
|
## 3. Obligatoriskt outputformat
|
||||||
- **Sårbarheter**:
|
Returnera exakt i denna ordning:
|
||||||
- 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`
|
1. `Scope`
|
||||||
2. `Gate-beslut` (`PASS` eller `BLOCK`)
|
- Urvalsregel: `staged` eller `working-tree`
|
||||||
3. `1. Allmän kodkvalitet` (blocking issues)
|
- Analyserade filer (exakt lista)
|
||||||
4. `1b. Performance-optimeringar` (informational)
|
- Exkluderade filer (med orsak)
|
||||||
5. `2. Säkerhetsanalys` (blocking issues)
|
|
||||||
6. `2b. Backend-specifik kontroll` (blocking + informational)
|
|
||||||
7. `3. Sammanfattning` (topprioriteringar, tidskattning)
|
|
||||||
|
|
||||||
|
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:
|
Om inga relevanta filer hittas:
|
||||||
- Skriv `Inget att analysera` och varför (t.ex. tom staged + tom working tree).
|
- 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).
|
- Ge nästa konkreta steg:
|
||||||
|
- `git add <filer>`
|
||||||
|
- `git diff --cached --name-only`
|
||||||
|
- Kör analysen igen.
|
||||||
|
|
||||||
---
|
---
|
||||||
### **Kontext för projektet**
|
## 6. Kontext för projektet
|
||||||
- **Backend**: NestJS + Prisma + MariaDB (Docker-container).
|
- Backend: NestJS + Prisma + MariaDB (Docker).
|
||||||
- **Frontend**: Next.js + TypeScript + Flutter (kan förekomma i samma repo).
|
- Frontend: Next.js + TypeScript + Flutter.
|
||||||
- **Mål**: Förbereda för produktion, minska teknisk skuld, säkra känslig data.
|
- Mål: produktion, låg teknisk skuld, säkrad hantering av känslig data.
|
||||||
|
|
||||||
---
|
---
|
||||||
### **CI-koppling**
|
## 7. CI-koppling
|
||||||
- Denna prompt är främst ett lokalt pre-commit-steg.
|
- Detta är lokalt pre-commit-steg.
|
||||||
- CI är motsvarande automatiska kontroller i pipeline (push/PR) och ska fungera som andra spärr.
|
- Samma kvalitetskrav bör speglas i CI (push/PR) för att minska miljöskillnader.
|
||||||
- Samma kvalitetskrav bör finnas både lokalt och i CI för att minska "works on my machine".
|
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ class ProductApiPaths {
|
|||||||
static String setStatus(int id) => '/products/$id/status';
|
static String setStatus(int id) => '/products/$id/status';
|
||||||
static String update(int id) => '/products/$id';
|
static String update(int id) => '/products/$id';
|
||||||
static String canonicalName(int id) => '/products/$id/canonical-name';
|
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 remove(int id) => '/products/$id';
|
||||||
static String restore(int id) => '/products/$id/restore';
|
static String restore(int id) => '/products/$id/restore';
|
||||||
static const bulkUpdate = '/products/bulk-update';
|
static const bulkUpdate = '/products/bulk-update';
|
||||||
@@ -28,6 +29,27 @@ class ProductApiPaths {
|
|||||||
|
|
||||||
class AiApiPaths {
|
class AiApiPaths {
|
||||||
static const models = '/ai/models';
|
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 {
|
class CategoryApiPaths {
|
||||||
@@ -43,13 +65,17 @@ class FlyerImportApiPaths {
|
|||||||
static const parse = '/flyer-import/parse';
|
static const parse = '/flyer-import/parse';
|
||||||
static const latestSession = '/flyer-import/sessions/latest';
|
static const latestSession = '/flyer-import/sessions/latest';
|
||||||
static String bySession(int sessionId) => '/flyer-import/sessions/$sessionId';
|
static String bySession(int sessionId) => '/flyer-import/sessions/$sessionId';
|
||||||
static String sourceBySession(int sessionId) => '/flyer-import/sessions/$sessionId/source';
|
static String sourceBySession(int sessionId) =>
|
||||||
static String patchItem(int sessionId, int itemId) => '/flyer-import/sessions/$sessionId/items/$itemId';
|
'/flyer-import/sessions/$sessionId/source';
|
||||||
|
static String patchItem(int sessionId, int itemId) =>
|
||||||
|
'/flyer-import/sessions/$sessionId/items/$itemId';
|
||||||
}
|
}
|
||||||
|
|
||||||
class FlyerSelectionApiPaths {
|
class FlyerSelectionApiPaths {
|
||||||
static String bySession(int sessionId) => '/flyer-sessions/$sessionId/selections';
|
static String bySession(int sessionId) =>
|
||||||
static String bulkBySession(int sessionId) => '/flyer-sessions/$sessionId/selections/bulk';
|
'/flyer-sessions/$sessionId/selections';
|
||||||
|
static String bulkBySession(int sessionId) =>
|
||||||
|
'/flyer-sessions/$sessionId/selections/bulk';
|
||||||
static String planToShoppingListBySession(int sessionId) =>
|
static String planToShoppingListBySession(int sessionId) =>
|
||||||
'/flyer-sessions/$sessionId/selections/plan-to-shopping-list';
|
'/flyer-sessions/$sessionId/selections/plan-to-shopping-list';
|
||||||
static const open = '/flyer-selections/open';
|
static const open = '/flyer-selections/open';
|
||||||
@@ -57,7 +83,8 @@ class FlyerSelectionApiPaths {
|
|||||||
|
|
||||||
class ShoppingListApiPaths {
|
class ShoppingListApiPaths {
|
||||||
static const items = '/shopping-list/items';
|
static const items = '/shopping-list/items';
|
||||||
static String updateStatus(int itemId) => '/shopping-list/items/$itemId/status';
|
static String updateStatus(int itemId) =>
|
||||||
|
'/shopping-list/items/$itemId/status';
|
||||||
}
|
}
|
||||||
|
|
||||||
class HelpTextApiPaths {
|
class HelpTextApiPaths {
|
||||||
@@ -77,7 +104,8 @@ class RecipeApiPaths {
|
|||||||
static String remove(int id) => '/recipes/$id';
|
static String remove(int id) => '/recipes/$id';
|
||||||
static String setVisibility(int id) => '/recipes/$id/visibility';
|
static String setVisibility(int id) => '/recipes/$id/visibility';
|
||||||
static String share(int id) => '/recipes/$id/share';
|
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 inventoryPreview(int id) => '/recipes/$id/inventory-preview';
|
||||||
static String analysis(int id) => '/recipes/$id/analysis';
|
static String analysis(int id) => '/recipes/$id/analysis';
|
||||||
static String rematch(int id) => '/recipes/$id/rematch';
|
static String rematch(int id) => '/recipes/$id/rematch';
|
||||||
@@ -92,9 +120,11 @@ class InventoryApiPaths {
|
|||||||
static String update(int id) => '/inventory/$id';
|
static String update(int id) => '/inventory/$id';
|
||||||
static String remove(int id) => '/inventory/$id';
|
static String remove(int id) => '/inventory/$id';
|
||||||
static String moveToPantry(int id) => '/inventory/$id/move-to-pantry';
|
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 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 {
|
class AdminInventoryApiPaths {
|
||||||
@@ -105,10 +135,12 @@ class AdminInventoryApiPaths {
|
|||||||
if (sort != null && sort.isNotEmpty) params['sort'] = sort;
|
if (sort != null && sort.isNotEmpty) params['sort'] = sort;
|
||||||
if (params.isEmpty) return list;
|
if (params.isEmpty) return list;
|
||||||
final query = params.entries
|
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('&');
|
.join('&');
|
||||||
return '$list?$query';
|
return '$list?$query';
|
||||||
}
|
}
|
||||||
|
|
||||||
static String update(int id) => '/inventory/admin/$id';
|
static String update(int id) => '/inventory/admin/$id';
|
||||||
static String remove(int id) => '/inventory/admin/$id';
|
static String remove(int id) => '/inventory/admin/$id';
|
||||||
static String moveToPantry(int id) => '/inventory/admin/$id/move-to-pantry';
|
static String moveToPantry(int id) => '/inventory/admin/$id/move-to-pantry';
|
||||||
@@ -121,7 +153,8 @@ class PantryApiPaths {
|
|||||||
static const list = '/pantry';
|
static const list = '/pantry';
|
||||||
static String remove(int id) => '/pantry/$id';
|
static String remove(int id) => '/pantry/$id';
|
||||||
static String moveToInventory(int id) => '/pantry/$id/move-to-inventory';
|
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 adminList = '/pantry/admin';
|
||||||
static const adminCreate = '/pantry/admin';
|
static const adminCreate = '/pantry/admin';
|
||||||
static String adminUpdate(int id) => '/pantry/admin/$id';
|
static String adminUpdate(int id) => '/pantry/admin/$id';
|
||||||
@@ -143,14 +176,14 @@ class MealPlanApiPaths {
|
|||||||
static const list = '/meal-plan';
|
static const list = '/meal-plan';
|
||||||
|
|
||||||
static String listByRange(String from, String to) =>
|
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) =>
|
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) =>
|
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) =>
|
static String removeByDate(String date) =>
|
||||||
'$list/${Uri.encodeComponent(date)}';
|
'$list/${Uri.encodeComponent(date)}';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,8 +101,8 @@ class AppShell extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final isRecipesRoute = location.startsWith('/recipes') &&
|
final isRecipesRoute =
|
||||||
!location.startsWith('/recipes/');
|
location.startsWith('/recipes') && !location.startsWith('/recipes/');
|
||||||
final isImportRoute = location == '/import';
|
final isImportRoute = location == '/import';
|
||||||
final isAdminRoute = location.startsWith('/admin');
|
final isAdminRoute = location.startsWith('/admin');
|
||||||
final adminTab = AdminViewTabX.fromQuery(
|
final adminTab = AdminViewTabX.fromQuery(
|
||||||
@@ -133,6 +133,12 @@ class AppShell extends ConsumerWidget {
|
|||||||
selected: adminTab == AdminViewTab.database,
|
selected: adminTab == AdminViewTab.database,
|
||||||
onSelected: (_) => navigateToAdminTab(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(
|
Widget shell = Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: isAdminRoute ? buildAdminTitle() : Text(selectedDestination.title),
|
title:
|
||||||
|
isAdminRoute ? buildAdminTitle() : Text(selectedDestination.title),
|
||||||
bottom: isImportRoute
|
bottom: isImportRoute
|
||||||
? const TabBar(
|
? const TabBar(
|
||||||
tabs: [
|
tabs: [
|
||||||
@@ -184,8 +191,9 @@ class AppShell extends ConsumerWidget {
|
|||||||
PopupMenuButton<int>(
|
PopupMenuButton<int>(
|
||||||
icon: const Icon(Icons.grid_view),
|
icon: const Icon(Icons.grid_view),
|
||||||
tooltip: 'Välj antal kolumner',
|
tooltip: 'Välj antal kolumner',
|
||||||
onSelected: (columns) =>
|
onSelected: (columns) => ref
|
||||||
ref.read(recipesViewProvider.notifier).setColumns(columns),
|
.read(recipesViewProvider.notifier)
|
||||||
|
.setColumns(columns),
|
||||||
itemBuilder: (context) => const [
|
itemBuilder: (context) => const [
|
||||||
PopupMenuItem(value: 2, child: Text('2 kolumner')),
|
PopupMenuItem(value: 2, child: Text('2 kolumner')),
|
||||||
PopupMenuItem(value: 4, child: Text('4 kolumner')),
|
PopupMenuItem(value: 4, child: Text('4 kolumner')),
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import '../domain/admin_category_node.dart';
|
|||||||
import '../domain/admin_pantry_item.dart';
|
import '../domain/admin_pantry_item.dart';
|
||||||
import '../domain/admin_inventory_item.dart';
|
import '../domain/admin_inventory_item.dart';
|
||||||
import '../domain/admin_product.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/ai_model_info.dart';
|
||||||
import '../domain/pending_product.dart';
|
import '../domain/pending_product.dart';
|
||||||
import '../domain/receipt_alias.dart';
|
import '../domain/receipt_alias.dart';
|
||||||
@@ -145,7 +147,8 @@ class AdminRepository {
|
|||||||
(data['data'] as List<dynamic>?) ??
|
(data['data'] as List<dynamic>?) ??
|
||||||
const [];
|
const [];
|
||||||
if (raw.isEmpty && data.isNotEmpty) {
|
if (raw.isEmpty && data.isNotEmpty) {
|
||||||
debugPrint('[AdminRepository] Unexpected API wrapper shape: ${data.keys}');
|
debugPrint(
|
||||||
|
'[AdminRepository] Unexpected API wrapper shape: ${data.keys}');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
raw = const [];
|
raw = const [];
|
||||||
@@ -172,7 +175,8 @@ class AdminRepository {
|
|||||||
Future<UserAdmin> setRecipeSharing(int userId,
|
Future<UserAdmin> setRecipeSharing(int userId,
|
||||||
{required bool canShareRecipes}) =>
|
{required bool canShareRecipes}) =>
|
||||||
_patch(UserApiPaths.setRecipeSharing(userId),
|
_patch(UserApiPaths.setRecipeSharing(userId),
|
||||||
body: {'canShareRecipes': canShareRecipes}, parse: UserAdmin.fromJson);
|
body: {'canShareRecipes': canShareRecipes},
|
||||||
|
parse: UserAdmin.fromJson);
|
||||||
|
|
||||||
Future<void> updateEmail(int userId, String email) =>
|
Future<void> updateEmail(int userId, String email) =>
|
||||||
_patchVoid(UserApiPaths.updateEmail(userId), {'email': email});
|
_patchVoid(UserApiPaths.updateEmail(userId), {'email': email});
|
||||||
@@ -194,7 +198,8 @@ class AdminRepository {
|
|||||||
parse: (d) => UserAdmin.fromJson(d as Map<String, dynamic>),
|
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 }`.
|
/// Returns `{ temporaryPassword, to, subject, body }`.
|
||||||
Future<Map<String, dynamic>> resetPassword(int userId) =>
|
Future<Map<String, dynamic>> resetPassword(int userId) =>
|
||||||
@@ -203,7 +208,8 @@ class AdminRepository {
|
|||||||
// ── Produkter ──────────────────────────────────────────────────────────────
|
// ── Produkter ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
Future<List<AdminProduct>> listProducts() =>
|
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.')
|
@Deprecated('Use listProducts(). Kept for temporary compatibility.')
|
||||||
Future<List<AdminProduct>> listGlobalProducts() => listProducts();
|
Future<List<AdminProduct>> listGlobalProducts() => listProducts();
|
||||||
@@ -249,7 +255,8 @@ class AdminRepository {
|
|||||||
|
|
||||||
final list = merged.values.toList();
|
final list = merged.values.toList();
|
||||||
list.sort(
|
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);
|
_selectableProductsCache = List<AdminProduct>.from(list);
|
||||||
_selectableProductsCacheAt = now;
|
_selectableProductsCacheAt = now;
|
||||||
@@ -263,7 +270,8 @@ class AdminRepository {
|
|||||||
_getList(ProductApiPaths.pending, PendingProduct.fromJson);
|
_getList(ProductApiPaths.pending, PendingProduct.fromJson);
|
||||||
|
|
||||||
Future<void> setProductStatus(int productId, String status) =>
|
Future<void> setProductStatus(int productId, String status) =>
|
||||||
_patchVoid(ProductApiPaths.setStatus(productId), {'status': status}).then((_) {
|
_patchVoid(ProductApiPaths.setStatus(productId), {'status': status})
|
||||||
|
.then((_) {
|
||||||
_invalidateSelectableProductsCache();
|
_invalidateSelectableProductsCache();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -271,14 +279,16 @@ class AdminRepository {
|
|||||||
_post<AdminProduct>(
|
_post<AdminProduct>(
|
||||||
ProductApiPaths.promotePrivate(productId),
|
ProductApiPaths.promotePrivate(productId),
|
||||||
body: null,
|
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) {
|
).then((value) {
|
||||||
_invalidateSelectableProductsCache();
|
_invalidateSelectableProductsCache();
|
||||||
return value;
|
return value;
|
||||||
});
|
});
|
||||||
|
|
||||||
Future<void> setProductCategory(int productId, {required int? categoryId}) =>
|
Future<void> setProductCategory(int productId, {required int? categoryId}) =>
|
||||||
_patchVoid(ProductApiPaths.update(productId), {'categoryId': categoryId}).then((_) {
|
_patchVoid(ProductApiPaths.update(productId), {'categoryId': categoryId})
|
||||||
|
.then((_) {
|
||||||
_invalidateSelectableProductsCache();
|
_invalidateSelectableProductsCache();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -301,7 +311,8 @@ class AdminRepository {
|
|||||||
_invalidateSelectableProductsCache();
|
_invalidateSelectableProductsCache();
|
||||||
});
|
});
|
||||||
|
|
||||||
Future<void> updateCanonicalNamePrivate(int productId, String canonicalName) =>
|
Future<void> updateCanonicalNamePrivate(
|
||||||
|
int productId, String canonicalName) =>
|
||||||
_patchVoid(
|
_patchVoid(
|
||||||
ProductApiPaths.canonicalNamePrivate(productId),
|
ProductApiPaths.canonicalNamePrivate(productId),
|
||||||
{'canonicalName': canonicalName.trim()},
|
{'canonicalName': canonicalName.trim()},
|
||||||
@@ -336,7 +347,8 @@ class AdminRepository {
|
|||||||
|
|
||||||
int _parseUpdatedCount(dynamic data) {
|
int _parseUpdatedCount(dynamic data) {
|
||||||
if (data is! Map) {
|
if (data is! Map) {
|
||||||
debugPrint('[AdminRepository] bulkSetCategory unexpected response type: ${data.runtimeType}');
|
debugPrint(
|
||||||
|
'[AdminRepository] bulkSetCategory unexpected response type: ${data.runtimeType}');
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
final map = Map<String, dynamic>.from(data);
|
final map = Map<String, dynamic>.from(data);
|
||||||
@@ -391,8 +403,7 @@ class AdminRepository {
|
|||||||
|
|
||||||
// ── Kategorier ─────────────────────────────────────────────────────────────
|
// ── Kategorier ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
Future<List<AdminCategoryNode>> listCategoryTree() =>
|
Future<List<AdminCategoryNode>> listCategoryTree() => _getList(
|
||||||
_getList(
|
|
||||||
CategoryApiPaths.tree,
|
CategoryApiPaths.tree,
|
||||||
AdminCategoryNode.fromJson,
|
AdminCategoryNode.fromJson,
|
||||||
requiresAuth: false,
|
requiresAuth: false,
|
||||||
@@ -404,6 +415,26 @@ class AdminRepository {
|
|||||||
Future<List<AiModelInfo>> listAiModels() =>
|
Future<List<AiModelInfo>> listAiModels() =>
|
||||||
_getList(AiApiPaths.models, AiModelInfo.fromJson);
|
_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) ───────────────────────────────────
|
// ── Kvittoalias (admin/global fallback) ───────────────────────────────────
|
||||||
|
|
||||||
Future<List<ReceiptAlias>> listReceiptAliases() =>
|
Future<List<ReceiptAlias>> listReceiptAliases() =>
|
||||||
@@ -543,7 +574,8 @@ class AdminRepository {
|
|||||||
if (location != null && location.trim().isNotEmpty)
|
if (location != null && location.trim().isNotEmpty)
|
||||||
'location': location.trim(),
|
'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,
|
required int targetInventoryId,
|
||||||
}) =>
|
}) =>
|
||||||
_getMap(
|
_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,10 +1,13 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../../../core/api/api_error_mapper.dart';
|
import '../../../core/api/api_error_mapper.dart';
|
||||||
import '../../../core/l10n/l10n.dart';
|
|
||||||
import '../data/admin_repository.dart';
|
import '../data/admin_repository.dart';
|
||||||
import '../domain/ai_model_info.dart';
|
import '../domain/admin_ai_trace.dart';
|
||||||
|
import '../domain/admin_ai_trace_detail.dart';
|
||||||
|
|
||||||
class AdminAiPanel extends ConsumerStatefulWidget {
|
class AdminAiPanel extends ConsumerStatefulWidget {
|
||||||
final bool embedded;
|
final bool embedded;
|
||||||
@@ -18,7 +21,19 @@ class AdminAiPanel extends ConsumerStatefulWidget {
|
|||||||
class _AdminAiPanelState extends ConsumerState<AdminAiPanel> {
|
class _AdminAiPanelState extends ConsumerState<AdminAiPanel> {
|
||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
String? _error;
|
String? _error;
|
||||||
List<AiModelInfo> _models = [];
|
|
||||||
|
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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -32,108 +47,491 @@ class _AdminAiPanelState extends ConsumerState<AdminAiPanel> {
|
|||||||
_error = null;
|
_error = null;
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
final models = await ref.read(adminRepositoryProvider).listAiModels();
|
final response = await ref.read(adminRepositoryProvider).listAiTraces(
|
||||||
|
source: _source,
|
||||||
|
limit: 30,
|
||||||
|
period: _period,
|
||||||
|
onlyErrors: _onlyErrors,
|
||||||
|
);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() => _models = models);
|
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) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() => _error = mapErrorToUserMessage(e, context));
|
setState(() => _error = mapErrorToUserMessage(e, context));
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setState(() => _isLoading = false);
|
if (mounted) {
|
||||||
|
setState(() => _isLoading = false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Color _chipColor(String value, ColorScheme scheme) {
|
Future<void> _loadMore() async {
|
||||||
final lower = value.toLowerCase();
|
if (_nextCursor == null || _nextCursor!.isEmpty) return;
|
||||||
if (lower.contains('admin')) return scheme.primaryContainer;
|
try {
|
||||||
if (lower.contains('premium')) return scheme.tertiaryContainer;
|
final response = await ref.read(adminRepositoryProvider).listAiTraces(
|
||||||
return scheme.secondaryContainer;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
|
||||||
if (_isLoading) return const Center(child: CircularProgressIndicator());
|
if (_isLoading) return const Center(child: CircularProgressIndicator());
|
||||||
if (_error != null) {
|
if (_error != null) {
|
||||||
return buildCopyableErrorPanel(
|
return buildCopyableErrorPanel(
|
||||||
context: context,
|
context: context,
|
||||||
message: _error!,
|
message: _error!,
|
||||||
onRetry: _load,
|
onRetry: _load,
|
||||||
title: 'Kunde inte läsa AI-modeller',
|
title: 'Kunde inte läsa AI-spårning',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Column(
|
final content = LayoutBuilder(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
builder: (context, constraints) {
|
||||||
children: [
|
final isWide = constraints.maxWidth >= 980;
|
||||||
Card(
|
final listPane = _buildTraceList();
|
||||||
child: Padding(
|
final detailPane = _buildTraceDetail();
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
child: Column(
|
if (isWide) {
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
return Row(
|
||||||
children: [
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
Text('AI', style: theme.textTheme.titleMedium),
|
children: [
|
||||||
const SizedBox(height: 8),
|
Expanded(flex: 2, child: listPane),
|
||||||
Text(
|
const SizedBox(width: 12),
|
||||||
context.l10n.adminAiDescription,
|
Expanded(flex: 3, child: detailPane),
|
||||||
style: theme.textTheme.bodyMedium,
|
],
|
||||||
),
|
);
|
||||||
const SizedBox(height: 8),
|
}
|
||||||
const Wrap(
|
|
||||||
spacing: 8,
|
return Column(
|
||||||
runSpacing: 8,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Chip(label: Text('Models')),
|
SizedBox(height: 260, child: listPane),
|
||||||
Chip(label: Text('Access')),
|
const SizedBox(height: 12),
|
||||||
Chip(label: Text('Trigger')),
|
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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
);
|
||||||
if (_models.isEmpty)
|
}
|
||||||
Card(
|
|
||||||
child: Padding(
|
return Card(
|
||||||
padding: const EdgeInsets.all(16),
|
child: Column(
|
||||||
child: Text(
|
children: [
|
||||||
'Inga AI-modeller hittades.',
|
Expanded(
|
||||||
style: theme.textTheme.bodyMedium,
|
child: ListView.separated(
|
||||||
),
|
itemCount: _items.length,
|
||||||
),
|
separatorBuilder: (_, __) => const Divider(height: 1),
|
||||||
),
|
itemBuilder: (context, index) {
|
||||||
..._models.map(
|
final item = _items[index];
|
||||||
(model) => Card(
|
final selected = item.id == _selectedId;
|
||||||
child: Padding(
|
return ListTile(
|
||||||
padding: const EdgeInsets.all(16),
|
selected: selected,
|
||||||
child: Column(
|
onTap: () {
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
setState(() {
|
||||||
children: [
|
_selectedId = item.id;
|
||||||
Text(model.name, style: theme.textTheme.titleMedium),
|
_promptExpanded = false;
|
||||||
const SizedBox(height: 8),
|
});
|
||||||
Text(model.description),
|
_loadDetail(item.id);
|
||||||
const SizedBox(height: 12),
|
},
|
||||||
Wrap(
|
title: Text(item.fileName ?? item.id),
|
||||||
spacing: 8,
|
subtitle: Text(
|
||||||
runSpacing: 8,
|
'${_formatDateTime(item.createdAt)} • ${item.userLabel}'),
|
||||||
children: [
|
trailing: Chip(
|
||||||
Chip(label: Text(model.model)),
|
label: Text(item.status.label),
|
||||||
Chip(
|
labelStyle: TextStyle(
|
||||||
label: Text(model.access),
|
color: _statusColor(item.status, theme.colorScheme)),
|
||||||
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),
|
},
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
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/api/api_error_mapper.dart';
|
||||||
import '../../../core/l10n/l10n.dart';
|
import '../../../core/l10n/l10n.dart';
|
||||||
import '../../../core/realtime/realtime_sync.dart';
|
import '../../../core/realtime/realtime_sync.dart';
|
||||||
import 'admin_ai_panel.dart';
|
|
||||||
import 'admin_aliases_panel.dart';
|
import 'admin_aliases_panel.dart';
|
||||||
import 'admin_inventory_panel.dart';
|
import 'admin_inventory_panel.dart';
|
||||||
import 'admin_pantry_panel.dart';
|
import 'admin_pantry_panel.dart';
|
||||||
@@ -14,7 +13,14 @@ import 'admin_pending_products_panel.dart';
|
|||||||
import 'admin_products_panel.dart';
|
import 'admin_products_panel.dart';
|
||||||
import '../../profile/data/profile_repository.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 {
|
class _DatabaseTabConfig {
|
||||||
final _DatabaseTab tab;
|
final _DatabaseTab tab;
|
||||||
@@ -98,11 +104,6 @@ class _AdminDatabasePanelState extends ConsumerState<AdminDatabasePanel> {
|
|||||||
title: 'Alias',
|
title: 'Alias',
|
||||||
panel: const AdminAliasesPanel(embedded: true),
|
panel: const AdminAliasesPanel(embedded: true),
|
||||||
),
|
),
|
||||||
_DatabaseTabConfig(
|
|
||||||
tab: _DatabaseTab.ai,
|
|
||||||
title: 'AI',
|
|
||||||
panel: const AdminAiPanel(embedded: true),
|
|
||||||
),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
Future<void> _refreshCategories() async {
|
Future<void> _refreshCategories() async {
|
||||||
@@ -125,7 +126,8 @@ class _AdminDatabasePanelState extends ConsumerState<AdminDatabasePanel> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final currentTab = _tabConfigs.firstWhere((config) => config.tab == _activeTab);
|
final currentTab =
|
||||||
|
_tabConfigs.firstWhere((config) => config.tab == _activeTab);
|
||||||
|
|
||||||
final header = Card(
|
final header = Card(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@@ -146,7 +148,8 @@ class _AdminDatabasePanelState extends ConsumerState<AdminDatabasePanel> {
|
|||||||
child: ChoiceChip(
|
child: ChoiceChip(
|
||||||
label: Text(config.title),
|
label: Text(config.title),
|
||||||
selected: _activeTab == config.tab,
|
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),
|
const SizedBox(width: 8),
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: 'Uppdatera kategorier',
|
tooltip: 'Uppdatera kategorier',
|
||||||
onPressed: _isRefreshingCategories ? null : _refreshCategories,
|
onPressed:
|
||||||
|
_isRefreshingCategories ? null : _refreshCategories,
|
||||||
icon: _isRefreshingCategories
|
icon: _isRefreshingCategories
|
||||||
? const SizedBox(
|
? const SizedBox(
|
||||||
height: 16,
|
height: 16,
|
||||||
@@ -183,7 +187,8 @@ class _AdminDatabasePanelState extends ConsumerState<AdminDatabasePanel> {
|
|||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: KeyedSubtree(
|
child: KeyedSubtree(
|
||||||
key: ValueKey('admin-db-${_activeTab.name}-$_panelRefreshVersion'),
|
key:
|
||||||
|
ValueKey('admin-db-${_activeTab.name}-$_panelRefreshVersion'),
|
||||||
child: currentTab.panel,
|
child: currentTab.panel,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -192,4 +197,3 @@ class _AdminDatabasePanelState extends ConsumerState<AdminDatabasePanel> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,24 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'admin_ai_panel.dart';
|
||||||
import 'admin_database_panel.dart';
|
import 'admin_database_panel.dart';
|
||||||
import 'admin_users_panel.dart';
|
import 'admin_users_panel.dart';
|
||||||
|
|
||||||
enum AdminViewTab { users, database }
|
enum AdminViewTab { users, database, ai }
|
||||||
|
|
||||||
extension AdminViewTabX on AdminViewTab {
|
extension AdminViewTabX on AdminViewTab {
|
||||||
static AdminViewTab fromQuery(String? value) {
|
static AdminViewTab fromQuery(String? value) {
|
||||||
return switch (value) {
|
return switch (value) {
|
||||||
'database' => AdminViewTab.database,
|
'database' => AdminViewTab.database,
|
||||||
|
'ai' => AdminViewTab.ai,
|
||||||
_ => AdminViewTab.users,
|
_ => AdminViewTab.users,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
String get queryValue => this == AdminViewTab.database ? 'database' : 'users';
|
String get queryValue => switch (this) {
|
||||||
|
AdminViewTab.users => 'users',
|
||||||
|
AdminViewTab.database => 'database',
|
||||||
|
AdminViewTab.ai => 'ai',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
class AdminScreen extends StatelessWidget {
|
class AdminScreen extends StatelessWidget {
|
||||||
@@ -25,6 +31,7 @@ class AdminScreen extends StatelessWidget {
|
|||||||
final activePanel = switch (initialTab) {
|
final activePanel = switch (initialTab) {
|
||||||
AdminViewTab.users => const AdminUsersPanel(embedded: true),
|
AdminViewTab.users => const AdminUsersPanel(embedded: true),
|
||||||
AdminViewTab.database => const AdminDatabasePanel(embedded: true),
|
AdminViewTab.database => const AdminDatabasePanel(embedded: true),
|
||||||
|
AdminViewTab.ai => const AdminAiPanel(embedded: true),
|
||||||
};
|
};
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
@@ -33,4 +40,3 @@ class AdminScreen extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:recipe_flutter/features/admin/data/admin_repository.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_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_category_node.dart';
|
||||||
import 'package:recipe_flutter/features/admin/domain/admin_inventory_item.dart';
|
import 'package:recipe_flutter/features/admin/domain/admin_inventory_item.dart';
|
||||||
import 'package:recipe_flutter/features/admin/domain/admin_pantry_item.dart';
|
import 'package:recipe_flutter/features/admin/domain/admin_pantry_item.dart';
|
||||||
@@ -20,99 +22,187 @@ class TestAdminRepositoryWrapper implements AdminRepository {
|
|||||||
TestAdminRepositoryWrapper(this._fakeRepo);
|
TestAdminRepositoryWrapper(this._fakeRepo);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<ReceiptAlias>> listReceiptAliases() => _fakeRepo.listReceiptAliases();
|
Future<List<ReceiptAlias>> listReceiptAliases() =>
|
||||||
|
_fakeRepo.listReceiptAliases();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<AdminProduct>> listGlobalProducts() => _fakeRepo.listGlobalProducts();
|
Future<List<AdminProduct>> listGlobalProducts() =>
|
||||||
|
_fakeRepo.listGlobalProducts();
|
||||||
|
|
||||||
@override
|
@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
|
// Stub implementations for other required methods
|
||||||
@override
|
@override
|
||||||
Future<List<AdminAiCategorizeResult>> aiCategorizeBulk({List<int>? productIds}) => throw UnimplementedError();
|
Future<List<AdminAiCategorizeResult>> aiCategorizeBulk(
|
||||||
|
{List<int>? productIds}) =>
|
||||||
|
throw UnimplementedError();
|
||||||
@override
|
@override
|
||||||
Future<int> bulkSetCategory(List<int> ids, {required int? categoryId}) => throw UnimplementedError();
|
Future<int> bulkSetCategory(List<int> ids, {required int? categoryId}) =>
|
||||||
|
throw UnimplementedError();
|
||||||
@override
|
@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
|
@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
|
@override
|
||||||
Future<Map<String, dynamic>> createProduct(String name, {int? categoryId}) => throw UnimplementedError();
|
Future<Map<String, dynamic>> createProduct(String name, {int? categoryId}) =>
|
||||||
|
throw UnimplementedError();
|
||||||
@override
|
@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
|
@override
|
||||||
Future<void> deleteUser(int userId) => throw UnimplementedError();
|
Future<void> deleteUser(int userId) => throw UnimplementedError();
|
||||||
@override
|
@override
|
||||||
Future<List<AdminInventoryItem>> listAdminInventory({int? userId, String? sort}) => throw UnimplementedError();
|
Future<List<AdminInventoryItem>> listAdminInventory(
|
||||||
|
{int? userId, String? sort}) =>
|
||||||
|
throw UnimplementedError();
|
||||||
@override
|
@override
|
||||||
Future<List<AdminPantryItem>> listAdminPantry({int? userId}) => throw UnimplementedError();
|
Future<List<AdminPantryItem>> listAdminPantry({int? userId}) =>
|
||||||
|
throw UnimplementedError();
|
||||||
@override
|
@override
|
||||||
Future<List<AiModelInfo>> listAiModels() => throw UnimplementedError();
|
Future<List<AiModelInfo>> listAiModels() => throw UnimplementedError();
|
||||||
@override
|
@override
|
||||||
Future<List<AdminCategoryNode>> listCategoryTree() => throw UnimplementedError();
|
Future<AdminAiTraceDetail> getAiTraceById(String traceId) =>
|
||||||
|
throw UnimplementedError();
|
||||||
@override
|
@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
|
@override
|
||||||
Future<List<PendingProduct>> listPendingProducts() => throw UnimplementedError();
|
Future<List<AdminCategoryNode>> listCategoryTree() =>
|
||||||
|
throw UnimplementedError();
|
||||||
@override
|
@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
|
@override
|
||||||
Future<List<AdminProduct>> listProducts() => throw UnimplementedError();
|
Future<List<AdminProduct>> listProducts() => throw UnimplementedError();
|
||||||
@override
|
@override
|
||||||
Future<List<AdminProduct>> listSelectableProductsForAdmin({bool forceRefresh = false}) => throw UnimplementedError();
|
Future<List<AdminProduct>> listSelectableProductsForAdmin(
|
||||||
|
{bool forceRefresh = false}) =>
|
||||||
|
throw UnimplementedError();
|
||||||
@override
|
@override
|
||||||
Future<List<UserAdmin>> listUsers() => throw UnimplementedError();
|
Future<List<UserAdmin>> listUsers() => throw UnimplementedError();
|
||||||
@override
|
@override
|
||||||
Future<void> mergeAdminInventory({required int sourceInventoryId, required int targetInventoryId}) => throw UnimplementedError();
|
Future<void> mergeAdminInventory(
|
||||||
|
{required int sourceInventoryId, required int targetInventoryId}) =>
|
||||||
|
throw UnimplementedError();
|
||||||
@override
|
@override
|
||||||
Future<void> mergeProducts({required int sourceProductId, required int targetProductId}) => throw UnimplementedError();
|
Future<void> mergeProducts(
|
||||||
|
{required int sourceProductId, required int targetProductId}) =>
|
||||||
|
throw UnimplementedError();
|
||||||
@override
|
@override
|
||||||
Future<void> mergeProductsPrivate({required int sourceProductId, required int targetProductId}) => throw UnimplementedError();
|
Future<void> mergeProductsPrivate(
|
||||||
|
{required int sourceProductId, required int targetProductId}) =>
|
||||||
|
throw UnimplementedError();
|
||||||
@override
|
@override
|
||||||
Future<void> moveAdminInventoryToPantry(int inventoryId) => throw UnimplementedError();
|
Future<void> moveAdminInventoryToPantry(int inventoryId) =>
|
||||||
|
throw UnimplementedError();
|
||||||
@override
|
@override
|
||||||
Future<void> moveAdminPantryToInventory(int pantryItemId, Map<String, dynamic> body) => throw UnimplementedError();
|
Future<void> moveAdminPantryToInventory(
|
||||||
|
int pantryItemId, Map<String, dynamic> body) =>
|
||||||
|
throw UnimplementedError();
|
||||||
@override
|
@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
|
@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
|
@override
|
||||||
Future<AdminProduct> promotePrivateProduct(int productId) => throw UnimplementedError();
|
Future<AdminProduct> promotePrivateProduct(int productId) =>
|
||||||
|
throw UnimplementedError();
|
||||||
@override
|
@override
|
||||||
Future<void> removeAdminInventory(int inventoryId) => throw UnimplementedError();
|
Future<void> removeAdminInventory(int inventoryId) =>
|
||||||
|
throw UnimplementedError();
|
||||||
@override
|
@override
|
||||||
Future<void> removeAdminPantryItem(int pantryItemId) => throw UnimplementedError();
|
Future<void> removeAdminPantryItem(int pantryItemId) =>
|
||||||
|
throw UnimplementedError();
|
||||||
@override
|
@override
|
||||||
Future<void> removeProduct(int productId) => throw UnimplementedError();
|
Future<void> removeProduct(int productId) => throw UnimplementedError();
|
||||||
@override
|
@override
|
||||||
Future<void> removeReceiptAlias(int id) => throw UnimplementedError();
|
Future<void> removeReceiptAlias(int id) => throw UnimplementedError();
|
||||||
@override
|
@override
|
||||||
Future<Map<String, dynamic>> resetPassword(int userId) => throw UnimplementedError();
|
Future<Map<String, dynamic>> resetPassword(int userId) =>
|
||||||
|
throw UnimplementedError();
|
||||||
@override
|
@override
|
||||||
Future<void> restoreProduct(int productId) => throw UnimplementedError();
|
Future<void> restoreProduct(int productId) => throw UnimplementedError();
|
||||||
@override
|
@override
|
||||||
Future<UserAdmin> setPremium(int userId, {required bool isPremium}) => throw UnimplementedError();
|
Future<UserAdmin> setPremium(int userId, {required bool isPremium}) =>
|
||||||
|
throw UnimplementedError();
|
||||||
@override
|
@override
|
||||||
Future<void> setProductCategory(int productId, {required int? categoryId}) => throw UnimplementedError();
|
Future<void> setProductCategory(int productId, {required int? categoryId}) =>
|
||||||
|
throw UnimplementedError();
|
||||||
@override
|
@override
|
||||||
Future<void> setProductStatus(int productId, String status) => throw UnimplementedError();
|
Future<void> setProductStatus(int productId, String status) =>
|
||||||
|
throw UnimplementedError();
|
||||||
@override
|
@override
|
||||||
Future<UserAdmin> setRecipeSharing(int userId, {required bool canShareRecipes}) => throw UnimplementedError();
|
Future<UserAdmin> setRecipeSharing(int userId,
|
||||||
|
{required bool canShareRecipes}) =>
|
||||||
|
throw UnimplementedError();
|
||||||
@override
|
@override
|
||||||
Future<UserAdmin> setRole(int userId, String newRole) => throw UnimplementedError();
|
Future<UserAdmin> setRole(int userId, String newRole) =>
|
||||||
|
throw UnimplementedError();
|
||||||
@override
|
@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
|
@override
|
||||||
Future<AdminPantryItem> updateAdminPantry(int pantryItemId, {int? productId, String? location}) => throw UnimplementedError();
|
Future<AdminPantryItem> updateAdminPantry(int pantryItemId,
|
||||||
|
{int? productId, String? location}) =>
|
||||||
|
throw UnimplementedError();
|
||||||
@override
|
@override
|
||||||
Future<void> updateCanonicalName(int productId, String canonicalName) => throw UnimplementedError();
|
Future<void> updateCanonicalName(int productId, String canonicalName) =>
|
||||||
|
throw UnimplementedError();
|
||||||
@override
|
@override
|
||||||
Future<void> updateCanonicalNamePrivate(int productId, String canonicalName) => throw UnimplementedError();
|
Future<void> updateCanonicalNamePrivate(
|
||||||
|
int productId, String canonicalName) =>
|
||||||
|
throw UnimplementedError();
|
||||||
@override
|
@override
|
||||||
Future<void> updateEmail(int userId, String email) => throw UnimplementedError();
|
Future<void> updateEmail(int userId, String email) =>
|
||||||
|
throw UnimplementedError();
|
||||||
@override
|
@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
|
// Simple fake that only implements the methods we need
|
||||||
@@ -128,7 +218,8 @@ class FakeAdminRepository {
|
|||||||
return _products;
|
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
|
// Find and update alias
|
||||||
final index = _aliases.indexWhere((a) => a.id == id);
|
final index = _aliases.indexWhere((a) => a.id == id);
|
||||||
if (index >= 0) {
|
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