feat(ai): enhance AI trace warnings and reason codes system
Test Suite / backend-pr-quick (push) Has been skipped
Test Suite / quick-import-pr-quick (push) Has been skipped
Test Suite / backend-full (push) Successful in 4m21s
Test Suite / flutter-quality (push) Failing after 1m38s

- 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:
Nils-Johan Gynther
2026-05-23 21:11:46 +02:00
parent 0fb507f247
commit d9f992ca9a
18 changed files with 1308 additions and 81 deletions
+346
View File
@@ -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).
+66 -1
View File
@@ -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']),
);
}); });
}); });
+75 -14
View File
@@ -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,5 +1,14 @@
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;
rawName: string; rawName: string;
@@ -20,11 +29,13 @@ export type FlyerImportItem = {
offerLimitText: string | null; offerLimitText: string | null;
parseConfidence: number; parseConfidence: number;
parseReasons: string[]; parseReasons: string[];
parseReasonsDetailed: FlyerReasonDescriptor[];
matchedProductId: number | null; matchedProductId: number | null;
matchedProductName: string | null; matchedProductName: string | null;
matchedVia: FlyerImportMatchVia; matchedVia: FlyerImportMatchVia;
matchConfidence: number; matchConfidence: number;
matchReasons: string[]; matchReasons: string[];
matchReasonsDetailed: FlyerReasonDescriptor[];
}; };
export type FlyerImportResponse = { export type FlyerImportResponse = {
@@ -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);
}); });
@@ -17,6 +17,7 @@ import {
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,11 +136,13 @@ export class FlyerImportService {
offerLimitText, offerLimitText,
parseConfidence: item.confidence, parseConfidence: item.confidence,
parseReasons: item.reasonCodes, parseReasons: item.reasonCodes,
parseReasonsDetailed: this.describeParseReasons(item.reasonCodes),
matchedProductId: match.product?.id ?? null, matchedProductId: match.product?.id ?? null,
matchedProductName: match.product?.name ?? null, matchedProductName: match.product?.name ?? null,
matchedVia: match.via, matchedVia: match.via,
matchConfidence: match.confidence, matchConfidence: match.confidence,
matchReasons: match.reasons, matchReasons: match.reasons,
matchReasonsDetailed: this.describeMatchReasons(match.reasons),
}; };
}); });
@@ -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,3 +1,5 @@
import 'flyer_reason_descriptor.dart';
class FlyerImportItem { class FlyerImportItem {
final int? flyerItemId; final int? flyerItemId;
final String rawName; final String rawName;
@@ -13,10 +15,13 @@ class FlyerImportItem {
final String? comparisonUnit; final String? comparisonUnit;
final double? parseConfidence; final double? parseConfidence;
final List<String> parseReasons; final List<String> parseReasons;
final List<FlyerReasonDescriptor> parseReasonsDetailed;
final int? matchedProductId; final int? matchedProductId;
final String? matchedProductName; final String? matchedProductName;
final String? matchedVia; final String? matchedVia;
final double? matchConfidence; final double? matchConfidence;
final List<String> matchReasons;
final List<FlyerReasonDescriptor> matchReasonsDetailed;
FlyerImportItem({ FlyerImportItem({
required this.flyerItemId, required this.flyerItemId,
@@ -33,10 +38,13 @@ class FlyerImportItem {
this.comparisonUnit, this.comparisonUnit,
this.parseConfidence, this.parseConfidence,
this.parseReasons = const [], this.parseReasons = const [],
this.parseReasonsDetailed = const [],
this.matchedProductId, this.matchedProductId,
this.matchedProductName, this.matchedProductName,
this.matchedVia, this.matchedVia,
this.matchConfidence, this.matchConfidence,
this.matchReasons = const [],
this.matchReasonsDetailed = const [],
}); });
factory FlyerImportItem.fromJson(Map<String, dynamic> json) { factory FlyerImportItem.fromJson(Map<String, dynamic> json) {
@@ -55,10 +63,23 @@ class FlyerImportItem {
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 [],
parseReasonsDetailed:
(json['parseReasonsDetailed'] as List?)
?.whereType<Map>()
.map((e) => FlyerReasonDescriptor.fromJson(Map<String, dynamic>.from(e)))
.toList() ??
const [],
matchedProductId: (json['matchedProductId'] as num?)?.toInt(), matchedProductId: (json['matchedProductId'] as num?)?.toInt(),
matchedProductName: json['matchedProductName'] as String?, matchedProductName: json['matchedProductName'] as String?,
matchedVia: json['matchedVia'] as String?, matchedVia: json['matchedVia'] as String?,
matchConfidence: (json['matchConfidence'] as num?)?.toDouble(), 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));
}); });
}); });