feat(ai): enhance AI trace warnings and reason codes system
- Added structured warning system with `AdminAiWarning` type in backend and Flutter - Implemented detailed reason descriptors with `FlyerReasonDescriptor` for parse and match operations - Added `legacyWarnings` field to maintain backward compatibility - Enhanced AI trace service to collect and format warnings with item-level context - Updated flyer import services to include detailed reason descriptions in responses - Added Swedish diacritic preservation for cheese variants (Prästost, Herrgårdsost, Grevéost) - Implemented UTF-8 content validation for AI responses - Added new reason code definitions in `reason-codes.ts` - Updated Flutter UI to display structured warnings with severity indicators - Added error report generation and copy functionality in admin panel - Added comprehensive test coverage for new warning system and cheese normalization BREAKING CHANGE: AI trace warnings are now structured objects instead of simple strings
This commit is contained in:
@@ -0,0 +1,346 @@
|
|||||||
|
# Plan: Flyerimport - specialtecken, beskrivande felmeddelanden, synlig prompt
|
||||||
|
|
||||||
|
## Mål
|
||||||
|
Tre sammanhängande förbättringar av flyerimport-pipelinen så att användaren får begripligt feedback och korrekt data:
|
||||||
|
|
||||||
|
1. Säkerställ att svenska tecken (ä, å, ö, é) bevaras i produktnamn som "Prästost", "Herrgårdsost", "Grevéost".
|
||||||
|
2. Ersätt opaka koder som `parse:ai_parsed` och `match:no_match` med människovänliga svenska förklaringar som beskriver "vad" som hände och "var" det hände.
|
||||||
|
3. Visa promptens innehåll (inte bara output) för operatörer/admins och vid behov i importflödet vid varningar.
|
||||||
|
|
||||||
|
Allt arbete ska respektera nuvarande arkitektur: NestJS backend (`backend/src/flyer-import`, `backend/src/ai`), Flutter web frontend (`flutter/lib/features/import`, `flutter/lib/features/admin`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verifierad nulägesbild (källkodsbevis)
|
||||||
|
|
||||||
|
### Specialtecken
|
||||||
|
- `ai-flyer-parser.service.ts:189-293` skickar prompt utan diakritiska tecken (skriver "Prastost", "Herrgardsost", "Greveost", "ARLA KO", "ä" undviks medvetet i prompt-instruktionerna). Detta gör att modellen tränas/uppmuntras att returnera namnen utan ä/å/é.
|
||||||
|
- `ai-flyer-parser.service.ts:358-364` (`normalizeName`) tar bort allt som inte är `[a-zåäö0-9\s]` men `rawName` skickas vidare som det är, så ä/å bevaras tekniskt om AI returnerar dem.
|
||||||
|
- `flyer-normalizer.service.ts:26-30` har en hårdkodad mappning för cheese variants:
|
||||||
|
- `prast: 'Prästost'`
|
||||||
|
- `herrgard: 'Herrgårdsost'`
|
||||||
|
- `greve: 'Greveost'` (saknar `é`! Bör vara `Grevéost`)
|
||||||
|
- `flyer-normalizer.service.ts:196-227` (`expandCheeseVariants`) gör `stripDiacritics` på rawName innan den jämför med token-list. Detta funkar för matchning men producerar diakrit-fria varianter om mappningen inte är korrekt.
|
||||||
|
- `flyer-normalizer.service.ts:140-146` (`normalizeName`) fungerar med `[a-zåäö0-9\s]` så svenska tecken behålls i normaliserat namn när inputen har dem.
|
||||||
|
|
||||||
|
### "parse:ai_parsed" och "match:no_match"
|
||||||
|
- Konstanter genereras som "reason codes" i koden:
|
||||||
|
- `ai-flyer-parser.service.ts:354`: `reasonCodes: ['ai_parsed']` - sätts på alla AI-parsade items.
|
||||||
|
- `flyer-import.service.ts:496`: `reasons: ['no_match']` - sätts när inget produktnamn matchar.
|
||||||
|
- Reason codes prefixas med `parse:` eller `match:` av `ai-trace.service.ts:435-452` (`collectWarnings`):
|
||||||
|
```ts
|
||||||
|
warnings.add(`parse:${text}`);
|
||||||
|
warnings.add(`match:${text}`);
|
||||||
|
```
|
||||||
|
- Dessa visas i admin AI-traces-vyn (`admin_ai_panel.dart:355-362`, `_WarningsCard`) och möjligen i import-UI:t via `_buildWarningsPanel` (`flyer_import_tab.dart:497-539`) om `_result.warnings` innehåller dem.
|
||||||
|
- Slutsats: koderna är inte fel - de är obegripliga utan översättning.
|
||||||
|
|
||||||
|
### Promptens synlighet
|
||||||
|
- Admin AI-panelen (`admin_ai_panel.dart:444-508`, `_PromptCard`) visar redan prompt med expand/copy-knappar.
|
||||||
|
- Importflödet (`flyer_import_tab.dart`, `receipt_import_tab.dart`) visar idag bara `warnings`-listan. Ingen promptvisning, ingen mappning från reason codes till begripligt språk, ingen länk till AI-trace.
|
||||||
|
- AI-trace lagras alltid i DB (`flyer-import.service.ts:148-164`, `persistFlyerTrace`). Promptens innehåll finns alltså redan tillgängligt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Strategi och faser
|
||||||
|
|
||||||
|
Tre paralleliserbara delprojekt med varsin egen fas. Slutlig sammanflätning verifieras med befintliga tester och ny smoke-test.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fas A: Specialtecken i produktnamn (lågriskfix, hög nytta)
|
||||||
|
|
||||||
|
### A1. Korrigera hårdkodad cheese-mappning
|
||||||
|
- `backend/src/flyer-import/services/flyer-normalizer.service.ts:26-30`
|
||||||
|
- Ändra `greve: 'Greveost'` till `greve: 'Grevéost'`.
|
||||||
|
- Behåll `prast: 'Prästost'` och `herrgard: 'Herrgårdsost'`.
|
||||||
|
|
||||||
|
### A2. Skydda diakritiska tecken hela vägen från AI-svar till klient
|
||||||
|
- `backend/src/flyer-import/services/ai-flyer-parser.service.ts`
|
||||||
|
- Säkerställ att Mistralklientens response.choices[0].message.content tolkas som UTF-8. Logga hex-dump i debug-läge om tecken förvanskas.
|
||||||
|
- I `sanitizeJsonResponse`/`normalizeAiItem`: säkerställ att `rawName` inte normaliseras eller stripps innan `normalize`-steget.
|
||||||
|
- Uppdatera prompten:
|
||||||
|
- Lägg till explicit instruktion: `Behåll svenska diakritiska tecken (ä, å, ö, é) i produktnamn. Returnera "Prästost", "Herrgårdsost", "Grevéost" - inte ASCII-versioner.`
|
||||||
|
- Uppdatera exempel-utdata i prompten (rad 256-286) från "Prastost"/"Herrgardsost" till "Prästost"/"Herrgårdsost"/"Grevéost" så modellen tränas att returnera korrekt.
|
||||||
|
- Behåll fortfarande `prast/herrgard/greve` som tokens i instruktion 9 men kräv normalisering till diakrit-versionen i utdatan.
|
||||||
|
- `backend/src/flyer-import/services/flyer-normalizer.service.ts`
|
||||||
|
- I `expandCheeseVariants` (rad 196-227): efter `stripDiacritics`-tokenisering, mappa via `CHEESE_VARIANT_TO_NAME` så slutnamnet alltid har korrekta tecken.
|
||||||
|
- I `fixKnownOcrTypos` (rad 250-262): lägg till regel som korrigerar "Greveost" -> "Grevéost" (men bara när det är klart att det är ostnamnet, inte personnamn). Använd kontext: bara om `category` antyder hårdost/ost eller `rawName` slutar på `ost`.
|
||||||
|
|
||||||
|
### A3. Säkra Caddy/HTTP-respons för UTF-8
|
||||||
|
- Verifiera att `Content-Type: application/json; charset=utf-8` returneras från NestJS (default i `@nestjs/platform-express`, men kontrollera att ingen middleware skriver om).
|
||||||
|
- Caddy gör inte byteomvandling som standard, men dubbelkolla att `flutter/Caddyfile` inte har någon `replace`-direktiv (det har det inte i nuläget).
|
||||||
|
|
||||||
|
### A4. Test
|
||||||
|
- Utöka `flyer-normalizer.service.spec.ts` med:
|
||||||
|
- Test som matar in rawName "PRAST" och kontrollerar att outputens rawName blir "Prästost" (inte "Prastost").
|
||||||
|
- Test som matar in rawName "GREVE" och kontrollerar att outputens rawName blir "Grevéost".
|
||||||
|
- Test som verifierar att `é`-tecken bevaras i hela pipen.
|
||||||
|
- Snapshot-test för prompten i `ai-flyer-parser.service.spec.ts` så att ändringar i prompten är medvetna.
|
||||||
|
|
||||||
|
### Acceptanskriterier Fas A
|
||||||
|
- En flyer som innehåller "PRAST, HERRGARD, GREVE" producerar rader med rawName `Prästost`, `Herrgårdsost`, `Grevéost`.
|
||||||
|
- Ingen befintlig test går sönder.
|
||||||
|
- UI:t (Flutter) visar de korrekta tecknen utan encoding-artifacts.
|
||||||
|
|
||||||
|
### Risker
|
||||||
|
- Mistral kan ignorera diakritiska tecken i instruktioner. Motåtgärd: post-normalisering i `flyer-normalizer.service.ts` är hårda fallback-regeln.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fas B: Beskrivande felmeddelanden ersätter `parse:ai_parsed` och `match:no_match`
|
||||||
|
|
||||||
|
### B1. Centraliserad reason-code-katalog (backend)
|
||||||
|
- Skapa `backend/src/flyer-import/services/reason-codes.ts` (eller `backend/src/ai/reason-codes.ts` om det är delat med receipt) som exporterar:
|
||||||
|
- `ParseReasonCode = 'ai_parsed' | 'split_cheese_variants' | 'normalized' | 'low_confidence' | ...`
|
||||||
|
- `MatchReasonCode = 'no_match' | 'alias_exact' | 'normalized_exact' | 'token_overlap' | 'alias_points_to_missing_product' | 'empty_name'`
|
||||||
|
- Funktion `describeParseReason(code, context?): { title, message, severity, location? }`
|
||||||
|
- Funktion `describeMatchReason(code, context?): { title, message, severity }`
|
||||||
|
- Exempelmappning:
|
||||||
|
- `ai_parsed` -> `severity: 'info', title: 'AI-tolkad rad', message: 'Raden tolkades av AI utan att en deterministisk regel matchade.'`
|
||||||
|
- `split_cheese_variants` -> `severity: 'info', title: 'Variant-split', message: 'AI-svarade en gruppannons som expanderades till individuella ostvarianter.'`
|
||||||
|
- `low_confidence` -> `severity: 'warning', title: 'Låg parsningskvalitet', message: 'Modellens säkerhet är låg, granska raden manuellt.'`
|
||||||
|
- `no_match` -> `severity: 'warning', title: 'Ingen produktmatchning', message: 'Vi kunde inte hitta någon befintlig produkt som matchar texten på flyern.'`
|
||||||
|
- `alias_points_to_missing_product` -> `severity: 'error', title: 'Trasig alias-koppling', message: 'Ett alias pekar på en produkt som inte längre finns.'`
|
||||||
|
- `empty_name` -> `severity: 'error', title: 'Tomt produktnamn', message: 'Raden saknar tolkbart produktnamn.'`
|
||||||
|
- Inkludera `location` när relevant: t ex `'Steg: AI-parser, chunk N/M'` eller `'Steg: matchning mot dina produkter'`.
|
||||||
|
|
||||||
|
### B2. Returnera strukturerade reasons i API
|
||||||
|
- Utöka `backend/src/flyer-import/dto/flyer-import.response.ts`:
|
||||||
|
```ts
|
||||||
|
export type FlyerReasonDescriptor = {
|
||||||
|
code: string; // 'ai_parsed', 'no_match', ...
|
||||||
|
kind: 'parse' | 'match';
|
||||||
|
title: string; // Människovänlig titel
|
||||||
|
message: string; // Förklarande text
|
||||||
|
severity: 'info' | 'warning' | 'error';
|
||||||
|
location: string | null; // T ex 'Steg: matchning mot dina produkter'
|
||||||
|
};
|
||||||
|
```
|
||||||
|
- Lägg till på `FlyerImportItem`:
|
||||||
|
- `parseReasonsDetailed: FlyerReasonDescriptor[]`
|
||||||
|
- `matchReasonsDetailed: FlyerReasonDescriptor[]`
|
||||||
|
- Behåll befintliga `parseReasons: string[]` och `matchReasons: string[]` för bakåtkompatibilitet.
|
||||||
|
- I `flyer-import.service.ts:110-144` (där `FlyerImportItem` byggs): mappa via `describeParseReason`/`describeMatchReason`.
|
||||||
|
- Detsamma för session-läs-paths (`toFlyerImportItem` rad 721-799 och `toFlyerImportResponseFromSession`).
|
||||||
|
|
||||||
|
### B3. Uppdatera AI-trace warnings (admin)
|
||||||
|
- `backend/src/ai/ai-trace.service.ts:435-452` (`collectWarnings`):
|
||||||
|
- Ändra till att returnera strukturerade objekt istället för `parse:xxx`/`match:xxx`-strängar:
|
||||||
|
```ts
|
||||||
|
type AdminAiWarning = {
|
||||||
|
code: string;
|
||||||
|
kind: 'parse' | 'match';
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
severity: 'info' | 'warning' | 'error';
|
||||||
|
itemIndex?: number; // Pekar på vilken rad i sessionen
|
||||||
|
};
|
||||||
|
```
|
||||||
|
- Uppdatera `AdminAiTraceDetail.warnings` schema till strukturerat format.
|
||||||
|
- Bibehåll en `legacyWarnings: string[]` med gamla formatet ifall någon klient ännu inte uppdaterats.
|
||||||
|
|
||||||
|
### B4. Uppdatera Flutter-modeller och vyer
|
||||||
|
- `flutter/lib/features/import/domain/flyer_import_item.dart`:
|
||||||
|
- Lägg till `parseReasonsDetailed: List<FlyerReasonDescriptor>` och `matchReasonsDetailed: List<FlyerReasonDescriptor>` med `fromJson`/`toJson`.
|
||||||
|
- Skapa `flutter/lib/features/import/domain/flyer_reason_descriptor.dart`:
|
||||||
|
- `class FlyerReasonDescriptor { final String code; final String kind; final String title; final String message; final String severity; final String? location; ... }`
|
||||||
|
- `flutter/lib/features/import/presentation/flyer_import_tab.dart`:
|
||||||
|
- I `_buildWarningsPanel` (rad 497-539): visa enbart sessionens egentliga warnings (existerande) men gör dem klickbara så de kan kopieras.
|
||||||
|
- Per rad: visa en summerande badge "X varningar" som expanderar till en lista med titel + message istället för tekniska koder. Använd ikoner per severity (info/warning/error).
|
||||||
|
- Behåll befintlig kvalitetsbadge (Hög/Medel/Låg).
|
||||||
|
- `flutter/lib/features/admin/presentation/admin_ai_panel.dart`:
|
||||||
|
- `_WarningsCard` (rad 583+): byt ut SelectableText med rå-strängar mot strukturerad rendering: titel (fet), message (vanlig), severity-färg, eventuellt itemIndex som länk.
|
||||||
|
- Behåll copy-funktion - kopierar då en formaterad sträng `"[severity] title: message"`.
|
||||||
|
|
||||||
|
### B5. Test
|
||||||
|
- Backend: enhetstest för `describeParseReason`/`describeMatchReason` som täcker alla codes.
|
||||||
|
- Backend: integrationstest som verifierar att `FlyerImportResponse` innehåller `parseReasonsDetailed` med rätt fält.
|
||||||
|
- Flutter: widget-test för warnings-panel som verifierar att `parse:ai_parsed` ALDRIG visas, utan ersätts av "AI-tolkad rad".
|
||||||
|
- Uppdatera `flutter/test/features/admin/presentation/admin_ai_panel_test.dart` som idag verifierar `find.text('parse:low_confidence')` - testet ska istället leta efter "Låg parsningskvalitet".
|
||||||
|
|
||||||
|
### Acceptanskriterier Fas B
|
||||||
|
- Inga `parse:xxx`- eller `match:xxx`-strängar visas i UI:t (varken admin eller import).
|
||||||
|
- Varje warning har: titel, beskrivande text, severity-ikon, och om relevant en location ("Steg: ...").
|
||||||
|
- API:et returnerar både legacy- och nytt format, så ingen klient bryts.
|
||||||
|
|
||||||
|
### Risker
|
||||||
|
- Översättning av code -> human text måste hållas i sync mellan backend och Flutter. Motåtgärd: sätt textmappningen ENBART i backend och returnera färdig string. Flutter renderar bara.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fas C: Synlig prompt i import-flödet och vid varningar
|
||||||
|
|
||||||
|
### C1. Bestäm synlighetspolicy
|
||||||
|
- Operatörer/admins ska kunna se prompten direkt i admin AI-panelen (finns redan, fortsätter fungera).
|
||||||
|
- Vanliga användare ska kunna se prompten **efter behov** för att felsöka eller rapportera fel - men inte by default eftersom prompten är teknisk.
|
||||||
|
- Föreslagen UX: när en rad har varningar (parse eller match), visa knapp "Visa AI-detaljer" som öppnar modal med:
|
||||||
|
- Använd modell + retry/chunk-info
|
||||||
|
- Prompten (expanderbar)
|
||||||
|
- AI-svar (raw output, expanderbar)
|
||||||
|
- Lista över alla parse/match-reasons med deras `describeXxxReason`-output
|
||||||
|
- Promptvisning i adminpanelen redan komplett (`_PromptCard`) - bara verifiera att det fortsätter funka efter Fas B.
|
||||||
|
|
||||||
|
### C2. Backend-ändringar
|
||||||
|
- Ny endpoint `GET /api/flyer-import/sessions/:sessionId/ai-trace` som returnerar:
|
||||||
|
```ts
|
||||||
|
{
|
||||||
|
sessionId: number;
|
||||||
|
model: string;
|
||||||
|
prompt: string;
|
||||||
|
rawOutput: string;
|
||||||
|
chunkCount: number | null;
|
||||||
|
retryCount: number | null;
|
||||||
|
durationMs: number | null;
|
||||||
|
status: 'success' | 'warning' | 'error';
|
||||||
|
warnings: AdminAiWarning[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- Återanvänd existerande `aiTrace`-tabellen. Auth: kräv att `userId` ägar sessionen (samma policy som `getSessionSource`).
|
||||||
|
- Eventuellt feature-flagga visa-prompt-för-användare (`FLYER_AI_USER_PROMPT_VISIBLE` env). Default: `true` för admin, `true` för användare som äger sessionen.
|
||||||
|
|
||||||
|
### C3. Flutter-ändringar
|
||||||
|
- `flutter/lib/features/import/data/import_repository.dart`: ny metod `getFlyerSessionAiTrace(sessionId, token)`.
|
||||||
|
- `flutter/lib/features/import/domain/`: ny modell `FlyerAiTrace`.
|
||||||
|
- `flutter/lib/features/import/presentation/flyer_import_tab.dart`:
|
||||||
|
- Lägg till en "AI-detaljer"-knapp i headern (efter Importera-knappen) eller i varje rads expanderingspanel.
|
||||||
|
- Visa prompt + rawOutput i en modal/expanderbar Card med samma look-and-feel som admin (`_PromptCard` + `_OutputJsonCard` kan extraheras till delad widget under `flutter/lib/features/import/presentation/widgets/ai_trace_view.dart`).
|
||||||
|
- Lägg till samma åtgärd för `receipt_import_tab.dart` om motsvarande backend-stöd finns (det finns - receipts har också AI-trace).
|
||||||
|
|
||||||
|
### C4. Säkerhet och PII
|
||||||
|
- Innan prompt visas till slutanvändare: kör `maskSensitiveText` (finns redan i `ai-trace.service.ts`).
|
||||||
|
- Alla loggade prompts/rawOutputs ska redan vara maskerade i nuvarande pipeline. Verifiera att det fortsätter gälla.
|
||||||
|
|
||||||
|
### C5. Test
|
||||||
|
- Backend: e2e-test för nya endpointen, inklusive 403 för icke-ägare och 200 för ägare.
|
||||||
|
- Flutter: widget-test för att modalen öppnas och visar prompten.
|
||||||
|
- Manuell QA: Importera en flyer, klicka "AI-detaljer", verifiera att prompten visas och att kopiera-knappen fungerar.
|
||||||
|
|
||||||
|
### Acceptanskriterier Fas C
|
||||||
|
- Användare kan se prompten som skickades vid sin egen flyerimport.
|
||||||
|
- Adminpanelen visar fortfarande prompten oförändrat.
|
||||||
|
- PII-mask appliceras innan prompten skickas till klienten.
|
||||||
|
|
||||||
|
### Risker
|
||||||
|
- Prompten är lång (>5000 tecken). Motåtgärd: använd existerande expand/collapse-mönster (`_PromptCard.expanded`).
|
||||||
|
- Prompten kan innehålla användardata. Motåtgärd: maskning + tydlig "Detta innehåller text från din flyer"-disclaimer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prioriterad genomförandeordning
|
||||||
|
1. **Fas A** (specialtecken) - lågrisk, snabb seger för UX.
|
||||||
|
2. **Fas B** (mänskliga felmeddelanden) - medelarbete, hög UX-impact.
|
||||||
|
3. **Fas C** (prompt-synlighet) - mer komplex pga ny endpoint + UI, men oberoende av A och B.
|
||||||
|
|
||||||
|
Faserna kan implementeras i parallella PR:er om så önskas; Fas A och B berör delvis samma kodvägar i `flyer-normalizer.service.ts` och `flyer-import.service.ts`, och bör samordnas så att en PR mergas före nästa.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Definition of Done
|
||||||
|
- "Prästost", "Herrgårdsost", "Grevéost" och `é`/`å`/`ä` syns korrekt i flyerimport-UI.
|
||||||
|
- Inga råa `parse:ai_parsed`/`match:no_match`-strängar visas för användare eller admin.
|
||||||
|
- Användare och admin kan se vilken prompt som skickades till AI.
|
||||||
|
- Befintliga tester passerar; nya tester täcker varje fas separat.
|
||||||
|
- Inga regressioner i Docker-bygget (`backend/Dockerfile` kör `npm test -- --runInBand`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Konkreta filer som berörs
|
||||||
|
|
||||||
|
Backend:
|
||||||
|
- `backend/src/flyer-import/services/flyer-normalizer.service.ts` (Fas A)
|
||||||
|
- `backend/src/flyer-import/services/ai-flyer-parser.service.ts` (Fas A, eventuellt B)
|
||||||
|
- `backend/src/flyer-import/services/reason-codes.ts` (ny, Fas B)
|
||||||
|
- `backend/src/flyer-import/dto/flyer-import.response.ts` (Fas B)
|
||||||
|
- `backend/src/flyer-import/flyer-import.service.ts` (Fas B, Fas C)
|
||||||
|
- `backend/src/flyer-import/flyer-import.controller.ts` (Fas C, ny endpoint)
|
||||||
|
- `backend/src/ai/ai-trace.service.ts` (Fas B)
|
||||||
|
- `backend/src/flyer-import/services/flyer-normalizer.service.spec.ts` (Fas A)
|
||||||
|
- `backend/src/flyer-import/services/ai-flyer-parser.service.spec.ts` (Fas A, B)
|
||||||
|
|
||||||
|
Flutter:
|
||||||
|
- `flutter/lib/features/import/domain/flyer_import_item.dart` (Fas B)
|
||||||
|
- `flutter/lib/features/import/domain/flyer_reason_descriptor.dart` (ny, Fas B)
|
||||||
|
- `flutter/lib/features/import/domain/flyer_ai_trace.dart` (ny, Fas C)
|
||||||
|
- `flutter/lib/features/import/data/import_repository.dart` (Fas C)
|
||||||
|
- `flutter/lib/features/import/presentation/flyer_import_tab.dart` (Fas A-C)
|
||||||
|
- `flutter/lib/features/import/presentation/widgets/ai_trace_view.dart` (ny, Fas C)
|
||||||
|
- `flutter/lib/features/admin/domain/admin_ai_trace_detail.dart` (Fas B)
|
||||||
|
- `flutter/lib/features/admin/presentation/admin_ai_panel.dart` (Fas B)
|
||||||
|
- `flutter/test/features/admin/presentation/admin_ai_panel_test.dart` (Fas B)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Riskanalys och rollback
|
||||||
|
|
||||||
|
| Risk | Sannolikhet | Motåtgärd |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Mistral returnerar fortfarande ASCII-versioner | Medel | Hård post-normalisering i `flyer-normalizer.service.ts` (Fas A2) |
|
||||||
|
| Strukturerade reasons bryter befintlig admin-vy | Låg | Behåll legacy-format parallellt under en release |
|
||||||
|
| Promptens längd försämrar mobil-UX | Låg | Default collapsed med expand-knapp |
|
||||||
|
| AI-trace exponerar PII oavsiktligt | Medel | Återanvänd befintlig `maskSensitiveText` + tydlig disclaimer |
|
||||||
|
| Ändrade reason codes bryter andra konsumenter (t ex flyer-selection-matcher) | Låg | Sökning visar att `reasonCodes` används endast i flyer-import + ai-trace; uppdatera samtliga callsites i samma PR |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Beslut tagna med dig
|
||||||
|
|
||||||
|
1. **Promptens synlighet**: endast admin. Vanliga användare ser inte prompten - bara översatta reasons. Detta förenklar Fas C avsevärt: ingen ny användarendpoint, ingen PII-mask för slutanvändare, ingen ny användar-UI.
|
||||||
|
2. **Reason-codes översätts i backend**: backend returnerar färdig svensk text (`title`, `message`) i `FlyerReasonDescriptor`. Frontend renderar bara strängarna utan översättning. Lang-parameter förbereds för framtida flerspråksstöd.
|
||||||
|
3. **Kopiera felrapport-knapp**: ja. Lägg till "Kopiera felrapport"-knapp i admin AI-panelens detail-vy som producerar formaterad text:
|
||||||
|
```
|
||||||
|
[AI-trace flyer-123]
|
||||||
|
Modell: ministral-8b-2512
|
||||||
|
Status: warning (3 varningar)
|
||||||
|
Tid: 2026-05-23T20:12:00
|
||||||
|
|
||||||
|
Varningar:
|
||||||
|
- [warning] Ingen produktmatchning (rad 5): Vi kunde inte hitta...
|
||||||
|
- [info] AI-tolkad rad (rad 7): Raden tolkades av AI...
|
||||||
|
|
||||||
|
Prompt:
|
||||||
|
...
|
||||||
|
|
||||||
|
Raw output:
|
||||||
|
...
|
||||||
|
```
|
||||||
|
4. **Stavning**: `Grevéost` med `é` (Arlas officiella stavning).
|
||||||
|
|
||||||
|
## Konsekvenser av besluten på faserna
|
||||||
|
|
||||||
|
### Fas A (oförändrad)
|
||||||
|
- `Grevéost` används i mappningen.
|
||||||
|
|
||||||
|
### Fas B (oförändrad)
|
||||||
|
- Backend översätter och returnerar färdig svensk text i `title`/`message`.
|
||||||
|
- Behåll `code`-fältet så frontend kan filtrera/rendera olika per typ.
|
||||||
|
|
||||||
|
### Fas C (förenklad)
|
||||||
|
- **Tas bort**: Ny användarendpoint `GET /api/flyer-import/sessions/:id/ai-trace`.
|
||||||
|
- **Tas bort**: Ny Flutter-modell `FlyerAiTrace` och repository-metod för användarvy.
|
||||||
|
- **Tas bort**: Ny widget `ai_trace_view.dart` för importflödet.
|
||||||
|
- **Behålls**: Adminpanelens promptvisning (`_PromptCard`) - finns redan, fungerar.
|
||||||
|
- **Läggs till**: "Kopiera felrapport"-knapp i adminpanelens detail-vy. Genererar formaterad text enligt mallen ovan.
|
||||||
|
- **Användarflöde uppdateras**: i flyer/receipt-import-tabben visa endast översatta reasons via Fas B; ingen prompt-knapp för användare.
|
||||||
|
|
||||||
|
## Uppdaterade konkreta filer (efter beslut)
|
||||||
|
|
||||||
|
Backend:
|
||||||
|
- `backend/src/flyer-import/services/flyer-normalizer.service.ts` (Fas A)
|
||||||
|
- `backend/src/flyer-import/services/ai-flyer-parser.service.ts` (Fas A)
|
||||||
|
- `backend/src/flyer-import/services/reason-codes.ts` (ny, Fas B)
|
||||||
|
- `backend/src/flyer-import/dto/flyer-import.response.ts` (Fas B)
|
||||||
|
- `backend/src/flyer-import/flyer-import.service.ts` (Fas B)
|
||||||
|
- `backend/src/ai/ai-trace.service.ts` (Fas B)
|
||||||
|
- `backend/src/flyer-import/services/flyer-normalizer.service.spec.ts` (Fas A)
|
||||||
|
- `backend/src/flyer-import/services/ai-flyer-parser.service.spec.ts` (Fas A)
|
||||||
|
- *Inte längre*: ny endpoint i `flyer-import.controller.ts` (utgår med beslut 1).
|
||||||
|
|
||||||
|
Flutter:
|
||||||
|
- `flutter/lib/features/import/domain/flyer_import_item.dart` (Fas B)
|
||||||
|
- `flutter/lib/features/import/domain/flyer_reason_descriptor.dart` (ny, Fas B)
|
||||||
|
- `flutter/lib/features/import/presentation/flyer_import_tab.dart` (Fas A-B)
|
||||||
|
- `flutter/lib/features/admin/domain/admin_ai_trace_detail.dart` (Fas B)
|
||||||
|
- `flutter/lib/features/admin/presentation/admin_ai_panel.dart` (Fas B + felrapportknapp)
|
||||||
|
- `flutter/test/features/admin/presentation/admin_ai_panel_test.dart` (Fas B)
|
||||||
|
- *Inte längre*: `flyer_ai_trace.dart`, `ai_trace_view.dart`, repository-metod (utgår med beslut 1).
|
||||||
@@ -143,6 +143,71 @@ describe('AiTraceService receipt masking', () => {
|
|||||||
expect(result.rawOutput).toContain('{"ok":true}');
|
expect(result.rawOutput).toContain('{"ok":true}');
|
||||||
expect(result.retryCount).toBe(2);
|
expect(result.retryCount).toBe(2);
|
||||||
expect(result.chunkCount).toBe(4);
|
expect(result.chunkCount).toBe(4);
|
||||||
expect(result.warnings).toContain('parse:low_confidence');
|
expect(result.warnings).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
kind: 'parse',
|
||||||
|
code: 'low_confidence',
|
||||||
|
title: 'Låg parsningskvalitet',
|
||||||
|
severity: 'warning',
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(result.legacyWarnings).toContain('parse:low_confidence');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps multiple token_overlap warnings for same row', async () => {
|
||||||
|
prismaMock.flyerSession.findUnique.mockResolvedValue({
|
||||||
|
id: 202,
|
||||||
|
userId: 9,
|
||||||
|
createdAt: new Date('2026-05-23T09:00:00.000Z'),
|
||||||
|
sourceFileName: 'willys-v21.pdf',
|
||||||
|
sourceMimeType: 'application/pdf',
|
||||||
|
sourceFileSize: 2222,
|
||||||
|
user: { username: 'admin', email: 'admin@example.com' },
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 11,
|
||||||
|
rawName: 'Tomatmix',
|
||||||
|
normalizedName: 'tomatmix',
|
||||||
|
brand: null,
|
||||||
|
categoryHint: 'Grönsaker',
|
||||||
|
categoryId: null,
|
||||||
|
price: null,
|
||||||
|
priceUnit: null,
|
||||||
|
comparisonPrice: null,
|
||||||
|
comparisonUnit: null,
|
||||||
|
weight: null,
|
||||||
|
bundleWeight: null,
|
||||||
|
isBundle: false,
|
||||||
|
bundleItems: [],
|
||||||
|
offerText: null,
|
||||||
|
parseConfidence: 0.9,
|
||||||
|
parseReasons: [],
|
||||||
|
matchedProductId: null,
|
||||||
|
matchedProductName: null,
|
||||||
|
matchedVia: 'token',
|
||||||
|
matchConfidence: 0.7,
|
||||||
|
matchReasons: ['token_overlap:0.42', 'token_overlap:0.73'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
prismaMock.aiTrace.findMany.mockResolvedValue([
|
||||||
|
{
|
||||||
|
sessionId: 202,
|
||||||
|
prompt: 'prompt',
|
||||||
|
rawOutput: '{"ok":true}',
|
||||||
|
normalizedOutput: { retryCount: 0, chunkCount: 1 },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await service.getTraceById('flyer-202');
|
||||||
|
|
||||||
|
const tokenWarnings = result.warnings.filter((warning) => warning.code === 'token_overlap');
|
||||||
|
expect(tokenWarnings).toHaveLength(2);
|
||||||
|
expect(result.legacyWarnings).toEqual(
|
||||||
|
expect.arrayContaining(['match:token_overlap:0.42', 'match:token_overlap:0.73']),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import {
|
||||||
|
describeMatchReason,
|
||||||
|
describeParseReason,
|
||||||
|
FlyerReasonDescriptor,
|
||||||
|
} from '../flyer-import/services/reason-codes';
|
||||||
|
|
||||||
export type AiTraceSource = 'receipt' | 'flyer';
|
export type AiTraceSource = 'receipt' | 'flyer';
|
||||||
|
|
||||||
@@ -45,7 +50,8 @@ export type AiTraceDetail = {
|
|||||||
durationMs: number | null;
|
durationMs: number | null;
|
||||||
retryCount: number | null;
|
retryCount: number | null;
|
||||||
chunkCount: number | null;
|
chunkCount: number | null;
|
||||||
warnings: string[];
|
warnings: AdminAiWarning[];
|
||||||
|
legacyWarnings: string[];
|
||||||
error: string | null;
|
error: string | null;
|
||||||
prompt: string | null;
|
prompt: string | null;
|
||||||
rawOutput: string | null;
|
rawOutput: string | null;
|
||||||
@@ -53,6 +59,10 @@ export type AiTraceDetail = {
|
|||||||
summary: Record<string, unknown>;
|
summary: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AdminAiWarning = FlyerReasonDescriptor & {
|
||||||
|
itemIndex?: number;
|
||||||
|
};
|
||||||
|
|
||||||
type FlyerTraceSupplement = {
|
type FlyerTraceSupplement = {
|
||||||
prompt: string | null;
|
prompt: string | null;
|
||||||
rawOutput: string | null;
|
rawOutput: string | null;
|
||||||
@@ -107,11 +117,14 @@ export class AiTraceService {
|
|||||||
const page = hasMore ? sessions.slice(0, take) : sessions;
|
const page = hasMore ? sessions.slice(0, take) : sessions;
|
||||||
|
|
||||||
const items: AiTraceListItem[] = page.map((session) => {
|
const items: AiTraceListItem[] = page.map((session) => {
|
||||||
const warningsCount = session.items.reduce((sum, item) => {
|
const warningSet = this.collectWarnings(
|
||||||
const parseWarnings = Array.isArray(item.parseReasons) ? item.parseReasons.length : 0;
|
session.items.map((item, itemIndex) => ({
|
||||||
const matchWarnings = Array.isArray(item.matchReasons) ? item.matchReasons.length : 0;
|
parseReasons: item.parseReasons,
|
||||||
return sum + parseWarnings + matchWarnings;
|
matchReasons: item.matchReasons,
|
||||||
}, 0);
|
itemIndex,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const warningsCount = this.countActionableWarnings(warningSet.warnings);
|
||||||
const status = this.statusFromSession(session.items.length, warningsCount);
|
const status = this.statusFromSession(session.items.length, warningsCount);
|
||||||
return {
|
return {
|
||||||
id: this.flyerTraceId(session.id),
|
id: this.flyerTraceId(session.id),
|
||||||
@@ -205,8 +218,18 @@ export class AiTraceService {
|
|||||||
throw new NotFoundException('AI-trace hittades inte.');
|
throw new NotFoundException('AI-trace hittades inte.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const warnings = this.collectWarnings(session.items);
|
const warningSet = this.collectWarnings(
|
||||||
const status = this.statusFromSession(session.items.length, warnings.length);
|
session.items.map((item, itemIndex) => ({
|
||||||
|
parseReasons: item.parseReasons,
|
||||||
|
matchReasons: item.matchReasons,
|
||||||
|
itemIndex,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const warnings = warningSet.warnings;
|
||||||
|
const status = this.statusFromSession(
|
||||||
|
session.items.length,
|
||||||
|
this.countActionableWarnings(warnings),
|
||||||
|
);
|
||||||
const supplement = await this.getFlyerTraceSupplementBySessionId(session.id);
|
const supplement = await this.getFlyerTraceSupplementBySessionId(session.id);
|
||||||
|
|
||||||
const normalizedOutput = {
|
const normalizedOutput = {
|
||||||
@@ -241,6 +264,7 @@ export class AiTraceService {
|
|||||||
matchReasons: Array.isArray(item.matchReasons) ? item.matchReasons : [],
|
matchReasons: Array.isArray(item.matchReasons) ? item.matchReasons : [],
|
||||||
})),
|
})),
|
||||||
warnings,
|
warnings,
|
||||||
|
legacyWarnings: warningSet.legacyWarnings,
|
||||||
} as Record<string, unknown>;
|
} as Record<string, unknown>;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -257,6 +281,7 @@ export class AiTraceService {
|
|||||||
retryCount: supplement.retryCount,
|
retryCount: supplement.retryCount,
|
||||||
chunkCount: supplement.chunkCount,
|
chunkCount: supplement.chunkCount,
|
||||||
warnings,
|
warnings,
|
||||||
|
legacyWarnings: warningSet.legacyWarnings,
|
||||||
error: session.items.length === 0 ? 'Inga produkter kunde extraheras från flyern.' : null,
|
error: session.items.length === 0 ? 'Inga produkter kunde extraheras från flyern.' : null,
|
||||||
prompt: supplement.prompt,
|
prompt: supplement.prompt,
|
||||||
rawOutput:
|
rawOutput:
|
||||||
@@ -266,7 +291,7 @@ export class AiTraceService {
|
|||||||
source: 'flyer',
|
source: 'flyer',
|
||||||
sessionId: session.id,
|
sessionId: session.id,
|
||||||
itemCount: session.items.length,
|
itemCount: session.items.length,
|
||||||
warningsCount: warnings.length,
|
warningsCount: this.countActionableWarnings(warnings),
|
||||||
promptAvailable: !!supplement.prompt,
|
promptAvailable: !!supplement.prompt,
|
||||||
outputAvailable: true,
|
outputAvailable: true,
|
||||||
retentionHintDays: 30,
|
retentionHintDays: 30,
|
||||||
@@ -432,23 +457,58 @@ export class AiTraceService {
|
|||||||
return `user:${userId}`;
|
return `user:${userId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private collectWarnings(items: Array<{ parseReasons: unknown; matchReasons: unknown }>): string[] {
|
private collectWarnings(items: Array<{ parseReasons: unknown; matchReasons: unknown; itemIndex?: number }>): {
|
||||||
const warnings = new Set<string>();
|
warnings: AdminAiWarning[];
|
||||||
|
legacyWarnings: string[];
|
||||||
|
} {
|
||||||
|
const warnings: AdminAiWarning[] = [];
|
||||||
|
const legacyWarnings = new Set<string>();
|
||||||
|
const dedupe = new Set<string>();
|
||||||
|
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
|
const itemIndex = item.itemIndex != null ? item.itemIndex + 1 : undefined;
|
||||||
|
|
||||||
if (Array.isArray(item.parseReasons)) {
|
if (Array.isArray(item.parseReasons)) {
|
||||||
for (const reason of item.parseReasons) {
|
for (const reason of item.parseReasons) {
|
||||||
const text = String(reason ?? '').trim();
|
const text = String(reason ?? '').trim();
|
||||||
if (text.length > 0) warnings.add(`parse:${text}`);
|
if (!text) continue;
|
||||||
|
const warning: AdminAiWarning = {
|
||||||
|
...describeParseReason(text),
|
||||||
|
itemIndex,
|
||||||
|
};
|
||||||
|
const key = `${warning.kind}:${text}:${warning.itemIndex ?? 0}`;
|
||||||
|
if (dedupe.has(key)) continue;
|
||||||
|
dedupe.add(key);
|
||||||
|
warnings.push(warning);
|
||||||
|
legacyWarnings.add(`parse:${text}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Array.isArray(item.matchReasons)) {
|
if (Array.isArray(item.matchReasons)) {
|
||||||
for (const reason of item.matchReasons) {
|
for (const reason of item.matchReasons) {
|
||||||
const text = String(reason ?? '').trim();
|
const text = String(reason ?? '').trim();
|
||||||
if (text.length > 0) warnings.add(`match:${text}`);
|
if (!text) continue;
|
||||||
|
const warning: AdminAiWarning = {
|
||||||
|
...describeMatchReason(text),
|
||||||
|
itemIndex,
|
||||||
|
};
|
||||||
|
const key = `${warning.kind}:${text}:${warning.itemIndex ?? 0}`;
|
||||||
|
if (dedupe.has(key)) continue;
|
||||||
|
dedupe.add(key);
|
||||||
|
warnings.push(warning);
|
||||||
|
legacyWarnings.add(`match:${text}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return Array.from(warnings);
|
|
||||||
|
return {
|
||||||
|
warnings,
|
||||||
|
legacyWarnings: Array.from(legacyWarnings),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private countActionableWarnings(warnings: AdminAiWarning[]): number {
|
||||||
|
return warnings.filter((warning) => warning.severity !== 'info').length;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async listReceiptTraces(params: {
|
private async listReceiptTraces(params: {
|
||||||
@@ -553,6 +613,7 @@ export class AiTraceService {
|
|||||||
retryCount: null,
|
retryCount: null,
|
||||||
chunkCount: null,
|
chunkCount: null,
|
||||||
warnings: [],
|
warnings: [],
|
||||||
|
legacyWarnings: [],
|
||||||
error: row.error,
|
error: row.error,
|
||||||
prompt: row.prompt ? this.maskSensitiveText(row.prompt) : null,
|
prompt: row.prompt ? this.maskSensitiveText(row.prompt) : null,
|
||||||
rawOutput: this.maskRawOutput(row.rawOutput),
|
rawOutput: this.maskRawOutput(row.rawOutput),
|
||||||
|
|||||||
@@ -1,4 +1,13 @@
|
|||||||
export type FlyerImportMatchVia = 'alias' | 'exact' | 'token' | 'none';
|
export type FlyerImportMatchVia = 'alias' | 'exact' | 'token' | 'none';
|
||||||
|
|
||||||
|
export type FlyerReasonDescriptor = {
|
||||||
|
code: string;
|
||||||
|
kind: 'parse' | 'match';
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
severity: 'info' | 'warning' | 'error';
|
||||||
|
location: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type FlyerImportItem = {
|
export type FlyerImportItem = {
|
||||||
flyerItemId: number | null;
|
flyerItemId: number | null;
|
||||||
@@ -18,14 +27,16 @@ export type FlyerImportItem = {
|
|||||||
offerText: string | null;
|
offerText: string | null;
|
||||||
isOffer: boolean;
|
isOffer: boolean;
|
||||||
offerLimitText: string | null;
|
offerLimitText: string | null;
|
||||||
parseConfidence: number;
|
parseConfidence: number;
|
||||||
parseReasons: string[];
|
parseReasons: string[];
|
||||||
matchedProductId: number | null;
|
parseReasonsDetailed: FlyerReasonDescriptor[];
|
||||||
matchedProductName: string | null;
|
matchedProductId: number | null;
|
||||||
matchedVia: FlyerImportMatchVia;
|
matchedProductName: string | null;
|
||||||
matchConfidence: number;
|
matchedVia: FlyerImportMatchVia;
|
||||||
matchReasons: string[];
|
matchConfidence: number;
|
||||||
};
|
matchReasons: string[];
|
||||||
|
matchReasonsDetailed: FlyerReasonDescriptor[];
|
||||||
|
};
|
||||||
|
|
||||||
export type FlyerImportResponse = {
|
export type FlyerImportResponse = {
|
||||||
sessionId: number | null;
|
sessionId: number | null;
|
||||||
|
|||||||
@@ -97,6 +97,8 @@ describe('FlyerImportService', () => {
|
|||||||
expect(result.items).toHaveLength(1);
|
expect(result.items).toHaveLength(1);
|
||||||
expect(result.items[0].flyerItemId).toBe(99);
|
expect(result.items[0].flyerItemId).toBe(99);
|
||||||
expect(result.items[0].matchedVia).toBe('exact');
|
expect(result.items[0].matchedVia).toBe('exact');
|
||||||
|
expect(result.items[0].parseReasonsDetailed[0].title).toBe('AI-tolkad rad');
|
||||||
|
expect(result.items[0].matchReasonsDetailed[0].title).toBe('Exakt normaliserad matchning');
|
||||||
expect(result.sourceAvailable).toBe(false);
|
expect(result.sourceAvailable).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -9,14 +9,15 @@ import {
|
|||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { normalizeName } from '../common/utils/normalize-name';
|
import { normalizeName } from '../common/utils/normalize-name';
|
||||||
import {
|
import {
|
||||||
FlyerImportItem,
|
FlyerImportItem,
|
||||||
FlyerImportMatchVia,
|
FlyerImportMatchVia,
|
||||||
FlyerImportResponse,
|
FlyerImportResponse,
|
||||||
} from './dto/flyer-import.response';
|
} from './dto/flyer-import.response';
|
||||||
import { TextExtractorService } from './services/text-extractor.service';
|
import { TextExtractorService } from './services/text-extractor.service';
|
||||||
import { AiFlyerParserService } from './services/ai-flyer-parser.service';
|
import { AiFlyerParserService } from './services/ai-flyer-parser.service';
|
||||||
import { FlyerNormalizerService } from './services/flyer-normalizer.service';
|
import { FlyerNormalizerService } from './services/flyer-normalizer.service';
|
||||||
|
import { describeMatchReason, describeParseReason } from './services/reason-codes';
|
||||||
|
|
||||||
type FlyerParseItem = {
|
type FlyerParseItem = {
|
||||||
rawName: string;
|
rawName: string;
|
||||||
@@ -135,13 +136,15 @@ export class FlyerImportService {
|
|||||||
offerLimitText,
|
offerLimitText,
|
||||||
parseConfidence: item.confidence,
|
parseConfidence: item.confidence,
|
||||||
parseReasons: item.reasonCodes,
|
parseReasons: item.reasonCodes,
|
||||||
matchedProductId: match.product?.id ?? null,
|
parseReasonsDetailed: this.describeParseReasons(item.reasonCodes),
|
||||||
matchedProductName: match.product?.name ?? null,
|
matchedProductId: match.product?.id ?? null,
|
||||||
matchedVia: match.via,
|
matchedProductName: match.product?.name ?? null,
|
||||||
matchConfidence: match.confidence,
|
matchedVia: match.via,
|
||||||
matchReasons: match.reasons,
|
matchConfidence: match.confidence,
|
||||||
};
|
matchReasons: match.reasons,
|
||||||
});
|
matchReasonsDetailed: this.describeMatchReasons(match.reasons),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const persistedItems = await this.persistSessionWithItems(userId, parsed.retailer, items, file);
|
const persistedItems = await this.persistSessionWithItems(userId, parsed.retailer, items, file);
|
||||||
|
|
||||||
@@ -790,14 +793,24 @@ export class FlyerImportService {
|
|||||||
offerLimitText,
|
offerLimitText,
|
||||||
parseConfidence: item.parseConfidence,
|
parseConfidence: item.parseConfidence,
|
||||||
parseReasons: toStringArray(item.parseReasons),
|
parseReasons: toStringArray(item.parseReasons),
|
||||||
|
parseReasonsDetailed: this.describeParseReasons(toStringArray(item.parseReasons)),
|
||||||
matchedProductId: item.matchedProductId,
|
matchedProductId: item.matchedProductId,
|
||||||
matchedProductName: item.matchedProductName,
|
matchedProductName: item.matchedProductName,
|
||||||
matchedVia: normalizedMatchVia,
|
matchedVia: normalizedMatchVia,
|
||||||
matchConfidence: item.matchConfidence ?? 0,
|
matchConfidence: item.matchConfidence ?? 0,
|
||||||
matchReasons: toStringArray(item.matchReasons),
|
matchReasons: toStringArray(item.matchReasons),
|
||||||
|
matchReasonsDetailed: this.describeMatchReasons(toStringArray(item.matchReasons)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private describeParseReasons(codes: string[]) {
|
||||||
|
return codes.map((code) => describeParseReason(code));
|
||||||
|
}
|
||||||
|
|
||||||
|
private describeMatchReasons(codes: string[]) {
|
||||||
|
return codes.map((code) => describeMatchReason(code));
|
||||||
|
}
|
||||||
|
|
||||||
private buildCategoryPath(categoryRef?: {
|
private buildCategoryPath(categoryRef?: {
|
||||||
name: string;
|
name: string;
|
||||||
parent?: {
|
parent?: {
|
||||||
|
|||||||
@@ -4,6 +4,15 @@ import { AiFlyerParserService } from './ai-flyer-parser.service';
|
|||||||
describe('AiFlyerParserService dedupe', () => {
|
describe('AiFlyerParserService dedupe', () => {
|
||||||
const service = Object.create(AiFlyerParserService.prototype) as AiFlyerParserService;
|
const service = Object.create(AiFlyerParserService.prototype) as AiFlyerParserService;
|
||||||
|
|
||||||
|
it('buildPrompt enforces Swedish diacritics for cheese variants', () => {
|
||||||
|
const prompt = (service as any).buildPrompt('PRAST, HERRGARD, GREVE', 3000) as string;
|
||||||
|
|
||||||
|
expect(prompt).toContain('Behåll svenska diakritiska tecken (ä, å, ö, é)');
|
||||||
|
expect(prompt).toContain('Prästost');
|
||||||
|
expect(prompt).toContain('Herrgårdsost');
|
||||||
|
expect(prompt).toContain('Grevéost');
|
||||||
|
});
|
||||||
|
|
||||||
it('dedupes same product with minor offer text differences', () => {
|
it('dedupes same product with minor offer text differences', () => {
|
||||||
const items = [
|
const items = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -214,10 +214,11 @@ Regler:
|
|||||||
6) Om en rubrik/lista innehaller flera kommaseparerade namn och efterfoljande rad/rader innehaller gemensam brand, vikt, pris eller kampanjvillkor: expandera till separata objekt (en per namn) och arv all gemensam metadata.
|
6) Om en rubrik/lista innehaller flera kommaseparerade namn och efterfoljande rad/rader innehaller gemensam brand, vikt, pris eller kampanjvillkor: expandera till separata objekt (en per namn) och arv all gemensam metadata.
|
||||||
7) Tillämpa samma split-regel generellt for liknande tillbud (inte bara ost), nar listan tydligt representerar produktvarianter/smaker/sorter.
|
7) Tillämpa samma split-regel generellt for liknande tillbud (inte bara ost), nar listan tydligt representerar produktvarianter/smaker/sorter.
|
||||||
8) Splitta INTE om listan snarare ar ingredienser, avdelningar, eller otydlig marknadsforing utan tydlig produktvariant.
|
8) Splitta INTE om listan snarare ar ingredienser, avdelningar, eller otydlig marknadsforing utan tydlig produktvariant.
|
||||||
9) Specialregel ost: namn som PRAST/HERRGARD/GREVE ska normaliseras till Prastost/Herrgardsost/Greveost.
|
9) Specialregel ost: namn som PRAST/HERRGARD/GREVE ska normaliseras till Prästost/Herrgårdsost/Grevéost.
|
||||||
10) Om texten innehaller "ARLA KO" ska brand vara exakt "Arla Ko".
|
10) Om texten innehaller "ARLA KO" ska brand vara exakt "Arla Ko".
|
||||||
11) For ovan ostsorter ska category vara "Hardost".
|
11) For ovan ostsorter ska category vara "Hardost".
|
||||||
12) Returnera aldrig extra nycklar, text, markdown eller forklaringar utanfor JSON-arrayen.
|
12) Behåll svenska diakritiska tecken (ä, å, ö, é) i produktnamn. Returnera "Prästost", "Herrgårdsost", "Grevéost" - inte ASCII-versioner.
|
||||||
|
13) Returnera aldrig extra nycklar, text, markdown eller forklaringar utanfor JSON-arrayen.
|
||||||
|
|
||||||
Exempel bundle utdata:
|
Exempel bundle utdata:
|
||||||
[
|
[
|
||||||
@@ -258,7 +259,7 @@ Input-idé: "PRAST, HERRGARD, GREVE" + "ARLA KO" + gemensam vikt/pris.
|
|||||||
Output-idé:
|
Output-idé:
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
"name": "Prastost",
|
"name": "Prästost",
|
||||||
"brand": "Arla Ko",
|
"brand": "Arla Ko",
|
||||||
"category": "Hardost",
|
"category": "Hardost",
|
||||||
"isBundle": false,
|
"isBundle": false,
|
||||||
@@ -271,7 +272,7 @@ Output-idé:
|
|||||||
"offer": ["Max 3 forp/hushall"]
|
"offer": ["Max 3 forp/hushall"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Herrgardsost",
|
"name": "Herrgårdsost",
|
||||||
"brand": "Arla Ko",
|
"brand": "Arla Ko",
|
||||||
"category": "Hardost",
|
"category": "Hardost",
|
||||||
"isBundle": false,
|
"isBundle": false,
|
||||||
@@ -358,7 +359,7 @@ ${truncatedText}`;
|
|||||||
private normalizeName(name: string): string {
|
private normalizeName(name: string): string {
|
||||||
return name
|
return name
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.replace(/[^a-zåäö0-9\s]/g, '')
|
.replace(/[^a-zåäöé0-9\s]/g, '')
|
||||||
.replace(/\s+/g, ' ')
|
.replace(/\s+/g, ' ')
|
||||||
.trim();
|
.trim();
|
||||||
}
|
}
|
||||||
@@ -427,7 +428,7 @@ ${truncatedText}`;
|
|||||||
'Mistral-anrop timeout',
|
'Mistral-anrop timeout',
|
||||||
);
|
);
|
||||||
|
|
||||||
const content = response.choices?.[0]?.message?.content;
|
const content = this.ensureUtf8Content(response.choices?.[0]?.message?.content);
|
||||||
if (!content) {
|
if (!content) {
|
||||||
throw new BadRequestException('Tomt svar från AI-modellen.');
|
throw new BadRequestException('Tomt svar från AI-modellen.');
|
||||||
}
|
}
|
||||||
@@ -531,6 +532,40 @@ ${truncatedText}`;
|
|||||||
return hasCampaignMarkers ? normalized : '';
|
return hasCampaignMarkers ? normalized : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private ensureUtf8Content(content: unknown): string {
|
||||||
|
const asString = this.flattenContent(content);
|
||||||
|
if (!asString) return '';
|
||||||
|
|
||||||
|
const utf8 = Buffer.from(asString, 'utf8').toString('utf8');
|
||||||
|
if (this.debugEnabled && (asString.includes('\uFFFD') || utf8.includes('\uFFFD'))) {
|
||||||
|
const hex = Buffer.from(asString, 'utf8').toString('hex').slice(0, 256);
|
||||||
|
this.logger.debug(`Potential encoding issue in AI response (hex preview): ${hex}`);
|
||||||
|
}
|
||||||
|
return utf8;
|
||||||
|
}
|
||||||
|
|
||||||
|
private flattenContent(content: unknown): string {
|
||||||
|
if (typeof content === 'string') {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
if (Array.isArray(content)) {
|
||||||
|
return content
|
||||||
|
.map((part) => {
|
||||||
|
if (typeof part === 'string') return part;
|
||||||
|
if (part && typeof part === 'object' && 'text' in part) {
|
||||||
|
const text = (part as { text?: unknown }).text;
|
||||||
|
return typeof text === 'string' ? text : '';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
if (content == null) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return String(content);
|
||||||
|
}
|
||||||
|
|
||||||
private readPositiveIntEnv(key: string, fallback: number): number {
|
private readPositiveIntEnv(key: string, fallback: number): number {
|
||||||
const raw = process.env[key];
|
const raw = process.env[key];
|
||||||
if (!raw) return fallback;
|
if (!raw) return fallback;
|
||||||
|
|||||||
@@ -120,12 +120,28 @@ describe('FlyerNormalizerService', () => {
|
|||||||
const result = service.normalize(items);
|
const result = service.normalize(items);
|
||||||
|
|
||||||
expect(result).toHaveLength(3);
|
expect(result).toHaveLength(3);
|
||||||
expect(result.map((item) => item.rawName)).toEqual(['Prästost', 'Herrgårdsost', 'Greveost']);
|
expect(result.map((item) => item.rawName)).toEqual(['Prästost', 'Herrgårdsost', 'Grevéost']);
|
||||||
expect(result.every((item) => item.brand === 'Arla Ko')).toBe(true);
|
expect(result.every((item) => item.brand === 'Arla Ko')).toBe(true);
|
||||||
expect(result.every((item) => item.categoryHint === 'Hårdost')).toBe(true);
|
expect(result.every((item) => item.categoryHint === 'Hårdost')).toBe(true);
|
||||||
expect(result[0].parseReasons).toContain('split_cheese_variants');
|
expect(result[0].parseReasons).toContain('split_cheese_variants');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('normalizes PRAST token to Prästost', () => {
|
||||||
|
const items = [{ rawName: 'PRAST, GREVE', brand: 'ARLA KO' }];
|
||||||
|
|
||||||
|
const result = service.normalize(items);
|
||||||
|
|
||||||
|
expect(result.map((item) => item.rawName)).toContain('Prästost');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes GREVE token to Grevéost', () => {
|
||||||
|
const items = [{ rawName: 'GREVE, PRAST', brand: 'ARLA KO' }];
|
||||||
|
|
||||||
|
const result = service.normalize(items);
|
||||||
|
|
||||||
|
expect(result.map((item) => item.rawName)).toContain('Grevéost');
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps single cheese item unsplit but normalizes brand/category', () => {
|
it('keeps single cheese item unsplit but normalizes brand/category', () => {
|
||||||
const items = [
|
const items = [
|
||||||
{
|
{
|
||||||
@@ -184,5 +200,20 @@ describe('FlyerNormalizerService', () => {
|
|||||||
expect(result).toHaveLength(1);
|
expect(result).toHaveLength(1);
|
||||||
expect(result[0].rawName).toContain('Herrgårdsost');
|
expect(result[0].rawName).toContain('Herrgårdsost');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('fixes greveost typo in cheese context and preserves é', () => {
|
||||||
|
const items = [
|
||||||
|
{
|
||||||
|
rawName: 'Greveost skivad',
|
||||||
|
brand: 'Arla Ko',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = service.normalize(items);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].rawName).toContain('Grevéost');
|
||||||
|
expect(result[0].normalizedName).toContain('grevéost');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export class FlyerNormalizerService {
|
|||||||
private readonly CHEESE_VARIANT_TO_NAME: Record<string, string> = {
|
private readonly CHEESE_VARIANT_TO_NAME: Record<string, string> = {
|
||||||
prast: 'Prästost',
|
prast: 'Prästost',
|
||||||
herrgard: 'Herrgårdsost',
|
herrgard: 'Herrgårdsost',
|
||||||
greve: 'Greveost',
|
greve: 'Grevéost',
|
||||||
};
|
};
|
||||||
|
|
||||||
private readonly UNIT_MAPPING: Record<string, string> = {
|
private readonly UNIT_MAPPING: Record<string, string> = {
|
||||||
@@ -140,7 +140,7 @@ export class FlyerNormalizerService {
|
|||||||
private normalizeName(name: string): string {
|
private normalizeName(name: string): string {
|
||||||
return name
|
return name
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.replace(/[^a-zåäö0-9\s]/g, '')
|
.replace(/[^a-zåäöé0-9\s]/g, '')
|
||||||
.replace(/\s+/g, ' ')
|
.replace(/\s+/g, ' ')
|
||||||
.trim();
|
.trim();
|
||||||
}
|
}
|
||||||
@@ -256,6 +256,7 @@ export class FlyerNormalizerService {
|
|||||||
|
|
||||||
if (/ost\b|hårdost/i.test(value)) {
|
if (/ost\b|hårdost/i.test(value)) {
|
||||||
corrected = corrected.replace(/\bherg{1,2}årds?ost\b/gi, (match) => (match[0] === 'H' ? 'Herrgårdsost' : 'herrgårdsost'));
|
corrected = corrected.replace(/\bherg{1,2}årds?ost\b/gi, (match) => (match[0] === 'H' ? 'Herrgårdsost' : 'herrgårdsost'));
|
||||||
|
corrected = corrected.replace(/\bgreveost\b/gi, (match) => (match[0] === 'G' ? 'Grevéost' : 'grevéost'));
|
||||||
}
|
}
|
||||||
|
|
||||||
return corrected;
|
return corrected;
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { describeMatchReason, describeParseReason } from './reason-codes';
|
||||||
|
|
||||||
|
describe('reason-codes', () => {
|
||||||
|
it('describes known parse reasons in Swedish', () => {
|
||||||
|
expect(describeParseReason('ai_parsed')).toMatchObject({
|
||||||
|
kind: 'parse',
|
||||||
|
code: 'ai_parsed',
|
||||||
|
severity: 'info',
|
||||||
|
title: 'AI-tolkad rad',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(describeParseReason('split_cheese_variants')).toMatchObject({
|
||||||
|
kind: 'parse',
|
||||||
|
code: 'split_cheese_variants',
|
||||||
|
severity: 'info',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(describeParseReason('normalized')).toMatchObject({
|
||||||
|
kind: 'parse',
|
||||||
|
code: 'normalized',
|
||||||
|
severity: 'info',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(describeParseReason('low_confidence')).toMatchObject({
|
||||||
|
kind: 'parse',
|
||||||
|
code: 'low_confidence',
|
||||||
|
severity: 'warning',
|
||||||
|
title: 'Låg parsningskvalitet',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('describes known match reasons in Swedish', () => {
|
||||||
|
expect(describeMatchReason('no_match')).toMatchObject({
|
||||||
|
kind: 'match',
|
||||||
|
code: 'no_match',
|
||||||
|
severity: 'warning',
|
||||||
|
title: 'Ingen produktmatchning',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(describeMatchReason('alias_exact')).toMatchObject({
|
||||||
|
kind: 'match',
|
||||||
|
code: 'alias_exact',
|
||||||
|
severity: 'info',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(describeMatchReason('normalized_exact')).toMatchObject({
|
||||||
|
kind: 'match',
|
||||||
|
code: 'normalized_exact',
|
||||||
|
severity: 'info',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(describeMatchReason('token_overlap:0.72')).toMatchObject({
|
||||||
|
kind: 'match',
|
||||||
|
code: 'token_overlap',
|
||||||
|
severity: 'info',
|
||||||
|
title: 'Tokenmatchning',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(describeMatchReason('alias_points_to_missing_product')).toMatchObject({
|
||||||
|
kind: 'match',
|
||||||
|
code: 'alias_points_to_missing_product',
|
||||||
|
severity: 'error',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(describeMatchReason('empty_name')).toMatchObject({
|
||||||
|
kind: 'match',
|
||||||
|
code: 'empty_name',
|
||||||
|
severity: 'error',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
export type ReasonKind = 'parse' | 'match';
|
||||||
|
export type ReasonSeverity = 'info' | 'warning' | 'error';
|
||||||
|
|
||||||
|
export type ParseReasonCode =
|
||||||
|
| 'ai_parsed'
|
||||||
|
| 'split_cheese_variants'
|
||||||
|
| 'normalized'
|
||||||
|
| 'low_confidence';
|
||||||
|
|
||||||
|
export type MatchReasonCode =
|
||||||
|
| 'no_match'
|
||||||
|
| 'alias_exact'
|
||||||
|
| 'normalized_exact'
|
||||||
|
| 'token_overlap'
|
||||||
|
| 'alias_points_to_missing_product'
|
||||||
|
| 'empty_name';
|
||||||
|
|
||||||
|
export type FlyerReasonDescriptor = {
|
||||||
|
code: string;
|
||||||
|
kind: ReasonKind;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
severity: ReasonSeverity;
|
||||||
|
location: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DescribeReasonContext = {
|
||||||
|
location?: string | null;
|
||||||
|
itemIndex?: number;
|
||||||
|
lang?: 'sv';
|
||||||
|
};
|
||||||
|
|
||||||
|
const PARSE_DEFAULT_LOCATION = 'Steg: AI-parser';
|
||||||
|
const MATCH_DEFAULT_LOCATION = 'Steg: matchning mot dina produkter';
|
||||||
|
|
||||||
|
export function describeParseReason(
|
||||||
|
rawCode: string,
|
||||||
|
context?: DescribeReasonContext,
|
||||||
|
): FlyerReasonDescriptor {
|
||||||
|
const code = normalizeCode(rawCode);
|
||||||
|
const location = context?.location ?? PARSE_DEFAULT_LOCATION;
|
||||||
|
|
||||||
|
switch (code) {
|
||||||
|
case 'ai_parsed':
|
||||||
|
return {
|
||||||
|
code,
|
||||||
|
kind: 'parse',
|
||||||
|
title: 'AI-tolkad rad',
|
||||||
|
message: 'Raden tolkades av AI utan att en deterministisk regel matchade.',
|
||||||
|
severity: 'info',
|
||||||
|
location,
|
||||||
|
};
|
||||||
|
case 'split_cheese_variants':
|
||||||
|
return {
|
||||||
|
code,
|
||||||
|
kind: 'parse',
|
||||||
|
title: 'Variant-split',
|
||||||
|
message: 'Gruppannonsen expanderades till individuella ostvarianter.',
|
||||||
|
severity: 'info',
|
||||||
|
location,
|
||||||
|
};
|
||||||
|
case 'normalized':
|
||||||
|
return {
|
||||||
|
code,
|
||||||
|
kind: 'parse',
|
||||||
|
title: 'Normaliserad rad',
|
||||||
|
message: 'Produkttexten normaliserades för bättre matchning.',
|
||||||
|
severity: 'info',
|
||||||
|
location,
|
||||||
|
};
|
||||||
|
case 'low_confidence':
|
||||||
|
return {
|
||||||
|
code,
|
||||||
|
kind: 'parse',
|
||||||
|
title: 'Låg parsningskvalitet',
|
||||||
|
message: 'Modellens säkerhet är låg, granska raden manuellt.',
|
||||||
|
severity: 'warning',
|
||||||
|
location,
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
code,
|
||||||
|
kind: 'parse',
|
||||||
|
title: 'Okänd parserorsak',
|
||||||
|
message: `En okänd parserorsak rapporterades: ${rawCode}`,
|
||||||
|
severity: 'warning',
|
||||||
|
location,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function describeMatchReason(
|
||||||
|
rawCode: string,
|
||||||
|
context?: DescribeReasonContext,
|
||||||
|
): FlyerReasonDescriptor {
|
||||||
|
const location = context?.location ?? MATCH_DEFAULT_LOCATION;
|
||||||
|
const code = normalizeCode(rawCode);
|
||||||
|
|
||||||
|
switch (code) {
|
||||||
|
case 'no_match':
|
||||||
|
return {
|
||||||
|
code,
|
||||||
|
kind: 'match',
|
||||||
|
title: 'Ingen produktmatchning',
|
||||||
|
message:
|
||||||
|
'Vi kunde inte hitta någon befintlig produkt som matchar texten på flyern.',
|
||||||
|
severity: 'warning',
|
||||||
|
location,
|
||||||
|
};
|
||||||
|
case 'alias_exact':
|
||||||
|
return {
|
||||||
|
code,
|
||||||
|
kind: 'match',
|
||||||
|
title: 'Aliasmatchning',
|
||||||
|
message: 'Raden matchades exakt via ett registrerat alias.',
|
||||||
|
severity: 'info',
|
||||||
|
location,
|
||||||
|
};
|
||||||
|
case 'normalized_exact':
|
||||||
|
return {
|
||||||
|
code,
|
||||||
|
kind: 'match',
|
||||||
|
title: 'Exakt normaliserad matchning',
|
||||||
|
message: 'Raden matchades exakt efter normalisering av produktnamnet.',
|
||||||
|
severity: 'info',
|
||||||
|
location,
|
||||||
|
};
|
||||||
|
case 'token_overlap': {
|
||||||
|
const overlap = parseTokenOverlap(rawCode);
|
||||||
|
const overlapSuffix = overlap == null ? '' : ` (överlapp: ${Math.round(overlap * 100)}%)`;
|
||||||
|
return {
|
||||||
|
code,
|
||||||
|
kind: 'match',
|
||||||
|
title: 'Tokenmatchning',
|
||||||
|
message: `Raden matchades med tokenöverlapp mot en befintlig produkt${overlapSuffix}.`,
|
||||||
|
severity: 'info',
|
||||||
|
location,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'alias_points_to_missing_product':
|
||||||
|
return {
|
||||||
|
code,
|
||||||
|
kind: 'match',
|
||||||
|
title: 'Trasig alias-koppling',
|
||||||
|
message: 'Ett alias pekar på en produkt som inte längre finns.',
|
||||||
|
severity: 'error',
|
||||||
|
location,
|
||||||
|
};
|
||||||
|
case 'empty_name':
|
||||||
|
return {
|
||||||
|
code,
|
||||||
|
kind: 'match',
|
||||||
|
title: 'Tomt produktnamn',
|
||||||
|
message: 'Raden saknar tolkbart produktnamn.',
|
||||||
|
severity: 'error',
|
||||||
|
location,
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
code,
|
||||||
|
kind: 'match',
|
||||||
|
title: 'Okänd matchorsak',
|
||||||
|
message: `En okänd matchorsak rapporterades: ${rawCode}`,
|
||||||
|
severity: 'warning',
|
||||||
|
location,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCode(rawCode: string): string {
|
||||||
|
const trimmed = String(rawCode ?? '').trim();
|
||||||
|
if (trimmed.startsWith('token_overlap:')) {
|
||||||
|
return 'token_overlap';
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTokenOverlap(rawCode: string): number | null {
|
||||||
|
const match = String(rawCode).trim().match(/^token_overlap:(\d+(?:\.\d+)?)$/);
|
||||||
|
if (!match) return null;
|
||||||
|
const parsed = Number.parseFloat(match[1]);
|
||||||
|
if (!Number.isFinite(parsed)) return null;
|
||||||
|
return Math.max(0, Math.min(1, parsed));
|
||||||
|
}
|
||||||
@@ -1,5 +1,53 @@
|
|||||||
import 'admin_ai_trace.dart';
|
import 'admin_ai_trace.dart';
|
||||||
|
|
||||||
|
class AdminAiWarning {
|
||||||
|
final String code;
|
||||||
|
final String kind;
|
||||||
|
final String title;
|
||||||
|
final String message;
|
||||||
|
final String severity;
|
||||||
|
final String? location;
|
||||||
|
final int? itemIndex;
|
||||||
|
|
||||||
|
const AdminAiWarning({
|
||||||
|
required this.code,
|
||||||
|
required this.kind,
|
||||||
|
required this.title,
|
||||||
|
required this.message,
|
||||||
|
required this.severity,
|
||||||
|
required this.location,
|
||||||
|
required this.itemIndex,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory AdminAiWarning.fromJson(Map<String, dynamic> json) {
|
||||||
|
return AdminAiWarning(
|
||||||
|
code: (json['code'] ?? '').toString(),
|
||||||
|
kind: (json['kind'] ?? '').toString(),
|
||||||
|
title: (json['title'] ?? '').toString(),
|
||||||
|
message: (json['message'] ?? '').toString(),
|
||||||
|
severity: (json['severity'] ?? '').toString(),
|
||||||
|
location: json['location']?.toString(),
|
||||||
|
itemIndex: (json['itemIndex'] as num?)?.toInt(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
factory AdminAiWarning.fromLegacy(String value) {
|
||||||
|
final trimmed = value.trim();
|
||||||
|
final parts = trimmed.split(':');
|
||||||
|
final kind = parts.isNotEmpty ? parts.first : 'parse';
|
||||||
|
final code = parts.length > 1 ? parts.sublist(1).join(':') : trimmed;
|
||||||
|
return AdminAiWarning(
|
||||||
|
code: code,
|
||||||
|
kind: kind,
|
||||||
|
title: trimmed,
|
||||||
|
message: trimmed,
|
||||||
|
severity: 'warning',
|
||||||
|
location: null,
|
||||||
|
itemIndex: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class AdminAiTraceDetail {
|
class AdminAiTraceDetail {
|
||||||
final String id;
|
final String id;
|
||||||
final AdminAiTraceSource source;
|
final AdminAiTraceSource source;
|
||||||
@@ -13,7 +61,8 @@ class AdminAiTraceDetail {
|
|||||||
final int? durationMs;
|
final int? durationMs;
|
||||||
final int? retryCount;
|
final int? retryCount;
|
||||||
final int? chunkCount;
|
final int? chunkCount;
|
||||||
final List<String> warnings;
|
final List<AdminAiWarning> warnings;
|
||||||
|
final List<String> legacyWarnings;
|
||||||
final String? error;
|
final String? error;
|
||||||
final String? prompt;
|
final String? prompt;
|
||||||
final String? rawOutput;
|
final String? rawOutput;
|
||||||
@@ -34,6 +83,7 @@ class AdminAiTraceDetail {
|
|||||||
required this.retryCount,
|
required this.retryCount,
|
||||||
required this.chunkCount,
|
required this.chunkCount,
|
||||||
required this.warnings,
|
required this.warnings,
|
||||||
|
required this.legacyWarnings,
|
||||||
required this.error,
|
required this.error,
|
||||||
required this.prompt,
|
required this.prompt,
|
||||||
required this.rawOutput,
|
required this.rawOutput,
|
||||||
@@ -43,6 +93,7 @@ class AdminAiTraceDetail {
|
|||||||
|
|
||||||
factory AdminAiTraceDetail.fromJson(Map<String, dynamic> json) {
|
factory AdminAiTraceDetail.fromJson(Map<String, dynamic> json) {
|
||||||
final warningsRaw = (json['warnings'] as List<dynamic>?) ?? const [];
|
final warningsRaw = (json['warnings'] as List<dynamic>?) ?? const [];
|
||||||
|
final legacyWarningsRaw = (json['legacyWarnings'] as List<dynamic>?) ?? const [];
|
||||||
final normalizedOutputMap = json['normalizedOutput'] is Map
|
final normalizedOutputMap = json['normalizedOutput'] is Map
|
||||||
? Map<String, dynamic>.from(json['normalizedOutput'] as Map)
|
? Map<String, dynamic>.from(json['normalizedOutput'] as Map)
|
||||||
: null;
|
: null;
|
||||||
@@ -64,7 +115,15 @@ class AdminAiTraceDetail {
|
|||||||
durationMs: (json['durationMs'] as num?)?.toInt(),
|
durationMs: (json['durationMs'] as num?)?.toInt(),
|
||||||
retryCount: (json['retryCount'] as num?)?.toInt(),
|
retryCount: (json['retryCount'] as num?)?.toInt(),
|
||||||
chunkCount: (json['chunkCount'] as num?)?.toInt(),
|
chunkCount: (json['chunkCount'] as num?)?.toInt(),
|
||||||
warnings: warningsRaw.map((entry) => entry.toString()).toList(),
|
warnings: warningsRaw
|
||||||
|
.map((entry) {
|
||||||
|
if (entry is Map) {
|
||||||
|
return AdminAiWarning.fromJson(Map<String, dynamic>.from(entry));
|
||||||
|
}
|
||||||
|
return AdminAiWarning.fromLegacy(entry.toString());
|
||||||
|
})
|
||||||
|
.toList(),
|
||||||
|
legacyWarnings: legacyWarningsRaw.map((entry) => entry.toString()).toList(),
|
||||||
error: json['error']?.toString(),
|
error: json['error']?.toString(),
|
||||||
prompt: json['prompt']?.toString(),
|
prompt: json['prompt']?.toString(),
|
||||||
rawOutput: json['rawOutput']?.toString(),
|
rawOutput: json['rawOutput']?.toString(),
|
||||||
|
|||||||
@@ -137,6 +137,41 @@ class _AdminAiPanelState extends ConsumerState<AdminAiPanel> {
|
|||||||
return const JsonEncoder.withIndent(' ').convert(data);
|
return const JsonEncoder.withIndent(' ').convert(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _formatWarningLine(AdminAiWarning warning) {
|
||||||
|
final rowSuffix = warning.itemIndex == null ? '' : ' (rad ${warning.itemIndex})';
|
||||||
|
return '[${warning.severity}] ${warning.title}$rowSuffix: ${warning.message}';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _buildErrorReport({
|
||||||
|
required AdminAiTraceDetail detail,
|
||||||
|
required String prettyOutput,
|
||||||
|
}) {
|
||||||
|
final warningCount = detail.warnings.length;
|
||||||
|
final buffer = StringBuffer()
|
||||||
|
..writeln('[AI-trace ${detail.id}]')
|
||||||
|
..writeln('Modell: ${detail.model ?? 'okänd'}')
|
||||||
|
..writeln('Status: ${detail.status.name} ($warningCount varningar)')
|
||||||
|
..writeln('Tid: ${detail.createdAt.toIso8601String()}')
|
||||||
|
..writeln();
|
||||||
|
|
||||||
|
if (detail.warnings.isNotEmpty) {
|
||||||
|
buffer.writeln('Varningar:');
|
||||||
|
for (final warning in detail.warnings) {
|
||||||
|
buffer.writeln('- ${_formatWarningLine(warning)}');
|
||||||
|
}
|
||||||
|
buffer.writeln();
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer
|
||||||
|
..writeln('Prompt:')
|
||||||
|
..writeln((detail.prompt ?? '').trim().isEmpty ? '[saknas]' : detail.prompt!.trim())
|
||||||
|
..writeln()
|
||||||
|
..writeln('Raw output:')
|
||||||
|
..writeln((detail.rawOutput ?? '').trim().isEmpty ? prettyOutput : detail.rawOutput!.trim());
|
||||||
|
|
||||||
|
return buffer.toString().trimRight();
|
||||||
|
}
|
||||||
|
|
||||||
Color _statusColor(AdminAiTraceStatus status, ColorScheme scheme) {
|
Color _statusColor(AdminAiTraceStatus status, ColorScheme scheme) {
|
||||||
return switch (status) {
|
return switch (status) {
|
||||||
AdminAiTraceStatus.success => Colors.green.shade700,
|
AdminAiTraceStatus.success => Colors.green.shade700,
|
||||||
@@ -348,16 +383,30 @@ class _AdminAiPanelState extends ConsumerState<AdminAiPanel> {
|
|||||||
? const <String, dynamic>{}
|
? const <String, dynamic>{}
|
||||||
: {'rawOutput': detail.rawOutput});
|
: {'rawOutput': detail.rawOutput});
|
||||||
final prettyOutput = _prettyOutputFor(detail.id, outputJson);
|
final prettyOutput = _prettyOutputFor(detail.id, outputJson);
|
||||||
|
final errorReport = _buildErrorReport(detail: detail, prettyOutput: prettyOutput);
|
||||||
|
|
||||||
return ListView(
|
return ListView(
|
||||||
children: [
|
children: [
|
||||||
_TraceMetaCard(detail: detail, formatDateTime: _formatDateTime),
|
_TraceMetaCard(detail: detail, formatDateTime: _formatDateTime),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
onPressed: () => _copyText(errorReport, 'Felrapport'),
|
||||||
|
icon: const Icon(Icons.bug_report_outlined),
|
||||||
|
label: const Text('Kopiera felrapport'),
|
||||||
|
),
|
||||||
|
),
|
||||||
if (detail.warnings.isNotEmpty) ...[
|
if (detail.warnings.isNotEmpty) ...[
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_WarningsCard(
|
_WarningsCard(
|
||||||
warnings: detail.warnings,
|
warnings: detail.warnings,
|
||||||
onCopyWarning: (warning) => _copyText(warning, 'Varning'),
|
onCopyWarning: (warning) =>
|
||||||
onCopyAll: () => _copyText(detail.warnings.join('\n'), 'Varningar'),
|
_copyText(_formatWarningLine(warning), 'Varning'),
|
||||||
|
onCopyAll: () => _copyText(
|
||||||
|
detail.warnings.map(_formatWarningLine).join('\n'),
|
||||||
|
'Varningar',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
@@ -583,8 +632,8 @@ class _OutputJsonCardState extends State<_OutputJsonCard> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _WarningsCard extends StatelessWidget {
|
class _WarningsCard extends StatelessWidget {
|
||||||
final List<String> warnings;
|
final List<AdminAiWarning> warnings;
|
||||||
final void Function(String warning) onCopyWarning;
|
final void Function(AdminAiWarning warning) onCopyWarning;
|
||||||
final VoidCallback onCopyAll;
|
final VoidCallback onCopyAll;
|
||||||
|
|
||||||
const _WarningsCard({
|
const _WarningsCard({
|
||||||
@@ -622,8 +671,27 @@ class _WarningsCard extends StatelessWidget {
|
|||||||
(warning) => ListTile(
|
(warning) => ListTile(
|
||||||
dense: true,
|
dense: true,
|
||||||
contentPadding: EdgeInsets.zero,
|
contentPadding: EdgeInsets.zero,
|
||||||
leading: const Icon(Icons.warning_amber_rounded, size: 18),
|
leading: Icon(_severityIcon(warning), size: 18, color: _severityColor(warning, theme)),
|
||||||
title: SelectableText(warning),
|
title: Text(
|
||||||
|
warning.title,
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
subtitle: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(warning.message),
|
||||||
|
if ((warning.location ?? '').trim().isNotEmpty)
|
||||||
|
Text(
|
||||||
|
warning.location!,
|
||||||
|
style: theme.textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
if (warning.itemIndex != null)
|
||||||
|
Text(
|
||||||
|
'Rad: ${warning.itemIndex}',
|
||||||
|
style: theme.textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
trailing: IconButton(
|
trailing: IconButton(
|
||||||
tooltip: 'Kopiera varning',
|
tooltip: 'Kopiera varning',
|
||||||
onPressed: () => onCopyWarning(warning),
|
onPressed: () => onCopyWarning(warning),
|
||||||
@@ -636,4 +704,26 @@ class _WarningsCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
IconData _severityIcon(AdminAiWarning warning) {
|
||||||
|
switch (warning.severity) {
|
||||||
|
case 'error':
|
||||||
|
return Icons.error_outline;
|
||||||
|
case 'warning':
|
||||||
|
return Icons.warning_amber_rounded;
|
||||||
|
default:
|
||||||
|
return Icons.info_outline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Color _severityColor(AdminAiWarning warning, ThemeData theme) {
|
||||||
|
switch (warning.severity) {
|
||||||
|
case 'error':
|
||||||
|
return theme.colorScheme.error;
|
||||||
|
case 'warning':
|
||||||
|
return Colors.orange.shade700;
|
||||||
|
default:
|
||||||
|
return theme.colorScheme.primary;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
class FlyerImportItem {
|
import 'flyer_reason_descriptor.dart';
|
||||||
|
|
||||||
|
class FlyerImportItem {
|
||||||
final int? flyerItemId;
|
final int? flyerItemId;
|
||||||
final String rawName;
|
final String rawName;
|
||||||
final String normalizedName;
|
final String normalizedName;
|
||||||
@@ -12,11 +14,14 @@ class FlyerImportItem {
|
|||||||
final double? comparisonPrice;
|
final double? comparisonPrice;
|
||||||
final String? comparisonUnit;
|
final String? comparisonUnit;
|
||||||
final double? parseConfidence;
|
final double? parseConfidence;
|
||||||
final List<String> parseReasons;
|
final List<String> parseReasons;
|
||||||
final int? matchedProductId;
|
final List<FlyerReasonDescriptor> parseReasonsDetailed;
|
||||||
final String? matchedProductName;
|
final int? matchedProductId;
|
||||||
final String? matchedVia;
|
final String? matchedProductName;
|
||||||
final double? matchConfidence;
|
final String? matchedVia;
|
||||||
|
final double? matchConfidence;
|
||||||
|
final List<String> matchReasons;
|
||||||
|
final List<FlyerReasonDescriptor> matchReasonsDetailed;
|
||||||
|
|
||||||
FlyerImportItem({
|
FlyerImportItem({
|
||||||
required this.flyerItemId,
|
required this.flyerItemId,
|
||||||
@@ -31,13 +36,16 @@ class FlyerImportItem {
|
|||||||
this.offerLimitText,
|
this.offerLimitText,
|
||||||
this.comparisonPrice,
|
this.comparisonPrice,
|
||||||
this.comparisonUnit,
|
this.comparisonUnit,
|
||||||
this.parseConfidence,
|
this.parseConfidence,
|
||||||
this.parseReasons = const [],
|
this.parseReasons = const [],
|
||||||
this.matchedProductId,
|
this.parseReasonsDetailed = const [],
|
||||||
this.matchedProductName,
|
this.matchedProductId,
|
||||||
this.matchedVia,
|
this.matchedProductName,
|
||||||
this.matchConfidence,
|
this.matchedVia,
|
||||||
});
|
this.matchConfidence,
|
||||||
|
this.matchReasons = const [],
|
||||||
|
this.matchReasonsDetailed = const [],
|
||||||
|
});
|
||||||
|
|
||||||
factory FlyerImportItem.fromJson(Map<String, dynamic> json) {
|
factory FlyerImportItem.fromJson(Map<String, dynamic> json) {
|
||||||
return FlyerImportItem(
|
return FlyerImportItem(
|
||||||
@@ -53,12 +61,25 @@ class FlyerImportItem {
|
|||||||
offerLimitText: json['offerLimitText'] as String?,
|
offerLimitText: json['offerLimitText'] as String?,
|
||||||
comparisonPrice: (json['comparisonPrice'] as num?)?.toDouble(),
|
comparisonPrice: (json['comparisonPrice'] as num?)?.toDouble(),
|
||||||
comparisonUnit: json['comparisonUnit'] as String?,
|
comparisonUnit: json['comparisonUnit'] as String?,
|
||||||
parseConfidence: (json['parseConfidence'] as num?)?.toDouble(),
|
parseConfidence: (json['parseConfidence'] as num?)?.toDouble(),
|
||||||
parseReasons: (json['parseReasons'] as List?)?.map((e) => e.toString()).toList() ?? const [],
|
parseReasons: (json['parseReasons'] as List?)?.map((e) => e.toString()).toList() ?? const [],
|
||||||
matchedProductId: (json['matchedProductId'] as num?)?.toInt(),
|
parseReasonsDetailed:
|
||||||
matchedProductName: json['matchedProductName'] as String?,
|
(json['parseReasonsDetailed'] as List?)
|
||||||
matchedVia: json['matchedVia'] as String?,
|
?.whereType<Map>()
|
||||||
matchConfidence: (json['matchConfidence'] as num?)?.toDouble(),
|
.map((e) => FlyerReasonDescriptor.fromJson(Map<String, dynamic>.from(e)))
|
||||||
|
.toList() ??
|
||||||
|
const [],
|
||||||
|
matchedProductId: (json['matchedProductId'] as num?)?.toInt(),
|
||||||
|
matchedProductName: json['matchedProductName'] as String?,
|
||||||
|
matchedVia: json['matchedVia'] as String?,
|
||||||
|
matchConfidence: (json['matchConfidence'] as num?)?.toDouble(),
|
||||||
|
matchReasons: (json['matchReasons'] as List?)?.map((e) => e.toString()).toList() ?? const [],
|
||||||
|
matchReasonsDetailed:
|
||||||
|
(json['matchReasonsDetailed'] as List?)
|
||||||
|
?.whereType<Map>()
|
||||||
|
.map((e) => FlyerReasonDescriptor.fromJson(Map<String, dynamic>.from(e)))
|
||||||
|
.toList() ??
|
||||||
|
const [],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,10 +99,15 @@ class FlyerImportItem {
|
|||||||
'comparisonUnit': comparisonUnit,
|
'comparisonUnit': comparisonUnit,
|
||||||
'parseConfidence': parseConfidence,
|
'parseConfidence': parseConfidence,
|
||||||
'parseReasons': parseReasons,
|
'parseReasons': parseReasons,
|
||||||
|
'parseReasonsDetailed':
|
||||||
|
parseReasonsDetailed.map((reason) => reason.toJson()).toList(),
|
||||||
'matchedProductId': matchedProductId,
|
'matchedProductId': matchedProductId,
|
||||||
'matchedProductName': matchedProductName,
|
'matchedProductName': matchedProductName,
|
||||||
'matchedVia': matchedVia,
|
'matchedVia': matchedVia,
|
||||||
'matchConfidence': matchConfidence,
|
'matchConfidence': matchConfidence,
|
||||||
|
'matchReasons': matchReasons,
|
||||||
|
'matchReasonsDetailed':
|
||||||
|
matchReasonsDetailed.map((reason) => reason.toJson()).toList(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,10 +131,13 @@ class FlyerImportItem {
|
|||||||
comparisonUnit: comparisonUnit,
|
comparisonUnit: comparisonUnit,
|
||||||
parseConfidence: parseConfidence,
|
parseConfidence: parseConfidence,
|
||||||
parseReasons: parseReasons,
|
parseReasons: parseReasons,
|
||||||
|
parseReasonsDetailed: parseReasonsDetailed,
|
||||||
matchedProductId: matchedProductId,
|
matchedProductId: matchedProductId,
|
||||||
matchedProductName: matchedProductName,
|
matchedProductName: matchedProductName,
|
||||||
matchedVia: matchedVia,
|
matchedVia: matchedVia,
|
||||||
matchConfidence: matchConfidence,
|
matchConfidence: matchConfidence,
|
||||||
|
matchReasons: matchReasons,
|
||||||
|
matchReasonsDetailed: matchReasonsDetailed,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
class FlyerReasonDescriptor {
|
||||||
|
final String code;
|
||||||
|
final String kind;
|
||||||
|
final String title;
|
||||||
|
final String message;
|
||||||
|
final String severity;
|
||||||
|
final String? location;
|
||||||
|
|
||||||
|
const FlyerReasonDescriptor({
|
||||||
|
required this.code,
|
||||||
|
required this.kind,
|
||||||
|
required this.title,
|
||||||
|
required this.message,
|
||||||
|
required this.severity,
|
||||||
|
required this.location,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory FlyerReasonDescriptor.fromJson(Map<String, dynamic> json) {
|
||||||
|
return FlyerReasonDescriptor(
|
||||||
|
code: (json['code'] ?? '').toString(),
|
||||||
|
kind: (json['kind'] ?? '').toString(),
|
||||||
|
title: (json['title'] ?? '').toString(),
|
||||||
|
message: (json['message'] ?? '').toString(),
|
||||||
|
severity: (json['severity'] ?? '').toString(),
|
||||||
|
location: json['location']?.toString(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'code': code,
|
||||||
|
'kind': kind,
|
||||||
|
'title': title,
|
||||||
|
'message': message,
|
||||||
|
'severity': severity,
|
||||||
|
'location': location,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import 'package:file_picker/file_picker.dart';
|
import 'package:file_picker/file_picker.dart';
|
||||||
import 'dart:typed_data';
|
|
||||||
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 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
@@ -14,6 +14,7 @@ import '../../shopping_list/data/shopping_list_providers.dart';
|
|||||||
import '../data/flyer_import_session.dart';
|
import '../data/flyer_import_session.dart';
|
||||||
import '../data/import_providers.dart';
|
import '../data/import_providers.dart';
|
||||||
import '../domain/flyer_import_item.dart';
|
import '../domain/flyer_import_item.dart';
|
||||||
|
import '../domain/flyer_reason_descriptor.dart';
|
||||||
import '../domain/flyer_import_result.dart';
|
import '../domain/flyer_import_result.dart';
|
||||||
import 'error_dialog.dart';
|
import 'error_dialog.dart';
|
||||||
|
|
||||||
@@ -32,6 +33,7 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
|
|||||||
List<AdminCategoryNode> _categoryTree = const [];
|
List<AdminCategoryNode> _categoryTree = const [];
|
||||||
FlyerImportResult? _result;
|
FlyerImportResult? _result;
|
||||||
final Map<int, bool> _selected = {};
|
final Map<int, bool> _selected = {};
|
||||||
|
final Map<int, bool> _expandedReasonRows = {};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -90,6 +92,7 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
|
|||||||
_selected
|
_selected
|
||||||
..clear()
|
..clear()
|
||||||
..addAll(selected);
|
..addAll(selected);
|
||||||
|
_expandedReasonRows.clear();
|
||||||
});
|
});
|
||||||
await _loadRestoredSourceIfNeeded(serverResult, token);
|
await _loadRestoredSourceIfNeeded(serverResult, token);
|
||||||
notifier.setImportedResult(
|
notifier.setImportedResult(
|
||||||
@@ -117,6 +120,7 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
|
|||||||
_selected
|
_selected
|
||||||
..clear()
|
..clear()
|
||||||
..addAll(selected);
|
..addAll(selected);
|
||||||
|
_expandedReasonRows.clear();
|
||||||
});
|
});
|
||||||
await _loadRestoredSourceIfNeeded(latest, token);
|
await _loadRestoredSourceIfNeeded(latest, token);
|
||||||
notifier.setImportedResult(
|
notifier.setImportedResult(
|
||||||
@@ -130,6 +134,7 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
|
|||||||
_selected
|
_selected
|
||||||
..clear()
|
..clear()
|
||||||
..addAll(session.selected);
|
..addAll(session.selected);
|
||||||
|
_expandedReasonRows.clear();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
@@ -141,6 +146,7 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
|
|||||||
_selected
|
_selected
|
||||||
..clear()
|
..clear()
|
||||||
..addAll(session.selected);
|
..addAll(session.selected);
|
||||||
|
_expandedReasonRows.clear();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -196,6 +202,7 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
|
|||||||
_selected
|
_selected
|
||||||
..clear()
|
..clear()
|
||||||
..addAll(selected);
|
..addAll(selected);
|
||||||
|
_expandedReasonRows.clear();
|
||||||
});
|
});
|
||||||
ref.read(flyerImportSessionProvider.notifier).setImportedResult(
|
ref.read(flyerImportSessionProvider.notifier).setImportedResult(
|
||||||
result: parsed,
|
result: parsed,
|
||||||
@@ -494,6 +501,39 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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')));
|
||||||
|
}
|
||||||
|
|
||||||
|
IconData _reasonSeverityIcon(String severity) {
|
||||||
|
switch (severity) {
|
||||||
|
case 'error':
|
||||||
|
return Icons.error_outline;
|
||||||
|
case 'warning':
|
||||||
|
return Icons.warning_amber_rounded;
|
||||||
|
default:
|
||||||
|
return Icons.info_outline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Color _reasonSeverityColor(String severity) {
|
||||||
|
switch (severity) {
|
||||||
|
case 'error':
|
||||||
|
return Colors.red.shade700;
|
||||||
|
case 'warning':
|
||||||
|
return Colors.orange.shade700;
|
||||||
|
default:
|
||||||
|
return Colors.blue.shade700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int _countWarningReasons(List<FlyerReasonDescriptor> reasons) {
|
||||||
|
return reasons.where((reason) => reason.severity == 'warning' || reason.severity == 'error').length;
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildWarningsPanel(ThemeData theme) {
|
Widget _buildWarningsPanel(ThemeData theme) {
|
||||||
final warnings = _result?.warnings ?? const <String>[];
|
final warnings = _result?.warnings ?? const <String>[];
|
||||||
if (warnings.isEmpty) return const SizedBox.shrink();
|
if (warnings.isEmpty) return const SizedBox.shrink();
|
||||||
@@ -526,10 +566,24 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
|
|||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
...warnings.map((warning) => Padding(
|
...warnings.map((warning) => Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 4),
|
padding: const EdgeInsets.only(bottom: 4),
|
||||||
child: Text(
|
child: InkWell(
|
||||||
'• $warning',
|
onTap: () => _copyText(warning, 'Varning'),
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
borderRadius: BorderRadius.circular(4),
|
||||||
color: Colors.amber.shade900,
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'• $warning',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: Colors.amber.shade900,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Icon(Icons.copy, size: 14, color: Colors.amber.shade800),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)),
|
)),
|
||||||
@@ -672,6 +726,12 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
|
|||||||
final sanitizedOfferText = item.offerText == null
|
final sanitizedOfferText = item.offerText == null
|
||||||
? ''
|
? ''
|
||||||
: _removeLimitTextFromOfferText(item.offerText!, limitText);
|
: _removeLimitTextFromOfferText(item.offerText!, limitText);
|
||||||
|
final detailedReasons = [
|
||||||
|
...item.parseReasonsDetailed,
|
||||||
|
...item.matchReasonsDetailed,
|
||||||
|
];
|
||||||
|
final warningCount = _countWarningReasons(detailedReasons);
|
||||||
|
final reasonExpanded = _expandedReasonRows[index] == true;
|
||||||
|
|
||||||
return CheckboxListTile(
|
return CheckboxListTile(
|
||||||
value: _selected[index] ?? false,
|
value: _selected[index] ?? false,
|
||||||
@@ -720,6 +780,100 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
|
|||||||
if (sanitizedOfferText.isNotEmpty) Text(sanitizedOfferText),
|
if (sanitizedOfferText.isNotEmpty) Text(sanitizedOfferText),
|
||||||
if (item.matchedProductName != null)
|
if (item.matchedProductName != null)
|
||||||
Text('Match: ${item.matchedProductName}'),
|
Text('Match: ${item.matchedProductName}'),
|
||||||
|
if (detailedReasons.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
InkWell(
|
||||||
|
onTap: () => setState(
|
||||||
|
() => _expandedReasonRows[index] = !reasonExpanded,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 8, vertical: 3),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: warningCount > 0
|
||||||
|
? Colors.orange.shade50
|
||||||
|
: Colors.blue.shade50,
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
border: Border.all(
|
||||||
|
color: warningCount > 0
|
||||||
|
? Colors.orange.shade200
|
||||||
|
: Colors.blue.shade200,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
warningCount > 0
|
||||||
|
? '$warningCount varningar'
|
||||||
|
: '${detailedReasons.length} info',
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
color: warningCount > 0
|
||||||
|
? Colors.orange.shade900
|
||||||
|
: Colors.blue.shade900,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Icon(
|
||||||
|
reasonExpanded
|
||||||
|
? Icons.expand_less
|
||||||
|
: Icons.expand_more,
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (reasonExpanded)
|
||||||
|
...detailedReasons.map(
|
||||||
|
(reason) => Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 6),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding:
|
||||||
|
const EdgeInsets.only(top: 2, right: 6),
|
||||||
|
child: Icon(
|
||||||
|
_reasonSeverityIcon(reason.severity),
|
||||||
|
size: 16,
|
||||||
|
color: _reasonSeverityColor(
|
||||||
|
reason.severity,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment:
|
||||||
|
CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
reason.title,
|
||||||
|
style: theme.textTheme.bodySmall
|
||||||
|
?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
reason.message,
|
||||||
|
style: theme.textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
if ((reason.location ?? '').isNotEmpty)
|
||||||
|
Text(
|
||||||
|
reason.location!,
|
||||||
|
style: theme.textTheme.bodySmall
|
||||||
|
?.copyWith(
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
controlAffinity: ListTileControlAffinity.leading,
|
controlAffinity: ListTileControlAffinity.leading,
|
||||||
|
|||||||
@@ -86,7 +86,28 @@ void main() {
|
|||||||
durationMs: 1880,
|
durationMs: 1880,
|
||||||
retryCount: 1,
|
retryCount: 1,
|
||||||
chunkCount: 3,
|
chunkCount: 3,
|
||||||
warnings: const ['parse:low_confidence', 'match:no_match'],
|
warnings: const [
|
||||||
|
AdminAiWarning(
|
||||||
|
code: 'low_confidence',
|
||||||
|
kind: 'parse',
|
||||||
|
title: 'Låg parsningskvalitet',
|
||||||
|
message: 'Modellens säkerhet är låg, granska raden manuellt.',
|
||||||
|
severity: 'warning',
|
||||||
|
location: 'Steg: AI-parser',
|
||||||
|
itemIndex: 5,
|
||||||
|
),
|
||||||
|
AdminAiWarning(
|
||||||
|
code: 'no_match',
|
||||||
|
kind: 'match',
|
||||||
|
title: 'Ingen produktmatchning',
|
||||||
|
message:
|
||||||
|
'Vi kunde inte hitta någon befintlig produkt som matchar texten på flyern.',
|
||||||
|
severity: 'warning',
|
||||||
|
location: 'Steg: matchning mot dina produkter',
|
||||||
|
itemIndex: 7,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
legacyWarnings: const ['parse:low_confidence', 'match:no_match'],
|
||||||
error: null,
|
error: null,
|
||||||
prompt: 'Prompttext exempel',
|
prompt: 'Prompttext exempel',
|
||||||
rawOutput: veryLargeOutput,
|
rawOutput: veryLargeOutput,
|
||||||
@@ -198,8 +219,8 @@ void main() {
|
|||||||
|
|
||||||
expect(find.text('Sammanfattning'), findsOneWidget);
|
expect(find.text('Sammanfattning'), findsOneWidget);
|
||||||
expect(find.text('Varningar (2)'), findsOneWidget);
|
expect(find.text('Varningar (2)'), findsOneWidget);
|
||||||
expect(find.text('parse:low_confidence'), findsOneWidget);
|
expect(find.text('Låg parsningskvalitet'), findsOneWidget);
|
||||||
expect(find.text('match:no_match'), findsOneWidget);
|
expect(find.text('Ingen produktmatchning'), findsOneWidget);
|
||||||
final detailScroll = find.byType(Scrollable).last;
|
final detailScroll = find.byType(Scrollable).last;
|
||||||
await tester.scrollUntilVisible(
|
await tester.scrollUntilVisible(
|
||||||
find.text('Model Output'),
|
find.text('Model Output'),
|
||||||
@@ -219,9 +240,11 @@ void main() {
|
|||||||
final copyPrompt = find.byTooltip('Kopiera');
|
final copyPrompt = find.byTooltip('Kopiera');
|
||||||
final copyOutput = find.byTooltip('Kopiera JSON');
|
final copyOutput = find.byTooltip('Kopiera JSON');
|
||||||
final copyWarnings = find.byTooltip('Kopiera alla varningar');
|
final copyWarnings = find.byTooltip('Kopiera alla varningar');
|
||||||
|
final copyErrorReport = find.text('Kopiera felrapport');
|
||||||
expect(copyPrompt, findsOneWidget);
|
expect(copyPrompt, findsOneWidget);
|
||||||
expect(copyOutput, findsOneWidget);
|
expect(copyOutput, findsOneWidget);
|
||||||
expect(copyWarnings, findsOneWidget);
|
expect(copyWarnings, findsOneWidget);
|
||||||
|
expect(copyErrorReport, findsOneWidget);
|
||||||
|
|
||||||
await tester.tap(copyPrompt);
|
await tester.tap(copyPrompt);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
@@ -235,6 +258,10 @@ void main() {
|
|||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(tester.takeException(), isNull);
|
expect(tester.takeException(), isNull);
|
||||||
|
|
||||||
|
await tester.tap(copyErrorReport);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
|
||||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user