Files
recipe-app/.kilo/plans/1779551030351-witty-engine.md
Nils-Johan Gynther d9f992ca9a
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
feat(ai): enhance AI trace warnings and reason codes system
- Added structured warning system with `AdminAiWarning` type in backend and Flutter
- Implemented detailed reason descriptors with `FlyerReasonDescriptor` for parse and match operations
- Added `legacyWarnings` field to maintain backward compatibility
- Enhanced AI trace service to collect and format warnings with item-level context
- Updated flyer import services to include detailed reason descriptions in responses
- Added Swedish diacritic preservation for cheese variants (Prästost, Herrgårdsost, Grevéost)
- Implemented UTF-8 content validation for AI responses
- Added new reason code definitions in `reason-codes.ts`
- Updated Flutter UI to display structured warnings with severity indicators
- Added error report generation and copy functionality in admin panel
- Added comprehensive test coverage for new warning system and cheese normalization

BREAKING CHANGE: AI trace warnings are now structured objects instead of simple strings
2026-05-23 21:11:46 +02:00

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:

  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):
    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:
    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:
      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:
    {
      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).