d9f992ca9a
- 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
21 KiB
21 KiB
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:
- Säkerställ att svenska tecken (ä, å, ö, é) bevaras i produktnamn som "Prästost", "Herrgårdsost", "Grevéost".
- Ersätt opaka koder som
parse:ai_parsedochmatch:no_matchmed människovänliga svenska förklaringar som beskriver "vad" som hände och "var" det hände. - 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-293skickar 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]menrawNameskickas vidare som det är, så ä/å bevaras tekniskt om AI returnerar dem.flyer-normalizer.service.ts:26-30har en hårdkodad mappning för cheese variants:prast: 'Prästost'herrgard: 'Herrgårdsost'greve: 'Greveost'(saknaré! Bör varaGrevéost)
flyer-normalizer.service.ts:196-227(expandCheeseVariants) görstripDiacriticspå 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:ellermatch:avai-trace.service.ts:435-452(collectWarnings):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.warningsinnehå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 barawarnings-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'tillgreve: 'Grevéost'. - Behåll
prast: 'Prästost'ochherrgard: 'Herrgårdsost'.
- Ändra
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 attrawNameinte normaliseras eller stripps innannormalize-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/grevesom tokens i instruktion 9 men kräv normalisering till diakrit-versionen i utdatan.
- Lägg till explicit instruktion:
backend/src/flyer-import/services/flyer-normalizer.service.ts- I
expandCheeseVariants(rad 196-227): efterstripDiacritics-tokenisering, mappa viaCHEESE_VARIANT_TO_NAMEså 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 omcategoryantyder hårdost/ost ellerrawNameslutar påost.
- I
A3. Säkra Caddy/HTTP-respons för UTF-8
- Verifiera att
Content-Type: application/json; charset=utf-8returneras 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/Caddyfileinte har någonreplace-direktiv (det har det inte i nuläget).
A4. Test
- Utöka
flyer-normalizer.service.spec.tsmed:- 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.tsså 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(ellerbackend/src/ai/reason-codes.tsom 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
locationnä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: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[]ochmatchReasons: string[]för bakåtkompatibilitet.
- I
flyer-import.service.ts:110-144(därFlyerImportItembyggs): mappa viadescribeParseReason/describeMatchReason. - Detsamma för session-läs-paths (
toFlyerImportItemrad 721-799 ochtoFlyerImportResponseFromSession).
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: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.warningsschema till strukturerat format. - Bibehåll en
legacyWarnings: string[]med gamla formatet ifall någon klient ännu inte uppdaterats.
- Ändra till att returnera strukturerade objekt istället för
B4. Uppdatera Flutter-modeller och vyer
flutter/lib/features/import/domain/flyer_import_item.dart:- Lägg till
parseReasonsDetailed: List<FlyerReasonDescriptor>ochmatchReasonsDetailed: List<FlyerReasonDescriptor>medfromJson/toJson.
- Lägg till
- 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).
- I
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/describeMatchReasonsom täcker alla codes. - Backend: integrationstest som verifierar att
FlyerImportResponseinnehållerparseReasonsDetailedmed rätt fält. - Flutter: widget-test för warnings-panel som verifierar att
parse:ai_parsedALDRIG visas, utan ersätts av "AI-tolkad rad". - Uppdatera
flutter/test/features/admin/presentation/admin_ai_panel_test.dartsom idag verifierarfind.text('parse:low_confidence')- testet ska istället leta efter "Låg parsningskvalitet".
Acceptanskriterier Fas B
- Inga
parse:xxx- ellermatch: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-tracesom returnerar:{ 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 attuserIdägar sessionen (samma policy somgetSessionSource). - Eventuellt feature-flagga visa-prompt-för-användare (
FLYER_AI_USER_PROMPT_VISIBLEenv). Default:trueför admin,trueför användare som äger sessionen.
C3. Flutter-ändringar
flutter/lib/features/import/data/import_repository.dart: ny metodgetFlyerSessionAiTrace(sessionId, token).flutter/lib/features/import/domain/: ny modellFlyerAiTrace.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+_OutputJsonCardkan extraheras till delad widget underflutter/lib/features/import/presentation/widgets/ai_trace_view.dart).
- Lägg till samma åtgärd för
receipt_import_tab.dartom 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 iai-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
- Fas A (specialtecken) - lågrisk, snabb seger för UX.
- Fas B (mänskliga felmeddelanden) - medelarbete, hög UX-impact.
- 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/Dockerfilekörnpm 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
- 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.
- Reason-codes översätts i backend: backend returnerar färdig svensk text (
title,message) iFlyerReasonDescriptor. Frontend renderar bara strängarna utan översättning. Lang-parameter förbereds för framtida flerspråksstöd. - 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: ... - Stavning:
Grevéostmedé(Arlas officiella stavning).
Konsekvenser av besluten på faserna
Fas A (oförändrad)
Grevéostanvä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
FlyerAiTraceoch repository-metod för användarvy. - Tas bort: Ny widget
ai_trace_view.dartfö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).