diff --git a/.kilo/plans/1779551030351-witty-engine.md b/.kilo/plans/1779551030351-witty-engine.md new file mode 100644 index 00000000..a6821dfb --- /dev/null +++ b/.kilo/plans/1779551030351-witty-engine.md @@ -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` och `matchReasonsDetailed: List` 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). diff --git a/backend/src/ai/ai-trace.service.spec.ts b/backend/src/ai/ai-trace.service.spec.ts index 82adf70a..12cc03f5 100644 --- a/backend/src/ai/ai-trace.service.spec.ts +++ b/backend/src/ai/ai-trace.service.spec.ts @@ -143,6 +143,71 @@ describe('AiTraceService receipt masking', () => { expect(result.rawOutput).toContain('{"ok":true}'); expect(result.retryCount).toBe(2); 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']), + ); }); }); diff --git a/backend/src/ai/ai-trace.service.ts b/backend/src/ai/ai-trace.service.ts index a8a177c2..ad9cd9ee 100644 --- a/backend/src/ai/ai-trace.service.ts +++ b/backend/src/ai/ai-trace.service.ts @@ -1,5 +1,10 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; +import { + describeMatchReason, + describeParseReason, + FlyerReasonDescriptor, +} from '../flyer-import/services/reason-codes'; export type AiTraceSource = 'receipt' | 'flyer'; @@ -45,7 +50,8 @@ export type AiTraceDetail = { durationMs: number | null; retryCount: number | null; chunkCount: number | null; - warnings: string[]; + warnings: AdminAiWarning[]; + legacyWarnings: string[]; error: string | null; prompt: string | null; rawOutput: string | null; @@ -53,6 +59,10 @@ export type AiTraceDetail = { summary: Record; }; +export type AdminAiWarning = FlyerReasonDescriptor & { + itemIndex?: number; +}; + type FlyerTraceSupplement = { prompt: string | null; rawOutput: string | null; @@ -107,11 +117,14 @@ export class AiTraceService { const page = hasMore ? sessions.slice(0, take) : sessions; const items: AiTraceListItem[] = page.map((session) => { - const warningsCount = session.items.reduce((sum, item) => { - const parseWarnings = Array.isArray(item.parseReasons) ? item.parseReasons.length : 0; - const matchWarnings = Array.isArray(item.matchReasons) ? item.matchReasons.length : 0; - return sum + parseWarnings + matchWarnings; - }, 0); + const warningSet = this.collectWarnings( + session.items.map((item, itemIndex) => ({ + parseReasons: item.parseReasons, + matchReasons: item.matchReasons, + itemIndex, + })), + ); + const warningsCount = this.countActionableWarnings(warningSet.warnings); const status = this.statusFromSession(session.items.length, warningsCount); return { id: this.flyerTraceId(session.id), @@ -205,8 +218,18 @@ export class AiTraceService { throw new NotFoundException('AI-trace hittades inte.'); } - const warnings = this.collectWarnings(session.items); - const status = this.statusFromSession(session.items.length, warnings.length); + const warningSet = this.collectWarnings( + 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 normalizedOutput = { @@ -241,6 +264,7 @@ export class AiTraceService { matchReasons: Array.isArray(item.matchReasons) ? item.matchReasons : [], })), warnings, + legacyWarnings: warningSet.legacyWarnings, } as Record; return { @@ -257,6 +281,7 @@ export class AiTraceService { retryCount: supplement.retryCount, chunkCount: supplement.chunkCount, warnings, + legacyWarnings: warningSet.legacyWarnings, error: session.items.length === 0 ? 'Inga produkter kunde extraheras från flyern.' : null, prompt: supplement.prompt, rawOutput: @@ -266,7 +291,7 @@ export class AiTraceService { source: 'flyer', sessionId: session.id, itemCount: session.items.length, - warningsCount: warnings.length, + warningsCount: this.countActionableWarnings(warnings), promptAvailable: !!supplement.prompt, outputAvailable: true, retentionHintDays: 30, @@ -432,23 +457,58 @@ export class AiTraceService { return `user:${userId}`; } - private collectWarnings(items: Array<{ parseReasons: unknown; matchReasons: unknown }>): string[] { - const warnings = new Set(); + private collectWarnings(items: Array<{ parseReasons: unknown; matchReasons: unknown; itemIndex?: number }>): { + warnings: AdminAiWarning[]; + legacyWarnings: string[]; + } { + const warnings: AdminAiWarning[] = []; + const legacyWarnings = new Set(); + const dedupe = new Set(); + for (const item of items) { + const itemIndex = item.itemIndex != null ? item.itemIndex + 1 : undefined; + if (Array.isArray(item.parseReasons)) { for (const reason of item.parseReasons) { const text = String(reason ?? '').trim(); - if (text.length > 0) warnings.add(`parse:${text}`); + if (!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)) { for (const reason of item.matchReasons) { 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: { @@ -553,6 +613,7 @@ export class AiTraceService { retryCount: null, chunkCount: null, warnings: [], + legacyWarnings: [], error: row.error, prompt: row.prompt ? this.maskSensitiveText(row.prompt) : null, rawOutput: this.maskRawOutput(row.rawOutput), diff --git a/backend/src/flyer-import/dto/flyer-import.response.ts b/backend/src/flyer-import/dto/flyer-import.response.ts index 93bd995b..25cf1046 100644 --- a/backend/src/flyer-import/dto/flyer-import.response.ts +++ b/backend/src/flyer-import/dto/flyer-import.response.ts @@ -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 = { flyerItemId: number | null; @@ -18,14 +27,16 @@ export type FlyerImportItem = { offerText: string | null; isOffer: boolean; offerLimitText: string | null; - parseConfidence: number; - parseReasons: string[]; - matchedProductId: number | null; - matchedProductName: string | null; - matchedVia: FlyerImportMatchVia; - matchConfidence: number; - matchReasons: string[]; -}; + parseConfidence: number; + parseReasons: string[]; + parseReasonsDetailed: FlyerReasonDescriptor[]; + matchedProductId: number | null; + matchedProductName: string | null; + matchedVia: FlyerImportMatchVia; + matchConfidence: number; + matchReasons: string[]; + matchReasonsDetailed: FlyerReasonDescriptor[]; +}; export type FlyerImportResponse = { sessionId: number | null; diff --git a/backend/src/flyer-import/flyer-import.service.spec.ts b/backend/src/flyer-import/flyer-import.service.spec.ts index e5b7db00..09c3c7a0 100644 --- a/backend/src/flyer-import/flyer-import.service.spec.ts +++ b/backend/src/flyer-import/flyer-import.service.spec.ts @@ -97,6 +97,8 @@ describe('FlyerImportService', () => { expect(result.items).toHaveLength(1); expect(result.items[0].flyerItemId).toBe(99); 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); }); diff --git a/backend/src/flyer-import/flyer-import.service.ts b/backend/src/flyer-import/flyer-import.service.ts index 27e51941..d660cfe1 100644 --- a/backend/src/flyer-import/flyer-import.service.ts +++ b/backend/src/flyer-import/flyer-import.service.ts @@ -9,14 +9,15 @@ import { import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { normalizeName } from '../common/utils/normalize-name'; -import { - FlyerImportItem, - FlyerImportMatchVia, - FlyerImportResponse, -} from './dto/flyer-import.response'; +import { + FlyerImportItem, + FlyerImportMatchVia, + FlyerImportResponse, +} from './dto/flyer-import.response'; import { TextExtractorService } from './services/text-extractor.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 = { rawName: string; @@ -135,13 +136,15 @@ export class FlyerImportService { offerLimitText, parseConfidence: item.confidence, parseReasons: item.reasonCodes, - matchedProductId: match.product?.id ?? null, - matchedProductName: match.product?.name ?? null, - matchedVia: match.via, - matchConfidence: match.confidence, - matchReasons: match.reasons, - }; - }); + parseReasonsDetailed: this.describeParseReasons(item.reasonCodes), + matchedProductId: match.product?.id ?? null, + matchedProductName: match.product?.name ?? null, + matchedVia: match.via, + matchConfidence: match.confidence, + matchReasons: match.reasons, + matchReasonsDetailed: this.describeMatchReasons(match.reasons), + }; + }); const persistedItems = await this.persistSessionWithItems(userId, parsed.retailer, items, file); @@ -790,14 +793,24 @@ export class FlyerImportService { offerLimitText, parseConfidence: item.parseConfidence, parseReasons: toStringArray(item.parseReasons), + parseReasonsDetailed: this.describeParseReasons(toStringArray(item.parseReasons)), matchedProductId: item.matchedProductId, matchedProductName: item.matchedProductName, matchedVia: normalizedMatchVia, matchConfidence: item.matchConfidence ?? 0, 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?: { name: string; parent?: { diff --git a/backend/src/flyer-import/services/ai-flyer-parser.service.spec.ts b/backend/src/flyer-import/services/ai-flyer-parser.service.spec.ts index 4cd004dc..85dff8dc 100644 --- a/backend/src/flyer-import/services/ai-flyer-parser.service.spec.ts +++ b/backend/src/flyer-import/services/ai-flyer-parser.service.spec.ts @@ -4,6 +4,15 @@ import { AiFlyerParserService } from './ai-flyer-parser.service'; describe('AiFlyerParserService dedupe', () => { 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', () => { const items = [ { diff --git a/backend/src/flyer-import/services/ai-flyer-parser.service.ts b/backend/src/flyer-import/services/ai-flyer-parser.service.ts index 35e9af02..bf287c98 100644 --- a/backend/src/flyer-import/services/ai-flyer-parser.service.ts +++ b/backend/src/flyer-import/services/ai-flyer-parser.service.ts @@ -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. 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. -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". 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: [ @@ -258,7 +259,7 @@ Input-idé: "PRAST, HERRGARD, GREVE" + "ARLA KO" + gemensam vikt/pris. Output-idé: [ { - "name": "Prastost", + "name": "Prästost", "brand": "Arla Ko", "category": "Hardost", "isBundle": false, @@ -271,7 +272,7 @@ Output-idé: "offer": ["Max 3 forp/hushall"] }, { - "name": "Herrgardsost", + "name": "Herrgårdsost", "brand": "Arla Ko", "category": "Hardost", "isBundle": false, @@ -358,7 +359,7 @@ ${truncatedText}`; private normalizeName(name: string): string { return name .toLowerCase() - .replace(/[^a-zåäö0-9\s]/g, '') + .replace(/[^a-zåäöé0-9\s]/g, '') .replace(/\s+/g, ' ') .trim(); } @@ -427,7 +428,7 @@ ${truncatedText}`; 'Mistral-anrop timeout', ); - const content = response.choices?.[0]?.message?.content; + const content = this.ensureUtf8Content(response.choices?.[0]?.message?.content); if (!content) { throw new BadRequestException('Tomt svar från AI-modellen.'); } @@ -531,6 +532,40 @@ ${truncatedText}`; 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 { const raw = process.env[key]; if (!raw) return fallback; diff --git a/backend/src/flyer-import/services/flyer-normalizer.service.spec.ts b/backend/src/flyer-import/services/flyer-normalizer.service.spec.ts index b0c3e844..2af4470b 100644 --- a/backend/src/flyer-import/services/flyer-normalizer.service.spec.ts +++ b/backend/src/flyer-import/services/flyer-normalizer.service.spec.ts @@ -120,12 +120,28 @@ describe('FlyerNormalizerService', () => { const result = service.normalize(items); 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.categoryHint === 'Hårdost')).toBe(true); 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', () => { const items = [ { @@ -184,5 +200,20 @@ describe('FlyerNormalizerService', () => { expect(result).toHaveLength(1); 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'); + }); }); }); diff --git a/backend/src/flyer-import/services/flyer-normalizer.service.ts b/backend/src/flyer-import/services/flyer-normalizer.service.ts index 3104dc85..f6a25b44 100644 --- a/backend/src/flyer-import/services/flyer-normalizer.service.ts +++ b/backend/src/flyer-import/services/flyer-normalizer.service.ts @@ -26,7 +26,7 @@ export class FlyerNormalizerService { private readonly CHEESE_VARIANT_TO_NAME: Record = { prast: 'Prästost', herrgard: 'Herrgårdsost', - greve: 'Greveost', + greve: 'Grevéost', }; private readonly UNIT_MAPPING: Record = { @@ -140,7 +140,7 @@ export class FlyerNormalizerService { private normalizeName(name: string): string { return name .toLowerCase() - .replace(/[^a-zåäö0-9\s]/g, '') + .replace(/[^a-zåäöé0-9\s]/g, '') .replace(/\s+/g, ' ') .trim(); } @@ -256,6 +256,7 @@ export class FlyerNormalizerService { 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(/\bgreveost\b/gi, (match) => (match[0] === 'G' ? 'Grevéost' : 'grevéost')); } return corrected; diff --git a/backend/src/flyer-import/services/reason-codes.spec.ts b/backend/src/flyer-import/services/reason-codes.spec.ts new file mode 100644 index 00000000..8afd0bd6 --- /dev/null +++ b/backend/src/flyer-import/services/reason-codes.spec.ts @@ -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', + }); + }); +}); diff --git a/backend/src/flyer-import/services/reason-codes.ts b/backend/src/flyer-import/services/reason-codes.ts new file mode 100644 index 00000000..57e0edbc --- /dev/null +++ b/backend/src/flyer-import/services/reason-codes.ts @@ -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)); +} diff --git a/flutter/lib/features/admin/domain/admin_ai_trace_detail.dart b/flutter/lib/features/admin/domain/admin_ai_trace_detail.dart index f1c2d148..8b136599 100644 --- a/flutter/lib/features/admin/domain/admin_ai_trace_detail.dart +++ b/flutter/lib/features/admin/domain/admin_ai_trace_detail.dart @@ -1,5 +1,53 @@ 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 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 { final String id; final AdminAiTraceSource source; @@ -13,7 +61,8 @@ class AdminAiTraceDetail { final int? durationMs; final int? retryCount; final int? chunkCount; - final List warnings; + final List warnings; + final List legacyWarnings; final String? error; final String? prompt; final String? rawOutput; @@ -34,6 +83,7 @@ class AdminAiTraceDetail { required this.retryCount, required this.chunkCount, required this.warnings, + required this.legacyWarnings, required this.error, required this.prompt, required this.rawOutput, @@ -43,6 +93,7 @@ class AdminAiTraceDetail { factory AdminAiTraceDetail.fromJson(Map json) { final warningsRaw = (json['warnings'] as List?) ?? const []; + final legacyWarningsRaw = (json['legacyWarnings'] as List?) ?? const []; final normalizedOutputMap = json['normalizedOutput'] is Map ? Map.from(json['normalizedOutput'] as Map) : null; @@ -64,7 +115,15 @@ class AdminAiTraceDetail { durationMs: (json['durationMs'] as num?)?.toInt(), retryCount: (json['retryCount'] as num?)?.toInt(), chunkCount: (json['chunkCount'] as num?)?.toInt(), - warnings: warningsRaw.map((entry) => entry.toString()).toList(), + warnings: warningsRaw + .map((entry) { + if (entry is Map) { + return AdminAiWarning.fromJson(Map.from(entry)); + } + return AdminAiWarning.fromLegacy(entry.toString()); + }) + .toList(), + legacyWarnings: legacyWarningsRaw.map((entry) => entry.toString()).toList(), error: json['error']?.toString(), prompt: json['prompt']?.toString(), rawOutput: json['rawOutput']?.toString(), diff --git a/flutter/lib/features/admin/presentation/admin_ai_panel.dart b/flutter/lib/features/admin/presentation/admin_ai_panel.dart index ee36105c..d9ea6382 100644 --- a/flutter/lib/features/admin/presentation/admin_ai_panel.dart +++ b/flutter/lib/features/admin/presentation/admin_ai_panel.dart @@ -137,6 +137,41 @@ class _AdminAiPanelState extends ConsumerState { 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) { return switch (status) { AdminAiTraceStatus.success => Colors.green.shade700, @@ -348,16 +383,30 @@ class _AdminAiPanelState extends ConsumerState { ? const {} : {'rawOutput': detail.rawOutput}); final prettyOutput = _prettyOutputFor(detail.id, outputJson); + final errorReport = _buildErrorReport(detail: detail, prettyOutput: prettyOutput); return ListView( children: [ _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) ...[ const SizedBox(height: 12), _WarningsCard( warnings: detail.warnings, - onCopyWarning: (warning) => _copyText(warning, 'Varning'), - onCopyAll: () => _copyText(detail.warnings.join('\n'), 'Varningar'), + onCopyWarning: (warning) => + _copyText(_formatWarningLine(warning), 'Varning'), + onCopyAll: () => _copyText( + detail.warnings.map(_formatWarningLine).join('\n'), + 'Varningar', + ), ), ], const SizedBox(height: 12), @@ -583,8 +632,8 @@ class _OutputJsonCardState extends State<_OutputJsonCard> { } class _WarningsCard extends StatelessWidget { - final List warnings; - final void Function(String warning) onCopyWarning; + final List warnings; + final void Function(AdminAiWarning warning) onCopyWarning; final VoidCallback onCopyAll; const _WarningsCard({ @@ -622,8 +671,27 @@ class _WarningsCard extends StatelessWidget { (warning) => ListTile( dense: true, contentPadding: EdgeInsets.zero, - leading: const Icon(Icons.warning_amber_rounded, size: 18), - title: SelectableText(warning), + leading: Icon(_severityIcon(warning), size: 18, color: _severityColor(warning, theme)), + 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( tooltip: 'Kopiera varning', 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; + } + } } diff --git a/flutter/lib/features/import/domain/flyer_import_item.dart b/flutter/lib/features/import/domain/flyer_import_item.dart index 72c4b602..79c45533 100644 --- a/flutter/lib/features/import/domain/flyer_import_item.dart +++ b/flutter/lib/features/import/domain/flyer_import_item.dart @@ -1,4 +1,6 @@ -class FlyerImportItem { +import 'flyer_reason_descriptor.dart'; + +class FlyerImportItem { final int? flyerItemId; final String rawName; final String normalizedName; @@ -12,11 +14,14 @@ class FlyerImportItem { final double? comparisonPrice; final String? comparisonUnit; final double? parseConfidence; - final List parseReasons; - final int? matchedProductId; - final String? matchedProductName; - final String? matchedVia; - final double? matchConfidence; + final List parseReasons; + final List parseReasonsDetailed; + final int? matchedProductId; + final String? matchedProductName; + final String? matchedVia; + final double? matchConfidence; + final List matchReasons; + final List matchReasonsDetailed; FlyerImportItem({ required this.flyerItemId, @@ -31,13 +36,16 @@ class FlyerImportItem { this.offerLimitText, this.comparisonPrice, this.comparisonUnit, - this.parseConfidence, - this.parseReasons = const [], - this.matchedProductId, - this.matchedProductName, - this.matchedVia, - this.matchConfidence, - }); + this.parseConfidence, + this.parseReasons = const [], + this.parseReasonsDetailed = const [], + this.matchedProductId, + this.matchedProductName, + this.matchedVia, + this.matchConfidence, + this.matchReasons = const [], + this.matchReasonsDetailed = const [], + }); factory FlyerImportItem.fromJson(Map json) { return FlyerImportItem( @@ -53,12 +61,25 @@ class FlyerImportItem { offerLimitText: json['offerLimitText'] as String?, comparisonPrice: (json['comparisonPrice'] as num?)?.toDouble(), comparisonUnit: json['comparisonUnit'] as String?, - parseConfidence: (json['parseConfidence'] as num?)?.toDouble(), - parseReasons: (json['parseReasons'] as List?)?.map((e) => e.toString()).toList() ?? const [], - matchedProductId: (json['matchedProductId'] as num?)?.toInt(), - matchedProductName: json['matchedProductName'] as String?, - matchedVia: json['matchedVia'] as String?, - matchConfidence: (json['matchConfidence'] as num?)?.toDouble(), + parseConfidence: (json['parseConfidence'] as num?)?.toDouble(), + parseReasons: (json['parseReasons'] as List?)?.map((e) => e.toString()).toList() ?? const [], + parseReasonsDetailed: + (json['parseReasonsDetailed'] as List?) + ?.whereType() + .map((e) => FlyerReasonDescriptor.fromJson(Map.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((e) => FlyerReasonDescriptor.fromJson(Map.from(e))) + .toList() ?? + const [], ); } @@ -78,10 +99,15 @@ class FlyerImportItem { 'comparisonUnit': comparisonUnit, 'parseConfidence': parseConfidence, 'parseReasons': parseReasons, + 'parseReasonsDetailed': + parseReasonsDetailed.map((reason) => reason.toJson()).toList(), 'matchedProductId': matchedProductId, 'matchedProductName': matchedProductName, 'matchedVia': matchedVia, 'matchConfidence': matchConfidence, + 'matchReasons': matchReasons, + 'matchReasonsDetailed': + matchReasonsDetailed.map((reason) => reason.toJson()).toList(), }; } @@ -105,10 +131,13 @@ class FlyerImportItem { comparisonUnit: comparisonUnit, parseConfidence: parseConfidence, parseReasons: parseReasons, + parseReasonsDetailed: parseReasonsDetailed, matchedProductId: matchedProductId, matchedProductName: matchedProductName, matchedVia: matchedVia, matchConfidence: matchConfidence, + matchReasons: matchReasons, + matchReasonsDetailed: matchReasonsDetailed, ); } } diff --git a/flutter/lib/features/import/domain/flyer_reason_descriptor.dart b/flutter/lib/features/import/domain/flyer_reason_descriptor.dart new file mode 100644 index 00000000..df5dc505 --- /dev/null +++ b/flutter/lib/features/import/domain/flyer_reason_descriptor.dart @@ -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 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 toJson() { + return { + 'code': code, + 'kind': kind, + 'title': title, + 'message': message, + 'severity': severity, + 'location': location, + }; + } +} diff --git a/flutter/lib/features/import/presentation/flyer_import_tab.dart b/flutter/lib/features/import/presentation/flyer_import_tab.dart index 30339368..20e6efc6 100644 --- a/flutter/lib/features/import/presentation/flyer_import_tab.dart +++ b/flutter/lib/features/import/presentation/flyer_import_tab.dart @@ -1,6 +1,6 @@ import 'package:file_picker/file_picker.dart'; -import 'dart:typed_data'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.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/import_providers.dart'; import '../domain/flyer_import_item.dart'; +import '../domain/flyer_reason_descriptor.dart'; import '../domain/flyer_import_result.dart'; import 'error_dialog.dart'; @@ -32,6 +33,7 @@ class _FlyerImportTabState extends ConsumerState { List _categoryTree = const []; FlyerImportResult? _result; final Map _selected = {}; + final Map _expandedReasonRows = {}; @override void initState() { @@ -90,6 +92,7 @@ class _FlyerImportTabState extends ConsumerState { _selected ..clear() ..addAll(selected); + _expandedReasonRows.clear(); }); await _loadRestoredSourceIfNeeded(serverResult, token); notifier.setImportedResult( @@ -117,6 +120,7 @@ class _FlyerImportTabState extends ConsumerState { _selected ..clear() ..addAll(selected); + _expandedReasonRows.clear(); }); await _loadRestoredSourceIfNeeded(latest, token); notifier.setImportedResult( @@ -130,6 +134,7 @@ class _FlyerImportTabState extends ConsumerState { _selected ..clear() ..addAll(session.selected); + _expandedReasonRows.clear(); }); } } catch (_) { @@ -141,6 +146,7 @@ class _FlyerImportTabState extends ConsumerState { _selected ..clear() ..addAll(session.selected); + _expandedReasonRows.clear(); }); } } @@ -196,6 +202,7 @@ class _FlyerImportTabState extends ConsumerState { _selected ..clear() ..addAll(selected); + _expandedReasonRows.clear(); }); ref.read(flyerImportSessionProvider.notifier).setImportedResult( result: parsed, @@ -494,6 +501,39 @@ class _FlyerImportTabState extends ConsumerState { ); } + Future _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 reasons) { + return reasons.where((reason) => reason.severity == 'warning' || reason.severity == 'error').length; + } + Widget _buildWarningsPanel(ThemeData theme) { final warnings = _result?.warnings ?? const []; if (warnings.isEmpty) return const SizedBox.shrink(); @@ -526,10 +566,24 @@ class _FlyerImportTabState extends ConsumerState { const SizedBox(height: 8), ...warnings.map((warning) => Padding( padding: const EdgeInsets.only(bottom: 4), - child: Text( - '• $warning', - style: theme.textTheme.bodySmall?.copyWith( - color: Colors.amber.shade900, + child: InkWell( + onTap: () => _copyText(warning, 'Varning'), + borderRadius: BorderRadius.circular(4), + 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 { final sanitizedOfferText = item.offerText == null ? '' : _removeLimitTextFromOfferText(item.offerText!, limitText); + final detailedReasons = [ + ...item.parseReasonsDetailed, + ...item.matchReasonsDetailed, + ]; + final warningCount = _countWarningReasons(detailedReasons); + final reasonExpanded = _expandedReasonRows[index] == true; return CheckboxListTile( value: _selected[index] ?? false, @@ -720,6 +780,100 @@ class _FlyerImportTabState extends ConsumerState { if (sanitizedOfferText.isNotEmpty) Text(sanitizedOfferText), if (item.matchedProductName != null) 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, diff --git a/flutter/test/features/admin/presentation/admin_ai_panel_test.dart b/flutter/test/features/admin/presentation/admin_ai_panel_test.dart index 349f563e..cc5678a7 100644 --- a/flutter/test/features/admin/presentation/admin_ai_panel_test.dart +++ b/flutter/test/features/admin/presentation/admin_ai_panel_test.dart @@ -86,7 +86,28 @@ void main() { durationMs: 1880, retryCount: 1, 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, prompt: 'Prompttext exempel', rawOutput: veryLargeOutput, @@ -198,8 +219,8 @@ void main() { expect(find.text('Sammanfattning'), findsOneWidget); expect(find.text('Varningar (2)'), findsOneWidget); - expect(find.text('parse:low_confidence'), findsOneWidget); - expect(find.text('match:no_match'), findsOneWidget); + expect(find.text('Låg parsningskvalitet'), findsOneWidget); + expect(find.text('Ingen produktmatchning'), findsOneWidget); final detailScroll = find.byType(Scrollable).last; await tester.scrollUntilVisible( find.text('Model Output'), @@ -219,9 +240,11 @@ void main() { final copyPrompt = find.byTooltip('Kopiera'); final copyOutput = find.byTooltip('Kopiera JSON'); final copyWarnings = find.byTooltip('Kopiera alla varningar'); + final copyErrorReport = find.text('Kopiera felrapport'); expect(copyPrompt, findsOneWidget); expect(copyOutput, findsOneWidget); expect(copyWarnings, findsOneWidget); + expect(copyErrorReport, findsOneWidget); await tester.tap(copyPrompt); await tester.pumpAndSettle(); @@ -235,6 +258,10 @@ void main() { await tester.pumpAndSettle(); expect(tester.takeException(), isNull); + await tester.tap(copyErrorReport); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + addTearDown(() => tester.binding.setSurfaceSize(null)); }); });